@jgengine/core 0.11.0 → 0.12.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 (219) hide show
  1. package/CHANGELOG.md +26 -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.d.ts +1 -0
  9. package/dist/combat.js +1 -0
  10. package/dist/devtools/controls.d.ts +38 -0
  11. package/dist/devtools/controls.js +181 -0
  12. package/dist/devtools/devtools.d.ts +5 -197
  13. package/dist/devtools/devtools.js +38 -774
  14. package/dist/devtools/discover.d.ts +32 -0
  15. package/dist/devtools/discover.js +240 -0
  16. package/dist/devtools/fallbackSeams.d.ts +50 -0
  17. package/dist/devtools/fallbackSeams.js +111 -0
  18. package/dist/devtools/frame.d.ts +15 -0
  19. package/dist/devtools/frame.js +247 -0
  20. package/dist/devtools/logs.d.ts +15 -0
  21. package/dist/devtools/logs.js +86 -0
  22. package/dist/devtools/overrides.d.ts +16 -0
  23. package/dist/devtools/overrides.js +66 -0
  24. package/dist/devtools/types.d.ts +212 -0
  25. package/dist/devtools/types.js +1 -0
  26. package/dist/economy/auctionBook.d.ts +122 -0
  27. package/dist/economy/auctionBook.js +213 -0
  28. package/dist/economy/priceHistory.d.ts +44 -0
  29. package/dist/economy/priceHistory.js +89 -0
  30. package/dist/economy/unlockPoints.d.ts +92 -0
  31. package/dist/economy/unlockPoints.js +103 -0
  32. package/dist/editor/commandApply.d.ts +5 -0
  33. package/dist/editor/commandApply.js +755 -0
  34. package/dist/editor/commands.d.ts +48 -3
  35. package/dist/editor/commands.js +40 -754
  36. package/dist/editor/directives.d.ts +94 -0
  37. package/dist/editor/directives.js +206 -0
  38. package/dist/editor/document.d.ts +37 -2
  39. package/dist/editor/document.js +422 -6
  40. package/dist/editor/entityCatalog.d.ts +42 -0
  41. package/dist/editor/entityCatalog.js +101 -0
  42. package/dist/editor/environment.d.ts +74 -0
  43. package/dist/editor/environment.js +169 -0
  44. package/dist/editor/index.d.ts +26 -1
  45. package/dist/editor/index.js +8 -1
  46. package/dist/editor/minimap.d.ts +31 -0
  47. package/dist/editor/minimap.js +73 -0
  48. package/dist/editor/streamer.d.ts +66 -0
  49. package/dist/editor/streamer.js +68 -0
  50. package/dist/editor/types.d.ts +143 -0
  51. package/dist/editor/world.d.ts +136 -0
  52. package/dist/editor/world.js +303 -0
  53. package/dist/game/breeding.d.ts +161 -0
  54. package/dist/game/breeding.js +181 -0
  55. package/dist/game/cameraConfig.d.ts +451 -0
  56. package/dist/game/cameraConfig.js +1 -0
  57. package/dist/game/defineGame.d.ts +16 -9
  58. package/dist/game/defineGame.js +14 -6
  59. package/dist/game/defineSystem.d.ts +2 -0
  60. package/dist/game/defineSystem.js +2 -0
  61. package/dist/game/events.d.ts +28 -0
  62. package/dist/game/playableGame.d.ts +34 -435
  63. package/dist/game/playableGame.js +1 -1
  64. package/dist/game/progression.d.ts +35 -0
  65. package/dist/game/progression.js +19 -0
  66. package/dist/game/quest.js +1 -3
  67. package/dist/game/tribe.d.ts +184 -0
  68. package/dist/game/tribe.js +432 -0
  69. package/dist/gameplay.d.ts +3 -1
  70. package/dist/gameplay.js +3 -1
  71. package/dist/inventory/encumbrance.d.ts +69 -0
  72. package/dist/inventory/encumbrance.js +68 -0
  73. package/dist/item/durability.js +1 -3
  74. package/dist/meta/changelog.d.ts +1 -1
  75. package/dist/meta/changelog.js +24 -1
  76. package/dist/movement/movementModel.d.ts +15 -1
  77. package/dist/movement/movementModel.js +33 -15
  78. package/dist/movement/playerMovement.js +102 -2
  79. package/dist/multiplayer/combatSnapshot.js +13 -8
  80. package/dist/multiplayer/presenceModel.d.ts +2 -0
  81. package/dist/multiplayer/presenceModel.js +8 -0
  82. package/dist/physics/drivableVehicle.d.ts +2 -0
  83. package/dist/physics/drivableVehicle.js +1 -1
  84. package/dist/physics/flightDynamics.d.ts +82 -0
  85. package/dist/physics/flightDynamics.js +165 -0
  86. package/dist/physics/gravityField.d.ts +25 -0
  87. package/dist/physics/gravityField.js +41 -0
  88. package/dist/physics/kinematicVehicle.d.ts +87 -0
  89. package/dist/physics/kinematicVehicle.js +193 -11
  90. package/dist/physics/physicsWorld.d.ts +26 -0
  91. package/dist/physics/physicsWorld.js +109 -117
  92. package/dist/physics/vehicleObstacles.d.ts +44 -0
  93. package/dist/physics/vehicleObstacles.js +115 -0
  94. package/dist/random/rng.d.ts +4 -1
  95. package/dist/random/rng.js +4 -1
  96. package/dist/runtime/adapter.d.ts +13 -0
  97. package/dist/runtime/adapter.js +13 -0
  98. package/dist/runtime/context/combat.d.ts +51 -0
  99. package/dist/runtime/context/combat.js +130 -0
  100. package/dist/runtime/context/deathLoot.d.ts +30 -0
  101. package/dist/runtime/context/deathLoot.js +39 -0
  102. package/dist/runtime/context/player.d.ts +48 -0
  103. package/dist/runtime/context/player.js +125 -0
  104. package/dist/runtime/context/scene.d.ts +59 -0
  105. package/dist/runtime/context/scene.js +291 -0
  106. package/dist/runtime/context/snapshotCodecs.d.ts +12 -0
  107. package/dist/runtime/context/snapshotCodecs.js +26 -0
  108. package/dist/runtime/descriptors/baseline.js +26 -3
  109. package/dist/runtime/descriptors/features.d.ts +5 -0
  110. package/dist/runtime/descriptors/features.js +20 -2
  111. package/dist/runtime/gameContext.d.ts +2 -428
  112. package/dist/runtime/gameContext.js +88 -501
  113. package/dist/runtime/gameContextTypes.d.ts +467 -0
  114. package/dist/runtime/gameContextTypes.js +1 -0
  115. package/dist/runtime/headlessRunner.d.ts +4 -2
  116. package/dist/runtime/headlessRunner.js +1 -0
  117. package/dist/runtime/hostPolicy.d.ts +51 -0
  118. package/dist/runtime/hostPolicy.js +68 -0
  119. package/dist/runtime/hostedGameRunner.d.ts +3 -1
  120. package/dist/runtime/hostedGameRunner.js +1 -0
  121. package/dist/runtime/hostedWorldSession.d.ts +3 -1
  122. package/dist/runtime/hostedWorldSession.js +1 -0
  123. package/dist/runtime/worldSnapshot.d.ts +13 -1
  124. package/dist/runtime/worldSnapshot.js +15 -3
  125. package/dist/scene/assetCatalog.d.ts +5 -0
  126. package/dist/scene/assetSpace.js +1 -3
  127. package/dist/scene/authoredTriggers.d.ts +50 -0
  128. package/dist/scene/authoredTriggers.js +114 -0
  129. package/dist/scene/builtinSceneKinds.js +8 -3
  130. package/dist/scene/colliders.d.ts +58 -0
  131. package/dist/scene/colliders.js +108 -0
  132. package/dist/scene/collisionMesh.d.ts +94 -0
  133. package/dist/scene/collisionMesh.js +699 -0
  134. package/dist/scene/companion.d.ts +147 -0
  135. package/dist/scene/companion.js +193 -0
  136. package/dist/scene/entityStats.d.ts +7 -9
  137. package/dist/scene/entityStats.js +10 -14
  138. package/dist/scene/sceneKinds.d.ts +16 -0
  139. package/dist/scene/sceneKinds.js +4 -0
  140. package/dist/scene/sceneRaycast.js +15 -0
  141. package/dist/stats/spawnLevelStats.d.ts +110 -0
  142. package/dist/stats/spawnLevelStats.js +140 -0
  143. package/dist/stats/statPool.d.ts +70 -0
  144. package/dist/stats/statPool.js +59 -0
  145. package/dist/store/changeSignal.d.ts +3 -0
  146. package/dist/store/changeSignal.js +2 -0
  147. package/dist/store/observableKeyedStore.d.ts +2 -0
  148. package/dist/store/observableKeyedStore.js +1 -0
  149. package/dist/survival/decayMeter.js +1 -3
  150. package/dist/survival/regionHealth.js +1 -3
  151. package/dist/ui/hudLayout.d.ts +5 -0
  152. package/dist/ui/hudLayout.js +17 -1
  153. package/dist/world/authoredEntities.d.ts +29 -0
  154. package/dist/world/authoredEntities.js +29 -0
  155. package/dist/world/authoredObjects.d.ts +15 -1
  156. package/dist/world/authoredObjects.js +13 -1
  157. package/dist/world/authoredSpawn.d.ts +39 -0
  158. package/dist/world/authoredSpawn.js +37 -0
  159. package/dist/world/buildSockets.d.ts +152 -0
  160. package/dist/world/buildSockets.js +188 -0
  161. package/dist/world/cityBlocks.d.ts +152 -0
  162. package/dist/world/cityBlocks.js +670 -0
  163. package/dist/world/cityContent.d.ts +77 -0
  164. package/dist/world/cityContent.js +261 -0
  165. package/dist/world/cityGeometry.d.ts +114 -0
  166. package/dist/world/cityGeometry.js +573 -0
  167. package/dist/world/cityKind.d.ts +314 -0
  168. package/dist/world/cityKind.js +1578 -0
  169. package/dist/world/features.d.ts +18 -3
  170. package/dist/world/features.js +10 -1
  171. package/dist/world/grassKind.js +2 -2
  172. package/dist/world/markers.d.ts +50 -4
  173. package/dist/world/markers.js +49 -0
  174. package/dist/world/massing.js +1 -1
  175. package/dist/world/minimapBake.d.ts +78 -0
  176. package/dist/world/minimapBake.js +208 -0
  177. package/dist/world/minimapTrack.d.ts +17 -0
  178. package/dist/world/minimapTrack.js +22 -0
  179. package/dist/world/pathNetwork.d.ts +159 -0
  180. package/dist/world/pathNetwork.js +684 -0
  181. package/dist/world/place.d.ts +166 -0
  182. package/dist/world/place.js +96 -0
  183. package/dist/world/placeAsset.js +3 -1
  184. package/dist/world/poleLineKind.d.ts +40 -0
  185. package/dist/world/poleLineKind.js +97 -0
  186. package/dist/world/polyline.js +9 -2
  187. package/dist/world/resourceNode.d.ts +109 -0
  188. package/dist/world/resourceNode.js +140 -0
  189. package/dist/world/scatter.js +2 -20
  190. package/dist/world/streets.d.ts +29 -0
  191. package/dist/world/streets.js +55 -0
  192. package/dist/world/terraform.js +58 -88
  193. package/dist/world/terrain.d.ts +45 -0
  194. package/dist/world/terrain.js +78 -0
  195. package/dist/world.d.ts +14 -5
  196. package/dist/world.js +12 -3
  197. package/package.json +1 -1
  198. package/skills/game-design/SKILL.md +28 -0
  199. package/skills/game-design/agents/openai.yaml +4 -0
  200. package/skills/game-design/references/application-playbook.md +170 -0
  201. package/skills/game-design/references/field-guide.md +288 -0
  202. package/skills/jgengine/SKILL.md +7 -4
  203. package/skills/jgengine/api.md +172 -79
  204. package/skills/jgengine/capabilities.md +104 -2
  205. package/skills/jgengine/recipes/minimal-game.md +88 -0
  206. package/skills/jgengine-combat/SKILL.md +5 -1
  207. package/skills/jgengine-combat/api.md +36 -0
  208. package/skills/jgengine-combat/capabilities.md +44 -0
  209. package/skills/jgengine-combat/recipes/portable-stat-pools.md +122 -0
  210. package/skills/jgengine-combat/reference.md +12 -2
  211. package/skills/jgengine-gameplay/api.md +152 -26
  212. package/skills/jgengine-gameplay/capabilities.md +78 -2
  213. package/skills/jgengine-gameplay/reference-systems.md +1 -5
  214. package/skills/jgengine-world/SKILL.md +13 -0
  215. package/skills/jgengine-world/api.md +419 -15
  216. package/skills/jgengine-world/capabilities.md +108 -2
  217. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  218. package/skills/jgengine-world/reference.md +43 -4
  219. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -1,47 +1,15 @@
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 { createItemUse } from "../item/use.js";
27
10
  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";
