The chronicle tells each move as the table saw it, oldest first: the Griffon leaps, the Dwarf trudges, a gaze falls, a King steps off the land, and who lost what

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-10-26 18:20:17 -04:00
1 parent 4212bf9419
commit 04831ed2aa
6 files changed
+310 -66

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
// The chronicle: every move told as the table saw it, with what followed
// from it. A Griffon leaps and a Dwarf trudges, a capture names who lost
// what, a Basilisk's gaze falls on whatever stops above it, and a King that
// steps off the land is told what it has done. The lines are read off a
// replay of the moves, so the chronicle is a function of the game and
// never part of the ledger: rewording it changes no rules.
import {
type Cell, type Kind, type Side, CELLS, PER_LEVEL, NAMES, frozen, kindOf, levelOf, opening, other, playOn, rankOf, sideOf, squareName
} from './board';
import type { Move, State } from './index';
export type LineKind = 'move' | 'capture' | 'check' | 'freeze' | 'promotion' | 'end' | 'note';
export interface Line {
kind: LineKind;
text: string;
}
export interface Telling {
/** The move's number, from one. */
ply: number;
move: Move;
lines: Line[];
}
const WORLD: Record<number, string> = { 3: 'the air', 2: 'the land', 1: 'the underworld' };
/** How each piece goes when it simply moves on its own board. */
const GOES: Record<Kind, string> = {
S: 'drifts', G: 'leaps', R: 'glides', W: 'steps', O: 'lumbers', U: 'springs', T: 'slips', H: 'bounds',
C: 'walks', M: 'sweeps', K: 'steps', P: 'rides', D: 'trudges', B: 'slithers', E: 'grinds'
};
/** The word for a piece that changes boards, going up or down. */
function crossing(kind: Kind, from: number, to: number): string {
const up = levelOf(to) > levelOf(from);
if (kind === 'G') return up ? 'takes wing' : 'swoops down';
if (kind === 'P') return up ? 'vaults up' : 'vaults down';
if (kind === 'H') return up ? 'springs up' : 'springs down';
if (kind === 'M') return up ? 'ascends' : 'descends';
if (kind === 'D') return up ? 'bursts up' : 'drops back';
if (kind === 'E') return up ? 'heaves itself up' : 'sinks';
if (kind === 'S') return up ? 'rises' : 'dives';
return up ? 'climbs' : 'drops';
}
/** How a victim is told. */
function falls(victim: Kind, owner: string, where: string): string {
switch (victim) {
case 'K': return `${owner} King is taken`;
case 'R': return `${owner} Dragon falls from the sky at ${where}`;
case 'B': return `${owner} Basilisk is crushed at ${where}, and its gaze is broken`;
case 'S': return `${owner} Sylph is scattered at ${where}`;
case 'E': return `${owner} Elemental is shattered at ${where}`;
default: return `${owner} ${NAMES[victim]} falls at ${where}`;
}
}
const possessive = (name: string) => `${name}’s`;
/** The pieces on the land held frozen, by square. */
function frozenSquares(cells: Cell[]): Set<number> {
const held = new Set<number>();
for (let i = PER_LEVEL; i < PER_LEVEL * 2; i++) if (cells[i] !== '' && frozen(cells, i)) held.add(i);
return held;
}
/** Tell one move from the position it was made in. */
function tellOne(before: Cell[], move: Move, ply: number, name: (side: Side) => string): { lines: Line[]; after: Cell[] } {
const who = possessive(name(move.by));
const foe = possessive(name(other(move.by)));
const piece = NAMES[move.kind];
const from = squareName(move.from);
const to = squareName(move.to);
const played = playOn(before, move.from, move.to);
const after = played.cells;
const lines: Line[] = [];
if (move.afar) {
lines.push({ kind: 'capture', text: `${who} Dragon strikes down from ${from} without leaving the air, and ${falls(move.captured!, foe, to)}.` });
} else if (levelOf(move.from) !== levelOf(move.to)) {
const verb = crossing(move.kind, move.from, move.to);
if (move.captured) lines.push({ kind: 'capture', text: `${who} ${piece} ${verb} from ${from} onto ${to}, and ${falls(move.captured, foe, to)}.` });
else lines.push({ kind: 'move', text: `${who} ${piece} ${verb} from ${from} to ${to}, into ${WORLD[levelOf(move.to)]}.` });
} else if (move.captured) {
lines.push({ kind: 'capture', text: `${who} ${piece} ${GOES[move.kind]} from ${from} to ${to}, and ${falls(move.captured, foe, to)}.` });
} else {
lines.push({ kind: 'move', text: `${who} ${piece} ${GOES[move.kind]} from ${from} to ${to}.` });
}
if (move.promoted) lines.push({ kind: 'promotion', text: `On the far rank the Warrior stands up a Hero.` });
// What the move did to the land beneath and above: a gaze that falls, a gaze that lifts.
const heldBefore = frozenSquares(before);
const heldAfter = frozenSquares(after);
for (const i of heldAfter) {
if (heldBefore.has(i) && !(move.afar === false && i === move.to)) continue;
const owner = sideOf(after[i]) === move.by ? who : foe;
lines.push({ kind: 'freeze', text: `The Basilisk’s gaze falls on ${owner} ${NAMES[kindOf(after[i])]} at ${squareName(i)}, which cannot stir.` });
}
for (const i of heldBefore) {
if (heldAfter.has(i) || after[i] === '' || i === move.from) continue;
const owner = sideOf(after[i]) === move.by ? who : foe;
lines.push({ kind: 'note', text: `${owner} ${NAMES[kindOf(after[i])]} at ${squareName(i)} is free to move again.` });
}
// A piece that has put itself somewhere it can do little.
const landed = after[move.to];
if (landed !== '' && !move.afar) {
const k = kindOf(landed);
const lv = levelOf(move.to);
if (k === 'K' && lv !== 2) lines.push({ kind: 'note', text: `Off the land the King can do nothing but come back.` });
if (k === 'S' && lv === 2) lines.push({ kind: 'note', text: `Grounded, the Sylph can only rise again.` });
if (k === 'E' && lv === 2) lines.push({ kind: 'note', text: `On the land the Elemental is a rock until it sinks again.` });
if (k === 'W' && !move.promoted && rankOf(move.to) === (move.by === 'gold' ? 6 : 1)) lines.push({ kind: 'note', text: `One more step and it is a Hero.` });
}
if (move.check) lines.push({ kind: 'check', text: `${foe} King is in check.` });
void ply;
return { lines, after };
}
/** The whole game told, one entry a move, from a replay of the moves. A game begun from the opening needs no `start`. */
export function narrate(state: State, start?: Cell[]): Telling[] {
const name = (side: Side) => {
const seat = state.seats.find((id) => state.players[id].side === side);
return seat ? state.players[seat].name : side === 'gold' ? 'Gold' : 'Scarlet';
};
let cells = start ?? opening(state.setup);
const out: Telling[] = [];
state.moves.forEach((move, n) => {
const { lines, after } = tellOne(cells, move, n + 1, name);
cells = after;
out.push({ ply: n + 1, move, lines });
});
if (state.over && out.length) out[out.length - 1].lines.push({ kind: 'end', text: state.over.reason });
return out;
}
/** One move told on its own, from the position it was made in. */
export function tell(before: Cell[], move: Move, name: (side: Side) => string): string {
return tellOne(before, move, 0, name).lines.map((l) => l.text).join(' ');
}
export { CELLS };