diff --git a/deploy/replay-ledgers.ts b/deploy/replay-ledgers.ts index aa53731..ba4cbd7 100644 --- a/deploy/replay-ledgers.ts +++ b/deploy/replay-ledgers.ts @@ -38,6 +38,7 @@ async function main(): Promise { try { for (const line of lines) { if (line.t === 'seat') seats.push(line); + else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1); else if (line.t === 'start') state = createGame(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1); else if (line.t === 'turn' && state) { state = resolveTurn(state, line.inputs as Record); diff --git a/deploy/visitors.sh b/deploy/visitors.sh index ff6144a..cdddbfb 100755 --- a/deploy/visitors.sh +++ b/deploy/visitors.sh @@ -87,6 +87,8 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"): if x["t"] == "seat": (bots_seated if x["bot"] else humans).append(x["name"]) names[x["id"]] = x["name"] + elif x["t"] == "unseat": + bots_seated.remove(names.pop(x["id"], None)) if x["id"] in names else None elif x["t"] == "start": started = True elif x["t"] == "turn": diff --git a/deploy/waving-hands-rollup.sh b/deploy/waving-hands-rollup.sh index 31e9035..9790623 100755 --- a/deploy/waving-hands-rollup.sh +++ b/deploy/waving-hands-rollup.sh @@ -79,6 +79,7 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"): if x["t"] == "seat": if x["bot"]: bots += 1 else: humans.add(x["name"]) + elif x["t"] == "unseat": bots -= 1 day_turns = [x for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1] turns += len(day_turns) # A duel finished today: its last turn is today's and the engine would say so; approximate by replay-free means: diff --git a/server/src/index.ts b/server/src/index.ts index 6f4ade6..152347a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,6 +5,7 @@ // POST /api/rooms/:id/join {name} take the next seat // 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 +// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the duel begins // GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view // POST /api/rooms/:id/turn {token, input} this seat's move // POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room @@ -205,6 +206,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 === 'unseat') { + const token = typeof body.token === 'string' ? body.token : undefined; + rooms.unseat(room, token, body.seat); + const seat = rooms.seatOf(room, token); + 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); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 67f6b8e..ce812d8 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -136,7 +136,7 @@ export class Rooms { 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. */ + /** The host, who opened the table, begins the duel once at least two are seated, however full the table. */ 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); @@ -158,12 +158,22 @@ export class Rooms { if (room.seats.length >= room.size) throw new RoomError('Every seat at this duel is taken.', 409); const name = cleanName(rawName); 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 }; + const id = SEAT_IDS.find((s) => !room.seats.some((taken) => taken.id === s))!; + const seat: Seat = { id, 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: newSeed(), rules: CURRENT_RULES }); return seat; } + /** The host sends a bot away before the duel 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 wizard who opened the table may send a bot away.', 403); + if (room.state) throw new RoomError('The duel 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); + this.commit(room, { t: 'unseat', id: seat.id }); + } + /** Record a seat's move; resolve the turn once nobody else is awaited. */ submit(room: Room, token: string | undefined, raw: unknown): void { const seat = this.seatOf(room, token); @@ -278,6 +288,9 @@ function apply(room: Room, line: LedgerLine): void { case 'seat': room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot }); break; + case 'unseat': + room.seats = room.seats.filter((s) => s.id !== line.id); + break; case 'start': room.state = createGame(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1); break; diff --git a/server/src/store.ts b/server/src/store.ts index 3cd9608..63489a2 100644 --- a/server/src/store.ts +++ b/server/src/store.ts @@ -9,6 +9,8 @@ import type { TurnInput, WizardId } from '../../src/lib/game/state'; export type LedgerLine = | { t: 'room'; id: string; seats: number; createdAt: number } | { t: 'seat'; id: WizardId; name: string; token: string; bot: boolean } + /** A bot sent away by the host before the duel began; its seat id is free again. */ + | { t: 'unseat'; id: WizardId } /** Ledgers from before revisions were recorded resolve under rules 1. */ | { t: 'start'; seed: number; rules?: number } | { t: 'turn'; at: number; inputs: Record } diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index 7a045f1..96b1106 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -3,11 +3,13 @@ import HandPicker from './HandPicker.svelte'; import Ledger from './Ledger.svelte'; import MobileBar from './MobileBar.svelte'; + import Roster from './Roster.svelte'; import SpellSheet from './SpellSheet.svelte'; import TableTalk from './TableTalk.svelte'; import TurnSummary from './TurnSummary.svelte'; import WizardStatus from './WizardStatus.svelte'; import { castKey, listText, tokensText, type Duel } from '$lib/game/duel.svelte'; + import { seatColor } from '$lib/game/colors'; import { GESTURE_SHORT, glyph } from '$lib/game/glyphs'; import { MAGIC_GESTURES } from '$lib/game/spells'; @@ -16,6 +18,8 @@ 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); + /** Past three wizards the rail lists the others on a line each. */ + const crowded = $derived(duel.others.length >= 3);
@@ -58,11 +62,11 @@ {#if duel.threatsNow.length}

Their hands could finish this turn:

    - {#each duel.threatsNow as t (t.hand + t.spell.id)} + {#each duel.threatsNow as t (duel.threatKey(t))} {@const active = duel.isHighlighted(t)}
  • @@ -74,11 +78,11 @@
    Two gestures away ({duel.threatsNext.length})
      - {#each duel.threatsNext as t (t.hand + t.spell.id)} + {#each duel.threatsNext as t (duel.threatKey(t))} {@const active = duel.isHighlighted(t)}
    • @@ -118,9 +122,9 @@ {#if duel.stabbing}