@helix3/helix-cli 0.1.16-helix3.249 → 0.1.16-helix3.250
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 +1 -1
- package/dist/device.d.ts +1 -1
- package/dist/device.js +371 -2
- package/dist/device.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/device-hand-reach.md +44 -0
- package/package.json +2 -1
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_abs.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_expression.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_jbeam.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_materials.cpython-312.pyc +0 -0
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ The token is stored in `~/.helix/credentials.json` (mode `600`). It's sent as
|
|
|
49
49
|
| `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. |
|
|
50
50
|
| `helix item distribution create\|list\|update\|disable …` | Manage Marketplace/World Claim or Buy routes without republishing the item or paying another publish fee. |
|
|
51
51
|
| `helix device create <dir> --id <slug> --title <t> --model <mesh.glb> --template standing-desk\|clock\|television` | Scaffold a GLB-backed `helix.device/1` definition, including the interactive TV/media-player template. There is intentionally no website Device editor. |
|
|
52
|
-
| `helix device validate [dir] [--json]` | Validate the definition, GLB bytes and named-node bindings, typed state/controls, capabilities, and Home+World compatibility. |
|
|
52
|
+
| `helix device validate [dir] [--json]` | Validate the definition, GLB bytes and named-node bindings, typed state/controls, capabilities, and Home+World compatibility, including [declarative character hand reach](docs/device-hand-reach.md). |
|
|
53
53
|
| `helix device test [dir] [--mode single\|multiplayer\|both] [--json]` | Exercise every interaction, persistence remount, and deterministic one-/two-client authority convergence. |
|
|
54
54
|
| `helix world scene-elements compile <input> --output <scene>` | Compile typed Scene v2 authoring, including exact Continuum Device pins, instances, and locally validated typed links. See [`examples/scene-elements-devices.json`](examples/scene-elements-devices.json). |
|
|
55
55
|
| `helix preview-video <slug> <youtube-url>` | Set or clear a published world's YouTube preview. |
|
package/dist/device.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ export declare const DEVICE_COMPONENT_TYPES: readonly ["slider", "clockHands", "
|
|
|
42
42
|
* `repeat`, `stopAudio`) — their absence from the examples is why the gap went
|
|
43
43
|
* unnoticed for so long.
|
|
44
44
|
*/
|
|
45
|
-
export declare const DEVICE_GRAPH_STEP_KINDS: readonly ["setState", "toggleState", "moveNode", "rotateNode", "playAudio", "stopAudio", "setVfx", "attachCharacter", "playCharacterAnimation", "releaseCharacter", "enterCharacterSpot", "attachItem", "releaseItem", "emitSignal", "branch", "sequence", "delay", "repeat"];
|
|
45
|
+
export declare const DEVICE_GRAPH_STEP_KINDS: readonly ["setState", "toggleState", "moveNode", "rotateNode", "playAudio", "stopAudio", "setVfx", "attachCharacter", "playCharacterAnimation", "releaseCharacter", "enterCharacterSpot", "reachCharacterHand", "releaseCharacterHand", "attachItem", "releaseItem", "emitSignal", "branch", "sequence", "delay", "repeat"];
|
|
46
46
|
/**
|
|
47
47
|
* The closed CHARACTER socket set an `attachItem` step may target.
|
|
48
48
|
*
|
package/dist/device.js
CHANGED
|
@@ -117,6 +117,8 @@ exports.DEVICE_GRAPH_STEP_KINDS = [
|
|
|
117
117
|
'playCharacterAnimation',
|
|
118
118
|
'releaseCharacter',
|
|
119
119
|
'enterCharacterSpot',
|
|
120
|
+
'reachCharacterHand',
|
|
121
|
+
'releaseCharacterHand',
|
|
120
122
|
'attachItem',
|
|
121
123
|
'releaseItem',
|
|
122
124
|
'emitSignal',
|
|
@@ -162,6 +164,8 @@ const COMPONENT_TYPES = new Set(exports.DEVICE_COMPONENT_TYPES);
|
|
|
162
164
|
const GRAPH_STEP_KINDS = new Set(exports.DEVICE_GRAPH_STEP_KINDS);
|
|
163
165
|
const TRIGGER_KINDS = new Set(exports.DEVICE_TRIGGER_KINDS);
|
|
164
166
|
const HAND_SOCKETS = new Set(exports.DEVICE_HAND_SOCKETS);
|
|
167
|
+
const CHARACTER_HANDS = new Set(['left', 'right']);
|
|
168
|
+
const CHARACTER_HAND_ELBOWS = new Set(['down', 'out', 'up']);
|
|
165
169
|
const DEVICE_HOST_SET = new Set(exports.DEVICE_HOSTS);
|
|
166
170
|
const THUMBNAIL_POLICIES = new Set(exports.DEVICE_THUMBNAIL_POLICIES);
|
|
167
171
|
/**
|
|
@@ -183,6 +187,8 @@ const DEVICE_BUDGETS = {
|
|
|
183
187
|
maxThumbnailStateKeys: 16,
|
|
184
188
|
maxScreens: 4,
|
|
185
189
|
maxPointLights: 4,
|
|
190
|
+
maxStepsPerFire: 256,
|
|
191
|
+
maxConcurrentDelays: 8,
|
|
186
192
|
};
|
|
187
193
|
const DEVICE_EASINGS = new Set(['linear', 'easeOutCubic', 'easeInOutCubic']);
|
|
188
194
|
const DEVICE_GRAPH_EXPR_KINDS = new Set([
|
|
@@ -348,7 +354,7 @@ const DEVICE_GRAPH_MAX_BRANCH_DEPTH = 8;
|
|
|
348
354
|
/**
|
|
349
355
|
* Validate one behavior-graph step list, recursing into every nested list.
|
|
350
356
|
*
|
|
351
|
-
* The client's `stepSchema` (`validate.ts:133-222`) is a union
|
|
357
|
+
* The client's `stepSchema` (`validate.ts:133-222`) is a closed union and
|
|
352
358
|
* this walks all of them; validating only the four character steps let ten
|
|
353
359
|
* kinds — including `moveNode`, `setVfx`, `emitSignal` and `delay` — ship
|
|
354
360
|
* entirely unchecked, so a missing `durationMs` or a `seconds: 4000` delay
|
|
@@ -520,6 +526,83 @@ function validateGraphSteps(steps, context, path, depth) {
|
|
|
520
526
|
}
|
|
521
527
|
return;
|
|
522
528
|
}
|
|
529
|
+
case 'reachCharacterHand': {
|
|
530
|
+
context.usage.hand = true;
|
|
531
|
+
const hand = String(step.hand ?? '');
|
|
532
|
+
if (!CHARACTER_HANDS.has(hand)) {
|
|
533
|
+
context.errors.push(`${stepPath}: reachCharacterHand hand must be left or right`);
|
|
534
|
+
}
|
|
535
|
+
if (!step.target ||
|
|
536
|
+
typeof step.target !== 'object' ||
|
|
537
|
+
Array.isArray(step.target)) {
|
|
538
|
+
context.errors.push(`${stepPath}: reachCharacterHand target must be an object with exactly one of node or socket`);
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
const target = object(step.target);
|
|
542
|
+
const keys = Object.keys(target);
|
|
543
|
+
const hasNode = Object.prototype.hasOwnProperty.call(target, 'node');
|
|
544
|
+
const hasSocket = Object.prototype.hasOwnProperty.call(target, 'socket');
|
|
545
|
+
if (hasNode === hasSocket ||
|
|
546
|
+
keys.some((key) => key !== 'node' && key !== 'socket')) {
|
|
547
|
+
context.errors.push(`${stepPath}: reachCharacterHand target must declare exactly one of node or socket`);
|
|
548
|
+
}
|
|
549
|
+
else if (hasNode) {
|
|
550
|
+
if (!isNonEmptyName(target.node)) {
|
|
551
|
+
context.errors.push(`${stepPath}: reachCharacterHand target.node must be a non-empty name`);
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
const semantic = String(target.node);
|
|
555
|
+
if (!context.nodesKnown &&
|
|
556
|
+
!Object.prototype.hasOwnProperty.call(context.bindings, semantic)) {
|
|
557
|
+
context.errors.push(`${stepPath}.target.node must name an authored model.nodeBindings entry when GLB bytes are unavailable`);
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
graphNode(context, target.node, `${stepPath}.target.node`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
else if (!isNonEmptyName(target.socket)) {
|
|
565
|
+
context.errors.push(`${stepPath}: reachCharacterHand target.socket must be a non-empty name`);
|
|
566
|
+
}
|
|
567
|
+
else if (!context.sockets.has(String(target.socket))) {
|
|
568
|
+
context.errors.push(`${stepPath}: reachCharacterHand references unknown Device socket "${String(target.socket)}"`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (!isFiniteNumber(step.engageDistanceM) ||
|
|
572
|
+
step.engageDistanceM < 0.1 ||
|
|
573
|
+
step.engageDistanceM > 2) {
|
|
574
|
+
context.errors.push(`${stepPath}: reachCharacterHand engageDistanceM must be between 0.1 and 2`);
|
|
575
|
+
}
|
|
576
|
+
if (!isFiniteNumber(step.releaseDistanceM) ||
|
|
577
|
+
step.releaseDistanceM > 3 ||
|
|
578
|
+
(isFiniteNumber(step.engageDistanceM) &&
|
|
579
|
+
step.releaseDistanceM < step.engageDistanceM)) {
|
|
580
|
+
context.errors.push(`${stepPath}: reachCharacterHand releaseDistanceM must be at least engageDistanceM and no more than 3`);
|
|
581
|
+
}
|
|
582
|
+
if (step.maxAngleDeg !== undefined &&
|
|
583
|
+
(!isFiniteNumber(step.maxAngleDeg) ||
|
|
584
|
+
step.maxAngleDeg < 1 ||
|
|
585
|
+
step.maxAngleDeg > 180)) {
|
|
586
|
+
context.errors.push(`${stepPath}: reachCharacterHand maxAngleDeg must be between 1 and 180`);
|
|
587
|
+
}
|
|
588
|
+
if (step.elbow !== undefined &&
|
|
589
|
+
!CHARACTER_HAND_ELBOWS.has(String(step.elbow))) {
|
|
590
|
+
context.errors.push(`${stepPath}: reachCharacterHand elbow must be down, out or up`);
|
|
591
|
+
}
|
|
592
|
+
if (step.orientMatch !== undefined &&
|
|
593
|
+
typeof step.orientMatch !== 'boolean') {
|
|
594
|
+
context.errors.push(`${stepPath}: reachCharacterHand orientMatch must be boolean`);
|
|
595
|
+
}
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
case 'releaseCharacterHand': {
|
|
599
|
+
context.usage.hand = true;
|
|
600
|
+
const hand = String(step.hand ?? '');
|
|
601
|
+
if (!CHARACTER_HANDS.has(hand)) {
|
|
602
|
+
context.errors.push(`${stepPath}: releaseCharacterHand hand must be left or right`);
|
|
603
|
+
}
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
523
606
|
case 'attachItem': {
|
|
524
607
|
// `attachItem` puts the Device's OWN model on the acting character's
|
|
525
608
|
// hand, so it is a hand power rather than an animation one. Its
|
|
@@ -620,6 +703,267 @@ function validateGraphSteps(steps, context, path, depth) {
|
|
|
620
703
|
}
|
|
621
704
|
});
|
|
622
705
|
}
|
|
706
|
+
/**
|
|
707
|
+
* Follow every bounded graph branch to prove that a reach cannot outlive the
|
|
708
|
+
* trigger execution path that created it. A later, unrelated trigger is not a
|
|
709
|
+
* release guarantee: it may never fire. An orphan release is intentionally a
|
|
710
|
+
* no-op so cleanup steps remain idempotent.
|
|
711
|
+
*/
|
|
712
|
+
function handReachPathEnds(steps, starts = [new Set()], depth = 0) {
|
|
713
|
+
const context = {
|
|
714
|
+
nextRelationId: 1,
|
|
715
|
+
relations: new WeakMap(),
|
|
716
|
+
};
|
|
717
|
+
const endings = handUnitEnds(steps, starts.map(handSetToMask), depth, context);
|
|
718
|
+
return endings.map(handMaskToSet);
|
|
719
|
+
}
|
|
720
|
+
function handUnitEnds(steps, starts, depth, context) {
|
|
721
|
+
const paths = walkHandSteps(steps, starts.map((state) => ({ state, delays: [] })), depth, context);
|
|
722
|
+
return dedupeHandMasks(paths.flatMap((path) => finishHandDelays(path.state, path.delays)));
|
|
723
|
+
}
|
|
724
|
+
function walkHandSteps(steps, starts, depth, context) {
|
|
725
|
+
if (!Array.isArray(steps) || depth > DEVICE_GRAPH_MAX_BRANCH_DEPTH) {
|
|
726
|
+
return starts.map(cloneHandPath);
|
|
727
|
+
}
|
|
728
|
+
let paths = starts.map(cloneHandPath);
|
|
729
|
+
for (const raw of steps) {
|
|
730
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
731
|
+
continue;
|
|
732
|
+
const step = object(raw);
|
|
733
|
+
const kind = String(step.step ?? '');
|
|
734
|
+
if (kind === 'reachCharacterHand') {
|
|
735
|
+
const bit = handBit(step.hand);
|
|
736
|
+
if (bit !== 0)
|
|
737
|
+
for (const path of paths)
|
|
738
|
+
path.state |= bit;
|
|
739
|
+
}
|
|
740
|
+
else if (kind === 'releaseCharacterHand') {
|
|
741
|
+
const bit = handBit(step.hand);
|
|
742
|
+
if (bit !== 0)
|
|
743
|
+
for (const path of paths)
|
|
744
|
+
path.state &= ~bit;
|
|
745
|
+
}
|
|
746
|
+
else if (kind === 'branch') {
|
|
747
|
+
const branched = [];
|
|
748
|
+
const condition = object(step.when);
|
|
749
|
+
const arms = condition.op === 'true'
|
|
750
|
+
? [step.then]
|
|
751
|
+
: condition.op === 'false'
|
|
752
|
+
? [step.else ?? []]
|
|
753
|
+
: [step.then, step.else ?? []];
|
|
754
|
+
for (const path of paths) {
|
|
755
|
+
for (const arm of arms) {
|
|
756
|
+
branched.push(...walkHandSteps(arm, [path], depth + 1, context));
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
paths = dedupeHandPaths(branched);
|
|
760
|
+
}
|
|
761
|
+
else if (kind === 'sequence') {
|
|
762
|
+
paths = walkHandSteps(step.do, paths, depth + 1, context);
|
|
763
|
+
}
|
|
764
|
+
else if (kind === 'delay') {
|
|
765
|
+
const relation = handRelation(step.then, depth + 1, context);
|
|
766
|
+
for (const path of paths) {
|
|
767
|
+
// The runtime silently drops delay nine while eight are pending. The
|
|
768
|
+
// metric validator rejects that shape; mirroring the drop here keeps
|
|
769
|
+
// path analysis faithful even for an already-invalid graph.
|
|
770
|
+
if (path.delays.length < DEVICE_BUDGETS.maxConcurrentDelays) {
|
|
771
|
+
path.delays.push(relation);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
else if (kind === 'repeat' &&
|
|
776
|
+
Number.isInteger(step.times) &&
|
|
777
|
+
Number(step.times) > 0) {
|
|
778
|
+
for (let index = 0; index < Number(step.times); index += 1) {
|
|
779
|
+
paths = walkHandSteps(step.do, paths, depth + 1, context);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return dedupeHandPaths(paths);
|
|
784
|
+
}
|
|
785
|
+
function dedupeHandPaths(paths) {
|
|
786
|
+
const unique = new Map();
|
|
787
|
+
for (const path of paths) {
|
|
788
|
+
const key = `${path.state}:${path.delays.map((delay) => delay.id).join(',')}`;
|
|
789
|
+
if (!unique.has(key))
|
|
790
|
+
unique.set(key, cloneHandPath(path));
|
|
791
|
+
}
|
|
792
|
+
return [...unique.values()];
|
|
793
|
+
}
|
|
794
|
+
function handRelation(steps, depth, context) {
|
|
795
|
+
if (steps && typeof steps === 'object') {
|
|
796
|
+
const cached = context.relations.get(steps);
|
|
797
|
+
if (cached)
|
|
798
|
+
return cached;
|
|
799
|
+
}
|
|
800
|
+
const relation = {
|
|
801
|
+
id: context.nextRelationId++,
|
|
802
|
+
outputs: [new Set(), new Set(), new Set(), new Set()],
|
|
803
|
+
};
|
|
804
|
+
if (steps && typeof steps === 'object')
|
|
805
|
+
context.relations.set(steps, relation);
|
|
806
|
+
for (let state = 0; state < 4; state += 1) {
|
|
807
|
+
relation.outputs[state] = new Set(handUnitEnds(steps, [state], depth, context));
|
|
808
|
+
}
|
|
809
|
+
return relation;
|
|
810
|
+
}
|
|
811
|
+
function finishHandDelays(state, delays) {
|
|
812
|
+
if (delays.length === 0)
|
|
813
|
+
return [state];
|
|
814
|
+
const full = (1 << delays.length) - 1;
|
|
815
|
+
const seen = new Set();
|
|
816
|
+
const endings = new Set();
|
|
817
|
+
const visit = (mask, current) => {
|
|
818
|
+
const key = `${mask}:${current}`;
|
|
819
|
+
if (seen.has(key))
|
|
820
|
+
return;
|
|
821
|
+
seen.add(key);
|
|
822
|
+
if (mask === full) {
|
|
823
|
+
endings.add(current);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
delays.forEach((relation, index) => {
|
|
827
|
+
const bit = 1 << index;
|
|
828
|
+
if (mask & bit)
|
|
829
|
+
return;
|
|
830
|
+
for (const next of relation.outputs[current] ?? []) {
|
|
831
|
+
visit(mask | bit, next);
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
};
|
|
835
|
+
visit(0, state);
|
|
836
|
+
return [...endings];
|
|
837
|
+
}
|
|
838
|
+
function handBit(hand) {
|
|
839
|
+
return hand === 'left' ? 1 : hand === 'right' ? 2 : 0;
|
|
840
|
+
}
|
|
841
|
+
function handSetToMask(hands) {
|
|
842
|
+
let mask = 0;
|
|
843
|
+
for (const hand of hands)
|
|
844
|
+
mask |= handBit(hand);
|
|
845
|
+
return mask;
|
|
846
|
+
}
|
|
847
|
+
function handMaskToSet(mask) {
|
|
848
|
+
const hands = new Set();
|
|
849
|
+
if (mask & 1)
|
|
850
|
+
hands.add('left');
|
|
851
|
+
if (mask & 2)
|
|
852
|
+
hands.add('right');
|
|
853
|
+
return hands;
|
|
854
|
+
}
|
|
855
|
+
function dedupeHandMasks(states) {
|
|
856
|
+
return [...new Set(states)];
|
|
857
|
+
}
|
|
858
|
+
function cloneHandPath(path) {
|
|
859
|
+
return { state: path.state, delays: [...path.delays] };
|
|
860
|
+
}
|
|
861
|
+
function handGraphExecutionMetrics(steps, depth = 0) {
|
|
862
|
+
const current = { steps: 0 };
|
|
863
|
+
const delayed = [];
|
|
864
|
+
if (!Array.isArray(steps) || depth > DEVICE_GRAPH_MAX_BRANCH_DEPTH) {
|
|
865
|
+
return [current];
|
|
866
|
+
}
|
|
867
|
+
for (const raw of steps) {
|
|
868
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
869
|
+
continue;
|
|
870
|
+
const step = object(raw);
|
|
871
|
+
const kind = String(step.step ?? '');
|
|
872
|
+
current.steps = Math.min(DEVICE_BUDGETS.maxStepsPerFire, current.steps + 1);
|
|
873
|
+
if (kind === 'delay') {
|
|
874
|
+
delayed.push(...handGraphExecutionMetrics(step.then, depth + 1));
|
|
875
|
+
}
|
|
876
|
+
else if (kind === 'sequence') {
|
|
877
|
+
const [body, ...children] = handGraphExecutionMetrics(step.do, depth + 1);
|
|
878
|
+
current.steps = cappedStepSum(current.steps, body.steps);
|
|
879
|
+
delayed.push(...children);
|
|
880
|
+
}
|
|
881
|
+
else if (kind === 'repeat' && Number.isInteger(step.times)) {
|
|
882
|
+
const times = Math.max(0, Number(step.times));
|
|
883
|
+
const [body, ...children] = handGraphExecutionMetrics(step.do, depth + 1);
|
|
884
|
+
current.steps = cappedStepSum(current.steps, times * (1 + body.steps));
|
|
885
|
+
delayed.push(...children);
|
|
886
|
+
}
|
|
887
|
+
else if (kind === 'branch') {
|
|
888
|
+
const condition = object(step.when);
|
|
889
|
+
const arms = condition.op === 'true'
|
|
890
|
+
? [step.then]
|
|
891
|
+
: condition.op === 'false'
|
|
892
|
+
? [step.else ?? []]
|
|
893
|
+
: [step.then, step.else ?? []];
|
|
894
|
+
const armMetrics = arms.map((arm) => handGraphExecutionMetrics(arm, depth + 1));
|
|
895
|
+
current.steps = cappedStepSum(current.steps, Math.max(0, ...armMetrics.map(([arm]) => arm.steps)));
|
|
896
|
+
for (const [, ...children] of armMetrics)
|
|
897
|
+
delayed.push(...children);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return [current, ...delayed];
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Maximum delay-queue width reachable from one execution unit. A queued delay
|
|
904
|
+
* occupies one slot until it fires; its continuation can replace that slot
|
|
905
|
+
* with every delay it schedules while unrelated siblings remain pending.
|
|
906
|
+
* Taking the widest reachable replacement forest catches nested overflow
|
|
907
|
+
* without rejecting a long chain that still occupies only one slot at a time.
|
|
908
|
+
*/
|
|
909
|
+
function handGraphMaxPendingDelays(steps, depth = 0) {
|
|
910
|
+
if (!Array.isArray(steps) || depth > DEVICE_GRAPH_MAX_BRANCH_DEPTH)
|
|
911
|
+
return 0;
|
|
912
|
+
let width = 0;
|
|
913
|
+
for (const raw of steps) {
|
|
914
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
915
|
+
continue;
|
|
916
|
+
const step = object(raw);
|
|
917
|
+
const kind = String(step.step ?? '');
|
|
918
|
+
if (kind === 'delay') {
|
|
919
|
+
width += Math.max(1, handGraphMaxPendingDelays(step.then, depth + 1));
|
|
920
|
+
}
|
|
921
|
+
else if (kind === 'sequence') {
|
|
922
|
+
width += handGraphMaxPendingDelays(step.do, depth + 1);
|
|
923
|
+
}
|
|
924
|
+
else if (kind === 'repeat' && Number.isInteger(step.times)) {
|
|
925
|
+
width += Math.max(0, Number(step.times)) *
|
|
926
|
+
handGraphMaxPendingDelays(step.do, depth + 1);
|
|
927
|
+
}
|
|
928
|
+
else if (kind === 'branch') {
|
|
929
|
+
const condition = object(step.when);
|
|
930
|
+
const arms = condition.op === 'true'
|
|
931
|
+
? [step.then]
|
|
932
|
+
: condition.op === 'false'
|
|
933
|
+
? [step.else ?? []]
|
|
934
|
+
: [step.then, step.else ?? []];
|
|
935
|
+
width += Math.max(0, ...arms.map((arm) => handGraphMaxPendingDelays(arm, depth + 1)));
|
|
936
|
+
}
|
|
937
|
+
if (width > DEVICE_BUDGETS.maxConcurrentDelays)
|
|
938
|
+
return width;
|
|
939
|
+
}
|
|
940
|
+
return width;
|
|
941
|
+
}
|
|
942
|
+
function cappedStepSum(left, right) {
|
|
943
|
+
return Math.min(DEVICE_BUDGETS.maxStepsPerFire, left + right);
|
|
944
|
+
}
|
|
945
|
+
function graphHasHandStep(steps) {
|
|
946
|
+
if (!Array.isArray(steps))
|
|
947
|
+
return false;
|
|
948
|
+
return steps.some((raw) => {
|
|
949
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
950
|
+
return false;
|
|
951
|
+
const step = object(raw);
|
|
952
|
+
if (step.step === 'reachCharacterHand' ||
|
|
953
|
+
step.step === 'releaseCharacterHand') {
|
|
954
|
+
return true;
|
|
955
|
+
}
|
|
956
|
+
if (step.step === 'branch') {
|
|
957
|
+
return graphHasHandStep(step.then) || graphHasHandStep(step.else);
|
|
958
|
+
}
|
|
959
|
+
if (step.step === 'delay')
|
|
960
|
+
return graphHasHandStep(step.then);
|
|
961
|
+
if (step.step === 'sequence' || step.step === 'repeat') {
|
|
962
|
+
return graphHasHandStep(step.do);
|
|
963
|
+
}
|
|
964
|
+
return false;
|
|
965
|
+
});
|
|
966
|
+
}
|
|
623
967
|
const DEVICE_VFX_TYPES = new Set(['particles', 'glow']);
|
|
624
968
|
const DEVICE_VFX_SHAPES = new Set(['cone', 'sphere', 'disc']);
|
|
625
969
|
/**
|
|
@@ -1835,8 +2179,33 @@ function validateDeviceProject(input) {
|
|
|
1835
2179
|
// The host's `do` array is non-optional on every trigger variant.
|
|
1836
2180
|
if (trigger.do === undefined)
|
|
1837
2181
|
errors.push(`${triggerPath}: every trigger requires a do array`);
|
|
1838
|
-
else
|
|
2182
|
+
else {
|
|
1839
2183
|
validateGraphSteps(trigger.do, graphContext, `${triggerPath}.do`, 0);
|
|
2184
|
+
let handBudgetInvalid = false;
|
|
2185
|
+
if (graphHasHandStep(trigger.do)) {
|
|
2186
|
+
const metrics = handGraphExecutionMetrics(trigger.do);
|
|
2187
|
+
for (const metric of metrics) {
|
|
2188
|
+
if (metric.steps >= DEVICE_BUDGETS.maxStepsPerFire) {
|
|
2189
|
+
errors.push(`${triggerPath}: a hand reach/release execution path can exhaust the ${DEVICE_BUDGETS.maxStepsPerFire}-step runtime budget`);
|
|
2190
|
+
handBudgetInvalid = true;
|
|
2191
|
+
break;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
if (handGraphMaxPendingDelays(trigger.do) >
|
|
2195
|
+
DEVICE_BUDGETS.maxConcurrentDelays) {
|
|
2196
|
+
errors.push(`${triggerPath}: a hand reach/release execution path can exceed the ${DEVICE_BUDGETS.maxConcurrentDelays}-delay runtime limit`);
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
// Do not enumerate a graph the runtime cannot finish. In particular,
|
|
2200
|
+
// nested 64-repeat bodies have an exponential concrete path count even
|
|
2201
|
+
// though the structural metric above proves they abort at 256 steps.
|
|
2202
|
+
const endings = handBudgetInvalid ? [] : handReachPathEnds(trigger.do);
|
|
2203
|
+
for (const hand of ['left', 'right']) {
|
|
2204
|
+
if (endings.some((path) => path.has(hand))) {
|
|
2205
|
+
errors.push(`${triggerPath}: a reachable path leaves ${hand} reachCharacterHand without releaseCharacterHand`);
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
1840
2209
|
});
|
|
1841
2210
|
// `compatibility` itself is optional on the host, and its `hosts` array is a
|
|
1842
2211
|
// `min(1)` enum — NOT "both home and world". Demanding both here rejected a
|