@jgengine/core 0.24.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 (98) hide show
  1. package/CHANGELOG.md +92 -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/game/notices.d.ts +4 -0
  50. package/dist/game/notices.js +4 -3
  51. package/dist/game/playableGame.d.ts +9 -1
  52. package/dist/gameplay.d.ts +4 -0
  53. package/dist/gameplay.js +4 -0
  54. package/dist/meta/changelog.d.ts +1 -1
  55. package/dist/meta/changelog.js +78 -1
  56. package/dist/nav/navCrowd.d.ts +138 -0
  57. package/dist/nav/navCrowd.js +412 -0
  58. package/dist/nav/navRuntime.d.ts +108 -0
  59. package/dist/nav/navRuntime.js +256 -0
  60. package/dist/render/colorPipeline.d.ts +53 -0
  61. package/dist/render/colorPipeline.js +79 -0
  62. package/dist/render/lightProbes.d.ts +97 -0
  63. package/dist/render/lightProbes.js +221 -0
  64. package/dist/render/lookPreset.js +4 -4
  65. package/dist/render/physicalSky.d.ts +39 -0
  66. package/dist/render/physicalSky.js +89 -0
  67. package/dist/render/postProcessing.d.ts +40 -8
  68. package/dist/render/postProcessing.js +1 -1
  69. package/dist/render/skyOwnedLighting.d.ts +31 -0
  70. package/dist/render/skyOwnedLighting.js +46 -0
  71. package/dist/sensor/perception.d.ts +66 -5
  72. package/dist/sensor/perception.js +167 -42
  73. package/dist/sim/nearAgents.d.ts +68 -0
  74. package/dist/sim/nearAgents.js +152 -0
  75. package/dist/sim/needs.d.ts +60 -0
  76. package/dist/sim/needs.js +108 -0
  77. package/dist/sim/offScreen.d.ts +181 -0
  78. package/dist/sim/offScreen.js +450 -0
  79. package/dist/sim/routine.d.ts +79 -0
  80. package/dist/sim/routine.js +137 -0
  81. package/dist/visibility/visibilitySystem.js +21 -0
  82. package/dist/world/features.d.ts +8 -0
  83. package/dist/world.d.ts +5 -0
  84. package/dist/world.js +5 -0
  85. package/package.json +1 -1
  86. package/skills/jgengine/api.md +59 -6
  87. package/skills/jgengine/capabilities.md +4 -0
  88. package/skills/jgengine-combat/SKILL.md +17 -0
  89. package/skills/jgengine-combat/api.md +246 -0
  90. package/skills/jgengine-combat/capabilities.md +112 -0
  91. package/skills/jgengine-combat/reference.md +32 -0
  92. package/skills/jgengine-gameplay/SKILL.md +8 -0
  93. package/skills/jgengine-gameplay/api.md +71 -1
  94. package/skills/jgengine-gameplay/capabilities.md +16 -0
  95. package/skills/jgengine-world/SKILL.md +3 -1
  96. package/skills/jgengine-world/api.md +107 -3
  97. package/skills/jgengine-world/capabilities.md +22 -2
  98. package/skills/jgengine-world/recipes/npc-senses.md +8 -2
