@jgengine/core 0.37.0 → 0.38.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,69 @@ 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.38.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.38.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.0`; `@jgengine/github` is `0.25.0`.
34
+ - Composing a session loop directly: pass your rule as `settle`, `carries: false` when nothing is carried, and your authored options as `offers`. `createRunLoop` is removed one minor release after Beacon Bastion and Drift Foundry stop using it.
35
+
36
+ ### Added
37
+
38
+ - `GameMenu` and `GamePauseMenu` (`@jgengine/react/gameMenu`): a full-bleed menu surface over the live scene or drifting key art, with a token-sized logo, an animated navigable verb list, a generated selection tick and a detail pane per verb. The document scroll is locked while it is open, so the surface is exactly the viewport at any size.
39
+ - Panes for the detail slot: `ContinuePane`, `NewCharacterPane`, `SaveSlotsPane` (slot cards with a `meta.thumbnail`, playtime, progress, timestamp and a two-press delete) and `CreditsPane` (`@jgengine/react/gameMenuSaves`).
40
+ - `shrinkCapture` (`@jgengine/react/saveThumbnail`) turns a `captureCanvas` frame into a card-sized thumbnail data URL.
41
+ - `createSessionLoop` (`@jgengine/core/run/sessionLoop`) is one session loop for every game: a session opens, may carry a quantity, closes at its boundary, and the game's own `settle` callback decides what happens to what it carried. `carries: false` for a session with nothing at stake, and an optional `offers` table (with `present`/`choose`) for a game whose next session opens with an authored change; without a table nothing is drawn. `installSessionProbe` reports the live session on `window.__jgengineRun`.
42
+
43
+ ### Changed
44
+
45
+ - `switchSlot(slot, { flush: false })` (`@jgengine/core/game/gameSave`) loads a slot without first writing the live state over it. Opening a save from a menu uses it so Continue cannot overwrite the save it resumes.
46
+ - The dev runner's registry and environment modules import outside Vite, so a test or tool can read them.
47
+ - `createRunLoop` is now a thin composition of `createSessionLoop` and is deprecated: bank-or-lose and a per-run change table are one game type's rules, so they live in the composition instead of the base. Its API, state shape and tests are unchanged. One difference: the option draw is seeded under `:offer:` rather than `:run:` (`seededRng(\`${seed}:offer:${index}\`)`), so a career that keeps its seed draws a different change on a given session than 0.37.0 did for the same seed. Ids, payloads and saves stay valid: a saved `changeId` still restores the same change.
48
+
49
+ ### Fixed
50
+
51
+ - `shoot` fails instead of writing a PNG when the whole frame is one flat colour, and when a scene is still behind its loading cover. Both used to write a plausible screenshot that read as evidence of a rendered frame.
52
+ - The editor's play-mode pixel test waits for the warmup cover to clear before screenshotting. It shot the cover, so it read `0.9988` one-colour locally and passed in CI where the race fell the other way.
53
+ - `shoot` prints the WebGL2 renderer it draws with and fails the capture outright when the launched browser has none.
54
+
55
+ Root cause: capture readiness was trusted to mean "the scene has rendered", but a loading cover and a canvas that never committed both photograph as a flat frame, and nothing measured the frame after the fact. Headless Chrome on this PC was never the problem: it renders with the GPU (`ANGLE (NVIDIA GeForce RTX 3070, D3D11)`) and with SwiftShader when asked.
56
+
57
+ Before: `bun run shoot --url <flat canvas page>` exited 0 and wrote a one-colour PNG, and `packages/editor/src/CreatorApp.test.ts` failed locally at `dominantColorShare 0.9988`.
58
+ After: the same shoot exits 1 with `the whole frame is one flat color (100.0%) - no scene reached the capture - nothing was written`, and the editor test passes in 12s with sky and ground in the PNG.
59
+
60
+ Cost: forcing SwiftShader on the same game shot costs 2.4x the capture (16.0s vs 6.7s warm, beacon-bastion play at half size), so the launch keeps the native GPU. The two checks add about 10ms to a shot.
61
+
62
+ Verification:
63
+ - `bun test scripts/shot-metrics.test.ts` - pass
64
+ - `bun test packages/editor/src/CreatorApp.test.ts` - pass
65
+ - `bun run shoot beacon-bastion --mode play --size half` - exit 0, scene renders
66
+ - `bun run shoot --url <flat canvas page>` - exit 1, no PNG written
67
+ - `bun run verify:fast` - pass
68
+
69
+ The CI reliability fix this lane also carried (a browser setup hook shorter than Playwright's launch default) is #2335, so either change can be reverted without the other.
70
+ - The publish workflow's last step no longer reports a whole release as unpublished because it ran seconds after `npm publish`. `check:release-artifacts --wait <seconds>` re-checks while the only failures are absent packages (no metadata, version missing, tarball 404) and gives up when the budget runs out; a wrong version in the tarball, a missing subpath or a main entry that will not import still fails on the first check. The 0.37.0 publish run failed this step at 04:48 listing all 13 packages, then npm served every one of them. The step now passes `-- --wait 300`.
71
+ - A Playwright test file's `beforeAll` now outlives the Chrome launch it waits on, and `browser-test-budget` rejects a file that leaves the hook at or under Playwright's 30 s launch default. On `test (g)` in #2326 the hook's 30 s budget and that default expired together, so CI reported only `(unnamed) hook timed out` beside `killed 1 dangling process` and nothing named the launch.
72
+ - `packages/react/src/dialogBehavior.test.ts` gives its browser navigation its own 20 s deadline and one retry inside a 60 s hook.
73
+
74
+ Root cause: Bun prints a `beforeAll` timeout as an unnamed hook timeout, so a setup budget equal to Playwright's own 30 s launch default reports the anonymous timer instead of the launch that stalled.
75
+
76
+ Before: `test (g)` on #2326 reported `(fail) (unnamed) [30001.51ms] ^ a beforeEach/afterEach hook timed out` and `killed 1 dangling process`.
77
+ After: a stall now surfaces as Playwright's own `Timeout ... exceeded`, and `bun scripts/browser-test-budget.ts` names any file whose setup hook sits at or under the launch default.
78
+
79
+ Verification:
80
+ - `bun scripts/browser-test-budget.ts` - pass, and the new rule names the unraised files on the previous tip
81
+ - `bun test scripts/browser-test-budget.test.ts` - pass
82
+ - `bun test packages/react/src/dialogBehavior.test.ts packages/react/src/scrollRail.test.ts packages/shell/src/settingsBoundary.test.ts` - pass
83
+ - `bun run verify:fast` - pass
84
+ - A prop placed at runtime honours its model's `variants` levels. `StaticObjectModels` applied the level switch only inside its batched branch, so a prop placed with `scene.object.place` — Harbor Heat's palm line — drew its full asset at every camera distance: 15 palms at 9,134 triangles each, and pointing the far level at a dispatch booth changed nothing on screen. `IsolatedEntityModel` now mounts `SingleVariantModel`, which asks the same `createModelVariantSelector` and `lodBandIndex` authority the batched path uses, at the prop's own camera distance, and keeps only the active level mounted so both levels never draw at once. The switch carries the selector's hysteresis, warms every level at mount, measures only the near level, and reads the placement from the scene store through scratch vectors, so a frame allocates nothing. One palm past 60 m draws 4,110 triangles instead of 9,134 (5,024 saved, 45% of the full level).
85
+
86
+ - `singleVariantLevel` and `singleVariantModel` (`@jgengine/shell` internal, `render/singleVariantLod`) are the isolated path's level decision and the model it draws, so a fixture pins the same switch the component runs.
87
+
88
+ `bun test packages/shell/src/render/singleVariantLod.test.ts` — 6 pass. With the old render decision restored, the wiring test fails (`Expected: SingleVariantModel, Received: EntityModel`).
89
+ `bun --cwd=packages/shell run test` — 1,229 pass, 0 fail.
90
+ `bun --cwd=packages/shell run check-types` — clean.
91
+
29
92
  ## 0.37.0
30
93
 
31
94
  ### Migrate
@@ -102,8 +102,14 @@ export interface GameSave {
102
102
  save(): Promise<void>;
103
103
  /** Read the active slot and apply every section to its system. */
104
104
  load(): Promise<GameSaveDocument>;
105
- /** Flush the current slot, switch, and load the next one. */
106
- switchSlot(slot: string): Promise<GameSaveDocument>;
105
+ /**
106
+ * Switch the active slot and load it. By default the live state is flushed into the slot being
107
+ * left first; pass `{ flush: false }` when opening a save from a menu, where the live state is
108
+ * either empty or already the slot being opened and flushing would overwrite the save.
109
+ */
110
+ switchSlot(slot: string, options?: {
111
+ flush?: boolean;
112
+ }): Promise<GameSaveDocument>;
107
113
  /**
108
114
  * Read a slot's document without applying it to the live systems — the save-select menu's data
109
115
  * source. Returns `null` for an empty slot.
@@ -137,8 +137,9 @@ export function createGameSave(config) {
137
137
  apply(document);
138
138
  return document;
139
139
  },
140
- async switchSlot(slot) {
141
- store.set(capture());
140
+ async switchSlot(slot, options) {
141
+ if (options?.flush !== false)
142
+ store.set(capture());
142
143
  const document = await store.switchSlot(slot);
143
144
  apply(document);
144
145
  return document;
@@ -1,5 +1,5 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export declare const VERSION = "0.37.0";
2
+ export declare const VERSION = "0.38.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,42 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export const VERSION = "0.37.0";
2
+ export const VERSION = "0.38.0";
3
3
  /** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
4
4
  export const CHANGELOG = {
5
+ "0.38.0": {
6
+ migrate: [
7
+ "Bump lockstep SDK packages to `^0.38.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.0`; `@jgengine/github` is `0.25.0`.",
8
+ "Composing a session loop directly: pass your rule as `settle`, `carries: false` when nothing is carried, and your authored options as `offers`. `createRunLoop` is removed one minor release after Beacon Bastion and Drift Foundry stop using it.",
9
+ ],
10
+ added: [
11
+ "`GameMenu` and `GamePauseMenu` (`@jgengine/react/gameMenu`): a full-bleed menu surface over the live scene or drifting key art, with a token-sized logo, an animated navigable verb list, a generated selection tick and a detail pane per verb. The document scroll is locked while it is open, so the surface is exactly the viewport at any size.",
12
+ "Panes for the detail slot: `ContinuePane`, `NewCharacterPane`, `SaveSlotsPane` (slot cards with a `meta.thumbnail`, playtime, progress, timestamp and a two-press delete) and `CreditsPane` (`@jgengine/react/gameMenuSaves`).",
13
+ "`shrinkCapture` (`@jgengine/react/saveThumbnail`) turns a `captureCanvas` frame into a card-sized thumbnail data URL.",
14
+ "`createSessionLoop` (`@jgengine/core/run/sessionLoop`) is one session loop for every game: a session opens, may carry a quantity, closes at its boundary, and the game's own `settle` callback decides what happens to what it carried. `carries: false` for a session with nothing at stake, and an optional `offers` table (with `present`/`choose`) for a game whose next session opens with an authored change; without a table nothing is drawn. `installSessionProbe` reports the live session on `window.__jgengineRun`.",
15
+ ],
16
+ changed: [
17
+ "`switchSlot(slot, { flush: false })` (`@jgengine/core/game/gameSave`) loads a slot without first writing the live state over it. Opening a save from a menu uses it so Continue cannot overwrite the save it resumes.",
18
+ "The dev runner's registry and environment modules import outside Vite, so a test or tool can read them.",
19
+ "`createRunLoop` is now a thin composition of `createSessionLoop` and is deprecated: bank-or-lose and a per-run change table are one game type's rules, so they live in the composition instead of the base. Its API, state shape and tests are unchanged. One difference: the option draw is seeded under `:offer:` rather than `:run:` (`seededRng(\\`${seed}:offer:${index}\\`)`), so a career that keeps its seed draws a different change on a given session than 0.37.0 did for the same seed. Ids, payloads and saves stay valid: a saved `changeId` still restores the same change.",
20
+ "`shoot` fails instead of writing a PNG when the whole frame is one flat colour, and when a scene is still behind its loading cover. Both used to write a plausible screenshot that read as evidence of a rendered frame.",
21
+ "The editor's play-mode pixel test waits for the warmup cover to clear before screenshotting. It shot the cover, so it read `0.9988` one-colour locally and passed in CI where the race fell the other way.",
22
+ "`shoot` prints the WebGL2 renderer it draws with and fails the capture outright when the launched browser has none.",
23
+ "`bun test scripts/shot-metrics.test.ts` - pass",
24
+ "`bun test packages/editor/src/CreatorApp.test.ts` - pass",
25
+ "`bun run shoot beacon-bastion --mode play --size half` - exit 0, scene renders",
26
+ "`bun run shoot --url <flat canvas page>` - exit 1, no PNG written",
27
+ "`bun run verify:fast` - pass",
28
+ "The publish workflow's last step no longer reports a whole release as unpublished because it ran seconds after `npm publish`. `check:release-artifacts --wait <seconds>` re-checks while the only failures are absent packages (no metadata, version missing, tarball 404) and gives up when the budget runs out; a wrong version in the tarball, a missing subpath or a main entry that will not import still fails on the first check. The 0.37.0 publish run failed this step at 04:48 listing all 13 packages, then npm served every one of them. The step now passes `-- --wait 300`.",
29
+ "A Playwright test file's `beforeAll` now outlives the Chrome launch it waits on, and `browser-test-budget` rejects a file that leaves the hook at or under Playwright's 30 s launch default. On `test (g)` in #2326 the hook's 30 s budget and that default expired together, so CI reported only `(unnamed) hook timed out` beside `killed 1 dangling process` and nothing named the launch.",
30
+ "`packages/react/src/dialogBehavior.test.ts` gives its browser navigation its own 20 s deadline and one retry inside a 60 s hook.",
31
+ "`bun scripts/browser-test-budget.ts` - pass, and the new rule names the unraised files on the previous tip",
32
+ "`bun test scripts/browser-test-budget.test.ts` - pass",
33
+ "`bun test packages/react/src/dialogBehavior.test.ts packages/react/src/scrollRail.test.ts packages/shell/src/settingsBoundary.test.ts` - pass",
34
+ "`bun run verify:fast` - pass",
35
+ "A prop placed at runtime honours its model's `variants` levels. `StaticObjectModels` applied the level switch only inside its batched branch, so a prop placed with `scene.object.place` — Harbor Heat's palm line — drew its full asset at every camera distance: 15 palms at 9,134 triangles each, and pointing the far level at a dispatch booth changed nothing on screen. `IsolatedEntityModel` now mounts `SingleVariantModel`, which asks the same `createModelVariantSelector` and `lodBandIndex` authority the batched path uses, at the prop's own camera distance, and keeps only the active level mounted so both levels never draw at once. The switch carries the selector's hysteresis, warms every level at mount, measures only the near level, and reads the placement from the scene store through scratch vectors, so a frame allocates nothing. One palm past 60 m draws 4,110 triangles instead of 9,134 (5,024 saved, 45% of the full level).",
36
+ "`singleVariantLevel` and `singleVariantModel` (`@jgengine/shell` internal, `render/singleVariantLod`) are the isolated path's level decision and the model it draws, so a fixture pins the same switch the component runs.",
37
+ ],
38
+ removed: [],
39
+ },
5
40
  "0.37.0": {
6
41
  migrate: [
7
42
  "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`.",
@@ -113,12 +113,20 @@ export interface RunLoop<T> {
113
113
  * A run's haul banks when it ends in a bank and is lost when it ends in a loss; the money itself stays
114
114
  * with the game (pair it with `progression/careerBank`). After each settled run the loop draws the next
115
115
  * run's change from `changes`: eligible when `after <= completed`, never the change just played unless
116
- * it is the only one eligible, weighted by `weight`. The draw is `seededRng(seed:run:N)`, so the same
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.
116
+ * it is the only one eligible, weighted by `weight`. The draw is seeded, so the same seed and the same
117
+ * run history give the same sequence, before or after a save/load. `present(count)` draws several of
118
+ * those changes for a choice screen and `choose(id)` adopts the pick as the next run's change.
120
119
  *
121
- * @capability run-loop bank or lose what a run carries, with one deterministic change per run
120
+ * This is a composition of `createSessionLoop` (`run/sessionLoop`): the session loop owns opening,
121
+ * carrying, closing and restoring, and this module supplies the two things that make it a roguelike -
122
+ * a `settle` that banks or loses the haul, and a change table the next run can open with.
123
+ *
124
+ * @deprecated Bank-or-lose and a per-run change table are one game type's rules, not the engine's.
125
+ * Compose `createSessionLoop` with your own `settle` and `offers` instead. Kept unchanged for the two
126
+ * adopters (Beacon Bastion, Drift Foundry) that pin 0.37.0. Removal path: delete this module one minor
127
+ * release after both compose `createSessionLoop` directly.
128
+ *
129
+ * @capability run-loop deprecated roguelike composition of session-loop - bank or lose what a run carries, with one deterministic change per run
122
130
  */
123
131
  export declare function createRunLoop<T>(config: RunLoopConfig<T>): RunLoop<T>;
124
132
  /**
@@ -126,6 +134,9 @@ export declare function createRunLoop<T>(config: RunLoopConfig<T>): RunLoop<T>;
126
134
  * the live run, what it carries, what it banked or lost and the change it offers. Reads only; it never
127
135
  * starts, settles or changes a run. Returns the uninstaller.
128
136
  *
129
- * @capability run-loop bank or lose what a run carries, with one deterministic change per run
137
+ * @deprecated Ships with {@link createRunLoop}; compose `installSessionProbe` from `run/sessionLoop`
138
+ * when you compose the session loop directly.
139
+ *
140
+ * @capability run-loop deprecated roguelike composition of session-loop - bank or lose what a run carries, with one deterministic change per run
130
141
  */
131
142
  export declare function installRunProbe<T>(loop: RunLoop<T>): () => void;
@@ -1,5 +1,4 @@
1
- import { pickWeighted } from "../random/pick.js";
2
- import { seededRng } from "../random/rng.js";
1
+ import { createSessionLoop } from "./sessionLoop.js";
3
2
  function positive(value) {
4
3
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
5
4
  }
@@ -9,113 +8,79 @@ function positive(value) {
9
8
  * 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
9
  * with the game (pair it with `progression/careerBank`). After each settled run the loop draws the next
11
10
  * 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. `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.
11
+ * it is the only one eligible, weighted by `weight`. The draw is seeded, so the same seed and the same
12
+ * run history give the same sequence, before or after a save/load. `present(count)` draws several of
13
+ * those changes for a choice screen and `choose(id)` adopts the pick as the next run's change.
16
14
  *
17
- * @capability run-loop bank or lose what a run carries, with one deterministic change per run
15
+ * This is a composition of `createSessionLoop` (`run/sessionLoop`): the session loop owns opening,
16
+ * carrying, closing and restoring, and this module supplies the two things that make it a roguelike -
17
+ * a `settle` that banks or loses the haul, and a change table the next run can open with.
18
+ *
19
+ * @deprecated Bank-or-lose and a per-run change table are one game type's rules, not the engine's.
20
+ * Compose `createSessionLoop` with your own `settle` and `offers` instead. Kept unchanged for the two
21
+ * adopters (Beacon Bastion, Drift Foundry) that pin 0.37.0. Removal path: delete this module one minor
22
+ * release after both compose `createSessionLoop` directly.
23
+ *
24
+ * @capability run-loop deprecated roguelike composition of session-loop - bank or lose what a run carries, with one deterministic change per run
18
25
  */
19
26
  export function createRunLoop(config) {
20
- const table = config.changes ?? [];
21
- const known = new Set();
22
- for (const change of table) {
23
- if (known.has(change.id))
24
- throw new Error(`run change "${change.id}" is declared twice`);
25
- known.add(change.id);
26
- }
27
- let run = 0;
28
- let completed = 0;
29
- let haul = 0;
30
- let changeId = null;
31
27
  let bankedTotal = 0;
32
28
  let lostTotal = 0;
33
29
  let bestHaul = 0;
34
- function drawFor(index, previous) {
35
- const pool = poolFor(index, previous);
36
- const rng = seededRng(`${config.seed}:run:${index}`);
37
- const drawn = pickWeighted(rng, pool, (change) => change.weight ?? 1) ?? null;
38
- return { run: index, changeId: drawn?.id ?? null, change: drawn?.value ?? null };
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
- }
47
- function offer() {
48
- // An open run's change was adopted at `start`, so the offer repeats it instead of redrawing.
49
- if (run > completed) {
50
- const adopted = table.find((change) => change.id === changeId);
51
- return { run, changeId, change: adopted?.value ?? null };
52
- }
53
- return drawFor(completed + 1, changeId);
54
- }
55
- return {
56
- start() {
57
- if (run > completed)
58
- return offer();
59
- const next = offer();
60
- run = next.run;
61
- haul = 0;
62
- changeId = next.changeId;
63
- return next;
64
- },
65
- carry(amount) {
66
- haul += positive(amount);
67
- return haul;
68
- },
69
- haul: () => haul,
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
- },
97
- settle(outcome, multiplier) {
98
- if (run <= completed)
99
- throw new Error("run loop: no run is open to settle");
100
- const settledHaul = haul;
101
- const scale = Number.isFinite(multiplier) ? Math.max(0, multiplier ?? 1) : 1;
102
- const banked = outcome === "banked" ? Math.round(settledHaul * scale) : 0;
103
- const lost = outcome === "lost" ? settledHaul : 0;
30
+ const loop = createSessionLoop({
31
+ kind: "run",
32
+ seed: config.seed,
33
+ offers: config.changes,
34
+ settle: ({ carried, input }) => {
35
+ const scale = Number.isFinite(input.multiplier) ? Math.max(0, input.multiplier ?? 1) : 1;
36
+ const banked = input.outcome === "banked" ? Math.round(carried * scale) : 0;
37
+ const lost = input.outcome === "lost" ? carried : 0;
104
38
  bankedTotal += banked;
105
39
  lostTotal += lost;
106
40
  bestHaul = Math.max(bestHaul, banked);
107
- completed = run;
108
- haul = 0;
109
- return { outcome, haul: settledHaul, banked, lost, completed, bestHaul, next: drawFor(completed + 1, changeId) };
41
+ return { banked, lost };
42
+ },
43
+ });
44
+ const offerOf = (offer) => ({
45
+ run: offer.session,
46
+ changeId: offer.optionId,
47
+ change: offer.option,
48
+ });
49
+ return {
50
+ start: () => offerOf(loop.start()),
51
+ carry: (amount) => loop.carry(amount),
52
+ haul: () => loop.carried(),
53
+ offer: () => offerOf(loop.offer()),
54
+ present: (count) => loop.present(count).map((choice) => ({ id: choice.id, change: choice.value })),
55
+ choose: (changeId) => loop.choose(changeId),
56
+ settle(outcome, multiplier) {
57
+ if (loop.phase() !== "open")
58
+ throw new Error("run loop: no run is open to settle");
59
+ const settlement = loop.settle({ outcome, multiplier });
60
+ return {
61
+ outcome,
62
+ haul: settlement.carried,
63
+ banked: settlement.result.banked,
64
+ lost: settlement.result.lost,
65
+ completed: settlement.closed,
66
+ bestHaul,
67
+ next: offerOf(settlement.next),
68
+ };
69
+ },
70
+ snapshot() {
71
+ const state = loop.snapshot();
72
+ return {
73
+ run: state.session,
74
+ completed: state.closed,
75
+ haul: state.carried,
76
+ changeId: state.optionId,
77
+ bankedTotal,
78
+ lostTotal,
79
+ bestHaul,
80
+ };
110
81
  },
111
- snapshot: () => ({ run, completed, haul, changeId, bankedTotal, lostTotal, bestHaul }),
112
82
  restore(next) {
113
- run = Math.max(0, Math.floor(next.run));
114
- completed = Math.max(0, Math.floor(next.completed));
115
- if (run < completed)
116
- run = completed;
117
- haul = positive(next.haul);
118
- changeId = next.changeId !== null && known.has(next.changeId) ? next.changeId : null;
83
+ loop.restore({ session: next.run, closed: next.completed, carried: next.haul, optionId: next.changeId });
119
84
  bankedTotal = positive(next.bankedTotal);
120
85
  lostTotal = positive(next.lostTotal);
121
86
  bestHaul = positive(next.bestHaul);
@@ -128,7 +93,10 @@ const RUN_PROBE_KEY = "__jgengineRun";
128
93
  * the live run, what it carries, what it banked or lost and the change it offers. Reads only; it never
129
94
  * starts, settles or changes a run. Returns the uninstaller.
130
95
  *
131
- * @capability run-loop bank or lose what a run carries, with one deterministic change per run
96
+ * @deprecated Ships with {@link createRunLoop}; compose `installSessionProbe` from `run/sessionLoop`
97
+ * when you compose the session loop directly.
98
+ *
99
+ * @capability run-loop deprecated roguelike composition of session-loop - bank or lose what a run carries, with one deterministic change per run
132
100
  */
133
101
  export function installRunProbe(loop) {
134
102
  const scope = globalThis;
@@ -0,0 +1,152 @@
1
+ /** Where a session stands: not opened yet, in progress, or closed at its boundary. */
2
+ export type SessionPhase = "idle" | "open" | "closed";
3
+ /**
4
+ * One option the next session may open with. `T` is the game's own payload (a tighter rival line, an
5
+ * unlocked part, a shorter route) - the loop only draws and remembers which id was drawn.
6
+ */
7
+ export interface SessionOfferOption<T> {
8
+ id: string;
9
+ value: T;
10
+ /** Relative draw weight (default `1`); a non-positive weight is never drawn. */
11
+ weight?: number;
12
+ /** Sessions that must close before this option can be drawn (default `0`). */
13
+ after?: number;
14
+ }
15
+ /** The option a session opens with, and which session it is. */
16
+ export interface SessionOffer<T> {
17
+ /** 1-based session number: `1` is the first session, before any boundary has closed. */
18
+ session: number;
19
+ optionId: string | null;
20
+ option: T | null;
21
+ }
22
+ /** One option the next session could open with, in the shape a choice screen shows. */
23
+ export interface SessionChoice<T> {
24
+ id: string;
25
+ value: T;
26
+ }
27
+ /** The session closing, handed to a game's own `settle`. */
28
+ export interface SettlingSession<I = void> {
29
+ /** 1-based number of the session closing. */
30
+ session: number;
31
+ /** The quantity the session carried to its boundary; `0` when the game carries nothing. */
32
+ carried: number;
33
+ /** The game's own reason for closing, passed to `settle`. */
34
+ input: I;
35
+ }
36
+ /** What closing one session did, and what the next session opens with. */
37
+ export interface SessionSettlement<R = void, T = never> {
38
+ /** The session that closed. */
39
+ session: number;
40
+ /** The quantity it carried to its boundary. */
41
+ carried: number;
42
+ /** Sessions closed so far. */
43
+ closed: number;
44
+ /** What the game's `settle` returned. */
45
+ result: R;
46
+ /** The option the next session opens with. */
47
+ next: SessionOffer<T>;
48
+ }
49
+ /** What the loop reports about the session it is playing, for a capture or devtools read. */
50
+ export interface SessionReadout {
51
+ /** The session in progress, or the last one started when none is open. */
52
+ session: number;
53
+ phase: SessionPhase;
54
+ /** What a session is in this game, or `null` when the game did not say. */
55
+ kind: string | null;
56
+ /** What the session in progress carries. */
57
+ carried: number;
58
+ closed: number;
59
+ /** The option the session in progress opened with, or the one the next session is offered. */
60
+ offered: string | null;
61
+ }
62
+ /** Read-only handle a capture or devtools uses to prove a session loop is live, not merely imported. */
63
+ export interface SessionProbe {
64
+ read(): SessionReadout;
65
+ }
66
+ /** Everything a session loop stores as plain JSON. */
67
+ export interface SessionLoopState {
68
+ /** The session in progress, or the last one started; `0` before the first `start`. */
69
+ session: number;
70
+ /** Sessions whose boundary has closed; also the number `after` gates are read against. */
71
+ closed: number;
72
+ /** The quantity the session in progress carries. */
73
+ carried: number;
74
+ /** The option the session in progress opened with, or the one the next session opens with. */
75
+ optionId: string | null;
76
+ }
77
+ /** Configuration for {@link createSessionLoop}. */
78
+ export interface SessionLoopConfig<T = never, I = void, R = void> {
79
+ /** What a session is, for the readout: `"day"`, `"shift"`, `"wave"`. */
80
+ kind?: string;
81
+ /** Whether a session carries a quantity (default `true`); `false` makes {@link SessionLoop.carry} inert. */
82
+ carries?: boolean;
83
+ /**
84
+ * What the boundary does with what the session carried: the game's own rule, so bank, lose, keep and
85
+ * nothing are all one callback. Omitted means closing a session only closes it.
86
+ */
87
+ settle?: (session: SettlingSession<I>) => R;
88
+ /** Authored options the next session may open with. Omitted means sessions never open with one. */
89
+ offers?: readonly SessionOfferOption<T>[];
90
+ /** Seed for the option draw (default `"session"`): the same seed and history replay the same sequence. */
91
+ seed?: string | number;
92
+ }
93
+ /**
94
+ * One session's lifecycle: open it, carry a quantity, close it at its boundary, and optionally open
95
+ * the next one with an option the game authored.
96
+ */
97
+ export interface SessionLoop<T = never, I = void, R = void> {
98
+ /** What a session is in this game, or `null` when the game did not say. */
99
+ kind(): string | null;
100
+ /** Open the next session and hand back its offer. Calling it on an open session returns that session's offer and keeps what it carries. */
101
+ start(): SessionOffer<T>;
102
+ /** Add to what the session in progress carries; negative or non-finite amounts are ignored. Returns what it carries. */
103
+ carry(amount: number): number;
104
+ /** What the session in progress carries. */
105
+ carried(): number;
106
+ /** Whether no session has opened, one is open, or the last one closed. */
107
+ phase(): SessionPhase;
108
+ /** The open session's offer, or the offer the next `start` would open with. */
109
+ offer(): SessionOffer<T>;
110
+ /**
111
+ * Up to `count` distinct options the next session could open with, drawn weighted and in draw order,
112
+ * so a game can offer a choice instead of the single option {@link SessionOffer} names. `choose` takes one.
113
+ */
114
+ present(count: number): SessionChoice<T>[];
115
+ /**
116
+ * Open the next session around `optionId` (or around no option for `null`), which is how a presented
117
+ * choice becomes the option the session carries. Returns false for an id this loop's table does not
118
+ * carry, leaving the offer untouched.
119
+ */
120
+ choose(optionId: string | null): boolean;
121
+ /**
122
+ * Close the session in progress and hand what it carried to the game's `settle` rule. Throws when no
123
+ * session is open, so a double close cannot settle the same quantity twice.
124
+ */
125
+ settle(input?: I): SessionSettlement<R, T>;
126
+ snapshot(): SessionLoopState;
127
+ restore(next: SessionLoopState): void;
128
+ }
129
+ /**
130
+ * The shared shape of a game session, with the game's own rules supplied as config: a session opens,
131
+ * it may carry a quantity, its boundary closes it, and the game's `settle` callback decides what
132
+ * happens to what it carried. A park day, a night shift and a wave are the same base with a different
133
+ * `settle`, a different `kind` and, where nothing is at stake, `carries: false`.
134
+ *
135
+ * `offers` is optional and defaults to nothing, so a game whose sessions never differ says nothing:
136
+ * sessions open plain. A game that wants its next session to open with an authored change supplies the
137
+ * table and gets the same draw rules everywhere - eligible when `after <= closed`, never the option
138
+ * just played unless it is the only one eligible, weighted by `weight`, drawn as
139
+ * `seededRng(seed:offer:N)` so the same seed and history replay the same sequence before or after a
140
+ * save/load. `present(count)` draws several for a choice screen and `choose(id)` adopts the pick.
141
+ *
142
+ * @capability session-loop open and close a game session, settle the quantity it carried and optionally draw the next session's authored option
143
+ */
144
+ export declare function createSessionLoop<T = never, I = void, R = void>(config?: SessionLoopConfig<T, I, R>): SessionLoop<T, I, R>;
145
+ /**
146
+ * Publish the loop's own readout on `window.__jgengineRun`, so a capture or devtools probe can state
147
+ * which session is live, its phase, what it carries and the option it offers. Reads only; it never
148
+ * starts, closes or changes a session. Returns the uninstaller.
149
+ *
150
+ * @capability session-loop open and close a game session, settle the quantity it carried and optionally draw the next session's authored option
151
+ */
152
+ export declare function installSessionProbe<T, I, R>(loop: SessionLoop<T, I, R>): () => void;
@@ -0,0 +1,151 @@
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
+ function count(value) {
7
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
8
+ }
9
+ /**
10
+ * The shared shape of a game session, with the game's own rules supplied as config: a session opens,
11
+ * it may carry a quantity, its boundary closes it, and the game's `settle` callback decides what
12
+ * happens to what it carried. A park day, a night shift and a wave are the same base with a different
13
+ * `settle`, a different `kind` and, where nothing is at stake, `carries: false`.
14
+ *
15
+ * `offers` is optional and defaults to nothing, so a game whose sessions never differ says nothing:
16
+ * sessions open plain. A game that wants its next session to open with an authored change supplies the
17
+ * table and gets the same draw rules everywhere - eligible when `after <= closed`, never the option
18
+ * just played unless it is the only one eligible, weighted by `weight`, drawn as
19
+ * `seededRng(seed:offer:N)` so the same seed and history replay the same sequence before or after a
20
+ * save/load. `present(count)` draws several for a choice screen and `choose(id)` adopts the pick.
21
+ *
22
+ * @capability session-loop open and close a game session, settle the quantity it carried and optionally draw the next session's authored option
23
+ */
24
+ export function createSessionLoop(config = {}) {
25
+ const table = config.offers ?? [];
26
+ const carries = config.carries !== false;
27
+ const seed = config.seed ?? "session";
28
+ const known = new Set();
29
+ for (const option of table) {
30
+ if (known.has(option.id))
31
+ throw new Error(`session option "${option.id}" is declared twice`);
32
+ known.add(option.id);
33
+ }
34
+ let session = 0;
35
+ let closed = 0;
36
+ let carried = 0;
37
+ let optionId = null;
38
+ const isOpen = () => session > closed;
39
+ function poolFor(index, previous) {
40
+ const eligible = table.filter((option) => (option.after ?? 0) <= index - 1);
41
+ const fresh = eligible.filter((option) => option.id !== previous);
42
+ // A one-entry table still offers its option rather than nothing.
43
+ return fresh.length > 0 ? fresh : eligible;
44
+ }
45
+ function drawFor(index, previous) {
46
+ const rng = seededRng(`${seed}:offer:${index}`);
47
+ const drawn = pickWeighted(rng, poolFor(index, previous), (option) => option.weight ?? 1) ?? null;
48
+ return { session: index, optionId: drawn?.id ?? null, option: drawn?.value ?? null };
49
+ }
50
+ function offer() {
51
+ // An open session's option was adopted at `start` or `choose`, so the offer repeats it instead of redrawing.
52
+ if (isOpen()) {
53
+ const adopted = table.find((option) => option.id === optionId);
54
+ return { session, optionId, option: adopted?.value ?? null };
55
+ }
56
+ return drawFor(closed + 1, optionId);
57
+ }
58
+ return {
59
+ kind: () => config.kind ?? null,
60
+ start() {
61
+ if (isOpen())
62
+ return offer();
63
+ const next = offer();
64
+ session = next.session;
65
+ carried = 0;
66
+ optionId = next.optionId;
67
+ return next;
68
+ },
69
+ carry(amount) {
70
+ if (carries)
71
+ carried += positive(amount);
72
+ return carried;
73
+ },
74
+ carried: () => carried,
75
+ phase: () => (session === 0 && closed === 0 ? "idle" : isOpen() ? "open" : "closed"),
76
+ offer,
77
+ present(wanted) {
78
+ const index = isOpen() ? session : closed + 1;
79
+ const remaining = poolFor(index, optionId);
80
+ const drawn = [];
81
+ const take = Number.isFinite(wanted) ? Math.max(0, Math.floor(wanted)) : 0;
82
+ const rng = seededRng(`${seed}:present:${index}`);
83
+ while (drawn.length < take && remaining.length > 0) {
84
+ const picked = pickWeighted(rng, remaining, (option) => option.weight ?? 1);
85
+ if (picked === undefined)
86
+ break;
87
+ drawn.push({ id: picked.id, value: picked.value });
88
+ remaining.splice(remaining.indexOf(picked), 1);
89
+ }
90
+ return drawn;
91
+ },
92
+ choose(nextOptionId) {
93
+ if (nextOptionId !== null && !known.has(nextOptionId))
94
+ return false;
95
+ // A closed loop has no session to adopt the pick, so the pick opens the next one.
96
+ if (!isOpen()) {
97
+ session = closed + 1;
98
+ carried = 0;
99
+ }
100
+ optionId = nextOptionId;
101
+ return true;
102
+ },
103
+ settle(input) {
104
+ if (!isOpen())
105
+ throw new Error("session loop: no session is open to settle");
106
+ const settling = { session, carried, input: input };
107
+ const result = config.settle === undefined ? undefined : config.settle(settling);
108
+ closed = session;
109
+ carried = 0;
110
+ return { session: settling.session, carried: settling.carried, closed, result, next: drawFor(closed + 1, optionId) };
111
+ },
112
+ snapshot: () => ({ session, closed, carried, optionId }),
113
+ restore(next) {
114
+ session = count(next.session);
115
+ closed = count(next.closed);
116
+ if (session < closed)
117
+ session = closed;
118
+ carried = carries ? positive(next.carried) : 0;
119
+ optionId = next.optionId !== null && known.has(next.optionId) ? next.optionId : null;
120
+ },
121
+ };
122
+ }
123
+ const SESSION_PROBE_KEY = "__jgengineRun";
124
+ /**
125
+ * Publish the loop's own readout on `window.__jgengineRun`, so a capture or devtools probe can state
126
+ * which session is live, its phase, what it carries and the option it offers. Reads only; it never
127
+ * starts, closes or changes a session. Returns the uninstaller.
128
+ *
129
+ * @capability session-loop open and close a game session, settle the quantity it carried and optionally draw the next session's authored option
130
+ */
131
+ export function installSessionProbe(loop) {
132
+ const scope = globalThis;
133
+ const probe = {
134
+ read() {
135
+ const state = loop.snapshot();
136
+ return {
137
+ session: state.session,
138
+ phase: loop.phase(),
139
+ kind: loop.kind(),
140
+ carried: state.carried,
141
+ closed: state.closed,
142
+ offered: loop.offer().optionId,
143
+ };
144
+ },
145
+ };
146
+ scope[SESSION_PROBE_KEY] = probe;
147
+ return () => {
148
+ if (scope[SESSION_PROBE_KEY] === probe)
149
+ delete scope[SESSION_PROBE_KEY];
150
+ };
151
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/core",
3
- "version": "0.37.0",
3
+ "version": "0.38.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.37.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.38.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.37.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.38.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -2699,6 +2699,22 @@
2699
2699
  - `createRunLoop` (function): function createRunLoop<T>(config: RunLoopConfig<T>): RunLoop<T> — Bank-or-lose between runs, with one deterministic change per run drawn from an authored table.
2700
2700
  - `installRunProbe` (function): function installRunProbe<T>(loop: RunLoop<T>): () => void — Publish the loop's own readout on `window.__jgengineRun`, so a capture or devtools probe can state the live run, what it carries, what it banked or lost and the change it offers. Reads only; it never starts, settles or changes a run. Returns the uninstaller.
2701
2701
 
2702
+ ## @jgengine/core/run/sessionLoop
2703
+
2704
+ - `SessionChoice` (interface): interface SessionChoice<T> — One option the next session could open with, in the shape a choice screen shows.
2705
+ - `SessionLoop` (interface): interface SessionLoop<T = never, I = void, R = void> — One session's lifecycle: open it, carry a quantity, close it at its boundary, and optionally open the next one with an option the game authored.
2706
+ - `SessionLoopConfig` (interface): interface SessionLoopConfig<T = never, I = void, R = void> — Configuration for {@link createSessionLoop}.
2707
+ - `SessionLoopState` (interface): interface SessionLoopState — Everything a session loop stores as plain JSON.
2708
+ - `SessionOffer` (interface): interface SessionOffer<T> — The option a session opens with, and which session it is.
2709
+ - `SessionOfferOption` (interface): interface SessionOfferOption<T> — One option the next session may open with. `T` is the game's own payload (a tighter rival line, an unlocked part, a shorter route) - the loop only draws and remembers which id was drawn.
2710
+ - `SessionPhase` (type): type SessionPhase = "idle" | "open" | "closed" — Where a session stands: not opened yet, in progress, or closed at its boundary.
2711
+ - `SessionProbe` (interface): interface SessionProbe — Read-only handle a capture or devtools uses to prove a session loop is live, not merely imported.
2712
+ - `SessionReadout` (interface): interface SessionReadout — What the loop reports about the session it is playing, for a capture or devtools read.
2713
+ - `SessionSettlement` (interface): interface SessionSettlement<R = void, T = never> — What closing one session did, and what the next session opens with.
2714
+ - `SettlingSession` (interface): interface SettlingSession<I = void> — The session closing, handed to a game's own `settle`.
2715
+ - `createSessionLoop` (function): function createSessionLoop<T = never, I = void, R = void>(config: SessionLoopConfig<T, I, R> = {}): SessionLoop<T, I, R> — The shared shape of a game session, with the game's own rules supplied as config: a session opens, it may carry a quantity, its boundary closes it, and the game's `settle` callback decides what happens to what it carried. A park day, a night shift and a wave are the same base with a different `settle`, a different `kind` and, where nothing is at stake, `carries: false`.
2716
+ - `installSessionProbe` (function): function installSessionProbe<T, I, R>(loop: SessionLoop<T, I, R>): () => void — Publish the loop's own readout on `window.__jgengineRun`, so a capture or devtools probe can state which session is live, its phase, what it carries and the option it offers. Reads only; it never starts, closes or changes a session. Returns the uninstaller.
2717
+
2702
2718
  ## @jgengine/core/session/contestedChannel
2703
2719
 
2704
2720
  - `ContestReaction` (type): type ContestReaction = "pause" | "decay" — ⚠ undocumented
@@ -649,7 +649,7 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
649
649
  - `rerollRules` (function) · `import { rerollRules } from "@jgengine/core/gameplay"`
650
650
  - `selectRules` (function) · `import { selectRules } from "@jgengine/core/gameplay"`
651
651
 
652
- ## run-loop — bank or lose what a run carries, with one deterministic change per run
652
+ ## run-loop — deprecated roguelike composition of session-loop - bank or lose what a run carries, with one deterministic change per run
653
653
 
654
654
  - `createRunLoop` (function) · `import { createRunLoop } from "@jgengine/core/run/runLoop"`
655
655
  - `installRunProbe` (function) · `import { installRunProbe } from "@jgengine/core/run/runLoop"`
@@ -678,6 +678,11 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
678
678
 
679
679
  - `seededRng` (function) · `import { seededRng } from "@jgengine/core/gameplay"`
680
680
 
681
+ ## session-loop — open and close a game session, settle the quantity it carried and optionally draw the next session's authored option
682
+
683
+ - `createSessionLoop` (function) · `import { createSessionLoop } from "@jgengine/core/run/sessionLoop"`
684
+ - `installSessionProbe` (function) · `import { installSessionProbe } from "@jgengine/core/run/sessionLoop"`
685
+
681
686
  ## set — a bounded keyed value in a caller-owned serializable record
682
687
 
683
688
  - `setValue` (function) · `import { setValue } from "@jgengine/core/gameplay"`