From 12a655868b964136ebe87800664d27771f7d4671 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 16 Sep 2026 22:52:55 -0400 Subject: [PATCH] The table says when it is out of reach, a finished table can call a rematch, and spells and treasure read plainly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connection: while the socket is down a notice says so, the hand, board, and action buttons go quiet, a card left selected stays selected, and table talk stays in its box. A command that could not leave is named as not sent; one that left before the drop is named as unconfirmed until the board answers. A command on its way shows as sending beside End turn and confirms when the table replies. Talk shows as sending until the table echoes it, and comes back to the composer if the socket drops first. Rematch: anyone at a finished table may call one. The server makes a new room hosted by the caller, with the same clockwork at the same tiers and moods (a mystery stays a mystery) and the same expansion setting, and keeps seats for the last table's other wizards, who find the call on the finished table and in their lobby ledger and sit with one click. The old table's replay stays where it was. A solo table against the clockwork keeps "play again". Reading the table: an ongoing spell's chip opens its card with a plain account — what it does, who cast it on whom, and when it ends, in the rulebook's own terms. Each score row counts the treasures a wizard has carried home, and marks a wizard one carry from winning. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/server/src/index.ts | 29 ++++++- packages/server/src/rooms.ts | 38 ++++++++- packages/server/src/store.ts | 15 +++- packages/web/src/App.svelte | 143 ++++++++++++++++++++++++++++++--- packages/web/src/net.svelte.ts | 110 +++++++++++++++++++++++-- 5 files changed, 312 insertions(+), 23 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e94854c..8c88ab1 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 { +import { callRematch, catchUpSteps, momentSteps, claimTransferCode, @@ -521,6 +521,9 @@ function roomInfo(room: Room) { hostId: room.hostId, started: room.state !== null, audience: audienceCount(room), + rematch: room.rematch ?? null, + expected: room.expected ?? [], + expansion: room.expansion, colors: Object.fromEntries(room.colorChoices), bots: Object.fromEntries( // A mystery machine keeps its mood only while the game lives: once it @@ -840,6 +843,30 @@ wss.on("connection", (socket, req) => { broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at })); break; } + case "rematch": { + // Anyone at a finished table may call; the caller lands in the + // new lobby, and the old table hears where it went. + const room = session.roomId ? getRoom(session.roomId) : undefined; + if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + const called = callRematch(room, session.playerId); + if ("error" in called) return send(socket, { type: "error", message: called.error }); + const next = getRoom(called.roomId); + if (!next) return send(socket, { type: "error", message: "the rematch room is gone" }); + let token = called.token; + if (!token) { + const joined = joinRoom(next, session.playerId, null); + if ("error" in joined) return send(socket, { type: "error", message: joined.error }); + token = joined.token; + } + if (called.created) broadcast(room, () => ({ type: "rematch", roomId: room.id, to: next.id, by: session.playerId })); + leaveGallery(session); + send(socket, { type: "rematched", roomId: next.id }); + session.roomId = next.id; + session.token = token; + send(socket, { type: "seat", playerId: session.playerId, token }); + broadcastRoomState(next); + break; + } case "chat": { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index a4ad524..1a65084 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -52,6 +52,10 @@ export interface Room { bots: Map; /** 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. */ + rematch?: { roomId: string; by: PlayerId }; + /** A rematch lobby: the wizards of the last table who have not yet sat. */ + expected?: PlayerId[]; } const rooms = new Map(); @@ -91,7 +95,10 @@ export function runningRooms(): Room[] { return [...rooms.values()].filter((r) => r.state && r.state.phase === "playing"); } -export function createRoom(hostId: PlayerId): { room: Room; token: string } { +export function createRoom( + hostId: PlayerId, + rematch?: { of: string; expected: PlayerId[]; expansion: boolean }, +): { room: Room; token: string } { const token = randomBytes(16).toString("hex"); const room: Room = { id: makeRoomCode(), @@ -99,7 +106,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } { players: [hostId], tokens: new Map([[hostId, hashToken(token)]]), seed: randomInt(0, 0xffffffff), - expansion: false, + expansion: rematch?.expansion ?? false, colorChoices: new Map(), createdAt: new Date().toISOString(), state: null, @@ -108,6 +115,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } { chat: [], bots: new Map(), touchedAt: Date.now(), + ...(rematch ? { expected: [...rematch.expected] } : {}), }; rooms.set(room.id, room); recordRoom(room); @@ -118,10 +126,29 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } { hostTokenHash: hashToken(token), seed: room.seed, createdAt: new Date().toISOString(), + ...(rematch ? { rematchOf: rematch.of, expected: rematch.expected, expansion: rematch.expansion } : {}), }); return { room, token }; } +/** 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 + * call, and a second call finds the room already made. */ +export function callRematch( + room: Room, byId: PlayerId, +): { roomId: string; token: string | null; created: boolean } | { error: string } { + 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); + 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); + 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 }; +} + export function getRoom(id: string): Room | undefined { const code = id.toUpperCase(); const live = rooms.get(code); @@ -211,6 +238,7 @@ function baseSummary(room: Room, active: PlayerId | null): Omit 0 ? room.log[room.log.length - 1]!.at : null, chatCount: room.chat.length, + rematch: room.rematch ?? null, }; } @@ -467,6 +495,8 @@ export interface GameSummary { lastMoveAt: string | null; /** Total table-talk messages; the client tracks which it has seen. */ chatCount: number; + /** A finished table that moved on: the rematch room and who called it. */ + rematch?: { roomId: string; by: PlayerId } | null; } /** Who holds the table's attention, and why — the summary's turn facts. @@ -743,6 +773,8 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null { chat: [], bots: new Map(), touchedAt: Date.now(), + ...(meta.expected ? { expected: [...meta.expected] } : {}), + ...(meta.expansion !== undefined ? { expansion: meta.expansion } : {}), }; if (lines.some((l) => l.kind === "abandon")) return null; for (const line of lines.slice(1)) { @@ -770,6 +802,8 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null { } else if (line.kind === "start") { const r = startInMemory(room, line.expansion, line.colors, line.deckRev); 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 === "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 c0cb7ed..78a70ee 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -16,6 +16,19 @@ export interface RoomMetaLine { hostToken?: string; seed: number; createdAt: string; + /** A rematch: the finished room it follows, and the wizards it waits for. */ + rematchOf?: string; + expected?: string[]; + /** The lobby's expansion default, carried over from the last table. */ + expansion?: boolean; +} + +/** The finished table called for a rematch: the new room it moved to. */ +export interface RematchLine { + kind: "rematch"; + to: string; + by: string; + at: string; } export interface JoinLine { @@ -71,7 +84,7 @@ export interface AbandonLine { at: string; } -export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine; +export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine; 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 689d42f..c18e071 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1389,6 +1389,56 @@ // Press-and-hold on a wall/door/fire segment (touch has no hover). let peekEdge = $state(null); + /** An ongoing spell, opened from its chip: its card, with a plain account beneath. */ + type Sustained = GameView["sustained"][number]; + let peekEffect = $state(null); + /** What each ongoing spell does, in a sentence. */ + const EFFECT_GLOSS: Record = { + slow: "Moves one square a turn, no numbers or speed spells, and attacks only every other turn.", + "no-spell": "Cannot cast any spell; a magic stone's power still works, and cards may be discarded.", + medusa: "Cannot move or cast, counteractions included — and takes no damage from anything.", + "lock-in-place": "Cannot move or be moved in any way, teleportation included; spells may still be cast.", + blind: "Movement, thrown objects, attacks, and spells go in a direction the die decides; self-cast spells work.", + invisible: "An attacker must roll the die for a random direction to see whether the blow finds them.", + shrink: "Half as likely to be hit by any attack, and walks only two squares a turn.", + adrenaline: "May make two attacks in one turn.", + "big-man": "Fills the corridor: nobody moves, casts, or punches past them, or enters their square; they can push what stands in their way.", + "mist-body": "Passes through anything but stone; neither attacks nor can be attacked; walls of fire still burn.", + disease: "Carries the plague: sharing a square with another wizard costs each of them three points.", + empathy: "Any attack against them lands on the attacker as well.", + fear: "Nobody comes within three squares, walls or no walls; those in range must move away.", + strength: "Physical damage they deal is doubled, and they may tear a treasure from a wizard sharing their square.", + buddy: "Will not attack the wizard who cast it, until attacked first.", + lifesaver: "Not eliminated for losing both treasures to enemy homes.", + "force-field": "Stops the spell it answered, then stands: the opponent may not enter this square, nor cast on or past this wizard, on any side.", + "around-the-corner": "May cast line-of-sight spells around one corner.", + }; + function effectNote(e: Sustained): string { + const gloss = EFFECT_GLOSS[e.cardId] ?? (realCard(e.cardId) ? cardDef(e.cardId).text ?? "" : ""); + const who = e.casterId === e.targetId ? `${e.casterId} cast it on themself.` : `Cast by ${e.casterId} on ${e.targetId}.`; + const ends = + e.cardId === "force-field" ? "Stands until the end of the opponent's turn." + : e.cardId === "buddy" ? "Holds until the pact is broken by an attack." + : isPermanentDuration(e.remainingTurns) ? "Permanent — until dispelled, or the game ends." + : e.remainingTurns === 1 ? `Ends at the start of ${e.casterId}'s next turn.` + : `Ends at the start of ${e.casterId}'s turn, ${e.remainingTurns} of their turns from now.`; + return `${gloss} ${who} ${ends}`; + } + /** Treasures a wizard has carried home: stolen chests sitting on their home square. */ + function treasuresHomeOf(p: { id: string; home: { x: number; y: number } }): number { + return view ? view.treasures.filter((t) => t.owner !== p.id && t.position && + t.position.x === p.home.x && t.position.y === p.home.y).length : 0; + } + /** The table is out of reach: play waits, and nothing here can be sent. */ + const offline = $derived(!local.active && net.roomId != null && net.status !== "connected"); + // Talk that never arrived goes back into the composer, with a word. + $effect(() => { + const lost = net.chatUnsent; + if (!lost) return; + if (!chatDraft.trim()) chatDraft = lost; + net.flash("Your message was not sent — it is back in the box"); + net.chatUnsent = null; + }); /** The preferences slip (client-side taste, saved on this device). */ let prefsOpen = $state(false); @@ -1755,6 +1805,14 @@ + {#if offline} + + {/if} + {#if showDiscards && view}
(showDiscards = false)} onkeydown={() => {}}>