A game told: twenty-seven written moves stepped through on the boards with a callout wherever there is something to see, and a composer to write the next one
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
This commit is contained in:
1 parent
c8443c6f50
commit
965a28142c
7 files changed
+298
-4
No files matched your search
@@ -100,7 +100,8 @@ function tellOne(before: Cell[], move: Move, ply: number, name: (side: Side) =>
|
||||
lines.push({ kind: 'freeze', text: `The Basilisk’s gaze falls on ${owner} ${NAMES[kindOf(after[i])]} at ${squareName(i)}, which cannot stir.` });
|
||||
}
|
||||
for (const i of heldBefore) {
|
||||
if (heldAfter.has(i) || after[i] === '' || i === move.from) continue;
|
||||
// The same piece, still there and no longer held; a piece that has just arrived is another story.
|
||||
if (heldAfter.has(i) || after[i] === '' || after[i] !== before[i] || i === move.from || i === move.to) continue;
|
||||
const owner = sideOf(after[i]) === move.by ? who : foe;
|
||||
lines.push({ kind: 'note', text: `${owner} ${NAMES[kindOf(after[i])]} at ${squareName(i)} is free to move again.` });
|
||||
}
|
||||
|
||||
@@ -61,3 +61,22 @@ describe('the lessons', () => {
|
||||
void other;
|
||||
});
|
||||
});
|
||||
|
||||
describe('the game told', () => {
|
||||
it('plays through the engine from the opening to a checkmate, and every callout sits on a move', async () => {
|
||||
const { toldStates, TOLD_MOVES, CALLOUTS } = await import('./told');
|
||||
const states = toldStates();
|
||||
expect(states).toHaveLength(TOLD_MOVES.length + 1);
|
||||
const last = states[states.length - 1];
|
||||
expect(last.end).toBe('mate');
|
||||
expect(last.over?.winner).toBe('A');
|
||||
for (const n of Object.keys(CALLOUTS).map(Number)) expect(n).toBeLessThanOrEqual(TOLD_MOVES.length);
|
||||
// The things the callouts say happened.
|
||||
expect(last.moves[8].captured).toBe('R');
|
||||
expect(last.moves[16].kind).toBe('B');
|
||||
expect(last.moves[20].captured).toBe('H');
|
||||
expect(last.moves[23].captured).toBe('H');
|
||||
expect(last.moves[26].captured).toBe('S');
|
||||
expect(last.moves[26].check).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// A game told: twenty-seven moves written to show what a first game should
|
||||
// see, with a callout where there is something to look at. The moves are
|
||||
// replayed through the engine, so the game can never drift from the rules,
|
||||
// and a test says so.
|
||||
|
||||
import { applyMove, createGame, validateMove, type State } from './index';
|
||||
import { square } from './board';
|
||||
|
||||
/** The moves, from and to, in order. */
|
||||
export const TOLD_MOVES: [string, string][] = [
|
||||
['2f2', '2f3'], ['2g7', '2g6'], ['2b1', '2c3'], ['2k8', '2j6'], ['2c1', '2e3'], ['3g8', '3c4'], ['2c3', '2e4'], ['3c4', '3d4'], ['2e3', '3d4'],
|
||||
['2c8', '2e6'], ['3d4', '2e5'], ['2h7', '2h6'], ['1c1', '1c2'], ['2e6', '2c4'], ['1c2', '1c3'], ['2i8', '2h7'], ['1c3', '1c4'], ['2d7', '2d6'],
|
||||
['2b2', '2b3'], ['2j6', '2h5'], ['2b3', '2c4'], ['3c8', '3f6'], ['2e2', '2e3'], ['3f6', '2e5'], ['3g1', '3d4'], ['2a7', '2a6'], ['3d4', '3g7']
|
||||
];
|
||||
|
||||
/** What to look at, by the ply just played; 0 is before the first move. */
|
||||
export const CALLOUTS: Record<number, string> = {
|
||||
0: 'Both armies stand as Gygax drew them, and Gold moves first. Step through the game a move at a time. Where there is something to see, a word on it appears here.',
|
||||
1: 'A Warrior steps one square, never two, so the opening is slower than chess. This one clears the f file for the Mage behind it.',
|
||||
3: 'The Unicorn is the first piece out, because it is the one piece of the land that can leap the wall of Warriors.',
|
||||
5: 'The Hero leaps two squares along a diagonal, over its own Warrior at 2d2. That leap is how a Hero gets out early.',
|
||||
6: 'Scarlet’s Dragon leaves its roost, which Gygax warned against. Look at what it threatens now: the five land squares beneath 3c4, and Gold’s Unicorn at 2c3 among them. Then look at what it has left behind. There is no roof over Scarlet’s King.',
|
||||
7: 'Gold steps the Unicorn out of the Dragon’s shadow.',
|
||||
8: 'The Dragon follows, hungry, to 3d4, where it threatens the Unicorn again. But it has stepped onto a square a Hero can reach. From 2e3 a Hero rises onto any square diagonally beside 3e3, and 3d4 is one of them.',
|
||||
9: 'The Hero rises and the Dragon falls. Only five kinds of land piece can touch the air at all, and this is how one of them does it. The Hero is in the air now, where it can do nothing but come down.',
|
||||
10: 'Scarlet brings a Hero out in answer, leaping its own Warrior at 2d7.',
|
||||
11: 'Gold’s Hero comes home to the land, onto a square diagonally beside the one beneath it. Off the land, that was its only kind of move.',
|
||||
13: 'Gold’s Basilisk sets out on a slow walk, one square a move. It wants to be beneath whatever Scarlet sends deep into Gold’s side. Watch where Scarlet’s Hero goes.',
|
||||
14: 'The Hero digs into Gold’s side at 2c4, two leaps from home. A piece that comes this deep should look beneath its feet.',
|
||||
17: 'The gaze falls. The Hero at 2c4 is frozen: it cannot move, cannot take, and threatens nothing, until the Basilisk moves or dies. Gold is in no hurry.',
|
||||
19: 'A Warrior comes to stand diagonally behind the frozen Hero.',
|
||||
21: 'Taken, in Gygax’s words, more or less at your leisure. The Warrior is safe above Gold’s own Basilisk, since a Basilisk freezes only enemies.',
|
||||
22: 'Scarlet’s Griffon leaps to 3f6, two squares one way and three the other, over everything. Now look at the four land squares diagonally beside 2f6. One of them is 2e5, where Gold’s Hero stands.',
|
||||
23: 'Gold does not look up.',
|
||||
24: 'The swoop. The Griffon drops onto the Hero and takes it. On the land it has lost its wings, and creeps one square at a time until it climbs back.',
|
||||
25: 'Gold’s Dragon leaves its roost, and this time it is safe to do so: Scarlet’s Dragon is dead, so nothing threatens Gold’s King from the air. Look along the diagonal from 3d4 through e5 and f6 to 3g7, where a Scarlet Sylph hovers above Scarlet’s King.',
|
||||
26: 'Scarlet does not look up either.',
|
||||
27: 'The Dragon takes the Sylph at 3g7 and strikes down at every square around the King: 2g7 beneath it, and 2g8, 2f7 and 2h7 beside that. The King cannot step up, because the Dragon stands beside 3g8. It cannot step down, because its own Elemental is at 1g8. Its neighbours on the land are its own pieces or struck squares, and nothing of Scarlet’s can reach 3g7. Checkmate.'
|
||||
};
|
||||
|
||||
/** The game at every ply, from the opening to the end. The engine refuses a move that is not legal. */
|
||||
export function toldStates(): State[] {
|
||||
let s = createGame({ A: 'Gold', B: 'Scarlet' });
|
||||
const out = [s];
|
||||
TOLD_MOVES.forEach(([from, to], n) => {
|
||||
const seat = s.toMove === 'gold' ? 'A' : 'B';
|
||||
const why = validateMove(s, seat, { from: square(from), to: square(to) });
|
||||
if (why) throw new Error(`told game, ply ${n + 1}, ${from} to ${to}: ${why}`);
|
||||
s = applyMove(s, square(from), square(to));
|
||||
out.push(s);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
<section id="first">
|
||||
<h2>A first game</h2>
|
||||
<p>Before that, <a href="/learn">learn the pieces</a>: fifteen short scenes, one a piece, each a small position with a move for you to make and a word on what came of it.</p>
|
||||
<p>Before that, <a href="/learn">learn the pieces</a>, fifteen short scenes, one a piece, each a small position with a move for you to make and a word on what came of it, and then <a href="/learn/game">a game told</a>, twenty-seven moves stepped through with a callout wherever there is something to see.</p>
|
||||
<p>Play a drake first. It plays a fair game and never minds how long you take. Begin as you would at chess, with Warriors stepping forward and the knights of the land, the Unicorns and Heroes, coming out behind them. Gygax's own advice is to hold the Mage and the Paladin back until the lesser pieces are developed, and to keep your Dragon home. It starts directly above your King for a reason. A Dragon that flies off to raid leaves the King open to the other Dragon, which strikes the land from the air without ever coming down.</p>
|
||||
<p>Watch the Basilisks. Anything you leave on the land directly above an enemy Basilisk is frozen on the spot, and a frozen piece may as well be off the board. The Cleric is stronger than it looks, since it is the only piece that plays the same on all three boards, and the Dwarves, if you do not hurry them forward, make good guards late in the game.</p>
|
||||
<p>The full rules, piece by piece, are on the <a href={withRoom('/rules')}>rules page</a>, with Gygax's article beneath them. If a rule here reads wrong to you, or something misbehaves, <a href="mailto:eric@ericwagoner.com">send word</a>, or use <em>Report</em> in the masthead at the table, which pins what you saw to the move it happened on. The hall's <em>about this game</em> lists the other ways to reach the keeper.</p>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<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>
|
||||
<h1>Learn the pieces</h1>
|
||||
<p class="lede">Fifteen pieces, one scene each, and a checkmate to finish. Every scene is a small position with something to see and one move to make. Tap any piece to read about it, as at the table.</p>
|
||||
<p class="lede">Fifteen pieces, one scene each, and a checkmate to finish. Every scene is a small position with something to see and one move to make. Tap any piece to read about it, as at the table. When the scenes are done, <a href="/learn/game">a game told</a> shows the pieces working together.</p>
|
||||
</header>
|
||||
|
||||
<nav class="scenes" aria-label="Scenes">
|
||||
@@ -78,7 +78,7 @@
|
||||
<p>{outcome.text}</p>
|
||||
<div class="ways">
|
||||
<button type="button" class="quiet" onclick={() => setScene(n)}>Set the scene again</button>
|
||||
{#if n < SCENES.length - 1}<button type="button" class="commit small" onclick={() => setScene(n + 1)}>Next: {SCENES[n + 1].title}</button>{:else}<a class="commit small" href="/">Back to the hall</a>{/if}
|
||||
{#if n < SCENES.length - 1}<button type="button" class="commit small" onclick={() => setScene(n + 1)}>Next: {SCENES[n + 1].title}</button>{:else}<a class="commit small" href="/learn/game">Next: a game told</a>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="task">{scene.task}</p>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
// A game told: the boards, one move at a time, each move as the chronicle
|
||||
// tells it, and a callout where there is something to see.
|
||||
import BoardArtwork from '$lib/components/BoardArtwork.svelte';
|
||||
import { CALLOUTS, TOLD_MOVES, toldStates } from '$lib/game/told';
|
||||
import { narrate } from '$lib/game/chronicle';
|
||||
import { notation, kingSquare, inCheck } from '$lib/game';
|
||||
import { prefs, reducedMotion } from '$lib/prefs.svelte';
|
||||
|
||||
const states = toldStates();
|
||||
const last = states[states.length - 1];
|
||||
const tellings = narrate(last);
|
||||
let ply = $state(0);
|
||||
const g = $derived(states[ply]);
|
||||
const move = $derived(ply > 0 ? last.moves[ply - 1] : null);
|
||||
const telling = $derived(ply > 0 ? tellings[ply - 1] : null);
|
||||
const callout = $derived(CALLOUTS[ply] ?? '');
|
||||
const shownSquares = $derived(move ? (move.afar ? [move.from] : [move.from, move.to]) : []);
|
||||
const shownCaptured = $derived(move?.captured ? [move.to] : []);
|
||||
const checkSquare = $derived(!g.over && ply > 0 && inCheck(g.cells, g.toMove) ? kingSquare(g.cells, g.toMove) : move?.check ? kingSquare(g.cells, g.toMove) : -1);
|
||||
const calm = $derived(reducedMotion());
|
||||
const showCoords = $derived(prefs.coordinates !== false);
|
||||
const marks = Object.keys(CALLOUTS).map(Number).filter((n) => n > 0);
|
||||
|
||||
function go(n: number) {
|
||||
ply = Math.max(0, Math.min(TOLD_MOVES.length, n));
|
||||
}
|
||||
function key(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowRight') go(ply + 1);
|
||||
else if (e.key === 'ArrowLeft') go(ply - 1);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Wyrm: a game told</title>
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window onkeydown={key} />
|
||||
|
||||
<main class="lesson">
|
||||
<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>
|
||||
<h1>A game told</h1>
|
||||
<p class="lede">Twenty-seven moves, written to show what a first game should see: a Dragon that leaves its roost too soon and the Hero that kills it, a Basilisk’s slow walk and the piece it freezes, a Griffon’s swoop, and a Dragon’s checkmate from the air. Step through it with the buttons or the arrow keys.</p>
|
||||
</header>
|
||||
|
||||
<section class="stage">
|
||||
<div class="boards">
|
||||
<BoardArtwork {g} {showCoords} {shownSquares} {shownCaptured} {checkSquare} {calm} decorative />
|
||||
</div>
|
||||
<div class="words">
|
||||
<p class="eyebrow">{ply === 0 ? 'Before the first move' : `Move ${ply} of ${TOLD_MOVES.length}`}{#if move} · {notation(move)}{/if}</p>
|
||||
{#if telling}
|
||||
<ul class="told">
|
||||
{#each telling.lines as l, i (i)}<li class={l.kind}>{l.text}</li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if callout}<p class="callout">{callout}</p>{/if}
|
||||
<div class="ways">
|
||||
<button type="button" class="quiet" disabled={ply === 0} onclick={() => go(ply - 1)}>Back</button>
|
||||
<button type="button" class="commit small" disabled={ply === TOLD_MOVES.length} onclick={() => go(ply + 1)}>Next move</button>
|
||||
{#if ply === TOLD_MOVES.length}<a class="quiet" href="/">Play a drake</a>{/if}
|
||||
</div>
|
||||
<p class="muted small marks">
|
||||
Jump to a callout:
|
||||
{#each marks as n (n)}<button type="button" class="link plain mark" class:current={ply === n} onclick={() => go(n)}>{n}</button>{/each}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<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;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 420px);
|
||||
gap: 1.5rem clamp(1.5rem, 4vw, 3.5rem);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stage {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.words {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.boards {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.words {
|
||||
position: sticky;
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
.told {
|
||||
list-style: none;
|
||||
margin: 0.3rem 0 0;
|
||||
padding: 0;
|
||||
border-left: 2px solid var(--frost);
|
||||
padding-left: 0.6rem;
|
||||
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 {
|
||||
margin-top: 0.8rem;
|
||||
color: var(--bone);
|
||||
border-left: 2px solid var(--ember);
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
|
||||
.ways {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.8rem;
|
||||
margin-top: 0.9rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ways .quiet {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.marks {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.mark {
|
||||
color: var(--bone-dim);
|
||||
text-decoration: none;
|
||||
margin-left: 0.45rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mark.current {
|
||||
color: var(--ember);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user