diff --git a/docs/memories/feedback/design-partner-workflow.md b/docs/memories/feedback/design-partner-workflow.md index 775c835..3f3c445 100644 --- a/docs/memories/feedback/design-partner-workflow.md +++ b/docs/memories/feedback/design-partner-workflow.md @@ -98,6 +98,22 @@ final design. report the guardrail you had to relax, rather than silently completing the guardrail and leaving the feature half-applied. +- A screenshot captioned "this looks like a bug" may be correct behaviour. Confirmed 2026-09-07: Arcadia reported an unexpected capture, Seer traced it to the diagonal four-piece ring firing on a stone that was mid-line rather than isolated, and asked which fix she wanted BEFORE designing one — her answer was "this is correct behaviour" and no task was recorded. Establish which rule fired and confirm the rule's intent with Arcadia before designing a fix; a rule that fires surprisingly is not yet a defect. Naming the exact firing rule and the exact cells involved is what makes that confirmation cheap for her. + +- Answers to a batch of `AskUserQuestion` questions are not independent — + check them against each other before designing, because two individually + sensible choices can be mutually impossible. Found 2026-09-08 designing + concession: Arcadia chose "a conceded player's stones and home base are all + removed from the board" on one question and "mark them with a white-flag + emblem, distinct from the skull" on the next. Both are reasonable; together + they're incoherent, because full removal leaves no piece to draw an emblem + on. The right move was to name the contradiction back to her in a + follow-up question with the resolutions spelled out (panel-only flag / keep + the home base as a monument / paint a marker on the vacated intersection) + rather than silently picking one and quietly dropping half of what she + asked for. Seer sessions batching questions should re-read the collected + answers as a set, not just individually, before writing the design. + Stated as the working process in `docs/tasks/2026-09-05/01-decouple-game-rules-from-ui-effects.md`; confirmed 2026-09-05 when Seer proposed deleting `game.ts`'s `MoveResult`/`Effect`/`Illegality` diff --git a/docs/memories/project/build-and-serve.md b/docs/memories/project/build-and-serve.md index 93a9fc9..4d32740 100644 --- a/docs/memories/project/build-and-serve.md +++ b/docs/memories/project/build-and-serve.md @@ -12,9 +12,14 @@ - `dist/` is gitignored, so a fresh clone renders nothing until the first build. - `node`/`npm`/`tsc` are not on `PATH` in a fresh shell in this - environment — `which node` fails. Run `source ~/.nvm/nvm.sh && nvm - use default` first (this session found node v26.8.1 that way), then - `./node_modules/.bin/tsc` works directly from `frontend/`. + environment — `which node` fails, but the `nvm` function itself IS + already loaded (from shell profile), so `source ~/.nvm/nvm.sh` is + unnecessary overhead. Just run `nvm use default` (Arcadia's + correction, 2026-09-08 — she'd previously seen a session `source` + every time). `./node_modules/.bin/tsc` then works directly from + `frontend/`. Only v24.15.0 was installed as of 2026-09-08 (`nvm use + 26` fails with "not yet installed" until someone runs `nvm install + 26`); don't assume a specific version is present without checking. - To hand-verify pure logic in `game.ts` (no test setup exists in this repo) without a browser: compile with `tsc`, copy the relevant `dist/*.js` file to a `.mjs` extension in a scratch directory (the @@ -81,6 +86,20 @@ still fires). Generally: when a rules layer and a view guard can both suppress the same observable outcome, test the mechanism you changed, not the outcome they share. +- When scripting many synthetic turns against real `game.ts` rules to + stress-test something unrelated to game outcome (e.g. verifying the + turn-log UI scrolls/paginates correctly with 40+ rows), a small board + runs out of room or ends the game (elimination/draw) long before you + hit the row count you wanted — `rows*cols / placementsPerTurn` is the + ceiling on how many turns a board can even hold. Confirmed 2026-09-08 + on `docs/tasks/2026-09-08/16-time-travel-history.md`: a 10x10 board + filled up around turn ~33 and `endTurn` correctly started returning + `null` forever after (End Turn permanently disabled) — the game was + over, not a defect in the log. Fix: either size the board generously + above what the stress scenario needs (e.g. 24x24 for 45+ turns), or + sidestep `game.ts` legality entirely and fabricate a `History` object + directly (a plain data literal, no encapsulation to violate) to test + view-layer behavior like scroll position in isolation. - Home base positions are randomized on every page load (`generateHomeBases` takes unseeded `Math.random`), so pixel coordinates read from one screenshot/load do not carry over to the diff --git a/docs/memories/project/capture-rules.md b/docs/memories/project/capture-rules.md index 5e9fed4..82ec780 100644 --- a/docs/memories/project/capture-rules.md +++ b/docs/memories/project/capture-rules.md @@ -127,6 +127,8 @@ These follow from the existing rules rather than being new rules. They were deri **Solid shapes are immune to both ring patterns and to cutting.** Every stone in a solid rectangle — or in any wall two cells thick — has at least two orthogonal neighbours of its own colour, and its inward diagonals are its own colour as well. Neither ring can then be all-one-enemy around such a stone. Since 2026-09-07, cutting works by chaining links rather than piercing an encirclement, but the immunity survives: a pierce link needs its middle cell to hold exactly one enemy stone with an empty (fresh-placeable) cell immediately beyond it, and a solid or two-thick wall has no such cell anywhere along it. This was the main gap in the ruleset before territory shipped: territory has since become the answer to solid matter, since an enemy stone's own solidity does not block an opponent's flood fill — a solid blob fully enclosed by someone else's wall is captured wholesale like anything else inside it. See "Territory" above. +**A one-thick line is NOT immune to the diagonal ring.** Confirmed intended by Arcadia 2026-09-07 against a live board: an enemy stone with its own stones directly east and west — the middle of a one-thick horizontal line — was captured by the diagonal ring, and that is correct behaviour, not a defect. The immunity above is specifically about solid and two-thick shapes, where the victim's inward diagonals are its own colour; a one-thick line leaves all four diagonals free for the attacker, so its interior stones are capturable even though they stay orthogonally connected to their own group. "The rule captures a stone that is geometrically in the open" (below) describes the four ATTACKING stones and the four open orthogonal exits they leave — it is not a requirement that the victim be isolated. There is deliberately no escape/connection clause in `pendingEncirclements`: a ring needs all four cells of one pattern owned by the active player with at least one placed this turn, and nothing else. Note in particular that the diagonal ring does NOT mirror the cut rule's "line of 2+" victim test — cutting requires the victim to have a same-owner 8-neighbour, the diagonal ring requires nothing of the victim at all. That asymmetry is intended; don't "unify" them. + **Hollow shapes are soft at their corners, and the hollow is why.** In a 3x3 ring with an empty centre, a corner stone's four diagonals are three cells outside the shape plus the hollow centre — all empty. An attacker takes it for 4 placements over two turns: three outside diagonals on turn one (within the 3-placement budget), then the centre stone on turn two, which completes the ring and fires that same turn. One centre stone is the inward diagonal for all four corners at once. Edge-midpoint stones are immune, because both their inward diagonals are wall. Since 2026-09-07 this ring is independently cuttable too, wherever a fresh stone can reach an empty cell along a line that pierces one of its member stones — cutting no longer needs the ring itself to be a target of an encirclement. The defence is to fill the hole, which makes the shape solid and fully immune — but a filled shape encloses nothing. A shape is impenetrable exactly when it holds no space. diff --git a/docs/memories/project/separation-of-concerns.md b/docs/memories/project/separation-of-concerns.md index 9719624..25234d7 100644 --- a/docs/memories/project/separation-of-concerns.md +++ b/docs/memories/project/separation-of-concerns.md @@ -10,7 +10,14 @@ headers, strict mode). placed, whose turn it is, and what is legal. Knows nothing about pixels, clicks, the DOM, or rendering. Returns the new state, or `null` when a move is illegal; it never describes what happened or - what to draw. + what to draw. `conceded` is the second stored-history field on + `BoardState`, after `scores`, for the same reason: a conceding + player's pieces are removed outright, so once that happens nothing + in `pieces` records that they ever played — the field is the only + trace. Turn rotation and victory read `playersOut` (eliminated OR + conceded), not `eliminatedPlayers` alone — a conceded player's base + is gone, so `eliminatedPlayers` alone can never see them, but they + still must never take a turn or win. - **canvas.ts** — the board renderer specifically. Owns layout geometry, drawing, and hit-testing the board. Knows nothing about players, turns, or rules; it receives a view model (`canvas.Piece`: @@ -27,6 +34,10 @@ headers, strict mode). - **theme.ts** — the named colour schemes (Goban, Kanagawa, Catppuccin Mocha) as pure data: no DOM, no localStorage. Imports `canvas.ts` for the `Palette` type and nothing else. +- **history.ts** — the record of play: every board state the game has + passed through, and which one is currently on screen. Pure; owns what + the turn log shows and which frame is on screen. Imports `game.ts` + only. - **main.ts** — composition root only. Owns the single mutable state, looks up DOM elements, attaches listeners, and holds the one `render()` entry point. No view logic, no state-to-view-model @@ -65,8 +76,9 @@ session is most likely to get wrong. 4. View models belong to the renderer, not the rules. `canvas.Piece` and `game.Piece` are deliberately different types. 5. Imports are directional: main → ui, main → canvas, main → theme, - ui → game, ui → canvas, ui → theme, theme → canvas. game.ts - imports nothing; canvas.ts is still a leaf. + main → history, ui → game, ui → canvas, ui → theme, ui → history, + theme → canvas, history → game. game.ts imports nothing; canvas.ts + and game.ts stay leaves. ## Known deliberate trade-offs @@ -81,3 +93,20 @@ session is most likely to get wrong. `game.gameStatus` had already collapsed three passes into one *within* `showPanel`. If this ever needs collapsing further, the fix is a shared helper in `ui.ts`, not view logic in `main.ts`. + +## Known invariants + +- Every state transition in `game.ts` is non-mutating AND preserves `Piece` + object identity. `place` returns `{ ...state, pieces: [...state.pieces, + piece] }`, `endTurn` and `retract` return `.filter`ed copies — no function + writes through an existing `BoardState` or its `pieces` array, and the + `Piece` objects themselves are shared by reference between a state and its + successor. Two things depend on this and would break silently if any + transition ever mutated in place: `endTurn` resolves captures via + `captured.has(piece)` on a `Set`, and `pieceAt`/`isOverlay` compare + pieces by reference (`pieceAt(state, piece.position) !== piece`) — all + identity semantics, not structural. It also means holding a reference to an + old `BoardState` is safe and nearly free: a retained past state costs one + object plus an array of pointers to `Piece` objects that already exist, + not a deep copy of the board. Verified 2026-09-08 by reading every + transition in the file. diff --git a/docs/memories/project/window-level-input-guards.md b/docs/memories/project/window-level-input-guards.md new file mode 100644 index 0000000..c9ea210 --- /dev/null +++ b/docs/memories/project/window-level-input-guards.md @@ -0,0 +1,33 @@ +# Window-level input guards + +`attachEndTurn` in `ui.ts` binds its Enter/Space handler to `window`, not to +the End Turn button. That one decision has now produced three separate bugs, +each found after the guard was believed correct: + +1. 2026-09-06 — `button.disabled` suppresses the button's own click but has + no effect on a window listener, so Enter/Space kept ending turns after a + win. Fixed by moving the real legality check into `game.ts`. +2. 2026-09-07 — adding a ` + + +

