Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
531 lines
19 KiB
Svelte
531 lines
19 KiB
Svelte
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
import { page } from '$app/state';
|
||
import Board from '$lib/components/Board.svelte';
|
||
import ReportSlip from '$lib/components/ReportSlip.svelte';
|
||
import SeatsSlip from '$lib/components/SeatsSlip.svelte';
|
||
import TableTalk from '$lib/components/TableTalk.svelte';
|
||
import { api, rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/game/client';
|
||
import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte';
|
||
|
||
const roomId = $derived((page.params.code ?? '').toUpperCase());
|
||
|
||
let room = $state<Duel | null>(null);
|
||
let name = $state('');
|
||
let joining = $state(false);
|
||
/** The host pressed Begin while a seat is still held for someone expected; confirm before going on. */
|
||
let beginAsked = $state(false);
|
||
let busy = $state(false);
|
||
let error = $state('');
|
||
let copied = $state(false);
|
||
let reporting = $state(false);
|
||
/** This seat's transfer phrase, until it expires. */
|
||
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
|
||
let rematching = $state(false);
|
||
let seatsOpen = $state(false);
|
||
/** The server's reason for refusing this browser's seat; shown while spectating. */
|
||
let seatLost = $state('');
|
||
let claimPhrase = $state('');
|
||
let claiming = $state(false);
|
||
|
||
/** The current hour in the site maintainer's time zone, in words, shown beside the challenge button. */
|
||
let minute = $state(0);
|
||
$effect(() => {
|
||
const id = setInterval(() => (minute += 1), 60_000);
|
||
return () => clearInterval(id);
|
||
});
|
||
const keeperHour = $derived.by(() => {
|
||
void minute;
|
||
const zone = room?.remote?.keeperZone;
|
||
if (!zone) return '';
|
||
try {
|
||
const hour = Number(new Intl.DateTimeFormat('en-US', { hour: 'numeric', hour12: false, timeZone: zone }).format(new Date())) % 24;
|
||
if (hour === 0) return 'midnight';
|
||
if (hour === 12) return 'noon';
|
||
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 transfer() {
|
||
if (!room) return;
|
||
try {
|
||
phrase = await room.transfer();
|
||
setTimeout(() => (phrase = null), Math.max(0, phrase.expiresAt - Date.now()));
|
||
} catch (e) {
|
||
error = e instanceof Error ? e.message : 'The seat could not be offered.';
|
||
}
|
||
}
|
||
|
||
/** Call for a rematch, or take the seat held at one already called. */
|
||
async function rematch() {
|
||
if (!room) return;
|
||
rematching = true;
|
||
try {
|
||
await goto(`/join/${await room.rematch()}`);
|
||
} catch (e) {
|
||
error = e instanceof Error ? e.message : 'The rematch could not be called.';
|
||
} finally {
|
||
rematching = false;
|
||
}
|
||
}
|
||
|
||
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 spectator may still take a seat before the game starts, while one is free. */
|
||
const seatFree = $derived(!!room?.remote && !room.remote.started && room.remote.seats.length < room.remote.size);
|
||
|
||
// Resume a held seat, else spectate. A link may carry a seat (#seat=...): it is stripped from the address
|
||
// bar at once, saved only once the server accepts it, and never replaces a held seat the server still
|
||
// accepts, so a forwarded link cannot take a working seat.
|
||
$effect(() => {
|
||
const id = roomId;
|
||
room = null;
|
||
error = '';
|
||
const sent = seatInLink(location.hash);
|
||
// The History API directly, not SvelteKit's replaceState: this runs on first load, before the router is ready.
|
||
if (sent) history.replaceState(history.state, '', location.pathname + location.search);
|
||
const held = seatFor(id);
|
||
const take = (s: HeldSeat) =>
|
||
Duel.resumeRoom(id, s).then((remote) => {
|
||
rememberSeat(id, s);
|
||
trustSeat(id);
|
||
return remote;
|
||
});
|
||
// Only a 403 on the held token lets the link's seat in; a dropped connection is not a lost seat.
|
||
const refused = (e: unknown) => e instanceof ServerError && e.status === 403;
|
||
// A refused seat with no link to replace it falls back to spectating, keeping the server's reason.
|
||
const watchRefused = (e: Error) => {
|
||
seatLost = e.message;
|
||
return Duel.watchRoom(id);
|
||
};
|
||
seatLost = '';
|
||
const arrive = held
|
||
? Duel.resumeRoom(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : refused(e) ? watchRefused(e) : Promise.reject(e)))
|
||
: sent
|
||
? take(sent).catch(() => Duel.watchRoom(id))
|
||
: Duel.watchRoom(id);
|
||
joining = !held && !sent;
|
||
if (!held) name = playerName();
|
||
arrive
|
||
.then((remote) => {
|
||
room = new Duel(remote);
|
||
if (remote.spectating) joining = true;
|
||
})
|
||
.catch((e: Error) => (error = e.message));
|
||
});
|
||
|
||
// Keep the view fresh for as long as the room is on screen.
|
||
$effect(() => {
|
||
const r = room;
|
||
if (!r) return;
|
||
const stop = r.connect();
|
||
return stop;
|
||
});
|
||
|
||
// The seat was revoked while the page was open (the host gave it to a bot): switch to spectating.
|
||
$effect(() => {
|
||
const r = room;
|
||
const lost = r?.remote?.lost;
|
||
if (!r || !lost) return;
|
||
seatLost = lost;
|
||
Duel.watchRoom(r.remote!.roomId)
|
||
.then((remote) => (room = new Duel(remote)))
|
||
.catch((e: Error) => (error = e.message));
|
||
});
|
||
|
||
/** Claim a seat by transfer phrase: the host's, or one this seat made on another device. */
|
||
async function claim(e: SubmitEvent) {
|
||
e.preventDefault();
|
||
claiming = true;
|
||
error = '';
|
||
try {
|
||
const got = await api.claim(claimPhrase.trim());
|
||
rememberSeat(got.roomId, { seat: got.seat, token: got.token });
|
||
trustSeat(got.roomId);
|
||
claimPhrase = '';
|
||
seatLost = '';
|
||
if (got.roomId === roomId) room = new Duel(await Duel.resumeRoom(roomId, { seat: got.seat, token: got.token }));
|
||
else await goto(`/join/${got.roomId}`);
|
||
} catch (err) {
|
||
error = err instanceof Error ? err.message : 'The phrase opened no seat.';
|
||
} finally {
|
||
claiming = false;
|
||
}
|
||
}
|
||
|
||
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
|
||
$effect(() => {
|
||
const r = room;
|
||
if (!r) return;
|
||
const query = window.matchMedia(COMPACT_QUERY);
|
||
const apply = () => (r.compact = query.matches);
|
||
apply();
|
||
query.addEventListener('change', apply);
|
||
return () => query.removeEventListener('change', apply);
|
||
});
|
||
|
||
async function takeSeat(e: SubmitEvent) {
|
||
e.preventDefault();
|
||
busy = true;
|
||
error = '';
|
||
try {
|
||
room = new Duel(await Duel.joinRoom(roomId, name));
|
||
joining = false;
|
||
} catch (err) {
|
||
error = err instanceof Error ? err.message : 'The seat could not be taken.';
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
}
|
||
|
||
async function copyLink() {
|
||
try {
|
||
await navigator.clipboard.writeText(link);
|
||
copied = true;
|
||
setTimeout(() => (copied = false), 1600);
|
||
} catch {
|
||
// The link is on screen to copy by hand.
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>Waving Hands: a duel</title>
|
||
</svelte:head>
|
||
|
||
<header class="masthead">
|
||
<div>
|
||
<h1>Waving Hands</h1>
|
||
{#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}.
|
||
{#if !room.remote.connected}<span class="muted">Reconnecting to the duel.</span>{/if}
|
||
</p>
|
||
{:else}
|
||
<p class="standfirst">A duel between people, played by turns whenever each of you has a moment.</p>
|
||
{/if}
|
||
</div>
|
||
<nav class="actions">
|
||
{#if room}
|
||
<button type="button" class="quiet room" title="Copy the invite link" onclick={copyLink}>
|
||
<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{room.remote.galleryTalk ? ', talking' : ''}</span>
|
||
{/if}
|
||
{#if !watching}
|
||
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
|
||
{/if}
|
||
{#if room.isHost && room.remote?.started}
|
||
<button type="button" class="quiet" title="Bring a wizard back to a seat their browser lost, or give a seat to a bot" onclick={() => (seatsOpen = true)}>Seats</button>
|
||
{/if}
|
||
{/if}
|
||
<a class="quiet" href="/guide">How to play</a>
|
||
<a class="quiet" href="/rules">Rules</a>
|
||
{#if room}
|
||
<button type="button" class="quiet" title="Something behaved unexpectedly? Tell the keeper." onclick={() => (reporting = true)}>Report</button>
|
||
{/if}
|
||
<a class="quiet" href="/">The hall</a>
|
||
</nav>
|
||
</header>
|
||
|
||
{#if room}
|
||
<ReportSlip duel={room} bind:open={reporting} />
|
||
{#if room.isHost}<SeatsSlip duel={room} bind:open={seatsOpen} />{/if}
|
||
{/if}
|
||
|
||
{#if error}
|
||
<p class="notice">{error}</p>
|
||
{/if}
|
||
|
||
{#if phrase}
|
||
<p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p>
|
||
{/if}
|
||
|
||
{#if room?.remote?.started && !seatLost && room.remote.seats.some((s) => s.standIn)}
|
||
<p class="notice muted">A bot duels for {room.remote.seats.filter((s) => s.standIn).map((s) => s.name).join(', ')}, at the host's word, until a transfer phrase from the host brings them back.</p>
|
||
{/if}
|
||
|
||
{#if watching && room?.remote?.started && !room.state.over}
|
||
<div class="notice muted">
|
||
{#if seatLost}<p>{seatLost} Claim it here, or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a>.</p>
|
||
{:else}{#if room.remote.expected.length > 0}<p>{room.speaker(room.remote.host)} began the duel before {room.remote.expected.join(' and ')} arrived, and the seat held for {room.remote.expected.length === 1 ? 'them' : 'each'} went with it.</p>{/if}
|
||
<p>Had a seat at this duel? Ask the host for a transfer phrase, or make one on the device that still holds the seat, and claim it here. Or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a> for a link that brings it back.</p>{/if}
|
||
<form class="rename" onsubmit={claim}>
|
||
<input type="text" bind:value={claimPhrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
|
||
<button type="submit" class="quiet" disabled={claiming || claimPhrase.trim().split(/[\s-]+/).length < 4}>Claim the seat</button>
|
||
</form>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if room?.remote?.started && room.state.over && !watching}
|
||
{@const r = room.remote}
|
||
<p class="notice muted">
|
||
{#if r.rematch}
|
||
{room.speaker(r.rematch.by)} called for a rematch at table {r.rematch.to}.
|
||
<a href={`/join/${r.rematch.to}`}>Take your seat</a>
|
||
{:else}
|
||
<button type="button" class="quiet" disabled={rematching} onclick={rematch}>Duel again with this table</button>
|
||
<span>The same company sits down at a new table, with a seat held for everyone here.</span>
|
||
{/if}
|
||
</p>
|
||
{/if}
|
||
|
||
{#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. Choose a name and sit down. The duel begins when the host says.</p>
|
||
<form class="rename" onsubmit={takeSeat}>
|
||
<label class="field">
|
||
<span>Your name</span>
|
||
<input type="text" bind:value={name} maxlength={NAME_MAX} autocomplete="nickname" required />
|
||
</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>
|
||
{/if}
|
||
|
||
{#if room && lobby}
|
||
{@const r = room.remote!}
|
||
{@const hostName = r.seats.find((s) => s.id === r.host)?.name ?? 'the host'}
|
||
<section class="lobby">
|
||
{#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, so 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, up to a dozen, and the duel begins when you say. Anyone who arrives once it 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>
|
||
<span class="seat-name">{s.name}</span>
|
||
<span class="muted">{s.id === r.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === r.seat ? ', you' : ''}{#if s.bot && room.isHost}
|
||
<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}
|
||
{#each r.expected as name (name)}
|
||
<li>
|
||
<span class="seat-name">{name}</span>
|
||
<span class="muted">{r.challenge && name.toLowerCase() === r.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">{r.size - r.seats.length - r.expected.length} of {r.size} seats empty{#if r.audience > 0}; {r.audience} in the gallery{r.galleryTalk ? ', allowed to talk' : ''}{/if}</li>
|
||
</ul>
|
||
{#if r.rematchOf}
|
||
<p class="muted">This is a rematch of table {r.rematchOf}, with seats held for those not yet back.</p>
|
||
{/if}
|
||
{#if !watching}
|
||
<div class="ways">
|
||
{#if r.seats.length < r.size && room.isHost}
|
||
<button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button>
|
||
{/if}
|
||
{#if room.isHost && beginAsked && r.expected.length > 0}
|
||
<span>A seat is held for {r.expected.join(' and ')}, who {r.expected.length === 1 ? 'has' : 'have'} not arrived. The seat goes once the duel begins.</span>
|
||
<button type="button" class="reveal small" onclick={() => room?.begin()}>Begin without them</button>
|
||
<button type="button" class="link" onclick={() => (beginAsked = false)}>wait</button>
|
||
{:else if room.isHost}
|
||
<button type="button" class="reveal small" disabled={r.seats.length < 2} onclick={() => (r.expected.length > 0 ? (beginAsked = true) : 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 room.isHost}
|
||
<label class="switch muted">
|
||
<input type="checkbox" checked={r.galleryTalk} onchange={(e) => room?.setGalleryTalk(e.currentTarget.checked)} />
|
||
Let the Peanut Gallery talk at the table
|
||
</label>
|
||
{/if}
|
||
{#if r.keeper && !r.challenge && !r.seats.some((s) => s.name.toLowerCase() === r.keeper.toLowerCase()) && r.seats.length + r.expected.length < r.size}
|
||
<div class="keeper">
|
||
<button type="button" class="quiet" title="A seat is held for {r.keeper}, who keeps this site, and their phone rings" onclick={() => room?.callKeeper()}>Challenge {r.keeper}, the keeper</button>
|
||
<p class="muted small">{r.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {r.keeper} lives.{/if} The seat is held either way.</p>
|
||
</div>
|
||
{:else if r.challenge}
|
||
<p class="muted small">{r.keeper} has been called to the table by {room.speaker(r.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held for them. If they can come now, they will take it. If they are asleep or away, they will leave a word below when they can. In the meantime you can seat a bot, invite a friend, or play another duel in a new tab. This table keeps its place in your hall until you come back.</p>
|
||
{/if}
|
||
{/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">{joining ? 'Finding the table.' : 'Finding your seat.'}</p>
|
||
{/if}
|
||
|
||
<footer class="colophon">
|
||
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>. A rule read wrong or a bug found: <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
|
||
</footer>
|
||
|
||
<style>
|
||
.quiet.room b {
|
||
letter-spacing: 0.12em;
|
||
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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.phrase {
|
||
letter-spacing: 0.06em;
|
||
user-select: all;
|
||
}
|
||
|
||
.eyebrow {
|
||
font-size: 0.85rem;
|
||
letter-spacing: 0.12em;
|
||
color: var(--bone-faint);
|
||
margin-bottom: 0.2rem;
|
||
}
|
||
|
||
.notice {
|
||
max-width: 1280px;
|
||
margin: 0 auto;
|
||
padding: 0 var(--gutter) 0.5rem;
|
||
color: var(--blood);
|
||
}
|
||
|
||
.notice.muted {
|
||
color: var(--bone-dim);
|
||
}
|
||
|
||
.lobby {
|
||
max-width: 1280px;
|
||
margin: 0 auto;
|
||
padding: 1rem var(--gutter) 2rem;
|
||
}
|
||
|
||
.lobby h2 {
|
||
font-size: 1.4rem;
|
||
margin-bottom: 0.4rem;
|
||
}
|
||
|
||
.lobby p + p {
|
||
margin-top: 0.6rem;
|
||
}
|
||
|
||
.rename {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.5rem 0.75rem;
|
||
margin-top: 0.8rem;
|
||
}
|
||
|
||
.rename input {
|
||
width: 14em;
|
||
}
|
||
|
||
.roster {
|
||
list-style: none;
|
||
margin: 0.9rem 0 0.6rem;
|
||
padding: 0;
|
||
max-width: 28em;
|
||
}
|
||
|
||
.roster li {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
padding: 0.3rem 0;
|
||
border-bottom: 1px solid var(--rule);
|
||
}
|
||
|
||
.roster li.empty {
|
||
border-bottom: 0;
|
||
justify-content: flex-start;
|
||
font-style: italic;
|
||
}
|
||
|
||
.seat-name {
|
||
font-weight: 500;
|
||
}
|
||
|
||
.unseat {
|
||
background: none;
|
||
border: 0;
|
||
color: var(--bone-faint);
|
||
font-size: 1.1rem;
|
||
padding: 0 0.3rem;
|
||
margin-left: 0.3rem;
|
||
line-height: 1;
|
||
}
|
||
|
||
.unseat:hover {
|
||
color: var(--blood);
|
||
}
|
||
|
||
.ways {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.5rem 0.9rem;
|
||
margin-top: 0.4rem;
|
||
}
|
||
|
||
.link-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.5rem 0.75rem;
|
||
}
|
||
|
||
code {
|
||
font-family: var(--font);
|
||
background: var(--slate);
|
||
padding: 0.3rem 0.6rem;
|
||
border-radius: 4px;
|
||
word-break: break-all;
|
||
}
|
||
</style>
|