diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 7d0ef53..c2bcfcd 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -16,7 +16,7 @@ README. with no look-alikes, and its link is `/join/CODE`. The masthead button reads *invite friends · room CODE* and copies the link. Whoever opened the table is **the host**; they may *Seat a bot* and they *Begin* when the - company suits them. A full table begins by itself. + company suits them, at any size of table; there is no automatic start. - **The Peanut Gallery** is where a link takes anyone without a seat: a read-only view showing only what every seat could see, counted for the table (*3 in the gallery*). While the table is laid, a watcher is offered diff --git a/template/deploy/__SLUG__-rollup.sh b/template/deploy/__SLUG__-rollup.sh index aab576f..f32b3ea 100755 --- a/template/deploy/__SLUG__-rollup.sh +++ b/template/deploy/__SLUG__-rollup.sh @@ -79,6 +79,7 @@ for f in glob.glob("/var/lib/__SLUG__/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 game finished today: its last turn is today's and the engine would say so; approximate by replay-free means: diff --git a/template/deploy/replay-ledgers.ts b/template/deploy/replay-ledgers.ts index ab4d672..0562095 100644 --- a/template/deploy/replay-ledgers.ts +++ b/template/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 = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1); else if (line.t === 'turn' && state) { state = game.resolve(state, line.inputs); diff --git a/template/deploy/visitors.sh b/template/deploy/visitors.sh index dd69539..55f5c34 100755 --- a/template/deploy/visitors.sh +++ b/template/deploy/visitors.sh @@ -86,6 +86,8 @@ for f in glob.glob("/var/lib/__SLUG__/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/template/server/src/index.ts b/template/server/src/index.ts index 3d1cadf..a38827a 100644 --- a/template/server/src/index.ts +++ b/template/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 game begins) // POST /api/rooms/:id/begin {token} the host begins with the players seated so far +// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game 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 @@ -193,6 +194,10 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise rooms.addBot(room, token); return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); } + if (action === 'unseat') { + rooms.unseat(room, token, body.seat); + return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); + } if (action === 'begin') { rooms.begin(room, token); return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index 0736f1d..604051a 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -148,7 +148,7 @@ export class Rooms { return this.sit(room, name, false); } - /** The host, who opened the table, begins once enough are seated. A full table begins by itself. */ + /** The host, who opened the table, begins once enough 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 player who opened the table may begin.', 403); @@ -170,12 +170,22 @@ export class Rooms { if (room.seats.length >= room.size) throw new RoomError('Every seat at this table is taken.', 409); const name = cleanName(rawName); if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another player here already has that name.', 409); - const seat: Seat = { id: this.game.seatIds[room.seats.length], name, token: bot ? '' : newId(18), bot }; + const id = this.game.seatIds.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: this.game.currentRules }); return seat; } + /** 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 (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); + this.commit(room, { t: 'unseat', id: seat.id }); + } + /** Record a seat's move; resolve the round once nobody else is awaited. */ submit(room: Room, token: string | undefined, raw: unknown): void { const seat = this.seatOf(room, token); @@ -287,6 +297,9 @@ export class Rooms { 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 = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1); break; diff --git a/template/server/src/store.ts b/template/server/src/store.ts index 97cdae1..1c2906a 100644 --- a/template/server/src/store.ts +++ b/template/server/src/store.ts @@ -9,6 +9,8 @@ import type { SeatId } from '../../src/lib/game/spec'; export type LedgerLine = | { t: 'room'; id: string; seats: number; createdAt: number } | { t: 'seat'; id: SeatId; name: string; token: string; bot: boolean } + /** A bot sent away by the host before the game began; its seat id is free again. */ + | { t: 'unseat'; id: SeatId } /** 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/template/src/lib/components/Lobby.svelte b/template/src/lib/components/Lobby.svelte index 461d577..2c78844 100644 --- a/template/src/lib/components/Lobby.svelte +++ b/template/src/lib/components/Lobby.svelte @@ -31,14 +31,15 @@ {:else}

room {v.roomId}

The table is laid

-

Send this link, or the code. Whoever opens it takes a seat. Anyone who arrives once the game has begun watches from the Peanut Gallery.

+

Send this link, or the code. Whoever opens it takes a seat, and the game begins when you say. Anyone who arrives once it has begun watches from the Peanut Gallery.

{/if}
    {#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' : ''} + {s.id === v.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost} + {/if}
  • {/each}
  • {v.size - v.seats.length} of {v.size} seats empty{#if v.audience > 0}; {v.audience} in the gallery{/if}
  • @@ -101,6 +102,20 @@ font-weight: 500; } + .unseat { + background: none; + border: 0; + color: var(--bone-faint); + font-size: 1.1rem; + padding: 0 0.3rem; + margin-left: 0.3rem; + line-height: 1; + } + + .unseat:hover { + color: var(--blood); + } + .ways { display: flex; flex-wrap: wrap; diff --git a/template/src/lib/net/client.ts b/template/src/lib/net/client.ts index daf7208..ab78d28 100644 --- a/template/src/lib/net/client.ts +++ b/template/src/lib/net/client.ts @@ -88,6 +88,7 @@ export function makeApi() { 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 }), + unseat: (roomId: string, token: string, seat: SeatId) => call('POST', `/api/rooms/${roomId}/unseat`, { token, seat }), view: (roomId: string, token: string) => call('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`), /** The gallery's view: no token, no seat, nothing any player could not see. */ watch: (roomId: string) => call('GET', `/api/rooms/${roomId}`), diff --git a/template/src/lib/net/room.svelte.ts b/template/src/lib/net/room.svelte.ts index 7f47af4..c3d382b 100644 --- a/template/src/lib/net/room.svelte.ts +++ b/template/src/lib/net/room.svelte.ts @@ -104,6 +104,11 @@ export class Room { return this.act(() => api.addBot(this.roomId, this.token), 'The bot could not be seated.'); } + /** Send a bot away before the game begins; only the host may. */ + unseat(seat: SeatId): Promise { + return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.'); + } + say(text: string): Promise { if (this.spectating) return Promise.resolve(false); return this.act(() => api.say(this.roomId, this.token, text), 'The table did not hear you.'); @@ -125,11 +130,12 @@ export class Room { return new Room(seated.view, seated.token); } - /** A room with a bot already seated, so a solo game is a ledger like any other. */ + /** A room with a bot seated and the game begun, so a solo game is a ledger like any other. */ static async createWithBot(name: string): Promise { const seated = await api.create(name, 2); rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token }); - return new Room(await api.addBot(seated.view.roomId, seated.token), seated.token); + await api.addBot(seated.view.roomId, seated.token); + return new Room(await api.begin(seated.view.roomId, seated.token), seated.token); } static async join(roomId: string, name: string): Promise { diff --git a/template/src/routes/guide/+page.svelte b/template/src/routes/guide/+page.svelte index 631b99e..ab4ca1b 100644 --- a/template/src/routes/guide/+page.svelte +++ b/template/src/routes/guide/+page.svelte @@ -30,7 +30,7 @@

    The table

    -

    A table fills as players open the link. Whoever laid it is the host: they may seat a bot in any empty chair, and they begin the game when the company suits them. Once begun, the seats are closed.

    +

    A table fills as players open the link. Whoever laid it is the host: they may seat a bot in any empty chair or send one away, and they begin the game when the company suits them, however full the table. Once begun, the seats are closed.