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.
5.6 kB · 132 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133// bot.ts owns what a bot strategy is and how one takes a turn. Pure: no// DOM, no network, no clock -- see docs/bots.md for the contract strategy// authors write against.
import * as game from "./game.js";import * as lib from "./bot/lib.js";import * as cuts from "./bot/cuts.js";import * as circles from "./bot/circles.js";import * as tentacles from "./bot/tentacles.js";
export type Strategy = { readonly id: string; readonly name: string; // Called once per placement, on the live board, until it returns null or // the turn's placement budget is spent. Must not mutate state; must not // call Math.random -- use the supplied `random` so a whole game replays // identically from its seed. readonly nextPlacement: (state: game.BoardState, random: () => number) => game.Position | null;};
// A strategy is a module, not a registered object -- each of bot/*.ts// exports `id`, `name` and `nextPlacement` at top level, and is listed// here by its own namespace import. No import back from a strategy to this// file, so no cycle, and structural typing makes a drifting strategy file// a compile error at this one line. Adding a strategy is: one new file,// one line here.export const strategies: readonly Strategy[] = [cuts, circles, tentacles];
export const byId = (id: string): Strategy | undefined => strategies.find((strategy) => strategy.id === id);
export type TurnOutcome = { readonly placements: readonly game.Position[]; // in order, all legal readonly state: game.BoardState; // after placements, BEFORE endTurn readonly fault: string | null;};
// A generous ceiling, not a turn-length estimate: `remaining` is NOT// monotonically decreasing during a turn, since each pending cut grants a// bonus placement mid-turn (game.ts's bonusPlacements). This guards against// a strategy bug that keeps extending its own turn, or loops outright --// not against a long but ordinary cutting spree.const MAX_STEPS = 64;
// The opening-turn safety net: endTurn refuses an incomplete opening turn// forever, so a strategy that stops early or faults on turn one would// otherwise hang the game permanently. Fills the remaining opening// placements from empty cells adjacent to the active player's own base --// this path exists for faults only; every shipped strategy defines its own// opening behaviour and should never actually reach it in normal play.//// This can only actually fill every remaining slot because// game.generateHomeBases guarantees MIN_HOME_BASE_DISTANCE (>= 8)// Chebyshev separation between bases, so all 8 of a base's own neighbours// are free during the opening turn -- no other player's stones can reach// them yet. If that home-base placement guarantee ever loosens, this can// legitimately run out of legal neighbours and place fewer than needed;// takeTurn below now records a fault whenever that happens (not only when// it placed nothing at all), rather than silently leaving the opening// turn incomplete forever. Flagged by Seer's review 2026-09-08.const forcedOpeningFill = (state: game.BoardState): { state: game.BoardState; placed: game.Position[] } => { const base = state.pieces.find( (piece) => piece.isHomeBase && piece.player === state.activePlayer, ); const placed: game.Position[] = []; let current = state; if (!base) { return { state: current, placed }; } for (const offset of lib.DIRECTIONS) { if (game.remaining(current) <= 0) { break; } const candidate = lib.shift(base.position, offset); const next = game.place(current, candidate); if (next) { current = next; placed.push(candidate); } } return { state: current, placed };};
export const takeTurn = ( state: game.BoardState, strategy: Strategy, random: () => number,): TurnOutcome => { let current = state; const placements: game.Position[] = []; let fault: string | null = null; let steps = 0;
while (game.remaining(current) > 0) { if (++steps > MAX_STEPS) { fault = `${strategy.id} exceeded ${MAX_STEPS} placements in a single turn`; break; } const position = strategy.nextPlacement(current, random); if (position === null) { break; } const next = game.place(current, position); if (!next) { fault = `${strategy.id} suggested an illegal placement at (${position.row},${position.col})`; break; } current = next; placements.push(position); }
if (game.isOpeningTurn(current) && game.remaining(current) > 0) { const { state: filled, placed } = forcedOpeningFill(current); current = filled; placements.push(...placed); // Faults whenever the opening turn is left incomplete here, not only // when the forced fill placed something -- a zero-placement forced // fill (e.g. every base neighbour is somehow already occupied) would // otherwise record no fault at all while still leaving the opening // turn incomplete, hanging the game permanently and silently. See // forcedOpeningFill's own comment for why this shouldn't be reachable // today. if (game.isOpeningTurn(current) && game.remaining(current) > 0) { fault ??= `${strategy.id} left the opening turn incomplete; forced fill placed ${placed.length} ` + `and still has ${game.remaining(current)} remaining -- game may hang`; } else if (placed.length > 0) { fault ??= `${strategy.id} left the opening turn incomplete; forced ${placed.length} placement(s)`; } }
return { placements, state: current, fault };};