1// Pure game state: manifest v3 types, geometry derived from the tile2// shapes, and the fold over the append-only log. No Deno or DOM APIs here;3// the referee, the SSE viewer, and replay all use this same fold, so cells4// are derived from events and never stored. A v2 manifest (square tiles,5// seam segments, filler zones) is converted on load, so old logs replay.6//7// State contract (for the viewer and the analyzer readers):8//9// agents[] {id, name, harness, model, tags, joined, seed_tile, group}10// one per `join` event; `group` is the id of the agent whose11// territory this agent's has merged into (union-find root;12// an agent's own id until it meets someone)13// tileAgent[] per tile id: the agent that solved it (null while unsolved)14// meets[] {rev, agents: [a, b], tile}: the tile solved by a that15// touched a tile solved by b's group; the groups merged then16// frontierOf(s, agent) hidden or revealed-unsolved tiles overlapping a17// tile solved by the agent's group, plus the agent's own seed18// tile while unsolved; frontierOf(s) is the union over all19// agents plus every unclaimed seed tile20// analysis totals and per-agent summaries, recomputed on every event21// (pure fold, so live == replay at every revision):22// guess rate, reveal:commit, retractions, reward per batch,23// batch size drift, mean wall gap, per agent tiles/cells/24// reward/forced/guess/wrong/retracts/reveals/territory share/25// group/meets/strategy (bfs | dfs | greedy | mixed)26// events `join` (agent, name?, harness, model?, tags?, seed_tile),27// `batch` (op reveal|commit|retract, carries `agent`;28// commit also records `solved` and `meets` for readers,29// both derived by the fold), `end` (carries the final30// `analysis` summary), `manifest`31// Logs written before agents existed fold with one implicit agent `a0`32// whose seed tile is the manifest's seed tile.33//34// Growing fields (manifest v3 with `field`, `rules.grow`):35// events `grow` (ring, tiles: TileSpec[] appended with ids that36// continue, seed_tiles of the new ring, field) — cell ids37// never change (the field is allocated at its final G)38// geo may be a NEW object after a `grow` event: tiles appended,39// ids stable, hues of old tiles unchanged; readers must not40// cache a geometry across a grow (compare `s.geo` identity)41// grown number of grow events folded so far42// reached Uint8Array per tile: 1 if the tile is revealed or solved,43// overlaps a revealed or solved tile, or is some agent's44// seed tile; 0 otherwise (the spectator draws unreached45// tiles as the faintest wash; observe under fog lists a46// hidden tile only when the caller's own group reaches it)47// tileRevealer[] per tile id: the agent that revealed it (null while hidden)48// agents[].health {state, rate, committed, wrong, window, died_at}: the49// agent's wrong-rate over its last HEALTH_WINDOW committed50// cells (accepted + wrong): healthy under HEALTH_WARN,51// warning up to HEALTH_DEATH, dead at HEALTH_DEATH once at52// least HEALTH_MIN cells are in the window. Death is sticky53// for the game (died_at = the revision); the window heals54// the other states on its own. Guessing bleeds you.55// seedRings seed tiles per ring (ring 0 = the initial field); a56// joining agent is seated on the newest ring first5758/** The chunk grid of a field; the engine adds what growth needs. */59export type FieldInfo = {60 chunks: number;61 chunk_g: number;62 band: number;63 grown?: number;64 [k: string]: unknown;65};6667/** A rectangle in a tile's local coordinates: [lx, ly, w, h]. */68export type LocalRect = [number, number, number, number];6970/** Cells shared with one neighbour: the partner id and how many. */71export type Overlap = { with: number; cells: number };7273/**74 * A tile is a shape: the union of `rects` inside a w x h bounding box at75 * (x0, y0). Every maximal horizontal or vertical run of its cells is a76 * line the Takuzu rules apply to.77 */78export type TileSpec = {79 id: number;80 x0: number;81 y0: number;82 w: number;83 h: number;84 rects: LocalRect[];85 cells: number;86 hash?: string;87 m: number;88 overlaps: Overlap[];89 pins?: number[];90};9192/** v2 filler zone: ownerless cells known from the start. Kept for old logs. */93export type ZoneSpec = {94 id: number;95 rects: [number, number, number, number][]; // x0, y0, w, h in cells96 values?: string; // row-major over the rects in order97};9899export type Manifest = {100 version: 3;101 id: string;102 seed_commit: string;103 G: number;104 packer: { kind: string; params?: Record<string, unknown> };105 m_by_n?: Record<string, number>;106 rules: Record<string, unknown>;107 seed_tile: number;108 seed_tiles?: number[]; // one per chunk on a field; candidates for joining agents109 field?: FieldInfo;110 tiles: TileSpec[];111 zones?: ZoneSpec[]; // only on manifests converted from v2112 k: string | number;113 k_hist?: Record<string, number>;114 generator: string;115 library: string | null;116 source_version?: number; // 2 when converted on load117};118119/** v2 shape, as written by gen-v2..v4 and the two logs on disk. */120export type ManifestV2 = {121 version: 2;122 id: string;123 seed_commit: string;124 G: number;125 packer: { kind: string; params?: Record<string, unknown> };126 m_by_n: Record<string, number>;127 rules: Record<string, unknown>;128 seed_tile: number;129 tiles: {130 id: number;131 x0: number;132 y0: number;133 n: number;134 hash?: string;135 m: number;136 seams: { side: string; from: number; len: number; with: number }[];137 pins?: number[];138 }[];139 zones: ZoneSpec[];140 k: string;141 generator: string;142 library: string | null;143};144145/** phase-1 shape: an X-by-X grid of n-squares on a pitch of n-1; no tile list. */146export type ManifestV1 = {147 id: string;148 seed_commit: string;149 X: number;150 n: number;151 m: number;152 G: number;153 rules: Record<string, unknown>;154 k: string;155 generator: string;156};157158export type AnyManifest = Manifest | ManifestV2 | ManifestV1;159160export function isV1(m: AnyManifest): m is ManifestV1 {161 return !("version" in m) && "X" in m;162}163164/** Growth policy of a field: a timer, a coverage threshold, or both. */165export type GrowRules = {166 every_minutes?: number;167 /** grow when this share of the current tiles is revealed or solved */168 frontier_coverage?: number;169};170171export type GameRules = {172 guess: "accept" | "reject";173 depth: number;174 /** hide unreached tiles from observe (default: on for growing fields) */175 fog?: boolean;176 grow?: GrowRules;177 /** "required": a join needs a verified machine key (the hosted daily);178 * unset/"optional": keyless joins become provisional a<N> agents (old179 * logs, local tests) */180 identity?: "required" | "optional";181};182183export type Class = "forced" | "guess";184export type CellKind = "given" | "commit" | "zone";185186export type AcceptedCell = {187 id: number;188 v: 0 | 1;189 class: Class;190 rules: string[];191};192193export type RejectedCell = { id: number; reason: "wrong" | "guess"; v?: 0 | 1 };194195export type ManifestEvent = {196 rev: 0;197 t: string;198 kind: "manifest";199 game_id: string;200 manifest: AnyManifest;201 rules: GameRules;202 seed_tile?: [number, number]; // phase-1 logs: the seed tile as (tx, ty)203 /** the daily: yesterday's winners, each with a reserved seed tile and204 * hue; a returning winner (same machine id or name) is seated there */205 honours?: Honour[];206 /** the daily: the ISO date this field is the board of */207 day?: string;208};209210/** A winner of a previous day, honoured on this field. */211export type Honour = {212 agent: string; // the agent id they had (machine id or provisional)213 name: string;214 model: string | null;215 user: string; // machine id or name: what the leaderboard keyed on216 hue: number; // reserved hue index217 seed_tile: number; // reserved seed tile on this field218 day: string; // the day they won219 reward: number;220};221222/** An agent joining the field. */223export type JoinEvent = {224 rev: number;225 t: string;226 kind: "join";227 agent: string;228 name?: string;229 harness: string;230 harness_source?: "envelope" | "client_version" | "argument" | "none";231 model?: string;232 tags?: Record<string, string>;233 machine_id?: string;234 /** the machine proved its key (see lib/identity.ts); the agent id is235 * machine_id/session and the leaderboard's user axis is the machine */236 verified?: boolean;237 session?: string;238 seed_tile: number;239};240241/** Recorded on a commit for readers: a meeting the fold derives itself. */242export type Meet = { with: string; tile: number };243244export type RevealEvent = {245 rev: number;246 t: string;247 kind: "batch";248 op: "reveal";249 agent?: string;250 tile: number; // phase-1 logs carry [tx, ty] here; the fold maps it (tileIdOf)251 givens: [number, 0 | 1][];252 seam: [number, 0 | 1][];253 intent: string;254};255256export type CommitEvent = {257 rev: number;258 t: string;259 kind: "batch";260 op: "commit";261 agent?: string;262 intent: string;263 accepted: AcceptedCell[];264 rejected: RejectedCell[];265 ignored: number[];266 solved: number[];267 meets?: Meet[];268 judge_ms: number;269 unevaluated?: number[]; // cells after the one that killed the machine270 died_at?: number; // this batch killed the machine (the revision)271};272273export type RetractEvent = {274 rev: number;275 t: string;276 kind: "batch";277 op: "retract";278 agent?: string;279 cells: number[];280 intent: string;281};282283/** A ring of chunks generated around the cluster: tiles appended. */284export type GrowEvent = {285 rev: number;286 t: string;287 kind: "grow";288 ring: number;289 tiles: TileSpec[];290 seed_tiles: number[];291 field?: FieldInfo;292 exhausted?: boolean;293};294295export type EndEvent = {296 rev: number;297 t: string;298 kind: "end";299 seed?: string;300 nonce?: string;301 analysis?: Analysis;302};303304export type GameEvent =305 | ManifestEvent306 | JoinEvent307 | RevealEvent308 | CommitEvent309 | RetractEvent310 | GrowEvent311 | EndEvent;312313export const IMPLICIT_AGENT = "a0";314315export type HealthState = "healthy" | "warning" | "dead";316317/** An agent's health: its wrong-rate over a window of committed cells. */318export type Health = {319 state: HealthState;320 rate: number; // wrong / committed over the window321 committed: number; // cells in the window (accepted + wrong)322 wrong: number; // wrong cells in the window323 window: number; // HEALTH_WINDOW324 died_at: number | null; // revision the agent died at325};326327/** Cells of the health window. */328export const HEALTH_WINDOW = 2000;329/** Wrong-rate from which an agent is warned. */330export const HEALTH_WARN = 0.015;331/** Wrong-rate at which an agent dies for the game. */332export const HEALTH_DEATH = 0.04;333/** Committed cells in the window before the rate rule can apply. */334export const HEALTH_MIN = 300;335/** Wrong cells in the window that kill before the floor (a probe burst). */336export const HEALTH_BURST = 30;337338/**339 * A machine's health window. One per machine (all sessions of a verified340 * machine share it; an unverified agent keys on its own id): a ring of the341 * last HEALTH_WINDOW counted outcomes, and the cells already counted, so a342 * correct cell enters the window at most once per machine (retract and343 * recommit cannot launder the rate) while a wrong one always counts.344 * Persistent: copied on write per event, `counted` per touched chunk.345 */346export type HealthWindow = {347 outcomes: Uint8Array;348 opos: number;349 ofill: number;350 owrong: number;351 counted: (Uint8Array | undefined)[]; // bitset chunks of COUNT_CHUNK cells352 died_at: number | null;353};354const COUNT_CHUNK = 8192;355356export function newWindow(): HealthWindow {357 return {358 outcomes: new Uint8Array(HEALTH_WINDOW),359 opos: 0,360 ofill: 0,361 owrong: 0,362 counted: [],363 died_at: null,364 };365}366367/** A copy safe to mutate; chunks are shared until written. */368export function cloneWindow(w: HealthWindow): HealthWindow {369 return {370 ...w,371 outcomes: new Uint8Array(w.outcomes),372 counted: [...w.counted],373 };374}375376function isCounted(w: HealthWindow, id: number): boolean {377 const c = w.counted[Math.floor(id / COUNT_CHUNK)];378 if (!c) return false;379 const bit = id % COUNT_CHUNK;380 return (c[bit >> 3] & (1 << (bit & 7))) !== 0;381}382383function markCounted(w: HealthWindow, id: number): void {384 const ci = Math.floor(id / COUNT_CHUNK);385 const c = new Uint8Array(w.counted[ci] ?? new Uint8Array(COUNT_CHUNK / 8));386 const bit = id % COUNT_CHUNK;387 c[bit >> 3] |= 1 << (bit & 7);388 w.counted[ci] = c;389}390391function pushOutcome(w: HealthWindow, wrong: boolean): void {392 if (w.ofill === HEALTH_WINDOW) {393 if (w.outcomes[w.opos] === 1) w.owrong--;394 } else {395 w.ofill++;396 }397 w.outcomes[w.opos] = wrong ? 1 : 0;398 if (wrong) w.owrong++;399 w.opos = (w.opos + 1) % HEALTH_WINDOW;400}401402/**403 * Record one verdict on a (cloned) window: a wrong cell always enters the404 * window; a correct one only the first time this machine is credited for405 * that cell. Returns whether the window counted it.406 */407export function windowRecord(408 w: HealthWindow,409 id: number,410 wrong: boolean,411): boolean {412 if (wrong) {413 pushOutcome(w, true);414 markCounted(w, id);415 return true;416 }417 if (isCounted(w, id)) return false;418 pushOutcome(w, false);419 markCounted(w, id);420 return true;421}422423/** Death: a burst of wrongs, or the rate past the floor. */424export function windowDead(w: HealthWindow): boolean {425 if (w.owrong >= HEALTH_BURST) return true;426 return w.ofill >= HEALTH_MIN && w.owrong / w.ofill >= HEALTH_DEATH;427}428429/** The health read off a window. */430export function healthOfWindow(w: HealthWindow): Health {431 const rate = w.ofill ? w.owrong / w.ofill : 0;432 let state: HealthState = "healthy";433 if (w.died_at !== null) state = "dead";434 else if (rate >= HEALTH_WARN) state = "warning";435 return {436 state,437 rate,438 committed: w.ofill,439 wrong: w.owrong,440 window: HEALTH_WINDOW,441 died_at: w.died_at,442 };443}444445/** The key of the window an agent's writes count against. */446export function machineKeyOf(447 a: { id: string; verified: boolean; machine_id: string | null },448): string {449 return a.verified && a.machine_id ? a.machine_id : a.id;450}451452export type AgentInfo = {453 id: string;454 name: string;455 harness: string;456 model: string | null;457 tags: Record<string, string>;458 /** a verified machine key stands behind this agent; unverified agents459 * are provisional (a<N>) and cannot be honoured */460 verified: boolean;461 machine_id: string | null;462 joined: number; // revision of the join event (0 for the implicit agent)463 seed_tile: number;464 group: string; // union-find root agent id465 health: Health;466};467468export type Strategy = "bfs" | "dfs" | "greedy" | "mixed" | "none";469470export type AgentAnalysis = {471 agent: string;472 group: string;473 tiles: number; // solved by this agent474 cells: number;475 reward: number;476 reveals: number;477 commits: number;478 forced: number;479 guess: number;480 wrong: number;481 retracts: number;482 retracted: number;483 territory: number; // cells solved by this agent / cells solved by anyone484 meets: number;485 strategy: Strategy;486 strategy_counts: {487 bfs: number;488 dfs: number;489 greedy: number;490 reveals: number;491 };492 last_solved: number | null; // tile id493 health: Health;494};495496export type Analysis = {497 revision: number;498 guess_rate: number; // guess / (forced + guess)499 reveal_commit: number; // reveals / commits500 retractions: number;501 reward_per_batch: number;502 batch_size_mean: number;503 batch_size_recent: number; // mean over the last 10 commits504 batch_size_drift: number; // recent - mean505 mean_gap_ms: number | null; // wall-clock between consecutive batches506 solved_tiles: number;507 solved_cells: number;508 meets: number;509 agents: AgentAnalysis[];510 k: string | number;511 k_hist?: Record<string, number>;512};513514export type TileStatus = "hidden" | "revealed" | "solved";515516export type CellInfo = {517 v: 0 | 1;518 kind: CellKind;519 class?: Class;520 rules?: string[];521 rev: number;522};523524export type ConsoleEntry = {525 rev: number;526 t: string;527 op: "start" | "join" | "reveal" | "commit" | "retract" | "grow" | "end";528 agent?: string;529 intent: string;530 tile?: number;531 meets?: Meet[];532 accepted: number;533 forced: number;534 guess: number;535 rejected: number;536 ignored: number;537 retracted: number;538 revealed_cells: number;539 solved: number[];540 reward: number; // cells per tile solved by this batch541 dt_ms: number | null; // wall-clock since the previous batch542};543544/**545 * Geometry derived once from a manifest: a CSR owner index (cell -> tile ids),546 * tile cell lists, adjacency, and a hue per tile. Immutable after build and547 * shared by every state folded from the same manifest.548 */549export type Geometry = {550 G: number;551 ownerStart: Uint32Array; // length G*G + 1552 ownerList: Uint16Array; // tile ids, grouped per cell553 hues: Uint8Array; // per tile id554 adjacency: number[][]; // per tile id, sorted: tiles sharing a cell555 cellsOf: number[][]; // per tile id, sorted global cell ids556 holes: number; // cells no tile covers557};558559export type State = {560 game_id: string;561 revision: number;562 manifest: Manifest | null;563 geo: Geometry | null;564 G: number;565 seed_tile: number;566 rules: GameRules;567 tiles: TileStatus[]; // by tile id568 cells: Record<number, CellInfo>; // read-only façade over cellStore569 cellStore: CellStore;570 knownCount: Uint32Array; // per tile: known cells (givens, shared, commits)571 solvedTiles: number;572 solvedCells: number;573 v1X: number; // X for a converted phase-1 log (reveal.tile is [tx, ty]); 0 otherwise574 console: ConsoleEntry[];575 status: "playing" | "solved";576 totals: {577 reveals: number;578 commits: number;579 retracts: number;580 forced: number;581 guess: number;582 wrong: number;583 retracted: number;584 reward: number;585 };586 last_t: string | null;587 agents: AgentInfo[];588 tileAgent: (string | null)[]; // by tile id589 tileRevealer: (string | null)[]; // by tile id590 meets: { rev: number; agents: [string, string]; tile: number }[];591 reached: Uint8Array; // by tile id; see the contract above592 grown: number;593 seedRings: number[][]; // seed tiles per ring594 exhausted: boolean; // the last grow found no room595 honours: Honour[]; // yesterday's winners with reserved seats (the daily)596 day: string | null; // the daily: the date this field belongs to597 analysis: Analysis;598 // analyzer scratch, derived and deterministic (part of the state so that599 // live == replay holds for it too)600 agentStats: Record<string, AgentStats>;601 machines: Record<string, HealthWindow>; // health windows by machine key602 batchSizes: number[];603 gaps: number[];604};605606/** Per-agent counters the analysis is computed from. */607export type AgentStats = {608 reveals: number;609 commits: number;610 forced: number;611 guess: number;612 wrong: number;613 retracts: number;614 retracted: number;615 tiles: number;616 cells: number;617 meets: number;618 bfs: number;619 dfs: number;620 greedy: number;621 last_solved: number | null;622 seed_dist: number[]; // BFS distance from the agent's seed tile, by tile id623 hkey: string; // the machine window this agent's writes count against624};625626/** A fresh agent's health. */627export function healthyHealth(): Health {628 return {629 state: "healthy",630 rate: 0,631 committed: 0,632 wrong: 0,633 window: HEALTH_WINDOW,634 died_at: null,635 };636}637638/** An agent's health: its machine's window. */639export function healthOf(s: State, agent: string): Health {640 const st = s.agentStats[agent];641 const w = st ? s.machines[st.hkey] : undefined;642 return w ? healthOfWindow(w) : healthyHealth();643}644645export function isDead(s: State, agent: string): boolean {646 return agentOf(s, agent)?.health.state === "dead";647}648649// ------------------------------------------------------------- cell store650//651// Known cells live in a persistent chunked store: an array of chunks of652// CHUNK cells, copied on write per chunk, so a state derived from the653// previous one shares every chunk the event did not touch. A fold of N654// events over K known cells costs O(N * touched chunks), not O(N * K), and655// foldAll's states share memory. `State.cells` is a read-only façade over656// the store with plain-object semantics (`s.cells[id]`, `id in s.cells`,657// `Object.keys(s.cells)`), so readers need not know; hot paths use658// `cellAt` and `knownIds`.659660export const CHUNK = 1024;661662export type CellStore = {663 chunks: ((CellInfo | undefined)[] | undefined)[];664 size: number;665};666667export function emptyStore(): CellStore {668 return { chunks: [], size: 0 };669}670671export function storeGet(st: CellStore, id: number): CellInfo | undefined {672 const c = st.chunks[id >> 10];673 return c === undefined ? undefined : c[id & 1023];674}675676/** Every known id, ascending. */677export function knownIds(st: CellStore): number[] {678 const out: number[] = [];679 for (let ci = 0; ci < st.chunks.length; ci++) {680 const c = st.chunks[ci];681 if (!c) continue;682 for (let k = 0; k < c.length; k++) {683 if (c[k] !== undefined) out.push((ci << 10) | k);684 }685 }686 return out;687}688689/** Copy-on-write writer: one copy of the chunk array and of each touched chunk per event. */690class StoreWriter {691 readonly chunks: CellStore["chunks"];692 private touched = new Set<number>();693 size: number;694 constructor(prev: CellStore) {695 this.chunks = prev.chunks.slice();696 this.size = prev.size;697 }698 private chunk(ci: number): (CellInfo | undefined)[] {699 let c = this.chunks[ci];700 if (!this.touched.has(ci)) {701 c = c ? c.slice() : new Array(CHUNK);702 this.chunks[ci] = c;703 this.touched.add(ci);704 }705 return c!;706 }707 get(id: number): CellInfo | undefined {708 const c = this.chunks[id >> 10];709 return c === undefined ? undefined : c[id & 1023];710 }711 set(id: number, info: CellInfo): void {712 const c = this.chunk(id >> 10);713 if (c[id & 1023] === undefined) this.size++;714 c[id & 1023] = info;715 }716 delete(id: number): boolean {717 const ci = id >> 10;718 const cur = this.chunks[ci];719 if (!cur || cur[id & 1023] === undefined) return false;720 const c = this.chunk(ci);721 c[id & 1023] = undefined;722 this.size--;723 return true;724 }725 store(): CellStore {726 return { chunks: this.chunks, size: this.size };727 }728}729730const facadeCache = new WeakMap<CellStore, Record<number, CellInfo>>();731732/** A read-only plain-object view of a store (memoised per store). */733export function cellsFacade(st: CellStore): Record<number, CellInfo> {734 let f = facadeCache.get(st);735 if (f) return f;736 const keyOf = (p: string | symbol): number | null => {737 if (typeof p !== "string") return null;738 const n = Number(p);739 return Number.isInteger(n) && n >= 0 && String(n) === p ? n : null;740 };741 f = new Proxy({} as Record<number, CellInfo>, {742 get(_t, p) {743 const k = keyOf(p);744 return k === null ? undefined : storeGet(st, k);745 },746 has(_t, p) {747 const k = keyOf(p);748 return k !== null && storeGet(st, k) !== undefined;749 },750 ownKeys() {751 return knownIds(st).map(String);752 },753 getOwnPropertyDescriptor(_t, p) {754 const k = keyOf(p);755 const v = k === null ? undefined : storeGet(st, k);756 return v === undefined ? undefined : {757 value: v,758 writable: false,759 enumerable: true,760 configurable: true,761 };762 },763 set() {764 throw new Error("State.cells is read-only; fold events instead");765 },766 deleteProperty() {767 throw new Error("State.cells is read-only; fold events instead");768 },769 });770 facadeCache.set(st, f);771 return f;772}773774/** A known cell, or undefined (the fast path; `s.cells[id]` is the same). */775export function cellAt(s: State, id: number): CellInfo | undefined {776 return storeGet(s.cellStore, id);777}778779// ---------------------------------------------------------------- geometry780781export const HUES = 7;782783export function cellId(x: number, y: number, G: number): number {784 return y * G + x;785}786787export function cellXY(id: number, G: number): [number, number] {788 return [id % G, Math.floor(id / G)];789}790791/** Bounding box of a tile: inclusive x0..x1, y0..y1 in global cells. */792export function tileWindow(793 t: TileSpec,794): { x0: number; y0: number; x1: number; y1: number } {795 return { x0: t.x0, y0: t.y0, x1: t.x0 + t.w - 1, y1: t.y0 + t.h - 1 };796}797798/** Local mask of a tile's bounding box: 1 where the tile has a cell. */799export function tileMask(t: TileSpec): Uint8Array {800 const mask = new Uint8Array(t.w * t.h);801 for (const [lx, ly, rw, rh] of t.rects) {802 for (let y = ly; y < ly + rh; y++) {803 for (let x = lx; x < lx + rw; x++) mask[y * t.w + x] = 1;804 }805 }806 return mask;807}808809export function tileContains(t: TileSpec, x: number, y: number): boolean {810 const lx = x - t.x0, ly = y - t.y0;811 if (lx < 0 || ly < 0 || lx >= t.w || ly >= t.h) return false;812 for (const [rx, ry, rw, rh] of t.rects) {813 if (lx >= rx && lx < rx + rw && ly >= ry && ly < ry + rh) return true;814 }815 return false;816}817818/** Global ids of a tile's cells, sorted. */819export function tileCells(t: TileSpec, G: number): number[] {820 const mask = tileMask(t);821 const ids: number[] = [];822 for (let y = 0; y < t.h; y++) {823 for (let x = 0; x < t.w; x++) {824 if (mask[y * t.w + x]) ids.push(cellId(t.x0 + x, t.y0 + y, G));825 }826 }827 return ids;828}829830/**831 * Convert a v2 manifest (square tiles, seam segments, filler zones) to v3832 * shapes. Overlaps are recomputed from the geometry; zones are kept so833 * their cells stay known from revision 0. A v3 manifest is returned as is.834 */835export function normaliseManifest(836 m: AnyManifest,837 seedXY?: [number, number],838): Manifest {839 if (isV1(m)) return normaliseV1(m, seedXY);840 if (m.version === 3) return m;841 const tiles: TileSpec[] = m.tiles.map((t) => ({842 id: t.id,843 x0: t.x0,844 y0: t.y0,845 w: t.n,846 h: t.n,847 rects: [[0, 0, t.n, t.n] as LocalRect],848 cells: t.n * t.n,849 hash: t.hash,850 m: t.m,851 overlaps: [],852 pins: t.pins,853 }));854 const out: Manifest = {855 version: 3,856 id: m.id,857 seed_commit: m.seed_commit,858 G: m.G,859 packer: m.packer,860 m_by_n: m.m_by_n,861 rules: m.rules,862 seed_tile: m.seed_tile,863 tiles,864 zones: m.zones,865 k: m.k,866 generator: m.generator,867 library: m.library,868 source_version: 2,869 };870 const geo = buildGeometry(out);871 for (const t of tiles) t.overlaps = overlapsOf(geo, t.id);872 return out;873}874875/** phase-1: X-by-X squares of n on a pitch of n-1, ids in raster order. */876function normaliseV1(m: ManifestV1, seedXY?: [number, number]): Manifest {877 const tiles: TileSpec[] = [];878 for (let ty = 0; ty < m.X; ty++) {879 for (let tx = 0; tx < m.X; tx++) {880 tiles.push({881 id: tiles.length,882 x0: tx * (m.n - 1),883 y0: ty * (m.n - 1),884 w: m.n,885 h: m.n,886 rects: [[0, 0, m.n, m.n]],887 cells: m.n * m.n,888 m: m.m,889 overlaps: [],890 });891 }892 }893 const out: Manifest = {894 version: 3,895 id: m.id,896 seed_commit: m.seed_commit,897 G: m.G,898 packer: { kind: "grid", params: { X: m.X, n: m.n } },899 m_by_n: { [String(m.n)]: m.m },900 rules: m.rules,901 seed_tile: seedXY ? seedXY[1] * m.X + seedXY[0] : 0,902 tiles,903 k: m.k,904 generator: m.generator,905 library: null,906 source_version: 1,907 };908 const geo = buildGeometry(out);909 for (const t of tiles) t.overlaps = overlapsOf(geo, t.id);910 return out;911}912913/** Shared-cell counts between a tile and each tile it touches. */914export function overlapsOf(geo: Geometry, tile: number): Overlap[] {915 const counts = new Map<number, number>();916 for (const id of geo.cellsOf[tile]) {917 for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {918 const o = geo.ownerList[i];919 if (o !== tile) counts.set(o, (counts.get(o) ?? 0) + 1);920 }921 }922 return [...counts.entries()].sort((a, b) => a[0] - b[0]).map((923 [w, c],924 ) => ({ with: w, cells: c }));925}926927/** Build the owner index, tile cell lists, adjacency and hues for a manifest. */928export function buildGeometry(m: Manifest): Geometry {929 const G = m.G;930 const N = G * G;931 const cellsOf = m.tiles.map((t) => tileCells(t, G));932 const count = new Uint32Array(N + 1);933 for (const ids of cellsOf) for (const c of ids) count[c + 1]++;934 const ownerStart = new Uint32Array(N + 1);935 for (let i = 0; i < N; i++) ownerStart[i + 1] = ownerStart[i] + count[i + 1];936 const ownerList = new Uint16Array(ownerStart[N]);937 const fill = new Uint32Array(N);938 for (const t of m.tiles) {939 for (const c of cellsOf[t.id]) ownerList[ownerStart[c] + fill[c]++] = t.id;940 }941 // adjacency: tiles sharing any cell942 const adj: Set<number>[] = m.tiles.map(() => new Set<number>());943 let holes = 0;944 for (let c = 0; c < N; c++) {945 const a = ownerStart[c], b = ownerStart[c + 1];946 if (a === b) holes++;947 if (b - a < 2) continue;948 for (let i = a; i < b; i++) {949 for (let j = a; j < b; j++) {950 if (i !== j) adj[ownerList[i]].add(ownerList[j]);951 }952 }953 }954 const adjacency = adj.map((s) => [...s].sort((p, q) => p - q));955 return {956 G,957 ownerStart,958 ownerList,959 hues: colourTiles(adjacency),960 adjacency,961 cellsOf,962 holes,963 };964}965966/**967 * Geometry after a grow: the manifest now holds the old tiles plus the new968 * ones (ids continuing). The owner index, cell lists, adjacency and holes969 * are rebuilt for the union; hues of existing tiles are kept and the new970 * tiles get greedy hues against their neighbours, so nothing already drawn971 * changes colour. Returns a new object; the old geometry is untouched.972 */973export function extendGeometry(prev: Geometry, m: Manifest): Geometry {974 const next = buildGeometry(m);975 const hues = new Uint8Array(m.tiles.length).fill(255);976 hues.set(prev.hues.subarray(0, Math.min(prev.hues.length, hues.length)));977 for (let i = prev.hues.length; i < m.tiles.length; i++) {978 const used = new Set<number>();979 for (const j of next.adjacency[i]) if (hues[j] !== 255) used.add(hues[j]);980 let h = 0;981 while (used.has(h) && h < HUES) h++;982 if (h === HUES) {983 const counts = new Array(HUES).fill(0);984 for (const j of next.adjacency[i]) if (hues[j] !== 255) counts[hues[j]]++;985 h = counts.indexOf(Math.min(...counts));986 }987 hues[i] = h;988 }989 return { ...next, hues };990}991992/** Re-pick the hues of the given tiles against their (now known)993 * neighbours; a placeholder was coloured with no adjacency. */994export function recolourTiles(geo: Geometry, ids: number[]): Geometry {995 const hues = new Uint8Array(geo.hues);996 for (const i of ids) {997 const used = new Set<number>();998 for (const j of geo.adjacency[i]) {999 if (j !== i && hues[j] !== 255) used.add(hues[j]);1000 }1001 let h = 0;1002 while (used.has(h) && h < HUES) h++;1003 if (h === HUES) {1004 const counts = new Array(HUES).fill(0);1005 for (const j of geo.adjacency[i]) if (hues[j] !== 255) counts[hues[j]]++;1006 h = counts.indexOf(Math.min(...counts));1007 }1008 hues[i] = h;1009 }1010 return { ...geo, hues };1011}10121013/**1014 * Greedy 7-colouring in smallest-last order (the contact graph of a square1015 * packing is planar, so this never needs more than 6). Deterministic.1016 */1017export function colourTiles(adjacency: number[][]): Uint8Array {1018 const n = adjacency.length;1019 const hues = new Uint8Array(n).fill(255);1020 const deg = adjacency.map((a) => a.length);1021 const removed = new Uint8Array(n);1022 const order: number[] = [];1023 for (let k = 0; k < n; k++) {1024 let best = -1;1025 for (let i = 0; i < n; i++) {1026 if (!removed[i] && (best < 0 || deg[i] < deg[best])) best = i;1027 }1028 removed[best] = 1;1029 order.push(best);1030 for (const j of adjacency[best]) if (!removed[j]) deg[j]--;1031 }1032 for (let k = order.length - 1; k >= 0; k--) {1033 const i = order[k];1034 const used = new Set<number>();1035 for (const j of adjacency[i]) if (hues[j] !== 255) used.add(hues[j]);1036 let h = 0;1037 while (used.has(h) && h < HUES) h++;1038 if (h === HUES) {1039 // planarity says this cannot happen; degrade to the least-used hue1040 const counts = new Array(HUES).fill(0);1041 for (const j of adjacency[i]) if (hues[j] !== 255) counts[hues[j]]++;1042 h = counts.indexOf(Math.min(...counts));1043 }1044 hues[i] = h;1045 }1046 return hues;1047}10481049/** Tile ids owning a cell (empty for a hole). */1050export function ownersOf(geo: Geometry, id: number): number[] {1051 const out: number[] = [];1052 for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {1053 out.push(geo.ownerList[i]);1054 }1055 return out;1056}10571058export function ownerCount(geo: Geometry, id: number): number {1059 return geo.ownerStart[id + 1] - geo.ownerStart[id];1060}10611062/** A hole: a cell no tile covers (a v2 filler-zone cell is one too). */1063export function isHole(geo: Geometry, id: number): boolean {1064 return ownerCount(geo, id) === 0;1065}10661067/** Hues of a cell's owners, for the colour mix. */1068export function hueOf(geo: Geometry, id: number): number[] {1069 const out: number[] = [];1070 for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {1071 out.push(geo.hues[geo.ownerList[i]]);1072 }1073 return out;1074}10751076/** Tile ids sharing a cell with t, from its overlaps. */1077export function neighboursOf(t: TileSpec): number[] {1078 return t.overlaps.map((o) => o.with).sort((a, b) => a - b);1079}10801081/** v2 filler-zone cells with their values (none on a v3 manifest). */1082export function zoneCells(m: Manifest): [number, 0 | 1][] {1083 const out: [number, 0 | 1][] = [];1084 for (const z of m.zones ?? []) {1085 let k = 0;1086 for (const [x0, y0, w, h] of z.rects) {1087 for (let y = y0; y < y0 + h; y++) {1088 for (let x = x0; x < x0 + w; x++) {1089 const ch = z.values?.[k++];1090 out.push([cellId(x, y, m.G), ch === "1" ? 1 : 0]);1091 }1092 }1093 }1094 }1095 return out;1096}10971098// -------------------------------------------------------------------- fold10991100export function emptyAnalysis(): Analysis {1101 return {1102 revision: -1,1103 guess_rate: 0,1104 reveal_commit: 0,1105 retractions: 0,1106 reward_per_batch: 0,1107 batch_size_mean: 0,1108 batch_size_recent: 0,1109 batch_size_drift: 0,1110 mean_gap_ms: null,1111 solved_tiles: 0,1112 solved_cells: 0,1113 meets: 0,1114 agents: [],1115 k: "unmeasured",1116 };1117}11181119export function emptyState(): State {1120 return {1121 game_id: "",1122 revision: -1,1123 manifest: null,1124 geo: null,1125 G: 0,1126 seed_tile: 0,1127 rules: { guess: "accept", depth: 0 },1128 tiles: [],1129 cells: cellsFacade(emptyStore()),1130 cellStore: emptyStore(),1131 knownCount: new Uint32Array(0),1132 solvedTiles: 0,1133 solvedCells: 0,1134 v1X: 0,1135 console: [],1136 status: "playing",1137 totals: {1138 reveals: 0,1139 commits: 0,1140 retracts: 0,1141 forced: 0,1142 guess: 0,1143 wrong: 0,1144 retracted: 0,1145 reward: 0,1146 },1147 last_t: null,1148 agents: [],1149 tileAgent: [],1150 tileRevealer: [],1151 meets: [],1152 reached: new Uint8Array(0),1153 grown: 0,1154 seedRings: [],1155 exhausted: false,1156 honours: [],1157 day: null,1158 analysis: emptyAnalysis(),1159 agentStats: {},1160 machines: {},1161 batchSizes: [],1162 gaps: [],1163 };1164}11651166/** The agent record, or undefined. */1167export function agentOf(s: State, id: string): AgentInfo | undefined {1168 return s.agents.find((a) => a.id === id);1169}11701171/** Union-find root of an agent's territory group. */1172export function groupOf(s: State, agent: string): string {1173 return agentOf(s, agent)?.group ?? agent;1174}11751176/** Agents whose territory has merged with this agent's. */1177export function groupMembers(s: State, agent: string): string[] {1178 const g = groupOf(s, agent);1179 return s.agents.filter((a) => a.group === g).map((a) => a.id);1180}11811182function tileInGroup(s: State, tile: number, group: string): boolean {1183 const a = s.tileAgent[tile];1184 return a !== null && a !== undefined && groupOf(s, a) === group;1185}11861187/**1188 * Frontier of one agent: unsolved tiles overlapping a tile solved by its1189 * group, plus its own seed tile while unsolved. Without an agent: the union1190 * over every agent plus every seed tile nobody has claimed yet (the1191 * manifest's seed tile and `seed_tiles`).1192 */1193export function frontierOf(s: State, agent?: string): number[] {1194 if (!s.manifest || !s.geo) return [];1195 const out: number[] = [];1196 if (agent !== undefined) {1197 const a = agentOf(s, agent);1198 const group = groupOf(s, agent);1199 // another agent's seed tile is reserved for it until it reveals it1200 const reserved = new Set(1201 s.agents.filter((x) =>1202 x.id !== agent && s.tiles[x.seed_tile] === "hidden"1203 )1204 .map((x) => x.seed_tile),1205 );1206 for (const t of s.manifest.tiles) {1207 if (s.tiles[t.id] === "solved") continue;1208 const isSeed = a !== undefined && t.id === a.seed_tile;1209 if (!isSeed && reserved.has(t.id)) continue;1210 const near = s.geo.adjacency[t.id].some((u) => tileInGroup(s, u, group));1211 if (isSeed || near) out.push(t.id);1212 }1213 return out;1214 }1215 const seeds = new Set<number>([1216 s.seed_tile,1217 ...(s.manifest.seed_tiles ?? []),1218 ]);1219 for (const a of s.agents) seeds.add(a.seed_tile);1220 for (const t of s.manifest.tiles) {1221 if (s.tiles[t.id] === "solved") continue;1222 const nearSolved = s.geo.adjacency[t.id].some((u) =>1223 s.tiles[u] === "solved"1224 );1225 if (seeds.has(t.id) || nearSolved) out.push(t.id);1226 }1227 return out;1228}12291230export function isOnFrontier(s: State, tile: number, agent?: string): boolean {1231 return frontierOf(s, agent).includes(tile);1232}12331234/**1235 * Seed tiles available to a joining agent, in order of preference: the1236 * manifest's unclaimed seed tiles first (one per chunk on a field), then1237 * every hidden tile that overlaps no revealed or solved tile.1238 */1239export function joinCandidates(s: State): number[] {1240 if (!s.manifest || !s.geo) return [];1241 const claimed = new Set(s.agents.map((a) => a.seed_tile));1242 // seats reserved for yesterday's winners are not handed to anyone else1243 for (const h of s.honours) claimed.add(h.seed_tile);1244 // newest ring first: a late joiner starts where the field just grew1245 const rings = s.seedRings.length1246 ? [...s.seedRings].reverse()1247 : [[s.manifest.seed_tile, ...(s.manifest.seed_tiles ?? [])]];1248 for (const ring of rings) {1249 const listed = ring.filter((t, i, arr) => arr.indexOf(t) === i);1250 const first = listed.filter((t) =>1251 !claimed.has(t) && s.tiles[t] === "hidden" &&1252 s.geo!.adjacency[t].every((u) => s.tiles[u] === "hidden")1253 );1254 if (first.length) return first;1255 }1256 const out: number[] = [];1257 for (const t of s.manifest.tiles) {1258 if (s.tiles[t.id] !== "hidden" || claimed.has(t.id)) continue;1259 if (s.geo.adjacency[t.id].every((u) => s.tiles[u] === "hidden")) {1260 out.push(t.id);1261 }1262 }1263 return out;1264}12651266/**1267 * Mark a cell known (no-op if it already is), keeping the per-tile known1268 * counts, and return the owner tiles so settle can check just those.1269 */1270function markKnown(s: State, w: StoreWriter, id: number, info: CellInfo): void {1271 if (w.get(id) !== undefined) return;1272 w.set(id, info);1273 const geo = s.geo!;1274 for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {1275 s.knownCount[geo.ownerList[i]]++;1276 }1277}12781279function unmarkKnown(s: State, w: StoreWriter, id: number): boolean {1280 if (!w.delete(id)) return false;1281 const geo = s.geo!;1282 for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {1283 s.knownCount[geo.ownerList[i]]--;1284 }1285 return true;1286}12871288/** BFS distance over the overlap graph from one tile. */1289export function distancesFrom(geo: Geometry, from: number): number[] {1290 const dist = new Array(geo.adjacency.length).fill(-1);1291 dist[from] = 0;1292 const q = [from];1293 for (let i = 0; i < q.length; i++) {1294 const t = q[i];1295 for (const u of geo.adjacency[t]) {1296 if (dist[u] < 0) {1297 dist[u] = dist[t] + 1;1298 q.push(u);1299 }1300 }1301 }1302 return dist;1303}13041305function newAgentStats(geo: Geometry, seed: number, hkey: string): AgentStats {1306 return {1307 reveals: 0,1308 commits: 0,1309 forced: 0,1310 guess: 0,1311 wrong: 0,1312 retracts: 0,1313 retracted: 0,1314 tiles: 0,1315 cells: 0,1316 meets: 0,1317 bfs: 0,1318 dfs: 0,1319 greedy: 0,1320 last_solved: null,1321 seed_dist: distancesFrom(geo, seed),1322 hkey,1323 };1324}13251326/** The seed-dependent part of an agent's stats (moves on a reseat). */1327function seedStats(geo: Geometry, seed: number): Pick<AgentStats, "seed_dist"> {1328 return { seed_dist: distancesFrom(geo, seed) };1329}13301331/** Register an agent in the state (mutates s). */1332function addAgent(1333 s: State,1334 info: Omit<AgentInfo, "group" | "health">,1335): AgentInfo {1336 const hkey = machineKeyOf(info);1337 if (!s.machines[hkey]) s.machines = { ...s.machines, [hkey]: newWindow() };1338 const a: AgentInfo = {1339 ...info,1340 group: info.id,1341 health: healthOfWindow(s.machines[hkey]),1342 };1343 s.agents = [...s.agents, a];1344 s.agentStats = {1345 ...s.agentStats,1346 [a.id]: newAgentStats(s.geo!, a.seed_tile, hkey),1347 };1348 return a;1349}13501351/** The agent an event belongs to, creating the implicit one on old logs. */1352function ensureAgent(s: State, id: string | undefined): string {1353 const agent = id ?? IMPLICIT_AGENT;1354 if (!agentOf(s, agent)) {1355 addAgent(s, {1356 id: agent,1357 name: agent,1358 harness: "unknown",1359 model: null,1360 tags: {},1361 verified: false,1362 machine_id: null,1363 joined: 0,1364 seed_tile: s.seed_tile,1365 });1366 }1367 return agent;1368}13691370/**1371 * Recompute solved status after a write by `agent`; newly solved tiles are1372 * attributed to it. Returns the tiles, their reward, and the meetings: a1373 * newly solved tile touching a tile solved by another group merges the1374 * groups (the agent's group takes the other's root).1375 */1376function settle(1377 s: State,1378 agent: string,1379 rev: number,1380 candidates: Iterable<number>,1381): { newly: number[]; reward: number; meets: Meet[] } {1382 const newly: number[] = [];1383 const meets: Meet[] = [];1384 let reward = 0;1385 if (!s.manifest) return { newly, reward, meets };1386 const seen = new Set<number>();1387 for (const id of candidates) {1388 if (seen.has(id)) continue;1389 seen.add(id);1390 if (s.tiles[id] !== "revealed") continue;1391 const t = s.manifest.tiles[id];1392 if (s.knownCount[id] === t.cells) {1393 s.tiles[id] = "solved";1394 s.tileAgent[id] = agent;1395 newly.push(id);1396 reward += t.cells;1397 }1398 }1399 newly.sort((a, b) => a - b);1400 s.solvedTiles += newly.length;1401 s.solvedCells += reward;1402 if (newly.length) {1403 const st = s.agentStats[agent];1404 st.tiles += newly.length;1405 st.cells += reward;1406 st.last_solved = newly[newly.length - 1];1407 }1408 for (const t of newly) {1409 for (const u of s.geo!.adjacency[t]) {1410 const other = s.tileAgent[u];1411 if (other === null || other === undefined) continue;1412 const ga = groupOf(s, agent), gb = groupOf(s, other);1413 if (ga === gb) continue;1414 // merge: everyone in the agent's group joins the other's root1415 s.agents = s.agents.map((x) => x.group === ga ? { ...x, group: gb } : x);1416 meets.push({ with: other, tile: t });1417 s.meets = [...s.meets, { rev, agents: [agent, other], tile: t }];1418 s.agentStats[agent].meets++;1419 s.agentStats[other].meets++;1420 }1421 }1422 return { newly, reward, meets };1423}14241425function dt(s: State, t: string): number | null {1426 if (s.last_t === null) return null;1427 return new Date(t).getTime() - new Date(s.last_t).getTime();1428}14291430function mean(xs: number[]): number {1431 return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;1432}14331434function classify(st: AgentStats): Strategy {1435 if (st.reveals === 0) return "none";1436 const shares = [1437 ["bfs", st.bfs / st.reveals],1438 ["dfs", st.dfs / st.reveals],1439 ["greedy", st.greedy / st.reveals],1440 ] as const;1441 const top = shares.reduce((a, b) => (b[1] > a[1] ? b : a));1442 if (st.reveals < 3 || top[1] < 0.6) return "mixed";1443 return top[0];1444}14451446/** Recompute the analysis from the state's counters (mutates s.analysis). */1447function analyse(s: State): void {1448 const t = s.totals;1449 const solvedCells = s.solvedCells, solvedTiles = s.solvedTiles;1450 const recent = s.batchSizes.slice(-10);1451 const agents: AgentAnalysis[] = s.agents.map((a) => {1452 const st = s.agentStats[a.id];1453 return {1454 agent: a.id,1455 group: a.group,1456 tiles: st.tiles,1457 cells: st.cells,1458 reward: st.cells,1459 reveals: st.reveals,1460 commits: st.commits,1461 forced: st.forced,1462 guess: st.guess,1463 wrong: st.wrong,1464 retracts: st.retracts,1465 retracted: st.retracted,1466 territory: solvedCells ? st.cells / solvedCells : 0,1467 meets: st.meets,1468 strategy: classify(st),1469 strategy_counts: {1470 bfs: st.bfs,1471 dfs: st.dfs,1472 greedy: st.greedy,1473 reveals: st.reveals,1474 },1475 last_solved: st.last_solved,1476 health: healthOf(s, a.id),1477 };1478 });1479 s.analysis = {1480 revision: s.revision,1481 guess_rate: t.forced + t.guess ? t.guess / (t.forced + t.guess) : 0,1482 reveal_commit: t.commits ? t.reveals / t.commits : 0,1483 retractions: t.retracts,1484 reward_per_batch: t.commits ? t.reward / t.commits : 0,1485 batch_size_mean: mean(s.batchSizes),1486 batch_size_recent: mean(recent),1487 batch_size_drift: mean(recent) - mean(s.batchSizes),1488 mean_gap_ms: s.gaps.length ? mean(s.gaps) : null,1489 solved_tiles: solvedTiles,1490 solved_cells: solvedCells,1491 meets: s.meets.length,1492 agents,1493 k: s.manifest?.k ?? "unmeasured",1494 ...(s.manifest?.k_hist ? { k_hist: s.manifest.k_hist } : {}),1495 };1496}14971498/**1499 * Score a reveal for the strategy classifier, against the agent's frontier1500 * as it was before the reveal: bfs when the tile is at the minimal seed1501 * distance available, dfs when it touches the agent's last solved tile,1502 * greedy when it is the largest available. A reveal can count for several.1503 */1504function scoreReveal(1505 s: State,1506 agent: string,1507 tile: number,1508 frontier: number[],1509) {1510 const st = s.agentStats[agent];1511 const m = s.manifest!;1512 st.reveals++;1513 const dist = st.seed_dist;1514 const minDist = Math.min(1515 ...frontier.map((t) => (dist[t] < 0 ? Infinity : dist[t])),1516 );1517 if (dist[tile] >= 0 && dist[tile] === minDist) st.bfs++;1518 if (1519 st.last_solved !== null &&1520 s.geo!.adjacency[st.last_solved].includes(tile)1521 ) st.dfs++;1522 const maxCells = Math.max(...frontier.map((t) => m.tiles[t].cells));1523 if (m.tiles[tile].cells === maxCells) st.greedy++;1524}15251526/** Apply one event, returning a new state. The input is not mutated. */1527/** Recompute `reached` (see the contract) from tiles, adjacency and seeds. */1528function computeReached(s: State): Uint8Array {1529 const n = s.tiles.length;1530 const out = new Uint8Array(n);1531 const geo = s.geo!;1532 for (let t = 0; t < n; t++) {1533 if (s.tiles[t] === "hidden") continue;1534 out[t] = 1;1535 for (const u of geo.adjacency[t]) out[u] = 1;1536 }1537 for (const a of s.agents) if (a.seed_tile < n) out[a.seed_tile] = 1;1538 for (const h of s.honours) if (h.seed_tile < n) out[h.seed_tile] = 1;1539 return out;1540}15411542/** Whether the tile is reached by one group: revealed/solved by it, next to a tile it revealed/solved, or its member's seed. */1543export function reachedByGroup(s: State, tile: number, group: string): boolean {1544 const mine = (t: number) => {1545 const r = s.tileRevealer[t], a = s.tileAgent[t];1546 return (r !== null && r !== undefined && groupOf(s, r) === group) ||1547 (a !== null && a !== undefined && groupOf(s, a) === group);1548 };1549 if (mine(tile)) return true;1550 if (1551 s.agents.some((x) => groupOf(s, x.id) === group && x.seed_tile === tile)1552 ) {1553 return true;1554 }1555 return s.geo!.adjacency[tile].some(mine);1556}15571558export function applyEvent(prev: State, ev: GameEvent): State {1559 const w = new StoreWriter(prev.cellStore);1560 const s: State = {1561 ...prev,1562 tiles: [...prev.tiles],1563 knownCount: new Uint32Array(prev.knownCount),1564 console: [...prev.console],1565 totals: { ...prev.totals },1566 tileAgent: [...prev.tileAgent],1567 tileRevealer: [...prev.tileRevealer],1568 agentStats: Object.fromEntries(1569 Object.entries(prev.agentStats).map(([k, v]) => [k, {1570 ...v,1571 seed_dist: v.seed_dist,1572 }]),1573 ),1574 batchSizes: [...prev.batchSizes],1575 gaps: [...prev.gaps],1576 machines: { ...prev.machines },1577 };1578 if (ev.rev !== prev.revision + 1) {1579 throw new Error(1580 `event rev ${ev.rev} does not follow revision ${prev.revision}`,1581 );1582 }1583 s.revision = ev.rev;1584 // the live broadcast under fog: tiles that came into view with this1585 // event arrive as `reach` (full specs for ids the manifest listed as1586 // placeholders); they are filled in before the event body applies1587 const reach = (ev as { reach?: TileSpec[] }).reach;1588 if (reach && reach.length) {1589 const m = s.manifest!;1590 const tiles = [...m.tiles];1591 for (const t of reach) {1592 if (t.id < 0 || t.id >= tiles.length) {1593 throw new Error(`reach: tile ${t.id} is not in the manifest`);1594 }1595 tiles[t.id] = t;1596 }1597 s.manifest = { ...m, tiles };1598 s.geo = recolourTiles(1599 extendGeometry(s.geo!, s.manifest),1600 reach.map((t) => t.id),1601 );1602 for (const a of s.agents) {1603 s.agentStats[a.id] = {1604 ...s.agentStats[a.id],1605 seed_dist: distancesFrom(s.geo, a.seed_tile),1606 };1607 }1608 }1609 const base = {1610 rev: ev.rev,1611 t: ev.t,1612 accepted: 0,1613 forced: 0,1614 guess: 0,1615 rejected: 0,1616 ignored: 0,1617 retracted: 0,1618 revealed_cells: 0,1619 solved: [] as number[],1620 reward: 0,1621 dt_ms: dt(prev, ev.t),1622 };1623 switch (ev.kind) {1624 case "manifest": {1625 const m = normaliseManifest(ev.manifest, ev.seed_tile);1626 s.v1X = isV1(ev.manifest) ? ev.manifest.X : 0;1627 s.game_id = ev.game_id;1628 s.manifest = m;1629 s.geo = buildGeometry(m);1630 s.G = m.G;1631 s.seed_tile = m.seed_tile;1632 s.rules = ev.rules;1633 s.tiles = new Array(m.tiles.length).fill("hidden");1634 s.tileAgent = new Array(m.tiles.length).fill(null);1635 s.tileRevealer = new Array(m.tiles.length).fill(null);1636 s.knownCount = new Uint32Array(m.tiles.length);1637 s.seedRings = [1638 [m.seed_tile, ...(m.seed_tiles ?? [])].filter((t, i, a) =>1639 a.indexOf(t) === i1640 ),1641 ];1642 s.grown = 0;1643 s.exhausted = false;1644 s.honours = (ev.honours ?? []).filter((h) =>1645 Number.isInteger(h.seed_tile) && h.seed_tile >= 0 &&1646 h.seed_tile < m.tiles.length1647 );1648 s.day = ev.day ?? null;1649 for (const [id, v] of zoneCells(m)) {1650 markKnown(s, w, id, { v, kind: "zone", rev: 0 });1651 }1652 const sizes = m.tiles.map((t) => t.cells);1653 const seeds = 1 +1654 (m.seed_tiles?.filter((t) => t !== m.seed_tile).length ?? 0);1655 s.console.push({1656 ...base,1657 op: "start",1658 intent: `${m.tiles.length} tiles of ${Math.min(...sizes)}..${1659 Math.max(...sizes)1660 } cells (${m.packer.kind}), ${s.G}x${s.G} field, ${s.geo.holes} hole cells, ${seeds} seed tile${1661 seeds === 1 ? "" : "s"1662 }`,1663 });1664 break;1665 }1666 case "join": {1667 const existing = agentOf(s, ev.agent);1668 if (existing) {1669 // the same agent joining again: a reseat on a fresh seed tile1670 // (it had nowhere left to play); identity, group and counters1671 // stay, only the seed moves1672 s.agents = s.agents.map((a) =>1673 a.id === ev.agent ? { ...a, seed_tile: ev.seed_tile } : a1674 );1675 s.agentStats = {1676 ...s.agentStats,1677 [ev.agent]: {1678 ...s.agentStats[ev.agent],1679 ...seedStats(s.geo!, ev.seed_tile),1680 },1681 };1682 s.console.push({1683 ...base,1684 op: "join",1685 agent: ev.agent,1686 tile: ev.seed_tile,1687 intent: `${existing.name} reseated at seed tile #${ev.seed_tile}`,1688 });1689 break;1690 }1691 addAgent(s, {1692 id: ev.agent,1693 name: ev.name ?? ev.agent,1694 harness: ev.harness,1695 model: ev.model ?? null,1696 tags: ev.tags ?? {},1697 verified: ev.verified === true,1698 machine_id: ev.verified === true ? ev.machine_id ?? null : null,1699 joined: ev.rev,1700 seed_tile: ev.seed_tile,1701 });1702 s.console.push({1703 ...base,1704 op: "join",1705 agent: ev.agent,1706 tile: ev.seed_tile,1707 intent: `${ev.name ?? ev.agent} joined (${ev.harness}${1708 ev.model ? ", " + ev.model : ""1709 }) at seed tile #${ev.seed_tile}`,1710 });1711 break;1712 }1713 case "batch": {1714 const agent = ensureAgent(s, ev.agent);1715 const st = s.agentStats[agent];1716 if (ev.op === "reveal") {1717 const tile = tileIdOf(s, ev.tile);1718 const frontier = frontierOf(s, agent); // before the reveal lands1719 s.tiles[tile] = "revealed";1720 s.tileRevealer[tile] = agent;1721 const touched: number[] = [tile];1722 for (const [id, v] of ev.givens) {1723 markKnown(s, w, id, { v, kind: "given", rev: ev.rev });1724 touched.push(...ownersOf(s.geo!, id));1725 }1726 s.totals.reveals++;1727 scoreReveal(s, agent, tile, frontier.length ? frontier : [tile]);1728 const { newly, reward, meets } = settle(s, agent, ev.rev, touched);1729 s.totals.reward += reward;1730 s.console.push({1731 ...base,1732 op: "reveal",1733 agent,1734 intent: ev.intent,1735 tile,1736 revealed_cells: ev.givens.length + ev.seam.length,1737 solved: newly,1738 reward,1739 ...(meets.length ? { meets } : {}),1740 });1741 } else if (ev.op === "commit") {1742 const touched: number[] = [];1743 for (const c of ev.accepted) {1744 markKnown(s, w, c.id, {1745 v: c.v,1746 kind: "commit",1747 class: c.class,1748 rules: c.rules,1749 rev: ev.rev,1750 });1751 touched.push(...ownersOf(s.geo!, c.id));1752 if (c.class === "forced") {1753 s.totals.forced++;1754 st.forced++;1755 } else {1756 s.totals.guess++;1757 st.guess++;1758 }1759 }1760 s.totals.commits++;1761 st.commits++;1762 const wrong = ev.rejected.filter((r) => r.reason === "wrong").length;1763 s.totals.wrong += wrong;1764 st.wrong += wrong;1765 // the machine's window: correct cells count once per machine, wrong1766 // ones always; death is sticky and shared by the machine's agents1767 const win = cloneWindow(s.machines[st.hkey] ?? newWindow());1768 for (const c of ev.accepted) windowRecord(win, c.id, false);1769 for (const r of ev.rejected) {1770 if (r.reason === "wrong") windowRecord(win, r.id, true);1771 }1772 if (win.died_at === null && windowDead(win)) win.died_at = ev.rev;1773 s.machines[st.hkey] = win;1774 const health = healthOfWindow(win);1775 s.agents = s.agents.map((a) =>1776 s.agentStats[a.id]?.hkey === st.hkey ? { ...a, health } : a1777 );1778 s.batchSizes.push(ev.accepted.length);1779 if (base.dt_ms !== null) s.gaps.push(base.dt_ms);1780 const { newly, reward, meets } = settle(s, agent, ev.rev, touched);1781 s.totals.reward += reward;1782 s.console.push({1783 ...base,1784 op: "commit",1785 agent,1786 intent: ev.intent,1787 accepted: ev.accepted.length,1788 forced: ev.accepted.filter((c) => c.class === "forced").length,1789 guess: ev.accepted.filter((c) => c.class === "guess").length,1790 rejected: ev.rejected.length,1791 ignored: ev.ignored.length,1792 solved: newly,1793 reward,1794 ...(meets.length ? { meets } : {}),1795 });1796 } else if (ev.op === "retract") {1797 let n = 0;1798 for (const id of ev.cells) {1799 if (w.get(id)?.kind === "commit" && unmarkKnown(s, w, id)) n++;1800 }1801 s.totals.retracts++;1802 s.totals.retracted += n;1803 st.retracts++;1804 st.retracted += n;1805 s.console.push({1806 ...base,1807 op: "retract",1808 agent,1809 intent: ev.intent,1810 retracted: n,1811 });1812 }1813 break;1814 }1815 case "grow": {1816 const m = s.manifest!;1817 const before = m.tiles.length;1818 const bad = ev.tiles.find((t, i) => t.id !== before + i);1819 if (bad) {1820 throw new Error(1821 `grow: tile ids must continue from ${before}, got ${bad.id}`,1822 );1823 }1824 const tiles = [...m.tiles, ...ev.tiles];1825 s.manifest = {1826 ...m,1827 tiles,1828 seed_tiles: [...(m.seed_tiles ?? []), ...ev.seed_tiles],1829 ...(ev.field ? { field: ev.field } : {}),1830 };1831 s.geo = extendGeometry(s.geo!, s.manifest);1832 s.tiles = [...s.tiles, ...ev.tiles.map(() => "hidden" as TileStatus)];1833 s.tileAgent = [...s.tileAgent, ...ev.tiles.map(() => null)];1834 s.tileRevealer = [...s.tileRevealer, ...ev.tiles.map(() => null)];1835 const kc = new Uint32Array(tiles.length);1836 kc.set(s.knownCount);1837 s.knownCount = kc;1838 s.seedRings = [...s.seedRings, ev.seed_tiles];1839 s.grown++;1840 if (ev.exhausted) s.exhausted = true;1841 // distances from every seed change with the new tiles1842 for (const a of s.agents) {1843 s.agentStats[a.id] = {1844 ...s.agentStats[a.id],1845 seed_dist: distancesFrom(s.geo, a.seed_tile),1846 };1847 }1848 s.console.push({1849 ...base,1850 op: "grow",1851 intent: ev.tiles.length1852 ? `ring ${ev.ring}: ${ev.tiles.length} tiles grew around the field (${tiles.length} now), ${ev.seed_tiles.length} new seed tile${1853 ev.seed_tiles.length === 1 ? "" : "s"1854 }`1855 : `ring ${ev.ring}: no room left to grow`,1856 });1857 break;1858 }1859 case "end": {1860 s.status = "solved";1861 const done = s.tiles.filter((t) => t === "solved").length;1862 s.console.push({1863 ...base,1864 op: "end",1865 intent: done === s.tiles.length1866 ? "all tiles solved"1867 : `ended with ${done} of ${s.tiles.length} tiles solved`,1868 });1869 break;1870 }1871 }1872 s.reached = computeReached(s);1873 s.last_t = ev.t;1874 s.cellStore = w.store();1875 s.cells = cellsFacade(s.cellStore);1876 analyse(s);1877 return s;1878}18791880/** phase-1 logs name tiles as [tx, ty]; everything since uses the id. */1881function tileIdOf(s: State, tile: number): number {1882 const t = tile as unknown;1883 if (Array.isArray(t)) return (t[1] as number) * s.v1X + (t[0] as number);1884 return tile;1885}18861887export function fold(events: GameEvent[]): State {1888 let s = emptyState();1889 for (const ev of events) s = applyEvent(s, ev);1890 return s;1891}18921893/** States at every revision 0..N, for scrubbing. */1894export function foldAll(events: GameEvent[]): State[] {1895 const out: State[] = [];1896 let s = emptyState();1897 for (const ev of events) {1898 s = applyEvent(s, ev);1899 out.push(s);1900 }1901 return out;1902}19031904export function allSolved(s: State): boolean {1905 return s.tiles.length > 0 && s.tiles.every((t) => t === "solved");1906}19071908/** Mean cell count of the solved tiles (0 when none). */1909export function meanSolvedCells(s: State): number {1910 if (!s.manifest) return 0;1911 let sum = 0, k = 0;1912 for (const t of s.manifest.tiles) {1913 if (s.tiles[t.id] === "solved") {1914 sum += t.cells;1915 k++;1916 }1917 }1918 return k ? sum / k : 0;1919}19201921/** Delta-code a sorted id list (cumulative sum decodes it). */1922export function deltaEncode(ids: number[]): number[] {1923 const sorted = [...ids].sort((a, b) => a - b);1924 let prev = 0;1925 return sorted.map((id) => {1926 const d = id - prev;1927 prev = id;1928 return d;1929 });1930}19311932export function deltaDecode(deltas: number[]): number[] {1933 let acc = 0;1934 return deltas.map((d) => (acc += d));1935}19361937export function parseLog(text: string): GameEvent[] {1938 return text.split("\n").filter((l) => l.trim().length > 0).map((l) =>1939 JSON.parse(l) as GameEvent1940 );1941}