@jgengine/core 0.25.0 → 0.27.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 (133) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/dist/ai/rvo.d.ts +106 -0
  3. package/dist/ai/rvo.js +581 -0
  4. package/dist/ai/utilityBrain.d.ts +173 -0
  5. package/dist/ai/utilityBrain.js +277 -0
  6. package/dist/combat/cover.d.ts +169 -0
  7. package/dist/combat/cover.js +264 -0
  8. package/dist/combat/damageRuntime.d.ts +150 -0
  9. package/dist/combat/damageRuntime.js +208 -0
  10. package/dist/combat/damageTable.d.ts +116 -0
  11. package/dist/combat/damageTable.js +165 -0
  12. package/dist/combat/hitZones.d.ts +70 -0
  13. package/dist/combat/hitZones.js +77 -0
  14. package/dist/combat/magazine.d.ts +8 -0
  15. package/dist/combat/magazine.js +20 -2
  16. package/dist/combat/squadCombat.d.ts +167 -0
  17. package/dist/combat/squadCombat.js +557 -0
  18. package/dist/combat/squadTactics.d.ts +73 -0
  19. package/dist/combat/squadTactics.js +49 -0
  20. package/dist/combat/statusRuntime.d.ts +142 -0
  21. package/dist/combat/statusRuntime.js +236 -0
  22. package/dist/combat/weaponDefinition.d.ts +106 -0
  23. package/dist/combat/weaponDefinition.js +108 -0
  24. package/dist/combat/weaponSystem.d.ts +189 -0
  25. package/dist/combat/weaponSystem.js +469 -0
  26. package/dist/combat.d.ts +9 -0
  27. package/dist/combat.js +9 -0
  28. package/dist/devtools/devtools.d.ts +4 -2
  29. package/dist/devtools/devtools.js +2 -1
  30. package/dist/devtools/discover.d.ts +2 -1
  31. package/dist/devtools/discover.js +38 -0
  32. package/dist/devtools/frame.d.ts +0 -2
  33. package/dist/devtools/frame.js +1 -2
  34. package/dist/devtools/logBus.d.ts +58 -0
  35. package/dist/devtools/logBus.js +79 -0
  36. package/dist/devtools/logs.d.ts +6 -3
  37. package/dist/devtools/logs.js +17 -13
  38. package/dist/devtools/report.d.ts +120 -0
  39. package/dist/devtools/report.js +49 -0
  40. package/dist/devtools/reportEndpoint.d.ts +17 -0
  41. package/dist/devtools/reportEndpoint.js +7 -0
  42. package/dist/devtools/types.d.ts +48 -11
  43. package/dist/editor/document.js +210 -0
  44. package/dist/editor/environment.d.ts +48 -1
  45. package/dist/editor/environment.js +161 -0
  46. package/dist/editor/index.d.ts +6 -2
  47. package/dist/editor/index.js +1 -1
  48. package/dist/editor/types.d.ts +54 -0
  49. package/dist/game/playableGame.d.ts +19 -2
  50. package/dist/gameplay.d.ts +9 -0
  51. package/dist/gameplay.js +9 -0
  52. package/dist/inventory/carryLoad.d.ts +48 -0
  53. package/dist/inventory/carryLoad.js +44 -0
  54. package/dist/inventory/equipment.d.ts +95 -0
  55. package/dist/inventory/equipment.js +169 -0
  56. package/dist/inventory/inventoryModel.d.ts +15 -6
  57. package/dist/inventory/inventoryModel.js +54 -5
  58. package/dist/inventory/shapedGrid.d.ts +26 -0
  59. package/dist/inventory/shapedGrid.js +47 -0
  60. package/dist/loot/lootGen.d.ts +186 -0
  61. package/dist/loot/lootGen.js +437 -0
  62. package/dist/loot/lootWeapon.d.ts +35 -0
  63. package/dist/loot/lootWeapon.js +70 -0
  64. package/dist/meta/changelog.d.ts +1 -1
  65. package/dist/meta/changelog.js +92 -1
  66. package/dist/nav/laneGraph.d.ts +178 -0
  67. package/dist/nav/laneGraph.js +671 -0
  68. package/dist/nav/navCrowd.d.ts +138 -0
  69. package/dist/nav/navCrowd.js +412 -0
  70. package/dist/nav/navRuntime.d.ts +108 -0
  71. package/dist/nav/navRuntime.js +256 -0
  72. package/dist/nav/traffic.d.ts +223 -0
  73. package/dist/nav/traffic.js +919 -0
  74. package/dist/physics/kinematicVehicle.d.ts +36 -0
  75. package/dist/physics/kinematicVehicle.js +15 -1
  76. package/dist/physics/vehicleDamage.d.ts +136 -0
  77. package/dist/physics/vehicleDamage.js +214 -0
  78. package/dist/physics/vehicleParts.d.ts +62 -0
  79. package/dist/physics/vehicleParts.js +65 -0
  80. package/dist/progression/gearScore.d.ts +56 -0
  81. package/dist/progression/gearScore.js +59 -0
  82. package/dist/progression/skillTree.d.ts +129 -0
  83. package/dist/progression/skillTree.js +265 -0
  84. package/dist/progression/xpCurve.d.ts +60 -0
  85. package/dist/progression/xpCurve.js +141 -0
  86. package/dist/render/colorPipeline.d.ts +53 -0
  87. package/dist/render/colorPipeline.js +79 -0
  88. package/dist/render/lightProbes.d.ts +97 -0
  89. package/dist/render/lightProbes.js +221 -0
  90. package/dist/render/lookPreset.js +4 -4
  91. package/dist/render/physicalSky.d.ts +39 -0
  92. package/dist/render/physicalSky.js +89 -0
  93. package/dist/render/postProcessing.d.ts +40 -8
  94. package/dist/render/postProcessing.js +1 -1
  95. package/dist/render/skyOwnedLighting.d.ts +31 -0
  96. package/dist/render/skyOwnedLighting.js +46 -0
  97. package/dist/scene/builtinSceneKinds.js +2 -0
  98. package/dist/sensor/perception.d.ts +66 -5
  99. package/dist/sensor/perception.js +167 -42
  100. package/dist/sim/nearAgents.d.ts +68 -0
  101. package/dist/sim/nearAgents.js +152 -0
  102. package/dist/sim/needs.d.ts +60 -0
  103. package/dist/sim/needs.js +108 -0
  104. package/dist/sim/offScreen.d.ts +181 -0
  105. package/dist/sim/offScreen.js +450 -0
  106. package/dist/sim/routine.d.ts +79 -0
  107. package/dist/sim/routine.js +137 -0
  108. package/dist/visibility/visibilitySystem.js +21 -0
  109. package/dist/world/decalKind.d.ts +48 -0
  110. package/dist/world/decalKind.js +86 -0
  111. package/dist/world/features.d.ts +11 -1
  112. package/dist/world/features.js +1 -0
  113. package/dist/world/grassKind.d.ts +5 -0
  114. package/dist/world/grassKind.js +3 -0
  115. package/dist/world/terraform.d.ts +8 -0
  116. package/dist/world.d.ts +10 -1
  117. package/dist/world.js +9 -0
  118. package/package.json +1 -1
  119. package/skills/jgengine/api.md +59 -6
  120. package/skills/jgengine/capabilities.md +4 -0
  121. package/skills/jgengine-combat/SKILL.md +19 -0
  122. package/skills/jgengine-combat/api.md +246 -0
  123. package/skills/jgengine-combat/capabilities.md +112 -0
  124. package/skills/jgengine-combat/reference.md +32 -0
  125. package/skills/jgengine-gameplay/SKILL.md +39 -0
  126. package/skills/jgengine-gameplay/api.md +221 -7
  127. package/skills/jgengine-gameplay/capabilities.md +64 -3
  128. package/skills/jgengine-gameplay/reference-systems.md +118 -0
  129. package/skills/jgengine-world/SKILL.md +7 -1
  130. package/skills/jgengine-world/api.md +222 -8
  131. package/skills/jgengine-world/capabilities.md +42 -2
  132. package/skills/jgengine-world/recipes/npc-senses.md +8 -2
  133. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
