@jgengine/core 0.30.0 → 0.32.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.
@@ -1,7 +1,51 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export const VERSION = "0.30.0";
2
+ export const VERSION = "0.32.0";
3
3
  /** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
4
4
  export const CHANGELOG = {
5
+ "0.32.0": {
6
+ migrate: [
7
+ "Bump lockstep SDK packages to `^0.32.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.29.0`; `@jgengine/github` is `0.19.0`.",
8
+ ],
9
+ added: [
10
+ "Audio mixing grew a mute that composes with gain: `AudioEngine.setBusMute` / `setMasterMute` keep the player's volume while silencing a bus, `masterLevel()` / `busLevel()` report the composed value, and Settings carries a mute row per bus plus \"Mute all\" (`sound.muted`, `sound.bus.<id>.muted`). `AudioEngine.play` and `playOneShot` hold cues in a bounded 16-slot queue while the browser keeps the context suspended and play them on the first gesture instead of dropping them; `installGestureResume` wires that gesture for both presentation paths.",
11
+ "`AudioEngine.measure()` reads master RMS and peak from a lazily built analyser, and `installAudioProbe` publishes `window.__jgengineAudio` so a headless capture can prove sound with a measured level rather than a screenshot.",
12
+ "`@jgengine/core/audio/feedbackCues` ships seven procedural reactions - `hit`, `hurt`, `kill`, `pickup`, `objective`, `wave`, `deny` - on an `sfx` bus, merged into every game's catalog as `feel.<cue>`. A game sound with that id replaces one; `audio.feedbackCues` replaces or silences them.",
13
+ "`@jgengine/shell/audio/eventFeedback` maps the events the engine already announces to those reactions: `combat.damage` (local player reads as hurt, anything else as a hit), `entity.died`, `worldItem.picked_up`, `quest.completed`, `stat.levelUp` and `command.refused`. Each row carries a sound, a gamepad pulse, camera trauma and a caption; the mapping and reactions are data (`DEFAULT_FEEDBACK_BINDINGS`, `DEFAULT_FEEDBACK_REACTIONS`) a game can replace, cooldowns fold a burst into one impact, reduced motion keeps sound and rumble but drops the camera kick, and the resolved reaction is pooled so handling an event allocates nothing per frame. `attachWaveFeedback(runner, ctx.game.feedback)` announces each new wave.",
14
+ "`ctx.game.feedback.cue(cue, caption?)` announces a moment no engine event covers and emits `feedback.cue`.",
15
+ "`@jgengine/shell/audio/captions` and `CaptionOverlay` render a bounded, deduping, self-expiring caption line under the accessibility captions setting; `FeedbackHost` mounts the whole layer - feedback mapping, gesture resume, audio probe and captions - for both the 3D and HUD presentation paths.",
16
+ ],
17
+ changed: [
18
+ "The CI gate runs nine test shards instead of ten, and a `types` job whose stages overlap, so a docs-only pull request fits the account's 20-job runner cap with room for another run beside it. `scripts/test-shard.ts` owns which files each shard runs and what it prepares first; `bun scripts/type-shard.ts` runs the `check-types` plan two stages at a time and then the tsc fan-out alone.",
19
+ ],
20
+ removed: [],
21
+ },
22
+ "0.31.0": {
23
+ migrate: [
24
+ "Bump lockstep SDK packages to `^0.31.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.28.0`; `@jgengine/github` is `0.18.0`.",
25
+ ],
26
+ added: [
27
+ "`createAgent` (`@jgengine/core/agents/agent`) runs one resident's whole loop on the world clock: needs decay through `sim/needs`, a utility brain picks among the targets the game offers, an authored day plan says where the agent belongs, and the agent falls back to a declared target when its pick disappears. Mood, action and the far flag snapshot and restore with the needs.",
28
+ "`createMoodReader` / `readMood` (`@jgengine/core/agents/mood`) derive a 0-100 mood and a caller-labelled tier from weighted need satisfaction, plus named sources a queue, a litter patch or a cold snap raises and clears.",
29
+ "`createAgentSchedule` (`@jgengine/core/agents/schedule`) resolves an authored day plan through `sim/routine` and adds a work beat: a stop names the places it patrols, so a janitor is an agent with a route rather than a special case. Off-hours behaviour is a stop without a beat.",
30
+ "`createRelationshipBook` (`@jgengine/core/agents/relationships`) keeps undirected familiarity and opinion per pair. A social action moves both and reports the milestones crossed, friends prefer each other's company, and a bonded pair refuses to split. Pair keys come from `relation/keyedValues` and crossings from `relation/thresholds`.",
31
+ "`GameSave.deleteSlot(slot)` and `SaveStore.deleteSlot(slot)` remove one slot's payload and drop it from the slot index; they resolve `true` when a document was removed and `false` for an unknown slot. Deleting the active slot resets the live state to the initial document. `load()` no longer lists a slot that holds nothing, so a deleted slot cannot return.",
32
+ "`HudPanel` takes `lane` (`@jgengine/react`): blocks naming the same anchor and lane stack without overlap, lanes in one anchor sit side by side, and `resolveHudLanes` resolves the rectangles. A moved panel leaves its lane and keeps its authored placement.",
33
+ "`hudBlockOverlaps` and `formatHudOverlapReport` (`@jgengine/react`) name every pair of rendered HUD blocks whose rectangles intersect, with both ids and the viewport; `HudCanvas` warns with that report in a dev build.",
34
+ "`rollDrop` (`@jgengine/core/loot/drop`) rolls one deterministic item from a seed and `compareToEquipped` turns it into per-stat deltas against worn gear with a weighted verdict a HUD can render.",
35
+ "`createRunHaul` (`@jgengine/core/loot/haul`) gates carried loot by weight through the shared encumbrance rules, banks what the persistent stash fits, and discards the run's haul when the game calls `fail`.",
36
+ "`salvageItem` and `createItemLedger` (`@jgengine/core/loot/salvage`) break an item down for the currency its level and rarity say and claim the instance, so a salvaged item cannot be salvaged, equipped or sold again.",
37
+ "`stockVendor` and `createVendorRotation` (`@jgengine/core/loot/vendor`) fill a `ShopStock` with rolled items priced through the economy wallet, with a run-count restock rule the game authors.",
38
+ "`markerProximity` on `createMissionRuntime` picks the plane a marker proximity test compares in: `\"horizontal\"` (default) measures ground distance and allows the actor a `tolerance` (default 8 m) above or below the marker, `\"spatial\"` is the straight 3D check for worlds with stacked platforms. `MissionMarkerPlane`, `MissionMarkerProximity` and `DEFAULT_MARKER_TOLERANCE` are exported with it.",
39
+ ],
40
+ changed: [
41
+ "`evaluatePressure` (`@jgengine/core/ai/director/pressure`) reads pressure as player control: a healthy player further into a run is leaned on harder, a hurt player is eased off, and every effect stays inside the rule's `maxTier`.",
42
+ "Loot bases and affixes take `minLevel`, so `rollLoot` only draws from entries at or below the item level. Loot bases take `mass`; rolled items carry it for carry checks.",
43
+ "`HudCanvas` resolved its host with `canvas.parentElement`, which is `HudThemeRoot`'s `display: contents` element and measures 0x0 — the boundary overlay, the HUD-fit measurement and the new overlap report never ran under the standard HUD root. It now uses the nearest ancestor with a real box.",
44
+ "The HUD boundary check returned early whenever nothing overflowed, so a later report in the same pass could never run.",
45
+ "`reach`, `interact` and the `left-area` fail condition compared a marker in 3D, so a marker authored at `y=0` under rising ground was unreachable. They now read the same plane.",
46
+ ],
47
+ removed: [],
48
+ },
5
49
  "0.30.0": {
6
50
  migrate: [
7
51
  "Bump lockstep SDK packages to `^0.30.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.27.0`; `@jgengine/github` is `0.17.0`.",
@@ -341,6 +341,8 @@ export interface MissionRuntimeConfig {
341
341
  readonly missions: readonly MissionDef[];
342
342
  /** Marker positions resolved from the authored scene, by marker id. See `missionMarkers`. */
