@jgengine/core 0.38.1 → 0.38.3

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,45 @@ 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.3
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.38.3`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.3`; `@jgengine/github` is `0.25.3`.
34
+
35
+ ### Fixed
36
+
37
+ - `GameMenu`'s `style` and `detailStyle` accept a plain `CSSProperties` again (`CSSProperties | GameMenuStyle`). 0.38.2 typed them as `CSSProperties & Record<`--${string}`, ...>`, which a `CSSProperties` value cannot satisfy, so Harbor Heat and Beacon Bastion stopped typechecking on a patch release while they passed the same objects to `style` before it.
38
+ - `SceneCaptureBinding`'s props are a required object with an optional `bind` again. The 0.38.2 `{ bind } = {}` optional parameter made React's `createElement` overload infer `Attributes & undefined` for the props, so a game that builds its overlay as `createElement(SceneCaptureBinding, { bind })` failed with `'bind' does not exist in type 'Attributes'`; `<SceneCaptureBinding />` and `<SceneCaptureBinding bind={…} />` both still typecheck.
39
+
40
+ ## 0.38.2
41
+
42
+ ### Migrate
43
+
44
+ - **Bump lockstep SDK packages to `^0.38.2`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.2`; `@jgengine/github` is `0.25.2`.
45
+
46
+ ### Added
47
+
48
+ - `requestSceneCapture` and `requestSceneThumbnail` (`@jgengine/shell/render/sceneCapture`) ask the live scene for a frame or a save-card thumbnail. A request waits for `SceneCaptureBinding` to mount, waits for the canvas to paint, retries a blank read and resolves with the image or a stated failure (`no-binding`, `no-frame`, `undecodable`), so a game stops polling for the binding and waiting frames before a save screenshot.
49
+ - `registerSceneCapture` (`@jgengine/shell/render/sceneCapture`) lets a host that owns its own canvas hand the capture path a reader.
50
+ - `createDownedState` (`@jgengine/core/combat/downed`) exposes `restore(next)`, the counterpart to `snapshot()`, so a saved bleed-out resumes with the same remaining seconds, revive progress, phase and banner timer instead of being rebuilt around a guessed countdown.
51
+
52
+ ### Changed
53
+
54
+ - Pin Games to the 0.38.1 adoption
55
+ - `GameMenu`'s `style` and `detailStyle` accept `--jg-*` custom properties (`GameMenuStyle`), so a game passes `{ "--jg-menu-surface-radius": "12px" }` without a cast.
56
+ - `GameMenu` sets `pointer-events: auto` on its own root while its painted backdrop stays `none`, so a menu inside a HUD wrapper that passes clicks through to the scene no longer has dead buttons.
57
+ - `check-stateful-ratchet` skips a factory carrying `// stateless: <reason>`, the named case for a catalog whose only closure state is an index of its own config. `createBuildSocketCatalog` declares it instead of being baselined.
58
+
59
+ ### Fixed
60
+
61
+ - `bun run shoot` refuses a scene shot whose page has no 3D canvas, and scores rendered geometry inside the canvas region before writing the file. `bun run shoot <game> --view <name>` defaults to `--mode ui`, which renders the game's HUD preview over a CSS backdrop and creates no canvas; the whole-frame flat test was skipped without a region and the backdrop's full-screen paint withheld the viewport verdict, so a HUD-only frame was written with exit 0.
62
+ - `countStaticBatchDraws` (`@jgengine/shell/world/staticObjectBatches`) counts the draws the static batches submit, so a variant level with no instance in a cell and a cell the camera culled are no longer reported as draws. `submitInstanceCount` (`@jgengine/shell/render/submittedDraws`) is the one place the batch, scatter, proxy and authored-primitive paths set an instance count and its visibility, and three issues a draw only when both say there is something to draw.
63
+ - `createCastRunner.state()` carries the cast's `moveTolerance`, `moveDeadzone` and `movedTotal`, and `restore(next)` resumes it. A cast saved mid-fight used to come back with a fresh movement budget, so the caster could walk further than the ability allowed.
64
+ - `createDecayMeterSet.serialize()` / `restore(next)` carry each meter's value, base rate and `setRateModifier` multiplier. A save used to drop a tuned rate, so a cold biome's faster warmth drain reset to the config rate on load.
65
+ - `createAreaEffectField.restore(state)`, `createSelectionBookmarks.restore(snapshot)` and `createPlotPermissions.restore(snapshot)` are the state-in counterparts to their existing `serialize()` / `snapshot()`; restoring into a live field releases the members the saved state does not carry.
66
+ - `check-stateful-ratchet` names the round-trip gap separately and caps it: a `snapshot()` with no `restore()`/`reset()` fails as `round-trip` debt, `--write` refuses to raise that ceiling, and a group paid below its ceiling must lower it. `createDownedState` was baselined when the ratchet shipped and never revisited; the restores in this release take the baseline from 113 entries to 93 and the `round-trip` ceiling from 8 to 2.
67
+
29
68
  ## 0.38.1
