diff --git a/server/src/index.ts b/server/src/index.ts index b01fbfb..67ae11d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 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); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 1882ba3..67dce19 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -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 diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index 96d3f23..1c12526 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -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 : '');
@@ -35,6 +33,12 @@ {/if} {:else} + {#if duel.out} +
+

{duel.you.alive ? 'You have yielded' : 'You have fallen'}

+

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.

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

Time stands still. Take your extra turn.

@@ -162,23 +166,23 @@ {/if} - {#if duel.youCharmedFoe} + {#each duel.charmedFoes as id (id)} - {/if} + {/each} {#if duel.remote?.error}

{duel.remote.error}

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

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

+

Your move is in. Waiting for {duel.awaitingText}.

{/if} {#if duel.surrendering}

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

@@ -191,6 +195,7 @@ {duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Reveal gestures'}
+ {/if} {/if} diff --git a/src/lib/components/Hall.svelte b/src/lib/components/Hall.svelte index 1635fba..4d4b956 100644 --- a/src/lib/components/Hall.svelte +++ b/src/lib/components/Hall.svelte @@ -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 @@

Waving Hands

Two wizards. Two hands each. The right run of gestures is a spell.

-

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.

+

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.

(aboutOpen = true)}>about this game — a labor of love
@@ -128,7 +128,7 @@ {/if}

- +
or join one diff --git a/src/lib/components/Ledger.svelte b/src/lib/components/Ledger.svelte index b966cde..bc8cf54 100644 --- a/src/lib/components/Ledger.svelte +++ b/src/lib/components/Ledger.svelte @@ -79,14 +79,14 @@ Turn {#each columns as who (who)} - {duel.state.wizards[who].name}{#if who === duel.me} (you){/if} + {duel.state.wizards[who].name}{#if who === duel.me} (you){/if} {/each} {#each columns as who (who)} - left - right + left + right {/each} @@ -96,7 +96,7 @@ {row.label}{#if row.note}{row.note}{/if} {#each columns as who (who)} {#each ['left', 'right'] as const as hand (hand)} - + {#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 @@ {duel.timeStopped ? '' : duel.turnNumber}{#if duel.timeStopped}time stop{/if} {#each ['left', 'right'] as const as hand (hand)} - + {#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 @@ diff --git a/src/lib/components/MobileBar.svelte b/src/lib/components/MobileBar.svelte index d82b140..8ffeeab 100644 --- a/src/lib/components/MobileBar.svelte +++ b/src/lib/components/MobileBar.svelte @@ -8,9 +8,12 @@ {#if !duel.state.over}
-
+
1}> {duel.you.name} {duel.you.hp}/{MAX_HP} - {duel.foe.name} {duel.foe.hp}/{MAX_HP} + {#each duel.others as id (id)} + {@const w = duel.state.wizards[id]} + {w.name} {w.hp}/{MAX_HP} + {/each}
{#each HANDS as hand (hand)} @@ -18,7 +21,7 @@ {/each}
{/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; diff --git a/src/lib/components/SpellSheet.svelte b/src/lib/components/SpellSheet.svelte index 1258e8e..ee59ad1 100644 --- a/src/lib/components/SpellSheet.svelte +++ b/src/lib/components/SpellSheet.svelte @@ -42,7 +42,7 @@ onclick={() => duel.follow(hand, plan)} > Plan with {hand} hand - {#if plan.done.length}{tokensText(plan.done)} then {/if}{tokensText(plan.remaining)} ({plan.remaining.length} more) + {#if plan.done.length}{tokensText(plan.done)} then {/if}{tokensText(plan.remaining)} ({plan.remaining.length} more) {/each}
diff --git a/src/lib/game/bot.ts b/src/lib/game/bot.ts index 71d7dde..8d71394 100644 --- a/src/lib/game/bot.ts +++ b/src/lib/game/bot.ts @@ -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; } diff --git a/src/lib/game/client.ts b/src/lib/game/client.ts index dc9a58a..37d120f 100644 --- a/src/lib/game/client.ts +++ b/src/lib/game/client.ts @@ -72,6 +72,7 @@ export const api = { create: (name: string, size: number) => call('POST', '/api/rooms', { name, size }), join: (roomId: string, name: string) => call('POST', `/api/rooms/${roomId}/join`, { name }), addBot: (roomId: string, token: string) => call('POST', `/api/rooms/${roomId}/bot`, { token }), + begin: (roomId: string, token: string) => call('POST', `/api/rooms/${roomId}/begin`, { token }), view: (roomId: string, token: string) => call('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`), turn: (roomId: string, token: string, input: TurnInput) => call('POST', `/api/rooms/${roomId}/turn`, { token, input }) }; diff --git a/src/lib/game/duel.svelte.ts b/src/lib/game/duel.svelte.ts index 507180c..4cbc0d6 100644 --- a/src/lib/game/duel.svelte.ts +++ b/src/lib/game/duel.svelte.ts @@ -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>({}); /** Narrow screens get shorter lists and a sticky bar; the page sets this from a media query. */ compact = $state(false); - charmGesture = $state('P'); + /** The gesture each wizard you charmed will make, by their seat. */ + charmGestures = $state>({}); /** Where to loose the banked spell this turn; empty keeps it. */ release = $state(''); /** 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(this.hasted ? ['main', 'haste'] : ['main']); @@ -336,9 +349,10 @@ export class Duel { return set === 'haste' ? this.forcedSecond[hand] : this.forced[hand]; } charmedHand = $derived(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 { + 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 { 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 { + /** 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 { 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, diff --git a/src/lib/game/engine.test.ts b/src/lib/game/engine.test.ts index 64b7681..0c11191 100644 --- a/src/lib/game/engine.test.ts +++ b/src/lib/game/engine.test.ts @@ -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'); + }); +}); diff --git a/src/lib/game/resolve.ts b/src/lib/game/resolve.ts index ffedf35..25ea434 100644 --- a/src/lib/game/resolve.ts +++ b/src/lib/game/resolve.ts @@ -214,7 +214,8 @@ export function resolveTurn(previous: GameState, inputs: Record c.spell.id === 'fire_storm'); @@ -727,8 +729,9 @@ export function resolveTurn(previous: GameState, inputs: Record(); const healing = new Map(); const slain = new Set(); - 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 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; - /** 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; + /** 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 { diff --git a/src/lib/game/view.ts b/src/lib/game/view.ts index 18dc937..bf05173 100644 --- a/src/lib/game/view.ts +++ b/src/lib/game/view.ts @@ -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[]; diff --git a/src/routes/join/[code]/+page.svelte b/src/routes/join/[code]/+page.svelte index 7d81335..e5939c5 100644 --- a/src/routes/join/[code]/+page.svelte +++ b/src/routes/join/[code]/+page.svelte @@ -113,7 +113,7 @@

room {roomId}

Take a seat

-

Another wizard has opened this duel and is waiting for an opponent.

+

A wizard has opened this table and is gathering opponents.

{:else if room && lobby} + {@const r = room.remote!} + {@const hostName = r.seats.find((s) => s.id === r.host)?.name ?? 'the host'}

room {roomId}

-

Waiting for {room.remote!.size - room.remote!.seats.length === 1 ? 'one more wizard' : `${room.remote!.size - room.remote!.seats.length} more wizards`}

-

Send this link, or the code. Whoever opens it takes the empty seat, and the duel begins.

+

The table is laid

+

Send this link, or the code. Whoever opens it takes a seat; the table holds a dozen.

-

so far: {room.remote!.seats.map((s) => s.name).join(', ')}

-

+
    + {#each r.seats as s (s.id)} +
  • + {s.name} + {s.id === r.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === r.seat ? ', you' : ''} +
  • + {/each} +
  • {r.size - r.seats.length} of {r.size} seats empty
  • +
+
+ {#if r.seats.length < r.size} + + {/if} + {#if room.isHost} + + {r.seats.length < 2 ? 'once a second wizard is seated' : `with ${r.seats.length} wizards`} + {:else} + {hostName} will begin the duel when the table is ready. + {/if} +
+ {#if r.error}

{r.error}

{/if}
{:else if 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;