eve-fit-engine 0.1.0 → 0.1.2

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.
package/dist/node.js CHANGED
@@ -653,7 +653,7 @@ var FitContext = class {
653
653
  const root = this.resolveDomain(modifier.domain, source);
654
654
  switch (modifier.func) {
655
655
  case "ItemModifier":
656
- if (modifier.domain === "charID" && source.kind === "module") {
656
+ if (modifier.domain === "charID" && source.kind === "module" && modifier.modifiedAttributeID === 212) {
657
657
  const out = [];
658
658
  for (const m of this.modules) {
659
659
  if (m.charge) out.push(m.charge);
@@ -1065,6 +1065,299 @@ function makeModeState(modeTypeID, type) {
1065
1065
  return new ItemState({ kind: "mode", id: `mode:${modeTypeID}`, type, state: "ACTIVE" });
1066
1066
  }
1067
1067
 
1068
+ // src/stackingGroups.ts
1069
+ var STACKING_PENALTY_GROUPS = /* @__PURE__ */ new Map([
1070
+ [89, "default"],
1071
+ [91, "default"],
1072
+ [92, "default"],
1073
+ [93, "default"],
1074
+ [95, "default"],
1075
+ [96, "default"],
1076
+ [394, "default"],
1077
+ [395, "default"],
1078
+ [494, "default"],
1079
+ [607, "postMul"],
1080
+ [657, "default"],
1081
+ [699, "default"],
1082
+ [763, "default"],
1083
+ [784, "default"],
1084
+ [854, "cloakingScanResolutionMultiplier"],
1085
+ [856, "default"],
1086
+ [889, "default"],
1087
+ [891, "default"],
1088
+ [892, "default"],
1089
+ [1024, "default"],
1090
+ [1230, "default"],
1091
+ [1281, "default"],
1092
+ [1318, "default"],
1093
+ [1445, "default"],
1094
+ [1446, "default"],
1095
+ [1448, "default"],
1096
+ [1452, "default"],
1097
+ [1472, "default"],
1098
+ [1590, "default"],
1099
+ [1617, "default"],
1100
+ [1720, "default"],
1101
+ [1764, "default"],
1102
+ [1885, "postPerc"],
1103
+ [1886, "postPerc"],
1104
+ [2013, "default"],
1105
+ [2014, "default"],
1106
+ [2019, "default"],
1107
+ [2020, "default"],
1108
+ [2041, "default"],
1109
+ [2052, "default"],
1110
+ [2152, "default"],
1111
+ [2232, "default"],
1112
+ [2302, "preMul"],
1113
+ [2644, "default"],
1114
+ [2645, "default"],
1115
+ [2646, "default"],
1116
+ [2670, "default"],
1117
+ [2693, "default"],
1118
+ [2694, "default"],
1119
+ [2695, "default"],
1120
+ [2696, "default"],
1121
+ [2697, "default"],
1122
+ [2698, "default"],
1123
+ [2716, "default"],
1124
+ [2717, "default"],
1125
+ [2792, "default"],
1126
+ [2795, "default"],
1127
+ [2796, "default"],
1128
+ [2797, "default"],
1129
+ [2798, "default"],
1130
+ [2799, "default"],
1131
+ [2801, "default"],
1132
+ [2802, "default"],
1133
+ [2803, "default"],
1134
+ [2804, "default"],
1135
+ [2851, "default"],
1136
+ [2858, "default"],
1137
+ [2865, "default"],
1138
+ [2867, "default"],
1139
+ [2868, "default"],
1140
+ [3001, "postPerc"],
1141
+ [3046, "default"],
1142
+ [3174, "default"],
1143
+ [3175, "default"],
1144
+ [3182, "default"],
1145
+ [3200, "default"],
1146
+ [3201, "default"],
1147
+ [3586, "default"],
1148
+ [3655, "default"],
1149
+ [3656, "default"],
1150
+ [3657, "default"],
1151
+ [3659, "default"],
1152
+ [3674, "default"],
1153
+ [3726, "default"],
1154
+ [3727, "default"],
1155
+ [3993, "postMul"],
1156
+ [3995, "postMul"],
1157
+ [3996, "default"],
1158
+ [3997, "default"],
1159
+ [3998, "default"],
1160
+ [3999, "default"],
1161
+ [4002, "postMul"],
1162
+ [4003, "postMul"],
1163
+ [4016, "default"],
1164
+ [4017, "postMul"],
1165
+ [4018, "postMul"],
1166
+ [4019, "postMul"],
1167
+ [4020, "postMul"],
1168
+ [4021, "postMul"],
1169
+ [4022, "postMul"],
1170
+ [4023, "postMul"],
1171
+ [4054, "default"],
1172
+ [4055, "default"],
1173
+ [4056, "default"],
1174
+ [4057, "postMul"],
1175
+ [4058, "postMul"],
1176
+ [4059, "postMul"],
1177
+ [4060, "postMul"],
1178
+ [4061, "postMul"],
1179
+ [4062, "postMul"],
1180
+ [4063, "postMul"],
1181
+ [4086, "postMul"],
1182
+ [4088, "postMul"],
1183
+ [4089, "postMul"],
1184
+ [4135, "default"],
1185
+ [4136, "default"],
1186
+ [4137, "default"],
1187
+ [4138, "default"],
1188
+ [4162, "default"],
1189
+ [4280, "default"],
1190
+ [4358, "default"],
1191
+ [4464, "default"],
1192
+ [4489, "default"],
1193
+ [4490, "default"],
1194
+ [4491, "default"],
1195
+ [4492, "default"],
1196
+ [4527, "default"],
1197
+ [4559, "default"],
1198
+ [4575, "default"],
1199
+ [4809, "default"],
1200
+ [4810, "default"],
1201
+ [4811, "default"],
1202
+ [4812, "default"],
1203
+ [4906, "postMul"],
1204
+ [4928, "preMul"],
1205
+ [4961, "postMul"],
1206
+ [5081, "default"],
1207
+ [5188, "default"],
1208
+ [5189, "default"],
1209
+ [5190, "default"],
1210
+ [5213, "default"],
1211
+ [5214, "default"],
1212
+ [5230, "default"],
1213
+ [5231, "default"],
1214
+ [5397, "default"],
1215
+ [5399, "default"],
1216
+ [5440, "postMul"],
1217
+ [5468, "default"],
1218
+ [5560, "default"],
1219
+ [5618, "postPerc"],
1220
+ [5757, "default"],
1221
+ [5867, "default"],
1222
+ [5911, "default"],
1223
+ [5912, "postMul"],
1224
+ [5914, "postMul"],
1225
+ [5915, "postMul"],
1226
+ [5916, "postMul"],
1227
+ [5917, "postMul"],
1228
+ [5918, "postMul"],
1229
+ [5919, "postMul"],
1230
+ [5920, "postMul"],
1231
+ [5921, "postMul"],
1232
+ [5922, "postMul"],
1233
+ [5923, "postMul"],
1234
+ [5924, "postMul"],
1235
+ [5925, "postMul"],
1236
+ [5926, "postMul"],
1237
+ [5927, "postMul"],
1238
+ [5929, "postMul"],
1239
+ [5951, "default"],
1240
+ [5998, "default"],
1241
+ [6010, "postDiv"],
1242
+ [6011, "postDiv"],
1243
+ [6012, "postDiv"],
1244
+ [6014, "postDiv"],
1245
+ [6015, "postDiv"],
1246
+ [6016, "postDiv"],
1247
+ [6017, "postDiv"],
1248
+ [6039, "postDiv"],
1249
+ [6040, "postDiv"],
1250
+ [6041, "postDiv"],
1251
+ [6063, "default"],
1252
+ [6076, "postDiv"],
1253
+ [6110, "default"],
1254
+ [6111, "default"],
1255
+ [6112, "default"],
1256
+ [6113, "default"],
1257
+ [6135, "default"],
1258
+ [6152, "postDiv"],
1259
+ [6154, "postDiv"],
1260
+ [6164, "default"],
1261
+ [6201, "default"],
1262
+ [6208, "default"],
1263
+ [6402, "default"],
1264
+ [6403, "default"],
1265
+ [6404, "default"],
1266
+ [6405, "default"],
1267
+ [6406, "default"],
1268
+ [6409, "default"],
1269
+ [6410, "default"],
1270
+ [6411, "default"],
1271
+ [6412, "default"],
1272
+ [6422, "default"],
1273
+ [6423, "default"],
1274
+ [6424, "default"],
1275
+ [6425, "default"],
1276
+ [6426, "default"],
1277
+ [6427, "default"],
1278
+ [6428, "default"],
1279
+ [6435, "default"],
1280
+ [6439, "default"],
1281
+ [6440, "default"],
1282
+ [6441, "default"],
1283
+ [6448, "default"],
1284
+ [6449, "default"],
1285
+ [6472, "default"],
1286
+ [6473, "default"],
1287
+ [6474, "default"],
1288
+ [6476, "default"],
1289
+ [6478, "default"],
1290
+ [6479, "default"],
1291
+ [6481, "default"],
1292
+ [6484, "postMul"],
1293
+ [6487, "default"],
1294
+ [6555, "default"],
1295
+ [6556, "default"],
1296
+ [6557, "default"],
1297
+ [6559, "default"],
1298
+ [6566, "postMul"],
1299
+ [6567, "default"],
1300
+ [6581, "default"],
1301
+ [6582, "postPercent"],
1302
+ [6658, "preMul"],
1303
+ [6670, "default"],
1304
+ [6671, "default"],
1305
+ [6682, "default"],
1306
+ [6683, "default"],
1307
+ [6684, "default"],
1308
+ [6686, "default"],
1309
+ [6690, "default"],
1310
+ [6692, "default"],
1311
+ [6693, "default"],
1312
+ [6694, "default"],
1313
+ [6727, "default"],
1314
+ [6730, "postMul"],
1315
+ [6731, "postMul"],
1316
+ [6796, "postDiv"],
1317
+ [6797, "postDiv"],
1318
+ [6798, "postDiv"],
1319
+ [6799, "postDiv"],
1320
+ [6801, "postDiv"],
1321
+ [6877, "default"],
1322
+ [7029, "default"],
1323
+ [7077, "default"],
1324
+ [7078, "default"],
1325
+ [7098, "default"],
1326
+ [7111, "default"],
1327
+ [7142, "default"],
1328
+ [7202, "default"],
1329
+ [7203, "default"],
1330
+ [7223, "default"],
1331
+ [7237, "default"],
1332
+ [8033, "default"],
1333
+ [8057, "default"],
1334
+ [8076, "default"],
1335
+ [8082, "default"],
1336
+ [8108, "postMul"],
1337
+ [8109, "postMul"],
1338
+ [8111, "default"],
1339
+ [8112, "default"],
1340
+ [8113, "postMul"],
1341
+ [8114, "postMul"],
1342
+ [8119, "default"],
1343
+ [11445, "default"],
1344
+ [11691, "default"],
1345
+ [11946, "default"],
1346
+ [11947, "postMul"],
1347
+ [11948, "postMul"],
1348
+ [11953, "postMul"],
1349
+ [12126, "default"],
1350
+ [12597, "default"],
1351
+ [12761, "default"],
1352
+ [12794, "postDiv"],
1353
+ [12795, "postDiv"],
1354
+ [12796, "postDiv"],
1355
+ [12798, "postDiv"],
1356
+ [12799, "postDiv"],
1357
+ [12838, "postMul"],
1358
+ [12839, "postMul"]
1359
+ ]);
1360
+
1068
1361
  // src/modifierEngine.ts
1069
1362
  var NO_PENALTY_KINDS = /* @__PURE__ */ new Set([
1070
1363
  "skill",
@@ -1079,16 +1372,21 @@ function scaleForPipeline(rawValue, _unitID, op) {
1079
1372
  }
1080
1373
  function applySourceItem(source, ctx, dataset) {
1081
1374
  const isLocalModule = source.kind === "module";
1375
+ const selfMods = [];
1376
+ const outMods = [];
1082
1377
  for (const eid of source.effectIDs) {
1083
1378
  if (LEGACY_HANDLED_EFFECT_IDS.has(eid)) continue;
1379
+ if (SEC_STATUS_SCALED_EFFECT_IDS.has(eid)) continue;
1084
1380
  if (isLocalModule && ctx.stoppedLocalEffectIDs.has(eid)) continue;
1085
1381
  const effect = dataset.effects.get(eid);
1086
1382
  if (!effect) continue;
1087
1383
  if (!source.appliesAtState(effect)) continue;
1088
1384
  for (const mi of effect.modifierInfo) {
1089
- applyOneModifier(source, effect, mi, ctx, dataset);
1385
+ (mi.domain === "itemID" ? selfMods : outMods).push({ effect, mi });
1090
1386
  }
1091
1387
  }
1388
+ for (const { effect, mi } of selfMods) applyOneModifier(source, effect, mi, ctx, dataset);
1389
+ for (const { effect, mi } of outMods) applyOneModifier(source, effect, mi, ctx, dataset);
1092
1390
  }
1093
1391
  function collectEffectStoppers(projectedSources, dataset) {
1094
1392
  const out = /* @__PURE__ */ new Set();
@@ -1154,7 +1452,7 @@ function applyOneModifier(source, effect, mi, ctx, dataset) {
1154
1452
  if (computed === null) return;
1155
1453
  const targets = ctx.targetsForModifier(mi, source);
1156
1454
  if (targets.length === 0) return;
1157
- const stackingGroup = computeStackingGroup(source, mi, dataset);
1455
+ const stackingGroup = computeStackingGroup(source, mi, dataset, effect);
1158
1456
  const isMul = op === "PreMul" || op === "PostMul" || op === "PreDiv" || op === "PostDiv";
1159
1457
  const value = isMul && computed.scaled ? 1 + computed.value : computed.value;
1160
1458
  const sdeDefault = isMul ? dataset.attributes.get(mi.modifiedAttributeID)?.defaultValue ?? 0 : 0;
@@ -1662,7 +1960,52 @@ var SHIP_BONUS_SCALING_SKILL = /* @__PURE__ */ new Map([
1662
1960
  [6112, 24313],
1663
1961
  [6113, 24312],
1664
1962
  [6114, 24311],
1665
- [6116, 24314]
1963
+ [6116, 24314],
1964
+ // ----- Auto-derived from the SDE: attrs that are BOTH skill-level-scaled
1965
+ // (a skill effect does `attr ×= skillLevel` via attr 280) AND read by a
1966
+ // SHIP-side effect as the bonus value. These are per-racial/role-skill hull
1967
+ // bonuses whose ship-side reader must scale by the skill level. Previously
1968
+ // missing → the bonus was taken at base (×1) instead of ×5 at All-V.
1969
+ // Notable: Exhumer/Barge shield+armor resist role bonuses (Hulk/Skiff/
1970
+ // Mackinaw shield resist 4 %→20 %), Bhaalgorn drone+laser (492), industrial
1971
+ // command (Orca/Rorqual), Marauder/pirate/expedition hull bonuses.
1972
+ [66, 89611],
1973
+ [310, 3432],
1974
+ [349, 19760],
1975
+ [492, 3339],
1976
+ [1296, 21610],
1977
+ [1669, 3184],
1978
+ [1670, 3184],
1979
+ [1842, 32918],
1980
+ [3167, 33856],
1981
+ [3181, 17940],
1982
+ [3182, 17940],
1983
+ [3183, 17940],
1984
+ [3184, 17940],
1985
+ [3185, 17940],
1986
+ [3187, 17940],
1987
+ [3188, 17940],
1988
+ [3190, 33856],
1989
+ [3191, 33856],
1990
+ [3192, 33856],
1991
+ [3193, 22551],
1992
+ [3194, 22551],
1993
+ [3197, 22551],
1994
+ [3198, 22551],
1995
+ [3199, 22551],
1996
+ [3203, 29637],
1997
+ [3204, 29637],
1998
+ [3205, 29637],
1999
+ [3210, 3341],
2000
+ [3221, 29637],
2001
+ [3222, 29637],
2002
+ [3223, 28374],
2003
+ [3224, 28374],
2004
+ [3237, 32918],
2005
+ [3240, 32918],
2006
+ [3326, 28374],
2007
+ [6088, 33092],
2008
+ [6089, 33094]
1666
2009
  ]);
1667
2010
  var SUBSYSTEM_BONUS_SCALING_SKILL = /* @__PURE__ */ new Map([
1668
2011
  // Amarr
@@ -1717,8 +2060,42 @@ var SUBSYSTEM_BONUS_SCALING_SKILL = /* @__PURE__ */ new Map([
1717
2060
  // MinmatarDefensive
1718
2061
  [1449, 30551],
1719
2062
  // MinmatarOffensive
1720
- [1450, 30554]
2063
+ [1450, 30554],
1721
2064
  // MinmatarPropulsion
2065
+ // ----- Authoritative completion: every subsystem-bonus attr that the SDE
2066
+ // ----- scales by a racial subsystem skill, derived verbatim from the
2067
+ // ----- `subsystemSkillLevel*` skill effects (modAttr = bonus attr,
2068
+ // ----- modifying = 280/skillLevel, PreMul). The Amarr/Caldari secondaries
2069
+ // ----- above were hand-added; Gallente + Minmatar secondaries (and the
2070
+ // ----- 2680-2687 defensive/core block) were MISSING, so subsystem-sourced
2071
+ // ----- bonuses on those races (e.g. Loki Propulsion agility 1523, Offensive
2072
+ // ----- RoF 1522 / 1534) fell to the flat path and applied ×1 instead of ×5.
2073
+ // ----- Duplicates of the entries above are harmless (same value).
2074
+ [1517, 30540],
2075
+ [1519, 30546],
2076
+ [1520, 30553],
2077
+ [1521, 30550],
2078
+ // Gallente Def/Core/Prop/Off secondaries
2079
+ [1522, 30551],
2080
+ [1523, 30554],
2081
+ [1525, 30547],
2082
+ [1526, 30545],
2083
+ // Minmatar Off/Prop/Core/Def secondaries
2084
+ [1531, 30537],
2085
+ [1532, 30550],
2086
+ [1533, 30549],
2087
+ [1534, 30551],
2088
+ // Off cross-race tertiaries (Amarr/Gallente/Caldari/Minmatar)
2089
+ [2680, 30532],
2090
+ [2681, 30539],
2091
+ [2682, 30544],
2092
+ [2683, 30548],
2093
+ // Def/Core extra (Amarr/Caldari)
2094
+ [2684, 30540],
2095
+ [2685, 30546],
2096
+ [2686, 30545],
2097
+ [2687, 30547]
2098
+ // Def/Core extra (Gallente/Minmatar)
1722
2099
  ]);
1723
2100
  function computeModifierValue(source, mi, ctx, dataset, op) {
1724
2101
  if (mi.modifyingAttributeID === void 0) return null;
@@ -1758,7 +2135,7 @@ function computeModifierValue(source, mi, ctx, dataset, op) {
1758
2135
  if (skillID === void 0) return null;
1759
2136
  const level = ctx.skillLevel(skillID);
1760
2137
  if (level === 0) return null;
1761
- if (source.kind === "ship" && SHIP_ROLE_BONUS_ATTRS.has(mi.modifyingAttributeID)) {
2138
+ if (source.kind === "ship") {
1762
2139
  return { value: baseValue, scaled: false };
1763
2140
  }
1764
2141
  if (itemRequiresSkill(source, skillID)) {
@@ -1768,46 +2145,21 @@ function computeModifierValue(source, mi, ctx, dataset, op) {
1768
2145
  }
1769
2146
  return { value: baseValue, scaled: false };
1770
2147
  }
1771
- var SHIP_ROLE_BONUS_ATTRS = /* @__PURE__ */ new Set([
1772
- 793,
1773
- // shipBonusRole7
1774
- 1688,
1775
- // shipBonusRole8
1776
- 1803,
1777
- // MWDSignatureRadiusBonus — Assault Frigate / Interceptor MWD sig role bonus (flat)
1778
- 2059,
1779
- // eliteBonusCommandDestroyer1 — Command Destroyer T2 specialisation (flat per-level applied via skill, but the SHIP-side reader is flat)
1780
- 2060,
1781
- // eliteBonusCommandDestroyer2
1782
- 2064,
1783
- // roleBonusCD — Command Destroyer command burst PG / activation cost reduction.
1784
- // Without this entry, effect 6214 (Draugur's `roleBonusCDLinksPGReduction`)
1785
- // double-applies the -95 % bonus at × Skirmish Command Burst V → -475 %
1786
- // PostPercent on Skirmish Command Burst II `power` (110) → -412.5 MW per
1787
- // burst → total ship power used reads −738 MW instead of +97 MW.
1788
- 2298,
1789
- // shipBonusRole1
1790
- 2299,
1791
- // shipBonusRole2
1792
- 2300,
1793
- // shipBonusRole3
1794
- 2301,
1795
- // shipBonusRole4
1796
- 2302,
1797
- // shipBonusRole5
1798
- 2303,
1799
- // shipBonusRole6
1800
- 5952
1801
- // shipBonusGasCloudDurationRoleBonusOreMiningDestroyer
1802
- ]);
1803
- function computeStackingGroup(source, mi, dataset) {
2148
+ function computeStackingGroup(source, mi, dataset, effect) {
1804
2149
  if (NO_PENALTY_KINDS.has(source.kind)) return null;
1805
2150
  if (mi.modifiedAttributeID !== void 0) {
1806
2151
  const attr = dataset.attributes.get(mi.modifiedAttributeID);
1807
2152
  if (attr?.stackable) return null;
1808
2153
  }
2154
+ const group = STACKING_PENALTY_GROUPS.get(effect.id);
2155
+ if (group !== void 0 && group !== "default" && CUSTOM_STACK_GROUPS_HONOURED.has(group)) {
2156
+ return `${group}:${mi.modifiedAttributeID}`;
2157
+ }
1809
2158
  return `attr:${mi.modifiedAttributeID}`;
1810
2159
  }
2160
+ var CUSTOM_STACK_GROUPS_HONOURED = /* @__PURE__ */ new Set([
2161
+ "cloakingScanResolutionMultiplier"
2162
+ ]);
1811
2163
  function mapSourceKind(kind) {
1812
2164
  switch (kind) {
1813
2165
  case "ship":
@@ -3525,6 +3877,13 @@ var LEGACY_HANDLED_HARDCODED_EFFECTS = /* @__PURE__ */ new Set([
3525
3877
  6658
3526
3878
  // Bastion Module — applyLegacyBastion
3527
3879
  ]);
3880
+ var SEC_STATUS_SCALED_EFFECT_IDS = /* @__PURE__ */ new Set([
3881
+ 6871,
3882
+ 12165,
3883
+ 12181,
3884
+ 12185,
3885
+ 12202
3886
+ ]);
3528
3887
  var LEGACY_HANDLED_EFFECT_IDS = /* @__PURE__ */ new Set([
3529
3888
  ...LEGACY_HANDLED_PASSIVE_ADD_EFFECTS,
3530
3889
  ...LEGACY_HANDLED_HARDCODED_EFFECTS
@@ -3595,6 +3954,176 @@ function computeTotalEhp(shield, armor, hull, useProfile) {
3595
3954
  return Number.isFinite(total) ? total : Number.MAX_SAFE_INTEGER;
3596
3955
  }
3597
3956
 
3957
+ // src/fitChecks.ts
3958
+ function readAttr(t, id) {
3959
+ return t.attributes.find((a) => a.id === id)?.v;
3960
+ }
3961
+ function typeFitsSlotType(t, slot) {
3962
+ for (const e of t.effects) {
3963
+ const mapped = SLOT_EFFECT_TO_SLOT_TYPE[e.id];
3964
+ if (mapped === slot) return true;
3965
+ }
3966
+ return false;
3967
+ }
3968
+ function isTurretWeapon(t) {
3969
+ return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "TURRET");
3970
+ }
3971
+ function isMissileLauncher(t) {
3972
+ return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "MISSILE");
3973
+ }
3974
+ function isSmartBomb(t) {
3975
+ return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "SMARTBOMB");
3976
+ }
3977
+ function shipGroupRestrictions(t) {
3978
+ const out = [];
3979
+ for (const id of CAN_FIT_SHIP_GROUP_ATTRS) {
3980
+ const v = readAttr(t, id);
3981
+ if (v != null && v > 0) out.push(Math.round(v));
3982
+ }
3983
+ return out;
3984
+ }
3985
+ function shipTypeRestrictions(t) {
3986
+ const out = [];
3987
+ for (const id of CAN_FIT_SHIP_TYPE_ATTRS) {
3988
+ const v = readAttr(t, id);
3989
+ if (v != null && v > 0) out.push(Math.round(v));
3990
+ }
3991
+ return out;
3992
+ }
3993
+ function maxGroupFittedFor(mod) {
3994
+ const v = readAttr(mod, ATTR.MAX_GROUP_FITTED);
3995
+ return v != null && v > 0 ? Math.round(v) : void 0;
3996
+ }
3997
+ function maxTypeFittedFor(mod) {
3998
+ const v = readAttr(mod, ATTR.MAX_TYPE_FITTED);
3999
+ return v != null && v > 0 ? Math.round(v) : void 0;
4000
+ }
4001
+ function freeFitGroupSlotsFor(mod, fittedModules, dataset) {
4002
+ const groupCap = maxGroupFittedFor(mod);
4003
+ const typeCap = maxTypeFittedFor(mod);
4004
+ if (groupCap === void 0 && typeCap === void 0) return Number.POSITIVE_INFINITY;
4005
+ let groupCount = 0;
4006
+ let typeCount = 0;
4007
+ for (const fm of fittedModules) {
4008
+ if (typeCap !== void 0 && fm.typeID === mod.id) typeCount++;
4009
+ if (groupCap !== void 0) {
4010
+ const t = dataset.getType(fm.typeID);
4011
+ if (t && t.groupID === mod.groupID) groupCount++;
4012
+ }
4013
+ }
4014
+ let free = Number.POSITIVE_INFINITY;
4015
+ if (groupCap !== void 0) free = Math.min(free, groupCap - groupCount);
4016
+ if (typeCap !== void 0) free = Math.min(free, typeCap - typeCount);
4017
+ return Math.max(0, free);
4018
+ }
4019
+ function canFitModuleOnShip(mod, ship, slot, fitContext) {
4020
+ if (!typeFitsSlotType(mod, slot)) {
4021
+ return { ok: false, reason: `Module does not declare a ${slot} slot effect` };
4022
+ }
4023
+ if (!ship) return { ok: true };
4024
+ const allowedGroups = shipGroupRestrictions(mod);
4025
+ const allowedTypes = shipTypeRestrictions(mod);
4026
+ if (allowedGroups.length > 0 || allowedTypes.length > 0) {
4027
+ const groupOk = allowedGroups.includes(ship.groupID);
4028
+ const typeOk = allowedTypes.includes(ship.id);
4029
+ if (!groupOk && !typeOk) {
4030
+ return { ok: false, reason: "Ship hull/group not allowed by this module" };
4031
+ }
4032
+ }
4033
+ if (slot === "RIG") {
4034
+ const modSize = readAttr(mod, ATTR.RIG_SIZE);
4035
+ const shipSize = readAttr(ship, ATTR.RIG_SIZE);
4036
+ if (modSize != null && shipSize != null && Math.round(modSize) !== Math.round(shipSize)) {
4037
+ return { ok: false, reason: "Rig size mismatch" };
4038
+ }
4039
+ }
4040
+ if (slot === "SUBSYSTEM") {
4041
+ const fitsTo = readAttr(mod, 1380);
4042
+ if (fitsTo != null && fitsTo > 0 && Math.round(fitsTo) !== ship.id) {
4043
+ return { ok: false, reason: "Subsystem locked to a different T3C hull" };
4044
+ }
4045
+ }
4046
+ if (slot === "HI") {
4047
+ if (isTurretWeapon(mod)) {
4048
+ const turrets = readAttr(ship, ATTR.TURRET_HARDPOINTS) ?? 0;
4049
+ if (turrets <= 0) {
4050
+ return { ok: false, reason: "Ship has no turret hardpoints" };
4051
+ }
4052
+ } else if (isMissileLauncher(mod)) {
4053
+ const launchers = readAttr(ship, ATTR.LAUNCHER_HARDPOINTS) ?? 0;
4054
+ if (launchers <= 0) {
4055
+ return { ok: false, reason: "Ship has no launcher hardpoints" };
4056
+ }
4057
+ }
4058
+ }
4059
+ if (fitContext) {
4060
+ const free = freeFitGroupSlotsFor(mod, fitContext.fittedModules, fitContext.dataset);
4061
+ if (free <= 0) {
4062
+ const cap = maxTypeFittedFor(mod) ?? maxGroupFittedFor(mod);
4063
+ return { ok: false, reason: `Only ${cap} of this module type can be fitted to a ship` };
4064
+ }
4065
+ }
4066
+ return { ok: true };
4067
+ }
4068
+ function chargeGroupsForModule(mod) {
4069
+ const out = [];
4070
+ for (const attrID of CHARGE_GROUP_ATTRS) {
4071
+ const v = readAttr(mod, attrID);
4072
+ if (v != null && v > 0) out.push(Math.round(v));
4073
+ }
4074
+ return out;
4075
+ }
4076
+ function moduleAcceptsAnyCharge(mod) {
4077
+ return chargeGroupsForModule(mod).length > 0;
4078
+ }
4079
+ function moduleAcceptsChargeType(mod, charge) {
4080
+ const allowedGroups = chargeGroupsForModule(mod);
4081
+ if (allowedGroups.length === 0) return false;
4082
+ if (!allowedGroups.includes(charge.groupID)) return false;
4083
+ const modSize = readAttr(mod, ATTR.CHARGE_SIZE);
4084
+ const chargeSize = readAttr(charge, ATTR.CHARGE_SIZE);
4085
+ if (modSize != null && chargeSize != null && Math.round(chargeSize) > Math.round(modSize)) {
4086
+ return false;
4087
+ }
4088
+ return true;
4089
+ }
4090
+ var ACTIVE_EFFECT_CATEGORIES = /* @__PURE__ */ new Set([1, 2, 3]);
4091
+ function isActivatableModule(mod, effects) {
4092
+ for (const e of mod.effects) {
4093
+ const eff = effects.get(e.id);
4094
+ if (eff && eff.effectCategoryID !== void 0 && ACTIVE_EFFECT_CATEGORIES.has(eff.effectCategoryID)) {
4095
+ return true;
4096
+ }
4097
+ }
4098
+ return false;
4099
+ }
4100
+ var DEFAULT_OFFLINE_ACTIVATION_GROUPS = /* @__PURE__ */ new Set([
4101
+ 330,
4102
+ 4117
4103
+ ]);
4104
+ function defaultStateForModule(mod, effects) {
4105
+ if (DEFAULT_OFFLINE_ACTIVATION_GROUPS.has(mod.groupID)) return "ONLINE";
4106
+ return isActivatableModule(mod, effects) ? "ACTIVE" : "ONLINE";
4107
+ }
4108
+ function freeHardpointsFor(mod, ship, fittedHiModules, dataset) {
4109
+ const turretCap = readAttr(ship, ATTR.TURRET_HARDPOINTS) ?? 0;
4110
+ const launcherCap = readAttr(ship, ATTR.LAUNCHER_HARDPOINTS) ?? 0;
4111
+ let turretUsed = 0;
4112
+ let launcherUsed = 0;
4113
+ for (const m of fittedHiModules) {
4114
+ const t = dataset.getType(m.typeID);
4115
+ if (!t) continue;
4116
+ if (isTurretWeapon(t)) turretUsed++;
4117
+ else if (isMissileLauncher(t)) launcherUsed++;
4118
+ }
4119
+ let hardpointFree;
4120
+ if (isTurretWeapon(mod)) hardpointFree = Math.max(0, turretCap - turretUsed);
4121
+ else if (isMissileLauncher(mod)) hardpointFree = Math.max(0, launcherCap - launcherUsed);
4122
+ else hardpointFree = Number.POSITIVE_INFINITY;
4123
+ const groupFree = freeFitGroupSlotsFor(mod, fittedHiModules, dataset);
4124
+ return Math.min(hardpointFree, groupFree);
4125
+ }
4126
+
3598
4127
  // src/derived/capacitor.ts
3599
4128
  function computeCapacitor(ctx, dataset) {
3600
4129
  const ship = ctx.ship;
@@ -3674,6 +4203,7 @@ var CAP_BOOSTER_EFFECT_ID = 48;
3674
4203
  var ATTR_CAPACITOR_BONUS = 67;
3675
4204
  var ATTR_CAPACITOR_NEED2 = 6;
3676
4205
  var ATTR_DURATION_MS2 = 73;
4206
+ var ATTR_REACTIVATION_MS = 669;
3677
4207
  var ATTR_RELOAD_TIME = 1795;
3678
4208
  var ATTR_CAPACITY = 38;
3679
4209
  var ATTR_VOLUME = 161;
@@ -3708,8 +4238,9 @@ function drainEntryFromEffect(effect, mod) {
3708
4238
  if (effect.dischargeAttributeID === void 0) return null;
3709
4239
  const discharge = mod.getFinal(effect.dischargeAttributeID, 0);
3710
4240
  if (discharge <= 0) return null;
3711
- const cycleMs = effect.durationAttributeID !== void 0 ? mod.getFinal(effect.durationAttributeID, 0) : 1e3;
3712
- if (cycleMs <= 0) return null;
4241
+ const baseCycleMs = effect.durationAttributeID !== void 0 ? mod.getFinal(effect.durationAttributeID, 0) : 1e3;
4242
+ if (baseCycleMs <= 0) return null;
4243
+ const cycleMs = Math.round(baseCycleMs + mod.getFinal(ATTR_REACTIVATION_MS, 0));
3713
4244
  return {
3714
4245
  cycleMs,
3715
4246
  capNeed: discharge,
@@ -3717,7 +4248,12 @@ function drainEntryFromEffect(effect, mod) {
3717
4248
  // ammo doesn't reload for cap purposes on regular modules
3718
4249
  reloadMs: 0,
3719
4250
  isInjector: false,
3720
- disableStagger: false
4251
+ // Pyfa: `disableStagger = mod.hardpoint == TURRET`. Turrets fire as a
4252
+ // synchronized volley (their cap drains aggregate, capNeed × N at the
4253
+ // shared cycle); everything else is staggered evenly across its cycle.
4254
+ // Without this, N turrets were staggered (one drain at cycle/N) instead
4255
+ // of N together, shifting the cap timeline (time-to-empty off ~5-10 %).
4256
+ disableStagger: isTurretWeapon(mod.type)
3721
4257
  };
3722
4258
  }
3723
4259
  function boosterDrainEntry(mod) {
@@ -3725,19 +4261,19 @@ function boosterDrainEntry(mod) {
3725
4261
  if (!charge) return null;
3726
4262
  const capNeed = mod.getFinal(ATTR_CAPACITOR_NEED2, 0);
3727
4263
  const inject = charge.getFinal(ATTR_CAPACITOR_BONUS, 0);
3728
- const cycleMs = mod.getFinal(ATTR_DURATION_MS2, 0);
4264
+ const cycleMs = Math.round(mod.getFinal(ATTR_DURATION_MS2, 0));
3729
4265
  if (cycleMs <= 0) return null;
3730
- let reloadMs = mod.getFinal(ATTR_RELOAD_TIME, 0);
4266
+ let reloadMs = Math.round(mod.getFinal(ATTR_RELOAD_TIME, 0));
3731
4267
  if (reloadMs <= 0) reloadMs = 1e4;
3732
4268
  const capacity = mod.getFinal(ATTR_CAPACITY, 0);
3733
4269
  const chargeVol = charge.getFinal(ATTR_VOLUME, 0);
3734
- const charges = capacity > 0 && chargeVol > 0 ? Math.floor(capacity / chargeVol) : 1;
4270
+ const charges = capacity > 0 && chargeVol > 0 ? Math.floor(capacity / chargeVol) : 0;
3735
4271
  return {
3736
4272
  cycleMs,
3737
4273
  // Pyfa convention: positive capNeed = drain, negative = injection.
3738
4274
  // For boosters the *net* per-cycle change is (capNeed_module - inject_charge).
3739
4275
  capNeed: capNeed - inject,
3740
- clipSize: Math.max(1, charges),
4276
+ clipSize: charges,
3741
4277
  reloadMs,
3742
4278
  isInjector: true,
3743
4279
  disableStagger: false
@@ -4669,8 +5205,8 @@ function computeFit(fit, dataset, opts) {
4669
5205
  }
4670
5206
  ctx.stoppedLocalEffectIDs = collectEffectStoppers(earlyProjected, dataset);
4671
5207
  }
4672
- for (const m of modules) applySourceItem(m, ctx, dataset);
4673
5208
  for (const m of modules) if (m.charge) applySourceItem(m.charge, ctx, dataset);
5209
+ for (const m of modules) applySourceItem(m, ctx, dataset);
4674
5210
  for (const d of drones) applySourceItem(d, ctx, dataset);
4675
5211
  for (const f of fighters) applySourceItem(f, ctx, dataset);
4676
5212
  for (const i of implants) applySourceItem(i, ctx, dataset);
@@ -4833,6 +5369,7 @@ function buildProjectionReport(source, ctx) {
4833
5369
  summary: labels[cls.kind] ?? cls.kind
4834
5370
  };
4835
5371
  }
5372
+ var CHAR_BASE_MAX_LOCKED_TARGETS = 2;
4836
5373
  function deriveStats(ctx, dataset, damageProfile, fit, projectionReports) {
4837
5374
  const ship = ctx.ship;
4838
5375
  const slotUsed = { HI: 0, MED: 0, LO: 0, RIG: 0, SUBSYSTEM: 0, SERVICE: 0 };
@@ -4953,7 +5490,18 @@ function deriveStats(ctx, dataset, damageProfile, fit, projectionReports) {
4953
5490
  ship.getFinal(ATTR.MAX_TARGET_RANGE, 0),
4954
5491
  ship.getFinal(ATTR.MAX_TARGET_RANGE_CAP, 3e5)
4955
5492
  ),
4956
- maxLockedTargets: ship.getFinal(ATTR.MAX_LOCKED_TARGETS, 0),
5493
+ // In-game lockable-target count is the LOWER of the ship's
5494
+ // maxLockedTargets and the pilot's skill-derived cap. Pyfa:
5495
+ // ceil(min(maxTargetsLockedFromSkills, ship maxLockedTargets)),
5496
+ // where maxTargetsLockedFromSkills seeds at a base of 2 (every
5497
+ // capsuleer locks 2 without skills) + Target Management / Advanced
5498
+ // Target Management (+1/level each) → 2 + 5 + 5 = 12 at All-V. The
5499
+ // character item carries only the skill ModAdds (10), so add the
5500
+ // base 2. Without the char cap, high-slot capitals reported 14-20.
5501
+ maxLockedTargets: Math.ceil(Math.min(
5502
+ CHAR_BASE_MAX_LOCKED_TARGETS + ctx.character.getFinal(ATTR.MAX_LOCKED_TARGETS, 0),
5503
+ ship.getFinal(ATTR.MAX_LOCKED_TARGETS, 0)
5504
+ )),
4957
5505
  signatureRadius: ship.getFinal(ATTR.SIGNATURE_RADIUS, 0),
4958
5506
  scanResolution: ship.getFinal(ATTR.SCAN_RESOLUTION, 0),
4959
5507
  sensorStrength: pickSensorStrength(ship).value,
@@ -5213,176 +5761,6 @@ function tempId() {
5213
5761
  return `tmp:${++tempCounter}:${Date.now().toString(36)}`;
5214
5762
  }
5215
5763
 
5216
- // src/fitChecks.ts
5217
- function readAttr(t, id) {
5218
- return t.attributes.find((a) => a.id === id)?.v;
5219
- }
5220
- function typeFitsSlotType(t, slot) {
5221
- for (const e of t.effects) {
5222
- const mapped = SLOT_EFFECT_TO_SLOT_TYPE[e.id];
5223
- if (mapped === slot) return true;
5224
- }
5225
- return false;
5226
- }
5227
- function isTurretWeapon(t) {
5228
- return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "TURRET");
5229
- }
5230
- function isMissileLauncher(t) {
5231
- return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "MISSILE");
5232
- }
5233
- function isSmartBomb(t) {
5234
- return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "SMARTBOMB");
5235
- }
5236
- function shipGroupRestrictions(t) {
5237
- const out = [];
5238
- for (const id of CAN_FIT_SHIP_GROUP_ATTRS) {
5239
- const v = readAttr(t, id);
5240
- if (v != null && v > 0) out.push(Math.round(v));
5241
- }
5242
- return out;
5243
- }
5244
- function shipTypeRestrictions(t) {
5245
- const out = [];
5246
- for (const id of CAN_FIT_SHIP_TYPE_ATTRS) {
5247
- const v = readAttr(t, id);
5248
- if (v != null && v > 0) out.push(Math.round(v));
5249
- }
5250
- return out;
5251
- }
5252
- function maxGroupFittedFor(mod) {
5253
- const v = readAttr(mod, ATTR.MAX_GROUP_FITTED);
5254
- return v != null && v > 0 ? Math.round(v) : void 0;
5255
- }
5256
- function maxTypeFittedFor(mod) {
5257
- const v = readAttr(mod, ATTR.MAX_TYPE_FITTED);
5258
- return v != null && v > 0 ? Math.round(v) : void 0;
5259
- }
5260
- function freeFitGroupSlotsFor(mod, fittedModules, dataset) {
5261
- const groupCap = maxGroupFittedFor(mod);
5262
- const typeCap = maxTypeFittedFor(mod);
5263
- if (groupCap === void 0 && typeCap === void 0) return Number.POSITIVE_INFINITY;
5264
- let groupCount = 0;
5265
- let typeCount = 0;
5266
- for (const fm of fittedModules) {
5267
- if (typeCap !== void 0 && fm.typeID === mod.id) typeCount++;
5268
- if (groupCap !== void 0) {
5269
- const t = dataset.getType(fm.typeID);
5270
- if (t && t.groupID === mod.groupID) groupCount++;
5271
- }
5272
- }
5273
- let free = Number.POSITIVE_INFINITY;
5274
- if (groupCap !== void 0) free = Math.min(free, groupCap - groupCount);
5275
- if (typeCap !== void 0) free = Math.min(free, typeCap - typeCount);
5276
- return Math.max(0, free);
5277
- }
5278
- function canFitModuleOnShip(mod, ship, slot, fitContext) {
5279
- if (!typeFitsSlotType(mod, slot)) {
5280
- return { ok: false, reason: `Module does not declare a ${slot} slot effect` };
5281
- }
5282
- if (!ship) return { ok: true };
5283
- const allowedGroups = shipGroupRestrictions(mod);
5284
- const allowedTypes = shipTypeRestrictions(mod);
5285
- if (allowedGroups.length > 0 || allowedTypes.length > 0) {
5286
- const groupOk = allowedGroups.includes(ship.groupID);
5287
- const typeOk = allowedTypes.includes(ship.id);
5288
- if (!groupOk && !typeOk) {
5289
- return { ok: false, reason: "Ship hull/group not allowed by this module" };
5290
- }
5291
- }
5292
- if (slot === "RIG") {
5293
- const modSize = readAttr(mod, ATTR.RIG_SIZE);
5294
- const shipSize = readAttr(ship, ATTR.RIG_SIZE);
5295
- if (modSize != null && shipSize != null && Math.round(modSize) !== Math.round(shipSize)) {
5296
- return { ok: false, reason: "Rig size mismatch" };
5297
- }
5298
- }
5299
- if (slot === "SUBSYSTEM") {
5300
- const fitsTo = readAttr(mod, 1380);
5301
- if (fitsTo != null && fitsTo > 0 && Math.round(fitsTo) !== ship.id) {
5302
- return { ok: false, reason: "Subsystem locked to a different T3C hull" };
5303
- }
5304
- }
5305
- if (slot === "HI") {
5306
- if (isTurretWeapon(mod)) {
5307
- const turrets = readAttr(ship, ATTR.TURRET_HARDPOINTS) ?? 0;
5308
- if (turrets <= 0) {
5309
- return { ok: false, reason: "Ship has no turret hardpoints" };
5310
- }
5311
- } else if (isMissileLauncher(mod)) {
5312
- const launchers = readAttr(ship, ATTR.LAUNCHER_HARDPOINTS) ?? 0;
5313
- if (launchers <= 0) {
5314
- return { ok: false, reason: "Ship has no launcher hardpoints" };
5315
- }
5316
- }
5317
- }
5318
- if (fitContext) {
5319
- const free = freeFitGroupSlotsFor(mod, fitContext.fittedModules, fitContext.dataset);
5320
- if (free <= 0) {
5321
- const cap = maxTypeFittedFor(mod) ?? maxGroupFittedFor(mod);
5322
- return { ok: false, reason: `Only ${cap} of this module type can be fitted to a ship` };
5323
- }
5324
- }
5325
- return { ok: true };
5326
- }
5327
- function chargeGroupsForModule(mod) {
5328
- const out = [];
5329
- for (const attrID of CHARGE_GROUP_ATTRS) {
5330
- const v = readAttr(mod, attrID);
5331
- if (v != null && v > 0) out.push(Math.round(v));
5332
- }
5333
- return out;
5334
- }
5335
- function moduleAcceptsAnyCharge(mod) {
5336
- return chargeGroupsForModule(mod).length > 0;
5337
- }
5338
- function moduleAcceptsChargeType(mod, charge) {
5339
- const allowedGroups = chargeGroupsForModule(mod);
5340
- if (allowedGroups.length === 0) return false;
5341
- if (!allowedGroups.includes(charge.groupID)) return false;
5342
- const modSize = readAttr(mod, ATTR.CHARGE_SIZE);
5343
- const chargeSize = readAttr(charge, ATTR.CHARGE_SIZE);
5344
- if (modSize != null && chargeSize != null && Math.round(chargeSize) > Math.round(modSize)) {
5345
- return false;
5346
- }
5347
- return true;
5348
- }
5349
- var ACTIVE_EFFECT_CATEGORIES = /* @__PURE__ */ new Set([1, 2, 3]);
5350
- function isActivatableModule(mod, effects) {
5351
- for (const e of mod.effects) {
5352
- const eff = effects.get(e.id);
5353
- if (eff && eff.effectCategoryID !== void 0 && ACTIVE_EFFECT_CATEGORIES.has(eff.effectCategoryID)) {
5354
- return true;
5355
- }
5356
- }
5357
- return false;
5358
- }
5359
- var DEFAULT_OFFLINE_ACTIVATION_GROUPS = /* @__PURE__ */ new Set([
5360
- 330,
5361
- 4117
5362
- ]);
5363
- function defaultStateForModule(mod, effects) {
5364
- if (DEFAULT_OFFLINE_ACTIVATION_GROUPS.has(mod.groupID)) return "ONLINE";
5365
- return isActivatableModule(mod, effects) ? "ACTIVE" : "ONLINE";
5366
- }
5367
- function freeHardpointsFor(mod, ship, fittedHiModules, dataset) {
5368
- const turretCap = readAttr(ship, ATTR.TURRET_HARDPOINTS) ?? 0;
5369
- const launcherCap = readAttr(ship, ATTR.LAUNCHER_HARDPOINTS) ?? 0;
5370
- let turretUsed = 0;
5371
- let launcherUsed = 0;
5372
- for (const m of fittedHiModules) {
5373
- const t = dataset.getType(m.typeID);
5374
- if (!t) continue;
5375
- if (isTurretWeapon(t)) turretUsed++;
5376
- else if (isMissileLauncher(t)) launcherUsed++;
5377
- }
5378
- let hardpointFree;
5379
- if (isTurretWeapon(mod)) hardpointFree = Math.max(0, turretCap - turretUsed);
5380
- else if (isMissileLauncher(mod)) hardpointFree = Math.max(0, launcherCap - launcherUsed);
5381
- else hardpointFree = Number.POSITIVE_INFINITY;
5382
- const groupFree = freeFitGroupSlotsFor(mod, fittedHiModules, dataset);
5383
- return Math.min(hardpointFree, groupFree);
5384
- }
5385
-
5386
5764
  // src/eft/format.ts
5387
5765
  var SLOT_ORDER = ["LO", "MED", "HI", "RIG"];
5388
5766
  function formatEft(fit, dataset) {