// bot/lib.ts is the shared substrate every strategy builds on: cheap board // lookups, the candidate moves worth considering, and scored selection. Not // a strategy itself -- bot.ts lists strategies explicitly (see bot.ts), so // there's no ambiguity about this file's role. // // Revision log: // - 2026-09-08: initial version, designed by Seer. // - 2026-09-10: added `ringPressure`/`CellPressure`/`EXPOSURE_PENALTY` (a // shared offence/defence/exposure signal over ring captures) and the // `fallback` ladder, designed by Seer after diagnosing // `seed1990751459` (Tentacles rebuild livelock, T43-T160) and // `seed3846449704` (Cuts scattering with 0 captures for 33 stones) -- // see docs/memories/project/bot-strategies.md. Wired into circles.ts, // cuts.ts, and tentacles.ts in the same pass. // - 2026-09-10: added `topBy`/`choose`, replacing circles.ts/cuts.ts's // "take the best score only if positive, else fall back" shape. That // shape discarded a strictly better move whenever every candidate's // score was negative (common once EXPOSURE_PENALTY is in play) -- // `fallback` can't rank by score, so it picked at random between a real // signal and none. `choose` takes the max unconditionally (the // exposure penalty already gates correctly on its own: a safe, // worthless cell scores 0 and beats any exposed cell's negative score) // and only hands ties at that max to `fallback`. Found by Seer's // review 2026-09-10. // - 2026-09-10 (Pass 2, substrate only -- not yet wired into any // strategy): added `SeaCut`/`seaCuts`, an exact one-ply lookahead over // game.seaCells for "what would this placement enclose" -- offensively // (my own next closing cell) and defensively (an enemy's, to find kill // cells against my own base) from the same function. Designed by Seer // after diagnosing that Arcadia's actual games are won by cut-bonus- // funded wall construction that no bot participates in or defends // against at all -- see docs/memories/project/bot-strategies.md's "Two // regimes" section. Verified against a hand-replayed seed2800878872 // (docs/games/2026-09-09T02-53-30-seed2800878872.md) before landing; // see that verification for the exact acceptance checks. Deliberately // NOT a border-reachability test -- see this section's own header // comment for why that would score Arcadia's actual winning walls at // zero. The first acceptance run against seed2800878872 T17 failed: // `(15,7)`, the actual one-cell kill move against Tentacles, was // candidate #28 of 30 unique sea-adjacent cells, past the ~16 cap -- // the candidate-gathering loop traversed `blockers` in placement // order, and Arcadia's oldest stones (opening turn, home base) simply // aren't near an unfinished wall's frontier. Fixed by traversing // `blockers` newest-first (see seaCuts' own comment); re-run passed // with `(15,7)` as candidate #1. // - 2026-09-10 (same pass, before wiring): Seer's review pushed back on // the newest-first fix above -- it's a good prior, not a sound one (a // wall closed from both ends toward a middle gap has old stones on // both sides), so the cap could still silently drop a decisive // candidate on a different board, indistinguishable from "no seal // available." Replaced with `mightSplitSea`, a sound O(1) prefilter // derived from the sea's 4-connected graph (a single stone can only // convert territory at a cut vertex of the sea -- see // docs/memories/project/capture-rules.md's "Derived property" note); // newest-first demoted to a tie-break among survivors, and the cap // raised to a pathological-board backstop rather than a routine limit. // Also fixed `victims` to exclude home bases (game.pendingCaptures // never lets one be captured), which turned a plausibility check into // an exact match: 16 before the fix, 15 after -- exactly // seed2800878872's transcript-recorded "territory 15." // - 2026-09-12: widened `seaCuts`' candidate loop from `ORTHOGONAL` to // `DIRECTIONS` (see that section's own dated entry -- a diagonal // closing cell was never even generated as a candidate), and added // `exposureAfter` and the whole "Cut planning" section // (`CUT_WEIGHT`/`SIEGE_RADIUS`/`planCuts`/`CutPlan`) -- new substrate // for `cuts.ts` alone, following Analyst's review of four of Arcadia's // 1v1 games in which Cuts made zero cuts across all four despite one // being available every time. See each new export's own comment for // the full rationale; `cuts.ts`'s revision log records how they're // wired in. // // KNOWN LIMITATION, accepted rather than fixed: `seaCuts` can FINISH a // wall but never PLAN one -- it's one-ply, so a multi-stone gap (like // the actual seed2800878872 T29 turn, which closed a 3-cell gap in // sequence within one turn) scores 0 on every stone but the last, same // blindness cuts.ts already documents for its own lookahead. Accepted // because the composition covers it: each strategy's own idiom // (Tentacles' arms, Cuts' lines, Circles' clusters) does the // multi-turn building, and seaCuts only needs to recognise the moment // a wall is one stone from done. // - 2026-09-12: `seaCuts`' candidate loop collected only ORTHOGONAL // neighbours of a wall's blocking stones, so a closing cell that // touches the wall exclusively on a diagonal was never even generated // as a candidate -- `mightSplitSea`'s prefilter (which correctly // considers all 8 neighbours) never got a chance to run on it at all. // Found via Analyst's review of four of Arcadia's 1v1 games against // Cuts (`docs/games/2026-09-12T14-0{3,7,8}*`): three of the four ended // on exactly this shape -- `survivalMove` correctly plugged the // orthogonal gap cell (`(15,11)` / `(14,7)` / `(5,15)`), and Arcadia // closed the kill on the diagonal cell right next to it the following // turn (`(15,12)` / `(15,7)` / `(6,15)`), which no bot ever saw // coming. Fixed by widening the candidate loop from `ORTHOGONAL` to // `DIRECTIONS` -- sound, not just a heuristic improvement: a cell with // no blocker among ANY of its 8 neighbours can never pass // `mightSplitSea` (0 or 1 in-sea orthogonal neighbours trivially can't // split anything), so the existing prefilter still keeps the // candidate-gathering cost cheap; this only adds candidates the // prefilter can actually act on. Affects every strategy that calls // `survivalMove`/`sealMove` (all three, as of Pass 2), not Cuts alone. // - 2026-09-12 (same day, at Arcadia's request): widened the "Cut // planning" section into "Capture planning" -- `planCuts`/`CutPlan` // replaced by a four-layer split (`cutPlans`/`ringPlans` as generic, // strategy-free finders; `validatePlan` as the one arbiter over both // rules at once; `planCaptures` as the strategy-supplied ranker/ // validator). Added `ringPlans` (new), extracted `scanRing` out of // `offenceScores` so both share one copy of ring-walk geometry, and // added `withComboVictims` so a plan that closes a ring in addition to // its own rule (the shape Arcadia praised in `seed2328586520` T18, see // docs/memories/project/bot-strategies.md) ranks as such BEFORE // validation narrows the field. Only `cuts.ts` consumes this so far; // `circles.ts`/`tentacles.ts` are untouched. Full design and // acceptance evidence: docs/tasks/2026-09-12/21-cuts-ring-planning.md. // - 2026-09-12 (same day, Analyst's re-review of the pass above): found // `validatePlan` diffing cut and ring victims against SEPARATE // per-rule baselines, which let a stone already doomed by a pending // cut get "recaptured" by a ring plan around it and counted as a // second, phantom victim -- confirmed in exactly this shape at // `seed2328586520` T18, where the bonus placement re-ringed the same // stone its own cut had just doomed for ~100 phantom points. Fixed: // `validatePlan` takes an optional shared `baseline` (defaulting to // `game.pendingCaptures(state)`, which is already deduped, already // excludes home bases, and already covers territory too -- so any // FUTURE finder, territory included, is covered by the same fix with // no further change here), and `planCaptures` computes that baseline // ONCE per decision and threads it through every candidate's // validation AND through `withComboVictims`' own estimate (which // otherwise inflated the same way, pre-validation). General rule, not // specific to cuts/rings: measuring what a hypothetical placement // NEWLY captures always needs the baseline to be the union of // EVERYTHING already doomed this turn, never a per-rule baseline. // - 2026-09-12 (same day, Circles enclosure planning -- Analyst's design, // see docs/tasks/2026-09-12/22-circles-enclosure-planner.md for the // full brief and acceptance evidence): widened "Capture planning" into // a third rule, `"territory"`, so Circles can plan and seek LARGE // deliberate enclosures instead of only ever reacting to a small ring // an enemy stumbles into (`docs/memories/project/bot-strategies.md`'s // "bot identities" note -- Circles should reach rough parity with Cuts // as the bot that most aggressively encircles). New substrate, all // strategy-free per Arcadia's standing "shared capture finders" rule: // - `enclosurePlans`/`oneStoneTerritoryPlans` (LAYER 1): propose // disc-shaped wall candidates around enemy targets (grown to swallow // any enemy stone sitting on the wall's own boundary, since an enemy // stone never blocks the active player's OWN sea) plus `seaCuts`' // existing one-ply closing moves, folded in as exact 1-cell territory // plans. // - `validatePlan` now dispatches on `plan.rule`: a territory plan is // validated EXACTLY via `game.seaCells` (`validateTerritoryPlan`, // mirroring `seaCuts`' own diff, widened to a whole wall and to two // turn-groups for a plan spanning into a future turn), never via // `game.place` chaining and never via `game.pendingTerritories` // per-candidate (both would be wrong or far too expensive -- see // that function's own comment). // - `asPlayer` (a pure, finder-only perspective swap -- NEVER pass its // result to a mutating rule function, see its own comment) lets // `interruptions` run `cutPlans`/`ringPlans` from an ENEMY's own // side, answering "can my shape be interrupted next turn" with the // exact same finders used everywhere else, no separate threat // geometry. // - `territoryRisk` is the reseal-livelock guard: Analyst's review of // `seed3927004296`/`seed1443119786` found Circles repeatedly // ring/cutting an enemy's own wall stone from WITHIN that enemy's // existing territory, net-losing every round when the enemy simply // refilled the wall and recaptured Circles' own stones caught inside // the resealed pocket -- see that function's own comment and // `docs/memories/project/bot-strategies.md`'s note. // - `planCaptures` gained `horizon` (a plan may need placements beyond // this decision's own budget, spilling into a future turn -- // `RankedPlan.turnsNeeded` reports how many) and, gated behind // `rules.includes("territory")` so cuts.ts pays nothing extra, // `interruptions`/`territoryRisk` counts per validated plan. Also // carries the parked task-21 fix in this same pass, at Arcadia's // explicit approval: a raw plan's `estimatedVictims` is stripped of // anything already in `doomed` BEFORE ranking, not just before // validating, so a plan left with nothing real never spends a // `validateCount` slot for nothing -- see `planCaptures`' own // comment. // - `threateningPlayers`/`siegeBreaker` lifted out of cuts.ts (private // there) into shared substrate, widened to check territory victims // too, so circles.ts can use the identical "break the siege, don't // just plug it" idiom. // `cutPlans`/`ringPlans`/`validatePlan`'s cut-and-ring path are // BYTE-FOR-BYTE unchanged in behaviour for `cuts.ts` -- see cuts.ts's // own revision log for its one-line update (calling the lifted // `lib.siegeBreaker` instead of a private copy). // - 2026-09-12 (task 23, docs/tasks/2026-09-12/23-circles-siege-defence- // and-offence.md): task 22's own acceptance testing found Circles // still only ever detects a ONE-STONE kill against its own base // (`survivalMove`, a one-ply lookahead) -- Arcadia's actual winning // walls are typically exactly her own `placementsPerTurn` (3) stones // from closing, which looks like an ordinary board right up until the // turn it fires. Confirmed at `seed660896122`'s real T7: Circles chose // a 2-turn enclosure over a same-turn double ring that would have left // her needing 5 stones (over budget) to close, but the 2-turn plan // left her needing only 3 -- and playing her REAL historical T8 (not // skipping it, as task 22's own test 5 had) shows the 2-turn plan // loses the game. New substrate, again strategy-free: // - `siegeCost(state, defender, attacker, cap)`: the minimum EMPTY // cells `attacker` must still fill to wall `defender`'s base off, // a capped min-cut/max-flow over `attacker`'s own sea (vertex // capacity 1 for empty, infinite for anyone else's stone -- see that // function's own comment for the full graph shape and why the // heuristic sink placement is always confirmed by an exact // `game.seaCells` replay, never trusted blind). // - `siegeThreats`/`withOwnPlacements`/`siegeDefence`: the generic // "am I under a real siege, and if so defuse or plug it" idiom (see // `siegeDefence`'s own comment for the exact defuse-then-plug // contract) -- built for Circles but placed here, not in // `circles.ts`, so Tentacles (task 24) can reuse it unchanged. // - `siegePlans`: the offensive mirror -- `siegeCost` run the other way // (attacker = the active player) turns a confirmed cut against an // enemy's own base into an ordinary territory `CapturePlan`, folded // into `planCaptures`' existing `"territory"` gathering alongside // `enclosurePlans`. `RankedPlan` gained a `cells` field (every cell a // plan still needs, not just its next `position`) so a caller can // build the "whole plan complete" board `withOwnPlacements`/ // `siegeDefence` need without re-deriving a plan's own geometry. // `cutPlans`/`ringPlans`/`enclosurePlans`'s own candidate geometry is // unchanged; `cuts.ts` untouched this round (task 23 was Circles-only, // same as task 22). import * as game from "../game.js"; export type Offset = { row: number; col: number }; export const ORTHOGONAL: readonly Offset[] = [ { row: -1, col: 0 }, { row: 1, col: 0 }, { row: 0, col: -1 }, { row: 0, col: 1 }, ]; export const DIAGONAL: readonly Offset[] = [ { row: -1, col: -1 }, { row: -1, col: 1 }, { row: 1, col: -1 }, { row: 1, col: 1 }, ]; export const DIRECTIONS: readonly Offset[] = [...ORTHOGONAL, ...DIAGONAL]; export const shift = (position: game.Position, offset: Offset, steps: number = 1): game.Position => ({ row: position.row + offset.row * steps, col: position.col + offset.col * steps, }); export const inBounds = (size: game.BoardSize, position: game.Position): boolean => position.row >= 0 && position.row < size.rows && position.col >= 0 && position.col < size.cols; export const chebyshev = (a: game.Position, b: game.Position): number => Math.max(Math.abs(a.row - b.row), Math.abs(a.col - b.col)); export const key = (position: game.Position): string => `${position.row},${position.col}`; export type Occupancy = ReadonlyMap; // Built once per call and handed to every lookup after -- the fix for the // cost trap recorded in docs/memories/project/separation-of-concerns.md: // game.pieceAt is a linear scan, so re-deriving occupancy (or worse, // calling game.pendingEncirclements / game.pendingTerritories) inside a // per-candidate loop is the one way a bot could reintroduce the turn // latency that killed the LLM agent player. See docs/bots.md. export const occupancy = (state: game.BoardState): Occupancy => { const map = new Map(); for (const piece of state.pieces) { map.set(key(piece.position), piece); } return map; }; export const at = (occ: Occupancy, position: game.Position): game.Piece | undefined => occ.get(key(position)); export const hasOwnNeighbour = ( state: game.BoardState, occ: Occupancy, position: game.Position, ): boolean => DIRECTIONS.some((offset) => { const neighbour = at(occ, shift(position, offset)); return neighbour !== undefined && neighbour.player === state.activePlayer; }); export const hasEnemyNeighbour = ( state: game.BoardState, occ: Occupancy, position: game.Position, ): boolean => DIRECTIONS.some((offset) => { const neighbour = at(occ, shift(position, offset)); return neighbour !== undefined && neighbour.player !== state.activePlayer; }); export const ownBase = (state: game.BoardState, player: number): game.Piece | undefined => state.pieces.find((piece) => piece.isHomeBase && piece.player === player); // --- Ring pressure --------------------------------------------------------- // // Shared substrate for every strategy's self-preservation: how much a cell // is worth OFFENSIVELY (it completes/advances a ring around an enemy), // DEFENSIVELY (it denies/delays a ring an enemy is building around one of // my own stones), and how EXPOSED my own stone would be if I placed it // there (how close that placement is to arming an enemy's ring around // itself). All three are derived from local occupancy-map lookups only -- // never game.pendingEncirclements, per docs/bots.md's cost rules. export type CellPressure = { readonly offence: number; // enemy stones I'd take by completing my ring here readonly defence: number; // own stones I'd save by denying an enemy's ring here readonly exposure: number; // how easily a stone of MINE placed here gets ringed }; // At this weight, a cell that completes a ring (offence 100) still beats // walking into a one-gap enemy trap (exposure 3, penalty 90); a one-point // crumb (offence 1) does not. See docs/memories/project/bot-strategies.md // for the cost baseline this was tuned against. export const EXPOSURE_PENALTY = 30; const RING_PATTERNS: readonly (readonly Offset[])[] = [ORTHOGONAL, DIAGONAL]; const addScore = (scores: Map, position: game.Position, amount: number): void => { const scoreKey = key(position); scores.set(scoreKey, (scores.get(scoreKey) ?? 0) + amount); }; // 3 remaining gaps down to 1: the closer a ring is to complete, the more // it's worth. Shared by offence and defence, which are otherwise mirrors // of each other over different target sets and different required owners. const weightFor = (present: number): number => present === 3 ? 100 : present === 2 ? 10 : present === 1 ? 1 : 0; // A ring pattern's cells around `center`, classified against `occ` from // the active player's own point of view: which are empty, how many are // already the active player's own (a stone OR their own home base -- // `occupant.player === state.activePlayer` doesn't distinguish the two, // which is exactly right here: a base is a legal ring member, just never // a capturable target), and whether any OTHER player's stone sits in the // ring at all (which kills the pattern outright -- a ring holding a THIRD // player's stone, or an enemy stone where an active-player stone would be // needed, can never complete). Returns null if the pattern runs off-board // -- off-board is never a surrounder, capture-rules.md's own invariant. // Shared by `offenceScores` below and `lib.ringPlans` (Capture planning // section, further down this file) so the two don't maintain separate // copies of this geometry -- extracted 2026-09-12 at Arcadia's explicit // request that rule-geometry recognition live in exactly one place in // lib.ts, not be re-derived per strategy or per caller. See // docs/memories/project/bot-strategies.md's "shared capture finders" note. type RingScan = { readonly ring: readonly game.Position[]; readonly empty: readonly game.Position[]; readonly ownCount: number; readonly blocked: boolean; }; const scanRing = ( state: game.BoardState, occ: Occupancy, center: game.Position, pattern: readonly Offset[], ): RingScan | null => { const ring = pattern.map((offset) => shift(center, offset)); if (!ring.every((cell) => inBounds(state.size, cell))) { return null; } const empty: game.Position[] = []; let ownCount = 0; let blocked = false; for (const cell of ring) { const occupant = at(occ, cell); if (occupant === undefined) { empty.push(cell); } else if (occupant.player === state.activePlayer) { ownCount += 1; } else { blocked = true; } } return { ring, empty, ownCount, blocked }; }; // For each enemy non-base piece, score the empty ring cells that would // complete an orthogonal/diagonal 4-cell ring around it if the active // player filled them. `scanRing`'s own `blocked` flag already IS "a ring // holding a THIRD player's stone (or any non-active-player stone) can // never complete" -- every ring cell is classified as exactly one of // empty/own/blocked, so `ownCount + empty.length === ring.length` // whenever `blocked` is false, with no separate sum check needed. Lifted // from circles.ts's original scoreMoves, which lacked this check // entirely; bug found by Seer's review 2026-09-10. const offenceScores = (state: game.BoardState, occ: Occupancy): Map => { const scores = new Map(); for (const piece of state.pieces) { if (piece.player === state.activePlayer || piece.isHomeBase) { continue; } for (const pattern of RING_PATTERNS) { const scan = scanRing(state, occ, piece.position, pattern); if (!scan || scan.blocked) { continue; } const weight = weightFor(scan.ownCount); if (weight === 0) { continue; } if (scan.ownCount === 3) { addScore(scores, scan.empty[0], weight); } else { scan.empty.forEach((cell) => addScore(scores, cell, weight)); } } } return scores; }; // The mirror of offenceScores over the active player's own non-base // stones -- game.pendingCaptures never lets a home base be captured, so a // base is never worth defending. Requires every occupied ring cell to // belong to ONE single enemy; a ring cell held by the active player's own // stone, or by two different enemies, can never complete and must not // score. const defenceScores = (state: game.BoardState, occ: Occupancy): Map => { const scores = new Map(); for (const piece of state.pieces) { if (piece.player !== state.activePlayer || piece.isHomeBase) { continue; } for (const pattern of RING_PATTERNS) { const ring = pattern.map((offset) => shift(piece.position, offset)); if (!ring.every((cell) => inBounds(state.size, cell))) { continue; } let owner: number | null = null; let present = 0; const empty: game.Position[] = []; let blocked = false; ring.forEach((cell) => { const occupant = at(occ, cell); if (occupant === undefined) { empty.push(cell); return; } if (occupant.player === state.activePlayer) { blocked = true; return; } if (owner === null) { owner = occupant.player; } else if (owner !== occupant.player) { blocked = true; return; } present += 1; }); if (blocked || owner === null || present + empty.length !== ring.length) { continue; } const weight = weightFor(present); if (weight === 0) { continue; } if (present === 3) { addScore(scores, empty[0], weight); } else { empty.forEach((cell) => addScore(scores, cell, weight)); } } } return scores; }; // How exposed `position` would be if the active player placed a stone // there right now: for each pattern and each enemy, k is the number of // currently-empty cells in the 4-cell ring around `position`. // // - k === 0: the ring already stands but is INERT -- the enemy has // nowhere fresh to place, so game.ts's isCapturingRing (which requires // a ring member placed THIS turn) can never fire. Safe. This is // capture-rules.md's "placing into an existing enemy ring is safe." // - k in [1, 3]: the enemy can fill the remaining gaps next turn, within // their own 3-placement budget, and the filler is fresh -- the capture // fires. Dangerous, worth 4 - k (3 down to 1). // - k === 4, or any ring cell off-board / the active player's own / a // third player's: that pattern is dead. Corollary: border and corner // cells are ring-immune, since at least one pattern always runs off // the board there. // // This is NOT monotonic in k -- re-derive against game.ts's // isCapturingRing (game.ts:548) before changing it. // // Known false positive, deliberately left uncorrected: this treats every // non-active player's standing stones as a live threat, including an // eliminated player's -- an eliminated player never gets a turn, so their // ring can never actually fire. Not worth fixing here: elimination is // reversible (game.ts's territory STATE, not an event), so a captured // stone remaining "dangerous" in this heuristic is a defensible caution // rather than a bug, and correcting it would need a game.gameStatus call // (a per-player flood fill) inside what's otherwise a purely local, // per-candidate-cheap function. Flagged by Seer's review 2026-09-10 so a // future reader doesn't "discover" and silently fix this at greater cost // than it's worth. const exposureAt = (state: game.BoardState, occ: Occupancy, position: game.Position): number => { let worst = 0; for (const pattern of RING_PATTERNS) { const ring = pattern.map((offset) => shift(position, offset)); if (!ring.every((cell) => inBounds(state.size, cell))) { continue; } for (let enemy = 0; enemy < state.players.length; enemy++) { if (enemy === state.activePlayer) { continue; } let blocked = false; let emptyCount = 0; for (const cell of ring) { const occupant = at(occ, cell); if (occupant === undefined) { emptyCount += 1; } else if (occupant.player !== enemy) { blocked = true; break; } } if (blocked || emptyCount < 1 || emptyCount > 3) { continue; } worst = Math.max(worst, 4 - emptyCount); } } return worst; }; // `exposureAt`, but against a hypothetical board with `removed` pieces // deleted from occupancy first. For a move whose cut fires THIS turn, the // victims are gone from the board by the time any enemy could ever // complete a ring through them -- captures resolve at end of turn, but so // does an enemy's own next placement, so by the time they could act the // cut's victims are already off the board. Scoring exposure against the // PRE-cut occupancy overstates it: a cut's own cells almost always sit // directly next to the stones they just captured, which `exposureAt` // counts as live enemy ring-mates even though they'll never be there to // complete anything. Cloning `occ` costs O(pieces), so -- same rule as // everywhere else in this file -- call this only for the handful of // gain>0 / plan moves a turn actually produces, never per candidate (see // docs/bots.md's performance rules). export const exposureAfter = ( state: game.BoardState, occ: Occupancy, position: game.Position, removed: ReadonlySet, ): number => { if (removed.size === 0) { return exposureAt(state, occ, position); } const filtered = new Map(occ); for (const removedKey of removed) { filtered.delete(removedKey); } return exposureAt(state, filtered, position); }; export const ringPressure = ( state: game.BoardState, occ: Occupancy, cells: readonly game.Position[], ): ReadonlyMap => { const offence = offenceScores(state, occ); const defence = defenceScores(state, occ); const pressure = new Map(); for (const cell of cells) { const cellKey = key(cell); pressure.set(cellKey, { offence: offence.get(cellKey) ?? 0, defence: defence.get(cellKey) ?? 0, exposure: exposureAt(state, occ, cell), }); } return pressure; }; // --- Sea cuts --------------------------------------------------------------- // // A one-ply, EXACT lookahead over game.seaCells -- no separate rules, no // approximation. Deliberately not a border-reachability test: a wall // pinned against the board's rim still leaves the region it encloses // touching that rim, and `refineSea`'s "largest rim-touching child stays // sea" rule is what actually surrenders it -- a naive "does this region // still reach an edge" check scores that wall at zero. See // docs/memories/project/bot-strategies.md and // docs/memories/project/home-bases-and-victory.md's "Testing it cheaply" // note, both from the same design pass. export type SeaCut = { readonly position: game.Position; readonly surrendered: number; // sea cells that become territory readonly victims: number; // other players' stones inside readonly eliminates: readonly number[]; // players whose base falls inside }; // A backstop cap on candidates that survive `mightSplitSea` below, not a // routine filter -- correctness comes from that filter being sound (no // false negatives), not from this number. Raised well past the // once-per-nextPlacement cost budget's nominal ~16 (see the cost-baseline // memory above) specifically so it's pathological-board insurance rather // than load-bearing for any real board. Every surviving candidate still // costs one game.seaCells replay. const SEA_CUT_CANDIDATE_CAP = 64; // The four orthogonal neighbours of a cell, paired with the diagonal // "joint" that connects each to the NEXT one in this cyclic order (N to E // via NE, E to S via SE, S to W via SW, W to N via NW) -- the two cells a // path from one orthogonal neighbour to the next can route through // without leaving `c`'s own 8-neighbourhood. Used by `mightSplitSea` // only, which reasons about the sea's 4-connected graph; `seaCuts`' own // candidate loop below iterates all 8 `DIRECTIONS`, not just this ring's // 4 orthogonal spokes -- see that loop's own comment. const CUT_VERTEX_RING: readonly { orth: Offset; joint: Offset }[] = [ { orth: { row: -1, col: 0 }, joint: { row: -1, col: 1 } }, // N -> (NE) -> E { orth: { row: 0, col: 1 }, joint: { row: 1, col: 1 } }, // E -> (SE) -> S { orth: { row: 1, col: 0 }, joint: { row: 1, col: -1 } }, // S -> (SW) -> W { orth: { row: 0, col: -1 }, joint: { row: -1, col: -1 } }, // W -> (NW) -> N ]; // A sound (no false negatives), O(1) prefilter for "can placing a stone at // `c` possibly split the sea," avoiding a full game.seaCells replay for // candidates that provably cannot. If every one of `c`'s in-sea orthogonal // neighbours is connected to every other through `c`'s own 8-neighbourhood // (i.e. without passing through `c` itself), removing `c` cannot disconnect // them, so `c` is not a cut vertex of the sea's 4-connected graph and // cannot be a closing move. 0 or 1 in-sea orthogonal neighbours trivially // can't split anything either. False POSITIVES are fine and expected -- // those still go through the real `seaCells` replay below, which is the // actual source of truth; this only ever skips candidates that cannot // possibly matter. Deliberately not a substitute for newest-first // ordering (see the candidate loop below) -- that ordering is a good // prior but not sound on its own (a wall closed from both ends toward a // middle gap has old stones on both sides of the gap), so this filter is // what makes the cap safe to rely on rather than merely usually-right. // Found necessary by Seer's review 2026-09-10 after the cap-ordering fix // alone passed the seed2800878872 acceptance test without being provably // correct in general. const mightSplitSea = (sea: ReadonlySet, c: game.Position): boolean => { const present = CUT_VERTEX_RING.map(({ orth }) => sea.has(key(shift(c, orth)))); if (present.filter(Boolean).length <= 1) { return false; } const parent = [0, 1, 2, 3]; const find = (i: number): number => { while (parent[i] !== i) { i = parent[i]; } return i; }; for (let i = 0; i < 4; i++) { const j = (i + 1) % 4; if (present[i] && present[j] && sea.has(key(shift(c, CUT_VERTEX_RING[i].joint)))) { const ri = find(i); const rj = find(j); if (ri !== rj) { parent[ri] = rj; } } } const roots = new Set(present.map((isPresent, i) => (isPresent ? find(i) : null)).filter((r) => r !== null)); return roots.size > 1; }; // `player`'s live pieces with overlays dropped -- mirrors game.ts's // private `isOverlay` filter (pieceAt always resolves to the earliest, // non-overlay piece at a cell) without needing game.ts to export // isOverlay itself: a piece IS the overlay at its own cell exactly when // pieceAt reports a DIFFERENT piece there. const ownLiveBlockers = (state: game.BoardState, player: number): game.Piece[] => state.pieces.filter( (piece) => piece.player === player && game.pieceAt(state, piece.position) === piece, ); // One `seaCells` replay for `player`'s own sea, then one more per // candidate closing cell -- the candidate's own turn stamp matters and is // NOT interchangeable between the offensive and defensive use of this // function: // // - Offensive (`player === state.activePlayer`): the synthetic stone // carries `state.turn`, the same stamp as any other stone `player` // places this turn, so it joins today's turn group exactly as a real // placement would. // - Defensive (`player !== state.activePlayer`, i.e. probing an enemy): // `player` has no pieces stamped `state.turn` (they're not the one // moving), so the synthetic stone forms its own trailing turn group -- // exactly "one more turn of walling" from `player`, which is the // question being asked ("if THEY get to place here, do I lose my // base"). // // `refineSea` only refines a turn group whose stones sit in the CURRENT // sea (game.ts's own performance guard) -- true here by construction, // since every candidate is drawn from `sea` itself. Widening candidate // selection beyond sea-adjacency would need to account for that. export const seaCuts = (state: game.BoardState, player: number): SeaCut[] => { const blockers = ownLiveBlockers(state, player); const sea = game.seaCells(state.size, blockers); if (sea.size === 0) { return []; } // Newest stones first, as a TIE-BREAK ordering only: `blockers` is in // placement order (state.pieces' append order), and a wall is usually // built incrementally outward from whatever already stands, so recently // placed stones are more often near an unfinished frontier. This is a // good prior, not a sound filter on its own -- a wall closed from both // ends toward a middle gap has old stones flanking the gap on both // sides, and no ordering fixes that. Correctness comes from // `mightSplitSea` below, not from this order; this only decides which // survivors the (deliberately generous) SEA_CUT_CANDIDATE_CAP keeps if // it's ever actually reached. Found by Seer's review 2026-09-10. // All 8 neighbours, not just the 4 orthogonal ones -- a closing cell // that only touches a wall diagonally is still a real candidate (see // this section's 2026-09-12 revision log entry). Safe to widen: a cell // with no blocker among any of its 8 neighbours can never pass // `mightSplitSea` below regardless, so this only adds candidates the // sound prefilter can actually act on, not ones that bypass it. const candidates: game.Position[] = []; const seenCandidates = new Set(); for (const piece of [...blockers].reverse()) { for (const offset of DIRECTIONS) { const neighbour = shift(piece.position, offset); const neighbourKey = key(neighbour); if (seenCandidates.has(neighbourKey) || !sea.has(neighbourKey)) { continue; } seenCandidates.add(neighbourKey); candidates.push(neighbour); } } // Sound prefilter first (see mightSplitSea -- no false negatives), THEN // the cap. This is what makes the cap a backstop rather than // load-bearing for correctness: every candidate this drops is one that // provably cannot convert any territory, not merely one that's unlikely // to. const splitters = candidates.filter((c) => mightSplitSea(sea, c)); return splitters.slice(0, SEA_CUT_CANDIDATE_CAP).map((position) => { const synthetic: game.Piece = { position, player, turn: state.turn, isHomeBase: false }; const nextSea = game.seaCells(state.size, [...blockers, synthetic]); // Everything that left the sea, minus the candidate's own cell (which // leaves the sea too, as a now-blocked stone, not as surrendered // territory). `nextSea` is always a subset of `sea` here -- adding a // blocker can only shrink a later flood-filled child, never grow it -- // so this diff is exact, not a heuristic. const surrendered = new Set(); for (const cellKey of sea) { if (cellKey !== key(position) && !nextSea.has(cellKey)) { surrendered.add(cellKey); } } // Home bases excluded: game.pendingCaptures never lets a base be // captured (it's filtered out of the union of all three rules), so a // base sitting inside newly-surrendered territory is never actually // taken -- counting it here would overstate the capture by exactly // one. Confirmed against seed2800878872: victims read 16 before this // fix (Tentacles' base was inside the sealed region) against a // transcript-recorded "territory 15" -- 16 minus that one base is // exactly 15. Found by Seer's review 2026-09-10. const victims = state.pieces.filter( (piece) => piece.player !== player && !piece.isHomeBase && surrendered.has(key(piece.position)), ).length; const eliminates: number[] = []; for (let other = 0; other < state.players.length; other++) { if (other === player) { continue; } const base = ownBase(state, other); if (base && surrendered.has(key(base.position))) { eliminates.push(other); } } return { position, surrendered: surrendered.size, victims, eliminates }; }); }; // --- Siege cost ------------------------------------------------------------ // // Added 2026-09-12, task 23 (docs/tasks/2026-09-12/23-circles-siege-defence- // and-offence.md), after task 22's own acceptance testing found a real // elimination `lib.survivalMove`/`lib.seaCuts` cannot see coming at all: // both are a ONE-PLY lookahead ("is there a single cell that finishes the // wall right now"), so a wall sitting exactly `placementsPerTurn` (3) // stones from closing -- Arcadia's actual, most common winning shape -- // looks completely ordinary right up until the turn it fires. `seaCuts` // answers "can this be finished in one placement"; this answers the // question underneath that: "how many placements does it take, exactly," // which a one-ply check has no way to express. // // `siegeCost(state, defender, attacker)` is the minimum number of EMPTY // cells `attacker` must still fill to wall `defender`'s own base off from // the rest of `attacker`'s own sea -- a min vertex-cut / max-flow over // `game.seaCells(size, attacker's own live blockers)`: // // - Nodes: every cell in `attacker`'s current sea (this necessarily // includes `defender`'s base cell, exactly when `defender` is NOT // already eliminated by `attacker` -- see `game.ts`'s own // `isEliminatedWithTerritories`, the same fact stated as a flow-graph // membership check instead of a set-membership one). // - Vertex capacity: 1 for a cell nobody occupies (the attacker could // place a wall stone there, at the cost of exactly one placement); ∞ // for a cell someone ELSE already occupies (the attacker can never // place there, so it costs them nothing AND can never be part of a // cut -- see `game.place`'s own single-occupant rule). Every occupied // cell inside `attacker`'s sea is guaranteed to belong to someone other // than `attacker` by construction (`sea` already excludes every cell // `attacker`'s own blockers cover), so this needs no extra check. // Looks through `game.pieceAt` (earliest piece, i.e. a doomed victim // under an overlay), not `lib.occupancy`'s latest-wins convention -- // "ignore overlays" per this function's own design brief, since an // overlay is `attacker`'s own fresh stone sitting on someone else's // still-standing (this-turn) piece, and what matters here is what's // actually blocking the cell for placement purposes. // - Source: `defender`'s base cell. // - Sink: every sea cell at Chebyshev distance >= a radius, joined to one // virtual terminal node -- see the MULTI-RADIUS SWEEP note below for // why this is no longer a single fixed knob. // - Max-flow (Edmonds-Karp, vertex-split so a node's own capacity is a // real edge in the flow graph, not just an annotation), capped: stops // augmenting the instant flow exceeds `cap` (the caller's own budget -- // `placementsPerTurn`, or a wider horizon), since every finite capacity // here is exactly 1 and the caller only ever wants to know "is this // `<= cap` or not," not the exact cost of an unaffordable siege. Cheap // regardless of board size: the graph is bounded by sea size (<= a few // hundred cells), and Edmonds-Karp's cost scales with the FLOW value // found, not the graph size, when the flow itself is capped this small. // // The sink is a HEURISTIC placement, not a rule -- sticky sea's own "the // largest RIM-touching child stays sea, ties all stay sea" tie-break // (capture-rules.md) is a RELATIVE comparison across however many pieces // a split produces, not "disconnected from every sufficiently-distant // cell," so ANY single fixed radius can disagree with it on a specific // board. Never trusted blind: whenever a radius's own flow (and // therefore its candidate min cut) comes out `<= cap`, it is CONFIRMED by // an exact `game.seaCells` replay -- the cut cells added as `attacker`'s // own stones, stamped with `attacker`'s NEXT turn (mirroring // `validateTerritoryPlan`'s own two-turn-group convention), checking that // `defender`'s base actually leaves the resulting sea. // // MULTI-RADIUS SWEEP (added 2026-09-12, same day, after task 23's own // acceptance found a single fixed `SIEGE_FAR` (6) gives a false NEGATIVE // on its own canonical board: Arcadia's real 3-cell closing wall seals a // pocket whose farthest corner sits at EXACTLY Chebyshev 6 from the // defender's base, so at radius 6 part of the TRUE surrendered pocket // itself still counts as "open" sink, forcing the search to wall those // cells off too -- reported cost 4, not the true 3. No single radius is // safe in general: sweeping 1..10 on that same board found only radius 7+ // recovers the true cost (see `docs/memories/project/bot-strategies.md`'s // own note for the full sweep). `siegeCost` below now tries every radius // from 3 up to the farthest sea cell from the base (a few dozen small // flows at most, cheap per the cost note above) and returns the MINIMUM // CONFIRMED cost across all of them -- widening the search this way can // only ever REMOVE a false negative, never introduce a false positive, // since every candidate a wider radius finds is still independently // confirmed by the same exact replay as before. `confirmed` is honestly // `false`, with `cost` reporting the best (lowest) unconfirmed figure any // radius found, on the rare occasion NO radius's cut survives // confirmation -- report it, don't discard it silently, and never treat // `confirmed: false` as a proven threat regardless of `cost` (see // `siegeThreats`/`siegePlans`'s own gates). A single fixed radius is kept // available via the optional `farOverride` parameter, for a test that // wants to pin one specific radius rather than sweep. No longer read by // `siegeCost` itself (the sweep tries every radius from 3 up), kept // exported as the pre-sweep default -- a natural `farOverride` value for // a test that wants the OLD single-radius behaviour specifically. export const SIEGE_FAR = 6; export type SiegeCost = { // Capped at `cap + 1` when `confirmed` -- once every radius's flow // either exceeds `cap` or fails confirmation, this stops trying to // find the EXACT cost, since the caller only asked "is this within // reach." When `!confirmed`, this is instead the LOWEST raw (possibly // unconfirmed) figure any radius in the sweep found -- informative // ("how close did the search get"), never a proven figure either way. readonly cost: number; // The empty cells forming the minimum CONFIRMED cut, only when // `confirmed` -- empty otherwise (there's nothing proven to report). readonly cut: readonly game.Position[]; // True only when some radius's cut is CONFIRMED by a real // `game.seaCells` replay to cost `<= cap`; trivially true when the // defender is already eliminated (cost 0, no cut needed). False // otherwise -- INCLUDING when every radius's own flow already exceeds // `cap` (this whole sink-based approach is itself a heuristic family; // "every radius we tried says no" is still not the same thing as a // proof no wall within budget exists at all) -- the honest signal that // NO radius the sweep tried produced a cut PROVEN to work on this exact // board within budget. Never treat `cost` as meaningful proof when this // is false, only as a hint of how close the search got. readonly confirmed: boolean; }; // A large-but-finite stand-in for "infinite" vertex capacity -- large // enough that it can never be the constraining edge in a max-flow bounded // by `cap` (a handful, in practice), never large enough to risk overflow // in ordinary IEEE-754 arithmetic. const SIEGE_INFINITE_CAPACITY = 1_000_000; type FlowEdge = { readonly to: number; capacity: number; readonly reverse: number }; // Standard Edmonds-Karp over an explicit edge list (with reverse edges for // residual capacity), vertex-split (`${key}:in` / `${key}:out`) so a // node's own capacity is an ordinary edge rather than special-cased. // Capped: `augment` returns null (no more paths) or the residual graph // itself once `flow > cap`, whichever comes first -- the caller never // needs the exact flow beyond that point, only "is it `<= cap`." // One max-flow run at a SINGLE sink radius (`far`) -- everything // `siegeCost` used to do in one shot before the multi-radius sweep below. // Returns the flow (capped at `cap + 1`) and, when `flow <= cap`, the // min-cut cells; NEVER confirms -- that's the sweep's own job, once per // candidate worth confirming, not once per radius tried. const siegeFlowAt = ( state: game.BoardState, base: game.Piece, baseKey: string, sea: ReadonlySet, cap: number, far: number, ): { readonly cost: number; readonly cut: readonly game.Position[] } => { const nodeOf = new Map(); const nodeId = (nodeKey: string): number => { let id = nodeOf.get(nodeKey); if (id === undefined) { id = nodeOf.size; nodeOf.set(nodeKey, id); } return id; }; const edges: FlowEdge[] = []; const adjacency: number[][] = []; const addEdge = (from: number, to: number, capacity: number): void => { while (adjacency.length <= Math.max(from, to)) { adjacency.push([]); } adjacency[from].push(edges.length); edges.push({ to, capacity, reverse: edges.length + 1 }); adjacency[to].push(edges.length); edges.push({ to: from, capacity: 0, reverse: edges.length - 1 }); }; const sinkNode = nodeId("SINK"); for (const cellKey of sea) { const [rowText, colText] = cellKey.split(","); const position = { row: Number(rowText), col: Number(colText) }; const inNode = nodeId(`${cellKey}:in`); const outNode = nodeId(`${cellKey}:out`); const occupant = game.pieceAt(state, position); addEdge(inNode, outNode, occupant === undefined ? 1 : SIEGE_INFINITE_CAPACITY); for (const offset of ORTHOGONAL) { const neighbour = shift(position, offset); const neighbourKey = key(neighbour); if (!sea.has(neighbourKey)) { continue; } // Both directions -- flow can cross an edge either way; each // direction gets its own forward/reverse pair, matching the // vertex-split convention (out of one cell into the next cell's // in-node). addEdge(outNode, nodeId(`${neighbourKey}:in`), SIEGE_INFINITE_CAPACITY); } if (chebyshev(base.position, position) >= far) { addEdge(outNode, sinkNode, SIEGE_INFINITE_CAPACITY); } } const sourceNode = nodeId(`${baseKey}:in`); // Edmonds-Karp: repeated BFS augmenting paths, capped the instant flow // exceeds `cap` -- the caller never needs the exact value past that // point. All finite capacities are exactly 1, so every augmenting path // found contributes exactly 1 unit of flow. let flow = 0; for (; flow <= cap; flow++) { const parentEdge = new Array(adjacency.length).fill(-1); const visited = new Array(adjacency.length).fill(false); visited[sourceNode] = true; const queue: number[] = [sourceNode]; let head = 0; while (head < queue.length && !visited[sinkNode]) { const current = queue[head++]; for (const edgeIndex of adjacency[current]) { const edge = edges[edgeIndex]; if (edge.capacity > 0 && !visited[edge.to]) { visited[edge.to] = true; parentEdge[edge.to] = edgeIndex; queue.push(edge.to); } } } if (!visited[sinkNode]) { break; // no more augmenting paths -- this IS the max flow } // Push 1 unit back along the found path (every finite capacity is 1, // so no need to compute a bottleneck). let walk = sinkNode; while (walk !== sourceNode) { const edgeIndex = parentEdge[walk]; edges[edgeIndex].capacity -= 1; edges[edges[edgeIndex].reverse].capacity += 1; walk = edges[edges[edgeIndex].reverse].to; } } if (flow > cap) { return { cost: cap + 1, cut: [] }; } // Min cut extraction: nodes reachable from source in the FINAL residual // graph. A cell is in the cut exactly when its `in` node is reachable // but its `out` node is not, AND its own in->out edge has finite // (i.e. == 1, not SIEGE_INFINITE_CAPACITY) original capacity -- an // occupied cell's edge is never saturated (capacity stays huge), so // this can never select one. Menger's theorem: this cut's size equals // `flow`. const reachable = new Array(adjacency.length).fill(false); { reachable[sourceNode] = true; const queue: number[] = [sourceNode]; let head = 0; while (head < queue.length) { const current = queue[head++]; for (const edgeIndex of adjacency[current]) { const edge = edges[edgeIndex]; if (edge.capacity > 0 && !reachable[edge.to]) { reachable[edge.to] = true; queue.push(edge.to); } } } } const cut: game.Position[] = []; for (const cellKey of sea) { const inNode = nodeOf.get(`${cellKey}:in`) as number; const outNode = nodeOf.get(`${cellKey}:out`) as number; if (reachable[inNode] && !reachable[outNode]) { const [rowText, colText] = cellKey.split(","); cut.push({ row: Number(rowText), col: Number(colText) }); } } return { cost: flow, cut }; }; // Added 2026-09-12 (Analyst, same day as the rest of task 23): a SINGLE // `SIEGE_FAR` produced a false NEGATIVE on task 23's own canonical // board (`seed660896122`) -- Arcadia's real 3-cell closing wall seals a // pocket whose farthest corner sits at EXACTLY Chebyshev 6 from Circles' // base, so at `SIEGE_FAR = 6` part of the TRUE pocket itself counts as // "still open" sink, forcing the cut to wall those cells off too (cost // 4). No single fixed radius is safe in general -- swept 1..10 on that // same board and only 7+ found the real cost of 3 (see // docs/memories/project/bot-strategies.md's own note). Fixed by SWEEPING // every radius from 3 up to the farthest sea cell from the base and // taking the MINIMUM CONFIRMED cost across all of them: any run whose // cut is confirmed by a real `game.seaCells` replay is a genuine kill // within budget, so widening the search can only ever remove false // negatives, never introduce a false positive (each candidate is still // independently confirmed exactly as before). `farOverride` keeps the // single-radius path available for a test that wants to pin one // specific radius; every real caller lets this sweep. // Shared prep every sweep-based query below needs once: `defender`'s // base, `attacker`'s current sea, and a `confirm` closure over both. Only // the base-existence/already-eliminated short circuits are baked in here // (both terminal for EVERY caller); a null `base` result folds those two // into the same `{ cost, cut: [] }` shape the flow search itself // produces, so callers don't need a separate branch for them. type SiegePrep = // Shouldn't happen for a live player, but every caller must still // return something sane -- nothing to besiege at all. | { readonly kind: "no-base" } // Already outside attacker's sea -- defender is already eliminated by // attacker (game.ts's own isEliminatedWithTerritories, restated). | { readonly kind: "eliminated" } | { readonly kind: "ready"; readonly base: game.Piece; readonly baseKey: string; readonly sea: ReadonlySet; readonly confirm: (cut: readonly game.Position[]) => boolean; }; const siegePrep = ( state: game.BoardState, defender: number, attacker: number, ): SiegePrep => { const base = ownBase(state, defender); if (!base) { return { kind: "no-base" }; } const attackerBlockers = ownLiveBlockers(state, attacker); const sea = game.seaCells(state.size, attackerBlockers); const baseKey = key(base.position); if (!sea.has(baseKey)) { return { kind: "eliminated" }; } const confirm = (cut: readonly game.Position[]): boolean => { // Confirm via an exact replay: the cut cells as `attacker`'s own // stones, stamped with `attacker`'s NEXT turn (same two-turn-group // convention `validateTerritoryPlan` uses) -- never assume a // candidate radius's own heuristic sink was right about this exact // board. const synthetic: game.Piece[] = cut.map((position) => ({ position, player: attacker, turn: state.turn + state.players.length, isHomeBase: false, })); const afterSea = game.seaCells(state.size, [...attackerBlockers, ...synthetic]); return !afterSea.has(baseKey); }; return { kind: "ready", base, baseKey, sea, confirm }; }; const farthestSeaDistance = (base: game.Piece, sea: ReadonlySet): number => { let maxFar = 0; for (const cellKey of sea) { const [rowText, colText] = cellKey.split(","); const distance = chebyshev(base.position, { row: Number(rowText), col: Number(colText) }); if (distance > maxFar) { maxFar = distance; } } return maxFar; }; export const siegeCost = ( state: game.BoardState, defender: number, attacker: number, cap: number, farOverride?: number, ): SiegeCost => { const prep = siegePrep(state, defender, attacker); if (prep.kind === "no-base") { return { cost: cap + 1, cut: [], confirmed: true }; } if (prep.kind === "eliminated") { return { cost: 0, cut: [], confirmed: true }; } const { base, baseKey, sea, confirm } = prep; if (farOverride !== undefined) { const result = siegeFlowAt(state, base, baseKey, sea, cap, farOverride); if (result.cost > cap) { return { cost: result.cost, cut: [], confirmed: true }; } return { cost: result.cost, cut: result.cut, confirmed: confirm(result.cut) }; } const maxFar = farthestSeaDistance(base, sea); let best: SiegeCost | null = null; let bestUnconfirmedCost = cap + 1; for (let far = 3; far <= Math.max(3, maxFar); far++) { const result = siegeFlowAt(state, base, baseKey, sea, cap, far); if (result.cost > cap) { continue; // nothing to confirm at this radius -- see the loop's own fallback below } bestUnconfirmedCost = Math.min(bestUnconfirmedCost, result.cost); if (best !== null && result.cost >= best.cost) { continue; // already have an equally-or-more confirmed-cheap candidate } if (confirm(result.cut)) { best = { cost: result.cost, cut: result.cut, confirmed: true }; } } // No radius produced a CONFIRMED cut within `cap` -- honest about it: // never report `confirmed: true` without a replay backing it. `cost` // is still the best (lowest) raw figure any radius found, confirmed or // not, so a caller reading `cost` alongside `confirmed: false` sees how // close the search got, not just "false." return best ?? { cost: bestUnconfirmedCost, cut: [], confirmed: false }; }; // Added 2026-09-12 (Analyst, same day as the multi-radius sweep above): // EVERY distinct CONFIRMED cut the sweep finds within `cap`, not just the // cheapest -- several radii can converge on genuinely DIFFERENT wall // shapes around the same base at the same or a nearby cost (the same // plateau behaviour that motivated the sweep itself), and `siegePlans` // below wants each of them as its own territory candidate rather than // only the first `siegeCost` would ever return. Sorted cheapest first; // deduplicated by exact cell set (several radii commonly converge on an // IDENTICAL cut once cost plateaus). Empty when the defender is already // eliminated (nothing to plan) or no radius confirms within `cap`. export const siegeCuts = ( state: game.BoardState, defender: number, attacker: number, cap: number, ): readonly SiegeCost[] => { const prep = siegePrep(state, defender, attacker); if (prep.kind !== "ready") { return []; } const { base, baseKey, sea, confirm } = prep; const maxFar = farthestSeaDistance(base, sea); const seen = new Set(); const results: SiegeCost[] = []; for (let far = 3; far <= Math.max(3, maxFar); far++) { const result = siegeFlowAt(state, base, baseKey, sea, cap, far); if (result.cost > cap) { continue; } const dedupeKey = [...result.cut].map(key).sort().join("|"); if (seen.has(dedupeKey) || !confirm(result.cut)) { continue; } seen.add(dedupeKey); results.push({ cost: result.cost, cut: result.cut, confirmed: true }); } return results.sort((a, b) => a.cost - b.cost); }; // One live enemy whose siege against `defender` is CONFIRMED and within // `cap` placements -- "am I currently under a real, multi-turn-but-still- // imminent siege," not just the one-ply kill `survivalMove` already // catches (see docs/tasks/2026-09-12/23-circles-siege-defence-and- // offence.md). A caller uses a non-empty result to force `horizon` 0 for // this decision (a plan spanning a future turn is moot if the active // player might not survive to take it) and feeds the same list to // `siegeDefence` below. export type SiegeThreat = { readonly enemy: number; readonly siege: SiegeCost; // .cost <= cap, .confirmed === true }; export const siegeThreats = ( state: game.BoardState, defender: number, cap: number, ): readonly SiegeThreat[] => { const threats: SiegeThreat[] = []; for (let enemy = 0; enemy < state.players.length; enemy++) { if (enemy === defender) { continue; } const siege = siegeCost(state, defender, enemy, cap); if (siege.confirmed && siege.cost <= cap) { threats.push({ enemy, siege }); } } return threats; }; // A PURE, FINDER-ONLY synthetic board with a plan's own cells added as // the ACTIVE player's fresh stones and (optionally) a set of positions // removed entirely -- "what would siegeCost read on the board AFTER this // plan/placement," never a legal state on its own. Same caveat as // `asPlayer` above: **never pass this to `game.place`, `game.endTurn`, or // any other mutating rule function** -- turn stamps on the synthetic // cells are not honestly staggered across turn groups the way // `validateTerritoryPlan`'s own replay is (this is a same-decision // siegeCost PROBE, not a multi-turn validity check, which is still // `validateTerritoryPlan`'s job alone). `remove` models a captured // victim actually leaving the board -- exactly what a real capture does // at `endTurn` -- which matters here because removing an enemy's own // wall stone can shrink or reroute THEIR sea just as much as occupying a // cell can (`siegeBreaker`'s "break the wall, don't just plug it" idiom, // restated as a board rather than a filter). Exported so a future // caller (Tentacles, task 24, needs the identical "post-plan board" for // its own siege-progress scoring) never re-derives this. export const withOwnPlacements = ( state: game.BoardState, cells: readonly game.Position[], remove: ReadonlySet = new Set(), ): game.BoardState => ({ ...state, pieces: [ ...state.pieces.filter((piece) => !remove.has(key(piece.position))), ...cells.map((position): game.Piece => ({ position, player: state.activePlayer, turn: state.turn, isHomeBase: false, })), ], }); // --- Capture planning ---------------------------------------------------- // // Added 2026-09-12 (as `planCuts`, cut-only), replacing cuts.ts's own // random-pool one-ply lookahead (`setupValue`/`pickRandomSubset`, see that // file's revision log) after Analyst's review of four of Arcadia's 1v1 // games found Cuts made ZERO cuts across all four despite a wall-breaking // cut being available every time -- the old lookahead is blind for a // cut's first two placements (it can only ever see a cut once two of its // three stones are already down), so it never actually STARTS one from // nothing on a board where nothing forces it. // // Widened the SAME day, at Arcadia's explicit request, into a four-layer // shape covering rings too, after she praised a Cuts turn // (`seed2328586520` T18, see docs/memories/project/bot-strategies.md's // "brilliantly done" note) that cut AND incidentally closed two rings in // one turn -- she wants bots that recognise and seek this kind of // multi-rule combo, not just raw capture count, and she wants the RULE // RECOGNITION itself to be strategy-free substrate any bot can later // adopt, never re-derived per strategy. The four layers, in the order // data actually flows through them: // // 1. GENERIC FINDERS (`cutPlans`, `ringPlans`) -- "what does the RULEBOOK // allow here." Pure geometry off today's board, zero weighting, zero // strategy knowledge. A cut is always exactly a template: three cells // (c0, c1, c2) along one of the 8 `DIRECTIONS` d, with each of the two // steps c0->c1 and c1->c2 being 1 (a joint) or 2 (a pierce, requiring // the skipped cell to hold an enemy stone -- see // docs/memories/project/capture-rules.md's "Cutting"). A ring is the // four-cell orthogonal/diagonal pattern around one enemy non-base // piece, needing every occupied member to already be the active // player's (a stone OR their own base -- see `ringPlans`' own comment) // and at least one cell still empty. Both are closed, enumerable // search spaces -- `estimateCutTemplate`/`ringPlans` mirror `game.ts`'s // own `tryLink`/`linkCaptures`/`isCapturingRing` geometry respectively; // re-derive against `game.ts` before changing either. // 2. GENERIC VALIDATOR (`validatePlan`) -- "does the rulebook actually // agree, once fully placed." Chains `game.place` over a plan's own // empty cells and reads `game.pendingCuts`/`game.pendingEncirclements` // off the result, diffed against a pre-plan baseline by object // IDENTITY (see its own comment). This is the sole source of truth; // layer 1's estimate is a ranking heuristic only. If the two ever // disagree, this layer wins -- that's what makes the estimate safe to // widen or retune without re-deriving `game.ts`'s own rules by hand. // 3. STRATEGY-SUPPLIED RANKING (`planCaptures`) -- "which of the rulebook's // options do I actually want, and how much." Gathers candidates from // whichever finders the caller requests, folds in cross-rule COMBOS // (see `withComboVictims`) so a plan that does two things at once // ranks as such before validation ever narrows the field, filters by // this turn's remaining placement budget, ranks by the caller's own // `weight` function, validates only the top few (expensive relative to // estimating -- `game.place` chains plus `game.pendingCuts`/ // `game.pendingEncirclements` reads, real money per docs/bots.md's // performance rules), and returns validated plans ranked by weighted // value minus this turn's own exposure cost. // 4. STRATEGY WIRING (`cuts.ts`) -- the only layer that knows what a // "victim" is worth, whether a lone ring is worth chasing at all, or // how heavily to weight a siege. See that file's own revision log for // Cuts' particular tuning (a knob, explicitly not derived from any // rule); Circles and Tentacles are untouched by this pass and could // adopt the same layers 1-3 later with their own layer-4 weighting. // // None of layers 1-3 know cuts.ts exists. That's the point: recognising // what the rules allow lives in exactly one place, strategies differ only // in how they weight and choose among what the finders return. export type CaptureRule = "cut" | "ring" | "territory"; // A raw, UNWEIGHTED candidate from a finder -- what layer 1 returns. // `empty` is every cell of this plan still needing a placement, in an // order `validatePlan` can chain `game.place` over directly (source to // target for a cut; ring order doesn't matter, since a ring's own empty // cells have no sequencing requirement between them; a territory plan's // order matters for a different reason -- see `orderWallCells`). // `estimatedVictims` is the finder's own best-effort read of what // completing this exact plan would capture, off TODAY's board -- see each // finder's own comment for why that's sound, not merely convenient. // `eliminates`/`area` are territory-only (added 2026-09-12 for // `enclosurePlans`, see docs/tasks/2026-09-12/22-circles-enclosure-planner.md) // -- left undefined by `cutPlans`/`ringPlans`, which have no equivalent // concept. export type CapturePlan = { readonly rule: CaptureRule; readonly empty: readonly game.Position[]; readonly estimatedVictims: readonly game.Piece[]; readonly eliminates?: readonly number[]; // territory only: enemy players whose base falls inside readonly area?: number; // territory only: total cell count the plan would surrender, by ESTIMATE (validatePlan's game.seaCells replay is the exact figure) }; // Same scale as `weightFor(3)` above: one captured non-base stone is // worth as much as a ring that's one placement from firing. Previously // cuts.ts scored a cut's raw capture COUNT (`10 * gain`) against // `EXPOSURE_PENALTY` (30) -- since a cut's own cells always sit directly // next to the enemy stones they just captured, `exposure` there reads 2-3 // almost every time, so a genuine 2-capture cut scored `20 - 60` to // `20 - 90` (`-40` to `-70`) and lost to any safe, worthless cell every // single time. That scale mismatch, not a missing feature, is why Cuts // made zero cuts across every one of Analyst's four reviewed games // despite one being available in each. `EXPOSURE_PENALTY` itself is // unchanged -- ring-avoidance tuning is a separate concern -- only the // OTHER side of the comparison was wrong. A generic, reusable scale // constant, not cuts.ts-specific -- kept here alongside `EXPOSURE_PENALTY` // for the same reason, even though only cuts.ts's own `weight` function // uses it today (see that file for exactly how). export const CUT_WEIGHT = 100; // Chebyshev radius around a strategy's own home base that a `weight` // function may treat as "under siege." Arcadia's actual winning walls in // Analyst's reviewed games sat 3-4 cells out and were visible from her // 2nd-3rd turn -- a tuning knob, not derived from any rule, and not // applied by anything in this section itself (siege weighting is a // strategy concern -- see layer 4 above -- not a finder concern). export const SIEGE_RADIUS = 4; // The four (step1, step2) shape combinations a cut template can take -- // each step is a joint (1) or a pierce (2), matching `game.ts`'s `Link` // exactly. `[2, 2]` (two pierces on one line) is the up-to-4-stone case // capture-rules.md's "Cutting" section calls out explicitly. const CUT_TEMPLATE_SHAPES: readonly (readonly [number, number])[] = [ [1, 1], [1, 2], [2, 1], [2, 2], ]; // Non-base `pendingCuts` count -- shared so cuts.ts's own immediate-gain // fallback scoring and this section's own baseline diffing share one // definition instead of two copies drifting apart. export const cutCount = (state: game.BoardState): number => game.pendingCuts(state).filter((piece) => !piece.isHomeBase).length; const isFreshOwn = (state: game.BoardState, piece: game.Piece | undefined): boolean => piece !== undefined && piece.player === state.activePlayer && piece.turn === state.turn && !piece.isHomeBase; const isDiagonalOffset = (offset: Offset): boolean => offset.row !== 0 && offset.col !== 0; // Local mirror of `game.ts`'s private `hasSameOwnerNeighbour` -- that // function isn't exported (and if it were, it reads through the linear // `pieceAt`, which is exactly the per-candidate cost trap // bot-strategies.md warns about), so this re-derives the same "at least // one 8-neighbour belongs to the same owner" check off an already-built // `Occupancy` map instead. Re-derive against `game.ts`'s own version if // that rule ever changes. const sameOwnerNeighbour = (occ: Occupancy, piece: game.Piece): boolean => DIRECTIONS.some((offset) => { const neighbour = at(occ, shift(piece.position, offset)); return neighbour !== undefined && neighbour.player === piece.player; }); // What template (cells, steps, d) would capture, estimated purely off // TODAY's board -- never off a hypothetical post-placement board, which // is what makes this safe to compute for every template up front rather // than only for ones already fully built. This is sound, not merely // convenient: every victim this function can ever find (a pierce's // middle cell, a diagonal joint's two shoulders) is necessarily an ENEMY // stone standing on the board right now, and this template's own cells // (c0, c1, c2) are constrained below to be EMPTY or FRESH OWN -- so // placing them can only ever change who owns c0/c1/c2 themselves, never // the ownership or neighbours of a cell this function reads to find a // victim. Returns null if the template is geometrically impossible (a // required pierce cell isn't enemy-owned -- not merely "captures // nothing", `tryLink` would find no link there AT ALL), blocked (a // template cell is a pre-existing own stone, a third player's stone, or // a home base -- pre-existing own stones deliberately do NOT satisfy a // template cell, matching `game.ts`'s own rule exactly: a joint needs its // neighbour FRESH, so an old own stone there can never chain either, no // matter which code is doing the searching. Confirmed in `seed22465832`: // Cuts' own earlier filler stones at `(5,11)`/`(5,13)` sat exactly where // a later cut's own template needed empty cells, permanently ruling that // shape out -- not a bug this fixed (a `game.place`-based lookahead // obeys the identical rule regardless; the cell was simply occupied and // therefore illegal to place into either way), just a strategic lesson // this planner makes newly visible: a filler placement made before any // cut plan exists can permanently poison one), captures nothing, or is // already fully placed this turn (nothing left to plan for). const estimateCutTemplate = ( state: game.BoardState, occ: Occupancy, cells: readonly [game.Position, game.Position, game.Position], steps: readonly [number, number], d: Offset, ): CapturePlan | null => { for (const cell of cells) { const occupant = at(occ, cell); if (occupant !== undefined && !isFreshOwn(state, occupant)) { return null; } } const victims = new Map(); const addVictim = (piece: game.Piece | undefined): void => { if (piece && !piece.isHomeBase) { victims.set(key(piece.position), piece); } }; // One link, source -> source+step*d. Returns false only when the // template ISN'T a link at all here (a required pierce cell isn't // enemy-owned), never merely "captures nothing" -- see this function's // own header for why that distinction matters. const link = (source: game.Position, step: number): boolean => { if (step === 1) { if (!isDiagonalOffset(d)) { return true; // orthogonal joint: chains, captures nothing } const shoulderA = at(occ, { row: source.row + d.row, col: source.col }); const shoulderB = at(occ, { row: source.row, col: source.col + d.col }); if ( shoulderA && shoulderB && shoulderA.player === shoulderB.player && shoulderA.player !== state.activePlayer ) { addVictim(shoulderA); addVictim(shoulderB); } return true; } const mid = shift(source, d, 1); const midPiece = at(occ, mid); if (!midPiece || midPiece.player === state.activePlayer) { return false; } if (sameOwnerNeighbour(occ, midPiece)) { addVictim(midPiece); } return true; }; if (!link(cells[0], steps[0]) || !link(cells[1], steps[1])) { return null; } if (victims.size === 0) { return null; } const empty = cells.filter((cell) => at(occ, cell) === undefined); if (empty.length === 0) { return null; } return { rule: "cut", empty, estimatedVictims: [...victims.values()] }; }; // LAYER 1, cut finder: every geometrically possible cut template on // today's board, brute forced -- `size.rows * size.cols` starting cells x // 8 directions x 4 (step1, step2) shape combinations (~8k on a 16x16 // board), each an O(1) `estimateCutTemplate` lookup. No weighting, no // budget filter (that's `planCaptures`' job -- see layer 3 above) -- this // is purely "what does the rulebook allow," so it stays usable by any // future caller with its own idea of value or turn budget. export const cutPlans = (state: game.BoardState, occ: Occupancy): CapturePlan[] => { const plans: CapturePlan[] = []; for (let row = 0; row < state.size.rows; row++) { for (let col = 0; col < state.size.cols; col++) { const c0 = { row, col }; for (const d of DIRECTIONS) { for (const [s1, s2] of CUT_TEMPLATE_SHAPES) { const c1 = shift(c0, d, s1); const c2 = shift(c1, d, s2); if (!inBounds(state.size, c1) || !inBounds(state.size, c2)) { continue; } const plan = estimateCutTemplate(state, occ, [c0, c1, c2], [s1, s2], d); if (plan) { plans.push(plan); } } } } } return plans; }; // LAYER 1, ring finder: every geometrically possible ring-completion plan // on today's board, one per (enemy non-base piece x pattern). Reuses // `scanRing` (see the "Ring pressure" section above) rather than // re-deriving the same geometry a second time. Rules, matching // `game.ts`'s `isCapturingRing`/`ringAt` and capture-rules.md's own // invariants exactly: // // - All 4 ring cells must be on-board (`scanRing` returns null // otherwise) -- off-board is never a surrounder. // - Every OCCUPIED ring cell must belong to the active player -- // `scanRing`'s `blocked` flag already IS "an enemy or third player's // stone kills the plan." Unlike a cut template, a PRE-EXISTING own // stone counts here (there is no "fresh" requirement for a ring member // other than the eventual capturing placement itself -- `game.ts`'s // `isCapturingRing` only requires ONE member placed this turn, not all // four), and the active player's own home base counts as a ring member // too (`occupant.player === state.activePlayer` doesn't distinguish a // base from an ordinary stone, which is exactly right: a base can be a // ring member without ever being a capturable target -- see // `capture-rules.md`'s home-base section). // - At least one ring cell must still be empty (`scan.empty.length >= // 1`) -- this is what will let the "at least one member placed this // turn" rule fire once every empty cell is actually filled; a ring with // zero empty cells is already fully built and has nothing left to plan. // - The victim needs no line-of-2 or same-owner-neighbour condition, // unlike a cut's pierce victim -- capture-rules.md is explicit that the // diagonal ring does NOT mirror the cut's line requirement. export const ringPlans = (state: game.BoardState, occ: Occupancy): CapturePlan[] => { const plans: CapturePlan[] = []; for (const piece of state.pieces) { if (piece.player === state.activePlayer || piece.isHomeBase) { continue; } for (const pattern of RING_PATTERNS) { const scan = scanRing(state, occ, piece.position, pattern); if (!scan || scan.blocked || scan.empty.length === 0) { continue; } plans.push({ rule: "ring", empty: scan.empty, estimatedVictims: [piece] }); } } return plans; }; // LAYER 1, territory finder: added 2026-09-12 at Arcadia's request that // Circles become a real encircler ("look for LARGER opportunities, plan // its moves" -- see docs/tasks/2026-09-12/22-circles-enclosure-planner.md // for the full design). Unlike a cut or a ring, there is no single closed // template to enumerate exhaustively -- an enclosure is a WALL around a // region, and the region's own shape is a choice, not a rule. This finder // therefore proposes candidate DISC-shaped regions (round walls read as // circles, on-vision per docs/vision.md) around each enemy target, at a // handful of radii and centres, rather than searching every possible // wall shape. `validatePlan`'s `game.seaCells` replay (below) is still the // exact source of truth for what a candidate actually captures -- // exactly the same layer-1-estimate-vs-layer-2-validation split // `cutPlans`/`ringPlans` already use, just with a heuristic search // standing in for `cutPlans`' exhaustive template enumeration, since an // exhaustive search over "every possible wall" has no finite closed form. // `ownLiveBlockers` is defined further down this file (Sea cuts section); // forward-declared here in prose since JS function declarations hoist but // this comment can't -- see that section for the "why non-overlay, why // live" rationale, shared unchanged by every use below. type EnclosureTarget = { readonly player: number; readonly cells: readonly game.Position[] }; // Every enemy non-base stone, grouped into 8-connected clusters (a target // is a GROUP, not a single stone -- a solid enemy blob should get one // candidate region around the whole blob, not one per stone), plus each // enemy's own home base as its own separate single-cell target (a base is // never captured, but eliminating a player by walling their base into // territory is exactly the aggression Arcadia asked for -- see // `docs/memories/project/bot-strategies.md`'s "bot identities" note). const enclosureTargets = (state: game.BoardState, occ: Occupancy): EnclosureTarget[] => { const targets: EnclosureTarget[] = []; for (let player = 0; player < state.players.length; player++) { if (player === state.activePlayer) { continue; } const visited = new Set(); for (const piece of state.pieces) { if (piece.player !== player || piece.isHomeBase) { continue; } const startKey = key(piece.position); if (visited.has(startKey)) { continue; } const cells: game.Position[] = []; const stack: game.Position[] = [piece.position]; visited.add(startKey); while (stack.length > 0) { const current = stack.pop() as game.Position; cells.push(current); for (const offset of DIRECTIONS) { const neighbour = shift(current, offset); const neighbourKey = key(neighbour); if (visited.has(neighbourKey)) { continue; } const occupant = at(occ, neighbour); if (occupant && occupant.player === player && !occupant.isHomeBase) { visited.add(neighbourKey); stack.push(neighbour); } } } targets.push({ player, cells }); } const base = ownBase(state, player); if (base) { targets.push({ player, cells: [base.position] }); } } return targets; }; const centroid = (cells: readonly game.Position[]): game.Position => ({ row: Math.round(cells.reduce((sum, cell) => sum + cell.row, 0) / cells.length), col: Math.round(cells.reduce((sum, cell) => sum + cell.col, 0) / cells.length), }); // The single direction pointing at whichever board edge `center` is // closest to -- "the rim is a free wall" (Analyst's phrasing): a region // pushed up against the border needs fewer placed stones per unit area, // since the border itself blocks the sea exactly as a stone would (see // docs/memories/project/capture-rules.md's territory section). const towardNearestRim = (size: game.BoardSize, center: game.Position): Offset => { const candidates: { offset: Offset; distance: number }[] = [ { offset: { row: -1, col: 0 }, distance: center.row }, { offset: { row: 1, col: 0 }, distance: size.rows - 1 - center.row }, { offset: { row: 0, col: -1 }, distance: center.col }, { offset: { row: 0, col: 1 }, distance: size.cols - 1 - center.col }, ]; return candidates.reduce((closest, candidate) => (candidate.distance < closest.distance ? candidate : closest)).offset; }; // Radii in cells, matching the disc-shape formula {(r-r0)^2+(c-c0)^2 <= // R^2} from smallest (barely more than a ring) to a genuinely large // enclosure -- Arcadia's "look for LARGER opportunities" brief. Fractional // radii (1.5, 2.5, 3.5) are deliberate, not rounded away: squaring a // half-integer lands between two integer-radius disc sizes, giving the // search finer steps than integers alone would. const ENCLOSURE_RADII: readonly number[] = [1, 1.5, 2, 2.5, 3, 3.5]; // Backstops on the grow loop below (see its own comment) -- both // pathological-board insurance, in the same spirit as `SEA_CUT_CANDIDATE_CAP`, // not routine limits expected to bind on a normal board. const ENCLOSURE_MAX_REGION = 40; const ENCLOSURE_MAX_GROW_ITERATIONS = 3; const discRegion = (size: game.BoardSize, center: game.Position, radius: number): game.Position[] => { const cells: game.Position[] = []; const reach = Math.ceil(radius); const radiusSquared = radius * radius; for (let dr = -reach; dr <= reach; dr++) { for (let dc = -reach; dc <= reach; dc++) { if (dr * dr + dc * dc > radiusSquared) { continue; } const cell = { row: center.row + dr, col: center.col + dc }; if (inBounds(size, cell)) { cells.push(cell); } } } return cells; }; // Every in-bounds cell orthogonally adjacent to `region` but not itself // in it -- the wall a disc-shaped region needs built around it. Only // orthogonal adjacency: `game.seaCells`' own flood fill is 4-connected // (see capture-rules.md), so an orthogonal wall is what actually blocks // it; a diagonal-only neighbour of the region is not a hole in the wall. const regionBoundary = (size: game.BoardSize, region: ReadonlySet): game.Position[] => { const boundary = new Map(); for (const cellKey of region) { const [rowText, colText] = cellKey.split(","); const cell = { row: Number(rowText), col: Number(colText) }; for (const offset of ORTHOGONAL) { const neighbour = shift(cell, offset); const neighbourKey = key(neighbour); if (region.has(neighbourKey) || boundary.has(neighbourKey) || !inBounds(size, neighbour)) { continue; } boundary.set(neighbourKey, neighbour); } } return [...boundary.values()]; }; const eliminatesIn = (state: game.BoardState, region: ReadonlySet): number[] => { const result: number[] = []; for (let other = 0; other < state.players.length; other++) { if (other === state.activePlayer) { continue; } const base = ownBase(state, other); if (base && region.has(key(base.position))) { result.push(other); } } return result; }; // Order `needed` (the wall's own empty cells) so a chain of placements // grows CONTIGUOUSLY outward from stones already standing -- the first // cell adjacent to an existing own stone (or, failing that, an arbitrary // cell restarting a new chain segment), then repeatedly the next // remaining cell adjacent to the chain built so far. This is what keeps a // half-built enclosure (this turn's portion of a multi-turn plan, see // `planCaptures`' `horizon`) looking and functioning like a connected // wall-in-progress rather than scattered stones that happen to sum to the // right shape. O(n^2) over the wall's own cell count, bounded by // `ENCLOSURE_MAX_REGION` -- cheap at that size. const orderWallCells = ( state: game.BoardState, occ: Occupancy, needed: readonly game.Position[], ): game.Position[] => { const remaining = new Map(needed.map((cell) => [key(cell), cell])); const ordered: game.Position[] = []; const orderedKeys = new Set(); const isAdjacentToChosen = (cell: game.Position): boolean => DIRECTIONS.some((offset) => { const neighbour = shift(cell, offset); const neighbourKey = key(neighbour); if (orderedKeys.has(neighbourKey)) { return true; } const occupant = at(occ, neighbour); return occupant !== undefined && occupant.player === state.activePlayer; }); while (remaining.size > 0) { let next: game.Position | undefined; for (const cell of remaining.values()) { if (ordered.length === 0 || isAdjacentToChosen(cell)) { next = cell; break; } } // No remaining cell touches the chain or an own stone -- the wall has // a disjoint segment (e.g. a grown-in enemy stone far from the rest of // the disc). Restart from an arbitrary remaining cell rather than // stalling; `[...remaining.values()][0]` is deterministic given // `needed`'s own order, not random, so this stays reproducible. next ??= [...remaining.values()][0]; ordered.push(next); orderedKeys.add(key(next)); remaining.delete(key(next)); } return ordered; }; // One candidate region: grow `discRegion(center, radius)` to also swallow // any ENEMY stone sitting on its own wall boundary (an enemy stone can // never block the active player's own sea -- see capture-rules.md's // territory section -- so a target sitting just outside the disc is // folded IN rather than treated as blocking it), recompute the boundary, // and repeat until no boundary cell is enemy-owned or a backstop cap // trips. Returns null if the region can't be finished at all (a // THIRD-PARTY... no: any non-active-player stone should always be // growable in by construction, so a still-blocked boundary cell after the // grow loop means the cap tripped, not a genuine dead end) or if there is // nothing worth walling in (no victim, no eliminated base). const buildEnclosure = ( state: game.BoardState, occ: Occupancy, center: game.Position, radius: number, ): CapturePlan | null => { const region = new Set(discRegion(state.size, center, radius).map(key)); for (let iteration = 0; iteration < ENCLOSURE_MAX_GROW_ITERATIONS; iteration++) { const boundary = regionBoundary(state.size, region); const enemyOnBoundary = boundary.filter((cell) => { const occupant = at(occ, cell); return occupant !== undefined && occupant.player !== state.activePlayer; }); if (enemyOnBoundary.length === 0) { break; } for (const cell of enemyOnBoundary) { region.add(key(cell)); } if (region.size > ENCLOSURE_MAX_REGION) { return null; } } if (region.size > ENCLOSURE_MAX_REGION) { return null; } const boundary = regionBoundary(state.size, region); const needed: game.Position[] = []; for (const cell of boundary) { const occupant = at(occ, cell); if (occupant === undefined) { needed.push(cell); } else if (occupant.player !== state.activePlayer) { // The grow loop above folds in every enemy stone reachable this way; // still seeing one here means the cap tripped before the boundary // stabilised -- reject rather than propose a wall with a hole in it. return null; } // occupant.player === state.activePlayer: free wall, already standing. } if (needed.length === 0) { return null; // fully built already -- nothing left to plan for. } const estimatedVictims = state.pieces.filter( (piece) => piece.player !== state.activePlayer && !piece.isHomeBase && region.has(key(piece.position)), ); const eliminates = eliminatesIn(state, region); if (estimatedVictims.length === 0 && eliminates.length === 0) { return null; // walling in empty space captures nothing. } return { rule: "territory", empty: orderWallCells(state, occ, needed), estimatedVictims, eliminates, area: region.size, }; }; // LAYER 1, territory finder proper: every (target x centre x radius) // combination, deduplicated by identical needed-cell sets (several // centre/radius pairs often converge on the same wall once the grow loop // and rim-clamping run). Bounded to `enclosureTargets().length * // (up to 3 centres) * ENCLOSURE_RADII.length` candidate builds -- small // (single digits to low tens of targets on a real board), each build // itself O(region size) which is capped by `ENCLOSURE_MAX_REGION`. export const enclosurePlans = (state: game.BoardState, occ: Occupancy): CapturePlan[] => { const plans: CapturePlan[] = []; const seen = new Set(); for (const target of enclosureTargets(state, occ)) { const base = centroid(target.cells); const rimOffset = towardNearestRim(state.size, base); const centreCandidates = new Map(); for (const steps of [0, 1, 2]) { const center = shift(base, rimOffset, steps); if (inBounds(state.size, center)) { centreCandidates.set(key(center), center); } } for (const center of centreCandidates.values()) { for (const radius of ENCLOSURE_RADII) { const plan = buildEnclosure(state, occ, center, radius); if (!plan) { continue; } const dedupeKey = [...plan.empty].map(key).sort().join("|"); if (seen.has(dedupeKey)) { continue; } seen.add(dedupeKey); plans.push(plan); } } } return plans; }; // LAYER 1, territory finder's one-stone special case: folds `seaCuts`' // own (already exact, one-ply) closing-move candidates in as territory // plans, so a wall one stone from done is never missed just because it // doesn't happen to match one of `enclosurePlans`' disc shapes. `seaCuts` // itself reports only COUNTS (surrendered/victims/eliminates), not the // actual captured `Piece`s `estimatedVictims` needs -- so this recomputes // the exact surrendered set ONE more time, but only for candidates that // already cleared `seaCuts`' own `victims > 0 || eliminates.length > 0` // filter (typically a small handful), never for every candidate `seaCuts` // considered. No rule geometry is duplicated by this: the recompute is // the exact same `game.seaCells` diff `seaCuts` already does internally, // just asked again to recover positions instead of a bare count. const oneStoneTerritoryPlans = (state: game.BoardState): CapturePlan[] => { const plans: CapturePlan[] = []; const relevant = seaCuts(state, state.activePlayer).filter( (cut) => cut.victims > 0 || cut.eliminates.length > 0, ); if (relevant.length === 0) { return plans; } const blockers = ownLiveBlockers(state, state.activePlayer); const sea = game.seaCells(state.size, blockers); for (const cut of relevant) { const synthetic: game.Piece = { position: cut.position, player: state.activePlayer, turn: state.turn, isHomeBase: false, }; const nextSea = game.seaCells(state.size, [...blockers, synthetic]); const surrendered = new Set(); for (const cellKey of sea) { if (cellKey !== key(cut.position) && !nextSea.has(cellKey)) { surrendered.add(cellKey); } } const estimatedVictims = state.pieces.filter( (piece) => piece.player !== state.activePlayer && !piece.isHomeBase && surrendered.has(key(piece.position)), ); if (estimatedVictims.length === 0 && cut.eliminates.length === 0) { continue; } plans.push({ rule: "territory", empty: [cut.position], estimatedVictims, eliminates: cut.eliminates, area: surrendered.size, }); } return plans; }; // LAYER 1, territory finder: Circles' own OFFENSIVE use of `siegeCost` // (task 23, docs/tasks/2026-09-12/23-circles-siege-defence-and-offence.md) // -- "wall an enemy's base off entirely" is Arcadia's own actual winning // pattern (bot-strategies.md's "threat horizon" note) restated as a // FINDER rather than only a defensive measure. For each enemy, ask // `siegeCost` the other way around (attacker = the active player, // defender = that enemy) -- a confirmed cut within this decision's own // `game.remaining` budget plus `horizonCap` IS a territory wall's own // needed cells, no separate geometry required. `estimatedVictims`/`area`/ // `eliminates` are read off the SAME before/after `game.seaCells` diff // `siegeCost`'s own confirm step already ran (stamped at the active // player's own NEXT turn, matching that function's own convention) -- // reproduced here rather than widening `SiegeCost`'s own return type, // the same "recompute once more for positions, not just a bare count" // idiom `oneStoneTerritoryPlans` above already uses against `seaCuts`. export const siegePlans = ( state: game.BoardState, occ: Occupancy, horizonCap: number, ): CapturePlan[] => { const plans: CapturePlan[] = []; const cap = game.remaining(state) + horizonCap; if (cap <= 0) { return plans; } const blockers = ownLiveBlockers(state, state.activePlayer); const beforeSea = game.seaCells(state.size, blockers); for (let enemy = 0; enemy < state.players.length; enemy++) { if (enemy === state.activePlayer) { continue; } // Every DISTINCT confirmed cut, not just the cheapest -- widened // 2026-09-12 (Analyst, task 23's own siege-defusal re-verification): // the multi-radius sweep behind `siegeCuts` naturally surfaces // several genuinely different wall shapes around the same base at // the same or a nearby cost (the plateau behaviour that motivated // the sweep itself, see `siegeCost`'s own comment) -- free breadth // for offence once `planCaptures`/`validateTerritoryPlan` already // rank whatever candidates a finder proposes. for (const siege of siegeCuts(state, enemy, state.activePlayer, cap)) { const synthetic: game.Piece[] = siege.cut.map((position) => ({ position, player: state.activePlayer, turn: state.turn + state.players.length, isHomeBase: false, })); const afterSea = game.seaCells(state.size, [...blockers, ...synthetic]); const surrendered = new Set(); for (const cellKey of beforeSea) { if (!afterSea.has(cellKey)) { surrendered.add(cellKey); } } for (const position of siege.cut) { surrendered.delete(key(position)); } const estimatedVictims = state.pieces.filter( (piece) => piece.player !== state.activePlayer && !piece.isHomeBase && surrendered.has(key(piece.position)), ); const eliminates = eliminatesIn(state, surrendered); if (estimatedVictims.length === 0 && eliminates.length === 0) { continue; } plans.push({ rule: "territory", empty: orderWallCells(state, occ, siege.cut), estimatedVictims, eliminates, area: surrendered.size, }); } } return plans; }; // A PURE, FINDER-ONLY perspective swap: the same board, viewed as though // `p` were the one about to move, on `p`'s own NEXT turn (`state.turn + 1`, // so nothing currently on the board -- including a stone the real active // player is about to place -- is stamped with `p`'s own current turn and // therefore counts as fresh for them). This is what lets `interruptions` // below ask "what could an ENEMY do" by literally running the same // `cutPlans`/`ringPlans` finders a second time, from their side, instead // of maintaining a separate copy of "how threatened is my own shape." // // **NEVER pass the result of `asPlayer` to `game.place`, `game.endTurn`, or // any other MUTATING rule function.** It is not a legal, playable state -- // `activePlayer`/`turn` are overwritten independently of `pieces`, which // can desynchronize turn-ordering invariants `game.ts` relies on elsewhere // (e.g. `isOpeningTurn`, `nextActivePlayer`). It exists solely so // `cutPlans`/`ringPlans` (which only ever READ `state.activePlayer`/ // `state.turn` to decide "is this fresh, is this mine") can be pointed at // a hypothetical actor without copying their internals. export const asPlayer = (state: game.BoardState, player: number): game.BoardState => ({ ...state, activePlayer: player, turn: state.turn + 1, }); // "Can my shape be interrupted next turn": add `ownCells` (this plan's own // planned placements) to the board as the active player's stones, then, // from EVERY live enemy's own `asPlayer` view, run the same // `cutPlans`/`ringPlans` finders used everywhere else in this file -- // no separate "am I in danger" geometry, just the offence finders pointed // the other way. Returns the set of my own stones (existing or newly // planned) that appear as an `estimatedVictims` member of some enemy plan // needing 3 or fewer placements of their own (`plan.empty.length <= 3` -- // within a single ordinary turn's budget, a genuine near-term threat, not // a plan needing a whole extra turn's placements to even begin). A // strategy uses this to prefer a robust shape over a hollow one that // scores the same today but hands an opponent an easy reply -- see // `docs/tasks/2026-09-12/22-circles-enclosure-planner.md`'s acceptance // case 4. export const interruptions = ( state: game.BoardState, ownCells: readonly game.Position[], ): ReadonlySet => { const occ = occupancy(state); const plannedKeys = new Set(ownCells.map(key)); const hypotheticalPieces = [ ...state.pieces, ...ownCells .filter((cell) => at(occ, cell) === undefined) .map((position): game.Piece => ({ position, player: state.activePlayer, turn: state.turn, isHomeBase: false })), ]; const hypState: game.BoardState = { ...state, pieces: hypotheticalPieces }; const atRisk = new Set(); for (let enemy = 0; enemy < state.players.length; enemy++) { if (enemy === state.activePlayer) { continue; } const enemyView = asPlayer(hypState, enemy); const enemyOcc = occupancy(enemyView); const enemyPlans = [...cutPlans(enemyView, enemyOcc), ...ringPlans(enemyView, enemyOcc)]; for (const plan of enemyPlans) { if (plan.empty.length > 3) { continue; } for (const victim of plan.estimatedVictims) { if (victim.player !== state.activePlayer) { continue; } const victimKey = key(victim.position); if (plannedKeys.has(victimKey) || occ.has(victimKey)) { atRisk.add(victimKey); } } } } return atRisk; }; // One `game.seaCells` replay per enemy (plus their own blockers), computed // ONCE per decision by `planCaptures` and threaded through every // validated plan's `territoryRisk` check below -- never per candidate, // per the standing cost rule (docs/bots.md). export type EnemyTerritory = { readonly player: number; readonly sea: ReadonlySet; }; export const enemyTerritories = (state: game.BoardState): readonly EnemyTerritory[] => { const territories: EnemyTerritory[] = []; for (let player = 0; player < state.players.length; player++) { if (player === state.activePlayer) { continue; } const blockers = ownLiveBlockers(state, player); territories.push({ player, sea: game.seaCells(state.size, blockers) }); } return territories; }; // The 4-connected component of NON-sea cells (i.e. `e`'s current // territory) reachable from `start` -- used by `territoryRisk` to find // exactly which of the active player's own stones sit in the SAME pocket // as a stone about to be captured, not merely "somewhere in e's // territory" (which could wildly overcount on a large board with several // unrelated territory pockets). const territoryComponent = ( size: game.BoardSize, sea: ReadonlySet, start: game.Position, ): ReadonlySet => { const component = new Set(); if (!inBounds(size, start) || sea.has(key(start))) { return component; } const stack: game.Position[] = [start]; component.add(key(start)); while (stack.length > 0) { const current = stack.pop() as game.Position; for (const offset of ORTHOGONAL) { const neighbour = shift(current, offset); const neighbourKey = key(neighbour); if (component.has(neighbourKey) || !inBounds(size, neighbour) || sea.has(neighbourKey)) { continue; } component.add(neighbourKey); stack.push(neighbour); } } return component; }; // The reseal-livelock guard. Found by Analyst reviewing `seed3927004296` // (T9-T29) and `seed1443119786` (T41-T57): Circles repeatedly rang/cut one // of an enemy's own WALL stones that happened to sit just inside their // existing territory, using placements that themselves land INSIDE that // same territory pocket -- capturing the wall stone opens the pocket for // exactly one turn, the enemy simply refills it next turn (their own sea // replay only ever sees LIVE pieces, so a refilled wall re-seals the // identical pocket), and Circles' own stones placed inside it during the // "open" turn are recaptured wholesale. A net LOSS dressed up as a // capture, repeated turn after turn (the livelock). // // For each of `victims` that belongs to some enemy `e` and sits directly // (orthogonally) next to `e`'s own CURRENT territory (a wall stone // bordering the pocket it helps seal -- exactly the "refillable" shape), // find that bordering pocket's own connected component and count how many // of the active player's own cells (already standing, or `ownCells` -- // this very plan's own placements) fall inside it: that count is exactly // what `e` recaptures the instant they refill the wall stone this plan is // about to remove. Returns the worst (largest) count across every // qualifying victim/territory pair, 0 if none qualify. export const territoryRisk = ( state: game.BoardState, ownCells: readonly game.Position[], victims: readonly game.Piece[], territories: readonly EnemyTerritory[], ): number => { if (victims.length === 0) { return 0; } const myCells = [ ...state.pieces.filter((piece) => piece.player === state.activePlayer).map((piece) => piece.position), ...ownCells, ]; let worst = 0; for (const territory of territories) { for (const victim of victims) { if (victim.player !== territory.player) { continue; } const borderingTerritoryCell = ORTHOGONAL .map((offset) => shift(victim.position, offset)) .find((cell) => inBounds(state.size, cell) && !territory.sea.has(key(cell))); if (!borderingTerritoryCell) { continue; } const component = territoryComponent(state.size, territory.sea, borderingTerritoryCell); const count = myCells.filter((cell) => component.has(key(cell))).length; worst = Math.max(worst, count); } } return worst; }; // LAYER 2, generic validator: chains `game.place` over `plan.empty` (in // order) and reads BOTH `game.pendingCuts` and `game.pendingEncirclements` // off the fully-chained result -- a plan's own empty cells can complete // captures under either rule regardless of which finder proposed it (a // cut template's cells can incidentally close a ring, and vice versa), // so both are always checked, never just the one matching `plan.rule`. // Deliberately does NOT read `game.pendingTerritories`: a sticky-sea // replay is FAR more expensive than the other two rules (see // docs/memories/project/capture-rules.md's Performance section), and // nothing here needs it -- `lib.seaCuts`/`sealMove`/`survivalMove` are // the existing, cheaper-by-construction answer to territory. Returns null // if any placement in the chain is illegal, or if nothing was actually // captured under either rule -- `game.pendingCuts`/`pendingEncirclements` // are the sole source of truth here, never the finder's own estimate. // // `baseline` is everything ALREADY doomed before this plan's own // placements -- by object IDENTITY, not position, since `game.place` // always appends to the SAME `pieces` array (`{ ...state, pieces: // [...state.pieces, piece] }`), so every pre-existing `Piece` reference // stays stable across the whole chain, and a newly captured piece is // simply one that's in the result but wasn't in the baseline by // reference. Deliberately a SINGLE union across every rule, not one // baseline per rule -- a stone already doomed by a pending CUT is // otherwise invisible to a RING baseline computed from // `game.pendingEncirclements(state)` alone, so a ring plan that merely // re-encircles an already-cut stone would report it as a fresh // `ringVictim`, double-counting a capture that was already going to // happen. Found 2026-09-12 by Analyst's review, in exactly this shape: // `seed2328586520` T18's bonus placement at `(5,4)` re-ringed the SAME // `(4,4)` its own cut had just doomed, and the per-rule-baseline version // of this function valued that as if it were a genuine ~100-point // second capture -- see `docs/tasks/2026-09-12/21-cuts-ring-planning.md`'s // "known limitation, corrected" note. Defaults to // `game.pendingCaptures(state)` (already deduped, already excludes home // bases, and already includes territory-doomed stones too, so ANY future // finder --including a territory one -- is automatically covered by the // same baseline with no change here) when the caller doesn't supply one; // `planCaptures` below computes it ONCE per decision and passes the same // set to every candidate's validation, since `pendingCaptures` runs a // sticky-sea replay and must never be paid per candidate. // `territoryVictims`/`eliminates`/`area` are territory-only, added // 2026-09-12 alongside `enclosurePlans` -- always present (empty/0) on a // cut/ring plan's validated result, so callers never need to branch on // `plan.rule` to read a `ValidatedPlan`. export type ValidatedPlan = { readonly position: game.Position; readonly cutVictims: readonly game.Piece[]; readonly ringVictims: readonly game.Piece[]; readonly territoryVictims: readonly game.Piece[]; readonly eliminates: readonly number[]; readonly area: number; }; // Territory's own validator: dispatched by `validatePlan` below rather // than chaining `game.place` (a cut/ring plan's cells are placed one at a // time and re-checked via `game.pendingCuts`/`game.pendingEncirclements`, // but neither of those rules is territory -- and `game.pendingTerritories` // is deliberately never called per-candidate, see `validatePlan`'s own // comment). Mirrors `seaCuts`' own EXACT `game.seaCells` diff instead, // widened from one candidate cell to a whole plan: // // - `plan.empty`'s FIRST `game.remaining(state)` cells (this turn's own // budget) are stamped with `state.turn`, same as a real placement would // be; any remainder is stamped with the active player's own NEXT turn // (`state.turn + state.players.length`) -- sticky sea is order-dependent // (capture-rules.md), so a multi-turn plan's own turn GROUPS have to // stay honest about which cells land this turn versus a future one. // - `surrendered` is exactly what `seaCuts` computes for one cell, widened // to the whole synthetic wall: every cell that was sea before (against // today's live blockers) and isn't sea after (blockers + every synthetic // cell), excluding the plan's own cells themselves (they leave the sea // as blockers, not as surrendered territory). // - Victims are non-base enemy pieces inside `surrendered`, minus // `baseline` (the same already-doomed union `planCaptures` computes // once per decision -- see `validatePlan`'s own comment on why a // baseline must always be a single cross-rule union). `eliminates` is // read the same way `seaCuts` reads it -- a player whose base falls in // `surrendered`. const validateTerritoryPlan = ( state: game.BoardState, plan: CapturePlan, baseline: ReadonlySet, ): ValidatedPlan | null => { if (plan.empty.length === 0) { return null; } const budget = game.remaining(state); const thisTurn = plan.empty.slice(0, Math.max(0, budget)); const nextTurn = plan.empty.slice(thisTurn.length); const synthetic: game.Piece[] = [ ...thisTurn.map((position): game.Piece => ({ position, player: state.activePlayer, turn: state.turn, isHomeBase: false })), ...nextTurn.map((position): game.Piece => ({ position, player: state.activePlayer, turn: state.turn + state.players.length, isHomeBase: false, })), ]; const blockers = ownLiveBlockers(state, state.activePlayer); const beforeSea = game.seaCells(state.size, blockers); const afterSea = game.seaCells(state.size, [...blockers, ...synthetic]); const surrendered = new Set(); for (const cellKey of beforeSea) { if (!afterSea.has(cellKey)) { surrendered.add(cellKey); } } for (const position of plan.empty) { surrendered.delete(key(position)); } if (surrendered.size === 0) { return null; } const territoryVictims = state.pieces.filter( (piece) => piece.player !== state.activePlayer && !piece.isHomeBase && surrendered.has(key(piece.position)) && !baseline.has(piece), ); const eliminates: number[] = []; for (let other = 0; other < state.players.length; other++) { if (other === state.activePlayer) { continue; } const base = ownBase(state, other); if (base && surrendered.has(key(base.position))) { eliminates.push(other); } } if (territoryVictims.length === 0 && eliminates.length === 0) { return null; } return { position: plan.empty[0], cutVictims: [], ringVictims: [], territoryVictims, eliminates, area: surrendered.size, }; }; export const validatePlan = ( state: game.BoardState, plan: CapturePlan, baseline: ReadonlySet = new Set(game.pendingCaptures(state)), ): ValidatedPlan | null => { if (plan.rule === "territory") { return validateTerritoryPlan(state, plan, baseline); } let current = state; for (const cell of plan.empty) { const next = game.place(current, cell); if (!next) { return null; } current = next; } const cutVictims = game.pendingCuts(current).filter( (piece) => !piece.isHomeBase && !baseline.has(piece), ); const ringVictims = game.pendingEncirclements(current).filter( (piece) => !piece.isHomeBase && !baseline.has(piece), ); if (cutVictims.length === 0 && ringVictims.length === 0) { return null; } return { position: plan.empty[0], cutVictims, ringVictims, territoryVictims: [], eliminates: [], area: 0 }; }; // Every empty cell adjacent to any cell in `emptyCells`, checked as a // potential ring TARGET (a piece whose ring includes one of those cells // must itself be within Chebyshev 1 of it) -- bounds the combo search to // `emptyCells.length * 8` lookups per plan rather than scanning every // piece on the board again. // // Part of `planCaptures`' pre-validation ranking (layer 3): a plan's // estimate has to count a ring it would ALSO close, or a cut-plus-ring // combo (the shape Arcadia praised in `seed2328586520` T18 -- see // docs/memories/project/bot-strategies.md) competes as a bare 1-victim // plan and may never reach the top few `planCaptures` actually validates. // Checked against a HYPOTHETICAL board where every one of `emptyCells` is // already an own stone (not just the first one) -- this estimates the // plan's value once fully realized, not just its next placement; // `validatePlan` is still what confirms any of this for real, off the // actual chained board. This can only ever find RING victims (it only // asks "does this ring become fully own"), never a combo CUT -- a cut // finder's own plan already guarantees at least one real cut victim by // construction, so this never needs to look for one. // // `doomed` is the SAME already-doomed union `planCaptures` computes once // per decision (see `validatePlan`'s own comment on why this must be a // union across every rule, not per-rule) -- skipped here so the ESTIMATE // doesn't inflate a plan with a "combo" that would just be re-capturing // something already on its way out. This mirrors `validatePlan`'s own // baseline check, but at the estimate stage rather than after chaining // `game.place`, so a plan that only ever "combos" an already-doomed // target loses that phantom credit before ranking, not just after // validation. const withComboVictims = ( state: game.BoardState, occ: Occupancy, plan: CapturePlan, doomed: ReadonlySet, ): CapturePlan => { if (plan.empty.length === 0) { return plan; } const hyp = new Map(occ); for (const cell of plan.empty) { hyp.set(key(cell), { position: cell, player: state.activePlayer, turn: state.turn, isHomeBase: false }); } const combo = new Map(); for (const cell of plan.empty) { for (const offset of DIRECTIONS) { const target = at(hyp, shift(cell, offset)); if (!target || target.player === state.activePlayer || target.isHomeBase || doomed.has(target)) { continue; } for (const pattern of RING_PATTERNS) { const scan = scanRing(state, hyp, target.position, pattern); if (scan && !scan.blocked && scan.empty.length === 0) { combo.set(key(target.position), target); } } } } if (combo.size === 0) { return plan; } const merged = new Map(plan.estimatedVictims.map((victim) => [key(victim.position), victim])); for (const [k, victim] of combo) { merged.set(k, victim); } return { ...plan, estimatedVictims: [...merged.values()] }; }; // Pre-validation ranking heuristic ONLY (see `estimate` below) -- not the // scale `weight` itself uses. `area` needs some non-zero weight so a big // enclosure doesn't lose the validateCount budget to a pile of small // ring/cut candidates before it's ever actually validated; `eliminates` // needs a large flat bonus so an elimination-capable plan is essentially // always among the top candidates spent on. Neither number appears in a // validated `RankedPlan.value` -- that's still purely `weight(victim, // rule)` summed over ACTUAL victims, exactly as before this section grew // a territory rule; a caller that wants eliminations/area to affect its // own final choice reads `RankedPlan.eliminates`/`.area` directly (see // circles.ts's own `adjustedValue`). const AREA_RANK_WEIGHT = 1; const ELIMINATE_RANK_BONUS = 1000; export type RankedPlan = { readonly position: game.Position; // VALIDATED legal via game.place (validatePlan already placed it) // EVERY cell this plan still needs, not just `position` (its next // placement) -- the same `CapturePlan.empty` its own finder proposed, // carried through unchanged. Added task 23 // (docs/tasks/2026-09-12/23-circles-siege-defence-and-offence.md) so a // caller can build a "what if this whole plan were completed" board // (`lib.withOwnPlacements`) for a siegeCost probe -- `siegeDefence`'s // own defusing check needs this, and task 24's Tentacles siege-progress // scoring will too. A plan half-built this turn (stateless // reconstruction, see `planCaptures`' own header) simply has fewer // cells here on a later call, exactly like `position` already does. readonly cells: readonly game.Position[]; readonly value: number; // caller's weight(...) summed over ACTUAL victims, minus EXPOSURE_PENALTY * this placement's own exposureAfter readonly cutVictims: readonly game.Piece[]; readonly ringVictims: readonly game.Piece[]; readonly territoryVictims: readonly game.Piece[]; readonly eliminates: readonly number[]; // territory only: players this plan eliminates readonly area: number; // territory only: exact surrendered cell count (validatePlan's own figure, not the layer-1 estimate) // How many of the active player's OWN turns this plan needs, given // THIS decision's own `game.remaining` budget: 1 if it fits entirely // within it, otherwise 1 + however many more of `state.placementsPerTurn` // are needed for the remainder. Cut/ring plans are always 1 -- neither // finder ever proposes a plan wider than `options.horizon` (default 0) // beyond budget, and cuts.ts/tentacles.ts never set horizon. readonly turnsNeeded: number; // `lib.interruptions`' count for this plan's cells, MARGINAL to the // decision's own baseline (`interruptions(state, [])`, computed once -- // see planCaptures' own comment for why the raw count is a poor // cross-candidate signal on its own) -- 0 unless `options.rules` // includes `"territory"` (gated so cuts.ts pays nothing extra). readonly interruptions: number; // `lib.territoryRisk`'s own count for this plan's victims -- 0 unless // `options.rules` includes `"territory"`. readonly territoryRisk: number; }; // LAYER 3: strategy-supplied ranking over the generic finders/validator // above. `rules` selects which finder(s) to gather candidates from; // `weight(victim, rule)` is the caller's own value for a victim captured // by a plan of that RULE (a plan's rule stays whatever its finder tagged // it, even after `withComboVictims` adds cross-rule victims onto it -- see // that function's own comment for why a cut-finder plan's combo ring // victim is still weighted as "cut": a cut plan always has at least one // genuine cut victim by construction, and this combo pass can only ever // ADD ring victims, never remove the cut ones, so "does this plan have a // cut victim" and "is this plan cut-ruled" are the same fact); this is // exactly what lets a strategy express "a cut-plus-ring combo is worth as // much as a plain cut, a lone ring is worth less" (see cuts.ts) without // this function knowing anything about cuts or rings itself. // `validateCount` bounds how many top-ranked candidates get the expensive // `validatePlan` treatment -- purely a cost knob, never a correctness // one, since validation is exact either way and raising it can only ever // find MORE plans. // // Stateless by construction, like `openingTriple`: a plan already // half-built this turn is simply a template whose leading cells are // FRESH OWN stones rather than empty (cuts) or already the active // player's own (rings, which allow pre-existing stones), so calling this // again on next turn's placement (or a bonus placement after this one // fires) picks the same template back up with fewer empty cells, with // nothing carried between calls but `state` itself. export const planCaptures = ( state: game.BoardState, options: { readonly rules: readonly CaptureRule[]; readonly weight: (victim: game.Piece, rule: CaptureRule) => number; readonly validateCount: number; // Extra placements BEYOND this turn's own `game.remaining` budget a // plan may still need -- default 0, so `cutPlans`/`ringPlans`-only // callers (cuts.ts) are completely unaffected. Lets a strategy plan a // wall that spans into a future turn (see `RankedPlan.turnsNeeded`) // instead of only ever considering plans it could finish this instant. readonly horizon?: number; }, ): readonly RankedPlan[] => { const budget = game.remaining(state); if (budget <= 0) { return []; } const horizon = options.horizon ?? 0; const wantsTerritory = options.rules.includes("territory"); const occ = occupancy(state); // Computed ONCE per decision, never per candidate -- `game.pendingCaptures` // runs a sticky-sea replay (`pendingTerritories`), the expensive one. // A SINGLE union across every rule, not one baseline per rule -- see // `validatePlan`'s own comment for why a per-rule baseline // double-counts a stone already doomed under a different rule. const doomed = new Set(game.pendingCaptures(state)); const raw: CapturePlan[] = []; if (options.rules.includes("cut")) { raw.push(...cutPlans(state, occ)); } if (options.rules.includes("ring")) { raw.push(...ringPlans(state, occ)); } if (wantsTerritory) { raw.push(...enclosurePlans(state, occ)); raw.push(...oneStoneTerritoryPlans(state)); // task 23: Circles' own base-walling offence -- see siegePlans' own // comment. Reuses this same `horizon` knob (0 while under siege, see // circles.ts's own wiring), so an offensive siege plan is bounded by // the identical budget an enclosure/one-stone plan already is. raw.push(...siegePlans(state, occ, horizon)); } const estimate = (plan: CapturePlan): number => plan.estimatedVictims.reduce((sum, victim) => sum + options.weight(victim, plan.rule), 0) + (plan.area ?? 0) * AREA_RANK_WEIGHT + (plan.eliminates?.length ?? 0) * ELIMINATE_RANK_BONUS; // A plan left with no estimated victims AND no eliminated base is worth // nothing -- this is the parked task-21 fix, generalized to every rule: // a raw finder's own `estimatedVictims` can include a stone some OTHER // already-ranked candidate already dooms this decision (most commonly a // ring around a stone a just-placed cut already dooms), which would // otherwise still rank and spend one of `validateCount`'s validation // slots for nothing -- `validatePlan` correctly returns null for it // (its own baseline dedupes the phantom victim away), but only AFTER // the slot is already spent. Stripping `doomed` from `estimatedVictims` // before ranking -- not just before validating -- means a plan that's // ENTIRELY phantom victims never reaches the top few at all, freeing // that slot for a candidate that can actually validate. See // docs/tasks/2026-09-12/21-cuts-ring-planning.md's "Known limitations" // (recorded there, fixed here) and // docs/tasks/2026-09-12/22-circles-enclosure-planner.md. const isEmptyPlan = (plan: CapturePlan): boolean => plan.estimatedVictims.length === 0 && (plan.eliminates?.length ?? 0) === 0; const candidates = raw .map((plan): CapturePlan => ({ ...plan, estimatedVictims: plan.estimatedVictims.filter((victim) => !doomed.has(victim)), })) .filter((plan) => !isEmptyPlan(plan)) .map((plan) => withComboVictims(state, occ, plan, doomed)) .filter((plan) => plan.empty.length <= budget + horizon) .sort((a, b) => estimate(b) - estimate(a) || a.empty.length - b.empty.length); // Computed ONCE per decision (one game.seaCells replay per enemy), // never per candidate -- see `enemyTerritories`' own comment. Only // gated behind `wantsTerritory`, not computed unconditionally, so // cut/ring-only callers (cuts.ts) pay nothing extra: "Keep the cut/ring // behaviour identical for Cuts" (Analyst's task-22 spec). const territories = wantsTerritory ? enemyTerritories(state) : []; // The baseline interruption count -- how many of the active player's OWN // stones are ALREADY within some enemy's 3-or-fewer-placement reach, // regardless of any candidate plan at all (`interruptions(state, [])`). // Every `RankedPlan.interruptions` below is reported MARGINAL to this // baseline (clamped at 0), not the raw "planned + existing" count // `lib.interruptions` itself returns -- found necessary during // acceptance testing (docs/tasks/2026-09-12/22-circles-enclosure-planner.md): // a dense own cluster is very often ALREADY somewhat interruptible // independent of what's being planned next, and that pre-existing // fragility is roughly CONSTANT across every candidate this decision // considers. Charging it in full against every plan overwhelmingly // penalizes a small, immediately-completable plan (whose own small // value has little room to absorb a large constant) far more, in // proportion, than a large speculative one (whose bigger value absorbs // the same constant easily) -- exactly backwards from "does pursuing // THIS plan make my shape more fragile," which is what a strategy // actually wants to weigh. `lib.interruptions` itself is unchanged // (still the literal "planned + existing" set from Analyst's spec, a // useful generic query on its own); only what `planCaptures` derives // from it for ranking purposes is adjusted. const baselineInterruptions = wantsTerritory ? interruptions(state, []).size : 0; const plans: RankedPlan[] = []; for (const plan of candidates.slice(0, options.validateCount)) { const validated = validatePlan(state, plan, doomed); if (!validated) { continue; } // Deduped by reference, not concatenated: the same Piece can // legitimately satisfy more than one rule at once (independent checks // over the same board), same reasoning as game.pendingCaptures' own // union -- summing weight over a plain concatenation would // double-count it. const victims = [ ...new Set([...validated.cutVictims, ...validated.ringVictims, ...validated.territoryVictims]), ]; const captureValue = victims.reduce((sum, victim) => sum + options.weight(victim, plan.rule), 0); // Exposure of the ACTUAL placement this turn (`validated.position`), // evaluated on the board right after just that one placement -- never // the fully-chained validation board, since only the first cell is // placed for real this turn; the rest may come later (or not at all, // if this plan gets abandoned next call). If THIS placement alone // already completes the plan (`plan.empty.length === 1`), its victims // are already gone by the time an enemy could act on them, so // they're removed from occupancy first (`exposureAfter`); otherwise // nothing is removed yet -- the capture hasn't fired, so scoring as // if it had would understate real risk for a plan that might never // finish. Unchanged from `planCuts`' own behaviour before this // section was widened to rings -- see docs/tasks/2026-09-12/20-...'s // "known limitations" section for a follow-up Analyst flagged but did // not ask for yet (scoring exposure once on the fully-chained board // instead of just after the first cell). const firstStep = game.place(state, validated.position); if (!firstStep) { continue; // unreachable: validatePlan already placed this exact cell successfully } const removed = plan.empty.length === 1 ? new Set(victims.map((victim) => key(victim.position))) : new Set(); const exposure = exposureAfter(firstStep, occupancy(firstStep), validated.position, removed); const turnsNeeded = plan.empty.length <= budget ? 1 : 1 + Math.ceil((plan.empty.length - budget) / state.placementsPerTurn); plans.push({ position: validated.position, cells: plan.empty, value: captureValue - EXPOSURE_PENALTY * exposure, cutVictims: validated.cutVictims, ringVictims: validated.ringVictims, territoryVictims: validated.territoryVictims, eliminates: validated.eliminates, area: validated.area, turnsNeeded, interruptions: wantsTerritory ? Math.max(0, interruptions(state, plan.empty).size - baselineInterruptions) : 0, territoryRisk: wantsTerritory ? territoryRisk(state, plan.empty, victims, territories) : 0, }); } return plans.sort((a, b) => b.value - a.value); }; // Every enemy currently one stone from eliminating the active player, // mirroring `survivalMove`'s own internal scan -- exposed here (rather // than only inline in that function) so a caller holding a // `planCaptures` result can separately ask "are any of this plan's // victims one of the player who's actually threatening me right now," // which is what `siegeBreaker` below does. Lifted 2026-09-12 out of // cuts.ts (originally private there, see that file's own revision log) // into generic substrate so circles.ts can use the identical "prefer // breaking a siege over merely plugging it" idiom without re-deriving it // -- Analyst's task-22 spec calls this "Cuts' siegeBreaker pattern," // lifted rather than copied. export const threateningPlayers = (state: game.BoardState): readonly number[] => { const threats: number[] = []; for (let enemy = 0; enemy < state.players.length; enemy++) { if (enemy === state.activePlayer) { continue; } if (seaCuts(state, enemy).some((cut) => cut.eliminates.includes(state.activePlayer))) { threats.push(enemy); } } return threats; }; // A validated plan whose victims (cut, ring, OR territory -- any of the // three removes a threatening wall's own stone just as well, since an // enemy's sea replay only ever sees LIVE pieces) include a THREATENING // player's own stone within `siegeRadius` of the active player's own // base -- breaking their siege wall rather than merely plugging the one // cell `survivalMove` found (see docs/memories/project/bot-strategies.md's // "plugging vs breaking" note: plugging a single cell is trivially routed // around by a multi-placement opponent next turn, breaking the wall // itself is not). `plans` is already ranked best-value-first (see // `planCaptures`), so this returns the highest-value plan meeting the // condition, or null if none of the (small, validated) candidate set // qualifies. `siegeRadius` is the caller's own idiom-specific constant // (both shipped callers use `SIEGE_RADIUS`), not hard-coded here, so a // future strategy can use a different radius without this function // changing. export const siegeBreaker = ( state: game.BoardState, plans: readonly RankedPlan[], siegeRadius: number, ): RankedPlan | null => { const base = ownBase(state, state.activePlayer); if (!base) { return null; } const threats = threateningPlayers(state); if (threats.length === 0) { return null; } return plans.find((plan) => [...plan.cutVictims, ...plan.ringVictims, ...plan.territoryVictims].some( (victim) => threats.includes(victim.player) && chebyshev(base.position, victim.position) <= siegeRadius, )) ?? null; }; // The generic siege-defence idiom task 23 // (docs/tasks/2026-09-12/23-circles-siege-defence-and-offence.md) built // for Circles and lifted here so Tentacles (task 24) can reuse it // unchanged. Caller contract, given a non-empty `threats` (see // `siegeThreats`): // // 1. (the caller's own job, BEFORE calling this: force `planCaptures`' // `horizon` to 0 for this decision -- a plan spanning a future turn is // moot if the active player might not survive to take it.) // 2. Prefer a DEFUSING plan: one of `plans` (already best-value-first // from `planCaptures`, sliced here to the first 6 -- each check costs // one `siegeCost` flow run per threat, so this is bounded regardless // of how many candidates `planCaptures` itself returns) such that, on // the board AFTER the WHOLE plan lands (`lib.withOwnPlacements`, this // plan's own victims removed, its own cells added as fresh stones), // EVERY threatening enemy's own `siegeCost` against the active player // climbs back out of `cap` reach at once -- not just whichever threat // the plan's own cells happen to touch. // 3. Otherwise, plug greedily: among every threat's own reported min-cut // cell that's still a legal placement right now, take whichever one // raises the SUM of every threat's `siegeCost` the most, recomputed // per trial (occupying one min-cut cell can shift where the next // min-cut even lies, so this is never assumed from the pre-placement // figures alone). // 4. `survivalMove` stays the caller's OWN unconditional fast path, // checked before this function is ever called -- see circles.ts's own // wiring for why: `siegeCost`'s heuristic sink placement can // occasionally fail to CONFIRM a real one-stone kill on a specific // board (that function's own comment), and `survivalMove`'s exact // one-ply `seaCuts` scan has no such heuristic at all. // // Returns null when neither a defusing plan nor any legal plug candidate // exists (rare) -- the caller falls through to its own normal // scoring/fallback ladder either way, with `horizon` already forced to 0 // for this decision regardless of what this function itself returns. export const siegeDefence = ( state: game.BoardState, threats: readonly SiegeThreat[], plans: readonly RankedPlan[], available: readonly Move[], cap: number, random: () => number, ): game.Position | null => { if (threats.length === 0) { return null; } const defusing = plans.slice(0, 6).find((plan) => { const victimKeys = new Set( [...plan.cutVictims, ...plan.ringVictims, ...plan.territoryVictims].map((victim) => key(victim.position)), ); const after = withOwnPlacements(state, plan.cells, victimKeys); return threats.every((threat) => { const recomputed = siegeCost(after, state.activePlayer, threat.enemy, cap); return !(recomputed.confirmed && recomputed.cost <= cap); }); }); if (defusing) { return defusing.position; } const trialCells = new Map(); for (const threat of threats) { for (const cell of threat.siege.cut) { trialCells.set(key(cell), cell); } } const availableKeys = new Set(available.map((move) => key(move.position))); const trials = [...trialCells.values()].filter((cell) => availableKeys.has(key(cell))); if (trials.length === 0) { return null; } const scored = trials.map((cell) => { const after = withOwnPlacements(state, [cell]); const gain = threats.reduce((sum, threat) => { const recomputed = siegeCost(after, state.activePlayer, threat.enemy, cap); return sum + Math.max(0, recomputed.cost - threat.siege.cost); }, 0); return { cell, gain }; }); const maxGain = Math.max(...scored.map((entry) => entry.gain)); const winners = scored.filter((entry) => entry.gain === maxGain); return winners[Math.floor(random() * winners.length)]?.cell ?? winners[0].cell; }; export type Move = { readonly position: game.Position; readonly next: game.BoardState }; // Enumeration is a heuristic filter, not a legality claim: only empty cells // within Chebyshev 1 of some existing piece are considered. An isolated // stone is inert under every rule in this game -- cuts need adjacency // chains, rings need neighbours, territory needs connected walls -- so a // cell far from any stone is never worth a placement. This cuts the // candidate set from the whole board down to a few dozen cells, which is // what makes per-candidate scoring affordable. // // Evaluation goes through game.place: each enumerated cell is mapped // through game.place(state, p) and only the non-null results are kept. // That gives every strategy a real hypothetical BoardState to score // against, the legality check comes free from the rules module with zero // duplicated rules, and the opening turn is handled correctly with no // special case (place enforces base-adjacency itself). // // Known limitation: an occupied-but-legal overlay cell (an empty-looking // slot that actually holds a single doomed enemy stone) is never // enumerated here, since it's excluded by the `occ.has` check below -- // pieceAt/occupancy report the doomed victim, not "empty". A strategy can // therefore see zero candidates and stop even though an overlay placement // is technically still legal. Accepted as a rare edge case none of the // three shipped strategies chase; a future strategy that wants to target // overlays needs its own enumeration. export const moves = (state: game.BoardState): Move[] => { const occ = occupancy(state); const candidates = new Map(); for (const piece of state.pieces) { for (const offset of DIRECTIONS) { const candidate = shift(piece.position, offset); if (!inBounds(state.size, candidate)) { continue; } const candidateKey = key(candidate); if (occ.has(candidateKey) || candidates.has(candidateKey)) { continue; } candidates.set(candidateKey, candidate); } } const result: Move[] = []; for (const position of candidates.values()) { const next = game.place(state, position); if (next) { result.push({ position, next }); } } return result; }; // Picks the maximum-scoring item, breaking ties with `random` so // equal-scoring moves vary between seeds instead of always taking the // first in scan order. Returns null for an empty list. export const bestBy = ( items: readonly T[], score: (item: T) => number, random: () => number, ): T | null => { if (items.length === 0) { return null; } let best = -Infinity; let winners: T[] = []; for (const item of items) { const value = score(item); if (value > best) { best = value; winners = [item]; } else if (value === best) { winners.push(item); } } return winners[Math.floor(random() * winners.length)] ?? winners[0]; }; // Every item tied at the maximum score. Never empty for non-empty input. // Split out from bestBy so a caller can break the tie with something // better than uniform `random` -- see `choose`. export const topBy = (items: readonly T[], score: (item: T) => number): T[] => { let best = -Infinity; let winners: T[] = []; for (const item of items) { const value = score(item); if (value > best) { best = value; winners = [item]; } else if (value === best) { winners.push(item); } } return winners; }; // Narrows `pool` to the items matching `predicate`, but only if that's not // every item -- an empty result would mean "nothing left to choose from," // which is never useful for a ladder step. The unfiltered pool is returned // unchanged instead. const narrow = (pool: readonly T[], predicate: (item: T) => boolean): readonly T[] => { const filtered = pool.filter(predicate); return filtered.length > 0 ? filtered : pool; }; // The shared fallback for "nothing scored, where do I play instead" -- // replaces every bare `bestBy(available, () => 0, random)` that used to // pick uniformly at random across the WHOLE candidate set (every cell // adjacent to ANY piece on the board, not just this player's own), which // is what produced cuts.ts's whole-board scattering // (docs/memories/project/bot-strategies.md). Narrows in order -- each // step only applied if it leaves at least one candidate -- from safest and // most local to "anything legal": // // 1. Not one placement away from arming an enemy's ring (exposure === 0). // 2. Adjacent to one of the player's own stones (stay compact). // 3. Adjacent to BOTH an own stone and an enemy stone (the contested // frontier, where a strategy's own scoring next turn has the most to // work with). // 4. Anything legal, if none of the above leaves a choice. export const fallback = ( state: game.BoardState, occ: Occupancy, pressure: ReadonlyMap, available: readonly Move[], random: () => number, ): Move | null => { const safe = narrow(available, (move) => (pressure.get(key(move.position))?.exposure ?? 0) === 0); const nearOwn = narrow(safe, (move) => hasOwnNeighbour(state, occ, move.position)); const contested = narrow( nearOwn, (move) => hasOwnNeighbour(state, occ, move.position) && hasEnemyNeighbour(state, occ, move.position), ); return bestBy(contested, () => 0, random); }; // The standard selection for a scored strategy: take a maximum-scoring // move, and among moves tied at that maximum prefer the safest and most // local via the fallback ladder. Replaces the earlier "take it only if the // score is positive, else fall back" shape used by circles.ts/cuts.ts, // which could discard a strictly better move: with EXPOSURE_PENALTY // folded into every score, a real signal (offence 10, exposure 1 -> -20) // can still be negative and would previously have been thrown away in // favour of a random pick from `fallback`, which can't see score at all. // The exposure penalty already does the gating on its own -- a safe, // worthless cell (score 0) beats an exposed one with the same non-zero // signal (score < 0) automatically, because bestBy/topBy take the max // unconditionally. There is therefore only one selection mechanism here, // not two. Found by Seer's review 2026-09-10. export const choose = ( state: game.BoardState, occ: Occupancy, pressure: ReadonlyMap, scored: readonly { move: Move; score: number }[], random: () => number, ): Move | null => { if (scored.length === 0) { return null; } const winners = topBy(scored, (entry) => entry.score).map((entry) => entry.move); return fallback(state, occ, pressure, winners, random); }; // --- Sea-cut selection ------------------------------------------------------ // // Two shared uses of `seaCuts` on top of a strategy's own scoring, applied // in this order (survival before seal, both before a strategy's own // idiom): losing the base ends the game, so denying a kill cell always // outranks everything else, including a strategy's own offence; a // large-enough seal of my own is next, since it's Arcadia's actual // winning mechanism (see docs/memories/project/bot-strategies.md's "Two // regimes" section) and no shipped strategy otherwise participates in // territory at all. Below SEAL_THRESHOLD, a strategy plays its own idiom // unchanged -- see SEAL_THRESHOLD's own comment for why that floor // exists. // Below this, a strategy ignores an available seal and plays its own // idiom instead. Deliberately NOT zero: taking every seal, however small, // the moment it's available would make all three strategies converge on // the same territory-chasing behaviour and lose what's actually on-vision // here (docs/vision.md) -- three visually distinct shapes, not one // optimal one. Start at 6; a tuning knob Arcadia will want to move once // she's seen it play. Asymmetric with survival on purpose: there is no // "too small" threshold for not losing the game outright. export const SEAL_THRESHOLD = 6; // The shared reducer behind both survivalMove and sealMove: match each // SeaCut's position against a currently legal move (a SeaCut candidate // can name a cell that's occupied by another player's stone and // therefore illegal to close right now -- sea membership doesn't imply // legality, only `available`/game.place does), then take the // largest-`surrendered` survivor, random-tie-broken. const bestSeaCutMove = ( cuts: readonly SeaCut[], available: readonly Move[], random: () => number, ): Move | null => { const availableByKey = new Map(available.map((move) => [key(move.position), move])); const matched: { cut: SeaCut; move: Move }[] = []; for (const cut of cuts) { const move = availableByKey.get(key(cut.position)); if (move) { matched.push({ cut, move }); } } if (matched.length === 0) { return null; } const winners = topBy(matched, (entry) => entry.cut.surrendered); return winners[Math.floor(random() * winners.length)]?.move ?? winners[0].move; }; // Is any enemy one stone from eliminating me right now? If so, and a // legal move denies it, take that move -- unconditional, no threshold, // and meant to be checked before anything else a strategy considers. export const survivalMove = ( state: game.BoardState, available: readonly Move[], random: () => number, ): Move | null => { const threats: SeaCut[] = []; for (let enemy = 0; enemy < state.players.length; enemy++) { if (enemy === state.activePlayer) { continue; } for (const cut of seaCuts(state, enemy)) { if (cut.eliminates.includes(state.activePlayer)) { threats.push(cut); } } } return bestSeaCutMove(threats, available, random); }; // My own best closing move, if it clears SEAL_THRESHOLD -- see that // constant's own comment for why the floor exists. export const sealMove = ( state: game.BoardState, available: readonly Move[], random: () => number, ): Move | null => { const seals = seaCuts(state, state.activePlayer).filter((cut) => cut.surrendered >= SEAL_THRESHOLD); return bestSeaCutMove(seals, available, random); }; // A reusable "opening triple" chooser: given a set of candidate 3-cell // patterns (offsets from the strategy's own base), picks one pattern on // the first call of an opening turn and sticks with it across the turn's // remaining calls by reconstructing the choice from state alone -- // nextPlacement is called once per placement with no memory of its own, so // the pattern choice has to be recoverable from the board rather than // redrawn each call (which would let the pattern change mid-turn as // `random` advances). Returns null if there's no own base, or the chosen // pattern's next unplaced cell isn't legal (caller falls back to // `lib.moves`). export const openingTriple = ( state: game.BoardState, patterns: readonly (readonly Offset[])[], random: () => number, ): game.Position | null => { const base = ownBase(state, state.activePlayer); if (!base) { return null; } const placed = state.pieces.filter( (piece) => piece.player === state.activePlayer && piece.turn === state.turn && !piece.isHomeBase, ); // Matched against EVERY placed cell, not just the first: patterns can // share a leading offset (e.g. two of cuts.ts's four sides both start // at the base's NW corner), so matching only placed[0] can resolve to // the wrong pattern once a second stone disambiguates it, silently // collapsing several patterns into one and making others unreachable. // Confirmed by Seer's review 2026-09-08 against cuts.ts's OPENING_SIDES. const patternContains = (candidate: readonly Offset[], piece: game.Piece): boolean => candidate.some((offset) => { const cell = shift(base.position, offset); return cell.row === piece.position.row && cell.col === piece.position.col; }); const pattern = placed.length === 0 ? patterns[Math.floor(random() * patterns.length)] : (patterns.find((candidate) => placed.every((piece) => patternContains(candidate, piece))) ?? patterns[0]); const cells = pattern.map((offset) => shift(base.position, offset)); const next = cells.find( (cell) => !placed.some((piece) => piece.position.row === cell.row && piece.position.col === cell.col), ); return next && game.place(state, next) ? next : null; };