@jgengine/core 0.19.0 → 0.22.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 (266) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/dist/ai/decisionGraph.d.ts +2 -0
  3. package/dist/ai/decisionGraph.js +6 -0
  4. package/dist/ai/flock.d.ts +1 -1
  5. package/dist/ai/flock.js +7 -4
  6. package/dist/anim/animGraph.d.ts +33 -2
  7. package/dist/anim/animGraph.js +200 -12
  8. package/dist/anim/locomotionGraph.d.ts +11 -3
  9. package/dist/anim/locomotionGraph.js +25 -15
  10. package/dist/audio/audioFalloff.d.ts +4 -0
  11. package/dist/audio/voiceAllocator.d.ts +46 -0
  12. package/dist/audio/voiceAllocator.js +76 -0
  13. package/dist/combat/abilityKit.d.ts +19 -0
  14. package/dist/combat/abilityKit.js +40 -4
  15. package/dist/combat/death.d.ts +4 -3
  16. package/dist/combat/death.js +3 -3
  17. package/dist/combat/magazine.d.ts +9 -0
  18. package/dist/combat/magazine.js +24 -2
  19. package/dist/combat/projectiles.d.ts +94 -2
  20. package/dist/combat/projectiles.js +282 -61
  21. package/dist/combat/regenShield.d.ts +14 -0
  22. package/dist/combat/regenShield.js +23 -7
  23. package/dist/crafting/recipeCatalog.d.ts +16 -0
  24. package/dist/crafting/recipeCatalog.js +55 -0
  25. package/dist/economy/auctionBook.d.ts +1 -1
  26. package/dist/economy/auctionBook.js +11 -1
  27. package/dist/economy/listingBook.d.ts +1 -1
  28. package/dist/economy/listingBook.js +11 -1
  29. package/dist/economy/resourceLedger.js +3 -3
  30. package/dist/economy/shopStock.js +7 -6
  31. package/dist/economy/techTree.js +7 -6
  32. package/dist/economy/unlockPoints.js +3 -6
  33. package/dist/editor/commandApply.js +89 -16
  34. package/dist/editor/commands.d.ts +38 -1
  35. package/dist/editor/commands.js +320 -3
  36. package/dist/editor/creatorStorage.d.ts +59 -0
  37. package/dist/editor/creatorStorage.js +164 -0
  38. package/dist/editor/document.d.ts +4 -0
  39. package/dist/editor/document.js +116 -17
  40. package/dist/editor/index.d.ts +3 -0
  41. package/dist/editor/index.js +1 -0
  42. package/dist/editor/liveSync.d.ts +4 -1
  43. package/dist/editor/liveSync.js +26 -10
  44. package/dist/editor/materialAuthoring.d.ts +14 -0
  45. package/dist/editor/materialAuthoring.js +38 -0
  46. package/dist/editor/modelNodeVisibility.d.ts +11 -0
  47. package/dist/editor/modelNodeVisibility.js +29 -0
  48. package/dist/editor/movementCatalog.d.ts +90 -0
  49. package/dist/editor/movementCatalog.js +144 -0
  50. package/dist/editor/simulation.d.ts +60 -0
  51. package/dist/editor/simulation.js +202 -0
  52. package/dist/editor/staticPrefab.d.ts +17 -0
  53. package/dist/editor/staticPrefab.js +51 -0
  54. package/dist/editor/terrainMaterials.d.ts +10 -0
  55. package/dist/editor/terrainMaterials.js +13 -0
  56. package/dist/editor/types.d.ts +11 -0
  57. package/dist/game/cameraConfig.d.ts +3 -1
  58. package/dist/game/clipRoles.d.ts +2 -1
  59. package/dist/game/clipRoles.js +7 -3
  60. package/dist/game/contentValidation.d.ts +69 -0
  61. package/dist/game/contentValidation.js +118 -0
  62. package/dist/game/controlGate.d.ts +8 -0
  63. package/dist/game/controlGate.js +43 -6
  64. package/dist/game/defineGame.d.ts +32 -3
  65. package/dist/game/dialogueGraph.d.ts +20 -0
  66. package/dist/game/dialogueGraph.js +107 -0
  67. package/dist/game/events.d.ts +5 -0
  68. package/dist/game/keyValueStore.d.ts +17 -4
  69. package/dist/game/keyValueStore.js +35 -23
  70. package/dist/game/lootModifiers.d.ts +17 -0
  71. package/dist/game/lootModifiers.js +26 -0
  72. package/dist/game/narrativeAuthoring.d.ts +67 -0
  73. package/dist/game/narrativeAuthoring.js +122 -0
  74. package/dist/game/playableGame.d.ts +52 -20
  75. package/dist/game/quest.d.ts +15 -0
  76. package/dist/game/quest.js +209 -63
  77. package/dist/game/questCatalog.d.ts +50 -0
  78. package/dist/game/questCatalog.js +205 -0
  79. package/dist/game/saveStore.d.ts +6 -5
  80. package/dist/game/saveStore.js +57 -57
  81. package/dist/game/systemRuntime.d.ts +2 -2
  82. package/dist/game/systemRuntime.js +84 -33
  83. package/dist/gameplay.d.ts +3 -0
  84. package/dist/gameplay.js +3 -0
  85. package/dist/interaction/promptRegistry.d.ts +5 -4
  86. package/dist/interaction/promptRegistry.js +8 -2
  87. package/dist/inventory/inventoryModel.js +9 -6
  88. package/dist/inventory/shapedGrid.d.ts +48 -9
  89. package/dist/inventory/shapedGrid.js +69 -10
  90. package/dist/item/durability.js +4 -0
  91. package/dist/item/itemInstanceRegistry.d.ts +32 -1
  92. package/dist/item/itemInstanceRegistry.js +111 -7
  93. package/dist/item/itemgen.d.ts +1 -1
  94. package/dist/item/itemgen.js +1 -1
  95. package/dist/item/modularItem.js +7 -1
  96. package/dist/material/appearanceSignals.d.ts +53 -0
  97. package/dist/material/appearanceSignals.js +47 -0
  98. package/dist/material/hairCards.d.ts +47 -0
  99. package/dist/material/hairCards.js +136 -0
  100. package/dist/material/materialAsset.d.ts +162 -0
  101. package/dist/material/materialAsset.js +382 -0
  102. package/dist/meta/changelog.d.ts +1 -1
  103. package/dist/meta/changelog.js +348 -1
  104. package/dist/movement/characterController.d.ts +5 -2
  105. package/dist/movement/characterController.js +50 -23
  106. package/dist/movement/freeFlight.d.ts +2 -2
  107. package/dist/movement/freeFlight.js +3 -3
  108. package/dist/movement/movementModel.d.ts +24 -1
  109. package/dist/movement/movementModel.js +74 -7
  110. package/dist/movement/playerMovement.d.ts +28 -1
  111. package/dist/movement/playerMovement.js +425 -102
  112. package/dist/movement/poseState.d.ts +2 -0
  113. package/dist/movement/poseState.js +29 -1
  114. package/dist/movement/solidObstacles.d.ts +3 -1
  115. package/dist/movement/solidObstacles.js +8 -5
  116. package/dist/movement/terrainGrade.d.ts +13 -0
  117. package/dist/movement/terrainGrade.js +33 -0
  118. package/dist/movement/voxelController.d.ts +2 -2
  119. package/dist/movement/voxelController.js +3 -3
  120. package/dist/nav/navGrid.d.ts +19 -1
  121. package/dist/nav/navGrid.js +26 -10
  122. package/dist/nav/solidRoute.d.ts +50 -0
  123. package/dist/nav/solidRoute.js +144 -0
  124. package/dist/physics/ballisticSweep.d.ts +10 -3
  125. package/dist/physics/ballisticSweep.js +53 -29
  126. package/dist/physics/forceVolume.d.ts +41 -0
  127. package/dist/physics/forceVolume.js +88 -0
  128. package/dist/physics/kinematicVehicle.d.ts +8 -3
  129. package/dist/physics/kinematicVehicle.js +2 -2
  130. package/dist/physics/physicsWorldBackend.js +19 -3
  131. package/dist/physics/spatialGrid.d.ts +1 -1
  132. package/dist/physics/spatialGrid.js +8 -9
  133. package/dist/physics/vehicleDynamics.d.ts +3 -2
  134. package/dist/physics/vehicleDynamics.js +2 -2
  135. package/dist/physics/vehicleObstacles.d.ts +3 -2
  136. package/dist/physics/vehicleObstacles.js +29 -5
  137. package/dist/runtime/cameraDirector.d.ts +39 -2
  138. package/dist/runtime/cameraDirector.js +162 -0
  139. package/dist/runtime/commandDispatch.d.ts +14 -0
  140. package/dist/runtime/commandDispatch.js +19 -0
  141. package/dist/runtime/context/combat.d.ts +11 -4
  142. package/dist/runtime/context/combat.js +178 -11
  143. package/dist/runtime/context/deathLoot.d.ts +9 -8
  144. package/dist/runtime/context/deathLoot.js +16 -16
  145. package/dist/runtime/context/scene.d.ts +2 -0
  146. package/dist/runtime/context/scene.js +13 -21
  147. package/dist/runtime/descriptors/baseline.js +34 -3
  148. package/dist/runtime/descriptors/features.d.ts +2 -1
  149. package/dist/runtime/descriptors/features.js +18 -0
  150. package/dist/runtime/gameContext.js +79 -13
  151. package/dist/runtime/gameContextTypes.d.ts +14 -0
  152. package/dist/runtime/headlessRunner.d.ts +3 -1
  153. package/dist/runtime/headlessRunner.js +11 -11
  154. package/dist/runtime/hostedGameRunner.d.ts +24 -1
  155. package/dist/runtime/hostedGameRunner.js +156 -10
  156. package/dist/runtime/hostedWorldSession.d.ts +17 -5
  157. package/dist/runtime/hostedWorldSession.js +154 -16
  158. package/dist/runtime/hostedWorldStore.d.ts +2 -2
  159. package/dist/runtime/inputRecorder.d.ts +1 -1
  160. package/dist/runtime/inputRecorder.js +21 -1
  161. package/dist/runtime/inputSnapshot.d.ts +16 -4
  162. package/dist/runtime/inputSnapshot.js +31 -3
  163. package/dist/runtime/runtimeSave.d.ts +10 -6
  164. package/dist/runtime/runtimeSave.js +11 -5
  165. package/dist/runtime/simContext.d.ts +8 -3
  166. package/dist/runtime/simContext.js +7 -3
  167. package/dist/runtime/simSnapshot.js +2 -0
  168. package/dist/runtime/worldReplication.d.ts +2 -0
  169. package/dist/runtime/worldReplication.js +1 -1
  170. package/dist/scene/assetCatalog.d.ts +2 -0
  171. package/dist/scene/behaviorRuntime.d.ts +26 -5
  172. package/dist/scene/behaviorRuntime.js +263 -16
  173. package/dist/scene/behaviors.d.ts +30 -1
  174. package/dist/scene/behaviors.js +29 -0
  175. package/dist/scene/entityStore.d.ts +14 -3
  176. package/dist/scene/entityStore.js +45 -9
  177. package/dist/scene/mount.d.ts +9 -0
  178. package/dist/scene/mount.js +35 -0
  179. package/dist/scene/possession.d.ts +2 -0
  180. package/dist/scene/possession.js +21 -1
  181. package/dist/scene/sceneRaycast.d.ts +7 -0
  182. package/dist/scene/sceneRaycast.js +55 -0
  183. package/dist/scene/spatial.d.ts +20 -1
  184. package/dist/scene/spatial.js +94 -2
  185. package/dist/scene/vehicleSeat.d.ts +36 -2
  186. package/dist/scene/vehicleSeat.js +89 -2
  187. package/dist/stats/accumulatorMeter.d.ts +10 -0
  188. package/dist/stats/accumulatorMeter.js +6 -0
  189. package/dist/stats/eventMeter.d.ts +9 -2
  190. package/dist/stats/eventMeter.js +5 -2
  191. package/dist/store/defineStore.d.ts +3 -1
  192. package/dist/store/defineStore.js +3 -1
  193. package/dist/store/observableKeyedStore.d.ts +1 -1
  194. package/dist/store/observableKeyedStore.js +8 -6
  195. package/dist/time/simClock.d.ts +6 -2
  196. package/dist/time/simClock.js +5 -0
  197. package/dist/vfx/particleDirector.d.ts +58 -6
  198. package/dist/vfx/particleDirector.js +113 -22
  199. package/dist/vfx/particles.d.ts +68 -5
  200. package/dist/vfx/particles.js +284 -32
  201. package/dist/visibility/assetStreaming.d.ts +21 -3
  202. package/dist/visibility/assetStreaming.js +108 -24
  203. package/dist/visibility/settings.d.ts +8 -2
  204. package/dist/visibility/settings.js +3 -0
  205. package/dist/visibility/visibilitySystem.js +1 -0
  206. package/dist/work/expedition.d.ts +110 -0
  207. package/dist/work/expedition.js +195 -0
  208. package/dist/work/staffedStation.d.ts +29 -0
  209. package/dist/work/staffedStation.js +27 -0
  210. package/dist/world/authoredAnimation.d.ts +38 -0
  211. package/dist/world/authoredAnimation.js +99 -0
  212. package/dist/world/authoredObjects.d.ts +24 -4
  213. package/dist/world/authoredObjects.js +43 -16
  214. package/dist/world/authoredSimulation.d.ts +74 -0
  215. package/dist/world/authoredSimulation.js +302 -0
  216. package/dist/world/authoredWeather.d.ts +120 -0
  217. package/dist/world/authoredWeather.js +314 -0
  218. package/dist/world/buildingKit.d.ts +8 -3
  219. package/dist/world/buildingKit.js +21 -8
  220. package/dist/world/environmentForces.d.ts +21 -0
  221. package/dist/world/environmentForces.js +42 -0
  222. package/dist/world/features.d.ts +3 -3
  223. package/dist/world/footprintGrid.d.ts +55 -0
  224. package/dist/world/footprintGrid.js +201 -0
  225. package/dist/world/grassKind.d.ts +10 -0
  226. package/dist/world/grassKind.js +22 -0
  227. package/dist/world/pathTerrain.d.ts +1 -1
  228. package/dist/world/terraform.d.ts +2 -2
  229. package/dist/world/terraform.js +46 -18
  230. package/dist/world/weather.d.ts +17 -0
  231. package/dist/world/weather.js +116 -20
  232. package/dist/world/worldSolids.d.ts +11 -0
  233. package/dist/world/worldSolids.js +112 -1
  234. package/dist/world.d.ts +5 -5
  235. package/dist/world.js +4 -4
  236. package/package.json +1 -1
  237. package/skills/ce-handoff/SKILL.md +1 -1
  238. package/skills/game-design/SKILL.md +2 -0
  239. package/skills/jgengine/SKILL.md +23 -2
  240. package/skills/jgengine/api.md +28 -17
  241. package/skills/jgengine/recipes/minimal-game.md +7 -2
  242. package/skills/jgengine-combat/SKILL.md +8 -2
  243. package/skills/jgengine-combat/api.md +22 -10
  244. package/skills/jgengine-combat/recipes/portable-runtime-state.md +60 -6
  245. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +37 -3
  246. package/skills/jgengine-combat/reference.md +24 -2
  247. package/skills/jgengine-gameplay/SKILL.md +63 -0
  248. package/skills/jgengine-gameplay/api.md +131 -37
  249. package/skills/jgengine-gameplay/capabilities.md +68 -0
  250. package/skills/jgengine-gameplay/recipes/device-save-status.md +46 -0
  251. package/skills/jgengine-gameplay/recipes/expeditions.md +108 -0
  252. package/skills/jgengine-gameplay/recipes/narrative-authoring.md +76 -0
  253. package/skills/jgengine-gameplay/recipes/paged-item-storage.md +101 -0
  254. package/skills/jgengine-gameplay/recipes/quest-authoring.md +63 -0
  255. package/skills/jgengine-gameplay/recipes/shaped-inventory.md +50 -0
  256. package/skills/jgengine-gameplay/reference-content-validation.md +74 -0
  257. package/skills/jgengine-gameplay/reference-systems.md +119 -0
  258. package/skills/jgengine-world/SKILL.md +12 -3
  259. package/skills/jgengine-world/api.md +201 -58
  260. package/skills/jgengine-world/capabilities.md +116 -3
  261. package/skills/jgengine-world/recipes/character-animation.md +31 -8
  262. package/skills/jgengine-world/recipes/controls.md +4 -0
  263. package/skills/jgengine-world/recipes/npc-senses.md +12 -0
  264. package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
  265. package/skills/jgengine-world/reference.md +167 -5
  266. package/skills/jgengine-world/visibility.md +28 -1
