Duration system: sustained effects expire at the start of the caster's turns; SLOW (movement 1, no number cards, attack every other turn), NO SPELL, MEDUSA (paralysis + damage immunity), INVISIBLE (1-in-4 hit roll), SHRINK (50% miss, movement 2). Doors: PICK LOCK and MASTER KEY (displayed, reusable) unlock adjacent doors until end of turn, REMOVE LOCK is permanent, JAM LOCK seals a door for everyone. Movement: TELEPORT (4 spaces through walls, ends movement), PASS THROUGH WALL charges, POWER RUN (life for spaces), SWAP (consumes movement), GO AWAY (knockback + lost turn), TELEPORT OPPONENT. Card warfare: CARD ERASURE (named), THOUGHT-STEAL (2 random via seeded RNG), TELEPATH (private hand reveal), POWER DRAIN (damage feeds the caster), SUDDEN DEATH, STONE DEAD, WIZARDBLADE (same-square, number-powered, stays displayed). Cast modifiers: AMPLIFY doubles power/duration (stackable x2), ADD permits two number cards, EXTEND doubles duration; REVERSE heals instead of harms but keeps secondary effects. Counters now also halve durations (BLUNT) and split them (REFLECTION). Security (from review findings): room codes and game seeds now come from node:crypto, and every seat gets a secret token — reclaiming a name in a room requires its token, closing the impersonation hole. 29 cards implemented; 51 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
137 lines
5.1 KiB
TypeScript
137 lines
5.1 KiB
TypeScript
// Websocket front door. Protocol (JSON messages):
|
|
// client -> server:
|
|
// {type:"create", name} create a room, become host
|
|
// {type:"join", roomId, name} join (or rejoin) a room
|
|
// {type:"start"} host starts the game
|
|
// {type:"command", command} a game Command for the engine
|
|
// server -> client:
|
|
// {type:"welcome"} on connect
|
|
// {type:"room", roomId, players, hostId, started}
|
|
// {type:"events", events} redacted for this recipient
|
|
// {type:"state", view} redacted full view (after every change)
|
|
// {type:"error", message}
|
|
|
|
import { WebSocketServer, WebSocket } from "ws";
|
|
import type { Command, PlayerId } from "@wizwar/engine";
|
|
import {
|
|
createRoom,
|
|
getRoom,
|
|
joinRoom,
|
|
redactFor,
|
|
runCommand,
|
|
startGame,
|
|
viewForPlayer,
|
|
type Room,
|
|
} from "./rooms";
|
|
|
|
const port = Number(process.env.PORT ?? 8787);
|
|
const wss = new WebSocketServer({ port });
|
|
|
|
interface Session {
|
|
socket: WebSocket;
|
|
playerId: PlayerId | null;
|
|
roomId: string | null;
|
|
}
|
|
|
|
const sessions = new Set<Session>();
|
|
|
|
function send(socket: WebSocket, message: unknown): void {
|
|
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
|
|
}
|
|
|
|
function roomInfo(room: Room) {
|
|
return {
|
|
type: "room",
|
|
roomId: room.id,
|
|
players: room.players,
|
|
hostId: room.hostId,
|
|
started: room.state !== null,
|
|
};
|
|
}
|
|
|
|
function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): void {
|
|
for (const s of sessions) {
|
|
if (s.roomId === room.id && s.playerId) {
|
|
send(s.socket, makeMessage(s.playerId));
|
|
}
|
|
}
|
|
}
|
|
|
|
function broadcastRoomState(room: Room): void {
|
|
broadcast(room, (playerId) => roomInfo(room));
|
|
if (room.state) {
|
|
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) }));
|
|
}
|
|
}
|
|
|
|
wss.on("connection", (socket) => {
|
|
const session: Session = { socket, playerId: null, roomId: null };
|
|
sessions.add(session);
|
|
send(socket, { type: "welcome", game: "wizwar" });
|
|
|
|
socket.on("close", () => sessions.delete(session));
|
|
|
|
socket.on("message", (data) => {
|
|
let msg: Record<string, unknown>;
|
|
try {
|
|
msg = JSON.parse(data.toString());
|
|
} catch {
|
|
return send(socket, { type: "error", message: "invalid JSON" });
|
|
}
|
|
|
|
try {
|
|
switch (msg.type) {
|
|
case "create": {
|
|
const name = String(msg.name ?? "").trim();
|
|
if (!name) return send(socket, { type: "error", message: "name required" });
|
|
const { room, token } = createRoom(name);
|
|
session.playerId = name;
|
|
session.roomId = room.id;
|
|
send(socket, { type: "seat", playerId: name, token });
|
|
broadcastRoomState(room);
|
|
break;
|
|
}
|
|
case "join": {
|
|
const name = String(msg.name ?? "").trim();
|
|
const roomId = String(msg.roomId ?? "").trim();
|
|
if (!name || !roomId) return send(socket, { type: "error", message: "name and roomId required" });
|
|
const room = getRoom(roomId);
|
|
if (!room) return send(socket, { type: "error", message: "no such room" });
|
|
const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null);
|
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
|
session.playerId = name;
|
|
session.roomId = room.id;
|
|
send(socket, { type: "seat", playerId: name, token: result.token });
|
|
broadcastRoomState(room);
|
|
break;
|
|
}
|
|
case "start": {
|
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
|
if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host can start" });
|
|
const result = startGame(room);
|
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
|
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
|
|
broadcastRoomState(room);
|
|
break;
|
|
}
|
|
case "command": {
|
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
|
const result = runCommand(room, session.playerId, msg.command as Command);
|
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
|
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
|
|
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) }));
|
|
break;
|
|
}
|
|
default:
|
|
send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` });
|
|
}
|
|
} catch (e) {
|
|
send(socket, { type: "error", message: e instanceof Error ? e.message : "internal error" });
|
|
}
|
|
});
|
|
});
|
|
|
|
console.log(`wizwar server listening on ws://localhost:${port}`);
|