Something went wrong. Try again.
A Go-like game designed to be playe by two-to-eight players competing for territory!
Something went wrong. Try again.
149 kB · 3145 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146// 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<string, game.Piece>;
// 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<string, game.Piece>(); 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<string, number>, 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<string, number> => { const scores = new Map<string, number>(); 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<string, number> => { const scores = new Map<string, number>(); 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<string>,): 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<string, CellPressure> => { const offence = offenceScores(state, occ); const defence = defenceScores(state, occ); const pressure = new Map<string, CellPressure>(); 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<string>, 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<string>(); 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<string>(); 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<string>, cap: number, far: number,): { readonly cost: number; readonly cut: readonly game.Position[] } => { const nodeOf = new Map<string, number>(); 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<number>(adjacency.length).fill(-1); const visited = new Array<boolean>(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<boolean>(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<string>; 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<string>): 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<string>(); 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<string> = 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<string, game.Piece>(); 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<string>(); 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<string>): game.Position[] => { const boundary = new Map<string, game.Position>(); 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<string>): 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<string>(); 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<string>(); for (const target of enclosureTargets(state, occ)) { const base = centroid(target.cells); const rimOffset = towardNearestRim(state.size, base); const centreCandidates = new Map<string, game.Position>(); 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<string>(); 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<string>(); 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<string> => { 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<string>(); 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<string>;};
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<string>, start: game.Position,): ReadonlySet<string> => { const component = new Set<string>(); 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<game.Piece>,): 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<string>(); 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<game.Piece> = 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<game.Piece>,): 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<string, game.Piece>(); 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<string>(); 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<string, game.Position>(); 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<string, game.Position>(); 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 = <T>( 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 = <T>(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 = <T,>(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<string, CellPressure>, 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<string, CellPressure>, 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;};