@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
@@ -0,0 +1,157 @@
1
+ import { canAfford, charge, grant } from "./wallet.js";
2
+ function assertPrice(price) {
3
+ if (typeof price.currency !== "string" || price.currency.length === 0) {
4
+ throw new TypeError(`price.currency must be a non-empty string, got ${String(price.currency)}`);
5
+ }
6
+ if (!Number.isFinite(price.amount) || price.amount < 0) {
7
+ throw new RangeError(`price.amount must be a non-negative finite number, got ${price.amount}`);
8
+ }
9
+ }
10
+ function assertQty(qty) {
11
+ if (qty === null)
12
+ return;
13
+ if (!Number.isInteger(qty) || qty < 0) {
14
+ throw new RangeError(`qty must be null or a non-negative integer, got ${qty}`);
15
+ }
16
+ }
17
+ /** Deep-copy an entry so live internal records are never handed out or retained by callers. */
18
+ function cloneEntry(entry) {
19
+ const copy = {
20
+ id: entry.id,
21
+ kind: entry.kind,
22
+ price: { currency: entry.price.currency, amount: entry.price.amount },
23
+ qty: entry.qty,
24
+ };
25
+ if (entry.sellPrice !== undefined) {
26
+ copy.sellPrice = { currency: entry.sellPrice.currency, amount: entry.sellPrice.amount };
27
+ }
28
+ return copy;
29
+ }
30
+ function validateEntry(entry) {
31
+ if (typeof entry.id !== "string" || entry.id.length === 0) {
32
+ throw new TypeError(`entry.id must be a non-empty string, got ${String(entry.id)}`);
33
+ }
34
+ if (typeof entry.kind !== "string") {
35
+ throw new TypeError(`entry.kind must be a string, got ${String(entry.kind)}`);
36
+ }
37
+ assertPrice(entry.price);
38
+ assertQty(entry.qty);
39
+ if (entry.sellPrice !== undefined)
40
+ assertPrice(entry.sellPrice);
41
+ }
42
+ /**
43
+ * A serializable, observable vendor/shop stock. Each entry carries a game-owned free-string `kind`, a
44
+ * `price` in a free-string `currency`, a finite or unlimited (`qty: null`) count, and an optional
45
+ * `sellPrice` buy-back. All currency math is delegated to the existing pure `wallet` model — `buy`
46
+ * charges and `sell` grants a **caller-owned** {@link WalletState} (the shop never holds the wallet, it
47
+ * returns the new wallet for the caller to adopt), so the same stock composes with any wallet the game
48
+ * owns. Nothing here is genre-specific: `kind` and `currency` are free labels the game styles and the
49
+ * model never branches on. `restock`/`setPrice`/`add`/`remove` mutate the catalog, `subscribe` observes
50
+ * every change, and `snapshot`/`restore` round-trips the whole stock through a save.
51
+ *
52
+ * @capability shop-grid serializable vendor/shop stock with buy/sell over a caller-owned wallet — finite/unlimited qty, sell-back, restock/setPrice, snapshot/restore
53
+ */
54
+ export function createShopStock(config = {}) {
55
+ // Insertion-ordered map of live records (Map preserves insertion order for list()/snapshot()).
56
+ const entries = new Map();
57
+ const listeners = new Set();
58
+ function notify() {
59
+ for (const listener of listeners)
60
+ listener();
61
+ }
62
+ function ingest(entry) {
63
+ validateEntry(entry);
64
+ if (entries.has(entry.id))
65
+ throw new Error(`duplicate shop entry id: ${entry.id}`);
66
+ entries.set(entry.id, cloneEntry(entry));
67
+ }
68
+ for (const entry of config.entries ?? [])
69
+ ingest(entry);
70
+ return {
71
+ buy(id, wallet) {
72
+ const entry = entries.get(id);
73
+ if (entry === undefined)
74
+ return { ok: false, reason: "unknown-item" };
75
+ if (entry.qty !== null && entry.qty <= 0)
76
+ return { ok: false, reason: "out-of-stock" };
77
+ let nextWallet = wallet;
78
+ if (entry.price.amount > 0) {
79
+ const result = charge(wallet, entry.price.currency, entry.price.amount);
80
+ if (result.status === "rejected")
81
+ return { ok: false, reason: "insufficient-funds" };
82
+ nextWallet = result.state;
83
+ }
84
+ if (entry.qty !== null)
85
+ entry.qty -= 1;
86
+ notify();
87
+ return { ok: true, wallet: nextWallet, entry: cloneEntry(entry) };
88
+ },
89
+ sell(id, wallet) {
90
+ const entry = entries.get(id);
91
+ if (entry === undefined)
92
+ return { ok: false, reason: "unknown-item" };
93
+ if (entry.sellPrice === undefined)
94
+ return { ok: false, reason: "not-sellable" };
95
+ const nextWallet = entry.sellPrice.amount > 0 ? grant(wallet, entry.sellPrice.currency, entry.sellPrice.amount) : wallet;
96
+ if (entry.qty !== null)
97
+ entry.qty += 1;
98
+ notify();
99
+ return { ok: true, wallet: nextWallet, entry: cloneEntry(entry) };
100
+ },
101
+ canAfford(id, wallet) {
102
+ const entry = entries.get(id);
103
+ if (entry === undefined)
104
+ return false;
105
+ if (entry.qty !== null && entry.qty <= 0)
106
+ return false;
107
+ if (entry.price.amount <= 0)
108
+ return true;
109
+ return canAfford(wallet, { [entry.price.currency]: entry.price.amount });
110
+ },
111
+ restock(id, n) {
112
+ if (!Number.isInteger(n) || n < 0)
113
+ throw new RangeError(`restock count must be a non-negative integer, got ${n}`);
114
+ const entry = entries.get(id);
115
+ if (entry === undefined || entry.qty === null || n === 0)
116
+ return;
117
+ entry.qty += n;
118
+ notify();
119
+ },
120
+ setPrice(id, price) {
121
+ const entry = entries.get(id);
122
+ if (entry === undefined)
123
+ return;
124
+ assertPrice(price);
125
+ entry.price = { currency: price.currency, amount: price.amount };
126
+ notify();
127
+ },
128
+ add(entry) {
129
+ ingest(entry);
130
+ notify();
131
+ },
132
+ remove(id) {
133
+ if (entries.delete(id))
134
+ notify();
135
+ },
136
+ list() {
137
+ return Array.from(entries.values(), cloneEntry);
138
+ },
139
+ get(id) {
140
+ const entry = entries.get(id);
141
+ return entry === undefined ? null : cloneEntry(entry);
142
+ },
143
+ subscribe(listener) {
144
+ listeners.add(listener);
145
+ return () => listeners.delete(listener);
146
+ },
147
+ snapshot() {
148
+ return { entries: Array.from(entries.values(), cloneEntry) };
149
+ },
150
+ restore(snapshot) {
151
+ entries.clear();
152
+ for (const entry of snapshot.entries)
153
+ ingest(entry);
154
+ notify();
155
+ },
156
+ };
157
+ }
@@ -1,6 +1,11 @@
1
1
  export interface WalletState {
2
2
  balances: Readonly<Record<string, number>>;
3
3
  }