@@ -12,6 +12,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
12
12
 
13
13
  - `createAntiOneShotPolicy` (function) · `import { createAntiOneShotPolicy } from "@jgengine/core/combat"`
14
14
 
15
+ ## armor-bypass — ask whether a damage type ignores armor points
16
+
17
+ - `typeBypassesArmor` (function) · `import { typeBypassesArmor } from "@jgengine/core/combat"`
18
+
19
+ ## armor-mitigation — read the fraction of a hit armor removes at a given armor value
20
+
21
+ - `armorMitigation` (function) · `import { armorMitigation } from "@jgengine/core/combat"`
22
+
15
23
  ## capture-bonus-levels — convert wild level and capture effectiveness into bonus levels
16
24
 
17
25
  - `captureBonusLevels` (function) · `import { captureBonusLevels } from "@jgengine/core/stats/spawnLevelStats"`
@@ -40,6 +48,26 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
40
48
 
41
49
  - `createComboPoints` (function) · `import { createComboPoints } from "@jgengine/core/combat"`
42
50
 
51
+ ## cover-field — authored-geometry cover with a bounded nearest query and rebuild on edit
52
+
53
+ - `createCoverField` (function) · `import { createCoverField } from "@jgengine/core/combat"`
54
+
55
+ ## cover-points-from-solids — derive stand-able cover positions from authored solid boxes
56
+
57
+ - `coverPointsFromSolids` (function) · `import { coverPointsFromSolids } from "@jgengine/core/combat"`
58
+
59
+ ## cover-points-near — bounded nearest-cover query
60
+
61
+ - `coverPointsNear` (function) · `import { coverPointsNear } from "@jgengine/core/combat"`
62
+
63
+ ## cover-score — threat-driven cover point scoring
64
+
65
+ - `scoreCoverPoint` (function) · `import { scoreCoverPoint } from "@jgengine/core/combat"`
66
+
67
+ ## cover-select — select cover by threat, role range and flank angle
68
+
69
+ - `selectCover` (function) · `import { selectCover } from "@jgengine/core/combat"`
70
+
43
71
  ## damage-clamp — cap incoming damage per hit at a configured maximum
