@adea-ai/spatial 0.95.0 → 0.96.0

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.
package/dist/index.d.ts CHANGED
@@ -8,6 +8,8 @@ export declare const DEFAULT_CHARACTER_ID = "configurable";
8
8
  /**
9
9
  * Shell-side character param check. The engine validates against its
10
10
  * reference/preset catalogs on mount; the shell only needs a plausible
11
- * non-empty value to thread through.
11
+ * non-empty value to thread through. A serialized customization
12
+ * (`character:v1:` and 13 slots, 157 characters by default) is checked by the
13
+ * bridge's structural parser instead of the short-id length rule.
12
14
  */
13
15
  export declare function isPlausibleCharacterId(value: string | undefined): value is string;
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isSerializedSimCharacter } from './sim-bridge/character';
1
2
  export * from './asset-manifests';
2
3
  export { agentSimEngineManifestUrl, isLocalDevHost, isOfficialAgentSimWebOrigin, parseAgentSimEngineManifest, OFFICIAL_AGENT_SIM_WEB_HOSTS, } from './engine';
3
4
  export { hqHomeManifest, hqWorkManifest } from './manifests';
@@ -8,8 +9,12 @@ export const DEFAULT_CHARACTER_ID = configurableCharacterId;
8
9
  /**
9
10
  * Shell-side character param check. The engine validates against its
10
11
  * reference/preset catalogs on mount; the shell only needs a plausible
11
- * non-empty value to thread through.
12
+ * non-empty value to thread through. A serialized customization
13
+ * (`character:v1:` and 13 slots, 157 characters by default) is checked by the
14
+ * bridge's structural parser instead of the short-id length rule.
12
15
  */
13
16
  export function isPlausibleCharacterId(value) {
14
- return typeof value === 'string' && value.length > 0 && value.length <= 128;
17
+ if (typeof value !== 'string' || value.length === 0)
18
+ return false;
19
+ return value.length <= 128 || isSerializedSimCharacter(value);
15
20
  }
@@ -1,4 +1,4 @@
1
- import type { SimFrameMessage } from './messages';
1
+ import type { SimFrameStatusMessage } from './messages';
2
2
  import type { SimLaunchGrant } from './launch';
