A table may ask each seat to rank its choices before the deal, in secret; the start line keeps the rankings and create reads them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-29 12:17:18 -04:00
co-authored by Claude Opus 5.5
parent eca8c92964
commit 8c99126aee
12 changed files with 186 additions and 9 deletions
+23 -3
View File
@@ -49,6 +49,8 @@ export interface Room<State> {
state: State | null;
/** Inputs received for the round in progress, by seat. */
pending: Record<SeatId, unknown>;
/** Each seat's ranking of the table's choices, where the table asks for one; secret before the deal and after. */
rankings: Record<SeatId, string[]>;
/** 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<SeatId, unknown>;
chat: ChatLine[];
@@ -195,7 +197,7 @@ export class Rooms<State, Input> {
/** A table with nobody seated yet. */
private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], options: TableOptions, seq = 0): Room<State> {
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<State, Input> {
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<State, Input> {
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<State>, 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<State, Input> {
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<State, Input> {
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;