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:
co-authored by
Claude Fable 5.1
parent
75c2dc9241
commit
8e7376d59a
@@ -4,6 +4,7 @@
|
||||
import Ledger from './Ledger.svelte';
|
||||
import MobileBar from './MobileBar.svelte';
|
||||
import SpellSheet from './SpellSheet.svelte';
|
||||
import TableTalk from './TableTalk.svelte';
|
||||
import TurnSummary from './TurnSummary.svelte';
|
||||
import WizardStatus from './WizardStatus.svelte';
|
||||
import { castKey, listText, tokensText, type Duel } from '$lib/game/duel.svelte';
|
||||
@@ -24,7 +25,7 @@
|
||||
|
||||
{#if outcome}
|
||||
<div class="verdict" id="verdict" class:won>
|
||||
<h2>{won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}</h2>
|
||||
<h2>{duel.spectating ? (outcome.winner === null ? 'A draw.' : `${duel.state.wizards[outcome.winner].name} wins.`) : won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}</h2>
|
||||
<p>{outcome.reason}</p>
|
||||
{#if duel.remote}
|
||||
<a class="reveal again" href="/">Back to the hall</a>
|
||||
@@ -33,7 +34,12 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if duel.out}
|
||||
{#if duel.spectating}
|
||||
<section class="move out" aria-labelledby="move-heading">
|
||||
<h2 id="move-heading">You watch from the Peanut Gallery</h2>
|
||||
<p class="threats muted">{duel.awaitingText ? `${duel.awaitingText} ${duel.awaiting.length === 1 ? 'is' : 'are'} still writing turn ${duel.turnNumber}.` : `Turn ${duel.turnNumber} is being written.`} The gallery sees only what every wizard at the table can see.</p>
|
||||
</section>
|
||||
{:else if duel.out}
|
||||
<section class="move out" aria-labelledby="move-heading">
|
||||
<h2 id="move-heading">{duel.you.alive ? 'You have yielded' : 'You have fallen'}</h2>
|
||||
<p class="threats muted">The duel goes on without you. Watch the ledger fill; {listText(duel.others.filter((id) => duel.state.wizards[id].alive && !duel.state.wizards[id].surrendered).map((id) => duel.state.wizards[id].name))} still stand.</p>
|
||||
@@ -200,10 +206,13 @@
|
||||
</section>
|
||||
|
||||
<aside class="rail">
|
||||
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
|
||||
{#if !duel.spectating}
|
||||
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
|
||||
{/if}
|
||||
{#each duel.others as id (id)}
|
||||
<WizardStatus wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
|
||||
{/each}
|
||||
<TableTalk {duel} />
|
||||
<SpellSheet {duel} />
|
||||
<Chronicle log={duel.state.log} />
|
||||
</aside>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { allSeats, api, forgetSeat } from '$lib/game/client';
|
||||
import { Duel, duel, NAME_MAX, playerName, rememberName } from '$lib/game/duel.svelte';
|
||||
import { unreadTalk } from '$lib/game/talk';
|
||||
import type { RoomView } from '$lib/game/view';
|
||||
|
||||
let name = $state(playerName() || duel.you.name);
|
||||
@@ -92,6 +93,12 @@
|
||||
return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`;
|
||||
}
|
||||
|
||||
/** Lines of table talk said since this browser last looked in. */
|
||||
function unread(held: { roomId: string }): number {
|
||||
const g = games[held.roomId];
|
||||
return g ? unreadTalk(held.roomId, g.chat.length) : 0;
|
||||
}
|
||||
|
||||
function yourMove(held: { roomId: string; seat: string }): boolean {
|
||||
const g = games[held.roomId];
|
||||
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
|
||||
@@ -109,7 +116,7 @@
|
||||
<section class="lid">
|
||||
<div class="lid-head">
|
||||
<h1>Waving Hands</h1>
|
||||
<p class="pitch">Two wizards. Two hands each. The right run of gestures is a spell.</p>
|
||||
<p class="pitch">Duelling wizards. Two hands each. The right run of gestures is a spell.</p>
|
||||
<p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn every wizard chooses a gesture per hand and all reveal together; a Fireball is F-S-S-D-D on one hand, a Shield is a single palm. Play the bot, or open a table for up to a dozen wizards and play by turns.</p>
|
||||
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a></p>
|
||||
</div>
|
||||
@@ -135,6 +142,7 @@
|
||||
<button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="muted small">A code opens the door either way: take a seat if one is free, or watch from the Peanut Gallery.</p>
|
||||
{#if error}<p class="warning">{error}</p>{/if}
|
||||
</div>
|
||||
|
||||
@@ -174,7 +182,7 @@
|
||||
<div class="ledger-row" class:your-move={yourMove(held)}>
|
||||
<a class="ledger-resume" href={`/join/${held.roomId}`}>
|
||||
<span class="ledger-code">{held.roomId}</span>
|
||||
<span class="ledger-info">{status(held)}</span>
|
||||
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
|
||||
</a>
|
||||
<button type="button" class="ledger-forget" title="forget this duel" onclick={() => forget(held.roomId)}>×</button>
|
||||
</div>
|
||||
@@ -428,6 +436,10 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.talk-new {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.ledger-code {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Duel } from '$lib/game/duel.svelte';
|
||||
import { glyph } from '$lib/game/glyphs';
|
||||
import { SPELL_BY_ID, type Hand } from '$lib/game/spells';
|
||||
import type { HistoryEntry, WizardId } from '$lib/game/state';
|
||||
import { hiddenFromViewer, type HistoryEntry, type WizardId } from '$lib/game/state';
|
||||
|
||||
let { duel }: { duel: Duel } = $props();
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
cells: Record<WizardId, Placed[]>;
|
||||
}
|
||||
|
||||
/** You first, then the others in table order. */
|
||||
const columns = $derived<WizardId[]>([duel.me, ...duel.others]);
|
||||
/** You first, then the others in table order; the gallery sees the table as laid. */
|
||||
const columns = $derived<WizardId[]>(duel.spectating ? duel.others : [duel.me, ...duel.others]);
|
||||
|
||||
function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
|
||||
const history = who === duel.me ? duel.you.history : duel.visible(who);
|
||||
@@ -30,7 +30,7 @@
|
||||
const out: Placed[] = [];
|
||||
history.forEach((entry, index) => {
|
||||
if (entry.turn === turn && phases.includes(entry.phase)) {
|
||||
out.push({ entry, index, hidden: raw[index].hiddenFrom.includes(duel.me) });
|
||||
out.push({ entry, index, hidden: hiddenFromViewer(raw[index].hiddenFrom, duel.me) });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
@@ -110,7 +110,15 @@
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{#if drafting}
|
||||
{#if drafting && duel.spectating}
|
||||
<tr class="draft">
|
||||
<td class="turn">{duel.turnNumber}</td>
|
||||
{#each columns as who (who)}
|
||||
<td class="cell foe"><span class="letter placeholder">?</span></td>
|
||||
<td class="cell foe"><span class="letter placeholder">?</span></td>
|
||||
{/each}
|
||||
</tr>
|
||||
{:else if drafting}
|
||||
<tr class="draft" class:stopped={duel.timeStopped}>
|
||||
<td class="turn">{duel.timeStopped ? '' : duel.turnNumber}{#if duel.timeStopped}<span class="rownote">time stop</span>{/if}</td>
|
||||
{#each ['left', 'right'] as const as hand (hand)}
|
||||
|
||||
@@ -7,22 +7,26 @@
|
||||
</script>
|
||||
|
||||
{#if !duel.state.over}
|
||||
<div class="bar">
|
||||
<div class="bar" class:watching={duel.spectating}>
|
||||
<div class="health" class:many={duel.others.length > 1}>
|
||||
<span><em>{duel.you.name}</em> {duel.you.hp}/{MAX_HP}</span>
|
||||
{#if !duel.spectating}<span><em>{duel.you.name}</em> {duel.you.hp}/{MAX_HP}</span>{/if}
|
||||
{#each duel.others as id (id)}
|
||||
{@const w = duel.state.wizards[id]}
|
||||
<span class:gone={!w.alive || w.surrendered}><em>{w.name}</em> {w.hp}/{MAX_HP}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="gestures" aria-label="Chosen gestures">
|
||||
{#each HANDS as hand (hand)}
|
||||
<span class="letter" class:empty={duel.chosenGesture('main', hand) === null}>{duel.drawn('main', hand)}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
|
||||
{duel.out ? 'Out' : duel.moved ? 'Waiting' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
|
||||
</button>
|
||||
{#if duel.spectating}
|
||||
<span class="gallery">the gallery</span>
|
||||
{:else}
|
||||
<div class="gestures" aria-label="Chosen gestures">
|
||||
{#each HANDS as hand (hand)}
|
||||
<span class="letter" class:empty={duel.chosenGesture('main', hand) === null}>{duel.drawn('main', hand)}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
|
||||
{duel.out ? 'Out' : duel.moved ? 'Waiting' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -49,6 +53,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.bar.watching {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
|
||||
.health {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { Duel } from '$lib/game/duel.svelte';
|
||||
|
||||
let { duel }: { duel: Duel } = $props();
|
||||
|
||||
const remote = $derived(duel.remote);
|
||||
const lines = $derived(remote?.chat ?? []);
|
||||
let draft = $state('');
|
||||
let sending = $state(false);
|
||||
let list = $state<HTMLElement | null>(null);
|
||||
|
||||
// New talk scrolls into view, as at any table.
|
||||
$effect(() => {
|
||||
void lines.length;
|
||||
void tick().then(() => {
|
||||
if (list) list.scrollTop = list.scrollHeight;
|
||||
});
|
||||
});
|
||||
|
||||
async function say(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const text = draft.trim();
|
||||
if (!text || sending) return;
|
||||
sending = true;
|
||||
if (await duel.say(text)) draft = '';
|
||||
sending = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if remote}
|
||||
<section class="talk" aria-label="Table talk">
|
||||
<h2>Table talk{#if remote.audience > 0}<span class="audience">{remote.audience} in the gallery</span>{/if}</h2>
|
||||
<ul bind:this={list}>
|
||||
{#if lines.length === 0}
|
||||
<li class="muted">Nobody has said a word.</li>
|
||||
{/if}
|
||||
{#each lines as line, i (line.at + ':' + i)}
|
||||
<li class:you={line.id === duel.me}><em>{line.id === duel.me ? 'You' : duel.speaker(line.id)}</em> {line.text}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if remote.spectating}
|
||||
<p class="muted small">The gallery listens; only seated wizards speak.</p>
|
||||
{:else}
|
||||
<form onsubmit={say}>
|
||||
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something to the table" aria-label="Say something to the table" autocomplete="off" />
|
||||
<button type="submit" class="quiet" disabled={!draft.trim() || sending}>Say</button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.talk {
|
||||
padding: 0.75rem 0;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
font-style: italic;
|
||||
margin-bottom: 0.4rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.audience {
|
||||
font-size: 0.8rem;
|
||||
font-style: normal;
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
li em {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
li.you em {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
background: none;
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.8rem;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.quiet:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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