@adea-ai/spatial 0.95.0 → 0.97.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 +3 -1
- package/dist/index.js +7 -2
- package/dist/sim-bridge/availability.d.ts +2 -2
- package/dist/sim-bridge/character.d.ts +17 -0
- package/dist/sim-bridge/character.js +49 -0
- package/dist/sim-bridge/index.d.ts +2 -0
- package/dist/sim-bridge/index.js +2 -0
- package/dist/sim-bridge/messages.d.ts +29 -1
- package/dist/sim-bridge/messages.js +39 -2
- package/dist/sim-bridge/save-client.d.ts +25 -0
- package/dist/sim-bridge/save-client.js +55 -0
- package/package.json +2 -2
- package/src/index.ts +7 -2
- package/src/sim-bridge/availability.ts +7 -3
- package/src/sim-bridge/character.ts +53 -0
- package/src/sim-bridge/index.ts +2 -0
- package/src/sim-bridge/messages.ts +59 -3
- package/src/sim-bridge/save-client.ts +87 -0
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
|
-
|
|
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 {
|
|
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:
|
|
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
|
+
}
|
package/dist/sim-bridge/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 ?
|
|
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.
|
|
3
|
+
"version": "0.97.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": "
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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(
|
|
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
|
+
}
|
package/src/sim-bridge/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 ?
|
|
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
|
+
}
|