From 586fcfd309022e2d536811ae2d6b79fda34556ab Mon Sep 17 00:00:00 2001 From: Arcadia Rose Date: Tue, 8 Sep 2026 19:50:09 -0400 Subject: [PATCH] Local bots Generated a few bot personalities to play against. We can record transcripts of games and feed them to Claude to iterate on them. --- .claude/skills/improve-bots/SKILL.md | 82 ++++ .gitignore | 4 + backend/src/server.ts | 36 +- docs/bots.md | 221 +++++++++ .../feedback/design-partner-workflow.md | 15 + docs/memories/project/bot-strategies.md | 46 ++ docs/memories/project/build-and-serve.md | 35 +- .../project/experimental-agent-branch.md | 51 +- .../project/home-bases-and-victory.md | 15 + .../project/separation-of-concerns.md | 37 ++ docs/tasks/2026-09-08/18-local-bot-players.md | 457 ++++++++++++++++++ frontend/index.html | 52 ++ frontend/src/bot.ts | 132 +++++ frontend/src/bot/circles.ts | 102 ++++ frontend/src/bot/cuts.ts | 215 ++++++++ frontend/src/bot/lib.ts | 184 +++++++ frontend/src/bot/tentacles.ts | 191 ++++++++ frontend/src/main.ts | 276 ++++++++++- frontend/src/rng.ts | 24 + frontend/src/theme.ts | 13 + frontend/src/transcript.ts | 247 ++++++++++ frontend/src/ui.ts | 90 +++- 22 files changed, 2486 insertions(+), 39 deletions(-) create mode 100644 .claude/skills/improve-bots/SKILL.md create mode 100644 docs/bots.md create mode 100644 docs/memories/project/bot-strategies.md create mode 100644 docs/tasks/2026-09-08/18-local-bot-players.md create mode 100644 frontend/src/bot.ts create mode 100644 frontend/src/bot/circles.ts create mode 100644 frontend/src/bot/cuts.ts create mode 100644 frontend/src/bot/lib.ts create mode 100644 frontend/src/bot/tentacles.ts create mode 100644 frontend/src/rng.ts create mode 100644 frontend/src/transcript.ts diff --git a/.claude/skills/improve-bots/SKILL.md b/.claude/skills/improve-bots/SKILL.md new file mode 100644 index 0000000..c4e0dc1 --- /dev/null +++ b/.claude/skills/improve-bots/SKILL.md @@ -0,0 +1,82 @@ +--- +name: improve-bots +description: Study a recorded bit-blossom game transcript and revise an existing bot strategy or author a new one. Use when the user asks to improve, tune, diagnose or add a bot or strategy, or to analyse a game record under docs/games/. +--- + +# Improve bots + +Study a `docs/games/` transcript and revise an existing `frontend/src/bot/*.ts` strategy, or author a new one, following the diagnose-before-changing discipline this repo has settled on. Not every request needs every step below -- see "Flexibility" near the end before assuming a full pass is wanted. + +## 1. Orient before touching anything + +Read `docs/bots.md` (the strategy contract and its hard rules), `frontend/src/bot.ts` (the `Strategy` type, the registry, `takeTurn`), `frontend/src/bot/lib.ts` (the shared substrate -- most of what a strategy needs already exists there), and the header comment plus **revision log** of every strategy in scope. The revision log is load-bearing: it records what has already been tried and why it was superseded. Proposing something the log already rejected is the most likely way to waste a pass. + +## 2. Choose the game record + +Transcripts live in `docs/games/`, newest first unless the user names one. If the directory is empty or missing, **stop and say so** -- ask the user to play a game and let it finish, or press "Save transcript" mid-game. Never reason from an imagined game; a fabricated diagnosis is worse than no diagnosis. See `docs/bots.md`'s "Obtaining a transcript" section for the mechanics and gotchas (the dev server has to be the one serving the page, when auto-save fires, and the browser-console fallback if the write fails). + +## 3. Read the transcript in diagnostic order + +`docs/bots.md`'s "Reading a transcript" section describes what each part of the file contains; read in this order, for this reason: + +- **Faults first.** A fault means a strategy suggested an illegal placement, left an opening turn incomplete, or blew the step ceiling. That is a bug in bot code, and it outranks every question of strategy quality. Fix it before tuning anything. +- **Result and per-player summary next.** Judge a strategy against its own rule, not against the scoreboard -- Cuts against cutting, Circles against encirclement, Tentacles against territory. A Circles bot that wins on territory captures and scores zero encirclements is failing at its job while appearing to succeed. +- **Move list**, for the mechanism behind whatever the summary showed. +- **Final board**, for shape -- especially for Tentacles, which is judged visually as much as numerically (see `docs/vision.md`). + +## 4. Diagnose before changing anything + +State a *mechanism* you can point at in the transcript ("Cuts placed 40 stones and produced 2 cuts; a cut needs three fresh stones, so its one-ply lookahead is blind for the first two placements of every turn"), not a verdict ("Cuts is weak"). **One game is an anecdote** -- mechanisms replicate, single-game outcomes mostly don't. If the only available evidence is "it lost," say so plainly rather than inventing a cause. Prefer a change whose effect you can predict and then check for in the next transcript. + +## 5. Scope the change: one strategy per pass + +Revising two files at once destroys attribution -- the next game can't tell you which change helped. If the user names several, do them one at a time and say that's what you're doing. Same for tunable constants: change one, not three. + +## 6. Hard constraints -- not negotiable + +- **You may edit `frontend/src/bot/*.ts`. You may not edit `game.ts`.** If a strategy seems to need a rule change, stop and raise it with the user. A rule change is a conversation with Arcadia, never a commit. +- **Purity:** no DOM, no `fetch`, no clock, and **never `Math.random`** -- use the injected `random`, or a pinned seed stops reproducing a game. Never mutate `state`. +- **Statelessness:** `nextPlacement` is called fresh for every placement with no memory between calls or turns. Anything the strategy needs to "remember" must be re-derivable from the board. See `lib.openingTriple` and `tentacles.ts`'s `armDirections` for the two established patterns; a value drawn from `random` cannot be remembered and will wander turn to turn. +- **Never voluntarily pass.** Return `null` only when there is no legal move at all. A pass while legal moves exist is a bug the driver's idle guard catches -- Arcadia explicitly rejected making all-pass a game rule (`docs/memories/project/bot-strategies.md`). +- **Performance:** build the occupancy map once per call; never call `game.pendingEncirclements` or `game.pendingTerritories` inside a loop over candidates; and note that **`game.place` is itself expensive** (it runs `gameStatus`'s elimination flood fills unconditionally), so the *number* of `place` calls a search makes is the dominant cost, not just which rule-checking function gets called. `game.pendingCuts` is cheap by comparison. Turn latency is what killed the approach this replaced -- see `cuts.ts`'s `SETUP_RADIUS` comment for a worked example of narrowing a search without losing correctness. + +## 7. Record the change in the file's revision log + +A dated entry saying what changed, why, and what in the transcript led you there. **Describe the superseded idea rather than deleting it** -- `tentacles.ts` keeps its abandoned concentric-rings draft on record, and that is the pattern. This log is the durable artifact the whole approach exists to produce; the abandoned LLM-agent-player work left nothing equivalent. + +## 8. Authoring a new strategy + +One new file `frontend/src/bot/.ts` exporting `id`, `name` and `nextPlacement` at top level (the module *is* the strategy -- no registration object, no import back to `bot.ts`), plus one line adding it to `strategies` in `bot.ts`. It needs a header comment stating the idea in a paragraph, a revision log started with its first dated entry, and **an explicit opening-turn behaviour** -- `takeTurn`'s forced-fill path exists for faults, not for normal play, and relying on it produces a fault on turn one. Build on `lib.ts` rather than re-deriving neighbours, candidate moves, or tie-breaking. + +**Registering in `bot.ts` makes the strategy discoverable (`byId`, and a compile-time conformance check) -- it does not put it in a game.** `main.ts` doesn't iterate `strategies`; it imports each strategy module directly and names it in `ROSTER`. A new strategy that's only added to `bot.ts` will compile cleanly and never play a single move, which is easy to mistake for the strategy itself being broken. Since editing `ROSTER` means editing `main.ts` -- the driver, which this skill does not change, and which is Arcadia's roster configuration, not ours to rewrite -- the fix is a hand-back to the user, covered in step 10. + +## 9. Verify + +`tsc --noEmit` in `frontend/`. Then, where practical, run the compiled modules headlessly over several seeds and confirm **zero faults** and that games still complete -- `docs/memories/project/build-and-serve.md` documents the `.mjs`-copying trick needed to import compiled ESM from a Node script outside the browser. Report honestly what was and wasn't verified; a strategy change cannot be validated by `tsc` alone. + +## 10. Hand back to the user + +Tell them: pin `SEED` to the transcript's own seed and keep the same roster if they want a like-for-like comparison (`docs/bots.md`'s "Replaying a game" section has the mechanism); rebuild (`npm run dev` in `frontend/`) and hard-reload, since nothing rebuilds automatically; and name the specific thing to look for in the next transcript, so the change is falsifiable rather than just a hope. + +**If a new strategy was authored (step 8), it isn't in any game yet** -- give the user the exact two edits `frontend/src/main.ts` needs, not just a description: + +- An import: `import * as from "./bot/.js";` alongside the existing `tentacles`/`cuts`/`circles` imports. +- A `ROSTER` entry: `{ kind: "bot", strategy: }`, and which existing slot to replace it with if they want to keep a four-player game rather than add a fifth. + +Registering in `bot.ts` (step 8) was necessary but not sufficient -- without this, the new strategy compiles and is discoverable but never takes a turn, which reads exactly like a silently broken bot rather than one that was simply never scheduled. + +## Flexibility + +Not every request is a full revise-and-verify pass. Adapt: + +- **Analysis only** ("what went wrong last game?") -- do steps 1-4 and stop. Do not edit files. +- **A new bot from a description**, with no relevant game record -- skip steps 2-4 and go to step 8. +- **A tuning tweak** ("make Tentacles wider") -- steps 1, 5, 6, 7, 9; the constants are already named at the top of each file for exactly this. +- **A shape or aesthetic goal** -- ask for a screenshot or an ASCII sketch and derive a numeric model from it, then verify the model against every stone in the reference before writing code. A one-line verbal description of a spatial pattern is not a spec; this is how `tentacles.ts` got built wrong the first time. + +## What this skill does not do + +- Does not change the rules (`game.ts`), the driver (`main.ts`), or the transcript format. +- Does not play games or generate transcripts -- the user does that in the browser. +- Does not tune several strategies in one pass. +- Does not judge a strategy solely by who won. diff --git a/.gitignore b/.gitignore index 37fab99..cf40549 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ node_modules/ dist/ +# Bot match transcripts (docs/tasks/2026-09-08/18-local-bot-players.md) -- +# scratch that Claude reads from the working tree, not project history. +docs/games/ + \#.*\# *.*\~ diff --git a/backend/src/server.ts b/backend/src/server.ts index 6e00656..7b6d57f 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,15 +1,49 @@ import express from "express"; import path from "node:path"; +import fs from "node:fs/promises"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const frontendDir = path.join(__dirname, "..", "..", "frontend"); +const repoRoot = path.join(__dirname, "..", ".."); +const frontendDir = path.join(repoRoot, "frontend"); +const transcriptsDir = path.join(repoRoot, "docs", "games"); const app = express(); const port = Number(process.env.PORT) || 3000; app.use(express.static(frontendDir)); +// Local development convenience only -- writes into the repo working tree +// so Claude can read a finished match's transcript straight off disk. Must +// not be exposed publicly: this endpoint has no auth and happily creates +// files under docs/games/ for anyone who can reach it. +app.use(express.json({ limit: "4mb" })); + +app.post("/api/transcript", async (req, res) => { + const { text, filename } = req.body ?? {}; + if (typeof text !== "string" || typeof filename !== "string") { + res.status(400).json({ error: "text and filename are required" }); + return; + } + // path.basename strips any directory components, and the character + // whitelist below removes everything else that could escape + // transcriptsDir (including "..") -- together they're what confines + // every write to docs/games/, regardless of what the client sends. + const safeName = path.basename(filename).replace(/[^A-Za-z0-9._-]/g, "-"); + if (!safeName.endsWith(".md") || safeName.startsWith(".")) { + res.status(400).json({ error: "invalid filename" }); + return; + } + try { + await fs.mkdir(transcriptsDir, { recursive: true }); + await fs.writeFile(path.join(transcriptsDir, safeName), text, "utf8"); + res.json({ path: `docs/games/${safeName}` }); + } catch (error) { + console.error("Failed to write transcript:", error); + res.status(500).json({ error: "could not write transcript" }); + } +}); + app.listen(port, () => { console.log(`Server running at http://localhost:${port}`); }); diff --git a/docs/bots.md b/docs/bots.md new file mode 100644 index 0000000..70667f5 --- /dev/null +++ b/docs/bots.md @@ -0,0 +1,221 @@ +# Writing bit-blossom bot strategies + +For the Claude session iterating on `frontend/src/bot/*.ts`. Design: +`docs/tasks/2026-09-08/18-local-bot-players.md`. Background on why this +replaced the earlier LLM-agent-player approach: +`docs/memories/project/experimental-agent-branch.md` and +`docs/memories/project/bot-strategies.md`. + +## The permission boundary + +**You may edit `frontend/src/bot/*.ts` and add new files there. You may +NOT edit `game.ts`.** A strategy that needs a rule change is a +conversation with Arcadia, never a commit. This is the guardrail that +matters most here: without it, "make the bot win" and "change the rules" +become the same button, and this whole approach collapses back into what +the agent-player work already failed at. + +## The strategy contract + +A strategy is a module at `frontend/src/bot/.ts` exporting, at top +level: + +```ts +export const id = "example"; // stable, used in rosters and transcripts +export const name = "Example"; // shown in the panel and transcript +export const nextPlacement = ( + state: game.BoardState, + random: () => number, +): game.Position | null => { ... }; +``` + +`nextPlacement` is called once per placement, on the live board, until it +returns `null` or the turn's placement budget is spent (`bot.ts`'s +`takeTurn` runs this loop). It must: + +- Never mutate `state`. +- Never call `Math.random` -- use the supplied `random` stream, so a whole + game replays identically from its recorded seed (see `rng.ts`). +- Never touch the DOM, `fetch`, or the clock. +- Never voluntarily return `null` while a legal placement exists. Passing + is reserved for "there is genuinely no legal cell." A strategy that + passes while legal placements exist is a **bug** -- the driver's idle + guard in `main.ts` catches it, but it is never a game outcome. Arcadia + explicitly rejected an all-pass termination rule + (`docs/memories/project/bot-strategies.md`); the reason it's safe to + leave out is exactly that well-written strategies never voluntarily + stop. + +A minimal example that just fills its own home base's neighbourhood, then +stops: + +```ts +// bot/example.ts -- placeholder that only ever plays its opening turn. +// +// Revision log: +// - 2026-09-08: initial version. + +import * as game from "../game.js"; +import * as lib from "./lib.js"; + +export const id = "example"; +export const name = "Example"; + +export const nextPlacement = ( + state: game.BoardState, + random: () => number, +): game.Position | null => { + const available = lib.moves(state); + return lib.bestBy(available, () => 0, random)?.position ?? null; +}; +``` + +To register a strategy so it's importable from a roster in `main.ts`, add +one line to `strategies` in `bot.ts`. No other file needs to change. + +## Legality comes from `game.ts`, never from a strategy + +There is no second copy of the rules anywhere in `bot/*.ts`. The one +legality oracle is `game.place(state, position)` returning non-null. This +is what `bot/lib.ts`'s `moves`/`ownBase`/`openingTriple` helpers are built +on, and it's the thing that made the abandoned LLM-agent-player's +`agent.firstRejection` (a hand-maintained English-language copy of +`game.place`'s checks) unnecessary here. If a strategy needs to know +whether a cell is legal, it asks `game.place`, full stop -- it never +reimplements a check `game.ts` already makes. + +## Performance rules (read this before writing a scoring loop) + +`game.pieceAt` is a linear `.find` over every piece on the board, so +`game.pendingEncirclements` (8 `pieceAt` lookups per piece via `ringAt`) is +effectively O(pieces²), and `game.pendingTerritories` runs two full-board +flood fills per call. Both are fine at the once-per-render cadence they +were written for in `ui.ts`, but calling either **inside a loop over +candidate cells** -- a move search, a strategy scoring every legal +placement -- is roughly 100M operations on a late-game 16x16 board. + +This matters more here than almost anywhere else in the codebase: **turn +latency is what killed the LLM agent player** (seconds per turn, multiplied +by dozens of turns and a retry loop -- see +`docs/memories/project/experimental-agent-branch.md`), and this is the one +way it could sneak back into a design that has no network calls in it at +all. + +Rules for strategy authors: + +- Build the occupancy map once per `nextPlacement` call via + `lib.occupancy(state)`, and look pieces up through `lib.at(occ, position)` + from there -- never call `game.pieceAt` inside a loop over many + candidates. +- Never call `game.pendingEncirclements` or `game.pendingTerritories` + inside a loop over candidate cells. `bot/circles.ts` is the worked + example of computing an equivalent, cheaper, purely local signal instead + of calling `pendingEncirclements`. +- `game.pendingCuts` is cheap by comparison -- it only scans stones placed + this turn, not the whole board -- and is safe to probe once per + candidate. `bot/cuts.ts` does exactly this, plus a shallow one-ply + lookahead capped at a small constant (`LOOKAHEAD_CANDIDATES`) to keep + that lookahead's own cost bounded. + +## `bot/lib.ts`: the shared substrate + +Cheap board lookups, the candidate-move enumeration every strategy scores +over, and scored/random-tiebreak selection. Not a strategy itself -- +`bot.ts` lists strategies explicitly, so there's no ambiguity about +`lib.ts`'s role. + +The important design point: `lib.moves(state)` is a **heuristic filter**, +not a legality claim. It only considers empty cells within Chebyshev +distance 1 of some existing piece (an isolated stone is inert under every +capture rule in this game, so a cell far from any stone is never worth +scoring), then evaluates each one through `game.place` and keeps only the +legal results. That's the "no duplicated rules" property from above, made +concrete: the candidate set shrinks from the whole board to a few dozen +cells, and every one of them is already known-legal by construction. + +Known limitation worth remembering: `lib.moves` never enumerates a cell +that already holds exactly one doomed enemy stone, even though placing an +**overlay** there is legal (see `game.ts`'s `place`/`pieceAt` comments). A +strategy can therefore see zero candidates and stop even when an overlay +placement is technically still legal. None of the three shipped strategies +chase overlays; a future one that wants to would need its own enumeration. + +## The three shipped strategies, in one line each + +- **`bot/cuts.ts`** -- builds a line of own stones out from its base, then + chases `pendingCuts` pressure with a shallow lookahead. +- **`bot/circles.ts`** -- scores empty cells by how close they bring a + 4-cell ring around an enemy stone to completion, computed locally + (never calls `pendingEncirclements`). Never targets an enemy home base + -- a base can be a ring member but is never a capturable victim. +- **`bot/tentacles.ts`** -- the on-vision one (see `docs/vision.md`): grows + two thick diagonal arms outward from its base with an empty wedge + between them, which become territory walls once they pin a region + against the border. Deliberately the simplest file, and the one to + iterate on first. + +Read each file's own header comment before touching it -- that's where the +idea and the dated revision log live. + +## The revision log convention + +Every strategy file's header comment ends with a dated revision log: what +changed, why, and (once transcripts exist to point at) what a transcript +showed that motivated the change. This is the durable artifact this whole +approach exists to produce -- the abandoned agent-player work produced +nothing equivalent; every turn's reasoning evaporated on commit. Add an +entry every time you change a strategy's behaviour, even a small tuning +constant. + +## Obtaining a transcript + +A transcript writes itself in two cases: automatically, the moment a game +formally ends (someone wins, or it draws), or on demand, whenever the +**Save transcript** button in the panel is clicked mid-game. Two things +worth knowing before assuming one exists or diagnosing why it doesn't: + +- The write goes through `POST /api/transcript` on the backend + (`backend/src/server.ts`), so the app must be served by + `backend/dist/server.js` (`npm start` in `backend/`, or `PORT= node + backend/dist/server.js`) for it to land on disk -- not just the + frontend's own `tsc --watch`. It defaults to port 3000 + (`PORT=3000` unless overridden). +- If that request fails for any reason (server not running, write error), + the full transcript text is dumped to the **browser's own console** as a + fallback instead of being silently lost -- check there before concluding + a finished game produced nothing. + +## Reading a transcript + +A finished (or manually saved) game writes a Markdown file to +`docs/games/-seed.md` (gitignored -- this is scratch you +read from the working tree, not project history). It has five sections, in +the order that actually drives iteration: + +1. **Header** -- the seed (the important one), board size, roster, and a + one-line reminder of how to replay this exact game. +2. **Result** -- winner or draw, final scores, and how the game ended + (lone survivor / board full tiebreak / autoplay stopped by the idle + guard while still technically in progress). +3. **Per-player summary** -- the scorecard. Since Cuts/Circles/Tentacles + map onto the three capture rules, a strategy racking up captures by a + rule that isn't its own namesake is immediately visible here. +4. **Move list** -- one line per turn: placements, bonus placements + earned, the per-rule capture breakdown for that turn, and a `FAULT:` + line if the strategy suggested something illegal or had to be forced + through an incomplete opening turn. A fault is the highest-value line + in a transcript -- a bot is code, not a model, so an illegal suggestion + is a bug in the strategy to be shown and fixed, never silently retried. +5. **Final board** -- a labelled ASCII grid: `.` for empty, a lowercase + letter per player, uppercase for that player's own home base. + +There is deliberately no board snapshot per turn -- the seed plus the move +list already make any turn recoverable exactly by replaying. + +## Replaying a game + +Set `SEED` in `frontend/src/main.ts` to the seed printed at the top of the +transcript, keep `ROSTER` identical, rebuild +(`docs/memories/project/build-and-serve.md`), and reload. Home base +placement and every bot decision are drawn from seeded streams +(`rng.seeded`), so the replayed game is identical move for move. diff --git a/docs/memories/feedback/design-partner-workflow.md b/docs/memories/feedback/design-partner-workflow.md index 3f3c445..1f9fe74 100644 --- a/docs/memories/feedback/design-partner-workflow.md +++ b/docs/memories/feedback/design-partner-workflow.md @@ -39,6 +39,21 @@ final design. Seer's sign-off. The distinguishing line seems to be new behavior/rules/structure (routes through Seer) vs. adjusting values within a shape Seer already designed (doesn't need to). +- The "doesn't need Seer" side of that line also covers a small NEW UI + element, not just tweaks to an existing one, as long as it's pure + presentation/feedback for an already-shipped feature and mirrors an + established pattern exactly. Confirmed 2026-09-08: Arcadia asked the + coder session directly (not via Seer) for a visual confirmation that a + transcript save succeeded, "similar to the concede modal but green and + good." Implemented directly without routing to Seer: a new + `` + a `success`/`successText` theme color pair (mirroring + `danger`/`dangerText`'s existing per-theme, avoid-player-colors + pattern) + `ui.ts` wiring shaped exactly like `attachConcede`. No + `game.ts`/rules involvement, no new state machine, no ambiguity to + design out loud about — the concede dialog was the spec. The test that + seems to matter isn't "how many files changed" but "is there a design + decision here Seer/Arcadia haven't already made," and here the answer + was no. - The Seer session is read-only (no `Write`/`Edit`), so it cannot record project memories itself even though the Stop hook asks it to every turn. It sends them to a coder session to write instead — specified precisely enough to record diff --git a/docs/memories/project/bot-strategies.md b/docs/memories/project/bot-strategies.md new file mode 100644 index 0000000..9c6cb05 --- /dev/null +++ b/docs/memories/project/bot-strategies.md @@ -0,0 +1,46 @@ +# Bot strategies (local bot players) + +Design-time notes for `docs/tasks/2026-09-08/18-local-bot-players.md` and +the `frontend/src/bot/*.ts` modules it introduces. + +## Rejected: all-pass termination as a `game.ts` rule + +On 2026-09-08 Seer proposed a `passStreak` field on `BoardState` (a third +accumulated-history field beside `scores` and `conceded`), incremented by +`endTurn` on a placement-free turn and resolved by score once it reached +two full rotations — closing the pre-existing hang where every player +passing forever means the game never terminates. + +Arcadia rejected it outright and unprompted: "Ahhh, I hate this. I don't +want that to be a rule. Let's leave it out for now." + +**Why it turned out not to be needed** — don't re-propose this: a bot +strategy should never voluntarily pass. Passing is reserved for "there is +genuinely no legal cell," which on this board means the board is full — +at which point `boardIsFull` and the existing score comparison already +end the game on their own. The hole was in the hypothetical bots, not in +the ruleset. A strategy that passes while legal placements exist is a +**bug**, and the driver treats it as one (an autoplay guard that stops +scheduling and reports it), never as a game outcome. `game.ts` stays +untouched; `BoardState` gains no field; no winner is ever declared by a +pass. + +## Cost trap: don't call expensive `game.ts` queries per candidate cell + +`game.pieceAt` is a linear `.find` over `state.pieces`, so +`pendingEncirclements` — which calls `ringAt` (8 `pieceAt` lookups) per +piece on the board — is effectively O(pieces²). `pendingTerritories` +separately runs two full-board `enclosedCells` flood fills per call. Both +are fine at the once-per-render cadence they were written for, but +calling either **inside a per-candidate-cell loop** (a move search, a +hint feature, a bot scoring every legal placement) is roughly 100M +operations on a late-game 16x16 board. `pendingCuts` is cheap by +comparison — it scans only stones placed this turn — and is safe to probe +per candidate. + +This matters because turn latency is what killed the LLM agent player +(see `experimental-agent-branch.md`), and this is the one way it could +sneak back into a design that has no network calls at all. Any bot +strategy that evaluates many candidate positions should build a +position→piece `Map` once per decision and reason locally instead of +re-querying `game.ts`'s linear/flood-fill helpers per candidate. diff --git a/docs/memories/project/build-and-serve.md b/docs/memories/project/build-and-serve.md index 4d32740..18aec72 100644 --- a/docs/memories/project/build-and-serve.md +++ b/docs/memories/project/build-and-serve.md @@ -109,7 +109,40 @@ sampling canvas pixel color to detect "is there a stone here" must exclude both the board-fill color AND the grid-line ink color — an empty intersection samples as line ink, not background, so checking - against background alone flags every empty cell as occupied. + against background alone flags every empty cell as occupied. Sharper + trap hit 2026-09-08 (`docs/tasks/2026-09-08/18-local-bot-players.md`): + a loose "is this pixel dark" threshold to find a player's near-black + home base colour (Goban's `#2b2b3a`) accidentally also matched the grid + LINE's own colour (`#3a2e1f`) closely enough to false-positive on empty + cells throughout the board — match the exact target RGB (±10 or so), + not "generically dark". Separately, sampling dead-centre of a home base + cell reads the CROWN emblem's gold fill (`drawCrown` paints over the + stone there), not the underlying player colour — find crowns first (by + their gold), then sample a point offset below the crown's own path + (e.g. `center.y + radius * 0.7`) to read the actual stone colour + underneath. +- `cp` is aliased to `nocorrect cp -i` in this shell (confirmed 2026-09-08), + so it prompts "overwrite? (y/n)" on any existing destination file. In a + non-interactive/background Bash call there's no TTY to answer that + prompt, so it silently defaults to "not overwritten" and the command + exits 0 as if it had succeeded — a copy meant to refresh a stale file + (e.g. syncing a rebuilt `dist/*.js` into a scratch test harness) can + silently no-op, leaving the OLD file in place while every downstream + step keeps testing against it. Use `\cp` (or `command cp`) to bypass the + alias when overwriting is actually intended, and don't trust `cp`'s exit + code alone as proof a file changed -- spot-check the destination's + content (e.g. `grep` for a constant you just edited) after a `cp` that's + expected to overwrite something. +- Playwright's browser cache lives at `~/Library/Caches/ms-playwright` on + macOS, not `~/.cache/ms-playwright` (that's the Linux path) — check the + right one before concluding a browser isn't installed and re-downloading + it. Confirmed 2026-09-08: `npx playwright install chromium` silently did + nothing useful when run bare (no output, exit 0) because it wants a + project with dependencies installed first — the scratch-directory + `npm install playwright@` step from the fallback above must + happen *before* `npx playwright install`, not after, or the install step + is liable to no-op against an already-cached browser without you + noticing it did nothing new. - This repo's version control is **Jujutsu (`jj`)**, colocated with git (`git status`/`git log` etc. also work and reflect the same history, including a `main` bookmark). Arcadia works directly in `jj`'s diff --git a/docs/memories/project/experimental-agent-branch.md b/docs/memories/project/experimental-agent-branch.md index 43803cd..2bf0978 100644 --- a/docs/memories/project/experimental-agent-branch.md +++ b/docs/memories/project/experimental-agent-branch.md @@ -1,13 +1,38 @@ -# `qusssqlp` — parked experimental "agent can play" branch - -The jj change `qusssqlp` (description "WIP: Agent can play") is an -intentionally parked experimental line, not part of the active line of -work right now. Confirmed by the user 2026-09-08: "qusssqlp is an -experimental branch that I don't present right now." - -**How to apply:** Don't default to folding `qusssqlp` (or whatever WIP -commit sits on that line later) into the active working copy — e.g. when -pulling later `main` commits forward, prefer starting fresh on `main` -(`jj new main`) over merging/rebasing it in, unless the user asks for the -agent-play work specifically. See [[version-control-jujutsu]] for the jj -mechanics of how this was done. +# `qusssqlp` — abandoned "agent can play" branch (permanent, as of 2026-09-08) + +The jj change `qusssqlp` (description "WIP: Agent can play") held the +Claude-agent-player work: `frontend/src/{agent,session}.ts`, +`backend/src/{agent,rules}.ts`, and its own task docs. As of 2026-09-08 +this is **permanently abandoned**, not merely parked — do not revive it or +propose the same approach again. It is superseded by +`docs/tasks/2026-09-08/18-local-bot-players.md`: deterministic TypeScript +bot strategies (`frontend/src/bot/.ts`) that play through the +same UI-facing functions a human does, plus a post-game transcript for +Claude to iterate on strategy afterward — no LLM calls at runtime. + +The five faults that motivated abandoning the agent-player approach +(these are the durable part; the memory docs that originally recorded +them, `abandoned-agent-players.md` and `agent-player.md`, exist only on +`qusssqlp` itself and are not in the live tree): + +- **Latency** — seconds per turn × dozens of turns, multiplied by a retry + loop. Arcadia: "It was WAY too slow to be enjoyable." +- **Weak play** — the model read occupancy off the ASCII board + unreliably; one verified turn cost three API calls to two + occupied-cell guesses. +- **No artifact** — successful turns were never logged, so all of the + model's reasoning evaporated on commit. Nothing accumulated between + games. +- **Duplicated rules** — `agent.firstRejection` was a hand-maintained + second copy of `game.place`'s legality checks, existing solely to + phrase rejections in English. +- **Operational weight** — an API key, the Claude Agent SDK, three peer + deps, and a subtle trap where the SDK silently loaded this repo's own + `CLAUDE.md` into the game agent's context. + +**How to apply:** Don't fold `qusssqlp` into the active working copy for +any reason — e.g. when pulling later `main` commits forward, prefer +starting fresh on `main` (`jj new main`) over merging/rebasing it in. If +agent/LLM-driven play comes up again, point at the local-bot-players task +and these five faults rather than resurrecting this branch. See +[[version-control-jujutsu]] for the jj mechanics of how this was done. diff --git a/docs/memories/project/home-bases-and-victory.md b/docs/memories/project/home-bases-and-victory.md index 3089977..4af914c 100644 --- a/docs/memories/project/home-bases-and-victory.md +++ b/docs/memories/project/home-bases-and-victory.md @@ -122,6 +122,21 @@ game is over" lives — see `separation-of-concerns.md` for why the UI's button-disabled state is a display detail on top of this, not a substitute for it. +**Consequence for any turn-driving code (not just the UI): `gameStatus` is +re-derived from live pieces at any moment, not only at turn boundaries.** +Elimination is a territory *state*, not an event, so a player's own +mid-turn placements can already surround an opponent's base and end the +game — via the lone-survivor rule above — before that player's own +`endTurn` is ever called for the turn that did it. Confirmed for real +2026-09-08 while building `frontend/src/bot.ts`'s driver +(`docs/tasks/2026-09-08/18-local-bot-players.md`): a bot's own placements +ended a real simulated game at turn 67, and `endTurn` correctly returned +`null` immediately per the guard above, since there was nothing left to +end. A caller that treats "I just placed legally, so `endTurn` on my own +turn must succeed" as an invariant will misread this as an error — it +isn't one; check `gameStatus(state).result.kind !== "playing"` yourself +first and treat that as a normal conclusion, not a fault. + **Still open and deliberately so: game *length*.** A 32x32 board is 1,024 cells, and board-full may take hundreds of turns to reach. A stagnation or turn-limit trigger was considered and deferred — no full game has actually diff --git a/docs/memories/project/separation-of-concerns.md b/docs/memories/project/separation-of-concerns.md index 25234d7..39388bc 100644 --- a/docs/memories/project/separation-of-concerns.md +++ b/docs/memories/project/separation-of-concerns.md @@ -94,6 +94,43 @@ session is most likely to get wrong. `showPanel`. If this ever needs collapsing further, the fix is a shared helper in `ui.ts`, not view logic in `main.ts`. +## Known performance traps + +- `game.pieceAt` is a linear `.find` over `state.pieces`, so + `pendingEncirclements` (8 `pieceAt` lookups per piece via `ringAt`) is + effectively O(pieces²), and `pendingTerritories` runs two full-board + flood fills per call. Both are fine at the once-per-render cadence they + were written for, but calling either inside a per-candidate-cell loop + (a move search, a hint feature, a bot scoring every legal placement) is + roughly 100M operations on a late-game 16x16 board — build a + position→piece `Map` once and reason locally instead. `pendingCuts` is + cheap by comparison (scans only this-turn stones) and safe to probe per + candidate. See `bot-strategies.md` for the full writeup; this is the + mechanism that could reintroduce the LLM agent player's fatal latency + into a design with no network calls at all. +- **`game.place` itself is not cheap, and this is easy to miss** since + nothing in its name suggests it. It calls `gameStatus` unconditionally + (to check the game hasn't ended), which runs `eliminatedPlayers` → + `territoryByPlayer`'s flood fills and filters `state.pieces` through + `isOverlay`, which itself calls the same linear `pieceAt` scan as + above — so a single `place` call is roughly O(players × pieces) on top + of the flood fills, not O(1). This matters because `place` is also the + legality oracle every bot strategy is supposed to route through (see + `docs/bots.md`) instead of duplicating rules — `bot/lib.ts`'s `moves` + calls it once per enumerated candidate by design. Found by Seer's review + 2026-09-08 of `docs/tasks/2026-09-08/18-local-bot-players.md`: a + strategy that additionally does its own lookahead by calling `moves` + again per lookahead candidate (`bot/cuts.ts`'s `setupValue`) compounds + this multiplicatively — measured ~230ms for one Cuts turn decision on a + 195-piece board before that lookahead's own search was narrowed and + its candidate count (`LOOKAHEAD_CANDIDATES`) lowered, which got it down + to ~114ms — still not fully cheap, since the residual cost is `place`'s + own per-call overhead, not anything left to trim in the calling + strategy. A real fix (memoizing/indexing `pieceAt`, or letting a caller + skip `gameStatus` on a speculative `place`) would need a `game.ts` + change, which is Arcadia's call — flagged for her in the task doc + rather than done. + ## Known invariants - Every state transition in `game.ts` is non-mutating AND preserves `Piece` diff --git a/docs/tasks/2026-09-08/18-local-bot-players.md b/docs/tasks/2026-09-08/18-local-bot-players.md new file mode 100644 index 0000000..985eb50 --- /dev/null +++ b/docs/tasks/2026-09-08/18-local-bot-players.md @@ -0,0 +1,457 @@ +# Local bot players + +Status: Complete + +## Goal + +Replace the abandoned LLM-agent-player approach with deterministic bot +players written as TypeScript strategy modules that play in the browser +exactly as a human does, minus the clicking. + +- Strategy modules live at `frontend/src/bot/.ts`, one strategy + per file. +- Claude owns those files and iterates on them over time; each file carries + its own idea and dated revision log. +- Games can mix human and bot players in any combination. +- At the end of a game, a play-by-play transcript is produced so Claude can + iterate on strategies afterwards, from the whole game at once — rather + than reasoning about individual moves during play. + +## Decisions Arcadia has already made + +1. **Transcript delivery:** a small dev-server endpoint writes the + transcript to a file in the repo. Not a browser download. +2. **All-pass termination:** after two full turn rotations with no plays, + the game terminates and is ended. This only really arises when every + player is a bot. **Resolved during design, not as a `game.ts` rules + change:** Seer's `passStreak`-on-`BoardState` proposal was rejected + outright by Arcadia ("Ahhh, I hate this. I don't want that to be a + rule. Let's leave it out for now.") — see + `docs/memories/project/bot-strategies.md`. The rule turned out + unnecessary: a correctly written strategy never voluntarily passes + (passing is reserved for "no legal cell exists," which on this board + means the board is full, at which point `boardIsFull` and the existing + score comparison already end the game). A pass while legal moves exist + is therefore a **bot bug**, not a game state — `main.ts` has a driver- + level idle guard that stops scheduling and reports it after + `2 × players.length` consecutive empty bot turns. `game.ts` is + untouched; `BoardState` gains no field; no winner is ever declared by a + pass. +3. **Headless tournament runner: deferred.** Not part of this task. +4. **Bots cannot concede.** Passing suffices. Revisit later. +5. **No setup UI.** Roster configuration (who is human, which strategy + plays which slot) is done by editing `frontend/src/main.ts` and + rebuilding. No form, no URL params. + +## Constraints + +Claude may edit `frontend/src/bot/*.ts`; Claude may not edit `game.ts`. A +strategy that needs a rule change is a conversation with Arcadia, never a +commit. + +## Why this replaces the agent player + +The Claude-agent-player work (`frontend/src/{agent,session}.ts`, +`backend/src/{agent,rules}.ts`, and its own task docs) is parked on jj +change `qusssqlp` and is now abandoned for the second and final time. Do +not propose it again. Its faults, on record: + +- **Latency** — seconds per turn × dozens of turns, multiplied by a retry + loop. Arcadia: "It was WAY too slow to be enjoyable." +- **Weak play** — the model read occupancy off the ASCII board unreliably; + one verified turn cost three API calls to two occupied-cell guesses. +- **No artifact** — successful turns were never logged, so all of the + model's reasoning evaporated on commit. Nothing accumulated between + games. +- **Duplicated rules** — `agent.firstRejection` was a hand-maintained + second copy of `game.place`'s legality checks, existing solely to phrase + rejections in English. +- **Operational weight** — an API key, the Claude Agent SDK, three peer + deps, and a subtle trap where the SDK silently loaded this repo's own + `CLAUDE.md` into the game agent's context. + +The new approach inverts the axis: Claude's intelligence moves to design +time (authoring and revising strategy code, and reading match +transcripts), while runtime becomes instant, free, deterministic and +inspectable. It also delivers the project's first executable exercise of +the ruleset — the project still has no test setup of any kind; every rule +shipped so far was verified with throwaway scripts against compiled +`dist/` and then discarded. + +## Design + +Designed by Seer, implemented by Claude (Hacker), 2026-09-08. `game.ts`, +`history.ts`, `canvas.ts`, `theme.ts`, and `CLAUDE.md` are all untouched. + +**New files:** `frontend/src/rng.ts`, `frontend/src/bot.ts`, +`frontend/src/bot/lib.ts`, `frontend/src/bot/cuts.ts`, +`frontend/src/bot/circles.ts`, `frontend/src/bot/tentacles.ts`, +`frontend/src/transcript.ts`, `docs/bots.md`. +**Modified:** `frontend/src/main.ts`, `frontend/src/ui.ts`, +`frontend/index.html` (+ its inline CSS), `backend/src/server.ts`, +`.gitignore`. All follow the repo conventions without exception +(namespace imports, `.js` extensions, one-line file-header comments, +strict mode). `bot.ts`, `bot/*.ts`, `rng.ts`, and `transcript.ts` touch +none of the DOM, `fetch`, `Date.now()`, or `Math.random` — they import +only `game.ts` (and `history.ts`, for `transcript.ts`). + +**`rng.ts`** — `seeded(seed)` (mulberry32) and the one impure function, +`randomSeed()`, called exactly once from `main.ts`. `main.ts` derives one +stream per player plus a separate setup stream from a single seed +(`SEED ?? rng.randomSeed()`), so changing one strategy's draw count can +never perturb another player's choices, and a pinned `SEED` makes the +whole game — home bases included — replay identically. + +**`bot.ts`** — the `Strategy` contract (`id`, `name`, +`nextPlacement(state, random) => Position | null`) and `takeTurn`, which +calls `nextPlacement` once per placement against the live board until it +returns `null` or the budget is spent, validating every suggestion through +`game.place` (the sole legality oracle — no duplicated rules, unlike the +abandoned `agent.firstRejection`). An illegal suggestion ends the turn +immediately with a recorded fault; it is never retried, since a bot is +code, not a model. `MAX_STEPS = 64` guards a runaway strategy (turn length +isn't monotonic — cuts grant bonus placements mid-turn). A forced-fill +safety net completes an incomplete opening turn from cells adjacent to the +own base, since `endTurn` refuses an incomplete opening turn forever; +every shipped strategy's own opening logic is designed to never reach it +(confirmed: 0 forced fills across every simulated game — see Acceptance). +A strategy is a plain module (`bot/cuts.ts` etc. export `id`/`name`/ +`nextPlacement` at top level); `bot.ts` lists them by namespace import in +`strategies`, so adding one is one file plus one line, with no import back +from a strategy to `bot.ts`. + +**`bot/lib.ts`** — the shared substrate: `occupancy`/`at` (a `Map` built +once per call, the fix for the O(pieces²) cost trap in +`separation-of-concerns.md`), geometry helpers (`ORTHOGONAL`/`DIAGONAL`/ +`DIRECTIONS`/`shift`/`chebyshev`/`inBounds`), `ownBase`, `hasOwnNeighbour` +(shared by `circles.ts` and `tentacles.ts`), `bestBy` (max-score pick with +random tie-break), `openingTriple` (a reusable "pick one of several 3-cell +patterns on the first opening placement, then reconstruct the same choice +on later calls from the board alone" — shared by `cuts.ts` and +`circles.ts`), and `moves` — the enumerate/evaluate split: only empty +cells within Chebyshev 1 of an existing piece are considered (an isolated +cell is inert under every capture rule), each mapped through `game.place` +and kept only if legal. `allMoves` (every legal cell, not just those near +stones) was **left out**: none of the three shipped strategies need it, +per boundary rule 2 in `separation-of-concerns.md` ("no vocabulary without +a live consumer") — add it if a future strategy actually needs to seed a +distant cluster. Known, accepted limitation documented in `lib.ts` and +`docs/bots.md`: `moves` never enumerates a cell holding exactly one doomed +enemy stone, even though an overlay placement there is legal, so a +strategy can see zero candidates while one legal (overlay) move remains — +none of the three shipped strategies chase overlays. + +**The three strategies** (each file's own header comment carries the full +rationale and a dated revision log, per `docs/bots.md`'s convention): + +- **`cuts.ts`** — scores each candidate by the immediate gain in + `pendingCuts` (home bases filtered out, matching `bonusPlacements`' + own filter), plus a one-ply lookahead computed only for the top 8 + immediate-scoring moves (`LOOKAHEAD_CANDIDATES`, a tunable constant) so + the cost stays ~K×|moves| rather than |moves|². Opening turn: three + cells forming a straight line on one side of the base's own 3×3 + neighbourhood (reconstructed from the first stone already placed this + turn, not redrawn from `random` each call — see the statelessness note + below). +- **`circles.ts`** — scores empty cells by how many of a candidate + 4-cell ring's other three cells are already the bot's own stones, + computed entirely from a local occupancy-map scan (never calls + `game.pendingEncirclements`). Enemy home bases are excluded as targets + outright, since `pendingCaptures` can never let one be captured — the + single most likely bug in this file, spelled out in its own comment. + Opening turn: a compact corner cluster, not a line (rings want + clustered stones). +- **`tentacles.ts`** — the on-vision strategy. **Note: Seer's original + spec (concentric Chebyshev rings around the base) was superseded the + same day, before it was ever run**, after Arcadia sent a reference + screenshot showing the actual intended shape: two thick diagonal arms + growing outward from the base with an empty wedge between them, not + rings filling every direction at once. Implemented as: `ARM_COUNT = 2` + diagonals chosen once from the base position and board size (whichever + diagonals point most toward the board's centre — stable for the whole + game, since a strategy is stateless and re-derives everything from the + board on every call, never from `random`, which would make the arms + wander); each arm is a `ARM_HALF_WIDTH`-wide corridor around its ray + (`perpendicular`/`projection` helpers, verified cell-by-cell against + Arcadia's screenshot), filled outward by smallest distance from base, + ties broken toward the currently-shorter arm then by `random`, widening + the corridor by one cell if the nominal width has no room. Both + constants are named, per Arcadia's own request, since she's expected to + want to try more arms or different widths. The opening turn needs no + special case: the cells at distance 1 in either corridor are exactly + the base's own adjacent cells. Payoff: a 3-wide diagonal band blocks + `enclosedCells`'s flood fill like any other wall, so two diverging arms + eventually pin territory against the border or another player's stones. + +## Seer's implementation review, 2026-09-08 + +Reviewed `bot.ts`/`bot/lib.ts`/`bot/cuts.ts`/`bot/circles.ts`/`bot/tentacles.ts` +and the `main.ts` roster after the live-verification pass above. Four +findings, all addressed: + +1. **Real bug — `lib.openingTriple` pattern reconstruction collided.** + Matching only `placed[0]` against each pattern's cells breaks when two + patterns share a leading offset (`cuts.OPENING_SIDES`' `above` and + `left` both start at the base's NW corner) — `Array.find` silently + resolved to the wrong (earlier-indexed) pattern once a second stone + should have disambiguated it, making `above` play 3/4 of the time and + `left`/`right` unreachable. Not a crash (still a legal straight line), + a silent loss of variety. Fixed: `openingTriple` now matches a pattern + only if it contains *every* placed cell so far, not just the first. +2. **Strategy quality — the lookahead prefilter was arbitrary at 0.** + Early/mid game almost every move scores 0 on immediate `cutCount`, so + "top `LOOKAHEAD_CANDIDATES` by score" degenerated to "first K in + `lib.moves`' scan order" — spatially biased, not meaningful — for + exactly the case where the lookahead is the *only* way Cuts can start a + cut from nothing. Fixed: when the best immediate score is 0, the + lookahead pool is sampled randomly instead of taking the scan-order + prefix. +3. **Design error (Seer's) — `game.place` is not cheap, and it's called + per candidate.** `place` runs `gameStatus` unconditionally, which is + O(pieces²)-ish via `eliminatedPlayers`/`territoryByPlayer`/`isOverlay`'s + linear `pieceAt` scans, plus the flood fills — the same latency trap + as `pendingEncirclements`/`pendingTerritories`, entered through `place` + instead. `lib.moves` calls it once per candidate, and `cuts.ts`'s + `setupValue` called `lib.moves` again for every lookahead candidate, + compounding it. **Measured** (headless-Chromium Playwright against the + real dev server, `bot.takeTurn` for Cuts on a real simulated 195-piece + board, JIT-warmed, 20 samples): **~230ms average per Cuts turn + decision** — the whole synchronous block before a bot's turn even + starts animating. Fixed within `bot/cuts.ts` alone (no `lib.ts` or + `game.ts` change): `setupValue`'s second-ply search no longer rescans + the whole board via `lib.moves(move.next)` — it only checks cells + within `SETUP_RADIUS` (2) of one of this turn's own fresh stones, since + `tryLink` can never look further than that from a fresh source, so + nothing correct is lost. Combined with lowering `LOOKAHEAD_CANDIDATES` + from 8 to 4, this brought the same measurement down to **~114ms + average** (230ms → 182ms after the search-narrowing alone → 114ms + after also lowering K). Re-verified 0 faults across 7 seeds after both + changes (turn counts and the eventual winner shifted, as expected for + a real strategy-quality change — not a bug). + + **~114ms is still well above the ~50ms Seer flagged as the threshold + worth caring about, and the remaining cost is `game.place`'s own + per-call overhead, not anything left in `bot/cuts.ts`'s own search + shape.** The further fix (memoizing `pieceAt` behind a position index) + is a `game.ts` change, which is Arcadia's call, not implemented here. + **Flagged for Arcadia below**, not decided by this task. +4. **Minor — a latent hang in `forcedOpeningFill`.** If it placed zero + cells, no fault was recorded (`placed.length > 0` guarded the `??=`), + so an incomplete opening turn could hang the game forever with no + diagnostic. Currently unreachable, since `generateHomeBases` guarantees + ≥8 Chebyshev separation between bases (all 8 neighbours are free during + the opening) — now stated explicitly as a comment on + `forcedOpeningFill`, and `takeTurn` now records a fault whenever the + opening turn is left incomplete after the forced fill, not only when + the forced fill placed something. + +**`transcript.ts`** — `TurnRecord` is captured on the pre-`endTurn` state +(via `recordTurn`, called by `main.ts`), because per-rule capture +attribution (`pendingEncirclements`/`pendingCuts`/`pendingTerritories`) +depends on `placedThisTurn`, and `endTurn` destroys that evidence by +removing the captured pieces — the same justification `game.ts` already +gives for storing `scores`/`conceded` rather than deriving them. +`recordConcession` handles the concede path separately (no capture +lookups needed, since `game.concede` takes no parting captures); it uses +`pre.activePlayer` directly rather than diffing `conceded` arrays, since +`game.concede` only ever lets the active player concede. `render` produces +five sections (header with the seed and replay instructions, +result-with-how-it-ended, per-player summary, a move list with `FAULT:` +lines, and a labelled ASCII final board) and deliberately never snapshots +the board per turn — the seed plus the move list already make any turn +exactly recoverable by replaying. + +**`main.ts`** — the entire "setup UI": a `SEED` constant, a `Controller` +union (`{kind:"human", name}` or `{kind:"bot", strategy}`, kept local to +`main.ts` and never reaching `game.Player` — who controls a slot is not a +rule), and a `ROSTER` array. `isLocalTurn()` (`ROSTER[activePlayer].kind +=== "human"`) is the one real guard against a human playing a bot's turn, +checked inside `ui.handleClick` (before any board logic) and at the top +of `endHumanTurn`; `showPanel`'s button-`disabled` styling is a display +detail only, per `separation-of-concerns.md` boundary rule 3 (the +window-level Enter/Space listener bypasses `disabled` entirely — the +exact bug that rule was written about). `attachEndTurn` now takes an +`onEndTurn` callback instead of calling `game.endTurn` itself, since +`main.ts` needs the pre-`endTurn` state for `transcript.recordTurn`. +Bot turns are computed instantly and purely via `bot.takeTurn`, then +**replayed** on a timer one placement at a time through `game.place` +(cannot diverge from the computed turn, since every placement was already +validated) — a single copy of the runner logic rather than an instant path +and an animated path that could drift apart. Both `scheduleBotTurn` and +`animateBotTurn` reschedule themselves rather than skip a tick while +`history.isViewingPast(record)` is true, so browsing the log pauses bots +without dropping placements. The idle guard (`IDLE_TURN_LIMIT = +2 × players.length`) counts consecutive **bot** turns with zero +placements and sets `autoplayStopped` — never touches `game.ts` or ends +the game itself, per decision 2 above. + +**One correction found only by headless simulation, not by inspection:** +`game.gameStatus` is re-derived from live pieces at any moment, not only +at turn boundaries, so a bot's own placements can already end the game via +elimination (an opponent's base enclosed) *before* `endTurn` is ever +called for that turn — confirmed against a real 4-bot game at turn 67 of +a seeded run. `endTurn`'s own first guard correctly refuses to run in that +case (nothing left to end), which an earlier draft of `animateBotTurn` +mistook for an error. Fixed: `animateBotTurn` now checks +`game.gameStatus(pre).result.kind !== "playing"` after applying a turn's +placements and, if the game already ended, reports it as a normal +conclusion (triggering `onTurnEnded`'s auto-save) rather than logging a +spurious error and stalling. + +**`ui.ts`** — `PanelElements` gains `saveTranscript`; `attachSaveTranscript` +added alongside the existing attach helpers, wired to a button that is +never disabled (a long or stuck game — captures free up cells, so +`boardIsFull` can take a long while — must never lose its transcript to a +closed tab). `handleClick` gains an `isLocalTurn` parameter, checked +first, before any board/game logic. `showPanel` gains `botStatus`/ +`isLocalTurn` parameters (defaulted to `null`/`true`, so every pre-bot +call site keeps working unchanged); `statusText` checks `botStatus` after +the viewing-past branch and before the result branches. + +**`backend/src/server.ts`** — `POST /api/transcript` writes to +`docs/games/` (gitignored — scratch Claude reads from the working tree, +not project history), confined there by `path.basename` plus a character +whitelist on the filename. Commented as a local-development convenience +that must never be exposed publicly. This is what makes `CLAUDE.md`'s +"Backend's only job right now: serve frontend/ as static files" stale — +flagged for Arcadia below, not edited here. + +**`docs/bots.md`** — written for the Claude session that iterates on +strategies: the contract, the permission boundary (bot files only, never +`game.ts`), the performance rules, the revision-log convention, and how to +read/replay a transcript. + +## Three things flagged for Arcadia, not decided here + +- `CLAUDE.md`'s "Backend's only job right now: serve frontend/ as static + files" is now stale (the transcript endpoint). Worth a reword; not + edited by this task. +- `CLAUDE.md` has no pointer to `docs/bots.md`. Might help a future + session find the strategy contract faster; her call. +- **`game.place` performance.** Even after the two bot-side mitigations + above, a Cuts turn decision measured ~114ms on a 195-piece board — real + but hopefully-tolerable latency masked in practice by the existing + `TURN_DELAY_MS`/`PLACEMENT_DELAY_MS` pacing, not eliminated. `place` + runs `gameStatus` (and therefore the elimination flood fills and + `isOverlay`'s linear `pieceAt` scans) unconditionally on every call, so + any candidate-scoring strategy that calls it many times per decision + pays for that. A memoized/indexed `pieceAt` (or a way to skip + `gameStatus` on a purely-speculative `place` call) would fix this at + the source for every future bot, not just `cuts.ts` — but it's a + `game.ts` change, out of scope for bot files and Arcadia's call, not + ours. + +## Out of scope + +A setup UI for roster configuration, a headless tournament runner, bot +concession, and any change to `game.ts`'s rules — the all-pass termination +question turned out not to need one at all (see decision 2 above). + +## Acceptance + +1. A four-bot game runs start to finish unattended, stone by stone, and + ends with a winner or a draw. **Verified:** a headless simulation + harness (compiled `dist/` imported from a scratch `.mjs` script, per + `docs/memories/project/build-and-serve.md`) ran a 4-bot + (Tentacles/Cuts/Circles/Tentacles) game to completion across 5 seeds + (68–163 turns each); all reached a `"won"` result with **zero faults** + recorded across every turn of every run. One seed's `generateHomeBases` + legitimately returned `null` (pre-existing possible outcome of that + function, unrelated to bots) and correctly threw the same + already-existing error message. +2. A mixed human/bot game refuses clicks on a bot's turn (mouse and the + window-level Enter/Space path) and accepts them on the human's. + **Verified live**: `isLocalTurn()` gates both `ui.handleClick` (before + any board logic runs) and `endHumanTurn` (before `game.endTurn` is ever + called); `attachEndTurn`'s keydown listener reuses `button.disabled`, + which `showPanel` sets from the same `isLocalTurn` value. Also verified + against the actual running app (headless Playwright driving a real dev + server, mouse clicks through real DOM coordinates computed via + `canvas.computeLayout`/`gridPointToPixel`): played Arcadia's opening + turn for real, clicked End Turn, and while the panel read "Tentacles is + playing…" clicked an untouched far corner cell — it stayed empty + through the rest of that bot's turn, confirming the click was actually + refused by the running click handler, not merely by code reading. The + window-level Enter/Space path specifically was not re-driven live (it's + the same `button.disabled` value already exercised); zero console/page + errors throughout. +3. Each strategy completes a legal opening turn — exactly + `placementsPerTurn` stones adjacent to its own base — with no fault + recorded; the forced-fill path is never exercised in normal play. + **Verified:** 0 faults across every simulated game (5 seeds, 500+ + combined turns), which includes every strategy's own opening turn. +4. Two loads with the same `SEED` and roster produce identical games. + **Verified structurally**: home bases and every bot decision are drawn + from `rng.seeded(seed + offset)` streams with no other entropy source + in `bot/*.ts`/`rng.ts`; re-running the same seed through the headless + harness reproduced identical turn-by-turn output. Not re-verified + through the actual browser with `SEED` pinned in `main.ts` this + session (would require an edit-rebuild-reload cycle); the mechanism is + identical to the one already exercised live for home base generation + and bot decisions individually. +5. Circles never targets an enemy home base; Cuts excludes home bases from + its `pendingCuts` score; Tentacles fills the inner corridor before + widening. **Verified by code inspection** (each is a direct filter/ + guard in the relevant file) and indirectly by the simulation output + (no capture ever attributed to a home base; Tentacles' territory + captures grew from small to large over the game, consistent with + corridor-outward filling). +6. Browsing the turn log mid-game pauses the bots; returning to the + present resumes them with no placements lost. **Verified live**: + played two human turns, let bot turns begin, clicked the turn-0 log + row mid-animation — the board gained the `viewing-past` class, the + status line read "Viewing turn 0 — read-only", and the canvas's pixel + data stayed byte-identical across a full second of otherwise-active + bot animation (confirmed frozen, not just visually similar). Clicking + the newest log row afterward removed `viewing-past`, and the log grew + from 7 to 18 rows over the following few seconds — the bots resumed + and nothing was lost. +7. A transcript is written to `docs/games/` on game end and on the Save + button; names the seed, roster, per-rule capture counts, faults, and + final board. **Verified live end to end**: ran the actual dev server + (`backend/dist/server.js`), played real turns (including a concession + via the dialog's Cancel-then-Confirm path) through the real browser + UI, clicked Save transcript, and read the resulting file back from + `docs/games/` — header (seed, roster, replay instructions), + result/ended line, per-player summary, a `T0 Arcadia: CONCEDED` move + line, and a final board with Arcadia's base correctly absent were all + present and correct. This live pass caught a real bug fixed in this + session (see below). +8. A deliberately broken strategy produces a fault and does not hang the + game, including on the opening turn. **Verified by code inspection**: + `bot.takeTurn` records a fault and stops on any `game.place` rejection, + and the forced-opening-fill path always completes an opening turn + regardless. Not exercised with an actual intentionally-broken strategy + this session — worth a follow-up smoke test if a future strategy + change makes this path suspect. +9. `tsc --noEmit` clean in both packages; no strategy calls `Math.random`, + touches the DOM, or calls `pendingEncirclements`/`pendingTerritories` + inside a candidate loop. **Verified**: both packages built clean + (`tsc --noEmit` and `tsc`); `bot/*.ts`/`lib.ts`/`bot.ts`/`rng.ts`/ + `transcript.ts` import only `game.ts` (and `history.ts` for + `transcript.ts`) plus each other — confirmed by reading every import + line, not just grepping for the banned calls. + +### Two bugs found only by running this, not by reading it + +- **`animateBotTurn` mistook a legitimate early game-end for an error.** + `game.gameStatus` is re-derived from live pieces at any moment, not only + at turn boundaries, so a bot's own placements can already end the game + via elimination before `endTurn` is ever called for that turn — hit for + real at turn 67 of a headless 4-bot simulation. `endTurn`'s refusal to + run in that case is correct (nothing left to end); an earlier draft + logged it as `console.error` and stalled instead of auto-saving. Fixed + as described in the Design section above. +- **The transcript's "Ended:" line overclaimed.** It unconditionally read + "autoplay stopped (idle guard)" for any still-`"playing"` game, which is + wrong for the far more common case of a manual mid-game Save (confirmed + live: saving right after a concession, with no idle guard ever + triggered, produced that exact false claim). `transcript.ts` has no + signal to distinguish the two cases (`autoplayStopped` is a `main.ts` + runtime flag never threaded through), so the wording was softened to + stay honestly generic rather than guess. + +Both were caught by playing the real app end to end (headless Playwright +against a real dev server: real clicks, a real concession, a real +Save-transcript round trip) after the code already read correctly and +`tsc` was clean — neither would have surfaced from static review alone. diff --git a/frontend/index.html b/frontend/index.html index 9bf36d8..05734b1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -162,6 +162,15 @@ cursor: default; opacity: 0.5; } + #save-transcript { + padding: 6px 16px; + font-size: 13px; + cursor: pointer; + background: transparent; + color: var(--bb-muted-text, rgba(255, 255, 255, 0.6)); + border: 1px solid var(--bb-button-border, rgba(255, 255, 255, 0.25)); + border-radius: 4px; + } #concede-dialog { max-width: 380px; box-sizing: border-box; @@ -206,6 +215,41 @@ border: 1px solid var(--bb-danger, #b3202c); border-radius: 4px; } + #transcript-saved-dialog { + max-width: 380px; + box-sizing: border-box; + padding: 20px; + background: var(--bb-panel-bg, #16161a); + color: var(--bb-text, rgba(255, 255, 255, 0.85)); + border: 1px solid var(--bb-success, #2f9e44); + border-radius: 6px; + font: 14px/1.5 system-ui, sans-serif; + } + #transcript-saved-dialog::backdrop { + background: rgba(0, 0, 0, 0.6); + } + #transcript-saved-dialog h2 { + margin: 0 0 8px; + font-size: 17px; + color: var(--bb-success, #2f9e44); + } + #transcript-saved-dialog p { + margin: 0 0 16px; + word-break: break-word; + } + #transcript-saved-dialog .transcript-saved-actions { + display: flex; + justify-content: flex-end; + } + #transcript-saved-ok { + padding: 8px 16px; + font-size: 14px; + cursor: pointer; + background: var(--bb-success, #2f9e44); + color: var(--bb-success-text, #eafbea); + border: 1px solid var(--bb-success, #2f9e44); + border-radius: 4px; + } @@ -219,6 +263,7 @@ +

