@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
@@ -0,0 +1,326 @@
1
+ /**
2
+ * The engine's one humanoid skeleton standard, and the path any other CC0 rig takes onto it.
3
+ *
4
+ * The standard is the **KayKit character rig**: one armature shared by every rigged character in
5
+ * `kaykit-adventurers` (Barbarian, Knight, Mage, Rogue, Rogue_Hooded) and `kaykit-skeletons`
6
+ * (Skeleton_Mage/Minion/Rogue/Warrior). Because they share it, all nine characters play the same
7
+ * clip set and one authored graph, foot-IK rig, attachment slot or ragdoll layout serves all of
8
+ * them. There is no second standard to keep in sync.
9
+ *
10
+ * A rig on a different scheme (Quaternius aliens, Mixamo exports) is not rewritten: its bones are
11
+ * *mapped* slot by slot onto the standard's names, and what it does not have is reported rather
12
+ * than guessed. A rig missing every leg slot can still be driven on its spine and head; a rig
13
+ * missing a slot never silently borrows a neighbour's bone.
14
+ */
15
+ /** Every standard slot, in parent-before-child order. */
16
+ export const SKELETON_SLOTS = [
17
+ "root",
18
+ "hips",
19
+ "spine",
20
+ "chest",
21
+ "head",
22
+ "upperArm.l",
23
+ "upperArm.r",
24
+ "lowerArm.l",
25
+ "lowerArm.r",
26
+ "hand.l",
27
+ "hand.r",
28
+ "handSlot.l",
29
+ "handSlot.r",
30
+ "upperLeg.l",
31
+ "upperLeg.r",
32
+ "lowerLeg.l",
33
+ "lowerLeg.r",
34
+ "foot.l",
35
+ "foot.r",
36
+ "toes.l",
37
+ "toes.r",
38
+ ];
39
+ /** The parent slot of each slot that has one; `root` has none. */
40
+ export const SKELETON_PARENT = {
41
+ hips: "root",
42
+ spine: "hips",
43
+ chest: "spine",
44
+ head: "chest",
45
+ "upperArm.l": "chest",
46
+ "upperArm.r": "chest",
47
+ "lowerArm.l": "upperArm.l",
48
+ "lowerArm.r": "upperArm.r",
49
+ "hand.l": "lowerArm.l",
50
+ "hand.r": "lowerArm.r",
51
+ "handSlot.l": "hand.l",
52
+ "handSlot.r": "hand.r",
53
+ "upperLeg.l": "hips",
54
+ "upperLeg.r": "hips",
55
+ "lowerLeg.l": "upperLeg.l",
56
+ "lowerLeg.r": "upperLeg.r",
57
+ "foot.l": "lowerLeg.l",
58
+ "foot.r": "lowerLeg.r",
59
+ "toes.l": "foot.l",
60
+ "toes.r": "foot.r",
61
+ };
62
+ /**
63
+ * The one humanoid skeleton standard. Bone names are the authored KayKit names; the loaded
64
+ * hierarchy spells them without `.` and `resolveRigNode` accepts either.
65
+ *
66
+ * @capability skeleton-standard the shared humanoid bone standard every rigged character maps onto
67
+ */
68
+ export const SKELETON_STANDARD = {
69
+ id: "kaykit-humanoid",
70
+ label: "KayKit character rig (adventurers + skeletons)",
71
+ slots: {
72
+ root: "root",
73
+ hips: "hips",
74
+ spine: "spine",
75
+ chest: "chest",
76
+ head: "head",
77
+ "upperArm.l": "upperarm.l",
78
+ "upperArm.r": "upperarm.r",
79
+ "lowerArm.l": "lowerarm.l",
80
+ "lowerArm.r": "lowerarm.r",
81
+ "hand.l": "hand.l",
82
+ "hand.r": "hand.r",
83
+ "handSlot.l": "handslot.l",
84
+ "handSlot.r": "handslot.r",
85
+ "upperLeg.l": "upperleg.l",
86
+ "upperLeg.r": "upperleg.r",
87
+ "lowerLeg.l": "lowerleg.l",
88
+ "lowerLeg.r": "lowerleg.r",
89
+ "foot.l": "foot.l",
90
+ "foot.r": "foot.r",
91
+ "toes.l": "toes.l",
92
+ "toes.r": "toes.r",
93
+ },
94
+ };
95
+ /** Prefixes stripped from a bone name before matching: exporter scopes, not rig vocabulary. */
96
+ const NAME_PREFIXES = ["mixamorig", "armature"];
97
+ /**
98
+ * The standard itself, as a scheme: a KayKit rig already is the standard, so its own names are the
99
+ * candidates. Both authored (`upperleg.l`) and runtime (`upperlegl`) spellings are accepted.
100
+ *
101
+ * @capability skeleton-standard map a rig on the standard scheme onto the standard's slots
102
+ */
103
+ export const KAYKIT_SCHEME = {
104
+ id: "kaykit-humanoid",
105
+ label: "KayKit character rig",
106
+ source: "KayKit Adventurers / Skeletons (CC0)",
107
+ slots: Object.fromEntries(SKELETON_SLOTS.filter((slot) => SKELETON_STANDARD.slots[slot] !== undefined).map((slot) => {
108
+ const authored = SKELETON_STANDARD.slots[slot];
109
+ return [slot, [authored, runtimeBoneName(authored)]];
110
+ })),
111
+ };
112
+ /**
113
+ * Mixamo exports (`mixamorig*` names, or the same names without the prefix). Mixamo has a neck and
114
+ * a shoulder per side, which the standard folds into `chest` and `upperArm`; its toe is
115
+ * `ToeBase`, which is the standard's `toes`. Mixamo's topmost bone is `Hips`, so `root` and `hips`
116
+ * both resolve to it — a rig on this scheme has no bone above the pelvis.
117
+ */
118
+ export const MIXAMO_SCHEME = {
119
+ id: "mixamo",
120
+ label: "Mixamo humanoid",
121
+ source: "Mixamo auto-rig exports (per-asset licence)",
122
+ slots: {
123
+ root: ["Hips"],
124
+ hips: ["Hips"],
125
+ spine: ["Spine"],
126
+ chest: ["Spine2", "Spine1"],
127
+ head: ["Head"],
128
+ "upperArm.l": ["LeftArm"],
129
+ "upperArm.r": ["RightArm"],
130
+ "lowerArm.l": ["LeftForeArm"],
131
+ "lowerArm.r": ["RightForeArm"],
132
+ "hand.l": ["LeftHand"],
133
+ "hand.r": ["RightHand"],
134
+ "upperLeg.l": ["LeftUpLeg"],
135
+ "upperLeg.r": ["RightUpLeg"],
136
+ "lowerLeg.l": ["LeftLeg"],
137
+ "lowerLeg.r": ["RightLeg"],
138
+ "foot.l": ["LeftFoot"],
139
+ "foot.r": ["RightFoot"],
140
+ "toes.l": ["LeftToeBase"],
141
+ "toes.r": ["RightToeBase"],
142
+ },
143
+ };
144
+ /**
145
+ * Quaternius sci-fi aliens: a `Root` plus a `Spine_1..Spine_6` chain, a `Neck` and a `Head`. The
146
+ * pack ships no arms and no legs, so those slots stay empty and the alien can only be driven on
147
+ * its spine and head. That is the honest mapping: the pack's own clip set is `Alien_Idle` alone.
148
+ */
149
+ export const QUATERNIUS_ALIEN_SCHEME = {
150
+ id: "quaternius-alien",
151
+ label: "Quaternius sci-fi alien",
152
+ source: "Quaternius Modular Sci-Fi (CC0)",
153
+ slots: {
154
+ root: ["Root"],
155
+ hips: ["Spine_1"],
156
+ spine: ["Spine_3", "Spine_2"],
157
+ chest: ["Spine_5", "Spine_4"],
158
+ head: ["Head"],
159
+ },
160
+ };
161
+ /** Every scheme the engine ships a mapping for, keyed by {@link RigScheme.id}. */
162
+ export const RIG_SCHEMES = {
163
+ [KAYKIT_SCHEME.id]: KAYKIT_SCHEME,
164
+ [MIXAMO_SCHEME.id]: MIXAMO_SCHEME,
165
+ [QUATERNIUS_ALIEN_SCHEME.id]: QUATERNIUS_ALIEN_SCHEME,
166
+ };
167
+ /**
168
+ * The runtime name GLTFLoader gives an authored bone name: three's `PropertyBinding.sanitizeNodeName`
169
+ * strips `[ ] . : /`, so the authored `upperleg.l` loads as `upperlegl`.
170
+ *
171
+ * @capability skeleton-standard convert an authored bone name to its loaded runtime name
172
+ */
173
+ export function runtimeBoneName(authored) {
174
+ return authored.replace(/[[\].:/]/g, "");
175
+ }
176
+ /** Lowercase alphanumerics of a bone name, with exporter scope prefixes removed. */
177
+ function matchKey(name) {
178
+ let key = name.toLowerCase().replace(/[^a-z0-9]/g, "");
179
+ for (const prefix of NAME_PREFIXES) {
180
+ if (key.startsWith(prefix) && key.length > prefix.length)
181
+ key = key.slice(prefix.length);
182
+ }
183
+ return key;
184
+ }
185
+ /**
186
+ * Maps a rig's bone names onto {@link SKELETON_STANDARD} through one {@link RigScheme}. Each slot
187
+ * takes the first candidate that resolves; a candidate list is tried in order, so a scheme can name
188
+ * a preferred bone and fall back. Bones that fill no slot come back in `unmapped`, and slots the rig
189
+ * cannot fill in `missing` — the caller decides whether a partial map is usable.
190
+ *
191
+ * @capability skeleton-standard map any CC0 rig's bones onto the shared humanoid slots
192
+ */
193
+ export function mapRigToStandard(bones, scheme = KAYKIT_SCHEME) {
194
+ const resolved = typeof scheme === "string" ? RIG_SCHEMES[scheme] : scheme;
195
+ if (resolved === undefined)
196
+ throw new Error(`unknown rig scheme "${String(scheme)}"; known: ${Object.keys(RIG_SCHEMES).join(", ")}`);
197
+ const byKey = new Map();
198
+ for (const bone of bones) {
199
+ const key = matchKey(bone);
200
+ if (key.length > 0 && !byKey.has(key))
201
+ byKey.set(key, bone);
202
+ }
203
+ const slots = {};
204
+ const missing = [];
205
+ const used = new Set();
206
+ for (const slot of SKELETON_SLOTS) {
207
+ const candidates = resolved.slots[slot];
208
+ if (candidates === undefined) {
209
+ missing.push(slot);
210
+ continue;
211
+ }
212
+ let found;
213
+ for (const candidate of candidates) {
214
+ const bone = byKey.get(matchKey(candidate));
215
+ if (bone !== undefined) {
216
+ found = bone;
217
+ break;
218
+ }
219
+ }
220
+ if (found === undefined)
221
+ missing.push(slot);
222
+ else {
223
+ slots[slot] = found;
224
+ used.add(found);
225
+ }
226
+ }
227
+ return { scheme: resolved.id, slots, missing, unmapped: bones.filter((bone) => !used.has(bone)) };
228
+ }
229
+ /**
230
+ * Per-slot length ratios that stretch a source rig's bone chain onto the standard's proportions, so
231
+ * a retargeted clip moves both rigs the same distance rather than the same angle. Slots with no
232
+ * counterpart on either side are omitted; a source length of zero is skipped instead of dividing.
233
+ *
234
+ * @capability skeleton-standard scale a retargeted clip to the target rig's bone proportions
235
+ */
236
+ export function retargetBoneScales(source, target) {
237
+ const targetBySlot = new Map(target.map((entry) => [entry.slot, entry.length]));
238
+ const scales = {};
239
+ for (const entry of source) {
240
+ const to = targetBySlot.get(entry.slot);
241
+ if (to === undefined || entry.length <= 0 || to <= 0)
242
+ continue;
243
+ scales[entry.slot] = to / entry.length;
244
+ }
245
+ return scales;
246
+ }
247
+ /** Track-name property suffixes three writes on an `AnimationClip`. */
248
+ const TRACK_PROPERTIES = ["quaternion", "position", "scale", "morphTargetInfluences"];
249
+ /**
250
+ * Bone-name prefixes for a set of standard slots, for an {@link AnimLayer.mask}. A slot on one side
251
+ * contributes its side-less prefix (`upperLeg.l` → `upperleg`), which matches both sides' tracks; an
252
+ * unsided slot contributes its own runtime name. Masking by slot means a rig mapped onto the
253
+ * standard is masked without hand-typing its bone names.
254
+ *
255
+ * @capability skeleton-standard build a layer mask from the standard's own slots
256
+ */
257
+ export function standardBoneMask(slots) {
258
+ const out = new Set();
259
+ for (const slot of slots) {
260
+ const bone = SKELETON_STANDARD.slots[slot];
261
+ if (bone === undefined)
262
+ continue;
263
+ const sided = slot.endsWith(".l") || slot.endsWith(".r");
264
+ out.add(sided ? slot.slice(0, -2).toLowerCase() : runtimeBoneName(bone));
265
+ }
266
+ return [...out];
267
+ }
268
+ /** Standard slots an upper-body action layer drives: spine, chest, head and both arms. */
269
+ export const UPPER_BODY_SLOTS = [
270
+ "spine",
271
+ "chest",
272
+ "head",
273
+ "upperArm.l",
274
+ "upperArm.r",
275
+ "lowerArm.l",
276
+ "lowerArm.r",
277
+ "hand.l",
278
+ "hand.r",
279
+ "handSlot.l",
280
+ "handSlot.r",
281
+ ];
282
+ /** Standard slots a lower-body layer drives: hips, both legs and both feet. */
283
+ export const LOWER_BODY_SLOTS = [
284
+ "hips",
285
+ "upperLeg.l",
286
+ "upperLeg.r",
287
+ "lowerLeg.l",
288
+ "lowerLeg.r",
289
+ "foot.l",
290
+ "foot.r",
291
+ "toes.l",
292
+ "toes.r",
293
+ ];
294
+ /** Layer mask for an upper-body action layer (aim, reload, hit reaction) on a standard rig. */
295
+ export const UPPER_BODY_MASK = standardBoneMask(UPPER_BODY_SLOTS);
296
+ /** Layer mask for a lower-body layer on a standard rig. */
297
+ export const LOWER_BODY_MASK = standardBoneMask(LOWER_BODY_SLOTS);
298
+ /**
299
+ * Renames a clip's tracks from a rig's own bone names onto the standard's, so one authored clip
300
+ * plays on a mapped rig. The emitted node is the **runtime** bone name (see {@link runtimeBoneName}),
301
+ * which is what both the loaded scene graph and the loaded clips spell. A track whose node fills no
302
+ * slot is dropped (`null` in the result) rather than left pointing at a bone the rig does not have;
303
+ * a track whose property is not one of the four three writes is dropped too, since renaming it
304
+ * would not make it resolvable.
305
+ *
306
+ * @capability skeleton-standard rename an animation clip's tracks onto a mapped rig
307
+ */
308
+ export function retargetTracks(tracks, map) {
309
+ const bySlot = new Map();
310
+ for (const slot of SKELETON_SLOTS) {
311
+ const bone = map.slots[slot];
312
+ if (bone !== undefined)
313
+ bySlot.set(matchKey(bone), slot);
314
+ }
315
+ return tracks.map((track) => {
316
+ const split = track.lastIndexOf(".");
317
+ if (split <= 0)
318
+ return null;
319
+ const slot = bySlot.get(matchKey(track.slice(0, split)));
320
+ const property = track.slice(split + 1);
321
+ if (slot === undefined || !TRACK_PROPERTIES.includes(property))
322
+ return null;
323
+ const target = SKELETON_STANDARD.slots[slot];
324
+ return target === undefined ? null : `${runtimeBoneName(target)}.${property}`;
325
+ });
326
+ }
@@ -1,3 +1,4 @@
1
+ import type { DamageAppliedEvent, GameEvents } from "../game/events.js";
1
2
  import type { StatusApplicationOutcome, StatusInstance } from "./statusApplication.js";