4
+ /**
5
+ * Outcome of a {@link charge}/{@link chargeAll} attempt: `status: "ok"` carries the debited
6
+ * {@link WalletState}, while `status: "rejected"` leaves the wallet untouched and reports why
7
+ * (currently only `"insufficient-funds"`). Discriminate on `status` before reading `state`.
8
+ */
4
9
  export type ChargeResult = {
5
10
  status: "ok";
6
11
  state: WalletState;
@@ -36,5 +41,6 @@ export declare function grant(state: WalletState, currency: string, amount: numb
36
41
  export declare function charge(state: WalletState, currency: string, amount: number, options?: ChargeOptions): ChargeResult;
37
42
  /** True once `balance(state, currency)` has gone negative under an overdraft-enabled charge. */
38
43
  export declare function isOverdrawn(state: WalletState, currency: string): boolean;
44
+ /** True when every currency in `costs` has at least that much balance (a pure, non-mutating check). */
39
45
  export declare function canAfford(state: WalletState, costs: Readonly<Record<string, number>>): boolean;
40
46
  export declare function chargeAll(state: WalletState, costs: Readonly<Record<string, number>>, options?: ChargeOptions): ChargeResult;
@@ -58,6 +58,7 @@ export function charge(state, currency, amount, options) {
58
58
  export function isOverdrawn(state, currency) {
59
59
  return balance(state, currency) < 0;
60
60
  }
61
+ /** True when every currency in `costs` has at least that much balance (a pure, non-mutating check). */
61
62
  export function canAfford(state, costs) {
62
63
  return Object.entries(costs).every(([currency, amount]) => balance(state, currency) >= amount);
63
64
  }
@@ -0,0 +1,80 @@
1
+ /** Definition of one achievement. Content-agnostic — the game supplies names, targets, and icons. */
2
+ export interface AchievementDef {
3
+ id: string;
4
+ name: string;
5
+ description?: string;
6
+ /**
7
+ * Counter goal: the achievement unlocks when accumulated progress reaches it.
8
+ * Omit for a boolean achievement unlocked directly with `unlock(id)`.
9
+ */
10
+ target?: number;
11
+ /** Hide the name/description in UI until unlocked (a spoiler-guarded achievement). */
12
+ secret?: boolean;
13
+ /** Optional score weight (gamerscore-style), summed by {@link AchievementTracker.score}. */
14
+ points?: number;
15
+ category?: string;
16
+ /** Optional icon id / glyph the game renders; the engine never picks art. */
17
+ icon?: string;
18
+ }
19
+ /** A definition plus its live unlock/progress state — what UI renders. */
20
+ export interface AchievementView extends AchievementDef {
21
+ unlocked: boolean;
22
+ /** Unlock time from the injected clock, or null while locked. */
23
+ unlockedAt: number | null;
24
+ /** Current counter value (0 for untouched or boolean achievements). */
25
+ progress: number;
26
+ /** `progress / target` in `[0, 1]`; 1 when unlocked or when there is no target. */
27
+ fraction: number;
28
+ }
29
+ /** Emitted the instant an achievement unlocks — wire to a toast, feed, or sound. */
30
+ export interface AchievementUnlock {
31
+ id: string;
32
+ def: AchievementDef;
33
+ at: number;
34
+ }
35
+ /** Whole serializable state of a tracker — drop into a save blob. */
36
+ export interface AchievementSnapshot {
37
+ /** Achievement id → unlock timestamp. */
38
+ unlocked: Record<string, number>;
39
+ /** Achievement id → counter progress. */
40
+ counters: Record<string, number>;
41
+ }
42
+ /** Live achievement state over a fixed definition set — drives progress, unlocks, score, and a UI view list. */
43
+ export interface AchievementTracker {
44
+ /** Add `amount` (default 1) to a counter achievement; unlocks on reaching `target`. Returns the unlock if it just crossed, else null. */
45
+ progress(id: string, amount?: number): AchievementUnlock | null;
46
+ /** Set a counter achievement's absolute value; unlocks on reaching `target`. Returns the unlock if it just crossed, else null. */
47
+ setProgress(id: string, value: number): AchievementUnlock | null;
48
+ /** Unlock directly (boolean or counter). Returns the unlock, or null if unknown or already unlocked. */
49
+ unlock(id: string): AchievementUnlock | null;
50
+ isUnlocked(id: string): boolean;
51
+ get(id: string): AchievementView | null;
52
+ /** All achievements with live state, in definition order. Stable identity between changes (safe for `useSyncExternalStore`). */
53
+ list(): readonly AchievementView[];
54
+ unlocked(): readonly AchievementView[];
55
+ /** Total `points` across unlocked achievements. */
56
+ score(): number;
57
+ /** Fraction of achievements unlocked, `[0, 1]`. */
58
+ completion(): number;
59
+ subscribe(listener: () => void): () => void;
60
+ snapshot(): AchievementSnapshot;
61
+ restore(snapshot: AchievementSnapshot): void;
62
+ }
63
+ /** Options for {@link createAchievementTracker}. */
64
+ export interface AchievementTrackerOptions {
65
+ defs: readonly AchievementDef[];
66
+ /** Injected clock (ms). Default `Date.now`. */
67
+ now?: () => number;
68
+ /** Fires whenever an achievement unlocks — the seam for toasts, kill-feed lines, sounds, or telemetry. */
69
+ onUnlock?: (unlock: AchievementUnlock) => void;
70
+ }
71
+ /**
72
+ * Tracks achievement/trophy unlocks over caller-driven events — counter goals
73
+ * (`progress`/`setProgress`) and boolean flags (`unlock`) — with score,
74
+ * completion, an `onUnlock` seam, and serializable `snapshot`/`restore`. State
75
+ * is plain data; the view list keeps a stable identity between changes so React
76
+ * can read it through `useSyncExternalStore` without re-projecting every frame.
77
+ *
78
+ * @capability achievements track achievement/trophy unlocks over counter goals and boolean flags, with score, completion, an onUnlock seam, and serializable snapshot/restore
79
+ */
80
+ export declare function createAchievementTracker(options: AchievementTrackerOptions): AchievementTracker;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Tracks achievement/trophy unlocks over caller-driven events — counter goals
3
+ * (`progress`/`setProgress`) and boolean flags (`unlock`) — with score,
4
+ * completion, an `onUnlock` seam, and serializable `snapshot`/`restore`. State
5
+ * is plain data; the view list keeps a stable identity between changes so React
6
+ * can read it through `useSyncExternalStore` without re-projecting every frame.
7
+ *
8
+ * @capability achievements track achievement/trophy unlocks over counter goals and boolean flags, with score, completion, an onUnlock seam, and serializable snapshot/restore
9
+ */
10
+ export function createAchievementTracker(options) {
11
+ const now = options.now ?? Date.now;
12
+ const order = [];
13
+ const defs = new Map();
14
+ for (const def of options.defs) {
15
+ if (defs.has(def.id))
16
+ continue;
17
+ defs.set(def.id, def);
18
+ order.push(def.id);
19
+ }
20
+ const unlockedAt = new Map();
21
+ const counters = new Map();
22
+ const listeners = new Set();
23
+ let cache = null;
24
+ function notify() {
25
+ cache = null;
26
+ for (const listener of listeners)
27
+ listener();
28
+ }
29
+ function viewOf(id) {
30
+ const def = defs.get(id);
31
+ const at = unlockedAt.get(id) ?? null;
32
+ const progress = counters.get(id) ?? 0;
33
+ const fraction = at !== null ? 1 : def.target !== undefined && def.target > 0 ? Math.min(1, progress / def.target) : 0;
34
+ return { ...def, unlocked: at !== null, unlockedAt: at, progress, fraction };
35
+ }
36
+ function doUnlock(id) {
37
+ const at = now();
38
+ unlockedAt.set(id, at);
39
+ const def = defs.get(id);
40
+ if (def.target !== undefined)
41
+ counters.set(id, def.target);
42
+ const unlock = { id, def, at };
43
+ options.onUnlock?.(unlock);
44
+ return unlock;
45
+ }
46
+ function applyCounter(id, value) {
47
+ const def = defs.get(id);
48
+ if (def === undefined || def.target === undefined)
49
+ return null;
50
+ if (unlockedAt.has(id))
51
+ return null;
52
+ const clamped = Math.max(0, Math.min(def.target, value));
53
+ counters.set(id, clamped);
54
+ if (clamped >= def.target) {
55
+ const unlock = doUnlock(id);
56
+ notify();
57
+ return unlock;
58
+ }
59
+ notify();
60
+ return null;
61
+ }
62
+ const tracker = {
63
+ progress(id, amount = 1) {
64
+ return applyCounter(id, (counters.get(id) ?? 0) + amount);
65
+ },
66
+ setProgress(id, value) {
67
+ return applyCounter(id, value);
68
+ },
69
+ unlock(id) {
70
+ if (!defs.has(id) || unlockedAt.has(id))
71
+ return null;
72
+ const unlock = doUnlock(id);
73
+ notify();
74
+ return unlock;
75
+ },
76
+ isUnlocked(id) {
77
+ return unlockedAt.has(id);
78
+ },
79
+ get(id) {
80
+ return defs.has(id) ? viewOf(id) : null;
81
+ },
82
+ list() {
83
+ if (cache === null)
84
+ cache = order.map(viewOf);
85
+ return cache;
86
+ },
87
+ unlocked() {
88
+ return tracker.list().filter((view) => view.unlocked);
89
+ },
90
+ score() {
91
+ let total = 0;
92
+ for (const id of unlockedAt.keys())
93
+ total += defs.get(id)?.points ?? 0;
94
+ return total;
95
+ },
96
+ completion() {
97
+ return order.length === 0 ? 0 : unlockedAt.size / order.length;
98
+ },
99
+ subscribe(listener) {
100
+ listeners.add(listener);
101
+ return () => listeners.delete(listener);
102
+ },
103
+ snapshot() {
104
+ return {
105
+ unlocked: Object.fromEntries(unlockedAt),
106
+ counters: Object.fromEntries(counters),
107
+ };
108
+ },
109
+ restore(snapshot) {
110
+ unlockedAt.clear();
111
+ counters.clear();
112
+ for (const [id, at] of Object.entries(snapshot.counters)) {
113
+ if (defs.has(id))
114
+ counters.set(id, at);
115
+ }
116
+ for (const [id, at] of Object.entries(snapshot.unlocked)) {
117
+ if (defs.has(id))
118
+ unlockedAt.set(id, at);
119
+ }
120
+ notify();
121
+ },
122
+ };
123
+ return tracker;
124
+ }
@@ -198,6 +198,13 @@ export interface ChaseCameraConfig {
198
198
  minSpeed?: number;
199
199
  response?: number;
200
200
  };
201
+ /**
202
+ * Exponential response (`1-exp(-response*dt)`) the chase yaw follows the target's facing with
203
+ * (#1370). The default (5) arcs the boom smoothly behind a body whose facing flips from a strafe
204
+ * or direction change instead of teleporting it to the far side; `Infinity` restores the legacy
205
+ * rigid follow (yaw exactly equals body facing every frame).
206
+ */
207
+ yawResponse?: number;
201
208
  /** Which view to mount. Default "chase". */
202
209
  view?: ChaseView;
203
210
  /** Local offset for cockpit/hood/rear seats (relative to the vehicle, +z forward). */
@@ -0,0 +1,60 @@
1
+ /** A codex/bestiary/lorebook entry the game defines. Discovery state is tracked separately. */
2
+ export interface CodexEntryDef<TMeta = unknown> {
3
+ id: string;
4
+ name: string;
5
+ category?: string;
6
+ description?: string;
7
+ /** Longer lore for a detail pane. */
8
+ lore?: string;
9
+ /** Icon/image id or glyph the game renders; the engine never picks art. */
10
+ icon?: string;
11
+ /** Hide name/description/lore until discovered (a spoiler-guarded entry). */
12
+ secret?: boolean;
13
+ meta?: TMeta;
14
+ }
15
+ /** A definition plus its discovery state — what UI renders. */
16
+ export interface CodexEntryView<TMeta = unknown> extends CodexEntryDef<TMeta> {
17
+ discovered: boolean;
18
+ /** Discovery time from the injected clock, or null. */
19
+ discoveredAt: number | null;
20
+ }
21
+ /** Serializable discovery state — a save blob. */
22
+ export interface CodexSnapshot {
23
+ /** Entry id → discovery timestamp. */
24
+ discovered: Record<string, number>;
25
+ }
26
+ /** A codex/bestiary of defined entries with per-player discovery tracking. */
27
+ export interface Codex<TMeta = unknown> {
28
+ /** Mark an entry discovered; returns the newly-discovered view, or null if unknown or already discovered. */
29
+ discover(id: string): CodexEntryView<TMeta> | null;
30
+ isDiscovered(id: string): boolean;
31
+ get(id: string): CodexEntryView<TMeta> | null;
32
+ /** All entries (optionally filtered to a category) with discovery state, in definition order. Stable identity between changes. */
33
+ list(category?: string): readonly CodexEntryView<TMeta>[];
34
+ categories(): readonly string[];
35
+ discoveredCount(): number;
36
+ total(): number;
37
+ /** Fraction discovered, `[0, 1]`. */
38
+ completion(): number;
39
+ subscribe(listener: () => void): () => void;
40
+ snapshot(): CodexSnapshot;
41
+ restore(snapshot: CodexSnapshot): void;
42
+ }
43
+ /** Options for {@link createCodex}. */
44
+ export interface CodexOptions<TMeta = unknown> {
45
+ entries: readonly CodexEntryDef<TMeta>[];
46
+ /** Injected clock (ms). Default `Date.now`. */
47
+ now?: () => number;
48
+ /** Fires when an entry is discovered for the first time — wire to a toast/notification. */
49
+ onDiscover?: (entry: CodexEntryView<TMeta>) => void;
50
+ }
51
+ /**
52
+ * A codex / bestiary / lorebook: a fixed set of defined entries plus per-player
53
+ * discovery tracking, with categories, secret masking, completion, an
54
+ * `onDiscover` seam, and serializable `snapshot`/`restore`. The view `list()`
55
+ * keeps a stable identity between changes so React reads it through
56
+ * `useSyncExternalStore` without re-projecting each frame.
57
+ *
58
+ * @capability codex codex/bestiary/lorebook of defined entries with discovery tracking, categories, secret masking, completion, an onDiscover seam, and serializable state
59
+ */
60
+ export declare function createCodex<TMeta = unknown>(options: CodexOptions<TMeta>): Codex<TMeta>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * A codex / bestiary / lorebook: a fixed set of defined entries plus per-player
3
+ * discovery tracking, with categories, secret masking, completion, an
4
+ * `onDiscover` seam, and serializable `snapshot`/`restore`. The view `list()`
5
+ * keeps a stable identity between changes so React reads it through
6
+ * `useSyncExternalStore` without re-projecting each frame.
7
+ *
8
+ * @capability codex codex/bestiary/lorebook of defined entries with discovery tracking, categories, secret masking, completion, an onDiscover seam, and serializable state
9
+ */
10
+ export function createCodex(options) {
11
+ const now = options.now ?? Date.now;
12
+ const order = [];
13
+ const defs = new Map();
14
+ for (const def of options.entries) {
15
+ if (defs.has(def.id))
16
+ continue;
17
+ defs.set(def.id, def);
18
+ order.push(def.id);
19
+ }
20
+ const discoveredAt = new Map();
21
+ const listeners = new Set();
22
+ let cache = null;
23
+ function notify() {
24
+ cache = null;
25
+ for (const listener of listeners)
26
+ listener();
27
+ }
28
+ function viewOf(id) {
29
+ const def = defs.get(id);
30
+ const at = discoveredAt.get(id) ?? null;
31
+ return { ...def, discovered: at !== null, discoveredAt: at };
32
+ }
33
+ const codex = {
34
+ discover(id) {
35
+ if (!defs.has(id) || discoveredAt.has(id))
36
+ return null;
37
+ discoveredAt.set(id, now());
38
+ const view = viewOf(id);
39
+ options.onDiscover?.(view);
40
+ notify();
41
+ return view;
42
+ },
43
+ isDiscovered(id) {
44
+ return discoveredAt.has(id);
45
+ },
46
+ get(id) {
47
+ return defs.has(id) ? viewOf(id) : null;
48
+ },
49
+ list(category) {
50
+ if (cache === null)
51
+ cache = order.map(viewOf);
52
+ return category === undefined ? cache : cache.filter((view) => view.category === category);
53
+ },
54
+ categories() {
55
+ const seen = [];
56
+ for (const id of order) {
57
+ const category = defs.get(id).category;
58
+ if (category !== undefined && !seen.includes(category))
59
+ seen.push(category);
60
+ }
61
+ return seen;
62
+ },
63
+ discoveredCount() {
64
+ return discoveredAt.size;
65
+ },
66
+ total() {
67
+ return order.length;
68
+ },
69
+ completion() {
70
+ return order.length === 0 ? 0 : discoveredAt.size / order.length;
71
+ },
72
+ subscribe(listener) {
73
+ listeners.add(listener);
74
+ return () => listeners.delete(listener);
75
+ },
76
+ snapshot() {
77
+ return { discovered: Object.fromEntries(discoveredAt) };
78
+ },
79
+ restore(snapshot) {
80
+ discoveredAt.clear();
81
+ for (const [id, at] of Object.entries(snapshot.discovered)) {
82
+ if (defs.has(id))
83
+ discoveredAt.set(id, at);
84
+ }
85
+ notify();
86
+ },
87
+ };
88
+ return codex;
89
+ }
@@ -0,0 +1,36 @@
1
+ /** One credited line — a person, a studio, an asset pack, a tool. */
2
+ export interface CreditsEntry {
3
+ /** Who or what is credited. */
4
+ label: string;
5
+ /** Free-string qualifier shown beside the label — a role, a license, a pack title. */
6
+ detail?: string;
7
+ /** Where the work came from. Never fetched; the view decides whether to link it. */
8
+ href?: string;
9
+ }
10
+ /** A titled group of entries — "Design", "Music", "3D Assets". */
11
+ export interface CreditsSection {
12
+ heading: string;
13
+ entries: readonly CreditsEntry[];
14
+ }
15
+ /** Everything a credits screen shows. `notice` is the closing line (a license summary, a thank-you). */
16
+ export interface CreditsDocument {
17
+ title?: string;
18
+ sections: readonly CreditsSection[];
19
+ notice?: string;
20
+ }
21
+ /**
22
+ * Merges credit documents into one, concatenating same-heading sections in first-seen order and
23
+ * dropping exact duplicate entries. Use it to fold generated credits (asset packs, dependencies)
24
+ * into the game's hand-authored ones without curating the overlap by hand.
25
+ *
26
+ * @capability merge-credits fold generated and hand-authored credit documents into one, deduped by entry
27
+ */
28
+ export declare function mergeCredits(...documents: readonly (CreditsDocument | null | undefined)[]): CreditsDocument;
29
+ /**
30
+ * Groups entries into sections by a caller-supplied key — the shape most generated credits arrive
31
+ * in (a flat list of things used, grouped by license, provider, or role). Section order follows
32
+ * first appearance, so a stable input gives a stable screen.
33
+ *
34
+ * @capability group-credits turn a flat list of credited things into ordered credit sections
35
+ */
36
+ export declare function groupCredits<T>(items: readonly T[], heading: (item: T) => string, entry: (item: T) => CreditsEntry): CreditsSection[];
Binary file
@@ -15,7 +15,7 @@ import type { TimeConfig } from "../time/simClock.js";
15
15
  import type { WorldFeature } from "../world/features.js";
16
16
  /** Tunes offline whole-world save (`defineGameDefinition({ persist })`). Offline games already autosave to `localStorage` by default — pass this only to tune it (or `persist: false` to opt out). Defaults: continuous `autosave` to `localStorage`, one slot, no version. */
17
17
  export interface PersistConfig {
18
- /** `"autosave"` (default) writes on a debounce after any change; `"manual"` writes only on `ctx.game.save.checkpoint()` (save points / quest triggers). */
18
+ /** `"autosave"` (default) writes on a trailing timer while the world changes — at most once per `autosaveMs`, so a never-idle world (AI, physics, day/night) still persists; `"manual"` writes only on `ctx.game.save.checkpoint()` (save points / quest triggers). */
19
19
  mode?: RuntimeSaveMode;
20
20
  /** `"local"` (default) persists to `localStorage`; `"memory"` keeps saves in-session only (tests, "no persistence" mode). */
21
21
  storage?: "local" | "memory";
@@ -140,6 +140,20 @@ export interface LifecycleConfig<TState = unknown> {
140
140
  restart?: string;
141
141
  };
142
142
  }
143
+ /**
144
+ * How a game declares its run-phase story to {@link defineGameDefinition}. Every game states this
145
+ * explicitly so the shell never has to guess — a silent game used to default to `"playing"`, which
146
+ * painted the touch dock over title/menu/results screens (#1337, Vice Isle #1329).
147
+ *
148
+ * - A {@link LifecycleConfig} — the game moves through menu/playing/paused/ended run states; the
149
+ * runtime owns the command glue and phase sync.
150
+ * - `"always-live"` — an explicit, truthful declaration that the game has **no** menu/pause/end
151
+ * screens and runs live from boot. Runtime behaviour is identical to declaring no lifecycle at all
152
+ * (no phase sync, no start/restart commands), but the intent is now *stated*, not implied by
153
+ * silence. Games that instead drive phases by hand call {@link setGamePhase} directly and leave
154
+ * this unset.
155
+ */
156
+ export type GameLifecycle<TState = unknown> = LifecycleConfig<TState> | "always-live";
143
157
  /** Fully-resolved game description produced by {@link defineGameDefinition} — assets, scene, and opted-in subsystems. */
144
158
  export interface GameDefinition<TAssetRef extends ModelAssetRef = ModelAssetRef, TMultiplayer = unknown> {
145
159
  name: string;
@@ -177,7 +191,12 @@ export interface GameDefinition<TAssetRef extends ModelAssetRef = ModelAssetRef,
177
191
  persist?: boolean | PersistConfig;
178
192
  ui?: unknown;
179
193
  loop?: GameLoop<GameContext>;
180
- /** Declarative start/restart run lifecycle — see {@link LifecycleConfig}. Omitted games keep hand-rolling their own commands. */
194
+ /**
195
+ * Resolved run lifecycle — see {@link LifecycleConfig}. The config boundary also accepts the
196
+ * `"always-live"` sentinel ({@link GameLifecycle}); {@link defineGameDefinition} resolves that to
197
+ * `undefined` here, so a resolved definition only ever carries a real {@link LifecycleConfig} or
198
+ * nothing.
199
+ */
181
200
  lifecycle?: LifecycleConfig;
182
201
  /**
183
202
  * Host-side per-viewer replication policy — private-state and area-of-interest projection over the
@@ -188,8 +207,14 @@ export interface GameDefinition<TAssetRef extends ModelAssetRef = ModelAssetRef,
188
207
  replication?: ReplicationPolicy;
189
208
  }
190
209
  /** Input to {@link defineGameDefinition} — a `GameDefinition` with `scene` derived and `assets` optional. */
191
- export type GameDefinitionConfig<TAssetRef extends ModelAssetRef = ModelAssetRef, TMultiplayer = unknown> = Omit<GameDefinition<TAssetRef, TMultiplayer>, "scene" | "assets"> & {
210
+ export type GameDefinitionConfig<TAssetRef extends ModelAssetRef = ModelAssetRef, TMultiplayer = unknown> = Omit<GameDefinition<TAssetRef, TMultiplayer>, "scene" | "assets" | "lifecycle"> & {
192
211
  assets?: AssetCatalog<TAssetRef>;
212
+ /**
213
+ * The game's run-phase story — a {@link LifecycleConfig} or the `"always-live"` sentinel. Every
214
+ * game must state one (or drive phases by hand via {@link setGamePhase}); `check-game-shape`
215
+ * enforces it. See {@link GameLifecycle}.
216
+ */
217
+ lifecycle?: GameLifecycle;
193
218
  };
194
219
  /**
195
220
  * Host-level constructor for a bare `GameDefinition`: fills in `scene` and default `assets`, validates
@@ -43,11 +43,16 @@ export function defineGameDefinition(config) {
43
43
  // A place world carries the laws of that place: its physics resolves over the game-level default,
44
44
  // so every consumer of definition.physics (movement, combat, shell) sees the active world's laws.
45
45
  const physics = resolveWorldPhysics(config.world, config.physics);
46
+ // `"always-live"` is an explicit "no lifecycle sync" declaration — resolve the sentinel to
47
+ // `undefined` so every downstream consumer only ever sees a real LifecycleConfig or nothing,
48
+ // exactly as before the sentinel existed. The statement was for the gate, not the runtime.
49
+ const lifecycle = config.lifecycle === "always-live" ? undefined : config.lifecycle;
46
50
  return {
47
51
  ...config,
48
52
  ...(physics === undefined ? {} : { physics }),
49
53
  features,
50
54
  loop,
55
+ lifecycle,
51
56
  scene: createEntityStore(),
52
57
  assets: config.assets ?? createAssetCatalog(),
53
58
  };