@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/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
- for (const [name, vt] of Object.entries(decls))
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, joinLateCost(states)));
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
- if (wantEntity !== gotEntity || (wantEntity && kr.kind.kind !== keyedKind.slice('entity:'.length))) {
554
- const got = gotEntity ? `entity:${kr.kind.kind}` : 'player';
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
- if (eff.seconds < exports.MULTIPLAYER_CAPS.timerMinSeconds)
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
- if (payload[f].type === 'ref' && payload[f].of === undefined)
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, joinLate = 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) + joinLate;
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): set/delete one deadline entry
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 + exprCost(eff.value); // §5 (4.5.13) O(1) push (+ the value expr)
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
- // §5 (4.5.13) collection vars (list/counterMap) live on roomVars/playerVars only — not entity vars (they
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 t = vars[name].type;
864
- if (t === 'list' || t === 'counterMap')
865
- e.push(`multiplayer.entities.${kind}.vars.${name}: a "${t}" collection isn't allowed on an entity — declare it under multiplayer.state (room/player vars)`);
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-authoritative `motion` would fight its uploaded transform.
895
- if (entities[kind].motion && entities[kind].motion.type !== 'static')
896
- e.push(`multiplayer.entities.${kind}: authority:'owner' is client-simulated it can't also declare server motion "${entities[kind].motion.type}" (drop motion, or use authority:'server')`);
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, varReached — all self=player (self-scoped)
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' || 'op' in ref)
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
- flagReads(eff.value, b, path, 'append value'); // §5 (4.5.13) row-13 read-leak in the appended value
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), the `value` type must match it, and ordering cmps need a number var.
1332
- function checkVarReached(path, w, roomVars, playerVars) {
1333
- const decls = w.scope === 'room' ? roomVars : playerVars;
1334
- const vt = decls[w.var];
1335
- if (!vt) {
1336
- const declared = Object.keys(decls);
1337
- return [`${path}: varReached watches unknown ${w.scope} var "${w.var}"${didYouMean(w.var, declared)} (declared ${w.scope} vars: ${declared.join(', ') || 'none'})`];
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
- if (vt.type === 'vec3' || vt.type === 'ref') {
1340
- return [`${path}: varReached can't compare a "${vt.type}" var (${w.scope}.${w.var}) use a number/string/boolean var`];
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 ${w.scope}.${w.var} is "${vt.type}"`);
1346
- if (typeof w.value !== vt.type)
1347
- e.push(`${path}: value (${JSON.stringify(w.value)}) does not match ${w.scope}.${w.var} ("${vt.type}")`);
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 === 'startTimer' || eff.do === 'cancelTimer')
1423
- return []; // §8 timer effects — name/duration validated in checkTimerRefs
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 the list's element type.
1434
- const c = resolveCollectionVar(`${path}.target`, eff.target, bound, roomVars, playerVars);
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 "${eff.target}" is "${c.vt.type}"`];
1440
- const r = validateExpr(`${path}.value`, eff.value, bound, roomVars, playerVars, zoneIds, ec, 1);
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 = resolveCollectionVar(`${path}.target`, eff.target, bound, roomVars, playerVars);
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 "${eff.target}" is "${c.vt.type}"`];
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 = resolveCollectionVar(`${path}.target`, eff.target, bound, roomVars, playerVars);
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 "${eff.target}" is "${c.vt.type}"`);
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 "${eff.target}"${didYouMean(eff.key, c.vt.keys)} (keys: ${c.vt.keys.join(', ')})`);
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
- return typeof ref !== 'string' && 'op' in ref; // nearestPlayer / aggregate argmax|argmin (a {var} deref is fine)
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 === 'self' ? new Set(['self']) : new Set(); // self-scope binds the player instance
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 entity vars for an entity domain (entities:<kind> or a
1869
- // zone tracking 'entity:<kind>'), else player vars.
1870
- const fieldType = entityDomain ? ec.varTypes[node.field] : playerVars[node.field]?.type;
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
- e.push(`${path}: aggregate field unknown ${entityDomain ? 'entity' : 'player'} var "${node.field}"${didYouMean(node.field, declared)} (declared: ${declared.join(', ') || 'none'})`);
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 in PLAYER_BUILTIN_TYPES)
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). Collections
1938
- // live on roomVars/playerVars only, so `self` resolves against playerVars (an entity-self has none). Returns the
1939
- // declared HelixVarType (caller checks list vs counterMap) or an error. `self` needs the event to bind a member.
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
- const c = resolveCollectionVar(`${path}.map`, n.map ?? '', bound, roomVars, playerVars);
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 "${n.map}" is "${c.vt.type}"`], nodes: 1 };
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 "${n.map}"${didYouMean(n.key ?? '', c.vt.keys)} (keys: ${c.vt.keys.join(', ')})`], nodes: 1 };
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
- const c = resolveCollectionVar(`${path}.list`, n.list ?? '', bound, roomVars, playerVars);
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 "${n.list}" is "${c.vt.type}"`], nodes: 1 };
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; the value type is the list's element type. Out-of-range typed zero at runtime.
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
- return { errors: e, nodes: 1 + r.nodes, type: c.vt.of };
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 in PLAYER_BUILTIN_TYPES)
1991
- return { errors: [], type: PLAYER_BUILTIN_TYPES[name] }; // position/connectedmember built-ins
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
- return { errors: [], kind: ec.boundKinds?.[value] }; // the bound name's static member kind (if the event tracks it)
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 in PLAYER_BUILTIN_TYPES)
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 in PLAYER_BUILTIN_TYPES)
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'];