diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 6118e2a..23b9588 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -140,6 +140,11 @@ if it were never there. own input in waiting (`yourInput`), so a page reopened shows the draft, and its input in the last round it played (`yourLastInput`), so the page can tell it what became of its own choice. +- `rankable(options)`: the choices a table asks each seat to rank before + the deal (sides, roles, colours). The lobby shows a seat its own ranking + and how many have ranked, nothing more; the start line records every + ranking and `create` receives them. A solo table asking for one waits in + the lobby for the player's. - `tally(state)`: the game's own lines for the tally, from a finished game. - `preferences`, `options`: the preferences panel's settings and the host's table choices (above). diff --git a/template/deploy/replay-ledgers.ts b/template/deploy/replay-ledgers.ts index 9a54795..96d0742 100644 --- a/template/deploy/replay-ledgers.ts +++ b/template/deploy/replay-ledgers.ts @@ -43,7 +43,7 @@ async function main(): Promise { const i = seats.findIndex((s) => s.id === line.id); if (i >= 0) seats.splice(i, 1); } - else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options); + else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options, line.rankings); else if (line.t === 'turn' && state) { state = game.resolve(state, line.inputs); turns += 1; diff --git a/template/server/src/index.ts b/template/server/src/index.ts index e334bfa..7c21143 100644 --- a/template/server/src/index.ts +++ b/template/server/src/index.ts @@ -7,6 +7,7 @@ // POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins // POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins // POST /api/rooms/:id/begin {token} the host begins with the players seated so far +// POST /api/rooms/:id/rank {token, ranking} a seat ranks the table's choices, best first, where the table asks for it // POST /api/rooms/:id/turn {token, input} this seat's move // POST /api/rooms/:id/say {token, text, to?} table talk, from a seat to the whole room, or privately to the seats in `to`; // {name, text} or from the gallery, signed, when the host allows it @@ -230,6 +231,10 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise rooms.begin(room, token); return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); } + if (action === 'rank') { + rooms.rank(room, token, body.ranking); + return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); + } if (action === 'turn') { rooms.submit(room, token, body.input, body.resolved); return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); diff --git a/template/server/src/rooms.test.ts b/template/server/src/rooms.test.ts index fdd3322..6d7afbe 100644 --- a/template/server/src/rooms.test.ts +++ b/template/server/src/rooms.test.ts @@ -90,4 +90,38 @@ describe('rooms', () => { expect(rooms.view(room, 'B').chat[1]).toMatchObject({ id: 'A', to: ['B'], turn: 1 }); expect(() => rooms.say(room, bo, 'Anyone?', ['Q'])).toThrow(/Nobody at this table/); }); + + it('keeps each ranking secret until the deal, which reads them all from the start line', () => { + const dealt: Record[] = []; + const ranked: GameSpec = { + ...game, + rankable: () => [ + { value: 'red', label: 'Red' }, + { value: 'blue', label: 'Blue' } + ], + create: (names, seed, rules, options, rankings) => { + dealt.push(rankings ?? {}); + return game.create(names, seed, rules, options); + } + }; + const rooms = new Rooms(ranked, new Store(dir)); + const host = rooms.create('Ann', 2); + const guest = rooms.join(host.room, 'Bo'); + const room = host.room; + expect(() => rooms.rank(room, host.token, ['red'])).toThrow(/every choice once/); + rooms.rank(room, host.token, ['blue', 'red']); + expect(rooms.view(room, 'A').yourRanking).toEqual(['blue', 'red']); + expect(rooms.view(room, 'B')).toMatchObject({ yourRanking: null, ranked: 1 }); + rooms.begin(room, host.token); + expect(dealt).toEqual([{ A: ['blue', 'red'] }]); + expect(() => rooms.rank(room, guest.token, ['red', 'blue'])).toThrow(/ranking is closed/); + new Rooms(ranked, new Store(dir)); + expect(dealt[1]).toEqual({ A: ['blue', 'red'] }); + }); + + it('refuses a ranking at a table that asks for none', () => { + const rooms = new Rooms(game, new Store(dir)); + const host = rooms.create('Ann', 2); + expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/); + }); }); diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index bb5fe5e..6ae2a16 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -49,6 +49,8 @@ export interface Room { state: State | null; /** Inputs received for the round in progress, by seat. */ pending: Record; + /** Each seat's ranking of the table's choices, where the table asks for one; secret before the deal and after. */ + rankings: Record; /** Each seat's input in the last round it played, kept through rounds it had no part in; each seat is shown only its own. */ played: Record; chat: ChatLine[]; @@ -195,7 +197,7 @@ export class Rooms { /** A table with nobody seated yet. */ private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], options: TableOptions, seq = 0): Room { - return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, over: false, state: null, pending: {}, played: {}, chat: [], resolved: 0, seq, updatedAt: createdAt }; + return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, over: false, state: null, pending: {}, played: {}, rankings: {}, chat: [], resolved: 0, seq, updatedAt: createdAt }; } /** Open a table with its first seat taken; a rematch names the table it follows and holds seats for its players. */ @@ -307,7 +309,7 @@ export class Rooms { if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may begin.', 403); if (room.state) throw new RoomError('The game has already begun.', 409); if (room.seats.length < this.game.minSeats) throw new RoomError(`The game needs at least ${this.game.minSeats} players.`, 409); - this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules }); + this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules, ...(Object.keys(room.rankings).length ? { rankings: { ...room.rankings } } : {}) }); // A game whose first move belongs to a bot must not wait for a human who is not owed a move. this.driveBots(room); } @@ -361,6 +363,18 @@ export class Rooms { this.commit(room, { t: 'unseat', id: seat.id }); } + /** A seat's ranking of the choices the table asks for, best first, before the game begins. */ + rank(room: Room, token: string | undefined, raw: unknown): void { + const seat = this.seatOf(room, token); + if (room.state) throw new RoomError('The game has begun; the ranking is closed.', 409); + const choices = this.game.rankable?.(room.options); + if (!choices) throw new RoomError('This table asks for no ranking.', 409); + const ranking = Array.isArray(raw) ? raw.map(String) : []; + const values = choices.map((c) => c.value); + if (ranking.length !== values.length || new Set(ranking).size !== values.length || !ranking.every((r) => values.includes(r))) throw new RoomError('Rank every choice once.'); + this.commit(room, { t: 'rank', id: seat.id, ranking }); + } + /** * Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited. * A draft (an input the game does not call settled) may be replaced until it is settled. @@ -442,6 +456,8 @@ export class Rooms { resolved: room.resolved, over: room.state ? this.game.over(room.state) : null, state: room.state ? this.game.view(room.state, seatId) : null, + yourRanking: seated ? (room.rankings[seatId] ?? null) : null, + ranked: Object.keys(room.rankings).length, yourInput: seated && seatId in room.pending ? room.pending[seatId] : null, yourLastInput: seated && seatId in room.played ? room.played[seatId] : null, // Private lines only to their sender and the seats they name; a note is the sender's own memory, never sent. @@ -518,11 +534,15 @@ export class Rooms { case 'input': room.pending[line.id] = this.game.cleanInput(line.input); break; + case 'rank': + room.rankings[line.id] = line.ranking; + break; case 'unseat': room.seats = room.seats.filter((s) => s.id !== line.id); + delete room.rankings[line.id]; break; case 'start': - room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules, room.options); + room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules, room.options, line.rankings); break; case 'turn': if (!room.state) return; diff --git a/template/server/src/store.ts b/template/server/src/store.ts index 16e1b4b..ea1ab88 100644 --- a/template/server/src/store.ts +++ b/template/server/src/store.ts @@ -15,8 +15,14 @@ export type LedgerLine = | { t: 'input'; at: number; id: SeatId; input: unknown } /** A bot sent away by the host before the game began; its seat id is free again. */ | { t: 'unseat'; id: SeatId } - /** The seed and the rules revision the game began under; a later fix keeps this revision's path for it. */ - | { t: 'start'; seed: number; rules: number } + /** A seat's ranking of the table's choices, best first, before the game begins; the latest for a seat stands. */ + | { t: 'rank'; id: SeatId; ranking: string[] } + /** + * The seed and the rules revision the game began under (a later fix keeps + * this revision's path for it), and at a table that asks for rankings, + * the seats' rankings as the deal read them. + */ + | { t: 'start'; seed: number; rules: number; rankings?: Record } | { t: 'turn'; at: number; inputs: Record } /** Written after the turn that ends the game, for anything that reads ledgers without the engine. */ | { t: 'over'; at: number; winner: SeatId | null; reason: string } diff --git a/template/server/src/tally.ts b/template/server/src/tally.ts index c8a23d4..b7217d3 100644 --- a/template/server/src/tally.ts +++ b/template/server/src/tally.ts @@ -57,7 +57,7 @@ export class Tallies { if (hasBot) t.gamesWithBot += 1; if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt; try { - state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, cleanOptions(this.game, lines[0].options)); + state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, cleanOptions(this.game, lines[0].options), line.rankings); } catch { state = null; } diff --git a/template/src/lib/components/Lobby.svelte b/template/src/lib/components/Lobby.svelte index 301a482..2be0f26 100644 --- a/template/src/lib/components/Lobby.svelte +++ b/template/src/lib/components/Lobby.svelte @@ -1,5 +1,7 @@