@jgengine/core 0.34.0 → 0.36.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,5 +1,5 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export declare const VERSION = "0.34.0";
2
+ export declare const VERSION = "0.36.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,12 +1,54 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export const VERSION = "0.34.0";
2
+ export const VERSION = "0.36.0";
3
3
  /** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
4
4
  export const CHANGELOG = {
5
+ "0.36.0": {
6
+ migrate: [
7
+ "Bump lockstep SDK packages to `^0.36.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.33.0`; `@jgengine/github` is `0.23.0`.",
8
+ ],
9
+ added: [
10
+ "`createAgentSystem` (`@jgengine/core/agents/system`) takes a game's needs, options, mood weights, members and schedules once and returns a running cast: one `tick(dt)` for everyone, one target selector for the whole system, a fallback declared once, and a plain-JSON `save()`/`load()` that rides the game's own save.",
11
+ "`--quick` on `bun run shoot` and `bun run drive` is the 960x540 iteration preset: one settled frame (a 2s settle window in drive), no video, no perf pass, written as `<name>-quick.png` and timed on its own line. It refuses a 1600x900 pixel claim, a frame-time claim, a non-desktop device and a recording flag by name instead of producing weaker evidence; full stays the default for PR shots. On a warm daemon a two-shot `drive` of ember-command went 9.3s to 8.2s and 4316KB to 1383KB of PNG.",
12
+ "`createPlayScript` / `runPlayScript` (`@jgengine/shell/play`) play a real `PlayableGame` headlessly from a tick-based script: `move`, `aim`, `fire`, `use`, `wait` and `command` steps plus `setup`/`onTick` hooks, `expect` assertions read after setup and on their own tick, a tick timeline, and an FNV digest for replay comparison. It replays the shell's own frame pipeline — held movement through the walk controller, a bound action resolving to its command on press and repeat, interact prompts, hotbar — so a script proves the same code path a player's hands do. No browser, no wall clock, fixed `dt`.",
13
+ "`bun run simulate <game> <script>` runs a committed `<game>/scripts/*.play.ts` against a game under `./Games`, prints the timeline, names a failed assertion and exits 1. It merges the game's own tsconfig `paths` with this checkout's source packages, so the game imports and the engine under test are one module graph.",
14
+ "`HeadlessRunner.aim()` steers the walk controller mid-run; `HeadlessRunnerOptions` gains `seed` (deterministic `ctx.rng`) and `collision` (the shell's voxel config, previously dropped on the headless path).",
15
+ ],
16
+ changed: [
17
+ "The graphics quality tier now costs what it claims on the render path. A tier's post chain renders at its own resolution scale (`low` 0.7, `medium` 0.85, `high`/`ultra` native) instead of always at the canvas size, and a game's authored `shadowMapSize` and `cascades` are a ceiling the tier caps rather than a value it ignored — every reference game authored 3 cascades and 1024/2048, so no tier changed a shadow before this. `low` and `medium` also stretch the far cascades' refresh interval, and the smallest caster worth a depth draw grows as the map shrinks. `high` and `ultra` keep native resolution, the authored shadow sizes and the measured cadence, so their frames are unchanged.",
18
+ "Measured uncapped at 1600x900: whole-frame draws at `low` fell 53% in harbor-heat (515 → 242) and 63% in drift-foundry (1,098 → 408), with the shadow pass there down from 911 ms to 344 ms over a 6 s window; `high` and `ultra` held their draw counts.",
19
+ "Terrain build no longer scans every road and path segment for every vertex. A baked path corridor, a draped road and a painted polyline region each answer \"how far is this point?\" from a precomputed segment grid, so Scrap Signal's world build drops from 16.6 s to 0.51 s on the same machine and its longest boot stall from 11.6–18.4 s to 1.2 s. Sampled heights and palettes are bit-identical to the previous full scan, and `withPathProfiles` still composes profiles in order.",
20
+ "Load-time shader warmup compiles against the render state the first frame actually draws with. A scene whose post chain renders into its own buffer was linking two programs per material, one linear untone-mapped set the frame draws and one canvas set nothing ever drew; the cover no longer waits for the second set. Harbor Heat links 84 programs to 58 and Scrap Signal 124 to 83 before the cover lifts, with no visual change.",
21
+ ],
22
+ removed: [],
23
+ },
24
+ "0.35.0": {
25
+ migrate: [
26
+ "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`.",
27
+ ],
28
+ added: [
29
+ "`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.",
30
+ "`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.",
31
+ "`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.",
32
+ "`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.",
33
+ "`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.",
34
+ "`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.",
35
+ "`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.",
36
+ ],
37
+ changed: [
38
+ "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.",
39
+ "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.",
40
+ "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.",
41
+ "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.",
42
+ "A crowd batch's clock now follows the game clock when one is bound, so a paused game no longer keeps its crowd walking.",
43
+ ],
44
+ removed: [],
45
+ },
5
46
  "0.34.0": {
6
47
  migrate: [
7
48
  "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`.",
8
49
  ],
9
50
  added: [
51
+ "`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.",
10
52
  "`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`.",
11
53
  ],
12
54
  changed: [],
@@ -26,6 +26,8 @@ export interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultipl
26
26
  };
27
27
  /** Wall-clock source for the sim clock — inject a deterministic counter for reproducible runs. */