11
+ import {} from "../scene/entityStats.js";
12
+ import {} from "../scene/entityStore.js";
45
13
  import { createChangeSignal, notifyAfter } from "../store/changeSignal.js";
46
14
  import { createObservableKeyedStore } from "../store/observableKeyedStore.js";
47
15
  import { applyWorldSnapshot, composeWorldSnapshot, } from "./worldSnapshot.js";
@@ -50,15 +18,14 @@ import { createRuntimeSave } from "./runtimeSave.js";
50
18
  import { isOffline } from "./adapter.js";
51
19
  import { localSaveBackend, memorySaveBackend } from "../game/saveStore.js";
52
20
  import { createSimClock } from "../time/simClock.js";
53
- import {} from "../turn/turnLoop.js";
54
- import { RaceState } from "../game/race.js";
55
21
  import { createCameraDirector } from "./cameraDirector.js";
56
22
  import { createInputSnapshot } from "./inputSnapshot.js";
57
- import { createMotionIntents } from "./motionIntents.js";
58
23
  import { baselineDescriptors } from "./descriptors/baseline.js";
59
24
  import { featureDescriptors } from "./descriptors/features.js";
60
- import { createCombatFx } from "./context/combatFx.js";
25
+ import { createCombatSubsystem } from "./context/combat.js";
61
26
  import { createContextRegistries } from "./context/registries.js";
