@helixdev/helix-manifest 0.3.0-staging.13 → 0.3.0-staging.14
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/schema/manifest-v0.3.schema.json +99 -24
- package/dist/src/index.d.ts +169 -13
- package/dist/src/index.js +821 -89
- package/dist/src/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/schema/manifest-v0.3.schema.json +99 -24
package/dist/src/index.js
CHANGED
|
@@ -99,6 +99,14 @@ exports.PLATFORM_MAX_PLAYERS_PER_ROOM = 24;
|
|
|
99
99
|
// land. (Var-NAME length is capped by the schema's varDecls propertyNames pattern — 32 chars — not here.)
|
|
100
100
|
// Over any cap → publish fails.
|
|
101
101
|
exports.MULTIPLAYER_CAPS = {
|
|
102
|
+
/** Accepted `multiplayer.uploadHz` tiers (client→server upload cadence). 10 = default, 20 = opt-in fast tier.
|
|
103
|
+
* SYNC: mirrors the contract's MESSAGE_RATE.{stateHz=10, maxUploadHz=20} (helix-sdk multiplayer-contract) — the
|
|
104
|
+
* manifest takes no SDK dep, so widen both in lockstep if a tier is ever added. */
|
|
105
|
+
uploadHzAllowed: [10, 20],
|
|
106
|
+
/** The 20 Hz player cap: `uploadHz:20` requires maxPlayers ≤ this (the ~2× upstream cost is bounded by capacity).
|
|
107
|
+
* SYNC: mirrors the room's ROOM_MAX_CLIENTS_CAP_20HZ (helix-colyseus-server src/tuning.ts) — publish gate here,
|
|
108
|
+
* runtime backstop there. */
|
|
109
|
+
maxPlayersAt20Hz: 12,
|
|
102
110
|
/** Declared room-level vars. */
|
|
103
111
|
roomVars: 64,
|
|
104
112
|
/** Declared per-player vars (these are realized once PER PLAYER at runtime, so the effective cost scales). */
|
|
@@ -111,6 +119,8 @@ exports.MULTIPLAYER_CAPS = {
|
|
|
111
119
|
listMaxLen: 256,
|
|
112
120
|
/** Keys in a `counterMap` collection var's declared key enum (4.5.13). */
|
|
113
121
|
counterKeys: 64,
|
|
122
|
+
/** Fields in a `list` of `record` element (P3 Collections — bounds one synced record's flat scalar fields). */
|
|
123
|
+
recordFields: 8,
|
|
114
124
|
/** Declared behavior rules (Tier 2 Phase 2). */
|
|
115
125
|
rules: 128,
|
|
116
126
|
/** Effects in a single rule's `then`. */
|
|
@@ -152,6 +162,10 @@ exports.MULTIPLAYER_CAPS = {
|
|
|
152
162
|
/** Max live entities of a single kind in a room — bounds the synced entities collection + per-tick entity work.
|
|
153
163
|
* Enforced at RUNTIME (a spawn over the cap is skipped + counted in the H3 metric, never throws). */
|
|
154
164
|
entitiesPerKind: 256,
|
|
165
|
+
/** networked-physics (Phase 0): max entity kinds that may declare a `physics` block per world. Bounds the
|
|
166
|
+
* client-side dynamic-body count alongside entitiesPerKind — the worst case a client may simulate is
|
|
167
|
+
* physicsKinds × entitiesPerKind dynamic Rapier bodies, so keep this small (a casual world needs 1–2). */
|
|
168
|
+
physicsKinds: 8,
|
|
155
169
|
/** Ceiling on a zone's extent — any box dimension or a sphere radius, in meters. A sanity bound (a zone
|
|
156
170
|
* test is O(1) regardless of size), generous enough for whole-level kill-planes. */
|
|
157
171
|
zoneMaxExtent: 10000,
|
|
@@ -195,6 +209,17 @@ function applyV03CrossFieldRules(m, strict) {
|
|
|
195
209
|
if (minPlayers !== undefined && minPlayers > m.maxPlayers) {
|
|
196
210
|
errors.push(`manifest: multiplayer.minPlayers ${minPlayers} exceeds maxPlayers ${m.maxPlayers}`);
|
|
197
211
|
}
|
|
212
|
+
// Upload-rate tier. The schema enum normally gates the value (and defaults absent → 10); this defensive enum check
|
|
213
|
+
// covers manifests built outside the schema path. The maxPlayers coupling is cross-field, so it lives here only.
|
|
214
|
+
const uploadHz = m.multiplayer?.uploadHz;
|
|
215
|
+
if (uploadHz !== undefined) {
|
|
216
|
+
if (!exports.MULTIPLAYER_CAPS.uploadHzAllowed.includes(uploadHz)) {
|
|
217
|
+
errors.push(`manifest: multiplayer.uploadHz ${uploadHz} must be ${exports.MULTIPLAYER_CAPS.uploadHzAllowed.join(' or ')}`);
|
|
218
|
+
}
|
|
219
|
+
else if (uploadHz === 20 && m.maxPlayers > exports.MULTIPLAYER_CAPS.maxPlayersAt20Hz) {
|
|
220
|
+
errors.push(`manifest: multiplayer.uploadHz 20 caps maxPlayers at ${exports.MULTIPLAYER_CAPS.maxPlayersAt20Hz} (got ${m.maxPlayers}) — lower maxPlayers to ${exports.MULTIPLAYER_CAPS.maxPlayersAt20Hz} or uploadHz to 10`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
198
223
|
// v0.3 declared state (Tier 2 Phase 1.3): the JSON schema validated each VarType's SHAPE; here we add the
|
|
199
224
|
// cross-field semantics it can't express — declared state requires the multiplayer permission, and every
|
|
200
225
|
// var's default must satisfy its own declared constraints.
|
|
@@ -210,8 +235,14 @@ function applyV03CrossFieldRules(m, strict) {
|
|
|
210
235
|
for (const [scope, decls] of bags) {
|
|
211
236
|
if (!decls)
|
|
212
237
|
continue;
|
|
213
|
-
|
|
238
|
+
// A declared var may not shadow a reserved built-in (else it's silently unreadable/unwritable — the built-in
|
|
239
|
+
// wins). roomVars vs room built-ins (phase); playerVars vs player built-ins (position/connected/active).
|
|
240
|
+
const reserved = scope === 'roomVars' ? ROOM_BUILTIN_TYPES : PLAYER_BUILTIN_TYPES;
|
|
241
|
+
for (const [name, vt] of Object.entries(decls)) {
|
|
242
|
+
if (name in reserved)
|
|
243
|
+
errors.push(`multiplayer.state.${scope}.${name}: "${name}" is a reserved ${scope === 'roomVars' ? 'room' : 'player'} built-in — rename the var`);
|
|
214
244
|
errors.push(...checkVarDefault(`multiplayer.state.${scope}.${name}`, vt));
|
|
245
|
+
}
|
|
215
246
|
}
|
|
216
247
|
}
|
|
217
248
|
const entities = m.multiplayer?.entities;
|
|
@@ -281,7 +312,7 @@ function applyV03CrossFieldRules(m, strict) {
|
|
|
281
312
|
errors.push(...cascade.errors);
|
|
282
313
|
// §9/§11.3 (4.5) reject a within-tick unbounded entitySpawn → spawnEntity loop (the entity twin of the above).
|
|
283
314
|
errors.push(...analyzeEntityCascade(m.multiplayer.rules));
|
|
284
|
-
errors.push(...checkBudget(m.multiplayer.rules, m.multiplayer.timers ?? {}, cascade.maxDepth,
|
|
315
|
+
errors.push(...checkBudget(m.multiplayer.rules, m.multiplayer.timers ?? {}, cascade.maxDepth, states, maxDeclaredListLen(m)));
|
|
285
316
|
// §14 (4.5) single-player-safe gate: a solo-declared world (minPlayers 1) must not gate a phase transition
|
|
286
317
|
// behind playerCount ≥ 2 (a solo player would be soft-locked). Warns; strict (launch) flips it to an error.
|
|
287
318
|
const solo = checkSoloSafety(m, strict);
|
|
@@ -550,8 +581,9 @@ function checkTimerRefs(path, rule, timers, bound, roomVars, playerVars, zoneIds
|
|
|
550
581
|
if (kr.errors.length === 0 && keyedKind !== undefined && kr.kind !== undefined) {
|
|
551
582
|
const wantEntity = keyedKind.startsWith('entity:');
|
|
552
583
|
const gotEntity = kr.kind.type === 'entity';
|
|
553
|
-
|
|
554
|
-
|
|
584
|
+
const gotKind = bindingKind(kr.kind);
|
|
585
|
+
if (wantEntity !== gotEntity || (wantEntity && gotKind !== keyedKind.slice('entity:'.length))) {
|
|
586
|
+
const got = gotEntity ? `entity:${gotKind}` : 'player';
|
|
555
587
|
e.push(`${u.path}.key: timer "${u.timer}" is keyed:'${keyedKind}' but its key resolves to a ${got} ref — they must match`);
|
|
556
588
|
}
|
|
557
589
|
}
|
|
@@ -581,7 +613,10 @@ function collectEffectTimerUses(effects, path, bound, uses, e) {
|
|
|
581
613
|
const p = `${path}[${j}]`;
|
|
582
614
|
if (eff.do === 'startTimer') {
|
|
583
615
|
uses.push({ timer: eff.timer, key: eff.key, path: p, bound });
|
|
584
|
-
|
|
616
|
+
// The static floor only binds a LITERAL duration; an expression's value is unknown at publish, so its
|
|
617
|
+
// sub-floor/NaN/∞/negative handling is deferred to the runtime clamp/skip (spec §3.2). The expr's type +
|
|
618
|
+
// node budget are validated in validateEffect like any other effect-borne expression.
|
|
619
|
+
if (typeof eff.seconds === 'number' && eff.seconds < exports.MULTIPLAYER_CAPS.timerMinSeconds)
|
|
585
620
|
e.push(`${p}: startTimer "${eff.timer}" seconds ${eff.seconds} is below the ${exports.MULTIPLAYER_CAPS.timerMinSeconds}s floor (one sim tick)`);
|
|
586
621
|
}
|
|
587
622
|
else if (eff.do === 'cancelTimer') {
|
|
@@ -607,7 +642,7 @@ function collectEffectTimerUses(effects, path, bound, uses, e) {
|
|
|
607
642
|
}
|
|
608
643
|
// Wire message-type names a declared event (or action) name may not shadow — kept in sync with
|
|
609
644
|
// RESERVED_MESSAGE_TYPES in @hypersoniclabs/helix-sdk/multiplayer-contract (the manifest takes no SDK dep).
|
|
610
|
-
const RESERVED_EVENT_NAMES = new Set(['state', 'ability', 'action', 'entityState', 'entityStateBatch', 'ping', 'pong']);
|
|
645
|
+
const RESERVED_EVENT_NAMES = new Set(['state', 'ability', 'action', 'entityState', 'entityStateBatch', 'ping', 'pong', 'stateAck', 'entityStateAck']);
|
|
611
646
|
// §10.3 events: count + reserved-name + payload-field caps. A `ref` payload field must declare `of` (the
|
|
612
647
|
// schema enforces shape + the identifier name pattern; this adds the cross-field semantics).
|
|
613
648
|
function checkEvents(events) {
|
|
@@ -624,8 +659,25 @@ function checkEvents(events) {
|
|
|
624
659
|
e.push(`multiplayer.events."${name}": payload has ${fields.length} fields — the cap is ${exports.MULTIPLAYER_CAPS.broadcastFields}`);
|
|
625
660
|
}
|
|
626
661
|
for (const f of fields) {
|
|
627
|
-
|
|
662
|
+
const ft = payload[f];
|
|
663
|
+
if (ft.type === 'ref' && ft.of === undefined)
|
|
628
664
|
e.push(`multiplayer.events."${name}".payload.${f}: a ref field must declare "of"`);
|
|
665
|
+
// §10.3 (P3-B6) a collection-snapshot field — validate its element/keys shape like a var collection.
|
|
666
|
+
if (ft.type === 'list' && typeof ft.of === 'object' && ft.of.type === 'record') {
|
|
667
|
+
const rfields = Object.keys(ft.of.fields);
|
|
668
|
+
if (rfields.length > exports.MULTIPLAYER_CAPS.recordFields)
|
|
669
|
+
e.push(`multiplayer.events."${name}".payload.${f}: a record element declares ${rfields.length} fields — the cap is ${exports.MULTIPLAYER_CAPS.recordFields}`);
|
|
670
|
+
for (const rn of rfields)
|
|
671
|
+
e.push(...checkRecordField(`multiplayer.events."${name}".payload.${f}.of.fields.${rn}`, ft.of.fields[rn]));
|
|
672
|
+
}
|
|
673
|
+
if (ft.type === 'counterMap') {
|
|
674
|
+
if (ft.keys.length === 0)
|
|
675
|
+
e.push(`multiplayer.events."${name}".payload.${f}: counterMap needs at least one key`);
|
|
676
|
+
else if (ft.keys.length > exports.MULTIPLAYER_CAPS.counterKeys)
|
|
677
|
+
e.push(`multiplayer.events."${name}".payload.${f}: counterMap has ${ft.keys.length} keys — the cap is ${exports.MULTIPLAYER_CAPS.counterKeys}`);
|
|
678
|
+
if (new Set(ft.keys).size !== ft.keys.length)
|
|
679
|
+
e.push(`multiplayer.events."${name}".payload.${f}: counterMap keys must be unique`);
|
|
680
|
+
}
|
|
629
681
|
}
|
|
630
682
|
}
|
|
631
683
|
return e;
|
|
@@ -655,19 +707,40 @@ function checkActions(actions) {
|
|
|
655
707
|
return e;
|
|
656
708
|
}
|
|
657
709
|
// §11.2 static per-tick budget: a conservative WORST-CASE count of node-evaluations one tick could do across
|
|
710
|
+
// §5 (P3 ext) the worst-case element count the cost model charges a list-scan op (forEachInList / removeWhere /
|
|
711
|
+
// listCount / listIndexOf) — set to the LARGEST declared list maxLen in the config (a sound, much tighter bound than
|
|
712
|
+
// the global listMaxLen cap, since no list can exceed its own declared maxLen, and a {ref,var} scan can't exceed the
|
|
713
|
+
// largest declared list). checkBudget assigns it before the cost walk; defaults to the global cap (no lists declared).
|
|
714
|
+
let budgetListCap = exports.MULTIPLAYER_CAPS.listMaxLen;
|
|
715
|
+
// The largest declared `list` maxLen across roomVars / playerVars / every entity kind's vars (capped at the global
|
|
716
|
+
// ceiling); the global cap when no list is declared. Bounds the per-tick cost of list-scan ops precisely.
|
|
717
|
+
function maxDeclaredListLen(m) {
|
|
718
|
+
let max = 0;
|
|
719
|
+
const scan = (decls) => {
|
|
720
|
+
for (const vt of Object.values(decls ?? {}))
|
|
721
|
+
if (vt.type === 'list')
|
|
722
|
+
max = Math.max(max, vt.maxLen);
|
|
723
|
+
};
|
|
724
|
+
scan(m.multiplayer?.state?.roomVars);
|
|
725
|
+
scan(m.multiplayer?.state?.playerVars);
|
|
726
|
+
for (const e of Object.values(m.multiplayer?.entities ?? {}))
|
|
727
|
+
scan(e.vars);
|
|
728
|
+
return max > 0 ? Math.min(max, exports.MULTIPLAYER_CAPS.listMaxLen) : exports.MULTIPLAYER_CAPS.listMaxLen;
|
|
729
|
+
}
|
|
658
730
|
// all rules — Σ fireCount(event) × ruleCost. fireCount is how many times an event can fire per tick (tick=1;
|
|
659
731
|
// player/zone events ≤ playerCap; playerContact ≤ playerCap²; self varReached ≤ playerCap; a keyed timerElapsed
|
|
660
732
|
// ≤ playerCap / entitiesPerKind — so `timers` is threaded in). ruleCost sums the `if` + each effect, weighting
|
|
661
733
|
// reductions (aggregate/nearest*) + forEach fanout by the player cap. Over budget → publish fails. Shape-based,
|
|
662
|
-
// so it runs after the schema validated the rule shapes.
|
|
663
|
-
function checkBudget(rules, timers, maxCascadeDepth = 0,
|
|
734
|
+
// so it runs after the schema validated the rule shapes. `listCap` bounds list-scan cost (largest declared maxLen).
|
|
735
|
+
function checkBudget(rules, timers, maxCascadeDepth = 0, states, listCap = exports.MULTIPLAYER_CAPS.listMaxLen) {
|
|
736
|
+
budgetListCap = listCap; // set before the cost walk (covers ruleCost + joinLateCost list-scan charges)
|
|
664
737
|
let base = 0;
|
|
665
738
|
for (const rule of rules)
|
|
666
739
|
base += fireCount(rule.when, timers) * ruleCost(rule);
|
|
667
740
|
// §11.2 cascade term (N9): each cascade level can re-evaluate the matched state rules, so the worst case is
|
|
668
741
|
// the base batch ×(1 + maxCascadeDepth). maxCascadeDepth is the cycle-free longest chain from analyzeCascade.
|
|
669
742
|
// `joinLate` is the worst-case onLateJoin cost (≤ player cap joins/tick) from joinPolicy (§8/3.5).
|
|
670
|
-
const total = base * (1 + maxCascadeDepth) +
|
|
743
|
+
const total = base * (1 + maxCascadeDepth) + joinLateCost(states);
|
|
671
744
|
if (total > exports.MULTIPLAYER_CAPS.tickNodeBudget) {
|
|
672
745
|
return [
|
|
673
746
|
`manifest: multiplayer.rules worst-case ~${total} node-evaluations/tick (incl. the ×${1 + maxCascadeDepth} cascade factor) exceeds the per-tick budget of ${exports.MULTIPLAYER_CAPS.tickNodeBudget} — simplify rules (fewer effects, less forEach/aggregate, fewer playerContact rules, shallower phase cascades) or split the world`,
|
|
@@ -694,7 +767,7 @@ function fireCount(event, timers) {
|
|
|
694
767
|
case 'tick':
|
|
695
768
|
return 1; // everyN doesn't lower the worst case (the tick it fires)
|
|
696
769
|
case 'varReached':
|
|
697
|
-
return event.scope === 'self' ? p : 1;
|
|
770
|
+
return event.scope === 'self' ? p : event.scope === 'entity' ? exports.MULTIPLAYER_CAPS.entitiesPerKind : 1; // entity scope evaluates once per live instance of the kind
|
|
698
771
|
case 'playerContact':
|
|
699
772
|
return p * p;
|
|
700
773
|
case 'stateEnter':
|
|
@@ -733,7 +806,7 @@ function exprCost(expr) {
|
|
|
733
806
|
const node = expr;
|
|
734
807
|
if (node.op === 'aggregate' || node.op === 'nearestPlayer')
|
|
735
808
|
return exports.PLATFORM_MAX_PLAYERS_PER_ROOM; // O(members) scan
|
|
736
|
-
if (node.op === 'playerCount' || node.op === 'random' || node.op === 'timeInState' || node.op === 'timerRemaining')
|
|
809
|
+
if (node.op === 'playerCount' || node.op === 'random' || node.op === 'timeInState' || node.op === 'timerRemaining' || node.op === 'now')
|
|
737
810
|
return 1; // O(1) leaves
|
|
738
811
|
if (node.op === 'randomPoint')
|
|
739
812
|
return 1; // O(1) leaf → vec3
|
|
@@ -741,12 +814,37 @@ function exprCost(expr) {
|
|
|
741
814
|
return 1; // §5 (4.5.13) O(1) collection reads
|
|
742
815
|
if (node.op === 'listAt')
|
|
743
816
|
return 1 + exprCost(node.index ?? 0); // O(1) + the index expr
|
|
817
|
+
if (node.op === 'listCount' || node.op === 'listIndexOf')
|
|
818
|
+
return budgetListCap * (1 + exprCost(node.where ?? true)); // §5 (P3 ext) O(maxLen) scan × the where
|
|
819
|
+
if (node.op === 'controlledBy') {
|
|
820
|
+
const cb = expr;
|
|
821
|
+
return 1 + refCost(cb.entity) + refCost(cb.by);
|
|
822
|
+
} // §9 (P3) O(1) + the two ref resolutions
|
|
823
|
+
if (node.op === 'sameRef') {
|
|
824
|
+
const sr = expr;
|
|
825
|
+
return 1 + refCost(sr.a) + refCost(sr.b);
|
|
826
|
+
} // §7.3 (P3-B2) O(1) identity + the two ref resolutions
|
|
827
|
+
if (node.op === 'hostLoad')
|
|
828
|
+
return exports.MULTIPLAYER_CAPS.entitiesPerKind; // §9 (P3) O(entities) scan of the owner map
|
|
744
829
|
if (node.op === 'and' || node.op === 'or')
|
|
745
830
|
return 1 + node.of.reduce((s, e) => s + exprCost(e), 0);
|
|
746
831
|
if (node.op === 'not')
|
|
747
832
|
return 1 + exprCost(node.of);
|
|
748
833
|
return 1 + exprCost(node.a) + exprCost(node.b); // distance / binary cmp / arith
|
|
749
834
|
}
|
|
835
|
+
// §5 (P3 Collections) a record-list element literal vs an Expr/Ref node, for the static cost/taint walks: a plain
|
|
836
|
+
// object that is NOT a recognized expr/ref node (no op/var/vec3/ref key). A record field named op/var/vec3/ref is
|
|
837
|
+
// disambiguated by the list's declared element type at validation/runtime — this heuristic is only the static walk.
|
|
838
|
+
function isRecordLiteral(value) {
|
|
839
|
+
return (typeof value === 'object' && value !== null && !Array.isArray(value) && !('op' in value) && !('var' in value) && !('vec3' in value) && !('ref' in value));
|
|
840
|
+
}
|
|
841
|
+
// §5 (P3 Collections) cost of an append/setField value: a record literal sums its field expr costs; a scalar/ref
|
|
842
|
+
// value costs as an expr (exprCost tolerates the ref forms structurally).
|
|
843
|
+
function collectionValueCost(value) {
|
|
844
|
+
if (isRecordLiteral(value))
|
|
845
|
+
return 1 + Object.values(value).reduce((s, v) => s + exprCost(v), 0);
|
|
846
|
+
return exprCost(value);
|
|
847
|
+
}
|
|
750
848
|
// Node-evals to resolve a Ref once. A bound name / ref-var deref is O(1); a ref-returning op is an
|
|
751
849
|
// O(members) scan (the player cap).
|
|
752
850
|
function refCost(ref) {
|
|
@@ -758,6 +856,12 @@ function refCost(ref) {
|
|
|
758
856
|
return exports.PLATFORM_MAX_PLAYERS_PER_ROOM + exprCost(ref.from);
|
|
759
857
|
if (ref.op === 'nearestEntity')
|
|
760
858
|
return exports.MULTIPLAYER_CAPS.entitiesPerKind + exprCost(ref.from); // O(entities of a kind)
|
|
859
|
+
if (ref.op === 'controllerOf')
|
|
860
|
+
return 1 + refCost(ref.entity); // §9 (P3) O(1) field read + the entity ref resolution
|
|
861
|
+
if (ref.op === 'leastLoadedPlayer')
|
|
862
|
+
return exports.PLATFORM_MAX_PLAYERS_PER_ROOM + (ref.where !== undefined ? exprCost(ref.where) : 0); // §9 (P3) O(players) scan
|
|
863
|
+
if (ref.op === 'listAt')
|
|
864
|
+
return 1 + exprCost(ref.index); // §5 (P3-B2) O(1) element read + the index expr
|
|
761
865
|
return exports.PLATFORM_MAX_PLAYERS_PER_ROOM; // aggregate argmax|argmin
|
|
762
866
|
}
|
|
763
867
|
function lvalueCost(lvalue) {
|
|
@@ -786,11 +890,18 @@ function effectCost(eff) {
|
|
|
786
890
|
const body = (eff.where !== undefined ? exprCost(eff.where) : 0) + eff.then.reduce((s, e) => s + effectCost(e), 0);
|
|
787
891
|
return exports.MULTIPLAYER_CAPS.entitiesPerKind * body;
|
|
788
892
|
}
|
|
893
|
+
case 'forEachInList': {
|
|
894
|
+
// §5 (P3 ext) bounded by the list's maxLen ceiling (the worst-case element count); the body is O(1) per
|
|
895
|
+
// iteration (reductions forbidden inside, like the other loops).
|
|
896
|
+
const body = (eff.where !== undefined ? exprCost(eff.where) : 0) + eff.then.reduce((s, e) => s + effectCost(e), 0);
|
|
897
|
+
return lvalueCost(eff.list) + budgetListCap * body;
|
|
898
|
+
}
|
|
789
899
|
case 'transitionTo':
|
|
790
900
|
return 1; // O(1): set the phase + enqueue stateExit/stateEnter (the cascade itself is the ×depth budget term)
|
|
791
901
|
case 'startTimer':
|
|
902
|
+
return 1 + exprCost(eff.seconds); // O(1) set + the (possibly dynamic) duration expr
|
|
792
903
|
case 'cancelTimer':
|
|
793
|
-
return 1; // O(1):
|
|
904
|
+
return 1; // O(1): delete one deadline entry
|
|
794
905
|
case 'broadcast': {
|
|
795
906
|
const toCost = typeof eff.to === 'string' ? (eff.to === 'all' ? exports.PLATFORM_MAX_PLAYERS_PER_ROOM : 1) : 'team' in eff.to ? exports.PLATFORM_MAX_PLAYERS_PER_ROOM : refCost(eff.to);
|
|
796
907
|
const payload = Object.values(eff.payload ?? {}).reduce((s, v) => s + exprCost(v), 0);
|
|
@@ -806,11 +917,22 @@ function effectCost(eff) {
|
|
|
806
917
|
// O(1): resolve the refs + set controller/epoch (the ownershipChanged it fires is the ×depth cascade term).
|
|
807
918
|
return 1 + refCost(eff.entity) + (eff.to === null ? 0 : refCost(eff.to));
|
|
808
919
|
case 'append':
|
|
809
|
-
return 1 +
|
|
920
|
+
return 1 + collectionValueCost(eff.value); // §5 (4.5.13 + P3) O(1) push (+ the value's expr / record-field costs)
|
|
810
921
|
case 'clear':
|
|
811
922
|
return 1; // §5 (4.5.13) O(1) — empty the list / reset keys
|
|
812
923
|
case 'addCount':
|
|
813
924
|
return 1 + exprCost(eff.by); // §5 (4.5.13) O(1) key bump (+ the by expr)
|
|
925
|
+
case 'removeAt':
|
|
926
|
+
return 1 + exprCost(eff.index); // §5 (P3) O(1) splice (+ the index expr) — amortized; the shift is bounded by maxLen
|
|
927
|
+
case 'removeWhere':
|
|
928
|
+
return lvalueCost(eff.target) + budgetListCap * (1 + exprCost(eff.where)); // §5 (P3 ext) O(maxLen) scan × the where
|
|
929
|
+
case 'setField':
|
|
930
|
+
return 1 + (eff.index !== undefined ? exprCost(eff.index) : 0) + collectionValueCost(eff.to); // §5 (P3) O(1) field write
|
|
931
|
+
case 'advanceTurn':
|
|
932
|
+
return exports.PLATFORM_MAX_PLAYERS_PER_ROOM + lvalueCost(eff.order) + lvalueCost(eff.index); // §7.4 (P3-B2) O(order) skip-scan, bounded by the player cap
|
|
933
|
+
case 'eliminate':
|
|
934
|
+
case 'revive':
|
|
935
|
+
return 1 + refCost(eff.player); // §7.4 (P3-B2) O(1) flag toggle + the player ref resolution
|
|
814
936
|
}
|
|
815
937
|
}
|
|
816
938
|
// §6 zones: count cap, unique ids, an extent sanity bound, and (4.5.8) the `tracks` target. The schema already
|
|
@@ -849,20 +971,39 @@ function checkEntities(entities) {
|
|
|
849
971
|
if (kinds.length > exports.MULTIPLAYER_CAPS.entityKinds) {
|
|
850
972
|
e.push(`manifest: multiplayer.entities declares ${kinds.length} kinds — the cap is ${exports.MULTIPLAYER_CAPS.entityKinds}`);
|
|
851
973
|
}
|
|
974
|
+
// networked-physics (Phase 0): bound the per-world dynamic-body kind count — physicsKinds × entitiesPerKind is the
|
|
975
|
+
// worst-case client physics-body simulation load, so a small cap keeps the casual-world path cheap.
|
|
976
|
+
const physicsKindCount = kinds.filter((k) => entities[k].physics).length;
|
|
977
|
+
if (physicsKindCount > exports.MULTIPLAYER_CAPS.physicsKinds) {
|
|
978
|
+
e.push(`manifest: multiplayer.entities declares ${physicsKindCount} physics kinds — the cap is ${exports.MULTIPLAYER_CAPS.physicsKinds}`);
|
|
979
|
+
}
|
|
980
|
+
// §5 (P3 Collections) entity collections ARE allowed (P3-A1) — they're realized into the entity-var union. The
|
|
981
|
+
// union is one merged schema, so a COLLECTION var name shared across kinds must agree on its exact shape (a
|
|
982
|
+
// differing element/keys would mis-realize the shared ArraySchema/MapSchema and crash the encoder). Track shapes.
|
|
983
|
+
const seenColl = new Map();
|
|
852
984
|
for (const kind of kinds) {
|
|
853
985
|
const vars = entities[kind].vars ?? {};
|
|
854
986
|
const names = Object.keys(vars);
|
|
855
987
|
if (names.length > exports.MULTIPLAYER_CAPS.entityVars) {
|
|
856
988
|
e.push(`multiplayer.entities.${kind}.vars declares ${names.length} vars — the cap is ${exports.MULTIPLAYER_CAPS.entityVars}`);
|
|
857
989
|
}
|
|
858
|
-
for (const name of names)
|
|
990
|
+
for (const name of names) {
|
|
991
|
+
// An entity var may not shadow a UNIVERSAL member built-in (position). connected/active are player-only, so an
|
|
992
|
+
// entity is free to declare those (e.g. a coin's `active` = is-collectible) — see playerBuiltin.
|
|
993
|
+
if (name in PLAYER_BUILTIN_TYPES && !PLAYER_ONLY_BUILTINS.has(name))
|
|
994
|
+
e.push(`multiplayer.entities.${kind}.vars.${name}: "${name}" is a reserved member built-in — rename the var`);
|
|
859
995
|
e.push(...checkVarDefault(`multiplayer.entities.${kind}.vars.${name}`, vars[name]));
|
|
860
|
-
|
|
861
|
-
// can't be owner-uploaded or seeded at spawn, and the realized entity-var union stays scalar).
|
|
996
|
+
}
|
|
862
997
|
for (const name of names) {
|
|
863
|
-
const
|
|
864
|
-
if (
|
|
865
|
-
|
|
998
|
+
const vt = vars[name];
|
|
999
|
+
if (vt.type !== 'list' && vt.type !== 'counterMap')
|
|
1000
|
+
continue;
|
|
1001
|
+
const sig = JSON.stringify(vt);
|
|
1002
|
+
const prior = seenColl.get(name);
|
|
1003
|
+
if (prior === undefined)
|
|
1004
|
+
seenColl.set(name, sig);
|
|
1005
|
+
else if (prior !== sig)
|
|
1006
|
+
e.push(`multiplayer.entities.${kind}.vars.${name}: a "${name}" collection is declared with a different shape on another entity kind — collection vars shared across kinds must be identical (the entity-var union is one merged schema)`);
|
|
866
1007
|
}
|
|
867
1008
|
// §9 (Phase 4.6a) seek motion: `target` must name one of THIS kind's own ref vars (the member to home toward),
|
|
868
1009
|
// and `speed` must be positive. The server reads `vars[target]` → that member's position each tick.
|
|
@@ -886,14 +1027,18 @@ function checkEntities(entities) {
|
|
|
886
1027
|
// relay's movement-plausibility ceiling). `maxSpeed` on a `server` kind is ignored (server motion isn't gated) → warn.
|
|
887
1028
|
const authority = entities[kind].authority ?? 'server';
|
|
888
1029
|
const maxSpeed = entities[kind].maxSpeed;
|
|
1030
|
+
const physics = entities[kind].physics;
|
|
889
1031
|
if (authority === 'owner') {
|
|
890
1032
|
if (maxSpeed === undefined)
|
|
891
1033
|
e.push(`multiplayer.entities.${kind}: authority:'owner' requires a declared maxSpeed (m/s — the movement-plausibility ceiling for its relayed transform)`);
|
|
892
1034
|
else if (!(maxSpeed > 0))
|
|
893
1035
|
e.push(`multiplayer.entities.${kind}.maxSpeed: must be > 0, got ${maxSpeed}`);
|
|
894
|
-
// An owner entity is client-simulated; server
|
|
895
|
-
|
|
896
|
-
|
|
1036
|
+
// An owner entity is client-simulated; server motion would fight its uploaded transform — UNLESS it also
|
|
1037
|
+
// declares `physics` (the networked-physics HYBRID, Phase 3.5: the server runs `motion` while the body is
|
|
1038
|
+
// unowned, a client takes over the dynamic physics on contact, then it rejoins the path). So a non-static
|
|
1039
|
+
// motion is only an error when there is NO physics block to make it the hybrid.
|
|
1040
|
+
if (entities[kind].motion && entities[kind].motion.type !== 'static' && !physics)
|
|
1041
|
+
e.push(`multiplayer.entities.${kind}: authority:'owner' is client-simulated — it can't also declare server motion "${entities[kind].motion.type}" (drop motion, use authority:'server', or add a physics block for the server-behaved hybrid)`);
|
|
897
1042
|
}
|
|
898
1043
|
else if (maxSpeed !== undefined) {
|
|
899
1044
|
warnings.push(`multiplayer.entities.${kind}.maxSpeed: ignored on a server-authoritative kind (server motion is deterministic, not gated) — set authority:'owner' or remove maxSpeed`);
|
|
@@ -918,6 +1063,27 @@ function checkEntities(entities) {
|
|
|
918
1063
|
if (transferPolicy !== 'fixed' && authority !== 'owner') {
|
|
919
1064
|
e.push(`multiplayer.entities.${kind}: transferPolicy:'${transferPolicy}' requires authority:'owner' (only a client-simulated entity has a transferable controller)`);
|
|
920
1065
|
}
|
|
1066
|
+
// networked-physics (Phase 0/2): the dynamic-body opt-in + its per-capability cross-field coherence. The schema
|
|
1067
|
+
// already bounds the block's SHAPE (bodyType/shape dims/mass/restitution); here are the rules it can't express —
|
|
1068
|
+
// incoherent combos fail at PUBLISH, not runtime. (mass/restitution/shape bounds: enforced by the JSON schema.)
|
|
1069
|
+
if (physics) {
|
|
1070
|
+
if (authority !== 'owner')
|
|
1071
|
+
e.push(`multiplayer.entities.${kind}.physics: requires authority:'owner' (only an owner kind simulates a dynamic body — a server kind stays kinematic)`);
|
|
1072
|
+
// claim-on-contact fires the auto-takeover verb, so the kind must permit a takeover; dual-sim is the
|
|
1073
|
+
// controlled×controlled path, which by definition never transfers (sticky), so it needs the 'fixed' policy.
|
|
1074
|
+
if (physics.claimOnContact && transferPolicy !== 'takeover')
|
|
1075
|
+
e.push(`multiplayer.entities.${kind}.physics.claimOnContact: requires transferPolicy:'takeover' (the verb the auto-handoff fires); got '${transferPolicy}'`);
|
|
1076
|
+
if (physics.dualSimOnContact && transferPolicy !== 'fixed')
|
|
1077
|
+
e.push(`multiplayer.entities.${kind}.physics.dualSimOnContact: requires transferPolicy:'fixed' (a controlled body never transfers); got '${transferPolicy}'`);
|
|
1078
|
+
for (const other of physics.collidesWith ?? []) {
|
|
1079
|
+
if (!kinds.includes(other))
|
|
1080
|
+
e.push(`multiplayer.entities.${kind}.physics.collidesWith: references unknown entity kind "${other}"${didYouMean(other, kinds)} (declared entities: ${kinds.join(', ') || 'none'})`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
// rejoinOnRelease only means anything for the server-behaved HYBRID (physics + a non-static motion).
|
|
1084
|
+
if (entities[kind].rejoinOnRelease !== undefined && !(physics && entities[kind].motion && entities[kind].motion.type !== 'static')) {
|
|
1085
|
+
warnings.push(`multiplayer.entities.${kind}.rejoinOnRelease: ignored — it only applies to a server-behaved hybrid (a kind with both physics and a non-static motion)`);
|
|
1086
|
+
}
|
|
921
1087
|
// §6 (4.5.8) attached-zone tracks: 'entity:<kind>' (entity-vs-entity overlap) must name a declared kind;
|
|
922
1088
|
// the carrier never detects itself at runtime. 'players' (default) is unconstrained.
|
|
923
1089
|
const zone = entities[kind].zone;
|
|
@@ -936,7 +1102,7 @@ function checkEntities(entities) {
|
|
|
936
1102
|
// ruleEffect permissive branch lets an unknown node through to here; these sets MUST stay in sync with the
|
|
937
1103
|
// HelixRuleEvent/HelixRuleEffect unions + those schema `not` enums.
|
|
938
1104
|
const KNOWN_EVENTS = new Set(['tick', 'playerJoin', 'playerLeave', 'playerDisconnect', 'playerReconnect', 'zoneEnter', 'zoneExit', 'zoneInside', 'playerContact', 'action', 'stateEnter', 'stateExit', 'timerElapsed', 'entitySpawn', 'entityDestroy', 'ownershipChanged', 'varReached']);
|
|
939
|
-
const KNOWN_EFFECTS = new Set(['add', 'set', 'setRef', 'teleport', 'respawn', 'forEachPlayer', 'forEachEntity', 'broadcast', 'transitionTo', 'startTimer', 'cancelTimer', 'spawnEntity', 'destroyEntity', 'requestOwnership', 'takeover', 'append', 'clear', 'addCount']);
|
|
1105
|
+
const KNOWN_EFFECTS = new Set(['add', 'set', 'setRef', 'teleport', 'respawn', 'forEachPlayer', 'forEachEntity', 'forEachInList', 'broadcast', 'transitionTo', 'startTimer', 'cancelTimer', 'spawnEntity', 'destroyEntity', 'requestOwnership', 'takeover', 'append', 'clear', 'addCount', 'removeAt', 'removeWhere', 'setField', 'advanceTurn', 'eliminate', 'revive']);
|
|
940
1106
|
// Reserved nodes for a named LATER phase (provisional names — the grammar lands with that phase). A node here
|
|
941
1107
|
// publishes with a warning + is a runtime no-op; any other unknown node is a typo (hard error with did-you-mean).
|
|
942
1108
|
// (4.5.12 promoted the ownership-transfer trio to KNOWN; this set is empty until the next reserved seam lands.)
|
|
@@ -1020,7 +1186,7 @@ function checkRules(m) {
|
|
|
1020
1186
|
? { ...ec, boundKinds, actionArgs: m.multiplayer?.actions?.[rule.when.name]?.args ?? {} }
|
|
1021
1187
|
: { ...ec, boundKinds };
|
|
1022
1188
|
if (rule.when.on === 'varReached') {
|
|
1023
|
-
e.push(...checkVarReached(`multiplayer.rules[${i}].when`, rule.when, roomVars, playerVars));
|
|
1189
|
+
e.push(...checkVarReached(`multiplayer.rules[${i}].when`, rule.when, roomVars, playerVars, bound, zoneIds, ruleEc));
|
|
1024
1190
|
}
|
|
1025
1191
|
if (rule.when.on === 'zoneEnter' || rule.when.on === 'zoneExit' || rule.when.on === 'zoneInside') {
|
|
1026
1192
|
const zone = rule.when.zone;
|
|
@@ -1116,11 +1282,14 @@ function firewallBindings(when, timers, entityZoneKinds, zoneSelfKinds) {
|
|
|
1116
1282
|
return { self: { type: 'entity', kind: keyed.slice('entity:'.length) } };
|
|
1117
1283
|
return {};
|
|
1118
1284
|
}
|
|
1285
|
+
case 'varReached':
|
|
1286
|
+
// §7.5 (P3-B5) room scope binds nothing; self binds the player; entity binds the watched kind's instance.
|
|
1287
|
+
return when.scope === 'entity' ? { self: { type: 'entity', kind: when.kind ?? '' } } : when.scope === 'self' ? { self: { type: 'player' } } : {};
|
|
1119
1288
|
case 'tick':
|
|
1120
1289
|
case 'stateEnter':
|
|
1121
1290
|
case 'stateExit':
|
|
1122
1291
|
return {};
|
|
1123
|
-
default: // playerJoin/Leave/Disconnect/Reconnect, action
|
|
1292
|
+
default: // playerJoin/Leave/Disconnect/Reconnect, action — all self=player (self-scoped)
|
|
1124
1293
|
return { self: { type: 'player' } };
|
|
1125
1294
|
}
|
|
1126
1295
|
}
|
|
@@ -1136,6 +1305,8 @@ function walkRuleEffects(rules, timers, entityZoneKinds, zoneSelfKinds, visit) {
|
|
|
1136
1305
|
walk(p, eff.then, { ...b, [eff.as]: { type: 'player' } });
|
|
1137
1306
|
if (eff.do === 'forEachEntity')
|
|
1138
1307
|
walk(p, eff.then, { ...b, [eff.as]: { type: 'entity', kind: eff.kind } });
|
|
1308
|
+
if (eff.do === 'forEachInList')
|
|
1309
|
+
walk(p, eff.then, { ...b, [eff.as]: { type: 'listElem' } }); // §5 (P3 ext) element type unresolved here → coarse (untainted) binding
|
|
1139
1310
|
if (eff.do === 'spawnEntity' && eff.bind === 'spawned')
|
|
1140
1311
|
b = { ...b, spawned: { type: 'entity', kind: eff.kind } };
|
|
1141
1312
|
});
|
|
@@ -1146,8 +1317,13 @@ function walkRuleEffects(rules, timers, entityZoneKinds, zoneSelfKinds, visit) {
|
|
|
1146
1317
|
// clean. A `{var:'scope.name'}` is a ref var used as a reference → tainted iff that var is in the taint set:
|
|
1147
1318
|
// an owner entity's ref var (seeded), or a var transitively setRef'd from a tainted ref.
|
|
1148
1319
|
function refTainted(ref, bindings, taint) {
|
|
1149
|
-
if (ref === null || typeof ref !== 'object'
|
|
1320
|
+
if (ref === null || typeof ref !== 'object')
|
|
1150
1321
|
return false;
|
|
1322
|
+
// §9 (P3) the controller of a tainted (client-uploaded) entity is itself tainted — propagate through controllerOf so
|
|
1323
|
+
// it's caught in a {ref,var} read or a target sink. Other ref ops (nearestPlayer/leastLoadedPlayer/aggregate) are
|
|
1324
|
+
// server reductions over server state → never client-tainted.
|
|
1325
|
+
if ('op' in ref)
|
|
1326
|
+
return ref.op === 'controllerOf' && refTainted(ref.entity, bindings, taint);
|
|
1151
1327
|
const path = ref.var;
|
|
1152
1328
|
const dot = path.indexOf('.');
|
|
1153
1329
|
if (dot < 0)
|
|
@@ -1159,6 +1335,8 @@ function refTainted(ref, bindings, taint) {
|
|
|
1159
1335
|
const b = bindings[scope];
|
|
1160
1336
|
if (!b)
|
|
1161
1337
|
return false;
|
|
1338
|
+
if (b.type !== 'player' && b.type !== 'entity')
|
|
1339
|
+
return false; // §5 (P3 ext) a list-element binding — server-written, never client-tainted
|
|
1162
1340
|
return b.type === 'player' ? taint.player.has(name) : taint.entity.has(`${b.kind}.${name}`);
|
|
1163
1341
|
}
|
|
1164
1342
|
// Mark the var an lvalue writes as tainted (used when setRef stores a tainted ref into it). A {ref,var} lvalue
|
|
@@ -1179,8 +1357,8 @@ function taintLvalue(target, bindings, taint, allKinds) {
|
|
|
1179
1357
|
return;
|
|
1180
1358
|
if (b.type === 'player')
|
|
1181
1359
|
taint.player.add(name);
|
|
1182
|
-
else
|
|
1183
|
-
taint.entity.add(`${b.kind}.${name}`);
|
|
1360
|
+
else if (b.type === 'entity')
|
|
1361
|
+
taint.entity.add(`${b.kind}.${name}`); // a list-element binding is never a string-lvalue scope (self is player/entity)
|
|
1184
1362
|
return;
|
|
1185
1363
|
}
|
|
1186
1364
|
const r = target.ref;
|
|
@@ -1280,11 +1458,41 @@ function checkFirewall(m) {
|
|
|
1280
1458
|
flag(eff.to, `${eff.do} to`);
|
|
1281
1459
|
break;
|
|
1282
1460
|
case 'append':
|
|
1283
|
-
|
|
1461
|
+
// §5 (P3) a ref-addressed target is a cross-member write — taint-flag it like add/set; the value's reads too.
|
|
1462
|
+
if (typeof eff.target !== 'string')
|
|
1463
|
+
flag(eff.target.ref, 'append write-through-ref');
|
|
1464
|
+
if (isRecordLiteral(eff.value))
|
|
1465
|
+
for (const v of Object.values(eff.value))
|
|
1466
|
+
flagReads(v, b, path, 'append value');
|
|
1467
|
+
else
|
|
1468
|
+
flagReads(eff.value, b, path, 'append value');
|
|
1469
|
+
break;
|
|
1470
|
+
case 'clear':
|
|
1471
|
+
if (typeof eff.target !== 'string')
|
|
1472
|
+
flag(eff.target.ref, 'clear write-through-ref'); // §5 (P3) cross-member write
|
|
1284
1473
|
break;
|
|
1285
1474
|
case 'addCount':
|
|
1475
|
+
if (typeof eff.target !== 'string')
|
|
1476
|
+
flag(eff.target.ref, 'addCount write-through-ref'); // §5 (P3) cross-member write
|
|
1286
1477
|
flagReads(eff.by, b, path, 'addCount value'); // §5 (4.5.13) row-13 read-leak in the count delta
|
|
1287
1478
|
break;
|
|
1479
|
+
case 'removeAt':
|
|
1480
|
+
if (typeof eff.target !== 'string')
|
|
1481
|
+
flag(eff.target.ref, 'removeAt write-through-ref'); // §5 (P3) cross-member write
|
|
1482
|
+
flagReads(eff.index, b, path, 'removeAt index');
|
|
1483
|
+
break;
|
|
1484
|
+
case 'setField':
|
|
1485
|
+
if (eff.target !== undefined && typeof eff.target !== 'string')
|
|
1486
|
+
flag(eff.target.ref, 'setField write-through-ref'); // §5 (P3) cross-member write (index-form; the as-form has no target)
|
|
1487
|
+
if (eff.index !== undefined)
|
|
1488
|
+
flagReads(eff.index, b, path, 'setField index');
|
|
1489
|
+
flagReads(eff.to, b, path, 'setField value');
|
|
1490
|
+
break;
|
|
1491
|
+
case 'removeWhere':
|
|
1492
|
+
if (typeof eff.target !== 'string')
|
|
1493
|
+
flag(eff.target.ref, 'removeWhere write-through-ref'); // §5 (P3 ext) cross-member write
|
|
1494
|
+
flagReads(eff.where, { ...b, [eff.as]: { type: 'listElem' } }, path, 'removeWhere where'); // the element binding shadows an outer name + is untainted
|
|
1495
|
+
break;
|
|
1288
1496
|
case 'startTimer':
|
|
1289
1497
|
case 'cancelTimer':
|
|
1290
1498
|
if (eff.key !== undefined)
|
|
@@ -1307,6 +1515,9 @@ function checkFirewall(m) {
|
|
|
1307
1515
|
case 'forEachPlayer':
|
|
1308
1516
|
flagReads(eff.where, b, path, 'forEachPlayer where'); // row 13
|
|
1309
1517
|
break;
|
|
1518
|
+
case 'forEachInList':
|
|
1519
|
+
flagReads(eff.where, { ...b, [eff.as]: { type: 'listElem' } }, path, 'forEachInList where'); // row 13 (the element binding is untainted; the body is walked separately)
|
|
1520
|
+
break;
|
|
1310
1521
|
case 'broadcast':
|
|
1311
1522
|
// Forward-defense: the schema's broadcast `to` accepts only a bound name / server reduction (refOp) /
|
|
1312
1523
|
// team filter — never a {var} ref-var deref — so a client-uploaded id can't reach the TARGET today. This
|
|
@@ -1327,24 +1538,51 @@ function checkFirewall(m) {
|
|
|
1327
1538
|
});
|
|
1328
1539
|
return errors;
|
|
1329
1540
|
}
|
|
1330
|
-
// Validate a varReached event (§7.5): the watched var must be a declared scalar in its scope (vec3/ref
|
|
1331
|
-
// aren't comparable),
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1541
|
+
// Validate a varReached event (§7.5): the watched var must be a declared scalar in its scope (vec3/ref/collection
|
|
1542
|
+
// aren't comparable), ordering cmps need a number var, and the threshold (a literal, or a {var}/{ref,var} read —
|
|
1543
|
+
// P3-B5) must match the watched var's type. Entity scope (P3-B5) watches a declared `kind`'s var (binds self=entity).
|
|
1544
|
+
function checkVarReached(path, w, roomVars, playerVars, bound, zoneIds, ec) {
|
|
1545
|
+
let vt;
|
|
1546
|
+
let scopeLabel;
|
|
1547
|
+
if (w.scope === 'entity') {
|
|
1548
|
+
if (w.kind === undefined)
|
|
1549
|
+
return [`${path}: varReached scope:"entity" requires "kind" (the entity kind to watch)`];
|
|
1550
|
+
if (!ec.kinds.has(w.kind))
|
|
1551
|
+
return [`${path}: varReached watches unknown entity kind "${w.kind}"${didYouMean(w.kind, [...ec.kinds])} (declared entities: ${[...ec.kinds].join(', ') || 'none'})`];
|
|
1552
|
+
const kv = ec.kindVars[w.kind] ?? {};
|
|
1553
|
+
vt = kv[w.var];
|
|
1554
|
+
scopeLabel = `entity:${w.kind}`;
|
|
1555
|
+
if (!vt)
|
|
1556
|
+
return [`${path}: varReached watches unknown var "${w.var}" for entity kind "${w.kind}"${didYouMean(w.var, Object.keys(kv))} (declared: ${Object.keys(kv).join(', ') || 'none'})`];
|
|
1338
1557
|
}
|
|
1339
|
-
|
|
1340
|
-
|
|
1558
|
+
else {
|
|
1559
|
+
if (w.kind !== undefined)
|
|
1560
|
+
return [`${path}: varReached "kind" is only valid with scope:"entity"`];
|
|
1561
|
+
const decls = w.scope === 'room' ? roomVars : playerVars;
|
|
1562
|
+
vt = decls[w.var];
|
|
1563
|
+
scopeLabel = w.scope;
|
|
1564
|
+
if (!vt) {
|
|
1565
|
+
const declared = Object.keys(decls);
|
|
1566
|
+
return [`${path}: varReached watches unknown ${w.scope} var "${w.var}"${didYouMean(w.var, declared)} (declared ${w.scope} vars: ${declared.join(', ') || 'none'})`];
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
if (vt.type === 'vec3' || vt.type === 'ref' || vt.type === 'list' || vt.type === 'counterMap') {
|
|
1570
|
+
return [`${path}: varReached can't compare a "${vt.type}" var (${scopeLabel}.${w.var}) — use a number/string/boolean var`];
|
|
1341
1571
|
}
|
|
1342
1572
|
const e = [];
|
|
1343
1573
|
const ordering = w.cmp === '<' || w.cmp === '<=' || w.cmp === '>' || w.cmp === '>=';
|
|
1344
1574
|
if (ordering && vt.type !== 'number')
|
|
1345
|
-
e.push(`${path}: cmp "${w.cmp}" needs a number var, but ${
|
|
1346
|
-
if (typeof w.value !==
|
|
1347
|
-
|
|
1575
|
+
e.push(`${path}: cmp "${w.cmp}" needs a number var, but ${scopeLabel}.${w.var} is "${vt.type}"`);
|
|
1576
|
+
if (typeof w.value === 'object' && w.value !== null) {
|
|
1577
|
+
// P3-B5 a dynamic threshold ({var}/{ref,var}) — validate as an expr; its type must match the watched var.
|
|
1578
|
+
const r = validateExpr(`${path}.value`, w.value, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1579
|
+
e.push(...r.errors);
|
|
1580
|
+
if (r.type !== undefined && r.type !== vt.type)
|
|
1581
|
+
e.push(`${path}: threshold value is "${r.type}" but ${scopeLabel}.${w.var} is "${vt.type}"`);
|
|
1582
|
+
}
|
|
1583
|
+
else if (typeof w.value !== vt.type) {
|
|
1584
|
+
e.push(`${path}: value (${JSON.stringify(w.value)}) does not match ${scopeLabel}.${w.var} ("${vt.type}")`);
|
|
1585
|
+
}
|
|
1348
1586
|
return e;
|
|
1349
1587
|
}
|
|
1350
1588
|
// Reserved binding/scope names a `forEachPlayer as:` (or future bind) may not shadow (§7.1).
|
|
@@ -1361,6 +1599,8 @@ function checkEffect(path, eff, bound, roomVars, playerVars, zoneIds, events, ph
|
|
|
1361
1599
|
return checkForEach(path, eff, bound, roomVars, playerVars, zoneIds, events, phases, ec);
|
|
1362
1600
|
if (eff.do === 'forEachEntity')
|
|
1363
1601
|
return checkForEachEntity(path, eff, bound, roomVars, playerVars, zoneIds, events, phases, ec);
|
|
1602
|
+
if (eff.do === 'forEachInList')
|
|
1603
|
+
return checkForEachInList(path, eff, bound, roomVars, playerVars, zoneIds, events, phases, ec);
|
|
1364
1604
|
if (eff.do === 'broadcast')
|
|
1365
1605
|
return checkBroadcast(path, eff, bound, roomVars, playerVars, zoneIds, events, ec);
|
|
1366
1606
|
if (eff.do === 'spawnEntity') {
|
|
@@ -1383,6 +1623,12 @@ function checkEffect(path, eff, bound, roomVars, playerVars, zoneIds, events, ph
|
|
|
1383
1623
|
e.push(`${path}.vars: unknown var "${name}" for entity kind "${eff.kind}"${didYouMean(name, Object.keys(decls))} (declared vars: ${Object.keys(decls).join(', ') || 'none'})`);
|
|
1384
1624
|
continue;
|
|
1385
1625
|
}
|
|
1626
|
+
// §5 (P3 Collections) a collection entity var can't be seeded at spawn (it has no scalar literal — it starts
|
|
1627
|
+
// empty/zeroed). Populate it with append/addCount in an entitySpawn rule (self = the new entity) instead.
|
|
1628
|
+
if (vt.type === 'list' || vt.type === 'counterMap') {
|
|
1629
|
+
e.push(`${path}.vars.${name}: a "${vt.type}" collection can't be seeded at spawn — it starts empty; populate it via append/addCount in an {on:'entitySpawn'} rule`);
|
|
1630
|
+
continue;
|
|
1631
|
+
}
|
|
1386
1632
|
if (vt.type === 'ref') {
|
|
1387
1633
|
e.push(...resolveRefSlot(`${path}.vars.${name}`, val, bound, roomVars, playerVars, zoneIds, ec).errors);
|
|
1388
1634
|
}
|
|
@@ -1419,8 +1665,19 @@ function checkEffect(path, eff, bound, roomVars, playerVars, zoneIds, events, ph
|
|
|
1419
1665
|
const declared = [...phases];
|
|
1420
1666
|
return [`${path}: transitionTo references unknown phase "${eff.phase}"${didYouMean(eff.phase, declared)} (declared phases: ${declared.join(', ') || 'none — add multiplayer.states'})`];
|
|
1421
1667
|
}
|
|
1422
|
-
if (eff.do === '
|
|
1423
|
-
return []; // §8 timer
|
|
1668
|
+
if (eff.do === 'cancelTimer')
|
|
1669
|
+
return []; // §8 timer effect — name validated in checkTimerRefs (no duration)
|
|
1670
|
+
if (eff.do === 'startTimer') {
|
|
1671
|
+
// §8 timer name + literal-floor live in checkTimerRefs; here we type-check the (possibly dynamic) `seconds`
|
|
1672
|
+
// duration expr to number + cap its node count, exactly like add.by / set.to.
|
|
1673
|
+
const r = validateExpr(`${path}.seconds`, eff.seconds, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1674
|
+
const e = [...r.errors];
|
|
1675
|
+
if (r.type !== undefined && r.type !== 'number')
|
|
1676
|
+
e.push(`${path}.seconds: startTimer needs a number duration, got "${r.type}"`);
|
|
1677
|
+
if (r.nodes > exports.MULTIPLAYER_CAPS.exprNodes)
|
|
1678
|
+
e.push(`${path}.seconds has ${r.nodes} expression nodes — the cap is ${exports.MULTIPLAYER_CAPS.exprNodes}`);
|
|
1679
|
+
return e;
|
|
1680
|
+
}
|
|
1424
1681
|
if (eff.do === 'teleport' || eff.do === 'respawn') {
|
|
1425
1682
|
const e = resolveRefSlot(`${path}.player`, eff.player, bound, roomVars, playerVars, zoneIds, ec).errors;
|
|
1426
1683
|
const r = validateExpr(`${path}.to`, eff.to, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
@@ -1430,44 +1687,121 @@ function checkEffect(path, eff, bound, roomVars, playerVars, zoneIds, events, ph
|
|
|
1430
1687
|
return e;
|
|
1431
1688
|
}
|
|
1432
1689
|
if (eff.do === 'append') {
|
|
1433
|
-
// §5 (4.5.13) append a value to a list var; the value must match
|
|
1434
|
-
|
|
1690
|
+
// §5 (4.5.13 + P3 Collections) append a value to a list var (string or {ref,var} target); the value must match
|
|
1691
|
+
// the list's ELEMENT type (a scalar Expr, a ref, or a record literal).
|
|
1692
|
+
const c = resolveCollectionLvalue(`${path}.target`, eff.target, bound, roomVars, playerVars, zoneIds, ec);
|
|
1435
1693
|
if (c.errors.length)
|
|
1436
1694
|
return c.errors;
|
|
1437
|
-
const e = [];
|
|
1438
1695
|
if (c.vt.type !== 'list')
|
|
1439
|
-
return [`${path}: "append" needs a list var, but
|
|
1440
|
-
|
|
1441
|
-
e.push(...r.errors);
|
|
1442
|
-
if (r.type !== undefined && r.type !== c.vt.of)
|
|
1443
|
-
e.push(`${path}.value: a "${r.type}" can't be appended to a list of "${c.vt.of}"`);
|
|
1444
|
-
return e;
|
|
1696
|
+
return [`${path}: "append" needs a list var, but ${lvalueLabel(eff.target)} is "${c.vt.type}"`];
|
|
1697
|
+
return validateListElementValue(`${path}.value`, eff.value, c.vt.of, bound, roomVars, playerVars, zoneIds, ec);
|
|
1445
1698
|
}
|
|
1446
1699
|
if (eff.do === 'clear') {
|
|
1447
1700
|
// §5 (4.5.13) empty a list OR reset every counterMap key to 0.
|
|
1448
|
-
const c =
|
|
1701
|
+
const c = resolveCollectionLvalue(`${path}.target`, eff.target, bound, roomVars, playerVars, zoneIds, ec);
|
|
1449
1702
|
if (c.errors.length)
|
|
1450
1703
|
return c.errors;
|
|
1451
1704
|
if (c.vt.type !== 'list' && c.vt.type !== 'counterMap')
|
|
1452
|
-
return [`${path}: "clear" needs a list or counterMap var, but
|
|
1705
|
+
return [`${path}: "clear" needs a list or counterMap var, but ${lvalueLabel(eff.target)} is "${c.vt.type}"`];
|
|
1453
1706
|
return [];
|
|
1454
1707
|
}
|
|
1455
1708
|
if (eff.do === 'addCount') {
|
|
1456
1709
|
// §5 (4.5.13) add a number `by` to a counterMap's declared `key`.
|
|
1457
|
-
const c =
|
|
1710
|
+
const c = resolveCollectionLvalue(`${path}.target`, eff.target, bound, roomVars, playerVars, zoneIds, ec);
|
|
1458
1711
|
if (c.errors.length)
|
|
1459
1712
|
return c.errors;
|
|
1460
1713
|
const e = [];
|
|
1461
1714
|
if (c.vt.type !== 'counterMap')
|
|
1462
|
-
e.push(`${path}: "addCount" needs a counterMap var, but
|
|
1715
|
+
e.push(`${path}: "addCount" needs a counterMap var, but ${lvalueLabel(eff.target)} is "${c.vt.type}"`);
|
|
1463
1716
|
else if (!c.vt.keys.includes(eff.key))
|
|
1464
|
-
e.push(`${path}.key: "${eff.key}" is not a declared key of
|
|
1717
|
+
e.push(`${path}.key: "${eff.key}" is not a declared key of ${lvalueLabel(eff.target)}${didYouMean(eff.key, c.vt.keys)} (keys: ${c.vt.keys.join(', ')})`);
|
|
1465
1718
|
const r = validateExpr(`${path}.by`, eff.by, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1466
1719
|
e.push(...r.errors);
|
|
1467
1720
|
if (r.type !== undefined && r.type !== 'number')
|
|
1468
1721
|
e.push(`${path}.by: "addCount" needs a number, got "${r.type}"`);
|
|
1469
1722
|
return e;
|
|
1470
1723
|
}
|
|
1724
|
+
if (eff.do === 'removeAt') {
|
|
1725
|
+
// §5 (P3) remove the element at `index` from a list (shift). The index is a number expr; out-of-range = no-op.
|
|
1726
|
+
const c = resolveCollectionLvalue(`${path}.target`, eff.target, bound, roomVars, playerVars, zoneIds, ec);
|
|
1727
|
+
if (c.errors.length)
|
|
1728
|
+
return c.errors;
|
|
1729
|
+
if (c.vt.type !== 'list')
|
|
1730
|
+
return [`${path}: "removeAt" needs a list var, but ${lvalueLabel(eff.target)} is "${c.vt.type}"`];
|
|
1731
|
+
const r = validateExpr(`${path}.index`, eff.index, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1732
|
+
const e = [...r.errors];
|
|
1733
|
+
if (r.type !== undefined && r.type !== 'number')
|
|
1734
|
+
e.push(`${path}.index: "removeAt" needs a number index, got "${r.type}"`);
|
|
1735
|
+
return e;
|
|
1736
|
+
}
|
|
1737
|
+
if (eff.do === 'removeWhere')
|
|
1738
|
+
return checkRemoveWhere(path, eff, bound, roomVars, playerVars, zoneIds, ec);
|
|
1739
|
+
if (eff.do === 'setField') {
|
|
1740
|
+
// §5 (P3) write a record element's `field`. AS-form: address a record element `as` bound by an enclosing
|
|
1741
|
+
// forEachInList (no target/index). INDEX-form: address element `index` of the list `target`. A helper validates
|
|
1742
|
+
// the `to` value matches the resolved record field `ft`.
|
|
1743
|
+
const checkTo = (ft) => {
|
|
1744
|
+
if (ft.type === 'ref')
|
|
1745
|
+
return resolveRefSlot(`${path}.to`, eff.to, bound, roomVars, playerVars, zoneIds, ec).errors;
|
|
1746
|
+
const vr = validateExpr(`${path}.to`, eff.to, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1747
|
+
const e = [...vr.errors];
|
|
1748
|
+
if (vr.type !== undefined && vr.type !== ft.type)
|
|
1749
|
+
e.push(`${path}.to: a "${vr.type}" doesn't match the record field "${eff.field}" ("${ft.type}")`);
|
|
1750
|
+
return e;
|
|
1751
|
+
};
|
|
1752
|
+
if (eff.as !== undefined) {
|
|
1753
|
+
const e = [];
|
|
1754
|
+
if (eff.index !== undefined)
|
|
1755
|
+
e.push(`${path}: "setField" can't take both "as" and "index" — "as" addresses the element forEachInList bound`);
|
|
1756
|
+
const k = ec.boundKinds?.[eff.as];
|
|
1757
|
+
if (!bound.has(eff.as) || k?.type !== 'listRecord')
|
|
1758
|
+
return [...e, `${path}.as: "${eff.as}" is not a record element bound by an enclosing forEachInList`];
|
|
1759
|
+
const ft = k.fields[eff.field];
|
|
1760
|
+
if (!ft)
|
|
1761
|
+
return [...e, `${path}.field: unknown record field "${eff.field}"${didYouMean(eff.field, Object.keys(k.fields))} (fields: ${Object.keys(k.fields).join(', ')})`];
|
|
1762
|
+
return [...e, ...checkTo(ft)];
|
|
1763
|
+
}
|
|
1764
|
+
if (eff.target === undefined)
|
|
1765
|
+
return [`${path}: "setField" needs a "target" (with an "index"), or an "as" bound by an enclosing forEachInList`];
|
|
1766
|
+
const c = resolveCollectionLvalue(`${path}.target`, eff.target, bound, roomVars, playerVars, zoneIds, ec);
|
|
1767
|
+
if (c.errors.length)
|
|
1768
|
+
return c.errors;
|
|
1769
|
+
if (c.vt.type !== 'list' || typeof c.vt.of !== 'object' || c.vt.of.type !== 'record') {
|
|
1770
|
+
return [`${path}: "setField" needs a list of records, but ${lvalueLabel(eff.target)} is "${c.vt.type === 'list' ? `list of ${typeof c.vt.of === 'string' ? c.vt.of : c.vt.of.type}` : c.vt.type}"`];
|
|
1771
|
+
}
|
|
1772
|
+
if (eff.index === undefined)
|
|
1773
|
+
return [`${path}: "setField" needs an "index" (or an "as" bound by forEachInList)`];
|
|
1774
|
+
const e = [];
|
|
1775
|
+
const r = validateExpr(`${path}.index`, eff.index, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1776
|
+
e.push(...r.errors);
|
|
1777
|
+
if (r.type !== undefined && r.type !== 'number')
|
|
1778
|
+
e.push(`${path}.index: "setField" needs a number index, got "${r.type}"`);
|
|
1779
|
+
const ft = c.vt.of.fields[eff.field];
|
|
1780
|
+
if (!ft)
|
|
1781
|
+
e.push(`${path}.field: unknown record field "${eff.field}"${didYouMean(eff.field, Object.keys(c.vt.of.fields))} (fields: ${Object.keys(c.vt.of.fields).join(', ')})`);
|
|
1782
|
+
else
|
|
1783
|
+
e.push(...checkTo(ft));
|
|
1784
|
+
return e;
|
|
1785
|
+
}
|
|
1786
|
+
if (eff.do === 'advanceTurn') {
|
|
1787
|
+
// §7.4 (P3-B2) `order` must be a `list of ref:player`; `index` a writable number var.
|
|
1788
|
+
const c = resolveCollectionLvalue(`${path}.order`, eff.order, bound, roomVars, playerVars, zoneIds, ec);
|
|
1789
|
+
if (c.errors.length)
|
|
1790
|
+
return c.errors;
|
|
1791
|
+
const ofElem = c.vt.type === 'list' ? c.vt.of : undefined;
|
|
1792
|
+
if (c.vt.type !== 'list' || typeof ofElem !== 'object' || ofElem.type !== 'ref' || ofElem.of !== 'player') {
|
|
1793
|
+
return [`${path}.order: "advanceTurn" needs a list of player refs (got ${lvalueLabel(eff.order)})`];
|
|
1794
|
+
}
|
|
1795
|
+
const idx = resolveLvalue(`${path}.index`, eff.index, bound, roomVars, playerVars, zoneIds, ec);
|
|
1796
|
+
const e = [...idx.errors];
|
|
1797
|
+
if (idx.type !== undefined && idx.type !== 'number')
|
|
1798
|
+
e.push(`${path}.index: "advanceTurn" needs a number var, but ${lvalueLabel(eff.index)} is "${idx.type}"`);
|
|
1799
|
+
return e;
|
|
1800
|
+
}
|
|
1801
|
+
if (eff.do === 'eliminate' || eff.do === 'revive') {
|
|
1802
|
+
// §7.4 (P3-B2) `player` is a Ref (firewall-checked); the room toggles its reserved `active` flag.
|
|
1803
|
+
return resolveRefSlot(`${path}.player`, eff.player, bound, roomVars, playerVars, zoneIds, ec).errors;
|
|
1804
|
+
}
|
|
1471
1805
|
// add | set | setRef — all have a target lvalue. (TS can't reliably narrow this recursive union past the
|
|
1472
1806
|
// early returns above, so assert the remaining subtype; `ve.do` still discriminates `to` within it.)
|
|
1473
1807
|
const ve = eff;
|
|
@@ -1528,7 +1862,7 @@ function checkForEach(path, eff, bound, roomVars, playerVars, zoneIds, events, p
|
|
|
1528
1862
|
e.push(`${path}.then has ${eff.then.length} effects — the cap is ${exports.MULTIPLAYER_CAPS.ruleThen}`);
|
|
1529
1863
|
let running = inner;
|
|
1530
1864
|
eff.then.forEach((sub, j) => {
|
|
1531
|
-
if (sub.do === 'forEachPlayer' || sub.do === 'forEachEntity') {
|
|
1865
|
+
if (sub.do === 'forEachPlayer' || sub.do === 'forEachEntity' || sub.do === 'forEachInList') {
|
|
1532
1866
|
e.push(`${path}.then[${j}]: ${sub.do} can't nest inside forEachPlayer (single level only)`);
|
|
1533
1867
|
return;
|
|
1534
1868
|
}
|
|
@@ -1562,7 +1896,7 @@ function checkForEachEntity(path, eff, bound, roomVars, playerVars, zoneIds, eve
|
|
|
1562
1896
|
let running = inner;
|
|
1563
1897
|
let runningEc = innerEc;
|
|
1564
1898
|
eff.then.forEach((sub, j) => {
|
|
1565
|
-
if (sub.do === 'forEachPlayer' || sub.do === 'forEachEntity') {
|
|
1899
|
+
if (sub.do === 'forEachPlayer' || sub.do === 'forEachEntity' || sub.do === 'forEachInList') {
|
|
1566
1900
|
e.push(`${path}.then[${j}]: ${sub.do} can't nest inside forEachEntity (single level only)`);
|
|
1567
1901
|
return;
|
|
1568
1902
|
}
|
|
@@ -1576,6 +1910,95 @@ function checkForEachEntity(path, eff, bound, roomVars, playerVars, zoneIds, eve
|
|
|
1576
1910
|
});
|
|
1577
1911
|
return e;
|
|
1578
1912
|
}
|
|
1913
|
+
// §5 (P3 ext) the static binding kind for a list element of type `elem` (the name a forEachInList / listCount /
|
|
1914
|
+
// listIndexOf scan binds). A ref element flows like the member it points at (player/entity); a record element
|
|
1915
|
+
// exposes its fields ({ref:as,var:field}); a scalar element is a bound value ({var:as}). undefined elem (the list
|
|
1916
|
+
// didn't resolve) → the coarse listElem so downstream errors are about the list, not a spurious binding mismatch.
|
|
1917
|
+
function elemBinding(elem) {
|
|
1918
|
+
if (elem === undefined)
|
|
1919
|
+
return { type: 'listElem' };
|
|
1920
|
+
if (typeof elem === 'string')
|
|
1921
|
+
return { type: 'listScalar', scalar: elem };
|
|
1922
|
+
if (elem.type === 'ref')
|
|
1923
|
+
return ofToKind(elem.of) ?? { type: 'listElem' };
|
|
1924
|
+
return { type: 'listRecord', fields: elem.fields };
|
|
1925
|
+
}
|
|
1926
|
+
// §5 (P3 ext) two lvalues address the same collection (structural equality — a dotted path or a {ref,var}). Used to
|
|
1927
|
+
// forbid structurally mutating the very list a forEachInList iterates (the indices/elements would shift mid-scan).
|
|
1928
|
+
function sameLvalue(a, b) {
|
|
1929
|
+
if (typeof a === 'string' || typeof b === 'string')
|
|
1930
|
+
return a === b;
|
|
1931
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
1932
|
+
}
|
|
1933
|
+
// §5 (P3 ext) does `sub` STRUCTURALLY mutate the list `list` (append/clear/removeAt/removeWhere on the same lvalue)?
|
|
1934
|
+
// A setField (record-field write, no length change) is allowed on the iterated list — it's the point of the loop.
|
|
1935
|
+
function mutatesIteratedList(sub, list) {
|
|
1936
|
+
if (sub.do === 'append' || sub.do === 'clear' || sub.do === 'removeAt' || sub.do === 'removeWhere')
|
|
1937
|
+
return sameLvalue(sub.target, list);
|
|
1938
|
+
return false;
|
|
1939
|
+
}
|
|
1940
|
+
// §5 (P3 Collections ext) validate forEachInList — the bounded loop over a list's ELEMENTS (the §13 sublanguage
|
|
1941
|
+
// completion). Like forEachEntity (single level, optional `where`, reductions forbidden inside) but iterates a
|
|
1942
|
+
// collection lvalue and binds `as` to the element (its kind from elemBinding — scalar value / member Ref / record).
|
|
1943
|
+
// Can't structurally mutate the list it iterates (use removeWhere); the per-tick budget charges maxLen × body.
|
|
1944
|
+
function checkForEachInList(path, eff, bound, roomVars, playerVars, zoneIds, events, phases, ec) {
|
|
1945
|
+
const e = [];
|
|
1946
|
+
const c = resolveCollectionLvalue(`${path}.list`, eff.list, bound, roomVars, playerVars, zoneIds, ec);
|
|
1947
|
+
if (c.errors.length)
|
|
1948
|
+
e.push(...c.errors);
|
|
1949
|
+
else if (c.vt.type !== 'list')
|
|
1950
|
+
e.push(`${path}.list: forEachInList needs a list var, but ${lvalueLabel(eff.list)} is "${c.vt.type}"`);
|
|
1951
|
+
if (RESERVED_REF_NAMES.has(eff.as))
|
|
1952
|
+
e.push(`${path}.as: "${eff.as}" is a reserved name`);
|
|
1953
|
+
const elem = c.vt?.type === 'list' ? c.vt.of : undefined;
|
|
1954
|
+
const inner = new Set([...bound, eff.as]);
|
|
1955
|
+
const innerEc = { ...ec, boundKinds: { ...ec.boundKinds, [eff.as]: elemBinding(elem) } };
|
|
1956
|
+
if (eff.where !== undefined) {
|
|
1957
|
+
e.push(...validateExpr(`${path}.where`, eff.where, inner, roomVars, playerVars, zoneIds, innerEc, 1).errors);
|
|
1958
|
+
if (exprHasReduction(eff.where))
|
|
1959
|
+
e.push(`${path}.where: aggregate/nearest* are forbidden inside forEachInList`);
|
|
1960
|
+
}
|
|
1961
|
+
if (eff.then.length > exports.MULTIPLAYER_CAPS.ruleThen)
|
|
1962
|
+
e.push(`${path}.then has ${eff.then.length} effects — the cap is ${exports.MULTIPLAYER_CAPS.ruleThen}`);
|
|
1963
|
+
let running = inner;
|
|
1964
|
+
let runningEc = innerEc;
|
|
1965
|
+
eff.then.forEach((sub, j) => {
|
|
1966
|
+
if (sub.do === 'forEachPlayer' || sub.do === 'forEachEntity' || sub.do === 'forEachInList') {
|
|
1967
|
+
e.push(`${path}.then[${j}]: ${sub.do} can't nest inside forEachInList (single level only)`);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
if (mutatesIteratedList(sub, eff.list))
|
|
1971
|
+
e.push(`${path}.then[${j}]: "${sub.do}" can't mutate the list forEachInList is iterating — use removeWhere to scan-and-remove, or setField to edit an element in place`);
|
|
1972
|
+
e.push(...checkEffect(`${path}.then[${j}]`, sub, running, roomVars, playerVars, zoneIds, events, phases, runningEc));
|
|
1973
|
+
if (effectHasReduction(sub))
|
|
1974
|
+
e.push(`${path}.then[${j}]: aggregate/nearest* are forbidden inside forEachInList`);
|
|
1975
|
+
if (sub.do === 'spawnEntity' && sub.bind === 'spawned') {
|
|
1976
|
+
running = new Set([...running, 'spawned']);
|
|
1977
|
+
runningEc = { ...runningEc, boundKinds: { ...runningEc.boundKinds, spawned: { type: 'entity', kind: sub.kind } } };
|
|
1978
|
+
}
|
|
1979
|
+
});
|
|
1980
|
+
return e;
|
|
1981
|
+
}
|
|
1982
|
+
// §5 (P3 Collections ext) validate removeWhere — drop ALL elements of a list matching `where` (a bounded scan that
|
|
1983
|
+
// binds `as` to the element, exactly like forEachInList's binding). No nested effects; the `where` is read-only and
|
|
1984
|
+
// can't contain a reduction (O(maxLen) scan already). Used to scan-and-remove (cull broken/dead elements).
|
|
1985
|
+
function checkRemoveWhere(path, eff, bound, roomVars, playerVars, zoneIds, ec) {
|
|
1986
|
+
const e = [];
|
|
1987
|
+
const c = resolveCollectionLvalue(`${path}.target`, eff.target, bound, roomVars, playerVars, zoneIds, ec);
|
|
1988
|
+
if (c.errors.length)
|
|
1989
|
+
e.push(...c.errors);
|
|
1990
|
+
else if (c.vt.type !== 'list')
|
|
1991
|
+
e.push(`${path}.target: "removeWhere" needs a list var, but ${lvalueLabel(eff.target)} is "${c.vt.type}"`);
|
|
1992
|
+
if (RESERVED_REF_NAMES.has(eff.as))
|
|
1993
|
+
e.push(`${path}.as: "${eff.as}" is a reserved name`);
|
|
1994
|
+
const elem = c.vt?.type === 'list' ? c.vt.of : undefined;
|
|
1995
|
+
const inner = new Set([...bound, eff.as]);
|
|
1996
|
+
const innerEc = { ...ec, boundKinds: { ...ec.boundKinds, [eff.as]: elemBinding(elem) } };
|
|
1997
|
+
e.push(...validateExpr(`${path}.where`, eff.where, inner, roomVars, playerVars, zoneIds, innerEc, 1).errors);
|
|
1998
|
+
if (exprHasReduction(eff.where))
|
|
1999
|
+
e.push(`${path}.where: aggregate/nearest* are forbidden inside removeWhere`);
|
|
2000
|
+
return e;
|
|
2001
|
+
}
|
|
1579
2002
|
// Does an expression contain an aggregate / a ref-returning op (nearestPlayer / aggregate argmax|argmin)? The
|
|
1580
2003
|
// reductions, which are O(members) scans and so forbidden inside forEachPlayer (would be O(members²)).
|
|
1581
2004
|
function exprHasReduction(expr) {
|
|
@@ -1592,12 +2015,28 @@ function exprHasReduction(expr) {
|
|
|
1592
2015
|
return node.of.some(exprHasReduction);
|
|
1593
2016
|
if (node.op === 'not')
|
|
1594
2017
|
return exprHasReduction(node.of);
|
|
2018
|
+
if (node.op === 'controlledBy') {
|
|
2019
|
+
const cb = node;
|
|
2020
|
+
return refHasReduction(cb.entity) || refHasReduction(cb.by);
|
|
2021
|
+
} // §9 (P3) O(1) itself — only its refs can reduce
|
|
2022
|
+
if (node.op === 'sameRef') {
|
|
2023
|
+
const sr = node;
|
|
2024
|
+
return refHasReduction(sr.a) || refHasReduction(sr.b);
|
|
2025
|
+
} // §7.3 (P3-B2) O(1) itself — only its refs can reduce
|
|
2026
|
+
if (node.op === 'hostLoad')
|
|
2027
|
+
return true; // §9 (P3) O(entities) scan — a reduction, forbidden inside forEach
|
|
1595
2028
|
if (node.a !== undefined)
|
|
1596
2029
|
return exprHasReduction(node.a) || exprHasReduction(node.b);
|
|
1597
2030
|
return false;
|
|
1598
2031
|
}
|
|
1599
2032
|
function refHasReduction(ref) {
|
|
1600
|
-
|
|
2033
|
+
if (typeof ref === 'string' || !('op' in ref))
|
|
2034
|
+
return false; // a bound name / {var} deref — O(1)
|
|
2035
|
+
if (ref.op === 'controllerOf')
|
|
2036
|
+
return refHasReduction(ref.entity); // §9 (P3) O(1) field read — only a reducing inner entity ref counts
|
|
2037
|
+
if (ref.op === 'listAt')
|
|
2038
|
+
return exprHasReduction(ref.index); // §5 (P3-B2) O(1) element read — only a reducing index expr counts
|
|
2039
|
+
return true; // nearestPlayer / nearestEntity / aggregate argmax|argmin / leastLoadedPlayer — O(members) scans
|
|
1601
2040
|
}
|
|
1602
2041
|
// Validate a `broadcast` effect (§7.4/§10.3): the event must be declared; `to` must be a valid target; the
|
|
1603
2042
|
// payload must exactly match the event's declared fields (no unknown, none missing), each value a Ref for a
|
|
@@ -1624,6 +2063,10 @@ function checkBroadcast(path, eff, bound, roomVars, playerVars, zoneIds, events,
|
|
|
1624
2063
|
if (ft.type === 'ref') {
|
|
1625
2064
|
e.push(...resolveRefSlot(`${path}.payload.${field}`, val, bound, roomVars, playerVars, zoneIds, ec).errors);
|
|
1626
2065
|
}
|
|
2066
|
+
else if (ft.type === 'list' || ft.type === 'counterMap') {
|
|
2067
|
+
// §10.3 (P3-B6) a collection-snapshot field: the value must reference a collection var of the matching shape.
|
|
2068
|
+
e.push(...checkCollectionPayload(`${path}.payload.${field}`, ft, val, bound, roomVars, playerVars, ec));
|
|
2069
|
+
}
|
|
1627
2070
|
else {
|
|
1628
2071
|
const r = validateExpr(`${path}.payload.${field}`, val, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1629
2072
|
e.push(...r.errors);
|
|
@@ -1633,6 +2076,26 @@ function checkBroadcast(path, eff, bound, roomVars, playerVars, zoneIds, events,
|
|
|
1633
2076
|
}
|
|
1634
2077
|
return e;
|
|
1635
2078
|
}
|
|
2079
|
+
// §10.3 (P3-B6) validate a collection-snapshot payload field's value: a `{var:"room/self.<coll>"}` reference to a
|
|
2080
|
+
// declared collection var whose type + shape (element / keys) equals the field's. The room serializes that live
|
|
2081
|
+
// collection into the broadcast. (A literal collection value isn't expressible — a snapshot is always of a var.)
|
|
2082
|
+
function checkCollectionPayload(path, ft, val, bound, roomVars, playerVars, ec) {
|
|
2083
|
+
if (typeof val !== 'object' || val === null || !('var' in val) || typeof val.var !== 'string') {
|
|
2084
|
+
return [`${path}: a "${ft.type}" payload field must reference a collection var ({ "var": "room.<v>" | "self.<v>" }) to snapshot`];
|
|
2085
|
+
}
|
|
2086
|
+
const c = resolveCollectionVar(path, val.var, bound, roomVars, playerVars, ec);
|
|
2087
|
+
if (c.errors.length)
|
|
2088
|
+
return c.errors;
|
|
2089
|
+
if (c.vt.type !== ft.type)
|
|
2090
|
+
return [`${path}: field is a "${ft.type}" but "${val.var}" is a "${c.vt.type}"`];
|
|
2091
|
+
if (ft.type === 'list' && c.vt.type === 'list' && JSON.stringify(c.vt.of) !== JSON.stringify(ft.of)) {
|
|
2092
|
+
return [`${path}: the referenced list's element type doesn't match the declared payload field element`];
|
|
2093
|
+
}
|
|
2094
|
+
if (ft.type === 'counterMap' && c.vt.type === 'counterMap' && JSON.stringify([...c.vt.keys].sort()) !== JSON.stringify([...ft.keys].sort())) {
|
|
2095
|
+
return [`${path}: the referenced counterMap's keys don't match the declared payload field keys`];
|
|
2096
|
+
}
|
|
2097
|
+
return [];
|
|
2098
|
+
}
|
|
1636
2099
|
// Validate a broadcast `to` target: "all", a Ref (bound name / ref-var / ref-op), or {team:"<playerVar>=<val>"}.
|
|
1637
2100
|
function checkBroadcastTarget(path, to, bound, roomVars, playerVars, zoneIds, ec) {
|
|
1638
2101
|
if (to === 'all')
|
|
@@ -1665,6 +2128,9 @@ function effectHasReduction(eff) {
|
|
|
1665
2128
|
return exprHasReduction(eff.at) || Object.values(eff.vars ?? {}).some((v) => exprHasReduction(v));
|
|
1666
2129
|
case 'destroyEntity':
|
|
1667
2130
|
return refHasReduction(eff.entity);
|
|
2131
|
+
case 'eliminate':
|
|
2132
|
+
case 'revive':
|
|
2133
|
+
return refHasReduction(eff.player); // §7.4 (P3-B2) only a reducing player ref counts
|
|
1668
2134
|
default:
|
|
1669
2135
|
return false;
|
|
1670
2136
|
}
|
|
@@ -1690,7 +2156,7 @@ function boundNames(event, timers, entityZoneKinds) {
|
|
|
1690
2156
|
case 'action':
|
|
1691
2157
|
return new Set(['self']); // the player who sent the action
|
|
1692
2158
|
case 'varReached':
|
|
1693
|
-
return event.scope === '
|
|
2159
|
+
return event.scope === 'room' ? new Set() : new Set(['self']); // self/entity scope binds the watched instance (player/entity); room binds nothing
|
|
1694
2160
|
case 'stateEnter':
|
|
1695
2161
|
case 'stateExit':
|
|
1696
2162
|
return new Set(); // §8 room-scoped phase machine — no member bound
|
|
@@ -1708,7 +2174,11 @@ function boundNames(event, timers, entityZoneKinds) {
|
|
|
1708
2174
|
}
|
|
1709
2175
|
// Player built-in reads (§7.1) addressable as `self.<name>` or `{ref,var}` — NOT declared vars, can't be
|
|
1710
2176
|
// shadowed. Phase-2.4a exposes `position` (vec3); Phase-3 presence adds `connected` (boolean, read-only).
|
|
1711
|
-
const PLAYER_BUILTIN_TYPES = { position: 'vec3', connected: 'boolean' };
|
|
2177
|
+
const PLAYER_BUILTIN_TYPES = { position: 'vec3', connected: 'boolean', active: 'boolean' };
|
|
2178
|
+
// `connected`/`active` are PLAYER-only presence/participation built-ins — an ENTITY member's same-named DECLARED var
|
|
2179
|
+
// wins (entities have no connection/participation). `position` is universal (players + entities both have a transform).
|
|
2180
|
+
const PLAYER_ONLY_BUILTINS = new Set(['connected', 'active']);
|
|
2181
|
+
const playerBuiltin = (name, isEntity) => name in PLAYER_BUILTIN_TYPES && !(isEntity && PLAYER_ONLY_BUILTINS.has(name));
|
|
1712
2182
|
// Room built-in reads addressable as `room.<name>` — NOT declared roomVars. Phase-3 (§8) exposes `phase` (the
|
|
1713
2183
|
// current state-machine phase, a string); it is read-only here (change it via transitionTo, not `set`).
|
|
1714
2184
|
const ROOM_BUILTIN_TYPES = { phase: 'string' };
|
|
@@ -1760,14 +2230,37 @@ function validateExpr(path, expr, bound, roomVars, playerVars, zoneIds, ec, dept
|
|
|
1760
2230
|
// op node — the schema already validated its shape, so read children via a generic view (TS can't reliably
|
|
1761
2231
|
// narrow this recursive union past the `op` discriminant). Gather labelled child sub-expressions:
|
|
1762
2232
|
const node = expr;
|
|
1763
|
-
if (node.op === 'playerCount' || node.op === 'random' || node.op === 'timeInState' || node.op === 'timerRemaining')
|
|
2233
|
+
if (node.op === 'playerCount' || node.op === 'random' || node.op === 'timeInState' || node.op === 'timerRemaining' || node.op === 'now')
|
|
1764
2234
|
return { errors: [], nodes: 1, type: 'number' }; // leaf ops (timer-name refs resolved in checkTimerRefs)
|
|
1765
2235
|
if (node.op === 'randomPoint')
|
|
1766
2236
|
return { errors: [], nodes: 1, type: 'vec3' }; // leaf → a random vec3 in the [min,max] box (shape schema-validated)
|
|
1767
2237
|
if (node.op === 'aggregate')
|
|
1768
2238
|
return { errors: validateAggregate(path, node, bound, roomVars, playerVars, zoneIds, ec), nodes: 1, type: 'number' };
|
|
1769
|
-
if (node.op === 'listLength' || node.op === 'listAt' || node.op === 'count')
|
|
1770
|
-
return validateCollectionOp(path, expr, bound, roomVars, playerVars, zoneIds, ec, depth); // §5 (4.5.13) collection reads
|
|
2239
|
+
if (node.op === 'listLength' || node.op === 'listAt' || node.op === 'count' || node.op === 'listCount' || node.op === 'listIndexOf')
|
|
2240
|
+
return validateCollectionOp(path, expr, bound, roomVars, playerVars, zoneIds, ec, depth); // §5 (4.5.13 + P3 ext) collection reads
|
|
2241
|
+
if (node.op === 'controlledBy') {
|
|
2242
|
+
// §9 (P3 Ownership/A3) boolean: is `entity` controlled by the player `by` resolves to? Both are ref slots.
|
|
2243
|
+
const cb = expr;
|
|
2244
|
+
const e = [
|
|
2245
|
+
...resolveRefSlot(`${path}.entity`, cb.entity, bound, roomVars, playerVars, zoneIds, ec).errors,
|
|
2246
|
+
...resolveRefSlot(`${path}.by`, cb.by, bound, roomVars, playerVars, zoneIds, ec).errors,
|
|
2247
|
+
];
|
|
2248
|
+
return { errors: e, nodes: 1, type: 'boolean' };
|
|
2249
|
+
}
|
|
2250
|
+
if (node.op === 'sameRef') {
|
|
2251
|
+
// §7.3 (P3-B2) boolean: do two Refs resolve to the same live member? Both are ref slots (O(1) identity test).
|
|
2252
|
+
const sr = expr;
|
|
2253
|
+
const e = [
|
|
2254
|
+
...resolveRefSlot(`${path}.a`, sr.a, bound, roomVars, playerVars, zoneIds, ec).errors,
|
|
2255
|
+
...resolveRefSlot(`${path}.b`, sr.b, bound, roomVars, playerVars, zoneIds, ec).errors,
|
|
2256
|
+
];
|
|
2257
|
+
return { errors: e, nodes: 1, type: 'boolean' };
|
|
2258
|
+
}
|
|
2259
|
+
if (node.op === 'hostLoad') {
|
|
2260
|
+
// §9 (P3 Ownership/B8) the count of live entities the player `of` controls → number. O(entities) scan.
|
|
2261
|
+
const hl = expr;
|
|
2262
|
+
return { errors: resolveRefSlot(`${path}.of`, hl.of, bound, roomVars, playerVars, zoneIds, ec).errors, nodes: 1, type: 'number' };
|
|
2263
|
+
}
|
|
1771
2264
|
let children;
|
|
1772
2265
|
if (node.op === 'not')
|
|
1773
2266
|
children = [[`${path}.of`, node.of]];
|
|
@@ -1865,12 +2358,15 @@ function validateAggregate(path, node, bound, roomVars, playerVars, zoneIds, ec)
|
|
|
1865
2358
|
e.push(`${path}: aggregate "${node.agg}" needs a "field" (a number ${entityDomain ? 'entity' : 'player'} var)`);
|
|
1866
2359
|
return e;
|
|
1867
2360
|
}
|
|
1868
|
-
// The field is a number var of the reduced collection
|
|
1869
|
-
//
|
|
1870
|
-
|
|
2361
|
+
// The field is a number var of the reduced collection. (P3-B4) For an entity domain (entities:<kind> or a zone
|
|
2362
|
+
// tracking 'entity:<kind>') resolve `field` against the SPECIFIC tracked kind's vars, not the entity-var union — a
|
|
2363
|
+
// field absent on that kind is a publish error, not a silent runtime 0. Player domain → player vars.
|
|
2364
|
+
const kindVars = entityDomain && memberKind.kind !== undefined ? (ec.kindVars[memberKind.kind] ?? {}) : undefined;
|
|
2365
|
+
const fieldType = entityDomain ? (kindVars ? kindVars[node.field]?.type : ec.varTypes[node.field]) : playerVars[node.field]?.type;
|
|
1871
2366
|
if (fieldType === undefined) {
|
|
1872
|
-
const declared = entityDomain ? Object.keys(ec.varTypes) : Object.keys(playerVars);
|
|
1873
|
-
|
|
2367
|
+
const declared = entityDomain ? Object.keys(kindVars ?? ec.varTypes) : Object.keys(playerVars);
|
|
2368
|
+
const domainLabel = entityDomain ? (memberKind.kind !== undefined ? `entity kind "${memberKind.kind}"` : 'entity') : 'player';
|
|
2369
|
+
e.push(`${path}: aggregate field — unknown ${domainLabel} var "${node.field}"${didYouMean(node.field, declared)} (declared: ${declared.join(', ') || 'none'})`);
|
|
1874
2370
|
}
|
|
1875
2371
|
else if (fieldType !== 'number') {
|
|
1876
2372
|
e.push(`${path}: aggregate "${node.agg}" needs a number field, but "${node.field}" is "${fieldType}"`);
|
|
@@ -1883,6 +2379,17 @@ function resolveVarRead(path, dotted, bound, roomVars, playerVars, ec) {
|
|
|
1883
2379
|
const dot = dotted.indexOf('.');
|
|
1884
2380
|
const scope = dot >= 0 ? dotted.slice(0, dot) : '';
|
|
1885
2381
|
const name = dot >= 0 ? dotted.slice(dot + 1) : '';
|
|
2382
|
+
// §5 (P3 ext) a bare bound name (no scope) → a list element bound by forEachInList / listCount / listIndexOf. A
|
|
2383
|
+
// SCALAR element reads as its value; a record/ref element points the author at its proper read form.
|
|
2384
|
+
if (dot < 0 && bound.has(dotted)) {
|
|
2385
|
+
const k = ec.boundKinds?.[dotted];
|
|
2386
|
+
if (k?.type === 'listScalar')
|
|
2387
|
+
return { errors: [], type: k.scalar };
|
|
2388
|
+
if (k?.type === 'listRecord')
|
|
2389
|
+
return { errors: [`${path}: "${dotted}" is a record list element — read a field via {ref:"${dotted}",var:"<field>"}, not {var}`] };
|
|
2390
|
+
if (k?.type === 'player' || k?.type === 'entity')
|
|
2391
|
+
return { errors: [`${path}: "${dotted}" is a ref element — use it as a ref ({ref:"${dotted}",var:…}), not {var}`] };
|
|
2392
|
+
}
|
|
1886
2393
|
if (scope === 'action') {
|
|
1887
2394
|
// §11.5 (4.7) action.args.<arg> — readable only inside an {on:action} rule (ec.actionArgs set). The type is
|
|
1888
2395
|
// the matched action's declared arg type; a ref arg returns 'ref' (resolveRefSlot then accepts it as a Ref).
|
|
@@ -1900,7 +2407,7 @@ function resolveVarRead(path, dotted, bound, roomVars, playerVars, ec) {
|
|
|
1900
2407
|
return { errors: [`${path}: var "${dotted}" must be "room.<var>", "self.<var>", or "action.args.<arg>"`] };
|
|
1901
2408
|
if (scope === 'self' && !bound.has('self'))
|
|
1902
2409
|
return { errors: [`${path}: "self.${name}" — this rule's event binds no member (no "self" here)`] };
|
|
1903
|
-
if (scope === 'self' && name
|
|
2410
|
+
if (scope === 'self' && playerBuiltin(name, ec.boundKinds?.self?.type === 'entity'))
|
|
1904
2411
|
return { errors: [], type: PLAYER_BUILTIN_TYPES[name] };
|
|
1905
2412
|
if (scope === 'room' && name in ROOM_BUILTIN_TYPES)
|
|
1906
2413
|
return { errors: [], type: ROOM_BUILTIN_TYPES[name] };
|
|
@@ -1934,10 +2441,10 @@ function collectionReadError(path, dotted, t) {
|
|
|
1934
2441
|
const how = t === 'list' ? 'read it via listLength/listAt, mutate via append/clear' : 'read it via count, mutate via addCount/clear';
|
|
1935
2442
|
return `${path}: "${dotted}" is a ${t} collection — it can't be used as a scalar value (${how})`;
|
|
1936
2443
|
}
|
|
1937
|
-
// §5 (4.5.13) resolve a "room.<var>"/"self.<var>" path to its declared COLLECTION var (list/counterMap).
|
|
1938
|
-
//
|
|
1939
|
-
// declared HelixVarType (caller checks list vs counterMap) or an error.
|
|
1940
|
-
function resolveCollectionVar(path, dotted, bound, roomVars, playerVars) {
|
|
2444
|
+
// §5 (4.5.13 + P3) resolve a "room.<var>"/"self.<var>" path to its declared COLLECTION var (list/counterMap).
|
|
2445
|
+
// `self` resolves against playerVars, OR — when the event binds self to an entity (P3 entity collections) — against
|
|
2446
|
+
// that entity kind's vars. Returns the declared HelixVarType (caller checks list vs counterMap) or an error.
|
|
2447
|
+
function resolveCollectionVar(path, dotted, bound, roomVars, playerVars, ec) {
|
|
1941
2448
|
const dot = dotted.indexOf('.');
|
|
1942
2449
|
const scope = dot >= 0 ? dotted.slice(0, dot) : '';
|
|
1943
2450
|
const name = dot >= 0 ? dotted.slice(dot + 1) : '';
|
|
@@ -1945,50 +2452,134 @@ function resolveCollectionVar(path, dotted, bound, roomVars, playerVars) {
|
|
|
1945
2452
|
return { errors: [`${path}: "${dotted}" must be a "room.<var>" or "self.<var>" collection`] };
|
|
1946
2453
|
if (scope === 'self' && !bound.has('self'))
|
|
1947
2454
|
return { errors: [`${path}: "self.${name}" — this rule's event binds no member (no "self" here)`] };
|
|
2455
|
+
if (scope === 'self' && ec.boundKinds?.self?.type === 'entity') {
|
|
2456
|
+
const kind = ec.boundKinds.self.kind;
|
|
2457
|
+
const vt = ec.kindVars[kind]?.[name];
|
|
2458
|
+
if (!vt)
|
|
2459
|
+
return { errors: [`${path}: unknown var "${name}" for entity kind "${kind}"${didYouMean(name, Object.keys(ec.kindVars[kind] ?? {}))} (declared: ${Object.keys(ec.kindVars[kind] ?? {}).join(', ') || 'none'})`] };
|
|
2460
|
+
return { errors: [], vt };
|
|
2461
|
+
}
|
|
1948
2462
|
const decls = scope === 'room' ? roomVars : playerVars;
|
|
1949
2463
|
const vt = decls[name];
|
|
1950
2464
|
if (!vt)
|
|
1951
2465
|
return { errors: [`${path}: unknown ${scope} collection var "${name}"${didYouMean(name, Object.keys(decls))} (declared ${scope} vars: ${Object.keys(decls).join(', ') || 'none'})`] };
|
|
1952
2466
|
return { errors: [], vt };
|
|
1953
2467
|
}
|
|
2468
|
+
// §5 (P3) resolve a collection LVALUE — a "room/self.<var>" path OR a ref-addressed {ref,var} (push onto an
|
|
2469
|
+
// iterated/other member's or an entity's collection). The {ref,var} ref resolves to a player (→ playerVars) or a
|
|
2470
|
+
// known entity kind (→ that kind's vars). Returns the declared collection VarType, or an error.
|
|
2471
|
+
function resolveCollectionLvalue(path, target, bound, roomVars, playerVars, zoneIds, ec) {
|
|
2472
|
+
if (typeof target === 'string')
|
|
2473
|
+
return resolveCollectionVar(path, target, bound, roomVars, playerVars, ec);
|
|
2474
|
+
const r = resolveRefSlot(`${path}.ref`, target.ref, bound, roomVars, playerVars, zoneIds, ec);
|
|
2475
|
+
if (r.errors.length)
|
|
2476
|
+
return { errors: r.errors };
|
|
2477
|
+
const name = target.var;
|
|
2478
|
+
if (r.kind?.type === 'entity') {
|
|
2479
|
+
const vt = ec.kindVars[r.kind.kind]?.[name];
|
|
2480
|
+
if (!vt)
|
|
2481
|
+
return { errors: [`${path}.var: unknown var "${name}" for entity kind "${r.kind.kind}"${didYouMean(name, Object.keys(ec.kindVars[r.kind.kind] ?? {}))} (declared: ${Object.keys(ec.kindVars[r.kind.kind] ?? {}).join(', ') || 'none'})`] };
|
|
2482
|
+
return { errors: [], vt };
|
|
2483
|
+
}
|
|
2484
|
+
const vt = playerVars[name]; // a player ref (or an untracked-kind ref) addresses a player collection
|
|
2485
|
+
if (!vt)
|
|
2486
|
+
return { errors: [`${path}.var: unknown player collection var "${name}"${didYouMean(name, Object.keys(playerVars))} (declared player vars: ${Object.keys(playerVars).join(', ') || 'none'})`] };
|
|
2487
|
+
return { errors: [], vt };
|
|
2488
|
+
}
|
|
1954
2489
|
// §5 (4.5.13) validate a collection-read op (listLength/listAt/count): resolve its var, check the var's kind
|
|
1955
2490
|
// matches the op, and (listAt) validate the index expr. Returns the op's value type (number, or the list's
|
|
1956
2491
|
// element type for listAt). Mirrors validateExpr's contract (errors + node count + inferred type).
|
|
1957
2492
|
function validateCollectionOp(path, expr, bound, roomVars, playerVars, zoneIds, ec, depth) {
|
|
1958
2493
|
const n = expr;
|
|
2494
|
+
// §5 (P3 ext) listCount / listIndexOf — a bounded element scan that binds `as` to the element + counts / finds the
|
|
2495
|
+
// first index matching `where` (→ number). The list is any collection lvalue; the where reads the bound element
|
|
2496
|
+
// (scalar via {var:as}, record field via {ref:as,var:field}, ref element as a Ref) and can't itself reduce.
|
|
2497
|
+
if (n.op === 'listCount' || n.op === 'listIndexOf') {
|
|
2498
|
+
const sc = expr;
|
|
2499
|
+
const c = resolveCollectionLvalue(`${path}.list`, sc.list, bound, roomVars, playerVars, zoneIds, ec);
|
|
2500
|
+
if (c.errors.length)
|
|
2501
|
+
return { errors: c.errors, nodes: 1 };
|
|
2502
|
+
if (c.vt.type !== 'list')
|
|
2503
|
+
return { errors: [`${path}: "${n.op}" needs a list var, but ${lvalueLabel(sc.list)} is "${c.vt.type}"`], nodes: 1 };
|
|
2504
|
+
const e = [];
|
|
2505
|
+
if (RESERVED_REF_NAMES.has(sc.as))
|
|
2506
|
+
e.push(`${path}.as: "${sc.as}" is a reserved name`);
|
|
2507
|
+
const inner = new Set([...bound, sc.as]);
|
|
2508
|
+
const innerEc = { ...ec, boundKinds: { ...ec.boundKinds, [sc.as]: elemBinding(c.vt.of) } };
|
|
2509
|
+
const w = validateExpr(`${path}.where`, sc.where, inner, roomVars, playerVars, zoneIds, innerEc, depth + 1);
|
|
2510
|
+
e.push(...w.errors);
|
|
2511
|
+
if (exprHasReduction(sc.where))
|
|
2512
|
+
e.push(`${path}.where: aggregate/nearest* are forbidden inside ${n.op}`);
|
|
2513
|
+
return { errors: e, nodes: 1 + w.nodes, type: 'number' };
|
|
2514
|
+
}
|
|
1959
2515
|
if (n.op === 'count') {
|
|
1960
|
-
|
|
2516
|
+
// §5 (P3 ext) `map` is any counterMap lvalue (room/self path OR a {ref,var} addressing another member's / an entity's counterMap).
|
|
2517
|
+
const c = resolveCollectionLvalue(`${path}.map`, n.map ?? '', bound, roomVars, playerVars, zoneIds, ec);
|
|
1961
2518
|
if (c.errors.length)
|
|
1962
2519
|
return { errors: c.errors, nodes: 1 };
|
|
1963
2520
|
if (c.vt.type !== 'counterMap')
|
|
1964
|
-
return { errors: [`${path}: "count" needs a counterMap var, but
|
|
2521
|
+
return { errors: [`${path}: "count" needs a counterMap var, but ${lvalueLabel(n.map ?? '')} is "${c.vt.type}"`], nodes: 1 };
|
|
1965
2522
|
if (!c.vt.keys.includes(n.key ?? ''))
|
|
1966
|
-
return { errors: [`${path}.key: "${n.key}" is not a declared key of
|
|
2523
|
+
return { errors: [`${path}.key: "${n.key}" is not a declared key of ${lvalueLabel(n.map ?? '')}${didYouMean(n.key ?? '', c.vt.keys)} (keys: ${c.vt.keys.join(', ')})`], nodes: 1 };
|
|
1967
2524
|
return { errors: [], nodes: 1, type: 'number' };
|
|
1968
2525
|
}
|
|
1969
|
-
|
|
2526
|
+
// §5 (P3 ext) listLength / listAt — `list` is any list lvalue (room/self path OR {ref,var}).
|
|
2527
|
+
const c = resolveCollectionLvalue(`${path}.list`, n.list ?? '', bound, roomVars, playerVars, zoneIds, ec);
|
|
1970
2528
|
if (c.errors.length)
|
|
1971
2529
|
return { errors: c.errors, nodes: 1 };
|
|
1972
2530
|
if (c.vt.type !== 'list')
|
|
1973
|
-
return { errors: [`${path}: "${n.op}" needs a list var, but
|
|
2531
|
+
return { errors: [`${path}: "${n.op}" needs a list var, but ${lvalueLabel(n.list ?? '')} is "${c.vt.type}"`], nodes: 1 };
|
|
1974
2532
|
if (n.op === 'listLength')
|
|
1975
2533
|
return { errors: [], nodes: 1, type: 'number' };
|
|
1976
|
-
// listAt — the index is a number expr
|
|
2534
|
+
// listAt — the index is a number expr. A SCALAR list → the element type (no `field`); a RECORD list → the named
|
|
2535
|
+
// `field`'s scalar type (`field` required). A ref-list element-as-Ref is a deferred follow-up.
|
|
1977
2536
|
const r = validateExpr(`${path}.index`, n.index, bound, roomVars, playerVars, zoneIds, ec, depth + 1);
|
|
1978
2537
|
const e = [...r.errors];
|
|
1979
2538
|
if (r.type !== undefined && r.type !== 'number')
|
|
1980
2539
|
e.push(`${path}.index: "listAt" needs a number index, got "${r.type}"`);
|
|
1981
|
-
|
|
2540
|
+
const of = c.vt.of;
|
|
2541
|
+
if (typeof of === 'string') {
|
|
2542
|
+
if (n.field !== undefined)
|
|
2543
|
+
e.push(`${path}: "listAt" on a scalar list takes no "field"`);
|
|
2544
|
+
return { errors: e, nodes: 1 + r.nodes, type: of };
|
|
2545
|
+
}
|
|
2546
|
+
if (of.type === 'record') {
|
|
2547
|
+
if (n.field === undefined) {
|
|
2548
|
+
e.push(`${path}: "listAt" on a record list needs a "field" to read (fields: ${Object.keys(of.fields).join(', ')})`);
|
|
2549
|
+
return { errors: e, nodes: 1 + r.nodes };
|
|
2550
|
+
}
|
|
2551
|
+
const ft = of.fields[n.field];
|
|
2552
|
+
if (!ft) {
|
|
2553
|
+
e.push(`${path}.field: unknown record field "${n.field}"${didYouMean(n.field, Object.keys(of.fields))} (fields: ${Object.keys(of.fields).join(', ')})`);
|
|
2554
|
+
return { errors: e, nodes: 1 + r.nodes };
|
|
2555
|
+
}
|
|
2556
|
+
return { errors: e, nodes: 1 + r.nodes, type: ft.type };
|
|
2557
|
+
}
|
|
2558
|
+
e.push(`${path}: "listAt" can't read a ref list element as a scalar`);
|
|
2559
|
+
return { errors: e, nodes: 1 + r.nodes };
|
|
1982
2560
|
}
|
|
1983
2561
|
// Resolve a ref-addressed read `{ref:<Ref>, var:"<name>"}` (§7.1) — read a member's var. The ref is any Ref
|
|
1984
2562
|
// (a bound name, a ref-var deref, or a ref-returning op — validated by resolveRefSlot); the referenced member
|
|
1985
2563
|
// is a player, so `var` resolves against playerVars or a player built-in (e.g. `position`).
|
|
1986
2564
|
function resolveRefRead(path, ref, name, bound, roomVars, playerVars, zoneIds, ec) {
|
|
2565
|
+
// §5 (P3 ext) a {ref,var} whose ref is a bound RECORD element reads the record's FIELD (not a cross-member deref);
|
|
2566
|
+
// a scalar element has no fields (read it via {var:as}). A ref element falls through to the member-ref path below.
|
|
2567
|
+
if (typeof ref === 'string' && bound.has(ref)) {
|
|
2568
|
+
const k = ec.boundKinds?.[ref];
|
|
2569
|
+
if (k?.type === 'listRecord') {
|
|
2570
|
+
const ft = k.fields[name];
|
|
2571
|
+
if (!ft)
|
|
2572
|
+
return { errors: [`${path}.var: unknown record field "${name}"${didYouMean(name, Object.keys(k.fields))} (fields: ${Object.keys(k.fields).join(', ')})`] };
|
|
2573
|
+
return { errors: [], type: ft.type };
|
|
2574
|
+
}
|
|
2575
|
+
if (k?.type === 'listScalar')
|
|
2576
|
+
return { errors: [`${path}: "${ref}" is a scalar list element — read it via {var:"${ref}"}, not {ref,var}`] };
|
|
2577
|
+
}
|
|
1987
2578
|
const r = resolveRefSlot(`${path}.ref`, ref, bound, roomVars, playerVars, zoneIds, ec);
|
|
1988
2579
|
if (r.errors.length)
|
|
1989
2580
|
return { errors: r.errors };
|
|
1990
|
-
if (name
|
|
1991
|
-
return { errors: [], type: PLAYER_BUILTIN_TYPES[name] }; // position/
|
|
2581
|
+
if (playerBuiltin(name, r.kind?.type === 'entity'))
|
|
2582
|
+
return { errors: [], type: PLAYER_BUILTIN_TYPES[name] }; // position (universal) + connected/active (player-only — an entity's declared var wins)
|
|
1992
2583
|
// §9 (4.5, row 16) if the ref resolves to a known ENTITY kind, resolve `var` against THAT kind's decls (a
|
|
1993
2584
|
// wrong-kind var now errors); a player ref / untracked kind falls back to player vars then the entity-var union.
|
|
1994
2585
|
if (r.kind?.type === 'entity') {
|
|
@@ -2020,6 +2611,11 @@ function ofToKind(of) {
|
|
|
2020
2611
|
return { type: 'entity', kind: of.slice('entity:'.length) };
|
|
2021
2612
|
return undefined;
|
|
2022
2613
|
}
|
|
2614
|
+
// The entity sub-kind of a binding, or undefined (a player / a non-member list-element binding). Narrows the
|
|
2615
|
+
// widened FwBinding union at the sites that read `.kind` (only the entity variant carries one).
|
|
2616
|
+
function bindingKind(b) {
|
|
2617
|
+
return b.type === 'entity' ? b.kind : undefined;
|
|
2618
|
+
}
|
|
2023
2619
|
// §9 (4.5) the static member kind a `{var:"scope.name"}` ref-var deref resolves to — the declared ref var's `of`,
|
|
2024
2620
|
// looked up against the right scope (room vars; self vars per self's kind; an action ref arg). undefined = untracked.
|
|
2025
2621
|
function refVarKind(dotted, roomVars, playerVars, ec) {
|
|
@@ -2043,7 +2639,14 @@ function resolveRefSlot(path, value, bound, roomVars, playerVars, zoneIds, ec) {
|
|
|
2043
2639
|
if (typeof value === 'string') {
|
|
2044
2640
|
if (!bound.has(value))
|
|
2045
2641
|
return { errors: [`${path}: "${value}" is not a bound ref (${[...bound].join(', ') || 'none bound here'})`] };
|
|
2046
|
-
|
|
2642
|
+
const k = ec.boundKinds?.[value]; // the bound name's static member kind (if the event/loop tracks it)
|
|
2643
|
+
// §5 (P3 ext) a SCALAR / RECORD list element is NOT a member ref. A record field read ({ref:as,var:field}) is
|
|
2644
|
+
// resolved in resolveRefRead before reaching here, so anything landing here is a misuse in a plain ref slot.
|
|
2645
|
+
if (k?.type === 'listScalar')
|
|
2646
|
+
return { errors: [`${path}: "${value}" is a scalar list element, not a member ref — read its value via {var:"${value}"}`] };
|
|
2647
|
+
if (k?.type === 'listRecord')
|
|
2648
|
+
return { errors: [`${path}: "${value}" is a record list element, not a member ref — read a field via {ref:"${value}",var:"<field>"} or write via {do:"setField",as:"${value}",…}`] };
|
|
2649
|
+
return { errors: [], kind: k }; // a ref element resolves to its member kind (player/entity) — flows like any bound ref
|
|
2047
2650
|
}
|
|
2048
2651
|
if (typeof value !== 'object' || value === null) {
|
|
2049
2652
|
return { errors: [`${path}: expected a ref (a bound name, a ref var, or a ref-returning op), got ${value === null ? 'null' : typeof value}`] };
|
|
@@ -2074,6 +2677,51 @@ function resolveRefSlot(path, value, bound, roomVars, playerVars, zoneIds, ec) {
|
|
|
2074
2677
|
// A kinded nearestEntity resolves to that kind (per-kind reads); kindless = any entity (kind untracked).
|
|
2075
2678
|
return { errors: e, kind: value.kind !== undefined ? { type: 'entity', kind: value.kind } : undefined };
|
|
2076
2679
|
}
|
|
2680
|
+
if (value.op === 'controllerOf') {
|
|
2681
|
+
// §9 (P3 Ownership/A3) the player controlling `entity` — a player ref ('' = server/unowned). O(1) field read.
|
|
2682
|
+
const r = resolveRefSlot(`${path}.entity`, value.entity, bound, roomVars, playerVars, zoneIds, ec);
|
|
2683
|
+
return { errors: r.errors, kind: { type: 'player' } };
|
|
2684
|
+
}
|
|
2685
|
+
if (value.op === 'leastLoadedPlayer') {
|
|
2686
|
+
// §9 (P3 Ownership/B8) the least-busy connected player (optional where/as filter, validated like aggregate's
|
|
2687
|
+
// over the player domain). A reduction (O(players)) → forbidden inside forEach by refHasReduction.
|
|
2688
|
+
const e = [];
|
|
2689
|
+
if (value.where !== undefined || value.as !== undefined) {
|
|
2690
|
+
if (value.as === undefined)
|
|
2691
|
+
e.push(`${path}: leastLoadedPlayer "where" requires "as" (a name for each candidate player)`);
|
|
2692
|
+
else if (RESERVED_REF_NAMES.has(value.as))
|
|
2693
|
+
e.push(`${path}.as: "${value.as}" is a reserved name`);
|
|
2694
|
+
else if (value.where === undefined)
|
|
2695
|
+
e.push(`${path}: leastLoadedPlayer "as" requires a "where" filter`);
|
|
2696
|
+
else {
|
|
2697
|
+
const inner = new Set([...bound, value.as]);
|
|
2698
|
+
const innerEc = { ...ec, boundKinds: { ...ec.boundKinds, [value.as]: { type: 'player' } } };
|
|
2699
|
+
e.push(...validateExpr(`${path}.where`, value.where, inner, roomVars, playerVars, zoneIds, innerEc, 1).errors);
|
|
2700
|
+
if (exprHasReduction(value.where))
|
|
2701
|
+
e.push(`${path}.where: aggregate/nearest* are forbidden inside a leastLoadedPlayer "where"`);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
return { errors: e, kind: { type: 'player' } };
|
|
2705
|
+
}
|
|
2706
|
+
if (value.op === 'listAt') {
|
|
2707
|
+
// §5 (P3-B2 + P3 ext) a `list of ref` element AS a Ref (the turn-order current actor). The list is any list
|
|
2708
|
+
// lvalue (room/self path OR {ref,var}); its element must be a ref; the index a number. The resolved member kind is
|
|
2709
|
+
// the ref element's `of`. O(1) — not a reduction.
|
|
2710
|
+
const c = resolveCollectionLvalue(`${path}.list`, value.list, bound, roomVars, playerVars, zoneIds, ec);
|
|
2711
|
+
if (c.errors.length)
|
|
2712
|
+
return { errors: c.errors };
|
|
2713
|
+
const vt = c.vt;
|
|
2714
|
+
const elem = vt.type === 'list' ? vt.of : undefined;
|
|
2715
|
+
if (vt.type !== 'list' || typeof elem !== 'object' || elem.type !== 'ref') {
|
|
2716
|
+
const what = vt.type === 'list' ? `a list of ${typeof elem === 'string' ? elem : elem.type}` : `a "${vt.type}"`;
|
|
2717
|
+
return { errors: [`${path}: listAt is a Ref only over a list of refs (${lvalueLabel(value.list)} is ${what})`] };
|
|
2718
|
+
}
|
|
2719
|
+
const r = validateExpr(`${path}.index`, value.index, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
2720
|
+
const errs = [...r.errors];
|
|
2721
|
+
if (r.type !== undefined && r.type !== 'number')
|
|
2722
|
+
errs.push(`${path}.index: listAt needs a number index, got "${r.type}"`);
|
|
2723
|
+
return { errors: errs, kind: ofToKind(elem.of) };
|
|
2724
|
+
}
|
|
2077
2725
|
// aggregate argmax|argmin — the reduced scope's member kind (players → player; entities:<kind> or a zone
|
|
2078
2726
|
// tracking 'entity:<kind>' → that entity).
|
|
2079
2727
|
const e = validateAggregate(path, value, bound, roomVars, playerVars, zoneIds, ec);
|
|
@@ -2090,7 +2738,7 @@ function resolveLvalue(path, lvalue, bound, roomVars, playerVars, zoneIds, ec) {
|
|
|
2090
2738
|
const r = resolveRefSlot(`${path}.ref`, ref, bound, roomVars, playerVars, zoneIds, ec);
|
|
2091
2739
|
if (r.errors.length)
|
|
2092
2740
|
return { errors: r.errors };
|
|
2093
|
-
if (name
|
|
2741
|
+
if (playerBuiltin(name, r.kind?.type === 'entity'))
|
|
2094
2742
|
return { errors: [`${path}: can't write the built-in "${name}" — use teleport/respawn`] };
|
|
2095
2743
|
// §9 (4.5, row 16) write-through to an ENTITY var resolves against the ref's specific kind when known.
|
|
2096
2744
|
if (r.kind?.type === 'entity') {
|
|
@@ -2118,7 +2766,7 @@ function resolveLvalue(path, lvalue, bound, roomVars, playerVars, zoneIds, ec) {
|
|
|
2118
2766
|
return { errors: [`${path}: "${lvalue}" must be "room.<var>" or "self.<var>"`] };
|
|
2119
2767
|
if (scope === 'self' && !bound.has('self'))
|
|
2120
2768
|
return { errors: [`${path}: "self.${name}" — this rule's event binds no member (no "self" here)`] };
|
|
2121
|
-
if (scope === 'self' && name
|
|
2769
|
+
if (scope === 'self' && playerBuiltin(name, ec.boundKinds?.self?.type === 'entity'))
|
|
2122
2770
|
return { errors: [`${path}: can't write the built-in "self.${name}" — use teleport/respawn`] };
|
|
2123
2771
|
if (scope === 'room' && name in ROOM_BUILTIN_TYPES)
|
|
2124
2772
|
return { errors: [`${path}: can't write the built-in "room.${name}" — use transitionTo`] };
|
|
@@ -2189,6 +2837,14 @@ function checkVarDefault(path, vt) {
|
|
|
2189
2837
|
e.push(`${path}: list maxLen must be a positive integer, got ${vt.maxLen}`);
|
|
2190
2838
|
else if (vt.maxLen > exports.MULTIPLAYER_CAPS.listMaxLen)
|
|
2191
2839
|
e.push(`${path}: list maxLen ${vt.maxLen} exceeds the ceiling of ${exports.MULTIPLAYER_CAPS.listMaxLen}`);
|
|
2840
|
+
// §5 (P3 Collections) a record element: cap its field count + validate each flat scalar field (no nesting).
|
|
2841
|
+
if (typeof vt.of === 'object' && vt.of.type === 'record') {
|
|
2842
|
+
const names = Object.keys(vt.of.fields);
|
|
2843
|
+
if (names.length > exports.MULTIPLAYER_CAPS.recordFields)
|
|
2844
|
+
e.push(`${path}: a record element declares ${names.length} fields — the cap is ${exports.MULTIPLAYER_CAPS.recordFields}`);
|
|
2845
|
+
for (const fn of names)
|
|
2846
|
+
e.push(...checkRecordField(`${path}.of.fields.${fn}`, vt.of.fields[fn]));
|
|
2847
|
+
}
|
|
2192
2848
|
}
|
|
2193
2849
|
else if (vt.type === 'counterMap') {
|
|
2194
2850
|
// §5 (4.5.13) a counterMap starts with every declared key at 0; keys must be non-empty, unique, within the cap.
|
|
@@ -2201,6 +2857,82 @@ function checkVarDefault(path, vt) {
|
|
|
2201
2857
|
}
|
|
2202
2858
|
return e;
|
|
2203
2859
|
}
|
|
2860
|
+
// §5 (P3 Collections) cross-field check a record element's flat scalar field (the part JSON Schema can't express:
|
|
2861
|
+
// default-vs-bounds, integer-ness, enum membership, the shared maxLen/enum ceilings). Mirrors checkVarDefault but
|
|
2862
|
+
// the field default is OPTIONAL — only validate it when present.
|
|
2863
|
+
function checkRecordField(path, f) {
|
|
2864
|
+
const e = [];
|
|
2865
|
+
if (f.type === 'number') {
|
|
2866
|
+
if (f.min !== undefined && f.max !== undefined && f.min > f.max)
|
|
2867
|
+
e.push(`${path}: min ${f.min} exceeds max ${f.max}`);
|
|
2868
|
+
if (f.default !== undefined) {
|
|
2869
|
+
if (f.integer && !Number.isInteger(f.default))
|
|
2870
|
+
e.push(`${path}: default ${f.default} must be an integer`);
|
|
2871
|
+
if (f.min !== undefined && f.default < f.min)
|
|
2872
|
+
e.push(`${path}: default ${f.default} is below min ${f.min}`);
|
|
2873
|
+
if (f.max !== undefined && f.default > f.max)
|
|
2874
|
+
e.push(`${path}: default ${f.default} is above max ${f.max}`);
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
else if (f.type === 'string') {
|
|
2878
|
+
if (f.maxLen !== undefined && f.maxLen > exports.MULTIPLAYER_CAPS.stringMaxLen)
|
|
2879
|
+
e.push(`${path}: maxLen ${f.maxLen} exceeds the ceiling of ${exports.MULTIPLAYER_CAPS.stringMaxLen}`);
|
|
2880
|
+
if (f.enum && f.enum.length > exports.MULTIPLAYER_CAPS.enumValues)
|
|
2881
|
+
e.push(`${path}: enum has ${f.enum.length} values — the cap is ${exports.MULTIPLAYER_CAPS.enumValues}`);
|
|
2882
|
+
if (f.default !== undefined) {
|
|
2883
|
+
if (f.maxLen !== undefined && f.default.length > f.maxLen)
|
|
2884
|
+
e.push(`${path}: default "${f.default}" exceeds maxLen ${f.maxLen}`);
|
|
2885
|
+
if (f.enum && !f.enum.includes(f.default))
|
|
2886
|
+
e.push(`${path}: default "${f.default}" is not one of the declared enum values [${f.enum.join(', ')}]`);
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
return e;
|
|
2890
|
+
}
|
|
2891
|
+
// §5 (P3 Collections) validate a value written into a list whose element type is `elem`: a scalar Expr of the
|
|
2892
|
+
// matching type, a ref (a Ref of the matching kind), or a record literal { field: Expr|Ref } (each provided field
|
|
2893
|
+
// type-matches its declared field; an unknown field errors; an omitted field takes its default/zero at runtime).
|
|
2894
|
+
// Reused by append (C1) and the record-element mutators (C3/C4).
|
|
2895
|
+
function validateListElementValue(path, value, elem, bound, roomVars, playerVars, zoneIds, ec) {
|
|
2896
|
+
if (typeof elem === 'string') {
|
|
2897
|
+
const r = validateExpr(path, value, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
2898
|
+
const e = [...r.errors];
|
|
2899
|
+
if (r.type !== undefined && r.type !== elem)
|
|
2900
|
+
e.push(`${path}: a "${r.type}" can't go into a list of "${elem}"`);
|
|
2901
|
+
return e;
|
|
2902
|
+
}
|
|
2903
|
+
if (elem.type === 'ref') {
|
|
2904
|
+
const r = resolveRefSlot(path, value, bound, roomVars, playerVars, zoneIds, ec);
|
|
2905
|
+
const e = [...r.errors];
|
|
2906
|
+
const want = ofToKind(elem.of);
|
|
2907
|
+
if (r.kind && want && (r.kind.type !== want.type || bindingKind(r.kind) !== bindingKind(want))) {
|
|
2908
|
+
const lbl = (k) => (k.type === 'player' ? 'player' : `entity:${bindingKind(k)}`);
|
|
2909
|
+
e.push(`${path}: a "${lbl(r.kind)}" ref can't go into a list of "${lbl(want)}" refs`);
|
|
2910
|
+
}
|
|
2911
|
+
return e;
|
|
2912
|
+
}
|
|
2913
|
+
// record element — the value is a record literal (a plain object of field → Expr|Ref).
|
|
2914
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
2915
|
+
return [`${path}: a record list needs a record literal { field: value, ... }`];
|
|
2916
|
+
}
|
|
2917
|
+
const e = [];
|
|
2918
|
+
for (const [fn, fv] of Object.entries(value)) {
|
|
2919
|
+
const ft = elem.fields[fn];
|
|
2920
|
+
if (!ft) {
|
|
2921
|
+
e.push(`${path}.${fn}: unknown record field "${fn}"${didYouMean(fn, Object.keys(elem.fields))} (fields: ${Object.keys(elem.fields).join(', ')})`);
|
|
2922
|
+
continue;
|
|
2923
|
+
}
|
|
2924
|
+
if (ft.type === 'ref') {
|
|
2925
|
+
e.push(...resolveRefSlot(`${path}.${fn}`, fv, bound, roomVars, playerVars, zoneIds, ec).errors);
|
|
2926
|
+
}
|
|
2927
|
+
else {
|
|
2928
|
+
const r = validateExpr(`${path}.${fn}`, fv, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
2929
|
+
e.push(...r.errors);
|
|
2930
|
+
if (r.type !== undefined && r.type !== ft.type)
|
|
2931
|
+
e.push(`${path}.${fn}: a "${r.type}" value doesn't match the declared "${ft.type}"`);
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
return e;
|
|
2935
|
+
}
|
|
2204
2936
|
// The valid declared-var type discriminants — the single source for the did-you-mean below + the authoring
|
|
2205
2937
|
// vocabulary the schema's varDecls oneOf enumerates. (Bounds/enum/of live on each variant; this is the tag.)
|
|
2206
2938
|
exports.VAR_TYPES = ['number', 'string', 'boolean', 'vec3', 'ref', 'list', 'counterMap'];
|