3
3
  /**
4
4
  * What the host knows about a hosted Agent Sim launch, as a pure state machine. The host shows only
@@ -59,7 +59,7 @@ Readonly<{
59
59
  }> | Readonly<{
60
60
  type: 'frame';
61
61
  attempt: number;
62
- message: SimFrameMessage;
62
+ message: SimFrameStatusMessage;
63
63
  now: number;
64
64
  }> | Readonly<{
65
65
  type: 'timeout';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The character value the bridge carries in `host.launch` and `frame.character.save`.
3
+ *
4
+ * Two forms, both finite and structurally checked. The bridge never learns the private part
5
+ * catalog: slot contents are bounded opaque ids and the engine keeps validating membership.
6
+ *
7
+ * 1. A catalog id, such as `configurable`.
8
+ * 2. `character:v1:` followed by exactly 13 `|`-separated slots in `SIM_CHARACTER_SLOTS` order.
9
+ * An empty slot is `-`, and `body` is required.
10
+ */
11
+ export declare const SIM_CHARACTER_SLOTS: readonly ['body', 'ears', 'face', 'hair', 'hat', 'top', 'bottom', 'shoes', 'socks', 'glasses', 'gloves', 'accessory', 'costume'];
12
+ export declare const SIM_SERIALIZED_CHARACTER_PREFIX = "character:v1:";
13
+ /** The audited serializer's longest value is 251 characters; this leaves headroom, not slack. */
14
+ export declare const MAX_SIM_SERIALIZED_CHARACTER_LENGTH = 384;
15
+ export declare function isSerializedSimCharacter(value: unknown): value is string;
16
+ /** The value when it is a catalog id or a well-formed serialized character; otherwise null. */
17
+ export declare function parseSimCharacterValue(value: unknown): string | null;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The character value the bridge carries in `host.launch` and `frame.character.save`.
3
+ *
4
+ * Two forms, both finite and structurally checked. The bridge never learns the private part
5
+ * catalog: slot contents are bounded opaque ids and the engine keeps validating membership.
6
+ *
7
+ * 1. A catalog id, such as `configurable`.
8
+ * 2. `character:v1:` followed by exactly 13 `|`-separated slots in `SIM_CHARACTER_SLOTS` order.
9
+ * An empty slot is `-`, and `body` is required.
10
+ */
11
+ export const SIM_CHARACTER_SLOTS = [
12
+ 'body',
13
+ 'ears',
14
+ 'face',
15
+ 'hair',
16
+ 'hat',
17
+ 'top',
18
+ 'bottom',
19
+ 'shoes',
20
+ 'socks',
21
+ 'glasses',
22
+ 'gloves',
23
+ 'accessory',
24
+ 'costume',
25
+ ];
26
+ export const SIM_SERIALIZED_CHARACTER_PREFIX = 'character:v1:';
27
+ /** The audited serializer's longest value is 251 characters; this leaves headroom, not slack. */
28
+ export const MAX_SIM_SERIALIZED_CHARACTER_LENGTH = 384;
29
+ const CATALOG_ID = /^[A-Za-z0-9_.:-]{1,64}$/;
30
+ const SLOT_PART_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/;
31
+ const EMPTY_SLOT = '-';
32
+ export function isSerializedSimCharacter(value) {
33
+ if (typeof value !== 'string' ||
34
+ value.length > MAX_SIM_SERIALIZED_CHARACTER_LENGTH ||
35
+ !value.startsWith(SIM_SERIALIZED_CHARACTER_PREFIX))
36
+ return false;
37
+ const slots = value.slice(SIM_SERIALIZED_CHARACTER_PREFIX.length).split('|');
38
+ if (slots.length !== SIM_CHARACTER_SLOTS.length)
39
+ return false;
40
+ return slots.every((slot, index) => (slot === EMPTY_SLOT ? index !== 0 : SLOT_PART_ID.test(slot)));
41
+ }
42
+ /** The value when it is a catalog id or a well-formed serialized character; otherwise null. */
43
+ export function parseSimCharacterValue(value) {
44
+ if (typeof value !== 'string')
45
+ return null;
46
+ if (value.startsWith('character:'))
47
+ return isSerializedSimCharacter(value) ? value : null;
48
+ return CATALOG_ID.test(value) ? value : null;
49
+ }
@@ -1,6 +1,8 @@
1
1
  export * from './availability';
2
+ export * from './character';
2
3
  export * from './cookie';
3
4
  export * from './launch';
4
5
  export * from './messages';
5
6
  export * from './origin';
7
+ export * from './save-client';
6
8
  export * from './service';
@@ -1,6 +1,8 @@
1
1
  export * from './availability';
2
+ export * from './character';
2
3
  export * from './cookie';
3
4
  export * from './launch';
4
5
  export * from './messages';
5
6
  export * from './origin';
7
+ export * from './save-client';
6
8
  export * from './service';