27
+ import { createPlayerSubsystem } from "./context/player.js";
28
+ import { createSceneSubsystem } from "./context/scene.js";
62
29
  import { createWorldItemContext } from "./context/worldItems.js";
63
30
  export function createGameContext(options) {
64
31
  const { definition, content, player } = options;
@@ -69,131 +36,6 @@ export function createGameContext(options) {
69
36
  const activeUserId = () => actingUserId ?? player.userId;
70
37
  const time = createSimClock({ config: definition.time, onChange: signal.notify });
71
38
  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
39
  const rawEvents = createGameEvents();
198
40
  const events = {
199
41
  on: rawEvents.on,
@@ -203,8 +45,27 @@ export function createGameContext(options) {
203
45
  signal.notify();
204
46
  },
205
47
  };
48
+ // --- Scene installer (entities, objects, colliders, spatial, raycast, targeting) ---
49
+ const scene = createSceneSubsystem({
50
+ content,
51
+ signalNotify: signal.notify,
52
+ ground,
53
+ events,
54
+ time,
55
+ ...(occluder !== undefined ? { occluder } : {}),
56
+ ...(options.models !== undefined ? { models: options.models } : {}),
57
+ });
58
+ const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
59
+ // --- Shared game services (weapon, feed, features bag, commands, item use) ---
60
+ const weapon = createWeaponStats((itemId) => content.itemById?.(itemId));
206
61
  const feed = createGameFeed(definition.feed);
207
62
  const lootRegistry = createLootRegistry();
63
+ /**
64
+ * Single feature-activation path (G9): `definition.features` is already the final map after
65
+ * `defineGame` OR-merged explicit boolean sugar (`features: { quest: true }`) with system-implied
66
+ * flags via `mergeSystemFeatures`. Descriptor install below is the only place that decides
67
+ * "feature on" — never a second enable check against systems.
68
+ */
208
69
  const features = definition.features ?? {};
209
70
  const featureRegistry = new Map();
210
71
  const featureValue = (key) => featureRegistry.get(key);
@@ -222,16 +83,6 @@ export function createGameContext(options) {
222
83
  }
223
84
  return rawSocial;
224
85
  };
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
86
  const commandRegistry = createCommandRegistry();
236
87
  if (definition.lifecycle !== undefined) {
237
88
  const lifecycle = definition.lifecycle;
@@ -251,50 +102,18 @@ export function createGameContext(options) {
251
102
  });
252
103
  }
