@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
@@ -0,0 +1,247 @@
1
+ const FRAME_CAPACITY = 240;
2
+ const RECENT_FRAMES = 60;
3
+ /** Frame duration in milliseconds above which a frame is recorded as a long-frame spike (~30fps budget). */
4
+ export const LONG_FRAME_MS = 33.4;
5
+ const LONG_FRAME_CAPACITY = 40;
6
+ const MAX_PHASES_PER_FRAME = 24;
7
+ const MAX_LONG_FRAME_PHASES = 8;
8
+ const PHASE_HOT_MS = 2;
9
+ /** @internal */
10
+ export function nowMs() {
11
+ const perf = globalThis.performance;
12
+ return perf !== undefined ? perf.now() : Date.now();
13
+ }
14
+ function percentile(sorted, fraction) {
15
+ if (sorted.length === 0)
16
+ return 0;
17
+ const index = Math.min(sorted.length - 1, Math.floor(sorted.length * fraction));
18
+ return sorted[index];
19
+ }
20
+ function clampMs(value) {
21
+ return value > 0 && Number.isFinite(value) ? value : 0;
22
+ }
23
+ function rankPhases(phases) {
24
+ const entries = Object.entries(phases)
25
+ .filter(([, ms]) => Number.isFinite(ms) && ms > 0)
26
+ .map(([name, ms]) => ({ name, ms }))
27
+ .sort((a, b) => b.ms - a.ms)
28
+ .slice(0, MAX_LONG_FRAME_PHASES);
29
+ const total = entries.reduce((sum, entry) => sum + entry.ms, 0);
30
+ return entries.map((entry) => ({
31
+ name: entry.name,
32
+ ms: entry.ms,
33
+ pct: total > 0 ? (entry.ms / total) * 100 : 0,
34
+ }));
35
+ }
36
+ function explainLongFrame(frameMs, simMs, outsideMs, ranked) {
37
+ const top = ranked[0] ?? null;
38
+ const topPhaseMs = top?.ms ?? 0;
39
+ const phaseBits = ranked
40
+ .filter((entry) => entry.ms >= PHASE_HOT_MS)
41
+ .slice(0, 3)
42
+ .map((entry) => `${entry.name} ${entry.ms.toFixed(1)}ms`);
43
+ const outsideDominates = outsideMs > topPhaseMs && outsideMs >= simMs * 0.55 && outsideMs >= frameMs * 0.4;
44
+ if (outsideDominates) {
45
+ const detail = phaseBits.length > 0 ? ` · sim was ${phaseBits.join(", ")}` : simMs > 1 ? ` · sim ${simMs.toFixed(1)}ms` : "";
46
+ return {
47
+ topPhase: top?.name ?? null,
48
+ culprit: "outside-sim",
49
+ reason: `display/render/browser hitch ${outsideMs.toFixed(1)}ms outside sim${detail}`,
50
+ };
51
+ }
52
+ if (top !== null && top.ms >= PHASE_HOT_MS) {
53
+ const rest = phaseBits.slice(1);
54
+ const restText = rest.length > 0 ? ` · also ${rest.join(", ")}` : "";
55
+ const outsideText = outsideMs >= PHASE_HOT_MS ? ` · +${outsideMs.toFixed(1)}ms outside sim` : "";
56
+ return {
57
+ topPhase: top.name,
58
+ culprit: top.name,
59
+ reason: `${top.name} ${top.ms.toFixed(1)}ms (${top.pct.toFixed(0)}% of timed sim)${restText}${outsideText}`,
60
+ };
61
+ }
62
+ if (simMs >= frameMs * 0.5) {
63
+ return {
64
+ topPhase: null,
65
+ culprit: "sim",
66
+ reason: `sim ${simMs.toFixed(1)}ms with no named phase ≥${PHASE_HOT_MS}ms — wrap hot work in measure("name", fn)`,
67
+ };
68
+ }
69
+ return {
70
+ topPhase: null,
71
+ culprit: "outside-sim",
72
+ reason: `frame ${frameMs.toFixed(1)}ms · sim ${simMs.toFixed(1)}ms · outside ${outsideMs.toFixed(1)}ms (render/React/GC/tab throttle)`,
73
+ };
74
+ }
75
+ /** Create the frame subsystem that records frame/sim/phase timings, computes stats, and captures long-frame events. */
76
+ export const createFrameModule = (deps) => {
77
+ const { readProbes } = deps;
78
+ const frameMsBuffer = new Float64Array(FRAME_CAPACITY);
79
+ const simMsBuffer = new Float64Array(FRAME_CAPACITY);
80
+ const outsideMsBuffer = new Float64Array(FRAME_CAPACITY);
81
+ let frameIndex = 0;
82
+ let frameCount = 0;
83
+ let renderSample = null;
84
+ const longFrameEvents = [];
85
+ const phaseAccumulators = new Map();
86
+ let currentPhases = {};
87
+ let currentPhaseCount = 0;
88
+ const addPhaseMs = (name, ms) => {
89
+ const elapsed = clampMs(ms);
90
+ if (elapsed === 0 || name.length === 0)
91
+ return;
92
+ if (currentPhases[name] === undefined) {
93
+ if (currentPhaseCount >= MAX_PHASES_PER_FRAME)
94
+ return;
95
+ currentPhaseCount += 1;
96
+ currentPhases[name] = elapsed;
97
+ return;
98
+ }
99
+ currentPhases[name] = currentPhases[name] + elapsed;
100
+ };
101
+ const resetCurrentPhases = () => {
102
+ currentPhases = {};
103
+ currentPhaseCount = 0;
104
+ };
105
+ const recordPhaseStats = (phases) => {
106
+ for (const [name, ms] of Object.entries(phases)) {
107
+ const elapsed = clampMs(ms);
108
+ if (elapsed === 0)
109
+ continue;
110
+ const existing = phaseAccumulators.get(name);
111
+ if (existing === undefined) {
112
+ phaseAccumulators.set(name, { sumMs: elapsed, maxMs: elapsed, lastMs: elapsed, samples: 1 });
113
+ }
114
+ else {
115
+ existing.sumMs += elapsed;
116
+ existing.maxMs = Math.max(existing.maxMs, elapsed);
117
+ existing.lastMs = elapsed;
118
+ existing.samples += 1;
119
+ }
120
+ }
121
+ };
122
+ const phaseStatsList = (avgSimMs) => {
123
+ const list = [];
124
+ for (const [name, acc] of phaseAccumulators) {
125
+ const avgMs = acc.sumMs / acc.samples;
126
+ list.push({
127
+ name,
128
+ avgMs,
129
+ maxMs: acc.maxMs,
130
+ lastMs: acc.lastMs,
131
+ samples: acc.samples,
132
+ pctOfSim: avgSimMs > 0 ? (avgMs / avgSimMs) * 100 : 0,
133
+ });
134
+ }
135
+ list.sort((a, b) => b.avgMs - a.avgMs);
136
+ return list;
137
+ };
138
+ const frameStats = () => {
139
+ if (frameCount === 0)
140
+ return null;
141
+ const size = Math.min(frameCount, FRAME_CAPACITY);
142
+ const frames = [];
143
+ const sims = [];
144
+ const outsides = [];
145
+ for (let offset = 0; offset < size; offset += 1) {
146
+ const at = (frameIndex - size + offset + FRAME_CAPACITY) % FRAME_CAPACITY;
147
+ frames.push(frameMsBuffer[at]);
148
+ sims.push(simMsBuffer[at]);
149
+ outsides.push(outsideMsBuffer[at]);
150
+ }
151
+ const recent = frames.slice(-RECENT_FRAMES);
152
+ const recentAvg = recent.reduce((sum, ms) => sum + ms, 0) / recent.length;
153
+ const sorted = [...frames].sort((a, b) => a - b);
154
+ const avgSimMs = sims.reduce((sum, ms) => sum + ms, 0) / sims.length;
155
+ return {
156
+ fps: recentAvg > 0 ? 1000 / recentAvg : 0,
157
+ avgFrameMs: frames.reduce((sum, ms) => sum + ms, 0) / frames.length,
158
+ p95FrameMs: percentile(sorted, 0.95),
159
+ maxFrameMs: sorted[sorted.length - 1],
160
+ avgSimMs,
161
+ maxSimMs: sims.reduce((max, ms) => Math.max(max, ms), 0),
162
+ avgOutsideMs: outsides.reduce((sum, ms) => sum + ms, 0) / outsides.length,
163
+ maxOutsideMs: outsides.reduce((max, ms) => Math.max(max, ms), 0),
164
+ longFrames: frames.filter((ms) => ms > LONG_FRAME_MS).length,
165
+ samples: size,
166
+ recentFrameMs: recent,
167
+ phases: phaseStatsList(avgSimMs),
168
+ };
169
+ };
170
+ const recordFrame = (sample) => {
171
+ const frameMs = clampMs(sample.frameMs);
172
+ const simMs = clampMs(sample.simMs);
173
+ const outsideMs = clampMs(frameMs - simMs);
174
+ const phases = sample.phases !== undefined
175
+ ? Object.fromEntries(Object.entries(sample.phases)
176
+ .filter(([, ms]) => Number.isFinite(ms) && ms > 0)
177
+ .slice(0, MAX_PHASES_PER_FRAME))
178
+ : { ...currentPhases };
179
+ frameMsBuffer[frameIndex] = frameMs;
180
+ simMsBuffer[frameIndex] = simMs;
181
+ outsideMsBuffer[frameIndex] = outsideMs;
182
+ frameIndex = (frameIndex + 1) % FRAME_CAPACITY;
183
+ frameCount += 1;
184
+ recordPhaseStats(phases);
185
+ if (frameMs > LONG_FRAME_MS) {
186
+ const ranked = rankPhases(phases);
187
+ const explained = explainLongFrame(frameMs, simMs, outsideMs, ranked);
188
+ longFrameEvents.push({
189
+ at: Date.now(),
190
+ frameMs,
191
+ simMs,
192
+ outsideMs,
193
+ phases: ranked,
194
+ topPhase: explained.topPhase,
195
+ culprit: explained.culprit,
196
+ reason: explained.reason,
197
+ probes: readProbes(),
198
+ render: renderSample,
199
+ });
200
+ if (longFrameEvents.length > LONG_FRAME_CAPACITY) {
201
+ longFrameEvents.splice(0, longFrameEvents.length - LONG_FRAME_CAPACITY);
202
+ }
203
+ }
204
+ resetCurrentPhases();
205
+ };
206
+ return {
207
+ frame: {
208
+ record: recordFrame,
209
+ stats: frameStats,
210
+ longFrames: () => longFrameEvents,
211
+ clearLongFrames() {
212
+ longFrameEvents.length = 0;
213
+ },
214
+ reset() {
215
+ frameIndex = 0;
216
+ frameCount = 0;
217
+ longFrameEvents.length = 0;
218
+ phaseAccumulators.clear();
219
+ resetCurrentPhases();
220
+ },
221
+ },
222
+ profile: {
223
+ begin(name) {
224
+ const start = nowMs();
225
+ return () => addPhaseMs(name, nowMs() - start);
226
+ },
227
+ measure(name, fn) {
228
+ const start = nowMs();
229
+ try {
230
+ return fn();
231
+ }
232
+ finally {
233
+ addPhaseMs(name, nowMs() - start);
234
+ }
235
+ },
236
+ add: addPhaseMs,
237
+ current: () => ({ ...currentPhases }),
238
+ reset: resetCurrentPhases,
239
+ },
240
+ render: {
241
+ record(sample) {
242
+ renderSample = sample;
243
+ },
244
+ latest: () => renderSample,
245
+ },
246
+ };
247
+ };
@@ -0,0 +1,15 @@
1
+ import type { ChangeSignal } from "../store/changeSignal.js";
2
+ import type { Devtools } from "./types.js";
3
+ /** @internal */
4
+ export declare function formatLogArg(arg: unknown): string;
5
+ /** Format an array of console arguments into a single truncated log message string. */
6
+ export declare function formatLogMessage(args: readonly unknown[]): string;
7
+ /** Logs subsystem exposing the log-capture and latency-tracking facades. */
8
+ export interface LogsModule {
9
+ logs: Devtools["logs"];
10
+ latency: Devtools["latency"];
11
+ }
12
+ /** Create the logs subsystem that buffers captured console output and records latency samples. */
13
+ export declare const createLogsModule: (deps: {
14
+ signal: ChangeSignal;
15
+ }) => LogsModule;
@@ -0,0 +1,86 @@
1
+ const LATENCY_CAPACITY = 120;
2
+ const LOG_CAPACITY = 200;
3
+ const LOG_MESSAGE_MAX = 400;
4
+ /** @internal */
5
+ export function formatLogArg(arg) {
6
+ if (typeof arg === "string")
7
+ return arg;
8
+ if (arg instanceof Error)
9
+ return `${arg.name}: ${arg.message}`;
10
+ try {
11
+ return JSON.stringify(arg) ?? String(arg);
12
+ }
13
+ catch {
14
+ return String(arg);
15
+ }
16
+ }
17
+ /** Format an array of console arguments into a single truncated log message string. */
18
+ export function formatLogMessage(args) {
19
+ const message = args.map(formatLogArg).join(" ");
20
+ return message.length > LOG_MESSAGE_MAX ? `${message.slice(0, LOG_MESSAGE_MAX)}…` : message;
21
+ }
22
+ /** Create the logs subsystem that buffers captured console output and records latency samples. */
23
+ export const createLogsModule = (deps) => {
24
+ const { signal } = deps;
25
+ const logEntries = [];
26
+ let consoleCaptured = false;
27
+ const latencySamples = [];
28
+ const pushLog = (level, message) => {
29
+ logEntries.push({ at: Date.now(), level, message });
30
+ if (logEntries.length > LOG_CAPACITY)
31
+ logEntries.splice(0, logEntries.length - LOG_CAPACITY);
32
+ };
33
+ const latencyStats = () => {
34
+ if (latencySamples.length === 0)
35
+ return null;
36
+ let min = Number.POSITIVE_INFINITY;
37
+ let max = 0;
38
+ let sum = 0;
39
+ for (const ms of latencySamples) {
40
+ min = Math.min(min, ms);
41
+ max = Math.max(max, ms);
42
+ sum += ms;
43
+ }
44
+ return {
45
+ lastMs: latencySamples[latencySamples.length - 1],
46
+ avgMs: sum / latencySamples.length,
47
+ minMs: min,
48
+ maxMs: max,
49
+ samples: latencySamples.length,
50
+ };
51
+ };
52
+ return {
53
+ logs: {
54
+ push: pushLog,
55
+ captureConsole() {
56
+ if (consoleCaptured)
57
+ return;
58
+ const globalConsole = globalThis.console;
59
+ if (globalConsole === undefined)
60
+ return;
61
+ consoleCaptured = true;
62
+ const levels = ["log", "info", "warn", "error"];
63
+ for (const level of levels) {
64
+ const original = globalConsole[level].bind(globalConsole);
65
+ globalConsole[level] = (...args) => {
66
+ pushLog(level, formatLogMessage(args));
67
+ original(...args);
68
+ };
69
+ }
70
+ },
71
+ list: () => logEntries,
72
+ clear() {
73
+ logEntries.length = 0;
74
+ signal.notify();
75
+ },
76
+ },
77
+ latency: {
78
+ record(ms) {
79
+ latencySamples.push(ms);
80
+ if (latencySamples.length > LATENCY_CAPACITY)
81
+ latencySamples.splice(0, latencySamples.length - LATENCY_CAPACITY);
82
+ },
83
+ stats: latencyStats,
84
+ },
85
+ };
86
+ };
@@ -0,0 +1,16 @@
1
+ import type { ChangeSignal } from "../store/changeSignal.js";
2
+ import { type ControlRecord } from "./controls.js";
3
+ import type { DiscoveredRecord } from "./discover.js";
4
+ import type { Devtools } from "./types.js";
5
+ /** Overrides subsystem exposing the export/apply facade for persisting and restoring control values. */
6
+ export interface OverridesModule {
7
+ overrides: Devtools["overrides"];
8
+ }
9
+ /** Create the overrides subsystem that serializes changed control values and re-applies a saved overrides payload. */
10
+ export declare const createOverridesModule: (deps: {
11
+ signal: ChangeSignal;
12
+ controlRecords: Map<string, ControlRecord>;
13
+ writeControl: (record: ControlRecord, raw: unknown) => boolean;
14
+ discoveredRecords: Map<string, DiscoveredRecord>;
15
+ enableDiscovered: (id: string) => void;
16
+ }) => OverridesModule;
@@ -0,0 +1,66 @@
1
+ import { OVERRIDES_FORMAT_VERSION, cloneValue, parseOverridesPayload, } from "./tunableSchema.js";
2
+ import { isStructuralKind } from "./controls.js";
3
+ /** Create the overrides subsystem that serializes changed control values and re-applies a saved overrides payload. */
4
+ export const createOverridesModule = (deps) => {
5
+ const { signal, controlRecords, writeControl, discoveredRecords, enableDiscovered } = deps;
6
+ return {
7
+ overrides: {
8
+ export() {
9
+ const enabled = [...discoveredRecords.values()].filter((r) => r.enabled).map((r) => r.id);
10
+ const values = {};
11
+ const schemas = {};
12
+ for (const record of controlRecords.values()) {
13
+ schemas[record.name] = { kind: record.kind, schemaVersion: record.schemaVersion };
14
+ const same = isStructuralKind(record.kind)
15
+ ? JSON.stringify(record.value) === JSON.stringify(record.initial)
16
+ : Object.is(record.value, record.initial);
17
+ if (!same)
18
+ values[record.name] = cloneValue(record.value);
19
+ }
20
+ return {
21
+ version: OVERRIDES_FORMAT_VERSION,
22
+ enabled,
23
+ values,
24
+ schemas,
25
+ };
26
+ },
27
+ apply(raw) {
28
+ const parsed = parseOverridesPayload(raw);
29
+ const diagnostics = [...parsed.diagnostics];
30
+ const skipped = [];
31
+ if (parsed.overrides === null) {
32
+ return { applied: 0, skipped, diagnostics };
33
+ }
34
+ const overrides = parsed.overrides;
35
+ let applied = 0;
36
+ for (const id of overrides.enabled) {
37
+ if (!discoveredRecords.has(id) && !controlRecords.has(id)) {
38
+ skipped.push({ id, reason: "unknown control id" });
39
+ continue;
40
+ }
41
+ enableDiscovered(id);
42
+ applied += 1;
43
+ }
44
+ for (const [name, value] of Object.entries(overrides.values)) {
45
+ const record = controlRecords.get(name);
46
+ if (record === undefined) {
47
+ skipped.push({ id: name, reason: "control not registered" });
48
+ continue;
49
+ }
50
+ const schema = overrides.schemas?.[name];
51
+ if (schema !== undefined && schema.kind !== record.kind) {
52
+ skipped.push({ id: name, reason: `kind mismatch stored=${schema.kind} current=${record.kind}` });
53
+ continue;
54
+ }
55
+ if (!writeControl(record, value)) {
56
+ skipped.push({ id: name, reason: "value failed validation" });
57
+ continue;
58
+ }
59
+ applied += 1;
60
+ }
61
+ signal.notify();
62
+ return { applied, skipped, diagnostics };
63
+ },
64
+ },
65
+ };
66
+ };
@@ -0,0 +1,212 @@
1
+ import type { ChangeSignal } from "../store/changeSignal.js";
2
+ import type { AngleUnit, DevtoolsControlKind, DevtoolsOverrides, DiscoverySkip, OverrideApplyDiagnostic, ScanMeta, TunableChoice, TunableOptions } from "./tunableSchema.js";
3
+ /** Registered tunable control with its resolved schema metadata and read/write/reset accessors. */
4
+ export interface DevtoolsControl {
5
+ readonly name: string;
6
+ readonly kind: DevtoolsControlKind;
7
+ readonly label: string;
8
+ readonly group: string;
9
+ readonly initial: unknown;
10
+ readonly min?: number;
11
+ readonly max?: number;
12
+ readonly step?: number;
13
+ readonly options?: readonly unknown[];
14
+ readonly choices?: readonly TunableChoice[];
15
+ readonly unit?: AngleUnit;
16
+ readonly displayUnit?: AngleUnit;
17
+ readonly wrap?: boolean;
18
+ readonly integer?: boolean;
19
+ readonly axisLabels?: readonly string[];
20
+ readonly axisMin?: readonly number[];
21
+ readonly axisMax?: readonly number[];
22
+ readonly axisStep?: readonly number[];
23
+ readonly hasAlpha?: boolean;
24
+ readonly schemaVersion: number;
25
+ read(): unknown;
26
+ write(value: unknown): boolean;
27
+ reset(): void;
28
+ }
29
+ /** Typed handle to a registered control, exposing its live value and subscribe/set/reset operations. */
30
+ export interface Tunable<T> {
31
+ readonly name: string;
32
+ readonly kind: DevtoolsControlKind;
33
+ readonly initial: T;
34
+ readonly value: T;
35
+ set(value: T): boolean;
36
+ reset(): void;
37
+ subscribe(listener: (value: T) => void): () => void;
38
+ }
39
+ /** Outcome of applying a devtools overrides payload: counts of applied and skipped entries plus diagnostics. */
40
+ export interface OverrideApplyResult {
41
+ applied: number;
42
+ skipped: readonly OverrideApplyDiagnostic[];
43
+ diagnostics: readonly string[];
44
+ }
45
+ /** Aggregated frame-timing statistics over the recent sampling window, including per-phase breakdown. */
46
+ export interface FrameStats {
47
+ fps: number;
48
+ avgFrameMs: number;
49
+ p95FrameMs: number;
50
+ maxFrameMs: number;
51
+ avgSimMs: number;
52
+ maxSimMs: number;
53
+ avgOutsideMs: number;
54
+ maxOutsideMs: number;
55
+ longFrames: number;
56
+ samples: number;
57
+ recentFrameMs: readonly number[];
58
+ phases: readonly PhaseStats[];
59
+ }
60
+ /** Timing summary for a single named profiling phase across the sampling window. */
61
+ export interface PhaseStats {
62
+ name: string;
63
+ avgMs: number;
64
+ maxMs: number;
65
+ lastMs: number;
66
+ samples: number;
67
+ pctOfSim: number;
68
+ }
69
+ /** Recorded spike event for a frame that exceeded the long-frame threshold, with culprit attribution. */
70
+ export interface LongFrameEvent {
71
+ at: number;
72
+ frameMs: number;
73
+ simMs: number;
74
+ outsideMs: number;
75
+ phases: readonly {
76
+ name: string;
77
+ ms: number;
78
+ pct: number;
79
+ }[];
80
+ topPhase: string | null;
81
+ culprit: string;
82
+ reason: string;
83
+ probes: Record<string, unknown>;
84
+ render: RenderSample | null;
85
+ }
86
+ /** Snapshot of renderer resource counts (draw calls, triangles, geometries, textures) for a frame. */
87
+ export interface RenderSample {
88
+ drawCalls: number;
89
+ triangles: number;
90
+ geometries: number;
91
+ textures: number;
92
+ }
93
+ /** Aggregated latency statistics (last/avg/min/max) over recorded latency samples. */
94
+ export interface LatencyStats {
95
+ lastMs: number;
96
+ avgMs: number;
97
+ minMs: number;
98
+ maxMs: number;
99
+ samples: number;
100
+ }
101
+ /** Severity level for a captured devtools log entry. */
102
+ export type DevtoolsLogLevel = "log" | "info" | "warn" | "error";
103
+ /** A single captured log line with timestamp, level, and formatted message. */
104
+ export interface DevtoolsLogEntry {
105
+ at: number;
106
+ level: DevtoolsLogLevel;
107
+ message: string;
108
+ }
109
+ /** A control auto-discovered by scanning an object/table, with its binding metadata and current reader. */
110
+ export interface DiscoveredEntry {
111
+ readonly id: string;
112
+ readonly table: string;
113
+ readonly key: string;
114
+ readonly kind: DevtoolsControlKind;
115
+ readonly initial: unknown;
116
+ readonly enabled: boolean;
117
+ read(): unknown;
118
+ }
119
+ /** Get/set accessor pair plus initial value used to bind a discovered field to a control. */
120
+ export interface TunableAccessor {
121
+ initial: unknown;
122
+ get(): unknown;
123
+ set(value: unknown): void;
124
+ }
125
+ /** Serializable point-in-time snapshot of devtools state: frame, render, latency, logs, controls, and discovered fields. */
126
+ export interface DevtoolsSnapshot {
127
+ at: number;
128
+ frame: FrameStats | null;
129
+ render: RenderSample | null;
130
+ latency: LatencyStats | null;
131
+ longFrames: readonly LongFrameEvent[];
132
+ logs: readonly DevtoolsLogEntry[];
133
+ probes: Record<string, unknown>;
134
+ controls: readonly {
135
+ name: string;
136
+ kind: DevtoolsControlKind;
137
+ value: unknown;
138
+ initial: unknown;
139
+ }[];
140
+ discovered: readonly {
141
+ id: string;
142
+ kind: DevtoolsControlKind;
143
+ value: unknown;
144
+ enabled: boolean;
145
+ }[];
146
+ skipped: readonly DiscoverySkip[];
147
+ }
148
+ /** Per-frame timing input recorded by callers: total frame time, sim time, and optional named phase durations. */
149
+ export interface FrameRecordSample {
150
+ frameMs: number;
151
+ simMs: number;
152
+ phases?: Readonly<Record<string, number>>;
153
+ }
154
+ /** Top-level devtools facade aggregating frame, profile, render, logs, latency, controls, discover, overrides, and probes. */
155
+ export interface Devtools {
156
+ frame: {
157
+ record(sample: FrameRecordSample): void;
158
+ stats(): FrameStats | null;
159
+ longFrames(): readonly LongFrameEvent[];
160
+ clearLongFrames(): void;
161
+ /** Drop all frame/phase/long-frame history so the next stats() window starts clean — use after load/shader warmup before measuring. */
162
+ reset(): void;
163
+ };
164
+ profile: {
165
+ begin(name: string): () => void;
166
+ measure<T>(name: string, fn: () => T): T;
167
+ add(name: string, ms: number): void;
168
+ current(): Record<string, number>;
169
+ reset(): void;
170
+ };
171
+ render: {
172
+ record(sample: RenderSample): void;
173
+ latest(): RenderSample | null;
174
+ };
175
+ logs: {
176
+ push(level: DevtoolsLogLevel, message: string): void;
177
+ captureConsole(): void;
178
+ list(): readonly DevtoolsLogEntry[];
179
+ clear(): void;
180
+ };
181
+ latency: {
182
+ record(ms: number): void;
183
+ stats(): LatencyStats | null;
184
+ };
185
+ controls: {
186
+ register<T>(name: string, initial: T, options?: TunableOptions<T>): Tunable<T>;
187
+ list(): readonly DevtoolsControl[];
188
+ get(name: string): DevtoolsControl | null;
189
+ remove(name: string): void;
190
+ resetAll(): void;
191
+ };
192
+ discover: {
193
+ bind(table: string, key: string, accessor: TunableAccessor, options?: TunableOptions): void;
194
+ scanTable(table: string, candidate: unknown, meta?: ScanMeta): number;
195
+ scanModule(exports: Record<string, unknown>, meta?: ScanMeta): number;
196
+ list(): readonly DiscoveredEntry[];
197
+ skipped(): readonly DiscoverySkip[];
198
+ enable(id: string): void;
199
+ disable(id: string): void;
200
+ clear(): void;
201
+ };
202
+ overrides: {
203
+ export(): DevtoolsOverrides;
204
+ apply(overrides: DevtoolsOverrides | unknown): OverrideApplyResult;
205
+ };
206
+ probes: {
207
+ register(name: string, read: () => unknown): () => void;
208
+ read(): Record<string, unknown>;
209
+ };
210
+ signal: ChangeSignal;
211
+ snapshot(): DevtoolsSnapshot;
212
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ /**
2
+ * URL-backed debug flags — the single seam hosts use to mirror an engine debug surface (the scene
3
+ * editor, the devtools overlay) into the page query string. Mirroring means the URL is the honest
4
+ * record of what is open: share it to reproduce the state, and strip the param (then reload) to get
5
+ * a clean game with the surface gone. All functions no-op safely off the main browser thread.
6
+ */
7
+ /** The current query value of `param`, or null when absent (or no DOM). */
8
+ export declare function readUrlParam(param: string): string | null;
9
+ /** True when `param` is present and not an explicit off value (`0`/`false`/`off`/`no`). */
10
+ export declare function readUrlFlag(param: string): boolean;
11
+ /**
12
+ * Sets `param` to `value`, or removes it when `value` is null, rewriting the URL in place with
13
+ * `history.replaceState` so the rest of the query and the hash survive and no history entry is
14
+ * pushed. No-ops without a DOM, and skips the write when the URL already matches.
15
+ */
16
+ export declare function writeUrlParam(param: string, value: string | null): void;
17
+ /**
18
+ * Fires `listener` whenever the query string may have changed out from under a flag — browser
19
+ * back/forward (`popstate`). Manual address-bar edits reload the page, so initial reads cover
20
+ * those; this keeps in-app state honest across history navigation. Returns an unsubscribe.
21
+ */
22
+ export declare function subscribeUrlChange(listener: () => void): () => void;