30
69
 
31
70
  ### Migrate
@@ -115,6 +115,13 @@ export interface AreaEffectField<P> {
115
115
  clear(): AreaEffectEvent<P>[];
116
116
  /** Snapshot the persistent membership lifecycle for save/load; shapes are transient and re-supplied via `setSource`. */
117
117
  serialize(): AreaFieldState<P>;
118
+ /**
119
+ * Replace sources and membership with a {@link serialize} result — the save/load counterpart.
120
+ * Sources come back on a sentinel shape, so re-`setSource` live shapes before the next `step`.
121
+ * Members the restored state does not carry leave with reason `"cleared"`; survivors keep their
122
+ * refresh phase without an enter/leave churn.
123
+ */
124
+ restore(state: AreaFieldState<P>): AreaEffectEvent<P>[];
118
125
  }
119
126
  /**
120
127
  * Build a continuous area-effect field. Drive it with `setSource` (once per live source per tick, so
@@ -30,8 +30,9 @@ function shapeContains(shape, point) {
30
30
  */
31
31
  export function createAreaEffectField(state) {
32
32
  const sources = new Map();
33
- if (state !== undefined) {
34
- for (const source of state.sources) {
33
+ function applyState(next) {
34
+ sources.clear();
35
+ for (const source of next.sources) {
35
36
  sources.set(source.id, {
36
37
  shape: SENTINEL_SHAPE,
37
38
  payload: source.payload,
@@ -41,12 +42,14 @@ export function createAreaEffectField(state) {
41
42
  members: new Map(),
42
43
  });
43
44
  }
44
- for (const membership of state.memberships) {
45
+ for (const membership of next.memberships) {
45
46
  sources.get(membership.sourceId)?.members.set(membership.receiverId, {
46
47
  sinceRefreshMs: membership.sinceRefreshMs,
47
48
  });
48
49
  }
49
50
  }
51
+ if (state !== undefined)
52
+ applyState(state);
50
53
  function leaveAll(rec, sourceId, reason) {
51
54
  const events = [];
52
55
  for (const receiverId of rec.members.keys()) {
@@ -171,5 +174,19 @@ export function createAreaEffectField(state) {
171
174
  }
172
175
  return out;
173
176
  },
177
+ restore(next) {
178
+ const kept = new Set(next.memberships.map((membership) => `${membership.sourceId}\u0000${membership.receiverId}`));
179
+ const events = [];
180
+ for (const [sourceId, rec] of sources) {
181
+ for (const receiverId of [...rec.members.keys()]) {
182
+ if (kept.has(`${sourceId}\u0000${receiverId}`))
183
+ continue;
184
+ rec.members.delete(receiverId);
185
+ events.push({ kind: "leave", sourceId, receiverId, stackKey: rec.stackKey, payload: rec.payload, reason: "cleared" });
186
+ }
187
+ }
188
+ applyState(next);
189
+ return events;
190
+ },
174
191
  };
175
192
  }
@@ -20,6 +20,12 @@ export interface CastBarSnapshot {
20
20
  remainingMs: number;
21
21
  /** Fill fraction in `[0, 1]` for the cast bar. */
22
22
  fraction: number;
23
+ /** Movement budget this cast was begun with; `Infinity` for a cast that tolerates travel. */
24
+ moveTolerance: number;
25
+ /** The caster's noise floor this cast was begun with. */
26
+ moveDeadzone: number;
27
+ /** Travel already spent against `moveTolerance` (sub-deadzone jitter excluded). */
28
+ movedTotal: number;
23
29
  }
24
30
  export type CastInterruptReason = "moved" | "cancelled" | "replaced";
25
31
  export type CastEvent = {
@@ -40,7 +46,14 @@ export interface CastRunner {
40
46
  /** Start a cast; `false` while one is already in flight (interrupt or let it finish first). */
41
47
  begin(config: CastConfig): boolean;
42
48
  casting(): boolean;
49
+ /** The in-flight cast's timing and remaining move budget, or `null` when idle. */
43
50
  state(): CastBarSnapshot | null;
51
+ /**
52
+ * Replace the in-flight cast with a snapshot from {@link state} — resuming a saved cast with the
53
+ * movement already spent against its budget, or clearing it with `null`. The derived `fraction`
54
+ * and `remainingMs` are recomputed; everything else is kept as saved.
55
+ */
56
+ restore(next: CastBarSnapshot | null): void;
44
57
  /** Interrupt the in-flight cast now; returns the interruption event, or `null` when idle. */
45
58
  interrupt(reason?: CastInterruptReason): CastEvent | null;
46
59
  /**
@@ -16,6 +16,9 @@ export function createCastRunner() {
16
16
  elapsedMs: active.elapsedMs,
17
17
  remainingMs: Math.max(0, active.castTimeMs - active.elapsedMs),
18
18
  fraction,
19
+ moveTolerance: active.moveTolerance,
20
+ moveDeadzone: active.moveDeadzone,
21
+ movedTotal: active.movedTotal,
19
22
  };
20
23
  }
21
24
  return {
@@ -34,6 +37,20 @@ export function createCastRunner() {
34
37
  },
35
38
  casting: () => active !== null,
36
39
  state: snapshot,
40
+ restore(next) {
41
+ if (next === null) {
42
+ active = null;
43
+ return;
44
+ }
45
+ active = {
46
+ abilityId: next.abilityId,
47
+ castTimeMs: Math.max(0, next.castTimeMs),
48
+ moveTolerance: next.moveTolerance,
49
+ moveDeadzone: Math.max(0, next.moveDeadzone),
50
+ elapsedMs: Math.max(0, next.elapsedMs),
51
+ movedTotal: Math.max(0, next.movedTotal),
52
+ };
53
+ },
37
54
  interrupt(reason = "cancelled") {
38
55
  if (active === null)
39
56
  return null;
@@ -30,7 +30,10 @@ export interface DownedState {
30
30
  phase(instanceId: string): DownedPhase;
31
31
  get(instanceId: string): DownedEntry | null;
32
32
  clear(instanceId: string): void;
33
+ /** Every tracked entity's phase, remaining bleed-out, revive progress and banner timer. */
33
34
  snapshot(): Record<string, DownedEntry>;
35
+ /** Replaces the tracked entities, resuming a saved bleed-out where it left off. */
36
+ restore(next: Record<string, DownedEntry>): void;
34
37
  }
35
38
  /**
36
39
  * A downed/bleed-out state that ticks toward death and that teammates can revive before the timer runs out.
@@ -105,5 +105,16 @@ export function createDownedState(config) {
105
105
  out[instanceId] = { ...entry };
106
106
  return out;
107
107
  },
108
+ restore(next) {
109
+ entries.clear();
110
+ for (const [instanceId, entry] of Object.entries(next)) {
111
+ entries.set(instanceId, {
112
+ phase: entry.phase,
113
+ bleedoutRemaining: entry.bleedoutRemaining,
114
+ reviveProgress: entry.reviveProgress,
115
+ bannerRemaining: entry.bannerRemaining,
116
+ });
117
+ }
118
+ },
108
119
  };
109
120
  }
@@ -1,5 +1,5 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export declare const VERSION = "0.38.1";
2
+ export declare const VERSION = "0.38.3";
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,41 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export const VERSION = "0.38.1";
2
+ export const VERSION = "0.38.3";
3
3
  /** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
4
4
  export const CHANGELOG = {
5
+ "0.38.3": {
6
+ migrate: [
7
+ "Bump lockstep SDK packages to `^0.38.3`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.3`; `@jgengine/github` is `0.25.3`.",
8
+ ],
9
+ added: [],
10
+ changed: [
11
+ "`GameMenu`'s `style` and `detailStyle` accept a plain `CSSProperties` again (`CSSProperties | GameMenuStyle`). 0.38.2 typed them as `CSSProperties & Record<`--${string}`, ...>`, which a `CSSProperties` value cannot satisfy, so Harbor Heat and Beacon Bastion stopped typechecking on a patch release while they passed the same objects to `style` before it.",
12
+ "`SceneCaptureBinding`'s props are a required object with an optional `bind` again. The 0.38.2 `{ bind } = {}` optional parameter made React's `createElement` overload infer `Attributes & undefined` for the props, so a game that builds its overlay as `createElement(SceneCaptureBinding, { bind })` failed with `'bind' does not exist in type 'Attributes'`; `<SceneCaptureBinding />` and `<SceneCaptureBinding bind={…} />` both still typecheck.",
13
+ ],
14
+ removed: [],
15
+ },
16
+ "0.38.2": {
17
+ migrate: [
18
+ "Bump lockstep SDK packages to `^0.38.2`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.2`; `@jgengine/github` is `0.25.2`.",
19
+ ],
20
+ added: [
21
+ "`requestSceneCapture` and `requestSceneThumbnail` (`@jgengine/shell/render/sceneCapture`) ask the live scene for a frame or a save-card thumbnail. A request waits for `SceneCaptureBinding` to mount, waits for the canvas to paint, retries a blank read and resolves with the image or a stated failure (`no-binding`, `no-frame`, `undecodable`), so a game stops polling for the binding and waiting frames before a save screenshot.",
22
+ "`registerSceneCapture` (`@jgengine/shell/render/sceneCapture`) lets a host that owns its own canvas hand the capture path a reader.",
23
+ "`createDownedState` (`@jgengine/core/combat/downed`) exposes `restore(next)`, the counterpart to `snapshot()`, so a saved bleed-out resumes with the same remaining seconds, revive progress, phase and banner timer instead of being rebuilt around a guessed countdown.",
24
+ ],
25
+ changed: [
26
+ "Pin Games to the 0.38.1 adoption",
27
+ "`GameMenu`'s `style` and `detailStyle` accept `--jg-*` custom properties (`GameMenuStyle`), so a game passes `{ \"--jg-menu-surface-radius\": \"12px\" }` without a cast.",
28
+ "`GameMenu` sets `pointer-events: auto` on its own root while its painted backdrop stays `none`, so a menu inside a HUD wrapper that passes clicks through to the scene no longer has dead buttons.",
29
+ "`check-stateful-ratchet` skips a factory carrying `// stateless: <reason>`, the named case for a catalog whose only closure state is an index of its own config. `createBuildSocketCatalog` declares it instead of being baselined.",
30
+ "`bun run shoot` refuses a scene shot whose page has no 3D canvas, and scores rendered geometry inside the canvas region before writing the file. `bun run shoot <game> --view <name>` defaults to `--mode ui`, which renders the game's HUD preview over a CSS backdrop and creates no canvas; the whole-frame flat test was skipped without a region and the backdrop's full-screen paint withheld the viewport verdict, so a HUD-only frame was written with exit 0.",
31
+ "`countStaticBatchDraws` (`@jgengine/shell/world/staticObjectBatches`) counts the draws the static batches submit, so a variant level with no instance in a cell and a cell the camera culled are no longer reported as draws. `submitInstanceCount` (`@jgengine/shell/render/submittedDraws`) is the one place the batch, scatter, proxy and authored-primitive paths set an instance count and its visibility, and three issues a draw only when both say there is something to draw.",
32
+ "`createCastRunner.state()` carries the cast's `moveTolerance`, `moveDeadzone` and `movedTotal`, and `restore(next)` resumes it. A cast saved mid-fight used to come back with a fresh movement budget, so the caster could walk further than the ability allowed.",
33
+ "`createDecayMeterSet.serialize()` / `restore(next)` carry each meter's value, base rate and `setRateModifier` multiplier. A save used to drop a tuned rate, so a cold biome's faster warmth drain reset to the config rate on load.",
34
+ "`createAreaEffectField.restore(state)`, `createSelectionBookmarks.restore(snapshot)` and `createPlotPermissions.restore(snapshot)` are the state-in counterparts to their existing `serialize()` / `snapshot()`; restoring into a live field releases the members the saved state does not carry.",
35
+ "`check-stateful-ratchet` names the round-trip gap separately and caps it: a `snapshot()` with no `restore()`/`reset()` fails as `round-trip` debt, `--write` refuses to raise that ceiling, and a group paid below its ceiling must lower it. `createDownedState` was baselined when the ratchet shipped and never revisited; the restores in this release take the baseline from 113 entries to 93 and the `round-trip` ceiling from 8 to 2.",
36
+ ],
37
+ removed: [],
38
+ },
5
39
  "0.38.1": {
6
40
  migrate: [
7
41
  "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`.",
@@ -56,6 +56,8 @@ export interface SelectionBookmarks {
56
56
  prune(isValid: (id: string) => boolean): SelectionPruneResult;
57
57
  /** A plain-data copy of every bookmark — the exact input {@link createSelectionBookmarks} restores. */
58
58
  serialize(): SelectionBookmarkSnapshot;
59
+ /** Replace every bookmark with a {@link serialize} snapshot — re-deduped, empty sets dropped. */
60
+ restore(snapshot: SelectionBookmarkSnapshot): void;
59
61
  }
60
62
  /**
61
63
  * Create a keyed bookmark store, optionally restored from a {@link serialize}
@@ -18,13 +18,16 @@ function dedupe(ids) {
18
18
  */
19
19
  export function createSelectionBookmarks(snapshot) {
20
20
  const groups = new Map();
21
- if (snapshot !== undefined) {
22
- for (const [key, ids] of Object.entries(snapshot.bookmarks)) {
21
+ function load(next) {
22
+ groups.clear();
23
+ for (const [key, ids] of Object.entries(next.bookmarks)) {
23
24
  const list = dedupe(ids);
24
25
  if (list.length > 0)
25
26
  groups.set(key, list);
26
27
  }
27
28
  }
29
+ if (snapshot !== undefined)
30
+ load(snapshot);
28
31
  return {
29
32
  bind(key, ids) {
30
33
  const list = dedupe(ids);
@@ -94,6 +97,7 @@ export function createSelectionBookmarks(snapshot) {
94
97
  serialize: () => ({
95
98
  bookmarks: Object.fromEntries([...groups].map(([key, list]) => [key, [...list]])),
96
99
  }),
100
+ restore: load,
97
101
  };
98
102
  }
99
103
  /**
@@ -32,6 +32,18 @@ export interface DecayMeterState {
32
32
  /** 0..1 fill. */
33
33
  fraction: number;
34
34
  }
35
+ /**
36
+ * The whole persisted state of a {@link DecayMeterSet}: every meter's value plus the play-time rate
37
+ * changes `setRate` and `setRateModifier` applied. A save that carries only values resumes a tuned
38
+ * meter at its config rate, which is how a cold biome's faster warmth drain vanished on load.
39
+ */
40
+ export interface DecayMeterSetState {
41
+ values: DecayMeterValues;
42
+ /** Current base rate per meter id, including any `setRate` override. */
43
+ rates: Record<string, number>;
44
+ /** Rate multiplier per meter id from `setRateModifier`; a missing id means `1`. */
45
+ modifiers: Record<string, number>;
46
+ }
35
47
  /** Set of named survival meters (hunger/thirst/…) that drain and refill over game time. */
36
48
  export interface DecayMeterSet {
37
49
  /** Drain/fill every meter by `rate * rateModifier * dt`. Call once per game tick. */
@@ -46,14 +58,21 @@ export interface DecayMeterSet {
46
58
  setRateModifier(id: string, multiplier: number): void;
47
59
  /** Moodles for every crossed threshold, worst-first per meter. */
48
60
  moodles(): Moodle[];
49
- /** True while any threshold with the given severity (or worse) is active — cheap game gate. */
61
+ /** Every meter's numeric state for a HUD — the display view; {@link serialize} is the save pair. */
50
62
  snapshot(): Record<string, DecayMeterState>;
63
+ /** Values, base rates and rate modifiers — the full state to persist; see {@link restore}. */
64
+ serialize(): DecayMeterSetState;
65
+ /**
66
+ * Put a {@link serialize} result back: values, base rates and rate modifiers. Meters the state
67
+ * omits keep their current value and rate, and a modifier it omits is cleared.
68
+ */
69
+ restore(next: DecayMeterSetState): void;
51
70
  ids(): readonly string[];
52
71
  }
53
72
  /**
54
- * Plain-data meter values: `meter id → current value`. This is the whole serialized
55
- * form — it drops straight into a `defineGame` state record and round-trips through
56
- * save/load and multiplayer sync with no closure to rebuild.
73
+ * Plain-data meter values: `meter id → current value`. The value half of a save — a tuned meter
74
+ * also needs its rate and rate modifiers, which {@link DecayMeterSetState} carries and
75
+ * {@link createDecayMeterSet}'s `serialize`/`restore` hand back and forth.
57
76
  */
58
77
  export type DecayMeterValues = Record<string, number>;
59
78
  /**
@@ -160,6 +160,29 @@ export function createDecayMeterSet(configs) {
160
160
  snapshot() {
161
161
  return decayMeterSnapshot(values, defs);
162
162
  },
163
+ serialize() {
164
+ const rates = {};
165
+ for (const def of defs)
166
+ rates[def.id] = def.rate;
167
+ return { values: { ...values }, rates, modifiers: { ...modifiers } };
168
+ },
169
+ restore(next) {
170
+ const restored = {};
171
+ for (const def of defs) {
172
+ const rate = next.rates[def.id];
173
+ if (rate !== undefined)
174
+ def.rate = rate;
175
+ const modifier = next.modifiers[def.id];
176
+ if (modifier === undefined)
177
+ delete modifiers[def.id];
178
+ else
179
+ modifiers[def.id] = modifier;
180
+ const value = next.values[def.id];
181
+ if (value !== undefined)
182
+ restored[def.id] = value;
183
+ }
184
+ values = restored;
185
+ },
163
186
  ids() {
164
187
  return order;
165
188
  },
@@ -26,6 +26,8 @@ export interface PlotPermissions {
26
26
  setGuild(guildId: string | null, role?: BuildRole): void;
27
27
  transferOwner(userId: string): void;
28
28
  snapshot(): PlotPermissionSnapshot;
29
+ /** Replace owner, guild and grants from a {@link snapshot}; plot identity stays the config's. */
30
+ restore(next: PlotPermissionSnapshot): void;
29
31
  }
30
32
  export declare function createPlotPermissions(config: PlotPermissionConfig): PlotPermissions;
31
33
  export type ContributionGoal = Readonly<Record<string, number>>;
@@ -52,6 +52,16 @@ export function createPlotPermissions(config) {
52
52
  ...(guildRole === undefined ? {} : { guildRole }),
53
53
  };
54
54
  },
55
+ restore(next) {
56
+ ownerId = next.ownerId;
57
+ guildId = next.guildId;
58
+ guildRole = next.guildRole;
59
+ roles.clear();
60
+ for (const [userId, role] of Object.entries(next.roles)) {
61
+ if (userId !== next.ownerId)
62
+ roles.set(userId, role);
63
+ }
64
+ },
55
65
  };
56
66
  }
57
67
  export function createContributionPool(goal) {
@@ -82,6 +82,7 @@ export function pieceSocketsFromModel(sockets, kindOf, yawOf) {
82
82
  *
83
83
  * @capability build-socket-catalog data-driven named-socket snap catalog for modular base building
84
84
  */
85
+ // stateless: pieces, rules and their lookup indexes are built once from `config` and never written after the factory returns, so the constructor is the whole state
85
86
  export function createBuildSocketCatalog(config) {
86
87
  const cellSize = config.cellSize ?? 1;
87
88
  const pieces = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/core",
3
- "version": "0.38.1",
3
+ "version": "0.38.3",
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.38.1" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.38.3" — 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.38.1" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.38.3" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -389,7 +389,7 @@
389
389
 
390
390
  ## @jgengine/core/combat/castRunner
391
391
 
392
- - `CastBarSnapshot` (interface): interface CastBarSnapshot { abilityId: string; castTimeMs: number; elapsedMs: number; remainingMs: number; fraction: number } — ⚠ undocumented
392
+ - `CastBarSnapshot` (interface): interface CastBarSnapshot { abilityId: string; castTimeMs: number; elapsedMs: number; remainingMs: number; fraction: number; moveTolerance: number; moveDeadzone: number; movedTotal: number } — ⚠ undocumented
393
393
  - `CastConfig` (interface): interface CastConfig { abilityId: string; castTimeMs: number; moveTolerance?: number; moveDeadzone?: number } — ⚠ undocumented
394
394
  - `CastEvent` (type): type CastEvent = | { kind: "completed"; abilityId: string } | { kind: "interrupted"; abilityId: string; reason: CastInterruptReason } — ⚠ undocumented
395
395
  - `CastInterruptReason` (type): type CastInterruptReason = "moved" | "cancelled" | "replaced" — ⚠ undocumented
@@ -1342,7 +1342,7 @@
1342
1342
  - `DEFAULT_RIDE_RATING_RANGE` (const): const DEFAULT_RIDE_RATING_RANGE: RideRatingRange — A 0–10 rating scale, the usual shape of a ride catalog. Override it when a park rates differently.
1343
1343
  - `DEFAULT_TOUCH_STYLE` (const): const DEFAULT_TOUCH_STYLE: TouchStyle — Skin used when neither the game nor the player picks one.
1344
1344
  - `DecayMeterSet` (interface): interface DecayMeterSet — Set of named survival meters (hunger/thirst/…) that drain and refill over game time.
1345
- - `DecayMeterValues` (type): type DecayMeterValues = Record<string, number> — Plain-data meter values: `meter id → current value`. This is the whole serialized form — it drops straight into a `defineGame` state record and round-trips through save/load and multiplayer sync with no closure to rebuild.
1345
+ - `DecayMeterValues` (type): type DecayMeterValues = Record<string, number> — Plain-data meter values: `meter id → current value`. The value half of a save — a tuned meter also needs its rate and rate modifiers, which {@link DecayMeterSetState} carries and {@link createDecayMeterSet}'s `serialize`/`restore` hand back and forth.
1346
1346
  - `DecayModifier` (type): type DecayModifier = number | Record<string, number> — Rate multiplier for {@link decayMeters}: one scalar applied to every meter (a member's metabolism, a game-mode harshness dial) or a per-meter record (cold biome → warmth only). `1` / omitted leaves the base rates unscaled.
1347
1347
  - `DefendMissionOptions` (interface): interface DefendMissionOptions extends ComposedMissionOptions — Options for {@link defendMission}: hold a position, scored by what is left standing.
1348
1348
  - `DeliveryEntry` (interface): interface DeliveryEntry { id: string; userId: string; inventoryId: string; items: readonly ItemStack[]; deliverAt: number } — ⚠ undocumented
@@ -2878,8 +2878,9 @@
2878
2878
 
2879
2879
  - `DecayMeterConfig` (interface): interface DecayMeterConfig { id: string; max: number; min?: number; start?: number; rate: number; thresholds?: readonly MeterThreshold[] } — ⚠ undocumented · used by `createDecayMeterSet`: Named decay meters — hunger, thirst, oxygen, sanity, warmth, stamina.
2880
2880
  - `DecayMeterSet` (interface): interface DecayMeterSet — Set of named survival meters (hunger/thirst/…) that drain and refill over game time.
2881
+ - `DecayMeterSetState` (interface): interface DecayMeterSetState — The whole persisted state of a {@link DecayMeterSet}: every meter's value plus the play-time rate changes `setRate` and `setRateModifier` applied. A save that carries only values resumes a tuned meter at its config rate, which is how a cold biome's faster warmth drain vanished on load.
2881
2882
  - `DecayMeterState` (interface): interface DecayMeterState { id: string; value: number; max: number; min: number; fraction: number } — ⚠ undocumented · used by `decayMeterSnapshot`: Numeric state for every meter, keyed by id — the pure counterpart to {@link DecayMeterSet.snapshot}.
2882
- - `DecayMeterValues` (type): type DecayMeterValues = Record<string, number> — Plain-data meter values: `meter id → current value`. This is the whole serialized form — it drops straight into a `defineGame` state record and round-trips through save/load and multiplayer sync with no closure to rebuild.
2883
+ - `DecayMeterValues` (type): type DecayMeterValues = Record<string, number> — Plain-data meter values: `meter id → current value`. The value half of a save — a tuned meter also needs its rate and rate modifiers, which {@link DecayMeterSetState} carries and {@link createDecayMeterSet}'s `serialize`/`restore` hand back and forth.
2883
2884
  - `DecayModifier` (type): type DecayModifier = number | Record<string, number> — Rate multiplier for {@link decayMeters}: one scalar applied to every meter (a member's metabolism, a game-mode harshness dial) or a per-meter record (cold biome → warmth only). `1` / omitted leaves the base rates unscaled.
2884
2885
  - `MeterThreshold` (interface): interface MeterThreshold { id: string; label: string; at: number; when: "below" | "above"; severity?: MoodleSeverity; icon?: string } — ⚠ undocumented
2885
2886
  - `createDecayMeterSet` (function): function createDecayMeterSet(configs: readonly DecayMeterConfig[]): DecayMeterSet — Named decay meters — hunger, thirst, oxygen, sanity, warmth, stamina. Each drains (or recovers) on game-time `dt` at a configurable rate, refills from consumables or actions, and raises moodle statuses at thresholds. Rate modifiers let the environment drive them (colder → faster warmth loss; toxic biome → oxygen drops), so a game reads an environment field then calls `setRateModifier`.