@jgengine/core 0.37.0 → 0.38.1
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 +82 -0
- package/dist/game/gameSave.d.ts +8 -2
- package/dist/game/gameSave.js +3 -2
- package/dist/game/playableGame.d.ts +3 -3
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +53 -1
- package/dist/run/runLoop.d.ts +17 -6
- package/dist/run/runLoop.js +67 -99
- package/dist/run/sessionLoop.d.ts +152 -0
- package/dist/run/sessionLoop.js +151 -0
- package/dist/world/modelLod.d.ts +9 -1
- package/package.json +1 -1
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-gameplay/api.md +18 -1
- package/skills/jgengine-gameplay/capabilities.md +6 -1
- package/skills/jgengine-world/api.md +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -26,6 +26,88 @@ 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.1
|
|
30
|
+
|
|
31
|
+
### Migrate
|
|
32
|
+
|
|
33
|
+
- **Bump lockstep SDK packages to `^0.38.1`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.1`; `@jgengine/github` is `0.25.1`.
|
|
34
|
+
- Nothing to migrate. `variantLevels()` was renamed to `ModelVariantConfig.shadows` before any release shipped it, so no published version ever exported it and no consumer can be holding one. A level that wants to stop paying the near level's shadow cost writes `{ url, shadows: "none" }`; leaving `shadows` out keeps the model's own answer.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `ModelVariantConfig.shadows` (`@jgengine/core/world/modelLod`): a model variant level declares its own shadow participation as a plain field, `"inherit" | "cast" | "receive" | "both" | "none"`. `"inherit"`, the default, keeps the model's own `shadows`, so every existing model draws as before; a far level set to `"none"` leaves the shadow pass while still drawing its mesh.
|
|
39
|
+
- `ModelShadowMode` (`@jgengine/core/world/modelLod`) names the four-value shadow vocabulary, and `ModelConfig.shadows` uses it.
|
|
40
|
+
- `variantLevelModel` (`@jgengine/shell/render/singleVariantLod`) is the one place that resolves what a level draws and how it shadows; the single prop, batched prop and scatter paths all ask it, so a level's asset and its shadow answer cannot disagree.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- `SaveSlotsPane` cards are one box each: equal tracks, `grid-auto-rows: 1fr`, a card edge that reads over a bright scene, and a name that ellipsises instead of widening the card. A save with a long name and one with none produce the same card box.
|
|
45
|
+
- `GameMenu` puts the primary verbs on their own scrim, so their labels stay legible over whatever the live scene is showing instead of relying on a text shadow.
|
|
46
|
+
- The theme emits three menu tokens the slot card and the verb surface read: `--jg-menu-card-height` (`clamp(244px, 27vh, 300px)`), `--jg-menu-card-thumb` (`clamp(88px, 14vh, 160px)`) and `--jg-menu-surface-radius` (`8px`). A game overrides them in its own `HudTheme` instead of patching the menu components.
|
|
47
|
+
|
|
48
|
+
## 0.38.0
|
|
49
|
+
|
|
50
|
+
### Migrate
|
|
51
|
+
|
|
52
|
+
- **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`.
|
|
53
|
+
- 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.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- `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.
|
|
58
|
+
- 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`).
|
|
59
|
+
- `shrinkCapture` (`@jgengine/react/saveThumbnail`) turns a `captureCanvas` frame into a card-sized thumbnail data URL.
|
|
60
|
+
- `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`.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- `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.
|
|
65
|
+
- The dev runner's registry and environment modules import outside Vite, so a test or tool can read them.
|
|
66
|
+
- `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.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- `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.
|
|
71
|
+
- 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.
|
|
72
|
+
- `shoot` prints the WebGL2 renderer it draws with and fails the capture outright when the launched browser has none.
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
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`.
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
|
|
81
|
+
Verification:
|
|
82
|
+
- `bun test scripts/shot-metrics.test.ts` - pass
|
|
83
|
+
- `bun test packages/editor/src/CreatorApp.test.ts` - pass
|
|
84
|
+
- `bun run shoot beacon-bastion --mode play --size half` - exit 0, scene renders
|
|
85
|
+
- `bun run shoot --url <flat canvas page>` - exit 1, no PNG written
|
|
86
|
+
- `bun run verify:fast` - pass
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
- 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`.
|
|
90
|
+
- 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.
|
|
91
|
+
- `packages/react/src/dialogBehavior.test.ts` gives its browser navigation its own 20 s deadline and one retry inside a 60 s hook.
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
Before: `test (g)` on #2326 reported `(fail) (unnamed) [30001.51ms] ^ a beforeEach/afterEach hook timed out` and `killed 1 dangling process`.
|
|
96
|
+
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.
|
|
97
|
+
|
|
98
|
+
Verification:
|
|
99
|
+
- `bun scripts/browser-test-budget.ts` - pass, and the new rule names the unraised files on the previous tip
|
|
100
|
+
- `bun test scripts/browser-test-budget.test.ts` - pass
|
|
101
|
+
- `bun test packages/react/src/dialogBehavior.test.ts packages/react/src/scrollRail.test.ts packages/shell/src/settingsBoundary.test.ts` - pass
|
|
102
|
+
- `bun run verify:fast` - pass
|
|
103
|
+
- 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).
|
|
104
|
+
|
|
105
|
+
- `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.
|
|
106
|
+
|
|
107
|
+
`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`).
|
|
108
|
+
`bun --cwd=packages/shell run test` — 1,229 pass, 0 fail.
|
|
109
|
+
`bun --cwd=packages/shell run check-types` — clean.
|
|
110
|
+
|
|
29
111
|
## 0.37.0
|
|
30
112
|
|
|
31
113
|
### Migrate
|
package/dist/game/gameSave.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
106
|
-
|
|
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.
|
package/dist/game/gameSave.js
CHANGED
|
@@ -137,8 +137,9 @@ export function createGameSave(config) {
|
|
|
137
137
|
apply(document);
|
|
138
138
|
return document;
|
|
139
139
|
},
|
|
140
|
-
async switchSlot(slot) {
|
|
141
|
-
|
|
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;
|
|
@@ -3,8 +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
|
+
import type { ModelShadowMode, ModelVariantLodConfig } from "../world/modelLod.js";
|
|
7
|
+
export type { ModelShadowMode, ModelVariantConfig, ModelVariantLodConfig } from "../world/modelLod.js";
|
|
8
8
|
import type { UiCueOverrides } from "../audio/uiCues.js";
|
|
9
9
|
import type { FeedbackCueOverrides } from "../audio/feedbackCues.js";
|
|
10
10
|
import type { NoticeFeedConfig } from "./notices.js";
|
|
@@ -278,7 +278,7 @@ export interface ModelConfig {
|
|
|
278
278
|
/** Hide named visual subtrees on this instance before placement measurement. Exact runtime names or unique original imported names resolve without removing bones or changing the cached model; missing or ambiguous names warn and leave existing visibility unchanged. Malformed lists reject before model or texture allocation. Omit or pass [] to retain imported visibility. */
|
|
279
279
|
hiddenNodes?: readonly string[];
|
|
280
280
|
/** Shadow participation of every mesh in the model. Default `"both"` (casts and receives); `"none"` for foliage cards, decals, or viewmodels that must never shadow the world. */
|
|
281
|
-
shadows?:
|
|
281
|
+
shadows?: ModelShadowMode;
|
|
282
282
|
/**
|
|
283
283
|
* Plays a GLTF animation clip on the model when the source has any (skinned or not).
|
|
284
284
|
* A model resolved from a catalog id whose index carries `clips` animates automatically —
|
package/dist/meta/changelog.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.38.1";
|
|
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[];
|
package/dist/meta/changelog.js
CHANGED
|
@@ -1,7 +1,59 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.38.1";
|
|
3
3
|
/** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
|
|
4
4
|
export const CHANGELOG = {
|
|
5
|
+
"0.38.1": {
|
|
6
|
+
migrate: [
|
|
7
|
+
"Bump lockstep SDK packages to `^0.38.1`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.1`; `@jgengine/github` is `0.25.1`.",
|
|
8
|
+
"Nothing to migrate. `variantLevels()` was renamed to `ModelVariantConfig.shadows` before any release shipped it, so no published version ever exported it and no consumer can be holding one. A level that wants to stop paying the near level's shadow cost writes `{ url, shadows: \"none\" }`; leaving `shadows` out keeps the model's own answer.",
|
|
9
|
+
],
|
|
10
|
+
added: [
|
|
11
|
+
"`ModelVariantConfig.shadows` (`@jgengine/core/world/modelLod`): a model variant level declares its own shadow participation as a plain field, `\"inherit\" | \"cast\" | \"receive\" | \"both\" | \"none\"`. `\"inherit\"`, the default, keeps the model's own `shadows`, so every existing model draws as before; a far level set to `\"none\"` leaves the shadow pass while still drawing its mesh.",
|
|
12
|
+
"`ModelShadowMode` (`@jgengine/core/world/modelLod`) names the four-value shadow vocabulary, and `ModelConfig.shadows` uses it.",
|
|
13
|
+
"`variantLevelModel` (`@jgengine/shell/render/singleVariantLod`) is the one place that resolves what a level draws and how it shadows; the single prop, batched prop and scatter paths all ask it, so a level's asset and its shadow answer cannot disagree.",
|
|
14
|
+
],
|
|
15
|
+
changed: [
|
|
16
|
+
"`SaveSlotsPane` cards are one box each: equal tracks, `grid-auto-rows: 1fr`, a card edge that reads over a bright scene, and a name that ellipsises instead of widening the card. A save with a long name and one with none produce the same card box.",
|
|
17
|
+
"`GameMenu` puts the primary verbs on their own scrim, so their labels stay legible over whatever the live scene is showing instead of relying on a text shadow.",
|
|
18
|
+
"The theme emits three menu tokens the slot card and the verb surface read: `--jg-menu-card-height` (`clamp(244px, 27vh, 300px)`), `--jg-menu-card-thumb` (`clamp(88px, 14vh, 160px)`) and `--jg-menu-surface-radius` (`8px`). A game overrides them in its own `HudTheme` instead of patching the menu components.",
|
|
19
|
+
],
|
|
20
|
+
removed: [],
|
|
21
|
+
},
|
|
22
|
+
"0.38.0": {
|
|
23
|
+
migrate: [
|
|
24
|
+
"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`.",
|
|
25
|
+
"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.",
|
|
26
|
+
],
|
|
27
|
+
added: [
|
|
28
|
+
"`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.",
|
|
29
|
+
"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`).",
|
|
30
|
+
"`shrinkCapture` (`@jgengine/react/saveThumbnail`) turns a `captureCanvas` frame into a card-sized thumbnail data URL.",
|
|
31
|
+
"`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`.",
|
|
32
|
+
],
|
|
33
|
+
changed: [
|
|
34
|
+
"`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.",
|
|
35
|
+
"The dev runner's registry and environment modules import outside Vite, so a test or tool can read them.",
|
|
36
|
+
"`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.",
|
|
37
|
+
"`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.",
|
|
38
|
+
"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.",
|
|
39
|
+
"`shoot` prints the WebGL2 renderer it draws with and fails the capture outright when the launched browser has none.",
|
|
40
|
+
"`bun test scripts/shot-metrics.test.ts` - pass",
|
|
41
|
+
"`bun test packages/editor/src/CreatorApp.test.ts` - pass",
|
|
42
|
+
"`bun run shoot beacon-bastion --mode play --size half` - exit 0, scene renders",
|
|
43
|
+
"`bun run shoot --url <flat canvas page>` - exit 1, no PNG written",
|
|
44
|
+
"`bun run verify:fast` - pass",
|
|
45
|
+
"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`.",
|
|
46
|
+
"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.",
|
|
47
|
+
"`packages/react/src/dialogBehavior.test.ts` gives its browser navigation its own 20 s deadline and one retry inside a 60 s hook.",
|
|
48
|
+
"`bun scripts/browser-test-budget.ts` - pass, and the new rule names the unraised files on the previous tip",
|
|
49
|
+
"`bun test scripts/browser-test-budget.test.ts` - pass",
|
|
50
|
+
"`bun test packages/react/src/dialogBehavior.test.ts packages/react/src/scrollRail.test.ts packages/shell/src/settingsBoundary.test.ts` - pass",
|
|
51
|
+
"`bun run verify:fast` - pass",
|
|
52
|
+
"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).",
|
|
53
|
+
"`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.",
|
|
54
|
+
],
|
|
55
|
+
removed: [],
|
|
56
|
+
},
|
|
5
57
|
"0.37.0": {
|
|
6
58
|
migrate: [
|
|
7
59
|
"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`.",
|
package/dist/run/runLoop.d.ts
CHANGED
|
@@ -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
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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;
|
package/dist/run/runLoop.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
* @
|
|
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/dist/world/modelLod.d.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import type { LodBand } from "./lod.js";
|
|
2
|
-
/**
|
|
2
|
+
/** How a model, or one of its variant levels, takes part in the shadow pass. */
|
|
3
|
+
export type ModelShadowMode = "cast" | "receive" | "both" | "none";
|
|
4
|
+
/** One mesh level of a {@link ModelVariantLodConfig}: a full or decimated GLB, how far it draws and how it shadows. */
|
|
3
5
|
export interface ModelVariantConfig {
|
|
4
6
|
/** GLB url for this level — the source asset for the near level, a decimated asset for the far ones. */
|
|
5
7
|
url: string;
|
|
6
8
|
/** Furthest distance in metres from the camera this level draws. The last level omits it and covers everything beyond the level before it. */
|
|
7
9
|
maxDistance?: number;
|
|
10
|
+
/**
|
|
11
|
+
* This level's shadow participation. `"inherit"` (the default) keeps the model's own `shadows`, so a
|
|
12
|
+
* level that says nothing draws exactly as it did before levels could choose. A far level set to
|
|
13
|
+
* `"none"` leaves the shadow pass entirely while still drawing its mesh.
|
|
14
|
+
*/
|
|
15
|
+
shadows?: ModelShadowMode | "inherit";
|
|
8
16
|
}
|
|
9
17
|
/**
|
|
10
18
|
* Ordered mesh detail levels for one model, switched per instance by camera distance. The first level
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.1",
|
|
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",
|
package/skills/jgengine/api.md
CHANGED
|
@@ -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.
|
|
9
|
+
- `VERSION` (const): const VERSION: "0.38.1" — 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.
|
|
288
|
+
- `VERSION` (const): const VERSION: "0.38.1" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
289
289
|
|
|
290
290
|
## @jgengine/core/runtime/adapter
|
|
291
291
|
|
|
@@ -922,7 +922,8 @@
|
|
|
922
922
|
- `ModelMaterialOverride` (interface): interface ModelMaterialOverride extends MaterialSurfaceParameters — Per-entity PBR material override (#151.3) applied to every standard or physical material in the model's cloned scene graph.
|
|
923
923
|
- `ModelPart` (interface): interface ModelPart — Static child model stacked at a fixed local offset under its parent's transform — no bone/rig resolution, unlike `ModelAttachment`. Assembles a compound entity (e.g. a modular castle wall + tower + roof) from several single-mesh kit pieces.
|
|
924
924
|
- `ModelRimLight` (interface): interface ModelRimLight — Fresnel rim-light term added on top of a model's lighting — see {@link ModelMaterialOverride.rim}.
|
|
925
|
-
- `
|
|
925
|
+
- `ModelShadowMode` (type): type ModelShadowMode = "cast" | "receive" | "both" | "none" — How a model, or one of its variant levels, takes part in the shadow pass.
|
|
926
|
+
- `ModelVariantConfig` (interface): interface ModelVariantConfig — One mesh level of a {@link ModelVariantLodConfig}: a full or decimated GLB, how far it draws and how it shadows.
|
|
926
927
|
- `ModelVariantLodConfig` (interface): interface ModelVariantLodConfig — Ordered mesh detail levels for one model, switched per instance by camera distance. The first level is the full mesh the model's own `url` names; every later level is a cheaper asset. A tree line uses this so near trees keep their full mesh while far ones draw a decimated copy of the same species.
|
|
927
928
|
- `ModelWindConfig` (interface): interface ModelWindConfig — Wind sway for a foliage model; see {@link ModelConfig.wind}.
|
|
928
929
|
- `MovementCommitFrame` (interface): interface MovementCommitFrame — One frame's movement resolution handed to `PlayerMovementConfig.beforeCommit`.
|
|
@@ -2699,6 +2700,22 @@
|
|
|
2699
2700
|
- `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
2701
|
- `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
2702
|
|
|
2703
|
+
## @jgengine/core/run/sessionLoop
|
|
2704
|
+
|
|
2705
|
+
- `SessionChoice` (interface): interface SessionChoice<T> — One option the next session could open with, in the shape a choice screen shows.
|
|
2706
|
+
- `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.
|
|
2707
|
+
- `SessionLoopConfig` (interface): interface SessionLoopConfig<T = never, I = void, R = void> — Configuration for {@link createSessionLoop}.
|
|
2708
|
+
- `SessionLoopState` (interface): interface SessionLoopState — Everything a session loop stores as plain JSON.
|
|
2709
|
+
- `SessionOffer` (interface): interface SessionOffer<T> — The option a session opens with, and which session it is.
|
|
2710
|
+
- `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.
|
|
2711
|
+
- `SessionPhase` (type): type SessionPhase = "idle" | "open" | "closed" — Where a session stands: not opened yet, in progress, or closed at its boundary.
|
|
2712
|
+
- `SessionProbe` (interface): interface SessionProbe — Read-only handle a capture or devtools uses to prove a session loop is live, not merely imported.
|
|
2713
|
+
- `SessionReadout` (interface): interface SessionReadout — What the loop reports about the session it is playing, for a capture or devtools read.
|
|
2714
|
+
- `SessionSettlement` (interface): interface SessionSettlement<R = void, T = never> — What closing one session did, and what the next session opens with.
|
|
2715
|
+
- `SettlingSession` (interface): interface SettlingSession<I = void> — The session closing, handed to a game's own `settle`.
|
|
2716
|
+
- `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`.
|
|
2717
|
+
- `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.
|
|
2718
|
+
|
|
2702
2719
|
## @jgengine/core/session/contestedChannel
|
|
2703
2720
|
|
|
2704
2721
|
- `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"`
|
|
@@ -2655,7 +2655,7 @@
|
|
|
2655
2655
|
- `ModelAssetRef` (interface): interface ModelAssetRef { url: string; anchor?: "center" | "origin"; dims?: ModelDims; collisionMesh?: CollisionMeshData; space?: AssetSpace; clips?: readonly string[] } — ⚠ undocumented · used by `createHostedWorldSessionAsync` (@jgengine/core/runtime/hostedWorldSession): Build a hosted session from an asynchronous persistence backend.
|
|
2656
2656
|
- `ModelDims` (interface): interface ModelDims — Measured horizontal footprint, footprint center, and lowest Y of a model in model space.
|
|
2657
2657
|
- `ModelNode` (interface): interface ModelNode — Generic named-socket reader for loaded 3D models. Walks a node tree (any object with `.name`, `.position`, and `.children` — structurally satisfied by `THREE.Object3D`) and collects the local offsets of nodes whose name marks an attachment point. Genre-agnostic: wire anchors on a pylon, muzzle/hand mounts on a character, hardpoints on a ship, seat/decal slots on furniture — anything an artist tags with an empty in the GLB. Pure data (no three.js import), so it lives in core.
|
|
2658
|
-
- `ModelVariantConfig` (interface): interface ModelVariantConfig — One mesh level of a {@link ModelVariantLodConfig}: a full or decimated GLB
|
|
2658
|
+
- `ModelVariantConfig` (interface): interface ModelVariantConfig — One mesh level of a {@link ModelVariantLodConfig}: a full or decimated GLB, how far it draws and how it shadows.
|
|
2659
2659
|
- `ModelVariantLodConfig` (interface): interface ModelVariantLodConfig — Ordered mesh detail levels for one model, switched per instance by camera distance. The first level is the full mesh the model's own `url` names; every later level is a cheaper asset. A tree line uses this so near trees keep their full mesh while far ones draw a decimated copy of the same species.
|
|
2660
2660
|
- `ModelVariantSelector` (interface): interface ModelVariantSelector — Per-instance distance switch over a model's detail levels: one answer per id, held across a boundary until the instance clears it.
|
|
2661
2661
|
- `ModelVariantSelectorState` (interface): interface ModelVariantSelectorState — Plain JSON state of a {@link ModelVariantSelector}: each id's current level index.
|
|
@@ -4041,7 +4041,8 @@
|
|
|
4041
4041
|
## @jgengine/core/world/modelLod
|
|
4042
4042
|
|
|
4043
4043
|
- `DEFAULT_VARIANT_HYSTERESIS` (const): const DEFAULT_VARIANT_HYSTERESIS: 6 — Metres an instance must travel past a switch boundary before it changes level unless the model declares its own.
|
|
4044
|
-
- `
|
|
4044
|
+
- `ModelShadowMode` (type): type ModelShadowMode = "cast" | "receive" | "both" | "none" — How a model, or one of its variant levels, takes part in the shadow pass.
|
|
4045
|
+
- `ModelVariantConfig` (interface): interface ModelVariantConfig — One mesh level of a {@link ModelVariantLodConfig}: a full or decimated GLB, how far it draws and how it shadows.
|
|
4045
4046
|
- `ModelVariantLodConfig` (interface): interface ModelVariantLodConfig — Ordered mesh detail levels for one model, switched per instance by camera distance. The first level is the full mesh the model's own `url` names; every later level is a cheaper asset. A tree line uses this so near trees keep their full mesh while far ones draw a decimated copy of the same species.
|
|
4046
4047
|
- `ModelVariantSelector` (interface): interface ModelVariantSelector — Per-instance distance switch over a model's detail levels: one answer per id, held across a boundary until the instance clears it.
|
|
4047
4048
|
- `ModelVariantSelectorState` (interface): interface ModelVariantSelectorState — Plain JSON state of a {@link ModelVariantSelector}: each id's current level index.
|