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
@@ -140,6 +140,11 @@ if it were never there.
|
|||||||
own input in waiting (`yourInput`), so a page reopened shows the draft,
|
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
|
and its input in the last round it played (`yourLastInput`), so the page
|
||||||
can tell it what became of its own choice.
|
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.
|
- `tally(state)`: the game's own lines for the tally, from a finished game.
|
||||||
- `preferences`, `options`: the preferences panel's settings and the
|
- `preferences`, `options`: the preferences panel's settings and the
|
||||||
host's table choices (above).
|
host's table choices (above).
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ async function main(): Promise<void> {
|
|||||||
const i = seats.findIndex((s) => s.id === line.id);
|
const i = seats.findIndex((s) => s.id === line.id);
|
||||||
if (i >= 0) seats.splice(i, 1);
|
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) {
|
else if (line.t === 'turn' && state) {
|
||||||
state = game.resolve(state, line.inputs);
|
state = game.resolve(state, line.inputs);
|
||||||
turns += 1;
|
turns += 1;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins
|
// 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/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/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/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`;
|
// 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
|
// {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);
|
rooms.begin(room, token);
|
||||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
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') {
|
if (action === 'turn') {
|
||||||
rooms.submit(room, token, body.input, body.resolved);
|
rooms.submit(room, token, body.input, body.resolved);
|
||||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
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.view(room, 'B').chat[1]).toMatchObject({ id: 'A', to: ['B'], turn: 1 });
|
||||||
expect(() => rooms.say(room, bo, 'Anyone?', ['Q'])).toThrow(/Nobody at this table/);
|
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;
|
state: State | null;
|
||||||
/** Inputs received for the round in progress, by seat. */
|
/** Inputs received for the round in progress, by seat. */
|
||||||
pending: Record<SeatId, unknown>;
|
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. */
|
/** 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>;
|
played: Record<SeatId, unknown>;
|
||||||
chat: ChatLine[];
|
chat: ChatLine[];
|
||||||
@@ -195,7 +197,7 @@ export class Rooms<State, Input> {
|
|||||||
|
|
||||||
/** A table with nobody seated yet. */
|
/** 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> {
|
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. */
|
/** 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 (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.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);
|
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.
|
// A game whose first move belongs to a bot must not wait for a human who is not owed a move.
|
||||||
this.driveBots(room);
|
this.driveBots(room);
|
||||||
}
|
}
|
||||||
@@ -361,6 +363,18 @@ export class Rooms<State, Input> {
|
|||||||
this.commit(room, { t: 'unseat', id: seat.id });
|
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.
|
* 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.
|
* 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,
|
resolved: room.resolved,
|
||||||
over: room.state ? this.game.over(room.state) : null,
|
over: room.state ? this.game.over(room.state) : null,
|
||||||
state: room.state ? this.game.view(room.state, seatId) : 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,
|
yourInput: seated && seatId in room.pending ? room.pending[seatId] : null,
|
||||||
yourLastInput: seated && seatId in room.played ? room.played[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.
|
// 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':
|
case 'input':
|
||||||
room.pending[line.id] = this.game.cleanInput(line.input);
|
room.pending[line.id] = this.game.cleanInput(line.input);
|
||||||
break;
|
break;
|
||||||
|
case 'rank':
|
||||||
|
room.rankings[line.id] = line.ranking;
|
||||||
|
break;
|
||||||
case 'unseat':
|
case 'unseat':
|
||||||
room.seats = room.seats.filter((s) => s.id !== line.id);
|
room.seats = room.seats.filter((s) => s.id !== line.id);
|
||||||
|
delete room.rankings[line.id];
|
||||||
break;
|
break;
|
||||||
case 'start':
|
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;
|
break;
|
||||||
case 'turn':
|
case 'turn':
|
||||||
if (!room.state) return;
|
if (!room.state) return;
|
||||||
|
|||||||
@@ -15,8 +15,14 @@ export type LedgerLine =
|
|||||||
| { t: 'input'; at: number; id: SeatId; input: unknown }
|
| { 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. */
|
/** A bot sent away by the host before the game began; its seat id is free again. */
|
||||||
| { t: 'unseat'; id: SeatId }
|
| { t: 'unseat'; id: SeatId }
|
||||||
/** The seed and the rules revision the game began under; a later fix keeps this revision's path for it. */
|
/** A seat's ranking of the table's choices, best first, before the game begins; the latest for a seat stands. */
|
||||||
| { t: 'start'; seed: number; rules: number }
|
| { 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> }
|
| { t: 'turn'; at: number; inputs: Record<SeatId, unknown> }
|
||||||
/** Written after the turn that ends the game, for anything that reads ledgers without the engine. */
|
/** 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 }
|
| { t: 'over'; at: number; winner: SeatId | null; reason: string }
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export class Tallies<State, Input> {
|
|||||||
if (hasBot) t.gamesWithBot += 1;
|
if (hasBot) t.gamesWithBot += 1;
|
||||||
if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt;
|
if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt;
|
||||||
try {
|
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 {
|
} catch {
|
||||||
state = null;
|
state = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import TableTalk from './TableTalk.svelte';
|
import TableTalk from './TableTalk.svelte';
|
||||||
|
import { game } from '$lib/game';
|
||||||
import type { Room } from '$lib/net/room.svelte';
|
import type { Room } from '$lib/net/room.svelte';
|
||||||
|
|
||||||
let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props();
|
let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props();
|
||||||
@@ -8,6 +10,28 @@
|
|||||||
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
|
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
|
||||||
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
|
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
|
||||||
|
|
||||||
|
// A table that asks for rankings: this seat's, best first, kept on the server as it changes.
|
||||||
|
const choices = $derived(game.rankable?.(v.options) ?? null);
|
||||||
|
const people = $derived(v.seats.filter((s) => !s.bot).length);
|
||||||
|
const labelOf = (value: string) => choices?.find((c) => c.value === value)?.label ?? value;
|
||||||
|
let order = $state<string[]>([]);
|
||||||
|
// Follows the server's copy; the local order is read untracked, so a click is not undone before its reply.
|
||||||
|
$effect(() => {
|
||||||
|
if (!choices) return;
|
||||||
|
const values = choices.map((c) => c.value);
|
||||||
|
const mine = v.yourRanking;
|
||||||
|
if (mine && mine.length === values.length) order = [...mine];
|
||||||
|
else if (untrack(() => order.length) !== values.length) order = values;
|
||||||
|
});
|
||||||
|
/** Move the choice at `i` to place `j`, the others closing up behind it. */
|
||||||
|
async function move(i: number, j: number) {
|
||||||
|
if (j < 0 || j >= order.length || j === i) return;
|
||||||
|
const next = [...order];
|
||||||
|
next.splice(j, 0, ...next.splice(i, 1));
|
||||||
|
order = next;
|
||||||
|
await room.rank(next);
|
||||||
|
}
|
||||||
|
|
||||||
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
|
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
|
||||||
let minute = $state(0);
|
let minute = $state(0);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -62,6 +86,28 @@
|
|||||||
{#if v.rematchOf}
|
{#if v.rematchOf}
|
||||||
<p class="muted">A rematch of table {v.rematchOf}: the same company, seats held for those not yet back.</p>
|
<p class="muted">A rematch of table {v.rematchOf}: the same company, seats held for those not yet back.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if choices && !room.spectating}
|
||||||
|
<section class="ranking" aria-label="Your ranking">
|
||||||
|
<h3>Your ranking</h3>
|
||||||
|
<p class="muted small">
|
||||||
|
Best first. When the game begins, the deal reads everyone's ranking; nobody sees another player's.
|
||||||
|
{#if !v.yourRanking}Until you move one, you have no preference and take what is left.{:else}Your ranking is kept.{/if}
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
{#each order as value, i (value)}
|
||||||
|
<li>
|
||||||
|
<span class="place">{i + 1}.</span>
|
||||||
|
<span class="choice">{labelOf(value)}</span>
|
||||||
|
<button type="button" class="quiet" aria-label="Move {labelOf(value)} to the top" title="To the top" disabled={i === 0} onclick={() => move(i, 0)}>⤒</button>
|
||||||
|
<button type="button" class="quiet" aria-label="Move {labelOf(value)} up" disabled={i === 0} onclick={() => move(i, i - 1)}>↑</button>
|
||||||
|
<button type="button" class="quiet" aria-label="Move {labelOf(value)} down" disabled={i === order.length - 1} onclick={() => move(i, i + 1)}>↓</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{#if !v.yourRanking}<button type="button" class="quiet" onclick={() => room.rank(order)}>Keep this order</button>{/if}
|
||||||
|
{#if people > 1}<p class="muted small">{v.ranked} of the {people} players {v.ranked === 1 ? 'has' : 'have'} ranked.</p>{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
{#if !room.spectating}
|
{#if !room.spectating}
|
||||||
<div class="ways">
|
<div class="ways">
|
||||||
{#if room.seatEmpty && room.isHost}
|
{#if room.seatEmpty && room.isHost}
|
||||||
@@ -170,6 +216,45 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ranking {
|
||||||
|
max-width: 26rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking h3 {
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking ol {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking .place {
|
||||||
|
width: 1.6rem;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking .choice {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking .quiet {
|
||||||
|
min-width: 2.6rem;
|
||||||
|
min-height: 2.4rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.keeper {
|
.keeper {
|
||||||
margin-top: 0.8rem;
|
margin-top: 0.8rem;
|
||||||
max-width: 38em;
|
max-width: 38em;
|
||||||
|
|||||||
@@ -65,7 +65,17 @@ export interface GameSpec<State, Input> {
|
|||||||
*/
|
*/
|
||||||
currentRules: number;
|
currentRules: number;
|
||||||
|
|
||||||
create(names: Record<SeatId, string>, seed: number, rules: number, options?: TableOptions): State;
|
/**
|
||||||
|
* Begin the game. `rankings` are the seats' preferences, best first, at a
|
||||||
|
* table that asks for them (`rankable`); the ledger's start line keeps them.
|
||||||
|
*/
|
||||||
|
create(names: Record<SeatId, string>, seed: number, rules: number, options?: TableOptions, rankings?: Record<SeatId, string[]>): State;
|
||||||
|
/**
|
||||||
|
* Whether a table with these options asks each seat to rank something
|
||||||
|
* before the deal (sides, roles, colours): the choices to rank, or null.
|
||||||
|
* Each seat ranks in secret; `create` reads every ranking at once.
|
||||||
|
*/
|
||||||
|
rankable?(options: TableOptions): { value: string; label: string }[] | null;
|
||||||
/** Resolve one round. Must be a pure function of its arguments: the ledger is replayed through it. */
|
/** Resolve one round. Must be a pure function of its arguments: the ledger is replayed through it. */
|
||||||
resolve(state: State, inputs: Record<SeatId, Input>): State;
|
resolve(state: State, inputs: Record<SeatId, Input>): State;
|
||||||
/** Whether this seat's input is needed before the next resolution. */
|
/** Whether this seat's input is needed before the next resolution. */
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export function makeApi<State, Input>() {
|
|||||||
/** The gallery's view: no token, no seat, nothing any player could not see. */
|
/** The gallery's view: no token, no seat, nothing any player could not see. */
|
||||||
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
|
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
|
||||||
turn: (roomId: string, token: string, input: Input, resolved: number) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }),
|
turn: (roomId: string, token: string, input: Input, resolved: number) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }),
|
||||||
|
rank: (roomId: string, token: string, ranking: string[]) => call<View>('POST', `/api/rooms/${roomId}/rank`, { token, ranking }),
|
||||||
say: (roomId: string, token: string, text: string, to?: SeatId[]) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text, ...(to?.length ? { to } : {}) }),
|
say: (roomId: string, token: string, text: string, to?: SeatId[]) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text, ...(to?.length ? { to } : {}) }),
|
||||||
/** A word from the gallery, signed with a name, when the host allows it. */
|
/** A word from the gallery, signed with a name, when the host allows it. */
|
||||||
sayFromGallery: (roomId: string, name: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { name, text }),
|
sayFromGallery: (roomId: string, name: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { name, text }),
|
||||||
|
|||||||
@@ -112,6 +112,11 @@ export class Room {
|
|||||||
return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.');
|
return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** This seat's ranking of the table's choices, best first, before the game begins. */
|
||||||
|
rank(ranking: string[]): Promise<boolean> {
|
||||||
|
return this.act(() => api.rank(this.roomId, this.token, ranking), 'Your ranking was not kept.');
|
||||||
|
}
|
||||||
|
|
||||||
/** Talk to the whole table, or, naming seats, to them alone. */
|
/** Talk to the whole table, or, naming seats, to them alone. */
|
||||||
say(text: string, to?: SeatId[]): Promise<boolean> {
|
say(text: string, to?: SeatId[]): Promise<boolean> {
|
||||||
if (this.spectating) return Promise.resolve(false);
|
if (this.spectating) return Promise.resolve(false);
|
||||||
@@ -166,7 +171,9 @@ export class Room {
|
|||||||
static async createWithBot(name: string, options?: TableOptions): Promise<Room> {
|
static async createWithBot(name: string, options?: TableOptions): Promise<Room> {
|
||||||
const seated = await api.create(name, 2, options);
|
const seated = await api.create(name, 2, options);
|
||||||
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
||||||
await api.addBot(seated.view.roomId, seated.token);
|
const view = await api.addBot(seated.view.roomId, seated.token);
|
||||||
|
// A table that asks for rankings waits in the lobby for the player's; the rest begin at once.
|
||||||
|
if (game.rankable?.(view.options)) return new Room(view, seated.token);
|
||||||
return new Room(await api.begin(seated.view.roomId, seated.token), seated.token);
|
return new Room(await api.begin(seated.view.roomId, seated.token), seated.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export interface RoomView<State> {
|
|||||||
turn: number | null;
|
turn: number | null;
|
||||||
over: Outcome | null;
|
over: Outcome | null;
|
||||||
state: State | null;
|
state: State | null;
|
||||||
|
/** This seat's ranking of the table's choices, at a table that asks for one; nobody sees another's. */
|
||||||
|
yourRanking: string[] | null;
|
||||||
|
/** How many seats have ranked, and nothing more about them. */
|
||||||
|
ranked: number;
|
||||||
/** This seat's input for the round in progress, as the server holds it (a draft, or a settled move); null for none, and for the gallery. */
|
/** This seat's input for the round in progress, as the server holds it (a draft, or a settled move); null for none, and for the gallery. */
|
||||||
yourInput: unknown;
|
yourInput: unknown;
|
||||||
/** This seat's input in the last round it played, for telling it what became of its own choice; null for none, and for the gallery. */
|
/** This seat's input in the last round it played, for telling it what became of its own choice; null for none, and for the gallery. */
|
||||||
|
|||||||
Reference in New Issue
Block a user