Rematches, seats that travel by phrase, the keeper's bell with the hour where they live, the host's leave for the gallery to talk, and seats doubted before they are dropped
Ported from Wiz-War, where each earned its keep. All on the ledger: galleryTalk, challenge and rematch lines, gallery chat signed with a name, held seats on a rematch table's room line. Twenty-two protocol checks against a scratch server and a replay after restart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
65d62f2ab3
commit
6dce3b82f0
@@ -3,7 +3,7 @@
|
||||
// bot, open a table, join by code, the ledger of your tables, and the
|
||||
// about panel with the rules, the guide and a way to reach the keeper.
|
||||
import { goto } from '$app/navigation';
|
||||
import { allSeats, forgetSeat, type Report } from '$lib/net/client';
|
||||
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report } from '$lib/net/client';
|
||||
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
||||
import { unreadTalk } from '$lib/net/talk';
|
||||
import type { RoomView } from '$lib/net/view';
|
||||
@@ -24,17 +24,44 @@
|
||||
|
||||
const ready = $derived(name.trim().length > 0);
|
||||
|
||||
// Each held seat is looked up once, so the ledger can say whose move it is.
|
||||
// Each held seat is looked up once, so the ledger can say whose move it
|
||||
// is. A seat the server refuses outright is doubted; a second refusal in
|
||||
// a row forgets it. Silence (a restart, a stale answer) forgets nothing.
|
||||
$effect(() => {
|
||||
for (const held of seats) {
|
||||
if (held.roomId in games) continue;
|
||||
games[held.roomId] = null;
|
||||
api.view(held.roomId, held.token)
|
||||
.then((v) => (games[held.roomId] = v))
|
||||
.catch(() => (games[held.roomId] = null));
|
||||
.then((v) => {
|
||||
trustSeat(held.roomId);
|
||||
games[held.roomId] = v;
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
games[held.roomId] = null;
|
||||
if (e instanceof ServerError && (e.status === 403 || e.status === 404) && doubtSeat(held.roomId)) seats = allSeats();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let phrase = $state('');
|
||||
let claiming = $state(false);
|
||||
|
||||
/** Bring a seat from another device by the phrase it minted. */
|
||||
async function claim(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
claiming = true;
|
||||
error = '';
|
||||
try {
|
||||
const got = await api.claim(phrase.trim());
|
||||
rememberSeat(got.roomId, { seat: got.seat, token: got.token });
|
||||
await goto(`/join/${got.roomId}`);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'The phrase opened no seat.';
|
||||
} finally {
|
||||
claiming = false;
|
||||
}
|
||||
}
|
||||
|
||||
// The desk is asked once per visit for every seat this browser holds.
|
||||
$effect(() => {
|
||||
const held = seats;
|
||||
@@ -134,6 +161,11 @@
|
||||
</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>
|
||||
<form class="joinform claimform" onsubmit={claim}>
|
||||
<span class="or">or bring a seat from another device</span>
|
||||
<input type="text" bind:value={phrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
|
||||
<button type="submit" class="quiet" disabled={claiming || phrase.trim().split(/[\s-]+/).length < 4}>Claim</button>
|
||||
</form>
|
||||
{#if error}<p class="warning">{error}</p>{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,8 +7,29 @@
|
||||
const v = $derived(room.view);
|
||||
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
|
||||
const seatFree = $derived(v.seats.length < v.size);
|
||||
/** A seat is free for a newcomer once the held ones are counted. */
|
||||
const seatOpen = $derived(v.seats.length + v.expected.length < v.size);
|
||||
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
|
||||
let copied = $state(false);
|
||||
|
||||
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
|
||||
let minute = $state(0);
|
||||
$effect(() => {
|
||||
const id = setInterval(() => (minute += 1), 60_000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
const keeperHour = $derived.by(() => {
|
||||
void minute;
|
||||
try {
|
||||
const hour = Number(new Intl.DateTimeFormat('en-US', { hour: 'numeric', hour12: false, timeZone: v.keeperZone }).format(new Date())) % 24;
|
||||
const word = ['twelve', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven'][hour % 12];
|
||||
const part = hour < 5 ? 'in the small hours' : hour < 12 ? 'in the morning' : hour < 18 ? 'in the afternoon' : 'in the evening';
|
||||
return `${word} ${part}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
@@ -42,8 +63,17 @@
|
||||
<button type="button" class="unseat" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room.unseat(s.id)}>×</button>{/if}</span>
|
||||
</li>
|
||||
{/each}
|
||||
<li class="empty muted">{v.size - v.seats.length} of {v.size} seats empty{#if v.audience > 0}; {v.audience} in the gallery{/if}</li>
|
||||
{#each v.expected as name (name)}
|
||||
<li>
|
||||
<span class="seat-name">{name}</span>
|
||||
<span class="muted">{v.challenge && name.toLowerCase() === v.keeper.toLowerCase() ? 'called to the table; a seat is held' : 'from the last table; a seat is held'}</span>
|
||||
</li>
|
||||
{/each}
|
||||
<li class="empty muted">{v.size - v.seats.length - v.expected.length} of {v.size} seats empty{#if v.audience > 0}; {v.audience} in the gallery{v.galleryTalk ? ', allowed to talk' : ''}{/if}</li>
|
||||
</ul>
|
||||
{#if v.rematchOf}
|
||||
<p class="muted">A rematch of table {v.rematchOf}: the same company, seats held for those not yet back.</p>
|
||||
{/if}
|
||||
{#if !room.spectating}
|
||||
<div class="ways">
|
||||
{#if seatFree && room.isHost}
|
||||
@@ -56,6 +86,20 @@
|
||||
<span class="muted">{hostName} will begin when the table is ready.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if room.isHost}
|
||||
<label class="switch muted">
|
||||
<input type="checkbox" checked={v.galleryTalk} onchange={(e) => room.setGalleryTalk(e.currentTarget.checked)} />
|
||||
Let the Peanut Gallery talk at the table
|
||||
</label>
|
||||
{/if}
|
||||
{#if v.keeper && !v.challenge && !keeperSeated && seatOpen}
|
||||
<div class="keeper">
|
||||
<button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {v.keeper}, the keeper</button>
|
||||
<p class="muted small">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
|
||||
</div>
|
||||
{:else if v.challenge}
|
||||
<p class="muted small">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a bot, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
||||
<div class="lobby-talk"><TableTalk {room} /></div>
|
||||
@@ -143,4 +187,22 @@
|
||||
max-width: 28em;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.keeper {
|
||||
margin-top: 0.8rem;
|
||||
max-width: 38em;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { Room } from '$lib/net/room.svelte';
|
||||
import { NAME_MAX, playerName, rememberName, type Room } from '$lib/net/room.svelte';
|
||||
|
||||
let { room }: { room: Room } = $props();
|
||||
|
||||
const lines = $derived(room.view.chat);
|
||||
let draft = $state('');
|
||||
/** The gallery signs its lines; the name is the one the hall remembers. */
|
||||
let galleryName = $state(playerName());
|
||||
let sending = $state(false);
|
||||
let list = $state<HTMLElement | null>(null);
|
||||
|
||||
@@ -25,6 +27,17 @@
|
||||
if (await room.say(text)) draft = '';
|
||||
sending = false;
|
||||
}
|
||||
|
||||
async function sayFromGallery(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const text = draft.trim();
|
||||
const name = galleryName.trim();
|
||||
if (!text || !name || sending) return;
|
||||
sending = true;
|
||||
rememberName(name);
|
||||
if (await room.sayFromGallery(name, text)) draft = '';
|
||||
sending = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="talk" aria-label="Table talk">
|
||||
@@ -34,11 +47,17 @@
|
||||
<li class="muted">Nobody has said a word.</li>
|
||||
{/if}
|
||||
{#each lines as line, i (line.at + ':' + i)}
|
||||
<li class:you={line.id === room.me}><em>{line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li>
|
||||
<li class:you={!line.name && line.id === room.me}><em>{line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if room.spectating}
|
||||
<p class="muted small">The gallery listens; only seated players speak.</p>
|
||||
{#if room.spectating && room.view.galleryTalk}
|
||||
<form onsubmit={sayFromGallery}>
|
||||
<input type="text" class="name" bind:value={galleryName} maxlength={NAME_MAX} placeholder="Your name" aria-label="Your name in the gallery" autocomplete="nickname" />
|
||||
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something from the gallery" aria-label="Say something from the gallery" autocomplete="off" />
|
||||
<button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button>
|
||||
</form>
|
||||
{:else if room.spectating}
|
||||
<p class="muted small">The gallery listens; the host has not let it talk.</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" />
|
||||
@@ -102,6 +121,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input.name {
|
||||
flex: 0 1 8rem;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.4rem;
|
||||
|
||||
Reference in New Issue
Block a user