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
+85
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import TableTalk from './TableTalk.svelte';
import { game } from '$lib/game';
import type { Room } from '$lib/net/room.svelte';
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 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. */
let minute = $state(0);
$effect(() => {
@@ -62,6 +86,28 @@
{#if v.rematchOf}
<p class="muted">A rematch of table {v.rematchOf}: the same company, seats held for those not yet back.</p>
{/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}
<div class="ways">
{#if room.seatEmpty && room.isHost}
@@ -170,6 +216,45 @@
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 {
margin-top: 0.8rem;
max-width: 38em;
+11 -1
View File
@@ -65,7 +65,17 @@ export interface GameSpec<State, Input> {
*/
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(state: State, inputs: Record<SeatId, Input>): State;
/** Whether this seat's input is needed before the next resolution. */
+1
View File
@@ -132,6 +132,7 @@ export function makeApi<State, Input>() {
/** The gallery's view: no token, no seat, nothing any player could not see. */
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 }),
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 } : {}) }),
/** 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 }),
+8 -1
View File
@@ -112,6 +112,11 @@ export class Room {
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. */
say(text: string, to?: SeatId[]): Promise<boolean> {
if (this.spectating) return Promise.resolve(false);
@@ -166,7 +171,9 @@ export class Room {
static async createWithBot(name: string, options?: TableOptions): Promise<Room> {
const seated = await api.create(name, 2, options);
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);
}
+4
View File
@@ -35,6 +35,10 @@ export interface RoomView<State> {
turn: number | null;
over: Outcome | 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. */
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. */