@jgengine/core 0.25.0 → 0.27.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 (133) hide show
  1. package/CHANGELOG.md +109 -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/playableGame.d.ts +19 -2
  50. package/dist/gameplay.d.ts +9 -0
  51. package/dist/gameplay.js +9 -0
  52. package/dist/inventory/carryLoad.d.ts +48 -0
  53. package/dist/inventory/carryLoad.js +44 -0
  54. package/dist/inventory/equipment.d.ts +95 -0
  55. package/dist/inventory/equipment.js +169 -0
  56. package/dist/inventory/inventoryModel.d.ts +15 -6
  57. package/dist/inventory/inventoryModel.js +54 -5
  58. package/dist/inventory/shapedGrid.d.ts +26 -0
  59. package/dist/inventory/shapedGrid.js +47 -0
  60. package/dist/loot/lootGen.d.ts +186 -0
  61. package/dist/loot/lootGen.js +437 -0
  62. package/dist/loot/lootWeapon.d.ts +35 -0
  63. package/dist/loot/lootWeapon.js +70 -0
  64. package/dist/meta/changelog.d.ts +1 -1
  65. package/dist/meta/changelog.js +92 -1
  66. package/dist/nav/laneGraph.d.ts +178 -0
  67. package/dist/nav/laneGraph.js +671 -0
  68. package/dist/nav/navCrowd.d.ts +138 -0
  69. package/dist/nav/navCrowd.js +412 -0
  70. package/dist/nav/navRuntime.d.ts +108 -0
  71. package/dist/nav/navRuntime.js +256 -0
  72. package/dist/nav/traffic.d.ts +223 -0
  73. package/dist/nav/traffic.js +919 -0
  74. package/dist/physics/kinematicVehicle.d.ts +36 -0
  75. package/dist/physics/kinematicVehicle.js +15 -1
  76. package/dist/physics/vehicleDamage.d.ts +136 -0
  77. package/dist/physics/vehicleDamage.js +214 -0
  78. package/dist/physics/vehicleParts.d.ts +62 -0
  79. package/dist/physics/vehicleParts.js +65 -0
  80. package/dist/progression/gearScore.d.ts +56 -0
  81. package/dist/progression/gearScore.js +59 -0
  82. package/dist/progression/skillTree.d.ts +129 -0
  83. package/dist/progression/skillTree.js +265 -0
  84. package/dist/progression/xpCurve.d.ts +60 -0
  85. package/dist/progression/xpCurve.js +141 -0
  86. package/dist/render/colorPipeline.d.ts +53 -0
  87. package/dist/render/colorPipeline.js +79 -0
  88. package/dist/render/lightProbes.d.ts +97 -0
  89. package/dist/render/lightProbes.js +221 -0
  90. package/dist/render/lookPreset.js +4 -4
  91. package/dist/render/physicalSky.d.ts +39 -0
  92. package/dist/render/physicalSky.js +89 -0
  93. package/dist/render/postProcessing.d.ts +40 -8
  94. package/dist/render/postProcessing.js +1 -1
  95. package/dist/render/skyOwnedLighting.d.ts +31 -0
  96. package/dist/render/skyOwnedLighting.js +46 -0
  97. package/dist/scene/builtinSceneKinds.js +2 -0
  98. package/dist/sensor/perception.d.ts +66 -5
  99. package/dist/sensor/perception.js +167 -42
  100. package/dist/sim/nearAgents.d.ts +68 -0
  101. package/dist/sim/nearAgents.js +152 -0
  102. package/dist/sim/needs.d.ts +60 -0
  103. package/dist/sim/needs.js +108 -0
  104. package/dist/sim/offScreen.d.ts +181 -0
  105. package/dist/sim/offScreen.js +450 -0
  106. package/dist/sim/routine.d.ts +79 -0
  107. package/dist/sim/routine.js +137 -0
  108. package/dist/visibility/visibilitySystem.js +21 -0
  109. package/dist/world/decalKind.d.ts +48 -0
  110. package/dist/world/decalKind.js +86 -0
  111. package/dist/world/features.d.ts +11 -1
  112. package/dist/world/features.js +1 -0
  113. package/dist/world/grassKind.d.ts +5 -0
  114. package/dist/world/grassKind.js +3 -0
  115. package/dist/world/terraform.d.ts +8 -0
  116. package/dist/world.d.ts +10 -1
  117. package/dist/world.js +9 -0
  118. package/package.json +1 -1
  119. package/skills/jgengine/api.md +59 -6
  120. package/skills/jgengine/capabilities.md +4 -0
  121. package/skills/jgengine-combat/SKILL.md +19 -0
  122. package/skills/jgengine-combat/api.md +246 -0
  123. package/skills/jgengine-combat/capabilities.md +112 -0
  124. package/skills/jgengine-combat/reference.md +32 -0
  125. package/skills/jgengine-gameplay/SKILL.md +39 -0
  126. package/skills/jgengine-gameplay/api.md +221 -7
  127. package/skills/jgengine-gameplay/capabilities.md +64 -3
  128. package/skills/jgengine-gameplay/reference-systems.md +118 -0
  129. package/skills/jgengine-world/SKILL.md +7 -1
  130. package/skills/jgengine-world/api.md +222 -8
  131. package/skills/jgengine-world/capabilities.md +42 -2
  132. package/skills/jgengine-world/recipes/npc-senses.md +8 -2
  133. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * One damage type's row in a damage table: a caller-owned id (`"kinetic"`, `"fire"`) and how it
