feat: complete actor handoff and piloted aircraft presence
This commit is contained in:
@@ -37,6 +37,7 @@ export interface RealtimeServiceOptions {
|
||||
rateWindowMs?: number;
|
||||
actorMotionLimits?: MotionLimits;
|
||||
vehicleMotionLimits?: MotionLimits;
|
||||
aircraftMotionLimits?: MotionLimits;
|
||||
}
|
||||
|
||||
export interface RealtimeJoinInput {
|
||||
@@ -103,6 +104,7 @@ interface ResolvedOptions {
|
||||
rateWindowMs: number;
|
||||
actorMotionLimits: MotionLimits;
|
||||
vehicleMotionLimits: MotionLimits;
|
||||
aircraftMotionLimits: MotionLimits;
|
||||
}
|
||||
|
||||
export interface RealtimeService {
|
||||
@@ -176,6 +178,13 @@ function resolveOptions(value: RealtimeServiceOptions): ResolvedOptions {
|
||||
positionSlackM: 4,
|
||||
maximumDeltaSeconds: 2,
|
||||
},
|
||||
aircraftMotionLimits: value.aircraftMotionLimits ?? {
|
||||
maximumHorizontalSpeedMps: 125,
|
||||
maximumVerticalSpeedMps: 45,
|
||||
maximumTurnRateDegPerSec: 180,
|
||||
positionSlackM: 6,
|
||||
maximumDeltaSeconds: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,7 +217,9 @@ function clonePose(pose: EntityPoseSnapshot): EntityPoseSnapshot {
|
||||
}
|
||||
|
||||
function entityKey(pose: EntityPoseSnapshot): string {
|
||||
return pose.entity === "actor" ? `actor:${pose.actorId}` : `vehicle:${pose.vehicleId}`;
|
||||
if (pose.entity === "actor") return `actor:${pose.actorId}`;
|
||||
if (pose.entity === "vehicle") return `vehicle:${pose.vehicleId}`;
|
||||
return `aircraft:${pose.aircraftId}`;
|
||||
}
|
||||
|
||||
function poseInterestKey(pose: EntityPoseSnapshot): string | null {
|
||||
@@ -569,8 +580,16 @@ export function createRealtimeService(options: RealtimeServiceOptions = {}): Rea
|
||||
}
|
||||
if (
|
||||
(snapshot.entity === "actor" && snapshot.actorId !== session.actorId) ||
|
||||
(snapshot.entity === "vehicle" && snapshot.driverActorId !== session.actorId)
|
||||
(snapshot.entity === "vehicle" && snapshot.driverActorId !== session.actorId) ||
|
||||
(snapshot.entity === "aircraft" && snapshot.pilotActorId !== session.actorId)
|
||||
) return { ok: false, code: "ownership", message: "Realtime entity is not owned by this session." };
|
||||
if (snapshot.entity === "aircraft" && snapshot.pose.space !== "geographic") {
|
||||
return {
|
||||
ok: false,
|
||||
code: "interest",
|
||||
message: "Aircraft poses require an outdoor geographic interest.",
|
||||
};
|
||||
}
|
||||
const localKey = poseInterestKey(snapshot);
|
||||
if (localKey !== null && !session.interestKeys.has(localKey)) {
|
||||
return { ok: false, code: "interest", message: "Realtime pose is outside the joined interest." };
|
||||
@@ -585,10 +604,15 @@ export function createRealtimeService(options: RealtimeServiceOptions = {}): Rea
|
||||
const key = entityKey(snapshot);
|
||||
const previous = session.proposedPoses.get(key);
|
||||
if (previous) {
|
||||
const motionLimits = snapshot.entity === "vehicle"
|
||||
? settings.vehicleMotionLimits
|
||||
: snapshot.entity === "aircraft"
|
||||
? settings.aircraftMotionLimits
|
||||
: settings.actorMotionLimits;
|
||||
const motion = validateMotion(
|
||||
previous,
|
||||
snapshot,
|
||||
snapshot.entity === "vehicle" ? settings.vehicleMotionLimits : settings.actorMotionLimits,
|
||||
motionLimits,
|
||||
);
|
||||
if (!motion.ok) {
|
||||
return { ok: false, code: "motion", message: `Realtime motion rejected: ${motion.reason}.` };
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createHmac } from "node:crypto";
|
||||
import { after, describe, it } from "node:test";
|
||||
import type {
|
||||
ActorPoseSnapshot,
|
||||
AircraftPoseSnapshot,
|
||||
FloorInterest,
|
||||
InterestCell,
|
||||
PoseDelta,
|
||||
@@ -102,6 +103,37 @@ function vehiclePose(
|
||||
};
|
||||
}
|
||||
|
||||
function aircraftPose(
|
||||
pilotActorId: string,
|
||||
sequence: number,
|
||||
timestampMs: number,
|
||||
lat = 37.7,
|
||||
): AircraftPoseSnapshot {
|
||||
return {
|
||||
entity: "aircraft",
|
||||
aircraftId: `aircraft-${pilotActorId}`,
|
||||
kind: "electric-vtail",
|
||||
pilotActorId,
|
||||
sequence,
|
||||
timestampMs,
|
||||
pose: {
|
||||
space: "geographic",
|
||||
lat,
|
||||
lng: -122.4,
|
||||
altitudeM: 1_500,
|
||||
headingDeg: 320,
|
||||
pitchDeg: 2,
|
||||
},
|
||||
velocity: { xMps: -40, yMps: 2, zMps: 48, yawDegPerSec: 2 },
|
||||
rollDeg: -12,
|
||||
throttle: 0.7,
|
||||
rollInput: -0.25,
|
||||
pitchInput: 0.1,
|
||||
yawInput: 0,
|
||||
fanRadians: 3,
|
||||
};
|
||||
}
|
||||
|
||||
function joined(
|
||||
service: ReturnType<typeof createRealtimeService>,
|
||||
actorId: string,
|
||||
@@ -211,6 +243,63 @@ describe("authoritative realtime service", () => {
|
||||
assert.equal(socalMessages.length, 0);
|
||||
});
|
||||
|
||||
it("converges two clients on an owned aircraft and rejects authority, space, and speed attacks", () => {
|
||||
let now = 16_000;
|
||||
const service = createRealtimeService({ now: () => now });
|
||||
const bay = { kind: "city" as const, cityId: "bay-area" as const };
|
||||
const pilot = joined(service, "opaque-pilot", [bay], "private-pilot-subject");
|
||||
const observer = joined(service, "opaque-observer", [bay], "private-observer-subject");
|
||||
const received: PoseDelta[] = [];
|
||||
service.connect(observer.sessionId, observer.token, (message) => {
|
||||
if (message.type === "pose-delta") received.push(message);
|
||||
});
|
||||
|
||||
const first = service.submitPose(
|
||||
pilot.sessionId,
|
||||
pilot.token,
|
||||
aircraftPose("opaque-pilot", 1, now),
|
||||
);
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(received[0]?.updates[0]?.entity, "aircraft");
|
||||
assert.equal(JSON.stringify(received[0]).includes("private-pilot-subject"), false);
|
||||
|
||||
const stolen = service.submitPose(
|
||||
observer.sessionId,
|
||||
observer.token,
|
||||
aircraftPose("opaque-pilot", 1, now),
|
||||
);
|
||||
assert.equal(stolen.ok, false);
|
||||
if (!stolen.ok) assert.equal(stolen.code, "ownership");
|
||||
|
||||
const local = aircraftPose("opaque-pilot", 2, now);
|
||||
const invalidSpace = service.submitPose(pilot.sessionId, pilot.token, {
|
||||
...local,
|
||||
pose: { space: "local", cell: bay, xM: 0, yM: 0, zM: 0, headingDeg: 0, pitchDeg: 0 },
|
||||
});
|
||||
assert.equal(invalidSpace.ok, false);
|
||||
if (!invalidSpace.ok) assert.equal(invalidSpace.code, "interest");
|
||||
|
||||
now += 1_000;
|
||||
assert.equal(service.submitPose(
|
||||
pilot.sessionId,
|
||||
pilot.token,
|
||||
aircraftPose("opaque-pilot", 2, now, 37.7005),
|
||||
).ok, true);
|
||||
now += 100;
|
||||
const speedHack = service.submitPose(
|
||||
pilot.sessionId,
|
||||
pilot.token,
|
||||
aircraftPose("opaque-pilot", 3, now, 38.7),
|
||||
);
|
||||
assert.equal(speedHack.ok, false);
|
||||
if (!speedHack.ok) assert.equal(speedHack.code, "motion");
|
||||
|
||||
const lateObserver = joined(service, "opaque-late-observer", [bay]);
|
||||
assert.equal(lateObserver.grant.initial.length, 1);
|
||||
assert.equal(lateObserver.grant.initial[0]?.entity, "aircraft");
|
||||
});
|
||||
|
||||
it("removes the previous entity when one session switches actor to vehicle", () => {
|
||||
let now = 17_000;
|
||||
const service = createRealtimeService({ now: () => now });
|
||||
|
||||
Reference in New Issue
Block a user