Gallery talk, the keeper's bell, rematches, seats that travel by phrase, and seats doubted before dropped
Brought over from the game kit, where they were ported from Wiz-War. The host may let the Peanut Gallery talk; a watcher signs a name no seat holds. A seated wizard may challenge Kestrel, the keeper: a seat is held, the call rings Sentry once per table, and the lobby says what hour it is where the keeper lives. A finished table may call for a rematch with the same company and bots, seats held. Transfer seat mints four words good for ten minutes and one use; the hall claims them. The hall forgets a held seat only on the second refusal in a row. 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
5b169d7131
commit
e5c240d970
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { allSeats, api, forgetSeat, type Report } from '$lib/game/client';
|
||||
import { otherGames, type FamilyGame } from '$lib/family';
|
||||
import { allSeats, api, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report } 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';
|
||||
@@ -12,6 +13,11 @@
|
||||
let seats = $state(allSeats());
|
||||
let games = $state<Record<string, RoomView | null>>({});
|
||||
let aboutOpen = $state(false);
|
||||
/** The other games of the Kestrel's Nest, for the about panel. */
|
||||
let family = $state<FamilyGame[]>([]);
|
||||
$effect(() => {
|
||||
void otherGames('waving-hands').then((g) => (family = g));
|
||||
});
|
||||
/** Reports this browser's seats have filed, with the keeper's replies. */
|
||||
let reports = $state<Report[]>([]);
|
||||
let answers = $state<Record<string, string>>({});
|
||||
@@ -44,21 +50,44 @@
|
||||
const local = $derived(duel.inProgress ? duel : null);
|
||||
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) => {
|
||||
trustSeat(held.roomId);
|
||||
games[held.roomId] = v;
|
||||
})
|
||||
.catch(() => {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
async function playBot() {
|
||||
busy = true;
|
||||
error = '';
|
||||
@@ -170,6 +199,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>
|
||||
|
||||
@@ -256,6 +290,14 @@
|
||||
<p>It is free and keeps no accounts. A duel against the bot lives in your own browser. A duel between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first gesture, and each wizard is shown only what the rules let them see.</p>
|
||||
<h3>Send word</h3>
|
||||
<p>A rule read wrong, a bug, a duel you would like to tell of: write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
|
||||
{#if family.length}
|
||||
<h3>More games at the Kestrel's Nest</h3>
|
||||
<ul class="family">
|
||||
{#each family as g (g.slug)}
|
||||
<li><a href={g.url}>{g.name}</a>: {g.pitch} <span class="muted">{g.players} players; {g.origin}.</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<p class="made">Waving Hands is Richard Bartle's, 1977. This page was made by Eric and a very enthusiastic AI, 2026.</p>
|
||||
</div>
|
||||
</details>
|
||||
@@ -367,6 +409,15 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.claimform {
|
||||
flex-wrap: wrap;
|
||||
margin-top: -0.2rem;
|
||||
}
|
||||
|
||||
.claimform input {
|
||||
width: 15em;
|
||||
}
|
||||
|
||||
.or {
|
||||
color: var(--bone-dim);
|
||||
font-style: italic;
|
||||
@@ -620,4 +671,12 @@
|
||||
.about-body a {
|
||||
color: var(--frost);
|
||||
}
|
||||
.family {
|
||||
margin: 0.4rem 0 0.8rem 1.2rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.family li + li {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { Duel } from '$lib/game/duel.svelte';
|
||||
import { NAME_MAX, playerName, rememberName, 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('');
|
||||
/** 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);
|
||||
|
||||
@@ -26,6 +28,17 @@
|
||||
if (await duel.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 duel.sayFromGallery(name, text)) draft = '';
|
||||
sending = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if remote}
|
||||
@@ -36,11 +49,17 @@
|
||||
<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>
|
||||
<li class:you={!line.name && line.id === duel.me}><em>{line.name ? `${line.name} (gallery)` : 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>
|
||||
{#if remote.spectating && remote.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 remote.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" />
|
||||
@@ -105,6 +124,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input.name {
|
||||
flex: 0 1 8rem;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
background: none;
|
||||
border: 1px solid var(--rule-strong);
|
||||
|
||||
Reference in New Issue
Block a user