Rematches, seats that travel by phrase, the keeper's bell with the hour where they live, the host's leave for the gallery to talk, and seats doubted before they are dropped

Ported from Wiz-War, where each earned its keep. All on the ledger:
galleryTalk, challenge and rematch lines, gallery chat signed with a
name, held seats on a rematch table's room line. Twenty-two protocol
checks against a scratch server and a replay after restart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-09-23 12:19:03 -04:00
1 parent 65d62f2ab3
commit 6dce3b82f0
14 files changed
+521 -28

No files matched your search

+56
View File
@@ -38,6 +38,54 @@ export function forgetSeat(roomId: string): void {
}
}
/**
* A seat the server refused is doubted, not dropped: a restarting server, a
* stale answer or the wrong backend all answer with ignorance, and ignorance
* is not deletion. Only the SECOND refusal in a row costs the seat.
*/
const DOUBT_KEY = '__SLUG__:doubted';
function doubted(): Record<string, true> {
try {
return JSON.parse(localStorage.getItem(DOUBT_KEY) ?? '{}') as Record<string, true>;
} catch {
return {};
}
}
/** Note a refusal; true when it is the second in a row and the seat is forgotten. */
export function doubtSeat(roomId: string): boolean {
const all = doubted();
if (all[roomId]) {
forgetSeat(roomId);
delete all[roomId];
try {
localStorage.setItem(DOUBT_KEY, JSON.stringify(all));
} catch {
// Nothing to note without storage.
}
return true;
}
try {
localStorage.setItem(DOUBT_KEY, JSON.stringify({ ...all, [roomId]: true }));
} catch {
// Nothing to note without storage.
}
return false;
}
/** The server answered for this seat: any doubt is cleared. */
export function trustSeat(roomId: string): void {
const all = doubted();
if (!all[roomId]) return;
delete all[roomId];
try {
localStorage.setItem(DOUBT_KEY, JSON.stringify(all));
} catch {
// Nothing to clear without storage.
}
}
export function rememberSeat(roomId: string, held: HeldSeat): void {
try {
localStorage.setItem(SEATS_KEY, JSON.stringify({ ...heldSeats(), [roomId]: held }));
@@ -94,6 +142,14 @@ export function makeApi<State, Input>() {
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
turn: (roomId: string, token: string, input: Input) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input }),
say: (roomId: string, token: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text }),
/** 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 }),
setGalleryTalk: (roomId: string, token: string, on: boolean) => call<View>('POST', `/api/rooms/${roomId}/gallery`, { token, on }),
challenge: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/challenge`, { token }),
rematch: (roomId: string, token: string) =>
call<{ roomId: string; seat: SeatId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }),
transfer: (roomId: string, token: string) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/transfer`, { token }),
claim: (phrase: string) => call<{ roomId: string; seat: SeatId; token: string }>('POST', '/api/transfer', { phrase }),
report: (roomId: string, token: string, happened: string, expected: string) =>
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
reportImage: async (id: string, file: File) => {
+28
View File
@@ -114,6 +114,34 @@ export class Room {
return this.act(() => api.say(this.roomId, this.token, text), 'The table did not hear you.');
}
/** A word from the gallery, signed; only when the host has let the gallery talk. */
sayFromGallery(name: string, text: string): Promise<boolean> {
if (!this.spectating) return Promise.resolve(false);
return this.act(() => api.sayFromGallery(this.roomId, name, text), 'The table did not hear you.');
}
/** Host only: let the Peanut Gallery talk at this table, or hush it. */
setGalleryTalk(on: boolean): Promise<boolean> {
return this.act(() => api.setGalleryTalk(this.roomId, this.token, on), 'The gallery could not be told.');
}
/** Call the keeper of the site to a held seat; their phone rings. */
callKeeper(): Promise<boolean> {
return this.act(() => api.challenge(this.roomId, this.token), 'The keeper could not be called.');
}
/** A finished table opens another with the same company; the caller is seated as its host. */
async rematch(): Promise<string> {
const next = await api.rematch(this.roomId, this.token);
if (next.created && next.seat && next.token) rememberSeat(next.roomId, { seat: next.seat, token: next.token });
return next.roomId;
}
/** A phrase that brings this seat to another device, good for ten minutes. */
transfer(): Promise<{ phrase: string; expiresAt: number }> {
return api.transfer(this.roomId, this.token);
}
async submit(input: Input): Promise<boolean> {
this.sending = true;
try {
+15 -1
View File
@@ -3,11 +3,12 @@
import type { Outcome, SeatId } from '../game/spec';
/** One line of table talk. Only seated players speak; the gallery listens. */
/** One line of table talk: from a seat, or from the Peanut Gallery when the host allows it (id SPECTATOR, signed with `name`). */
export interface ChatLine {
id: SeatId;
text: string;
at: number;
name?: string;
}
export interface RoomView<State> {
@@ -29,4 +30,17 @@ export interface RoomView<State> {
chat: ChatLine[];
/** How many watch from the Peanut Gallery. */
audience: number;
/** The host lets the gallery talk. */
galleryTalk: boolean;
/** The keeper of the site was called to a seat, and by whom. */
challenge: { by: SeatId; at: number } | null;
/** Names with a seat held who have not yet sat: the last table's players, or the keeper. */
expected: string[];
/** A finished table's rematch: where it went, and who called. */
rematch: { to: string; by: SeatId } | null;
/** The table this one is the rematch of. */
rematchOf: string | null;
/** The keeper of the site, and where they live, for a table that calls them. */
keeper: string;
keeperZone: string;
}