Three words from 32 was ~15 bits — enumerable within a code's lifetime against an unthrottled endpoint. Now: 64-word list, 4-word phrases (~24 bits / 16.7M combinations), a per-connection cap of 5 failed claims, and a global backstop that voids all pending codes and cools the endpoint after 30 failures in 10 minutes. Codes stay speakable; a brute force now gets ~35 guesses against 16.7M before the door closes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
182 lines
7.0 KiB
TypeScript
182 lines
7.0 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 {
|
|
claimTransferCode,
|
|
createRoom,
|
|
getRoom,
|
|
joinRoom,
|
|
loadPersistedRooms,
|
|
makeTransferCode,
|
|
redactFor,
|
|
runCommand,
|
|
startGame,
|
|
summarize,
|
|
viewForPlayer,
|
|
type Room,
|
|
} from "./rooms";
|
|
|
|
const port = Number(process.env.PORT ?? 8787);
|
|
loadPersistedRooms();
|
|
const wss = new WebSocketServer({ port });
|
|
|
|
interface Session {
|
|
socket: WebSocket;
|
|
playerId: PlayerId | null;
|
|
roomId: string | null;
|
|
claimFails: number;
|
|
}
|
|
|
|
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, claimFails: 0 };
|
|
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 });
|
|
// Rejoining a running game: replay the chronicle so far.
|
|
if (room.state) {
|
|
send(socket, { type: "events", events: redactFor(room.events, name) });
|
|
}
|
|
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, msg.expansion === true);
|
|
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;
|
|
}
|
|
case "makeTransfer": {
|
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
|
const result = makeTransferCode(room, session.playerId);
|
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
|
send(socket, { type: "transferCode", code: result.code, expiresAt: result.expiresAt });
|
|
break;
|
|
}
|
|
case "claimTransfer": {
|
|
if (session.claimFails >= 5) {
|
|
return send(socket, { type: "error", message: "too many attempts on this connection — reconnect and mint a fresh phrase" });
|
|
}
|
|
const result = claimTransferCode(String(msg.code ?? ""));
|
|
if ("error" in result) {
|
|
session.claimFails++;
|
|
return send(socket, { type: "error", message: result.error });
|
|
}
|
|
session.claimFails = 0;
|
|
send(socket, { type: "transferClaimed", seat: result });
|
|
break;
|
|
}
|
|
case "myGames": {
|
|
// {seats: [{roomId, name, token}]} -> summaries for valid seats.
|
|
const seats = Array.isArray(msg.seats) ? msg.seats : [];
|
|
const games = [];
|
|
for (const seat of seats) {
|
|
const room = getRoom(String(seat.roomId ?? ""));
|
|
if (!room) continue;
|
|
const name = String(seat.name ?? "");
|
|
if (room.tokens.get(name) !== seat.token) continue;
|
|
games.push(summarize(room, name));
|
|
}
|
|
send(socket, { type: "games", games });
|
|
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}`);
|