@jgengine/core 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/dist/agents/system.d.ts +135 -0
  3. package/dist/agents/system.js +195 -0
  4. package/dist/combat/projectiles.d.ts +6 -2
  5. package/dist/combat/projectiles.js +55 -52
  6. package/dist/combat/shotSpec.d.ts +239 -0
  7. package/dist/combat/shotSpec.js +216 -0
  8. package/dist/combat/weaponSystem.js +26 -21
  9. package/dist/combat.d.ts +1 -0
  10. package/dist/combat.js +1 -0
  11. package/dist/game/playableGame.d.ts +9 -0
  12. package/dist/meta/changelog.d.ts +1 -1
  13. package/dist/meta/changelog.js +77 -1
  14. package/dist/movement/characterController.d.ts +5 -0
  15. package/dist/movement/characterController.js +8 -1
  16. package/dist/movement/movementModel.js +4 -1
  17. package/dist/movement/playerMovement.js +4 -0
  18. package/dist/physics/physicsBackendConformance.js +17 -0
  19. package/dist/physics/physicsWorld.d.ts +7 -0
  20. package/dist/physics/physicsWorld.js +27 -6
  21. package/dist/run/runLoop.d.ts +44 -1
  22. package/dist/run/runLoop.js +66 -5
  23. package/dist/world/features.d.ts +15 -1
  24. package/dist/world/features.js +51 -2
  25. package/dist/world/lod.d.ts +8 -0
  26. package/dist/world/lod.js +13 -5
  27. package/dist/world/modelLod.d.ts +44 -0
  28. package/dist/world/modelLod.js +75 -0
  29. package/dist/world.d.ts +2 -1
  30. package/dist/world.js +2 -1
  31. package/package.json +1 -1
  32. package/skills/jgengine/api.md +2 -2
  33. package/skills/jgengine-combat/SKILL.md +7 -0
  34. package/skills/jgengine-combat/api.md +61 -1
  35. package/skills/jgengine-combat/capabilities.md +4 -0
  36. package/skills/jgengine-combat/reference.md +1 -0
  37. package/skills/jgengine-gameplay/api.md +35 -0
  38. package/skills/jgengine-gameplay/capabilities.md +2 -0
  39. package/skills/jgengine-world/api.md +17 -0
@@ -1,8 +1,9 @@
1
1
  import { devtools } from "../devtools/devtools.js";
2
2
  import { createMagazine } from "./magazine.js";
3
- import { assertWeaponDefinition, weaponDamageAt, weaponIntervalMs, weaponMaxInFlight, } from "./weaponDefinition.js";
3
+ import { assertWeaponDefinition, weaponIntervalMs, weaponMaxInFlight, } from "./weaponDefinition.js";
4
4
  import { createWeaponHandling, } from "./weaponHandling.js";
5
5
  import { createFireCadence } from "./weaponFire.js";
6
+ import { SHOT_GRAVITY, resolveShotSpec, shotFalloffAt, shotSpecFromWeapon } from "./shotSpec.js";
6
7
  const MAX_SHOTS_PER_TICK_CAP = 16;
7
8
  const DEFAULT_SHOTS_PER_TICK = 4;
8
9
  const HALF_PI = Math.PI / 2;