44
72
 
45
73
  - `createDamageClamp` (function) · `import { createDamageClamp } from "@jgengine/core/combat"`
@@ -60,6 +88,42 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
60
88
 
61
89
  - `createDamagePipeline` (function) · `import { createDamagePipeline } from "@jgengine/core/combat"`
62
90
 
91
+ ## damage-runtime — resolve hits through a damage table, hit zones, and armor, and tick data-defined statuses with declared stacking
92
+
93
+ - `createDamageRuntime` (function) · `import { createDamageRuntime } from "@jgengine/core/combat"`
94
+
95
+ ## damage-table — score a data-defined damage type against a resistance class, with the outcome readable as a table
96
+
97
+ - `typeMultiplier` (function) · `import { typeMultiplier } from "@jgengine/core/combat"`
98
+
99
+ ## damage-table-format — render damage-table rows as fixed-width lines for a probe, a CLI dump, or a test
100
+
101
+ - `formatDamageTable` (function) · `import { formatDamageTable } from "@jgengine/core/combat"`
102
+
103
+ ## damage-table-read — read a damage table as rows of type × resistance × armor mitigation, with final damage per base
104
+
105
+ - `readDamageTable` (function) · `import { readDamageTable } from "@jgengine/core/combat"`
106
+
107
+ ## damage-table-resolve — resolve a damage type against a resistance class and armor into a multiplier, mitigation, and final damage
108
+
109
+ - `resolveTableDamage` (function) · `import { resolveTableDamage } from "@jgengine/core/combat"`
110
+
111
+ ## damage-table-validate — reject a damage table with duplicate types or non-finite multipliers before combat runs
112
+
113
+ - `assertDamageTable` (function) · `import { assertDamageTable } from "@jgengine/core/combat"`
114
+
115
+ ## damage-target-unknown — reject a hit on a target with no registered resistance or armor profile
116
+
117
+ - `UnknownTargetProfileError` (class) · `import { UnknownTargetProfileError } from "@jgengine/core/combat"`
118
+
119
+ ## damage-type-duplicate — reject a damage table that defines one type twice
120
+
121
+ - `DuplicateDamageTypeError` (class) · `import { DuplicateDamageTypeError } from "@jgengine/core/combat"`
122
+
123
+ ## damage-type-unknown — reject a hit whose damage type the table does not define instead of dealing 1×
124
+
125
+ - `UnknownDamageTypeError` (class) · `import { UnknownDamageTypeError } from "@jgengine/core/combat"`
126
+
63
127
  ## death-system — resolve entity death and its on-death consequences
64
128
 
65
129
  - `createDeathSystem` (function) · `import { createDeathSystem } from "@jgengine/core/combat"`
@@ -132,6 +196,22 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
132
196
 
133
197
  - `createAbilityKit` (function) · `import { createAbilityKit } from "@jgengine/core/combat"`
134
198
 
