@jgengine/core 0.27.0 → 0.29.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 (108) hide show
  1. package/CHANGELOG.md +140 -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 +55 -4
  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 +12 -2
  45. package/dist/gameplay.js +10 -0
  46. package/dist/meta/changelog.d.ts +1 -1
  47. package/dist/meta/changelog.js +130 -1
  48. package/dist/mission/compositions.d.ts +85 -0
  49. package/dist/mission/compositions.js +130 -0
  50. package/dist/mission/mission.d.ts +435 -0
  51. package/dist/mission/mission.js +773 -0
  52. package/dist/mission/triggers.d.ts +61 -0
  53. package/dist/mission/triggers.js +108 -0
  54. package/dist/nav/traffic.d.ts +0 -1
  55. package/dist/nav/traffic.js +1 -2
  56. package/dist/runtime/context/combat.js +11 -0
  57. package/dist/settings/accessibilitySettings.d.ts +81 -0
  58. package/dist/settings/accessibilitySettings.js +152 -0
  59. package/dist/settings/graphicsProfile.d.ts +3 -0
  60. package/dist/settings/graphicsProfile.js +4 -0
  61. package/dist/settings/settingsModel.d.ts +1 -1
  62. package/dist/settings/settingsModel.js +1 -0
  63. package/dist/sim/nearAgents.d.ts +8 -1
  64. package/dist/sim/nearAgents.js +34 -17
  65. package/dist/sim/offScreen.d.ts +2 -1
  66. package/dist/sim/offScreen.js +14 -3
  67. package/dist/sim/population.d.ts +147 -0
  68. package/dist/sim/population.js +182 -0
  69. package/dist/sim/queue.d.ts +90 -0
  70. package/dist/sim/queue.js +171 -0
  71. package/dist/sim/rides.d.ts +287 -0
  72. package/dist/sim/rides.js +471 -0
  73. package/dist/sim/routine.js +19 -11
  74. package/dist/ui/loadingTips.d.ts +41 -0
  75. package/dist/ui/loadingTips.js +64 -0
  76. package/dist/ui/menuNav.d.ts +79 -0
  77. package/dist/ui/menuNav.js +147 -0
  78. package/dist/ui/menuTransition.d.ts +50 -0
  79. package/dist/ui/menuTransition.js +67 -0
  80. package/dist/ui.d.ts +2 -0
  81. package/dist/ui.js +2 -0
  82. package/dist/vfx/effectLibrary.d.ts +98 -0
  83. package/dist/vfx/effectLibrary.js +181 -0
  84. package/dist/vfx/particleBudget.d.ts +40 -0
  85. package/dist/vfx/particleBudget.js +39 -0
  86. package/dist/vfx/particleDirector.d.ts +16 -0
  87. package/dist/vfx/particleDirector.js +18 -0
  88. package/dist/world/authoredSimulation.js +7 -1
  89. package/dist/world/captureCamera.d.ts +30 -0
  90. package/dist/world/captureCamera.js +19 -0
  91. package/dist/world/features.d.ts +15 -4
  92. package/dist/world/terrain.d.ts +1 -0
  93. package/dist/world/terrain.js +2 -0
  94. package/dist/world.d.ts +2 -1
  95. package/dist/world.js +1 -0
  96. package/package.json +1 -1
  97. package/skills/jgengine/api.md +2 -2
  98. package/skills/jgengine-combat/SKILL.md +6 -0
  99. package/skills/jgengine-combat/api.md +5 -4
  100. package/skills/jgengine-combat/reference.md +4 -0
  101. package/skills/jgengine-gameplay/SKILL.md +55 -0
  102. package/skills/jgengine-gameplay/api.md +335 -10
  103. package/skills/jgengine-gameplay/capabilities.md +83 -1
  104. package/skills/jgengine-world/SKILL.md +4 -0
  105. package/skills/jgengine-world/api.md +128 -6
  106. package/skills/jgengine-world/capabilities.md +41 -1
  107. package/skills/jgengine-world/recipes/character-animation.md +25 -2
  108. package/skills/jgengine-world/reference.md +1 -1
