diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8c88ab1..eea7727 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -38,7 +38,7 @@ import { extname, join, normalize, sep } from "node:path"; import { WebSocketServer, WebSocket } from "ws"; import { cardDef } from "@wizwar/engine"; import type { Command, PlayerId } from "@wizwar/engine"; -import { callRematch, +import { callKeeper, callRematch, catchUpSteps, momentSteps, 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 // nears them; a script filling the vault or the reports desk does. 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 MAX_COMMAND_BYTES = 16384; // serialized game command const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request @@ -524,6 +529,8 @@ function roomInfo(room: Room) { rematch: room.rematch ?? null, expected: room.expected ?? [], expansion: room.expansion, + challenge: room.challenge ?? null, + keeper: KEEPER, colors: Object.fromEntries(room.colorChoices), bots: Object.fromEntries( // 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. if (room.state) { 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); runBots(room); 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": { // The Peanut Gallery: no name, no seat, no ledger line — a pure // reader of the public broadcast, counted but never identified. diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 1a65084..e7e6da2 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -56,6 +56,8 @@ export interface Room { rematch?: { roomId: string; by: PlayerId }; /** A rematch lobby: the wizards of the last table who have not yet sat. */ 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(); @@ -131,6 +133,22 @@ export function createRoom( 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 * 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 @@ -804,6 +822,10 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null { if ("error" in r) throw new Error(`replay start failed: ${r.error}`); } else if (line.kind === "rematch") { 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") { // File order preserves the interleaving with commands. room.chat.push({ player: line.player, text: line.text, at: line.at }); diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 78a70ee..6d8f8d2 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -23,6 +23,13 @@ export interface RoomMetaLine { 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. */ export interface RematchLine { kind: "rematch"; @@ -84,7 +91,7 @@ export interface AbandonLine { 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"); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index fbe55b4..bdfd890 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -2653,7 +2653,7 @@ {n} - from the last table — not yet seated + {net.challenge && n === net.keeper ? `called to the table by ${net.challenge.by} — awaiting an answer` : "from the last table — not yet seated"} {/each} @@ -2680,6 +2680,32 @@ the link itself, for pasting by hand {inviteLink} + {#if !net.challenge && !net.players.includes(net.keeper) && net.players.length + net.expected.length < 6} +
+ +
+ {:else if net.challenge} +
{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.
+ {/if} + + {@const lobbyTalk = net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)} +
+ {#each lobbyTalk as line, i (i)} +
{line.text}
+ {/each} +
{ + 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"); + }}> + + +
+
{#if net.you === net.hostId && addingBot && net.players.length < 6}
@@ -4014,6 +4040,12 @@ padding: 0.5rem 0 0.7rem; } .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; } .invite-fallback { margin: 0.4rem 0 0; font-size: 0.8rem; } .invite-fallback summary { cursor: pointer; color: #8a6d3f; font-style: italic; } diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 4118bc9..0cb7c28 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -369,6 +369,10 @@ class Net { rematchCall = $state<{ roomId: string; by: string } | null>(null); /** A rematch lobby: the last table's wizards not yet seated. */ expected = $state([]); + /** 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(null); log = $state([]); error = $state(null); @@ -523,6 +527,8 @@ class Net { this.audience = msg.audience ?? 0; this.rematchCall = msg.rematch ?? null; this.expected = msg.expected ?? []; + this.challenge = msg.challenge ?? null; + if (typeof msg.keeper === "string") this.keeper = msg.keeper; if (this.you && this.token) { const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token }; localStorage.setItem(SEAT_KEY, JSON.stringify(seat)); @@ -796,6 +802,11 @@ class Net { 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. */ callRematch(): void { this.send({ type: "rematch" }); @@ -886,6 +897,7 @@ class Net { this.chatPending = null; this.rematchCall = null; this.expected = []; + this.challenge = null; this.roomId = null; this.roomIdPending = null; this.view = null;