eve-fit-engine 0.1.1 → 0.1.3

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/index.cjs CHANGED
@@ -649,7 +649,7 @@ var FitContext = class {
649
649
  const root = this.resolveDomain(modifier.domain, source);
650
650
  switch (modifier.func) {
651
651
  case "ItemModifier":
652
- if (modifier.domain === "charID" && source.kind === "module") {
652
+ if (modifier.domain === "charID" && source.kind === "module" && modifier.modifiedAttributeID === 212) {
653
653
  const out = [];
654
654
  for (const m of this.modules) {
655
655
  if (m.charge) out.push(m.charge);
@@ -1061,6 +1061,299 @@ function makeModeState(modeTypeID, type) {
1061
1061
  return new ItemState({ kind: "mode", id: `mode:${modeTypeID}`, type, state: "ACTIVE" });
1062
1062
  }
1063
1063
 
1064
+ // src/stackingGroups.ts
1065
+ var STACKING_PENALTY_GROUPS = /* @__PURE__ */ new Map([
1066
+ [89, "default"],
1067
+ [91, "default"],
1068
+ [92, "default"],
1069
+ [93, "default"],
1070
+ [95, "default"],
1071
+ [96, "default"],
1072
+ [394, "default"],
1073
+ [395, "default"],
1074
+ [494, "default"],
1075
+ [607, "postMul"],
1076
+ [657, "default"],
1077
+ [699, "default"],
1078
+ [763, "default"],
1079
+ [784, "default"],
1080
+ [854, "cloakingScanResolutionMultiplier"],
1081
+ [856, "default"],
1082
+ [889, "default"],
1083
+ [891, "default"],
1084
+ [892, "default"],
1085
+ [1024, "default"],
1086
+ [1230, "default"],
1087
+ [1281, "default"],
1088
+ [1318, "default"],
1089
+ [1445, "default"],
1090
+ [1446, "default"],
1091
+ [1448, "default"],
1092
+ [1452, "default"],
1093
+ [1472, "default"],
1094
+ [1590, "default"],
1095
+ [1617, "default"],
1096
+ [1720, "default"],
1097
+ [1764, "default"],
1098
+ [1885, "postPerc"],
1099
+ [1886, "postPerc"],
1100
+ [2013, "default"],
1101
+ [2014, "default"],
1102
+ [2019, "default"],
1103
+ [2020, "default"],
1104
+ [2041, "default"],
1105
+ [2052, "default"],
1106
+ [2152, "default"],
1107
+ [2232, "default"],
1108
+ [2302, "preMul"],
1109
+ [2644, "default"],
1110
+ [2645, "default"],
1111
+ [2646, "default"],
1112
+ [2670, "default"],
1113
+ [2693, "default"],
1114
+ [2694, "default"],
1115
+ [2695, "default"],
1116
+ [2696, "default"],
1117
+ [2697, "default"],
1118
+ [2698, "default"],
1119
+ [2716, "default"],
1120
+ [2717, "default"],
1121
+ [2792, "default"],
1122
+ [2795, "default"],
1123
+ [2796, "default"],
1124
+ [2797, "default"],
1125
+ [2798, "default"],
1126
+ [2799, "default"],
1127
+ [2801, "default"],
1128
+ [2802, "default"],
1129
+ [2803, "default"],
1130
+ [2804, "default"],
1131
+ [2851, "default"],
1132
+ [2858, "default"],
1133
+ [2865, "default"],
1134
+ [2867, "default"],
1135
+ [2868, "default"],
1136
+ [3001, "postPerc"],
1137
+ [3046, "default"],
1138
+ [3174, "default"],
1139
+ [3175, "default"],
1140
+ [3182, "default"],
1141
+ [3200, "default"],
1142
+ [3201, "default"],
1143
+ [3586, "default"],
1144
+ [3655, "default"],
1145
+ [3656, "default"],
1146
+ [3657, "default"],
1147
+ [3659, "default"],
1148
+ [3674, "default"],
1149
+ [3726, "default"],
1150
+ [3727, "default"],
1151
+ [3993, "postMul"],
1152
+ [3995, "postMul"],
1153
+ [3996, "default"],
1154
+ [3997, "default"],
1155
+ [3998, "default"],
1156
+ [3999, "default"],
1157
+ [4002, "postMul"],
1158
+ [4003, "postMul"],
1159
+ [4016, "default"],
1160
+ [4017, "postMul"],
1161
+ [4018, "postMul"],
1162
+ [4019, "postMul"],
1163
+ [4020, "postMul"],
1164
+ [4021, "postMul"],
1165
+ [4022, "postMul"],
1166
+ [4023, "postMul"],
1167
+ [4054, "default"],
1168
+ [4055, "default"],
1169
+ [4056, "default"],
1170
+ [4057, "postMul"],
1171
+ [4058, "postMul"],
1172
+ [4059, "postMul"],
1173
+ [4060, "postMul"],
1174
+ [4061, "postMul"],
1175
+ [4062, "postMul"],
1176
+ [4063, "postMul"],
1177
+ [4086, "postMul"],
1178
+ [4088, "postMul"],
1179
+ [4089, "postMul"],
1180
+ [4135, "default"],
1181
+ [4136, "default"],
1182
+ [4137, "default"],
1183
+ [4138, "default"],
1184
+ [4162, "default"],
1185
+ [4280, "default"],
1186
+ [4358, "default"],
1187
+ [4464, "default"],
1188
+ [4489, "default"],
1189
+ [4490, "default"],
1190
+ [4491, "default"],
1191
+ [4492, "default"],
1192
+ [4527, "default"],
1193
+ [4559, "default"],
1194
+ [4575, "default"],
1195
+ [4809, "default"],
1196
+ [4810, "default"],
1197
+ [4811, "default"],
1198
+ [4812, "default"],
1199
+ [4906, "postMul"],
1200
+ [4928, "preMul"],
1201
+ [4961, "postMul"],
1202
+ [5081, "default"],
1203
+ [5188, "default"],
1204
+ [5189, "default"],
1205
+ [5190, "default"],
1206
+ [5213, "default"],
1207
+ [5214, "default"],
1208
+ [5230, "default"],
1209
+ [5231, "default"],
1210
+ [5397, "default"],
1211
+ [5399, "default"],
1212
+ [5440, "postMul"],
1213
+ [5468, "default"],
1214
+ [5560, "default"],
1215
+ [5618, "postPerc"],
1216
+ [5757, "default"],
1217
+ [5867, "default"],
1218
+ [5911, "default"],
1219
+ [5912, "postMul"],
1220
+ [5914, "postMul"],
1221
+ [5915, "postMul"],
1222
+ [5916, "postMul"],
1223
+ [5917, "postMul"],
1224
+ [5918, "postMul"],
1225
+ [5919, "postMul"],
1226
+ [5920, "postMul"],
1227
+ [5921, "postMul"],
1228
+ [5922, "postMul"],
1229
+ [5923, "postMul"],
1230
+ [5924, "postMul"],
1231
+ [5925, "postMul"],
1232
+ [5926, "postMul"],
1233
+ [5927, "postMul"],
1234
+ [5929, "postMul"],
1235
+ [5951, "default"],
1236
+ [5998, "default"],
1237
+ [6010, "postDiv"],
1238
+ [6011, "postDiv"],
1239
+ [6012, "postDiv"],
1240
+ [6014, "postDiv"],
1241
+ [6015, "postDiv"],
1242
+ [6016, "postDiv"],
1243
+ [6017, "postDiv"],
1244
+ [6039, "postDiv"],
1245
+ [6040, "postDiv"],
1246
+ [6041, "postDiv"],
1247
+ [6063, "default"],
1248
+ [6076, "postDiv"],
1249
+ [6110, "default"],
1250
+ [6111, "default"],
1251
+ [6112, "default"],
1252
+ [6113, "default"],
1253
+ [6135, "default"],
1254
+ [6152, "postDiv"],
1255
+ [6154, "postDiv"],
1256
+ [6164, "default"],
1257
+ [6201, "default"],
1258
+ [6208, "default"],
1259
+ [6402, "default"],
1260
+ [6403, "default"],
1261
+ [6404, "default"],
1262
+ [6405, "default"],
1263
+ [6406, "default"],
1264
+ [6409, "default"],
1265
+ [6410, "default"],
1266
+ [6411, "default"],
1267
+ [6412, "default"],
1268
+ [6422, "default"],
1269
+ [6423, "default"],
1270
+ [6424, "default"],
1271
+ [6425, "default"],
1272
+ [6426, "default"],
1273
+ [6427, "default"],
1274
+ [6428, "default"],
1275
+ [6435, "default"],
1276
+ [6439, "default"],
1277
+ [6440, "default"],
1278
+ [6441, "default"],
1279
+ [6448, "default"],
1280
+ [6449, "default"],
1281
+ [6472, "default"],
1282
+ [6473, "default"],
1283
+ [6474, "default"],
1284
+ [6476, "default"],
1285
+ [6478, "default"],
1286
+ [6479, "default"],
1287
+ [6481, "default"],
1288
+ [6484, "postMul"],
1289
+ [6487, "default"],
1290
+ [6555, "default"],
1291
+ [6556, "default"],
1292
+ [6557, "default"],
1293
+ [6559, "default"],
1294
+ [6566, "postMul"],
1295
+ [6567, "default"],
1296
+ [6581, "default"],
1297
+ [6582, "postPercent"],
1298
+ [6658, "preMul"],
1299
+ [6670, "default"],
1300
+ [6671, "default"],
1301
+ [6682, "default"],
1302
+ [6683, "default"],
1303
+ [6684, "default"],
1304
+ [6686, "default"],
1305
+ [6690, "default"],
1306
+ [6692, "default"],
1307
+ [6693, "default"],
1308
+ [6694, "default"],
1309
+ [6727, "default"],
1310
+ [6730, "postMul"],
1311
+ [6731, "postMul"],
1312
+ [6796, "postDiv"],
1313
+ [6797, "postDiv"],
1314
+ [6798, "postDiv"],
1315
+ [6799, "postDiv"],
1316
+ [6801, "postDiv"],
1317
+ [6877, "default"],
1318
+ [7029, "default"],
1319
+ [7077, "default"],
1320
+ [7078, "default"],
1321
+ [7098, "default"],
1322
+ [7111, "default"],
1323
+ [7142, "default"],
1324
+ [7202, "default"],
1325
+ [7203, "default"],
1326
+ [7223, "default"],
1327
+ [7237, "default"],
1328
+ [8033, "default"],
1329
+ [8057, "default"],
1330
+ [8076, "default"],
1331
+ [8082, "default"],
1332
+ [8108, "postMul"],
1333
+ [8109, "postMul"],
1334
+ [8111, "default"],
1335
+ [8112, "default"],
1336
+ [8113, "postMul"],
1337
+ [8114, "postMul"],
1338
+ [8119, "default"],
1339
+ [11445, "default"],
1340
+ [11691, "default"],
1341
+ [11946, "default"],
1342
+ [11947, "postMul"],
1343
+ [11948, "postMul"],
1344
+ [11953, "postMul"],
1345
+ [12126, "default"],
1346
+ [12597, "default"],
1347
+ [12761, "default"],
1348
+ [12794, "postDiv"],
1349
+ [12795, "postDiv"],
1350
+ [12796, "postDiv"],
1351
+ [12798, "postDiv"],
1352
+ [12799, "postDiv"],
1353
+ [12838, "postMul"],
1354
+ [12839, "postMul"]
1355
+ ]);
1356
+
1064
1357
  // src/modifierEngine.ts
1065
1358
  var NO_PENALTY_KINDS = /* @__PURE__ */ new Set([
1066
1359
  "skill",
@@ -1075,16 +1368,21 @@ function scaleForPipeline(rawValue, _unitID, op) {
1075
1368
  }
1076
1369
  function applySourceItem(source, ctx, dataset) {
1077
1370
  const isLocalModule = source.kind === "module";
1371
+ const selfMods = [];
1372
+ const outMods = [];
1078
1373
  for (const eid of source.effectIDs) {
1079
1374
  if (LEGACY_HANDLED_EFFECT_IDS.has(eid)) continue;
1375
+ if (SEC_STATUS_SCALED_EFFECT_IDS.has(eid)) continue;
1080
1376
  if (isLocalModule && ctx.stoppedLocalEffectIDs.has(eid)) continue;
1081
1377
  const effect = dataset.effects.get(eid);
1082
1378
  if (!effect) continue;
1083
1379
  if (!source.appliesAtState(effect)) continue;
1084
1380
  for (const mi of effect.modifierInfo) {
1085
- applyOneModifier(source, effect, mi, ctx, dataset);
1381
+ (mi.domain === "itemID" ? selfMods : outMods).push({ effect, mi });
1086
1382
  }
1087
1383
  }
1384
+ for (const { effect, mi } of selfMods) applyOneModifier(source, effect, mi, ctx, dataset);
1385
+ for (const { effect, mi } of outMods) applyOneModifier(source, effect, mi, ctx, dataset);
1088
1386
  }
1089
1387
  function collectEffectStoppers(projectedSources, dataset) {
1090
1388
  const out = /* @__PURE__ */ new Set();
@@ -1150,7 +1448,7 @@ function applyOneModifier(source, effect, mi, ctx, dataset) {
1150
1448
  if (computed === null) return;
1151
1449
  const targets = ctx.targetsForModifier(mi, source);
1152
1450
  if (targets.length === 0) return;
1153
- const stackingGroup = computeStackingGroup(source, mi, dataset);
1451
+ const stackingGroup = computeStackingGroup(source, mi, dataset, effect);
1154
1452
  const isMul = op === "PreMul" || op === "PostMul" || op === "PreDiv" || op === "PostDiv";
1155
1453
  const value = isMul && computed.scaled ? 1 + computed.value : computed.value;
1156
1454
  const sdeDefault = isMul ? dataset.attributes.get(mi.modifiedAttributeID)?.defaultValue ?? 0 : 0;
@@ -1658,7 +1956,52 @@ var SHIP_BONUS_SCALING_SKILL = /* @__PURE__ */ new Map([
1658
1956
  [6112, 24313],
1659
1957
  [6113, 24312],
1660
1958
  [6114, 24311],
1661
- [6116, 24314]
1959
+ [6116, 24314],
1960
+ // ----- Auto-derived from the SDE: attrs that are BOTH skill-level-scaled
1961
+ // (a skill effect does `attr ×= skillLevel` via attr 280) AND read by a
1962
+ // SHIP-side effect as the bonus value. These are per-racial/role-skill hull
1963
+ // bonuses whose ship-side reader must scale by the skill level. Previously
1964
+ // missing → the bonus was taken at base (×1) instead of ×5 at All-V.
1965
+ // Notable: Exhumer/Barge shield+armor resist role bonuses (Hulk/Skiff/
1966
+ // Mackinaw shield resist 4 %→20 %), Bhaalgorn drone+laser (492), industrial
1967
+ // command (Orca/Rorqual), Marauder/pirate/expedition hull bonuses.
1968
+ [66, 89611],
1969
+ [310, 3432],
1970
+ [349, 19760],
1971
+ [492, 3339],
1972
+ [1296, 21610],
1973
+ [1669, 3184],
1974
+ [1670, 3184],
1975
+ [1842, 32918],
1976
+ [3167, 33856],
1977
+ [3181, 17940],
1978
+ [3182, 17940],
1979
+ [3183, 17940],
1980
+ [3184, 17940],
1981
+ [3185, 17940],
1982
+ [3187, 17940],
1983
+ [3188, 17940],
1984
+ [3190, 33856],
1985
+ [3191, 33856],
1986
+ [3192, 33856],
1987
+ [3193, 22551],
1988
+ [3194, 22551],
1989
+ [3197, 22551],
1990
+ [3198, 22551],
1991
+ [3199, 22551],
1992
+ [3203, 29637],
1993
+ [3204, 29637],
1994
+ [3205, 29637],
1995
+ [3210, 3341],
1996
+ [3221, 29637],
1997
+ [3222, 29637],
1998
+ [3223, 28374],
1999
+ [3224, 28374],
2000
+ [3237, 32918],
2001
+ [3240, 32918],
2002
+ [3326, 28374],
2003
+ [6088, 33092],
2004
+ [6089, 33094]
1662
2005
  ]);
1663
2006
  var SUBSYSTEM_BONUS_SCALING_SKILL = /* @__PURE__ */ new Map([
1664
2007
  // Amarr
@@ -1788,7 +2131,7 @@ function computeModifierValue(source, mi, ctx, dataset, op) {
1788
2131
  if (skillID === void 0) return null;
1789
2132
  const level = ctx.skillLevel(skillID);
1790
2133
  if (level === 0) return null;
1791
- if (source.kind === "ship" && SHIP_ROLE_BONUS_ATTRS.has(mi.modifyingAttributeID)) {
2134
+ if (source.kind === "ship") {
1792
2135
  return { value: baseValue, scaled: false };
1793
2136
  }
1794
2137
  if (itemRequiresSkill(source, skillID)) {
@@ -1798,55 +2141,21 @@ function computeModifierValue(source, mi, ctx, dataset, op) {
1798
2141
  }
1799
2142
  return { value: baseValue, scaled: false };
1800
2143
  }
1801
- var SHIP_ROLE_BONUS_ATTRS = /* @__PURE__ */ new Set([
1802
- 793,
1803
- // shipBonusRole7
1804
- 1688,
1805
- // shipBonusRole8
1806
- 1803,
1807
- // MWDSignatureRadiusBonus — Assault Frigate / Interceptor MWD sig role bonus (flat)
1808
- 2059,
1809
- // eliteBonusCommandDestroyer1 — Command Destroyer T2 specialisation (flat per-level applied via skill, but the SHIP-side reader is flat)
1810
- 2060,
1811
- // eliteBonusCommandDestroyer2
1812
- 2064,
1813
- // roleBonusCD — Command Destroyer command burst PG / activation cost reduction.
1814
- // Without this entry, effect 6214 (Draugur's `roleBonusCDLinksPGReduction`)
1815
- // double-applies the -95 % bonus at × Skirmish Command Burst V → -475 %
1816
- // PostPercent on Skirmish Command Burst II `power` (110) → -412.5 MW per
1817
- // burst → total ship power used reads −738 MW instead of +97 MW.
1818
- 2298,
1819
- // shipBonusRole1
1820
- 2299,
1821
- // shipBonusRole2
1822
- 2300,
1823
- // shipBonusRole3
1824
- 2301,
1825
- // shipBonusRole4
1826
- 2302,
1827
- // shipBonusRole5
1828
- 2303,
1829
- // shipBonusRole6
1830
- 5952,
1831
- // shipBonusGasCloudDurationRoleBonusOreMiningDestroyer
1832
- 1989
1833
- // probeLauncherCPUPercentRoleBonusT3 value — effect 6009 on T3C hulls
1834
- // (Loki/Tengu/…): "-99 % CPU for Scan Probe Launchers". Declared as
1835
- // LocationRequiredSkillModifier gated on Astrometrics (3412), but
1836
- // the skill only SELECTS the recipient (probe launchers) — the bonus
1837
- // is FLAT. Without this entry the ship-domain reader scales it ×
1838
- // Astrometrics level → -99 % becomes -495 % PostPercent → a Loki
1839
- // Expanded Probe Launcher's 242 tf CPU flips to -955.9 tf and total
1840
- // CPU used reads -388 instead of +569.5.
1841
- ]);
1842
- function computeStackingGroup(source, mi, dataset) {
2144
+ function computeStackingGroup(source, mi, dataset, effect) {
1843
2145
  if (NO_PENALTY_KINDS.has(source.kind)) return null;
1844
2146
  if (mi.modifiedAttributeID !== void 0) {
1845
2147
  const attr = dataset.attributes.get(mi.modifiedAttributeID);
1846
2148
  if (attr?.stackable) return null;
1847
2149
  }
2150
+ const group = STACKING_PENALTY_GROUPS.get(effect.id);
2151
+ if (group !== void 0 && group !== "default" && CUSTOM_STACK_GROUPS_HONOURED.has(group)) {
2152
+ return `${group}:${mi.modifiedAttributeID}`;
2153
+ }
1848
2154
  return `attr:${mi.modifiedAttributeID}`;
1849
2155
  }
2156
+ var CUSTOM_STACK_GROUPS_HONOURED = /* @__PURE__ */ new Set([
2157
+ "cloakingScanResolutionMultiplier"
2158
+ ]);
1850
2159
  function mapSourceKind(kind) {
1851
2160
  switch (kind) {
1852
2161
  case "ship":
@@ -3564,6 +3873,13 @@ var LEGACY_HANDLED_HARDCODED_EFFECTS = /* @__PURE__ */ new Set([
3564
3873
  6658
3565
3874
  // Bastion Module — applyLegacyBastion
3566
3875
  ]);
3876
+ var SEC_STATUS_SCALED_EFFECT_IDS = /* @__PURE__ */ new Set([
3877
+ 6871,
3878
+ 12165,
3879
+ 12181,
3880
+ 12185,
3881
+ 12202
3882
+ ]);
3567
3883
  var LEGACY_HANDLED_EFFECT_IDS = /* @__PURE__ */ new Set([
3568
3884
  ...LEGACY_HANDLED_PASSIVE_ADD_EFFECTS,
3569
3885
  ...LEGACY_HANDLED_HARDCODED_EFFECTS
@@ -3634,6 +3950,176 @@ function computeTotalEhp(shield, armor, hull, useProfile) {
3634
3950
  return Number.isFinite(total) ? total : Number.MAX_SAFE_INTEGER;
3635
3951
  }
3636
3952
 
3953
+ // src/fitChecks.ts
3954
+ function readAttr(t, id) {
3955
+ return t.attributes.find((a) => a.id === id)?.v;
3956
+ }
3957
+ function typeFitsSlotType(t, slot) {
3958
+ for (const e of t.effects) {
3959
+ const mapped = SLOT_EFFECT_TO_SLOT_TYPE[e.id];
3960
+ if (mapped === slot) return true;
3961
+ }
3962
+ return false;
3963
+ }
3964
+ function isTurretWeapon(t) {
3965
+ return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "TURRET");
3966
+ }
3967
+ function isMissileLauncher(t) {
3968
+ return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "MISSILE");
3969
+ }
3970
+ function isSmartBomb(t) {
3971
+ return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "SMARTBOMB");
3972
+ }
3973
+ function shipGroupRestrictions(t) {
3974
+ const out = [];
3975
+ for (const id of CAN_FIT_SHIP_GROUP_ATTRS) {
3976
+ const v = readAttr(t, id);
3977
+ if (v != null && v > 0) out.push(Math.round(v));
3978
+ }
3979
+ return out;
3980
+ }
3981
+ function shipTypeRestrictions(t) {
3982
+ const out = [];
3983
+ for (const id of CAN_FIT_SHIP_TYPE_ATTRS) {
3984
+ const v = readAttr(t, id);
3985
+ if (v != null && v > 0) out.push(Math.round(v));
3986
+ }
3987
+ return out;
3988
+ }
3989
+ function maxGroupFittedFor(mod) {
3990
+ const v = readAttr(mod, ATTR.MAX_GROUP_FITTED);
3991
+ return v != null && v > 0 ? Math.round(v) : void 0;
3992
+ }
3993
+ function maxTypeFittedFor(mod) {
3994
+ const v = readAttr(mod, ATTR.MAX_TYPE_FITTED);
3995
+ return v != null && v > 0 ? Math.round(v) : void 0;
3996
+ }
3997
+ function freeFitGroupSlotsFor(mod, fittedModules, dataset) {
3998
+ const groupCap = maxGroupFittedFor(mod);
3999
+ const typeCap = maxTypeFittedFor(mod);
4000
+ if (groupCap === void 0 && typeCap === void 0) return Number.POSITIVE_INFINITY;
4001
+ let groupCount = 0;
4002
+ let typeCount = 0;
4003
+ for (const fm of fittedModules) {
4004
+ if (typeCap !== void 0 && fm.typeID === mod.id) typeCount++;
4005
+ if (groupCap !== void 0) {
4006
+ const t = dataset.getType(fm.typeID);
4007
+ if (t && t.groupID === mod.groupID) groupCount++;
4008
+ }
4009
+ }
4010
+ let free = Number.POSITIVE_INFINITY;
4011
+ if (groupCap !== void 0) free = Math.min(free, groupCap - groupCount);
4012
+ if (typeCap !== void 0) free = Math.min(free, typeCap - typeCount);
4013
+ return Math.max(0, free);
4014
+ }
4015
+ function canFitModuleOnShip(mod, ship, slot, fitContext) {
4016
+ if (!typeFitsSlotType(mod, slot)) {
4017
+ return { ok: false, reason: `Module does not declare a ${slot} slot effect` };
4018
+ }
4019
+ if (!ship) return { ok: true };
4020
+ const allowedGroups = shipGroupRestrictions(mod);
4021
+ const allowedTypes = shipTypeRestrictions(mod);
4022
+ if (allowedGroups.length > 0 || allowedTypes.length > 0) {
4023
+ const groupOk = allowedGroups.includes(ship.groupID);
4024
+ const typeOk = allowedTypes.includes(ship.id);
4025
+ if (!groupOk && !typeOk) {
4026
+ return { ok: false, reason: "Ship hull/group not allowed by this module" };
4027
+ }
4028
+ }
4029
+ if (slot === "RIG") {
4030
+ const modSize = readAttr(mod, ATTR.RIG_SIZE);
4031
+ const shipSize = readAttr(ship, ATTR.RIG_SIZE);
4032
+ if (modSize != null && shipSize != null && Math.round(modSize) !== Math.round(shipSize)) {
4033
+ return { ok: false, reason: "Rig size mismatch" };
4034
+ }
4035
+ }
4036
+ if (slot === "SUBSYSTEM") {
4037
+ const fitsTo = readAttr(mod, 1380);
4038
+ if (fitsTo != null && fitsTo > 0 && Math.round(fitsTo) !== ship.id) {
4039
+ return { ok: false, reason: "Subsystem locked to a different T3C hull" };
4040
+ }
4041
+ }
4042
+ if (slot === "HI") {
4043
+ if (isTurretWeapon(mod)) {
4044
+ const turrets = readAttr(ship, ATTR.TURRET_HARDPOINTS) ?? 0;
4045
+ if (turrets <= 0) {
4046
+ return { ok: false, reason: "Ship has no turret hardpoints" };
4047
+ }
4048
+ } else if (isMissileLauncher(mod)) {
4049
+ const launchers = readAttr(ship, ATTR.LAUNCHER_HARDPOINTS) ?? 0;
4050
+ if (launchers <= 0) {
4051
+ return { ok: false, reason: "Ship has no launcher hardpoints" };
4052
+ }
4053
+ }
4054
+ }
4055
+ if (fitContext) {
4056
+ const free = freeFitGroupSlotsFor(mod, fitContext.fittedModules, fitContext.dataset);
4057
+ if (free <= 0) {
4058
+ const cap = maxTypeFittedFor(mod) ?? maxGroupFittedFor(mod);
4059
+ return { ok: false, reason: `Only ${cap} of this module type can be fitted to a ship` };
4060
+ }
4061
+ }
4062
+ return { ok: true };
4063
+ }
4064
+ function chargeGroupsForModule(mod) {
4065
+ const out = [];
4066
+ for (const attrID of CHARGE_GROUP_ATTRS) {
4067
+ const v = readAttr(mod, attrID);
4068
+ if (v != null && v > 0) out.push(Math.round(v));
4069
+ }
4070
+ return out;
4071
+ }
4072
+ function moduleAcceptsAnyCharge(mod) {
4073
+ return chargeGroupsForModule(mod).length > 0;
4074
+ }
4075
+ function moduleAcceptsChargeType(mod, charge) {
4076
+ const allowedGroups = chargeGroupsForModule(mod);
4077
+ if (allowedGroups.length === 0) return false;
4078
+ if (!allowedGroups.includes(charge.groupID)) return false;
4079
+ const modSize = readAttr(mod, ATTR.CHARGE_SIZE);
4080
+ const chargeSize = readAttr(charge, ATTR.CHARGE_SIZE);
4081
+ if (modSize != null && chargeSize != null && Math.round(chargeSize) > Math.round(modSize)) {
4082
+ return false;
4083
+ }
4084
+ return true;
4085
+ }
4086
+ var ACTIVE_EFFECT_CATEGORIES = /* @__PURE__ */ new Set([1, 2, 3]);
4087
+ function isActivatableModule(mod, effects) {
4088
+ for (const e of mod.effects) {
4089
+ const eff = effects.get(e.id);
4090
+ if (eff && eff.effectCategoryID !== void 0 && ACTIVE_EFFECT_CATEGORIES.has(eff.effectCategoryID)) {
4091
+ return true;
4092
+ }
4093
+ }
4094
+ return false;
4095
+ }
4096
+ var DEFAULT_OFFLINE_ACTIVATION_GROUPS = /* @__PURE__ */ new Set([
4097
+ 330,
4098
+ 4117
4099
+ ]);
4100
+ function defaultStateForModule(mod, effects) {
4101
+ if (DEFAULT_OFFLINE_ACTIVATION_GROUPS.has(mod.groupID)) return "ONLINE";
4102
+ return isActivatableModule(mod, effects) ? "ACTIVE" : "ONLINE";
4103
+ }
4104
+ function freeHardpointsFor(mod, ship, fittedHiModules, dataset) {
4105
+ const turretCap = readAttr(ship, ATTR.TURRET_HARDPOINTS) ?? 0;
4106
+ const launcherCap = readAttr(ship, ATTR.LAUNCHER_HARDPOINTS) ?? 0;
4107
+ let turretUsed = 0;
4108
+ let launcherUsed = 0;
4109
+ for (const m of fittedHiModules) {
4110
+ const t = dataset.getType(m.typeID);
4111
+ if (!t) continue;
4112
+ if (isTurretWeapon(t)) turretUsed++;
4113
+ else if (isMissileLauncher(t)) launcherUsed++;
4114
+ }
4115
+ let hardpointFree;
4116
+ if (isTurretWeapon(mod)) hardpointFree = Math.max(0, turretCap - turretUsed);
4117
+ else if (isMissileLauncher(mod)) hardpointFree = Math.max(0, launcherCap - launcherUsed);
4118
+ else hardpointFree = Number.POSITIVE_INFINITY;
4119
+ const groupFree = freeFitGroupSlotsFor(mod, fittedHiModules, dataset);
4120
+ return Math.min(hardpointFree, groupFree);
4121
+ }
4122
+
3637
4123
  // src/derived/capacitor.ts
3638
4124
  function computeCapacitor(ctx, dataset) {
3639
4125
  const ship = ctx.ship;
@@ -3713,6 +4199,7 @@ var CAP_BOOSTER_EFFECT_ID = 48;
3713
4199
  var ATTR_CAPACITOR_BONUS = 67;
3714
4200
  var ATTR_CAPACITOR_NEED2 = 6;
3715
4201
  var ATTR_DURATION_MS2 = 73;
4202
+ var ATTR_REACTIVATION_MS = 669;
3716
4203
  var ATTR_RELOAD_TIME = 1795;
3717
4204
  var ATTR_CAPACITY = 38;
3718
4205
  var ATTR_VOLUME = 161;
@@ -3730,11 +4217,15 @@ function collectDrains(ctx, dataset) {
3730
4217
  }
3731
4218
  continue;
3732
4219
  }
4220
+ const seen = /* @__PURE__ */ new Set();
3733
4221
  for (const eid of m.effectIDs) {
3734
4222
  const effect = dataset.effects.get(eid);
3735
4223
  if (!effect) continue;
3736
4224
  const entry = drainEntryFromEffect(effect, m);
3737
4225
  if (!entry) continue;
4226
+ const sig = `${entry.cycleMs}|${entry.capNeed}|${entry.clipSize}|${entry.reloadMs}|${entry.isInjector ? 1 : 0}|${entry.disableStagger ? 1 : 0}`;
4227
+ if (seen.has(sig)) continue;
4228
+ seen.add(sig);
3738
4229
  drains.push(entry);
3739
4230
  if (entry.capNeed > 0 && entry.cycleMs > 0) {
3740
4231
  totalGrossDrain += entry.capNeed / (entry.cycleMs / 1e3);
@@ -3747,8 +4238,9 @@ function drainEntryFromEffect(effect, mod) {
3747
4238
  if (effect.dischargeAttributeID === void 0) return null;
3748
4239
  const discharge = mod.getFinal(effect.dischargeAttributeID, 0);
3749
4240
  if (discharge <= 0) return null;
3750
- const cycleMs = effect.durationAttributeID !== void 0 ? mod.getFinal(effect.durationAttributeID, 0) : 1e3;
3751
- 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));
3752
4244
  return {
3753
4245
  cycleMs,
3754
4246
  capNeed: discharge,
@@ -3756,7 +4248,12 @@ function drainEntryFromEffect(effect, mod) {
3756
4248
  // ammo doesn't reload for cap purposes on regular modules
3757
4249
  reloadMs: 0,
3758
4250
  isInjector: false,
3759
- 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)
3760
4257
  };
3761
4258
  }
3762
4259
  function boosterDrainEntry(mod) {
@@ -3764,19 +4261,19 @@ function boosterDrainEntry(mod) {
3764
4261
  if (!charge) return null;
3765
4262
  const capNeed = mod.getFinal(ATTR_CAPACITOR_NEED2, 0);
3766
4263
  const inject = charge.getFinal(ATTR_CAPACITOR_BONUS, 0);
3767
- const cycleMs = mod.getFinal(ATTR_DURATION_MS2, 0);
4264
+ const cycleMs = Math.round(mod.getFinal(ATTR_DURATION_MS2, 0));
3768
4265
  if (cycleMs <= 0) return null;
3769
- let reloadMs = mod.getFinal(ATTR_RELOAD_TIME, 0);
4266
+ let reloadMs = Math.round(mod.getFinal(ATTR_RELOAD_TIME, 0));
3770
4267
  if (reloadMs <= 0) reloadMs = 1e4;
3771
4268
  const capacity = mod.getFinal(ATTR_CAPACITY, 0);
3772
4269
  const chargeVol = charge.getFinal(ATTR_VOLUME, 0);
3773
- const charges = capacity > 0 && chargeVol > 0 ? Math.floor(capacity / chargeVol) : 1;
4270
+ const charges = capacity > 0 && chargeVol > 0 ? Math.floor(capacity / chargeVol) : 0;
3774
4271
  return {
3775
4272
  cycleMs,
3776
4273
  // Pyfa convention: positive capNeed = drain, negative = injection.
3777
4274
  // For boosters the *net* per-cycle change is (capNeed_module - inject_charge).
3778
4275
  capNeed: capNeed - inject,
3779
- clipSize: Math.max(1, charges),
4276
+ clipSize: charges,
3780
4277
  reloadMs,
3781
4278
  isInjector: true,
3782
4279
  disableStagger: false
@@ -4708,8 +5205,8 @@ function computeFit(fit, dataset, opts) {
4708
5205
  }
4709
5206
  ctx.stoppedLocalEffectIDs = collectEffectStoppers(earlyProjected, dataset);
4710
5207
  }
4711
- for (const m of modules) applySourceItem(m, ctx, dataset);
4712
5208
  for (const m of modules) if (m.charge) applySourceItem(m.charge, ctx, dataset);
5209
+ for (const m of modules) applySourceItem(m, ctx, dataset);
4713
5210
  for (const d of drones) applySourceItem(d, ctx, dataset);
4714
5211
  for (const f of fighters) applySourceItem(f, ctx, dataset);
4715
5212
  for (const i of implants) applySourceItem(i, ctx, dataset);
@@ -4872,6 +5369,7 @@ function buildProjectionReport(source, ctx) {
4872
5369
  summary: labels[cls.kind] ?? cls.kind
4873
5370
  };
4874
5371
  }
5372
+ var CHAR_BASE_MAX_LOCKED_TARGETS = 2;
4875
5373
  function deriveStats(ctx, dataset, damageProfile, fit, projectionReports) {
4876
5374
  const ship = ctx.ship;
4877
5375
  const slotUsed = { HI: 0, MED: 0, LO: 0, RIG: 0, SUBSYSTEM: 0, SERVICE: 0 };
@@ -4992,7 +5490,18 @@ function deriveStats(ctx, dataset, damageProfile, fit, projectionReports) {
4992
5490
  ship.getFinal(ATTR.MAX_TARGET_RANGE, 0),
4993
5491
  ship.getFinal(ATTR.MAX_TARGET_RANGE_CAP, 3e5)
4994
5492
  ),
4995
- 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
+ )),
4996
5505
  signatureRadius: ship.getFinal(ATTR.SIGNATURE_RADIUS, 0),
4997
5506
  scanResolution: ship.getFinal(ATTR.SCAN_RESOLUTION, 0),
4998
5507
  sensorStrength: pickSensorStrength(ship).value,
@@ -5711,176 +6220,6 @@ var TARGET_PROFILE_PRESETS = [
5711
6220
  { name: "Keepstar", signatureRadius: 3e4, maxVelocity: 0, emResist: 0.7, thermalResist: 0.7, kineticResist: 0.7, explosiveResist: 0.7, isPreset: true }
5712
6221
  ];
5713
6222
 
5714
- // src/fitChecks.ts
5715
- function readAttr(t, id) {
5716
- return t.attributes.find((a) => a.id === id)?.v;
5717
- }
5718
- function typeFitsSlotType(t, slot) {
5719
- for (const e of t.effects) {
5720
- const mapped = SLOT_EFFECT_TO_SLOT_TYPE[e.id];
5721
- if (mapped === slot) return true;
5722
- }
5723
- return false;
5724
- }
5725
- function isTurretWeapon(t) {
5726
- return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "TURRET");
5727
- }
5728
- function isMissileLauncher(t) {
5729
- return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "MISSILE");
5730
- }
5731
- function isSmartBomb(t) {
5732
- return t.effects.some((e) => WEAPON_EFFECT_KIND[e.id] === "SMARTBOMB");
5733
- }
5734
- function shipGroupRestrictions(t) {
5735
- const out = [];
5736
- for (const id of CAN_FIT_SHIP_GROUP_ATTRS) {
5737
- const v = readAttr(t, id);
5738
- if (v != null && v > 0) out.push(Math.round(v));
5739
- }
5740
- return out;
5741
- }
5742
- function shipTypeRestrictions(t) {
5743
- const out = [];
5744
- for (const id of CAN_FIT_SHIP_TYPE_ATTRS) {
5745
- const v = readAttr(t, id);
5746
- if (v != null && v > 0) out.push(Math.round(v));
5747
- }
5748
- return out;
5749
- }
5750
- function maxGroupFittedFor(mod) {
5751
- const v = readAttr(mod, ATTR.MAX_GROUP_FITTED);
5752
- return v != null && v > 0 ? Math.round(v) : void 0;
5753
- }
5754
- function maxTypeFittedFor(mod) {
5755
- const v = readAttr(mod, ATTR.MAX_TYPE_FITTED);
5756
- return v != null && v > 0 ? Math.round(v) : void 0;
5757
- }
5758
- function freeFitGroupSlotsFor(mod, fittedModules, dataset) {
5759
- const groupCap = maxGroupFittedFor(mod);
5760
- const typeCap = maxTypeFittedFor(mod);
5761
- if (groupCap === void 0 && typeCap === void 0) return Number.POSITIVE_INFINITY;
5762
- let groupCount = 0;
5763
- let typeCount = 0;
5764
- for (const fm of fittedModules) {
5765
- if (typeCap !== void 0 && fm.typeID === mod.id) typeCount++;
5766
- if (groupCap !== void 0) {
5767
- const t = dataset.getType(fm.typeID);
5768
- if (t && t.groupID === mod.groupID) groupCount++;
5769
- }
5770
- }
5771
- let free = Number.POSITIVE_INFINITY;
5772
- if (groupCap !== void 0) free = Math.min(free, groupCap - groupCount);
5773
- if (typeCap !== void 0) free = Math.min(free, typeCap - typeCount);
5774
- return Math.max(0, free);
5775
- }
5776
- function canFitModuleOnShip(mod, ship, slot, fitContext) {
5777
- if (!typeFitsSlotType(mod, slot)) {
5778
- return { ok: false, reason: `Module does not declare a ${slot} slot effect` };
5779
- }
5780
- if (!ship) return { ok: true };
5781
- const allowedGroups = shipGroupRestrictions(mod);
5782
- const allowedTypes = shipTypeRestrictions(mod);
5783
- if (allowedGroups.length > 0 || allowedTypes.length > 0) {
5784
- const groupOk = allowedGroups.includes(ship.groupID);
5785
- const typeOk = allowedTypes.includes(ship.id);
5786
- if (!groupOk && !typeOk) {
5787
- return { ok: false, reason: "Ship hull/group not allowed by this module" };
5788
- }
5789
- }
5790
- if (slot === "RIG") {
5791
- const modSize = readAttr(mod, ATTR.RIG_SIZE);
5792
- const shipSize = readAttr(ship, ATTR.RIG_SIZE);
5793
- if (modSize != null && shipSize != null && Math.round(modSize) !== Math.round(shipSize)) {
5794
- return { ok: false, reason: "Rig size mismatch" };
5795
- }
5796
- }
5797
- if (slot === "SUBSYSTEM") {
5798
- const fitsTo = readAttr(mod, 1380);
5799
- if (fitsTo != null && fitsTo > 0 && Math.round(fitsTo) !== ship.id) {
5800
- return { ok: false, reason: "Subsystem locked to a different T3C hull" };
5801
- }
5802
- }
5803
- if (slot === "HI") {
5804
- if (isTurretWeapon(mod)) {
5805
- const turrets = readAttr(ship, ATTR.TURRET_HARDPOINTS) ?? 0;
5806
- if (turrets <= 0) {
5807
- return { ok: false, reason: "Ship has no turret hardpoints" };
5808
- }
5809
- } else if (isMissileLauncher(mod)) {
5810
- const launchers = readAttr(ship, ATTR.LAUNCHER_HARDPOINTS) ?? 0;
5811
- if (launchers <= 0) {
5812
- return { ok: false, reason: "Ship has no launcher hardpoints" };
5813
- }
5814
- }
5815
- }
5816
- if (fitContext) {
5817
- const free = freeFitGroupSlotsFor(mod, fitContext.fittedModules, fitContext.dataset);
5818
- if (free <= 0) {
5819
- const cap = maxTypeFittedFor(mod) ?? maxGroupFittedFor(mod);
5820
- return { ok: false, reason: `Only ${cap} of this module type can be fitted to a ship` };
5821
- }
5822
- }
5823
- return { ok: true };
5824
- }
5825
- function chargeGroupsForModule(mod) {
5826
- const out = [];
5827
- for (const attrID of CHARGE_GROUP_ATTRS) {
5828
- const v = readAttr(mod, attrID);
5829
- if (v != null && v > 0) out.push(Math.round(v));
5830
- }
5831
- return out;
5832
- }
5833
- function moduleAcceptsAnyCharge(mod) {
5834
- return chargeGroupsForModule(mod).length > 0;
5835
- }
5836
- function moduleAcceptsChargeType(mod, charge) {
5837
- const allowedGroups = chargeGroupsForModule(mod);
5838
- if (allowedGroups.length === 0) return false;
5839
- if (!allowedGroups.includes(charge.groupID)) return false;
5840
- const modSize = readAttr(mod, ATTR.CHARGE_SIZE);
5841
- const chargeSize = readAttr(charge, ATTR.CHARGE_SIZE);
5842
- if (modSize != null && chargeSize != null && Math.round(chargeSize) > Math.round(modSize)) {
5843
- return false;
5844
- }
5845
- return true;
5846
- }
5847
- var ACTIVE_EFFECT_CATEGORIES = /* @__PURE__ */ new Set([1, 2, 3]);
5848
- function isActivatableModule(mod, effects) {
5849
- for (const e of mod.effects) {
5850
- const eff = effects.get(e.id);
5851
- if (eff && eff.effectCategoryID !== void 0 && ACTIVE_EFFECT_CATEGORIES.has(eff.effectCategoryID)) {
5852
- return true;
5853
- }
5854
- }
5855
- return false;
5856
- }
5857
- var DEFAULT_OFFLINE_ACTIVATION_GROUPS = /* @__PURE__ */ new Set([
5858
- 330,
5859
- 4117
5860
- ]);
5861
- function defaultStateForModule(mod, effects) {
5862
- if (DEFAULT_OFFLINE_ACTIVATION_GROUPS.has(mod.groupID)) return "ONLINE";
5863
- return isActivatableModule(mod, effects) ? "ACTIVE" : "ONLINE";
5864
- }
5865
- function freeHardpointsFor(mod, ship, fittedHiModules, dataset) {
5866
- const turretCap = readAttr(ship, ATTR.TURRET_HARDPOINTS) ?? 0;
5867
- const launcherCap = readAttr(ship, ATTR.LAUNCHER_HARDPOINTS) ?? 0;
5868
- let turretUsed = 0;
5869
- let launcherUsed = 0;
5870
- for (const m of fittedHiModules) {
5871
- const t = dataset.getType(m.typeID);
5872
- if (!t) continue;
5873
- if (isTurretWeapon(t)) turretUsed++;
5874
- else if (isMissileLauncher(t)) launcherUsed++;
5875
- }
5876
- let hardpointFree;
5877
- if (isTurretWeapon(mod)) hardpointFree = Math.max(0, turretCap - turretUsed);
5878
- else if (isMissileLauncher(mod)) hardpointFree = Math.max(0, launcherCap - launcherUsed);
5879
- else hardpointFree = Number.POSITIVE_INFINITY;
5880
- const groupFree = freeFitGroupSlotsFor(mod, fittedHiModules, dataset);
5881
- return Math.min(hardpointFree, groupFree);
5882
- }
5883
-
5884
6223
  exports.ACTIVATION_EFFECT_ID = ACTIVATION_EFFECT_ID;
5885
6224
  exports.ATTR = ATTR;
5886
6225
  exports.CATEGORY = CATEGORY;