@jgengine/core 0.11.0 → 0.13.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 (256) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/ai/populationDirector.d.ts +103 -0
  3. package/dist/ai/populationDirector.js +190 -0
  4. package/dist/ai/pursuit.d.ts +56 -0
  5. package/dist/ai/pursuit.js +53 -0
  6. package/dist/authoring.d.ts +1 -1
  7. package/dist/authoring.js +1 -1
  8. package/dist/combat/effects.d.ts +11 -4
  9. package/dist/combat/effects.js +24 -17
  10. package/dist/combat/magazine.d.ts +13 -0
  11. package/dist/combat/magazine.js +27 -0
  12. package/dist/combat/vfxPresets.d.ts +176 -0
  13. package/dist/combat/vfxPresets.js +74 -0
  14. package/dist/combat/weaponFire.d.ts +114 -0
  15. package/dist/combat/weaponFire.js +96 -0
  16. package/dist/combat.d.ts +5 -2
  17. package/dist/combat.js +4 -1
  18. package/dist/devtools/controls.d.ts +38 -0
  19. package/dist/devtools/controls.js +181 -0
  20. package/dist/devtools/devtools.d.ts +5 -197
  21. package/dist/devtools/devtools.js +38 -774
  22. package/dist/devtools/discover.d.ts +32 -0
  23. package/dist/devtools/discover.js +240 -0
  24. package/dist/devtools/fallbackSeams.d.ts +50 -0
  25. package/dist/devtools/fallbackSeams.js +111 -0
  26. package/dist/devtools/frame.d.ts +15 -0
  27. package/dist/devtools/frame.js +247 -0
  28. package/dist/devtools/logs.d.ts +15 -0
  29. package/dist/devtools/logs.js +86 -0
  30. package/dist/devtools/overrides.d.ts +16 -0
  31. package/dist/devtools/overrides.js +66 -0
  32. package/dist/devtools/types.d.ts +212 -0
  33. package/dist/devtools/types.js +1 -0
  34. package/dist/devtools/urlFlags.d.ts +22 -0
  35. package/dist/devtools/urlFlags.js +62 -0
  36. package/dist/economy/auctionBook.d.ts +122 -0
  37. package/dist/economy/auctionBook.js +213 -0
  38. package/dist/economy/priceHistory.d.ts +44 -0
  39. package/dist/economy/priceHistory.js +89 -0
  40. package/dist/economy/unlockPoints.d.ts +92 -0
  41. package/dist/economy/unlockPoints.js +103 -0
  42. package/dist/editor/commandApply.d.ts +5 -0
  43. package/dist/editor/commandApply.js +755 -0
  44. package/dist/editor/commands.d.ts +48 -3
  45. package/dist/editor/commands.js +40 -754
  46. package/dist/editor/directives.d.ts +94 -0
  47. package/dist/editor/directives.js +206 -0
  48. package/dist/editor/document.d.ts +37 -2
  49. package/dist/editor/document.js +422 -6
  50. package/dist/editor/entityCatalog.d.ts +42 -0
  51. package/dist/editor/entityCatalog.js +101 -0
  52. package/dist/editor/environment.d.ts +74 -0
  53. package/dist/editor/environment.js +169 -0
  54. package/dist/editor/index.d.ts +26 -1
  55. package/dist/editor/index.js +8 -1
  56. package/dist/editor/minimap.d.ts +31 -0
  57. package/dist/editor/minimap.js +73 -0
  58. package/dist/editor/streamer.d.ts +66 -0
  59. package/dist/editor/streamer.js +68 -0
  60. package/dist/editor/types.d.ts +143 -0
  61. package/dist/editor/world.d.ts +136 -0
  62. package/dist/editor/world.js +303 -0
  63. package/dist/game/breeding.d.ts +161 -0
  64. package/dist/game/breeding.js +181 -0
  65. package/dist/game/cameraConfig.d.ts +451 -0
  66. package/dist/game/cameraConfig.js +1 -0
  67. package/dist/game/clipRoles.d.ts +51 -0
  68. package/dist/game/clipRoles.js +142 -0
  69. package/dist/game/defineGame.d.ts +16 -9
  70. package/dist/game/defineGame.js +14 -6
  71. package/dist/game/defineSystem.d.ts +2 -0
  72. package/dist/game/defineSystem.js +2 -0
  73. package/dist/game/events.d.ts +28 -0
  74. package/dist/game/partAnimation.d.ts +78 -0
  75. package/dist/game/partAnimation.js +147 -0
  76. package/dist/game/playableGame.d.ts +72 -440
  77. package/dist/game/playableGame.js +1 -1
  78. package/dist/game/progression.d.ts +35 -0
  79. package/dist/game/progression.js +22 -1
  80. package/dist/game/quest.js +1 -3
  81. package/dist/game/tribe.d.ts +184 -0
  82. package/dist/game/tribe.js +432 -0
  83. package/dist/gameplay.d.ts +3 -1
  84. package/dist/gameplay.js +3 -1
  85. package/dist/inventory/encumbrance.d.ts +69 -0
  86. package/dist/inventory/encumbrance.js +68 -0
  87. package/dist/inventory/inventoryModel.d.ts +24 -8
  88. package/dist/inventory/inventoryModel.js +47 -8
  89. package/dist/item/durability.js +1 -3
  90. package/dist/meta/changelog.d.ts +1 -1
  91. package/dist/meta/changelog.js +48 -1
  92. package/dist/movement/movementModel.d.ts +15 -1
  93. package/dist/movement/movementModel.js +33 -15
  94. package/dist/movement/playerMovement.js +102 -2
  95. package/dist/multiplayer/combatSnapshot.js +13 -8
  96. package/dist/multiplayer/presenceModel.d.ts +2 -0
  97. package/dist/multiplayer/presenceModel.js +8 -0
  98. package/dist/physics/drivableVehicle.d.ts +2 -0
  99. package/dist/physics/drivableVehicle.js +1 -1
  100. package/dist/physics/flightDynamics.d.ts +82 -0
  101. package/dist/physics/flightDynamics.js +165 -0
  102. package/dist/physics/gravityField.d.ts +25 -0
  103. package/dist/physics/gravityField.js +41 -0
  104. package/dist/physics/kinematicVehicle.d.ts +87 -0
  105. package/dist/physics/kinematicVehicle.js +193 -11
  106. package/dist/physics/physicsWorld.d.ts +26 -0
  107. package/dist/physics/physicsWorld.js +109 -117
  108. package/dist/physics/vehicleObstacles.d.ts +44 -0
  109. package/dist/physics/vehicleObstacles.js +115 -0
  110. package/dist/random/rng.d.ts +4 -1
  111. package/dist/random/rng.js +4 -1
  112. package/dist/runtime/adapter.d.ts +13 -0
  113. package/dist/runtime/adapter.js +13 -0
  114. package/dist/runtime/context/combat.d.ts +51 -0
  115. package/dist/runtime/context/combat.js +131 -0
  116. package/dist/runtime/context/combatFx.js +15 -5
  117. package/dist/runtime/context/deathLoot.d.ts +30 -0
  118. package/dist/runtime/context/deathLoot.js +39 -0
  119. package/dist/runtime/context/player.d.ts +48 -0
  120. package/dist/runtime/context/player.js +125 -0
  121. package/dist/runtime/context/scene.d.ts +60 -0
  122. package/dist/runtime/context/scene.js +329 -0
  123. package/dist/runtime/context/snapshotCodecs.d.ts +12 -0
  124. package/dist/runtime/context/snapshotCodecs.js +26 -0
  125. package/dist/runtime/descriptors/baseline.js +26 -3
  126. package/dist/runtime/descriptors/features.d.ts +5 -0
  127. package/dist/runtime/descriptors/features.js +20 -2
  128. package/dist/runtime/gameContext.d.ts +2 -428
  129. package/dist/runtime/gameContext.js +133 -500
  130. package/dist/runtime/gameContextTypes.d.ts +500 -0
  131. package/dist/runtime/gameContextTypes.js +1 -0
  132. package/dist/runtime/headlessRunner.d.ts +17 -2
  133. package/dist/runtime/headlessRunner.js +15 -0
  134. package/dist/runtime/hostPolicy.d.ts +51 -0
  135. package/dist/runtime/hostPolicy.js +68 -0
  136. package/dist/runtime/hostedGameRunner.d.ts +3 -1
  137. package/dist/runtime/hostedGameRunner.js +1 -0
  138. package/dist/runtime/hostedWorldSession.d.ts +3 -1
  139. package/dist/runtime/hostedWorldSession.js +1 -0
  140. package/dist/runtime/worldSnapshot.d.ts +13 -1
  141. package/dist/runtime/worldSnapshot.js +15 -3
  142. package/dist/scene/assetCatalog.d.ts +7 -0
  143. package/dist/scene/assetSpace.js +1 -3
  144. package/dist/scene/authoredTriggers.d.ts +50 -0
  145. package/dist/scene/authoredTriggers.js +114 -0
  146. package/dist/scene/builtinSceneKinds.js +8 -3
  147. package/dist/scene/colliders.d.ts +79 -0
  148. package/dist/scene/colliders.js +170 -0
  149. package/dist/scene/collisionMesh.d.ts +94 -0
  150. package/dist/scene/collisionMesh.js +699 -0
  151. package/dist/scene/companion.d.ts +147 -0
  152. package/dist/scene/companion.js +193 -0
  153. package/dist/scene/entityStats.d.ts +7 -9
  154. package/dist/scene/entityStats.js +19 -15
  155. package/dist/scene/sceneKinds.d.ts +16 -0
  156. package/dist/scene/sceneKinds.js +4 -0
  157. package/dist/scene/sceneRaycast.js +15 -0
  158. package/dist/stats/spawnLevelStats.d.ts +110 -0
  159. package/dist/stats/spawnLevelStats.js +140 -0
  160. package/dist/stats/statModifiers.d.ts +16 -0
  161. package/dist/stats/statModifiers.js +30 -1
  162. package/dist/stats/statPool.d.ts +70 -0
  163. package/dist/stats/statPool.js +62 -0
  164. package/dist/store/changeSignal.d.ts +3 -0
  165. package/dist/store/changeSignal.js +2 -0
  166. package/dist/store/observableKeyedStore.d.ts +2 -0
  167. package/dist/store/observableKeyedStore.js +1 -0
  168. package/dist/survival/decayMeter.js +1 -3
  169. package/dist/survival/regionHealth.js +1 -3
  170. package/dist/ui/hudLayout.d.ts +5 -0
  171. package/dist/ui/hudLayout.js +17 -1
  172. package/dist/ui/panelModel.d.ts +120 -0
  173. package/dist/ui/panelModel.js +168 -0
  174. package/dist/ui.d.ts +1 -0
  175. package/dist/ui.js +1 -0
  176. package/dist/world/authoredEntities.d.ts +29 -0
  177. package/dist/world/authoredEntities.js +29 -0
  178. package/dist/world/authoredObjects.d.ts +15 -1
  179. package/dist/world/authoredObjects.js +13 -1
  180. package/dist/world/authoredSpawn.d.ts +39 -0
  181. package/dist/world/authoredSpawn.js +37 -0
  182. package/dist/world/buildSockets.d.ts +152 -0
  183. package/dist/world/buildSockets.js +188 -0
  184. package/dist/world/buildingLots.d.ts +84 -0
  185. package/dist/world/buildingLots.js +119 -0
  186. package/dist/world/buildings.d.ts +6 -0
  187. package/dist/world/cityBlocks.d.ts +152 -0
  188. package/dist/world/cityBlocks.js +670 -0
  189. package/dist/world/cityContent.d.ts +77 -0
  190. package/dist/world/cityContent.js +261 -0
  191. package/dist/world/cityGenerator.d.ts +41 -0
  192. package/dist/world/cityGenerator.js +58 -0
  193. package/dist/world/cityGeometry.d.ts +114 -0
  194. package/dist/world/cityGeometry.js +573 -0
  195. package/dist/world/cityKind.d.ts +314 -0
  196. package/dist/world/cityKind.js +1578 -0
  197. package/dist/world/environmentSummary.js +57 -1
  198. package/dist/world/features.d.ts +53 -5
  199. package/dist/world/features.js +15 -3
  200. package/dist/world/grassKind.js +2 -2
  201. package/dist/world/markers.d.ts +50 -4
  202. package/dist/world/markers.js +49 -0
  203. package/dist/world/massing.js +1 -1
  204. package/dist/world/minimapBake.d.ts +78 -0
  205. package/dist/world/minimapBake.js +208 -0
  206. package/dist/world/minimapTrack.d.ts +17 -0
  207. package/dist/world/minimapTrack.js +22 -0
  208. package/dist/world/place.d.ts +166 -0
  209. package/dist/world/place.js +96 -0
  210. package/dist/world/placeAsset.js +3 -1
  211. package/dist/world/poleLineKind.d.ts +40 -0
  212. package/dist/world/poleLineKind.js +97 -0
  213. package/dist/world/polyline.js +9 -2
  214. package/dist/world/resourceNode.d.ts +109 -0
  215. package/dist/world/resourceNode.js +140 -0
  216. package/dist/world/roads.d.ts +19 -2
  217. package/dist/world/roads.js +60 -3
  218. package/dist/world/scatter.js +2 -20
  219. package/dist/world/streetGenerator.d.ts +159 -0
  220. package/dist/world/streetGenerator.js +684 -0
  221. package/dist/world/streets.d.ts +103 -1
  222. package/dist/world/streets.js +273 -0
  223. package/dist/world/terraform.js +58 -88
  224. package/dist/world/terrain.d.ts +65 -1
  225. package/dist/world/terrain.js +119 -2
  226. package/dist/world.d.ts +14 -5
  227. package/dist/world.js +12 -3
  228. package/package.json +1 -1
  229. package/skills/game-design/SKILL.md +28 -0
  230. package/skills/game-design/agents/openai.yaml +4 -0
  231. package/skills/game-design/references/application-playbook.md +170 -0
  232. package/skills/game-design/references/field-guide.md +288 -0
  233. package/skills/jgengine/SKILL.md +8 -4
  234. package/skills/jgengine/api.md +182 -81
  235. package/skills/jgengine/capabilities.md +104 -2
  236. package/skills/jgengine/recipes/minimal-game.md +88 -0
  237. package/skills/jgengine-combat/SKILL.md +19 -1
  238. package/skills/jgengine-combat/api.md +75 -2
  239. package/skills/jgengine-combat/capabilities.md +61 -1
  240. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  241. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  242. package/skills/jgengine-combat/recipes/portable-stat-pools.md +122 -0
  243. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  244. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  245. package/skills/jgengine-combat/reference.md +33 -4
  246. package/skills/jgengine-gameplay/SKILL.md +8 -0
  247. package/skills/jgengine-gameplay/api.md +182 -26
  248. package/skills/jgengine-gameplay/capabilities.md +96 -2
  249. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  250. package/skills/jgengine-gameplay/reference-systems.md +10 -5
  251. package/skills/jgengine-world/SKILL.md +13 -0
  252. package/skills/jgengine-world/api.md +453 -21
  253. package/skills/jgengine-world/capabilities.md +112 -2
  254. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  255. package/skills/jgengine-world/reference.md +43 -4
  256. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -1,47 +1,16 @@
