Files
waving-hands/src/routes/join/[code]/+page.svelte
T

531 lines
19 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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}&nbsp;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}&nbsp;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>