clone

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

site: asynchronous start, precise rules text, rejection detail, expiry

dami committed d6328fd
start_mosaic returns {game_id, status: generating} at once and the field
generates in the background; observe and list_games report generating |
failed | playing | solved; join on a generating game is refused with a
retry hint; games nobody joined expire after 30 min (manifest-only logs
deleted). Rules text now states the referee's propagation exactly (run in
three positions, balance count, uniqueness in both forms, single-cell
fixpoint, no distinct-line counting), that one solved neighbour need not
pin a tile, that observe {agent} lists hidden shapes and m by design, and
that the transport is stateless (no Mcp-Session-Id; initialized returns
202). Rejected commits carry {id, reason, v}. solve.ts polls until the
field is playing.
commit
d6328fd2223862028f90a78469fcdb0054aecc11
parent
63588089b01ab4368223ebfddc38e613e1637bda
author
dami <gazey55@gmail.com>
6 files changed, +314 -63
Msite/lib/fold.ts2+-
Msite/lib/games.ts188++++++++++++++++++++++++++----
Msite/lib/mcp.ts72+++++++----
Msite/lib/referee.ts4+-
Msite/scripts/solve.ts15+-
Asite/tests/games_test.ts96+++++++++++++++
M site/lib/fold.ts +1 -1
M site/lib/games.ts +162 -26
@@ -1,15 +1,34 @@
1-// In-memory registry of live games. State is rebuilt from the JSONL on2-// restart only for spectating (the private solution is not in the log, so a3-// restarted server cannot continue a game; the demo is one process).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.47 import { type GameRules } from "./fold.ts";5-import { gen, type GenParams, judge } from "./engine.ts";8+import { gen, type GenOutput, type GenParams, judge } from "./engine.ts";69 import { Referee } from "./referee.ts";10+import { logPath } from "./log.ts";11+12+export type Pending = {13+ status: "generating" | "failed";14+ game_id: string;15+ started: number; // ms since epoch16+ params: GenParams;17+ error?: string;18+};19+20+type Entry = { kind: "pending"; pending: Pending } | {21+ kind: "ready";22+ ref: Referee;23+ started: number;24+};725 826 type NewGameListener = (id: string) => void;927 type Registry = {10- games: Map<string, Referee>;28+ games: Map<string, Entry>;1129 latest: string | null;1230 listeners: Set<NewGameListener>;31+ sweeper: number | null;1332 };1433 1534 // Kept on globalThis so a dev-server module re-evaluation (HMR after an
@@ -19,12 +38,25 @@ const reg: Registry = g.__mosaicGames ??= {
1938 games: new Map(),2039 latest: null,2140 listeners: new Set(),41+ sweeper: null,2242 };2343 const games = reg.games;2444 const newGameListeners = reg.listeners;2545 46+/** Games nobody joined are dropped after this long. */47+export const EXPIRE_MS = 30 * 60 * 1000;48+49+/** The referee of a ready game; undefined while generating or unknown. */2650 export function getGame(id: string): Referee | undefined {27- return games.get(id);51+ const e = games.get(id);52+ return e?.kind === "ready" ? e.ref : undefined;53+}54+55+/** Status of any known game: generating, failed, or the ready referee. */56+export function getEntry(id: string): Pending | Referee | undefined {57+ const e = games.get(id);58+ if (!e) return undefined;59+ return e.kind === "ready" ? e.ref : e.pending;2860 }2961 3062 export function latestGameId(): string | null {
@@ -46,43 +78,147 @@ export type StartParams = {
4678 rules?: Partial<GameRules>;4779 };4880 49-/** Live games, newest first: id, status, revision, agents. */50-export function listGames(): {81+export type GameSummary = {5182 game_id: string;52- status: string;83+ status: "generating" | "failed" | "playing" | "solved";5384 revision: number;5485 G: number;5586 tiles: number;5687 agents: string[];57-}[] {58- return [...games.values()].reverse().map((ref) => ({59- game_id: ref.gameId,60- status: ref.state.status,61- revision: ref.state.revision,62- G: ref.state.G,63- tiles: ref.manifest.tiles.length,64- agents: ref.state.agents.map((a) => a.id),65- }));88+ error?: string;89+};90+91+/** Every known game, newest first. */92+export function listGames(): GameSummary[] {93+ return [...games.values()].reverse().map((e) =>94+ e.kind === "ready"95+ ? {96+ game_id: e.ref.gameId,97+ status: e.ref.state.status,98+ revision: e.ref.state.revision,99+ G: e.ref.state.G,100+ tiles: e.ref.manifest.tiles.length,101+ agents: e.ref.state.agents.map((a) => a.id),102+ }103+ : {104+ game_id: e.pending.game_id,105+ status: e.pending.status,106+ revision: -1,107+ G: e.pending.params.G,108+ tiles: 0,109+ agents: [],110+ ...(e.pending.error ? { error: e.pending.error } : {}),111+ }112+ );66113 }67114 68-export async function startGame(p: StartParams): Promise<Referee> {115+export type Generator = (p: GenParams) => Promise<GenOutput>;116+117+/**118+ * Start a game: returns at once with a generating entry; the field is119+ * generated in the background and the entry becomes a Referee when ready120+ * (`onNewGame` fires then). `generate` is injectable for tests.121+ */122+export function startGame(123+ p: StartParams,124+ generate: Generator = gen,125+ now: () => number = Date.now,126+): Pending {69127 const gameId = crypto.randomUUID();70128 const seed = p.seed ?? gameId;71- const out = await gen({129+ const params: GenParams = {72130 seed,73131 G: p.G,74132 kind: p.kind,75133 n: p.n,76134 chunks: p.chunks,77135 chunk_g: p.chunk_g,78- });136+ };137+ const pending: Pending = {138+ status: "generating",139+ game_id: gameId,140+ started: now(),141+ params,142+ };143+ games.set(gameId, { kind: "pending", pending });144+ ensureSweeper();79145 const rules: GameRules = {80146 guess: p.rules?.guess ?? "accept",81147 depth: p.rules?.depth ?? 0,82148 };83- const ref = await Referee.create(gameId, out, rules, judge, { seed });84- games.set(gameId, ref);85- reg.latest = gameId;86- for (const l of newGameListeners) l(gameId);87- return ref;149+ (async () => {150+ try {151+ const out = await generate(params);152+ const ref = await Referee.create(gameId, out, rules, judge, { seed });153+ if (!games.has(gameId)) return; // expired meanwhile154+ games.set(gameId, { kind: "ready", ref, started: pending.started });155+ reg.latest = gameId;156+ for (const l of newGameListeners) l(gameId);157+ } catch (e) {158+ pending.status = "failed";159+ pending.error = e instanceof Error ? e.message : String(e);160+ }161+ })();162+ return pending;163+}164+165+/** Wait for a starting game to be ready (tests and scripts). */166+export async function whenReady(167+ id: string,168+ timeoutMs = 600000,169+): Promise<Referee> {170+ const t0 = Date.now();171+ while (Date.now() - t0 < timeoutMs) {172+ const e = games.get(id);173+ if (!e) throw new Error(`game ${id} vanished`);174+ if (e.kind === "ready") return e.ref;175+ if (e.pending.status === "failed") {176+ throw new Error(`generation failed: ${e.pending.error}`);177+ }178+ await new Promise((r) => setTimeout(r, 50));179+ }180+ throw new Error(`game ${id} not ready after ${timeoutMs} ms`);181+}182+183+/**184+ * Drop games older than EXPIRE_MS that nobody joined (and failed ones),185+ * deleting a log that holds only the manifest. Returns the dropped ids.186+ */187+export async function sweep(now = Date.now()): Promise<string[]> {188+ const dropped: string[] = [];189+ for (const [id, e] of games) {190+ const age = now - (e.kind === "ready" ? e.started : e.pending.started);191+ if (age < EXPIRE_MS) continue;192+ if (e.kind === "ready") {193+ if (e.ref.state.agents.length > 0) continue;194+ if (e.ref.log.events.length <= 1) {195+ try {196+ await Deno.remove(logPath(id));197+ } catch {198+ // never persisted, or already gone199+ }200+ }201+ } else if (e.pending.status === "generating") {202+ continue; // still working; it will be judged once ready203+ }204+ games.delete(id);205+ if (reg.latest === id) reg.latest = null;206+ dropped.push(id);207+ }208+ return dropped;209+}210+211+function ensureSweeper(): void {212+ if (reg.sweeper !== null) return;213+ reg.sweeper = setInterval(() => {214+ sweep().catch(() => {});215+ }, 60 * 1000);216+ // do not keep the process alive for the sweeper alone217+ Deno.unrefTimer(reg.sweeper);218+}219+220+/** Test hook: forget every game. */221+export function resetGames(): void {222+ games.clear();223+ reg.latest = null;88224 }
M site/lib/mcp.ts +45 -27
@@ -2,7 +2,7 @@
22 // write, cell arrays, a short intent string shown verbatim in the spectator.33 import { McpServer } from "@modelcontextprotocol/server";44 import { z } from "zod";5-import { getGame, listGames, startGame } from "./games.ts";5+import { getEntry, getGame, listGames, startGame } from "./games.ts";66 import { type JoinOptions, RefereeError } from "./referee.ts";77 88 /**
@@ -66,7 +66,7 @@ function findImplementation(o: unknown): string | undefined {
6666 }6767 6868 const RULES_TEXT =69- `Rules of a mosaic. The board is a G x G field of binary cells partly covered by tiles. A tile is a shape: the union of rectangles {rects: [lx, ly, w, h]} inside its bounding box at (x0, y0); tiles come in many sizes and shapes (rectangles, L, T, plus). Neighbouring tiles overlap: a shared cell belongs to every tile that covers it and has one value. Cells no tile covers are holes: never playable. Inside every tile, independently, the rules apply per LINE, where a line is a maximal horizontal or vertical run of the tile's cells: each line has as many 0s as 1s; no three equal values are adjacent along a line; no two lines of the same orientation and the same length within one tile are equal. Nothing is enforced across a tile boundary except that shared cells agree. Every tile ALONE (its givens only) has 2..m solutions, m depending on its cell count (2 up to 64 cells, 3 up to 196, 4 above), that differ on some cells it shares with a neighbour; a solved neighbour fixes those cells and pins it. AGENTS: several agents play one field. Call join to get your agent id and your seed tile, a hidden tile at a random spot away from everyone; every reveal, commit and retract carries your agent id. Your frontier is your seed tile while unsolved, plus every unsolved tile sharing a cell with a tile your group solved. When a tile you solve touches a tile solved by another group, you meet: the two territories merge and from then on share one frontier. Tiles are referred to by id; observe {agent: your id} lists your territory, its surroundings and your frontier, each tile with id, x0, y0, w, h, rects, cells, m, status, agent (who solved it) and neighbours; observe {rect} lists a rectangle; full listings exist only on small fields. Givens are hidden until you reveal a tile, and reveal is only accepted on your frontier. Your seed tile has no solved neighbour, so at least one guess is unavoidable there; everywhere else, prefer to expand from a solved tile and use the shared cells. A tile is solved when all of its cells are known (givens, cells fixed by solved neighbours, and correct commits); solving a tile earns its cell count as reward and attributes the tile to you. Commits are checked per cell: wrong values are rejected (and reveal only that they were wrong); correct ones are accepted and classified forced or guess. Forced means the referee's own propagation of the three rules over the revealed cells (single-cell deductions to a fixpoint, plus bifurcation only to the game's configured depth, default 0) reaches that value; a cell that holds in every solution but needs deeper search is still recorded as guess. Commit propagation-forced cells first if you care about that split. Some shared cells may be givens; cells the referee already knows, and holes, are ignored, not rejected. Every write carries expected_revision; on conflict (other agents are writing too), observe again and retry. Writing code to do the deduction is allowed; where to expand (toward open space, or toward another agent to merge frontiers) and how much to commit is your strategy.`;69+ `Rules of a mosaic. The board is a G x G field of binary cells partly covered by tiles. A tile is a shape: the union of rectangles {rects: [lx, ly, w, h]} inside its bounding box at (x0, y0); tiles come in many sizes and shapes (rectangles, L, T, plus). Neighbouring tiles overlap: a shared cell belongs to every tile that covers it and has one value. Cells no tile covers are holes: never playable. Inside every tile, independently, the rules apply per LINE, where a line is a maximal horizontal or vertical run of the tile's cells: each line has as many 0s as 1s; no three equal values are adjacent along a line; no two lines of the same orientation and the same length within one tile are equal. Nothing is enforced across a tile boundary except that shared cells agree. Every tile ALONE (its givens only) has 2..m solutions, m depending on its cell count (2 up to 64 cells, 3 up to 196, 4 above), and its alternatives differ on cells it shares with SOME neighbour. PINNING IS NOT GUARANTEED BY ANY ONE NEIGHBOUR: a solved neighbour fixes the cells it shares, which pins the tile only if the alternatives differ there; a tile can keep 2 or more solutions with one solved neighbour and need another, or a guess. AGENTS: several agents play one field. Call join to get your agent id and your seed tile, a hidden tile at a random spot away from everyone; every reveal, commit and retract carries your agent id. Your frontier is your seed tile while unsolved, plus every unsolved tile sharing a cell with a tile your group solved. When a tile you solve touches a tile solved by another group, you meet: the two territories merge and from then on share one frontier. Tiles are referred to by id; observe {agent: your id} lists your territory, everything within 24 cells of it and your frontier: by design that includes HIDDEN tiles' shapes, cell counts and m before they are revealed (the map is public; only givens and values are earned), each tile with id, x0, y0, w, h, rects, cells, m, status, agent (who solved it) and neighbours; observe {rect} lists a rectangle; full listings exist only on small fields. Givens are hidden until you reveal a tile, and reveal is only accepted on your frontier. Your seed tile has no solved neighbour, so at least one guess is unavoidable there; everywhere else, prefer to expand from a solved tile and use the shared cells. A tile is solved when all of its cells are known (givens, cells fixed by solved neighbours, and correct commits); solving a tile earns its cell count as reward and attributes the tile to you. COMMITS are checked per cell against the true solution: a wrong value is rejected and reported as {id, reason: "wrong", v}, and since cells are binary a rejection is full information: the other value is correct, commit it next. Correct cells are accepted and classified forced or guess. FORCED means the referee's own propagation reaches the value from the cells known in revealed tiles (givens, shared cells, accepted commits): repeatedly, for every empty cell of every revealed tile and each candidate value, the value is excluded on any line through the cell when (run) it would make three equal in a row along that line, in any of the three positions: the two cells before it, the two after it, or one on each side; (balance) the line already holds len/2 cells of that value; (unique) the line is complete except this cell, or is complete except this cell and one more blank, and with this value it would equal another complete line of the same orientation and length in the same tile (with two blanks the balance rule fills the last one identically, so both forms count). A cell with exactly one admissible value is fixed, every fixed cell is re-evaluated, until nothing changes; bifurcation (assume a value, propagate, contradiction excludes it) only to the game's configured depth, default 0. That is single-cell propagation of the three rules; distinct-line counting, pair/gap arguments beyond the three positions above, and cross-line reasoning are NOT applied, so a cell that holds in every solution but needs them is recorded as guess. Commit propagation-forced cells first if you care about that split. Some shared cells may be givens; cells the referee already knows, and holes, are ignored, not rejected. Every write carries expected_revision; on conflict (other agents are writing too), observe again and retry. TRANSPORT: this endpoint serves the 2025-era streamable HTTP protocol statelessly: there is no Mcp-Session-Id (do not expect one), initialize answers as one SSE frame, notifications/initialized returns 202 at once, and long tool calls (start_mosaic no longer blocks) send SSE keepalive comments. Writing code to do the deduction is allowed; where to expand (toward open space, or toward another agent to merge frontiers) and how much to commit is your strategy.`;7070 7171 const gameIdSchema = z.string().uuid();7272
@@ -84,8 +84,21 @@ function errorResult(error: unknown) {
8484 8585 function requireGame(id: string) {8686 const g = getGame(id);87- if (!g) throw new RefereeError("Game not found.", "missing");88- return g;87+ if (g) return g;88+ const e = getEntry(id);89+ if (!e) throw new RefereeError("Game not found.", "missing");90+ if ("status" in e && e.status === "failed") {91+ throw new RefereeError(92+ `Generation failed: ${e.error}. Start another.`,93+ "failed",94+ );95+ }96+ throw new RefereeError(97+ `Game ${id} is still generating (${98+ Math.round((Date.now() - (e as { started: number }).started) / 1000)99+ }s so far; a G=100 field takes about 3 minutes). Poll observe or list_games every 10-20 s until status is playing, then join.`,100+ "generating",101+ );89102 }90103 91104 export function createServer(): McpServer {
@@ -96,7 +109,7 @@ export function createServer(): McpServer {
96109 {97110 title: "Start a mosaic",98111 description:99- `Create a new field and open it in the spectator at http://127.0.0.1:8000/ . Defaults: kind=scatter with G=100, a field of tiles of random shapes and sizes scattered with overlaps and holes; kind=field with chunks=3 (chunk_g=60) is a larger field generated chunk by chunk; kind=grid with n=14 and G=27 is the classic 2x2 of 14x14 squares (G-1 must be a multiple of n-1). Starting does not seat you: call join next. Then play autonomously with observe, reveal, commit, retract until status is solved. ${RULES_TEXT}`,112+ `Create a new field and open it in the spectator at http://127.0.0.1:8000/ . Returns at once with status "generating": the field is generated in the background (a G=60 scatter takes ~1 min, G=100 ~3 min); poll observe {game_id} or list_games every 10-20 s until the status is playing, then join. Do not start another field while one is generating; games nobody joins expire after 30 min. Defaults: kind=scatter with G=100, a field of tiles of random shapes and sizes scattered with overlaps and holes; kind=field with chunks=3 (chunk_g=60) is a larger field generated chunk by chunk; kind=grid with n=14 and G=27 is the classic 2x2 of 14x14 squares (G-1 must be a multiple of n-1). Starting does not seat you: call join next. Then play autonomously with observe, reveal, commit, retract until status is solved. ${RULES_TEXT}`,100113 inputSchema: z.object({101114 G: z.number().int().min(6).max(1000).default(100).describe(102115 "Cells per side of the field. For kind=grid, G-1 must be a multiple of n-1 (27 for a 2x2 of 14x14).",
@@ -127,7 +140,7 @@ export function createServer(): McpServer {
127140 }).optional(),128141 }),129142 },130- async ({ G, kind, n, chunks, chunk_g, seed, rules }) => {143+ ({ G, kind, n, chunks, chunk_g, seed, rules }) => {131144 try {132145 if (n % 2 !== 0) return errorResult(new Error("n must be even."));133146 if (kind === "grid" && (G - 1) % (n - 1) !== 0) {
@@ -135,7 +148,7 @@ export function createServer(): McpServer {
135148 new Error(`kind=grid needs G-1 to be a multiple of n-1.`),136149 );137150 }138- const ref = await startGame({151+ const pending = startGame({139152 G,140153 kind,141154 n,
@@ -144,22 +157,13 @@ export function createServer(): McpServer {
144157 seed,145158 rules,146159 });147- const m = ref.manifest;148- const seeds = 1 +149- (m.seed_tiles?.filter((t) => t !== m.seed_tile).length ?? 0);150160 return textResult(151- `Started field ${ref.gameId}: ${m.tiles.length} tiles on a ${m.G}x${m.G} field (${152- ref.state.geo!.holes153- } hole cells, ${seeds} seed tile${154- seeds === 1 ? "" : "s"155- }). Call join with this game_id to get your agent id and seed tile. Revision ${ref.revision}.`,161+ `Started field ${pending.game_id}: generating in the background (kind ${kind}, G ${G}). Poll observe {game_id: "${pending.game_id}"} or list_games every 10-20 s until status is playing, then join with this game_id.`,156162 {157- game_id: ref.gameId,158- revision: ref.revision,159- G: m.G,160- tiles: m.tiles.length,161- holes: ref.state.geo!.holes,162- seed_tiles: seeds,163+ game_id: pending.game_id,164+ status: "generating",165+ kind,166+ G,163167 spectator_url: "http://127.0.0.1:8000/",164168 },165169 );
@@ -174,7 +178,7 @@ export function createServer(): McpServer {
174178 {175179 title: "List fields",176180 description:177- "List the fields this server is running (newest first): game_id, status, revision, G, tiles, and the agents that joined. Join a playing one, or start your own.",181+ "List the fields this server knows (newest first): game_id, status (generating | failed | playing | solved), revision, G, tiles, and the agents that joined. Join a playing one, wait for a generating one, or start your own.",178182 inputSchema: z.object({}),179183 annotations: { readOnlyHint: true },180184 },
@@ -183,9 +187,13 @@ export function createServer(): McpServer {
183187 return textResult(184188 games.length185189 ? games.map((g) =>186- `${g.game_id}: ${g.status} at rev ${g.revision}, ${g.G}x${g.G}, ${g.tiles} tiles, agents ${187- JSON.stringify(g.agents)188- }`190+ g.status === "generating" || g.status === "failed"191+ ? `${g.game_id}: ${g.status}${192+ g.error ? " (" + g.error + ")" : ""193+ }, G ${g.G}`194+ : `${g.game_id}: ${g.status} at rev ${g.revision}, ${g.G}x${g.G}, ${g.tiles} tiles, agents ${195+ JSON.stringify(g.agents)196+ }`189197 ).join("\n")190198 : "No field is running; start one with start_mosaic.",191199 { games },
@@ -198,7 +206,7 @@ export function createServer(): McpServer {
198206 {199207 title: "Join a field",200208 description:201- "Join a field as an agent. Returns your agent id (your machine_id if you pass one, else a provisional a<N>), your seed tile (a hidden tile at a random spot away from everyone; reveal it first) and the revision. State your model (self-reported, e.g. claude-opus-5) and a short name; the harness is read from the transport when it identifies itself, else from the harness argument. Every reveal, commit and retract must carry your agent id.",209+ "Join a field as an agent (the field must be playing: a generating one answers with an error to retry after polling observe). Returns your agent id (your machine_id if you pass one, else a provisional a<N>), your seed tile (a hidden tile at a random spot away from everyone; reveal it first) and the revision. State your model (self-reported, e.g. claude-opus-5) and a short name; the harness is read from the transport when it identifies itself, else from the harness argument. Every reveal, commit and retract must carry your agent id.",202210 inputSchema: z.object({203211 game_id: gameIdSchema,204212 machine_id: z.string().min(1).max(80).optional().describe(
@@ -261,6 +269,16 @@ export function createServer(): McpServer {
261269 },262270 ({ game_id, agent, rect, full }) => {263271 try {272+ const entry = getEntry(game_id);273+ if (entry && "status" in entry && !("gameId" in entry)) {274+ const secs = Math.round((Date.now() - entry.started) / 1000);275+ return textResult(276+ entry.status === "failed"277+ ? `Game ${game_id}: generation failed (${entry.error}). Start another.`278+ : `Game ${game_id} is generating (${secs}s so far). Poll again in 10-20 s; join once the status is playing.`,279+ { observation: { game_id, status: entry.status, seconds: secs } },280+ );281+ }264282 const ref = requireGame(game_id);265283 const obs = ref.observe({ agent, rect, full });266284 const solved = obs.tiles.filter((t) => t.status === "solved").length;
@@ -310,7 +328,7 @@ export function createServer(): McpServer {
310328 {311329 title: "Commit cells",312330 description:313- "Commit a batch of [cell_id, value] pairs inside revealed tiles, with a one-line intent (<=160 chars) stating your reason; it is shown verbatim to spectators. Each cell is accepted (correct; classified forced or guess with the rules that force it), rejected (wrong value, or an unforced guess when the game rejects guesses), or ignored (already known, a given, a hole, duplicate, or outside revealed tiles). Reports newly solved tile ids, the reward earned, and the new frontier.",331+ "Commit a batch of [cell_id, value] pairs inside revealed tiles, with a one-line intent (<=160 chars) stating your reason; it is shown verbatim to spectators. Each cell is accepted (correct; classified forced or guess with the rules that force it), rejected as {id, reason, v} (reason wrong: the value v you sent is not the solution, and since cells are binary the other value is correct, commit it next and do not re-guess; reason guess: correct but unforced, on games that reject guesses), or ignored (already known, a given, a hole, duplicate, or outside revealed tiles). Reports newly solved tile ids, meetings, the reward earned, and your new frontier.",314332 inputSchema: z.object({315333 game_id: gameIdSchema,316334 agent: z.string().min(1).max(80).describe("Your agent id from join."),
M site/lib/referee.ts +2 -2
M site/scripts/solve.ts +8 -7
A site/tests/games_test.ts +96 -0
@@ -0,0 +1,96 @@
1+// Async game start: start returns a generating entry at once, observe and2+// list report it, join is refused until ready, and games nobody joined3+// expire (their manifest-only log is deleted). Rejected commits echo the4+// value they rejected.5+import { assert, assertEquals, assertRejects } from "@std/assert";6+import {7+ EXPIRE_MS,8+ getEntry,9+ getGame,10+ listGames,11+ resetGames,12+ startGame,13+ sweep,14+ whenReady,15+} from "../lib/games.ts";16+import { logPath } from "../lib/log.ts";17+import { Referee } from "../lib/referee.ts";18+import { gridLayout, stubJudge, synthesise } from "./fixture.ts";19+20+const tmp = await Deno.makeTempDir();21+Deno.env.set("MOSAIC_GAMES", tmp + "/");22+23+const fixture = () =>24+ Promise.resolve(structuredClone(synthesise(gridLayout(2, 8))));25+26+Deno.test("start is asynchronous: generating, then playing; expiry sweeps the unjoined", async () => {27+ resetGames();28+ let release: () => void = () => {};29+ const gate = new Promise<void>((r) => release = r);30+ const slowGen = async () => {31+ await gate;32+ return structuredClone(synthesise(gridLayout(2, 8)));33+ };34+ const t0 = 1_000_000;35+ const pending = startGame({ G: 15, kind: "grid", n: 8 }, slowGen, () => t0);36+ assertEquals(pending.status, "generating");37+ assertEquals(getGame(pending.game_id), undefined);38+ const e = getEntry(pending.game_id);39+ assert(e && "status" in e && e.status === "generating");40+ assertEquals(listGames()[0].status, "generating");41+ release();42+ const ref = await whenReady(pending.game_id);43+ assertEquals(ref.gameId, pending.game_id);44+ assertEquals(listGames()[0].status, "playing");45+ assert(await Deno.stat(logPath(pending.game_id)));46+ // nobody joined: after EXPIRE_MS the game and its manifest-only log go47+ assertEquals(await sweep(t0 + EXPIRE_MS - 1), []);48+ assertEquals(await sweep(t0 + EXPIRE_MS), [pending.game_id]);49+ assertEquals(getEntry(pending.game_id), undefined);50+ await assertRejects(() => Deno.stat(logPath(pending.game_id)));51+ // a joined game is kept52+ const p2 = startGame({ G: 15, kind: "grid", n: 8 }, fixture, () => t0);53+ const ref2 = await whenReady(p2.game_id);54+ await ref2.join({ name: "x", harness: "t" });55+ assertEquals(await sweep(t0 + 2 * EXPIRE_MS), []);56+ assert(getGame(p2.game_id));57+ // a failed generation is reported and swept58+ const p3 = startGame(59+ { G: 15, kind: "grid", n: 8 },60+ () => Promise.reject(new Error("boom")),61+ () => t0,62+ );63+ await assertRejects(() => whenReady(p3.game_id), Error, "boom");64+ assertEquals(65+ listGames().find((g) => g.game_id === p3.game_id)?.status,66+ "failed",67+ );68+ assertEquals(await sweep(t0 + EXPIRE_MS), [p3.game_id]);69+ resetGames();70+});71+72+Deno.test("a wrong commit echoes the value it rejected", async () => {73+ const out = await fixture();74+ const ref = await Referee.create(75+ crypto.randomUUID(),76+ out,77+ { guess: "accept", depth: 0 },78+ stubJudge,79+ { persist: false },80+ );81+ const j = await ref.join({ harness: "t" });82+ const r = await ref.reveal(ref.revision, j.seed_tile, "", j.agent);83+ const G = out.manifest.G;84+ const unknown = ref.state.geo!.cellsOf[j.seed_tile].filter((c) =>85+ !ref.state.cells[c]86+ );87+ const id = unknown[0];88+ const truth = out.solution[Math.floor(id / G)][id % G] === "1" ? 1 : 0;89+ const c = await ref.commit(90+ r.revision,91+ [[id, 1 - truth]],92+ "wrong on purpose",93+ j.agent,94+ );95+ assertEquals(c.rejected, [{ id, reason: "wrong", v: (1 - truth) as 0 | 1 }]);96+});