@jgengine/core 0.33.0 → 0.35.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.
package/CHANGELOG.md CHANGED
@@ -26,6 +26,44 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.35.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.35.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.32.0`; `@jgengine/github` is `0.22.0`.
34
+
35
+ ### Added
36
+
37
+ - `ModelLodConfig.crowdCapacity` declares how many characters of one model a batch draws (default 128). A character past the cap keeps its own rig at its distance tier instead of disappearing.
38
+ - `ModelLodConfig.crowdDistance` accepts `0`, which batches a character at every distance instead of only past 25 m. A game with a near crowd or a close-quarters army sets it to batch members in front of the camera too.
39
+ - `characterRigTier` (`@jgengine/shell/render/characterLod`) resolves the distance tier a character poses its own rig on, which is the fallback for an over-cap crowd.
40
+ - `audio.zones` (`@jgengine/core/game/playableGame`): a scene declares world-space ambience zones, one looping bed per zone. The shell crossfades bed levels from the listener position on each sound's own bus, so entering, leaving and walking between zones hand over instead of cutting.
41
+ - `audio.occlusion` (`@jgengine/core/audio/occlusion`): budgeted geometry occlusion for retained positional loops. A blocked line attenuates the loop and darkens it through a low-pass; the per-tick test budget and interval bound the cost, and a loop that cannot be tested stays on distance-only.
42
+ - `SoundDef.duck`: a cue can lower named buses for its own duration. `AudioEngine.duck` returns a release, and the player's own gain is restored exactly even when they move the slider mid-duck.
43
+ - `AudioEngine.ambience()`, `AudioEngine.occlusionStats()` and `window.__jgengineAudio` report the zone mix, the newest occlusion test and the measured per-test cost for capture and diagnostics.
44
+
45
+ ### Changed
46
+
47
+ - The character batch path now covers every drawable character model, not only skinned locomotion rigs: a node-transform character (rotating limbs, no skin) rides the node a clip moves, and a model with no clips at all batches as a static geometry. Both still bake to one geometry per material, so a crowd draws once per model and material.
48
+ - Batched characters are pickable: the batch carries the entity ids of the instances it drew, so `worldHit().entity` and a click on a batched character resolve to the character instead of falling through to the ground.
49
+
50
+ ### Fixed
51
+
52
+ - Load-time shader warmup compiles what the first frame can reach, reveals as soon as those programs are linked, and finishes the rest in background rounds, so a scene no longer holds the loading cover for its whole model stream and no longer drops a compile onto a frame that is already showing. The loading cover names the shader count, the seconds waited and the work left for the background.
53
+ - A crowd batch silently dropped members past its fixed 128 instances while their markers still hid them, so a full scene lost characters. Members past the declared capacity now keep their own rigs.
54
+ - A crowd batch's clock now follows the game clock when one is bound, so a paused game no longer keeps its crowd walking.
55
+
56
+ ## 0.34.0
57
+
58
+ ### Migrate
59
+
60
+ - **Bump lockstep SDK packages to `^0.34.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.31.0`; `@jgengine/github` is `0.21.0`.
61
+
62
+ ### Added
63
+
64
+ - `PaceBand` on `DriverGoal` (`paceBand` + caller-measured `paceGap`) gives catch-up pacing an owner in `@jgengine/core/ai/driver`: a dead zone around `margin` (`tolerance`), `ease` shedding pace for a car ahead over `easeAt`, `push` adding it for a car behind over `pushAt`, and `scale` as the per-car share. `paceBandFactor(band, gap)` exposes the rule on its own. Deterministic scalar math, no allocation per step; the push never outdrives `DriverTuning.maxSpeed`. First adopter: Drift Foundry's rival field, which deletes its own `paceBand`; second: Harbor Heat's interceptors.
65
+ - `createRunLoop` (`@jgengine/core/run/runLoop`) holds a run's at-risk haul, banks it scaled by the line or loses it on a failure, and draws one authored change for each next run from a seeded table, so the same seed and run history replay the same sequence. State round-trips through `snapshot`/`restore`; the money stays in `progression/careerBank`.
66
+
29
67
  ## 0.33.0
30
68
 
31
69
  ### Migrate
@@ -28,7 +28,44 @@ export interface DriverGoal {
28
28
  stopAtGoal?: boolean;
29
29
  /** Cap on desired speed for this goal (a speed limit, a formation speed); default unlimited. */
30
30
  speedLimit?: number;
31
+ /**
32
+ * This car's catch-up pacing band, applied to the goal's pace. The caller measures {@link paceGap}
33
+ * (it owns the spatial query) and tunes the band; {@link paceBandFactor} is the rule.
34
+ */
35
+ paceBand?: PaceBand;
36
+ /** Signed gap to the band's reference: positive when this car is ahead of it; default `0`. */
37
+ paceGap?: number;
31
38
  }
39
+ /**
40
+ * Catch-up pacing for one car in a field: the gap it tries to hold to a reference car (a rival, the
41
+ * player, a convoy lead) and how its pace bends when it drifts off that gap. Past `margin` by
42
+ * `tolerance` it sheds up to `ease` of its pace; short of it by `tolerance` it gains up to `push`;
43
+ * inside that tolerance it leaves the car alone. The push never outdrives `DriverTuning.maxSpeed`,
44
+ * so a band changes how hard a car tries, never what it is built to do.
45
+ */
46
+ export interface PaceBand {
47
+ /** Gap to the reference this car aims to hold, world units; default `0` (level with it). */
48
+ margin?: number;
49
+ /** Gap error either side of `margin` the car leaves alone; default `0`. */
50
+ tolerance?: number;
51
+ /** Gap error past `tolerance` where the ease reaches full strength; default `0` (at once). */
52
+ easeAt?: number;
53
+ /** Fraction of the goal's pace shed at full ease; default `0`. */
54
+ ease?: number;
55
+ /** Gap error past `tolerance` where the push reaches full strength; default `0` (at once). */
56
+ pushAt?: number;
57
+ /** Fraction of the goal's pace added at full push; default `0`. */
58
+ push?: number;
59
+ /** Share of the band this car accepts, `0..1`; default `1`. */
60
+ scale?: number;
61
+ }
62
+ /**
63
+ * The pace multiplier a {@link PaceBand} asks for at `gap`: `1` inside the tolerance, `1 - ease *
64
+ * scale` fully ahead of it, `1 + push * scale` fully behind it, ramped linearly across `easeAt` /
65
+ * `pushAt` in between. Pure scalar math, so a band is deterministic and costs no allocation per
66
+ * step; the result never goes negative.
67
+ */
68
+ export declare function paceBandFactor(band: PaceBand, gap: number): number;
32
69
  /** Vehicle-feel numbers the game supplies; everything has a sensible default except `maxSpeed`. */
33
70
  export interface DriverTuning {
34
71
  /** Top desired speed on a straight, world units/second. */
@@ -86,8 +123,10 @@ export interface DriverStep extends AxisInput {
86
123
  * `speed * reactionSeconds`, so easy tiers brake at the last moment and expert tiers with proper
87
124
  * margin. The steering target is a delay-line over the goal — resampled every `reactionSeconds`,
88
125
  * so it trails a moving goal by up to that long, which is what makes an easy chaser visibly cut
89
- * toward where the target *was*. When throttle produces no motion for a tier-scaled while the
90
- * driver reverses out with counter-steer (brake past standstill — the shared sim's reverse).
126
+ * toward where the target *was*. A {@link PaceBand} on the goal eases a car ahead of its margin and
127
+ * pushes one behind it, bounded by the car's own top speed. When throttle produces no motion for a
128
+ * tier-scaled while the driver reverses out with counter-steer (brake past standstill — the shared
129
+ * sim's reverse).
91
130
  *
92
131
  * @capability ai-driver difficulty-aware chase/route driving step producing throttle/brake/steer for the vehicle sim
93
132
  */
package/dist/ai/driver.js CHANGED
@@ -1,4 +1,30 @@
1
1
  import { executionError } from "./difficulty.js";
2
+ /**
3
+ * The pace multiplier a {@link PaceBand} asks for at `gap`: `1` inside the tolerance, `1 - ease *
4
+ * scale` fully ahead of it, `1 + push * scale` fully behind it, ramped linearly across `easeAt` /
5
+ * `pushAt` in between. Pure scalar math, so a band is deterministic and costs no allocation per
6
+ * step; the result never goes negative.
7
+ */
8
+ export function paceBandFactor(band, gap) {
9
+ const scale = band.scale ?? 1;
10
+ if (scale <= 0)
11
+ return 1;
12
+ const error = gap - (band.margin ?? 0);
13
+ const tolerance = band.tolerance ?? 0;
14
+ if (error > tolerance) {
15
+ const ease = band.ease ?? 0;
16
+ if (ease <= 0)
17
+ return 1;
18
+ return Math.max(0, 1 - ease * scale * Math.min(1, (error - tolerance) / (band.easeAt ?? 0)));
19
+ }
20
+ if (error < -tolerance) {
21
+ const push = band.push ?? 0;
22
+ if (push <= 0)
23
+ return 1;
24
+ return Math.max(0, 1 + push * scale * Math.min(1, (-error - tolerance) / (band.pushAt ?? 0)));
25
+ }
26
+ return 1;
27
+ }
2
28
  /**
3
29
  * Fresh driver state committed to an initial target (usually the spawn-time goal or the spawn
4
30
  * position itself).
@@ -32,8 +58,10 @@ const clamp01 = (value) => (value < 0 ? 0 : value > 1 ? 1 : value);
32
58
  * `speed * reactionSeconds`, so easy tiers brake at the last moment and expert tiers with proper
33
59
  * margin. The steering target is a delay-line over the goal — resampled every `reactionSeconds`,
34
60
  * so it trails a moving goal by up to that long, which is what makes an easy chaser visibly cut
35
- * toward where the target *was*. When throttle produces no motion for a tier-scaled while the
36
- * driver reverses out with counter-steer (brake past standstill — the shared sim's reverse).
61
+ * toward where the target *was*. A {@link PaceBand} on the goal eases a car ahead of its margin and
62
+ * pushes one behind it, bounded by the car's own top speed. When throttle produces no motion for a
63
+ * tier-scaled while the driver reverses out with counter-steer (brake past standstill — the shared
64
+ * sim's reverse).
37
65
  *
38
66
  * @capability ai-driver difficulty-aware chase/route driving step producing throttle/brake/steer for the vehicle sim
39
67
  */
@@ -75,6 +103,9 @@ export function driveStep(state, dt, pose, goal, profile, tuning, rng, obstacleA
75
103
  const cornerSlowdown = tuning.cornerSlowdown ?? 0.75;
76
104
  const cornerFactor = 1 - cornerSlowdown * Math.min(1, Math.abs(headingError) / (Math.PI / 2));
77
105
  let desiredSpeed = Math.min(tuning.maxSpeed, goal.speedLimit ?? Number.POSITIVE_INFINITY);
106
+ if (goal.paceBand !== undefined) {
107
+ desiredSpeed = Math.min(tuning.maxSpeed, desiredSpeed * paceBandFactor(goal.paceBand, goal.paceGap ?? 0));
108
+ }
78
109
  desiredSpeed *= cornerFactor;
79
110
  if (goal.stopAtGoal === true) {
80
111
  // Ease in over the last stretch so the stop lands inside the radius instead of overshooting.
@@ -0,0 +1,47 @@
1
+ /** A world-space volume whose sound bed plays while the listener is inside it. */
2
+ export type AmbienceZoneShape = {
3
+ kind: "sphere";
4
+ center: readonly [number, number, number];
5
+ radius: number;
6
+ } | {
7
+ kind: "box";
8
+ min: readonly [number, number, number];
9
+ max: readonly [number, number, number];
10
+ };
11
+ /** One ambience zone: a sound bed keyed to a volume of the scene.
12
+ * @capability ambience-zones a scene declares looping sound beds keyed to world volumes; walking between them crossfades bed levels on the existing buses instead of cutting.
13
+ */
14
+ export interface AmbienceZoneDef {
15
+ /** Stable id; the shell retains one looping bed per zone for the engine's lifetime. */
16
+ id: string;
17
+ /** Sound id in the playable's `audio.sounds` catalog. The shell loops it regardless of the sound's `loop` flag. */
18
+ sound: string;
19
+ shape: AmbienceZoneShape;
20
+ /**
21
+ * World units over which the bed fades across the boundary, measured on both sides. Default 4.
22
+ * Two zones that share a boundary hand over their full level exactly across this band.
23
+ */
24
+ crossfade?: number;
25
+ /** Bed level multiplier, 0..1. Default 1. */
26
+ gain?: number;
27
+ }
28
+ /** One zone's share of the ambience mix at a listener position, 0..1. */
29
+ export interface AmbienceWeight {
30
+ id: string;
31
+ weight: number;
32
+ }
33
+ /**
34
+ * Distance from a point to a zone boundary, positive inside and negative outside.
35
+ * Spheres measure from the surface; boxes use the nearest face.
36
+ */
37
+ export declare function zoneSignedDistance(shape: AmbienceZoneShape, x: number, y: number, z: number): number;
38
+ /**
39
+ * Every zone's mix weight at a listener position. A zone is at full level `crossfade` units
40
+ * inside its boundary, at half level on the boundary and silent `crossfade` units outside, so a
41
+ * listener walking between two zones that share a boundary hears a constant-sum handover.
42
+ * Weights that would sum above 1 (overlapping zones) are normalized so total bed level never
43
+ * exceeds unity. Writes into `out` when given, so a per-frame caller allocates nothing.
44
+ */
45
+ export declare function ambienceWeights(zones: readonly AmbienceZoneDef[], x: number, y: number, z: number, out?: AmbienceWeight[]): AmbienceWeight[];
46
+ /** The loudest zone in a weight list, or null when every zone is silent. */
47
+ export declare function activeAmbienceZone(weights: readonly AmbienceWeight[]): string | null;
@@ -0,0 +1,75 @@
1
+ const DEFAULT_CROSSFADE = 4;
2
+ function clamp01(value) {
3
+ return value < 0 ? 0 : value > 1 ? 1 : value;
4
+ }
5
+ /**
6
+ * Distance from a point to a zone boundary, positive inside and negative outside.
7
+ * Spheres measure from the surface; boxes use the nearest face.
8
+ */
9
+ export function zoneSignedDistance(shape, x, y, z) {
10
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
11
+ return -Infinity;
12
+ if (shape.kind === "sphere") {
13
+ if (!(shape.radius > 0))
14
+ return -Infinity;
15
+ const dx = x - shape.center[0];
16
+ const dy = y - shape.center[1];
17
+ const dz = z - shape.center[2];
18
+ return shape.radius - Math.hypot(dx, dy, dz);
19
+ }
20
+ const min = shape.min;
21
+ const max = shape.max;
22
+ const values = [x, y, z];
23
+ let outsideSquared = 0;
24
+ let nearestFace = Infinity;
25
+ for (let axis = 0; axis < 3; axis += 1) {
26
+ const lo = min[axis];
27
+ const hi = max[axis];
28
+ const value = values[axis];
29
+ if (value < lo)
30
+ outsideSquared += (lo - value) * (lo - value);
31
+ else if (value > hi)
32
+ outsideSquared += (value - hi) * (value - hi);
33
+ else
34
+ nearestFace = Math.min(nearestFace, value - lo, hi - value);
35
+ }
36
+ if (outsideSquared > 0)
37
+ return -Math.sqrt(outsideSquared);
38
+ return Number.isFinite(nearestFace) ? nearestFace : 0;
39
+ }
40
+ /**
41
+ * Every zone's mix weight at a listener position. A zone is at full level `crossfade` units
42
+ * inside its boundary, at half level on the boundary and silent `crossfade` units outside, so a
43
+ * listener walking between two zones that share a boundary hears a constant-sum handover.
44
+ * Weights that would sum above 1 (overlapping zones) are normalized so total bed level never
45
+ * exceeds unity. Writes into `out` when given, so a per-frame caller allocates nothing.
46
+ */
47
+ export function ambienceWeights(zones, x, y, z, out = []) {
48
+ out.length = 0;
49
+ let total = 0;
50
+ for (const zone of zones) {
51
+ const crossfade = zone.crossfade ?? DEFAULT_CROSSFADE;
52
+ const signed = zoneSignedDistance(zone.shape, x, y, z);
53
+ if (!Number.isFinite(signed))
54
+ continue;
55
+ const weight = crossfade > 0 ? clamp01(0.5 + signed / (2 * crossfade)) : signed >= 0 ? 1 : 0;
56
+ if (weight <= 0)
57
+ continue;
58
+ out.push({ id: zone.id, weight });
59
+ total += weight;
60
+ }
61
+ if (total > 1) {
62
+ for (const entry of out)
63
+ entry.weight /= total;
64
+ }
65
+ return out;
66
+ }
67
+ /** The loudest zone in a weight list, or null when every zone is silent. */
68
+ export function activeAmbienceZone(weights) {
69
+ let best = null;
70
+ for (const entry of weights) {
71
+ if (best === null || entry.weight > best.weight)
72
+ best = entry;
73
+ }
74
+ return best !== null && best.weight > 0.01 ? best.id : null;
75
+ }
@@ -28,6 +28,18 @@ export interface SoundDef {
28
28
  maxVoices?: number;
29
29
  /** Playback priority; higher priorities survive voice-budget pressure. Default 0. */
30
30
  priority?: number;
31
+ /**
32
+ * Ducking policy for this cue: while it plays, the named buses drop so a hit, explosion or voice
33
+ * line cuts through the music and ambience, then return to exactly the player's own level.
34
+ */
35
+ duck?: {
36
+ /** Buses to lower. Default `["music"]`. */
37
+ buses?: readonly string[];
38
+ /** How far to lower them, 0..1 (0.6 leaves 40% of the player's level). Default 0.6. */
39
+ amount?: number;
40
+ /** Seconds to hold the duck. Default the cue's own length: synth patch duration or decoded sample length. */
41
+ seconds?: number;
42
+ };
31
43
  /** Positional emitters attenuate by distance from the listener; non-positional sounds (UI, music) play at flat gain. Default true. */
32
44
  positional?: boolean;
33
45
  falloff?: AudioFalloffConfig;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Geometry occlusion for retained positional loops: a cheap line test between the listener and an
3
+ * emitter behind blocking geometry, bounded so a busy scene can never turn audio into a raycast
4
+ * pass. The shell runs the test against the scene's blockable colliders; the engine applies the
5
+ * resulting attenuation and low-pass to the loop it already owns.
6
+ * @capability audio-occlusion an emitter behind blocking world geometry is attenuated and darkened by a budgeted line test, and falls back to distance-only once the tick's budget is spent.
7
+ */
8
+ export interface AudioOcclusionConfig {
9
+ /** Geometry tests shared by every retained loop in one listener tick. Default 8; 0 keeps distance-only. */
10
+ maxTestsPerTick?: number;
11
+ /** Minimum milliseconds before the same loop is tested again. Default 150. */
12
+ intervalMs?: number;
13
+ /** Gain multiplier at full blockage; 1 disables attenuation. Default 0.55. */
14
+ attenuation?: number;
15
+ /** Low-pass cutoff at full blockage in Hz. Default 1200; a loop already darker keeps its own cutoff. */
16
+ lowpassHz?: number;
17
+ /** Loops farther than this from the listener are never tested. Default 60. */
18
+ maxDistance?: number;
19
+ }
20
+ /** Defaults for {@link AudioOcclusionConfig}. */
21
+ export declare const AUDIO_OCCLUSION_DEFAULTS: {
22
+ readonly maxTestsPerTick: 8;
23
+ readonly intervalMs: 150;
24
+ readonly attenuation: 0.55;
25
+ readonly lowpassHz: 1200;
26
+ readonly maxDistance: 60;
27
+ };
28
+ /** A fully specified occlusion policy, with every unset field replaced by its default. */
29
+ export interface ResolvedAudioOcclusionConfig {
30
+ maxTestsPerTick: number;
31
+ intervalMs: number;
32
+ attenuation: number;
33
+ lowpassHz: number;
34
+ maxDistance: number;
35
+ }
36
+ /**
37
+ * Fill in the occlusion defaults and clamp each field to a workable range: a finite test budget,
38
+ * a non-negative retest interval and distance cap, and an attenuation in `0..1`.
39
+ */
40
+ export declare function resolveOcclusionConfig(config?: AudioOcclusionConfig | false): ResolvedAudioOcclusionConfig;
@@ -0,0 +1,27 @@
1
+ /** Defaults for {@link AudioOcclusionConfig}. */
2
+ export const AUDIO_OCCLUSION_DEFAULTS = {
3
+ maxTestsPerTick: 8,
4
+ intervalMs: 150,
5
+ attenuation: 0.55,
6
+ lowpassHz: 1200,
7
+ maxDistance: 60,
8
+ };
9
+ function finite(value, fallback) {
10
+ return value !== undefined && Number.isFinite(value) ? value : fallback;
11
+ }
12
+ /**
13
+ * Fill in the occlusion defaults and clamp each field to a workable range: a finite test budget,
14
+ * a non-negative retest interval and distance cap, and an attenuation in `0..1`.
15
+ */
16
+ export function resolveOcclusionConfig(config = {}) {
17
+ if (config === false) {
18
+ return { ...AUDIO_OCCLUSION_DEFAULTS, maxTestsPerTick: 0 };
19
+ }
20
+ return {
21
+ maxTestsPerTick: Math.max(0, Math.floor(finite(config.maxTestsPerTick, AUDIO_OCCLUSION_DEFAULTS.maxTestsPerTick))),
22
+ intervalMs: Math.max(0, finite(config.intervalMs, AUDIO_OCCLUSION_DEFAULTS.intervalMs)),
23
+ attenuation: Math.min(1, Math.max(0, finite(config.attenuation, AUDIO_OCCLUSION_DEFAULTS.attenuation))),
24
+ lowpassHz: Math.max(10, finite(config.lowpassHz, AUDIO_OCCLUSION_DEFAULTS.lowpassHz)),
25
+ maxDistance: Math.max(0, finite(config.maxDistance, AUDIO_OCCLUSION_DEFAULTS.maxDistance)),
26
+ };
27
+ }
@@ -1,5 +1,7 @@
1
1
  import type { AnimGraph } from "../anim/animGraph.js";
2
+ import type { AmbienceZoneDef } from "../audio/ambience.js";
2
3
  import type { AudioBusDef, SoundDef } from "../audio/audioFalloff.js";
4
+ import type { AudioOcclusionConfig } from "../audio/occlusion.js";
3
5
  import type { MusicTheme } from "../audio/music.js";
4
6
  import type { UiCueOverrides } from "../audio/uiCues.js";
5
7
  import type { FeedbackCueOverrides } from "../audio/feedbackCues.js";
@@ -315,10 +317,19 @@ export interface ModelLodConfig {
315
317
  fullRateDistance?: number;
316
318
  /** Within this distance the rig poses every other frame, carrying the accumulated time. Default 40. */
317
319
  halfRateDistance?: number;
318
- /** Beyond this distance a crowd-eligible rig renders through the GPU crowd path and updates no bones. Default 25. */
320
+ /**
321
+ * At or beyond this distance a crowd-eligible character renders through the GPU batch path and
322
+ * updates no bones. Default 25. `0` batches it at every distance, which is what a crowd or an army
323
+ * wants: near members then draw from the same instance buffer instead of one rig each.
324
+ */
319
325
  crowdDistance?: number;
320
326
  /** Metres a character must come back inside `crowdDistance` before it returns to its own rig, so the swap cannot flicker at the boundary. Default 2. */
321
327
  crowdHysteresis?: number;
328
+ /**
329
+ * Characters of this model one batch can draw. A character past the cap keeps its own rig, so a
330
+ * crowded scene loses draws rather than characters. Default 128.
331
+ */
332
+ crowdCapacity?: number;
322
333
  }
323
334
  /** Wind sway for a foliage model; see {@link ModelConfig.wind}. */
324
335
  export interface ModelWindConfig {
@@ -789,6 +800,15 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
789
800
  uiCues?: UiCueOverrides;
790
801
  /** Default gameplay reactions (`feel.hit`, `feel.kill`, `feel.pickup`, ...) on an "sfx" bus, played by the shell from the events the engine announces. A sound named `feel.<cue>` in `sounds` replaces one; a map entry replaces or (`false`) silences one; `false` silences all. */
791
802
  feedbackCues?: FeedbackCueOverrides;
803
+ /**
804
+ * World-space ambience zones. While the listener is inside a zone the shell loops its bed on
805
+ * the sound's own bus; walking between zones hands over across each floor's `crossfade`
806
+ * distance, and leaving every zone fades to silence. Beds are retained, so a zone you return to
807
+ * resumes without a seam.
808
+ */
809
+ zones?: readonly AmbienceZoneDef[];
810
+ /** Geometry occlusion for retained positional loops. Unset uses the engine defaults; `false` keeps distance-only attenuation everywhere. */
811
+ occlusion?: AudioOcclusionConfig | false;
792
812
  };
793
813
  /** Continuous positional emitter keyed by entity kind name: while a matching entity exists, the shell plays and repositions the mapped `audio.sounds` id (looping engine hum, campfire crackle, footstep loop) with listener-distance falloff. */
794
814
  entitySounds?: Record<string, string>;
@@ -1,5 +1,5 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export declare const VERSION = "0.33.0";
2
+ export declare const VERSION = "0.35.0";
3
3
  /** One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). */
4
4
  export interface ChangelogEntry {
5
5
  migrate: readonly string[];
@@ -1,7 +1,40 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export const VERSION = "0.33.0";
2
+ export const VERSION = "0.35.0";
3
3
  /** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
4
4
  export const CHANGELOG = {
5
+ "0.35.0": {
6
+ migrate: [
7
+ "Bump lockstep SDK packages to `^0.35.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.32.0`; `@jgengine/github` is `0.22.0`.",
8
+ ],
9
+ added: [
10
+ "`ModelLodConfig.crowdCapacity` declares how many characters of one model a batch draws (default 128). A character past the cap keeps its own rig at its distance tier instead of disappearing.",
11
+ "`ModelLodConfig.crowdDistance` accepts `0`, which batches a character at every distance instead of only past 25 m. A game with a near crowd or a close-quarters army sets it to batch members in front of the camera too.",
12
+ "`characterRigTier` (`@jgengine/shell/render/characterLod`) resolves the distance tier a character poses its own rig on, which is the fallback for an over-cap crowd.",
13
+ "`audio.zones` (`@jgengine/core/game/playableGame`): a scene declares world-space ambience zones, one looping bed per zone. The shell crossfades bed levels from the listener position on each sound's own bus, so entering, leaving and walking between zones hand over instead of cutting.",
14
+ "`audio.occlusion` (`@jgengine/core/audio/occlusion`): budgeted geometry occlusion for retained positional loops. A blocked line attenuates the loop and darkens it through a low-pass; the per-tick test budget and interval bound the cost, and a loop that cannot be tested stays on distance-only.",
15
+ "`SoundDef.duck`: a cue can lower named buses for its own duration. `AudioEngine.duck` returns a release, and the player's own gain is restored exactly even when they move the slider mid-duck.",
16
+ "`AudioEngine.ambience()`, `AudioEngine.occlusionStats()` and `window.__jgengineAudio` report the zone mix, the newest occlusion test and the measured per-test cost for capture and diagnostics.",
17
+ ],
18
+ changed: [
19
+ "The character batch path now covers every drawable character model, not only skinned locomotion rigs: a node-transform character (rotating limbs, no skin) rides the node a clip moves, and a model with no clips at all batches as a static geometry. Both still bake to one geometry per material, so a crowd draws once per model and material.",
20
+ "Batched characters are pickable: the batch carries the entity ids of the instances it drew, so `worldHit().entity` and a click on a batched character resolve to the character instead of falling through to the ground.",
21
+ "Load-time shader warmup compiles what the first frame can reach, reveals as soon as those programs are linked, and finishes the rest in background rounds, so a scene no longer holds the loading cover for its whole model stream and no longer drops a compile onto a frame that is already showing. The loading cover names the shader count, the seconds waited and the work left for the background.",
22
+ "A crowd batch silently dropped members past its fixed 128 instances while their markers still hid them, so a full scene lost characters. Members past the declared capacity now keep their own rigs.",
23
+ "A crowd batch's clock now follows the game clock when one is bound, so a paused game no longer keeps its crowd walking.",
24
+ ],
25
+ removed: [],
26
+ },
27
+ "0.34.0": {
28
+ migrate: [
29
+ "Bump lockstep SDK packages to `^0.34.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.31.0`; `@jgengine/github` is `0.21.0`.",
30
+ ],
31
+ added: [
32
+ "`PaceBand` on `DriverGoal` (`paceBand` + caller-measured `paceGap`) gives catch-up pacing an owner in `@jgengine/core/ai/driver`: a dead zone around `margin` (`tolerance`), `ease` shedding pace for a car ahead over `easeAt`, `push` adding it for a car behind over `pushAt`, and `scale` as the per-car share. `paceBandFactor(band, gap)` exposes the rule on its own. Deterministic scalar math, no allocation per step; the push never outdrives `DriverTuning.maxSpeed`. First adopter: Drift Foundry's rival field, which deletes its own `paceBand`; second: Harbor Heat's interceptors.",
33
+ "`createRunLoop` (`@jgengine/core/run/runLoop`) holds a run's at-risk haul, banks it scaled by the line or loses it on a failure, and draws one authored change for each next run from a seeded table, so the same seed and run history replay the same sequence. State round-trips through `snapshot`/`restore`; the money stays in `progression/careerBank`.",
34
+ ],
35
+ changed: [],
36
+ removed: [],
37
+ },
5
38
  "0.33.0": {
6
39
  migrate: [
7
40
  "Bump lockstep SDK packages to `^0.33.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.30.0`; `@jgengine/github` is `0.20.0`.",
@@ -0,0 +1,88 @@
1
+ /** How a run ended: what it carried reached the bank, or it was lost. */
2
+ export type RunOutcome = "banked" | "lost";
3
+ /**
4
+ * One change the next run can open with. `T` is the game's own payload (a tighter rival line, an
5
+ * unlocked part, a route modifier) - the loop only draws and remembers which id was drawn.
6
+ */
7
+ export interface RunChange<T> {
8
+ id: string;
9
+ value: T;
10
+ /** Relative draw weight (default `1`); a non-positive weight is never drawn. */
11
+ weight?: number;
12
+ /** Completed runs this change needs before it can be drawn (default `0`). */
13
+ after?: number;
14
+ }
15
+ /** The change a run opens with, and which run it is. */
16
+ export interface RunOffer<T> {
17
+ /** 1-based run number: `1` is the first run, before anything has been banked. */
18
+ run: number;
19
+ changeId: string | null;
20
+ change: T | null;
21
+ }
22
+ /** Everything a run loop stores as plain JSON: the run in progress plus lifetime totals. */
23
+ export interface RunLoopState {
24
+ /** The run in progress, or `0` before the first `start`. */
25
+ run: number;
26
+ /** Runs settled so far; also the number `after` gates are read against. */
27
+ completed: number;
28
+ /** What the run in progress carries and can still lose. */
29
+ haul: number;
30
+ /** Change drawn for the run in progress, or `null`. */
31
+ changeId: string | null;
32
+ bankedTotal: number;
33
+ lostTotal: number;
34
+ bestHaul: number;
35
+ }
36
+ /** What one closed run banked or lost, and what the next run changes. */
37
+ export interface RunSettlement<T> {
38
+ outcome: RunOutcome;
39
+ /** What the run carried before the multiplier. */
40
+ haul: number;
41
+ /** What reached the bank: the haul after the line multiplier on a bank, `0` on a loss. */
42
+ banked: number;
43
+ /** What the run lost: the whole haul on a loss, `0` on a bank. */
44
+ lost: number;
45
+ completed: number;
46
+ bestHaul: number;
47
+ /** The next run's change, drawn here so a results screen can name it. */
48
+ next: RunOffer<T>;
49
+ }
50
+ /** Configuration for {@link createRunLoop}. */
51
+ export interface RunLoopConfig<T> {
52
+ /** Seed for the run-to-run draw: the same seed and history replay the same sequence. */
53
+ seed: string | number;
54
+ /** Authored table of next-run changes; omit a table for a loop that never varies. */
55
+ changes?: readonly RunChange<T>[];
56
+ }
57
+ /**
58
+ * A run's lifecycle: what it carries and can lose, and the change it hands the next run.
59
+ */
60
+ export interface RunLoop<T> {
61
+ /** Open the next run and hand back its offer. Calling it on an open run returns that run's offer and keeps its haul. */
62
+ start(): RunOffer<T>;
63
+ /** Add to what the run in progress carries; negative or non-finite amounts are ignored. Returns the haul. */
64
+ carry(amount: number): number;
65
+ /** What the run in progress carries. */
66
+ haul(): number;
67
+ /** The run in progress's offer, or the offer the next `start` would open. */
68
+ offer(): RunOffer<T>;
69
+ /**
70
+ * Close the run in progress: bank the haul scaled by `multiplier`, or lose it. Throws when no run
71
+ * is open, so a double settle cannot bank the same haul twice.
72
+ */
73
+ settle(outcome: RunOutcome, multiplier?: number): RunSettlement<T>;
74
+ snapshot(): RunLoopState;
75
+ restore(next: RunLoopState): void;
76
+ }
77
+ /**
78
+ * Bank-or-lose between runs, with one deterministic change per run drawn from an authored table.
79
+ *
80
+ * A run's haul banks when it ends in a bank and is lost when it ends in a loss; the money itself stays
81
+ * with the game (pair it with `progression/careerBank`). After each settled run the loop draws the next
82
+ * run's change from `changes`: eligible when `after <= completed`, never the change just played unless
83
+ * it is the only one eligible, weighted by `weight`. The draw is `seededRng(seed:run:N)`, so the same
84
+ * seed and the same run history give the same sequence, before or after a save/load.
85
+ *
86
+ * @capability run-loop bank or lose what a run carries, with one deterministic change per run
87
+ */
88
+ export declare function createRunLoop<T>(config: RunLoopConfig<T>): RunLoop<T>;
@@ -0,0 +1,92 @@
1
+ import { pickWeighted } from "../random/pick.js";
2
+ import { seededRng } from "../random/rng.js";
3
+ function positive(value) {
4
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
5
+ }
6
+ /**
7
+ * Bank-or-lose between runs, with one deterministic change per run drawn from an authored table.
8
+ *
9
+ * A run's haul banks when it ends in a bank and is lost when it ends in a loss; the money itself stays
10
+ * with the game (pair it with `progression/careerBank`). After each settled run the loop draws the next
11
+ * run's change from `changes`: eligible when `after <= completed`, never the change just played unless
12
+ * it is the only one eligible, weighted by `weight`. The draw is `seededRng(seed:run:N)`, so the same
13
+ * seed and the same run history give the same sequence, before or after a save/load.
14
+ *
15
+ * @capability run-loop bank or lose what a run carries, with one deterministic change per run
16
+ */
17
+ export function createRunLoop(config) {
18
+ const table = config.changes ?? [];
19
+ const known = new Set();
20
+ for (const change of table) {
21
+ if (known.has(change.id))
22
+ throw new Error(`run change "${change.id}" is declared twice`);
23
+ known.add(change.id);
24
+ }
25
+ let run = 0;
26
+ let completed = 0;
27
+ let haul = 0;
28
+ let changeId = null;
29
+ let bankedTotal = 0;
30
+ let lostTotal = 0;
31
+ let bestHaul = 0;
32
+ function drawFor(index, previous) {
33
+ const eligible = table.filter((change) => (change.after ?? 0) <= index - 1);
34
+ const fresh = eligible.filter((change) => change.id !== previous);
35
+ // A one-entry table still offers its change rather than nothing.
36
+ const pool = fresh.length > 0 ? fresh : eligible;
37
+ const rng = seededRng(`${config.seed}:run:${index}`);
38
+ const drawn = pickWeighted(rng, pool, (change) => change.weight ?? 1) ?? null;
39
+ return { run: index, changeId: drawn?.id ?? null, change: drawn?.value ?? null };
40
+ }
41
+ function offer() {
42
+ // An open run's change was adopted at `start`, so the offer repeats it instead of redrawing.
43
+ if (run > completed) {
44
+ const adopted = table.find((change) => change.id === changeId);
45
+ return { run, changeId, change: adopted?.value ?? null };
46
+ }
47
+ return drawFor(completed + 1, changeId);
48
+ }
49
+ return {
50
+ start() {
51
+ if (run > completed)
52
+ return offer();
53
+ const next = offer();
54
+ run = next.run;
55
+ haul = 0;
56
+ changeId = next.changeId;
57
+ return next;
58
+ },
59
+ carry(amount) {
60
+ haul += positive(amount);
61
+ return haul;
62
+ },
63
+ haul: () => haul,
64
+ offer,
65
+ settle(outcome, multiplier) {
66
+ if (run <= completed)
67
+ throw new Error("run loop: no run is open to settle");
68
+ const settledHaul = haul;
69
+ const scale = Number.isFinite(multiplier) ? Math.max(0, multiplier ?? 1) : 1;
70
+ const banked = outcome === "banked" ? Math.round(settledHaul * scale) : 0;
71
+ const lost = outcome === "lost" ? settledHaul : 0;
72
+ bankedTotal += banked;
73
+ lostTotal += lost;
74
+ bestHaul = Math.max(bestHaul, banked);
75
+ completed = run;
76
+ haul = 0;
77
+ return { outcome, haul: settledHaul, banked, lost, completed, bestHaul, next: drawFor(completed + 1, changeId) };
78
+ },
79
+ snapshot: () => ({ run, completed, haul, changeId, bankedTotal, lostTotal, bestHaul }),
80
+ restore(next) {
81
+ run = Math.max(0, Math.floor(next.run));
82
+ completed = Math.max(0, Math.floor(next.completed));
83
+ if (run < completed)
84
+ run = completed;
85
+ haul = positive(next.haul);
86
+ changeId = next.changeId !== null && known.has(next.changeId) ? next.changeId : null;
87
+ bankedTotal = positive(next.bankedTotal);
88
+ lostTotal = positive(next.lostTotal);
89
+ bestHaul = positive(next.bestHaul);
90
+ },
91
+ };
92
+ }