253
104
  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();
280
- },
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();
287
- return null;
288
- },
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")
295
- return;
296
- }
297
- }
105
+ // --- Player installer (inventory, wallets, pose, motion, loadout) ---
106
+ const playerSys = createPlayerSubsystem({
107
+ signalNotify: signal.notify,
108
+ inventoryDeclarations: definition.inventories ?? {},
109
+ entities,
110
+ events,
111
+ catalogEntry,
112
+ ensureInstanceStats,
113
+ feature: featureValue,
114
+ });
115
+ const { inventoryIds, inventoryByUser, inventoryFor, layouts, wallets, walletOf, economy, putIntoAnyInventory, playerStatsFor, pose, possession, motionByUser, motionFor, loadouts, seedUserPool, } = playerSys;
116
+ scene.setOnAfterDespawn((instanceId) => pose.clear(instanceId));
298
117
  const loot = {
299
118
  register: lootRegistry.register,
300
119
  has: lootRegistry.has,
@@ -310,100 +129,6 @@ export function createGameContext(options) {
310
129
  events.emit("loot.granted", event);
311
130
  },
312
131
  };
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
132
  const { worldItems, spawnWorldItem, pickupWorldItem } = createWorldItemContext({
408
133
  entities,
409
134
  events,
@@ -411,171 +136,43 @@ export function createGameContext(options) {
411
136
  grantToPlayer: loot.grantToPlayer,
412
137
  signalNotify: signal.notify,
413
138
  });
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,
139
+ // --- Combat installer (effects, projectiles, death, combat FX) ---
140
+ // Command runner closes over `ctx` once assembled (same late-bind pattern as before).
141
+ let ctxRef = null;
142
+ const combat = createCombatSubsystem({
143
+ content,
144
+ signalNotify: signal.notify,
145
+ now,
475
146
  events,
476
147
  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,
148
+ entities,
149
+ objects,
150
+ combatSpatial,
151
+ sceneRaycast,
531
152
  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();
153
+ objectCollidersOf,
154
+ catalogEntry,
155
+ statsByInstance,
156
+ weapon,
157
+ loot,
158
+ lootRegistry,
159
+ spawnWorldItem,
160
+ despawnEntity,
161
+ runCommand(name, args) {
162
+ if (ctxRef === null)
163
+ return;
164
+ commandRegistry.run(ctxRef, name, args);
562
165
  },
563
- collidersOf: objectCollidersOf,
564
- selection: objectSelection,
565
- };
166
+ localUserId: player.userId,
167
+ ...(definition.physics !== undefined ? { physics: definition.physics } : {}),
168
+ });
169
+ scene.setOnAfterSpawn((instanceId) => combat.death.revive(instanceId));
170
+ const { projectiles, combatFx, vfxInstances, effects } = combat;
566
171
  const store = notifyAfter(createObservableKeyedStore(), ["set", "delete", "hydrate"], signal.notify);
