A lobby may challenge the keeper, and the table can talk before the boards flip
"Challenge Kestrel, the keeper" holds a seat under the keeper's name, writes the call to the ledger, says so at the table, and raises a Sentry issue fingerprinted to the room — one ring per call, with the room's link in the message — which is the alarm the keeper's phone listens for. The keeper answers by the link: taking the seat, and leaving a word if the game must wait. For that word, and for any table to settle when to start, the lobby now shows its talk and carries a composer; a joiner sees what was said before they sat. Three calls per address per hour, one per room. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
de279364ce
commit
1be1cf7d1b
@@ -38,7 +38,7 @@ import { extname, join, normalize, sep } from "node:path";
|
|||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
import { cardDef } from "@wizwar/engine";
|
import { cardDef } from "@wizwar/engine";
|
||||||
import type { Command, PlayerId } from "@wizwar/engine";
|
import type { Command, PlayerId } from "@wizwar/engine";
|
||||||
import { callRematch,
|
import { callKeeper, callRematch,
|
||||||
catchUpSteps,
|
catchUpSteps,
|
||||||
momentSteps,
|
momentSteps,
|
||||||
claimTransferCode,
|
claimTransferCode,
|
||||||
@@ -86,6 +86,11 @@ const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create
|
|||||||
// Per-address limits, held across reconnects: a table of friends never
|
// Per-address limits, held across reconnects: a table of friends never
|
||||||
// nears them; a script filling the vault or the reports desk does.
|
// nears them; a script filling the vault or the reports desk does.
|
||||||
const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000);
|
const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000);
|
||||||
|
/** Calls to the keeper: a real person's phone rings for each. */
|
||||||
|
const challengesPerAddress = new SlidingLimit(3, 60 * 60 * 1000);
|
||||||
|
/** The keeper of this table: the wizard a lobby may challenge. */
|
||||||
|
const KEEPER = process.env.WIZWAR_KEEPER ?? "Kestrel";
|
||||||
|
const PUBLIC_URL = (process.env.WIZWAR_PUBLIC_URL ?? "https://wizwar.kestrelsnest.social").replace(/\/$/, "");
|
||||||
const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
|
const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
|
||||||
const MAX_COMMAND_BYTES = 16384; // serialized game command
|
const MAX_COMMAND_BYTES = 16384; // serialized game command
|
||||||
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
|
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
|
||||||
@@ -524,6 +529,8 @@ function roomInfo(room: Room) {
|
|||||||
rematch: room.rematch ?? null,
|
rematch: room.rematch ?? null,
|
||||||
expected: room.expected ?? [],
|
expected: room.expected ?? [],
|
||||||
expansion: room.expansion,
|
expansion: room.expansion,
|
||||||
|
challenge: room.challenge ?? null,
|
||||||
|
keeper: KEEPER,
|
||||||
colors: Object.fromEntries(room.colorChoices),
|
colors: Object.fromEntries(room.colorChoices),
|
||||||
bots: Object.fromEntries(
|
bots: Object.fromEntries(
|
||||||
// A mystery machine keeps its mood only while the game lives: once it
|
// A mystery machine keeps its mood only while the game lives: once it
|
||||||
@@ -721,11 +728,38 @@ wss.on("connection", (socket, req) => {
|
|||||||
// from firing again on every return to the room.
|
// from firing again on every return to the room.
|
||||||
if (room.state) {
|
if (room.state) {
|
||||||
send(socket, { type: "events", events: redactFor(room.events, name), replayed: true });
|
send(socket, { type: "events", events: redactFor(room.events, name), replayed: true });
|
||||||
|
} else if (room.chat.length > 0) {
|
||||||
|
// The lobby's talk so far, for whoever just sat down.
|
||||||
|
send(socket, { type: "events", events: room.chat.map((c) => ({ type: "tableTalk", player: c.player, text: c.text })), replayed: true });
|
||||||
}
|
}
|
||||||
broadcastRoomState(room);
|
broadcastRoomState(room);
|
||||||
runBots(room);
|
runBots(room);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "challengeKeeper": {
|
||||||
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||||
|
if (!challengesPerAddress.allow(session.address)) {
|
||||||
|
return send(socket, { type: "error", message: "the keeper has been called enough from here for one hour" });
|
||||||
|
}
|
||||||
|
const called = callKeeper(room, session.playerId, KEEPER);
|
||||||
|
if ("error" in called) return send(socket, { type: "error", message: called.error });
|
||||||
|
// The alarm the keeper listens for: one issue per room, so each
|
||||||
|
// call rings once, with the door in the message.
|
||||||
|
const link = `${PUBLIC_URL}/join/${room.id}`;
|
||||||
|
if (process.env.SENTRY_DSN) {
|
||||||
|
Sentry.captureMessage(`${session.playerId} challenges ${KEEPER} to a game — ${link}`, {
|
||||||
|
level: "warning",
|
||||||
|
fingerprint: ["challenge", room.id],
|
||||||
|
tags: { room: room.id, challenger: session.playerId },
|
||||||
|
extra: { link, players: room.players.join(", ") },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const said = addChat(room, session.playerId, `calls ${KEEPER} to the table`);
|
||||||
|
if (!("error" in said)) broadcast(room, () => ({ type: "chat", player: session.playerId, text: said.text, at: said.at }));
|
||||||
|
broadcastRoomState(room);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "watch": {
|
case "watch": {
|
||||||
// The Peanut Gallery: no name, no seat, no ledger line — a pure
|
// The Peanut Gallery: no name, no seat, no ledger line — a pure
|
||||||
// reader of the public broadcast, counted but never identified.
|
// reader of the public broadcast, counted but never identified.
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export interface Room {
|
|||||||
rematch?: { roomId: string; by: PlayerId };
|
rematch?: { roomId: string; by: PlayerId };
|
||||||
/** A rematch lobby: the wizards of the last table who have not yet sat. */
|
/** A rematch lobby: the wizards of the last table who have not yet sat. */
|
||||||
expected?: PlayerId[];
|
expected?: PlayerId[];
|
||||||
|
/** The table called the keeper of this site to a seat, and by whom. */
|
||||||
|
challenge?: { by: PlayerId; at: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rooms = new Map<string, Room>();
|
const rooms = new Map<string, Room>();
|
||||||
@@ -131,6 +133,22 @@ export function createRoom(
|
|||||||
return { room, token };
|
return { room, token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A lobby calls the keeper to the table: a seat is held under their
|
||||||
|
* name, the call is written to the ledger, and the caller (index.ts)
|
||||||
|
* raises the alarm the keeper listens for. Once per room. */
|
||||||
|
export function callKeeper(room: Room, byId: PlayerId, keeper: PlayerId): { at: string } | { error: string } {
|
||||||
|
if (room.state) return { error: "the game has started" };
|
||||||
|
if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "take a seat first" };
|
||||||
|
if (room.challenge) return { error: `${keeper} has already been called to this table` };
|
||||||
|
if (room.players.includes(keeper)) return { error: `${keeper} is already here` };
|
||||||
|
if (room.players.length + (room.expected?.length ?? 0) >= 6) return { error: "the table is full" };
|
||||||
|
const at = new Date().toISOString();
|
||||||
|
room.challenge = { by: byId, at };
|
||||||
|
room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
|
||||||
|
appendLine(room.id, { kind: "challenge", by: byId, at });
|
||||||
|
return { at };
|
||||||
|
}
|
||||||
|
|
||||||
/** A finished table calls for a rematch: a new room with the same
|
/** A finished table calls for a rematch: a new room with the same
|
||||||
* clockwork at the same tiers and the same expansion setting, waiting for
|
* clockwork at the same tiers and the same expansion setting, waiting for
|
||||||
* the same humans. The first caller hosts it; anyone at the old table may
|
* the same humans. The first caller hosts it; anyone at the old table may
|
||||||
@@ -804,6 +822,10 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
|||||||
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
|
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
|
||||||
} else if (line.kind === "rematch") {
|
} else if (line.kind === "rematch") {
|
||||||
room.rematch = { roomId: line.to, by: line.by };
|
room.rematch = { roomId: line.to, by: line.by };
|
||||||
|
} else if (line.kind === "challenge") {
|
||||||
|
room.challenge = { by: line.by, at: line.at };
|
||||||
|
const keeper = process.env.WIZWAR_KEEPER ?? "Kestrel";
|
||||||
|
if (!room.players.includes(keeper)) room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
|
||||||
} else if (line.kind === "chat") {
|
} else if (line.kind === "chat") {
|
||||||
// File order preserves the interleaving with commands.
|
// File order preserves the interleaving with commands.
|
||||||
room.chat.push({ player: line.player, text: line.text, at: line.at });
|
room.chat.push({ player: line.player, text: line.text, at: line.at });
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export interface RoomMetaLine {
|
|||||||
expansion?: boolean;
|
expansion?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A table called the keeper: the seat is held and the keeper told. */
|
||||||
|
export interface ChallengeLine {
|
||||||
|
kind: "challenge";
|
||||||
|
by: string;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** The finished table called for a rematch: the new room it moved to. */
|
/** The finished table called for a rematch: the new room it moved to. */
|
||||||
export interface RematchLine {
|
export interface RematchLine {
|
||||||
kind: "rematch";
|
kind: "rematch";
|
||||||
@@ -84,7 +91,7 @@ export interface AbandonLine {
|
|||||||
at: string;
|
at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine;
|
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine;
|
||||||
|
|
||||||
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
||||||
|
|
||||||
|
|||||||
@@ -2653,7 +2653,7 @@
|
|||||||
<span class="seat-portrait empty"></span>
|
<span class="seat-portrait empty"></span>
|
||||||
<span class="seat-text">
|
<span class="seat-text">
|
||||||
<span class="seat-name">{n}</span>
|
<span class="seat-name">{n}</span>
|
||||||
<span class="seat-role">from the last table — not yet seated</span>
|
<span class="seat-role">{net.challenge && n === net.keeper ? `called to the table by ${net.challenge.by} — awaiting an answer` : "from the last table — not yet seated"}</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -2680,6 +2680,32 @@
|
|||||||
<summary>the link itself, for pasting by hand</summary>
|
<summary>the link itself, for pasting by hand</summary>
|
||||||
<code class="invite-link">{inviteLink}</code>
|
<code class="invite-link">{inviteLink}</code>
|
||||||
</details>
|
</details>
|
||||||
|
{#if !net.challenge && !net.players.includes(net.keeper) && net.players.length + net.expected.length < 6}
|
||||||
|
<div class="challenge-row">
|
||||||
|
<button class="stamp quiet-lid" onclick={() => net.challengeKeeper()}
|
||||||
|
title="{net.keeper} keeps this table and gets a call; if they can come they take the seat, and either way they may leave a word here">
|
||||||
|
⚔ Challenge {net.keeper}, the keeper</button>
|
||||||
|
</div>
|
||||||
|
{:else if net.challenge}
|
||||||
|
<div class="challenge-note">{net.keeper} has been called by {net.challenge.by}. If they can come, they take their seat; if not, they may leave a word below.</div>
|
||||||
|
{/if}
|
||||||
|
<!-- Talk before the boards flip: to say when, or to leave a word. -->
|
||||||
|
{@const lobbyTalk = net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)}
|
||||||
|
<div class="lobby-talk">
|
||||||
|
{#each lobbyTalk as line, i (i)}
|
||||||
|
<div class="lobby-line">{line.text}</div>
|
||||||
|
{/each}
|
||||||
|
<form class="say-box lobby-say" onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const t = chatDraft.trim();
|
||||||
|
if (!t) return;
|
||||||
|
if (net.sendChat(t)) chatDraft = "";
|
||||||
|
else net.flash("Not sent — the table is out of reach; your words wait in the box");
|
||||||
|
}}>
|
||||||
|
<input class="say-input" bind:value={chatDraft} maxlength="300" placeholder="say something to the table…" aria-label="table talk" />
|
||||||
|
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{#if net.you === net.hostId && addingBot && net.players.length < 6}
|
{#if net.you === net.hostId && addingBot && net.players.length < 6}
|
||||||
<div class="bot-panel">
|
<div class="bot-panel">
|
||||||
<div class="bot-field">
|
<div class="bot-field">
|
||||||
@@ -4014,6 +4040,12 @@
|
|||||||
padding: 0.5rem 0 0.7rem;
|
padding: 0.5rem 0 0.7rem;
|
||||||
}
|
}
|
||||||
.fill-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; }
|
.fill-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; }
|
||||||
|
.challenge-row { margin-top: 0.6rem; }
|
||||||
|
.stamp.quiet-lid { background: #f4eede; box-shadow: none; }
|
||||||
|
.challenge-note { margin-top: 0.6rem; font-size: 0.88rem; color: #6b5a41; font-style: italic; }
|
||||||
|
.lobby-talk { margin-top: 0.7rem; text-align: left; }
|
||||||
|
.lobby-line { font-family: "Courier Prime", monospace; font-size: 0.82rem; color: #43331f; padding: 0.1rem 0; }
|
||||||
|
.lobby-say .say-input { background: #f4eede; color: #43331f; border: 1px solid #b3a687; }
|
||||||
.fill-row .stamp.current { background: #e4dbc2; }
|
.fill-row .stamp.current { background: #e4dbc2; }
|
||||||
.invite-fallback { margin: 0.4rem 0 0; font-size: 0.8rem; }
|
.invite-fallback { margin: 0.4rem 0 0; font-size: 0.8rem; }
|
||||||
.invite-fallback summary { cursor: pointer; color: #8a6d3f; font-style: italic; }
|
.invite-fallback summary { cursor: pointer; color: #8a6d3f; font-style: italic; }
|
||||||
|
|||||||
@@ -369,6 +369,10 @@ class Net {
|
|||||||
rematchCall = $state<{ roomId: string; by: string } | null>(null);
|
rematchCall = $state<{ roomId: string; by: string } | null>(null);
|
||||||
/** A rematch lobby: the last table's wizards not yet seated. */
|
/** A rematch lobby: the last table's wizards not yet seated. */
|
||||||
expected = $state<string[]>([]);
|
expected = $state<string[]>([]);
|
||||||
|
/** The table called the keeper of this site, and by whom. */
|
||||||
|
challenge = $state<{ by: string; at: string } | null>(null);
|
||||||
|
/** The keeper's name, as the server knows it. */
|
||||||
|
keeper = $state("Kestrel");
|
||||||
view = $state<GameView | null>(null);
|
view = $state<GameView | null>(null);
|
||||||
log = $state<LogLine[]>([]);
|
log = $state<LogLine[]>([]);
|
||||||
error = $state<string | null>(null);
|
error = $state<string | null>(null);
|
||||||
@@ -523,6 +527,8 @@ class Net {
|
|||||||
this.audience = msg.audience ?? 0;
|
this.audience = msg.audience ?? 0;
|
||||||
this.rematchCall = msg.rematch ?? null;
|
this.rematchCall = msg.rematch ?? null;
|
||||||
this.expected = msg.expected ?? [];
|
this.expected = msg.expected ?? [];
|
||||||
|
this.challenge = msg.challenge ?? null;
|
||||||
|
if (typeof msg.keeper === "string") this.keeper = msg.keeper;
|
||||||
if (this.you && this.token) {
|
if (this.you && this.token) {
|
||||||
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
|
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
|
||||||
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
|
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
|
||||||
@@ -796,6 +802,11 @@ class Net {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Call the keeper of this site to the table: a seat is held, and their phone rings. */
|
||||||
|
challengeKeeper(): void {
|
||||||
|
this.send({ type: "challengeKeeper" });
|
||||||
|
}
|
||||||
|
|
||||||
/** Call for a rematch from a finished table, or join the one already called. */
|
/** Call for a rematch from a finished table, or join the one already called. */
|
||||||
callRematch(): void {
|
callRematch(): void {
|
||||||
this.send({ type: "rematch" });
|
this.send({ type: "rematch" });
|
||||||
@@ -886,6 +897,7 @@ class Net {
|
|||||||
this.chatPending = null;
|
this.chatPending = null;
|
||||||
this.rematchCall = null;
|
this.rematchCall = null;
|
||||||
this.expected = [];
|
this.expected = [];
|
||||||
|
this.challenge = null;
|
||||||
this.roomId = null;
|
this.roomId = null;
|
||||||
this.roomIdPending = null;
|
this.roomIdPending = null;
|
||||||
this.view = null;
|
this.view = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user