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 WagonerandClaude Opus 5.5 committed 2026-10-16 15:43:24 -04:00
1 parent 7ef0c1bc14
commit 4b7e05713d
10 files changed
+476 -39

No files matched your search

+5 -4
View File
@@ -8,7 +8,7 @@
import { timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { PlayerId } from "@wizwar/engine";
import type { GameEvent, PlayerId } from "@wizwar/engine";
import { getRoom, reseat, seatsForKeeper, type Room } from "./rooms";
function send(res: ServerResponse, status: number, body: unknown): void {
@@ -45,8 +45,9 @@ export interface KeeperDoor {
port: number;
publicUrl: string;
keyFile: string;
/** Lets the caller set adrift any socket still seated on the old token. */
onReseat: (room: Room, name: PlayerId) => void;
/** Lets the caller set adrift any socket still seated on the old token,
* and tell the table when the seat comes back from the clockwork. */
onReseat: (room: Room, name: PlayerId, returned?: GameEvent) => void;
}
async function handle(req: IncomingMessage, res: ServerResponse, { publicUrl, keyFile, onReseat }: KeeperDoor): Promise<void> {
@@ -63,7 +64,7 @@ async function handle(req: IncomingMessage, res: ServerResponse, { publicUrl, ke
const name = await readSeat(req);
const result = reseat(room, name);
if ("error" in result) return send(res, 404, result);
onReseat(room, name);
onReseat(room, name, result.returned);
// The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor the server.
return send(res, 200, { name, link: `${publicUrl}/join/${room.id}#seat=${encodeURIComponent(name)}.${result.token}` });
}