@jgengine/core 0.25.0 → 0.26.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 (95) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/ai/rvo.d.ts +106 -0
  3. package/dist/ai/rvo.js +581 -0
  4. package/dist/ai/utilityBrain.d.ts +173 -0
  5. package/dist/ai/utilityBrain.js +277 -0
  6. package/dist/combat/cover.d.ts +169 -0
  7. package/dist/combat/cover.js +264 -0
  8. package/dist/combat/damageRuntime.d.ts +150 -0
  9. package/dist/combat/damageRuntime.js +208 -0
  10. package/dist/combat/damageTable.d.ts +116 -0
  11. package/dist/combat/damageTable.js +165 -0
  12. package/dist/combat/hitZones.d.ts +70 -0
  13. package/dist/combat/hitZones.js +77 -0
  14. package/dist/combat/magazine.d.ts +8 -0
  15. package/dist/combat/magazine.js +20 -2
  16. package/dist/combat/squadCombat.d.ts +167 -0
  17. package/dist/combat/squadCombat.js +557 -0
  18. package/dist/combat/squadTactics.d.ts +73 -0
  19. package/dist/combat/squadTactics.js +49 -0
  20. package/dist/combat/statusRuntime.d.ts +142 -0
  21. package/dist/combat/statusRuntime.js +236 -0
  22. package/dist/combat/weaponDefinition.d.ts +106 -0
  23. package/dist/combat/weaponDefinition.js +108 -0
  24. package/dist/combat/weaponSystem.d.ts +189 -0
  25. package/dist/combat/weaponSystem.js +469 -0
  26. package/dist/combat.d.ts +9 -0
  27. package/dist/combat.js +9 -0
  28. package/dist/devtools/devtools.d.ts +4 -2
  29. package/dist/devtools/devtools.js +2 -1
  30. package/dist/devtools/discover.d.ts +2 -1
  31. package/dist/devtools/discover.js +38 -0
  32. package/dist/devtools/frame.d.ts +0 -2
  33. package/dist/devtools/frame.js +1 -2
  34. package/dist/devtools/logBus.d.ts +58 -0
  35. package/dist/devtools/logBus.js +79 -0
  36. package/dist/devtools/logs.d.ts +6 -3
  37. package/dist/devtools/logs.js +17 -13
  38. package/dist/devtools/report.d.ts +120 -0
  39. package/dist/devtools/report.js +49 -0
  40. package/dist/devtools/reportEndpoint.d.ts +17 -0
  41. package/dist/devtools/reportEndpoint.js +7 -0
  42. package/dist/devtools/types.d.ts +48 -11
  43. package/dist/editor/document.js +210 -0
  44. package/dist/editor/environment.d.ts +48 -1
  45. package/dist/editor/environment.js +161 -0
  46. package/dist/editor/index.d.ts +6 -2
  47. package/dist/editor/index.js +1 -1
  48. package/dist/editor/types.d.ts +54 -0
  49. package/dist/gameplay.d.ts +4 -0
  50. package/dist/gameplay.js +4 -0
  51. package/dist/meta/changelog.d.ts +1 -1
  52. package/dist/meta/changelog.js +56 -1
  53. package/dist/nav/navCrowd.d.ts +138 -0
  54. package/dist/nav/navCrowd.js +412 -0
  55. package/dist/nav/navRuntime.d.ts +108 -0
  56. package/dist/nav/navRuntime.js +256 -0
  57. package/dist/render/colorPipeline.d.ts +53 -0
  58. package/dist/render/colorPipeline.js +79 -0
  59. package/dist/render/lightProbes.d.ts +97 -0
  60. package/dist/render/lightProbes.js +221 -0
  61. package/dist/render/lookPreset.js +4 -4
  62. package/dist/render/physicalSky.d.ts +39 -0
  63. package/dist/render/physicalSky.js +89 -0
  64. package/dist/render/postProcessing.d.ts +40 -8
  65. package/dist/render/postProcessing.js +1 -1
  66. package/dist/render/skyOwnedLighting.d.ts +31 -0
  67. package/dist/render/skyOwnedLighting.js +46 -0
  68. package/dist/sensor/perception.d.ts +66 -5
  69. package/dist/sensor/perception.js +167 -42
  70. package/dist/sim/nearAgents.d.ts +68 -0
  71. package/dist/sim/nearAgents.js +152 -0
  72. package/dist/sim/needs.d.ts +60 -0
  73. package/dist/sim/needs.js +108 -0
  74. package/dist/sim/offScreen.d.ts +181 -0
  75. package/dist/sim/offScreen.js +450 -0
  76. package/dist/sim/routine.d.ts +79 -0
  77. package/dist/sim/routine.js +137 -0
  78. package/dist/visibility/visibilitySystem.js +21 -0
  79. package/dist/world/features.d.ts +8 -0
  80. package/dist/world.d.ts +5 -0
  81. package/dist/world.js +5 -0
  82. package/package.json +1 -1
  83. package/skills/jgengine/api.md +59 -6
  84. package/skills/jgengine/capabilities.md +4 -0
  85. package/skills/jgengine-combat/SKILL.md +17 -0
  86. package/skills/jgengine-combat/api.md +246 -0
  87. package/skills/jgengine-combat/capabilities.md +112 -0
  88. package/skills/jgengine-combat/reference.md +32 -0
  89. package/skills/jgengine-gameplay/SKILL.md +8 -0
  90. package/skills/jgengine-gameplay/api.md +71 -1
  91. package/skills/jgengine-gameplay/capabilities.md +16 -0
  92. package/skills/jgengine-world/SKILL.md +3 -1
  93. package/skills/jgengine-world/api.md +107 -3
  94. package/skills/jgengine-world/capabilities.md +22 -2
  95. package/skills/jgengine-world/recipes/npc-senses.md +8 -2
