The table says when it is out of reach, a finished table can call a rematch, and spells and treasure read plainly
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
bc99ee3eb7
commit
12a655868b
@@ -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" });
|
||||
|
||||
@@ -52,6 +52,10 @@ export interface Room {
|
||||
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
|
||||
/** 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<string, Room>();
|
||||
@@ -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<GameSummary, "na
|
||||
round: room.state?.turn.round ?? null,
|
||||
lastMoveAt: room.log.length > 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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user