Something went wrong. Try again.
A Go-like game designed to be playe by two-to-eight players competing for territory!
Something went wrong. Try again.
12 kB · 287 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288// 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<string, game.Piece>(); 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`;};