28
28
  now?: () => number;
29
+ /** Seed for `ctx.rng` — pass one so a recorded run and its replay draw the same numbers. Defaults to the definition's name. */
30
+ seed?: string | number;
29
31
  /** Per-step real-dt clamp in seconds, matching the shell's frame cap so one long stall can't fast-forward the sim. Default `0.05`. */
30
32
  maxStepSeconds?: number;
31
33
  /**
@@ -36,7 +38,9 @@ export interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultipl
36
38
  playerMovement?: boolean;
37
39
  /** Movement tuning — when `playerMovement` is on, the shell resolves from `definition.world`/`physics`; pass a direct config to test flight or other movement overrides headlessly. */
38
40
  movement?: import("../game/playableGame.js").PlayerMovementConfig;
39
- /** Yaw heading (radians) fed to the movement controller when `playerMovement` is on; the shell supplies its camera yaw. */
41
+ /** Voxel collision config (`defineGame({ collision })`) — the shell's walk controller reads it, so a headless caller passes the same object to walk the same geometry. */
42
+ collision?: import("../game/playableGame.js").VoxelCollisionConfig;
43
+ /** Yaw heading (radians) fed to the movement controller when `playerMovement` is on; the shell supplies its camera yaw. Change it mid-run with {@link HeadlessRunner.aim}. */
40
44
  heading?: number;
41
45
  /** Pitch (radians) fed alongside yaw for 6DOF spectator flight when `alignWithLook` is set. */
42
46
  pitch?: number;
@@ -78,6 +82,11 @@ export interface HeadlessRunner {
78
82
  publishInput(input: HeadlessInput): void;
79
83
  /** Drive the game's own UI commands / intents headlessly — the same registry `ctx.game.commands` and the shell's command sink dispatch into. No renderer, no pointer simulation: dispatch an intent, then read reactive state off `ctx`. */
80
84
  readonly ui: HeadlessCommandInterface;
85
+ /** Point the movement controller at a yaw/pitch mid-run — what the shell does when the camera turns, so a scripted run can walk one way and then another. */
86
+ aim(look: {
87
+ heading?: number;
88
+ pitch?: number;
89
+ }): void;
81
90
  /** Capture the whole simulation (world, clock, loop, rng cursor) for {@link restore}; with a fixed `simulation.hz` and recorded input, replaying from it reproduces the run exactly. */
82
91
  snapshot(): SimSnapshot;
83
92
  restore(snapshot: SimSnapshot): void;
@@ -12,13 +12,16 @@ export function createHeadlessRunner(options) {
12
12
  content,
13
13
  player,
14
14
  ...(options.now === undefined ? {} : { now: options.now }),
15
+ ...(options.seed === undefined ? {} : { seed: options.seed }),
15
16
  ...(options.models === undefined ? {} : { models: options.models }),
16
17
  });
17
18
  loop?.onInit?.(ctx);
18
19
  loop?.onNewPlayer?.(ctx);
19
20
  const simSnapshot = createContextSimSnapshot(ctx);
21
+ const look = { heading: options.heading ?? 0, pitch: options.pitch ?? 0 };
20
22
  const tuning = options.playerMovement === true
