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,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"])}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user