2
3
  import { readDamageTable, type DamageTableDef, type DamageTableRow } from "./damageTable.js";
3
4
  import { type HitZoneResolution, type HitZoneTable } from "./hitZones.js";
@@ -78,6 +79,14 @@ export interface DamageRuntimeConfig {
78
79
  armorStat?: string;
79
80
  /** Injected `[0,1)` stream for crit rolls. Omitting it warns once and is not replayable. */
80
81
  rng?: () => number;
82
+ /** Bus every applied damage is announced on as `combat.damage`. Omitted, events reach only the probe. */
83
+ events?: GameEvents;
84
+ /**
85
+ * A target's live health, read once per application before the caller applies it. The event's
86
+ * `remaining` is that value minus the damage dealt, and `lethal` is `remaining <= 0`; omit the
87
+ * reader and every event reports `remaining: null` and `lethal: false`.
88
+ */
89
+ healthOf?: (targetId: string) => number | null;
81
90
  /** Register a one-line devtools probe under this name. */
82
91
  devtoolsName?: string;
83
92
  }
@@ -123,6 +132,8 @@ export interface DamageRuntime {
123
132
  restore(next: DamageRuntimeState): void;
124
133
  /** Carried statuses, for a HUD or a test. */
125
134
  statusesOf(targetId: string): readonly StatusInstance[];
135
+ /** The last `limit` damage applications this model announced, oldest first. Default all it keeps. */
136
+ recentDamage(limit?: number): readonly DamageAppliedEvent[];
126
137
  stats(): {
127
138
  targets: number;
128
139
  hits: number;
@@ -143,7 +154,9 @@ export declare class UnknownTargetProfileError extends Error {
143
154
  /**
144
155
  * Build a damage model from data. The number a hit lands is
145
156
  * `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.
157
+ * same table and armor, so a burn on an armored target is mitigated like a bullet. Every application
158
+ * — a hit or a status tick — is announced as `combat.damage` carrying the target, source, type,
159
+ * amount, absorbed, remaining health and a lethal flag, so a HUD names the blow instead of guessing.
147
160
  *
148
161
  * @capability damage-runtime resolve hits through a damage table, hit zones, and armor, and tick data-defined statuses with declared stacking
149
162
  */
@@ -3,6 +3,10 @@ import { resolveRng } from "../random/resolveRng.js";
3
3
  import { assertDamageTable, readDamageTable, resolveTableDamage, } from "./damageTable.js";
4
4
  import { assertHitZoneTable, hitZoneMultiplier, resolveHitZone, } from "./hitZones.js";
5
5
  import { createStatusRuntime, } from "./statusRuntime.js";
6
+ /** How many recent damage applications the runtime keeps for `recentDamage` and its probe line. */
7
+ const RECENT_DAMAGE_LIMIT = 8;
8
+ /** Most applications the probe line spells out; the ring keeps more than it prints. */
9
+ const RECENT_DAMAGE_PROBE_LINES = 3;
6
10
  /** Raised when a hit names a target that was never registered — a wiring error, not a 0-damage hit.
7
11
  * @capability damage-target-unknown reject a hit on a target with no registered resistance or armor profile
8
12
  */
@@ -17,7 +21,9 @@ export class UnknownTargetProfileError extends Error {
17
21
  /**
18
22
  * Build a damage model from data. The number a hit lands is
19
23
  * `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.
24
+ * same table and armor, so a burn on an armored target is mitigated like a bullet. Every application
25
+ * — a hit or a status tick — is announced as `combat.damage` carrying the target, source, type,
26
+ * amount, absorbed, remaining health and a lethal flag, so a HUD names the blow instead of guessing.
21
27
  *
22
28
  * @capability damage-runtime resolve hits through a damage table, hit zones, and armor, and tick data-defined statuses with declared stacking
23
29
  */
@@ -25,6 +31,8 @@ export function createDamageRuntime(config) {
25
31
  assertDamageTable(config.table);
26
32
  assertHitZoneTable(config.zones);
27
33
  const rng = resolveRng(config.rng, "createDamageRuntime");
34
+ const bus = config.events;
35
+ const healthOf = config.healthOf;
28
36
  let table = config.table;
29
37
  let zones = config.zones;
30
38
  let armorStat = config.armorStat ?? "armor";
@@ -34,6 +42,7 @@ export function createDamageRuntime(config) {
34
42
  let dealt = 0;
35
43
  let statusTicks = 0;
36
44
  let last = null;
45
+ const recent = [];
37
46
  let statusRuntime;
38
47
  statusRuntime = createStatusRuntime({
39
48
  statuses: config.statuses ?? [],
@@ -60,14 +69,50 @@ export function createDamageRuntime(config) {
60
69
  function resolveStatusDamage(tick) {
61
70
  if (!targets.has(tick.target))
62
71
  return tick.amount;
63
- return scaleAt(tick.target, tick.channel, tick.amount).damage ?? 0;
72
+ const dealt = scaleAt(tick.target, tick.channel, tick.amount).damage ?? 0;
73
+ announce({
74
+ target: tick.target,
75
+ type: tick.channel,
76
+ incoming: tick.amount,
77
+ dealt,
78
+ ...(tick.source !== undefined ? { source: tick.source } : {}),
79
+ status: tick.status,
80
+ });
81
+ return dealt;
82
+ }
83
+ function announce(input) {
84
+ const health = healthOf?.(input.target) ?? null;
85
+ const remaining = health === null ? null : Math.max(0, health - input.dealt);
86
+ const event = {
87
+ target: input.target,
88
+ ...(input.source !== undefined ? { source: input.source } : {}),
89
+ type: input.type,
90
+ amount: input.dealt,
91
+ absorbed: Math.max(0, input.incoming - input.dealt),
92
+ remaining,
93
+ lethal: remaining !== null && remaining <= 0,
94
+ ...(input.status !== undefined ? { status: input.status } : {}),
95
+ };
96
+ recent.push(event);
97
+ if (recent.length > RECENT_DAMAGE_LIMIT)
98
+ recent.shift();
99
+ bus?.emit("combat.damage", event);
100
+ }
101
+ function describe(event) {
102
+ const source = event.source === undefined ? "" : ` from ${event.source}`;
103
+ const status = event.status === undefined ? "" : ` ${event.status}`;
104
+ const left = event.remaining === null ? "" : ` → ${event.remaining.toFixed(0)}`;
105
+ return `${event.target} -${event.amount.toFixed(0)}${source}${status}${left}${event.lethal ? " LETHAL" : ""}`;
64
106
  }
65
107
  function probeLine() {
66
108
  const l = last;
67
109
  const tail = l === null
68
110
  ? "no hits"
69
111
  : `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}`;
112
+ const events = recent.length === 0
113
+ ? ""
114
+ : ` · events ${recent.slice(-RECENT_DAMAGE_PROBE_LINES).map(describe).join(" | ")}`;
115
+ return `${targets.size} targets · ${hits} hits ${crits} crits · ${statusTicks} status ticks · ${dealt.toFixed(0)} dealt · ${tail}${events}`;
71
116
  }
72
117
  const unregister = config.devtoolsName === undefined
73
118
  ? null
@@ -86,7 +131,8 @@ export function createDamageRuntime(config) {
86
131
  },
87
132
  hit(input) {
88
133
  const zone = resolveHitZone(zones, input.zone, rng);
89
- const row = scaleAt(input.target, input.type, input.base * zone.multiplier);
134
+ const incoming = input.base * zone.multiplier;
135
+ const row = scaleAt(input.target, input.type, incoming);
90
136
  const applied = [];
91
137
  for (const request of input.statuses ?? []) {
92
138
  const statusId = typeof request === "string" ? request : request.id;
@@ -108,6 +154,13 @@ export function createDamageRuntime(config) {
108
154
  crits += 1;
109
155
  dealt += damage;
110
156
  last = { type: input.type, base: input.base, damage, zone: zone.zone, crit: zone.crit };
157
+ announce({
158
+ target: input.target,
159
+ type: input.type,
160
+ incoming,
161
+ dealt: damage,
162
+ ...(input.source !== undefined ? { source: input.source } : {}),
163
+ });
111
164
  return {
112
165
  target: input.target,
113
166
  type: input.type,
@@ -197,8 +250,13 @@ export function createDamageRuntime(config) {
197
250
  crits = next.crits;
198
251
  dealt = next.dealt;
199
252
  last = next.last === null ? null : { ...next.last };
253
+ recent.length = 0;
200
254
  },
201
255
  statusesOf: (targetId) => statusRuntime.statusesOf(targetId),
256
+ recentDamage(limit) {
257
+ const count = limit === undefined ? recent.length : Math.max(0, Math.min(recent.length, Math.floor(limit)));
258
+ return recent.slice(recent.length - count);
259
+ },
202
260
  stats: () => ({ targets: targets.size, hits, crits, statusTicks, dealt }),
203
261
  dispose() {
204
262
  statusRuntime.dispose();
@@ -36,6 +36,8 @@ export function createDeathSystem(deps) {
36
36
  event.userId = identity.userId;
37
37
  if (identity.displayName !== undefined)
38
38
  event.displayName = identity.displayName;
39
+ const source = reason.kind === "environment" ? reason.attacker ?? reason.source : reason.source;
40
+ event.source = source;
39
41
  deps.events.emit("entity.died", event);
40
42
  const onDeath = normalizeOnDeath(deps.resolveOnDeath(instanceId, reason));
41
43
  const drops = [];
@@ -62,8 +64,10 @@ export function deathReasonFromEffect(ctx) {
62
64
  const killerUserId = ctx.userIdOf?.(ctx.from);
63
65
  if (killerUserId !== undefined) {
64
66
  return ctx.via?.item !== undefined
65
- ? { kind: "player_kill", killerUserId, via: { item: ctx.via.item } }
66
- : { kind: "player_kill", killerUserId };
67
+ ? { kind: "player_kill", killerUserId, source: ctx.from, via: { item: ctx.via.item } }
68
+ : { kind: "player_kill", killerUserId, source: ctx.from };
67
69
  }
68
- return { kind: "environment", source: ctx.via?.item ?? "effect" };
70
+ return ctx.via?.item !== undefined
71
+ ? { kind: "environment", source: ctx.via.item, attacker: ctx.from }
72
+ : { kind: "environment", source: "effect", attacker: ctx.from };
69
73
  }
@@ -1,13 +1,19 @@
1
- /** Why an entity died — who or what gets credit, for drop/command rules and the `entity.died` event. */
1
+ /**
2
+ * Why an entity died — who or what gets credit, for drop/command rules and the `entity.died` event.
3
+ * `source` names what dealt it: a player kill's killer instance id, or the hazard/item of an
4
+ * environment death; `attacker` carries the non-player entity's instance id when one dealt it.
5
+ */
2
6
  export type DeathReason = {
3
7
  kind: "player_kill";
4
8
  killerUserId: string;
9
+ source?: string;
5
10
  via?: {
6
11
  item?: string;
7
12
  };
8
13
  } | {
9
14
  kind: "environment";
10
15
  source: string;
16
+ attacker?: string;
11
17
  } | {
12
18
  kind: "self";
13
19
  source: string;
@@ -74,6 +74,26 @@ export interface AreaTarget {
74
74
  scale: number;
75
75
  }
76
76
  export declare function resolveAreaTargets(spatial: CombatSpatialDeps, input: AreaTargetInput, accept?: (instanceId: string) => boolean): AreaTarget[];
77
+ /**
78
+ * One effect application that drained a target's pools, as an announcer needs it. The effect path
79
+ * has no damage table, so the caller names the channel (`effect`) and reads health its own way.
80
+ */
81
+ export interface EffectDamageApplication {
82
+ /** The entity that took the drain. */
83
+ target: string;
84
+ /** Who dealt it: the effect's `from` instance id (attacker, hazard, projectile owner). */
85
+ source: string;
86
+ /** The effect id that landed (`"damage"`, `"bullet"`, `"fire"`). */
87
+ effect: string;
88
+ /** Magnitude before receive modifiers and area falloff. */
89
+ incoming: number;
90
+ /** Magnitude drained after modifiers, falloff and available pool headroom. */
91
+ dealt: number;
92
+ /** The last pool's current after the drain — the pool whose exhaustion is `lethal`. */
93
+ remaining: number | null;
94
+ /** True when the last pool reached its minimum. */
95
+ lethal: boolean;
96
+ }
77
97
  export interface EffectSystemDeps {
78
98
  resolveReceive(instanceId: string): ReceiveMap | null | undefined;
79
99
  /** Preferred portable bridge to caller-owned resource state. */
@@ -93,6 +113,11 @@ export interface EffectSystemDeps {
93
113
  */
94
114
  resolveSlainIdentity?(instanceId: string): SlainIdentity | null | undefined;
95
115
  onLethal?(instanceId: string, ctx: LethalContext): void;
116
+ /**
117
+ * Called once per target a draining effect lands on, after its pools changed and before
118
+ * {@link onLethal} despawns it. Restorative applications (`via.amount` below zero) stay silent.
119
+ */
120
+ onDamage?(application: EffectDamageApplication): void;
96
121
  }
97
122
  export interface EffectSystem {
98
123
  canReceive(instanceId: string, effect: string, magnitude?: number): string | null;
@@ -103,6 +103,22 @@ export function createEffectSystem(deps) {
103
103
  if (canReceive(instanceId, effect, drainMagnitude) !== null)
104
104
  return null;
105
105
  const result = drainPools(instanceId, effect, rule, drainMagnitude);
106
+ if (drainMagnitude > 0 && deps.onDamage !== undefined) {
107
+ let dealt = 0;
108
+ for (const delta of result.applied)
109
+ if (delta.delta < 0)
110
+ dealt -= delta.delta;
111
+ const lastStatId = rule.order[rule.order.length - 1];
112
+ deps.onDamage({
113
+ target: instanceId,
114
+ source: from,
115
+ effect,
116
+ incoming: drainMagnitude,
117
+ dealt,
118
+ remaining: statPools.get(instanceId, lastStatId)?.current ?? null,
119
+ lethal: result.lethal,
120
+ });
121
+ }
106
122
  if (result.lethal) {
107
123
  // Capture identity before onLethal, which runs the death system and despawns the target.
108
124
  const slain = deps.resolveSlainIdentity?.(instanceId);
@@ -44,6 +44,8 @@ export interface StatusDamageTick {
44
44
  amount: number;
45
45
  /** What the caller's pipeline reported as dealt; equals `amount` when no pipeline was supplied. */
46
46
  dealt: number;
47
+ /** Who applied the status that is ticking, when known — a burn names its arsonist. */
48
+ source?: string;
47
49
  }
48
50
  /** A stat tick: a per-stack delta the caller reads while the status lives. */
49
51
  export interface StatusStatTick {
@@ -87,6 +87,7 @@ export function createStatusRuntime(options) {
87
87
  channel: effect.channel,
88
88
  amount,
89
89
  dealt: amount,
90
+ ...(instance.source !== undefined ? { source: instance.source } : {}),
90
91
  };
91
92
  if (options.applyDamage !== undefined)
92
93
  tick.dealt = options.applyDamage(tick);