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 @@
- - - - - {#each g.seats as id (id)} - - {/each} - - - - {#each g.rounds as round, i (i)} - - - {#each g.seats as id (id)} - - {/each} - - {/each} - {#if !g.over} - - - {#each g.seats as id (id)} - - {/each} - - {/if} - -
Round{g.players[id].name}{#if id === room.me} (you){/if}
{i + 1}{round.picks[id]}
{g.rounds.length + 1}{id === room.me && pick ? pick : '?'}
+
+ {#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} +
+ {#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted)} +

0}>{shownStory}{#if reviewing !== null} {/if}

+ {/if} + {#if pending} +

Move {label(pending.from)} to {label(pending.to)}? + +

+ {/if} + {#if room.error}

{room.error}

{/if} + +
+ +
+

{liveText}

{#if g.over}
-

{g.over.winner === room.me ? 'You win.' : g.over.winner ? `${g.players[g.over.winner].name} wins.` : 'A draw.'}

-

{g.over.reason}

- {#each room.view.parting as last, i (i)}

{room.nameOf(last.id)} {last.text}

{/each} Back to the hall
- {:else if room.view.playingOn} -
-

The bots play on

-

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 @@ + + + + + + {#each levels as lv, slot (lv)} + {@const top = slot * (boardHeight + GAP)} + + + {WORLD[lv]} + {#if showCoords} + {#each Array.from({ length: FILES }, (_, n) => n) as n (n)} + {fileLetter(n)} + {/each} + {#each Array.from({ length: RANKS }, (_, n) => n) as n (n)} + {rankNumber(n)} + {/each} + {/if} + + {/each} + + {#each drawn as i (i)} + {@const p = place(i)!} + + + tap(i)} + onkeydown={decorative ? undefined : (e) => key(e, i)} + /> + {/each} + {#each artworkTokens as t (t.id)} + {@const p = place(t.at)} + {#if p} + + + + + 1} y="5.5" text-anchor="middle">{LETTERS[kindOf(t.piece)]} + + + {/if} + {/each} + {#each targets as i (i)} + {@const p = place(i)} + {#if p} + {#if g.cells[i] === ''} + + {:else} + + {/if} + {/if} + {/each} + + + + 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.

@@ -230,7 +233,7 @@ {/each} {/if} - +
@@ -249,8 +252,8 @@
{#snippet gameRow(held: (typeof seats)[number])} @@ -318,9 +321,9 @@ {#snippet aboutGame()}
-

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.

{/snippet} {#snippet aboutWord()} @@ -489,8 +492,10 @@ .lid-look { grid-area: look; display: grid; - place-items: center; + align-content: center; + gap: 0.4rem; min-height: 8rem; + padding: 0.6rem; background: var(--slate-deep); border: 1px solid var(--rule-strong); border-radius: 6px; diff --git a/src/lib/components/Lobby.svelte b/src/lib/components/Lobby.svelte index 6fa2951..42bae4c 100644 --- a/src/lib/components/Lobby.svelte +++ b/src/lib/components/Lobby.svelte @@ -73,7 +73,7 @@ {#each v.seats as s (s.id)}
  • {s.name} - {s.id === v.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost} + {s.id === v.host ? 'opened the table' : s.bot ? 'a drake' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost} {/if}
  • {/each} @@ -113,7 +113,7 @@ {#if !room.spectating}
    {#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 @@
    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