feat: add authenticated realtime presence
This commit is contained in:
+281
@@ -114,6 +114,13 @@ import type { MaterialRegistry } from "./assets/materials.ts";
|
||||
import type { OfficeMinimap } from "./engine/officeMinimap.ts";
|
||||
import type { Godmode, GodmodeHouseLights, GodmodePlace } from "./tools/index.ts";
|
||||
import type { PoseEditor } from "./tools/poseEditor.ts";
|
||||
import type {
|
||||
EntityPoseSnapshot,
|
||||
InterestCell,
|
||||
RealtimeClient,
|
||||
ServerRealtimeMessage,
|
||||
} from "./realtime/index.ts";
|
||||
import type { PresenceIndicator } from "./realtime/presenceIndicator.ts";
|
||||
|
||||
const CITIES: { id: string; label: string; city: City }[] = [
|
||||
{ id: "california", label: "California", city: CALIFORNIA },
|
||||
@@ -247,6 +254,15 @@ let wantedCity = "california";
|
||||
let office: OfficeScene | null = null;
|
||||
let inside = false;
|
||||
let markers: Marker[] = SAMPLE_MARKERS;
|
||||
let realtimeClient: RealtimeClient | null = null;
|
||||
let presenceIndicator: PresenceIndicator | null = null;
|
||||
let stopRealtimeSubscription: (() => void) | null = null;
|
||||
let realtimeOperation = 0;
|
||||
let lastRealtimePublishAt = 0;
|
||||
let realtimePageActive = true;
|
||||
/** Page-scoped wire identities; auth subject never enters the spatial protocol. */
|
||||
const realtimeActorId = crypto.randomUUID();
|
||||
const realtimeVehicleId = crypto.randomUUID();
|
||||
|
||||
/**
|
||||
* The buildings you can walk into, as procedural glyphs on the city.
|
||||
@@ -921,6 +937,9 @@ async function mountCity(id: string) {
|
||||
onProgress: (p) => {
|
||||
if (!mount.signal.aborted) bootProgress(entry.label, p.fraction, p.onMainThread);
|
||||
},
|
||||
...(access.subject !== null
|
||||
? { realtimePeers: { localSceneUnitsPerMetre: entry.city.latScale / 111_320 } }
|
||||
: {}),
|
||||
});
|
||||
if (!handle) {
|
||||
/**
|
||||
@@ -934,6 +953,7 @@ async function mountCity(id: string) {
|
||||
return;
|
||||
}
|
||||
city = handle;
|
||||
moveRealtimePresence();
|
||||
// A new board builds a new layer, and a new layer starts visible. Reapply
|
||||
// whatever the panel last said, or the setting silently undoes itself on the
|
||||
// first city switch.
|
||||
@@ -1086,6 +1106,7 @@ requestAnimationFrame(function pumpMinimap() {
|
||||
// everyone who is not god, so this is one property read per frame on a
|
||||
// public page.
|
||||
poseEditor?.tick();
|
||||
publishRealtimePresence(performance.now());
|
||||
pollLiveness();
|
||||
});
|
||||
|
||||
@@ -1231,12 +1252,14 @@ async function enterOffice() {
|
||||
...(depth === "full"
|
||||
? { onPresencePick: (p) => showDetail(p ? p.label : null) }
|
||||
: { onPlacePick: (place) => showDetail(place ? place.label : null) }),
|
||||
...(access.subject !== null ? { realtimePeers: {} } : {}),
|
||||
});
|
||||
office.onViewChange(() => renderLegend());
|
||||
officePlan = buildOfficePlan(createOfficeMinimap, office);
|
||||
}
|
||||
city.stage.setScene(office);
|
||||
inside = true;
|
||||
moveRealtimePresence();
|
||||
const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area";
|
||||
journeyToCity(desiredCity);
|
||||
dispatchJourney({ type: "enter-office", officeId: officeId as "lumbridge-hq" | "frontier-valley" | "mateo-court" });
|
||||
@@ -1362,6 +1385,7 @@ function leaveOffice() {
|
||||
dispatchJourney({ type: "leave-office" });
|
||||
city.stage.setScene(city.stageScene);
|
||||
inside = false;
|
||||
moveRealtimePresence();
|
||||
showPlan();
|
||||
showDetail(null);
|
||||
refreshGodmodePlace();
|
||||
@@ -1512,6 +1536,7 @@ const source = document.querySelector<HTMLElement>("#source");
|
||||
const minimapFrame = document.querySelector<HTMLElement>("#minimap .minimap-frame");
|
||||
const minimapReadout = document.querySelector<HTMLElement>("#minimap-readout");
|
||||
const tierBadge = document.querySelector<HTMLElement>("#tier");
|
||||
const presenceHost = document.querySelector<HTMLElement>("#presence-host");
|
||||
const officeBadge = document.querySelector<HTMLElement>("#office-badge");
|
||||
const panelToggle = document.querySelector<HTMLButtonElement>("#panel-toggle");
|
||||
const panelToggleLabel = document.querySelector<HTMLElement>("#panel-toggle-label");
|
||||
@@ -1927,6 +1952,256 @@ function renderTierBadge() {
|
||||
tierBadge.hidden = false;
|
||||
}
|
||||
|
||||
// ---- Hosted realtime presence --------------------------------------------
|
||||
|
||||
function realtimeInterest(): InterestCell {
|
||||
if (inside) return { kind: "office", officeId };
|
||||
if (cityId === "california") return { kind: "california-tile", x: 0, y: 0, level: 0 };
|
||||
return { kind: "city", cityId: cityId === "socal" ? "socal" : "bay-area" };
|
||||
}
|
||||
|
||||
function degrees(radians: number): number {
|
||||
return ((radians * 180 / Math.PI + 540) % 360) - 180;
|
||||
}
|
||||
|
||||
function realtimePose(): EntityPoseSnapshot | null {
|
||||
if (access.subject === null) return null;
|
||||
const timestampMs = Date.now();
|
||||
|
||||
if (inside) {
|
||||
const walker = office?.walker;
|
||||
if (!walker) return null;
|
||||
const state = walker.state();
|
||||
const headingDeg = -degrees(Math.atan2(-state.facing.x, -state.facing.z));
|
||||
return {
|
||||
entity: "actor",
|
||||
actorId: realtimeActorId,
|
||||
kind: state.actor === "anonymous-dog" ? "dog" : "humanoid",
|
||||
sequence: 0,
|
||||
timestampMs,
|
||||
pose: {
|
||||
space: "local",
|
||||
cell: { kind: "office", officeId },
|
||||
xM: state.position.x,
|
||||
yM: office?.plan.level(state.levelId)?.floorY ?? 0,
|
||||
zM: state.position.z,
|
||||
headingDeg,
|
||||
pitchDeg: 0,
|
||||
},
|
||||
velocity: {
|
||||
xMps: state.action.x * 1.6,
|
||||
yMps: 0,
|
||||
zMps: state.action.z * 1.6,
|
||||
yawDegPerSec: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const vehicle = journey.vehicle !== null ? city?.vehicleState() : null;
|
||||
if (vehicle) {
|
||||
const heading = vehicle.headingDeg * Math.PI / 180;
|
||||
return {
|
||||
entity: "vehicle",
|
||||
vehicleId: realtimeVehicleId,
|
||||
kind: "model-x",
|
||||
driverActorId: realtimeActorId,
|
||||
sequence: 0,
|
||||
timestampMs,
|
||||
pose: {
|
||||
space: "geographic",
|
||||
lat: vehicle.lat,
|
||||
lng: vehicle.lng,
|
||||
altitudeM: 0,
|
||||
headingDeg: vehicle.headingDeg,
|
||||
pitchDeg: 0,
|
||||
},
|
||||
velocity: {
|
||||
xMps: Math.sin(heading) * vehicle.speedMps,
|
||||
yMps: 0,
|
||||
zMps: Math.cos(heading) * vehicle.speedMps,
|
||||
yawDegPerSec: 0,
|
||||
},
|
||||
steering: vehicle.steering,
|
||||
wheelRadians: vehicle.wheelRadians,
|
||||
};
|
||||
}
|
||||
|
||||
const actor = city?.actorState();
|
||||
if (!actor || !city) return null;
|
||||
const headingDeg = -degrees(actor.yaw);
|
||||
const velocity = {
|
||||
xMps: -Math.sin(actor.yaw) * actor.speedMps,
|
||||
yMps: actor.verticalSpeedMps,
|
||||
// Geographic velocity is east/up/north; scene +Z is south.
|
||||
zMps: Math.cos(actor.yaw) * actor.speedMps,
|
||||
yawDegPerSec: 0,
|
||||
};
|
||||
const origin = cityId === "california" ? { lat: 35.5, lng: -119.5 } : city.world.city.center;
|
||||
const [originX, originZ] = city.world.project(origin.lat, origin.lng);
|
||||
const [lat, lng] = city.world.unproject(
|
||||
originX + actor.x / city.world.metresPerUnit,
|
||||
originZ + actor.z / city.world.metresPerUnit,
|
||||
);
|
||||
return {
|
||||
entity: "actor",
|
||||
actorId: realtimeActorId,
|
||||
kind: actor.kind,
|
||||
sequence: 0,
|
||||
timestampMs,
|
||||
pose: {
|
||||
space: "geographic",
|
||||
lat,
|
||||
lng,
|
||||
altitudeM: actor.y,
|
||||
headingDeg,
|
||||
pitchDeg: degrees(actor.pitch),
|
||||
},
|
||||
velocity,
|
||||
};
|
||||
}
|
||||
|
||||
function ownRealtimeEntity(snapshot: EntityPoseSnapshot): boolean {
|
||||
if (snapshot.entity === "actor") return snapshot.actorId === realtimeClient?.state().actorId;
|
||||
return snapshot.driverActorId === realtimeClient?.state().actorId || snapshot.vehicleId === realtimeVehicleId;
|
||||
}
|
||||
|
||||
function clearRealtimePeers(): void {
|
||||
city?.clearRemoteEntities();
|
||||
office?.clearRemoteEntities();
|
||||
updatePresenceIndicator();
|
||||
}
|
||||
|
||||
function applyRealtimeMessage(message: ServerRealtimeMessage): void {
|
||||
if (message.type === "membership-revoked") {
|
||||
clearRealtimePeers();
|
||||
return;
|
||||
}
|
||||
const updates = message.type === "pose-delta"
|
||||
? message.updates
|
||||
: message.type === "join-grant"
|
||||
? message.initial
|
||||
: message.type === "resume-grant"
|
||||
? message.snapshot
|
||||
: [];
|
||||
const target = inside ? office : city;
|
||||
for (const snapshot of updates) {
|
||||
if (!ownRealtimeEntity(snapshot)) target?.upsertRemoteSnapshot(snapshot);
|
||||
}
|
||||
if (message.type === "pose-delta") {
|
||||
for (const id of message.removedEntityIds) {
|
||||
if (id !== `actor:${realtimeClient?.state().actorId}` && id !== `vehicle:${realtimeVehicleId}`) {
|
||||
target?.removeRemoteEntity(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
updatePresenceIndicator();
|
||||
}
|
||||
|
||||
function updatePresenceIndicator(state = realtimeClient?.state()): void {
|
||||
if (!presenceIndicator || access.subject === null) return;
|
||||
const connection: "connecting" | "live" | "reconnecting" | "offline" =
|
||||
state?.status === "streaming" ? "live"
|
||||
: state?.status === "reconnecting" ? "reconnecting"
|
||||
: state?.status === "left" || state?.status === "disposed" ? "offline"
|
||||
: "connecting";
|
||||
const nearbyPeerCount = connection === "live"
|
||||
? (inside ? office?.remoteEntityCount() : city?.remoteEntityCount()) ?? 0
|
||||
: 0;
|
||||
presenceIndicator.update({ signedIn: true, connection, nearbyPeerCount });
|
||||
}
|
||||
|
||||
async function joinRealtimePresence(operation: number): Promise<void> {
|
||||
const client = realtimeClient;
|
||||
const pose = realtimePose();
|
||||
if (!client || !pose) return;
|
||||
try {
|
||||
const grant = await client.join(realtimeInterest(), pose);
|
||||
if (operation !== realtimeOperation) return;
|
||||
applyRealtimeMessage(grant);
|
||||
} catch {
|
||||
if (operation === realtimeOperation) {
|
||||
presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveRealtimePresence(): void {
|
||||
clearRealtimePeers();
|
||||
const client = realtimeClient;
|
||||
if (!client || access.subject === null) return;
|
||||
const operation = ++realtimeOperation;
|
||||
if (client.state().sessionId === null) {
|
||||
void joinRealtimePresence(operation);
|
||||
return;
|
||||
}
|
||||
updatePresenceIndicator({ ...client.state(), status: "reconnecting" });
|
||||
void client.moveInterest(realtimeInterest())
|
||||
.then((grant) => {
|
||||
if (operation === realtimeOperation) applyRealtimeMessage(grant);
|
||||
})
|
||||
.catch(() => {
|
||||
if (operation === realtimeOperation) {
|
||||
presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function publishRealtimePresence(now: number): void {
|
||||
if (!realtimeClient || now - lastRealtimePublishAt < 100) return;
|
||||
const pose = realtimePose();
|
||||
if (!pose || realtimeClient.state().sessionId === null) return;
|
||||
lastRealtimePublishAt = now;
|
||||
try {
|
||||
realtimeClient.publishPose(pose);
|
||||
} catch {
|
||||
presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeRealtimePresence(): Promise<void> {
|
||||
if (!realtimePageActive || access.subject === null || !presenceHost || realtimeClient) return;
|
||||
const [{ createRealtimeClient }, { createPresenceIndicator }] = await Promise.all([
|
||||
import("./realtime/client.ts"),
|
||||
import("./realtime/presenceIndicator.ts"),
|
||||
]);
|
||||
// `pagehide` can overtake the lazy chunks. Never construct a fresh client
|
||||
// after the page has already run its terminal cleanup.
|
||||
if (!realtimePageActive || access.subject === null || realtimeClient) return;
|
||||
presenceHost.hidden = false;
|
||||
document.body.classList.add("presence-on");
|
||||
presenceIndicator = createPresenceIndicator({
|
||||
container: presenceHost,
|
||||
onRetry: () => moveRealtimePresence(),
|
||||
});
|
||||
presenceIndicator.update({ signedIn: true, connection: "connecting", nearbyPeerCount: 0 });
|
||||
realtimeClient = createRealtimeClient({
|
||||
actorId: realtimeActorId,
|
||||
authenticatedFetch: authFetch,
|
||||
sendIntervalMs: 100,
|
||||
});
|
||||
stopRealtimeSubscription = realtimeClient.subscribe({
|
||||
onMessage: applyRealtimeMessage,
|
||||
onStateChange: updatePresenceIndicator,
|
||||
onError: () => presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 }),
|
||||
});
|
||||
moveRealtimePresence();
|
||||
}
|
||||
|
||||
window.addEventListener("pagehide", () => {
|
||||
realtimePageActive = false;
|
||||
realtimeOperation += 1;
|
||||
stopRealtimeSubscription?.();
|
||||
stopRealtimeSubscription = null;
|
||||
clearRealtimePeers();
|
||||
// Page teardown cannot await the leave round trip. Contain a rotated-token
|
||||
// or offline failure so navigation never produces an unhandled rejection;
|
||||
// the server's disconnect grace still removes the ephemeral session.
|
||||
void realtimeClient?.dispose().catch(() => undefined);
|
||||
realtimeClient = null;
|
||||
presenceIndicator?.dispose();
|
||||
presenceIndicator = null;
|
||||
});
|
||||
|
||||
function applyProfilePreview(profile: LocalProfile): void {
|
||||
if (access.subject === null) return;
|
||||
if (inside) {
|
||||
@@ -3165,6 +3440,12 @@ async function boot() {
|
||||
mountCity(first?.id ?? "california"),
|
||||
);
|
||||
|
||||
// There must be a mounted scene before remote snapshots have anywhere to go.
|
||||
// Anonymous and zero-server builds retain their existing local-only path.
|
||||
// Hosted presence is an enhancement: a failed lazy chunk or unavailable API
|
||||
// must not stop profile/tools initialization after the world is already live.
|
||||
await initializeRealtimePresence().catch(() => undefined);
|
||||
|
||||
// The instruments, after the first board, because the panel reads a live
|
||||
// stage and there is not one before this line.
|
||||
await mountGodmode();
|
||||
|
||||
Reference in New Issue
Block a user