// 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; // Non-null when this turn's OWN placements already ended the game (via // elimination or a full board) before endTurn was ever called for it -- // see recordTurn's own comment for why that turn would otherwise vanish // from the log entirely. note: string | null; }; // game.gameStatus is re-derived from live pieces at any moment, not only // at turn boundaries, so a turn's OWN placements can already end the game // (elimination, or the last cell on a full board) before endTurn is ever // called for it -- game.endTurn's own first guard correctly refuses to // run once that's true (there's nothing left to end), but a caller that // takes that refusal as "nothing happened" silently drops the whole turn: // no log entry, no scores update, and no transcript save trigger. Found // 2026-09-12 by Analyst reviewing four of Arcadia's 1v1 games against // Cuts, all won by a mid-turn elimination her own placements triggered -- // every one of them showed "Winner: Arcadia (score 0)" and a final board // with her winning stone (e.g. `(15,7)` in seed1169854431) present on the // board but absent from the move list, because `main.ts`'s // `endHumanTurn` called `game.endTurn` first and did nothing when it // returned null. Deliberately does NOT change the actual rule that a // game-ending turn's own captures go unresolved (Arcadia's call, not a // bug this addresses) -- this only makes sure the turn that ended the // game is still recorded faithfully when that happens, which // `main.ts`'s bot-turn driver was already doing correctly on its own. const midTurnEndReason = (state: game.BoardState): string => { const { out, result } = game.gameStatus(state); if (result.kind === "draw") { return "board full — draw"; } return out.filter((flag) => !flag).length === 1 ? "lone survivor" : "board full — highest score wins tiebreak"; }; // 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. // // `endedMidTurn` is true exactly when this turn's own placements already // ended the game -- see `midTurnEndReason`'s comment above. Defaults to // false so every existing call site (a normal turn, resolved via // game.endTurn as usual) keeps working unchanged. export const recordTurn = ( pre: game.BoardState, placements: readonly game.Position[], fault: string | null, endedMidTurn: boolean = false, ): 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, note: endedMidTurn ? `game ended mid-turn (${midTurnEndReason(pre)})` : null, }); // 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, note: null, }); 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 endedNote = entry.note ? ` | ${entry.note}` : ""; const faultNote = entry.fault ? `\n FAULT: ${entry.fault}` : ""; return `T${entry.turn} ${rosterName(setup, entry.player)}: ${cells}${bonusNote} | ${rules}${endedNote}${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`; };