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.cjs CHANGED
@@ -677,7 +677,7 @@ var FitContext = class {
677
677
  const root = this.resolveDomain(modifier.domain, source);
678
678
  switch (modifier.func) {
679
679
  case "ItemModifier":
680
- if (modifier.domain === "charID" && source.kind === "module") {
680
+ if (modifier.domain === "charID" && source.kind === "module" && modifier.modifiedAttributeID === 212) {
681
681
  const out = [];
682
682
  for (const m of this.modules) {
683
683
  if (m.charge) out.push(m.charge);
@@ -1089,6 +1089,299 @@ function makeModeState(modeTypeID, type) {
1089
1089
  return new ItemState({ kind: "mode", id: `mode:${modeTypeID}`, type, state: "ACTIVE" });
1090
1090
  }
1091
1091
 
1092
+ // src/stackingGroups.ts
1093
+ var STACKING_PENALTY_GROUPS = /* @__PURE__ */ new Map([
1094
+ [89, "default"],
1095
+ [91, "default"],
1096
+ [92, "default"],
1097
+ [93, "default"],
1098
+ [95, "default"],
1099
+ [96, "default"],
1100
+ [394, "default"],
1101
+ [395, "default"],
1102
+ [494, "default"],
1103
+ [607, "postMul"],
1104
+ [657, "default"],
1105
+ [699, "default"],
1106
+ [763, "default"],
1107
+ [784, "default"],
1108
+ [854, "cloakingScanResolutionMultiplier"],
1109
+ [856, "default"],
1110
+ [889, "default"],
1111
+ [891, "default"],
1112
+ [892, "default"],
1113
+ [1024, "default"],
1114
+ [1230, "default"],
1115
+ [1281, "default"],
1116
+ [1318, "default"],
1117
+ [1445, "default"],
1118
+ [1446, "default"],
1119
+ [1448, "default"],
1120
+ [1452, "default"],
1121
+ [1472, "default"],
1122
+ [1590, "default"],
1123
+ [1617, "default"],
1124
+ [1720, "default"],
1125
+ [1764, "default"],
1126
+ [1885, "postPerc"],
1127
+ [1886, "postPerc"],
1128
+ [2013, "default"],
1129
+ [2014, "default"],
1130
+ [2019, "default"],
1131
+ [2020, "default"],
1132
+ [2041, "default"],
1133
+ [2052, "default"],
1134
+ [2152, "default"],
1135
+ [2232, "default"],
1136
+ [2302, "preMul"],
1137
+ [2644, "default"],
1138
+ [2645, "default"],
1139
+ [2646, "default"],
1140
+ [2670, "default"],
1141
+ [2693, "default"],
1142
+ [2694, "default"],
1143
+ [2695, "default"],
1144
+ [2696, "default"],
1145
+ [2697, "default"],
1146
+ [2698, "default"],
1147
+ [2716, "default"],
1148
+ [2717, "default"],
1149
+ [2792, "default"],
1150
+ [2795, "default"],
1151
+ [2796, "default"],
1152
+ [2797, "default"],
1153
+ [2798, "default"],
1154
+ [2799, "default"],
1155
+ [2801, "default"],
1156
+ [2802, "default"],
1157
+ [2803, "default"],
1158
+ [2804, "default"],
1159
+ [2851, "default"],
1160
+ [2858, "default"],
1161
+ [2865, "default"],
1162
+ [2867, "default"],
1163
+ [2868, "default"],
1164
+ [3001, "postPerc"],
1165
+ [3046, "default"],
1166
+ [3174, "default"],
1167
+ [3175, "default"],
1168
+ [3182, "default"],
1169
+ [3200, "default"],
1170
+ [3201, "default"],
1171
+ [3586, "default"],
1172
+ [3655, "default"],
1173
+ [3656, "default"],
1174
+ [3657, "default"],
1175
+ [3659, "default"],
1176
+ [3674, "default"],
1177
+ [3726, "default"],
1178
+ [3727, "default"],
1179
+ [3993, "postMul"],
1180
+ [3995, "postMul"],
1181
+ [3996, "default"],
1182
+ [3997, "default"],
1183
+ [3998, "default"],
1184
+ [3999, "default"],
1185
+ [4002, "postMul"],
1186
+ [4003, "postMul"],
1187
+ [4016, "default"],
1188
+ [4017, "postMul"],
1189
+ [4018, "postMul"],
1190
+ [4019, "postMul"],
1191
+ [4020, "postMul"],
1192
+ [4021, "postMul"],
1193
+ [4022, "postMul"],
1194
+ [4023, "postMul"],
1195
+ [4054, "default"],
1196
+ [4055, "default"],
1197
+ [4056, "default"],
1198
+ [4057, "postMul"],
1199
+ [4058, "postMul"],
1200
+ [4059, "postMul"],
1201
+ [4060, "postMul"],
1202
+ [4061, "postMul"],
1203
+ [4062, "postMul"],
1204
+ [4063, "postMul"],
1205
+ [4086, "postMul"],
1206
+ [4088, "postMul"],
1207
+ [4089, "postMul"],
1208
+ [4135, "default"],
1209
+ [4136, "default"],
1210
+ [4137, "default"],
1211
+ [4138, "default"],
1212
+ [4162, "default"],
1213
+ [4280, "default"],
1214
+ [4358, "default"],
1215
+ [4464, "default"],
1216
+ [4489, "default"],
1217
+ [4490, "default"],
1218
+ [4491, "default"],
1219
+ [4492, "default"],
1220
+ [4527, "default"],
1221
+ [4559, "default"],
1222
+ [4575, "default"],
1223
+ [4809, "default"],
1224
+ [4810, "default"],
1225
+ [4811, "default"],
1226
+ [4812, "default"],
1227
+ [4906, "postMul"],
1228
+ [4928, "preMul"],
1229
+ [4961, "postMul"],
1230
+ [5081, "default"],
1231
+ [5188, "default"],
1232
+ [5189, "default"],
1233
+ [5190, "default"],
1234
+ [5213, "default"],
1235
+ [5214, "default"],
1236
+ [5230, "default"],
1237
+ [5231, "default"],
1238
+ [5397, "default"],
1239
+ [5399, "default"],
1240
+ [5440, "postMul"],
1241
+ [5468, "default"],
1242
+ [5560, "default"],
1243
+ [5618, "postPerc"],
1244
+ [5757, "default"],
1245
+ [5867, "default"],
1246
+ [5911, "default"],
1247
+ [5912, "postMul"],
1248
+ [5914, "postMul"],
1249
+ [5915, "postMul"],
1250
+ [5916, "postMul"],
1251
+ [5917, "postMul"],
1252
+ [5918, "postMul"],
1253
+ [5919, "postMul"],
1254
+ [5920, "postMul"],
1255
+ [5921, "postMul"],
1256
+ [5922, "postMul"],
1257
+ [5923, "postMul"],
1258
+ [5924, "postMul"],
1259
+ [5925, "postMul"],
1260
+ [5926, "postMul"],
1261
+ [5927, "postMul"],
1262
+ [5929, "postMul"],
1263
+ [5951, "default"],
1264
+ [5998, "default"],
1265
+ [6010, "postDiv"],
1266
+ [6011, "postDiv"],
1267
+ [6012, "postDiv"],
1268
+ [6014, "postDiv"],
1269
+ [6015, "postDiv"],
1270
+ [6016, "postDiv"],
1271
+ [6017, "postDiv"],
1272
+ [6039, "postDiv"],
1273
+ [6040, "postDiv"],
1274
+ [6041, "postDiv"],
1275
+ [6063, "default"],
1276
+ [6076, "postDiv"],
1277
+ [6110, "default"],
1278
+ [6111, "default"],
1279
+ [6112, "default"],
1280
+ [6113, "default"],
1281
+ [6135, "default"],
1282
+ [6152, "postDiv"],
1283
+ [6154, "postDiv"],
1284
+ [6164, "default"],
1285
+ [6201, "default"],
1286
+ [6208, "default"],
1287
+ [6402, "default"],
1288
+ [6403, "default"],
1289
+ [6404, "default"],
1290
+ [6405, "default"],
1291
+ [6406, "default"],
1292
+ [6409, "default"],
1293
+ [6410, "default"],
1294
+ [6411, "default"],
1295
+ [6412, "default"],
1296
+ [6422, "default"],
1297
+ [6423, "default"],
1298
+ [6424, "default"],
1299
+ [6425, "default"],
1300
+ [6426, "default"],
1301
+ [6427, "default"],
1302
+ [6428, "default"],
1303
+ [6435, "default"],
1304
+ [6439, "default"],
1305
+ [6440, "default"],
1306
+ [6441, "default"],
1307
+ [6448, "default"],
1308
+ [6449, "default"],
1309
+ [6472, "default"],
1310
+ [6473, "default"],
1311
+ [6474, "default"],
1312
+ [6476, "default"],
1313
+ [6478, "default"],
1314
+ [6479, "default"],
1315
+ [6481, "default"],
1316
+ [6484, "postMul"],
1317
+ [6487, "default"],
1318
+ [6555, "default"],
1319
+ [6556, "default"],
1320
+ [6557, "default"],
1321
+ [6559, "default"],
1322
+ [6566, "postMul"],
1323
+ [6567, "default"],
1324
+ [6581, "default"],
1325
+ [6582, "postPercent"],
1326
+ [6658, "preMul"],
1327
+ [6670, "default"],
1328
+ [6671, "default"],
1329
+ [6682, "default"],
1330
+ [6683, "default"],
1331
+ [6684, "default"],
1332
+ [6686, "default"],
1333
+ [6690, "default"],
1334
+ [6692, "default"],
1335
+ [6693, "default"],
1336
+ [6694, "default"],
1337
+ [6727, "default"],
1338
+ [6730, "postMul"],
1339
+ [6731, "postMul"],
1340
+ [6796, "postDiv"],
1341
+ [6797, "postDiv"],
1342
+ [6798, "postDiv"],
1343
+ [6799, "postDiv"],
1344
+ [6801, "postDiv"],
1345
+ [6877, "default"],
1346
+ [7029, "default"],
1347
+ [7077, "default"],
1348
+ [7078, "default"],
1349
+ [7098, "default"],
1350
+ [7111, "default"],
1351
+ [7142, "default"],
1352
+ [7202, "default"],
1353
+ [7203, "default"],
1354
+ [7223, "default"],
1355
+ [7237, "default"],
1356
+ [8033, "default"],
1357
+ [8057, "default"],
1358
+ [8076, "default"],
1359
+ [8082, "default"],
1360
+ [8108, "postMul"],
1361
+ [8109, "postMul"],
1362
+ [8111, "default"],
1363
+ [8112, "default"],
1364
+ [8113, "postMul"],
1365
+ [8114, "postMul"],
1366
+ [8119, "default"],
1367
+ [11445, "default"],
1368
+ [11691, "default"],
1369
+ [11946, "default"],
1370
+ [11947, "postMul"],
1371
+ [11948, "postMul"],
1372
+ [11953, "postMul"],
1373
+ [12126, "default"],
1374
+ [12597, "default"],
1375
+ [12761, "default"],
1376
+ [12794, "postDiv"],
1377
+ [12795, "postDiv"],
1378
+ [12796, "postDiv"],
1379
+ [12798, "postDiv"],
1380
+ [12799, "postDiv"],
1381
+ [12838, "postMul"],
1382
+ [12839, "postMul"]
1383
+ ]);
1384
+
1092
1385
  // src/modifierEngine.ts
1093
1386
  var NO_PENALTY_KINDS = /* @__PURE__ */ new Set([
1094
1387
  "skill",
@@ -1103,16 +1396,21 @@ function scaleForPipeline(rawValue, _unitID, op) {
1103
1396
  }
1104
1397
  function applySourceItem(source, ctx, dataset) {
1105
1398
  const isLocalModule = source.kind === "module";
1399
+ const selfMods = [];
1400
+ const outMods = [];
1106
1401
  for (const eid of source.effectIDs) {
1107
1402
  if (LEGACY_HANDLED_EFFECT_IDS.has(eid)) continue;
1403
+ if (SEC_STATUS_SCALED_EFFECT_IDS.has(eid)) continue;
1108
1404
  if (isLocalModule && ctx.stoppedLocalEffectIDs.has(eid)) continue;
1109
1405
  const effect = dataset.effects.get(eid);
1110
1406
  if (!effect) continue;
1111
1407
  if (!source.appliesAtState(effect)) continue;
1112
1408
  for (const mi of effect.modifierInfo) {
1113
- applyOneModifier(source, effect, mi, ctx, dataset);
1409
+ (mi.domain === "itemID" ? selfMods : outMods).push({ effect, mi });
1114
1410
  }
1115
1411
  }
1412
+ for (const { effect, mi } of selfMods) applyOneModifier(source, effect, mi, ctx, dataset);
1413
+ for (const { effect, mi } of outMods) applyOneModifier(source, effect, mi, ctx, dataset);
1116
1414
  }
1117
1415
  function collectEffectStoppers(projectedSources, dataset) {
1118
1416
  const out = /* @__PURE__ */ new Set();
@@ -1178,7 +1476,7 @@ function applyOneModifier(source, effect, mi, ctx, dataset) {
1178
1476
  if (computed === null) return;
1179
1477
  const targets = ctx.targetsForModifier(mi, source);
1180
1478
  if (targets.length === 0) return;
1181
- const stackingGroup = computeStackingGroup(source, mi, dataset);
1479
+ const stackingGroup = computeStackingGroup(source, mi, dataset, effect);
1182
1480
  const isMul = op === "PreMul" || op === "PostMul" || op === "PreDiv" || op === "PostDiv";
1183
1481
  const value = isMul && computed.scaled ? 1 + computed.value : computed.value;
1184
1482
  const sdeDefault = isMul ? dataset.attributes.get(mi.modifiedAttributeID)?.defaultValue ?? 0 : 0;
@@ -1686,7 +1984,52 @@ var SHIP_BONUS_SCALING_SKILL = /* @__PURE__ */ new Map([
1686
1984
  [6112, 24313],
1687
1985
  [6113, 24312],
1688
1986
  [6114, 24311],
1689
- [6116, 24314]
1987
+ [6116, 24314],
1988
+ // ----- Auto-derived from the SDE: attrs that are BOTH skill-level-scaled
1989
+ // (a skill effect does `attr ×= skillLevel` via attr 280) AND read by a
1990
+ // SHIP-side effect as the bonus value. These are per-racial/role-skill hull
1991
+ // bonuses whose ship-side reader must scale by the skill level. Previously
1992
+ // missing → the bonus was taken at base (×1) instead of ×5 at All-V.
1993
+ // Notable: Exhumer/Barge shield+armor resist role bonuses (Hulk/Skiff/
1994
+ // Mackinaw shield resist 4 %→20 %), Bhaalgorn drone+laser (492), industrial
1995
+ // command (Orca/Rorqual), Marauder/pirate/expedition hull bonuses.
1996
+ [66, 89611],
1997
+ [310, 3432],
1998
+ [349, 19760],
1999
+ [492, 3339],
2000
+ [1296, 21610],
2001
+ [1669, 3184],
2002
+ [1670, 3184],
2003
+ [1842, 32918],
2004
+ [3167, 33856],
2005
+ [3181, 17940],
2006
+ [3182, 17940],
2007
+ [3183, 17940],
2008
+ [3184, 17940],
2009
+ [3185, 17940],
2010
+ [3187, 17940],
2011
+ [3188, 17940],
2012
+ [3190, 33856],
2013
+ [3191, 33856],
2014
+ [3192, 33856],
2015
+ [3193, 22551],
2016
+ [3194, 22551],
2017
+ [3197, 22551],
2018
+ [3198, 22551],
2019
+ [3199, 22551],
2020
+ [3203, 29637],
2021
+ [3204, 29637],
2022
+ [3205, 29637],
2023
+ [3210, 3341],
2024
+ [3221, 29637],
2025
+ [3222, 29637],
2026
+ [3223, 28374],
2027
+ [3224, 28374],
2028
+ [3237, 32918],
2029
+ [3240, 32918],
2030
+ [3326, 28374],
2031
+ [6088, 33092],
2032
+ [6089, 33094]
1690
2033
  ]);
1691
2034
  var SUBSYSTEM_BONUS_SCALING_SKILL = /* @__PURE__ */ new Map([
1692
2035
  // Amarr
@@ -1741,8 +2084,42 @@ var SUBSYSTEM_BONUS_SCALING_SKILL = /* @__PURE__ */ new Map([
1741
2084
  // MinmatarDefensive
1742
2085
  [1449, 30551],
1743
2086
  // MinmatarOffensive
1744
- [1450, 30554]
2087
+ [1450, 30554],
1745
2088
  // MinmatarPropulsion
2089
+ // ----- Authoritative completion: every subsystem-bonus attr that the SDE
2090
+ // ----- scales by a racial subsystem skill, derived verbatim from the
2091
+ // ----- `subsystemSkillLevel*` skill effects (modAttr = bonus attr,
2092
+ // ----- modifying = 280/skillLevel, PreMul). The Amarr/Caldari secondaries
2093
+ // ----- above were hand-added; Gallente + Minmatar secondaries (and the
2094
+ // ----- 2680-2687 defensive/core block) were MISSING, so subsystem-sourced
2095
+ // ----- bonuses on those races (e.g. Loki Propulsion agility 1523, Offensive
2096
+ // ----- RoF 1522 / 1534) fell to the flat path and applied ×1 instead of ×5.
2097
+ // ----- Duplicates of the entries above are harmless (same value).
2098
+ [1517, 30540],
2099
+ [1519, 30546],
2100
+ [1520, 30553],
2101
+ [1521, 30550],
2102
+ // Gallente Def/Core/Prop/Off secondaries
2103
+ [1522, 30551],
2104
+ [1523, 30554],
2105
+ [1525, 30547],
2106
+ [1526, 30545],
2107
+ // Minmatar Off/Prop/Core/Def secondaries
2108
+ [1531, 30537],
2109
+ [1532, 30550],
2110
+ [1533, 30549],
2111
+ [1534, 30551],
2112
+ // Off cross-race tertiaries (Amarr/Gallente/Caldari/Minmatar)
2113
+ [2680, 30532],
2114
+ [2681, 30539],
2115
+ [2682, 30544],
2116
+ [2683, 30548],
2117
+ // Def/Core extra (Amarr/Caldari)
2118
+ [2684, 30540],
2119
+ [2685, 30546],
2120
+ [2686, 30545],
2121
+ [2687, 30547]
2122
+ // Def/Core extra (Gallente/Minmatar)
1746
2123
  ]);
1747
2124
  function computeModifierValue(source, mi, ctx, dataset, op) {
1748
2125
  if (mi.modifyingAttributeID === void 0) return null;
@@ -1782,7 +2159,7 @@ function computeModifierValue(source, mi, ctx, dataset, op) {
1782
2159
  if (skillID === void 0) return null;
1783
2160
  const level = ctx.skillLevel(skillID);
1784
2161
  if (level === 0) return null;
1785
- if (source.kind === "ship" && SHIP_ROLE_BONUS_ATTRS.has(mi.modifyingAttributeID)) {
2162
+ if (source.kind === "ship") {
1786
2163
  return { value: baseValue, scaled: false };
1787
2164
  }
1788
2165
  if (itemRequiresSkill(source, skillID)) {
@@ -1792,46 +2169,21 @@ function computeModifierValue(source, mi, ctx, dataset, op) {
1792
2169
  }
1793
2170
  return { value: baseValue, scaled: false };
1794
2171
  }
1795
- var SHIP_ROLE_BONUS_ATTRS = /* @__PURE__ */ new Set([
1796
- 793,
1797
- // shipBonusRole7
1798
- 1688,
1799
- // shipBonusRole8
1800
- 1803,
1801
- // MWDSignatureRadiusBonus — Assault Frigate / Interceptor MWD sig role bonus (flat)
1802
- 2059,
1803
- // eliteBonusCommandDestroyer1 — Command Destroyer T2 specialisation (flat per-level applied via skill, but the SHIP-side reader is flat)
1804
- 2060,
1805
- // eliteBonusCommandDestroyer2
1806
- 2064,
1807
- // roleBonusCD — Command Destroyer command burst PG / activation cost reduction.
1808
- // Without this entry, effect 6214 (Draugur's `roleBonusCDLinksPGReduction`)
1809
- // double-applies the -95 % bonus at × Skirmish Command Burst V → -475 %
1810
- // PostPercent on Skirmish Command Burst II `power` (110) → -412.5 MW per
1811
- // burst → total ship power used reads −738 MW instead of +97 MW.
1812
- 2298,
1813
- // shipBonusRole1
1814
- 2299,
1815
- // shipBonusRole2
1816
- 2300,
1817
- // shipBonusRole3
1818
- 2301,
1819
- // shipBonusRole4
1820
- 2302,
1821
- // shipBonusRole5
1822
- 2303,
1823
- // shipBonusRole6
1824
- 5952
1825
- // shipBonusGasCloudDurationRoleBonusOreMiningDestroyer
1826
- ]);
1827
- function computeStackingGroup(source, mi, dataset) {
2172
+ function computeStackingGroup(source, mi, dataset, effect) {
1828
2173
  if (NO_PENALTY_KINDS.has(source.kind)) return null;
1829
2174
  if (mi.modifiedAttributeID !== void 0) {
1830
2175
  const attr = dataset.attributes.get(mi.modifiedAttributeID);
1831
2176
  if (attr?.stackable) return null;
1832
2177
  }
2178
+ const group = STACKING_PENALTY_GROUPS.get(effect.id);
2179
+ if (group !== void 0 && group !== "default" && CUSTOM_STACK_GROUPS_HONOURED.has(group)) {
2180
+ return `${group}:${mi.modifiedAttributeID}`;
2181
+ }
1833
2182
  return `attr:${mi.modifiedAttributeID}`;
1834
2183
  }
2184
+ var CUSTOM_STACK_GROUPS_HONOURED = /* @__PURE__ */ new Set([
2185
+ "cloakingScanResolutionMultiplier"
2186
+ ]);
1835
2187
  function mapSourceKind(kind) {
1836
2188
  switch (kind) {
1837
2189
  case "ship":
@@ -3549,6 +3901,13 @@ var LEGACY_HANDLED_HARDCODED_EFFECTS = /* @__PURE__ */ new Set([
3549
3901
  6658
3550
3902
  // Bastion Module — applyLegacyBastion
3551
3903
  ]);
3904
+ var SEC_STATUS_SCALED_EFFECT_IDS = /* @__PURE__ */ new Set([
3905
+ 6871,
3906
+ 12165,
3907
+ 12181,
3908
+ 12185,
3909
+ 12202
3910
+ ]);
3552
3911
  var LEGACY_HANDLED_EFFECT_IDS = /* @__PURE__ */ new Set([
3553
3912
  ...LEGACY_HANDLED_PASSIVE_ADD_EFFECTS,
3554
3913
  ...LEGACY_HANDLED_HARDCODED_EFFECTS
@@ -3619,6 +3978,176 @@ function computeTotalEhp(shield, armor, hull, useProfile) {
3619
3978
  return Number.isFinite(total) ? total : Number.MAX_SAFE_INTEGER;
3620
3979
  }
3621
3980
 
3981
+ // src/fitChecks.ts
3982
+ function readAttr(t, id) {
3983
+ return t.attributes.find((a) => a.id === id)?.v;
3984
+ }
3985
+ function typeFitsSlotType(t, slot) {
3986
+ for (const e of t.effects) {
3987
+ const mapped = SLOT_EFFECT_TO_SLOT_TYPE[e.id];
3988
+ if (mapped === slot) return true;
3989
+ }
3990
+ return false;
3991
+ }
3992
+ function isTurretWeapon(t) {
3993
+ return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "TURRET");
3994
+ }
3995
+ function isMissileLauncher(t) {
3996
+ return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "MISSILE");
3997
+ }
3998
+ function isSmartBomb(t) {
3999
+ return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "SMARTBOMB");
4000
+ }
4001
+ function shipGroupRestrictions(t) {
4002
+ const out = [];
4003
+ for (const id of CAN_FIT_SHIP_GROUP_ATTRS) {
4004
+ const v = readAttr(t, id);
4005
+ if (v != null && v > 0) out.push(Math.round(v));
4006
+ }
4007
+ return out;
4008
+ }
4009
+ function shipTypeRestrictions(t) {
4010
+ const out = [];
4011
+ for (const id of CAN_FIT_SHIP_TYPE_ATTRS) {
4012
+ const v = readAttr(t, id);
4013
+ if (v != null && v > 0) out.push(Math.round(v));
4014
+ }
4015
+ return out;
4016
+ }
4017
+ function maxGroupFittedFor(mod) {
4018
+ const v = readAttr(mod, ATTR.MAX_GROUP_FITTED);
4019
+ return v != null && v > 0 ? Math.round(v) : void 0;
4020
+ }
4021
+ function maxTypeFittedFor(mod) {
4022
+ const v = readAttr(mod, ATTR.MAX_TYPE_FITTED);
4023
+ return v != null && v > 0 ? Math.round(v) : void 0;
4024
+ }
4025
+ function freeFitGroupSlotsFor(mod, fittedModules, dataset) {
4026
+ const groupCap = maxGroupFittedFor(mod);
4027
+ const typeCap = maxTypeFittedFor(mod);
4028
+ if (groupCap === void 0 && typeCap === void 0) return Number.POSITIVE_INFINITY;
4029
+ let groupCount = 0;
4030
+ let typeCount = 0;
4031
+ for (const fm of fittedModules) {
4032
+ if (typeCap !== void 0 && fm.typeID === mod.id) typeCount++;
4033
+ if (groupCap !== void 0) {
4034
+ const t = dataset.getType(fm.typeID);
4035
+ if (t && t.groupID === mod.groupID) groupCount++;
4036
+ }
4037
+ }
4038
+ let free = Number.POSITIVE_INFINITY;
4039
+ if (groupCap !== void 0) free = Math.min(free, groupCap - groupCount);
4040
+ if (typeCap !== void 0) free = Math.min(free, typeCap - typeCount);
4041
+ return Math.max(0, free);
4042
+ }
4043
+ function canFitModuleOnShip(mod, ship, slot, fitContext) {
4044
+ if (!typeFitsSlotType(mod, slot)) {
4045
+ return { ok: false, reason: `Module does not declare a ${slot} slot effect` };
4046
+ }
4047
+ if (!ship) return { ok: true };
4048
+ const allowedGroups = shipGroupRestrictions(mod);
4049
+ const allowedTypes = shipTypeRestrictions(mod);
4050
+ if (allowedGroups.length > 0 || allowedTypes.length > 0) {
4051
+ const groupOk = allowedGroups.includes(ship.groupID);
4052
+ const typeOk = allowedTypes.includes(ship.id);
4053
+ if (!groupOk && !typeOk) {
4054
+ return { ok: false, reason: "Ship hull/group not allowed by this module" };
4055
+ }
4056
+ }
4057
+ if (slot === "RIG") {
4058
+ const modSize = readAttr(mod, ATTR.RIG_SIZE);
4059
+ const shipSize = readAttr(ship, ATTR.RIG_SIZE);
4060
+ if (modSize != null && shipSize != null && Math.round(modSize) !== Math.round(shipSize)) {
4061
+ return { ok: false, reason: "Rig size mismatch" };
4062
+ }
4063
+ }
4064
+ if (slot === "SUBSYSTEM") {
4065
+ const fitsTo = readAttr(mod, 1380);
4066
+ if (fitsTo != null && fitsTo > 0 && Math.round(fitsTo) !== ship.id) {
4067
+ return { ok: false, reason: "Subsystem locked to a different T3C hull" };
4068
+ }
4069
+ }
4070
+ if (slot === "HI") {
4071
+ if (isTurretWeapon(mod)) {
4072
+ const turrets = readAttr(ship, ATTR.TURRET_HARDPOINTS) ?? 0;
4073
+ if (turrets <= 0) {
4074
+ return { ok: false, reason: "Ship has no turret hardpoints" };
4075
+ }
4076
+ } else if (isMissileLauncher(mod)) {
4077
+ const launchers = readAttr(ship, ATTR.LAUNCHER_HARDPOINTS) ?? 0;
4078
+ if (launchers <= 0) {
4079
+ return { ok: false, reason: "Ship has no launcher hardpoints" };
4080
+ }
4081
+ }
4082
+ }
4083
+ if (fitContext) {
4084
+ const free = freeFitGroupSlotsFor(mod, fitContext.fittedModules, fitContext.dataset);
4085
+ if (free <= 0) {
4086
+ const cap = maxTypeFittedFor(mod) ?? maxGroupFittedFor(mod);
4087
+ return { ok: false, reason: `Only ${cap} of this module type can be fitted to a ship` };
4088
+ }
4089
+ }
4090
+ return { ok: true };
4091
+ }
4092
+ function chargeGroupsForModule(mod) {
4093
+ const out = [];
4094
+ for (const attrID of CHARGE_GROUP_ATTRS) {
4095
+ const v = readAttr(mod, attrID);
4096
+ if (v != null && v > 0) out.push(Math.round(v));
4097
+ }
4098
+ return out;
4099
+ }
4100
+ function moduleAcceptsAnyCharge(mod) {
4101
+ return chargeGroupsForModule(mod).length > 0;
4102
+ }
4103
+ function moduleAcceptsChargeType(mod, charge) {
4104
+ const allowedGroups = chargeGroupsForModule(mod);
4105
+ if (allowedGroups.length === 0) return false;
4106
+ if (!allowedGroups.includes(charge.groupID)) return false;
4107
+ const modSize = readAttr(mod, ATTR.CHARGE_SIZE);
4108
+ const chargeSize = readAttr(charge, ATTR.CHARGE_SIZE);
4109
+ if (modSize != null && chargeSize != null && Math.round(chargeSize) > Math.round(modSize)) {
4110
+ return false;
4111
+ }
4112
+ return true;
4113
+ }
4114
+ var ACTIVE_EFFECT_CATEGORIES = /* @__PURE__ */ new Set([1, 2, 3]);
4115
+ function isActivatableModule(mod, effects) {
4116
+ for (const e of mod.effects) {
4117
+ const eff = effects.get(e.id);
4118
+ if (eff && eff.effectCategoryID !== void 0 && ACTIVE_EFFECT_CATEGORIES.has(eff.effectCategoryID)) {
4119
+ return true;
4120
+ }
4121
+ }
4122
+ return false;
4123
+ }
4124
+ var DEFAULT_OFFLINE_ACTIVATION_GROUPS = /* @__PURE__ */ new Set([
4125
+ 330,
4126
+ 4117
4127
+ ]);
4128
+ function defaultStateForModule(mod, effects) {
4129
+ if (DEFAULT_OFFLINE_ACTIVATION_GROUPS.has(mod.groupID)) return "ONLINE";
4130
+ return isActivatableModule(mod, effects) ? "ACTIVE" : "ONLINE";
4131
+ }
4132
+ function freeHardpointsFor(mod, ship, fittedHiModules, dataset) {
4133
+ const turretCap = readAttr(ship, ATTR.TURRET_HARDPOINTS) ?? 0;
4134
+ const launcherCap = readAttr(ship, ATTR.LAUNCHER_HARDPOINTS) ?? 0;
4135
+ let turretUsed = 0;
4136
+ let launcherUsed = 0;
4137
+ for (const m of fittedHiModules) {
4138
+ const t = dataset.getType(m.typeID);
4139
+ if (!t) continue;
4140
+ if (isTurretWeapon(t)) turretUsed++;
4141
+ else if (isMissileLauncher(t)) launcherUsed++;
4142
+ }
4143
+ let hardpointFree;
4144
+ if (isTurretWeapon(mod)) hardpointFree = Math.max(0, turretCap - turretUsed);
4145
+ else if (isMissileLauncher(mod)) hardpointFree = Math.max(0, launcherCap - launcherUsed);
4146
+ else hardpointFree = Number.POSITIVE_INFINITY;
4147
+ const groupFree = freeFitGroupSlotsFor(mod, fittedHiModules, dataset);
4148
+ return Math.min(hardpointFree, groupFree);
4149
+ }
4150
+
3622
4151
  // src/derived/capacitor.ts
3623
4152
  function computeCapacitor(ctx, dataset) {
3624
4153
  const ship = ctx.ship;
@@ -3698,6 +4227,7 @@ var CAP_BOOSTER_EFFECT_ID = 48;
3698
4227
  var ATTR_CAPACITOR_BONUS = 67;
3699
4228
  var ATTR_CAPACITOR_NEED2 = 6;
3700
4229
  var ATTR_DURATION_MS2 = 73;
4230
+ var ATTR_REACTIVATION_MS = 669;
3701
4231
  var ATTR_RELOAD_TIME = 1795;
3702
4232
  var ATTR_CAPACITY = 38;
3703
4233
  var ATTR_VOLUME = 161;
@@ -3732,8 +4262,9 @@ function drainEntryFromEffect(effect, mod) {
3732
4262
  if (effect.dischargeAttributeID === void 0) return null;
3733
4263
  const discharge = mod.getFinal(effect.dischargeAttributeID, 0);
3734
4264
  if (discharge <= 0) return null;
3735
- const cycleMs = effect.durationAttributeID !== void 0 ? mod.getFinal(effect.durationAttributeID, 0) : 1e3;
3736
- if (cycleMs <= 0) return null;
4265
+ const baseCycleMs = effect.durationAttributeID !== void 0 ? mod.getFinal(effect.durationAttributeID, 0) : 1e3;
4266
+ if (baseCycleMs <= 0) return null;
4267
+ const cycleMs = Math.round(baseCycleMs + mod.getFinal(ATTR_REACTIVATION_MS, 0));
3737
4268
  return {
3738
4269
  cycleMs,
3739
4270
  capNeed: discharge,
@@ -3741,7 +4272,12 @@ function drainEntryFromEffect(effect, mod) {
3741
4272
  // ammo doesn't reload for cap purposes on regular modules
3742
4273
  reloadMs: 0,
3743
4274
  isInjector: false,
3744
- disableStagger: false
4275
+ // Pyfa: `disableStagger = mod.hardpoint == TURRET`. Turrets fire as a
4276
+ // synchronized volley (their cap drains aggregate, capNeed × N at the
4277
+ // shared cycle); everything else is staggered evenly across its cycle.
4278
+ // Without this, N turrets were staggered (one drain at cycle/N) instead
4279
+ // of N together, shifting the cap timeline (time-to-empty off ~5-10 %).
4280
+ disableStagger: isTurretWeapon(mod.type)
3745
4281
  };
3746
4282
  }
3747
4283
  function boosterDrainEntry(mod) {
@@ -3749,19 +4285,19 @@ function boosterDrainEntry(mod) {
3749
4285
  if (!charge) return null;
3750
4286
  const capNeed = mod.getFinal(ATTR_CAPACITOR_NEED2, 0);
3751
4287
  const inject = charge.getFinal(ATTR_CAPACITOR_BONUS, 0);
3752
- const cycleMs = mod.getFinal(ATTR_DURATION_MS2, 0);
4288
+ const cycleMs = Math.round(mod.getFinal(ATTR_DURATION_MS2, 0));
3753
4289
  if (cycleMs <= 0) return null;
3754
- let reloadMs = mod.getFinal(ATTR_RELOAD_TIME, 0);
4290
+ let reloadMs = Math.round(mod.getFinal(ATTR_RELOAD_TIME, 0));
3755
4291
  if (reloadMs <= 0) reloadMs = 1e4;
3756
4292
  const capacity = mod.getFinal(ATTR_CAPACITY, 0);
3757
4293
  const chargeVol = charge.getFinal(ATTR_VOLUME, 0);
3758
- const charges = capacity > 0 && chargeVol > 0 ? Math.floor(capacity / chargeVol) : 1;
4294
+ const charges = capacity > 0 && chargeVol > 0 ? Math.floor(capacity / chargeVol) : 0;
3759
4295
  return {
3760
4296
  cycleMs,
3761
4297
  // Pyfa convention: positive capNeed = drain, negative = injection.
3762
4298
  // For boosters the *net* per-cycle change is (capNeed_module - inject_charge).
3763
4299
  capNeed: capNeed - inject,
3764
- clipSize: Math.max(1, charges),
4300
+ clipSize: charges,
3765
4301
  reloadMs,
3766
4302
  isInjector: true,
3767
4303
  disableStagger: false
@@ -4693,8 +5229,8 @@ function computeFit(fit, dataset, opts) {
4693
5229
  }
4694
5230
  ctx.stoppedLocalEffectIDs = collectEffectStoppers(earlyProjected, dataset);
4695
5231
  }
4696
- for (const m of modules) applySourceItem(m, ctx, dataset);
4697
5232
  for (const m of modules) if (m.charge) applySourceItem(m.charge, ctx, dataset);
5233
+ for (const m of modules) applySourceItem(m, ctx, dataset);
4698
5234
  for (const d of drones) applySourceItem(d, ctx, dataset);
4699
5235
  for (const f of fighters) applySourceItem(f, ctx, dataset);
4700
5236
  for (const i of implants) applySourceItem(i, ctx, dataset);
@@ -4857,6 +5393,7 @@ function buildProjectionReport(source, ctx) {
4857
5393
  summary: labels[cls.kind] ?? cls.kind
4858
5394
  };
4859
5395
  }
5396
+ var CHAR_BASE_MAX_LOCKED_TARGETS = 2;
4860
5397
  function deriveStats(ctx, dataset, damageProfile, fit, projectionReports) {
4861
5398
  const ship = ctx.ship;
4862
5399
  const slotUsed = { HI: 0, MED: 0, LO: 0, RIG: 0, SUBSYSTEM: 0, SERVICE: 0 };
@@ -4977,7 +5514,18 @@ function deriveStats(ctx, dataset, damageProfile, fit, projectionReports) {
4977
5514
  ship.getFinal(ATTR.MAX_TARGET_RANGE, 0),
4978
5515
  ship.getFinal(ATTR.MAX_TARGET_RANGE_CAP, 3e5)
4979
5516
  ),
4980
- maxLockedTargets: ship.getFinal(ATTR.MAX_LOCKED_TARGETS, 0),
5517
+ // In-game lockable-target count is the LOWER of the ship's
5518
+ // maxLockedTargets and the pilot's skill-derived cap. Pyfa:
5519
+ // ceil(min(maxTargetsLockedFromSkills, ship maxLockedTargets)),
5520
+ // where maxTargetsLockedFromSkills seeds at a base of 2 (every
5521
+ // capsuleer locks 2 without skills) + Target Management / Advanced
5522
+ // Target Management (+1/level each) → 2 + 5 + 5 = 12 at All-V. The
5523
+ // character item carries only the skill ModAdds (10), so add the
5524
+ // base 2. Without the char cap, high-slot capitals reported 14-20.
5525
+ maxLockedTargets: Math.ceil(Math.min(
5526
+ CHAR_BASE_MAX_LOCKED_TARGETS + ctx.character.getFinal(ATTR.MAX_LOCKED_TARGETS, 0),
5527
+ ship.getFinal(ATTR.MAX_LOCKED_TARGETS, 0)
5528
+ )),
4981
5529
  signatureRadius: ship.getFinal(ATTR.SIGNATURE_RADIUS, 0),
4982
5530
  scanResolution: ship.getFinal(ATTR.SCAN_RESOLUTION, 0),
4983
5531
  sensorStrength: pickSensorStrength(ship).value,
@@ -5237,176 +5785,6 @@ function tempId() {
5237
5785
  return `tmp:${++tempCounter}:${Date.now().toString(36)}`;
5238
5786
  }
5239
5787
 
5240
- // src/fitChecks.ts
5241
- function readAttr(t, id) {
5242
- return t.attributes.find((a) => a.id === id)?.v;
5243
- }
5244
- function typeFitsSlotType(t, slot) {
5245
- for (const e of t.effects) {
5246
- const mapped = SLOT_EFFECT_TO_SLOT_TYPE[e.id];
5247
- if (mapped === slot) return true;
5248
- }
5249
- return false;
5250
- }
5251
- function isTurretWeapon(t) {
5252
- return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "TURRET");
5253
- }
5254
- function isMissileLauncher(t) {
5255
- return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "MISSILE");
5256
- }
5257
- function isSmartBomb(t) {
5258
- return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "SMARTBOMB");
5259
- }
5260
- function shipGroupRestrictions(t) {
5261
- const out = [];
5262
- for (const id of CAN_FIT_SHIP_GROUP_ATTRS) {
5263
- const v = readAttr(t, id);
5264
- if (v != null && v > 0) out.push(Math.round(v));
5265
- }
5266
- return out;
5267
- }
5268
- function shipTypeRestrictions(t) {
5269
- const out = [];
5270
- for (const id of CAN_FIT_SHIP_TYPE_ATTRS) {
5271
- const v = readAttr(t, id);
5272
- if (v != null && v > 0) out.push(Math.round(v));
5273
- }
5274
- return out;
5275
- }
5276
- function maxGroupFittedFor(mod) {
5277
- const v = readAttr(mod, ATTR.MAX_GROUP_FITTED);
5278
- return v != null && v > 0 ? Math.round(v) : void 0;
5279
- }
5280
- function maxTypeFittedFor(mod) {
5281
- const v = readAttr(mod, ATTR.MAX_TYPE_FITTED);
5282
- return v != null && v > 0 ? Math.round(v) : void 0;
5283
- }
5284
- function freeFitGroupSlotsFor(mod, fittedModules, dataset) {
5285
- const groupCap = maxGroupFittedFor(mod);
5286
- const typeCap = maxTypeFittedFor(mod);
5287
- if (groupCap === void 0 && typeCap === void 0) return Number.POSITIVE_INFINITY;
5288
- let groupCount = 0;
5289
- let typeCount = 0;
5290
- for (const fm of fittedModules) {
5291
- if (typeCap !== void 0 && fm.typeID === mod.id) typeCount++;
5292
- if (groupCap !== void 0) {
5293
- const t = dataset.getType(fm.typeID);
5294
- if (t && t.groupID === mod.groupID) groupCount++;
5295
- }
5296
- }
5297
- let free = Number.POSITIVE_INFINITY;
5298
- if (groupCap !== void 0) free = Math.min(free, groupCap - groupCount);
5299
- if (typeCap !== void 0) free = Math.min(free, typeCap - typeCount);
5300
- return Math.max(0, free);
5301
- }
5302
- function canFitModuleOnShip(mod, ship, slot, fitContext) {
5303
- if (!typeFitsSlotType(mod, slot)) {
5304
- return { ok: false, reason: `Module does not declare a ${slot} slot effect` };
5305
- }
5306
- if (!ship) return { ok: true };
5307
- const allowedGroups = shipGroupRestrictions(mod);
5308
- const allowedTypes = shipTypeRestrictions(mod);
5309
- if (allowedGroups.length > 0 || allowedTypes.length > 0) {
5310
- const groupOk = allowedGroups.includes(ship.groupID);
5311
- const typeOk = allowedTypes.includes(ship.id);
5312
- if (!groupOk && !typeOk) {
5313
- return { ok: false, reason: "Ship hull/group not allowed by this module" };
5314
- }
5315
- }
5316
- if (slot === "RIG") {
5317
- const modSize = readAttr(mod, ATTR.RIG_SIZE);
5318
- const shipSize = readAttr(ship, ATTR.RIG_SIZE);
5319
- if (modSize != null && shipSize != null && Math.round(modSize) !== Math.round(shipSize)) {
5320
- return { ok: false, reason: "Rig size mismatch" };
5321
- }
5322
- }
5323
- if (slot === "SUBSYSTEM") {
5324
- const fitsTo = readAttr(mod, 1380);
5325
- if (fitsTo != null && fitsTo > 0 && Math.round(fitsTo) !== ship.id) {
5326
- return { ok: false, reason: "Subsystem locked to a different T3C hull" };
5327
- }
5328
- }
5329
- if (slot === "HI") {
5330
- if (isTurretWeapon(mod)) {
5331
- const turrets = readAttr(ship, ATTR.TURRET_HARDPOINTS) ?? 0;
5332
- if (turrets <= 0) {
5333
- return { ok: false, reason: "Ship has no turret hardpoints" };
5334
- }
5335
- } else if (isMissileLauncher(mod)) {
5336
- const launchers = readAttr(ship, ATTR.LAUNCHER_HARDPOINTS) ?? 0;
5337
- if (launchers <= 0) {
5338
- return { ok: false, reason: "Ship has no launcher hardpoints" };
5339
- }
5340
- }
5341
- }
5342
- if (fitContext) {
5343
- const free = freeFitGroupSlotsFor(mod, fitContext.fittedModules, fitContext.dataset);
5344
- if (free <= 0) {
5345
- const cap = maxTypeFittedFor(mod) ?? maxGroupFittedFor(mod);
5346
- return { ok: false, reason: `Only ${cap} of this module type can be fitted to a ship` };
5347
- }
5348
- }
5349
- return { ok: true };
5350
- }
5351
- function chargeGroupsForModule(mod) {
5352
- const out = [];
5353
- for (const attrID of CHARGE_GROUP_ATTRS) {
5354
- const v = readAttr(mod, attrID);
5355
- if (v != null && v > 0) out.push(Math.round(v));
5356
- }
5357
- return out;
5358
- }
5359
- function moduleAcceptsAnyCharge(mod) {
5360
- return chargeGroupsForModule(mod).length > 0;
5361
- }
5362
- function moduleAcceptsChargeType(mod, charge) {
5363
- const allowedGroups = chargeGroupsForModule(mod);
5364
- if (allowedGroups.length === 0) return false;
5365
- if (!allowedGroups.includes(charge.groupID)) return false;
5366
- const modSize = readAttr(mod, ATTR.CHARGE_SIZE);
5367
- const chargeSize = readAttr(charge, ATTR.CHARGE_SIZE);
5368
- if (modSize != null && chargeSize != null && Math.round(chargeSize) > Math.round(modSize)) {
5369
- return false;
5370
- }
5371
- return true;
5372
- }
5373
- var ACTIVE_EFFECT_CATEGORIES = /* @__PURE__ */ new Set([1, 2, 3]);
5374
- function isActivatableModule(mod, effects) {
5375
- for (const e of mod.effects) {
5376
- const eff = effects.get(e.id);
5377
- if (eff && eff.effectCategoryID !== void 0 && ACTIVE_EFFECT_CATEGORIES.has(eff.effectCategoryID)) {
5378
- return true;
5379
- }
5380
- }
5381
- return false;
5382
- }
5383
- var DEFAULT_OFFLINE_ACTIVATION_GROUPS = /* @__PURE__ */ new Set([
5384
- 330,
5385
- 4117
5386
- ]);
5387
- function defaultStateForModule(mod, effects) {
5388
- if (DEFAULT_OFFLINE_ACTIVATION_GROUPS.has(mod.groupID)) return "ONLINE";
5389
- return isActivatableModule(mod, effects) ? "ACTIVE" : "ONLINE";
5390
- }
5391
- function freeHardpointsFor(mod, ship, fittedHiModules, dataset) {
5392
- const turretCap = readAttr(ship, ATTR.TURRET_HARDPOINTS) ?? 0;
5393
- const launcherCap = readAttr(ship, ATTR.LAUNCHER_HARDPOINTS) ?? 0;
5394
- let turretUsed = 0;
5395
- let launcherUsed = 0;
5396
- for (const m of fittedHiModules) {
5397
- const t = dataset.getType(m.typeID);
5398
- if (!t) continue;
5399
- if (isTurretWeapon(t)) turretUsed++;
5400
- else if (isMissileLauncher(t)) launcherUsed++;
5401
- }
5402
- let hardpointFree;
5403
- if (isTurretWeapon(mod)) hardpointFree = Math.max(0, turretCap - turretUsed);
5404
- else if (isMissileLauncher(mod)) hardpointFree = Math.max(0, launcherCap - launcherUsed);
5405
- else hardpointFree = Number.POSITIVE_INFINITY;
5406
- const groupFree = freeFitGroupSlotsFor(mod, fittedHiModules, dataset);
5407
- return Math.min(hardpointFree, groupFree);
5408
- }
5409
-
5410
5788
  // src/eft/format.ts
5411
5789
  var SLOT_ORDER = ["LO", "MED", "HI", "RIG"];
5412
5790
  function formatEft(fit, dataset) {