199
+ ## hit-zone — resolve a hit zone into one damage multiplier, crit by zone with an injected RNG
200
+
201
+ - `resolveHitZone` (function) · `import { resolveHitZone } from "@jgengine/core/combat"`
202
+
203
+ ## hit-zone-multiplier — quote a zone's multiplier for a known crit outcome without rolling
204
+
205
+ - `hitZoneMultiplier` (function) · `import { hitZoneMultiplier } from "@jgengine/core/combat"`
206
+
207
+ ## hit-zone-unknown — reject a hit zone table whose named default does not exist
208
+
209
+ - `UnknownHitZoneError` (class) · `import { UnknownHitZoneError } from "@jgengine/core/combat"`
210
+
211
+ ## hit-zone-validate — reject a hit-zone table with duplicate zones or a missing default
212
+
213
+ - `assertHitZoneTable` (function) · `import { assertHitZoneTable } from "@jgengine/core/combat"`
214
+
135
215
  ## hitscan-shot — aim a hitscan shot (shoot a gun): resolve origin and direction from the eye or muzzle for a raycast
136
216
 
137
217
  - `resolveShot` (function) · `import { resolveShot } from "@jgengine/core/combat"`
@@ -199,6 +279,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
199
279
 
200
280
  - `resolveSpawnStats` (function) · `import { resolveSpawnStats } from "@jgengine/core/stats/spawnLevelStats"`
201
281
 
282
+ ## squad-combat — threat-driven cover, flanking, suppression, retreat and role-driven fire discipline
283
+
284
+ - `createSquadCombat` (function) · `import { createSquadCombat } from "@jgengine/core/combat"`
285
+
286
+ ## squad-roles — deterministic role assignment over a caller-authored composition
287
+
288
+ - `assignSquadRoles` (function) · `import { assignSquadRoles } from "@jgengine/core/combat"`
289
+
202
290
  ## stagger-meter — accumulate an ailment buildup (bleed, freeze) until it procs
203
291
 
204
292
  - `createBuildupMeter` (function) · `import { createBuildupMeter } from "@jgengine/core/combat"`
@@ -236,6 +324,18 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
236
324
 
237
325
  - `createEffectSystem` (function) · `import { createEffectSystem } from "@jgengine/core/combat"`
238
326
 
327
+ ## status-runtime — tick data-defined statuses (burn, shock, slow, corrode) with declared stacking rules and per-tick effects
328
+
329
+ - `createStatusRuntime` (function) · `import { createStatusRuntime } from "@jgengine/core/combat"`
330
+
331
+ ## status-unknown — reject a status id the runtime has no definition for
332
+
333
+ - `UnknownStatusError` (class) · `import { UnknownStatusError } from "@jgengine/core/combat"`
334
+
335
+ ## threats-from-memory — turn perception memory into a bounded, ranked threat list
336
+
337
+ - `threatsFromMemory` (function) · `import { threatsFromMemory } from "@jgengine/core/combat"`
338
+
239
339
  ## threshold-crossings — emit ordered crossing events as a numeric source passes named thresholds with hysteresis and once/repeat policy
240
340
 
241
341
  - `createThresholdTracker` (function) · `import { createThresholdTracker } from "@jgengine/core/combat"`
@@ -248,6 +348,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
248
348
 
249
349
  - `vfxPresets` (const) · `import { vfxPresets } from "@jgengine/core/combat"`
250
350
 
351
+ ## weapon-cadence — convert a rounds-per-minute weapon rate into the interval a fire gate takes
352
+
353
+ - `weaponIntervalMs` (function) · `import { weaponIntervalMs } from "@jgengine/core/combat"`
354
+
355
+ ## weapon-definition-validate — reject a malformed weapon definition at construction with the field named
356
+
357
+ - `assertWeaponDefinition` (function) · `import { assertWeaponDefinition } from "@jgengine/core/combat"`
358
+
251
359
  ## weapon-feedback — turn weapon shots, recoil kick and impacts into per-tick feedback-mixer signals for camera shake, audio and rumble
252
360
 
253
361
  - `createWeaponFeedbackSignals` (function) · `import { createWeaponFeedbackSignals } from "@jgengine/core/combat/weaponFeedback"`
@@ -267,3 +375,7 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
267
375
  ## weapon-runtime — compose cadence, magazine, a caller-owned raycast, and portable damage resolution into a fire controller
268
376
 
269
377
  - `createWeaponRuntime` (function) · `import { createWeaponRuntime } from "@jgengine/core/combat"`
