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.
18 kB · 327 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328// 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;};