// game.ts owns the game's own state and rules, independent of how any of it gets painted. export type Player = { name: string }; export type Position = { row: number; col: number }; export type Piece = { position: Position; player: number; turn: number; isHomeBase: boolean }; export type BoardSize = { rows: number; cols: number }; export type BoardState = { pieces: Piece[]; players: Player[]; activePlayer: number; turn: number; placementsPerTurn: number; size: BoardSize; // The first accumulated-history field on BoardState -- everything else here // is configuration or current position, reconstructible from a single // snapshot. Scores can't be: captured pieces are gone from `pieces` the // instant they're captured, so a running tally is the only record of them. // A capture log would let a future feature (e.g. replay, or task 09's // 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; // Sentinel turn stamped on every home base. Two behaviours fall out of it for // free, and both are load-bearing: `placedThisTurn` is permanently false for // a base (state.turn starts at 0 and only increases, so it can never equal // PRE_GAME_TURN), which means a base can never be the placement that // triggers a capture; and `piece.turn < state.turn` is permanently true, so // a base always counts as pre-existing on every start-of-turn board. const PRE_GAME_TURN = -1; export const HOME_BASE_MARGIN = 3; export const MIN_HOME_BASE_DISTANCE = 8; // --- Geometry helpers ------------------------------------------------- type Offset = { row: number; col: number }; const ORTHOGONAL: readonly Offset[] = [ { row: -1, col: 0 }, { row: 1, col: 0 }, { row: 0, col: -1 }, { row: 0, col: 1 }, ]; const DIAGONAL: readonly Offset[] = [ { row: -1, col: -1 }, { row: -1, col: 1 }, { row: 1, col: -1 }, { row: 1, col: 1 }, ]; const PATTERNS: readonly (readonly Offset[])[] = [ORTHOGONAL, DIAGONAL]; const DIRECTIONS: readonly Offset[] = [...ORTHOGONAL, ...DIAGONAL]; const shift = (position: Position, offset: Offset, steps: number): Position => ({ row: position.row + offset.row * steps, col: position.col + offset.col * steps, }); const isDiagonalOffset = (offset: Offset): boolean => offset.row !== 0 && offset.col !== 0; const chebyshev = (a: Position, b: Position): number => Math.max(Math.abs(a.row - b.row), Math.abs(a.col - b.col)); // --- Board / region helpers -------------------------------------------- const positionKey = (position: Position): string => `${position.row},${position.col}`; const isInBounds = (size: BoardSize, position: Position): boolean => position.row >= 0 && position.row < size.rows && position.col >= 0 && position.col < size.cols; const touchesBorder = (size: BoardSize, position: Position): boolean => position.row === 0 || position.row === size.rows - 1 || position.col === 0 || position.col === size.cols - 1; // --- Territory: the "sticky sea" ----------------------------------------- // // Each player has a SEA: the part of the board they have not walled off. It // starts as the whole board and can only shrink. `seaCells` replays that // player's live stones in turn order; each time their stones subdivide the // current sea, the largest rim-touching child stays the sea (ties: every // tied child stays sea) and every other child becomes territory -- and // stays territory for as long as the stones that made that split remain on // the board. See "Territory" in docs/memories/project/capture-rules.md for // the full derivation and the worked examples; the load-bearing facts are // repeated here because they constrain how this code may be changed: // // - Children are subsets of the parent, and `blocked` only grows within one // replay, so sea cells can never later reconnect to territory cells. // Restricting the flood fill to the CURRENT sea (not the whole board) is // therefore exact, not an approximation -- and it's what keeps the cost // down. Don't "simplify" this into a whole-board fill every step. // - A cell outside the current sea can never be affected by a later stone, // which is what licenses skipping a turn whose stones don't touch the // current sea (see the early-continue below). That skip is load-bearing // for performance, not an optional micro-optimisation. // - This must stay a from-scratch derivation over LIVE pieces, never // anything cached on BoardState: a captured wall stone is simply absent // from every replay step, so the split it made never happened, and the // region it cut off merges back into the sea for free. That's what keeps // "breach a wall and the territory releases; repair it and everything // inside is re-captured" working with no extra code. // One refinement step: split `sea` into its 4-connected components after // removing `blocked`, restricted to cells already in `sea` (never the whole // board -- see above). The rim-touching child(ren) with the most cells stay // the sea; every other child is surrendered as territory. If no child // touches the rim, the sea empties entirely (the whole board becomes // territory), which reproduces "if the active player owns the entire rim, // every region is territory." const refineSea = ( size: BoardSize, sea: ReadonlySet, blocked: ReadonlySet, ): Set => { const remaining = new Set([...sea].filter((key) => !blocked.has(key))); const visited = new Set(); const children: { cells: string[]; touchesBorder: boolean }[] = []; for (const startKey of remaining) { if (visited.has(startKey)) { continue; } const [startRowText, startColText] = startKey.split(","); const start: Position = { row: Number(startRowText), col: Number(startColText) }; const cells: string[] = []; let childTouchesBorder = false; const stack: Position[] = [start]; visited.add(startKey); while (stack.length > 0) { const position = stack.pop() as Position; cells.push(positionKey(position)); if (touchesBorder(size, position)) { childTouchesBorder = true; } for (const offset of ORTHOGONAL) { const next = { row: position.row + offset.row, col: position.col + offset.col }; const nextKey = positionKey(next); if (!remaining.has(nextKey) || visited.has(nextKey)) { continue; } visited.add(nextKey); stack.push(next); } } children.push({ cells, touchesBorder: childTouchesBorder }); } const rimmed = children.filter((child) => child.touchesBorder); if (rimmed.length === 0) { return new Set(); } const maxRimmedSize = rimmed.reduce((max, child) => Math.max(max, child.cells.length), 0); const next = new Set(); for (const child of rimmed) { if (child.cells.length === maxRimmedSize) { child.cells.forEach((key) => next.add(key)); } } return next; }; // Replays `blockers` (one player's live, already-filtered stones) in // ascending turn order -- PRE_GAME_TURN (-1, the home base) first -- through // `refineSea`, growing `blocked` one turn's worth of positions at a time. // Sorted explicitly rather than trusting `state.pieces` append order: append // order happens to be turn-ordered per player today, but sorting a handful // of values is self-evidently correct and doesn't depend on that holding. export const seaCells = (size: BoardSize, blockers: readonly Piece[]): Set => { const turns = [...new Set(blockers.map((piece) => piece.turn))].sort((a, b) => a - b); let sea = new Set(); for (let row = 0; row < size.rows; row++) { for (let col = 0; col < size.cols; col++) { sea.add(positionKey({ row, col })); } } const blocked = new Set(); for (const turn of turns) { for (const piece of blockers) { if (piece.turn === turn) { blocked.add(positionKey(piece.position)); } } // If nothing placed this turn sits inside the current sea, this turn // cannot have subdivided it -- skip the flood fill entirely. Safe // because a cell outside the current sea can never be affected by a // later stone (see the file-level comment above). const splitsSea = blockers.some( (piece) => piece.turn === turn && sea.has(positionKey(piece.position)), ); if (!splitsSea) { continue; } sea = refineSea(size, sea, blocked); if (sea.size === 0) { break; } } return sea; }; // The complement of the sea: every cell that is neither sea nor blocked by // one of the player's own stones. Drop-in replacement for the old // `enclosedCells`, now keyed off pieces (for turn order) rather than bare // positions. const territoryCells = (size: BoardSize, blockers: readonly Piece[]): Set => { const sea = seaCells(size, blockers); const blocked = new Set(blockers.map((piece) => positionKey(piece.position))); const territory = new Set(); for (let row = 0; row < size.rows; row++) { for (let col = 0; col < size.cols; col++) { const key = positionKey({ row, col }); if (!sea.has(key) && !blocked.has(key)) { territory.add(key); } } } return territory; }; // --- Setup --------------------------------------------------------------- const MAX_ATTEMPTS_PER_BASE = 200; const MAX_RESTARTS = 50; const randomInt = (random: () => number, min: number, max: number): number => min + Math.floor(random() * (max - min + 1)); // Pure rejection sampling: game.ts never calls Math.random itself, the // caller supplies the randomness so setup stays reproducible under a seeded // generator. A per-base retry loop can stall even when a full solution // exists elsewhere in the search space, so this bounds a small number of // full restarts rather than retrying one stuck base forever; a // small/crowded board can also have no solution at all, in which case this // returns null instead of looping. export const generateHomeBases = ( size: BoardSize, playerCount: number, random: () => number, ): Position[] | null => { const minRow = HOME_BASE_MARGIN; const maxRow = size.rows - 1 - HOME_BASE_MARGIN; const minCol = HOME_BASE_MARGIN; const maxCol = size.cols - 1 - HOME_BASE_MARGIN; if (minRow > maxRow || minCol > maxCol) { return null; } for (let restart = 0; restart < MAX_RESTARTS; restart++) { const bases: Position[] = []; for (let index = 0; index < playerCount; index++) { let placed = false; for (let attempt = 0; attempt < MAX_ATTEMPTS_PER_BASE; attempt++) { const candidate: Position = { row: randomInt(random, minRow, maxRow), col: randomInt(random, minCol, maxCol), }; if (bases.every((base) => chebyshev(base, candidate) >= MIN_HOME_BASE_DISTANCE)) { bases.push(candidate); placed = true; break; } } if (!placed) { break; } } if (bases.length === playerCount) { return bases; } } return null; }; // homeBases is index-aligned to players: homeBases[i] belongs to players[i]. export const initialize = ( players: readonly Player[], size: BoardSize, homeBases: readonly Position[], placementsPerTurn: number = DEFAULT_PLACEMENTS_PER_TURN, ): BoardState => ({ pieces: homeBases.map((position, player) => ({ position, player, turn: PRE_GAME_TURN, isHomeBase: true, })), players: [...players], activePlayer: 0, turn: 0, placementsPerTurn, size, scores: players.map(() => 0), conceded: players.map(() => false), }); export const currentPlayer = (state: BoardState): Player => state.players[state.activePlayer]; const isSamePosition = (a: Position, b: Position): boolean => a.row === b.row && a.col === b.col; // .find, not a topmost-wins lookup, is load-bearing since overlays shipped // (see isOverlay below): a doomed piece is always appended strictly before // any overlay placed on top of it, so this always reports the doomed piece, // never the overlay. Every rule that reads the board through pieceAt -- // tryLink, ringAt -- therefore sees the victim, not the overlay, and needs // no special case to keep overlays inert. Don't "fix" this into // topmost-wins; that would make overlays participate in cuts and rings. export const pieceAt = (state: BoardState, position: Position): Piece | undefined => state.pieces.find((piece) => isSamePosition(piece.position, position)); // An overlay is a piece whose position is also occupied by another, // earlier-placed piece -- the result of placing into a cell that // pendingCaptures has already doomed (see `place`). Because pieceAt always // resolves to the earlier piece, an overlay is never what pieceAt reports // for its own cell. Code that reads state.pieces directly instead of // through pieceAt -- pendingCuts' fresh-stone scan, territoryByPlayer's and // pendingTerritories' blocker lists -- has to exclude overlays itself. const isOverlay = (state: BoardState, piece: Piece): boolean => pieceAt(state, piece.position) !== piece; // The overlay at a position, if that position currently holds one -- // `pieceAt` can never find it (see above), so this is the one place that // deliberately wants the LATEST piece at a position rather than the // earliest. Retraction is the only caller: a click on a cell holding both a // doomed piece and an overlay should retract the overlay (the piece the // active player actually placed), never the victim underneath it. const overlayAt = (state: BoardState, position: Position): Piece | undefined => { const occupants = state.pieces.filter((piece) => isSamePosition(piece.position, position)); return occupants.length > 1 ? occupants[occupants.length - 1] : undefined; }; const homeBaseOf = (state: BoardState, player: number): Piece | undefined => state.pieces.find((piece) => piece.isHomeBase && piece.player === player); // "Can the user undo this" and "did this placement arm a capture" coincide today // but are different questions; they may diverge if retraction rules ever change. const placedThisTurn = (state: BoardState, piece: Piece): boolean => piece.player === state.activePlayer && piece.turn === state.turn; export const isRetractable = (state: BoardState, piece: Piece): boolean => placedThisTurn(state, piece); const placementsThisTurn = (state: BoardState): number => state.pieces.filter((piece) => isRetractable(state, piece)).length; // Derived, never stored: BoardState gains no field for this. Home bases are // excluded because they are never actually removed by a cut, so refunding a // placement for "capturing" one would hand out a free stone. A piece claimed // by both a cut and territory is deduped by pendingCuts already and still // counts once -- the player did cut it. export const bonusPlacements = (state: BoardState): number => pendingCuts(state).filter((piece) => !piece.isHomeBase).length; export const placementBudget = (state: BoardState): number => state.placementsPerTurn + bonusPlacements(state); export const remaining = (state: BoardState): number => placementBudget(state) - placementsThisTurn(state); // Nobody can be eliminated during round one -- elimination requires a // completed enemy territory wall, which needs more than three stones to // build, so no player is ever skipped in turn rotation before every player // has had a first turn. That means the global turn index maps 1:1 onto each // player's own first turn, and this comparison alone is a safe stand-in for // "is this player's first turn." export const isOpeningTurn = (state: BoardState): boolean => state.turn < state.players.length; const isAdjacentToOwnBase = (state: BoardState, position: Position): boolean => { const base = homeBaseOf(state, state.activePlayer); return base !== undefined && chebyshev(base.position, position) === 1; }; // --- Elimination and victory -------------------------------------------- // // Every other rule in this file is an EVENT: it fires once, at the moment a // shape completes, against a single before/after diff. Elimination is a // STATE predicate instead -- it holds continuously for as long as a base // sits inside enemy territory, and it reverses itself the instant that wall // breaks, with no "capture" ever recorded. It is deliberately never stored // on BoardState; it is re-derived from `pieces` every time it's asked. // One flood fill per player, shared across every isEliminated check derived // from the same state, so a caller checking several players (turn rotation, // victory) pays N flood fills rather than N^2. const territoryByPlayer = (state: BoardState): Set[] => state.players.map((_, player) => territoryCells( state.size, // Overlays excluded: an overlay sitting on a doomed enemy cell would // otherwise block that cell for its own owner's sea replay, silently // un-dooming the very piece it was placed on. state.pieces.filter((piece) => piece.player === player && !isOverlay(state, piece)), )); const isEliminatedWithTerritories = ( state: BoardState, player: number, territories: readonly Set[], ): boolean => { const base = homeBaseOf(state, player); if (!base) { return false; } const baseKey = positionKey(base.position); return territories.some((territory, other) => other !== player && territory.has(baseKey)); }; // Every player's eliminated-or-not, in one pass: a single territoryByPlayer // call (N flood fills) shared across every player's check, rather than each // caller -- isEliminated, gameResult, nextActivePlayer, and now the // renderer's per-render emblem lookup -- paying for its own pass. Callers // that need more than one player's status in the same breath should call // this once and index into the result rather than calling isEliminated per // player. export const eliminatedPlayers = (state: BoardState): readonly boolean[] => { const territories = territoryByPlayer(state); return state.players.map((_, player) => isEliminatedWithTerritories(state, player, territories)); }; 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 // shares a cell with the doomed piece underneath it: two pieces, one // occupied cell. This is the deadlock trigger (task 09). The old comment // here claimed "no legal plays" and "board full" were the same fact, // because placement was unrestricted whenever a cell was empty -- that // stated equivalence now has one exception: a cell holding exactly one // doomed piece is full (this function says so) but still a legal `place` // target (an overlay). The board can therefore read "full" while a // placement is still legal into it; `place`'s own guards, not this // function, are the source of truth for legality. const boardIsFull = (state: BoardState): boolean => new Set(state.pieces.map((piece) => positionKey(piece.position))).size === state.size.rows * state.size.cols; export type GameResult = | { kind: "playing" } | { kind: "won"; player: number } | { kind: "draw" }; const aliveScores = ( state: BoardState, out: readonly boolean[], ): { player: number; score: number }[] => state.players .map((_, player) => ({ player, score: state.scores[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 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, out: readonly boolean[]): GameResult => { const alive = aliveScores(state, out); if (alive.length === 1) { return { kind: "won", player: alive[0].player }; } if (!boardIsFull(state)) { return { kind: "playing" }; } 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: `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" }; }; // Both a per-player elimination flag and the derived result, from a single // 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. `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); const out = eliminated.map((flag, player) => flag || state.conceded[player]); return { eliminated, out, result: gameResultFrom(state, out) }; }; // 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 out = playersOut(state); const playerCount = state.players.length; for (let step = 1; step <= playerCount; step++) { const candidate = (state.activePlayer + step) % playerCount; if (!out[candidate]) { return candidate; } } return state.activePlayer; }; // --- Encirclement ---------------------------------------------------- const ringAt = ( state: BoardState, position: Position, pattern: readonly Offset[], ): readonly (Piece | undefined)[] => pattern.map((offset) => pieceAt(state, { row: position.row + offset.row, col: position.col + offset.col })); const isCapturingRing = ( state: BoardState, ring: readonly (Piece | undefined)[], ): boolean => { const owned = ring.filter( (piece): piece is Piece => piece !== undefined && piece.player === state.activePlayer, ); return owned.length === ring.length && owned.some((piece) => placedThisTurn(state, piece)); }; // The orthogonal ring is NOT redundant with territory: it fires on a cell // that is ALREADY the capturing player's territory, which territory itself // cannot do, since that cell's territory status never changes. Tightening a // ring around an infiltrator deep inside a standing territory is exactly // this case. (Deleted 2026-09-06 on the mistaken claim that a one-cell // territory reproduces it exactly; restored the same day when this case // surfaced.) The diagonal four-stone ring has no territory equivalent at // all: its stones sit two cells apart with no connected chain between them, // so both patterns stay hand-written special cases. export const pendingEncirclements = (state: BoardState): Piece[] => state.pieces.filter( (piece) => piece.player !== state.activePlayer && PATTERNS.some((pattern) => isCapturingRing(state, ringAt(state, piece.position, pattern))), ); // --- Cutting ----------------------------------------------------------- // // A cut severs a line; it needs a line of at least two enemy stones, not a // closed shape. See docs/memories/project/capture-rules.md for the // principle this superseded on 2026-09-07. // A link is the smallest unit a cut is built from: a fresh stone A reaching // one step further along direction d into another fresh stone (a joint), or // two steps through a single enemy stone into a fresh stone beyond it (a // pierce). No other case is a link -- which is what keeps pre-existing own // stones and empty gaps out of a cut line without a separate check: a joint // needs the neighbour fresh, a pierce needs it enemy. type Link = | { kind: "joint"; source: Position; direction: Offset; target: Position } | { kind: "pierce"; source: Position; direction: Offset; target: Position; victim: Piece }; const tryLink = (state: BoardState, source: Position, direction: Offset): Link | null => { const mid = shift(source, direction, 1); const midPiece = pieceAt(state, mid); if (midPiece && placedThisTurn(state, midPiece)) { return { kind: "joint", source, direction, target: mid }; } if (midPiece && midPiece.player !== state.activePlayer) { const target = shift(source, direction, 2); const targetPiece = pieceAt(state, target); if (targetPiece && placedThisTurn(state, targetPiece)) { return { kind: "pierce", source, direction, target, victim: midPiece }; } } return null; }; // A stone with at least one same-owner 8-neighbour -- the "line of 2+" // requirement that keeps a lone enemy stone immune to a pierce. The two cut // cells flanking the victim are the active player's, so a same-owner // neighbour is never on the cut line itself. const hasSameOwnerNeighbour = (state: BoardState, piece: Piece): boolean => DIRECTIONS.some((offset) => { const neighbour = pieceAt(state, shift(piece.position, offset, 1)); return neighbour !== undefined && neighbour.player === piece.player; }); // What a single link captures, evaluated against its own source stone and // direction -- not the cut as a whole, since a cut's two links can each have // a different source. const linkCaptures = (state: BoardState, link: Link): Piece[] => { if (link.kind === "pierce") { return hasSameOwnerNeighbour(state, link.victim) ? [link.victim] : []; } if (!isDiagonalOffset(link.direction)) { return []; } // The other two corners of the unit square between source and target: a // diagonal joint crosses them, and they're diagonally adjacent to each // other by construction, so no separate "line of 2" check is needed here. const shoulderA = pieceAt(state, { row: link.source.row + link.direction.row, col: link.source.col }); const shoulderB = pieceAt(state, { row: link.source.row, col: link.source.col + link.direction.col }); if ( shoulderA && shoulderB && shoulderA.player === shoulderB.player && shoulderA.player !== state.activePlayer ) { return [shoulderA, shoulderB]; } return []; }; // For each fresh stone and each of the 8 directions, two consecutive links // along the same direction form a cut -- three fresh stones, two links. // Iterating every fresh stone as a possible starting point, and all 8 // directions, covers both traversal orders of each axis; the final dedup // absorbs the resulting double count. export const pendingCuts = (state: BoardState): Piece[] => { // Overlays excluded from the fresh-stone scan: an overlay is fresh, but // pieceAt reports its cell as the enemy stone beneath it, so treating it // as a link source would let cuts chain off a cell that's simultaneously // "mine, fresh" and "an enemy stone" -- incoherent. const captures = state.pieces .filter((piece) => placedThisTurn(state, piece) && !isOverlay(state, piece)) .flatMap((piece) => DIRECTIONS.flatMap((direction) => { const link1 = tryLink(state, piece.position, direction); if (!link1) { return []; } const link2 = tryLink(state, link1.target, direction); if (!link2) { return []; } return [...linkCaptures(state, link1), ...linkCaptures(state, link2)]; })); return [...new Set(captures)]; }; // --- Territory ----------------------------------------------------------- // Per-cell, not per-region: a cell captures the moment it becomes territory // and stays captured-or-not from there, with no region identity to track. // This is what makes standing territory not re-fire, playing into standing // enemy territory safe, and a repaired wall re-capture everything inside. export const pendingTerritories = (state: BoardState): Piece[] => { // Overlays excluded for the same self-cancelling reason as // territoryByPlayer above: an overlay is the active player's own stone // sitting on a doomed enemy cell, and counting it as a blocker would // remove that very cell from `after`'s sea replay, un-dooming its own // victim. const activeBlockers = state.pieces.filter( (piece) => piece.player === state.activePlayer && !isOverlay(state, piece), ); // `before` is a full independent replay of the start-of-turn prefix, not // a truncation of `after`'s sea -- the sticky-sea replay is inherently // history-dependent, so there is no shortcut from one to the other. const startOfTurnBlockers = state.pieces.filter( (piece) => piece.player === state.activePlayer && piece.turn < state.turn, ); const after = territoryCells(state.size, activeBlockers); const before = territoryCells(state.size, startOfTurnBlockers); return state.pieces.filter( (piece) => piece.player !== state.activePlayer && after.has(positionKey(piece.position)) && !before.has(positionKey(piece.position)), ); }; // Home bases are filtered out here, once, rather than guarding each of the // three rules above -- one choke point to be right, one place to look. A // base can still be a ring member, a wall stone, or sit inside a wall that // gets cut; it just never leaves the board as a captured piece. export const pendingCaptures = (state: BoardState): Piece[] => [ ...new Set([ ...pendingEncirclements(state), ...pendingCuts(state), ...pendingTerritories(state), ]), ].filter((piece) => !piece.isHomeBase); export const endTurn = (state: BoardState): BoardState | null => { // Checked before anything else, and before pendingCaptures runs: once the // game is over, no turn -- including whatever captures it would have // resolved -- ends. if (gameStatus(state).result.kind !== "playing") { return null; } if (isOpeningTurn(state) && remaining(state) > 0) { return null; } const captured = new Set(pendingCaptures(state)); const afterCaptures: BoardState = { ...state, pieces: state.pieces.filter((piece) => !captured.has(piece)), scores: state.scores.map((score, player) => player === state.activePlayer ? score + captured.size : score), }; return { ...afterCaptures, activePlayer: nextActivePlayer(afterCaptures), turn: afterCaptures.turn + 1, }; }; // 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; } if (gameStatus(state).result.kind !== "playing") { return null; } if (isOpeningTurn(state) && !isAdjacentToOwnBase(state, position)) { return null; } // Empty is always legal. A cell with exactly one occupant is legal only // if that occupant is doomed -- placing an overlay on it, which reserves // the cell and costs a placement like any other, without granting the // captured stone itself (Arcadia's choice: cement the slot, or leave it // free and go aggressive elsewhere). A cell with two occupants already // (a doomed piece plus an overlay) never accepts a third -- no stacking. const occupants = state.pieces.filter((piece) => isSamePosition(piece.position, position)); if (occupants.length > 1) { return null; } if (occupants.length === 1 && !new Set(pendingCaptures(state)).has(occupants[0])) { return null; } const piece: Piece = { position, player: state.activePlayer, turn: state.turn, isHomeBase: false }; return { ...state, pieces: [...state.pieces, piece] }; }; // remaining >= 0 is a turn-long invariant. Placing more stones can only add // pending cuts, never remove them, so remaining can only be driven negative // by retraction -- this guard is the one place that has to check for it, // which is why endTurn needs no budget check of its own. // // A second, independent guard protects overlays: retracting the stone that // armed a cut can leave an overlay sitting on a piece that's no longer // doomed, without ever driving `remaining` negative (the budget guard alone // doesn't catch this -- see docs/tasks/2026-09-07/13-place-into-pending-captures.md // for the worked example). An orphaned overlay would leave a live enemy // stone permanently un-clickable -- so reject that retraction outright. In // practice the player retracts the overlay first, then the stone that // armed the cut -- the same natural order the budget guard already relies // on, and exactly what `overlayAt` below exists to make possible: a click // on an overlaid cell must retract the overlay, not the victim beneath it. export const retract = (state: BoardState, position: Position): BoardState | null => { const existing = overlayAt(state, position) ?? pieceAt(state, position); if (!existing || !isRetractable(state, existing)) { return null; } const next = { ...state, pieces: state.pieces.filter((piece) => piece !== existing) }; if (remaining(next) < 0) { return null; } const stillDoomed = new Set(pendingCaptures(next)); const orphaned = next.pieces.some( (piece) => isOverlay(next, piece) && !stillDoomed.has(pieceAt(next, piece.position) as Piece), ); return orphaned ? null : next; };