@jgengine/core 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/CHANGELOG.md +500 -1
  2. package/dist/ai/difficulty.d.ts +154 -0
  3. package/dist/ai/difficulty.js +194 -0
  4. package/dist/ai/driver.d.ts +107 -0
  5. package/dist/ai/driver.js +175 -0
  6. package/dist/ai/flock.js +13 -23
  7. package/dist/ai/laneSelect.js +2 -1
  8. package/dist/ai/mobBrain.js +2 -1
  9. package/dist/ai/waveRunner.d.ts +81 -0
  10. package/dist/ai/waveRunner.js +104 -0
  11. package/dist/audio/musicState.d.ts +47 -0
  12. package/dist/audio/musicState.js +38 -0
  13. package/dist/audio/stepCadence.d.ts +47 -0
  14. package/dist/audio/stepCadence.js +29 -0
  15. package/dist/audio/synth.d.ts +12 -0
  16. package/dist/authoring.d.ts +6 -4
  17. package/dist/authoring.js +6 -4
  18. package/dist/combat/castRunner.d.ts +13 -1
  19. package/dist/combat/castRunner.js +5 -1
  20. package/dist/combat/comboMeter.d.ts +140 -0
  21. package/dist/combat/comboMeter.js +215 -0
  22. package/dist/combat/comboPoints.d.ts +11 -0
  23. package/dist/combat/comboPoints.js +11 -2
  24. package/dist/combat/damageResolution.d.ts +1 -1
  25. package/dist/combat/defensiveWindow.d.ts +5 -0
  26. package/dist/combat/effects.d.ts +24 -0
  27. package/dist/combat/effects.js +6 -1
  28. package/dist/combat/projectiles.d.ts +6 -0
  29. package/dist/combat/projectiles.js +2 -2
  30. package/dist/combat/regenShield.d.ts +40 -2
  31. package/dist/combat/regenShield.js +66 -18
  32. package/dist/combat/resourcePool.d.ts +19 -0
  33. package/dist/combat/resourcePool.js +20 -2
  34. package/dist/combat/shotOrigin.d.ts +5 -0
  35. package/dist/combat/statusApplication.d.ts +1 -1
  36. package/dist/combat/statusApplication.js +2 -1
  37. package/dist/combat/statusEffectView.d.ts +56 -0
  38. package/dist/combat/statusEffectView.js +52 -0
  39. package/dist/combat.d.ts +4 -2
  40. package/dist/combat.js +2 -0
  41. package/dist/devtools/fallbackSeams.d.ts +3 -2
  42. package/dist/devtools/fallbackSeams.js +1 -1
  43. package/dist/devtools/textureErrors.d.ts +40 -0
  44. package/dist/devtools/textureErrors.js +68 -0
  45. package/dist/economy/shopStock.d.ts +110 -0
  46. package/dist/economy/shopStock.js +157 -0
  47. package/dist/economy/wallet.d.ts +6 -0
  48. package/dist/economy/wallet.js +1 -0
  49. package/dist/game/achievements.d.ts +80 -0
  50. package/dist/game/achievements.js +124 -0
  51. package/dist/game/cameraConfig.d.ts +7 -0
  52. package/dist/game/codex.d.ts +60 -0
  53. package/dist/game/codex.js +89 -0
  54. package/dist/game/credits.d.ts +36 -0
  55. package/dist/game/credits.js +0 -0
  56. package/dist/game/defineGame.d.ts +28 -3
  57. package/dist/game/defineGame.js +5 -0
  58. package/dist/game/dialogueGraph.d.ts +116 -0
  59. package/dist/game/dialogueGraph.js +103 -0
  60. package/dist/game/eventTicker.d.ts +95 -0
  61. package/dist/game/eventTicker.js +91 -0
  62. package/dist/game/events.d.ts +24 -5
  63. package/dist/game/events.js +18 -0
  64. package/dist/game/gamePhase.d.ts +1 -1
  65. package/dist/game/gamePhase.js +1 -1
  66. package/dist/game/leaderboardRank.d.ts +71 -0
  67. package/dist/game/leaderboardRank.js +76 -0
  68. package/dist/game/lootTable.d.ts +10 -1
  69. package/dist/game/lootTable.js +4 -2
  70. package/dist/game/notifications.d.ts +57 -0
  71. package/dist/game/notifications.js +107 -0
  72. package/dist/game/playableGame.d.ts +39 -2
  73. package/dist/game/quest.d.ts +26 -0
  74. package/dist/game/quest.js +31 -0
  75. package/dist/game/runDraft.js +2 -1
  76. package/dist/game/saveSlots.d.ts +102 -0
  77. package/dist/game/saveSlots.js +136 -0
  78. package/dist/game/talentTreeView.d.ts +106 -0
  79. package/dist/game/talentTreeView.js +114 -0
  80. package/dist/game/worldItem.js +2 -1
  81. package/dist/gameplay.d.ts +15 -4
  82. package/dist/gameplay.js +13 -2
  83. package/dist/i18n/i18n.d.ts +51 -0
  84. package/dist/i18n/i18n.js +82 -0
  85. package/dist/input/axisInput.d.ts +1 -1
  86. package/dist/input/axisInput.js +18 -2
  87. package/dist/input/rebindSession.d.ts +141 -0
  88. package/dist/input/rebindSession.js +235 -0
  89. package/dist/input/touchControlsMode.d.ts +16 -0
  90. package/dist/input/touchControlsMode.js +23 -0
  91. package/dist/input/touchScheme.d.ts +30 -1
  92. package/dist/input/touchScheme.js +31 -1
  93. package/dist/interaction/promptRegistry.d.ts +78 -0
  94. package/dist/interaction/promptRegistry.js +130 -0
  95. package/dist/inventory/storageTier.js +3 -2
  96. package/dist/meta/changelog.d.ts +1 -1
  97. package/dist/meta/changelog.js +145 -1
  98. package/dist/movement/movementModel.d.ts +26 -3
  99. package/dist/movement/movementModel.js +121 -15
  100. package/dist/movement/playerMovement.js +161 -25
  101. package/dist/physics/drivableVehicle.js +3 -1
  102. package/dist/physics/kinematicVehicle.d.ts +56 -1
  103. package/dist/physics/kinematicVehicle.js +99 -10
  104. package/dist/physics/vehicleObstacles.js +19 -0
  105. package/dist/random/resolveRng.d.ts +14 -0
  106. package/dist/random/resolveRng.js +31 -0
  107. package/dist/render/postProcessing.d.ts +12 -1
  108. package/dist/runtime/cameraDirector.d.ts +2 -2
  109. package/dist/runtime/context/combat.js +8 -0
  110. package/dist/runtime/gameContext.js +6 -1
  111. package/dist/runtime/gameContextTypes.d.ts +18 -0
  112. package/dist/runtime/inputSnapshot.d.ts +10 -1
  113. package/dist/runtime/inputSnapshot.js +13 -1
  114. package/dist/runtime/runtimeSave.d.ts +3 -3
  115. package/dist/runtime/runtimeSave.js +11 -2
  116. package/dist/scene/assetDiagnostics.js +2 -2
  117. package/dist/scene/autoTarget.js +2 -1
  118. package/dist/scene/captureCheck.js +3 -2
  119. package/dist/scene/entityStore.d.ts +4 -1
  120. package/dist/scene/entityStore.js +3 -0
  121. package/dist/scene/objectStore.d.ts +10 -0
  122. package/dist/scene/objectStore.js +29 -6
  123. package/dist/scene/sceneKinds.d.ts +18 -0
  124. package/dist/scene/sceneKinds.js +19 -0
  125. package/dist/scene/sequenceDirector.d.ts +110 -0
  126. package/dist/scene/sequenceDirector.js +146 -0
  127. package/dist/session/roles.js +3 -2
  128. package/dist/settings/settingsModel.d.ts +1 -0
  129. package/dist/settings/settingsModel.js +1 -0
  130. package/dist/stats/rollCheck.js +4 -2
  131. package/dist/time/dayNightCycle.d.ts +114 -0
  132. package/dist/time/dayNightCycle.js +208 -0
  133. package/dist/time/timerSet.d.ts +132 -0
  134. package/dist/time/timerSet.js +243 -0
  135. package/dist/ui/accessibility.d.ts +51 -0
  136. package/dist/ui/accessibility.js +83 -0
  137. package/dist/ui/coachMarks.d.ts +99 -0
  138. package/dist/ui/coachMarks.js +126 -0
  139. package/dist/ui/floatingText.d.ts +114 -0
  140. package/dist/ui/floatingText.js +203 -0
  141. package/dist/ui/modalStack.d.ts +143 -0
  142. package/dist/ui/modalStack.js +160 -0
  143. package/dist/ui/objectiveBanner.d.ts +123 -0
  144. package/dist/ui/objectiveBanner.js +162 -0
  145. package/dist/ui/photoMode.d.ts +32 -0
  146. package/dist/ui/photoMode.js +51 -0
  147. package/dist/ui/radialMenu.d.ts +47 -0
  148. package/dist/ui/radialMenu.js +74 -0
  149. package/dist/ui/screenMarkers.d.ts +128 -0
  150. package/dist/ui/screenMarkers.js +118 -0
  151. package/dist/ui.d.ts +8 -0
  152. package/dist/ui.js +8 -0
  153. package/dist/vfx/cameraShake.d.ts +97 -0
  154. package/dist/vfx/cameraShake.js +136 -0
  155. package/dist/vfx/damageDirection.d.ts +102 -0
  156. package/dist/vfx/damageDirection.js +188 -0
  157. package/dist/vfx/particles.d.ts +103 -0
  158. package/dist/vfx/particles.js +256 -0
  159. package/dist/vfx/screenEffects.d.ts +149 -0
  160. package/dist/vfx/screenEffects.js +191 -0
  161. package/dist/world/authoredObjects.d.ts +18 -0
  162. package/dist/world/authoredObjects.js +21 -0
  163. package/dist/world/authoredSpawn.js +19 -0
  164. package/dist/world/buildingLots.d.ts +54 -11
  165. package/dist/world/buildingLots.js +116 -30
  166. package/dist/world/cityBlocks.d.ts +45 -0
  167. package/dist/world/cityBlocks.js +159 -0
  168. package/dist/world/cityContent.d.ts +46 -2
  169. package/dist/world/cityContent.js +160 -2
  170. package/dist/world/cityGenerator.d.ts +261 -23
  171. package/dist/world/cityGenerator.js +643 -33
  172. package/dist/world/cityGeometry.d.ts +20 -0
  173. package/dist/world/cityGeometry.js +91 -0
  174. package/dist/world/cityKind.js +1 -44
  175. package/dist/world/fastTravel.d.ts +64 -0
  176. package/dist/world/fastTravel.js +108 -0
  177. package/dist/world/features.d.ts +16 -0
  178. package/dist/world/features.js +2 -0
  179. package/dist/world/grounded.d.ts +11 -0
  180. package/dist/world/grounded.js +12 -0
  181. package/dist/world/mapAnnotations.d.ts +75 -0
  182. package/dist/world/mapAnnotations.js +139 -0
  183. package/dist/world/markers.js +1 -0
  184. package/dist/world/resourceNode.d.ts +1 -1
  185. package/dist/world/resourceNode.js +2 -1
  186. package/dist/world/roads.d.ts +294 -5
  187. package/dist/world/roads.js +1262 -21
  188. package/dist/world/sharedAnnotations.d.ts +84 -0
  189. package/dist/world/sharedAnnotations.js +76 -0
  190. package/dist/world/spawnOverride.d.ts +37 -0
  191. package/dist/world/spawnOverride.js +50 -0
  192. package/dist/world/streetGenerator.d.ts +60 -11
  193. package/dist/world/streetGenerator.js +1786 -129
  194. package/dist/world/terrain.d.ts +1 -0
  195. package/dist/world/terrain.js +2 -0
  196. package/dist/world/waypoints.d.ts +63 -0
  197. package/dist/world/waypoints.js +137 -0
  198. package/dist/world.d.ts +17 -2
  199. package/dist/world.js +17 -2
  200. package/package.json +1 -1
  201. package/skills/ce-handoff/SKILL.md +149 -0
  202. package/skills/game-design/SKILL.md +2 -0
  203. package/skills/jgengine/SKILL.md +3 -1
  204. package/skills/jgengine/api.md +17 -7
  205. package/skills/jgengine/capabilities.md +12 -0
  206. package/skills/jgengine/recipes/minimal-game.md +7 -4
  207. package/skills/jgengine-combat/api.md +40 -4
  208. package/skills/jgengine-combat/capabilities.md +12 -0
  209. package/skills/jgengine-combat/reference.md +2 -0
  210. package/skills/jgengine-gameplay/api.md +212 -11
  211. package/skills/jgengine-gameplay/capabilities.md +66 -1
  212. package/skills/jgengine-world/api.md +397 -11
  213. package/skills/jgengine-world/capabilities.md +123 -2
  214. package/skills/jgengine-world/reference.md +15 -1