1
- import {} from "../cards/cardPile.js";
2
- import { createDeathSystem, deathReasonFromEffect, normalizeOnDeath } from "../combat/death.js";
3
- import { createEffectSystem, } from "../combat/effects.js";
4
- import { createProjectileSystem } from "../combat/projectiles.js";
5
- import {} from "../combat/hitReaction.js";
6
- import {} from "../combat/telegraph.js";
7
- import { createCommandRegistry, } from "../commands/commandRegistry.js";
8
- import { balance as walletBalance, charge as walletCharge, createEmptyWallet, grant as walletGrant, isOverdrawn as walletIsOverdrawn, } from "../economy/wallet.js";
1
+ import { createCommandRegistry } from "../commands/commandRegistry.js";
9
2
  import {} from "../game/cosmetics.js";
10
3
  import { groundFieldFor } from "../world/terrain.js";
11
4
  import { createGameEvents } from "../game/events.js";
12
- import { createVfxInstanceStore } from "../game/vfxInstance.js";
13
5
  import { createGameFeed } from "../game/feed.js";
14
6
  import { setGamePhase } from "../game/gamePhase.js";
15
- import {} from "../game/leaderboard.js";
16
- import { createLoadouts } from "../game/loadout.js";
17
7
  import { createLootRegistry, grantDrops } from "../game/lootTable.js";
