feat: add private profile media and realtime contracts
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user