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:
co-authored by
Claude Opus 5.5
parent
eca8c92964
commit
8c99126aee
@@ -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<void>
|
||||
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));
|
||||
|
||||
@@ -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<string, string[]>[] = [];
|
||||
const ranked: GameSpec<State, Input> = {
|
||||
...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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SeatId, string[]> }
|
||||
| { t: 'turn'; at: number; inputs: Record<SeatId, unknown> }
|
||||
/** 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 }
|
||||
|
||||
@@ -57,7 +57,7 @@ export class Tallies<State, Input> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user