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
364 lines
13 KiB
TypeScript
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;
|
|
}
|