@@ -0,0 +1,142 @@
1
+ import { type StatusApplicationOutcome, type StatusInstance, type StatusStackPolicy } from "./statusApplication.js";
2
+ /**
3
+ * What a status does on each tick. Damage ticks are reported with the channel the caller's damage
4
+ * pipeline resolves, so a burn still meets resistances and armor; stat ticks are a per-stack delta
5
+ * summed by {@link StatusRuntime.statDelta} (`slow` → move speed, `corrode` → armor).
6
+ */
7
+ export type StatusEffect = {
8
+ kind: "damage";
9
+ channel: string;
10
+ } | {
11
+ kind: "stat";
12
+ stat: string;
13
+ };
14
+ /**
15
+ * One status the runtime can apply: how a fresh application folds into a live one
16
+ * ({@link StatusStackPolicy}), how long it lives, how often and how hard it ticks, and what a tick
17
+ * does. The stack rule is the *only* thing that changes stacks — per-tick magnitude is
18
+ * `magnitude × stacks`, never `magnitude ^ applications`.
19
+ */
20
+ export interface StatusDef {
21
+ /** Caller-owned id (`"burn"`, `"shock"`, `"slow"`, `"corrode"`) — the runtime never branches on it. */
22
+ id: string;
23
+ /** Behaviour when the target already carries this status. */
24
+ stack: StatusStackPolicy;
25
+ /** Base lifetime in milliseconds. */
26
+ durationMs: number;
27
+ /** Milliseconds between ticks; defaults to `durationMs` (one tick as it expires). */
28
+ intervalMs?: number;
29
+ /** Per-stack effect size: damage per tick, or a stat delta per stack. */
30
+ magnitude?: number;
31
+ /** What each tick does. Omitted means a status with no periodic effect (HUD/predicate only). */
32
+ effect?: StatusEffect;
33
+ /** Landing chance in `[0,1]` before any application scale; default `1`. */
34
+ chance?: number;
35
+ }
36
+ /** A damage tick, already routed through the caller's `applyDamage` when one was supplied. */
37
+ export interface StatusDamageTick {
38
+ kind: "damage";
39
+ target: string;
40
+ status: string;
41
+ stacks: number;
42
+ channel: string;
43
+ /** Raw `magnitude × stacks` before the caller's resistances and armor. */
44
+ amount: number;
45
+ /** What the caller's pipeline reported as dealt; equals `amount` when no pipeline was supplied. */
46
+ dealt: number;
47
+ }
48
+ /** A stat tick: a per-stack delta the caller reads while the status lives. */
49
+ export interface StatusStatTick {
50
+ kind: "stat";
51
+ target: string;
52
+ status: string;
53
+ stacks: number;
54
+ stat: string;
55
+ delta: number;
56
+ }
57
+ /** A status ended: remove its HUD row and any stat delta it was contributing. */
58
+ export interface StatusExpiredTick {
59
+ kind: "expired";
60
+ target: string;
61
+ status: string;
62
+ stacks: number;
63
+ }
64
+ /** Everything one `tick` call produced, in target-then-status order. */
65
+ export type StatusTickEvent = StatusDamageTick | StatusStatTick | StatusExpiredTick;
66
+ /** Options for one application; scales compose with the definition's own base values. */
67
+ export interface StatusApplyOptions {
68
+ source?: string;
69
+ chanceScale?: number;
70
+ durationScale?: number;
71
+ magnitudeScale?: number;
72
+ }
73
+ /** One live status plus its tick timer, as saved and restored. */
74
+ export interface StatusRuntimeEntry {
75
+ instance: StatusInstance;
76
+ untilTickMs: number;
77
+ }
78
+ /** Serializable runtime state: carried statuses, tick timers, and the counters a probe reports. */
79
+ export interface StatusRuntimeState {
80
+ targets: Array<{
81
+ id: string;
82
+ entries: StatusRuntimeEntry[];
83
+ }>;
84
+ ticks: number;
85
+ applied: number;
86
+ expired: number;
87
+ }
88
+ /** Tunables a game can adjust at runtime without discarding carried statuses. */
89
+ export interface StatusRuntimeOptions {
90
+ /** Status definitions; re-supply through {@link StatusRuntime.retune} when they change. */
91
+ statuses: readonly StatusDef[];
92
+ /** Injected `[0,1)` stream for landing rolls. Omitting it falls back to `Math.random` and warns once. */
93
+ rng?: () => number;
94
+ /** Route a damage tick through the caller's resistances/armor and return what it dealt. */
95
+ applyDamage?: (tick: StatusDamageTick) => number;
96
+ /** Most ticks one status emits per call; the remainder carries to the next call, never dropped. Default `8`. */
97
+ maxTicksPerCall?: number;
98
+ /** Register a one-line devtools probe under this name. */
99
+ devtoolsName?: string;
100
+ }
101
+ /** A pool of per-target statuses that applies stack rules, ticks effects, and hands its state out. */
102
+ export interface StatusRuntime {
103
+ /** Try to apply one status to a target, folding into an existing instance per its declared stack rule. */
104
+ apply(targetId: string, statusId: string, options?: StatusApplyOptions): StatusApplicationOutcome;
105
+ statusesOf(targetId: string): readonly StatusInstance[];
106
+ has(targetId: string, statusId: string): boolean;
107
+ /** Sum of `magnitude × stacks` for every live status whose effect is this stat. */
108
+ statDelta(targetId: string, stat: string): number;
109
+ /** Advance every live status; returns each tick's effect and each expiry. */
110
+ tick(dtSeconds: number): StatusTickEvent[];
111
+ /** Drop one status, or every status, from a target. */
112
+ clear(targetId: string, statusId?: string): void;
113
+ snapshot(): StatusRuntimeState;
114
+ restore(next: StatusRuntimeState): void;
115
+ /** Swap the definitions (hot reload, tuning) while carried statuses keep running. */
116
+ retune(statuses: readonly StatusDef[]): void;
117
+ /** Counters for a probe or a test. */
118
+ stats(): {
119
+ targets: number;
120
+ active: number;
121
+ ticks: number;
122
+ applied: number;
123
+ expired: number;
124
+ };
125
+ /** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
126
+ dispose(): void;
127
+ }
128
+ /** Raised when a caller names a status id the runtime has no definition for.
129
+ * @capability status-unknown reject a status id the runtime has no definition for
130
+ */
131
+ export declare class UnknownStatusError extends Error {
132
+ readonly status: string;
133
+ constructor(status: string);
134
+ }
135
+ /**
136
+ * Build a status runtime over serializable definitions. Applications reuse
137
+ * {@link resolveStatusApplication} for chance and stacking, so a refresh/stack-to-N/ignore rule has
138
+ * exactly one implementation; `tick` then applies each status's declared effect per stack.
139
+ *
140
+ * @capability status-runtime tick data-defined statuses (burn, shock, slow, corrode) with declared stacking rules and per-tick effects
141
+ */
142
+ export declare function createStatusRuntime(options: StatusRuntimeOptions): StatusRuntime;
@@ -0,0 +1,236 @@
1
+ import { devtools } from "../devtools/devtools.js";
2
+ import { resolveRng } from "../random/resolveRng.js";
3
+ import { resolveStatusApplication, } from "./statusApplication.js";
4
+ /** Raised when a caller names a status id the runtime has no definition for.
5
+ * @capability status-unknown reject a status id the runtime has no definition for
6
+ */
7
+ export class UnknownStatusError extends Error {
8
+ status;
9
+ constructor(status) {
10
+ super(`Unknown status: ${status}`);
11
+ this.name = "UnknownStatusError";
12
+ this.status = status;
13
+ }
14
+ }
15
+ function intervalOf(def) {
16
+ return Math.max(1, Math.round(def.intervalMs ?? def.durationMs));
17
+ }
18
+ function tickCountOf(def) {
19
+ if (def.durationMs <= 0)
20
+ return 0;
21
+ return Math.max(1, Math.round(def.durationMs / intervalOf(def)));
22
+ }
23
+ function specOf(def) {
24
+ const spec = {
25
+ status: def.id,
26
+ chance: def.chance ?? 1,
27
+ durationMs: Math.max(0, def.durationMs),
28
+ ticks: tickCountOf(def),
29
+ magnitude: def.magnitude ?? 0,
30
+ stack: def.stack,
31
+ };
32
+ return spec;
33
+ }
34
+ /**
35
+ * Build a status runtime over serializable definitions. Applications reuse
36
+ * {@link resolveStatusApplication} for chance and stacking, so a refresh/stack-to-N/ignore rule has
37
+ * exactly one implementation; `tick` then applies each status's declared effect per stack.
38
+ *
39
+ * @capability status-runtime tick data-defined statuses (burn, shock, slow, corrode) with declared stacking rules and per-tick effects
40
+ */
41
+ export function createStatusRuntime(options) {
42
+ const rng = resolveRng(options.rng, "statusRuntime");
43
+ const maxTicksPerCall = Math.max(1, Math.round(options.maxTicksPerCall ?? 8));
44
+ let defs = new Map();
45
+ const targets = new Map();
46
+ let ticks = 0;
47
+ let applied = 0;
48
+ let expired = 0;
49
+ function retune(statuses) {
50
+ defs = new Map(statuses.map((def) => [def.id, def]));
51
+ }
52
+ retune(options.statuses);
53
+ function defOf(statusId) {
54
+ const def = defs.get(statusId);
55
+ if (def === undefined)
56
+ throw new UnknownStatusError(statusId);
57
+ return def;
58
+ }
59
+ function targetOf(targetId) {
60
+ let byId = targets.get(targetId);
61
+ if (byId === undefined) {
62
+ byId = new Map();
63
+ targets.set(targetId, byId);
64
+ }
65
+ return byId;
66
+ }
67
+ function effectEvent(targetId, def, instance) {
68
+ const effect = def.effect;
69
+ if (effect === undefined)
70
+ return [];
71
+ const amount = (def.magnitude ?? 0) * instance.stacks;
72
+ if (effect.kind === "stat") {
73
+ return [{
74
+ kind: "stat",
75
+ target: targetId,
76
+ status: def.id,
77
+ stacks: instance.stacks,
78
+ stat: effect.stat,
79
+ delta: amount,
80
+ }];
81
+ }
82
+ const tick = {
83
+ kind: "damage",
84
+ target: targetId,
85
+ status: def.id,
86
+ stacks: instance.stacks,
87
+ channel: effect.channel,
88
+ amount,
89
+ dealt: amount,
90
+ };
91
+ if (options.applyDamage !== undefined)
92
+ tick.dealt = options.applyDamage(tick);
93
+ return [tick];
94
+ }
95
+ function apply(targetId, statusId, applyOptions) {
96
+ const def = defOf(statusId);
97
+ const byId = targetOf(targetId);
98
+ const current = byId.get(statusId)?.instance ?? null;
99
+ const outcome = resolveStatusApplication({
100
+ spec: specOf(def),
101
+ scale: {
102
+ ...(applyOptions?.chanceScale !== undefined ? { applyChance: applyOptions.chanceScale } : {}),
103
+ ...(applyOptions?.durationScale !== undefined ? { duration: applyOptions.durationScale } : {}),
104
+ ...(applyOptions?.magnitudeScale !== undefined ? { magnitude: applyOptions.magnitudeScale } : {}),
105
+ },
106
+ current,
107
+ ...(applyOptions?.source !== undefined ? { source: applyOptions.source } : {}),
108
+ rng,
109
+ });
110
+ if (outcome.instance === null)
111
+ return outcome;
112
+ if (outcome.kind === "ignored" || outcome.kind === "missed" || outcome.kind === "immune")
113
+ return outcome;
114
+ // A fresh application restarts the tick clock, so stacking cannot bank a burst of instant ticks.
115
+ byId.set(statusId, { instance: outcome.instance, untilTickMs: intervalOf(def) });
116
+ if (outcome.kind === "applied")
117
+ applied += 1;
118
+ return outcome;
119
+ }
120
+ function stats() {
121
+ let active = 0;
122
+ for (const byId of targets.values())
123
+ active += byId.size;
124
+ return { targets: targets.size, active, ticks, applied, expired };
125
+ }
126
+ function probeLine() {
127
+ const counts = stats();
128
+ return `${counts.targets} targets ${counts.active} statuses · ${counts.applied} applied ${counts.expired} expired · ${counts.ticks} ticks`;
129
+ }
130
+ const unregister = options.devtoolsName === undefined
131
+ ? null
132
+ : devtools.probes.register(options.devtoolsName, () => probeLine());
133
+ return {
134
+ apply,
135
+ statusesOf(targetId) {
136
+ const byId = targets.get(targetId);
137
+ if (byId === undefined)
138
+ return [];
139
+ return [...byId.values()].map((entry) => entry.instance);
140
+ },
141
+ has(targetId, statusId) {
142
+ return targets.get(targetId)?.has(statusId) === true;
143
+ },
144
+ statDelta(targetId, stat) {
145
+ const byId = targets.get(targetId);
146
+ if (byId === undefined)
147
+ return 0;
148
+ let total = 0;
149
+ for (const [statusId, entry] of byId) {
150
+ const def = defs.get(statusId);
151
+ if (def?.effect?.kind !== "stat" || def.effect.stat !== stat)
152
+ continue;
153
+ total += (def.magnitude ?? 0) * entry.instance.stacks;
154
+ }
155
+ return total;
156
+ },
157
+ tick(dtSeconds) {
158
+ const events = [];
159
+ if (!(dtSeconds > 0))
160
+ return events;
161
+ const stepMs = dtSeconds * 1000;
162
+ ticks += 1;
163
+ for (const [targetId, byId] of targets) {
164
+ for (const [statusId, entry] of byId) {
165
+ const def = defs.get(statusId);
166
+ // Time past the status's own lifetime must not buy extra ticks, so a long frame neither
167
+ // over-ticks nor skips the tick that falls due on the last frame of the status.
168
+ const livedMs = Math.min(stepMs, Math.max(0, entry.instance.remainingMs));
169
+ if (def !== undefined) {
170
+ const intervalMs = intervalOf(def);
171
+ entry.untilTickMs -= livedMs;
172
+ let emitted = 0;
173
+ while (entry.untilTickMs <= 0 && emitted < maxTicksPerCall) {
174
+ entry.untilTickMs += intervalMs;
175
+ emitted += 1;
176
+ entry.instance.ticks = Math.max(0, entry.instance.ticks - 1);
177
+ for (const event of effectEvent(targetId, def, entry.instance))
178
+ events.push(event);
179
+ }
180
+ }
181
+ entry.instance.remainingMs -= livedMs;
182
+ if (entry.instance.remainingMs <= 0) {
183
+ entry.instance.remainingMs = 0;
184
+ byId.delete(statusId);
185
+ expired += 1;
186
+ events.push({ kind: "expired", target: targetId, status: statusId, stacks: 0 });
187
+ }
188
+ }
189
+ if (byId.size === 0)
190
+ targets.delete(targetId);
191
+ }
192
+ return events;
193
+ },
194
+ clear(targetId, statusId) {
195
+ const byId = targets.get(targetId);
196
+ if (byId === undefined)
197
+ return;
198
+ if (statusId === undefined)
199
+ targets.delete(targetId);
200
+ else
201
+ byId.delete(statusId);
202
+ },
203
+ snapshot() {
204
+ return {
205
+ targets: [...targets.entries()].map(([id, byId]) => ({
206
+ id,
207
+ entries: [...byId.values()].map((entry) => ({
208
+ instance: { ...entry.instance },
209
+ untilTickMs: entry.untilTickMs,
210
+ })),
211
+ })),
212
+ ticks,
213
+ applied,
214
+ expired,
215
+ };
216
+ },
217
+ restore(next) {
218
+ targets.clear();
219
+ ticks = next.ticks;
220
+ applied = next.applied;
221
+ expired = next.expired;
222
+ for (const saved of next.targets) {
223
+ const byId = new Map();
224
+ for (const entry of saved.entries) {
225
+ byId.set(entry.instance.status, { instance: { ...entry.instance }, untilTickMs: entry.untilTickMs });
226
+ }
227
+ targets.set(saved.id, byId);
228
+ }
229
+ },
230
+ retune,
231
+ stats,
232
+ dispose() {
233
+ unregister?.();
234
+ },
235
+ };
236
+ }
@@ -0,0 +1,106 @@
1
+ import type { WeaponHandlingTuning } from "./weaponHandling.js";
2
+ /** A world-space point or direction in metres. */
3
+ export type WeaponVec3 = readonly [number, number, number];
4
+ /** How a held or tapped trigger turns into shots. */
5
+ export type WeaponFireMode = "single" | "auto" | "burst";
6
+ /** Shots one trigger pull commits to. A burst finishes even if the trigger is released. */
7
+ export interface WeaponBurstDef {
8
+ /** Shots per pull, `1..64`. */
9
+ count: number;
10
+ }
11
+ /** A ray resolved in the caller's world on the same tick it is fired. */
12
+ export interface WeaponHitscanDef {
13
+ kind: "hitscan";
14
+ /** Longest ray the caller may return a contact within, m. Required so every shot is bounded. */
15
+ maxRange: number;
16
+ }
17
+ /** A travelling pellet simulated by the runtime, resolved when it meets the caller's world. */
18
+ export interface WeaponProjectileDef {
19
+ kind: "projectile";
20
+ /** Launch speed, m/s. */
21
+ speed: number;
22
+ /** Downward acceleration, m/s². `0` flies straight; `9.81` is Earth gravity for reference. */
23
+ gravity?: number;
24
+ /** Linear velocity decay, 1/s. `0` keeps speed until it hits. */
25
+ drag?: number;
26
+ /** Seconds before an unspent projectile expires. Required so every pellet is bounded. */
27
+ lifetime: number;
28
+ /** Optional hard distance cap, m, on top of `lifetime`. */
29
+ maxRange?: number;
30
+ }
31
+ /** Loaded rounds, reload time, and where reloads draw from. */
32
+ export interface WeaponMagazineDef {
33
+ capacity: number;
34
+ reloadMs: number;
35
+ /** Rounds available to reload from; omitted means an unlimited reserve. */
36
+ reserve?: number;
37
+ }
38
+ /** What one pellet does when it reaches a registered damage target. */
39
+ export interface WeaponDamageDef {
40
+ /** Damage type id in the caller's `DamageTableDef`. */
41
+ type: string;
42
+ /** Damage before the hit-zone multiplier, the table, armor, and falloff. */
43
+ base: number;
44
+ /** Status ids the hit tries to apply; the damage runtime owns their chance and effects. */
45
+ statuses?: readonly string[];
46
+ }
47
+ /** Linear damage falloff between two distances. */
48
+ export interface WeaponFalloffDef {
49
+ /** Distance, m, where damage starts dropping. */
50
+ start: number;
51
+ /** Distance, m, where it reaches `endScale`; clamped beyond. */
52
+ end: number;
53
+ /** Damage multiplier at `end`, `0..1`. */
54
+ endScale: number;
55
+ }
56
+ /**
57
+ * One weapon as plain data: cadence, fire mode, delivery, magazine, feel and damage. Nothing here is
58
+ * a function, so a generator can roll it, a save can store it, and two weapons differ by their fields.
59
+ */
60
+ export interface WeaponDefinition {
61
+ id: string;
62
+ /** Shots per minute at full cadence. */
63
+ roundsPerMinute: number;
64
+ fireMode: WeaponFireMode;
65
+ /** Burst size; required for `"burst"` and ignored otherwise. */
66
+ burst?: WeaponBurstDef;
67
+ delivery: WeaponHitscanDef | WeaponProjectileDef;
68
+ /** Pellets per shot, `1..64` (default `1`). */
69
+ pellets?: number;
70
+ /** Discrete ammo; omit for a weapon that never runs dry or reloads. */
71
+ magazine?: WeaponMagazineDef;
72
+ /** Recoil pattern, spread bloom and ADS time — exactly what `createWeaponHandling` consumes. */
73
+ handling: WeaponHandlingTuning;
74
+ damage: WeaponDamageDef;
75
+ /** Damage over distance, applied to both deliveries. Omit for no falloff. */
76
+ falloff?: WeaponFalloffDef;
77
+ /** Most pellets this weapon keeps live at once (default `32`, clamp `1..256`). */
78
+ maxInFlight?: number;
79
+ /** Start a reload by itself when the trigger is pulled dry (default `true`). */
80
+ autoReload?: boolean;
81
+ }
82
+ /** Cap on `WeaponDefinition.maxInFlight`; bounds per-tick projectile work. */
83
+ export declare const MAX_WEAPON_PELLETS_IN_FLIGHT = 256;
84
+ /** Default live-pellet cap for a weapon that does not name one. */
85
+ export declare const DEFAULT_WEAPON_PELLETS_IN_FLIGHT = 32;
86
+ /** Cap on `WeaponDefinition.pellets`; bounds rays and damage resolutions per shot. */
87
+ export declare const MAX_WEAPON_PELLETS_PER_SHOT = 64;
88
+ /** Cap on `WeaponBurstDef.count`. */
89
+ export declare const MAX_WEAPON_BURST_COUNT = 64;
90
+ /**
91
+ * Check a definition before it reaches the runtime, so a rolled weapon fails loudly at construction
92
+ * instead of firing nothing. Throws `RangeError` naming the field.
93
+ *
94
+ * @capability weapon-definition-validate reject a malformed weapon definition at construction with the field named
95
+ */
96
+ export declare function assertWeaponDefinition(definition: WeaponDefinition): void;
97
+ /**
98
+ * Milliseconds between shots at full cadence: `60_000 / roundsPerMinute`.
99
+ *
100
+ * @capability weapon-cadence convert a rounds-per-minute weapon rate into the interval a fire gate takes
101
+ */
102
+ export declare function weaponIntervalMs(definition: WeaponDefinition): number;
103
+ /** Damage multiplier at a distance: `1` before `falloff.start`, linearly down to `endScale` at `end`. */
104
+ export declare function weaponDamageAt(definition: WeaponDefinition, distance: number): number;
105
+ /** Live pellets one definition may keep, clamped to a bounded range. */
106
+ export declare function weaponMaxInFlight(definition: WeaponDefinition): number;
@@ -0,0 +1,108 @@
1
+ /** Cap on `WeaponDefinition.maxInFlight`; bounds per-tick projectile work. */
2
+ export const MAX_WEAPON_PELLETS_IN_FLIGHT = 256;
3
+ /** Default live-pellet cap for a weapon that does not name one. */
4
+ export const DEFAULT_WEAPON_PELLETS_IN_FLIGHT = 32;
5
+ /** Cap on `WeaponDefinition.pellets`; bounds rays and damage resolutions per shot. */
6
+ export const MAX_WEAPON_PELLETS_PER_SHOT = 64;
7
+ /** Cap on `WeaponBurstDef.count`. */
8
+ export const MAX_WEAPON_BURST_COUNT = 64;
9
+ function assertFinite(value, field, min, max) {
10
+ if (!Number.isFinite(value) || value < min || (max !== undefined && value > max)) {
11
+ const range = max === undefined ? `>= ${min}` : `in [${min}, ${max}]`;
12
+ throw new RangeError(`Weapon definition field "${field}" must be a finite number ${range}; got ${String(value)}`);
13
+ }
14
+ }
15
+ /**
16
+ * Check a definition before it reaches the runtime, so a rolled weapon fails loudly at construction
17
+ * instead of firing nothing. Throws `RangeError` naming the field.
18
+ *
19
+ * @capability weapon-definition-validate reject a malformed weapon definition at construction with the field named
20
+ */
21
+ export function assertWeaponDefinition(definition) {
22
+ if (definition.id.trim() === "")
23
+ throw new RangeError('Weapon definition field "id" must not be empty');
24
+ assertFinite(definition.roundsPerMinute, "roundsPerMinute", Number.MIN_VALUE);
25
+ if (definition.fireMode !== "single" && definition.fireMode !== "auto" && definition.fireMode !== "burst") {
26
+ throw new RangeError(`Weapon definition field "fireMode" must be single, auto or burst; got ${String(definition.fireMode)}`);
27
+ }
28
+ if (definition.fireMode === "burst") {
29
+ if (definition.burst === undefined)
30
+ throw new RangeError('Weapon definition field "burst" is required for fireMode "burst"');
31
+ assertFinite(definition.burst.count, "burst.count", 1, MAX_WEAPON_BURST_COUNT);
32
+ if (!Number.isInteger(definition.burst.count))
33
+ throw new RangeError('Weapon definition field "burst.count" must be an integer');
34
+ }
35
+ if (definition.pellets !== undefined) {
36
+ assertFinite(definition.pellets, "pellets", 1, MAX_WEAPON_PELLETS_PER_SHOT);
37
+ if (!Number.isInteger(definition.pellets))
38
+ throw new RangeError('Weapon definition field "pellets" must be an integer');
39
+ }
40
+ if (definition.maxInFlight !== undefined) {
41
+ assertFinite(definition.maxInFlight, "maxInFlight", 1, MAX_WEAPON_PELLETS_IN_FLIGHT);
42
+ if (!Number.isInteger(definition.maxInFlight))
43
+ throw new RangeError('Weapon definition field "maxInFlight" must be an integer');
44
+ }
45
+ assertFinite(definition.damage.base, "damage.base", Number.MIN_VALUE);
46
+ if (definition.damage.type.trim() === "")
47
+ throw new RangeError('Weapon definition field "damage.type" must not be empty');
48
+ const delivery = definition.delivery;
49
+ if (delivery.kind === "hitscan") {
50
+ assertFinite(delivery.maxRange, "delivery.maxRange", Number.MIN_VALUE);
51
+ }
52
+ else if (delivery.kind === "projectile") {
53
+ assertFinite(delivery.speed, "delivery.speed", Number.MIN_VALUE);
54
+ assertFinite(delivery.lifetime, "delivery.lifetime", Number.MIN_VALUE);
55
+ if (delivery.gravity !== undefined)
56
+ assertFinite(delivery.gravity, "delivery.gravity", 0, 1e5);
57
+ if (delivery.drag !== undefined)
58
+ assertFinite(delivery.drag, "delivery.drag", 0, 1e5);
59
+ if (delivery.maxRange !== undefined)
60
+ assertFinite(delivery.maxRange, "delivery.maxRange", Number.MIN_VALUE);
61
+ }
62
+ else {
63
+ throw new RangeError(`Weapon definition field "delivery.kind" must be hitscan or projectile; got ${String(delivery.kind)}`);
64
+ }
65
+ if (definition.magazine !== undefined) {
66
+ assertFinite(definition.magazine.capacity, "magazine.capacity", 1);
67
+ if (!Number.isInteger(definition.magazine.capacity))
68
+ throw new RangeError('Weapon definition field "magazine.capacity" must be an integer');
69
+ assertFinite(definition.magazine.reloadMs, "magazine.reloadMs", 0);
70
+ if (definition.magazine.reserve !== undefined)
71
+ assertFinite(definition.magazine.reserve, "magazine.reserve", 0);
72
+ }
73
+ assertFinite(definition.handling.spread.base, "handling.spread.base", 0, Math.PI / 2);
74
+ if (definition.handling.spread.perShot !== undefined)
75
+ assertFinite(definition.handling.spread.perShot, "handling.spread.perShot", 0);
76
+ if (definition.handling.spread.recoverRate !== undefined)
77
+ assertFinite(definition.handling.spread.recoverRate, "handling.spread.recoverRate", 0);
78
+ if (definition.falloff !== undefined) {
79
+ assertFinite(definition.falloff.start, "falloff.start", 0);
80
+ assertFinite(definition.falloff.end, "falloff.end", 0);
81
+ assertFinite(definition.falloff.endScale, "falloff.endScale", 0, 1);
82
+ if (definition.falloff.end < definition.falloff.start) {
83
+ throw new RangeError('Weapon definition field "falloff.end" must not be before "falloff.start"');
84
+ }
85
+ }
86
+ }
87
+ /**
88
+ * Milliseconds between shots at full cadence: `60_000 / roundsPerMinute`.
89
+ *
90
+ * @capability weapon-cadence convert a rounds-per-minute weapon rate into the interval a fire gate takes
91
+ */
92
+ export function weaponIntervalMs(definition) {
93
+ return 60_000 / definition.roundsPerMinute;
94
+ }
95
+ /** Damage multiplier at a distance: `1` before `falloff.start`, linearly down to `endScale` at `end`. */
96
+ export function weaponDamageAt(definition, distance) {
97
+ const falloff = definition.falloff;
98
+ if (falloff === undefined || distance <= falloff.start)
99
+ return 1;
100
+ if (falloff.end <= falloff.start)
101
+ return falloff.endScale;
102
+ const t = Math.min(1, (distance - falloff.start) / (falloff.end - falloff.start));
103
+ return 1 + (falloff.endScale - 1) * t;
104
+ }
105
+ /** Live pellets one definition may keep, clamped to a bounded range. */
106
+ export function weaponMaxInFlight(definition) {
107
+ return Math.min(MAX_WEAPON_PELLETS_IN_FLIGHT, Math.max(1, Math.floor(definition.maxInFlight ?? DEFAULT_WEAPON_PELLETS_IN_FLIGHT)));
108
+ }