// bot/circles.ts -- "always tries to do encirclings": plans deliberate // ring AND territory captures via lib.planCaptures, falling back to a // shallow local offence/defence/exposure scoring pass only when no plan is // available. No special-cased capture geometry here -- game.pendingCuts/ // game.pendingEncirclements/the game.seaCells replay behind a territory // plan (all via lib.planCaptures' validation) are the only source of truth // for what counts as a capture; this file only supplies the WEIGHTS (see // lib.ts's "Capture planning" section for the layer split this rests on). // // A strategy must never voluntarily pass: nextPlacement only returns null // when lib.moves finds no legal candidate at all. // // Revision log: // - 2026-09-08: initial version, designed by Seer. // - 2026-09-10: scoring moved to the shared lib.ringPressure (offence + // defence − EXPOSURE_PENALTY × exposure) and the bare random fallback // replaced with lib.fallback, designed by Seer. Along the way, Seer's // review found the original scoreMoves' own===2/own===1 branches never // checked that the OTHER ring cells were empty (rather than a third // player's stone) before scoring -- fixed by lifting the check into // lib.ts's offenceScores (own + empty === ring.length). See // docs/memories/project/bot-strategies.md for the transcript evidence // this responds to. // - 2026-09-10: dropped the "take the best score only if positive, else // fall back" gate in favour of lib.choose -- with EXPOSURE_PENALTY in // the score, the best available score can be negative while still // being the right move (a real offence/defence signal outweighed only // by exposure), and the old gate discarded it in favour of a random // pick from lib.fallback, which can't see score at all. Found by // Seer's review 2026-09-10. // - 2026-09-10 (Pass 2): wired in lib.survivalMove/lib.sealMove ahead of // Circles' own scoring -- deny an enemy's one-stone kill cell against // my own base unconditionally, then take my own seal if it clears // SEAL_THRESHOLD. Designed by Seer: no shipped strategy previously // participated in territory at all, which is Arcadia's actual winning // mechanism against them (docs/memories/project/bot-strategies.md's // "Two regimes" section). Below SEAL_THRESHOLD, Circles plays // unchanged -- rings stay its own idiom. // - 2026-09-12 (Circles enclosure planning -- Analyst's design, approved // by Arcadia; see docs/tasks/2026-09-12/22-circles-enclosure-planner.md // for the full brief and acceptance evidence): rewritten around // lib.planCaptures over TWO rules (`"ring"`, `"territory"`), replacing // the old local-pressure-only scoring entirely. Arcadia's brief: Circles // should "look for LARGER opportunities, plan its moves, balance setting // up an encirclement threat against building shapes that can be // interrupted, and be the bot that most aggressively encircles its // opponents directly" -- see // docs/memories/project/bot-strategies.md's "bot identities" note (rough // parity with Cuts, not converging on Cuts' own idiom). // // Two real bugs in the OLD scoring, found by Analyst reviewing 13 real // games (Circles lost every one, every capture a 1-stone ring or a // crumb): // // 1. THE SAME SCALE BUG CUTS HAD (see cuts.ts's own 2026-09-12 entries). // `lib.ringPressure`'s `weightFor` scores a ring 2-of-4 done at 10, // 3-of-4 at 100, against `EXPOSURE_PENALTY` (30) -- a ring two stones // from done scored `10 − 30×exposure < 0` almost always, so Circles // only ever completed a ring the enemy walked into, never built one // from nothing. Fixed the same way task 21 fixed it for Cuts: switch // from a local pressure HEURISTIC to `lib.planCaptures`' validated // plans, scored on `lib.CUT_WEIGHT`'s scale (100 per victim). This // alone recovers the "ring two 2-of-4s that share a member" shape // Analyst's evidence #1 named (`seed660896122`, before Circles' T7) -- // `lib.ringPlans` (task 21's own generic finder) already enumerates // both partial rings; Circles just never asked `planCaptures` to // validate and rank them before this pass. // 2. NO TERRITORY PARTICIPATION AT ALL (`sealMove` only ever fired above // a flat `SEAL_THRESHOLD` on a bare one-ply `seaCuts` closing move -- // Circles never PLANNED a multi-stone wall). New substrate in // lib.ts's "Capture planning" section: `enclosurePlans` proposes // disc-shaped wall candidates around enemy targets (round walls read // as circles, on-vision per docs/vision.md), validated exactly via // `game.seaCells`; `sealMove` is DROPPED as a separate step here, // since its own `seaCuts` candidates now flow through // `planCaptures` as exact 1-cell territory plans // (`lib.oneStoneTerritoryPlans`) instead. // // Wiring (every number below is Analyst's own tuning call, not derived // from any rule -- see this file's own constants for the exact values): // // - `rules: ["ring", "territory"]`, deliberately NOT `"cut"` -- Circles // should not converge on Cuts' own idiom (docs/vision.md wants three // visually distinct shapes, matching task 21's identical reasoning for // why Cuts values a lone ring at half a cut). // - `horizon: HORIZON` lets a plan span into a NEXT turn // (`RankedPlan.turnsNeeded`), since a real enclosure often needs more // stones than one turn's own budget -- "plan its moves" from Arcadia's // brief, not just react to what's completable this instant. // - A victim (ring OR territory, no distinction -- unlike Cuts, which // values a cut higher than a lone ring, Circles has no second rule to // prefer over the other) is worth `lib.CUT_WEIGHT` (100), doubled // within `lib.SIEGE_RADIUS` of Circles' own base (defence, the same // siege idiom cuts.ts uses); eliminating an enemy's base is worth // `ELIMINATE_WEIGHT` (1000) -- the aggression Arcadia explicitly // asked for; each surrendered cell is worth `AREA_WEIGHT` (5) -- // "larger opportunities" should outrank a bare victim count once two // plans are otherwise close. None of these three enter // `RankedPlan.value` itself (that stays purely `weight(victim, rule)` // summed over ACTUAL victims, per lib.ts's own comment) -- Circles // computes its own final `adjustedValue` from `RankedPlan`'s exposed // `eliminates`/`area`/`interruptions`/`territoryRisk`/`turnsNeeded` // fields instead, since those last three have no equivalent in Cuts' // own scoring at all. // - `adjustedValue` also subtracts `INTERRUPTION_PENALTY (60) × // plan.interruptions` (how many of my own stones, planned or // standing, an enemy could capture within their own next 3 // placements -- `lib.interruptions`, "balance setting up a threat // against building shapes that can be interrupted" from Arcadia's own // brief) and `TERRITORY_RISK_PENALTY (100) × plan.territoryRisk` // (`lib.territoryRisk`, the reseal-livelock guard -- see that // function's own comment for the mechanism it catches), then HALVES // the whole total when `plan.turnsNeeded > 1` -- a plan that needs a // future turn is worth less now than an equally-scored plan // completable immediately, since it can be disrupted before Circles // ever gets back to it. // - Survival still takes precedence unconditionally, same as // cuts.ts/tentacles.ts, but now prefers `lib.siegeBreaker` (lifted out // of cuts.ts this same pass into shared substrate, widened to check // territory victims too) over merely plugging the gap // `lib.survivalMove` found -- the identical "break the siege, don't // just plug it" idiom cuts.ts already uses // (docs/memories/project/bot-strategies.md's "plugging vs breaking" // note). // - The fallback ladder is computed UNCONDITIONALLY (task-21's own // "one max over both sources" gate, not the "> 0, else fall back" // anti-pattern lib.choose's own comment warns against), and now also // subtracts `TERRITORY_RISK_PENALTY × lib.territoryRisk(state, // [cell], [], territories)` for a bare fallback cell -- with no // capture involved this is definitionally 0 under `territoryRisk`'s // own "victims.length === 0 -> 0" rule (see that function's // comment), so this term is currently a no-op, kept only for shape // symmetry with the plan-scoring formula above; revisit if a future // transcript shows a fallback-specific territory risk worth modelling // separately. // - 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 // (`lib.survivalMove`) -- Arcadia's actual winning walls are typically // exactly her own `placementsPerTurn` (3) stones from closing, which a // one-ply check cannot see coming at all. Wired in `lib.siegeThreats`/ // `lib.siegeDefence` (new generic substrate, see lib.ts's own revision // log): each decision, `siegeThreats` asks whether any live enemy has a // CONFIRMED `lib.siegeCost` against this base within // `state.placementsPerTurn` -- if so, `horizon` is forced to 0 for this // decision (a plan spanning a future turn is moot if this base might // not survive to take it) and, after `survivalMove`'s own one-ply fast // path (kept unconditional -- see its own comment on why), a // `siegeDefence` call either finds a validated plan that DEFUSES every // current threat at once or, failing that, plugs whichever min-cut cell // raises the total siege cost the most. Circles' own offence side needs // no separate wiring here at all: `lib.siegePlans` (also new, lib.ts) // already flows into `planCaptures`' existing `"territory"` gathering, // so a confirmed base-walling cut simply shows up as another territory // plan, ranked by this file's own existing `ELIMINATE_WEIGHT`. import * as game from "../game.js"; import * as lib from "./lib.js"; export const id = "circles"; export const name = "Circles"; // A compact corner cluster (an L), not a line -- rings need clustered // stones around a target, not a spread-out wall. const OPENING_CLUSTERS: readonly (readonly lib.Offset[])[] = [ [{ row: -1, col: -1 }, { row: -1, col: 0 }, { row: 0, col: -1 }], // NW [{ row: -1, col: 1 }, { row: -1, col: 0 }, { row: 0, col: 1 }], // NE [{ row: 1, col: -1 }, { row: 1, col: 0 }, { row: 0, col: -1 }], // SW [{ row: 1, col: 1 }, { row: 1, col: 0 }, { row: 0, col: 1 }], // SE ]; // Plans this expensive-relative-to-estimating to actually verify -- each // one costs a game.seaCells replay (or a game.place chain) plus, for a // territory-aware caller, an interruptions/territoryRisk pass -- only the // best few by estimate get spent on. Not a correctness knob: raising it // can only ever find MORE plans, never wrong ones, since validation is // exact either way. const VALIDATE_COUNT = 6; // One extra own turn's worth of placements a plan may need beyond this // decision's own budget -- "plan its moves" from Arcadia's brief, not // just react to what's completable instantly. A tuning knob, not derived // from any rule; Analyst's own call for this pass. const HORIZON = 3; const ELIMINATE_WEIGHT = 1000; const AREA_WEIGHT = 5; const INTERRUPTION_PENALTY = 60; const TERRITORY_RISK_PENALTY = 100; // Circles' own value for a victim -- see this file's revision log for the // exact rationale. Ring and territory victims are worth the SAME flat // amount (unlike cuts.ts, which values a lone ring at half a cut): Circles // has no second rule to prefer over the other here, both are its own // idiom. const captureWeight = ( base: game.Piece | undefined, ) => (victim: game.Piece, _rule: lib.CaptureRule): number => lib.CUT_WEIGHT * (base && lib.chebyshev(base.position, victim.position) <= lib.SIEGE_RADIUS ? 2 : 1); // Circles' own final value for a validated plan, layering the elimination // bonus, area bonus, interruption penalty, and territory-risk penalty on // top of `RankedPlan.value` (which is purely `weight(victim, rule)` // summed over actual victims minus `EXPOSURE_PENALTY × exposure` -- // see lib.ts's own comment on why those don't already live there), then // halving the total for a plan that spills into a future turn. See this // file's revision log for the exact rationale behind each term. const adjustedValue = (plan: lib.RankedPlan): number => { const total = plan.value + plan.eliminates.length * ELIMINATE_WEIGHT + plan.area * AREA_WEIGHT - INTERRUPTION_PENALTY * plan.interruptions - TERRITORY_RISK_PENALTY * plan.territoryRisk; return plan.turnsNeeded > 1 ? total * 0.5 : total; }; // Among plans tied at the maximum adjusted value, tie-break by `random`, // matching the tie-break style used throughout lib.ts. const pickPlan = ( plans: readonly lib.RankedPlan[], random: () => number, ): { readonly plan: lib.RankedPlan; readonly value: number } | null => { if (plans.length === 0) { return null; } const scored = plans.map((plan) => ({ plan, value: adjustedValue(plan) })); const maxValue = Math.max(...scored.map((entry) => entry.value)); const tied = scored.filter((entry) => entry.value === maxValue); return tied[Math.floor(random() * tied.length)] ?? tied[0]; }; export const nextPlacement = ( state: game.BoardState, random: () => number, ): game.Position | null => { const available = lib.moves(state); if (available.length === 0) { return null; } const occ = lib.occupancy(state); const pressure = lib.ringPressure(state, occ, available.map((move) => move.position)); if (game.isOpeningTurn(state)) { return lib.openingTriple(state, OPENING_CLUSTERS, random) ?? lib.fallback(state, occ, pressure, available, random)?.position ?? null; } // Task 23 (docs/tasks/2026-09-12/23-circles-siege-defence-and-offence.md): // am I under a real, multi-placement siege right now -- a wall sitting // at exactly an enemy's own `placementsPerTurn` from closing my base, // Arcadia's actual winning shape, which a one-ply check (survivalMove // below) cannot see coming at all? `SIEGE_DEFENCE_CAP` is // `state.placementsPerTurn` itself, not a hardcoded 3 -- "how many // stones can the threatening player place in ONE turn" is the actual // question, and this stays correct if that ever differs from the // default. const SIEGE_DEFENCE_CAP = state.placementsPerTurn; const threats = lib.siegeThreats(state, state.activePlayer, SIEGE_DEFENCE_CAP); // Under siege: force horizon 0 for this decision -- a plan spanning a // future turn is moot if this base might not survive to take it. const plans = lib.planCaptures(state, { rules: ["ring", "territory"], weight: captureWeight(lib.ownBase(state, state.activePlayer)), validateCount: VALIDATE_COUNT, horizon: threats.length > 0 ? 0 : HORIZON, }); // Survival first, unconditional -- but prefer BREAKING the threatening // player's own siege wall over merely plugging the closing cell // lib.survivalMove found, same idiom as cuts.ts (see this file's // revision log's "plugging vs breaking" note). Kept as the COST-1 fast // path even though lib.siegeDefence below also covers a siege at cost // 2-3: siegeCost's own 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. const survival = lib.survivalMove(state, available, random); if (survival) { const breaker = lib.siegeBreaker(state, plans, lib.SIEGE_RADIUS); return breaker?.position ?? survival.position; } // The broader siege defence (task 23): a wall 2-3 placements from // closing. Prefers a validated plan that defuses every current threat // at once over merely plugging the highest-value min-cut cell -- see // lib.siegeDefence's own comment for the exact contract. if (threats.length > 0) { const defence = lib.siegeDefence(state, threats, plans, available, SIEGE_DEFENCE_CAP, random); if (defence) { return defence; } } // Fallback/safety scoring -- computed UNCONDITIONALLY, not only when no // plan exists, so it can be compared against a plan's own adjusted // value below rather than treated as a last resort (task-21's "one max // over both sources" gate). `territories` is computed once here, never // per candidate cell -- see lib.enemyTerritories' own comment. const territories = lib.enemyTerritories(state); const scored = available.map((move) => { const p = pressure.get(lib.key(move.position)); const risk = lib.territoryRisk(state, [move.position], [], territories); const score = (p?.defence ?? 0) - lib.EXPOSURE_PENALTY * (p?.exposure ?? 0) - TERRITORY_RISK_PENALTY * risk; return { move, score }; }); const bestFallbackScore = scored.reduce((max, entry) => Math.max(max, entry.score), -Infinity); // The primary mechanism: lib.planCaptures already walked every possible // ring/territory candidate on the current board (plus one turn's worth // of horizon) and validated the best few via game.place/game.seaCells, // ranked by this file's own weighting. A returned plan's position is // itself the result of a successful validation, so it needs no // re-checking against `available` here. const picked = pickPlan(plans, random); if (picked && picked.value >= bestFallbackScore) { return picked.plan.position; } // lib.choose takes the max score unconditionally rather than gating on // "> 0, else fall back" -- see lib.ts's own comment for why that gate // would discard a strictly better move once EXPOSURE_PENALTY is in the // score. Seer's review 2026-09-10. return lib.choose(state, occ, pressure, scored, random)?.position ?? null; };