378
+
379
+ ## weapon-system — run a data-defined weapon: fire modes, recoil and bloom, hitscan or ballistic projectiles, interrupted reloads and readable ammo, all resolving through the damage runtime
380
+
381
+ - `createWeaponSystem` (function) · `import { createWeaponSystem } from "@jgengine/core/combat"`
@@ -141,6 +141,27 @@ Genre systems layered over the same effects/projectiles/targeting/loot primitive
141
141
  - **Resistance matrix** (`combat/resistance`) — a damage-category × target-property table distinct from `combat/attackTags` (those are defense tags). `resolveResistance(matrix, category, targetProperties)` → `{ verdict: "immune" | "resist" | "normal" | "vulnerable", multiplier, immune }`; `resistanceScale` returns just the multiplier (default `immune:0, resist:0.5, normal:1, vulnerable:2`, overridable). Immune on any property wins; resists stack multiplicatively. Sits over the `receive` gate — the handler multiplies its effect `amount` by the scale (Bloons lead/camo immunities, elemental RPG weaknesses).
142
142
  - **Run draft** (`game/runDraft`, built on `world/scatterItems` `pickWeighted` + `stats/statModifiers`) — `createRunModifierStack(offers)` accumulates stacking picks (`add`, `count`, `atMax`, `total(stat)` aggregating adds × exponentiated multiplies, `apply(stats)` onto a `createStats` source). `createRunDraft({ offers, rng? })` adds the pause-flow: `present(n)` draws N distinct weighted offers (excluding maxed-out ones), `choose(id)` applies to the stack. Pause/resume is game-side (`ctx.time.pause()` before `present`, `play()` after `choose`). Vampire Survivors level-up picks, Hades boons, Risk of Rain stacking items.
143
143
 
