diff --git a/.claude/skills/wyrm-pulse/SKILL.md b/.claude/skills/wyrm-pulse/SKILL.md
index 631039d..1450061 100644
--- a/.claude/skills/wyrm-pulse/SKILL.md
+++ b/.claude/skills/wyrm-pulse/SKILL.md
@@ -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.
- 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
journal errors in the last seven days with the last three error lines.
diff --git a/.claude/skills/wyrm-reports/SKILL.md b/.claude/skills/wyrm-reports/SKILL.md
index c83d2f8..5e76f60 100644
--- a/.claude/skills/wyrm-reports/SKILL.md
+++ b/.claude/skills/wyrm-reports/SKILL.md
@@ -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
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),
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,
@@ -48,7 +48,7 @@ to hear his players' voices; never compress their words into a table.
## c) Process an unanswered report
-1. **Replay to the pin.** `scp root@__IP__:/var/lib/wyrm/rooms/.jsonl /`
+1. **Replay to the pin.** `scp root@159.203.115.252:/var/lib/wyrm/rooms/.jsonl /`
and replay it with the engine as `deploy/replay-ledgers.ts` does:
`game.create(names, start.seed, start.rules, room.options)` then
`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
players saw them; new games get the fix.
Either way add a test in `src/lib/game/*.test.ts` pinning the
- corrected behaviour. Deploy with `deploy/deploy.sh __IP__`.
-5. **Reply.** `bash deploy/report-reply.sh __IP__ "text"`.
+ corrected behaviour. Deploy with `deploy/deploy.sh 159.203.115.252`.
+5. **Reply.** `bash deploy/report-reply.sh 159.203.115.252 "text"`.
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
was. The desk's voice is warm and specific.
diff --git a/.claude/skills/wyrm-visitors/SKILL.md b/.claude/skills/wyrm-visitors/SKILL.md
index 112d66e..68ef981 100644
--- a/.claude/skills/wyrm-visitors/SKILL.md
+++ b/.claude/skills/wyrm-visitors/SKILL.md
@@ -10,7 +10,7 @@ host glancing over the hall.
## 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
spills into the next UTC day. Run yesterday too when the hour is early.
diff --git a/deploy/README.md b/deploy/README.md
index 5217cff..41f5d55 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -22,13 +22,13 @@ dip 8790.
## Current production
-- Host: __IP__
+- Host: 159.203.115.252
- 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).
-- Everyday deploy: `deploy/deploy.sh __IP__`
+ DNS lives) and https://wyrm.159.203.115.252.sslip.io (always works, zero DNS).
+- Everyday deploy: `deploy/deploy.sh 159.203.115.252`
- Players' reports: `deploy/pull-reports.sh` mirrors /var/lib/wyrm/feedback.jsonl
and the screenshots to ~/Desktop/wyrm-reports with a digest;
- `deploy/report-reply.sh __IP__ "text"` answers one.
+ `deploy/report-reply.sh 159.203.115.252 "text"` answers one.
## Adding this game to the host
diff --git a/deploy/pull-reports.sh b/deploy/pull-reports.sh
index eef04df..37cd114 100755
--- a/deploy/pull-reports.sh
+++ b/deploy/pull-reports.sh
@@ -4,7 +4,7 @@
# repository root:
# deploy/pull-reports.sh [host] [folder] (default ~/Desktop/wyrm-reports)
set -euo pipefail
-HOST="${1:-__IP__}"
+HOST="${1:-159.203.115.252}"
OUT="${2:-$HOME/Desktop/wyrm-reports}"
mkdir -p "$OUT/images"
scp -q "root@$HOST:/var/lib/wyrm/feedback.jsonl" "$OUT/feedback.jsonl" 2>/dev/null || : > "$OUT/feedback.jsonl"
diff --git a/docs/icon.html b/docs/icon.html
new file mode 100644
index 0000000..000a94a
--- /dev/null
+++ b/docs/icon.html
@@ -0,0 +1 @@
+
diff --git a/docs/og-card.html b/docs/og-card.html
new file mode 100644
index 0000000..0c3f004
--- /dev/null
+++ b/docs/og-card.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
Wyrm
+
Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.
+
Gary Gygax’s Dragonchess of 1985, free in the browser. Play a drake, or send a friend the link.
+
wyrm.kestrelsnest.social
+
+
+
diff --git a/server/src/rooms.test.ts b/server/src/rooms.test.ts
index f460bf6..c69ed7c 100644
--- a/server/src/rooms.test.ts
+++ b/server/src/rooms.test.ts
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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 { Rooms } from './rooms';
import { Store } from './store';
@@ -16,24 +17,37 @@ afterEach(() => {
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. */
-function table(spec: GameSpec = game, withCy = false) {
+/** Gold's first Warrior step and Scarlet's answer. */
+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 = game) {
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 third = withCy ? rooms.join(host.room, 'Cy') : null;
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', () => {
- 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();
- rooms.submit(room, ann, { pick: 5 }, 0);
- expect(rooms.waitingOn(room)).toEqual(['B']);
- rooms.submit(room, bo, { pick: 2 }, 0);
+ expect(rooms.waitingOn(room)).toEqual(['A']);
+ expect(() => rooms.submit(room, bo, SCARLET_ANSWERS, 0)).toThrow(/not your move/);
+ rooms.submit(room, ann, GOLD_OPENS, 0);
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", () => {
@@ -45,7 +59,7 @@ describe('rooms', () => {
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 phrase = rooms.transfer(room, bo).phrase;
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', () => {
const { rooms, room, ann } = table();
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);
expect(seats.map((s) => [s.id, s.name])).toEqual([['A', 'Ann'], ['B', 'Bo']]);
expect(seats[0].lastActed).toBeGreaterThan(0);
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', () => {
const { room } = table();
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();
});
- it('holds a draft until it is settled, and lets it be replaced until then', () => {
- type Draft = Input & { final?: boolean };
- const drafts: GameSpec = {
- ...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);
+ it("shows a seat its last move played, and nobody else's", () => {
+ const { rooms, room, ann } = table();
+ rooms.submit(room, ann, GOLD_OPENS, 0);
expect(rooms.view(room, 'A').yourInput).toBeNull();
- expect(rooms.view(room, 'A').yourLastInput).toEqual({ pick: 5 });
- expect(rooms.view(room, 'B').yourLastInput).toEqual({ pick: 2 });
+ expect(rooms.view(room, 'A').yourLastInput).toEqual(GOLD_OPENS);
+ expect(rooms.view(room, 'B').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', () => {
- const { rooms, room, ann, bo } = table(game, true);
+ it('shows a private line to its sender and the seat it names, never to the gallery', () => {
+ const { rooms, room, ann, bo } = table();
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);
- expect(heard('A')).toEqual(['Everyone, hello.', 'Bo, lay a five.']);
- expect(heard('B')).toEqual(['Everyone, hello.', 'Bo, lay a five.']);
- expect(heard('C')).toEqual(['Everyone, hello.']);
+ expect(heard('A')).toEqual(['Everyone, hello.', 'Bo, mind your Dragon.']);
+ expect(heard('B')).toEqual(['Everyone, hello.', 'Bo, mind your Dragon.']);
expect(heard('')).toEqual(['Everyone, hello.']);
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/);
});
- it('keeps each ranking secret until the deal, which reads them all from the start line', () => {
- const dealt: Record[] = [];
- const ranked: GameSpec = {
- ...game,
- rankable: () => [
- { value: 'red', label: 'Red' },
- { value: 'blue', label: 'Blue' }
- ],
- create: (names, seed, rules, options, rankings) => {
- dealt.push(rankings ?? {});
- return game.create(names, seed, rules, options);
- }
- };
- 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('seats the host on the side they chose, and a drake across from them', () => {
+ const rooms = new Rooms(game, new Store(dir));
+ const host = rooms.create('Ann', 2, { side: 'scarlet', setup: 'shifted' });
+ const bot = rooms.addBot(host.room, host.token);
+ expect(game.botNames).toContain(bot.name);
+ rooms.begin(host.room, host.token);
+ const s = host.room.state!;
+ expect(s.players.A.side).toBe('scarlet');
+ expect(s.players.B.side).toBe('gold');
+ expect(s.setup).toBe('shifted');
+ // Gold moves first, and Gold is the drake: it has already moved.
+ expect(s.moves).toHaveLength(1);
+ expect(rooms.waitingOn(host.room)).toEqual(['A']);
});
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/);
});
- it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', () => {
- const heard: string[][] = [];
- const talking: GameSpec = {
- ...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', () => {
+ it('refuses private talk to a drake, which reads none', () => {
const rooms = new Rooms(game, new Store(dir));
const host = rooms.create('Ann', 2);
rooms.addBot(host.room, host.token);
@@ -243,28 +195,20 @@ describe('rooms', () => {
});
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();
- rooms.submit(room, ann, { pick: 5 }, 0);
+ rooms.submit(room, ann, GOLD_OPENS, 0);
rooms.standIn(room, ann, 'B');
- // The round waited on Bo alone; the bot played 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);
+ // The move was Bo's; the drake made it.
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', () => {
- 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', () => {
+ 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();
expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the player who opened/);
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/);
});
- 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();
+ rooms.submit(room, ann, GOLD_OPENS, 0);
rooms.standIn(room, ann, 'B');
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);
expect(claimed.seat).toBe('B');
expect(rooms.seatOf(room, claimed.token).name).toBe('Bo');
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);
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/);
});
- 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 { phrase } = rooms.hostPhrase(room, ann, 'B');
rooms.standIn(room, ann, 'B');
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']);
});
});
diff --git a/server/src/rooms.ts b/server/src/rooms.ts
index 56e0d9f..49697e9 100644
--- a/server/src/rooms.ts
+++ b/server/src/rooms.ts
@@ -194,7 +194,7 @@ export class Rooms {
if (!token) throw new RoomError('That token opens no seat at this game.', 403);
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));
- 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);
return seat;
}
@@ -412,7 +412,7 @@ export class Rooms {
/** The host seats a bot in an empty chair. */
addBot(room: Room, token: string | undefined): Seat {
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);
const taken = new Set(room.seats.map((s) => s.name.toLowerCase()));
const pool = this.game.botNames.filter((n) => !taken.has(n.toLowerCase()));
@@ -456,7 +456,7 @@ export class Rooms {
*/
standIn(room: Room, token: string | undefined, seatId: unknown): void {
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 (seatId === host.id) throw new RoomError('The host keeps their own seat.', 409);
const seat = room.seats.find((s) => s.id === seatId && !s.bot);
@@ -485,10 +485,10 @@ export class Rooms {
/** The host sends a bot away before the game begins. People leave by not coming back. */
unseat(room: Room, token: string | undefined, seatId: unknown): void {
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);
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 });
}
diff --git a/server/src/tally.test.ts b/server/src/tally.test.ts
index afbca1b..69e2aa3 100644
--- a/server/src/tally.test.ts
+++ b/server/src/tally.test.ts
@@ -18,7 +18,7 @@ afterEach(() => {
describe('the tally', () => {
it("adds a game's own lines from every game begun, finished or not", () => {
- const counted: GameSpec = { ...game, tallyBegun: () => ({ 'games of High Card begun': 1 }) };
+ const counted: GameSpec = { ...game, tallyBegun: () => ({ 'games of Wyrm begun': 1 }) };
const rooms = new Rooms(counted, new Store(dir));
for (let i = 0; i < 2; i++) {
const host = rooms.create('Ann', 2);
@@ -28,6 +28,6 @@ describe('the tally', () => {
rooms.create('Bo', 2);
const t = new Tallies(counted, new Store(dir)).get();
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 });
});
});
diff --git a/src/app.html b/src/app.html
index 982ead9..1387f5c 100644
--- a/src/app.html
+++ b/src/app.html
@@ -7,21 +7,21 @@
Wyrm
-
+
-
+
-
+
%sveltekit.head%
diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte
index 37dd270..72ba834 100644
--- a/src/lib/components/Board.svelte
+++ b/src/lib/components/Board.svelte
@@ -1,95 +1,250 @@
-
-
-
-
Round
- {#each g.seats as id (id)}
-
{g.players[id].name}{#if id === room.me} (you){/if}
- {/each}
-
-
-
- {#each g.rounds as round, i (i)}
-
-
{i + 1}
- {#each g.seats as id (id)}
-
{round.picks[id]}
- {/each}
-
- {/each}
- {#if !g.over}
-
-
{g.rounds.length + 1}
- {#each g.seats as id (id)}
-
{id === room.me && pick ? pick : '?'}
- {/each}
-
- {/if}
-
-
+
+ {#if g.over}
+ {verdict}{g.over.reason}
+ {:else if room.spectating}
+ {nameOfSide(g.toMove)} to move · {sideName(g.toMove)}{#if inCheckNow} · in check{/if} · move {g.moves.length + 1}
+ {:else if yourMove}
+ Your move · {sideName(mySide!)}{#if inCheckNow} · your King is in check{/if} · Checkmate {nameOfSide(g.toMove === 'gold' ? 'scarlet' : 'gold')}’s King.
+ {:else if room.view.playingOn}
+ The drakes play on · a move every few seconds{#if !room.spectating} {/if}
+ {:else}
+ {nameOfSide(g.toMove)} is thinking · you are {sideName(mySide!)}{#if inCheckNow} · {nameOfSide(g.toMove)} is in check{/if}
+ {/if}
+
Nobody else owes a move, so the bots play a round every few seconds for you to watch.
- {#if !room.spectating}{/if}
-
- {:else if room.spectating}
-
-
You watch from the Peanut Gallery
-
{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.'}
-
- {:else}
-
-
Name a number for round {g.rounds.length + 1}
-
Every player lays a card face down, and the highest card nobody else laid takes the point. The first to {TARGET} points wins.
-
- {#each Array.from({ length: HIGHEST }, (_, i) => i + 1) as n (n)}
-
- {/each}
-
-
- {#if room.error}
{room.error}
{/if}
-
+ {:else if yourMove}
+
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.
{/if}
@@ -98,7 +253,7 @@
diff --git a/src/lib/components/BoardArtwork.svelte b/src/lib/components/BoardArtwork.svelte
new file mode 100644
index 0000000..280e88d
--- /dev/null
+++ b/src/lib/components/BoardArtwork.svelte
@@ -0,0 +1,346 @@
+
+
+
+
+
+
+
diff --git a/src/lib/components/Hall.svelte b/src/lib/components/Hall.svelte
index 5554e81..068b436 100644
--- a/src/lib/components/Hall.svelte
+++ b/src/lib/components/Hall.svelte
@@ -13,7 +13,8 @@
import { unreadTalk } from '$lib/net/talk';
import type { Report, RoomView, Tally } from '$lib/net/view';
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';
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]))
);
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 error = $state('');
let seats = $state(allSeats());
@@ -163,14 +166,14 @@
const g = games[held.roomId];
if (g === undefined) return 'looking';
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.over) {
if (g.over.winner === held.seat) return 'you won';
if (g.over.winner === null) return 'a draw';
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(', ')}`;
return `waiting on ${g.waitingOn.map((id) => game.nameOf(g.state!, id)).join(', ')} · ${round}`;
}
@@ -203,8 +206,8 @@
0}>
Wyrm
-
One sentence that says what the game is, in its own world.
-
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.
+
Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.
+
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.
Where the game comes from, who made the original and when, and how the keeper of this page first met it.
-
What this version keeps and what it changes. Where the rules text it follows lives (the rules page), and any borrowed art or text with its notice.
-
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).
+
Gary Gygax, who made Dungeons & 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.
+
Wyrm plays the game as the article gives it, every piece as written, with the few open readings settled on the rules page, 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.
+
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.
{/snippet}
{#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
ko-fi.com/kestrelsnest. Chipping in unlocks nothing, and nothing is held back without it.
-
Wyrm is its designer's.
+
Dragonchess is Gary Gygax's, published by TSR, Inc. in 1985. Wyrm is an unofficial rebuild of it for the browser.
{#if room.seatEmpty && room.isHost}
-
+
{/if}
{#if room.isHost && beginAsked && v.expected.length > 0}
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.
@@ -138,7 +138,7 @@
{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.
{:else if v.challenge}
-
{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} 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.
+
{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} 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.
{/if}
{/if}
{#if room.error}
{room.error}
{/if}
diff --git a/src/lib/components/SeatsSlip.svelte b/src/lib/components/SeatsSlip.svelte
index 766d4c5..c644c45 100644
--- a/src/lib/components/SeatsSlip.svelte
+++ b/src/lib/components/SeatsSlip.svelte
@@ -37,7 +37,7 @@
}
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.`;
}
@@ -48,22 +48,22 @@
ev.target === ev.currentTarget && close()}>
The other seats
-
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.
+
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.
{#if people.length === 0}
Nobody else at this table is a person.
{/if}
{#each people as s (s.id)}
- {s.name}{#if s.standIn}a bot plays for them{/if}
+ {s.name}{#if s.standIn}a drake plays for them{/if}
{#if !s.standIn && !room.view.over}
{#if asked === s.id}
-
+
{:else}
-
+
{/if}
{/if}
diff --git a/src/lib/game/index.ts b/src/lib/game/index.ts
index b70d876..559e6ba 100644
--- a/src/lib/game/index.ts
+++ b/src/lib/game/index.ts
@@ -9,6 +9,7 @@ import {
type Cell, type Kind, type Side, type Setup, NAMES, LETTERS, cellOf, hasLegalMove, inCheck, kindOf, legalMovesFrom,
levelOf, movesFrom, opening, other, playOn, sideOf, squareName
} from './board';
+export { FILES, RANKS, LEVELS, PER_LEVEL, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, kingSquare, inCheck, other, square } from './board';
import { chooseMove } from './bot';
export const CURRENT_RULES = 1;
diff --git a/src/lib/net/room.svelte.ts b/src/lib/net/room.svelte.ts
index 3ff47fa..92b59c9 100644
--- a/src/lib/net/room.svelte.ts
+++ b/src/lib/net/room.svelte.ts
@@ -109,12 +109,12 @@ export class Room {
}
addBot(): Promise {
- 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. */
unseat(seat: SeatId): Promise {
- 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. */
@@ -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. */
standIn(seat: SeatId): Promise {
- 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 {
diff --git a/src/lib/source.ts b/src/lib/source.ts
new file mode 100644
index 0000000..f1c1427
--- /dev/null
+++ b/src/lib/source.ts
@@ -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, '&').replace(//g, '>');
+
+/** Bold, italic and the escaped backslash, inside a line. */
+function inline(text: string): string {
+ return escape(text)
+ .replace(/\\\*/g, '*')
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/(^|[\s(])\*([^*]+?)\*/g, '$1$2');
+}
+
+function block(lines: string[]): string {
+ if (lines.length === 0) return '';
+ if (lines.every((l) => l.startsWith(' '))) return `
`;
+ }
+ 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 `${items.map((i) => `
${inline(i)}
`).join('')}`;
+ }
+ return `
${inline(lines.join(' '))}
`;
+}
+
+/** 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;
+}
diff --git a/src/routes/guide/+page.svelte b/src/routes/guide/+page.svelte
index 701a4cb..484d71f 100644
--- a/src/routes/guide/+page.svelte
+++ b/src/routes/guide/+page.svelte
@@ -1,8 +1,6 @@
@@ -17,31 +21,187 @@
{#if room}Back to your game{' · '}{/if}Back to the hall · How to play
The rules
-
One paragraph on where these rules come from and which edition or text this page follows.
+
+ 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 What this table decides.
+
+
-
-
A turn
-
What each player does on a turn, in order, in short paragraphs. Put the thing a player checks mid-game first.
+
+
The boards
+
+ 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 3g1 is the air above Gold's King and 1g8 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.
+
+
+ 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.
+
Winning
-
How the game ends and who wins, including draws.
+
+ 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.
+
+
+ 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.
+
+
+
+
+
The pieces of the air
+
+ Sylph, 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.
+
+
+ Griffon, 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.
+
+
+ Dragon, 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.
+
+
+
+
+
The pieces of the land
+
+ Warrior, 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.
+
+
+ Oliphant, two a side, moves like a rook. Unicorn, two a side, moves like a knight. Thief, two a side, moves like a bishop. None of the three leaves the land.
+
+
+ Hero, 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.
+
+
+ Cleric, 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.
+
+
+ Mage, 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.
+
+
+ King, 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.
+
+
+ Paladin, 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.
+
+
+
+
+
The pieces of the underworld
+
+ Dwarf, 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.
+
+
+ Basilisk, 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.
+
+
+ Elemental, 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.
+
+
+
+
+
Between the boards
+
+ 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.
+
+
+
+
+
What this table decides
+
+ 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.
+
+ {#if decisions}{@html decisions.html}{/if}
The original text
-
The full source text, in collapsible sections, so a rules question can be settled by the words the engine follows.
+
+ "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.
+
+ {#each article as s (s.id)}
+
+ {s.title}
+
{@html s.html}
+
+ {/each}
If this page reads a rule differently from the original text, that is a bug. Please send word.
diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png
new file mode 100644
index 0000000..c1cea58
Binary files /dev/null and b/static/apple-touch-icon.png differ
diff --git a/static/favicon.svg b/static/favicon.svg
index fa91d51..a1ca77b 100644
--- a/static/favicon.svg
+++ b/static/favicon.svg
@@ -1 +1 @@
-
+
diff --git a/static/guide/boards.png b/static/guide/boards.png
new file mode 100644
index 0000000..60aa094
Binary files /dev/null and b/static/guide/boards.png differ
diff --git a/static/guide/hall.png b/static/guide/hall.png
new file mode 100644
index 0000000..148f6e3
Binary files /dev/null and b/static/guide/hall.png differ
diff --git a/static/guide/move.png b/static/guide/move.png
new file mode 100644
index 0000000..ba71b8b
Binary files /dev/null and b/static/guide/move.png differ
diff --git a/static/guide/table.png b/static/guide/table.png
new file mode 100644
index 0000000..e7842d2
Binary files /dev/null and b/static/guide/table.png differ
diff --git a/static/og.png b/static/og.png
new file mode 100644
index 0000000..05b8e2d
Binary files /dev/null and b/static/og.png differ