@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,154 @@
1
+ /**
2
+ * Difficulty is decision *quality*, owned in one place: a serializable {@link DifficultyProfile}
3
+ * describing how fast, how accurately, and how far ahead an agent decides, plus appliers at the
4
+ * universal decision seams every opponent shares — reacting to new information, choosing among
5
+ * scored options, executing with error, spending abilities, and planning ahead. The same profile
6
+ * tunes an RTS grunt, a street enemy, a boss's special-ability discipline, or a turn-based
7
+ * lookahead player; the game supplies the domain (candidates, scores, moves) and this module
8
+ * degrades or sharpens the choice. Three canonical tiers ({@link DIFFICULTY_TIERS}) cover
9
+ * "kinda dumb / smart / super smart"; per-game overrides stay data.
10
+ *
11
+ * Everything is deterministic under an injected rng, work is bounded by the profile, and state
12
+ * (the reaction gate) is one small serializable object. Compose with the existing substrates
13
+ * rather than replacing them: scale `MobBrainConfig.aggroRadius` by {@link DifficultyProfile.perceptionScale},
14
+ * run `acquireTarget` candidates through {@link pickScored}, gate `advancePursuit` target swaps
15
+ * behind {@link advanceReactionGate}, and score ability windows into {@link shouldUseAbility}.
16
+ */
17
+ /** The three canonical decision-quality tiers. */
18
+ export type DifficultyTier = "easy" | "standard" | "expert";
19
+ /**
20
+ * Serializable decision-quality data. All fields are plain numbers so a profile can live in a
21
+ * save, an encounter definition, or a per-entity spawn record.
22
+ */
23
+ export interface DifficultyProfile {
24
+ /** Seconds before newly observed information takes effect (see {@link advanceReactionGate}). */
25
+ reactionSeconds: number;
26
+ /**
27
+ * `0..1` chance of a degraded pick in {@link pickScored}, and the amplitude of perceived-value
28
+ * noise in {@link shouldUseAbility} and {@link planLookahead}. `0` is optimal play.
29
+ */
30
+ decisionNoise: number;
31
+ /** `0..1` amplitude of execution error returned by {@link executionError} (aim, lead, timing). */
32
+ executionJitter: number;
33
+ /**
34
+ * `0..1` opportunity threshold for spending abilities: low values fire specials on weak
35
+ * moments, high values hold them for strong windows (see {@link shouldUseAbility}).
36
+ */
37
+ abilityDiscipline: number;
38
+ /** Multiplier games apply to sight/aggro/awareness radii (e.g. `aggroRadius * perceptionScale`). */
39
+ perceptionScale: number;
40
+ /** Lookahead plies for {@link planLookahead}; `1` is greedy. */
41
+ planDepth: number;
42
+ /** Candidate moves considered per ply in {@link planLookahead} (branching bound). */
43
+ planWidth: number;
44
+ }
45
+ /**
46
+ * The canonical easy/standard/expert profiles. Frozen — use {@link difficultyProfile} to derive
47
+ * a tweaked copy instead of mutating.
48
+ *
49
+ * @capability difficulty-tiers canonical easy/standard/expert decision-quality profiles for any opponent
50
+ */
51
+ export declare const DIFFICULTY_TIERS: Readonly<Record<DifficultyTier, DifficultyProfile>>;
52
+ /**
53
+ * A fresh profile from a canonical tier with optional per-game overrides — e.g. an "easy" boss
54
+ * that still reacts fast: `difficultyProfile("easy", { reactionSeconds: 0.2 })`.
55
+ *
56
+ * @capability difficulty-profile derive a per-game decision-quality profile from a canonical tier
57
+ */
58
+ export declare function difficultyProfile(tier: DifficultyTier, overrides?: Partial<DifficultyProfile>): DifficultyProfile;
59
+ /**
60
+ * Serializable reaction-gate state: the value the agent is currently acting on plus the newer
61
+ * value it has noticed but not yet committed to. `T` should be serializable (ids, positions as
62
+ * tuples) if the gate lives in saved state.
63
+ */
64
+ export interface ReactionGateState<T> {
65
+ /** The value currently in effect — what the agent acts on this step. */
66
+ committed: T;
67
+ /** The newer observed value waiting out the reaction delay, or the committed value when idle. */
68
+ pending: T;
69
+ /** Seconds the pending value has been observed. */
70
+ pendingSeconds: number;
71
+ }
72
+ /**
73
+ * A fresh gate already committed to `initial`.
74
+ *
75
+ * @capability difficulty-reaction-gate-state fresh serializable reaction-gate state for one observed value
76
+ */
77
+ export declare function createReactionGate<T>(initial: T): ReactionGateState<T>;
78
+ /**
79
+ * Advance one reaction step and return the value to act on. `desired` is what a perfect agent
80
+ * would act on this frame (current best target, latest known player position); the gate holds the
81
+ * previously committed value until `desired` has been stable for `profile.reactionSeconds`,
82
+ * restarting the delay whenever `desired` changes again (compared with `Object.is`). Feed the
83
+ * returned value — not `desired` — into pursuit/aim/ability logic. Mutates `state` in place;
84
+ * allocation-free.
85
+ *
86
+ * @capability difficulty-reaction-gate delay acting on new information by the profile's reaction time
87
+ */
88
+ export declare function advanceReactionGate<T>(state: ReactionGateState<T>, dt: number, desired: T, profile: DifficultyProfile): T;
89
+ /** One candidate with a caller-computed score; higher is better. */
90
+ export interface ScoredOption<T> {
91
+ option: T;
92
+ score: number;
93
+ }
94
+ /**
95
+ * Choose from a scored option list at the profile's quality: with probability
96
+ * `profile.decisionNoise` the pick is uniform over all options (the "wrong target / bad move"
97
+ * mistake), otherwise it is the best score (first wins ties). Works over any decision the game
98
+ * can score — acquisition candidates, ability choices, retreat routes, chess moves. Returns
99
+ * `null` for an empty list. Allocation-free scan; one rng draw plus one more on a noisy pick.
100
+ *
101
+ * @capability difficulty-pick noisy argmax over scored options; expert picks best, easy blunders
102
+ */
103
+ export declare function pickScored<T>(options: readonly ScoredOption<T>[], profile: DifficultyProfile, rng: () => number): T | null;
104
+ /**
105
+ * Symmetric execution error in `[-scale * executionJitter, +scale * executionJitter]` — add it to
106
+ * a yaw, a lead time, a throw power, or a release timestamp. `scale` maps the unitless jitter
107
+ * onto the caller's units (e.g. pass max miss radians).
108
+ *
109
+ * @capability difficulty-execution-error symmetric aim/lead/timing error scaled by the profile
110
+ */
111
+ export declare function executionError(profile: DifficultyProfile, rng: () => number, scale?: number): number;
112
+ /**
113
+ * Should the agent spend an ability now? `opportunity` is the caller-scored quality of this
114
+ * moment in `0..1` (targets clumped for the AoE, player mid-animation, burn phase). The perceived
115
+ * opportunity is fuzzed by `decisionNoise` and compared against `abilityDiscipline`: an easy
116
+ * profile (low discipline, high noise) wastes cooldowns on weak moments and sometimes sits on a
117
+ * perfect window; an expert profile fires exactly on strong windows. One rng draw when noisy.
118
+ *
119
+ * @capability difficulty-ability-gate spend-or-hold ability decision from a scored opportunity window
120
+ */
121
+ export declare function shouldUseAbility(opportunity: number, profile: DifficultyProfile, rng: () => number): boolean;
122
+ /**
123
+ * The game-owned decision domain {@link planLookahead} searches. States and moves are caller
124
+ * types; the planner never inspects them.
125
+ */
126
+ export interface LookaheadDomain<S, M> {
127
+ /**
128
+ * Candidate moves from `state` with immediate scores (higher better for the player to move).
129
+ * The planner explores only the best `planWidth` of these per ply.
130
+ */
131
+ moves(state: S): readonly ScoredOption<M>[];
132
+ /** The successor state after the player to move plays `move`. Must not mutate `state`. */
133
+ apply(state: S, move: M): S;
134
+ /** Static evaluation of `state` from the perspective of the player to move in it. */
135
+ evaluate(state: S): number;
136
+ /**
137
+ * `true` (default) alternates perspective each ply — adversarial turn games (chess-like,
138
+ * duels). `false` keeps one perspective — planning the agent's own action sequence
139
+ * (rotations, build orders, multi-step routes).
140
+ */
141
+ adversarial?: boolean;
142
+ }
143
+ /**
144
+ * Pick the agent's next move by bounded lookahead: a depth-limited negamax (or single-perspective
145
+ * sequence search when `adversarial: false`) over the caller's {@link LookaheadDomain}, exploring
146
+ * at most `profile.planWidth` moves per ply to `profile.planDepth` plies, with the final root
147
+ * choice run through {@link pickScored} so `decisionNoise` still injects tier-appropriate
148
+ * blunders. Depth `1` is greedy — an easy profile literally cannot see the fork coming; an
149
+ * expert profile finds multi-ply tactics. Work is bounded by `planWidth ** planDepth` domain
150
+ * calls; run it at decision cadence (turns, ability windows, order re-evaluation), not per frame.
151
+ *
152
+ * @capability difficulty-lookahead depth/width-bounded deterministic planner for turn-based or sequential decisions
153
+ */
154
+ export declare function planLookahead<S, M>(root: S, domain: LookaheadDomain<S, M>, profile: DifficultyProfile, rng: () => number): M | null;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Difficulty is decision *quality*, owned in one place: a serializable {@link DifficultyProfile}
3
+ * describing how fast, how accurately, and how far ahead an agent decides, plus appliers at the
4
+ * universal decision seams every opponent shares — reacting to new information, choosing among
5
+ * scored options, executing with error, spending abilities, and planning ahead. The same profile
6
+ * tunes an RTS grunt, a street enemy, a boss's special-ability discipline, or a turn-based
7
+ * lookahead player; the game supplies the domain (candidates, scores, moves) and this module
8
+ * degrades or sharpens the choice. Three canonical tiers ({@link DIFFICULTY_TIERS}) cover
9
+ * "kinda dumb / smart / super smart"; per-game overrides stay data.
10
+ *
11
+ * Everything is deterministic under an injected rng, work is bounded by the profile, and state
12
+ * (the reaction gate) is one small serializable object. Compose with the existing substrates
13
+ * rather than replacing them: scale `MobBrainConfig.aggroRadius` by {@link DifficultyProfile.perceptionScale},
14
+ * run `acquireTarget` candidates through {@link pickScored}, gate `advancePursuit` target swaps
15
+ * behind {@link advanceReactionGate}, and score ability windows into {@link shouldUseAbility}.
16
+ */
17
+ const TIERS = {
18
+ easy: {
19
+ reactionSeconds: 0.9,
20
+ decisionNoise: 0.35,
21
+ executionJitter: 0.3,
22
+ abilityDiscipline: 0.2,
23
+ perceptionScale: 0.7,
24
+ planDepth: 1,
25
+ planWidth: 2,
26
+ },
27
+ standard: {
28
+ reactionSeconds: 0.35,
29
+ decisionNoise: 0.12,
30
+ executionJitter: 0.12,
31
+ abilityDiscipline: 0.55,
32
+ perceptionScale: 1,
33
+ planDepth: 2,
34
+ planWidth: 4,
35
+ },
36
+ expert: {
37
+ reactionSeconds: 0.08,
38
+ decisionNoise: 0,
39
+ executionJitter: 0.02,
40
+ abilityDiscipline: 0.85,
41
+ perceptionScale: 1.3,
42
+ planDepth: 4,
43
+ planWidth: 8,
44
+ },
45
+ };
46
+ for (const tier of Object.values(TIERS))
47
+ Object.freeze(tier);
48
+ /**
49
+ * The canonical easy/standard/expert profiles. Frozen — use {@link difficultyProfile} to derive
50
+ * a tweaked copy instead of mutating.
51
+ *
52
+ * @capability difficulty-tiers canonical easy/standard/expert decision-quality profiles for any opponent
53
+ */
54
+ export const DIFFICULTY_TIERS = Object.freeze(TIERS);
55
+ /**
56
+ * A fresh profile from a canonical tier with optional per-game overrides — e.g. an "easy" boss
57
+ * that still reacts fast: `difficultyProfile("easy", { reactionSeconds: 0.2 })`.
58
+ *
59
+ * @capability difficulty-profile derive a per-game decision-quality profile from a canonical tier
60
+ */
61
+ export function difficultyProfile(tier, overrides) {
62
+ return { ...DIFFICULTY_TIERS[tier], ...overrides };
63
+ }
64
+ /**
65
+ * A fresh gate already committed to `initial`.
66
+ *
67
+ * @capability difficulty-reaction-gate-state fresh serializable reaction-gate state for one observed value
68
+ */
69
+ export function createReactionGate(initial) {
70
+ return { committed: initial, pending: initial, pendingSeconds: 0 };
71
+ }
72
+ /**
73
+ * Advance one reaction step and return the value to act on. `desired` is what a perfect agent
74
+ * would act on this frame (current best target, latest known player position); the gate holds the
75
+ * previously committed value until `desired` has been stable for `profile.reactionSeconds`,
76
+ * restarting the delay whenever `desired` changes again (compared with `Object.is`). Feed the
77
+ * returned value — not `desired` — into pursuit/aim/ability logic. Mutates `state` in place;
78
+ * allocation-free.
79
+ *
80
+ * @capability difficulty-reaction-gate delay acting on new information by the profile's reaction time
81
+ */
82
+ export function advanceReactionGate(state, dt, desired, profile) {
83
+ if (Object.is(desired, state.committed)) {
84
+ state.pending = desired;
85
+ state.pendingSeconds = 0;
86
+ return state.committed;
87
+ }
88
+ if (!Object.is(desired, state.pending)) {
89
+ state.pending = desired;
90
+ state.pendingSeconds = 0;
91
+ }
92
+ state.pendingSeconds += dt;
93
+ if (state.pendingSeconds >= profile.reactionSeconds) {
94
+ state.committed = state.pending;
95
+ state.pendingSeconds = 0;
96
+ }
97
+ return state.committed;
98
+ }
99
+ /**
100
+ * Choose from a scored option list at the profile's quality: with probability
101
+ * `profile.decisionNoise` the pick is uniform over all options (the "wrong target / bad move"
102
+ * mistake), otherwise it is the best score (first wins ties). Works over any decision the game
103
+ * can score — acquisition candidates, ability choices, retreat routes, chess moves. Returns
104
+ * `null` for an empty list. Allocation-free scan; one rng draw plus one more on a noisy pick.
105
+ *
106
+ * @capability difficulty-pick noisy argmax over scored options; expert picks best, easy blunders
107
+ */
108
+ export function pickScored(options, profile, rng) {
109
+ if (options.length === 0)
110
+ return null;
111
+ if (profile.decisionNoise > 0 && rng() < profile.decisionNoise) {
112
+ const index = Math.min(options.length - 1, Math.floor(rng() * options.length));
113
+ return options[index].option;
114
+ }
115
+ let best = options[0];
116
+ for (let i = 1; i < options.length; i++) {
117
+ const candidate = options[i];
118
+ if (candidate.score > best.score)
119
+ best = candidate;
120
+ }
121
+ return best.option;
122
+ }
123
+ /**
124
+ * Symmetric execution error in `[-scale * executionJitter, +scale * executionJitter]` — add it to
125
+ * a yaw, a lead time, a throw power, or a release timestamp. `scale` maps the unitless jitter
126
+ * onto the caller's units (e.g. pass max miss radians).
127
+ *
128
+ * @capability difficulty-execution-error symmetric aim/lead/timing error scaled by the profile
129
+ */
130
+ export function executionError(profile, rng, scale = 1) {
131
+ if (profile.executionJitter <= 0)
132
+ return 0;
133
+ return (rng() * 2 - 1) * profile.executionJitter * scale;
134
+ }
135
+ /**
136
+ * Should the agent spend an ability now? `opportunity` is the caller-scored quality of this
137
+ * moment in `0..1` (targets clumped for the AoE, player mid-animation, burn phase). The perceived
138
+ * opportunity is fuzzed by `decisionNoise` and compared against `abilityDiscipline`: an easy
139
+ * profile (low discipline, high noise) wastes cooldowns on weak moments and sometimes sits on a
140
+ * perfect window; an expert profile fires exactly on strong windows. One rng draw when noisy.
141
+ *
142
+ * @capability difficulty-ability-gate spend-or-hold ability decision from a scored opportunity window
143
+ */
144
+ export function shouldUseAbility(opportunity, profile, rng) {
145
+ const perceived = profile.decisionNoise > 0 ? opportunity + (rng() * 2 - 1) * profile.decisionNoise : opportunity;
146
+ return perceived >= profile.abilityDiscipline;
147
+ }
148
+ function rankedFrontier(domain, state, width) {
149
+ const moves = domain.moves(state);
150
+ if (moves.length <= width)
151
+ return moves;
152
+ return [...moves].sort((a, b) => b.score - a.score).slice(0, width);
153
+ }
154
+ function lookaheadValue(domain, state, depth, width) {
155
+ if (depth <= 0)
156
+ return domain.evaluate(state);
157
+ const frontier = rankedFrontier(domain, state, width);
158
+ if (frontier.length === 0)
159
+ return domain.evaluate(state);
160
+ const negate = domain.adversarial !== false;
161
+ let best = Number.NEGATIVE_INFINITY;
162
+ for (const candidate of frontier) {
163
+ const child = lookaheadValue(domain, domain.apply(state, candidate.option), depth - 1, width);
164
+ const value = negate ? -child : child;
165
+ if (value > best)
166
+ best = value;
167
+ }
168
+ return best;
169
+ }
170
+ /**
171
+ * Pick the agent's next move by bounded lookahead: a depth-limited negamax (or single-perspective
172
+ * sequence search when `adversarial: false`) over the caller's {@link LookaheadDomain}, exploring
173
+ * at most `profile.planWidth` moves per ply to `profile.planDepth` plies, with the final root
174
+ * choice run through {@link pickScored} so `decisionNoise` still injects tier-appropriate
175
+ * blunders. Depth `1` is greedy — an easy profile literally cannot see the fork coming; an
176
+ * expert profile finds multi-ply tactics. Work is bounded by `planWidth ** planDepth` domain
177
+ * calls; run it at decision cadence (turns, ability windows, order re-evaluation), not per frame.
178
+ *
179
+ * @capability difficulty-lookahead depth/width-bounded deterministic planner for turn-based or sequential decisions
180
+ */
181
+ export function planLookahead(root, domain, profile, rng) {
182
+ const depth = Math.max(1, Math.floor(profile.planDepth));
183
+ const width = Math.max(1, Math.floor(profile.planWidth));
184
+ const negate = domain.adversarial !== false;
185
+ const frontier = rankedFrontier(domain, root, width);
186
+ if (frontier.length === 0)
187
+ return null;
188
+ const valued = [];
189
+ for (const candidate of frontier) {
190
+ const child = lookaheadValue(domain, domain.apply(root, candidate.option), depth - 1, width);
191
+ valued.push({ option: candidate.option, score: negate ? -child : child });
192
+ }
193
+ return pickScored(valued, profile, rng);
194
+ }
@@ -0,0 +1,107 @@
1
+ import type { AxisInput } from "../input/axisInput.js";
2
+ import { type DifficultyProfile } from "./difficulty.js";
3
+ /**
4
+ * A difficulty-aware driver brain: pose + goal in, the shared {@link AxisInput}
5
+ * (`throttle`/`brake`/`steer`/`handbrake`) out, ready for `tickDrivableVehicle` — the chase/traffic
6
+ * steering loop every game with AI cars hand-rolls as `steer = error * k, throttle = 1`. A
7
+ * {@link DifficultyProfile} decides how *well* it drives: reaction time trails the target, corners
8
+ * shed speed instead of orbiting at full throttle, obstacles are noticed late on low tiers, the
9
+ * wheel wobbles by `executionJitter`, and a stuck car backs out with counter-steer after grinding
10
+ * for a tier-scaled while. State is one small serializable object; randomness is injected; every
11
+ * step is allocation-light scalar math. The caller owns the sim, collision, and all spatial
12
+ * queries — this module never scans the world.
13
+ */
14
+ /** The driver's own vehicle this step: world position, yaw heading, signed forward speed. */
15
+ export interface DriverPose {
16
+ x: number;
17
+ z: number;
18
+ /** Yaw with the same convention as the kinematic vehicle sim: `atan2(dx, dz)`. */
19
+ heading: number;
20
+ /** Signed forward speed in world units/second (negative while reversing). */
21
+ speed: number;
22
+ }
23
+ /** Where the driver is trying to go this step. */
24
+ export interface DriverGoal {
25
+ x: number;
26
+ z: number;
27
+ /** Brake to a halt inside `arriveRadius` instead of driving through; default `false`. */
28
+ stopAtGoal?: boolean;
29
+ /** Cap on desired speed for this goal (a speed limit, a formation speed); default unlimited. */
30
+ speedLimit?: number;
31
+ }
32
+ /** Vehicle-feel numbers the game supplies; everything has a sensible default except `maxSpeed`. */
33
+ export interface DriverTuning {
34
+ /** Top desired speed on a straight, world units/second. */
35
+ maxSpeed: number;
36
+ /** Steering-output gain per radian of heading error; default `2`. */
37
+ steerGain?: number;
38
+ /** Fraction of speed shed at a right-angle heading error (`0` never slows for corners); default `0.75`. */
39
+ cornerSlowdown?: number;
40
+ /** Distance at which the goal counts as reached; default `4`. */
41
+ arriveRadius?: number;
42
+ /** Meters of clearance the driver tries to keep to an obstacle ahead; default `6`. */
43
+ brakeMargin?: number;
44
+ /** Seconds between steering-jitter resamples, so low tiers wobble instead of noise-shake; default `0.35`. */
45
+ jitterIntervalSeconds?: number;
46
+ /** Heading-error radians one full unit of `executionJitter` adds; default `0.35`. */
47
+ jitterScaleRadians?: number;
48
+ /** Below this |speed| while throttling the car counts as stuck; default `0.8`. */
49
+ stuckSpeed?: number;
50
+ /** Seconds of grinding (plus the profile's `reactionSeconds`) before reversing out; default `1.5`. */
51
+ stuckSeconds?: number;
52
+ /** How long a reverse-out lasts; default `1.1`. */
53
+ reverseSeconds?: number;
54
+ }
55
+ /** Serializable per-driver state advanced in place by {@link driveStep}. */
56
+ export interface DriverState {
57
+ /** The reaction-committed target actually steered toward (the goal as of up to `reactionSeconds` ago). */
58
+ targetX: number;
59
+ targetZ: number;
60
+ /** Seconds until the next target resample. */
61
+ sampleCooldown: number;
62
+ /** Current steering-wobble offset (radians of heading error) and time until its next resample. */
63
+ jitter: number;
64
+ jitterCooldown: number;
65
+ /** Seconds spent throttling without moving. */
66
+ grindSeconds: number;
67
+ /** Seconds of reverse-out remaining; `> 0` means currently backing up. */
68
+ reverseRemaining: number;
69
+ }
70
+ /**
71
+ * Fresh driver state committed to an initial target (usually the spawn-time goal or the spawn
72
+ * position itself).
73
+ *
74
+ * @capability ai-driver-state fresh serializable state for one difficulty-aware AI driver
75
+ */
76
+ export declare function createDriverState(targetX?: number, targetZ?: number): DriverState;
77
+ /** {@link driveStep}'s result: the axis sample to feed the vehicle sim, plus arrival. */
78
+ export interface DriverStep extends AxisInput {
79
+ /** The vehicle is inside `arriveRadius` of the *actual* goal this step. */
80
+ arrived: boolean;
81
+ }
82
+ /**
83
+ * Advance one driving step, mutating `state`, and return the {@link AxisInput} to feed the vehicle
84
+ * sim. `obstacleAhead` is the caller-measured distance to the nearest blocker in the driving
85
+ * corridor this step (`null`/omitted when clear) — the driver perceives it late by
86
+ * `speed * reactionSeconds`, so easy tiers brake at the last moment and expert tiers with proper
87
+ * margin. The steering target is a delay-line over the goal — resampled every `reactionSeconds`,
88
+ * so it trails a moving goal by up to that long, which is what makes an easy chaser visibly cut
89
+ * toward where the target *was*. When throttle produces no motion for a tier-scaled while the
90
+ * driver reverses out with counter-steer (brake past standstill — the shared sim's reverse).
91
+ *
92
+ * @capability ai-driver difficulty-aware chase/route driving step producing throttle/brake/steer for the vehicle sim
93
+ */
94
+ export declare function driveStep(state: DriverState, dt: number, pose: DriverPose, goal: DriverGoal, profile: DifficultyProfile, tuning: DriverTuning, rng: () => number, obstacleAhead?: number | null): DriverStep;
95
+ /** A 2D polyline point (`[x, z]`), structurally compatible with authored road path centerlines. */
96
+ export type DrivePathPoint = readonly [number, number];
97
+ /**
98
+ * The pure-pursuit target for street/route following: the point `lookahead` meters further along
99
+ * the polyline from wherever the vehicle currently projects onto it. Feed the result to
100
+ * {@link driveStep} as the goal each tick and the driver follows the road; `loop` wraps a circuit
101
+ * (traffic block, race lap) instead of pinning at the final point. Returns `null` for a
102
+ * degenerate path. Give sharper tiers a longer `lookahead` to cut corners cleanly and duller
103
+ * tiers a short one so they steer segment-to-segment.
104
+ *
105
+ * @capability ai-driver-path-target pure-pursuit lookahead target on a road/route polyline for street following
106
+ */
107
+ export declare function pathTargetAhead(path: readonly DrivePathPoint[], x: number, z: number, lookahead: number, loop?: boolean): DrivePathPoint | null;
@@ -0,0 +1,175 @@
1
+ import { executionError } from "./difficulty.js";
2
+ /**
3
+ * Fresh driver state committed to an initial target (usually the spawn-time goal or the spawn
4
+ * position itself).
5
+ *
6
+ * @capability ai-driver-state fresh serializable state for one difficulty-aware AI driver
7
+ */
8
+ export function createDriverState(targetX = 0, targetZ = 0) {
9
+ return {
10
+ targetX,
11
+ targetZ,
12
+ sampleCooldown: 0,
13
+ jitter: 0,
14
+ jitterCooldown: 0,
15
+ grindSeconds: 0,
16
+ reverseRemaining: 0,
17
+ };
18
+ }
19
+ const wrapAngle = (angle) => {
20
+ let a = angle;
21
+ while (a > Math.PI)
22
+ a -= Math.PI * 2;
23
+ while (a < -Math.PI)
24
+ a += Math.PI * 2;
25
+ return a;
26
+ };
27
+ const clamp01 = (value) => (value < 0 ? 0 : value > 1 ? 1 : value);
28
+ /**
29
+ * Advance one driving step, mutating `state`, and return the {@link AxisInput} to feed the vehicle
30
+ * sim. `obstacleAhead` is the caller-measured distance to the nearest blocker in the driving
31
+ * corridor this step (`null`/omitted when clear) — the driver perceives it late by
32
+ * `speed * reactionSeconds`, so easy tiers brake at the last moment and expert tiers with proper
33
+ * margin. The steering target is a delay-line over the goal — resampled every `reactionSeconds`,
34
+ * so it trails a moving goal by up to that long, which is what makes an easy chaser visibly cut
35
+ * toward where the target *was*. When throttle produces no motion for a tier-scaled while the
36
+ * driver reverses out with counter-steer (brake past standstill — the shared sim's reverse).
37
+ *
38
+ * @capability ai-driver difficulty-aware chase/route driving step producing throttle/brake/steer for the vehicle sim
39
+ */
40
+ export function driveStep(state, dt, pose, goal, profile, tuning, rng, obstacleAhead = null) {
41
+ const arriveRadius = tuning.arriveRadius ?? 4;
42
+ // Delay-line target: resample the live goal every reactionSeconds, so the steering target is
43
+ // the goal as of up to that long ago and a moving goal is chased where it *was*.
44
+ state.sampleCooldown -= dt;
45
+ if (state.sampleCooldown <= 0) {
46
+ state.targetX = goal.x;
47
+ state.targetZ = goal.z;
48
+ state.sampleCooldown = profile.reactionSeconds;
49
+ }
50
+ const goalDistance = Math.hypot(goal.x - pose.x, goal.z - pose.z);
51
+ const arrived = goalDistance <= arriveRadius;
52
+ if (arrived && goal.stopAtGoal === true) {
53
+ state.grindSeconds = 0;
54
+ state.reverseRemaining = 0;
55
+ return { throttle: 0, brake: clamp01(Math.abs(pose.speed)), steer: 0, handbrake: 0, arrived };
56
+ }
57
+ const dx = state.targetX - pose.x;
58
+ const dz = state.targetZ - pose.z;
59
+ const headingError = wrapAngle(Math.atan2(dx, dz) - pose.heading);
60
+ // Steering wobble: resample on an interval so it reads as a loose wheel, not per-frame noise.
61
+ state.jitterCooldown -= dt;
62
+ if (state.jitterCooldown <= 0) {
63
+ state.jitter = executionError(profile, rng, tuning.jitterScaleRadians ?? 0.35);
64
+ state.jitterCooldown = tuning.jitterIntervalSeconds ?? 0.35;
65
+ }
66
+ // Reverse-out recovery: back up with counter-steer, nose swinging toward the target.
67
+ if (state.reverseRemaining > 0) {
68
+ state.reverseRemaining -= dt;
69
+ const steer = headingError >= 0 ? -1 : 1;
70
+ return { throttle: 0, brake: 1, steer, handbrake: 0, arrived };
71
+ }
72
+ const steerGain = tuning.steerGain ?? 2;
73
+ const steer = Math.max(-1, Math.min(1, (headingError + state.jitter) * steerGain));
74
+ // Corner-aware desired speed: shed speed into large heading errors instead of orbiting.
75
+ const cornerSlowdown = tuning.cornerSlowdown ?? 0.75;
76
+ const cornerFactor = 1 - cornerSlowdown * Math.min(1, Math.abs(headingError) / (Math.PI / 2));
77
+ let desiredSpeed = Math.min(tuning.maxSpeed, goal.speedLimit ?? Number.POSITIVE_INFINITY);
78
+ desiredSpeed *= cornerFactor;
79
+ if (goal.stopAtGoal === true) {
80
+ // Ease in over the last stretch so the stop lands inside the radius instead of overshooting.
81
+ desiredSpeed = Math.min(desiredSpeed, Math.max(2, goalDistance - arriveRadius));
82
+ }
83
+ // Obstacle response on perceived (reaction-late) distance.
84
+ let hardBrake = false;
85
+ if (obstacleAhead !== null && pose.speed > 0) {
86
+ const brakeMargin = tuning.brakeMargin ?? 6;
87
+ const perceived = obstacleAhead - pose.speed * profile.reactionSeconds;
88
+ if (perceived <= brakeMargin)
89
+ hardBrake = true;
90
+ else if (perceived <= brakeMargin + pose.speed * 0.9)
91
+ desiredSpeed = Math.min(desiredSpeed, pose.speed * 0.5);
92
+ }
93
+ let throttle = 0;
94
+ let brake = 0;
95
+ if (hardBrake) {
96
+ brake = 1;
97
+ }
98
+ else if (pose.speed > desiredSpeed + 1) {
99
+ brake = clamp01((pose.speed - desiredSpeed) / Math.max(4, desiredSpeed));
100
+ }
101
+ else if (pose.speed < desiredSpeed) {
102
+ throttle = clamp01((desiredSpeed - pose.speed) / 3);
103
+ }
104
+ // Stuck detection: throttling but not moving (wall, prop, another car).
105
+ const stuckSpeed = tuning.stuckSpeed ?? 0.8;
106
+ if (throttle > 0.3 && Math.abs(pose.speed) < stuckSpeed) {
107
+ state.grindSeconds += dt;
108
+ if (state.grindSeconds >= (tuning.stuckSeconds ?? 1.5) + profile.reactionSeconds) {
109
+ state.grindSeconds = 0;
110
+ state.reverseRemaining = tuning.reverseSeconds ?? 1.1;
111
+ }
112
+ }
113
+ else {
114
+ state.grindSeconds = 0;
115
+ }
116
+ return { throttle, brake, steer, handbrake: 0, arrived };
117
+ }
118
+ /**
119
+ * The pure-pursuit target for street/route following: the point `lookahead` meters further along
120
+ * the polyline from wherever the vehicle currently projects onto it. Feed the result to
121
+ * {@link driveStep} as the goal each tick and the driver follows the road; `loop` wraps a circuit
122
+ * (traffic block, race lap) instead of pinning at the final point. Returns `null` for a
123
+ * degenerate path. Give sharper tiers a longer `lookahead` to cut corners cleanly and duller
124
+ * tiers a short one so they steer segment-to-segment.
125
+ *
126
+ * @capability ai-driver-path-target pure-pursuit lookahead target on a road/route polyline for street following
127
+ */
128
+ export function pathTargetAhead(path, x, z, lookahead, loop = false) {
129
+ if (path.length < 2)
130
+ return null;
131
+ let bestDistSq = Number.POSITIVE_INFINITY;
132
+ let bestSegment = 0;
133
+ let bestT = 0;
134
+ for (let i = 0; i < path.length - 1; i += 1) {
135
+ const a = path[i];
136
+ const b = path[i + 1];
137
+ const abx = b[0] - a[0];
138
+ const abz = b[1] - a[1];
139
+ const lengthSq = abx * abx + abz * abz;
140
+ const t = lengthSq === 0 ? 0 : Math.max(0, Math.min(1, ((x - a[0]) * abx + (z - a[1]) * abz) / lengthSq));
141
+ const px = a[0] + abx * t;
142
+ const pz = a[1] + abz * t;
143
+ const distSq = (x - px) * (x - px) + (z - pz) * (z - pz);
144
+ if (distSq < bestDistSq) {
145
+ bestDistSq = distSq;
146
+ bestSegment = i;
147
+ bestT = t;
148
+ }
149
+ }
150
+ let remaining = lookahead;
151
+ let segment = bestSegment;
152
+ let t = bestT;
153
+ for (let hops = 0; hops <= path.length * 2; hops += 1) {
154
+ const a = path[segment];
155
+ const b = path[segment + 1];
156
+ const segLength = Math.hypot(b[0] - a[0], b[1] - a[1]);
157
+ const left = segLength * (1 - t);
158
+ if (remaining <= left || segLength === 0) {
159
+ const nt = segLength === 0 ? t : t + remaining / segLength;
160
+ return [a[0] + (b[0] - a[0]) * nt, a[1] + (b[1] - a[1]) * nt];
161
+ }
162
+ remaining -= left;
163
+ t = 0;
164
+ if (segment + 1 < path.length - 1) {
165
+ segment += 1;
166
+ }
167
+ else if (loop) {
168
+ segment = 0;
169
+ }
170
+ else {
171
+ return [b[0], b[1]];
172
+ }
173
+ }
174
+ return [path[path.length - 1][0], path[path.length - 1][1]];
175
+ }