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.
37 kB · 827 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828// 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<string>, blocked: ReadonlySet<string>,): Set<string> => { const remaining = new Set([...sea].filter((key) => !blocked.has(key))); const visited = new Set<string>(); 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<string>(); 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<string> => { const turns = [...new Set(blockers.map((piece) => piece.turn))].sort((a, b) => a - b);
let sea = new Set<string>(); 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<string>(); 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<string> => { const sea = seaCells(size, blockers); const blocked = new Set(blockers.map((piece) => positionKey(piece.position))); const territory = new Set<string>(); 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<string>[] => 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<string>[],): 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;};