diff --git a/server/src/rooms.ts b/server/src/rooms.ts index bb34ce8..341417e 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -8,7 +8,7 @@ import { chooseBotTurn } from '../../src/lib/game/bot'; import { resolveTurn } from '../../src/lib/game/resolve'; import { GESTURES, type Gesture } from '../../src/lib/game/spells'; import { createGame, inDuel, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state'; -import { viewFor } from '../../src/lib/game/view'; +import { viewFor, type RoomView } from '../../src/lib/game/view'; import type { LedgerLine, Store } from './store'; const SEAT_IDS: WizardId[] = ['A', 'B', 'C', 'D']; @@ -35,18 +35,6 @@ export interface Room { updatedAt: number; } -/** What a seated player is told. Other seats' pending inputs and tokens never leave the server. */ -export interface View { - roomId: string; - seq: number; - size: number; - me: WizardId; - seats: { id: WizardId; name: string; bot: boolean }[]; - /** Seats that still have to move before the turn resolves. */ - waitingOn: WizardId[]; - state: GameState | null; -} - export class RoomError extends Error { constructor( message: string, @@ -60,6 +48,17 @@ function newId(bytes: number): string { return randomBytes(bytes).toString('base64url'); } +/** Room codes: four letters or digits, none that read alike, easy to say aloud. */ +const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +function newCode(): string { + const bytes = randomBytes(4); + return [...bytes].map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join(''); +} + +export function normalizeCode(raw: string): string { + return raw.trim().toUpperCase(); +} + export class Rooms { private rooms = new Map(); private listeners = new Map void>>(); @@ -72,8 +71,8 @@ export class Rooms { } get(id: string): Room { - const room = this.rooms.get(id); - if (!room) throw new RoomError('No such duel.', 404); + const room = this.rooms.get(id) ?? this.rooms.get(normalizeCode(id)); + if (!room) throw new RoomError('No duel answers to that code.', 404); return room; } @@ -86,8 +85,8 @@ export class Rooms { create(name: string, size: number): { room: Room; seat: Seat } { if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A duel seats 2 to ${MAX_SEATS} wizards.`); - let id = newId(6); - while (this.rooms.has(id)) id = newId(6); + let id = newCode(); + while (this.rooms.has(id)) id = newCode(); const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, seq: 0, updatedAt: Date.now() }; this.rooms.set(id, room); this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt }); @@ -144,7 +143,7 @@ export class Rooms { } } - view(room: Room, seatId: WizardId): View { + view(room: Room, seatId: WizardId): RoomView { return { roomId: room.id, seq: room.seq, diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte new file mode 100644 index 0000000..96d3f23 --- /dev/null +++ b/src/lib/components/Board.svelte @@ -0,0 +1,395 @@ + + +
+
+ + + + {#if outcome} +
+

{won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}

+

{outcome.reason}

+ {#if duel.remote} + Back to the hall + {:else} + + {/if} +
+ {:else} +
+ {#if duel.timeStopped} +

Time stands still. Take your extra turn.

+

{duel.foe.name} cannot move, see this, or resist anything you do. Last turn's enchantments do not bind you.

+ {:else} +

Turn {duel.turnNumber}: write your gestures{#if duel.hasted}, twice{/if}

+ {/if} + + {#if duel.threats.length && !duel.timeStopped} +
+ {#if duel.threatsNow.length} +

Their hands could finish this turn:

+
    + {#each duel.threatsNow as t (t.hand + t.spell.id)} + {@const active = duel.isHighlighted(t)} +
  • + +
  • + {/each} +
+ {/if} + {#if duel.threatsNext.length} +
+ Two gestures away ({duel.threatsNext.length}) +
    + {#each duel.threatsNext as t (t.hand + t.spell.id)} + {@const active = duel.isHighlighted(t)} +
  • + +
  • + {/each} +
+
+ {/if} +
+ {:else if !duel.timeStopped} +

Nothing hostile is one gesture away.

+ {/if} + +
+ + +
+
+ + +
+ {#if duel.hasted} +

You are hastened: a second pair of gestures follows the first, and both take effect together.

+
+ + +
+ {/if} + +
+ {#if duel.stabbing} + + {/if} + {#each duel.yourMonsters as m (m.id)} + + {/each} + {#if duel.bankedSpell} + + {/if} + {#if duel.bankCandidates.length > 1} + + {/if} + {#if duel.permanentCandidates.length > 1} + + {/if} + {#if duel.youCharmedFoe} + + {/if} +
+ + {#if duel.remote?.error} +

{duel.remote.error}

+ {/if} + {#if duel.moved} +

Your move is in. Waiting for {duel.awaiting.join(' and ')}.

+ {/if} + {#if duel.surrendering} +

Both palms at once is surrender. You will lose the duel.

+ {/if} + {#if duel.conflict} +

Those two spells share a gesture. A gesture can finish only one spell; let one of them pass.

+ {/if} + + +
+ {/if} +
+ + +
+ + + + diff --git a/src/lib/components/Hall.svelte b/src/lib/components/Hall.svelte new file mode 100644 index 0000000..663be62 --- /dev/null +++ b/src/lib/components/Hall.svelte @@ -0,0 +1,274 @@ + + +
+

Duel a friend

+

A duel between people is played by turns, whenever each of you has a moment. Open one and send the link, or join with a code.

+
+ +
+ or join one + + +
+
+ {#if error}

{error}

{/if} + + {#if seats.length} +
+
your duels
+ {#each seats as held (held.roomId)} +
+ + {held.roomId} + {status(held)} + + +
+ {/each} +
+ {/if} + +
+ about this game — a labor of love +
+

Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. Each wizard writes down a gesture for each hand, both reveal at once, and the right run of gestures on one hand is a spell. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's Spellcast brought it to university X terminals in 1993, which is where the maker of this page first met it.

+

This version keeps the game as written: all forty spells, the same simultaneous resolution, the same ledger of letters. The rules text it follows is the fanzine transcription, kept in full on the rules page. The bot is a heuristic that reads your gestures as you would read its; the hand silhouettes on the buttons are adapted from Plotkin's bitmaps, with his notice retained.

+

It is free, keeps no accounts, and stores your single-player duel in your own browser. Duels between people live on a small server as an append-only ledger of moves, so a game can be replayed from its first gesture.

+
+
+
+ + diff --git a/src/lib/components/Ledger.svelte b/src/lib/components/Ledger.svelte index a4b576d..b966cde 100644 --- a/src/lib/components/Ledger.svelte +++ b/src/lib/components/Ledger.svelte @@ -1,5 +1,5 @@ @@ -78,6 +69,7 @@ @@ -91,176 +83,9 @@ {/if} -
-
- - + - {#if outcome} -
-

{won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}

-

{outcome.reason}

- -
- {:else} -
- {#if duel.timeStopped} -

Time stands still. Take your extra turn.

-

{duel.foe.name} cannot move, see this, or resist anything you do. Last turn's enchantments do not bind you.

- {:else} -

Turn {duel.turnNumber}: write your gestures{#if duel.hasted}, twice{/if}

- {/if} - - {#if duel.threats.length && !duel.timeStopped} -
- {#if duel.threatsNow.length} -

Their hands could finish this turn:

-
    - {#each duel.threatsNow as t (t.hand + t.spell.id)} - {@const active = duel.highlight?.hand === t.hand && duel.highlight.from === t.from} -
  • - -
  • - {/each} -
- {/if} - {#if duel.threatsNext.length} -
- Two gestures away ({duel.threatsNext.length}) -
    - {#each duel.threatsNext as t (t.hand + t.spell.id)} - {@const active = duel.highlight?.hand === t.hand && duel.highlight.from === t.from && duel.highlight.to === t.to} -
  • - -
  • - {/each} -
-
- {/if} -
- {:else if !duel.timeStopped} -

Nothing hostile is one gesture away.

- {/if} - -
- - -
-
- - -
- {#if duel.hasted} -

You are hastened: a second pair of gestures follows the first, and both take effect together.

-
- - -
- {/if} - -
- {#if duel.stabbing} - - {/if} - {#each duel.yourMonsters as m (m.id)} - - {/each} - {#if duel.bankedSpell} - - {/if} - {#if duel.bankCandidates.length > 1} - - {/if} - {#if duel.permanentCandidates.length > 1} - - {/if} - {#if duel.youCharmedFoe} - - {/if} -
- - {#if duel.surrendering} -

Both palms at once is surrender. You will lose the duel.

- {/if} - {#if duel.conflict} -

Those two spells share a gesture. A gesture can finish only one spell; let one of them pass.

- {/if} - - -
- {/if} -
- - -
- - +