@jgengine/core 0.26.0 → 0.28.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 (143) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/ai/policeResponse.d.ts +253 -0
  3. package/dist/ai/policeResponse.js +421 -0
  4. package/dist/anim/actionLayer.d.ts +47 -0
  5. package/dist/anim/actionLayer.js +63 -0
  6. package/dist/anim/ikSolver.d.ts +14 -0
  7. package/dist/anim/ikSolver.js +37 -0
  8. package/dist/anim/locomotionGraph.d.ts +58 -3
  9. package/dist/anim/locomotionGraph.js +142 -15
  10. package/dist/anim/ragdollRig.d.ts +53 -0
  11. package/dist/anim/ragdollRig.js +132 -0
  12. package/dist/anim/skeletonStandard.d.ts +140 -0
  13. package/dist/anim/skeletonStandard.js +326 -0
  14. package/dist/combat/damageRuntime.d.ts +14 -1
  15. package/dist/combat/damageRuntime.js +62 -4
  16. package/dist/combat/death.js +7 -3
  17. package/dist/combat/deathReason.d.ts +7 -1
  18. package/dist/combat/effects.d.ts +25 -0
  19. package/dist/combat/effects.js +16 -0
  20. package/dist/combat/statusRuntime.d.ts +2 -0
  21. package/dist/combat/statusRuntime.js +1 -0
  22. package/dist/economy/loanBook.d.ts +182 -0
  23. package/dist/economy/loanBook.js +303 -0
  24. package/dist/economy/market.d.ts +161 -0
  25. package/dist/economy/market.js +248 -0
  26. package/dist/economy/transactionLedger.d.ts +162 -0
  27. package/dist/economy/transactionLedger.js +301 -0
  28. package/dist/editor/creatorStorage.js +3 -1
  29. package/dist/editor/simulation.d.ts +5 -2
  30. package/dist/editor/simulation.js +29 -19
  31. package/dist/game/cameraConfig.d.ts +14 -0
  32. package/dist/game/clipRoles.d.ts +5 -4
  33. package/dist/game/clipRoles.js +34 -4
  34. package/dist/game/events.d.ts +27 -0
  35. package/dist/game/gameSave.d.ts +142 -0
  36. package/dist/game/gameSave.js +174 -0
  37. package/dist/game/playableGame.d.ts +74 -6
  38. package/dist/game/saveSlotIndex.d.ts +54 -0
  39. package/dist/game/saveSlotIndex.js +71 -0
  40. package/dist/game/saveSlots.d.ts +2 -0
  41. package/dist/game/saveSlots.js +1 -1
  42. package/dist/game/saveStore.d.ts +8 -0
  43. package/dist/game/saveStore.js +16 -0
  44. package/dist/gameplay.d.ts +17 -2
  45. package/dist/gameplay.js +15 -0
  46. package/dist/inventory/carryLoad.d.ts +48 -0
  47. package/dist/inventory/carryLoad.js +44 -0
  48. package/dist/inventory/equipment.d.ts +95 -0
  49. package/dist/inventory/equipment.js +169 -0
  50. package/dist/inventory/inventoryModel.d.ts +15 -6
  51. package/dist/inventory/inventoryModel.js +54 -5
  52. package/dist/inventory/shapedGrid.d.ts +26 -0
  53. package/dist/inventory/shapedGrid.js +47 -0
  54. package/dist/loot/lootGen.d.ts +186 -0
  55. package/dist/loot/lootGen.js +437 -0
  56. package/dist/loot/lootWeapon.d.ts +35 -0
  57. package/dist/loot/lootWeapon.js +70 -0
  58. package/dist/meta/changelog.d.ts +1 -1
  59. package/dist/meta/changelog.js +156 -1
  60. package/dist/mission/compositions.d.ts +85 -0
  61. package/dist/mission/compositions.js +130 -0
  62. package/dist/mission/mission.d.ts +435 -0
  63. package/dist/mission/mission.js +770 -0
  64. package/dist/mission/triggers.d.ts +61 -0
  65. package/dist/mission/triggers.js +108 -0
  66. package/dist/nav/laneGraph.d.ts +178 -0
  67. package/dist/nav/laneGraph.js +671 -0
  68. package/dist/nav/traffic.d.ts +222 -0
  69. package/dist/nav/traffic.js +918 -0
  70. package/dist/physics/kinematicVehicle.d.ts +36 -0
  71. package/dist/physics/kinematicVehicle.js +15 -1
  72. package/dist/physics/vehicleDamage.d.ts +136 -0
  73. package/dist/physics/vehicleDamage.js +214 -0
  74. package/dist/physics/vehicleParts.d.ts +62 -0
  75. package/dist/physics/vehicleParts.js +65 -0
  76. package/dist/progression/gearScore.d.ts +56 -0
  77. package/dist/progression/gearScore.js +59 -0
  78. package/dist/progression/skillTree.d.ts +129 -0
  79. package/dist/progression/skillTree.js +265 -0
  80. package/dist/progression/xpCurve.d.ts +60 -0
  81. package/dist/progression/xpCurve.js +141 -0
  82. package/dist/runtime/context/combat.js +11 -0
  83. package/dist/scene/builtinSceneKinds.js +2 -0
  84. package/dist/settings/accessibilitySettings.d.ts +81 -0
  85. package/dist/settings/accessibilitySettings.js +152 -0
  86. package/dist/settings/graphicsProfile.d.ts +3 -0
  87. package/dist/settings/graphicsProfile.js +4 -0
  88. package/dist/settings/settingsModel.d.ts +1 -1
  89. package/dist/settings/settingsModel.js +1 -0
  90. package/dist/sim/nearAgents.d.ts +8 -1
  91. package/dist/sim/nearAgents.js +34 -17
  92. package/dist/sim/offScreen.d.ts +2 -1
  93. package/dist/sim/offScreen.js +14 -3
  94. package/dist/sim/population.d.ts +147 -0
  95. package/dist/sim/population.js +182 -0
  96. package/dist/sim/queue.d.ts +90 -0
  97. package/dist/sim/queue.js +171 -0
  98. package/dist/sim/rides.d.ts +287 -0
  99. package/dist/sim/rides.js +471 -0
  100. package/dist/sim/routine.js +19 -11
  101. package/dist/ui/loadingTips.d.ts +41 -0
  102. package/dist/ui/loadingTips.js +64 -0
  103. package/dist/ui/menuNav.d.ts +79 -0
  104. package/dist/ui/menuNav.js +147 -0
  105. package/dist/ui/menuTransition.d.ts +50 -0
  106. package/dist/ui/menuTransition.js +67 -0
  107. package/dist/ui.d.ts +2 -0
  108. package/dist/ui.js +2 -0
  109. package/dist/vfx/effectLibrary.d.ts +98 -0
  110. package/dist/vfx/effectLibrary.js +181 -0
  111. package/dist/vfx/particleBudget.d.ts +40 -0
  112. package/dist/vfx/particleBudget.js +39 -0
  113. package/dist/vfx/particleDirector.d.ts +16 -0
  114. package/dist/vfx/particleDirector.js +18 -0
  115. package/dist/world/authoredSimulation.js +7 -1
  116. package/dist/world/captureCamera.d.ts +30 -0
  117. package/dist/world/captureCamera.js +19 -0
  118. package/dist/world/decalKind.d.ts +48 -0
  119. package/dist/world/decalKind.js +86 -0
  120. package/dist/world/features.d.ts +18 -5
  121. package/dist/world/features.js +1 -0
  122. package/dist/world/grassKind.d.ts +5 -0
  123. package/dist/world/grassKind.js +3 -0
  124. package/dist/world/terraform.d.ts +8 -0
  125. package/dist/world/terrain.d.ts +1 -0
  126. package/dist/world/terrain.js +2 -0
  127. package/dist/world.d.ts +7 -2
  128. package/dist/world.js +5 -0
  129. package/package.json +1 -1
  130. package/skills/jgengine/api.md +2 -2
  131. package/skills/jgengine-combat/SKILL.md +9 -1
  132. package/skills/jgengine-combat/api.md +5 -4
  133. package/skills/jgengine-combat/reference.md +4 -0
  134. package/skills/jgengine-gameplay/SKILL.md +86 -0
  135. package/skills/jgengine-gameplay/api.md +485 -16
  136. package/skills/jgengine-gameplay/capabilities.md +131 -4
  137. package/skills/jgengine-gameplay/reference-systems.md +118 -0
  138. package/skills/jgengine-world/SKILL.md +8 -0
  139. package/skills/jgengine-world/api.md +243 -11
  140. package/skills/jgengine-world/capabilities.md +61 -1
  141. package/skills/jgengine-world/recipes/character-animation.md +25 -2
  142. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
  143. package/skills/jgengine-world/reference.md +1 -1