18
- import {} from "../game/dialogue.js";
19
- import {} from "../game/quest.js";
20
- import { resolveDeathDrops, DEFAULT_RARITY, } from "../game/worldItem.js";
21
- import {} from "../game/chat.js";
22
8
  import { createSocial } from "../game/social.js";
23
- import {} from "../game/trade.js";
24
- import {} from "../game/unlocks.js";
25
- import { createInventorySet, putItem, } from "../inventory/inventoryModel.js";
26
- import { createItemUse, } from "../item/use.js";
9
+ import { splitStack } from "../inventory/inventoryModel.js";
10
+ import { createItemUse } from "../item/use.js";
27
11
  import { createWeaponStats } from "../item/weapon.js";
28
- import { createPoseState } from "../movement/poseState.js";
29
- import { createBodyBind } from "../scene/bodyBind.js";
30
- import { createPaintLayer } from "../scene/paintLayer.js";
31
- import { createEntityStatsApi, seedStatValues, setStatValue, } from "../scene/entityStats.js";
32
- import { createEntityStore, } from "../scene/entityStore.js";
33
- import { createForms } from "../scene/form.js";
34
- import { scaledEntityColliders, scaledObjectColliders } from "../scene/colliders.js";
35
- import { raycastObjects, raycastObjectsAll } from "../scene/objectQuery.js";
36
- import { createObjectStore, objectVisualScale } from "../scene/objectStore.js";
37
- import {} from "../scene/roster.js";
38
- import { createSelectionSet } from "../scene/selection.js";
39
- import {} from "../game/connectedPlayers.js";
40
- import { createPossession } from "../scene/possession.js";
41
- import { createSceneRaycast, } from "../scene/sceneRaycast.js";
42
- import { createSpatialApi } from "../scene/spatial.js";
43
- import { createTargeting } from "../scene/targeting.js";
44
- import { createStats } from "../stats/statModifiers.js";
12
+ import {} from "../scene/entityStats.js";
13
+ import {} from "../scene/entityStore.js";
45
14
  import { createChangeSignal, notifyAfter } from "../store/changeSignal.js";
46
15
  import { createObservableKeyedStore } from "../store/observableKeyedStore.js";
47
16
  import { applyWorldSnapshot, composeWorldSnapshot, } from "./worldSnapshot.js";
@@ -50,15 +19,14 @@ import { createRuntimeSave } from "./runtimeSave.js";
50
19
  import { isOffline } from "./adapter.js";
51
20
  import { localSaveBackend, memorySaveBackend } from "../game/saveStore.js";
52
21
  import { createSimClock } from "../time/simClock.js";
53
- import {} from "../turn/turnLoop.js";
54
- import { RaceState } from "../game/race.js";
55
22
  import { createCameraDirector } from "./cameraDirector.js";
56
23
  import { createInputSnapshot } from "./inputSnapshot.js";
57
- import { createMotionIntents } from "./motionIntents.js";
58
24
  import { baselineDescriptors } from "./descriptors/baseline.js";
59
25
  import { featureDescriptors } from "./descriptors/features.js";
60
- import { createCombatFx } from "./context/combatFx.js";
26
+ import { createCombatSubsystem } from "./context/combat.js";
61
27
  import { createContextRegistries } from "./context/registries.js";
28
+ import { createPlayerSubsystem } from "./context/player.js";
29
+ import { createSceneSubsystem } from "./context/scene.js";
62
30
  import { createWorldItemContext } from "./context/worldItems.js";
