Initial public release of PSX Adventure Engine
Browser reference stack for PSX-era third-person adventure: fixed cameras, inventory puzzles, Box3D physics, host-authoritative P2P co-op, and a modular character harness. Ships the Ashgrove Precinct Level 1 investigation demo with a full cast and nine linked rooms.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import type { Server } from "node:http";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import type { PeerInfo, SignalMessage, SignalPayload } from "@psx/shared";
|
||||
import { validateSignalMessage } from "@psx/shared";
|
||||
import { verifyToken } from "../auth.js";
|
||||
import type { LobbyRecord, Store } from "../db/store.js";
|
||||
|
||||
/**
|
||||
* SDP/ICE exchange — GDD §8.
|
||||
*
|
||||
* The server is a relay and nothing more. It never sees gameplay: once peers
|
||||
* have exchanged descriptors they talk over data channels directly, and this
|
||||
* socket carries only membership changes for the rest of the session.
|
||||
*
|
||||
* Two rules make the relay safe to expose:
|
||||
* 1. `from` on a relayed signal is stamped from the authenticated socket,
|
||||
* never read from the payload, so peers cannot impersonate one another.
|
||||
* 2. A socket may only address peers inside the lobby it authenticated into.
|
||||
*/
|
||||
|
||||
interface Connection {
|
||||
socket: WebSocket;
|
||||
peerId: number;
|
||||
userId: string;
|
||||
lobbyCode: string;
|
||||
isHost: boolean;
|
||||
alive: boolean;
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
|
||||
export class SignalingServer {
|
||||
private readonly wss: WebSocketServer;
|
||||
/** lobby code → peer id → connection */
|
||||
private readonly rooms = new Map<string, Map<number, Connection>>();
|
||||
private readonly heartbeat: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
server: Server,
|
||||
private readonly store: Store,
|
||||
path = "/ws",
|
||||
) {
|
||||
this.wss = new WebSocketServer({ server, path });
|
||||
this.wss.on("connection", (socket) => this.onConnection(socket));
|
||||
|
||||
// Half-open TCP connections would otherwise linger as phantom lobby members.
|
||||
this.heartbeat = setInterval(() => {
|
||||
for (const room of this.rooms.values()) {
|
||||
for (const connection of room.values()) {
|
||||
if (!connection.alive) {
|
||||
connection.socket.terminate();
|
||||
continue;
|
||||
}
|
||||
connection.alive = false;
|
||||
connection.socket.ping();
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private onConnection(socket: WebSocket): void {
|
||||
let connection: Connection | null = null;
|
||||
|
||||
socket.on("pong", () => {
|
||||
if (connection) connection.alive = true;
|
||||
});
|
||||
|
||||
socket.on("message", async (raw) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return send(socket, { t: "error", message: "malformed JSON" });
|
||||
}
|
||||
|
||||
const validation = validateSignalMessage(parsed);
|
||||
if (!validation.ok) {
|
||||
return send(socket, { t: "error", message: validation.errors.join("; ") });
|
||||
}
|
||||
const message = validation.value;
|
||||
|
||||
// Everything except `hello` requires an authenticated socket.
|
||||
if (!connection && message.t !== "hello") {
|
||||
return send(socket, { t: "error", message: "expected hello first" });
|
||||
}
|
||||
|
||||
switch (message.t) {
|
||||
case "hello": {
|
||||
if (connection) return send(socket, { t: "error", message: "already authenticated" });
|
||||
connection = await this.authenticate(socket, message.token, message.lobbyCode);
|
||||
break;
|
||||
}
|
||||
case "signal":
|
||||
this.relaySignal(connection!, message.to, message.payload);
|
||||
break;
|
||||
case "ready":
|
||||
await this.setReady(connection!, message.isReady);
|
||||
break;
|
||||
default:
|
||||
send(socket, { t: "error", message: `unsupported message: ${message.t}` });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
if (connection) void this.disconnect(connection);
|
||||
});
|
||||
}
|
||||
|
||||
private async authenticate(
|
||||
socket: WebSocket,
|
||||
token: string,
|
||||
lobbyCode: string,
|
||||
): Promise<Connection | null> {
|
||||
const verified = verifyToken(token);
|
||||
if (!verified) {
|
||||
send(socket, { t: "error", message: "invalid or expired token" });
|
||||
socket.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
const lobby = await this.store.findLobbyByCode(lobbyCode);
|
||||
if (!lobby || lobby.status === "closed") {
|
||||
send(socket, { t: "error", message: "lobby not found or closed" });
|
||||
socket.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Membership is established over REST (`POST /lobbies/:code/join`) so that
|
||||
// capacity and duplicate-join rules live in exactly one place.
|
||||
const member = lobby.players.find((player) => player.userId === verified.userId);
|
||||
if (!member) {
|
||||
send(socket, { t: "error", message: "join the lobby before connecting" });
|
||||
socket.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
const room = this.rooms.get(lobby.code) ?? new Map<number, Connection>();
|
||||
this.rooms.set(lobby.code, room);
|
||||
|
||||
// A reconnect from the same peer supersedes the stale socket.
|
||||
room.get(member.peerId)?.socket.terminate();
|
||||
|
||||
const connection: Connection = {
|
||||
socket,
|
||||
peerId: member.peerId,
|
||||
userId: verified.userId,
|
||||
lobbyCode: lobby.code,
|
||||
isHost: verified.userId === lobby.hostId,
|
||||
alive: true,
|
||||
};
|
||||
room.set(member.peerId, connection);
|
||||
|
||||
send(socket, {
|
||||
t: "welcome",
|
||||
peerId: connection.peerId,
|
||||
isHost: connection.isHost,
|
||||
peers: this.peerList(lobby, room),
|
||||
});
|
||||
|
||||
this.broadcast(
|
||||
lobby.code,
|
||||
{ t: "peer-joined", peer: this.peerInfo(lobby, member.peerId) },
|
||||
connection.peerId,
|
||||
);
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
private relaySignal(from: Connection, to: number, payload: SignalPayload): void {
|
||||
const target = this.rooms.get(from.lobbyCode)?.get(to);
|
||||
if (!target) return;
|
||||
|
||||
send(target.socket, {
|
||||
t: "signal",
|
||||
// Stamped from the authenticated socket — never trusted from the payload.
|
||||
from: from.peerId,
|
||||
to,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
private async setReady(connection: Connection, isReady: boolean): Promise<void> {
|
||||
const lobby = await this.store.setReady(connection.lobbyCode, connection.userId, isReady);
|
||||
if (!lobby) return;
|
||||
this.broadcast(connection.lobbyCode, {
|
||||
t: "peer-updated",
|
||||
peer: this.peerInfo(lobby, connection.peerId),
|
||||
});
|
||||
}
|
||||
|
||||
private async disconnect(connection: Connection): Promise<void> {
|
||||
const room = this.rooms.get(connection.lobbyCode);
|
||||
// Guard against a superseded socket removing its replacement.
|
||||
if (room?.get(connection.peerId)?.socket === connection.socket) {
|
||||
room.delete(connection.peerId);
|
||||
}
|
||||
if (room && room.size === 0) this.rooms.delete(connection.lobbyCode);
|
||||
|
||||
await this.store.leaveLobby(connection.lobbyCode, connection.userId);
|
||||
this.broadcast(connection.lobbyCode, { t: "peer-left", peerId: connection.peerId });
|
||||
|
||||
// The authoritative simulation lived in the host's browser, so its departure
|
||||
// ends the session for everyone rather than silently freezing the world.
|
||||
if (connection.isHost) {
|
||||
this.broadcast(connection.lobbyCode, { t: "error", message: "the host left the session" });
|
||||
for (const remaining of room?.values() ?? []) remaining.socket.close();
|
||||
this.rooms.delete(connection.lobbyCode);
|
||||
}
|
||||
}
|
||||
|
||||
private peerInfo(lobby: LobbyRecord, peerId: number): PeerInfo {
|
||||
const player = lobby.players.find((entry) => entry.peerId === peerId);
|
||||
return {
|
||||
peerId,
|
||||
displayName: player?.displayName ?? `Player ${peerId}`,
|
||||
isHost: player ? player.userId === lobby.hostId : false,
|
||||
isReady: player?.isReady ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
private peerList(lobby: LobbyRecord, room: Map<number, Connection>): PeerInfo[] {
|
||||
return [...room.keys()].map((peerId) => this.peerInfo(lobby, peerId));
|
||||
}
|
||||
|
||||
private broadcast(lobbyCode: string, message: SignalMessage, exceptPeerId?: number): void {
|
||||
for (const [peerId, connection] of this.rooms.get(lobbyCode) ?? []) {
|
||||
if (peerId === exceptPeerId) continue;
|
||||
send(connection.socket, message);
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
clearInterval(this.heartbeat);
|
||||
this.wss.close();
|
||||
}
|
||||
}
|
||||
|
||||
function send(socket: WebSocket, message: SignalMessage): void {
|
||||
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
|
||||
}
|
||||
Reference in New Issue
Block a user