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.
13 kB · 266 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267// bot/tentacles.ts -- "sprawls outwards forming square-shapes spreading// outwards": grows two thick diagonal arms out from its own home base, with// an empty wedge left between them, rather than filling in every direction// at once. This is the on-vision strategy (see docs/vision.md) and// deliberately the simplest of the three, since it's the one Arcadia will// want iterated most.//// The payoff: a three-wide diagonal band is a wall -- a diagonal staircase// of stones blocks a 4-connected flood fill just like an orthogonal one, so// game.ts's enclosedCells treats an arm as a genuine territory wall. Two// arms diverging from a base eventually pin a region against the board's// border or against another player's stones, and everything caught inside// is captured by territory once the pincer closes. The band is also dense// with collinear own stones, which is good substrate for cuts.//// A strategy must never voluntarily pass: nextPlacement only returns null// when lib.moves finds no legal candidate at all.//// Both numbers below are read off a single reference screenshot from// Arcadia -- named constants rather than buried in the logic, since she'll// want to try more/fewer/wider arms and that should be a one-line edit.//// Revision log:// - 2026-09-08: initial version, designed by Seer -- concentric Chebyshev// rings around the base, filled outward in every direction at once.// Superseded the same day, before ever running against a real game (see// below).// - 2026-09-08: replaced with two diagonal arms after Arcadia sent a// reference screenshot showing the intended shape isn't rings -- it's a// pincer with an empty wedge between two arms. Rings grew in every// direction simultaneously; arms grow in exactly ARM_COUNT chosen// diagonals, which is what produces the wedge.// - 2026-09-10: two fixes from Seer's review of seed1990751459 (T43-T160):// the growth rule used to pick the legal cell closest to the base every// time, so a near-base cell freed by an opponent's capture would be// refilled forever while the tip never advanced -- now prefers the// smallest `along` that's at-or-ahead of that arm's current reach,// falling back to a behind-the-tip cell only when nothing ahead is// legal. Also, corridor candidates within one placement of arming an// enemy's ring (lib.ringPressure's exposure >= 2) are filtered out// before ranking, and the bare random fallbacks (no own base found;// both corridors full even widened) now route through lib.fallback// instead of picking uniformly across the whole board. Lower-impact// than the growth-rule fix; noted here, not oversold -- see// docs/memories/project/bot-strategies.md.//// PARTIAL FIX, not a full one -- Seer traced seed1990751459's actual// livelock mechanics: with reach 4 and legal cells at along 4, `ahead`// (`along >= armLength`) still includes those cells and `minAlong`// still picks among them (that's correct in general -- fill the tip's// width before advancing). The loop there was actually broken by the// EXPOSURE FILTER, not the anti-backfill rule -- it only covers// ring-recapture. In that transcript Circles was retaking the cells by// ring AND territory most turns; nothing in this pass sees the// territory half, since that needs a sea-replay lookahead (Pass 2's// lib.seaCuts, not yet wired in here). Next iteration on this file// should revisit whether the exposure filter alone is still holding// once seaCuts-aware play changes what opponents do near an arm's tip.// - 2026-09-10 (Pass 2): wired in lib.survivalMove/lib.sealMove ahead of// arm growth -- the finisher this file's own header comment says the// arms were "always for": two arms pinning a region against the// border was always the shape, but nothing here ever recognised the// moment of closing it until now. Designed by Seer. Also the direct// answer to the PARTIAL FIX note above: `seaCuts`'s survivalMove is// the territory-aware defence that entry says this file was missing,// though it's a shared mechanism (lib.ts), not a tentacles-specific// one.
import * as game from "../game.js";import * as lib from "./lib.js";
export const id = "tentacles";export const name = "Tentacles";
const ARM_COUNT = 2;const ARM_HALF_WIDTH = 1; // corridor width = 2 * ARM_HALF_WIDTH + 1 (3 today)
// The signed distance from `cell` to the diagonal ray from `base` along// `d`, in the same units as `d` itself (each step off the ray is 1, not 2)// -- 0 exactly on the ray, verified against Arcadia's screenshot cell by// cell. |perpendicular| <= ARM_HALF_WIDTH is what defines the corridor's// width.const perpendicular = (base: game.Position, d: lib.Offset, cell: game.Position): number => { const row = cell.row - base.row; const col = cell.col - base.col; return row * d.row - col * d.col;};
// The signed projection of `cell` onto the ray itself: positive on the// outward side of the base along `d`, zero at the base, negative behind// it. This is what keeps a corridor from including cells on the opposite// diagonal (behind the base) even when they'd otherwise have a small// `perpendicular` value.const projection = (base: game.Position, d: lib.Offset, cell: game.Position): number => { const row = cell.row - base.row; const col = cell.col - base.col; return row * d.row + col * d.col;};
// The two diagonals pointing most toward the centre of the board, from the// base's own position -- stable for the whole game and needs no memory,// which matters because nextPlacement is called fresh for every placement// with no state carried between calls. A random choice here would make the// arms wander turn to turn and dissolve the shape; deriving the directions// from data that never changes (the base and the board size) is what keeps// them straight.const armDirections = (base: game.Position, size: game.BoardSize): readonly lib.Offset[] => { const center = { row: (size.rows - 1) / 2, col: (size.cols - 1) / 2 }; const toward = { row: center.row - base.row, col: center.col - base.col }; return [...lib.DIAGONAL] .sort((a, b) => (b.row * toward.row + b.col * toward.col) - (a.row * toward.row + a.col * toward.col)) .slice(0, ARM_COUNT);};
// Every cell in the diagonal band an arm grows through: strictly outward// from the base (projection > 0) and within halfWidth of the ray. Iterates// the whole board rather than walking outward from the base -- this board// is small (game.ts's own enclosedCells does the same) and it stays a flat// scan with no game.ts lookups, so it's cheap regardless.const corridor = ( size: game.BoardSize, base: game.Position, d: lib.Offset, halfWidth: number,): game.Position[] => { const cells: game.Position[] = []; for (let row = 0; row < size.rows; row++) { for (let col = 0; col < size.cols; col++) { const cell = { row, col }; if (projection(base, d, cell) <= 0 || Math.abs(perpendicular(base, d, cell)) > halfWidth) { continue; } cells.push(cell); } } return cells;};
// How far this arm currently reaches: the largest Chebyshev distance among// the bot's own stones that fall inside this arm's corridor.const armLength = ( state: game.BoardState, base: game.Position, d: lib.Offset, halfWidth: number,): number => { let max = 0; for (const piece of state.pieces) { if (piece.player !== state.activePlayer || piece.isHomeBase) { continue; } if (projection(base, d, piece.position) <= 0 || Math.abs(perpendicular(base, d, piece.position)) > halfWidth) { continue; } max = Math.max(max, lib.chebyshev(base, piece.position)); } return max;};
const isAvailable = (available: readonly lib.Move[], cell: game.Position): boolean => available.some((move) => move.position.row === cell.row && move.position.col === cell.col);
type Candidate = { position: game.Position; along: number; armLength: number };
export const nextPlacement = ( state: game.BoardState, random: () => number,): game.Position | null => { const available = lib.moves(state); if (available.length === 0) { return null; }
// Survival first, unconditional; a seal of my own next, if it's big // enough to be worth abandoning arm-growth for this placement -- the // finisher the arms have never had (see lib.ts's "Sea-cut selection" // section, and the revision log entry below). Guarded by // `!isOpeningTurn`: unlike circles.ts/cuts.ts, this file has no // opening-turn branch to place the check below, but a seal // structurally can't exist by turn 3 either way, so checking is four // wasted seaCells calls per opening placement -- guarded per Seer's // review 2026-09-10 (clarity, not correctness). if (!game.isOpeningTurn(state)) { const survival = lib.survivalMove(state, available, random); if (survival) { return survival.position; } const seal = lib.sealMove(state, available, random); if (seal) { return seal.position; } }
const occ = lib.occupancy(state); const pressure = lib.ringPressure(state, occ, available.map((move) => move.position));
const base = lib.ownBase(state, state.activePlayer); if (!base) { return lib.fallback(state, occ, pressure, available, random)?.position ?? null; }
const directions = armDirections(base.position, state.size);
// Widen from the nominal width to one cell wider before giving up on the // corridors entirely -- reproduces the ragged stragglers at an arm's tip // in Arcadia's reference picture, where a couple of cells sit at // perpendicular distance 2 once the width-1 band is already full there. for (const halfWidth of [ARM_HALF_WIDTH, ARM_HALF_WIDTH + 1]) { // Keyed by cell so a cell that falls in both corridors (near the base, // where the bands can overlap) is considered once, associated with // whichever arm currently has the shorter length -- consistent with // the tie-break rule below, and avoids double-weighting that cell when // picking randomly among equal-along winners. const candidates = new Map<string, Candidate>(); for (const d of directions) { const length = armLength(state, base.position, d, halfWidth); const legal = corridor(state.size, base.position, d, halfWidth).filter( (cell) => lib.at(occ, cell) === undefined && isAvailable(available, cell), ); for (const cell of legal) { const cellKey = lib.key(cell); const existing = candidates.get(cellKey); if (!existing || length < existing.armLength) { candidates.set(cellKey, { position: cell, along: lib.chebyshev(base.position, cell), armLength: length }); } } } if (candidates.size === 0) { continue; }
// Drop cells one placement away from arming an enemy's ring before // ranking by distance -- a filter, not a penalty, since there's no // score here to subtract from. Only applied when it leaves at least // one candidate; an emptied-out corridor still has to grow somewhere. const all = [...candidates.values()]; const safe = all.filter((c) => (pressure.get(lib.key(c.position))?.exposure ?? 0) < 2); const pool = safe.length > 0 ? safe : all;
// Growth rule: fill each corridor outward from the base, but never // backward once it has a tip -- prefer the smallest `along` that is // AT OR AHEAD of that cell's own arm length (its current reach), so // the arm advances monotonically instead of refilling a near-base // cell an opponent just freed by capture. Only fall back to a cell // BEHIND the tip (`along < armLength`) when nothing at or ahead is // legal in this corridor at all. Ties broken by preferring the arm // that's currently shorter (keeps the two arms roughly even), then by // `random`. Fixed 2026-09-10 after seed1990751459 T43-T160 showed // Tentacles replaying the same three near-base cells for 40+ turns // against an opponent recapturing them every round -- see // docs/memories/project/bot-strategies.md's rebuild-livelock note. const ahead = pool.filter((c) => c.along >= c.armLength); const considered = ahead.length > 0 ? ahead : pool; const minAlong = Math.min(...considered.map((c) => c.along)); const nearest = considered.filter((c) => c.along === minAlong); const minArmLength = Math.min(...nearest.map((c) => c.armLength)); const shortestArm = nearest.filter((c) => c.armLength === minArmLength); return lib.bestBy(shortestArm, () => 0, random)?.position ?? null; }
// Neither corridor has room even widened -- fall back to the shared // ladder (near own stones, avoiding exposure) rather than whatever's // merely legal anywhere on the board. return lib.fallback(state, occ, pressure, available, random)?.position ?? null;};