Spaces:
Paused
Paused
Download tools/duelCore.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 7.52 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/tools/duelCore.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/tools/duelCore.js
-
curl -L -o duelCore.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/tools/duelCore.js
7.52 kB
| /* ============================================================ | |
| DUEL CORE β the fairness-critical "run one duel match" primitive, extracted out of | |
| tools/ailab.js (docs/competitions-and-elo.md Phase 1) so a browser Worker can import it in a | |
| later stage without pulling in ailab.js's ~1600 lines of CLI/printing/argv/search/leaderboard | |
| code. Everything below is pure: it reads engine/aiDifficulty.js's DIFFICULTY_OPTIONS table and | |
| drives tools/selfplay.js's createSelfPlayState/runSelfPlayMatch β both already DOM-free β and | |
| this file itself has no browser-global reference anywhere (test/duelCore.test.js pins that). | |
| tools/ailab.js re-imports these three names (pinnedDuelDials, duelSeed, and runDuelMatch under | |
| its own existing local name `duelRun`) so every one of its own call sites β runDuel, | |
| runRoundRobinSwapped, runSwissBracket, and (through those) runSwappedDuel/runDuelBrackets/the | |
| `search --tournament-against` integration/the duel+swiss CLI handlers β keeps working exactly | |
| as before, unmodified. runDuel's own header comment in tools/ailab.js is where the FAIRNESS | |
| argument for why difficulty/apm/micro must be pinned identical for both sides lives; the two | |
| comments below explain only what each function itself does. | |
| ============================================================ */ | |
| ; | |
| import { DIFFICULTY_OPTIONS } from "../engine/aiDifficulty.js"; | |
| import { hashStr } from "../engine/rng.js"; | |
| import { createSelfPlayState, runSelfPlayMatch } from "./selfplay.js"; | |
| import { playerScore } from "../engine/victory.js"; | |
| // difficulty -> the ONE dial object both sides read (apm/micro, derived from the same | |
| // DIFFICULTY_OPTIONS row labWorld's own --apm real already reads for a single side) β see | |
| // tools/ailab.js's DUEL header comment for the FAIRNESS argument this exists to satisfy. Exported | |
| // so a test can read back exactly what a run should have used, rather than trusting the CLI flag | |
| // was honoured. | |
| /** | |
| * @param {string} difficulty | |
| * @returns {{ difficulty: string, apm: number|null, micro: boolean }} | |
| */ | |
| export function pinnedDuelDials(difficulty) { | |
| const opt = DIFFICULTY_OPTIONS.find(o => o.mult === difficulty) || DIFFICULTY_OPTIONS.find(o => o.mult === "medium"); | |
| // Echo back opt.mult, NOT the raw input: apm/micro already fall back to medium's values when | |
| // `difficulty` doesn't match any DIFFICULTY_OPTIONS row, so the reported difficulty has to fall | |
| // back with them β otherwise a typo'd/unknown --difficulty would run medium's dials but LABEL | |
| // the result with the garbage string, misleading anyone reading a saved --json duel result about | |
| // which difficulty actually ran. | |
| return { difficulty: opt.mult, apm: opt.aiApm, micro: !!opt.aiMicro }; | |
| } | |
| // Every duel match's seed is derived from (base seed, world, difficulty, BOTH candidate names, | |
| // replicate) β reproducible on its own. The pair's names are SORTED into the hash, so | |
| // duelSeed(base, w, d, a, b, rep) and duelSeed(base, w, d, b, a, rep) draw the identical map: | |
| // runSwappedDuel (tools/ailab.js) exists to isolate seat asymmetry β its header says "a | |
| // side-symmetry bug would show up as those two disagreeing" β and with an order-dependent seed the | |
| // two halves would differ in seat AND map, so a disagreement would be confounded with ordinary | |
| // seed variance and carry no information at all. | |
| // | |
| // `seedKey` OVERRIDES the name-derived half of that hash, and exists for one caller: an | |
| // evolutionary search (tools/genome.js, docs/ai-evolution-design.md Β§8.1). Names in the hash are | |
| // exactly right when the pair IS the experiment β two hand-written candidates, compared to each | |
| // other β but wrong the moment names are generated per individual, because then a generation's | |
| // every pairing draws its own map set and "the child scored better than its parent did" is partly | |
| // a statement about map luck rather than about the genome. Passing one fixed key for a whole | |
| // generation (or a whole run) pins every pairing in it to the same maps, so genomes are compared | |
| // on identical ground. Omitted, this is byte-for-byte the seed derivation it has always been. | |
| /** | |
| * @param {number} base | |
| * @param {string} world | |
| * @param {string} difficulty | |
| * @param {string} aName | |
| * @param {string} bName | |
| * @param {number} rep | |
| * @param {string} [seedKey] replaces the sorted-names segment; omit for today's exact behaviour. | |
| * @returns {number} | |
| */ | |
| export function duelSeed(base, world, difficulty, aName, bName, rep, seedKey) { | |
| const pairPart = seedKey != null ? seedKey : [aName, bName].sort().join("|"); | |
| return hashStr(`${base}:duel:${world}:${difficulty}:${pairPart}:${rep}`); | |
| } | |
| // Run ONE match: candidate A as owner "player", candidate B as owner "ai", both spending from the | |
| // SAME `dials` object (see pinnedDuelDials above / tools/ailab.js's FAIRNESS paragraph). `swapAsym` | |
| // exchanges the map's own asym halves (engine/map.js) for THIS match only. Returns a plain result | |
| // row β this exact shape is load-bearing: many functions in tools/ailab.js consume rows shaped | |
| // exactly like this, so every field name here must stay byte-for-byte stable. | |
| /** | |
| * @param {{ world: string, seed: number, | |
| * dials: { difficulty: string, apm: number|null, micro: boolean }, | |
| * aName: string, aStrategy: string, bName: string, bStrategy: string, | |
| * aArchetype?: string|null, bArchetype?: string|null, | |
| * minutes?: number, swapAsym?: boolean }} cfg | |
| * @returns {{ | |
| * world: string, seed: number, difficulty: string, swapAsym: boolean, | |
| * aName: string, aStrategy: string, bName: string, bStrategy: string, | |
| * aDifficulty: string, bDifficulty: string, | |
| * aApm: number|null, bApm: number|null, aMicro: boolean, bMicro: boolean, | |
| * winner: "a"|"b"|"draw", winReason: string|null, time: number, | |
| * aScore: number, bScore: number, margin: number, | |
| * }} | |
| */ | |
| export function runDuelMatch({ world, seed, dials, aName, aStrategy, bName, bStrategy, aArchetype, bArchetype, minutes, swapAsym }) { | |
| const state = createSelfPlayState({ | |
| planetId: world, seed, swapAsym, | |
| matchTimeLimit: minutes ? minutes * 60 : undefined, | |
| // aArchetype/bArchetype (docs/competitions-and-elo.md D3): an INPUT dial only β never reflected | |
| // on the returned row below, whose field set is pinned byte-for-byte by test/duelCore.test.js's | |
| // own exact-shape test (every tools/ailab.js consumer depends on it not growing a field). | |
| ai: { ...dials, strategy: bStrategy, archetype: bArchetype }, | |
| playerAi: { ...dials, strategy: aStrategy, archetype: aArchetype }, | |
| }); | |
| const result = runSelfPlayMatch(state, minutes ? { maxSeconds: minutes * 60 + 120 } : undefined); | |
| const aScore = playerScore(state, "player"); | |
| const bScore = playerScore(state, "ai"); | |
| const winner = !result.over ? "draw" : result.winner === "player" ? "a" : "b"; | |
| return { | |
| world, seed, difficulty: dials.difficulty, swapAsym: !!swapAsym, | |
| aName, aStrategy, bName, bStrategy, | |
| // Read back off the actual controllers, not the input β the structural-fairness tests assert | |
| // on exactly these fields, so a bug that let the two dials diverge would show here. | |
| aDifficulty: state.playerAi.difficulty, bDifficulty: state.ai.difficulty, | |
| aApm: state.playerAi.apm, bApm: state.ai.apm, aMicro: state.playerAi.micro, bMicro: state.ai.micro, | |
| winner, winReason: result.winReason, time: +result.time.toFixed(1), | |
| aScore: +aScore.toFixed(1), bScore: +bScore.toFixed(1), margin: +(aScore - bScore).toFixed(1), | |
| }; | |
| } | |