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.
179 lines
6.7 KiB
TypeScript
179 lines
6.7 KiB
TypeScript
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"])}`);
|
|
}
|
|
}
|