incanto 0.58.0 → 0.59.0

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.
Files changed (59) hide show
  1. package/bin/incanto-multiplay.mjs +170 -0
  2. package/bin/incanto-verify.mjs +68 -4
  3. package/bin/incanto.mjs +1 -0
  4. package/dist/2d.d.ts +8 -2
  5. package/dist/2d.js +3 -3
  6. package/dist/3d.d.ts +9 -3
  7. package/dist/3d.js +4 -4
  8. package/dist/{behavior-l08AEbq9.d.ts → behavior-DWKTUzKI.d.ts} +10 -0
  9. package/dist/{create-game-D10bU5_J.js → create-game-BiW8Men_.js} +6 -6
  10. package/dist/{create-game-BZwWJIns.js → create-game-CHDLDQsQ.js} +6 -6
  11. package/dist/debug.d.ts +1 -1
  12. package/dist/debug.js +2 -2
  13. package/dist/{duplicate-BPLZDZpd.js → duplicate-DJQd44CD.js} +1 -1
  14. package/dist/{environment-presets-CvvQr_bJ.js → environment-presets-DRAz5EV9.js} +12 -10
  15. package/dist/{gameplay-BVphcxmE.js → gameplay-BBEjPFsR.js} +62 -34
  16. package/dist/gameplay.d.ts +1 -1
  17. package/dist/gameplay.js +1 -1
  18. package/dist/index.d.ts +36 -8
  19. package/dist/index.js +8 -8
  20. package/dist/{json-BLk7H2Qa.js → json-CwwhxQgb.js} +7 -1
  21. package/dist/{loader-BcrRSjxB.js → loader-D8n7TU8W.js} +142 -5
  22. package/dist/{loader-BbEMTuWg.d.ts → loader-TvkRFbyL.d.ts} +1 -1
  23. package/dist/net.d.ts +2 -2
  24. package/dist/net.js +1 -1
  25. package/dist/{pathfinding-mEN4V1CU.d.ts → pathfinding-BqWBb0kh.d.ts} +1 -1
  26. package/dist/{physics-2d-EqA-jddf.js → physics-2d-BaRSRrrZ.js} +12 -2
  27. package/dist/{physics-3d-Dnz4fsXX.js → physics-3d-CYxjh-HW.js} +13 -3
  28. package/dist/react.d.ts +1 -1
  29. package/dist/react.js +2 -2
  30. package/dist/{register-C6ZBFRjd.js → register-BpFcgdcL.js} +57 -27
  31. package/dist/{register-Ch70uByv.js → register-CDrAQqPp.js} +90 -41
  32. package/dist/{registry-C7u42TID.js → registry-WWcQcfMr.js} +1 -1
  33. package/dist/{replay-s7I2GstT.js → replay-CEPyQtF_.js} +31 -7
  34. package/dist/{replay-Dw6gMlYA.d.ts → replay-O-yAGM76.d.ts} +1 -1
  35. package/dist/{split-screen-B0baBwxI.d.ts → split-screen-BQ3tAsf-.d.ts} +38 -1
  36. package/dist/{split-screen-DLsUrleX.js → split-screen-DDMZutQ6.js} +56 -13
  37. package/dist/{sprite-animation-C0wXLBZJ.js → sprite-animation-CY-mrr1L.js} +1 -1
  38. package/dist/{src-C1J09Op6.js → src-CY21B462.js} +1 -1
  39. package/dist/{teardown-Cs113S9F.js → teardown-RApWnM1G.js} +1 -1
  40. package/dist/{test-ZBga8kQ9.js → test-DHYuFyAu.js} +260 -25
  41. package/dist/test.d.ts +103 -5
  42. package/dist/test.js +3 -3
  43. package/dist/vite.js +2 -2
  44. package/editor/assets/{agent8-BQQjE9UQ.js → agent8-BoRGtVxK.js} +1 -1
  45. package/editor/assets/{debug-C9UCsXBS.js → debug-CzdyCg75.js} +1 -1
  46. package/editor/assets/{index-CBgfM3WD.js → index-VesuVEhe.js} +91 -91
  47. package/editor/index.html +1 -1
  48. package/package.json +2 -1
  49. package/schemas/scene.schema.json +1174 -70
  50. package/skills/incanto-localization.md +40 -8
  51. package/skills/incanto-multiplayer.md +57 -4
  52. package/skills/incanto-node-reference.md +18 -0
  53. package/skills/incanto-physics-and-input.md +26 -0
  54. package/skills/incanto-verifying-your-game.md +22 -0
  55. package/templates-app/beacon-isle-3d/package.json +1 -1
  56. package/templates-app/platformer-2d/package.json +1 -1
  57. package/templates-app/star-survivor/package.json +1 -1
  58. package/templates-app/tps-3d/package.json +1 -1
  59. package/templates-app/village-quest-3d/package.json +1 -1