@@ -44,6 +44,13 @@ export interface KinematicVehicleTuning {
44
44
  steering?: KinematicSteeringTuning;
45
45
  /** Optional aerodynamic drag/downforce and electronic driver assists. */
46
46
  dynamics?: KinematicDynamicsTuning;
47
+ /**
48
+ * Optional load-transfer handling: the tires' combined grip falls as weight moves off a corner, so a
49
+ * car on the brakes into a corner holds less slide than the same car coasting, and a hard corner
50
+ * itself costs grip. Bounded by the geometry (never diverges) and reported per tick as
51
+ * {@link KinematicVehicleStep.weightTransfer}. Omit to keep the flat-grip model.
52
+ */
53
+ weightTransfer?: KinematicWeightTransferTuning;
47
54
  /**
48
55
  * Optional mass-and-force chassis layer (#1051). When present it supersedes `engineAccel`/`brakeAccel`
49
56
  * with force/mass dynamics — a tire friction budget, lateral weight transfer, and engine braking — so
@@ -120,6 +127,23 @@ export interface KinematicDynamicsTuning {
120
127
  maxBodyRoll?: number;
121
128
  attitudeResponse?: number;
122
129
  }
130
+ /**
131
+ * Load-transfer handling for the arcade model. Weight moves under braking, traction and cornering; the
132
+ * tire that goes light loses more grip than the loaded one gains (load sensitivity), so the axle's
133
+ * combined grip falls. Every term is bounded by the geometry, and `sensitivity` caps the loss, so the
134
+ * transfer can never drive grip negative or diverge. For per-axle balance from tire forces, use
135
+ * `createVehicleDynamics` (`physics/vehicleDynamics`) instead.
136
+ */
137
+ export interface KinematicWeightTransferTuning {
138
+ /** Centre-of-mass height, m. */
139
+ comHeight: number;
140
+ /** Wheelbase, m; sets longitudinal transfer under braking and traction. */
141
+ wheelbase: number;
142
+ /** Track width, m; sets lateral transfer under cornering. */
143
+ trackWidth: number;
144
+ /** Grip lost per unit of total load transfer (default `0.35`); the tire load sensitivity. */
145
+ sensitivity?: number;
146
+ }
123
147
  export interface KinematicVehicleOptions {
124
148
  position?: readonly [number, number, number];
125
149
  heading?: number;
@@ -157,6 +181,11 @@ export interface KinematicVehicleModifiers {
157
181
  * Separate from `accelScale` because stronger drive and stronger brakes are not the same upgrade.
158
182
  */
159
183
  brakeScale?: number;
184
+ /**
185
+ * Scale the tire grip curve this tick (damage, a low-grip zone, a temporary debuff). Mirrors the
186
+ * `gripScale` on `VehicleDynamicsModifiers`, so a damage layer's per-tick output drives either sim.
187
+ */
188
+ gripScale?: number;
160
189
  }
161
190
  export interface KinematicVehicleStep {
162
191
  position: readonly [number, number, number];
@@ -168,6 +197,13 @@ export interface KinematicVehicleStep {
168
197
  slip: number;
169
198
  /** The `surfaceFriction` sample used this tick (`< 1` = off the ideal surface). */
170
199
  surface: number;
200
+ /**
201
+ * Total load transfer this tick, `0..1`: longitudinal (braking/traction) plus lateral (cornering)
202
+ * weight movement as a fraction of the vehicle's weight. Always `0` without a `weightTransfer` block.
203
+ */
204
+ weightTransfer: number;
205
+ /** The grip multiplier the load transfer applied this tick (`1` = no loss); always `1` without the block. */
206
+ loadGrip: number;
171
207
  /** One-based forward gear, or `0` while reversing/neutral. */
172
208
  gear: number;
173
209
  rpm: number;
@@ -188,7 +188,19 @@ export function createKinematicVehicle(initialTuning, options = {}) {
188
188
  const slip = Math.abs(lateralSpeed) / (Math.abs(forwardSpeed) + 1);
189
189
  const handbrakeFactor = 1 - axis.handbrake * (1 - tuning.handbrakeGrip);
190
190
  const downforce = 1 + (tuning.dynamics?.downforce ?? 0) * Math.min(1, Math.abs(forwardSpeed) / Math.max(1, topSpeed));
191
- const gripValue = sampleGripCurve(grip, slip) * surface * handbrakeFactor * downforce;
191
+ // Load transfer: longitudinal from the commanded accel, lateral from the cornering demand. Both are
192
+ // fractions of weight, so the same car on the brakes into a corner and coasting through it differ
193
+ // by this term alone. `loadGrip` is floored so the loss stays a feel change, never a black hole.
194
+ const transfer = tuning.weightTransfer;
195
+ let weightTransfer = 0;
196
+ let loadGrip = 1;
197
+ if (transfer !== undefined) {
198
+ const longitudinal = (Math.abs(accel) * transfer.comHeight) / (Math.max(0.1, transfer.wheelbase) * GRAVITY);
199
+ const lateral = (Math.abs(yawRate * forwardSpeed) * transfer.comHeight) / (Math.max(0.1, transfer.trackWidth) * GRAVITY);
200
+ weightTransfer = Math.min(1, longitudinal + lateral);
201
+ loadGrip = Math.max(0.3, 1 - (transfer.sensitivity ?? 0.35) * weightTransfer);
202
+ }
203
+ const gripValue = sampleGripCurve(grip, slip) * surface * handbrakeFactor * downforce * loadGrip * (modifiers?.gripScale ?? 1);
192
204
  const keep = Math.max(0, 1 - gripValue * tuning.gripStrength * dt);
193
205
  // ESC fights intentional slides — ease it off while the handbrake is held so drift stays steerable.
194
206
  const stability = (tuning.dynamics?.stabilityControl ?? 0) * (1 - 0.85 * axis.handbrake);
@@ -285,6 +297,8 @@ export function createKinematicVehicle(initialTuning, options = {}) {
285
297
  lateralSpeed: -vx * fz + vz * fx,
286
298
  slip,
287
299
  surface,
300
+ weightTransfer,
301
+ loadGrip,
288
302
  gear: speed < -0.2 ? 0 : gearIndex + 1,
289
303
  rpm,
290
304
  steerAngle,
@@ -0,0 +1,136 @@
1
+ import type { AxisInput } from "../input/axisInput.js";
2
+ import type { KinematicVehicleModifiers, KinematicVehicleTuning } from "./kinematicVehicle.js";
3
+ /**
4
+ * Handling change one damage zone applies at its *last* stage. Multiplier fields read `1` = undamaged,
5
+ * and a zone part-way through its stages gets the fraction between; `steerPull` and `drag` are additive
6
+ * and reach their full value only at the last stage.
7
+ */
8
+ export interface VehicleDamageEffect {
9
+ /** Tire grip multiplier at full damage (`< 1` loses grip). */
10
+ gripScale?: number;
11
+ /** Drive-force multiplier at full damage. */
12
+ engineScale?: number;
13
+ brakeScale?: number;
14
+ topSpeedScale?: number;
15
+ /** Steering bias in radians at full damage — a bent frame that pulls one way. */
16
+ steerPull?: number;
17
+ /** Extra coast drag per second at full damage. */
18
+ drag?: number;
19
+ }
20
+ /**
21
+ * One damageable zone: a fixed local body point, the impulse thresholds that step it through its
22
+ * stages, the bounded dent it shows, and the handling it costs. There is no mesh editing — a zone's
23
+ * deformation is one displacement it can never exceed, so the visual can never grow without limit.
24
+ */
25
+ export interface VehicleDamageZone {
26
+ id: string;
27
+ /** Local body point in metres (x right, y up, z forward); picks the zone nearest an impact and anchors the dent. */
28
+ at: readonly [number, number, number];
29
+ /** Impulse thresholds; the count is the zone's stage count, and the last stage is fully damaged. */
30
+ thresholds: readonly number[];
31
+ /** Dent displacement in metres at the last stage, scaled by stage. Omit for a zone that only changes handling. */
32
+ deform?: readonly [number, number, number];
33
+ /** Stage at which the part comes off (the renderer hides or drops it); omit to never detach. */
34
+ detachStage?: number;
35
+ /** Handling change at the last stage, reached proportionally across the stages. */
36
+ effect?: VehicleDamageEffect;
37
+ }
38
+ /** Live state of one zone, as a HUD or a save reads it. */
39
+ export interface VehicleDamageZoneState {
40
+ id: string;
41
+ accumulated: number;
42
+ stage: number;
43
+ detached: boolean;
44
+ }
45
+ /** Serializable damage state: {@link VehicleDamage.restore} needs only this. */
46
+ export interface VehicleDamageState {
47
+ zones: VehicleDamageZoneState[];
48
+ total: number;
49
+ disabled: boolean;
50
+ }
51
+ /** A zone crossing a threshold this tick — the event a HUD feed, a smoke puff or a camera shake keys off. */
52
+ export interface VehicleDamageTransition {
53
+ zone: string;
54
+ stage: number;
55
+ previousStage: number;
56
+ detached: boolean;
57
+ disabled: boolean;
58
+ total: number;
59
+ }
60
+ /** A zone's current dent: a bounded local displacement and whether the part has come off. */
61
+ export interface VehicleDeformation {
62
+ zoneId: string;
63
+ stage: number;
64
+ /** Local displacement in metres, never larger than the zone's `deform`. */
65
+ offset: readonly [number, number, number];
66
+ /** The part has come off — the renderer hides or drops it. */
67
+ detached: boolean;
68
+ }
69
+ /** Every zone's handling cost summed into one set of numbers (all fields present, defaults filled). */
70
+ export interface VehicleDamageEffectResolved {
71
+ gripScale: number;
72
+ engineScale: number;
73
+ brakeScale: number;
74
+ topSpeedScale: number;
75
+ steerPull: number;
76
+ drag: number;
77
+ }
78
+ /** Everything {@link createVehicleDamage} needs. */
79
+ export interface VehicleDamageConfig {
80
+ zones: readonly VehicleDamageZone[];
81
+ /** Total accumulated impulse across every zone that disables the vehicle; omit to never disable. */
82
+ disableAt?: number;
83
+ /** Called on each threshold crossing, for HUD/feed/audio. */
84
+ onTransition?: (transition: VehicleDamageTransition) => void;
85
+ /** Register a devtools probe under this name so the perf overlay shows the damage summary. */
86
+ devtoolsName?: string;
87
+ }
88
+ /**
89
+ * Fixed-zone crash damage for a vehicle: impacts accumulate into discrete stages, stages change handling
90
+ * and show a bounded dent, and a part can come off at a set stage. Data-driven and bounded by
91
+ * construction — the zone set is authored, each zone's deformation is one capped displacement, and no
92
+ * mesh is edited. Serializes through {@link snapshot}/{@link restore}, so a save, a replay or a host
93
+ * correction resumes mid-crash.
94
+ */
95
+ export interface VehicleDamage {
96
+ /**
97
+ * Route an impulse to the zone nearest a local body hit point (x right, y up, z forward) and return
98
+ * the transitions it caused — empty when nothing crossed a threshold. The caller rotates a world
99
+ * impact into body space; this module never reads the world.
100
+ */
101
+ hit(local: readonly [number, number, number], impulse: number): readonly VehicleDamageTransition[];
102
+ /** Add impulse straight to a named zone; null for an unknown zone or a non-positive impulse. */
103
+ impact(zoneId: string, impulse: number): VehicleDamageTransition | null;
104
+ stageOf(zoneId: string): number;
105
+ zones(): readonly VehicleDamageZoneState[];
106
+ /** Summed handling change across every zone at its current stage. */
107
+ effect(): VehicleDamageEffectResolved;
108
+ /** Bounded visual deformation, one entry per zone that has taken a stage — the render hook. */
109
+ deformation(): readonly VehicleDeformation[];
110
+ total(): number;
111
+ disabled(): boolean;
112
+ /**
113
+ * Per-tick modifiers the sim applies for one frame. Use this for a scratch or a reset that should not
114
+ * rebuild the tuning; for lasting damage call {@link damagedTuning} and `retune` once per change.
115
+ */
116
+ modifiers(): KinematicVehicleModifiers;
117
+ snapshot(): VehicleDamageState;
118
+ restore(next: VehicleDamageState): void;
119
+ reset(): void;
120
+ /** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
121
+ dispose(): void;
122
+ }
123
+ /**
124
+ * Fixed-zone crash damage (issue #86's stage model) wired to handling and visuals: see {@link VehicleDamage}.
125
+ *
126
+ * @capability vehicle-damage fixed-zone crash damage that changes handling and shows a bounded dent, serializable
127
+ */
128
+ export declare function createVehicleDamage(config: VehicleDamageConfig): VehicleDamage;
129
+ /**
130
+ * Bake a damage effect into a tuning — the lasting-damage path. Hand the result to the sim's `retune`,
131
+ * which keeps the vehicle's momentum; the base tuning is not mutated, so a repair is the same call with
132
+ * the undamaged base.
133
+ */
134
+ export declare function damagedTuning(base: KinematicVehicleTuning, effect: VehicleDamageEffectResolved): KinematicVehicleTuning;
135
+ /** Apply a damage `steerPull` to a steer axis, clamped to the input range — a bent frame pulling one way. */
136
+ export declare function damagedAxis(axis: AxisInput, effect: VehicleDamageEffectResolved): AxisInput;
@@ -0,0 +1,214 @@
1
+ import { devtools } from "../devtools/devtools.js";
2
+ const NO_EFFECT = {
3
+ gripScale: 1,
4
+ engineScale: 1,
5
+ brakeScale: 1,
6
+ topSpeedScale: 1,
7
+ steerPull: 0,
8
+ drag: 0,
9
+ };
10
+ function stageFor(thresholds, accumulated) {
11
+ let stage = 0;
12
+ for (const threshold of thresholds) {
13
+ if (accumulated >= threshold)
14
+ stage += 1;
15
+ else
16
+ break;
17
+ }
18
+ return stage;
19
+ }
20
+ /**
21
+ * Fixed-zone crash damage (issue #86's stage model) wired to handling and visuals: see {@link VehicleDamage}.
22
+ *
23
+ * @capability vehicle-damage fixed-zone crash damage that changes handling and shows a bounded dent, serializable
24
+ */
25
+ export function createVehicleDamage(config) {
26
+ const order = [];
27
+ const defs = new Map();
28
+ const states = new Map();
29
+ for (const zone of config.zones) {
30
+ if (defs.has(zone.id))
31
+ throw new Error(`duplicate damage zone: ${zone.id}`);
32
+ defs.set(zone.id, zone);
33
+ states.set(zone.id, { id: zone.id, accumulated: 0, stage: 0, detached: false });
34
+ order.push(zone.id);
35
+ }
36
+ const disableAt = config.disableAt ?? null;
37
+ let totalImpulse = 0;
38
+ let disabledFlag = false;
39
+ function absorb(zoneId, impulse) {
40
+ const def = defs.get(zoneId);
41
+ const state = states.get(zoneId);
42
+ if (def === undefined || state === undefined || !(impulse > 0))
43
+ return null;
44
+ const previousStage = state.stage;
45
+ const wasDisabled = disabledFlag;
46
+ state.accumulated += impulse;
47
+ totalImpulse += impulse;
48
+ state.stage = Math.min(def.thresholds.length, stageFor(def.thresholds, state.accumulated));
49
+ let detachedNow = false;
50
+ if (def.detachStage !== undefined && !state.detached && state.stage >= def.detachStage) {
51
+ state.detached = true;
52
+ detachedNow = true;
53
+ }
54
+ if (disableAt !== null && totalImpulse >= disableAt)
55
+ disabledFlag = true;
56
+ if (state.stage === previousStage && !detachedNow && disabledFlag === wasDisabled)
57
+ return null;
58
+ const transition = {
59
+ zone: zoneId,
60
+ stage: state.stage,
61
+ previousStage,
62
+ detached: state.detached,
63
+ disabled: disabledFlag,
64
+ total: totalImpulse,
65
+ };
66
+ config.onTransition?.(transition);
67
+ return transition;
68
+ }
69
+ function factorOf(def, stage) {
70
+ const maxStage = def.thresholds.length;
71
+ return maxStage <= 0 ? 0 : Math.min(1, stage / maxStage);
72
+ }
73
+ function resolveEffect() {
74
+ const out = { ...NO_EFFECT };
75
+ for (const id of order) {
76
+ const def = defs.get(id);
77
+ const effect = def.effect;
78
+ if (effect === undefined)
79
+ continue;
80
+ const factor = factorOf(def, states.get(id).stage);
81
+ if (factor <= 0)
82
+ continue;
83
+ out.gripScale *= 1 + ((effect.gripScale ?? 1) - 1) * factor;
84
+ out.engineScale *= 1 + ((effect.engineScale ?? 1) - 1) * factor;
85
+ out.brakeScale *= 1 + ((effect.brakeScale ?? 1) - 1) * factor;
86
+ out.topSpeedScale *= 1 + ((effect.topSpeedScale ?? 1) - 1) * factor;
87
+ out.steerPull += (effect.steerPull ?? 0) * factor;
88
+ out.drag += (effect.drag ?? 0) * factor;
89
+ }
90
+ return out;
91
+ }
92
+ const unregister = config.devtoolsName === undefined ? null : devtools.probes.register(config.devtoolsName, probeLine);
93
+ function probeLine() {
94
+ let worst = 0;
95
+ let detached = 0;
96
+ for (const id of order) {
97
+ const state = states.get(id);
98
+ if (state.stage > worst)
99
+ worst = state.stage;
100
+ if (state.detached)
101
+ detached += 1;
102
+ }
103
+ return `${order.length} zones ${worst} stages ${detached} detached ${totalImpulse.toFixed(0)} impulse${disabledFlag ? " disabled" : ""}`;
104
+ }
105
+ return {
106
+ hit(local, impulse) {
107
+ if (!(impulse > 0))
108
+ return [];
109
+ let best = null;
110
+ let bestSq = Number.POSITIVE_INFINITY;
111
+ for (const id of order) {
112
+ const at = defs.get(id).at;
113
+ const dx = local[0] - at[0];
114
+ const dy = local[1] - at[1];
115
+ const dz = local[2] - at[2];
116
+ const distSq = dx * dx + dy * dy + dz * dz;
117
+ if (distSq < bestSq) {
118
+ bestSq = distSq;
119
+ best = id;
120
+ }
121
+ }
122
+ if (best === null)
123
+ return [];
124
+ const transition = absorb(best, impulse);
125
+ return transition === null ? [] : [transition];
126
+ },
127
+ impact: (zoneId, impulse) => absorb(zoneId, impulse),
128
+ stageOf: (zoneId) => states.get(zoneId)?.stage ?? 0,
129
+ zones: () => order.map((id) => ({ ...states.get(id) })),
130
+ effect: resolveEffect,
131
+ deformation() {
132
+ const out = [];
133
+ for (const id of order) {
134
+ const def = defs.get(id);
135
+ const state = states.get(id);
136
+ if (state.stage <= 0)
137
+ continue;
138
+ const factor = factorOf(def, state.stage);
139
+ const dent = def.deform;
140
+ out.push({
141
+ zoneId: id,
142
+ stage: state.stage,
143
+ offset: dent === undefined
144
+ ? [0, 0, 0]
145
+ : [dent[0] * factor, dent[1] * factor, dent[2] * factor],
146
+ detached: state.detached,
147
+ });
148
+ }
149
+ return out;
150
+ },
151
+ total: () => totalImpulse,
152
+ disabled: () => disabledFlag,
153
+ modifiers() {
154
+ const effect = resolveEffect();
155
+ return {
156
+ topSpeedScale: effect.topSpeedScale,
157
+ accelScale: effect.engineScale,
158
+ brakeScale: effect.brakeScale,
159
+ gripScale: effect.gripScale,
160
+ };
161
+ },
162
+ snapshot: () => ({
163
+ zones: order.map((id) => ({ ...states.get(id) })),
164
+ total: totalImpulse,
165
+ disabled: disabledFlag,
166
+ }),
167
+ restore(next) {
168
+ for (const saved of next.zones) {
169
+ const state = states.get(saved.id);
170
+ if (state === undefined)
171
+ continue;
172
+ state.accumulated = saved.accumulated;
173
+ state.stage = Math.min(defs.get(saved.id).thresholds.length, Math.max(0, saved.stage));
174
+ state.detached = saved.detached;
175
+ }
176
+ totalImpulse = next.total;
177
+ disabledFlag = next.disabled;
178
+ },
179
+ reset() {
180
+ for (const id of order) {
181
+ const state = states.get(id);
182
+ state.accumulated = 0;
183
+ state.stage = 0;
184
+ state.detached = false;
185
+ }
186
+ totalImpulse = 0;
187
+ disabledFlag = false;
188
+ },
189
+ dispose() {
190
+ unregister?.();
191
+ },
192
+ };
193
+ }
194
+ /**
195
+ * Bake a damage effect into a tuning — the lasting-damage path. Hand the result to the sim's `retune`,
196
+ * which keeps the vehicle's momentum; the base tuning is not mutated, so a repair is the same call with
197
+ * the undamaged base.
198
+ */
199
+ export function damagedTuning(base, effect) {
200
+ return {
201
+ ...base,
202
+ engineAccel: base.engineAccel * effect.engineScale,
203
+ brakeAccel: base.brakeAccel * effect.brakeScale,
204
+ topSpeed: base.topSpeed * effect.topSpeedScale,
205
+ gripStrength: base.gripStrength * effect.gripScale,
206
+ coastDeceleration: (base.coastDeceleration ?? 0) + effect.drag,
207
+ };
208
+ }
209
+ /** Apply a damage `steerPull` to a steer axis, clamped to the input range — a bent frame pulling one way. */
210
+ export function damagedAxis(axis, effect) {
211
+ if (effect.steerPull === 0)
212
+ return axis;
213
+ return { ...axis, steer: Math.max(-1, Math.min(1, axis.steer + effect.steerPull)) };
214
+ }
@@ -0,0 +1,62 @@
1
+ import type { KinematicVehicleTuning } from "./kinematicVehicle.js";
2
+ import { type GripCurve } from "./vehicleBody.js";
3
+ /** A tunable vehicle number a part can move; each names a {@link KinematicVehicleTuning} field. */
4
+ export type VehicleStat = "engineAccel" | "brakeAccel" | "topSpeed" | "reverseSpeed" | "turnRate" | "turnSpeedRef" | "gripStrength" | "handbrakeGrip" | "rollingResistance" | "coastDeceleration";
5
+ /** How one part moves a stat: multiply the base by `scale`, then add `add`. Both default to no change. */
6
+ export interface VehiclePartEffect {
7
+ scale?: number;
8
+ add?: number;
9
+ }
10
+ /**
11
+ * One installable part. `slot` is the caller's own grouping ("tires", "engine", "front-bumper") — the
12
+ * engine does not name slots, so a game's upgrade tree stays its own. Effects compose in install order,
13
+ * and a part's `gripCurve` replaces the base tire compound outright (last one installed wins).
14
+ */
15
+ export interface VehiclePart {
16
+ id: string;
17
+ slot: string;
18
+ effects?: Partial<Record<VehicleStat, VehiclePartEffect>>;
19
+ /** Tire compound: replaces the base grip curve when installed. */
20
+ gripCurve?: GripCurve;
21
+ }
22
+ /**
23
+ * Comparable handling numbers derived from a tuning — what "the numbers and the feel" means to a shop
24
+ * screen, a HUD or a test. Grip is reported as the curve's own shape, so a compound change shows up as
25
+ * a moved peak rather than a single scalar.
26
+ */
27
+ export interface VehicleFeel {
28
+ topSpeed: number;
29
+ engineAccel: number;
30
+ brakeAccel: number;
31
+ turnRate: number;
32
+ handbrakeGrip: number;
33
+ /** Grip available before the tire breaks away (the curve's highest point). */
34
+ peakGrip: number;
35
+ /** Slip where that peak sits — the far end of a flat plateau, so it reads as the breakaway slip. */
36
+ peakSlip: number;
37
+ /** Grip left once the tire is fully sliding (the curve at slip 1). */
38
+ slideGrip: number;
39
+ }
40
+ /** Read the handling numbers out of any tuning — base or parts-composed. */
41
+ export declare function feelOf(tuning: KinematicVehicleTuning): VehicleFeel;
42
+ /** A composed vehicle: the tuning to hand the sim, the feel it produces, and what was installed. */
43
+ export interface VehicleSetup {
44
+ tuning: KinematicVehicleTuning;
45
+ feel: VehicleFeel;
46
+ /** Installed part ids, in install order. */
47
+ parts: readonly string[];
48
+ }
49
+ /**
50
+ * Fold parts onto a base tuning: every effect scales then adds onto the running value, in array order,
51
+ * so a setup is deterministic and a caller can reason about which part moved what. The base tuning is
52
+ * never mutated, so the same base can seed several builds.
53
+ *
54
+ * @capability vehicle-parts compose data-driven tuning parts (tires, engine, brakes) onto a vehicle tuning and read the resulting feel
55
+ */
56
+ export declare function applyVehicleParts(base: KinematicVehicleTuning, parts: readonly VehiclePart[]): VehicleSetup;
57
+ /**
58
+ * Swap a part into its slot: any part already in `next.slot` is removed and `next` takes its place in
59
+ * the same install position, so the order (and therefore the composed numbers) stays stable across an
60
+ * upgrade.
61
+ */
62
+ export declare function withPart(parts: readonly VehiclePart[], next: VehiclePart): VehiclePart[];
@@ -0,0 +1,65 @@
1
+ import { DEFAULT_GRIP_CURVE, sampleGripCurve } from "./vehicleBody.js";
2
+ /** Read the handling numbers out of any tuning — base or parts-composed. */
3
+ export function feelOf(tuning) {
4
+ const curve = tuning.grip ?? DEFAULT_GRIP_CURVE;
5
+ let peakGrip = 0;
6
+ let peakSlip = 0;
7
+ for (const [slip, grip] of curve.points) {
8
+ if (grip >= peakGrip) {
9
+ peakGrip = grip;
10
+ peakSlip = slip;
11
+ }
12
+ }
13
+ return {
14
+ topSpeed: tuning.topSpeed,
15
+ engineAccel: tuning.engineAccel,
16
+ brakeAccel: tuning.brakeAccel,
17
+ turnRate: tuning.turnRate,
18
+ handbrakeGrip: tuning.handbrakeGrip,
19
+ peakGrip,
20
+ peakSlip,
21
+ slideGrip: sampleGripCurve(curve, 1),
22
+ };
23
+ }
24
+ function statOf(tuning, stat) {
25
+ return tuning[stat] ?? 0;
26
+ }
27
+ /**
28
+ * Fold parts onto a base tuning: every effect scales then adds onto the running value, in array order,
29
+ * so a setup is deterministic and a caller can reason about which part moved what. The base tuning is
30
+ * never mutated, so the same base can seed several builds.
31
+ *
32
+ * @capability vehicle-parts compose data-driven tuning parts (tires, engine, brakes) onto a vehicle tuning and read the resulting feel
33
+ */
34
+ export function applyVehicleParts(base, parts) {
35
+ const tuning = { ...base };
36
+ const ids = [];
37
+ for (const part of parts) {
38
+ ids.push(part.id);
39
+ if (part.gripCurve !== undefined)
40
+ tuning.grip = part.gripCurve;
41
+ if (part.effects === undefined)
42
+ continue;
43
+ for (const stat of Object.keys(part.effects)) {
44
+ const effect = part.effects[stat];
45
+ if (effect === undefined)
46
+ continue;
47
+ const scaled = statOf(tuning, stat) * (effect.scale ?? 1) + (effect.add ?? 0);
48
+ tuning[stat] = scaled;
49
+ }
50
+ }
51
+ return { tuning, feel: feelOf(tuning), parts: ids };
52
+ }
53
+ /**
54
+ * Swap a part into its slot: any part already in `next.slot` is removed and `next` takes its place in
55
+ * the same install position, so the order (and therefore the composed numbers) stays stable across an
56
+ * upgrade.
57
+ */
58
+ export function withPart(parts, next) {
59
+ const at = parts.findIndex((part) => part.slot === next.slot);
60
+ if (at < 0)
61
+ return [...parts, next];
62
+ const out = parts.slice();
63
+ out[at] = next;
64
+ return out;
65
+ }
@@ -0,0 +1,56 @@
1
+ import { type LootComparisonOptions } from "../loot/lootGen.js";
2
+ /** The least a worn item must expose to be scored: its stat block, plus optional level and rarity for the gear-score terms. */
3
+ export interface GearSlotItem {
4
+ stats: Readonly<Record<string, number>>;
5
+ /** Item level, added as `levelWeight × level` when the option is set. */
6
+ level?: number;
7
+ /** Rarity id, looked up in `rarityScore` when the option names one. */
8
+ rarity?: string;
9
+ }
10
+ /** Caller policy for a loadout score: stat weights, which stats read better small, and what level, rarity and an empty slot are worth. */
11
+ export interface GearScoreOptions extends Pick<LootComparisonOptions, "weights" | "lowerIsBetter"> {
12
+ /** Points added per item level (default `0`, so level is ignored). */
13
+ levelWeight?: number;
14
+ /** Points added per rarity id (default none). */
15
+ rarityScore?: Readonly<Record<string, number>>;
16
+ /** Slot ids a complete loadout fills; named slots that are empty are reported and penalized. */
17
+ expectedSlots?: readonly string[];
18
+ /** Points removed per empty slot (default `0`). */
19
+ emptySlotPenalty?: number;
20
+ }
21
+ /** A scored loadout: the total, each slot's share, each stat's signed contribution, and how complete it is. */
22
+ export interface GearScore {
23
+ /** `Σ slotScore - emptySlotPenalty × emptySlots`. */
24
+ score: number;
25
+ /** Score per filled slot id. */
26
+ bySlot: Readonly<Record<string, number>>;
27
+ /** Signed weighted contribution per stat, summed across items. */
28
+ byStat: Readonly<Record<string, number>>;
29
+ /** Number of filled slots. */
30
+ items: number;
31
+ /** `score / items`, or `0` for an empty loadout. */
32
+ average: number;
33
+ /** Filled slots over expected slots, `1` when no slots are expected. */
34
+ coverage: number;
35
+ /** Expected slot ids with no item. */
36
+ emptySlots: readonly string[];
37
+ }
38
+ /**
39
+ * Score a whole loadout with one documented formula:
40
+ *
41
+ * `slotScore = Σ_stat value × weight × (lowerIsBetter ? -1 : 1) + levelWeight × level + rarityScore[rarity]`
42
+ *
43
+ * `score = Σ slotScore - emptySlotPenalty × emptySlots`, and `byStat` sums the stat term alone. The
44
+ * stat term is `scoreLootStats`, the same weight policy `compareLootItems` uses, so an item swap and
45
+ * a loadout total agree. Ties are exact; nothing here reads a clock or RNG.
46
+ *
47
+ * @capability gear-score score a loadout from stat weights, level, rarity and slot coverage
48
+ */
49
+ export declare function gearScore(set: Readonly<Record<string, GearSlotItem | null | undefined>>, options?: GearScoreOptions): GearScore;
50
+ /** Compare two loadouts; negative means `a` scores lower. */
51
+ export declare function compareGearSets(a: Readonly<Record<string, GearSlotItem | null | undefined>>, b: Readonly<Record<string, GearSlotItem | null | undefined>>, options?: GearScoreOptions): {
52
+ a: GearScore;
53
+ b: GearScore;
54
+ delta: number;
55
+ better: "a" | "b" | "equal";
56
+ };