567
172
  const { pile, loop, raceState, cardPiles, turnLoops } = createContextRegistries(signal.notify);
568
173
  const camera = notifyAfter(createCameraDirector(), ["follow", "setCinematic", "setChaseTuning"], signal.notify);
569
174
  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
- }
175
+ // --- Descriptor install (single feature enable path) ---
579
176
  const featureDeps = {
580
177
  features,
581
178
  signalNotify: signal.notify,
@@ -614,6 +211,12 @@ export function createGameContext(options) {
614
211
  if (build.save !== undefined)
615
212
  featureSaveModules.push(build.save);
616
213
  }
214
+ const gameFeatureBlock = {};
215
+ for (const descriptor of featureDescriptors) {
216
+ if (descriptor.key === "cosmetics")
217
+ continue;
218
+ gameFeatureBlock[descriptor.key] = featureRegistry.get(descriptor.key);
219
+ }
617
220
  const baselineDeps = {
618
221
  signalNotify: signal.notify,
619
222
  entities,
@@ -638,15 +241,16 @@ export function createGameContext(options) {
638
241
  const replication = options.replication;
639
242
  const projectsViewers = policyProjectsViewers(replication);
640
243
  const aoiRadius = replication?.aoiRadius;
244
+ const typedProject = (project) => (data, viewer, world) => project(data, viewer, world);
641
245
  const projectorFor = (key) => {
642
246
  if (aoiRadius !== undefined && key === "entities") {
643
- return (data, viewer) => projectEntitiesForViewer(data, viewer, aoiRadius);
247
+ return typedProject((data, viewer) => projectEntitiesForViewer(data, viewer, aoiRadius));
644
248
  }
645
249
  if (aoiRadius !== undefined && key === "stats") {
646
- return (data, viewer, world) => projectByVisibleIds(data, visibleEntityIds((world["entities"] ?? []), viewer, aoiRadius));
250
+ return typedProject((data, viewer, world) => projectByVisibleIds(data, visibleEntityIds((world["entities"] ?? []), viewer, aoiRadius)));
647
251
  }
648
252
  if (replication?.privatePerUser === true && key === "inventory") {
649
- return (data, viewer) => projectPerUserForViewer(data, viewer);
253
+ return typedProject((data, viewer) => projectPerUserForViewer(data, viewer));
650
254
  }
651
255
  return undefined;
652
256
  };
@@ -659,17 +263,6 @@ export function createGameContext(options) {
659
263
  };
660
264
  });
661
265
  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
266
  const saveModules = [
674
267
  ...snapshotModules,
675
268
  ...baselineBuilds.flatMap((build) => (build.save === undefined ? [] : [build.save])),
@@ -687,6 +280,7 @@ export function createGameContext(options) {
687
280
  });
688
281
  saveModules.push(module);
689
282
  }