343
343
  readonly markers?: Readonly<Record<string, ScenePoint3>>;
344
+ /** How marker proximity is measured for `reach`, `interact` and `left-area`. Default horizontal. */
345
+ readonly markerProximity?: MissionMarkerProximity;
344
346
  /** Actor id used for a `pursue` objective that names no `pursuer`. */
345
347
  readonly defaultPursuer?: string;
346
348
  /** `[0,1)` source for a mission's `rollout`; pass `ctx.rng`. Only read when a mission declares one. */
@@ -378,8 +380,26 @@ export interface MissionRuntime {
378
380
  /** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
379
381
  dispose(): void;
380
382
  }
383
+ /** Which plane a marker proximity test compares in. */
384
+ export type MissionMarkerPlane = "horizontal" | "spatial";
385
+ /**
386
+ * How a positional objective compares an actor to its authored marker.
387
+ *
388
+ * Authored markers sit on a flat plan (`y = 0`) while the terrain around them rises, so a straight
389
+ * 3D test can leave a marker unreachable from the ground below it. `"horizontal"` compares the
390
+ * ground distance and allows the actor up to `tolerance` metres off the marker's height;
391
+ * `"spatial"` is the plain 3D check, for a world whose stacked platforms must stay distinct.
392
+ */
393
+ export interface MissionMarkerProximity {
394
+ /** `"horizontal"` (default) or `"spatial"`. */
395
+ plane?: MissionMarkerPlane;
396
+ /** Horizontal plane only: how far (m) the actor may sit above or below the marker. Default {@link DEFAULT_MARKER_TOLERANCE}. */
397
+ tolerance?: number;
398
+ }
381
399
  /** Default proximity radius (m) for a `reach` / `interact` objective. */
382
400
  export declare const DEFAULT_REACH_RADIUS = 2.5;
401
+ /** Default vertical allowance (m) for a horizontal marker proximity test. */
402
+ export declare const DEFAULT_MARKER_TOLERANCE = 8;
383
403
  /** Default keep-close distance (m) for a `pursue` objective. */
384
404
  export declare const DEFAULT_PURSUE_DISTANCE = 3;
385
405
  /** A pursuit escapes at this multiple of its keep-close distance when none is declared. */
@@ -404,6 +424,10 @@ export declare function applyMissionRewards(rewards: MissionRewards, appliers: M
404
424
  *
405
425
  * Cost per tick is bounded by the active objective's own fail list plus one goal test.
406
426
  *
427
+ * Marker proximity runs on the plane `markerProximity` picks: horizontal by default, so a marker
428
+ * authored on a flat `y = 0` plan still triggers from ground that rises under it; `spatial` for a
429
+ * world whose stacked platforms must stay apart.
430
+ *
407
431
  * @capability mission-runtime missions as a data-driven objective state machine with fail states, timers, branches and rewards
408
432
  */
409
433
  export declare function createMissionRuntime(config: MissionRuntimeConfig): MissionRuntime;
@@ -23,6 +23,8 @@ export function isCounterObjective(kind) {
23
23
  }
24
24
  /** Default proximity radius (m) for a `reach` / `interact` objective. */
25
25
  export const DEFAULT_REACH_RADIUS = 2.5;
26
+ /** Default vertical allowance (m) for a horizontal marker proximity test. */
27
+ export const DEFAULT_MARKER_TOLERANCE = 8;
26
28
  /** Default keep-close distance (m) for a `pursue` objective. */
27
29
  export const DEFAULT_PURSUE_DISTANCE = 3;
28
30
  /** A pursuit escapes at this multiple of its keep-close distance when none is declared. */
@@ -30,6 +32,21 @@ export const DEFAULT_ESCAPE_MULTIPLE = 3;
30
32
  function distance(a, b) {
31
33
  return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
32
34
  }
35
+ /** Whether `from` is inside the marker's reach radius under the configured plane — no allocation, no sqrt. */
36
+ function markerWithin(from, to, radius, plane, tolerance) {
37
+ const dy = from.y - to.y;
38
+ const limit = radius * radius;
39
+ if (plane === "spatial") {
40
+ const dx = from.x - to.x;
41
+ const dz = from.z - to.z;
42
+ return dx * dx + dy * dy + dz * dz <= limit;
43
+ }
44
+ if (Math.abs(dy) > tolerance)
45
+ return false;
46
+ const dx = from.x - to.x;
47
+ const dz = from.z - to.z;
48
+ return dx * dx + dz * dz <= limit;
49
+ }
33
50
  function cloneState(state) {
34
51
  return {
35
52
  ...state,
@@ -110,6 +127,10 @@ export function applyMissionRewards(rewards, appliers) {
110
127
  *
111
128
  * Cost per tick is bounded by the active objective's own fail list plus one goal test.
112
129
  *
130
+ * Marker proximity runs on the plane `markerProximity` picks: horizontal by default, so a marker
131
+ * authored on a flat `y = 0` plan still triggers from ground that rises under it; `spatial` for a
132
+ * world whose stacked platforms must stay apart.
133
+ *
113
134
  * @capability mission-runtime missions as a data-driven objective state machine with fail states, timers, branches and rewards
114
135
  */
115
136
  export function createMissionRuntime(config) {
@@ -117,6 +138,8 @@ export function createMissionRuntime(config) {
117
138
  for (const def of config.missions)
118
139
  defs.set(def.id, def);
119
140
  const markers = config.markers ?? {};
141
+ const markerPlane = config.markerProximity?.plane ?? "horizontal";
142
+ const markerTolerance = Math.max(0, config.markerProximity?.tolerance ?? DEFAULT_MARKER_TOLERANCE);
120
143
  let rng = config.rng;
121
144
  let state = null;
122
145
  let ticks = 0;
@@ -222,7 +245,7 @@ export function createMissionRuntime(config) {
222
245
  const to = objective.marker === undefined ? undefined : markers[objective.marker];
223
246
  if (from === undefined || to === undefined)
224
247
  return false;
225
- return distance(from, to) <= (objective.radius ?? DEFAULT_REACH_RADIUS);
248
+ return markerWithin(from, to, objective.radius ?? DEFAULT_REACH_RADIUS, markerPlane, markerTolerance);
226
249
  }
227
250
  case "interact": {
228
251
  if (objective.actor === undefined || !(input.interact ?? []).includes(objective.actor))
@@ -231,7 +254,8 @@ export function createMissionRuntime(config) {
231
254
  if (to === undefined)
232
255
  return true;
233
256
  const from = positionOf(objective.actor, input);
234
- return from !== undefined && distance(from, to) <= (objective.radius ?? DEFAULT_REACH_RADIUS);
257
+ return from !== undefined
258
+ && markerWithin(from, to, objective.radius ?? DEFAULT_REACH_RADIUS, markerPlane, markerTolerance);
235
259
  }
236
260
  case "survive":
237
261
  return state.objectiveElapsed >= (objective.duration ?? 0);
@@ -306,7 +330,8 @@ export function createMissionRuntime(config) {
306
330
  const actor = condition.actor ?? objective.actor;
307
331
  const from = positionOf(actor, input);
308
332
  const to = condition.marker === undefined ? undefined : markers[condition.marker];
309
- if (from !== undefined && to !== undefined && distance(from, to) > (condition.radius ?? DEFAULT_REACH_RADIUS)) {
333
+ const radius = condition.radius ?? DEFAULT_REACH_RADIUS;
334
+ if (from !== undefined && to !== undefined && !markerWithin(from, to, radius, markerPlane, markerTolerance)) {
310
335
  return { state: condition.to ?? "failed", reason: condition.message ?? `${actor ?? "actor"} left the area` };
311
336
  }
312
337
  continue;
@@ -505,6 +505,9 @@ export function createGameContext(options) {
505
505
  setLoop: (id, params) => events.emit("audio.loopSet", { id, ...params }),
506
506
  stopLoop: (id) => events.emit("audio.loopStop", { id }),
507
507
  },
508
+ feedback: {
509
+ cue: (cue, caption) => events.emit("feedback.cue", caption === undefined ? { cue } : { cue, caption }),
510
+ },
508
511
  feed: {
509
512
  bind: (action) => feed.bind(action, events),
510
513
  push(action, entry) {
@@ -1,5 +1,6 @@
1
1
  import type { AuthoredSimulationRuntime } from "../world/authoredSimulation.js";
2
2
  import type { Territory } from "../world/territory.js";
3
+ import type { FeedbackCue } from "../audio/feedbackCues.js";
3
4
  import type { NoticeFeed } from "../game/notices.js";
4
5
  import type { CurrencyDefinition } from "../economy/currency.js";
5
6
  import type { CardPile, CardPileConfig } from "../cards/cardPile.js";
@@ -436,6 +437,15 @@ export interface GameContextRace {
436
437
  /** Lazily creates (on first call, `config` required) or returns the existing reactive race for `id` — discrete mutations and eventful `update` calls bump `ctx.version()`, so HUDs stop hand-managing `store.set` (#286.2). */
437
438
  state(id: string, config?: RaceStateConfig): RaceState;
438
439
  }
440
+ /**
441
+ * The game-announced feel seam on `ctx.game`: name a moment the engine has no event for and the
442
+ * shell plays its cue's sound, rumble, camera kick and caption. The reaction table lives in the
443
+ * shell, so a game never encodes what a hit or a wave should sound like.
444
+ */
445
+ export interface GameFeedback {
446
+ /** Raise `cue` (`"hit" | "hurt" | "kill" | "pickup" | "objective" | "wave" | "deny"`); `caption` replaces the cue's default caption text. Emits `feedback.cue`. */
447
+ cue(cue: FeedbackCue, caption?: string): void;
448
+ }
439
449
  /** Reachable audio seam on `ctx.game`: `play`, `music`, and `resume` route through the `audio.play`/`audio.music`/`audio.resume` events the shell's audio engine listens on, so game code triggers sound without importing the shell. Retained `loop`/`setLoop`/`stopLoop` add id-keyed loops with live pitch/gain control over `audio.loopStart`/`audio.loopSet`/`audio.loopStop` (#1051). */
440
450
  export interface GameAudio {
441
451
  play(sound: string, at?: readonly [number, number, number]): void;
@@ -500,6 +510,12 @@ export interface GameContext {
500
510
  */
501
511
  notices: NoticeFeed;
502
512
  audio: GameAudio;
513
+ /**
514
+ * Announce a gameplay moment the engine has no event for (a wave starting, a scripted beat).
515
+ * The shell answers with the cue's sound, gamepad pulse, camera kick and caption, so a game
516
+ * never encodes the reaction itself.
517
+ */
518
+ feedback: GameFeedback;
503
519
  /** Play a model's one-shot animation clip bound to `event` in its `animation.oneShots` (e.g. an attack swing) — emits `entity.animation` for the shell to pick up. */
504
520
  playEntityAnimation(instanceId: string, event: string): void;
505
521
  feed: GameContextFeed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/core",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "jgengine core — pure-TypeScript game framework SDK (npm @jgengine/core). Entity stores, commands, combat, inventory, multiplayer seams. Zero dependencies. Not an ECS. Agent skills ship in the tarball. https://jgengine.com",
5
5
  "keywords": [
6
6
  "jgengine",
@@ -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.30.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.32.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -285,7 +285,7 @@
285
285
 
286
286
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
287
287
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
288
- - `VERSION` (const): const VERSION: "0.30.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.32.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -410,6 +410,7 @@
410
410
  - `GameContextRace` (interface): interface GameContextRace { state(id: string, config?: RaceStateConfig): RaceState } — ⚠ undocumented
411
411
  - `GameContextTurn` (interface): interface GameContextTurn { loop(id: string, config?: TurnLoopConfig): TurnLoop } — ⚠ undocumented
412
412
  - `GameContextWorld` (interface): interface GameContextWorld { ground: TerrainField; groundHeightAt(x: number, z: number): number; solids: WorldSolids } — ⚠ undocumented
413
+ - `GameFeedback` (interface): interface GameFeedback — The game-announced feel seam on `ctx.game`: name a moment the engine has no event for and the shell plays its cue's sound, rumble, camera kick and caption. The reaction table lives in the shell, so a game never encodes what a hit or a wave should sound like.
413
414
  - `HitReactionInput` (interface): interface HitReactionInput { from: string; to: string; config: HitReactionConfig | ImpactPresetName; power?: number } — ⚠ undocumented · used by `resolveHitReaction` (@jgengine/core/combat): Resolves hit feel (hitstop, knockback impulse, camera shake) from either a named `impactPresets` event (`resolveHitReaction("explosion", inp…
414
415
  - `MoveTowardCommitOptions` (interface): interface MoveTowardCommitOptions extends MoveTowardOptions — Options for {@link SceneEntityContext.moveTowardCommit}: {@link MoveTowardOptions} plus an optional facing turn.
415
416
  - `SceneEntityContext` (interface): interface SceneEntityContext { spawn(name: string, options?: SpawnOptions): string; despawn(instanceId: string): boolean; update: EntityStore["update"]; setPose(instanceId: string, pose: EntityPose): boolean; setVelocity: EntityStore["setVelocity"]; setPoseConstraint: … — ⚠ undocumented
@@ -728,7 +729,7 @@
728
729
  - `GameLoop` (interface): interface GameLoop<TContext = unknown> — Lifecycle hooks a game implements to drive init, per-tick simulation, and player join/leave.
729
730
  - `Hotbar` (function): function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, itemIcon, style, className, }: { inventoryId: string; activeSlot?: number; keys?: readonly string[]; slotSize?: number; /** Caller-supplied item id → icon registry; return null/undefined to fall back to the default glyph. */ itemIcon?: … — A numbered hotbar bound to an inventory — painted iconed slots (a `GameIcon` glyph over a school-keyed gradient with a count badge, #1035), an active-slot highlight, and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one. Supply `itemIcon` to map your item ids to your own glyphs — the default resolves a `GameIcon` from the item id.
730
731
  - `HudCanvas` (function): function HudCanvas({ layout, editChord, compactScale, showDuring, className, style, children, }: { layout: HudLayoutStore; editChord?: HudEditChord | false; /** Zoom applied to the whole HUD on compact displays. Default 0.85. */ compactScale?: number; /** Opt-in play-phase gate: render the HUD only … — Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine anchor regions and stack automatically with a gap — no per-panel pixel offsets, no manual clearance for sibling panels, the touch-control dock (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the whole surface scales down and each panel applies its `compact` behavior.
731
- - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** Stack … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
732
+ - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", lane, order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
732
733
  - `PlayableGame` (type): type PlayableGame = EnginePlayableGame< ComponentType, ComponentType, RenderEntity, RenderObject, ComponentType<ViewmodelProps>, ComponentType<WorldOverlayProps> > & { /** HUD theme the shell applies at the `GameUI` root. Unset uses `defaultHudTheme`; `false` opts out of the default theme and the en… — The concrete React-bound playable a shell `defineGame` returns and `GameHost` mounts — the one PlayableGame games handle.
733
734
  - `StatBar` (function): function StatBar({ statId = "health", entityId, tone = "health", label, showValue = true, width = 200, icon, style, className, }: { statId?: string; entityId?: string; tone?: StatTone; label?: string; showValue?: boolean; width?: number; icon?: ReactNode; style?: CSSProperties; className?: string; }… — A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player). Renders nothing until the stat exists.
734
735
  - `StoreHandle` (interface): interface StoreHandle<T> — A typed, cast-free handle onto one slot of the reactive game store (`ctx.game.store`). The single type parameter is fixed at definition; `read`/`write`/`update` never widen to `unknown`, so no call site repeats `store.get(key) as T`. Writes flow through the engine store, so they bump `ctx.version()`, serialize into a {@link WorldSnapshot}, and replay under host authority — the same guarantees a hand-rolled `store.get`/`store.set` pair gives up the moment it forks run state into a module-level singleton.
@@ -44,7 +44,16 @@ tiers, manufacturer stat personalities and a level curve, all caller data.
44
44
  `validateLootItem` names each defect, `compareLootItems` returns per-stat deltas
45
45
  and a weighted score, and `lootWeaponDefinition` (`loot/lootWeapon`) projects a
46
46
  rolled item onto a `WeaponDefinition` for `createWeaponSystem`. `createLootRoller`
47
- counts drops per rarity for a debug probe and snapshots its counters. Sample
47
+ counts drops per rarity for a debug probe and snapshots its counters.
48
+
49
+ Roll a campaign drop with `rollDrop` (`loot/drop`) and show `compareToEquipped`
50
+ against worn gear; carry and bank it with `createRunHaul` (`loot/haul`), which
51
+ weighs the hold through the encumbrance rules and drops the hold on `fail`. Break
52
+ an item down once with `salvageItem` and `createItemLedger` (`loot/salvage`), and
53
+ price a vendor shelf through the wallet with `stockVendor` (`loot/vendor`).
54
+ Bases and affixes take `minLevel`; bases take `mass`, which a roll carries forward.
55
+
56
+ Sample
48
57
  catalog, distribution check and generated-weapon proof are in
49
58
  [reference-systems.md](reference-systems.md#procedural-gear).
50
59
 
@@ -604,6 +604,7 @@
604
604
  - `EntityAnimationEvent` (interface): interface EntityAnimationEvent — Request that an entity's rig play a one-shot animation clip bound to `event` in its `animation.oneShots` (e.g. an "attack" swing); the shell resolves the clip and plays it once over the locomotion state. With an `animation.graph`, `event` arms the trigger of that name.
605
605
  - `EntityDiedEvent` (interface): interface EntityDiedEvent { instanceId: string; catalogId: string; userId?: string; displayName?: string; reason: DeathReason; source?: string; position: [number, number, number]; serverId?: string } — ⚠ undocumented
606
606
  - `EntityFloatTextEvent` (interface): interface EntityFloatTextEvent { instanceId?: string; position: [number, number, number]; text: string; kind: string; amount?: number; hitType?: string; element?: string; crit?: boolean; scale?: number } — ⚠ undocumented
607
+ - `FeedbackCueEvent` (interface): interface FeedbackCueEvent — A gameplay moment the game announces itself, for the moments no engine event covers (a wave starting, a scripted beat). The shell reacts with the cue's sound, rumble, camera kick and caption.
607
608
  - `FormChangedEvent` (interface): interface FormChangedEvent { instanceId: string; formId: string | null } — ⚠ undocumented
608
609
  - `GameEventHandler` (type): type GameEventHandler<TPayload> = (payload: TPayload) => void — ⚠ undocumented
609
610
  - `GameEventMap` (interface): interface GameEventMap { "entity.died": EntityDiedEvent; "combat.damage": DamageAppliedEvent; "entity.floatText": EntityFloatTextEvent; "combat.telegraph": CombatTelegraphEvent; "combat.vfx": CombatVfxEvent; "combat.vfxInstance": CombatVfxInstanceEvent; "combat.t… — ⚠ undocumented · used by `createGameEvents`: A typed publish/subscribe bus for gameplay events that systems and HUDs subscribe to.
@@ -1297,6 +1298,7 @@
1297
1298
  - `DEFAULT_ESCAPE_MULTIPLE` (const): const DEFAULT_ESCAPE_MULTIPLE: 3 — A pursuit escapes at this multiple of its keep-close distance when none is declared.
1298
1299
  - `DEFAULT_FIXED_STAGES` (const): const DEFAULT_FIXED_STAGES: readonly ["input", "movement", "combat", "ai", "activities", "cleanup"] — Default fixed-sim stage order — systems pick a stage; most need only this.
1299
1300
  - `DEFAULT_FRAME_STAGES` (const): const DEFAULT_FRAME_STAGES: readonly ["input", "movement", "combat", "ai", "activities", "cleanup", "animation", "camera", "effects"] — Default frame stage order. Gameplay stages mirror the fixed table so once-per-frame systems (`type: "frame"`) can pick `combat`/`ai`/… without falling into the unknown-stage bucket; presentation stages follow.
1301
+ - `DEFAULT_MARKER_TOLERANCE` (const): const DEFAULT_MARKER_TOLERANCE: 8 — Default vertical allowance (m) for a horizontal marker proximity test.
1300
1302
  - `DEFAULT_PICKUP_RADIUS` (const): const DEFAULT_PICKUP_RADIUS: 2 — ⚠ undocumented
1301
1303
  - `DEFAULT_PING_CATEGORIES` (const): const DEFAULT_PING_CATEGORIES: Record<PingCategory, PingCategoryDef> — Content-agnostic default ping wheel: enemy / loot / location / danger.
1302
1304
  - `DEFAULT_PURSUE_DISTANCE` (const): const DEFAULT_PURSUE_DISTANCE: 3 — Default keep-close distance (m) for a `pursue` objective.
@@ -1445,7 +1447,7 @@
1445
1447
  - `LootDropProvenance` (interface): interface LootDropProvenance — Why one drop is in the result: the stage/table/entry that produced it, its weights, and the modifiers that shaped it.
1446
1448
  - `LootFilterRule` (interface): interface LootFilterRule { id: string; when: LootFilterCondition; hide?: boolean; color?: string; beam?: boolean; label?: string } — ⚠ undocumented · used by `evaluateLootFilter`: First matching rule wins (PoE/Last Epoch block semantics) — later rules never override an earlier match.
1447
1449
  - `LootInstalledPart` (interface): interface LootInstalledPart — One part occupying one slot of a rolled item.
1448
- - `LootIssueCode` (type): type LootIssueCode = | "invalid-level" | "unknown-base" | "unknown-manufacturer" | "manufacturer-base" | "unknown-rarity" | "duplicate-slot" | "unknown-slot" | "missing-slot-part" | "unknown-part" | "part-category" | "unknown-affix" | "duplicate-affix" | "invalid-affix" | "non-finite-stat" | "empty-… — A defect found by {@link validateLootItem}.
1450
+ - `LootIssueCode` (type): type LootIssueCode = | "invalid-level" | "unknown-base" | "level-gated-base" | "unknown-manufacturer" | "manufacturer-base" | "unknown-rarity" | "duplicate-slot" | "unknown-slot" | "missing-slot-part" | "unknown-part" | "part-category" | "unknown-affix" | "duplicate-affix" | "invalid-affix" | "non-f… — A defect found by {@link validateLootItem}.
1449
1451
  - `LootItem` (interface): interface LootItem — One rolled item: the base, maker, rarity, level, composed name, chosen parts, rolled affixes and resolved stats. Plain JSON, so a save stores it and the instance registry can key it.
1450
1452
  - `LootManufacturerDef` (interface): interface LootManufacturerDef — A maker with a stat personality: its mark goes in the name, `bias` shifts stats additively, `scale` multiplies them, and favored affix pools weigh more when this maker rolls the item.
1451
1453
  - `LootModifier` (interface): interface LootModifier<TCtx = unknown> — A registered, id-tagged loot policy applied to one stage. `plan` transforms eligibility, weights, and roll counts before rolling (luck, difficulty, gating); `drops` post-processes the rolled drops (quantity multipliers, dedupe, caps). Neither hook mutates table definitions, and the id is recorded in provenance so a resolved drop can be traced back to the policies that shaped it.
@@ -1493,6 +1495,8 @@
1493
1495
  - `MissionFailCondition` (interface): interface MissionFailCondition — A condition that fails the mission while the objective is active. `to` names a {@link MissionFailDef}; with no `to` the mission fails with the generic `"failed"` state.
1494
1496
  - `MissionFailDef` (interface): interface MissionFailDef — A named fail state an objective, timer or branch can land in.
1495
1497
  - `MissionIssue` (interface): interface MissionIssue — A located mission-catalog problem; `path` is a JSON Pointer into the supplied catalog.
1498
+ - `MissionMarkerPlane` (type): type MissionMarkerPlane = "horizontal" | "spatial" — Which plane a marker proximity test compares in.
1499
+ - `MissionMarkerProximity` (interface): interface MissionMarkerProximity — How a positional objective compares an actor to its authored marker.
1496
1500
  - `MissionObjectiveDef` (interface): interface MissionObjectiveDef — One objective. Every field is content; the machine reads only these.
1497
1501
  - `MissionObjectiveKind` (type): type MissionObjectiveKind = "reach" | "interact" | "survive" | "pursue" | "kill" | "collect" — The primitive goals an objective can have. Escort, chase and defend are not kinds — they are compositions of these plus fail conditions and timers (see `compositions.ts`).
1498
1502
  - `MissionRewardAppliers` (interface): interface MissionRewardAppliers — Appliers a mission reward routes through — the same shape quests use, plus a loot-table hook.
@@ -2178,7 +2182,7 @@
2178
2182
 
2179
2183
  ## @jgengine/core/item/affix
2180
2184
 
2181
- - `AffixDef` (interface): interface AffixDef { id: string; stat: string; op?: AffixOp; roll: number | [number, number]; weight: number; namePart?: {position: "prefix" | "suffix"; text: string} } — ⚠ undocumented
2185
+ - `AffixDef` (interface): interface AffixDef { id: string; stat: string; op?: AffixOp; roll: number | [number, number]; weight: number; minLevel?: number; namePart?: {position: "prefix" | "suffix"; text: string} } — ⚠ undocumented
2182
2186
  - `AffixOp` (type): type AffixOp = "add" | "mul" — ⚠ undocumented
2183
2187
  - `AffixPool` (interface): interface AffixPool { id: string; affixes: readonly AffixDef[] } — ⚠ undocumented
2184
2188
  - `AffixRoller` (interface): interface AffixRoller { rollRarity(rng: () => number): RarityTier; roll(base: ItemBaseDef, rarityId: string, rng: () => number): RolledItem; rollRandom(base: ItemBaseDef, rng: () => number): RolledItem } — ⚠ undocumented · used by `createAffixRoller`: Rolls randomized gear from a base item: a weighted rarity tier scales base stats and picks prefix/suffix affixes that name and modify the it…
@@ -2309,6 +2313,25 @@
2309
2313
  - `createWeaponStats` (function): function createWeaponStats(resolveEntry: (itemId: string) => WeaponEntry | null | undefined): WeaponStats — Resolve per-weapon stat values — damage, fire rate, spread — for combat math.
2310
2314
  - `getWeaponStat` (function): function getWeaponStat(entry: WeaponEntry | null | undefined, stat: string): number | null — ⚠ undocumented
2311
2315
 
2316
+ ## @jgengine/core/loot/drop
2317
+
2318
+ - `EquippedComparison` (interface): interface EquippedComparison — What a HUD renders when a drop lands: per-stat deltas against the worn item and one verdict.
2319
+ - `EquippedStatDelta` (interface): interface EquippedStatDelta extends LootStatDelta — A per-stat delta from the equipped item to the candidate; positive `delta` means the candidate is larger.
2320
+ - `ItemDrop` (interface): interface ItemDrop extends LootItem — A rolled item with a stable instance id, so a stash, ledger and vendor stock can name it.
2321
+ - `RollDropOptions` (interface): interface RollDropOptions extends LootRollOptions — {@link rollDrop} options: the loot roll plus the instance id (defaults to `drop:<seed>`).
2322
+ - `compareToEquipped` (function): function compareToEquipped(candidate: ItemDrop, equipped: ItemDrop | null, options: LootComparisonOptions = {}): EquippedComparison — Compare a dropped item against what is equipped, with one delta per stat and a weighted verdict. The delta is `candidate - equipped`; `better` flips for stats the caller lists in `lowerIsBetter`, so reload time improving does not read as worse. A missing equipped item compares against zeros.
2323
+ - `itemMass` (function): function itemMass(item: Pick<LootItem, "mass">): number — Carried mass of a rolled item. Older saves may predate `mass`; those default to `1`.
2324
+ - `rollDrop` (function): function rollDrop(catalog: LootCatalog, seed: string | number, options: RollDropOptions = {}): ItemDrop — Roll one item from a seed. Same seed, catalog and options produce the same id, name, affixes, stats and mass on every run; level gates which bases and affixes are in the pool.
2325
+
2326
+ ## @jgengine/core/loot/haul
2327
+
2328
+ - `CarryOutcome` (type): type CarryOutcome = | { ok: true; mass: number; remaining: number } | { ok: false; reason: "over-capacity" | "no-space" | "invalid-count"; mass: number; remaining: number } — What a carry attempt returns: the new load, or the rule that refused it.
2329
+ - `HaulTransfer` (interface): interface HaulTransfer — Stacks moved between a run and its stash, or lost when the run fails.
2330
+ - `RunHaul` (interface): interface RunHaul — A run's carried loot and the stash that outlives it.
2331
+ - `RunHaulConfig` (interface): interface RunHaulConfig<TId extends string> — What a run haul needs: two inventories, a weight rule and an optional save cell.
2332
+ - `RunHaulState` (interface): interface RunHaulState — Detached run and stash contents, for a save or a debug readout.
2333
+ - `createRunHaul` (function): function createRunHaul<TId extends string>(config: RunHaulConfig<TId>): RunHaul — A run's haul beside a persistent stash. Carrying is gated by the shared encumbrance rules; `bank` moves what the stash fits and saves; `fail` throws the run's haul away and leaves the stash as it was. The game decides when a run succeeds or fails and calls one of them.
2334
+
2312
2335
  ## @jgengine/core/loot/lootGen
2313
2336
 
2314
2337
  - `LootBaseDef` (interface): interface LootBaseDef extends ItemBaseDef — A base family (frame, housing, chassis — caller nouns) with its slots, stats and affix pools.
@@ -2316,7 +2339,7 @@
2316
2339
  - `LootComparison` (interface): interface LootComparison — How two items compare: every stat delta plus one weighted score for each.
2317
2340
  - `LootComparisonOptions` (interface): interface LootComparisonOptions — Caller policy for the comparison score.
2318
2341
  - `LootInstalledPart` (interface): interface LootInstalledPart — One part occupying one slot of a rolled item.
2319
- - `LootIssueCode` (type): type LootIssueCode = | "invalid-level" | "unknown-base" | "unknown-manufacturer" | "manufacturer-base" | "unknown-rarity" | "duplicate-slot" | "unknown-slot" | "missing-slot-part" | "unknown-part" | "part-category" | "unknown-affix" | "duplicate-affix" | "invalid-affix" | "non-finite-stat" | "empty-… — A defect found by {@link validateLootItem}.
2342
+ - `LootIssueCode` (type): type LootIssueCode = | "invalid-level" | "unknown-base" | "level-gated-base" | "unknown-manufacturer" | "manufacturer-base" | "unknown-rarity" | "duplicate-slot" | "unknown-slot" | "missing-slot-part" | "unknown-part" | "part-category" | "unknown-affix" | "duplicate-affix" | "invalid-affix" | "non-f… — A defect found by {@link validateLootItem}.
2320
2343
  - `LootItem` (interface): interface LootItem — One rolled item: the base, maker, rarity, level, composed name, chosen parts, rolled affixes and resolved stats. Plain JSON, so a save stores it and the instance registry can key it.
2321
2344
  - `LootManufacturerDef` (interface): interface LootManufacturerDef — A maker with a stat personality: its mark goes in the name, `bias` shifts stats additively, `scale` multiplies them, and favored affix pools weigh more when this maker rolls the item.
2322
2345
  - `LootPartDef` (interface): interface LootPartDef extends PartDef — A part a base can mount: its stats, a pick weight, and an optional word for the item name.
@@ -2342,6 +2365,30 @@
2342
2365
  - `LootWeaponStatMap` (interface): interface LootWeaponStatMap — Which rolled stat feeds which part of a {@link WeaponDefinition}. Every field is a stat id from the item; an omitted field keeps the template's value, so a catalog only maps what it rolls.
2343
2366
  - `lootWeaponDefinition` (function): function lootWeaponDefinition(item: LootItem, projection: LootWeaponProjection): WeaponDefinition — Project a rolled item onto a plain weapon definition: copy the template, then write the mapped stats into rate, damage, magazine, spread, range and projectile speed. The result is a {@link WeaponDefinition}, so `assertWeaponDefinition` and `createWeaponSystem` accept it unchanged.
2344
2367
 
2368
+ ## @jgengine/core/loot/salvage
2369
+
2370
+ - `ItemDropLike` (interface): interface ItemDropLike — The fields salvage reads; an `ItemDrop` from `./drop` satisfies it.
2371
+ - `ItemLedger` (interface): interface ItemLedger — One record of what happened to rolled item instances. Every path that removes an item - salvage, sale, granting a duplicate - claims it here first, so a second claim returns false and the item cannot be equipped, sold or broken down twice.
2372
+ - `ItemLedgerState` (type): type ItemLedgerState = Readonly<Record<string, Exclude<ItemLifecycle, "owned">>> — Items that have left play, keyed by instance id; anything absent is still `owned`.
2373
+ - `ItemLifecycle` (type): type ItemLifecycle = "owned" | "salvaged" | "sold" — Where an item instance stands: still usable, broken down, or sold.
2374
+ - `SalvageResult` (type): type SalvageResult = | { ok: true; id: string; yield: SalvageYield } | { ok: false; reason: "already-spent" | "invalid-item" } — Salvage outcome: the yield, or the reason the instance could not be broken down.
2375
+ - `SalvageTable` (interface): interface SalvageTable — What a broken-down item is worth: a game-authored currency, rate and per-rarity multiplier.
2376
+ - `SalvageYield` (interface): interface SalvageYield — The currency and amount one salvage yields. `currency` is the game's key, never interpreted.
2377
+ - `createItemLedger` (function): function createItemLedger(initial: ItemLedgerState = {}): ItemLedger — Build an item ledger. Ids are consumed once: the first successful {@link ItemLedger.spend} wins, every later call fails, which is what makes salvage and sale mutually exclusive.
2378
+ - `salvageItem` (function): function salvageItem(item: ItemDropLike, table: SalvageTable, ledger: ItemLedger): SalvageResult — Break one item down through its ledger. The ledger is claimed before the yield is computed, so a salvaged item cannot be salvaged, equipped or sold again; pass the game's own wallet update from `yield`.
2379
+ - `salvageValue` (function): function salvageValue(item: Pick<ItemDropLike, "level" | "rarity">, table: SalvageTable): SalvageYield — The yield an item of this level and rarity breaks down into. The formula is `round(perLevel x level x rarityMultiplier)`, floored by `minimum`, so the same item always yields the same amount and a higher level never yields less.
2380
+
2381
+ ## @jgengine/core/loot/vendor
2382
+
2383
+ - `StockVendorOptions` (interface): interface StockVendorOptions — What stocking a vendor reads: the roll catalog, a seeded rng and the pricing policy.
2384
+ - `VendorOffer` (interface): interface VendorOffer — One stocked item and the shop entry that prices it.
2385
+ - `VendorPriceRule` (interface): interface VendorPriceRule — How a vendor price is derived from an item: a base, a per-level rate and a rarity multiplier.
2386
+ - `VendorRotation` (interface): interface VendorRotation — A run-count refresh rule; the game owns how many runs between restocks.
2387
+ - `VendorRotationState` (interface): interface VendorRotationState — Where a vendor shelf stands: the run number it was last stocked on.
2388
+ - `createVendorRotation` (function): function createVendorRotation(everyRuns: number, start = 0): VendorRotation — A restock rule the game authors as "every N runs". It only answers when a refresh is due; the game calls {@link stockVendor} and then `mark`. No timer, so a paused or reloaded save keeps the same shelf until the run count moves.
2389
+ - `itemPrice` (function): function itemPrice(item: Pick<LootItem, "level" | "rarity">, rule: VendorPriceRule): { buy: number; sell: number | null } — The price of one item under a `VendorPriceRule`: `round(base + perLevel x level)` times the rarity multiplier, never below 1. Sell-back is `floor(price x sellFactor)` when the rule sets one.
2390
+ - `stockVendor` (function): function stockVendor(shop: ShopStock, options: StockVendorOptions): VendorOffer[] — Roll a vendor's shelf and add it to an existing {@link ShopStock}. Entries added by a previous call with the same `idPrefix` are removed first, so a refresh replaces the shelf instead of throwing on duplicate ids. Buying, selling and wallet charging stay in the economy owner.
2391
+
2345
2392
  ## @jgengine/core/mission/compositions
2346
2393
 
2347
2394
  - `ChaseMissionOptions` (interface): interface ChaseMissionOptions extends ComposedMissionOptions — Options for {@link chaseMission}: a target kept close until it is caught.
@@ -2355,6 +2402,7 @@
2355
2402
  ## @jgengine/core/mission/mission
2356
2403
 
2357
2404
  - `DEFAULT_ESCAPE_MULTIPLE` (const): const DEFAULT_ESCAPE_MULTIPLE: 3 — A pursuit escapes at this multiple of its keep-close distance when none is declared.
2405
+ - `DEFAULT_MARKER_TOLERANCE` (const): const DEFAULT_MARKER_TOLERANCE: 8 — Default vertical allowance (m) for a horizontal marker proximity test.
2358
2406
  - `DEFAULT_PURSUE_DISTANCE` (const): const DEFAULT_PURSUE_DISTANCE: 3 — Default keep-close distance (m) for a `pursue` objective.
2359
2407
  - `DEFAULT_REACH_RADIUS` (const): const DEFAULT_REACH_RADIUS: 2.5 — Default proximity radius (m) for a `reach` / `interact` objective.
2360
2408
  - `MISSION_COUNTER_KINDS` (const): const MISSION_COUNTER_KINDS: readonly MissionObjectiveKind[] — Objective kinds that are satisfied only by a counter — a mission made of these alone is a lint error.
@@ -2367,6 +2415,8 @@
2367
2415
  - `MissionFailCondition` (interface): interface MissionFailCondition — A condition that fails the mission while the objective is active. `to` names a {@link MissionFailDef}; with no `to` the mission fails with the generic `"failed"` state.
2368
2416
  - `MissionFailDef` (interface): interface MissionFailDef — A named fail state an objective, timer or branch can land in.
2369
2417
  - `MissionIssue` (interface): interface MissionIssue — A located mission-catalog problem; `path` is a JSON Pointer into the supplied catalog.
2418
+ - `MissionMarkerPlane` (type): type MissionMarkerPlane = "horizontal" | "spatial" — Which plane a marker proximity test compares in.
2419
+ - `MissionMarkerProximity` (interface): interface MissionMarkerProximity — How a positional objective compares an actor to its authored marker.
2370
2420
  - `MissionObjectiveDef` (interface): interface MissionObjectiveDef — One objective. Every field is content; the machine reads only these.
2371
2421
  - `MissionObjectiveKind` (type): type MissionObjectiveKind = "reach" | "interact" | "survive" | "pursue" | "kill" | "collect" — The primitive goals an objective can have. Escort, chase and defend are not kinds — they are compositions of these plus fail conditions and timers (see `compositions.ts`).
2372
2422
  - `MissionRewardAppliers` (interface): interface MissionRewardAppliers — Appliers a mission reward routes through — the same shape quests use, plus a loot-table hook.
@@ -244,6 +244,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
244
244
 
245
245
  - `splitStack` (function) · `import { splitStack } from "@jgengine/core/inventory/inventoryModel"`
246
246
 
247
+ ## item-comparison — show per-stat deltas and a weighted verdict for a drop against equipped gear
248
+
249
+ - `compareToEquipped` (function) · `import { compareToEquipped } from "@jgengine/core/loot/drop"`
250
+
247
251
  ## item-generation — compose constraints, weighted pools, transforms, and provenance into a deterministic generated item
248
252
 
249
253
  - `generate` (function) · `import { generate } from "@jgengine/core/gameplay"`
@@ -265,10 +269,46 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
265
269
  - `createItemInstanceRegistry` (function) · `import { createItemInstanceRegistry } from "@jgengine/core/gameplay"`
266
270
  - `proceduralLootEntry` (function) · `import { proceduralLootEntry } from "@jgengine/core/gameplay"`
267
271
 
272
+ ## item-ledger — track which rolled item instances are still owned, salvaged or sold
273
+
274
+ - `createItemLedger` (function) · `import { createItemLedger } from "@jgengine/core/loot/salvage"`
275
+
276
+ ## item-roll — roll one deterministic, level-gated item from a seed
277
+
278
+ - `rollDrop` (function) · `import { rollDrop } from "@jgengine/core/loot/drop"`
279
+
280
+ ## item-run-haul — carry loot with a weight gate, bank it into a persistent stash, or lose it when the run fails
281
+
282
+ - `createRunHaul` (function) · `import { createRunHaul } from "@jgengine/core/loot/haul"`
283
+
284
+ ## item-salvage — break an item down for the currency its level and rarity say, once
285
+
286
+ - `salvageItem` (function) · `import { salvageItem } from "@jgengine/core/loot/salvage"`
287
+
288
+ ## item-salvage-value — compute the currency an item of a level and rarity breaks down into
289
+
290
+ - `salvageValue` (function) · `import { salvageValue } from "@jgengine/core/loot/salvage"`
291
+
268
292
  ## item-use-composition — compose pluggable, ordered item use-behaviors from serializable references
269
293
 
270
294
  - `createUseBehaviorRegistry` (function) · `import { createUseBehaviorRegistry } from "@jgengine/core/gameplay"`
271
295
 
296
+ ## item-vendor — stock a vendor with rolled items priced through the shared economy
297
+
298
+ - `stockVendor` (function) · `import { stockVendor } from "@jgengine/core/loot/vendor"`
299
+
300
+ ## item-vendor-price — price an item from its level and rarity through the economy's currency
301
+
302
+ - `itemPrice` (function) · `import { itemPrice } from "@jgengine/core/loot/vendor"`
303
+
304
+ ## item-vendor-rotation — decide when a vendor's shelf is due for a restock
305
+
306
+ - `createVendorRotation` (function) · `import { createVendorRotation } from "@jgengine/core/loot/vendor"`
307
+
308
+ ## item-weight — read a rolled item's mass for a carry check
309
+
310
+ - `itemMass` (function) · `import { itemMass } from "@jgengine/core/loot/drop"`
311
+
272
312
  ## key-rebinding — conflict-aware key-remap session over the action-binding model — capture/reassign, conflict detection, reset-to-defaults, persist overrides, snapshot/restore
273
313
 
274
314
  - `createRebindSession` (function) · `import { createRebindSession } from "@jgengine/core/gameplay"`
@@ -455,11 +455,11 @@
455
455
 
456
456
  ## @jgengine/core/audio/audioFalloff
457
457
 
458
- - `AudioBusDef` (interface): interface AudioBusDef { id: AudioBusId; gain?: number } — ⚠ undocumented · used by `withUiCueSounds` (@jgengine/core/audio/uiCues): Merge the default UI cue patches into a game's sound catalog.
458
+ - `AudioBusDef` (interface): interface AudioBusDef { id: AudioBusId; gain?: number } — ⚠ undocumented · used by `withFeedbackCueSounds` (@jgengine/core/audio/feedbackCues): Merge the default feedback reactions into a game's sound catalog.
459
459
  - `AudioBusId` (type): type AudioBusId = string — ⚠ undocumented
460
460
  - `AudioFalloffConfig` (interface): interface AudioFalloffConfig { minDistance?: number; maxDistance?: number; curve?: FalloffCurve } — ⚠ undocumented
461
461
  - `FalloffCurve` (type): type FalloffCurve = "linear" | "inverse" | "none" — ⚠ undocumented
462
- - `SoundDef` (interface): interface SoundDef { id: string; url?: string; synth?: SynthPatch; bus: AudioBusId; gain?: number; loop?: boolean; maxVoices?: number; priority?: number; positional?: boolean; falloff?: AudioFalloffConfig; doppler?: number; spatial?: {panning: "hrtf" | "equal… — ⚠ undocumented · used by `DEFAULT_UI_CUE_SOUNDS` (@jgengine/core/audio/uiCues): Short, soft procedural patches: no samples to load, quiet enough to sit under game audio.
462
+ - `SoundDef` (interface): interface SoundDef { id: string; url?: string; synth?: SynthPatch; bus: AudioBusId; gain?: number; loop?: boolean; maxVoices?: number; priority?: number; positional?: boolean; falloff?: AudioFalloffConfig; doppler?: number; spatial?: {panning: "hrtf" | "equal… — ⚠ undocumented · used by `DEFAULT_FEEDBACK_CUE_SOUNDS` (@jgengine/core/audio/feedbackCues): Short procedural reactions - no samples to load, so every game has impact feedback without authoring audio.
463
463
  - `computeFalloffGain` (function): function computeFalloffGain(distance: number, config: AudioFalloffConfig = {}): number — ⚠ undocumented
464
464
  - `distance3` (function): function distance3(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }): number — ⚠ undocumented
465
465
  - `resolveEmitterGain` (function): function resolveEmitterGain(distance: number, sound: Pick<SoundDef, "gain" | "positional" | "falloff">, busGain: number): number — ⚠ undocumented
@@ -481,6 +481,16 @@
481
481
  - `EngineLayersState` (interface): interface EngineLayersState — Serializable {@link EngineLayers} state.
482
482
  - `createEngineLayers` (function): function createEngineLayers(initial: EngineLayersConfig): EngineLayers — Layered engine sound: each set of rpm-keyed samples is equal-power crossfaded between the two samples bracketing the current rpm and pitched by `rpm / layer.rpm`; the on- and off-load sets are equal-power crossfaded by smoothed `engineLoad`. A config with only one load set plays it at every load. `play` drives `ctx.game.audio` directly, so a game needs no per-layer glue.
483
483
 
484
+ ## @jgengine/core/audio/feedbackCues
485
+
486
+ - `DEFAULT_FEEDBACK_CUE_SOUNDS` (const): const DEFAULT_FEEDBACK_CUE_SOUNDS: Readonly<Record<FeedbackCue, SoundDef>> — Short procedural reactions - no samples to load, so every game has impact feedback without authoring audio. Loud enough to read, quiet enough to sit under a weapon's own sound.
487
+ - `FEEDBACK_CUES` (const): const FEEDBACK_CUES: readonly FeedbackCue[] — Every {@link FeedbackCue}, in a stable order.
488
+ - `FEEDBACK_CUE_BUS` (const): const FEEDBACK_CUE_BUS: "sfx" — Bus the default feedback cues mix through; the settings menu shows its volume and mute.
489
+ - `FeedbackCue` (type): type FeedbackCue = "hit" | "hurt" | "kill" | "pickup" | "objective" | "wave" | "deny" — A gameplay moment the engine reacts to: taking or dealing a hit, a kill, a pickup, an objective, a wave starting, or a refused action. The game names the moment; the engine owns the reaction.
490
+ - `FeedbackCueOverrides` (type): type FeedbackCueOverrides = false | Partial<Record<FeedbackCue, SoundDef | false>> — Per-cue override: a replacement sound, or `false` to silence that cue. `false` for the whole map silences all.
491
+ - `feedbackCueSoundId` (function): function feedbackCueSoundId(cue: FeedbackCue): string — Catalog id a cue plays under: `feel.hit`, `feel.kill`, ... A game sound with that id replaces the default.
492
+ - `withFeedbackCueSounds` (function): function withFeedbackCueSounds(sounds: Readonly<Record<string, SoundDef>> | undefined, buses: Readonly<Record<string, AudioBusDef>> | undefined, overrides?: FeedbackCueOverrides): { sounds: Record<string, SoundDef>; buses: Record<string, AudioBusDef> | undefined } — Merge the default feedback reactions into a game's sound catalog. A game sound already named `feel.<cue>` wins, then `overrides`, then the default. Adds the `sfx` bus (gain 0.9) when the game did not declare one, so the impact layer has its own volume and mute row.
493
+
484
494
  ## @jgengine/core/audio/music
485
495
 
486
496
  - `MusicInstrument` (type): type MusicInstrument = | "strings" | "flute" | "harp" | "horn" | "choir" | "bell" | "timpani" | "bass" | "stacc" | "pad" | "lute" | "dulcimer" | "frameDrum" | "warDrum" | "reed" | "pipe" | "squareLead" | "woodBlock" | "tinyBell" | "piano" | "shaker" | "brassStab" | "cymSwell" | "oboe" — Named synthesised instrument. Each maps to a voice in the shell's instrument library (`@jgengine/shell/audio/musicVoices`); an unknown name falls back to a plain sine voice so a theme is never silent.
@@ -2358,7 +2368,7 @@
2358
2368
  - `AssetSpace` (interface): interface AssetSpace — Catalog-level asset-space metadata: how a model is authored relative to the engine, owned by the catalog entry rather than re-derived per game. Every field is optional so an unmeasured asset stays valid; {@link resolveFacingRotationY}/{@link assetUnitScale}/{@link resolveAnchorOffset} supply the documented defaults.
2359
2369
  - `AssetSpaceIssue` (interface): interface AssetSpaceIssue — One problem found by {@link validateAssetSpace}: which metadata field and why it is invalid.
2360
2370
  - `AttackMoveOrderPayload` (interface): interface AttackMoveOrderPayload — Payload for an attack-move: advance toward `point`, engaging hostiles encountered en route.
2361
- - `AudioBusDef` (interface): interface AudioBusDef { id: AudioBusId; gain?: number } — ⚠ undocumented · used by `withUiCueSounds` (@jgengine/core/audio/uiCues): Merge the default UI cue patches into a game's sound catalog.
2371
+ - `AudioBusDef` (interface): interface AudioBusDef { id: AudioBusId; gain?: number } — ⚠ undocumented · used by `withFeedbackCueSounds` (@jgengine/core/audio/feedbackCues): Merge the default feedback reactions into a game's sound catalog.
2362
2372
  - `AudioFalloffConfig` (interface): interface AudioFalloffConfig { minDistance?: number; maxDistance?: number; curve?: FalloffCurve } — ⚠ undocumented
2363
2373
  - `AuthoredNavLink` (interface): interface AuthoredNavLink — A connection the scene author places between two spots — a door through a wall, a ladder to a deck, a jump across a gap. Authored as world points, not polygon ids, so it survives a re-bake: {@link NavRuntime.applyMesh} resolves each end onto the nearest walkable polygon.
2364
2374
  - `AuthoredTrigger` (interface): interface AuthoredTrigger — One resolved trigger binding from a document object.
@@ -2847,7 +2857,7 @@
2847
2857
  - `SnapMode` (type): type SnapMode = "grid" | "free" | "surface" — ⚠ undocumented
2848
2858
  - `SnowEnvironmentDescriptor` (type): type SnowEnvironmentDescriptor = { kind: "snow" } & Required< Pick<SnowEnvironmentConfig, "area" | "density" | "speed" | "flakeSize" | "drift" | "wind" | "color" | "opacity"> > — ⚠ undocumented · used by `snow`: Declares a snowfall weather effect for `environment()` — area, density, drift, wind, and flake opacity.
2849
2859
  - `SoilRules` (interface): interface SoilRules — Fully-defaulted soil params parsed from a volume's `meta`.
2850
- - `SoundDef` (interface): interface SoundDef { id: string; url?: string; synth?: SynthPatch; bus: AudioBusId; gain?: number; loop?: boolean; maxVoices?: number; priority?: number; positional?: boolean; falloff?: AudioFalloffConfig; doppler?: number; spatial?: {panning: "hrtf" | "equal… — ⚠ undocumented · used by `DEFAULT_UI_CUE_SOUNDS` (@jgengine/core/audio/uiCues): Short, soft procedural patches: no samples to load, quiet enough to sit under game audio.
2860
+ - `SoundDef` (interface): interface SoundDef { id: string; url?: string; synth?: SynthPatch; bus: AudioBusId; gain?: number; loop?: boolean; maxVoices?: number; priority?: number; positional?: boolean; falloff?: AudioFalloffConfig; doppler?: number; spatial?: {panning: "hrtf" | "equal… — ⚠ undocumented · used by `DEFAULT_FEEDBACK_CUE_SOUNDS` (@jgengine/core/audio/feedbackCues): Short procedural reactions - no samples to load, so every game has impact feedback without authoring audio.
2851
2861
  - `SpatialGrid` (class): class SpatialGrid — A uniform-grid broad-phase over the x/z plane, separate from the rigid-body sim, for cheap same-tick proximity across hundreds–thousands of simple movers (swarm enemies). Rebuild each tick from the caller's own position arrays, then `queryCircle` (enemies hitting the player / an AoE) or `forEachPair` (mutual separation). Both are precise: no false negatives, no false positives beyond the exact distance test.
2852
2862
  - `SpawnDirectorConfig` (interface): interface SpawnDirectorConfig { waves: readonly WaveManifest[]; maxAlive?: number; escalationPerSecond?: number; alertBudgetPerSecond?: number; alertDecayPerSecond?: number; playerBudgetPerSecond?: number; maxSpawnsPerTick?: number; loop?: boolean; seed?: number; spawnP… — ⚠ undocumented
2853
2863
  - `SpawnDirectorState` (interface): interface SpawnDirectorState { wave: number; elapsed: number; waveElapsed: number; budget: number; alert: number; spawnedThisWave: number; spawnedTotal: number; rng: RandomSeed; done: boolean } — ⚠ undocumented
@@ -370,6 +370,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
370
370
 
371
371
  - `createFastTravelNetwork` (function) · `import { createFastTravelNetwork } from "@jgengine/core/world"`
372
372
 
373
+ ## feedback-cue-sounds — default procedural hit/hurt/kill/pickup/objective/wave/deny reactions on an "sfx" bus, overridable per game
374
+
375
+ - `withFeedbackCueSounds` (function) · `import { withFeedbackCueSounds } from "@jgengine/core/audio/feedbackCues"`
376
+
373
377
  ## feedback-mixer — map sim telemetry to camera, audio and haptic parameters through curves, smoothing and threshold events
374
378
 
375
379
  - `createFeedbackMixer` (function) · `import { createFeedbackMixer } from "@jgengine/core/vfx/feedbackMixer"`