// bot/cuts.ts -- "always tries to do cuts": builds a line of its own stones // out from its base, then plans cuts (and, since 2026-09-12, rings too) via // lib.planCaptures, falling back to a shallow immediate-gain/safety scoring // pass only when no plan is available. No special-cased capture geometry // here -- game.pendingCuts/game.pendingEncirclements (via lib.planCaptures' // game.place 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. A pass while legal moves // exist is a bug the driver's idle guard catches (see bot.ts / main.ts), // never a game outcome -- Arcadia explicitly rejected an all-pass // termination rule (docs/memories/project/bot-strategies.md). // // Revision log: // - 2026-09-08: initial version, designed by Seer. // - 2026-09-08: Seer's review caught the lookahead prefilter defaulting to // an arbitrary, spatially-biased top-K whenever every move tied at 0 // immediate score (the common case early/mid game) -- now samples the // lookahead pool randomly in that case instead of always taking the // first K in enumeration order. // - 2026-09-08: Seer's review measured ~230ms/decision on a 195-piece // board (game.place is expensive -- see setupValue's own comment) and // found a pattern-reconstruction collision in lib.openingTriple; both // fixed. setupValue's search narrowed to SETUP_RADIUS and // LOOKAHEAD_CANDIDATES lowered 8->4, bringing the same measurement to // ~114ms (residual cost is game.place's own per-call overhead, flagged // for Arcadia in docs/tasks/2026-09-08/18-local-bot-players.md, not // fixable from here). // // Strategic observation from the same review, not acted on at the time: // a cut needs three fresh stones, so the one-ply lookahead that used to // live here could only ever see a completion once TWO fresh stones were // already down -- Cuts played essentially blind for two thirds of every // turn. The cheaper direction flagged then, acted on 2026-09-12 below: a // GEOMETRIC heuristic instead of more search. // - 2026-09-10: the "no real immediate signal" tie-break used to fall // through to `bestBy(available, ...)` uniformly at random over the // WHOLE candidate set -- every cell adjacent to ANY piece on the board, // not just this bot's own -- which is what produced `seed3846449704`'s // T5 `(1,12) (14,10) (3,9)` from a base at (12,3): 33 stones placed for // 0 captures before elimination at T34. Replaced with lib.fallback (an // exposure-aware, own-stone-local ladder) whenever the best scored move // isn't actually positive. Score also now subtracts // `EXPOSURE_PENALTY × exposure` (via lib.ringPressure) so a placement // isn't chosen purely for cut/setup value while ignoring that it arms // an enemy's ring. Designed by Seer -- see // docs/memories/project/bot-strategies.md for the transcript evidence. // Same pass, Seer's review caught two follow-on bugs: the lookahead // pool's `bestImmediate` check was folding the exposure penalty into // the value it ranked pool candidates by, which could silently starve // the ONLY mechanism that starts a cut from nothing whenever every // candidate carried some exposure -- split into a raw `gain` (pool // selection) vs exposure-adjusted `score` (final choice); and the // final "best score, but only if positive" gate could discard a // strictly better negative-scoring move in favour of a random pick -- // replaced with lib.choose (see lib.ts's revision log). // - 2026-09-10 (Pass 2): wired in lib.survivalMove/lib.sealMove ahead of // Cuts' own cut/setup scoring. Designed by Seer specifically because // Cuts is the one strategy that already earns bonus placements // (`seed4182901394` T101: 25 placements off 22 cut victims, previously // scattered for nothing) -- with a seal available, those same extra // placements now go into wall instead, which is exactly Arcadia's own // winning pattern (cut bonuses funding wall construction), arrived at // by composition rather than special-cased: bot.ts's takeTurn just // calls nextPlacement again per bonus, no branch needed here for // "I have bonus placements now." See // docs/memories/project/bot-strategies.md's "Two regimes" section. // - 2026-09-12: Analyst reviewed four of Arcadia's 1v1 games against Cuts // played the same day (`docs/games/2026-09-12T14-0{3,7,8}*`) -- Cuts // made ZERO cuts across all four despite a wall-breaking cut being // available on essentially every one of its turns, and lost every game // by T8-T14 to thin diagonal zigzag walls, the easiest shape in the // game to cut. Root cause was a scale mismatch, not a missing feature: // the old score was `10 * gain - EXPOSURE_PENALTY(30) * exposure`, and // a cut's own cells always sit directly next to the enemy stones they // just captured, so `exposure` read 2-3 almost every time -- a genuine // 2-capture cut scored `20 - 60` to `20 - 90` (`-40` to `-70`, depending // on whether exposure read 2 or 3) and lost to any safe, worthless // cell. Two fixes, both designed by Analyst and specified in // docs/tasks/2026-09-12/20-cuts-planner-and-transcript-fixes.md: // // 1. Scores are now on lib.CUT_WEIGHT's scale (100 per non-base // captured stone, matching lib's own weightFor(3)), and a gain>0 // move's exposure is evaluated via lib.exposureAfter with this // turn's own cut victims removed from occupancy first -- those // victims are gone by the time an enemy could ever act on them, so // scoring against the pre-cut board overstated exposure specifically // for the moves this file most needs to recognise as good. // 2. The random-pool one-ply lookahead (`setupValue`/ // `pickRandomSubset`, both removed) is replaced by a geometric // planner (originally `lib.planCuts`, cut-only) that walks every // possible 3-stone cut template on the CURRENT board and validates // the top few by actually chaining game.place, rather than // discovering a cut only after two of its three stones happen to // already be down. This is what actually fixes the root strategic // gap the 2026-09-08 entry above flagged and didn't act on: Cuts can // now recognise and start an entire cut from nothing on its very // first placement of a turn. Pre-existing own stones sitting in a // template's own cells correctly block it rather than satisfying it, // matching game.ts's own rule exactly -- this isn't a bug fix (a // game.place-based lookahead obeys the identical rule regardless), // but it is a strategic lesson the planner makes newly visible: 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. A filler placed before any cut // plan exists can poison one for good. // // Also: when lib.survivalMove finds a threat, Cuts now prefers a // validated plan that captures the threatening player's own stones // within lib.SIEGE_RADIUS of this base over merely plugging the closing // cell -- an enemy's stones don't block ITS sea, so a plug is trivially // routed around by a 3-placement opponent next turn (see // docs/memories/project/bot-strategies.md's "plugging vs breaking" // note); plugging remains the fallback when no such plan exists. // lib.survivalMove itself is unchanged -- Tentacles and Circles still // plug unconditionally. // - 2026-09-12 (same day, Analyst's re-review): `planCuts`' value ignored // exposure on plan moves entirely -- fixed to subtract // EXPOSURE_PENALTY * exposureAfter for the actual cell placed this // turn, evaluated on the board right after just that one placement // (mirroring the immediate-gain branch below exactly): if that one // placement alone completes the plan, its victims are removed from // occupancy first (they're already gone by the time an enemy could act // on them); otherwise nothing is removed, since the capture hasn't // fired yet and scoring as if it had would understate real risk for a // plan that might never finish. This logic now lives in lib.ts's // `planCaptures` (see the entry below), not here, since it's shared by // every rule a plan can capture under. // - 2026-09-12 (same day, at Arcadia's request): Cuts now ALSO seeks // rings, not just cuts, after she called a Cuts turn "brilliantly done" // (`seed2328586520` T18 -- a cut that incidentally closed two rings // around her own stones and denied her a wall repair, all in one turn; // see docs/memories/project/bot-strategies.md's note). `lib.planCuts` // was generalised into `lib.planCaptures` over two generic finders // (`lib.cutPlans`/`lib.ringPlans`, see lib.ts's "Capture planning" // section) at her explicit requirement that rule RECOGNITION -- "what // can I cut," "what can I ring" -- be strategy-free substrate any bot // could adopt, with strategies differing only in how they WEIGH what // the finders return. This file supplies that weighting (`captureWeight` // below) and nothing else; Circles and Tentacles are untouched by this // pass and still don't use lib.ringPlans at all. // // Cuts' own tuning (a knob, not derived from any rule -- Analyst's // call, approved by Arcadia): a victim is worth lib.CUT_WEIGHT (100) // whether it's captured by the cut itself or by a ring that the SAME // plan closes (a cut-plus-ring combo, or a plain cut, both score the // same per victim); a victim of a RING-ONLY plan (no cut victims at // all) is worth half that (`RING_ONLY_WEIGHT`, 50). Rationale: Cuts // should prefer combos and plain cuts, and take a lone ring only when // nothing better is available, so it doesn't converge on Circles' // behaviour (docs/vision.md wants three visually distinct shapes, not // one optimal one). The ×2 siege bonus (lib.SIEGE_RADIUS of this base) // is unchanged, just moved from lib.ts's old cut-only estimate into // this file's own weight function, since siege preference is a Cuts // idiom, not a rule. At equal `value`, `pickPlan` below prefers a plan // with at least one real cut victim over a ring-only one -- only cuts // earn bonus placements (lib.CutPlan's old comment; still true of // lib.RankedPlan's cutVictims), so an otherwise-tied plan that also // funds more placements this turn is strictly more useful. `siegeBreaker` // now checks ring victims too, not just cut victims -- ringing one of a // threatening wall's own stones breaks it exactly as well as cutting it // does (an enemy's sea replay only ever sees LIVE pieces; either // capture removes the stone just the same). // // See docs/tasks/2026-09-12/21-cuts-ring-planning.md for the full design // and acceptance evidence. // - 2026-09-12 (same day, Analyst's re-review of the ring-planning pass // above): the fallback-vs-plan comparison below now takes ONE max over // both `pickPlan(plans, random)` and the fallback `scored` ladder, // rather than always preferring any plan that exists. A // `RING_ONLY_WEIGHT` (50) plan can score negative once its own // exposure is subtracted (50 - 60 = -10 at exposure 2), which used to // still beat a safe 0-scoring fallback cell simply because a plan // existed at all -- harmless while every plan was a cut (100 minus at // most 90 is still positive), a real bug once a lone ring could be // negative. This is deliberately NOT the "> 0, else fall back" // anti-pattern `lib.choose`'s own comment warns against (that gate // would wrongly discard a real signal like `-10` in favour of a worse // `-30` fallback); it's the same "max over every source" shape // `lib.choose` already uses, just comparing a plan's value against the // fallback ladder's own best score instead of a bare threshold. // Unrelated fix in the SAME pass, entirely inside lib.ts (see that // file's own revision log): `lib.validatePlan`/`lib.planCaptures` used // to diff cut and ring victims against SEPARATE per-rule baselines, // so a stone already doomed by a pending cut could be "recaptured" by // a ring plan and counted as a second, phantom victim -- caught in // exactly this shape in `seed2328586520` T18, where the bonus // placement re-ringed the same stone its own cut had just doomed. Both // fixes, and the fact this file's own earlier "pleasant surprise" note // was actually this exact bug, are recorded in // docs/tasks/2026-09-12/21-cuts-ring-planning.md. // - 2026-09-12 (same day, Circles enclosure planning -- // docs/tasks/2026-09-12/22-circles-enclosure-planner.md): this file's // own private `threateningPlayers`/`siegeBreaker` were lifted, unchanged // in behaviour, into `lib.ts` as generic substrate (widened there to // also check territory victims, since a strategy that plans territory // can break a siege that way too) so circles.ts can use the identical // "break the siege, don't just plug it" idiom instead of re-deriving // it. `lib.planCaptures` itself gained a `"territory"` rule and a // `horizon` option in the same pass, but this file's own call // (`rules: ["cut", "ring"]`, no `horizon`) is unaffected -- Analyst's // spec explicitly required Cuts' behaviour stay byte-for-byte // unchanged, verified by the regression assertions in that task's own // acceptance section. import * as game from "../game.js"; import * as lib from "./lib.js"; export const id = "cuts"; export const name = "Cuts"; // Three cells adjacent to the base in a straight line -- one of the four // sides of the base's own 3x3 neighbourhood, e.g. the row directly above // it. Every cell here is Chebyshev-1 from the base, so this satisfies the // opening turn's own-base-adjacency rule directly; it's the substrate every // future cut is built from, not a cut itself. const OPENING_SIDES: readonly (readonly lib.Offset[])[] = [ [{ row: -1, col: -1 }, { row: -1, col: 0 }, { row: -1, col: 1 }], // above [{ row: 1, col: -1 }, { row: 1, col: 0 }, { row: 1, col: 1 }], // below [{ row: -1, col: -1 }, { row: 0, col: -1 }, { row: 1, col: -1 }], // left [{ row: -1, col: 1 }, { row: 0, col: 1 }, { row: 1, col: 1 }], // right ]; // Templates/rings this expensive-relative-to-estimating to actually // verify -- each one costs up to a few `game.place` calls plus // `game.pendingCuts`/`game.pendingEncirclements` reads, real money per // docs/bots.md's performance rules -- so 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 = 4; // Half of lib.CUT_WEIGHT: a lone ring, with no cut involved in the same // plan, is worth chasing but never preferred over a cut or a combo -- see // this file's revision log for the full rationale (a Cuts-specific tuning // knob, not a rule). const RING_ONLY_WEIGHT = lib.CUT_WEIGHT / 2; // Cuts' own value for a victim captured by a plan of the given rule -- // see this file's revision log for the exact rationale. `rule` is the // PLAN's rule (whichever finder proposed it), applied uniformly to every // victim that plan captures, including one lib.planCaptures' own combo // detection added on top of a cut template -- see lib.ts's // `withComboVictims` comment for why that's the correct reading of "a // victim is 100 whether captured by the cut or by a ring that plan // closes." const captureWeight = ( base: game.Piece | undefined, ) => (victim: game.Piece, rule: lib.CaptureRule): number => { const flat = rule === "cut" ? lib.CUT_WEIGHT : RING_ONLY_WEIGHT; return flat * (base && lib.chebyshev(base.position, victim.position) <= lib.SIEGE_RADIUS ? 2 : 1); }; // Among plans tied at the maximum value, prefer one with at least one // real cut victim -- only cuts earn bonus placements, so an // otherwise-equally-valuable plan that also funds more placements this // turn is strictly more useful (see this file's revision log). Falls // back to `random` among ties within that preference, matching the // tie-break style used throughout lib.ts. const pickPlan = ( plans: readonly lib.RankedPlan[], random: () => number, ): lib.RankedPlan | null => { if (plans.length === 0) { return null; } const maxValue = Math.max(...plans.map((plan) => plan.value)); const tied = plans.filter((plan) => plan.value === maxValue); const withCut = tied.filter((plan) => plan.cutVictims.length > 0); const pool = withCut.length > 0 ? withCut : tied; return pool[Math.floor(random() * pool.length)] ?? pool[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_SIDES, random) ?? lib.fallback(state, occ, pressure, available, random)?.position ?? null; } const plans = lib.planCaptures(state, { rules: ["cut", "ring"], weight: captureWeight(lib.ownBase(state, state.activePlayer)), validateCount: VALIDATE_COUNT, }); // Survival first, unconditional -- but prefer BREAKING the threatening // player's own siege wall over merely plugging the closing cell // lib.survivalMove found, if a validated plan captures their stones // near this base (see this file's revision log's "plugging vs // breaking" note and lib.survivalMove's own comment for why survival // still outranks everything else, including a bigger cut elsewhere). const survival = lib.survivalMove(state, available, random); if (survival) { const breaker = lib.siegeBreaker(state, plans, lib.SIEGE_RADIUS); return breaker?.position ?? survival.position; } // A seal of my own next, if it's big enough to be worth abandoning // Cuts' own idiom for this placement -- including a bonus placement, // which is exactly the mechanism Arcadia wins with (bot.ts's takeTurn // just calls nextPlacement again once a cut fires, no special-casing // needed here). See lib.ts's "Sea-cut selection" section. const seal = lib.sealMove(state, available, random); if (seal) { return seal.position; } // Fallback/safety scoring -- computed UNCONDITIONALLY, not only when no // plan exists, so it can be compared against a plan's own value below // rather than treated as a last resort. `gain` (immediate // lib.cutCount delta) is rare here by construction (lib.planCaptures // already found every reachable cut/ring above); this mostly reduces // to the plain `-EXPOSURE_PENALTY * exposure` branch. When gain IS // positive, exposure is evaluated with this move's own cut victims // already removed from occupancy (lib.exposureAfter) -- see this // file's revision log for why scoring against the pre-cut board // previously discarded good cuts outright. const baseline = lib.cutCount(state); const scored = available.map((move) => { const gain = lib.cutCount(move.next) - baseline; if (gain > 0) { const occNext = lib.occupancy(move.next); const removed = new Set( game.pendingCuts(move.next) .filter((piece) => !piece.isHomeBase) .map((piece) => lib.key(piece.position)), ); const exposure = lib.exposureAfter(move.next, occNext, move.position, removed); return { move, score: lib.CUT_WEIGHT * gain - lib.EXPOSURE_PENALTY * exposure }; } const exposure = pressure.get(lib.key(move.position))?.exposure ?? 0; return { move, score: -lib.EXPOSURE_PENALTY * exposure }; }); const bestFallbackScore = scored.reduce((max, entry) => Math.max(max, entry.score), -Infinity); // The primary mechanism: lib.planCaptures already walked every possible // cut/ring template on the current board and validated the best few // via game.place/game.pendingCuts/game.pendingEncirclements, ranked by // this file's own weighting. A returned plan's position is itself the // result of `game.place(state, position)` succeeding during that // validation, so it needs no re-checking against `available` here. // // Taken only if its value is at least as good as the best fallback // score -- a RING_ONLY_WEIGHT (50) plan can still score negative once // its own exposure is subtracted (e.g. 50 - 60 = -10 at exposure 2), // and a negative plan must never beat a safer fallback cell just // because a plan happened to exist. This is ONE max taken over both // sources, not the "> 0, else fall back" anti-pattern lib.choose's own // comment warns against -- when every fallback cell is ALSO exposed, a // -10 plan can legitimately still beat a -30 fallback. Found by // Analyst's review 2026-09-12: before this fix, Cuts would take ANY // plan over a safe growth cell regardless of value, harmless while // every plan was a cut (100 minus at most 90 exposure penalty is still // positive) but not once a lone, non-siege ring could score negative. const plan = pickPlan(plans, random); if (plan && plan.value >= bestFallbackScore) { return 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; };