@hypersoniclabs/helix-manifest 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/schema/manifest-v0.3.schema.json +361 -2
- package/dist/src/index.d.ts +574 -2
- package/dist/src/index.js +2902 -8
- package/dist/src/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/schema/manifest-v0.3.schema.json +361 -2
package/dist/src/index.js
CHANGED
|
@@ -17,7 +17,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
17
17
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
18
|
};
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
-
exports.PACKAGE_ENVELOPE_VERSION = exports.PLATFORM_MAX_PLAYERS_PER_ROOM = exports.MAIN_BACKEND_CONTENT_RATING = exports.MAIN_BACKEND_PACKAGE_TYPE = exports.BUNDLE_KINDS = exports.PERMISSIONS_V03 = exports.PERMISSIONS_V01 = exports.MANIFEST_VERSIONS = exports.MANIFEST_FILENAME = void 0;
|
|
20
|
+
exports.PACKAGE_ENVELOPE_VERSION = exports.VAR_TYPES = exports.MULTIPLAYER_CAPS = exports.PLATFORM_MAX_PLAYERS_PER_ROOM = exports.MAIN_BACKEND_CONTENT_RATING = exports.MAIN_BACKEND_PACKAGE_TYPE = exports.BUNDLE_KINDS = exports.PERMISSIONS_V03 = exports.PERMISSIONS_V01 = exports.MANIFEST_VERSIONS = exports.MANIFEST_FILENAME = void 0;
|
|
21
21
|
exports.validateManifest = validateManifest;
|
|
22
22
|
exports.parseManifest = parseManifest;
|
|
23
23
|
exports.validatePackageEnvelope = validatePackageEnvelope;
|
|
@@ -31,9 +31,12 @@ const package_envelope_v1_schema_json_1 = __importDefault(require("../schema/pac
|
|
|
31
31
|
exports.MANIFEST_FILENAME = 'helix.json';
|
|
32
32
|
exports.MANIFEST_VERSIONS = ['0.1', '0.2', '0.3'];
|
|
33
33
|
exports.PERMISSIONS_V01 = ['auth.profile'];
|
|
34
|
-
// v0.3 adds multiplayer + the reserved voice.* perms. The per-version JSON Schema
|
|
35
|
-
// of these a given manifest may declare (v0.1/v0.2 accept only auth.profile); this
|
|
36
|
-
|
|
34
|
+
// v0.3 adds multiplayer + the reserved voice.* perms + camera.capture. The per-version JSON Schema
|
|
35
|
+
// still gates which of these a given manifest may declare (v0.1/v0.2 accept only auth.profile); this
|
|
36
|
+
// is the full union. camera.capture: save a photo taken with the in-engine world camera to the
|
|
37
|
+
// player's account album (the phone Gallery, cross-device). Player-authed via the shell — login is
|
|
38
|
+
// prompted on save, not on entry, so it does NOT imply requiresAuth.
|
|
39
|
+
exports.PERMISSIONS_V03 = ['auth.profile', 'multiplayer', 'voice.room', 'voice.proximity', 'camera.capture'];
|
|
37
40
|
/**
|
|
38
41
|
* Bundle vocabulary, pinned for main-backend compatibility. A **system** is a
|
|
39
42
|
* versioned runtime framework that hosts abilities (the humanoid character is
|
|
@@ -55,6 +58,27 @@ exports.MAIN_BACKEND_PACKAGE_TYPE = {
|
|
|
55
58
|
ability: 'Ability', // proposed
|
|
56
59
|
'asset-pack': 'AssetPack', // proposed
|
|
57
60
|
};
|
|
61
|
+
// §6 (4.5.8) the entity kind a zone's `tracks` targets, or undefined for the default 'players'. A zone tracking
|
|
62
|
+
// 'entity:<kind>' tests entities of that kind (not players) and binds `self` = the entering entity of that kind.
|
|
63
|
+
function entityTrackKind(tracks) {
|
|
64
|
+
return typeof tracks === 'string' && tracks.startsWith('entity:') ? tracks.slice('entity:'.length) : undefined;
|
|
65
|
+
}
|
|
66
|
+
// §6 (4.5.8) zone-reference → the entity kind its `self` binds to (only zones tracking 'entity:<kind>'). Keyed by
|
|
67
|
+
// the same string a zone event's `when.zone` carries: a static zone's id, or an attached zone's carrier kind.
|
|
68
|
+
function buildZoneSelfKinds(m) {
|
|
69
|
+
const map = new Map();
|
|
70
|
+
for (const z of m.multiplayer?.zones ?? []) {
|
|
71
|
+
const k = entityTrackKind(z.tracks);
|
|
72
|
+
if (k)
|
|
73
|
+
map.set(z.id, k);
|
|
74
|
+
}
|
|
75
|
+
for (const [kind, ent] of Object.entries(m.multiplayer?.entities ?? {})) {
|
|
76
|
+
const k = ent.zone && entityTrackKind(ent.zone.tracks);
|
|
77
|
+
if (k)
|
|
78
|
+
map.set(kind, k);
|
|
79
|
+
}
|
|
80
|
+
return map;
|
|
81
|
+
}
|
|
58
82
|
/**
|
|
59
83
|
* contentRating → main-backend ContentRating. 'unrated' has no counterpart there (their enum is
|
|
60
84
|
* Everyone/Teen/Mature) — an unrated bundle cannot federate into the catalog until it is rated.
|
|
@@ -68,8 +92,93 @@ exports.MAIN_BACKEND_CONTENT_RATING = {
|
|
|
68
92
|
// The platform caps CONCURRENT players PER ROOM at this number regardless of the manifest's maxPlayers
|
|
69
93
|
// (the room auto-locks here and spills extra players into additional room instances). A manifest above
|
|
70
94
|
// this is VALID but warned at publish so the author isn't surprised by the runtime cap. MUST match the
|
|
71
|
-
// room server's ROOM_MAX_CLIENTS_CAP (helix-colyseus-server src/
|
|
95
|
+
// room server's ROOM_MAX_CLIENTS_CAP (helix-colyseus-server src/tuning.ts — the runtime tuning home).
|
|
72
96
|
exports.PLATFORM_MAX_PLAYERS_PER_ROOM = 24;
|
|
97
|
+
// §11.1 closed cap table — THE publish-time tuning home: platform limits on declared world config, enforced
|
|
98
|
+
// at publish so a world can't blow the room's per-tick/state budget. Tweak a ceiling here and worlds are
|
|
99
|
+
// re-validated against it. (The other two tuning homes: runtime room knobs — sim rate, movement-gate speed,
|
|
100
|
+
// capacity — in helix-colyseus-server src/tuning.ts; message rates + the wire contract in
|
|
101
|
+
// @hypersoniclabs/helix-sdk/multiplayer-contract.) The table grows per phase as new declarable dimensions
|
|
102
|
+
// land. (Var-NAME length is capped by the schema's varDecls propertyNames pattern — 32 chars — not here.)
|
|
103
|
+
// Over any cap → publish fails.
|
|
104
|
+
exports.MULTIPLAYER_CAPS = {
|
|
105
|
+
/** Accepted `multiplayer.uploadHz` tiers (client→server upload cadence). 10 = default, 20 = opt-in fast tier.
|
|
106
|
+
* SYNC: mirrors the contract's MESSAGE_RATE.{stateHz=10, maxUploadHz=20} (helix-sdk multiplayer-contract) — the
|
|
107
|
+
* manifest takes no SDK dep, so widen both in lockstep if a tier is ever added. */
|
|
108
|
+
uploadHzAllowed: [10, 20],
|
|
109
|
+
/** The 20 Hz player cap: `uploadHz:20` requires maxPlayers ≤ this (the ~2× upstream cost is bounded by capacity).
|
|
110
|
+
* SYNC: mirrors the room's ROOM_MAX_CLIENTS_CAP_20HZ (helix-colyseus-server src/tuning.ts) — publish gate here,
|
|
111
|
+
* runtime backstop there. */
|
|
112
|
+
maxPlayersAt20Hz: 12,
|
|
113
|
+
/** Declared room-level vars. */
|
|
114
|
+
roomVars: 64,
|
|
115
|
+
/** Declared per-player vars (these are realized once PER PLAYER at runtime, so the effective cost scales). */
|
|
116
|
+
playerVars: 64,
|
|
117
|
+
/** Ceiling on a string var's declared `maxLen` (bounds a single synced string). */
|
|
118
|
+
stringMaxLen: 1024,
|
|
119
|
+
/** Values in a string var's `enum`. */
|
|
120
|
+
enumValues: 64,
|
|
121
|
+
/** Ceiling on a `list` collection var's declared `maxLen` (4.5.13 — bounds a single synced array). */
|
|
122
|
+
listMaxLen: 256,
|
|
123
|
+
/** Keys in a `counterMap` collection var's declared key enum (4.5.13). */
|
|
124
|
+
counterKeys: 64,
|
|
125
|
+
/** Fields in a `list` of `record` element (P3 Collections — bounds one synced record's flat scalar fields). */
|
|
126
|
+
recordFields: 8,
|
|
127
|
+
/** Declared behavior rules (Tier 2 Phase 2). */
|
|
128
|
+
rules: 128,
|
|
129
|
+
/** Effects in a single rule's `then`. */
|
|
130
|
+
ruleThen: 16,
|
|
131
|
+
/** Max nesting depth of a condition expression (`if`). */
|
|
132
|
+
exprDepth: 8,
|
|
133
|
+
/** Max total nodes in a single condition expression. */
|
|
134
|
+
exprNodes: 64,
|
|
135
|
+
/** Declared state-machine phases (Tier 2 Phase 3, spec §8). One room-scoped machine; this caps its phase set. */
|
|
136
|
+
phases: 32,
|
|
137
|
+
/** Declared timers (Tier 2 Phase 3, spec §8). Each active timer (room-scoped, or × live members when keyed in
|
|
138
|
+
* 3.3) reifies a deadline into synced state — this caps the declared name count. */
|
|
139
|
+
timers: 32,
|
|
140
|
+
/** Floor on a `startTimer` duration (seconds) = one sim tick (1/20s). A shorter timer can't resolve below the
|
|
141
|
+
* tick rate; rejecting it stops a 0s timer from same-tick start→elapse churn. MUST track the room's SIM_HZ. */
|
|
142
|
+
timerMinSeconds: 0.05,
|
|
143
|
+
/** Effects in a single phase's `joinPolicy.onLateJoin` (Tier 2 Phase 3.5). Runs once per late join (≤ player
|
|
144
|
+
* cap/tick), so it's bounded like a playerJoin rule's `then`. */
|
|
145
|
+
joinPolicyEffects: 16,
|
|
146
|
+
/** §3.6/§11.3 cascade-drain depth — the longest within-tick chain of stateEnter/stateExit → transitionTo →
|
|
147
|
+
* stateEnter… Computed statically at publish (a cycle is rejected outright); a config whose chain exceeds
|
|
148
|
+
* this fails publish. Bounds the ×(1+maxCascadeDepth) budget term + MUST stay ≤ the room's MAX_CASCADE_DEPTH
|
|
149
|
+
* runtime backstop (helix-colyseus-server src/tuning.ts). */
|
|
150
|
+
maxCascadeDepth: 16,
|
|
151
|
+
/** Declared zones (Tier 2 Phase 2.3). Membership is O(members × zones)/tick, both capped. */
|
|
152
|
+
zones: 64,
|
|
153
|
+
/** Declared broadcast events (Tier 2 Phase 2.5). */
|
|
154
|
+
events: 64,
|
|
155
|
+
/** Fields in a single broadcast event's payload. */
|
|
156
|
+
broadcastFields: 16,
|
|
157
|
+
/** Declared client→server actions (Tier 2 Phase 2.5b). */
|
|
158
|
+
actions: 64,
|
|
159
|
+
/** Args in a single action's schema. */
|
|
160
|
+
actionArgs: 16,
|
|
161
|
+
/** Declared entity archetypes (Tier 2 Phase 4, spec §9). */
|
|
162
|
+
entityKinds: 32,
|
|
163
|
+
/** Max declared vars per entity kind (realized once PER live entity at runtime, so the effective cost scales). */
|
|
164
|
+
entityVars: 64,
|
|
165
|
+
/** Max live entities of a single kind in a room — bounds the synced entities collection + per-tick entity work.
|
|
166
|
+
* Enforced at RUNTIME (a spawn over the cap is skipped + counted in the H3 metric, never throws). */
|
|
167
|
+
entitiesPerKind: 256,
|
|
168
|
+
/** networked-physics (Phase 0): max entity kinds that may declare a `physics` block per world. Bounds the
|
|
169
|
+
* client-side dynamic-body count alongside entitiesPerKind — the worst case a client may simulate is
|
|
170
|
+
* physicsKinds × entitiesPerKind dynamic Rapier bodies, so keep this small (a casual world needs 1–2). */
|
|
171
|
+
physicsKinds: 8,
|
|
172
|
+
/** Ceiling on a zone's extent — any box dimension or a sphere radius, in meters. A sanity bound (a zone
|
|
173
|
+
* test is O(1) regardless of size), generous enough for whole-level kill-planes. */
|
|
174
|
+
zoneMaxExtent: 10000,
|
|
175
|
+
/** §11.2 per-tick budget — the worst-case node-evaluations across ALL rules in one tick: Σ over rules of
|
|
176
|
+
* fireCount(event) × ruleCost, where reductions (aggregate/nearest*) + forEach fanout are weighted by the
|
|
177
|
+
* player cap and contact events by playerCap², all ×(1 + maxCascadeDepth) for the Phase-3 cascade drain
|
|
178
|
+
* (analyzeCascade computes the cycle-free longest chain). Over this → publish fails. PROVISIONAL: a generous
|
|
179
|
+
* bound that catches pathological configs; the precise number wants the S6 load test. */
|
|
180
|
+
tickNodeBudget: 100_000,
|
|
181
|
+
};
|
|
73
182
|
const ajv = new _2020_1.Ajv2020({ allErrors: true, useDefaults: true });
|
|
74
183
|
const validateV01 = ajv.compile(manifest_v0_1_schema_json_1.default);
|
|
75
184
|
const validateV02 = ajv.compile(manifest_v0_2_schema_json_1.default);
|
|
@@ -77,7 +186,7 @@ const validateV03 = ajv.compile(manifest_v0_3_schema_json_1.default);
|
|
|
77
186
|
// Cross-field rules JSON Schema can't express cleanly, run after schema validation on the normalized
|
|
78
187
|
// v0.3 manifest. Returns errors (empty = ok) + non-fatal warnings, and may normalize in place. Both the
|
|
79
188
|
// CLI (pre-upload) and the API (finalize) call validateManifest, so these can never disagree.
|
|
80
|
-
function applyV03CrossFieldRules(m) {
|
|
189
|
+
function applyV03CrossFieldRules(m, strict) {
|
|
81
190
|
const errors = [];
|
|
82
191
|
const warnings = [];
|
|
83
192
|
const perms = m.permissions ?? [];
|
|
@@ -98,8 +207,2784 @@ function applyV03CrossFieldRules(m) {
|
|
|
98
207
|
if (multiplayer && m.maxPlayers > exports.PLATFORM_MAX_PLAYERS_PER_ROOM) {
|
|
99
208
|
warnings.push(`manifest: maxPlayers ${m.maxPlayers} exceeds the platform per-room cap of ${exports.PLATFORM_MAX_PLAYERS_PER_ROOM} — each room holds at most ${exports.PLATFORM_MAX_PLAYERS_PER_ROOM} concurrent players; extra players spill into additional room instances`);
|
|
100
209
|
}
|
|
210
|
+
// §14 (4.5) the declared playable floor can't exceed the ceiling (the schema already bounds minPlayers ≥ 1).
|
|
211
|
+
const minPlayers = m.multiplayer?.minPlayers;
|
|
212
|
+
if (minPlayers !== undefined && minPlayers > m.maxPlayers) {
|
|
213
|
+
errors.push(`manifest: multiplayer.minPlayers ${minPlayers} exceeds maxPlayers ${m.maxPlayers}`);
|
|
214
|
+
}
|
|
215
|
+
// Upload-rate tier. The schema enum normally gates the value (and defaults absent → 10); this defensive enum check
|
|
216
|
+
// covers manifests built outside the schema path. The maxPlayers coupling is cross-field, so it lives here only.
|
|
217
|
+
const uploadHz = m.multiplayer?.uploadHz;
|
|
218
|
+
if (uploadHz !== undefined) {
|
|
219
|
+
if (!exports.MULTIPLAYER_CAPS.uploadHzAllowed.includes(uploadHz)) {
|
|
220
|
+
errors.push(`manifest: multiplayer.uploadHz ${uploadHz} must be ${exports.MULTIPLAYER_CAPS.uploadHzAllowed.join(' or ')}`);
|
|
221
|
+
}
|
|
222
|
+
else if (uploadHz === 20 && m.maxPlayers > exports.MULTIPLAYER_CAPS.maxPlayersAt20Hz) {
|
|
223
|
+
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`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// v0.3 declared state (Tier 2 Phase 1.3): the JSON schema validated each VarType's SHAPE; here we add the
|
|
227
|
+
// cross-field semantics it can't express — declared state requires the multiplayer permission, and every
|
|
228
|
+
// var's default must satisfy its own declared constraints.
|
|
229
|
+
const state = m.multiplayer?.state;
|
|
230
|
+
if (state && !multiplayer)
|
|
231
|
+
errors.push('manifest: multiplayer.state requires the "multiplayer" permission');
|
|
232
|
+
if (state) {
|
|
233
|
+
errors.push(...checkStateCaps(state)); // §11.1 caps first — a precise "over the cap" message
|
|
234
|
+
const bags = [
|
|
235
|
+
['roomVars', state.roomVars],
|
|
236
|
+
['playerVars', state.playerVars],
|
|
237
|
+
];
|
|
238
|
+
for (const [scope, decls] of bags) {
|
|
239
|
+
if (!decls)
|
|
240
|
+
continue;
|
|
241
|
+
// A declared var may not shadow a reserved built-in (else it's silently unreadable/unwritable — the built-in
|
|
242
|
+
// wins). roomVars vs room built-ins (phase); playerVars vs player built-ins (position/connected/active).
|
|
243
|
+
const reserved = scope === 'roomVars' ? ROOM_BUILTIN_TYPES : PLAYER_BUILTIN_TYPES;
|
|
244
|
+
for (const [name, vt] of Object.entries(decls)) {
|
|
245
|
+
if (name in reserved)
|
|
246
|
+
errors.push(`multiplayer.state.${scope}.${name}: "${name}" is a reserved ${scope === 'roomVars' ? 'room' : 'player'} built-in — rename the var`);
|
|
247
|
+
errors.push(...checkVarDefault(`multiplayer.state.${scope}.${name}`, vt));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const entities = m.multiplayer?.entities;
|
|
252
|
+
if (entities) {
|
|
253
|
+
if (!multiplayer)
|
|
254
|
+
errors.push('manifest: multiplayer.entities requires the "multiplayer" permission');
|
|
255
|
+
const en = checkEntities(entities);
|
|
256
|
+
errors.push(...en.errors);
|
|
257
|
+
warnings.push(...en.warnings);
|
|
258
|
+
}
|
|
259
|
+
const zones = m.multiplayer?.zones;
|
|
260
|
+
if (zones) {
|
|
261
|
+
if (!multiplayer)
|
|
262
|
+
errors.push('manifest: multiplayer.zones requires the "multiplayer" permission');
|
|
263
|
+
const z = checkZones(zones, new Set(Object.keys(entities ?? {})));
|
|
264
|
+
errors.push(...z.errors);
|
|
265
|
+
warnings.push(...z.warnings);
|
|
266
|
+
}
|
|
267
|
+
const events = m.multiplayer?.events;
|
|
268
|
+
if (events) {
|
|
269
|
+
if (!multiplayer)
|
|
270
|
+
errors.push('manifest: multiplayer.events requires the "multiplayer" permission');
|
|
271
|
+
errors.push(...checkEvents(events));
|
|
272
|
+
}
|
|
273
|
+
const actions = m.multiplayer?.actions;
|
|
274
|
+
if (actions) {
|
|
275
|
+
if (!multiplayer)
|
|
276
|
+
errors.push('manifest: multiplayer.actions requires the "multiplayer" permission');
|
|
277
|
+
errors.push(...checkActions(actions));
|
|
278
|
+
}
|
|
279
|
+
const states = m.multiplayer?.states;
|
|
280
|
+
if (states) {
|
|
281
|
+
if (!multiplayer)
|
|
282
|
+
errors.push('manifest: multiplayer.states requires the "multiplayer" permission');
|
|
283
|
+
errors.push(...checkStates(states));
|
|
284
|
+
errors.push(...checkJoinPolicy(m)); // §8/3.5 per-phase late-join policies
|
|
285
|
+
}
|
|
286
|
+
const timers = m.multiplayer?.timers;
|
|
287
|
+
if (timers) {
|
|
288
|
+
if (!multiplayer)
|
|
289
|
+
errors.push('manifest: multiplayer.timers requires the "multiplayer" permission');
|
|
290
|
+
const names = Object.keys(timers);
|
|
291
|
+
if (names.length > exports.MULTIPLAYER_CAPS.timers)
|
|
292
|
+
errors.push(`manifest: multiplayer.timers declares ${names.length} timers — the cap is ${exports.MULTIPLAYER_CAPS.timers}`);
|
|
293
|
+
// §9 (Phase 4.5) an entity-keyed timer's `keyed:'entity:<kind>'` must name a declared entity kind.
|
|
294
|
+
const entityKinds = new Set(Object.keys(m.multiplayer?.entities ?? {}));
|
|
295
|
+
for (const [name, t] of Object.entries(timers)) {
|
|
296
|
+
if (typeof t.keyed === 'string' && t.keyed.startsWith('entity:')) {
|
|
297
|
+
const kind = t.keyed.slice('entity:'.length);
|
|
298
|
+
if (!entityKinds.has(kind))
|
|
299
|
+
errors.push(`multiplayer.timers."${name}": keyed:'entity:${kind}' references unknown entity kind "${kind}"${didYouMean(kind, [...entityKinds])} (declared entities: ${[...entityKinds].join(', ') || 'none'})`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (m.multiplayer?.rules) {
|
|
304
|
+
if (!multiplayer)
|
|
305
|
+
errors.push('manifest: multiplayer.rules requires the "multiplayer" permission');
|
|
306
|
+
errors.push(...checkRules(m));
|
|
307
|
+
// §5.2 (4.9) accept + loud-no-op: a reserved future node warns (strict → errors), a typo errors did-you-mean.
|
|
308
|
+
const future = checkFutureNodes(m, strict);
|
|
309
|
+
errors.push(...future.errors);
|
|
310
|
+
warnings.push(...future.warnings);
|
|
311
|
+
// §9/§11.6 (4.6b) cross-player FIREWALL: a client-uploaded owner-entity id can't aim a cross-player effect.
|
|
312
|
+
errors.push(...checkFirewall(m));
|
|
313
|
+
// §3.6/§11.3 cascade analysis: reject a within-tick re-fire cycle + compute maxCascadeDepth for the budget.
|
|
314
|
+
const cascade = analyzeCascade(m.multiplayer.rules, states?.phases ?? []);
|
|
315
|
+
errors.push(...cascade.errors);
|
|
316
|
+
// §9/§11.3 (4.5) reject a within-tick unbounded entitySpawn → spawnEntity loop (the entity twin of the above).
|
|
317
|
+
errors.push(...analyzeEntityCascade(m.multiplayer.rules));
|
|
318
|
+
errors.push(...checkBudget(m.multiplayer.rules, m.multiplayer.timers ?? {}, cascade.maxDepth, states, maxDeclaredListLen(m)));
|
|
319
|
+
// §14 (4.5) single-player-safe gate: a solo-declared world (minPlayers 1) must not gate a phase transition
|
|
320
|
+
// behind playerCount ≥ 2 (a solo player would be soft-locked). Warns; strict (launch) flips it to an error.
|
|
321
|
+
const solo = checkSoloSafety(m, strict);
|
|
322
|
+
errors.push(...solo.errors);
|
|
323
|
+
warnings.push(...solo.warnings);
|
|
324
|
+
}
|
|
101
325
|
return { errors, warnings };
|
|
102
326
|
}
|
|
327
|
+
// §8 state machine: the phase set is capped + `initial` must be one of the declared phases (the schema already
|
|
328
|
+
// enforced unique names + the identifier pattern). One room-scoped machine per world.
|
|
329
|
+
function checkStates(states) {
|
|
330
|
+
const e = [];
|
|
331
|
+
if (states.phases.length > exports.MULTIPLAYER_CAPS.phases) {
|
|
332
|
+
e.push(`manifest: multiplayer.states declares ${states.phases.length} phases — the cap is ${exports.MULTIPLAYER_CAPS.phases}`);
|
|
333
|
+
}
|
|
334
|
+
if (!states.phases.includes(states.initial)) {
|
|
335
|
+
e.push(`manifest: multiplayer.states.initial "${states.initial}"${didYouMean(states.initial, states.phases)} is not one of phases [${states.phases.join(', ')}]`);
|
|
336
|
+
}
|
|
337
|
+
return e;
|
|
338
|
+
}
|
|
339
|
+
// §8/3.5 per-phase late-join policy: each keyed phase must be declared; each onLateJoin effect validates like a
|
|
340
|
+
// playerJoin rule's `then` (bound self = the joiner) — full effect grammar + timer resolution — under the
|
|
341
|
+
// onLateJoin effect cap. Gathers the symbol tables from the manifest (independent of the `rules` array).
|
|
342
|
+
function checkJoinPolicy(m) {
|
|
343
|
+
const states = m.multiplayer?.states;
|
|
344
|
+
if (!states?.joinPolicy)
|
|
345
|
+
return [];
|
|
346
|
+
const e = [];
|
|
347
|
+
const phases = new Set(states.phases);
|
|
348
|
+
const roomVars = m.multiplayer?.state?.roomVars ?? {};
|
|
349
|
+
const playerVars = m.multiplayer?.state?.playerVars ?? {};
|
|
350
|
+
const zoneIds = new Set((m.multiplayer?.zones ?? []).map((z) => z.id));
|
|
351
|
+
const events = m.multiplayer?.events ?? {};
|
|
352
|
+
const timersDecl = m.multiplayer?.timers ?? {};
|
|
353
|
+
const ec = buildEntityCtx(m);
|
|
354
|
+
const joinBound = new Set(['self']); // the joiner is `self` in onLateJoin
|
|
355
|
+
for (const [phase, policy] of Object.entries(states.joinPolicy)) {
|
|
356
|
+
const path = `multiplayer.states.joinPolicy."${phase}"`;
|
|
357
|
+
if (!phases.has(phase)) {
|
|
358
|
+
e.push(`${path}: unknown phase "${phase}"${didYouMean(phase, [...phases])} (declared phases: ${[...phases].join(', ') || 'none'})`);
|
|
359
|
+
}
|
|
360
|
+
const effects = policy.onLateJoin ?? [];
|
|
361
|
+
if (effects.length > exports.MULTIPLAYER_CAPS.joinPolicyEffects) {
|
|
362
|
+
e.push(`${path}.onLateJoin has ${effects.length} effects — the cap is ${exports.MULTIPLAYER_CAPS.joinPolicyEffects}`);
|
|
363
|
+
}
|
|
364
|
+
effects.forEach((eff, k) => e.push(...checkEffect(`${path}.onLateJoin[${k}]`, eff, joinBound, roomVars, playerVars, zoneIds, events, phases, ec)));
|
|
365
|
+
e.push(...checkTimerRefs(`${path}.onLateJoin`, { when: { on: 'playerJoin' }, then: effects }, timersDecl, joinBound, roomVars, playerVars, zoneIds, ec));
|
|
366
|
+
}
|
|
367
|
+
return e;
|
|
368
|
+
}
|
|
369
|
+
// §3.6/§11.3 cascade analysis over the phase graph: an edge P→Q exists when a stateEnter:P / stateExit:P rule
|
|
370
|
+
// has a transitionTo:Q (Q≠P — a transitionTo to the just-entered phase is a runtime no-op). A CYCLE means a
|
|
371
|
+
// within-tick infinite re-fire → publish REJECTS it. Otherwise maxCascadeDepth = the longest path (the BFS
|
|
372
|
+
// levels the runtime drain can reach), feeding the §11.2 budget. Sound + conservative: stateExit edges are
|
|
373
|
+
// included (the runtime MAX_CASCADE_DEPTH backstop is the final guard for any exotic case this misses).
|
|
374
|
+
function analyzeCascade(rules, phases) {
|
|
375
|
+
const adj = new Map();
|
|
376
|
+
for (const p of phases)
|
|
377
|
+
adj.set(p, new Set());
|
|
378
|
+
for (const rule of rules) {
|
|
379
|
+
const w = rule.when;
|
|
380
|
+
if (w.on !== 'stateEnter' && w.on !== 'stateExit')
|
|
381
|
+
continue;
|
|
382
|
+
const from = w.phase;
|
|
383
|
+
if (!adj.has(from))
|
|
384
|
+
adj.set(from, new Set());
|
|
385
|
+
for (const target of transitionTargets(rule.then))
|
|
386
|
+
if (target !== from)
|
|
387
|
+
adj.get(from).add(target);
|
|
388
|
+
}
|
|
389
|
+
// Cycle detection (DFS colouring) — any back-edge is a within-tick re-fire loop.
|
|
390
|
+
const WHITE = 0, GREY = 1, BLACK = 2;
|
|
391
|
+
const color = new Map();
|
|
392
|
+
let cyclic = false;
|
|
393
|
+
const visit = (n) => {
|
|
394
|
+
color.set(n, GREY);
|
|
395
|
+
for (const m of adj.get(n) ?? []) {
|
|
396
|
+
const c = color.get(m) ?? WHITE;
|
|
397
|
+
if (c === GREY)
|
|
398
|
+
cyclic = true;
|
|
399
|
+
else if (c === WHITE)
|
|
400
|
+
visit(m);
|
|
401
|
+
}
|
|
402
|
+
color.set(n, BLACK);
|
|
403
|
+
};
|
|
404
|
+
for (const n of adj.keys())
|
|
405
|
+
if ((color.get(n) ?? WHITE) === WHITE)
|
|
406
|
+
visit(n);
|
|
407
|
+
if (cyclic) {
|
|
408
|
+
return { errors: ['manifest: multiplayer.rules has a stateEnter/stateExit → transitionTo cycle that would re-fire forever within a tick — break the loop (gate a transition behind a tick/timer/event instead)'], maxDepth: 0 };
|
|
409
|
+
}
|
|
410
|
+
// Longest path in the DAG (memoized DFS) = the deepest cascade chain.
|
|
411
|
+
const depth = new Map();
|
|
412
|
+
const longest = (n) => {
|
|
413
|
+
const memo = depth.get(n);
|
|
414
|
+
if (memo !== undefined)
|
|
415
|
+
return memo;
|
|
416
|
+
let best = 0;
|
|
417
|
+
for (const m of adj.get(n) ?? [])
|
|
418
|
+
best = Math.max(best, 1 + longest(m));
|
|
419
|
+
depth.set(n, best);
|
|
420
|
+
return best;
|
|
421
|
+
};
|
|
422
|
+
let maxDepth = 0;
|
|
423
|
+
for (const n of adj.keys())
|
|
424
|
+
maxDepth = Math.max(maxDepth, longest(n));
|
|
425
|
+
return { errors: [], maxDepth };
|
|
426
|
+
}
|
|
427
|
+
// The transitionTo target phases reachable in an effect list (top level + inside a forEachPlayer body).
|
|
428
|
+
function transitionTargets(effects) {
|
|
429
|
+
const out = [];
|
|
430
|
+
for (const eff of effects) {
|
|
431
|
+
if (eff.do === 'transitionTo')
|
|
432
|
+
out.push(eff.phase);
|
|
433
|
+
else if (eff.do === 'forEachPlayer' || eff.do === 'forEachEntity')
|
|
434
|
+
out.push(...transitionTargets(eff.then));
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
// §9/§11.3 (Phase 4.5) entity-cascade analysis: an edge K→K2 exists when an `entitySpawn:K` rule mints a K2 —
|
|
439
|
+
// handling one spawn enqueues another. A CYCLE is a within-tick UNBOUNDED spawn loop (spawning A eventually
|
|
440
|
+
// spawns A again, a fresh entity each pass) → publish REJECTS it. Only `entitySpawn` events seed edges:
|
|
441
|
+
// `entityDestroy`/`tick`/etc. spawns are bounded (finite live entities each destroyed once; a tick spawns once,
|
|
442
|
+
// capped by the budget + MAX_ENTITIES_PER_KIND). Sound + conservative — no false positives (every edge is a real
|
|
443
|
+
// spawn→spawn re-trigger); the runtime MAX_CASCADE_DEPTH + MAX_ENTITIES_PER_KIND backstops catch anything subtler.
|
|
444
|
+
function analyzeEntityCascade(rules) {
|
|
445
|
+
const adj = new Map();
|
|
446
|
+
for (const rule of rules) {
|
|
447
|
+
if (rule.when.on !== 'entitySpawn')
|
|
448
|
+
continue;
|
|
449
|
+
const from = rule.when.kind;
|
|
450
|
+
if (!adj.has(from))
|
|
451
|
+
adj.set(from, new Set());
|
|
452
|
+
for (const target of spawnTargets(rule.then))
|
|
453
|
+
adj.get(from).add(target);
|
|
454
|
+
}
|
|
455
|
+
const WHITE = 0, GREY = 1, BLACK = 2;
|
|
456
|
+
const color = new Map();
|
|
457
|
+
let cyclic = false;
|
|
458
|
+
const visit = (n) => {
|
|
459
|
+
color.set(n, GREY);
|
|
460
|
+
for (const m of adj.get(n) ?? []) {
|
|
461
|
+
const c = color.get(m) ?? WHITE;
|
|
462
|
+
if (c === GREY)
|
|
463
|
+
cyclic = true;
|
|
464
|
+
else if (c === WHITE)
|
|
465
|
+
visit(m);
|
|
466
|
+
}
|
|
467
|
+
color.set(n, BLACK);
|
|
468
|
+
};
|
|
469
|
+
for (const n of adj.keys())
|
|
470
|
+
if ((color.get(n) ?? WHITE) === WHITE)
|
|
471
|
+
visit(n);
|
|
472
|
+
return cyclic
|
|
473
|
+
? ['manifest: multiplayer.rules has an entitySpawn → spawnEntity cycle that would mint entities forever within a tick — break the loop (gate the spawn behind a tick/timer/non-entity event instead)']
|
|
474
|
+
: [];
|
|
475
|
+
}
|
|
476
|
+
// The entity kinds a spawnEntity mints anywhere in an effect list (top level + inside a forEachPlayer body).
|
|
477
|
+
function spawnTargets(effects) {
|
|
478
|
+
const out = [];
|
|
479
|
+
for (const eff of effects) {
|
|
480
|
+
if (eff.do === 'spawnEntity')
|
|
481
|
+
out.push(eff.kind);
|
|
482
|
+
else if (eff.do === 'forEachPlayer' || eff.do === 'forEachEntity')
|
|
483
|
+
out.push(...spawnTargets(eff.then));
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
// §14 (4.5) does this condition REQUIRE playerCount ≥ 2 (i.e. is it unsatisfiable with a single player)? Detects
|
|
488
|
+
// the explicit `playerCount <cmp> n` patterns (either operand order), AND (any child requires), OR (all children
|
|
489
|
+
// require). Conservative — anything it can't prove returns false, so the solo gate only warns on a clear soft-lock.
|
|
490
|
+
function requiresMultiplePlayers(expr) {
|
|
491
|
+
if (typeof expr !== 'object' || expr === null || 'vec3' in expr || 'var' in expr || 'ref' in expr)
|
|
492
|
+
return false;
|
|
493
|
+
const n = expr;
|
|
494
|
+
if (n.op === 'and')
|
|
495
|
+
return Array.isArray(n.of) && n.of.some(requiresMultiplePlayers);
|
|
496
|
+
if (n.op === 'or')
|
|
497
|
+
return Array.isArray(n.of) && n.of.length > 0 && n.of.every(requiresMultiplePlayers);
|
|
498
|
+
const cmps = ['==', '<', '<=', '>', '>='];
|
|
499
|
+
if (n.op === undefined || !cmps.includes(n.op))
|
|
500
|
+
return false;
|
|
501
|
+
// playerCount OR its exact equivalent — an UNFILTERED count over the whole player domain. A `where` makes the
|
|
502
|
+
// value ≤ playerCount (and 0-or-1 at a single player), so the value-at-1 substitution below no longer holds:
|
|
503
|
+
// stay conservative and don't treat a filtered count as playerCount. (P2 — catches aggregate-count soft-locks.)
|
|
504
|
+
const isPc = (x) => {
|
|
505
|
+
if (typeof x !== 'object' || x === null)
|
|
506
|
+
return false;
|
|
507
|
+
const o = x;
|
|
508
|
+
return o.op === 'playerCount' || (o.op === 'aggregate' && o.agg === 'count' && o.scope === 'players' && o.where === undefined);
|
|
509
|
+
};
|
|
510
|
+
const flip = { '==': '==', '<': '>', '<=': '>=', '>': '<', '>=': '<=' };
|
|
511
|
+
let pcOp;
|
|
512
|
+
let threshold;
|
|
513
|
+
if (isPc(n.a) && typeof n.b === 'number') {
|
|
514
|
+
pcOp = n.op;
|
|
515
|
+
threshold = n.b;
|
|
516
|
+
}
|
|
517
|
+
else if (isPc(n.b) && typeof n.a === 'number') {
|
|
518
|
+
pcOp = flip[n.op];
|
|
519
|
+
threshold = n.a;
|
|
520
|
+
}
|
|
521
|
+
if (pcOp === undefined || threshold === undefined)
|
|
522
|
+
return false;
|
|
523
|
+
// "playerCount <pcOp> threshold" requires ≥2 iff playerCount == 1 does NOT satisfy it.
|
|
524
|
+
const satisfiedAtOne = pcOp === '==' ? 1 === threshold : pcOp === '<' ? 1 < threshold : pcOp === '<=' ? 1 <= threshold : pcOp === '>' ? 1 > threshold : 1 >= threshold;
|
|
525
|
+
return !satisfiedAtOne;
|
|
526
|
+
}
|
|
527
|
+
// §14 (4.5) single-player-safe gate: a world that declares itself solo-playable (minPlayers 1, the default) must
|
|
528
|
+
// not gate a phase TRANSITION behind playerCount ≥ 2 — a solo player could never trigger it and would be stuck.
|
|
529
|
+
// Warns per offending rule; strict (the launch pipeline) flips the warnings to errors. A world that genuinely
|
|
530
|
+
// needs multiple players declares minPlayers ≥ 2 and the gate stands down. Only meaningful with a state machine.
|
|
531
|
+
function checkSoloSafety(m, strict) {
|
|
532
|
+
const out = { errors: [], warnings: [] };
|
|
533
|
+
const mp = m.multiplayer;
|
|
534
|
+
if (!mp || !mp.states || (mp.minPlayers ?? 1) > 1)
|
|
535
|
+
return out;
|
|
536
|
+
(mp.rules ?? []).forEach((rule, i) => {
|
|
537
|
+
if (!requiresMultiplePlayers(rule.if))
|
|
538
|
+
return;
|
|
539
|
+
const targets = transitionTargets(rule.then);
|
|
540
|
+
if (targets.length === 0)
|
|
541
|
+
return;
|
|
542
|
+
const msg = `multiplayer.rules[${i}]: the transition (→ ${targets.join('/')}) only fires when playerCount ≥ 2, but the world is single-player-safe (minPlayers 1) — a solo player can't trigger it. Add a solo/timer path, or set multiplayer.minPlayers ≥ 2 if the world needs multiple players.`;
|
|
543
|
+
if (strict)
|
|
544
|
+
out.errors.push(`${msg} [rejected: pre-launch strict mode]`);
|
|
545
|
+
else
|
|
546
|
+
out.warnings.push(msg);
|
|
547
|
+
});
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
// §8 the single timer-validation authority: every timerElapsed/startTimer/cancelTimer/timerRemaining names a
|
|
551
|
+
// declared timer (did-you-mean on a miss); keyed-ness agreement (a keyed:'player' timer needs a player `key`,
|
|
552
|
+
// a room-scoped one takes none — N15: keyed-ness is static per name); the `key` ref resolves; the startTimer
|
|
553
|
+
// 1-tick floor. Best-effort over the realistic placements — the runtime treats an unknown/inactive timer as 0
|
|
554
|
+
// and a null key as a no-op (never throws), so an unresolved use is a publish-UX error, not unsafe.
|
|
555
|
+
function checkTimerRefs(path, rule, timers, bound, roomVars, playerVars, zoneIds, ec) {
|
|
556
|
+
const e = [];
|
|
557
|
+
const declared = Object.keys(timers);
|
|
558
|
+
const uses = [];
|
|
559
|
+
if (rule.if !== undefined)
|
|
560
|
+
for (const u of exprTimerUses(rule.if))
|
|
561
|
+
uses.push({ ...u, path: `${path}.if`, bound });
|
|
562
|
+
collectEffectTimerUses(rule.then, `${path}.then`, bound, uses, e);
|
|
563
|
+
// timerElapsed (the event) — name existence only; its self-binding is derived from keyed-ness in boundNames.
|
|
564
|
+
if (rule.when.on === 'timerElapsed' && !(rule.when.timer in timers)) {
|
|
565
|
+
e.push(`${path}.when: timerElapsed references unknown timer "${rule.when.timer}"${didYouMean(rule.when.timer, declared)} (declared timers: ${declared.join(', ') || 'none — add multiplayer.timers'})`);
|
|
566
|
+
}
|
|
567
|
+
for (const u of uses) {
|
|
568
|
+
if (!(u.timer in timers)) {
|
|
569
|
+
e.push(`${u.path}: references unknown timer "${u.timer}"${didYouMean(u.timer, declared)} (declared timers: ${declared.join(', ') || 'none — add multiplayer.timers'})`);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const keyedKind = timers[u.timer]?.keyed; // undefined | 'player' | 'entity:<kind>'
|
|
573
|
+
if (keyedKind !== undefined && u.key === undefined) {
|
|
574
|
+
const refHint = keyedKind === 'player' ? 'a player ref, e.g. "self"' : `an entity ref (a ${keyedKind})`;
|
|
575
|
+
e.push(`${u.path}: timer "${u.timer}" is keyed:'${keyedKind}' — it needs a "key" (${refHint})`);
|
|
576
|
+
}
|
|
577
|
+
if (keyedKind === undefined && u.key !== undefined)
|
|
578
|
+
e.push(`${u.path}: timer "${u.timer}" is room-scoped — it takes no "key" (or declare it keyed)`);
|
|
579
|
+
if (u.key !== undefined) {
|
|
580
|
+
const kr = resolveRefSlot(`${u.path}.key`, u.key, u.bound, roomVars, playerVars, zoneIds, ec);
|
|
581
|
+
e.push(...kr.errors);
|
|
582
|
+
// §9 (4.5, row 15) the key's static kind must match the timer's keyed kind — a wrong-kind key would key a
|
|
583
|
+
// dead instance at runtime (no-op). Only checked when the key's kind is statically known (else no false positive).
|
|
584
|
+
if (kr.errors.length === 0 && keyedKind !== undefined && kr.kind !== undefined) {
|
|
585
|
+
const wantEntity = keyedKind.startsWith('entity:');
|
|
586
|
+
const gotEntity = kr.kind.type === 'entity';
|
|
587
|
+
const gotKind = bindingKind(kr.kind);
|
|
588
|
+
if (wantEntity !== gotEntity || (wantEntity && gotKind !== keyedKind.slice('entity:'.length))) {
|
|
589
|
+
const got = gotEntity ? `entity:${gotKind}` : 'player';
|
|
590
|
+
e.push(`${u.path}.key: timer "${u.timer}" is keyed:'${keyedKind}' but its key resolves to a ${got} ref — they must match`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return e;
|
|
596
|
+
}
|
|
597
|
+
// The timerRemaining uses (timer + optional key) anywhere in an expression tree (logic / arithmetic / cmp).
|
|
598
|
+
function exprTimerUses(expr) {
|
|
599
|
+
if (typeof expr !== 'object' || expr === null || 'vec3' in expr || 'var' in expr || 'ref' in expr)
|
|
600
|
+
return [];
|
|
601
|
+
const node = expr;
|
|
602
|
+
if (node.op === 'timerRemaining')
|
|
603
|
+
return node.timer ? [{ timer: node.timer, key: node.key }] : [];
|
|
604
|
+
if (node.op === 'and' || node.op === 'or')
|
|
605
|
+
return node.of.flatMap(exprTimerUses);
|
|
606
|
+
if (node.op === 'not')
|
|
607
|
+
return exprTimerUses(node.of);
|
|
608
|
+
if (node.a !== undefined)
|
|
609
|
+
return [...exprTimerUses(node.a), ...exprTimerUses(node.b)];
|
|
610
|
+
return [];
|
|
611
|
+
}
|
|
612
|
+
// Walk an effect list (incl. nested forEachPlayer, which widens `bound` with its `as`) collecting startTimer/
|
|
613
|
+
// cancelTimer uses (+ the startTimer duration floor) and any timerRemaining uses in their value-exprs.
|
|
614
|
+
function collectEffectTimerUses(effects, path, bound, uses, e) {
|
|
615
|
+
effects.forEach((eff, j) => {
|
|
616
|
+
const p = `${path}[${j}]`;
|
|
617
|
+
if (eff.do === 'startTimer') {
|
|
618
|
+
uses.push({ timer: eff.timer, key: eff.key, path: p, bound });
|
|
619
|
+
// The static floor only binds a LITERAL duration; an expression's value is unknown at publish, so its
|
|
620
|
+
// sub-floor/NaN/∞/negative handling is deferred to the runtime clamp/skip (spec §3.2). The expr's type +
|
|
621
|
+
// node budget are validated in validateEffect like any other effect-borne expression.
|
|
622
|
+
if (typeof eff.seconds === 'number' && eff.seconds < exports.MULTIPLAYER_CAPS.timerMinSeconds)
|
|
623
|
+
e.push(`${p}: startTimer "${eff.timer}" seconds ${eff.seconds} is below the ${exports.MULTIPLAYER_CAPS.timerMinSeconds}s floor (one sim tick)`);
|
|
624
|
+
}
|
|
625
|
+
else if (eff.do === 'cancelTimer') {
|
|
626
|
+
uses.push({ timer: eff.timer, key: eff.key, path: p, bound });
|
|
627
|
+
}
|
|
628
|
+
else if (eff.do === 'set' || eff.do === 'teleport' || eff.do === 'respawn') {
|
|
629
|
+
for (const u of exprTimerUses(eff.to))
|
|
630
|
+
uses.push({ ...u, path: p, bound });
|
|
631
|
+
}
|
|
632
|
+
else if (eff.do === 'forEachPlayer' || eff.do === 'forEachEntity') {
|
|
633
|
+
const inner = new Set([...bound, eff.as]);
|
|
634
|
+
if (eff.where !== undefined)
|
|
635
|
+
for (const u of exprTimerUses(eff.where))
|
|
636
|
+
uses.push({ ...u, path: p, bound: inner });
|
|
637
|
+
collectEffectTimerUses(eff.then, `${p}.then`, inner, uses, e);
|
|
638
|
+
}
|
|
639
|
+
else if (eff.do === 'broadcast') {
|
|
640
|
+
for (const v of Object.values(eff.payload ?? {}))
|
|
641
|
+
for (const u of exprTimerUses(v))
|
|
642
|
+
uses.push({ ...u, path: p, bound });
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
// Wire message-type names a declared event (or action) name may not shadow — kept in sync with
|
|
647
|
+
// RESERVED_MESSAGE_TYPES in @hypersoniclabs/helix-sdk/multiplayer-contract (the manifest takes no SDK dep).
|
|
648
|
+
const RESERVED_EVENT_NAMES = new Set(['state', 'ability', 'action', 'entityState', 'entityStateBatch', 'ping', 'pong', 'stateAck', 'entityStateAck']);
|
|
649
|
+
// §10.3 events: count + reserved-name + payload-field caps. A `ref` payload field must declare `of` (the
|
|
650
|
+
// schema enforces shape + the identifier name pattern; this adds the cross-field semantics).
|
|
651
|
+
function checkEvents(events) {
|
|
652
|
+
const e = [];
|
|
653
|
+
const names = Object.keys(events);
|
|
654
|
+
if (names.length > exports.MULTIPLAYER_CAPS.events)
|
|
655
|
+
e.push(`manifest: multiplayer.events declares ${names.length} events — the cap is ${exports.MULTIPLAYER_CAPS.events}`);
|
|
656
|
+
for (const name of names) {
|
|
657
|
+
if (RESERVED_EVENT_NAMES.has(name))
|
|
658
|
+
e.push(`multiplayer.events."${name}": is a reserved wire message name — rename the event`);
|
|
659
|
+
const payload = events[name].payload ?? {};
|
|
660
|
+
const fields = Object.keys(payload);
|
|
661
|
+
if (fields.length > exports.MULTIPLAYER_CAPS.broadcastFields) {
|
|
662
|
+
e.push(`multiplayer.events."${name}": payload has ${fields.length} fields — the cap is ${exports.MULTIPLAYER_CAPS.broadcastFields}`);
|
|
663
|
+
}
|
|
664
|
+
for (const f of fields) {
|
|
665
|
+
const ft = payload[f];
|
|
666
|
+
if (ft.type === 'ref' && ft.of === undefined)
|
|
667
|
+
e.push(`multiplayer.events."${name}".payload.${f}: a ref field must declare "of"`);
|
|
668
|
+
// §10.3 (P3-B6) a collection-snapshot field — validate its element/keys shape like a var collection.
|
|
669
|
+
if (ft.type === 'list' && typeof ft.of === 'object' && ft.of.type === 'record') {
|
|
670
|
+
const rfields = Object.keys(ft.of.fields);
|
|
671
|
+
if (rfields.length > exports.MULTIPLAYER_CAPS.recordFields)
|
|
672
|
+
e.push(`multiplayer.events."${name}".payload.${f}: a record element declares ${rfields.length} fields — the cap is ${exports.MULTIPLAYER_CAPS.recordFields}`);
|
|
673
|
+
for (const rn of rfields)
|
|
674
|
+
e.push(...checkRecordField(`multiplayer.events."${name}".payload.${f}.of.fields.${rn}`, ft.of.fields[rn]));
|
|
675
|
+
}
|
|
676
|
+
if (ft.type === 'counterMap') {
|
|
677
|
+
if (ft.keys.length === 0)
|
|
678
|
+
e.push(`multiplayer.events."${name}".payload.${f}: counterMap needs at least one key`);
|
|
679
|
+
else if (ft.keys.length > exports.MULTIPLAYER_CAPS.counterKeys)
|
|
680
|
+
e.push(`multiplayer.events."${name}".payload.${f}: counterMap has ${ft.keys.length} keys — the cap is ${exports.MULTIPLAYER_CAPS.counterKeys}`);
|
|
681
|
+
if (new Set(ft.keys).size !== ft.keys.length)
|
|
682
|
+
e.push(`multiplayer.events."${name}".payload.${f}: counterMap keys must be unique`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return e;
|
|
687
|
+
}
|
|
688
|
+
// §11.5 actions: count + reserved-name + arg-count caps + arg-shape cross-fields (ref needs `of`; number
|
|
689
|
+
// min ≤ max). The room validates a client's args against this schema (type/bounds/liveness) before firing.
|
|
690
|
+
function checkActions(actions) {
|
|
691
|
+
const e = [];
|
|
692
|
+
const names = Object.keys(actions);
|
|
693
|
+
if (names.length > exports.MULTIPLAYER_CAPS.actions)
|
|
694
|
+
e.push(`manifest: multiplayer.actions declares ${names.length} actions — the cap is ${exports.MULTIPLAYER_CAPS.actions}`);
|
|
695
|
+
for (const name of names) {
|
|
696
|
+
if (RESERVED_EVENT_NAMES.has(name))
|
|
697
|
+
e.push(`multiplayer.actions."${name}": is a reserved wire message name — rename the action`);
|
|
698
|
+
const args = actions[name].args ?? {};
|
|
699
|
+
const argNames = Object.keys(args);
|
|
700
|
+
if (argNames.length > exports.MULTIPLAYER_CAPS.actionArgs)
|
|
701
|
+
e.push(`multiplayer.actions."${name}": ${argNames.length} args — the cap is ${exports.MULTIPLAYER_CAPS.actionArgs}`);
|
|
702
|
+
for (const a of argNames) {
|
|
703
|
+
const at = args[a];
|
|
704
|
+
if (at.type === 'ref' && at.of === undefined)
|
|
705
|
+
e.push(`multiplayer.actions."${name}".args.${a}: a ref arg must declare "of"`);
|
|
706
|
+
if (at.type === 'number' && at.min !== undefined && at.max !== undefined && at.min > at.max)
|
|
707
|
+
e.push(`multiplayer.actions."${name}".args.${a}: min ${at.min} exceeds max ${at.max}`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return e;
|
|
711
|
+
}
|
|
712
|
+
// §11.2 static per-tick budget: a conservative WORST-CASE count of node-evaluations one tick could do across
|
|
713
|
+
// §5 (P3 ext) the worst-case element count the cost model charges a list-scan op (forEachInList / removeWhere /
|
|
714
|
+
// listCount / listIndexOf) — set to the LARGEST declared list maxLen in the config (a sound, much tighter bound than
|
|
715
|
+
// the global listMaxLen cap, since no list can exceed its own declared maxLen, and a {ref,var} scan can't exceed the
|
|
716
|
+
// largest declared list). checkBudget assigns it before the cost walk; defaults to the global cap (no lists declared).
|
|
717
|
+
let budgetListCap = exports.MULTIPLAYER_CAPS.listMaxLen;
|
|
718
|
+
// The largest declared `list` maxLen across roomVars / playerVars / every entity kind's vars (capped at the global
|
|
719
|
+
// ceiling); the global cap when no list is declared. Bounds the per-tick cost of list-scan ops precisely.
|
|
720
|
+
function maxDeclaredListLen(m) {
|
|
721
|
+
let max = 0;
|
|
722
|
+
const scan = (decls) => {
|
|
723
|
+
for (const vt of Object.values(decls ?? {}))
|
|
724
|
+
if (vt.type === 'list')
|
|
725
|
+
max = Math.max(max, vt.maxLen);
|
|
726
|
+
};
|
|
727
|
+
scan(m.multiplayer?.state?.roomVars);
|
|
728
|
+
scan(m.multiplayer?.state?.playerVars);
|
|
729
|
+
for (const e of Object.values(m.multiplayer?.entities ?? {}))
|
|
730
|
+
scan(e.vars);
|
|
731
|
+
return max > 0 ? Math.min(max, exports.MULTIPLAYER_CAPS.listMaxLen) : exports.MULTIPLAYER_CAPS.listMaxLen;
|
|
732
|
+
}
|
|
733
|
+
// all rules — Σ fireCount(event) × ruleCost. fireCount is how many times an event can fire per tick (tick=1;
|
|
734
|
+
// player/zone events ≤ playerCap; playerContact ≤ playerCap²; self varReached ≤ playerCap; a keyed timerElapsed
|
|
735
|
+
// ≤ playerCap / entitiesPerKind — so `timers` is threaded in). ruleCost sums the `if` + each effect, weighting
|
|
736
|
+
// reductions (aggregate/nearest*) + forEach fanout by the player cap. Over budget → publish fails. Shape-based,
|
|
737
|
+
// so it runs after the schema validated the rule shapes. `listCap` bounds list-scan cost (largest declared maxLen).
|
|
738
|
+
function checkBudget(rules, timers, maxCascadeDepth = 0, states, listCap = exports.MULTIPLAYER_CAPS.listMaxLen) {
|
|
739
|
+
budgetListCap = listCap; // set before the cost walk (covers ruleCost + joinLateCost list-scan charges)
|
|
740
|
+
let base = 0;
|
|
741
|
+
for (const rule of rules)
|
|
742
|
+
base += fireCount(rule.when, timers) * ruleCost(rule);
|
|
743
|
+
// §11.2 cascade term (N9): each cascade level can re-evaluate the matched state rules, so the worst case is
|
|
744
|
+
// the base batch ×(1 + maxCascadeDepth). maxCascadeDepth is the cycle-free longest chain from analyzeCascade.
|
|
745
|
+
// `joinLate` is the worst-case onLateJoin cost (≤ player cap joins/tick) from joinPolicy (§8/3.5).
|
|
746
|
+
const total = base * (1 + maxCascadeDepth) + joinLateCost(states);
|
|
747
|
+
if (total > exports.MULTIPLAYER_CAPS.tickNodeBudget) {
|
|
748
|
+
return [
|
|
749
|
+
`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`,
|
|
750
|
+
];
|
|
751
|
+
}
|
|
752
|
+
return [];
|
|
753
|
+
}
|
|
754
|
+
// §8/3.5 the worst-case onLateJoin cost charged to the per-tick budget: a join runs one phase's onLateJoin, and
|
|
755
|
+
// up to a roomful can join in a tick — so sum the effect costs across all phases' policies × the player cap (a
|
|
756
|
+
// safe over-estimate; only the current phase's policy actually fires).
|
|
757
|
+
function joinLateCost(states) {
|
|
758
|
+
if (!states?.joinPolicy)
|
|
759
|
+
return 0;
|
|
760
|
+
let sum = 0;
|
|
761
|
+
for (const policy of Object.values(states.joinPolicy))
|
|
762
|
+
sum += (policy.onLateJoin ?? []).reduce((s, eff) => s + effectCost(eff), 0);
|
|
763
|
+
return sum * exports.PLATFORM_MAX_PLAYERS_PER_ROOM;
|
|
764
|
+
}
|
|
765
|
+
// How many times an event's rule can fire in one tick — the budget multiplier. Bounded by the player cap
|
|
766
|
+
// (PLATFORM_MAX_PLAYERS_PER_ROOM); playerContact is pairwise × both directions (≈ cap²).
|
|
767
|
+
function fireCount(event, timers) {
|
|
768
|
+
const p = exports.PLATFORM_MAX_PLAYERS_PER_ROOM;
|
|
769
|
+
switch (event.on) {
|
|
770
|
+
case 'tick':
|
|
771
|
+
return 1; // everyN doesn't lower the worst case (the tick it fires)
|
|
772
|
+
case 'varReached':
|
|
773
|
+
return event.scope === 'self' ? p : event.scope === 'entity' ? exports.MULTIPLAYER_CAPS.entitiesPerKind : 1; // entity scope evaluates once per live instance of the kind
|
|
774
|
+
case 'playerContact':
|
|
775
|
+
return p * p;
|
|
776
|
+
case 'stateEnter':
|
|
777
|
+
case 'stateExit':
|
|
778
|
+
return 1; // room-level, once per transition (cascade repetition is the ×depth term)
|
|
779
|
+
case 'timerElapsed': {
|
|
780
|
+
// P0.4: a keyed timer fires once PER INSTANCE in a tick — up to the player cap (keyed:'player') or the
|
|
781
|
+
// per-kind entity cap (keyed:'entity:<kind>'), not once. Charging it 1 lets an entity-keyed timer body
|
|
782
|
+
// pass publish then stall the sim. Room-scoped (undefined) is a single expiry.
|
|
783
|
+
const keyed = timers[event.timer]?.keyed;
|
|
784
|
+
if (keyed === 'player')
|
|
785
|
+
return p;
|
|
786
|
+
if (typeof keyed === 'string' && keyed.startsWith('entity:'))
|
|
787
|
+
return exports.MULTIPLAYER_CAPS.entitiesPerKind;
|
|
788
|
+
return 1;
|
|
789
|
+
}
|
|
790
|
+
default:
|
|
791
|
+
return p; // playerJoin / playerLeave / zoneEnter / zoneExit / zoneInside — up to once per player
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
// Worst-case node-evals to run a rule once: the `if` condition + every effect.
|
|
795
|
+
function ruleCost(rule) {
|
|
796
|
+
return (rule.if !== undefined ? exprCost(rule.if) : 0) + rule.then.reduce((sum, eff) => sum + effectCost(eff), 0);
|
|
797
|
+
}
|
|
798
|
+
// Node-evals to evaluate an expression once. A reduction (aggregate / nearestPlayer) is an O(members) scan,
|
|
799
|
+
// so it costs the player cap; everything else is 1 per node + its children.
|
|
800
|
+
function exprCost(expr) {
|
|
801
|
+
if (typeof expr !== 'object' || expr === null)
|
|
802
|
+
return 1;
|
|
803
|
+
if ('vec3' in expr)
|
|
804
|
+
return 1;
|
|
805
|
+
if ('ref' in expr)
|
|
806
|
+
return 1 + refCost(expr.ref);
|
|
807
|
+
if ('var' in expr)
|
|
808
|
+
return 1;
|
|
809
|
+
const node = expr;
|
|
810
|
+
if (node.op === 'aggregate' || node.op === 'nearestPlayer')
|
|
811
|
+
return exports.PLATFORM_MAX_PLAYERS_PER_ROOM; // O(members) scan
|
|
812
|
+
if (node.op === 'playerCount' || node.op === 'random' || node.op === 'timeInState' || node.op === 'timerRemaining' || node.op === 'now')
|
|
813
|
+
return 1; // O(1) leaves
|
|
814
|
+
if (node.op === 'randomPoint')
|
|
815
|
+
return 1; // O(1) leaf → vec3
|
|
816
|
+
if (node.op === 'listLength' || node.op === 'count')
|
|
817
|
+
return 1; // §5 (4.5.13) O(1) collection reads
|
|
818
|
+
if (node.op === 'listAt')
|
|
819
|
+
return 1 + exprCost(node.index ?? 0); // O(1) + the index expr
|
|
820
|
+
if (node.op === 'listCount' || node.op === 'listIndexOf')
|
|
821
|
+
return budgetListCap * (1 + exprCost(node.where ?? true)); // §5 (P3 ext) O(maxLen) scan × the where
|
|
822
|
+
if (node.op === 'controlledBy') {
|
|
823
|
+
const cb = expr;
|
|
824
|
+
return 1 + refCost(cb.entity) + refCost(cb.by);
|
|
825
|
+
} // §9 (P3) O(1) + the two ref resolutions
|
|
826
|
+
if (node.op === 'sameRef') {
|
|
827
|
+
const sr = expr;
|
|
828
|
+
return 1 + refCost(sr.a) + refCost(sr.b);
|
|
829
|
+
} // §7.3 (P3-B2) O(1) identity + the two ref resolutions
|
|
830
|
+
if (node.op === 'hostLoad')
|
|
831
|
+
return exports.MULTIPLAYER_CAPS.entitiesPerKind; // §9 (P3) O(entities) scan of the owner map
|
|
832
|
+
if (node.op === 'and' || node.op === 'or')
|
|
833
|
+
return 1 + node.of.reduce((s, e) => s + exprCost(e), 0);
|
|
834
|
+
if (node.op === 'not')
|
|
835
|
+
return 1 + exprCost(node.of);
|
|
836
|
+
return 1 + exprCost(node.a) + exprCost(node.b); // distance / binary cmp / arith
|
|
837
|
+
}
|
|
838
|
+
// §5 (P3 Collections) a record-list element literal vs an Expr/Ref node, for the static cost/taint walks: a plain
|
|
839
|
+
// object that is NOT a recognized expr/ref node (no op/var/vec3/ref key). A record field named op/var/vec3/ref is
|
|
840
|
+
// disambiguated by the list's declared element type at validation/runtime — this heuristic is only the static walk.
|
|
841
|
+
function isRecordLiteral(value) {
|
|
842
|
+
return (typeof value === 'object' && value !== null && !Array.isArray(value) && !('op' in value) && !('var' in value) && !('vec3' in value) && !('ref' in value));
|
|
843
|
+
}
|
|
844
|
+
// §5 (P3 Collections) cost of an append/setField value: a record literal sums its field expr costs; a scalar/ref
|
|
845
|
+
// value costs as an expr (exprCost tolerates the ref forms structurally).
|
|
846
|
+
function collectionValueCost(value) {
|
|
847
|
+
if (isRecordLiteral(value))
|
|
848
|
+
return 1 + Object.values(value).reduce((s, v) => s + exprCost(v), 0);
|
|
849
|
+
return exprCost(value);
|
|
850
|
+
}
|
|
851
|
+
// Node-evals to resolve a Ref once. A bound name / ref-var deref is O(1); a ref-returning op is an
|
|
852
|
+
// O(members) scan (the player cap).
|
|
853
|
+
function refCost(ref) {
|
|
854
|
+
if (typeof ref === 'string')
|
|
855
|
+
return 1;
|
|
856
|
+
if ('var' in ref)
|
|
857
|
+
return 1;
|
|
858
|
+
if (ref.op === 'nearestPlayer')
|
|
859
|
+
return exports.PLATFORM_MAX_PLAYERS_PER_ROOM + exprCost(ref.from);
|
|
860
|
+
if (ref.op === 'nearestEntity')
|
|
861
|
+
return exports.MULTIPLAYER_CAPS.entitiesPerKind + exprCost(ref.from); // O(entities of a kind)
|
|
862
|
+
if (ref.op === 'controllerOf')
|
|
863
|
+
return 1 + refCost(ref.entity); // §9 (P3) O(1) field read + the entity ref resolution
|
|
864
|
+
if (ref.op === 'leastLoadedPlayer')
|
|
865
|
+
return exports.PLATFORM_MAX_PLAYERS_PER_ROOM + (ref.where !== undefined ? exprCost(ref.where) : 0); // §9 (P3) O(players) scan
|
|
866
|
+
if (ref.op === 'listAt')
|
|
867
|
+
return 1 + exprCost(ref.index); // §5 (P3-B2) O(1) element read + the index expr
|
|
868
|
+
return exports.PLATFORM_MAX_PLAYERS_PER_ROOM; // aggregate argmax|argmin
|
|
869
|
+
}
|
|
870
|
+
function lvalueCost(lvalue) {
|
|
871
|
+
return typeof lvalue === 'string' ? 1 : 1 + refCost(lvalue.ref);
|
|
872
|
+
}
|
|
873
|
+
// Node-evals to apply an effect once. forEachPlayer is the player cap × its per-iteration body (where +
|
|
874
|
+
// nested effects); reductions are forbidden inside it, so the body is O(1) per iteration.
|
|
875
|
+
function effectCost(eff) {
|
|
876
|
+
switch (eff.do) {
|
|
877
|
+
case 'add':
|
|
878
|
+
return 1 + lvalueCost(eff.target) + exprCost(eff.by);
|
|
879
|
+
case 'set':
|
|
880
|
+
return 1 + lvalueCost(eff.target) + exprCost(eff.to);
|
|
881
|
+
case 'setRef':
|
|
882
|
+
return 1 + lvalueCost(eff.target) + (eff.to === null ? 0 : refCost(eff.to));
|
|
883
|
+
case 'teleport':
|
|
884
|
+
case 'respawn':
|
|
885
|
+
return 1 + refCost(eff.player) + exprCost(eff.to);
|
|
886
|
+
case 'forEachPlayer': {
|
|
887
|
+
const body = (eff.where !== undefined ? exprCost(eff.where) : 0) + eff.then.reduce((s, e) => s + effectCost(e), 0);
|
|
888
|
+
return exports.PLATFORM_MAX_PLAYERS_PER_ROOM * body;
|
|
889
|
+
}
|
|
890
|
+
case 'forEachEntity': {
|
|
891
|
+
// §9 (4.5) bounded by the per-kind entity cap (live entities of a kind can't exceed it — runtime-enforced),
|
|
892
|
+
// so the static budget stays the worst case (no runtime backstop needed — the original §11.2 rationale).
|
|
893
|
+
const body = (eff.where !== undefined ? exprCost(eff.where) : 0) + eff.then.reduce((s, e) => s + effectCost(e), 0);
|
|
894
|
+
return exports.MULTIPLAYER_CAPS.entitiesPerKind * body;
|
|
895
|
+
}
|
|
896
|
+
case 'forEachInList': {
|
|
897
|
+
// §5 (P3 ext) bounded by the list's maxLen ceiling (the worst-case element count); the body is O(1) per
|
|
898
|
+
// iteration (reductions forbidden inside, like the other loops).
|
|
899
|
+
const body = (eff.where !== undefined ? exprCost(eff.where) : 0) + eff.then.reduce((s, e) => s + effectCost(e), 0);
|
|
900
|
+
return lvalueCost(eff.list) + budgetListCap * body;
|
|
901
|
+
}
|
|
902
|
+
case 'transitionTo':
|
|
903
|
+
return 1; // O(1): set the phase + enqueue stateExit/stateEnter (the cascade itself is the ×depth budget term)
|
|
904
|
+
case 'startTimer':
|
|
905
|
+
return 1 + exprCost(eff.seconds); // O(1) set + the (possibly dynamic) duration expr
|
|
906
|
+
case 'cancelTimer':
|
|
907
|
+
return 1; // O(1): delete one deadline entry
|
|
908
|
+
case 'broadcast': {
|
|
909
|
+
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);
|
|
910
|
+
const payload = Object.values(eff.payload ?? {}).reduce((s, v) => s + exprCost(v), 0);
|
|
911
|
+
return 1 + toCost + payload;
|
|
912
|
+
}
|
|
913
|
+
case 'spawnEntity':
|
|
914
|
+
// O(1): mint + insert one entity + write its seed vars (its entitySpawn is the ×depth cascade term).
|
|
915
|
+
return 1 + exprCost(eff.at) + Object.values(eff.vars ?? {}).reduce((s, v) => s + exprCost(v), 0);
|
|
916
|
+
case 'destroyEntity':
|
|
917
|
+
return 1 + refCost(eff.entity); // O(1): resolve the ref + delete (entityDestroy is the ×depth cascade term)
|
|
918
|
+
case 'requestOwnership':
|
|
919
|
+
case 'takeover':
|
|
920
|
+
// O(1): resolve the refs + set controller/epoch (the ownershipChanged it fires is the ×depth cascade term).
|
|
921
|
+
return 1 + refCost(eff.entity) + (eff.to === null ? 0 : refCost(eff.to));
|
|
922
|
+
case 'append':
|
|
923
|
+
return 1 + collectionValueCost(eff.value); // §5 (4.5.13 + P3) O(1) push (+ the value's expr / record-field costs)
|
|
924
|
+
case 'clear':
|
|
925
|
+
return 1; // §5 (4.5.13) O(1) — empty the list / reset keys
|
|
926
|
+
case 'addCount':
|
|
927
|
+
return 1 + exprCost(eff.by); // §5 (4.5.13) O(1) key bump (+ the by expr)
|
|
928
|
+
case 'removeAt':
|
|
929
|
+
return 1 + exprCost(eff.index); // §5 (P3) O(1) splice (+ the index expr) — amortized; the shift is bounded by maxLen
|
|
930
|
+
case 'removeWhere':
|
|
931
|
+
return lvalueCost(eff.target) + budgetListCap * (1 + exprCost(eff.where)); // §5 (P3 ext) O(maxLen) scan × the where
|
|
932
|
+
case 'setField':
|
|
933
|
+
return 1 + (eff.index !== undefined ? exprCost(eff.index) : 0) + collectionValueCost(eff.to); // §5 (P3) O(1) field write
|
|
934
|
+
case 'advanceTurn':
|
|
935
|
+
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
|
|
936
|
+
case 'eliminate':
|
|
937
|
+
case 'revive':
|
|
938
|
+
return 1 + refCost(eff.player); // §7.4 (P3-B2) O(1) flag toggle + the player ref resolution
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
// §6 zones: count cap, unique ids, an extent sanity bound, and (4.5.8) the `tracks` target. The schema already
|
|
942
|
+
// validated each zone's SHAPE (shape discriminant, vec3 center, positive extents) + that `tracks` is 'players'
|
|
943
|
+
// or 'entity:<kind>'; here a 'entity:<kind>' must name a DECLARED entity kind (the zone tests those entities).
|
|
944
|
+
function checkZones(zones, entityKinds) {
|
|
945
|
+
const errors = [];
|
|
946
|
+
const warnings = [];
|
|
947
|
+
if (zones.length > exports.MULTIPLAYER_CAPS.zones) {
|
|
948
|
+
errors.push(`manifest: multiplayer.zones declares ${zones.length} zones — the cap is ${exports.MULTIPLAYER_CAPS.zones}`);
|
|
949
|
+
}
|
|
950
|
+
const seen = new Set();
|
|
951
|
+
zones.forEach((z, i) => {
|
|
952
|
+
const path = `multiplayer.zones[${i}]`;
|
|
953
|
+
if (seen.has(z.id))
|
|
954
|
+
errors.push(`${path}: duplicate zone id "${z.id}"`);
|
|
955
|
+
seen.add(z.id);
|
|
956
|
+
const extents = z.shape === 'sphere' ? [z.radius] : z.size;
|
|
957
|
+
if (extents.some((v) => v > exports.MULTIPLAYER_CAPS.zoneMaxExtent)) {
|
|
958
|
+
errors.push(`${path} ("${z.id}"): an extent exceeds the zone-size cap of ${exports.MULTIPLAYER_CAPS.zoneMaxExtent}`);
|
|
959
|
+
}
|
|
960
|
+
const trackKind = entityTrackKind(z.tracks);
|
|
961
|
+
if (trackKind !== undefined && !entityKinds.has(trackKind)) {
|
|
962
|
+
errors.push(`${path} ("${z.id}"): tracks "${z.tracks}" references unknown entity kind "${trackKind}"${didYouMean(trackKind, [...entityKinds])} (declared entities: ${[...entityKinds].join(', ') || 'none — add multiplayer.entities'})`);
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
return { errors, warnings };
|
|
966
|
+
}
|
|
967
|
+
// §9 entities (Phase 4.1): cap the declared kind count + each kind's var count, and validate every entity var's
|
|
968
|
+
// default against its own constraints (same as roomVars/playerVars). Kind names + var names are pattern-enforced by
|
|
969
|
+
// the schema. entitiesPerKind is a RUNTIME cap (the live-count ceiling), not a declaration count, so it isn't here.
|
|
970
|
+
function checkEntities(entities) {
|
|
971
|
+
const e = [];
|
|
972
|
+
const warnings = [];
|
|
973
|
+
const kinds = Object.keys(entities);
|
|
974
|
+
if (kinds.length > exports.MULTIPLAYER_CAPS.entityKinds) {
|
|
975
|
+
e.push(`manifest: multiplayer.entities declares ${kinds.length} kinds — the cap is ${exports.MULTIPLAYER_CAPS.entityKinds}`);
|
|
976
|
+
}
|
|
977
|
+
// networked-physics (Phase 0): bound the per-world dynamic-body kind count — physicsKinds × entitiesPerKind is the
|
|
978
|
+
// worst-case client physics-body simulation load, so a small cap keeps the casual-world path cheap.
|
|
979
|
+
const physicsKindCount = kinds.filter((k) => entities[k].physics).length;
|
|
980
|
+
if (physicsKindCount > exports.MULTIPLAYER_CAPS.physicsKinds) {
|
|
981
|
+
e.push(`manifest: multiplayer.entities declares ${physicsKindCount} physics kinds — the cap is ${exports.MULTIPLAYER_CAPS.physicsKinds}`);
|
|
982
|
+
}
|
|
983
|
+
// §5 (P3 Collections) entity collections ARE allowed (P3-A1) — they're realized into the entity-var union. The
|
|
984
|
+
// union is one merged schema, so a COLLECTION var name shared across kinds must agree on its exact shape (a
|
|
985
|
+
// differing element/keys would mis-realize the shared ArraySchema/MapSchema and crash the encoder). Track shapes.
|
|
986
|
+
const seenColl = new Map();
|
|
987
|
+
for (const kind of kinds) {
|
|
988
|
+
const vars = entities[kind].vars ?? {};
|
|
989
|
+
const names = Object.keys(vars);
|
|
990
|
+
if (names.length > exports.MULTIPLAYER_CAPS.entityVars) {
|
|
991
|
+
e.push(`multiplayer.entities.${kind}.vars declares ${names.length} vars — the cap is ${exports.MULTIPLAYER_CAPS.entityVars}`);
|
|
992
|
+
}
|
|
993
|
+
for (const name of names) {
|
|
994
|
+
// An entity var may not shadow a UNIVERSAL member built-in (position). connected/active are player-only, so an
|
|
995
|
+
// entity is free to declare those (e.g. a coin's `active` = is-collectible) — see playerBuiltin.
|
|
996
|
+
if (name in PLAYER_BUILTIN_TYPES && !PLAYER_ONLY_BUILTINS.has(name))
|
|
997
|
+
e.push(`multiplayer.entities.${kind}.vars.${name}: "${name}" is a reserved member built-in — rename the var`);
|
|
998
|
+
e.push(...checkVarDefault(`multiplayer.entities.${kind}.vars.${name}`, vars[name]));
|
|
999
|
+
}
|
|
1000
|
+
for (const name of names) {
|
|
1001
|
+
const vt = vars[name];
|
|
1002
|
+
if (vt.type !== 'list' && vt.type !== 'counterMap')
|
|
1003
|
+
continue;
|
|
1004
|
+
const sig = JSON.stringify(vt);
|
|
1005
|
+
const prior = seenColl.get(name);
|
|
1006
|
+
if (prior === undefined)
|
|
1007
|
+
seenColl.set(name, sig);
|
|
1008
|
+
else if (prior !== sig)
|
|
1009
|
+
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)`);
|
|
1010
|
+
}
|
|
1011
|
+
// §9 (Phase 4.6a) seek motion: `target` must name one of THIS kind's own ref vars (the member to home toward),
|
|
1012
|
+
// and `speed` must be positive. The server reads `vars[target]` → that member's position each tick.
|
|
1013
|
+
const motion = entities[kind].motion;
|
|
1014
|
+
if (motion?.type === 'seek') {
|
|
1015
|
+
const refVars = names.filter((n) => vars[n].type === 'ref');
|
|
1016
|
+
if (!refVars.includes(motion.target)) {
|
|
1017
|
+
e.push(`multiplayer.entities.${kind}.motion.target: seek targets "${motion.target}", which is not a ref var on "${kind}"${didYouMean(motion.target, refVars)} (ref vars: ${refVars.join(', ') || 'none'})`);
|
|
1018
|
+
}
|
|
1019
|
+
if (!(motion.speed > 0))
|
|
1020
|
+
e.push(`multiplayer.entities.${kind}.motion.speed: seek speed must be > 0, got ${motion.speed}`);
|
|
1021
|
+
}
|
|
1022
|
+
// §9 (4.5) waypoints: ≥2 points (schema-enforced) + a positive speed (the schema validated point vec3 shape).
|
|
1023
|
+
if (motion?.type === 'waypoints') {
|
|
1024
|
+
if (motion.points.length < 2)
|
|
1025
|
+
e.push(`multiplayer.entities.${kind}.motion.points: waypoints needs at least 2 points, got ${motion.points.length}`);
|
|
1026
|
+
if (!(motion.speed > 0))
|
|
1027
|
+
e.push(`multiplayer.entities.${kind}.motion.speed: waypoints speed must be > 0, got ${motion.speed}`);
|
|
1028
|
+
}
|
|
1029
|
+
// §9 (Phase 4.6b) owner authority: a client-simulated `owner` kind MUST declare a positive `maxSpeed` (the
|
|
1030
|
+
// relay's movement-plausibility ceiling). `maxSpeed` on a `server` kind is ignored (server motion isn't gated) → warn.
|
|
1031
|
+
const authority = entities[kind].authority ?? 'server';
|
|
1032
|
+
const maxSpeed = entities[kind].maxSpeed;
|
|
1033
|
+
const physics = entities[kind].physics;
|
|
1034
|
+
if (authority === 'owner') {
|
|
1035
|
+
if (maxSpeed === undefined)
|
|
1036
|
+
e.push(`multiplayer.entities.${kind}: authority:'owner' requires a declared maxSpeed (m/s — the movement-plausibility ceiling for its relayed transform)`);
|
|
1037
|
+
else if (!(maxSpeed > 0))
|
|
1038
|
+
e.push(`multiplayer.entities.${kind}.maxSpeed: must be > 0, got ${maxSpeed}`);
|
|
1039
|
+
// An owner entity is client-simulated; server motion would fight its uploaded transform — UNLESS it also
|
|
1040
|
+
// declares `physics` (the networked-physics HYBRID, Phase 3.5: the server runs `motion` while the body is
|
|
1041
|
+
// unowned, a client takes over the dynamic physics on contact, then it rejoins the path). So a non-static
|
|
1042
|
+
// motion is only an error when there is NO physics block to make it the hybrid.
|
|
1043
|
+
if (entities[kind].motion && entities[kind].motion.type !== 'static' && !physics)
|
|
1044
|
+
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)`);
|
|
1045
|
+
}
|
|
1046
|
+
else if (maxSpeed !== undefined) {
|
|
1047
|
+
warnings.push(`multiplayer.entities.${kind}.maxSpeed: ignored on a server-authoritative kind (server motion is deterministic, not gated) — set authority:'owner' or remove maxSpeed`);
|
|
1048
|
+
}
|
|
1049
|
+
// §9 (Phase 4.8) shared (game-owned, host-migrated) entities: shared requires authority:'owner' + the
|
|
1050
|
+
// hostMigrate lifecycle (the server assigns + re-elects the host); hostMigrate is shared-only.
|
|
1051
|
+
const lifecycle = entities[kind].ownerLifecycle;
|
|
1052
|
+
if (entities[kind].shared) {
|
|
1053
|
+
if (authority !== 'owner')
|
|
1054
|
+
e.push(`multiplayer.entities.${kind}: shared:true requires authority:'owner' (a shared entity is hosted + simulated by a client)`);
|
|
1055
|
+
if (lifecycle !== 'hostMigrate')
|
|
1056
|
+
e.push(`multiplayer.entities.${kind}: shared:true requires ownerLifecycle:'hostMigrate' (got "${lifecycle ?? 'despawnWithOwner (default)'}")`);
|
|
1057
|
+
}
|
|
1058
|
+
else if (lifecycle === 'hostMigrate') {
|
|
1059
|
+
e.push(`multiplayer.entities.${kind}: ownerLifecycle:'hostMigrate' is only for shared:true entities — a single-owner entity uses despawnWithOwner/persist/migrateToServer`);
|
|
1060
|
+
}
|
|
1061
|
+
const idleTimeout = entities[kind].idleTimeout;
|
|
1062
|
+
if (idleTimeout !== undefined && !(idleTimeout > 0))
|
|
1063
|
+
e.push(`multiplayer.entities.${kind}.idleTimeout: must be > 0 seconds, got ${idleTimeout}`);
|
|
1064
|
+
// §9 (4.5.12) voluntary ownership transfer is only for client-simulated entities (they have a transferable controller).
|
|
1065
|
+
const transferPolicy = entities[kind].transferPolicy ?? 'fixed';
|
|
1066
|
+
if (transferPolicy !== 'fixed' && authority !== 'owner') {
|
|
1067
|
+
e.push(`multiplayer.entities.${kind}: transferPolicy:'${transferPolicy}' requires authority:'owner' (only a client-simulated entity has a transferable controller)`);
|
|
1068
|
+
}
|
|
1069
|
+
// networked-physics (Phase 0/2): the dynamic-body opt-in + its per-capability cross-field coherence. The schema
|
|
1070
|
+
// already bounds the block's SHAPE (bodyType/shape dims/mass/restitution); here are the rules it can't express —
|
|
1071
|
+
// incoherent combos fail at PUBLISH, not runtime. (mass/restitution/shape bounds: enforced by the JSON schema.)
|
|
1072
|
+
if (physics) {
|
|
1073
|
+
if (authority !== 'owner')
|
|
1074
|
+
e.push(`multiplayer.entities.${kind}.physics: requires authority:'owner' (only an owner kind simulates a dynamic body — a server kind stays kinematic)`);
|
|
1075
|
+
// claim-on-contact fires the auto-takeover verb, so the kind must permit a takeover; dual-sim is the
|
|
1076
|
+
// controlled×controlled path, which by definition never transfers (sticky), so it needs the 'fixed' policy.
|
|
1077
|
+
if (physics.claimOnContact && transferPolicy !== 'takeover')
|
|
1078
|
+
e.push(`multiplayer.entities.${kind}.physics.claimOnContact: requires transferPolicy:'takeover' (the verb the auto-handoff fires); got '${transferPolicy}'`);
|
|
1079
|
+
if (physics.dualSimOnContact && transferPolicy !== 'fixed')
|
|
1080
|
+
e.push(`multiplayer.entities.${kind}.physics.dualSimOnContact: requires transferPolicy:'fixed' (a controlled body never transfers); got '${transferPolicy}'`);
|
|
1081
|
+
for (const other of physics.collidesWith ?? []) {
|
|
1082
|
+
if (!kinds.includes(other))
|
|
1083
|
+
e.push(`multiplayer.entities.${kind}.physics.collidesWith: references unknown entity kind "${other}"${didYouMean(other, kinds)} (declared entities: ${kinds.join(', ') || 'none'})`);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
// rejoinOnRelease only means anything for the server-behaved HYBRID (physics + a non-static motion).
|
|
1087
|
+
if (entities[kind].rejoinOnRelease !== undefined && !(physics && entities[kind].motion && entities[kind].motion.type !== 'static')) {
|
|
1088
|
+
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)`);
|
|
1089
|
+
}
|
|
1090
|
+
// §6 (4.5.8) attached-zone tracks: 'entity:<kind>' (entity-vs-entity overlap) must name a declared kind;
|
|
1091
|
+
// the carrier never detects itself at runtime. 'players' (default) is unconstrained.
|
|
1092
|
+
const zone = entities[kind].zone;
|
|
1093
|
+
const zoneTrackKind = zone && entityTrackKind(zone.tracks);
|
|
1094
|
+
if (zoneTrackKind && !kinds.includes(zoneTrackKind)) {
|
|
1095
|
+
e.push(`multiplayer.entities.${kind}.zone: tracks "${zone.tracks}" references unknown entity kind "${zoneTrackKind}"${didYouMean(zoneTrackKind, kinds)} (declared entities: ${kinds.join(', ') || 'none'})`);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return { errors: e, warnings };
|
|
1099
|
+
}
|
|
1100
|
+
// ── §5.2 (Phase 4.9) the accept + loud-no-op PUBLISH half (Phase-3 §6 Deferred #1) ───────────────────────
|
|
1101
|
+
// The closed v0.3 grammar would HARD-REJECT any unrecognized event/effect node. Instead a CURATED future node
|
|
1102
|
+
// (a reserved seam for a named later phase) publishes with a WARNING (the room already H3-skips it at runtime —
|
|
1103
|
+
// shipped 3.1), while a real TYPO still hard-ERRORS with did-you-mean. The pre-launch gate (validateManifest
|
|
1104
|
+
// `{strict:true}`) flips the future-node warnings → errors before any real publish. The schema's ruleEvent/
|
|
1105
|
+
// ruleEffect permissive branch lets an unknown node through to here; these sets MUST stay in sync with the
|
|
1106
|
+
// HelixRuleEvent/HelixRuleEffect unions + those schema `not` enums.
|
|
1107
|
+
const KNOWN_EVENTS = new Set(['tick', 'playerJoin', 'playerLeave', 'playerDisconnect', 'playerReconnect', 'zoneEnter', 'zoneExit', 'zoneInside', 'playerContact', 'action', 'stateEnter', 'stateExit', 'timerElapsed', 'entitySpawn', 'entityDestroy', 'ownershipChanged', 'varReached']);
|
|
1108
|
+
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']);
|
|
1109
|
+
// Reserved nodes for a named LATER phase (provisional names — the grammar lands with that phase). A node here
|
|
1110
|
+
// publishes with a warning + is a runtime no-op; any other unknown node is a typo (hard error with did-you-mean).
|
|
1111
|
+
// (4.5.12 promoted the ownership-transfer trio to KNOWN; this set is empty until the next reserved seam lands.)
|
|
1112
|
+
const FUTURE_EVENTS = {};
|
|
1113
|
+
const FUTURE_EFFECTS = {};
|
|
1114
|
+
// Classify one unrecognized node name: a reserved future node → a warning (or, in strict pre-launch mode, an
|
|
1115
|
+
// error); anything else → a hard error with did-you-mean against the known + reserved names.
|
|
1116
|
+
function classifyUnknownNode(path, kind, name, known, future, strict, out) {
|
|
1117
|
+
if (known.has(name))
|
|
1118
|
+
return;
|
|
1119
|
+
const reserved = future[name];
|
|
1120
|
+
if (reserved !== undefined) {
|
|
1121
|
+
const msg = `${path}: "${name}" is a reserved ${kind} for ${reserved} — not interpreted yet; published worlds skip it at runtime (no-op)`;
|
|
1122
|
+
if (strict)
|
|
1123
|
+
out.errors.push(`${msg} [rejected: pre-launch strict mode]`);
|
|
1124
|
+
else
|
|
1125
|
+
out.warnings.push(msg);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
out.errors.push(`${path}: unknown ${kind} "${name}"${didYouMean(name, [...known, ...Object.keys(future)])} (known ${kind}s: ${[...known].join(', ')})`);
|
|
1129
|
+
}
|
|
1130
|
+
// §5.2 (Phase 4.9) scan every rule's event + effect (recursively into forEach*.then + joinPolicy.onLateJoin)
|
|
1131
|
+
// for an unrecognized node, classifying each. Returns {errors, warnings}; runs alongside checkRules (which skips
|
|
1132
|
+
// an unknown-event rule + treats an unknown effect verb as a no-op, deferring the verdict to here).
|
|
1133
|
+
function checkFutureNodes(m, strict) {
|
|
1134
|
+
const out = { errors: [], warnings: [] };
|
|
1135
|
+
const walkEffects = (path, effects) => {
|
|
1136
|
+
(effects ?? []).forEach((eff, j) => {
|
|
1137
|
+
const p = `${path}[${j}]`;
|
|
1138
|
+
classifyUnknownNode(p, 'effect', eff.do, KNOWN_EFFECTS, FUTURE_EFFECTS, strict, out);
|
|
1139
|
+
// (P1.A3) recurse into BOTH loop bodies — a typo'd verb inside forEachEntity (incl. nested in onLateJoin)
|
|
1140
|
+
// otherwise bypasses the classifier and silently no-ops at runtime.
|
|
1141
|
+
if (eff.do === 'forEachPlayer' || eff.do === 'forEachEntity')
|
|
1142
|
+
walkEffects(`${p}.then`, eff.then);
|
|
1143
|
+
});
|
|
1144
|
+
};
|
|
1145
|
+
(m.multiplayer?.rules ?? []).forEach((rule, i) => {
|
|
1146
|
+
classifyUnknownNode(`multiplayer.rules[${i}].when`, 'event', rule.when.on, KNOWN_EVENTS, FUTURE_EVENTS, strict, out);
|
|
1147
|
+
walkEffects(`multiplayer.rules[${i}].then`, rule.then);
|
|
1148
|
+
});
|
|
1149
|
+
for (const [phase, policy] of Object.entries(m.multiplayer?.states?.joinPolicy ?? {})) {
|
|
1150
|
+
walkEffects(`multiplayer.states.joinPolicy.${phase}.onLateJoin`, policy.onLateJoin);
|
|
1151
|
+
}
|
|
1152
|
+
return out;
|
|
1153
|
+
}
|
|
1154
|
+
// §7 rules: cross-field-resolve each effect's references against the declared state. Phase-2.1 subset =
|
|
1155
|
+
// the `tick` event + the `add` effect; an `add` target must be a declared NUMBER var (room.<v>/self.<v>).
|
|
1156
|
+
// Later slices extend this (more events/effects/conditions, zones, the symbol table for bound names).
|
|
1157
|
+
function checkRules(m) {
|
|
1158
|
+
const e = [];
|
|
1159
|
+
const rules = m.multiplayer?.rules;
|
|
1160
|
+
if (!rules)
|
|
1161
|
+
return e;
|
|
1162
|
+
if (rules.length > exports.MULTIPLAYER_CAPS.rules) {
|
|
1163
|
+
e.push(`manifest: multiplayer.rules declares ${rules.length} rules — the cap is ${exports.MULTIPLAYER_CAPS.rules}`);
|
|
1164
|
+
}
|
|
1165
|
+
const roomVars = m.multiplayer?.state?.roomVars ?? {};
|
|
1166
|
+
const playerVars = m.multiplayer?.state?.playerVars ?? {};
|
|
1167
|
+
const zoneIds = new Set((m.multiplayer?.zones ?? []).map((z) => z.id));
|
|
1168
|
+
const events = m.multiplayer?.events ?? {};
|
|
1169
|
+
const actionNames = new Set(Object.keys(m.multiplayer?.actions ?? {}));
|
|
1170
|
+
const phases = new Set(m.multiplayer?.states?.phases ?? []);
|
|
1171
|
+
const timersDecl = m.multiplayer?.timers ?? {};
|
|
1172
|
+
const ec = buildEntityCtx(m);
|
|
1173
|
+
const entityKinds = ec.kinds;
|
|
1174
|
+
// §9 entity kinds that declare an attached zone — a zone event naming one binds `source` + resolves here.
|
|
1175
|
+
const entityZoneKinds = new Set(Object.entries(m.multiplayer?.entities ?? {}).filter(([, e]) => e.zone).map(([k]) => k));
|
|
1176
|
+
const zoneSelfKinds = buildZoneSelfKinds(m); // §6 (4.5.8) zones tracking 'entity:<kind>' bind self = that kind
|
|
1177
|
+
rules.forEach((rule, i) => {
|
|
1178
|
+
// §5.2 (4.9) an unrecognized event is classified in checkFutureNodes (reserved future → warn; typo → error);
|
|
1179
|
+
// skip this rule's deep validation here — its binding semantics are unknown, and the room H3-skips it anyway.
|
|
1180
|
+
if (!KNOWN_EVENTS.has(rule.when.on))
|
|
1181
|
+
return;
|
|
1182
|
+
const bound = boundNames(rule.when, timersDecl, entityZoneKinds);
|
|
1183
|
+
// §11.5 (4.7): inside an {on:action} rule, the matched action's arg-schema is in scope for `action.args.<arg>`
|
|
1184
|
+
// reads (a per-rule shallow copy of the shared ctx; non-action rules use ec as-is, actionArgs undefined).
|
|
1185
|
+
// §9 (4.5) thread the event's binding kinds (self/source/other → player|entity:<kind>, mirroring the firewall)
|
|
1186
|
+
// so ref/self reads resolve against the specific kind's vars (row 16) + a keyed timer's key must match (row 15).
|
|
1187
|
+
const boundKinds = firewallBindings(rule.when, timersDecl, entityZoneKinds, zoneSelfKinds);
|
|
1188
|
+
const ruleEc = rule.when.on === 'action'
|
|
1189
|
+
? { ...ec, boundKinds, actionArgs: m.multiplayer?.actions?.[rule.when.name]?.args ?? {} }
|
|
1190
|
+
: { ...ec, boundKinds };
|
|
1191
|
+
if (rule.when.on === 'varReached') {
|
|
1192
|
+
e.push(...checkVarReached(`multiplayer.rules[${i}].when`, rule.when, roomVars, playerVars, bound, zoneIds, ruleEc));
|
|
1193
|
+
}
|
|
1194
|
+
if (rule.when.on === 'zoneEnter' || rule.when.on === 'zoneExit' || rule.when.on === 'zoneInside') {
|
|
1195
|
+
const zone = rule.when.zone;
|
|
1196
|
+
const isStatic = zoneIds.has(zone);
|
|
1197
|
+
const isEntity = entityZoneKinds.has(zone); // §9 an entity kind with an attached zone
|
|
1198
|
+
if (isStatic && isEntity) {
|
|
1199
|
+
e.push(`multiplayer.rules[${i}].when: "${zone}" is ambiguous — it is both a declared zone id and an entity kind with an attached zone; rename one`);
|
|
1200
|
+
}
|
|
1201
|
+
else if (!isStatic && !isEntity) {
|
|
1202
|
+
const declared = [...zoneIds, ...entityZoneKinds];
|
|
1203
|
+
e.push(`multiplayer.rules[${i}].when: references unknown zone "${zone}"${didYouMean(zone, declared)} (declared zones/entity-zones: ${declared.join(', ') || 'none'})`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
if (rule.when.on === 'action') {
|
|
1207
|
+
const name = rule.when.name;
|
|
1208
|
+
if (!actionNames.has(name)) {
|
|
1209
|
+
const declared = [...actionNames];
|
|
1210
|
+
e.push(`multiplayer.rules[${i}].when: references unknown action "${name}"${didYouMean(name, declared)} (declared actions: ${declared.join(', ') || 'none'})`);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if (rule.when.on === 'stateEnter' || rule.when.on === 'stateExit') {
|
|
1214
|
+
const phase = rule.when.phase;
|
|
1215
|
+
if (!phases.has(phase)) {
|
|
1216
|
+
const declared = [...phases];
|
|
1217
|
+
e.push(`multiplayer.rules[${i}].when: "${rule.when.on}" references unknown phase "${phase}"${didYouMean(phase, declared)} (declared phases: ${declared.join(', ') || 'none — add multiplayer.states'})`);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (rule.when.on === 'entitySpawn' || rule.when.on === 'entityDestroy' || rule.when.on === 'ownershipChanged') {
|
|
1221
|
+
const kind = rule.when.kind;
|
|
1222
|
+
if (!entityKinds.has(kind)) {
|
|
1223
|
+
const declared = [...entityKinds];
|
|
1224
|
+
e.push(`multiplayer.rules[${i}].when: "${rule.when.on}" references unknown entity kind "${kind}"${didYouMean(kind, declared)} (declared entities: ${declared.join(', ') || 'none — add multiplayer.entities'})`);
|
|
1225
|
+
}
|
|
1226
|
+
else if (rule.when.on === 'ownershipChanged' && (m.multiplayer?.entities?.[kind]?.transferPolicy ?? 'fixed') === 'fixed') {
|
|
1227
|
+
// §9 (4.5.12) ownershipChanged fires only on a voluntary transfer — a 'fixed' kind's ownership never moves that way.
|
|
1228
|
+
e.push(`multiplayer.rules[${i}].when: ownershipChanged on "${kind}" never fires — that kind declares no transferPolicy (set transferPolicy:'request' or 'takeover')`);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
// §8 timer resolution: every timerElapsed/startTimer/cancelTimer/timerRemaining names a declared timer;
|
|
1232
|
+
// keyed-ness agreement (a keyed timer needs a player `key`, a room-scoped one takes none) + the key ref
|
|
1233
|
+
// resolves + the startTimer 1-tick floor.
|
|
1234
|
+
e.push(...checkTimerRefs(`multiplayer.rules[${i}]`, rule, timersDecl, bound, roomVars, playerVars, zoneIds, ruleEc));
|
|
1235
|
+
if (rule.if !== undefined) {
|
|
1236
|
+
const r = validateExpr(`multiplayer.rules[${i}].if`, rule.if, bound, roomVars, playerVars, zoneIds, ruleEc, 1);
|
|
1237
|
+
e.push(...r.errors);
|
|
1238
|
+
if (r.nodes > exports.MULTIPLAYER_CAPS.exprNodes) {
|
|
1239
|
+
e.push(`multiplayer.rules[${i}].if has ${r.nodes} expression nodes — the cap is ${exports.MULTIPLAYER_CAPS.exprNodes}`);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
if (rule.then.length > exports.MULTIPLAYER_CAPS.ruleThen) {
|
|
1243
|
+
e.push(`multiplayer.rules[${i}].then has ${rule.then.length} effects — the cap is ${exports.MULTIPLAYER_CAPS.ruleThen}`);
|
|
1244
|
+
}
|
|
1245
|
+
// `spawned` (bound by a spawnEntity) is in scope for the REST of the then-list — thread an evolving bound set.
|
|
1246
|
+
let running = bound;
|
|
1247
|
+
rule.then.forEach((eff, j) => {
|
|
1248
|
+
e.push(...checkEffect(`multiplayer.rules[${i}].then[${j}]`, eff, running, roomVars, playerVars, zoneIds, events, phases, ruleEc));
|
|
1249
|
+
if (eff.do === 'spawnEntity' && eff.bind === 'spawned')
|
|
1250
|
+
running = new Set([...running, 'spawned']);
|
|
1251
|
+
});
|
|
1252
|
+
});
|
|
1253
|
+
return e;
|
|
1254
|
+
}
|
|
1255
|
+
// The reserved names an event binds + their static member type/kind (for the firewall's dataflow). Mirrors the
|
|
1256
|
+
// runtime binding (HelixRoom): zone-on-an-entity-kind binds `source`=that entity; entity events + entity-keyed
|
|
1257
|
+
// timers bind `self`=that entity; everything else player-bound. `spawned`/`as` are threaded in the then-walk.
|
|
1258
|
+
function firewallBindings(when, timers, entityZoneKinds, zoneSelfKinds) {
|
|
1259
|
+
switch (when.on) {
|
|
1260
|
+
case 'playerContact':
|
|
1261
|
+
return { self: { type: 'player' }, other: { type: 'player' } };
|
|
1262
|
+
case 'zoneEnter':
|
|
1263
|
+
case 'zoneExit':
|
|
1264
|
+
case 'zoneInside': {
|
|
1265
|
+
// §6 (4.5.8) a zone tracking 'entity:<kind>' makes `self` that entity kind (else a player); this closes
|
|
1266
|
+
// the firewall edge — `self`'s ref vars on an owner kind are now correctly tainted. An entity-ATTACHED
|
|
1267
|
+
// zone (the name is an entity kind with a declared zone) also binds `source` = the carrying entity.
|
|
1268
|
+
const selfKind = zoneSelfKinds.get(when.zone);
|
|
1269
|
+
const b = { self: selfKind ? { type: 'entity', kind: selfKind } : { type: 'player' } };
|
|
1270
|
+
if (entityZoneKinds.has(when.zone))
|
|
1271
|
+
b.source = { type: 'entity', kind: when.zone };
|
|
1272
|
+
return b;
|
|
1273
|
+
}
|
|
1274
|
+
case 'entitySpawn':
|
|
1275
|
+
case 'entityDestroy':
|
|
1276
|
+
return { self: { type: 'entity', kind: when.kind } };
|
|
1277
|
+
case 'ownershipChanged':
|
|
1278
|
+
// §9 (4.5.12) self = the new owner (a server-chosen player), source = the entity — both server-resolved, untainted.
|
|
1279
|
+
return { self: { type: 'player' }, source: { type: 'entity', kind: when.kind } };
|
|
1280
|
+
case 'timerElapsed': {
|
|
1281
|
+
const keyed = timers[when.timer]?.keyed;
|
|
1282
|
+
if (keyed === 'player')
|
|
1283
|
+
return { self: { type: 'player' } };
|
|
1284
|
+
if (typeof keyed === 'string' && keyed.startsWith('entity:'))
|
|
1285
|
+
return { self: { type: 'entity', kind: keyed.slice('entity:'.length) } };
|
|
1286
|
+
return {};
|
|
1287
|
+
}
|
|
1288
|
+
case 'varReached':
|
|
1289
|
+
// §7.5 (P3-B5) room scope binds nothing; self binds the player; entity binds the watched kind's instance.
|
|
1290
|
+
return when.scope === 'entity' ? { self: { type: 'entity', kind: when.kind ?? '' } } : when.scope === 'self' ? { self: { type: 'player' } } : {};
|
|
1291
|
+
case 'tick':
|
|
1292
|
+
case 'stateEnter':
|
|
1293
|
+
case 'stateExit':
|
|
1294
|
+
return {};
|
|
1295
|
+
default: // playerJoin/Leave/Disconnect/Reconnect, action — all self=player (self-scoped)
|
|
1296
|
+
return { self: { type: 'player' } };
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
// Walk every effect of every rule, threading the binding scope (forEachPlayer adds its `as`=player for its body;
|
|
1300
|
+
// a spawnEntity bind:'spawned' adds that entity for the rest of its then-list). visit gets (effect, bindings, path).
|
|
1301
|
+
function walkRuleEffects(rules, timers, entityZoneKinds, zoneSelfKinds, visit) {
|
|
1302
|
+
const walk = (path, effects, bindings) => {
|
|
1303
|
+
let b = bindings;
|
|
1304
|
+
effects.forEach((eff, j) => {
|
|
1305
|
+
const p = `${path}.then[${j}]`;
|
|
1306
|
+
visit(eff, b, p);
|
|
1307
|
+
if (eff.do === 'forEachPlayer')
|
|
1308
|
+
walk(p, eff.then, { ...b, [eff.as]: { type: 'player' } });
|
|
1309
|
+
if (eff.do === 'forEachEntity')
|
|
1310
|
+
walk(p, eff.then, { ...b, [eff.as]: { type: 'entity', kind: eff.kind } });
|
|
1311
|
+
if (eff.do === 'forEachInList')
|
|
1312
|
+
walk(p, eff.then, { ...b, [eff.as]: { type: 'listElem' } }); // §5 (P3 ext) element type unresolved here → coarse (untainted) binding
|
|
1313
|
+
if (eff.do === 'spawnEntity' && eff.bind === 'spawned')
|
|
1314
|
+
b = { ...b, spawned: { type: 'entity', kind: eff.kind } };
|
|
1315
|
+
});
|
|
1316
|
+
};
|
|
1317
|
+
rules.forEach((rule, i) => walk(`multiplayer.rules[${i}]`, rule.then, firewallBindings(rule.when, timers, entityZoneKinds, zoneSelfKinds)));
|
|
1318
|
+
}
|
|
1319
|
+
// Is this Ref's VALUE owner-tainted? A bound name (string) or a ref-op resolves to a server-chosen identity →
|
|
1320
|
+
// clean. A `{var:'scope.name'}` is a ref var used as a reference → tainted iff that var is in the taint set:
|
|
1321
|
+
// an owner entity's ref var (seeded), or a var transitively setRef'd from a tainted ref.
|
|
1322
|
+
function refTainted(ref, bindings, taint) {
|
|
1323
|
+
if (ref === null || typeof ref !== 'object')
|
|
1324
|
+
return false;
|
|
1325
|
+
// §9 (P3) the controller of a tainted (client-uploaded) entity is itself tainted — propagate through controllerOf so
|
|
1326
|
+
// it's caught in a {ref,var} read or a target sink. Other ref ops (nearestPlayer/leastLoadedPlayer/aggregate) are
|
|
1327
|
+
// server reductions over server state → never client-tainted.
|
|
1328
|
+
if ('op' in ref)
|
|
1329
|
+
return ref.op === 'controllerOf' && refTainted(ref.entity, bindings, taint);
|
|
1330
|
+
const path = ref.var;
|
|
1331
|
+
const dot = path.indexOf('.');
|
|
1332
|
+
if (dot < 0)
|
|
1333
|
+
return false;
|
|
1334
|
+
const scope = path.slice(0, dot);
|
|
1335
|
+
const name = path.slice(dot + 1);
|
|
1336
|
+
if (scope === 'room')
|
|
1337
|
+
return taint.room.has(name);
|
|
1338
|
+
const b = bindings[scope];
|
|
1339
|
+
if (!b)
|
|
1340
|
+
return false;
|
|
1341
|
+
if (b.type !== 'player' && b.type !== 'entity')
|
|
1342
|
+
return false; // §5 (P3 ext) a list-element binding — server-written, never client-tainted
|
|
1343
|
+
return b.type === 'player' ? taint.player.has(name) : taint.entity.has(`${b.kind}.${name}`);
|
|
1344
|
+
}
|
|
1345
|
+
// Mark the var an lvalue writes as tainted (used when setRef stores a tainted ref into it). A {ref,var} lvalue
|
|
1346
|
+
// whose ref's member-kind is statically unclear over-taints (safe — the whitelist errs toward rejecting).
|
|
1347
|
+
function taintLvalue(target, bindings, taint, allKinds) {
|
|
1348
|
+
const broad = (name) => { taint.player.add(name); for (const k of allKinds)
|
|
1349
|
+
taint.entity.add(`${k}.${name}`); };
|
|
1350
|
+
if (typeof target === 'string') {
|
|
1351
|
+
const dot = target.indexOf('.');
|
|
1352
|
+
const scope = target.slice(0, dot);
|
|
1353
|
+
const name = target.slice(dot + 1);
|
|
1354
|
+
if (scope === 'room') {
|
|
1355
|
+
taint.room.add(name);
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
const b = bindings[scope];
|
|
1359
|
+
if (!b)
|
|
1360
|
+
return;
|
|
1361
|
+
if (b.type === 'player')
|
|
1362
|
+
taint.player.add(name);
|
|
1363
|
+
else if (b.type === 'entity')
|
|
1364
|
+
taint.entity.add(`${b.kind}.${name}`); // a list-element binding is never a string-lvalue scope (self is player/entity)
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
const r = target.ref;
|
|
1368
|
+
if (typeof r === 'string') {
|
|
1369
|
+
const b = bindings[r];
|
|
1370
|
+
if (b?.type === 'player')
|
|
1371
|
+
taint.player.add(target.var);
|
|
1372
|
+
else if (b?.type === 'entity')
|
|
1373
|
+
taint.entity.add(`${b.kind}.${target.var}`);
|
|
1374
|
+
else
|
|
1375
|
+
broad(target.var);
|
|
1376
|
+
}
|
|
1377
|
+
else {
|
|
1378
|
+
broad(target.var);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
// §9 (4.5, rows 12/13) visit every `{ref,var}` READ in an expression tree (conditions, effect values, broadcast
|
|
1382
|
+
// payloads). Dereferencing a client-uploaded (owner-tainted) ref to READ a member's var leaks cross-player state
|
|
1383
|
+
// (a payload read to "all" is the row-12 broadcast leak; a read into your own var is the row-13 leak). Recurses op
|
|
1384
|
+
// children (a/b/of/from); a bare `{var}` (no ref) is a same-member read, not a cross-member deref.
|
|
1385
|
+
function forEachRefRead(expr, visit) {
|
|
1386
|
+
if (typeof expr !== 'object' || expr === null)
|
|
1387
|
+
return;
|
|
1388
|
+
if ('vec3' in expr)
|
|
1389
|
+
return;
|
|
1390
|
+
if ('ref' in expr) {
|
|
1391
|
+
visit(expr.ref, expr.var);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
if ('var' in expr)
|
|
1395
|
+
return;
|
|
1396
|
+
const n = expr;
|
|
1397
|
+
forEachRefRead(n.a, visit);
|
|
1398
|
+
forEachRefRead(n.b, visit);
|
|
1399
|
+
forEachRefRead(n.from, visit);
|
|
1400
|
+
if (Array.isArray(n.of))
|
|
1401
|
+
n.of.forEach((s) => forEachRefRead(s, visit));
|
|
1402
|
+
else
|
|
1403
|
+
forEachRefRead(n.of, visit);
|
|
1404
|
+
}
|
|
1405
|
+
function checkFirewall(m) {
|
|
1406
|
+
const entities = m.multiplayer?.entities ?? {};
|
|
1407
|
+
const ownerKinds = Object.keys(entities).filter((k) => (entities[k].authority ?? 'server') === 'owner');
|
|
1408
|
+
if (ownerKinds.length === 0)
|
|
1409
|
+
return []; // no client-authoritative surface → the firewall is inert
|
|
1410
|
+
const rules = m.multiplayer?.rules ?? [];
|
|
1411
|
+
if (rules.length === 0)
|
|
1412
|
+
return [];
|
|
1413
|
+
const timers = m.multiplayer?.timers ?? {};
|
|
1414
|
+
const allKinds = Object.keys(entities);
|
|
1415
|
+
const entityZoneKinds = new Set(Object.entries(entities).filter(([, e]) => e.zone).map(([k]) => k));
|
|
1416
|
+
const zoneSelfKinds = buildZoneSelfKinds(m); // §6 (4.5.8) an entity-tracking zone binds self = an entity kind
|
|
1417
|
+
// Seed: every ref var on an owner kind is directly client-uploaded → tainted. Then fixed-point through setRef.
|
|
1418
|
+
const taint = { room: new Set(), player: new Set(), entity: new Set() };
|
|
1419
|
+
for (const k of ownerKinds)
|
|
1420
|
+
for (const [n, vt] of Object.entries(entities[k].vars ?? {}))
|
|
1421
|
+
if (vt.type === 'ref')
|
|
1422
|
+
taint.entity.add(`${k}.${n}`);
|
|
1423
|
+
let size = -1;
|
|
1424
|
+
while (taint.room.size + taint.player.size + taint.entity.size !== size) {
|
|
1425
|
+
size = taint.room.size + taint.player.size + taint.entity.size;
|
|
1426
|
+
walkRuleEffects(rules, timers, entityZoneKinds, zoneSelfKinds, (eff, b) => {
|
|
1427
|
+
if (eff.do === 'setRef' && eff.to !== null && refTainted(eff.to, b, taint))
|
|
1428
|
+
taintLvalue(eff.target, b, taint, allKinds);
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
// Sink pass: a tainted ref reaching any target-selecting WRITE/aim sink — or being DEREFERENCED to READ a
|
|
1432
|
+
// member's var (rows 12/13) — fails publish.
|
|
1433
|
+
const errors = [];
|
|
1434
|
+
const why = "a client-uploaded owner-entity id can't aim a cross-player effect — route cross-player consequences through a server-validated 'action'";
|
|
1435
|
+
const readWhy = "reading another member's var through a client-uploaded owner-entity ref leaks cross-player state — route cross-player reads through a server-validated 'action'";
|
|
1436
|
+
const flagReads = (expr, b, path, where) => {
|
|
1437
|
+
forEachRefRead(expr, (ref) => {
|
|
1438
|
+
if (refTainted(ref, b, taint))
|
|
1439
|
+
errors.push(`${path}: cross-player firewall — the ${where} dereferences owner-tainted "${ref.var}" to read a member's var; ${readWhy}`);
|
|
1440
|
+
});
|
|
1441
|
+
};
|
|
1442
|
+
walkRuleEffects(rules, timers, entityZoneKinds, zoneSelfKinds, (eff, b, path) => {
|
|
1443
|
+
const flag = (ref, sink) => {
|
|
1444
|
+
if (refTainted(ref, b, taint))
|
|
1445
|
+
errors.push(`${path}: cross-player firewall — the ${sink} dereferences owner-tainted "${ref.var}"; ${why}`);
|
|
1446
|
+
};
|
|
1447
|
+
switch (eff.do) {
|
|
1448
|
+
case 'teleport':
|
|
1449
|
+
case 'respawn':
|
|
1450
|
+
flag(eff.player, `${eff.do} target`);
|
|
1451
|
+
flagReads(eff.to, b, path, `${eff.do} position`); // row 13: a tainted-ref read in the target vec3
|
|
1452
|
+
break;
|
|
1453
|
+
case 'destroyEntity':
|
|
1454
|
+
flag(eff.entity, 'destroyEntity target');
|
|
1455
|
+
break;
|
|
1456
|
+
case 'requestOwnership':
|
|
1457
|
+
case 'takeover':
|
|
1458
|
+
// §9 (4.5.12) both refs aim a cross-player consequence (who gets the entity) — a client-uploaded id can't.
|
|
1459
|
+
flag(eff.entity, `${eff.do} entity`);
|
|
1460
|
+
if (eff.to !== null)
|
|
1461
|
+
flag(eff.to, `${eff.do} to`);
|
|
1462
|
+
break;
|
|
1463
|
+
case 'append':
|
|
1464
|
+
// §5 (P3) a ref-addressed target is a cross-member write — taint-flag it like add/set; the value's reads too.
|
|
1465
|
+
if (typeof eff.target !== 'string')
|
|
1466
|
+
flag(eff.target.ref, 'append write-through-ref');
|
|
1467
|
+
if (isRecordLiteral(eff.value))
|
|
1468
|
+
for (const v of Object.values(eff.value))
|
|
1469
|
+
flagReads(v, b, path, 'append value');
|
|
1470
|
+
else
|
|
1471
|
+
flagReads(eff.value, b, path, 'append value');
|
|
1472
|
+
break;
|
|
1473
|
+
case 'clear':
|
|
1474
|
+
if (typeof eff.target !== 'string')
|
|
1475
|
+
flag(eff.target.ref, 'clear write-through-ref'); // §5 (P3) cross-member write
|
|
1476
|
+
break;
|
|
1477
|
+
case 'addCount':
|
|
1478
|
+
if (typeof eff.target !== 'string')
|
|
1479
|
+
flag(eff.target.ref, 'addCount write-through-ref'); // §5 (P3) cross-member write
|
|
1480
|
+
flagReads(eff.by, b, path, 'addCount value'); // §5 (4.5.13) row-13 read-leak in the count delta
|
|
1481
|
+
break;
|
|
1482
|
+
case 'removeAt':
|
|
1483
|
+
if (typeof eff.target !== 'string')
|
|
1484
|
+
flag(eff.target.ref, 'removeAt write-through-ref'); // §5 (P3) cross-member write
|
|
1485
|
+
flagReads(eff.index, b, path, 'removeAt index');
|
|
1486
|
+
break;
|
|
1487
|
+
case 'setField':
|
|
1488
|
+
if (eff.target !== undefined && typeof eff.target !== 'string')
|
|
1489
|
+
flag(eff.target.ref, 'setField write-through-ref'); // §5 (P3) cross-member write (index-form; the as-form has no target)
|
|
1490
|
+
if (eff.index !== undefined)
|
|
1491
|
+
flagReads(eff.index, b, path, 'setField index');
|
|
1492
|
+
flagReads(eff.to, b, path, 'setField value');
|
|
1493
|
+
break;
|
|
1494
|
+
case 'removeWhere':
|
|
1495
|
+
if (typeof eff.target !== 'string')
|
|
1496
|
+
flag(eff.target.ref, 'removeWhere write-through-ref'); // §5 (P3 ext) cross-member write
|
|
1497
|
+
flagReads(eff.where, { ...b, [eff.as]: { type: 'listElem' } }, path, 'removeWhere where'); // the element binding shadows an outer name + is untainted
|
|
1498
|
+
break;
|
|
1499
|
+
case 'startTimer':
|
|
1500
|
+
case 'cancelTimer':
|
|
1501
|
+
if (eff.key !== undefined)
|
|
1502
|
+
flag(eff.key, `${eff.do} key`);
|
|
1503
|
+
break;
|
|
1504
|
+
case 'add':
|
|
1505
|
+
if (typeof eff.target !== 'string')
|
|
1506
|
+
flag(eff.target.ref, 'add write-through-ref');
|
|
1507
|
+
flagReads(eff.by, b, path, 'add value'); // row 13
|
|
1508
|
+
break;
|
|
1509
|
+
case 'set':
|
|
1510
|
+
if (typeof eff.target !== 'string')
|
|
1511
|
+
flag(eff.target.ref, 'set write-through-ref');
|
|
1512
|
+
flagReads(eff.to, b, path, 'set value'); // row 13
|
|
1513
|
+
break;
|
|
1514
|
+
case 'setRef':
|
|
1515
|
+
if (typeof eff.target !== 'string')
|
|
1516
|
+
flag(eff.target.ref, 'setRef write-through-ref');
|
|
1517
|
+
break;
|
|
1518
|
+
case 'forEachPlayer':
|
|
1519
|
+
flagReads(eff.where, b, path, 'forEachPlayer where'); // row 13
|
|
1520
|
+
break;
|
|
1521
|
+
case 'forEachInList':
|
|
1522
|
+
flagReads(eff.where, { ...b, [eff.as]: { type: 'listElem' } }, path, 'forEachInList where'); // row 13 (the element binding is untainted; the body is walked separately)
|
|
1523
|
+
break;
|
|
1524
|
+
case 'broadcast':
|
|
1525
|
+
// Forward-defense: the schema's broadcast `to` accepts only a bound name / server reduction (refOp) /
|
|
1526
|
+
// team filter — never a {var} ref-var deref — so a client-uploaded id can't reach the TARGET today. This
|
|
1527
|
+
// clause stays correct if `to` ever widens. Row 12: a tainted-ref READ in a PAYLOAD field would leak
|
|
1528
|
+
// cross-player state to the recipients — flagged below. (A server-provable `owner` binding to legitimately
|
|
1529
|
+
// target the owner stays a usability follow-up; its absence is not a leak.)
|
|
1530
|
+
if (eff.to !== 'all' && !(typeof eff.to === 'object' && 'team' in eff.to))
|
|
1531
|
+
flag(eff.to, 'broadcast `to`');
|
|
1532
|
+
for (const [f, v] of Object.entries(eff.payload ?? {}))
|
|
1533
|
+
flagReads(v, b, `${path}.payload.${f}`, 'broadcast payload'); // row 12/13
|
|
1534
|
+
break;
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
// Row 13: tainted-ref reads in a rule's `if` condition (evaluated at rule scope — no forEach `as`/spawned yet).
|
|
1538
|
+
rules.forEach((rule, i) => {
|
|
1539
|
+
if (rule.if !== undefined)
|
|
1540
|
+
flagReads(rule.if, firewallBindings(rule.when, timers, entityZoneKinds, zoneSelfKinds), `multiplayer.rules[${i}].if`, 'condition');
|
|
1541
|
+
});
|
|
1542
|
+
return errors;
|
|
1543
|
+
}
|
|
1544
|
+
// Validate a varReached event (§7.5): the watched var must be a declared scalar in its scope (vec3/ref/collection
|
|
1545
|
+
// aren't comparable), ordering cmps need a number var, and the threshold (a literal, or a {var}/{ref,var} read —
|
|
1546
|
+
// P3-B5) must match the watched var's type. Entity scope (P3-B5) watches a declared `kind`'s var (binds self=entity).
|
|
1547
|
+
function checkVarReached(path, w, roomVars, playerVars, bound, zoneIds, ec) {
|
|
1548
|
+
let vt;
|
|
1549
|
+
let scopeLabel;
|
|
1550
|
+
if (w.scope === 'entity') {
|
|
1551
|
+
if (w.kind === undefined)
|
|
1552
|
+
return [`${path}: varReached scope:"entity" requires "kind" (the entity kind to watch)`];
|
|
1553
|
+
if (!ec.kinds.has(w.kind))
|
|
1554
|
+
return [`${path}: varReached watches unknown entity kind "${w.kind}"${didYouMean(w.kind, [...ec.kinds])} (declared entities: ${[...ec.kinds].join(', ') || 'none'})`];
|
|
1555
|
+
const kv = ec.kindVars[w.kind] ?? {};
|
|
1556
|
+
vt = kv[w.var];
|
|
1557
|
+
scopeLabel = `entity:${w.kind}`;
|
|
1558
|
+
if (!vt)
|
|
1559
|
+
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'})`];
|
|
1560
|
+
}
|
|
1561
|
+
else {
|
|
1562
|
+
if (w.kind !== undefined)
|
|
1563
|
+
return [`${path}: varReached "kind" is only valid with scope:"entity"`];
|
|
1564
|
+
const decls = w.scope === 'room' ? roomVars : playerVars;
|
|
1565
|
+
vt = decls[w.var];
|
|
1566
|
+
scopeLabel = w.scope;
|
|
1567
|
+
if (!vt) {
|
|
1568
|
+
const declared = Object.keys(decls);
|
|
1569
|
+
return [`${path}: varReached watches unknown ${w.scope} var "${w.var}"${didYouMean(w.var, declared)} (declared ${w.scope} vars: ${declared.join(', ') || 'none'})`];
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
if (vt.type === 'vec3' || vt.type === 'ref' || vt.type === 'list' || vt.type === 'counterMap') {
|
|
1573
|
+
return [`${path}: varReached can't compare a "${vt.type}" var (${scopeLabel}.${w.var}) — use a number/string/boolean var`];
|
|
1574
|
+
}
|
|
1575
|
+
const e = [];
|
|
1576
|
+
const ordering = w.cmp === '<' || w.cmp === '<=' || w.cmp === '>' || w.cmp === '>=';
|
|
1577
|
+
if (ordering && vt.type !== 'number')
|
|
1578
|
+
e.push(`${path}: cmp "${w.cmp}" needs a number var, but ${scopeLabel}.${w.var} is "${vt.type}"`);
|
|
1579
|
+
if (typeof w.value === 'object' && w.value !== null) {
|
|
1580
|
+
// P3-B5 a dynamic threshold ({var}/{ref,var}) — validate as an expr; its type must match the watched var.
|
|
1581
|
+
const r = validateExpr(`${path}.value`, w.value, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1582
|
+
e.push(...r.errors);
|
|
1583
|
+
if (r.type !== undefined && r.type !== vt.type)
|
|
1584
|
+
e.push(`${path}: threshold value is "${r.type}" but ${scopeLabel}.${w.var} is "${vt.type}"`);
|
|
1585
|
+
}
|
|
1586
|
+
else if (typeof w.value !== vt.type) {
|
|
1587
|
+
e.push(`${path}: value (${JSON.stringify(w.value)}) does not match ${scopeLabel}.${w.var} ("${vt.type}")`);
|
|
1588
|
+
}
|
|
1589
|
+
return e;
|
|
1590
|
+
}
|
|
1591
|
+
// Reserved binding/scope names a `forEachPlayer as:` (or future bind) may not shadow (§7.1).
|
|
1592
|
+
const RESERVED_REF_NAMES = new Set(['self', 'other', 'source', 'spawned', 'room']);
|
|
1593
|
+
// Validate one effect (§7.4) against the declared state + the event's bound names. forEachPlayer + teleport/
|
|
1594
|
+
// respawn have no target lvalue, so branch first; add/set need a writable scalar var (set requires a
|
|
1595
|
+
// type-compatible value); setRef needs a ref var + a Ref/null `to`. A target may be string or `{ref,var}`.
|
|
1596
|
+
function checkEffect(path, eff, bound, roomVars, playerVars, zoneIds, events, phases, ec) {
|
|
1597
|
+
// §5.2 (4.9) an unrecognized verb is classified in checkFutureNodes (reserved future → warn; typo → error) —
|
|
1598
|
+
// don't double-report or crash on its absent fields here; treat it as a no-op for the structural pass.
|
|
1599
|
+
if (!KNOWN_EFFECTS.has(eff.do))
|
|
1600
|
+
return [];
|
|
1601
|
+
if (eff.do === 'forEachPlayer')
|
|
1602
|
+
return checkForEach(path, eff, bound, roomVars, playerVars, zoneIds, events, phases, ec);
|
|
1603
|
+
if (eff.do === 'forEachEntity')
|
|
1604
|
+
return checkForEachEntity(path, eff, bound, roomVars, playerVars, zoneIds, events, phases, ec);
|
|
1605
|
+
if (eff.do === 'forEachInList')
|
|
1606
|
+
return checkForEachInList(path, eff, bound, roomVars, playerVars, zoneIds, events, phases, ec);
|
|
1607
|
+
if (eff.do === 'broadcast')
|
|
1608
|
+
return checkBroadcast(path, eff, bound, roomVars, playerVars, zoneIds, events, ec);
|
|
1609
|
+
if (eff.do === 'spawnEntity') {
|
|
1610
|
+
const e = [];
|
|
1611
|
+
if (!ec.kinds.has(eff.kind)) {
|
|
1612
|
+
const declared = [...ec.kinds];
|
|
1613
|
+
e.push(`${path}: spawnEntity references unknown entity kind "${eff.kind}"${didYouMean(eff.kind, declared)} (declared entities: ${declared.join(', ') || 'none — add multiplayer.entities'})`);
|
|
1614
|
+
}
|
|
1615
|
+
const r = validateExpr(`${path}.at`, eff.at, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1616
|
+
e.push(...r.errors);
|
|
1617
|
+
if (r.type !== undefined && r.type !== 'vec3')
|
|
1618
|
+
e.push(`${path}.at: spawnEntity needs a vec3 position, got "${r.type}"`);
|
|
1619
|
+
// §9 (4.6a) `vars` seeds the SPAWNED kind's declared vars — each value type-matches its var (a ref var
|
|
1620
|
+
// takes a Ref, else an Expr of the var's type); an unknown var name is an error; omitted vars use defaults.
|
|
1621
|
+
if (eff.vars !== undefined) {
|
|
1622
|
+
const decls = ec.kindVars[eff.kind] ?? {};
|
|
1623
|
+
for (const [name, val] of Object.entries(eff.vars)) {
|
|
1624
|
+
const vt = decls[name];
|
|
1625
|
+
if (!vt) {
|
|
1626
|
+
e.push(`${path}.vars: unknown var "${name}" for entity kind "${eff.kind}"${didYouMean(name, Object.keys(decls))} (declared vars: ${Object.keys(decls).join(', ') || 'none'})`);
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
// §5 (P3 Collections) a collection entity var can't be seeded at spawn (it has no scalar literal — it starts
|
|
1630
|
+
// empty/zeroed). Populate it with append/addCount in an entitySpawn rule (self = the new entity) instead.
|
|
1631
|
+
if (vt.type === 'list' || vt.type === 'counterMap') {
|
|
1632
|
+
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`);
|
|
1633
|
+
continue;
|
|
1634
|
+
}
|
|
1635
|
+
if (vt.type === 'ref') {
|
|
1636
|
+
e.push(...resolveRefSlot(`${path}.vars.${name}`, val, bound, roomVars, playerVars, zoneIds, ec).errors);
|
|
1637
|
+
}
|
|
1638
|
+
else {
|
|
1639
|
+
const vr = validateExpr(`${path}.vars.${name}`, val, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1640
|
+
e.push(...vr.errors);
|
|
1641
|
+
if (vr.type !== undefined && vr.type !== vt.type)
|
|
1642
|
+
e.push(`${path}.vars.${name}: a "${vr.type}" value doesn't match the declared "${vt.type}"`);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
return e;
|
|
1647
|
+
}
|
|
1648
|
+
if (eff.do === 'destroyEntity')
|
|
1649
|
+
return resolveRefSlot(`${path}.entity`, eff.entity, bound, roomVars, playerVars, zoneIds, ec).errors;
|
|
1650
|
+
if (eff.do === 'requestOwnership' || eff.do === 'takeover') {
|
|
1651
|
+
// §9 (4.5.12) entity Ref + a `to` player Ref (or null = release). Both are target sinks (firewall-checked).
|
|
1652
|
+
const ent = resolveRefSlot(`${path}.entity`, eff.entity, bound, roomVars, playerVars, zoneIds, ec);
|
|
1653
|
+
const e = [...ent.errors];
|
|
1654
|
+
if (eff.to !== null)
|
|
1655
|
+
e.push(...resolveRefSlot(`${path}.to`, eff.to, bound, roomVars, playerVars, zoneIds, ec).errors);
|
|
1656
|
+
// When the target entity's kind is statically known, flag a verb its declared transferPolicy can never honor.
|
|
1657
|
+
if (ent.kind?.type === 'entity' && ent.kind.kind) {
|
|
1658
|
+
const policy = ec.kindPolicy[ent.kind.kind] ?? 'fixed';
|
|
1659
|
+
const ok = eff.do === 'takeover' ? policy === 'takeover' : policy !== 'fixed';
|
|
1660
|
+
if (!ok)
|
|
1661
|
+
e.push(`${path}: "${eff.do}" can't transfer "${ent.kind.kind}" — its transferPolicy is "${policy}" (${eff.do === 'takeover' ? "needs 'takeover'" : "needs 'request' or 'takeover'"})`);
|
|
1662
|
+
}
|
|
1663
|
+
return e;
|
|
1664
|
+
}
|
|
1665
|
+
if (eff.do === 'transitionTo') {
|
|
1666
|
+
if (phases.has(eff.phase))
|
|
1667
|
+
return [];
|
|
1668
|
+
const declared = [...phases];
|
|
1669
|
+
return [`${path}: transitionTo references unknown phase "${eff.phase}"${didYouMean(eff.phase, declared)} (declared phases: ${declared.join(', ') || 'none — add multiplayer.states'})`];
|
|
1670
|
+
}
|
|
1671
|
+
if (eff.do === 'cancelTimer')
|
|
1672
|
+
return []; // §8 timer effect — name validated in checkTimerRefs (no duration)
|
|
1673
|
+
if (eff.do === 'startTimer') {
|
|
1674
|
+
// §8 timer name + literal-floor live in checkTimerRefs; here we type-check the (possibly dynamic) `seconds`
|
|
1675
|
+
// duration expr to number + cap its node count, exactly like add.by / set.to.
|
|
1676
|
+
const r = validateExpr(`${path}.seconds`, eff.seconds, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1677
|
+
const e = [...r.errors];
|
|
1678
|
+
if (r.type !== undefined && r.type !== 'number')
|
|
1679
|
+
e.push(`${path}.seconds: startTimer needs a number duration, got "${r.type}"`);
|
|
1680
|
+
if (r.nodes > exports.MULTIPLAYER_CAPS.exprNodes)
|
|
1681
|
+
e.push(`${path}.seconds has ${r.nodes} expression nodes — the cap is ${exports.MULTIPLAYER_CAPS.exprNodes}`);
|
|
1682
|
+
return e;
|
|
1683
|
+
}
|
|
1684
|
+
if (eff.do === 'teleport' || eff.do === 'respawn') {
|
|
1685
|
+
const e = resolveRefSlot(`${path}.player`, eff.player, bound, roomVars, playerVars, zoneIds, ec).errors;
|
|
1686
|
+
const r = validateExpr(`${path}.to`, eff.to, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1687
|
+
e.push(...r.errors);
|
|
1688
|
+
if (r.type !== undefined && r.type !== 'vec3')
|
|
1689
|
+
e.push(`${path}.to: "${eff.do}" needs a vec3 position, got "${r.type}"`);
|
|
1690
|
+
return e;
|
|
1691
|
+
}
|
|
1692
|
+
if (eff.do === 'append') {
|
|
1693
|
+
// §5 (4.5.13 + P3 Collections) append a value to a list var (string or {ref,var} target); the value must match
|
|
1694
|
+
// the list's ELEMENT type (a scalar Expr, a ref, or a record literal).
|
|
1695
|
+
const c = resolveCollectionLvalue(`${path}.target`, eff.target, bound, roomVars, playerVars, zoneIds, ec);
|
|
1696
|
+
if (c.errors.length)
|
|
1697
|
+
return c.errors;
|
|
1698
|
+
if (c.vt.type !== 'list')
|
|
1699
|
+
return [`${path}: "append" needs a list var, but ${lvalueLabel(eff.target)} is "${c.vt.type}"`];
|
|
1700
|
+
return validateListElementValue(`${path}.value`, eff.value, c.vt.of, bound, roomVars, playerVars, zoneIds, ec);
|
|
1701
|
+
}
|
|
1702
|
+
if (eff.do === 'clear') {
|
|
1703
|
+
// §5 (4.5.13) empty a list OR reset every counterMap key to 0.
|
|
1704
|
+
const c = resolveCollectionLvalue(`${path}.target`, eff.target, bound, roomVars, playerVars, zoneIds, ec);
|
|
1705
|
+
if (c.errors.length)
|
|
1706
|
+
return c.errors;
|
|
1707
|
+
if (c.vt.type !== 'list' && c.vt.type !== 'counterMap')
|
|
1708
|
+
return [`${path}: "clear" needs a list or counterMap var, but ${lvalueLabel(eff.target)} is "${c.vt.type}"`];
|
|
1709
|
+
return [];
|
|
1710
|
+
}
|
|
1711
|
+
if (eff.do === 'addCount') {
|
|
1712
|
+
// §5 (4.5.13) add a number `by` to a counterMap's declared `key`.
|
|
1713
|
+
const c = resolveCollectionLvalue(`${path}.target`, eff.target, bound, roomVars, playerVars, zoneIds, ec);
|
|
1714
|
+
if (c.errors.length)
|
|
1715
|
+
return c.errors;
|
|
1716
|
+
const e = [];
|
|
1717
|
+
if (c.vt.type !== 'counterMap')
|
|
1718
|
+
e.push(`${path}: "addCount" needs a counterMap var, but ${lvalueLabel(eff.target)} is "${c.vt.type}"`);
|
|
1719
|
+
else if (!c.vt.keys.includes(eff.key))
|
|
1720
|
+
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(', ')})`);
|
|
1721
|
+
const r = validateExpr(`${path}.by`, eff.by, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1722
|
+
e.push(...r.errors);
|
|
1723
|
+
if (r.type !== undefined && r.type !== 'number')
|
|
1724
|
+
e.push(`${path}.by: "addCount" needs a number, got "${r.type}"`);
|
|
1725
|
+
return e;
|
|
1726
|
+
}
|
|
1727
|
+
if (eff.do === 'removeAt') {
|
|
1728
|
+
// §5 (P3) remove the element at `index` from a list (shift). The index is a number expr; out-of-range = no-op.
|
|
1729
|
+
const c = resolveCollectionLvalue(`${path}.target`, eff.target, bound, roomVars, playerVars, zoneIds, ec);
|
|
1730
|
+
if (c.errors.length)
|
|
1731
|
+
return c.errors;
|
|
1732
|
+
if (c.vt.type !== 'list')
|
|
1733
|
+
return [`${path}: "removeAt" needs a list var, but ${lvalueLabel(eff.target)} is "${c.vt.type}"`];
|
|
1734
|
+
const r = validateExpr(`${path}.index`, eff.index, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1735
|
+
const e = [...r.errors];
|
|
1736
|
+
if (r.type !== undefined && r.type !== 'number')
|
|
1737
|
+
e.push(`${path}.index: "removeAt" needs a number index, got "${r.type}"`);
|
|
1738
|
+
return e;
|
|
1739
|
+
}
|
|
1740
|
+
if (eff.do === 'removeWhere')
|
|
1741
|
+
return checkRemoveWhere(path, eff, bound, roomVars, playerVars, zoneIds, ec);
|
|
1742
|
+
if (eff.do === 'setField') {
|
|
1743
|
+
// §5 (P3) write a record element's `field`. AS-form: address a record element `as` bound by an enclosing
|
|
1744
|
+
// forEachInList (no target/index). INDEX-form: address element `index` of the list `target`. A helper validates
|
|
1745
|
+
// the `to` value matches the resolved record field `ft`.
|
|
1746
|
+
const checkTo = (ft) => {
|
|
1747
|
+
if (ft.type === 'ref')
|
|
1748
|
+
return resolveRefSlot(`${path}.to`, eff.to, bound, roomVars, playerVars, zoneIds, ec).errors;
|
|
1749
|
+
const vr = validateExpr(`${path}.to`, eff.to, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1750
|
+
const e = [...vr.errors];
|
|
1751
|
+
if (vr.type !== undefined && vr.type !== ft.type)
|
|
1752
|
+
e.push(`${path}.to: a "${vr.type}" doesn't match the record field "${eff.field}" ("${ft.type}")`);
|
|
1753
|
+
return e;
|
|
1754
|
+
};
|
|
1755
|
+
if (eff.as !== undefined) {
|
|
1756
|
+
const e = [];
|
|
1757
|
+
if (eff.index !== undefined)
|
|
1758
|
+
e.push(`${path}: "setField" can't take both "as" and "index" — "as" addresses the element forEachInList bound`);
|
|
1759
|
+
const k = ec.boundKinds?.[eff.as];
|
|
1760
|
+
if (!bound.has(eff.as) || k?.type !== 'listRecord')
|
|
1761
|
+
return [...e, `${path}.as: "${eff.as}" is not a record element bound by an enclosing forEachInList`];
|
|
1762
|
+
const ft = k.fields[eff.field];
|
|
1763
|
+
if (!ft)
|
|
1764
|
+
return [...e, `${path}.field: unknown record field "${eff.field}"${didYouMean(eff.field, Object.keys(k.fields))} (fields: ${Object.keys(k.fields).join(', ')})`];
|
|
1765
|
+
return [...e, ...checkTo(ft)];
|
|
1766
|
+
}
|
|
1767
|
+
if (eff.target === undefined)
|
|
1768
|
+
return [`${path}: "setField" needs a "target" (with an "index"), or an "as" bound by an enclosing forEachInList`];
|
|
1769
|
+
const c = resolveCollectionLvalue(`${path}.target`, eff.target, bound, roomVars, playerVars, zoneIds, ec);
|
|
1770
|
+
if (c.errors.length)
|
|
1771
|
+
return c.errors;
|
|
1772
|
+
if (c.vt.type !== 'list' || typeof c.vt.of !== 'object' || c.vt.of.type !== 'record') {
|
|
1773
|
+
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}"`];
|
|
1774
|
+
}
|
|
1775
|
+
if (eff.index === undefined)
|
|
1776
|
+
return [`${path}: "setField" needs an "index" (or an "as" bound by forEachInList)`];
|
|
1777
|
+
const e = [];
|
|
1778
|
+
const r = validateExpr(`${path}.index`, eff.index, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1779
|
+
e.push(...r.errors);
|
|
1780
|
+
if (r.type !== undefined && r.type !== 'number')
|
|
1781
|
+
e.push(`${path}.index: "setField" needs a number index, got "${r.type}"`);
|
|
1782
|
+
const ft = c.vt.of.fields[eff.field];
|
|
1783
|
+
if (!ft)
|
|
1784
|
+
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(', ')})`);
|
|
1785
|
+
else
|
|
1786
|
+
e.push(...checkTo(ft));
|
|
1787
|
+
return e;
|
|
1788
|
+
}
|
|
1789
|
+
if (eff.do === 'advanceTurn') {
|
|
1790
|
+
// §7.4 (P3-B2) `order` must be a `list of ref:player`; `index` a writable number var.
|
|
1791
|
+
const c = resolveCollectionLvalue(`${path}.order`, eff.order, bound, roomVars, playerVars, zoneIds, ec);
|
|
1792
|
+
if (c.errors.length)
|
|
1793
|
+
return c.errors;
|
|
1794
|
+
const ofElem = c.vt.type === 'list' ? c.vt.of : undefined;
|
|
1795
|
+
if (c.vt.type !== 'list' || typeof ofElem !== 'object' || ofElem.type !== 'ref' || ofElem.of !== 'player') {
|
|
1796
|
+
return [`${path}.order: "advanceTurn" needs a list of player refs (got ${lvalueLabel(eff.order)})`];
|
|
1797
|
+
}
|
|
1798
|
+
const idx = resolveLvalue(`${path}.index`, eff.index, bound, roomVars, playerVars, zoneIds, ec);
|
|
1799
|
+
const e = [...idx.errors];
|
|
1800
|
+
if (idx.type !== undefined && idx.type !== 'number')
|
|
1801
|
+
e.push(`${path}.index: "advanceTurn" needs a number var, but ${lvalueLabel(eff.index)} is "${idx.type}"`);
|
|
1802
|
+
return e;
|
|
1803
|
+
}
|
|
1804
|
+
if (eff.do === 'eliminate' || eff.do === 'revive') {
|
|
1805
|
+
// §7.4 (P3-B2) `player` is a Ref (firewall-checked); the room toggles its reserved `active` flag.
|
|
1806
|
+
return resolveRefSlot(`${path}.player`, eff.player, bound, roomVars, playerVars, zoneIds, ec).errors;
|
|
1807
|
+
}
|
|
1808
|
+
// add | set | setRef — all have a target lvalue. (TS can't reliably narrow this recursive union past the
|
|
1809
|
+
// early returns above, so assert the remaining subtype; `ve.do` still discriminates `to` within it.)
|
|
1810
|
+
const ve = eff;
|
|
1811
|
+
const target = resolveLvalue(`${path}.target`, ve.target, bound, roomVars, playerVars, zoneIds, ec);
|
|
1812
|
+
const e = [...target.errors];
|
|
1813
|
+
if (target.type === undefined)
|
|
1814
|
+
return e; // target unresolved — don't pile on
|
|
1815
|
+
const label = lvalueLabel(ve.target);
|
|
1816
|
+
if (ve.do === 'add') {
|
|
1817
|
+
if (target.type !== 'number')
|
|
1818
|
+
e.push(`${path}: "do":"add" needs a number var, but ${label} is "${target.type}"`);
|
|
1819
|
+
const r = validateExpr(`${path}.by`, ve.by, bound, roomVars, playerVars, zoneIds, ec, 1); // §7.4 `by` is a number expr (4.5)
|
|
1820
|
+
e.push(...r.errors);
|
|
1821
|
+
if (r.nodes > exports.MULTIPLAYER_CAPS.exprNodes)
|
|
1822
|
+
e.push(`${path}.by has ${r.nodes} expression nodes — the cap is ${exports.MULTIPLAYER_CAPS.exprNodes}`);
|
|
1823
|
+
if (r.type !== undefined && r.type !== 'number')
|
|
1824
|
+
e.push(`${path}.by: "do":"add" needs a number, got "${r.type}"`);
|
|
1825
|
+
}
|
|
1826
|
+
else if (ve.do === 'set') {
|
|
1827
|
+
if (target.type === 'ref')
|
|
1828
|
+
e.push(`${path}: "do":"set" can't write a ref var — use "setRef" for ${label}`);
|
|
1829
|
+
const r = validateExpr(`${path}.to`, ve.to, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
1830
|
+
e.push(...r.errors);
|
|
1831
|
+
if (r.nodes > exports.MULTIPLAYER_CAPS.exprNodes)
|
|
1832
|
+
e.push(`${path}.to has ${r.nodes} expression nodes — the cap is ${exports.MULTIPLAYER_CAPS.exprNodes}`);
|
|
1833
|
+
if (target.type !== 'ref' && r.type !== undefined && r.type !== target.type) {
|
|
1834
|
+
e.push(`${path}.to: a "${r.type}" value can't be set into ${label} ("${target.type}")`);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
else {
|
|
1838
|
+
if (target.type !== 'ref')
|
|
1839
|
+
e.push(`${path}: "do":"setRef" needs a ref var, but ${label} is "${target.type}"`);
|
|
1840
|
+
if (ve.to !== null)
|
|
1841
|
+
e.push(...resolveRefSlot(`${path}.to`, ve.to, bound, roomVars, playerVars, zoneIds, ec).errors);
|
|
1842
|
+
}
|
|
1843
|
+
return e;
|
|
1844
|
+
}
|
|
1845
|
+
// A human-readable label for an lvalue in an error message — "self.x" or `{ref:…,var:"team"}`.
|
|
1846
|
+
function lvalueLabel(lvalue) {
|
|
1847
|
+
if (typeof lvalue === 'string')
|
|
1848
|
+
return lvalue;
|
|
1849
|
+
return `{ref:${typeof lvalue.ref === 'string' ? `"${lvalue.ref}"` : '…'},var:"${lvalue.var}"}`;
|
|
1850
|
+
}
|
|
1851
|
+
// Validate forEachPlayer (§7.4) — the one bounded loop: single level (no nested forEach), binds `as` (which
|
|
1852
|
+
// can't shadow a reserved name), an optional `where` filter, and nested effects. The `as` ref is in scope for
|
|
1853
|
+
// where + then. `aggregate`/`nearest*` are FORBIDDEN inside (an O(n²) scan-in-a-loop) — a publish error.
|
|
1854
|
+
function checkForEach(path, eff, bound, roomVars, playerVars, zoneIds, events, phases, ec) {
|
|
1855
|
+
const e = [];
|
|
1856
|
+
if (RESERVED_REF_NAMES.has(eff.as))
|
|
1857
|
+
e.push(`${path}.as: "${eff.as}" is a reserved name`);
|
|
1858
|
+
const inner = new Set([...bound, eff.as]);
|
|
1859
|
+
if (eff.where !== undefined) {
|
|
1860
|
+
e.push(...validateExpr(`${path}.where`, eff.where, inner, roomVars, playerVars, zoneIds, ec, 1).errors);
|
|
1861
|
+
if (exprHasReduction(eff.where))
|
|
1862
|
+
e.push(`${path}.where: aggregate/nearest* are forbidden inside forEachPlayer`);
|
|
1863
|
+
}
|
|
1864
|
+
if (eff.then.length > exports.MULTIPLAYER_CAPS.ruleThen)
|
|
1865
|
+
e.push(`${path}.then has ${eff.then.length} effects — the cap is ${exports.MULTIPLAYER_CAPS.ruleThen}`);
|
|
1866
|
+
let running = inner;
|
|
1867
|
+
eff.then.forEach((sub, j) => {
|
|
1868
|
+
if (sub.do === 'forEachPlayer' || sub.do === 'forEachEntity' || sub.do === 'forEachInList') {
|
|
1869
|
+
e.push(`${path}.then[${j}]: ${sub.do} can't nest inside forEachPlayer (single level only)`);
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
e.push(...checkEffect(`${path}.then[${j}]`, sub, running, roomVars, playerVars, zoneIds, events, phases, ec));
|
|
1873
|
+
if (effectHasReduction(sub))
|
|
1874
|
+
e.push(`${path}.then[${j}]: aggregate/nearest* are forbidden inside forEachPlayer`);
|
|
1875
|
+
if (sub.do === 'spawnEntity' && sub.bind === 'spawned')
|
|
1876
|
+
running = new Set([...running, 'spawned']);
|
|
1877
|
+
});
|
|
1878
|
+
return e;
|
|
1879
|
+
}
|
|
1880
|
+
// §7.4/§9 (4.5/4.3b) validate forEachEntity — the entity loop. Like forEachPlayer (single level, optional `where`,
|
|
1881
|
+
// reductions forbidden inside) but iterates a declared `kind` and binds `as` = an entity of that kind, threading
|
|
1882
|
+
// its kind into boundKinds so `{ref:as,var}` reads resolve per-kind (4.5.1). Bounded by the per-kind entity cap.
|
|
1883
|
+
function checkForEachEntity(path, eff, bound, roomVars, playerVars, zoneIds, events, phases, ec) {
|
|
1884
|
+
const e = [];
|
|
1885
|
+
if (!ec.kinds.has(eff.kind)) {
|
|
1886
|
+
e.push(`${path}: forEachEntity references unknown entity kind "${eff.kind}"${didYouMean(eff.kind, [...ec.kinds])} (declared entities: ${[...ec.kinds].join(', ') || 'none — add multiplayer.entities'})`);
|
|
1887
|
+
}
|
|
1888
|
+
if (RESERVED_REF_NAMES.has(eff.as))
|
|
1889
|
+
e.push(`${path}.as: "${eff.as}" is a reserved name`);
|
|
1890
|
+
const inner = new Set([...bound, eff.as]);
|
|
1891
|
+
const innerEc = { ...ec, boundKinds: { ...ec.boundKinds, [eff.as]: { type: 'entity', kind: eff.kind } } };
|
|
1892
|
+
if (eff.where !== undefined) {
|
|
1893
|
+
e.push(...validateExpr(`${path}.where`, eff.where, inner, roomVars, playerVars, zoneIds, innerEc, 1).errors);
|
|
1894
|
+
if (exprHasReduction(eff.where))
|
|
1895
|
+
e.push(`${path}.where: aggregate/nearest* are forbidden inside forEachEntity`);
|
|
1896
|
+
}
|
|
1897
|
+
if (eff.then.length > exports.MULTIPLAYER_CAPS.ruleThen)
|
|
1898
|
+
e.push(`${path}.then has ${eff.then.length} effects — the cap is ${exports.MULTIPLAYER_CAPS.ruleThen}`);
|
|
1899
|
+
let running = inner;
|
|
1900
|
+
let runningEc = innerEc;
|
|
1901
|
+
eff.then.forEach((sub, j) => {
|
|
1902
|
+
if (sub.do === 'forEachPlayer' || sub.do === 'forEachEntity' || sub.do === 'forEachInList') {
|
|
1903
|
+
e.push(`${path}.then[${j}]: ${sub.do} can't nest inside forEachEntity (single level only)`);
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
e.push(...checkEffect(`${path}.then[${j}]`, sub, running, roomVars, playerVars, zoneIds, events, phases, runningEc));
|
|
1907
|
+
if (effectHasReduction(sub))
|
|
1908
|
+
e.push(`${path}.then[${j}]: aggregate/nearest* are forbidden inside forEachEntity`);
|
|
1909
|
+
if (sub.do === 'spawnEntity' && sub.bind === 'spawned') {
|
|
1910
|
+
running = new Set([...running, 'spawned']);
|
|
1911
|
+
runningEc = { ...runningEc, boundKinds: { ...runningEc.boundKinds, spawned: { type: 'entity', kind: sub.kind } } };
|
|
1912
|
+
}
|
|
1913
|
+
});
|
|
1914
|
+
return e;
|
|
1915
|
+
}
|
|
1916
|
+
// §5 (P3 ext) the static binding kind for a list element of type `elem` (the name a forEachInList / listCount /
|
|
1917
|
+
// listIndexOf scan binds). A ref element flows like the member it points at (player/entity); a record element
|
|
1918
|
+
// exposes its fields ({ref:as,var:field}); a scalar element is a bound value ({var:as}). undefined elem (the list
|
|
1919
|
+
// didn't resolve) → the coarse listElem so downstream errors are about the list, not a spurious binding mismatch.
|
|
1920
|
+
function elemBinding(elem) {
|
|
1921
|
+
if (elem === undefined)
|
|
1922
|
+
return { type: 'listElem' };
|
|
1923
|
+
if (typeof elem === 'string')
|
|
1924
|
+
return { type: 'listScalar', scalar: elem };
|
|
1925
|
+
if (elem.type === 'ref')
|
|
1926
|
+
return ofToKind(elem.of) ?? { type: 'listElem' };
|
|
1927
|
+
return { type: 'listRecord', fields: elem.fields };
|
|
1928
|
+
}
|
|
1929
|
+
// §5 (P3 ext) two lvalues address the same collection (structural equality — a dotted path or a {ref,var}). Used to
|
|
1930
|
+
// forbid structurally mutating the very list a forEachInList iterates (the indices/elements would shift mid-scan).
|
|
1931
|
+
function sameLvalue(a, b) {
|
|
1932
|
+
if (typeof a === 'string' || typeof b === 'string')
|
|
1933
|
+
return a === b;
|
|
1934
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
1935
|
+
}
|
|
1936
|
+
// §5 (P3 ext) does `sub` STRUCTURALLY mutate the list `list` (append/clear/removeAt/removeWhere on the same lvalue)?
|
|
1937
|
+
// A setField (record-field write, no length change) is allowed on the iterated list — it's the point of the loop.
|
|
1938
|
+
function mutatesIteratedList(sub, list) {
|
|
1939
|
+
if (sub.do === 'append' || sub.do === 'clear' || sub.do === 'removeAt' || sub.do === 'removeWhere')
|
|
1940
|
+
return sameLvalue(sub.target, list);
|
|
1941
|
+
return false;
|
|
1942
|
+
}
|
|
1943
|
+
// §5 (P3 Collections ext) validate forEachInList — the bounded loop over a list's ELEMENTS (the §13 sublanguage
|
|
1944
|
+
// completion). Like forEachEntity (single level, optional `where`, reductions forbidden inside) but iterates a
|
|
1945
|
+
// collection lvalue and binds `as` to the element (its kind from elemBinding — scalar value / member Ref / record).
|
|
1946
|
+
// Can't structurally mutate the list it iterates (use removeWhere); the per-tick budget charges maxLen × body.
|
|
1947
|
+
function checkForEachInList(path, eff, bound, roomVars, playerVars, zoneIds, events, phases, ec) {
|
|
1948
|
+
const e = [];
|
|
1949
|
+
const c = resolveCollectionLvalue(`${path}.list`, eff.list, bound, roomVars, playerVars, zoneIds, ec);
|
|
1950
|
+
if (c.errors.length)
|
|
1951
|
+
e.push(...c.errors);
|
|
1952
|
+
else if (c.vt.type !== 'list')
|
|
1953
|
+
e.push(`${path}.list: forEachInList needs a list var, but ${lvalueLabel(eff.list)} is "${c.vt.type}"`);
|
|
1954
|
+
if (RESERVED_REF_NAMES.has(eff.as))
|
|
1955
|
+
e.push(`${path}.as: "${eff.as}" is a reserved name`);
|
|
1956
|
+
const elem = c.vt?.type === 'list' ? c.vt.of : undefined;
|
|
1957
|
+
const inner = new Set([...bound, eff.as]);
|
|
1958
|
+
const innerEc = { ...ec, boundKinds: { ...ec.boundKinds, [eff.as]: elemBinding(elem) } };
|
|
1959
|
+
if (eff.where !== undefined) {
|
|
1960
|
+
e.push(...validateExpr(`${path}.where`, eff.where, inner, roomVars, playerVars, zoneIds, innerEc, 1).errors);
|
|
1961
|
+
if (exprHasReduction(eff.where))
|
|
1962
|
+
e.push(`${path}.where: aggregate/nearest* are forbidden inside forEachInList`);
|
|
1963
|
+
}
|
|
1964
|
+
if (eff.then.length > exports.MULTIPLAYER_CAPS.ruleThen)
|
|
1965
|
+
e.push(`${path}.then has ${eff.then.length} effects — the cap is ${exports.MULTIPLAYER_CAPS.ruleThen}`);
|
|
1966
|
+
let running = inner;
|
|
1967
|
+
let runningEc = innerEc;
|
|
1968
|
+
eff.then.forEach((sub, j) => {
|
|
1969
|
+
if (sub.do === 'forEachPlayer' || sub.do === 'forEachEntity' || sub.do === 'forEachInList') {
|
|
1970
|
+
e.push(`${path}.then[${j}]: ${sub.do} can't nest inside forEachInList (single level only)`);
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
if (mutatesIteratedList(sub, eff.list))
|
|
1974
|
+
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`);
|
|
1975
|
+
e.push(...checkEffect(`${path}.then[${j}]`, sub, running, roomVars, playerVars, zoneIds, events, phases, runningEc));
|
|
1976
|
+
if (effectHasReduction(sub))
|
|
1977
|
+
e.push(`${path}.then[${j}]: aggregate/nearest* are forbidden inside forEachInList`);
|
|
1978
|
+
if (sub.do === 'spawnEntity' && sub.bind === 'spawned') {
|
|
1979
|
+
running = new Set([...running, 'spawned']);
|
|
1980
|
+
runningEc = { ...runningEc, boundKinds: { ...runningEc.boundKinds, spawned: { type: 'entity', kind: sub.kind } } };
|
|
1981
|
+
}
|
|
1982
|
+
});
|
|
1983
|
+
return e;
|
|
1984
|
+
}
|
|
1985
|
+
// §5 (P3 Collections ext) validate removeWhere — drop ALL elements of a list matching `where` (a bounded scan that
|
|
1986
|
+
// binds `as` to the element, exactly like forEachInList's binding). No nested effects; the `where` is read-only and
|
|
1987
|
+
// can't contain a reduction (O(maxLen) scan already). Used to scan-and-remove (cull broken/dead elements).
|
|
1988
|
+
function checkRemoveWhere(path, eff, bound, roomVars, playerVars, zoneIds, ec) {
|
|
1989
|
+
const e = [];
|
|
1990
|
+
const c = resolveCollectionLvalue(`${path}.target`, eff.target, bound, roomVars, playerVars, zoneIds, ec);
|
|
1991
|
+
if (c.errors.length)
|
|
1992
|
+
e.push(...c.errors);
|
|
1993
|
+
else if (c.vt.type !== 'list')
|
|
1994
|
+
e.push(`${path}.target: "removeWhere" needs a list var, but ${lvalueLabel(eff.target)} is "${c.vt.type}"`);
|
|
1995
|
+
if (RESERVED_REF_NAMES.has(eff.as))
|
|
1996
|
+
e.push(`${path}.as: "${eff.as}" is a reserved name`);
|
|
1997
|
+
const elem = c.vt?.type === 'list' ? c.vt.of : undefined;
|
|
1998
|
+
const inner = new Set([...bound, eff.as]);
|
|
1999
|
+
const innerEc = { ...ec, boundKinds: { ...ec.boundKinds, [eff.as]: elemBinding(elem) } };
|
|
2000
|
+
e.push(...validateExpr(`${path}.where`, eff.where, inner, roomVars, playerVars, zoneIds, innerEc, 1).errors);
|
|
2001
|
+
if (exprHasReduction(eff.where))
|
|
2002
|
+
e.push(`${path}.where: aggregate/nearest* are forbidden inside removeWhere`);
|
|
2003
|
+
return e;
|
|
2004
|
+
}
|
|
2005
|
+
// Does an expression contain an aggregate / a ref-returning op (nearestPlayer / aggregate argmax|argmin)? The
|
|
2006
|
+
// reductions, which are O(members) scans and so forbidden inside forEachPlayer (would be O(members²)).
|
|
2007
|
+
function exprHasReduction(expr) {
|
|
2008
|
+
if (typeof expr !== 'object' || expr === null || 'vec3' in expr)
|
|
2009
|
+
return false;
|
|
2010
|
+
if ('ref' in expr)
|
|
2011
|
+
return refHasReduction(expr.ref);
|
|
2012
|
+
if ('var' in expr)
|
|
2013
|
+
return false;
|
|
2014
|
+
const node = expr;
|
|
2015
|
+
if (node.op === 'aggregate' || node.op === 'nearestPlayer')
|
|
2016
|
+
return true;
|
|
2017
|
+
if (node.op === 'and' || node.op === 'or')
|
|
2018
|
+
return node.of.some(exprHasReduction);
|
|
2019
|
+
if (node.op === 'not')
|
|
2020
|
+
return exprHasReduction(node.of);
|
|
2021
|
+
if (node.op === 'controlledBy') {
|
|
2022
|
+
const cb = node;
|
|
2023
|
+
return refHasReduction(cb.entity) || refHasReduction(cb.by);
|
|
2024
|
+
} // §9 (P3) O(1) itself — only its refs can reduce
|
|
2025
|
+
if (node.op === 'sameRef') {
|
|
2026
|
+
const sr = node;
|
|
2027
|
+
return refHasReduction(sr.a) || refHasReduction(sr.b);
|
|
2028
|
+
} // §7.3 (P3-B2) O(1) itself — only its refs can reduce
|
|
2029
|
+
if (node.op === 'hostLoad')
|
|
2030
|
+
return true; // §9 (P3) O(entities) scan — a reduction, forbidden inside forEach
|
|
2031
|
+
if (node.a !== undefined)
|
|
2032
|
+
return exprHasReduction(node.a) || exprHasReduction(node.b);
|
|
2033
|
+
return false;
|
|
2034
|
+
}
|
|
2035
|
+
function refHasReduction(ref) {
|
|
2036
|
+
if (typeof ref === 'string' || !('op' in ref))
|
|
2037
|
+
return false; // a bound name / {var} deref — O(1)
|
|
2038
|
+
if (ref.op === 'controllerOf')
|
|
2039
|
+
return refHasReduction(ref.entity); // §9 (P3) O(1) field read — only a reducing inner entity ref counts
|
|
2040
|
+
if (ref.op === 'listAt')
|
|
2041
|
+
return exprHasReduction(ref.index); // §5 (P3-B2) O(1) element read — only a reducing index expr counts
|
|
2042
|
+
return true; // nearestPlayer / nearestEntity / aggregate argmax|argmin / leastLoadedPlayer — O(members) scans
|
|
2043
|
+
}
|
|
2044
|
+
// Validate a `broadcast` effect (§7.4/§10.3): the event must be declared; `to` must be a valid target; the
|
|
2045
|
+
// payload must exactly match the event's declared fields (no unknown, none missing), each value a Ref for a
|
|
2046
|
+
// ref field or a type-matching Expr otherwise.
|
|
2047
|
+
function checkBroadcast(path, eff, bound, roomVars, playerVars, zoneIds, events, ec) {
|
|
2048
|
+
const decl = events[eff.event];
|
|
2049
|
+
if (!decl) {
|
|
2050
|
+
const names = Object.keys(events);
|
|
2051
|
+
return [`${path}.event: unknown event "${eff.event}"${didYouMean(eff.event, names)} (declared events: ${names.join(', ') || 'none'})`];
|
|
2052
|
+
}
|
|
2053
|
+
const e = checkBroadcastTarget(`${path}.to`, eff.to, bound, roomVars, playerVars, zoneIds, ec);
|
|
2054
|
+
const fields = decl.payload ?? {};
|
|
2055
|
+
const provided = eff.payload ?? {};
|
|
2056
|
+
for (const k of Object.keys(provided)) {
|
|
2057
|
+
if (!(k in fields))
|
|
2058
|
+
e.push(`${path}.payload: unknown field "${k}" (event "${eff.event}" declares: ${Object.keys(fields).join(', ') || 'none'})`);
|
|
2059
|
+
}
|
|
2060
|
+
for (const [field, ft] of Object.entries(fields)) {
|
|
2061
|
+
if (!(field in provided)) {
|
|
2062
|
+
e.push(`${path}.payload: missing field "${field}" (declared "${ft.type}")`);
|
|
2063
|
+
continue;
|
|
2064
|
+
}
|
|
2065
|
+
const val = provided[field];
|
|
2066
|
+
if (ft.type === 'ref') {
|
|
2067
|
+
e.push(...resolveRefSlot(`${path}.payload.${field}`, val, bound, roomVars, playerVars, zoneIds, ec).errors);
|
|
2068
|
+
}
|
|
2069
|
+
else if (ft.type === 'list' || ft.type === 'counterMap') {
|
|
2070
|
+
// §10.3 (P3-B6) a collection-snapshot field: the value must reference a collection var of the matching shape.
|
|
2071
|
+
e.push(...checkCollectionPayload(`${path}.payload.${field}`, ft, val, bound, roomVars, playerVars, ec));
|
|
2072
|
+
}
|
|
2073
|
+
else {
|
|
2074
|
+
const r = validateExpr(`${path}.payload.${field}`, val, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
2075
|
+
e.push(...r.errors);
|
|
2076
|
+
if (r.type !== undefined && r.type !== ft.type)
|
|
2077
|
+
e.push(`${path}.payload.${field}: a "${r.type}" value doesn't match the declared "${ft.type}"`);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
return e;
|
|
2081
|
+
}
|
|
2082
|
+
// §10.3 (P3-B6) validate a collection-snapshot payload field's value: a `{var:"room/self.<coll>"}` reference to a
|
|
2083
|
+
// declared collection var whose type + shape (element / keys) equals the field's. The room serializes that live
|
|
2084
|
+
// collection into the broadcast. (A literal collection value isn't expressible — a snapshot is always of a var.)
|
|
2085
|
+
function checkCollectionPayload(path, ft, val, bound, roomVars, playerVars, ec) {
|
|
2086
|
+
if (typeof val !== 'object' || val === null || !('var' in val) || typeof val.var !== 'string') {
|
|
2087
|
+
return [`${path}: a "${ft.type}" payload field must reference a collection var ({ "var": "room.<v>" | "self.<v>" }) to snapshot`];
|
|
2088
|
+
}
|
|
2089
|
+
const c = resolveCollectionVar(path, val.var, bound, roomVars, playerVars, ec);
|
|
2090
|
+
if (c.errors.length)
|
|
2091
|
+
return c.errors;
|
|
2092
|
+
if (c.vt.type !== ft.type)
|
|
2093
|
+
return [`${path}: field is a "${ft.type}" but "${val.var}" is a "${c.vt.type}"`];
|
|
2094
|
+
if (ft.type === 'list' && c.vt.type === 'list' && JSON.stringify(c.vt.of) !== JSON.stringify(ft.of)) {
|
|
2095
|
+
return [`${path}: the referenced list's element type doesn't match the declared payload field element`];
|
|
2096
|
+
}
|
|
2097
|
+
if (ft.type === 'counterMap' && c.vt.type === 'counterMap' && JSON.stringify([...c.vt.keys].sort()) !== JSON.stringify([...ft.keys].sort())) {
|
|
2098
|
+
return [`${path}: the referenced counterMap's keys don't match the declared payload field keys`];
|
|
2099
|
+
}
|
|
2100
|
+
return [];
|
|
2101
|
+
}
|
|
2102
|
+
// Validate a broadcast `to` target: "all", a Ref (bound name / ref-var / ref-op), or {team:"<playerVar>=<val>"}.
|
|
2103
|
+
function checkBroadcastTarget(path, to, bound, roomVars, playerVars, zoneIds, ec) {
|
|
2104
|
+
if (to === 'all')
|
|
2105
|
+
return [];
|
|
2106
|
+
if (typeof to === 'string' || 'var' in to || 'op' in to)
|
|
2107
|
+
return resolveRefSlot(path, to, bound, roomVars, playerVars, zoneIds, ec).errors;
|
|
2108
|
+
// { team: "<playerVar>=<value>" }
|
|
2109
|
+
const eq = to.team.indexOf('=');
|
|
2110
|
+
if (eq < 1)
|
|
2111
|
+
return [`${path}.team: must be "<playerVar>=<value>", got "${to.team}"`];
|
|
2112
|
+
const v = to.team.slice(0, eq);
|
|
2113
|
+
if (!playerVars[v])
|
|
2114
|
+
return [`${path}.team: unknown player var "${v}"${didYouMean(v, Object.keys(playerVars))} (declared player vars: ${Object.keys(playerVars).join(', ') || 'none'})`];
|
|
2115
|
+
return [];
|
|
2116
|
+
}
|
|
2117
|
+
function effectHasReduction(eff) {
|
|
2118
|
+
switch (eff.do) {
|
|
2119
|
+
case 'add':
|
|
2120
|
+
return (typeof eff.target !== 'string' && refHasReduction(eff.target.ref)) || exprHasReduction(eff.by);
|
|
2121
|
+
case 'set':
|
|
2122
|
+
return (typeof eff.target !== 'string' && refHasReduction(eff.target.ref)) || exprHasReduction(eff.to);
|
|
2123
|
+
case 'setRef':
|
|
2124
|
+
return (typeof eff.target !== 'string' && refHasReduction(eff.target.ref)) || (eff.to !== null && refHasReduction(eff.to));
|
|
2125
|
+
case 'teleport':
|
|
2126
|
+
case 'respawn':
|
|
2127
|
+
return refHasReduction(eff.player) || exprHasReduction(eff.to);
|
|
2128
|
+
case 'broadcast':
|
|
2129
|
+
return (typeof eff.to !== 'string' && 'op' in eff.to && refHasReduction(eff.to)) || Object.values(eff.payload ?? {}).some((v) => exprHasReduction(v));
|
|
2130
|
+
case 'spawnEntity':
|
|
2131
|
+
return exprHasReduction(eff.at) || Object.values(eff.vars ?? {}).some((v) => exprHasReduction(v));
|
|
2132
|
+
case 'destroyEntity':
|
|
2133
|
+
return refHasReduction(eff.entity);
|
|
2134
|
+
case 'eliminate':
|
|
2135
|
+
case 'revive':
|
|
2136
|
+
return refHasReduction(eff.player); // §7.4 (P3-B2) only a reducing player ref counts
|
|
2137
|
+
default:
|
|
2138
|
+
return false;
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
// The reserved names an event binds, resolvable as `self`/refs in the rule (spec §7.1). Phase-2.2: `tick` is
|
|
2142
|
+
// room-level and binds nothing; player-bound events (playerJoin/…) add `self` in a later slice. `room`/
|
|
2143
|
+
// `room.*` are always readable (built-ins, not bindings).
|
|
2144
|
+
function boundNames(event, timers, entityZoneKinds) {
|
|
2145
|
+
switch (event.on) {
|
|
2146
|
+
case 'playerJoin':
|
|
2147
|
+
case 'playerLeave':
|
|
2148
|
+
case 'playerDisconnect':
|
|
2149
|
+
case 'playerReconnect':
|
|
2150
|
+
return new Set(['self']); // the joining/leaving/dropping/resuming player
|
|
2151
|
+
case 'zoneEnter':
|
|
2152
|
+
case 'zoneExit':
|
|
2153
|
+
case 'zoneInside':
|
|
2154
|
+
// §6 the entering member is `self`; §9 an entity-ATTACHED zone (the name is an entity kind, not a static
|
|
2155
|
+
// zone id) ALSO binds `source` = the carrying entity.
|
|
2156
|
+
return entityZoneKinds.has(event.zone) ? new Set(['self', 'source']) : new Set(['self']);
|
|
2157
|
+
case 'playerContact':
|
|
2158
|
+
return new Set(['self', 'other']); // the two players in contact (tag/infection)
|
|
2159
|
+
case 'action':
|
|
2160
|
+
return new Set(['self']); // the player who sent the action
|
|
2161
|
+
case 'varReached':
|
|
2162
|
+
return event.scope === 'room' ? new Set() : new Set(['self']); // self/entity scope binds the watched instance (player/entity); room binds nothing
|
|
2163
|
+
case 'stateEnter':
|
|
2164
|
+
case 'stateExit':
|
|
2165
|
+
return new Set(); // §8 room-scoped phase machine — no member bound
|
|
2166
|
+
case 'timerElapsed':
|
|
2167
|
+
return timers[event.timer]?.keyed ? new Set(['self']) : new Set(); // §8/§9 a keyed timer binds the instance (player or entity); room-scoped binds nothing
|
|
2168
|
+
case 'entitySpawn':
|
|
2169
|
+
case 'entityDestroy':
|
|
2170
|
+
return new Set(['self']); // §9 the spawned/destroyed entity is `self`
|
|
2171
|
+
case 'ownershipChanged':
|
|
2172
|
+
return new Set(['self', 'source']); // §9 (4.5.12) self = the new owner (player), source = the entity
|
|
2173
|
+
case 'tick':
|
|
2174
|
+
default:
|
|
2175
|
+
return new Set(); // room-level — no member bound
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
// Player built-in reads (§7.1) addressable as `self.<name>` or `{ref,var}` — NOT declared vars, can't be
|
|
2179
|
+
// shadowed. Phase-2.4a exposes `position` (vec3); Phase-3 presence adds `connected` (boolean, read-only).
|
|
2180
|
+
const PLAYER_BUILTIN_TYPES = { position: 'vec3', connected: 'boolean', active: 'boolean' };
|
|
2181
|
+
// `connected`/`active` are PLAYER-only presence/participation built-ins — an ENTITY member's same-named DECLARED var
|
|
2182
|
+
// wins (entities have no connection/participation). `position` is universal (players + entities both have a transform).
|
|
2183
|
+
const PLAYER_ONLY_BUILTINS = new Set(['connected', 'active']);
|
|
2184
|
+
const playerBuiltin = (name, isEntity) => name in PLAYER_BUILTIN_TYPES && !(isEntity && PLAYER_ONLY_BUILTINS.has(name));
|
|
2185
|
+
// Room built-in reads addressable as `room.<name>` — NOT declared roomVars. Phase-3 (§8) exposes `phase` (the
|
|
2186
|
+
// current state-machine phase, a string); it is read-only here (change it via transitionTo, not `set`).
|
|
2187
|
+
const ROOM_BUILTIN_TYPES = { phase: 'string' };
|
|
2188
|
+
const EMPTY_ENTITY_CTX = { kinds: new Set(), varTypes: {}, kindVars: {}, kindPolicy: {}, zoneSelfKinds: new Map() };
|
|
2189
|
+
function buildEntityCtx(m) {
|
|
2190
|
+
const entities = m.multiplayer?.entities ?? {};
|
|
2191
|
+
const varTypes = {};
|
|
2192
|
+
const kindVars = {};
|
|
2193
|
+
const kindPolicy = {};
|
|
2194
|
+
for (const [kind, ent] of Object.entries(entities)) {
|
|
2195
|
+
kindVars[kind] = ent.vars ?? {};
|
|
2196
|
+
kindPolicy[kind] = ent.transferPolicy ?? 'fixed';
|
|
2197
|
+
for (const [name, vt] of Object.entries(ent.vars ?? {}))
|
|
2198
|
+
varTypes[name] = vt.type;
|
|
2199
|
+
}
|
|
2200
|
+
return { kinds: new Set(Object.keys(entities)), varTypes, kindVars, kindPolicy, zoneSelfKinds: buildZoneSelfKinds(m) };
|
|
2201
|
+
}
|
|
2202
|
+
// The member kind an aggregate scope reduces over: `entities:<kind>` → that kind; a `zone:<id>` tracking
|
|
2203
|
+
// 'entity:<kind>' (4.5.8) → that kind; `players` or a player-tracking zone → player.
|
|
2204
|
+
function aggregateMemberKind(scope, ec) {
|
|
2205
|
+
if (scope.startsWith('entities:'))
|
|
2206
|
+
return { type: 'entity', kind: scope.slice('entities:'.length) };
|
|
2207
|
+
if (scope.startsWith('zone:')) {
|
|
2208
|
+
const tracked = ec.zoneSelfKinds.get(scope.slice('zone:'.length));
|
|
2209
|
+
if (tracked)
|
|
2210
|
+
return { type: 'entity', kind: tracked };
|
|
2211
|
+
}
|
|
2212
|
+
return { type: 'player' };
|
|
2213
|
+
}
|
|
2214
|
+
// Recursively validate a condition/value expression (§7.3): depth cap + var/ref resolution + value-type
|
|
2215
|
+
// inference; returns errors + node count (the caller checks the total against exprNodes) + the inferred type.
|
|
2216
|
+
// `bound` = the reserved refs the event provides (§7.1), needed to resolve `self.*` and `{ref,var}` reads.
|
|
2217
|
+
function validateExpr(path, expr, bound, roomVars, playerVars, zoneIds, ec, depth) {
|
|
2218
|
+
if (depth > exports.MULTIPLAYER_CAPS.exprDepth) {
|
|
2219
|
+
return { errors: [`${path}: expression nesting exceeds the depth cap of ${exports.MULTIPLAYER_CAPS.exprDepth}`], nodes: 1 };
|
|
2220
|
+
}
|
|
2221
|
+
if (typeof expr === 'number')
|
|
2222
|
+
return { errors: [], nodes: 1, type: 'number' };
|
|
2223
|
+
if (typeof expr === 'string')
|
|
2224
|
+
return { errors: [], nodes: 1, type: 'string' };
|
|
2225
|
+
if (typeof expr === 'boolean')
|
|
2226
|
+
return { errors: [], nodes: 1, type: 'boolean' };
|
|
2227
|
+
if ('vec3' in expr)
|
|
2228
|
+
return { errors: [], nodes: 1, type: 'vec3' };
|
|
2229
|
+
if ('ref' in expr)
|
|
2230
|
+
return { ...resolveRefRead(path, expr.ref, expr.var, bound, roomVars, playerVars, zoneIds, ec), nodes: 1 };
|
|
2231
|
+
if ('var' in expr)
|
|
2232
|
+
return { ...resolveVarRead(path, expr.var, bound, roomVars, playerVars, ec), nodes: 1 };
|
|
2233
|
+
// op node — the schema already validated its shape, so read children via a generic view (TS can't reliably
|
|
2234
|
+
// narrow this recursive union past the `op` discriminant). Gather labelled child sub-expressions:
|
|
2235
|
+
const node = expr;
|
|
2236
|
+
if (node.op === 'playerCount' || node.op === 'random' || node.op === 'timeInState' || node.op === 'timerRemaining' || node.op === 'now')
|
|
2237
|
+
return { errors: [], nodes: 1, type: 'number' }; // leaf ops (timer-name refs resolved in checkTimerRefs)
|
|
2238
|
+
if (node.op === 'randomPoint')
|
|
2239
|
+
return { errors: [], nodes: 1, type: 'vec3' }; // leaf → a random vec3 in the [min,max] box (shape schema-validated)
|
|
2240
|
+
if (node.op === 'aggregate')
|
|
2241
|
+
return { errors: validateAggregate(path, node, bound, roomVars, playerVars, zoneIds, ec), nodes: 1, type: 'number' };
|
|
2242
|
+
if (node.op === 'listLength' || node.op === 'listAt' || node.op === 'count' || node.op === 'listCount' || node.op === 'listIndexOf')
|
|
2243
|
+
return validateCollectionOp(path, expr, bound, roomVars, playerVars, zoneIds, ec, depth); // §5 (4.5.13 + P3 ext) collection reads
|
|
2244
|
+
if (node.op === 'controlledBy') {
|
|
2245
|
+
// §9 (P3 Ownership/A3) boolean: is `entity` controlled by the player `by` resolves to? Both are ref slots.
|
|
2246
|
+
const cb = expr;
|
|
2247
|
+
const e = [
|
|
2248
|
+
...resolveRefSlot(`${path}.entity`, cb.entity, bound, roomVars, playerVars, zoneIds, ec).errors,
|
|
2249
|
+
...resolveRefSlot(`${path}.by`, cb.by, bound, roomVars, playerVars, zoneIds, ec).errors,
|
|
2250
|
+
];
|
|
2251
|
+
return { errors: e, nodes: 1, type: 'boolean' };
|
|
2252
|
+
}
|
|
2253
|
+
if (node.op === 'sameRef') {
|
|
2254
|
+
// §7.3 (P3-B2) boolean: do two Refs resolve to the same live member? Both are ref slots (O(1) identity test).
|
|
2255
|
+
const sr = expr;
|
|
2256
|
+
const e = [
|
|
2257
|
+
...resolveRefSlot(`${path}.a`, sr.a, bound, roomVars, playerVars, zoneIds, ec).errors,
|
|
2258
|
+
...resolveRefSlot(`${path}.b`, sr.b, bound, roomVars, playerVars, zoneIds, ec).errors,
|
|
2259
|
+
];
|
|
2260
|
+
return { errors: e, nodes: 1, type: 'boolean' };
|
|
2261
|
+
}
|
|
2262
|
+
if (node.op === 'hostLoad') {
|
|
2263
|
+
// §9 (P3 Ownership/B8) the count of live entities the player `of` controls → number. O(entities) scan.
|
|
2264
|
+
const hl = expr;
|
|
2265
|
+
return { errors: resolveRefSlot(`${path}.of`, hl.of, bound, roomVars, playerVars, zoneIds, ec).errors, nodes: 1, type: 'number' };
|
|
2266
|
+
}
|
|
2267
|
+
let children;
|
|
2268
|
+
if (node.op === 'not')
|
|
2269
|
+
children = [[`${path}.of`, node.of]];
|
|
2270
|
+
else if (node.op === 'and' || node.op === 'or')
|
|
2271
|
+
children = node.of.map((s, i) => [`${path}.of[${i}]`, s]);
|
|
2272
|
+
else
|
|
2273
|
+
children = [[`${path}.a`, node.a], [`${path}.b`, node.b]]; // distance / binary cmp / arith
|
|
2274
|
+
const e = [];
|
|
2275
|
+
let nodes = 1;
|
|
2276
|
+
const childTypes = [];
|
|
2277
|
+
for (const [childPath, sub] of children) {
|
|
2278
|
+
const r = validateExpr(childPath, sub, bound, roomVars, playerVars, zoneIds, ec, depth + 1);
|
|
2279
|
+
e.push(...r.errors);
|
|
2280
|
+
nodes += r.nodes;
|
|
2281
|
+
childTypes.push(r.type);
|
|
2282
|
+
}
|
|
2283
|
+
// distance: both operands must be vec3 → number. cmp/logic → boolean. arithmetic → number.
|
|
2284
|
+
if (node.op === 'distance') {
|
|
2285
|
+
childTypes.forEach((t, idx) => {
|
|
2286
|
+
if (t !== undefined && t !== 'vec3')
|
|
2287
|
+
e.push(`${path}.${idx === 0 ? 'a' : 'b'}: "distance" needs a vec3 operand, got "${t}"`);
|
|
2288
|
+
});
|
|
2289
|
+
return { errors: e, nodes, type: 'number' };
|
|
2290
|
+
}
|
|
2291
|
+
// (P1.A2) operand-type checks the runtime can't recover from. Ordering/arithmetic coerce via Number(), so a
|
|
2292
|
+
// non-number operand (vec3 / string / boolean / ref) is NaN → always-false / NaN-poisoned. Equality is strict
|
|
2293
|
+
// (===), so a vec3 compares by reference (always false) and a type mismatch is always false. Refs ARE comparable
|
|
2294
|
+
// (they hold a member-key string). Only flag statically-known types — undefined = a dynamic read, left to runtime.
|
|
2295
|
+
const ordering = node.op === '<' || node.op === '<=' || node.op === '>' || node.op === '>=';
|
|
2296
|
+
const arithmetic = node.op === '+' || node.op === '-' || node.op === '*' || node.op === '/';
|
|
2297
|
+
if (ordering || arithmetic) {
|
|
2298
|
+
childTypes.forEach((t, idx) => {
|
|
2299
|
+
if (t !== undefined && t !== 'number')
|
|
2300
|
+
e.push(`${path}.${idx === 0 ? 'a' : 'b'}: "${node.op}" needs a number operand, got "${t}"`);
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
else if (node.op === '==' || node.op === '!=') {
|
|
2304
|
+
const [ta, tb] = childTypes;
|
|
2305
|
+
if (ta === 'vec3' || tb === 'vec3')
|
|
2306
|
+
e.push(`${path}: "${node.op}" can't compare vec3 operands (use "distance" for proximity)`);
|
|
2307
|
+
else if (ta !== undefined && tb !== undefined && ta !== tb)
|
|
2308
|
+
e.push(`${path}: "${node.op}" compares mismatched types "${ta}" and "${tb}" — always ${node.op === '==' ? 'false' : 'true'}`);
|
|
2309
|
+
}
|
|
2310
|
+
const logical = node.op === 'and' || node.op === 'or' || node.op === 'not';
|
|
2311
|
+
const comparison = node.op === '==' || node.op === '!=' || ordering;
|
|
2312
|
+
return { errors: e, nodes, type: logical || comparison ? 'boolean' : 'number' };
|
|
2313
|
+
}
|
|
2314
|
+
// Validate an `aggregate` reduction (§7.3, Phase-2.4b): a scalar (count/sum/min/max/avg → number) over
|
|
2315
|
+
// `players` or a declared zone's members. count takes no field; the other aggs need a number `field` (the
|
|
2316
|
+
// iterated player's var). scope is shape-checked by the schema; here we resolve the zone id + the field.
|
|
2317
|
+
function validateAggregate(path, node, bound, roomVars, playerVars, zoneIds, ec) {
|
|
2318
|
+
const e = [];
|
|
2319
|
+
const scope = node.scope ?? '';
|
|
2320
|
+
const entityScope = scope.startsWith('entities:'); // §9 reduce over entities of a kind (Phase 4.3)
|
|
2321
|
+
const memberKind = aggregateMemberKind(scope, ec); // (4.5.8) a zone tracking 'entity:<kind>' reduces over entities, not players
|
|
2322
|
+
const entityDomain = memberKind.type === 'entity';
|
|
2323
|
+
if (scope.startsWith('zone:')) {
|
|
2324
|
+
const id = scope.slice('zone:'.length);
|
|
2325
|
+
if (!zoneIds.has(id)) {
|
|
2326
|
+
const declared = [...zoneIds];
|
|
2327
|
+
e.push(`${path}: aggregate scope references unknown zone "${id}"${didYouMean(id, declared)} (declared zones: ${declared.join(', ') || 'none'})`);
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
else if (entityScope) {
|
|
2331
|
+
const kind = scope.slice('entities:'.length);
|
|
2332
|
+
if (!ec.kinds.has(kind)) {
|
|
2333
|
+
const declared = [...ec.kinds];
|
|
2334
|
+
e.push(`${path}: aggregate scope references unknown entity kind "${kind}"${didYouMean(kind, declared)} (declared entities: ${declared.join(', ') || 'none'})`);
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
// §7.3 (4.5) optional `where` filter — needs `as` (a name for each candidate member); validate `where` with `as`
|
|
2338
|
+
// bound to the scope's member kind (players/zone → player; entities:<kind> → that entity). Applies to every agg
|
|
2339
|
+
// (count included). A reduction inside would be O(members²) — forbidden, like a forEach body.
|
|
2340
|
+
if (node.where !== undefined || node.as !== undefined) {
|
|
2341
|
+
if (node.as === undefined)
|
|
2342
|
+
e.push(`${path}: aggregate "where" requires "as" (a name for each candidate member)`);
|
|
2343
|
+
else if (RESERVED_REF_NAMES.has(node.as))
|
|
2344
|
+
e.push(`${path}.as: "${node.as}" is a reserved name`);
|
|
2345
|
+
else if (node.where === undefined)
|
|
2346
|
+
e.push(`${path}: aggregate "as" requires a "where" filter`);
|
|
2347
|
+
else {
|
|
2348
|
+
const inner = new Set([...bound, node.as]);
|
|
2349
|
+
const innerEc = { ...ec, boundKinds: { ...ec.boundKinds, [node.as]: memberKind } };
|
|
2350
|
+
e.push(...validateExpr(`${path}.where`, node.where, inner, roomVars, playerVars, zoneIds, innerEc, 1).errors);
|
|
2351
|
+
if (exprHasReduction(node.where))
|
|
2352
|
+
e.push(`${path}.where: aggregate/nearest* are forbidden inside an aggregate "where"`);
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
if (node.agg === 'count') {
|
|
2356
|
+
if (node.field !== undefined)
|
|
2357
|
+
e.push(`${path}: aggregate "count" takes no field`);
|
|
2358
|
+
return e;
|
|
2359
|
+
}
|
|
2360
|
+
if (node.field === undefined) {
|
|
2361
|
+
e.push(`${path}: aggregate "${node.agg}" needs a "field" (a number ${entityDomain ? 'entity' : 'player'} var)`);
|
|
2362
|
+
return e;
|
|
2363
|
+
}
|
|
2364
|
+
// The field is a number var of the reduced collection. (P3-B4) For an entity domain (entities:<kind> or a zone
|
|
2365
|
+
// tracking 'entity:<kind>') resolve `field` against the SPECIFIC tracked kind's vars, not the entity-var union — a
|
|
2366
|
+
// field absent on that kind is a publish error, not a silent runtime 0. Player domain → player vars.
|
|
2367
|
+
const kindVars = entityDomain && memberKind.kind !== undefined ? (ec.kindVars[memberKind.kind] ?? {}) : undefined;
|
|
2368
|
+
const fieldType = entityDomain ? (kindVars ? kindVars[node.field]?.type : ec.varTypes[node.field]) : playerVars[node.field]?.type;
|
|
2369
|
+
if (fieldType === undefined) {
|
|
2370
|
+
const declared = entityDomain ? Object.keys(kindVars ?? ec.varTypes) : Object.keys(playerVars);
|
|
2371
|
+
const domainLabel = entityDomain ? (memberKind.kind !== undefined ? `entity kind "${memberKind.kind}"` : 'entity') : 'player';
|
|
2372
|
+
e.push(`${path}: aggregate field — unknown ${domainLabel} var "${node.field}"${didYouMean(node.field, declared)} (declared: ${declared.join(', ') || 'none'})`);
|
|
2373
|
+
}
|
|
2374
|
+
else if (fieldType !== 'number') {
|
|
2375
|
+
e.push(`${path}: aggregate "${node.agg}" needs a number field, but "${node.field}" is "${fieldType}"`);
|
|
2376
|
+
}
|
|
2377
|
+
return e;
|
|
2378
|
+
}
|
|
2379
|
+
// Resolve a `{var:"scope.name"}` read to a declared var or a `self.<built-in>`, returning its inferred type.
|
|
2380
|
+
// `self` requires the event to bind a player; did-you-mean on an unknown var.
|
|
2381
|
+
function resolveVarRead(path, dotted, bound, roomVars, playerVars, ec) {
|
|
2382
|
+
const dot = dotted.indexOf('.');
|
|
2383
|
+
const scope = dot >= 0 ? dotted.slice(0, dot) : '';
|
|
2384
|
+
const name = dot >= 0 ? dotted.slice(dot + 1) : '';
|
|
2385
|
+
// §5 (P3 ext) a bare bound name (no scope) → a list element bound by forEachInList / listCount / listIndexOf. A
|
|
2386
|
+
// SCALAR element reads as its value; a record/ref element points the author at its proper read form.
|
|
2387
|
+
if (dot < 0 && bound.has(dotted)) {
|
|
2388
|
+
const k = ec.boundKinds?.[dotted];
|
|
2389
|
+
if (k?.type === 'listScalar')
|
|
2390
|
+
return { errors: [], type: k.scalar };
|
|
2391
|
+
if (k?.type === 'listRecord')
|
|
2392
|
+
return { errors: [`${path}: "${dotted}" is a record list element — read a field via {ref:"${dotted}",var:"<field>"}, not {var}`] };
|
|
2393
|
+
if (k?.type === 'player' || k?.type === 'entity')
|
|
2394
|
+
return { errors: [`${path}: "${dotted}" is a ref element — use it as a ref ({ref:"${dotted}",var:…}), not {var}`] };
|
|
2395
|
+
}
|
|
2396
|
+
if (scope === 'action') {
|
|
2397
|
+
// §11.5 (4.7) action.args.<arg> — readable only inside an {on:action} rule (ec.actionArgs set). The type is
|
|
2398
|
+
// the matched action's declared arg type; a ref arg returns 'ref' (resolveRefSlot then accepts it as a Ref).
|
|
2399
|
+
if (!name.startsWith('args.'))
|
|
2400
|
+
return { errors: [`${path}: "${dotted}" — only "action.args.<arg>" is readable from the action scope`] };
|
|
2401
|
+
const arg = name.slice('args.'.length);
|
|
2402
|
+
if (!ec.actionArgs)
|
|
2403
|
+
return { errors: [`${path}: "action.args.${arg}" is only readable in an {on:"action"} rule`] };
|
|
2404
|
+
const at = ec.actionArgs[arg];
|
|
2405
|
+
if (!at)
|
|
2406
|
+
return { errors: [`${path}: unknown action arg "${arg}"${didYouMean(arg, Object.keys(ec.actionArgs))} (declared args: ${Object.keys(ec.actionArgs).join(', ') || 'none'})`] };
|
|
2407
|
+
return { errors: [], type: at.type };
|
|
2408
|
+
}
|
|
2409
|
+
if (scope !== 'room' && scope !== 'self')
|
|
2410
|
+
return { errors: [`${path}: var "${dotted}" must be "room.<var>", "self.<var>", or "action.args.<arg>"`] };
|
|
2411
|
+
if (scope === 'self' && !bound.has('self'))
|
|
2412
|
+
return { errors: [`${path}: "self.${name}" — this rule's event binds no member (no "self" here)`] };
|
|
2413
|
+
if (scope === 'self' && playerBuiltin(name, ec.boundKinds?.self?.type === 'entity'))
|
|
2414
|
+
return { errors: [], type: PLAYER_BUILTIN_TYPES[name] };
|
|
2415
|
+
if (scope === 'room' && name in ROOM_BUILTIN_TYPES)
|
|
2416
|
+
return { errors: [], type: ROOM_BUILTIN_TYPES[name] };
|
|
2417
|
+
const decls = scope === 'room' ? roomVars : playerVars;
|
|
2418
|
+
const builtins = scope === 'room' ? ROOM_BUILTIN_TYPES : PLAYER_BUILTIN_TYPES;
|
|
2419
|
+
const vt = decls[name];
|
|
2420
|
+
if (!vt) {
|
|
2421
|
+
// §9 entity self: when self is an entity, `self.<var>` reads an entity var. Phase 4.5 (row 16): if self's kind
|
|
2422
|
+
// is statically known, resolve against THAT kind's decls (a wrong-kind var now errors); else fall back to the union.
|
|
2423
|
+
if (scope === 'self') {
|
|
2424
|
+
const sk = ec.boundKinds?.self;
|
|
2425
|
+
if (sk?.type === 'entity') {
|
|
2426
|
+
const kv = ec.kindVars[sk.kind]?.[name];
|
|
2427
|
+
if (kv)
|
|
2428
|
+
return { errors: [], type: kv.type };
|
|
2429
|
+
const d2 = Object.keys(ec.kindVars[sk.kind] ?? {});
|
|
2430
|
+
return { errors: [`${path}: unknown var "${name}" for entity kind "${sk.kind}"${didYouMean(name, d2)} (declared: ${d2.join(', ') || 'none'})`] };
|
|
2431
|
+
}
|
|
2432
|
+
if (name in ec.varTypes)
|
|
2433
|
+
return { errors: [], type: ec.varTypes[name] };
|
|
2434
|
+
}
|
|
2435
|
+
const declared = [...Object.keys(decls), ...Object.keys(builtins)];
|
|
2436
|
+
return { errors: [`${path}: unknown ${scope} var "${name}"${didYouMean(name, declared)} (declared ${scope} vars: ${declared.join(', ') || 'none'})`] };
|
|
2437
|
+
}
|
|
2438
|
+
if (vt.type === 'list' || vt.type === 'counterMap')
|
|
2439
|
+
return { errors: [collectionReadError(path, dotted, vt.type)] }; // §5 (4.5.13) not a scalar value
|
|
2440
|
+
return { errors: [], type: vt.type };
|
|
2441
|
+
}
|
|
2442
|
+
// §5 (4.5.13) the error when a collection var is read/written as a scalar — direct the author to the right node.
|
|
2443
|
+
function collectionReadError(path, dotted, t) {
|
|
2444
|
+
const how = t === 'list' ? 'read it via listLength/listAt, mutate via append/clear' : 'read it via count, mutate via addCount/clear';
|
|
2445
|
+
return `${path}: "${dotted}" is a ${t} collection — it can't be used as a scalar value (${how})`;
|
|
2446
|
+
}
|
|
2447
|
+
// §5 (4.5.13 + P3) resolve a "room.<var>"/"self.<var>" path to its declared COLLECTION var (list/counterMap).
|
|
2448
|
+
// `self` resolves against playerVars, OR — when the event binds self to an entity (P3 entity collections) — against
|
|
2449
|
+
// that entity kind's vars. Returns the declared HelixVarType (caller checks list vs counterMap) or an error.
|
|
2450
|
+
function resolveCollectionVar(path, dotted, bound, roomVars, playerVars, ec) {
|
|
2451
|
+
const dot = dotted.indexOf('.');
|
|
2452
|
+
const scope = dot >= 0 ? dotted.slice(0, dot) : '';
|
|
2453
|
+
const name = dot >= 0 ? dotted.slice(dot + 1) : '';
|
|
2454
|
+
if (scope !== 'room' && scope !== 'self')
|
|
2455
|
+
return { errors: [`${path}: "${dotted}" must be a "room.<var>" or "self.<var>" collection`] };
|
|
2456
|
+
if (scope === 'self' && !bound.has('self'))
|
|
2457
|
+
return { errors: [`${path}: "self.${name}" — this rule's event binds no member (no "self" here)`] };
|
|
2458
|
+
if (scope === 'self' && ec.boundKinds?.self?.type === 'entity') {
|
|
2459
|
+
const kind = ec.boundKinds.self.kind;
|
|
2460
|
+
const vt = ec.kindVars[kind]?.[name];
|
|
2461
|
+
if (!vt)
|
|
2462
|
+
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'})`] };
|
|
2463
|
+
return { errors: [], vt };
|
|
2464
|
+
}
|
|
2465
|
+
const decls = scope === 'room' ? roomVars : playerVars;
|
|
2466
|
+
const vt = decls[name];
|
|
2467
|
+
if (!vt)
|
|
2468
|
+
return { errors: [`${path}: unknown ${scope} collection var "${name}"${didYouMean(name, Object.keys(decls))} (declared ${scope} vars: ${Object.keys(decls).join(', ') || 'none'})`] };
|
|
2469
|
+
return { errors: [], vt };
|
|
2470
|
+
}
|
|
2471
|
+
// §5 (P3) resolve a collection LVALUE — a "room/self.<var>" path OR a ref-addressed {ref,var} (push onto an
|
|
2472
|
+
// iterated/other member's or an entity's collection). The {ref,var} ref resolves to a player (→ playerVars) or a
|
|
2473
|
+
// known entity kind (→ that kind's vars). Returns the declared collection VarType, or an error.
|
|
2474
|
+
function resolveCollectionLvalue(path, target, bound, roomVars, playerVars, zoneIds, ec) {
|
|
2475
|
+
if (typeof target === 'string')
|
|
2476
|
+
return resolveCollectionVar(path, target, bound, roomVars, playerVars, ec);
|
|
2477
|
+
const r = resolveRefSlot(`${path}.ref`, target.ref, bound, roomVars, playerVars, zoneIds, ec);
|
|
2478
|
+
if (r.errors.length)
|
|
2479
|
+
return { errors: r.errors };
|
|
2480
|
+
const name = target.var;
|
|
2481
|
+
if (r.kind?.type === 'entity') {
|
|
2482
|
+
const vt = ec.kindVars[r.kind.kind]?.[name];
|
|
2483
|
+
if (!vt)
|
|
2484
|
+
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'})`] };
|
|
2485
|
+
return { errors: [], vt };
|
|
2486
|
+
}
|
|
2487
|
+
const vt = playerVars[name]; // a player ref (or an untracked-kind ref) addresses a player collection
|
|
2488
|
+
if (!vt)
|
|
2489
|
+
return { errors: [`${path}.var: unknown player collection var "${name}"${didYouMean(name, Object.keys(playerVars))} (declared player vars: ${Object.keys(playerVars).join(', ') || 'none'})`] };
|
|
2490
|
+
return { errors: [], vt };
|
|
2491
|
+
}
|
|
2492
|
+
// §5 (4.5.13) validate a collection-read op (listLength/listAt/count): resolve its var, check the var's kind
|
|
2493
|
+
// matches the op, and (listAt) validate the index expr. Returns the op's value type (number, or the list's
|
|
2494
|
+
// element type for listAt). Mirrors validateExpr's contract (errors + node count + inferred type).
|
|
2495
|
+
function validateCollectionOp(path, expr, bound, roomVars, playerVars, zoneIds, ec, depth) {
|
|
2496
|
+
const n = expr;
|
|
2497
|
+
// §5 (P3 ext) listCount / listIndexOf — a bounded element scan that binds `as` to the element + counts / finds the
|
|
2498
|
+
// first index matching `where` (→ number). The list is any collection lvalue; the where reads the bound element
|
|
2499
|
+
// (scalar via {var:as}, record field via {ref:as,var:field}, ref element as a Ref) and can't itself reduce.
|
|
2500
|
+
if (n.op === 'listCount' || n.op === 'listIndexOf') {
|
|
2501
|
+
const sc = expr;
|
|
2502
|
+
const c = resolveCollectionLvalue(`${path}.list`, sc.list, bound, roomVars, playerVars, zoneIds, ec);
|
|
2503
|
+
if (c.errors.length)
|
|
2504
|
+
return { errors: c.errors, nodes: 1 };
|
|
2505
|
+
if (c.vt.type !== 'list')
|
|
2506
|
+
return { errors: [`${path}: "${n.op}" needs a list var, but ${lvalueLabel(sc.list)} is "${c.vt.type}"`], nodes: 1 };
|
|
2507
|
+
const e = [];
|
|
2508
|
+
if (RESERVED_REF_NAMES.has(sc.as))
|
|
2509
|
+
e.push(`${path}.as: "${sc.as}" is a reserved name`);
|
|
2510
|
+
const inner = new Set([...bound, sc.as]);
|
|
2511
|
+
const innerEc = { ...ec, boundKinds: { ...ec.boundKinds, [sc.as]: elemBinding(c.vt.of) } };
|
|
2512
|
+
const w = validateExpr(`${path}.where`, sc.where, inner, roomVars, playerVars, zoneIds, innerEc, depth + 1);
|
|
2513
|
+
e.push(...w.errors);
|
|
2514
|
+
if (exprHasReduction(sc.where))
|
|
2515
|
+
e.push(`${path}.where: aggregate/nearest* are forbidden inside ${n.op}`);
|
|
2516
|
+
return { errors: e, nodes: 1 + w.nodes, type: 'number' };
|
|
2517
|
+
}
|
|
2518
|
+
if (n.op === 'count') {
|
|
2519
|
+
// §5 (P3 ext) `map` is any counterMap lvalue (room/self path OR a {ref,var} addressing another member's / an entity's counterMap).
|
|
2520
|
+
const c = resolveCollectionLvalue(`${path}.map`, n.map ?? '', bound, roomVars, playerVars, zoneIds, ec);
|
|
2521
|
+
if (c.errors.length)
|
|
2522
|
+
return { errors: c.errors, nodes: 1 };
|
|
2523
|
+
if (c.vt.type !== 'counterMap')
|
|
2524
|
+
return { errors: [`${path}: "count" needs a counterMap var, but ${lvalueLabel(n.map ?? '')} is "${c.vt.type}"`], nodes: 1 };
|
|
2525
|
+
if (!c.vt.keys.includes(n.key ?? ''))
|
|
2526
|
+
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 };
|
|
2527
|
+
return { errors: [], nodes: 1, type: 'number' };
|
|
2528
|
+
}
|
|
2529
|
+
// §5 (P3 ext) listLength / listAt — `list` is any list lvalue (room/self path OR {ref,var}).
|
|
2530
|
+
const c = resolveCollectionLvalue(`${path}.list`, n.list ?? '', bound, roomVars, playerVars, zoneIds, ec);
|
|
2531
|
+
if (c.errors.length)
|
|
2532
|
+
return { errors: c.errors, nodes: 1 };
|
|
2533
|
+
if (c.vt.type !== 'list')
|
|
2534
|
+
return { errors: [`${path}: "${n.op}" needs a list var, but ${lvalueLabel(n.list ?? '')} is "${c.vt.type}"`], nodes: 1 };
|
|
2535
|
+
if (n.op === 'listLength')
|
|
2536
|
+
return { errors: [], nodes: 1, type: 'number' };
|
|
2537
|
+
// listAt — the index is a number expr. A SCALAR list → the element type (no `field`); a RECORD list → the named
|
|
2538
|
+
// `field`'s scalar type (`field` required). A ref-list element-as-Ref is a deferred follow-up.
|
|
2539
|
+
const r = validateExpr(`${path}.index`, n.index, bound, roomVars, playerVars, zoneIds, ec, depth + 1);
|
|
2540
|
+
const e = [...r.errors];
|
|
2541
|
+
if (r.type !== undefined && r.type !== 'number')
|
|
2542
|
+
e.push(`${path}.index: "listAt" needs a number index, got "${r.type}"`);
|
|
2543
|
+
const of = c.vt.of;
|
|
2544
|
+
if (typeof of === 'string') {
|
|
2545
|
+
if (n.field !== undefined)
|
|
2546
|
+
e.push(`${path}: "listAt" on a scalar list takes no "field"`);
|
|
2547
|
+
return { errors: e, nodes: 1 + r.nodes, type: of };
|
|
2548
|
+
}
|
|
2549
|
+
if (of.type === 'record') {
|
|
2550
|
+
if (n.field === undefined) {
|
|
2551
|
+
e.push(`${path}: "listAt" on a record list needs a "field" to read (fields: ${Object.keys(of.fields).join(', ')})`);
|
|
2552
|
+
return { errors: e, nodes: 1 + r.nodes };
|
|
2553
|
+
}
|
|
2554
|
+
const ft = of.fields[n.field];
|
|
2555
|
+
if (!ft) {
|
|
2556
|
+
e.push(`${path}.field: unknown record field "${n.field}"${didYouMean(n.field, Object.keys(of.fields))} (fields: ${Object.keys(of.fields).join(', ')})`);
|
|
2557
|
+
return { errors: e, nodes: 1 + r.nodes };
|
|
2558
|
+
}
|
|
2559
|
+
return { errors: e, nodes: 1 + r.nodes, type: ft.type };
|
|
2560
|
+
}
|
|
2561
|
+
e.push(`${path}: "listAt" can't read a ref list element as a scalar`);
|
|
2562
|
+
return { errors: e, nodes: 1 + r.nodes };
|
|
2563
|
+
}
|
|
2564
|
+
// Resolve a ref-addressed read `{ref:<Ref>, var:"<name>"}` (§7.1) — read a member's var. The ref is any Ref
|
|
2565
|
+
// (a bound name, a ref-var deref, or a ref-returning op — validated by resolveRefSlot); the referenced member
|
|
2566
|
+
// is a player, so `var` resolves against playerVars or a player built-in (e.g. `position`).
|
|
2567
|
+
function resolveRefRead(path, ref, name, bound, roomVars, playerVars, zoneIds, ec) {
|
|
2568
|
+
// §5 (P3 ext) a {ref,var} whose ref is a bound RECORD element reads the record's FIELD (not a cross-member deref);
|
|
2569
|
+
// a scalar element has no fields (read it via {var:as}). A ref element falls through to the member-ref path below.
|
|
2570
|
+
if (typeof ref === 'string' && bound.has(ref)) {
|
|
2571
|
+
const k = ec.boundKinds?.[ref];
|
|
2572
|
+
if (k?.type === 'listRecord') {
|
|
2573
|
+
const ft = k.fields[name];
|
|
2574
|
+
if (!ft)
|
|
2575
|
+
return { errors: [`${path}.var: unknown record field "${name}"${didYouMean(name, Object.keys(k.fields))} (fields: ${Object.keys(k.fields).join(', ')})`] };
|
|
2576
|
+
return { errors: [], type: ft.type };
|
|
2577
|
+
}
|
|
2578
|
+
if (k?.type === 'listScalar')
|
|
2579
|
+
return { errors: [`${path}: "${ref}" is a scalar list element — read it via {var:"${ref}"}, not {ref,var}`] };
|
|
2580
|
+
}
|
|
2581
|
+
const r = resolveRefSlot(`${path}.ref`, ref, bound, roomVars, playerVars, zoneIds, ec);
|
|
2582
|
+
if (r.errors.length)
|
|
2583
|
+
return { errors: r.errors };
|
|
2584
|
+
if (playerBuiltin(name, r.kind?.type === 'entity'))
|
|
2585
|
+
return { errors: [], type: PLAYER_BUILTIN_TYPES[name] }; // position (universal) + connected/active (player-only — an entity's declared var wins)
|
|
2586
|
+
// §9 (4.5, row 16) if the ref resolves to a known ENTITY kind, resolve `var` against THAT kind's decls (a
|
|
2587
|
+
// wrong-kind var now errors); a player ref / untracked kind falls back to player vars then the entity-var union.
|
|
2588
|
+
if (r.kind?.type === 'entity') {
|
|
2589
|
+
const kv = ec.kindVars[r.kind.kind]?.[name];
|
|
2590
|
+
if (kv)
|
|
2591
|
+
return { errors: [], type: kv.type };
|
|
2592
|
+
const d2 = Object.keys(ec.kindVars[r.kind.kind] ?? {});
|
|
2593
|
+
return { errors: [`${path}.var: unknown var "${name}" for entity kind "${r.kind.kind}"${didYouMean(name, d2)} (declared: ${d2.join(', ') || 'none'})`] };
|
|
2594
|
+
}
|
|
2595
|
+
const vt = playerVars[name];
|
|
2596
|
+
if (vt) {
|
|
2597
|
+
if (vt.type === 'list' || vt.type === 'counterMap')
|
|
2598
|
+
return { errors: [collectionReadError(`${path}.var`, name, vt.type)] }; // §5 (4.5.13)
|
|
2599
|
+
return { errors: [], type: vt.type };
|
|
2600
|
+
}
|
|
2601
|
+
if (name in ec.varTypes)
|
|
2602
|
+
return { errors: [], type: ec.varTypes[name] };
|
|
2603
|
+
const declared = [...Object.keys(playerVars), ...Object.keys(ec.varTypes), ...Object.keys(PLAYER_BUILTIN_TYPES)];
|
|
2604
|
+
return { errors: [`${path}.var: unknown member var "${name}"${didYouMean(name, declared)} (declared player/entity vars: ${declared.join(', ') || 'none'})`] };
|
|
2605
|
+
}
|
|
2606
|
+
// Validate a ref slot (§7.1 N2/N14 disambiguation): in a ref position a bare string is a BOUND NAME, and the
|
|
2607
|
+
// object forms are a ref-typed var read {var} (deref), nearestPlayer (from a vec3), or aggregate argmax|argmin
|
|
2608
|
+
// (a number field). Returns errors (empty = a well-formed ref). Resolution to a member happens at runtime.
|
|
2609
|
+
// §9 (4.5) map a declared ref var's `of` ('player' | 'entity:<kind>') to a member kind. undefined = not a ref var.
|
|
2610
|
+
function ofToKind(of) {
|
|
2611
|
+
if (of === 'player')
|
|
2612
|
+
return { type: 'player' };
|
|
2613
|
+
if (typeof of === 'string' && of.startsWith('entity:'))
|
|
2614
|
+
return { type: 'entity', kind: of.slice('entity:'.length) };
|
|
2615
|
+
return undefined;
|
|
2616
|
+
}
|
|
2617
|
+
// The entity sub-kind of a binding, or undefined (a player / a non-member list-element binding). Narrows the
|
|
2618
|
+
// widened FwBinding union at the sites that read `.kind` (only the entity variant carries one).
|
|
2619
|
+
function bindingKind(b) {
|
|
2620
|
+
return b.type === 'entity' ? b.kind : undefined;
|
|
2621
|
+
}
|
|
2622
|
+
// §9 (4.5) the static member kind a `{var:"scope.name"}` ref-var deref resolves to — the declared ref var's `of`,
|
|
2623
|
+
// looked up against the right scope (room vars; self vars per self's kind; an action ref arg). undefined = untracked.
|
|
2624
|
+
function refVarKind(dotted, roomVars, playerVars, ec) {
|
|
2625
|
+
const dot = dotted.indexOf('.');
|
|
2626
|
+
const scope = dot >= 0 ? dotted.slice(0, dot) : '';
|
|
2627
|
+
const name = dot >= 0 ? dotted.slice(dot + 1) : '';
|
|
2628
|
+
if (scope === 'action' && name.startsWith('args.')) {
|
|
2629
|
+
const at = ec.actionArgs?.[name.slice('args.'.length)];
|
|
2630
|
+
return at?.type === 'ref' ? ofToKind(at.of) : undefined;
|
|
2631
|
+
}
|
|
2632
|
+
let decl;
|
|
2633
|
+
if (scope === 'room')
|
|
2634
|
+
decl = roomVars[name];
|
|
2635
|
+
else if (scope === 'self') {
|
|
2636
|
+
const sk = ec.boundKinds?.self;
|
|
2637
|
+
decl = sk?.type === 'entity' ? ec.kindVars[sk.kind]?.[name] : playerVars[name];
|
|
2638
|
+
}
|
|
2639
|
+
return decl?.type === 'ref' ? ofToKind(decl.of) : undefined;
|
|
2640
|
+
}
|
|
2641
|
+
function resolveRefSlot(path, value, bound, roomVars, playerVars, zoneIds, ec) {
|
|
2642
|
+
if (typeof value === 'string') {
|
|
2643
|
+
if (!bound.has(value))
|
|
2644
|
+
return { errors: [`${path}: "${value}" is not a bound ref (${[...bound].join(', ') || 'none bound here'})`] };
|
|
2645
|
+
const k = ec.boundKinds?.[value]; // the bound name's static member kind (if the event/loop tracks it)
|
|
2646
|
+
// §5 (P3 ext) a SCALAR / RECORD list element is NOT a member ref. A record field read ({ref:as,var:field}) is
|
|
2647
|
+
// resolved in resolveRefRead before reaching here, so anything landing here is a misuse in a plain ref slot.
|
|
2648
|
+
if (k?.type === 'listScalar')
|
|
2649
|
+
return { errors: [`${path}: "${value}" is a scalar list element, not a member ref — read its value via {var:"${value}"}`] };
|
|
2650
|
+
if (k?.type === 'listRecord')
|
|
2651
|
+
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}",…}`] };
|
|
2652
|
+
return { errors: [], kind: k }; // a ref element resolves to its member kind (player/entity) — flows like any bound ref
|
|
2653
|
+
}
|
|
2654
|
+
if (typeof value !== 'object' || value === null) {
|
|
2655
|
+
return { errors: [`${path}: expected a ref (a bound name, a ref var, or a ref-returning op), got ${value === null ? 'null' : typeof value}`] };
|
|
2656
|
+
}
|
|
2657
|
+
if ('var' in value) {
|
|
2658
|
+
const r = resolveVarRead(path, value.var, bound, roomVars, playerVars, ec);
|
|
2659
|
+
if (r.errors.length)
|
|
2660
|
+
return { errors: r.errors };
|
|
2661
|
+
if (r.type !== 'ref')
|
|
2662
|
+
return { errors: [`${path}: "${value.var}" is "${r.type}", not a ref`] };
|
|
2663
|
+
return { errors: [], kind: refVarKind(value.var, roomVars, playerVars, ec) };
|
|
2664
|
+
}
|
|
2665
|
+
if (value.op === 'nearestPlayer') {
|
|
2666
|
+
const r = validateExpr(`${path}.from`, value.from, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
2667
|
+
const e = [...r.errors];
|
|
2668
|
+
if (r.type !== undefined && r.type !== 'vec3')
|
|
2669
|
+
e.push(`${path}.from: "nearestPlayer" needs a vec3, got "${r.type}"`);
|
|
2670
|
+
return { errors: e, kind: { type: 'player' } };
|
|
2671
|
+
}
|
|
2672
|
+
if (value.op === 'nearestEntity') {
|
|
2673
|
+
const r = validateExpr(`${path}.from`, value.from, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
2674
|
+
const e = [...r.errors];
|
|
2675
|
+
if (r.type !== undefined && r.type !== 'vec3')
|
|
2676
|
+
e.push(`${path}.from: "nearestEntity" needs a vec3, got "${r.type}"`);
|
|
2677
|
+
if (value.kind !== undefined && !ec.kinds.has(value.kind)) {
|
|
2678
|
+
e.push(`${path}.kind: unknown entity kind "${value.kind}"${didYouMean(value.kind, [...ec.kinds])} (declared entities: ${[...ec.kinds].join(', ') || 'none'})`);
|
|
2679
|
+
}
|
|
2680
|
+
// A kinded nearestEntity resolves to that kind (per-kind reads); kindless = any entity (kind untracked).
|
|
2681
|
+
return { errors: e, kind: value.kind !== undefined ? { type: 'entity', kind: value.kind } : undefined };
|
|
2682
|
+
}
|
|
2683
|
+
if (value.op === 'controllerOf') {
|
|
2684
|
+
// §9 (P3 Ownership/A3) the player controlling `entity` — a player ref ('' = server/unowned). O(1) field read.
|
|
2685
|
+
const r = resolveRefSlot(`${path}.entity`, value.entity, bound, roomVars, playerVars, zoneIds, ec);
|
|
2686
|
+
return { errors: r.errors, kind: { type: 'player' } };
|
|
2687
|
+
}
|
|
2688
|
+
if (value.op === 'leastLoadedPlayer') {
|
|
2689
|
+
// §9 (P3 Ownership/B8) the least-busy connected player (optional where/as filter, validated like aggregate's
|
|
2690
|
+
// over the player domain). A reduction (O(players)) → forbidden inside forEach by refHasReduction.
|
|
2691
|
+
const e = [];
|
|
2692
|
+
if (value.where !== undefined || value.as !== undefined) {
|
|
2693
|
+
if (value.as === undefined)
|
|
2694
|
+
e.push(`${path}: leastLoadedPlayer "where" requires "as" (a name for each candidate player)`);
|
|
2695
|
+
else if (RESERVED_REF_NAMES.has(value.as))
|
|
2696
|
+
e.push(`${path}.as: "${value.as}" is a reserved name`);
|
|
2697
|
+
else if (value.where === undefined)
|
|
2698
|
+
e.push(`${path}: leastLoadedPlayer "as" requires a "where" filter`);
|
|
2699
|
+
else {
|
|
2700
|
+
const inner = new Set([...bound, value.as]);
|
|
2701
|
+
const innerEc = { ...ec, boundKinds: { ...ec.boundKinds, [value.as]: { type: 'player' } } };
|
|
2702
|
+
e.push(...validateExpr(`${path}.where`, value.where, inner, roomVars, playerVars, zoneIds, innerEc, 1).errors);
|
|
2703
|
+
if (exprHasReduction(value.where))
|
|
2704
|
+
e.push(`${path}.where: aggregate/nearest* are forbidden inside a leastLoadedPlayer "where"`);
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
return { errors: e, kind: { type: 'player' } };
|
|
2708
|
+
}
|
|
2709
|
+
if (value.op === 'listAt') {
|
|
2710
|
+
// §5 (P3-B2 + P3 ext) a `list of ref` element AS a Ref (the turn-order current actor). The list is any list
|
|
2711
|
+
// lvalue (room/self path OR {ref,var}); its element must be a ref; the index a number. The resolved member kind is
|
|
2712
|
+
// the ref element's `of`. O(1) — not a reduction.
|
|
2713
|
+
const c = resolveCollectionLvalue(`${path}.list`, value.list, bound, roomVars, playerVars, zoneIds, ec);
|
|
2714
|
+
if (c.errors.length)
|
|
2715
|
+
return { errors: c.errors };
|
|
2716
|
+
const vt = c.vt;
|
|
2717
|
+
const elem = vt.type === 'list' ? vt.of : undefined;
|
|
2718
|
+
if (vt.type !== 'list' || typeof elem !== 'object' || elem.type !== 'ref') {
|
|
2719
|
+
const what = vt.type === 'list' ? `a list of ${typeof elem === 'string' ? elem : elem.type}` : `a "${vt.type}"`;
|
|
2720
|
+
return { errors: [`${path}: listAt is a Ref only over a list of refs (${lvalueLabel(value.list)} is ${what})`] };
|
|
2721
|
+
}
|
|
2722
|
+
const r = validateExpr(`${path}.index`, value.index, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
2723
|
+
const errs = [...r.errors];
|
|
2724
|
+
if (r.type !== undefined && r.type !== 'number')
|
|
2725
|
+
errs.push(`${path}.index: listAt needs a number index, got "${r.type}"`);
|
|
2726
|
+
return { errors: errs, kind: ofToKind(elem.of) };
|
|
2727
|
+
}
|
|
2728
|
+
// aggregate argmax|argmin — the reduced scope's member kind (players → player; entities:<kind> or a zone
|
|
2729
|
+
// tracking 'entity:<kind>' → that entity).
|
|
2730
|
+
const e = validateAggregate(path, value, bound, roomVars, playerVars, zoneIds, ec);
|
|
2731
|
+
const scope = value.scope ?? '';
|
|
2732
|
+
const kind = aggregateMemberKind(scope, ec);
|
|
2733
|
+
return { errors: e, kind };
|
|
2734
|
+
}
|
|
2735
|
+
// Resolve an lvalue (effect target) to its writable var's type. A string scope-path "room.<var>"/"self.<var>"
|
|
2736
|
+
// or the ref-addressed `{ref,var}` write-through-ref. `self`/ref require a bound player; a built-in (position)
|
|
2737
|
+
// is read-only here (write it via teleport/respawn); did-you-mean on an unknown var.
|
|
2738
|
+
function resolveLvalue(path, lvalue, bound, roomVars, playerVars, zoneIds, ec) {
|
|
2739
|
+
if (typeof lvalue !== 'string') {
|
|
2740
|
+
const { ref, var: name } = lvalue;
|
|
2741
|
+
const r = resolveRefSlot(`${path}.ref`, ref, bound, roomVars, playerVars, zoneIds, ec);
|
|
2742
|
+
if (r.errors.length)
|
|
2743
|
+
return { errors: r.errors };
|
|
2744
|
+
if (playerBuiltin(name, r.kind?.type === 'entity'))
|
|
2745
|
+
return { errors: [`${path}: can't write the built-in "${name}" — use teleport/respawn`] };
|
|
2746
|
+
// §9 (4.5, row 16) write-through to an ENTITY var resolves against the ref's specific kind when known.
|
|
2747
|
+
if (r.kind?.type === 'entity') {
|
|
2748
|
+
const kv = ec.kindVars[r.kind.kind]?.[name];
|
|
2749
|
+
if (kv)
|
|
2750
|
+
return { errors: [], type: kv.type };
|
|
2751
|
+
const d2 = Object.keys(ec.kindVars[r.kind.kind] ?? {});
|
|
2752
|
+
return { errors: [`${path}: unknown var "${name}" for entity kind "${r.kind.kind}"${didYouMean(name, d2)} (declared: ${d2.join(', ') || 'none'})`] };
|
|
2753
|
+
}
|
|
2754
|
+
const vt = playerVars[name];
|
|
2755
|
+
if (vt) {
|
|
2756
|
+
if (vt.type === 'list' || vt.type === 'counterMap')
|
|
2757
|
+
return { errors: [collectionReadError(path, name, vt.type)] }; // §5 (4.5.13) not a scalar write
|
|
2758
|
+
return { errors: [], type: vt.type };
|
|
2759
|
+
}
|
|
2760
|
+
if (name in ec.varTypes)
|
|
2761
|
+
return { errors: [], type: ec.varTypes[name] };
|
|
2762
|
+
const declared = [...Object.keys(playerVars), ...Object.keys(ec.varTypes)];
|
|
2763
|
+
return { errors: [`${path}: unknown member var "${name}"${didYouMean(name, declared)} (declared player/entity vars: ${declared.join(', ') || 'none'})`] };
|
|
2764
|
+
}
|
|
2765
|
+
const dot = lvalue.indexOf('.');
|
|
2766
|
+
const scope = dot >= 0 ? lvalue.slice(0, dot) : '';
|
|
2767
|
+
const name = dot >= 0 ? lvalue.slice(dot + 1) : '';
|
|
2768
|
+
if (scope !== 'room' && scope !== 'self')
|
|
2769
|
+
return { errors: [`${path}: "${lvalue}" must be "room.<var>" or "self.<var>"`] };
|
|
2770
|
+
if (scope === 'self' && !bound.has('self'))
|
|
2771
|
+
return { errors: [`${path}: "self.${name}" — this rule's event binds no member (no "self" here)`] };
|
|
2772
|
+
if (scope === 'self' && playerBuiltin(name, ec.boundKinds?.self?.type === 'entity'))
|
|
2773
|
+
return { errors: [`${path}: can't write the built-in "self.${name}" — use teleport/respawn`] };
|
|
2774
|
+
if (scope === 'room' && name in ROOM_BUILTIN_TYPES)
|
|
2775
|
+
return { errors: [`${path}: can't write the built-in "room.${name}" — use transitionTo`] };
|
|
2776
|
+
const decls = scope === 'room' ? roomVars : playerVars;
|
|
2777
|
+
const vt = decls[name];
|
|
2778
|
+
if (!vt) {
|
|
2779
|
+
if (scope === 'self' && name in ec.varTypes)
|
|
2780
|
+
return { errors: [], type: ec.varTypes[name] }; // §9 entity self write
|
|
2781
|
+
const declared = Object.keys(decls);
|
|
2782
|
+
return { errors: [`${path}: unknown ${scope} var "${name}"${didYouMean(name, declared)} (declared ${scope} vars: ${declared.join(', ') || 'none'})`] };
|
|
2783
|
+
}
|
|
2784
|
+
if (vt.type === 'list' || vt.type === 'counterMap')
|
|
2785
|
+
return { errors: [collectionReadError(path, lvalue, vt.type)] }; // §5 (4.5.13) not a scalar write
|
|
2786
|
+
return { errors: [], type: vt.type };
|
|
2787
|
+
}
|
|
2788
|
+
// §11.1 closed cap table enforcement: count caps per scope + per-string-var ceilings, with a precise
|
|
2789
|
+
// "over the cap" message. The dimensions are exactly MULTIPLAYER_CAPS (Phase-1 declarable set).
|
|
2790
|
+
function checkStateCaps(state) {
|
|
2791
|
+
const e = [];
|
|
2792
|
+
const scopes = [
|
|
2793
|
+
['roomVars', state.roomVars, exports.MULTIPLAYER_CAPS.roomVars],
|
|
2794
|
+
['playerVars', state.playerVars, exports.MULTIPLAYER_CAPS.playerVars],
|
|
2795
|
+
];
|
|
2796
|
+
for (const [scope, decls, cap] of scopes) {
|
|
2797
|
+
if (!decls)
|
|
2798
|
+
continue;
|
|
2799
|
+
const names = Object.keys(decls);
|
|
2800
|
+
if (names.length > cap)
|
|
2801
|
+
e.push(`manifest: multiplayer.state.${scope} declares ${names.length} vars — the cap is ${cap}`);
|
|
2802
|
+
for (const name of names) {
|
|
2803
|
+
const vt = decls[name];
|
|
2804
|
+
if (vt.type !== 'string')
|
|
2805
|
+
continue;
|
|
2806
|
+
if (vt.maxLen !== undefined && vt.maxLen > exports.MULTIPLAYER_CAPS.stringMaxLen) {
|
|
2807
|
+
e.push(`multiplayer.state.${scope}.${name}: maxLen ${vt.maxLen} exceeds the ceiling of ${exports.MULTIPLAYER_CAPS.stringMaxLen}`);
|
|
2808
|
+
}
|
|
2809
|
+
if (vt.enum && vt.enum.length > exports.MULTIPLAYER_CAPS.enumValues) {
|
|
2810
|
+
e.push(`multiplayer.state.${scope}.${name}: enum has ${vt.enum.length} values — the cap is ${exports.MULTIPLAYER_CAPS.enumValues}`);
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
return e;
|
|
2815
|
+
}
|
|
2816
|
+
// Cross-field check a declared var's default against its own constraints — the part JSON Schema can't
|
|
2817
|
+
// express (default-vs-bounds, integer-ness, enum membership). The schema already validated vt's shape +
|
|
2818
|
+
// discriminant, so we branch on vt.type.
|
|
2819
|
+
function checkVarDefault(path, vt) {
|
|
2820
|
+
const e = [];
|
|
2821
|
+
if (vt.type === 'number') {
|
|
2822
|
+
if (vt.min !== undefined && vt.max !== undefined && vt.min > vt.max)
|
|
2823
|
+
e.push(`${path}: min ${vt.min} exceeds max ${vt.max}`);
|
|
2824
|
+
if (vt.integer && !Number.isInteger(vt.default))
|
|
2825
|
+
e.push(`${path}: default ${vt.default} must be an integer`);
|
|
2826
|
+
if (vt.min !== undefined && vt.default < vt.min)
|
|
2827
|
+
e.push(`${path}: default ${vt.default} is below min ${vt.min}`);
|
|
2828
|
+
if (vt.max !== undefined && vt.default > vt.max)
|
|
2829
|
+
e.push(`${path}: default ${vt.default} is above max ${vt.max}`);
|
|
2830
|
+
}
|
|
2831
|
+
else if (vt.type === 'string') {
|
|
2832
|
+
if (vt.maxLen !== undefined && vt.default.length > vt.maxLen)
|
|
2833
|
+
e.push(`${path}: default "${vt.default}" exceeds maxLen ${vt.maxLen}`);
|
|
2834
|
+
if (vt.enum && !vt.enum.includes(vt.default))
|
|
2835
|
+
e.push(`${path}: default "${vt.default}" is not one of the declared enum values [${vt.enum.join(', ')}]`);
|
|
2836
|
+
}
|
|
2837
|
+
else if (vt.type === 'list') {
|
|
2838
|
+
// §5 (4.5.13) a list starts empty (no author default); maxLen must be a positive integer within the cap.
|
|
2839
|
+
if (!Number.isInteger(vt.maxLen) || vt.maxLen < 1)
|
|
2840
|
+
e.push(`${path}: list maxLen must be a positive integer, got ${vt.maxLen}`);
|
|
2841
|
+
else if (vt.maxLen > exports.MULTIPLAYER_CAPS.listMaxLen)
|
|
2842
|
+
e.push(`${path}: list maxLen ${vt.maxLen} exceeds the ceiling of ${exports.MULTIPLAYER_CAPS.listMaxLen}`);
|
|
2843
|
+
// §5 (P3 Collections) a record element: cap its field count + validate each flat scalar field (no nesting).
|
|
2844
|
+
if (typeof vt.of === 'object' && vt.of.type === 'record') {
|
|
2845
|
+
const names = Object.keys(vt.of.fields);
|
|
2846
|
+
if (names.length > exports.MULTIPLAYER_CAPS.recordFields)
|
|
2847
|
+
e.push(`${path}: a record element declares ${names.length} fields — the cap is ${exports.MULTIPLAYER_CAPS.recordFields}`);
|
|
2848
|
+
for (const fn of names)
|
|
2849
|
+
e.push(...checkRecordField(`${path}.of.fields.${fn}`, vt.of.fields[fn]));
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
else if (vt.type === 'counterMap') {
|
|
2853
|
+
// §5 (4.5.13) a counterMap starts with every declared key at 0; keys must be non-empty, unique, within the cap.
|
|
2854
|
+
if (vt.keys.length === 0)
|
|
2855
|
+
e.push(`${path}: counterMap needs at least one key`);
|
|
2856
|
+
else if (vt.keys.length > exports.MULTIPLAYER_CAPS.counterKeys)
|
|
2857
|
+
e.push(`${path}: counterMap has ${vt.keys.length} keys — the cap is ${exports.MULTIPLAYER_CAPS.counterKeys}`);
|
|
2858
|
+
if (new Set(vt.keys).size !== vt.keys.length)
|
|
2859
|
+
e.push(`${path}: counterMap keys must be unique`);
|
|
2860
|
+
}
|
|
2861
|
+
return e;
|
|
2862
|
+
}
|
|
2863
|
+
// §5 (P3 Collections) cross-field check a record element's flat scalar field (the part JSON Schema can't express:
|
|
2864
|
+
// default-vs-bounds, integer-ness, enum membership, the shared maxLen/enum ceilings). Mirrors checkVarDefault but
|
|
2865
|
+
// the field default is OPTIONAL — only validate it when present.
|
|
2866
|
+
function checkRecordField(path, f) {
|
|
2867
|
+
const e = [];
|
|
2868
|
+
if (f.type === 'number') {
|
|
2869
|
+
if (f.min !== undefined && f.max !== undefined && f.min > f.max)
|
|
2870
|
+
e.push(`${path}: min ${f.min} exceeds max ${f.max}`);
|
|
2871
|
+
if (f.default !== undefined) {
|
|
2872
|
+
if (f.integer && !Number.isInteger(f.default))
|
|
2873
|
+
e.push(`${path}: default ${f.default} must be an integer`);
|
|
2874
|
+
if (f.min !== undefined && f.default < f.min)
|
|
2875
|
+
e.push(`${path}: default ${f.default} is below min ${f.min}`);
|
|
2876
|
+
if (f.max !== undefined && f.default > f.max)
|
|
2877
|
+
e.push(`${path}: default ${f.default} is above max ${f.max}`);
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
else if (f.type === 'string') {
|
|
2881
|
+
if (f.maxLen !== undefined && f.maxLen > exports.MULTIPLAYER_CAPS.stringMaxLen)
|
|
2882
|
+
e.push(`${path}: maxLen ${f.maxLen} exceeds the ceiling of ${exports.MULTIPLAYER_CAPS.stringMaxLen}`);
|
|
2883
|
+
if (f.enum && f.enum.length > exports.MULTIPLAYER_CAPS.enumValues)
|
|
2884
|
+
e.push(`${path}: enum has ${f.enum.length} values — the cap is ${exports.MULTIPLAYER_CAPS.enumValues}`);
|
|
2885
|
+
if (f.default !== undefined) {
|
|
2886
|
+
if (f.maxLen !== undefined && f.default.length > f.maxLen)
|
|
2887
|
+
e.push(`${path}: default "${f.default}" exceeds maxLen ${f.maxLen}`);
|
|
2888
|
+
if (f.enum && !f.enum.includes(f.default))
|
|
2889
|
+
e.push(`${path}: default "${f.default}" is not one of the declared enum values [${f.enum.join(', ')}]`);
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
return e;
|
|
2893
|
+
}
|
|
2894
|
+
// §5 (P3 Collections) validate a value written into a list whose element type is `elem`: a scalar Expr of the
|
|
2895
|
+
// matching type, a ref (a Ref of the matching kind), or a record literal { field: Expr|Ref } (each provided field
|
|
2896
|
+
// type-matches its declared field; an unknown field errors; an omitted field takes its default/zero at runtime).
|
|
2897
|
+
// Reused by append (C1) and the record-element mutators (C3/C4).
|
|
2898
|
+
function validateListElementValue(path, value, elem, bound, roomVars, playerVars, zoneIds, ec) {
|
|
2899
|
+
if (typeof elem === 'string') {
|
|
2900
|
+
const r = validateExpr(path, value, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
2901
|
+
const e = [...r.errors];
|
|
2902
|
+
if (r.type !== undefined && r.type !== elem)
|
|
2903
|
+
e.push(`${path}: a "${r.type}" can't go into a list of "${elem}"`);
|
|
2904
|
+
return e;
|
|
2905
|
+
}
|
|
2906
|
+
if (elem.type === 'ref') {
|
|
2907
|
+
const r = resolveRefSlot(path, value, bound, roomVars, playerVars, zoneIds, ec);
|
|
2908
|
+
const e = [...r.errors];
|
|
2909
|
+
const want = ofToKind(elem.of);
|
|
2910
|
+
if (r.kind && want && (r.kind.type !== want.type || bindingKind(r.kind) !== bindingKind(want))) {
|
|
2911
|
+
const lbl = (k) => (k.type === 'player' ? 'player' : `entity:${bindingKind(k)}`);
|
|
2912
|
+
e.push(`${path}: a "${lbl(r.kind)}" ref can't go into a list of "${lbl(want)}" refs`);
|
|
2913
|
+
}
|
|
2914
|
+
return e;
|
|
2915
|
+
}
|
|
2916
|
+
// record element — the value is a record literal (a plain object of field → Expr|Ref).
|
|
2917
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
2918
|
+
return [`${path}: a record list needs a record literal { field: value, ... }`];
|
|
2919
|
+
}
|
|
2920
|
+
const e = [];
|
|
2921
|
+
for (const [fn, fv] of Object.entries(value)) {
|
|
2922
|
+
const ft = elem.fields[fn];
|
|
2923
|
+
if (!ft) {
|
|
2924
|
+
e.push(`${path}.${fn}: unknown record field "${fn}"${didYouMean(fn, Object.keys(elem.fields))} (fields: ${Object.keys(elem.fields).join(', ')})`);
|
|
2925
|
+
continue;
|
|
2926
|
+
}
|
|
2927
|
+
if (ft.type === 'ref') {
|
|
2928
|
+
e.push(...resolveRefSlot(`${path}.${fn}`, fv, bound, roomVars, playerVars, zoneIds, ec).errors);
|
|
2929
|
+
}
|
|
2930
|
+
else {
|
|
2931
|
+
const r = validateExpr(`${path}.${fn}`, fv, bound, roomVars, playerVars, zoneIds, ec, 1);
|
|
2932
|
+
e.push(...r.errors);
|
|
2933
|
+
if (r.type !== undefined && r.type !== ft.type)
|
|
2934
|
+
e.push(`${path}.${fn}: a "${r.type}" value doesn't match the declared "${ft.type}"`);
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
return e;
|
|
2938
|
+
}
|
|
2939
|
+
// The valid declared-var type discriminants — the single source for the did-you-mean below + the authoring
|
|
2940
|
+
// vocabulary the schema's varDecls oneOf enumerates. (Bounds/enum/of live on each variant; this is the tag.)
|
|
2941
|
+
exports.VAR_TYPES = ['number', 'string', 'boolean', 'vec3', 'ref', 'list', 'counterMap'];
|
|
2942
|
+
function levenshtein(a, b) {
|
|
2943
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
2944
|
+
for (let j = 0; j <= b.length; j++)
|
|
2945
|
+
d[0][j] = j;
|
|
2946
|
+
for (let i = 1; i <= a.length; i++) {
|
|
2947
|
+
for (let j = 1; j <= b.length; j++) {
|
|
2948
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
2949
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
return d[a.length][b.length];
|
|
2953
|
+
}
|
|
2954
|
+
// "" when nothing is close enough (edit distance > 2), so an unrelated typo doesn't get a misleading guess.
|
|
2955
|
+
function didYouMean(input, candidates) {
|
|
2956
|
+
let best = '';
|
|
2957
|
+
let bestD = Infinity;
|
|
2958
|
+
for (const c of candidates) {
|
|
2959
|
+
const dist = levenshtein(input, c);
|
|
2960
|
+
if (dist < bestD)
|
|
2961
|
+
[bestD, best] = [dist, c];
|
|
2962
|
+
}
|
|
2963
|
+
return bestD <= 2 ? ` — did you mean "${best}"?` : '';
|
|
2964
|
+
}
|
|
2965
|
+
// §11.9 validation/error UX: an invalid VarType discriminant makes the schema's varDecls oneOf fail with an
|
|
2966
|
+
// unreadable per-branch cascade, so when v0.3 validation fails we scan declared state for a bad `type` and
|
|
2967
|
+
// surface the actionable root cause (path + did-you-mean + the valid set) instead. Empty => no bad
|
|
2968
|
+
// discriminant (the raw Ajv errors stand). NOTE: the fuller symbol table (cross-reference resolution +
|
|
2969
|
+
// warn-on-unreferenced) lands in Phase 2, when rules/zones/events introduce the references it checks.
|
|
2970
|
+
function declaredTypeErrors(candidate) {
|
|
2971
|
+
const errs = [];
|
|
2972
|
+
const state = candidate.multiplayer?.state;
|
|
2973
|
+
if (!state || typeof state !== 'object')
|
|
2974
|
+
return errs;
|
|
2975
|
+
for (const scope of ['roomVars', 'playerVars']) {
|
|
2976
|
+
const decls = state[scope];
|
|
2977
|
+
if (!decls || typeof decls !== 'object')
|
|
2978
|
+
continue;
|
|
2979
|
+
for (const [name, vt] of Object.entries(decls)) {
|
|
2980
|
+
const t = vt?.type;
|
|
2981
|
+
if (typeof t === 'string' && !exports.VAR_TYPES.includes(t)) {
|
|
2982
|
+
errs.push(`multiplayer.state.${scope}.${name}: unknown var type "${t}"${didYouMean(t, exports.VAR_TYPES)} (valid: ${exports.VAR_TYPES.join(', ')})`);
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
return errs;
|
|
2987
|
+
}
|
|
103
2988
|
function formatError(err, label = 'manifest') {
|
|
104
2989
|
const path = err.instancePath ? err.instancePath.replace(/^\//, '').replace(/\//g, '.') : label;
|
|
105
2990
|
if (err.keyword === 'additionalProperties') {
|
|
@@ -112,7 +2997,10 @@ function formatError(err, label = 'manifest') {
|
|
|
112
2997
|
}
|
|
113
2998
|
// Validates and normalizes a parsed helix.json. Same code runs in the CLI
|
|
114
2999
|
// (pre-upload) and the backend (finalize) so the two can never disagree.
|
|
115
|
-
|
|
3000
|
+
// `strict` (Phase 4.9 pre-launch gate): flip the accept+loud-no-op future-node WARNINGS into hard ERRORS, so a
|
|
3001
|
+
// real publish/launch can't ship a world that leans on a not-yet-interpreted reserved node. Default off (author-
|
|
3002
|
+
// time tooling warns; the launch pipeline passes { strict: true }).
|
|
3003
|
+
function validateManifest(data, opts) {
|
|
116
3004
|
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
117
3005
|
return { valid: false, errors: ['manifest: must be a JSON object'] };
|
|
118
3006
|
}
|
|
@@ -127,13 +3015,19 @@ function validateManifest(data) {
|
|
|
127
3015
|
// Route to the schema for the declared version (omitted → v0.1, which requires helixVersion).
|
|
128
3016
|
const validate = candidate.helixVersion === '0.3' ? validateV03 : candidate.helixVersion === '0.2' ? validateV02 : validateV01;
|
|
129
3017
|
if (!validate(candidate)) {
|
|
3018
|
+
// A bad VarType discriminant detonates the varDecls oneOf into noise — surface the clean root cause.
|
|
3019
|
+
if (candidate.helixVersion === '0.3') {
|
|
3020
|
+
const typeErrs = declaredTypeErrors(candidate);
|
|
3021
|
+
if (typeErrs.length)
|
|
3022
|
+
return { valid: false, errors: typeErrs };
|
|
3023
|
+
}
|
|
130
3024
|
const errors = (validate.errors ?? []).map((e) => formatError(e));
|
|
131
3025
|
return { valid: false, errors: [...new Set(errors)] };
|
|
132
3026
|
}
|
|
133
3027
|
const manifest = candidate;
|
|
134
3028
|
let warnings = [];
|
|
135
3029
|
if (manifest.helixVersion === '0.3') {
|
|
136
|
-
const cross = applyV03CrossFieldRules(manifest);
|
|
3030
|
+
const cross = applyV03CrossFieldRules(manifest, opts?.strict ?? false);
|
|
137
3031
|
if (cross.errors.length)
|
|
138
3032
|
return { valid: false, errors: cross.errors };
|
|
139
3033
|
warnings = cross.warnings;
|