Spaces:
Paused
Paused
Claude
Close the guard gaps behind the last three bugs; withdraw one review finding
021c865 unverified Download tools/selfplay.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 10.6 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/tools/selfplay.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/tools/selfplay.js
-
curl -L -o selfplay.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/tools/selfplay.js
10.6 kB
| /* ============================================================ | |
| SELF-PLAY β Tier 1 of the AI roadmap: a headless bench that drives BOTH | |
| sides of a skirmish match with the real scripted AI (engine/ai.js runAI), | |
| each side independently configured (archetype comes from the planet, plus | |
| its own strategy/difficulty/APM/micro), fighting each other for real. This | |
| is the ONLY place true AI-vs-AI self-play is exercised β never wired into | |
| the shipped game (setup.js/boot.js/main.js are untouched) and skirmish-only | |
| (no Odyssey, no diplomacy, no galaxy β engine/victory.js's ordinary | |
| elimination/score-at-clock resolution, unmodified). | |
| FAIRNESS, the whole point of this tier: owner "ai" is driven by | |
| runAI(state, dt, "ai") β today's controller, reading state.fogs.ai and | |
| spending from state.ai's own action budget, completely unchanged. owner | |
| "player" is driven by a SECOND controller, state.playerAi (engine/ | |
| state.js's createAiController), reading state.fogs.player and spending | |
| from state.playerAi's own budget β never state.fogAI, never state.ai's | |
| budget. Neither side is omniscient of the other, and neither can starve | |
| or double-spend the other's actions. See engine/aiCommon.js's | |
| controllerFor/otherOwner and the fairness test in test/ai-selfplay.test.js. | |
| This file is the pure core ONLY β createSelfPlayState, tickSelfPlay, runSelfPlayMatch, | |
| fingerprint, plus the DT they share. It has no Node-only or DOM-only global anywhere in it, so | |
| it can be imported from a browser main thread or a Worker, not just Node (docs/ | |
| competitions-and-elo.md, Phase 0). The CLI entry point lives in tools/selfplay-cli.js, which | |
| imports these exports β run `node tools/selfplay-cli.js run [--world ferros] [--seed 1] β¦`. | |
| Deterministic by construction: the map seeds from mulberry32, and the sim | |
| itself reaches for no unseeded randomness and no wall clock β this file and | |
| engine/ are both covered by test/engine-purity.test.js. Two runs | |
| from the same seed produce byte-identical results β see fingerprint() | |
| below and test/ai-selfplay.test.js. | |
| ============================================================ */ | |
| ; | |
| import { createGameState, createAiController, seedDifficultyEdge } from "../engine/state.js"; | |
| import { tick } from "../engine/sim.js"; | |
| import { runAI } from "../engine/ai.js"; | |
| import { mulberry32 } from "../engine/rng.js"; | |
| import { DEFAULT_MATCH_TIME_LIMIT } from "../engine/victory.js"; | |
| // THE FIXED STEP EVERY SELF-PLAY MATCH IS SIMULATED AT β 10 Hz, and exported because who else runs | |
| // at it is now a correctness question, not a detail. | |
| // | |
| // This used to be commented "same as the game loop (engine/loop.js)". IT IS NOT: createLoop's own | |
| // default is 20 Hz (dtFixed 0.05), so an ordinary skirmish has always advanced in half-size steps. | |
| // That difference is invisible while the two never simulate the same match β but it is not a | |
| // cosmetic tuning knob. A fixed step IS the simulation: the same seed advanced in different-sized | |
| // steps runs a different number of ticks, hits its think cycles at different moments and | |
| // accumulates floats in a different order, so it is a DIFFERENT GAME. Measured, on ferros/medium | |
| // from one seed: dt 0.1 ended "ai" by elimination at 1138 s, dt 0.05 ended "player" by elimination | |
| // at 1686 s β opposite winners. | |
| // | |
| // So anything that has to reproduce a recorded/simulated row β docs/competitions-and-elo.md Phase | |
| // 5's REPLAY, and the watched match that shares its path β must run at exactly this step. boot.js's | |
| // bootState takes a `selfPlay` option for precisely that, and test/boot.test.js drives the real | |
| // loop to prove it. Ordinary play is untouched and still runs at the loop's own 20 Hz. | |
| export const SELFPLAY_DT = 0.1; | |
| export const SELFPLAY_HZ = 1 / SELFPLAY_DT; | |
| const DT = SELFPLAY_DT; | |
| /* ============================================================ | |
| THE SELF-PLAY MATCH | |
| ============================================================ */ | |
| /** | |
| * A skirmish state with BOTH owners driven by the real AI: state.ai (owner "ai", today's exact | |
| * shape/meaning, untouched) plus a second, independently-configured state.playerAi (owner | |
| * "player"). `ai`/`playerAi` options are each `{ apm, micro, strategy, difficulty, archetype }` β | |
| * same shape setup.js's own dials use, all optional (defaults mirror createGameState's own: | |
| * unthrottled APM, no micro, "default" strategy, "medium" difficulty, the world's own archetype). | |
| * `archetype` (docs/competitions-and-elo.md D3) is a STRING KEY into engine/aiArchetypes.js's | |
| * ARCHETYPES, resolved independently for each seat β so a Quick Duel entrant carries its own | |
| * doctrine instead of both seats sharing whatever temperament the world hands out. | |
| * @param {{ planetId?: string, seed?: number, sizeMult?: number, resourceMult?: number, | |
| * swapAsym?: boolean, matchTimeLimit?: number, popCap?: number, | |
| * ai?: object, playerAi?: object }} [opts] | |
| */ | |
| export function createSelfPlayState({ | |
| planetId = "ferros", seed = 1, sizeMult, resourceMult, swapAsym, matchTimeLimit, popCap, | |
| ai = {}, playerAi = {}, | |
| } = {}) { | |
| const state = createGameState({ | |
| planetId, seed, rng: mulberry32(seed), sizeMult, resourceMult, swapAsym, matchTimeLimit, popCap, | |
| aiApm: ai.apm, aiMicro: ai.micro, aiStrategy: ai.strategy, difficulty: ai.difficulty, | |
| aiArchetype: ai.archetype, | |
| }); | |
| // The second controller for owner "player" β same factory state.js uses to build state.ai | |
| // itself, so the two can never structurally drift. Assigning it directly (rather than any | |
| // setup.js/boot.js flow, which stay untouched) is the ONLY way self-play ever gets activated. | |
| state.playerAi = createAiController(planetId, { | |
| apm: playerAi.apm, micro: playerAi.micro, strategy: playerAi.strategy, difficulty: playerAi.difficulty, | |
| archetype: playerAi.archetype, | |
| }); | |
| // Hard difficulty's economic edge (engine/aiDifficulty.js economicEdge, engine/state.js | |
| // seedDifficultyEdge): createGameState already seeded owner "ai"'s own edge off ITS difficulty; | |
| // state.playerAi didn't exist yet at that point, so its edge has to be applied here, the instant | |
| // it's configured β otherwise a self-play "player" controller picked Hard would fight at a | |
| // permanent, un-researchable economic disadvantage against a Hard "ai" no matter what difficulty | |
| // IT was given, exactly the asymmetry FAIRNESS (this file's header) rules out everywhere else. | |
| seedDifficultyEdge(state, "player"); | |
| return state; | |
| } | |
| /** | |
| * Advance a self-play state by one fixed step: owner "player" is driven explicitly here, then | |
| * tick(state, dt) runs its own hardcoded runAI(state, dt) for owner "ai" (engine/sim.js). | |
| * | |
| * ORDERING, STATED HONESTLY. This comment used to claim both controllers think on the IDENTICAL | |
| * pre-tick snapshot. They do not. runAI(β¦, "player") is not a read: issueBuild inserts into | |
| * state.buildings immediately, queueProduction mutates queues, payCost mutates resources. Both | |
| * controllers share THINK_INTERVAL and start their countdowns together, so the "ai" seat reads | |
| * state the "player" seat has already changed that frame. Measured over 400 player think-cycles in | |
| * a 10-sim-minute korrath match: orders differed on 13% of cycles, queues and resources on 6%, and | |
| * buildings on 1%. | |
| * | |
| * The channel that actually feeds decisions is `buildings` β chooseAttackTarget's seenBuildings | |
| * scan and counterToPlayerArmy's static-defense scan both walk state.buildings.values() β so a | |
| * turret the player controller founded microseconds earlier is answerable one think cycle (1.5 s) | |
| * sooner than the mirror case ever could be. Small, but FIXED IN DIRECTION: it always favours the | |
| * "ai" seat, every cycle, in every duel and Swiss match. | |
| * | |
| * Why it isn't simply fixed here: making the claim true means either a genuinely frozen pre-tick | |
| * read, or moving the "ai" call out of sim.js's hardcoded pipeline so the two can alternate. Both | |
| * restructure engine/sim.js's tick contract, which is a larger change than this seam. The bound is | |
| * pinned by a characterisation test in test/ai-selfplay.test.js instead, so the edge cannot grow | |
| * unnoticed while the real fix is scheduled (docs/code-improvement-tiers.md, Tier 3). | |
| * @param {State} state @param {number} [dt] | |
| */ | |
| export function tickSelfPlay(state, dt = DT) { | |
| runAI(state, dt, "player"); // the second controller β a no-op once state.over, same as tick() below | |
| tick(state, dt); // ...then the ordinary per-tick pipeline, which drives owner "ai" itself | |
| } | |
| /** | |
| * Run a self-play match to resolution (elimination or the score-at-clock timeout, engine/ | |
| * victory.js β unmodified) or until `maxSeconds` of sim time, whichever comes first. Returns a | |
| * small summary; the state itself is mutated in place (same convention engine/sim.js's tick has). | |
| * @param {State} state @param {{ maxSeconds?: number, dt?: number }} [opts] | |
| */ | |
| export function runSelfPlayMatch(state, { maxSeconds = DEFAULT_MATCH_TIME_LIMIT + 120, dt = DT } = {}) { | |
| while (!state.over && state.time < maxSeconds) tickSelfPlay(state, dt); | |
| return { | |
| over: state.over, winner: state.winner, winReason: state.winReason, time: state.time, tick: state.tick, | |
| }; | |
| } | |
| /** | |
| * Serialize the deterministic, sim-owned facts relevant to a self-play match β entity positions/ | |
| * hp/order, both economies, both controllers' bookkeeping, and how much fog each side has | |
| * revealed. Sorted by id so Map iteration order can't matter. Mirrors the pattern | |
| * test/determinism.test.js's own snapshot() and test/ailab.test.js's own fingerprint() already | |
| * use elsewhere in this codebase, extended with state.playerAi so a self-play run's SECOND | |
| * controller is covered by the same determinism guarantee the first already has. | |
| * @param {State} state | |
| */ | |
| export function fingerprint(state) { | |
| const units = [...state.units.values()] | |
| .map(u => `${u.id}|${u.type}|${u.owner}|${u.x}|${u.y}|${u.hp}|${u.order ? u.order.type : "-"}`) | |
| .sort(); | |
| const builds = [...state.buildings.values()] | |
| .map(b => `${b.id}|${b.type}|${b.owner}|${b.hp}|${b.buildProgress}|${b.queue.length}`) | |
| .sort(); | |
| const res = state.owners.map(o => JSON.stringify(state.players[o].resources)).join("|"); | |
| const fogs = state.owners.map(o => state.fogs[o].explored.reduce((a, v) => a + v, 0)).join("|"); | |
| const ai = JSON.stringify(state.ai); | |
| const playerAi = JSON.stringify(state.playerAi); | |
| return JSON.stringify({ units, builds, res, fogs, ai, playerAi, tick: state.tick, time: state.time, over: state.over, winner: state.winner }); | |
| } | |