feat: add private profile media and realtime contracts
This commit is contained in:
@@ -972,6 +972,8 @@
|
|||||||
<button class="walk-control" data-walk-key="w" aria-pressed="false">Forward</button>
|
<button class="walk-control" data-walk-key="w" aria-pressed="false">Forward</button>
|
||||||
<button class="walk-control" data-walk-key="s" aria-pressed="false">Back</button>
|
<button class="walk-control" data-walk-key="s" aria-pressed="false">Back</button>
|
||||||
<button class="walk-control" data-walk-key="d" aria-pressed="false">Right</button>
|
<button class="walk-control" data-walk-key="d" aria-pressed="false">Right</button>
|
||||||
|
<button class="walk-control flight-only" data-walk-key="q" aria-pressed="false">Descend</button>
|
||||||
|
<button class="walk-control flight-only" data-walk-key="e" aria-pressed="false">Climb</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="source" class="source"></p>
|
<p id="source" class="source"></p>
|
||||||
@@ -989,6 +991,7 @@
|
|||||||
<dt><kbd>1</kbd>–<kbd>9</kbd></dt><dd>Fly to a chapter, or an office viewpoint</dd>
|
<dt><kbd>1</kbd>–<kbd>9</kbd></dt><dd>Fly to a chapter, or an office viewpoint</dd>
|
||||||
<dt><kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd></dt><dd>Drive a route, or move while walking inside</dd>
|
<dt><kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd></dt><dd>Drive a route, or move while walking inside</dd>
|
||||||
<dt><kbd>V</kbd></dt><dd>Walk through an office / return to the dollhouse view</dd>
|
<dt><kbd>V</kbd></dt><dd>Walk through an office / return to the dollhouse view</dd>
|
||||||
|
<dt><kbd>Q</kbd> / <kbd>E</kbd></dt><dd>Descend / climb while flying as a crow</dd>
|
||||||
<dt><kbd>Space</kbd></dt><dd>Handbrake while driving</dd>
|
<dt><kbd>Space</kbd></dt><dd>Handbrake while driving</dd>
|
||||||
<dt><kbd>P</kbd> / <kbd>R</kbd></dt><dd>Resume assisted drive / reset the car</dd>
|
<dt><kbd>P</kbd> / <kbd>R</kbd></dt><dd>Resume assisted drive / reset the car</dd>
|
||||||
<dt><kbd>C</kbd></dt><dd>Switch chase / driver-height camera</dd>
|
<dt><kbd>C</kbd></dt><dd>Switch chase / driver-height camera</dd>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface ActorProfile {
|
|||||||
skinTone?: string;
|
skinTone?: string;
|
||||||
primaryColor?: string;
|
primaryColor?: string;
|
||||||
accentColor?: 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.skinTone === undefined ? {} : { skinTone: profile.appearance.skinTone }),
|
||||||
...(profile.appearance.primaryColor === undefined ? {} : { primaryColor: profile.appearance.primaryColor }),
|
...(profile.appearance.primaryColor === undefined ? {} : { primaryColor: profile.appearance.primaryColor }),
|
||||||
...(profile.appearance.accentColor === undefined ? {} : { accentColor: profile.appearance.accentColor }),
|
...(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;
|
: undefined;
|
||||||
return {
|
return {
|
||||||
@@ -227,10 +231,17 @@ function checkedIdentity(identity: ActorIdentity): Readonly<ActorIdentity> {
|
|||||||
profile.appearance?.skinTone,
|
profile.appearance?.skinTone,
|
||||||
profile.appearance?.primaryColor,
|
profile.appearance?.primaryColor,
|
||||||
profile.appearance?.accentColor,
|
profile.appearance?.accentColor,
|
||||||
|
profile.appearance?.hairColor,
|
||||||
];
|
];
|
||||||
if (strings.some((item) => item !== undefined && typeof item !== "string")) {
|
if (strings.some((item) => item !== undefined && typeof item !== "string")) {
|
||||||
throw new RangeError("actor profile fields must be strings");
|
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 = {
|
const copy: ActorIdentity = {
|
||||||
id: identity.id,
|
id: identity.id,
|
||||||
displayName: identity.displayName,
|
displayName: identity.displayName,
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ function buildActor(kind: ActorKind, identity: Readonly<ActorIdentity>): RiggedA
|
|||||||
...(color(appearance?.skinTone) === undefined ? {} : { skinTone: color(appearance?.skinTone) }),
|
...(color(appearance?.skinTone) === undefined ? {} : { skinTone: color(appearance?.skinTone) }),
|
||||||
...(color(appearance?.primaryColor) === undefined ? {} : { outfitColor: color(appearance?.primaryColor) }),
|
...(color(appearance?.primaryColor) === undefined ? {} : { outfitColor: color(appearance?.primaryColor) }),
|
||||||
...(color(appearance?.accentColor) === undefined ? {} : { accentColor: color(appearance?.accentColor) }),
|
...(color(appearance?.accentColor) === undefined ? {} : { accentColor: color(appearance?.accentColor) }),
|
||||||
|
...(color(appearance?.hairColor) === undefined ? {} : { hairColor: color(appearance?.hairColor) }),
|
||||||
|
...(appearance?.bodyShape === undefined ? {} : { bodyShape: appearance.bodyShape }),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-8
@@ -69,6 +69,14 @@ import {
|
|||||||
type JourneyEvent,
|
type JourneyEvent,
|
||||||
type JourneyState,
|
type JourneyState,
|
||||||
} from "./journey/index.ts";
|
} 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
|
* Three type-only imports and not one value among them, which is what keeps the
|
||||||
* office and the instruments out of the entry chunk.
|
* office and the instruments out of the entry chunk.
|
||||||
@@ -118,6 +126,12 @@ let journey: JourneyState = restoredJourney.status === "loaded"
|
|||||||
profile: { displayName: "Guest" },
|
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 {
|
function dispatchJourney(event: JourneyEvent): boolean {
|
||||||
const next = journeyReducer(journey, event);
|
const next = journeyReducer(journey, event);
|
||||||
@@ -801,7 +815,7 @@ async function mountCity(id: string) {
|
|||||||
const handle = await createScene(stage, {
|
const handle = await createScene(stage, {
|
||||||
city: entry.city,
|
city: entry.city,
|
||||||
actor: {
|
actor: {
|
||||||
kind: access.subject === null ? "crow" : "humanoid",
|
kind: actorKindForPresence(access.subject !== null, "outdoors"),
|
||||||
identity: {
|
identity: {
|
||||||
id: access.subject ?? "anonymous",
|
id: access.subject ?? "anonymous",
|
||||||
displayName: access.subject ?? "Guest",
|
displayName: access.subject ?? "Guest",
|
||||||
@@ -809,11 +823,22 @@ async function mountCity(id: string) {
|
|||||||
profile: {
|
profile: {
|
||||||
appearance: access.subject === null
|
appearance: access.subject === null
|
||||||
? { primaryColor: "#11151a", accentColor: "#f2b134" }
|
? { 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",
|
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,
|
minFlightAltitude: 20,
|
||||||
maxFlightAltitude: 1_500,
|
maxFlightAltitude: 1_500,
|
||||||
...(access.subject === null
|
...(access.subject === null
|
||||||
@@ -1010,6 +1035,7 @@ async function mountCity(id: string) {
|
|||||||
*/
|
*/
|
||||||
requestAnimationFrame(function pumpMinimap() {
|
requestAnimationFrame(function pumpMinimap() {
|
||||||
requestAnimationFrame(pumpMinimap);
|
requestAnimationFrame(pumpMinimap);
|
||||||
|
syncJourneyVehicle(performance.now());
|
||||||
// Only the mounted one. The other is still constructed and still holds a live
|
// 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
|
// 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
|
// `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.
|
* subtraction on the frames it skips.
|
||||||
*/
|
*/
|
||||||
let livenessCheckedAt = 0;
|
let livenessCheckedAt = 0;
|
||||||
|
let journeySyncedAt = 0;
|
||||||
|
let previousVehicleProgress: number | null = null;
|
||||||
function pollLiveness() {
|
function pollLiveness() {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (now - livenessCheckedAt < 1000) return;
|
if (now - livenessCheckedAt < 1000) return;
|
||||||
@@ -1041,6 +1069,32 @@ function pollLiveness() {
|
|||||||
renderSource();
|
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 ---------------------------------------------------------------
|
// ---- Office ---------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1108,9 +1162,9 @@ async function enterOffice() {
|
|||||||
z: -Math.cos(pack.viewpoints[0].focus.rotation),
|
z: -Math.cos(pack.viewpoints[0].focus.rotation),
|
||||||
}
|
}
|
||||||
: { x: 0, z: -1 },
|
: { x: 0, z: -1 },
|
||||||
actor: access.subject === null
|
actor: actorKindForPresence(access.subject !== null, "office") === "dog"
|
||||||
? { kind: "anonymous-dog", coatColor: 0x17191c, collarColor: 0xf2b134 }
|
? { 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
|
// 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
|
// gradient and a horizon; painting the old flat colour over that is the
|
||||||
@@ -1593,6 +1647,9 @@ function renderLegend() {
|
|||||||
renderOfficeBadge();
|
renderOfficeBadge();
|
||||||
if (driveControls) driveControls.hidden = !routeDriveIsActive();
|
if (driveControls) driveControls.hidden = !routeDriveIsActive();
|
||||||
if (walkControls) walkControls.hidden = !(walking || exploring);
|
if (walkControls) walkControls.hidden = !(walking || exploring);
|
||||||
|
for (const control of walkControls?.querySelectorAll<HTMLElement>(".flight-only") ?? []) {
|
||||||
|
control.hidden = inside;
|
||||||
|
}
|
||||||
if (driveHint) driveHint.hidden = inside || cityId !== "california";
|
if (driveHint) driveHint.hidden = inside || cityId !== "california";
|
||||||
if (walkHint) walkHint.hidden = !(inside || city.actorState());
|
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),
|
right: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
|
||||||
turn: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
|
turn: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
|
||||||
sprint: heldDriveKeys.has(" "),
|
sprint: heldDriveKeys.has(" "),
|
||||||
climb: heldDriveKeys.has(" ") ? 1 : 0,
|
climb: (heldDriveKeys.has("e") || heldDriveKeys.has(" ") ? 1 : 0) -
|
||||||
|
(heldDriveKeys.has("q") ? 1 : 0),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2232,7 +2290,10 @@ window.addEventListener("keydown", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lower = event.key.toLowerCase();
|
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);
|
heldDriveKeys.add(event.key === " " ? " " : lower);
|
||||||
if (publishVehicleActions()) {
|
if (publishVehicleActions()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -2698,6 +2759,17 @@ async function boot() {
|
|||||||
|
|
||||||
if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
|
if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
|
||||||
access = await resolveAccess();
|
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({
|
dispatchJourney({
|
||||||
type: "sign-in-actor-swap",
|
type: "sign-in-actor-swap",
|
||||||
actor: access.subject === null
|
actor: access.subject === null
|
||||||
@@ -2711,7 +2783,10 @@ async function boot() {
|
|||||||
id: access.subject,
|
id: access.subject,
|
||||||
kind: "humanoid",
|
kind: "humanoid",
|
||||||
signedIn: true,
|
signedIn: true,
|
||||||
profile: { displayName: access.subject },
|
profile: {
|
||||||
|
displayName: localProfile?.displayName ?? access.subject,
|
||||||
|
color: humanoidAppearance()?.accentColor,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
applyTimeControl();
|
applyTimeControl();
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|
||||||
@@ -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<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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<Record<SkinToneChoice, string>> = Object.freeze({
|
||||||
|
espresso: "#4b2d25",
|
||||||
|
umber: "#704534",
|
||||||
|
sienna: "#9a6248",
|
||||||
|
sand: "#c58d68",
|
||||||
|
peach: "#e6b28e",
|
||||||
|
});
|
||||||
|
|
||||||
|
const OUTFIT_COLORS: Readonly<Record<OutfitChoice, string>> = Object.freeze({
|
||||||
|
ink: "#18222d",
|
||||||
|
navy: "#1d3b58",
|
||||||
|
slate: "#53616d",
|
||||||
|
sage: "#566c5d",
|
||||||
|
clay: "#80594a",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ACCENT_COLORS: Readonly<Record<AccentChoice, string>> = Object.freeze({
|
||||||
|
aqua: "#55b8c8",
|
||||||
|
amber: "#d99b39",
|
||||||
|
coral: "#db6d62",
|
||||||
|
violet: "#826faf",
|
||||||
|
lime: "#86a94a",
|
||||||
|
});
|
||||||
|
|
||||||
|
const HAIR_VALUES: Readonly<Record<HairColorChoice, string>> = Object.freeze({
|
||||||
|
black: "#171719",
|
||||||
|
espresso: "#2d211d",
|
||||||
|
auburn: "#65372b",
|
||||||
|
silver: "#8b9298",
|
||||||
|
platinum: "#d0c5ac",
|
||||||
|
});
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactKeys(value: Record<string, unknown>, 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<T extends string>(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<T>(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";
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> {
|
||||||
|
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<string, unknown>): boolean {
|
||||||
|
return value.protocolVersion === 1 && string(value.requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateClientRealtimeMessage(
|
||||||
|
value: unknown,
|
||||||
|
): RealtimeValidationResult<ClientRealtimeMessage> {
|
||||||
|
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<ServerRealtimeMessage> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<T> =
|
||||||
|
| { 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;
|
||||||
|
}
|
||||||
@@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>();
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,6 +74,20 @@ describe("playable city scene actor", () => {
|
|||||||
actor.dispose();
|
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", () => {
|
it("keeps its stable root and identity while replacing procedural rigs", () => {
|
||||||
const actor = createSceneActor(options({ active: true }));
|
const actor = createSceneActor(options({ active: true }));
|
||||||
const root = actor.root;
|
const root = actor.root;
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ describe("walker input and clock", () => {
|
|||||||
leaked.position.x = Number.NaN;
|
leaked.position.x = Number.NaN;
|
||||||
assert.deepEqual(controller.state().position, { x: 2, z: 2 });
|
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", () => {
|
describe("walker collision", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user