@@ -17,7 +17,13 @@ export declare const SIM_FRAME_STATES: readonly ['loading', 'running', 'unavaila
17
17
  export type SimFrameState = (typeof SIM_FRAME_STATES)[number];
18
18
  export declare const SIM_FRAME_ERRORS: readonly ['launch_rejected', 'asset_unavailable', 'engine_failed', 'bridge_incompatible'];
19
19
  export type SimFrameError = (typeof SIM_FRAME_ERRORS)[number];
20
- export type SimFrameMessage = Readonly<{
20
+ /** Where HQ may send the host: the designers only. A designer returns with `frame.close`. */
21
+ export declare const SIM_NAVIGATE_TARGETS: readonly ['room-designer', 'character-designer'];
22
+ export type SimNavigateTarget = (typeof SIM_NAVIGATE_TARGETS)[number];
23
+ export declare const SIM_SAVE_FAILURES: readonly ['refused', 'failed', 'timeout'];
24
+ export type SimSaveFailure = (typeof SIM_SAVE_FAILURES)[number];
25
+ /** What the frame reports about its own launch; the pure launch reducer consumes only these. */
26
+ export type SimFrameStatusMessage = Readonly<{
21
27
  type: 'frame.ready';
22
28
  payload: Readonly<Record<string, never>>;
23
29
  }> | Readonly<{
@@ -35,6 +41,21 @@ export type SimFrameMessage = Readonly<{
35
41
  code: SimFrameError;
36
42
  }>;
37
43
  }>;
44
+ /** Closed requests the frame may make of the host. Each is answered or ends the attempt. */
45
+ export type SimFrameRequestMessage = Readonly<{
46
+ type: 'frame.navigate';
47
+ payload: Readonly<{
48
+ requestId: string;
49
+ target: SimNavigateTarget;
50
+ }>;
51
+ }> | Readonly<{
52
+ type: 'frame.character.save';
53
+ payload: Readonly<{
54
+ requestId: string;
55
+ character: string;
56
+ }>;
57
+ }>;
58
+ export type SimFrameMessage = SimFrameStatusMessage | SimFrameRequestMessage;
38
59
  export type SimHostMessage = Readonly<{
39
60
  type: 'host.launch';
40
61
  payload: Readonly<{
@@ -47,6 +68,13 @@ export type SimHostMessage = Readonly<{
47
68
  }> | Readonly<{
48
69
  type: 'host.dispose';
49
70
  payload: Readonly<Record<string, never>>;
71
+ }> | Readonly<{
72
+ type: 'host.character.save.result';
73
+ payload: Readonly<{
74
+ requestId: string;
75
+ saved: boolean;
76
+ reason?: SimSaveFailure;
77
+ }>;
50
78
  }>;
51
79
  export type SimBridgeFailure = 'origin_refused' | 'source_mismatch' | 'not_a_bridge_message' | 'version_unsupported' | 'bridge_mismatch' | 'unknown_type' | 'invalid_payload' | 'too_large';
52
80
  export type SimBridgeResult<T> = Readonly<{
@@ -1,5 +1,6 @@
1
1
  import { boundedString, isPlainRecord, strictRecord } from './record';
2
2
  import { SIM_SURFACES } from './launch';
3
+ import { parseSimCharacterValue } from './character';
3
4
  import { SIM_UNAVAILABLE_REASONS } from './availability';
4
5
  /**
5
6
  * Versioned message schema between the Adea host window and the hosted Agent Sim frame.
@@ -15,7 +16,7 @@ export const SIM_BRIDGE_VERSION = 1;
15
16
  export const MAX_SIM_MESSAGE_BYTES = 2048;
16
17
  const BRIDGE_ID = /^[A-Za-z0-9_-]{16,64}$/;
17
18
  const LAUNCH_CODE = /^[A-Za-z0-9_-]{32,128}$/;
18
- const CHARACTER_ID = /^[A-Za-z0-9_.:-]{1,64}$/;
19
+ const REQUEST_ID = /^[A-Za-z0-9_-]{1,32}$/;
19
20
  export const SIM_FRAME_STATES = ['loading', 'running', 'unavailable', 'offline'];
20
21
  export const SIM_FRAME_ERRORS = [
21
22
  'launch_rejected',
@@ -23,6 +24,9 @@ export const SIM_FRAME_ERRORS = [
23
24
  'engine_failed',
24
25
  'bridge_incompatible',
25
26
  ];
27
+ /** Where HQ may send the host: the designers only. A designer returns with `frame.close`. */
28
+ export const SIM_NAVIGATE_TARGETS = ['room-designer', 'character-designer'];
29
+ export const SIM_SAVE_FAILURES = ['refused', 'failed', 'timeout'];
26
30
  const ENVELOPE_KEYS = ['channel', 'version', 'bridgeId', 'type', 'payload'];
27
31
  function envelope(event, bridgeId) {
28
32
  const data = strictRecord(event.data, ENVELOPE_KEYS);
@@ -122,6 +126,22 @@ export function parseFrameMessage(event, context) {
122
126
  const code = SIM_FRAME_ERRORS.find((item) => item === body?.code);
123
127
  return code ? { ok: true, message: { type, payload: { code } } } : fail('invalid_payload');
124
128
  }
129
+ case 'frame.navigate': {
130
+ const body = strictRecord(payload, ['requestId', 'target']);
131
+ const requestId = boundedString(body?.requestId, REQUEST_ID);
132
+ const target = SIM_NAVIGATE_TARGETS.find((item) => item === body?.target);
133
+ return requestId && target
134
+ ? { ok: true, message: { type, payload: { requestId, target } } }
135
+ : fail('invalid_payload');
136
+ }
137
+ case 'frame.character.save': {
138
+ const body = strictRecord(payload, ['requestId', 'character']);
139
+ const requestId = boundedString(body?.requestId, REQUEST_ID);
140
+ const character = parseSimCharacterValue(body?.character);
141
+ return requestId && character
142
+ ? { ok: true, message: { type, payload: { requestId, character } } }
143
+ : fail('invalid_payload');
144
+ }
125
145
  default:
126
146
  return fail('unknown_type');
127
147
  }
@@ -148,7 +168,7 @@ export function parseHostMessage(event, context) {
148
168
  const code = boundedString(body?.code, LAUNCH_CODE);
149
169
  const surface = SIM_SURFACES.find((item) => item === body?.surface);
150
170
  const expiresAt = body?.expiresAt;
151
- const character = body && 'character' in body ? boundedString(body.character, CHARACTER_ID) : undefined;
171
+ const character = body && 'character' in body ? parseSimCharacterValue(body.character) : undefined;
152
172
  if (!body ||
153
173
  !code ||
154
174
  !surface ||
@@ -164,6 +184,23 @@ export function parseHostMessage(event, context) {
164
184
  },
165
185
  };
166
186
  }
187
+ case 'host.character.save.result': {
188
+ const body = strictRecord(payload, ['requestId', 'saved'], ['reason']);
189
+ const requestId = boundedString(body?.requestId, REQUEST_ID);
190
+ if (!body || !requestId || typeof body.saved !== 'boolean')
191
+ return fail('invalid_payload');
192
+ const reason = SIM_SAVE_FAILURES.find((item) => item === body.reason);
193
+ // A failure names its reason; a success has none.
194
+ if (body.saved ? 'reason' in body : !reason)
195
+ return fail('invalid_payload');
196
+ return {
197
+ ok: true,
198
+ message: {
199
+ type,
200
+ payload: reason ? { requestId, saved: false, reason } : { requestId, saved: true },
201
+ },
202
+ };
203
+ }
167
204
  default:
168
205
  return fail('unknown_type');
169
206
  }
@@ -0,0 +1,25 @@
1
+ import type { SimFrameRequestMessage, SimHostMessage } from './messages';
2
+ /**
3
+ * Frame side of the acknowledged character save.
4
+ *
5
+ * `save` resolves `true` only when the host answers the very request it sent with
6
+ * `saved: true`. A refusal, a failure, a timeout, a host dispose, `dispose()`, a second save while
7
+ * one is open, an invalid value, a post that throws, and a late or mismatched answer all resolve
8
+ * `false`, so the engine never shows a saved value the host did not commit.
9
+ */
10
+ export type CharacterSaveClient = Readonly<{
11
+ save(character: string): Promise<boolean>;
12
+ /** Feed every parsed host message in; unrelated ones are ignored. */
13
+ receive(message: SimHostMessage): void;
14
+ /** Fails any open save. Call when the frame is torn down. */
15
+ dispose(): void;
16
+ }>;
17
+ export type CharacterSaveClientOptions = Readonly<{
18
+ post(message: SimFrameRequestMessage): void;
19
+ /** Longer than the host's own timeout so the host's answer normally arrives first. */
20
+ timeoutMs?: number;
21
+ mintRequestId?: () => string;
22
+ schedule?: (run: () => void, ms: number) => () => void;
23
+ }>;
24
+ export declare const DEFAULT_SAVE_CLIENT_TIMEOUT_MS = 8000;
25
+ export declare function createCharacterSaveClient(options: CharacterSaveClientOptions): CharacterSaveClient;
@@ -0,0 +1,55 @@
1
+ import { parseSimCharacterValue } from './character';
2
+ export const DEFAULT_SAVE_CLIENT_TIMEOUT_MS = 8_000;
3
+ export function createCharacterSaveClient(options) {
4
+ const timeoutMs = options.timeoutMs ?? DEFAULT_SAVE_CLIENT_TIMEOUT_MS;
5
+ const schedule = options.schedule ??
6
+ ((run, ms) => {
7
+ const id = setTimeout(run, ms);
8
+ return () => clearTimeout(id);
9
+ });
10
+ let counter = 0;
11
+ const mint = options.mintRequestId ?? (() => `save${++counter}`);
12
+ let open = null;
13
+ let disposed = false;
14
+ function failOpen() {
15
+ open?.settle(false);
16
+ }
17
+ return {
18
+ save(character) {
19
+ if (disposed || open || parseSimCharacterValue(character) === null)
20
+ return Promise.resolve(false);
21
+ const requestId = mint();
22
+ return new Promise((resolve) => {
23
+ const cancel = schedule(() => failOpen(), timeoutMs);
24
+ const entry = {
25
+ requestId,
26
+ settle(saved) {
27
+ if (open !== entry)
28
+ return;
29
+ open = null;
30
+ cancel();
31
+ resolve(saved);
32
+ },
33
+ };
34
+ open = entry;
35
+ try {
36
+ options.post({ type: 'frame.character.save', payload: { requestId, character } });
37
+ }
38
+ catch {
39
+ entry.settle(false);
40
+ }
41
+ });
42
+ },
43
+ receive(message) {
44
+ if (message.type === 'host.dispose')
45
+ failOpen();
46
+ else if (message.type === 'host.character.save.result' &&
47
+ open?.requestId === message.payload.requestId)
48
+ open.settle(message.payload.saved);
49
+ },
50
+ dispose() {
51
+ disposed = true;
52
+ failOpen();
53
+ },
54
+ };
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adea-ai/spatial",
3
- "version": "0.95.0",
3
+ "version": "0.96.0",
4
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
5
  "keywords": [
6
6
  "assets",
@@ -44,6 +44,6 @@
44
44
  "build": "tsc -p tsconfig.json",
45
45
  "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
46
46
  "test": "bun test test/*.test.ts",
47
- "typecheck": "tsc -p tsconfig.json --noEmit"
47
+ "typecheck": "bun check -p tsconfig.json"
48
48
  }
49
49
  }
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { isSerializedSimCharacter } from './sim-bridge/character'
2
+
1
3
  export * from './asset-manifests'
2
4
  export {
3
5
  agentSimEngineManifestUrl,
@@ -35,8 +37,11 @@ export const DEFAULT_CHARACTER_ID = configurableCharacterId
35
37
  /**
36
38
  * Shell-side character param check. The engine validates against its
37
39
  * reference/preset catalogs on mount; the shell only needs a plausible
38
- * non-empty value to thread through.
40
+ * non-empty value to thread through. A serialized customization
41
+ * (`character:v1:` and 13 slots, 157 characters by default) is checked by the
42
+ * bridge's structural parser instead of the short-id length rule.
39
43
  */
40
44
  export function isPlausibleCharacterId(value: string | undefined): value is string {
41
- return typeof value === 'string' && value.length > 0 && value.length <= 128
45
+ if (typeof value !== 'string' || value.length === 0) return false
46
+ return value.length <= 128 || isSerializedSimCharacter(value)
42
47
  }
@@ -1,4 +1,4 @@
1
- import type { SimFrameMessage } from './messages'
1
+ import type { SimFrameStatusMessage } from './messages'
2
2
  import type { SimLaunchGrant } from './launch'
3
3
 
4
4
  /**
@@ -47,7 +47,7 @@ export type SimLaunchEvent =
47
47
  | Readonly<{ type: 'start'; online?: boolean }>
48
48
  /** The backend's parsed reply for `attempt`; `null` is a reply that failed validation. */
49
49
  | Readonly<{ type: 'grant'; attempt: number; grant: SimLaunchGrant | null }>
50
- | Readonly<{ type: 'frame'; attempt: number; message: SimFrameMessage; now: number }>
50
+ | Readonly<{ type: 'frame'; attempt: number; message: SimFrameStatusMessage; now: number }>
51
51
  | Readonly<{ type: 'timeout'; attempt: number }>
52
52
  | Readonly<{ type: 'offline' }>
53
53
  | Readonly<{ type: 'dispose' }>
@@ -76,7 +76,11 @@ function unavailable(attempt: number, reason: SimUnavailableReason): SimLaunchSt
76
76
  return { state: { phase: 'unavailable', attempt, reason } }
77
77
  }
78
78
 
79
- function reduceFrame(state: SimLaunchState, message: SimFrameMessage, now: number): SimLaunchStep {
79
+ function reduceFrame(
80
+ state: SimLaunchState,
81
+ message: SimFrameStatusMessage,
82
+ now: number
83
+ ): SimLaunchStep {
80
84
  const { attempt } = state
81
85
  switch (message.type) {
82
86
  case 'frame.ready': {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The character value the bridge carries in `host.launch` and `frame.character.save`.
3
+ *
4
+ * Two forms, both finite and structurally checked. The bridge never learns the private part
5
+ * catalog: slot contents are bounded opaque ids and the engine keeps validating membership.
6
+ *
7
+ * 1. A catalog id, such as `configurable`.
8
+ * 2. `character:v1:` followed by exactly 13 `|`-separated slots in `SIM_CHARACTER_SLOTS` order.
9
+ * An empty slot is `-`, and `body` is required.
10
+ */
11
+
12
+ export const SIM_CHARACTER_SLOTS = [
13
+ 'body',
14
+ 'ears',
15
+ 'face',
16
+ 'hair',
17
+ 'hat',
18
+ 'top',
19
+ 'bottom',
20
+ 'shoes',
21
+ 'socks',
22
+ 'glasses',
23
+ 'gloves',
24
+ 'accessory',
25
+ 'costume',
26
+ ] as const
27
+
28
+ export const SIM_SERIALIZED_CHARACTER_PREFIX = 'character:v1:'
29
+ /** The audited serializer's longest value is 251 characters; this leaves headroom, not slack. */
30
+ export const MAX_SIM_SERIALIZED_CHARACTER_LENGTH = 384
31
+
32
+ const CATALOG_ID = /^[A-Za-z0-9_.:-]{1,64}$/
33
+ const SLOT_PART_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/
34
+ const EMPTY_SLOT = '-'
35
+
36
+ export function isSerializedSimCharacter(value: unknown): value is string {
37
+ if (
38
+ typeof value !== 'string' ||
39
+ value.length > MAX_SIM_SERIALIZED_CHARACTER_LENGTH ||
40
+ !value.startsWith(SIM_SERIALIZED_CHARACTER_PREFIX)
41
+ )
42
+ return false
43
+ const slots = value.slice(SIM_SERIALIZED_CHARACTER_PREFIX.length).split('|')
44
+ if (slots.length !== SIM_CHARACTER_SLOTS.length) return false
45
+ return slots.every((slot, index) => (slot === EMPTY_SLOT ? index !== 0 : SLOT_PART_ID.test(slot)))
46
+ }
47
+
48
+ /** The value when it is a catalog id or a well-formed serialized character; otherwise null. */
49
+ export function parseSimCharacterValue(value: unknown): string | null {
50
+ if (typeof value !== 'string') return null
51
+ if (value.startsWith('character:')) return isSerializedSimCharacter(value) ? value : null
52
+ return CATALOG_ID.test(value) ? value : null
53
+ }
@@ -1,6 +1,8 @@
1
1
  export * from './availability'
2
+ export * from './character'
2
3
  export * from './cookie'
3
4
  export * from './launch'
4
5
  export * from './messages'
5
6
  export * from './origin'
7
+ export * from './save-client'
6
8
  export * from './service'
@@ -1,6 +1,7 @@
1
1
  import { boundedString, isPlainRecord, strictRecord } from './record'
2
2
  import type { OriginAllowlist } from './origin'
3
3
  import { SIM_SURFACES, type SimSurface } from './launch'
4
+ import { parseSimCharacterValue } from './character'
4
5
  import { SIM_UNAVAILABLE_REASONS, type SimUnavailableReason } from './availability'
5
6
 
6
7
  /**
@@ -19,7 +20,7 @@ export const MAX_SIM_MESSAGE_BYTES = 2048
19
20
 
20
21
  const BRIDGE_ID = /^[A-Za-z0-9_-]{16,64}$/
21
22
  const LAUNCH_CODE = /^[A-Za-z0-9_-]{32,128}$/
22
- const CHARACTER_ID = /^[A-Za-z0-9_.:-]{1,64}$/
23
+ const REQUEST_ID = /^[A-Za-z0-9_-]{1,32}$/
23
24
 
24
25
  export const SIM_FRAME_STATES = ['loading', 'running', 'unavailable', 'offline'] as const
25
26
  export type SimFrameState = (typeof SIM_FRAME_STATES)[number]
@@ -31,7 +32,14 @@ export const SIM_FRAME_ERRORS = [
31
32
  ] as const
32
33
  export type SimFrameError = (typeof SIM_FRAME_ERRORS)[number]
33
34
 
34
- export type SimFrameMessage =
35
+ /** Where HQ may send the host: the designers only. A designer returns with `frame.close`. */
36
+ export const SIM_NAVIGATE_TARGETS = ['room-designer', 'character-designer'] as const
37
+ export type SimNavigateTarget = (typeof SIM_NAVIGATE_TARGETS)[number]
38
+ export const SIM_SAVE_FAILURES = ['refused', 'failed', 'timeout'] as const
39
+ export type SimSaveFailure = (typeof SIM_SAVE_FAILURES)[number]
40
+
41
+ /** What the frame reports about its own launch; the pure launch reducer consumes only these. */
42
+ export type SimFrameStatusMessage =
35
43
  | Readonly<{ type: 'frame.ready'; payload: Readonly<Record<string, never>> }>
36
44
  | Readonly<{
37
45
  type: 'frame.state'
@@ -40,6 +48,19 @@ export type SimFrameMessage =
40
48
  | Readonly<{ type: 'frame.close'; payload: Readonly<Record<string, never>> }>
41
49
  | Readonly<{ type: 'frame.error'; payload: Readonly<{ code: SimFrameError }> }>
42
50
 
51
+ /** Closed requests the frame may make of the host. Each is answered or ends the attempt. */
52
+ export type SimFrameRequestMessage =
53
+ | Readonly<{
54
+ type: 'frame.navigate'
55
+ payload: Readonly<{ requestId: string; target: SimNavigateTarget }>
56
+ }>
57
+ | Readonly<{
58
+ type: 'frame.character.save'
59
+ payload: Readonly<{ requestId: string; character: string }>
60
+ }>
61
+
62
+ export type SimFrameMessage = SimFrameStatusMessage | SimFrameRequestMessage
63
+
43
64
  export type SimHostMessage =
44
65
  | Readonly<{
45
66
  type: 'host.launch'
@@ -52,6 +73,10 @@ export type SimHostMessage =
52
73
  }>
53
74
  }>
54
75
  | Readonly<{ type: 'host.dispose'; payload: Readonly<Record<string, never>> }>
76
+ | Readonly<{
77
+ type: 'host.character.save.result'
78
+ payload: Readonly<{ requestId: string; saved: boolean; reason?: SimSaveFailure }>
79
+ }>
55
80
 
56
81
  export type SimBridgeFailure =
57
82
  | 'origin_refused'
@@ -168,6 +193,22 @@ export function parseFrameMessage(
168
193
  const code = SIM_FRAME_ERRORS.find((item) => item === body?.code)
169
194
  return code ? { ok: true, message: { type, payload: { code } } } : fail('invalid_payload')
170
195
  }
196
+ case 'frame.navigate': {
197
+ const body = strictRecord(payload, ['requestId', 'target'])
198
+ const requestId = boundedString(body?.requestId, REQUEST_ID)
199
+ const target = SIM_NAVIGATE_TARGETS.find((item) => item === body?.target)
200
+ return requestId && target
201
+ ? { ok: true, message: { type, payload: { requestId, target } } }
202
+ : fail('invalid_payload')
203
+ }
204
+ case 'frame.character.save': {
205
+ const body = strictRecord(payload, ['requestId', 'character'])
206
+ const requestId = boundedString(body?.requestId, REQUEST_ID)
207
+ const character = parseSimCharacterValue(body?.character)
208
+ return requestId && character
209
+ ? { ok: true, message: { type, payload: { requestId, character } } }
210
+ : fail('invalid_payload')
211
+ }
171
212
  default:
172
213
  return fail('unknown_type')
173
214
  }
@@ -203,7 +244,7 @@ export function parseHostMessage(
203
244
  const surface = SIM_SURFACES.find((item) => item === body?.surface)
204
245
  const expiresAt = body?.expiresAt
205
246
  const character =
206
- body && 'character' in body ? boundedString(body.character, CHARACTER_ID) : undefined
247
+ body && 'character' in body ? parseSimCharacterValue(body.character) : undefined
207
248
  if (
208
249
  !body ||
209
250
  !code ||
@@ -221,6 +262,21 @@ export function parseHostMessage(
221
262
  },
222
263
  }
223
264
  }
265
+ case 'host.character.save.result': {
266
+ const body = strictRecord(payload, ['requestId', 'saved'], ['reason'])
267
+ const requestId = boundedString(body?.requestId, REQUEST_ID)
268
+ if (!body || !requestId || typeof body.saved !== 'boolean') return fail('invalid_payload')
269
+ const reason = SIM_SAVE_FAILURES.find((item) => item === body.reason)
270
+ // A failure names its reason; a success has none.
271
+ if (body.saved ? 'reason' in body : !reason) return fail('invalid_payload')
272
+ return {
273
+ ok: true,
274
+ message: {
275
+ type,
276
+ payload: reason ? { requestId, saved: false, reason } : { requestId, saved: true },
277
+ },
278
+ }
279
+ }
224
280
  default:
225
281
  return fail('unknown_type')
226
282
  }
@@ -0,0 +1,87 @@
1
+ import { parseSimCharacterValue } from './character'
2
+ import type { SimFrameRequestMessage, SimHostMessage } from './messages'
3
+
4
+ /**
5
+ * Frame side of the acknowledged character save.
6
+ *
7
+ * `save` resolves `true` only when the host answers the very request it sent with
8
+ * `saved: true`. A refusal, a failure, a timeout, a host dispose, `dispose()`, a second save while
9
+ * one is open, an invalid value, a post that throws, and a late or mismatched answer all resolve
10
+ * `false`, so the engine never shows a saved value the host did not commit.
11
+ */
12
+
13
+ export type CharacterSaveClient = Readonly<{
14
+ save(character: string): Promise<boolean>
15
+ /** Feed every parsed host message in; unrelated ones are ignored. */
16
+ receive(message: SimHostMessage): void
17
+ /** Fails any open save. Call when the frame is torn down. */
18
+ dispose(): void
19
+ }>
20
+
21
+ export type CharacterSaveClientOptions = Readonly<{
22
+ post(message: SimFrameRequestMessage): void
23
+ /** Longer than the host's own timeout so the host's answer normally arrives first. */
24
+ timeoutMs?: number
25
+ mintRequestId?: () => string
26
+ schedule?: (run: () => void, ms: number) => () => void
27
+ }>
28
+
29
+ export const DEFAULT_SAVE_CLIENT_TIMEOUT_MS = 8_000
30
+
31
+ export function createCharacterSaveClient(
32
+ options: CharacterSaveClientOptions
33
+ ): CharacterSaveClient {
34
+ const timeoutMs = options.timeoutMs ?? DEFAULT_SAVE_CLIENT_TIMEOUT_MS
35
+ const schedule =
36
+ options.schedule ??
37
+ ((run, ms) => {
38
+ const id = setTimeout(run, ms)
39
+ return () => clearTimeout(id)
40
+ })
41
+ let counter = 0
42
+ const mint = options.mintRequestId ?? (() => `save${++counter}`)
43
+ let open: { requestId: string; settle(saved: boolean): void } | null = null
44
+ let disposed = false
45
+
46
+ function failOpen() {
47
+ open?.settle(false)
48
+ }
49
+
50
+ return {
51
+ save(character) {
52
+ if (disposed || open || parseSimCharacterValue(character) === null)
53
+ return Promise.resolve(false)
54
+ const requestId = mint()
55
+ return new Promise<boolean>((resolve) => {
56
+ const cancel = schedule(() => failOpen(), timeoutMs)
57
+ const entry = {
58
+ requestId,
59
+ settle(saved: boolean) {
60
+ if (open !== entry) return
61
+ open = null
62
+ cancel()
63
+ resolve(saved)
64
+ },
65
+ }
66
+ open = entry
67
+ try {
68
+ options.post({ type: 'frame.character.save', payload: { requestId, character } })
69
+ } catch {
70
+ entry.settle(false)
71
+ }
72
+ })
73
+ },
74
+ receive(message) {
75
+ if (message.type === 'host.dispose') failOpen()
76
+ else if (
77
+ message.type === 'host.character.save.result' &&
78
+ open?.requestId === message.payload.requestId
79
+ )
80
+ open.settle(message.payload.saved)
81
+ },
82
+ dispose() {
83
+ disposed = true
84
+ failOpen()
85
+ },
86
+ }
87
+ }