Concede the game?

+

+ This is permanent. is removed from the game + immediately — every stone they hold, including their home base, is taken off the + board. They cannot win under any circumstances, including a tiebreak. This cannot + be undone. +

+
+ + +
+
diff --git a/frontend/src/game.ts b/frontend/src/game.ts index ae9d02b..e919437 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -20,6 +20,12 @@ export type BoardState = { // deadlock tiebreak) recover more than a running total, but that's // speculative until something actually needs it. scores: readonly number[]; + // The second accumulated-history field, for the same reason as `scores`: + // concession is an EVENT, not a derivable fact. Once a conceding player's + // pieces are removed from `pieces`, nothing on the board records that they + // ever played -- their entry here is the only trace. Derived-over-stored + // doesn't apply to a fact the board itself no longer contains. + conceded: readonly boolean[]; }; const DEFAULT_PLACEMENTS_PER_TURN = 3; @@ -207,6 +213,7 @@ export const initialize = ( placementsPerTurn, size, scores: players.map(() => 0), + conceded: players.map(() => false), }); export const currentPlayer = (state: BoardState): Player => @@ -340,6 +347,12 @@ export const eliminatedPlayers = (state: BoardState): readonly boolean[] => { export const isEliminated = (state: BoardState, player: number): boolean => eliminatedPlayers(state)[player]; +// Eliminated is a reversible territory STATE; conceded is a permanent, +// chosen act. They differ in meaning and in display, but every rule that +// asks "does this player still take turns / can they still win" wants both. +export const playersOut = (state: BoardState): readonly boolean[] => + eliminatedPlayers(state).map((eliminated, player) => eliminated || state.conceded[player]); + // Board is exactly full when every cell holds at least one piece -- home // bases included, since they've been ordinary pieces occupying a cell since // setup. Counts DISTINCT positions, not `pieces.length`, because an overlay @@ -363,19 +376,19 @@ export type GameResult = const aliveScores = ( state: BoardState, - eliminated: readonly boolean[], + out: readonly boolean[], ): { player: number; score: number }[] => state.players .map((_, player) => ({ player, score: state.scores[player] })) - .filter(({ player }) => !eliminated[player]); + .filter(({ player }) => !out[player]); // Order matters: a lone survivor wins outright, even on a full board, before // the score comparison is ever reached (rule 1 below). Only once more than -// one player is still alive does board-fullness get a say, and even then -// the comparison runs over alive players only -- an eliminated player's +// one player is still in does board-fullness get a say, and even then +// the comparison runs over players still in only -- an out player's // score, however high, never enters it. -const gameResultFrom = (state: BoardState, eliminated: readonly boolean[]): GameResult => { - const alive = aliveScores(state, eliminated); +const gameResultFrom = (state: BoardState, out: readonly boolean[]): GameResult => { + const alive = aliveScores(state, out); if (alive.length === 1) { return { kind: "won", player: alive[0].player }; } @@ -385,11 +398,14 @@ const gameResultFrom = (state: BoardState, eliminated: readonly boolean[]): Game const highest = alive.reduce((max, entry) => Math.max(max, entry.score), -Infinity); const holders = alive.filter((entry) => entry.score === highest); // holders.length === 0 only when alive.length === 0, which cannot actually - // happen: a player's own turn can never eliminate that player (elimination - // depends only on an opponent's stones), so the mover who just ended their - // turn is always alive, and once the board is full no further placements - // occur to change the elimination set. The "draw" fallthrough below covers - // that unreachable case as a benign default, not as a considered ruling. + // happen: `concede` returns null unless `result.kind === "playing"`, which + // requires at least two players in. So a concession always leaves at least + // one player in who is not the mover, and an elimination can only ever + // happen on an opponent's turn, never the mover's own -- so the mover who + // just ended their turn is always still in, and once the board is full no + // further placements occur to change who's in. The "draw" fallthrough + // below covers that unreachable case as a benign default, not as a + // considered ruling. return holders.length === 1 ? { kind: "won", player: holders[0].player } : { kind: "draw" }; }; @@ -397,25 +413,30 @@ const gameResultFrom = (state: BoardState, eliminated: readonly boolean[]): Game // territoryByPlayer pass -- the combination `showPanel` needs every render. // Calling `eliminatedPlayers` and re-deriving the result separately for the // same state would run that flood fill twice; this is the one call that -// runs it once. -export type GameStatus = { eliminated: readonly boolean[]; result: GameResult }; +// runs it once. `result` is computed from `playersOut`, not `eliminated` +// alone, since a conceded player can never win either. +export type GameStatus = { eliminated: readonly boolean[]; out: readonly boolean[]; result: GameResult }; export const gameStatus = (state: BoardState): GameStatus => { const eliminated = eliminatedPlayers(state); - return { eliminated, result: gameResultFrom(state, eliminated) }; + const out = eliminated.map((flag, player) => flag || state.conceded[player]); + return { eliminated, out, result: gameResultFrom(state, out) }; }; -// Skips eliminated players. A live player always exists to land on -- a -// player's own turn can never eliminate themselves (elimination depends only -// on an opponent's stones), so the mover who just ended their turn is always -// alive -- but the scan is still bounded at playerCount steps rather than -// written as an unbounded loop. +// Skips players who are out (eliminated or conceded). A player still in +// always exists to land on -- `concede` returns null unless +// `result.kind === "playing"`, which requires at least two players in. So a +// concession always leaves at least one player in who is not the mover, and +// an elimination can only ever happen on an opponent's turn, never the +// mover's own -- so the mover who just ended their turn is always still in. +// The scan is still bounded at playerCount steps rather than written as an +// unbounded loop. const nextActivePlayer = (state: BoardState): number => { - const eliminated = eliminatedPlayers(state); + const out = playersOut(state); const playerCount = state.players.length; for (let step = 1; step <= playerCount; step++) { const candidate = (state.activePlayer + step) % playerCount; - if (!eliminated[candidate]) { + if (!out[candidate]) { return candidate; } } @@ -619,6 +640,38 @@ export const endTurn = (state: BoardState): BoardState | null => { }; }; +// Conceding removes the player's stones -- home base included, unlike a +// capture -- and hands the turn off immediately. +export const concede = (state: BoardState): BoardState | null => { + if (gameStatus(state).result.kind !== "playing") { + return null; + } + const player = state.activePlayer; + if (state.conceded[player]) { + return null; + } + // No pendingCaptures resolution here, unlike endTurn: a conceding player + // takes no parting captures. Their `scores` entry is left untouched as a + // record, but `aliveScores` no longer sees them once `playersOut` marks + // them out. Removing all their pieces also discards their in-progress + // placements for free -- those stones are theirs, so the same filter + // handles it; no separate retraction pass is needed. + const afterRemoval: BoardState = { + ...state, + conceded: state.conceded.map((flag, index) => (index === player ? true : flag)), + pieces: state.pieces.filter((piece) => piece.player !== player), + }; + // nextActivePlayer runs on afterRemoval, not state: removing the + // conceder's walls can un-eliminate a player whose base those walls + // enclosed, and that player must be back in rotation immediately. + // Computing rotation on the pre-removal board would skip them for a turn. + return { + ...afterRemoval, + activePlayer: nextActivePlayer(afterRemoval), + turn: afterRemoval.turn + 1, + }; +}; + export const place = (state: BoardState, position: Position): BoardState | null => { if (!isInBounds(state.size, position) || remaining(state) <= 0) { return null; diff --git a/frontend/src/history.ts b/frontend/src/history.ts new file mode 100644 index 0000000..707a870 --- /dev/null +++ b/frontend/src/history.ts @@ -0,0 +1,89 @@ +// history.ts owns the record of play: every board state the game has passed +// through, and which one is currently on screen. + +import * as game from "./game.js"; + +export type History = { + starts: readonly game.BoardState[]; // starts[t] = board at the START of turn t; starts[0] is the initial board + present: game.BoardState; // live board, including the in-progress turn's placements + viewing: number | null; // null = present; otherwise a turn index +}; + +export type CaptureCount = { player: number; count: number }; + +export type Frame = { + turn: number; + player: number; // whose turn this was + state: game.BoardState; // the board to display for this row + placed: number; + captured: readonly CaptureCount[]; // grouped by the OWNER of the captured stones, sorted by player index + pending: boolean; // the in-progress turn: counts are provisional + conceded: number | null; // the player who conceded during this turn, if any +}; + +export const begin = (initial: game.BoardState): History => ({ + starts: [initial], + present: initial, + viewing: null, +}); + +// The whole recording mechanism -- one reducer, no coupling to which +// listener fired. endTurn is the only transition that increments `turn`, +// and its return value IS the board at the start of the next turn. +export const advance = (record: History, next: game.BoardState): History => + next.turn > record.present.turn + ? { starts: [...record.starts, next], present: next, viewing: null } + : { ...record, present: next }; + +// Normalizes and validates. The last frame's board IS `present`, so +// selecting the newest row and selecting "present" collapse to the same +// `viewing: null` -- one board never has two selectable representations. +export const view = (record: History, index: number | null): History => { + if (index === null || index === record.starts.length - 1) { + return { ...record, viewing: null }; + } + if (index < 0 || index >= record.starts.length) { + return record; + } + return { ...record, viewing: index }; +}; + +export const visible = (record: History): game.BoardState => + record.viewing === null ? record.present : (record.starts[record.viewing + 1] ?? record.present); + +export const isViewingPast = (record: History): boolean => record.viewing !== null; + +const groupByPlayer = (pieces: readonly game.Piece[]): CaptureCount[] => { + const counts = new Map(); + for (const piece of pieces) { + counts.set(piece.player, (counts.get(piece.player) ?? 0) + 1); + } + return [...counts.entries()] + .map(([player, count]) => ({ player, count })) + .sort((a, b) => a.player - b.player); +}; + +// The single index where this turn's active player conceded, if any -- at +// most one per turn, since only the active player can concede and doing so +// ends their turn. Needed to keep a concession from reading as a phantom +// capture below: removing a whole player's stones from `pieces` looks +// identical to capturing them unless the quitter is excluded from the diff. +const concededDuring = (start: game.BoardState, after: game.BoardState): number | null => { + const index = after.conceded.findIndex((flag, player) => flag && !start.conceded[player]); + return index === -1 ? null : index; +}; + +export const frames = (record: History): readonly Frame[] => + record.starts.map((start, t) => { + const after = record.starts[t + 1] ?? record.present; + const pending = t === record.starts.length - 1; + const placed = after.pieces.filter((piece) => piece.turn === t).length; + const afterSet = new Set(after.pieces); + const conceded = concededDuring(start, after); + const captured = pending + ? groupByPlayer(game.pendingCaptures(record.present)) + : groupByPlayer( + start.pieces.filter((piece) => !afterSet.has(piece) && piece.player !== conceded), + ); + return { turn: t, player: start.activePlayer, state: after, placed, captured, pending, conceded }; + }); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 989135e..23b3704 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -3,6 +3,7 @@ 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"; const ROWS = 16; @@ -46,7 +47,38 @@ if (!(themeSelect instanceof HTMLSelectElement)) { throw new Error("Missing #theme-select element"); } -const panelElements: ui.PanelElements = { playerList, status: turnStatus, endTurn: endTurnButton }; +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 panelElements: ui.PanelElements = { + playerList, + status: turnStatus, + endTurn: endTurnButton, + concede: concedeButton, + log: turnLog, +}; const homeBases = game.generateHomeBases({ rows: ROWS, cols: COLS }, PLAYERS.length, Math.random); if (!homeBases) { @@ -54,18 +86,20 @@ if (!homeBases) { } let layout: canvas.BoardLayout; -let state = game.initialize(PLAYERS, { rows: ROWS, cols: COLS }, homeBases); +let record = history.begin(game.initialize(PLAYERS, { rows: ROWS, cols: COLS }, homeBases)); let currentTheme = theme.byId(ui.storedThemeId() ?? "") ?? theme.defaultTheme; const render = (): void => { + const visibleState = history.visible(record); canvas.renderBoard( ctx, { width: canvasEl.clientWidth, height: canvasEl.clientHeight }, BOARD, - ui.toCanvasPieces(state, currentTheme), + ui.toCanvasPieces(visibleState, currentTheme), currentTheme.board, ); - ui.showPanel(panelElements, state, currentTheme); + ui.showPanel(panelElements, record, currentTheme); + canvasEl.classList.toggle("viewing-past", history.isViewingPast(record)); }; // CSS (flex layout) owns the canvas's on-screen size; this only sizes the @@ -88,7 +122,15 @@ const resize = (): void => { }; const applyState = (next: game.BoardState): void => { - state = next; + if (history.isViewingPast(record)) { + return; + } + record = history.advance(record, next); + render(); +}; + +const setView = (index: number): void => { + record = history.view(record, index); render(); }; @@ -103,9 +145,18 @@ const setTheme = (next: theme.Theme): void => { // doesn't paint Goban chrome for a frame. ui.applyChrome(document.documentElement, currentTheme); -ui.attachInteraction(canvasEl, BOARD, () => layout, () => state, applyState); -ui.attachEndTurn(endTurnButton, () => state, applyState); +ui.attachInteraction(canvasEl, BOARD, () => layout, () => record.present, applyState); +ui.attachEndTurn(endTurnButton, () => record.present, applyState); +ui.attachConcede( + concedeButton, + concedeDialog, + concedeConfirmButton, + concedeCancelButton, + () => record.present, + applyState, +); ui.attachThemePicker(themeSelect, currentTheme, setTheme); +ui.attachLog(turnLog, setView); window.addEventListener("resize", resize); resize(); diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index 105ea3a..dac504f 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -13,6 +13,8 @@ export type Chrome = { buttonBg: string; buttonText: string; buttonBorder: string; + danger: string; + dangerText: string; }; export type Theme = { id: string; // stable, stored in localStorage @@ -50,6 +52,9 @@ const goban: Theme = { buttonBg: "#2a2a30", buttonText: "rgba(255, 255, 255, 0.85)", buttonBorder: "rgba(255, 255, 255, 0.25)", + // Deliberately not #e63946, which is player 3's stone colour in this theme. + danger: "#b3202c", + dangerText: "#fff1f1", }, }; @@ -80,6 +85,8 @@ const kanagawa: Theme = { buttonBg: "#2A2A37", buttonText: "#DCD7BA", buttonBorder: "#54546D", + danger: "#C34043", + dangerText: "#DCD7BA", }, }; @@ -110,6 +117,9 @@ const catppuccinMocha: Theme = { buttonBg: "#313244", buttonText: "#cdd6f4", buttonBorder: "#45475a", + // Mocha's red is light, so it takes dark text. + danger: "#f38ba8", + dangerText: "#11111b", }, }; diff --git a/frontend/src/ui.ts b/frontend/src/ui.ts index a93912a..bcc42a4 100644 --- a/frontend/src/ui.ts +++ b/frontend/src/ui.ts @@ -5,12 +5,15 @@ import * as canvas from "./canvas.js"; import * as game from "./game.js"; +import * as history from "./history.js"; import * as theme from "./theme.js"; export type PanelElements = { playerList: HTMLElement; status: HTMLElement; endTurn: HTMLButtonElement; + concede: HTMLButtonElement; + log: HTMLElement; }; const buildPlayerRow = ( @@ -19,6 +22,7 @@ const buildPlayerRow = ( score: number, isActive: boolean, isEliminated: boolean, + isConceded: boolean, ): HTMLElement => { const row = document.createElement("div"); row.className = "player-row"; @@ -28,6 +32,9 @@ const buildPlayerRow = ( if (isEliminated) { row.classList.add("eliminated"); } + if (isConceded) { + row.classList.add("conceded"); + } const swatch = document.createElement("span"); swatch.className = "player-swatch"; @@ -35,13 +42,31 @@ const buildPlayerRow = ( const name = document.createElement("span"); name.className = "player-name"; - name.textContent = isEliminated ? `${player.name} (out)` : player.name; + // Conceded takes precedence over eliminated: it's permanent, and the + // eliminated state is meaningless once the player's stones -- including + // the base elimination depends on -- are gone. + name.textContent = isConceded + ? `${player.name} (conceded)` + : isEliminated + ? `${player.name} (out)` + : player.name; const scoreEl = document.createElement("span"); scoreEl.className = "player-score"; scoreEl.textContent = String(score); - row.append(swatch, name, scoreEl); + row.append(swatch, name); + // A DOM text glyph is fine here -- the "don't use glyphs" note in canvas.ts + // is about ctx.fillText ignoring fillStyle, which doesn't apply to CSS + // `color`. The flag is panel-only: a conceded player's pieces are removed + // from the board, so there's no piece left to draw an emblem on. + if (isConceded) { + const flag = document.createElement("span"); + flag.className = "player-flag"; + flag.textContent = "⚑"; + row.append(flag); + } + row.append(scoreEl); return row; }; @@ -63,11 +88,19 @@ const renderPlayerList = ( state.scores[index], index === state.activePlayer, eliminated[index], + state.conceded[index], )), ); }; -const statusText = (state: game.BoardState, result: game.GameResult): string => { +const statusText = ( + state: game.BoardState, + result: game.GameResult, + viewingTurn: number | null, +): string => { + if (viewingTurn !== null) { + return `Viewing turn ${viewingTurn} — read-only`; + } if (result.kind === "won") { return `${state.players[result.player].name} wins!`; } @@ -85,18 +118,97 @@ const statusText = (state: game.BoardState, result: game.GameResult): string => : `${placed} of ${budget} placed`; }; +const buildLogDot = (color: string): HTMLElement => { + const dot = document.createElement("span"); + dot.className = "log-dot"; + dot.style.background = color; + return dot; +}; + +const buildLogChip = (count: number, color: string): HTMLElement => { + const chip = document.createElement("span"); + chip.className = "log-chip"; + chip.append(buildLogDot(color), document.createTextNode(String(count))); + return chip; +}; + +const buildLogRow = ( + frame: history.Frame, + viewing: number | null, + activeTheme: theme.Theme, +): HTMLElement => { + const row = document.createElement("button"); + row.type = "button"; + row.className = "log-row"; + row.dataset.index = String(frame.turn); + if (viewing === frame.turn || (viewing === null && frame.pending)) { + row.classList.add("selected"); + } + if (frame.pending) { + row.classList.add("pending"); + } + + const label = document.createElement("span"); + label.className = "log-label"; + label.textContent = `Turn ${frame.turn}`; + label.style.color = theme.playerColors(activeTheme, frame.player).stone; + + const placedChip = buildLogChip(frame.placed, theme.playerColors(activeTheme, frame.player).stone); + + const capturedChips = frame.captured.map((entry) => + buildLogChip(entry.count, theme.playerColors(activeTheme, entry.player).stone)); + + row.append(label, placedChip, ...capturedChips); + return row; +}; + +// Captures scrollTop before rebuilding (replaceChildren, same "render is a +// pure function of state" reasoning as the player list) and restores it +// afterward -- unless a turn just ended while live, in which case it +// follows the newest row instead of yanking the reader back down mid-scrub. +const renderLog = ( + listEl: HTMLElement, + frameList: readonly history.Frame[], + viewing: number | null, + activeTheme: theme.Theme, +): void => { + const priorScrollTop = listEl.scrollTop; + const priorCount = listEl.childElementCount; + + listEl.replaceChildren(...frameList.map((frame) => buildLogRow(frame, viewing, activeTheme))); + + if (frameList.length > priorCount && viewing === null) { + listEl.scrollTop = listEl.scrollHeight; + } else { + listEl.scrollTop = priorScrollTop; + } +}; + +export const attachLog = (listEl: HTMLElement, onView: (index: number) => void): void => { + listEl.addEventListener("click", (event) => { + const row = (event.target as Element).closest(".log-row"); + if (row instanceof HTMLElement && row.dataset.index !== undefined) { + onView(Number(row.dataset.index)); + } + }); +}; + // 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. export const showPanel = ( elements: PanelElements, - state: game.BoardState, + record: history.History, activeTheme: theme.Theme, ): void => { + const state = history.visible(record); const { eliminated, result } = game.gameStatus(state); renderPlayerList(elements.playerList, state, eliminated, activeTheme); - elements.status.textContent = statusText(state, result); - elements.endTurn.disabled = result.kind !== "playing"; + elements.status.textContent = statusText(state, result, record.viewing); + const disabled = result.kind !== "playing" || history.isViewingPast(record); + elements.endTurn.disabled = disabled; + elements.concede.disabled = disabled; + renderLog(elements.log, history.frames(record), record.viewing, activeTheme); }; // eliminatedPlayers runs one flood fill per player; computed once here and @@ -178,6 +290,17 @@ const isFocusedFormControl = (element: Element | null): boolean => element instanceof HTMLTextAreaElement || element instanceof HTMLButtonElement; +// A 's showModal() makes the rest of the page inert -- no pointer +// events, no focus, no tab stops out there -- but that inertness does NOT +// extend to window-level listeners: a keydown dispatched on any element +// inside the dialog (even non-focusable body text, which throws focus onto +// the itself) still bubbles through document to window like any +// other keydown. isFocusedFormControl alone can't be trusted to catch this +// -- it only holds while focus happens to sit on a button/input/etc, and a +// click on plain text inside an open dialog breaks that. This checks the +// actual thing that matters instead. +const isModalOpen = (): boolean => document.querySelector("dialog[open]") !== null; + export const attachEndTurn = ( button: HTMLButtonElement, getState: () => game.BoardState, @@ -197,7 +320,15 @@ export const attachEndTurn = ( // effect on this window-level listener, and a disabled button can never // become document.activeElement -- so without this check, Enter/Space // would keep ending turns after a win even with the button greyed out. - if (button.disabled || !isEndTurnKey(event.key) || isFocusedFormControl(document.activeElement)) { + // isModalOpen() is the load-bearing guard against any open dialog (see + // its comment above) -- isFocusedFormControl is kept alongside it for + // ordinary form controls (a ) outside any dialog. + if ( + button.disabled || + !isEndTurnKey(event.key) || + isFocusedFormControl(document.activeElement) || + isModalOpen() + ) { return; } event.preventDefault(); @@ -205,6 +336,46 @@ export const attachEndTurn = ( }); }; +// 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 +// what actually keeps attachEndTurn's Enter/Space handler from firing behind +// this overlay). Confirm is never dismissed by backdrop click -- a +// destructive action requires an explicit Cancel or Escape -- so no backdrop +// listener is added. +export const attachConcede = ( + button: HTMLButtonElement, + dialog: HTMLDialogElement, + confirmButton: HTMLButtonElement, + cancelButton: HTMLButtonElement, + getState: () => game.BoardState, + onState: (next: game.BoardState) => void, +): void => { + const nameEl = dialog.querySelector("[data-concede-name]"); + + button.addEventListener("click", () => { + if (nameEl) { + nameEl.textContent = game.currentPlayer(getState()).name; + } + dialog.showModal(); + // Cancel, not Confirm, gets focus: a stray Enter should cancel, not + // concede. + cancelButton.focus(); + }); + + confirmButton.addEventListener("click", () => { + dialog.close(); + const next = game.concede(getState()); + if (next) { + onState(next); + } + }); + + cancelButton.addEventListener("click", () => { + dialog.close(); + }); +}; + // --- Theme: CSS variables, chrome application, picker, persistence ------- // The variable names are a DOM concern, so this mapping lives here rather @@ -219,6 +390,12 @@ export const cssVariables = (activeTheme: theme.Theme): Readonly {