@@ -0,0 +1,301 @@
1
+ import { devtools } from "../devtools/devtools.js";
2
+ const EPSILON = 1e-9;
3
+ function isMinorUnits(value) {
4
+ return Number.isSafeInteger(value);
5
+ }
6
+ function withinOverdraft(nextBalance, overdraft) {
7
+ if (nextBalance >= 0)
8
+ return true;
9
+ if (overdraft === undefined || overdraft === false)
10
+ return false;
11
+ if (overdraft === true)
12
+ return true;
13
+ return nextBalance >= -overdraft.max;
14
+ }
15
+ function validCharge(charge) {
16
+ return (charge.id.trim() !== "" &&
17
+ charge.account.trim() !== "" &&
18
+ charge.currency.trim() !== "" &&
19
+ charge.reason.trim() !== "" &&
20
+ isMinorUnits(charge.amountPerPeriod) && charge.amountPerPeriod > 0 &&
21
+ Number.isFinite(charge.everySeconds) && charge.everySeconds > 0 &&
22
+ (charge.startSeconds === undefined || (Number.isFinite(charge.startSeconds) && charge.startSeconds >= 0)) &&
23
+ (charge.maxCatchUp === undefined || (Number.isInteger(charge.maxCatchUp) && charge.maxCatchUp > 0)));
24
+ }
25
+ /**
26
+ * The books a business keeps: per-account, per-currency balances in integer minor units with a
27
+ * journal that records every movement, the balance it produced and the reason it happened.
28
+ * {@link TransactionLedger.post} is the one money path — sales, purchases, upkeep and loan
29
+ * payments all go through it, so nothing moves without a reason on the record.
30
+ * {@link TransactionLedger.chargeDue} settles recurring charges (upkeep, rent) on their own
31
+ * cadence, bounded per advance; a cycle with no funds is counted as missed instead of overdrawing.
32
+ * Journal memory is bounded by `maxEntries`, with `totals()` keeping the per-reason summary beyond it.
33
+ *
34
+ * @capability transaction-ledger record every money movement with its reason over per-account integer minor-unit balances
35
+ */
36
+ export function createTransactionLedger(options = {}) {
37
+ const maxEntries = Math.max(1, Math.floor(options.maxEntries ?? 1024));
38
+ const maxCyclesPerAdvance = Math.max(1, Math.floor(options.maxCyclesPerAdvance ?? 256));
39
+ let nowSeconds = 0;
40
+ let accounts = {};
41
+ let entries = [];
42
+ let totals = {};
43
+ let charges = {};
44
+ let cursors = {};
45
+ let seq = 0;
46
+ const probe = options.devtoolsName === undefined
47
+ ? null
48
+ : devtools.probes.register(options.devtoolsName, () => ({ ...readStats(), totals: readTotals() }));
49
+ function readStats() {
50
+ return { accounts: Object.keys(accounts).length, entries: entries.length, charges: Object.keys(charges).length, seq };
51
+ }
52
+ function readTotals() {
53
+ const out = {};
54
+ for (const [reason, entry] of Object.entries(totals))
55
+ out[reason] = { ...entry };
56
+ return out;
57
+ }
58
+ function readAccounts() {
59
+ const out = {};
60
+ for (const [account, wallet] of Object.entries(accounts))
61
+ out[account] = { ...wallet };
62
+ return out;
63
+ }
64
+ function apply(at, account, currency, amount, reason, ref, overdraft) {
65
+ const wallet = accounts[account];
66
+ if (wallet === undefined)
67
+ return { ok: false, reason: "unknown-account" };
68
+ if (!isMinorUnits(amount) || amount === 0)
69
+ return { ok: false, reason: "invalid-amount" };
70
+ if (reason.trim() === "")
71
+ return { ok: false, reason: "invalid-reason" };
72
+ const next = (wallet[currency] ?? 0) + amount;
73
+ if (!isMinorUnits(next) || !withinOverdraft(next, overdraft))
74
+ return { ok: false, reason: "insufficient-funds" };
75
+ wallet[currency] = next;
76
+ seq += 1;
77
+ const entry = ref === undefined
78
+ ? { seq, at, account, currency, amount, balanceAfter: next, reason }
79
+ : { seq, at, account, currency, amount, balanceAfter: next, reason, ref };
80
+ entries.push(entry);
81
+ if (entries.length > maxEntries)
82
+ entries.splice(0, entries.length - maxEntries);
83
+ const total = totals[reason] ?? { count: 0, net: 0 };
84
+ totals[reason] = { count: total.count + 1, net: total.net + amount };
85
+ return { ok: true, entry: { ...entry } };
86
+ }
87
+ return {
88
+ openAccount(account, currency, initial = 0) {
89
+ if (account.trim() === "" || currency.trim() === "")
90
+ return;
91
+ if (!isMinorUnits(initial))
92
+ return;
93
+ const wallet = accounts[account] ?? (accounts[account] = {});
94
+ if (wallet[currency] === undefined)
95
+ wallet[currency] = initial;
96
+ },
97
+ balanceOf(account, currency) {
98
+ return accounts[account]?.[currency] ?? 0;
99
+ },
100
+ post(input) {
101
+ return apply(input.at, input.account, input.currency, input.amount, input.reason, input.ref, input.overdraft);
102
+ },
103
+ addCharge(charge) {
104
+ if (!validCharge(charge) || charges[charge.id] !== undefined)
105
+ return false;
106
+ charges[charge.id] = { ...charge };
107
+ cursors[charge.id] = {
108
+ nextDueSeconds: charge.startSeconds ?? nowSeconds,
109
+ fired: 0,
110
+ missed: 0,
111
+ done: false,
112
+ };
113
+ return true;
114
+ },
115
+ removeCharge(id) {
116
+ if (charges[id] === undefined)
117
+ return false;
118
+ const nextCharges = { ...charges };
119
+ const nextCursors = { ...cursors };
120
+ delete nextCharges[id];
121
+ delete nextCursors[id];
122
+ charges = nextCharges;
123
+ cursors = nextCursors;
124
+ return true;
125
+ },
126
+ chargeDue(toSeconds) {
127
+ if (toSeconds < nowSeconds) {
128
+ throw new RangeError(`chargeDue cannot rewind: ${toSeconds} < ${nowSeconds}`);
129
+ }
130
+ const charged = [];
131
+ const missed = [];
132
+ for (const id of Object.keys(charges).sort()) {
133
+ const charge = charges[id];
134
+ const cursor = { ...cursors[id] };
135
+ if (cursor.done || cursor.nextDueSeconds > toSeconds) {
136
+ cursors[id] = cursor;
137
+ continue;
138
+ }
139
+ const dueCount = Math.floor((toSeconds - cursor.nextDueSeconds) / charge.everySeconds + EPSILON) + 1;
140
+ const policy = charge.catchUp ?? "each";
141
+ const perChargeLimit = charge.maxCatchUp ?? Number.POSITIVE_INFINITY;
142
+ const kept = policy === "skip" ? 1 : Math.min(dueCount, perChargeLimit, maxCyclesPerAdvance);
143
+ const firstDue = cursor.nextDueSeconds;
144
+ if (policy === "skip") {
145
+ const at = firstDue + (dueCount - 1) * charge.everySeconds;
146
+ const result = apply(at, charge.account, charge.currency, -charge.amountPerPeriod, charge.reason, charge.ref ?? id, undefined);
147
+ if (result.ok) {
148
+ charged.push(result.entry);
149
+ cursor.fired += 1;
150
+ }
151
+ else {
152
+ cursor.missed += 1;
153
+ missed.push({ id, cycles: 1 });
154
+ }
155
+ }
156
+ else if (policy === "sum") {
157
+ const at = firstDue + (kept - 1) * charge.everySeconds;
158
+ const result = apply(at, charge.account, charge.currency, -charge.amountPerPeriod * kept, charge.reason, charge.ref ?? id, undefined);
159
+ if (result.ok) {
160
+ charged.push(result.entry);
161
+ cursor.fired += kept;
162
+ }
163
+ else {
164
+ cursor.missed += kept;
165
+ missed.push({ id, cycles: kept });
166
+ }
167
+ }
168
+ else {
169
+ let failed = 0;
170
+ for (let i = 0; i < kept; i += 1) {
171
+ const at = firstDue + i * charge.everySeconds;
172
+ const result = apply(at, charge.account, charge.currency, -charge.amountPerPeriod, charge.reason, charge.ref ?? id, undefined);
173
+ if (result.ok) {
174
+ charged.push(result.entry);
175
+ cursor.fired += 1;
176
+ }
177
+ else {
178
+ failed += 1;
179
+ }
180
+ }
181
+ cursor.missed += failed;
182
+ if (failed > 0)
183
+ missed.push({ id, cycles: failed });
184
+ }
185
+ cursor.nextDueSeconds = firstDue + dueCount * charge.everySeconds;
186
+ cursors[id] = cursor;
187
+ }
188
+ nowSeconds = toSeconds;
189
+ return { charged, missed };
190
+ },
191
+ entries(filter = {}) {
192
+ const limit = filter.limit === undefined ? Number.POSITIVE_INFINITY : Math.max(0, Math.floor(filter.limit));
193
+ const out = [];
194
+ for (const entry of entries) {
195
+ if (filter.account !== undefined && entry.account !== filter.account)
196
+ continue;
197
+ if (filter.reason !== undefined && entry.reason !== filter.reason)
198
+ continue;
199
+ if (filter.since !== undefined && entry.at < filter.since)
200
+ continue;
201
+ out.push({ ...entry });
202
+ }
203
+ return Number.isFinite(limit) && out.length > limit ? out.slice(out.length - limit) : out;
204
+ },
205
+ totals: readTotals,
206
+ accounts: readAccounts,
207
+ snapshot() {
208
+ return {
209
+ nowSeconds,
210
+ accounts: readAccounts(),
211
+ entries: entries.map((entry) => ({ ...entry })),
212
+ totals: readTotals(),
213
+ charges: Object.fromEntries(Object.entries(charges).map(([id, charge]) => [id, { ...charge }])),
214
+ cursors: Object.fromEntries(Object.entries(cursors).map(([id, cursor]) => [id, { ...cursor }])),
215
+ seq,
216
+ };
217
+ },
218
+ restore(next) {
219
+ if (!Number.isFinite(next.nowSeconds) || next.nowSeconds < 0)
220
+ return false;
221
+ if (!Number.isInteger(next.seq) || next.seq < 0)
222
+ return false;
223
+ if (next.accounts === undefined || next.entries === undefined || next.totals === undefined)
224
+ return false;
225
+ if (next.charges === undefined || next.cursors === undefined)
226
+ return false;
227
+ const nextAccounts = {};
228
+ for (const [account, wallet] of Object.entries(next.accounts)) {
229
+ const copy = {};
230
+ for (const [currency, amount] of Object.entries(wallet)) {
231
+ if (!isMinorUnits(amount))
232
+ return false;
233
+ copy[currency] = amount;
234
+ }
235
+ nextAccounts[account] = copy;
236
+ }
237
+ const nextEntries = [];
238
+ for (const entry of next.entries) {
239
+ if (!Number.isInteger(entry.seq) || entry.seq < 1)
240
+ return false;
241
+ if (!Number.isFinite(entry.at) || entry.at < 0)
242
+ return false;
243
+ if (typeof entry.account !== "string" || entry.account.trim() === "")
244
+ return false;
245
+ if (typeof entry.currency !== "string" || entry.currency.trim() === "")
246
+ return false;
247
+ if (!isMinorUnits(entry.amount) || !isMinorUnits(entry.balanceAfter))
248
+ return false;
249
+ if (typeof entry.reason !== "string" || entry.reason.trim() === "")
250
+ return false;
251
+ nextEntries.push({ ...entry });
252
+ }
253
+ const nextTotals = {};
254
+ for (const [reason, total] of Object.entries(next.totals)) {
255
+ if (!Number.isInteger(total.count) || total.count < 0 || !isMinorUnits(total.net))
256
+ return false;
257
+ nextTotals[reason] = { count: total.count, net: total.net };
258
+ }
259
+ const nextCharges = {};
260
+ const nextCursors = {};
261
+ for (const [id, charge] of Object.entries(next.charges)) {
262
+ if (!validCharge(charge) || charge.id !== id)
263
+ return false;
264
+ const cursor = next.cursors[id];
265
+ if (cursor === undefined)
266
+ return false;
267
+ if (!Number.isFinite(cursor.nextDueSeconds) || cursor.nextDueSeconds < 0)
268
+ return false;
269
+ if (!Number.isInteger(cursor.fired) || cursor.fired < 0)
270
+ return false;
271
+ if (!Number.isInteger(cursor.missed) || cursor.missed < 0)
272
+ return false;
273
+ if (typeof cursor.done !== "boolean")
274
+ return false;
275
+ nextCharges[id] = { ...charge };
276
+ nextCursors[id] = { ...cursor };
277
+ }
278
+ nowSeconds = next.nowSeconds;
279
+ accounts = nextAccounts;
280
+ entries = nextEntries.length > maxEntries ? nextEntries.slice(nextEntries.length - maxEntries) : nextEntries;
281
+ totals = nextTotals;
282
+ charges = nextCharges;
283
+ cursors = nextCursors;
284
+ seq = next.seq;
285
+ return true;
286
+ },
287
+ stats: readStats,
288
+ reset() {
289
+ nowSeconds = 0;
290
+ accounts = {};
291
+ entries = [];
292
+ totals = {};
293
+ charges = {};
294
+ cursors = {};
295
+ seq = 0;
296
+ },
297
+ dispose() {
298
+ probe?.();
299
+ },
300
+ };
301
+ }
@@ -1,3 +1,4 @@
1
+ import { findEffect } from "../vfx/effectLibrary.js";
1
2
  import { cloneEditorDocument, decodeEditorDocument, editorDocumentSize } from "./document.js";