@@ -1,7 +1,8 @@
1
- import { C as diagnose, M as Signal, S as Node, _ as createSaveStore, b as resolveOrderGroups, h as restoreBehaviors, m as captureBehaviors, v as ORDER_GROUP_BASE, x as InputMap } from "./loader-BcrRSjxB.js";
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
+ import { C as Node, N as Signal, S as InputMap, g as restoreBehaviors, h as captureBehaviors, v as createSaveStore, w as diagnose, x as resolveOrderGroups, y as ORDER_GROUP_BASE } from "./loader-D8n7TU8W.js";
2
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
4
  import { t as Rng } from "./rng-DP-SR7eg.js";
4
- import { l as registerNode } from "./registry-C7u42TID.js";
5
+ import { l as registerNode } from "./registry-WWcQcfMr.js";
5
6
  //#region src/core/audio/buses.ts
6
7
  const AUDIO_LOG_LIMIT = 200;
7
8
  var AudioBuses = class {
@@ -1160,6 +1161,18 @@ var Localization = class {
1160
1161
  const key = translationKey(value);
1161
1162
  return key === null ? value : this.t(key, params);
1162
1163
  }
1164
+ /**
1165
+ * Whether the ACTIVE locale or the base declares this key.
1166
+ *
1167
+ * This is the runtime question, and it is not the same as `has`: a key only
1168
+ * `ko` declares is "declared", and an English player still gets the raw key
1169
+ * back from `t()`, because the fallback chain is current → base → the key.
1170
+ * Widgets that ask before translating asked the wrong one and painted
1171
+ * `settings.quality.low` into an English menu.
1172
+ */
1173
+ declares(key) {
1174
+ return this.tables[this.current]?.[key] !== void 0 || this.tables["en"]?.[key] !== void 0;
1175
+ }
1163
1176
  /** Whether any locale declares this key — for the audit, not for the runtime. */
1164
1177
  has(key) {
1165
1178
  for (const entries of Object.values(this.tables)) if (entries[key] !== void 0) return true;
@@ -1182,6 +1195,22 @@ function suggestLocale(available, languages) {
1182
1195
  }
1183
1196
  return "en";
1184
1197
  }
1198
+ /**
1199
+ * Resolve a text prop on ANY node, not just a DOM HUD widget.
1200
+ *
1201
+ * `@t:` was honoured in exactly one place — `HudWidgetBase._t` — so text that
1202
+ * lives in the WORLD had no localization path at all: a `Label3D` reading
1203
+ * `"@t:sign.welcome"` rasterized those literal characters into the texture
1204
+ * standing over the shop door, in every language, and the audit certified the
1205
+ * scene clean because the key WAS declared.
1206
+ *
1207
+ * Returns the value untouched when there is no tree yet or no marker, so it is
1208
+ * safe to call from a rasterize path that runs before the node is in a scene.
1209
+ */
1210
+ function translateOn(node, value) {
1211
+ const locale = node.tree?.engine?.locale;
1212
+ return locale ? locale.resolve(value) : value;
1213
+ }
1185
1214
  //#endregion
1186
1215
  //#region src/core/nodes/hud.ts
1187
1216
  const ANCHOR_CSS = {
@@ -1536,6 +1565,33 @@ var HudWidgetBase = class extends Node {
1536
1565
  const locale = this.tree?.engine?.locale;
1537
1566
  return locale ? locale.resolve(value) : value;
1538
1567
  }
1568
+ /**
1569
+ * A translation with an English wording to fall back to.
1570
+ *
1571
+ * For text the ENGINE supplies — "Music", "Mute", "Unlimited" — where there
1572
+ * is no author string to carry a `@t:` marker. Asks `declares()`, not
1573
+ * `has()`: a key only another locale declares would otherwise come back as
1574
+ * the raw key, which is worse than the English.
1575
+ */
1576
+ /**
1577
+ * What this widget's label READS as — resolved every frame, never stored.
1578
+ *
1579
+ * `UiVolumeSlider` and `UiMuteToggle` used to compute their auto-label in
1580
+ * `_build()` and write the result back into `this.label`, which broke them
1581
+ * twice over: the per-frame `_t(this.label)` then had a plain string with no
1582
+ * marker left to resolve, and `_build` runs from `onReady` — inside
1583
+ * `loadScene`, BEFORE `setScene` merges the scene's `strings` — so the table
1584
+ * was empty at bake time and the English was permanent even for a game that
1585
+ * boots in another language.
1586
+ */
1587
+ _labelText() {
1588
+ const label = this.label;
1589
+ return typeof label === "string" ? this._t(label) : "";
1590
+ }
1591
+ _tOr(key, fallback) {
1592
+ const locale = this.tree?.engine?.locale;
1593
+ return locale?.declares(key) ? locale.t(key) : fallback;
1594
+ }
1539
1595
  };
1540
1596
  /** A text line (score, timer, hints). Set `.text` from behaviors. */
1541
1597
  var UiText = class extends HudWidgetBase {
@@ -2126,7 +2182,7 @@ var Settings = class {
2126
2182
  * without knowing this class exists. Returns the unsubscribe.
2127
2183
  */
2128
2184
  bindLocale(locale) {
2129
- locale.locale = this.values.language;
2185
+ locale.locale = this.store.get("language", "") || suggestLocale(locale.available());
2130
2186
  return locale.changed.connect((next) => {
2131
2187
  this.set("language", next);
2132
2188
  });
@@ -2229,6 +2285,7 @@ function qualityRendering(tier) {
2229
2285
  }
2230
2286
  //#endregion
2231
2287
  //#region src/core/engine.ts
2288
+ var engine_exports = /* @__PURE__ */ __exportAll({ Engine: () => Engine });
2232
2289
  const MAX_DT_SECONDS = .25;
2233
2290
  function rafScheduler(cb) {
2234
2291
  let live = true;
@@ -3193,17 +3250,6 @@ function px(n) {
3193
3250
  return `${n}px`;
3194
3251
  }
3195
3252
  /**
3196
- * The translated string, or the English one.
3197
- *
3198
- * `locale.t` returns the KEY when nothing declares it, and a menu reading
3199
- * `settings.volume.music` is worse than one reading `Music`. English is the
3200
- * base language and the fallback, always.
3201
- */
3202
- function translated(engine, key, fallback) {
3203
- const locale = engine?.locale;
3204
- return locale?.has(key) ? locale.t(key) : fallback;
3205
- }
3206
- /**
3207
3253
  * A box that holds other widgets — the thing a menu actually is.
3208
3254
  *
3209
3255
  * Widgets normally mount into their HudLayer's anchor slot, which is why a
@@ -3377,7 +3423,7 @@ var UiSlider = class extends HudWidgetBase {
3377
3423
  const wrap = document.createElement("div");
3378
3424
  wrap.style.cssText = "display:flex;align-items:center;gap:8px;pointer-events:auto;font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;";
3379
3425
  const text = document.createElement("span");
3380
- text.textContent = this._t(this.label);
3426
+ text.textContent = this._labelText();
3381
3427
  const input = document.createElement("input");
3382
3428
  input.type = "range";
3383
3429
  input.min = String(this.min);
@@ -3390,7 +3436,7 @@ var UiSlider = class extends HudWidgetBase {
3390
3436
  this.last = this.value;
3391
3437
  this.emit("changed", this.value);
3392
3438
  });
3393
- if (this.label) wrap.appendChild(text);
3439
+ if (this._labelText()) wrap.appendChild(text);
3394
3440
  wrap.appendChild(input);
3395
3441
  this.input = input;
3396
3442
  this.text = text;
@@ -3402,7 +3448,7 @@ var UiSlider = class extends HudWidgetBase {
3402
3448
  this.last = this.value;
3403
3449
  this.input.value = String(this.value);
3404
3450
  }
3405
- const label = this._t(this.label);
3451
+ const label = this._labelText();
3406
3452
  if (this.text && this.text.textContent !== label) this.text.textContent = label;
3407
3453
  }
3408
3454
  };
@@ -3442,9 +3488,9 @@ var UiToggle = class extends HudWidgetBase {
3442
3488
  this.emit("changed", this.value);
3443
3489
  });
3444
3490
  const text = document.createElement("span");
3445
- text.textContent = this._t(this.label);
3491
+ text.textContent = this._labelText();
3446
3492
  this.tag = text;
3447
- this.lastLabel = this._t(this.label);
3493
+ this.lastLabel = this._labelText();
3448
3494
  wrap.append(input, text);
3449
3495
  this.input = input;
3450
3496
  this.last = this.value;
@@ -3457,7 +3503,7 @@ var UiToggle = class extends HudWidgetBase {
3457
3503
  this.last = this.value;
3458
3504
  this.input.checked = this.value;
3459
3505
  }
3460
- const label = this._t(this.label);
3506
+ const label = this._labelText();
3461
3507
  if (this.tag && label !== this.lastLabel) {
3462
3508
  this.lastLabel = label;
3463
3509
  this.tag.textContent = label;
@@ -3507,24 +3553,33 @@ var UiSelect = class extends HudWidgetBase {
3507
3553
  this.last = this.value;
3508
3554
  this.emit("changed", this.value);
3509
3555
  });
3510
- if (this.label) wrap.appendChild(text);
3556
+ if (this._labelText()) wrap.appendChild(text);
3511
3557
  wrap.appendChild(select);
3512
3558
  this.select = select;
3513
3559
  this.fillOptions();
3514
3560
  return wrap;
3515
3561
  }
3516
- /** Override point: what an option READS as, when it differs from its value. */
3562
+ /**
3563
+ * What an option READS as, when it differs from its value.
3564
+ *
3565
+ * The base resolves `@t:` like every other text prop: the VALUE stays exactly
3566
+ * what the author wrote — so a `changed` handler never has to know the active
3567
+ * language — and only the displayed words change. Without this a dropdown was
3568
+ * the one widget with no way to speak anything but English, and
3569
+ * `"options": "@t:diff.easy"` rendered that marker literally on screen.
3570
+ */
3517
3571
  _optionLabel(value) {
3518
- return value;
3572
+ return this._t(value);
3519
3573
  }
3520
3574
  fillOptions() {
3521
3575
  const select = this.select;
3522
- if (!select || this.options === this.lastOptions) return;
3523
- this.lastOptions = this.options;
3576
+ if (!select) return;
3577
+ const values = this.options.split(",").map((o) => o.trim()).filter(Boolean);
3578
+ const painted = values.map((v) => `${v}\u0000${this._optionLabel(v)}`).join("");
3579
+ if (painted === this.lastOptions) return;
3580
+ this.lastOptions = painted;
3524
3581
  select.textContent = "";
3525
- for (const raw of this.options.split(",")) {
3526
- const value = raw.trim();
3527
- if (!value) continue;
3582
+ for (const value of values) {
3528
3583
  const option = document.createElement("option");
3529
3584
  option.value = value;
3530
3585
  option.textContent = this._optionLabel(value);
@@ -3635,9 +3690,6 @@ var UiSettingSelect = class extends UiSelect {
3635
3690
  * `settings.quality.high` is worse than one reading `High`. English is the
3636
3691
  * base language and the fallback, always.
3637
3692
  */
3638
- _tOr(key, fallback) {
3639
- return translated(this._engine(), key, fallback);
3640
- }
3641
3693
  _sync() {
3642
3694
  const engine = this._engine();
3643
3695
  if (engine) {
@@ -3807,12 +3859,10 @@ var UiVolumeSlider = class extends UiSlider {
3807
3859
  bus = "master";
3808
3860
  /** Last value we PUSHED in, to tell an engine-side change from a player drag. */
3809
3861
  lastPushed = null;
3810
- _build() {
3811
- if (!this.label) {
3812
- const english = VOLUME_LABELS[this.bus] ?? VOLUME_LABELS.master;
3813
- this.label = translated(this.tree?.engine ?? null, `settings.volume.${this.bus}`, english);
3814
- }
3815
- return super._build();
3862
+ _labelText() {
3863
+ if (this.label) return this._t(this.label);
3864
+ const english = VOLUME_LABELS[this.bus] ?? VOLUME_LABELS.master;
3865
+ return this._tOr(`settings.volume.${this.bus}`, english);
3816
3866
  }
3817
3867
  _sync() {
3818
3868
  const audio = this.tree?.engine?.audio;
@@ -3846,9 +3896,8 @@ var UiMuteToggle = class extends UiToggle {
3846
3896
  label: { default: "" }
3847
3897
  };
3848
3898
  lastPushed = null;
3849
- _build() {
3850
- if (!this.label) this.label = translated(this.tree?.engine ?? null, "settings.mute", "Mute");
3851
- return super._build();
3899
+ _labelText() {
3900
+ return this.label ? this._t(this.label) : this._tOr("settings.mute", "Mute");
3852
3901
  }
3853
3902
  _sync() {
3854
3903
  const audio = this.tree?.engine?.audio;
@@ -3894,4 +3943,4 @@ function registerCoreNodes() {
3894
3943
  registerNode(UiMuteToggle);
3895
3944
  }
3896
3945
  //#endregion
3897
- export { UiText as A, spatialPan as B, suggestQuality as C, HudWidgetBase as D, HudLayer as E, translationKey as F, WebAudioMusicBackend as G, SFX_PRESET_NAMES as H, SfxEngine as I, AudioBuses as J, crossfadeGains as K, isAudioContextAvailable as L, Localization as M, T_PREFIX as N, UiBanner as O, suggestLocale as P, ROLLOFF_MODELS as R, readDeviceHints as S, EffectLog as T, synthSfx as U, SFX_PRESETS as V, MusicManager as W, Engine as _, UiMuteToggle as a, qualityEnvironment as b, UiRenderScaleSelect as c, UiToggle as d, UiVolumeSlider as f, AudioPlayer as g, UiDialogue as h, UiLanguageSelect as i, BASE_LOCALE as j, UiBar as k, UiSelect as l, UiButton as m, UiFrameCapSelect as n, UiPanel as o, Timer as p, fadeGain as q, UiImage as r, UiQualitySelect as s, registerCoreNodes as t, UiSlider as u, Settings as v, LogManager as w, qualityRendering as x, qualityCaps as y, spatialGain as z };
3946
+ export { UiBar as A, ROLLOFF_MODELS as B, readDeviceHints as C, HudLayer as D, EffectLog as E, suggestLocale as F, synthSfx as G, spatialPan as H, translateOn as I, crossfadeGains as J, MusicManager as K, translationKey as L, BASE_LOCALE as M, Localization as N, HudWidgetBase as O, T_PREFIX as P, SfxEngine as R, qualityRendering as S, LogManager as T, SFX_PRESETS as U, spatialGain as V, SFX_PRESET_NAMES as W, AudioBuses as X, fadeGain as Y, Engine as _, UiMuteToggle as a, qualityCaps as b, UiRenderScaleSelect as c, UiToggle as d, UiVolumeSlider as f, AudioPlayer as g, UiDialogue as h, UiLanguageSelect as i, UiText as j, UiBanner as k, UiSelect as l, UiButton as m, UiFrameCapSelect as n, UiPanel as o, Timer as p, WebAudioMusicBackend as q, UiImage as r, UiQualitySelect as s, registerCoreNodes as t, UiSlider as u, engine_exports as v, suggestQuality as w, qualityEnvironment as x, Settings as y, isAudioContextAvailable as z };
@@ -1,5 +1,5 @@
1
1
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
2
- import { r as jsonKind, t as jsonClone } from "./json-BLk7H2Qa.js";
2
+ import { r as jsonKind, t as jsonClone } from "./json-CwwhxQgb.js";
3
3
  //#region src/core/registry.ts
4
4
  /**
5
5
  * Validate a prop value against its schema default:
@@ -1,6 +1,6 @@
1
- import { E as behaviorSchema, w as parseNodePath } from "./loader-BcrRSjxB.js";
2
- import { D as HudWidgetBase, F as translationKey } from "./register-Ch70uByv.js";
3
- import { i as getNodeSchema, o as getNodeType } from "./registry-C7u42TID.js";
1
+ import { D as behaviorSchema, T as parseNodePath } from "./loader-D8n7TU8W.js";
2
+ import { L as translationKey, O as HudWidgetBase } from "./register-CDrAQqPp.js";
3
+ import { i as getNodeSchema, o as getNodeType } from "./registry-WWcQcfMr.js";
4
4
  //#region src/core/node-refs.ts
5
5
  /**
6
6
  * Node-path PROPS, resolved against scene JSON.
@@ -317,15 +317,28 @@ function auditScene(scene) {
317
317
  * screen. Reported here — at author time, where it can still be fixed — rather
318
318
  * than at runtime in front of someone playing.
319
319
  */
320
+ /**
321
+ * Close to `@t:` without being it — `@T:`, a leading space, `@t :`, `@t.`.
322
+ * Deliberately narrow: an at-sign in ordinary prose ("email me @ home") must
323
+ * not trip it.
324
+ */
325
+ const NEAR_MISS = /^\s*@\s*t\s*[:.]/i;
320
326
  function auditStrings(scene) {
321
327
  const tables = scene.strings;
322
328
  const declared = /* @__PURE__ */ new Set();
323
329
  for (const entries of Object.values(tables ?? {})) for (const key of Object.keys(entries)) declared.add(key);
330
+ const inBase = new Set(Object.keys(tables?.["en"] ?? {}));
324
331
  const missing = /* @__PURE__ */ new Set();
332
+ const notInEnglish = /* @__PURE__ */ new Set();
333
+ const nearMiss = /* @__PURE__ */ new Set();
325
334
  const walk = (value) => {
326
335
  if (typeof value === "string") {
336
+ if (translationKey(value) === null && NEAR_MISS.test(value)) nearMiss.add(value);
327
337
  const key = translationKey(value);
328
- if (key !== null && !declared.has(key)) missing.add(key);
338
+ if (key !== null) {
339
+ if (!declared.has(key)) missing.add(key);
340
+ else if (tables && !inBase.has(key)) notInEnglish.add(key);
341
+ }
329
342
  return;
330
343
  }
331
344
  if (Array.isArray(value)) {
@@ -335,9 +348,20 @@ function auditStrings(scene) {
335
348
  if (value !== null && typeof value === "object") for (const item of Object.values(value)) walk(item);
336
349
  };
337
350
  walk(scene.root);
338
- if (missing.size === 0) return [];
339
- const listed = [...missing].sort();
340
- return [`${listed.length} translation key${listed.length === 1 ? "" : "s"} nothing declares: ${listed.join(", ")}. The key itself renders on screen. Add them under "strings": { "en": { ... } } — other locales may omit any of them and fall back to English.`];
351
+ const out = [];
352
+ if (missing.size > 0) {
353
+ const listed = [...missing].sort();
354
+ out.push(`${listed.length} translation key${listed.length === 1 ? "" : "s"} nothing declares: ${listed.join(", ")}. The key itself renders on screen. Add them under "strings": { "en": { ... } } — other locales may omit any of them and fall back to English.`);
355
+ }
356
+ if (nearMiss.size > 0) {
357
+ const listed = [...nearMiss].sort();
358
+ out.push(`${listed.length} value${listed.length === 1 ? "" : "s"} look${listed.length === 1 ? "s" : ""} like a translation marker but ${listed.length === 1 ? "is" : "are"} not: ${listed.map((v) => JSON.stringify(v)).join(", ")}. It must be exactly "@t:key" — lower-case, no space. As written, the text itself renders on screen.`);
359
+ }
360
+ if (notInEnglish.size > 0) {
361
+ const listed = [...notInEnglish].sort();
362
+ out.push(`${listed.length} translation key${listed.length === 1 ? "" : "s"} missing from "en": ${listed.join(", ")}. English is the base every other locale falls back to, so these render as the raw key for anyone not using the locale that declares them.`);
363
+ }
364
+ return out;
341
365
  }
342
366
  //#endregion
343
367
  //#region src/core/replay.ts
@@ -1,4 +1,4 @@
1
- import { b as Engine } from "./behavior-l08AEbq9.js";
1
+ import { b as Engine } from "./behavior-DWKTUzKI.js";
2
2
  import { s as JsonObject } from "./schema-CFeioQRE.js";
3
3
 
4
4
  //#region src/core/audit.d.ts
@@ -1,5 +1,6 @@
1
- import { At as Node, It as Signal, b as Engine } from "./behavior-l08AEbq9.js";
1
+ import { At as Node, It as Signal, b as Engine } from "./behavior-DWKTUzKI.js";
2
2
  import { c as JsonValue, i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
3
+ import { t as LoadSceneOptions } from "./loader-TvkRFbyL.js";
3
4
 
4
5
  //#region src/net/types.d.ts
5
6
  type Unsubscribe = () => void;
@@ -277,6 +278,17 @@ interface NetworkManagerOptions {
277
278
  };
278
279
  /** Replication send window in ms (default 50; node `network.throttleMs` can lower it). */
279
280
  throttleMs?: number;
281
+ /**
282
+ * How often the FULL sync set is re-sent regardless of what changed, in ms
283
+ * (default 2000; 0 disables).
284
+ *
285
+ * Deltas are right on the wire and wrong in the store: backends shallow-merge
286
+ * one level down, so each send replaces the whole `sync` object and a key
287
+ * that stopped changing is erased from the authoritative state. A keyframe
288
+ * puts it back — for the next joiner, and for a send that never reached the
289
+ * wire, which is never retried.
290
+ */
291
+ keyframeMs?: number;
280
292
  }
281
293
  /**
282
294
  * Per-engine multiplayer hub: joins a room, exposes the room channels as core
@@ -308,6 +320,7 @@ declare class NetworkManager {
308
320
  private readonly server;
309
321
  private readonly engine;
310
322
  private readonly throttleMs;
323
+ private readonly keyframeMs;
311
324
  private readonly subs;
312
325
  private readonly messageSignals;
313
326
  private readonly collectionSignals;
@@ -320,8 +333,23 @@ declare class NetworkManager {
320
333
  /** Sync keys already reported unreadable, so a per-frame send says it once. */
321
334
  private readonly reportedDeadKeys;
322
335
  /** Name a sync key the owner cannot read — a typo in the prop or the path. */
336
+ /**
337
+ * A sync key that has NEVER been readable, reported once — after a grace
338
+ * period.
339
+ *
340
+ * Behaviours set their props in `onReady`/`update`, and replication runs on
341
+ * the fixed step, so a perfectly correct key reads as `undefined` on the
342
+ * first pass or two. Reporting immediately made the engine's own co-op
343
+ * example print "sync key 'firing' … it is never sent" about a key that is
344
+ * sent from frame 2 onward — a diagnostic that cries wolf teaches its reader
345
+ * to ignore the one that does not.
346
+ */
323
347
  private reportDeadSyncKey;
324
348
  private sendAccumulator;
349
+ /** Simulated ms since the last time the FULL sync set went out. */
350
+ private keyframeAccumulator;
351
+ /** Simulated ms this manager has been replicating — see reportDeadSyncKey. */
352
+ private aliveMs;
325
353
  private detachReplication;
326
354
  private lastOwner;
327
355
  private boundScene;
@@ -399,6 +427,15 @@ interface SplitScreenOptions extends LocalGameServerOptions {
399
427
  /** Engine seed base (each panel gets seed + index). */
400
428
  seed?: number;
401
429
  /**
430
+ * Loader options, per panel — `stubMissingBehaviors` for a headless check
431
+ * that has no TypeScript, `resolveScene` for `instance:` sub-scenes.
432
+ *
433
+ * Without these, `createSplitScreen` hard-failed on any real game: it called
434
+ * `loadScene` with nothing registered, so a scene naming one of its own
435
+ * behaviours threw `Unknown behavior` before a single frame ran.
436
+ */
437
+ load?: LoadSceneOptions;
438
+ /**
402
439
  * The room every panel joins. Default: whatever the FIRST panel got.
403
440
  *
404
441
  * A shared scene usually says `multiplayer: { room: "auto" }`, and "auto"
@@ -1,8 +1,8 @@
1
- import { C as diagnose, M as Signal, S as Node, n as loadScene, t as buildNodeJson } from "./loader-BcrRSjxB.js";
2
- import { _ as Engine, t as registerCoreNodes } from "./register-Ch70uByv.js";
1
+ import { C as Node, N as Signal, n as loadScene, t as buildNodeJson, w as diagnose } from "./loader-D8n7TU8W.js";
2
+ import { _ as Engine, t as registerCoreNodes } from "./register-CDrAQqPp.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
- import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
5
- import { l as registerNode } from "./registry-C7u42TID.js";
4
+ import { n as jsonEquals, t as jsonClone } from "./json-CwwhxQgb.js";
5
+ import { l as registerNode } from "./registry-WWcQcfMr.js";
6
6
  //#region src/net/loopback.ts
7
7
  /**
8
8
  * In-memory multiplayer hub implementing the SAME kernel contract as
@@ -771,6 +771,15 @@ var LocalGameServerTransport = class {
771
771
  };
772
772
  //#endregion
773
773
  //#region src/net/network-manager.ts
774
+ /**
775
+ * How long a sync key may read as `undefined` before it is called dead.
776
+ *
777
+ * Long enough for `onReady` and a few `update`s to have run; short enough that
778
+ * a genuinely wrong prop name is named while its author is still looking.
779
+ */
780
+ const DEAD_KEY_GRACE_MS = 500;
781
+ /** Full-state resend interval, in simulated ms. See `keyframeMs`. */
782
+ const DEFAULT_KEYFRAME_MS = 2e3;
774
783
  const managers = /* @__PURE__ */ new WeakMap();
775
784
  /**
776
785
  * Per-engine multiplayer hub: joins a room, exposes the room channels as core
@@ -802,6 +811,7 @@ var NetworkManager = class NetworkManager {
802
811
  server;
803
812
  engine;
804
813
  throttleMs;
814
+ keyframeMs;
805
815
  subs = [];
806
816
  messageSignals = /* @__PURE__ */ new Map();
807
817
  collectionSignals = /* @__PURE__ */ new Map();
@@ -814,12 +824,28 @@ var NetworkManager = class NetworkManager {
814
824
  /** Sync keys already reported unreadable, so a per-frame send says it once. */
815
825
  reportedDeadKeys = /* @__PURE__ */ new Set();
816
826
  /** Name a sync key the owner cannot read — a typo in the prop or the path. */
827
+ /**
828
+ * A sync key that has NEVER been readable, reported once — after a grace
829
+ * period.
830
+ *
831
+ * Behaviours set their props in `onReady`/`update`, and replication runs on
832
+ * the fixed step, so a perfectly correct key reads as `undefined` on the
833
+ * first pass or two. Reporting immediately made the engine's own co-op
834
+ * example print "sync key 'firing' … it is never sent" about a key that is
835
+ * sent from frame 2 onward — a diagnostic that cries wolf teaches its reader
836
+ * to ignore the one that does not.
837
+ */
817
838
  reportDeadSyncKey(owner, key) {
818
839
  if (this.reportedDeadKeys.has(key)) return;
840
+ if (this.aliveMs < DEAD_KEY_GRACE_MS) return;
819
841
  this.reportedDeadKeys.add(key);
820
842
  diagnose(owner.tree?.engine ?? null, "error", `[incanto] ${owner.getPath()}: sync key '${key}' reads as undefined — it is never sent, so remote players never see it change. Check the prop name and the node path.`);
821
843
  }
822
844
  sendAccumulator = 0;
845
+ /** Simulated ms since the last time the FULL sync set went out. */
846
+ keyframeAccumulator = 0;
847
+ /** Simulated ms this manager has been replicating — see reportDeadSyncKey. */
848
+ aliveMs = 0;
823
849
  detachReplication = null;
824
850
  lastOwner = null;
825
851
  boundScene = null;
@@ -831,16 +857,17 @@ var NetworkManager = class NetworkManager {
831
857
  const server = opts.transport ?? await createDefaultTransport(opts.config);
832
858
  await server.connect();
833
859
  const requested = opts.room ?? engine.scene?.multiplayer?.room ?? "auto";
834
- const manager = new NetworkManager(engine, server, await server.remoteFunction("joinRoom", [requested === "auto" ? void 0 : requested], { needResponse: true }), opts.throttleMs ?? 50);
860
+ const manager = new NetworkManager(engine, server, await server.remoteFunction("joinRoom", [requested === "auto" ? void 0 : requested], { needResponse: true }), opts.throttleMs ?? 50, opts.keyframeMs ?? DEFAULT_KEYFRAME_MS);
835
861
  managers.set(engine, manager);
836
862
  return manager;
837
863
  }
838
- constructor(engine, server, roomId, throttleMs) {
864
+ constructor(engine, server, roomId, throttleMs, keyframeMs) {
839
865
  this.engine = engine;
840
866
  this.server = server;
841
867
  this.roomId = roomId;
842
868
  this.account = server.account;
843
869
  this.throttleMs = Math.max(30, throttleMs);
870
+ this.keyframeMs = Math.max(0, keyframeMs);
844
871
  this.boundScene = engine.scene;
845
872
  this.subs.push(server.subscribeRoomState(roomId, (state) => {
846
873
  this.latestRoomState = state;
@@ -996,6 +1023,7 @@ var NetworkManager = class NetworkManager {
996
1023
  const scene = this.engine.scene;
997
1024
  if (!scene || scene !== this.boundScene) return;
998
1025
  this.sendAccumulator += dt * 1e3;
1026
+ this.aliveMs += dt * 1e3;
999
1027
  const owner = findOwnerNode(scene.root, true);
1000
1028
  if (!owner) return;
1001
1029
  if (owner !== this.lastOwner) {
@@ -1005,22 +1033,29 @@ var NetworkManager = class NetworkManager {
1005
1033
  const net = owner.network;
1006
1034
  const window = Math.max(30, typeof net.throttleMs === "number" ? net.throttleMs : this.throttleMs);
1007
1035
  const syncKeys = Array.isArray(net.sync) ? net.sync : [];
1008
- const patch = {};
1036
+ const full = {};
1037
+ let changed = false;
1009
1038
  for (const key of syncKeys) {
1010
1039
  const value = readSyncKey(owner, key);
1011
1040
  if (value === void 0) {
1012
1041
  this.reportDeadSyncKey(owner, key);
1013
1042
  continue;
1014
1043
  }
1015
- if (!jsonEquals(this.lastSent.get(key) ?? null, value)) patch[key] = jsonClone(value);
1044
+ full[key] = jsonClone(value);
1045
+ if (!jsonEquals(this.lastSent.get(key) ?? null, value)) changed = true;
1016
1046
  }
1047
+ this.keyframeAccumulator += dt * 1e3;
1048
+ const keyframeDue = this.keyframeMs > 0 && this.keyframeAccumulator >= this.keyframeMs;
1017
1049
  if (this.sendAccumulator < window) return;
1018
- if (Object.keys(patch).length === 0) return;
1019
- for (const [key, value] of Object.entries(patch)) this.lastSent.set(key, jsonClone(value));
1050
+ if (!changed && !keyframeDue) return;
1051
+ if (Object.keys(full).length === 0) return;
1052
+ for (const [key, value] of Object.entries(full)) this.lastSent.set(key, jsonClone(value));
1053
+ this.keyframeAccumulator = 0;
1020
1054
  this.sendAccumulator = 0;
1021
- this.setMyState({ sync: patch });
1055
+ this.setMyState({ sync: full });
1022
1056
  }
1023
1057
  };
1058
+ /** The single `network.mode === 'owner'` node in a tree, or null. @internal */
1024
1059
  function findOwnerNode(node, enforceSingle = false) {
1025
1060
  const owners = [];
1026
1061
  collectOwners(node, owners);
@@ -1032,6 +1067,13 @@ function collectOwners(node, out) {
1032
1067
  for (const c of node.children) collectOwners(c, out);
1033
1068
  }
1034
1069
  /** `'position'` → own prop; `'Sprite.animation'` → child path + prop (last dot splits). */
1070
+ /**
1071
+ * Read one `network.sync` key off an owner node, exactly as the sender does.
1072
+ *
1073
+ * Exported so an agreement check reads GROUND TRUTH through the same last-dot
1074
+ * split the protocol uses — a second implementation would eventually disagree
1075
+ * with this one, and then the check would be measuring itself.
1076
+ */
1035
1077
  function readSyncKey(node, key) {
1036
1078
  const lastDot = key.lastIndexOf(".");
1037
1079
  const target = lastDot === -1 ? node : node.getNodeOrNull(key.slice(0, lastDot)) ?? void 0;
@@ -1176,6 +1218,7 @@ var NetworkSpawner = class extends Node {
1176
1218
  }
1177
1219
  }
1178
1220
  };
1221
+ /** The child name a spawner gives an account's instance. @internal */
1179
1222
  function sanitizeName(key) {
1180
1223
  return key.replace(/[/%]/g, "_") || "remote";
1181
1224
  }
@@ -1218,7 +1261,7 @@ async function createSplitScreen(opts) {
1218
1261
  for (let i = 0; i < accounts.length; i++) {
1219
1262
  const account = accounts[i];
1220
1263
  const engine = new Engine(opts.seed !== void 0 ? { seed: opts.seed + i } : {});
1221
- engine.setScene(loadScene(jsonClone(opts.scene)));
1264
+ engine.setScene(loadScene(jsonClone(opts.scene), opts.load));
1222
1265
  const manager = await NetworkManager.create(engine, {
1223
1266
  transport: server.createClient(account),
1224
1267
  ...opts.room !== void 0 || i > 0 ? { room: opts.room ?? players[0].manager.roomId } : {}
@@ -1275,4 +1318,4 @@ function hasBodyNamed(node, base) {
1275
1318
  return false;
1276
1319
  }
1277
1320
  //#endregion
1278
- export { applySyncPatch as a, createLocalGameServer as c, NetworkManager as i, LoopbackHub as l, registerNodesNet as n, LocalGameServer as o, NetworkSpawner as r, LocalGameServerTransport as s, createSplitScreen as t, LoopbackTransport as u };
1321
+ export { NetworkManager as a, readSyncKey as c, createLocalGameServer as d, LoopbackHub as f, sanitizeName as i, LocalGameServer as l, registerNodesNet as n, applySyncPatch as o, LoopbackTransport as p, NetworkSpawner as r, findOwnerNode as s, createSplitScreen as t, LocalGameServerTransport as u };
@@ -1,5 +1,5 @@
1
1
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
2
- import { n as jsonEquals } from "./json-BLk7H2Qa.js";
2
+ import { n as jsonEquals } from "./json-CwwhxQgb.js";
3
3
  //#region src/core/particle-presets.ts
4
4
  const PARTICLE_PRESETS = {
5
5
  fire: {
@@ -214,6 +214,6 @@ function newUid() {
214
214
  //#endregion
215
215
  //#region src/index.ts
216
216
  /** Engine version. Kept in sync with package.json by the release pipeline. */
217
- const VERSION = "0.58.0";
217
+ const VERSION = "0.59.0";
218
218
  //#endregion
219
219
  export { preloadUrls as a, preloadSceneAssets as i, newUid as n, findPath as o, assetUrls as r, gridFromRows as s, VERSION as t };
@@ -1,4 +1,4 @@
1
- import { g as AudioPlayer } from "./register-Ch70uByv.js";
1
+ import { g as AudioPlayer } from "./register-CDrAQqPp.js";
2
2
  import { a as logReport, s as parseDrive } from "./touch-DESwnpOc.js";
3
3
  import { a as frameSignature, i as frameImage, o as frameStats } from "./frame-report-BSMny7oe.js";
4
4
  //#region src/core/audio-errors.ts