Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
142 lines
6.6 KiB
TypeScript
142 lines
6.6 KiB
TypeScript
// 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, PER_LEVEL, NAMES, frozen, kindOf, levelOf, opening, other, playOn, rankOf, sideOf, squareName } from './board';
|
||
import { playerName, type Move, type 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[];
|
||
/** The boards as they stood once the move was made. */
|
||
after: Cell[];
|
||
}
|
||
|
||
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 '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}`;
|
||
}
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/** Whose a piece is, as a possessive: "Aud’s", "Scarlet’s", or "Your" at a lesson. */
|
||
export type Owner = (side: Side) => string;
|
||
|
||
/** Tell one move from the position it was made in. */
|
||
function tellOne(before: Cell[], move: Move, owner: Owner): { lines: Line[]; after: Cell[] } {
|
||
const who = owner(move.by);
|
||
const foe = owner(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 || 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) {
|
||
// The same piece, still there and no longer held; a piece that has just arrived is another story.
|
||
if (heldAfter.has(i) || after[i] === '' || after[i] !== before[i] || i === move.from || i === move.to) 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.` });
|
||
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 owner: Owner = (side) => `${playerName(state, side)}’s`;
|
||
let cells = start ?? opening(state.setup);
|
||
const out: Telling[] = [];
|
||
state.moves.forEach((move, n) => {
|
||
const { lines, after } = tellOne(cells, move, owner);
|
||
cells = after;
|
||
out.push({ ply: n + 1, move, lines, after });
|
||
});
|
||
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, owner: Owner): string {
|
||
return tellOne(before, move, owner).lines.map((l) => l.text).join(' ');
|
||
}
|