Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
273 lines
9.5 KiB
Svelte
273 lines
9.5 KiB
Svelte
<script lang="ts">
|
||
import { untrack } from 'svelte';
|
||
import TableTalk from './TableTalk.svelte';
|
||
import { game } from '$lib/game';
|
||
import type { Room } from '$lib/net/room.svelte';
|
||
|
||
let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props();
|
||
|
||
const v = $derived(room.view);
|
||
/** The host asked to begin while a seat is still held for someone on their way. */
|
||
let beginAsked = $state(false);
|
||
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
|
||
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
|
||
|
||
// A table that asks for rankings: this seat's, best first, kept on the server as it changes.
|
||
const choices = $derived(game.rankable?.(v.options) ?? null);
|
||
const people = $derived(v.seats.filter((s) => !s.bot).length);
|
||
const labelOf = (value: string) => choices?.find((c) => c.value === value)?.label ?? value;
|
||
let order = $state<string[]>([]);
|
||
// Follows the server's copy; the local order is read untracked, so a click is not undone before its reply.
|
||
$effect(() => {
|
||
if (!choices) return;
|
||
const values = choices.map((c) => c.value);
|
||
const mine = v.yourRanking;
|
||
if (mine && mine.length === values.length) order = [...mine];
|
||
else if (untrack(() => order.length) !== values.length) order = values;
|
||
});
|
||
/** Move the choice at `i` to place `j`, the others closing up behind it. */
|
||
async function move(i: number, j: number) {
|
||
if (j < 0 || j >= order.length || j === i) return;
|
||
const next = [...order];
|
||
next.splice(j, 0, ...next.splice(i, 1));
|
||
order = next;
|
||
await room.rank(next);
|
||
}
|
||
|
||
/** 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;
|
||
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 '';
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<section class="lobby">
|
||
{#if room.spectating}
|
||
<h2>At the table</h2>
|
||
{#if room.seatEmpty}
|
||
<p>{hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.</p>
|
||
{:else}
|
||
<p>The table is full, so you watch from the Peanut Gallery. The game begins when {hostName} says.</p>
|
||
{/if}
|
||
{:else}
|
||
<p class="eyebrow">room {v.roomId}</p>
|
||
<h2>The table is laid</h2>
|
||
<p>Send this link, or the code. Whoever opens it takes a seat, and the game 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 v.seats as s (s.id)}
|
||
<li>
|
||
<span class="seat-name">{s.name}</span>
|
||
<span class="muted">{s.id === v.host ? 'opened the table' : s.bot ? 'a drake' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost}
|
||
<button type="button" class="dismiss" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room.unseat(s.id)}>×</button>{/if}</span>
|
||
</li>
|
||
{/each}
|
||
{#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">This is a rematch of table {v.rematchOf}, with seats held for those not yet back.</p>
|
||
{/if}
|
||
{#if choices && !room.spectating}
|
||
<section class="ranking" aria-label="Your ranking">
|
||
<h3>Your ranking</h3>
|
||
<p class="muted small">
|
||
Best first. When the game begins, the deal reads everyone's ranking, and nobody sees another player's.
|
||
{#if !v.yourRanking}Until you move one, you have no preference and take what is left.{:else}Your ranking is kept.{/if}
|
||
</p>
|
||
<ol>
|
||
{#each order as value, i (value)}
|
||
<li>
|
||
<span class="place">{i + 1}.</span>
|
||
<span class="choice">{labelOf(value)}</span>
|
||
<button type="button" class="quiet" aria-label="Move {labelOf(value)} to the top" title="To the top" disabled={i === 0} onclick={() => move(i, 0)}>⤒</button>
|
||
<button type="button" class="quiet" aria-label="Move {labelOf(value)} up" disabled={i === 0} onclick={() => move(i, i - 1)}>↑</button>
|
||
<button type="button" class="quiet" aria-label="Move {labelOf(value)} down" disabled={i === order.length - 1} onclick={() => move(i, i + 1)}>↓</button>
|
||
</li>
|
||
{/each}
|
||
</ol>
|
||
{#if !v.yourRanking}<button type="button" class="quiet" onclick={() => room.rank(order)}>Keep this order</button>{/if}
|
||
{#if people > 1}<p class="muted small">{v.ranked} of the {people} players {v.ranked === 1 ? 'has' : 'have'} ranked.</p>{/if}
|
||
</section>
|
||
{/if}
|
||
{#if !room.spectating}
|
||
<div class="ways">
|
||
{#if room.seatEmpty && room.isHost}
|
||
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a drake</button>
|
||
{/if}
|
||
{#if room.isHost && beginAsked && v.expected.length > 0}
|
||
<span>A seat is held for {v.expected.join(' and ')}, who {v.expected.length === 1 ? 'has' : 'have'} not arrived. The seat goes once the game begins.</span>
|
||
<button type="button" class="commit 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="commit small" disabled={v.seats.length < 2} onclick={() => (v.expected.length > 0 ? (beginAsked = true) : room.begin())}>Begin</button>
|
||
<span class="muted">{v.seats.length < 2 ? 'once a second player is seated' : `with ${v.seats.length} players`}</span>
|
||
{:else}
|
||
<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 && room.seatUnheld}
|
||
<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 note">{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 note">{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 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 drake, invite a friend, or play another game in a new tab. 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>
|
||
</section>
|
||
|
||
<style>
|
||
.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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.ranking {
|
||
max-width: 26rem;
|
||
margin: 1rem 0;
|
||
}
|
||
|
||
.ranking h3 {
|
||
margin: 0 0 0.2rem;
|
||
font-size: 1.05rem;
|
||
}
|
||
|
||
.ranking ol {
|
||
list-style: none;
|
||
margin: 0.4rem 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.ranking li {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
padding: 0.15rem 0;
|
||
}
|
||
|
||
.ranking .place {
|
||
width: 1.6rem;
|
||
color: var(--bone-dim);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.ranking .choice {
|
||
flex: 1;
|
||
}
|
||
|
||
.ranking .quiet {
|
||
min-width: 2.6rem;
|
||
min-height: 2.4rem;
|
||
padding: 0.2rem 0.5rem;
|
||
}
|
||
|
||
.keeper {
|
||
margin-top: 0.8rem;
|
||
max-width: 38em;
|
||
}
|
||
|
||
.note {
|
||
margin-top: 0.4rem;
|
||
}
|
||
</style>
|