Wire online multiplayer: game rooms, protocol, playable Svelte client
Server: room registry with 4-letter codes, host/join/start flow, the authoritative command loop (seed + append-only command log per room — the replay/async foundation), and per-player redacted views and events broadcast after every change. Client: lobby, SVG board (floors, walls, doors, homes, color-keyed treasures and wizard tokens matching the physical set's six colors, warp arrows), click-to-move, click-to-punch, card hand with tooltips from verified card text, cast flow with number card attachment and waterbolt split, edge-click targeting for wall spells, counteract-or-pass prompt, discard flow, end-turn draw selector, and a humanized event log. Verified end-to-end over real websockets with two clients: join, start, private deals, moves, turn sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7c607ad5c7
commit
36b3ffe9a6
@@ -1,13 +1,133 @@
|
||||
import { WebSocketServer } from "ws";
|
||||
// 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) => {
|
||||
socket.send(JSON.stringify({ type: "hello", game: "wizwar" }));
|
||||
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) => {
|
||||
// TODO: route client commands into game rooms once the engine exists.
|
||||
console.log("received:", data.toString());
|
||||
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 = createRoom(name);
|
||||
session.playerId = name;
|
||||
session.roomId = room.id;
|
||||
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 problem = joinRoom(room, name);
|
||||
if (problem) return send(socket, { type: "error", message: problem });
|
||||
session.playerId = name;
|
||||
session.roomId = room.id;
|
||||
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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Game rooms: the server is the authority. Each room holds one GameState and
|
||||
// the append-only command log (the seed + log IS the game — the basis for
|
||||
// replays and async play). Clients get per-player redacted views and events.
|
||||
|
||||
import {
|
||||
applyCommand,
|
||||
createGame,
|
||||
redactEvent,
|
||||
viewFor,
|
||||
type Command,
|
||||
type GameEvent,
|
||||
type GameState,
|
||||
type GameView,
|
||||
type PlayerId,
|
||||
} from "@wizwar/engine";
|
||||
|
||||
export interface LoggedCommand {
|
||||
seq: number;
|
||||
playerId: PlayerId;
|
||||
command: Command;
|
||||
at: string; // ISO timestamp (server-side wall clock; not used by the engine)
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
hostId: PlayerId;
|
||||
players: PlayerId[]; // join order
|
||||
seed: number;
|
||||
state: GameState | null; // null until started
|
||||
log: LoggedCommand[];
|
||||
events: GameEvent[]; // full history (unredacted — redact per recipient)
|
||||
}
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
|
||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
function makeRoomCode(): string {
|
||||
let code = "";
|
||||
for (let i = 0; i < 4; i++) {
|
||||
code += ROOM_CODE_ALPHABET[Math.floor(Math.random() * ROOM_CODE_ALPHABET.length)];
|
||||
}
|
||||
return rooms.has(code) ? makeRoomCode() : code;
|
||||
}
|
||||
|
||||
export function createRoom(hostId: PlayerId): Room {
|
||||
const room: Room = {
|
||||
id: makeRoomCode(),
|
||||
hostId,
|
||||
players: [hostId],
|
||||
seed: Math.floor(Math.random() * 0xffffffff),
|
||||
state: null,
|
||||
log: [],
|
||||
events: [],
|
||||
};
|
||||
rooms.set(room.id, room);
|
||||
return room;
|
||||
}
|
||||
|
||||
export function getRoom(id: string): Room | undefined {
|
||||
return rooms.get(id.toUpperCase());
|
||||
}
|
||||
|
||||
export function joinRoom(room: Room, playerId: PlayerId): string | null {
|
||||
if (room.state) return "game already started";
|
||||
if (room.players.includes(playerId)) return null; // rejoin is fine
|
||||
if (room.players.length >= 4) return "room is full";
|
||||
room.players.push(playerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function startGame(room: Room): { events: GameEvent[] } | { error: string } {
|
||||
if (room.state) return { error: "already started" };
|
||||
const n = room.players.length;
|
||||
if (n !== 2 && n !== 4) return { error: "supported player counts: 2 or 4" };
|
||||
const { state, events } = createGame({
|
||||
playerIds: room.players,
|
||||
seed: room.seed,
|
||||
sets: ["basic"],
|
||||
});
|
||||
room.state = state;
|
||||
room.events.push(...events);
|
||||
return { events };
|
||||
}
|
||||
|
||||
export function runCommand(
|
||||
room: Room,
|
||||
playerId: PlayerId,
|
||||
command: Command,
|
||||
): { events: GameEvent[] } | { error: string } {
|
||||
if (!room.state) return { error: "game not started" };
|
||||
const result = applyCommand(room.state, playerId, command);
|
||||
if (!result.ok) return { error: result.error };
|
||||
room.state = result.state;
|
||||
room.log.push({
|
||||
seq: room.log.length,
|
||||
playerId,
|
||||
command,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
room.events.push(...result.events);
|
||||
return { events: result.events };
|
||||
}
|
||||
|
||||
export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null {
|
||||
return room.state ? viewFor(room.state, playerId) : null;
|
||||
}
|
||||
|
||||
export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[] {
|
||||
return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null);
|
||||
}
|
||||
Reference in New Issue
Block a user