The host can give a stuck player's seat to the clockwork and make a phrase that brings a seat back, and a socket in a background tab no longer drops every minute

A "seats" button beside "transfer seat" opens the host's panel. In a game
under way, "give to the clockwork" lets an automaton play a person's seat
from the next beat, with their hand, treasures and place untouched; a
standIn line on the ledger records it, the chronicle says so, the
stand-in never talks under the person's name, and the person's token
opens nothing after. "Make a phrase" mints the four-word phrase for any
person's seat; claiming it re-seats on a new token, and a seat the
clockwork stood in for goes back to its person mid-turn. A rematch holds
a stood-in seat for its person. The gallery now has its own claim box,
for a player who opens the invite link in a fresh browser.

The client's watchdog pinged and then closed a socket quiet for 45
seconds. A background tab's timers run about once a minute, so the last
pong was always a minute old when it looked, and idle tabs reconnected
every 58 seconds. It now closes only when its own ping went unanswered.
The server pings every socket at the protocol level every 30 seconds and
drops one that left the last ping unanswered, so a crashed browser's
socket no longer lingers until TCP gives up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-10-16 15:43:24 -04:00
co-authored by Claude Opus 5.5
parent 7ef0c1bc14
commit 4b7e05713d
10 changed files with 476 additions and 39 deletions
+77 -2
View File
@@ -32,6 +32,12 @@
const hall = $derived(kin.hall);
let joinCode = $state("");
let claimPhrase = $state("");
/** The host's seats panel: hand a seat to the clockwork, or make a phrase for it. */
let seatsOpen = $state(false);
/** The seat the host asked to give the clockwork, waiting on a second click. */
let standInAsked = $state<string | null>(null);
/** The people's seats besides the host's own: the clockwork the host seated is not among them. */
const peopleSeats = $derived(net.players.filter((p) => p !== net.you && (!net.roomBots[p] || net.standIns.includes(p))));
/** A /join/<CODE> recruiting link. The visitor lands looking at the
* room: a held seat walks them back to it, a waiting room offers a
* seat or the gallery, a started game is watched from the gallery. */
@@ -2030,6 +2036,10 @@
{#if !net.spectating}
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
{/if}
{#if !net.spectating && net.you === net.hostId && peopleSeats.length > 0}
<button class="mast-leave" title="Make a phrase that brings a wizard back to their seat, or let the clockwork play for someone who cannot."
onclick={() => { seatsOpen = !seatsOpen; standInAsked = null; }}>seats</button>
{/if}
<button class="mast-leave" onclick={() => net.leave()}>{net.spectating ? "leave the gallery" : "leave table"}</button>
<button class="mast-leave" title="Something behaved unexpectedly? Tell the keeper."
onclick={() => (feedbackOpen = true)}>🐞 report</button>
@@ -2563,6 +2573,40 @@
</div>
{/if}
{#if seatsOpen && net.you === net.hostId && !net.spectating}
<div class="slip seats-slip">
<div class="seats-head">
<span>Seats at your table</span>
<button class="hint-cancel" onclick={() => { seatsOpen = false; standInAsked = null; }}>close</button>
</div>
{#each peopleSeats as p (p)}
{@const standing = net.standIns.includes(p)}
<div class="seats-row">
<span class="seats-name">{p}{#if standing} <span class="seats-note">⚙ the clockwork plays for them</span>{/if}</span>
<button class="stamp tiny" title="A phrase for {p} to claim. Their old link stops opening the seat."
onclick={() => net.makeSeatPhrase(p)}>{standing ? "phrase to take it back" : "make a phrase"}</button>
{#if !standing && net.started && view?.phase === "playing" && view.players.find((w) => w.id === p)?.alive}
{#if standInAsked === p}
<button class="stamp tiny primary" onclick={() => { net.standIn(p); standInAsked = null; }}>yes, the clockwork plays for {p}</button>
<button class="hint-cancel" onclick={() => (standInAsked = null)}>no</button>
{:else}
<button class="stamp tiny" title="{p} keeps their hand, treasures and place, and an automaton takes their turns until you make them a phrase to take it back."
onclick={() => (standInAsked = p)}>give to the clockwork</button>
{/if}
{/if}
</div>
{/each}
</div>
{/if}
{#if net.seatPhrase}
<div class="slip transfer-slip">
Give {net.seatPhrase.name} this phrase to claim within ten minutes, on the front page or while watching this table. It works once, and their old link stops opening the seat.
<strong class="transfer-phrase">{net.seatPhrase.code}</strong>
<button class="hint-cancel" onclick={() => (net.seatPhrase = null)}>dismiss</button>
</div>
{/if}
{#if local.setup}
<div class="scrim-handoff">
<div class="handoff-card">
@@ -3272,7 +3316,7 @@
{#if view.phase === "finished"}
<div class="slip winner">🏆 {view.winner} wins!
{#if !local.active}
{@const otherHumans = net.players.some((p) => p !== net.you && !net.roomBots[p])}
{@const otherHumans = net.players.some((p) => p !== net.you && (!net.roomBots[p] || net.standIns.includes(p)))}
{#if net.rematchCall}
{#if net.rematchCall.by === net.you}
<button class="stamp tiny" onclick={() => net.acceptRematch(net.rematchCall!.roomId)}>back to the rematch</button>
@@ -3770,7 +3814,15 @@
{#if net.spectating}
<span class="gallery-note">👁 You watch from the Peanut Gallery, and every hand stays secret, even from you.</span>
{#if view.phase !== "finished"}
<span class="gallery-note">Had a seat at this table on another device? Choose <em>transfer seat</em> there and claim the phrase on the front page, or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${net.roomId}`)}">write to the keeper</a> for a link that brings it back.</span>
<span class="gallery-note">Had a seat at this table on another device? Choose <em>transfer seat</em> there, or ask {net.hostId ?? "the host"} for a phrase, and claim it here. Failing that, <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${net.roomId}`)}">write to the keeper</a> for a link that brings it back.</span>
<span class="claim-row gallery-claim">
<input class="claim-input" bind:value={claimPhrase}
placeholder="ember-troll-dagger-moss" aria-label="seat transfer phrase" />
<button class="stamp tiny" disabled={!claimPhrase.trim()}
onclick={() => { net.claimTransfer(claimPhrase); claimPhrase = ""; }}>
Claim the seat
</button>
</span>
{/if}
{/if}
{#each view.phase === "finished" ? [] : orderedHand as card (card.instanceId)}
@@ -4366,6 +4418,29 @@
border-radius: 3px;
user-select: all;
}
.seats-slip {
width: min(34rem, 100%);
box-sizing: border-box;
margin: 0 auto 0.7rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.seats-head {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
}
.seats-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.seats-name { flex: 1 1 8rem; }
.seats-note { font-weight: normal; font-style: italic; opacity: 0.8; margin-left: 0.4em; }
.gallery-claim { margin-top: 0.4rem; }
.claim-row {
display: flex;
flex-wrap: wrap;
+35 -1
View File
@@ -223,6 +223,9 @@ export function humanize(e: GameEvent): string | null {
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name}, and their actions are over.`;
case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`;
case "tableTalk": return `\u{1F4AC} ${e.player}: ${e.text}`;
case "seatChanged": return e.to === "clockwork"
? `⚙ ${e.by ?? "The host"} gives ${e.player}'s seat to the clockwork, which plays on in their place.`
: `${e.player} takes their seat back from the clockwork.`;
case "dieRolled": return `\u{1F3B2} ${e.player ?? "The maze"} rolls a ${e.roll} (${e.purpose}).`;
case "pushed": return e.player
? `${e.by} shoves ${e.player} down the corridor!`
@@ -359,6 +362,10 @@ class Net {
* socket that never fires onclose; the watchdog closes it by hand so
* the reconnect loop (and the masthead banner) actually engage. */
private lastHeard = Date.now();
/** When the watchdog's last ping went out. A socket is dead when that
* ping went unanswered, not when it has been quiet a while: a hidden
* tab's timers may run only once a minute, so quiet proves nothing. */
private pingSentAt: number | null = null;
/** Whose turn the chronicle is in, for the mark on lines with no actor of their own. */
private turnOwner: string | null = null;
private watchdog: ReturnType<typeof setInterval> | null = null;
@@ -369,6 +376,10 @@ class Net {
/** Lobby standee choices, by player name. */
roomColors = $state<Record<string, number>>({});
roomBots = $state<Record<string, string>>({});
/** People's seats the clockwork plays for now, at the host's word. */
standIns = $state<string[]>([]);
/** A phrase the host made for another wizard's seat, to pass to them. */
seatPhrase = $state<{ name: string; code: string; expiresAt: number } | null>(null);
you = $state<string | null>(null);
/** Seated in the Peanut Gallery: watching nameless, read-only. */
spectating = $state(false);
@@ -462,11 +473,16 @@ class Net {
const ws = new WebSocket(SERVER_URL);
this.ws = ws;
this.lastHeard = Date.now();
this.pingSentAt = null;
if (!this.watchdog) {
this.watchdog = setInterval(() => {
if (!this.ws || this.status !== "connected") return;
if (this.pingSentAt !== null && this.lastHeard < this.pingSentAt) {
this.ws.close();
return;
}
this.pingSentAt = Date.now();
this.send({ type: "ping" });
if (Date.now() - this.lastHeard > 45_000) this.ws.close();
}, 15_000);
}
ws.onopen = () => {
@@ -558,6 +574,7 @@ class Net {
this.roomId = msg.roomId;
this.roomColors = msg.colors ?? {};
this.roomBots = msg.bots ?? {};
this.standIns = msg.standIns ?? [];
this.audience = msg.audience ?? 0;
this.rematchCall = msg.rematch ?? null;
this.expected = msg.expected ?? [];
@@ -652,6 +669,13 @@ class Net {
this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }];
this.leaveLocal();
break;
case "standingIn":
this.log = [...this.log, { text: `— the host gave your seat in ${msg.roomId} to the clockwork —`, turn: null, notable: false }];
this.leaveLocal();
break;
case "seatPhrase":
this.seatPhrase = { name: msg.name, code: msg.code, expiresAt: msg.expiresAt };
break;
case "roomAbandoned":
this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }];
this.leaveLocal();
@@ -862,6 +886,16 @@ class Net {
this.send({ type: "kickSeat", name });
}
/** Host only: the clockwork plays this person's seat from here. */
standIn(name: string): void {
this.send({ type: "standIn", name });
}
/** Host only: a phrase that brings this person's seat to whoever claims it. */
makeSeatPhrase(name: string): void {
this.send({ type: "makeSeatPhrase", name });
}
abandonRoom(): void {
this.send({ type: "abandonRoom" });
}