@@ -1,3 +1,4 @@
1
+ import { resolveRng } from "../random/resolveRng.js";
1
2
  function assertValidEntry(entry, mode) {
2
3
  const kinds = [entry.item !== undefined, entry.currency !== undefined, entry.generate !== undefined].filter(Boolean).length;
3
4
  if (kinds !== 1) {
@@ -53,8 +54,9 @@ function rollEntry(entry, rng) {
53
54
  *
54
55
  * @capability loot-table register loot tables and roll weighted randomized drops
55
56
  */
56
- export function createLootRegistry() {
57
+ export function createLootRegistry(options = {}) {
57
58
  const tables = new Map();
59
+ const defaultRng = resolveRng(options.rng, "createLootRegistry");
58
60
  return {
59
61
  register(def) {
60
62
  assertValidDef(def);
@@ -66,7 +68,7 @@ export function createLootRegistry() {
66
68
  has(id) {
67
69
  return tables.has(id);
68
70
  },
69
- roll(id, rng = Math.random) {
71
+ roll(id, rng = defaultRng) {
70
72
  const table = tables.get(id);
71
73
  if (!table) {
72
74
  throw new Error(`unknown loot table: ${id}`);
@@ -0,0 +1,57 @@
1
+ /** One persistent notification — unlike a transient toast, it stays in the log until dismissed. */
2
+ export interface NotificationEntry<TMeta = unknown> {
3
+ id: string;
4
+ /** Severity/category (`"info"`, `"success"`, `"warning"`, `"danger"`, or a game kind). */
5
+ kind: string;
6
+ title: string;
7
+ body?: string;
8
+ /** Creation time from the injected clock. */
9
+ at: number;
10
+ read: boolean;
11
+ meta?: TMeta;
12
+ }
13
+ /** Fields accepted by {@link NotificationStore.push}. */
14
+ export interface NotificationInput<TMeta = unknown> {
15
+ id?: string;
16
+ kind?: string;
17
+ title: string;
18
+ body?: string;
19
+ at?: number;
20
+ meta?: TMeta;
21
+ }
22
+ /** Filter for {@link NotificationStore.list}. */
23
+ export interface NotificationFilter {
24
+ kind?: string;
25
+ unreadOnly?: boolean;
26
+ }
27
+ /** Serializable, observable log of persistent notifications with read tracking. */
28
+ export interface NotificationStore<TMeta = unknown> {
29
+ push(input: NotificationInput<TMeta>): NotificationEntry<TMeta>;
30
+ /** Newest-first; filter by kind and/or unread. Stable identity between changes for the unfiltered read. */
31
+ list(filter?: NotificationFilter): readonly NotificationEntry<TMeta>[];
32
+ unreadCount(): number;
33
+ markRead(id: string): void;
34
+ markAllRead(): void;
35
+ remove(id: string): boolean;
36
+ clear(): void;
37
+ subscribe(listener: () => void): () => void;
38
+ snapshot(): readonly NotificationEntry<TMeta>[];
39
+ restore(snapshot: readonly NotificationEntry<TMeta>[]): void;
40
+ }
41
+ /** Options for {@link createNotificationCenter}. */
42
+ export interface NotificationCenterOptions {
43
+ /** Injected clock (ms). Default `Date.now`. */
44
+ now?: () => number;
45
+ /** Keep only the newest `cap` notifications. Default 100; 0 = unbounded. */
46
+ cap?: number;
47
+ }
48
+ /**
49
+ * A persistent notification log with read/unread tracking — the "notification
50
+ * center" surface toasts don't cover. Newest-first, capped, serializable, and
51
+ * observable; a game pushes durable events (quest updates, trades, party news)
52
+ * here and a HUD renders the list + an unread badge. `snapshot`/`restore`
53
+ * round-trip through a save.
54
+ *
55
+ * @capability notification-center persistent, read-tracked notification log (newest-first, capped, serializable) behind an unread badge — the durable counterpart to transient toasts
56
+ */
57
+ export declare function createNotificationCenter<TMeta = unknown>(options?: NotificationCenterOptions): NotificationStore<TMeta>;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * A persistent notification log with read/unread tracking — the "notification
3
+ * center" surface toasts don't cover. Newest-first, capped, serializable, and
4
+ * observable; a game pushes durable events (quest updates, trades, party news)
5
+ * here and a HUD renders the list + an unread badge. `snapshot`/`restore`
6
+ * round-trip through a save.
7
+ *
8
+ * @capability notification-center persistent, read-tracked notification log (newest-first, capped, serializable) behind an unread badge — the durable counterpart to transient toasts
9
+ */
10
+ export function createNotificationCenter(options = {}) {
11
+ const now = options.now ?? Date.now;
12
+ const cap = options.cap ?? 100;
13
+ let entries = []; // newest-first
14
+ const listeners = new Set();
15
+ let cache = null;
16
+ let counter = 0;
17
+ function notify() {
18
+ cache = null;
19
+ for (const listener of listeners)
20
+ listener();
21
+ }
22
+ function generateId() {
23
+ counter += 1;
24
+ return `notice-${counter}`;
25
+ }
26
+ return {
27
+ push(input) {
28
+ const entry = {
29
+ id: input.id ?? generateId(),
30
+ kind: input.kind ?? "info",
31
+ title: input.title,
32
+ at: input.at ?? now(),
33
+ read: false,
34
+ ...(input.body !== undefined ? { body: input.body } : {}),
35
+ ...(input.meta !== undefined ? { meta: input.meta } : {}),
36
+ };
37
+ entries = [entry, ...entries.filter((existing) => existing.id !== entry.id)];
38
+ if (cap > 0 && entries.length > cap)
39
+ entries = entries.slice(0, cap);
40
+ notify();
41
+ return entry;
42
+ },
43
+ list(filter) {
44
+ if (filter === undefined) {
45
+ if (cache === null)
46
+ cache = [...entries];
47
+ return cache;
48
+ }
49
+ return entries.filter((entry) => (filter.kind === undefined || entry.kind === filter.kind) &&
50
+ (filter.unreadOnly !== true || !entry.read));
51
+ },
52
+ unreadCount() {
53
+ let count = 0;
54
+ for (const entry of entries)
55
+ if (!entry.read)
56
+ count += 1;
57
+ return count;
58
+ },
59
+ markRead(id) {
60
+ const entry = entries.find((candidate) => candidate.id === id);
61
+ if (entry === undefined || entry.read)
62
+ return;
63
+ entry.read = true;
64
+ notify();
65
+ },
66
+ markAllRead() {
67
+ let changed = false;
68
+ for (const entry of entries) {
69
+ if (!entry.read) {
70
+ entry.read = true;
71
+ changed = true;
72
+ }
73
+ }
74
+ if (changed)
75
+ notify();
76
+ },
77
+ remove(id) {
78
+ const next = entries.filter((entry) => entry.id !== id);
79
+ if (next.length === entries.length)
80
+ return false;
81
+ entries = next;
82
+ notify();
83
+ return true;
84
+ },
85
+ clear() {
86
+ if (entries.length === 0)
87
+ return;
88
+ entries = [];
89
+ notify();
90
+ },
91
+ subscribe(listener) {
92
+ listeners.add(listener);
93
+ return () => listeners.delete(listener);
94
+ },
95
+ snapshot() {
96
+ if (cache === null)
97
+ cache = [...entries];
98
+ return cache;
99
+ },
100
+ restore(snapshot) {
101
+ entries = snapshot.map((entry) => ({ ...entry }));
102
+ if (cap > 0 && entries.length > cap)
103
+ entries = entries.slice(0, cap);
104
+ notify();
105
+ },
106
+ };
107
+ }
@@ -140,6 +140,22 @@ export interface ModelMaterialOverride {
140
140
  emissiveIntensity?: number;
141
141
  /** Real PBR texture maps applied over the model's material — see {@link ModelMaterialMaps}. */
142
142
  maps?: ModelMaterialMaps;
143
+ /**
144
+ * Fresnel rim light: a bright edge where the surface turns away from the viewer. This is what
145
+ * separates an actor from ground of the same value — a body lit only by the scene's own key sits
146
+ * in the terrain's tonal band at range and reads as a smudge, however well its albedo is chosen.
147
+ * Omit for no rim.
148
+ */
149
+ rim?: ModelRimLight;
150
+ }
151
+ /** Fresnel rim-light term added on top of a model's lighting — see {@link ModelMaterialOverride.rim}. */
152
+ export interface ModelRimLight {
153
+ /** Rim colour. Default "#ffffff". */
154
+ color?: string;
155
+ /** Rim brightness. 0 disables it; ~0.3 is a subtle read, ~1.5 is a hard sci-fi edge. Default 0.6. */
156
+ strength?: number;
157
+ /** Falloff exponent: higher confines the rim to the extreme edge. Default 2.5. */
158
+ power?: number;
143
159
  }
144
160
  /** Parents a prop/weapon model to a named bone or node on the host model's rig — a sword on `handslot.r`, a spellbook offhand — following the bone's animated transform each frame. */
145
161
  export interface ModelAttachment {
@@ -221,6 +237,14 @@ export interface DirectionalLightingConfig {
221
237
  shadowMapSize?: number;
222
238
  /** Half-extent of the orthographic shadow camera in world units — sized to the shadowed area. Default 40. */
223
239
  shadowCameraSize?: number;
240
+ /**
241
+ * Cascade count for cascaded shadow maps (CSM). When `> 1` and `castShadow`, the shell mounts
242
+ * three.js CSM so outdoor scenes keep shadows past a single ortho frustum. Default `1` (legacy
243
+ * single map). Typical outdoor value: `3` or `4`.
244
+ */
245
+ cascades?: number;
246
+ /** Max far plane for CSM cascades in world units. Default 200. */
247
+ shadowMaxFar?: number;
224
248
  /** Shadow depth bias to fight acne. Default -0.0004. */
225
249
  shadowBias?: number;
226
250
  /** Shadow normal bias to fight peter-panning. Default 0.02. */
@@ -258,8 +282,16 @@ export interface PlayerMovementConfig {
258
282
  axis?: "x" | "z";
259
283
  /** Cell size for mode "grid". Default 1. */
260
284
  cellSize?: number;
261
- /** Collide the walking player against placed scene objects (unit-box AABBs) even without `collision.voxel`. Default false. */
285
+ /**
286
+ * Collide the walking player against placed scene objects' blocking colliders even without
287
+ * `collision.voxel`. Default **true** — a placed object with a blocking physical collider stops,
288
+ * supports, and is stood upon by the player; set `false` to walk through everything (ghost/spectator
289
+ * modes, pure-board presentations). Objects that should never block should carry non-blocking
290
+ * colliders instead of relying on this switch.
291
+ */
262
292
  collideObjects?: boolean;
293
+ /** Tallest object ledge stepped up while walking (and largest drop still snapped down instead of falling). Default 0.4. */
294
+ stepHeight?: number;
263
295
  /** Intercepts each frame's resolved position before the pose commits (and before onTick): return a replacement [x,y,z] to constrain or redirect the step, or nothing to accept it. */
264
296
  beforeCommit?: (frame: MovementCommitFrame) => readonly [number, number, number] | undefined | void;
265
297
  /** Gates the built-in sprint (`runSpeedMultiplier`) behind live game state — a stamina stat, an encumbrance check (#282.3). Called each frame while sprint is held; `false` walks. */
@@ -297,7 +329,12 @@ export interface PresentationEffectsConfig {
297
329
  vfx?: boolean;
298
330
  /** Floating combat text (damage numbers, etc.). Default true. */
299
331
  floatText?: boolean;
300
- /** Projectile tracer lines. Default true. */
332
+ /**
333
+ * Straight muzzle→impact tracer lines for direct-fire shots. Opt-in (default false): unlike the
334
+ * other channels, a missing `tracers` key leaves them OFF, because `fireProjectile` is a generic
335
+ * seam and a tracer is only meaningful for a game that fires direct, bullet-like projectiles.
336
+ * Arced/exploding shots (grenades, launchers) never draw a tracer even when this is on.
337
+ */
301
338
  tracers?: boolean;
302
339
  /** Trauma-driven combat camera shake. Default true. */
303
340
  shake?: boolean;
@@ -99,6 +99,32 @@ export interface QuestJournal {
99
99
  * @capability quest-log track accepted quests and their per-objective progress
100
100
  */
101
101
  export declare function createQuestJournal(deps: QuestJournalDeps): QuestJournal;
102
+ /** One objective as a tracker/HUD reads it — label + progress toward its count. */
103
+ export interface TrackedObjectiveView {
104
+ id: string;
105
+ /** Human label (e.g. "Defeat 3 wraiths"); falls back to the objective id. */
106
+ label: string;
107
+ count: number;
108
+ progress: number;
109
+ complete: boolean;
110
+ }
111
+ /** A quest as a tracker/HUD reads it — title, status, and per-objective progress. */
112
+ export interface TrackedQuestView {
113
+ id: string;
114
+ title: string;
115
+ status: QuestStatus;
116
+ objectives: TrackedObjectiveView[];
117
+ }
118
+ /** Default objective label: a readable "verb count noun" from a {@link QuestObjective}. */
119
+ export declare function defaultObjectiveLabel(objective: QuestObjective): string;
120
+ /**
121
+ * Join a quest's static {@link QuestDef} with a player's live {@link QuestInstance}
122
+ * into a flat, renderer-free view a HUD tracker draws (title, status, labelled
123
+ * objective progress). Pass `label` to override the derived objective text.
124
+ *
125
+ * @capability quest-tracker-view join a QuestDef + live QuestInstance into a flat labelled view for a quest/objective HUD tracker
126
+ */
127
+ export declare function describeTrackedQuest(def: QuestDef, instance: QuestInstance, label?: (objective: QuestObjective) => string): TrackedQuestView;
102
128
  export interface QuestAcceptOptions {
103
129
  hasUnlock?(id: string): boolean;
104
130
  }
@@ -259,6 +259,37 @@ export function createQuestJournal(deps) {
259
259
  },
260
260
  };
261
261
  }
262
+ /** Default objective label: a readable "verb count noun" from a {@link QuestObjective}. */
263
+ export function defaultObjectiveLabel(objective) {
264
+ const noun = objective.target ?? objective.item ?? objective.id;
265
+ const verb = objective.kind === "kill" ? "Defeat" : objective.kind === "collect" ? "Collect" : objective.kind;
266
+ return `${verb} ${objective.count} ${noun}`;
267
+ }
268
+ /**
269
+ * Join a quest's static {@link QuestDef} with a player's live {@link QuestInstance}
270
+ * into a flat, renderer-free view a HUD tracker draws (title, status, labelled
271
+ * objective progress). Pass `label` to override the derived objective text.
272
+ *
273
+ * @capability quest-tracker-view join a QuestDef + live QuestInstance into a flat labelled view for a quest/objective HUD tracker
274
+ */
275
+ export function describeTrackedQuest(def, instance, label = defaultObjectiveLabel) {
276
+ const defs = new Map(def.objectives.map((objective) => [objective.id, objective]));
277
+ return {
278
+ id: def.id,
279
+ title: def.title,
280
+ status: instance.status,
281
+ objectives: instance.objectives.map((progress) => {
282
+ const source = defs.get(progress.id);
283
+ return {
284
+ id: progress.id,
285
+ label: source === undefined ? progress.id : label(source),
286
+ count: progress.count,
287
+ progress: progress.progress,
288
+ complete: progress.complete,
289
+ };
290
+ }),
291
+ };
292
+ }
262
293
  export function applyQuestRewards(rewards, appliers) {
263
294
  for (const entry of rewards.items ?? []) {
264
295
  const fail = appliers.grantItem?.(entry.inventory, entry.item, entry.count);
@@ -1,4 +1,5 @@
1
1
  import { pickWeighted } from "../world/scatterItems.js";
2
+ import { resolveRng } from "../random/resolveRng.js";
2
3
  export function createRunModifierStack(offers) {
3
4
  const byId = new Map();
4
5
  for (const offer of offers) {
@@ -75,7 +76,7 @@ export function createRunModifierStack(offers) {
75
76
  */
76
77
  export function createRunDraft(config) {
77
78
  const stack = createRunModifierStack(config.offers);
78
- const defaultRng = config.rng ?? Math.random;
79
+ const defaultRng = resolveRng(config.rng, "createRunDraft");
79
80
  return {
80
81
  present(count, rng = defaultRng) {
81
82
  const pool = config.offers.filter((offer) => !stack.atMax(offer.id));
@@ -0,0 +1,102 @@
1
+ /**
2
+ * One entry in a {@link SaveSlots} index: a save/profile slot's *display*
3
+ * metadata, not its payload. The real save data lives in a `createSaveStore`
4
+ * slot; this record is only what a save-select menu renders — an id, an optional
5
+ * player-facing `name`, whether the slot is `empty`, when it was last written
6
+ * (`savedAt`), and a free-string `meta` bag the game fills with whatever the
7
+ * menu should show (level, playtime, location, chapter, a thumbnail ref, …).
8
+ * `meta` keys are opaque to this model — it never reads or branches on them.
9
+ */
10
+ export interface SaveSlotMeta {
11
+ /** Stable slot id — pair it with the same slot in the game's `createSaveStore`. */
12
+ id: string;
13
+ /** Player-facing label (character name, save title). `undefined` until named. */
14
+ name?: string;
15
+ /** `true` when no save occupies this slot — the menu shows a "New Game" affordance. */
16
+ empty: boolean;
17
+ /** Clock time (ms) of the last {@link SaveSlots.write}, or `undefined` while empty. */
18
+ savedAt?: number;
19
+ /**
20
+ * Free-string display fields the game owns and the menu renders as chips —
21
+ * e.g. `{ chapter: "The Drowned Keep", playtime: "4h 12m", level: 8 }`. The
22
+ * model never interprets these keys; they are pure passthrough to the renderer.
23
+ */
24
+ meta: Record<string, string | number>;
25
+ }
26
+ /** What a {@link SaveSlots.write} stamps onto a slot — a name and/or a fresh `meta` bag. */
27
+ export interface SaveSlotWrite {
28
+ /** Player-facing label for the slot. Omit to keep the existing name. */
29
+ name?: string;
30
+ /** Replacement free-string display fields. Omit to keep the existing `meta`. */
31
+ meta?: Record<string, string | number>;
32
+ }
33
+ /** How a {@link createSaveSlots} index is wired. */
34
+ export interface SaveSlotsConfig {
35
+ /** Injected clock (ms) used to stamp `savedAt`. Default `Date.now`. */
36
+ now?: () => number;
37
+ /**
38
+ * Initial slots, in menu order. Any slot omitting `empty` is treated as empty.
39
+ * When `capacity` is set and this is shorter, the list is padded with empty
40
+ * slots (`slot-1`, `slot-2`, …) up to `capacity`.
41
+ */
42
+ slots?: SaveSlotMeta[];
43
+ /**
44
+ * Fixed number of slots the menu shows. When set, the index is padded with
45
+ * empty slots up to this count and never grows past it. Omit for an
46
+ * open-ended list that only holds the slots you seed.
47
+ */
48
+ capacity?: number;
49
+ }
50
+ /** Serializable state of a {@link SaveSlots} index, for save/restore. */
51
+ export interface SaveSlotsSnapshot {
52
+ /** The slots in menu order. */
53
+ slots: SaveSlotMeta[];
54
+ /** The fixed capacity, or `undefined` for an open-ended list. */
55
+ capacity: number | undefined;
56
+ }
57
+ /**
58
+ * A serializable, observable index of save/profile slot *metadata* that a
59
+ * save-select menu renders. It complements `createSaveStore` (which owns the
60
+ * actual save payload and named slots) by carrying the per-slot display fields
61
+ * the store does not: name, empty flag, last-saved time, and a free-string
62
+ * `meta` bag the game fills. Nothing here is genre-specific.
63
+ */
64
+ export interface SaveSlots {
65
+ /**
66
+ * Stamp a slot as written: sets `empty=false`, records `savedAt` via the
67
+ * injected clock, and applies the given `name`/`meta`. Creates the slot if it
68
+ * does not exist (subject to `capacity`). Returns the resulting slot record.
69
+ */
70
+ write(id: string, write?: SaveSlotWrite): SaveSlotMeta;
71
+ /** Mark a slot empty, dropping its `name`, `savedAt`, and `meta`. Returns the cleared record, or `null` if the id is unknown. */
72
+ clear(id: string): SaveSlotMeta | null;
73
+ /** Rename a slot without touching its `savedAt`/`meta`. Returns the updated record, or `null` if the id is unknown. */
74
+ rename(id: string, name: string): SaveSlotMeta | null;
75
+ /** The slot with this id, or `null`. The returned record is a copy — mutating it does not affect the index. */
76
+ get(id: string): SaveSlotMeta | null;
77
+ /** All slots in menu order (copies). Powers the save-select grid. */
78
+ list(): SaveSlotMeta[];
79
+ /** The newest non-empty slot (highest `savedAt`), or `null` when every slot is empty — powers "Continue". */
80
+ mostRecent(): SaveSlotMeta | null;
81
+ /** Observe any change (write/clear/rename/restore). Returns an unsubscribe fn. */
82
+ subscribe(listener: () => void): () => void;
83
+ /** Serializable state for a save. */
84
+ snapshot(): SaveSlotsSnapshot;
85
+ /** Restore from a {@link SaveSlotsSnapshot}, replacing all slots. */
86
+ restore(snapshot: SaveSlotsSnapshot): void;
87
+ }
88
+ /**
89
+ * Create a {@link SaveSlots} index — the serializable, observable list of
90
+ * save/profile slot metadata a save-select menu renders. It is a thin companion
91
+ * to `createSaveStore`: the store owns the real save payload keyed by slot, this
92
+ * owns only the display fields (name, empty, last-saved, and a free-string
93
+ * `meta` bag) the menu shows. `write` stamps a slot saved on the injected clock,
94
+ * `clear` empties one, `rename` relabels one, `mostRecent()` returns the newest
95
+ * non-empty slot to power a "Continue" button, and `list()` feeds the New /
96
+ * Continue / Load / Delete grid. `meta` keys are free strings the game owns —
97
+ * the model never interprets them — and `snapshot`/`restore` round-trips the
98
+ * whole index through a save.
99
+ *
100
+ * @capability save-slots serializable save-slot / profile metadata index — write/clear/rename/mostRecent over free-string per-slot meta, powering New/Continue/Load/Delete menus, with snapshot/restore
101
+ */
102
+ export declare function createSaveSlots(config?: SaveSlotsConfig): SaveSlots;
@@ -0,0 +1,136 @@
1
+ function emptySlot(id) {
2
+ return { id, empty: true, meta: {} };
3
+ }
4
+ function cloneSlot(slot) {
5
+ return {
6
+ id: slot.id,
7
+ name: slot.name,
8
+ empty: slot.empty,
9
+ savedAt: slot.savedAt,
10
+ meta: { ...slot.meta },
11
+ };
12
+ }
13
+ function normalizeSlot(slot) {
14
+ const empty = slot.empty ?? true;
15
+ return {
16
+ id: slot.id,
17
+ name: slot.name,
18
+ empty,
19
+ savedAt: empty ? undefined : slot.savedAt,
20
+ meta: empty ? {} : { ...(slot.meta ?? {}) },
21
+ };
22
+ }
23
+ /**
24
+ * Create a {@link SaveSlots} index — the serializable, observable list of
25
+ * save/profile slot metadata a save-select menu renders. It is a thin companion
26
+ * to `createSaveStore`: the store owns the real save payload keyed by slot, this
27
+ * owns only the display fields (name, empty, last-saved, and a free-string
28
+ * `meta` bag) the menu shows. `write` stamps a slot saved on the injected clock,
29
+ * `clear` empties one, `rename` relabels one, `mostRecent()` returns the newest
30
+ * non-empty slot to power a "Continue" button, and `list()` feeds the New /
31
+ * Continue / Load / Delete grid. `meta` keys are free strings the game owns —
32
+ * the model never interprets them — and `snapshot`/`restore` round-trips the
33
+ * whole index through a save.
34
+ *
35
+ * @capability save-slots serializable save-slot / profile metadata index — write/clear/rename/mostRecent over free-string per-slot meta, powering New/Continue/Load/Delete menus, with snapshot/restore
36
+ */
37
+ export function createSaveSlots(config = {}) {
38
+ const now = config.now ?? Date.now;
39
+ let capacity = config.capacity;
40
+ const slots = (config.slots ?? []).map(normalizeSlot);
41
+ const seen = new Set(slots.map((slot) => slot.id));
42
+ if (capacity !== undefined) {
43
+ let n = 1;
44
+ while (slots.length < capacity) {
45
+ let id = `slot-${n}`;
46
+ while (seen.has(id)) {
47
+ n += 1;
48
+ id = `slot-${n}`;
49
+ }
50
+ seen.add(id);
51
+ slots.push(emptySlot(id));
52
+ n += 1;
53
+ }
54
+ }
55
+ const listeners = new Set();
56
+ function notify() {
57
+ for (const listener of listeners)
58
+ listener();
59
+ }
60
+ function indexOf(id) {
61
+ return slots.findIndex((slot) => slot.id === id);
62
+ }
63
+ return {
64
+ write(id, write) {
65
+ let i = indexOf(id);
66
+ if (i === -1) {
67
+ if (capacity !== undefined && slots.length >= capacity) {
68
+ throw new Error(`save-slots: cannot create slot "${id}" — capacity ${capacity} reached`);
69
+ }
70
+ slots.push(emptySlot(id));
71
+ i = slots.length - 1;
72
+ }
73
+ const slot = slots[i];
74
+ slot.empty = false;
75
+ slot.savedAt = now();
76
+ if (write?.name !== undefined)
77
+ slot.name = write.name;
78
+ if (write?.meta !== undefined)
79
+ slot.meta = { ...write.meta };
80
+ notify();
81
+ return cloneSlot(slot);
82
+ },
83
+ clear(id) {
84
+ const i = indexOf(id);
85
+ if (i === -1)
86
+ return null;
87
+ const slot = slots[i];
88
+ slot.empty = true;
89
+ slot.name = undefined;
90
+ slot.savedAt = undefined;
91
+ slot.meta = {};
92
+ notify();
93
+ return cloneSlot(slot);
94
+ },
95
+ rename(id, name) {
96
+ const i = indexOf(id);
97
+ if (i === -1)
98
+ return null;
99
+ const slot = slots[i];
100
+ slot.name = name;
101
+ notify();
102
+ return cloneSlot(slot);
103
+ },
104
+ get(id) {
105
+ const i = indexOf(id);
106
+ return i === -1 ? null : cloneSlot(slots[i]);
107
+ },
108
+ list() {
109
+ return slots.map(cloneSlot);
110
+ },
111
+ mostRecent() {
112
+ let best = null;
113
+ for (const slot of slots) {
114
+ if (slot.empty)
115
+ continue;
116
+ if (best === null || (slot.savedAt ?? 0) > (best.savedAt ?? 0))
117
+ best = slot;
118
+ }
119
+ return best === null ? null : cloneSlot(best);
120
+ },
121
+ subscribe(listener) {
122
+ listeners.add(listener);
123
+ return () => listeners.delete(listener);
124
+ },
125
+ snapshot() {
126
+ return { slots: slots.map(cloneSlot), capacity };
127
+ },
128
+ restore(snapshot) {
129
+ capacity = snapshot.capacity;
130
+ slots.length = 0;
131
+ for (const slot of snapshot.slots)
132
+ slots.push(normalizeSlot(slot));
133
+ notify();
134
+ },
135
+ };
136
+ }