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:
co-authored by
Claude Opus 5.5
parent
7ef0c1bc14
commit
4b7e05713d
+6
-1
@@ -53,7 +53,12 @@ longer replays becomes unreachable after restart, so this is not optional.
|
||||
script, configure rclone (the script refuses to run while the config still
|
||||
holds its CHANGE_ME placeholder), and add the cron entry by hand.
|
||||
- A player whose browser lost their seat (Safari's seven days, an app's own
|
||||
browser, cleared data) writes to the keeper. On the droplet,
|
||||
browser, cleared data) asks the host, whose "seats" button makes a
|
||||
four-word phrase that re-seats them on a new token. The same panel lets the
|
||||
host give a seat to the clockwork in a game under way, for a player who
|
||||
cannot take their turns; a `standIn` line on the ledger records it, and a
|
||||
later reseat gives the seat back. Failing the host, they write to the
|
||||
keeper. On the droplet,
|
||||
`wizwar-reseat.sh ROOM` lists who sits at the table and when each last
|
||||
moved or spoke; `wizwar-reseat.sh ROOM NAME` gives that wizard's seat a new
|
||||
token and prints a link carrying it in the fragment. The old token opens
|
||||
|
||||
@@ -41,6 +41,7 @@ for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"):
|
||||
ca = meta.get("createdAt", "")
|
||||
if not ca: continue
|
||||
humans = [meta["hostId"]]; bots = []; started = False; cmds = 0; chat = []; last = ca; steps = []
|
||||
standing = set(); stood = [] # seats the host gave the clockwork, and who and when
|
||||
for l in lines[1:]:
|
||||
try: x = json.loads(l)
|
||||
except Exception: continue
|
||||
@@ -48,15 +49,19 @@ for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"):
|
||||
if k == "join":
|
||||
(bots if x.get("bot") else humans).append(x["name"])
|
||||
elif k == "start": started = True
|
||||
elif k == "standIn":
|
||||
standing.add(x["name"]); stood.append((x.get("at", "")[11:16], x["name"], "to the clockwork"))
|
||||
elif k == "reseat" and x["name"] in standing:
|
||||
standing.discard(x["name"]); stood.append((x.get("at", "")[11:16], x["name"], "back to a person"))
|
||||
elif k == "command":
|
||||
cmds += 1; last = x.get("at", last)
|
||||
steps.append((x.get("playerId"), x["command"].get("type"), x.get("at", "")))
|
||||
steps.append((x.get("playerId"), x["command"].get("type"), x.get("at", ""), x.get("playerId") in standing))
|
||||
elif k == "chat":
|
||||
last = x.get("at", last)
|
||||
if x["player"] in humans: chat.append((x.get("at", "")[11:16], x["player"], x["text"]))
|
||||
for h in humans:
|
||||
if h not in first_seen or ca < first_seen[h]: first_seen[h] = ca
|
||||
rooms.append(dict(id=meta["id"], created=ca, humans=humans, bots=bots, started=started, cmds=cmds, last=last, chat=chat, steps=steps))
|
||||
rooms.append(dict(id=meta["id"], created=ca, humans=humans, bots=bots, started=started, cmds=cmds, last=last, chat=chat, steps=steps, stood=stood))
|
||||
|
||||
today = sorted([r for r in rooms if r["created"].startswith(day)], key=lambda r: r["created"])
|
||||
names = sorted({h for r in today for h in r["humans"]})
|
||||
@@ -68,12 +73,13 @@ for r in today:
|
||||
if r["cmds"]:
|
||||
t0 = datetime.datetime.fromisoformat(r["created"].replace("Z", "+00:00")); t1 = datetime.datetime.fromisoformat(r["last"].replace("Z", "+00:00"))
|
||||
span = "%d min" % round((t1 - t0).total_seconds() / 60)
|
||||
human_cmds = [s for s in r["steps"] if s[0] in r["humans"]]
|
||||
human_cmds = [s for s in r["steps"] if s[0] in r["humans"] and not s[3]]
|
||||
last_human = ("last human move: %s %s at %s" % (human_cmds[-1][0], human_cmds[-1][1], human_cmds[-1][2][11:16])) if human_cmds else "no human move"
|
||||
state = "lobby only" if not r["started"] else ("started, no moves" if not r["cmds"] else "in play")
|
||||
print(" %s %s %s%s %s %d moves (%d human) %s %s" % (
|
||||
r["created"][11:16], r["id"], "+".join(r["humans"]), (" vs %d bot%s" % (len(r["bots"]), "" if len(r["bots"]) == 1 else "s")) if r["bots"] else "",
|
||||
state, r["cmds"], len(human_cmds), span, last_human))
|
||||
for at, who, what in r["stood"]: print(" %s %s's seat goes %s" % (at, who, what))
|
||||
for at, who, text in r["chat"]: print(" %s %s: %s" % (at, who, text))
|
||||
|
||||
# --- the desk ---
|
||||
|
||||
@@ -706,6 +706,9 @@ export type GameEvent =
|
||||
| { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null }
|
||||
| { type: "dieRolled"; player: PlayerId | null; roll: number; purpose: string }
|
||||
| { type: "tableTalk"; player: PlayerId; text: string }
|
||||
/** The room's word, not the maze's: the host gave a person's seat to the
|
||||
* clockwork, or the seat went back to a person. */
|
||||
| { type: "seatChanged"; player: PlayerId; to: "clockwork" | "person"; by?: PlayerId }
|
||||
| { type: "retreatedInHorror"; player: PlayerId; from: Cell; to: Cell }
|
||||
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
|
||||
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
GALLERY_MARK,
|
||||
driveOneAutomaton,
|
||||
makeTransferCode,
|
||||
makeSeatPhrase,
|
||||
redactFor,
|
||||
roomCount,
|
||||
runCommand,
|
||||
@@ -74,6 +75,7 @@ import {
|
||||
viewForPlayer,
|
||||
type Room,
|
||||
kickSeat,
|
||||
standIn,
|
||||
abandonRoom,
|
||||
KEEPER,
|
||||
MAX_SEATS,
|
||||
@@ -544,11 +546,19 @@ openKeeperDoor({
|
||||
port: Number(process.env.KEEPER_PORT ?? 9787),
|
||||
publicUrl: PUBLIC_URL,
|
||||
keyFile: process.env.KEEPER_KEY_FILE ?? "/etc/wizwar/keeper.key",
|
||||
onReseat: (room, name) => {
|
||||
for (const s of sessions) if (s.roomId === room.id && s.playerId === name) s.socket.close();
|
||||
onReseat: (room, name, returned) => {
|
||||
closeSeat(room, name);
|
||||
if (returned) broadcast(room, () => ({ type: "events", events: [returned] }));
|
||||
broadcastRoomState(room);
|
||||
},
|
||||
});
|
||||
|
||||
/** Close every socket still seated as NAME: its reconnect offers the old
|
||||
* token, which opens nothing now. */
|
||||
function closeSeat(room: Room, name: PlayerId): void {
|
||||
for (const s of sessions) if (s.roomId === room.id && s.playerId === name) s.socket.close();
|
||||
}
|
||||
|
||||
interface Session {
|
||||
socket: WebSocket;
|
||||
playerId: PlayerId | null;
|
||||
@@ -568,6 +578,8 @@ interface Session {
|
||||
roomsCreated: number;
|
||||
lastCatchUpAt: number;
|
||||
hotseatReports: number;
|
||||
/** Answered the last protocol ping. */
|
||||
alive: boolean;
|
||||
}
|
||||
|
||||
function underRateLimit(s: Session): boolean {
|
||||
@@ -581,6 +593,21 @@ function underRateLimit(s: Session): boolean {
|
||||
|
||||
const sessions = new Set<Session>();
|
||||
|
||||
// A browser that crashes or loses its network never closes its socket, and
|
||||
// TCP may take minutes to notice. Every beat each socket is pinged at the
|
||||
// protocol level, which browsers answer on their own, and one that left the
|
||||
// last ping unanswered is dropped.
|
||||
setInterval(() => {
|
||||
for (const s of sessions) {
|
||||
if (!s.alive) {
|
||||
s.socket.terminate();
|
||||
continue;
|
||||
}
|
||||
s.alive = false;
|
||||
s.socket.ping();
|
||||
}
|
||||
}, Number(process.env.WIZWAR_PING_MS ?? 30_000));
|
||||
|
||||
function send(socket: WebSocket, message: unknown): void {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
|
||||
}
|
||||
@@ -620,12 +647,15 @@ function roomInfo(room: Room) {
|
||||
keeperZone: KEEPER_ZONE,
|
||||
galleryTalk: room.galleryTalk,
|
||||
colors: Object.fromEntries(room.colorChoices),
|
||||
/** People's seats the clockwork is playing for now. */
|
||||
standIns: [...room.bots].filter(([, b]) => b.standIn).map(([name]) => name),
|
||||
bots: Object.fromEntries(
|
||||
// A mystery machine keeps its mood only while the game lives: once it
|
||||
// ends, the hands go face-up and so does the temperament.
|
||||
[...room.bots].map(([name, b]) => [
|
||||
name,
|
||||
room.state?.phase === "finished"
|
||||
b.standIn ? "standing in"
|
||||
: room.state?.phase === "finished"
|
||||
? `${b.tier} ${b.style}${b.secret ? " 🎭" : ""}`
|
||||
: `${b.tier} ${b.secret ? "mystery" : b.style}`,
|
||||
]),
|
||||
@@ -679,6 +709,8 @@ function banterTrigger(
|
||||
* deeds, bystanders on their suffering. Rarely, and one voice at most. */
|
||||
function botRemark(room: Room, actor: string, events: { type: string; [k: string]: unknown }[]): void {
|
||||
for (const [seat, bot] of room.bots) {
|
||||
// A stand-in plays under a person's name, and does not speak for them.
|
||||
if (bot.standIn) continue;
|
||||
const lines = BOT_LINES[bot.style] ?? {};
|
||||
for (const e of events) {
|
||||
const trigger = banterTrigger(e, seat, actor);
|
||||
@@ -720,7 +752,7 @@ function runBots(room: Room): void {
|
||||
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
|
||||
// A refused brain-choice becomes a visible stumble, in character —
|
||||
// an idle bot turn should read as hesitation, never as nothing.
|
||||
if (step.hesitated) {
|
||||
if (step.hesitated && !room.bots.get(step.seat)?.standIn) {
|
||||
const said = addChat(room, step.seat, HESITATION_LINES[Math.floor(Math.random() * HESITATION_LINES.length)]!);
|
||||
if (!("error" in said)) {
|
||||
broadcast(room, () => ({ type: "chat", player: step.seat, text: said.text, at: said.at }));
|
||||
@@ -749,9 +781,10 @@ wss.on("connection", (socket, req) => {
|
||||
socket, playerId: null, roomId: null, spectator: false, token: null, claimFails: 0,
|
||||
address: clientAddress(req.headers, req.socket.remoteAddress),
|
||||
bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0,
|
||||
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0,
|
||||
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0, alive: true,
|
||||
};
|
||||
sessions.add(session);
|
||||
socket.on("pong", () => { session.alive = true; });
|
||||
// A socket's own error (a frame over the limit, a malformed one) closes that socket, never the server.
|
||||
socket.on("error", () => socket.terminate());
|
||||
send(socket, { type: "welcome", game: "wizwar" });
|
||||
@@ -904,6 +937,35 @@ wss.on("connection", (socket, req) => {
|
||||
broadcastRoomState(room);
|
||||
break;
|
||||
}
|
||||
case "standIn": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
const name = String(msg.name ?? "");
|
||||
const result = standIn(room, session.playerId, name);
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
// The person's live sockets are set adrift: the seat plays itself now.
|
||||
for (const other of sessions) {
|
||||
if (other.roomId === room.id && other.playerId === name) {
|
||||
other.playerId = null;
|
||||
other.roomId = null;
|
||||
other.token = null;
|
||||
send(other.socket, { type: "standingIn", roomId: room.id });
|
||||
}
|
||||
}
|
||||
broadcast(room, () => ({ type: "events", events: [result.event] }));
|
||||
broadcastRoomState(room);
|
||||
runBots(room);
|
||||
break;
|
||||
}
|
||||
case "makeSeatPhrase": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
const name = String(msg.name ?? "");
|
||||
const result = makeSeatPhrase(room, session.playerId, name);
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
send(socket, { type: "seatPhrase", name, code: result.code, expiresAt: result.expiresAt });
|
||||
break;
|
||||
}
|
||||
case "abandonRoom": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
@@ -1036,7 +1098,13 @@ wss.on("connection", (socket, req) => {
|
||||
return send(socket, { type: "error", message: result.error });
|
||||
}
|
||||
session.claimFails = 0;
|
||||
send(socket, { type: "transferClaimed", seat: result });
|
||||
const claimed = getRoom(result.roomId);
|
||||
if (result.reseated && claimed) {
|
||||
closeSeat(claimed, result.name);
|
||||
if (result.returned) broadcast(claimed, () => ({ type: "events", events: [result.returned] }));
|
||||
broadcastRoomState(claimed);
|
||||
}
|
||||
send(socket, { type: "transferClaimed", seat: { roomId: result.roomId, name: result.name, token: result.token } });
|
||||
break;
|
||||
}
|
||||
case "catchUp": {
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
|
||||
@@ -49,8 +49,9 @@ export interface Room {
|
||||
events: GameEvent[]; // full history (unredacted — redact per recipient)
|
||||
/** Table talk, persisted with the room (public to all seats). */
|
||||
chat: { player: PlayerId; text: string; at: string }[];
|
||||
/** Seats the server itself plays: temperament, tier, and secrecy. */
|
||||
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
|
||||
/** Seats the server itself plays: temperament, tier, and secrecy. A
|
||||
* stand-in plays a person's seat the host handed over mid-game. */
|
||||
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier; standIn?: true }>;
|
||||
/** Last time anything looked at this room (memory eviction clock). */
|
||||
touchedAt?: number;
|
||||
/** A finished table that called for a rematch: where it went, and who called. */
|
||||
@@ -173,9 +174,10 @@ export function callRematch(
|
||||
if (room.state?.phase !== "finished") return { error: "the game is not over yet" };
|
||||
if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "only a wizard of this table may call a rematch" };
|
||||
if (room.rematch) return { roomId: room.rematch.roomId, token: null, created: false };
|
||||
const humans = room.players.filter((p) => !room.bots.has(p) && p !== byId);
|
||||
// A seat the clockwork stood in for is still a person's, and is held for them.
|
||||
const humans = room.players.filter((p) => personSeat(room, p) && p !== byId);
|
||||
const { room: next, token } = createRoom(byId, { of: room.id, expected: humans, expansion: room.expansion });
|
||||
for (const [, b] of room.bots) addAutomaton(next, b.secret ? undefined : b.style, b.tier);
|
||||
for (const [, b] of room.bots) if (!b.standIn) addAutomaton(next, b.secret ? undefined : b.style, b.tier);
|
||||
room.rematch = { roomId: next.id, by: byId };
|
||||
appendLine(room.id, { kind: "rematch", to: next.id, by: byId, at: new Date().toISOString() });
|
||||
return { roomId: next.id, token, created: true };
|
||||
@@ -299,6 +301,7 @@ export function joinRoom(
|
||||
token: string | null,
|
||||
): { token: string } | { error: string } {
|
||||
if (room.players.includes(playerId)) {
|
||||
if (room.bots.get(playerId)?.standIn) return { error: "the host gave this seat to the clockwork; ask them for a phrase to take it back" };
|
||||
// Reclaiming an existing seat requires that seat's secret.
|
||||
if (tokenMatches(room, playerId, token)) return { token: token! };
|
||||
return { error: "that wizard name is taken in this room" };
|
||||
@@ -331,17 +334,64 @@ export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | n
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The keeper gives a person's seat a new token, for a player whose browser
|
||||
* lost the old one. Whether the one asking holds the seat is the keeper's
|
||||
* judgment; this carries it out. The old token opens nothing after. */
|
||||
export function reseat(room: Room, name: PlayerId): { token: string } | { error: string } {
|
||||
if (!room.tokens.has(name) || room.bots.has(name)) return { error: "no person sits in that seat" };
|
||||
/** A person's seat gets a new token, for a player whose browser lost the
|
||||
* old one: the keeper's door asks for it, or a phrase the host made is
|
||||
* claimed. Whether the one asking holds the seat is their judgment; this
|
||||
* carries it out. The old token opens nothing after, and a seat the
|
||||
* clockwork was standing in for goes back to a person. */
|
||||
export function reseat(room: Room, name: PlayerId): { token: string; returned?: GameEvent } | { error: string } {
|
||||
if (!personSeat(room, name)) return { error: "no person sits in that seat" };
|
||||
const token = randomBytes(16).toString("hex");
|
||||
room.tokens.set(name, hashToken(token));
|
||||
const returned = seatToPerson(room, name, hashToken(token));
|
||||
appendLine(room.id, { kind: "reseat", name, tokenHash: hashToken(token), at: new Date().toISOString() });
|
||||
// A phrase minted with the old token would hand over a seat that no longer answers to it.
|
||||
for (const [code, t] of transfers) if (t.roomId === room.id && t.name === name) transfers.delete(code);
|
||||
return { token };
|
||||
return { token, ...(returned ? { returned } : {}) };
|
||||
}
|
||||
|
||||
/** A seat answers to a new token; one the clockwork was standing in for
|
||||
* goes back to its person, and the chronicle says so. */
|
||||
function seatToPerson(room: Room, name: PlayerId, tokenHash: string): GameEvent | undefined {
|
||||
const wasStanding = room.bots.get(name)?.standIn === true;
|
||||
room.bots.delete(name);
|
||||
room.tokens.set(name, tokenHash);
|
||||
if (!wasStanding) return undefined;
|
||||
const event: GameEvent = { type: "seatChanged", player: name, to: "person" };
|
||||
room.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/** The clockwork takes a person's seat, and the chronicle says so. */
|
||||
function seatToClockwork(room: Room, name: PlayerId, by: PlayerId, style: AutomatonStyle, tier: AutomatonTier): GameEvent {
|
||||
room.tokens.delete(name);
|
||||
room.bots.set(name, { style, secret: true, tier, standIn: true });
|
||||
const event: GameEvent = { type: "seatChanged", player: name, to: "clockwork", by };
|
||||
room.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/** A seat a person holds, or held until the host gave it to the clockwork. */
|
||||
function personSeat(room: Room, name: PlayerId): boolean {
|
||||
const bot = room.bots.get(name);
|
||||
return bot ? bot.standIn === true : room.tokens.has(name);
|
||||
}
|
||||
|
||||
/** The host gives a person's seat to the clockwork in a game under way, for
|
||||
* a player who cannot take their turns: the wizard keeps hand, treasures
|
||||
* and place, and an automaton plays on from here. The person's token opens
|
||||
* nothing until a reseat gives the seat back. */
|
||||
export function standIn(room: Room, byId: PlayerId, name: PlayerId): { event: GameEvent } | { error: string } {
|
||||
if (byId !== room.hostId) return { error: "only the host may give a seat to the clockwork" };
|
||||
if (room.state?.phase !== "playing") return { error: "only a game under way needs a stand-in" };
|
||||
if (name === room.hostId) return { error: "the host keeps their own seat" };
|
||||
if (!room.tokens.has(name) || room.bots.has(name)) return { error: "no person sits in that seat" };
|
||||
if (!room.state.players.find((p) => p.id === name)?.alive) return { error: `${name} is out of the game already` };
|
||||
const style = AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!;
|
||||
const tier: AutomatonTier = "adept";
|
||||
const event = seatToClockwork(room, name, byId, style, tier);
|
||||
for (const [code, t] of transfers) if (t.roomId === room.id && t.name === name) transfers.delete(code);
|
||||
appendLine(room.id, { kind: "standIn", name, by: byId, style, tier, at: new Date().toISOString() });
|
||||
return { event };
|
||||
}
|
||||
|
||||
/** The table as the keeper sees it before re-seating: who sits where, and
|
||||
@@ -351,7 +401,7 @@ export function seatsForKeeper(room: Room): { name: PlayerId; host: boolean; bot
|
||||
for (const c of room.log) last.set(c.playerId, c.at);
|
||||
for (const c of room.chat) if ((last.get(c.player) ?? "") < c.at) last.set(c.player, c.at);
|
||||
return room.players.map((name) => {
|
||||
const bot = room.bots.has(name);
|
||||
const bot = room.bots.has(name) && !room.bots.get(name)!.standIn;
|
||||
return { name, host: name === room.hostId, bot, lastActed: bot ? null : (last.get(name) ?? null) };
|
||||
});
|
||||
}
|
||||
@@ -770,7 +820,9 @@ const TRANSFER_WORDS = [
|
||||
interface PendingTransfer {
|
||||
roomId: string;
|
||||
name: PlayerId;
|
||||
token: string;
|
||||
/** The seat's own token, from its holder; null for a phrase the host
|
||||
* made, which re-seats on its claim. */
|
||||
token: string | null;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
@@ -783,7 +835,23 @@ export function makeTransferCode(
|
||||
rawToken: string | null,
|
||||
): { code: string; expiresAt: number } | { error: string } {
|
||||
if (!tokenMatches(room, playerId, rawToken)) return { error: "you hold no seat in this room" };
|
||||
const token = rawToken!;
|
||||
return mintPhrase(room.id, playerId, rawToken);
|
||||
}
|
||||
|
||||
/** The host makes a phrase for a person's seat, for a player whose browser
|
||||
* lost it or who wants it back from the clockwork. Claiming it re-seats:
|
||||
* the seat gets a new token and the old one opens nothing after. */
|
||||
export function makeSeatPhrase(
|
||||
room: Room,
|
||||
byId: PlayerId,
|
||||
name: PlayerId,
|
||||
): { code: string; expiresAt: number } | { error: string } {
|
||||
if (byId !== room.hostId) return { error: "only the host may make a phrase for another seat" };
|
||||
if (!room.players.includes(name) || !personSeat(room, name)) return { error: "no person sits in that seat" };
|
||||
return mintPhrase(room.id, name, null);
|
||||
}
|
||||
|
||||
function mintPhrase(roomId: string, name: PlayerId, token: string | null): { code: string; expiresAt: number } | { error: string } {
|
||||
// Sweep expired codes while we are here.
|
||||
const now = Date.now();
|
||||
for (const [code, t] of transfers) {
|
||||
@@ -795,7 +863,7 @@ export function makeTransferCode(
|
||||
code = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-");
|
||||
} while (transfers.has(code));
|
||||
const expiresAt = now + TRANSFER_TTL_MS;
|
||||
transfers.set(code, { roomId: room.id, name: playerId, token, expiresAt });
|
||||
transfers.set(code, { roomId, name, token, expiresAt });
|
||||
return { code, expiresAt };
|
||||
}
|
||||
|
||||
@@ -806,7 +874,9 @@ let failWindowStart = 0;
|
||||
const FAIL_WINDOW_MS = 10 * 60 * 1000;
|
||||
const FAIL_LIMIT = 30;
|
||||
|
||||
export function claimTransferCode(code: string): { roomId: string; name: PlayerId; token: string } | { error: string } {
|
||||
export function claimTransferCode(
|
||||
code: string,
|
||||
): { roomId: string; name: PlayerId; token: string; reseated: boolean; returned?: GameEvent } | { error: string } {
|
||||
const now = Date.now();
|
||||
if (now - failWindowStart > FAIL_WINDOW_MS) {
|
||||
failWindowStart = now;
|
||||
@@ -824,8 +894,14 @@ export function claimTransferCode(code: string): { roomId: string; name: PlayerI
|
||||
}
|
||||
transfers.delete(normalized); // one-time
|
||||
const room = rooms.get(t.roomId);
|
||||
if (!room || !tokenMatches(room, t.name, t.token)) return { error: "that seat no longer exists" };
|
||||
return { roomId: t.roomId, name: t.name, token: t.token };
|
||||
if (!room) return { error: "that seat no longer exists" };
|
||||
if (t.token === null) {
|
||||
const fresh = reseat(room, t.name);
|
||||
if ("error" in fresh) return { error: "that seat no longer exists" };
|
||||
return { roomId: t.roomId, name: t.name, token: fresh.token, reseated: true, ...(fresh.returned ? { returned: fresh.returned } : {}) };
|
||||
}
|
||||
if (!tokenMatches(room, t.name, t.token)) return { error: "that seat no longer exists" };
|
||||
return { roomId: t.roomId, name: t.name, token: t.token, reseated: false };
|
||||
}
|
||||
|
||||
/** Rebuild one room from its ledger lines — the seed plus the log IS the
|
||||
@@ -861,7 +937,9 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
||||
room.bots.delete(line.name);
|
||||
room.colorChoices.delete(line.name);
|
||||
} else if (line.kind === "reseat") {
|
||||
if (room.tokens.has(line.name)) room.tokens.set(line.name, line.tokenHash);
|
||||
if (personSeat(room, line.name)) seatToPerson(room, line.name, line.tokenHash);
|
||||
} else if (line.kind === "standIn") {
|
||||
seatToClockwork(room, line.name, line.by, line.style as AutomatonStyle, line.tier as AutomatonTier);
|
||||
} else if (line.kind === "join") {
|
||||
if (line.bot) {
|
||||
room.players.push(line.name);
|
||||
|
||||
@@ -93,8 +93,9 @@ export interface KickLine {
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** The keeper gave a person's seat a new token, for a player whose browser
|
||||
* lost the old one; the old one opens nothing after. */
|
||||
/** A person's seat got a new token, from the keeper or from a phrase the
|
||||
* host made, and the old one opens nothing after. A seat the clockwork was
|
||||
* standing in for goes back to a person. */
|
||||
export interface ReseatLine {
|
||||
kind: "reseat";
|
||||
name: string;
|
||||
@@ -102,13 +103,25 @@ export interface ReseatLine {
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** The host gave a person's seat to the clockwork in a game under way: the
|
||||
* wizard keeps their hand and place, the automaton plays their turns, and
|
||||
* the person's token opens nothing until a reseat gives the seat back. */
|
||||
export interface StandInLine {
|
||||
kind: "standIn";
|
||||
name: string;
|
||||
by: string;
|
||||
style: string;
|
||||
tier: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface AbandonLine {
|
||||
kind: "abandon";
|
||||
by: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | ReseatLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine;
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | ReseatLine | StandInLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine;
|
||||
|
||||
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The store reads its directory when first imported, so the rooms module is
|
||||
// loaded only after the ledgers have somewhere of their own to go.
|
||||
process.env.WIZWAR_DATA_DIR = join(mkdtempSync(join(tmpdir(), "wizwar-seats-")), "rooms");
|
||||
|
||||
let rooms: typeof import("../src/rooms");
|
||||
beforeAll(async () => {
|
||||
rooms = await import("../src/rooms");
|
||||
rooms.loadPersistedRooms();
|
||||
});
|
||||
|
||||
/** Ann hosts, Bo and Cy sit, and the boards have flipped. */
|
||||
function playing() {
|
||||
const { room, token: ann } = rooms.createRoom("Ann");
|
||||
const bo = token(rooms.joinRoom(room, "Bo", null));
|
||||
const cy = token(rooms.joinRoom(room, "Cy", null));
|
||||
const started = rooms.startGame(room, false);
|
||||
if ("error" in started) throw new Error(started.error);
|
||||
return { room, ann, bo, cy };
|
||||
}
|
||||
|
||||
function token(result: { token: string } | { error: string }): string {
|
||||
if ("error" in result) throw new Error(result.error);
|
||||
return result.token;
|
||||
}
|
||||
|
||||
function phrase(result: { code: string } | { error: string }): string {
|
||||
if ("error" in result) throw new Error(result.error);
|
||||
return result.code;
|
||||
}
|
||||
|
||||
describe("a stand-in", () => {
|
||||
it("plays a person's seat the host gives it, and the person's token opens nothing", () => {
|
||||
const { room, bo } = playing();
|
||||
expect(rooms.standIn(room, "Ann", "Bo")).toEqual({ event: { type: "seatChanged", player: "Bo", to: "clockwork", by: "Ann" } });
|
||||
expect(room.bots.get("Bo")).toMatchObject({ standIn: true, secret: true });
|
||||
expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ error: "the host gave this seat to the clockwork; ask them for a phrase to take it back" });
|
||||
expect(room.players).toContain("Bo");
|
||||
});
|
||||
|
||||
it("takes the turn when the seat it stands in for holds it, and leaves the host's to the host", () => {
|
||||
const { room } = playing();
|
||||
rooms.standIn(room, "Ann", "Bo");
|
||||
rooms.standIn(room, "Ann", "Cy");
|
||||
const holder = room.state!.players[room.state!.turn.activeIndex]!.id;
|
||||
expect(rooms.driveOneAutomaton(room)?.seat ?? null).toBe(holder === "Ann" ? null : holder);
|
||||
});
|
||||
|
||||
it("is the host's call alone, in a game under way, for a person still in it", () => {
|
||||
const { room } = playing();
|
||||
expect(rooms.standIn(room, "Bo", "Cy")).toEqual({ error: "only the host may give a seat to the clockwork" });
|
||||
expect(rooms.standIn(room, "Ann", "Ann")).toEqual({ error: "the host keeps their own seat" });
|
||||
expect(rooms.standIn(room, "Ann", "Dee")).toEqual({ error: "no person sits in that seat" });
|
||||
rooms.standIn(room, "Ann", "Bo");
|
||||
expect(rooms.standIn(room, "Ann", "Bo")).toEqual({ error: "no person sits in that seat" });
|
||||
|
||||
const { room: lobby } = rooms.createRoom("Ann");
|
||||
rooms.joinRoom(lobby, "Bo", null);
|
||||
expect(rooms.standIn(lobby, "Ann", "Bo")).toEqual({ error: "only a game under way needs a stand-in" });
|
||||
});
|
||||
|
||||
it("is still standing in after a reload from the ledger", async () => {
|
||||
const { room, bo } = playing();
|
||||
rooms.standIn(room, "Ann", "Bo");
|
||||
vi.resetModules();
|
||||
const reloaded: typeof import("../src/rooms") = await import("../src/rooms");
|
||||
const again = reloaded.getRoom(room.id)!;
|
||||
expect(again).not.toBe(room);
|
||||
expect(again.bots.get("Bo")).toEqual(room.bots.get("Bo"));
|
||||
expect(again.events.filter((e) => e.type === "seatChanged")).toEqual([{ type: "seatChanged", player: "Bo", to: "clockwork", by: "Ann" }]);
|
||||
expect("error" in reloaded.joinRoom(again, "Bo", bo)).toBe(true);
|
||||
});
|
||||
it("holds the person's seat at a rematch, and brings no automaton for it", () => {
|
||||
const { room } = playing();
|
||||
rooms.standIn(room, "Ann", "Bo");
|
||||
room.state = { ...room.state!, phase: "finished" };
|
||||
const called = rooms.callRematch(room, "Ann");
|
||||
if ("error" in called) throw new Error(called.error);
|
||||
const next = rooms.getRoom(called.roomId)!;
|
||||
expect(next.expected).toEqual(["Bo", "Cy"]);
|
||||
expect(next.bots.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a phrase the host makes", () => {
|
||||
it("re-seats a person whose browser lost the seat: the old token is refused, the new one opens it", () => {
|
||||
const { room, bo } = playing();
|
||||
const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo")));
|
||||
if ("error" in claimed) throw new Error(claimed.error);
|
||||
expect(claimed).toMatchObject({ roomId: room.id, name: "Bo", reseated: true });
|
||||
expect(claimed.token).not.toBe(bo);
|
||||
expect("error" in rooms.joinRoom(room, "Bo", bo)).toBe(true);
|
||||
expect(rooms.joinRoom(room, "Bo", claimed.token)).toEqual({ token: claimed.token });
|
||||
});
|
||||
|
||||
it("takes a seat back from the clockwork", () => {
|
||||
const { room } = playing();
|
||||
rooms.standIn(room, "Ann", "Bo");
|
||||
const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo")));
|
||||
if ("error" in claimed) throw new Error(claimed.error);
|
||||
expect(room.bots.has("Bo")).toBe(false);
|
||||
expect(claimed.returned).toEqual({ type: "seatChanged", player: "Bo", to: "person" });
|
||||
expect(rooms.joinRoom(room, "Bo", claimed.token)).toEqual({ token: claimed.token });
|
||||
});
|
||||
|
||||
it("keeps the seat a person's again after a reload from the ledger", async () => {
|
||||
const { room } = playing();
|
||||
rooms.standIn(room, "Ann", "Bo");
|
||||
const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo")));
|
||||
if ("error" in claimed) throw new Error(claimed.error);
|
||||
vi.resetModules();
|
||||
const reloaded: typeof import("../src/rooms") = await import("../src/rooms");
|
||||
const again = reloaded.getRoom(room.id)!;
|
||||
expect(again.bots.has("Bo")).toBe(false);
|
||||
expect(again.events.filter((e) => e.type === "seatChanged").map((e) => (e as { to: string }).to)).toEqual(["clockwork", "person"]);
|
||||
expect(reloaded.joinRoom(again, "Bo", claimed.token)).toEqual({ token: claimed.token });
|
||||
});
|
||||
|
||||
it("is the host's to make, and only for a person's seat", () => {
|
||||
const { room } = playing();
|
||||
expect(rooms.makeSeatPhrase(room, "Bo", "Cy")).toEqual({ error: "only the host may make a phrase for another seat" });
|
||||
expect(rooms.makeSeatPhrase(room, "Ann", "Dee")).toEqual({ error: "no person sits in that seat" });
|
||||
const { room: withBot } = rooms.createRoom("Ann");
|
||||
const bot = rooms.addAutomaton(withBot);
|
||||
if ("error" in bot) throw new Error(bot.error);
|
||||
expect(rooms.makeSeatPhrase(withBot, "Ann", bot.name)).toEqual({ error: "no person sits in that seat" });
|
||||
});
|
||||
|
||||
it("works once", () => {
|
||||
const { room } = playing();
|
||||
const code = phrase(rooms.makeSeatPhrase(room, "Ann", "Cy"));
|
||||
expect("error" in rooms.claimTransferCode(code)).toBe(false);
|
||||
expect(rooms.claimTransferCode(code)).toEqual({ error: "that transfer phrase is unknown or has expired" });
|
||||
});
|
||||
|
||||
it("is voided when the host gives the seat to the clockwork after making it", () => {
|
||||
const { room } = playing();
|
||||
const code = phrase(rooms.makeSeatPhrase(room, "Ann", "Bo"));
|
||||
rooms.standIn(room, "Ann", "Bo");
|
||||
expect("error" in rooms.claimTransferCode(code)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a player's own transfer phrase", () => {
|
||||
it("still hands over the same token, without re-seating", () => {
|
||||
const { room, bo } = playing();
|
||||
const claimed = rooms.claimTransferCode(phrase(rooms.makeTransferCode(room, "Bo", bo)));
|
||||
expect(claimed).toEqual({ roomId: room.id, name: "Bo", token: bo, reseated: false });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user