// 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(); 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; };