283
+ // --- Assemble public GameContext ---
690
284
  const ctx = {
691
285
  scene: {
692
286
  object: sceneObjects,
@@ -705,17 +299,17 @@ export function createGameContext(options) {
705
299
  resetAllToSpawn,
706
300
  blackboard: entities.blackboard,
707
301
  stats: entityStats,
708
- floatText: emitFloatText,
709
- vfx: emitVfx,
302
+ floatText: combatFx.emitFloatText,
303
+ vfx: combatFx.emitVfx,
710
304
  vfxInstance: vfxInstances,
711
- telegraph: fireTelegraph,
712
- hitReaction: applyHitReaction,
305
+ telegraph: combatFx.fireTelegraph,
306
+ hitReaction: combatFx.applyHitReaction,
713
307
  setTarget: targeting.setTarget,
714
308
  getTarget: targeting.getTarget,
715
309
  cycleTarget: targeting.cycleTarget,
716
310
  canReceive: effects.canReceive,
717
311
  preview: effects.preview,
718
- effect: applyEffectAndFloat,
312
+ effect: combatFx.applyEffectAndFloat,
719
313
  willHitProjectile: projectiles.willHitProjectile,
720
314
  fireProjectile: projectiles.fireProjectile,
721
315
  settleProjectile: projectiles.settleProjectile,
@@ -784,6 +378,9 @@ export function createGameContext(options) {
784
378
  play: (sound, at) => events.emit("audio.play", at === undefined ? { sound } : { sound, at }),
785
379
  music: (theme, transpose) => events.emit("audio.music", transpose === undefined ? { theme } : { theme, transpose }),
786
380
  resume: () => events.emit("audio.resume", {}),
381
+ loop: (id, sound, options) => events.emit("audio.loopStart", options?.at === undefined ? { id, sound } : { id, sound, at: options.at }),
382
+ setLoop: (id, params) => events.emit("audio.loopSet", { id, ...params }),
383
+ stopLoop: (id) => events.emit("audio.loopStop", { id }),
787
384
  },
788
385
  feed: {
789
386
  bind: (action) => feed.bind(action, events),
@@ -800,20 +397,9 @@ export function createGameContext(options) {
800
397
  },
801
398
  },
802
399
  loot,
803
- trade: featureValue("trade"),
804
- quest: featureValue("quest"),
805
- social: featureValue("social"),
806
- chat: featureValue("chat"),
807
- unlocks: featureValue("unlocks"),
808
400
  economy,
809
- leaderboard: featureValue("leaderboard"),
810
- roster: featureValue("roster"),
811
401
  store,
812
- dialogue: featureValue("dialogue"),
813
- cards: featureValue("cards"),
814
- turn: featureValue("turn"),
815
- race: featureValue("race"),
816
- players: featureValue("players"),
402
+ ...gameFeatureBlock,
817
403
  registerSave,
818
404
  registerReplicate,
819
405
  },
@@ -867,6 +453,7 @@ export function createGameContext(options) {
867
453
  replicationVersion,
868
454
  replicatesPerViewer: () => projectsViewers,
869
455
  };
456
+ ctxRef = ctx;
870
457
  const saveOptions = resolveSaveOptions(definition, options);
871
458
  if (saveOptions !== undefined) {
872
459
  const saveTarget = {