21
23
  ? { ...resolvePlayerMovementTuning({
24
+ collision: options.collision,
22
25
  physics: definition.physics,
23
26
  world: definition.world,
24
27
  movement: options.movement,
@@ -48,6 +51,12 @@ export function createHeadlessRunner(options) {
48
51
  names: () => ctx.game.commands.names(),
49
52
  has: (name) => ctx.game.commands.has(name),
50
53
  },
54
+ aim(next) {
55
+ if (next.heading !== undefined && Number.isFinite(next.heading))
56
+ look.heading = next.heading;
57
+ if (next.pitch !== undefined && Number.isFinite(next.pitch))
58
+ look.pitch = next.pitch;
59
+ },
51
60
  step(dtSeconds, input) {
52
61
  if (input !== undefined)
53
62
  publishInput(input);
@@ -58,7 +67,7 @@ export function createHeadlessRunner(options) {
58
67
  total += gameDt;
59
68
  ctx.sim.runStages("beforeMovement", stepDt);
60
69
  if (tuning !== null && gameDt > 0) {
61
- stepPlayerMovement(ctx, player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer() }, gameDt, tuning, options.heading, options.pitch);
70
+ stepPlayerMovement(ctx, player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer() }, gameDt, tuning, look.heading, look.pitch);
62
71
  }
63
72
  ctx.sim.runStages("afterMovement", stepDt);
64
73
  loop?.onTick?.(ctx, gameDt);
@@ -1,3 +1,4 @@
1
+ import { buildPolylineIndex } from "./polylineIndex.js";
1
2
  function clamp01(value) {
2
3
  return value < 0 ? 0 : value > 1 ? 1 : value;
3
4
  }
@@ -15,34 +16,28 @@ function buildCorridor(points) {
15
16
  const b = points[i];
16
17
  cumulative.push(cumulative[i - 1] + Math.hypot(b[0] - a[0], b[1] - a[1]));
17
18
  }
18
- return { points, cumulative, total: cumulative[cumulative.length - 1] ?? 0 };
19
+ return { points, cumulative, total: cumulative[cumulative.length - 1] ?? 0, index: buildPolylineIndex(points) };
19
20
  }
20
21
  /**
21
- * Closest point on the corridor to `(x, z)`: unsigned lateral distance and arc-length position. Self-
22
- * contained (each segment projects independently and the minimum lateral wins) so a vertex always reports
23
- * a near-zero lateral, regardless of which side of the line the neighbours fall.
22
+ * Closest point on the corridor to `(x, z)` within `limit`: unsigned lateral distance and arc-length
23
+ * position. Self-contained (each segment projects independently and the minimum lateral wins) so a
24
+ * vertex always reports a near-zero lateral, regardless of which side of the line the neighbours fall.
25
+ * Beyond `limit` the corridor cannot change the sample, so the answer is `null`.
24
26
  */
25
- function nearestOnCorridor(corridor, x, z) {
26
- let bestLateral = Number.POSITIVE_INFINITY;
27
- let bestAlong = 0;
28
- let bestPoint = corridor.points[0] ?? [x, z];
29
- for (let i = 1; i < corridor.points.length; i += 1) {
30
- const a = corridor.points[i - 1];
31
- const b = corridor.points[i];
32
- const abx = b[0] - a[0];
33
- const abz = b[1] - a[1];
34
- const segLenSq = abx * abx + abz * abz;
35
- const t = segLenSq <= 0 ? 0 : Math.max(0, Math.min(1, ((x - a[0]) * abx + (z - a[1]) * abz) / segLenSq));
36
- const px = a[0] + abx * t;
37
- const pz = a[1] + abz * t;
38
- const lateral = Math.hypot(x - px, z - pz);
39
- if (lateral < bestLateral) {
40
- bestLateral = lateral;
41
- bestAlong = corridor.cumulative[i - 1] + t * Math.sqrt(segLenSq);
42
- bestPoint = [px, pz];
43
- }
44
- }
45
- return { lateral: bestLateral, along: bestAlong, point: bestPoint };
27
+ function nearestOnCorridor(corridor, x, z, limit) {
28
+ const hit = corridor.index.nearest(x, z, limit);
29
+ if (hit === null)
30
+ return null;
31
+ const a = corridor.points[hit.segment];
32
+ const b = corridor.points[hit.segment + 1];
33
+ const abx = b[0] - a[0];
34
+ const abz = b[1] - a[1];
35
+ const segLenSq = abx * abx + abz * abz;
36
+ return {
37
+ lateral: hit.distance,
38
+ along: corridor.cumulative[hit.segment] + hit.t * Math.sqrt(segLenSq),
39
+ point: hit.point,
40
+ };
46
41
  }
47
42
  function resolveProfile(profile) {
48
43
  if (profile.points.length < 2 || profile.width <= 0)
@@ -94,10 +89,10 @@ function centerlineTarget(profile, along, point, base) {
94
89
  return base(point[0], point[1]);
95
90
  }
96
91
  function applyProfileAt(profile, x, z, surrounding, base) {
97
- const hit = nearestOnCorridor(profile.corridor, x, z);
98
- const lateral = hit.lateral;
99
- if (lateral > profile.reach)
92
+ const hit = nearestOnCorridor(profile.corridor, x, z, profile.reach);
93
+ if (hit === null)
100
94
  return surrounding;
95
+ const lateral = hit.lateral;
101
96
  const target = clampCutFill(centerlineTarget(profile, hit.along, hit.point, base), surrounding, profile.maxCut, profile.maxFill);
102
97
  // Inside the core: carve a channel below the target, deepest at the centerline, level at the edge.
103
98
  if (lateral <= profile.half) {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Nearest-point queries against a polyline within a distance bound. The corridor sampler, road
3
+ * draping and the palette's polyline regions ask "is this vertex near that line?" for every vertex of
4
+ * a terrain mesh, so a linear scan of every segment per vertex turned one authored road into seconds
5
+ * of boot time. This keeps the exact same arithmetic as a full scan while visiting only the segments
6
+ * that can possibly win: a query returns the same segment, the same `t` and the same distance a scan
7
+ * would, and `null` only when the whole polyline is farther away than the bound.
8
+ * @internal
9
+ */
10
+ import type { Vec2 } from "./geometry.js";
11
+ /** Where the polyline comes closest to the query, within the query's bound. @internal */
12
+ export interface PolylineHit {
13
+ /** Index of the winning segment, i.e. the one from `points[segment]` to `points[segment + 1]`. */
14
+ segment: number;
15
+ /** Position along that segment, 0..1. */
16
+ t: number;
17
+ /** Distance from the query to the projected point. */
18
+ distance: number;
19
+ /** Projected point on the polyline. */
20
+ point: Vec2;
21
+ }
22
+ /** @internal */
23
+ export interface PolylineIndex {
24
+ /** Exact nearest segment sample within `limit`, or null when the polyline stays farther away. */
25
+ nearest(x: number, z: number, limit: number): PolylineHit | null;
26
+ /** Segments tested by the most recent query — the bounded-work invariant tests assert on. */
27
+ examined(): number;
28
+ }
29
+ /** @internal */
30
+ export declare function buildPolylineIndex(points: readonly Vec2[]): PolylineIndex;
@@ -0,0 +1,80 @@
1
+ import { buildSpatialBuckets } from "./spatialBuckets.js";
2
+ /** @internal */
3
+ export function buildPolylineIndex(points) {
4
+ const count = Math.max(0, points.length - 1);
5
+ const ax = new Float64Array(count);
6
+ const az = new Float64Array(count);
7
+ const abx = new Float64Array(count);
8
+ const abz = new Float64Array(count);
9
+ const lengthSq = new Float64Array(count);
10
+ let minX = Number.POSITIVE_INFINITY;
11
+ let minZ = Number.POSITIVE_INFINITY;
12
+ let maxX = Number.NEGATIVE_INFINITY;
13
+ let maxZ = Number.NEGATIVE_INFINITY;
14
+ for (let i = 0; i < count; i += 1) {
15
+ const a = points[i];
16
+ const b = points[i + 1];
17
+ ax[i] = a[0];
18
+ az[i] = a[1];
19
+ abx[i] = b[0] - a[0];
20
+ abz[i] = b[1] - a[1];
21
+ lengthSq[i] = abx[i] * abx[i] + abz[i] * abz[i];
22
+ minX = Math.min(minX, a[0], b[0]);
23
+ maxX = Math.max(maxX, a[0], b[0]);
24
+ minZ = Math.min(minZ, a[1], b[1]);
25
+ maxZ = Math.max(maxZ, a[1], b[1]);
26
+ }
27
+ const span = count === 0 ? 1 : Math.max(maxX - minX, maxZ - minZ, 1);
28
+ // Twice the average segment length keeps a typical reach inside one or two cells, so a query tests a
29
+ // handful of segments; the 256-cell cap bounds memory on a very long, very dense polyline.
30
+ const buckets = buildSpatialBuckets(Math.max(1, span / 256, (span / Math.max(1, count)) * 2));
31
+ for (let i = 0; i < count; i += 1) {
32
+ buckets.insert(i, Math.min(ax[i], ax[i] + abx[i]), Math.min(az[i], az[i] + abz[i]), Math.max(ax[i], ax[i] + abx[i]), Math.max(az[i], az[i] + abz[i]));
33
+ }
34
+ const candidates = [];
35
+ let examined = 0;
36
+ return {
37
+ nearest(x, z, limit) {
38
+ if (count === 0 || !(limit >= 0)) {
39
+ examined = 0;
40
+ return null;
41
+ }
42
+ if (x < minX - limit || x > maxX + limit || z < minZ - limit || z > maxZ + limit) {
43
+ examined = 0;
44
+ return null;
45
+ }
46
+ // A segment closer than `limit` passes through this box, so it is registered in one of its cells.
47
+ // Clamping to the polyline's own box keeps an infinite bound from walking an infinite grid.
48
+ const reach = limit * (1 + 1e-9) + 1e-9;
49
+ buckets.query(Math.max(minX, x - reach), Math.max(minZ, z - reach), Math.min(maxX, x + reach), Math.min(maxZ, z + reach), candidates);
50
+ examined = candidates.length;
51
+ if (candidates.length === 0)
52
+ return null;
53
+ let bestSegment = -1;
54
+ let bestT = 0;
55
+ let bestDistance = Number.POSITIVE_INFINITY;
56
+ let bestX = 0;
57
+ let bestZ = 0;
58
+ for (const segment of candidates) {
59
+ const offsetX = x - ax[segment];
60
+ const offsetZ = z - az[segment];
61
+ const segLengthSq = lengthSq[segment];
62
+ const t = segLengthSq <= 0 ? 0 : Math.max(0, Math.min(1, (offsetX * abx[segment] + offsetZ * abz[segment]) / segLengthSq));
63
+ const px = ax[segment] + abx[segment] * t;
64
+ const pz = az[segment] + abz[segment] * t;
65
+ const distance = Math.hypot(x - px, z - pz);
66
+ if (distance < bestDistance) {
67
+ bestDistance = distance;
68
+ bestSegment = segment;
69
+ bestT = t;
70
+ bestX = px;
71
+ bestZ = pz;
72
+ }
73
+ }
74
+ if (bestSegment < 0 || bestDistance > limit)
75
+ return null;
76
+ return { segment: bestSegment, t: bestT, distance: bestDistance, point: [bestX, bestZ] };
77
+ },
78
+ examined: () => examined,
79
+ };
80
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Uniform XZ grid over items with a rectangular footprint: insert once, then ask which items cover a
3
+ * box. One mechanism behind the two sampling hot paths — a corridor's segments and a palette's regions
4
+ * — so a per-vertex query tests the handful of items nearby instead of every item in the world.
5
+ * @internal
6
+ */
7
+ /** @internal */
8
+ export interface SpatialBuckets {
9
+ /** Register `id` under every cell its rectangle overlaps. Ids are returned in insertion order. */
10
+ insert(id: number, minX: number, minZ: number, maxX: number, maxZ: number): void;
11
+ /** Ids whose rectangle overlaps the query box, ascending and deduped, written into `out`. */
12
+ query(minX: number, minZ: number, maxX: number, maxZ: number, out: number[]): number[];
13
+ }
14
+ /** @internal */
15
+ export declare function buildSpatialBuckets(cellSize: number): SpatialBuckets;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Uniform XZ grid over items with a rectangular footprint: insert once, then ask which items cover a
3
+ * box. One mechanism behind the two sampling hot paths — a corridor's segments and a palette's regions
4
+ * — so a per-vertex query tests the handful of items nearby instead of every item in the world.
5
+ * @internal
6
+ */
7
+ /** @internal */
8
+ export function buildSpatialBuckets(cellSize) {
9
+ const cell = Math.max(1e-6, cellSize);
10
+ const columns = new Map();
11
+ let marks = new Int32Array(16);
12
+ let generation = 0;
13
+ let highest = -1;
14
+ const mark = (id) => {
15
+ if (id <= highest)
16
+ return;
17
+ highest = id;
18
+ if (id < marks.length)
19
+ return;
20
+ let size = marks.length;
21
+ while (size <= id)
22
+ size *= 2;
23
+ const grown = new Int32Array(size);
24
+ grown.set(marks);
25
+ marks = grown;
26
+ };
27
+ return {
28
+ insert(id, minX, minZ, maxX, maxZ) {
29
+ mark(id);
30
+ const cx0 = Math.floor(minX / cell);
31
+ const cx1 = Math.floor(maxX / cell);
32
+ const cz0 = Math.floor(minZ / cell);
33
+ const cz1 = Math.floor(maxZ / cell);
34
+ for (let cx = cx0; cx <= cx1; cx += 1) {
35
+ let column = columns.get(cx);
36
+ if (column === undefined) {
37
+ column = new Map();
38
+ columns.set(cx, column);
39
+ }
40
+ for (let cz = cz0; cz <= cz1; cz += 1) {
41
+ const bucket = column.get(cz);
42
+ if (bucket === undefined)
43
+ column.set(cz, [id]);
44
+ else
45
+ bucket.push(id);
46
+ }
47
+ }
48
+ },
49
+ query(minX, minZ, maxX, maxZ, out) {
50
+ out.length = 0;
51
+ generation += 1;
52
+ if (generation >= 2147483647) {
53
+ marks.fill(0);
54
+ generation = 1;
55
+ }
56
+ const cx0 = Math.floor(minX / cell);
57
+ const cx1 = Math.floor(maxX / cell);
58
+ const cz0 = Math.floor(minZ / cell);
59
+ const cz1 = Math.floor(maxZ / cell);
60
+ for (let cx = cx0; cx <= cx1; cx += 1) {
61
+ const column = columns.get(cx);
62
+ if (column === undefined)
63
+ continue;
64
+ for (let cz = cz0; cz <= cz1; cz += 1) {
65
+ const bucket = column.get(cz);
66
+ if (bucket === undefined)
67
+ continue;
68
+ for (const id of bucket) {
69
+ if (marks[id] === generation)
70
+ continue;
71
+ marks[id] = generation;
72
+ out.push(id);
73
+ }
74
+ }
75
+ }
76
+ out.sort((a, b) => a - b);
77
+ return out;
78
+ },
79
+ };
80
+ }
@@ -1,5 +1,6 @@
1
1
  import { withPathProfiles } from "./pathTerrain.js";
2
- import { isOnRoad, nearestOnPath } from "./roads.js";
2
+ import { buildPolylineIndex } from "./polylineIndex.js";
3
+ import { buildSpatialBuckets } from "./spatialBuckets.js";
3
4
  function clamp01(value) {
4
5
  return value < 0 ? 0 : value > 1 ? 1 : value;
5
6
  }
@@ -321,10 +322,15 @@ export function flattenFieldAround(base, zones) {
321
322
  export function roadDrapedField(base, roads) {
322
323
  if (roads.length === 0)
323
324
  return base;
325
+ const draped = roads.map((road) => ({
326
+ elevation: road.elevation,
327
+ half: road.width / 2,
328
+ index: buildPolylineIndex(road.path),
329
+ }));
324
330
  const sampleHeight = (x, z) => {
325
331
  let lift = 0;
326
- for (const road of roads) {
327
- if (road.elevation > lift && isOnRoad(road.path, road.width, x, z))
332
+ for (const road of draped) {
333
+ if (road.elevation > lift && road.index.nearest(x, z, road.half) !== null)
328
334
  lift = road.elevation;
329
335
  }
330
336
  return base.sampleHeight(x, z) + lift;
@@ -680,9 +686,13 @@ export function createTerrainPaletteSampler(descriptor) {
680
686
  }));
681
687
  if (regions.length === 0 && bandSampler === null)
682
688
  return () => base;
689
+ const buckets = buildSpatialBuckets(PALETTE_REGION_CELL);
690
+ regions.forEach((region, index) => buckets.insert(index, region.minX, region.minZ, region.maxX, region.maxZ));
691
+ const nearby = [];
683
692
  return (x, z) => {
684
693
  let palette = bandSampler === null ? base : bandSampler(z);
685
- for (const region of regions) {
694
+ for (const index of buckets.query(x, z, x, z, nearby)) {
695
+ const region = regions[index];
686
696
  const distance = region.coreDistance(x, z);
687
697
  if (distance <= 0) {
688
698
  palette = region.palette;
@@ -695,20 +705,44 @@ export function createTerrainPaletteSampler(descriptor) {
695
705
  return palette;
696
706
  };
697
707
  }
708
+ /**
709
+ * Cell size for the palette's region index, in world units: coarse enough that inserting a small
710
+ * region touches one cell, fine enough that a vertex only tests the handful of regions over it.
711
+ */
712
+ const PALETTE_REGION_CELL = 32;
698
713
  /**
699
714
  * Resolves a `TerrainMaterialRegion` of any shape to a `coreDistance(x, z)` — 0 inside the painted
700
- * core, growing positive outward — plus its blend-ring `falloff`, so the palette sampler blends every
701
- * shape through one shape-agnostic loop.
715
+ * core, growing positive outward — plus its blend-ring `falloff` and the box a region can paint
716
+ * inside (core plus blend ring), so the palette sampler blends every shape through one shape-agnostic
717
+ * loop that only visits regions covering the sample.
702
718
  */
703
719
  function resolveRegionShape(region) {
704
720
  if (region.shape === "polyline") {
705
721
  const half = region.width * 0.5;
722
+ const falloff = region.falloff ?? half;
723
+ const index = buildPolylineIndex(region.points);
724
+ const limit = half + falloff;
725
+ let minX = Number.POSITIVE_INFINITY;
726
+ let minZ = Number.POSITIVE_INFINITY;
727
+ let maxX = Number.NEGATIVE_INFINITY;
728
+ let maxZ = Number.NEGATIVE_INFINITY;
729
+ for (const point of region.points) {
730
+ minX = Math.min(minX, point[0]);
731
+ maxX = Math.max(maxX, point[0]);
732
+ minZ = Math.min(minZ, point[1]);
733
+ maxZ = Math.max(maxZ, point[1]);
734
+ }
735
+ const empty = region.points.length < 2;
706
736
  return {
707
- falloff: region.falloff ?? half,
737
+ falloff,
708
738
  coreDistance: (x, z) => {
709
- const sample = nearestOnPath(region.points, x, z);
710
- return sample === null ? Number.POSITIVE_INFINITY : Math.max(0, sample.distance - half);
739
+ const hit = index.nearest(x, z, limit);
740
+ return hit === null ? Number.POSITIVE_INFINITY : Math.max(0, hit.distance - half);
711
741
  },
742
+ minX: empty ? 0 : minX - limit,
743
+ minZ: empty ? 0 : minZ - limit,
744
+ maxX: empty ? -1 : maxX + limit,
745
+ maxZ: empty ? -1 : maxZ + limit,
712
746
  };
713
747
  }
714
748
  if (region.shape === "rect") {
@@ -716,8 +750,11 @@ function resolveRegionShape(region) {
716
750
  const angle = region.rotationY ?? 0;
717
751
  const cos = Math.cos(angle);
718
752
  const sin = Math.sin(angle);
753
+ const falloff = region.falloff ?? Math.min(hx, hz) * 0.5;
754
+ const extentX = Math.abs(hx * cos) + Math.abs(hz * sin) + falloff;
755
+ const extentZ = Math.abs(hx * sin) + Math.abs(hz * cos) + falloff;
719
756
  return {
720
- falloff: region.falloff ?? Math.min(hx, hz) * 0.5,
757
+ falloff,
721
758
  coreDistance: (x, z) => {
722
759
  const dx = x - region.center[0];
723
760
  const dz = z - region.center[1];
@@ -725,11 +762,21 @@ function resolveRegionShape(region) {
725
762
  const lz = -dx * sin + dz * cos;
726
763
  return Math.hypot(Math.max(0, Math.abs(lx) - hx), Math.max(0, Math.abs(lz) - hz));
727
764
  },
765
+ minX: region.center[0] - extentX,
766
+ minZ: region.center[1] - extentZ,
767
+ maxX: region.center[0] + extentX,
768
+ maxZ: region.center[1] + extentZ,
728
769
  };
729
770
  }
771
+ const falloff = region.falloff ?? region.radius * 0.5;
772
+ const extent = region.radius + falloff;
730
773
  return {
731
- falloff: region.falloff ?? region.radius * 0.5,
774
+ falloff,
732
775
  coreDistance: (x, z) => Math.max(0, Math.hypot(x - region.center[0], z - region.center[1]) - region.radius),
776
+ minX: region.center[0] - extent,
777
+ minZ: region.center[1] - extent,
778
+ maxX: region.center[0] + extent,
779
+ maxZ: region.center[1] + extent,
733
780
  };
734
781
  }
735
782
  function withFlattenMasks(sampleHeight, masks) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/core",
3
- "version": "0.34.0",
3
+ "version": "0.36.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.34.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.36.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.34.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.36.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -436,7 +436,7 @@
436
436
  - `HeadlessCommandInterface` (interface): interface HeadlessCommandInterface — Canvas-free command/intent surface for {@link HeadlessRunner} — invoke a game's registered UI commands and read the resulting reactive state off `ctx`.
437
437
  - `HeadlessInput` (interface): interface HeadlessInput — One step's worth of player intent handed to {@link HeadlessRunner.step} — the held-action set and pointer state the shell would otherwise publish from the browser each frame.
438
438
  - `HeadlessRunner` (interface): interface HeadlessRunner — A renderer-free driver for a game loop: builds a {@link GameContext} from a {@link GameDefinition}, runs the init hooks, then advances the simulation one step at a time from injected input. No React, R3F, or three.js — the whole play path (time, input, `onTick`, behaviour nav, optional player movement) runs from `core` primitives alone, so a non-React host (a server tick, a test, a CLI replay) can play a real game and read its world snapshot. The shell's FrameDriver is one such driver bolted to `useFrame`; this is the same step distilled out of the render tree.
439
- - `HeadlessRunnerOptions` (interface): interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultiplayer> { definition: GameDefinition<TAssetRef, TMultiplayer>; content?: GameContextContent; loop?: GameLoop<GameContext>; player?: {userId: string; isNew: boolean}; now?: () => number; maxStepSeconds?: number; playerMovement?: boolean; movement?: … — ⚠ undocumented
439
+ - `HeadlessRunnerOptions` (interface): interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultiplayer> { definition: GameDefinition<TAssetRef, TMultiplayer>; content?: GameContextContent; loop?: GameLoop<GameContext>; player?: {userId: string; isNew: boolean}; now?: () => number; seed?: string | number; maxStepSeconds?: number; playerMovemen… — ⚠ undocumented
440
440
  - `createHeadlessRunner` (function): function createHeadlessRunner<TAssetRef extends ModelAssetRef, TMultiplayer>(options: HeadlessRunnerOptions<TAssetRef, TMultiplayer>): HeadlessRunner — ⚠ undocumented
441
441
 
442
442
  ## @jgengine/core/runtime/hostPersistence
@@ -48,6 +48,35 @@
48
48
  - `AgentScheduleStop` (interface): interface AgentScheduleStop extends RoutineStopSpec — One authored stop in an agent's day. The shape is the routine owner's (`sim/routine`) plus a beat: the places a working stop walks in order, so a janitor on patrol is an agent with a route rather than a special case.
49
49
  - `createAgentSchedule` (function): function createAgentSchedule(config: AgentScheduleConfig): AgentSchedule — Resolve an authored day plan into a clock-driven schedule. Timing, travel and arrival slips come from `sim/routine`; this adds the work beat and the refills a stop carries, so an off-hours stop is just another authored stop and a patrol is not a second scheduler.
50
50
 
51
+ ## @jgengine/core/agents/system
52
+
53
+ - `Agent` (interface): interface Agent — Needs, mood, schedule and a chosen action, in one object a game ticks.
54
+ - `AgentAction` (interface): interface AgentAction — What the agent is doing.
55
+ - `AgentFallback` (interface): interface AgentFallback — Where an agent goes when the thing it wanted is gone: a named option and a named place.
56
+ - `AgentFrame` (interface): interface AgentFrame — What one update produced: the action, the feeling, and where the day plan puts the agent.
57
+ - `AgentMemberSpec` (interface): interface AgentMemberSpec extends UtilityTunables — One member: an id, and only what differs from the system's defaults.
58
+ - `AgentMemberState` (interface): interface AgentMemberState — One member's saved state: its agent snapshot plus the off-camera flag the snapshot does not carry.
59
+ - `AgentMoodConfig` (interface): interface AgentMoodConfig — The mood policy a member runs on: which needs matter, and the game's own words for the bands.
60
+ - `AgentScheduleConfig` (interface): interface AgentScheduleConfig — An agent's whole day as data: what it does, where, and when.
61
+ - `AgentSchedulePhase` (interface): interface AgentSchedulePhase — Where a schedule puts an agent at one instant.
62
+ - `AgentScheduleStop` (interface): interface AgentScheduleStop extends RoutineStopSpec — One authored stop in an agent's day. The shape is the routine owner's (`sim/routine`) plus a beat: the places a working stop walks in order, so a janitor on patrol is an agent with a route rather than a special case.
63
+ - `AgentSelectView` (interface): interface AgentSelectView — What a selector is told about the member asking: who it is, its live agent, and where it stands.
64
+ - `AgentSelector` (type): type AgentSelector = (view: AgentSelectView) => readonly AgentTarget[] — Everything the member could do right now, as the game's world offers it. Declared once per system.
65
+ - `AgentSnapshot` (interface): interface AgentSnapshot — Serializable whole agent: needs, feeling, held action, day-plan cursor and the far flag.
66
+ - `AgentSystem` (interface): interface AgentSystem — A whole cast of agents behind one factory and one tick.
67
+ - `AgentSystemConfig` (interface): interface AgentSystemConfig — The game's data, in one place: what its people need, what they can do, and how they feel.
68
+ - `AgentSystemState` (interface): interface AgentSystemState — The whole system as plain JSON - drop it in a save, read it back with {@link AgentSystem.load}.
69
+ - `AgentSystemStorage` (interface): interface AgentSystemStorage — Where a system keeps its save data: a key-value store, a save slot, or a test's map.
70
+ - `AgentTarget` (interface): interface AgentTarget — One reachable thing the agent could do this tick, as the game sees its own world.
71
+ - `Mood` (interface): interface Mood — What the agent feels right now, on the caller's own tier labels.
72
+ - `MoodTier` (interface): interface MoodTier — One labelled band of the 0-100 mood score.
73
+ - `MoodWeight` (interface): interface MoodWeight — One need's say in the mood score; weights are relative, so they need no fixed total.
74
+ - `NeedDefinition` (type): type NeedDefinition = DecayMeterConfig — A decaying need is a survival decay meter (`max`/`min`/`start`/`rate`, optional moodle thresholds) whose satisfaction an agent brain reads to score actions. The meter owns the numbers; per-need appeal lives in the game's utility options.
75
+ - `UtilityInterrupt` (interface): interface UtilityInterrupt — A need level that overrides the sticky pick the moment it is crossed.
76
+ - `UtilityOption` (interface): interface UtilityOption — A thing an agent can decide to do, scored against its needs.
77
+ - `UtilityTunables` (interface): interface UtilityTunables — In-play dials that stay reachable through {@link UtilityBrain.retune}.
78
+ - `createAgentSystem` (function): function createAgentSystem(config: AgentSystemConfig): AgentSystem — One call for a whole cast: a game hands over its needs, options, mood weights, schedules and one target selector, and gets back a running system with a single `tick(dt)` and a save blob that goes into the game's own save.
79
+
51
80
  ## @jgengine/core/behaviour/behaviour
52
81
 
53
82
  - `Behaviour` (class): class Behaviour — Subclass and override the lifecycle hooks. A behaviour only joins the per-frame update dispatch if it actually overrides `onUpdate` (prototype-identity check at each activation), so hook-only behaviours cost nothing per frame.
@@ -23,6 +23,7 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
23
23
  ## agents — a needs, thought, action and mood loop an agent runs on the world clock
24
24
 
25
25
  - `createAgent` (function) · `import { createAgent } from "@jgengine/core/agents/agent"`
26
+ - `createAgentSystem` (function) · `import { createAgentSystem } from "@jgengine/core/agents/system"`
26
27
 
27
28
  ## auction-book — timed bid auctions with escrowed bids, buyout, anti-snipe close extension, and collection-box settlement
28
29