@jgengine/core 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/ai/populationDirector.d.ts +103 -0
  3. package/dist/ai/populationDirector.js +190 -0
  4. package/dist/ai/pursuit.d.ts +56 -0
  5. package/dist/ai/pursuit.js +53 -0
  6. package/dist/authoring.d.ts +1 -1
  7. package/dist/authoring.js +1 -1
  8. package/dist/combat/effects.d.ts +11 -4
  9. package/dist/combat/effects.js +24 -17
  10. package/dist/combat/magazine.d.ts +13 -0
  11. package/dist/combat/magazine.js +27 -0
  12. package/dist/combat/vfxPresets.d.ts +176 -0
  13. package/dist/combat/vfxPresets.js +74 -0
  14. package/dist/combat/weaponFire.d.ts +114 -0
  15. package/dist/combat/weaponFire.js +96 -0
  16. package/dist/combat.d.ts +5 -2
  17. package/dist/combat.js +4 -1
  18. package/dist/devtools/controls.d.ts +38 -0
  19. package/dist/devtools/controls.js +181 -0
  20. package/dist/devtools/devtools.d.ts +5 -197
  21. package/dist/devtools/devtools.js +38 -774
  22. package/dist/devtools/discover.d.ts +32 -0
  23. package/dist/devtools/discover.js +240 -0
  24. package/dist/devtools/fallbackSeams.d.ts +50 -0
  25. package/dist/devtools/fallbackSeams.js +111 -0
  26. package/dist/devtools/frame.d.ts +15 -0
  27. package/dist/devtools/frame.js +247 -0
  28. package/dist/devtools/logs.d.ts +15 -0
  29. package/dist/devtools/logs.js +86 -0
  30. package/dist/devtools/overrides.d.ts +16 -0
  31. package/dist/devtools/overrides.js +66 -0
  32. package/dist/devtools/types.d.ts +212 -0
  33. package/dist/devtools/types.js +1 -0
  34. package/dist/devtools/urlFlags.d.ts +22 -0
  35. package/dist/devtools/urlFlags.js +62 -0
  36. package/dist/economy/auctionBook.d.ts +122 -0
  37. package/dist/economy/auctionBook.js +213 -0
  38. package/dist/economy/priceHistory.d.ts +44 -0
  39. package/dist/economy/priceHistory.js +89 -0
  40. package/dist/economy/unlockPoints.d.ts +92 -0
  41. package/dist/economy/unlockPoints.js +103 -0
  42. package/dist/editor/commandApply.d.ts +5 -0
  43. package/dist/editor/commandApply.js +755 -0
  44. package/dist/editor/commands.d.ts +48 -3
  45. package/dist/editor/commands.js +40 -754
  46. package/dist/editor/directives.d.ts +94 -0
  47. package/dist/editor/directives.js +206 -0
  48. package/dist/editor/document.d.ts +37 -2
  49. package/dist/editor/document.js +422 -6
  50. package/dist/editor/entityCatalog.d.ts +42 -0
  51. package/dist/editor/entityCatalog.js +101 -0
  52. package/dist/editor/environment.d.ts +74 -0
  53. package/dist/editor/environment.js +169 -0
  54. package/dist/editor/index.d.ts +26 -1
  55. package/dist/editor/index.js +8 -1
  56. package/dist/editor/minimap.d.ts +31 -0
  57. package/dist/editor/minimap.js +73 -0
  58. package/dist/editor/streamer.d.ts +66 -0
  59. package/dist/editor/streamer.js +68 -0
  60. package/dist/editor/types.d.ts +143 -0
  61. package/dist/editor/world.d.ts +136 -0
  62. package/dist/editor/world.js +303 -0
  63. package/dist/game/breeding.d.ts +161 -0
  64. package/dist/game/breeding.js +181 -0
  65. package/dist/game/cameraConfig.d.ts +451 -0
  66. package/dist/game/cameraConfig.js +1 -0
  67. package/dist/game/clipRoles.d.ts +51 -0
  68. package/dist/game/clipRoles.js +142 -0
  69. package/dist/game/defineGame.d.ts +16 -9
  70. package/dist/game/defineGame.js +14 -6
  71. package/dist/game/defineSystem.d.ts +2 -0
  72. package/dist/game/defineSystem.js +2 -0
  73. package/dist/game/events.d.ts +28 -0
  74. package/dist/game/partAnimation.d.ts +78 -0
  75. package/dist/game/partAnimation.js +147 -0
  76. package/dist/game/playableGame.d.ts +72 -440
  77. package/dist/game/playableGame.js +1 -1
  78. package/dist/game/progression.d.ts +35 -0
  79. package/dist/game/progression.js +22 -1
  80. package/dist/game/quest.js +1 -3
  81. package/dist/game/tribe.d.ts +184 -0
  82. package/dist/game/tribe.js +432 -0
  83. package/dist/gameplay.d.ts +3 -1
  84. package/dist/gameplay.js +3 -1
  85. package/dist/inventory/encumbrance.d.ts +69 -0
  86. package/dist/inventory/encumbrance.js +68 -0
  87. package/dist/inventory/inventoryModel.d.ts +24 -8
  88. package/dist/inventory/inventoryModel.js +47 -8
  89. package/dist/item/durability.js +1 -3
  90. package/dist/meta/changelog.d.ts +1 -1
  91. package/dist/meta/changelog.js +48 -1
  92. package/dist/movement/movementModel.d.ts +15 -1
  93. package/dist/movement/movementModel.js +33 -15
  94. package/dist/movement/playerMovement.js +102 -2
  95. package/dist/multiplayer/combatSnapshot.js +13 -8
  96. package/dist/multiplayer/presenceModel.d.ts +2 -0
  97. package/dist/multiplayer/presenceModel.js +8 -0
  98. package/dist/physics/drivableVehicle.d.ts +2 -0
  99. package/dist/physics/drivableVehicle.js +1 -1
  100. package/dist/physics/flightDynamics.d.ts +82 -0
  101. package/dist/physics/flightDynamics.js +165 -0
  102. package/dist/physics/gravityField.d.ts +25 -0
  103. package/dist/physics/gravityField.js +41 -0
  104. package/dist/physics/kinematicVehicle.d.ts +87 -0
  105. package/dist/physics/kinematicVehicle.js +193 -11
  106. package/dist/physics/physicsWorld.d.ts +26 -0
  107. package/dist/physics/physicsWorld.js +109 -117
  108. package/dist/physics/vehicleObstacles.d.ts +44 -0
  109. package/dist/physics/vehicleObstacles.js +115 -0
  110. package/dist/random/rng.d.ts +4 -1
  111. package/dist/random/rng.js +4 -1
  112. package/dist/runtime/adapter.d.ts +13 -0
  113. package/dist/runtime/adapter.js +13 -0
  114. package/dist/runtime/context/combat.d.ts +51 -0
  115. package/dist/runtime/context/combat.js +131 -0
  116. package/dist/runtime/context/combatFx.js +15 -5
  117. package/dist/runtime/context/deathLoot.d.ts +30 -0
  118. package/dist/runtime/context/deathLoot.js +39 -0
  119. package/dist/runtime/context/player.d.ts +48 -0
  120. package/dist/runtime/context/player.js +125 -0
  121. package/dist/runtime/context/scene.d.ts +60 -0
  122. package/dist/runtime/context/scene.js +329 -0
  123. package/dist/runtime/context/snapshotCodecs.d.ts +12 -0
  124. package/dist/runtime/context/snapshotCodecs.js +26 -0
  125. package/dist/runtime/descriptors/baseline.js +26 -3
  126. package/dist/runtime/descriptors/features.d.ts +5 -0
  127. package/dist/runtime/descriptors/features.js +20 -2
  128. package/dist/runtime/gameContext.d.ts +2 -428
  129. package/dist/runtime/gameContext.js +133 -500
  130. package/dist/runtime/gameContextTypes.d.ts +500 -0
  131. package/dist/runtime/gameContextTypes.js +1 -0
  132. package/dist/runtime/headlessRunner.d.ts +17 -2
  133. package/dist/runtime/headlessRunner.js +15 -0
  134. package/dist/runtime/hostPolicy.d.ts +51 -0
  135. package/dist/runtime/hostPolicy.js +68 -0
  136. package/dist/runtime/hostedGameRunner.d.ts +3 -1
  137. package/dist/runtime/hostedGameRunner.js +1 -0
  138. package/dist/runtime/hostedWorldSession.d.ts +3 -1
  139. package/dist/runtime/hostedWorldSession.js +1 -0
  140. package/dist/runtime/worldSnapshot.d.ts +13 -1
  141. package/dist/runtime/worldSnapshot.js +15 -3
  142. package/dist/scene/assetCatalog.d.ts +7 -0
  143. package/dist/scene/assetSpace.js +1 -3
  144. package/dist/scene/authoredTriggers.d.ts +50 -0
  145. package/dist/scene/authoredTriggers.js +114 -0
  146. package/dist/scene/builtinSceneKinds.js +8 -3
  147. package/dist/scene/colliders.d.ts +79 -0
  148. package/dist/scene/colliders.js +170 -0
  149. package/dist/scene/collisionMesh.d.ts +94 -0
  150. package/dist/scene/collisionMesh.js +699 -0
  151. package/dist/scene/companion.d.ts +147 -0
  152. package/dist/scene/companion.js +193 -0
  153. package/dist/scene/entityStats.d.ts +7 -9
  154. package/dist/scene/entityStats.js +19 -15
  155. package/dist/scene/sceneKinds.d.ts +16 -0
  156. package/dist/scene/sceneKinds.js +4 -0
  157. package/dist/scene/sceneRaycast.js +15 -0
  158. package/dist/stats/spawnLevelStats.d.ts +110 -0
  159. package/dist/stats/spawnLevelStats.js +140 -0
  160. package/dist/stats/statModifiers.d.ts +16 -0
  161. package/dist/stats/statModifiers.js +30 -1
  162. package/dist/stats/statPool.d.ts +70 -0
  163. package/dist/stats/statPool.js +62 -0
  164. package/dist/store/changeSignal.d.ts +3 -0
  165. package/dist/store/changeSignal.js +2 -0
  166. package/dist/store/observableKeyedStore.d.ts +2 -0
  167. package/dist/store/observableKeyedStore.js +1 -0
  168. package/dist/survival/decayMeter.js +1 -3
  169. package/dist/survival/regionHealth.js +1 -3
  170. package/dist/ui/hudLayout.d.ts +5 -0
  171. package/dist/ui/hudLayout.js +17 -1
  172. package/dist/ui/panelModel.d.ts +120 -0
  173. package/dist/ui/panelModel.js +168 -0
  174. package/dist/ui.d.ts +1 -0
  175. package/dist/ui.js +1 -0
  176. package/dist/world/authoredEntities.d.ts +29 -0
  177. package/dist/world/authoredEntities.js +29 -0
  178. package/dist/world/authoredObjects.d.ts +15 -1
  179. package/dist/world/authoredObjects.js +13 -1
  180. package/dist/world/authoredSpawn.d.ts +39 -0
  181. package/dist/world/authoredSpawn.js +37 -0
  182. package/dist/world/buildSockets.d.ts +152 -0
  183. package/dist/world/buildSockets.js +188 -0
  184. package/dist/world/buildingLots.d.ts +84 -0
  185. package/dist/world/buildingLots.js +119 -0
  186. package/dist/world/buildings.d.ts +6 -0
  187. package/dist/world/cityBlocks.d.ts +152 -0
  188. package/dist/world/cityBlocks.js +670 -0
  189. package/dist/world/cityContent.d.ts +77 -0
  190. package/dist/world/cityContent.js +261 -0
  191. package/dist/world/cityGenerator.d.ts +41 -0
  192. package/dist/world/cityGenerator.js +58 -0
  193. package/dist/world/cityGeometry.d.ts +114 -0
  194. package/dist/world/cityGeometry.js +573 -0
  195. package/dist/world/cityKind.d.ts +314 -0
  196. package/dist/world/cityKind.js +1578 -0
  197. package/dist/world/environmentSummary.js +57 -1
  198. package/dist/world/features.d.ts +53 -5
  199. package/dist/world/features.js +15 -3
  200. package/dist/world/grassKind.js +2 -2
  201. package/dist/world/markers.d.ts +50 -4
  202. package/dist/world/markers.js +49 -0
  203. package/dist/world/massing.js +1 -1
  204. package/dist/world/minimapBake.d.ts +78 -0
  205. package/dist/world/minimapBake.js +208 -0
  206. package/dist/world/minimapTrack.d.ts +17 -0
  207. package/dist/world/minimapTrack.js +22 -0
  208. package/dist/world/place.d.ts +166 -0
  209. package/dist/world/place.js +96 -0
  210. package/dist/world/placeAsset.js +3 -1
  211. package/dist/world/poleLineKind.d.ts +40 -0
  212. package/dist/world/poleLineKind.js +97 -0
  213. package/dist/world/polyline.js +9 -2
  214. package/dist/world/resourceNode.d.ts +109 -0
  215. package/dist/world/resourceNode.js +140 -0
  216. package/dist/world/roads.d.ts +19 -2
  217. package/dist/world/roads.js +60 -3
  218. package/dist/world/scatter.js +2 -20
  219. package/dist/world/streetGenerator.d.ts +159 -0
  220. package/dist/world/streetGenerator.js +684 -0
  221. package/dist/world/streets.d.ts +103 -1
  222. package/dist/world/streets.js +273 -0
  223. package/dist/world/terraform.js +58 -88
  224. package/dist/world/terrain.d.ts +65 -1
  225. package/dist/world/terrain.js +119 -2
  226. package/dist/world.d.ts +14 -5
  227. package/dist/world.js +12 -3
  228. package/package.json +1 -1
  229. package/skills/game-design/SKILL.md +28 -0
  230. package/skills/game-design/agents/openai.yaml +4 -0
  231. package/skills/game-design/references/application-playbook.md +170 -0
  232. package/skills/game-design/references/field-guide.md +288 -0
  233. package/skills/jgengine/SKILL.md +8 -4
  234. package/skills/jgengine/api.md +182 -81
  235. package/skills/jgengine/capabilities.md +104 -2
  236. package/skills/jgengine/recipes/minimal-game.md +88 -0
  237. package/skills/jgengine-combat/SKILL.md +19 -1
  238. package/skills/jgengine-combat/api.md +75 -2
  239. package/skills/jgengine-combat/capabilities.md +61 -1
  240. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  241. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  242. package/skills/jgengine-combat/recipes/portable-stat-pools.md +122 -0
  243. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  244. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  245. package/skills/jgengine-combat/reference.md +33 -4
  246. package/skills/jgengine-gameplay/SKILL.md +8 -0
  247. package/skills/jgengine-gameplay/api.md +182 -26
  248. package/skills/jgengine-gameplay/capabilities.md +96 -2
  249. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  250. package/skills/jgengine-gameplay/reference-systems.md +10 -5
  251. package/skills/jgengine-world/SKILL.md +13 -0
  252. package/skills/jgengine-world/api.md +453 -21
  253. package/skills/jgengine-world/capabilities.md +112 -2
  254. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  255. package/skills/jgengine-world/reference.md +43 -4
  256. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -1,798 +1,62 @@
