diff --git a/index.html b/index.html index 5a89de1..d732b3f 100644 --- a/index.html +++ b/index.html @@ -972,6 +972,8 @@ + +

@@ -989,6 +991,7 @@
19
Fly to a chapter, or an office viewpoint
W A S D
Drive a route, or move while walking inside
V
Walk through an office / return to the dollhouse view
+
Q / E
Descend / climb while flying as a crow
Space
Handbrake while driving
P / R
Resume assisted drive / reset the car
C
Switch chase / driver-height camera
diff --git a/src/actors/controller.ts b/src/actors/controller.ts index a00081e..9b61ee1 100644 --- a/src/actors/controller.ts +++ b/src/actors/controller.ts @@ -23,6 +23,8 @@ export interface ActorProfile { skinTone?: string; primaryColor?: string; accentColor?: string; + hairColor?: string; + bodyShape?: "slim" | "average" | "broad"; }; } @@ -202,6 +204,8 @@ function copyProfile(profile: ActorProfile): ActorProfile { ...(profile.appearance.skinTone === undefined ? {} : { skinTone: profile.appearance.skinTone }), ...(profile.appearance.primaryColor === undefined ? {} : { primaryColor: profile.appearance.primaryColor }), ...(profile.appearance.accentColor === undefined ? {} : { accentColor: profile.appearance.accentColor }), + ...(profile.appearance.hairColor === undefined ? {} : { hairColor: profile.appearance.hairColor }), + ...(profile.appearance.bodyShape === undefined ? {} : { bodyShape: profile.appearance.bodyShape }), } : undefined; return { @@ -227,10 +231,17 @@ function checkedIdentity(identity: ActorIdentity): Readonly { profile.appearance?.skinTone, profile.appearance?.primaryColor, profile.appearance?.accentColor, + profile.appearance?.hairColor, ]; if (strings.some((item) => item !== undefined && typeof item !== "string")) { throw new RangeError("actor profile fields must be strings"); } + if ( + profile.appearance?.bodyShape !== undefined && + profile.appearance.bodyShape !== "slim" && + profile.appearance.bodyShape !== "average" && + profile.appearance.bodyShape !== "broad" + ) throw new RangeError("actor body shape is invalid"); const copy: ActorIdentity = { id: identity.id, displayName: identity.displayName, diff --git a/src/actors/sceneActor.ts b/src/actors/sceneActor.ts index acfa45a..e007442 100644 --- a/src/actors/sceneActor.ts +++ b/src/actors/sceneActor.ts @@ -124,6 +124,8 @@ function buildActor(kind: ActorKind, identity: Readonly): RiggedA ...(color(appearance?.skinTone) === undefined ? {} : { skinTone: color(appearance?.skinTone) }), ...(color(appearance?.primaryColor) === undefined ? {} : { outfitColor: color(appearance?.primaryColor) }), ...(color(appearance?.accentColor) === undefined ? {} : { accentColor: color(appearance?.accentColor) }), + ...(color(appearance?.hairColor) === undefined ? {} : { hairColor: color(appearance?.hairColor) }), + ...(appearance?.bodyShape === undefined ? {} : { bodyShape: appearance.bodyShape }), }), }; } diff --git a/src/main.ts b/src/main.ts index 6f833e2..f79017e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -69,6 +69,14 @@ import { type JourneyEvent, type JourneyState, } from "./journey/index.ts"; +import { + actorKindForPresence, + createDefaultLocalProfile, + loadLocalProfile, + resolveHumanoidAppearance, + saveLocalProfile, + type LocalProfile, +} from "./profile/index.ts"; /** * Three type-only imports and not one value among them, which is what keeps the * office and the instruments out of the entry chunk. @@ -118,6 +126,12 @@ let journey: JourneyState = restoredJourney.status === "loaded" profile: { displayName: "Guest" }, }, }); +const LOCAL_PROFILE_KEY = "tera:profile:v2"; +let localProfile: LocalProfile | null = null; + +function humanoidAppearance() { + return localProfile ? resolveHumanoidAppearance(localProfile.appearance) : null; +} function dispatchJourney(event: JourneyEvent): boolean { const next = journeyReducer(journey, event); @@ -801,7 +815,7 @@ async function mountCity(id: string) { const handle = await createScene(stage, { city: entry.city, actor: { - kind: access.subject === null ? "crow" : "humanoid", + kind: actorKindForPresence(access.subject !== null, "outdoors"), identity: { id: access.subject ?? "anonymous", displayName: access.subject ?? "Guest", @@ -809,11 +823,22 @@ async function mountCity(id: string) { profile: { appearance: access.subject === null ? { primaryColor: "#11151a", accentColor: "#f2b134" } - : { primaryColor: "#151a20", accentColor: "#f2b134" }, + : (() => { + const appearance = humanoidAppearance(); + return appearance + ? { + skinTone: appearance.skinTone, + primaryColor: appearance.outfitColor, + accentColor: appearance.accentColor, + hairColor: appearance.hairColor, + bodyShape: appearance.bodyShape, + } + : { primaryColor: "#151a20", accentColor: "#f2b134" }; + })(), }, }, mode: access.subject === null ? "flight" : "ground", - position: access.subject === null ? { y: 500 } : { y: 0 }, + position: access.subject === null ? { y: 1_200 } : { y: 0 }, minFlightAltitude: 20, maxFlightAltitude: 1_500, ...(access.subject === null @@ -1010,6 +1035,7 @@ async function mountCity(id: string) { */ requestAnimationFrame(function pumpMinimap() { requestAnimationFrame(pumpMinimap); + syncJourneyVehicle(performance.now()); // Only the mounted one. The other is still constructed and still holds a live // camera, but its canvas is out of the document, so `clientWidth` is 0, every // `resize()` puts it back to `ready = false`, and ticking it would be a @@ -1034,6 +1060,8 @@ requestAnimationFrame(function pumpMinimap() { * subtraction on the frames it skips. */ let livenessCheckedAt = 0; +let journeySyncedAt = 0; +let previousVehicleProgress: number | null = null; function pollLiveness() { const now = performance.now(); if (now - livenessCheckedAt < 1000) return; @@ -1041,6 +1069,32 @@ function pollLiveness() { renderSource(); } +/** Persist route progress cheaply and turn the hero's wrap into a real scale door. */ +function syncJourneyVehicle(now: number): void { + if (!routeDriveIsActive() || !city || journey.route === null || journey.vehicle === null) { + previousVehicleProgress = null; + return; + } + const vehicle = city.vehicleState(); + if (!vehicle) return; + const progress = vehicle.progress; + const arrived = journey.route.direction === 1 + ? previousVehicleProgress !== null && previousVehicleProgress > 0.94 && progress < 0.06 + : previousVehicleProgress !== null && previousVehicleProgress < 0.06 && progress > 0.94; + previousVehicleProgress = progress; + + if (arrived) { + const endpoint = journey.route.direction === 1 ? "san-francisco" : "los-angeles"; + dispatchJourney({ type: "reach-route-endpoint", endpoint }); + dispatchJourney({ type: "exit-vehicle" }); + switchCity(endpoint === "san-francisco" ? "sf" : "socal"); + return; + } + if (now - journeySyncedAt < 500) return; + journeySyncedAt = now; + dispatchJourney({ type: "update-route-progress", progress }); +} + // ---- Office --------------------------------------------------------------- /** @@ -1108,9 +1162,9 @@ async function enterOffice() { z: -Math.cos(pack.viewpoints[0].focus.rotation), } : { x: 0, z: -1 }, - actor: access.subject === null + actor: actorKindForPresence(access.subject !== null, "office") === "dog" ? { kind: "anonymous-dog", coatColor: 0x17191c, collarColor: 0xf2b134 } - : { kind: "humanoid", outfitColor: 0x151a20, accentColor: 0xf2b134 }, + : { kind: "humanoid", ...(humanoidAppearance() ?? { outfitColor: 0x151a20, accentColor: 0xf2b134 }) }, }, // Only when there is no sky to put behind it. A sited office computes a // gradient and a horizon; painting the old flat colour over that is the @@ -1593,6 +1647,9 @@ function renderLegend() { renderOfficeBadge(); if (driveControls) driveControls.hidden = !routeDriveIsActive(); if (walkControls) walkControls.hidden = !(walking || exploring); + for (const control of walkControls?.querySelectorAll(".flight-only") ?? []) { + control.hidden = inside; + } if (driveHint) driveHint.hidden = inside || cityId !== "california"; if (walkHint) walkHint.hidden = !(inside || city.actorState()); } @@ -2091,7 +2148,8 @@ function publishCityActorActions(): boolean { right: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0), turn: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0), sprint: heldDriveKeys.has(" "), - climb: heldDriveKeys.has(" ") ? 1 : 0, + climb: (heldDriveKeys.has("e") || heldDriveKeys.has(" ") ? 1 : 0) - + (heldDriveKeys.has("q") ? 1 : 0), }); return true; } @@ -2232,7 +2290,10 @@ window.addEventListener("keydown", (event) => { return; } const lower = event.key.toLowerCase(); - if (lower === "w" || lower === "a" || lower === "s" || lower === "d" || event.key === " ") { + if ( + lower === "w" || lower === "a" || lower === "s" || lower === "d" || + lower === "q" || lower === "e" || event.key === " " + ) { heldDriveKeys.add(event.key === " " ? " " : lower); if (publishVehicleActions()) { event.preventDefault(); @@ -2698,6 +2759,17 @@ async function boot() { if (bootStep) bootStep.textContent = "Asking the deployment who you are…"; access = await resolveAccess(); + if (access.subject !== null) { + const loadedProfile = loadLocalProfile(sessionStorage, LOCAL_PROFILE_KEY); + localProfile = loadedProfile.status === "loaded" + ? loadedProfile.profile + : createDefaultLocalProfile(access.subject, access.subject); + if (loadedProfile.status !== "loaded" || loadedProfile.migrated) { + saveLocalProfile(sessionStorage, LOCAL_PROFILE_KEY, localProfile); + } + } else { + localProfile = null; + } dispatchJourney({ type: "sign-in-actor-swap", actor: access.subject === null @@ -2711,7 +2783,10 @@ async function boot() { id: access.subject, kind: "humanoid", signedIn: true, - profile: { displayName: access.subject }, + profile: { + displayName: localProfile?.displayName ?? access.subject, + color: humanoidAppearance()?.accentColor, + }, }, }); applyTimeControl(); diff --git a/src/media/index.ts b/src/media/index.ts new file mode 100644 index 0000000..b77274d --- /dev/null +++ b/src/media/index.ts @@ -0,0 +1,13 @@ +export * from "./types.ts"; +export { + isMediaSurface, + parseMediaSource, + parseMediaSurface, + parseMediaViewerContext, +} from "./validation.ts"; +export { authorizeMediaSurface, transitionMediaSurface } from "./policy.ts"; +export { + createMediaSurfaceLifecycle, + type MediaSurfaceBinding, + type MediaSurfaceLifecycle, +} from "./lifecycle.ts"; diff --git a/src/media/lifecycle.ts b/src/media/lifecycle.ts new file mode 100644 index 0000000..254967c --- /dev/null +++ b/src/media/lifecycle.ts @@ -0,0 +1,62 @@ +import type { VideoTexture } from "three"; +import type { MediaAuthorizationDecision } from "./types.ts"; + +export interface MediaSurfaceBinding { + /** All objects are caller-owned. This adapter never obtains or fetches them. */ + video: HTMLVideoElement; + texture: VideoTexture; + track?: MediaStreamTrack; +} + +export interface MediaSurfaceLifecycle { + bind(binding: MediaSurfaceBinding): void; + apply(decision: MediaAuthorizationDecision): void; + texture(): VideoTexture | null; + optedIn(): boolean; + dispose(): void; +} + +/** + * Gate an already-created video texture behind a server decision and explicit + * viewer consent. `play`, `getUserMedia`, `fetch`, `track.stop`, and + * `texture.dispose` are intentionally absent: acquisition and ownership remain + * with the caller. + */ +export function createMediaSurfaceLifecycle(): MediaSurfaceLifecycle { + let binding: MediaSurfaceBinding | null = null; + let allowed = false; + let consent = false; + let disposed = false; + + function silence(video: HTMLVideoElement): void { + video.autoplay = false; + video.muted = true; + video.pause(); + } + + return { + bind(next) { + if (disposed) throw new Error("media surface lifecycle is disposed"); + if (binding) silence(binding.video); + binding = next; + silence(next.video); + }, + apply(decision) { + if (disposed) return; + allowed = decision.canView && decision.surface.source !== null; + consent = decision.optedIn; + if (!allowed && binding) silence(binding.video); + }, + texture: () => (!disposed && allowed && consent ? binding?.texture ?? null : null), + optedIn: () => consent, + dispose() { + if (disposed) return; + disposed = true; + if (binding) silence(binding.video); + binding = null; + allowed = false; + consent = false; + }, + }; +} + diff --git a/src/media/policy.ts b/src/media/policy.ts new file mode 100644 index 0000000..a9e8543 --- /dev/null +++ b/src/media/policy.ts @@ -0,0 +1,127 @@ +import type { + MediaAuthorizationDecision, + MediaAuthorizationReason, + MediaSurface, + MediaSurfaceEvent, + MediaSurfaceSource, + MediaViewerContext, +} from "./types.ts"; +import { parseMediaSource, parseMediaSurface, parseMediaViewerContext } from "./validation.ts"; + +/** + * Produce the response a server may return to this viewer. The source locator + * is copied only after authorization, presentation state, and explicit opt-in. + */ +export function authorizeMediaSurface( + input: MediaSurface, + viewer: MediaViewerContext, +): MediaAuthorizationDecision { + const surface = parseMediaSurface(input); + const context = parseMediaViewerContext(viewer); + const access = audienceDecision(surface, context); + const presenting = surface.state.status === "presenting" && surface.source !== null; + let reason: MediaAuthorizationReason = access.reason; + if (access.authorized && !presenting) reason = "not_presenting"; + else if (access.authorized && presenting && !context.optedIn) reason = "opt_in_required"; + else if (access.authorized && presenting && context.optedIn) reason = "ready"; + const canView = reason === "ready"; + return { + authorized: access.authorized, + optedIn: context.optedIn, + canView, + reason, + surface: { + screenId: surface.screenId, + officeId: surface.officeId, + levelId: surface.levelId, + roomId: surface.roomId, + status: surface.state.status, + source: canView && surface.source ? { ...surface.source } : null, + playback: { autoplay: false, muted: true }, + }, + }; +} + +/** Strict state machine for presenter stop/revoke/disconnect/reconnect flows. */ +export function transitionMediaSurface(input: MediaSurface, event: MediaSurfaceEvent): MediaSurface { + const surface = parseMediaSurface(input); + switch (event.type) { + case "present": { + requirePresenter(surface, event.presenterId); + const source = parseMediaSource(event.source); + if (source.privacy === "private" && surface.acl.audience === "public") { + throw new Error("a private source cannot be presented to a public audience"); + } + return next(surface, "presenting", event.presenterId, source); + } + case "stop": + requireCurrent(surface, event.presenterId); + return next(surface, "stopped", null, null); + case "revoke": + if (event.actorId !== surface.state.presenterId && !surface.acl.moderatorIds.includes(event.actorId)) { + throw new Error("only the current presenter or a moderator may revoke a surface"); + } + return next(surface, "revoked", null, null); + case "disconnect": + requireCurrent(surface, event.presenterId); + if (surface.state.status !== "presenting") throw new Error("only a presenting surface can disconnect"); + return next(surface, "reconnecting", event.presenterId, surface.source); + case "reconnect": + requireCurrent(surface, event.presenterId); + if (surface.state.status !== "reconnecting") throw new Error("only a reconnecting surface can reconnect"); + return next( + surface, + "presenting", + event.presenterId, + event.source === undefined ? surface.source : parseMediaSource(event.source), + ); + } +} + +function audienceDecision( + surface: MediaSurface, + viewer: MediaViewerContext, +): { authorized: boolean; reason: MediaAuthorizationReason } { + const id = viewer.viewerId; + switch (surface.acl.audience) { + case "public": + return { authorized: true, reason: "ready" }; + case "authenticated": + return id ? { authorized: true, reason: "ready" } : { authorized: false, reason: "authentication_required" }; + case "office-members": + return id && viewer.officeIds.includes(surface.officeId) + ? { authorized: true, reason: "ready" } + : { authorized: false, reason: id ? "office_membership_required" : "authentication_required" }; + case "allowlist": + return id && surface.acl.viewerIds.includes(id) + ? { authorized: true, reason: "ready" } + : { authorized: false, reason: id ? "not_allowlisted" : "authentication_required" }; + } +} + +function requirePresenter(surface: MediaSurface, presenterId: string): void { + if (!surface.acl.presenterIds.includes(presenterId)) throw new Error("presenter is not permitted by this surface"); +} + +function requireCurrent(surface: MediaSurface, presenterId: string): void { + if (surface.state.presenterId !== presenterId) throw new Error("caller is not the current presenter"); +} + +function next( + surface: MediaSurface, + status: MediaSurface["state"]["status"], + presenterId: string | null, + source: MediaSurfaceSource | null, +): MediaSurface { + return parseMediaSurface({ + ...surface, + acl: { + ...surface.acl, + viewerIds: [...surface.acl.viewerIds], + presenterIds: [...surface.acl.presenterIds], + moderatorIds: [...surface.acl.moderatorIds], + }, + source: source ? { ...source } : null, + state: { status, presenterId, revision: surface.state.revision + 1 }, + }); +} diff --git a/src/media/types.ts b/src/media/types.ts new file mode 100644 index 0000000..3f25bf9 --- /dev/null +++ b/src/media/types.ts @@ -0,0 +1,84 @@ +/** JSON-only contracts for office screen surfaces. No browser or renderer types. */ + +export type MediaAudience = "public" | "authenticated" | "office-members" | "allowlist"; +export type MediaSourcePrivacy = "public" | "private"; +export type MediaSourceKind = "live-stream" | "recording"; +export type MediaSurfaceStatus = "idle" | "presenting" | "reconnecting" | "stopped" | "revoked"; + +export interface MediaSurfaceAcl { + audience: MediaAudience; + viewerIds: string[]; + presenterIds: string[]; + moderatorIds: string[]; +} + +export interface MediaSurfaceSource { + kind: MediaSourceKind; + /** Opaque server-issued locator. Never expose it before authorization and opt-in. */ + locator: string; + privacy: MediaSourcePrivacy; + hasAudio: boolean; + /** Deliberately literal invariants: a surface never asks a browser to autoplay. */ + autoplay: false; + muted: true; +} + +export interface MediaSurfaceState { + status: MediaSurfaceStatus; + presenterId: string | null; + revision: number; +} + +export interface MediaSurface { + screenId: string; + officeId: string; + levelId: string; + roomId: string | null; + acl: MediaSurfaceAcl; + source: MediaSurfaceSource | null; + state: MediaSurfaceState; +} + +export interface MediaViewerContext { + /** Trusted server identity. `null` is the anonymous public viewer. */ + viewerId: string | null; + /** Trusted memberships, not values supplied by the browser. */ + officeIds: string[]; + /** Viewing never starts merely because authorization succeeded. */ + optedIn: boolean; +} + +export type MediaAuthorizationReason = + | "ready" + | "not_presenting" + | "authentication_required" + | "office_membership_required" + | "not_allowlisted" + | "opt_in_required"; + +/** Safe response shape. `source` is null unless both policy and opt-in passed. */ +export interface MediaSurfaceView { + screenId: string; + officeId: string; + levelId: string; + roomId: string | null; + status: MediaSurfaceStatus; + source: MediaSurfaceSource | null; + playback: { autoplay: false; muted: true }; +} + +export interface MediaAuthorizationDecision { + authorized: boolean; + optedIn: boolean; + canView: boolean; + reason: MediaAuthorizationReason; + surface: MediaSurfaceView; +} + +export type MediaSurfaceEvent = + | { type: "present"; presenterId: string; source: MediaSurfaceSource } + | { type: "stop"; presenterId: string } + | { type: "revoke"; actorId: string } + | { type: "disconnect"; presenterId: string } + | { type: "reconnect"; presenterId: string; source?: MediaSurfaceSource }; + diff --git a/src/media/validation.ts b/src/media/validation.ts new file mode 100644 index 0000000..b3a375c --- /dev/null +++ b/src/media/validation.ts @@ -0,0 +1,144 @@ +import type { + MediaSurface, + MediaSurfaceAcl, + MediaSurfaceSource, + MediaSurfaceState, + MediaViewerContext, +} from "./types.ts"; + +const ID = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/; +const AUDIENCES = new Set(["public", "authenticated", "office-members", "allowlist"]); +const SOURCE_KINDS = new Set(["live-stream", "recording"]); +const PRIVACY = new Set(["public", "private"]); +const STATUSES = new Set(["idle", "presenting", "reconnecting", "stopped", "revoked"]); + +export function parseMediaSurface(value: unknown): MediaSurface { + const surface = record(value, "surface", [ + "screenId", "officeId", "levelId", "roomId", "acl", "source", "state", + ]); + const parsed: MediaSurface = { + screenId: id(surface.screenId, "screenId"), + officeId: id(surface.officeId, "officeId"), + levelId: id(surface.levelId, "levelId"), + roomId: surface.roomId === null ? null : id(surface.roomId, "roomId"), + acl: parseAcl(surface.acl), + source: surface.source === null ? null : parseSource(surface.source), + state: parseState(surface.state), + }; + const live = parsed.state.status === "presenting" || parsed.state.status === "reconnecting"; + if (live !== (parsed.source !== null)) { + throw new TypeError("presenting/reconnecting surfaces require a source; all other states forbid one"); + } + if (parsed.source?.privacy === "private" && parsed.acl.audience === "public") { + throw new TypeError("a private source cannot have a public audience"); + } + if (live !== (parsed.state.presenterId !== null)) { + throw new TypeError("presenting/reconnecting surfaces require a presenter; all other states forbid one"); + } + if (parsed.state.presenterId && !parsed.acl.presenterIds.includes(parsed.state.presenterId)) { + throw new TypeError("current presenter is not permitted by acl.presenterIds"); + } + return parsed; +} + +export function isMediaSurface(value: unknown): value is MediaSurface { + try { + parseMediaSurface(value); + return true; + } catch { + return false; + } +} + +export function parseMediaSource(value: unknown): MediaSurfaceSource { + return parseSource(value); +} + +/** Validate the trusted context assembled by a server/session boundary. */ +export function parseMediaViewerContext(value: unknown): MediaViewerContext { + const viewer = record(value, "viewer", ["viewerId", "officeIds", "optedIn"]); + if (viewer.viewerId !== null && typeof viewer.viewerId !== "string") { + throw new TypeError("viewer.viewerId is invalid"); + } + if (typeof viewer.optedIn !== "boolean") throw new TypeError("viewer.optedIn must be boolean"); + return { + viewerId: viewer.viewerId === null ? null : id(viewer.viewerId, "viewer.viewerId"), + officeIds: ids(viewer.officeIds, "viewer.officeIds"), + optedIn: viewer.optedIn, + }; +} + +function parseAcl(value: unknown): MediaSurfaceAcl { + const acl = record(value, "acl", ["audience", "viewerIds", "presenterIds", "moderatorIds"]); + if (typeof acl.audience !== "string" || !AUDIENCES.has(acl.audience)) { + throw new TypeError("acl.audience is invalid"); + } + const parsed: MediaSurfaceAcl = { + audience: acl.audience as MediaSurfaceAcl["audience"], + viewerIds: ids(acl.viewerIds, "acl.viewerIds"), + presenterIds: ids(acl.presenterIds, "acl.presenterIds"), + moderatorIds: ids(acl.moderatorIds, "acl.moderatorIds"), + }; + if (parsed.audience === "allowlist" && parsed.viewerIds.length === 0) { + throw new TypeError("an allowlist audience requires acl.viewerIds"); + } + return parsed; +} + +function parseSource(value: unknown): MediaSurfaceSource { + const source = record(value, "source", ["kind", "locator", "privacy", "hasAudio", "autoplay", "muted"]); + if (typeof source.kind !== "string" || !SOURCE_KINDS.has(source.kind)) throw new TypeError("source.kind is invalid"); + if (typeof source.privacy !== "string" || !PRIVACY.has(source.privacy)) throw new TypeError("source.privacy is invalid"); + if (typeof source.locator !== "string" || source.locator.length < 1 || source.locator.length > 2048) { + throw new TypeError("source.locator must be a non-empty string of at most 2048 characters"); + } + if (typeof source.hasAudio !== "boolean") throw new TypeError("source.hasAudio must be boolean"); + if (source.autoplay !== false) throw new TypeError("media sources must set autoplay to false"); + if (source.muted !== true) throw new TypeError("media sources must start muted"); + return { + kind: source.kind as MediaSurfaceSource["kind"], + locator: source.locator, + privacy: source.privacy as MediaSurfaceSource["privacy"], + hasAudio: source.hasAudio, + autoplay: false, + muted: true, + }; +} + +function parseState(value: unknown): MediaSurfaceState { + const state = record(value, "state", ["status", "presenterId", "revision"]); + if (typeof state.status !== "string" || !STATUSES.has(state.status)) throw new TypeError("state.status is invalid"); + if (state.presenterId !== null && typeof state.presenterId !== "string") throw new TypeError("state.presenterId is invalid"); + if (!Number.isSafeInteger(state.revision) || (state.revision as number) < 0) { + throw new TypeError("state.revision must be a non-negative safe integer"); + } + return { + status: state.status as MediaSurfaceState["status"], + presenterId: state.presenterId === null ? null : id(state.presenterId, "state.presenterId"), + revision: state.revision as number, + }; +} + +function record(value: unknown, name: string, keys: readonly string[]): Record { + if (typeof value !== "object" || value === null || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + throw new TypeError(`${name} must be a plain JSON object`); + } + const result = value as Record; + const actual = Object.keys(result); + if (actual.length !== keys.length || actual.some((key) => !keys.includes(key))) { + throw new TypeError(`${name} must contain exactly: ${keys.join(", ")}`); + } + return result; +} + +function id(value: unknown, name: string): string { + if (typeof value !== "string" || !ID.test(value)) throw new TypeError(`${name} is not a valid id`); + return value; +} + +function ids(value: unknown, name: string): string[] { + if (!Array.isArray(value) || value.length > 1024) throw new TypeError(`${name} must be an array`); + const result = value.map((item, index) => id(item, `${name}[${index}]`)); + if (new Set(result).size !== result.length) throw new TypeError(`${name} contains duplicates`); + return result; +} diff --git a/src/profile/index.ts b/src/profile/index.ts new file mode 100644 index 0000000..45794d5 --- /dev/null +++ b/src/profile/index.ts @@ -0,0 +1,51 @@ +export { + ACCENTS, + BODY_SHAPES, + HAIR_COLORS, + LOCAL_PROFILE_VERSION, + OUTFITS, + SKIN_TONES, + actorKindForPresence, + createDefaultLocalProfile, + decodeLocalProfile, + encodeLocalProfile, + isHumanoidAppearance, + isLocalProfile, + migrateLocalProfileV1, + resolveHumanoidAppearance, + type AccentChoice, + type BodyShapeChoice, + type HairColorChoice, + type HumanoidAppearanceChoices, + type LocalProfile, + type LocalProfileV1, + type LocalProfileV2, + type OutfitChoice, + type PresenceActorKind, + type PresenceContext, + type ProfileDecodeResult, + type ResolvedHumanoidAppearance, + type SkinToneChoice, +} from "./model.ts"; + +export { + clearLocalProfile, + loadLocalProfile, + saveLocalProfile, + type ProfileClearResult, + type ProfileLoadResult, + type ProfileSaveResult, + type ProfileStorageAdapter, +} from "./persistence.ts"; + +export { + createWebcamFaceConsent, + initialWebcamFaceConsent, + transitionWebcamFaceConsent, + type WebcamFaceConsentController, + type WebcamFaceConsentEvent, + type WebcamFaceConsentRejection, + type WebcamFaceConsentState, + type WebcamFaceConsentStatus, + type WebcamFaceConsentTransition, +} from "./webcamConsent.ts"; diff --git a/src/profile/model.ts b/src/profile/model.ts new file mode 100644 index 0000000..cdaeb38 --- /dev/null +++ b/src/profile/model.ts @@ -0,0 +1,260 @@ +/** Versioned, JSON-only local profile data for an authenticated Tera member. */ + +export const LOCAL_PROFILE_VERSION = 2 as const; + +export const SKIN_TONES = ["espresso", "umber", "sienna", "sand", "peach"] as const; +export const OUTFITS = ["ink", "navy", "slate", "sage", "clay"] as const; +export const ACCENTS = ["aqua", "amber", "coral", "violet", "lime"] as const; +export const HAIR_COLORS = ["black", "espresso", "auburn", "silver", "platinum"] as const; +export const BODY_SHAPES = ["slim", "average", "broad"] as const; + +export type SkinToneChoice = (typeof SKIN_TONES)[number]; +export type OutfitChoice = (typeof OUTFITS)[number]; +export type AccentChoice = (typeof ACCENTS)[number]; +export type HairColorChoice = (typeof HAIR_COLORS)[number]; +export type BodyShapeChoice = (typeof BODY_SHAPES)[number]; + +export interface HumanoidAppearanceChoices { + skinTone: SkinToneChoice; + outfit: OutfitChoice; + accent: AccentChoice; + hair: HairColorChoice; + bodyShape: BodyShapeChoice; +} + +/** + * This intentionally contains no identity id, auth credential, webcam state, + * face URL, image, device id, or arbitrary metadata bucket. + */ +export interface LocalProfileV2 { + version: typeof LOCAL_PROFILE_VERSION; + displayName: string; + appearance: HumanoidAppearanceChoices; +} + +/** The only supported legacy shape: V1 kept appearance choices flat. */ +export interface LocalProfileV1 { + version: 1; + displayName: string; + skinTone: SkinToneChoice; + outfit: OutfitChoice; + accent: AccentChoice; + hair: HairColorChoice; + bodyShape: BodyShapeChoice; +} + +export type LocalProfile = LocalProfileV2; + +export type ProfileDecodeResult = + | { ok: true; profile: LocalProfile; migrated: false } + | { ok: true; profile: LocalProfile; migrated: true; fromVersion: 1 } + | { ok: false; error: string }; + +export interface ResolvedHumanoidAppearance { + skinTone: string; + outfitColor: string; + accentColor: string; + hairColor: string; + bodyShape: BodyShapeChoice; +} + +export type PresenceContext = "outdoors" | "office"; +export type PresenceActorKind = "humanoid" | "dog" | "crow"; + +const SKIN_COLORS: Readonly> = Object.freeze({ + espresso: "#4b2d25", + umber: "#704534", + sienna: "#9a6248", + sand: "#c58d68", + peach: "#e6b28e", +}); + +const OUTFIT_COLORS: Readonly> = Object.freeze({ + ink: "#18222d", + navy: "#1d3b58", + slate: "#53616d", + sage: "#566c5d", + clay: "#80594a", +}); + +const ACCENT_COLORS: Readonly> = Object.freeze({ + aqua: "#55b8c8", + amber: "#d99b39", + coral: "#db6d62", + violet: "#826faf", + lime: "#86a94a", +}); + +const HAIR_VALUES: Readonly> = Object.freeze({ + black: "#171719", + espresso: "#2d211d", + auburn: "#65372b", + silver: "#8b9298", + platinum: "#d0c5ac", +}); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactKeys(value: Record, expected: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]); +} + +function oneOf(value: unknown, choices: readonly T[]): value is T { + return typeof value === "string" && (choices as readonly string[]).includes(value); +} + +function validDisplayName(value: unknown): value is string { + return ( + typeof value === "string" && + value === value.trim() && + value.length >= 1 && + value.length <= 64 && + !/[\u0000-\u001f\u007f]/u.test(value) + ); +} + +export function isHumanoidAppearance(value: unknown): value is HumanoidAppearanceChoices { + return ( + isRecord(value) && + exactKeys(value, ["skinTone", "outfit", "accent", "hair", "bodyShape"]) && + oneOf(value.skinTone, SKIN_TONES) && + oneOf(value.outfit, OUTFITS) && + oneOf(value.accent, ACCENTS) && + oneOf(value.hair, HAIR_COLORS) && + oneOf(value.bodyShape, BODY_SHAPES) + ); +} + +export function isLocalProfile(value: unknown): value is LocalProfile { + return ( + isRecord(value) && + exactKeys(value, ["version", "displayName", "appearance"]) && + value.version === LOCAL_PROFILE_VERSION && + validDisplayName(value.displayName) && + isHumanoidAppearance(value.appearance) + ); +} + +function isLocalProfileV1(value: unknown): value is LocalProfileV1 { + return ( + isRecord(value) && + exactKeys(value, ["version", "displayName", "skinTone", "outfit", "accent", "hair", "bodyShape"]) && + value.version === 1 && + validDisplayName(value.displayName) && + oneOf(value.skinTone, SKIN_TONES) && + oneOf(value.outfit, OUTFITS) && + oneOf(value.accent, ACCENTS) && + oneOf(value.hair, HAIR_COLORS) && + oneOf(value.bodyShape, BODY_SHAPES) + ); +} + +function clone(profile: LocalProfile): LocalProfile { + return { ...profile, appearance: { ...profile.appearance } }; +} + +export function migrateLocalProfileV1(profile: LocalProfileV1): LocalProfile { + if (!isLocalProfileV1(profile)) throw new RangeError("profile: invalid V1 profile"); + return { + version: LOCAL_PROFILE_VERSION, + displayName: profile.displayName, + appearance: { + skinTone: profile.skinTone, + outfit: profile.outfit, + accent: profile.accent, + hair: profile.hair, + bodyShape: profile.bodyShape, + }, + }; +} + +/** Strictly decode current data or the one documented legacy version. */ +export function decodeLocalProfile(payload: string | unknown): ProfileDecodeResult { + let value: unknown = payload; + if (typeof payload === "string") { + try { + value = JSON.parse(payload) as unknown; + } catch { + return { ok: false, error: "profile: data is not valid JSON" }; + } + } + if (!isRecord(value) || !Number.isInteger(value.version)) { + return { ok: false, error: "profile: missing version" }; + } + if (value.version === LOCAL_PROFILE_VERSION) { + if (!isLocalProfile(value)) return { ok: false, error: "profile: invalid V2 profile" }; + return { ok: true, profile: clone(value), migrated: false }; + } + if (value.version === 1) { + if (!isLocalProfileV1(value)) return { ok: false, error: "profile: invalid V1 profile" }; + return { ok: true, profile: migrateLocalProfileV1(value), migrated: true, fromVersion: 1 }; + } + return { ok: false, error: "profile: unsupported version" }; +} + +export function encodeLocalProfile(profile: LocalProfile): string { + if (!isLocalProfile(profile)) throw new RangeError("profile: cannot encode invalid profile"); + return JSON.stringify(profile); +} + +/** + * Stable per-id defaults without retaining the id or its hash. Salted hashes + * keep each appearance axis independent and reveal nothing in the output that + * was not already one of the public palette choices. + */ +export function createDefaultLocalProfile(identityId: string, displayName: string): LocalProfile { + if (typeof identityId !== "string" || identityId.length === 0) { + throw new RangeError("profile: identity id is required for deterministic defaults"); + } + const name = displayName.trim(); + if (!validDisplayName(name)) throw new RangeError("profile: display name is invalid"); + function choose(items: readonly T[], salt: string): T { + const item = items[stableHash(`${salt}\u0000${identityId}`) % items.length]; + if (item === undefined) throw new Error("profile: empty choice catalogue"); + return item; + } + return { + version: LOCAL_PROFILE_VERSION, + displayName: name, + appearance: { + skinTone: choose(SKIN_TONES, "skin"), + outfit: choose(OUTFITS, "outfit"), + accent: choose(ACCENTS, "accent"), + hair: choose(HAIR_COLORS, "hair"), + bodyShape: choose(BODY_SHAPES, "body"), + }, + }; +} + +function stableHash(value: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +/** Material-ready values for `buildHumanoid`, still containing no render objects. */ +export function resolveHumanoidAppearance( + appearance: HumanoidAppearanceChoices, +): ResolvedHumanoidAppearance { + if (!isHumanoidAppearance(appearance)) throw new RangeError("profile: invalid humanoid appearance"); + return { + skinTone: SKIN_COLORS[appearance.skinTone], + outfitColor: OUTFIT_COLORS[appearance.outfit], + accentColor: ACCENT_COLORS[appearance.accent], + hairColor: HAIR_VALUES[appearance.hair], + bodyShape: appearance.bodyShape, + }; +} + +/** Signed-in members use their humanoid; anonymous presence follows environment role. */ +export function actorKindForPresence(signedIn: boolean, context: PresenceContext): PresenceActorKind { + if (signedIn) return "humanoid"; + return context === "office" ? "dog" : "crow"; +} diff --git a/src/profile/persistence.ts b/src/profile/persistence.ts new file mode 100644 index 0000000..9d76170 --- /dev/null +++ b/src/profile/persistence.ts @@ -0,0 +1,64 @@ +/** Exception-contained persistence over a caller-owned local storage adapter. */ +import { decodeLocalProfile, encodeLocalProfile, type LocalProfile } from "./model.ts"; + +export interface ProfileStorageAdapter { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +export type ProfileSaveResult = { ok: true } | { ok: false; error: string }; +export type ProfileClearResult = ProfileSaveResult; +export type ProfileLoadResult = + | { status: "loaded"; profile: LocalProfile; migrated: boolean } + | { status: "missing" } + | { status: "invalid"; error: string } + | { status: "unavailable"; error: string }; + +function validKey(key: string): boolean { + return typeof key === "string" && key.trim().length > 0; +} + +export function saveLocalProfile( + storage: ProfileStorageAdapter, + key: string, + profile: LocalProfile, +): ProfileSaveResult { + if (!validKey(key)) return { ok: false, error: "profile: storage key is empty" }; + let encoded: string; + try { + encoded = encodeLocalProfile(profile); + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "profile: encoding failed" }; + } + try { + storage.setItem(key, encoded); + return { ok: true }; + } catch { + return { ok: false, error: "profile: storage is unavailable" }; + } +} + +export function loadLocalProfile(storage: ProfileStorageAdapter, key: string): ProfileLoadResult { + if (!validKey(key)) return { status: "invalid", error: "profile: storage key is empty" }; + let encoded: string | null; + try { + encoded = storage.getItem(key); + } catch { + return { status: "unavailable", error: "profile: storage is unavailable" }; + } + if (encoded === null) return { status: "missing" }; + const decoded = decodeLocalProfile(encoded); + if (!decoded.ok) return { status: "invalid", error: decoded.error }; + return { status: "loaded", profile: decoded.profile, migrated: decoded.migrated }; +} + +export function clearLocalProfile(storage: ProfileStorageAdapter, key: string): ProfileClearResult { + if (!validKey(key)) return { ok: false, error: "profile: storage key is empty" }; + try { + storage.removeItem(key); + return { ok: true }; + } catch { + return { ok: false, error: "profile: storage is unavailable" }; + } +} diff --git a/src/profile/webcamConsent.ts b/src/profile/webcamConsent.ts new file mode 100644 index 0000000..a057c83 --- /dev/null +++ b/src/profile/webcamConsent.ts @@ -0,0 +1,125 @@ +/** + * Ephemeral webcam-face consent state, with no media or persistence capability. + * + * A future browser adapter may react to an accepted `start` transition by + * calling `getUserMedia`; this module cannot do so, cannot fetch anything, and + * deliberately has no encode/storage API. Every fresh controller starts off. + */ + +export type WebcamFaceConsentStatus = + | "off" + | "awaiting-consent" + | "active" + | "stopped" + | "revoked"; + +export interface WebcamFaceConsentState { + status: WebcamFaceConsentStatus; + /** True only between an explicit accepted start and stop/revoke. */ + consentGranted: boolean; + readonly ephemeral: true; + readonly persistence: "none"; + readonly autoFetch: false; + revision: number; +} + +export type WebcamFaceConsentEvent = + | { type: "request-start" } + | { type: "start"; explicitConsent: true } + | { type: "stop" } + | { type: "revoke" }; + +export type WebcamFaceConsentRejection = + | "already-awaiting" + | "already-active" + | "already-stopped" + | "already-revoked" + | "consent-required"; + +export type WebcamFaceConsentTransition = + | { accepted: true; state: WebcamFaceConsentState } + | { accepted: false; state: WebcamFaceConsentState; reason: WebcamFaceConsentRejection }; + +export interface WebcamFaceConsentController { + state(): WebcamFaceConsentState; + requestStart(): WebcamFaceConsentTransition; + /** Only valid after `requestStart`; the literal true prevents accidental implicit consent. */ + start(explicitConsent: true): WebcamFaceConsentTransition; + stop(): WebcamFaceConsentTransition; + revoke(): WebcamFaceConsentTransition; +} + +export function initialWebcamFaceConsent(): WebcamFaceConsentState { + return { + status: "off", + consentGranted: false, + ephemeral: true, + persistence: "none", + autoFetch: false, + revision: 0, + }; +} + +function copy(state: WebcamFaceConsentState): WebcamFaceConsentState { + return { ...state }; +} + +function reject( + state: WebcamFaceConsentState, + reason: WebcamFaceConsentRejection, +): WebcamFaceConsentTransition { + return { accepted: false, state: copy(state), reason }; +} + +function accept( + state: WebcamFaceConsentState, + status: WebcamFaceConsentStatus, + consentGranted: boolean, +): WebcamFaceConsentTransition { + return { + accepted: true, + state: { ...state, status, consentGranted, revision: state.revision + 1 }, + }; +} + +/** Pure transition primitive for UI stores and deterministic tests. */ +export function transitionWebcamFaceConsent( + state: WebcamFaceConsentState, + event: WebcamFaceConsentEvent, +): WebcamFaceConsentTransition { + switch (event.type) { + case "request-start": + if (state.status === "awaiting-consent") return reject(state, "already-awaiting"); + if (state.status === "active") return reject(state, "already-active"); + return accept(state, "awaiting-consent", false); + case "start": + if (state.status === "active") return reject(state, "already-active"); + if (state.status !== "awaiting-consent" || event.explicitConsent !== true) { + return reject(state, "consent-required"); + } + return accept(state, "active", true); + case "stop": + if (state.status === "stopped" || state.status === "off") return reject(state, "already-stopped"); + if (state.status === "revoked") return reject(state, "already-revoked"); + return accept(state, "stopped", false); + case "revoke": + if (state.status === "revoked") return reject(state, "already-revoked"); + return accept(state, "revoked", false); + } +} + +export function createWebcamFaceConsent(): WebcamFaceConsentController { + let current = initialWebcamFaceConsent(); + function apply(event: WebcamFaceConsentEvent): WebcamFaceConsentTransition { + const transition = transitionWebcamFaceConsent(current, event); + if (transition.accepted) current = transition.state; + return { ...transition, state: copy(transition.state) }; + } + return { + state: () => copy(current), + requestStart: () => apply({ type: "request-start" }), + start: (explicitConsent) => apply({ type: "start", explicitConsent }), + stop: () => apply({ type: "stop" }), + revoke: () => apply({ type: "revoke" }), + }; +} diff --git a/src/realtime/index.ts b/src/realtime/index.ts new file mode 100644 index 0000000..7dd6bda --- /dev/null +++ b/src/realtime/index.ts @@ -0,0 +1,11 @@ +export { + isEntityPoseSnapshot, + isInterestCell, + isRealtimeSequence, + isRealtimeTimestamp, + validateClientRealtimeMessage, + validateMotion, + validateServerRealtimeMessage, +} from "./protocol.ts"; +export { PoseInterpolationBuffer } from "./interpolation.ts"; +export type * from "./types.ts"; diff --git a/src/realtime/interpolation.ts b/src/realtime/interpolation.ts new file mode 100644 index 0000000..8b1438d --- /dev/null +++ b/src/realtime/interpolation.ts @@ -0,0 +1,181 @@ +/** Deterministic snapshot interpolation with deliberately bounded prediction. */ + +import type { + BufferedPoseSample, + EntityPoseSnapshot, + InterpolationOptions, + PoseVelocity, + SpatialPose, +} from "./types.ts"; + +interface ResolvedOptions { + interpolationDelayMs: number; + maximumExtrapolationMs: number; + capacity: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function options(value: InterpolationOptions): ResolvedOptions { + return { + interpolationDelayMs: clamp(value.interpolationDelayMs ?? 100, 0, 2_000), + maximumExtrapolationMs: clamp(value.maximumExtrapolationMs ?? 150, 0, 1_000), + capacity: Math.floor(clamp(value.capacity ?? 32, 2, 256)), + }; +} + +function angle(from: number, to: number, t: number): number { + const delta = ((to - from + 540) % 360) - 180; + return from + delta * t; +} + +function compatible(a: SpatialPose, b: SpatialPose): boolean { + if (a.space !== b.space) return false; + if (a.space === "geographic") return true; + return b.space === "local" && JSON.stringify(a.cell) === JSON.stringify(b.cell); +} + +function mixVelocity(a: PoseVelocity, b: PoseVelocity, t: number): PoseVelocity { + return { + xMps: a.xMps + (b.xMps - a.xMps) * t, + yMps: a.yMps + (b.yMps - a.yMps) * t, + zMps: a.zMps + (b.zMps - a.zMps) * t, + yawDegPerSec: a.yawDegPerSec + (b.yawDegPerSec - a.yawDegPerSec) * t, + }; +} + +function mixPose(a: SpatialPose, b: SpatialPose, t: number): SpatialPose { + if (!compatible(a, b)) return { ...b }; + if (a.space === "geographic" && b.space === "geographic") { + return { + space: "geographic", + lat: a.lat + (b.lat - a.lat) * t, + lng: a.lng + (b.lng - a.lng) * t, + altitudeM: a.altitudeM + (b.altitudeM - a.altitudeM) * t, + headingDeg: angle(a.headingDeg, b.headingDeg, t), + pitchDeg: a.pitchDeg + (b.pitchDeg - a.pitchDeg) * t, + }; + } + if (a.space === "local" && b.space === "local") { + return { + space: "local", + cell: { ...b.cell }, + xM: a.xM + (b.xM - a.xM) * t, + yM: a.yM + (b.yM - a.yM) * t, + zM: a.zM + (b.zM - a.zM) * t, + headingDeg: angle(a.headingDeg, b.headingDeg, t), + pitchDeg: a.pitchDeg + (b.pitchDeg - a.pitchDeg) * t, + }; + } + return { ...b }; +} + +function predictPose(pose: SpatialPose, velocity: PoseVelocity, seconds: number): SpatialPose { + if (pose.space === "local") { + return { + ...pose, + cell: { ...pose.cell }, + xM: pose.xM + velocity.xMps * seconds, + yM: pose.yM + velocity.yMps * seconds, + zM: pose.zM + velocity.zMps * seconds, + headingDeg: pose.headingDeg + velocity.yawDegPerSec * seconds, + }; + } + // Geographic velocity uses east/up/north axes matching local x/y/z. + const earth = 6_371_000; + const latitudeRadians = pose.lat * Math.PI / 180; + return { + ...pose, + lat: pose.lat + (velocity.zMps * seconds / earth) * 180 / Math.PI, + lng: pose.lng + (velocity.xMps * seconds / (earth * Math.max(0.01, Math.cos(latitudeRadians)))) * 180 / Math.PI, + altitudeM: pose.altitudeM + velocity.yMps * seconds, + headingDeg: pose.headingDeg + velocity.yawDegPerSec * seconds, + }; +} + +function id(snapshot: EntityPoseSnapshot): string { + return snapshot.entity === "actor" ? `actor:${snapshot.actorId}` : `vehicle:${snapshot.vehicleId}`; +} + +export class PoseInterpolationBuffer { + private readonly settings: ResolvedOptions; + private readonly snapshots: EntityPoseSnapshot[] = []; + private entityId: string | null = null; + + constructor(optionsValue: InterpolationOptions = {}) { + this.settings = options(optionsValue); + } + + clear(): void { + this.snapshots.length = 0; + this.entityId = null; + } + + size(): number { + return this.snapshots.length; + } + + /** Reject duplicates, stale sequence numbers, and accidental entity mixing. */ + push(snapshot: EntityPoseSnapshot): boolean { + const incomingId = id(snapshot); + if (this.entityId !== null && incomingId !== this.entityId) return false; + const latest = this.snapshots.at(-1); + if (latest && (snapshot.sequence <= latest.sequence || snapshot.timestampMs <= latest.timestampMs)) { + return false; + } + this.entityId = incomingId; + this.snapshots.push(snapshot); + if (this.snapshots.length > this.settings.capacity) this.snapshots.shift(); + return true; + } + + /** Sample server time minus interpolation delay; never predicts past the configured cap. */ + sample(serverNowMs: number): BufferedPoseSample | null { + if (!Number.isFinite(serverNowMs) || this.snapshots.length === 0) return null; + const target = serverNowMs - this.settings.interpolationDelayMs; + const first = this.snapshots[0]; + const latest = this.snapshots.at(-1); + if (!first || !latest) return null; + if (target <= first.timestampMs) return this.from(first, "held", first.timestampMs); + + for (let index = 1; index < this.snapshots.length; index += 1) { + const after = this.snapshots[index]; + const before = this.snapshots[index - 1]; + if (!after || !before || target > after.timestampMs) continue; + if (target === after.timestampMs) return this.from(after, "exact", target); + if (!compatible(before.pose, after.pose)) return this.from(after, "held", target); + const t = (target - before.timestampMs) / (after.timestampMs - before.timestampMs); + return { + mode: "interpolated", + pose: mixPose(before.pose, after.pose, t), + velocity: mixVelocity(before.velocity, after.velocity, t), + timestampMs: target, + sourceSequence: after.sequence, + }; + } + + const extrapolationMs = clamp(target - latest.timestampMs, 0, this.settings.maximumExtrapolationMs); + if (extrapolationMs === 0) return this.from(latest, "exact", latest.timestampMs); + return { + mode: target - latest.timestampMs > this.settings.maximumExtrapolationMs ? "held" : "extrapolated", + pose: predictPose(latest.pose, latest.velocity, extrapolationMs / 1_000), + velocity: { ...latest.velocity }, + timestampMs: latest.timestampMs + extrapolationMs, + sourceSequence: latest.sequence, + }; + } + + private from(snapshot: EntityPoseSnapshot, mode: "exact" | "held", timestampMs: number): BufferedPoseSample { + return { + mode, + pose: snapshot.pose.space === "local" + ? { ...snapshot.pose, cell: { ...snapshot.pose.cell } } + : { ...snapshot.pose }, + velocity: { ...snapshot.velocity }, + timestampMs, + sourceSequence: snapshot.sequence, + }; + } +} diff --git a/src/realtime/protocol.ts b/src/realtime/protocol.ts new file mode 100644 index 0000000..72efc82 --- /dev/null +++ b/src/realtime/protocol.ts @@ -0,0 +1,262 @@ +/** Strict runtime validation for untrusted realtime protocol payloads. */ + +import type { + ClientRealtimeMessage, + EntityPoseSnapshot, + InterestCell, + MotionLimits, + MotionValidationResult, + RealtimeSequence, + RealtimeValidationResult, + ServerRealtimeMessage, + SpatialPose, +} from "./types.ts"; + +const UINT32_MAX = 4_294_967_295; +const MAX_TIMESTAMP_MS = 8_640_000_000_000_000; +const MAX_INTERESTS = 128; +const MAX_ENTITIES_PER_MESSAGE = 2_048; +const MAX_STRING = 256; +const EARTH_RADIUS_M = 6_371_000; + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function string(value: unknown, maximum = MAX_STRING): value is string { + return typeof value === "string" && value.length > 0 && value.length <= maximum; +} + +function finite(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +export function isRealtimeSequence(value: unknown): value is RealtimeSequence { + return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= UINT32_MAX; +} + +export function isRealtimeTimestamp(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= MAX_TIMESTAMP_MS; +} + +export function isInterestCell(value: unknown): value is InterestCell { + if (!record(value)) return false; + switch (value.kind) { + case "california-tile": + return ( + Number.isInteger(value.x) && + Number.isInteger(value.y) && + Number.isInteger(value.level) && + Number(value.x) >= -1_000_000 && + Number(value.x) <= 1_000_000 && + Number(value.y) >= -1_000_000 && + Number(value.y) <= 1_000_000 && + Number(value.level) >= 0 && + Number(value.level) <= 24 + ); + case "city": + return value.cityId === "bay-area" || value.cityId === "socal"; + case "office": + return string(value.officeId); + case "floor": + return string(value.officeId) && string(value.floorId); + case "room": + return string(value.officeId) && string(value.floorId) && string(value.roomId); + default: + return false; + } +} + +function isSpatialPose(value: unknown): value is SpatialPose { + if (!record(value)) return false; + if (value.space === "geographic") { + return ( + finite(value.lat) && value.lat >= -90 && value.lat <= 90 && + finite(value.lng) && value.lng >= -180 && value.lng <= 180 && + finite(value.altitudeM) && value.altitudeM >= -500 && value.altitudeM <= 100_000 && + finite(value.headingDeg) && value.headingDeg >= -360 && value.headingDeg <= 360 && + finite(value.pitchDeg) && value.pitchDeg >= -90 && value.pitchDeg <= 90 + ); + } + return ( + value.space === "local" && + isInterestCell(value.cell) && + value.cell.kind !== "california-tile" && + finite(value.xM) && Math.abs(value.xM) <= 1_000_000 && + finite(value.yM) && Math.abs(value.yM) <= 100_000 && + finite(value.zM) && Math.abs(value.zM) <= 1_000_000 && + finite(value.headingDeg) && value.headingDeg >= -360 && value.headingDeg <= 360 && + finite(value.pitchDeg) && value.pitchDeg >= -90 && value.pitchDeg <= 90 + ); +} + +function isVelocity(value: unknown): boolean { + return ( + record(value) && + finite(value.xMps) && Math.abs(value.xMps) <= 1_000 && + finite(value.yMps) && Math.abs(value.yMps) <= 1_000 && + finite(value.zMps) && Math.abs(value.zMps) <= 1_000 && + finite(value.yawDegPerSec) && Math.abs(value.yawDegPerSec) <= 10_000 + ); +} + +export function isEntityPoseSnapshot(value: unknown): value is EntityPoseSnapshot { + if ( + !record(value) || + !isRealtimeSequence(value.sequence) || + !isRealtimeTimestamp(value.timestampMs) || + !isSpatialPose(value.pose) || + !isVelocity(value.velocity) + ) return false; + if (value.entity === "actor") { + return ( + string(value.actorId) && + (value.kind === "humanoid" || value.kind === "dog" || value.kind === "crow") + ); + } + return ( + value.entity === "vehicle" && + string(value.vehicleId) && + value.kind === "model-x" && + (value.driverActorId === null || string(value.driverActorId)) && + finite(value.steering) && value.steering >= -1 && value.steering <= 1 && + finite(value.wheelRadians) && Math.abs(value.wheelRadians) <= 1_000_000 + ); +} + +function interests(value: unknown): value is readonly InterestCell[] { + return Array.isArray(value) && value.length <= MAX_INTERESTS && value.every(isInterestCell); +} + +function entities(value: unknown): value is readonly EntityPoseSnapshot[] { + return Array.isArray(value) && value.length <= MAX_ENTITIES_PER_MESSAGE && value.every(isEntityPoseSnapshot); +} + +function common(value: Record): boolean { + return value.protocolVersion === 1 && string(value.requestId); +} + +export function validateClientRealtimeMessage( + value: unknown, +): RealtimeValidationResult { + if (!record(value) || !common(value)) return { ok: false, error: "realtime: invalid envelope" }; + if (value.type === "join-request") { + if ( + string(value.actorId) && + (value.resumeToken === null || string(value.resumeToken, 512)) && + (value.lastReceivedSequence === null || isRealtimeSequence(value.lastReceivedSequence)) && + interests(value.interests) + ) return { ok: true, value: value as unknown as ClientRealtimeMessage }; + return { ok: false, error: "realtime: invalid join request" }; + } + if (value.type === "resume-request") { + if ( + string(value.sessionId) && string(value.resumeToken, 512) && string(value.serverEpoch) && + isRealtimeSequence(value.lastReceivedSequence) && interests(value.interests) + ) return { ok: true, value: value as unknown as ClientRealtimeMessage }; + return { ok: false, error: "realtime: invalid resume request" }; + } + return { ok: false, error: "realtime: unknown client message" }; +} + +export function validateServerRealtimeMessage( + value: unknown, +): RealtimeValidationResult { + if (!record(value) || value.protocolVersion !== 1) { + return { ok: false, error: "realtime: invalid envelope" }; + } + if (value.type === "join-grant") { + if ( + string(value.requestId) && string(value.sessionId) && string(value.actorId) && + (value.role === "visitor" || value.role === "member" || value.role === "admin") && + string(value.serverEpoch) && isRealtimeTimestamp(value.serverTimeMs) && + isRealtimeSequence(value.nextSequence) && string(value.resumeToken, 512) && + interests(value.interests) && entities(value.initial) + ) return { ok: true, value: value as unknown as ServerRealtimeMessage }; + return { ok: false, error: "realtime: invalid join grant" }; + } + if (value.type === "resume-grant") { + if ( + string(value.requestId) && string(value.sessionId) && string(value.serverEpoch) && + isRealtimeTimestamp(value.serverTimeMs) && isRealtimeSequence(value.nextSequence) && + string(value.resumeToken, 512) && typeof value.continuous === "boolean" && entities(value.snapshot) + ) return { ok: true, value: value as unknown as ServerRealtimeMessage }; + return { ok: false, error: "realtime: invalid resume grant" }; + } + if (value.type === "pose-delta") { + if ( + string(value.serverEpoch) && isRealtimeSequence(value.sequence) && + isRealtimeTimestamp(value.timestampMs) && entities(value.updates) && + Array.isArray(value.removedEntityIds) && + value.removedEntityIds.length <= MAX_ENTITIES_PER_MESSAGE && + value.removedEntityIds.every((id) => string(id)) + ) return { ok: true, value: value as unknown as ServerRealtimeMessage }; + return { ok: false, error: "realtime: invalid pose delta" }; + } + if (value.type === "membership-revoked") { + const reasons = new Set([ + "signed-out", "membership-revoked", "session-replaced", "protocol-violation", "server-shutdown", + ]); + if ( + string(value.serverEpoch) && isRealtimeSequence(value.sequence) && + isRealtimeTimestamp(value.timestampMs) && string(value.sessionId) && string(value.actorId) && + reasons.has(String(value.reason)) && typeof value.reconnectAllowed === "boolean" + ) return { ok: true, value: value as unknown as ServerRealtimeMessage }; + return { ok: false, error: "realtime: invalid membership revocation" }; + } + return { ok: false, error: "realtime: unknown server message" }; +} + +function entityId(snapshot: EntityPoseSnapshot): string { + return snapshot.entity === "actor" ? `actor:${snapshot.actorId}` : `vehicle:${snapshot.vehicleId}`; +} + +function localCellKey(pose: SpatialPose): string | null { + if (pose.space !== "local") return null; + return JSON.stringify(pose.cell); +} + +function angleDeltaDegrees(from: number, to: number): number { + return ((to - from + 540) % 360) - 180; +} + +function displacementMetres(from: SpatialPose, to: SpatialPose): { horizontal: number; vertical: number } | null { + if (from.space !== to.space) return null; + if (from.space === "local" && to.space === "local") { + if (localCellKey(from) !== localCellKey(to)) return null; + return { horizontal: Math.hypot(to.xM - from.xM, to.zM - from.zM), vertical: to.yM - from.yM }; + } + if (from.space === "geographic" && to.space === "geographic") { + const meanLat = ((from.lat + to.lat) / 2) * Math.PI / 180; + const north = (to.lat - from.lat) * Math.PI / 180 * EARTH_RADIUS_M; + const east = (to.lng - from.lng) * Math.PI / 180 * Math.cos(meanLat) * EARTH_RADIUS_M; + return { horizontal: Math.hypot(north, east), vertical: to.altitudeM - from.altitudeM }; + } + return null; +} + +/** Server-side helper for rejecting impossible client-proposed movement. */ +export function validateMotion( + previous: EntityPoseSnapshot, + proposed: EntityPoseSnapshot, + limits: MotionLimits, +): MotionValidationResult { + if (entityId(previous) !== entityId(proposed)) return { ok: false, reason: "identity-mismatch" }; + const displacement = displacementMetres(previous.pose, proposed.pose); + if (!displacement) return { ok: false, reason: "space-mismatch" }; + const dt = (proposed.timestampMs - previous.timestampMs) / 1_000; + const minimum = Math.max(0.001, limits.minimumDeltaSeconds ?? 1 / 240); + const maximum = Math.max(minimum, limits.maximumDeltaSeconds ?? 2); + if (!Number.isFinite(dt) || dt < minimum || dt > maximum || proposed.sequence <= previous.sequence) { + return { ok: false, reason: "time-invalid" }; + } + const horizontalSpeedMps = Math.max(0, displacement.horizontal - Math.max(0, limits.positionSlackM)) / dt; + const verticalSpeedMps = Math.abs(displacement.vertical) / dt; + const turnRateDegPerSec = Math.abs(angleDeltaDegrees(previous.pose.headingDeg, proposed.pose.headingDeg)) / dt; + if ( + !finite(limits.maximumHorizontalSpeedMps) || horizontalSpeedMps > limits.maximumHorizontalSpeedMps || + !finite(limits.maximumVerticalSpeedMps) || verticalSpeedMps > limits.maximumVerticalSpeedMps || + !finite(limits.maximumTurnRateDegPerSec) || turnRateDegPerSec > limits.maximumTurnRateDegPerSec + ) return { ok: false, reason: "speed-exceeded" }; + return { ok: true, horizontalSpeedMps, verticalSpeedMps, turnRateDegPerSec }; +} diff --git a/src/realtime/types.ts b/src/realtime/types.ts new file mode 100644 index 0000000..42a83fb --- /dev/null +++ b/src/realtime/types.ts @@ -0,0 +1,219 @@ +/** JSON-safe contracts for an eventual server-authoritative Tera session. */ + +export type RealtimeActorKind = "humanoid" | "dog" | "crow"; +export type RealtimeVehicleKind = "model-x"; +export type RealtimeMemberRole = "visitor" | "member" | "admin"; +export type RealtimeRevocationReason = + | "signed-out" + | "membership-revoked" + | "session-replaced" + | "protocol-violation" + | "server-shutdown"; + +export interface CaliforniaTileInterest { + kind: "california-tile"; + /** Discrete authored world grid, not web-map tiles. */ + x: number; + y: number; + level: number; +} + +export interface CityInterest { + kind: "city"; + cityId: "bay-area" | "socal"; +} + +export interface OfficeInterest { + kind: "office"; + officeId: string; +} + +export interface FloorInterest { + kind: "floor"; + officeId: string; + floorId: string; +} + +export interface RoomInterest { + kind: "room"; + officeId: string; + floorId: string; + roomId: string; +} + +export type InterestCell = + | CaliforniaTileInterest + | CityInterest + | OfficeInterest + | FloorInterest + | RoomInterest; + +/** Monotonic within one server epoch and bounded to an unsigned 32-bit integer. */ +export type RealtimeSequence = number; +/** Unix epoch milliseconds, serialized as a safe integer. */ +export type RealtimeTimestamp = number; + +export interface GeographicPose { + space: "geographic"; + lat: number; + lng: number; + altitudeM: number; + headingDeg: number; + pitchDeg: number; +} + +export interface LocalPose { + space: "local"; + /** The smallest spatial cell whose coordinates define this pose. */ + cell: CityInterest | OfficeInterest | FloorInterest | RoomInterest; + xM: number; + yM: number; + zM: number; + headingDeg: number; + pitchDeg: number; +} + +export type SpatialPose = GeographicPose | LocalPose; + +export interface PoseVelocity { + xMps: number; + yMps: number; + zMps: number; + yawDegPerSec: number; +} + +interface EntityPoseSnapshotBase { + sequence: RealtimeSequence; + timestampMs: RealtimeTimestamp; + pose: SpatialPose; + velocity: PoseVelocity; +} + +export interface ActorPoseSnapshot extends EntityPoseSnapshotBase { + entity: "actor"; + actorId: string; + kind: RealtimeActorKind; +} + +export interface VehiclePoseSnapshot extends EntityPoseSnapshotBase { + entity: "vehicle"; + vehicleId: string; + kind: RealtimeVehicleKind; + driverActorId: string | null; + steering: number; + wheelRadians: number; +} + +export type EntityPoseSnapshot = ActorPoseSnapshot | VehiclePoseSnapshot; + +export interface JoinRequest { + type: "join-request"; + protocolVersion: 1; + requestId: string; + actorId: string; + resumeToken: string | null; + lastReceivedSequence: RealtimeSequence | null; + interests: readonly InterestCell[]; +} + +export interface JoinGrant { + type: "join-grant"; + protocolVersion: 1; + requestId: string; + sessionId: string; + actorId: string; + role: RealtimeMemberRole; + serverEpoch: string; + serverTimeMs: RealtimeTimestamp; + nextSequence: RealtimeSequence; + resumeToken: string; + interests: readonly InterestCell[]; + initial: readonly EntityPoseSnapshot[]; +} + +export interface ResumeRequest { + type: "resume-request"; + protocolVersion: 1; + requestId: string; + sessionId: string; + resumeToken: string; + serverEpoch: string; + lastReceivedSequence: RealtimeSequence; + interests: readonly InterestCell[]; +} + +export interface ResumeGrant { + type: "resume-grant"; + protocolVersion: 1; + requestId: string; + sessionId: string; + serverEpoch: string; + serverTimeMs: RealtimeTimestamp; + nextSequence: RealtimeSequence; + resumeToken: string; + /** True means queued deltas bridge the requested sequence without a gap. */ + continuous: boolean; + snapshot: readonly EntityPoseSnapshot[]; +} + +export interface PoseDelta { + type: "pose-delta"; + protocolVersion: 1; + serverEpoch: string; + sequence: RealtimeSequence; + timestampMs: RealtimeTimestamp; + updates: readonly EntityPoseSnapshot[]; + removedEntityIds: readonly string[]; +} + +export interface MembershipRevoked { + type: "membership-revoked"; + protocolVersion: 1; + serverEpoch: string; + sequence: RealtimeSequence; + timestampMs: RealtimeTimestamp; + sessionId: string; + actorId: string; + reason: RealtimeRevocationReason; + reconnectAllowed: boolean; +} + +export type ClientRealtimeMessage = JoinRequest | ResumeRequest; +export type ServerRealtimeMessage = JoinGrant | ResumeGrant | PoseDelta | MembershipRevoked; + +export type RealtimeValidationResult = + | { ok: true; value: T } + | { ok: false; error: string }; + +export interface MotionLimits { + maximumHorizontalSpeedMps: number; + maximumVerticalSpeedMps: number; + maximumTurnRateDegPerSec: number; + /** Allows quantization and packet jitter without permitting teleportation. */ + positionSlackM: number; + minimumDeltaSeconds?: number; + maximumDeltaSeconds?: number; +} + +export type MotionValidationResult = + | { ok: true; horizontalSpeedMps: number; verticalSpeedMps: number; turnRateDegPerSec: number } + | { + ok: false; + reason: "identity-mismatch" | "space-mismatch" | "time-invalid" | "speed-exceeded"; + }; + +export interface InterpolationOptions { + interpolationDelayMs?: number; + maximumExtrapolationMs?: number; + capacity?: number; +} + +export type BufferedSampleMode = "exact" | "interpolated" | "extrapolated" | "held"; + +export interface BufferedPoseSample { + mode: BufferedSampleMode; + pose: SpatialPose; + velocity: PoseVelocity; + timestampMs: RealtimeTimestamp; + sourceSequence: RealtimeSequence; +} diff --git a/src/test/mediaSurface.test.ts b/src/test/mediaSurface.test.ts new file mode 100644 index 0000000..94f6b92 --- /dev/null +++ b/src/test/mediaSurface.test.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + authorizeMediaSurface, + createMediaSurfaceLifecycle, + isMediaSurface, + parseMediaSurface, + parseMediaViewerContext, + transitionMediaSurface, + type MediaSurface, + type MediaSurfaceSource, +} from "../media/index.ts"; +import type { VideoTexture } from "three"; + +const PRIVATE_SOURCE: MediaSurfaceSource = { + kind: "live-stream", + locator: "opaque:private-stream-token", + privacy: "private", + hasAudio: true, + autoplay: false, + muted: true, +}; + +function surface(over: Partial = {}): MediaSurface { + return { + screenId: "screen-1", + officeId: "office-1", + levelId: "level-1", + roomId: "room-1", + acl: { + audience: "office-members", + viewerIds: [], + presenterIds: ["presenter-1"], + moderatorIds: ["moderator-1"], + }, + source: PRIVATE_SOURCE, + state: { status: "presenting", presenterId: "presenter-1", revision: 1 }, + ...over, + }; +} + +describe("media surface validation", () => { + it("accepts a strict JSON round trip and rejects extra/non-JSON fields", () => { + const parsed = parseMediaSurface(JSON.parse(JSON.stringify(surface()))); + assert.deepEqual(parsed, surface()); + assert.equal(isMediaSurface({ ...surface(), surprise: true }), false); + assert.equal(isMediaSurface({ ...surface(), screenId: "bad id" }), false); + assert.equal(isMediaSurface(Object.assign(Object.create(null), surface())), false); + }); + + it("enforces state/source consistency and safe playback literals", () => { + assert.throws(() => parseMediaSurface({ ...surface(), source: null }), /require a source/); + assert.throws(() => parseMediaSurface({ + ...surface(), + source: { ...PRIVATE_SOURCE, autoplay: true }, + }), /autoplay/); + assert.throws(() => parseMediaSurface({ + ...surface(), + source: { ...PRIVATE_SOURCE, muted: false }, + }), /muted/); + assert.throws(() => parseMediaSurface({ + ...surface(), + acl: { ...surface().acl, audience: "public" }, + }), /private source/); + }); + + it("strictly validates the server-created viewer context", () => { + assert.deepEqual(parseMediaViewerContext({ + viewerId: "viewer-1", officeIds: ["office-1"], optedIn: true, + }), { viewerId: "viewer-1", officeIds: ["office-1"], optedIn: true }); + assert.throws(() => parseMediaViewerContext({ + viewerId: null, officeIds: [], optedIn: true, clientClaimsAdmin: true, + }), /exactly/); + }); +}); + +describe("server-enforceable authorization", () => { + it("never returns a private source to an anonymous public viewer", () => { + const decision = authorizeMediaSurface(surface(), { viewerId: null, officeIds: [], optedIn: true }); + assert.equal(decision.authorized, false); + assert.equal(decision.canView, false); + assert.equal(decision.reason, "authentication_required"); + assert.equal(decision.surface.source, null); + assert.ok(!JSON.stringify(decision).includes(PRIVATE_SOURCE.locator)); + }); + + it("requires both trusted membership and explicit opt-in before releasing source", () => { + const context = { viewerId: "viewer-1", officeIds: ["office-1"], optedIn: false }; + const waiting = authorizeMediaSurface(surface(), context); + assert.equal(waiting.authorized, true); + assert.equal(waiting.reason, "opt_in_required"); + assert.equal(waiting.surface.source, null); + const ready = authorizeMediaSurface(surface(), { ...context, optedIn: true }); + assert.equal(ready.canView, true); + assert.equal(ready.surface.source?.locator, PRIVATE_SOURCE.locator); + assert.deepEqual(ready.surface.playback, { autoplay: false, muted: true }); + }); +}); + +describe("presenter lifecycle", () => { + it("stops and revokes by clearing the sensitive source", () => { + const stopped = transitionMediaSurface(surface(), { type: "stop", presenterId: "presenter-1" }); + assert.equal(stopped.state.status, "stopped"); + assert.equal(stopped.source, null); + assert.equal(stopped.state.revision, 2); + const revoked = transitionMediaSurface(surface(), { type: "revoke", actorId: "moderator-1" }); + assert.equal(revoked.state.status, "revoked"); + assert.equal(revoked.source, null); + assert.throws(() => transitionMediaSurface(surface(), { type: "revoke", actorId: "stranger" })); + }); + + it("retains authorization through a deterministic disconnect/reconnect", () => { + const reconnecting = transitionMediaSurface(surface(), { type: "disconnect", presenterId: "presenter-1" }); + assert.equal(reconnecting.state.status, "reconnecting"); + const resumed = transitionMediaSurface(reconnecting, { type: "reconnect", presenterId: "presenter-1" }); + assert.equal(resumed.state.status, "presenting"); + assert.equal(resumed.state.revision, 3); + assert.throws(() => transitionMediaSurface(reconnecting, { type: "reconnect", presenterId: "other" })); + }); +}); + +describe("caller-owned video/texture lifecycle", () => { + it("never plays, stops a track, or disposes a texture and releases references", () => { + let pauses = 0; + let stops = 0; + let disposals = 0; + const video = { + autoplay: true, + muted: false, + pause: () => { pauses += 1; }, + } as unknown as HTMLVideoElement; + const texture = { dispose: () => { disposals += 1; } } as unknown as VideoTexture; + const track = { stop: () => { stops += 1; } } as unknown as MediaStreamTrack; + const lifecycle = createMediaSurfaceLifecycle(); + lifecycle.bind({ video, texture, track }); + assert.equal(video.autoplay, false); + assert.equal(video.muted, true); + assert.equal(lifecycle.texture(), null); + lifecycle.apply(authorizeMediaSurface(surface(), { + viewerId: "viewer-1", officeIds: ["office-1"], optedIn: true, + })); + assert.equal(lifecycle.texture(), texture); + lifecycle.dispose(); + lifecycle.dispose(); + assert.equal(lifecycle.texture(), null); + assert.ok(pauses >= 2); + assert.equal(stops, 0); + assert.equal(disposals, 0); + }); +}); diff --git a/src/test/profile.test.ts b/src/test/profile.test.ts new file mode 100644 index 0000000..03060e5 --- /dev/null +++ b/src/test/profile.test.ts @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + actorKindForPresence, + clearLocalProfile, + createDefaultLocalProfile, + createWebcamFaceConsent, + decodeLocalProfile, + encodeLocalProfile, + initialWebcamFaceConsent, + loadLocalProfile, + resolveHumanoidAppearance, + saveLocalProfile, + transitionWebcamFaceConsent, + type LocalProfileV1, + type ProfileStorageAdapter, +} from "../profile/index.ts"; + +class MemoryStorage implements ProfileStorageAdapter { + readonly values = new Map(); + getItem(key: string): string | null { return this.values.get(key) ?? null; } + setItem(key: string, value: string): void { this.values.set(key, value); } + removeItem(key: string): void { this.values.delete(key); } +} + +describe("local member profile", () => { + it("makes stable, useful defaults from identity without retaining or leaking the id", () => { + const identityId = "auth0|private-user-82914"; + const first = createDefaultLocalProfile(identityId, " Avery "); + const second = createDefaultLocalProfile(identityId, "Avery"); + assert.deepEqual(first, second); + assert.equal(first.displayName, "Avery"); + const encoded = encodeLocalProfile(first); + assert.equal(encoded.includes(identityId), false); + assert.deepEqual(Object.keys(first).sort(), ["appearance", "displayName", "version"]); + assert.doesNotThrow(() => JSON.stringify(first)); + const resolved = resolveHumanoidAppearance(first.appearance); + assert.match(resolved.skinTone, /^#[0-9a-f]{6}$/i); + assert.match(resolved.outfitColor, /^#[0-9a-f]{6}$/i); + assert.equal(resolved.bodyShape, first.appearance.bodyShape); + }); + + it("strictly rejects unknown, malformed and future profile data", () => { + const profile = createDefaultLocalProfile("member", "Member"); + assert.equal(decodeLocalProfile({ ...profile, identityId: "leak" }).ok, false); + assert.equal(decodeLocalProfile({ ...profile, faceImageUrl: "camera://stream" }).ok, false); + assert.equal(decodeLocalProfile({ ...profile, appearance: { ...profile.appearance, outfit: "rainbow" } }).ok, false); + assert.deepEqual(decodeLocalProfile({ version: 99 }), { ok: false, error: "profile: unsupported version" }); + assert.deepEqual(decodeLocalProfile("{"), { ok: false, error: "profile: data is not valid JSON" }); + assert.throws(() => createDefaultLocalProfile("", "Member"), RangeError); + }); + + it("migrates the one exact V1 shape into an isolated V2 appearance", () => { + const legacy: LocalProfileV1 = { + version: 1, + displayName: "River", + skinTone: "umber", + outfit: "navy", + accent: "amber", + hair: "auburn", + bodyShape: "slim", + }; + const decoded = decodeLocalProfile(JSON.stringify(legacy)); + assert.equal(decoded.ok, true); + if (!decoded.ok) return; + assert.equal(decoded.migrated, true); + assert.deepEqual(decoded.profile, { + version: 2, + displayName: "River", + appearance: { + skinTone: "umber", + outfit: "navy", + accent: "amber", + hair: "auburn", + bodyShape: "slim", + }, + }); + assert.equal(decodeLocalProfile({ ...legacy, extra: true }).ok, false); + }); + + it("maps signed-in and anonymous roles by environment", () => { + assert.equal(actorKindForPresence(true, "outdoors"), "humanoid"); + assert.equal(actorKindForPresence(true, "office"), "humanoid"); + assert.equal(actorKindForPresence(false, "outdoors"), "crow"); + assert.equal(actorKindForPresence(false, "office"), "dog"); + }); +}); + +describe("profile persistence boundary", () => { + it("round-trips, reports migration without an implicit rewrite, and clears", () => { + const storage = new MemoryStorage(); + const profile = createDefaultLocalProfile("signed-in-1", "Morgan"); + assert.deepEqual(saveLocalProfile(storage, "tera.profile", profile), { ok: true }); + assert.deepEqual(loadLocalProfile(storage, "tera.profile"), { + status: "loaded", + profile, + migrated: false, + }); + + const legacy: LocalProfileV1 = { + version: 1, + displayName: "Morgan", + skinTone: "sand", + outfit: "sage", + accent: "aqua", + hair: "black", + bodyShape: "average", + }; + storage.values.set("legacy", JSON.stringify(legacy)); + const before = storage.values.get("legacy"); + const loaded = loadLocalProfile(storage, "legacy"); + assert.equal(loaded.status, "loaded"); + if (loaded.status === "loaded") assert.equal(loaded.migrated, true); + assert.equal(storage.values.get("legacy"), before, "load never silently rewrites data"); + assert.deepEqual(clearLocalProfile(storage, "tera.profile"), { ok: true }); + assert.deepEqual(loadLocalProfile(storage, "tera.profile"), { status: "missing" }); + }); + + it("contains adapter and encoder exceptions", () => { + const unavailable: ProfileStorageAdapter = { + getItem: () => { throw new Error("blocked"); }, + setItem: () => { throw new Error("quota"); }, + removeItem: () => { throw new Error("blocked"); }, + }; + const profile = createDefaultLocalProfile("signed-in-2", "Taylor"); + assert.equal(loadLocalProfile(unavailable, "tera.profile").status, "unavailable"); + assert.equal(saveLocalProfile(unavailable, "tera.profile", profile).ok, false); + assert.equal(clearLocalProfile(unavailable, "tera.profile").ok, false); + assert.equal(loadLocalProfile(unavailable, " ").status, "invalid"); + const invalid = { ...profile, displayName: "" }; + assert.equal(saveLocalProfile(new MemoryStorage(), "x", invalid).ok, false); + }); +}); + +describe("ephemeral webcam face consent", () => { + it("starts off with explicit privacy invariants and cannot auto-start", () => { + const state = initialWebcamFaceConsent(); + assert.deepEqual(state, { + status: "off", + consentGranted: false, + ephemeral: true, + persistence: "none", + autoFetch: false, + revision: 0, + }); + const rejected = transitionWebcamFaceConsent(state, { type: "start", explicitConsent: true }); + assert.equal(rejected.accepted, false); + if (!rejected.accepted) assert.equal(rejected.reason, "consent-required"); + }); + + it("requires request then explicit start, and clears consent on stop", () => { + const consent = createWebcamFaceConsent(); + assert.equal(consent.requestStart().accepted, true); + assert.equal(consent.state().status, "awaiting-consent"); + assert.equal(consent.start(true).accepted, true); + assert.deepEqual(consent.state(), { + status: "active", + consentGranted: true, + ephemeral: true, + persistence: "none", + autoFetch: false, + revision: 2, + }); + assert.equal(consent.stop().accepted, true); + assert.equal(consent.state().status, "stopped"); + assert.equal(consent.state().consentGranted, false); + assert.equal(consent.start(true).accepted, false, "a stopped session needs a new request"); + }); + + it("revokes from any live state and requires a fresh user request before reuse", () => { + const consent = createWebcamFaceConsent(); + consent.requestStart(); + consent.start(true); + assert.equal(consent.revoke().accepted, true); + assert.equal(consent.state().status, "revoked"); + assert.equal(consent.state().consentGranted, false); + assert.equal(consent.start(true).accepted, false); + assert.equal(consent.requestStart().accepted, true); + assert.equal(consent.state().status, "awaiting-consent"); + const leaked = consent.state(); + leaked.status = "active"; + assert.equal(consent.state().status, "awaiting-consent"); + }); +}); diff --git a/src/test/realtime.test.ts b/src/test/realtime.test.ts new file mode 100644 index 0000000..86755f8 --- /dev/null +++ b/src/test/realtime.test.ts @@ -0,0 +1,262 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + PoseInterpolationBuffer, + isEntityPoseSnapshot, + isInterestCell, + isRealtimeSequence, + isRealtimeTimestamp, + validateClientRealtimeMessage, + validateMotion, + validateServerRealtimeMessage, + type ActorPoseSnapshot, + type InterestCell, + type VehiclePoseSnapshot, +} from "../realtime/index.ts"; + +const localActor = ( + sequence: number, + timestampMs: number, + xM: number, + headingDeg = 0, +): ActorPoseSnapshot => ({ + entity: "actor", + actorId: "actor-karti", + kind: "humanoid", + sequence, + timestampMs, + pose: { + space: "local", + cell: { kind: "floor", officeId: "lumbridge-hq", floorId: "level-1" }, + xM, + yM: 0, + zM: 2, + headingDeg, + pitchDeg: 0, + }, + velocity: { xMps: 10, yMps: 0, zMps: 0, yawDegPerSec: 10 }, +}); + +const geographicVehicle = ( + sequence: number, + timestampMs: number, + lat: number, +): VehiclePoseSnapshot => ({ + entity: "vehicle", + vehicleId: "model-x-hero", + kind: "model-x", + driverActorId: "actor-karti", + sequence, + timestampMs, + pose: { + space: "geographic", + lat, + lng: -120, + altitudeM: 100, + headingDeg: 0, + pitchDeg: 0, + }, + velocity: { xMps: 0, yMps: 0, zMps: 30, yawDegPerSec: 0 }, + steering: 0, + wheelRadians: 2, +}); + +describe("realtime spatial protocol", () => { + it("validates all hierarchical interest cell kinds", () => { + const cells: InterestCell[] = [ + { kind: "california-tile", x: -4, y: 18, level: 6 }, + { kind: "city", cityId: "bay-area" }, + { kind: "office", officeId: "lumbridge-hq" }, + { kind: "floor", officeId: "lumbridge-hq", floorId: "level-1" }, + { + kind: "room", + officeId: "lumbridge-hq", + floorId: "level-1", + roomId: "commons", + }, + ]; + assert.ok(cells.every(isInterestCell)); + assert.equal(isInterestCell({ kind: "city", cityId: "new-york" }), false); + assert.equal(isInterestCell({ kind: "california-tile", x: 0.5, y: 2, level: 4 }), false); + }); + + it("bounds sequence and timestamps before accepting an envelope", () => { + assert.equal(isRealtimeSequence(0), true); + assert.equal(isRealtimeSequence(4_294_967_295), true); + assert.equal(isRealtimeSequence(4_294_967_296), false); + assert.equal(isRealtimeTimestamp(1_765_000_000_000), true); + assert.equal(isRealtimeTimestamp(Number.MAX_SAFE_INTEGER), false); + }); + + it("accepts strict join/resume grants and membership revocation", () => { + const joinRequest = { + type: "join-request", + protocolVersion: 1, + requestId: "request-1", + actorId: "actor-karti", + resumeToken: null, + lastReceivedSequence: null, + interests: [{ kind: "city", cityId: "bay-area" }], + }; + assert.equal(validateClientRealtimeMessage(joinRequest).ok, true); + + const grant = { + type: "join-grant", + protocolVersion: 1, + requestId: "request-1", + sessionId: "session-1", + actorId: "actor-karti", + role: "member", + serverEpoch: "epoch-a", + serverTimeMs: 1_765_000_000_000, + nextSequence: 4, + resumeToken: "rotating-secret", + interests: joinRequest.interests, + initial: [localActor(3, 1_765_000_000_000, 0)], + }; + assert.equal(validateServerRealtimeMessage(grant).ok, true); + assert.equal(validateClientRealtimeMessage({ + type: "resume-request", + protocolVersion: 1, + requestId: "request-2", + sessionId: "session-1", + resumeToken: "rotating-secret", + serverEpoch: "epoch-a", + lastReceivedSequence: 3, + interests: joinRequest.interests, + }).ok, true); + assert.equal(validateServerRealtimeMessage({ + type: "membership-revoked", + protocolVersion: 1, + serverEpoch: "epoch-a", + sequence: 5, + timestampMs: 1_765_000_000_010, + sessionId: "session-1", + actorId: "actor-karti", + reason: "membership-revoked", + reconnectAllowed: false, + }).ok, true); + }); + + it("rejects malformed, oversized, and non-finite server input", () => { + const actor = localActor(1, 1000, 0); + assert.equal(isEntityPoseSnapshot(actor), true); + assert.equal(isEntityPoseSnapshot({ ...actor, pose: { ...actor.pose, xM: Number.NaN } }), false); + assert.equal(validateServerRealtimeMessage({ + type: "pose-delta", + protocolVersion: 1, + serverEpoch: "epoch-a", + sequence: 2, + timestampMs: 1000, + updates: [actor], + removedEntityIds: Array.from({ length: 2_049 }, (_, index) => `actor-${index}`), + }).ok, false); + assert.equal(validateServerRealtimeMessage({ type: "pose-delta", protocolVersion: 2 }).ok, false); + }); +}); + +describe("authoritative motion validation", () => { + const limits = { + maximumHorizontalSpeedMps: 15, + maximumVerticalSpeedMps: 8, + maximumTurnRateDegPerSec: 180, + positionSlackM: 0.25, + }; + + it("accepts plausible actor movement and reports measured rates", () => { + const result = validateMotion(localActor(1, 1_000, 0), localActor(2, 2_000, 10), limits); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.horizontalSpeedMps, 9.75); + assert.equal(result.verticalSpeedMps, 0); + assert.equal(result.turnRateDegPerSec, 0); + } + }); + + it("rejects speed hacks, stale sequences, identity and interest-cell jumps", () => { + assert.deepEqual(validateMotion(localActor(1, 1_000, 0), localActor(2, 1_100, 50), limits), { + ok: false, + reason: "speed-exceeded", + }); + assert.deepEqual(validateMotion(localActor(2, 2_000, 0), localActor(2, 3_000, 1), limits), { + ok: false, + reason: "time-invalid", + }); + const another = { ...localActor(2, 2_000, 1), actorId: "actor-other" }; + assert.deepEqual(validateMotion(localActor(1, 1_000, 0), another, limits), { + ok: false, + reason: "identity-mismatch", + }); + const anotherFloor = localActor(2, 2_000, 1); + if (anotherFloor.pose.space === "local" && anotherFloor.pose.cell.kind === "floor") { + anotherFloor.pose.cell.floorId = "level-2"; + } + assert.deepEqual(validateMotion(localActor(1, 1_000, 0), anotherFloor, limits), { + ok: false, + reason: "space-mismatch", + }); + }); + + it("uses real geographic distance for statewide vehicle checks", () => { + const start = geographicVehicle(10, 10_000, 34); + const plausible = geographicVehicle(11, 11_000, 34.0002); + const result = validateMotion(start, plausible, { + maximumHorizontalSpeedMps: 40, + maximumVerticalSpeedMps: 5, + maximumTurnRateDegPerSec: 90, + positionSlackM: 1, + }); + assert.equal(result.ok, true); + }); +}); + +describe("pose interpolation buffer", () => { + it("interpolates deterministically at delayed server time", () => { + const a = new PoseInterpolationBuffer({ interpolationDelayMs: 100 }); + const b = new PoseInterpolationBuffer({ interpolationDelayMs: 100 }); + for (const buffer of [a, b]) { + assert.equal(buffer.push(localActor(1, 1_000, 0, 350)), true); + assert.equal(buffer.push(localActor(2, 1_200, 2, 10)), true); + } + const first = a.sample(1_200); + const second = b.sample(1_200); + assert.deepEqual(first, second); + assert.equal(first?.mode, "interpolated"); + assert.equal(first?.timestampMs, 1_100); + assert.ok(first?.pose.space === "local"); + if (first?.pose.space === "local") { + assert.equal(first.pose.xM, 1); + assert.equal(first.pose.headingDeg, 360, "heading follows the short arc through north"); + } + }); + + it("caps extrapolation and then holds the capped predicted pose", () => { + const buffer = new PoseInterpolationBuffer({ + interpolationDelayMs: 0, + maximumExtrapolationMs: 150, + }); + buffer.push(localActor(1, 1_000, 0)); + const predicted = buffer.sample(1_100); + assert.equal(predicted?.mode, "extrapolated"); + assert.ok(predicted?.pose.space === "local"); + if (predicted?.pose.space === "local") assert.equal(predicted.pose.xM, 1); + const capped = buffer.sample(10_000); + assert.equal(capped?.mode, "held"); + assert.equal(capped?.timestampMs, 1_150); + assert.ok(capped?.pose.space === "local"); + if (capped?.pose.space === "local") assert.equal(capped.pose.xM, 1.5); + }); + + it("rejects stale, duplicate, or mixed-entity input and caps capacity", () => { + const buffer = new PoseInterpolationBuffer({ capacity: 2 }); + assert.equal(buffer.push(localActor(1, 1_000, 0)), true); + assert.equal(buffer.push(localActor(1, 1_100, 1)), false); + assert.equal(buffer.push(localActor(2, 1_200, 2)), true); + assert.equal(buffer.push(localActor(3, 1_300, 3)), true); + assert.equal(buffer.size(), 2); + assert.equal(buffer.push({ ...localActor(4, 1_400, 4), actorId: "other" }), false); + buffer.clear(); + assert.equal(buffer.size(), 0); + assert.equal(buffer.sample(1_500), null); + }); +}); diff --git a/src/test/sceneActor.test.ts b/src/test/sceneActor.test.ts index 2c00ebc..127d077 100644 --- a/src/test/sceneActor.test.ts +++ b/src/test/sceneActor.test.ts @@ -74,6 +74,20 @@ describe("playable city scene actor", () => { actor.dispose(); }); + it("can exaggerate presentation scale without changing metre-space motion", () => { + const actor = createSceneActor(options({ + sceneUnitsPerMetre: 0.001, + visualSceneUnitsPerMetre: 0.025, + active: true, + })); + assert.equal(actor.root.scale.x, 0.025); + actor.setActions({ forward: 1 }); + actor.tick(0.2); + assert.ok(actor.state().z < 3); + assert.ok(Math.abs(actor.root.position.z - actor.state().z * 0.001) < 1e-12); + actor.dispose(); + }); + it("keeps its stable root and identity while replacing procedural rigs", () => { const actor = createSceneActor(options({ active: true })); const root = actor.root; diff --git a/src/test/walker.test.ts b/src/test/walker.test.ts index 313e4e4..f63c356 100644 --- a/src/test/walker.test.ts +++ b/src/test/walker.test.ts @@ -69,6 +69,15 @@ describe("walker input and clock", () => { leaked.position.x = Number.NaN; assert.deepEqual(controller.state().position, { x: 2, z: 2 }); }); + + it("uses and restores a normalized authored arrival facing", () => { + const controller = walker(planWith(), { facing: { x: 3, z: 4 } }); + assert.deepEqual(controller.state().facing, { x: 0.6, z: 0.8 }); + controller.tick(0.1, { x: -1, z: 0 }); + assert.deepEqual(controller.state().facing, { x: -1, z: 0 }); + controller.reset(); + assert.deepEqual(controller.state().facing, { x: 0.6, z: 0.8 }); + }); }); describe("walker collision", () => {