@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
@@ -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 {};