Files
wyrm/src/lib/game/board.ts
T
Eric WagonerandClaude Fable 5.1 0be89a1dfe Credibility pass: the code reads as one hand's work. Dead plumbing and copies removed, four claims the source does not support corrected, and inherited kit text re-read for this game
Engine: the facade re-exports the board in one block; describe() and the
dead letters preference, void ply, the King-taken line, the identity maps
and the unused imports are gone; SIDE_NAMES is the one spelling of a side;
the drake's hunter comment no longer claims only four kinds reach the air.
Prose: no shatranj and no sitting duck, which Gygax never wrote; the Thief
statistic and the Basilisk's "one trick" are gone; lessons say what the
board shows instead of repeating the panel; a Thief's leap counts four.
Browser: one keyboard walk, in BoardArtwork; the lesson board and page share
one game and one inspected piece; the chronicle's possessive is told, not
patched by a regex; the chronicle colours and the reading layout are global
rules instead of three copies; the guide and the about panel say what the
code and the article actually do.
Server and docs: the room server states that it is the kit's, kept whole;
the skills, the deploy README and the share card name files that exist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-26 22:38:00 -04:00

364 lines
13 KiB
TypeScript

// The three boards and how every piece moves on them, on a bare array of
// cells so the engine, the bot and the board drawing share one source. The
// rules are docs/source.md, Gygax's text, read as its "Decisions" section
// says: board 3 is the air, 2 the land, 1 the underworld.
export type Side = 'gold' | 'scarlet';
/** One letter a piece: Gygax's initials, with R for the Dragon (he wrote Dr). */
export type Kind = 'S' | 'G' | 'R' | 'W' | 'O' | 'U' | 'H' | 'T' | 'C' | 'M' | 'K' | 'P' | 'D' | 'B' | 'E';
/** A cell holds '' or a side letter and a kind: 'gK' is Gold's King, 'sR' Scarlet's Dragon. */
export type Cell = string;
export type Setup = 'printed' | 'shifted';
export const FILES = 12;
export const RANKS = 8;
export const LEVELS = 3;
export const PER_LEVEL = FILES * RANKS;
export const CELLS = PER_LEVEL * LEVELS;
export const KINDS: Kind[] = ['S', 'G', 'R', 'W', 'O', 'U', 'H', 'T', 'C', 'M', 'K', 'P', 'D', 'B', 'E'];
export const NAMES: Record<Kind, string> = {
S: 'Sylph', G: 'Griffon', R: 'Dragon', W: 'Warrior', O: 'Oliphant', U: 'Unicorn', H: 'Hero', T: 'Thief',
C: 'Cleric', M: 'Mage', K: 'King', P: 'Paladin', D: 'Dwarf', B: 'Basilisk', E: 'Elemental'
};
/** The letters the pieces are written with: Gygax's initials, and Dr for the Dragon. */
export const LETTERS: Record<Kind, string> = {
S: 'S', G: 'G', R: 'Dr', W: 'W', O: 'O', U: 'U', H: 'H', T: 'T', C: 'C', M: 'M', K: 'K', P: 'P', D: 'D', B: 'B', E: 'E'
};
/** Gygax's values. He gives the King none: 0 here, and the panel says "beyond price". */
export const VALUES: Record<Kind, number> = {
K: 0, M: 11, P: 10, C: 9, R: 8, G: 5, O: 5, H: 4.5, T: 4, E: 4, B: 3, U: 2.5, D: 2, S: 1, W: 1
};
export const SIDES: Side[] = ['gold', 'scarlet'];
export const SIDE_NAMES: Record<Side, string> = { gold: 'Gold', scarlet: 'Scarlet' };
export const other = (side: Side): Side => (side === 'gold' ? 'scarlet' : 'gold');
/** A cell index from level 1 to 3, file 0 to 11 and rank 0 to 7. */
export const idx = (level: number, file: number, rank: number): number => (level - 1) * PER_LEVEL + rank * FILES + file;
export const levelOf = (i: number): number => Math.floor(i / PER_LEVEL) + 1;
export const fileOf = (i: number): number => i % FILES;
export const rankOf = (i: number): number => Math.floor((i % PER_LEVEL) / FILES);
const inBounds = (file: number, rank: number): boolean => file >= 0 && file < FILES && rank >= 0 && rank < RANKS;
/** The square written as Gygax writes it: 3c4 is the air, file c, rank 4. */
export function squareName(i: number): string {
return `${levelOf(i)}${'abcdefghijkl'[fileOf(i)]}${rankOf(i) + 1}`;
}
/** The index of a square written as Gygax writes it. */
export function square(name: string): number {
const level = Number(name[0]);
const file = 'abcdefghijkl'.indexOf(name[1]);
const rank = Number(name.slice(2)) - 1;
if (!(level >= 1 && level <= 3) || file < 0 || !(rank >= 0 && rank < RANKS)) throw new Error(`no square ${name}`);
return idx(level, file, rank);
}
/** Light squares: a light square sits in Gold's near right corner, l1. */
export const isLight = (i: number): boolean => (fileOf(i) + rankOf(i)) % 2 === 1;
export const sideOf = (cell: Cell): Side | null => (cell === '' ? null : cell[0] === 'g' ? 'gold' : 'scarlet');
export const kindOf = (cell: Cell): Kind => cell[1] as Kind;
export const cellOf = (side: Side, kind: Kind): Cell => (side === 'gold' ? 'g' : 's') + kind;
/** Which way is forward: Gold climbs the ranks, Scarlet descends them. */
export const forward = (side: Side): number => (side === 'gold' ? 1 : -1);
/** The home files of the Sylphs: as printed, or shifted onto the Dwarves' files. */
export const sylphFiles = (setup: Setup): number[] => (setup === 'shifted' ? [1, 3, 5, 7, 9, 11] : [0, 2, 4, 6, 8, 10]);
/** The opening position. */
export function opening(setup: Setup = 'printed'): Cell[] {
const cells: Cell[] = new Array(CELLS).fill('');
const backRank: Kind[] = ['O', 'U', 'H', 'T', 'C', 'M', 'K', 'P', 'T', 'H', 'U', 'O'];
for (const side of SIDES) {
const home = side === 'gold' ? 0 : RANKS - 1;
const second = side === 'gold' ? 1 : RANKS - 2;
for (let f = 0; f < FILES; f++) {
cells[idx(2, f, home)] = cellOf(side, backRank[f]);
cells[idx(2, f, second)] = cellOf(side, 'W');
}
for (const f of sylphFiles(setup)) cells[idx(3, f, second)] = cellOf(side, 'S');
cells[idx(3, 2, home)] = cellOf(side, 'G');
cells[idx(3, 10, home)] = cellOf(side, 'G');
cells[idx(3, 6, home)] = cellOf(side, 'R');
for (const f of [1, 3, 5, 7, 9, 11]) cells[idx(1, f, second)] = cellOf(side, 'D');
cells[idx(1, 2, home)] = cellOf(side, 'B');
cells[idx(1, 10, home)] = cellOf(side, 'B');
cells[idx(1, 6, home)] = cellOf(side, 'E');
}
return cells;
}
/** A piece on the middle board with an enemy Basilisk beneath it cannot move, capture or give check. */
export function frozen(cells: Cell[], i: number): boolean {
if (levelOf(i) !== 2) return false;
const below = cells[i - PER_LEVEL];
return below !== '' && kindOf(below) === 'B' && sideOf(below) !== sideOf(cells[i]);
}
const KNIGHT = [[1, 2], [2, 1], [-1, 2], [-2, 1], [1, -2], [2, -1], [-1, -2], [-2, -1]];
const GRIFFON = [[2, 3], [3, 2], [-2, 3], [-3, 2], [2, -3], [3, -2], [-2, -3], [-3, -2]];
const ORTHOGONAL = [[1, 0], [-1, 0], [0, 1], [0, -1]];
const DIAGONAL = [[1, 1], [1, -1], [-1, 1], [-1, -1]];
const AROUND = [...ORTHOGONAL, ...DIAGONAL];
/**
* Every square the piece on `from` may move to or capture on, by the rules
* alone: its own King may be left in check. The Dragon's capture from afar
* is a move to the middle-board square it empties. A frozen piece has none.
*/
export function movesFrom(cells: Cell[], from: number, setup: Setup = 'printed'): number[] {
const cell = cells[from];
if (cell === '' || frozen(cells, from)) return [];
const side = sideOf(cell)!;
const kind = kindOf(cell);
const level = levelOf(from);
const f = fileOf(from);
const r = rankOf(from);
const fw = forward(side);
const out: number[] = [];
const empty = (i: number) => cells[i] === '';
const enemy = (i: number) => cells[i] !== '' && sideOf(cells[i]) !== side;
const open = (i: number) => cells[i] === '' || sideOf(cells[i]) !== side;
/** Add the square if it is on the board and passes the test. */
const add = (lv: number, file: number, rank: number, ok: (i: number) => boolean): void => {
if (lv < 1 || lv > LEVELS || !inBounds(file, rank)) return;
const i = idx(lv, file, rank);
if (ok(i)) out.push(i);
};
/** Slide until something is in the way; a piece of the other side may be taken. */
const slide = (dirs: number[][]) => {
for (const [df, dr] of dirs) {
let file = f + df;
let rank = r + dr;
while (inBounds(file, rank)) {
const i = idx(level, file, rank);
if (cells[i] === '') out.push(i);
else {
if (sideOf(cells[i]) !== side) out.push(i);
break;
}
file += df;
rank += dr;
}
}
};
const step = (dirs: number[][], lv = level, ok = open) => {
for (const [df, dr] of dirs) add(lv, f + df, r + dr, ok);
};
const lastRank = side === 'gold' ? RANKS - 1 : 0;
switch (kind) {
case 'S':
if (level === 3) {
add(3, f - 1, r + fw, empty);
add(3, f + 1, r + fw, empty);
add(3, f, r + fw, enemy);
add(2, f, r, enemy);
} else if (level === 2) {
add(3, f, r, empty);
const homeRank = side === 'gold' ? 1 : RANKS - 2;
for (const file of sylphFiles(setup)) add(3, file, homeRank, empty);
}
break;
case 'G':
if (level === 3) {
step(GRIFFON);
step(DIAGONAL, 2);
} else if (level === 2) {
step(DIAGONAL);
step(DIAGONAL, 3);
}
break;
case 'R':
if (level === 3) {
slide(DIAGONAL);
step(ORTHOGONAL);
add(2, f, r, enemy);
step(ORTHOGONAL, 2, enemy);
}
break;
case 'W':
if (level === 2) {
add(2, f, r + fw, empty);
add(2, f - 1, r + fw, enemy);
add(2, f + 1, r + fw, enemy);
}
break;
case 'O':
if (level === 2) slide(ORTHOGONAL);
break;
case 'U':
if (level === 2) step(KNIGHT);
break;
case 'T':
if (level === 2) slide(DIAGONAL);
break;
case 'H':
if (level === 2) {
step(DIAGONAL);
step(DIAGONAL.map(([df, dr]) => [df * 2, dr * 2]));
step(DIAGONAL, 1);
step(DIAGONAL, 3);
} else step(DIAGONAL, 2);
break;
case 'C':
step(AROUND);
add(level + 1, f, r, open);
add(level - 1, f, r, open);
break;
case 'M':
if (level === 2) {
slide(ORTHOGONAL);
slide(DIAGONAL);
add(3, f, r, open);
add(1, f, r, open);
} else {
step(ORTHOGONAL);
add(2, f, r, open);
if (cells[idx(2, f, r)] === '') add(level === 3 ? 1 : 3, f, r, open);
}
break;
case 'K':
if (level === 2) {
step(AROUND);
add(3, f, r, open);
add(1, f, r, open);
} else add(2, f, r, open);
break;
case 'P':
step(AROUND);
if (level === 2) {
step(KNIGHT);
for (const lv of [1, 3]) step(ORTHOGONAL.map(([df, dr]) => [df * 2, dr * 2]), lv);
} else {
step(ORTHOGONAL.map(([df, dr]) => [df * 2, dr * 2]), 2);
step(ORTHOGONAL, level === 3 ? 1 : 3);
}
break;
case 'D':
if (level === 1 || level === 2) {
if (r !== lastRank) {
add(level, f, r + fw, empty);
add(level, f - 1, r + fw, enemy);
add(level, f + 1, r + fw, enemy);
}
add(level, f - 1, r, empty);
add(level, f + 1, r, empty);
if (level === 1) add(2, f, r, enemy);
else add(1, f, r, empty);
}
break;
case 'B':
if (level === 1) {
add(1, f, r + fw, open);
add(1, f - 1, r + fw, open);
add(1, f + 1, r + fw, open);
add(1, f, r - fw, empty);
}
break;
case 'E':
if (level === 1) {
for (const [df, dr] of ORTHOGONAL) {
if (!inBounds(f + df, r + dr)) continue;
const near = idx(1, f + df, r + dr);
if (open(near)) out.push(near);
if (cells[near] === '') {
add(1, f + df * 2, r + dr * 2, open);
add(2, f + df, r + dr, enemy);
}
}
step(DIAGONAL, 1, empty);
} else if (level === 2 && cells[idx(1, f, r)] === '') step(ORTHOGONAL, 1);
break;
}
return out;
}
/** Whether `side` attacks square `i` in this position: one of its pieces could capture there now. */
export function attacked(cells: Cell[], i: number, by: Side): boolean {
for (let from = 0; from < CELLS; from++) {
const cell = cells[from];
if (cell === '' || sideOf(cell) !== by) continue;
if (movesFrom(cells, from).includes(i)) return true;
}
return false;
}
export function kingSquare(cells: Cell[], side: Side): number {
const king = cellOf(side, 'K');
for (let i = 0; i < CELLS; i++) if (cells[i] === king) return i;
return -1;
}
export function inCheck(cells: Cell[], side: Side): boolean {
const k = kingSquare(cells, side);
return k >= 0 && attacked(cells, k, other(side));
}
export interface Played {
cells: Cell[];
captured: Kind | null;
promoted: boolean;
/** The Dragon's capture from afar: the Dragon stays and the square it struck is emptied. */
afar: boolean;
}
/** The position after the move, on a copy. The move is taken to be one `movesFrom` offered. */
export function playOn(cells: Cell[], from: number, to: number): Played {
const next = cells.slice();
const piece = cells[from];
const side = sideOf(piece)!;
const kind = kindOf(piece);
const captured = cells[to] === '' ? null : kindOf(cells[to]);
if (kind === 'R' && levelOf(to) === 2) {
next[to] = '';
return { cells: next, captured, promoted: false, afar: true };
}
next[from] = '';
const promoted = kind === 'W' && rankOf(to) === (side === 'gold' ? RANKS - 1 : 0);
next[to] = promoted ? cellOf(side, 'H') : piece;
return { cells: next, captured, promoted, afar: false };
}
/** The moves of the piece on `from` that leave its own King out of check. */
export function legalMovesFrom(cells: Cell[], from: number, setup: Setup = 'printed'): number[] {
const side = sideOf(cells[from]);
if (!side) return [];
return movesFrom(cells, from, setup).filter((to) => !inCheck(playOn(cells, from, to).cells, side));
}
/** Every legal move for a side, as [from, to] pairs. */
export function legalMoves(cells: Cell[], side: Side, setup: Setup = 'printed'): [number, number][] {
const out: [number, number][] = [];
for (let from = 0; from < CELLS; from++) {
if (cells[from] === '' || sideOf(cells[from]) !== side) continue;
for (const to of legalMovesFrom(cells, from, setup)) out.push([from, to]);
}
return out;
}
/** Whether a side has any legal move at all, found without listing them. */
export function hasLegalMove(cells: Cell[], side: Side, setup: Setup = 'printed'): boolean {
for (let from = 0; from < CELLS; from++) {
if (cells[from] === '' || sideOf(cells[from]) !== side) continue;
for (const to of movesFrom(cells, from, setup)) if (!inCheck(playOn(cells, from, to).cells, side)) return true;
}
return false;
}
/** The pieces a side has left, by kind. */
export function census(cells: Cell[], side: Side): Partial<Record<Kind, number>> {
const count: Partial<Record<Kind, number>> = {};
for (const cell of cells) if (cell !== '' && sideOf(cell) === side) count[kindOf(cell)] = (count[kindOf(cell)] ?? 0) + 1;
return count;
}