144
+ ## Damage model, armor, crits, and statuses
145
+
146
+ `createDamageRuntime` (`combat/damageRuntime`) is the stateful in-process composition of the table, the zones, the armor curve, and carried statuses. A number reads as `base × zone multiplier × type multiplier × (1 − armor mitigation)`; `hit()` returns every stage, `tick()` ticks statuses through the same table and armor, `timeToKill()` quotes a fight, and `table()` reads the whole chart. `snapshot()`/`restore()` carry registered targets and carried statuses; `retune()` swaps table, zones, or definitions without dropping either. For multiplayer authority, keep the pure stages (`resolveMatchup`, `resolveReceivedDamage`, `resolveStatusApplication`) and replicate their results instead.
147
+
148
+ - **Type × resistance table** (`combat/damageTable`) — `{ types: [{ id, vs: { armor: 2, flesh: 0.5 }, bypassArmor? }], armor: { halfValue, maxMitigation }, defaultMultiplier? }`. `typeMultiplier` reads one pair; `resolveTableDamage` folds type, armor, and base into a row; `readDamageTable` returns every row at given armor values and `formatDamageTable` prints them. Armor uses `armor / (armor + halfValue)` — armor equal to `halfValue` halves the hit — so a designer reads the number rather than guessing a percentage per point. Unknown types throw; unknown resistance classes use the default.
149
+ - **Hit zones and crits** (`combat/hitZones`) — `{ zones: [{ id, multiplier?, critChance?, critMultiplier? }], defaultZone, critChance?, critMultiplier? }`. `resolveHitZone(table, zone, rng)` rolls once and returns **one** multiplier: a crit replaces the zone's normal multiplier (head 1.5× normal, 2.5× crit → 2.5×, never 3.75×). `hitZoneMultiplier(table, zone, crit)` quotes the same number without a roll, which is what `timeToKill({ crit: true })` uses.
150
+ - **Statuses** (`combat/statusRuntime`) — `createStatusRuntime({ statuses, rng?, applyDamage? })`; each `StatusDef` declares `{ id, stack, durationMs, intervalMs?, magnitude?, effect?, chance? }`. `stack` is the existing `StatusStackPolicy` (`refresh` / `stack` to `max` / `replace` / `ignore`), and it is the only thing that changes stacks: a tick deals `magnitude × stacks`, so two burns at 4 per tick deal 8, never 16. `effect` is `{ kind: "damage", channel }` (routed through `applyDamage`, so resistances and armor apply) or `{ kind: "stat", stat }` (read with `statDelta`, e.g. negative `moveSpeed` for slow or negative `armor` for corrode). Reapplication restarts the tick clock instead of banking a burst; ticks per call are bounded and the remainder carries. `dotField` stays the cheap no-stacking scheduler.
151
+
152
+ ## Weapons as data
153
+
154
+ `createWeaponSystem` (`combat/weaponSystem`) runs one `WeaponDefinition` (`combat/weaponDefinition`): plain JSON with `roundsPerMinute`, `fireMode` (`single` / `auto` / `burst` + `burst.count`), `delivery` (`hitscan` + `maxRange`, or `projectile` + `speed` / `gravity` / `drag` / `lifetime`), optional `magazine`, `handling` (the same recoil bloom and ADS tuning `createWeaponHandling` takes), `damage` (`type`, `base`, optional `statuses`), optional `falloff`, `pellets`, `maxInFlight` and `autoReload`. Nothing in it is a function, so a generator rolls it and a save stores it; `assertWeaponDefinition` rejects a bad roll at construction with the field named.
155
+
156
+ - **Loop.** `setAim(yaw, pitch)` from look input, `setTrigger(down)`, `tick(dt, stance)` → `{ shots, impacts }`. A press survives to the next tick that can use it; a tap does not repeat. Recoil is applied to the aim before the shot leaves, so `record.directions` and the world query see the kicked vector, and `aim()` reports it with recovery folded in.
157
+ - **Spread and bounds.** Each pellet samples the cone uniformly over solid angle from the injected `rng`; zero spread draws nothing. One tick fires at most `maxShotsPerTick` (default 4, clamp 16) catch-up shots and keeps the remaining budget, so a stalled frame cannot turn into a long burst.
158
+ - **Delivery.** A hitscan calls your `raycast(from, direction, maxRange)` on the firing tick. A projectile is stepped with semi-implicit Euler (drag, then gravity), swept as one segment per tick so it cannot tunnel, and resolved when `sweep(from, to)` reports a contact. Both build the same `DamageRuntimeHitInput` for `createDamageRuntime().hit()`, so zone, type, armor, status rolls and provenance are identical; only the distance differs. Falloff scales `base` by the distance the pellet actually travelled.
159
+ - **Live pellets are capped** by `maxInFlight` (default 32, max 256); excess pellets of a shot are counted in `stats().pelletsDropped` rather than queued.
160
+ - **Reload states.** `reload()` starts or continues; `interruptReload()` suspends through `Magazine.pauseReload` keeping progress; `resumeReload()` continues from it; `cancelReload()` discards. `ammo()` returns loaded / capacity / reserve / state / reload fraction / burst remaining / cadence fraction, and `stats()` carries shots, rounds spent, pulls, reloads, drops and damage dealt — the whole ammo economy is readable without touching internals.
161
+ - **State.** `snapshot()` / `restore()` carry aim, handling, magazine, trigger, burst, pellets and counters as plain data; `retune(definition)` swaps rate, feel, damage and capacity without losing ammo or aim (a shrinking magazine discards rounds above the new capacity); a `devtoolsName` registers a one-line probe.
162
+ - **When to use which.** `createWeaponRuntime` (`combat/weaponFire`) is the minimal plumbing for a game that already owns its fire loop and raycaster; `createWeaponSystem` is the definition-driven version with fire modes, ballistics, reload states and ammo accounting. Both leave models, audio, muzzle flash, camera and input to the game; feel numbers come from `measureWeapon` and presentation from `weaponPresentation`.
163
+ - **Evidence** (`combat/weaponSystem.test.ts`): the test fixture at 600 rpm with a 30-round magazine empties in 2.9 s, climbs 17.0° pitch and 3.1° yaw over the magazine, is back to half climb in 0.37 s and within 5% in 0.6 s; bloom runs 2.0 → 8.0 → 12.0 mrad (cap) by the tenth shot and settles to base 0.63 s after the last shot. A hitscan and a projectile contact at the same distance produce the same 20 damage (30 on a 1.5x head zone), and one second of held fire cannot exceed four shots.
164
+
144
165
  ## Encounter sequencing
145
166
 
146
167
  `combat/encounterSequence` is the genre-agnostic hierarchical sequencer behind arenas, raids, tutorials, races, defense phases, and scripted objectives — a serializable state machine over caller-defined phase `data` with **no built-in wave/boss concept**. State is plain JSON (the working tree, per-node runtime, active path, elapsed) so a mid-encounter save round-trips; the predicate/spawn registries live in the config you re-supply alongside it, the way `advanceSpawnDirector` pairs config with state.