3
+ * fares against each resistance class a target can present (`"flesh"`, `"shield"`, `"armor"`).
4
+ * Values below `1` are resisted, `1` is neutral, above `1` is a weakness, and `0` is immune.
5
+ */
6
+ export interface DamageTypeDef {
7
+ id: string;
8
+ /** Resistance class → multiplier. A class the type omits uses the table's `defaultMultiplier`. */
9
+ vs?: Readonly<Record<string, number>>;
10
+ /** True when armor points never reduce this type. Defaults to false. */
11
+ bypassArmor?: boolean;
12
+ }
13
+ /**
14
+ * The armor curve: `mitigation = armor / (armor + halfValue)`. Naming the half value instead of a
15
+ * percentage per point is what makes a generated gun's armor number readable — armor equal to
16
+ * `halfValue` halves the hit, and mitigation approaches the cap without ever reaching it.
17
+ */
18
+ export interface ArmorConfig {
19
+ /** Armor points at which the hit is halved. Default `100`. */
20
+ halfValue?: number;
21
+ /** Ceiling on the mitigated fraction, so armor never fully negates a hit. Default `0.9`. */
22
+ maxMitigation?: number;
23
+ }
24
+ /**
25
+ * A serializable damage table: damage types scored against resistance classes, plus the armor curve
26
+ * those rows are read with. It is data a generated gun (B3.1) and a weapon runtime (B2.3) pass in;
27
+ * the engine holds no built-in elemental chart.
28
+ */
29
+ export interface DamageTableDef {
30
+ types: readonly DamageTypeDef[];
31
+ /** Multiplier for a type/class pair the table does not list. Default `1`. */
32
+ defaultMultiplier?: number;
33
+ armor?: ArmorConfig;
34
+ }
35
+ /** One readable row: a type against a resistance class at a given armor value. */
36
+ export interface DamageTableRow {
37
+ type: string;
38
+ resistance: string;
39
+ /** Type × resistance multiplier before armor. */
40
+ multiplier: number;
41
+ /** Armor points this row was read at. */
42
+ armor: number;
43
+ /** Fraction of the hit armor removes, in `[0, maxMitigation]`. */
44
+ mitigation: number;
45
+ /** `multiplier × (1 - mitigation)` — the fraction of the base that lands. */
46
+ net: number;
47
+ /** `base × net`, when the read supplied a base. */
48
+ damage?: number;
49
+ }
50
+ /** Raised when a hit names a damage type the table does not define, rather than silently dealing 1×.
51
+ * @capability damage-type-unknown reject a hit whose damage type the table does not define instead of dealing 1×
52
+ */
53
+ export declare class UnknownDamageTypeError extends Error {
54
+ readonly type: string;
55
+ constructor(type: string);
56
+ }
57
+ /** Raised when a table lists the same damage type twice, which would make the winner depend on order.
58
+ * @capability damage-type-duplicate reject a damage table that defines one type twice
59
+ */
60
+ export declare class DuplicateDamageTypeError extends Error {
61
+ readonly type: string;
62
+ constructor(type: string);
63
+ }
64
+ /** Default armor points at which a hit is halved. */
65
+ export declare const DEFAULT_ARMOR_HALF_VALUE = 100;
66
+ /** Default ceiling on armor mitigation, so armor never fully negates a hit. */
67
+ export declare const DEFAULT_MAX_MITIGATION = 0.9;
68
+ /** Validate a table once at construction: duplicate ids and non-finite multipliers fail loudly.
69
+ * @capability damage-table-validate reject a damage table with duplicate types or non-finite multipliers before combat runs
70
+ */
71
+ export declare function assertDamageTable(table: DamageTableDef): void;
72
+ /** Fraction of a hit that armor removes at `armor` points; `0` at zero armor, capped by config.
73
+ * @capability armor-mitigation read the fraction of a hit armor removes at a given armor value
74
+ */
75
+ export declare function armorMitigation(armor: number, config?: ArmorConfig): number;
76
+ /**
77
+ * The type × resistance multiplier for one class, before armor. An unknown type throws; an unknown
78
+ * resistance class uses the table's `defaultMultiplier`, since target classes may be narrower than
79
+ * the table's coverage.
80
+ *
81
+ * @capability damage-table score a data-defined damage type against a resistance class, with the outcome readable as a table
82
+ */
83
+ export declare function typeMultiplier(table: DamageTableDef, type: string, resistance: string): number;
84
+ /** Whether armor points reduce this damage type.
85
+ * @capability armor-bypass ask whether a damage type ignores armor points
86
+ */
87
+ export declare function typeBypassesArmor(table: DamageTableDef, type: string): boolean;
88
+ /**
89
+ * Resolve one hit through the table: type multiplier, then armor mitigation, and the base scaled by
90
+ * both. Formula order matters only for the numbers a UI shows — the product is the same.
91
+ *
92
+ * @capability damage-table-resolve resolve a damage type against a resistance class and armor into a multiplier, mitigation, and final damage
93
+ */
94
+ export declare function resolveTableDamage(table: DamageTableDef, input: {
95
+ type: string;
96
+ resistance: string;
97
+ armor?: number;
98
+ base?: number;
99
+ }): DamageTableRow;
100
+ /**
101
+ * Read the whole table as rows — every listed type against every listed resistance class, each with
102
+ * the armor it was read at and the damage a base would land. This is the table a designer reads and
103
+ * a test asserts, so a resistance change is visible without running combat.
104
+ *
105
+ * @capability damage-table-read read a damage table as rows of type × resistance × armor mitigation, with final damage per base
106
+ */
107
+ export declare function readDamageTable(table: DamageTableDef, read?: {
108
+ base?: number;
109
+ armor?: number | Readonly<Record<string, number>>;
110
+ types?: readonly string[];
111
+ resistances?: readonly string[];
112
+ }): DamageTableRow[];
113
+ /** Render rows as fixed-width lines for a devtools probe, a CLI dump, or a test's diff.
114
+ * @capability damage-table-format render damage-table rows as fixed-width lines for a probe, a CLI dump, or a test
115
+ */
116
+ export declare function formatDamageTable(rows: readonly DamageTableRow[]): string[];
@@ -0,0 +1,165 @@
1
+ /** Raised when a hit names a damage type the table does not define, rather than silently dealing 1×.
2
+ * @capability damage-type-unknown reject a hit whose damage type the table does not define instead of dealing 1×
3
+ */
4
+ export class UnknownDamageTypeError extends Error {
5
+ type;
6
+ constructor(type) {
7
+ super(`Unknown damage type: ${type}`);
8
+ this.name = "UnknownDamageTypeError";
9
+ this.type = type;
10
+ }
11
+ }
12
+ /** Raised when a table lists the same damage type twice, which would make the winner depend on order.
13
+ * @capability damage-type-duplicate reject a damage table that defines one type twice
14
+ */
15
+ export class DuplicateDamageTypeError extends Error {
16
+ type;
17
+ constructor(type) {
18
+ super(`Duplicate damage type: ${type}`);
19
+ this.name = "DuplicateDamageTypeError";
20
+ this.type = type;
21
+ }
22
+ }
23
+ /** Default armor points at which a hit is halved. */
24
+ export const DEFAULT_ARMOR_HALF_VALUE = 100;
25
+ /** Default ceiling on armor mitigation, so armor never fully negates a hit. */
26
+ export const DEFAULT_MAX_MITIGATION = 0.9;
27
+ function clamp01(value) {
28
+ if (value < 0)
29
+ return 0;
30
+ if (value > 1)
31
+ return 1;
32
+ return value;
33
+ }
34
+ /** Validate a table once at construction: duplicate ids and non-finite multipliers fail loudly.
35
+ * @capability damage-table-validate reject a damage table with duplicate types or non-finite multipliers before combat runs
36
+ */
37
+ export function assertDamageTable(table) {
38
+ const seen = new Set();
39
+ for (const type of table.types) {
40
+ if (seen.has(type.id))
41
+ throw new DuplicateDamageTypeError(type.id);
42
+ seen.add(type.id);
43
+ for (const [resistance, multiplier] of Object.entries(type.vs ?? {})) {
44
+ if (!Number.isFinite(multiplier) || multiplier < 0) {
45
+ throw new RangeError(`Damage type "${type.id}" has a bad multiplier vs "${resistance}": ${multiplier}`);
46
+ }
47
+ }
48
+ }
49
+ const defaultMultiplier = table.defaultMultiplier ?? 1;
50
+ if (!Number.isFinite(defaultMultiplier) || defaultMultiplier < 0) {
51
+ throw new RangeError(`Damage table defaultMultiplier must be finite and >= 0, got ${defaultMultiplier}`);
52
+ }
53
+ }
54
+ function findType(table, type) {
55
+ for (const entry of table.types)
56
+ if (entry.id === type)
57
+ return entry;
58
+ throw new UnknownDamageTypeError(type);
59
+ }
60
+ /** Fraction of a hit that armor removes at `armor` points; `0` at zero armor, capped by config.
61
+ * @capability armor-mitigation read the fraction of a hit armor removes at a given armor value
62
+ */
63
+ export function armorMitigation(armor, config) {
64
+ const points = Math.max(0, armor);
65
+ if (points <= 0)
66
+ return 0;
67
+ const halfValue = Math.max(1e-6, config?.halfValue ?? DEFAULT_ARMOR_HALF_VALUE);
68
+ const cap = clamp01(config?.maxMitigation ?? DEFAULT_MAX_MITIGATION);
69
+ return Math.min(cap, points / (points + halfValue));
70
+ }
71
+ /**
72
+ * The type × resistance multiplier for one class, before armor. An unknown type throws; an unknown
73
+ * resistance class uses the table's `defaultMultiplier`, since target classes may be narrower than
74
+ * the table's coverage.
75
+ *
76
+ * @capability damage-table score a data-defined damage type against a resistance class, with the outcome readable as a table
77
+ */
78
+ export function typeMultiplier(table, type, resistance) {
79
+ const entry = findType(table, type);
80
+ const listed = entry.vs?.[resistance];
81
+ if (listed !== undefined)
82
+ return listed;
83
+ return table.defaultMultiplier ?? 1;
84
+ }
85
+ /** Whether armor points reduce this damage type.
86
+ * @capability armor-bypass ask whether a damage type ignores armor points
87
+ */
88
+ export function typeBypassesArmor(table, type) {
89
+ return findType(table, type).bypassArmor === true;
90
+ }
91
+ /**
92
+ * Resolve one hit through the table: type multiplier, then armor mitigation, and the base scaled by
93
+ * both. Formula order matters only for the numbers a UI shows — the product is the same.
94
+ *
95
+ * @capability damage-table-resolve resolve a damage type against a resistance class and armor into a multiplier, mitigation, and final damage
96
+ */
97
+ export function resolveTableDamage(table, input) {
98
+ const multiplier = typeMultiplier(table, input.type, input.resistance);
99
+ const armorPoints = Math.max(0, input.armor ?? 0);
100
+ const mitigation = typeBypassesArmor(table, input.type)
101
+ ? 0
102
+ : armorMitigation(armorPoints, table.armor);
103
+ const net = multiplier * (1 - mitigation);
104
+ const row = {
105
+ type: input.type,
106
+ resistance: input.resistance,
107
+ multiplier,
108
+ armor: armorPoints,
109
+ mitigation,
110
+ net,
111
+ };
112
+ if (input.base !== undefined)
113
+ row.damage = input.base * net;
114
+ return row;
115
+ }
116
+ function listedResistances(table) {
117
+ const seen = new Set();
118
+ for (const type of table.types)
119
+ for (const resistance of Object.keys(type.vs ?? {}))
120
+ seen.add(resistance);
121
+ return [...seen].sort();
122
+ }
123
+ /**
124
+ * Read the whole table as rows — every listed type against every listed resistance class, each with
125
+ * the armor it was read at and the damage a base would land. This is the table a designer reads and
126
+ * a test asserts, so a resistance change is visible without running combat.
127
+ *
128
+ * @capability damage-table-read read a damage table as rows of type × resistance × armor mitigation, with final damage per base
129
+ */
130
+ export function readDamageTable(table, read) {
131
+ assertDamageTable(table);
132
+ const types = read?.types ?? table.types.map((entry) => entry.id);
133
+ const resistances = read?.resistances ?? listedResistances(table);
134
+ const rows = [];
135
+ for (const type of types) {
136
+ for (const resistance of resistances) {
137
+ const armor = typeof read?.armor === "number" ? read.armor : (read?.armor?.[resistance] ?? 0);
138
+ const input = {
139
+ type,
140
+ resistance,
141
+ armor,
142
+ };
143
+ if (read?.base !== undefined)
144
+ input.base = read.base;
145
+ rows.push(resolveTableDamage(table, input));
146
+ }
147
+ }
148
+ return rows;
149
+ }
150
+ function pad(value, width) {
151
+ return value.length >= width ? value : value + " ".repeat(width - value.length);
152
+ }
153
+ /** Render rows as fixed-width lines for a devtools probe, a CLI dump, or a test's diff.
154
+ * @capability damage-table-format render damage-table rows as fixed-width lines for a probe, a CLI dump, or a test
155
+ */
156
+ export function formatDamageTable(rows) {
157
+ const typeWidth = Math.max(4, ...rows.map((row) => row.type.length));
158
+ const resistanceWidth = Math.max(10, ...rows.map((row) => row.resistance.length));
159
+ return rows.map((row) => {
160
+ const multiplier = `×${row.multiplier.toFixed(2)}`;
161
+ const mitigation = row.armor > 0 ? `${(row.mitigation * 100).toFixed(0)}%` : "0%";
162
+ const damage = row.damage === undefined ? "" : ` → ${row.damage.toFixed(1)}`;
163
+ return `${pad(row.type, typeWidth)} vs ${pad(row.resistance, resistanceWidth)} ${pad(multiplier, 6)} armor ${pad(String(row.armor), 4)} ${pad(mitigation, 4)} net ×${row.net.toFixed(2)}${damage}`;
164
+ });
165
+ }
@@ -0,0 +1,70 @@
1
+ /** One hit zone: what a hit there multiplies, and how much likelier it is to crit. */
2
+ export interface HitZoneDef {
3
+ id: string;
4
+ /** Multiplier for a hit in this zone that does not crit. Default `1`. */
5
+ multiplier?: number;
6
+ /** Added to the table's base crit chance for hits in this zone. Default `0`. */
7
+ critChance?: number;
8
+ /** Crit multiplier in this zone; defaults to the table's `critMultiplier`. */
9
+ critMultiplier?: number;
10
+ }
11
+ /**
12
+ * A serializable hit-zone table: where a hit landed, what that multiplies, and the table's base crit
13
+ * chance and multiplier. A generated gun or a weapon runtime passes its own zones in; the engine has
14
+ * no built-in headshot.
15
+ */
16
+ export interface HitZoneTable {
17
+ zones: readonly HitZoneDef[];
18
+ /** Zone used when a caller names one the table does not list. Must appear in `zones`. */
19
+ defaultZone: string;
20
+ /** Base crit chance in `[0,1]`. Default `0`. */
21
+ critChance?: number;
22
+ /** Base crit multiplier. Default `2`. */
23
+ critMultiplier?: number;
24
+ }
25
+ /** One resolved zone: where the hit landed, whether it crit, and the single multiplier to apply. */
26
+ export interface HitZoneResolution {
27
+ zone: string;
28
+ crit: boolean;
29
+ /**
30
+ * The one multiplier this hit earns. A crit *replaces* the zone's normal multiplier instead of
31
+ * compounding with it, so a headshot crit can never silently become zone × crit.
32
+ */
33
+ multiplier: number;
34
+ /** The crit draw, or `null` when the chance was zero and no draw was taken. */
35
+ roll: number | null;
36
+ /** True when the named zone was absent from the table and `defaultZone` was used. */
37
+ fallback: boolean;
38
+ }
39
+ /** Raised when neither the named zone nor the table's `defaultZone` exists — a data error, not a 1× hit.
40
+ * @capability hit-zone-unknown reject a hit zone table whose named default does not exist
41
+ */
42
+ export declare class UnknownHitZoneError extends Error {
43
+ readonly zone: string;
44
+ constructor(zone: string);
45
+ }
46
+ /** Default crit multiplier for a zone that does not name one. */
47
+ export declare const DEFAULT_CRIT_MULTIPLIER = 2;
48
+ /** Validate a zone table once at construction: duplicate ids and a missing default fail loudly.
49
+ * @capability hit-zone-validate reject a hit-zone table with duplicate zones or a missing default
50
+ */
51
+ export declare function assertHitZoneTable(table: HitZoneTable): void;
52
+ /**
53
+ * The multiplier a zone earns for a known crit outcome, without rolling. `resolveHitZone` and a
54
+ * time-to-kill quote both read through this, so a quoted crit uses the same number combat does.
55
+ *
56
+ * @capability hit-zone-multiplier quote a zone's multiplier for a known crit outcome without rolling
57
+ */
58
+ export declare function hitZoneMultiplier(table: HitZoneTable, zone: string | undefined, crit: boolean): {
59
+ zone: string;
60
+ multiplier: number;
61
+ fallback: boolean;
62
+ };
63
+ /**
64
+ * Resolve where a hit landed into one multiplier: roll the crit chance for that zone, and apply the
65
+ * crit multiplier when it lands, otherwise the zone's own multiplier. The injected `rng` keeps the
66
+ * roll reproducible; a zero chance draws nothing, so a deterministic test can force either outcome.
67
+ *
68
+ * @capability hit-zone resolve a hit zone into one damage multiplier, crit by zone with an injected RNG
69
+ */
70
+ export declare function resolveHitZone(table: HitZoneTable, zone: string | undefined, rng?: () => number): HitZoneResolution;
@@ -0,0 +1,77 @@
1
+ import { resolveRng } from "../random/resolveRng.js";
2
+ /** Raised when neither the named zone nor the table's `defaultZone` exists — a data error, not a 1× hit.
3
+ * @capability hit-zone-unknown reject a hit zone table whose named default does not exist
4
+ */
5
+ export class UnknownHitZoneError extends Error {
6
+ zone;
7
+ constructor(zone) {
8
+ super(`Unknown hit zone: ${zone}`);
9
+ this.name = "UnknownHitZoneError";
10
+ this.zone = zone;
11
+ }
12
+ }
13
+ /** Default crit multiplier for a zone that does not name one. */
14
+ export const DEFAULT_CRIT_MULTIPLIER = 2;
15
+ function clamp01(value) {
16
+ if (value < 0)
17
+ return 0;
18
+ if (value > 1)
19
+ return 1;
20
+ return value;
21
+ }
22
+ function findZone(table, zone) {
23
+ for (const entry of table.zones)
24
+ if (entry.id === zone)
25
+ return entry;
26
+ return undefined;
27
+ }
28
+ /** Validate a zone table once at construction: duplicate ids and a missing default fail loudly.
29
+ * @capability hit-zone-validate reject a hit-zone table with duplicate zones or a missing default
30
+ */
31
+ export function assertHitZoneTable(table) {
32
+ const seen = new Set();
33
+ for (const zone of table.zones) {
34
+ if (seen.has(zone.id))
35
+ throw new RangeError(`Duplicate hit zone: ${zone.id}`);
36
+ seen.add(zone.id);
37
+ }
38
+ if (!seen.has(table.defaultZone))
39
+ throw new UnknownHitZoneError(table.defaultZone);
40
+ }
41
+ /**
42
+ * The multiplier a zone earns for a known crit outcome, without rolling. `resolveHitZone` and a
43
+ * time-to-kill quote both read through this, so a quoted crit uses the same number combat does.
44
+ *
45
+ * @capability hit-zone-multiplier quote a zone's multiplier for a known crit outcome without rolling
46
+ */
47
+ export function hitZoneMultiplier(table, zone, crit) {
48
+ const named = zone ?? table.defaultZone;
49
+ let entry = findZone(table, named);
50
+ const fallback = entry === undefined;
51
+ if (entry === undefined)
52
+ entry = findZone(table, table.defaultZone);
53
+ if (entry === undefined)
54
+ throw new UnknownHitZoneError(named);
55
+ const multiplier = crit
56
+ ? (entry.critMultiplier ?? table.critMultiplier ?? DEFAULT_CRIT_MULTIPLIER)
57
+ : (entry.multiplier ?? 1);
58
+ return { zone: entry.id, multiplier, fallback };
59
+ }
60
+ /**
61
+ * Resolve where a hit landed into one multiplier: roll the crit chance for that zone, and apply the
62
+ * crit multiplier when it lands, otherwise the zone's own multiplier. The injected `rng` keeps the
63
+ * roll reproducible; a zero chance draws nothing, so a deterministic test can force either outcome.
64
+ *
65
+ * @capability hit-zone resolve a hit zone into one damage multiplier, crit by zone with an injected RNG
66
+ */
67
+ export function resolveHitZone(table, zone, rng) {
68
+ const chance = clamp01((table.critChance ?? 0) + (findZone(table, zone ?? table.defaultZone)?.critChance ?? 0));
69
+ let roll = null;
70
+ let crit = false;
71
+ if (chance > 0) {
72
+ roll = resolveRng(rng, "hitZone")();
73
+ crit = roll < chance;
74
+ }
75
+ const resolved = hitZoneMultiplier(table, zone, crit);
76
+ return { zone: resolved.zone, crit, multiplier: resolved.multiplier, roll, fallback: resolved.fallback };
77
+ }
@@ -29,6 +29,8 @@ export interface MagazineSnapshot {
29
29
  loaded: number;
30
30
  reloadElapsedMs: number | null;
31
31
  reserve: number | null;
32
+ /** Present and `true` only while a reload is suspended, so idle snapshots keep their shape. */
33
+ paused?: boolean;
32
34
  }
