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.
22 kB · 378 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379// 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;};