@@ -149,3 +170,14 @@ Genre systems layered over the same effects/projectiles/targeting/loot primitive
149
170
  - **Completion predicates** — `completion: { kind, params }` names a registered evaluator; `BUILTIN_COMPLETION_PREDICATES` ships `immediate`, `manual`/`flag`, `timer` (phase seconds), `cleared` (tagged count drained), `metric` (live threshold), `event` (cumulative count), and `quorum` (N of nested predicates). Signals (`counts`/`metrics`/`events`/`flags`) come in through `ctx.signals`; evaluation is deterministic. Register your own via `config.predicates`.
150
171
  - **Spawn providers** — spawning is one optional node adapter: `spawn: { kind, params }` invoked on enter. `BUILTIN_SPAWN_PROVIDERS.points` reads a named authored scene point list from `ctx.spawnPoints` (coordinates stay in the scene, referenced by key — never embedded in the sequence); `list` echoes inline requests. Register catalog/director-backed providers via `config.spawnProviders`.
151
172
  - **Injection & scripted control** — `injectPhase(state, phase, { after | before | childOf })` inserts a phase or branch at runtime (e.g. a boss after the final wave) into the serializable tree without disturbing active/completed nodes; `forceCompletePhase` completes the active phase regardless of its predicate for scripted skips and failure/retry, restoring the authored predicate afterward.
173
+
174
+ ## Enemy squad AI
175
+
176
+ `createSquadCombat` (`combat/squadCombat`) is the combat half of an NPC squad: it reads the perception memory the game already keeps (`createPerception`, B1.4) and moves through the nav crowd it already owns (`createNavCrowd`, B1.2). It never sees a global player position — a member reacts to what its own memory or a squadmate's shared sighting says, so losing contact means losing the fight.
177
+
178
+ - **Cover comes from geometry.** `createCoverField({ solids })` turns authored solid boxes (a `WorldSolid` list drops in) into stand points on every vertical face, clear of the corners and filtered by `walkable: (p) => nav.findPolygon(p) !== null`; `rebuild(solids)` re-derives after an editor edit. A game with its own spatial index supplies the narrower `CoverSource` instead: `near(position, radius, limit)`, bounded per call. Never author a coordinate list for cover.
179
+ - **Selection is by threat.** `selectCover(points, query)` scores each point on `hidden` (confidence-weighted share of remembered threats whose line of sight it blocks, including a body-sized footprint), the role's `preferredRange`, its `flankAngle` around the threat, distance, a squadmate's claim, and whether the walk to it is a detour. A point that hides from one of two shooters is not cover; a memory below `minConfidence` stops steering.
180
+ - **Roles are content.** `assignSquadRoles(members, { roles })` maps names and counts to members deterministically; a role carries `preferredRange`, `flankAngle`, `retreatBelow`, `holdCover`, `discipline` (hold/peek/peek distance/fire interval) and `cover` weights. The engine has no role list, and `"assault"`, `"support"`, `"flanker"` exist only in the game's composition.
181
+ - **Behavior over a tick.** A member picks cover, walks to it, leans out for `peekSeconds` to shoot (`peekSpot` steps around the corner it hides behind), then drops back. `underFire(memberId, amount)` raises suppression, which pins the member to its cover and cancels its peeks until the meter decays. Below a role's `retreatBelow`, the member falls back to the point furthest from the threat. After `relocateAfterSeconds` of continuous exposure it stops trusting its point and moves, which is what breaks a long spell in the open.
182
+ - **Wiring.** `engage(memberId, targetId, distance)` is the paced shot: resolve damage in your own pipeline, call `underFire` on the victim, push a `damage` stimulus at the shooter's position so a member learns about a shooter it cannot see. `healthOf` reads authoritative health, `teamOf` decides who is hostile, `blocked` is your collision layer. `snapshot()`/`restore()` carry member decisions, the crowd and the tuning, so a save mid-fight resumes the same fight.
183
+ - **Evidence.** `combat/squadCombat.soak.test.ts` is the reference run: 2v4 in an authored arena, 90 s, exposure measured every tick against the walls from live positions. Worst exposure 2.33 s against a 3 s bar, 0 of 38 exposure spells over 3 s, p50 0.05 ms per tick. Copy its shape (authored boxes, nav grid with the same holes, perception observing live positions) rather than trusting a flag.
@@ -38,6 +38,37 @@ Pins consume no choice RNG and must satisfy the same pool and constraints;
38
38
  incompatible pins backtrack earlier unpinned choices or return `unsatisfiable`
39
39
  within the configured attempt and pick budgets.
40
40
 