63
31
  export function createGameContext(options) {
64
32
  const { definition, content, player } = options;
@@ -69,131 +37,6 @@ export function createGameContext(options) {
69
37
  const activeUserId = () => actingUserId ?? player.userId;
70
38
  const time = createSimClock({ config: definition.time, onChange: signal.notify });
71
39
  const ground = groundFieldFor(definition.world);
72
- const entities = createEntityStore();
73
- const objects = createObjectStore();
74
- entities.subscribe(signal.notify);
75
- objects.subscribe(signal.notify);
76
- const statsByInstance = new Map();
77
- const entityStats = notifyAfter(createEntityStatsApi((instanceId) => statsByInstance.get(instanceId)), ["set", "delta"], signal.notify);
78
- function ensureInstanceStats(instanceId) {
79
- let map = statsByInstance.get(instanceId);
80
- if (map === undefined) {
81
- map = {};
82
- statsByInstance.set(instanceId, map);
83
- }
84
- return map;
85
- }
86
- function catalogEntry(instanceId) {
87
- const entity = entities.get(instanceId);
88
- return entity === null ? undefined : content.entityById?.(entity.name);
89
- }
90
- function catalogObject(instanceId) {
91
- const object = objects.get(instanceId);
92
- return object === null ? undefined : content.objectById?.(object.catalogId);
93
- }
94
- let spatialGeneration = 0;
95
- const candidateIds = [];
96
- let candidateIdsDirty = true;
97
- function refreshCandidateIds() {
98
- if (!candidateIdsDirty)
99
- return candidateIds;
100
- candidateIds.length = 0;
101
- for (const entity of entities.list())
102
- candidateIds.push(entity.id);
103
- candidateIdsDirty = false;
104
- return candidateIds;
105
- }
106
- const spatial = createSpatialApi({
107
- resolvePosition: (instanceId) => entities.get(instanceId)?.position,
108
- candidates: refreshCandidateIds,
109
- grid: { cellSize: 8 },
110
- getVersion: () => spatialGeneration,
111
- ...(occluder !== undefined ? { occluder } : {}),
112
- });
113
- entities.subscribe(() => {
114
- candidateIdsDirty = true;
115
- spatialGeneration += 1;
116
- spatial.invalidate();
117
- });
118
- const entityColliders = new Map();
119
- const objectColliders = new Map();
120
- function entityCollidersOf(instanceId) {
121
- const override = entityColliders.get(instanceId);
122
- if (override !== undefined)
123
- return override;
124
- const entry = catalogEntry(instanceId);
125
- if (entry?.colliders !== undefined)
126
- return entry.colliders;
127
- const scale = entry?.scale;
128
- if (scale !== undefined && scale !== 1)
129
- return scaledEntityColliders(scale);
130
- return null;
131
- }
132
- function entityVisualScaleOf(instanceId) {
133
- return catalogEntry(instanceId)?.scale ?? 1;
134
- }
135
- function objectCollidersOf(instanceId) {
136
- const override = objectColliders.get(instanceId);
137
- if (override !== undefined)
138
- return override;
139
- const catalog = catalogObject(instanceId);
140
- if (catalog?.colliders !== undefined)
141
- return catalog.colliders;
142
- if (catalog?.halfExtents !== undefined)
143
- return null;
144
- const object = objects.get(instanceId);
145
- if (object === null || object.visual?.scale === undefined)
146
- return null;
147
- return scaledObjectColliders(objectVisualScale(object.visual));
148
- }
149
- const targeting = notifyAfter(createTargeting({
150
- candidates: () => [...refreshCandidateIds()],
151
- classify(_fromId, toId) {
152
- const role = catalogEntry(toId)?.role;
153
- return role === "enemy" || role === "hostile" ? "hostile" : "friendly";
154
- },
155
- distance: (fromId, toId) => spatial.distance(fromId, toId),
156
- }), ["setTarget", "cycleTarget"], signal.notify);
157
- const combatSpatial = {
158
- inRadius: (center, radius) => spatial.inRadius(center, radius),
159
- hasLineOfSight: (from, to) => {
160
- if (typeof from === "string")
161
- return spatial.hasLineOfSight(from, to);
162
- const toPos = entities.get(to)?.position;
163
- if (toPos === undefined)
164
- return false;
165
- if (occluder === undefined)
166
- return true;
167
- return !occluder(from, toPos);
168
- },
169
- positionOf: (instanceId) => entities.get(instanceId)?.position,
170
- };
171
- const sceneRaycast = createSceneRaycast({
172
- entities: {
173
- list: () => entities.list().map((entity) => ({
174
- id: entity.id,
175
- position: entity.position,
176
- rotationY: entity.rotationY,
177
- name: entity.name,
178
- })),
179
- collidersOf: entityCollidersOf,
180
- inRadius: (center, radius) => spatial.inRadius(center, radius),
181
- get: (id) => {
182
- const entity = entities.get(id);
183
- if (entity === null)
184
- return null;
185
- return { id: entity.id, position: entity.position, rotationY: entity.rotationY, name: entity.name };
186
- },
187
- },
188
- objects: {
189
- list: () => objects.list(),
190
- inBox: (min, max) => objects.inBox(min, max),
191
- collidersOf: objectCollidersOf,
192
- halfExtentsOf: (catalogId) => content.objectById?.(catalogId)?.halfExtents ?? null,
193
- },
194
- terrain: ground,
195
- });
196
- const weapon = createWeaponStats((itemId) => content.itemById?.(itemId));
197
40
  const rawEvents = createGameEvents();
198
41
  const events = {
199
42
  on: rawEvents.on,
@@ -203,8 +46,27 @@ export function createGameContext(options) {
203
46
  signal.notify();
204
47
  },
205
48
  };
49
+ // --- Scene installer (entities, objects, colliders, spatial, raycast, targeting) ---
50
+ const scene = createSceneSubsystem({
51
+ content,
52
+ signalNotify: signal.notify,
53
+ ground,
54
+ events,
55
+ time,
56
+ ...(occluder !== undefined ? { occluder } : {}),
57
+ ...(options.models !== undefined ? { models: options.models } : {}),
58
+ });
59
+ const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, reportEntityBounds, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
60
+ // --- Shared game services (weapon, feed, features bag, commands, item use) ---
61
+ const weapon = createWeaponStats((itemId) => content.itemById?.(itemId));
206
62
  const feed = createGameFeed(definition.feed);
207
63
  const lootRegistry = createLootRegistry();
64
+ /**
65
+ * Single feature-activation path (G9): `definition.features` is already the final map after
66
+ * `defineGame` OR-merged explicit boolean sugar (`features: { quest: true }`) with system-implied
67
+ * flags via `mergeSystemFeatures`. Descriptor install below is the only place that decides
68
+ * "feature on" — never a second enable check against systems.
69
+ */
208
70
  const features = definition.features ?? {};
209
71
  const featureRegistry = new Map();
210
72
  const featureValue = (key) => featureRegistry.get(key);
@@ -222,16 +84,6 @@ export function createGameContext(options) {
222
84
  }
223
85
  return rawSocial;
224
86
  };
225
- const playerStatsByUser = new Map();
226
- function playerStatsFor(userId) {
227
- let stats = playerStatsByUser.get(userId);
228
- if (stats === undefined) {
229
- stats = createStats({});
230
- playerStatsByUser.set(userId, stats);
231
- }
232
- return stats;
233
- }
234
- const pose = createPoseState((instanceId) => catalogEntry(instanceId)?.movement);
235
87
  const commandRegistry = createCommandRegistry();
236
88
  if (definition.lifecycle !== undefined) {
237
89
  const lifecycle = definition.lifecycle;
@@ -251,50 +103,57 @@ export function createGameContext(options) {
251
103
  });
252
104
  }
