Spaces:
Paused
Paused
File size: 10,616 Bytes
916c3ad 022fcd2 916c3ad 021c865 022fcd2 916c3ad 090c049 916c3ad 628afc1 916c3ad 628afc1 916c3ad 628afc1 916c3ad c3a176e 916c3ad | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | /* ============================================================
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.
============================================================ */
"use strict";
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 });
}
|