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={() => {}}>