Concede the game?

@@ -233,6 +278,13 @@
+ +

Transcript saved!

+

Saved to .

+
+ +
+
diff --git a/frontend/src/bot.ts b/frontend/src/bot.ts new file mode 100644 index 0000000..f91507f --- /dev/null +++ b/frontend/src/bot.ts @@ -0,0 +1,132 @@ +// bot.ts owns what a bot strategy is and how one takes a turn. Pure: no +// DOM, no network, no clock -- see docs/bots.md for the contract strategy +// authors write against. + +import * as game from "./game.js"; +import * as lib from "./bot/lib.js"; +import * as cuts from "./bot/cuts.js"; +import * as circles from "./bot/circles.js"; +import * as tentacles from "./bot/tentacles.js"; + +export type Strategy = { + readonly id: string; + readonly name: string; + // Called once per placement, on the live board, until it returns null or + // the turn's placement budget is spent. Must not mutate state; must not + // call Math.random -- use the supplied `random` so a whole game replays + // identically from its seed. + readonly nextPlacement: (state: game.BoardState, random: () => number) => game.Position | null; +}; + +// A strategy is a module, not a registered object -- each of bot/*.ts +// exports `id`, `name` and `nextPlacement` at top level, and is listed +// here by its own namespace import. No import back from a strategy to this +// file, so no cycle, and structural typing makes a drifting strategy file +// a compile error at this one line. Adding a strategy is: one new file, +// one line here. +export const strategies: readonly Strategy[] = [cuts, circles, tentacles]; + +export const byId = (id: string): Strategy | undefined => + strategies.find((strategy) => strategy.id === id); + +export type TurnOutcome = { + readonly placements: readonly game.Position[]; // in order, all legal + readonly state: game.BoardState; // after placements, BEFORE endTurn + readonly fault: string | null; +}; + +// A generous ceiling, not a turn-length estimate: `remaining` is NOT +// monotonically decreasing during a turn, since each pending cut grants a +// bonus placement mid-turn (game.ts's bonusPlacements). This guards against +// a strategy bug that keeps extending its own turn, or loops outright -- +// not against a long but ordinary cutting spree. +const MAX_STEPS = 64; + +// The opening-turn safety net: endTurn refuses an incomplete opening turn +// forever, so a strategy that stops early or faults on turn one would +// otherwise hang the game permanently. Fills the remaining opening +// placements from empty cells adjacent to the active player's own base -- +// this path exists for faults only; every shipped strategy defines its own +// opening behaviour and should never actually reach it in normal play. +// +// This can only actually fill every remaining slot because +// game.generateHomeBases guarantees MIN_HOME_BASE_DISTANCE (>= 8) +// Chebyshev separation between bases, so all 8 of a base's own neighbours +// are free during the opening turn -- no other player's stones can reach +// them yet. If that home-base placement guarantee ever loosens, this can +// legitimately run out of legal neighbours and place fewer than needed; +// takeTurn below now records a fault whenever that happens (not only when +// it placed nothing at all), rather than silently leaving the opening +// turn incomplete forever. Flagged by Seer's review 2026-09-08. +const forcedOpeningFill = (state: game.BoardState): { state: game.BoardState; placed: game.Position[] } => { + const base = state.pieces.find( + (piece) => piece.isHomeBase && piece.player === state.activePlayer, + ); + const placed: game.Position[] = []; + let current = state; + if (!base) { + return { state: current, placed }; + } + for (const offset of lib.DIRECTIONS) { + if (game.remaining(current) <= 0) { + break; + } + const candidate = lib.shift(base.position, offset); + const next = game.place(current, candidate); + if (next) { + current = next; + placed.push(candidate); + } + } + return { state: current, placed }; +}; + +export const takeTurn = ( + state: game.BoardState, + strategy: Strategy, + random: () => number, +): TurnOutcome => { + let current = state; + const placements: game.Position[] = []; + let fault: string | null = null; + let steps = 0; + + while (game.remaining(current) > 0) { + if (++steps > MAX_STEPS) { + fault = `${strategy.id} exceeded ${MAX_STEPS} placements in a single turn`; + break; + } + const position = strategy.nextPlacement(current, random); + if (position === null) { + break; + } + const next = game.place(current, position); + if (!next) { + fault = `${strategy.id} suggested an illegal placement at (${position.row},${position.col})`; + break; + } + current = next; + placements.push(position); + } + + if (game.isOpeningTurn(current) && game.remaining(current) > 0) { + const { state: filled, placed } = forcedOpeningFill(current); + current = filled; + placements.push(...placed); + // Faults whenever the opening turn is left incomplete here, not only + // when the forced fill placed something -- a zero-placement forced + // fill (e.g. every base neighbour is somehow already occupied) would + // otherwise record no fault at all while still leaving the opening + // turn incomplete, hanging the game permanently and silently. See + // forcedOpeningFill's own comment for why this shouldn't be reachable + // today. + if (game.isOpeningTurn(current) && game.remaining(current) > 0) { + fault ??= `${strategy.id} left the opening turn incomplete; forced fill placed ${placed.length} ` + + `and still has ${game.remaining(current)} remaining -- game may hang`; + } else if (placed.length > 0) { + fault ??= `${strategy.id} left the opening turn incomplete; forced ${placed.length} placement(s)`; + } + } + + return { placements, state: current, fault }; +}; diff --git a/frontend/src/bot/circles.ts b/frontend/src/bot/circles.ts new file mode 100644 index 0000000..cd63b79 --- /dev/null +++ b/frontend/src/bot/circles.ts @@ -0,0 +1,102 @@ +// bot/circles.ts -- "always tries to do encirclings": scores empty cells by +// how close they bring an orthogonal or diagonal 4-cell ring around an +// enemy stone to completion, entirely from local occupancy-map lookups. +// Deliberately does NOT call game.pendingEncirclements -- see docs/bots.md's +// performance rules; this file computes a cheaper local equivalent instead. +// +// A strategy must never voluntarily pass: nextPlacement only returns null +// when lib.moves finds no legal candidate at all. +// +// Revision log: +// - 2026-09-08: initial version, designed by Seer. + +import * as game from "../game.js"; +import * as lib from "./lib.js"; + +export const id = "circles"; +export const name = "Circles"; + +const PATTERNS: readonly (readonly lib.Offset[])[] = [lib.ORTHOGONAL, lib.DIAGONAL]; + +// A compact corner cluster (an L), not a line -- rings need clustered +// stones around a target, not a spread-out wall. +const OPENING_CLUSTERS: readonly (readonly lib.Offset[])[] = [ + [{ row: -1, col: -1 }, { row: -1, col: 0 }, { row: 0, col: -1 }], // NW + [{ row: -1, col: 1 }, { row: -1, col: 0 }, { row: 0, col: 1 }], // NE + [{ row: 1, col: -1 }, { row: 1, col: 0 }, { row: 0, col: -1 }], // SW + [{ row: 1, col: 1 }, { row: 1, col: 0 }, { row: 0, col: 1 }], // SE +]; + +// One pass over every enemy non-base piece, scoring the empty ring cells +// around it by how many of the other three ring cells are already this +// player's own stones. Enemy home bases are excluded as targets outright +// (the `piece.isHomeBase` check below) -- game.pendingCaptures never lets +// a base be captured, so a ring scored around one could never actually +// complete. This is the single most likely bug in this file, which is why +// it's spelled out rather than left implicit. +// +// No "was a ring member placed this turn" check is needed: the cell being +// scored is itself empty right now, so the placement that fills it is +// necessarily fresh -- game.ts's own freshness requirement +// (isCapturingRing) holds automatically. Adding a redundant freshness +// check here would be dead code. +const scoreMoves = (state: game.BoardState, occ: lib.Occupancy): Map => { + const scores = new Map(); + const add = (position: game.Position, amount: number): void => { + const scoreKey = lib.key(position); + scores.set(scoreKey, (scores.get(scoreKey) ?? 0) + amount); + }; + + for (const piece of state.pieces) { + if (piece.player === state.activePlayer || piece.isHomeBase) { + continue; + } + for (const pattern of PATTERNS) { + const ring = pattern.map((offset) => lib.shift(piece.position, offset)); + if (!ring.every((cell) => lib.inBounds(state.size, cell))) { + continue; + } + const occupants = ring.map((cell) => lib.at(occ, cell)); + const own = occupants.filter( + (occupant) => occupant !== undefined && occupant.player === state.activePlayer, + ).length; + const empty = ring.filter((_cell, index) => occupants[index] === undefined); + if (own === 3 && empty.length === 1) { + add(empty[0], 100); + } else if (own === 2) { + empty.forEach((cell) => add(cell, 10)); + } else if (own === 1) { + empty.forEach((cell) => add(cell, 1)); + } + } + } + return scores; +}; + +export const nextPlacement = ( + state: game.BoardState, + random: () => number, +): game.Position | null => { + const available = lib.moves(state); + if (available.length === 0) { + return null; + } + + if (game.isOpeningTurn(state)) { + return lib.openingTriple(state, OPENING_CLUSTERS, random) + ?? lib.bestBy(available, () => 0, random)?.position + ?? null; + } + + const occ = lib.occupancy(state); + const scores = scoreMoves(state, occ); + const best = lib.bestBy(available, (move) => scores.get(lib.key(move.position)) ?? 0, random); + if (best && (scores.get(lib.key(best.position)) ?? 0) > 0) { + return best.position; + } + + // Nothing to encircle yet: stay compact rather than scatter. + const compact = available.filter((move) => lib.hasOwnNeighbour(state, occ, move.position)); + const pool = compact.length > 0 ? compact : available; + return lib.bestBy(pool, () => 0, random)?.position ?? null; +}; diff --git a/frontend/src/bot/cuts.ts b/frontend/src/bot/cuts.ts new file mode 100644 index 0000000..e8a759c --- /dev/null +++ b/frontend/src/bot/cuts.ts @@ -0,0 +1,215 @@ +// bot/cuts.ts -- "always tries to do cuts": builds a line of its own stones +// out from its base, then chases pendingCuts pressure move to move, with a +// shallow one-ply lookahead so it can see a cut coming before it's already +// complete. No special-cased cut geometry here -- game.pendingCuts (via +// lib.moves' game.place evaluation) is the only source of truth for what +// counts as a cut. +// +// A strategy must never voluntarily pass: nextPlacement only returns null +// when lib.moves finds no legal candidate at all. A pass while legal moves +// exist is a bug the driver's idle guard catches (see bot.ts / main.ts), +// never a game outcome -- Arcadia explicitly rejected an all-pass +// termination rule (docs/memories/project/bot-strategies.md). +// +// Revision log: +// - 2026-09-08: initial version, designed by Seer. +// - 2026-09-08: Seer's review caught the lookahead prefilter defaulting to +// an arbitrary, spatially-biased top-K whenever every move tied at 0 +// immediate score (the common case early/mid game) -- now samples the +// lookahead pool randomly in that case instead of always taking the +// first K in enumeration order. +// - 2026-09-08: Seer's review measured ~230ms/decision on a 195-piece +// board (game.place is expensive -- see setupValue's own comment) and +// found a pattern-reconstruction collision in lib.openingTriple; both +// fixed. setupValue's search narrowed to SETUP_RADIUS and +// LOOKAHEAD_CANDIDATES lowered 8->4, bringing the same measurement to +// ~114ms (residual cost is game.place's own per-call overhead, flagged +// for Arcadia in docs/tasks/2026-09-08/18-local-bot-players.md, not +// fixable from here). +// +// Strategic observation from the same review, not acted on: a cut needs +// three fresh stones, so this one-ply lookahead can only ever see a +// completion once TWO fresh stones are already down. That means the +// first two placements of every turn score 0 everywhere and fall +// through to the random near-own tie-break -- Cuts plays essentially +// blind for two thirds of each turn, then sharp on the third stone. +// That's inherent to one-ply search, not a bug, and NOT worth chasing +// with a two-ply search (the timing numbers above say that's +// unaffordable). The cheaper direction, next time this file gets +// iterated on: a GEOMETRIC heuristic instead of more search -- prefer +// placements collinear with an own fresh stone along one of the 8 +// directions and pointing toward an enemy stone at distance 1 or 2, +// building the cut shape directly rather than discovering it by +// trial placements. + +import * as game from "../game.js"; +import * as lib from "./lib.js"; + +export const id = "cuts"; +export const name = "Cuts"; + +// pendingCuts excludes home bases from what it actually captures, but not +// from what it *returns* -- a piece that would be a cut victim except that +// it's a base still appears in the list. Filtered out here so the score +// never counts a capture that game.ts would never actually award, matching +// bonusPlacements' own home-base filter. +const cutCount = (state: game.BoardState): number => + game.pendingCuts(state).filter((piece) => !piece.isHomeBase).length; + +// Only the top K moves by immediate score get a second-ply lookahead. K is +// a tunable: raise it for a stronger but slower bot. Lowered from 8 to 4 +// alongside setupValue's narrower search (see its own comment) after +// Seer's review measured ~230ms/decision on a 195-piece board -- each +// lookahead candidate still costs a handful of game.place calls, which +// are themselves expensive (see setupValue), so K is a direct multiplier +// on wall-clock cost, not just search breadth. +const LOOKAHEAD_CANDIDATES = 4; + +// A cut needs three own stones in a line, so it's rarely completable from +// nothing in a single placement -- pure greed on cutCount alone would never +// start one. This one-ply lookahead rewards a move that SETS UP a cut next +// turn: the best further gain in cutCount achievable from any single +// following placement. + +// game.place is expensive (it runs gameStatus's elimination flood fills +// unconditionally -- see docs/memories/project/separation-of-concerns.md's +// performance traps), so the NUMBER of place calls the lookahead makes +// matters far more than how the candidates were found. A full +// lib.moves(move.next) rescan here re-derives ~|moves| candidates from +// the WHOLE board and calls place on every one of them, for each of up to +// LOOKAHEAD_CANDIDATES pool entries -- Seer's review measured this +// averaging ~230ms per Cuts turn decision on a 195-piece board, well past +// perceptible. But a second placement can only ever raise cutCount by +// being one of the three stones in a cut chain (source -> link1.target -> +// link2.target) -- it can never be a link's victim, a diagonal joint's +// shoulder, or a pierce's mid-cell, since linkCaptures/tryLink require +// all of those to be ENEMY-owned, and it can't retroactively change +// hasSameOwnerNeighbour for an already-placed enemy victim either. Each +// consecutive pair in that chain is at most 2 apart (a joint is 1, a +// pierce is 2), so the new stone is always within Chebyshev 2 of an +// already-fresh own stone -- this searches exactly that much narrower, +// still-correct set instead of the whole board. Verified by Seer's +// review 2026-09-08 against game.ts's actual link/capture logic, not just +// asserted; re-derive this argument before widening or narrowing the +// radius. +const SETUP_RADIUS = 2; + +const setupCandidates = (state: game.BoardState): game.Position[] => { + const fresh = state.pieces.filter( + (piece) => piece.player === state.activePlayer && piece.turn === state.turn && !piece.isHomeBase, + ); + const seen = new Set(); + const candidates: game.Position[] = []; + for (const piece of fresh) { + for (let dRow = -SETUP_RADIUS; dRow <= SETUP_RADIUS; dRow++) { + for (let dCol = -SETUP_RADIUS; dCol <= SETUP_RADIUS; dCol++) { + if (dRow === 0 && dCol === 0) { + continue; + } + const cell = { row: piece.position.row + dRow, col: piece.position.col + dCol }; + if (!lib.inBounds(state.size, cell)) { + continue; + } + const cellKey = lib.key(cell); + if (seen.has(cellKey)) { + continue; + } + seen.add(cellKey); + candidates.push(cell); + } + } + } + return candidates; +}; + +const setupValue = (move: lib.Move, baseline: number): number => { + let best = 0; + // Deliberately `move.next`, not `state`: on the FIRST placement of a + // turn there are no fresh stones yet, so seeding candidates from + // `state` would return an empty set and silently zero the whole + // lookahead for that call. `move.next` already has the candidate + // move's own stone placed, which is exactly the fresh stone this + // search needs to expand outward from. Don't "simplify" this to + // `state`. + for (const position of setupCandidates(move.next)) { + const next = game.place(move.next, position); + if (!next) { + continue; + } + const gained = cutCount(next) - baseline; + if (gained > best) { + best = gained; + } + } + return best; +}; + +// A tiny partial Fisher-Yates: picks `count` distinct entries from `items` +// off the same seeded `random` stream every other scoring decision here +// already uses, so it stays reproducible from a pinned SEED. +const pickRandomSubset = (items: readonly T[], count: number, random: () => number): T[] => { + const pool = [...items]; + const picked: T[] = []; + while (picked.length < count && pool.length > 0) { + const index = Math.floor(random() * pool.length); + picked.push(...pool.splice(index, 1)); + } + return picked; +}; + +// Three cells adjacent to the base in a straight line -- one of the four +// sides of the base's own 3x3 neighbourhood, e.g. the row directly above +// it. Every cell here is Chebyshev-1 from the base, so this satisfies the +// opening turn's own-base-adjacency rule directly; it's the substrate every +// future cut is built from, not a cut itself. +const OPENING_SIDES: readonly (readonly lib.Offset[])[] = [ + [{ row: -1, col: -1 }, { row: -1, col: 0 }, { row: -1, col: 1 }], // above + [{ row: 1, col: -1 }, { row: 1, col: 0 }, { row: 1, col: 1 }], // below + [{ row: -1, col: -1 }, { row: 0, col: -1 }, { row: 1, col: -1 }], // left + [{ row: -1, col: 1 }, { row: 0, col: 1 }, { row: 1, col: 1 }], // right +]; + +export const nextPlacement = ( + state: game.BoardState, + random: () => number, +): game.Position | null => { + const available = lib.moves(state); + if (available.length === 0) { + return null; + } + + if (game.isOpeningTurn(state)) { + return lib.openingTriple(state, OPENING_SIDES, random) + ?? lib.bestBy(available, () => 0, random)?.position + ?? null; + } + + const baseline = cutCount(state); + const scored = available.map((move) => ({ move, score: 10 * (cutCount(move.next) - baseline) })); + + // Early/mid game, almost every move scores 0 here -- pendingCuts needs a + // near-complete line, which a single placement rarely produces on its + // own. When nothing scores above 0, "top K by score" degenerates to + // "first K in lib.moves' scan order", which is spatially biased + // (state.pieces x DIRECTIONS), not meaningful -- and this lookahead is + // the ONLY mechanism by which Cuts can ever start a cut from nothing, so + // spending it on an arbitrary spatial subset wastes it. Sample the + // lookahead pool randomly instead whenever there's no real immediate + // signal to rank by; once something scores above 0, ranking by that + // score is meaningful again and worth keeping as-is. Found by Seer's + // review 2026-09-08. + const bestImmediate = scored.reduce((max, entry) => Math.max(max, entry.score), 0); + const pool = bestImmediate > 0 + ? [...scored].sort((a, b) => b.score - a.score).slice(0, LOOKAHEAD_CANDIDATES) + : pickRandomSubset(scored, LOOKAHEAD_CANDIDATES, random); + const poolMoves = new Set(pool.map((entry) => entry.move)); + + const withSetup = scored.map((entry) => ({ + move: entry.move, + score: poolMoves.has(entry.move) + ? entry.score + setupValue(entry.move, cutCount(entry.move.next)) + : entry.score, + })); + + return lib.bestBy(withSetup, (entry) => entry.score, random)?.move.position ?? null; +}; diff --git a/frontend/src/bot/lib.ts b/frontend/src/bot/lib.ts new file mode 100644 index 0000000..5273ace --- /dev/null +++ b/frontend/src/bot/lib.ts @@ -0,0 +1,184 @@ +// bot/lib.ts is the shared substrate every strategy builds on: cheap board +// lookups, the candidate moves worth considering, and scored selection. Not +// a strategy itself -- bot.ts lists strategies explicitly (see bot.ts), so +// there's no ambiguity about this file's role. + +import * as game from "../game.js"; + +export type Offset = { row: number; col: number }; + +export const ORTHOGONAL: readonly Offset[] = [ + { row: -1, col: 0 }, { row: 1, col: 0 }, { row: 0, col: -1 }, { row: 0, col: 1 }, +]; +export const DIAGONAL: readonly Offset[] = [ + { row: -1, col: -1 }, { row: -1, col: 1 }, { row: 1, col: -1 }, { row: 1, col: 1 }, +]; +export const DIRECTIONS: readonly Offset[] = [...ORTHOGONAL, ...DIAGONAL]; + +export const shift = (position: game.Position, offset: Offset, steps: number = 1): game.Position => ({ + row: position.row + offset.row * steps, + col: position.col + offset.col * steps, +}); + +export const inBounds = (size: game.BoardSize, position: game.Position): boolean => + position.row >= 0 && position.row < size.rows && + position.col >= 0 && position.col < size.cols; + +export const chebyshev = (a: game.Position, b: game.Position): number => + Math.max(Math.abs(a.row - b.row), Math.abs(a.col - b.col)); + +export const key = (position: game.Position): string => `${position.row},${position.col}`; + +export type Occupancy = ReadonlyMap; + +// Built once per call and handed to every lookup after -- the fix for the +// cost trap recorded in docs/memories/project/separation-of-concerns.md: +// game.pieceAt is a linear scan, so re-deriving occupancy (or worse, +// calling game.pendingEncirclements / game.pendingTerritories) inside a +// per-candidate loop is the one way a bot could reintroduce the turn +// latency that killed the LLM agent player. See docs/bots.md. +export const occupancy = (state: game.BoardState): Occupancy => { + const map = new Map(); + for (const piece of state.pieces) { + map.set(key(piece.position), piece); + } + return map; +}; + +export const at = (occ: Occupancy, position: game.Position): game.Piece | undefined => + occ.get(key(position)); + +export const hasOwnNeighbour = ( + state: game.BoardState, + occ: Occupancy, + position: game.Position, +): boolean => + DIRECTIONS.some((offset) => { + const neighbour = at(occ, shift(position, offset)); + return neighbour !== undefined && neighbour.player === state.activePlayer; + }); + +export const ownBase = (state: game.BoardState, player: number): game.Piece | undefined => + state.pieces.find((piece) => piece.isHomeBase && piece.player === player); + +export type Move = { readonly position: game.Position; readonly next: game.BoardState }; + +// Enumeration is a heuristic filter, not a legality claim: only empty cells +// within Chebyshev 1 of some existing piece are considered. An isolated +// stone is inert under every rule in this game -- cuts need adjacency +// chains, rings need neighbours, territory needs connected walls -- so a +// cell far from any stone is never worth a placement. This cuts the +// candidate set from the whole board down to a few dozen cells, which is +// what makes per-candidate scoring affordable. +// +// Evaluation goes through game.place: each enumerated cell is mapped +// through game.place(state, p) and only the non-null results are kept. +// That gives every strategy a real hypothetical BoardState to score +// against, the legality check comes free from the rules module with zero +// duplicated rules, and the opening turn is handled correctly with no +// special case (place enforces base-adjacency itself). +// +// Known limitation: an occupied-but-legal overlay cell (an empty-looking +// slot that actually holds a single doomed enemy stone) is never +// enumerated here, since it's excluded by the `occ.has` check below -- +// pieceAt/occupancy report the doomed victim, not "empty". A strategy can +// therefore see zero candidates and stop even though an overlay placement +// is technically still legal. Accepted as a rare edge case none of the +// three shipped strategies chase; a future strategy that wants to target +// overlays needs its own enumeration. +export const moves = (state: game.BoardState): Move[] => { + const occ = occupancy(state); + const candidates = new Map(); + for (const piece of state.pieces) { + for (const offset of DIRECTIONS) { + const candidate = shift(piece.position, offset); + if (!inBounds(state.size, candidate)) { + continue; + } + const candidateKey = key(candidate); + if (occ.has(candidateKey) || candidates.has(candidateKey)) { + continue; + } + candidates.set(candidateKey, candidate); + } + } + const result: Move[] = []; + for (const position of candidates.values()) { + const next = game.place(state, position); + if (next) { + result.push({ position, next }); + } + } + return result; +}; + +// Picks the maximum-scoring item, breaking ties with `random` so +// equal-scoring moves vary between seeds instead of always taking the +// first in scan order. Returns null for an empty list. +export const bestBy = ( + items: readonly T[], + score: (item: T) => number, + random: () => number, +): T | null => { + if (items.length === 0) { + return null; + } + let best = -Infinity; + let winners: T[] = []; + for (const item of items) { + const value = score(item); + if (value > best) { + best = value; + winners = [item]; + } else if (value === best) { + winners.push(item); + } + } + return winners[Math.floor(random() * winners.length)] ?? winners[0]; +}; + +// A reusable "opening triple" chooser: given a set of candidate 3-cell +// patterns (offsets from the strategy's own base), picks one pattern on +// the first call of an opening turn and sticks with it across the turn's +// remaining calls by reconstructing the choice from state alone -- +// nextPlacement is called once per placement with no memory of its own, so +// the pattern choice has to be recoverable from the board rather than +// redrawn each call (which would let the pattern change mid-turn as +// `random` advances). Returns null if there's no own base, or the chosen +// pattern's next unplaced cell isn't legal (caller falls back to +// `lib.moves`). +export const openingTriple = ( + state: game.BoardState, + patterns: readonly (readonly Offset[])[], + random: () => number, +): game.Position | null => { + const base = ownBase(state, state.activePlayer); + if (!base) { + return null; + } + const placed = state.pieces.filter( + (piece) => + piece.player === state.activePlayer && + piece.turn === state.turn && + !piece.isHomeBase, + ); + // Matched against EVERY placed cell, not just the first: patterns can + // share a leading offset (e.g. two of cuts.ts's four sides both start + // at the base's NW corner), so matching only placed[0] can resolve to + // the wrong pattern once a second stone disambiguates it, silently + // collapsing several patterns into one and making others unreachable. + // Confirmed by Seer's review 2026-09-08 against cuts.ts's OPENING_SIDES. + const patternContains = (candidate: readonly Offset[], piece: game.Piece): boolean => + candidate.some((offset) => { + const cell = shift(base.position, offset); + return cell.row === piece.position.row && cell.col === piece.position.col; + }); + const pattern = placed.length === 0 + ? patterns[Math.floor(random() * patterns.length)] + : (patterns.find((candidate) => placed.every((piece) => patternContains(candidate, piece))) ?? patterns[0]); + const cells = pattern.map((offset) => shift(base.position, offset)); + const next = cells.find( + (cell) => !placed.some((piece) => piece.position.row === cell.row && piece.position.col === cell.col), + ); + return next && game.place(state, next) ? next : null; +}; diff --git a/frontend/src/bot/tentacles.ts b/frontend/src/bot/tentacles.ts new file mode 100644 index 0000000..c3f97d9 --- /dev/null +++ b/frontend/src/bot/tentacles.ts @@ -0,0 +1,191 @@ +// bot/tentacles.ts -- "sprawls outwards forming square-shapes spreading +// outwards": grows two thick diagonal arms out from its own home base, with +// an empty wedge left between them, rather than filling in every direction +// at once. This is the on-vision strategy (see docs/vision.md) and +// deliberately the simplest of the three, since it's the one Arcadia will +// want iterated most. +// +// The payoff: a three-wide diagonal band is a wall -- a diagonal staircase +// of stones blocks a 4-connected flood fill just like an orthogonal one, so +// game.ts's enclosedCells treats an arm as a genuine territory wall. Two +// arms diverging from a base eventually pin a region against the board's +// border or against another player's stones, and everything caught inside +// is captured by territory once the pincer closes. The band is also dense +// with collinear own stones, which is good substrate for cuts. +// +// A strategy must never voluntarily pass: nextPlacement only returns null +// when lib.moves finds no legal candidate at all. +// +// Both numbers below are read off a single reference screenshot from +// Arcadia -- named constants rather than buried in the logic, since she'll +// want to try more/fewer/wider arms and that should be a one-line edit. +// +// Revision log: +// - 2026-09-08: initial version, designed by Seer -- concentric Chebyshev +// rings around the base, filled outward in every direction at once. +// Superseded the same day, before ever running against a real game (see +// below). +// - 2026-09-08: replaced with two diagonal arms after Arcadia sent a +// reference screenshot showing the intended shape isn't rings -- it's a +// pincer with an empty wedge between two arms. Rings grew in every +// direction simultaneously; arms grow in exactly ARM_COUNT chosen +// diagonals, which is what produces the wedge. + +import * as game from "../game.js"; +import * as lib from "./lib.js"; + +export const id = "tentacles"; +export const name = "Tentacles"; + +const ARM_COUNT = 2; +const ARM_HALF_WIDTH = 1; // corridor width = 2 * ARM_HALF_WIDTH + 1 (3 today) + +// The signed distance from `cell` to the diagonal ray from `base` along +// `d`, in the same units as `d` itself (each step off the ray is 1, not 2) +// -- 0 exactly on the ray, verified against Arcadia's screenshot cell by +// cell. |perpendicular| <= ARM_HALF_WIDTH is what defines the corridor's +// width. +const perpendicular = (base: game.Position, d: lib.Offset, cell: game.Position): number => { + const row = cell.row - base.row; + const col = cell.col - base.col; + return row * d.row - col * d.col; +}; + +// The signed projection of `cell` onto the ray itself: positive on the +// outward side of the base along `d`, zero at the base, negative behind +// it. This is what keeps a corridor from including cells on the opposite +// diagonal (behind the base) even when they'd otherwise have a small +// `perpendicular` value. +const projection = (base: game.Position, d: lib.Offset, cell: game.Position): number => { + const row = cell.row - base.row; + const col = cell.col - base.col; + return row * d.row + col * d.col; +}; + +// The two diagonals pointing most toward the centre of the board, from the +// base's own position -- stable for the whole game and needs no memory, +// which matters because nextPlacement is called fresh for every placement +// with no state carried between calls. A random choice here would make the +// arms wander turn to turn and dissolve the shape; deriving the directions +// from data that never changes (the base and the board size) is what keeps +// them straight. +const armDirections = (base: game.Position, size: game.BoardSize): readonly lib.Offset[] => { + const center = { row: (size.rows - 1) / 2, col: (size.cols - 1) / 2 }; + const toward = { row: center.row - base.row, col: center.col - base.col }; + return [...lib.DIAGONAL] + .sort((a, b) => + (b.row * toward.row + b.col * toward.col) - (a.row * toward.row + a.col * toward.col)) + .slice(0, ARM_COUNT); +}; + +// Every cell in the diagonal band an arm grows through: strictly outward +// from the base (projection > 0) and within halfWidth of the ray. Iterates +// the whole board rather than walking outward from the base -- this board +// is small (game.ts's own enclosedCells does the same) and it stays a flat +// scan with no game.ts lookups, so it's cheap regardless. +const corridor = ( + size: game.BoardSize, + base: game.Position, + d: lib.Offset, + halfWidth: number, +): game.Position[] => { + const cells: game.Position[] = []; + for (let row = 0; row < size.rows; row++) { + for (let col = 0; col < size.cols; col++) { + const cell = { row, col }; + if (projection(base, d, cell) <= 0 || Math.abs(perpendicular(base, d, cell)) > halfWidth) { + continue; + } + cells.push(cell); + } + } + return cells; +}; + +// How far this arm currently reaches: the largest Chebyshev distance among +// the bot's own stones that fall inside this arm's corridor. +const armLength = ( + state: game.BoardState, + base: game.Position, + d: lib.Offset, + halfWidth: number, +): number => { + let max = 0; + for (const piece of state.pieces) { + if (piece.player !== state.activePlayer || piece.isHomeBase) { + continue; + } + if (projection(base, d, piece.position) <= 0 || Math.abs(perpendicular(base, d, piece.position)) > halfWidth) { + continue; + } + max = Math.max(max, lib.chebyshev(base, piece.position)); + } + return max; +}; + +const isAvailable = (available: readonly lib.Move[], cell: game.Position): boolean => + available.some((move) => move.position.row === cell.row && move.position.col === cell.col); + +type Candidate = { position: game.Position; along: number; armLength: number }; + +export const nextPlacement = ( + state: game.BoardState, + random: () => number, +): game.Position | null => { + const available = lib.moves(state); + if (available.length === 0) { + return null; + } + + const base = lib.ownBase(state, state.activePlayer); + if (!base) { + return lib.bestBy(available, () => 0, random)?.position ?? null; + } + + const occ = lib.occupancy(state); + const directions = armDirections(base.position, state.size); + + // Widen from the nominal width to one cell wider before giving up on the + // corridors entirely -- reproduces the ragged stragglers at an arm's tip + // in Arcadia's reference picture, where a couple of cells sit at + // perpendicular distance 2 once the width-1 band is already full there. + for (const halfWidth of [ARM_HALF_WIDTH, ARM_HALF_WIDTH + 1]) { + // Keyed by cell so a cell that falls in both corridors (near the base, + // where the bands can overlap) is considered once, associated with + // whichever arm currently has the shorter length -- consistent with + // the tie-break rule below, and avoids double-weighting that cell when + // picking randomly among equal-along winners. + const candidates = new Map(); + for (const d of directions) { + const length = armLength(state, base.position, d, halfWidth); + const legal = corridor(state.size, base.position, d, halfWidth).filter( + (cell) => lib.at(occ, cell) === undefined && isAvailable(available, cell), + ); + for (const cell of legal) { + const cellKey = lib.key(cell); + const existing = candidates.get(cellKey); + if (!existing || length < existing.armLength) { + candidates.set(cellKey, { position: cell, along: lib.chebyshev(base.position, cell), armLength: length }); + } + } + } + if (candidates.size === 0) { + continue; + } + // Growth rule: fill each corridor outward from the base. Smallest + // `along` first (closest to the base among legal cells), ties broken + // by preferring the arm that's currently shorter (keeps the two arms + // roughly even rather than one racing ahead), then by `random`. + const all = [...candidates.values()]; + const minAlong = Math.min(...all.map((c) => c.along)); + const nearest = all.filter((c) => c.along === minAlong); + const minArmLength = Math.min(...nearest.map((c) => c.armLength)); + const shortestArm = nearest.filter((c) => c.armLength === minArmLength); + return lib.bestBy(shortestArm, () => 0, random)?.position ?? null; + } + + // Neither corridor has room even widened -- fall back to whatever's + // legal, preferring cells near its own stones. + const nearOwn = available.filter((move) => lib.hasOwnNeighbour(state, occ, move.position)); + return lib.bestBy(nearOwn.length > 0 ? nearOwn : available, () => 0, random)?.position ?? null; +}; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 23b3704..0973014 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -5,18 +5,48 @@ import * as ui from "./ui.js"; import * as game from "./game.js"; import * as history from "./history.js"; import * as theme from "./theme.js"; +import * as rng from "./rng.js"; +import * as bot from "./bot.js"; +import * as tentacles from "./bot/tentacles.js"; +import * as cuts from "./bot/cuts.js"; +import * as circles from "./bot/circles.js"; +import * as transcript from "./transcript.js"; const ROWS = 16; const COLS = 16; const BOARD: canvas.BoardConfig = { rows: ROWS, cols: COLS }; -const PLAYERS = [ - { name: "Player 1" }, - { name: "Player 2" }, - { name: "Player 3" }, - { name: "Player 4" }, +// --- Roster configuration ------------------------------------------------- +// This is the entire "setup UI" per Arcadia's decision: no form, no URL +// params. Mixing humans and bots, and which strategy plays which slot, is +// done here and the frontend rebuilt (`npm run build` / `npm run dev` in +// frontend/ -- see docs/memories/project/build-and-serve.md). + +const SEED: number | null = null; // null = fresh seed each load, recorded in the transcript +const PLACEMENT_DELAY_MS = 120; // stone-by-stone animation, within a bot's turn +const TURN_DELAY_MS = 250; // pause between turns before the next bot acts + +type Controller = + | { readonly kind: "human"; readonly name: string } + | { readonly kind: "bot"; readonly strategy: bot.Strategy }; + +const ROSTER: readonly Controller[] = [ + { kind: "human", name: "Arcadia" }, + { kind: "bot", strategy: tentacles }, + { kind: "bot", strategy: cuts }, + { kind: "bot", strategy: circles }, ]; +// Controller stays local to main.ts and never reaches game.Player: who +// controls a slot is not a rule. A bot slot's Player.name is its +// strategy's name, so the panel and transcript read "Tentacles" rather +// than "Player 2". +const PLAYERS: readonly game.Player[] = ROSTER.map((controller) => ({ + name: controller.kind === "human" ? controller.name : controller.strategy.name, +})); + +const IDLE_TURN_LIMIT = 2 * PLAYERS.length; + const canvasEl = document.getElementById("board"); if (!(canvasEl instanceof HTMLCanvasElement)) { throw new Error("Missing #board canvas element"); @@ -72,15 +102,41 @@ if (!(concedeCancelButton instanceof HTMLButtonElement)) { throw new Error("Missing #concede-cancel button element"); } +const saveTranscriptButton = document.getElementById("save-transcript"); +if (!(saveTranscriptButton instanceof HTMLButtonElement)) { + throw new Error("Missing #save-transcript button element"); +} + +const transcriptSavedDialog = document.getElementById("transcript-saved-dialog"); +if (!(transcriptSavedDialog instanceof HTMLDialogElement)) { + throw new Error("Missing #transcript-saved-dialog element"); +} + +const transcriptSavedOkButton = document.getElementById("transcript-saved-ok"); +if (!(transcriptSavedOkButton instanceof HTMLButtonElement)) { + throw new Error("Missing #transcript-saved-ok button element"); +} + const panelElements: ui.PanelElements = { playerList, status: turnStatus, endTurn: endTurnButton, concede: concedeButton, + saveTranscript: saveTranscriptButton, log: turnLog, }; -const homeBases = game.generateHomeBases({ rows: ROWS, cols: COLS }, PLAYERS.length, Math.random); +// One stream per player, plus a separate setup stream. Without this +// separation, changing one strategy's number of draws would silently +// perturb its opponents' choices too, and two matchups would stop being +// comparable -- a subtle way to fool yourself reading a transcript. It +// also fixes the wrinkle build-and-serve.md records, that home bases +// reshuffle on every page load: with a pinned SEED, they don't. +const seed = SEED ?? rng.randomSeed(); +const setupStream = rng.seeded(seed); +const playerStreams = ROSTER.map((_controller, index) => rng.seeded(seed + 1 + index)); + +const homeBases = game.generateHomeBases({ rows: ROWS, cols: COLS }, PLAYERS.length, setupStream); if (!homeBases) { throw new Error("Could not place home bases: board too small for this many players"); } @@ -89,6 +145,42 @@ let layout: canvas.BoardLayout; let record = history.begin(game.initialize(PLAYERS, { rows: ROWS, cols: COLS }, homeBases)); let currentTheme = theme.byId(ui.storedThemeId() ?? "") ?? theme.defaultTheme; +// --- Transcript bookkeeping ------------------------------------------------ + +const startedAt = new Date().toISOString(); + +const setup: transcript.Setup = { + seed, + size: { rows: ROWS, cols: COLS }, + placementsPerTurn: record.present.placementsPerTurn, + roster: ROSTER.map((controller) => + controller.kind === "human" + ? { name: controller.name, kind: "human" as const } + : { name: controller.strategy.name, kind: "bot" as const, strategyId: controller.strategy.id }), + startedAt, +}; + +const log: transcript.TurnRecord[] = []; + +let botStatus: string | null = null; +let autoplayStopped = false; +let idleStreak = 0; +let animationHandle: ReturnType | null = null; + +const activeController = (): Controller => ROSTER[record.present.activePlayer]; + +// The one thing that guards a human from playing a bot's turn (both a +// board click and a keyboard End Turn/Concede) -- deliberately not the +// same thing as `history.isViewingPast`, which applyState already guards +// on its own. showPanel's `disabled` styling folds both in as a display +// detail; this function is the actual enforcement. +const isLocalTurn = (): boolean => activeController().kind === "human"; + +const placementsThisTurn = (state: game.BoardState): game.Position[] => + state.pieces + .filter((piece) => piece.player === state.activePlayer && piece.turn === state.turn && !piece.isHomeBase) + .map((piece) => piece.position); + const render = (): void => { const visibleState = history.visible(record); canvas.renderBoard( @@ -98,7 +190,7 @@ const render = (): void => { ui.toCanvasPieces(visibleState, currentTheme), currentTheme.board, ); - ui.showPanel(panelElements, record, currentTheme); + ui.showPanel(panelElements, record, currentTheme, botStatus, isLocalTurn()); canvasEl.classList.toggle("viewing-past", history.isViewingPast(record)); }; @@ -141,22 +233,186 @@ const setTheme = (next: theme.Theme): void => { render(); }; +// --- Transcript saving: the one impure edge of the driver ------------------ + +const saveTranscript = async (): Promise => { + const text = transcript.render(record, setup, log); + try { + const response = await fetch("/api/transcript", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, filename: transcript.filename(setup) }), + }); + if (!response.ok) { + throw new Error(`status ${response.status}`); + } + const body = await response.json(); + console.info("bit-blossom: transcript saved to", body.path); + ui.showTranscriptSaved(transcriptSavedDialog, body.path); + } catch (error) { + console.error("bit-blossom: could not save transcript", error); + // Fallback so a long game is never lost to a dead dev server. + console.log(text); + } +}; + +// --- Turn driver: human end-turn, concede, and animated bot turns ---------- + +const endHumanTurn = (): void => { + if (!isLocalTurn() || history.isViewingPast(record)) { + return; + } + const pre = record.present; + // game.endTurn itself refuses an incomplete opening turn (returns null) + // -- that's ordinary user behaviour (still placing), not a fault, so + // nothing is logged when it does. + const next = game.endTurn(pre); + if (!next) { + return; + } + log.push(transcript.recordTurn(pre, placementsThisTurn(pre), null)); + applyState(next); + onTurnEnded(); +}; + +// Only the active player can concede (game.concede reads state.activePlayer +// itself and rejects anyone else), so `pre.activePlayer` -- not a diff +// against `next.conceded` -- already identifies who conceded, unambiguously. +const onConcede = (next: game.BoardState): void => { + const pre = record.present; + log.push(transcript.recordConcession(pre)); + applyState(next); + onTurnEnded(); +}; + +const onTurnEnded = (): void => { + render(); + if (game.gameStatus(record.present).result.kind !== "playing") { + void saveTranscript(); + return; + } + if (autoplayStopped) { + return; + } + animationHandle = setTimeout(scheduleBotTurn, TURN_DELAY_MS); +}; + +// Replays a pre-computed turn on a timer, one placement at a time. +// Re-applying through game.place cannot diverge from the computed turn -- +// placement is deterministic and every placement in `outcome` was already +// validated inside bot.takeTurn -- so this keeps a single copy of the +// runner logic instead of an instant path and an animated path that could +// drift apart. +const animateBotTurn = (outcome: bot.TurnOutcome, index: number): void => { + // Browsing the log mid-turn must pause the bot, not drop its + // placements -- reschedule the same tick rather than skip it, so + // returning to the present resumes exactly where the animation left off. + if (history.isViewingPast(record)) { + animationHandle = setTimeout(() => animateBotTurn(outcome, index), TURN_DELAY_MS); + return; + } + + if (index < outcome.placements.length) { + const next = game.place(record.present, outcome.placements[index]); + if (next) { + applyState(next); + } + animationHandle = setTimeout(() => animateBotTurn(outcome, index + 1), PLACEMENT_DELAY_MS); + return; + } + + const pre = record.present; + log.push(transcript.recordTurn(pre, outcome.placements, outcome.fault)); + + // Idle guard: a DRIVER guard, not a rule -- Arcadia explicitly rejected + // making all-pass a game.ts rule (docs/memories/project/bot-strategies.md). + // This exists to catch a strategy bug (voluntarily passing while legal + // moves exist), not to resolve a game: no winner is ever declared here, + // gameStatus(...).result stays whatever game.ts already says, game.ts is + // untouched, and BoardState gains no field. + if (outcome.placements.length === 0) { + idleStreak += 1; + } else { + idleStreak = 0; + } + + if (idleStreak >= IDLE_TURN_LIMIT) { + autoplayStopped = true; + botStatus = "Bots are idle -- autoplay stopped. End Turn still works by hand."; + } else { + botStatus = null; + } + + // gameStatus is re-derived from live pieces at any moment, not only at + // turn boundaries (see game.ts's eliminatedPlayers/gameResultFrom), so + // this turn's own placements can already have ended the game via + // elimination before endTurn is ever called -- confirmed against a real + // 4-bot simulation, not just in theory. endTurn's own first guard + // refuses to run in that case (`gameStatus(state).result.kind !== + // "playing"`), and rightly so: there's no further turn to end. + // record.present already holds the winning board, applied placement by + // placement above, so this is a normal ending, not a fault. + if (game.gameStatus(pre).result.kind !== "playing") { + render(); + onTurnEnded(); + return; + } + + const ended = game.endTurn(pre); + if (!ended) { + // Genuinely unexpected now that the above case is handled: an + // opening turn is always completed by bot.takeTurn's forced fill, and + // scheduleBotTurn only starts a bot turn while the game is playing. + // Surfaced loudly rather than silently stalling if some future change + // breaks one of those invariants. + console.error("bit-blossom: bot turn could not end", outcome); + botStatus = null; + render(); + return; + } + + applyState(ended); + onTurnEnded(); +}; + +const scheduleBotTurn = (): void => { + if (history.isViewingPast(record)) { + animationHandle = setTimeout(scheduleBotTurn, TURN_DELAY_MS); + return; + } + if (isLocalTurn() || autoplayStopped || game.gameStatus(record.present).result.kind !== "playing") { + return; + } + const controller = activeController(); + if (controller.kind !== "bot") { + return; + } + const pre = record.present; + const outcome = bot.takeTurn(pre, controller.strategy, playerStreams[pre.activePlayer]); + botStatus = `${controller.strategy.name} is playing…`; + render(); + animateBotTurn(outcome, 0); +}; + // Applied before the first resize()/render() so a stored non-default theme // doesn't paint Goban chrome for a frame. ui.applyChrome(document.documentElement, currentTheme); -ui.attachInteraction(canvasEl, BOARD, () => layout, () => record.present, applyState); -ui.attachEndTurn(endTurnButton, () => record.present, applyState); +ui.attachInteraction(canvasEl, BOARD, () => layout, () => record.present, isLocalTurn, applyState); +ui.attachEndTurn(endTurnButton, endHumanTurn); ui.attachConcede( concedeButton, concedeDialog, concedeConfirmButton, concedeCancelButton, () => record.present, - applyState, + onConcede, ); ui.attachThemePicker(themeSelect, currentTheme, setTheme); ui.attachLog(turnLog, setView); +ui.attachSaveTranscript(saveTranscriptButton, () => void saveTranscript()); +ui.attachTranscriptSavedDialog(transcriptSavedDialog, transcriptSavedOkButton); window.addEventListener("resize", resize); resize(); +scheduleBotTurn(); diff --git a/frontend/src/rng.ts b/frontend/src/rng.ts new file mode 100644 index 0000000..ba50497 --- /dev/null +++ b/frontend/src/rng.ts @@ -0,0 +1,24 @@ +// rng.ts provides seeded, reproducible pseudo-randomness, so a whole game +// replays exactly from its seed. + +// mulberry32: small, fast, good-enough distribution for game-flavoured +// randomness (home base placement, bot tie-breaking). Not cryptographic -- +// nothing here needs to be. +export const seeded = (seed: number): (() => number) => { + let state = seed >>> 0; + return (): number => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +}; + +// The one impure function in this module, called exactly once by main.ts +// when no seed was pinned. Mirrors the convention game.ts's +// generateHomeBases already set: game.ts and bot code never reach for +// Math.random themselves -- the caller supplies the randomness, which is +// what makes a whole game (home bases, every bot decision) replayable from +// a single recorded seed. +export const randomSeed = (): number => Math.floor(Math.random() * 2 ** 32); diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index dac504f..9a4da7c 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -15,6 +15,8 @@ export type Chrome = { buttonBorder: string; danger: string; dangerText: string; + success: string; + successText: string; }; export type Theme = { id: string; // stable, stored in localStorage @@ -55,6 +57,8 @@ const goban: Theme = { // Deliberately not #e63946, which is player 3's stone colour in this theme. danger: "#b3202c", dangerText: "#fff1f1", + success: "#2f9e44", + successText: "#eafbea", }, }; @@ -87,6 +91,10 @@ const kanagawa: Theme = { buttonBorder: "#54546D", danger: "#C34043", dangerText: "#DCD7BA", + // Deliberately not #98BB6C/#6E8F4A, which are player 3's stone/home + // colours in this theme. + success: "#76946A", + successText: "#DCD7BA", }, }; @@ -120,6 +128,11 @@ const catppuccinMocha: Theme = { // Mocha's red is light, so it takes dark text. danger: "#f38ba8", dangerText: "#11111b", + // Mocha's teal, not #a6e3a1/#6fae6b (player 3's stone/home colours in + // this theme) -- also light, so it takes dark text for the same reason + // danger does. + success: "#94e2d5", + successText: "#11111b", }, }; diff --git a/frontend/src/transcript.ts b/frontend/src/transcript.ts new file mode 100644 index 0000000..dee597d --- /dev/null +++ b/frontend/src/transcript.ts @@ -0,0 +1,247 @@ +// transcript.ts renders a finished (or in-progress) game into a +// human/Claude-readable play-by-play, so strategies in frontend/src/bot/ +// can be iterated on after the fact, from a whole game at once, rather +// than by reasoning about individual moves during play. Pure: no DOM, no +// network, no clock -- main.ts supplies `startedAt` and does the actual +// fetch to save the rendered text. + +import * as game from "./game.js"; +import * as history from "./history.js"; + +export type RosterEntry = { name: string; kind: "human" | "bot"; strategyId?: string }; + +export type Setup = { + seed: number; + size: game.BoardSize; + placementsPerTurn: number; + roster: readonly RosterEntry[]; + startedAt: string; // ISO string, supplied by main.ts +}; + +// Per-rule capture counts can overlap: a piece claimed by both a cut and a +// territory wall is one capture but appears in both counts, since +// game.pendingCuts and game.pendingTerritories are independent checks over +// the same board. capturedTotal is the deduped figure (game.pendingCaptures +// itself already dedupes and excludes home bases) -- the three per-rule +// counts are a diagnostic breakdown, not parts that sum to it. +export type TurnRecord = { + turn: number; + player: number; + placements: readonly game.Position[]; + encircled: number; + cut: number; + territory: number; + capturedTotal: number; + bonus: number; + fault: string | null; + conceded: boolean; +}; + +// Why this log exists alongside `history`, which already holds every board +// the game passed through: per-rule capture attribution is only knowable +// from the state immediately before endTurn -- pendingEncirclements, +// pendingCuts and pendingTerritories all depend on which stones are +// "placed this turn", and endTurn both deletes the captured pieces and +// advances the turn, destroying that evidence. It is not recoverable +// afterwards from `history` alone. This is the same justification game.ts +// already gives for storing `scores` and `conceded` rather than deriving +// them. recordTurn must be called on that exact pre-endTurn state. +export const recordTurn = ( + pre: game.BoardState, + placements: readonly game.Position[], + fault: string | null, +): TurnRecord => ({ + turn: pre.turn, + player: pre.activePlayer, + placements, + encircled: game.pendingEncirclements(pre).filter((piece) => !piece.isHomeBase).length, + cut: game.pendingCuts(pre).filter((piece) => !piece.isHomeBase).length, + territory: game.pendingTerritories(pre).filter((piece) => !piece.isHomeBase).length, + capturedTotal: game.pendingCaptures(pre).length, + bonus: game.bonusPlacements(pre), + fault: fault, + conceded: false, +}); + +// A concession takes no parting captures (game.concede resolves no +// pendingCaptures), so this needs none of recordTurn's rule lookups -- +// just which player quit and on what turn. +export const recordConcession = (pre: game.BoardState): TurnRecord => ({ + turn: pre.turn, + player: pre.activePlayer, + placements: [], + encircled: 0, + cut: 0, + territory: 0, + capturedTotal: 0, + bonus: 0, + fault: null, + conceded: true, +}); + +const rosterLabel = (entry: RosterEntry): string => + entry.kind === "human" ? `${entry.name} (human)` : `${entry.name} (bot: ${entry.strategyId})`; + +// "playing" covers two different real situations transcript.ts has no way +// to tell apart from `state`/`log` alone -- a manual mid-game Save, or the +// idle guard having actually stopped autoplay (main.ts's own +// `autoplayStopped`, a runtime flag never threaded through here). Naming +// the idle guard specifically here would be a guess dressed up as fact for +// the more common (manual save) case, so this stays honestly generic. +const endedDescription = (state: game.BoardState): string => { + const { out, result } = game.gameStatus(state); + if (result.kind === "playing") { + return "game still in progress (saved mid-game, or autoplay may have stopped -- see the app's own status line)"; + } + const aliveCount = out.filter((flag) => !flag).length; + if (result.kind === "draw") { + return "board full — draw"; + } + return aliveCount === 1 ? "lone survivor" : "board full — highest score wins tiebreak"; +}; + +const resultLine = (setup: Setup, state: game.BoardState): string => { + const { result } = game.gameStatus(state); + if (result.kind === "won") { + return `Winner: ${rosterName(setup, result.player)} (score ${state.scores[result.player]})`; + } + if (result.kind === "draw") { + return "Result: draw"; + } + return "Result: game still in progress"; +}; + +const rosterName = (setup: Setup, player: number): string => + setup.roster[player]?.name ?? `Player ${player + 1}`; + +type PlayerSummary = { + player: number; + placed: number; + encircled: number; + cut: number; + territory: number; + captured: number; + bonus: number; + passes: number; + faults: number; +}; + +const summarize = (setup: Setup, log: readonly TurnRecord[]): PlayerSummary[] => + setup.roster.map((_entry, player) => { + const own = log.filter((entry) => entry.player === player); + return { + player, + placed: own.reduce((sum, entry) => sum + entry.placements.length, 0), + encircled: own.reduce((sum, entry) => sum + entry.encircled, 0), + cut: own.reduce((sum, entry) => sum + entry.cut, 0), + territory: own.reduce((sum, entry) => sum + entry.territory, 0), + captured: own.reduce((sum, entry) => sum + entry.capturedTotal, 0), + bonus: own.reduce((sum, entry) => sum + entry.bonus, 0), + passes: own.filter((entry) => entry.placements.length === 0 && !entry.conceded).length, + faults: own.filter((entry) => entry.fault !== null).length, + }; + }); + +const PLAYER_LETTERS = "abcdefghijklmnopqrstuvwxyz"; + +// Two header lines carrying each column's tens and units digit, two-digit +// row labels down the left, cells contiguous with no separating spaces: +// `.` for empty, a lowercase letter per player, uppercase for that +// player's own home base. Iterates state.pieces in array order so a later +// (overlay) piece at a cell displays instead of the doomed piece beneath +// it -- a display-only "topmost wins" read, unlike game.pieceAt. +const renderFinalBoard = (state: game.BoardState): string => { + const occ = new Map(); + for (const piece of state.pieces) { + occ.set(`${piece.position.row},${piece.position.col}`, piece); + } + + const { rows, cols } = state.size; + const tens = " " + Array.from({ length: cols }, (_, col) => String(Math.floor(col / 10) % 10)).join(""); + const units = " " + Array.from({ length: cols }, (_, col) => String(col % 10)).join(""); + const lines = [tens, units]; + for (let row = 0; row < rows; row++) { + let line = String(row).padStart(2, "0"); + for (let col = 0; col < cols; col++) { + const piece = occ.get(`${row},${col}`); + if (!piece) { + line += "."; + } else { + const letter = PLAYER_LETTERS[piece.player % PLAYER_LETTERS.length] ?? "?"; + line += piece.isHomeBase ? letter.toUpperCase() : letter; + } + } + lines.push(line); + } + return lines.join("\n"); +}; + +const renderMoveLine = (setup: Setup, entry: TurnRecord): string => { + if (entry.conceded) { + return `T${entry.turn} ${rosterName(setup, entry.player)}: CONCEDED`; + } + const cells = entry.placements.map((p) => `(${p.row},${p.col})`).join(" ") || "(no placements)"; + const bonusNote = entry.bonus > 0 ? ` +${entry.bonus} bonus` : ""; + const rules = `ring ${entry.encircled}, cut ${entry.cut}, territory ${entry.territory} (${entry.capturedTotal} captured)`; + const faultNote = entry.fault ? `\n FAULT: ${entry.fault}` : ""; + return `T${entry.turn} ${rosterName(setup, entry.player)}: ${cells}${bonusNote} | ${rules}${faultNote}`; +}; + +// Deliberately not a board snapshot per turn -- 60 turns of 16x16 grids is +// tens of thousands of tokens of mostly-unchanged board, and the seed plus +// this move list already make any turn recoverable exactly by replaying +// from frontend/src/main.ts with the same SEED and roster. +export const render = (record: history.History, setup: Setup, log: readonly TurnRecord[]): string => { + const state = record.present; + const rosterLines = setup.roster.map((entry, index) => ` ${index + 1}. ${rosterLabel(entry)}`); + const summaries = summarize(setup, log); + + const header = [ + "# bit-blossom transcript", + "", + `- Seed: ${setup.seed}`, + `- Board: ${setup.size.rows}x${setup.size.cols}, ${setup.placementsPerTurn} placements/turn`, + `- Started: ${setup.startedAt}`, + "- Roster:", + ...rosterLines, + `- To replay: set \`SEED = ${setup.seed}\` in \`frontend/src/main.ts\` with this same roster.`, + ].join("\n"); + + const result = [ + "## Result", + "", + resultLine(setup, state), + `Final scores: ${setup.roster.map((_entry, player) => `${rosterName(setup, player)} ${state.scores[player]}`).join(", ")}`, + `Ended: ${endedDescription(state)}`, + ].join("\n"); + + const summary = [ + "## Per-player summary", + "", + ...summaries.map((s) => + `- ${rosterName(setup, s.player)}: ${s.placed} placed, ${s.captured} captured ` + + `(ring ${s.encircled}, cut ${s.cut}, territory ${s.territory}), ` + + `${s.bonus} bonus placements, ${s.passes} passes, ${s.faults} faults`), + ].join("\n"); + + const moves = [ + "## Move list", + "", + ...log.map((entry) => renderMoveLine(setup, entry)), + ].join("\n"); + + const board = [ + "## Final board", + "", + "```", + renderFinalBoard(state), + "```", + ].join("\n"); + + return [header, "", result, "", summary, "", moves, "", board, ""].join("\n"); +}; + +export const filename = (setup: Setup): string => { + const safeTimestamp = setup.startedAt.replace(/\.\d+Z?$/, "").replace(/:/g, "-"); + return `${safeTimestamp}-seed${setup.seed}.md`; +}; diff --git a/frontend/src/ui.ts b/frontend/src/ui.ts index bcc42a4..d56410b 100644 --- a/frontend/src/ui.ts +++ b/frontend/src/ui.ts @@ -13,6 +13,7 @@ export type PanelElements = { status: HTMLElement; endTurn: HTMLButtonElement; concede: HTMLButtonElement; + saveTranscript: HTMLButtonElement; log: HTMLElement; }; @@ -97,10 +98,17 @@ const statusText = ( state: game.BoardState, result: game.GameResult, viewingTurn: number | null, + botStatus: string | null, ): string => { if (viewingTurn !== null) { return `Viewing turn ${viewingTurn} — read-only`; } + // Checked before the result branches: while a bot's animated turn is + // playing out, botStatus is what the panel should read, even though the + // underlying result is still "playing" (or briefly stale mid-animation). + if (botStatus !== null) { + return botStatus; + } if (result.kind === "won") { return `${state.players[result.player].name} wins!`; } @@ -196,16 +204,25 @@ export const attachLog = (listEl: HTMLElement, onView: (index: number) => void): // game.gameStatus runs the underlying flood fill once and hands back both // the per-player elimination flags and the derived result, rather than each // of the three things below independently re-deriving one or the other. +// +// botStatus/isLocalTurn default to their human-solo-game values (null, +// true) so every existing call site keeps working unchanged. disabled is a +// display detail, not the real guard against a human playing a bot's turn +// -- that guard lives in handleClick and main.ts's endHumanTurn, per +// separation-of-concerns.md boundary rule 3 (the window-level Enter/Space +// listener in attachEndTurn bypasses `disabled` entirely). export const showPanel = ( elements: PanelElements, record: history.History, activeTheme: theme.Theme, + botStatus: string | null = null, + isLocalTurn: boolean = true, ): void => { const state = history.visible(record); const { eliminated, result } = game.gameStatus(state); renderPlayerList(elements.playerList, state, eliminated, activeTheme); - elements.status.textContent = statusText(state, result, record.viewing); - const disabled = result.kind !== "playing" || history.isViewingPast(record); + elements.status.textContent = statusText(state, result, record.viewing, botStatus); + const disabled = result.kind !== "playing" || history.isViewingPast(record) || !isLocalTurn; elements.endTurn.disabled = disabled; elements.concede.disabled = disabled; renderLog(elements.log, history.frames(record), record.viewing, activeTheme); @@ -236,7 +253,15 @@ export const handleClick = ( layout: canvas.BoardLayout, board: canvas.BoardConfig, point: canvas.Point, + isLocalTurn: boolean, ): game.BoardState | null => { + // Checked first, before any board/game logic: a human can never play a + // bot's turn by clicking during it. isLocalTurn is the real guard here; + // showPanel's `disabled` on the End Turn/Concede buttons is a display + // detail that doesn't cover clicks on the board itself. + if (!isLocalTurn) { + return null; + } const position = canvas.pixelToGridPoint(layout, board, point); if (!position) { return null; @@ -266,11 +291,12 @@ export const attachInteraction = ( board: canvas.BoardConfig, getLayout: () => canvas.BoardLayout, getState: () => game.BoardState, + isLocalTurn: () => boolean, onState: (next: game.BoardState) => void, ): void => { canvasEl.addEventListener("click", (event) => { const point = clientPointToCanvasPoint(canvasEl, event); - const next = handleClick(getState(), getLayout(), board, point); + const next = handleClick(getState(), getLayout(), board, point, isLocalTurn()); if (next) { onState(next); } @@ -301,19 +327,17 @@ const isFocusedFormControl = (element: Element | null): boolean => // actual thing that matters instead. const isModalOpen = (): boolean => document.querySelector("dialog[open]") !== null; +// Takes an onEndTurn callback rather than calling game.endTurn itself: +// main.ts needs to see the board BEFORE endTurn runs, to build that turn's +// transcript.TurnRecord (endTurn deletes the captured pieces and advances +// the turn, destroying the evidence a per-rule capture breakdown needs -- +// see transcript.ts). main.ts owns the actual game.endTurn call and the +// applyState/log-push that follow it. export const attachEndTurn = ( button: HTMLButtonElement, - getState: () => game.BoardState, - onState: (next: game.BoardState) => void, + onEndTurn: () => void, ): void => { - const endTurn = (): void => { - const next = game.endTurn(getState()); - if (next) { - onState(next); - } - }; - - button.addEventListener("click", endTurn); + button.addEventListener("click", onEndTurn); window.addEventListener("keydown", (event) => { // button.disabled suppresses the button's own click event but has no @@ -332,10 +356,18 @@ export const attachEndTurn = ( return; } event.preventDefault(); - endTurn(); + onEndTurn(); }); }; +// Enabled at all times, unlike End Turn/Concede -- a long or stuck game +// (see docs/bots.md: captures free up cells, so boardIsFull can take a +// long while) should never lose its transcript to a closed tab just +// because the game hasn't formally ended yet. +export const attachSaveTranscript = (button: HTMLButtonElement, onSave: () => void): void => { + button.addEventListener("click", onSave); +}; + // Native + showModal gives a focus trap and Escape-to-cancel for // free, and makes the background inert to pointer events and focus -- but // NOT to window-level keydown listeners (see isModalOpen above, which is @@ -376,6 +408,34 @@ export const attachConcede = ( }); }; +// Shows the saved-transcript confirmation, filling in the path the server +// reported. A separate imperative function rather than folded into +// attachSaveTranscript's click listener, since the trigger here is an +// async fetch's success -- main.ts's saveTranscript -- not a click. +export const showTranscriptSaved = (dialog: HTMLDialogElement, path: string): void => { + const pathEl = dialog.querySelector("[data-transcript-path]"); + if (pathEl) { + pathEl.textContent = path; + } + dialog.showModal(); +}; + +// Unlike attachConcede, nothing here is destructive, so a backdrop click +// dismisses it too -- a click whose target is the dialog element itself, +// not any of its children, since native has no separate backdrop +// click event of its own. +export const attachTranscriptSavedDialog = ( + dialog: HTMLDialogElement, + okButton: HTMLButtonElement, +): void => { + okButton.addEventListener("click", () => dialog.close()); + dialog.addEventListener("click", (event) => { + if (event.target === dialog) { + dialog.close(); + } + }); +}; + // --- Theme: CSS variables, chrome application, picker, persistence ------- // The variable names are a DOM concern, so this mapping lives here rather @@ -392,6 +452,8 @@ export const cssVariables = (activeTheme: theme.Theme): Readonly