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.
19 kB · 483 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484// 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 transcriptconst PLACEMENT_DELAY_MS = 120; // stone-by-stone animation, within a bot's turnconst 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<typeof setTimeout> | 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<void> => { 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();