41
+ Roll whole named gear from an authored catalog with `rollLoot`
42
+ (`loot/lootGen`): bases with mount slots, parts, affix pools, weighted rarity
43
+ tiers, manufacturer stat personalities and a level curve, all caller data.
44
+ `validateLootItem` names each defect, `compareLootItems` returns per-stat deltas
45
+ and a weighted score, and `lootWeaponDefinition` (`loot/lootWeapon`) projects a
46
+ rolled item onto a `WeaponDefinition` for `createWeaponSystem`. `createLootRoller`
47
+ counts drops per rarity for a debug probe and snapshots its counters. Sample
48
+ catalog, distribution check and generated-weapon proof are in
49
+ [reference-systems.md](reference-systems.md#procedural-gear).
50
+
51
+ Resolve an authored XP curve with `defineXpCurve` (`progression/xpCurve`): one growth rule
52
+ (`flat`, `linear`, `power`, `geometric`, `table`), a `maxLevel` cap and a stated `rule` string;
53
+ `table()` and `totalXpFor()` read the same data, and `track()` hands those thresholds to
54
+ `leveling`. Spend a character's points in `createSkillTree` (`progression/skillTree`): nodes carry
55
+ a point `cost`, `requires`, `excludes`, `passives` and `actives`, `synergies` pay off two or more
56
+ ranked nodes together, `respec()` refunds exactly the points spent, and `resolved()` returns the
57
+ `StatModifierSet` plus ability-slot-shaped actives to feed `createStats` / `createAbilityKit`.
58
+ Score a whole loadout with `gearScore` (`progression/gearScore`) on the same weight policy as
59
+ `compareLootItems`. [reference-systems.md](reference-systems.md#progression-and-gear-score) shows
60
+ the data shapes and a two-build comparison.
61
+
62
+ Wear gear with `createEquipment` (`inventory/equipment`): slot defs own accepted kinds and the slots
63
+ they block, item defs own the slots an item may occupy, a filled slot swaps and hands back the
64
+ replaced item, and `state()` / `restore()` round-trip the worn set. Weigh a character with
65
+ `characterLoad` (`inventory/carryLoad`), which turns carried inventories plus worn gear into the
66
+ shared encumbrance bands (`moveMultiplier`, sprint/jump gates, `remaining`), so `canCarry` gates a
67
+ pickup. Declare a stash as its own inventory (`tier: "banked"`) and move items with
68
+ `InventorySet.move`; `snapshot()` / `restore()` save both as detached data. The bag and stash render
69
+ through `<InventoryGrid>` with `<EquipmentSlots>` above it. See
70
+ [inventory, equipment and weight](reference-systems.md#inventory-equipment-and-weight).
71
+
41
72
  Validate generated quest catalogs with `validateQuestCatalog` (`game/questCatalog`).
42
73
  Follow the [quest authoring recipe](recipes/quest-authoring.md) for bounded batches,
43
74
  canonical facts, external unlock declarations, and precise repair diagnostics.
@@ -63,6 +94,14 @@ bounded batches, repair located issues and require explicit semantic approval.
63
94
 
64
95
  Prefer `defineSystem` and `defineGame({ systems })` for scheduled capabilities. Keep boot/join in the game loop only when it is truly lifecycle glue. System ordering, frequency, and serialization are explicit; avoid a giant per-frame callback.
65
96
 
97
+ ## Days and off-screen agents
98
+
99
+ `resolveRoutine` (`sim/routine`) resolves an authored day (home, work, meal) against the world clock: injected travel time makes each stop depart early enough to arrive on time, and an arrival that runs late slips instead of teleporting. The timeline is pure, so a save, a forecast and a live agent read the same day.
100
+
101
+ `createOffScreenWorld` (`sim/offScreen`) keeps every resident either near or cheap. Near residents spawn into your own simulation through `NearAgents`; far ones are a position, a routine phase and a needs record that only move when the clock crosses a routine boundary — no navigation, no needs tick, no utility decision. The distance radius, optional cost model and per-tick switch budget are game config. Crossing back in spawns the resident where the cheap state says it is, with its schedule progress and needs intact, so it cannot pop in at the origin.
102
+
103
+ `createNearAgents` (`sim/nearAgents`) is the default near half: a nav crowd with a need set and a utility brain per agent, counting its decisions for tests and probes. Give it the same need definitions the off-screen world decays, or the two halves disagree at the boundary.
104
+
66
105
  For staffed production, use `stationOutputRate` (`work/staffedStation`) to resolve
67
106
  caller-owned worker stats and efficiency, then feed the rate to `tickProduction`.
68
107
  Keep staffing and production progress in the same serializable store; the