eve-fit-engine 0.1.1 → 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
@@ -1816,7 +2159,7 @@ function computeModifierValue(source, mi, ctx, dataset, op) {
1816
2159
  if (skillID === void 0) return null;
1817
2160
  const level = ctx.skillLevel(skillID);
1818
2161
  if (level === 0) return null;
1819
- if (source.kind === "ship" && SHIP_ROLE_BONUS_ATTRS.has(mi.modifyingAttributeID)) {
2162
+ if (source.kind === "ship") {
1820
2163
  return { value: baseValue, scaled: false };
1821
2164
  }
1822
2165
  if (itemRequiresSkill(source, skillID)) {
@@ -1826,55 +2169,21 @@ function computeModifierValue(source, mi, ctx, dataset, op) {
1826
2169
  }
1827
2170
  return { value: baseValue, scaled: false };
1828
2171
  }
1829
- var SHIP_ROLE_BONUS_ATTRS = /* @__PURE__ */ new Set([
1830
- 793,
1831
- // shipBonusRole7
1832
- 1688,
1833
- // shipBonusRole8
1834
- 1803,
1835
- // MWDSignatureRadiusBonus — Assault Frigate / Interceptor MWD sig role bonus (flat)
1836
- 2059,
1837
- // eliteBonusCommandDestroyer1 — Command Destroyer T2 specialisation (flat per-level applied via skill, but the SHIP-side reader is flat)
1838
- 2060,
1839
- // eliteBonusCommandDestroyer2
1840
- 2064,
1841
- // roleBonusCD — Command Destroyer command burst PG / activation cost reduction.
1842
- // Without this entry, effect 6214 (Draugur's `roleBonusCDLinksPGReduction`)
1843
- // double-applies the -95 % bonus at × Skirmish Command Burst V → -475 %
1844
- // PostPercent on Skirmish Command Burst II `power` (110) → -412.5 MW per
1845
- // burst → total ship power used reads −738 MW instead of +97 MW.
1846
- 2298,
1847
- // shipBonusRole1
1848
- 2299,
1849
- // shipBonusRole2
1850
- 2300,
1851
- // shipBonusRole3
1852
- 2301,
1853
- // shipBonusRole4
1854
- 2302,
1855
- // shipBonusRole5
1856
- 2303,
1857
- // shipBonusRole6
1858
- 5952,
1859
- // shipBonusGasCloudDurationRoleBonusOreMiningDestroyer
1860
- 1989
1861
- // probeLauncherCPUPercentRoleBonusT3 value — effect 6009 on T3C hulls
1862
- // (Loki/Tengu/…): "-99 % CPU for Scan Probe Launchers". Declared as
1863
- // LocationRequiredSkillModifier gated on Astrometrics (3412), but
1864
- // the skill only SELECTS the recipient (probe launchers) — the bonus
1865
- // is FLAT. Without this entry the ship-domain reader scales it ×
1866
- // Astrometrics level → -99 % becomes -495 % PostPercent → a Loki
1867
- // Expanded Probe Launcher's 242 tf CPU flips to -955.9 tf and total
1868
- // CPU used reads -388 instead of +569.5.
1869
- ]);
1870
- function computeStackingGroup(source, mi, dataset) {
2172
+ function computeStackingGroup(source, mi, dataset, effect) {
1871
2173
  if (NO_PENALTY_KINDS.has(source.kind)) return null;
1872
2174
  if (mi.modifiedAttributeID !== void 0) {
1873
2175
  const attr = dataset.attributes.get(mi.modifiedAttributeID);
1874
2176
  if (attr?.stackable) return null;
1875
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
+ }
1876
2182
  return `attr:${mi.modifiedAttributeID}`;
1877
2183
  }
2184
+ var CUSTOM_STACK_GROUPS_HONOURED = /* @__PURE__ */ new Set([
2185
+ "cloakingScanResolutionMultiplier"
2186
+ ]);
1878
2187
  function mapSourceKind(kind) {
1879
2188
  switch (kind) {
1880
2189
  case "ship":
@@ -3592,6 +3901,13 @@ var LEGACY_HANDLED_HARDCODED_EFFECTS = /* @__PURE__ */ new Set([
3592
3901
  6658
3593
3902
  // Bastion Module — applyLegacyBastion
3594
3903
  ]);
3904
+ var SEC_STATUS_SCALED_EFFECT_IDS = /* @__PURE__ */ new Set([
3905
+ 6871,
3906
+ 12165,
3907
+ 12181,
3908
+ 12185,
3909
+ 12202
3910
+ ]);
3595
3911
  var LEGACY_HANDLED_EFFECT_IDS = /* @__PURE__ */ new Set([
3596
3912
  ...LEGACY_HANDLED_PASSIVE_ADD_EFFECTS,
3597
3913
  ...LEGACY_HANDLED_HARDCODED_EFFECTS
@@ -3662,6 +3978,176 @@ function computeTotalEhp(shield, armor, hull, useProfile) {
3662
3978
  return Number.isFinite(total) ? total : Number.MAX_SAFE_INTEGER;
3663
3979
  }
3664
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
+
3665
4151
  // src/derived/capacitor.ts
3666
4152
  function computeCapacitor(ctx, dataset) {
3667
4153
  const ship = ctx.ship;
@@ -3741,6 +4227,7 @@ var CAP_BOOSTER_EFFECT_ID = 48;
3741
4227
  var ATTR_CAPACITOR_BONUS = 67;
3742
4228
  var ATTR_CAPACITOR_NEED2 = 6;
3743
4229
  var ATTR_DURATION_MS2 = 73;
4230
+ var ATTR_REACTIVATION_MS = 669;
3744
4231
  var ATTR_RELOAD_TIME = 1795;
3745
4232
  var ATTR_CAPACITY = 38;
3746
4233
  var ATTR_VOLUME = 161;
@@ -3775,8 +4262,9 @@ function drainEntryFromEffect(effect, mod) {
3775
4262
  if (effect.dischargeAttributeID === void 0) return null;
3776
4263
  const discharge = mod.getFinal(effect.dischargeAttributeID, 0);
3777
4264
  if (discharge <= 0) return null;
3778
- const cycleMs = effect.durationAttributeID !== void 0 ? mod.getFinal(effect.durationAttributeID, 0) : 1e3;
3779
- 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));
3780
4268
  return {
3781
4269
  cycleMs,
3782
4270
  capNeed: discharge,
@@ -3784,7 +4272,12 @@ function drainEntryFromEffect(effect, mod) {
3784
4272
  // ammo doesn't reload for cap purposes on regular modules
3785
4273
  reloadMs: 0,
3786
4274
  isInjector: false,
3787
- 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)
3788
4281
  };
3789
4282
  }
3790
4283
  function boosterDrainEntry(mod) {
@@ -3792,19 +4285,19 @@ function boosterDrainEntry(mod) {
3792
4285
  if (!charge) return null;
3793
4286
  const capNeed = mod.getFinal(ATTR_CAPACITOR_NEED2, 0);
3794
4287
  const inject = charge.getFinal(ATTR_CAPACITOR_BONUS, 0);
3795
- const cycleMs = mod.getFinal(ATTR_DURATION_MS2, 0);
4288
+ const cycleMs = Math.round(mod.getFinal(ATTR_DURATION_MS2, 0));
3796
4289
  if (cycleMs <= 0) return null;
3797
- let reloadMs = mod.getFinal(ATTR_RELOAD_TIME, 0);
4290
+ let reloadMs = Math.round(mod.getFinal(ATTR_RELOAD_TIME, 0));
3798
4291
  if (reloadMs <= 0) reloadMs = 1e4;
3799
4292
  const capacity = mod.getFinal(ATTR_CAPACITY, 0);
3800
4293
  const chargeVol = charge.getFinal(ATTR_VOLUME, 0);
3801
- const charges = capacity > 0 && chargeVol > 0 ? Math.floor(capacity / chargeVol) : 1;
4294
+ const charges = capacity > 0 && chargeVol > 0 ? Math.floor(capacity / chargeVol) : 0;
3802
4295
  return {
3803
4296
  cycleMs,
3804
4297
  // Pyfa convention: positive capNeed = drain, negative = injection.
3805
4298
  // For boosters the *net* per-cycle change is (capNeed_module - inject_charge).
3806
4299
  capNeed: capNeed - inject,
3807
- clipSize: Math.max(1, charges),
4300
+ clipSize: charges,
3808
4301
  reloadMs,
3809
4302
  isInjector: true,
3810
4303
  disableStagger: false
@@ -4736,8 +5229,8 @@ function computeFit(fit, dataset, opts) {
4736
5229
  }
4737
5230
  ctx.stoppedLocalEffectIDs = collectEffectStoppers(earlyProjected, dataset);
4738
5231
  }
4739
- for (const m of modules) applySourceItem(m, ctx, dataset);
4740
5232
  for (const m of modules) if (m.charge) applySourceItem(m.charge, ctx, dataset);
5233
+ for (const m of modules) applySourceItem(m, ctx, dataset);
4741
5234
  for (const d of drones) applySourceItem(d, ctx, dataset);
4742
5235
  for (const f of fighters) applySourceItem(f, ctx, dataset);
4743
5236
  for (const i of implants) applySourceItem(i, ctx, dataset);
@@ -4900,6 +5393,7 @@ function buildProjectionReport(source, ctx) {
4900
5393
  summary: labels[cls.kind] ?? cls.kind
4901
5394
  };
4902
5395
  }
5396
+ var CHAR_BASE_MAX_LOCKED_TARGETS = 2;
4903
5397
  function deriveStats(ctx, dataset, damageProfile, fit, projectionReports) {
4904
5398
  const ship = ctx.ship;
4905
5399
  const slotUsed = { HI: 0, MED: 0, LO: 0, RIG: 0, SUBSYSTEM: 0, SERVICE: 0 };
@@ -5020,7 +5514,18 @@ function deriveStats(ctx, dataset, damageProfile, fit, projectionReports) {
5020
5514
  ship.getFinal(ATTR.MAX_TARGET_RANGE, 0),
5021
5515
  ship.getFinal(ATTR.MAX_TARGET_RANGE_CAP, 3e5)
5022
5516
  ),
5023
- 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
+ )),
5024
5529
  signatureRadius: ship.getFinal(ATTR.SIGNATURE_RADIUS, 0),
5025
5530
  scanResolution: ship.getFinal(ATTR.SCAN_RESOLUTION, 0),
5026
5531
  sensorStrength: pickSensorStrength(ship).value,
@@ -5280,176 +5785,6 @@ function tempId() {
5280
5785
  return `tmp:${++tempCounter}:${Date.now().toString(36)}`;
5281
5786
  }
5282
5787
 
5283
- // src/fitChecks.ts
5284
- function readAttr(t, id) {
5285
- return t.attributes.find((a) => a.id === id)?.v;
5286
- }
5287
- function typeFitsSlotType(t, slot) {
5288
- for (const e of t.effects) {
5289
- const mapped = SLOT_EFFECT_TO_SLOT_TYPE[e.id];
5290
- if (mapped === slot) return true;
5291
- }
5292
- return false;
5293
- }
5294
- function isTurretWeapon(t) {
5295
- return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "TURRET");
5296
- }
5297
- function isMissileLauncher(t) {
5298
- return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "MISSILE");
5299
- }
5300
- function isSmartBomb(t) {
5301
- return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "SMARTBOMB");
5302
- }
5303
- function shipGroupRestrictions(t) {
5304
- const out = [];
5305
- for (const id of CAN_FIT_SHIP_GROUP_ATTRS) {
5306
- const v = readAttr(t, id);
5307
- if (v != null && v > 0) out.push(Math.round(v));
5308
- }
5309
- return out;
5310
- }
5311
- function shipTypeRestrictions(t) {
5312
- const out = [];
5313
- for (const id of CAN_FIT_SHIP_TYPE_ATTRS) {
5314
- const v = readAttr(t, id);
5315
- if (v != null && v > 0) out.push(Math.round(v));
5316
- }
5317
- return out;
5318
- }
5319
- function maxGroupFittedFor(mod) {
5320
- const v = readAttr(mod, ATTR.MAX_GROUP_FITTED);
5321
- return v != null && v > 0 ? Math.round(v) : void 0;
5322
- }
5323
- function maxTypeFittedFor(mod) {
5324
- const v = readAttr(mod, ATTR.MAX_TYPE_FITTED);
5325
- return v != null && v > 0 ? Math.round(v) : void 0;
5326
- }
5327
- function freeFitGroupSlotsFor(mod, fittedModules, dataset) {
5328
- const groupCap = maxGroupFittedFor(mod);
5329
- const typeCap = maxTypeFittedFor(mod);
5330
- if (groupCap === void 0 && typeCap === void 0) return Number.POSITIVE_INFINITY;
5331
- let groupCount = 0;
5332
- let typeCount = 0;
5333
- for (const fm of fittedModules) {
5334
- if (typeCap !== void 0 && fm.typeID === mod.id) typeCount++;
5335
- if (groupCap !== void 0) {
5336
- const t = dataset.getType(fm.typeID);
5337
- if (t && t.groupID === mod.groupID) groupCount++;
5338
- }
5339
- }
5340
- let free = Number.POSITIVE_INFINITY;
5341
- if (groupCap !== void 0) free = Math.min(free, groupCap - groupCount);
5342
- if (typeCap !== void 0) free = Math.min(free, typeCap - typeCount);
5343
- return Math.max(0, free);
5344
- }
5345
- function canFitModuleOnShip(mod, ship, slot, fitContext) {
5346
- if (!typeFitsSlotType(mod, slot)) {
5347
- return { ok: false, reason: `Module does not declare a ${slot} slot effect` };
5348
- }
5349
- if (!ship) return { ok: true };
5350
- const allowedGroups = shipGroupRestrictions(mod);
5351
- const allowedTypes = shipTypeRestrictions(mod);
5352
- if (allowedGroups.length > 0 || allowedTypes.length > 0) {
5353
- const groupOk = allowedGroups.includes(ship.groupID);
5354
- const typeOk = allowedTypes.includes(ship.id);
5355
- if (!groupOk && !typeOk) {
5356
- return { ok: false, reason: "Ship hull/group not allowed by this module" };
5357
- }
5358
- }
5359
- if (slot === "RIG") {
5360
- const modSize = readAttr(mod, ATTR.RIG_SIZE);
5361
- const shipSize = readAttr(ship, ATTR.RIG_SIZE);
5362
- if (modSize != null && shipSize != null && Math.round(modSize) !== Math.round(shipSize)) {
5363
- return { ok: false, reason: "Rig size mismatch" };
5364
- }
5365
- }
5366
- if (slot === "SUBSYSTEM") {
5367
- const fitsTo = readAttr(mod, 1380);
5368
- if (fitsTo != null && fitsTo > 0 && Math.round(fitsTo) !== ship.id) {
5369
- return { ok: false, reason: "Subsystem locked to a different T3C hull" };
5370
- }
5371
- }
5372
- if (slot === "HI") {
5373
- if (isTurretWeapon(mod)) {
5374
- const turrets = readAttr(ship, ATTR.TURRET_HARDPOINTS) ?? 0;
5375
- if (turrets <= 0) {
5376
- return { ok: false, reason: "Ship has no turret hardpoints" };
5377
- }
5378
- } else if (isMissileLauncher(mod)) {
5379
- const launchers = readAttr(ship, ATTR.LAUNCHER_HARDPOINTS) ?? 0;
5380
- if (launchers <= 0) {
5381
- return { ok: false, reason: "Ship has no launcher hardpoints" };
5382
- }
5383
- }
5384
- }
5385
- if (fitContext) {
5386
- const free = freeFitGroupSlotsFor(mod, fitContext.fittedModules, fitContext.dataset);
5387
- if (free <= 0) {
5388
- const cap = maxTypeFittedFor(mod) ?? maxGroupFittedFor(mod);
5389
- return { ok: false, reason: `Only ${cap} of this module type can be fitted to a ship` };
5390
- }
5391
- }
5392
- return { ok: true };
5393
- }
5394
- function chargeGroupsForModule(mod) {
5395
- const out = [];
5396
- for (const attrID of CHARGE_GROUP_ATTRS) {
5397
- const v = readAttr(mod, attrID);
5398
- if (v != null && v > 0) out.push(Math.round(v));
5399
- }
5400
- return out;
5401
- }
5402
- function moduleAcceptsAnyCharge(mod) {
5403
- return chargeGroupsForModule(mod).length > 0;
5404
- }
5405
- function moduleAcceptsChargeType(mod, charge) {
5406
- const allowedGroups = chargeGroupsForModule(mod);
5407
- if (allowedGroups.length === 0) return false;
5408
- if (!allowedGroups.includes(charge.groupID)) return false;
5409
- const modSize = readAttr(mod, ATTR.CHARGE_SIZE);
5410
- const chargeSize = readAttr(charge, ATTR.CHARGE_SIZE);
5411
- if (modSize != null && chargeSize != null && Math.round(chargeSize) > Math.round(modSize)) {
5412
- return false;
5413
- }
5414
- return true;
5415
- }
5416
- var ACTIVE_EFFECT_CATEGORIES = /* @__PURE__ */ new Set([1, 2, 3]);
5417
- function isActivatableModule(mod, effects) {
5418
- for (const e of mod.effects) {
5419
- const eff = effects.get(e.id);
5420
- if (eff && eff.effectCategoryID !== void 0 && ACTIVE_EFFECT_CATEGORIES.has(eff.effectCategoryID)) {
5421
- return true;
5422
- }
5423
- }
5424
- return false;
5425
- }
5426
- var DEFAULT_OFFLINE_ACTIVATION_GROUPS = /* @__PURE__ */ new Set([
5427
- 330,
5428
- 4117
5429
- ]);
5430
- function defaultStateForModule(mod, effects) {
5431
- if (DEFAULT_OFFLINE_ACTIVATION_GROUPS.has(mod.groupID)) return "ONLINE";
5432
- return isActivatableModule(mod, effects) ? "ACTIVE" : "ONLINE";
5433
- }
5434
- function freeHardpointsFor(mod, ship, fittedHiModules, dataset) {
5435
- const turretCap = readAttr(ship, ATTR.TURRET_HARDPOINTS) ?? 0;
5436
- const launcherCap = readAttr(ship, ATTR.LAUNCHER_HARDPOINTS) ?? 0;
5437
- let turretUsed = 0;
5438
- let launcherUsed = 0;
5439
- for (const m of fittedHiModules) {
5440
- const t = dataset.getType(m.typeID);
5441
- if (!t) continue;
5442
- if (isTurretWeapon(t)) turretUsed++;
5443
- else if (isMissileLauncher(t)) launcherUsed++;
5444
- }
5445
- let hardpointFree;
5446
- if (isTurretWeapon(mod)) hardpointFree = Math.max(0, turretCap - turretUsed);
5447
- else if (isMissileLauncher(mod)) hardpointFree = Math.max(0, launcherCap - launcherUsed);
5448
- else hardpointFree = Number.POSITIVE_INFINITY;
5449
- const groupFree = freeFitGroupSlotsFor(mod, fittedHiModules, dataset);
5450
- return Math.min(hardpointFree, groupFree);
5451
- }
5452
-
5453
5788
  // src/eft/format.ts
5454
5789
  var SLOT_ORDER = ["LO", "MED", "HI", "RIG"];
5455
5790
  function formatEft(fit, dataset) {