@adea-ai/spatial 0.69.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ export type SceneNavigationType = 'navigate' | 'push' | 'replace' | 'traverse' | 'scene-mount';
2
+ export type ScenePerformanceReport = {
3
+ version: 1;
4
+ event: 'load' | 'runtime' | 'dispose' | 'error';
5
+ release?: string;
6
+ scene: string;
7
+ path: string;
8
+ navigation: {
9
+ id: string;
10
+ type: SceneNavigationType;
11
+ startTime: number;
12
+ };
13
+ milestones: {
14
+ firstRenderedFrameMs?: number;
15
+ playableCharacterMs?: number;
16
+ phasesMs: Record<string, number>;
17
+ };
18
+ network: {
19
+ requests: number;
20
+ networkRequests: number;
21
+ cacheHits: number;
22
+ unknownCacheStatus: number;
23
+ transferBytes: number;
24
+ encodedBodyBytes: number;
25
+ decodedBodyBytes: number;
26
+ };
27
+ runtime: {
28
+ sampleWindowMs: number;
29
+ frames: number;
30
+ fps?: number;
31
+ p50FrameMs?: number;
32
+ p95FrameMs?: number;
33
+ longestFrameMs?: number;
34
+ p95FrameIntervalMs?: number;
35
+ longestFrameIntervalMs?: number;
36
+ longTasks: number;
37
+ longTaskDurationMs: number;
38
+ drawCalls?: number;
39
+ drawCallsP95?: number;
40
+ drawCallsMax?: number;
41
+ triangles?: number;
42
+ geometries?: number;
43
+ textures?: number;
44
+ programs?: number;
45
+ reactCommits?: number;
46
+ reactCommitDurationMs?: number;
47
+ reactMaxCommitMs?: number;
48
+ };
49
+ renderables: SceneRuntimeStats;
50
+ lifecycle: {
51
+ disposedGeometries: number;
52
+ disposedMaterials: number;
53
+ disposedTextures: number;
54
+ };
55
+ device: {
56
+ tier: 'high' | 'standard' | 'low';
57
+ mobile: boolean;
58
+ devicePixelRatio: number;
59
+ hardwareConcurrency: number;
60
+ deviceMemoryGb?: number;
61
+ webglVersion: string;
62
+ gpu?: string;
63
+ maxTextureSize?: number;
64
+ };
65
+ error?: string;
66
+ };
67
+ export type SceneRuntimeStats = {
68
+ meshes: number;
69
+ visibleMeshes: number;
70
+ frustumCulledMeshes: number;
71
+ materials: number;
72
+ instancedMeshes: number;
73
+ instances: number;
74
+ foliageMeshes: number;
75
+ foliageInstances: number;
76
+ fenceMeshes: number;
77
+ fenceInstances: number;
78
+ repeatedGeometryGroups: number;
79
+ repeatedGeometryInstances: number;
80
+ };
81
+ export declare const SCENE_TELEMETRY_LOG_PREFIX = "[scene-telemetry] ";
82
+ export declare const MAX_SCENE_TELEMETRY_BYTES: number;
83
+ export type SceneTelemetryEnvelope = {
84
+ receivedAt: string;
85
+ deployment?: string;
86
+ report: ScenePerformanceReport;
87
+ };
88
+ export declare function parseScenePerformanceReport(value: unknown): ScenePerformanceReport | null;
89
+ export declare function createSceneTelemetryEnvelope(report: ScenePerformanceReport, receivedAt?: Date, deployment?: string | undefined): SceneTelemetryEnvelope;
90
+ type NavigationStart = {
91
+ id: string;
92
+ url: string;
93
+ startTime: number;
94
+ type: 'navigate' | 'push' | 'replace' | 'traverse';
95
+ claimed?: boolean;
96
+ };
97
+ declare global {
98
+ interface Window {
99
+ __ADEA_NAVIGATION_START__?: NavigationStart;
100
+ __ADEA_SCENE_TELEMETRY_ENDPOINT__?: string;
101
+ __ADEA_RELEASE__?: string;
102
+ }
103
+ }
104
+ export declare function recordNavigation(url: string, type: 'navigate' | 'push' | 'replace' | 'traverse', startTime: number): void;
105
+ export declare function onRouterTransitionStart(url: string, navigationType: 'push' | 'replace' | 'traverse'): void;
106
+ export {};
@@ -0,0 +1,79 @@
1
+ export const SCENE_TELEMETRY_LOG_PREFIX = '[scene-telemetry] ';
2
+ export const MAX_SCENE_TELEMETRY_BYTES = 64 * 1024;
3
+ function isFiniteNumber(value) {
4
+ return typeof value === 'number' && Number.isFinite(value);
5
+ }
6
+ function isRecord(value) {
7
+ return value != null && typeof value === 'object' && !Array.isArray(value);
8
+ }
9
+ function validReport(value) {
10
+ if (!isRecord(value))
11
+ return false;
12
+ if (value.version !== 1 || !['load', 'runtime', 'dispose', 'error'].includes(String(value.event)))
13
+ return false;
14
+ if (typeof value.scene !== 'string' || value.scene.length < 1 || value.scene.length > 80)
15
+ return false;
16
+ if (typeof value.path !== 'string' || value.path.length < 1 || value.path.length > 256)
17
+ return false;
18
+ if (value.release != null && (typeof value.release !== 'string' || value.release.length > 128))
19
+ return false;
20
+ if (!isRecord(value.navigation) ||
21
+ typeof value.navigation.id !== 'string' ||
22
+ !isFiniteNumber(value.navigation.startTime))
23
+ return false;
24
+ if (!isRecord(value.milestones) || !isRecord(value.milestones.phasesMs))
25
+ return false;
26
+ if (!isRecord(value.network) ||
27
+ !isFiniteNumber(value.network.requests) ||
28
+ !isFiniteNumber(value.network.transferBytes))
29
+ return false;
30
+ if (!isRecord(value.runtime) ||
31
+ !isFiniteNumber(value.runtime.sampleWindowMs) ||
32
+ !isFiniteNumber(value.runtime.frames))
33
+ return false;
34
+ if (!isRecord(value.device) || !['high', 'standard', 'low'].includes(String(value.device.tier)))
35
+ return false;
36
+ if (typeof value.device.mobile !== 'boolean' || !isFiniteNumber(value.device.devicePixelRatio))
37
+ return false;
38
+ return true;
39
+ }
40
+ export function parseScenePerformanceReport(value) {
41
+ return validReport(value) ? value : null;
42
+ }
43
+ export function createSceneTelemetryEnvelope(report, receivedAt = new Date(), deployment = process.env.DEPLOY_GIT_COMMIT_SHA) {
44
+ return {
45
+ receivedAt: receivedAt.toISOString(),
46
+ ...(deployment ? { deployment } : {}),
47
+ report,
48
+ };
49
+ }
50
+ export function recordNavigation(url, type, startTime) {
51
+ window.__ADEA_NAVIGATION_START__ = {
52
+ id: `nav-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
53
+ url,
54
+ startTime,
55
+ type,
56
+ };
57
+ }
58
+ try {
59
+ const navigation = performance.getEntriesByType('navigation')[0];
60
+ recordNavigation(window.location.href, 'navigate', navigation?.startTime ?? 0);
61
+ const endpoint = process.env.ADEA_PUBLIC_SCENE_TELEMETRY_ENDPOINT ||
62
+ (process.env.NODE_ENV === 'production' ? '/api/telemetry/scene-performance' : undefined);
63
+ if (endpoint)
64
+ window.__ADEA_SCENE_TELEMETRY_ENDPOINT__ = endpoint;
65
+ const release = process.env.ADEA_PUBLIC_DEPLOY_GIT_COMMIT_SHA;
66
+ if (release)
67
+ window.__ADEA_RELEASE__ = release;
68
+ }
69
+ catch {
70
+ // Telemetry must never interfere with page initialization.
71
+ }
72
+ export function onRouterTransitionStart(url, navigationType) {
73
+ try {
74
+ recordNavigation(new URL(url, window.location.href).href, navigationType, performance.now());
75
+ }
76
+ catch {
77
+ // Invalid route metadata should not affect navigation.
78
+ }
79
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@adea-ai/spatial",
3
+ "version": "0.69.10",
4
+ "description": "Scene and asset manifest contracts plus the shell-to-engine spatial protocol for Adea workspaces: scene mount manifests, spawn URL helpers, and scene telemetry schemas. Pure types — no rendering, no binaries.",
5
+ "keywords": [
6
+ "assets",
7
+ "manifest",
8
+ "protocol",
9
+ "scene",
10
+ "spatial",
11
+ "telemetry",
12
+ "threejs",
13
+ "workspace"
14
+ ],
15
+ "license": "Apache-2.0",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/adea-ai/adea.git",
19
+ "directory": "packages/spatial"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "src",
24
+ "tsconfig.json",
25
+ "README.md"
26
+ ],
27
+ "type": "module",
28
+ "main": "dist/index.js",
29
+ "types": "dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "development": "./src/index.ts",
34
+ "import": "./dist/index.js"
35
+ }
36
+ },
37
+ "scripts": {
38
+ "lint": "oxlint",
39
+ "build": "tsc -p tsconfig.json",
40
+ "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
41
+ "test": "bun test test/*.test.ts",
42
+ "typecheck": "tsc -p tsconfig.json --noEmit"
43
+ }
44
+ }
@@ -0,0 +1,95 @@
1
+ export type SceneAvailability = 'ready' | 'planned'
2
+
3
+ export type SceneZone = {
4
+ id: string
5
+ assetUrl: string
6
+ /** Optional collision layer that follows this visual zone. */
7
+ collisionAssetUrl?: string
8
+ /** Additional visual layers that load and unload with this zone. */
9
+ assetUrls?: readonly string[]
10
+ /** Exact-triangle field colliders that load and unload with this zone. */
11
+ collisionAssetUrls?: readonly string[]
12
+ preload?: boolean
13
+ /** Load the zone automatically when the player enters this radius. */
14
+ preloadDistance?: number
15
+ /** Optional transform for a model authored in local building space. */
16
+ transform?: {
17
+ position?: readonly [number, number, number]
18
+ quaternion?: readonly [number, number, number, number]
19
+ scale?: readonly [number, number, number]
20
+ }
21
+ }
22
+
23
+ export type SceneStartPosition = {
24
+ x: number
25
+ y: number
26
+ z: number
27
+ yaw?: number
28
+ pitch?: number
29
+ /** Keep an authored start height instead of snapping to the first ray hit. */
30
+ snapToGround?: boolean
31
+ }
32
+
33
+ export type StaticFieldAssetUrls = {
34
+ foliage?: string
35
+ props?: string
36
+ }
37
+
38
+ export type AssignedPropManifestPlacement = {
39
+ id?: string
40
+ p?: readonly [number, number, number]
41
+ q?: readonly [number, number, number, number]
42
+ s?: readonly [number, number, number]
43
+ footprint?: readonly [number, number]
44
+ }
45
+
46
+ export type AssignedPropManifestAsset = {
47
+ assetUrl: string
48
+ defaultScale: number
49
+ footprint?: readonly [number, number]
50
+ placementSurface?: 'floor' | 'wall'
51
+ wallMountHeight?: number
52
+ floorLift?: number
53
+ placeableOnTop?: boolean
54
+ }
55
+
56
+ /** Runtime-only room content for props already assigned to a scene. */
57
+ export type AssignedPropsManifest = {
58
+ version?: number
59
+ scene?: string
60
+ assets: Record<string, AssignedPropManifestAsset>
61
+ placements: Record<string, AssignedPropManifestPlacement[]>
62
+ }
63
+
64
+ export type SceneManifest = {
65
+ id: string
66
+ label: string
67
+ availability?: SceneAvailability
68
+ entryAssetUrl: string
69
+ /** Optional id for making the initially loaded asset unloadable as a zone. */
70
+ entryZoneId?: string
71
+ /** Keep the entry collision layer active while visual zones transition. */
72
+ preserveEntryCollision?: boolean
73
+ collisionAssetUrl?: string
74
+ /** Additional collision layers kept active for multi-level scenes. */
75
+ additionalCollisionAssetUrls?: readonly string[]
76
+ additionalAssetUrls?: readonly string[]
77
+ zones?: SceneZone[]
78
+ startPosition?: SceneStartPosition
79
+ /** Build-generated instanced fields for static scenes (preferred over runtime manifests). */
80
+ staticFieldAssetUrls?: StaticFieldAssetUrls
81
+ /** Render-free exact-triangle companions for the generated static fields. */
82
+ staticFieldCollisionAssetUrls?: StaticFieldAssetUrls
83
+ /** Optional foliage placement manifest (instanced from the foliage catalog at runtime). */
84
+ foliageManifestUrl?: string
85
+ /** Optional props placement manifest (instanced from the props catalog at runtime). */
86
+ propsManifestUrl?: string
87
+ /** Runtime-only manifest for props explicitly assigned to this workspace. */
88
+ assignedPropsManifestUrl?: string
89
+ /** Public URL for persisted authored-object editor overrides. */
90
+ editorOverridesUrl?: string
91
+ /** Optional repository-relative source path for persisted overrides. */
92
+ editorOverridesSourcePath?: string
93
+ /** Optional repository-relative public copy path for persisted overrides. */
94
+ editorOverridesPublicPath?: string
95
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Entitlement gate and loading contract for the Agent Sim engine.
3
+ *
4
+ * The engine (models, textures, audio, runtime code) lives in the private
5
+ * agent-sim repo and never ships in this public one. The shell may only
6
+ * resolve the engine remote when the deployment is entitled to it:
7
+ *
8
+ * - Web: the site is served from an official Adea domain (adea.dev /
9
+ * adea.io, including subdomains). Forked deployments on other origins
10
+ * are refused before any engine fetch happens.
11
+ * - Desktop / any build: an official build packs the engine into its own
12
+ * assets at build time (see scripts/pack-agent-sim.mjs, wired into the
13
+ * release lanes). The pack writes a manifest at a well-known same-origin
14
+ * URL; plain checkouts and fork builds simply do not have it.
15
+ *
16
+ * Both signals are checked; the engine is fetched only when both pass, so
17
+ * copying engine assets into a forked website still fails the host gate.
18
+ */
19
+
20
+ export const OFFICIAL_AGENT_SIM_WEB_HOSTS = ['adea.dev', 'adea.io'] as const
21
+
22
+ /** Known-public hostname suffixes never treated as official even if they embed an official host. */
23
+ const NON_OFFICIAL_SUFFIXES = ['.local', '.localhost', '.internal']
24
+ const OFFICIAL_LOCAL_HOSTS = ['localhost', '127.0.0.1', '[::1]']
25
+
26
+ export type AgentSimWebPlatform = 'web' | 'desktop'
27
+
28
+ export type AgentSimEngineManifest = {
29
+ /**
30
+ * Same-origin ES module URL of the engine entry. The entry must register
31
+ * the `window.__adeaAgentSim` mount API documented in the package README
32
+ * before its load event resolves.
33
+ */
34
+ entryUrl: string
35
+ /** Engine pack version (agent-sim repo release). Diagnostics only. */
36
+ version: string
37
+ }
38
+
39
+ /** Well-known same-origin URL the official pack lane writes the manifest to. */
40
+ export function agentSimEngineManifestUrl(origin: string): string {
41
+ return new URL('/assets/agent-sim/engine.json', origin).toString()
42
+ }
43
+
44
+ /**
45
+ * Parse and validate an engine manifest fetched from a trusted origin.
46
+ * Rejects payloads that would point the loader off the deployment's own
47
+ * origin — the engine ships with the site, never from third parties.
48
+ */
49
+ export function parseAgentSimEngineManifest(
50
+ value: unknown,
51
+ origin: string
52
+ ): { ok: true; manifest: AgentSimEngineManifest } | { ok: false } {
53
+ if (typeof value !== 'object' || value === null) return { ok: false }
54
+ const entryUrl = (value as { engine?: { entryUrl?: unknown } }).engine?.entryUrl
55
+ const version = (value as { engine?: { version?: unknown } }).engine?.version
56
+ if (typeof entryUrl !== 'string' || entryUrl.length === 0) return { ok: false }
57
+ if (typeof version !== 'string' || version.length === 0) return { ok: false }
58
+ let resolved: URL
59
+ try {
60
+ resolved = new URL(entryUrl, origin)
61
+ } catch {
62
+ return { ok: false }
63
+ }
64
+ if (resolved.origin !== new URL(origin).origin) return { ok: false }
65
+ if (!resolved.pathname.endsWith('.js')) return { ok: false }
66
+ return { ok: true, manifest: { entryUrl: resolved.toString(), version } }
67
+ }
68
+
69
+ /**
70
+ * Whether a web origin is an official Adea deployment allowed to resolve the
71
+ * Agent Sim engine remote. Desktop builds skip this check: they are gated by
72
+ * the packed engine manifest instead.
73
+ */
74
+ export function isOfficialAgentSimWebOrigin(origin: string): boolean {
75
+ let url: URL
76
+ try {
77
+ url = new URL(origin)
78
+ } catch {
79
+ return false
80
+ }
81
+ if (url.protocol !== 'https:') return false
82
+ const host = url.hostname.toLowerCase()
83
+ if (NON_OFFICIAL_SUFFIXES.some((suffix) => host.endsWith(suffix))) return false
84
+ return OFFICIAL_AGENT_SIM_WEB_HOSTS.some(
85
+ (official) => host === official || host.endsWith(`.${official}`)
86
+ )
87
+ }
88
+
89
+ /**
90
+ * Hosts where a developer runs the shell locally. Local development is
91
+ * entitled only when the engine was explicitly packed into the local build
92
+ * (engine manifest present); the host check alone never entitles it.
93
+ */
94
+ export function isLocalDevHost(hostname: string): boolean {
95
+ const host = hostname.toLowerCase()
96
+ return OFFICIAL_LOCAL_HOSTS.includes(host) || host.endsWith('.localhost')
97
+ }
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ export * from './asset-manifests'
2
+ export {
3
+ agentSimEngineManifestUrl,
4
+ isLocalDevHost,
5
+ isOfficialAgentSimWebOrigin,
6
+ parseAgentSimEngineManifest,
7
+ OFFICIAL_AGENT_SIM_WEB_HOSTS,
8
+ type AgentSimEngineManifest,
9
+ } from './engine'
10
+ export { hqHomeManifest, hqWorkManifest } from './manifests'
11
+ export {
12
+ appRouteHref,
13
+ encodeSceneStartPosition,
14
+ portalNavigationHref,
15
+ readSceneStartPosition,
16
+ type PortalNavigation,
17
+ type SceneApp,
18
+ } from './scene-spawn'
19
+ export {
20
+ createSceneTelemetryEnvelope,
21
+ MAX_SCENE_TELEMETRY_BYTES,
22
+ onRouterTransitionStart,
23
+ parseScenePerformanceReport,
24
+ recordNavigation,
25
+ SCENE_TELEMETRY_LOG_PREFIX,
26
+ type SceneNavigationType,
27
+ type ScenePerformanceReport,
28
+ type SceneRuntimeStats,
29
+ type SceneTelemetryEnvelope,
30
+ } from './telemetry'
31
+ export const configurableCharacterId = 'configurable'
32
+ export const DEFAULT_CHARACTER_ID = configurableCharacterId
33
+
34
+ /**
35
+ * Shell-side character param check. The engine validates against its
36
+ * reference/preset catalogs on mount; the shell only needs a plausible
37
+ * non-empty value to thread through.
38
+ */
39
+ export function isPlausibleCharacterId(value: string | undefined): value is string {
40
+ return typeof value === 'string' && value.length > 0 && value.length <= 128
41
+ }
@@ -0,0 +1,41 @@
1
+ import type { SceneManifest } from './asset-manifests'
2
+
3
+ // Scene ids mirror @adea-ai/app-core's HqSceneId, which remains the canonical
4
+ // shell-side scene identity. This package only carries mount constants.
5
+ type HqSceneId = 'home' | 'work'
6
+
7
+ type HqSceneConfig = {
8
+ label: string
9
+ floorVersion: number
10
+ foliageVersion: number
11
+ }
12
+
13
+ const sceneConfig = {
14
+ home: { label: 'Home', floorVersion: 20, foliageVersion: 10 },
15
+ work: { label: 'Work', floorVersion: 21, foliageVersion: 11 },
16
+ } as const satisfies Record<HqSceneId, HqSceneConfig>
17
+
18
+ // Start height mirrors the engine's ROOM_GALLERY_FOUNDATION_TOP_Y (slab base 0
19
+ // + height 6) plus the authored 135-unit lift. The engine's room-config is
20
+ // the simulation source of truth; this package carries the mount constants
21
+ // the shell needs without depending on engine code.
22
+ const START_Y = 6 + 135
23
+
24
+ function createHqManifest(scene: HqSceneId): SceneManifest {
25
+ const { label, floorVersion, foliageVersion } = sceneConfig[scene]
26
+ const sceneId = `hq-${scene}`
27
+ return {
28
+ id: sceneId,
29
+ label,
30
+ availability: 'ready',
31
+ entryAssetUrl: `/assets/worlds/${sceneId}/floor.glb?v=${floorVersion}`,
32
+ collisionAssetUrl: `/assets/worlds/${sceneId}/floor-collision.glb?v=${floorVersion}`,
33
+ foliageManifestUrl: `/assets/worlds/${sceneId}/foliage.json?v=${foliageVersion}`,
34
+ assignedPropsManifestUrl: `/assets/worlds/${sceneId}/props-runtime.json?v=room-layout`,
35
+ zones: [],
36
+ startPosition: { x: 0, y: START_Y, z: 0, yaw: 0, pitch: -0.2 },
37
+ }
38
+ }
39
+
40
+ export const hqHomeManifest = createHqManifest('home')
41
+ export const hqWorkManifest = createHqManifest('work')
@@ -0,0 +1,107 @@
1
+ import type { SceneStartPosition } from './asset-manifests'
2
+
3
+ export type SceneApp = 'world' | 'hq'
4
+
5
+ const SPAWN_QUERY_KEY = 'spawn'
6
+ const APP_DEV_PORTS: Record<SceneApp, string> = { world: '3000', hq: '3004' }
7
+ const APP_PORTLESS_URLS: Record<SceneApp, string> = {
8
+ world: 'https://world.localhost',
9
+ hq: 'https://adea.localhost',
10
+ }
11
+
12
+ function firstValue(value: string | string[] | undefined): string | undefined {
13
+ return Array.isArray(value) ? value[0] : value
14
+ }
15
+
16
+ function finiteNumber(value: string | undefined): number | undefined {
17
+ if (!value) return undefined
18
+ const parsed = Number(value)
19
+ return Number.isFinite(parsed) ? parsed : undefined
20
+ }
21
+
22
+ /** Encode a scene start position for a cross-app navigation URL. */
23
+ export function encodeSceneStartPosition(position: SceneStartPosition): string {
24
+ return [
25
+ position.x,
26
+ position.y,
27
+ position.z,
28
+ position.yaw ?? '',
29
+ position.pitch ?? '',
30
+ position.snapToGround == null ? '' : position.snapToGround ? '1' : '0',
31
+ ].join(',')
32
+ }
33
+
34
+ /** Parse a validated scene start position from a route's `spawn` query. */
35
+ export function readSceneStartPosition(
36
+ value: string | string[] | undefined
37
+ ): SceneStartPosition | undefined {
38
+ const parts = firstValue(value)?.split(',')
39
+ if (!parts || parts.length < 3 || parts.length > 6) return undefined
40
+ const x = finiteNumber(parts[0])
41
+ const y = finiteNumber(parts[1])
42
+ const z = finiteNumber(parts[2])
43
+ if (x == null || y == null || z == null) return undefined
44
+ const yaw = finiteNumber(parts[3])
45
+ const pitch = finiteNumber(parts[4])
46
+ const snap = parts[5]
47
+ if (parts[3] && yaw == null) return undefined
48
+ if (parts[4] && pitch == null) return undefined
49
+ if (snap && snap !== '0' && snap !== '1') return undefined
50
+ return {
51
+ x,
52
+ y,
53
+ z,
54
+ ...(yaw == null ? {} : { yaw }),
55
+ ...(pitch == null ? {} : { pitch }),
56
+ ...(snap === '0' ? { snapToGround: false } : snap === '1' ? { snapToGround: true } : {}),
57
+ }
58
+ }
59
+
60
+ function configuredAppBase(app: SceneApp): string | undefined {
61
+ const configured =
62
+ app === 'world' ? process.env.ADEA_PUBLIC_WORLD_URL : process.env.ADEA_PUBLIC_HQ_URL
63
+ return configured?.trim() || undefined
64
+ }
65
+
66
+ function isDevelopmentHost(hostname: string): boolean {
67
+ return (
68
+ hostname === 'localhost' ||
69
+ hostname === '127.0.0.1' ||
70
+ hostname === '0.0.0.0' ||
71
+ hostname === 'amf-mb-pro' ||
72
+ hostname.endsWith('.localhost') ||
73
+ hostname.endsWith('.ts.net')
74
+ )
75
+ }
76
+
77
+ /** Resolve a route for the sibling World or HQ app in dev and production. */
78
+ export function appRouteHref(app: SceneApp, path: string, currentHref?: string): string {
79
+ const current = new URL(currentHref ?? window.location.href)
80
+ const configuredBase = configuredAppBase(app)
81
+ const isPortlessHost = current.hostname.endsWith('.localhost')
82
+ const target = new URL(
83
+ path,
84
+ configuredBase ?? (isPortlessHost ? APP_PORTLESS_URLS[app] : current.origin)
85
+ )
86
+ if (!configuredBase && !isPortlessHost && isDevelopmentHost(current.hostname)) {
87
+ target.port = APP_DEV_PORTS[app]
88
+ }
89
+ return target.toString()
90
+ }
91
+
92
+ export type PortalNavigation = {
93
+ app: SceneApp
94
+ path: string
95
+ spawn?: SceneStartPosition
96
+ }
97
+
98
+ /** Build a portal URL while carrying the selected character and target spawn. */
99
+ export function portalNavigationHref(navigation: PortalNavigation, currentHref?: string): string {
100
+ const current = new URL(currentHref ?? window.location.href)
101
+ const target = new URL(appRouteHref(navigation.app, navigation.path, current.toString()))
102
+ const character = current.searchParams.get('character')
103
+ if (character) target.searchParams.set('character', character)
104
+ if (navigation.spawn)
105
+ target.searchParams.set(SPAWN_QUERY_KEY, encodeSceneStartPosition(navigation.spawn))
106
+ return target.toString()
107
+ }