clone

a takuzu puzzle field for agents to explore tile by tile, with a referee and a replayable spectator view

main
mosaic / site / lib / games.ts
games.ts 14.8K
1// In-memory registry of games. A game starts as a pending entry while the2// engine generates its field in the background (generation is seconds to3// minutes; MCP calls must not block that long), then becomes a Referee.4// State is rebuilt from the JSONL on restart only for spectating (the5// private solution is not in the log, so a restarted server cannot6// continue a game; the demo is one process). Games nobody joined expire.7import {8 type GameRules,9 type GrowRules,10 type Honour,11 parseLog,12} from "./fold.ts";13import {14 type FieldOutput,15 gen,16 type GenOutput,17 type GenParams,18 grow as engineGrow,19 judge,20 mergeGrowth,21} from "./engine.ts";22import { type Judge, Referee } from "./referee.ts";23import { logPath, readLog } from "./log.ts";2425export type Pending = {26 status: "generating" | "failed";27 game_id: string;28 started: number; // ms since epoch29 params: GenParams;30 error?: string;31};3233type Ready = {34 kind: "ready";35 ref: Referee;36 started: number;37 /** the daily's game: never swept, ended only by rollover */38 pinned: boolean;39 /** the full private output so far (grows with the field) */40 priv: GenOutput;41 seed: string;42 grow: GrowRules | null;43 /** pre-generated growth rings, applied in order before any engine call */44 rings: GenOutput[];45 growing: boolean;46 exhausted: boolean;47 timer: number | null;48 lastGrow: number; // ms since epoch of the last growth (or start)49};50type Entry = { kind: "pending"; pending: Pending } | Ready;5152type NewGameListener = (id: string) => void;53type Registry = {54 games: Map<string, Entry>;55 latest: string | null;56 listeners: Set<NewGameListener>;57 sweeper: number | null;58};5960// Kept on globalThis so a dev-server module re-evaluation (HMR after an61// edit) does not lose the live games.62const g = globalThis as unknown as { __mosaicGames?: Registry };63const reg: Registry = g.__mosaicGames ??= {64 games: new Map(),65 latest: null,66 listeners: new Set(),67 sweeper: null,68};69const games = reg.games;70const newGameListeners = reg.listeners;7172/** Games nobody joined are dropped after this long. */73export const EXPIRE_MS = 30 * 60 * 1000;7475/** The referee of a ready game; undefined while generating or unknown. */76export function getGame(id: string): Referee | undefined {77 const e = games.get(id);78 return e?.kind === "ready" ? e.ref : undefined;79}8081/** Status of any known game: generating, failed, or the ready referee. */82export function getEntry(id: string): Pending | Referee | undefined {83 const e = games.get(id);84 if (!e) return undefined;85 return e.kind === "ready" ? e.ref : e.pending;86}8788export function latestGameId(): string | null {89 return reg.latest;90}9192export function onNewGame(l: NewGameListener): () => void {93 newGameListeners.add(l);94 return () => newGameListeners.delete(l);95}9697export type StartParams = {98 G: number;99 kind: GenParams["kind"];100 n?: number;101 chunks?: number;102 chunk_g?: number;103 seed?: string;104 rules?: Partial<GameRules>;105 /** kind=field: growth policy */106 grow?: GrowRules;107 /** kind=field: allocate the field at this side with the initial chunks108 * centred, so rings can grow around them (1000 for the daily) */109 field_g?: number;110 /** kind=field: pre-generate this many rings at start ("all" = until111 * full); growth then reveals a stored ring, the engine is not run by day */112 rings?: number | "all";113 /** the daily: yesterday's winners with reserved seats */114 honours?: Honour[];115 /** the daily: the date this field is the board of */116 day?: string;117 /** the daily: never swept */118 pinned?: boolean;119 /** use this id instead of a random one (the daily names its game) */120 game_id?: string;121};122123export type GameSummary = {124 game_id: string;125 status: "generating" | "failed" | "playing" | "solved";126 revision: number;127 G: number;128 tiles: number;129 agents: string[];130 grown?: number;131 growing?: boolean;132 rings_left?: number;133 fog?: boolean;134 /** the daily: the date this field is the board of */135 day?: string;136 error?: string;137};138139/** Every known game, newest first. */140export function listGames(): GameSummary[] {141 return [...games.values()].reverse().map((e) =>142 e.kind === "ready"143 ? {144 game_id: e.ref.gameId,145 status: e.ref.state.status,146 revision: e.ref.state.revision,147 G: e.ref.state.G,148 tiles: e.ref.manifest.tiles.length,149 agents: e.ref.state.agents.map((a) => a.id),150 grown: e.ref.state.grown,151 growing: e.growing,152 rings_left: e.rings?.length ?? 0, // entries from before this field existed153 fog: e.ref.fog,154 ...(e.ref.state.day ? { day: e.ref.state.day } : {}),155 }156 : {157 game_id: e.pending.game_id,158 status: e.pending.status,159 revision: -1,160 G: e.pending.params.G,161 tiles: 0,162 agents: [],163 ...(e.pending.error ? { error: e.pending.error } : {}),164 }165 );166}167168export type Generator = (p: GenParams) => Promise<FieldOutput>;169170/**171 * Start a game: returns at once with a generating entry; the field is172 * generated in the background and the entry becomes a Referee when ready173 * (`onNewGame` fires then). `generate` is injectable for tests.174 */175export function startGame(176 p: StartParams,177 generate: Generator = gen,178 now: () => number = Date.now,179 judgeFn: Judge = judge,180): Pending {181 const gameId = p.game_id ?? crypto.randomUUID();182 const seed = p.seed ?? gameId;183 const growing = p.kind === "field" && !!p.grow &&184 (p.grow.every_minutes !== undefined ||185 p.grow.frontier_coverage !== undefined);186 const params: GenParams = {187 seed,188 G: p.G,189 kind: p.kind,190 n: p.n,191 chunks: p.chunks,192 chunk_g: p.chunk_g,193 ...(p.kind === "field" && p.field_g ? { field_g: p.field_g } : {}),194 ...(p.kind === "field" && growing && p.rings !== undefined195 ? { rings: p.rings }196 : {}),197 };198 const pending: Pending = {199 status: "generating",200 game_id: gameId,201 started: now(),202 params,203 };204 games.set(gameId, { kind: "pending", pending });205 ensureSweeper();206 const rules: GameRules = {207 guess: p.rules?.guess ?? "accept",208 depth: p.rules?.depth ?? 0,209 fog: p.rules?.fog ?? (p.kind === "field"),210 identity: "required",211 ...(growing ? { grow: p.grow } : {}),212 };213 (async () => {214 try {215 const out = await generate(params);216 const { rings = [], ...base } = out;217 const ref = await Referee.create(gameId, base, rules, judgeFn, {218 seed,219 honours: p.honours,220 day: p.day,221 });222 if (!games.has(gameId)) return; // expired meanwhile223 const entry: Ready = {224 kind: "ready",225 ref,226 started: pending.started,227 pinned: p.pinned ?? false,228 priv: base,229 seed,230 grow: growing ? p.grow! : null,231 rings: [...rings],232 growing: false,233 exhausted: false,234 timer: null,235 lastGrow: now(),236 };237 games.set(gameId, entry);238 reg.latest = gameId;239 wireGrowth(gameId, entry, now);240 for (const l of newGameListeners) l(gameId);241 } catch (e) {242 pending.status = "failed";243 pending.error = e instanceof Error ? e.message : String(e);244 }245 })();246 return pending;247}248249export type Grower = (250 state: GenOutput,251 seed: string,252 ring: number,253) => Promise<GenOutput>;254255/** Test hook: the growth engine call (the real one runs `field --grow`). */256export let grower: Grower = engineGrow;257export function setGrower(g: Grower): void {258 grower = g;259}260261/** Whether growth may run the engine when no pre-generated ring is262 * stored. Off by default even where generation is allowed: the day's263 * rings are made the night before, and daytime engine work is what a264 * busy box does not need (MOSAIC_GROW_ON_DEMAND=1 turns it on; tests265 * set a grower). */266function growthAllowed(): boolean {267 return grower !== engineGrow || Deno.env.get("MOSAIC_GROW_ON_DEMAND") === "1";268}269270/**271 * Growth policy: a timer (`every_minutes`), a coverage threshold checked272 * after every write (`frontier_coverage`: share of the current tiles273 * revealed or solved), and always when every current tile is solved.274 * Growth runs in the background; the `grow` event lands between batches275 * under the referee's write lock. Stops when a ring finds no room.276 */277function wireGrowth(id: string, e: Ready, now: () => number): void {278 if (!e.grow) return;279 e.ref.canGrow = () => !e.exhausted;280 e.ref.onNeedGrow = () => {281 growGame(id).catch(() => {});282 };283 if (e.grow.every_minutes) {284 const ms = Math.max(5, e.grow.every_minutes * 60) * 1000;285 e.timer = setInterval(() => {286 if (now() - e.lastGrow >= ms - 1) growGame(id).catch(() => {});287 }, Math.min(ms, 15000));288 Deno.unrefTimer(e.timer);289 }290 if (e.grow.frontier_coverage !== undefined) {291 const threshold = e.grow.frontier_coverage;292 e.ref.log.subscribe((ev) => {293 if (ev.kind !== "batch" || e.growing || e.exhausted) return;294 const st = e.ref.state;295 const n = st.tiles.length;296 const active = st.tiles.filter((t) => t !== "hidden").length;297 if (n > 0 && active / n >= threshold) growGame(id).catch(() => {});298 });299 }300}301302/**303 * Grow a field by one ring now (idempotent while a growth is running): the304 * next pre-generated ring if any is stored (the daily: rings are made the305 * night before and revealed by day), else the engine on demand.306 */307export async function growGame(id: string): Promise<boolean> {308 const e = games.get(id);309 if (!e || e.kind !== "ready" || !e.grow || e.growing || e.exhausted) {310 return false;311 }312 if (e.ref.state.status === "solved") return false;313 if (!e.rings?.length && !growthAllowed()) {314 // nothing pre-generated is left and this box may not generate: the315 // field is as big as it gets316 e.exhausted = true;317 if (e.timer !== null) clearInterval(e.timer);318 e.timer = null;319 return false;320 }321 e.growing = true;322 try {323 const ring = e.ref.state.grown + 1;324 const delta = e.rings?.length325 ? e.rings.shift()!326 : await grower(e.priv, e.seed, ring);327 const ev = await e.ref.grow(delta, ring);328 e.priv = mergeGrowth(e.priv, delta);329 e.lastGrow = Date.now();330 if (ev.exhausted) {331 e.exhausted = true;332 if (e.timer !== null) clearInterval(e.timer);333 e.timer = null;334 }335 return true;336 } finally {337 e.growing = false;338 }339}340341/**342 * Resume a game from its log after a restart: the private output comes343 * back from the generator cache (deterministic per seed), the log is344 * folded without being re-appended, and every grow in the log is paired345 * with its stored ring (or regenerated on demand). Returns null when the346 * log is missing or the game had already ended.347 */348export async function resumeGame(349 gameId: string,350 p: StartParams & { seed: string },351 generate: Generator = gen,352 now: () => number = Date.now,353 judgeFn: Judge = judge,354): Promise<Referee | null> {355 const text = await readLog(gameId);356 if (text === null) return null;357 const events = parseLog(text);358 if (events.length === 0 || events.at(-1)!.kind === "end") return null;359 const growing = p.kind === "field" && !!p.grow &&360 (p.grow.every_minutes !== undefined ||361 p.grow.frontier_coverage !== undefined);362 const params: GenParams = {363 seed: p.seed,364 G: p.G,365 kind: p.kind,366 n: p.n,367 chunks: p.chunks,368 chunk_g: p.chunk_g,369 ...(p.kind === "field" && p.field_g ? { field_g: p.field_g } : {}),370 ...(p.kind === "field" && growing && p.rings !== undefined371 ? { rings: p.rings }372 : {}),373 };374 const out = await generate(params);375 const { rings = [], ...base } = out;376 const grows = events.filter((e) => e.kind === "grow").length;377 const deltas: GenOutput[] = [];378 let priv = base;379 for (let k = 0; k < grows; k++) {380 const delta = k < rings.length381 ? rings[k]382 : await grower(priv, p.seed, k + 1);383 deltas.push(delta);384 priv = mergeGrowth(priv, delta);385 }386 const ref = Referee.resume(gameId, base, judgeFn, events, deltas, {387 seed: p.seed,388 });389 const entry: Ready = {390 kind: "ready",391 ref,392 started: now(),393 pinned: p.pinned ?? false,394 priv,395 seed: p.seed,396 grow: growing ? p.grow! : null,397 rings: rings.slice(grows),398 growing: false,399 exhausted: ref.state.exhausted,400 timer: null,401 lastGrow: now(),402 };403 games.set(gameId, entry);404 reg.latest = gameId;405 wireGrowth(gameId, entry, now);406 for (const l of newGameListeners) l(gameId);407 return ref;408}409410/** End a game now (the daily's rollover); stops its growth timer. */411export async function endGame(id: string): Promise<boolean> {412 const e = games.get(id);413 if (!e || e.kind !== "ready") return false;414 if (e.timer !== null) clearInterval(e.timer);415 e.timer = null;416 e.exhausted = true;417 return await e.ref.end();418}419420/** Forget a game (after archiving); its log stays on disk. */421export function dropGame(id: string): void {422 const e = games.get(id);423 if (e?.kind === "ready" && e.timer !== null) clearInterval(e.timer);424 games.delete(id);425 if (reg.latest === id) reg.latest = null;426}427428/** Wait for a starting game to be ready (tests and scripts). */429export async function whenReady(430 id: string,431 timeoutMs = 600000,432): Promise<Referee> {433 const t0 = Date.now();434 while (Date.now() - t0 < timeoutMs) {435 const e = games.get(id);436 if (!e) throw new Error(`game ${id} vanished`);437 if (e.kind === "ready") return e.ref;438 if (e.pending.status === "failed") {439 throw new Error(`generation failed: ${e.pending.error}`);440 }441 await new Promise((r) => setTimeout(r, 50));442 }443 throw new Error(`game ${id} not ready after ${timeoutMs} ms`);444}445446/**447 * Drop games older than EXPIRE_MS that nobody joined (and failed ones),448 * deleting a log that holds only the manifest. Returns the dropped ids.449 */450export async function sweep(now = Date.now()): Promise<string[]> {451 const dropped: string[] = [];452 for (const [id, e] of games) {453 const age = now - (e.kind === "ready" ? e.started : e.pending.started);454 if (age < EXPIRE_MS) continue;455 if (e.kind === "ready") {456 if (e.pinned || e.ref.state.agents.length > 0) continue;457 if (e.ref.log.events.length <= 1) {458 try {459 await Deno.remove(logPath(id));460 } catch {461 // never persisted, or already gone462 }463 }464 } else if (e.pending.status === "generating") {465 continue; // still working; it will be judged once ready466 }467 if (e.kind === "ready" && e.timer !== null) clearInterval(e.timer);468 games.delete(id);469 if (reg.latest === id) reg.latest = null;470 dropped.push(id);471 }472 return dropped;473}474475function ensureSweeper(): void {476 if (reg.sweeper !== null) return;477 reg.sweeper = setInterval(() => {478 sweep().catch(() => {});479 }, 60 * 1000);480 // do not keep the process alive for the sweeper alone481 Deno.unrefTimer(reg.sweeper);482}483484/** Test hook: forget every game. */485export function resetGames(): void {486 for (const e of games.values()) {487 if (e.kind === "ready" && e.timer !== null) clearInterval(e.timer);488 }489 games.clear();490 reg.latest = null;491}