2
3
  import { isScatterPath } from "../world/scatterRegion.js";
3
4
  function byteSize(value) {
@@ -59,7 +60,8 @@ export function validateCreatorDocument(value, policy) {
59
60
  const child = emitter.options?.collisionEffect;
60
61
  if (child !== undefined)
61
62
  maxChildParticles = Math.max(maxChildParticles, Math.min(child.count, child.config.max ?? child.count));
62
- return total + (emitter.config.max ?? 512);
63
+ const authored = emitter.config ?? (emitter.effect === undefined ? undefined : findEffect(emitter.effect)?.config);
64
+ return total + (authored?.max ?? 512);
63
65
  }, 0);
64
66
  // The renderer retains up to 32 concurrent child bursts, shared by all standing emitters.
65
67
  if (weatherParticles + emitterParticles + 32 * maxChildParticles > (policy.maxSimulationParticles ?? 0))
@@ -5,13 +5,16 @@ import { type ParticleAttachOptions } from "../vfx/particleDirector.js";
5
5
  import type { FireGridConfig } from "../world/weather.js";
6
6
  import type { FlockConfig } from "../ai/flock.js";
7
7
  import type { EditorDocument, EditorVec3 } from "./types.js";
8
- /** A named emitter authored in metres, with game-owned appearance and attachment ids. */
8
+ /** A named emitter authored in metres: either a library effect name or a full config. */
9
9
  export interface AuthoredEmitter {
10
10
  id: string;
11
11
  /** Spawn only while this authored fire area burns; live particles finish their lifetime. */
12
12
  fireAreaId?: string;
13
13
  position: EditorVec3;
14
- config: EmitterConfig;
14
+ /** Named effect from the shared library (`fire/flame`, `explosion/core`, …). */
15
+ effect?: string;
16
+ /** Full emitter config; used when `effect` is absent. */
17
+ config?: EmitterConfig;
15
18
  options?: ParticleAttachOptions;
16
19
  }
