The table, the hall, the rules and the guide: three boards drawn in Gygax's colours, a drake to play, the article in full, and the pages that walk a player in

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 16:39:50 -04:00
1 parent 1ef5b23712
commit b9efe304a0
29 files changed
+1116 -333

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ description: The weekly Wyrm operations pulse — game server health, errors, th
One command on the droplet prints everything; read it as a short report. One command on the droplet prints everything; read it as a short report.
ssh root@__IP__ wyrm-pulse.sh ssh root@159.203.115.252 wyrm-pulse.sh
- **game server**: whether the service and Caddy are up, restarts and - **game server**: whether the service and Caddy are up, restarts and
journal errors in the last seven days with the last three error lines. journal errors in the last seven days with the last three error lines.
+4 -4
View File
@@ -7,7 +7,7 @@ description: Work the Wyrm reports desk — fetch players' bug reports, show the
Players file reports from the Report button in a room's masthead (seated Players file reports from the Report button in a room's masthead (seated
or from the gallery). Each lands in `/var/lib/wyrm/feedback.jsonl` or from the gallery). Each lands in `/var/lib/wyrm/feedback.jsonl`
on the droplet (__IP__) pinned with `roomId`, `turn` (the turn on the droplet (159.203.115.252) pinned with `roomId`, `turn` (the turn
being written when it was filed) and `seq` (the ledger's length then), being written when it was filed) and `seq` (the ledger's length then),
enough to replay the game to the moment. Replies live in the same file enough to replay the game to the moment. Replies live in the same file
and appear under the report in the player's hall. This desk fetches, and appear under the report in the player's hall. This desk fetches,
@@ -48,7 +48,7 @@ to hear his players' voices; never compress their words into a table.
## c) Process an unanswered report ## c) Process an unanswered report
1. **Replay to the pin.** `scp root@__IP__:/var/lib/wyrm/rooms/<roomId>.jsonl <scratchpad>/` 1. **Replay to the pin.** `scp root@159.203.115.252:/var/lib/wyrm/rooms/<roomId>.jsonl <scratchpad>/`
and replay it with the engine as `deploy/replay-ledgers.ts` does: and replay it with the engine as `deploy/replay-ledgers.ts` does:
`game.create(names, start.seed, start.rules, room.options)` then `game.create(names, start.seed, start.rules, room.options)` then
`game.resolve(state, line.inputs)` per turn line, printing the state `game.resolve(state, line.inputs)` per turn line, printing the state
@@ -72,8 +72,8 @@ to hear his players' voices; never compress their words into a table.
passing `rules` to `createGame`). Old ledgers then replay as their passing `rules` to `createGame`). Old ledgers then replay as their
players saw them; new games get the fix. players saw them; new games get the fix.
Either way add a test in `src/lib/game/*.test.ts` pinning the Either way add a test in `src/lib/game/*.test.ts` pinning the
corrected behaviour. Deploy with `deploy/deploy.sh __IP__`. corrected behaviour. Deploy with `deploy/deploy.sh 159.203.115.252`.
5. **Reply.** `bash deploy/report-reply.sh __IP__ <reportId> <status> "text"`. 5. **Reply.** `bash deploy/report-reply.sh 159.203.115.252 <reportId> <status> "text"`.
Pass the id as ONE clean argument. Write to the PLAYER: name what you Pass the id as ONE clean argument. Write to the PLAYER: name what you
replayed, cite the rule, and say plainly what was wrong or why nothing replayed, cite the rule, and say plainly what was wrong or why nothing
was. The desk's voice is warm and specific. was. The desk's voice is warm and specific.
+1 -1
View File
@@ -10,7 +10,7 @@ host glancing over the hall.
## Gather ## Gather
ssh root@__IP__ wyrm-visitors.sh [YYYY-MM-DD] ssh root@159.203.115.252 wyrm-visitors.sh [YYYY-MM-DD]
Default is today, UTC; Eric is in US Eastern, so an evening at his desk Default is today, UTC; Eric is in US Eastern, so an evening at his desk
spills into the next UTC day. Run yesterday too when the hour is early. spills into the next UTC day. Run yesterday too when the hour is early.
+4 -4
View File
@@ -22,13 +22,13 @@ dip 8790.
## Current production ## Current production
- Host: __IP__ - Host: 159.203.115.252
- URLs: https://wyrm.kestrelsnest.social (A record at Hover, where kestrelsnest.social's - URLs: https://wyrm.kestrelsnest.social (A record at Hover, where kestrelsnest.social's
DNS lives) and https://wyrm.__IP__.sslip.io (always works, zero DNS). DNS lives) and https://wyrm.159.203.115.252.sslip.io (always works, zero DNS).
- Everyday deploy: `deploy/deploy.sh __IP__` - Everyday deploy: `deploy/deploy.sh 159.203.115.252`
- Players' reports: `deploy/pull-reports.sh` mirrors /var/lib/wyrm/feedback.jsonl - Players' reports: `deploy/pull-reports.sh` mirrors /var/lib/wyrm/feedback.jsonl
and the screenshots to ~/Desktop/wyrm-reports with a digest; and the screenshots to ~/Desktop/wyrm-reports with a digest;
`deploy/report-reply.sh __IP__ <id> <status> "text"` answers one. `deploy/report-reply.sh 159.203.115.252 <id> <status> "text"` answers one.
## Adding this game to the host ## Adding this game to the host
+1 -1
View File
@@ -4,7 +4,7 @@
# repository root: # repository root:
# deploy/pull-reports.sh [host] [folder] (default ~/Desktop/wyrm-reports) # deploy/pull-reports.sh [host] [folder] (default ~/Desktop/wyrm-reports)
set -euo pipefail set -euo pipefail
HOST="${1:-__IP__}" HOST="${1:-159.203.115.252}"
OUT="${2:-$HOME/Desktop/wyrm-reports}" OUT="${2:-$HOME/Desktop/wyrm-reports}"
mkdir -p "$OUT/images" mkdir -p "$OUT/images"
scp -q "root@$HOST:/var/lib/wyrm/feedback.jsonl" "$OUT/feedback.jsonl" 2>/dev/null || : > "$OUT/feedback.jsonl" scp -q "root@$HOST:/var/lib/wyrm/feedback.jsonl" "$OUT/feedback.jsonl" 2>/dev/null || : > "$OUT/feedback.jsonl"
+1
View File
@@ -0,0 +1 @@
<!doctype html><html><body style="margin:0;background:#121a20"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="180" height="180" style="display:block"><rect width="32" height="32" fill="#121a20"/><path d="M6 23.5l6-3h14l-6 3z" fill="#7a4a3a"/><path d="M6 17.5l6-3h14l-6 3z" fill="#8fa66a"/><path d="M6 11.5l6-3h14l-6 3z" fill="#9fb8cc"/><circle cx="17" cy="8.5" r="4.2" fill="#d8a63e" stroke="#8a6218" stroke-width="1"/><circle cx="15" cy="20.5" r="2.6" fill="#a4302a" stroke="#5e1612" stroke-width="0.8"/></svg></body></html>
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<!-- The share card, 1200 by 630. Open it with the site running at localhost:5173 and screenshot the body: docs/artwork.md. -->
<html>
<head>
<meta charset="utf-8" />
<style>
body { margin: 0; width: 1200px; height: 630px; background: #121a20; color: #e9e2d2; font-family: 'Iowan Old Style', Georgia, serif; overflow: hidden; position: relative; }
.boards { position: absolute; left: 56px; top: 44px; width: 480px; height: 542px; overflow: hidden; border-radius: 8px; border: 1px solid rgba(233, 226, 210, 0.2); }
.boards img { width: 480px; display: block; }
.words { position: absolute; left: 600px; top: 120px; width: 540px; }
h1 { font-size: 110px; font-weight: 300; margin: 0; line-height: 1; letter-spacing: -0.01em; }
.pitch { font-size: 30px; font-style: italic; margin: 22px 0 0; line-height: 1.3; }
.tag { font-size: 23px; color: #98a4ab; margin: 26px 0 0; line-height: 1.4; }
.site { font-size: 22px; color: #f0a53a; margin: 34px 0 0; letter-spacing: 0.04em; }
</style>
</head>
<body>
<div class="boards"><img src="http://localhost:5173/guide/boards.png" alt="" /></div>
<div class="words">
<h1>Wyrm</h1>
<p class="pitch">Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.</p>
<p class="tag">Gary Gygax’s Dragonchess of 1985, free in the browser. Play a drake, or send a friend the link.</p>
<p class="site">wyrm.kestrelsnest.social</p>
</div>
</body>
</html>
+72 -132
View File
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { game, type Input, type State } from '../../src/lib/game'; import { game, type Input, type State } from '../../src/lib/game';
import { square } from '../../src/lib/game/board';
import type { GameSpec } from '../../src/lib/game/spec'; import type { GameSpec } from '../../src/lib/game/spec';
import { Rooms } from './rooms'; import { Rooms } from './rooms';
import { Store } from './store'; import { Store } from './store';
@@ -16,24 +17,37 @@ afterEach(() => {
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
}); });
/** A table of the demo game: Ann, who opened it, Bo, and Cy when asked for, the game begun. */ /** Gold's first Warrior step and Scarlet's answer. */
function table(spec: GameSpec<State, Input> = game, withCy = false) { const GOLD_OPENS: Input = { from: square('2c2'), to: square('2c3') };
const SCARLET_ANSWERS: Input = { from: square('2c7'), to: square('2c6') };
/** A table of two: Ann, who opened it and plays Gold, and Bo, the game begun. */
function table(spec: GameSpec<State, Input> = game) {
const rooms = new Rooms(spec, new Store(dir)); const rooms = new Rooms(spec, new Store(dir));
const host = rooms.create('Ann', 3); const host = rooms.create('Ann', 2);
const guest = rooms.join(host.room, 'Bo'); const guest = rooms.join(host.room, 'Bo');
const third = withCy ? rooms.join(host.room, 'Cy') : null;
rooms.begin(host.room, host.token); rooms.begin(host.room, host.token);
return { rooms, room: host.room, ann: host.token, bo: guest.token, cy: third?.token }; return { rooms, room: host.room, ann: host.token, bo: guest.token };
} }
describe('rooms', () => { describe('rooms', () => {
it('resolves a round once every seat has moved, and refuses a move for a round already played', () => { it('plays a move as soon as the side to move sends it, and refuses one for a round already played', () => {
const { rooms, room, ann, bo } = table(); const { rooms, room, ann, bo } = table();
rooms.submit(room, ann, { pick: 5 }, 0); expect(rooms.waitingOn(room)).toEqual(['A']);
expect(rooms.waitingOn(room)).toEqual(['B']); expect(() => rooms.submit(room, bo, SCARLET_ANSWERS, 0)).toThrow(/not your move/);
rooms.submit(room, bo, { pick: 2 }, 0); rooms.submit(room, ann, GOLD_OPENS, 0);
expect(room.resolved).toBe(1); expect(room.resolved).toBe(1);
expect(() => rooms.submit(room, ann, { pick: 4 }, 0)).toThrow(/already been played/); expect(rooms.waitingOn(room)).toEqual(['B']);
expect(() => rooms.submit(room, ann, GOLD_OPENS, 0)).toThrow(/already been played/);
rooms.submit(room, bo, SCARLET_ANSWERS, 1);
expect(room.resolved).toBe(2);
expect(room.state!.moves).toHaveLength(2);
});
it('refuses a move the rules refuse, with the reason', () => {
const { rooms, room, ann } = table();
expect(() => rooms.submit(room, ann, { from: square('2c2'), to: square('2c4') }, 0)).toThrow(/Warrior cannot go to 2c4/);
expect(() => rooms.submit(room, ann, { from: square('2c7'), to: square('2c6') }, 0)).toThrow(/not one of your pieces/);
}); });
it("puts the keeper's word in the table's talk though the gallery may not talk, and a restart keeps it", () => { it("puts the keeper's word in the table's talk though the gallery may not talk, and a restart keeps it", () => {
@@ -45,7 +59,7 @@ describe('rooms', () => {
expect(new Rooms(game, new Store(dir)).get(room.id).chat.at(-1)).toMatchObject(said); expect(new Rooms(game, new Store(dir)).get(room.id).chat.at(-1)).toMatchObject(said);
}); });
it('gives a seat a new token at the keeper\'s word: the old one opens nothing, and a restart keeps the change', () => { it("gives a seat a new token at the keeper's word: the old one opens nothing, and a restart keeps the change", () => {
const { rooms, room, bo } = table(); const { rooms, room, bo } = table();
const phrase = rooms.transfer(room, bo).phrase; const phrase = rooms.transfer(room, bo).phrase;
const { seat, token } = rooms.reseat(room, 'B'); const { seat, token } = rooms.reseat(room, 'B');
@@ -62,13 +76,23 @@ describe('rooms', () => {
it('re-seats only a person, and says when each person last acted', () => { it('re-seats only a person, and says when each person last acted', () => {
const { rooms, room, ann } = table(); const { rooms, room, ann } = table();
expect(() => rooms.reseat(room, 'Z')).toThrow(/No person sits/); expect(() => rooms.reseat(room, 'Z')).toThrow(/No person sits/);
rooms.submit(room, ann, { pick: 5 }, 0); rooms.submit(room, ann, GOLD_OPENS, 0);
const seats = rooms.seatsForKeeper(room); const seats = rooms.seatsForKeeper(room);
expect(seats.map((s) => [s.id, s.name])).toEqual([['A', 'Ann'], ['B', 'Bo']]); expect(seats.map((s) => [s.id, s.name])).toEqual([['A', 'Ann'], ['B', 'Bo']]);
expect(seats[0].lastActed).toBeGreaterThan(0); expect(seats[0].lastActed).toBeGreaterThan(0);
expect(seats[1].lastActed).toBeNull(); expect(seats[1].lastActed).toBeNull();
}); });
it('replays a game from its ledger to the same position after a restart', () => {
const { rooms, room, ann, bo } = table();
rooms.submit(room, ann, GOLD_OPENS, 0);
rooms.submit(room, bo, SCARLET_ANSWERS, 1);
const reopened = new Rooms(game, new Store(dir)).get(room.id);
expect(reopened.state!.cells).toEqual(room.state!.cells);
expect(reopened.state!.toMove).toBe('gold');
expect(reopened.resolved).toBe(2);
});
it('leaves a room with an unreadable ledger unloaded and opens the rest', () => { it('leaves a room with an unreadable ledger unloaded and opens the rest', () => {
const { room } = table(); const { room } = table();
appendFileSync(join(dir, 'ZZZZ.jsonl'), '{"t":"room","id":"ZZZZ","seats":2,"createdAt":1,"options":{}}\n{"t":"seat", torn'); appendFileSync(join(dir, 'ZZZZ.jsonl'), '{"t":"room","id":"ZZZZ","seats":2,"createdAt":1,"options":{}}\n{"t":"seat", torn');
@@ -79,80 +103,40 @@ describe('rooms', () => {
errors.mockRestore(); errors.mockRestore();
}); });
it('holds a draft until it is settled, and lets it be replaced until then', () => { it("shows a seat its last move played, and nobody else's", () => {
type Draft = Input & { final?: boolean }; const { rooms, room, ann } = table();
const drafts: GameSpec<State, Input> = { rooms.submit(room, ann, GOLD_OPENS, 0);
...game,
cleanInput: (raw) => ({ ...game.cleanInput(raw), final: (raw as Draft)?.final === true }) as Draft,
settled: (input) => (input as Draft).final === true
};
const { rooms, room, ann, bo } = table(drafts);
rooms.submit(room, ann, { pick: 5 }, 0);
rooms.submit(room, bo, { pick: 2, final: true }, 0);
expect(rooms.waitingOn(room)).toEqual(['A']);
rooms.submit(room, ann, { pick: 4 }, 0);
expect(room.resolved).toBe(0);
rooms.submit(room, ann, { pick: 3, final: true }, 0);
expect(room.resolved).toBe(1);
expect(room.state!.rounds[0].picks).toEqual({ A: 3, B: 2 });
rooms.submit(room, bo, { pick: 1, final: true }, 1);
expect(() => rooms.submit(room, bo, { pick: 2, final: true }, 1)).toThrow(/already in/);
rooms.submit(room, bo, { pick: 2 }, 1);
expect(rooms.waitingOn(room)).toEqual(['A', 'B']);
});
it("shows a seat its own move in waiting and its last move played, and nobody else's", () => {
const { rooms, room, ann, bo } = table();
rooms.submit(room, ann, { pick: 5 }, 0);
expect(rooms.view(room, 'A').yourInput).toEqual({ pick: 5 });
expect(rooms.view(room, 'B').yourInput).toBeNull();
expect(rooms.view(room, '').yourInput).toBeNull();
rooms.submit(room, bo, { pick: 2 }, 0);
expect(rooms.view(room, 'A').yourInput).toBeNull(); expect(rooms.view(room, 'A').yourInput).toBeNull();
expect(rooms.view(room, 'A').yourLastInput).toEqual({ pick: 5 }); expect(rooms.view(room, 'A').yourLastInput).toEqual(GOLD_OPENS);
expect(rooms.view(room, 'B').yourLastInput).toEqual({ pick: 2 }); expect(rooms.view(room, 'B').yourLastInput).toBeNull();
expect(rooms.view(room, '').yourLastInput).toBeNull(); expect(rooms.view(room, '').yourLastInput).toBeNull();
}); });
it('shows a private line to its sender and the seats it names, never to anyone else or the gallery', () => { it('shows a private line to its sender and the seat it names, never to the gallery', () => {
const { rooms, room, ann, bo } = table(game, true); const { rooms, room, ann, bo } = table();
rooms.say(room, ann, 'Everyone, hello.'); rooms.say(room, ann, 'Everyone, hello.');
rooms.say(room, ann, 'Bo, lay a five.', ['B']); rooms.say(room, ann, 'Bo, mind your Dragon.', ['B']);
const heard = (seat: string) => rooms.view(room, seat).chat.map((l) => l.text); const heard = (seat: string) => rooms.view(room, seat).chat.map((l) => l.text);
expect(heard('A')).toEqual(['Everyone, hello.', 'Bo, lay a five.']); expect(heard('A')).toEqual(['Everyone, hello.', 'Bo, mind your Dragon.']);
expect(heard('B')).toEqual(['Everyone, hello.', 'Bo, lay a five.']); expect(heard('B')).toEqual(['Everyone, hello.', 'Bo, mind your Dragon.']);
expect(heard('C')).toEqual(['Everyone, hello.']);
expect(heard('')).toEqual(['Everyone, hello.']); expect(heard('')).toEqual(['Everyone, hello.']);
expect(rooms.view(room, 'B').chat[1]).toMatchObject({ id: 'A', to: ['B'], turn: 1 }); expect(rooms.view(room, 'B').chat[1]).toMatchObject({ id: 'A', to: ['B'], turn: 1 });
expect(() => rooms.say(room, bo, 'Anyone?', ['Q'])).toThrow(/Nobody at this table/); expect(() => rooms.say(room, bo, 'Anyone?', ['Q'])).toThrow(/Nobody at this table/);
}); });
it('keeps each ranking secret until the deal, which reads them all from the start line', () => { it('seats the host on the side they chose, and a drake across from them', () => {
const dealt: Record<string, string[]>[] = []; const rooms = new Rooms(game, new Store(dir));
const ranked: GameSpec<State, Input> = { const host = rooms.create('Ann', 2, { side: 'scarlet', setup: 'shifted' });
...game, const bot = rooms.addBot(host.room, host.token);
rankable: () => [ expect(game.botNames).toContain(bot.name);
{ value: 'red', label: 'Red' }, rooms.begin(host.room, host.token);
{ value: 'blue', label: 'Blue' } const s = host.room.state!;
], expect(s.players.A.side).toBe('scarlet');
create: (names, seed, rules, options, rankings) => { expect(s.players.B.side).toBe('gold');
dealt.push(rankings ?? {}); expect(s.setup).toBe('shifted');
return game.create(names, seed, rules, options); // Gold moves first, and Gold is the drake: it has already moved.
} expect(s.moves).toHaveLength(1);
}; expect(rooms.waitingOn(host.room)).toEqual(['A']);
const rooms = new Rooms(ranked, new Store(dir));
const host = rooms.create('Ann', 2);
const guest = rooms.join(host.room, 'Bo');
const room = host.room;
expect(() => rooms.rank(room, host.token, ['red'])).toThrow(/every choice once/);
rooms.rank(room, host.token, ['blue', 'red']);
expect(rooms.view(room, 'A').yourRanking).toEqual(['blue', 'red']);
expect(rooms.view(room, 'B')).toMatchObject({ yourRanking: null, ranked: 1 });
rooms.begin(room, host.token);
expect(dealt).toEqual([{ A: ['blue', 'red'] }]);
expect(() => rooms.rank(room, guest.token, ['red', 'blue'])).toThrow(/ranking is closed/);
new Rooms(ranked, new Store(dir));
expect(dealt[1]).toEqual({ A: ['blue', 'red'] });
}); });
it('refuses a ranking at a table that asks for none', () => { it('refuses a ranking at a table that asks for none', () => {
@@ -161,39 +145,7 @@ describe('rooms', () => {
expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/); expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/);
}); });
it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', () => { it('refuses private talk to a drake, which reads none', () => {
const heard: string[][] = [];
const talking: GameSpec<State, Input> = {
...game,
botInput: (state, seat, press) => {
heard.push((press ?? []).map((l) => l.text));
return game.botInput(state, seat);
},
botPress: (state, seat, press, people, trigger) =>
trigger === 'phase' ? [{ to: people, text: `Round ${game.turn(state)}.`, note: 'secret' }] : [{ to: [press[press.length - 1].id], text: 'I hear you.' }],
parting: (_press, viewer) => [{ id: 'B', text: `Farewell, ${viewer}.` }]
};
const rooms = new Rooms(talking, new Store(dir));
const host = rooms.create('Ann', 3);
rooms.addBot(host.room, host.token);
const cy = rooms.join(host.room, 'Cy');
const room = host.room;
rooms.begin(room, host.token);
rooms.say(room, host.token, 'Psst.', ['B']);
rooms.say(room, cy.token, 'Hello, all.');
const annSees = rooms.view(room, 'A').chat;
expect(annSees.map((l) => l.text)).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.']);
expect(annSees.some((l) => 'note' in l)).toBe(false);
expect(rooms.view(room, 'C').chat.map((l) => l.text)).toEqual(['Round 1.', 'Hello, all.', 'I hear you.']);
expect(rooms.view(room, '').chat.map((l) => l.text)).toEqual(['Hello, all.']);
rooms.submit(room, host.token, { pick: 1 }, 0);
rooms.submit(room, cy.token, { pick: 2 }, 0);
expect(heard[0]).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.', 'I hear you.']);
expect(room.chat.filter((l) => l.note === 'secret').map((l) => l.text)).toEqual(['Round 1.', 'Round 2.']);
expect(rooms.view(room, 'A').parting).toEqual([]);
});
it('refuses private talk to a bot when the game gives bots no words', () => {
const rooms = new Rooms(game, new Store(dir)); const rooms = new Rooms(game, new Store(dir));
const host = rooms.create('Ann', 2); const host = rooms.create('Ann', 2);
rooms.addBot(host.room, host.token); rooms.addBot(host.room, host.token);
@@ -243,28 +195,20 @@ describe('rooms', () => {
}); });
describe('the host and the other seats', () => { describe('the host and the other seats', () => {
it("gives a person's seat to a bot, which plays it at once, and the person's token opens nothing", () => { it("gives a person's seat to a drake, which plays it at once, and the person's token opens nothing", () => {
const { rooms, room, ann, bo } = table(); const { rooms, room, ann, bo } = table();
rooms.submit(room, ann, { pick: 5 }, 0); rooms.submit(room, ann, GOLD_OPENS, 0);
rooms.standIn(room, ann, 'B'); rooms.standIn(room, ann, 'B');
// The round waited on Bo alone; the bot played it. // The move was Bo's; the drake made it.
expect(room.resolved).toBe(1);
expect(rooms.waitingOn(room)).toEqual(['A']);
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a bot/);
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
rooms.submit(room, ann, { pick: 3 }, 1);
expect(room.resolved).toBe(2); expect(room.resolved).toBe(2);
expect(rooms.waitingOn(room)).toEqual(['A']);
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 });
rooms.submit(room, ann, { from: square('2d2'), to: square('2d3') }, 2);
expect(room.resolved).toBe(4);
}); });
it('keeps a move the person made before the bot stood in', () => { it("is the host's call alone, in a game under way, for a person other than the host", () => {
const { rooms, room, ann, bo } = table();
rooms.submit(room, bo, { pick: 2 }, 0);
rooms.standIn(room, ann, 'B');
rooms.submit(room, ann, { pick: 5 }, 0);
expect(room.state!.rounds[0].picks).toEqual({ A: 5, B: 2 });
});
it('is the host\'s call alone, in a game under way, for a person other than the host', () => {
const { rooms, room, ann, bo } = table(); const { rooms, room, ann, bo } = table();
expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the player who opened/); expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the player who opened/);
expect(() => rooms.standIn(room, ann, 'A')).toThrow(/keeps their own seat/); expect(() => rooms.standIn(room, ann, 'A')).toThrow(/keeps their own seat/);
@@ -274,15 +218,15 @@ describe('the host and the other seats', () => {
expect(() => rooms.standIn(lobby.room, lobby.token, 'B')).toThrow(/under way/); expect(() => rooms.standIn(lobby.room, lobby.token, 'B')).toThrow(/under way/);
}); });
it('gives the seat back by the host\'s phrase, with a new token, and a restart keeps both changes', () => { it("gives the seat back by the host's phrase, with a new token, and a restart keeps both changes", () => {
const { rooms, room, ann, bo } = table(); const { rooms, room, ann, bo } = table();
rooms.submit(room, ann, GOLD_OPENS, 0);
rooms.standIn(room, ann, 'B'); rooms.standIn(room, ann, 'B');
expect(new Rooms(game, new Store(dir)).get(room.id).seats[1]).toMatchObject({ bot: true, standIn: true }); expect(new Rooms(game, new Store(dir)).get(room.id).seats[1]).toMatchObject({ bot: true, standIn: true });
const claimed = rooms.claim(rooms.hostPhrase(room, ann, 'B').phrase); const claimed = rooms.claim(rooms.hostPhrase(room, ann, 'B').phrase);
expect(claimed.seat).toBe('B'); expect(claimed.seat).toBe('B');
expect(rooms.seatOf(room, claimed.token).name).toBe('Bo'); expect(rooms.seatOf(room, claimed.token).name).toBe('Bo');
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/); expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
expect(rooms.waitingOn(room)).toContain('B');
const reopened = new Rooms(game, new Store(dir)).get(room.id); const reopened = new Rooms(game, new Store(dir)).get(room.id);
expect(reopened.seats[1]).toEqual({ id: 'B', name: 'Bo', tokenHash: room.seats[1].tokenHash, bot: false }); expect(reopened.seats[1]).toEqual({ id: 'B', name: 'Bo', tokenHash: room.seats[1].tokenHash, bot: false });
}); });
@@ -305,14 +249,10 @@ describe('the host and the other seats', () => {
expect(() => rooms.hostPhrase(withBot.room, withBot.token, bot.id)).toThrow(/No other person/); expect(() => rooms.hostPhrase(withBot.room, withBot.token, bot.id)).toThrow(/No other person/);
}); });
it('voids a phrase for the seat once a bot stands in, and holds the person\'s seat at a rematch without the stand-in', () => { it('voids a phrase for the seat once a drake stands in', () => {
const { rooms, room, ann } = table(); const { rooms, room, ann } = table();
const { phrase } = rooms.hostPhrase(room, ann, 'B'); const { phrase } = rooms.hostPhrase(room, ann, 'B');
rooms.standIn(room, ann, 'B'); rooms.standIn(room, ann, 'B');
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/); expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
while (!game.over(room.state!)) rooms.submit(room, ann, { pick: 5 }, room.resolved);
const next = rooms.get(rooms.rematch(room, ann).roomId);
expect(next.expected).toEqual(['Bo']);
expect(next.seats.map((s) => s.name)).toEqual(['Ann']);
}); });
}); });
+5 -5
View File
@@ -194,7 +194,7 @@ export class Rooms<State, Input> {
if (!token) throw new RoomError('That token opens no seat at this game.', 403); if (!token) throw new RoomError('That token opens no seat at this game.', 403);
const given = Buffer.from(hashToken(token), 'hex'); const given = Buffer.from(hashToken(token), 'hex');
const seat = room.seats.find((s) => s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given)); const seat = room.seats.find((s) => s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
if (seat?.standIn) throw new RoomError('The host gave your seat to a bot, which plays on in your place. Ask them for a transfer phrase to take it back.', 403); if (seat?.standIn) throw new RoomError('The host gave your seat to a drake, which plays on in your place. Ask them for a transfer phrase to take it back.', 403);
if (!seat) throw new RoomError('That token opens no seat at this game.', 403); if (!seat) throw new RoomError('That token opens no seat at this game.', 403);
return seat; return seat;
} }
@@ -412,7 +412,7 @@ export class Rooms<State, Input> {
/** The host seats a bot in an empty chair. */ /** The host seats a bot in an empty chair. */
addBot(room: Room<State>, token: string | undefined): Seat { addBot(room: Room<State>, token: string | undefined): Seat {
const host = this.seatOf(room, token); const host = this.seatOf(room, token);
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may seat a bot.', 403); if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may seat a drake.', 403);
if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409); if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409);
const taken = new Set(room.seats.map((s) => s.name.toLowerCase())); const taken = new Set(room.seats.map((s) => s.name.toLowerCase()));
const pool = this.game.botNames.filter((n) => !taken.has(n.toLowerCase())); const pool = this.game.botNames.filter((n) => !taken.has(n.toLowerCase()));
@@ -456,7 +456,7 @@ export class Rooms<State, Input> {
*/ */
standIn(room: Room<State>, token: string | undefined, seatId: unknown): void { standIn(room: Room<State>, token: string | undefined, seatId: unknown): void {
const host = this.seatOf(room, token); const host = this.seatOf(room, token);
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may give a seat to a bot.', 403); if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may give a seat to a drake.', 403);
if (!room.state || this.game.over(room.state)) throw new RoomError('Only a game under way needs a stand-in.', 409); if (!room.state || this.game.over(room.state)) throw new RoomError('Only a game under way needs a stand-in.', 409);
if (seatId === host.id) throw new RoomError('The host keeps their own seat.', 409); if (seatId === host.id) throw new RoomError('The host keeps their own seat.', 409);
const seat = room.seats.find((s) => s.id === seatId && !s.bot); const seat = room.seats.find((s) => s.id === seatId && !s.bot);
@@ -485,10 +485,10 @@ export class Rooms<State, Input> {
/** The host sends a bot away before the game begins. People leave by not coming back. */ /** The host sends a bot away before the game begins. People leave by not coming back. */
unseat(room: Room<State>, token: string | undefined, seatId: unknown): void { unseat(room: Room<State>, token: string | undefined, seatId: unknown): void {
const host = this.seatOf(room, token); const host = this.seatOf(room, token);
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may send a bot away.', 403); if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may send a drake away.', 403);
if (room.state) throw new RoomError('The game has begun; the table is set.', 409); if (room.state) throw new RoomError('The game has begun; the table is set.', 409);
const seat = room.seats.find((s) => s.id === seatId); const seat = room.seats.find((s) => s.id === seatId);
if (!seat || !seat.bot) throw new RoomError('Only a bot can be sent away.', 400); if (!seat || !seat.bot) throw new RoomError('Only a drake can be sent away.', 400);
this.commit(room, { t: 'unseat', id: seat.id }); this.commit(room, { t: 'unseat', id: seat.id });
} }
+2 -2
View File
@@ -18,7 +18,7 @@ afterEach(() => {
describe('the tally', () => { describe('the tally', () => {
it("adds a game's own lines from every game begun, finished or not", () => { it("adds a game's own lines from every game begun, finished or not", () => {
const counted: GameSpec<State, Input> = { ...game, tallyBegun: () => ({ 'games of High Card begun': 1 }) }; const counted: GameSpec<State, Input> = { ...game, tallyBegun: () => ({ 'games of Wyrm begun': 1 }) };
const rooms = new Rooms(counted, new Store(dir)); const rooms = new Rooms(counted, new Store(dir));
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
const host = rooms.create('Ann', 2); const host = rooms.create('Ann', 2);
@@ -28,6 +28,6 @@ describe('the tally', () => {
rooms.create('Bo', 2); rooms.create('Bo', 2);
const t = new Tallies(counted, new Store(dir)).get(); const t = new Tallies(counted, new Store(dir)).get();
expect(t).toMatchObject({ tablesOpened: 3, gamesBegun: 2, gamesFinished: 0 }); expect(t).toMatchObject({ tablesOpened: 3, gamesBegun: 2, gamesFinished: 0 });
expect(t.byGame).toEqual({ 'games of High Card begun': 2 }); expect(t.byGame).toEqual({ 'games of Wyrm begun': 2 });
}); });
}); });
+3 -3
View File
@@ -7,21 +7,21 @@
<meta name="theme-color" content="#121a20" /> <meta name="theme-color" content="#121a20" />
<title>Wyrm</title> <title>Wyrm</title>
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="description" content="Wyrm, a game between friends, free in the browser." /> <meta name="description" content="Gary Gygax&#39;s Dragonchess, free in the browser: chess on three boards, with Dragons in the air and Basilisks below. Play a drake or a friend." />
<link rel="canonical" href="https://wyrm.kestrelsnest.social/" /> <link rel="canonical" href="https://wyrm.kestrelsnest.social/" />
<link rel="icon" href="/favicon.ico" sizes="32x32" /> <link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Wyrm" /> <meta property="og:site_name" content="Wyrm" />
<meta property="og:title" content="Wyrm" /> <meta property="og:title" content="Wyrm" />
<meta property="og:description" content="Wyrm, a game between friends, free in the browser." /> <meta property="og:description" content="Gary Gygax&#39;s Dragonchess, free in the browser: chess on three boards, with Dragons in the air and Basilisks below. Play a drake or a friend." />
<meta property="og:url" content="https://wyrm.kestrelsnest.social/" /> <meta property="og:url" content="https://wyrm.kestrelsnest.social/" />
<meta property="og:image" content="https://wyrm.kestrelsnest.social/og.png" /> <meta property="og:image" content="https://wyrm.kestrelsnest.social/og.png" />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Wyrm" /> <meta name="twitter:title" content="Wyrm" />
<meta name="twitter:description" content="Wyrm, a game between friends, free in the browser." /> <meta name="twitter:description" content="Gary Gygax&#39;s Dragonchess, free in the browser: chess on three boards, with Dragons in the air and Basilisks below. Play a drake or a friend." />
<meta name="twitter:image" content="https://wyrm.kestrelsnest.social/og.png" /> <meta name="twitter:image" content="https://wyrm.kestrelsnest.social/og.png" />
%sveltekit.head% %sveltekit.head%
</head> </head>
+338 -127
View File
@@ -1,95 +1,250 @@
<script lang="ts"> <script lang="ts">
// The demo game's board. A real game replaces this file entirely; the // The table: the three boards one above the other, the pieces as discs
// masthead, lobby, talk, gallery and reports around it stay. // that slide and fade, and two taps or a keyboard to move. Above them a
// strip says whose move it is; beside them the record, each move a click
// from being shown on the boards, and the talk.
import TableTalk from './TableTalk.svelte'; import TableTalk from './TableTalk.svelte';
import { HIGHEST, TARGET } from '$lib/game'; import BoardArtwork, { type Token } from './BoardArtwork.svelte';
import {
describe, legalMovesFrom, squareName, sideOf, kindOf, kingSquare, inCheck, levelOf, fileOf, rankOf, idx, frozen, notation, NAMES, FILES, RANKS, LEVELS,
type Move, type Side
} from '$lib/game';
import type { Room } from '$lib/net/room.svelte'; import type { Room } from '$lib/net/room.svelte';
import { prefs, reducedMotion } from '$lib/prefs.svelte';
let { room }: { room: Room } = $props(); let { room }: { room: Room } = $props();
const g = $derived(room.state!); const g = $derived(room.state!);
let pick = $state(0); const mySide = $derived<Side | null>(room.spectating ? null : (g.players[room.me]?.side ?? null));
const yourMove = $derived(!!mySide && mySide === g.toMove && !g.over);
const nameOfSide = (side: Side) => {
const seat = g.seats.find((id) => g.players[id].side === side);
return seat ? g.players[seat].name : side;
};
const sideName = (side: Side) => (side === 'gold' ? 'Gold' : 'Scarlet');
const flipped = $derived(mySide === 'scarlet');
const inCheckNow = $derived(!g.over && inCheck(g.cells, g.toMove));
const checkSquare = $derived(inCheckNow ? kingSquare(g.cells, g.toMove) : -1);
const label = squareName;
async function play() { // --- Tokens: pieces with a life of their own, so a move slides, a capture fades and a Warrior stands up a Hero ---
if (!pick) return; let tokens = $state<Token[]>([]);
if (await room.submit({ pick })) pick = 0; /** How many of the game's moves the tokens have played through. */
let shown = $state(0);
let animating = $state(false);
const calm = $derived(reducedMotion());
const showCoords = $derived(prefs.coordinates !== false);
const wait = (ms: number) => new Promise((r) => setTimeout(r, calm ? 0 : ms));
function resetTokens() {
let id = 0;
tokens = g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: id++, piece, at, dying: false }]));
shown = g.moves.length;
} }
async function catchUp() {
if (animating) return;
animating = true;
try {
while (shown < g.moves.length) {
const m = g.moves[shown];
const mover = tokens.find((t) => t.at === m.from && !t.dying);
if (!mover) {
resetTokens();
break;
}
const victim = m.captured ? tokens.find((t) => t.at === m.to && !t.dying && t !== mover) : undefined;
if (m.afar) {
if (victim) victim.dying = true;
await wait(360);
} else {
if (victim) victim.dying = true;
mover.at = m.to;
await wait(380);
if (m.promoted) mover.piece = (mover.piece[0] + 'H') as typeof mover.piece;
}
if (victim) tokens = tokens.filter((t) => !t.dying);
shown += 1;
announce(storyOf(shown));
if (shown < g.moves.length) await wait(260);
}
} finally {
animating = false;
}
}
$effect(() => {
const total = g.moves.length;
if (total < shown || (tokens.length === 0 && g.cells.some((p) => p !== ''))) resetTokens();
else if (total > shown) void catchUp();
});
// --- What the boards show: the last move played, or a move picked from the record ---
let reviewing = $state<number | null>(null);
$effect(() => {
void g.moves.length;
reviewing = null;
selected = null;
});
const shownPly = $derived(reviewing ?? shown);
const shownLast = $derived<Move | null>(shownPly > 0 ? g.moves[shownPly - 1] : null);
const shownSquares = $derived(shownLast ? (shownLast.afar ? [shownLast.from] : [shownLast.from, shownLast.to]) : []);
const shownCaptured = $derived(shownLast?.captured ? [shownLast.to] : []);
function storyOf(ply: number): string {
const m = g.moves[ply - 1];
return m ? describe(g, m) : '';
}
const shownStory = $derived(shownPly > 0 ? storyOf(shownPly) : '');
// --- Choosing and moving ---------------------------------------------------
let selected = $state<number | null>(null);
const targets = $derived(selected === null ? [] : legalMovesFrom(g.cells, selected, g.setup));
let liveText = $state('');
function announce(text: string) {
liveText = text;
}
/** A move awaiting the player's word, when they asked to confirm. */
let pending = $state<{ from: number; to: number } | null>(null);
async function send(from: number, to: number) {
pending = null;
selected = null;
await room.submit({ from, to });
}
const wordFor = (i: number) => `${sideOf(g.cells[i]) === 'gold' ? 'Gold' : 'Scarlet'} ${NAMES[kindOf(g.cells[i])]}`;
async function tap(i: number) {
if (!yourMove || room.sending || animating) return;
reviewing = null;
if (selected !== null && targets.includes(i)) {
if (prefs.confirmMoves === true) {
pending = { from: selected, to: i };
announce(`Move ${label(selected)} to ${label(i)}? Confirm or cancel below.`);
return;
}
await send(selected, i);
return;
}
pending = null;
if (sideOf(g.cells[i]) === mySide) {
selected = selected === i ? null : i;
if (selected !== null) {
const to = legalMovesFrom(g.cells, i, g.setup);
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}`);
} else announce('Selection cleared.');
} else selected = null;
}
function focusSquare(i: number) {
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;
reviewing = null;
pending = null;
announce('Selection cleared.');
}
}
const verdict = $derived.by(() => {
if (!g.over) return '';
if (g.over.winner === null) return 'A draw.';
return g.over.winner === room.me ? 'You win.' : `${g.players[g.over.winner].name} wins.`;
});
const pieceCount = (side: Side) => g.cells.filter((p) => p !== '' && sideOf(p) === side).length;
</script> </script>
<main class="board"> <main class="board">
<section class="play"> <section class="play">
<table class="rounds"> <div class="status" class:yours={yourMove}>
<thead> {#if g.over}
<tr> <strong>{verdict}</strong> <span class="muted">{g.over.reason}</span>
<th class="turn"><span class="visually-hidden">Round</span></th> {:else if room.spectating}
{#each g.seats as id (id)} <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>
<th class:you={id === room.me}>{g.players[id].name}{#if id === room.me}&nbsp;(you){/if}</th> {:else if yourMove}
{/each} <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>
</tr> {:else if room.view.playingOn}
</thead> <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}
<tbody> {:else}
{#each g.rounds as round, i (i)} <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}
<tr> {/if}
<td class="turn">{i + 1}</td> </div>
{#each g.seats as id (id)} {#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted)}
<td class:scored={round.scorer === id}>{round.picks[id]}</td> <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>
{/each} {/if}
</tr> {#if pending}
{/each} <p class="confirm">Move {label(pending.from)} to {label(pending.to)}?
{#if !g.over} <button type="button" class="commit small" onclick={() => pending && send(pending.from, pending.to)}>Confirm</button>
<tr class="draft"> <button type="button" class="quiet" onclick={() => (pending = null)}>Cancel</button></p>
<td class="turn">{g.rounds.length + 1}</td> {/if}
{#each g.seats as id (id)} {#if room.error}<p class="warning">{room.error}</p>{/if}
<td class="muted">{id === room.me && pick ? pick : '?'}</td>
{/each} <div class="frame">
</tr> <BoardArtwork {g} {tokens} {showCoords} {flipped} {selected} {targets} {shownSquares} {shownCaptured} {checkSquare} {yourMove} {calm} {tap} {key} />
{/if} </div>
</tbody> <p class="visually-hidden" aria-live="polite">{liveText}</p>
</table>
{#if g.over} {#if g.over}
<div class="verdict"> <div class="verdict">
<h2>{g.over.winner === room.me ? 'You win.' : g.over.winner ? `${g.players[g.over.winner].name} wins.` : 'A draw.'}</h2>
<p>{g.over.reason}</p>
{#each room.view.parting as last, i (i)}<p><em>{room.nameOf(last.id)}</em> {last.text}</p>{/each}
<a class="commit" href="/">Back to the hall</a> <a class="commit" href="/">Back to the hall</a>
</div> </div>
{:else if room.view.playingOn} {:else if yourMove}
<section class="move"> <p class="muted small how">Tap a piece, then a square on any board. With a keyboard: arrows to move about a board, Page Up and Page Down to change boards, Enter to pick up and to set down, Escape to cancel.</p>
<h2>The bots play on</h2>
<p class="muted">Nobody else owes a move, so the bots play a round every few seconds for you to watch.</p>
{#if !room.spectating}<button type="button" class="quiet" onclick={() => room.hurry()}>Play the rest at once</button>{/if}
</section>
{:else if room.spectating}
<section class="move">
<h2>You watch from the Peanut Gallery</h2>
<p class="muted">{room.awaitingText ? `${room.awaitingText} ${room.awaiting.length === 1 ? 'turns a card over and over, still choosing.' : 'turn their cards over and over, still choosing.'}` : 'Cards are being chosen, face down.'}</p>
</section>
{:else}
<section class="move">
<h2>Name a number for round {g.rounds.length + 1}</h2>
<p class="muted">Every player lays a card face down, and the highest card nobody else laid takes the point. The first to {TARGET} points wins.</p>
<div class="picks">
{#each Array.from({ length: HIGHEST }, (_, i) => i + 1) as n (n)}
<button type="button" class="pick" class:chosen={pick === n} disabled={room.moved} onclick={() => (pick = n)}>{n}</button>
{/each}
</div>
<button type="button" class="commit" disabled={!pick || room.moved || room.sending} onclick={play}>
{room.moved ? `Waiting on ${room.awaitingText}` : room.sending ? 'Sending' : 'Commit'}
</button>
{#if room.error}<p class="warning">{room.error}</p>{/if}
</section>
{/if} {/if}
</section> </section>
<aside class="rail"> <aside class="rail">
<section class="standings"> <section class="sides">
<h2>Standings</h2> <h2>At the table</h2>
<ul> <ul>
{#each g.seats as id (id)} {#each g.seats as id (id)}
<li><span>{g.players[id].name}</span><span>{g.players[id].points} / {TARGET}</span></li> <li class:to-move={g.players[id].side === g.toMove && !g.over}>
<span class="dot {g.players[id].side}"></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>
</li>
{/each} {/each}
</ul> </ul>
<p class="muted small">Move {g.moves.length + 1}.{#if g.moves.length}&nbsp;Click a move to see it on the boards.{/if}</p>
{#if g.moves.length}
<ol class="record muted small" reversed start={g.moves.length}>
{#each g.moves.slice(-10).reverse() as m, i (g.moves.length - i)}
{@const ply = g.moves.length - i}
<li class:mine={mySide === m.by} class:reviewed={reviewing === ply}>
<button type="button" class="link plain" onclick={() => (reviewing = reviewing === ply ? null : ply)}>
<span class="who">{nameOfSide(m.by)}</span> <span class="notation">{notation(m)}</span>{#if m.captured}, <strong>takes the {NAMES[m.captured]}</strong>{/if}
</button>
</li>
{/each}
</ol>
{/if}
</section> </section>
<TableTalk {room} /> <TableTalk {room} />
</aside> </aside>
@@ -98,7 +253,7 @@
<style> <style>
.board { .board {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
gap: 0 clamp(1.5rem, 4vw, 3.5rem); gap: 0 clamp(1.5rem, 4vw, 3.5rem);
padding: 0 var(--gutter); padding: 0 var(--gutter);
max-width: 1280px; max-width: 1280px;
@@ -111,99 +266,155 @@
} }
} }
.rounds { .status {
width: 100%; max-width: 640px;
border-collapse: collapse; margin: 0 auto 0.5rem;
font-size: 1.3rem; padding: 0.45rem 0.8rem;
text-align: center; border: 1px solid var(--rule);
border-radius: 6px;
font-size: 0.95rem;
color: var(--bone-dim);
position: sticky;
top: 0.4rem;
z-index: 2;
background: var(--slate-deep);
} }
.rounds th { .status strong {
font-weight: 500; color: var(--bone);
font-style: italic;
font-size: 1rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--rule-strong);
} }
.rounds th.you { .status.yours {
color: var(--frost); border-color: var(--ember);
} }
.rounds td { .status.yours strong {
padding: 0.35rem 0; color: var(--ember);
border-bottom: 1px solid var(--rule);
} }
.rounds .turn { .goal {
width: 2.5rem; color: var(--bone);
font-size: 0.8rem;
color: var(--bone-faint);
} }
.rounds td.scored { .check {
color: var(--ember); color: var(--ember);
font-weight: 600; font-weight: 600;
} }
.rounds tr.draft td { .story {
background: var(--slate); max-width: 640px;
margin: 0 auto 0.5rem;
font-size: 0.9rem;
color: var(--bone-dim);
} }
.move, .story.capture {
.verdict {
margin-top: 1.2rem;
}
.move h2,
.verdict h2 {
font-size: 1.3rem;
margin-bottom: 0.4rem;
}
.picks {
display: flex;
gap: 0.5rem;
margin: 0.8rem 0;
}
.pick {
width: 3rem;
height: 3rem;
font-size: 1.3rem;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
color: var(--bone); color: var(--bone);
} }
.pick.chosen { .warning {
border-color: var(--ember); max-width: 640px;
color: var(--ember); margin: 0 auto 0.5rem;
} }
.verdict .commit { .confirm {
display: inline-block; max-width: 640px;
margin-top: 0.6rem; margin: 0 auto 0.5rem;
text-decoration: none; display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
} }
.standings h2 { .frame {
max-width: 640px;
margin: 0 auto;
}
.how {
max-width: 640px;
margin: 0.5rem auto 0;
}
.verdict {
max-width: 640px;
margin: 0.8rem auto 0;
}
.sides h2 {
font-size: 1.1rem; font-size: 1.1rem;
font-style: italic; font-style: italic;
margin: 0.75rem 0 0.4rem; margin-bottom: 0.4rem;
} }
.standings ul { .sides ul {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
.standings li { .sides li {
display: flex; display: flex;
justify-content: space-between; align-items: center;
padding: 0.3rem 0; gap: 0.5rem;
padding: 0.25rem 0;
border-bottom: 1px solid var(--rule); border-bottom: 1px solid var(--rule);
flex-wrap: wrap;
}
.sides li.to-move {
color: var(--ember);
}
.dot {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
display: inline-block;
}
.dot.gold {
background: #d8a63e;
border: 1px solid #8a6218;
}
.dot.scarlet {
background: #a4302a;
border: 1px solid #5e1612;
}
.sides p {
margin-top: 0.5rem;
}
.record {
margin: 0.3rem 0 0;
padding-left: 2rem;
}
.record li.mine {
color: var(--bone);
}
.record .plain {
color: inherit;
text-decoration: none;
}
.record .plain:hover {
text-decoration: underline;
}
.record li.reviewed .link {
color: var(--frost);
}
.record .notation {
font-variant-numeric: tabular-nums;
}
.record strong {
color: var(--ember);
font-weight: 500;
} }
</style> </style>
+346
View File
@@ -0,0 +1,346 @@
<script module lang="ts">
import type { Cell } from '$lib/game';
/** A piece with a life of its own, so a move slides and a capture fades. */
export interface Token {
id: number;
piece: Cell;
at: number;
dying: boolean;
}
</script>
<script lang="ts">
// 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
// the pieces as discs bearing his letters. Shared by the table, the hall's
// still of the opening and the share artwork.
import { FILES, RANKS, PER_LEVEL, fileOf, rankOf, levelOf, isLight, kindOf, sideOf, frozen, squareName, LETTERS, NAMES, type State } from '$lib/game';
let {
g, tokens, levels = [3, 2, 1], showCoords = false, flipped = false, selected = null, targets = [], shownSquares = [], shownCaptured = [], checkSquare = -1,
yourMove = false, calm = true, decorative = false, tap = () => {}, key = () => {}
}: {
g: State;
tokens?: Token[];
/** Which boards to draw, top first. */
levels?: number[];
showCoords?: boolean;
/** Scarlet's view: rank 8 nearest, file l on the left. */
flipped?: boolean;
selected?: number | null;
targets?: number[];
shownSquares?: number[];
shownCaptured?: number[];
checkSquare?: number;
yourMove?: boolean;
calm?: boolean;
decorative?: boolean;
tap?: (i: number) => void | Promise<void>;
key?: (e: KeyboardEvent, i: number) => void;
} = $props();
const artworkTokens = $derived(tokens ?? g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: at, piece, at, dying: false }])));
const CELL = 40;
const PAD_L = $derived(showCoords ? 18 : 6);
const PAD_R = 6;
const PAD_T = 20;
const PAD_B = $derived(showCoords ? 16 : 6);
const BOARD_W = FILES * CELL;
const BOARD_H = RANKS * CELL;
const GAP = 14;
const width = $derived(PAD_L + BOARD_W + PAD_R);
const boardHeight = $derived(PAD_T + BOARD_H + PAD_B);
const height = $derived(levels.length * boardHeight + (levels.length - 1) * GAP);
const WORLD: Record<number, string> = { 3: 'The air', 2: 'The land', 1: 'The underworld' };
/** Where a cell sits on the drawing: its board's slot, then file and rank from the viewer's side. */
function place(i: number): { x: number; y: number } | null {
const slot = levels.indexOf(levelOf(i));
if (slot < 0) return null;
const f = flipped ? FILES - 1 - fileOf(i) : fileOf(i);
const r = flipped ? rankOf(i) : RANKS - 1 - rankOf(i);
return { x: PAD_L + f * CELL, y: slot * (boardHeight + GAP) + PAD_T + r * CELL };
}
const drawn = $derived(levels.flatMap((lv) => Array.from({ length: PER_LEVEL }, (_, n) => (lv - 1) * PER_LEVEL + n)));
const fileLetter = (n: number) => 'abcdefghijkl'[flipped ? FILES - 1 - n : n];
const rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n);
const describe = (i: number) => {
const piece = g.cells[i];
let text = squareName(i);
if (piece !== '') text += `, ${sideOf(piece) === 'gold' ? 'Gold' : 'Scarlet'} ${NAMES[kindOf(piece)]}${frozen(g.cells, i) ? ', frozen' : ''}`;
if (targets.includes(i)) text += ', a legal move';
if (i === checkSquare) text += ', in check';
return text;
};
</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'}>
{#each levels as lv, slot (lv)}
{@const top = slot * (boardHeight + GAP)}
<g class="world" data-level={lv}>
<rect class="ground" x="1" y={top + 1} width={width - 2} height={boardHeight - 2} rx="5" />
<text class="title" x={PAD_L} y={top + 14}>{WORLD[lv]}</text>
{#if showCoords}
{#each Array.from({ length: FILES }, (_, n) => n) as n (n)}
<text class="coord" x={PAD_L + n * CELL + CELL / 2} y={top + boardHeight - 4} text-anchor="middle">{fileLetter(n)}</text>
{/each}
{#each Array.from({ length: RANKS }, (_, n) => n) as n (n)}
<text class="coord" x={PAD_L - 4} y={top + PAD_T + n * CELL + CELL / 2 + 3} text-anchor="end">{rankNumber(n)}</text>
{/each}
{/if}
</g>
{/each}
<g>
{#each drawn as i (i)}
{@const p = place(i)!}
<!-- Interactive squares have gridcell roles; illustrations have no tab stops. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<rect
class="square"
class:light={isLight(i)}
class:last={shownSquares.includes(i)}
class:target={targets.includes(i)}
class:fallen={shownCaptured.includes(i)}
class:check={i === checkSquare}
data-i={i}
data-level={levelOf(i)}
x={p.x}
y={p.y}
width={CELL}
height={CELL}
role={decorative ? undefined : 'gridcell'}
aria-label={decorative ? undefined : describe(i)}
aria-selected={decorative ? undefined : selected === i}
tabindex={decorative ? undefined : yourMove ? 0 : -1}
onclick={decorative ? undefined : () => tap(i)}
onkeydown={decorative ? undefined : (e) => key(e, i)}
/>
{/each}
{#each artworkTokens as t (t.id)}
{@const p = place(t.at)}
{#if p}
<g
class="piece {sideOf(t.piece)}"
class:selected={selected === t.at}
class:dying={t.dying}
class:frozen={!t.dying && g.cells[t.at] === t.piece && frozen(g.cells, t.at)}
style="transform: translate({p.x + CELL / 2}px, {p.y + CELL / 2}px)"
pointer-events="none"
>
<circle class="shadow" cx="1" cy="2" r="15" />
<circle class="body" r="14.5" />
<circle class="rim" r="12.2" />
<text class="letter" class:wide={LETTERS[kindOf(t.piece)].length > 1} y="5.5" text-anchor="middle">{LETTERS[kindOf(t.piece)]}</text>
<circle class="ice" r="16.5" />
</g>
{/if}
{/each}
{#each targets as i (i)}
{@const p = place(i)}
{#if p}
{#if g.cells[i] === ''}
<circle class="hint" cx={p.x + CELL / 2} cy={p.y + CELL / 2} r={CELL * 0.13} pointer-events="none" />
{:else}
<rect class="hint-ring" x={p.x + 2} y={p.y + 2} width={CELL - 4} height={CELL - 4} rx="4" pointer-events="none" />
{/if}
{/if}
{/each}
</g>
</svg>
<style>
.artwork {
width: 100%;
height: auto;
display: block;
--air-light: #e4ecf2;
--air-dark: #9fb8cc;
--land-light: #e6d7a3;
--land-dark: #8fa66a;
--under-light: #c9937a;
--under-dark: #7a4a3a;
--ground: #2a2522;
--title: #c9bca5;
--coord: #d8cdb6;
--line: rgba(20, 14, 10, 0.35);
--gold: #d8a63e;
--gold-rim: #8a6218;
--gold-ink: #2b1d05;
--scarlet: #a4302a;
--scarlet-rim: #5e1612;
--scarlet-ink: #fbe9dc;
}
.ground {
fill: var(--ground);
stroke: rgba(255, 240, 210, 0.12);
stroke-width: 0.8;
}
.title {
fill: var(--title);
font-family: var(--font);
font-size: 10px;
font-style: italic;
letter-spacing: 0.04em;
pointer-events: none;
}
.coord {
fill: var(--coord);
font-family: var(--font);
font-size: 8.5px;
font-weight: 600;
pointer-events: none;
opacity: 0.8;
}
.square {
stroke: var(--line);
stroke-width: 0.5;
cursor: default;
transition: fill 0.3s ease;
}
.square[data-level='3'] {
fill: var(--air-dark);
}
.square[data-level='3'].light {
fill: var(--air-light);
}
.square[data-level='2'] {
fill: var(--land-dark);
}
.square[data-level='2'].light {
fill: var(--land-light);
}
.square[data-level='1'] {
fill: var(--under-dark);
}
.square[data-level='1'].light {
fill: var(--under-light);
}
.square.last {
fill: #86b5d6;
}
.square.last.light {
fill: #b7d6ea;
}
.square.target {
cursor: pointer;
}
.square.fallen,
.square.fallen.light {
fill: #d9736a;
}
.square.check,
.square.check.light {
fill: #e0484a;
}
.square:focus-visible {
outline: none;
stroke: var(--frost);
stroke-width: 2.5;
}
.hint {
fill: var(--frost);
opacity: 0.9;
}
.hint-ring {
fill: none;
stroke: var(--frost);
stroke-width: 2.6;
opacity: 0.95;
}
.piece {
transition:
transform 0.36s cubic-bezier(0.2, 0.7, 0.2, 1),
opacity 0.3s ease;
}
.piece.dying {
opacity: 0;
}
.piece .shadow {
fill: #0d0906;
opacity: 0.3;
}
.piece .body {
fill: var(--gold);
stroke: var(--gold-rim);
stroke-width: 1;
}
.piece .rim {
fill: none;
stroke: var(--gold-rim);
stroke-width: 0.6;
opacity: 0.55;
}
.piece .letter {
fill: var(--gold-ink);
font-family: var(--font);
font-size: 15px;
font-weight: 700;
}
.piece .letter.wide {
font-size: 12.5px;
}
.piece.scarlet .body {
fill: var(--scarlet);
stroke: var(--scarlet-rim);
}
.piece.scarlet .rim {
stroke: var(--scarlet-ink);
}
.piece.scarlet .letter {
fill: var(--scarlet-ink);
}
.piece .ice {
fill: none;
stroke: #bfe6ff;
stroke-width: 1.6;
stroke-dasharray: 3 2.5;
opacity: 0;
}
.piece.frozen .ice {
opacity: 0.95;
}
.piece.frozen .body {
opacity: 0.6;
}
.piece.selected .body {
stroke: var(--frost);
stroke-width: 3;
}
.calm .piece,
.calm .square {
transition: none;
}
</style>
+18 -13
View File
@@ -13,7 +13,8 @@
import { unreadTalk } from '$lib/net/talk'; import { unreadTalk } from '$lib/net/talk';
import type { Report, RoomView, Tally } from '$lib/net/view'; import type { Report, RoomView, Tally } from '$lib/net/view';
import type { State } from '$lib/game'; import type { State } from '$lib/game';
import { game } from '$lib/game'; import { createGame, game } from '$lib/game';
import BoardArtwork from './BoardArtwork.svelte';
import { tableSize } from '$lib/game/spec'; import { tableSize } from '$lib/game/spec';
let name = $state(playerName()); let name = $state(playerName());
@@ -23,6 +24,8 @@
Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default])) Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default]))
); );
let prefsOpen = $state(false); let prefsOpen = $state(false);
/** The opening as the host's choices lay it, drawn beside the buttons. */
const opening = $derived(createGame({ A: 'Gold', B: 'Scarlet' }, 0, undefined, options));
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
let seats = $state(allSeats()); let seats = $state(allSeats());
@@ -163,14 +166,14 @@
const g = games[held.roomId]; const g = games[held.roomId];
if (g === undefined) return 'looking'; if (g === undefined) return 'looking';
if (g === null) return 'unreachable'; if (g === null) return 'unreachable';
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.standIn ? `${s.name} (a bot stands in)` : s.bot ? `${s.name} (the bot)` : s.name)); const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.standIn ? `${s.name} (a drake stands in)` : s.bot ? `${s.name} (a drake)` : s.name));
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`; if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
if (g.over) { if (g.over) {
if (g.over.winner === held.seat) return 'you won'; if (g.over.winner === held.seat) return 'you won';
if (g.over.winner === null) return 'a draw'; if (g.over.winner === null) return 'a draw';
return `${game.nameOf(g.state, g.over.winner)} won`; return `${game.nameOf(g.state, g.over.winner)} won`;
} }
const round = `round ${g.turn}`; const round = `move ${g.turn}`;
if (g.waitingOn.includes(held.seat)) return `your move · ${round} · against ${others.join(', ')}`; if (g.waitingOn.includes(held.seat)) return `your move · ${round} · against ${others.join(', ')}`;
return `waiting on ${g.waitingOn.map((id) => game.nameOf(g.state!, id)).join(', ')} · ${round}`; return `waiting on ${g.waitingOn.map((id) => game.nameOf(g.state!, id)).join(', ')} · ${round}`;
} }
@@ -203,8 +206,8 @@
<section class="lid" class:returning={active.length > 0}> <section class="lid" class:returning={active.length > 0}>
<div class="lid-head"> <div class="lid-head">
<h1>Wyrm</h1> <h1>Wyrm</h1>
<p class="pitch">One sentence that says what the game is, in its own world.</p> <p class="pitch">Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.</p>
<p class="tag">Two or three sentences for someone who has never heard of it: what you do on a turn, what makes it fun, and that it is free to play here against the bot or with friends.</p> <p class="tag">Gary Gygax's Dragonchess, from Dragon Magazine in 1985. Gold and Scarlet each lead 42 pieces across the air, the land and the underworld, and the game ends the way chess does, with a King that cannot escape. Play a drake here, free, or open a table and send a friend the link.</p>
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about">about this game</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p> <p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about">about this game</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
<Preferences bind:open={prefsOpen} /> <Preferences bind:open={prefsOpen} />
</div> </div>
@@ -230,7 +233,7 @@
{/each} {/each}
</div> </div>
{/if} {/if}
<button type="button" class="commit primary" disabled={!ready || busy} onclick={() => open(() => Room.createWithBot(name.trim(), options))}>Play now against the bot</button> <button type="button" class="commit primary" disabled={!ready || busy} onclick={() => open(() => Room.createWithBot(name.trim(), options))}>Play now against a drake</button>
<div class="ways"> <div class="ways">
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), tableSize(game, options), options))}>Open a table</button> <button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), tableSize(game, options), options))}>Open a table</button>
<form class="joinform" onsubmit={join}> <form class="joinform" onsubmit={join}>
@@ -249,8 +252,8 @@
</div> </div>
<div class="lid-look" aria-hidden="true"> <div class="lid-look" aria-hidden="true">
<!-- A still of the game at its most characteristic: a few rounds of the ledger, a board mid-play. --> <BoardArtwork g={opening} levels={[3, 2]} decorative />
<p class="caption">A picture of the game goes here.</p> <p class="caption">The air and the land in the opening position. The underworld lies beneath.</p>
</div> </div>
{#snippet gameRow(held: (typeof seats)[number])} {#snippet gameRow(held: (typeof seats)[number])}
@@ -318,9 +321,9 @@
<!-- 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>Where the game comes from, who made the original and when, and how the keeper of this page first met it.</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. 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>What this version keeps and what it changes. Where the rules text it follows lives (the <a href="/rules">rules page</a>), and any borrowed art or text with its notice.</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 between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move. Each player is shown only what the rules let them see (drop this sentence if nothing in the game is hidden).</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>
{/snippet} {/snippet}
{#snippet aboutTally()} {#snippet aboutTally()}
@@ -343,7 +346,7 @@
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and nothing is held back without it. <a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and nothing is held back without it.
</p> </p>
<p class="made">Wyrm is its designer's.</p> <p class="made">Dragonchess is Gary Gygax's, published by TSR, Inc. in 1985. Wyrm is an unofficial rebuild of it for the browser.</p>
</div> </div>
{/snippet} {/snippet}
{#snippet aboutWord()} {#snippet aboutWord()}
@@ -489,8 +492,10 @@
.lid-look { .lid-look {
grid-area: look; grid-area: look;
display: grid; display: grid;
place-items: center; align-content: center;
gap: 0.4rem;
min-height: 8rem; min-height: 8rem;
padding: 0.6rem;
background: var(--slate-deep); background: var(--slate-deep);
border: 1px solid var(--rule-strong); border: 1px solid var(--rule-strong);
border-radius: 6px; border-radius: 6px;
+3 -3
View File
@@ -73,7 +73,7 @@
{#each v.seats as s (s.id)} {#each v.seats as s (s.id)}
<li> <li>
<span class="seat-name">{s.name}</span> <span class="seat-name">{s.name}</span>
<span class="muted">{s.id === v.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost} <span class="muted">{s.id === v.host ? 'opened the table' : s.bot ? 'a drake' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost}
<button type="button" class="dismiss" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room.unseat(s.id)}>×</button>{/if}</span> <button type="button" class="dismiss" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room.unseat(s.id)}>×</button>{/if}</span>
</li> </li>
{/each} {/each}
@@ -113,7 +113,7 @@
{#if !room.spectating} {#if !room.spectating}
<div class="ways"> <div class="ways">
{#if room.seatEmpty && room.isHost} {#if room.seatEmpty && room.isHost}
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button> <button type="button" class="quiet" onclick={() => room.addBot()}>Seat a drake</button>
{/if} {/if}
{#if room.isHost && beginAsked && v.expected.length > 0} {#if room.isHost && beginAsked && v.expected.length > 0}
<span>A seat is held for {v.expected.join(' and ')}, who {v.expected.length === 1 ? 'has' : 'have'} not arrived. The seat goes once the game begins.</span> <span>A seat is held for {v.expected.join(' and ')}, who {v.expected.length === 1 ? 'has' : 'have'} not arrived. The seat goes once the game begins.</span>
@@ -138,7 +138,7 @@
<p class="muted small note">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour}&nbsp;It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p> <p class="muted small note">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour}&nbsp;It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
</div> </div>
{:else if v.challenge} {:else if v.challenge}
<p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour}&nbsp;It is {keeperHour} there.{/if} A seat is held for them. If they can come now, they will take it. If they are asleep or away, they will leave a word below when they can. In the meantime you can seat a bot, invite a friend, or play another game in a new tab. This table keeps its place in your hall until you come back.</p> <p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour}&nbsp;It is {keeperHour} there.{/if} A seat is held for them. If they can come now, they will take it. If they are asleep or away, they will leave a word below when they can. In the meantime you can seat a drake, invite a friend, or play another game in a new tab. This table keeps its place in your hall until you come back.</p>
{/if} {/if}
{/if} {/if}
{#if room.error}<p class="warning">{room.error}</p>{/if} {#if room.error}<p class="warning">{room.error}</p>{/if}
+5 -5
View File
@@ -37,7 +37,7 @@
} }
function phraseTitle(name: string, standIn: boolean): string { function phraseTitle(name: string, standIn: boolean): string {
const why = standIn ? `Brings ${name} back from the bot.` : `For when ${name}'s browser lost the seat, or they need to play from another device.`; const why = standIn ? `Brings ${name} back from the drake.` : `For when ${name}'s browser lost the seat, or they need to play from another device.`;
return `${why} You get four words to pass to them. Within ten minutes, they enter them in the hall, or in the claim box while watching this table, and sit back down as ${name}. The phrase works once, and the browser they used before stops opening the seat.`; return `${why} You get four words to pass to them. Within ten minutes, they enter them in the hall, or in the claim box while watching this table, and sit back down as ${name}. The phrase works once, and the browser they used before stops opening the seat.`;
} }
</script> </script>
@@ -48,22 +48,22 @@
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}> <div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
<div class="slip" role="dialog" aria-labelledby="seats-heading"> <div class="slip" role="dialog" aria-labelledby="seats-heading">
<h2 id="seats-heading">The other seats</h2> <h2 id="seats-heading">The other seats</h2>
<p class="muted small">As the host, you can bring a player back to a seat their browser lost, or give the seat of one who cannot go on to a bot, which plays it at once.</p> <p class="muted small">As the host, you can bring a player back to a seat their browser lost, or give the seat of one who cannot go on to a drake, which plays it at once.</p>
{#if people.length === 0} {#if people.length === 0}
<p class="muted">Nobody else at this table is a person.</p> <p class="muted">Nobody else at this table is a person.</p>
{/if} {/if}
<ul> <ul>
{#each people as s (s.id)} {#each people as s (s.id)}
<li> <li>
<span class="name">{s.name}{#if s.standIn}<span class="muted note">a bot plays for them</span>{/if}</span> <span class="name">{s.name}{#if s.standIn}<span class="muted note">a drake plays for them</span>{/if}</span>
<span class="ways"> <span class="ways">
<button type="button" class="quiet" title={phraseTitle(s.name, !!s.standIn)} onclick={() => makePhrase(s.id, s.name)}>{s.standIn ? 'Transfer phrase to take it back' : 'Make a transfer phrase'}</button> <button type="button" class="quiet" title={phraseTitle(s.name, !!s.standIn)} onclick={() => makePhrase(s.id, s.name)}>{s.standIn ? 'Transfer phrase to take it back' : 'Make a transfer phrase'}</button>
{#if !s.standIn && !room.view.over} {#if !s.standIn && !room.view.over}
{#if asked === s.id} {#if asked === s.id}
<button type="button" class="commit small" onclick={() => standIn(s.id)}>Yes, a bot 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 bot 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 bot</button>
{/if} {/if}
{/if} {/if}
</span> </span>
+1
View File
@@ -9,6 +9,7 @@ import {
type Cell, type Kind, type Side, type Setup, NAMES, LETTERS, cellOf, hasLegalMove, inCheck, kindOf, legalMovesFrom, type Cell, type Kind, type Side, type Setup, NAMES, LETTERS, cellOf, hasLegalMove, inCheck, kindOf, legalMovesFrom,
levelOf, movesFrom, opening, other, playOn, sideOf, squareName levelOf, movesFrom, opening, other, playOn, sideOf, squareName
} from './board'; } from './board';
export { FILES, RANKS, LEVELS, PER_LEVEL, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, kingSquare, inCheck, other, square } from './board';
import { chooseMove } from './bot'; import { chooseMove } from './bot';
export const CURRENT_RULES = 1; export const CURRENT_RULES = 1;
+3 -3
View File
@@ -109,12 +109,12 @@ export class Room {
} }
addBot(): Promise<boolean> { addBot(): Promise<boolean> {
return this.act(() => api.addBot(this.roomId, this.token), 'The bot could not be seated.'); return this.act(() => api.addBot(this.roomId, this.token), 'The drake could not be seated.');
} }
/** Send a bot away before the game begins; only the host may. */ /** Send a bot away before the game begins; only the host may. */
unseat(seat: SeatId): Promise<boolean> { unseat(seat: SeatId): Promise<boolean> {
return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.'); return this.act(() => api.unseat(this.roomId, this.token, seat), 'The drake could not be sent away.');
} }
/** Out of the game, with the bots playing on: play the rest at once. */ /** Out of the game, with the bots playing on: play the rest at once. */
@@ -168,7 +168,7 @@ export class Room {
/** Host only: a bot plays this person's seat for the rest of the game, until a phrase gives it back. */ /** Host only: a bot plays this person's seat for the rest of the game, until a phrase gives it back. */
standIn(seat: SeatId): Promise<boolean> { standIn(seat: SeatId): Promise<boolean> {
return this.act(() => api.standIn(this.roomId, this.token, seat), 'The seat could not be given to a bot.'); return this.act(() => api.standIn(this.roomId, this.token, seat), 'The seat could not be given to a drake.');
} }
async submit(input: Input): Promise<boolean> { async submit(input: Input): Promise<boolean> {
+68
View File
@@ -0,0 +1,68 @@
// The rules page reads docs/source.md, the transcription of Gygax's article
// the engine follows, and shows it section by section, so the page and the
// engine have one source. This is the small amount of Markdown that file
// uses: headings, paragraphs, bold and italic, indented diagrams, one
// table and one numbered list.
import raw from '../../docs/source.md?raw';
export interface SourceSection {
id: string;
title: string;
html: string;
}
const escape = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** Bold, italic and the escaped backslash, inside a line. */
function inline(text: string): string {
return escape(text)
.replace(/\\\*/g, '*')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[\s(])\*([^*]+?)\*/g, '$1<em>$2</em>');
}
function block(lines: string[]): string {
if (lines.length === 0) return '';
if (lines.every((l) => l.startsWith(' '))) return `<pre>${escape(lines.map((l) => l.slice(4)).join('\n'))}</pre>`;
if (lines[0].startsWith('|')) {
const rows = lines.filter((l) => !/^\|[-| ]+\|$/.test(l)).map((l) => l.split('|').slice(1, -1).map((c) => inline(c.trim())));
const [head, ...body] = rows;
return `<table><thead><tr>${head.map((c) => `<th>${c}</th>`).join('')}</tr></thead><tbody>${body.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
}
if (/^\d+\. /.test(lines[0])) {
const items: string[] = [];
for (const l of lines) {
if (/^\d+\. /.test(l)) items.push(l.replace(/^\d+\. /, ''));
else items[items.length - 1] += ' ' + l.trim();
}
return `<ol>${items.map((i) => `<li>${inline(i)}</li>`).join('')}</ol>`;
}
return `<p>${inline(lines.join(' '))}</p>`;
}
/** The article's sections, split at its headings; the text before the first heading is the transcriber's note. */
export function sections(): SourceSection[] {
const out: SourceSection[] = [];
let current: SourceSection | null = null;
let para: string[] = [];
const flush = () => {
if (current && para.length) current.html += block(para);
para = [];
};
for (const line of raw.split('\n')) {
const heading = /^(#{1,3}) (.*)$/.exec(line);
if (heading) {
flush();
const title = heading[2].trim();
current = { id: title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''), title, html: '' };
out.push(current);
} else if (line === '---') flush();
else if (line.trim() === '') flush();
else if (line.startsWith(' ') && para.length && !para[0].startsWith(' ')) {
flush();
para.push(line);
} else para.push(line);
}
flush();
return out;
}
+40 -15
View File
@@ -1,8 +1,6 @@
<script lang="ts"> <script lang="ts">
// How to play: a walk through the page in the game's own voice, one // How to play: a walk from the hall to a first game, in the game's own
// screenshot per section, from the hall to a first game. Capture the // voice, with a figure for each part of the page.
// figures from the running game into static/guide/ and describe what a
// new player is looking at, not what the code does.
import { page } from '$app/state'; import { page } from '$app/state';
/** Opened from a table, the page offers the way back to it and carries it to its cross-links. */ /** Opened from a table, the page offers the way back to it and carries it to its cross-links. */
@@ -18,11 +16,12 @@
<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('/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('/rules')}>The rules</a>
<h1>How to play</h1> <h1>How to play</h1>
<p class="lede">Two sentences that set the scene: who the player is, and what they are trying to do.</p> <p class="lede">You command Gold or Scarlet, 42 pieces spread across the air, the land and the underworld, and you are after the other King. If you know chess you know most of it already. The rest is learning which pieces can climb and which can dig.</p>
<nav aria-label="Sections"> <nav aria-label="Sections">
<a href="#hall">The hall</a> <a href="#hall">The hall</a>
<a href="#table">The table</a> <a href="#table">The table</a>
<a href="#board">The board</a> <a href="#boards">The boards</a>
<a href="#moving">Making a move</a>
<a href="#gallery">The Peanut Gallery and table talk</a> <a href="#gallery">The Peanut Gallery and table talk</a>
<a href="#first">A first game</a> <a href="#first">A first game</a>
</nav> </nav>
@@ -30,29 +29,43 @@
<section id="hall"> <section id="hall">
<h2>The hall</h2> <h2>The hall</h2>
<p>Write your name and choose your company. <strong>Play now against the bot</strong> seats you across from the bot and begins. <strong>Open a table</strong> lays a table and hands you a link and a four-letter code to send. <strong>Join one</strong> takes a code someone sent you. Beneath, <em>your games</em> lists every table this browser holds a seat at, and whose move it is.</p> <figure><img src="/guide/hall.png" alt="The hall: 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>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>
</section> </section>
<section id="table"> <section id="table">
<h2>The table</h2> <h2>The table</h2>
<p>A table fills as players open the link. Whoever laid it is the host. The host may seat a bot in any empty chair or send one away, and they begin the game when the company suits them, however full the table. Once begun, the seats are closed.</p> <figure><img src="/guide/table.png" alt="A table laid: the room code, the link to send, two seats, and the Begin button" loading="lazy" /></figure>
<p>A table seats two. Whoever laid it is the host. The host may seat a drake in the empty chair or send one away, and begins the game when the company is right. Once begun, the seats are closed, and anyone else who opens the link watches from the Peanut Gallery.</p>
</section> </section>
<section id="board"> <section id="boards">
<h2>The board</h2> <h2>The boards</h2>
<p>What the player sees during play, section by section, with a figure for each.</p> <figure><img src="/guide/boards.png" alt="The three boards one above the other in the opening position, the air in blue and white, the land in green and amber, the underworld in red and brown" loading="lazy" /></figure>
<p>The three boards stand one above the other, the air at the top, the land in the middle and the underworld at the bottom, in the colours Gygax suggested. Your side is nearest you on every board. Each piece is a disc bearing the initial Gygax gave it, with Dr for the Dragon so it is not mistaken for a Dwarf. The square names along the edges are his too, and you can hide them under <em>preferences</em>.</p>
<p>The strip above the boards says whose move it is and warns when a King is in check. The record beside the boards lists the moves played in Gygax's notation. Click one to see it on the boards, and click again, or make your move, to come back to now.</p>
</section>
<section id="moving">
<h2>Making a move</h2>
<figure><img src="/guide/move.png" alt="A Gold Dragon selected in the air, with dots on every square it can reach and a ring around the Scarlet Sylph it can take" loading="lazy" /></figure>
<p>Tap one of your pieces and every square it can reach lights up, on its own board and on any board it can climb or drop to. A dot marks an empty square and a ring marks a piece it can take. Tap the square and the move is made. Tap the piece again, or anywhere else, to put it down. With a keyboard, the arrows walk a board, Page Up and Page Down change boards, Enter picks up and sets down, and Escape cancels.</p>
<p>A move that would leave your own King in check is never offered. If you would rather be asked before a move goes, turn on <em>confirm a move before it is sent</em> in preferences.</p>
<p>A piece under a Basilisk's gaze is drawn pale with a frost ring around it. It is frozen and can do nothing until the Basilisk moves or is taken.</p>
</section> </section>
<section id="gallery"> <section id="gallery">
<h2>The Peanut Gallery and table talk</h2> <h2>The Peanut Gallery and table talk</h2>
<p>A room's link is an invitation to sit while the table is laid and to watch once the game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery, shown only what every player at the table could see. <em>Table talk</em> is for the players seated, unless the host lets the gallery talk. Then a watcher gives a name, and their lines are marked as the gallery's. A line goes to everyone at the table and everyone in the gallery, and is kept with the game.</p> <p>A room's link is an invitation to sit while the table is laid and to watch once the game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery and sees the whole game, since nothing in Wyrm is hidden. <em>Table talk</em> is for the two players, unless the host lets the gallery talk. Then a watcher gives a name, and their lines are marked as the gallery's. Every line is kept with the game.</p>
<p>A seat can follow you to another device. <em>Transfer seat</em> in the masthead gives four words, good for ten minutes, and the hall on the other device claims them. If your browser loses the seat, the host's <em>Seats</em> makes those words for you. If a player cannot go on, the host can give their seat to a bot there, and the same words bring them back to it. When a game ends, anyone at the table may call for a rematch, which opens a new table for the same company and holds their seats until they are back. While a table is laid, you may challenge the keeper of this site to a seat; the note beside the button gives the hour where they live.</p> <p>A seat can follow you to another device. <em>Transfer seat</em> in the masthead gives four words, good for ten minutes, and the hall on the other device claims them. If your browser loses the seat, the host's <em>Seats</em> makes those words for you. If a player cannot go on, the host can give their seat to a drake there, and the same words bring them back to it. When a game ends, either player may call for a rematch, which opens a new table for the same company and holds the seats until both are back. While a table is laid, you may challenge the keeper of this site to a seat, and the note beside the button gives the hour where they live.</p>
</section> </section>
<section id="first"> <section id="first">
<h2>A first game</h2> <h2>A first game</h2>
<p>The advice you would give a friend across the table for their first few turns.</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>The full rules are on the <a href="/rules">rules page</a>. If a rule here reads wrong to you, or something misbehaves, <a href="mailto:eric@ericwagoner.com">send word</a>. The hall's <em>about this game</em> lists the other ways to reach the keeper.</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>
</section> </section>
</main> </main>
@@ -68,4 +81,16 @@
p + p { p + p {
margin-top: 0.7rem; margin-top: 0.7rem;
} }
figure {
margin: 0 0 0.9rem;
}
figure img {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--rule-strong);
border-radius: 6px;
}
</style> </style>
+3 -3
View File
@@ -169,7 +169,7 @@
{:else if room?.started} {:else if room?.started}
<p class="standfirst">You are <em>{room.nameOf(room.me)}</em>, playing {room.others.map((s) => s.name).join(', ')}.{#if !room.connected} <span class="muted">Reconnecting.</span>{/if}</p> <p class="standfirst">You are <em>{room.nameOf(room.me)}</em>, playing {room.others.map((s) => s.name).join(', ')}.{#if !room.connected} <span class="muted">Reconnecting.</span>{/if}</p>
{:else} {:else}
<p class="standfirst">A game between people, played by turns whenever each of you has a moment.</p> <p class="standfirst">Gold and Scarlet move by turns, and the boards wait for whoever is away.</p>
{/if} {/if}
</div> </div>
<nav class="actions"> <nav class="actions">
@@ -184,7 +184,7 @@
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button> <button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
{/if} {/if}
{#if room.isHost && room.started} {#if room.isHost && room.started}
<button type="button" class="quiet" title="Bring a player back to a seat their browser lost, or give a seat to a bot" onclick={() => (seatsOpen = true)}>Seats</button> <button type="button" class="quiet" title="Bring a player back to a seat their browser lost, or give a seat to a drake" onclick={() => (seatsOpen = true)}>Seats</button>
{/if} {/if}
{/if} {/if}
<button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button> <button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button>
@@ -240,7 +240,7 @@
{/if} {/if}
{#if room?.started && !seatLost && room.view.seats.some((s) => s.standIn)} {#if room?.started && !seatLost && room.view.seats.some((s) => s.standIn)}
<p class="notice muted">A bot plays for {room.view.seats.filter((s) => s.standIn).map((s) => s.name).join(', ')}, at the host's word, until a transfer phrase from the host brings them back.</p> <p class="notice muted">A drake plays for {room.view.seats.filter((s) => s.standIn).map((s) => s.name).join(', ')}, at the host's word, until a transfer phrase from the host brings them back.</p>
{/if} {/if}
{#if watching && room?.started && !room.view.over} {#if watching && room?.started && !room.view.over}
+170 -10
View File
@@ -1,12 +1,16 @@
<script lang="ts"> <script lang="ts">
// The rules page: the game's own text, structured for reading mid-game. // The rules page: the game as this table plays it, piece by piece, then
// Keep the original rules text verbatim in docs/ and render from it // Gygax's article in full from docs/source.md, which the engine follows.
// where you can, so the page and the engine follow one source.
import { page } from '$app/state'; import { page } from '$app/state';
import { sections } from '$lib/source';
/** Opened from a table, the page offers the way back to it and carries it to its cross-links. */ /** Opened from a table, the page offers the way back to it and carries it to its cross-links. */
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);
const source = sections();
/** The article proper runs from its title to the end of "Putting together a game"; the notes around it are the transcriber's. */
const article = source.filter((s) => s.id !== 'the-source-text' && s.id !== 'decisions');
const decisions = source.find((s) => s.id === 'decisions');
</script> </script>
<svelte:head> <svelte:head>
@@ -17,31 +21,187 @@
<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> {#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>
<h1>The rules</h1> <h1>The rules</h1>
<p class="lede">One paragraph on where these rules come from and which edition or text this page follows.</p> <p class="lede">
Wyrm plays Dragonchess as Gary Gygax published it in Dragon Magazine No. 100, August 1985. The article is the only rules text there is. It is transcribed in full at the foot of this page, and the game follows it word for word, with the handful of readings the text leaves open settled under <a href="#decisions">What this table decides</a>.
</p>
<nav aria-label="Sections">
<a href="#boards">The boards</a>
<a href="#winning">Winning</a>
<a href="#air">The air</a>
<a href="#land">The land</a>
<a href="#underworld">The underworld</a>
<a href="#between">Between the boards</a>
<a href="#decisions">What this table decides</a>
<a href="#source">The original text</a>
</nav>
</header> </header>
<section id="turn"> <section id="boards">
<h2>A turn</h2> <h2>The boards</h2>
<p>What each player does on a turn, in order, in short paragraphs. Put the thing a player checks mid-game first.</p> <p>
Three boards lie one above another, each twelve squares wide and eight deep. The top board is the air, the middle board the land, and the bottom board the underworld. Gold and Scarlet each have 42 pieces, and Gold moves first. A square is named by its board, its file and its rank, so <strong>3g1</strong> is the air above Gold's King and <strong>1g8</strong> the underworld beneath Scarlet's. The files run a to l from Gold's left, the ranks 1 to 8 from Gold's side, and the square at Gold's near right corner, l1, is light.
</p>
<p>
A move takes one piece to one square. A piece lands on an empty square or on an enemy piece, which is taken, and never on a friend. Some pieces live on one board for the whole game, and some pass between boards in ways of their own. There is no castling, no two-square first step for a Warrior, and so no capture in passing.
</p>
</section> </section>
<section id="winning"> <section id="winning">
<h2>Winning</h2> <h2>Winning</h2>
<p>How the game ends and who wins, including draws.</p> <p>
A King attacked by an enemy piece is in check, and a move may not leave your own King in check. A King in check with no way out, by moving, by taking the attacker or by putting a piece in the way, is checkmated, and the game is lost. A player who is not in check but has no legal move at all is stalemated, and the game is drawn. The game is also drawn when the same position stands for the third time with the same side to move, and when fifty moves pass on each side without a capture.
</p>
<p>
A Basilisk in the underworld freezes the enemy piece on the square directly above it. A frozen piece cannot move, cannot take and threatens nothing, though it still blocks the square it stands on. A frozen King cannot step out of check, so it is mated by any attack that cannot be answered by a capture or a block.
</p>
</section>
<section id="air">
<h2>The pieces of the air</h2>
<p>
<strong>Sylph</strong>, six a side. A Sylph is a pawn turned inside out. It moves one square diagonally forward and captures the square straight ahead. It can also capture the enemy piece directly beneath it on the land, and that is the only way down. On the land it cannot move except to climb back, without capturing, to the square directly above it or to any empty one of its six starting squares. A Sylph that reaches the far rank can go no further on the air board, but it can still strike down, and once it has it may climb again into a starting square.
</p>
<p>
<strong>Griffon</strong>, two a side. In the air a Griffon leaps two squares one way and three the other, over anything in between, the way a knight leaps one and two. It can drop to the land on any of the four squares diagonally next to the square beneath it, taking a piece there or not. On the land it loses its flight and moves one square diagonally, and it rises again to any of the four squares diagonally next to the square above it.
</p>
<p>
<strong>Dragon</strong>, one a side. The Dragon never leaves the air. It slides any distance along a diagonal, or steps one square in any direction. It also strikes from afar. Without moving, it can take an enemy piece on the land that stands directly beneath it or on one of the four squares beside that one. The piece is removed and the Dragon stays where it is. That strike is the Dragon's whole move, and it counts as an attack for check.
</p>
</section>
<section id="land">
<h2>The pieces of the land</h2>
<p>
<strong>Warrior</strong>, twelve a side. A pawn that cannot take a double first step. It moves one square straight forward and captures one square diagonally forward. On reaching the far rank it becomes a Hero, with no choice in the matter. It never leaves the land.
</p>
<p>
<strong>Oliphant</strong>, two a side, moves like a rook. <strong>Unicorn</strong>, two a side, moves like a knight. <strong>Thief</strong>, two a side, moves like a bishop. None of the three leaves the land.
</p>
<p>
<strong>Hero</strong>, two a side. On the land a Hero moves one or two squares along a diagonal, leaping over whatever is on the square between. It can also go up to the air or down to the underworld, landing on any of the four squares diagonally next to the square above or below it, and may take a piece as it goes. Off the land, its only move is back, to any of the four squares diagonally next to the square above or below it.
</p>
<p>
<strong>Cleric</strong>, one a side. On any board the Cleric moves one square in any direction, like a king. It also steps straight up or down one board. It is the one piece that plays the same on all three boards.
</p>
<p>
<strong>Mage</strong>, one a side. On the land the Mage is a queen, sliding 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.
</p>
<p>
<strong>King</strong>, one a side. On the land the King moves one square in any direction, and it may step straight up to the air or down to the underworld. On either of those boards it can do nothing but come straight back to the land.
</p>
<p>
<strong>Paladin</strong>, one a side. On the land the Paladin has the king's move and the knight's leap together. It can also leap between boards the way a knight leaps on one, one board up or down and two squares along a rank or file, or two boards and one square, over anything in between. In the air or the underworld it keeps only the king's step.
</p>
</section>
<section id="underworld">
<h2>The pieces of the underworld</h2>
<p>
<strong>Dwarf</strong>, six a side. A Dwarf moves one square straight forward or one square sideways without capturing, and captures one square diagonally forward. From the underworld it can take the enemy piece directly above it on the land, and on the land it keeps the same moves. It comes back down only to the empty square directly beneath it. A Dwarf on the far rank can still move sideways and between the boards, and it never reaches the air.
</p>
<p>
<strong>Basilisk</strong>, two a side. A Basilisk 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 enemy piece directly above it on the land is frozen for as long as the Basilisk stays beneath it.
</p>
<p>
<strong>Elemental</strong>, one a side. In the underworld the Elemental moves one or two squares along a rank or file and captures the same way, with the nearer square empty before it can reach the further. It can also move one square diagonally, but never captures that way. It climbs to the land only to capture, moving one square along a rank or file to an empty square and then straight up onto an enemy piece. On the land it can do nothing but come down, straight down through an empty square and then one square along a rank or file, taking a piece there or not.
</p>
</section>
<section id="between">
<h2>Between the boards</h2>
<p>
Five kinds of piece pass between boards. The Griffon, the Hero and the Paladin leap, and nothing in the way stops them. The Mage and the Elemental do not leap. A Mage going from the underworld to the air needs the square on the land between to be empty, and an Elemental needs the square beside it empty on the way up and the square beneath it empty on the way down. The Sylph, the Dwarf, the Cleric and the King move straight up or down, one board at a time, onto the square itself.
</p>
</section>
<section id="decisions">
<h2>What this table decides</h2>
<p class="muted">
Gygax's text contradicts itself in two places and leaves a few cases unsaid. These are the readings the game follows, as recorded with the transcription. Two of them are choices the host makes when opening a table.
</p>
{#if decisions}{@html decisions.html}{/if}
</section> </section>
<section id="source"> <section id="source">
<h2>The original text</h2> <h2>The original text</h2>
<p>The full source text, in collapsible sections, so a rules question can be settled by the words the engine follows.</p> <p class="muted">
"Dragonchess" by Gary Gygax, Dragon Magazine No. 100, August 1985, pages 34 to 40. Transcribed from the keeper's copy. The diagrams are described in words where they stood. Open a heading to read that part.
</p>
{#each article as s (s.id)}
<details>
<summary>{s.title}</summary>
<div class="source">{@html s.html}</div>
</details>
{/each}
</section> </section>
<p class="word">If this page reads a rule differently from the original text, that is a bug. Please <a href="mailto:eric@ericwagoner.com">send word</a>.</p> <p class="word">If this page reads a rule differently from the original text, that is a bug. Please <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
</main> </main>
<style> <style>
nav {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1rem;
margin: 1rem 0 0.5rem;
font-size: 0.95rem;
}
p + p {
margin-top: 0.7rem;
}
details {
border-top: 1px solid var(--rule);
padding: 0.5rem 0;
}
summary {
cursor: pointer;
font-weight: 500;
}
.source {
padding: 0.5rem 0 0.4rem 1rem;
border-left: 2px solid var(--rule-strong);
color: var(--bone-dim);
font-size: 0.95rem;
}
.source :global(p + p),
.source :global(pre + p),
.source :global(p + pre),
.source :global(table) {
margin-top: 0.7rem;
}
.source :global(pre) {
font-size: 0.85rem;
line-height: 1.35;
overflow-x: auto;
}
.source :global(table) {
border-collapse: collapse;
}
.source :global(th),
.source :global(td) {
padding: 0.15rem 0.8rem 0.15rem 0;
text-align: left;
}
#decisions :global(ol) {
padding-left: 1.4rem;
}
#decisions :global(li + li) {
margin-top: 0.5rem;
}
.word { .word {
margin-top: 2rem; margin-top: 2rem;
color: var(--bone-faint); color: var(--bone-dim);
font-size: 0.9rem; font-size: 0.9rem;
} }
</style> </style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#121a20"/><circle cx="16" cy="16" r="8" fill="none" stroke="#e9e2d2" stroke-width="2.5"/><circle cx="16" cy="16" r="2.5" fill="#f0a53a"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#121a20"/><path d="M6 23.5l6-3h14l-6 3z" fill="#7a4a3a"/><path d="M6 17.5l6-3h14l-6 3z" fill="#8fa66a"/><path d="M6 11.5l6-3h14l-6 3z" fill="#9fb8cc"/><circle cx="17" cy="8.5" r="4.2" fill="#d8a63e" stroke="#8a6218" stroke-width="1"/><circle cx="15" cy="20.5" r="2.6" fill="#a4302a" stroke="#5e1612" stroke-width="0.8"/></svg>

Before

Width:  |  Height:  |  Size: 246 B

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB