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
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-10-26 22:38:00 -04:00
1 parent 944e4e8306
commit 0be89a1dfe
28 files changed
+220 -333

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
--- ---
name: wyrm-pulse name: wyrm-pulse
description: The weekly Wyrm operations pulse — game server health, errors, the last week of traffic and rooms from the rollup, backup status, and the box's vitals. Use when Eric asks how the server is doing, wants an ops check, or says "run the pulse". description: The weekly Wyrm operations pulse: game server health, errors, the last week of traffic and rooms from the rollup, backup status, and the box's vitals. Use when Eric asks how the server is doing, wants an ops check, or says "run the pulse".
--- ---
# Wyrm pulse # Wyrm pulse
+5 -4
View File
@@ -1,6 +1,6 @@
--- ---
name: wyrm-reports name: wyrm-reports
description: Work the Wyrm reports desk — fetch players' bug reports, show the last week's reports and replies, and process the unanswered ones end to end (replay the ledger to the pinned round, check the rules text, fix, reply). Use when Eric asks about bug reports, player feedback, or says "work the reports desk". description: Work the Wyrm reports desk: fetch players' bug reports, show the last week's reports and replies, and process the unanswered ones end to end (replay the ledger to the pinned round, check the rules text, fix, reply). Use when Eric asks about bug reports, player feedback, or says "work the reports desk".
--- ---
# The Wyrm reports desk # The Wyrm reports desk
@@ -54,9 +54,10 @@ to hear his players' voices; never compress their words into a table.
`game.resolve(state, line.inputs)` per turn line, printing the state `game.resolve(state, line.inputs)` per turn line, printing the state
around the pinned round. The `chat` lines show what the players said around the pinned round. The `chat` lines show what the players said
to each other at the time. to each other at the time.
2. **Check the rules before the code.** The rules are data in 2. **Check the rules before the code.** The rules are the article in
`src/lib/game/rules.ts` and prose at /rules, which names the texts it `docs/source.md`, with the engine's readings of its open points under
follows; read them before deciding the engine is wrong. Many reports Decisions; the moves are in `src/lib/game/board.ts` and the ends of a
game in `index.ts`. Read them before deciding the engine is wrong. Many reports
are the rules working as written. are the rules working as written.
3. **Verdict.** `by-design` (the engine matches the text; no change), 3. **Verdict.** `by-design` (the engine matches the text; no change),
`resolved` (a defect, fixed before replying), or `open` (needs Eric's `resolved` (a defect, fixed before replying), or `open` (needs Eric's
+2 -2
View File
@@ -1,6 +1,6 @@
--- ---
name: wyrm-visitors name: wyrm-visitors
description: The Wyrm visitors report — who is at the table right now, who came today and how far each game got, and today's traffic. Use when Eric asks who has been playing, about new players or visitors, "anyone playing?", or wants a traffic report. description: The Wyrm visitors report: who is at the table right now, who came today and how far each game got, and today's traffic. Use when Eric asks who has been playing, about new players or visitors, "anyone playing?", or wants a traffic report.
--- ---
# Wyrm visitors # Wyrm visitors
@@ -23,7 +23,7 @@ spills into the next UTC day. Run yesterday too when the hour is early.
- **players today / NEW today**: names seated in rooms created today; - **players today / NEW today**: names seated in rooms created today;
NEW means the name's first room ever is today. NEW means the name's first room ever is today.
- **rooms today**: one line each: time, code, humans vs bots, empty - **rooms today**: one line each: time, code, humans vs bots, empty
seats, state (lobby only / started, no turns / in play), turn count, seats, state (lobby only / started, no turns / in play / abandoned after twelve idle hours / who won, or a draw), turn count,
span, the last turn's time, and how many lines of table talk were said. span, the last turn's time, and how many lines of table talk were said.
Talk is a sign people came together; a bot game has none. Talk is a sign people came together; a bot game has none.
+4 -3
View File
@@ -18,7 +18,7 @@ service user, /opt/<slug> and /var/lib/<slug> trees, access log
small Node process capped at 300 MB by its unit file, and a room is a few small Node process capped at 300 MB by its unit file, and a room is a few
kilobytes of ledger, so the host carries several. Each game needs a port kilobytes of ledger, so the host carries several. Each game needs a port
of its own on the host: wizwar 8787, waving-hands 8788, hnefatafl 8789, of its own on the host: wizwar 8787, waving-hands 8788, hnefatafl 8789,
dip 8790. dip 8790, wyrm 8791.
## Current production ## Current production
@@ -65,8 +65,9 @@ runs every game's rollup and backup from /etc/cron.d/gamehall.
- `wyrm-backup.sh`: nightly at 07:23 UTC, mirrors /var/lib/wyrm - `wyrm-backup.sh`: nightly at 07:23 UTC, mirrors /var/lib/wyrm
to the `kestrel-wizwar-backups` Space under `wyrm/` (a current copy to the `kestrel-wizwar-backups` Space under `wyrm/` (a current copy
less the rollup, and dated snapshots kept 90 days). It needs rclone with the Spaces less the rollup, and dated snapshots kept 90 days). It needs rclone with the Spaces
credentials in /root/.config/rclone/rclone.conf, copied by hand from the credentials in /root/.config/rclone/rclone.conf, which the shared host has;
wizwar droplet; until then it logs "skipped". a game on a box of its own copies them over first, and until then the
script logs "skipped".
Errors from the game server go to Sentry, project `wyrm` in the Errors from the game server go to Sentry, project `wyrm` in the
locallygrownnet organisation; the DSN is in the unit file. On the shared host locallygrownnet organisation; the DSN is in the unit file. On the shared host
+3 -3
View File
@@ -6,7 +6,7 @@
import { createGame, applyMove, validateMove } from '../src/lib/game/index.ts'; import { createGame, applyMove, validateMove } from '../src/lib/game/index.ts';
import { chooseMove } from '../src/lib/game/bot.ts'; import { chooseMove } from '../src/lib/game/bot.ts';
import { narrate } from '../src/lib/game/chronicle.ts'; import { narrate } from '../src/lib/game/chronicle.ts';
import { square, squareName, legalMovesFrom, type Side } from '../src/lib/game/board.ts'; import { square, squareName, legalMovesFrom } from '../src/lib/game/board.ts';
import { TOLD_MOVES } from '../src/lib/game/told.ts'; import { TOLD_MOVES } from '../src/lib/game/told.ts';
const FORCED: Record<number, [string, string]> = Object.fromEntries(TOLD_MOVES.map((m, n) => [n + 1, m])); const FORCED: Record<number, [string, string]> = Object.fromEntries(TOLD_MOVES.map((m, n) => [n + 1, m]));
@@ -17,7 +17,7 @@ let s = createGame({ A: 'Gold', B: 'Scarlet' });
for (let ply = 1; ply <= plies && !s.over; ply++) { for (let ply = 1; ply <= plies && !s.over; ply++) {
const seat = s.toMove === 'gold' ? 'A' : 'B'; const seat = s.toMove === 'gold' ? 'A' : 'B';
if (ply === askPly && askSquare) { if (ply === askPly && askSquare) {
console.log(`ply ${ply}, ${s.toMove} to move, ${askSquare} may go to: ${legalMovesFrom(s.cells, square(askSquare), 'printed').map(squareName).join(' ')}`); console.log(`ply ${ply}, ${s.toMove} to move, ${askSquare} may go to: ${legalMovesFrom(s.cells, square(askSquare), s.setup).map(squareName).join(' ')}`);
process.exit(0); process.exit(0);
} }
const forced = FORCED[ply]; const forced = FORCED[ply];
@@ -29,7 +29,7 @@ for (let ply = 1; ply <= plies && !s.over; ply++) {
console.log(`ply ${ply}: forced ${forced[0]}-${forced[1]} refused: ${why}`); console.log(`ply ${ply}: forced ${forced[0]}-${forced[1]} refused: ${why}`);
process.exit(1); process.exit(1);
} }
} else move = chooseMove(s.cells, s.toMove as Side, 'printed', 40000)!; } else move = chooseMove(s.cells, s.toMove, s.setup, 40000)!;
s = applyMove(s, move.from, move.to); s = applyMove(s, move.from, move.to);
} }
for (const t of narrate(s)) console.log(`${t.ply}${FORCED[t.ply] ? '*' : ' '} ${t.lines.map((l) => l.text).join(' ')}`); for (const t of narrate(s)) console.log(`${t.ply}${FORCED[t.ply] ? '*' : ' '} ${t.lines.map((l) => l.text).join(' ')}`);
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<!-- The share card, 1200 by 630. Open it with the site running at localhost:5173 and screenshot the body: docs/artwork.md. --> <!-- The share card, 1200 by 630. Open it with the site running at localhost:5173 and screenshot the body at 1200 by 630 into static/. -->
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
+2 -2
View File
@@ -29,7 +29,7 @@
// //
// Caddy serves the static site and proxies /api and /ws here. The keeper's // Caddy serves the static site and proxies /api and /ws here. The keeper's
// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE // door answers only on the host itself, and only to the key in KEEPER_KEY_FILE
// (deploy/wyrm-reseat.sh sends it): // (the deploy/wyrm-*.sh scripts send it):
// //
// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke // GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them // POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
@@ -389,7 +389,7 @@ server.on('upgrade', (req, socket, head) => {
/** /**
* A browser that died without closing (a crashed tab, a sleeping laptop, a dropped network) leaves its socket * A browser that died without closing (a crashed tab, a sleeping laptop, a dropped network) leaves its socket
* open until TCP gives up, counted in the gallery all the while. The protocol's own ping, which every browser * open until TCP gives up, counted in the gallery all the while. The protocol's own ping, which every browser
* answers by itself, finds it within two rounds. * answers by itself, finds it: one silent through a whole round is closed.
*/ */
const answered = new WeakMap<WebSocket, boolean>(); const answered = new WeakMap<WebSocket, boolean>();
setInterval(() => { setInterval(() => {
+4 -4
View File
@@ -22,7 +22,7 @@ const GOLD_OPENS: Input = { from: square('2c2'), to: square('2c3') };
const SCARLET_ANSWERS: Input = { from: square('2c7'), to: square('2c6') }; const SCARLET_ANSWERS: Input = { from: square('2c7'), to: square('2c6') };
/** The bots move once the request that made their move due has been answered: after this tick. */ /** The bots move once the request that made their move due has been answered: after this tick. */
const settled = () => new Promise<void>((r) => setImmediate(r)); const answered = () => new Promise<void>((r) => setImmediate(r));
/** A table of two: Ann, who opened it and plays Gold, and Bo, the game begun. */ /** A table of two: Ann, who opened it and plays Gold, and Bo, the game begun. */
function table(spec: GameSpec<State, Input> = game) { function table(spec: GameSpec<State, Input> = game) {
@@ -138,7 +138,7 @@ describe('rooms', () => {
expect(host.room.state!.setup).toBe('shifted'); expect(host.room.state!.setup).toBe('shifted');
// Gold moves first, and Gold is the drake: it moves once the host's request has been answered, not inside it. // Gold moves first, and Gold is the drake: it moves once the host's request has been answered, not inside it.
expect(host.room.state!.moves).toHaveLength(0); expect(host.room.state!.moves).toHaveLength(0);
await settled(); await answered();
expect(host.room.state!.moves).toHaveLength(1); expect(host.room.state!.moves).toHaveLength(1);
expect(rooms.waitingOn(host.room)).toEqual(['A']); expect(rooms.waitingOn(host.room)).toEqual(['A']);
}); });
@@ -205,13 +205,13 @@ describe('the host and the other seats', () => {
rooms.submit(room, ann, GOLD_OPENS, 0); rooms.submit(room, ann, GOLD_OPENS, 0);
rooms.standIn(room, ann, 'B'); rooms.standIn(room, ann, 'B');
// The move was Bo's; the drake makes it as soon as the host has been answered. // The move was Bo's; the drake makes it as soon as the host has been answered.
await settled(); await answered();
expect(room.resolved).toBe(2); expect(room.resolved).toBe(2);
expect(rooms.waitingOn(room)).toEqual(['A']); expect(rooms.waitingOn(room)).toEqual(['A']);
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a drake/); expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a drake/);
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true }); expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
rooms.submit(room, ann, { from: square('2d2'), to: square('2d3') }, 2); rooms.submit(room, ann, { from: square('2d2'), to: square('2d3') }, 2);
await settled(); await answered();
expect(room.resolved).toBe(4); expect(room.resolved).toBe(4);
}); });
+13 -4
View File
@@ -3,6 +3,12 @@
// same place. Inputs wait in memory until every seat that must move has moved; // same place. Inputs wait in memory until every seat that must move has moved;
// then the round resolves at once, bots included. Nothing here knows the // then the round resolves at once, bots included. Nothing here knows the
// game beyond the GameSpec it is given. // game beyond the GameSpec it is given.
//
// This is the kit's room server, kept whole so the kit's fixes land by copy.
// Wyrm seats two and moves one side at a time, so only one seat ever owes a
// move and the paced play of bots left alone at a table never arises here.
// The strings a player reads say drake, the game's word for its house
// player; the code and its comments keep the kit's word, bot.
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { cleanOptions, SPECTATOR, tableSize, type GameSpec, type SeatId, type TableOptions } from '../../src/lib/game/spec'; import { cleanOptions, SPECTATOR, tableSize, type GameSpec, type SeatId, type TableOptions } from '../../src/lib/game/spec';
@@ -361,10 +367,14 @@ export class Rooms<State, Input> {
* to hang and the bot's to come at once. The table is told as each move * to hang and the bot's to come at once. The table is told as each move
* lands, the same as any other. * lands, the same as any other.
*/ */
/** A room forgotten and loaded again drives itself; a timer or tick that wakes with the old one must do nothing. */
private isTable(room: Room<State>): boolean {
return this.rooms.get(room.id) === room;
}
private botsAfterAnswering(room: Room<State>): void { private botsAfterAnswering(room: Room<State>): void {
setImmediate(() => { setImmediate(() => {
// A room forgotten and loaded again drives itself; this one is no longer the table. if (!this.isTable(room)) return;
if (this.rooms.get(room.id) !== room) return;
try { try {
this.driveBots(room); this.driveBots(room);
this.botsSpeak(room, 'phase'); this.botsSpeak(room, 'phase');
@@ -403,8 +413,7 @@ export class Rooms<State, Input> {
const hurried = this.hurried.has(room.id); const hurried = this.hurried.has(room.id);
const next = setTimeout(() => { const next = setTimeout(() => {
this.pacing.delete(room.id); this.pacing.delete(room.id);
// A room forgotten and loaded again drives itself; this one is no longer the table. if (!this.isTable(room)) return;
if (this.rooms.get(room.id) !== room) return;
this.driveBots(room, hurried ? HURRIED_STEP : 1); this.driveBots(room, hurried ? HURRIED_STEP : 1);
// Played out: the bots have their say once, as after any round, which ends a game with parting words. // Played out: the bots have their say once, as after any round, which ends a game with parting words.
if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase'); if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase');
+25
View File
@@ -196,6 +196,31 @@ button:disabled {
max-width: 40em; max-width: 40em;
} }
/* A lesson: the boards beside the words. */
.reading.wide {
max-width: 1280px;
}
/* The chronicle's lines, coloured by what they tell, at the table and in a lesson. */
.telling li.capture {
color: var(--ember);
}
.telling li.check,
.telling li.freeze,
.telling li.promotion {
color: var(--frost);
}
.telling li.note {
color: var(--bone-dim);
font-style: italic;
}
.telling li.end {
color: var(--blood);
}
.reading section { .reading section {
padding: 1.25rem 0 0.5rem; padding: 1.25rem 0 0.5rem;
border-top: 1px solid var(--rule); border-top: 1px solid var(--rule);
+14 -42
View File
@@ -9,7 +9,7 @@
import { narrate } from '$lib/game/chronicle'; import { narrate } from '$lib/game/chronicle';
import BoardArtwork, { type Token } from './BoardArtwork.svelte'; import BoardArtwork, { type Token } from './BoardArtwork.svelte';
import { import {
legalMovesFrom, movesFrom, squareName, sideOf, kindOf, kingSquare, inCheck, levelOf, fileOf, rankOf, idx, frozen, NAMES, FILES, RANKS, LEVELS, legalMovesFrom, movesFrom, squareName, sideOf, kindOf, kingSquare, inCheck, levelOf, frozen, NAMES, SIDE_NAMES,
type Move, type Side type Move, type Side
} from '$lib/game'; } from '$lib/game';
import type { Room } from '$lib/net/room.svelte'; import type { Room } from '$lib/net/room.svelte';
@@ -24,11 +24,9 @@
const seat = g.seats.find((id) => g.players[id].side === side); const seat = g.seats.find((id) => g.players[id].side === side);
return seat ? g.players[seat].name : side; return seat ? g.players[seat].name : side;
}; };
const sideName = (side: Side) => (side === 'gold' ? 'Gold' : 'Scarlet');
const flipped = $derived(mySide === 'scarlet'); const flipped = $derived(mySide === 'scarlet');
const inCheckNow = $derived(!g.over && inCheck(g.cells, g.toMove)); const inCheckNow = $derived(!g.over && inCheck(g.cells, g.toMove));
const checkSquare = $derived(inCheckNow ? kingSquare(g.cells, g.toMove) : -1); const checkSquare = $derived(inCheckNow ? kingSquare(g.cells, g.toMove) : -1);
const label = squareName;
// --- Tokens: pieces with a life of their own, so a move slides, a capture fades and a Warrior stands up a Hero --- // --- Tokens: pieces with a life of their own, so a move slides, a capture fades and a Warrior stands up a Hero ---
let tokens = $state<Token[]>([]); let tokens = $state<Token[]>([]);
@@ -102,7 +100,7 @@
} }
const shownStory = $derived(shownPly > 0 ? storyOf(shownPly) : ''); const shownStory = $derived(shownPly > 0 ? storyOf(shownPly) : '');
// --- Choosing and moving --------------------------------------------------- // --- Choosing and moving ---
let selected = $state<number | null>(null); let selected = $state<number | null>(null);
const targets = $derived(selected === null ? [] : legalMovesFrom(g.cells, selected, g.setup)); const targets = $derived(selected === null ? [] : legalMovesFrom(g.cells, selected, g.setup));
/** Any piece, tapped to read about it; the panel beside the boards tells what it is and what it can do from there. */ /** Any piece, tapped to read about it; the panel beside the boards tells what it is and what it can do from there. */
@@ -128,7 +126,7 @@
await room.submit({ from, to }); await room.submit({ from, to });
} }
const wordFor = (i: number) => `${sideOf(g.cells[i]) === 'gold' ? 'Gold' : 'Scarlet'} ${NAMES[kindOf(g.cells[i])]}`; const wordFor = (i: number) => `${SIDE_NAMES[sideOf(g.cells[i])!]} ${NAMES[kindOf(g.cells[i])]}`;
async function tap(i: number) { async function tap(i: number) {
if (room.sending || animating) return; if (room.sending || animating) return;
@@ -136,13 +134,13 @@
if (!yourMove) { if (!yourMove) {
// Not your move, or no seat: a tap on a piece still opens its panel. // Not your move, or no seat: a tap on a piece still opens its panel.
inspected = g.cells[i] !== '' ? (inspected === i ? null : i) : null; inspected = g.cells[i] !== '' ? (inspected === i ? null : i) : null;
if (inspected !== null) announce(`${wordFor(i)} on ${label(i)}. About this piece is shown beside the boards.`); if (inspected !== null) announce(`${wordFor(i)} on ${squareName(i)}. About this piece is shown beside the boards.`);
return; return;
} }
if (selected !== null && targets.includes(i)) { if (selected !== null && targets.includes(i)) {
if (prefs.confirmMoves === true) { if (prefs.confirmMoves === true) {
pending = { from: selected, to: i }; pending = { from: selected, to: i };
announce(`Move ${label(selected)} to ${label(i)}? Confirm or cancel below.`); announce(`Move ${squareName(selected)} to ${squareName(i)}? Confirm or cancel below.`);
return; return;
} }
await send(selected, i); await send(selected, i);
@@ -156,44 +154,18 @@
if (selected !== null) { if (selected !== null) {
const to = legalMovesFrom(g.cells, i, g.setup); const to = legalMovesFrom(g.cells, i, g.setup);
const why = frozen(g.cells, i) ? ' It is frozen by the Basilisk beneath it.' : ''; const why = frozen(g.cells, i) ? ' It is frozen by the Basilisk beneath it.' : '';
announce(`Selected ${wordFor(i)} on ${label(i)}. ${to.length} ${to.length === 1 ? 'square' : 'squares'} to move to${to.length ? ': ' + to.map(label).join(', ') : ''}.${why}`); announce(`Selected ${wordFor(i)} on ${squareName(i)}. ${to.length} ${to.length === 1 ? 'square' : 'squares'} to move to${to.length ? ': ' + to.map(squareName).join(', ') : ''}.${why}`);
} else announce('Selection cleared.'); } else announce('Selection cleared.');
} else selected = null; } else selected = null;
} }
function focusSquare(i: number) { function escape() {
document.querySelector<SVGRectElement>(`rect.square[data-i="${i}"]`)?.focus();
}
/** Arrows walk a board, Page Up and Page Down climb and descend, Enter picks up and sets down. */
function key(e: KeyboardEvent, i: number) {
const f = fileOf(i);
const r = rankOf(i);
const lv = levelOf(i);
const step: Record<string, [number, number, number]> = {
ArrowLeft: [flipped ? 1 : -1, 0, 0], ArrowRight: [flipped ? -1 : 1, 0, 0], ArrowUp: [0, flipped ? -1 : 1, 0], ArrowDown: [0, flipped ? 1 : -1, 0],
PageUp: [0, 0, 1], PageDown: [0, 0, -1]
};
if (e.key in step) {
e.preventDefault();
const [df, dr, dl] = step[e.key];
const nf = f + df;
const nr = r + dr;
const nl = lv + dl;
if (nf >= 0 && nf < FILES && nr >= 0 && nr < RANKS && nl >= 1 && nl <= LEVELS) focusSquare(idx(nl, nf, nr));
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
void tap(i);
} else if (e.key === 'Escape') {
selected = null; selected = null;
inspected = null; inspected = null;
reviewing = null; reviewing = null;
pending = null; pending = null;
announce('Selection cleared.'); announce('Selection cleared.');
} }
}
const verdict = $derived.by(() => { const verdict = $derived.by(() => {
if (!g.over) return ''; if (!g.over) return '';
@@ -210,27 +182,27 @@
{#if g.over} {#if g.over}
<strong>{verdict}</strong> <span class="muted">{g.over.reason}</span> <strong>{verdict}</strong> <span class="muted">{g.over.reason}</span>
{:else if room.spectating} {:else if room.spectating}
<strong>{nameOfSide(g.toMove)} to move</strong> · {sideName(g.toMove)}{#if inCheckNow}&nbsp;· <span class="check">in check</span>{/if} · <span class="muted">move {g.moves.length + 1}</span> <strong>{nameOfSide(g.toMove)} to move</strong> · {SIDE_NAMES[g.toMove]}{#if inCheckNow}&nbsp;· <span class="check">in check</span>{/if} · <span class="muted">move {g.moves.length + 1}</span>
{:else if yourMove} {:else if yourMove}
<strong>Your move</strong> · {sideName(mySide!)}{#if inCheckNow}&nbsp;· <span class="check">your King is in check</span>{/if} · <span class="goal">Checkmate {nameOfSide(g.toMove === 'gold' ? 'scarlet' : 'gold')}’s King.</span> <strong>Your move</strong> · {SIDE_NAMES[mySide!]}{#if inCheckNow}&nbsp;· <span class="check">your King is in check</span>{/if} · <span class="goal">Checkmate {nameOfSide(g.toMove === 'gold' ? 'scarlet' : 'gold')}’s King.</span>
{:else if room.view.playingOn} {:else if room.view.playingOn}
<strong>The drakes play on</strong> · <span class="muted">a move every few seconds</span>{#if !room.spectating}&nbsp;<button type="button" class="link" onclick={() => room.hurry()}>play the rest at once</button>{/if} <strong>The drakes play on</strong> · <span class="muted">a move every few seconds</span>&nbsp;<button type="button" class="link" onclick={() => room.hurry()}>play the rest at once</button>
{:else} {:else}
<strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {sideName(mySide!)}{#if inCheckNow}&nbsp;· <span class="check">{nameOfSide(g.toMove)} is in check</span>{/if} <strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {SIDE_NAMES[mySide!]}{#if inCheckNow}&nbsp;· <span class="check">{nameOfSide(g.toMove)} is in check</span>{/if}
{/if} {/if}
</div> </div>
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted)} {#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted)}
<p class="story" class:capture={shownCaptured.length > 0}>{shownStory}{#if reviewing !== null}&nbsp;<button type="button" class="link" onclick={() => (reviewing = null)}>back to now</button>{/if}</p> <p class="story" class:capture={shownCaptured.length > 0}>{shownStory}{#if reviewing !== null}&nbsp;<button type="button" class="link" onclick={() => (reviewing = null)}>back to now</button>{/if}</p>
{/if} {/if}
{#if pending} {#if pending}
<p class="confirm">Move {label(pending.from)} to {label(pending.to)}? <p class="confirm">Move {squareName(pending.from)} to {squareName(pending.to)}?
<button type="button" class="commit small" onclick={() => pending && send(pending.from, pending.to)}>Confirm</button> <button type="button" class="commit small" onclick={() => pending && send(pending.from, pending.to)}>Confirm</button>
<button type="button" class="quiet" onclick={() => (pending = null)}>Cancel</button></p> <button type="button" class="quiet" onclick={() => (pending = null)}>Cancel</button></p>
{/if} {/if}
{#if room.error}<p class="warning">{room.error}</p>{/if} {#if room.error}<p class="warning">{room.error}</p>{/if}
<div class="frame"> <div class="frame">
<BoardArtwork {g} {tokens} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} yourMove={true} {calm} {tap} {key} /> <BoardArtwork {g} {tokens} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
</div> </div>
<p class="visually-hidden" aria-live="polite">{liveText}</p> <p class="visually-hidden" aria-live="polite">{liveText}</p>
@@ -256,7 +228,7 @@
<li class:to-move={g.players[id].side === g.toMove && !g.over}> <li class:to-move={g.players[id].side === g.toMove && !g.over}>
<span class="dot {g.players[id].side}"></span> <span class="dot {g.players[id].side}"></span>
<span>{g.players[id].name}{id === room.me ? ' (you)' : ''}</span> <span>{g.players[id].name}{id === room.me ? ' (you)' : ''}</span>
<span class="muted">{sideName(g.players[id].side)}, {pieceCount(g.players[id].side)} pieces</span> <span class="muted">{SIDE_NAMES[g.players[id].side]}, {pieceCount(g.players[id].side)} pieces</span>
</li> </li>
{/each} {/each}
</ul> </ul>
+30 -9
View File
@@ -13,12 +13,12 @@
<script lang="ts"> <script lang="ts">
// The three boards, drawn one above the other: the air, the land and the // The three boards, drawn one above the other: the air, the land and the
// underworld, each twelve by eight in the colours Gygax suggested, with // underworld, each twelve by eight in the colours Gygax suggested, with
// the pieces as discs bearing his letters. Shared by the table, the hall's // the pieces as discs bearing his letters. Shared by the table, the
// still of the opening and the share artwork. // lessons and the hall's still of the opening.
import { FILES, RANKS, PER_LEVEL, fileOf, rankOf, levelOf, isLight, kindOf, sideOf, frozen, squareName, LETTERS, NAMES, type State } from '$lib/game'; import { FILES, RANKS, PER_LEVEL, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, squareName, LETTERS, NAMES, SIDE_NAMES, type State } from '$lib/game';
let { let {
g, tokens, levels = [3, 2, 1], showCoords = false, flipped = false, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1, g, tokens, levels = [3, 2, 1], showCoords = false, flipped = false, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1,
yourMove = false, calm = true, decorative = false, tap = () => {}, key = () => {} calm = true, decorative = false, tap = () => {}, escape = () => {}
}: { }: {
g: State; g: State;
tokens?: Token[]; tokens?: Token[];
@@ -34,13 +34,34 @@
shownSquares?: number[]; shownSquares?: number[];
shownCaptured?: number[]; shownCaptured?: number[];
checkSquare?: number; checkSquare?: number;
yourMove?: boolean;
calm?: boolean; calm?: boolean;
/** An illustration: no roles, no tab stops, nothing to tap. */
decorative?: boolean; decorative?: boolean;
tap?: (i: number) => void | Promise<void>; tap?: (i: number) => void | Promise<void>;
key?: (e: KeyboardEvent, i: number) => void; /** Escape pressed on a square: the caller lets go of whatever it was doing. */
escape?: () => void;
} = $props(); } = $props();
let svg: SVGSVGElement | undefined = $state();
/** Arrows walk a board the way it is drawn, Page Up and Page Down climb and descend, Enter picks up and sets down. */
function key(e: KeyboardEvent, i: number) {
const step: Record<string, [number, number, number]> = {
ArrowLeft: [flipped ? 1 : -1, 0, 0], ArrowRight: [flipped ? -1 : 1, 0, 0], ArrowUp: [0, flipped ? -1 : 1, 0], ArrowDown: [0, flipped ? 1 : -1, 0],
PageUp: [0, 0, 1], PageDown: [0, 0, -1]
};
if (e.key in step) {
e.preventDefault();
const [df, dr, dl] = step[e.key];
const f = fileOf(i) + df;
const r = rankOf(i) + dr;
const lv = levelOf(i) + dl;
if (f >= 0 && f < FILES && r >= 0 && r < RANKS && levels.includes(lv)) svg?.querySelector<SVGRectElement>(`rect.square[data-i="${idx(lv, f, r)}"]`)?.focus();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
void tap(i);
} else if (e.key === 'Escape') escape();
}
const artworkTokens = $derived(tokens ?? g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: at, piece, at, dying: false }]))); const artworkTokens = $derived(tokens ?? g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: at, piece, at, dying: false }])));
const CELL = 40; const CELL = 40;
const PAD_L = $derived(showCoords ? 18 : 6); const PAD_L = $derived(showCoords ? 18 : 6);
@@ -70,14 +91,14 @@
const describe = (i: number) => { const describe = (i: number) => {
const piece = g.cells[i]; const piece = g.cells[i];
let text = squareName(i); let text = squareName(i);
if (piece !== '') text += `, ${sideOf(piece) === 'gold' ? 'Gold' : 'Scarlet'} ${NAMES[kindOf(piece)]}${frozen(g.cells, i) ? ', frozen' : ''}`; if (piece !== '') text += `, ${SIDE_NAMES[sideOf(piece)!]} ${NAMES[kindOf(piece)]}${frozen(g.cells, i) ? ', frozen' : ''}`;
if (targets.includes(i)) text += ', a legal move'; if (targets.includes(i)) text += ', a legal move';
if (i === checkSquare) text += ', in check'; if (i === checkSquare) text += ', in check';
return text; return text;
}; };
</script> </script>
<svg xmlns="http://www.w3.org/2000/svg" class="artwork" class:calm viewBox="0 0 {width} {height}" role={decorative ? 'img' : 'grid'} aria-label={decorative ? 'The three boards in their opening position' : 'The three boards'}> <svg xmlns="http://www.w3.org/2000/svg" bind:this={svg} class="artwork" class:calm viewBox="0 0 {width} {height}" role={decorative ? 'img' : 'grid'} aria-label={decorative ? 'The three boards in their opening position' : 'The three boards'}>
{#each levels as lv, slot (lv)} {#each levels as lv, slot (lv)}
{@const top = slot * (boardHeight + GAP)} {@const top = slot * (boardHeight + GAP)}
<g class="world" data-level={lv}> <g class="world" data-level={lv}>
@@ -114,7 +135,7 @@
role={decorative ? undefined : 'gridcell'} role={decorative ? undefined : 'gridcell'}
aria-label={decorative ? undefined : describe(i)} aria-label={decorative ? undefined : describe(i)}
aria-selected={decorative ? undefined : selected === i} aria-selected={decorative ? undefined : selected === i}
tabindex={decorative ? undefined : yourMove ? 0 : -1} tabindex={decorative ? undefined : 0}
onclick={decorative ? undefined : () => tap(i)} onclick={decorative ? undefined : () => tap(i)}
onkeydown={decorative ? undefined : (e) => key(e, i)} onkeydown={decorative ? undefined : (e) => key(e, i)}
/> />
+1 -19
View File
@@ -29,7 +29,7 @@
Move {t.ply} <span class="notation">{notation(t.move)}</span> Move {t.ply} <span class="notation">{notation(t.move)}</span>
</button> </button>
</h3> </h3>
<ul> <ul class="telling">
{#each t.lines as l, i (i)} {#each t.lines as l, i (i)}
<li class={l.kind}>{l.text}</li> <li class={l.kind}>{l.text}</li>
{/each} {/each}
@@ -97,22 +97,4 @@
color: var(--bone); color: var(--bone);
} }
li.capture {
color: var(--ember);
}
li.check,
li.freeze,
li.promotion {
color: var(--frost);
}
li.note {
color: var(--bone-dim);
font-style: italic;
}
li.end {
color: var(--blood);
}
</style> </style>
+2 -3
View File
@@ -48,14 +48,13 @@
{ n: t.turnsPlayed, label: 'turns recorded' }, { n: t.turnsPlayed, label: 'turns recorded' },
{ n: hours(t.minutesAtTable), label: 'at the table' } { n: hours(t.minutesAtTable), label: 'at the table' }
]; ];
const onCards = new Set<string>([]);
const details = [ const details = [
{ n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` }, { n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' }, { n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
{ n: t.turnsPlayed, label: `turns recorded; ${t.longestGameTurns} in the longest game` }, { n: t.turnsPlayed, label: `turns recorded; ${t.longestGameTurns} in the longest game` },
{ n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a bot seated' }, { n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a bot seated' },
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []), ...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
...Object.entries(t.byGame).filter(([label]) => !onCards.has(label)).map(([label, n]) => ({ n, label })) ...Object.entries(t.byGame).map(([label, n]) => ({ n, label }))
]; ];
const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : ''; const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.` }; return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.` };
@@ -321,7 +320,7 @@
<!-- The about panel, a tab at a time: its sections are written here, in the game's own words. --> <!-- The about panel, a tab at a time: its sections are written here, in the game's own words. -->
{#snippet aboutGame()} {#snippet aboutGame()}
<div class="about-body"> <div class="about-body">
<p>Gary Gygax, who made Dungeons &amp; Dragons, published Dragonchess in the hundredth issue of Dragon Magazine, in August 1985, as seven pages of rules and no board. He played it against himself on three sheets of plexiglass with metal miniatures for pieces, and told readers that three rectangles laid in a row on a table would do. The keeper has that issue on the shelf and built the boards it never came with.</p> <p>Gary Gygax, who made Dungeons &amp; Dragons, published Dragonchess in the hundredth issue of Dragon Magazine, in August 1985, as seven pages of rules and no board. It was playtested on three sheets of plexiglass with metal miniatures for pieces, and he told readers that three rectangles laid in a row on a table would do. The keeper has that issue on the shelf and built the boards it never came with.</p>
<p>Wyrm plays the game as the article gives it, every piece as written, with the few open readings settled on the <a href="/rules">rules page</a>, where the article is transcribed in full. The house player is a drake, a search that reads a few moves ahead and values the pieces roughly as Gygax did, so it is fair company for a first game and beatable by a careful one. The name is this site's own. Dragonchess was Gygax's and TSR's, and another company sells a game called Dragon Chess that has nothing to do with this one.</p> <p>Wyrm plays the game as the article gives it, every piece as written, with the few open readings settled on the <a href="/rules">rules page</a>, where the article is transcribed in full. The house player is a drake, a search that reads a few moves ahead and values the pieces roughly as Gygax did, so it is fair company for a first game and beatable by a careful one. The name is this site's own. Dragonchess was Gygax's and TSR's, and another company sells a game called Dragon Chess that has nothing to do with this one.</p>
<p>It is free and keeps no accounts. A game lives on a small server as an append-only ledger of moves, so it can be replayed from its first move, and nothing in it is hidden from either player or from the gallery.</p> <p>It is free and keeps no accounts. A game lives on a small server as an append-only ledger of moves, so it can be replayed from its first move, and nothing in it is hidden from either player or from the gallery.</p>
</div> </div>
+17 -45
View File
@@ -1,37 +1,30 @@
<script lang="ts"> <script lang="ts">
// A board for a lesson: the scene's position, the player's side to move, // A board for a lesson: the scene's position, the reader's side to move,
// two taps or a keyboard to move, and the move handed back to the page. // two taps or a keyboard to move, and the move handed back to the page.
// No room, no server; the engine judges the move here. // No room, no server; the engine judges the move here.
import BoardArtwork from './BoardArtwork.svelte'; import BoardArtwork from './BoardArtwork.svelte';
import { createGame, legalMovesFrom, movesFrom, squareName, sideOf, kindOf, kingSquare, inCheck, levelOf, fileOf, rankOf, idx, NAMES, FILES, RANKS, LEVELS, type Cell, type Side } from '$lib/game'; import { legalMovesFrom, movesFrom, squareName, sideOf, kindOf, kingSquare, inCheck, NAMES, SIDE_NAMES, type State } from '$lib/game';
import { prefs, reducedMotion } from '$lib/prefs.svelte'; import { prefs, reducedMotion } from '$lib/prefs.svelte';
let { cells, side, levels, locked = false, onMove, onInspect }: { let { g, levels, locked = false, onMove, inspected = $bindable(null) }: {
cells: Cell[]; /** The scene as a game: its cells, and the reader's side to move. */
side: Side; g: State;
levels: number[]; levels: number[];
/** The move has been made and told: no more moving until the scene is set again. */ /** The move has been made and told: no more moving until the scene is set again. */
locked?: boolean; locked?: boolean;
onMove: (from: number, to: number) => void; onMove: (from: number, to: number) => void;
onInspect?: (at: number | null) => void; /** A piece tapped to read about, which the page shows in its panel. */
inspected?: number | null;
} = $props(); } = $props();
/** A state around the cells, for the artwork and the panel, which read a game. */
const g = $derived.by(() => {
const s = createGame({ A: 'You', B: 'Scarlet' });
s.cells = cells;
s.toMove = side;
return s;
});
let selected = $state<number | null>(null); let selected = $state<number | null>(null);
const targets = $derived(selected === null || locked ? [] : legalMovesFrom(cells, selected, 'printed')); const targets = $derived(selected === null || locked ? [] : legalMovesFrom(g.cells, selected, 'printed'));
let inspected = $state<number | null>(null); const reach = $derived(inspected !== null && inspected !== selected && g.cells[inspected] !== '' ? movesFrom(g.cells, inspected, 'printed') : []);
const reach = $derived(inspected !== null && inspected !== selected && cells[inspected] !== '' ? movesFrom(cells, inspected, 'printed') : []); const checkSquare = $derived(inCheck(g.cells, g.toMove) ? kingSquare(g.cells, g.toMove) : -1);
const checkSquare = $derived(inCheck(cells, side) ? kingSquare(cells, side) : -1);
const calm = $derived(reducedMotion()); const calm = $derived(reducedMotion());
const showCoords = $derived(prefs.coordinates !== false); const showCoords = $derived(prefs.coordinates !== false);
$effect(() => { $effect(() => {
void cells; void g.cells;
selected = null; selected = null;
inspected = null; inspected = null;
}); });
@@ -45,46 +38,25 @@
onMove(from, i); onMove(from, i);
return; return;
} }
inspected = cells[i] !== '' ? (inspected === i && selected === i ? null : i) : null; inspected = g.cells[i] !== '' ? (inspected === i && selected === i ? null : i) : null;
onInspect?.(inspected); if (!locked && sideOf(g.cells[i]) === g.toMove) {
if (!locked && sideOf(cells[i]) === side) {
selected = selected === i ? null : i; selected = selected === i ? null : i;
if (selected === null) inspected = null; if (selected === null) inspected = null;
if (selected !== null) { if (selected !== null) {
const to = legalMovesFrom(cells, i, 'printed'); const to = legalMovesFrom(g.cells, i, 'printed');
liveText = `Selected ${NAMES[kindOf(cells[i])]} on ${squareName(i)}. ${to.length} ${to.length === 1 ? 'square' : 'squares'} to move to${to.length ? ': ' + to.map(squareName).join(', ') : ''}.`; liveText = `Selected ${SIDE_NAMES[g.toMove]} ${NAMES[kindOf(g.cells[i])]} on ${squareName(i)}. ${to.length} ${to.length === 1 ? 'square' : 'squares'} to move to${to.length ? ': ' + to.map(squareName).join(', ') : ''}.`;
} }
} else selected = null; } else selected = null;
} }
function focusSquare(i: number) { function escape() {
document.querySelector<SVGRectElement>(`rect.square[data-i="${i}"]`)?.focus();
}
function key(e: KeyboardEvent, i: number) {
const step: Record<string, [number, number, number]> = { ArrowLeft: [-1, 0, 0], ArrowRight: [1, 0, 0], ArrowUp: [0, 1, 0], ArrowDown: [0, -1, 0], PageUp: [0, 0, 1], PageDown: [0, 0, -1] };
if (e.key in step) {
e.preventDefault();
const [df, dr, dl] = step[e.key];
const nf = fileOf(i) + df;
const nr = rankOf(i) + dr;
const nl = levelOf(i) + dl;
if (nf >= 0 && nf < FILES && nr >= 0 && nr < RANKS && nl >= 1 && nl <= LEVELS && levels.includes(nl)) focusSquare(idx(nl, nf, nr));
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
tap(i);
} else if (e.key === 'Escape') {
selected = null; selected = null;
inspected = null; inspected = null;
onInspect?.(null);
}
} }
</script> </script>
<div class="frame"> <div class="frame">
<BoardArtwork {g} {levels} {showCoords} {selected} {targets} {reach} {checkSquare} yourMove={true} {calm} {tap} {key} /> <BoardArtwork {g} {levels} {showCoords} {selected} {targets} {reach} {checkSquare} {calm} {tap} {escape} />
</div> </div>
<p class="visually-hidden" aria-live="polite">{liveText}</p> <p class="visually-hidden" aria-live="polite">{liveText}</p>
+3 -5
View File
@@ -4,8 +4,7 @@
// how to play it. The lore is src/lib/game/lore.ts; the rest is read off // how to play it. The lore is src/lib/game/lore.ts; the rest is read off
// the board. // the board.
import { LORE } from '$lib/game/lore'; import { LORE } from '$lib/game/lore';
import { NAMES, LETTERS, kindOf, sideOf, levelOf, rankOf, frozen, movesFrom, legalMovesFrom, squareName, inCheck, type State, type Side } from '$lib/game'; import { NAMES, LETTERS, SIDE_NAMES, VALUES, PER_LEVEL, kindOf, sideOf, levelOf, rankOf, frozen, movesFrom, legalMovesFrom, squareName, inCheck, type State, type Side } from '$lib/game';
import { VALUES, PER_LEVEL } from '$lib/game/board';
let { g, at, mySide, close }: { g: State; at: number; mySide: Side | null; close: () => void } = $props(); let { g, at, mySide, close }: { g: State; at: number; mySide: Side | null; close: () => void } = $props();
@@ -13,15 +12,14 @@
const kind = $derived(kindOf(cell)); const kind = $derived(kindOf(cell));
const side = $derived(sideOf(cell)!); const side = $derived(sideOf(cell)!);
const lore = $derived(LORE[kind]); const lore = $derived(LORE[kind]);
const sideWord = $derived(side === 'gold' ? 'Gold' : 'Scarlet'); const owner = $derived(side === mySide ? 'Your' : `${SIDE_NAMES[side]}’s`);
const owner = $derived(mySide === null ? `${sideWord}’s` : side === mySide ? 'Your' : `${sideWord}’s`);
const world = (i: number) => (levelOf(i) === 3 ? 'in the air' : levelOf(i) === 1 ? 'in the underworld' : 'on the land'); const world = (i: number) => (levelOf(i) === 3 ? 'in the air' : levelOf(i) === 1 ? 'in the underworld' : 'on the land');
const value = $derived(kind === 'K' ? 'beyond price' : String(VALUES[kind]).replace('.5', '½')); const value = $derived(kind === 'K' ? 'beyond price' : String(VALUES[kind]).replace('.5', '½'));
/** Whose piece a victim is, from the viewer's side of the table. */ /** Whose piece a victim is, from the viewer's side of the table. */
const whose = (i: number) => { const whose = (i: number) => {
const s = sideOf(g.cells[i])!; const s = sideOf(g.cells[i])!;
return mySide === null ? `${s === 'gold' ? 'Gold' : 'Scarlet'}’s` : s === mySide ? 'your' : 'the'; return mySide === null ? `${SIDE_NAMES[s]}’s` : s === mySide ? 'your' : 'the';
}; };
const list = (items: string[]) => (items.length <= 1 ? items[0] ?? '' : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`); const list = (items: string[]) => (items.length <= 1 ? items[0] ?? '' : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`);
+1 -1
View File
@@ -63,7 +63,7 @@
<button type="button" class="commit small" onclick={() => standIn(s.id)}>Yes, a drake plays for {s.name}</button> <button type="button" class="commit small" onclick={() => standIn(s.id)}>Yes, a drake plays for {s.name}</button>
<button type="button" class="link" onclick={() => (asked = null)}>no</button> <button type="button" class="link" onclick={() => (asked = null)}>no</button>
{:else} {:else}
<button type="button" class="quiet" title="A drake plays {s.name}'s seat from their next move, and their browser stops opening it. A transfer phrase gives it back." onclick={() => (asked = s.id)}>Give to a bot</button> <button type="button" class="quiet" title="A drake plays {s.name}'s seat from their next move, and their browser stops opening it. A transfer phrase gives it back." onclick={() => (asked = s.id)}>Give to a drake</button>
{/if} {/if}
{/if} {/if}
</span> </span>
+4 -4
View File
@@ -31,12 +31,13 @@ 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' 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, with the King beyond price. */ /** Gygax's values. He gives the King none: 0 here, and the panel says "beyond price". */
export const VALUES: Record<Kind, number> = { 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 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 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'); 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. */ /** A cell index from level 1 to 3, file 0 to 11 and rank 0 to 7. */
@@ -128,11 +129,10 @@ export function movesFrom(cells: Cell[], from: number, setup: Setup = 'printed')
const enemy = (i: number) => cells[i] !== '' && sideOf(cells[i]) !== side; const enemy = (i: number) => cells[i] !== '' && sideOf(cells[i]) !== side;
const open = (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. */ /** 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) => { const add = (lv: number, file: number, rank: number, ok: (i: number) => boolean): void => {
if (lv < 1 || lv > LEVELS || !inBounds(file, rank)) return false; if (lv < 1 || lv > LEVELS || !inBounds(file, rank)) return;
const i = idx(lv, file, rank); const i = idx(lv, file, rank);
if (ok(i)) out.push(i); if (ok(i)) out.push(i);
return true;
}; };
/** Slide until something is in the way; a piece of the other side may be taken. */ /** Slide until something is in the way; a piece of the other side may be taken. */
const slide = (dirs: number[][]) => { const slide = (dirs: number[][]) => {
+7 -3
View File
@@ -5,7 +5,7 @@
// replays to the same choice. // replays to the same choice.
import { import {
CELLS, PER_LEVEL, VALUES, type Cell, type Kind, type Side, type Setup, CELLS, type Cell, type Kind, type Side, type Setup,
frozen, idx, kindOf, levelOf, rankOf, fileOf, movesFrom, other, playOn, sideOf, inCheck, legalMoves frozen, idx, kindOf, levelOf, rankOf, fileOf, movesFrom, other, playOn, sideOf, inCheck, legalMoves
} from './board'; } from './board';
@@ -15,12 +15,16 @@ const MAX_DEPTH = 8;
const QUIESCE_DEPTH = 4; const QUIESCE_DEPTH = 4;
const MATE = 100_000; const MATE = 100_000;
/** Centipawn-ish weights: Gygax's values times twenty, the Dragon raised as Jackman advises. */ /**
* Centipawn-ish weights: Gygax's values times twenty, except the Dragon,
* raised to a Paladin's weight. Edward Jackman's write-up on the Chess
* Variant Pages argues Gygax's 8 is too low, and the search bears it out.
*/
const WEIGHT: Record<Kind, number> = { const WEIGHT: Record<Kind, number> = {
K: 0, M: 220, P: 200, C: 180, R: 200, G: 100, O: 100, H: 90, T: 80, E: 80, B: 60, U: 50, D: 40, S: 20, W: 20 K: 0, M: 220, P: 200, C: 180, R: 200, G: 100, O: 100, H: 90, T: 80, E: 80, B: 60, U: 50, D: 40, S: 20, W: 20
}; };
/** The pieces that can reach the air and so can hunt a Dragon: the Griffon, the Hero, the Paladin and the Mage. */ /** The land pieces fast enough to hunt a Dragon in the air. The Cleric and the King can rise too, but a step at a time. */
const HUNTERS: ReadonlySet<Kind> = new Set<Kind>(['G', 'H', 'P', 'M']); const HUNTERS: ReadonlySet<Kind> = new Set<Kind>(['G', 'H', 'P', 'M']);
/** Chebyshev distance between two squares of the air, the Dragon's measure. */ /** Chebyshev distance between two squares of the air, the Dragon's measure. */
+13 -21
View File
@@ -5,10 +5,8 @@
// replay of the moves, so the chronicle is a function of the game and // replay of the moves, so the chronicle is a function of the game and
// never part of the ledger: rewording it changes no rules. // never part of the ledger: rewording it changes no rules.
import { import { type Cell, type Kind, type Side, PER_LEVEL, NAMES, frozen, kindOf, levelOf, opening, other, playOn, rankOf, sideOf, squareName } from './board';
type Cell, type Kind, type Side, CELLS, PER_LEVEL, NAMES, frozen, kindOf, levelOf, opening, other, playOn, rankOf, sideOf, squareName import { playerName, type Move, type State } from './index';
} from './board';
import type { Move, State } from './index';
export type LineKind = 'move' | 'capture' | 'check' | 'freeze' | 'promotion' | 'end' | 'note'; export type LineKind = 'move' | 'capture' | 'check' | 'freeze' | 'promotion' | 'end' | 'note';
@@ -48,7 +46,6 @@ function crossing(kind: Kind, from: number, to: number): string {
/** How a victim is told. */ /** How a victim is told. */
function falls(victim: Kind, owner: string, where: string): string { function falls(victim: Kind, owner: string, where: string): string {
switch (victim) { switch (victim) {
case 'K': return `${owner} King is taken`;
case 'R': return `${owner} Dragon falls from the sky at ${where}`; 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 'B': return `${owner} Basilisk is crushed at ${where}, and its gaze is broken`;
case 'S': return `${owner} Sylph is scattered at ${where}`; case 'S': return `${owner} Sylph is scattered at ${where}`;
@@ -57,8 +54,6 @@ function falls(victim: Kind, owner: string, where: string): string {
} }
} }
const possessive = (name: string) => `${name}’s`;
/** The pieces on the land held frozen, by square. */ /** The pieces on the land held frozen, by square. */
function frozenSquares(cells: Cell[]): Set<number> { function frozenSquares(cells: Cell[]): Set<number> {
const held = new Set<number>(); const held = new Set<number>();
@@ -66,10 +61,13 @@ function frozenSquares(cells: Cell[]): Set<number> {
return held; 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. */ /** 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[] } { function tellOne(before: Cell[], move: Move, owner: Owner): { lines: Line[]; after: Cell[] } {
const who = possessive(name(move.by)); const who = owner(move.by);
const foe = possessive(name(other(move.by))); const foe = owner(other(move.by));
const piece = NAMES[move.kind]; const piece = NAMES[move.kind];
const from = squareName(move.from); const from = squareName(move.from);
const to = squareName(move.to); const to = squareName(move.to);
@@ -95,7 +93,7 @@ function tellOne(before: Cell[], move: Move, ply: number, name: (side: Side) =>
const heldBefore = frozenSquares(before); const heldBefore = frozenSquares(before);
const heldAfter = frozenSquares(after); const heldAfter = frozenSquares(after);
for (const i of heldAfter) { for (const i of heldAfter) {
if (heldBefore.has(i) && !(move.afar === false && i === move.to)) continue; if (heldBefore.has(i) && (move.afar || i !== move.to)) continue;
const owner = sideOf(after[i]) === move.by ? who : foe; 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.` }); lines.push({ kind: 'freeze', text: `The Basilisk’s gaze falls on ${owner} ${NAMES[kindOf(after[i])]} at ${squareName(i)}, which cannot stir.` });
} }
@@ -118,20 +116,16 @@ function tellOne(before: Cell[], move: Move, ply: number, name: (side: Side) =>
} }
if (move.check) lines.push({ kind: 'check', text: `${foe} King is in check.` }); if (move.check) lines.push({ kind: 'check', text: `${foe} King is in check.` });
void ply;
return { lines, after }; 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`. */ /** 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[] { export function narrate(state: State, start?: Cell[]): Telling[] {
const name = (side: Side) => { const owner: Owner = (side) => `${playerName(state, side)}’s`;
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); let cells = start ?? opening(state.setup);
const out: Telling[] = []; const out: Telling[] = [];
state.moves.forEach((move, n) => { state.moves.forEach((move, n) => {
const { lines, after } = tellOne(cells, move, n + 1, name); const { lines, after } = tellOne(cells, move, owner);
cells = after; cells = after;
out.push({ ply: n + 1, move, lines }); out.push({ ply: n + 1, move, lines });
}); });
@@ -140,8 +134,6 @@ export function narrate(state: State, start?: Cell[]): Telling[] {
} }
/** One move told on its own, from the position it was made in. */ /** 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 { export function tell(before: Cell[], move: Move, owner: Owner): string {
return tellOne(before, move, 0, name).lines.map((l) => l.text).join(' '); return tellOne(before, move, owner).lines.map((l) => l.text).join(' ');
} }
export { CELLS };
+14 -11
View File
@@ -1,15 +1,19 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { CURRENT_RULES, applyMove, createGame, game, describe as tell, notation, positionKey, resolveRound, validateMove, type State } from './index'; import { CURRENT_RULES, applyMove, createGame, game, notation, positionKey, resolveRound, validateMove, type State } from './index';
import { CELLS, census, frozen, inCheck, kindOf, legalMoves, legalMovesFrom, movesFrom, opening, playOn, square, squareName, type Cell, type Side } from './board'; import { CELLS, census, frozen, inCheck, kindOf, legalMoves, legalMovesFrom, movesFrom, opening, playOn, square, squareName, type Cell, type Side } from './board';
import { chooseMove, evaluate } from './bot'; import { chooseMove, evaluate } from './bot';
import { narrate, tell } from './chronicle';
/** The players' names as the chronicle writes them. */
const owner = (side: Side) => (side === 'gold' ? 'Aud’s' : 'Bjorn’s');
/** A position from a few named pieces: 'gK' on '2g1' and so on. Kings are added where none is given. */ /** A position from a few named pieces: 'gK' on '2g1' and so on. Kings are added where none is given. */
function position(pieces: Record<string, string>, toMove: Side = 'gold'): State { function position(pieces: Record<string, string>, toMove: Side = 'gold'): State {
const s = createGame({ A: 'Aud', B: 'Bjorn' }); const s = createGame({ A: 'Aud', B: 'Bjorn' });
s.cells = new Array(CELLS).fill(''); s.cells = new Array(CELLS).fill('');
for (const [name, cell] of Object.entries(pieces)) s.cells[square(name)] = cell; for (const [name, cell] of Object.entries(pieces)) s.cells[square(name)] = cell;
if (!s.cells.includes('gK')) s.cells[square('2l1')] = s.cells[square('2l1')] || 'gK'; if (!s.cells.includes('gK')) s.cells[square('2l1')] = 'gK';
if (!s.cells.includes('sK')) s.cells[square('2a8')] = s.cells[square('2a8')] || 'sK'; if (!s.cells.includes('sK')) s.cells[square('2a8')] = 'sK';
s.toMove = toMove; s.toMove = toMove;
s.seen = { [positionKey(s.cells, toMove)]: 1 }; s.seen = { [positionKey(s.cells, toMove)]: 1 };
return s; return s;
@@ -209,7 +213,7 @@ describe('the lower board', () => {
const s = position({ '1d4': 'gB', '2d4': 'sO', '2d3': 'gW' }); const s = position({ '1d4': 'gB', '2d4': 'sO', '2d3': 'gW' });
expect(frozen(s.cells, square('2d4'))).toBe(true); expect(frozen(s.cells, square('2d4'))).toBe(true);
expect(names(s.cells, '2d4')).toEqual([]); expect(names(s.cells, '2d4')).toEqual([]);
// The frozen Oliphant looks along the rank at Gold's King on l... put the King in its line. // Gold's King on the frozen Oliphant's rank is not in check: a held piece threatens nothing.
const line = position({ '1d4': 'gB', '2d4': 'sO', '2l4': 'gK' }); const line = position({ '1d4': 'gB', '2d4': 'sO', '2l4': 'gK' });
expect(inCheck(line.cells, 'gold')).toBe(false); expect(inCheck(line.cells, 'gold')).toBe(false);
line.cells[square('1d4')] = ''; line.cells[square('1d4')] = '';
@@ -301,17 +305,17 @@ describe('check and the ends', () => {
const after = resolveRound(s, { A: { from: square('2c2'), to: square('2c3') } }); const after = resolveRound(s, { A: { from: square('2c2'), to: square('2c3') } });
expect(after.toMove).toBe('scarlet'); expect(after.toMove).toBe('scarlet');
expect(after.moves).toHaveLength(1); expect(after.moves).toHaveLength(1);
expect(tell(after, after.moves[0])).toBe('Aud’s Warrior steps from 2c2 to 2c3.'); expect(tell(s.cells, after.moves[0], owner)).toBe('Aud’s Warrior steps from 2c2 to 2c3.');
expect(notation(after.moves[0])).toBe('W/2c2-2c3'); expect(notation(after.moves[0])).toBe('W/2c2-2c3');
}); });
it('tells a capture, a climb and a strike from afar', () => { it('tells a capture, a climb and a strike from afar', () => {
const s = position({ '3c4': 'gR', '2c4': 'sW', '2e4': 'gH' }); const s = position({ '3c4': 'gR', '2c4': 'sW', '2e4': 'gH' });
const afar = applyMove(s, square('3c4'), square('2c4')); const afar = applyMove(s, square('3c4'), square('2c4'));
expect(tell(afar, afar.moves[0], s.cells)).toBe('Aud’s Dragon strikes down from 3c4 without leaving the air, and Bjorn\u2019s Warrior falls at 2c4.'); expect(tell(s.cells, afar.moves[0], owner)).toBe('Aud’s Dragon strikes down from 3c4 without leaving the air, and Bjorn\u2019s Warrior falls at 2c4.');
expect(notation(afar.moves[0])).toBe('Drx2c4'); expect(notation(afar.moves[0])).toBe('Drx2c4');
const climb = applyMove(s, square('2e4'), square('3d5')); const climb = applyMove(s, square('2e4'), square('3d5'));
expect(tell(climb, climb.moves[0], s.cells)).toBe('Aud’s Hero springs up from 2e4 to 3d5, into the air.'); expect(tell(s.cells, climb.moves[0], owner)).toBe('Aud’s Hero springs up from 2e4 to 3d5, into the air.');
}); });
}); });
@@ -372,8 +376,7 @@ describe('the drake', () => {
}); });
describe('the chronicle', () => { describe('the chronicle', () => {
it('tells a Basilisk’s gaze falling and lifting, a Warrior standing up a Hero, and a King leaving the land', async () => { it('tells a Basilisk’s gaze falling and lifting, a Warrior standing up a Hero, and a King leaving the land', () => {
const { narrate } = await import('./chronicle');
const s = position({ '1c3': 'gB', '2d4': 'sT', '2a7': 'gW', '2g1': 'gK' }); const s = position({ '1c3': 'gB', '2d4': 'sT', '2a7': 'gW', '2g1': 'gK' });
s.cells[square('2l1')] = ''; s.cells[square('2l1')] = '';
const frozen = applyMove(s, square('1c3'), square('1d4')); const frozen = applyMove(s, square('1c3'), square('1d4'));
@@ -386,9 +389,9 @@ describe('the chronicle', () => {
'Aud’s Basilisk slithers from 1d4 to 1d5.', 'Aud’s Basilisk slithers from 1d4 to 1d5.',
'Bjorn’s Thief at 2d4 is free to move again.' 'Bjorn’s Thief at 2d4 is free to move again.'
]); ]);
const hero = applyMove(applyMove(freed, square('2b7'), square('2a8')), square('2a7'), square('2a8')); const hero = applyMove(applyMove(freed, square('2b7'), square('2c7')), square('2a7'), square('2a8'));
const told = narrate(hero, s.cells)[4].lines; const told = narrate(hero, s.cells)[4].lines;
expect(told[0].text).toBe('Aud’s Warrior steps from 2a7 to 2a8, and Bjorn’s King is taken.'); expect(told[0].text).toBe('Aud’s Warrior steps from 2a7 to 2a8.');
expect(told.some((l) => l.kind === 'promotion')).toBe(true); expect(told.some((l) => l.kind === 'promotion')).toBe(true);
const up = applyMove(s, square('2g1'), square('3g1')); const up = applyMove(s, square('2g1'), square('3g1'));
expect(narrate(up, s.cells)[0].lines.map((l) => l.text)).toEqual([ expect(narrate(up, s.cells)[0].lines.map((l) => l.text)).toEqual([
+17 -31
View File
@@ -5,22 +5,21 @@
// around them: seats, turns, the ends, and the GameSpec the kit runs. // around them: seats, turns, the ends, and the GameSpec the kit runs.
import { type GameSpec, type Outcome, type SeatId, type TableOptions } from './spec'; import { type GameSpec, type Outcome, type SeatId, type TableOptions } from './spec';
import { import { type Cell, type Kind, type Side, type Setup, LETTERS, NAMES, SIDE_NAMES, hasLegalMove, inCheck, kindOf, legalMovesFrom, levelOf, movesFrom, opening, other, playOn, sideOf, squareName } from './board';
type Cell, type Kind, type Side, type Setup, NAMES, LETTERS, cellOf, hasLegalMove, inCheck, kindOf, legalMovesFrom,
levelOf, movesFrom, opening, other, playOn, sideOf, squareName
} from './board';
export { FILES, RANKS, LEVELS, PER_LEVEL, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, kingSquare, inCheck, other, square, playOn } from './board';
import { chooseMove } from './bot'; import { chooseMove } from './bot';
import { narrate } from './chronicle';
// The board's geometry and move generation, re-exported so the browser imports one module.
export type { Cell, Kind, Side, Setup };
export {
CELLS, FILES, LEVELS, PER_LEVEL, RANKS, NAMES, LETTERS, SIDE_NAMES, VALUES, cellOf, fileOf, frozen, hasLegalMove, idx, inCheck, isLight, kindOf, kingSquare,
legalMovesFrom, levelOf, movesFrom, other, playOn, rankOf, sideOf, square, squareName
} from './board';
export const CURRENT_RULES = 1; export const CURRENT_RULES = 1;
/** Plies without a capture before the game is drawn: fifty moves by each side. */ /** Plies without a capture before the game is drawn: fifty moves by each side. */
export const QUIET_LIMIT = 100; export const QUIET_LIMIT = 100;
export type { Cell, Kind, Side, Setup };
export { NAMES, LETTERS, squareName, legalMovesFrom, movesFrom };
export interface Player { export interface Player {
id: SeatId; id: SeatId;
name: string; name: string;
@@ -62,18 +61,16 @@ export interface State {
export type Input = { from: number; to: number }; export type Input = { from: number; to: number };
export const SIDES: { value: Side; label: string; note: string }[] = [ export const SIDE_CHOICES: { value: Side; label: string; note: string }[] = [
{ value: 'gold', label: 'Gold, and move first', note: 'Gold opens the game.' }, { value: 'gold', label: 'Gold, and move first', note: 'Gold opens the game.' },
{ value: 'scarlet', label: 'Scarlet', note: 'Scarlet answers.' } { value: 'scarlet', label: 'Scarlet', note: 'Scarlet answers.' }
]; ];
export const SETUPS: { value: Setup; label: string; note: string }[] = [ export const SETUP_CHOICES: { value: Setup; label: string; note: string }[] = [
{ value: 'printed', label: 'As printed in 1985', note: 'The Sylphs stand on a, c, e, g, i and k. Gold’s Dragon can take one on the first move.' }, { value: 'printed', label: 'As printed in 1985', note: 'The Sylphs stand on a, c, e, g, i and k. Gold’s Dragon can take one on the first move.' },
{ value: 'shifted', label: 'Sylphs shifted one file', note: 'The Sylphs stand over the Dwarves, on b, d, f, h, j and l, out of the Dragons’ reach.' } { value: 'shifted', label: 'Sylphs shifted one file', note: 'The Sylphs stand over the Dwarves, on b, d, f, h, j and l, out of the Dragons’ reach.' }
]; ];
export const sideName = (side: Side): string => (side === 'gold' ? 'Gold' : 'Scarlet');
export function createGame(names: Record<SeatId, string>, _seed = 0, rules = CURRENT_RULES, options?: TableOptions): State { export function createGame(names: Record<SeatId, string>, _seed = 0, rules = CURRENT_RULES, options?: TableOptions): State {
const seats = Object.keys(names); const seats = Object.keys(names);
const hostSide: Side = options?.side === 'scarlet' ? 'scarlet' : 'gold'; const hostSide: Side = options?.side === 'scarlet' ? 'scarlet' : 'gold';
@@ -94,7 +91,7 @@ export function seatOf(state: State, side: Side): SeatId | null {
export function playerName(state: State, side: Side): string { export function playerName(state: State, side: Side): string {
const seat = seatOf(state, side); const seat = seatOf(state, side);
return seat ? state.players[seat].name : sideName(side); return seat ? state.players[seat].name : SIDE_NAMES[side];
} }
/** The end of the game in words a player reads, and the key the tally counts. */ /** The end of the game in words a player reads, and the key the tally counts. */
@@ -155,13 +152,6 @@ export function resolveRound(previous: State, inputs: Record<SeatId, Input>): St
return applyMove(previous, input.from, input.to); return applyMove(previous, input.from, input.to);
} }
/** The move told as the chronicle tells it, with what came of it. */
export function describe(state: State, move: Move, start?: Cell[]): string {
const n = state.moves.indexOf(move);
const telling = narrate(state, start)[n];
return telling ? telling.lines.map((l) => l.text).join(' ') : '';
}
/** The move in Gygax's notation: piece, from, x, to, with (H), "afar" and ch as he wrote them. */ /** The move in Gygax's notation: piece, from, x, to, with (H), "afar" and ch as he wrote them. */
export function notation(move: Move): string { export function notation(move: Move): string {
const piece = LETTERS[move.kind]; const piece = LETTERS[move.kind];
@@ -177,12 +167,11 @@ export const game: GameSpec<State, Input> = {
botNames: ['Fafnir', 'Nidhogg', 'Ladon', 'Vritra', 'Apep', 'Jormungand'], botNames: ['Fafnir', 'Nidhogg', 'Ladon', 'Vritra', 'Apep', 'Jormungand'],
currentRules: CURRENT_RULES, currentRules: CURRENT_RULES,
options: [ options: [
{ key: 'side', label: 'You play', choices: SIDES, default: 'gold' }, { key: 'side', label: 'You play', choices: SIDE_CHOICES, default: 'gold' },
{ key: 'setup', label: 'The Sylphs', choices: SETUPS, default: 'printed' } { key: 'setup', label: 'The Sylphs', choices: SETUP_CHOICES, default: 'printed' }
], ],
preferences: [ preferences: [
{ key: 'coordinates', label: 'Square names on the boards', kind: 'toggle', default: true }, { key: 'coordinates', label: 'Square names on the boards', kind: 'toggle', default: true }
{ key: 'letters', label: 'Pieces as letters', kind: 'toggle', default: false, note: 'Gygax’s initials instead of the drawn pieces.' }
], ],
create: createGame, create: createGame,
resolve: resolveRound, resolve: resolveRound,
@@ -191,10 +180,8 @@ export const game: GameSpec<State, Input> = {
turn: (state) => state.moves.length + 1, turn: (state) => state.moves.length + 1,
// Nothing is hidden on any board, so every viewer sees the whole state. // Nothing is hidden on any board, so every viewer sees the whole state.
view: (state) => state, view: (state) => state,
botInput: (state, seat) => { // A side with no legal move has already ended the game, so the drake is never asked for one.
const side = state.players[seat]?.side ?? state.toMove; botInput: (state, seat) => chooseMove(state.cells, state.players[seat]?.side ?? state.toMove, state.setup)!,
return chooseMove(state.cells, side, state.setup) ?? { from: -1, to: -1 };
},
cleanInput: (raw) => { cleanInput: (raw) => {
const r = raw as { from?: unknown; to?: unknown }; const r = raw as { from?: unknown; to?: unknown };
const n = (v: unknown) => (Number.isInteger(v) ? (v as number) : -1); const n = (v: unknown) => (Number.isInteger(v) ? (v as number) : -1);
@@ -208,11 +195,10 @@ export const game: GameSpec<State, Input> = {
else if (state.end === 'stalemate') lines['ended in stalemate'] = 1; else if (state.end === 'stalemate') lines['ended in stalemate'] = 1;
else if (state.end === 'repetition') lines['drawn by repetition'] = 1; else if (state.end === 'repetition') lines['drawn by repetition'] = 1;
else if (state.end === 'quiet') lines['drawn after fifty quiet moves'] = 1; else if (state.end === 'quiet') lines['drawn after fifty quiet moves'] = 1;
if (state.over?.winner) lines[`won by ${sideName(state.players[state.over.winner].side)}`] = 1; if (state.over?.winner) lines[`won by ${SIDE_NAMES[state.players[state.over.winner].side]}`] = 1;
lines['moves played'] = state.moves.length; lines['moves played'] = state.moves.length;
return lines; return lines;
}, },
tallyBegun: (options) => ({ [options.setup === 'shifted' ? 'begun with the Sylphs shifted' : 'begun as printed']: 1 }) tallyBegun: (options) => ({ [options.setup === 'shifted' ? 'begun with the Sylphs shifted' : 'begun as printed']: 1 })
}; };
export { cellOf };
+5 -6
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { SCENES, sceneCells } from './lessons'; import { SCENES, sceneCells } from './lessons';
import { frozen, hasLegalMove, inCheck, kindOf, legalMovesFrom, movesFrom, other, playOn, square } from './board'; import { frozen, hasLegalMove, inCheck, kindOf, legalMovesFrom, movesFrom, playOn, square } from './board';
import { CALLOUTS, TOLD_MOVES, toldStates } from './told';
describe('the lessons', () => { describe('the lessons', () => {
it('every scene’s move is legal for the side to move, and only the asked-for moves count', () => { it('every scene’s move is legal for the side to move, and only the asked-for moves count', () => {
@@ -29,7 +30,7 @@ describe('the lessons', () => {
return { cells, after: playOn(cells, square(from), square(to)), s }; return { cells, after: playOn(cells, square(from), square(to)), s };
}; };
// The Warrior's step is blocked and its capture is not. // The Warrior's step is blocked and its capture is not.
expect(movesFrom(sceneCells(at('warrior')), square('2c2')).map((i) => i)).toEqual([square('2d3')]); expect(movesFrom(sceneCells(at('warrior')), square('2c2'))).toEqual([square('2d3')]);
// The Hero leaps the Warrior and takes the Mage. // The Hero leaps the Warrior and takes the Mage.
expect(play('hero').after.captured).toBe('M'); expect(play('hero').after.captured).toBe('M');
// The Mage comes down two boards onto the Basilisk. // The Mage comes down two boards onto the Basilisk.
@@ -51,7 +52,7 @@ describe('the lessons', () => {
// The King is in check before it moves, and out of it after either move; up or down leaves it off the land. // The King is in check before it moves, and out of it after either move; up or down leaves it off the land.
const king = sceneCells(at('king')); const king = sceneCells(at('king'));
expect(inCheck(king, 'gold')).toBe(true); expect(inCheck(king, 'gold')).toBe(true);
expect(legalMovesFrom(king, square('2g1'), 'printed').map((i) => i).sort()).toEqual([square('1g1'), square('2f1'), square('2h1'), square('3g1')].sort()); expect(legalMovesFrom(king, square('2g1'), 'printed').sort()).toEqual([square('1g1'), square('2f1'), square('2h1'), square('3g1')].sort());
// The last scene is mate in one by either listed move, and by no other. // The last scene is mate in one by either listed move, and by no other.
const mate = play('mate'); const mate = play('mate');
for (const [from, to] of mate.s.moves) { for (const [from, to] of mate.s.moves) {
@@ -67,13 +68,11 @@ describe('the lessons', () => {
} }
expect(kindOf(mate.cells[square('2d4')])).toBe('K'); expect(kindOf(mate.cells[square('2d4')])).toBe('K');
expect(frozen(mate.cells, square('2d4'))).toBe(true); expect(frozen(mate.cells, square('2d4'))).toBe(true);
void other;
}); });
}); });
describe('the game told', () => { describe('the game told', () => {
it('plays through the engine from the opening to a checkmate, and every callout sits on a move', async () => { it('plays through the engine from the opening to a checkmate, and every callout sits on a move', () => {
const { toldStates, TOLD_MOVES, CALLOUTS } = await import('./told');
const states = toldStates(); const states = toldStates();
expect(states).toHaveLength(TOLD_MOVES.length + 1); expect(states).toHaveLength(TOLD_MOVES.length + 1);
const last = states[states.length - 1]; const last = states[states.length - 1];
+12 -12
View File
@@ -1,8 +1,8 @@
// The lessons: one scene a piece, each a small position with something to // The lessons: one scene a piece, each a small position with something to
// see and a move to make. The page shows the boards the scene needs, says // see and a move to make. The page shows the boards the scene needs, says
// what to look at, takes the move from the player, and tells what came of // what to look at, takes the move from the player, and tells what came of
// it. Every scene's move is checked legal by a test, and every claim in // it. A test plays every scene's move through the engine and checks the
// the words is checked against board.ts there too. // captures, the freeze and the mate the words promise.
import { type Cell, type Side, CELLS, square } from './board'; import { type Cell, type Side, CELLS, square } from './board';
@@ -37,7 +37,7 @@ export const SCENES: Scene[] = [
before: 'Twelve of these stand in a wall at the start. A Warrior steps one square straight ahead, never two, and takes one square diagonally ahead, as a pawn does. The Unicorn at 2c3 blocks this one’s step, but the Warrior at 2d3 is on its diagonal.', before: 'Twelve of these stand in a wall at the start. A Warrior steps one square straight ahead, never two, and takes one square diagonally ahead, as a pawn does. The Unicorn at 2c3 blocks this one’s step, but the Warrior at 2d3 is on its diagonal.',
task: 'Take the Scarlet Warrior. Tap your Warrior at 2c2, then 2d3.', task: 'Take the Scarlet Warrior. Tap your Warrior at 2c2, then 2d3.',
moves: [['2c2', '2d3']], moves: [['2c2', '2d3']],
after: 'The Warrior falls, and yours stands on the d file now. A Warrior that reaches the far rank stands up a Hero, with no choice about it, which is a real gain but not a queen. There is no double step and no capture in passing, because Gygax limited the Warrior to the old single step of shatranj, and so every advance is a commitment.', after: 'The Warrior falls, and yours stands on the d file now. A Warrior that reaches the far rank stands up a Hero, with no choice about it, which is a real gain but not a queen. There is no double step and no capture in passing, so every advance is a commitment.',
aside: 'That is a legal move, but the Warrior at 2d3 is the one to take. Set the scene again and look for it.' aside: 'That is a legal move, but the Warrior at 2d3 is the one to take. Set the scene again and look for it.'
}, },
{ {
@@ -49,7 +49,7 @@ export const SCENES: Scene[] = [
before: 'The war elephant is a rook by another name. It runs any distance along a rank or a file while the way is clear, and never leaves the land. The a file is open from your corner to the Thief at 2a6.', before: 'The war elephant is a rook by another name. It runs any distance along a rank or a file while the way is clear, and never leaves the land. The a file is open from your corner to the Thief at 2a6.',
task: 'Take the Thief. Tap the Oliphant at 2a1, then 2a6.', task: 'Take the Thief. Tap the Oliphant at 2a1, then 2a6.',
moves: [['2a1', '2a6']], moves: [['2a1', '2a6']],
after: 'On a board twelve squares wide an open file runs a long way, and the Oliphant is the piece that owns it. Remember what the sky can do, though. An Oliphant standing on the land beneath an enemy Dragon is as good as lost, because it cannot strike back upward.', after: 'On a board twelve squares wide an open file runs a long way, and the Oliphant is the piece that owns it. Look up before you settle on a file, though. The Oliphant cannot strike at the air, so an enemy Dragon over the squares ahead of it takes it at leisure.',
aside: 'A legal move, but the Thief at 2a6 is the one to take. Set the scene again and look for it.' aside: 'A legal move, but the Thief at 2a6 is the one to take. Set the scene again and look for it.'
}, },
{ {
@@ -73,7 +73,7 @@ export const SCENES: Scene[] = [
before: 'The bishop of the land, quick along the diagonals. From 2d1 the diagonal runs e2, f3, g4 and on to the Oliphant at 2h5, with nothing in the way.', before: 'The bishop of the land, quick along the diagonals. From 2d1 the diagonal runs e2, f3, g4 and on to the Oliphant at 2h5, with nothing in the way.',
task: 'Take the Oliphant. Tap the Thief, then 2h5.', task: 'Take the Oliphant. Tap the Thief, then 2h5.',
moves: [['2d1', '2h5']], moves: [['2d1', '2h5']],
after: 'Five squares in one move, and a five-point piece for a four-point one. A Thief’s work is in the middle ranks, which is where the Dragon hunts from the air, so it is the piece most often lost to a strike from above. Two Thieves cover both colours of square.', after: 'Four squares in one move, and a five-point piece for a four-point one. A Thief’s work is in the middle ranks, which is where the Dragon hunts from the air, so look up before you leave it there. Two Thieves cover both colours of square.',
aside: 'A legal move, but the Oliphant at 2h5 is the one to take. Set the scene again and look for it.' aside: 'A legal move, but the Oliphant at 2h5 is the one to take. Set the scene again and look for it.'
}, },
{ {
@@ -97,7 +97,7 @@ export const SCENES: Scene[] = [
before: 'The one piece at home on every board. It moves one square in any direction wherever it stands, and it can step straight up or down to the board above or below. A Scarlet Sylph hovers directly over it.', before: 'The one piece at home on every board. It moves one square in any direction wherever it stands, and it can step straight up or down to the board above or below. A Scarlet Sylph hovers directly over it.',
task: 'Step up into the air and take the Sylph. Tap the Cleric, then 3e4 on the upper board.', task: 'Step up into the air and take the Sylph. Tap the Cleric, then 3e4 on the upper board.',
moves: [['2e4', '3e4']], moves: [['2e4', '3e4']],
after: 'The Cleric is in the air now and moves there exactly as it did on the land. Gygax says not to underestimate it. Of the five kinds of piece that can visit all three boards, it is the only one that plays the same on each, and he values it at nine, above the Dragon.', after: 'The Cleric is in the air now and moves there exactly as it did on the land. Nothing else that visits all three boards keeps its moves on each, which is why Gygax values it at nine, above the Dragon.',
aside: 'A legal move, but the step up to 3e4 is the one to see. Set the scene again and look for it.' aside: 'A legal move, but the step up to 3e4 is the one to see. Set the scene again and look for it.'
}, },
{ {
@@ -109,7 +109,7 @@ export const SCENES: Scene[] = [
before: 'On the land the Mage is a queen. Here it is in the air, where it can only step one square along a rank or file, or move straight up or down. Straight down two boards is the underworld, and a Scarlet Basilisk sits there at 1c4. The land square between, 2c4, is empty, and it has to be, because the Mage does not vault.', before: 'On the land the Mage is a queen. Here it is in the air, where it can only step one square along a rank or file, or move straight up or down. Straight down two boards is the underworld, and a Scarlet Basilisk sits there at 1c4. The land square between, 2c4, is empty, and it has to be, because the Mage does not vault.',
task: 'Descend two boards and take the Basilisk. Tap the Mage, then 1c4 on the lowest board.', task: 'Descend two boards and take the Basilisk. Tap the Mage, then 1c4 on the lowest board.',
moves: [['3c4', '1c4']], moves: [['3c4', '1c4']],
after: 'The Basilisk is crushed and the Mage stands in the underworld, where it is a shadow of itself, one step at a time. Gygax warns against bringing the Mage out early, since lesser pieces develop by chasing it. Its moves between boards serve it best as an escape.', after: 'The Basilisk is crushed and the Mage stands in the underworld, where it is a shadow of itself, one step at a time. Gygax warns against bringing the Mage out early, since lesser pieces develop by chasing it.',
aside: 'A legal move, but the drop to 1c4 is the one to see. Set the scene again and look for it.' aside: 'A legal move, but the drop to 1c4 is the one to see. Set the scene again and look for it.'
}, },
{ {
@@ -145,7 +145,7 @@ export const SCENES: Scene[] = [
before: 'Half eagle and half lion. In the air the Griffon leaps two squares one way and three the other, so the Warrior at 3f7 is one leap away. It can also swoop to the land, onto any of the four squares diagonally beside the square beneath it. The Thief at 2e5 is one of those four.', before: 'Half eagle and half lion. In the air the Griffon leaps two squares one way and three the other, so the Warrior at 3f7 is one leap away. It can also swoop to the land, onto any of the four squares diagonally beside the square beneath it. The Thief at 2e5 is one of those four.',
task: 'Swoop down and take the Thief. Tap the Griffon, then 2e5 on the middle board.', task: 'Swoop down and take the Thief. Tap the Griffon, then 2e5 on the middle board.',
moves: [['3d4', '2e5']], moves: [['3d4', '2e5']],
after: 'The Thief is gone, and the Griffon is on the land, where it has lost its wings and creeps one square diagonally until it climbs back. Drop to pick off a defender and climb before the land can gather. In the air it is worth a little more than a Hero, and nothing on the land sees its leap coming.', after: 'The Thief is gone, and the Griffon is on the land, where it has lost its wings and creeps one square diagonally until it climbs back. A Griffon that drops is a Griffon that must climb again, so drop for something worth two moves. Nothing on the land sees its leap coming.',
aside: 'A legal move, but the swoop onto the Thief at 2e5 is the one to see. Set the scene again and look for it.' aside: 'A legal move, but the swoop onto the Thief at 2e5 is the one to see. Set the scene again and look for it.'
}, },
{ {
@@ -169,7 +169,7 @@ export const SCENES: Scene[] = [
before: 'A miner of the underworld. It walks one square forward or sideways without taking, and takes one square diagonally ahead, so the Warrior at 1e5 is in reach. It can also strike straight up at the land. A Scarlet Hero has stopped at 2d4, directly over it.', before: 'A miner of the underworld. It walks one square forward or sideways without taking, and takes one square diagonally ahead, so the Warrior at 1e5 is in reach. It can also strike straight up at the land. A Scarlet Hero has stopped at 2d4, directly over it.',
task: 'Burst up and take the Hero. Tap the Dwarf, then 2d4 on the middle board.', task: 'Burst up and take the Hero. Tap the Dwarf, then 2d4 on the middle board.',
moves: [['1d4', '2d4']], moves: [['1d4', '2d4']],
after: 'A Dwarf is a mine beneath the land, and a piece that settles above one is taken. On the land it keeps the same moves, and it comes back down only onto an empty square beneath it. Gygax says the Dwarves play little part early, and make the best outposts late, if you have not hurried them forward.', after: 'The square above a Dwarf is a trap for anything that settles there. On the land the Dwarf keeps the same moves, and it comes back down only onto an empty square beneath it. Gygax says the Dwarves play little part early, and make the best outposts late, if you have not hurried them forward.',
aside: 'A legal move, but the strike up at the Hero on 2d4 is the one to see. Set the scene again and look for it.' aside: 'A legal move, but the strike up at the Hero on 2d4 is the one to see. Set the scene again and look for it.'
}, },
{ {
@@ -181,7 +181,7 @@ export const SCENES: Scene[] = [
before: 'The serpent whose gaze turns to stone. A Basilisk moves one square forward or diagonally forward, taking what is there, or one square straight back, and never leaves the underworld. Whatever enemy stands on the land directly above it cannot move, take or threaten anything. The Scarlet Mage stands at 2e4. The square beneath it, 1e4, is one step ahead of your Basilisk.', before: 'The serpent whose gaze turns to stone. A Basilisk moves one square forward or diagonally forward, taking what is there, or one square straight back, and never leaves the underworld. Whatever enemy stands on the land directly above it cannot move, take or threaten anything. The Scarlet Mage stands at 2e4. The square beneath it, 1e4, is one step ahead of your Basilisk.',
task: 'Slither under the Mage. Tap the Basilisk, then 1e4.', task: 'Slither under the Mage. Tap the Basilisk, then 1e4.',
moves: [['1e3', '1e4']], moves: [['1e3', '1e4']],
after: 'The Mage is frozen where it stands, and will be until the Basilisk moves or is taken. Gygax says an immobilised piece can be treated as if it did not exist, except that it still blocks its square. His own trick runs the other way too. Stand a piece of yours over your own Basilisk, and whatever takes it is frozen on the spot for you to take at your leisure.', after: 'The Mage is frozen where it stands, and will be until the Basilisk moves or is taken. Gygax says an immobilised piece can be treated as if it did not exist, except that it still blocks its square. Stand a piece of yours over your own Basilisk and the trick runs the other way.',
aside: 'A legal move, but 1e4, beneath the Mage, is the one to see. Set the scene again and look for it.' aside: 'A legal move, but 1e4, beneath the Mage, is the one to see. Set the scene again and look for it.'
}, },
{ {
@@ -202,10 +202,10 @@ export const SCENES: Scene[] = [
pieces: { '2g1': 'gK', '2g8': 'sO', '2f2': 'gW', '2h2': 'gW' }, pieces: { '2g1': 'gK', '2g8': 'sO', '2f2': 'gW', '2h2': 'gW' },
side: 'gold', side: 'gold',
levels: [3, 2, 1], levels: [3, 2, 1],
before: 'The whole game. The King moves one square in any direction on the land, and one step straight up or down between boards. Here it is in check, because the Scarlet Oliphant looks down the open g file. Nothing of yours can take the Oliphant or step into the file, so the King must move. Two squares on the land are safe, and it could also climb into the air or dig into the underworld.', before: 'The King is the whole game. It moves one square in any direction on the land, and one step straight up or down between boards. Here it is in check, because the Scarlet Oliphant looks down the open g file. Nothing of yours can take the Oliphant or step into the file, so the King must move. Two squares on the land are safe, and it could also climb into the air or dig into the underworld.',
task: 'Step out of check along the rank. Tap the King, then 2f1 or 2h1.', task: 'Step out of check along the rank. Tap the King, then 2f1 or 2h1.',
moves: [['2g1', '2f1'], ['2g1', '2h1']], moves: [['2g1', '2f1'], ['2g1', '2h1']],
after: 'Safe, and still on the land. The King could have climbed or dug instead, but off the land a King can do nothing except come straight back, and Gygax calls it a sitting duck there. At the table, keep two more things in mind. The square beneath your King is where an enemy Basilisk would freeze it, and your Dragon above it is its roof.', after: 'Safe, and still on the land. The King could have climbed or dug instead, but off the land a King can do nothing except come straight back, and Gygax says it is easily trapped there. At the table, keep two more things in mind. The square beneath your King is where an enemy Basilisk would freeze it, and your Dragon above it is its roof.',
aside: 'That is out of check, but off the land the King can only come back. Set the scene again and stay on the land.' aside: 'That is out of check, but off the land the King can only come back. Set the scene again and stay on the land.'
}, },
{ {
+7 -7
View File
@@ -1,7 +1,7 @@
// What each piece is, in the game's own voice: the panel a player opens by // What each piece is, in the game's own voice: the panel a player opens by
// tapping a piece. The moves are Gygax's, restated for a reader at the // tapping a piece. The moves are Gygax's, restated for a reader at the
// table. The rest is ours, written from his strategic notes and from the // table. The rest is ours, written from his strategic notes and from the
// shape of the rules, and every claim in it is checked against board.ts. // shape of the rules, checked against board.ts before either changes.
import type { Kind } from './board'; import type { Kind } from './board';
@@ -38,13 +38,13 @@ export const LORE: Record<Kind, Lore> = {
is: 'The great wyrm of the sky, roosting above its own King on the g file. The game takes its name from this one.', is: 'The great wyrm of the sky, roosting above its own King on the g file. The game takes its name from this one.',
moves: 'It never leaves the air. It slides any distance along a diagonal, or steps one square in any direction. And it strikes from afar. Without moving, it can take an enemy on the land directly beneath it or on any of the four squares beside that one. The victim is lifted away and the Dragon stays where it is.', moves: 'It never leaves the air. It slides any distance along a diagonal, or steps one square in any direction. And it strikes from afar. Without moving, it can take an enemy on the land directly beneath it or on any of the four squares beside that one. The victim is lifted away and the Dragon stays where it is.',
why: 'Gygax set the Dragon directly over the King on purpose. Its strike covers the five land squares beneath it, so a Dragon at home is a roof over the King, and a Dragon that flies off to hunt leaves the King under the other Dragon’s shadow.', why: 'Gygax set the Dragon directly over the King on purpose. Its strike covers the five land squares beneath it, so a Dragon at home is a roof over the King, and a Dragon that flies off to hunt leaves the King under the other Dragon’s shadow.',
play: 'Gygax rated it at eight, and players since have called that too low. The drake values it as a Paladin, and keeps it home until the other Dragon is dead or far away. Early, keep yours home and let it threaten. Later, a Dragon over the enemy’s back rank is a terror, since only five kinds of land piece can reach the air at all. Mind the Sylphs, which capture straight ahead on its board and cost a point to lose.' play: 'Gygax rated it at eight, and Edward Jackman, who wrote the game up for the Chess Variant Pages, called that too low. The drake values it as a Paladin, and keeps it home until the other Dragon is dead or far away. Early, keep yours home and let it threaten. Later, a Dragon over the enemy’s back rank is a terror, since only five kinds of land piece can reach the air at all. Mind the Sylphs, which capture straight ahead on its board and cost a point to lose.'
}, },
W: { W: {
count: 'twelve a side', count: 'twelve a side',
is: 'The levy of the land, twelve spears in a wall across the second rank.', is: 'The levy of the land, twelve spears in a wall across the second rank.',
moves: 'One square straight ahead, never two, and it captures one square diagonally ahead. On reaching the far rank it stands up as a Hero, with no say in the matter. It never leaves the land.', moves: 'One square straight ahead, never two, and it captures one square diagonally ahead. On reaching the far rank it stands up as a Hero, with no say in the matter. It never leaves the land.',
why: 'A chess pawn with the double step and the capture in passing taken away. Gygax said he limited it to the single step of shatranj, the old game chess came from, which slows the opening and makes every advance a commitment.', why: 'A chess pawn with the double step and the capture in passing taken away, which slows the opening and makes every advance a commitment.',
play: 'Twelve files is a wide front. Push in the middle and leave the wings to the Oliphants. Chains guard one another as in chess. A Warrior that gets through becomes a Hero, worth four and a half, a real gain but not a queen, so do not spend three pieces to get one there.' play: 'Twelve files is a wide front. Push in the middle and leave the wings to the Oliphants. Chains guard one another as in chess. A Warrior that gets through becomes a Hero, worth four and a half, a real gain but not a queen, so do not spend three pieces to get one there.'
}, },
O: { O: {
@@ -73,7 +73,7 @@ export const LORE: Record<Kind, Lore> = {
is: 'The bishop of the land, quick along the diagonals and quick to be elsewhere.', is: 'The bishop of the land, quick along the diagonals and quick to be elsewhere.',
moves: 'Any distance along a diagonal, as far as the way is clear. It never leaves the land.', moves: 'Any distance along a diagonal, as far as the way is clear. It never leaves the land.',
why: 'Two Thieves cover both colours of square, and on a board this wide the diagonals run far.', why: 'Two Thieves cover both colours of square, and on a board this wide the diagonals run far.',
play: 'A Thief is the piece most often lost to a Dragon’s strike, because its work is in the middle ranks where the Dragon hunts. Keep it on a diagonal the Dragon does not cover, and remember that a Basilisk beneath a Thief freezes it mid-flight.' play: 'A Thief works in the middle ranks, where the Dragon hunts, so keep it on a diagonal the Dragon does not cover, and remember that a Basilisk beneath a Thief freezes it mid-flight.'
}, },
C: { C: {
count: 'one a side', count: 'one a side',
@@ -87,14 +87,14 @@ export const LORE: Record<Kind, Lore> = {
is: 'The queen of the land, and a traveller between worlds with little to do once it has arrived.', is: 'The queen of the land, and a traveller between worlds with little to do once it has arrived.',
moves: 'On the land a Mage slides any distance along a rank, file or diagonal. It can also move straight up or down, one board or two, though to pass through the land the square there must be empty. In the air or the underworld it is reduced to one square sideways, forward or back.', moves: 'On the land a Mage slides any distance along a rank, file or diagonal. It can also move straight up or down, one board or two, though to pass through the land the square there must be empty. In the air or the underworld it is reduced to one square sideways, forward or back.',
why: 'The most valuable piece on the board, worth eleven, and Gygax warns against the queen hunt. A Mage brought out early is chased by lesser pieces that develop as they chase it.', why: 'The most valuable piece on the board, worth eleven, and Gygax warns against the queen hunt. A Mage brought out early is chased by lesser pieces that develop as they chase it.',
play: 'Keep it back until the Unicorns and Heroes are out. Its step between boards is a way to flee a Dragon’s gaze, or to drop into the underworld and take a Basilisk from beside it, but a Mage down there is a shadow of itself and takes three moves to go and come back. Its moves between boards serve it best as an escape.' play: 'Keep it back until the Unicorns and Heroes are out. Its step between boards is a way to flee a Dragon’s gaze, or to drop into the underworld and take a Basilisk from beside it, but a Mage down there is a shadow of itself and takes three moves to go and come back.'
}, },
K: { K: {
count: 'one a side', count: 'one a side',
is: 'The King of the land. The game is won by trapping the other one and lost by losing this one.', is: 'The King of the land. The game is won by trapping the other one and lost by losing this one.',
moves: 'One square in any direction on the land, and one step straight up to the air or down to the underworld, taking what is there. Off the land it can do nothing but come straight back.', moves: 'One square in any direction on the land, and one step straight up to the air or down to the underworld, taking what is there. Off the land it can do nothing but come straight back.',
why: 'As in chess, the object is to checkmate it. A King attacked with no escape loses the game, and a King frozen by a Basilisk beneath it is mated by any attack that cannot be blocked or captured.', why: 'As in chess, the object is to checkmate it. A King attacked with no escape loses the game, and a King frozen by a Basilisk beneath it is mated by any attack that cannot be blocked or captured.',
play: 'Keep the square beneath it in mind. An enemy Basilisk that reaches the underworld under your King freezes it where it stands. Keep it on the land unless staying is worse, since off the land it can only return, and Gygax calls it a sitting duck there. The Dragon above it is its roof.' play: 'Keep the square beneath it in mind. An enemy Basilisk that reaches the underworld under your King freezes it where it stands. Keep it on the land unless staying is worse, since off the land it can only return, and Gygax says it is easily trapped there. The Dragon above it is its roof.'
}, },
P: { P: {
count: 'one a side', count: 'one a side',
@@ -114,7 +114,7 @@ export const LORE: Record<Kind, Lore> = {
count: 'two a side', count: 'two a side',
is: 'The serpent whose gaze turns to stone. Whatever enemy stands on the land directly above a Basilisk cannot move, cannot take, and threatens nothing, until the Basilisk moves or dies.', is: 'The serpent whose gaze turns to stone. Whatever enemy stands on the land directly above a Basilisk cannot move, cannot take, and threatens nothing, until the Basilisk moves or dies.',
moves: 'It moves one square straight or diagonally forward, taking what is there, or one square straight back onto an empty square. It never leaves the underworld. The freeze is not a move. It simply holds for as long as the Basilisk stays beneath its victim.', moves: 'It moves one square straight or diagonally forward, taking what is there, or one square straight back onto an empty square. It never leaves the underworld. The freeze is not a move. It simply holds for as long as the Basilisk stays beneath its victim.',
why: 'The Basilisk is the game’s one trick that chess does not have. It is how the underworld reaches the land without ever rising.', why: 'The Basilisk is how the underworld reaches the land without ever rising.',
play: 'Gygax’s own advice is to stand a piece of yours over your own Basilisk. Whatever captures it is frozen on the spot, and you take it at your leisure. A Basilisk arriving under a piece that guards the enemy King can be a checkmate on its own, and a Basilisk under the King itself leaves it unable to step out of any check. Walk it forward early. It is slow, and the squares that matter are far away.' play: 'Gygax’s own advice is to stand a piece of yours over your own Basilisk. Whatever captures it is frozen on the spot, and you take it at your leisure. A Basilisk arriving under a piece that guards the enemy King can be a checkmate on its own, and a Basilisk under the King itself leaves it unable to step out of any check. Walk it forward early. It is slow, and the squares that matter are far away.'
}, },
E: { E: {
+1 -1
View File
@@ -31,7 +31,7 @@
<h2>The hall</h2> <h2>The hall</h2>
<figure><img src="/guide/hall.png" alt="The hall, with a name field, the two table choices, the button to play the drake, and the opening position drawn beside them" loading="lazy" /></figure> <figure><img src="/guide/hall.png" alt="The hall, with a name field, the two table choices, the button to play the drake, and the opening position drawn beside them" loading="lazy" /></figure>
<p>Write your name and choose your company. <strong>Play now against a drake</strong> seats you across from the house player and begins at once. <strong>Open a table</strong> lays a table and hands you a link and a four-letter code to send to a friend. <strong>Join one</strong> takes a code someone sent you.</p> <p>Write your name and choose your company. <strong>Play now against a drake</strong> seats you across from the house player and begins at once. <strong>Open a table</strong> lays a table and hands you a link and a four-letter code to send to a friend. <strong>Join one</strong> takes a code someone sent you.</p>
<p>Two choices sit above the buttons. <em>You play</em> picks your side, and Gold moves first. <em>The Sylphs</em> picks where the six Sylphs start. As Gygax printed it, Gold's Dragon can take an undefended Sylph on the very first move, which a reader of the Chess Variant Pages took for a misprint. The shifted setup moves the Sylphs one file over, out of reach. Beneath the buttons, <em>your games</em> lists every table this browser holds a seat at and whose move it is.</p> <p>Two choices sit above the buttons. <em>You play</em> picks your side, and Gold moves first. <em>The Sylphs</em> picks where the six Sylphs start. As Gygax printed it, Gold's Dragon can take an undefended Sylph on the very first move, which a reader of the Chess Variant Pages took for a misprint. The shifted setup moves the Sylphs one file over, out of reach. <em>Your games</em>, which comes first once you have one, lists every table this browser holds a seat at and whose move it is.</p>
</section> </section>
<section id="table"> <section id="table">
+7 -30
View File
@@ -6,8 +6,7 @@
import PiecePanel from '$lib/components/PiecePanel.svelte'; import PiecePanel from '$lib/components/PiecePanel.svelte';
import { SCENES, sceneCells } from '$lib/game/lessons'; import { SCENES, sceneCells } from '$lib/game/lessons';
import { tell } from '$lib/game/chronicle'; import { tell } from '$lib/game/chronicle';
import { createGame, playOn, square, squareName, type Cell, type Move } from '$lib/game'; import { createGame, playOn, square, kindOf, inCheck, hasLegalMove, other, SIDE_NAMES, type Cell, type Move, type Side } from '$lib/game';
import { kindOf, inCheck, hasLegalMove, other } from '$lib/game/board';
const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase()); const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase());
const withRoom = (path: string) => (room ? `${path}?room=${room}` : path); const withRoom = (path: string) => (room ? `${path}?room=${room}` : path);
@@ -31,17 +30,17 @@
const right = scene.moves.some(([a, b]) => square(a) === from && square(b) === to); const right = scene.moves.some(([a, b]) => square(a) === from && square(b) === to);
const played = playOn(cells, from, to); const played = playOn(cells, from, to);
const move: Move = { by: scene.side, from, to, kind: kindOf(cells[from]), captured: played.captured, afar: played.afar, promoted: played.promoted, check: inCheck(played.cells, other(scene.side)) }; const move: Move = { by: scene.side, from, to, kind: kindOf(cells[from]), captured: played.captured, afar: played.afar, promoted: played.promoted, check: inCheck(played.cells, other(scene.side)) };
const name = (s: typeof scene.side) => (s === scene.side ? 'You' : 'Scarlet'); const owner = (s: Side) => (s === scene.side ? 'Your' : `${SIDE_NAMES[s]}’s`);
let told = tell(cells, move, name).replace(/You’s/g, 'Your'); let told = tell(cells, move, owner);
const mated = move.check && !hasLegalMove(played.cells, other(scene.side), 'printed'); const mated = move.check && !hasLegalMove(played.cells, other(scene.side), 'printed');
if (mated) told += ' Checkmate.'; if (mated) told += ' Checkmate.';
outcome = { told, text: right ? scene.after : (scene.aside ?? scene.after), right }; outcome = { told, text: right ? scene.after : (scene.aside ?? scene.after), right };
cells = played.cells; cells = played.cells;
} }
/** The panel reads a game; this one is the scene's. */ /** The board and the panel read a game; this one is the scene's, with the reader on the scene's side. */
const g = $derived.by(() => { const g = $derived.by(() => {
const s = createGame({ A: 'You', B: 'Scarlet' }); const s = createGame({ A: scene.side === 'gold' ? 'You' : SIDE_NAMES.gold, B: scene.side === 'scarlet' ? 'You' : SIDE_NAMES.scarlet });
s.cells = cells; s.cells = cells;
s.toMove = scene.side; s.toMove = scene.side;
return s; return s;
@@ -52,7 +51,7 @@
<title>Wyrm: learn the pieces</title> <title>Wyrm: learn the pieces</title>
</svelte:head> </svelte:head>
<main class="lesson"> <main class="reading wide">
<header> <header>
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a>{' · '}{/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/guide')}>How to play</a> · <a class="back" href={withRoom('/rules')}>The rules</a> {#if room}<a class="back" href={`/join/${room}`}>Back to your game</a>{' · '}{/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/guide')}>How to play</a> · <a class="back" href={withRoom('/rules')}>The rules</a>
<h1>Learn the pieces</h1> <h1>Learn the pieces</h1>
@@ -67,7 +66,7 @@
<section class="stage"> <section class="stage">
<div class="boards"> <div class="boards">
<LessonBoard {cells} side={scene.side} levels={scene.levels} locked={outcome !== null} onMove={moved} onInspect={(at) => (inspected = at)} /> <LessonBoard {g} levels={scene.levels} locked={outcome !== null} onMove={moved} bind:inspected />
</div> </div>
<div class="words"> <div class="words">
<p class="eyebrow">Scene {n + 1} of {SCENES.length}</p> <p class="eyebrow">Scene {n + 1} of {SCENES.length}</p>
@@ -97,28 +96,6 @@
</main> </main>
<style> <style>
.lesson {
max-width: 1280px;
margin: 0 auto;
padding: 1.5rem var(--gutter) 4rem;
}
.lesson a.back {
color: var(--frost);
font-size: 0.95rem;
}
h1 {
font-size: 2.4rem;
font-weight: 300;
margin: 0.5rem 0 0.3rem;
}
.lede {
color: var(--bone-dim);
max-width: 44em;
}
.scenes { .scenes {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+5 -59
View File
@@ -5,7 +5,7 @@
import PiecePanel from '$lib/components/PiecePanel.svelte'; import PiecePanel from '$lib/components/PiecePanel.svelte';
import { CALLOUTS, TOLD_MOVES, toldStates } from '$lib/game/told'; import { CALLOUTS, TOLD_MOVES, toldStates } from '$lib/game/told';
import { narrate } from '$lib/game/chronicle'; import { narrate } from '$lib/game/chronicle';
import { notation, kingSquare, inCheck, movesFrom, legalMovesFrom, levelOf, fileOf, rankOf, idx, FILES, RANKS, LEVELS } from '$lib/game'; import { notation, kingSquare, inCheck, movesFrom, legalMovesFrom, sideOf } from '$lib/game';
import { prefs, reducedMotion } from '$lib/prefs.svelte'; import { prefs, reducedMotion } from '$lib/prefs.svelte';
const states = toldStates(); const states = toldStates();
@@ -27,8 +27,7 @@
let inspected = $state<number | null>(null); let inspected = $state<number | null>(null);
const reach = $derived.by(() => { const reach = $derived.by(() => {
if (inspected === null || g.cells[inspected] === '' || g.over) return []; if (inspected === null || g.cells[inspected] === '' || g.over) return [];
const side = g.cells[inspected][0] === 'g' ? 'gold' : 'scarlet'; return sideOf(g.cells[inspected]) === g.toMove ? legalMovesFrom(g.cells, inspected, g.setup) : movesFrom(g.cells, inspected, g.setup);
return side === g.toMove ? legalMovesFrom(g.cells, inspected, g.setup) : movesFrom(g.cells, inspected, g.setup);
}); });
function go(n: number) { function go(n: number) {
@@ -38,21 +37,6 @@
function tap(i: number) { function tap(i: number) {
inspected = g.cells[i] !== '' && inspected !== i ? i : null; inspected = g.cells[i] !== '' && inspected !== i ? i : null;
} }
function squareKey(e: KeyboardEvent, i: number) {
const step: Record<string, [number, number, number]> = { ArrowLeft: [-1, 0, 0], ArrowRight: [1, 0, 0], ArrowUp: [0, 1, 0], ArrowDown: [0, -1, 0], PageUp: [0, 0, 1], PageDown: [0, 0, -1] };
if (e.key in step) {
e.preventDefault();
e.stopPropagation();
const [df, dr, dl] = step[e.key];
const nf = fileOf(i) + df;
const nr = rankOf(i) + dr;
const nl = levelOf(i) + dl;
if (nf >= 0 && nf < FILES && nr >= 0 && nr < RANKS && nl >= 1 && nl <= LEVELS) document.querySelector<SVGRectElement>(`rect.square[data-i="${idx(nl, nf, nr)}"]`)?.focus();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
tap(i);
} else if (e.key === 'Escape') inspected = null;
}
/** Left and right step the game, unless the keyboard is on a square, where the arrows walk the board. */ /** Left and right step the game, unless the keyboard is on a square, where the arrows walk the board. */
function key(e: KeyboardEvent) { function key(e: KeyboardEvent) {
if ((e.target as HTMLElement | null)?.tagName === 'rect') return; if ((e.target as HTMLElement | null)?.tagName === 'rect') return;
@@ -69,7 +53,7 @@
<svelte:window onkeydown={key} /> <svelte:window onkeydown={key} />
<main class="lesson"> <main class="reading wide">
<header> <header>
<a class="back" href="/">Back to the hall</a> · <a class="back" href="/learn">Learn the pieces</a> · <a class="back" href="/guide">How to play</a> · <a class="back" href="/rules">The rules</a> <a class="back" href="/">Back to the hall</a> · <a class="back" href="/learn">Learn the pieces</a> · <a class="back" href="/guide">How to play</a> · <a class="back" href="/rules">The rules</a>
<h1>A game told</h1> <h1>A game told</h1>
@@ -78,12 +62,12 @@
<section class="stage"> <section class="stage">
<div class="boards"> <div class="boards">
<BoardArtwork {g} {showCoords} {shownSquares} {shownCaptured} {checkSquare} selected={inspected} {reach} {calm} yourMove={true} {tap} key={squareKey} /> <BoardArtwork {g} {showCoords} {shownSquares} {shownCaptured} {checkSquare} selected={inspected} {reach} {calm} {tap} escape={() => (inspected = null)} />
</div> </div>
<div class="words"> <div class="words">
<p class="eyebrow">{ply === 0 ? 'Before the first move' : `Move ${ply} of ${TOLD_MOVES.length}`}{#if move}&nbsp;· {notation(move)}{/if}</p> <p class="eyebrow">{ply === 0 ? 'Before the first move' : `Move ${ply} of ${TOLD_MOVES.length}`}{#if move}&nbsp;· {notation(move)}{/if}</p>
{#if telling} {#if telling}
<ul class="told"> <ul class="told telling">
{#each telling.lines as l, i (i)}<li class={l.kind}>{l.text}</li>{/each} {#each telling.lines as l, i (i)}<li class={l.kind}>{l.text}</li>{/each}
</ul> </ul>
{/if} {/if}
@@ -107,26 +91,7 @@
</main> </main>
<style> <style>
.lesson {
max-width: 1280px;
margin: 0 auto;
padding: 1.5rem var(--gutter) 4rem;
}
.lesson a.back {
color: var(--frost);
font-size: 0.95rem;
}
h1 {
font-size: 2.4rem;
font-weight: 300;
margin: 0.5rem 0 0.3rem;
}
.lede { .lede {
color: var(--bone-dim);
max-width: 44em;
margin-bottom: 1.2rem; margin-bottom: 1.2rem;
} }
@@ -167,25 +132,6 @@
font-size: 0.95rem; font-size: 0.95rem;
} }
.told li.capture {
color: var(--ember);
}
.told li.check,
.told li.freeze,
.told li.promotion {
color: var(--frost);
}
.told li.note {
color: var(--bone-dim);
font-style: italic;
}
.told li.end {
color: var(--blood);
}
.callout { .callout {
margin-top: 0.8rem; margin-top: 0.8rem;
color: var(--bone); color: var(--bone);