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;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a href="#reveal">The reveal</a>
|
||||
<a href="#rail">Health, creatures and the sheet</a>
|
||||
<a href="#phone">On a phone</a>
|
||||
<a href="#gallery">The Peanut Gallery and table talk</a>
|
||||
<a href="#first">A first duel</a>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -97,6 +98,12 @@
|
||||
<p>Everything is here, in one column. The bar at the foot keeps every wizard's health, your two chosen gestures and the reveal button in reach; after a reveal the page brings the ledger's last rows and the result into view. Lists are shorter, and <em>More spells</em> opens the rest.</p>
|
||||
</section>
|
||||
|
||||
<section id="gallery">
|
||||
<h2>The Peanut Gallery and table talk</h2>
|
||||
<p>A room's link is an invitation to sit while the table is laid and to watch once the duel has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery: they see the ledger, the result of each turn and the chronicle, and the table is told how many are watching. The gallery is shown only what every wizard at the table could see; a gesture hidden from any one of them is a question mark to everyone in the gallery, and the turn being written shows nothing at all.</p>
|
||||
<p><em>Table talk</em>, in the rail, is for the wizards seated. A line goes to everyone at the table and everyone in the gallery, and it is kept with the duel; when you come back to a table, the hall says how many lines were said while you were away. The gallery listens and does not speak.</p>
|
||||
</section>
|
||||
|
||||
<section id="first">
|
||||
<h2>A first duel</h2>
|
||||
<p>Open with a wave on each hand. From W-W you may turn either hand into a counter-spell (W-W-S), a protection from evil (W-W-P) or resist heat (W-W-F-P), and nobody can hurt you on the first turn. Watch the warnings: when a missile is one gesture away, a palm costs you one hand for one turn; when a fireball is one gesture away, only a counter-spell, a resistance, or a counter of your own will do.</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import Board from '$lib/components/Board.svelte';
|
||||
import TableTalk from '$lib/components/TableTalk.svelte';
|
||||
import { seatFor } from '$lib/game/client';
|
||||
import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte';
|
||||
|
||||
@@ -15,8 +16,11 @@
|
||||
|
||||
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
|
||||
const lobby = $derived(room !== null && !room.remote?.started);
|
||||
const watching = $derived(room?.spectating ?? false);
|
||||
/** A watcher may still sit while the table is laid and a seat is free. */
|
||||
const seatFree = $derived(!!room?.remote && !room.remote.started && room.remote.seats.length < room.remote.size);
|
||||
|
||||
// Return to a held seat, or offer one.
|
||||
// Return to a held seat, or take a place in the gallery and offer one.
|
||||
$effect(() => {
|
||||
const id = roomId;
|
||||
room = null;
|
||||
@@ -25,6 +29,9 @@
|
||||
if (!held) {
|
||||
name = playerName();
|
||||
joining = true;
|
||||
Duel.watchRoom(id)
|
||||
.then((remote) => (room = new Duel(remote)))
|
||||
.catch((e: Error) => (error = e.message));
|
||||
return;
|
||||
}
|
||||
joining = false;
|
||||
@@ -84,7 +91,13 @@
|
||||
<header class="masthead">
|
||||
<div>
|
||||
<h1>Waving Hands</h1>
|
||||
{#if room?.remote?.started}
|
||||
{#if room?.remote?.started && watching}
|
||||
<p class="standfirst">
|
||||
From the Peanut Gallery you watch
|
||||
{#each room.others as id, i (id)}{#if i > 0}{i === room.others.length - 1 ? ' and ' : ', '}{/if}<em>{room.state.wizards[id].name}</em>{/each} duel.
|
||||
{#if !room.remote.connected}<span class="muted">Reconnecting to the duel.</span>{/if}
|
||||
</p>
|
||||
{:else if room?.remote?.started}
|
||||
<p class="standfirst">
|
||||
You are <em>{room.you.name}</em>, duelling
|
||||
{#each room.others as id, i (id)}{#if i > 0}{i === room.others.length - 1 ? ' and ' : ', '}{/if}<em>{room.state.wizards[id].name}</em>{/each}.
|
||||
@@ -97,8 +110,11 @@
|
||||
<nav class="actions">
|
||||
{#if room}
|
||||
<button type="button" class="quiet room" title="Copy the invite link" onclick={copyLink}>
|
||||
<span class="muted">invite friends · room</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
|
||||
<span class="muted">{watching ? 'watching · room' : 'invite friends · room'}</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
|
||||
</button>
|
||||
{#if room.remote && room.remote.audience > 0}
|
||||
<span class="audience" title="the Peanut Gallery">{room.remote.audience} in the gallery</span>
|
||||
{/if}
|
||||
{/if}
|
||||
<a class="quiet" href="/guide">How to play</a>
|
||||
<a class="quiet" href="/rules">Rules</a>
|
||||
@@ -110,11 +126,11 @@
|
||||
<p class="notice">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if joining}
|
||||
{#if joining && seatFree}
|
||||
<section class="lobby">
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
<h2>Take a seat</h2>
|
||||
<p>A wizard has opened this table and is gathering opponents.</p>
|
||||
<p>A wizard has opened this table and is gathering opponents. Choose a name and sit; the duel begins when the host says.</p>
|
||||
<form class="rename" onsubmit={takeSeat}>
|
||||
<label class="field">
|
||||
<span>Your name</span>
|
||||
@@ -122,15 +138,27 @@
|
||||
</label>
|
||||
<button type="submit" class="reveal small" disabled={busy}>Sit down</button>
|
||||
</form>
|
||||
<p class="muted">Just watching? You are in the Peanut Gallery until you sit.</p>
|
||||
</section>
|
||||
{:else if room && lobby}
|
||||
{/if}
|
||||
|
||||
{#if room && lobby}
|
||||
{@const r = room.remote!}
|
||||
{@const hostName = r.seats.find((s) => s.id === r.host)?.name ?? 'the host'}
|
||||
<section class="lobby">
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
<h2>The table is laid</h2>
|
||||
<p>Send this link, or the code. Whoever opens it takes a seat; the table holds a dozen.</p>
|
||||
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
|
||||
{#if watching}
|
||||
<h2>At the table</h2>
|
||||
{#if seatFree}
|
||||
<p>{hostName} is gathering wizards. Sit down above, or watch from the gallery until the duel begins.</p>
|
||||
{:else}
|
||||
<p>The table is full. You watch from the Peanut Gallery; the duel begins when {hostName} says.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
<h2>The table is laid</h2>
|
||||
<p>Send this link, or the code. Whoever opens it takes a seat; the table holds a dozen. Anyone who arrives once the duel has begun watches from the Peanut Gallery.</p>
|
||||
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
|
||||
{/if}
|
||||
<ul class="roster">
|
||||
{#each r.seats as s (s.id)}
|
||||
<li>
|
||||
@@ -138,25 +166,28 @@
|
||||
<span class="muted">{s.id === r.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === r.seat ? ', you' : ''}</span>
|
||||
</li>
|
||||
{/each}
|
||||
<li class="empty muted">{r.size - r.seats.length} of {r.size} seats empty</li>
|
||||
<li class="empty muted">{r.size - r.seats.length} of {r.size} seats empty{#if r.audience > 0}; {r.audience} in the gallery{/if}</li>
|
||||
</ul>
|
||||
<div class="ways">
|
||||
{#if r.seats.length < r.size}
|
||||
<button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button>
|
||||
{/if}
|
||||
{#if room.isHost}
|
||||
<button type="button" class="reveal small" disabled={r.seats.length < 2} onclick={() => room?.begin()}>Begin the duel</button>
|
||||
<span class="muted">{r.seats.length < 2 ? 'once a second wizard is seated' : `with ${r.seats.length} wizards`}</span>
|
||||
{:else}
|
||||
<span class="muted">{hostName} will begin the duel when the table is ready.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !watching}
|
||||
<div class="ways">
|
||||
{#if r.seats.length < r.size}
|
||||
<button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button>
|
||||
{/if}
|
||||
{#if room.isHost}
|
||||
<button type="button" class="reveal small" disabled={r.seats.length < 2} onclick={() => room?.begin()}>Begin the duel</button>
|
||||
<span class="muted">{r.seats.length < 2 ? 'once a second wizard is seated' : `with ${r.seats.length} wizards`}</span>
|
||||
{:else}
|
||||
<span class="muted">{hostName} will begin the duel when the table is ready.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if r.error}<p class="notice">{r.error}</p>{/if}
|
||||
<div class="lobby-talk"><TableTalk duel={room} /></div>
|
||||
</section>
|
||||
{:else if room}
|
||||
<Board duel={room} />
|
||||
{:else if !error}
|
||||
<p class="notice muted">Finding your seat.</p>
|
||||
<p class="notice muted">{joining ? 'Finding the table.' : 'Finding your seat.'}</p>
|
||||
{/if}
|
||||
|
||||
<footer class="colophon">
|
||||
@@ -217,6 +248,19 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.audience {
|
||||
align-self: center;
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
color: var(--bone-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lobby-talk {
|
||||
max-width: 28em;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.12em;
|
||||
|
||||
Reference in New Issue
Block a user