@@ -0,0 +1,264 @@
1
+ import { devtools } from "../devtools/devtools.js";
2
+ const DEFAULT_STAND_OFF = 0.7;
3
+ const DEFAULT_SPACING = 2;
4
+ const DEFAULT_MIN_FACE = 1.2;
5
+ const DEFAULT_MAX_PER_FACE = 3;
6
+ function round(value) {
7
+ return Math.round(value * 100) / 100;
8
+ }
9
+ function addFace(out, solid, normalX, normalZ, tangentX, tangentZ, halfAlong, halfAcross, options) {
10
+ // A spot near a wall's end is not cover: a shooter at an angle sees around the corner, and a body
11
+ // does not stand exactly on the point. Keep stand points clear of the corners by roughly one body.
12
+ const edgeInset = Math.min(options.standOff + 0.4, halfAcross * 0.6);
13
+ const usable = halfAcross - edgeInset;
14
+ if (usable * 2 < options.minFaceLength || usable <= 0)
15
+ return;
16
+ const count = Math.max(1, Math.min(options.maxPerFace, Math.floor((usable * 2) / options.spacing) + 1));
17
+ const step = count === 1 ? 0 : (usable * 2) / (count - 1);
18
+ const faceX = solid.center[0] + normalX * halfAlong;
19
+ const faceZ = solid.center[2] + normalZ * halfAlong;
20
+ for (let i = 0; i < count; i += 1) {
21
+ const offset = count === 1 ? 0 : -usable + step * i;
22
+ const x = faceX + tangentX * offset + normalX * options.standOff;
23
+ const z = faceZ + tangentZ * offset + normalZ * options.standOff;
24
+ const y = options.yOf === undefined ? solid.center[1] - solid.halfExtents[1] : options.yOf(x, z);
25
+ const point = [x, y, z];
26
+ if (options.walkable !== undefined && !options.walkable(point))
27
+ continue;
28
+ out.push({
29
+ id: `${round(x)},${round(z)}|${round(normalX)},${round(normalZ)}`,
30
+ position: point,
31
+ normal: [normalX, normalZ],
32
+ });
33
+ }
34
+ }
35
+ /**
36
+ * Stand points on every vertical face of the supplied boxes: one row per face, spaced along it,
37
+ * `standOff` out from the surface. Geometry in, cover out — no authored coordinate list, so moving
38
+ * a wall in the editor moves its cover.
39
+ *
40
+ * @capability cover-points-from-solids derive stand-able cover positions from authored solid boxes
41
+ */
42
+ export function coverPointsFromSolids(solids, options = {}) {
43
+ const resolved = {
44
+ standOff: Math.max(0, options.standOff ?? DEFAULT_STAND_OFF),
45
+ spacing: Math.max(0.2, options.spacing ?? DEFAULT_SPACING),
46
+ minFaceLength: Math.max(0, options.minFaceLength ?? DEFAULT_MIN_FACE),
47
+ maxPerFace: Math.max(1, Math.floor(options.maxPerFace ?? DEFAULT_MAX_PER_FACE)),
48
+ ...(options.yOf === undefined ? {} : { yOf: options.yOf }),
49
+ ...(options.walkable === undefined ? {} : { walkable: options.walkable }),
50
+ };
51
+ const out = [];
52
+ for (const solid of solids) {
53
+ const yaw = solid.rotationY ?? 0;
54
+ const cos = Math.cos(yaw);
55
+ const sin = Math.sin(yaw);
56
+ // Local +X and +Z in world XZ, matching the engine's rotationY convention.
57
+ const ux = cos;
58
+ const uz = -sin;
59
+ const vx = sin;
60
+ const vz = cos;
61
+ const [hx, , hz] = solid.halfExtents;
62
+ addFace(out, solid, ux, uz, vx, vz, hx, hz, resolved);
63
+ addFace(out, solid, -ux, -uz, vx, vz, hx, hz, resolved);
64
+ addFace(out, solid, vx, vz, ux, uz, hz, hx, resolved);
65
+ addFace(out, solid, -vx, -vz, ux, uz, hz, hx, resolved);
66
+ }
67
+ return out;
68
+ }
69
+ /**
70
+ * The `limit` nearest cover points within `radius`, nearest first, ties broken by id. A linear scan
71
+ * of the points the field holds; the field caps how many points exist.
72
+ *
73
+ * @capability cover-points-near bounded nearest-cover query
74
+ */
75
+ export function coverPointsNear(points, position, radius, limit = Number.POSITIVE_INFINITY) {
76
+ const max = radius * radius;
77
+ const found = [];
78
+ for (const point of points) {
79
+ const dx = point.position[0] - position[0];
80
+ const dz = point.position[2] - position[2];
81
+ const distance = dx * dx + dz * dz;
82
+ if (distance <= max)
83
+ found.push({ point, distance });
84
+ }
85
+ found.sort((a, b) => a.distance - b.distance || (a.point.id < b.point.id ? -1 : a.point.id > b.point.id ? 1 : 0));
86
+ return found.slice(0, Math.max(0, Math.floor(limit))).map((entry) => entry.point);
87
+ }
88
+ /**
89
+ * Cover over authored boxes: derive the stand points once, re-derive them after an edit, and answer
90
+ * bounded nearest-cover queries. `walkable` is injected, so the seam checks the game's own ground
91
+ * test (`nav.findPolygon(point) !== null`) instead of assuming a flat floor.
92
+ *
93
+ * @capability cover-field authored-geometry cover with a bounded nearest query and rebuild on edit
94
+ */
95
+ export function createCoverField(options) {
96
+ let solids = options.solids.map((solid) => ({ ...solid }));
97
+ let tunables = {
98
+ ...(options.standOff === undefined ? {} : { standOff: options.standOff }),
99
+ ...(options.spacing === undefined ? {} : { spacing: options.spacing }),
100
+ ...(options.minFaceLength === undefined ? {} : { minFaceLength: options.minFaceLength }),
101
+ ...(options.maxPerFace === undefined ? {} : { maxPerFace: options.maxPerFace }),
102
+ ...(options.yOf === undefined ? {} : { yOf: options.yOf }),
103
+ ...(options.walkable === undefined ? {} : { walkable: options.walkable }),
104
+ };
105
+ let derived = [];
106
+ let queries = 0;
107
+ function derive() {
108
+ derived = coverPointsFromSolids(solids, tunables);
109
+ }
110
+ derive();
111
+ function debugLine() {
112
+ return `${derived.length} points from ${solids.length} solids · ${queries} queries`;
113
+ }
114
+ const unregister = options.devtoolsName === undefined ? null : devtools.probes.register(options.devtoolsName, () => debugLine());
115
+ return {
116
+ near(position, radius, limit) {
117
+ queries += 1;
118
+ return coverPointsNear(derived, position, radius, limit);
119
+ },
120
+ points: () => derived,
121
+ count: () => derived.length,
122
+ rebuild(next) {
123
+ solids = next.map((solid) => ({ ...solid }));
124
+ derive();
125
+ },
126
+ retune(patch) {
127
+ tunables = { ...tunables, ...patch };
128
+ derive();
129
+ },
130
+ snapshot() {
131
+ return {
132
+ solids: solids.map((solid) => ({ ...solid })),
133
+ standOff: tunables.standOff ?? DEFAULT_STAND_OFF,
134
+ spacing: tunables.spacing ?? DEFAULT_SPACING,
135
+ minFaceLength: tunables.minFaceLength ?? DEFAULT_MIN_FACE,
136
+ maxPerFace: tunables.maxPerFace ?? DEFAULT_MAX_PER_FACE,
137
+ };
138
+ },
139
+ restore(next) {
140
+ solids = next.solids.map((solid) => ({ ...solid }));
141
+ tunables = {
142
+ ...tunables,
143
+ standOff: next.standOff,
144
+ spacing: next.spacing,
145
+ minFaceLength: next.minFaceLength,
146
+ maxPerFace: next.maxPerFace,
147
+ };
148
+ derive();
149
+ },
150
+ dispose() {
151
+ unregister?.();
152
+ },
153
+ };
154
+ }
155
+ const DEFAULT_WEIGHTS = { hidden: 3, range: 1, flank: 1, near: 0.6, crowding: 2, retreat: 0, path: 1.2 };
156
+ function clamp01(value) {
157
+ return value < 0 ? 0 : value > 1 ? 1 : value;
158
+ }
159
+ function wrapAngle(angle) {
160
+ let value = angle;
161
+ while (value > Math.PI)
162
+ value -= Math.PI * 2;
163
+ while (value < -Math.PI)
164
+ value += Math.PI * 2;
165
+ return value;
166
+ }
167
+ function flatDistance(a, b) {
168
+ return Math.hypot(a[0] - b[0], a[2] - b[2]);
169
+ }
170
+ /** True when the whole body at `point` is out of sight from `threat`, not just the point's centre. */
171
+ function hiddenAt(point, threat, bodyRadius, blocked) {
172
+ if (!blocked(point, threat))
173
+ return false;
174
+ if (bodyRadius <= 0)
175
+ return true;
176
+ const dx = threat[0] - point[0];
177
+ const dz = threat[2] - point[2];
178
+ const length = Math.hypot(dx, dz);
179
+ if (length < 1e-6)
180
+ return true;
181
+ const px = (-dz / length) * bodyRadius;
182
+ const pz = (dx / length) * bodyRadius;
183
+ return blocked([point[0] + px, point[1], point[2] + pz], threat) && blocked([point[0] - px, point[1], point[2] - pz], threat);
184
+ }
185
+ /**
186
+ * Score one stand point against the threats the member remembers. `hidden` is the confidence-weighted
187
+ * share of remembered threats that cannot see the point, so a point that hides from the shooter that
188
+ * matters scores over one that hides from a fading one. Returns `null` when nothing it can hide from
189
+ * is worth considering.
190
+ *
191
+ * @capability cover-score threat-driven cover point scoring
192
+ */
193
+ export function scoreCoverPoint(point, query) {
194
+ const minConfidence = query.minConfidence ?? 0.05;
195
+ const weights = { ...DEFAULT_WEIGHTS, ...query.weights };
196
+ const bodyRadius = Math.max(0, query.bodyRadius ?? 0.4);
197
+ let totalConfidence = 0;
198
+ let hidden = 0;
199
+ let nearest = Number.POSITIVE_INFINITY;
200
+ let strongest = null;
201
+ for (const threat of query.threats) {
202
+ if (threat.confidence < minConfidence)
203
+ continue;
204
+ totalConfidence += threat.confidence;
205
+ if (hiddenAt(point.position, threat.position, bodyRadius, query.blocked))
206
+ hidden += threat.confidence;
207
+ const distance = flatDistance(point.position, threat.position);
208
+ if (distance < nearest)
209
+ nearest = distance;
210
+ if (strongest === null || threat.confidence > strongest.confidence || (threat.confidence === strongest.confidence && threat.id < strongest.id)) {
211
+ strongest = threat;
212
+ }
213
+ }
214
+ if (strongest === null || totalConfidence <= 0)
215
+ return null;
216
+ const hiddenShare = hidden / totalConfidence;
217
+ if (query.hiddenOnly === true && hiddenShare <= 0)
218
+ return null;
219
+ const radius = Math.max(query.radius, 0.0001);
220
+ const range = clamp01(1 - Math.abs(nearest - query.preferredRange) / Math.max(query.preferredRange, 1));
221
+ const near = clamp01(1 - flatDistance(query.position, point.position) / radius);
222
+ const retreat = clamp01(nearest / radius);
223
+ let flank = 0;
224
+ const line = query.lineThrough ?? query.position;
225
+ const axisX = line[0] - strongest.position[0];
226
+ const axisZ = line[2] - strongest.position[2];
227
+ const axisLength = Math.hypot(axisX, axisZ);
228
+ if (axisLength > 0.0001) {
229
+ const dirX = point.position[0] - strongest.position[0];
230
+ const dirZ = point.position[2] - strongest.position[2];
231
+ const dirLength = Math.hypot(dirX, dirZ);
232
+ if (dirLength > 0.0001) {
233
+ const offset = Math.abs(wrapAngle(Math.atan2(dirX, dirZ) - Math.atan2(axisX, axisZ)));
234
+ flank = clamp01(1 - Math.abs(offset - (query.flankAngle ?? 0)) / Math.PI);
235
+ }
236
+ }
237
+ const crowding = query.taken?.has(point.id) === true ? 1 : 0;
238
+ const path = query.blocked(query.position, point.position) ? 1 : 0;
239
+ const score = weights.hidden * hiddenShare +
240
+ weights.range * range +
241
+ weights.flank * flank +
242
+ weights.near * near +
243
+ weights.retreat * retreat -
244
+ weights.crowding * crowding -
245
+ weights.path * path;
246
+ return { point, score, hidden: hiddenShare, range, flank, near, retreat, path };
247
+ }
248
+ /**
249
+ * The best of `points` under one {@link CoverQuery}, ties broken by point id. Pass the candidate set
250
+ * the injected query already bounded; this scores what it is given.
251
+ *
252
+ * @capability cover-select select cover by threat, role range and flank angle
253
+ */
254
+ export function selectCover(points, query) {
255
+ let best = null;
256
+ for (const point of points) {
257
+ const choice = scoreCoverPoint(point, query);
258
+ if (choice === null)
259
+ continue;
260
+ if (best === null || choice.score > best.score || (choice.score === best.score && choice.point.id < best.point.id))
261
+ best = choice;
262
+ }
263
+ return best;
264
+ }
@@ -0,0 +1,150 @@
1
+ import type { StatusApplicationOutcome, StatusInstance } from "./statusApplication.js";
2
+ import { readDamageTable, type DamageTableDef, type DamageTableRow } from "./damageTable.js";
3
+ import { type HitZoneResolution, type HitZoneTable } from "./hitZones.js";
4
+ import { type StatusDef, type StatusRuntimeState, type StatusTickEvent } from "./statusRuntime.js";
5
+ /** What a target presents to the table: one resistance class and its armor points. */
6
+ export interface TargetProfile {
7
+ /** Resistance class (`"flesh"`, `"shield"`, `"armor"`) the target presents. */
8
+ resistance: string;
9
+ /** Armor points before any carried armor-shred statuses. Default `0`. */
10
+ armor?: number;
11
+ }
12
+ /** One status a hit tries to apply, scaled against that status's own base chance and values. */
13
+ export interface DamageStatusRequest {
14
+ id: string;
15
+ chanceScale?: number;
16
+ durationScale?: number;
17
+ magnitudeScale?: number;
18
+ }
19
+ /** One incoming hit: what struck, how hard, where it landed, and what statuses it carries. */
20
+ export interface DamageRuntimeHitInput {
21
+ target: string;
22
+ type: string;
23
+ /** Damage before the zone multiplier, the table, and armor. */
24
+ base: number;
25
+ /** Hit zone id; omitted uses the table's `defaultZone`. */
26
+ zone?: string;
27
+ source?: string;
28
+ statuses?: readonly (string | DamageStatusRequest)[];
29
+ }
30
+ /** Every stage of one resolved hit, so a UI or a test can read why the final number is what it is. */
31
+ export interface DamageRuntimeResolution {
32
+ target: string;
33
+ type: string;
34
+ base: number;
35
+ zone: HitZoneResolution;
36
+ /** Type × resistance multiplier before armor. */
37
+ multiplier: number;
38
+ /** Armor after carried shred statuses, never below zero. */
39
+ armor: number;
40
+ /** Fraction of the hit armor removes at `armor`. */
41
+ mitigation: number;
42
+ /** `base × zone.multiplier × multiplier × (1 − mitigation)`. */
43
+ damage: number;
44
+ statuses: StatusApplicationOutcome[];
45
+ }
46
+ /** A quoted time to kill for one fixed hit against one health pool. */
47
+ export interface TimeToKill {
48
+ damagePerShot: number;
49
+ /** Hits needed, or `Infinity` when the hit deals nothing. */
50
+ shots: number;
51
+ /** Seconds from the first hit to the last: `(shots - 1) × intervalSeconds`. */
52
+ seconds: number;
53
+ intervalSeconds: number;
54
+ }
55
+ /** Serialized model state: registered targets, carried statuses, and the probe counters. */
56
+ export interface DamageRuntimeState {
57
+ targets: Array<{
58
+ id: string;
59
+ } & TargetProfile>;
60
+ statuses: StatusRuntimeState;
61
+ hits: number;
62
+ crits: number;
63
+ dealt: number;
64
+ last: {
65
+ type: string;
66
+ base: number;
67
+ damage: number;
68
+ zone: string;
69
+ crit: boolean;
70
+ } | null;
71
+ }
72
+ /** Everything the model reads: the table, the zones, the status definitions, and the RNG. */
73
+ export interface DamageRuntimeConfig {
74
+ table: DamageTableDef;
75
+ zones: HitZoneTable;
76
+ statuses?: readonly StatusDef[];
77
+ /** Stat id carried armor-shred statuses reduce. Default `"armor"`. */
78
+ armorStat?: string;
79
+ /** Injected `[0,1)` stream for crit rolls. Omitting it warns once and is not replayable. */
80
+ rng?: () => number;
81
+ /** Register a one-line devtools probe under this name. */
82
+ devtoolsName?: string;
83
+ }
84
+ /** In-play dials, all retunable without discarding registered targets or carried statuses. */
85
+ export interface DamageRuntimeRetune {
86
+ table?: DamageTableDef;
87
+ zones?: HitZoneTable;
88
+ statuses?: readonly StatusDef[];
89
+ armorStat?: string;
90
+ }
91
+ /**
92
+ * A damage model: registered targets, the table/zones/statuses they are hit with, and carried
93
+ * status state. `hit` resolves a blow, `tick` runs status effects, `timeToKill` quotes a fight, and
94
+ * `table` reads the whole chart. State is plain data, so a save, a replay, or a worker can move it.
95
+ */
96
+ export interface DamageRuntime {
97
+ /** Register or replace a target's resistance class and armor. */
98
+ registerTarget(id: string, profile: TargetProfile): void;
99
+ removeTarget(id: string): void;
100
+ target(id: string): TargetProfile | null;
101
+ /** Resolve one hit and apply any statuses it carries. */
102
+ hit(input: DamageRuntimeHitInput): DamageRuntimeResolution;
103
+ /** Apply one status directly (an ability, a hazard) without a damage hit. */
104
+ applyStatus(target: string, statusId: string, options?: DamageStatusRequest & {
105
+ source?: string;
106
+ }): StatusApplicationOutcome;
107
+ /** Advance carried statuses; damage ticks are resolved through this model's table and armor. */
108
+ tick(dtSeconds: number): StatusTickEvent[];
109
+ /** Quote a fixed hit against a health pool; pass `crit` explicitly so the number never depends on a roll. */
110
+ timeToKill(input: {
111
+ target: string;
112
+ type: string;
113
+ base: number;
114
+ hp: number;
115
+ intervalSeconds: number;
116
+ zone?: string;
117
+ crit?: boolean;
118
+ }): TimeToKill;
119
+ /** Read the whole table with armor mitigation, optionally at given armor values and base damage. */
120
+ table(read?: Parameters<typeof readDamageTable>[1]): DamageTableRow[];
121
+ retune(patch: DamageRuntimeRetune): void;
122
+ snapshot(): DamageRuntimeState;
123
+ restore(next: DamageRuntimeState): void;
124
+ /** Carried statuses, for a HUD or a test. */
125
+ statusesOf(targetId: string): readonly StatusInstance[];
126
+ stats(): {
127
+ targets: number;
128
+ hits: number;
129
+ crits: number;
130
+ statusTicks: number;
131
+ dealt: number;
132
+ };
133
+ /** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
134
+ dispose(): void;
135
+ }
136
+ /** Raised when a hit names a target that was never registered — a wiring error, not a 0-damage hit.
137
+ * @capability damage-target-unknown reject a hit on a target with no registered resistance or armor profile
138
+ */
139
+ export declare class UnknownTargetProfileError extends Error {
140
+ readonly target: string;
141
+ constructor(target: string);
142
+ }
143
+ /**
144
+ * Build a damage model from data. The number a hit lands is
145
+ * `base × zone multiplier × type multiplier × (1 − armor mitigation)`; statuses tick through the
146
+ * same table and armor, so a burn on an armored target is mitigated like a bullet.
147
+ *
148
+ * @capability damage-runtime resolve hits through a damage table, hit zones, and armor, and tick data-defined statuses with declared stacking
149
+ */
150
+ export declare function createDamageRuntime(config: DamageRuntimeConfig): DamageRuntime;
@@ -0,0 +1,208 @@
1
+ import { devtools } from "../devtools/devtools.js";
2
+ import { resolveRng } from "../random/resolveRng.js";
3
+ import { assertDamageTable, readDamageTable, resolveTableDamage, } from "./damageTable.js";
4
+ import { assertHitZoneTable, hitZoneMultiplier, resolveHitZone, } from "./hitZones.js";
5
+ import { createStatusRuntime, } from "./statusRuntime.js";
6
+ /** Raised when a hit names a target that was never registered — a wiring error, not a 0-damage hit.
7
+ * @capability damage-target-unknown reject a hit on a target with no registered resistance or armor profile
8
+ */
9
+ export class UnknownTargetProfileError extends Error {
10
+ target;
11
+ constructor(target) {
12
+ super(`No damage profile registered for target: ${target}`);
13
+ this.name = "UnknownTargetProfileError";
14
+ this.target = target;
15
+ }
16
+ }
17
+ /**
18
+ * Build a damage model from data. The number a hit lands is
19
+ * `base × zone multiplier × type multiplier × (1 − armor mitigation)`; statuses tick through the
20
+ * same table and armor, so a burn on an armored target is mitigated like a bullet.
21
+ *
22
+ * @capability damage-runtime resolve hits through a damage table, hit zones, and armor, and tick data-defined statuses with declared stacking
23
+ */
24
+ export function createDamageRuntime(config) {
25
+ assertDamageTable(config.table);
26
+ assertHitZoneTable(config.zones);
27
+ const rng = resolveRng(config.rng, "createDamageRuntime");
28
+ let table = config.table;
29
+ let zones = config.zones;
30
+ let armorStat = config.armorStat ?? "armor";
31
+ const targets = new Map();
32
+ let hits = 0;
33
+ let crits = 0;
34
+ let dealt = 0;
35
+ let statusTicks = 0;
36
+ let last = null;
37
+ let statusRuntime;
38
+ statusRuntime = createStatusRuntime({
39
+ statuses: config.statuses ?? [],
40
+ rng,
41
+ maxTicksPerCall: 8,
42
+ applyDamage: (tick) => resolveStatusDamage(tick),
43
+ });
44
+ function profileOf(targetId) {
45
+ const profile = targets.get(targetId);
46
+ if (profile === undefined)
47
+ throw new UnknownTargetProfileError(targetId);
48
+ return profile;
49
+ }
50
+ /** Base armor plus any carried shred, never negative. */
51
+ function armorOf(targetId, profile) {
52
+ return Math.max(0, (profile.armor ?? 0) + statusRuntime.statDelta(targetId, armorStat));
53
+ }
54
+ /** Resolve a raw amount through the type table and the target's effective armor. */
55
+ function scaleAt(targetId, type, base) {
56
+ const profile = profileOf(targetId);
57
+ const armor = armorOf(targetId, profile);
58
+ return { ...resolveTableDamage(table, { type, resistance: profile.resistance, armor, base }), armor };
59
+ }
60
+ function resolveStatusDamage(tick) {
61
+ if (!targets.has(tick.target))
62
+ return tick.amount;
63
+ return scaleAt(tick.target, tick.channel, tick.amount).damage ?? 0;
64
+ }
65
+ function probeLine() {
66
+ const l = last;
67
+ const tail = l === null
68
+ ? "no hits"
69
+ : `last ${l.type} ${l.base.toFixed(1)}→${l.damage.toFixed(1)} ${l.zone}${l.crit ? " crit" : ""}`;
70
+ return `${targets.size} targets · ${hits} hits ${crits} crits · ${statusTicks} status ticks · ${dealt.toFixed(0)} dealt · ${tail}`;
71
+ }
72
+ const unregister = config.devtoolsName === undefined
73
+ ? null
74
+ : devtools.probes.register(config.devtoolsName, () => probeLine());
75
+ return {
76
+ registerTarget(id, profile) {
77
+ targets.set(id, { resistance: profile.resistance, armor: Math.max(0, profile.armor ?? 0) });
78
+ },
79
+ removeTarget(id) {
80
+ targets.delete(id);
81
+ statusRuntime.clear(id);
82
+ },
83
+ target(id) {
84
+ const profile = targets.get(id);
85
+ return profile === undefined ? null : { ...profile };
86
+ },
87
+ hit(input) {
88
+ const zone = resolveHitZone(zones, input.zone, rng);
89
+ const row = scaleAt(input.target, input.type, input.base * zone.multiplier);
90
+ const applied = [];
91
+ for (const request of input.statuses ?? []) {
92
+ const statusId = typeof request === "string" ? request : request.id;
93
+ const options = typeof request === "string"
94
+ ? {}
95
+ : {
96
+ ...(request.chanceScale !== undefined ? { chanceScale: request.chanceScale } : {}),
97
+ ...(request.durationScale !== undefined ? { durationScale: request.durationScale } : {}),
98
+ ...(request.magnitudeScale !== undefined ? { magnitudeScale: request.magnitudeScale } : {}),
99
+ };
100
+ applied.push(statusRuntime.apply(input.target, statusId, {
101
+ ...options,
102
+ ...(input.source !== undefined ? { source: input.source } : {}),
103
+ }));
104
+ }
105
+ const damage = row.damage ?? 0;
106
+ hits += 1;
107
+ if (zone.crit)
108
+ crits += 1;
109
+ dealt += damage;
110
+ last = { type: input.type, base: input.base, damage, zone: zone.zone, crit: zone.crit };
111
+ return {
112
+ target: input.target,
113
+ type: input.type,
114
+ base: input.base,
115
+ zone,
116
+ multiplier: row.multiplier,
117
+ armor: row.armor,
118
+ mitigation: row.mitigation,
119
+ damage,
120
+ statuses: applied,
121
+ };
122
+ },
123
+ applyStatus(target, statusId, options) {
124
+ return statusRuntime.apply(target, statusId, options === undefined ? {} : { ...options });
125
+ },
126
+ tick(dtSeconds) {
127
+ const events = statusRuntime.tick(dtSeconds);
128
+ for (const event of events) {
129
+ if (event.kind === "damage") {
130
+ statusTicks += 1;
131
+ dealt += event.dealt;
132
+ }
133
+ }
134
+ return events;
135
+ },
136
+ timeToKill(input) {
137
+ const profile = profileOf(input.target);
138
+ const zoneResolved = hitZoneMultiplier(zones, input.zone, input.crit === true);
139
+ const row = resolveTableDamage(table, {
140
+ type: input.type,
141
+ resistance: profile.resistance,
142
+ armor: armorOf(input.target, profile),
143
+ base: input.base * zoneResolved.multiplier,
144
+ });
145
+ const damagePerShot = row.damage ?? 0;
146
+ const intervalSeconds = Math.max(0, input.intervalSeconds);
147
+ const shots = damagePerShot <= 0
148
+ ? Number.POSITIVE_INFINITY
149
+ // The epsilon keeps a hit that exactly empties the pool from reading as needing one more
150
+ // because of floating-point noise in the product of four factors.
151
+ : Math.ceil(Math.max(0, input.hp) / damagePerShot - 1e-9);
152
+ return {
153
+ damagePerShot,
154
+ shots,
155
+ seconds: Number.isFinite(shots) ? Math.max(0, shots - 1) * intervalSeconds : Number.POSITIVE_INFINITY,
156
+ intervalSeconds,
157
+ };
158
+ },
159
+ table(read) {
160
+ return readDamageTable(table, read);
161
+ },
162
+ retune(patch) {
163
+ if (patch.table !== undefined) {
164
+ assertDamageTable(patch.table);
165
+ table = patch.table;
166
+ }
167
+ if (patch.zones !== undefined) {
168
+ assertHitZoneTable(patch.zones);
169
+ zones = patch.zones;
170
+ }
171
+ if (patch.statuses !== undefined)
172
+ statusRuntime.retune(patch.statuses);
173
+ if (patch.armorStat !== undefined)
174
+ armorStat = patch.armorStat;
175
+ },
176
+ snapshot() {
177
+ return {
178
+ targets: [...targets.entries()].map(([id, profile]) => ({
179
+ id,
180
+ resistance: profile.resistance,
181
+ armor: profile.armor ?? 0,
182
+ })),
183
+ statuses: statusRuntime.snapshot(),
184
+ hits,
185
+ crits,
186
+ dealt,
187
+ last: last === null ? null : { ...last },
188
+ };
189
+ },
190
+ restore(next) {
191
+ targets.clear();
192
+ for (const profile of next.targets) {
193
+ targets.set(profile.id, { resistance: profile.resistance, armor: profile.armor });
194
+ }
195
+ statusRuntime.restore(next.statuses);
196
+ hits = next.hits;
197
+ crits = next.crits;
198
+ dealt = next.dealt;
199
+ last = next.last === null ? null : { ...next.last };
200
+ },
201
+ statusesOf: (targetId) => statusRuntime.statusesOf(targetId),
202
+ stats: () => ({ targets: targets.size, hits, crits, statusTicks, dealt }),
203
+ dispose() {
204
+ statusRuntime.dispose();
205
+ unregister?.();
206
+ },
207
+ };
208
+ }