Spaces:
Paused
Paused
Claude
Phase 2 (part 1): per-entrant archetype override (D3) and the competition ledger (D8)
925767d unverified Download elo.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 8.92 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/elo.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/elo.js
-
curl -L -o elo.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/elo.js
8.92 kB
| // @ts-check | |
| ; | |
| /* ============================================================ | |
| ELO β one rating implementation, shared by the in-game competition screen AND tools/ailab.js's | |
| duel/swiss CLI output (docs/competitions-and-elo.md D1: "if the bench and the game each grow | |
| their own rating math, a number the player sees in-game and a number a tuning session reports | |
| stop meaning the same thing"). Root-level, not under engine/ β a rating is not sim state, it's | |
| a fact about a series of matches the caller already resolved some other way (see D1). | |
| Pure rating math over data the CALLER owns: no clock, no storage, no DOM, no Math.random. A | |
| `ratings` table is a plain object keyed by entrant name β the caller decides what "entrant name" | |
| means for a given pool (D2: ratings are bracketed by difficulty and never blended, so a caller | |
| that wants "Aggressive @ Hard" and "Aggressive @ Medium" kept apart uses two separate `ratings` | |
| objects, one per bracket β this module has no notion of "bracket" itself). | |
| D7 is the whole formula set implemented here: plain Elo (E = 1/(1+10^((Rb-Ra)/400)), | |
| R' = R + K(S-E)), starting rating 1200, K = 40 while provisional (<10 games) then 20 then 10 | |
| above 2400, no margin-of-victory scaling (a match's score margin is a separate, non-rating | |
| field elsewhere β see tools/duelCore.js's row shape β never folded in here). | |
| ============================================================ */ | |
| /** | |
| * @typedef {{ rating: number, games: number }} RatingEntry | |
| * One entrant's rating and how many rated games it has played so far, in whichever pool the | |
| * caller's `ratings` object represents (D2: typically one (entrant, difficulty bracket) pair). | |
| */ | |
| /** @typedef {Object.<string, RatingEntry>} RatingsTable Plain object, keyed by entrant name. */ | |
| /** | |
| * @typedef {{ aName: string, bName: string, score: number }} MatchRow | |
| * One match's result for applySeries, `score` from A's point of view: 1 (A won), 0.5 (draw), | |
| * 0 (A lost) β the same three values applyResult's own `score` parameter takes. | |
| */ | |
| // Every new entrant starts here (D7). Exported so a caller never hardcodes the number twice. | |
| export const INITIAL_RATING = 1200; | |
| // Games played before an entrant "graduates" out of the wide provisional K (D7) β the same | |
| // threshold competitionLedger.js's standings read-back uses to flag a row `provisional`, so the | |
| // number lives in exactly one place (this file's own INITIAL_RATING precedent) rather than being | |
| // hardcoded a second time wherever "is this rating still noisy" needs asking. | |
| export const PROVISIONAL_GAMES = 10; | |
| /** | |
| * The standard Elo expectation: A's probability of "winning" this pairing (1 = certain win, | |
| * 0.5 = a coin flip, 0 = certain loss), purely a function of the ratings gap β D7's formula, | |
| * verbatim. Symmetric by construction: expectedScore(r, r) is always exactly 0.5, and | |
| * expectedScore(a, b) + expectedScore(b, a) is 1 to within floating-point noise (see | |
| * test/elo.test.js β two independently-rounded `10 ** x` evaluations aren't bit-exact | |
| * complements of one another, which is why applyResult below derives B's expectation as | |
| * `1 - expectedScore(a, b)` rather than by calling this a second time with the arguments | |
| * swapped). | |
| * @param {number} ratingA | |
| * @param {number} ratingB | |
| * @returns {number} A's expected score, in [0, 1]. | |
| */ | |
| export function expectedScore(ratingA, ratingB) { | |
| return 1 / (1 + 10 ** ((ratingB - ratingA) / 400)); | |
| } | |
| /** | |
| * How much one match can move a rating, decided by the PLAYER'S OWN games/rating going into that | |
| * match (D7). Provisional (fewer than 10 games played so far) always uses the wide K, even for a | |
| * seeded-high rating β a brand-new entrant is provisional however it started, so this check comes | |
| * before the rating-based tier. Only once there's a track record does the 2400 rating tier apply. | |
| * @param {number} games games this player had played BEFORE this match (i.e. going INTO it). | |
| * @param {number} rating this player's rating going INTO this match. | |
| * @returns {number} the K-factor: 40, 20, or 10. | |
| */ | |
| export function kFactor(games, rating) { | |
| if (games < PROVISIONAL_GAMES) return 40; | |
| return rating >= 2400 ? 10 : 20; | |
| } | |
| /** | |
| * Apply one match's result to `ratings`, MUTATING AND RETURNING it (this module never clones β | |
| * a caller that needs the pre-match table preserved must copy it first, e.g. | |
| * `structuredClone(ratings)`). Either side's entry is created at | |
| * `{ rating: INITIAL_RATING, games: 0 }` the first time that name is seen, so a caller never has | |
| * to pre-seed every entrant before a competition can run. | |
| * | |
| * K for each side comes from THAT side's own games/rating going into this call (kFactor above) β | |
| * so a provisional A meeting an established B is ordinary, correct Elo (each side's rating moves | |
| * by its own K), not a bug. The two deltas are exact negatives of each other ONLY when both sides | |
| * land in the same K-bucket (equal games-bucket and rating-bucket) β see test/elo.test.js for both | |
| * the equal-K and unequal-K cases. This is guaranteed by construction: both deltas are derived from | |
| * ONE shared `diff` (A's actual-minus-expected score this match), not from two independently | |
| * rounded `expectedScore` calls, so at equal K the negation is exact, not merely close. | |
| * | |
| * REJECTS aName === bName, before touching `ratings` at all. RatingsTable is keyed by name (see | |
| * the module header), so a same-name pairing would make `a`/`b` above alias the SAME object: | |
| * this function would compute A's post-match entry, write it to `ratings[aName]`, then | |
| * unconditionally overwrite that exact slot with B's post-match entry (`ratings[bName] = ...` runs | |
| * second and always wins, since aName and bName are one key) β A's half silently vanishes and the | |
| * survivor represents neither side faithfully. Two entrants that are conceptually different (say, | |
| * different strategies) but happen to share one display string collide exactly the same way, since | |
| * this module has no notion of identity beyond the name string. That's not a match anyone can | |
| * actually referee β an entrant can't play itself β so it's refused up front rather than silently | |
| * producing a garbage rating; every shipped caller (competition.js's buildJob, tools/ailab.js's | |
| * runDuel) already rejects this earlier, with a user/CLI-facing message, but this check is the | |
| * shared backstop any caller gets for free, present or future, per D1's "one implementation" point. | |
| * @param {RatingsTable} ratings | |
| * @param {string} aName | |
| * @param {string} bName | |
| * @param {number} score A's result: 1 (A won), 0.5 (draw), 0 (A lost). | |
| * @returns {RatingsTable} the same `ratings` object, mutated in place. | |
| * @throws {Error} if aName === bName. | |
| */ | |
| export function applyResult(ratings, aName, bName, score) { | |
| if (aName === bName) { | |
| throw new Error( | |
| `applyResult: A and B must be different entrants, both were "${aName}" β RatingsTable is ` + | |
| `keyed by name, so a same-name pairing would collide both sides into one entry instead of two` | |
| ); | |
| } | |
| const a = ratings[aName] || (ratings[aName] = { rating: INITIAL_RATING, games: 0 }); | |
| const b = ratings[bName] || (ratings[bName] = { rating: INITIAL_RATING, games: 0 }); | |
| const kA = kFactor(a.games, a.rating); | |
| const kB = kFactor(b.games, b.rating); | |
| // One match, one shared "surprise" (actual minus expected) from A's side; B's is its exact | |
| // negation by construction (a single game has exactly two complementary outcomes) β see the | |
| // header comment above for why this beats computing B's own expectedScore(b, a) independently. | |
| const diff = score - expectedScore(a.rating, b.rating); | |
| const ratingA = a.rating + kA * diff; | |
| const ratingB = b.rating - kB * diff; | |
| ratings[aName] = { rating: ratingA, games: a.games + 1 }; | |
| ratings[bName] = { rating: ratingB, games: b.games + 1 }; | |
| return ratings; | |
| } | |
| /** | |
| * Fold a series of match rows through applyResult, ONE AT A TIME, in the array's OWN order. | |
| * Deliberately does NOT sort or reorder `rows` itself β Elo is order-dependent (each match reads | |
| * whatever rating the previous ones left behind), so a fixed rows array always replays to the same | |
| * final table (reproducibility), but the SAME matches in a DIFFERENT order settle on a DIFFERENT | |
| * one (see test/elo.test.js's ORDER MATTERS case). Establishing the canonical order β round, then | |
| * pairing index, then side (D6) β is the CALLER's responsibility; this is the seam where that | |
| * discipline is actually enforced, by staying silent about ordering rather than imposing one of | |
| * its own. | |
| * @param {RatingsTable} ratings | |
| * @param {MatchRow[]} rows | |
| * @returns {RatingsTable} the same `ratings` object, mutated in place. | |
| */ | |
| export function applySeries(ratings, rows) { | |
| for (const { aName, bName, score } of rows) applyResult(ratings, aName, bName, score); | |
| return ratings; | |
| } | |