Files
hnefatafl/src/lib/game/bot.ts
T
Eric WagonerandClaude Fable 5.1 ee5690ebda A credibility pass over the whole repo: the board's mechanics in board.ts, one copy of each helper and stylesheet rule, the kit's plumbing this game never used removed, and the ops scripts counting traffic through one parser
The engine and the bot no longer import each other: geometry, movement
and captures live in src/lib/game/board.ts and both use it. The escape
test, the four directions, the king's neighbours and the enumeration of
a side's moves each exist once; the tally counts by the named end
sentences rather than by regex over them; exports nobody imports are
exports no more. The tests pin the bot's opening move and the three-
beside-the-throne capture they named but never exercised.

In the client: .small, the word-as-button, the × that dismisses, the
visually-hidden rule and the frame of the reading pages are in app.css
once; the preferences panel and the report slip share one modal shape;
the room store drops the simultaneous-round fields this game never read
and gains seatEmpty and seatUnheld, which the lobby and the join page
read instead of three spellings of their own. The wire shapes for
reports and the tally are declared in view.ts for both sides. The
artwork component is re-indented for the top level it lives at.

On the server and in deploy: the plaintext-token fallback and its
migration script guarded ledgers this game never wrote; the seat line
now requires the hash and the start line the rules revision. The route
table lists every route. The visitors digest and the nightly rollup
count Caddy's log through deploy/traffic.py, and the rollup writes the
finished-games count it had been computing behind "and False". The
reports digest is one program, deploy/report-digest.ts, that
pull-reports.sh and the desk skill both use. The deploy README, the
visitors skill and the reports skill no longer describe a browser-only
game, a /play route or a rules text in docs/; conventions.md carries
the kit's Preferences and tally sections.

Kit-shared files touched, to port back: server/src/{index,rooms,store,
tally,reports}.ts, deploy/{deploy.sh,replay-ledgers.ts,pull-reports.sh,
traffic.py,report-digest.ts,*-rollup.sh,*-visitors.sh,*-pulse.sh},
src/lib/net/{client.ts,room.svelte.ts,view.ts}, Preferences.svelte,
ReportSlip.svelte, TableTalk.svelte.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
2026-09-23 20:02:45 -04:00

121 lines
4.3 KiB
TypeScript

// The bot: a short alpha-beta search over the legal moves, scored by what
// the game is about — the king's road to safety, the pieces on the board,
// and the attackers pressing around the king. A function of the state
// alone, so a ledger replays to the same choice.
import { cellOf, cornersOf, DIRECTIONS, escapeAt, isEdge, movesOf, neighbors, playOn, type Input, type Piece, type Side } from './board';
import { RULESETS, type Ruleset } from './rules';
import type { SeatId } from './spec';
import type { State } from './index';
/** Plies of lookahead: three is well under a second a move on either board. */
const DEPTH = 3;
/** The board as the search sees it: pieces, who moves, and a verdict when one has fallen. */
interface Node {
cells: Piece[];
toMove: Side;
/** The side that has won, once the king is taken or away. */
won: Side | null;
}
function stepNode(set: Ruleset, node: Node, from: number, to: number): Node {
const piece = node.cells[from];
const { cells } = playOn(set, node.cells, from, to);
const kingAt = cells.indexOf('k');
let won: Side | null = null;
if (kingAt === -1) won = 'attackers';
else if (piece === 'k' && escapeAt(set, kingAt)) won = 'defenders';
return { cells, toMove: node.toMove === 'attackers' ? 'defenders' : 'attackers', won };
}
/** Positive is good for the attackers. */
function evaluate(set: Ruleset, node: Node): number {
if (node.won) return node.won === 'attackers' ? 10_000 : -10_000;
const size = set.size;
const cells = node.cells;
let score = 0;
let attackers = 0;
let defenders = 0;
let kingAt = -1;
cells.forEach((p, i) => {
if (p === 'a') attackers += 1;
else if (p === 'd') defenders += 1;
else if (p === 'k') kingAt = i;
});
score += attackers * 10 - defenders * 16;
if (kingAt === -1) return 10_000;
const k = cellOf(size, kingAt);
// The king's distance to safety, and how open his roads are.
const distance =
set.escape === 'corner'
? Math.min(...cornersOf(size).map((i) => cellOf(size, i)).map((g) => Math.abs(g.x - k.x) + Math.abs(g.y - k.y)))
: Math.min(k.x, k.y, size - 1 - k.x, size - 1 - k.y);
score += distance * 6;
// Open lines from the king straight to an escape square are worth a great deal.
for (const [dx, dy] of DIRECTIONS) {
let x = k.x + dx;
let y = k.y + dy;
let open = true;
while (x >= 0 && y >= 0 && x < size && y < size) {
if (cells[y * size + x] !== '.') {
open = false;
break;
}
x += dx;
y += dy;
}
if (open && escapeAt(set, (y - dy) * size + (x - dx))) score -= 400;
}
// Attackers pressing the king.
score += neighbors(size, kingAt).filter((n) => cells[n] === 'a').length * 12;
return score;
}
function orderMoves(set: Ruleset, cells: Piece[], moves: Input[]): Input[] {
// King moves and edge moves first: they change the position most.
const king = cells.indexOf('k');
return moves
.map((m) => ({ m, w: (m.from === king ? 2 : 0) + (isEdge(set.size, m.to) ? 1 : 0) }))
.sort((a, b) => b.w - a.w)
.map((x) => x.m);
}
function search(set: Ruleset, node: Node, depth: number, alpha: number, beta: number, forSide: Side): number {
if (depth === 0 || node.won) return evaluate(set, node) * (forSide === 'attackers' ? 1 : -1);
const maximizing = node.toMove === forSide;
let best = maximizing ? -Infinity : Infinity;
for (const m of orderMoves(set, node.cells, movesOf(set, node.cells, node.toMove))) {
const next = stepNode(set, node, m.from, m.to);
const v = search(set, next, depth - 1, alpha, beta, forSide);
if (maximizing) {
best = Math.max(best, v);
alpha = Math.max(alpha, v);
} else {
best = Math.min(best, v);
beta = Math.min(beta, v);
}
if (beta <= alpha) break;
}
return best;
}
export function chooseMove(state: State, seat: SeatId): Input {
const set = RULESETS[state.set];
const side = state.players[seat].side;
const root: Node = { cells: state.cells, toMove: side, won: null };
// A side with no move has already lost in applyMove, so there is always one.
const moves = orderMoves(set, state.cells, movesOf(set, state.cells, side));
let best = moves[0];
let bestScore = -Infinity;
for (const m of moves) {
const next = stepNode(set, root, m.from, m.to);
const v = search(set, next, DEPTH - 1, -Infinity, Infinity, side);
if (v > bestScore) {
bestScore = v;
best = m;
}
}
return { from: best.from, to: best.to };
}