@@ -73,15 +74,16 @@ export function createWeaponSystem(initial, deps) {
73
74
  assertWeaponDefinition(initial);
74
75
  const rng = deps.rng;
75
76
  const maxShotsPerTick = Math.min(MAX_SHOTS_PER_TICK_CAP, Math.max(1, Math.floor(deps.maxShotsPerTick ?? DEFAULT_SHOTS_PER_TICK)));
76
- function assertWiring(definition) {
77
- if (definition.delivery.kind === "hitscan" && deps.raycast === undefined) {
77
+ function assertWiring(definition, shotSpec) {
78
+ if (shotSpec.mode === "trace" && deps.raycast === undefined) {
78
79
  throw new Error(`Weapon "${definition.id}" is hitscan but no raycast was provided`);
79
80
  }
80
- if (definition.delivery.kind === "projectile" && deps.sweep === undefined) {
81
+ if (shotSpec.mode === "body" && deps.sweep === undefined) {
81
82
  throw new Error(`Weapon "${definition.id}" is projectile but no sweep was provided`);
82
83
  }
83
84
  }
84
- assertWiring(initial);
85
+ const initialShot = resolveShotSpec(shotSpecFromWeapon(initial));
86
+ assertWiring(initial, initialShot);
85
87
  function buildMagazine(definition) {
86
88
  const magazine = definition.magazine;
87
89
  if (magazine === undefined)
@@ -93,6 +95,7 @@ export function createWeaponSystem(initial, deps) {
93
95
  });
94
96
  }
95
97
  let definition = initial;
98
+ let shotSpec = initialShot;
96
99
  let intervalMs = weaponIntervalMs(initial);
97
100
  let cadence = createFireCadence({ intervalMs });
98
101
  let magazine = buildMagazine(definition);
@@ -134,7 +137,7 @@ export function createWeaponSystem(initial, deps) {
134
137
  const target = contact.target;
135
138
  if (target === undefined)
136
139
  return;
137
- const falloff = weaponDamageAt(definition, distance);
140
+ const falloff = shotFalloffAt(shotSpec, distance);
138
141
  const context = { shot, pellet, distance, falloff };
139
142
  const input = deps.damageFor === undefined
140
143
  ? defaultDamage(contact, target, falloff)
@@ -154,17 +157,17 @@ export function createWeaponSystem(initial, deps) {
154
157
  resolution,
155
158
  });
156
159
  }
157
- function spawnPellet(shot, pellet, origin, direction) {
158
- const delivery = definition.delivery;
159
- if (delivery.kind !== "projectile")
160
+ function spawnPellet(shotIndex, pellet, origin, direction) {
161
+ const body = shotSpec.body;
162
+ if (body === null)
160
163
  return;
161
164
  if (pellets.length >= weaponMaxInFlight(definition)) {
162
165
  stats.pelletsDropped += 1;
163
166
  return;
164
167
  }
165
- const speed = delivery.speed;
168
+ const speed = body.speed;
166
169
  pellets.push({
167
- shot,
170
+ shot: shotIndex,
168
171
  pellet,
169
172
  position: origin,
170
173
  velocity: [direction[0] * speed, direction[1] * speed, direction[2] * speed],
@@ -183,15 +186,15 @@ export function createWeaponSystem(initial, deps) {
183
186
  for (let pellet = 0; pellet < perShot; pellet += 1) {
184
187
  const direction = coneDirection(aim.direction, shot.spread, rng);
185
188
  directions.push(direction);
186
- if (definition.delivery.kind === "hitscan") {
189
+ if (shotSpec.trace !== null) {
187
190
  const raycast = deps.raycast;
188
191
  if (raycast === undefined)
189
192
  continue;
190
- const contact = raycast(origin, direction, definition.delivery.maxRange);
193
+ const contact = raycast(origin, direction, shotSpec.trace.range);
191
194
  if (contact === null)
192
195
  continue;
193
196
  const distance = distanceBetween(origin, contact.point);
194
- if (distance > definition.delivery.maxRange + 1e-6)
197
+ if (distance > shotSpec.trace.range + 1e-6)
195
198
  continue;
196
199
  resolveContact(contact, distance, shotCount, pellet, out);
197
200
  }
@@ -217,14 +220,14 @@ export function createWeaponSystem(initial, deps) {
217
220
  });
218
221
  }
219
222
  function advancePellets(dt, out) {
220
- const delivery = definition.delivery;
221
- if (pellets.length === 0 || delivery.kind !== "projectile")
223
+ const body = shotSpec.body;
224
+ if (pellets.length === 0 || body === null)
222
225
  return;
223
226
  const sweep = deps.sweep;
224
227
  if (sweep === undefined)
225
228
  return;
226
- const gravity = delivery.gravity ?? 0;
227
- const drag = delivery.drag ?? 0;
229
+ const gravity = body.gravity * SHOT_GRAVITY;
230
+ const drag = body.drag;
228
231
  const dragScale = Math.max(0, 1 - drag * dt);
229
232
  const next = [];
230
233
  for (const pellet of pellets) {
@@ -250,8 +253,8 @@ export function createWeaponSystem(initial, deps) {
250
253
  pellet.velocity = velocity;
251
254
  pellet.age += dt;
252
255
  pellet.distance = travelled;
253
- const outOfRange = delivery.maxRange !== undefined && travelled >= delivery.maxRange;
254
- if (pellet.age >= delivery.lifetime || outOfRange) {
256
+ const outOfRange = travelled >= shotSpec.range;
257
+ if (pellet.age >= body.lifetime || outOfRange) {
255
258
  stats.projectilesExpired += 1;
256
259
  continue;
257
260
  }
@@ -385,11 +388,13 @@ export function createWeaponSystem(initial, deps) {
385
388
  },
386
389
  retune(next) {
387
390
  assertWeaponDefinition(next);
388
- assertWiring(next);
391
+ const nextShot = resolveShotSpec(shotSpecFromWeapon(next));
392
+ assertWiring(next, nextShot);
389
393
  if ((next.magazine === undefined) !== (magazine === null))
390
394
  return false;
391
395
  const elapsed = cadence.elapsedMs();
392
396
  definition = next;
397
+ shotSpec = nextShot;
393
398
  intervalMs = weaponIntervalMs(next);
394
399
  cadence = createFireCadence({ intervalMs });
395
400
  cadence.restore(elapsed);
package/dist/combat.d.ts CHANGED
@@ -30,6 +30,7 @@ export { resistanceScale, resolveResistance } from "./combat/resistance.js";
30
30
  export { createResourcePool, type ResourcePool } from "./combat/resourcePool.js";
31
31
  export { allocateRewards, filterOutcomeFor, resolveClaim, type AllocationOutcome, type AllocationRequest, type ClaimOutcome, type ClaimablePool, type RewardClaimSpec, type RewardGrant, type RewardPolicyKind, type RewardRecipient, type RewardResult, type RewardShare, } from "./combat/rewardAllocation.js";
32
32
  export { DEFAULT_EYE_HEIGHT, convergeShot, eyeHeightFromColliders, resolveShot, type ResolvedShot, type ShotOriginPolicy, } from "./combat/shotOrigin.js";
33
+ export { DEFAULT_FALLOFF_END_SCALE, DEFAULT_SHOT_PENETRATION, DEFAULT_SHOT_RADIUS, DEFAULT_SHOT_RANGE, DEFAULT_SHOT_SPEED, SHOT_GRAVITY, resolveShotSpec, shotArc, shotFalloffAt, shotFuseSeconds, shotHitOutcome, shotSpecFromStats, type ResolvedShotSpec, type ShotArc, type ShotBody, type ShotContact, type ShotDamage, type ShotDelivery, type ShotFalloff, type ShotHitOutcome, type ShotLaunch, type ShotPenetration, type ShotRicochet, type ShotSpec, type ShotSplash, type ShotStatReader, type ShotTrace, type ShotVec3, } from "./combat/shotSpec.js";
33
34
  export { createSquadCombat, type SquadCombat, type SquadCombatOptions, type SquadCombatSnapshot, type SquadCombatStats, type SquadCombatTunables, type SquadMemberSpec, type SquadMemberView, type SquadMode, } from "./combat/squadCombat.js";
34
35
  export { assignSquadRoles, threatsFromMemory, type FireDiscipline, type SquadComposition, type SquadRole, type ThreatReadOptions, type ThreatReading, } from "./combat/squadTactics.js";
35
36
  export { resolveStatusApplication, type StatusApplicationOutcome, type StatusApplicationSpec, type StatusImmunity, type StatusInstance, type StatusStackPolicy, } from "./combat/statusApplication.js";
package/dist/combat.js CHANGED
@@ -30,6 +30,7 @@ export { resistanceScale, resolveResistance } from "./combat/resistance.js";
30
30
  export { createResourcePool } from "./combat/resourcePool.js";
31
31
  export { allocateRewards, filterOutcomeFor, resolveClaim, } from "./combat/rewardAllocation.js";
32
32
  export { DEFAULT_EYE_HEIGHT, convergeShot, eyeHeightFromColliders, resolveShot, } from "./combat/shotOrigin.js";
33
+ export { DEFAULT_FALLOFF_END_SCALE, DEFAULT_SHOT_PENETRATION, DEFAULT_SHOT_RADIUS, DEFAULT_SHOT_RANGE, DEFAULT_SHOT_SPEED, SHOT_GRAVITY, resolveShotSpec, shotArc, shotFalloffAt, shotFuseSeconds, shotHitOutcome, shotSpecFromStats, } from "./combat/shotSpec.js";
33
34
  export { createSquadCombat, } from "./combat/squadCombat.js";
34
35
  export { assignSquadRoles, threatsFromMemory, } from "./combat/squadTactics.js";
35
36
  export { resolveStatusApplication, } from "./combat/statusApplication.js";
@@ -3,6 +3,8 @@ import type { AmbienceZoneDef } from "../audio/ambience.js";
3
3
  import type { AudioBusDef, SoundDef } from "../audio/audioFalloff.js";
4
4
  import type { AudioOcclusionConfig } from "../audio/occlusion.js";
5
5
  import type { MusicTheme } from "../audio/music.js";
6
+ import type { ModelVariantLodConfig } from "../world/modelLod.js";
7
+ export type { ModelVariantConfig, ModelVariantLodConfig } from "../world/modelLod.js";
6
8
  import type { UiCueOverrides } from "../audio/uiCues.js";
7
9
  import type { FeedbackCueOverrides } from "../audio/feedbackCues.js";
8
10
  import type { NoticeFeedConfig } from "./notices.js";
@@ -307,6 +309,13 @@ export interface ModelConfig {
307
309
  * distance tiers (full rate) — see {@link ModelLodConfig}.
308
310
  */
309
311
  lod?: ModelLodConfig | false;
312
+ /**
313
+ * Ordered mesh detail levels — the full mesh plus decimated copies — switched per instance by
314
+ * distance to the camera, so one species can keep full geometry near and a cheaper mesh far. Only
315
+ * the instanced draw path swaps levels; a single-instance placement always draws `url`. See
316
+ * {@link ModelVariantLodConfig} and `core/world/modelLod`.
317
+ */
318
+ variants?: ModelVariantLodConfig;
310
319
  }
311
320
  /**
312
321
  * Distance tiers for one character model. Distances are metres from the camera; every field is data
@@ -1,5 +1,5 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export declare const VERSION = "0.35.0";
2
+ export declare const VERSION = "0.37.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,83 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export const VERSION = "0.35.0";
2
+ export const VERSION = "0.37.0";
3
3
  /** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
4
4
  export const CHANGELOG = {
5
+ "0.37.0": {
6
+ migrate: [
7
+ "Bump lockstep SDK packages to `^0.37.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.34.0`; `@jgengine/github` is `0.24.0`.",
8
+ ],
9
+ added: [
10
+ "`bun run check:script-help` walks every `package.json` script that points into `scripts/`, runs each with `--help`, and fails when one errors, hangs over its own timeout, prints nothing, or when a script names a file the repo does not have. Script files that predate `--help` are listed in `scripts/script-help-baseline.json` and reported, so a new one cannot arrive undocumented.",
11
+ "`land`, `lane:close`, `lane:audit`, `ci-status` and `verify:fast` answer `--help` with their purpose, the flags they actually accept, one example and their exit codes, through one shared `scripts/script-help.ts`. No install, network or browser needed.",
12
+ "`RunLoop.present(count)` and `RunLoop.choose(id)` (`@jgengine/core/run/runLoop`) draw several next-run changes for a choice screen and adopt the pick, so a game that offers three relics does not compose `game/runDraft` beside the loop. `present` honours the same `after` gates and weights as the single draw and never repeats the change just played while alternatives remain; `choose` opens the next run around the pick and refuses an id the loop's table does not carry.",
13
+ "`installRunProbe` (`@jgengine/core/run/runLoop`) publishes `window.__jgengineRun` with the run in progress, what it carries, what it banked or lost, its best bank and the change it offers, so a capture or devtools read proves the loop is live instead of only imported.",
14
+ "`bun run find <pattern> [--in <dir>] [--kind ts,tsx] [--files]` answers a repository-wide search in about a second: ripgrep reads the tree once, honours `.gitignore` and never enters `node_modules`, `.git`, `.claude/worktrees` or build output. `--files` lists names by `--glob`, which is what a tree listing was for. Eight of the ten hard timeouts in three hours of lane transcripts were a recursive directory listing over this checkout (160 s, 3.0M files) against a 120 s limit, while every other call's p90 was 10.5 s.",
15
+ "`bun run check-recursive-walk` fails a `scripts/` file or a lane document (`AGENTS.md`, `README.md`, every skill under `.claude/skills`) that ships a recursive directory listing, a `find` walk from a root, or `git ls-files` piped into per-file work; a required one carries `// walk-ok: <reason>`. It runs in CI through the scripts test shard.",
16
+ "`ModelConfig.variants` (`levels: [{ url, maxDistance? }]`, `hysteresis?`) declares ordered mesh detail levels for a model. The instanced prop path switches level per instance by distance to the camera, so near and far placements of one species keep their own mesh inside one batch cell. A model without `variants` draws exactly as before.",
17
+ "`createModelVariantSelector` (`@jgengine/core/world/modelLod`) returns the level index for an id at a distance, carrying hysteresis across each boundary (`DEFAULT_VARIANT_HYSTERESIS`, 6 m). `lodBandIndex` is now exported from `@jgengine/core/world/lod` and both the LOD scheduler and the selector read distances through it, so simulation throttling and mesh detail cannot disagree.",
18
+ "`syncStaticObjectSource` takes an optional `accept` predicate so one batch can split its instances between two instanced draws.",
19
+ "`InstancedScatter` now honours `ModelConfig.variants`: each species-chunk batch asks the same `createModelVariantSelector` the static prop path uses for every placement's level, and each level draws its own instanced meshes claiming only its placements. A chunk that holds near and far palms of one species draws both levels from the one batch; a model without `variants` draws exactly as before.",
20
+ "`writeScatterVariantLevel` (`@jgengine/shell` internal, `scatter/scatterVariants`) is the per-frame pass: it writes one level's composed matrices into a reserved slot array and returns `count`, allocating nothing per frame, and takes an optional `keep` predicate so a tier-distance or occlusion cull composes with level selection in one loop.",
21
+ "`terrain({ cellSize })` states ground density as world units per quad instead of a segment count, and derives the per-axis grid from `bounds` — the same `cellSize` on a bigger world spends more cells rather than stretching one count across it. `segments` still works and also takes `[x, z]` for a non-square grid. Setting both fails naming `cellSize`; a cell size over the 1,000,000-vertex ceiling fails with the cell/vertex numbers and the smallest `cellSize` that fits.",
22
+ "`shoot`, `drive` and `perf` take `--quality <low|medium|high|ultra>` and pin that graphics tier on the game page (`?quality=<tier>`). Each run reads the tier back from the shell (`data-jg-quality`) and fails when it is missing or different, so a capture or perf record always names the tier it measured. Every capture summary prints `tier <tier>`; `perf` echoes `perf: tier <tier>` and records it in `quality`.",
23
+ "The old `--quality draft|full` iteration mode is now the `--draft` switch (low tier, half viewport, `-draft` file names), on `shoot`, `drive` and `perf`; `perf:gate` takes `--draft` too. An unknown `--quality` value fails naming all four tiers.",
24
+ "`attributeTriangles` (`@jgengine/shell/render/triangleAttribution`) sums a scene's triangles by model and geometry with instance counts, and the devtools render probe publishes it as `probes[\"render/triangles\"]` and the `render/triangles` system probe, so `debug_snapshot` names the object behind a heavy frame instead of only counting it. Harbor Heat read 1,021,671 triangles from 840 objects: the 640 m terrain at 300 segments (180,000), the `TwistedTree_2` foliage species (137,010 over 15 trees) and 93 instances of a 1,280-triangle NPC model.",
25
+ "`ShotSpec` and `resolveShotSpec` (`@jgengine/core/combat/shotSpec`) describe how a shot travels and what it does on contact as plain data: an instant trace or a travelling body with speed, gravity, drag, fuse, lifetime, radius and homing, plus contact damage, falloff, penetration, splash, ricochet and statuses. Every field but `delivery.kind` is optional with a documented default, so a plain gun is three lines and a grenade, mortar, arrow or hitscan explosive is the same shape with different numbers. `shotArc`, `shotFalloffAt` and `shotHitOutcome` are the one place trajectory, falloff and penetration are solved; `shotSpecFromStats` and `shotSpecFromWeapon` adapt existing item stats and weapon definitions once, and a splash radius or damage number never decides that a shot travels.",
26
+ "`ProjectileShotInput.spec` lets a caller state the spec directly; the projectile and weapon systems read the resolved spec at spawn, fuse, hitscan settle and damage application instead of classifying a shot.",
27
+ "A trace that carries splash now resolves its area damage at the contact point on the same tick, which is the #2272 hitscan-explosive configuration.",
28
+ "`bun run check:path-portability` fails a test that asserts one platform's path separator: a `\\\\` literal under `toContain`/`toEqual`/`toBe`/`includes`/`endsWith`, a bare `toContain(\"/\")`, a POSIX root handed to `join`/`resolve`, or a literal compared with an OS path result. It reports the file, the line and the `join(...)` that replaces it. A genuinely single-platform assertion is named in `scripts/path-portability-allow.json` with a reason, printed on every run. This is the class behind #2301 (`toContain(\"proofs\\\\flows\")`) and the `agent-worktree.test.ts` assertions that only pass on one OS.",
29
+ "`bun test scripts/check-path-portability.test.ts` - pass, including the pre-fix `proofs\\\\flows` line failing through the gate and the fixed one passing",
30
+ "`bun run check:path-portability` - ok, 3 allowlisted with reasons",
31
+ "`bun test scripts/agent-worktree.test.ts scripts/shot-metrics.test.ts scripts/lane-audit.test.ts` - pass",
32
+ ],
33
+ changed: [
34
+ "Merge pull request #2309 from Noisemaker111/agent/lane-path-portability",
35
+ "`bun run land` hands the PR over instead of waiting for it: it pushes, opens or updates the PR, runs `--verify`, arms auto-merge and exits in seconds, printing the PR URL and `bun run ci:status --pr <n>`. A trivial change spent 5m09s inside `land`'s own poll loop, and one night's lanes spent 449 minutes watching CI. `--watch` still lands the old way and says it is deprecated.",
36
+ "`bun run land:queue` is the only process that waits for CI. It lands ready PRs into `beta` in readiness order (auto-merge armed, else the `land:queue` label), rebases a branch whose base moved in a throwaway worktree so a moved base costs one CI run instead of two, waits once per PR and prints one line per PR with its wait time and run id. A lane hands off by running `bun run land`; it never rebases, labels or waits.",
37
+ "`bun run lane:close` ends a lane in one command. It stages the worktree's tracked and new files (ignored ones such as `.scratch/` stay out), takes the commit message from the branch's own `changes/` fragment, writes that fragment from the commit summary when the diff has none, pushes, opens or updates the PR with the fragment as the body, arms auto-merge, and prints the PR URL and `bun run ci:status --pr <n>`. One lane's last 20 calls were 13 of commit, push, rebase and PR choreography; this replaces them with one. `bun run land --commit` does the same for a lane that skips `lane:close`, and a clean tree lands as it did before.",
38
+ "`bun run bg:run -- <cmd>` starts a long command and returns its id at once; `bun run bg:await <id>` blocks on that task's exit file as an event, then prints its whole output and its exit code. It replaces waiting out a `.out` file with guessed intervals — one lane spent 10 minutes of `Start-Sleep` calls watching a capture it had already started. A runner that dies is answered rather than waited on forever.",
39
+ "`--commit` prints every path it will commit before committing, and refuses the ones a lane did not author: a scratch artifact, a path git ignores, a lockfile staged without its `package.json` manifest, and a new `changes/` fragment whose name is not part of the branch's name. `--allow <path>` keeps one refused file on purpose. The first replay committed a test-written `changes/agent-lane-x.md` and this checkout's shared-install `bun.lock` drift; both are refused now, with the file list printed either way.",
40
+ "A capture at a non-default tier writes its tier into the file name (`-low`, `-medium`, `-ultra`), so an ultra shot cannot overwrite the high one it was compared against.",
41
+ "`verify:fast`, `land` and `lane:close` refuse a checkout with no `node_modules` up front: they print the missing marker, the exact fix (`bun install` in this checkout, or `bun run agent:bootstrap`) and exit 78, instead of a `command not found` or a module error from a test file. A genuine failure still exits on its own code.",
42
+ "`lane:audit --cwd` finds the lane whose own tool calls name that worktree, and prints `attributed ses_… (N references to …)`. Ambiguous ties name their candidates and pick the worst-waiting one; no candidate at all exits non-zero naming the path it searched, so `--fail-on-waiting` has a lane to judge instead of an empty table.",
43
+ "`land` builds the PR body only from `changes/` fragments its own `git diff origin/beta...HEAD` adds, prints a line for every fragment it ignores, and falls back to the branch's commit subjects.",
44
+ "`bun test scripts/lane-audit.test.ts scripts/lane-close.test.ts scripts/land.test.ts` — pass",
45
+ "`bun run verify:fast` — pass",
46
+ "`bun run lane:audit --cwd <the real lane worktree> --hours 6` — attributed that lane by 136 references",
47
+ "`lane:audit` classifies a call by what it runs, not by the text inside its arguments: `rg`, `grep`, `Select-String`, `findstr`, `Get-Content`, `cat`, `echo`, `Write-Output` and `git` (any subcommand) never count as a sleep or a CI watch, and quoted arguments are stripped before matching. A lane that greps for `Start-Sleep` or writes a PR body about `gh run watch` no longer records waiting it never did.",
48
+ "Catches the waits it used to miss: `Start-Sleep` under `powershell -Command`/`pwsh -c`/`bash -lc`, `cmd /c \"timeout /t\"`, `Thread.sleep`, `Atomics.wait`, `Bun.sleep`, and `gh pr checks` inside a `for`/`while` loop.",
49
+ "`bun test scripts/lane-audit.test.ts scripts/lane-close.test.ts scripts/land.test.ts` — pass",
50
+ "`bun run lane:audit --json --hours 40` before and after, compared per command",
51
+ "`lane:close` reads `calls` as the count it is, takes waits from a new `waits` list in `lane:audit --json`, and refuses an unfamiliar audit shape with `the audit output shape changed: <field> is <type>, expected <type> (keys seen: …)` instead of a TypeError. Exit codes unchanged: a sleep or CI watch is 2, `--waive` records the reason, no percentage thresholds.",
52
+ "`bun test scripts/lane-audit.test.ts scripts/lane-close.test.ts` — pass, including the captured-output fixture",
53
+ "`bun run verify:fast` — pass",
54
+ "`shoot --url ... --look` now sends the aim to the page it captures, so a lane can measure a branch on a dev runner it started itself. The read-back stays: a page that reports no applied aim still fails the capture instead of returning a plausible wrong view, and a page that applies `--param look=` itself can report it on `data-jg-look` or `window.__jgLook`. `--site --look` is refused at the command line, naming the `--url` form that works.",
55
+ "`check:no-blocking-wait` judges code that waits, not prose that describes waiting. In `packages/<pkg>/src/meta` (the changelog mirror and its neighbours) single- and double-quoted text and comments are masked before matching, so a release note reading \"one lane spent 10 minutes of `Start-Sleep`\" no longer fails the gate; a command in a template literal still fails, and every other `scripts/` path keeps the strict rule where a quoted command is a real finding. The 0.37.0 release cut failed `types` on `packages/core/src/meta/changelog.ts:46,55,56` with exactly that false positive.",
56
+ "`@jgengine/rapier` now accumulates `step(dt)` into its fixed substeps instead of stepping once per call with `fixedDt` as the timestep. A 1 s move covered 0.5 s of simulated time at 30 fps and 2.4 s at 144 fps; it now covers 1 s at either rate, matching `PhysicsWorld` and the `PhysicsBackend` contract.",
57
+ "`PhysicsWorld` bounds-floor friction is Coulomb (`friction × g × dt`) like contact friction, not a 60 Hz multiplicative brake: a crate sliding at 4 m/s travelled 0.165 m on the world floor and 0.970 m on a floor collider, and now travels 0.967 m on both.",
58
+ "Player and character-controller gravity integrate trapezoidally, so a jump reaches `v²/2g` at any frame rate: the default jump peaked at 0.933 m at 30 fps and 1.026 m at 144 fps, and now at 1.050 m for both.",
59
+ ],
60
+ removed: [],
61
+ },
62
+ "0.36.0": {
63
+ migrate: [
64
+ "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`.",
65
+ ],
66
+ added: [
67
+ "`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.",
68
+ "`--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.",
69
+ "`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`.",
70
+ "`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.",
71
+ "`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).",
72
+ ],
73
+ changed: [
74
+ "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.",
75
+ "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.",
76
+ "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.",
77
+ "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.",
78
+ ],
79
+ removed: [],
80
+ },
5
81
  "0.35.0": {
6
82
  migrate: [
7
83
  "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`.",
@@ -41,6 +41,11 @@ export interface CharacterMoveInput {
41
41
  crouch?: boolean;
42
42
  /** Body the controller itself is represented by, excluded from its own casts. */
43
43
  self?: BodyHandle;
44
+ /**
45
+ * Vertical displacement for this move when the caller already integrated it (a movement model that
46
+ * owns jump/gravity feel). Overrides the controller's own `verticalVelocity * dt` for the sweep.
47
+ */
48
+ verticalStep?: number;
44
49
  }
45
50
  /** What one move did. */
46
51
  export interface CharacterMoveResult {
@@ -182,14 +182,21 @@ export function createCharacterController(initial) {
182
182
  break;
183
183
  remaining = slid;
184
184
  }
185
+ const verticalBefore = state.verticalVelocity;
185
186
  if (input.gravity !== undefined)
186
187
  state.verticalVelocity -= input.gravity * dt;
188
+ let jumped = false;
187
189
  if (input.jumpVelocity !== undefined && state.grounded) {
188
190
  state.verticalVelocity = input.jumpVelocity;
189
191
  state.grounded = false;
192
+ jumped = true;
190
193
  }
191
194
  const wasGrounded = state.grounded;
192
- let vertical = state.verticalVelocity * dt + carry[1];
195
+ // Trapezoidal displacement: the step moves at the average of this step's start and end vertical
196
+ // speed, so a jump reaches its analytic apex (v²/2g) at any frame rate. A caller that already
197
+ // integrated the vertical motion passes the step explicitly.
198
+ const verticalStep = input.verticalStep ?? (jumped ? state.verticalVelocity : (verticalBefore + state.verticalVelocity) / 2) * dt;
199
+ let vertical = verticalStep + carry[1];
193
200
  if (wasGrounded && vertical <= 0)
194
201
  vertical -= cfg.snapDistance;
195
202
  const { hit: verticalHit } = sweep(backend, pos, height, [0, vertical, 0], self);
@@ -221,10 +221,13 @@ export function advancePlayerMotion(motion, intent, forwardX, forwardZ, baseSpee
221
221
  : motion.verticalVelocity < 0
222
222
  ? (tuning?.fallGravityScale ?? 1)
223
223
  : 1;
224
+ const risingVelocity = motion.verticalVelocity;
224
225
  motion.verticalVelocity -= gravityAcceleration * gravityScale * deltaSeconds;
225
226
  if (motion.verticalVelocity <= 0)
226
227
  motion.jumpRising = false;
227
- motion.jumpOffset += motion.verticalVelocity * deltaSeconds;
228
+ // Trapezoidal, not Euler: a jump integrates to v²/2g whatever the frame rate. First-order Euler
229
+ // undershot the apex by 11% at 30 fps and 2% at 144 fps, so the same jump reached different heights.
230
+ motion.jumpOffset += ((risingVelocity + motion.verticalVelocity) / 2) * deltaSeconds;
228
231
  if (motion.jumpOffset <= 0 && options?.externalGrounding !== true) {
229
232
  motion.jumpOffset = 0;
230
233
  motion.verticalVelocity = 0;
@@ -572,6 +572,9 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
572
572
  }
573
573
  const horizontalStep = advancePlayerMotion(horizontal, controllerIntent, forwardX, forwardZ, walkSpeed, dt, tuning.physics, { buffer: state.jumpBuffer, externalGrounding: true, authoritativeStep: tuning.authoritativeStep });
574
574
  state.controllerJumpHeld = intent.jumping;
575
+ // `jumpOffset` is this step's vertical displacement under the movement model's trapezoidal
576
+ // integration; the controller sweeps that instead of re-deriving `v * dt` from the end velocity.
577
+ const verticalStep = horizontal.jumpOffset;
575
578
  controller.restore({
576
579
  ...controller.state(),
577
580
  verticalVelocity: horizontal.verticalVelocity,
@@ -580,6 +583,7 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
580
583
  const result = controller.move(tuning.controller.backend, {
581
584
  motion: [horizontalStep.stepX, 0, horizontalStep.stepZ],
582
585
  dt: motionStepSeconds(dt, tuning.authoritativeStep),
586
+ verticalStep,
583
587
  });
584
588
  horizontal.verticalVelocity = controller.state().verticalVelocity;
585
589
  horizontal.jumpOffset = 0;
@@ -38,6 +38,23 @@ export function runPhysicsBackendConformance(create, harness) {
38
38
  expect(state.position[1]).toBeLessThan(0.7);
39
39
  expect(Math.abs(state.velocity[1])).toBeLessThan(0.5);
40
40
  });
41
+ test("the same wall-clock time simulates the same distance at 30, 60 and 144 fps", () => {
42
+ // A backend that honours `fixedDt` as one step per call simulates `fixedDt * fps` seconds per
43
+ // second of real time — half speed at 30 fps, 2.4x at 144 fps. Horizontal travel ignores the
44
+ // integrator, so the distance is exactly `v * realSeconds` for any backend that accumulates.
45
+ const backend = create();
46
+ const travelled = [];
47
+ for (const fps of [30, 60, 144]) {
48
+ const ball = backend.addBody({ shape: { kind: "sphere", radius: 0.2 }, position: [0, 8, 0], velocity: [10, 0, 0] });
49
+ for (let i = 0; i < fps; i += 1)
50
+ backend.step(1 / fps);
51
+ travelled.push(backend.body(ball).position[0]);
52
+ backend.removeBody(ball);
53
+ }
54
+ const spread = Math.max(...travelled) - Math.min(...travelled);
55
+ expect(travelled[1]).toBeGreaterThan(8);
56
+ expect(spread).toBeLessThan(0.2);
57
+ });
41
58
  test("raycast reports the floor with an upward normal and the right distance", () => {
42
59
  const backend = create();
43
60
  const ground = floor(backend);
@@ -276,6 +276,13 @@ export declare class PhysicsWorld {
276
276
  getStats(): PhysicsStats;
277
277
  private substep;
278
278
  private constrainBounds;
279
+ /**
280
+ * Coulomb friction against the bounds floor, using the same `friction` coefficient a contact
281
+ * applies: horizontal speed bleeds by `friction * |gravity| * dt` per substep. The old
282
+ * multiplicative form (`* (1 - friction)` every substep) removed 60 × 40% per second, so a body
283
+ * on the world floor stopped ~6× shorter than the same body on a static floor collider.
284
+ */
285
+ private applyFloorFriction;
279
286
  /**
280
287
  * Walk every broadphase cell body `i`'s AABB overlaps, invoking `visit(cell)` once per cell. The
281
288
  * cell-range math (six `cellCoord` calls plus the row-major triple loop) lives here so the count
@@ -565,7 +565,7 @@ export class PhysicsWorld {
565
565
  this.posX[i] += this.velX[i] * dt;
566
566
  this.posY[i] += this.velY[i] * dt;
567
567
  this.posZ[i] += this.velZ[i] * dt;
568
- this.constrainBounds(i);
568
+ this.constrainBounds(i, dt, true);
569
569
  }
570
570
  this.buildGrid();
571
571
  const iterations = this.solverIterations;
@@ -575,10 +575,10 @@ export class PhysicsWorld {
575
575
  this.solveJoints(dt);
576
576
  }
577
577
  for (let a = 0; a < this.awakeCount; a += 1)
578
- this.constrainBounds(this.awakeList[a]);
578
+ this.constrainBounds(this.awakeList[a], dt, false);
579
579
  this.updateSleep();
580
580
  }
581
- constrainBounds(i) {
581
+ constrainBounds(i, dt, frictionPass) {
582
582
  const min = this.bounds.min;
583
583
  const max = this.bounds.max;
584
584
  const e = this.restitution;
@@ -613,9 +613,8 @@ export class PhysicsWorld {
613
613
  this.posY[i] = loY;
614
614
  if (this.velY[i] < 0)
615
615
  this.velY[i] = -this.velY[i] < rt ? 0 : this.velY[i] * -e;
616
- const tf = 1 - this.friction;
617
- this.velX[i] *= tf;
618
- this.velZ[i] *= tf;
616
+ if (frictionPass)
617
+ this.applyFloorFriction(i, dt);
619
618
  }
620
619
  else if (this.posY[i] > hiY) {
621
620
  this.posY[i] = hiY;
@@ -623,6 +622,28 @@ export class PhysicsWorld {
623
622
  this.velY[i] = this.velY[i] < rt ? 0 : -this.velY[i] * e;
624
623
  }
625
624
  }
625
+ /**
626
+ * Coulomb friction against the bounds floor, using the same `friction` coefficient a contact
627
+ * applies: horizontal speed bleeds by `friction * |gravity| * dt` per substep. The old
628
+ * multiplicative form (`* (1 - friction)` every substep) removed 60 × 40% per second, so a body
629
+ * on the world floor stopped ~6× shorter than the same body on a static floor collider.
630
+ */
631
+ applyFloorFriction(i, dt) {
632
+ const decel = this.friction * Math.abs(this.gravity) * dt;
633
+ if (!(decel > 0))
634
+ return;
635
+ const vx = this.velX[i];
636
+ const vz = this.velZ[i];
637
+ const speed = Math.sqrt(vx * vx + vz * vz);
638
+ if (speed <= decel) {
639
+ this.velX[i] = 0;
640
+ this.velZ[i] = 0;
641
+ return;
642
+ }
643
+ const keep = (speed - decel) / speed;
644
+ this.velX[i] = vx * keep;
645
+ this.velZ[i] = vz * keep;
646
+ }
626
647
  /**
627
648
  * Walk every broadphase cell body `i`'s AABB overlaps, invoking `visit(cell)` once per cell. The
628
649
  * cell-range math (six `cellCoord` calls plus the row-major triple loop) lives here so the count
@@ -19,6 +19,28 @@ export interface RunOffer<T> {
19
19
  changeId: string | null;
20
20
  change: T | null;
21
21
  }
22
+ /** One change a run could open with, in the shape a choice screen shows: the id and its payload. */
23
+ export interface RunChoice<T> {
24
+ id: string;
25
+ change: T;
26
+ }
27
+ /** What the loop reports about the run it is playing, for a capture or devtools read. */
28
+ export interface RunReadout {
29
+ /** The run in progress, or the last settled run when none is open. */
30
+ run: number;
31
+ /** What the run in progress carries and can still lose. */
32
+ carried: number;
33
+ banked: number;
34
+ lost: number;
35
+ /** The best single bank across the career. */
36
+ best: number;
37
+ /** The change the run in progress opened with, or the one the next run is offered. */
38
+ offered: string | null;
39
+ }
40
+ /** Read-only handle a capture or devtools uses to prove a run loop is live, not merely imported. */
41
+ export interface RunProbe {
42
+ read(): RunReadout;
43
+ }
22
44
  /** Everything a run loop stores as plain JSON: the run in progress plus lifetime totals. */
23
45
  export interface RunLoopState {
24
46
  /** The run in progress, or `0` before the first `start`. */
@@ -66,6 +88,17 @@ export interface RunLoop<T> {
66
88
  haul(): number;
67
89
  /** The run in progress's offer, or the offer the next `start` would open. */
68
90
  offer(): RunOffer<T>;
91
+ /**
92
+ * Up to `count` distinct changes the next run could open with, drawn weighted and in draw order,
93
+ * so a game can offer a choice instead of the single change {@link RunOffer} names. `choose` takes one.
94
+ */
95
+ present(count: number): RunChoice<T>[];
96
+ /**
97
+ * Open the next run around `changeId` (or around no change for `null`), which is how a presented
98
+ * choice becomes the change the run carries. Returns false for an id this loop's table does not
99
+ * carry, leaving the offer untouched.
100
+ */
101
+ choose(changeId: string | null): boolean;
69
102
  /**
70
103
  * Close the run in progress: bank the haul scaled by `multiplier`, or lose it. Throws when no run
71
104
  * is open, so a double settle cannot bank the same haul twice.
@@ -81,8 +114,18 @@ export interface RunLoop<T> {
81
114
  * with the game (pair it with `progression/careerBank`). After each settled run the loop draws the next
82
115
  * run's change from `changes`: eligible when `after <= completed`, never the change just played unless
83
116
  * 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.
117
+ * seed and the same run history give the same sequence, before or after a save/load. `present(count)`
118
+ * draws several of those changes for a choice screen and `choose(id)` adopts the pick as the next run's
119
+ * change.
85
120
  *
86
121
  * @capability run-loop bank or lose what a run carries, with one deterministic change per run
87
122
  */
88
123
  export declare function createRunLoop<T>(config: RunLoopConfig<T>): RunLoop<T>;
124
+ /**
125
+ * Publish the loop's own readout on `window.__jgengineRun`, so a capture or devtools probe can state
126
+ * the live run, what it carries, what it banked or lost and the change it offers. Reads only; it never
127
+ * starts, settles or changes a run. Returns the uninstaller.
128
+ *
129
+ * @capability run-loop bank or lose what a run carries, with one deterministic change per run
130
+ */
131
+ export declare function installRunProbe<T>(loop: RunLoop<T>): () => void;
@@ -10,7 +10,9 @@ function positive(value) {
10
10
  * with the game (pair it with `progression/careerBank`). After each settled run the loop draws the next
11
11
  * run's change from `changes`: eligible when `after <= completed`, never the change just played unless
12
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.
13
+ * seed and the same run history give the same sequence, before or after a save/load. `present(count)`
14
+ * draws several of those changes for a choice screen and `choose(id)` adopts the pick as the next run's
15
+ * change.
14
16
  *
15
17
  * @capability run-loop bank or lose what a run carries, with one deterministic change per run
16
18
  */
@@ -30,14 +32,18 @@ export function createRunLoop(config) {
30
32
  let lostTotal = 0;
31
33
  let bestHaul = 0;
32
34
  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;
35
+ const pool = poolFor(index, previous);
37
36
  const rng = seededRng(`${config.seed}:run:${index}`);
38
37
  const drawn = pickWeighted(rng, pool, (change) => change.weight ?? 1) ?? null;
39
38
  return { run: index, changeId: drawn?.id ?? null, change: drawn?.value ?? null };
40
39
  }
40
+ /** The changes run `index` may open with: `after`-eligible, and never the one just played while others remain. */
41
+ function poolFor(index, previous) {
42
+ const eligible = table.filter((change) => (change.after ?? 0) <= index - 1);
43
+ const fresh = eligible.filter((change) => change.id !== previous);
44
+ // A one-entry table still offers its change rather than nothing.
45
+ return fresh.length > 0 ? fresh : eligible;
46
+ }
41
47
  function offer() {
42
48
  // An open run's change was adopted at `start`, so the offer repeats it instead of redrawing.
43
49
  if (run > completed) {
@@ -62,6 +68,32 @@ export function createRunLoop(config) {
62
68
  },
63
69
  haul: () => haul,
64
70
  offer,
71
+ present(count) {
72
+ const index = run > completed ? run : completed + 1;
73
+ const remaining = poolFor(index, changeId);
74
+ const drawn = [];
75
+ const wanted = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0;
76
+ const rng = seededRng(`${config.seed}:present:${index}`);
77
+ while (drawn.length < wanted && remaining.length > 0) {
78
+ const picked = pickWeighted(rng, remaining, (change) => change.weight ?? 1);
79
+ if (picked === undefined)
80
+ break;
81
+ drawn.push({ id: picked.id, change: picked.value });
82
+ remaining.splice(remaining.indexOf(picked), 1);
83
+ }
84
+ return drawn;
85
+ },
86
+ choose(nextChangeId) {
87
+ if (nextChangeId !== null && !known.has(nextChangeId))
88
+ return false;
89
+ // A settled loop has no run to adopt the pick, so the pick opens the next one.
90
+ if (run <= completed) {
91
+ run = completed + 1;
92
+ haul = 0;
93
+ }
94
+ changeId = nextChangeId;
95
+ return true;
96
+ },
65
97
  settle(outcome, multiplier) {
66
98
  if (run <= completed)
67
99
  throw new Error("run loop: no run is open to settle");
@@ -90,3 +122,32 @@ export function createRunLoop(config) {
90
122
  },
91
123
  };
92
124
  }
125
+ const RUN_PROBE_KEY = "__jgengineRun";
126
+ /**
127
+ * Publish the loop's own readout on `window.__jgengineRun`, so a capture or devtools probe can state
128
+ * the live run, what it carries, what it banked or lost and the change it offers. Reads only; it never
129
+ * starts, settles or changes a run. Returns the uninstaller.
130
+ *
131
+ * @capability run-loop bank or lose what a run carries, with one deterministic change per run
132
+ */
133
+ export function installRunProbe(loop) {
134
+ const scope = globalThis;
135
+ const probe = {
136
+ read() {
137
+ const state = loop.snapshot();
138
+ return {
139
+ run: state.run,
140
+ carried: state.haul,
141
+ banked: state.bankedTotal,
142
+ lost: state.lostTotal,
143
+ best: state.bestHaul,
144
+ offered: loop.offer().changeId,
145
+ };
146
+ },
147
+ };
148
+ scope[RUN_PROBE_KEY] = probe;
149
+ return () => {
150
+ if (scope[RUN_PROBE_KEY] === probe)
151
+ delete scope[RUN_PROBE_KEY];
152
+ };
153
+ }