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:
ryanfitzpatrickio
2026-07-31 06:32:43 -05:00
commit 8a96ede9f2
181 changed files with 25807 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
import type { ClientMessage, HostMessage, SignalMessage, SignalPayload } from "../types/net.js";
import { MAX_PLAYERS, PROTOCOL_VERSION } from "../types/net.js";
import { fail, isFiniteNumber, isRecord, isStringArray, ok, type ValidationResult } from "./common.js";
/**
* Peer message validation.
*
* Guests are untrusted. Every `ClientMessage` the host acts on passes through
* here first, and the validator *sanitises* rather than merely accepting:
* movement is clamped to the unit square so a peer cannot claim a 1e9 input
* vector and teleport, and identifiers are length-capped so a peer cannot
* exhaust host memory with a single frame.
*/
/** Nothing legitimate approaches these; they exist to bound hostile input. */
const MAX_ID_LENGTH = 128;
const MAX_CHAT_LENGTH = 280;
const clampUnit = (value: number): number => (value < -1 ? -1 : value > 1 ? 1 : value);
const isSaneId = (value: unknown): value is string =>
typeof value === "string" && value.length > 0 && value.length <= MAX_ID_LENGTH;
export function validateClientMessage(input: unknown): ValidationResult<ClientMessage> {
if (!isRecord(input)) return fail("message must be an object");
switch (input["t"]) {
case "input": {
const move = input["move"];
if (!isRecord(move) || !isFiniteNumber(move["x"]) || !isFiniteNumber(move["z"])) {
return fail("input.move must be {x, z} finite numbers");
}
if (!isFiniteNumber(input["tick"]) || input["tick"] < 0) {
return fail("input.tick must be a non-negative number");
}
if (!isFiniteNumber(input["yaw"])) return fail("input.yaw must be finite");
return ok({
t: "input",
tick: Math.floor(input["tick"]),
move: { x: clampUnit(move["x"]), z: clampUnit(move["z"]) },
run: input["run"] === true,
yaw: input["yaw"],
});
}
case "interact":
if (!isSaneId(input["interactableId"])) return fail("interact.interactableId invalid");
return ok({ t: "interact", interactableId: input["interactableId"] });
case "combine":
if (!isSaneId(input["itemA"]) || !isSaneId(input["itemB"])) {
return fail("combine requires two item ids");
}
return ok({ t: "combine", itemA: input["itemA"], itemB: input["itemB"] });
case "chat": {
const text = input["text"];
if (typeof text !== "string") return fail("chat.text must be a string");
return ok({ t: "chat", text: text.slice(0, MAX_CHAT_LENGTH) });
}
default:
return fail(`unknown client message type: ${String(input["t"])}`);
}
}
/**
* Guests validate host messages too. The host is authoritative over *game*
* state, not over the guest's memory safety, and a compromised host should not
* be able to crash the room.
*/
export function validateHostMessage(input: unknown): ValidationResult<HostMessage> {
if (!isRecord(input)) return fail("message must be an object");
switch (input["t"]) {
case "snapshot": {
if (!isFiniteNumber(input["tick"])) return fail("snapshot.tick must be finite");
if (!Array.isArray(input["players"])) return fail("snapshot.players must be an array");
if (input["players"].length > MAX_PLAYERS) return fail("snapshot.players exceeds MAX_PLAYERS");
for (const player of input["players"]) {
if (!isRecord(player) || !isFiniteNumber(player["peerId"])) {
return fail("snapshot.players entry missing peerId");
}
const position = player["p"];
if (
!isRecord(position) ||
!isFiniteNumber(position["x"]) ||
!isFiniteNumber(position["y"]) ||
!isFiniteNumber(position["z"])
) {
return fail("snapshot.players entry has an invalid position");
}
if (!isFiniteNumber(player["yaw"])) return fail("snapshot player yaw must be finite");
}
return ok(input as unknown as HostMessage);
}
case "flags":
if (!isStringArray(input["set"]) || !isStringArray(input["unset"])) {
return fail("flags.set and flags.unset must be string arrays");
}
return ok(input as unknown as HostMessage);
case "despawn":
if (!isStringArray(input["interactableIds"])) {
return fail("despawn.interactableIds must be a string array");
}
return ok(input as unknown as HostMessage);
case "inventory":
if (!isFiniteNumber(input["peerId"]) || !Array.isArray(input["items"])) {
return fail("inventory requires peerId and items[]");
}
return ok(input as unknown as HostMessage);
case "notice":
if (typeof input["text"] !== "string") return fail("notice.text must be a string");
return ok(input as unknown as HostMessage);
case "chat":
if (!isFiniteNumber(input["from"]) || typeof input["text"] !== "string") {
return fail("chat requires from and text");
}
return ok({ t: "chat", from: input["from"], text: input["text"].slice(0, MAX_CHAT_LENGTH) });
case "sync":
if (typeof input["roomId"] !== "string") return fail("sync.roomId must be a string");
if (!isStringArray(input["flags"]) || !isStringArray(input["despawned"])) {
return fail("sync.flags and sync.despawned must be string arrays");
}
return ok(input as unknown as HostMessage);
default:
return fail(`unknown host message type: ${String(input["t"])}`);
}
}
export function validateSignalMessage(input: unknown): ValidationResult<SignalMessage> {
if (!isRecord(input)) return fail("message must be an object");
switch (input["t"]) {
case "hello":
if (typeof input["token"] !== "string") return fail("hello.token must be a string");
if (!isSaneId(input["lobbyCode"])) return fail("hello.lobbyCode invalid");
if (input["protocol"] !== PROTOCOL_VERSION) {
return fail(
`protocol mismatch: peer speaks ${String(input["protocol"])}, server speaks ${PROTOCOL_VERSION}`,
);
}
return ok({
t: "hello",
token: input["token"],
lobbyCode: input["lobbyCode"],
protocol: PROTOCOL_VERSION,
});
case "signal": {
if (!isFiniteNumber(input["to"])) return fail("signal.to must be a peer id");
if (!isRecord(input["payload"])) return fail("signal.payload must be an object");
// `from` is assigned by the server from the authenticated socket, never
// taken from the sender — otherwise a peer could impersonate another.
return ok({
t: "signal",
from: -1,
to: input["to"],
payload: input["payload"] as SignalPayload,
});
}
case "ready":
return ok({ t: "ready", isReady: input["isReady"] === true });
default:
return fail(`unknown signal message type: ${String(input["t"])}`);
}
}