// main.ts is dedicated to the primary interactions with the DOM and the user. import * as canvas from "./canvas.js"; 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 }; // --- 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"); } const ctx = canvasEl.getContext("2d"); if (!ctx) { throw new Error("Canvas 2D context unavailable"); } const endTurnButton = document.getElementById("end-turn"); if (!(endTurnButton instanceof HTMLButtonElement)) { throw new Error("Missing #end-turn button element"); } const playerList = document.getElementById("player-list"); if (!(playerList instanceof HTMLElement)) { throw new Error("Missing #player-list element"); } const turnStatus = document.getElementById("turn-status"); if (!(turnStatus instanceof HTMLElement)) { throw new Error("Missing #turn-status element"); } const themeSelect = document.getElementById("theme-select"); if (!(themeSelect instanceof HTMLSelectElement)) { throw new Error("Missing #theme-select element"); } const turnLog = document.getElementById("turn-log"); if (!(turnLog instanceof HTMLElement)) { throw new Error("Missing #turn-log element"); } const concedeButton = document.getElementById("concede"); if (!(concedeButton instanceof HTMLButtonElement)) { throw new Error("Missing #concede button element"); } const concedeDialog = document.getElementById("concede-dialog"); if (!(concedeDialog instanceof HTMLDialogElement)) { throw new Error("Missing #concede-dialog element"); } const concedeConfirmButton = document.getElementById("concede-confirm"); if (!(concedeConfirmButton instanceof HTMLButtonElement)) { throw new Error("Missing #concede-confirm button element"); } const concedeCancelButton = document.getElementById("concede-cancel"); 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, }; // 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"); } 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( ctx, { width: canvasEl.clientWidth, height: canvasEl.clientHeight }, BOARD, ui.toCanvasPieces(visibleState, currentTheme), currentTheme.board, ); ui.showPanel(panelElements, record, currentTheme, botStatus, isLocalTurn()); canvasEl.classList.toggle("viewing-past", history.isViewingPast(record)); }; // CSS (flex layout) owns the canvas's on-screen size; this only sizes the // backing store to match it at the device's pixel ratio, reading back // clientWidth/clientHeight rather than the viewport now that the canvas // shares the page with the side panel. A ResizeObserver on canvasEl would be // the more correct trigger if the panel ever became fluid instead of a fixed // width, but the window resize listener below is sufficient today. const resize = (): void => { const dpr = window.devicePixelRatio || 1; const width = canvasEl.clientWidth; const height = canvasEl.clientHeight; canvasEl.width = width * dpr; canvasEl.height = height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); layout = canvas.computeLayout({ width, height }, BOARD); render(); }; const applyState = (next: game.BoardState): void => { if (history.isViewingPast(record)) { return; } record = history.advance(record, next); render(); }; // The onState callback for a HUMAN's own board clicks specifically (see // its wiring below) -- not onConcede, which already logs its own way, and // not a bot's replay (animateBotTurn), which applies placements directly // and handles this itself. game.gameStatus is re-derived from live pieces // at any moment, not only at turn boundaries, so a human's placement can // already end the game (elimination, or the last cell on a full board) // before End Turn is ever clicked -- and the very next render (inside // applyState, via showPanel) disables the End Turn/Concede buttons the // instant that's true (ui.ts's showPanel: `disabled = result.kind !== // "playing" || ...`), and the window-level Enter/Space handler also bails // on `button.disabled`. That makes `endHumanTurn`'s own mid-turn-ending // branch structurally UNREACHABLE through the actual UI: nothing can ever // click or key a disabled button. Found by Analyst's review 2026-09-12, // specifically because it traced this through ui.ts rather than trusting // the headless acceptance runs, which bypass ui.ts (and therefore this // bug) entirely -- see // docs/memories/project/separation-of-concerns.md's note on this. The // real fix has to live on the placement path itself, which is here: catch // the playing->not-playing transition around the one place a human's // placement is actually committed, log the turn (with the same // mid-turn-end note transcript.recordTurn already supports), and trigger // the save -- all before showPanel ever gets a chance to disable // anything. endHumanTurn's own branch is kept as a harmless defensive // backstop, not the real fix. const onHumanPlacement = (next: game.BoardState): void => { if (history.isViewingPast(record)) { return; } const wasPlaying = game.gameStatus(record.present).result.kind === "playing"; applyState(next); if (isLocalTurn() && wasPlaying && game.gameStatus(next).result.kind !== "playing") { log.push(transcript.recordTurn(next, placementsThisTurn(next), null, true)); onTurnEnded(); } }; const setView = (index: number): void => { record = history.view(record, index); render(); }; const setTheme = (next: theme.Theme): void => { currentTheme = next; ui.storeThemeId(next.id); ui.applyChrome(document.documentElement, next); 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; // Defensive backstop only -- NOT the real fix for a human placement // ending the game mid-turn. That case is actually handled on the // placement path itself, by `onHumanPlacement` above: the moment a // placement ends the game, the very next render disables this button // (ui.ts's showPanel: `disabled = result.kind !== "playing" || ...`), // so this branch is structurally unreachable through the real UI -- // nothing can click or key a disabled button. Found by Analyst's // review 2026-09-12 of the first version of this fix, which put the // logic here without tracing it through ui.ts. Left in place as a // harmless backstop (e.g. against a future refactor that re-enables the // button in some path that doesn't go through `onHumanPlacement`) -- // see that function's own comment for the actual fix and the full // story. Deliberately does NOT call game.endTurn here or otherwise // resolve this turn's pending captures -- "the ending turn's captures // go unresolved" is Arcadia's own rule, not a bug this fixes. if (game.gameStatus(pre).result.kind !== "playing") { log.push(transcript.recordTurn(pre, placementsThisTurn(pre), null, true)); render(); onTurnEnded(); return; } // 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; // Checked here, before logging, so the log entry itself can carry the // note transcript.recordTurn attaches when a turn's own placements // already ended the game (see its comment) -- rather than only acting // on this fact after logging, which is what let the human-turn path // (endHumanTurn) silently drop the turn entirely instead. See // transcript.ts's recordTurn/midTurnEndReason comment for the full // story. const endedMidTurn = game.gameStatus(pre).result.kind !== "playing"; log.push(transcript.recordTurn(pre, outcome.placements, outcome.fault, endedMidTurn)); // 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 (endedMidTurn) { 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, isLocalTurn, onHumanPlacement); ui.attachEndTurn(endTurnButton, endHumanTurn); ui.attachConcede( concedeButton, concedeDialog, concedeConfirmButton, concedeCancelButton, () => record.present, 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();