253
105
  const itemUse = createItemUse((itemId) => content.itemById?.(itemId)?.use);
254
- const possession = notifyAfter(createPossession({ entities, events }), ["possess", "own", "disown"], signal.notify);
255
- const forms = notifyAfter(createForms({ entities, time, events }), ["shapeshift", "revert"], signal.notify);
256
- const paintLayer = notifyAfter(createPaintLayer(), ["paint", "clear"], signal.notify);
257
- const inventoryDeclarations = definition.inventories ?? {};
258
- const inventoryIds = Object.keys(inventoryDeclarations);
259
- const layouts = {};
260
- for (const [inventoryId, declaration] of Object.entries(inventoryDeclarations)) {
261
- layouts[inventoryId] = { slots: declaration.slots, accepts: declaration.accepts };
262
- }
263
- const traits = Object.values(inventoryDeclarations).find((declaration) => declaration.traits !== undefined)?.traits ?? { stackLimit: () => Number.POSITIVE_INFINITY };
264
- const inventoryByUser = new Map();
265
- function inventoryFor(userId) {
266
- let set = inventoryByUser.get(userId);
267
- if (set === undefined) {
268
- set = notifyAfter(createInventorySet(layouts, traits), ["put", "take", "move", "replaceState"], signal.notify);
269
- inventoryByUser.set(userId, set);
270
- }
271
- return set;
272
- }
273
- const wallets = new Map();
274
- const walletOf = (userId) => wallets.get(userId) ?? createEmptyWallet();
275
- const economy = {
276
- balance: (userId, currencyId) => walletBalance(walletOf(userId), currencyId),
277
- grant(userId, currencyId, amount) {
278
- wallets.set(userId, walletGrant(walletOf(userId), currencyId, amount));
279
- signal.notify();
106
+ // --- Player installer (inventory, wallets, pose, motion, loadout) ---
107
+ const playerSys = createPlayerSubsystem({
108
+ signalNotify: signal.notify,
109
+ inventoryDeclarations: definition.inventories ?? {},
110
+ entities,
111
+ events,
112
+ catalogEntry,
113
+ ensureInstanceStats,
114
+ feature: featureValue,
115
+ });
116
+ const { inventoryIds, inventoryByUser, inventoryFor, layouts, wallets, walletOf, economy, putIntoAnyInventory, playerStatsFor, pose, possession, motionByUser, motionFor, loadouts, seedUserPool, } = playerSys;
117
+ scene.setOnAfterDespawn((instanceId) => pose.clear(instanceId));
118
+ // Built-in inventory mutation commands so a HUD grid drives moves/splits through the notifying
119
+ // command path (a raw InventorySet mutation would not re-render React). Each resolves the acting
120
+ // user's own inventory, so it respects `privatePerUser` replication.
121
+ const knownInventory = (inventoryId) => layouts[inventoryId] !== undefined;
122
+ const slotInRange = (inventoryId, slot) => typeof slot === "number" && Number.isInteger(slot) && slot >= 0 && slot < layouts[inventoryId].slots;
123
+ commandRegistry.define("inventory.move", {
124
+ validate(_state, input) {
125
+ if (!knownInventory(input.inventoryId))
126
+ return { reason: `unknown inventory "${input.inventoryId}"` };
127
+ if (!slotInRange(input.inventoryId, input.from) || !slotInRange(input.inventoryId, input.to)) {
128
+ return { reason: "invalid-slot" };
129
+ }
130
+ return null;
280
131
  },
281
- charge(userId, currencyId, amount, options) {
282
- const result = walletCharge(walletOf(userId), currencyId, amount, options);
283
- if (result.status === "rejected")
284
- return { reason: result.reason };
285
- wallets.set(userId, result.state);
286
- signal.notify();
132
+ apply(_state, input) {
133
+ const inventory = inventoryFor(activeUserId());
134
+ // A rejected move (empty source, wrong-kind, no-space) is a harmless no-op: the item bounces back.
135
+ inventory.move(input.inventoryId, input.from, input.inventoryId, input.to);
136
+ },
137
+ });
138
+ commandRegistry.define("inventory.split", {
139
+ validate(_state, input) {
140
+ if (!knownInventory(input.inventoryId))
141
+ return { reason: `unknown inventory "${input.inventoryId}"` };
142
+ if (!slotInRange(input.inventoryId, input.slot))
143
+ return { reason: "invalid-slot" };
144
+ if (input.toSlot !== undefined && !slotInRange(input.inventoryId, input.toSlot)) {
145
+ return { reason: "invalid-slot" };
146
+ }
287
147
  return null;
288
148
  },
289
- isOverdrawn: (userId, currencyId) => walletIsOverdrawn(walletOf(userId), currencyId),
290
- };
291
- function putIntoAnyInventory(userId, itemId, count) {
292
- const inventory = inventoryFor(userId);
293
- for (const inventoryId of inventoryIds) {
294
- if (inventory.put(inventoryId, itemId, count).status === "ok")
149
+ apply(_state, input) {
150
+ const inventory = inventoryFor(activeUserId());
151
+ const result = splitStack(inventory.state(input.inventoryId), input.slot, input.amount, input.toSlot);
152
+ if (result.status === "rejected")
295
153
  return;
296
- }
297
- }
154
+ inventory.replaceState(input.inventoryId, result.state);
155
+ },
156
+ });
298
157
  const loot = {
299
158
  register: lootRegistry.register,
300
159
  has: lootRegistry.has,
@@ -310,100 +169,6 @@ export function createGameContext(options) {
310
169
  events.emit("loot.granted", event);
311
170
  },
312
171
  };
313
- function seedUserPool(userId, statId, pool) {
314
- const map = ensureInstanceStats(userId);
315
- const next = setStatValue(map, statId, pool);
316
- map[statId] = next[statId];
317
- }
318
- const loadouts = notifyAfter(createLoadouts({
319
- inventory: {
320
- begin(userId) {
321
- const inventory = inventoryFor(userId);
322
- const staged = new Map();
323
- return {
324
- put(inventoryId, itemId, count, slot) {
325
- const layout = layouts[inventoryId];
326
- if (layout === undefined)
327
- return { reason: `unknown inventory "${inventoryId}"` };
328
- const state = staged.get(inventoryId) ?? inventory.state(inventoryId);
329
- const result = putItem(state, layout, traits, itemId, count, slot === undefined ? undefined : { slot });
330
- if (result.status === "rejected")
331
- return { reason: result.reason };
332
- staged.set(inventoryId, result.state);
333
- return null;
334
- },
335
- commit() {
336
- for (const [inventoryId, state] of staged)
337
- inventory.replaceState(inventoryId, state);
338
- },
339
- };
340
- },
341
- },
342
- stats: { seed: seedUserPool },
343
- economy: { grant: economy.grant },
344
- unlocks: { grant: (userId, unlockId) => featureValue("unlocks")?.grant(userId, unlockId) },
345
- }), ["applyLoadout"], signal.notify);
346
- function spawnEntity(name, spawnOptions) {
347
- const entry = content.entityById?.(name);
348
- const walkSpeed = spawnOptions?.movement?.walkSpeed ?? entry?.movement?.walkSpeed;
349
- const options = walkSpeed === undefined
350
- ? spawnOptions
351
- : { ...spawnOptions, movement: { ...spawnOptions?.movement, walkSpeed } };
352
- const instanceId = entities.spawn(name, options);
353
- death.revive(instanceId);
354
- statsByInstance.set(instanceId, entry?.stats === undefined ? {} : seedStatValues(entry.stats));
355
- return instanceId;
356
- }
357
- function despawnEntity(instanceId) {
358
- const existed = entities.despawn(instanceId);
359
- statsByInstance.delete(instanceId);
360
- targeting.clearAll(instanceId);
361
- pose.clear(instanceId);
362
- entityColliders.delete(instanceId);
363
- return existed;
364
- }
365
- function moveEntityTowardCommit(instanceId, target, options) {
366
- const next = spatial.moveToward(instanceId, target, options);
367
- if (next === null)
368
- return null;
369
- const current = entities.get(instanceId);
370
- if (current === null)
371
- return null;
372
- let rotationY = current.rotationY;
373
- if (options.face === true) {
374
- const dx = next[0] - current.position[0];
375
- const dz = next[2] - current.position[2];
376
- if (Math.hypot(dx, dz) > 1e-9)
377
- rotationY = Math.atan2(dx, dz);
378
- }
379
- entities.setPose(instanceId, { position: next, rotationY });
380
- return next;
381
- }
382
- function resetAllToSpawn(filter) {
383
- let count = 0;
384
- for (const entity of entities.list()) {
385
- if (filter !== undefined && !filter(entity))
386
- continue;
387
- if (entities.resetToSpawn(entity.id))
388
- count += 1;
389
- }
390
- return count;
391
- }
392
- const bodyBinds = new Map();
393
- function bind(key) {
394
- const existing = bodyBinds.get(key);
395
- if (existing !== undefined)
396
- return existing;
397
- const created = createBodyBind({
398
- has: (id) => entities.get(id) !== null,
399
- spawn: spawnEntity,
400
- despawn: despawnEntity,
401
- setPose: entities.setPose,
402
- update: entities.update,
403
- });
404
- bodyBinds.set(key, created);
405
- return created;
406
- }
407
172
  const { worldItems, spawnWorldItem, pickupWorldItem } = createWorldItemContext({
408
173
  entities,
409
174
  events,
@@ -411,171 +176,43 @@ export function createGameContext(options) {
411
176
  grantToPlayer: loot.grantToPlayer,
412
177
  signalNotify: signal.notify,
413
178
  });
414
- const death = createDeathSystem({
415
- resolveOnDeath: (instanceId) => catalogEntry(instanceId)?.onDeath,
416
- resolveIdentity(instanceId) {
417
- const entity = entities.get(instanceId);
418
- if (entity === null)
419
- return null;
420
- return {
421
- catalogId: entity.name,
422
- position: [entity.position[0], entity.position[1], entity.position[2]],
423
- };
424
- },
425
- loot: { roll: (tableId) => (lootRegistry.has(tableId) ? lootRegistry.roll(tableId) : []) },
426
- events,
427
- runCommand(name, args) {
428
- commandRegistry.run(ctx, name, args);
429
- },
430
- despawn(instanceId) {
431
- despawnEntity(instanceId);
432
- },
433
- });
434
- const effects = notifyAfter(createEffectSystem({
435
- resolveReceive: (instanceId) => catalogEntry(instanceId)?.receive,
436
- resolveStats: (instanceId) => statsByInstance.get(instanceId),
437
- getStat: weapon.getStat,
438
- spatial: combatSpatial,
439
- onLethal(instanceId, lethalCtx) {
440
- const dyingEntity = entities.get(instanceId);
441
- const catalogId = dyingEntity?.name;
442
- const position = dyingEntity?.position;
443
- const normalizedOnDeath = normalizeOnDeath(catalogEntry(instanceId)?.onDeath);
444
- const reason = deathReasonFromEffect({
445
- ...lethalCtx,
446
- userIdOf: (id) => (id === player.userId ? player.userId : undefined),
447
- });
448
- const resolution = death.resolveDeath(instanceId, reason);
449
- if (resolution.status === "resolved" &&
450
- resolution.drops.length > 0 &&
451
- reason.kind === "player_kill" &&
452
- reason.killerUserId === player.userId) {
453
- if (normalizedOnDeath.dropMode === "world" && position !== undefined) {
454
- const resolved = resolveDeathDrops(resolution.drops, {
455
- mode: "world",
456
- origin: position,
457
- resolveRarity: (itemId) => content.itemById?.(itemId)?.rarity ?? DEFAULT_RARITY,
458
- resolveBaseType: (itemId) => content.itemById?.(itemId)?.baseType ?? itemId,
459
- scatter: normalizedOnDeath.scatter,
460
- ...(catalogId !== undefined ? { source: catalogId } : {}),
461
- });
462
- for (const spawn of resolved.worldSpawns)
463
- spawnWorldItem(spawn);
464
- if (resolved.grants.length > 0)
465
- loot.grantToPlayer(player.userId, resolved.grants, catalogId);
466
- }
467
- else {
468
- loot.grantToPlayer(player.userId, resolution.drops, catalogId);
469
- }
470
- }
471
- },
472
- }), ["applyEffect"], signal.notify);
473
- const { emitFloatText, emitVfx, fireTelegraph, applyHitReaction, applyEffectAndFloat } = createCombatFx({
474
- entities,
179
+ // --- Combat installer (effects, projectiles, death, combat FX) ---
180
+ // Command runner closes over `ctx` once assembled (same late-bind pattern as before).
181
+ let ctxRef = null;
182
+ const combat = createCombatSubsystem({
183
+ content,
184
+ signalNotify: signal.notify,
185
+ now,
475
186
  events,
476
187
  time,
477
- applyEffect: (input) => effects.applyEffect(input),
478
- });
479
- const vfxInstances = createVfxInstanceStore({
480
- onOp: (op) => events.emit("combat.vfxInstance", op),
481
- now: () => time.now() * 1000,
482
- });
483
- const floatingEffects = {
484
- canReceive: effects.canReceive,
485
- preview: effects.preview,
486
- applyEffect: applyEffectAndFloat,
487
- };
488
- const projectileObstacles = definition.physics?.projectileObstacles === true;
489
- const projectileSceneRaycast = {
490
- raycast(input) {
491
- return sceneRaycast.raycast({
492
- ...input,
493
- filter: {
494
- entities: true,
495
- objects: projectileObstacles,
496
- terrain: false,
497
- walls: projectileObstacles,
498
- ...input.filter,
499
- },
500
- });
501
- },
502
- raycastAll(input) {
503
- return sceneRaycast.raycastAll({
504
- ...input,
505
- filter: {
506
- entities: true,
507
- objects: projectileObstacles,
508
- terrain: false,
509
- walls: projectileObstacles,
510
- ...input.filter,
511
- },
512
- });
513
- },
514
- };
515
- const projectiles = notifyAfter(createProjectileSystem({
516
- effects: floatingEffects,
517
- spatial: combatSpatial,
518
- getStat: weapon.getStat,
519
- sceneRaycast: projectileSceneRaycast,
520
- objects: projectileObstacles
521
- ? {
522
- list: () => objects.list(),
523
- inBox: (min, max) => objects.inBox(min, max),
524
- halfExtents: (catalogId) => {
525
- const half = content.objectById?.(catalogId)?.halfExtents;
526
- return half === undefined ? null : [half[0], half[1], half[2]];
527
- },
528
- collidersOf: objectCollidersOf,
529
- }
530
- : undefined,
188
+ entities,
189
+ objects,
190
+ combatSpatial,
191
+ sceneRaycast,
531
192
  entityCollidersOf,
532
- rotationYOf: (instanceId) => entities.get(instanceId)?.rotationY,
533
- now,
534
- onSettle(report) {
535
- events.emit("projectile.settled", {
536
- from: report.from,
537
- origin: [report.origin[0], report.origin[1], report.origin[2]],
538
- at: [report.at[0], report.at[1], report.at[2]],
539
- effect: report.effect,
540
- hit: report.hit,
541
- });
542
- },
543
- }), ["fireProjectile", "settleProjectile"], signal.notify);
544
- const objectSelection = notifyAfter(createSelectionSet(), ["add", "remove", "toggle", "replace", "clear"], signal.notify);
545
- const sceneObjects = {
546
- ...objects,
547
- remove(instanceId) {
548
- const existed = objects.remove(instanceId);
549
- if (existed)
550
- objectSelection.remove(instanceId);
551
- return existed;
552
- },
553
- catalog: (instanceId) => catalogObject(instanceId) ?? null,
554
- raycast: (input) => raycastObjects(objects, input),
555
- raycastAll: (input) => raycastObjectsAll(objects, input),
556
- setColliders(instanceId, colliders) {
557
- if (colliders === null)
558
- objectColliders.delete(instanceId);
559
- else
560
- objectColliders.set(instanceId, colliders);
561
- signal.notify();
193
+ objectCollidersOf,
194
+ catalogEntry,
195
+ statsByInstance,
196
+ weapon,
197
+ loot,
198
+ lootRegistry,
199
+ spawnWorldItem,
200
+ despawnEntity,
201
+ runCommand(name, args) {
202
+ if (ctxRef === null)
203
+ return;
204
+ commandRegistry.run(ctxRef, name, args);
562
205
  },
563
- collidersOf: objectCollidersOf,
564
- selection: objectSelection,
565
- };
206
+ localUserId: player.userId,
207
+ ...(definition.physics !== undefined ? { physics: definition.physics } : {}),
208
+ });
209
+ scene.setOnAfterSpawn((instanceId) => combat.death.revive(instanceId));
210
+ const { projectiles, combatFx, vfxInstances, effects } = combat;
566
211
  const store = notifyAfter(createObservableKeyedStore(), ["set", "delete", "hydrate"], signal.notify);
567
212
  const { pile, loop, raceState, cardPiles, turnLoops } = createContextRegistries(signal.notify);
568
213
  const camera = notifyAfter(createCameraDirector(), ["follow", "setCinematic", "setChaseTuning"], signal.notify);
569
214
  const input = createInputSnapshot();
570
- const motionByUser = new Map();
571
- function motionFor(userId) {
572
- let queue = motionByUser.get(userId);
573
- if (queue === undefined) {
574
- queue = createMotionIntents();
575
- motionByUser.set(userId, queue);
576
- }
577
- return queue;
578
- }
215
+ // --- Descriptor install (single feature enable path) ---
579
216
  const featureDeps = {
580
217
  features,
581
218
  signalNotify: signal.notify,
@@ -614,6 +251,12 @@ export function createGameContext(options) {
614
251
  if (build.save !== undefined)
615
252
  featureSaveModules.push(build.save);
616
253
  }
254
+ const gameFeatureBlock = {};
255
+ for (const descriptor of featureDescriptors) {
256
+ if (descriptor.key === "cosmetics")
257
+ continue;
258
+ gameFeatureBlock[descriptor.key] = featureRegistry.get(descriptor.key);
259
+ }
617
260
  const baselineDeps = {
618
261
  signalNotify: signal.notify,
619
262
  entities,
@@ -638,15 +281,16 @@ export function createGameContext(options) {
638
281
  const replication = options.replication;
639
282
  const projectsViewers = policyProjectsViewers(replication);
640
283
  const aoiRadius = replication?.aoiRadius;
284
+ const typedProject = (project) => (data, viewer, world) => project(data, viewer, world);
641
285
  const projectorFor = (key) => {
642
286
  if (aoiRadius !== undefined && key === "entities") {
643
- return (data, viewer) => projectEntitiesForViewer(data, viewer, aoiRadius);
287
+ return typedProject((data, viewer) => projectEntitiesForViewer(data, viewer, aoiRadius));
644
288
  }
645
289
  if (aoiRadius !== undefined && key === "stats") {
646
- return (data, viewer, world) => projectByVisibleIds(data, visibleEntityIds((world["entities"] ?? []), viewer, aoiRadius));
290
+ return typedProject((data, viewer, world) => projectByVisibleIds(data, visibleEntityIds((world["entities"] ?? []), viewer, aoiRadius)));
647
291
  }
648
292
  if (replication?.privatePerUser === true && key === "inventory") {
649
- return (data, viewer) => projectPerUserForViewer(data, viewer);
293
+ return typedProject((data, viewer) => projectPerUserForViewer(data, viewer));
650
294
  }
651
295
  return undefined;
652
296
  };
@@ -659,17 +303,6 @@ export function createGameContext(options) {
659
303
  };
660
304
  });
661
305
  const replicationVersion = () => replicationModules.reduce((sum, module) => sum + (module.version?.() ?? 0), 0);
662
- /**
663
- * The whole-world *save* set is a superset of the *replication* set (`snapshotModules`): it adds the
664
- * always-on baseline subsystems (economy, time, pose, possession, motion) plus every opted-in
665
- * feature's `save` module (unlocks, roster, quest, cosmetics, cards, turn) — the persistent state a
666
- * single-player save must restore but a host does not replicate to clients. Coverage is owned per
667
- * subsystem, not hand-listed against a drifting feature manifest: each feature descriptor emits its
668
- * own `save`, so a new persistent feature can't silently fall out of the save. Keeping the two sets
669
- * distinct leaves `ctx.snapshot()`/`ctx.hydrate()` — the host→client payload — byte-identical for
670
- * every multiplayer game, while `ctx.game.save` still persists everything.
671
- * Mutable so game systems can register save modules at install time via `ctx.game.registerSave`.
672
- */
673
306
  const saveModules = [
674
307
  ...snapshotModules,
675
308
  ...baselineBuilds.flatMap((build) => (build.save === undefined ? [] : [build.save])),
@@ -687,6 +320,7 @@ export function createGameContext(options) {
687
320
  });
688
321
  saveModules.push(module);
689
322
  }
323
+ // --- Assemble public GameContext ---
690
324
  const ctx = {
691
325
  scene: {
692
326
  object: sceneObjects,
@@ -705,17 +339,17 @@ export function createGameContext(options) {
705
339
  resetAllToSpawn,
706
340
  blackboard: entities.blackboard,
707
341
  stats: entityStats,
708
- floatText: emitFloatText,
709
- vfx: emitVfx,
342
+ floatText: combatFx.emitFloatText,
343
+ vfx: combatFx.emitVfx,
710
344
  vfxInstance: vfxInstances,
711
- telegraph: fireTelegraph,
712
- hitReaction: applyHitReaction,
345
+ telegraph: combatFx.fireTelegraph,
346
+ hitReaction: combatFx.applyHitReaction,
713
347
  setTarget: targeting.setTarget,
714
348
  getTarget: targeting.getTarget,
715
349
  cycleTarget: targeting.cycleTarget,
716
350
  canReceive: effects.canReceive,
717
351
  preview: effects.preview,
718
- effect: applyEffectAndFloat,
352
+ effect: combatFx.applyEffectAndFloat,
719
353
  willHitProjectile: projectiles.willHitProjectile,
720
354
  fireProjectile: projectiles.fireProjectile,
721
355
  settleProjectile: projectiles.settleProjectile,
@@ -734,6 +368,7 @@ export function createGameContext(options) {
734
368
  signal.notify();
735
369
  },
736
370
  collidersOf: entityCollidersOf,
371
+ reportBounds: reportEntityBounds,
737
372
  visualScaleOf: entityVisualScaleOf,
738
373
  form: forms,
739
374
  paint: paintLayer,
@@ -784,6 +419,9 @@ export function createGameContext(options) {
784
419
  play: (sound, at) => events.emit("audio.play", at === undefined ? { sound } : { sound, at }),
785
420
  music: (theme, transpose) => events.emit("audio.music", transpose === undefined ? { theme } : { theme, transpose }),
786
421
  resume: () => events.emit("audio.resume", {}),
422
+ loop: (id, sound, options) => events.emit("audio.loopStart", options?.at === undefined ? { id, sound } : { id, sound, at: options.at }),
423
+ setLoop: (id, params) => events.emit("audio.loopSet", { id, ...params }),
424
+ stopLoop: (id) => events.emit("audio.loopStop", { id }),
787
425
  },
788
426
  feed: {
789
427
  bind: (action) => feed.bind(action, events),
@@ -800,20 +438,9 @@ export function createGameContext(options) {
800
438
  },
801
439
  },
802
440
  loot,
803
- trade: featureValue("trade"),
804
- quest: featureValue("quest"),
805
- social: featureValue("social"),
806
- chat: featureValue("chat"),
807
- unlocks: featureValue("unlocks"),
808
441
  economy,
809
- leaderboard: featureValue("leaderboard"),
810
- roster: featureValue("roster"),
811
442
  store,
812
- dialogue: featureValue("dialogue"),
813
- cards: featureValue("cards"),
814
- turn: featureValue("turn"),
815
- race: featureValue("race"),
816
- players: featureValue("players"),
443
+ ...gameFeatureBlock,
817
444
  registerSave,
818
445
  registerReplicate,
819
446
  },
@@ -867,6 +494,7 @@ export function createGameContext(options) {
867
494
  replicationVersion,
868
495
  replicatesPerViewer: () => projectsViewers,
869
496
  };
497
+ ctxRef = ctx;
870
498
  const saveOptions = resolveSaveOptions(definition, options);
871
499
  if (saveOptions !== undefined) {
872
500
  const saveTarget = {
@@ -885,16 +513,21 @@ function saveKey(name) {
885
513
  const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
886
514
  return `jgengine:save:${slug === "" ? "game" : slug}`;
887
515
  }
888
- /** Explicit `options.save` wins (the shell can inject any backend, e.g. cloud); otherwise `defineGame({ persist })` auto-wires a localStorage save for offline/single-player worlds only. */
516
+ /**
517
+ * Explicit `options.save` wins (the shell can inject any backend, e.g. cloud); otherwise an offline /
518
+ * single-player world auto-wires a `localStorage` whole-world save **by default** — a game gets `ctx.game.save`
519
+ * and autosave without touching `persist`. Only an explicit `persist: false` opts out; a host-authoritative
520
+ * multiplayer world never persists to `localStorage` (its host owns persistence) regardless of `persist`.
521
+ */
889
522
  function resolveSaveOptions(definition, options) {
890
523
  if (options.save !== undefined)
891
524
  return options.save;
892
525
  const persist = definition.persist;
893
- if (persist === undefined || persist === false)
526
+ if (persist === false)
894
527
  return undefined;
895
528
  if (!isOffline(definition.multiplayer))
896
529
  return undefined;
897
- const config = persist === true ? {} : persist;
530
+ const config = persist === undefined || persist === true ? {} : persist;
898
531
  return {
899
532
  backend: config.storage === "memory" ? memorySaveBackend() : localSaveBackend(),
900
533
  mode: config.mode,