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:
1 parent
5b169d7131
commit
e5c240d970
11 files changed
+576
-32
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user