@helix3/helix-cli 0.1.14-helix3.155 → 0.1.14-helix3.157
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/README.md +3 -0
- package/dist/device.d.ts +38 -0
- package/dist/device.js +644 -0
- package/dist/device.js.map +1 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -1
- package/dist/lib.d.ts +1 -0
- package/dist/lib.js +11 -5
- package/dist/lib.js.map +1 -1
- package/dist/sceneElements/compiler.js +115 -20
- package/dist/sceneElements/compiler.js.map +1 -1
- package/dist/sceneElements/types.d.ts +26 -1
- package/dist/sceneImport/packageReader.js +3 -1
- package/dist/sceneImport/packageReader.js.map +1 -1
- package/dist/sceneImport/publish.d.ts +2 -0
- package/dist/sceneImport/publish.js +8 -3
- package/dist/sceneImport/publish.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -46,6 +46,9 @@ The token is stored in `~/.helix/credentials.json` (mode `600`). It's sent as
|
|
|
46
46
|
| `helix item list-slots [--json]` | Print the Character-Creator vocabulary a wearable declares: the cosmetic slot tags, and the genders. |
|
|
47
47
|
| `helix item publish <mesh.glb> --title <t> [--collectible-supply <n>] [initial distribution flags] [--dry-run\|--quote]` | Publish a Standard item by default, or one fixed-supply Collectible. The item definition is separate from acquisition routes. |
|
|
48
48
|
| `helix item distribution create\|list\|update\|disable …` | Manage Marketplace/World Claim or Buy routes without republishing the item or paying another publish fee. |
|
|
49
|
+
| `helix device create <dir> --id <slug> --title <t> --model <mesh.glb> --template standing-desk\|clock` | Scaffold a GLB-backed `helix.device/1` definition. There is intentionally no website Device editor. |
|
|
50
|
+
| `helix device validate [dir] [--json]` | Validate the definition, GLB bytes and named-node bindings, typed state/controls, capabilities, and Home+World compatibility. |
|
|
51
|
+
| `helix device test [dir] [--mode single\|multiplayer\|both] [--json]` | Exercise every interaction, persistence remount, and deterministic one-/two-client authority convergence. |
|
|
49
52
|
| `helix preview-video <slug> <youtube-url>` | Set or clear a published world's YouTube preview. |
|
|
50
53
|
| `helix thumbnail set <slug> <file>` | Upload a thumbnail or preview image through the CLI-owned media path. |
|
|
51
54
|
| `helix assets search\|get\|versions\|track\|install\|update` | Search the typed Vault with explainable ranking; inspect/manage annotations and immutable versions; install by durable ID with verified SHA-256 receipts. Material installs default to lean runtime KTX2 maps; use `--material-renditions source` for source PNGs or `all` for both families. |
|
package/dist/device.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export declare const DEVICE_FILENAME = "device.json";
|
|
2
|
+
type Scalar = number | string | boolean;
|
|
3
|
+
type JsonObject = Record<string, unknown>;
|
|
4
|
+
export interface DeviceValidationReport {
|
|
5
|
+
ok: boolean;
|
|
6
|
+
directory: string;
|
|
7
|
+
definitionPath: string;
|
|
8
|
+
modelPath: string | null;
|
|
9
|
+
nodeNames: string[];
|
|
10
|
+
errors: string[];
|
|
11
|
+
warnings: string[];
|
|
12
|
+
definition?: JsonObject;
|
|
13
|
+
}
|
|
14
|
+
export interface DeviceTestReport {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
mode: 'single' | 'multiplayer';
|
|
17
|
+
clients: number;
|
|
18
|
+
actionsExercised: string[];
|
|
19
|
+
finalState: Record<string, Scalar>;
|
|
20
|
+
persistentState: Record<string, Scalar>;
|
|
21
|
+
errors: string[];
|
|
22
|
+
}
|
|
23
|
+
export declare function readDeviceGlbNodeNames(file: string): string[];
|
|
24
|
+
export declare function validateDeviceProject(input: string): DeviceValidationReport;
|
|
25
|
+
export declare function testDeviceProject(input: string, mode: 'single' | 'multiplayer'): DeviceTestReport;
|
|
26
|
+
export declare function createDeviceProject(input: {
|
|
27
|
+
directory: string;
|
|
28
|
+
id: string;
|
|
29
|
+
title: string;
|
|
30
|
+
modelPath: string;
|
|
31
|
+
template: 'clock' | 'standing-desk';
|
|
32
|
+
}): {
|
|
33
|
+
directory: string;
|
|
34
|
+
definitionPath: string;
|
|
35
|
+
modelPath: string;
|
|
36
|
+
nodeNames: string[];
|
|
37
|
+
};
|
|
38
|
+
export {};
|
package/dist/device.js
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEVICE_FILENAME = void 0;
|
|
4
|
+
exports.readDeviceGlbNodeNames = readDeviceGlbNodeNames;
|
|
5
|
+
exports.validateDeviceProject = validateDeviceProject;
|
|
6
|
+
exports.testDeviceProject = testDeviceProject;
|
|
7
|
+
exports.createDeviceProject = createDeviceProject;
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_path_1 = require("node:path");
|
|
10
|
+
exports.DEVICE_FILENAME = 'device.json';
|
|
11
|
+
function object(value) {
|
|
12
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
13
|
+
? value
|
|
14
|
+
: {};
|
|
15
|
+
}
|
|
16
|
+
function scalar(value) {
|
|
17
|
+
return (typeof value === 'number' ||
|
|
18
|
+
typeof value === 'string' ||
|
|
19
|
+
typeof value === 'boolean');
|
|
20
|
+
}
|
|
21
|
+
function readDefinitionPath(input) {
|
|
22
|
+
const target = (0, node_path_1.resolve)(input);
|
|
23
|
+
if (target.endsWith('.json')) {
|
|
24
|
+
return { directory: (0, node_path_1.dirname)(target), path: target };
|
|
25
|
+
}
|
|
26
|
+
return { directory: target, path: (0, node_path_1.join)(target, exports.DEVICE_FILENAME) };
|
|
27
|
+
}
|
|
28
|
+
function glbJson(file) {
|
|
29
|
+
const bytes = (0, node_fs_1.readFileSync)(file);
|
|
30
|
+
if (bytes.length < 20 || bytes.readUInt32LE(0) !== 0x46546c67) {
|
|
31
|
+
throw new Error(`${(0, node_path_1.basename)(file)} is not a binary glTF (.glb)`);
|
|
32
|
+
}
|
|
33
|
+
if (bytes.readUInt32LE(4) !== 2) {
|
|
34
|
+
throw new Error(`${(0, node_path_1.basename)(file)} must use glTF 2`);
|
|
35
|
+
}
|
|
36
|
+
const declaredLength = bytes.readUInt32LE(8);
|
|
37
|
+
if (declaredLength !== bytes.length) {
|
|
38
|
+
throw new Error(`${(0, node_path_1.basename)(file)} length header is ${declaredLength}, actual ${bytes.length}`);
|
|
39
|
+
}
|
|
40
|
+
const jsonLength = bytes.readUInt32LE(12);
|
|
41
|
+
if (bytes.readUInt32LE(16) !== 0x4e4f534a || 20 + jsonLength > bytes.length) {
|
|
42
|
+
throw new Error(`${(0, node_path_1.basename)(file)} has no valid JSON chunk`);
|
|
43
|
+
}
|
|
44
|
+
return JSON.parse(bytes.subarray(20, 20 + jsonLength).toString('utf8'));
|
|
45
|
+
}
|
|
46
|
+
function readDeviceGlbNodeNames(file) {
|
|
47
|
+
const parsed = glbJson(file);
|
|
48
|
+
return [
|
|
49
|
+
...new Set((Array.isArray(parsed.nodes) ? parsed.nodes : [])
|
|
50
|
+
.map((node) => String(object(node).name ?? '').trim())
|
|
51
|
+
.filter(Boolean)),
|
|
52
|
+
].sort();
|
|
53
|
+
}
|
|
54
|
+
function stateDefault(spec) {
|
|
55
|
+
if (scalar(spec.default))
|
|
56
|
+
return spec.default;
|
|
57
|
+
const type = String(spec.type ?? '');
|
|
58
|
+
if (type === 'boolean')
|
|
59
|
+
return false;
|
|
60
|
+
if (type === 'enum')
|
|
61
|
+
return String((Array.isArray(spec.values) ? spec.values : [])[0] ?? '');
|
|
62
|
+
if (type === 'string')
|
|
63
|
+
return '';
|
|
64
|
+
return typeof spec.min === 'number' ? spec.min : 0;
|
|
65
|
+
}
|
|
66
|
+
function coerce(spec, value) {
|
|
67
|
+
const type = String(spec.type ?? '');
|
|
68
|
+
if (type === 'boolean')
|
|
69
|
+
return value === true;
|
|
70
|
+
if (type === 'string')
|
|
71
|
+
return String(value ?? '');
|
|
72
|
+
if (type === 'enum') {
|
|
73
|
+
const next = String(value ?? '');
|
|
74
|
+
const values = Array.isArray(spec.values) ? spec.values.map(String) : [];
|
|
75
|
+
return values.includes(next) ? next : String(values[0] ?? '');
|
|
76
|
+
}
|
|
77
|
+
let number = Number(value);
|
|
78
|
+
if (!Number.isFinite(number))
|
|
79
|
+
number = Number(stateDefault(spec));
|
|
80
|
+
if (type === 'integer')
|
|
81
|
+
number = Math.round(number);
|
|
82
|
+
if (typeof spec.min === 'number')
|
|
83
|
+
number = Math.max(spec.min, number);
|
|
84
|
+
if (typeof spec.max === 'number')
|
|
85
|
+
number = Math.min(spec.max, number);
|
|
86
|
+
return number;
|
|
87
|
+
}
|
|
88
|
+
function initialState(definition) {
|
|
89
|
+
return Object.fromEntries(Object.entries(object(definition.state)).map(([key, raw]) => [
|
|
90
|
+
key,
|
|
91
|
+
stateDefault(object(raw)),
|
|
92
|
+
]));
|
|
93
|
+
}
|
|
94
|
+
function expr(value, state) {
|
|
95
|
+
const node = object(value);
|
|
96
|
+
if (node.kind === 'lit' && scalar(node.value))
|
|
97
|
+
return node.value;
|
|
98
|
+
if (node.kind === 'state')
|
|
99
|
+
return state[String(node.key)] ?? 0;
|
|
100
|
+
const args = Array.isArray(node.args)
|
|
101
|
+
? node.args.map((part) => Number(expr(part, state)))
|
|
102
|
+
: [];
|
|
103
|
+
if (node.kind === 'add')
|
|
104
|
+
return args.reduce((a, b) => a + b, 0);
|
|
105
|
+
if (node.kind === 'sub')
|
|
106
|
+
return args.slice(1).reduce((a, b) => a - b, args[0] ?? 0);
|
|
107
|
+
if (node.kind === 'mul')
|
|
108
|
+
return args.reduce((a, b) => a * b, 1);
|
|
109
|
+
if (node.kind === 'min')
|
|
110
|
+
return Math.min(...args);
|
|
111
|
+
if (node.kind === 'max')
|
|
112
|
+
return Math.max(...args);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
function condition(value, state) {
|
|
116
|
+
const node = object(value);
|
|
117
|
+
if (node.op === 'true')
|
|
118
|
+
return true;
|
|
119
|
+
if (node.op === 'false')
|
|
120
|
+
return false;
|
|
121
|
+
if (node.op === 'not')
|
|
122
|
+
return !condition(node.of, state);
|
|
123
|
+
const list = Array.isArray(node.of) ? node.of : [];
|
|
124
|
+
if (node.op === 'and')
|
|
125
|
+
return list.every((part) => condition(part, state));
|
|
126
|
+
if (node.op === 'or')
|
|
127
|
+
return list.some((part) => condition(part, state));
|
|
128
|
+
const left = expr(node.left, state);
|
|
129
|
+
const right = expr(node.right, state);
|
|
130
|
+
if (node.op === 'eq')
|
|
131
|
+
return left === right;
|
|
132
|
+
if (node.op === 'ne')
|
|
133
|
+
return left !== right;
|
|
134
|
+
if (node.op === 'gt')
|
|
135
|
+
return left > right;
|
|
136
|
+
if (node.op === 'lt')
|
|
137
|
+
return left < right;
|
|
138
|
+
if (node.op === 'gte')
|
|
139
|
+
return left >= right;
|
|
140
|
+
if (node.op === 'lte')
|
|
141
|
+
return left <= right;
|
|
142
|
+
if (node.op === 'inRange') {
|
|
143
|
+
const number = Number(expr(node.value, state));
|
|
144
|
+
return (number >= Number(expr(node.min, state)) &&
|
|
145
|
+
number <= Number(expr(node.max, state)));
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
function applySteps(steps, definition, state, budget) {
|
|
150
|
+
if (!Array.isArray(steps))
|
|
151
|
+
return;
|
|
152
|
+
for (const raw of steps) {
|
|
153
|
+
if (--budget.remaining < 0)
|
|
154
|
+
throw new Error('behavior graph exceeded 2048 simulated steps');
|
|
155
|
+
const step = object(raw);
|
|
156
|
+
const kind = String(step.step ?? '');
|
|
157
|
+
if (kind === 'setState') {
|
|
158
|
+
const key = String(step.key ?? '');
|
|
159
|
+
const spec = object(object(definition.state)[key]);
|
|
160
|
+
if (!Object.keys(spec).length)
|
|
161
|
+
continue;
|
|
162
|
+
const value = step.value !== undefined
|
|
163
|
+
? expr(step.value, state)
|
|
164
|
+
: Number(state[key] ?? 0) + Number(expr(step.delta, state));
|
|
165
|
+
state[key] = coerce(spec, value);
|
|
166
|
+
}
|
|
167
|
+
else if (kind === 'toggleState') {
|
|
168
|
+
const key = String(step.key ?? '');
|
|
169
|
+
state[key] = !Boolean(state[key]);
|
|
170
|
+
}
|
|
171
|
+
else if (kind === 'branch') {
|
|
172
|
+
applySteps(condition(step.when, state) ? step.then : step.else, definition, state, budget);
|
|
173
|
+
}
|
|
174
|
+
else if (kind === 'sequence') {
|
|
175
|
+
applySteps(step.do, definition, state, budget);
|
|
176
|
+
}
|
|
177
|
+
else if (kind === 'repeat') {
|
|
178
|
+
const times = Math.max(0, Math.min(64, Number(step.times) || 0));
|
|
179
|
+
for (let index = 0; index < times; index += 1) {
|
|
180
|
+
applySteps(step.do, definition, state, budget);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else if (kind === 'delay') {
|
|
184
|
+
applySteps(step.then, definition, state, budget);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function invoke(definition, state, interaction, value) {
|
|
189
|
+
const control = object(interaction.control);
|
|
190
|
+
if (control.stateKey && value !== undefined) {
|
|
191
|
+
const key = String(control.stateKey);
|
|
192
|
+
state[key] = coerce(object(object(definition.state)[key]), value);
|
|
193
|
+
}
|
|
194
|
+
const triggers = Array.isArray(object(definition.graph).triggers)
|
|
195
|
+
? object(definition.graph).triggers
|
|
196
|
+
: [];
|
|
197
|
+
for (const raw of triggers) {
|
|
198
|
+
const trigger = object(raw);
|
|
199
|
+
if (trigger.on === 'interact' && trigger.interaction === interaction.id) {
|
|
200
|
+
applySteps(trigger.do, definition, state, { remaining: 2048 });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function exerciseValue(interaction, state) {
|
|
205
|
+
const control = object(interaction.control);
|
|
206
|
+
if (control.kind === 'number')
|
|
207
|
+
return Number(control.max ?? control.min ?? 1);
|
|
208
|
+
if (control.kind === 'choice') {
|
|
209
|
+
const options = Array.isArray(control.options)
|
|
210
|
+
? control.options.map(object)
|
|
211
|
+
: [];
|
|
212
|
+
const current = state[String(control.stateKey)];
|
|
213
|
+
return String(options.find((option) => !option.disabled && option.value !== current)
|
|
214
|
+
?.value ??
|
|
215
|
+
options.find((option) => !option.disabled)?.value ??
|
|
216
|
+
'');
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
function validateDeviceProject(input) {
|
|
221
|
+
const target = readDefinitionPath(input);
|
|
222
|
+
const errors = [];
|
|
223
|
+
const warnings = [];
|
|
224
|
+
let definition = {};
|
|
225
|
+
if (!(0, node_fs_1.existsSync)(target.path)) {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
directory: target.directory,
|
|
229
|
+
definitionPath: target.path,
|
|
230
|
+
modelPath: null,
|
|
231
|
+
nodeNames: [],
|
|
232
|
+
errors: [`Missing ${target.path}`],
|
|
233
|
+
warnings,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
definition = object(JSON.parse((0, node_fs_1.readFileSync)(target.path, 'utf8')));
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
241
|
+
}
|
|
242
|
+
if (definition.format !== 'helix.device' || definition.formatVersion !== 1) {
|
|
243
|
+
errors.push('format must be helix.device/1');
|
|
244
|
+
}
|
|
245
|
+
if (!/^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/.test(String(definition.id ?? ''))) {
|
|
246
|
+
errors.push('id must be a 3-50 character lowercase slug');
|
|
247
|
+
}
|
|
248
|
+
if (!/^\d+\.\d+\.\d+$/.test(String(definition.version ?? ''))) {
|
|
249
|
+
errors.push('version must be semver (for example 1.0.0)');
|
|
250
|
+
}
|
|
251
|
+
if (!String(definition.title ?? '').trim())
|
|
252
|
+
errors.push('title is required');
|
|
253
|
+
if (definition.script !== null)
|
|
254
|
+
errors.push('script must be null; Devices are declarative');
|
|
255
|
+
const authorityModes = [
|
|
256
|
+
'presentationOnly',
|
|
257
|
+
'clientPredicted',
|
|
258
|
+
'clientAuthoritative',
|
|
259
|
+
'serverValidated',
|
|
260
|
+
'serverAuthoritative',
|
|
261
|
+
];
|
|
262
|
+
if (!authorityModes.includes(String(definition.authority))) {
|
|
263
|
+
errors.push(`authority "${String(definition.authority)}" is unsupported`);
|
|
264
|
+
}
|
|
265
|
+
if (!['tiny', 'room', 'nearby', 'building', 'world'].includes(String(definition.activationPreset))) {
|
|
266
|
+
errors.push(`activationPreset "${String(definition.activationPreset)}" is unsupported`);
|
|
267
|
+
}
|
|
268
|
+
const model = object(definition.model);
|
|
269
|
+
let modelPath = null;
|
|
270
|
+
let nodeNames = [];
|
|
271
|
+
if (model.kind !== 'glb' || !String(model.url ?? '').trim()) {
|
|
272
|
+
errors.push('model.kind must be glb with a project-relative url');
|
|
273
|
+
}
|
|
274
|
+
else if (/^https?:\/\//.test(String(model.url))) {
|
|
275
|
+
warnings.push('remote model URL cannot be byte-verified locally; prefer a project GLB');
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
modelPath = (0, node_path_1.resolve)(target.directory, String(model.url));
|
|
279
|
+
if (!(0, node_fs_1.existsSync)(modelPath))
|
|
280
|
+
errors.push(`model GLB does not exist: ${modelPath}`);
|
|
281
|
+
else {
|
|
282
|
+
try {
|
|
283
|
+
nodeNames = readDeviceGlbNodeNames(modelPath);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const nodes = new Set(nodeNames);
|
|
291
|
+
for (const [semantic, authored] of Object.entries(object(model.nodeBindings))) {
|
|
292
|
+
if (!nodes.has(String(authored))) {
|
|
293
|
+
errors.push(`model.nodeBindings.${semantic} references missing GLB node "${String(authored)}"`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const state = object(definition.state);
|
|
297
|
+
if (Object.keys(state).length > 64)
|
|
298
|
+
errors.push('state exceeds the 64-variable Device budget');
|
|
299
|
+
for (const [key, raw] of Object.entries(state)) {
|
|
300
|
+
const spec = object(raw);
|
|
301
|
+
if (!['boolean', 'integer', 'float', 'string', 'enum'].includes(String(spec.type))) {
|
|
302
|
+
errors.push(`state.${key}.type "${String(spec.type)}" is unsupported`);
|
|
303
|
+
}
|
|
304
|
+
if (typeof spec.min === 'number' &&
|
|
305
|
+
typeof spec.max === 'number' &&
|
|
306
|
+
spec.max < spec.min) {
|
|
307
|
+
errors.push(`state.${key} has min > max`);
|
|
308
|
+
}
|
|
309
|
+
if (spec.type === 'enum' &&
|
|
310
|
+
(!Array.isArray(spec.values) || spec.values.length === 0)) {
|
|
311
|
+
errors.push(`state.${key} enum requires values`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const interactions = Array.isArray(definition.interactions)
|
|
315
|
+
? definition.interactions.map(object)
|
|
316
|
+
: [];
|
|
317
|
+
if (interactions.length > 16)
|
|
318
|
+
errors.push('interactions exceed the 16-action Device budget');
|
|
319
|
+
const interactionIds = new Set();
|
|
320
|
+
for (const interaction of interactions) {
|
|
321
|
+
const id = String(interaction.id ?? '');
|
|
322
|
+
if (!id || interactionIds.has(id))
|
|
323
|
+
errors.push(`interaction id "${id}" is missing or duplicated`);
|
|
324
|
+
interactionIds.add(id);
|
|
325
|
+
const control = object(interaction.control);
|
|
326
|
+
if (!control.kind)
|
|
327
|
+
continue;
|
|
328
|
+
const key = String(control.stateKey ?? '');
|
|
329
|
+
const spec = object(state[key]);
|
|
330
|
+
if (!Object.keys(spec).length)
|
|
331
|
+
errors.push(`interaction ${id} references unknown state "${key}"`);
|
|
332
|
+
if (control.kind === 'number') {
|
|
333
|
+
if (!['float', 'integer'].includes(String(spec.type)))
|
|
334
|
+
errors.push(`interaction ${id} number control must target numeric state`);
|
|
335
|
+
const min = Number(control.min ?? spec.min);
|
|
336
|
+
const max = Number(control.max ?? spec.max);
|
|
337
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min)
|
|
338
|
+
errors.push(`interaction ${id} requires finite max > min`);
|
|
339
|
+
}
|
|
340
|
+
else if (control.kind === 'choice') {
|
|
341
|
+
const options = Array.isArray(control.options)
|
|
342
|
+
? control.options.map(object)
|
|
343
|
+
: [];
|
|
344
|
+
if (!options.length)
|
|
345
|
+
errors.push(`interaction ${id} choice requires options`);
|
|
346
|
+
for (const option of options)
|
|
347
|
+
if (option.disabled && !String(option.reason ?? '').trim())
|
|
348
|
+
errors.push(`interaction ${id} disabled choice "${String(option.value)}" needs a reason`);
|
|
349
|
+
}
|
|
350
|
+
else
|
|
351
|
+
errors.push(`interaction ${id} uses unsupported control kind "${String(control.kind)}"`);
|
|
352
|
+
}
|
|
353
|
+
const components = Array.isArray(definition.components)
|
|
354
|
+
? definition.components.map(object)
|
|
355
|
+
: [];
|
|
356
|
+
if (components.length > 32)
|
|
357
|
+
errors.push('components exceed the 32-component Device budget');
|
|
358
|
+
const bindings = object(model.nodeBindings);
|
|
359
|
+
for (const component of components) {
|
|
360
|
+
const semanticNodes = [
|
|
361
|
+
'node',
|
|
362
|
+
'hourNode',
|
|
363
|
+
'minuteNode',
|
|
364
|
+
'secondNode',
|
|
365
|
+
'secondCounterweightNode',
|
|
366
|
+
];
|
|
367
|
+
for (const field of semanticNodes) {
|
|
368
|
+
if (!component[field])
|
|
369
|
+
continue;
|
|
370
|
+
const semantic = String(component[field]);
|
|
371
|
+
const authored = String(bindings[semantic] ?? semantic);
|
|
372
|
+
if (nodeNames.length && !nodes.has(authored))
|
|
373
|
+
errors.push(`component ${String(component.id)} ${field} resolves to missing GLB node "${authored}"`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const triggers = Array.isArray(object(definition.graph).triggers)
|
|
377
|
+
? object(definition.graph).triggers
|
|
378
|
+
: [];
|
|
379
|
+
if (triggers.length > 64)
|
|
380
|
+
errors.push('graph exceeds the 64-trigger Device budget');
|
|
381
|
+
for (const raw of triggers) {
|
|
382
|
+
const trigger = object(raw);
|
|
383
|
+
if (trigger.on === 'interact' &&
|
|
384
|
+
!interactionIds.has(String(trigger.interaction)))
|
|
385
|
+
errors.push(`graph references undeclared interaction "${String(trigger.interaction)}"`);
|
|
386
|
+
}
|
|
387
|
+
const hosts = Array.isArray(object(definition.compatibility).hosts)
|
|
388
|
+
? object(definition.compatibility).hosts
|
|
389
|
+
: [];
|
|
390
|
+
if (!hosts.includes('home') || !hosts.includes('world')) {
|
|
391
|
+
errors.push('compatibility.hosts must include both home and world for a universal Device');
|
|
392
|
+
}
|
|
393
|
+
const capabilities = new Set(Array.isArray(definition.capabilities)
|
|
394
|
+
? definition.capabilities.map(String)
|
|
395
|
+
: []);
|
|
396
|
+
const allowedCapabilities = new Set([
|
|
397
|
+
'model.glb',
|
|
398
|
+
'presentation.nodeMotion',
|
|
399
|
+
'clock.serverTime',
|
|
400
|
+
'state.typed',
|
|
401
|
+
'state.durable',
|
|
402
|
+
'room.authority',
|
|
403
|
+
'audio.playback',
|
|
404
|
+
'signals.local',
|
|
405
|
+
]);
|
|
406
|
+
for (const capability of capabilities) {
|
|
407
|
+
if (!allowedCapabilities.has(capability)) {
|
|
408
|
+
errors.push(`capability "${capability}" is unsupported`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
for (const required of ['model.glb', 'state.typed']) {
|
|
412
|
+
if (!capabilities.has(required))
|
|
413
|
+
errors.push(`capabilities must declare ${required}`);
|
|
414
|
+
}
|
|
415
|
+
if (['serverValidated', 'serverAuthoritative'].includes(String(definition.authority)) &&
|
|
416
|
+
!capabilities.has('room.authority')) {
|
|
417
|
+
errors.push('server authority requires capability room.authority');
|
|
418
|
+
}
|
|
419
|
+
for (const required of Array.isArray(object(definition.compatibility).requiredCapabilities)
|
|
420
|
+
? object(definition.compatibility).requiredCapabilities
|
|
421
|
+
: []) {
|
|
422
|
+
if (!capabilities.has(String(required))) {
|
|
423
|
+
errors.push(`compatibility requires undeclared capability "${String(required)}"`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const migrationStarts = new Set();
|
|
427
|
+
for (const raw of Array.isArray(definition.stateMigrations)
|
|
428
|
+
? definition.stateMigrations
|
|
429
|
+
: []) {
|
|
430
|
+
const migration = object(raw);
|
|
431
|
+
const from = Number(migration.from);
|
|
432
|
+
const to = Number(migration.to);
|
|
433
|
+
if (!Number.isInteger(from) || to !== from + 1) {
|
|
434
|
+
errors.push(`state migration ${from}->${to} must advance by one`);
|
|
435
|
+
}
|
|
436
|
+
if (migrationStarts.has(from)) {
|
|
437
|
+
errors.push(`state migrations duplicate from=${from}`);
|
|
438
|
+
}
|
|
439
|
+
migrationStarts.add(from);
|
|
440
|
+
}
|
|
441
|
+
for (const key of Array.isArray(object(definition.thumbnail).stateKeys)
|
|
442
|
+
? object(definition.thumbnail).stateKeys
|
|
443
|
+
: []) {
|
|
444
|
+
if (!state[String(key)]) {
|
|
445
|
+
errors.push(`thumbnail references unknown state "${String(key)}"`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
ok: errors.length === 0,
|
|
450
|
+
directory: target.directory,
|
|
451
|
+
definitionPath: target.path,
|
|
452
|
+
modelPath,
|
|
453
|
+
nodeNames,
|
|
454
|
+
errors,
|
|
455
|
+
warnings,
|
|
456
|
+
definition: errors.length === 0 ? definition : undefined,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function testDeviceProject(input, mode) {
|
|
460
|
+
const validation = validateDeviceProject(input);
|
|
461
|
+
if (!validation.ok || !validation.definition) {
|
|
462
|
+
return {
|
|
463
|
+
ok: false,
|
|
464
|
+
mode,
|
|
465
|
+
clients: mode === 'multiplayer' ? 2 : 1,
|
|
466
|
+
actionsExercised: [],
|
|
467
|
+
finalState: {},
|
|
468
|
+
persistentState: {},
|
|
469
|
+
errors: validation.errors,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
const definition = validation.definition;
|
|
473
|
+
const authority = initialState(definition);
|
|
474
|
+
const clientA = { ...authority };
|
|
475
|
+
const clientB = { ...authority };
|
|
476
|
+
const actions = Array.isArray(definition.interactions)
|
|
477
|
+
? definition.interactions.map(object)
|
|
478
|
+
: [];
|
|
479
|
+
const exercised = [];
|
|
480
|
+
const errors = [];
|
|
481
|
+
for (const interaction of actions) {
|
|
482
|
+
const value = exerciseValue(interaction, authority);
|
|
483
|
+
invoke(definition, authority, interaction, value);
|
|
484
|
+
Object.assign(clientA, authority);
|
|
485
|
+
if (mode === 'multiplayer')
|
|
486
|
+
Object.assign(clientB, authority);
|
|
487
|
+
exercised.push(String(interaction.id));
|
|
488
|
+
if (JSON.stringify(clientA) !== JSON.stringify(authority))
|
|
489
|
+
errors.push(`client A diverged after ${String(interaction.id)}`);
|
|
490
|
+
if (mode === 'multiplayer' &&
|
|
491
|
+
JSON.stringify(clientB) !== JSON.stringify(authority))
|
|
492
|
+
errors.push(`client B diverged after ${String(interaction.id)}`);
|
|
493
|
+
}
|
|
494
|
+
const stateSchema = object(definition.state);
|
|
495
|
+
const persistentState = Object.fromEntries(Object.entries(authority).filter(([key]) => object(stateSchema[key]).persist === true));
|
|
496
|
+
const remounted = initialState(definition);
|
|
497
|
+
for (const [key, value] of Object.entries(persistentState))
|
|
498
|
+
remounted[key] = coerce(object(stateSchema[key]), value);
|
|
499
|
+
for (const [key, value] of Object.entries(persistentState)) {
|
|
500
|
+
if (remounted[key] !== value)
|
|
501
|
+
errors.push(`persistent state ${key} did not round-trip`);
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
ok: errors.length === 0,
|
|
505
|
+
mode,
|
|
506
|
+
clients: mode === 'multiplayer' ? 2 : 1,
|
|
507
|
+
actionsExercised: exercised,
|
|
508
|
+
finalState: authority,
|
|
509
|
+
persistentState,
|
|
510
|
+
errors,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
function clockDefinition(id, title) {
|
|
514
|
+
return {
|
|
515
|
+
format: 'helix.device',
|
|
516
|
+
formatVersion: 1,
|
|
517
|
+
id,
|
|
518
|
+
version: '1.0.0',
|
|
519
|
+
title,
|
|
520
|
+
model: {
|
|
521
|
+
kind: 'glb',
|
|
522
|
+
url: './model.glb',
|
|
523
|
+
nodeBindings: {
|
|
524
|
+
hourHand: 'hourHand',
|
|
525
|
+
minuteHand: 'minuteHand',
|
|
526
|
+
secondHand: 'secondHand',
|
|
527
|
+
secondCounterweight: 'secondCounterweight',
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
state: {},
|
|
531
|
+
interactions: [],
|
|
532
|
+
components: [
|
|
533
|
+
{
|
|
534
|
+
id: 'hands',
|
|
535
|
+
type: 'clockHands',
|
|
536
|
+
hourNode: 'hourHand',
|
|
537
|
+
minuteNode: 'minuteHand',
|
|
538
|
+
secondNode: 'secondHand',
|
|
539
|
+
secondCounterweightNode: 'secondCounterweight',
|
|
540
|
+
timeZone: 'local',
|
|
541
|
+
},
|
|
542
|
+
],
|
|
543
|
+
graph: { triggers: [] },
|
|
544
|
+
script: null,
|
|
545
|
+
activationPreset: 'room',
|
|
546
|
+
authority: 'presentationOnly',
|
|
547
|
+
capabilities: ['model.glb', 'state.typed', 'clock.serverTime'],
|
|
548
|
+
stateVersion: 1,
|
|
549
|
+
compatibility: {
|
|
550
|
+
hosts: ['home', 'world'],
|
|
551
|
+
requiredCapabilities: ['model.glb', 'clock.serverTime'],
|
|
552
|
+
},
|
|
553
|
+
thumbnail: { policy: 'never' },
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function deskDefinition(id, title) {
|
|
557
|
+
return {
|
|
558
|
+
format: 'helix.device',
|
|
559
|
+
formatVersion: 1,
|
|
560
|
+
id,
|
|
561
|
+
version: '1.0.0',
|
|
562
|
+
title,
|
|
563
|
+
model: { kind: 'glb', url: './model.glb', nodeBindings: { top: 'top' } },
|
|
564
|
+
state: {
|
|
565
|
+
height: {
|
|
566
|
+
type: 'float',
|
|
567
|
+
min: 0.62,
|
|
568
|
+
max: 1.25,
|
|
569
|
+
default: 0.74,
|
|
570
|
+
persist: true,
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
interactions: [
|
|
574
|
+
{
|
|
575
|
+
id: 'setHeight',
|
|
576
|
+
label: 'Adjust height',
|
|
577
|
+
verb: 'use',
|
|
578
|
+
control: {
|
|
579
|
+
kind: 'number',
|
|
580
|
+
stateKey: 'height',
|
|
581
|
+
min: 0.62,
|
|
582
|
+
max: 1.25,
|
|
583
|
+
step: 0.01,
|
|
584
|
+
unit: 'm',
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
],
|
|
588
|
+
components: [
|
|
589
|
+
{
|
|
590
|
+
id: 'topLift',
|
|
591
|
+
type: 'slider',
|
|
592
|
+
node: 'top',
|
|
593
|
+
axis: 'y',
|
|
594
|
+
stateKey: 'height',
|
|
595
|
+
minOffsetM: 0.62,
|
|
596
|
+
maxOffsetM: 1.25,
|
|
597
|
+
durationMs: 900,
|
|
598
|
+
easing: 'easeOutCubic',
|
|
599
|
+
},
|
|
600
|
+
],
|
|
601
|
+
graph: { triggers: [] },
|
|
602
|
+
script: null,
|
|
603
|
+
activationPreset: 'room',
|
|
604
|
+
authority: 'serverValidated',
|
|
605
|
+
capabilities: [
|
|
606
|
+
'model.glb',
|
|
607
|
+
'state.typed',
|
|
608
|
+
'state.durable',
|
|
609
|
+
'presentation.nodeMotion',
|
|
610
|
+
'room.authority',
|
|
611
|
+
],
|
|
612
|
+
stateVersion: 1,
|
|
613
|
+
compatibility: {
|
|
614
|
+
hosts: ['home', 'world'],
|
|
615
|
+
requiredCapabilities: [
|
|
616
|
+
'model.glb',
|
|
617
|
+
'state.typed',
|
|
618
|
+
'presentation.nodeMotion',
|
|
619
|
+
'room.authority',
|
|
620
|
+
],
|
|
621
|
+
},
|
|
622
|
+
thumbnail: { policy: 'discreteState', stateKeys: ['height'] },
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
function createDeviceProject(input) {
|
|
626
|
+
const directory = (0, node_path_1.resolve)(input.directory);
|
|
627
|
+
const sourceModel = (0, node_path_1.resolve)(input.modelPath);
|
|
628
|
+
if (!(0, node_fs_1.existsSync)(sourceModel))
|
|
629
|
+
throw new Error(`Model does not exist: ${sourceModel}`);
|
|
630
|
+
const nodeNames = readDeviceGlbNodeNames(sourceModel);
|
|
631
|
+
(0, node_fs_1.mkdirSync)(directory, { recursive: true });
|
|
632
|
+
const definitionPath = (0, node_path_1.join)(directory, exports.DEVICE_FILENAME);
|
|
633
|
+
const modelPath = (0, node_path_1.join)(directory, 'model.glb');
|
|
634
|
+
if ((0, node_fs_1.existsSync)(definitionPath) || (0, node_fs_1.existsSync)(modelPath)) {
|
|
635
|
+
throw new Error(`Refusing to overwrite an existing Device project in ${directory}`);
|
|
636
|
+
}
|
|
637
|
+
const definition = input.template === 'clock'
|
|
638
|
+
? clockDefinition(input.id, input.title)
|
|
639
|
+
: deskDefinition(input.id, input.title);
|
|
640
|
+
(0, node_fs_1.copyFileSync)(sourceModel, modelPath);
|
|
641
|
+
(0, node_fs_1.writeFileSync)(definitionPath, `${JSON.stringify(definition, null, 2)}\n`);
|
|
642
|
+
return { directory, definitionPath, modelPath, nodeNames };
|
|
643
|
+
}
|
|
644
|
+
//# sourceMappingURL=device.js.map
|