The Peanut Gallery and table talk
A room's link without a seat is now a place in the gallery: a tokenless view that withholds any gesture hidden from anyone at the table, counted for the table and the lobby. Seated wizards can talk; each line is a ledger line, shown in the rail and in the lobby, and the hall counts what was said while you were away. The hall's pitch no longer says two wizards. 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
75c2dc9241
commit
8e7376d59a
19 files changed
+488
-80
No files matched your search
@@ -74,10 +74,13 @@ export const api = {
|
||||
addBot: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/bot`, { token }),
|
||||
begin: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/begin`, { token }),
|
||||
view: (roomId: string, token: string) => call<RoomView>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
|
||||
turn: (roomId: string, token: string, input: TurnInput) => call<RoomView>('POST', `/api/rooms/${roomId}/turn`, { token, input })
|
||||
/** The gallery's view: no token, no seat, nothing any wizard could not see. */
|
||||
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 socket that only ever says "fetch the view again"; reconnects on its own. */
|
||||
/** A socket that only ever says "fetch the view again"; reconnects on its own. An empty token sits in the gallery. */
|
||||
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
|
||||
let socket: WebSocket | null = null;
|
||||
let closed = false;
|
||||
@@ -85,7 +88,8 @@ export function watchRoom(roomId: string, token: string, onUpdate: () => void, o
|
||||
const open = () => {
|
||||
if (closed) return;
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
socket = new WebSocket(`${protocol}://${location.host}/ws?room=${encodeURIComponent(roomId)}&token=${encodeURIComponent(token)}`);
|
||||
const seat = token ? `&token=${encodeURIComponent(token)}` : '';
|
||||
socket = new WebSocket(`${protocol}://${location.host}/ws?room=${encodeURIComponent(roomId)}${seat}`);
|
||||
socket.onopen = () => {
|
||||
delay = 1000;
|
||||
onStatus(true);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { tick } from 'svelte';
|
||||
import { chooseBotTurn } from './bot';
|
||||
import { api, rememberSeat, watchRoom, type HeldSeat } from './client';
|
||||
import { markTalkSeen } from './talk';
|
||||
import {
|
||||
cellsOverlap,
|
||||
completableIn,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
} from './spells';
|
||||
import {
|
||||
NOWHERE,
|
||||
SPECTATOR,
|
||||
createGame,
|
||||
enemyOf,
|
||||
inDuel,
|
||||
@@ -44,7 +46,7 @@ import {
|
||||
type TurnInput,
|
||||
type WizardId
|
||||
} from './state';
|
||||
import type { RoomView } from './view';
|
||||
import type { ChatLine, RoomView } from './view';
|
||||
|
||||
/** The seats of a duel played in this browser against the bot. */
|
||||
const LOCAL_ME: WizardId = 'A';
|
||||
@@ -214,6 +216,11 @@ export interface Remote extends HeldSeat {
|
||||
started: boolean;
|
||||
connected: boolean;
|
||||
error: string;
|
||||
chat: ChatLine[];
|
||||
/** How many watch from the Peanut Gallery. */
|
||||
audience: number;
|
||||
/** Watching from the gallery: no seat, no hands, no voice. */
|
||||
spectating: boolean;
|
||||
}
|
||||
|
||||
export class Duel {
|
||||
@@ -249,7 +256,7 @@ export class Duel {
|
||||
if (initial && 'roomId' in initial) {
|
||||
this.remote = initial;
|
||||
this.me = initial.seat;
|
||||
this.state = createGame({ [initial.seat]: 'You' });
|
||||
this.state = createGame({ [initial.seat]: initial.spectating ? 'The gallery' : 'You' });
|
||||
} else if (initial) {
|
||||
this.state = initial;
|
||||
} else if (saved) {
|
||||
@@ -313,7 +320,10 @@ export class Duel {
|
||||
/** A duel worth asking about before it is thrown away. */
|
||||
inProgress = $derived(this.state.turn > 0 && !this.state.over);
|
||||
|
||||
you = $derived(this.state.wizards[this.me]);
|
||||
/** Watching from the gallery, with no seat at the table. */
|
||||
spectating = $derived(this.remote?.spectating ?? false);
|
||||
/** The gallery has no wizard of its own; it borrows the first seat for the fields that need one, and shows none of them. */
|
||||
you = $derived(this.state.wizards[this.me] ?? this.state.wizards[this.state.seats[0]]);
|
||||
/** The opponent spells go at by default: the first still standing. */
|
||||
foeId = $derived(enemyOf(this.state, this.me));
|
||||
foe = $derived(this.state.wizards[this.foeId]);
|
||||
@@ -723,6 +733,25 @@ export class Duel {
|
||||
}
|
||||
}
|
||||
|
||||
/** What a seat is called, for a line of talk. */
|
||||
speaker(id: WizardId): string {
|
||||
return this.remote?.seats.find((s) => s.id === id)?.name ?? this.state.wizards[id]?.name ?? id;
|
||||
}
|
||||
|
||||
/** Say something to the whole table. Resolves false when the words did not arrive. */
|
||||
async say(text: string): Promise<boolean> {
|
||||
const r = this.remote;
|
||||
if (!r || r.spectating) return false;
|
||||
r.error = '';
|
||||
try {
|
||||
this.apply(await api.say(r.roomId, r.token, text));
|
||||
return true;
|
||||
} catch (e) {
|
||||
r.error = e instanceof Error ? e.message : 'The table did not hear you.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Take a fresh view from the server; a new turn clears the drafts. */
|
||||
apply(view: RoomView): void {
|
||||
const r = this.remote!;
|
||||
@@ -730,6 +759,9 @@ export class Duel {
|
||||
r.size = view.size;
|
||||
r.host = view.host;
|
||||
r.waitingOn = view.waitingOn;
|
||||
r.chat = view.chat;
|
||||
r.audience = view.audience;
|
||||
markTalkSeen(r.roomId, view.chat.length);
|
||||
const wasStarted = r.started;
|
||||
r.started = view.state !== null;
|
||||
if (!view.state) return;
|
||||
@@ -742,8 +774,11 @@ export class Duel {
|
||||
connect(): () => void {
|
||||
const r = this.remote;
|
||||
if (!r) return () => {};
|
||||
const fetchView = () => (r.spectating ? api.watch(r.roomId) : api.view(r.roomId, r.token));
|
||||
const refresh = () => {
|
||||
api.view(r.roomId, r.token).then((v) => this.apply(v)).catch((e: Error) => (r.error = e.message));
|
||||
fetchView()
|
||||
.then((v) => this.apply(v))
|
||||
.catch((e: Error) => (r.error = e.message));
|
||||
};
|
||||
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
|
||||
}
|
||||
@@ -844,6 +879,11 @@ export class Duel {
|
||||
static async resumeRoom(roomId: string, held: HeldSeat): Promise<Remote> {
|
||||
return remoteFrom(await api.view(roomId, held.token), held.token);
|
||||
}
|
||||
|
||||
/** Take a place in the Peanut Gallery: watch a table with no seat and no voice. */
|
||||
static async watchRoom(roomId: string): Promise<Remote> {
|
||||
return remoteFrom(await api.watch(roomId), '');
|
||||
}
|
||||
}
|
||||
|
||||
function remoteFrom(view: RoomView, token: string): Remote {
|
||||
@@ -857,7 +897,10 @@ function remoteFrom(view: RoomView, token: string): Remote {
|
||||
waitingOn: view.waitingOn,
|
||||
started: view.state !== null,
|
||||
connected: false,
|
||||
error: ''
|
||||
error: '',
|
||||
chat: view.chat,
|
||||
audience: view.audience,
|
||||
spectating: view.me === SPECTATOR
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -288,7 +288,13 @@ export function beingName(state: GameState, id: TargetId | undefined): string {
|
||||
*/
|
||||
export function visibleHistory(state: GameState, viewer: WizardId, subject: WizardId): HistoryEntry[] {
|
||||
if (viewer === subject) return state.wizards[subject].history;
|
||||
return state.wizards[subject].history.map((h) =>
|
||||
h.hiddenFrom.includes(viewer) ? { ...h, left: '-', right: '-' } : h
|
||||
);
|
||||
return state.wizards[subject].history.map((h) => (hiddenFromViewer(h.hiddenFrom, viewer) ? { ...h, left: '-', right: '-' } : h));
|
||||
}
|
||||
|
||||
/** The gallery holds no seat: it is shown only what every wizard at the table could see. */
|
||||
export const SPECTATOR: WizardId = '';
|
||||
|
||||
export function hiddenFromViewer(hiddenFrom: WizardId[] | undefined, viewer: WizardId): boolean {
|
||||
if (!hiddenFrom?.length) return false;
|
||||
return viewer === SPECTATOR || hiddenFrom.includes(viewer);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// How much of each table's talk this browser has read, so the hall can say what is new.
|
||||
|
||||
const SEEN_KEY = 'wh:talk-seen';
|
||||
|
||||
function seen(): Record<string, number> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SEEN_KEY) ?? '{}') as Record<string, number>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function markTalkSeen(roomId: string, count: number): void {
|
||||
try {
|
||||
const all = seen();
|
||||
if ((all[roomId] ?? 0) >= count) return;
|
||||
localStorage.setItem(SEEN_KEY, JSON.stringify({ ...all, [roomId]: count }));
|
||||
} catch {
|
||||
// Without storage every line reads as new, which is the safe side.
|
||||
}
|
||||
}
|
||||
|
||||
export function unreadTalk(roomId: string, count: number): number {
|
||||
return Math.max(0, count - (seen()[roomId] ?? 0));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SPECTATOR, createGame } from './state';
|
||||
import { viewFor } from './view';
|
||||
|
||||
/** Three wizards; B's first gestures were hidden from C alone. */
|
||||
function tableWithASecret() {
|
||||
const s = createGame({ A: 'Aldric', B: 'Bryn', C: 'Cass' }, 1);
|
||||
s.wizards.B.history.push({ left: 'P', right: 'S', turn: 0, phase: 'main', hiddenFrom: ['C'] });
|
||||
s.log.push({ turn: 0, kind: 'gesture', text: 'Bryn: left proffered palm, right snap.', hiddenFrom: ['C'] });
|
||||
s.log.push({ turn: 0, kind: 'info', text: 'Nothing else happened.' });
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('the view a room sends', () => {
|
||||
it('shows a hidden gesture to the wizards who could see it', () => {
|
||||
const view = viewFor(tableWithASecret(), 'A');
|
||||
expect(view.wizards.B.history[0].left).toBe('P');
|
||||
expect(view.log).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('withholds it from the wizard it was hidden from', () => {
|
||||
const view = viewFor(tableWithASecret(), 'C');
|
||||
expect(view.wizards.B.history[0].left).toBe('-');
|
||||
expect(view.log.map((l) => l.text)).toEqual(['Nothing else happened.']);
|
||||
});
|
||||
|
||||
it('withholds from the gallery anything hidden from anyone', () => {
|
||||
const view = viewFor(tableWithASecret(), SPECTATOR);
|
||||
expect(view.wizards.B.history[0].left).toBe('-');
|
||||
expect(view.log.map((l) => l.text)).toEqual(['Nothing else happened.']);
|
||||
});
|
||||
});
|
||||
+14
-3
@@ -3,7 +3,7 @@
|
||||
// describe those gestures are withheld too. Spells cast are always visible, as
|
||||
// the rules say.
|
||||
|
||||
import { visibleHistory, type GameState, type WizardId } from './state';
|
||||
import { hiddenFromViewer, visibleHistory, type GameState, type WizardId } from './state';
|
||||
|
||||
export function viewFor(state: GameState, viewer: WizardId): GameState {
|
||||
const view = structuredClone(state);
|
||||
@@ -11,16 +11,24 @@ export function viewFor(state: GameState, viewer: WizardId): GameState {
|
||||
if (id === viewer) continue;
|
||||
view.wizards[id].history = visibleHistory(state, viewer, id);
|
||||
}
|
||||
view.log = view.log.filter((l) => !l.hiddenFrom?.includes(viewer));
|
||||
view.log = view.log.filter((l) => !hiddenFromViewer(l.hiddenFrom, viewer));
|
||||
return view;
|
||||
}
|
||||
|
||||
/** What a seated player is told by the server. Other seats' moves and tokens never leave it. */
|
||||
/** One line of table talk. Only seated wizards speak; the gallery listens. */
|
||||
export interface ChatLine {
|
||||
id: WizardId;
|
||||
text: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** What a seated player, or the gallery, is told by the server. Other seats' moves and tokens never leave it. */
|
||||
export interface RoomView {
|
||||
roomId: string;
|
||||
seq: number;
|
||||
/** The most seats the table will take. */
|
||||
size: number;
|
||||
/** The viewer's seat, or SPECTATOR for the gallery. */
|
||||
me: WizardId;
|
||||
/** The wizard who opened the table and may begin the duel. */
|
||||
host: WizardId;
|
||||
@@ -28,4 +36,7 @@ export interface RoomView {
|
||||
/** Seats that still have to move before the turn resolves. */
|
||||
waitingOn: WizardId[];
|
||||
state: GameState | null;
|
||||
chat: ChatLine[];
|
||||
/** How many watch from the Peanut Gallery. */
|
||||
audience: number;
|
||||
}
|
||||
Reference in new issue
Block a user