A table for a dozen wizards, begun by its host
Rooms take up to twelve seats and the host begins the duel with whoever is seated; a full table begins by itself. The lobby is a roster with a bot seat and the host's begin button. The ledger pins the turn and your own columns and scrolls sideways to the rest; the phone bar shows every wizard's health; a wizard who is out watches the rest; each charmed wizard gets their own gesture; an unnamed spell goes at whoever hurt you last, else the weakest opponent; a stab has a target from the start. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
79947e31ba
commit
58d7db106e
+8
-1
@@ -3,7 +3,8 @@
|
||||
//
|
||||
// POST /api/rooms {name, size?} create a duel and take seat A
|
||||
// POST /api/rooms/:id/join {name} take the next seat
|
||||
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may)
|
||||
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may, before the duel begins)
|
||||
// POST /api/rooms/:id/begin {token} the host begins the duel with the wizards seated so far
|
||||
// GET /api/rooms/:id?token= the view for that seat
|
||||
// POST /api/rooms/:id/turn {token, input} this seat's move
|
||||
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes
|
||||
@@ -109,6 +110,12 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
const seat = rooms.seatOf(room, body.token as string);
|
||||
return send(res, 200, rooms.view(room, seat.id));
|
||||
}
|
||||
if (action === 'begin') {
|
||||
const token = typeof body.token === 'string' ? body.token : undefined;
|
||||
rooms.begin(room, token);
|
||||
const seat = rooms.seatOf(room, token);
|
||||
return send(res, 200, rooms.view(room, seat.id));
|
||||
}
|
||||
if (action === 'turn') {
|
||||
const token = typeof body.token === 'string' ? body.token : undefined;
|
||||
rooms.submit(room, token, body.input);
|
||||
|
||||
+21
-4
@@ -11,8 +11,9 @@ import { createGame, inDuel, type GameState, type TurnInput, type WizardId } fro
|
||||
import { viewFor, type RoomView } from '../../src/lib/game/view';
|
||||
import type { LedgerLine, Store } from './store';
|
||||
|
||||
const SEAT_IDS: WizardId[] = ['A', 'B', 'C', 'D'];
|
||||
const BOT_NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'];
|
||||
/** A dozen seats at most: a turn resolves only when every seat has moved, and the slowest sets the pace. */
|
||||
const SEAT_IDS: WizardId[] = 'ABCDEFGHJKLM'.split('');
|
||||
const BOT_NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura', 'Bellamy', 'Hesper', 'Lucan', 'Wynne'];
|
||||
export const MAX_SEATS = SEAT_IDS.length;
|
||||
|
||||
export interface Seat {
|
||||
@@ -55,6 +56,10 @@ function newCode(): string {
|
||||
return [...bytes].map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
|
||||
}
|
||||
|
||||
function newSeed(): number {
|
||||
return (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0;
|
||||
}
|
||||
|
||||
export function normalizeCode(raw: string): string {
|
||||
return raw.trim().toUpperCase();
|
||||
}
|
||||
@@ -109,7 +114,7 @@ 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.`);
|
||||
if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A table seats 2 to ${MAX_SEATS} wizards.`);
|
||||
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() };
|
||||
@@ -120,11 +125,22 @@ export class Rooms {
|
||||
}
|
||||
|
||||
join(room: Room, name: string): Seat {
|
||||
if (room.state) throw new RoomError('The duel has begun; no more seats are taken.', 409);
|
||||
return this.sit(room, name, false);
|
||||
}
|
||||
|
||||
/** The host, who opened the table, begins the duel once at least two are seated. A full table begins by itself. */
|
||||
begin(room: Room, token: string | undefined): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the wizard who opened the table may begin the duel.', 403);
|
||||
if (room.state) throw new RoomError('The duel has already begun.', 409);
|
||||
if (room.seats.length < 2) throw new RoomError('A duel needs at least two wizards.', 409);
|
||||
this.commit(room, { t: 'start', seed: newSeed() });
|
||||
}
|
||||
|
||||
addBot(room: Room, token: string | undefined): Seat {
|
||||
this.seatOf(room, token);
|
||||
if (room.state) throw new RoomError('The duel has begun; no more seats are taken.', 409);
|
||||
const taken = new Set(room.seats.map((s) => s.name.toLowerCase()));
|
||||
const pool = BOT_NAMES.filter((n) => !taken.has(n.toLowerCase()));
|
||||
const name = pool[Math.floor(Math.random() * pool.length)] ?? 'The Construct';
|
||||
@@ -137,7 +153,7 @@ export class Rooms {
|
||||
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another wizard here already has that name.', 409);
|
||||
const seat: Seat = { id: SEAT_IDS[room.seats.length], name, token: bot ? '' : newId(18), bot };
|
||||
this.commit(room, { t: 'seat', id: seat.id, name, token: seat.token, bot });
|
||||
if (room.seats.length === room.size) this.commit(room, { t: 'start', seed: (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0 });
|
||||
if (room.seats.length === room.size) this.commit(room, { t: 'start', seed: newSeed() });
|
||||
return seat;
|
||||
}
|
||||
|
||||
@@ -175,6 +191,7 @@ export class Rooms {
|
||||
seq: room.seq,
|
||||
size: room.size,
|
||||
me: seatId,
|
||||
host: room.seats[0]?.id ?? seatId,
|
||||
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
|
||||
waitingOn: waitingOn(room),
|
||||
state: room.state ? viewFor(room.state, seatId) : null
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import SpellSheet from './SpellSheet.svelte';
|
||||
import TurnSummary from './TurnSummary.svelte';
|
||||
import WizardStatus from './WizardStatus.svelte';
|
||||
import { castKey, tokensText, type Duel } from '$lib/game/duel.svelte';
|
||||
import { castKey, listText, tokensText, type Duel } from '$lib/game/duel.svelte';
|
||||
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
|
||||
import { MAGIC_GESTURES } from '$lib/game/spells';
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me));
|
||||
const outcome = $derived(duel.state.over);
|
||||
const won = $derived(outcome?.winner === duel.me);
|
||||
const charmedName = $derived(duel.charmedFoe ? duel.state.wizards[duel.charmedFoe].name : '');
|
||||
const charmedHandName = $derived(duel.charmedFoe ? duel.state.wizards[duel.charmedFoe].constraints.charmed?.hand : '');
|
||||
</script>
|
||||
|
||||
<main class="board">
|
||||
@@ -34,6 +32,12 @@
|
||||
<button type="button" class="reveal" onclick={() => duel.newGame()}>Duel again</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if duel.out}
|
||||
<section class="move out" aria-labelledby="move-heading">
|
||||
<h2 id="move-heading">{duel.you.alive ? 'You have yielded' : 'You have fallen'}</h2>
|
||||
<p class="threats muted">The duel goes on without you. Watch the ledger fill; {listText(duel.others.filter((id) => duel.state.wizards[id].alive && !duel.state.wizards[id].surrendered).map((id) => duel.state.wizards[id].name))} still stand.</p>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="move" aria-labelledby="move-heading">
|
||||
{#if duel.timeStopped}
|
||||
@@ -162,23 +166,23 @@
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if duel.youCharmedFoe}
|
||||
{#each duel.charmedFoes as id (id)}
|
||||
<label class="field">
|
||||
<span>{charmedName}'s charmed {charmedHandName} hand makes</span>
|
||||
<select bind:value={duel.charmGesture}>
|
||||
<span>{duel.state.wizards[id].name}'s charmed {duel.state.wizards[id].constraints.charmed?.hand} hand makes</span>
|
||||
<select bind:value={duel.charmGestures[id]}>
|
||||
{#each MAGIC_GESTURES as g (g)}
|
||||
<option value={g}>{glyph(g)} {GESTURE_SHORT[g]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if duel.remote?.error}
|
||||
<p class="warning">{duel.remote.error}</p>
|
||||
{/if}
|
||||
{#if duel.moved}
|
||||
<p class="waiting">Your move is in. Waiting for {duel.awaiting.join(' and ')}.</p>
|
||||
<p class="waiting">Your move is in. Waiting for {duel.awaitingText}.</p>
|
||||
{/if}
|
||||
{#if duel.surrendering}
|
||||
<p class="warning">Both palms at once is surrender. You will lose the duel.</p>
|
||||
@@ -192,6 +196,7 @@
|
||||
</button>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<aside class="rail">
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
error = '';
|
||||
try {
|
||||
rememberName(name);
|
||||
const remote = await Duel.createRoom(name.trim(), 2);
|
||||
const remote = await Duel.createRoom(name.trim());
|
||||
await goto(`/join/${remote.roomId}`);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'The duel could not be opened.';
|
||||
@@ -110,7 +110,7 @@
|
||||
<div class="lid-head">
|
||||
<h1>Waving Hands</h1>
|
||||
<p class="pitch">Two wizards. Two hands each. The right run of gestures is a spell.</p>
|
||||
<p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn both wizards choose a gesture per hand and reveal them together; a Fireball is F-S-S-D-D on one hand, a Shield is a single palm. Play the bot, or a friend by turns.</p>
|
||||
<p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn every wizard chooses a gesture per hand and all reveal together; a Fireball is F-S-S-D-D on one hand, a Shield is a single palm. Play the bot, or open a table for up to a dozen wizards and play by turns.</p>
|
||||
<a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
{/if}
|
||||
</p>
|
||||
<div class="ways">
|
||||
<button type="button" class="quiet" disabled={!ready || busy} onclick={create}>Create a duel</button>
|
||||
<button type="button" class="quiet" disabled={!ready || busy} onclick={create}>Open a table</button>
|
||||
<form class="joinform" onsubmit={join}>
|
||||
<span class="or">or join one</span>
|
||||
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
|
||||
|
||||
@@ -79,14 +79,14 @@
|
||||
<tr>
|
||||
<th class="turn" scope="col"><span class="visually-hidden">Turn</span></th>
|
||||
{#each columns as who (who)}
|
||||
<th class:you={who === duel.me} class:foe={who !== duel.me} colspan="2" scope="colgroup">{duel.state.wizards[who].name}{#if who === duel.me} (you){/if}</th>
|
||||
<th class:you={who === duel.me} class:foe={who !== duel.me} class:gone={!duel.state.wizards[who].alive || duel.state.wizards[who].surrendered} colspan="2" scope="colgroup">{duel.state.wizards[who].name}{#if who === duel.me} (you){/if}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
<tr class="hands">
|
||||
<th></th>
|
||||
{#each columns as who (who)}
|
||||
<th scope="col">left</th>
|
||||
<th scope="col">right</th>
|
||||
<th scope="col" class:pin-left={who === duel.me}>left</th>
|
||||
<th scope="col" class:pin-right={who === duel.me}>right</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -96,7 +96,7 @@
|
||||
<td class="turn">{row.label}{#if row.note}<span class="rownote">{row.note}</span>{/if}</td>
|
||||
{#each columns as who (who)}
|
||||
{#each ['left', 'right'] as const as hand (hand)}
|
||||
<td class="cell" class:you={who === duel.me} class:foe={who !== duel.me}>
|
||||
<td class="cell" class:you={who === duel.me} class:foe={who !== duel.me} class:pin-left={who === duel.me && hand === 'left'} class:pin-right={who === duel.me && hand === 'right'}>
|
||||
{#each row.cells[who] as p (p.index)}
|
||||
{@const notes = notesFor(who, p.index, hand)}
|
||||
{@const other = hand === 'left' ? p.entry.right : p.entry.left}
|
||||
@@ -114,7 +114,7 @@
|
||||
<tr class="draft" class:stopped={duel.timeStopped}>
|
||||
<td class="turn">{duel.timeStopped ? '' : duel.turnNumber}{#if duel.timeStopped}<span class="rownote">time stop</span>{/if}</td>
|
||||
{#each ['left', 'right'] as const as hand (hand)}
|
||||
<td class="cell you">
|
||||
<td class="cell you" class:pin-left={hand === 'left'} class:pin-right={hand === 'right'}>
|
||||
{#each duel.activeSets as set (set)}
|
||||
{@const undecided = duel.chosenGesture(set, hand) === null || duel.charmedHand === hand}
|
||||
{@const cast = duel.chosen[set][hand]}
|
||||
@@ -138,17 +138,53 @@
|
||||
<style>
|
||||
.scroller {
|
||||
max-height: clamp(170px, 30vh, 400px);
|
||||
overflow-y: auto;
|
||||
overflow: auto;
|
||||
border-top: 1px solid var(--rule-strong);
|
||||
border-bottom: 1px solid var(--rule-strong);
|
||||
}
|
||||
|
||||
/* With many wizards the table grows sideways; the turn and your own two columns stay pinned on the left. */
|
||||
.ledger {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.ledger th,
|
||||
.ledger td {
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
th.pin-left,
|
||||
td.pin-left,
|
||||
th.pin-right,
|
||||
td.pin-right {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
background: var(--slate-deep);
|
||||
}
|
||||
|
||||
th.pin-left,
|
||||
td.pin-left {
|
||||
left: 2.9rem;
|
||||
}
|
||||
|
||||
th.pin-right,
|
||||
td.pin-right {
|
||||
left: 7.4rem;
|
||||
}
|
||||
|
||||
thead th.pin-left,
|
||||
thead th.pin-right {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
thead th.gone {
|
||||
text-decoration: line-through;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -177,7 +213,12 @@
|
||||
|
||||
th.turn,
|
||||
td.turn {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: var(--slate-deep);
|
||||
width: 2.9rem;
|
||||
min-width: 2.9rem;
|
||||
color: var(--bone-faint);
|
||||
text-align: right;
|
||||
padding-right: 0.5rem;
|
||||
@@ -266,4 +307,8 @@
|
||||
border-bottom: 2px solid var(--ember);
|
||||
}
|
||||
|
||||
tr.draft td.turn {
|
||||
background: var(--slate);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
|
||||
{#if !duel.state.over}
|
||||
<div class="bar">
|
||||
<div class="health">
|
||||
<div class="health" class:many={duel.others.length > 1}>
|
||||
<span><em>{duel.you.name}</em> {duel.you.hp}/{MAX_HP}</span>
|
||||
<span><em>{duel.foe.name}</em> {duel.foe.hp}/{MAX_HP}</span>
|
||||
{#each duel.others as id (id)}
|
||||
{@const w = duel.state.wizards[id]}
|
||||
<span class:gone={!w.alive || w.surrendered}><em>{w.name}</em> {w.hp}/{MAX_HP}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="gestures" aria-label="Chosen gestures">
|
||||
{#each HANDS as hand (hand)}
|
||||
@@ -18,7 +21,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
|
||||
{duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
|
||||
{duel.out ? 'Out' : duel.moved ? 'Waiting' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -55,10 +58,22 @@
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.health.many {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.health em {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.health .gone {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.gestures {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
onclick={() => duel.follow(hand, plan)}
|
||||
>
|
||||
<span class="which">Plan with {hand} hand</span>
|
||||
<span class="rest">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span> then {/if}{tokensText(plan.remaining)} <span class="count">({plan.remaining.length} more)</span></span>
|
||||
<span class="rest">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span> then {/if}{tokensText(plan.remaining)} <span class="count">({plan.remaining.length} more)</span></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
+8
-5
@@ -356,14 +356,16 @@ function toChoice(s: Situation, hand: Hand, completion: Completion): CastChoice
|
||||
return choice;
|
||||
}
|
||||
|
||||
/** The gesture that most disrupts a charmed hand's sequences. */
|
||||
function charmGesture(s: Situation): Gesture {
|
||||
const foeW = s.state.wizards[s.foe];
|
||||
/** The gesture that most disrupts a charmed wizard's hand. */
|
||||
function charmGesture(s: Situation, who: WizardId): Gesture {
|
||||
const foeW = s.state.wizards[who];
|
||||
const charmedHand = foeW.constraints.charmed?.hand ?? 'left';
|
||||
const hist = visibleHistory(s.state, s.me, who);
|
||||
const theirs = handSequence(hist, charmedHand);
|
||||
let best: Gesture = 'P';
|
||||
let bestScore = Infinity;
|
||||
for (const g of MAGIC_GESTURES) {
|
||||
const seq = [...s.foeSeq[charmedHand], { own: g, other: '-' as Gesture }];
|
||||
const seq = [...theirs, { own: g, other: '-' as Gesture }];
|
||||
let score = 0;
|
||||
for (const spell of SPELLS) {
|
||||
if (!HOSTILE_SPELLS.includes(spell.id)) continue;
|
||||
@@ -453,6 +455,7 @@ export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number
|
||||
input.release = { target: choice.target, monsterTarget: choice.monsterTarget };
|
||||
}
|
||||
}
|
||||
if (state.seats.some((id) => state.wizards[id].constraints.charmed?.by === me)) input.charmGesture = charmGesture(s);
|
||||
const charmed = state.seats.filter((id) => state.wizards[id].constraints.charmed?.by === me);
|
||||
if (charmed.length) input.charmGestures = Object.fromEntries(charmed.map((id) => [id, charmGesture(s, id)]));
|
||||
return input;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ export const api = {
|
||||
create: (name: string, size: number) => call<Seated>('POST', '/api/rooms', { name, size }),
|
||||
join: (roomId: string, name: string) => call<Seated>('POST', `/api/rooms/${roomId}/join`, { name }),
|
||||
addBot: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/bot`, { token }),
|
||||
begin: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/begin`, { token }),
|
||||
view: (roomId: string, token: string) => call<RoomView>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
|
||||
turn: (roomId: string, token: string, input: TurnInput) => call<RoomView>('POST', `/api/rooms/${roomId}/turn`, { token, input })
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
NOWHERE,
|
||||
createGame,
|
||||
enemyOf,
|
||||
inDuel,
|
||||
visibleHistory,
|
||||
type CastChoice,
|
||||
type GameState,
|
||||
@@ -139,6 +140,12 @@ export interface Highlight {
|
||||
to: number;
|
||||
}
|
||||
|
||||
/** Names as a sentence lists them. */
|
||||
export function listText(names: string[]): string {
|
||||
if (names.length <= 1) return names.join('');
|
||||
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
||||
}
|
||||
|
||||
/** A token in Bartle's notation: a bracketed lower-case letter is a two-handed gesture. */
|
||||
export function tokenText(t: Token): string {
|
||||
return t.both && t.gesture !== 'C' ? `(${t.gesture.toLowerCase()}` : t.gesture;
|
||||
@@ -201,6 +208,7 @@ function readPreference(key: string, fallback: boolean): boolean {
|
||||
export interface Remote extends HeldSeat {
|
||||
roomId: string;
|
||||
size: number;
|
||||
host: WizardId;
|
||||
seats: RoomView['seats'];
|
||||
waitingOn: WizardId[];
|
||||
started: boolean;
|
||||
@@ -218,7 +226,8 @@ export class Duel {
|
||||
monsterOrders = $state<Record<string, TargetId>>({});
|
||||
/** Narrow screens get shorter lists and a sticky bar; the page sets this from a media query. */
|
||||
compact = $state(false);
|
||||
charmGesture = $state<Gesture>('P');
|
||||
/** The gesture each wizard you charmed will make, by their seat. */
|
||||
charmGestures = $state<Record<WizardId, Gesture>>({});
|
||||
/** Where to loose the banked spell this turn; empty keeps it. */
|
||||
release = $state<TargetId | ''>('');
|
||||
/** Which cast to bank or make permanent when more than one qualifies. */
|
||||
@@ -261,6 +270,8 @@ export class Duel {
|
||||
*/
|
||||
syncAll(): void {
|
||||
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
|
||||
// A knife needs someone to stab: the same wizard a spell would go at.
|
||||
if (this.stabbing && !this.stabTarget) this.stabTarget = this.foeId;
|
||||
}
|
||||
|
||||
/** Rename the player for this duel and every duel after it. An empty name goes back to a drawn one. */
|
||||
@@ -317,6 +328,8 @@ export class Duel {
|
||||
awaiting = $derived(
|
||||
this.remote ? this.remote.waitingOn.filter((id) => id !== this.me).map((id) => this.state.wizards[id]?.name ?? id) : []
|
||||
);
|
||||
/** "Ysolde", "Ysolde and Grey", "Ysolde, Grey and Ormund". */
|
||||
awaitingText = $derived(listText(this.awaiting));
|
||||
hasted = $derived(this.you.hasteTurns > 0 && !this.timeStopped);
|
||||
activeSets = $derived<SetName[]>(this.hasted ? ['main', 'haste'] : ['main']);
|
||||
|
||||
@@ -336,9 +349,10 @@ export class Duel {
|
||||
return set === 'haste' ? this.forcedSecond[hand] : this.forced[hand];
|
||||
}
|
||||
charmedHand = $derived<Hand | undefined>(this.timeStopped ? undefined : this.you.constraints.charmed?.hand);
|
||||
/** The opponent whose hand is yours to command this turn, if any. */
|
||||
charmedFoe = $derived(this.others.find((id) => this.state.wizards[id].constraints.charmed?.by === this.me));
|
||||
youCharmedFoe = $derived(this.charmedFoe !== undefined && !this.timeStopped);
|
||||
/** The wizards whose hands are yours to command this turn. */
|
||||
charmedFoes = $derived(this.timeStopped ? [] : this.others.filter((id) => this.state.wizards[id].constraints.charmed?.by === this.me));
|
||||
/** Dead or surrendered: the duel goes on without you, and you may watch. */
|
||||
out = $derived(!inDuel(this.you));
|
||||
|
||||
/** The gesture a hand will make: forced by an enchantment, or chosen, or nothing yet. */
|
||||
chosenGesture(set: SetName, hand: Hand): Gesture | null {
|
||||
@@ -566,6 +580,7 @@ export class Duel {
|
||||
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === this.me));
|
||||
ready = $derived(
|
||||
!this.state.over &&
|
||||
!this.out &&
|
||||
!this.moved &&
|
||||
!this.sending &&
|
||||
this.activeSets.every((set) =>
|
||||
@@ -671,7 +686,9 @@ export class Duel {
|
||||
};
|
||||
}
|
||||
if (this.release && this.you.banked) yours.release = { target: this.release, monsterTarget: this.foeId };
|
||||
if (this.youCharmedFoe) yours.charmGesture = this.charmGesture;
|
||||
if (this.charmedFoes.length) {
|
||||
yours.charmGestures = Object.fromEntries(this.charmedFoes.map((id) => [id, this.charmGestures[id] ?? 'P']));
|
||||
}
|
||||
return yours;
|
||||
}
|
||||
|
||||
@@ -711,6 +728,7 @@ export class Duel {
|
||||
const r = this.remote!;
|
||||
r.seats = view.seats;
|
||||
r.size = view.size;
|
||||
r.host = view.host;
|
||||
r.waitingOn = view.waitingOn;
|
||||
const wasStarted = r.started;
|
||||
r.started = view.state !== null;
|
||||
@@ -730,7 +748,21 @@ export class Duel {
|
||||
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
|
||||
}
|
||||
|
||||
/** Seat the bot in the empty chair. */
|
||||
/** You opened this table, so the duel begins when you say. */
|
||||
isHost = $derived(!!this.remote && this.remote.host === this.remote.seat);
|
||||
|
||||
/** Begin the duel with whoever is seated. */
|
||||
async begin(): Promise<void> {
|
||||
const r = this.remote;
|
||||
if (!r) return;
|
||||
try {
|
||||
this.apply(await api.begin(r.roomId, r.token));
|
||||
} catch (e) {
|
||||
r.error = e instanceof Error ? e.message : 'The duel could not begin.';
|
||||
}
|
||||
}
|
||||
|
||||
/** Seat the bot in an empty chair. */
|
||||
async addBot(): Promise<void> {
|
||||
const r = this.remote;
|
||||
if (!r) return;
|
||||
@@ -777,6 +809,7 @@ export class Duel {
|
||||
this.release = '';
|
||||
this.bankPick = '';
|
||||
this.permanentPick = '';
|
||||
this.charmGestures = {};
|
||||
this.highlight = null;
|
||||
}
|
||||
|
||||
@@ -787,8 +820,8 @@ export class Duel {
|
||||
this.resetDrafts();
|
||||
}
|
||||
|
||||
/** Open a duel for another person on the server, seated as the first wizard. */
|
||||
static async createRoom(name: string, size = 2): Promise<Remote> {
|
||||
/** Open a table on the server, seated as its host; it takes up to a dozen and begins when the host says. */
|
||||
static async createRoom(name: string, size = 12): Promise<Remote> {
|
||||
const seated = await api.create(name, size);
|
||||
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
||||
return remoteFrom(seated.view, seated.token);
|
||||
@@ -819,6 +852,7 @@ function remoteFrom(view: RoomView, token: string): Remote {
|
||||
seat: view.me,
|
||||
token,
|
||||
size: view.size,
|
||||
host: view.host,
|
||||
seats: view.seats,
|
||||
waitingOn: view.waitingOn,
|
||||
started: view.state !== null,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { chooseBotTurn } from './bot';
|
||||
import { completedSpells, handSequence, castsConflict, completableIn, progressToward } from './gestures';
|
||||
import { resolveTurn } from './resolve';
|
||||
import { SPELLS, SPELL_BY_ID, parseSequence, type Gesture } from './spells';
|
||||
import { createGame } from './state';
|
||||
import { createGame, enemyOf } from './state';
|
||||
import { lcg, noop, play, runSequence } from './test-helpers';
|
||||
|
||||
describe('spell table', () => {
|
||||
@@ -446,3 +446,41 @@ describe('more than two wizards', () => {
|
||||
expect(s.wizards.A.history.at(-1)?.hiddenFrom).toEqual(['B', 'C']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('at a table of several wizards', () => {
|
||||
it('an unnamed spell goes at whoever hurt you last, else the weakest opponent', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White', C: 'Grey' }, 1);
|
||||
// Nobody has hurt anyone: the weakest opponent is the default; all equal, so table order.
|
||||
expect(enemyOf(s, 'A')).toBe('B');
|
||||
// Grey stabs Black; Black's next unnamed spell goes back at Grey.
|
||||
s = resolveTurn(s, { A: noop(), B: noop(), C: { ...noop(), left: '>', stabTarget: 'A' } });
|
||||
expect(s.wizards.A.lastHurtBy).toBe('C');
|
||||
expect(enemyOf(s, 'A')).toBe('C');
|
||||
s = runSequence(s, 'A', 'SD', '--', { casts: [{ hand: 'left', spellId: 'missile' }] });
|
||||
expect(s.wizards.C.hp).toBe(14);
|
||||
// With Grey gone, the grudge lapses to the weakest opponent standing.
|
||||
s.wizards.C.alive = false;
|
||||
expect(enemyOf(s, 'A')).toBe('B');
|
||||
});
|
||||
|
||||
it('a wizard who charms two others names a gesture for each', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White', C: 'Grey' }, 1);
|
||||
// Both palms at once would be a surrender, so the two charms finish a turn apart.
|
||||
const left = 'PSDF-';
|
||||
const right = '-PSDF';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const casts = [];
|
||||
if (i === 3) casts.push({ hand: 'left' as const, spellId: 'charm_person' as const, target: 'B', chosenHand: 'left' as const });
|
||||
if (i === 4) casts.push({ hand: 'right' as const, spellId: 'charm_person' as const, target: 'C', chosenHand: 'left' as const });
|
||||
s = resolveTurn(s, {
|
||||
A: { ...noop(), left: left[i] as Gesture, right: right[i] as Gesture, casts, charmGestures: i === 4 ? { B: 'W' } : undefined },
|
||||
B: { ...noop(), left: 'D' },
|
||||
C: { ...noop(), left: 'D' }
|
||||
});
|
||||
}
|
||||
expect(s.wizards.B.history.at(-1)?.left).toBe('W');
|
||||
s = resolveTurn(s, { A: { ...noop(), charmGestures: { C: 'F' } }, B: { ...noop(), left: 'D' }, C: { ...noop(), left: 'D' } });
|
||||
expect(s.wizards.C.history.at(-1)?.left).toBe('F');
|
||||
expect(s.wizards.B.history.at(-1)?.left).toBe('D');
|
||||
});
|
||||
});
|
||||
|
||||
+15
-11
@@ -214,7 +214,8 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
return [prev.left, prev.right];
|
||||
}
|
||||
if (c.charmed) {
|
||||
const g = c.charmedGesture ?? input(c.charmed.by).charmGesture ?? '-';
|
||||
const charmer = input(c.charmed.by);
|
||||
const g = c.charmedGesture ?? charmer.charmGestures?.[w.id] ?? charmer.charmGesture ?? '-';
|
||||
if (w.permanent.charmed && !w.permanent.charmedGesture) w.permanent.charmedGesture = g;
|
||||
if (c.charmed.hand === 'left') left = g;
|
||||
else right = g;
|
||||
@@ -495,7 +496,8 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
};
|
||||
state.monsters.push(m);
|
||||
orders.set(m.id, c.choice.monsterTarget ?? enemyOf(state, controller));
|
||||
log(`A ${stats.name.toLowerCase()} answers ${W[controller].name}'s summons.`, 'spell');
|
||||
const creature = stats.name.toLowerCase();
|
||||
log(`${/^[aeiou]/.test(creature) ? 'An' : 'A'} ${creature} answers ${W[controller].name}'s summons.`, 'spell');
|
||||
}
|
||||
|
||||
let fireStorm = live().some((c) => c.spell.id === 'fire_storm');
|
||||
@@ -727,8 +729,9 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
const damage = new Map<TargetId, number>();
|
||||
const healing = new Map<TargetId, number>();
|
||||
const slain = new Set<TargetId>();
|
||||
const hurt = (id: TargetId, n: number, why: string) => {
|
||||
const hurt = (id: TargetId, n: number, why: string, by?: WizardId) => {
|
||||
damage.set(id, (damage.get(id) ?? 0) + n);
|
||||
if (isWizard(id) && by && by !== id) W[id].lastHurtBy = by;
|
||||
note(id, tidy(why), -n);
|
||||
log(`${beingName(state, id)} takes ${n} from ${why}.`, 'damage');
|
||||
};
|
||||
@@ -743,31 +746,32 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
if (isShielded(t)) {
|
||||
facts.shieldBlocked.add(t);
|
||||
log(`${guard(t)} turns the missile aside.`, 'fizzle');
|
||||
} else hurt(t, 1, 'a missile');
|
||||
} else hurt(t, 1, 'a missile', c.caster);
|
||||
break;
|
||||
case 'finger_of_death':
|
||||
slain.add(t);
|
||||
if (isWizard(t)) W[t].lastHurtBy = c.caster;
|
||||
note(t, 'Finger of death', -(isWizard(t) ? W[t].hp : 0));
|
||||
log(`The finger of death touches ${beingName(state, t)}.`, 'death');
|
||||
break;
|
||||
case 'lightning_bolt':
|
||||
case 'lightning_bolt_quick':
|
||||
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'a lightning bolt' });
|
||||
hurt(t, 5, 'a lightning bolt');
|
||||
hurt(t, 5, 'a lightning bolt', c.caster);
|
||||
break;
|
||||
case 'cause_light_wounds':
|
||||
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'cause light wounds' });
|
||||
hurt(t, 2, 'cause light wounds');
|
||||
hurt(t, 2, 'cause light wounds', c.caster);
|
||||
break;
|
||||
case 'cause_heavy_wounds':
|
||||
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'cause heavy wounds' });
|
||||
hurt(t, 3, 'cause heavy wounds');
|
||||
hurt(t, 3, 'cause heavy wounds', c.caster);
|
||||
break;
|
||||
case 'fireball':
|
||||
if (resists(t, 'heat')) log(`${beingName(state, t)} shrugs off the fireball.`, 'fizzle');
|
||||
else {
|
||||
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'a fireball' });
|
||||
hurt(t, 5, 'a fireball');
|
||||
hurt(t, 5, 'a fireball', c.caster);
|
||||
}
|
||||
break;
|
||||
case 'cure_light_wounds':
|
||||
@@ -825,7 +829,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
if (isShielded(id)) continue;
|
||||
if (isWizard(id) && W[id].invisibleTurns > 0) continue;
|
||||
}
|
||||
hurt(id, m.attack, `the ${m.name.toLowerCase()}`);
|
||||
hurt(id, m.attack, `the ${m.name.toLowerCase()}`, m.owner);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -842,7 +846,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
log(`${m.name} flails at the air where ${W[target].name} used to be.`, 'fizzle');
|
||||
} else {
|
||||
if (m.createdTurn === turn && phase === 'main') facts.newMonsterAttacked = m.name;
|
||||
hurt(target, m.attack, `the ${m.name.toLowerCase()}`);
|
||||
hurt(target, m.attack, `the ${m.name.toLowerCase()}`, m.owner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -859,7 +863,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
facts.shieldBlocked.add(target);
|
||||
log(`${w.name}'s knife skids off ${guard(target)}.`, 'fizzle');
|
||||
} else {
|
||||
hurt(target, 1, `${w.name}'s knife`);
|
||||
hurt(target, 1, `${w.name}'s knife`, id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+15
-3
@@ -81,6 +81,8 @@ export interface WizardState {
|
||||
permanencyTurns: number;
|
||||
banked: BankedSpell | null;
|
||||
usedQuickLightning: boolean;
|
||||
/** The wizard whose spell, creature or knife hurt this one most recently. */
|
||||
lastHurtBy?: WizardId;
|
||||
}
|
||||
|
||||
export interface Monster {
|
||||
@@ -171,7 +173,9 @@ export interface TurnInput {
|
||||
release?: { target?: TargetId; monsterTarget?: TargetId };
|
||||
stabTarget?: TargetId;
|
||||
monsterOrders?: Record<string, TargetId>;
|
||||
/** When this wizard charmed the opponent last turn: the gesture the charmed hand makes. */
|
||||
/** When this wizard charmed another last turn: the gesture the charmed hand makes, by charmed wizard. */
|
||||
charmGestures?: Record<WizardId, Gesture>;
|
||||
/** The same for one charmed wizard, when only one was charmed. */
|
||||
charmGesture?: Gesture;
|
||||
}
|
||||
|
||||
@@ -190,9 +194,17 @@ export function opponentsOf(state: GameState, id: WizardId): WizardId[] {
|
||||
return state.seats.filter((s) => s !== id && inDuel(state.wizards[s]));
|
||||
}
|
||||
|
||||
/** The wizard a spell goes at when no target is named: the first opponent still standing. */
|
||||
/**
|
||||
* The wizard a spell goes at when no target is named: whoever hurt this one
|
||||
* most recently if they still stand, else the weakest opponent standing, else
|
||||
* anyone else at the table.
|
||||
*/
|
||||
export function enemyOf(state: GameState, id: WizardId): WizardId {
|
||||
return opponentsOf(state, id)[0] ?? state.seats.find((s) => s !== id) ?? id;
|
||||
const standing = opponentsOf(state, id);
|
||||
const grudge = state.wizards[id]?.lastHurtBy;
|
||||
if (grudge && standing.includes(grudge)) return grudge;
|
||||
if (standing.length) return [...standing].sort((a, b) => state.wizards[a].hp - state.wizards[b].hp)[0];
|
||||
return state.seats.find((s) => s !== id) ?? id;
|
||||
}
|
||||
|
||||
function makeWizard(id: WizardId, name: string): WizardState {
|
||||
|
||||
@@ -19,8 +19,11 @@ export function viewFor(state: GameState, viewer: WizardId): GameState {
|
||||
export interface RoomView {
|
||||
roomId: string;
|
||||
seq: number;
|
||||
/** The most seats the table will take. */
|
||||
size: number;
|
||||
me: WizardId;
|
||||
/** The wizard who opened the table and may begin the duel. */
|
||||
host: WizardId;
|
||||
seats: { id: WizardId; name: string; bot: boolean }[];
|
||||
/** Seats that still have to move before the turn resolves. */
|
||||
waitingOn: WizardId[];
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
<section class="lobby">
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
<h2>Take a seat</h2>
|
||||
<p>Another wizard has opened this duel and is waiting for an opponent.</p>
|
||||
<p>A wizard has opened this table and is gathering opponents.</p>
|
||||
<form class="rename" onsubmit={takeSeat}>
|
||||
<label class="field">
|
||||
<span>Your name</span>
|
||||
@@ -123,13 +123,34 @@
|
||||
</form>
|
||||
</section>
|
||||
{:else if room && lobby}
|
||||
{@const r = room.remote!}
|
||||
{@const hostName = r.seats.find((s) => s.id === r.host)?.name ?? 'the host'}
|
||||
<section class="lobby">
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
<h2>Waiting for {room.remote!.size - room.remote!.seats.length === 1 ? 'one more wizard' : `${room.remote!.size - room.remote!.seats.length} more wizards`}</h2>
|
||||
<p>Send this link, or the code. Whoever opens it takes the empty seat, and the duel begins.</p>
|
||||
<h2>The table is laid</h2>
|
||||
<p>Send this link, or the code. Whoever opens it takes a seat; the table holds a dozen.</p>
|
||||
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
|
||||
<p class="muted">so far: {room.remote!.seats.map((s) => s.name).join(', ')}</p>
|
||||
<p><button type="button" class="quiet" onclick={() => room?.addBot()}>Seat the bot instead</button></p>
|
||||
<ul class="roster">
|
||||
{#each r.seats as s (s.id)}
|
||||
<li>
|
||||
<span class="seat-name">{s.name}</span>
|
||||
<span class="muted">{s.id === r.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === r.seat ? ', you' : ''}</span>
|
||||
</li>
|
||||
{/each}
|
||||
<li class="empty muted">{r.size - r.seats.length} of {r.size} seats empty</li>
|
||||
</ul>
|
||||
<div class="ways">
|
||||
{#if r.seats.length < r.size}
|
||||
<button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button>
|
||||
{/if}
|
||||
{#if room.isHost}
|
||||
<button type="button" class="reveal small" disabled={r.seats.length < 2} onclick={() => room?.begin()}>Begin the duel</button>
|
||||
<span class="muted">{r.seats.length < 2 ? 'once a second wizard is seated' : `with ${r.seats.length} wizards`}</span>
|
||||
{:else}
|
||||
<span class="muted">{hostName} will begin the duel when the table is ready.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if r.error}<p class="notice">{r.error}</p>{/if}
|
||||
</section>
|
||||
{:else if room}
|
||||
<Board duel={room} />
|
||||
@@ -245,6 +266,39 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.roster {
|
||||
list-style: none;
|
||||
margin: 0.9rem 0 0.6rem;
|
||||
padding: 0;
|
||||
max-width: 28em;
|
||||
}
|
||||
|
||||
.roster li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.roster li.empty {
|
||||
border-bottom: 0;
|
||||
justify-content: flex-start;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.seat-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ways {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.9rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.link-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user