// 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 = { 3: 'the air', 2: 'the land', 1: 'the underworld' }; /** How each piece goes when it simply moves on its own board. */ const GOES: Record = { 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 { const held = new Set(); 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(' '); }