17
20
  /** A bounded fire grid; fuel and damage policy remain game content. */
@@ -1,6 +1,7 @@
1
1
  import { validateForceField } from "../physics/forceVolume.js";
2
2
  import { validateAuthoredWeather } from "../world/authoredWeather.js";
3
3
  import { validateEmitterConfig } from "../vfx/particles.js";
4
+ import { effectNames } from "../vfx/effectLibrary.js";
4
5
  import { validateParticleAttachOptions } from "../vfx/particleDirector.js";
5
6
  function object(value, path) {
6
7
  if (value === null || typeof value !== "object" || Array.isArray(value))
@@ -91,35 +92,44 @@ export function decodeEditorSimulation(value, document) {
91
92
  });
92
93
  }
93
94
  rows(source.emitters, "$.simulation.emitters", 128, (row, path) => {
94
- known(row, ["id", "position", "config", "options", "fireAreaId"], path);
95
+ known(row, ["id", "position", "effect", "config", "options", "fireAreaId"], path);
95
96
  if (row.fireAreaId !== undefined) {
96
97
  text(row.fireAreaId, `${path}.fireAreaId`);
97
98
  if (!Array.isArray(source.fires) || !source.fires.some((area) => area !== null && typeof area === "object" && area.id === row.fireAreaId))
98
99
  throw new Error(`${path}.fireAreaId: missing fire area ${row.fireAreaId}`);
99
100
  }
100
- const diagnostics = validateEmitterConfig(row.config);
101
- if (diagnostics.length)
102
- throw new Error(`${path}.config: ${diagnostics.join("; ")}`);
101
+ if ((row.effect === undefined) === (row.config === undefined))
102
+ throw new Error(`${path}: expected exactly one of effect or config`);
103
+ if (row.effect !== undefined) {
104
+ text(row.effect, `${path}.effect`);
105
+ if (!effectNames().includes(row.effect))
106
+ throw new Error(`${path}.effect: unknown effect ${row.effect}`);
107
+ }
108
+ else {
109
+ const diagnostics = validateEmitterConfig(row.config);
110
+ if (diagnostics.length)
111
+ throw new Error(`${path}.config: ${diagnostics.join("; ")}`);
112
+ const config = object(row.config, `${path}.config`);
113
+ for (const key of ["max", "rate", "spread", "drag"])
114
+ if (config[key] !== undefined)
115
+ finite(config[key], `${path}.config.${key}`, 0, key === "max" ? 100_000 : key === "drag" ? 1 : Infinity);
116
+ if (config.max !== undefined && !Number.isInteger(config.max))
117
+ throw new Error(`${path}.config.max: expected an integer`);
118
+ for (const key of ["position", "spawnJitter", "direction", "gravity"])
119
+ if (config[key] !== undefined)
120
+ tuple(config[key], `${path}.config.${key}`);
121
+ for (const key of ["lifetime", "speed"])
122
+ if (config[key] !== undefined) {
123
+ const range = object(config[key], `${path}.config.${key}`);
124
+ finite(range.min, `${path}.config.${key}.min`, 0);
125
+ finite(range.max, `${path}.config.${key}.max`, range.min);
126
+ }
127
+ }
103
128
  if (row.options !== undefined) {
104
129
  const errors = validateParticleAttachOptions(row.options);
105
130
  if (errors.length)
106
131
  throw new Error(`${path}.options: ${errors.join("; ")}`);
107
132
  }
108
- const config = object(row.config, `${path}.config`);
109
- for (const key of ["max", "rate", "spread", "drag"])
110
- if (config[key] !== undefined)
111
- finite(config[key], `${path}.config.${key}`, 0, key === "max" ? 100_000 : key === "drag" ? 1 : Infinity);
112
- if (config.max !== undefined && !Number.isInteger(config.max))
113
- throw new Error(`${path}.config.max: expected an integer`);
114
- for (const key of ["position", "spawnJitter", "direction", "gravity"])
115
- if (config[key] !== undefined)
116
- tuple(config[key], `${path}.config.${key}`);
117
- for (const key of ["lifetime", "speed"])
118
- if (config[key] !== undefined) {
119
- const range = object(config[key], `${path}.config.${key}`);
120
- finite(range.min, `${path}.config.${key}.min`, 0);
121
- finite(range.max, `${path}.config.${key}.max`, range.min);
122
- }
123
133
  if (row.options !== undefined) {
124
134
  const options = object(row.options, `${path}.options`);
125
135
  if (options.space !== undefined && options.space !== "local" && options.space !== "world")
@@ -30,6 +30,20 @@ export interface FirstPersonCameraConfig {
30
30
  reticle?: boolean;
31
31
  /** Render a viewmodel in view: the built-in three-mesh gun, or `PlayableGame.viewmodel` when set (#542). `false` renders no viewmodel regardless of `PlayableGame.viewmodel`. Default true. */
32
32
  viewmodel?: boolean;
33
+ /**
34
+ * Keep the eye out of world collision. The boom anchors `armBack` behind the eye and
35
+ * pulls the camera toward it so the near plane stays clear of a trunk or wall the body
36
+ * can reach; pulling in is immediate, easing back out is not. `false` restores the
37
+ * unchecked camera. Default on, with `clearance` = near plane + 0.15.
38
+ */
39
+ collision?: false | FirstPersonCollisionConfig;
40
+ }
41
+ /** Clearance tuning for the first-person camera's collision pull-in. */
42
+ export interface FirstPersonCollisionConfig {
43
+ /** Gap kept between the camera and the surface it would enter, world units. Defaults to the near plane plus 0.15. */
44
+ clearance?: number;
45
+ /** How far behind the eye the boom anchors, giving the pull somewhere to travel. Default 1.5. */
46
+ armBack?: number;
33
47
  }
34
48
  /**
35
49
  * Which camera rig the shell mounts. Every rig accepts `followEntityId: null`
@@ -4,7 +4,7 @@ import type { ModelAnimationConfig } from "./playableGame.js";
4
4
  * differently (`Idle` / `Walking_A` / `Armature|Run`); roles are the pack-agnostic vocabulary
5
5
  * the engine defaults from.
6
6
  */
7
- export type ClipRole = "idle" | "walk" | "run" | "attack" | "hit" | "death" | "jump" | "spawn" | "interact" | "cheer";
7
+ export type ClipRole = "idle" | "walk" | "run" | "strafe" | "backward" | "attack" | "hit" | "death" | "jump" | "spawn" | "interact" | "cheer";
8
8
  /**
9
9
  * Keyword table driving {@link classifyClip}: lowercase keywords matched against a clip name's
10
10
  * tokens (exact token match beats prefix match). Extend or replace per game/pack — the table is
@@ -34,9 +34,10 @@ export declare function classifyClip(name: string, table?: ClipRoleTable): ClipR
34
34
  export declare function rolesFromClips(clips: readonly string[], table?: ClipRoleTable): Partial<Record<ClipRole, string[]>>;
35
35
  /**
36
36
  * Build the default `ModelConfig.animation` for a rigged asset from its catalog clip names:
37
- * speed-driven idle/walk/run states plus hit/death/attack one-shots where identifiable. Returns
38
- * `undefined` when no idle clip is identifiable — an ambiguous rig renders its bind pose rather
39
- * than guessing. Walk falls back to idle when the pack has no walk clip.
37
+ * speed-driven idle/walk/run states plus hit/death/attack one-shots where identifiable, and the
38
+ * lateral/backward states when the pack names them (KayKit's `Running_Strafe_Left`,
39
+ * `Walking_Backwards`). Returns `undefined` when no idle clip is identifiable - an ambiguous rig
40
+ * renders its bind pose rather than guessing. Walk falls back to idle when the pack has no walk clip.
40
41
  *
41
42
  * @capability clip-roles derive a complete default animation config from a rigged asset's clip names
42
43
  */
@@ -7,6 +7,8 @@ const ROLE_PRIORITY = [
7
7
  "spawn",
8
8
  "interact",
9
9
  "cheer",
10
+ "strafe",
11
+ "backward",
10
12
  "run",
11
13
  "walk",
12
14
  "idle",
@@ -17,6 +19,8 @@ export const DEFAULT_CLIP_ROLE_TABLE = {
17
19
  idle: ["idle"],
18
20
  walk: ["walk"],
19
21
  run: ["run", "sprint", "jog"],
22
+ strafe: ["strafe"],
23
+ backward: ["backward", "backpedal"],
20
24
  attack: ["attack", "punch", "slash", "shoot", "chop", "stab", "cast", "swing", "strike", "kick", "throw"],
21
25
  hit: ["hit", "receive", "impact", "hurt", "damage"],
22
26
  death: ["death", "die", "dead", "dying"],
@@ -94,11 +98,21 @@ function oneShotSpec(variants) {
94
98
  return undefined;
95
99
  return variants.length === 1 ? variants[0] : variants;
96
100
  }
101
+ /** Which side a strafe clip covers, read from its own name; `undefined` when the name names no side. */
102
+ function strafeSide(name) {
103
+ const lower = name.toLowerCase();
104
+ if (lower.includes("left"))
105
+ return "left";
106
+ if (lower.includes("right"))
107
+ return "right";
108
+ return undefined;
109
+ }
97
110
  /**
98
111
  * Build the default `ModelConfig.animation` for a rigged asset from its catalog clip names:
99
- * speed-driven idle/walk/run states plus hit/death/attack one-shots where identifiable. Returns
100
- * `undefined` when no idle clip is identifiable — an ambiguous rig renders its bind pose rather
101
- * than guessing. Walk falls back to idle when the pack has no walk clip.
112
+ * speed-driven idle/walk/run states plus hit/death/attack one-shots where identifiable, and the
113
+ * lateral/backward states when the pack names them (KayKit's `Running_Strafe_Left`,
114
+ * `Walking_Backwards`). Returns `undefined` when no idle clip is identifiable - an ambiguous rig
115
+ * renders its bind pose rather than guessing. Walk falls back to idle when the pack has no walk clip.
102
116
  *
103
117
  * @capability clip-roles derive a complete default animation config from a rigged asset's clip names
104
118
  */
@@ -119,8 +133,24 @@ export function defaultAnimationForClips(clips, table = DEFAULT_CLIP_ROLE_TABLE)
119
133
  oneShots.death = death;
120
134
  if (attack !== undefined)
121
135
  oneShots.attack = attack;
136
+ let strafeLeft;
137
+ let strafeRight;
138
+ for (const clip of roles.strafe ?? []) {
139
+ const side = strafeSide(clip);
140
+ if (side === "left")
141
+ strafeLeft ??= clip;
142
+ else if (side === "right")
143
+ strafeRight ??= clip;
144
+ }
122
145
  return {
123
- states: { idle, walk, ...(run === undefined ? {} : { run }) },
146
+ states: {
147
+ idle,
148
+ walk,
149
+ ...(run === undefined ? {} : { run }),
150
+ ...(strafeLeft === undefined ? {} : { strafeLeft }),
151
+ ...(strafeRight === undefined ? {} : { strafeRight }),
152
+ ...(roles.backward?.[0] === undefined ? {} : { backward: roles.backward[0] }),
153
+ },
124
154
  ...(Object.keys(oneShots).length === 0 ? {} : { oneShots }),
125
155
  };
126
156
  }
@@ -9,10 +9,36 @@ export interface EntityDiedEvent {
9
9
  catalogId: string;
10
10
  userId?: string;
11
11
  displayName?: string;
12
+ /** The cause (`reason.kind`) and, through `source`, what dealt it. */
12
13
  reason: DeathReason;
14
+ /** Instance id of the killer, hazard or effect that caused it, when the caller knew one. */
15
+ source?: string;
13
16
  position: [number, number, number];
14
17
  serverId?: string;
15
18
  }
19
+ /**
20
+ * One application of damage to one target: what hit it, for how much, and where its health stands.
21
+ * `target` is always the thing that took the blow, so a hero and a keep hit in the same frame raise
22
+ * two separate events and a HUD reads only the one whose `target` it owns.
23
+ */
24
+ export interface DamageAppliedEvent {
25
+ /** The registered target that took the damage. */
26
+ target: string;
27
+ /** Who or what dealt it: an attacker instance id, a hazard, or the status that ticked. */
28
+ source?: string;
29
+ /** Damage type resolved through the target's table (`"kinetic"`, `"fire"`). On the effect path (`ctx.scene.entity.effect`) there is no table, so it is the effect id that landed. */
30
+ type: string;
31
+ /** Damage dealt after the zone, type and armor steps. */
32
+ amount: number;
33
+ /** How much of the zone-adjusted incoming amount resistance and armor stopped. */
34
+ absorbed: number;
35
+ /** Health left after this application, or `null` when the runtime was given no health reader. */
36
+ remaining: number | null;
37
+ /** True only when a health reader reported the pool reaching zero or below. */
38
+ lethal: boolean;
39
+ /** The status that ticked, when the damage came from a carried status rather than a direct hit. */
40
+ status?: string;
41
+ }
16
42
  /** Request that an entity's rig play a one-shot animation clip bound to `event` in its `animation.oneShots` (e.g. an "attack" swing); the shell resolves the clip and plays it once over the locomotion state. With an `animation.graph`, `event` arms the trigger of that name. */
17
43
  export interface EntityAnimationEvent {
18
44
  instanceId: string;
@@ -265,6 +291,7 @@ export interface AudioLoopStopEvent {
265
291
  }
266
292
  export interface GameEventMap {
267
293
  "entity.died": EntityDiedEvent;
294
+ "combat.damage": DamageAppliedEvent;
268
295
  "entity.floatText": EntityFloatTextEvent;
269
296
  "combat.telegraph": CombatTelegraphEvent;
270
297
  "combat.vfx": CombatVfxEvent;