Hnefatafl: Copenhagen and Tablut, a board that ages with the older game, and a bot that looks three moves ahead
The rules are data in rules.ts; the engine plays custodial capture, the king's own capture rule on and beside the throne, corner and edge escape, shieldwalls, edge forts, encirclement, repetition and stalemate; twenty tests pin them and play a bot game to a verdict on both boards. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
ed1dcad259
commit
0d2f54fe02
@@ -0,0 +1,153 @@
|
||||
// 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 { cornersOf, isEdge, cellOf, movesOn, playOn, rulesetOf, sideOf, type Input, type Piece, type Side, type State } from './index';
|
||||
import type { Ruleset } from './rules';
|
||||
import type { SeatId } from './spec';
|
||||
|
||||
/** 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 movesOf(set: Ruleset, cells: Piece[], side: Side): { from: number; to: number }[] {
|
||||
const out: { from: number; to: number }[] = [];
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
if (sideOf(cells[i]) !== side) continue;
|
||||
for (const to of movesOn(set, cells, i)) out.push({ from: i, to });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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' && (set.escape === 'corner' ? cornersOf(set.size).includes(kingAt) : isEdge(set.size, kingAt))) won = 'defenders';
|
||||
return { cells, toMove: node.toMove === 'attackers' ? 'defenders' : 'attackers', won };
|
||||
}
|
||||
|
||||
/** Search depth in plies: three looks ahead on either board, well under a second a move. */
|
||||
function depthFor(size: number): number {
|
||||
void size;
|
||||
return 3;
|
||||
}
|
||||
|
||||
/** 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 goals = set.escape === 'corner' ? cornersOf(size).map((i) => cellOf(size, i)) : [];
|
||||
let distance: number;
|
||||
if (set.escape === 'corner') distance = Math.min(...goals.map((g) => Math.abs(g.x - k.x) + Math.abs(g.y - k.y)));
|
||||
else distance = 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 [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1]
|
||||
] as const) {
|
||||
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) {
|
||||
const last = { x: x - dx, y: y - dy };
|
||||
const reaches = set.escape === 'edge' ? isEdge(size, last.y * size + last.x) : cornersOf(size).includes(last.y * size + last.x);
|
||||
if (reaches) score -= 400;
|
||||
}
|
||||
}
|
||||
// Attackers pressing the king.
|
||||
let press = 0;
|
||||
for (const [dx, dy] of [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1]
|
||||
] as const) {
|
||||
const x = k.x + dx;
|
||||
const y = k.y + dy;
|
||||
if (x < 0 || y < 0 || x >= size || y >= size) continue;
|
||||
if (cells[y * size + x] === 'a') press += 1;
|
||||
}
|
||||
score += press * 12;
|
||||
return score;
|
||||
}
|
||||
|
||||
function orderMoves(set: Ruleset, cells: Piece[], moves: { from: number; to: number }[]): { from: number; to: number }[] {
|
||||
// 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 = rulesetOf(state);
|
||||
const side = state.players[seat].side;
|
||||
const root: Node = { cells: state.cells, toMove: side, won: null };
|
||||
const moves = orderMoves(set, state.cells, movesOf(set, state.cells, side));
|
||||
if (moves.length === 0) return { from: -1, to: -1 };
|
||||
let best = moves[0];
|
||||
let bestScore = -Infinity;
|
||||
const depth = depthFor(state.size);
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user