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,28 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { InMemoryStore, type Store } from "./db/store.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { lobbyRoutes } from "./routes/lobbies.js";
|
||||
import { savesRoutes } from "./routes/saves.js";
|
||||
|
||||
/**
|
||||
* Route wiring, separated from the bootstrap so tests can mount the app
|
||||
* against a fresh store without opening a port.
|
||||
*/
|
||||
export function createApp(store: Store = new InMemoryStore()) {
|
||||
const app = new Hono();
|
||||
|
||||
// The client page is cross-origin isolated (for wasm threads), which does not
|
||||
// restrict its own outbound fetches, but in dev it runs on a different port.
|
||||
app.use("/*", cors({ origin: (origin) => origin ?? "*", credentials: true }));
|
||||
|
||||
app.get("/health", (context) =>
|
||||
context.json({ ok: true, storage: "in-memory", version: "0.1.0" }),
|
||||
);
|
||||
|
||||
app.route("/auth", authRoutes(store));
|
||||
app.route("/lobbies", lobbyRoutes(store));
|
||||
app.route("/saves", savesRoutes(store));
|
||||
|
||||
return { app, store };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createHmac, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import type { Store, UserRecord } from "./db/store.js";
|
||||
|
||||
/**
|
||||
* Password hashing and session tokens.
|
||||
*
|
||||
* Real scrypt and real HMAC even though the store is in-memory: getting these
|
||||
* wrong is the kind of thing that survives into production once a Neon
|
||||
* connection string appears, and there is no cost to doing it properly now.
|
||||
*/
|
||||
|
||||
const scryptAsync = promisify(scrypt) as (
|
||||
password: string,
|
||||
salt: string,
|
||||
keylen: number,
|
||||
) => Promise<Buffer>;
|
||||
|
||||
const SCRYPT_KEYLEN = 64;
|
||||
const TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Dev-only fallback secret. Regenerated per process, so restarting invalidates
|
||||
* every outstanding token rather than signing with a known constant.
|
||||
*/
|
||||
const SECRET = process.env["PSX_SESSION_SECRET"] ?? randomBytes(32).toString("hex");
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const derived = await scryptAsync(password, salt, SCRYPT_KEYLEN);
|
||||
return `${salt}:${derived.toString("hex")}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const [salt, expected] = stored.split(":");
|
||||
if (!salt || !expected) return false;
|
||||
|
||||
const derived = await scryptAsync(password, salt, SCRYPT_KEYLEN);
|
||||
const expectedBuffer = Buffer.from(expected, "hex");
|
||||
// Length check first: timingSafeEqual throws on mismatched lengths.
|
||||
if (expectedBuffer.length !== derived.length) return false;
|
||||
return timingSafeEqual(derived, expectedBuffer);
|
||||
}
|
||||
|
||||
interface TokenPayload {
|
||||
sub: string;
|
||||
exp: number;
|
||||
jti: string;
|
||||
}
|
||||
|
||||
const sign = (data: string): string =>
|
||||
createHmac("sha256", SECRET).update(data).digest("base64url");
|
||||
|
||||
/** Compact signed token. Not a full JWT — there is no need for the header. */
|
||||
export function issueToken(userId: string): string {
|
||||
const payload: TokenPayload = {
|
||||
sub: userId,
|
||||
exp: Date.now() + TOKEN_TTL_MS,
|
||||
jti: randomUUID(),
|
||||
};
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
return `${body}.${sign(body)}`;
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): { userId: string } | null {
|
||||
const [body, signature] = token.split(".");
|
||||
if (!body || !signature) return null;
|
||||
|
||||
const expected = sign(body);
|
||||
const givenBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (givenBuffer.length !== expectedBuffer.length) return null;
|
||||
if (!timingSafeEqual(givenBuffer, expectedBuffer)) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, "base64url").toString()) as TokenPayload;
|
||||
if (typeof payload.sub !== "string" || typeof payload.exp !== "number") return null;
|
||||
if (payload.exp < Date.now()) return null;
|
||||
return { userId: payload.sub };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves the caller from an `Authorization: Bearer …` header. */
|
||||
export async function userFromAuthHeader(
|
||||
store: Store,
|
||||
header: string | undefined,
|
||||
): Promise<UserRecord | null> {
|
||||
if (!header?.startsWith("Bearer ")) return null;
|
||||
const verified = verifyToken(header.slice(7));
|
||||
return verified ? store.findUserById(verified.userId) : null;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
-- PSX Adventure Engine — Neon schema (GDD §9, Appendix B).
|
||||
-- JSONB is used heavily so save shape and lobby settings can evolve without
|
||||
-- a migration for every gameplay addition.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saves (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
slot INT NOT NULL,
|
||||
campaign TEXT NOT NULL,
|
||||
title TEXT,
|
||||
data JSONB NOT NULL,
|
||||
playtime_seconds INT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, slot, campaign)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS saves_user_idx ON saves (user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lobbies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
host_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'waiting'
|
||||
CHECK (status IN ('waiting', 'starting', 'in_game', 'closed')),
|
||||
max_players INT NOT NULL DEFAULT 4,
|
||||
settings JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '4 hours'
|
||||
);
|
||||
|
||||
-- Lobby codes are looked up on every join attempt; keep it cheap.
|
||||
CREATE INDEX IF NOT EXISTS lobbies_open_idx
|
||||
ON lobbies (code) WHERE status <> 'closed';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lobby_players (
|
||||
lobby_id UUID NOT NULL REFERENCES lobbies(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
peer_id INT NOT NULL,
|
||||
is_ready BOOLEAN NOT NULL DEFAULT false,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (lobby_id, user_id)
|
||||
);
|
||||
@@ -0,0 +1,268 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { SaveData } from "@psx/shared";
|
||||
import { MAX_PLAYERS } from "@psx/shared";
|
||||
|
||||
/**
|
||||
* Persistence port.
|
||||
*
|
||||
* Milestone 2 still has no Neon credentials, so the in-memory implementation
|
||||
* below is what runs. The interface mirrors `schema.sql` closely enough that a
|
||||
* Neon-backed implementation is a matter of writing queries rather than
|
||||
* reshaping call sites.
|
||||
*/
|
||||
|
||||
export interface UserRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
displayName: string | null;
|
||||
passwordHash: string;
|
||||
createdAt: string;
|
||||
lastLogin: string | null;
|
||||
}
|
||||
|
||||
export interface SaveRecord {
|
||||
userId: string;
|
||||
slot: number;
|
||||
campaign: string;
|
||||
title: string | null;
|
||||
data: SaveData;
|
||||
playtimeSeconds: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type LobbyStatus = "waiting" | "starting" | "in_game" | "closed";
|
||||
|
||||
export interface LobbyPlayerRecord {
|
||||
userId: string;
|
||||
peerId: number;
|
||||
displayName: string;
|
||||
isReady: boolean;
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export interface LobbyRecord {
|
||||
id: string;
|
||||
code: string;
|
||||
hostId: string;
|
||||
status: LobbyStatus;
|
||||
maxPlayers: number;
|
||||
settings: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
players: LobbyPlayerRecord[];
|
||||
}
|
||||
|
||||
export interface Store {
|
||||
createUser(input: {
|
||||
username: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
passwordHash: string;
|
||||
}): Promise<UserRecord>;
|
||||
findUserByUsername(username: string): Promise<UserRecord | null>;
|
||||
findUserById(id: string): Promise<UserRecord | null>;
|
||||
touchLogin(id: string): Promise<void>;
|
||||
|
||||
putSave(record: Omit<SaveRecord, "updatedAt">): Promise<SaveRecord>;
|
||||
getSave(userId: string, campaign: string, slot: number): Promise<SaveRecord | null>;
|
||||
listSaves(userId: string): Promise<SaveRecord[]>;
|
||||
deleteSave(userId: string, campaign: string, slot: number): Promise<boolean>;
|
||||
|
||||
createLobby(hostId: string, hostName: string, settings?: Record<string, unknown>): Promise<LobbyRecord>;
|
||||
findLobbyByCode(code: string): Promise<LobbyRecord | null>;
|
||||
joinLobby(code: string, userId: string, displayName: string): Promise<LobbyRecord | { error: string }>;
|
||||
leaveLobby(code: string, userId: string): Promise<LobbyRecord | null>;
|
||||
setReady(code: string, userId: string, isReady: boolean): Promise<LobbyRecord | null>;
|
||||
setLobbyStatus(code: string, status: LobbyStatus): Promise<LobbyRecord | null>;
|
||||
}
|
||||
|
||||
const saveKey = (userId: string, campaign: string, slot: number) =>
|
||||
`${userId}:${campaign}:${slot}`;
|
||||
|
||||
/**
|
||||
* Lobby codes are read aloud over voice chat, so the alphabet omits characters
|
||||
* that sound or look alike: no O/0, no I/1, no S/5.
|
||||
*/
|
||||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRTUVWXY2346789";
|
||||
const CODE_LENGTH = 5;
|
||||
|
||||
export class InMemoryStore implements Store {
|
||||
private readonly users = new Map<string, UserRecord>();
|
||||
private readonly usersByUsername = new Map<string, string>();
|
||||
private readonly saves = new Map<string, SaveRecord>();
|
||||
private readonly lobbies = new Map<string, LobbyRecord>();
|
||||
|
||||
// --- users ---------------------------------------------------------------
|
||||
|
||||
async createUser(input: {
|
||||
username: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
passwordHash: string;
|
||||
}): Promise<UserRecord> {
|
||||
const key = input.username.toLowerCase();
|
||||
if (this.usersByUsername.has(key)) throw new Error("username already taken");
|
||||
|
||||
const user: UserRecord = {
|
||||
id: randomUUID(),
|
||||
username: input.username,
|
||||
email: input.email,
|
||||
displayName: input.displayName ?? input.username,
|
||||
passwordHash: input.passwordHash,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastLogin: null,
|
||||
};
|
||||
this.users.set(user.id, user);
|
||||
this.usersByUsername.set(key, user.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
async findUserByUsername(username: string): Promise<UserRecord | null> {
|
||||
const id = this.usersByUsername.get(username.toLowerCase());
|
||||
return id ? (this.users.get(id) ?? null) : null;
|
||||
}
|
||||
|
||||
async findUserById(id: string): Promise<UserRecord | null> {
|
||||
return this.users.get(id) ?? null;
|
||||
}
|
||||
|
||||
async touchLogin(id: string): Promise<void> {
|
||||
const user = this.users.get(id);
|
||||
if (user) user.lastLogin = new Date().toISOString();
|
||||
}
|
||||
|
||||
// --- saves ---------------------------------------------------------------
|
||||
|
||||
async putSave(record: Omit<SaveRecord, "updatedAt">): Promise<SaveRecord> {
|
||||
const stored: SaveRecord = { ...record, updatedAt: new Date().toISOString() };
|
||||
this.saves.set(saveKey(record.userId, record.campaign, record.slot), stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
async getSave(userId: string, campaign: string, slot: number): Promise<SaveRecord | null> {
|
||||
return this.saves.get(saveKey(userId, campaign, slot)) ?? null;
|
||||
}
|
||||
|
||||
async listSaves(userId: string): Promise<SaveRecord[]> {
|
||||
return [...this.saves.values()]
|
||||
.filter((record) => record.userId === userId)
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
async deleteSave(userId: string, campaign: string, slot: number): Promise<boolean> {
|
||||
return this.saves.delete(saveKey(userId, campaign, slot));
|
||||
}
|
||||
|
||||
// --- lobbies -------------------------------------------------------------
|
||||
|
||||
private generateCode(): string {
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
let code = "";
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
code += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
|
||||
}
|
||||
if (!this.lobbies.has(code)) return code;
|
||||
}
|
||||
throw new Error("could not allocate a free lobby code");
|
||||
}
|
||||
|
||||
async createLobby(
|
||||
hostId: string,
|
||||
hostName: string,
|
||||
settings: Record<string, unknown> = {},
|
||||
): Promise<LobbyRecord> {
|
||||
const now = Date.now();
|
||||
const lobby: LobbyRecord = {
|
||||
id: randomUUID(),
|
||||
code: this.generateCode(),
|
||||
hostId,
|
||||
status: "waiting",
|
||||
maxPlayers: MAX_PLAYERS,
|
||||
settings,
|
||||
createdAt: new Date(now).toISOString(),
|
||||
expiresAt: new Date(now + 4 * 60 * 60 * 1000).toISOString(),
|
||||
// The host is peer 0 by construction, which is what makes "peer 0 is
|
||||
// authoritative" a safe assumption everywhere else in the stack.
|
||||
players: [
|
||||
{
|
||||
userId: hostId,
|
||||
peerId: 0,
|
||||
displayName: hostName,
|
||||
isReady: true,
|
||||
joinedAt: new Date(now).toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
this.lobbies.set(lobby.code, lobby);
|
||||
return lobby;
|
||||
}
|
||||
|
||||
async findLobbyByCode(code: string): Promise<LobbyRecord | null> {
|
||||
const lobby = this.lobbies.get(code.toUpperCase());
|
||||
if (!lobby) return null;
|
||||
if (Date.parse(lobby.expiresAt) < Date.now()) {
|
||||
lobby.status = "closed";
|
||||
}
|
||||
return lobby;
|
||||
}
|
||||
|
||||
async joinLobby(
|
||||
code: string,
|
||||
userId: string,
|
||||
displayName: string,
|
||||
): Promise<LobbyRecord | { error: string }> {
|
||||
const lobby = await this.findLobbyByCode(code);
|
||||
if (!lobby) return { error: "lobby not found" };
|
||||
if (lobby.status === "closed") return { error: "lobby is closed" };
|
||||
|
||||
const existing = lobby.players.find((player) => player.userId === userId);
|
||||
if (existing) return lobby; // rejoin is idempotent
|
||||
|
||||
if (lobby.players.length >= lobby.maxPlayers) return { error: "lobby is full" };
|
||||
|
||||
// Reuse the lowest free peer id so a 4-player lobby always spans 0..3 even
|
||||
// after churn — the client indexes spawn points by peer id.
|
||||
const taken = new Set(lobby.players.map((player) => player.peerId));
|
||||
let peerId = 0;
|
||||
while (taken.has(peerId)) peerId++;
|
||||
|
||||
lobby.players.push({
|
||||
userId,
|
||||
peerId,
|
||||
displayName,
|
||||
isReady: false,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
return lobby;
|
||||
}
|
||||
|
||||
async leaveLobby(code: string, userId: string): Promise<LobbyRecord | null> {
|
||||
const lobby = await this.findLobbyByCode(code);
|
||||
if (!lobby) return null;
|
||||
|
||||
lobby.players = lobby.players.filter((player) => player.userId !== userId);
|
||||
|
||||
// Losing the host ends the session: the authoritative simulation lived in
|
||||
// that browser, so there is nothing for the remaining peers to continue.
|
||||
if (lobby.players.length === 0 || userId === lobby.hostId) {
|
||||
lobby.status = "closed";
|
||||
}
|
||||
return lobby;
|
||||
}
|
||||
|
||||
async setReady(code: string, userId: string, isReady: boolean): Promise<LobbyRecord | null> {
|
||||
const lobby = await this.findLobbyByCode(code);
|
||||
if (!lobby) return null;
|
||||
const player = lobby.players.find((entry) => entry.userId === userId);
|
||||
if (player) player.isReady = isReady;
|
||||
return lobby;
|
||||
}
|
||||
|
||||
async setLobbyStatus(code: string, status: LobbyStatus): Promise<LobbyRecord | null> {
|
||||
const lobby = await this.findLobbyByCode(code);
|
||||
if (!lobby) return null;
|
||||
lobby.status = status;
|
||||
return lobby;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Server } from "node:http";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { createApp } from "./app.js";
|
||||
import { SignalingServer } from "./ws/signaling.js";
|
||||
|
||||
/**
|
||||
* Hono API + WebSocket signalling — GDD §4, §8, §9.
|
||||
*
|
||||
* Storage is in-memory until Neon credentials exist; `db/schema.sql` is already
|
||||
* the target shape and `Store` is the seam.
|
||||
*/
|
||||
const { app, store } = createApp();
|
||||
const port = Number(process.env["PORT"] ?? 8787);
|
||||
|
||||
const server = serve({ fetch: app.fetch, port }, (info) => {
|
||||
console.log(`psx server listening on http://localhost:${info.port}`);
|
||||
console.log(`signalling on ws://localhost:${info.port}/ws`);
|
||||
});
|
||||
|
||||
// `serve` returns the underlying Node http server, which `ws` attaches to.
|
||||
const signaling = new SignalingServer(server as unknown as Server, store);
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
signaling.close();
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
}
|
||||
|
||||
export { app, store };
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Hono } from "hono";
|
||||
import { hashPassword, issueToken, userFromAuthHeader, verifyPassword } from "../auth.js";
|
||||
import type { Store } from "../db/store.js";
|
||||
|
||||
/** Accounts and sessions — GDD §9. */
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{3,24}$/;
|
||||
|
||||
export function authRoutes(store: Store) {
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/register", async (context) => {
|
||||
const body = (await context.req.json().catch(() => null)) as {
|
||||
username?: unknown;
|
||||
email?: unknown;
|
||||
password?: unknown;
|
||||
displayName?: unknown;
|
||||
} | null;
|
||||
|
||||
const { username, email, password, displayName } = body ?? {};
|
||||
|
||||
if (typeof username !== "string" || !USERNAME_PATTERN.test(username)) {
|
||||
return context.json({ error: "username must be 3-24 chars, alphanumeric/_/-" }, 400);
|
||||
}
|
||||
if (typeof email !== "string" || !email.includes("@")) {
|
||||
return context.json({ error: "a valid email is required" }, 400);
|
||||
}
|
||||
if (typeof password !== "string" || password.length < MIN_PASSWORD_LENGTH) {
|
||||
return context.json({ error: `password must be at least ${MIN_PASSWORD_LENGTH} chars` }, 400);
|
||||
}
|
||||
|
||||
if (await store.findUserByUsername(username)) {
|
||||
return context.json({ error: "username already taken" }, 409);
|
||||
}
|
||||
|
||||
const user = await store.createUser({
|
||||
username,
|
||||
email,
|
||||
displayName: typeof displayName === "string" ? displayName : username,
|
||||
passwordHash: await hashPassword(password),
|
||||
});
|
||||
|
||||
return context.json(
|
||||
{ token: issueToken(user.id), user: publicUser(user) },
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/login", async (context) => {
|
||||
const body = (await context.req.json().catch(() => null)) as {
|
||||
username?: unknown;
|
||||
password?: unknown;
|
||||
} | null;
|
||||
|
||||
const { username, password } = body ?? {};
|
||||
if (typeof username !== "string" || typeof password !== "string") {
|
||||
return context.json({ error: "username and password are required" }, 400);
|
||||
}
|
||||
|
||||
const user = await store.findUserByUsername(username);
|
||||
// Same response whether the user is missing or the password is wrong, so
|
||||
// this endpoint cannot be used to enumerate accounts.
|
||||
if (!user || !(await verifyPassword(password, user.passwordHash))) {
|
||||
return context.json({ error: "invalid credentials" }, 401);
|
||||
}
|
||||
|
||||
await store.touchLogin(user.id);
|
||||
return context.json({ token: issueToken(user.id), user: publicUser(user) });
|
||||
});
|
||||
|
||||
app.get("/me", async (context) => {
|
||||
const user = await userFromAuthHeader(store, context.req.header("authorization"));
|
||||
if (!user) return context.json({ error: "unauthorized" }, 401);
|
||||
return context.json({ user: publicUser(user) });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Never let `passwordHash` reach a response body. */
|
||||
function publicUser(user: { id: string; username: string; displayName: string | null }) {
|
||||
return { id: user.id, username: user.username, displayName: user.displayName };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Hono } from "hono";
|
||||
import { userFromAuthHeader } from "../auth.js";
|
||||
import type { LobbyRecord, Store } from "../db/store.js";
|
||||
|
||||
/** Lobby lifecycle — GDD §9. Signalling itself lives in `src/ws/`. */
|
||||
|
||||
export function lobbyRoutes(store: Store) {
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/", async (context) => {
|
||||
const user = await userFromAuthHeader(store, context.req.header("authorization"));
|
||||
if (!user) return context.json({ error: "unauthorized" }, 401);
|
||||
|
||||
const body = (await context.req.json().catch(() => null)) as {
|
||||
settings?: Record<string, unknown>;
|
||||
} | null;
|
||||
|
||||
const lobby = await store.createLobby(
|
||||
user.id,
|
||||
user.displayName ?? user.username,
|
||||
body?.settings ?? {},
|
||||
);
|
||||
return context.json(publicLobby(lobby), 201);
|
||||
});
|
||||
|
||||
app.get("/:code", async (context) => {
|
||||
const lobby = await store.findLobbyByCode(context.req.param("code"));
|
||||
if (!lobby) return context.json({ error: "lobby not found" }, 404);
|
||||
return context.json(publicLobby(lobby));
|
||||
});
|
||||
|
||||
app.post("/:code/join", async (context) => {
|
||||
const user = await userFromAuthHeader(store, context.req.header("authorization"));
|
||||
if (!user) return context.json({ error: "unauthorized" }, 401);
|
||||
|
||||
const result = await store.joinLobby(
|
||||
context.req.param("code"),
|
||||
user.id,
|
||||
user.displayName ?? user.username,
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
const status = result.error === "lobby not found" ? 404 : 409;
|
||||
return context.json({ error: result.error }, status);
|
||||
}
|
||||
return context.json(publicLobby(result));
|
||||
});
|
||||
|
||||
app.post("/:code/ready", async (context) => {
|
||||
const user = await userFromAuthHeader(store, context.req.header("authorization"));
|
||||
if (!user) return context.json({ error: "unauthorized" }, 401);
|
||||
|
||||
const body = (await context.req.json().catch(() => null)) as { isReady?: unknown } | null;
|
||||
const lobby = await store.setReady(
|
||||
context.req.param("code"),
|
||||
user.id,
|
||||
body?.isReady === true,
|
||||
);
|
||||
if (!lobby) return context.json({ error: "lobby not found" }, 404);
|
||||
return context.json(publicLobby(lobby));
|
||||
});
|
||||
|
||||
app.post("/:code/leave", async (context) => {
|
||||
const user = await userFromAuthHeader(store, context.req.header("authorization"));
|
||||
if (!user) return context.json({ error: "unauthorized" }, 401);
|
||||
|
||||
const lobby = await store.leaveLobby(context.req.param("code"), user.id);
|
||||
if (!lobby) return context.json({ error: "lobby not found" }, 404);
|
||||
return context.json(publicLobby(lobby));
|
||||
});
|
||||
|
||||
app.post("/:code/start", async (context) => {
|
||||
const user = await userFromAuthHeader(store, context.req.header("authorization"));
|
||||
if (!user) return context.json({ error: "unauthorized" }, 401);
|
||||
|
||||
const lobby = await store.findLobbyByCode(context.req.param("code"));
|
||||
if (!lobby) return context.json({ error: "lobby not found" }, 404);
|
||||
if (lobby.hostId !== user.id) return context.json({ error: "only the host can start" }, 403);
|
||||
|
||||
const notReady = lobby.players.filter((player) => !player.isReady);
|
||||
if (notReady.length > 0) {
|
||||
return context.json(
|
||||
{ error: "not everyone is ready", waitingOn: notReady.map((p) => p.displayName) },
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const started = await store.setLobbyStatus(lobby.code, "in_game");
|
||||
return context.json(publicLobby(started!));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Strips internal user ids; peers address each other by peer id only. */
|
||||
function publicLobby(lobby: LobbyRecord) {
|
||||
return {
|
||||
code: lobby.code,
|
||||
status: lobby.status,
|
||||
maxPlayers: lobby.maxPlayers,
|
||||
settings: lobby.settings,
|
||||
createdAt: lobby.createdAt,
|
||||
expiresAt: lobby.expiresAt,
|
||||
players: lobby.players.map((player) => ({
|
||||
peerId: player.peerId,
|
||||
displayName: player.displayName,
|
||||
isReady: player.isReady,
|
||||
isHost: player.userId === lobby.hostId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Hono } from "hono";
|
||||
import { validateSaveData } from "@psx/shared";
|
||||
import type { Store } from "../db/store.js";
|
||||
|
||||
/**
|
||||
* Cloud save endpoints (GDD §9).
|
||||
*
|
||||
* Save payloads are validated with the shared schema before they are stored —
|
||||
* the client is not trusted, and in co-op the host writes on behalf of the
|
||||
* party, which widens the blast radius of a malformed payload.
|
||||
*/
|
||||
export function savesRoutes(store: Store) {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/:campaign/:slot", async (context) => {
|
||||
const userId = context.req.header("x-user-id");
|
||||
if (!userId) return context.json({ error: "missing x-user-id" }, 401);
|
||||
|
||||
const slot = Number(context.req.param("slot"));
|
||||
if (!Number.isInteger(slot)) return context.json({ error: "slot must be an integer" }, 400);
|
||||
|
||||
const record = await store.getSave(userId, context.req.param("campaign"), slot);
|
||||
return record ? context.json(record) : context.json({ error: "not found" }, 404);
|
||||
});
|
||||
|
||||
app.put("/:campaign/:slot", async (context) => {
|
||||
const userId = context.req.header("x-user-id");
|
||||
if (!userId) return context.json({ error: "missing x-user-id" }, 401);
|
||||
|
||||
const slot = Number(context.req.param("slot"));
|
||||
if (!Number.isInteger(slot)) return context.json({ error: "slot must be an integer" }, 400);
|
||||
|
||||
const body = (await context.req.json().catch(() => null)) as { data?: unknown; title?: string } | null;
|
||||
const validation = validateSaveData(body?.data);
|
||||
if (!validation.ok) return context.json({ error: "invalid save", details: validation.errors }, 422);
|
||||
|
||||
const record = await store.putSave({
|
||||
userId,
|
||||
slot,
|
||||
campaign: context.req.param("campaign"),
|
||||
title: body?.title ?? null,
|
||||
data: validation.value,
|
||||
playtimeSeconds: validation.value.playtimeSeconds,
|
||||
});
|
||||
return context.json(record, 200);
|
||||
});
|
||||
|
||||
app.get("/", async (context) => {
|
||||
const userId = context.req.header("x-user-id");
|
||||
if (!userId) return context.json({ error: "missing x-user-id" }, 401);
|
||||
return context.json(await store.listSaves(userId));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import type { SignalMessage } from "@psx/shared";
|
||||
import { PROTOCOL_VERSION } from "@psx/shared";
|
||||
import { createApp } from "../app.js";
|
||||
import { InMemoryStore } from "../db/store.js";
|
||||
import { SignalingServer } from "./signaling.js";
|
||||
|
||||
/**
|
||||
* End-to-end signalling tests against a real HTTP + WebSocket server.
|
||||
*
|
||||
* The impersonation test is the important one: the relay stamps `from` off the
|
||||
* authenticated socket, so a peer claiming to be someone else must be ignored.
|
||||
*/
|
||||
|
||||
let server: Server;
|
||||
let signaling: SignalingServer;
|
||||
let store: InMemoryStore;
|
||||
let baseUrl: string;
|
||||
let wsUrl: string;
|
||||
|
||||
/** Bridges Hono's fetch handler onto a plain Node server so `ws` can attach. */
|
||||
function nodeAdapter(app: ReturnType<typeof createApp>["app"]): Server {
|
||||
return createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
const url = `http://localhost${req.url ?? "/"}`;
|
||||
const hasBody = chunks.length > 0;
|
||||
const request = new Request(url, {
|
||||
method: req.method,
|
||||
headers: req.headers as Record<string, string>,
|
||||
...(hasBody ? { body: Buffer.concat(chunks) } : {}),
|
||||
});
|
||||
|
||||
// Hono's `fetch` is sync-or-async depending on the route.
|
||||
void Promise.resolve(app.fetch(request)).then(async (response: Response) => {
|
||||
res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
|
||||
res.end(Buffer.from(await response.arrayBuffer()));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
store = new InMemoryStore();
|
||||
const { app } = createApp(store);
|
||||
server = nodeAdapter(app);
|
||||
signaling = new SignalingServer(server, store);
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
baseUrl = `http://localhost:${port}`;
|
||||
wsUrl = `ws://localhost:${port}/ws`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
signaling.close();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
async function register(username: string): Promise<string> {
|
||||
const response = await fetch(`${baseUrl}/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ username, email: `${username}@example.com`, password: "hunter2hunter2" }),
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
return ((await response.json()) as { token: string }).token;
|
||||
}
|
||||
|
||||
/** Opens a socket, sends `hello`, and resolves once `welcome` arrives. */
|
||||
async function connect(token: string, lobbyCode: string) {
|
||||
const socket = new WebSocket(wsUrl);
|
||||
const inbox: SignalMessage[] = [];
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.on("open", resolve);
|
||||
socket.on("error", reject);
|
||||
});
|
||||
socket.on("message", (raw) => inbox.push(JSON.parse(raw.toString()) as SignalMessage));
|
||||
socket.send(JSON.stringify({ t: "hello", token, lobbyCode, protocol: PROTOCOL_VERSION }));
|
||||
|
||||
const welcome = await waitFor(inbox, (m) => m.t === "welcome" || m.t === "error");
|
||||
return { socket, inbox, welcome };
|
||||
}
|
||||
|
||||
/** Polls `inbox` until a matching message appears, or times out. */
|
||||
async function waitFor(
|
||||
inbox: SignalMessage[],
|
||||
predicate: (message: SignalMessage) => boolean,
|
||||
timeoutMs = 3000,
|
||||
): Promise<SignalMessage> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const found = inbox.find(predicate);
|
||||
if (found) return found;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`timed out; inbox was ${JSON.stringify(inbox)}`);
|
||||
}
|
||||
|
||||
describe("signalling", () => {
|
||||
it("rejects a socket with an invalid token", async () => {
|
||||
const hostToken = await register("host");
|
||||
const created = await fetch(`${baseUrl}/lobbies`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const { code } = (await created.json()) as { code: string };
|
||||
|
||||
const { welcome, socket } = await connect("not-a-real-token", code);
|
||||
expect(welcome.t).toBe("error");
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("refuses a peer that has not joined the lobby over REST", async () => {
|
||||
const hostToken = await register("host");
|
||||
const strangerToken = await register("stranger");
|
||||
|
||||
const created = await fetch(`${baseUrl}/lobbies`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const { code } = (await created.json()) as { code: string };
|
||||
|
||||
const { welcome, socket } = await connect(strangerToken, code);
|
||||
expect(welcome.t).toBe("error");
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("welcomes host as peer 0 and announces a guest joining", async () => {
|
||||
const hostToken = await register("host");
|
||||
const guestToken = await register("guest");
|
||||
|
||||
const created = await fetch(`${baseUrl}/lobbies`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const { code } = (await created.json()) as { code: string };
|
||||
|
||||
await fetch(`${baseUrl}/lobbies/${code}/join`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${guestToken}` },
|
||||
});
|
||||
|
||||
const host = await connect(hostToken, code);
|
||||
expect(host.welcome).toMatchObject({ t: "welcome", peerId: 0, isHost: true });
|
||||
|
||||
const guest = await connect(guestToken, code);
|
||||
expect(guest.welcome).toMatchObject({ t: "welcome", peerId: 1, isHost: false });
|
||||
|
||||
const announced = await waitFor(host.inbox, (m) => m.t === "peer-joined");
|
||||
expect(announced).toMatchObject({ t: "peer-joined", peer: { peerId: 1 } });
|
||||
|
||||
host.socket.close();
|
||||
guest.socket.close();
|
||||
});
|
||||
|
||||
it("relays SDP between peers and stamps the true sender", async () => {
|
||||
const hostToken = await register("host");
|
||||
const guestToken = await register("guest");
|
||||
|
||||
const created = await fetch(`${baseUrl}/lobbies`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const { code } = (await created.json()) as { code: string };
|
||||
await fetch(`${baseUrl}/lobbies/${code}/join`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${guestToken}` },
|
||||
});
|
||||
|
||||
const host = await connect(hostToken, code);
|
||||
const guest = await connect(guestToken, code);
|
||||
|
||||
host.socket.send(
|
||||
JSON.stringify({
|
||||
t: "signal",
|
||||
// Lie about the sender; the relay must overwrite this with peer 0.
|
||||
from: 99,
|
||||
to: 1,
|
||||
payload: { sdp: { type: "offer", sdp: "v=0 fake" } },
|
||||
}),
|
||||
);
|
||||
|
||||
const relayed = await waitFor(guest.inbox, (m) => m.t === "signal");
|
||||
expect(relayed).toMatchObject({
|
||||
t: "signal",
|
||||
from: 0,
|
||||
to: 1,
|
||||
payload: { sdp: { type: "offer", sdp: "v=0 fake" } },
|
||||
});
|
||||
|
||||
host.socket.close();
|
||||
guest.socket.close();
|
||||
});
|
||||
|
||||
it("rejects a protocol version mismatch", async () => {
|
||||
const hostToken = await register("host");
|
||||
const created = await fetch(`${baseUrl}/lobbies`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const { code } = (await created.json()) as { code: string };
|
||||
|
||||
const socket = new WebSocket(wsUrl);
|
||||
const inbox: SignalMessage[] = [];
|
||||
await new Promise<void>((resolve) => socket.on("open", () => resolve()));
|
||||
socket.on("message", (raw) => inbox.push(JSON.parse(raw.toString()) as SignalMessage));
|
||||
socket.send(JSON.stringify({ t: "hello", token: hostToken, lobbyCode: code, protocol: 999 }));
|
||||
|
||||
const error = await waitFor(inbox, (m) => m.t === "error");
|
||||
expect(error).toMatchObject({ t: "error" });
|
||||
expect((error as { message: string }).message).toContain("protocol mismatch");
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("tells remaining peers when the host disconnects", async () => {
|
||||
const hostToken = await register("host");
|
||||
const guestToken = await register("guest");
|
||||
|
||||
const created = await fetch(`${baseUrl}/lobbies`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const { code } = (await created.json()) as { code: string };
|
||||
await fetch(`${baseUrl}/lobbies/${code}/join`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${guestToken}` },
|
||||
});
|
||||
|
||||
const host = await connect(hostToken, code);
|
||||
const guest = await connect(guestToken, code);
|
||||
|
||||
host.socket.close();
|
||||
|
||||
const left = await waitFor(guest.inbox, (m) => m.t === "peer-left");
|
||||
expect(left).toMatchObject({ t: "peer-left", peerId: 0 });
|
||||
|
||||
guest.socket.close();
|
||||
});
|
||||
});
|
||||
@@ -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