Gallery talk, the keeper's bell, rematches, seats that travel by phrase, and seats doubted before dropped

Brought over from the game kit, where they were ported from Wiz-War. The
host may let the Peanut Gallery talk; a watcher signs a name no seat
holds. A seated wizard may challenge Kestrel, the keeper: a seat is held,
the call rings Sentry once per table, and the lobby says what hour it is
where the keeper lives. A finished table may call for a rematch with the
same company and bots, seats held. Transfer seat mints four words good
for ten minutes and one use; the hall claims them. The hall forgets a
held seat only on the second refusal in a row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-09-23 12:27:21 -04:00
1 parent 5b169d7131
commit e5c240d970
11 files changed
+576 -32

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 = 'wh: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 }));
@@ -93,6 +141,14 @@ export const api = {
watch: (roomId: string) => call<RoomView>('GET', `/api/rooms/${roomId}`),
turn: (roomId: string, token: string, input: TurnInput) => call<RoomView>('POST', `/api/rooms/${roomId}/turn`, { token, input }),
say: (roomId: string, token: string, text: string) => call<RoomView>('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<RoomView>('POST', `/api/rooms/${roomId}/say`, { name, text }),
setGalleryTalk: (roomId: string, token: string, on: boolean) => call<RoomView>('POST', `/api/rooms/${roomId}/gallery`, { token, on }),
challenge: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/challenge`, { token }),
rematch: (roomId: string, token: string) =>
call<{ roomId: string; seat: WizardId | 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: WizardId; 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) => {
+64 -2
View File
@@ -222,8 +222,15 @@ export interface Remote extends HeldSeat {
chat: ChatLine[];
/** How many watch from the Peanut Gallery. */
audience: number;
/** Watching from the gallery: no seat, no hands, no voice. */
/** Watching from the gallery: no seat, no hands, no voice unless the host allows it. */
spectating: boolean;
galleryTalk: boolean;
challenge: RoomView['challenge'];
expected: string[];
rematch: RoomView['rematch'];
rematchOf: string | null;
keeper: string;
keeperZone: string;
}
export class Duel {
@@ -786,6 +793,49 @@ export class Duel {
return this.remote?.seats.find((s) => s.id === id)?.name ?? this.state.wizards[id]?.name ?? id;
}
private async remoteAct(fn: (r: Remote) => Promise<RoomView>, failure: string): Promise<boolean> {
const r = this.remote;
if (!r) return false;
r.error = '';
try {
this.apply(await fn(r));
return true;
} catch (e) {
r.error = e instanceof Error ? e.message : failure;
return false;
}
}
/** A word from the gallery, signed; only when the host has let the gallery talk. */
sayFromGallery(name: string, text: string): Promise<boolean> {
if (!this.remote?.spectating) return Promise.resolve(false);
return this.remoteAct((r) => api.sayFromGallery(r.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.remoteAct((r) => api.setGalleryTalk(r.roomId, r.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.remoteAct((r) => api.challenge(r.roomId, r.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 r = this.remote!;
const next = await api.rematch(r.roomId, r.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 }> {
const r = this.remote!;
return api.transfer(r.roomId, r.token);
}
/** Say something to the whole table. Resolves false when the words did not arrive. */
async say(text: string): Promise<boolean> {
const r = this.remote;
@@ -809,6 +859,11 @@ export class Duel {
r.waitingOn = view.waitingOn;
r.chat = view.chat;
r.audience = view.audience;
r.galleryTalk = view.galleryTalk;
r.challenge = view.challenge;
r.expected = view.expected;
r.rematch = view.rematch;
r.rematchOf = view.rematchOf;
markTalkSeen(r.roomId, view.chat.length);
const wasStarted = r.started;
r.started = view.state !== null;
@@ -960,7 +1015,14 @@ function remoteFrom(view: RoomView, token: string): Remote {
error: '',
chat: view.chat,
audience: view.audience,
spectating: view.me === SPECTATOR
spectating: view.me === SPECTATOR,
galleryTalk: view.galleryTalk,
challenge: view.challenge,
expected: view.expected,
rematch: view.rematch,
rematchOf: view.rematchOf,
keeper: view.keeper,
keeperZone: view.keeperZone
};
}
+15 -1
View File
@@ -15,11 +15,12 @@ export function viewFor(state: GameState, viewer: WizardId): GameState {
return view;
}
/** One line of table talk. Only seated wizards 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: WizardId;
text: string;
at: number;
name?: string;
}
/** What a seated player, or the gallery, is told by the server. Other seats' moves and tokens never leave it. */
@@ -39,4 +40,17 @@ export interface RoomView {
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: WizardId; at: number } | null;
/** Names with a seat held who have not yet sat: the last table's wizards, or the keeper. */
expected: string[];
/** A finished table's rematch: where it went, and who called. */
rematch: { to: string; by: WizardId } | 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;
}