1
1
  import { createChangeSignal } from "../store/changeSignal.js";
2
- import { CONTROL_SCHEMA_VERSION, MAX_SCAN_DEPTH, MAX_SCAN_TARGETS, MAX_TABLE_ENTRIES, OVERRIDES_FORMAT_VERSION, choiceValues, cloneValue, discoverableKind, expandAxisMeta, inferKind, isScannableContainer, joinTunablePath, normalizeColorValue, ownWritableDataKeys, parseOverridesPayload, resolveChoices, sliderBounds, validateControlValue, } from "./tunableSchema.js";
2
+ import { createFrameModule, nowMs } from "./frame.js";
3
+ import { createLogsModule } from "./logs.js";
4
+ import { createControlsModule } from "./controls.js";
5
+ import { createDiscoverModule } from "./discover.js";
6
+ import { createOverridesModule } from "./overrides.js";
3
7
  export { CONTROL_SCHEMA_VERSION, OVERRIDES_FORMAT_VERSION, convertAngle, escapePathSegment, formatColor, joinTunablePath, normalizeAngle, normalizeColorValue, parseColor, parseOverridesPayload, splitTunablePath, unescapePathSegment, validateControlValue, } from "./tunableSchema.js";
4
- const FRAME_CAPACITY = 240;
5
- const RECENT_FRAMES = 60;
6
- export const LONG_FRAME_MS = 33.4;
7
- const LATENCY_CAPACITY = 120;
8
- const LOG_CAPACITY = 200;
9
- const LOG_MESSAGE_MAX = 400;
10
- const LONG_FRAME_CAPACITY = 40;
11
- const MAX_PHASES_PER_FRAME = 24;
12
- const MAX_LONG_FRAME_PHASES = 8;
13
- const PHASE_HOT_MS = 2;
14
- function nowMs() {
15
- const perf = globalThis.performance;
16
- return perf !== undefined ? perf.now() : Date.now();
17
- }
18
- function formatLogArg(arg) {
19
- if (typeof arg === "string")
20
- return arg;
21
- if (arg instanceof Error)
22
- return `${arg.name}: ${arg.message}`;
23
- try {
24
- return JSON.stringify(arg) ?? String(arg);
25
- }
26
- catch {
27
- return String(arg);
28
- }
29
- }
30
- export function formatLogMessage(args) {
31
- const message = args.map(formatLogArg).join(" ");
32
- return message.length > LOG_MESSAGE_MAX ? `${message.slice(0, LOG_MESSAGE_MAX)}…` : message;
33
- }
34
- function percentile(sorted, fraction) {
35
- if (sorted.length === 0)
36
- return 0;
37
- const index = Math.min(sorted.length - 1, Math.floor(sorted.length * fraction));
38
- return sorted[index];
39
- }
40
- function clampMs(value) {
41
- return value > 0 && Number.isFinite(value) ? value : 0;
42
- }
43
- function rankPhases(phases) {
44
- const entries = Object.entries(phases)
45
- .filter(([, ms]) => Number.isFinite(ms) && ms > 0)
46
- .map(([name, ms]) => ({ name, ms }))
47
- .sort((a, b) => b.ms - a.ms)
48
- .slice(0, MAX_LONG_FRAME_PHASES);
49
- const total = entries.reduce((sum, entry) => sum + entry.ms, 0);
50
- return entries.map((entry) => ({
51
- name: entry.name,
52
- ms: entry.ms,
53
- pct: total > 0 ? (entry.ms / total) * 100 : 0,
54
- }));
55
- }
56
- function explainLongFrame(frameMs, simMs, outsideMs, ranked) {
57
- const top = ranked[0] ?? null;
58
- const topPhaseMs = top?.ms ?? 0;
59
- const phaseBits = ranked
60
- .filter((entry) => entry.ms >= PHASE_HOT_MS)
61
- .slice(0, 3)
62
- .map((entry) => `${entry.name} ${entry.ms.toFixed(1)}ms`);
63
- const outsideDominates = outsideMs > topPhaseMs && outsideMs >= simMs * 0.55 && outsideMs >= frameMs * 0.4;
64
- if (outsideDominates) {
65
- const detail = phaseBits.length > 0 ? ` · sim was ${phaseBits.join(", ")}` : simMs > 1 ? ` · sim ${simMs.toFixed(1)}ms` : "";
66
- return {
67
- topPhase: top?.name ?? null,
68
- culprit: "outside-sim",
69
- reason: `display/render/browser hitch ${outsideMs.toFixed(1)}ms outside sim${detail}`,
70
- };
71
- }
72
- if (top !== null && top.ms >= PHASE_HOT_MS) {
73
- const rest = phaseBits.slice(1);
74
- const restText = rest.length > 0 ? ` · also ${rest.join(", ")}` : "";
75
- const outsideText = outsideMs >= PHASE_HOT_MS ? ` · +${outsideMs.toFixed(1)}ms outside sim` : "";
76
- return {
77
- topPhase: top.name,
78
- culprit: top.name,
79
- reason: `${top.name} ${top.ms.toFixed(1)}ms (${top.pct.toFixed(0)}% of timed sim)${restText}${outsideText}`,
80
- };
81
- }
82
- if (simMs >= frameMs * 0.5) {
83
- return {
84
- topPhase: null,
85
- culprit: "sim",
86
- reason: `sim ${simMs.toFixed(1)}ms with no named phase ≥${PHASE_HOT_MS}ms — wrap hot work in measure("name", fn)`,
87
- };
88
- }
89
- return {
90
- topPhase: null,
91
- culprit: "outside-sim",
92
- reason: `frame ${frameMs.toFixed(1)}ms · sim ${simMs.toFixed(1)}ms · outside ${outsideMs.toFixed(1)}ms (render/React/GC/tab throttle)`,
93
- };
94
- }
95
- function collectScanTargets(root, meta) {
96
- const skipped = [];
97
- if (!isScannableContainer(root)) {
98
- if (root !== null && typeof root === "object") {
99
- skipped.push({ path: "", reason: "not a plain object or array" });
100
- }
101
- return { targets: [], skipped };
102
- }
103
- const out = [];
104
- const visited = new Set([root]);
105
- let frontier = [{ path: "", target: root }];
106
- for (let depth = 0; depth <= MAX_SCAN_DEPTH && frontier.length > 0; depth += 1) {
107
- const next = [];
108
- for (const { path, target } of frontier) {
109
- const keys = ownWritableDataKeys(target);
110
- let scalarCount = 0;
111
- for (const key of keys) {
112
- const value = target[key];
113
- const childPath = joinTunablePath(path, key);
114
- const fieldMeta = meta?.[childPath];
115
- const kind = discoverableKind(value, fieldMeta);
116
- if (kind !== null) {
117
- scalarCount += 1;
118
- if (scalarCount > MAX_TABLE_ENTRIES) {
119
- skipped.push({ path: path === "" ? "*" : path, reason: `more than ${MAX_TABLE_ENTRIES} scalar entries` });
120
- scalarCount = -1;
121
- break;
122
- }
123
- }
124
- }
125
- if (scalarCount < 0)
126
- continue;
127
- for (const key of keys) {
128
- const value = target[key];
129
- const childPath = joinTunablePath(path, key);
130
- const fieldMeta = meta?.[childPath];
131
- const kind = discoverableKind(value, fieldMeta);
132
- if (kind !== null) {
133
- if (out.length >= MAX_SCAN_TARGETS) {
134
- skipped.push({ path: childPath, reason: `scan cap ${MAX_SCAN_TARGETS}` });
135
- return { targets: out, skipped };
136
- }
137
- out.push({ path: childPath, target, key, kind, options: fieldMeta });
138
- continue;
139
- }
140
- if (value !== null && typeof value === "object") {
141
- if (!isScannableContainer(value)) {
142
- skipped.push({ path: childPath, reason: "unsupported structure" });
143
- continue;
144
- }
145
- if (visited.has(value)) {
146
- skipped.push({ path: childPath, reason: "cycle" });
147
- continue;
148
- }
149
- if (depth === MAX_SCAN_DEPTH) {
150
- skipped.push({ path: childPath, reason: `depth > ${MAX_SCAN_DEPTH}` });
151
- continue;
152
- }
153
- visited.add(value);
154
- next.push({ path: childPath, target: value });
155
- }
156
- }
157
- }
158
- frontier = next;
159
- }
160
- return { targets: out, skipped };
161
- }
8
+ export { LONG_FRAME_MS } from "./frame.js";
9
+ export { formatLogMessage } from "./logs.js";
162
10
  export function createDevtools() {
163
11
  const signal = createChangeSignal();
164
- const frameMsBuffer = new Float64Array(FRAME_CAPACITY);
165
- const simMsBuffer = new Float64Array(FRAME_CAPACITY);
166
- const outsideMsBuffer = new Float64Array(FRAME_CAPACITY);
167
- let frameIndex = 0;
168
- let frameCount = 0;
169
- let renderSample = null;
170
- const logEntries = [];
171
- let consoleCaptured = false;
172
- const latencySamples = [];
173
- const longFrameEvents = [];
174
- const phaseAccumulators = new Map();
175
- let currentPhases = {};
176
- let currentPhaseCount = 0;
177
- const controlRecords = new Map();
178
- const discoveredRecords = new Map();
179
- const probeReaders = new Map();
180
- let boundProps = new WeakMap();
181
- let discoverySkips = [];
182
- const readProbes = () => {
183
- const values = {};
184
- for (const [name, read] of probeReaders) {
185
- try {
186
- values[name] = read();
187
- }
188
- catch (error) {
189
- values[name] = `probe failed: ${formatLogArg(error)}`;
190
- }
191
- }
192
- return values;
193
- };
194
- const pushLog = (level, message) => {
195
- logEntries.push({ at: Date.now(), level, message });
196
- if (logEntries.length > LOG_CAPACITY)
197
- logEntries.splice(0, logEntries.length - LOG_CAPACITY);
198
- };
199
- const addPhaseMs = (name, ms) => {
200
- const elapsed = clampMs(ms);
201
- if (elapsed === 0 || name.length === 0)
202
- return;
203
- if (currentPhases[name] === undefined) {
204
- if (currentPhaseCount >= MAX_PHASES_PER_FRAME)
205
- return;
206
- currentPhaseCount += 1;
207
- currentPhases[name] = elapsed;
208
- return;
209
- }
210
- currentPhases[name] = currentPhases[name] + elapsed;
211
- };
212
- const resetCurrentPhases = () => {
213
- currentPhases = {};
214
- currentPhaseCount = 0;
215
- };
216
- const recordPhaseStats = (phases) => {
217
- for (const [name, ms] of Object.entries(phases)) {
218
- const elapsed = clampMs(ms);
219
- if (elapsed === 0)
220
- continue;
221
- const existing = phaseAccumulators.get(name);
222
- if (existing === undefined) {
223
- phaseAccumulators.set(name, { sumMs: elapsed, maxMs: elapsed, lastMs: elapsed, samples: 1 });
224
- }
225
- else {
226
- existing.sumMs += elapsed;
227
- existing.maxMs = Math.max(existing.maxMs, elapsed);
228
- existing.lastMs = elapsed;
229
- existing.samples += 1;
230
- }
231
- }
232
- };
233
- const phaseStatsList = (avgSimMs) => {
234
- const list = [];
235
- for (const [name, acc] of phaseAccumulators) {
236
- const avgMs = acc.sumMs / acc.samples;
237
- list.push({
238
- name,
239
- avgMs,
240
- maxMs: acc.maxMs,
241
- lastMs: acc.lastMs,
242
- samples: acc.samples,
243
- pctOfSim: avgSimMs > 0 ? (avgMs / avgSimMs) * 100 : 0,
244
- });
245
- }
246
- list.sort((a, b) => b.avgMs - a.avgMs);
247
- return list;
248
- };
249
- const frameStats = () => {
250
- if (frameCount === 0)
251
- return null;
252
- const size = Math.min(frameCount, FRAME_CAPACITY);
253
- const frames = [];
254
- const sims = [];
255
- const outsides = [];
256
- for (let offset = 0; offset < size; offset += 1) {
257
- const at = (frameIndex - size + offset + FRAME_CAPACITY) % FRAME_CAPACITY;
258
- frames.push(frameMsBuffer[at]);
259
- sims.push(simMsBuffer[at]);
260
- outsides.push(outsideMsBuffer[at]);
261
- }
262
- const recent = frames.slice(-RECENT_FRAMES);
263
- const recentAvg = recent.reduce((sum, ms) => sum + ms, 0) / recent.length;
264
- const sorted = [...frames].sort((a, b) => a - b);
265
- const avgSimMs = sims.reduce((sum, ms) => sum + ms, 0) / sims.length;
266
- return {
267
- fps: recentAvg > 0 ? 1000 / recentAvg : 0,
268
- avgFrameMs: frames.reduce((sum, ms) => sum + ms, 0) / frames.length,
269
- p95FrameMs: percentile(sorted, 0.95),
270
- maxFrameMs: sorted[sorted.length - 1],
271
- avgSimMs,
272
- maxSimMs: sims.reduce((max, ms) => Math.max(max, ms), 0),
273
- avgOutsideMs: outsides.reduce((sum, ms) => sum + ms, 0) / outsides.length,
274
- maxOutsideMs: outsides.reduce((max, ms) => Math.max(max, ms), 0),
275
- longFrames: frames.filter((ms) => ms > LONG_FRAME_MS).length,
276
- samples: size,
277
- recentFrameMs: recent,
278
- phases: phaseStatsList(avgSimMs),
279
- };
280
- };
281
- const recordFrame = (sample) => {
282
- const frameMs = clampMs(sample.frameMs);
283
- const simMs = clampMs(sample.simMs);
284
- const outsideMs = clampMs(frameMs - simMs);
285
- const phases = sample.phases !== undefined
286
- ? Object.fromEntries(Object.entries(sample.phases)
287
- .filter(([, ms]) => Number.isFinite(ms) && ms > 0)
288
- .slice(0, MAX_PHASES_PER_FRAME))
289
- : { ...currentPhases };
290
- frameMsBuffer[frameIndex] = frameMs;
291
- simMsBuffer[frameIndex] = simMs;
292
- outsideMsBuffer[frameIndex] = outsideMs;
293
- frameIndex = (frameIndex + 1) % FRAME_CAPACITY;
294
- frameCount += 1;
295
- recordPhaseStats(phases);
296
- if (frameMs > LONG_FRAME_MS) {
297
- const ranked = rankPhases(phases);
298
- const explained = explainLongFrame(frameMs, simMs, outsideMs, ranked);
299
- longFrameEvents.push({
300
- at: Date.now(),
301
- frameMs,
302
- simMs,
303
- outsideMs,
304
- phases: ranked,
305
- topPhase: explained.topPhase,
306
- culprit: explained.culprit,
307
- reason: explained.reason,
308
- probes: readProbes(),
309
- render: renderSample,
310
- });
311
- if (longFrameEvents.length > LONG_FRAME_CAPACITY) {
312
- longFrameEvents.splice(0, longFrameEvents.length - LONG_FRAME_CAPACITY);
313
- }
314
- }
315
- resetCurrentPhases();
316
- };
317
- const latencyStats = () => {
318
- if (latencySamples.length === 0)
319
- return null;
320
- let min = Number.POSITIVE_INFINITY;
321
- let max = 0;
322
- let sum = 0;
323
- for (const ms of latencySamples) {
324
- min = Math.min(min, ms);
325
- max = Math.max(max, ms);
326
- sum += ms;
327
- }
328
- return {
329
- lastMs: latencySamples[latencySamples.length - 1],
330
- avgMs: sum / latencySamples.length,
331
- minMs: min,
332
- maxMs: max,
333
- samples: latencySamples.length,
334
- };
335
- };
336
- const validationContext = (record) => ({
337
- min: record.min,
338
- max: record.max,
339
- step: record.step,
340
- integer: record.integer,
341
- unit: record.unit,
342
- wrap: record.wrap,
343
- choices: record.choices,
344
- options: record.options,
345
- axisBounds: record.axisBounds,
346
- alpha: record.hasAlpha,
12
+ const controls = createControlsModule({ signal });
13
+ const discover = createDiscoverModule({
14
+ signal,
15
+ register: controls.register,
16
+ controlRecords: controls.controlRecords,
347
17
  });
348
- const toControl = (record) => ({
349
- name: record.name,
350
- kind: record.kind,
351
- label: record.label,
352
- group: record.group,
353
- initial: record.initial,
354
- min: record.min,
355
- max: record.max,
356
- step: record.step,
357
- options: record.options ?? choiceValues(record.choices),
358
- choices: record.choices,
359
- unit: record.unit,
360
- displayUnit: record.displayUnit,
361
- wrap: record.wrap,
362
- integer: record.integer,
363
- axisLabels: record.axisBounds?.labels,
364
- axisMin: record.axisBounds?.min,
365
- axisMax: record.axisBounds?.max,
366
- axisStep: record.axisBounds?.step,
367
- hasAlpha: record.hasAlpha,
368
- schemaVersion: record.schemaVersion,
369
- read: () => record.value,
370
- write: (value) => writeControl(record, value),
371
- reset: () => {
372
- writeControl(record, cloneValue(record.initial));
373
- },
18
+ const frame = createFrameModule({ readProbes: discover.readProbes });
19
+ const logs = createLogsModule({ signal });
20
+ const overrides = createOverridesModule({
21
+ signal,
22
+ controlRecords: controls.controlRecords,
23
+ writeControl: controls.writeControl,
24
+ discoveredRecords: discover.discoveredRecords,
25
+ enableDiscovered: discover.enableDiscovered,
374
26
  });
375
- const writeControl = (record, raw) => {
376
- const validated = validateControlValue(record.kind, raw, validationContext(record));
377
- if (!validated.ok)
378
- return false;
379
- const next = validated.value;
380
- if (record.kind === "vec2" || record.kind === "vec3" || record.kind === "vec4" || record.kind === "interval") {
381
- if (JSON.stringify(record.value) === JSON.stringify(next))
382
- return true;
383
- record.value = cloneValue(next);
384
- }
385
- else {
386
- if (Object.is(record.value, next))
387
- return true;
388
- record.value = next;
389
- }
390
- for (const listener of record.listeners)
391
- listener(record.value);
392
- signal.notify();
393
- return true;
394
- };
395
- const hydrateRecord = (record, initial, options, kind) => {
396
- record.kind = kind;
397
- record.initial = cloneValue(initial);
398
- record.min = undefined;
399
- record.max = undefined;
400
- record.step = undefined;
401
- record.options = options?.options;
402
- record.choices = resolveChoices(options?.options, options?.choices);
403
- record.unit = options?.unit;
404
- record.displayUnit = options?.displayUnit ?? options?.unit;
405
- record.wrap = options?.wrap;
406
- record.integer = options?.integer;
407
- record.axisBounds = undefined;
408
- record.hasAlpha = undefined;
409
- record.schemaVersion = CONTROL_SCHEMA_VERSION;
410
- if (kind === "slider" || kind === "angle") {
411
- const bounds = sliderBounds(initial, options);
412
- record.min = bounds.min;
413
- record.max = bounds.max;
414
- record.step = bounds.step;
415
- if (kind === "angle") {
416
- record.unit = options?.unit ?? "rad";
417
- record.displayUnit = options?.displayUnit ?? "deg";
418
- }
419
- }
420
- else if (kind === "vec2" || kind === "vec3" || kind === "vec4") {
421
- const sample = Array.isArray(initial) ? initial : [];
422
- record.axisBounds = expandAxisMeta(sample.length, options?.axisLabels, options?.axisMin ?? options?.min, options?.axisMax ?? options?.max, options?.axisStep ?? options?.step, sample);
423
- }
424
- else if (kind === "interval") {
425
- record.min = options?.min;
426
- record.max = options?.max;
427
- record.step = options?.step;
428
- record.integer = options?.integer;
429
- }
430
- else if (kind === "color") {
431
- const normalized = normalizeColorValue(initial, options?.alpha);
432
- if (normalized !== null) {
433
- record.initial = normalized;
434
- if (Object.is(record.value, initial) || record.value === initial) {
435
- record.value = normalized;
436
- }
437
- record.hasAlpha = options?.alpha === true || normalized.length === 9;
438
- }
439
- }
440
- };
441
- const register = (name, initial, options) => {
442
- const kind = inferKind(initial, options);
443
- const existing = controlRecords.get(name);
444
- const slashIndex = name.indexOf("/");
445
- const derivedGroup = slashIndex > 0 ? name.slice(0, slashIndex) : "general";
446
- const derivedLabel = slashIndex > 0 ? name.slice(slashIndex + 1) : name;
447
- const record = existing !== undefined && existing.kind === kind
448
- ? existing
449
- : {
450
- name,
451
- kind,
452
- label: "",
453
- group: "",
454
- initial: cloneValue(initial),
455
- value: cloneValue(initial),
456
- schemaVersion: CONTROL_SCHEMA_VERSION,
457
- listeners: new Set(),
458
- };
459
- if (existing === undefined || existing.kind !== kind) {
460
- record.value = cloneValue(initial);
461
- }
462
- record.label = options?.label ?? derivedLabel;
463
- record.group = options?.group ?? derivedGroup;
464
- hydrateRecord(record, initial, options, kind);
465
- if (options?.onChange !== undefined)
466
- record.listeners.add(options.onChange);
467
- controlRecords.set(name, record);
468
- signal.notify();
469
- return {
470
- name,
471
- kind,
472
- initial: record.initial,
473
- get value() {
474
- return record.value;
475
- },
476
- set: (value) => writeControl(record, value),
477
- reset: () => {
478
- writeControl(record, cloneValue(record.initial));
479
- },
480
- subscribe: (listener) => {
481
- record.listeners.add(listener);
482
- return () => record.listeners.delete(listener);
483
- },
484
- };
485
- };
486
- const bindDiscovered = (table, key, kind, accessor, source, options) => {
487
- const id = `${table}/${key}`;
488
- const existing = discoveredRecords.get(id);
489
- if (existing === undefined) {
490
- discoveredRecords.set(id, {
491
- id,
492
- table,
493
- key,
494
- kind,
495
- initial: cloneValue(accessor.initial),
496
- enabled: false,
497
- source,
498
- options,
499
- get: accessor.get,
500
- set: accessor.set,
501
- });
502
- return true;
503
- }
504
- if (source !== null && existing.source === source)
505
- return false;
506
- existing.source = source;
507
- existing.get = accessor.get;
508
- existing.set = accessor.set;
509
- existing.kind = kind;
510
- existing.options = options ?? existing.options;
511
- const control = controlRecords.get(id);
512
- if (existing.enabled && control !== undefined)
513
- accessor.set(cloneValue(control.value));
514
- return true;
515
- };
516
- const scanTable = (table, candidate, meta) => {
517
- const { targets, skipped } = collectScanTargets(candidate, meta);
518
- discoverySkips = [
519
- ...discoverySkips.filter((entry) => entry.path !== table && !entry.path.startsWith(`${table}/`)),
520
- ...skipped.map((entry) => ({
521
- path: entry.path === "" ? table : `${table}/${entry.path}`,
522
- reason: entry.reason,
523
- })),
524
- ];
525
- if (targets.length === 0)
526
- return 0;
527
- let changed = false;
528
- let bound = 0;
529
- for (const { path, target, key, kind, options } of targets) {
530
- const boundKeys = boundProps.get(target);
531
- if (boundKeys?.has(key) === true && !discoveredRecords.has(`${table}/${path}`))
532
- continue;
533
- const accessor = {
534
- initial: cloneValue(target[key]),
535
- get: () => target[key],
536
- set: (value) => {
537
- target[key] = value;
538
- },
539
- };
540
- if (bindDiscovered(table, path, kind, accessor, target, options))
541
- changed = true;
542
- bound += 1;
543
- if (boundKeys === undefined)
544
- boundProps.set(target, new Set([key]));
545
- else
546
- boundKeys.add(key);
547
- }
548
- if (changed)
549
- signal.notify();
550
- return bound;
551
- };
552
- const enableDiscovered = (id) => {
553
- const record = discoveredRecords.get(id);
554
- if (record === undefined || record.enabled)
555
- return;
556
- record.enabled = true;
557
- const current = record.get();
558
- const handle = register(id, record.initial, {
559
- ...(record.options ?? {}),
560
- group: record.options?.group ?? record.table,
561
- label: record.options?.label ?? record.key,
562
- kind: record.kind,
563
- });
564
- handle.subscribe((value) => record.set(cloneValue(value)));
565
- if (JSON.stringify(current) !== JSON.stringify(record.initial))
566
- handle.set(current);
567
- };
568
27
  return {
569
- frame: {
570
- record: recordFrame,
571
- stats: frameStats,
572
- longFrames: () => longFrameEvents,
573
- clearLongFrames() {
574
- longFrameEvents.length = 0;
575
- },
576
- },
577
- profile: {
578
- begin(name) {
579
- const start = nowMs();
580
- return () => addPhaseMs(name, nowMs() - start);
581
- },
582
- measure(name, fn) {
583
- const start = nowMs();
584
- try {
585
- return fn();
586
- }
587
- finally {
588
- addPhaseMs(name, nowMs() - start);
589
- }
590
- },
591
- add: addPhaseMs,
592
- current: () => ({ ...currentPhases }),
593
- reset: resetCurrentPhases,
594
- },
595
- render: {
596
- record(sample) {
597
- renderSample = sample;
598
- },
599
- latest: () => renderSample,
600
- },
601
- logs: {
602
- push: pushLog,
603
- captureConsole() {
604
- if (consoleCaptured)
605
- return;
606
- const globalConsole = globalThis.console;
607
- if (globalConsole === undefined)
608
- return;
609
- consoleCaptured = true;
610
- const levels = ["log", "info", "warn", "error"];
611
- for (const level of levels) {
612
- const original = globalConsole[level].bind(globalConsole);
613
- globalConsole[level] = (...args) => {
614
- pushLog(level, formatLogMessage(args));
615
- original(...args);
616
- };
617
- }
618
- },
619
- list: () => logEntries,
620
- clear() {
621
- logEntries.length = 0;
622
- signal.notify();
623
- },
624
- },
625
- latency: {
626
- record(ms) {
627
- latencySamples.push(ms);
628
- if (latencySamples.length > LATENCY_CAPACITY)
629
- latencySamples.splice(0, latencySamples.length - LATENCY_CAPACITY);
630
- },
631
- stats: latencyStats,
632
- },
633
- controls: {
634
- register,
635
- list: () => [...controlRecords.values()].map(toControl),
636
- get: (name) => {
637
- const record = controlRecords.get(name);
638
- return record === undefined ? null : toControl(record);
639
- },
640
- remove(name) {
641
- if (controlRecords.delete(name))
642
- signal.notify();
643
- },
644
- resetAll() {
645
- for (const record of controlRecords.values())
646
- writeControl(record, cloneValue(record.initial));
647
- },
648
- },
649
- discover: {
650
- bind(table, key, accessor, options) {
651
- const kind = discoverableKind(accessor.initial, options);
652
- if (kind === null)
653
- return;
654
- if (bindDiscovered(table, key, kind, accessor, null, options))
655
- signal.notify();
656
- },
657
- scanTable,
658
- scanModule(exports, meta) {
659
- let total = 0;
660
- for (const [name, value] of Object.entries(exports)) {
661
- total += scanTable(name, value, meta);
662
- }
663
- return total;
664
- },
665
- list: () => [...discoveredRecords.values()].map((record) => ({
666
- id: record.id,
667
- table: record.table,
668
- key: record.key,
669
- kind: record.kind,
670
- initial: record.initial,
671
- enabled: record.enabled,
672
- read: () => record.get(),
673
- })),
674
- skipped: () => discoverySkips,
675
- enable(id) {
676
- enableDiscovered(id);
677
- signal.notify();
678
- },
679
- disable(id) {
680
- const record = discoveredRecords.get(id);
681
- if (record === undefined || !record.enabled)
682
- return;
683
- record.enabled = false;
684
- record.set(cloneValue(record.initial));
685
- controlRecords.delete(id);
686
- signal.notify();
687
- },
688
- clear() {
689
- for (const record of discoveredRecords.values()) {
690
- if (record.enabled) {
691
- record.set(cloneValue(record.initial));
692
- controlRecords.delete(record.id);
693
- }
694
- }
695
- discoveredRecords.clear();
696
- discoverySkips = [];
697
- boundProps = new WeakMap();
698
- signal.notify();
699
- },
700
- },
701
- overrides: {
702
- export() {
703
- const enabled = [...discoveredRecords.values()].filter((r) => r.enabled).map((r) => r.id);
704
- const values = {};
705
- const schemas = {};
706
- for (const record of controlRecords.values()) {
707
- schemas[record.name] = { kind: record.kind, schemaVersion: record.schemaVersion };
708
- const same = record.kind === "vec2" ||
709
- record.kind === "vec3" ||
710
- record.kind === "vec4" ||
711
- record.kind === "interval"
712
- ? JSON.stringify(record.value) === JSON.stringify(record.initial)
713
- : Object.is(record.value, record.initial);
714
- if (!same)
715
- values[record.name] = cloneValue(record.value);
716
- }
717
- return {
718
- version: OVERRIDES_FORMAT_VERSION,
719
- enabled,
720
- values,
721
- schemas,
722
- };
723
- },
724
- apply(raw) {
725
- const parsed = parseOverridesPayload(raw);
726
- const diagnostics = [...parsed.diagnostics];
727
- const skipped = [];
728
- if (parsed.overrides === null) {
729
- return { applied: 0, skipped, diagnostics };
730
- }
731
- const overrides = parsed.overrides;
732
- let applied = 0;
733
- for (const id of overrides.enabled) {
734
- if (!discoveredRecords.has(id) && !controlRecords.has(id)) {
735
- skipped.push({ id, reason: "unknown control id" });
736
- continue;
737
- }
738
- enableDiscovered(id);
739
- applied += 1;
740
- }
741
- for (const [name, value] of Object.entries(overrides.values)) {
742
- const record = controlRecords.get(name);
743
- if (record === undefined) {
744
- skipped.push({ id: name, reason: "control not registered" });
745
- continue;
746
- }
747
- const schema = overrides.schemas?.[name];
748
- if (schema !== undefined && schema.kind !== record.kind) {
749
- skipped.push({ id: name, reason: `kind mismatch stored=${schema.kind} current=${record.kind}` });
750
- continue;
751
- }
752
- if (!writeControl(record, value)) {
753
- skipped.push({ id: name, reason: "value failed validation" });
754
- continue;
755
- }
756
- applied += 1;
757
- }
758
- signal.notify();
759
- return { applied, skipped, diagnostics };
760
- },
761
- },
762
- probes: {
763
- register(name, read) {
764
- probeReaders.set(name, read);
765
- signal.notify();
766
- return () => {
767
- probeReaders.delete(name);
768
- signal.notify();
769
- };
770
- },
771
- read: readProbes,
772
- },
28
+ frame: frame.frame,
29
+ profile: frame.profile,
30
+ render: frame.render,
31
+ logs: logs.logs,
32
+ latency: logs.latency,
33
+ controls: controls.controls,
34
+ discover: discover.discover,
35
+ overrides: overrides.overrides,
36
+ probes: discover.probes,
773
37
  signal,
774
38
  snapshot() {
775
39
  return {
776
40
  at: Date.now(),
777
- frame: frameStats(),
778
- render: renderSample,
779
- latency: latencyStats(),
780
- longFrames: [...longFrameEvents],
781
- logs: [...logEntries],
782
- probes: readProbes(),
783
- controls: [...controlRecords.values()].map((record) => ({
41
+ frame: frame.frame.stats(),
42
+ render: frame.render.latest(),
43
+ latency: logs.latency.stats(),
44
+ longFrames: [...frame.frame.longFrames()],
45
+ logs: [...logs.logs.list()],
46
+ probes: discover.readProbes(),
47
+ controls: [...controls.controlRecords.values()].map((record) => ({
784
48
  name: record.name,
785
49
  kind: record.kind,
786
50
  value: record.value,
787
51
  initial: record.initial,
788
52
  })),
789
- discovered: [...discoveredRecords.values()].map((record) => ({
53
+ discovered: [...discover.discoveredRecords.values()].map((record) => ({
790
54
  id: record.id,
791
55
  kind: record.kind,
792
56
  value: record.get(),
793
57
  enabled: record.enabled,
794
58
  })),
795
- skipped: [...discoverySkips],
59
+ skipped: [...discover.getDiscoverySkips()],
796
60
  };
797
61
  },
798
62
  };