@jgengine/core 0.14.0 → 0.15.0-next.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 (209) hide show
  1. package/CHANGELOG.md +483 -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/authoring.d.ts +6 -4
  12. package/dist/authoring.js +6 -4
  13. package/dist/combat/castRunner.d.ts +13 -1
  14. package/dist/combat/castRunner.js +5 -1
  15. package/dist/combat/comboMeter.d.ts +140 -0
  16. package/dist/combat/comboMeter.js +215 -0
  17. package/dist/combat/comboPoints.d.ts +11 -0
  18. package/dist/combat/comboPoints.js +11 -2
  19. package/dist/combat/damageResolution.d.ts +1 -1
  20. package/dist/combat/defensiveWindow.d.ts +5 -0
  21. package/dist/combat/effects.d.ts +24 -0
  22. package/dist/combat/effects.js +6 -1
  23. package/dist/combat/projectiles.d.ts +6 -0
  24. package/dist/combat/projectiles.js +2 -2
  25. package/dist/combat/regenShield.d.ts +40 -2
  26. package/dist/combat/regenShield.js +66 -18
  27. package/dist/combat/resourcePool.d.ts +19 -0
  28. package/dist/combat/resourcePool.js +20 -2
  29. package/dist/combat/shotOrigin.d.ts +5 -0
  30. package/dist/combat/statusApplication.d.ts +1 -1
  31. package/dist/combat/statusApplication.js +2 -1
  32. package/dist/combat/statusEffectView.d.ts +56 -0
  33. package/dist/combat/statusEffectView.js +52 -0
  34. package/dist/combat.d.ts +4 -2
  35. package/dist/combat.js +2 -0
  36. package/dist/devtools/fallbackSeams.d.ts +3 -2
  37. package/dist/devtools/fallbackSeams.js +1 -1
  38. package/dist/devtools/textureErrors.d.ts +40 -0
  39. package/dist/devtools/textureErrors.js +68 -0
  40. package/dist/economy/shopStock.d.ts +110 -0
  41. package/dist/economy/shopStock.js +157 -0
  42. package/dist/economy/wallet.d.ts +6 -0
  43. package/dist/economy/wallet.js +1 -0
  44. package/dist/game/achievements.d.ts +80 -0
  45. package/dist/game/achievements.js +124 -0
  46. package/dist/game/cameraConfig.d.ts +7 -0
  47. package/dist/game/codex.d.ts +60 -0
  48. package/dist/game/codex.js +89 -0
  49. package/dist/game/credits.d.ts +36 -0
  50. package/dist/game/credits.js +0 -0
  51. package/dist/game/defineGame.d.ts +28 -3
  52. package/dist/game/defineGame.js +5 -0
  53. package/dist/game/dialogueGraph.d.ts +116 -0
  54. package/dist/game/dialogueGraph.js +103 -0
  55. package/dist/game/eventTicker.d.ts +95 -0
  56. package/dist/game/eventTicker.js +91 -0
  57. package/dist/game/events.d.ts +24 -5
  58. package/dist/game/events.js +18 -0
  59. package/dist/game/gamePhase.d.ts +1 -1
  60. package/dist/game/gamePhase.js +1 -1
  61. package/dist/game/leaderboardRank.d.ts +71 -0
  62. package/dist/game/leaderboardRank.js +76 -0
  63. package/dist/game/lootTable.d.ts +10 -1
  64. package/dist/game/lootTable.js +4 -2
  65. package/dist/game/notifications.d.ts +57 -0
  66. package/dist/game/notifications.js +107 -0
  67. package/dist/game/playableGame.d.ts +39 -2
  68. package/dist/game/quest.d.ts +26 -0
  69. package/dist/game/quest.js +31 -0
  70. package/dist/game/runDraft.js +2 -1
  71. package/dist/game/saveSlots.d.ts +102 -0
  72. package/dist/game/saveSlots.js +136 -0
  73. package/dist/game/talentTreeView.d.ts +106 -0
  74. package/dist/game/talentTreeView.js +114 -0
  75. package/dist/game/worldItem.js +2 -1
  76. package/dist/gameplay.d.ts +15 -4
  77. package/dist/gameplay.js +13 -2
  78. package/dist/i18n/i18n.d.ts +51 -0
  79. package/dist/i18n/i18n.js +82 -0
  80. package/dist/input/axisInput.d.ts +1 -1
  81. package/dist/input/axisInput.js +18 -2
  82. package/dist/input/rebindSession.d.ts +141 -0
  83. package/dist/input/rebindSession.js +235 -0
  84. package/dist/input/touchControlsMode.d.ts +16 -0
  85. package/dist/input/touchControlsMode.js +23 -0
  86. package/dist/input/touchScheme.d.ts +30 -1
  87. package/dist/input/touchScheme.js +31 -1
  88. package/dist/interaction/promptRegistry.d.ts +78 -0
  89. package/dist/interaction/promptRegistry.js +130 -0
  90. package/dist/inventory/storageTier.js +3 -2
  91. package/dist/meta/changelog.d.ts +1 -1
  92. package/dist/meta/changelog.js +1 -1
  93. package/dist/movement/movementModel.d.ts +26 -3
  94. package/dist/movement/movementModel.js +121 -15
  95. package/dist/movement/playerMovement.js +161 -25
  96. package/dist/physics/drivableVehicle.js +3 -1
  97. package/dist/physics/kinematicVehicle.d.ts +56 -1
  98. package/dist/physics/kinematicVehicle.js +99 -10
  99. package/dist/physics/vehicleObstacles.js +19 -0
  100. package/dist/random/resolveRng.d.ts +14 -0
  101. package/dist/random/resolveRng.js +31 -0
  102. package/dist/render/postProcessing.d.ts +12 -1
  103. package/dist/runtime/cameraDirector.d.ts +2 -2
  104. package/dist/runtime/context/combat.js +8 -0
  105. package/dist/runtime/gameContext.js +6 -1
  106. package/dist/runtime/gameContextTypes.d.ts +18 -0
  107. package/dist/runtime/inputSnapshot.d.ts +10 -1
  108. package/dist/runtime/inputSnapshot.js +13 -1
  109. package/dist/runtime/runtimeSave.d.ts +3 -3
  110. package/dist/runtime/runtimeSave.js +11 -2
  111. package/dist/scene/assetDiagnostics.js +2 -2
  112. package/dist/scene/autoTarget.js +2 -1
  113. package/dist/scene/captureCheck.js +3 -2
  114. package/dist/scene/entityStore.d.ts +4 -1
  115. package/dist/scene/entityStore.js +3 -0
  116. package/dist/scene/objectStore.d.ts +10 -0
  117. package/dist/scene/objectStore.js +29 -6
  118. package/dist/scene/sceneKinds.d.ts +18 -0
  119. package/dist/scene/sceneKinds.js +19 -0
  120. package/dist/scene/sequenceDirector.d.ts +110 -0
  121. package/dist/scene/sequenceDirector.js +146 -0
  122. package/dist/session/roles.js +3 -2
  123. package/dist/settings/settingsModel.d.ts +1 -0
  124. package/dist/settings/settingsModel.js +1 -0
  125. package/dist/stats/rollCheck.js +4 -2
  126. package/dist/time/dayNightCycle.d.ts +114 -0
  127. package/dist/time/dayNightCycle.js +208 -0
  128. package/dist/time/timerSet.d.ts +132 -0
  129. package/dist/time/timerSet.js +243 -0
  130. package/dist/ui/accessibility.d.ts +51 -0
  131. package/dist/ui/accessibility.js +83 -0
  132. package/dist/ui/coachMarks.d.ts +99 -0
  133. package/dist/ui/coachMarks.js +126 -0
  134. package/dist/ui/floatingText.d.ts +114 -0
  135. package/dist/ui/floatingText.js +203 -0
  136. package/dist/ui/modalStack.d.ts +143 -0
  137. package/dist/ui/modalStack.js +160 -0
  138. package/dist/ui/objectiveBanner.d.ts +123 -0
  139. package/dist/ui/objectiveBanner.js +162 -0
  140. package/dist/ui/photoMode.d.ts +32 -0
  141. package/dist/ui/photoMode.js +51 -0
  142. package/dist/ui/radialMenu.d.ts +47 -0
  143. package/dist/ui/radialMenu.js +74 -0
  144. package/dist/ui/screenMarkers.d.ts +128 -0
  145. package/dist/ui/screenMarkers.js +118 -0
  146. package/dist/ui.d.ts +8 -0
  147. package/dist/ui.js +8 -0
  148. package/dist/vfx/cameraShake.d.ts +97 -0
  149. package/dist/vfx/cameraShake.js +136 -0
  150. package/dist/vfx/damageDirection.d.ts +102 -0
  151. package/dist/vfx/damageDirection.js +188 -0
  152. package/dist/vfx/particles.d.ts +103 -0
  153. package/dist/vfx/particles.js +256 -0
  154. package/dist/vfx/screenEffects.d.ts +149 -0
  155. package/dist/vfx/screenEffects.js +191 -0
  156. package/dist/world/authoredObjects.d.ts +18 -0
  157. package/dist/world/authoredObjects.js +21 -0
  158. package/dist/world/authoredSpawn.js +19 -0
  159. package/dist/world/buildingLots.d.ts +54 -11
  160. package/dist/world/buildingLots.js +116 -30
  161. package/dist/world/cityBlocks.d.ts +45 -0
  162. package/dist/world/cityBlocks.js +159 -0
  163. package/dist/world/cityContent.d.ts +46 -2
  164. package/dist/world/cityContent.js +160 -2
  165. package/dist/world/cityGenerator.d.ts +261 -23
  166. package/dist/world/cityGenerator.js +643 -33
  167. package/dist/world/cityGeometry.d.ts +20 -0
  168. package/dist/world/cityGeometry.js +91 -0
  169. package/dist/world/cityKind.js +1 -44
  170. package/dist/world/fastTravel.d.ts +64 -0
  171. package/dist/world/fastTravel.js +108 -0
  172. package/dist/world/features.d.ts +16 -0
  173. package/dist/world/features.js +2 -0
  174. package/dist/world/grounded.d.ts +11 -0
  175. package/dist/world/grounded.js +12 -0
  176. package/dist/world/mapAnnotations.d.ts +75 -0
  177. package/dist/world/mapAnnotations.js +139 -0
  178. package/dist/world/markers.js +1 -0
  179. package/dist/world/resourceNode.d.ts +1 -1
  180. package/dist/world/resourceNode.js +2 -1
  181. package/dist/world/roads.d.ts +294 -5
  182. package/dist/world/roads.js +1262 -21
  183. package/dist/world/sharedAnnotations.d.ts +84 -0
  184. package/dist/world/sharedAnnotations.js +76 -0
  185. package/dist/world/spawnOverride.d.ts +37 -0
  186. package/dist/world/spawnOverride.js +50 -0
  187. package/dist/world/streetGenerator.d.ts +60 -11
  188. package/dist/world/streetGenerator.js +1786 -129
  189. package/dist/world/terrain.d.ts +1 -0
  190. package/dist/world/terrain.js +2 -0
  191. package/dist/world/waypoints.d.ts +63 -0
  192. package/dist/world/waypoints.js +137 -0
  193. package/dist/world.d.ts +17 -2
  194. package/dist/world.js +17 -2
  195. package/package.json +1 -1
  196. package/skills/ce-handoff/SKILL.md +149 -0
  197. package/skills/game-design/SKILL.md +2 -0
  198. package/skills/jgengine/SKILL.md +3 -1
  199. package/skills/jgengine/api.md +17 -7
  200. package/skills/jgengine/capabilities.md +12 -0
  201. package/skills/jgengine/recipes/minimal-game.md +7 -4
  202. package/skills/jgengine-combat/api.md +40 -4
  203. package/skills/jgengine-combat/capabilities.md +12 -0
  204. package/skills/jgengine-combat/reference.md +2 -0
  205. package/skills/jgengine-gameplay/api.md +212 -11
  206. package/skills/jgengine-gameplay/capabilities.md +66 -1
  207. package/skills/jgengine-world/api.md +378 -11
  208. package/skills/jgengine-world/capabilities.md +123 -2
  209. package/skills/jgengine-world/reference.md +15 -1