@@ -31,6 +31,14 @@ export interface RegenShieldConfig {
31
31
  */
32
32
  pool?: RegenShieldPool;
33
33
  }
34
+ /** Complete mutable shield state, including pending external damage detection. */
35
+ export interface RegenShieldState {
36
+ max: number;
37
+ current: number;
38
+ sinceDamageMs: number;
39
+ activeDelayMs: number;
40
+ observed: number | null;
41
+ }
34
42
  /**
35
43
  * A shield pool that stops regenerating for `regenDelayMs` after every hit, then refills at
36
44
  * `regenPerSecond` — the delayed-regen primitive that replaces snapshot-comparing stat values per
@@ -38,6 +46,10 @@ export interface RegenShieldConfig {
38
46
  * regenerates once it elapses.
39
47
  */
40
48
  export interface RegenShield {
49
+ /** Detached pool value, bounds, and regen timing. */
50
+ state(): RegenShieldState;
51
+ /** Restore state; returns false without mutation when an external pool's max differs. */
52
+ restore(next: RegenShieldState): boolean;
41
53
  current(): number;
42
54
  max(): number;
43
55
  setMax(value: number): void;
@@ -49,6 +61,8 @@ export interface RegenShield {
49
61
  /** Absorb `amount` and reset the regen grace timer; returns damage that got through (0 while the shield holds). */
50
62
  damage(amount: number): number;
51
63
  /** Add `amount` without touching the regen timer (a pickup or scripted refill). */
64
+ refill(amount: number): void;
65
+ /** @deprecated Use `refill(amount)` for pickups; `restore(state)` loads persisted state. */
52
66
  restore(amount: number): void;
53
67
  set(value: number): void;
54
68
  /**
@@ -39,7 +39,30 @@ export function createRegenShield(config) {
39
39
  sinceDamageMs = 0;
40
40
  observed = live;
41
41
  }
42
+ function refill(amount) {
43
+ if (amount <= 0)
44
+ return;
45
+ syncExternal();
46
+ write(readCurrent() + amount);
47
+ observed = pool === undefined ? observed : readCurrent();
48
+ }
49
+ function restore(next) {
50
+ if (typeof next === "number")
51
+ return refill(next);
52
+ if (pool !== undefined && readMax() !== next.max)
53
+ return false;
54
+ if (pool === undefined)
55
+ ownMax = Math.max(0, next.max);
56
+ write(next.current);
57
+ sinceDamageMs = Math.max(0, next.sinceDamageMs);
58
+ activeDelayMs = Math.max(0, next.activeDelayMs);
59
+ observed = pool === undefined ? null : next.observed;
60
+ return true;
61
+ }
42
62
  return {
63
+ state: () => ({ max: readMax(), current: readCurrent(), sinceDamageMs, activeDelayMs, observed }),
64
+ restore,
65
+ refill,
43
66
  current: readCurrent,
44
67
  max: readMax,
45
68
  setMax(value) {
@@ -65,13 +88,6 @@ export function createRegenShield(config) {
65
88
  observed = pool === undefined ? observed : readCurrent();
66
89
  return overflow;
67
90
  },
68
- restore(amount) {
69
- if (amount <= 0)
70
- return;
71
- syncExternal();
72
- write(readCurrent() + amount);
73
- observed = pool === undefined ? observed : readCurrent();
74
- },
75
91
  set(value) {
76
92
  write(value);
77
93
  observed = pool === undefined ? observed : readCurrent();
@@ -0,0 +1,16 @@
1
+ import type { ContentIssue } from "../game/contentValidation.js";
2
+ import type { RecipeDef } from "./recipe.js";
3
+ /** Caller catalogs for native crafting references; omission leaves that namespace unchecked. */
4
+ export interface RecipeCatalogValidationOptions {
5
+ hasReference?(kind: "item" | "station" | "unlock", id: string): boolean;
6
+ }
7
+ /**
8
+ * Validate native recipes before registration: ids, counts, timing and caller-catalog references.
9
+ * Empty outputs are valid for game-owned benefits; fractional and zero counts are allowed.
10
+ * Negative/nonfinite counts, durations and ranges reject. Duplicate input rows reject because the
11
+ * crafting input check does not aggregate them; consolidate their counts into one row.
12
+ * No price, scarcity, story or cycle policy is imposed. O(recipes + item rows + requirements).
13
+ *
14
+ * @capability recipe-catalog-validation locate broken crafting item/station/unlock references, duplicate inputs and invalid authored quantities with repair guidance
15
+ */
16
+ export declare function validateRecipeCatalog(recipes: readonly RecipeDef[], options?: RecipeCatalogValidationOptions): ContentIssue[];
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Validate native recipes before registration: ids, counts, timing and caller-catalog references.
3
+ * Empty outputs are valid for game-owned benefits; fractional and zero counts are allowed.
4
+ * Negative/nonfinite counts, durations and ranges reject. Duplicate input rows reject because the
5
+ * crafting input check does not aggregate them; consolidate their counts into one row.
6
+ * No price, scarcity, story or cycle policy is imposed. O(recipes + item rows + requirements).
7
+ *
8
+ * @capability recipe-catalog-validation locate broken crafting item/station/unlock references, duplicate inputs and invalid authored quantities with repair guidance
9
+ */
10
+ export function validateRecipeCatalog(recipes, options = {}) {
11
+ const issues = [];
12
+ const ids = new Set();
13
+ for (let index = 0; index < recipes.length; index++) {
14
+ const recipe = recipes[index];
15
+ const path = `/${index}`;
16
+ const add = (code, field, message, repair, referenceId) => {
17
+ issues.push({ code, severity: "error", path: `${path}/${field}`, contentId: recipe.id,
18
+ ...(referenceId === undefined ? {} : { referenceId }), message, repair });
19
+ };
20
+ const reference = (kind, id, field) => {
21
+ if (options.hasReference?.(kind, id) === false) {
22
+ add("unknown-reference", field, `Recipe "${recipe.id}" references unknown ${kind} "${id}".`, `Correct this reference or declare "${id}" in the ${kind} catalog.`, id);
23
+ }
24
+ };
25
+ if (ids.has(recipe.id))
26
+ add("duplicate-recipe", "id", `Recipe id "${recipe.id}" repeats.`, "Give recipes distinct ids and update their references, or remove the duplicate.", recipe.id);
27
+ ids.add(recipe.id);
28
+ for (const direction of ["inputs", "outputs"]) {
29
+ const inputs = new Set();
30
+ for (let row = 0; row < recipe[direction].length; row++) {
31
+ const item = recipe[direction][row];
32
+ const field = `${direction}/${row}`;
33
+ reference("item", item.itemId, `${field}/itemId`);
34
+ if (!Number.isFinite(item.count) || item.count < 0) {
35
+ add("invalid-count", `${field}/count`, "Recipe count must be finite and nonnegative.", "Use a finite nonnegative count; enforce any stricter discrete-unit policy in the game.");
36
+ }
37
+ if (direction === "inputs" && inputs.has(item.itemId)) {
38
+ add("duplicate-input", `${field}/itemId`, `Recipe "${recipe.id}" repeats input "${item.itemId}".`, "Combine this item's input counts into one row so the crafting check sees the total.", item.itemId);
39
+ }
40
+ inputs.add(item.itemId);
41
+ }
42
+ }
43
+ if (recipe.station !== undefined)
44
+ reference("station", recipe.station, "station");
45
+ for (const [row, id] of (recipe.requires ?? []).entries())
46
+ reference("unlock", id, `requires/${row}`);
47
+ for (const field of ["seconds", "stationRange"]) {
48
+ const value = recipe[field];
49
+ if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
50
+ add("invalid-number", field, `Recipe ${field} must be finite and nonnegative.`, "Use a finite nonnegative value, or omit the optional field.");
51
+ }
52
+ }
53
+ }
54
+ return issues;
55
+ }
@@ -44,7 +44,7 @@ export interface PostAuctionInput {
44
44
  now: number;
45
45
  }
46
46
  /** Why {@link AuctionBook.post} refused an auction. */
47
- export type PostAuctionReason = "invalid-count" | "invalid-price" | "invalid-increment" | "invalid-buyout" | "auction-cap-reached";
47
+ export type PostAuctionReason = "invalid-count" | "invalid-price" | "invalid-increment" | "invalid-buyout" | "auction-cap-reached" | "duplicate-id";
48
48
  /** Result of {@link AuctionBook.post}. */
49
49
  export type PostAuctionResult = {
50
50
  status: "ok";
@@ -92,7 +92,15 @@ export function createAuctionBook(config) {
92
92
  if (countOf(input.sellerId) >= config.maxAuctionsPerSeller) {
93
93
  return { status: "rejected", reason: "auction-cap-reached" };
94
94
  }
95
- const id = input.id ?? `auction_${(counter += 1)}`;
95
+ let id = input.id;
96
+ if (id === undefined) {
97
+ do {
98
+ id = `auction_${(counter += 1)}`;
99
+ } while (auctions.has(id));
100
+ }
101
+ else if (auctions.has(id)) {
102
+ return { status: "rejected", reason: "duplicate-id" };
103
+ }
96
104
  const state = {
97
105
  id,
98
106
  sellerId: input.sellerId,
@@ -196,6 +204,8 @@ export function createAuctionBook(config) {
196
204
  return claimed;
197
205
  },
198
206
  claimItem(playerId, itemId, count) {
207
+ if (!Number.isInteger(count) || count < 1)
208
+ return false;
199
209
  const box = boxOf(playerId);
200
210
  const index = box.items.findIndex((stack) => stack.itemId === itemId);
201
211
  if (index === -1)
@@ -29,7 +29,7 @@ export interface PostListingInput {
29
29
  now: number;
30
30
  }
31
31
  /** Why {@link ListingBook.post} refused a listing. */
32
- export type PostListingReason = "invalid-count" | "invalid-price" | "price-too-low" | "price-too-high" | "listing-cap-reached";
32
+ export type PostListingReason = "invalid-count" | "invalid-price" | "price-too-low" | "price-too-high" | "listing-cap-reached" | "duplicate-id";
33
33
  /** Result of {@link ListingBook.post}. */
34
34
  export type PostListingResult = {
35
35
  status: "ok";
@@ -55,7 +55,15 @@ export function createListingBook(config) {
55
55
  if (countOf(input.sellerId) >= config.maxListingsPerSeller) {
56
56
  return { status: "rejected", reason: "listing-cap-reached" };
57
57
  }
58
- const id = input.id ?? `listing_${(counter += 1)}`;
58
+ let id = input.id;
59
+ if (id === undefined) {
60
+ do {
61
+ id = `listing_${(counter += 1)}`;
62
+ } while (listings.has(id));
63
+ }
64
+ else if (listings.has(id)) {
65
+ return { status: "rejected", reason: "duplicate-id" };
66
+ }
59
67
  const listing = {
60
68
  id,
61
69
  sellerId: input.sellerId,
@@ -127,6 +135,8 @@ export function createListingBook(config) {
127
135
  return claimed;
128
136
  },
129
137
  claimItem(sellerId, itemId, count) {
138
+ if (!Number.isInteger(count) || count < 0)
139
+ return false;
130
140
  const box = boxOf(sellerId);
131
141
  const index = box.items.findIndex((stack) => stack.itemId === itemId);
132
142
  if (index === -1)
@@ -116,7 +116,7 @@ export function advanceLedger(ledger, toSeconds, options = {}) {
116
116
  const catchUp = rule.catchUp ?? "each";
117
117
  // Reserve-capped nominal amount per candidate cycle, in chronological order.
118
118
  const nominal = reserveCappedAmounts(dueCount, rule.amount, cursor.reserveRemaining);
119
- const depleted = cursor.reserveRemaining !== undefined && nominal.remaining <= EPSILON;
119
+ const depleted = cursor.reserveRemaining !== undefined && nominal.remaining <= 0;
120
120
  const cycles = buildCycles(catchUp, nominal.amounts, firstDue, rule.everySeconds, perRuleLimit, maxCycles);
121
121
  if (wasFirstFire && cycles.settled.length > 0) {
122
122
  events.push({ kind: "started", ruleId: id, atSeconds: cycles.settled[0].atSeconds, detail: 0 });
@@ -200,12 +200,12 @@ function reserveCappedAmounts(count, amount, reserveRemaining) {
200
200
  }
201
201
  const amounts = [];
202
202
  let remaining = reserveRemaining;
203
- for (let i = 0; i < count && remaining > EPSILON; i += 1) {
203
+ for (let i = 0; i < count && remaining > 0; i += 1) {
204
204
  const take = Math.min(amount, remaining);
205
205
  amounts.push(take);
206
206
  remaining -= take;
207
207
  }
208
- if (remaining < EPSILON)
208
+ if (remaining < 0)
209
209
  remaining = 0;
210
210
  return { amounts, remaining, consumed: reserveRemaining - remaining };
211
211
  }
@@ -53,17 +53,17 @@ function validateEntry(entry) {
53
53
  */
54
54
  export function createShopStock(config = {}) {
55
55
  // Insertion-ordered map of live records (Map preserves insertion order for list()/snapshot()).
56
- const entries = new Map();
56
+ let entries = new Map();
57
57
  const listeners = new Set();
58
58
  function notify() {
59
59
  for (const listener of listeners)
60
60
  listener();
61
61
  }
62
- function ingest(entry) {
62
+ function ingest(entry, target = entries) {
63
63
  validateEntry(entry);
64
- if (entries.has(entry.id))
64
+ if (target.has(entry.id))
65
65
  throw new Error(`duplicate shop entry id: ${entry.id}`);
66
- entries.set(entry.id, cloneEntry(entry));
66
+ target.set(entry.id, cloneEntry(entry));
67
67
  }
68
68
  for (const entry of config.entries ?? [])
69
69
  ingest(entry);
@@ -148,9 +148,10 @@ export function createShopStock(config = {}) {
148
148
  return { entries: Array.from(entries.values(), cloneEntry) };
149
149
  },
150
150
  restore(snapshot) {
151
- entries.clear();
151
+ const next = new Map();
152
152
  for (const entry of snapshot.entries)
153
- ingest(entry);
153
+ ingest(entry, next);
154
+ entries = next;
154
155
  notify();
155
156
  },
156
157
  };
@@ -50,15 +50,16 @@ export function createTechTree(defs = []) {
50
50
  const nodes = new Map();
51
51
  for (const def of defs)
52
52
  nodes.set(def.id, def);
53
- const unlocks = createUnlocks(defs.map((d) => ({ id: d.id, category: d.category })));
53
+ const definitions = Array.from(nodes.values());
54
+ const unlocks = createUnlocks(definitions.map((d) => ({ id: d.id, category: d.category })));
54
55
  function stateOf(userId) {
55
56
  return unlocks.list(userId);
56
57
  }
57
58
  return {
58
59
  has: (userId, id) => unlocks.has(userId, id),
59
- canUnlock: (userId, id) => canUnlockTech(defs, stateOf(userId), id),
60
+ canUnlock: (userId, id) => canUnlockTech(definitions, stateOf(userId), id),
60
61
  unlock(userId, id) {
61
- const check = canUnlockTech(defs, stateOf(userId), id);
62
+ const check = canUnlockTech(definitions, stateOf(userId), id);
62
63
  if (!check.ok)
63
64
  return check;
64
65
  const node = nodes.get(id);
@@ -67,10 +68,10 @@ export function createTechTree(defs = []) {
67
68
  unlocks.grant(userId, extra);
68
69
  return { ok: true };
69
70
  },
70
- available: (userId) => availableTech(defs, stateOf(userId)),
71
- recipes: (userId) => unlockedRecipes(defs, stateOf(userId)),
71
+ available: (userId) => availableTech(definitions, stateOf(userId)),
72
+ recipes: (userId) => unlockedRecipes(definitions, stateOf(userId)),
72
73
  list: (userId) => unlocks.list(userId),
73
- tree: (categoryId) => unlockTree(defs, categoryId),
74
+ tree: (categoryId) => unlockTree(definitions, categoryId),
74
75
  node: (id) => nodes.get(id) ?? null,
75
76
  snapshot: (userId) => unlocks.snapshot(userId),
76
77
  hydrate: (userId, ids) => unlocks.hydrate(userId, ids),
@@ -21,8 +21,8 @@ export function createUnlockPoints(config = {}) {
21
21
  unlocked.clear();
22
22
  earned = sanitizeAmount(state.earned);
23
23
  for (const [id, cost] of Object.entries(state.unlocked)) {
24
- const paid = Number.isFinite(cost) && cost >= 0 ? cost : 0;
25
- unlocked.set(id, paid);
24
+ if (Number.isFinite(cost) && cost >= 0)
25
+ unlocked.set(id, cost);
26
26
  }
27
27
  };
28
28
  if (config.state !== undefined) {
@@ -91,10 +91,7 @@ export function createUnlockPoints(config = {}) {
91
91
  return refunded;
92
92
  },
93
93
  snapshot() {
94
- const map = {};
95
- for (const [id, cost] of unlocked)
96
- map[id] = cost;
97
- return { earned, unlocked: map };
94
+ return { earned, unlocked: Object.fromEntries(unlocked) };
98
95
  },
99
96
  hydrate(state) {
100
97
  load(state);
@@ -1,6 +1,10 @@
1
- import { applyDeltaToSnapshot, applySurfaceDeltaToSnapshot, applyWeightDeltaToSnapshot, } from "../world/terraform.js";
1
+ import { decodeEditorSimulation } from "./simulation.js";
2
+ import { parseEditorMaterialAsset, authoredMaterialAssignments, sameMaterialSelector } from "./materialAuthoring.js";
3
+ import { parseMaterialAssignments, validateMaterialAssignments } from "../material/materialAsset.js";
4
+ import { parseStaticPrefabBake } from "./staticPrefab.js";
5
+ import { applyDeltaToSnapshot, applySurfaceDeltaToSnapshot, applyWeightDeltaToSnapshot, editableTerrainFromSnapshot, } from "../world/terraform.js";
2
6
  import { patchUiPanel, removeUiPanel } from "../ui/hudDocument.js";
3
- import { cloneEditorDocument, collectDescendants, createPrefabFragment, editorDocumentExtras, ensureUniqueEditorId, extractEditorFragment, findEditorCollection, findEditorMarker, findEditorPrefab, findEditorVolume, importEditorDocumentJson, isEditorObjectLocked, wouldCreateCycle, } from "./document.js";
7
+ import { cloneEditorDocument, collectDescendants, createEmptyEditorDocument, createPrefabFragment, decodeEditorDocument, editorDocumentExtras, ensureUniqueEditorId, extractEditorFragment, findEditorCollection, findEditorMarker, findEditorPrefab, findEditorVolume, importEditorDocumentJson, isEditorObjectLocked, wouldCreateCycle, } from "./document.js";
4
8
  import { fillGridRect, floodFillGrid, migrateGridLayer, paintGridCells, resizeGridLayer, } from "./grid.js";
5
9
  import { parseParams } from "../scene/sceneKinds.js";
6
10
  /** Pure document mutation helpers + exhaustive EditorCommand apply table. */
@@ -80,6 +84,11 @@ function insertFragment(state, fragment, offset, renameAll) {
80
84
  const volumes = clones.volumes.map((volume) => withId({ ...volume, center: shifted(volume.center, offset) }));
81
85
  const paths = clones.paths.map((path) => withId({ ...path, points: path.points.map((point) => shifted(point, offset)) }));
82
86
  const annotations = clones.annotations.map((note) => withId({ ...note, position: shifted(note.position, offset) }));
87
+ for (const asset of clones.materialAssets ?? []) {
88
+ const existing = doc.materialAssets?.find((item) => item.id === asset.id);
89
+ if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(asset))
90
+ throw new TypeError(`Material ID conflict: ${asset.id}; rename the imported material before insertion.`);
91
+ }
83
92
  if (newIds.length === 0)
84
93
  return state;
85
94
  return {
@@ -90,6 +99,7 @@ function insertFragment(state, fragment, offset, renameAll) {
90
99
  paths: [...doc.paths, ...paths],
91
100
  annotations: [...doc.annotations, ...annotations],
92
101
  ...editorDocumentExtras(doc),
102
+ ...(doc.materialAssets === undefined && clones.materialAssets === undefined ? {} : { materialAssets: [...(doc.materialAssets ?? []), ...(clones.materialAssets ?? []).filter((asset) => !doc.materialAssets?.some((item) => item.id === asset.id))] }),
93
103
  },
94
104
  selection: newIds,
95
105
  };
@@ -97,6 +107,7 @@ function insertFragment(state, fragment, offset, renameAll) {
97
107
  function fragmentAsDocument(fragment) {
98
108
  return {
99
109
  version: 1,
110
+ ...(fragment.materialAssets === undefined ? {} : { materialAssets: structuredClone(fragment.materialAssets) }),
100
111
  markers: [...fragment.markers],
101
112
  volumes: [...fragment.volumes],
102
113
  paths: [...fragment.paths],
@@ -448,13 +459,25 @@ const mutationHandlers = {
448
459
  setTerrain: (state, command) => ({ ...state, document: { ...state.document, terrain: command.terrain } }),
449
460
  setMinimapBake: (state, command) => ({ ...state, document: { ...state.document, minimap: command.minimap } }),
450
461
  setBake: (state, command) => ({ ...state, document: { ...state.document, bakes: [...(state.document.bakes ?? []).filter((bake) => bake.id !== command.bake.id), command.bake] } }),
462
+ setSimulation: (state, command) => {
463
+ if (command.simulation === undefined) {
464
+ const { simulation: _removed, ...document } = state.document;
465
+ void _removed;
466
+ return { ...state, document };
467
+ }
468
+ return { ...state, document: { ...state.document, simulation: decodeEditorSimulation(command.simulation, state.document) } };
469
+ },
451
470
  setEnvironment: (state, command) => {
452
471
  if (command.environment === undefined) {
453
472
  const { environment: _removed, ...rest } = state.document;
454
473
  void _removed;
455
474
  return { ...state, document: rest };
456
475
  }
457
- return { ...state, document: { ...state.document, environment: command.environment } };
476
+ const decoded = decodeEditorDocument({ ...createEmptyEditorDocument(), environment: command.environment });
477
+ if (!decoded.ok) {
478
+ throw new Error(`invalid editor environment: ${decoded.errors.map((error) => `${error.path} ${error.message}`).join("; ")}`);
479
+ }
480
+ return { ...state, document: { ...state.document, environment: decoded.document.environment } };
458
481
  },
459
482
  clearTerrain: (state) => {
460
483
  const nextDoc = {
@@ -470,6 +493,7 @@ const mutationHandlers = {
470
493
  ...(state.document.ui === undefined ? {} : { ui: state.document.ui }),
471
494
  ...(state.document.directives === undefined ? {} : { directives: state.document.directives }),
472
495
  ...(state.document.minimap === undefined ? {} : { minimap: state.document.minimap }),
496
+ ...(state.document.simulation === undefined ? {} : { simulation: structuredClone(state.document.simulation) }),
473
497
  ...(state.document.environment === undefined ? {} : { environment: state.document.environment }),
474
498
  };
475
499
  return { ...state, document: nextDoc };
@@ -522,19 +546,9 @@ const mutationHandlers = {
522
546
  setTerrainLayers: (state, command) => {
523
547
  if (state.document.terrain === undefined)
524
548
  return state;
525
- const terrain = state.document.terrain;
526
- const layers = command.layers.map((layer) => ({ ...layer }));
527
- const before = terrain.layers ?? [];
528
- // Keep hand-painted weights only when the layer id sequence is unchanged (a params-only edit).
529
- const sameSequence = before.length === layers.length && before.every((layer, i) => layer.id === layers[i].id);
530
- const next = sameSequence
531
- ? { ...terrain, layers }
532
- : (() => {
533
- const copy = { ...terrain, layers };
534
- delete copy.weights;
535
- return copy;
536
- })();
537
- return { ...state, document: { ...state.document, terrain: next } };
549
+ const terrain = editableTerrainFromSnapshot(state.document.terrain);
550
+ terrain.setLayers(command.layers);
551
+ return { ...state, document: { ...state.document, terrain: terrain.snapshot() } };
538
552
  },
539
553
  convertScatterToObjects: (state, command) => {
540
554
  const path = state.document.paths.find((entry) => entry.id === command.pathId);
@@ -570,6 +584,17 @@ const mutationHandlers = {
570
584
  },
571
585
  };
572
586
  },
587
+ setPrefabStaticBake: (state, command) => {
588
+ if (findEditorPrefab(state.document, command.prefabId) === undefined)
589
+ return null;
590
+ const bake = command.bake === null ? undefined : parseStaticPrefabBake(command.bake);
591
+ return { ...state, document: { ...state.document, prefabs: state.document.prefabs.map((prefab) => {
592
+ if (prefab.id !== command.prefabId)
593
+ return prefab;
594
+ const { staticBake: previous, ...source } = prefab;
595
+ return { ...source, ...(bake === undefined ? {} : { staticBake: bake }) };
596
+ }) } };
597
+ },
573
598
  deletePrefab: (state, command) => ({
574
599
  ...state,
575
600
  document: {
@@ -584,6 +609,7 @@ const mutationHandlers = {
584
609
  const instanceId = command.instanceId ??
585
610
  `${prefab.id}_inst_${Date.now().toString(36)}_${Math.floor(Math.random() * 1_296_000).toString(36)}`;
586
611
  const tagged = fragmentAsDocument({
612
+ ...(prefab.fragment.materialAssets === undefined ? {} : { materialAssets: prefab.fragment.materialAssets }),
587
613
  markers: prefab.fragment.markers.map((marker) => tagWithInstance(marker, prefab.id, instanceId)),
588
614
  volumes: prefab.fragment.volumes.map((volume) => tagWithInstance(volume, prefab.id, instanceId)),
589
615
  paths: prefab.fragment.paths.map((path) => tagWithInstance(path, prefab.id, instanceId)),
@@ -738,6 +764,53 @@ const mutationHandlers = {
738
764
  const stamp = (item) => ids.has(item.id) ? { ...item, meta: { ...item.meta, materialId: command.materialId } } : item;
739
765
  return { ...state, document: { ...state.document, ...mapPlaceables(state.document, stamp) } };
740
766
  },
767
+ upsertMaterialAsset: (state, command) => {
768
+ const asset = parseEditorMaterialAsset(command.asset);
769
+ return { ...state, document: { ...state.document, materialAssets: [...(state.document.materialAssets ?? []).filter((item) => item.id !== asset.id), asset] } };
770
+ },
771
+ removeMaterialAsset: (state, command) => {
772
+ const used = state.document.markers.some((marker) => authoredMaterialAssignments(marker.meta).some((assignment) => assignment.materialId === command.id))
773
+ || state.document.prefabs.some((prefab) => prefab.fragment.markers.some((marker) => authoredMaterialAssignments(marker.meta).some((assignment) => assignment.materialId === command.id)));
774
+ if (used)
775
+ throw new TypeError(`Material ${command.id} is assigned; clear its references before removal.`);
776
+ if (!state.document.materialAssets?.some((asset) => asset.id === command.id))
777
+ throw new TypeError(`Material not found: ${command.id}`);
778
+ return { ...state, document: { ...state.document, materialAssets: state.document.materialAssets.filter((asset) => asset.id !== command.id) } };
779
+ },
780
+ assignMaterialAsset: (state, command) => {
781
+ const assignment = parseMaterialAssignments([{ materialId: command.materialId, selector: command.selector }])[0];
782
+ const errors = validateMaterialAssignments([assignment], state.document.materialAssets ?? []).filter((item) => item.severity === "error");
783
+ if (errors.length > 0)
784
+ throw new TypeError(errors.map((item) => item.message).join("; "));
785
+ const ids = new Set(command.ids);
786
+ for (const id of ids) {
787
+ if (!state.document.markers.some((marker) => marker.id === id))
788
+ throw new TypeError(`material target is not a marker: ${id}`);
789
+ if (isEditorObjectLocked(state.document, id))
790
+ throw new TypeError(`object is locked: ${id}`);
791
+ }
792
+ const markers = state.document.markers.map((marker) => {
793
+ if (!ids.has(marker.id))
794
+ return marker;
795
+ const assignments = authoredMaterialAssignments(marker.meta).filter((item) => !sameMaterialSelector(item.selector, assignment.selector));
796
+ return { ...marker, meta: { ...marker.meta, materialAssignments: [...assignments, structuredClone(assignment)] } };
797
+ });
798
+ return { ...state, document: { ...state.document, markers } };
799
+ },
800
+ clearMaterialAssets: (state, command) => {
801
+ const ids = new Set(command.ids);
802
+ for (const id of ids)
803
+ if (isEditorObjectLocked(state.document, id))
804
+ throw new TypeError(`object is locked: ${id}`);
805
+ const markers = state.document.markers.map((marker) => {
806
+ if (!ids.has(marker.id) || marker.meta?.materialAssignments === undefined)
807
+ return marker;
808
+ const meta = { ...marker.meta };
809
+ delete meta.materialAssignments;
810
+ return { ...marker, meta };
811
+ });
812
+ return { ...state, document: { ...state.document, markers } };
813
+ },
741
814
  undo: () => null,
742
815
  redo: () => null,
743
816
  };
@@ -1,3 +1,6 @@
1
+ import type { EditorSimulation } from "./simulation.js";
2
+ import type { MaterialAsset, MaterialSelector } from "../material/materialAsset.js";
3
+ import { type StaticPrefabBake } from "./staticPrefab.js";
1
4
  import { type SurfaceDelta, type TerraformDelta, type TerrainMaterialLayer, type WeightDelta } from "../world/terraform.js";
2
5
  import type { EditorUiDocument, EditorUiPanelLayout } from "../ui/hudDocument.js";
3
6
  import type { EditorGridCellEdit, EditorGridLayer } from "./grid.js";
@@ -134,6 +137,9 @@ export type EditorCommand = {
134
137
  | {
135
138
  type: "setEnvironment";
136
139
  environment: EditorEnvironment | undefined;
140
+ } | {
141
+ type: "setSimulation";
142
+ simulation: EditorSimulation | undefined;
137
143
  } | {
138
144
  type: "convertScatterToObjects";
139
145
  pathId: string;
@@ -154,6 +160,10 @@ export type EditorCommand = {
154
160
  } | {
155
161
  type: "deletePrefab";
156
162
  prefabId: string;
163
+ } | {
164
+ type: "setPrefabStaticBake";
165
+ prefabId: string;
166
+ bake: StaticPrefabBake | null;
157
167
  } | {
158
168
  type: "createCollection";
159
169
  id: string;
@@ -208,6 +218,20 @@ export type EditorCommand = {
208
218
  label?: string;
209
219
  meta?: Record<string, unknown>;
210
220
  };
221
+ } | {
222
+ type: "upsertMaterialAsset";
223
+ asset: MaterialAsset;
224
+ } | {
225
+ type: "removeMaterialAsset";
226
+ id: string;
227
+ } | {
228
+ type: "assignMaterialAsset";
229
+ ids: readonly string[];
230
+ materialId: string;
231
+ selector: MaterialSelector;
232
+ } | {
233
+ type: "clearMaterialAssets";
234
+ ids: readonly string[];
211
235
  } | {
212
236
  type: "assignMaterial";
213
237
  ids: readonly string[];
@@ -274,14 +298,27 @@ export interface EditorSession {
274
298
  getState(): EditorSessionState;
275
299
  subscribe(listener: (state: EditorSessionState) => void): () => void;
276
300
  dispatch(command: EditorCommand, options?: EditorDispatchOptions): EditorSessionState;
301
+ /** Stage a command batch, then commit one undo step and notification; failures leave all state intact. */
302
+ transaction(commands: readonly EditorCommand[]): EditorTransactionResult;
277
303
  exportJson(pretty?: boolean): string;
278
304
  canUndo(): boolean;
279
305
  canRedo(): boolean;
280
306
  }
307
+ /** Atomic command-batch outcome; a failure identifies the zero-based command index. */
308
+ export type EditorTransactionResult = {
309
+ ok: true;
310
+ state: EditorSessionState;
311
+ changed: boolean;
312
+ } | {
313
+ ok: false;
314
+ error: string;
315
+ commandIndex?: number;
316
+ };
281
317
  /** Creates an editor session with undo/redo history seeded from an initial document.
318
+ * Environment commands validate and copy their candidate bag before publishing state or history.
282
319
  * @internal
283
320
  */
284
- export declare function createEditorSession(initial: EditorDocument, historyLimit?: number): EditorSession;
321
+ export declare function createEditorSession(initial: EditorDocument, historyLimit?: number, validateDocument?: (document: EditorDocument) => void): EditorSession;
285
322
  /** Compact snapshot of a session state — counts, selection, and the selected object.
286
323
  * @internal
287
324
  */