@jgengine/core 0.12.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 (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/combat/effects.d.ts +11 -4
  3. package/dist/combat/effects.js +24 -17
  4. package/dist/combat/magazine.d.ts +13 -0
  5. package/dist/combat/magazine.js +27 -0
  6. package/dist/combat/vfxPresets.d.ts +176 -0
  7. package/dist/combat/vfxPresets.js +74 -0
  8. package/dist/combat/weaponFire.d.ts +114 -0
  9. package/dist/combat/weaponFire.js +96 -0
  10. package/dist/combat.d.ts +4 -2
  11. package/dist/combat.js +3 -1
  12. package/dist/devtools/urlFlags.d.ts +22 -0
  13. package/dist/devtools/urlFlags.js +62 -0
  14. package/dist/game/clipRoles.d.ts +51 -0
  15. package/dist/game/clipRoles.js +142 -0
  16. package/dist/game/defineGame.d.ts +2 -2
  17. package/dist/game/partAnimation.d.ts +78 -0
  18. package/dist/game/partAnimation.js +147 -0
  19. package/dist/game/playableGame.d.ts +38 -5
  20. package/dist/game/progression.d.ts +2 -2
  21. package/dist/game/progression.js +4 -2
  22. package/dist/inventory/inventoryModel.d.ts +24 -8
  23. package/dist/inventory/inventoryModel.js +47 -8
  24. package/dist/meta/changelog.d.ts +1 -1
  25. package/dist/meta/changelog.js +25 -1
  26. package/dist/runtime/context/combat.js +2 -1
  27. package/dist/runtime/context/combatFx.js +15 -5
  28. package/dist/runtime/context/scene.d.ts +2 -1
  29. package/dist/runtime/context/scene.js +39 -1
  30. package/dist/runtime/gameContext.js +50 -4
  31. package/dist/runtime/gameContextTypes.d.ts +37 -4
  32. package/dist/runtime/headlessRunner.d.ts +13 -0
  33. package/dist/runtime/headlessRunner.js +14 -0
  34. package/dist/scene/assetCatalog.d.ts +2 -0
  35. package/dist/scene/colliders.d.ts +21 -0
  36. package/dist/scene/colliders.js +62 -0
  37. package/dist/scene/entityStats.js +9 -1
  38. package/dist/stats/statModifiers.d.ts +16 -0
  39. package/dist/stats/statModifiers.js +30 -1
  40. package/dist/stats/statPool.js +5 -2
  41. package/dist/ui/panelModel.d.ts +120 -0
  42. package/dist/ui/panelModel.js +168 -0
  43. package/dist/ui.d.ts +1 -0
  44. package/dist/ui.js +1 -0
  45. package/dist/world/buildingLots.d.ts +84 -0
  46. package/dist/world/buildingLots.js +119 -0
  47. package/dist/world/buildings.d.ts +6 -0
  48. package/dist/world/cityGenerator.d.ts +41 -0
  49. package/dist/world/cityGenerator.js +58 -0
  50. package/dist/world/cityKind.d.ts +2 -2
  51. package/dist/world/cityKind.js +4 -4
  52. package/dist/world/environmentSummary.js +57 -1
  53. package/dist/world/features.d.ts +35 -2
  54. package/dist/world/features.js +5 -2
  55. package/dist/world/roads.d.ts +19 -2
  56. package/dist/world/roads.js +60 -3
  57. package/dist/world/{pathNetwork.d.ts → streetGenerator.d.ts} +37 -37
  58. package/dist/world/{pathNetwork.js → streetGenerator.js} +9 -9
  59. package/dist/world/streets.d.ts +74 -1
  60. package/dist/world/streets.js +218 -0
  61. package/dist/world/terrain.d.ts +20 -1
  62. package/dist/world/terrain.js +41 -2
  63. package/package.json +1 -1
  64. package/skills/jgengine/SKILL.md +1 -0
  65. package/skills/jgengine/api.md +13 -5
  66. package/skills/jgengine-combat/SKILL.md +15 -1
  67. package/skills/jgengine-combat/api.md +39 -2
  68. package/skills/jgengine-combat/capabilities.md +17 -1
  69. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  70. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  71. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  72. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  73. package/skills/jgengine-combat/reference.md +22 -3
  74. package/skills/jgengine-gameplay/SKILL.md +8 -0
  75. package/skills/jgengine-gameplay/api.md +35 -5
  76. package/skills/jgengine-gameplay/capabilities.md +18 -0
  77. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  78. package/skills/jgengine-gameplay/reference-systems.md +9 -0
  79. package/skills/jgengine-world/api.md +52 -24
  80. package/skills/jgengine-world/capabilities.md +8 -4
  81. package/skills/jgengine-world/reference.md +1 -1
@@ -197,3 +197,221 @@ export function sidewalkPoint(road, side, fraction) {
197
197
  const along = Math.max(0, Math.min(1, fraction)) * pathLength(path);
198
198
  return pointAlong(path, along).point;
199
199
  }
200
+ /**
201
+ * The two curb lines of a road — offset polylines running along the outer edge of the asphalt on
202
+ * each side. Mesh these with a thin darker ribbon to give the road a curb/edge strip instead of a
203
+ * hard asphalt-to-terrain seam. `inset` (default 0) nudges the line inward off the raw edge so the
204
+ * strip straddles the border.
205
+ */
206
+ export function curbPaths(road, inset = 0) {
207
+ const edge = road.width / 2 - inset;
208
+ return [offsetPath(road.path, edge), offsetPath(road.path, -edge)];
209
+ }
210
+ const EPS = 1e-6;
211
+ /** Intersection point of segments a→b and c→d, or null when they don't cross (endpoints included). */
212
+ function segmentIntersection(a, b, c, d) {
213
+ const r0 = b[0] - a[0];
214
+ const r1 = b[1] - a[1];
215
+ const s0 = d[0] - c[0];
216
+ const s1 = d[1] - c[1];
217
+ const denom = r0 * s1 - r1 * s0;
218
+ if (Math.abs(denom) < EPS)
219
+ return null; // parallel or degenerate
220
+ const t = ((c[0] - a[0]) * s1 - (c[1] - a[1]) * s0) / denom;
221
+ const u = ((c[0] - a[0]) * r1 - (c[1] - a[1]) * r0) / denom;
222
+ if (t < -EPS || t > 1 + EPS || u < -EPS || u > 1 + EPS)
223
+ return null;
224
+ return [a[0] + r0 * t, a[1] + r1 * t];
225
+ }
226
+ function unit(dx, dz) {
227
+ const len = Math.hypot(dx, dz) || 1;
228
+ return [dx / len, dz / len];
229
+ }
230
+ /**
231
+ * Add the arms a road contributes at junction point `p`: the road continues past `p` in each
232
+ * direction that still has centerline beyond it, so a mid-path crossing yields two opposing arms
233
+ * and a road that terminates at `p` (a T-junction) yields only the one inward arm.
234
+ */
235
+ function armsAt(path, p, width, roadIndex) {
236
+ const arms = [];
237
+ // Nearest vertex "before" (toward path start) and "after" (toward path end) of p.
238
+ // Walk the path measuring where p falls; use the two adjacent tangents.
239
+ let hitSeg = -1;
240
+ for (let i = 0; i < path.length - 1; i += 1) {
241
+ const a = path[i];
242
+ const b = path[i + 1];
243
+ const abx = b[0] - a[0];
244
+ const abz = b[1] - a[1];
245
+ const lengthSq = abx * abx + abz * abz;
246
+ const t = lengthSq === 0 ? 0 : ((p[0] - a[0]) * abx + (p[1] - a[1]) * abz) / lengthSq;
247
+ const px = a[0] + abx * Math.max(0, Math.min(1, t));
248
+ const pz = a[1] + abz * Math.max(0, Math.min(1, t));
249
+ if (Math.hypot(p[0] - px, p[1] - pz) < 1e-3) {
250
+ hitSeg = i;
251
+ break;
252
+ }
253
+ }
254
+ if (hitSeg < 0)
255
+ return arms;
256
+ const a = path[hitSeg];
257
+ const b = path[hitSeg + 1];
258
+ const forward = unit(b[0] - a[0], b[1] - a[1]);
259
+ const atStart = Math.hypot(p[0] - path[0][0], p[1] - path[0][1]) < 1e-3;
260
+ const atEnd = Math.hypot(p[0] - path[path.length - 1][0], p[1] - path[path.length - 1][1]) < 1e-3;
261
+ // Arm toward path end exists unless p sits exactly at the end.
262
+ if (!atEnd)
263
+ arms.push({ roadIndex, direction: forward, width });
264
+ // Arm toward path start exists unless p sits exactly at the start.
265
+ if (!atStart)
266
+ arms.push({ roadIndex, direction: [-forward[0], -forward[1]], width });
267
+ return arms;
268
+ }
269
+ function mergeArm(arms, arm) {
270
+ for (const existing of arms) {
271
+ if (existing.roadIndex === arm.roadIndex) {
272
+ const dot = existing.direction[0] * arm.direction[0] + existing.direction[1] * arm.direction[1];
273
+ if (dot > 0.985)
274
+ return; // same road, near-identical direction — already have this arm
275
+ }
276
+ }
277
+ arms.push(arm);
278
+ }
279
+ /**
280
+ * Detect welded intersections across a set of roads: wherever two road centerlines cross (or a road
281
+ * terminates on another) within the same elevation band, emit a {@link RoadJunction} whose patch
282
+ * covers the crossing ribbons. Nearby crossings merge into one junction. Deterministic and bounded —
283
+ * O(roads² · segments²) with a `maxJunctions` cap — so a scene builds its intersections once.
284
+ */
285
+ export function findRoadJunctions(roads, options = {}) {
286
+ const elevationBand = options.elevationBand ?? 0.12;
287
+ const mergeDistance = options.mergeDistance ?? 6;
288
+ const maxJunctions = options.maxJunctions ?? 256;
289
+ const radiusScale = options.radiusScale ?? 1.15;
290
+ const mergeSq = mergeDistance * mergeDistance;
291
+ const candidates = [];
292
+ const emit = (p, ri, ci) => {
293
+ if (candidates.length >= maxJunctions && findNear(candidates, p, mergeSq) === null)
294
+ return;
295
+ const roadA = roads[ri];
296
+ const roadB = roads[ci];
297
+ const half = Math.max(roadA.width, roadB.width) / 2;
298
+ const radius = half * radiusScale;
299
+ const elevation = Math.max(roadA.elevation, roadB.elevation);
300
+ const wider = roadA.width >= roadB.width ? roadA : roadB;
301
+ const near = findNear(candidates, p, mergeSq);
302
+ const arms = [
303
+ ...armsAt(roadA.path, p, roadA.width, ri),
304
+ ...armsAt(roadB.path, p, roadB.width, ci),
305
+ ];
306
+ if (near === null) {
307
+ const merged = [];
308
+ for (const arm of arms)
309
+ mergeArm(merged, arm);
310
+ candidates.push({
311
+ x: p[0],
312
+ z: p[1],
313
+ radius,
314
+ elevation,
315
+ color: wider.color,
316
+ colorWidth: wider.width,
317
+ arms: merged,
318
+ });
319
+ }
320
+ else {
321
+ near.radius = Math.max(near.radius, radius);
322
+ near.elevation = Math.max(near.elevation, elevation);
323
+ if (wider.width > near.colorWidth) {
324
+ near.color = wider.color;
325
+ near.colorWidth = wider.width;
326
+ }
327
+ for (const arm of arms)
328
+ mergeArm(near.arms, arm);
329
+ }
330
+ };
331
+ for (let i = 0; i < roads.length; i += 1) {
332
+ const roadA = roads[i];
333
+ if (roadA.path.length < 2)
334
+ continue;
335
+ for (let j = i + 1; j < roads.length; j += 1) {
336
+ const roadB = roads[j];
337
+ if (roadB.path.length < 2)
338
+ continue;
339
+ if (Math.abs(roadA.elevation - roadB.elevation) > elevationBand)
340
+ continue;
341
+ for (let si = 0; si < roadA.path.length - 1; si += 1) {
342
+ for (let sj = 0; sj < roadB.path.length - 1; sj += 1) {
343
+ const hit = segmentIntersection(roadA.path[si], roadA.path[si + 1], roadB.path[sj], roadB.path[sj + 1]);
344
+ if (hit !== null)
345
+ emit(hit, i, j);
346
+ }
347
+ }
348
+ }
349
+ }
350
+ return candidates.map((c) => ({
351
+ center: [c.x, c.z],
352
+ radius: c.radius,
353
+ elevation: c.elevation,
354
+ color: c.color,
355
+ approaches: c.arms.map((arm) => ({ direction: arm.direction, width: arm.width })),
356
+ }));
357
+ }
358
+ function findNear(candidates, p, mergeSq) {
359
+ for (const c of candidates) {
360
+ const dx = c.x - p[0];
361
+ const dz = c.z - p[1];
362
+ if (dx * dx + dz * dz <= mergeSq)
363
+ return c;
364
+ }
365
+ return null;
366
+ }
367
+ /** Circular marking-exclusion zones for the junctions, sized to hide the center line under each patch. */
368
+ export function junctionExclusions(junctions, pad = 0.5) {
369
+ return junctions.map((j) => ({ center: j.center, radius: j.radius + pad }));
370
+ }
371
+ /**
372
+ * Data-driven junction entry markings: a stop-line bar across each approach and a band of crosswalk
373
+ * bars just outside the welded patch. Each returned polyline is a thin centerline meant to be meshed
374
+ * with {@link buildRoadRibbon} at a small width. Fully skippable per-feature via the options.
375
+ */
376
+ export function junctionMarkings(junction, options = {}) {
377
+ const wantStop = options.stopLines ?? true;
378
+ const wantCross = options.crosswalks ?? true;
379
+ const barCount = Math.max(1, Math.floor(options.crosswalkBars ?? 5));
380
+ const gap = options.gap ?? 0.6;
381
+ const depth = options.crosswalkDepth ?? 2.4;
382
+ const [cx, cz] = junction.center;
383
+ const stopLines = [];
384
+ const crosswalkBars = [];
385
+ for (const approach of junction.approaches) {
386
+ const [dx, dz] = approach.direction;
387
+ // Across-the-road axis, perpendicular to travel.
388
+ const ax = -dz;
389
+ const az = dx;
390
+ const half = approach.width / 2;
391
+ const stopDist = junction.radius + gap;
392
+ if (wantStop) {
393
+ const px = cx + dx * stopDist;
394
+ const pz = cz + dz * stopDist;
395
+ stopLines.push([
396
+ [px + ax * half, pz + az * half],
397
+ [px - ax * half, pz - az * half],
398
+ ]);
399
+ }
400
+ if (wantCross) {
401
+ const near = stopDist + 0.4;
402
+ const barWidth = half * 2 * 0.16;
403
+ const usable = half * 2 - barWidth;
404
+ for (let b = 0; b < barCount; b += 1) {
405
+ const frac = barCount === 1 ? 0 : b / (barCount - 1) - 0.5;
406
+ const offset = frac * usable;
407
+ const bx = cx + ax * offset;
408
+ const bz = cz + az * offset;
409
+ crosswalkBars.push([
410
+ [bx + dx * near, bz + dz * near],
411
+ [bx + dx * (near + depth), bz + dz * (near + depth)],
412
+ ]);
413
+ }
414
+ }
415
+ }
416
+ return { stopLines, crosswalkBars };
417
+ }
@@ -1,4 +1,4 @@
1
- import type { BiomeBand, EnvironmentWorldFeature, TerrainDetailConfig, TerrainEnvironmentConfig, TerrainEnvironmentDescriptor, TerrainIslandDescriptor, TerrainMaterial, TerrainMaterialMaps, WorldBounds, WorldFeature } from "./features.js";
1
+ import type { BiomeBand, EnvironmentWorldFeature, RoadEnvironmentDescriptor, TerrainDetailConfig, TerrainEnvironmentConfig, TerrainEnvironmentDescriptor, TerrainIslandDescriptor, TerrainMaterial, TerrainMaterialMaps, WorldBounds, WorldFeature } from "./features.js";
2
2
  import type { AvoidZone } from "./geometry.js";
3
3
  import { type TerrainPathProfile } from "./pathTerrain.js";
4
4
  import type { TerraformSnapshot } from "./terraform.js";
@@ -107,6 +107,25 @@ export declare function flattenFieldAround(base: TerrainField, zones: readonly A
107
107
  * through the one seam every consumer already reads.
108
108
  * @internal
109
109
  */
110
+ /**
111
+ * Drapes each `road()` ribbon's `elevation` onto the ground so a body standing on a road snaps to the
112
+ * road *surface* the renderer draws (`sampleHeight + elevation`), not the bare terrain beneath it. Without
113
+ * this the ground field and {@link RoadRibbons}/{@link CityRenderer} disagree by `elevation` per road, and
114
+ * anything grounded on a road — the player, NPCs, spawns via `groundHeightAt` — sinks by that offset into
115
+ * the asphalt. Overlapping ribbons (intersections) take the highest lift. Only `sampleHeight` is draped:
116
+ * the road ribbons are flat, so `sampleNormal` stays the smooth terrain normal — draping it too would spoof
117
+ * a near-vertical slope at every curb edge and trip slope-slide.
118
+ * @internal — reached through `environment({ roads })`; not called directly by games.
119
+ */
120
+ export declare function roadDrapedField(base: TerrainField, roads: readonly RoadEnvironmentDescriptor[]): TerrainField;
121
+ /**
122
+ * The full ground field for an environment world: base `terrain` composed with any `islands`, then
123
+ * any authored `sculpt` snapshot, then any `clearings`, then any `roads` draped on top — so an
124
+ * editor-sculpted heightfield drives both the rendered mesh and player collision, gameplay spots
125
+ * stay level, and anything grounded on a road stands on the drawn asphalt rather than the bare
126
+ * terrain beneath it, through the one seam every consumer already reads.
127
+ * @internal
128
+ */
110
129
  export declare function resolveEnvironmentField(feature: EnvironmentWorldFeature): TerrainField;
111
130
  export interface TerrainSlopeSample {
112
131
  /** Unit XZ direction pointing downhill; `[0, 0]` on level ground. */
@@ -1,5 +1,5 @@
1
1
  import { withPathProfiles } from "./pathTerrain.js";
2
- import { nearestOnPath } from "./roads.js";
2
+ import { isOnRoad, nearestOnPath } from "./roads.js";
3
3
  function clamp01(value) {
4
4
  return value < 0 ? 0 : value > 1 ? 1 : value;
5
5
  }
@@ -308,15 +308,54 @@ export function flattenFieldAround(base, zones) {
308
308
  * through the one seam every consumer already reads.
309
309
  * @internal
310
310
  */
311
+ /**
312
+ * Drapes each `road()` ribbon's `elevation` onto the ground so a body standing on a road snaps to the
313
+ * road *surface* the renderer draws (`sampleHeight + elevation`), not the bare terrain beneath it. Without
314
+ * this the ground field and {@link RoadRibbons}/{@link CityRenderer} disagree by `elevation` per road, and
315
+ * anything grounded on a road — the player, NPCs, spawns via `groundHeightAt` — sinks by that offset into
316
+ * the asphalt. Overlapping ribbons (intersections) take the highest lift. Only `sampleHeight` is draped:
317
+ * the road ribbons are flat, so `sampleNormal` stays the smooth terrain normal — draping it too would spoof
318
+ * a near-vertical slope at every curb edge and trip slope-slide.
319
+ * @internal — reached through `environment({ roads })`; not called directly by games.
320
+ */
321
+ export function roadDrapedField(base, roads) {
322
+ if (roads.length === 0)
323
+ return base;
324
+ const sampleHeight = (x, z) => {
325
+ let lift = 0;
326
+ for (const road of roads) {
327
+ if (road.elevation > lift && isOnRoad(road.path, road.width, x, z))
328
+ lift = road.elevation;
329
+ }
330
+ return base.sampleHeight(x, z) + lift;
331
+ };
332
+ return {
333
+ sampleHeight,
334
+ sampleNormal: base.sampleNormal,
335
+ ...(base.bounds === undefined ? {} : { bounds: base.bounds }),
336
+ ...(base.waterLevel === undefined ? {} : { waterLevel: base.waterLevel }),
337
+ };
338
+ }
339
+ /**
340
+ * The full ground field for an environment world: base `terrain` composed with any `islands`, then
341
+ * any authored `sculpt` snapshot, then any `clearings`, then any `roads` draped on top — so an
342
+ * editor-sculpted heightfield drives both the rendered mesh and player collision, gameplay spots
343
+ * stay level, and anything grounded on a road stands on the drawn asphalt rather than the bare
344
+ * terrain beneath it, through the one seam every consumer already reads.
345
+ * @internal
346
+ */
311
347
  export function resolveEnvironmentField(feature) {
312
348
  const base = feature.terrain === undefined ? null : resolveTerrainField(feature.terrain);
313
349
  const composed = feature.islands === undefined || feature.islands.length === 0
314
350
  ? base ?? flatField()
315
351
  : composeIslandFields(base, feature.islands);
316
352
  const sculpted = feature.sculpt === undefined ? composed : sculptedField(composed, feature.sculpt);
317
- return feature.clearings === undefined || feature.clearings.length === 0
353
+ const cleared = feature.clearings === undefined || feature.clearings.length === 0
318
354
  ? sculpted
319
355
  : flattenFieldAround(sculpted, feature.clearings);
356
+ return feature.roads === undefined || feature.roads.length === 0
357
+ ? cleared
358
+ : roadDrapedField(cleared, feature.roads);
320
359
  }
321
360
  /** Local slope from the field's normal — the input for gravity-roll, ski acceleration, and slide checks (#284.6).
322
361
  * @internal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/core",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "jgengine core — agent-first game framework SDK (npm @jgengine/core). Entity stores, commands, combat, inventory, multiplayer seams. Zero dependencies. Not an ECS. https://jgengine.com",
5
5
  "keywords": [
6
6
  "jgengine",
@@ -42,6 +42,7 @@ World content routes editor-first: any request that adds, moves, restyles, or re
42
42
 
43
43
  Within each selected domain:
44
44
 
45
+ 0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) for `combat-loop`, `boss-telegraph`, `loot`, `quest`, `coop-presence`, `third-person-camera`; `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
45
46
  1. Search `capabilities.md` by intent; it maps needs to imports.
46
47
  2. Open `api.md` only when exact signatures or export inventory are needed.
47
48
  3. Open the linked reference only for a deeper recipe or trap.
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.12.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.13.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -38,7 +38,7 @@
38
38
  - `WorldFeature` (type): type WorldFeature = | PlaceWorldFeature | ({ kind: "biomes" } & BiomesWorldConfig) | ({ kind: "voxel" } & VoxelWorldConfig) | ({ kind: "plots" } & PlotsWorldConfig) | ({ kind: "tilemap" } & TilemapWorldConfig) | EnvironmentWorldFeature | { kind: "flat" } — A declared world shape passed to `defineGame`. The preferred model is the place feature from `world()` (`@jgengine/core/world/place`): substrate + laws, with all dressing authored in the editor. The remaining members — biomes, voxel grid, plots, tilemap, environment, flat — are the legacy code-declared shapes kept for existing games.
39
39
  - `auditManifest` (function): function auditManifest(manifest: SceneOwnershipManifest): OwnershipDiagnostic[] — Audit every declaration in a manifest, keying diagnostics by each object's provenance id. An empty result means the manifest declares all of its content cleanly — every object is authored, generated, bakeable, or a reasoned runtime/transient object.
40
40
  - `biomes` (function): function biomes(config: BiomesWorldConfig): WorldFeature — Declares a biome-painted world — the whole-world alternative to a single `environment()` terrain.
41
- - `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style.
41
+ - `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style. Pass `along` to line road frontage instead of gridding around `position`.
42
42
  - `classifyOwnership` (function): function classifyOwnership(declaration: SceneOwnershipDeclaration): OwnershipVerdict — Resolve one declaration into a single boundary verdict.
43
43
  - `collectOwnershipDiagnostics` (function): function collectOwnershipDiagnostics(entries: Iterable<readonly [string, SceneOwnershipDeclaration]>): OwnershipDiagnostic[] — Audit a keyed set of declarations and return one diagnostic per object that breaks the boundary contract (a `reject` verdict carrying a `violation`). Deterministic: diagnostics come back in the iteration order of `entries`.
44
44
  - `contextVerb` (function): function contextVerb(label: string, command: string, args?: Record<string, unknown>): ContextVerb — Builds a {@link ContextVerb} for a right-click menu entry.
@@ -209,11 +209,18 @@
209
209
  - `Tunable` (interface): interface Tunable<T> — Typed handle to a registered control, exposing its live value and subscribe/set/reset operations.
210
210
  - `TunableAccessor` (interface): interface TunableAccessor — Get/set accessor pair plus initial value used to bind a discovered field to a control.
211
211
 
212
+ ## @jgengine/core/devtools/urlFlags
213
+
214
+ - `readUrlFlag` (function): function readUrlFlag(param: string): boolean — True when `param` is present and not an explicit off value (`0`/`false`/`off`/`no`).
215
+ - `readUrlParam` (function): function readUrlParam(param: string): string | null — The current query value of `param`, or null when absent (or no DOM).
216
+ - `subscribeUrlChange` (function): function subscribeUrlChange(listener: () => void): () => void — Fires `listener` whenever the query string may have changed out from under a flag — browser back/forward (`popstate`). Manual address-bar edits reload the page, so initial reads cover those; this keeps in-app state honest across history navigation. Returns an unsubscribe.
217
+ - `writeUrlParam` (function): function writeUrlParam(param: string, value: string | null): void — Sets `param` to `value`, or removes it when `value` is null, rewriting the URL in place with `history.replaceState` so the rest of the query and the hash survive and no history entry is pushed. No-ops without a DOM, and skips the write when the URL already matches.
218
+
212
219
  ## @jgengine/core/meta/changelog
213
220
 
214
221
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
215
222
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
216
- - `VERSION` (const): const VERSION: "0.12.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
223
+ - `VERSION` (const): const VERSION: "0.13.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
217
224
 
218
225
  ## @jgengine/core/runtime/adapter
219
226
 
@@ -276,7 +283,7 @@
276
283
  - `SceneObjectContext` (interface): interface SceneObjectContext extends ObjectStore — ⚠ undocumented
277
284
  - `SceneWorldItemContext` (interface): interface SceneWorldItemContext — ⚠ undocumented
278
285
  - `TelegraphInput` (interface): interface TelegraphInput — ⚠ undocumented
279
- - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst; `from`/`to` accept an instance id or a world point, `color` is a `0xRRGGBB` tint, and `durationMs` defaults per `kind`.
286
+ - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst. The easy path is a named `preset` — `vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with no color or archetype tuning; `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"` and the rest of {@link vfxPresets} likewise just work. `from`/`to` accept an instance id (the shell follows its live pose) or a fixed world point. Anything you also pass — `kind`, `color` (`0xRRGGBB`), `radius`, `durationMs` — overrides the preset; supply `kind` + `color` yourself for a fully custom burst with no preset. `durationMs` defaults per `kind`.
280
287
  - `WorldItemPickupResult` (type): type WorldItemPickupResult = | { status: "ok"; record: WorldItemRecord } | { status: "rejected"; reason: string } — ⚠ undocumented
281
288
  - `createGameContext` (function): function createGameContext<TAssetRef extends ModelAssetRef, TMultiplayer>(options: GameContextOptions<TAssetRef, TMultiplayer>): GameContext — ⚠ undocumented
282
289
 
@@ -307,7 +314,7 @@
307
314
  - `SceneObjectContext` (interface): interface SceneObjectContext extends ObjectStore — ⚠ undocumented
308
315
  - `SceneWorldItemContext` (interface): interface SceneWorldItemContext — ⚠ undocumented
309
316
  - `TelegraphInput` (interface): interface TelegraphInput — ⚠ undocumented
310
- - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst; `from`/`to` accept an instance id or a world point, `color` is a `0xRRGGBB` tint, and `durationMs` defaults per `kind`.
317
+ - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst. The easy path is a named `preset` — `vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with no color or archetype tuning; `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"` and the rest of {@link vfxPresets} likewise just work. `from`/`to` accept an instance id (the shell follows its live pose) or a fixed world point. Anything you also pass — `kind`, `color` (`0xRRGGBB`), `radius`, `durationMs` — overrides the preset; supply `kind` + `color` yourself for a fully custom burst with no preset. `durationMs` defaults per `kind`.
311
318
  - `WorldItemPickupResult` (type): type WorldItemPickupResult = | { status: "ok"; record: WorldItemRecord } | { status: "rejected"; reason: string } — ⚠ undocumented
312
319
 
313
320
  ## @jgengine/core/runtime/gameRuntime
@@ -322,6 +329,7 @@
322
329
 
323
330
  ## @jgengine/core/runtime/headlessRunner
324
331
 
332
+ - `HeadlessCommandInterface` (interface): interface HeadlessCommandInterface — Canvas-free command/intent surface for {@link HeadlessRunner} — invoke a game's registered UI commands and read the resulting reactive state off `ctx`.
325
333
  - `HeadlessInput` (interface): interface HeadlessInput — One step's worth of player intent handed to {@link HeadlessRunner.step} — the held-action set and pointer state the shell would otherwise publish from the browser each frame.
326
334
  - `HeadlessRunner` (interface): interface HeadlessRunner — A renderer-free driver for a game loop: builds a {@link GameContext} from a {@link GameDefinition}, runs the init hooks, then advances the simulation one step at a time from injected input. No React, R3F, or three.js — the whole play path (time, input, `onTick`, behaviour nav, optional player movement) runs from `core` primitives alone, so a non-React host (a server tick, a test, a CLI replay) can play a real game and read its world snapshot. The shell's FrameDriver is one such driver bolted to `useFrame`; this is the same step distilled out of the render tree.
327
335
  - `HeadlessRunnerOptions` (interface): interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultiplayer> — ⚠ undocumented
@@ -14,7 +14,21 @@ Search [capabilities.md](capabilities.md), then use [api.md](api.md) for signatu
14
14
  Existing projects keep their resource state and follow the
15
15
  [portable stat-pool recipe](recipes/portable-stat-pools.md). Adapt the caller's
16
16
  store through `StatPoolAccess`; do not require `StatValueMap`, `GameContext`, or
17
- a parallel entity store.
17
+ a parallel entity store. To bolt firing onto an existing game, compose
18
+ `createWeaponRuntime` (cadence + magazine + the caller's own raycast + portable
19
+ `resolveDamageHit`) per the
20
+ [portable weapon-plumbing recipe](recipes/portable-weapon-plumbing.md); never add
21
+ a default gun or FPS kit.
22
+
23
+ Damage commitment accepts the same structural pool adapter while the matchup,
24
+ receiver, interceptor, and status stages remain pure. Follow the
25
+ [portable damage/effects recipe](recipes/portable-damage-effects.md) to compose
26
+ them over an existing store, clock, RNG, spatial index, and death flow.
27
+
28
+ Closure-backed magazines and stat modifiers expose plain snapshot/restore
29
+ state for caller-owned saves, rollback, replay, and workers. Follow the
30
+ [portable runtime-state recipe](recipes/portable-runtime-state.md); the caller
31
+ keeps its clock, reserve state, schema, and authority.
18
32
 
19
33
  ## Canonical pipeline
20
34
 
@@ -28,6 +28,7 @@
28
28
  - `DEFAULT_FIRE_PULSE_SECONDS` (const): const DEFAULT_FIRE_PULSE_SECONDS: 0.12 — Default `RenderCueTuning.firePulseSeconds`.
29
29
  - `DEFAULT_HIT_PULSE_SECONDS` (const): const DEFAULT_HIT_PULSE_SECONDS: 0.2 — Default `RenderCueTuning.hitPulseSeconds`.
30
30
  - `DEFAULT_RENDER_CUES` (const): const DEFAULT_RENDER_CUES: Readonly<EntityRenderCues> — Neutral starting cue set: idle, unarmed, undamaged.
31
+ - `DEFAULT_VFX_PRESET` (const): const DEFAULT_VFX_PRESET: VfxPresetName — The fallback flavor used when a `vfx()` call names an unknown preset — so a typo still shows a spark instead of nothing.
31
32
  - `DamageChannelId` (type): type DamageChannelId = string — Caller-owned id for a damage channel — `"fire"`, `"ballistic"`, `"physical"`, `"corrosive"`. The engine never branches on the value; a channel is whatever a game's data declares.
32
33
  - `DamageClampConfig` (interface): interface DamageClampConfig — Caller data for a flat per-hit damage cap.
33
34
  - `DamageContext` (interface): interface DamageContext — The facts a received-damage modifier predicates over: the incoming `channel`, who dealt it, the statuses the target currently carries, and any caller tags. All caller-owned strings; the engine never assigns meaning to a channel, source, status, or tag.
@@ -50,6 +51,8 @@
50
51
  - `EntityRenderCues` (interface): interface EntityRenderCues — Per-entity render cues: the motion/animation signal a custom `renderEntity` or first-person viewmodel component needs to drive gait, muzzle flash, reload poses, and hit reactions — without diffing the parent group's position itself or reading a game-side module map for attack timing.
51
52
  - `EventMeter` (interface): interface EventMeter — ⚠ undocumented
52
53
  - `EventMeterFeedResult` (interface): interface EventMeterFeedResult — ⚠ undocumented
54
+ - `FireCadence` (interface): interface FireCadence — A minimal minimum-interval rate gate for a repeated action — weapon fire, ability spam, any "no faster than N per second" rule. Deterministic and serializable: its whole state is the elapsed time since the last action, exposed via {@link FireCadence.elapsedMs}.
55
+ - `FireCadenceConfig` (interface): interface FireCadenceConfig — Tuning for {@link createFireCadence}: the minimum interval between actions and whether it starts ready.
53
56
  - `FrameRange` (interface): interface FrameRange — ⚠ undocumented
54
57
  - `HitReactionConfig` (interface): interface HitReactionConfig — ⚠ undocumented
55
58
  - `ImmunityWindow` (interface): interface ImmunityWindow — A per-target timed immunity policy: an interceptor plus grant/query/serialize controls, toggled by transitions.
@@ -58,7 +61,9 @@
58
61
  - `InterceptRecord` (interface): interface InterceptRecord — One row of provenance: which interceptor acted, what it decided, and the amount before/after it acted.
59
62
  - `MAX_INTERCEPT_STEPS` (const): const MAX_INTERCEPT_STEPS: 256 — Upper bound on interceptor evaluations for a single resolution; guards against runaway `split`/`redirect` loops.
60
63
  - `Magazine` (interface): interface Magazine — A per-weapon magazine: discrete loaded rounds, a timed reload that refills from a reserve pool, and the reserve-pool interaction itself — the primitive that replaces hand-rolling mag size, reload delay, and reserve bookkeeping per game (#536.2).
64
+ - `MagazineConfig` (interface): interface MagazineConfig — Tuning for `createMagazine`: mag capacity, reload delay, and where reserve ammo is drawn from.
61
65
  - `MagazineReserve` (interface): interface MagazineReserve — Draws ammo for a `Magazine`'s reload from wherever the reserve pool actually lives.
66
+ - `MagazineSnapshot` (interface): interface MagazineSnapshot — Complete serializable state for a {@link Magazine}; `reserve: null` means infinite reserve.
62
67
  - `MatchupOutputs` (interface): interface MatchupOutputs — The independent, typed outputs a channel-vs-trait matchup can scale. Every field is an optional multiplier that defaults to `1` (identity), so a matchup entry names only the axes it bends. `impact` scales the direct hit, `applyChance` the odds a status lands, `magnitude` and `duration` the applied status itself, and `extra` carries any further caller-defined scalar (knockback, stagger, heat) the engine forwards without interpreting.
63
68
  - `MeterAddResult` (interface): interface MeterAddResult — ⚠ undocumented
64
69
  - `ObjectRaycastHit` (interface): interface ObjectRaycastHit — ⚠ undocumented
@@ -95,6 +100,7 @@
95
100
  - `StatPoolInput` (interface): interface StatPoolInput — Input used to create a normalized pool. `current` defaults to `max`; `min` defaults to zero.
96
101
  - `StatPoolPatch` (interface): interface StatPoolPatch — Partial update applied without mutating the previous pool.
97
102
  - `Stats` (interface): interface Stats<TStat extends string> — ⚠ undocumented
103
+ - `StatsSnapshot` (interface): interface StatsSnapshot<TStat extends string> — Complete plain-data state for a {@link Stats} runtime.
98
104
  - `StatusApplicationOutcome` (interface): interface StatusApplicationOutcome — The result of one status application: the outcome, the resulting instance (if any), and the roll.
99
105
  - `StatusApplicationSpec` (interface): interface StatusApplicationSpec — A serializable, effect-agnostic description of a status a hit tries to apply: an opaque `status` id the game routes to its own effect/DoT pipeline, a base landing `chance`, and the duration/ticks/magnitude/attribution/stacking data the resolver needs. The engine never interprets `status` — it is a reference, not a built-in condition.
100
106
  - `StatusImmunity` (interface): interface StatusImmunity — Statuses a target cannot receive at all — the immunity half of application policy.
@@ -108,6 +114,12 @@
108
114
  - `ThresholdMark` (interface): interface ThresholdMark — Generic numeric threshold-crossing tracker (#931).
109
115
  - `ThresholdTracker` (interface): interface ThresholdTracker — A stateful crossing tracker over one numeric source.
110
116
  - `ThresholdTrackerSnapshot` (interface): interface ThresholdTrackerSnapshot — Serializable tracker state for save/load.
117
+ - `VfxPreset` (interface): interface VfxPreset — A named visual flavor for a one-shot `combat.vfx` burst: the archetype (`kind`), a `0xRRGGBB` tint, and optional default `durationMs`/`radius`. Purely presentation — a preset never touches damage, range, or any authoritative value; it just answers "what does this attack look like" so a game does not hand-roll colors and archetypes.
118
+ - `VfxPresetName` (type): type VfxPresetName = keyof typeof vfxPresets — A named key into {@link vfxPresets} (`"arrow" | "fireball" | "lightning" | "web" | "slash" | "shield" | "heal" | ...`).
119
+ - `WeaponFireResult` (interface): interface WeaponFireResult<THit> — The result of a {@link WeaponRuntime.fire} attempt. On `"fired"`, `hits` is whatever the caller's raycast returned and `resolutions` holds the portable damage provenance for each hit that produced a {@link DamageHitInput}; on any blocked status both are empty and no round was spent.
120
+ - `WeaponFireStatus` (type): type WeaponFireStatus = "fired" | "cooling" | "empty" | "reloading" — Why a {@link WeaponRuntime.fire} attempt resolved the way it did.
121
+ - `WeaponRuntime` (interface): interface WeaponRuntime<TAim, THit> — A composed weapon fire controller: a {@link FireCadence} gate, an optional {@link Magazine}, the caller's raycast, and portable {@link resolveDamageHit} damage resolution wired together. The caller retains ownership of models, animations, muzzle flash, audio, input, camera, aiming, and the raycaster itself.
122
+ - `WeaponRuntimeConfig` (interface): interface WeaponRuntimeConfig<TAim, THit> — Configuration for {@link createWeaponRuntime}. Everything time/ammo-related is plain data; the two callbacks are where the caller keeps ownership of aiming, its own raycaster/projectile query, and how a hit maps to damage. There is deliberately no default weapon, projectile, or damage table here — those are game content.
111
123
  - `activePhaseId` (function): function activePhaseId<TData>(state: EncounterState<TData>): string | null — The deepest active phase id — the completion cursor a HUD reads to label the current wave/phase — or null before start and once done.
112
124
  - `advanceCombo` (function): function advanceCombo(input: AdvanceComboInput): AdvanceComboResult — ⚠ undocumented
113
125
  - `advanceMotionCues` (function): function advanceMotionCues(cues: EntityRenderCues, speed: number, dt: number, tuning?: RenderCueTuning): EntityRenderCues — Advances `bobPhase` and decays `recoil` from a live `speed` sample (e.g. `groundSpeed(entity)`); leaves event-driven fields untouched.
@@ -133,9 +145,10 @@
133
145
  - `createDefensiveWindow` (function): function createDefensiveWindow(config: DefensiveWindowConfig): DefensiveWindow — Open a timed defensive window — block, parry, or i-frames — and test incoming hits against it.
134
146
  - `createDotField` (function): function createDotField(): DotField — Builds an empty {@link DotField}; `apply` DoTs onto it and drain damage each frame with `tick`.
135
147
  - `createDownedState` (function): function createDownedState(config: DownedConfig): DownedState — A downed/bleed-out state that ticks toward death and that teammates can revive before the timer runs out.
136
- - `createEffectSystem` (function): function createEffectSystem(deps: EffectSystemDeps): EffectSystem — Apply, stack, and tick timed status effects — buffs, debuffs, DoTs — on entities.
148
+ - `createEffectSystem` (function): function createEffectSystem(deps: EffectSystemDeps): EffectSystem — Resolve direct or area resource effects through caller-owned stat pools, including ordered absorption, restoration, modifiers, and lethal context.
137
149
  - `createEncounterState` (function): function createEncounterState<TData>(config: EncounterConfig<TData>): EncounterState<TData> — Build the initial, serializable state for an encounter from its config. Deep-copies the authored phase tree (so later injection never mutates the config), verifies phase ids are unique, and leaves every node pending. Does not enter the first phase — call {@link startEncounter} for that.
138
150
  - `createEventMeter` (function): function createEventMeter(config: EventMeterConfig): EventMeter — A heat/hype gauge that rises as tagged events land and cools between them, firing when it fills or breaks — the streak/overdrive meter shooters and fighters hand-roll.
151
+ - `createFireCadence` (function): function createFireCadence(config: FireCadenceConfig): FireCadence — Build a {@link FireCadence} rate gate. Reach for this instead of hand-tracking a `lastFiredAt` timestamp and comparing against a fire interval per game.
139
152
  - `createImmunityWindow` (function): function createImmunityWindow(id = "immunity"): ImmunityWindow — A per-target immunity/invulnerability window as an ordinary damage policy — granted and cleared by state transitions (i-frames, phase invuln), rejecting damage while active. Deterministic (time is passed in) and serializable.
140
153
  - `createMagazine` (function): function createMagazine(config: MagazineConfig): Magazine — Builds a {@link Magazine}: discrete loaded ammo, a timed reload, and reserve-pool interaction.
141
154
  - `createProjectileSystem` (function): function createProjectileSystem(deps: ProjectileSystemDeps): ProjectileSystem — Spawn and advance projectiles each frame, resolving travel, lifetime, and hits.
@@ -145,6 +158,7 @@
145
158
  - `createStatPool` (function): function createStatPool(input: StatPoolInput): StatPool — Normalize caller data into a complete plain-data pool.
146
159
  - `createStats` (function): function createStats<TStat extends string>(base: Record<TStat, number>, options?: CreateStatsOptions): Stats<TStat> — A stat block whose base values take stacking, timed buffs and debuffs, resolving the modified value on read.
147
160
  - `createThresholdTracker` (function): function createThresholdTracker(config: ThresholdCrossingConfig): ThresholdTracker — Track a numeric source against named thresholds and emit ordered crossing events with direction, hysteresis, once/repeat policy, and skipped-threshold handling. Deterministic and serializable; drives phase transitions, armor breaks, resource warnings, and any other threshold consumer.
161
+ - `createWeaponRuntime` (function): function createWeaponRuntime<TAim, THit>(config: WeaponRuntimeConfig<TAim, THit>): WeaponRuntime<TAim, THit> — Compose a portable weapon fire controller from the existing generic primitives — a {@link FireCadence} gate, an optional {@link Magazine}, a caller-provided raycast, and portable {@link resolveDamageHit} damage resolution. No `GameContext`, entity store, renderer, or default weapon is involved; the caller owns aiming, its raycaster, and all presentation, and the result carries full damage provenance for authority to apply and replicate.
148
162
  - `encounterProgress` (function): function encounterProgress<TData>(state: EncounterState<TData>): { completed: number; total: number; done: boolean; } — Progress across the whole tree: how many phases have completed out of the total node count, plus whether the encounter has finished. Counts injected phases.
149
163
  - `eyeHeightFromColliders` (function): function eyeHeightFromColliders(set: EntityColliderSet | null | undefined): number — Eye height derived from a collider set: 90% of the tallest hitbox top, or the humanoid default when unknown.
150
164
  - `filterOutcomeFor` (function): function filterOutcomeFor(outcome: AllocationOutcome, viewerId: string): AllocationOutcome — Project an allocation outcome for one viewer, enforcing replication authoritatively: private grants belonging to other recipients are dropped, and pools the viewer cannot claim (not eligible, or reserved for someone else) are hidden — so private rewards never leak across the wire while provenance on the viewer's own grants is preserved.
@@ -167,10 +181,12 @@
167
181
  - `resolveResistance` (function): function resolveResistance<TCategory extends string = string, TProperty extends string = string>(matrix: ResistanceMatrix<TCategory, TProperty>, category: TCategory | string, targetProperties: readonly (TProperty | string)[]): ResistanceResult — ⚠ undocumented
168
182
  - `resolveShot` (function): function resolveShot(deps: ShotOriginDeps, from: string, aim: Aim, policy: ShotOriginPolicy = { kind: "eye" }): ResolvedShot | null — ⚠ undocumented
169
183
  - `resolveStatusApplication` (function): function resolveStatusApplication(input: StatusApplicationInput): StatusApplicationOutcome — Resolve one status application: reject on immunity, roll landing chance against injected RNG, scale magnitude/duration by the matchup, then fold into any existing instance per the stacking policy. Deterministic given a seeded `rng`; pure over serializable data, so authority can run it and replicate only the returned `instance`.
184
+ - `resolveVfxPreset` (function): function resolveVfxPreset(name: string | undefined, overrides?: Partial<VfxPreset>): VfxPreset — Resolve a named visual flavor into a concrete `{ kind, color, durationMs?, radius? }`, with any provided `overrides` winning field by field. An unknown name resolves to {@link DEFAULT_VFX_PRESET} rather than throwing, so a flavor typo degrades to a visible spark. Used by `ctx.scene.entity.vfx({ preset })`; call it directly when you need the raw numbers (e.g. to seed a retained `vfxInstance` from the same vocabulary).
170
185
  - `rollCheck` (function): function rollCheck(input: CheckInput, rng: () => number = Math.random): CheckResult — Resolve a tabletop-style pass/fail roll against a target number with modifiers and crit/fumble bands.
171
186
  - `startEncounter` (function): function startEncounter<TData>(config: EncounterConfig<TData>, state: EncounterState<TData>, ctx: EncounterContext = {}): EncounterStep<TData> — Start a created encounter: enter the first root phase, descending to its first leaf, and return the initial enter/spawn events. A no-op (with no events) on an already-started or empty encounter. Mutates and returns `state`.
172
187
  - `tierAt` (function): function tierAt(value: number, tiers: readonly MeterTier[]): string | null — The highest tier id whose `at` threshold `value` has reached, or `null` below every tier — the pure lookup `createAccumulatorMeter`/`createEventMeter` call on every `add`/`tick`.
173
188
  - `updateEncounter` (function): function updateEncounter<TData>(config: EncounterConfig<TData>, state: EncounterState<TData>, dt: number, ctx: EncounterContext = {}): EncounterStep<TData> — Advance an encounter by `dt` seconds against the caller `ctx`. Accumulates time, then repeatedly: evaluates the cursor node's completion predicate; on completion, either re-runs its subtree (`repeat`) or marks it complete and advances to the next sibling — bubbling up to parent groups whose children are exhausted — entering the next chain and emitting enter/spawn/complete events. Emits a final `done` event when the last phase completes. Bounded per call by node count; mutates and returns `state`.
189
+ - `vfxPresets` (const): const vfxPresets: { readonly arrow: { readonly kind: "projectile"; readonly color: 14207392; }; readonly bolt: { readonly kind: "projectile"; readonly color: 16777215; }; readonly fireball: { readonly kind: "projectile"; readonly color: 16738842; readonly durationMs: 460; }; readonly firebolt: { rea… — Ready-made visual flavors for the things players actually shoot, swing, and cast — so `ctx.scene.entity.vfx({ preset: "arrow", from, to })` renders a visible bolt with zero tuning, and `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"` likewise just work. Each entry only picks a `kind` (`projectile | beam | nova | glow | spark`), a color, and a lifetime; it is visual vocabulary (like named CSS colors), not a gameplay archetype — combat numbers stay in the caller's own effect/ability data. Any field passed to `vfx()` overrides the preset, and unknown names fall back to `spark` so a flavor typo still shows *something* rather than nothing.
174
190
 
175
191
  ## @jgengine/core/combat/abilityKit
176
192
 
@@ -350,7 +366,7 @@
350
366
  - `ReceiveMap` (type): type ReceiveMap = Record<string, ReceiveRule> — ⚠ undocumented
351
367
  - `ReceiveRule` (interface): interface ReceiveRule — ⚠ undocumented
352
368
  - `SingleTargetEffectInput` (interface): interface SingleTargetEffectInput — ⚠ undocumented
353
- - `createEffectSystem` (function): function createEffectSystem(deps: EffectSystemDeps): EffectSystem — Apply, stack, and tick timed status effects — buffs, debuffs, DoTs — on entities.
369
+ - `createEffectSystem` (function): function createEffectSystem(deps: EffectSystemDeps): EffectSystem — Resolve direct or area resource effects through caller-owned stat pools, including ordered absorption, restoration, modifiers, and lethal context.
354
370
  - `resolveAreaTargets` (function): function resolveAreaTargets(spatial: CombatSpatialDeps, input: AreaTargetInput, accept?: (instanceId: string) => boolean): AreaTarget[] — ⚠ undocumented
355
371
 
356
372
  ## @jgengine/core/combat/encounterSequence
@@ -400,6 +416,7 @@
400
416
  - `Magazine` (interface): interface Magazine — A per-weapon magazine: discrete loaded rounds, a timed reload that refills from a reserve pool, and the reserve-pool interaction itself — the primitive that replaces hand-rolling mag size, reload delay, and reserve bookkeeping per game (#536.2).
401
417
  - `MagazineConfig` (interface): interface MagazineConfig — Tuning for `createMagazine`: mag capacity, reload delay, and where reserve ammo is drawn from.
402
418
  - `MagazineReserve` (interface): interface MagazineReserve — Draws ammo for a `Magazine`'s reload from wherever the reserve pool actually lives.
419
+ - `MagazineSnapshot` (interface): interface MagazineSnapshot — Complete serializable state for a {@link Magazine}; `reserve: null` means infinite reserve.
403
420
  - `createMagazine` (function): function createMagazine(config: MagazineConfig): Magazine — Builds a {@link Magazine}: discrete loaded ammo, a timed reload, and reserve-pool interaction.
404
421
 
405
422
  ## @jgengine/core/combat/projectiles
@@ -526,6 +543,25 @@
526
543
  - `ThresholdTrackerSnapshot` (interface): interface ThresholdTrackerSnapshot — Serializable tracker state for save/load.
527
544
  - `createThresholdTracker` (function): function createThresholdTracker(config: ThresholdCrossingConfig): ThresholdTracker — Track a numeric source against named thresholds and emit ordered crossing events with direction, hysteresis, once/repeat policy, and skipped-threshold handling. Deterministic and serializable; drives phase transitions, armor breaks, resource warnings, and any other threshold consumer.
528
545
 
546
+ ## @jgengine/core/combat/vfxPresets
547
+
548
+ - `DEFAULT_VFX_PRESET` (const): const DEFAULT_VFX_PRESET: VfxPresetName — The fallback flavor used when a `vfx()` call names an unknown preset — so a typo still shows a spark instead of nothing.
549
+ - `VfxPreset` (interface): interface VfxPreset — A named visual flavor for a one-shot `combat.vfx` burst: the archetype (`kind`), a `0xRRGGBB` tint, and optional default `durationMs`/`radius`. Purely presentation — a preset never touches damage, range, or any authoritative value; it just answers "what does this attack look like" so a game does not hand-roll colors and archetypes.
550
+ - `VfxPresetName` (type): type VfxPresetName = keyof typeof vfxPresets — A named key into {@link vfxPresets} (`"arrow" | "fireball" | "lightning" | "web" | "slash" | "shield" | "heal" | ...`).
551
+ - `resolveVfxPreset` (function): function resolveVfxPreset(name: string | undefined, overrides?: Partial<VfxPreset>): VfxPreset — Resolve a named visual flavor into a concrete `{ kind, color, durationMs?, radius? }`, with any provided `overrides` winning field by field. An unknown name resolves to {@link DEFAULT_VFX_PRESET} rather than throwing, so a flavor typo degrades to a visible spark. Used by `ctx.scene.entity.vfx({ preset })`; call it directly when you need the raw numbers (e.g. to seed a retained `vfxInstance` from the same vocabulary).
552
+ - `vfxPresets` (const): const vfxPresets: { readonly arrow: { readonly kind: "projectile"; readonly color: 14207392; }; readonly bolt: { readonly kind: "projectile"; readonly color: 16777215; }; readonly fireball: { readonly kind: "projectile"; readonly color: 16738842; readonly durationMs: 460; }; readonly firebolt: { rea… — Ready-made visual flavors for the things players actually shoot, swing, and cast — so `ctx.scene.entity.vfx({ preset: "arrow", from, to })` renders a visible bolt with zero tuning, and `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"` likewise just work. Each entry only picks a `kind` (`projectile | beam | nova | glow | spark`), a color, and a lifetime; it is visual vocabulary (like named CSS colors), not a gameplay archetype — combat numbers stay in the caller's own effect/ability data. Any field passed to `vfx()` overrides the preset, and unknown names fall back to `spark` so a flavor typo still shows *something* rather than nothing.
553
+
554
+ ## @jgengine/core/combat/weaponFire
555
+
556
+ - `FireCadence` (interface): interface FireCadence — A minimal minimum-interval rate gate for a repeated action — weapon fire, ability spam, any "no faster than N per second" rule. Deterministic and serializable: its whole state is the elapsed time since the last action, exposed via {@link FireCadence.elapsedMs}.
557
+ - `FireCadenceConfig` (interface): interface FireCadenceConfig — Tuning for {@link createFireCadence}: the minimum interval between actions and whether it starts ready.
558
+ - `WeaponFireResult` (interface): interface WeaponFireResult<THit> — The result of a {@link WeaponRuntime.fire} attempt. On `"fired"`, `hits` is whatever the caller's raycast returned and `resolutions` holds the portable damage provenance for each hit that produced a {@link DamageHitInput}; on any blocked status both are empty and no round was spent.
559
+ - `WeaponFireStatus` (type): type WeaponFireStatus = "fired" | "cooling" | "empty" | "reloading" — Why a {@link WeaponRuntime.fire} attempt resolved the way it did.
560
+ - `WeaponRuntime` (interface): interface WeaponRuntime<TAim, THit> — A composed weapon fire controller: a {@link FireCadence} gate, an optional {@link Magazine}, the caller's raycast, and portable {@link resolveDamageHit} damage resolution wired together. The caller retains ownership of models, animations, muzzle flash, audio, input, camera, aiming, and the raycaster itself.
561
+ - `WeaponRuntimeConfig` (interface): interface WeaponRuntimeConfig<TAim, THit> — Configuration for {@link createWeaponRuntime}. Everything time/ammo-related is plain data; the two callbacks are where the caller keeps ownership of aiming, its own raycaster/projectile query, and how a hit maps to damage. There is deliberately no default weapon, projectile, or damage table here — those are game content.
562
+ - `createFireCadence` (function): function createFireCadence(config: FireCadenceConfig): FireCadence — Build a {@link FireCadence} rate gate. Reach for this instead of hand-tracking a `lastFiredAt` timestamp and comparing against a fire interval per game.
563
+ - `createWeaponRuntime` (function): function createWeaponRuntime<TAim, THit>(config: WeaponRuntimeConfig<TAim, THit>): WeaponRuntime<TAim, THit> — Compose a portable weapon fire controller from the existing generic primitives — a {@link FireCadence} gate, an optional {@link Magazine}, a caller-provided raycast, and portable {@link resolveDamageHit} damage resolution. No `GameContext`, entity store, renderer, or default weapon is involved; the caller owns aiming, its raycaster, and all presentation, and the result carries full damage provenance for authority to apply and replicate.
564
+
529
565
  ## @jgengine/core/stats/accumulatorMeter
530
566
 
531
567
  - `AccumulatorMeter` (interface): interface AccumulatorMeter — ⚠ undocumented
@@ -569,6 +605,7 @@
569
605
  - `StatModifier` (interface): interface StatModifier — ⚠ undocumented
570
606
  - `StatModifierSet` (type): type StatModifierSet<TStat extends string> = Partial<Record<TStat, StatModifier>> — ⚠ undocumented
571
607
  - `Stats` (interface): interface Stats<TStat extends string> — ⚠ undocumented
608
+ - `StatsSnapshot` (interface): interface StatsSnapshot<TStat extends string> — Complete plain-data state for a {@link Stats} runtime.
572
609
  - `createStats` (function): function createStats<TStat extends string>(base: Record<TStat, number>, options?: CreateStatsOptions): Stats<TStat> — A stat block whose base values take stacking, timed buffs and debuffs, resolving the modified value on read.
573
610
 
574
611
  ## @jgengine/core/stats/statPool
@@ -120,6 +120,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
120
120
 
121
121
  - `createEventMeter` (function) · `import { createEventMeter } from "@jgengine/core/combat"`
122
122
 
123
+ ## fire-cadence — a serializable minimum-interval rate gate for repeated actions (weapon fire, ability spam)
124
+
125
+ - `createFireCadence` (function) · `import { createFireCadence } from "@jgengine/core/combat"`
126
+
123
127
  ## immunity-window — reject damage during a per-target invulnerability window installed by transitions
124
128
 
125
129
  - `createImmunityWindow` (function) · `import { createImmunityWindow } from "@jgengine/core/combat"`
@@ -204,10 +208,22 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
204
208
 
205
209
  - `resolveStatusApplication` (function) · `import { resolveStatusApplication } from "@jgengine/core/combat"`
206
210
 
207
- ## status-effects — apply and tick timed status effects and buffs on entities
211
+ ## status-effects — commit direct or area effects through caller-owned stat pools with ordered spillover and lethal results
208
212
 
209
213
  - `createEffectSystem` (function) · `import { createEffectSystem } from "@jgengine/core/combat"`
210
214
 
211
215
  ## threshold-crossings — emit ordered crossing events as a numeric source passes named thresholds with hysteresis and once/repeat policy
212
216
 
213
217
  - `createThresholdTracker` (function) · `import { createThresholdTracker } from "@jgengine/core/combat"`
218
+
219
+ ## vfx-preset-resolve — turn a named attack flavor into concrete vfx kind/color/duration with per-field overrides
220
+
221
+ - `resolveVfxPreset` (function) · `import { resolveVfxPreset } from "@jgengine/core/combat"`
222
+
223
+ ## vfx-presets — render a named attack visual (arrow, fireball, lightning, web, slash, shield, heal, explosion) in one call
224
+
225
+ - `vfxPresets` (const) · `import { vfxPresets } from "@jgengine/core/combat"`
226
+
227
+ ## weapon-runtime — compose cadence, magazine, a caller-owned raycast, and portable damage resolution into a fire controller
228
+
229
+ - `createWeaponRuntime` (function) · `import { createWeaponRuntime } from "@jgengine/core/combat"`