@@ -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
+ }
@@ -0,0 +1,106 @@
1
+ import type { TalentNodeDef, TalentTree } from "./talents.js";
2
+ /**
3
+ * Render state of a talent node, derived from its rank and prerequisite satisfaction.
4
+ * - `locked` — rank 0 and at least one prerequisite unmet; cannot be trained yet.
5
+ * - `available` — rank 0, every prerequisite met; ready to take a first point.
6
+ * - `learned` — at least one rank invested, but below `maxRank`.
7
+ * - `maxed` — fully invested (`rank === maxRank`).
8
+ */
9
+ export type TalentNodeState = "locked" | "available" | "learned" | "maxed";
10
+ /** One prerequisite edge into a node — the source node, the rank it demands, and whether that is met. */
11
+ export interface TalentEdgeView {
12
+ /** Prerequisite node id (edge source). */
13
+ from: string;
14
+ /** Rank the prerequisite must reach for this edge to be satisfied. */
15
+ rank: number;
16
+ /** Whether the prerequisite currently sits at or above the required rank. */
17
+ met: boolean;
18
+ }
19
+ /** A per-node view for rendering: grid placement (tier/branch), rank, state, and inbound prerequisite edges. */
20
+ export interface TalentNodeView {
21
+ /** Stable node id (matches the {@link TalentNodeDef}). */
22
+ id: string;
23
+ /** Branch key the node belongs to (`""` when the def has none) — a free string the game styles/columns by. */
24
+ branch: string;
25
+ /** 0-based prerequisite depth: `0` for a root, else `1 + max(tier of prerequisites)`. Drives row/tier layout. */
26
+ tier: number;
27
+ /** Points currently invested in the node. */
28
+ rank: number;
29
+ /** Maximum points the node accepts. */
30
+ maxRank: number;
31
+ /** Derived render state (see {@link TalentNodeState}). */
32
+ state: TalentNodeState;
33
+ /** Whether a point can be spent on the node right now (mirrors `tree.canAllocate(id).ok`). */
34
+ allocatable: boolean;
35
+ /** Inbound prerequisite edges, in definition order. */
36
+ requires: readonly TalentEdgeView[];
37
+ }
38
+ /** A whole-tree render view: placed nodes plus branch/tier extents and point totals. */
39
+ export interface TalentTreeView {
40
+ /** Every node, in definition order, with its render placement and state. */
41
+ nodes: readonly TalentNodeView[];
42
+ /** Distinct branch keys, in first-seen (definition) order — the natural column order. */
43
+ branches: readonly string[];
44
+ /** Number of tiers, i.e. `max(tier) + 1` (0 when the tree is empty). */
45
+ tiers: number;
46
+ /** Points the player still has to spend. */
47
+ pointsAvailable: number;
48
+ /** Points already invested across the tree. */
49
+ pointsSpent: number;
50
+ }
51
+ /**
52
+ * Per-node status a game supplies to build a view from *any* unlock rule — not just point-spend.
53
+ * A game computes these from whatever it wants (a currency threshold, a level, a quest flag, a
54
+ * skill-point pool, nothing at all) so the same widget renders a buy-with-points talent tree, a
55
+ * money-gated upgrade tree, or a condition-unlocked ability web without changing the renderer.
56
+ */
57
+ export interface TalentNodeStatus {
58
+ /** Points/levels invested in the node — `0` means "not taken yet"; a binary unlock uses `0`/`1`. */
59
+ rank: number;
60
+ /**
61
+ * Whether the node can be taken right now under the game's own rule (enough points, enough cash,
62
+ * a level reached, a flag set, …). Drives the `available` state and whether the widget fires
63
+ * `onLearn` for it. The view never interprets *why* — that stays the game's decision.
64
+ */
65
+ allocatable: boolean;
66
+ }
67
+ /** Optional overall point totals for the view header; omit (or 0) for trees that spend no point currency. */
68
+ export interface TalentPointTotals {
69
+ /** Points the player still has to spend. Default `0`. */
70
+ pointsAvailable?: number;
71
+ /** Points already invested across the tree. Default `0`. */
72
+ pointsSpent?: number;
73
+ }
74
+ /**
75
+ * Build a flat, serializable render view of a node graph from a caller-supplied per-node status,
76
+ * decoupled from any particular progression model. For each node the game returns its `rank` and
77
+ * whether it is `allocatable` *right now* under whatever rule it likes — a skill-point pool, a cash
78
+ * threshold, a level gate, a quest flag, or nothing. The builder places every node by branch +
79
+ * prerequisite-depth tier, tags it learned/available/locked/maxed (`available` when rank 0 and every
80
+ * prerequisite is met — see below), and resolves inbound prerequisite edges (each `met` when the
81
+ * source node's supplied rank meets the demand). This is the general escape hatch behind
82
+ * {@link talentTreeView}: it lets the same widget render a buy-with-points talent tree, a money-gated
83
+ * upgrade tree, or a condition-unlocked ability web without the renderer changing. It never interprets
84
+ * what a node *means* — ids, branches, and ranks stay opaque game data.
85
+ *
86
+ * `state` is derived purely from the supplied `rank` vs `maxRank` and prerequisite satisfaction, so a
87
+ * node the game considers takeable-by-condition surfaces as `available` (rank 0, prerequisites met)
88
+ * and the widget only fires `onLearn` for nodes whose `allocatable` the game returned `true`.
89
+ *
90
+ * @capability talent-tree-view-from build a placed talent/upgrade-tree render view from any per-node unlock rule (points, currency, level, flag) — branch/tier layout, learned/available/locked/maxed state, prerequisite edges
91
+ */
92
+ export declare function talentTreeViewFrom<TStat extends string = string>(nodes: readonly TalentNodeDef<TStat>[], status: (node: TalentNodeDef<TStat>) => TalentNodeStatus, totals?: TalentPointTotals): TalentTreeView;
93
+ /**
94
+ * Project the existing talent model (`createTalentTree`) into a flat, serializable render view: every
95
+ * node placed by branch + prerequisite-depth tier, tagged learned/available/locked/maxed, with its
96
+ * inbound prerequisite edges (and whether each is met) and whether a point can be spent right now. It is
97
+ * a pure read over the node defs plus a live {@link TalentTree}, so a React/canvas widget can lay out
98
+ * nodes, draw edges, and gate clicks without re-deriving topology or re-interpreting eligibility. The
99
+ * model still owns allocation, requirements, and point rules — this only reshapes them for drawing, and
100
+ * never interprets what a node *means* (ids, branches, and ranks stay opaque game data). This is the
101
+ * point-spend adapter over {@link talentTreeViewFrom}; pass a custom status to that builder instead to
102
+ * drive the tree from a currency, level, or any other unlock rule.
103
+ *
104
+ * @capability talent-tree-view flatten a talent tree into a placed, per-node render view — branch/tier layout, learned/available/locked/maxed state, prerequisite edges
105
+ */
106
+ export declare function talentTreeView<TStat extends string = string>(nodes: readonly TalentNodeDef<TStat>[], tree: TalentTree<TStat>): TalentTreeView;