33
35
  /**
34
36
  * A per-weapon magazine: discrete loaded rounds, a timed reload that refills from a reserve pool,
@@ -54,6 +56,12 @@ export interface Magazine {
54
56
  canReload(): boolean;
55
57
  /** Begin the reload timer if `canReload()`; returns whether it started. */
56
58
  startReload(): boolean;
59
+ /** Suspend an in-progress reload, keeping its elapsed progress, so it can be resumed later. */
60
+ pauseReload(): boolean;
61
+ /** Continue a paused reload from the progress it kept. */
62
+ resumeReload(): boolean;
63
+ /** True while a reload is suspended by `pauseReload()`. */
64
+ isPaused(): boolean;
57
65
  /** Abort an in-progress reload; loaded/reserve are unchanged (only completion moves ammo). */
58
66
  cancelReload(): void;
59
67
  /** Add `amount` to a self-contained numeric reserve (a pickup); a no-op against an infinite or externally-owned reserve without a `gain`. */
@@ -39,6 +39,7 @@ export function createMagazine(config) {
39
39
  }
40
40
  let loaded = clampLoaded(config.loaded ?? capacity);
41
41
  let reloadElapsedMs = null;
42
+ let paused = false;
42
43
  return {
43
44
  loaded: () => loaded,
44
45
  capacity: () => capacity,
@@ -65,7 +66,7 @@ export function createMagazine(config) {
65
66
  return true;
66
67
  },
67
68
  reserve: () => (isInfiniteReserve ? null : reserve.current()),
68
- isReloading: () => reloadElapsedMs !== null,
69
+ isReloading: () => reloadElapsedMs !== null && !paused,
69
70
  reloadFraction: () => (reloadElapsedMs === null || reloadMs <= 0 ? 0 : Math.min(1, reloadElapsedMs / reloadMs)),
70
71
  isEmpty: () => loaded <= 0,
71
72
  isFull: () => loaded >= capacity,
@@ -91,17 +92,32 @@ export function createMagazine(config) {
91
92
  if (!this.canReload())
92
93
  return false;
93
94
  reloadElapsedMs = 0;
95
+ paused = false;
94
96
  return true;
95
97
  },
98
+ pauseReload() {
99
+ if (reloadElapsedMs === null || paused)
100
+ return false;
101
+ paused = true;
102
+ return true;
103
+ },
104
+ resumeReload() {
105
+ if (!paused)
106
+ return false;
107
+ paused = false;
108
+ return true;
109
+ },
110
+ isPaused: () => paused,
96
111
  cancelReload() {
97
112
  reloadElapsedMs = null;
113
+ paused = false;
98
114
  },
99
115
  addReserve(amount) {
100
116
  if (amount > 0)
101
117
  reserve.gain?.(amount);
102
118
  },
103
119
  tick(dtSeconds) {
104
- if (dtSeconds <= 0 || reloadElapsedMs === null)
120
+ if (dtSeconds <= 0 || reloadElapsedMs === null || paused)
105
121
  return;
106
122
  reloadElapsedMs += dtSeconds * 1000;
107
123
  if (reloadElapsedMs < reloadMs)
@@ -119,6 +135,7 @@ export function createMagazine(config) {
119
135
  loaded,
120
136
  reloadElapsedMs,
121
137
  reserve: isInfiniteReserve ? null : reserve.current(),
138
+ ...(paused ? { paused: true } : {}),
122
139
  };
123
140
  },
124
141
  restore(snapshot) {
@@ -139,6 +156,7 @@ export function createMagazine(config) {
139
156
  snapshot.reloadElapsedMs === null
140
157
  ? null
141
158
  : Math.max(0, Math.min(reloadMs, snapshot.reloadElapsedMs));
159
+ paused = reloadElapsedMs !== null && snapshot.paused === true;
142
160
  return true;
143
161
  },
144
162
  };