@axiapps/gw2-data 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.
Files changed (43) hide show
  1. package/data/overrides.json +24 -0
  2. package/package.json +4 -1
  3. package/src/engine/attributes.js +102 -23
  4. package/src/engine/boons.js +19 -1
  5. package/src/engine/constants.js +27 -2
  6. package/src/engine/index.js +4 -4
  7. package/src/engine/modifiers.js +57 -21
  8. package/src/index.js +5 -0
  9. package/src/wiki/parser.js +17 -9
  10. package/scripts/generate-fixtures.js +0 -242
  11. package/tests/api-client.test.js +0 -138
  12. package/tests/cache.test.js +0 -108
  13. package/tests/engine/attributes.test.js +0 -252
  14. package/tests/engine/boons.test.js +0 -129
  15. package/tests/engine/combos.test.js +0 -76
  16. package/tests/engine/constants.test.js +0 -576
  17. package/tests/engine/fixtures/berserker-thief.json +0 -61
  18. package/tests/engine/fixtures/berserker-warrior.json +0 -113
  19. package/tests/engine/fixtures/celestial-firebrand-wvw.json +0 -94
  20. package/tests/engine/fixtures/harrier-druid.json +0 -119
  21. package/tests/engine/fixtures/viper-mirage.json +0 -104
  22. package/tests/engine/graph.test.js +0 -30
  23. package/tests/engine/integration.test.js +0 -111
  24. package/tests/engine/modifiers.test.js +0 -473
  25. package/tests/engine/overrides.test.js +0 -70
  26. package/tests/engine/snapshot.test.js +0 -53
  27. package/tests/engine/test-utils.js +0 -20
  28. package/tests/engine/tooltips.test.js +0 -62
  29. package/tests/fixtures/capture.js +0 -160
  30. package/tests/fixtures/fixtures.json +0 -839
  31. package/tests/integration.test.js +0 -100
  32. package/tests/match.test.js +0 -176
  33. package/tests/merge.test.js +0 -128
  34. package/tests/normalize.test.js +0 -78
  35. package/tests/parser.test.js +0 -506
  36. package/tests/real-data.test.js +0 -296
  37. package/tests/relations.test.js +0 -80
  38. package/tests/resolver.test.js +0 -721
  39. package/tests/validate-live.js +0 -191
  40. package/tests/wiki-client.test.js +0 -468
  41. package/tests/wiki-integration.test.js +0 -177
  42. package/tests/wiki-live-validation.test.js +0 -61
  43. package/tests/wiki-snapshots.test.js +0 -166
@@ -18,8 +18,32 @@
18
18
  "trait:1831": {
19
19
  "description": "Primal Rage: grants access to Primal Bursts (no burst recharge reduction)"
20
20
  },
21
+ "trait:1453": {
22
+ "mightOverride": { "power": 40 },
23
+ "description": "Pinnacle of Strength: Might grants +10 bonus Power per stack (40P instead of 30P), +5% passive crit chance"
24
+ },
21
25
  "trait:2046": {
22
26
  "berserkConditional": true,
23
27
  "description": "Fatal Frenzy: stat bonuses only apply while in berserk mode"
28
+ },
29
+ "trait:2049": {
30
+ "berserkConditional": true,
31
+ "description": "Smash Brawler: crit chance bonus only applies while in berserk mode"
32
+ },
33
+ "trait:1338": {
34
+ "weaponConditional": { "weapons": ["greatsword", "spear"], "multiplier": 2 },
35
+ "description": "Forceful Greatsword: double Power bonus while wielding greatsword or underwater spear"
36
+ },
37
+ "trait:1011": {
38
+ "ignoreCritChance": true,
39
+ "description": "Precise Strike: 100% crit chance applies to Opening Strike only, not passive"
40
+ },
41
+ "trait:1215": {
42
+ "ignoreCritChance": true,
43
+ "description": "Hidden Killer: 100% crit chance applies while stealthed only, not passive"
44
+ },
45
+ "trait:1336": {
46
+ "ignoreCritChance": true,
47
+ "description": "Burst Precision: 100% crit chance applies to Burst skills only, not passive"
24
48
  }
25
49
  }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@axiapps/gw2-data",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "GW2 data library — wiki-sourced facts, GW2 API structural data, and stat computation",
5
5
  "main": "src/index.js",
6
6
  "exports": {
7
7
  ".": "./src/index.js",
8
8
  "./wiki": "./src/wiki/client.js",
9
+ "./wiki/resolver": "./src/wiki/resolver.js",
9
10
  "./api": "./src/api/client.js",
10
11
  "./facts": "./src/facts/merge.js",
11
12
  "./engine": "./src/engine/index.js"
@@ -19,6 +20,8 @@
19
20
  "clearMocks": true,
20
21
  "testTimeout": 15000
21
22
  },
23
+ "files": ["src", "data", "README.md"],
24
+ "publishConfig": { "access": "public" },
22
25
  "license": "MIT",
23
26
  "keywords": ["gw2", "guild-wars-2", "wiki", "api", "build-editor"],
24
27
  "repository": {
@@ -14,8 +14,11 @@ const {
14
14
  MIGHT_CONDI_PER_STACK,
15
15
  FURY_CRIT_CHANCE,
16
16
  FURY_CRIT_CHANCE_WVW,
17
+ BERSERK_CRIT_CHANCE,
18
+ BERSERK_CRIT_CHANCE_WVW,
17
19
  STACKING_SIGIL_DEFS,
18
20
  SIGNET_PASSIVE_BUFFS,
21
+ SIGNET_ACTIVE_EFFECTS,
19
22
  ALL_STAT_KEYS,
20
23
  CONVERSION_TARGET_MAP,
21
24
  } = require("./constants");
@@ -305,13 +308,31 @@ function computeAttributes(ctx, catalogs) {
305
308
 
306
309
  // -------------------------------------------------------------------------
307
310
  // Step 5: Infusions — per slot, flatMap arrays, lookup infixUpgrade.attributes
311
+ // For mainhand weapon slots, limit infusion count based on weapon type:
312
+ // 1H weapons get 1 infusion slot, 2H/aquatic weapons get 2.
308
313
  // -------------------------------------------------------------------------
309
314
  const infusionStats = zeroStats();
310
315
  if (equipment.infusions && catalogs.infusionById) {
311
316
  for (const [slotKey, infusionIds] of Object.entries(equipment.infusions)) {
312
317
  if (excluded.has(slotKey)) continue;
318
+ // Skip offhand infusions when the corresponding mainhand has a two-handed weapon
319
+ // (offhand is locked/empty when mainhand is 2H, so its infusions shouldn't count)
320
+ if (slotKey.startsWith("offhand") && !slotKey.startsWith("aquatic")) {
321
+ const mainhandKey = slotKey.replace("offhand", "mainhand");
322
+ if (TWO_HAND_WEAPON_TYPES.has(weapons[mainhandKey] || "")) continue;
323
+ }
313
324
  const ids = Array.isArray(infusionIds) ? infusionIds : [infusionIds];
314
- for (const infId of ids) {
325
+ // Cap infusion slots for mainhand weapons: 2H = 2, 1H = 1
326
+ let maxSlots = ids.length;
327
+ if (slotKey.startsWith("mainhand") || slotKey.startsWith("aquatic")) {
328
+ const weaponType = weapons[slotKey] || "";
329
+ if (weaponType && !slotKey.startsWith("aquatic")) {
330
+ maxSlots = TWO_HAND_WEAPON_TYPES.has(weaponType) ? 2 : 1;
331
+ }
332
+ }
333
+ const limit = Math.min(ids.length, maxSlots);
334
+ for (let i = 0; i < limit; i++) {
335
+ const infId = ids[i];
315
336
  if (!infId) continue;
316
337
  const inf = catalogs.infusionById.get(Number(infId));
317
338
  if (!inf?.infixUpgrade?.attributes) continue;
@@ -341,6 +362,8 @@ function computeAttributes(ctx, catalogs) {
341
362
  // Step 7: Signets
342
363
  // -------------------------------------------------------------------------
343
364
  const signetStats = zeroStats();
365
+ // Boons granted by activated signet active skills (aggregated for engine use).
366
+ const signetActiveBoons = { might: 0, fury: false };
344
367
  if (ctx.skills) {
345
368
  const skillIds = [
346
369
  ctx.skills.healId,
@@ -348,20 +371,49 @@ function computeAttributes(ctx, catalogs) {
348
371
  ctx.skills.eliteId,
349
372
  ].filter(Boolean).map(Number);
350
373
 
374
+ // Signet state per signet — three values:
375
+ // undefined / "passive" / true → passive bonus applies (default)
376
+ // "active" → skill used: active effect applies, passive removed
377
+ // "cooldown" / false → on cooldown: passive removed, no active bonus yet
378
+ // Absent/null activeSignets → all passives apply (backward compatible).
379
+ const activeSignets = ctx.activeSignets;
380
+ const getSignetState = (id) => {
381
+ if (!activeSignets) return "passive";
382
+ const v = activeSignets instanceof Map ? activeSignets.get(id) : activeSignets[id];
383
+ if (v === "active") return "active";
384
+ if (v === "cooldown" || v === false) return "cooldown";
385
+ return "passive"; // undefined, true, "passive"
386
+ };
387
+
351
388
  for (const skillId of skillIds) {
389
+ const state = getSignetState(skillId);
352
390
  const buff = SIGNET_PASSIVE_BUFFS.get(skillId);
353
- if (buff && buff.stat in signetStats) {
391
+ if (buff && buff.stat in signetStats && state === "passive") {
354
392
  signetStats[buff.stat] += buff.value;
355
393
  }
394
+ // When signet active is used, apply its active-skill effect.
395
+ if (state === "active") {
396
+ const active = SIGNET_ACTIVE_EFFECTS.get(skillId);
397
+ if (active) {
398
+ if (active.stats) {
399
+ for (const [stat, val] of Object.entries(active.stats)) {
400
+ if (stat in signetStats) signetStats[stat] += val;
401
+ }
402
+ }
403
+ if (active.boons) {
404
+ if (active.boons.might) signetActiveBoons.might += active.boons.might;
405
+ if (active.boons.fury) signetActiveBoons.fury = true;
406
+ }
407
+ }
408
+ }
356
409
  }
357
410
  }
358
411
 
359
412
  // -------------------------------------------------------------------------
360
- // Step 9: Pre-conversion totals (base + equipment + food + runes + infusions + enrichment + signets)
361
- // (Utility is added after but wait for its conversion to use pre-conv totals)
362
- // Note: We compute pre-conv totals without utility first, then add utility (non-conversion parts)
363
- // Actually per spec: utility conversion uses preConvTotal. We'll compute pre-conv WITHOUT utility,
364
- // parse utility conversions using that, then add utility flat/writ parts separately.
413
+ // Step 9: Pre-conversion base (base + equipment + food + runes + infusions + enrichment + signets)
414
+ // Both utility conversions and trait conversions use this snapshot as their source so that
415
+ // neither can feed into the other (utility-converted stats don't inflate trait conversions
416
+ // and vice versa). Matches the fix for issue #227 (utility) extended to issue #228 (traits).
365
417
  // -------------------------------------------------------------------------
366
418
  const preConvBase = zeroStats();
367
419
  for (const key of ALL_STAT_KEYS) {
@@ -369,7 +421,7 @@ function computeAttributes(ctx, catalogs) {
369
421
  runeStats[key] + infusionStats[key] + enrichmentStats[key] + signetStats[key];
370
422
  }
371
423
 
372
- // Parse utility buff using preConvBase as the "preConvTotal" for conversion
424
+ // Parse utility buff using preConvBase as the source for % conversions
373
425
  const utilityStats = zeroStats();
374
426
  if (equipment.utility && catalogs.utilityById) {
375
427
  const utility = catalogs.utilityById.get(equipment.utility);
@@ -379,39 +431,45 @@ function computeAttributes(ctx, catalogs) {
379
431
  }
380
432
  }
381
433
 
382
- // Recompute pre-conv totals including utility (for trait conversions)
383
- const preConvTotal = zeroStats();
384
- for (const key of ALL_STAT_KEYS) {
385
- preConvTotal[key] = preConvBase[key] + utilityStats[key];
386
- }
387
-
388
434
  // -------------------------------------------------------------------------
389
435
  // Step 10: Traits — flat bonuses from active traits
390
436
  // -------------------------------------------------------------------------
391
437
  const traitStats = zeroStats();
438
+ const traitDetails = []; // per-trait breakdown: { traitId, name, target, value }
392
439
  const modifiers = collectModifiers(ctx, catalogs, overrides);
393
- const furyAssumed = Boolean(ctx.assumedBoons?.fury);
440
+ const furyAssumed = Boolean(ctx.assumedBoons?.fury) || signetActiveBoons.fury;
394
441
  const berserkActive = Boolean(ctx.berserkActive);
395
442
 
396
443
  for (const mod of modifiers) {
397
444
  if (mod.type !== "flatBonus") continue;
398
- // Apply if: passive (condition === null), fury (when assumed), or berserk (when toggled)
445
+ // Apply if: passive (condition === null), fury (when assumed or from signet active), or berserk (when toggled)
399
446
  if (mod.condition === null
400
447
  || (mod.condition === "fury" && furyAssumed)
401
448
  || (mod.condition === "berserk" && berserkActive)) {
402
449
  if (mod.target in traitStats) {
403
450
  traitStats[mod.target] += mod.value;
451
+ // Extract trait ID from source (e.g. "trait:1338" → 1338)
452
+ const traitId = Number(mod.source?.replace("trait:", "")) || 0;
453
+ const trait = traitId && catalogs?.traitById?.get(traitId);
454
+ traitDetails.push({
455
+ traitId,
456
+ name: trait?.name || mod.source,
457
+ target: mod.target,
458
+ value: mod.value,
459
+ });
404
460
  }
405
461
  }
406
462
  }
407
463
 
408
464
  // -------------------------------------------------------------------------
409
465
  // Step 11: Conversions — trait conversion modifiers
466
+ // Source is preConvBase (same snapshot used for utility conversions, issue #228):
467
+ // excludes utility stats so utility-derived bonuses don't inflate trait conversions.
410
468
  // -------------------------------------------------------------------------
411
469
  const conversionStats = zeroStats();
412
470
  for (const mod of modifiers) {
413
471
  if (mod.type !== "conversion") continue;
414
- const sourceVal = preConvTotal[mod.sourceAttr] || 0;
472
+ const sourceVal = preConvBase[mod.sourceAttr] || 0;
415
473
  const bonus = Math.floor(sourceVal * mod.percent / 100);
416
474
  if (mod.target in conversionStats) {
417
475
  conversionStats[mod.target] += bonus;
@@ -422,7 +480,10 @@ function computeAttributes(ctx, catalogs) {
422
480
  // Step 12: Boons — Might stacks, fury crit (handled in derived)
423
481
  // -------------------------------------------------------------------------
424
482
  const boonStats = zeroStats();
425
- const mightStacks = ctx.assumedBoons?.might || 0;
483
+ // Total Might stacks = user-assumed + any from activated signet actives
484
+ const mightStacks = (ctx.assumedBoons?.might || 0) + signetActiveBoons.might;
485
+ // Fury = user-assumed OR granted by an activated signet active
486
+ const furyFromSignet = signetActiveBoons.fury;
426
487
 
427
488
  if (mightStacks > 0) {
428
489
  // Check for mightModifier overrides from traits
@@ -477,19 +538,35 @@ function computeAttributes(ctx, catalogs) {
477
538
 
478
539
  const health = profBaseHp + total.Vitality * 10;
479
540
 
541
+ // Crit bonus from traits and boons
542
+ let critBonus = 0;
543
+ // Passive crit chance modifiers (always active, condition === null)
544
+ for (const mod of modifiers) {
545
+ if (mod.type === "critChance" && mod.condition === null) {
546
+ critBonus += mod.value;
547
+ }
548
+ }
480
549
  // Fury crit bonus
481
- let furyCritBonus = 0;
482
550
  if (furyAssumed) {
483
- furyCritBonus += gameMode === "wvw" ? FURY_CRIT_CHANCE_WVW : FURY_CRIT_CHANCE;
484
- // Add any critChance modifiers from traits that have condition === "fury"
551
+ critBonus += gameMode === "wvw" ? FURY_CRIT_CHANCE_WVW : FURY_CRIT_CHANCE;
485
552
  for (const mod of modifiers) {
486
553
  if (mod.type === "critChance" && mod.condition === "fury") {
487
- furyCritBonus += mod.value;
554
+ critBonus += mod.value;
555
+ }
556
+ }
557
+ }
558
+ // Berserk crit bonus (only when berserk mode is active)
559
+ // WvW: base berserk crit is 0; crit comes from berserk-conditional traits (e.g. Smash Brawler WvW: +5%).
560
+ if (berserkActive) {
561
+ critBonus += gameMode === "wvw" ? BERSERK_CRIT_CHANCE_WVW : BERSERK_CRIT_CHANCE;
562
+ for (const mod of modifiers) {
563
+ if (mod.type === "critChance" && mod.condition === "berserk") {
564
+ critBonus += mod.value;
488
565
  }
489
566
  }
490
567
  }
491
568
 
492
- const critChance = Math.min(100, (total.Precision - 895) / 21 + furyCritBonus);
569
+ const critChance = Math.min(100, (total.Precision - 895) / 21 + critBonus);
493
570
  const critDamage = 150 + total.Ferocity / 15;
494
571
  const conditionDuration = total.Expertise / 15;
495
572
  const boonDuration = total.Concentration / 15;
@@ -513,7 +590,9 @@ function computeAttributes(ctx, catalogs) {
513
590
  enrichment: enrichmentStats,
514
591
  utility: utilityStats,
515
592
  signets: signetStats,
593
+ signetActiveBoons,
516
594
  traits: traitStats,
595
+ traitDetails,
517
596
  conversions: conversionStats,
518
597
  boons: boonStats,
519
598
  sigils: sigilStats,
@@ -60,9 +60,27 @@ function analyzeBoons(skills, traits, overrides, activeTraitIds) {
60
60
  activeTraitIds.has(2220);
61
61
 
62
62
  function processEntity(entity, type) {
63
- const facts = entity.facts || [];
63
+ const baseFacts = entity.facts || [];
64
64
  const description = entity.description || "";
65
65
 
66
+ // Merge traited_facts when the required trait is active (mirrors detail-panel logic).
67
+ // Entries with an `overrides` index replace the base fact at that position;
68
+ // entries without `overrides` are appended (e.g., Specter Wells gaining Resistance).
69
+ let facts = baseFacts;
70
+ const traitedFacts = entity.traitedFacts || [];
71
+ if (traitedFacts.length && activeTraitIds && activeTraitIds.size) {
72
+ facts = [...baseFacts];
73
+ for (const tf of traitedFacts) {
74
+ if (!activeTraitIds.has(Number(tf.requires_trait))) continue;
75
+ const { requires_trait: _r, overrides, ...factData } = tf;
76
+ if (overrides !== undefined && overrides !== null && overrides >= 0 && overrides < facts.length) {
77
+ facts[overrides] = factData;
78
+ } else if (overrides === undefined || overrides === null) {
79
+ facts.push(factData);
80
+ }
81
+ }
82
+ }
83
+
66
84
  // Collect all boon names from this entity for ally classification
67
85
  const entityBoonNames = [];
68
86
  for (const fact of facts) {
@@ -174,8 +174,10 @@ const BUFF_FACT_TYPES = new Set(["Buff", "ApplyBuffCondition", "PrefixedBuff"]);
174
174
  // Assumed boon stat effects (per GW2 wiki, level 80)
175
175
  const MIGHT_POWER_PER_STACK = 30;
176
176
  const MIGHT_CONDI_PER_STACK = 30;
177
- const FURY_CRIT_CHANCE = 25; // percentage points (PvE)
178
- const FURY_CRIT_CHANCE_WVW = 20; // percentage points (WvW)
177
+ const FURY_CRIT_CHANCE = 25; // percentage points (PvE)
178
+ const FURY_CRIT_CHANCE_WVW = 20; // percentage points (WvW)
179
+ const BERSERK_CRIT_CHANCE = 5; // base crit from Berserk mode in PvE (Berserker elite spec)
180
+ const BERSERK_CRIT_CHANCE_WVW = 0; // WvW: berserk has no inherent crit; bonus comes entirely from traits (Smash Brawler WvW: +5%)
179
181
 
180
182
  // ---------------------------------------------------------------------------
181
183
  // Stacking sigils
@@ -222,6 +224,26 @@ const SIGNET_PASSIVE_BUFFS = new Map([
222
224
  [10622, { stat: "Power", value: 180 }], // Signet of Spite
223
225
  ]);
224
226
 
227
+ // ---------------------------------------------------------------------------
228
+ // Signet active effects
229
+ // ---------------------------------------------------------------------------
230
+ // What each signet grants when its active skill is used (not the passive).
231
+ // Key = skill ID. Value:
232
+ // stats: { StatKey: amount } — direct flat stat boost for the active duration
233
+ // boons: { might: N } — N stacks of Might (each gives +30 Power, +30 CondiDmg)
234
+ // boons: { fury: true } — Fury boon (25% crit chance PvE / 20% WvW)
235
+ // Signets whose active has no stat-relevant effect (launch, immob, revive, etc.) are omitted.
236
+ // Source: https://wiki.guildwars2.com/wiki/Signet (PvE values)
237
+ const SIGNET_ACTIVE_EFFECTS = new Map([
238
+ // Warrior
239
+ [14404, { boons: { might: 10 } }], // Signet of Might → 10× Might
240
+ [14410, { stats: { Precision: 360, Ferocity: 360 } }], // Signet of Fury → +360 Prec, +360 Fero
241
+ // Ranger
242
+ [12491, { boons: { might: 10 } }], // Signet of the Wild → 10× Might
243
+ // Elementalist
244
+ [5542, { boons: { fury: true } }], // Signet of Fire → Fury
245
+ ]);
246
+
225
247
  // ---------------------------------------------------------------------------
226
248
  // Boon / condition name sets
227
249
  // ---------------------------------------------------------------------------
@@ -287,8 +309,11 @@ module.exports = {
287
309
  MIGHT_CONDI_PER_STACK,
288
310
  FURY_CRIT_CHANCE,
289
311
  FURY_CRIT_CHANCE_WVW,
312
+ BERSERK_CRIT_CHANCE,
313
+ BERSERK_CRIT_CHANCE_WVW,
290
314
  STACKING_SIGIL_DEFS,
291
315
  SIGNET_PASSIVE_BUFFS,
316
+ SIGNET_ACTIVE_EFFECTS,
292
317
  BOON_NAMES,
293
318
  CONDITION_NAMES,
294
319
  CONDITION_NAME_NORMALIZE,
@@ -13,8 +13,8 @@ const {
13
13
  PROFESSION_WEIGHT, ARMOR_DEFENSE_BY_WEIGHT, PROFESSION_BASE_HP,
14
14
  WEAPON_STRENGTH_MIDPOINT, BUFF_FACT_TYPES,
15
15
  MIGHT_POWER_PER_STACK, MIGHT_CONDI_PER_STACK,
16
- FURY_CRIT_CHANCE, FURY_CRIT_CHANCE_WVW,
17
- STACKING_SIGIL_DEFS, SIGNET_PASSIVE_BUFFS,
16
+ FURY_CRIT_CHANCE, FURY_CRIT_CHANCE_WVW, BERSERK_CRIT_CHANCE, BERSERK_CRIT_CHANCE_WVW,
17
+ STACKING_SIGIL_DEFS, SIGNET_PASSIVE_BUFFS, SIGNET_ACTIVE_EFFECTS,
18
18
  BOON_NAMES, CONDITION_NAMES, CONDITION_NAME_NORMALIZE,
19
19
  BOON_DISPLAY_ORDER, ALL_STAT_KEYS, CONVERSION_TARGET_MAP,
20
20
  } = require("./constants");
@@ -75,8 +75,8 @@ module.exports = {
75
75
  PROFESSION_WEIGHT, ARMOR_DEFENSE_BY_WEIGHT, PROFESSION_BASE_HP,
76
76
  WEAPON_STRENGTH_MIDPOINT, BUFF_FACT_TYPES,
77
77
  MIGHT_POWER_PER_STACK, MIGHT_CONDI_PER_STACK,
78
- FURY_CRIT_CHANCE, FURY_CRIT_CHANCE_WVW,
79
- STACKING_SIGIL_DEFS, SIGNET_PASSIVE_BUFFS,
78
+ FURY_CRIT_CHANCE, FURY_CRIT_CHANCE_WVW, BERSERK_CRIT_CHANCE, BERSERK_CRIT_CHANCE_WVW,
79
+ STACKING_SIGIL_DEFS, SIGNET_PASSIVE_BUFFS, SIGNET_ACTIVE_EFFECTS,
80
80
  BOON_NAMES, CONDITION_NAMES, CONDITION_NAME_NORMALIZE,
81
81
  BOON_DISPLAY_ORDER, ALL_STAT_KEYS, CONVERSION_TARGET_MAP,
82
82
  };
@@ -35,7 +35,10 @@ function collectActiveTraitIds(ctx, catalogs) {
35
35
  ? catalogs.specializationById.get(specId)
36
36
  : null;
37
37
  for (const minorId of specData?.minorTraits || []) {
38
- if (minorId) ids.add(Number(minorId));
38
+ // minorTraits may be IDs (number) or enriched objects ({ id, name, ... })
39
+ // depending on whether the source is the GW2 API catalog or a serialized build.
40
+ const n = typeof minorId === "object" ? Number(minorId?.id) : Number(minorId);
41
+ if (n) ids.add(n);
39
42
  }
40
43
  }
41
44
 
@@ -57,6 +60,26 @@ function isFuryTrait(trait, traitId, overrides) {
57
60
  return trait.facts?.some((f) => f.type === "Buff" && f.status === "Fury") || false;
58
61
  }
59
62
 
63
+ /**
64
+ * Return the set of weapon type strings for the active weapon set.
65
+ */
66
+ function _activeWeaponTypes(ctx) {
67
+ const weapons = ctx?.equipment?.weapons || {};
68
+ const underwater = Boolean(ctx?.underwaterMode);
69
+ const set = Number(ctx?.activeWeaponSet) || 1;
70
+ const types = new Set();
71
+ if (underwater) {
72
+ const key = `aquatic${set}`;
73
+ if (weapons[key]) types.add(weapons[key]);
74
+ } else {
75
+ for (const prefix of ["mainhand", "offhand"]) {
76
+ const key = `${prefix}${set}`;
77
+ if (weapons[key]) types.add(weapons[key]);
78
+ }
79
+ }
80
+ return types;
81
+ }
82
+
60
83
  /**
61
84
  * Collect typed modifier objects from active traits.
62
85
  *
@@ -77,6 +100,7 @@ function collectModifiers(ctx, catalogs, overrides) {
77
100
  if (!catalogs?.traitById) return modifiers;
78
101
 
79
102
  const gameMode = ctx?.gameMode || "pve";
103
+ const activeWeaponTypes = _activeWeaponTypes(ctx);
80
104
  const activeTraitIds = collectActiveTraitIds(ctx, catalogs);
81
105
  if (!activeTraitIds.size) return modifiers;
82
106
 
@@ -123,22 +147,30 @@ function collectModifiers(ctx, catalogs, overrides) {
123
147
  : fury ? "fury" : null;
124
148
 
125
149
  // 4. AttributeAdjust facts — flatBonus
126
- const byTarget = new Map();
127
- for (const fact of modeFacts) {
128
- if (fact.type !== "AttributeAdjust" || !fact.target || fact.value == null) continue;
129
- if (!byTarget.has(fact.target)) byTarget.set(fact.target, []);
130
- byTarget.get(fact.target).push(fact.value);
131
- }
132
- for (const [target, values] of byTarget) {
133
- const statKey = CONVERSION_TARGET_MAP[target] || target;
134
- const idx = gameMode === "wvw" ? Math.min(1, values.length - 1) : 0;
135
- modifiers.push({
136
- source,
137
- type: "flatBonus",
138
- target: statKey,
139
- value: values[idx],
140
- condition,
141
- });
150
+ // Skip for traits with mightOverride (the bonus is folded into per-stack values)
151
+ if (!override?.mightOverride) {
152
+ const byTarget = new Map();
153
+ for (const fact of modeFacts) {
154
+ if (fact.type !== "AttributeAdjust" || !fact.target || fact.value == null) continue;
155
+ if (!byTarget.has(fact.target)) byTarget.set(fact.target, []);
156
+ byTarget.get(fact.target).push(fact.value);
157
+ }
158
+ // weaponConditional: multiply flat bonuses when an active weapon matches
159
+ const wc = override?.weaponConditional;
160
+ const weaponMatch = wc && wc.weapons?.some((w) => activeWeaponTypes.has(w));
161
+
162
+ for (const [target, values] of byTarget) {
163
+ const statKey = CONVERSION_TARGET_MAP[target] || target;
164
+ const idx = gameMode === "wvw" ? Math.min(1, values.length - 1) : 0;
165
+ const base = values[idx];
166
+ modifiers.push({
167
+ source,
168
+ type: "flatBonus",
169
+ target: statKey,
170
+ value: weaponMatch ? base * wc.multiplier : base,
171
+ condition,
172
+ });
173
+ }
142
174
  }
143
175
 
144
176
  // 5. BuffConversion / AttributeConversion facts — conversion
@@ -166,18 +198,22 @@ function collectModifiers(ctx, catalogs, overrides) {
166
198
  });
167
199
  }
168
200
 
169
- // 6. Fury traits with "Critical Chance Increase" Percent facts — critChance
170
- if (fury) {
201
+ // 6. "Critical Chance Increase" Percent facts — critChance
202
+ // Fury traits: condition = "fury" (applied only when fury is assumed)
203
+ // Berserk traits: condition = "berserk" (applied only when berserk is active)
204
+ // Non-fury/non-berserk traits: condition = null (passive, always active)
205
+ // Skip traits with ignoreCritChance (skill-specific crit like Opening Strike, stealth, bursts)
206
+ if (!override?.ignoreCritChance) {
171
207
  const critFacts = modeFacts.filter(
172
208
  (f) => f.type === "Percent" && f.text === "Critical Chance Increase" && f.percent
173
209
  );
174
- if (critFacts.length > 0) {
210
+ if (critFacts.length > 0 && (fury || condition === null || condition === "berserk")) {
175
211
  const idx = gameMode === "wvw" ? Math.min(1, critFacts.length - 1) : 0;
176
212
  modifiers.push({
177
213
  source,
178
214
  type: "critChance",
179
215
  value: critFacts[idx].percent,
180
- condition: "fury",
216
+ condition,
181
217
  });
182
218
  }
183
219
  }
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ const {
9
9
  mapWikiFactToApiFact,
10
10
  parseInfoboxParams,
11
11
  } = require("./wiki/parser");
12
+ const { parseFactsByMode, groupFactsByMode } = require("./wiki/resolver");
12
13
  const { parseRelatedItems, parseRelatedGroups } = require("./wiki/relations");
13
14
  const { mergeFacts } = require("./facts/merge");
14
15
  const { buildMatchTables, valueChanged, VALUE_KEYS } = require("./facts/match");
@@ -34,6 +35,10 @@ module.exports = {
34
35
  mapWikiFactToApiFact,
35
36
  parseInfoboxParams,
36
37
 
38
+ // Resolver (mode-split: PvE/WvW/PvP facts the GW2 API lacks)
39
+ parseFactsByMode,
40
+ groupFactsByMode,
41
+
37
42
  // Relations
38
43
  parseRelatedItems,
39
44
  parseRelatedGroups,
@@ -11,7 +11,11 @@ const WIKI_ATTR_MAP = {
11
11
  "Critical Damage": "CritDamage",
12
12
  };
13
13
  function normalizeAttr(name) {
14
- return WIKI_ATTR_MAP[name] || name;
14
+ if (!name) return name;
15
+ // Wiki templates inconsistently use lowercase ("power") or capitalized ("Power")
16
+ // attribute names. Title-case before lookup so both forms produce the API stat key.
17
+ const titleCased = name.replace(/\b\w/g, (c) => c.toUpperCase());
18
+ return WIKI_ATTR_MAP[titleCased] || titleCased;
15
19
  }
16
20
 
17
21
  // Fact types to silently ignore
@@ -61,14 +65,14 @@ const BOONS = new Set([
61
65
 
62
66
  const CONDITIONS = new Set([
63
67
  "bleeding",
64
- "blind",
68
+ "blind", "blinded",
65
69
  "burning",
66
- "chilled",
70
+ "chill", "chilled",
67
71
  "confusion",
68
- "crippled",
72
+ "cripple", "crippled",
69
73
  "fear",
70
- "immobilize",
71
- "poison",
74
+ "immobilize", "immobilized",
75
+ "poison", "poisoned",
72
76
  "slow",
73
77
  "taunt",
74
78
  "torment",
@@ -199,9 +203,13 @@ function mapWikiFactToApiFact(factType, positional, params, isWvw, isUniversal)
199
203
  const rawCoefficient = params.coefficient || positional[0] || "0";
200
204
  const coefficient = parseFloat(stripWikiMarkup(rawCoefficient));
201
205
  const hits = parseInt(stripWikiMarkup(params.hits) || "1", 10);
206
+ // `alt=` carries the conditional damage label (e.g. "Damage to Controlled Foes").
207
+ // Without this, the conditional variant collapses with the base "Damage" fact
208
+ // during dedup, hiding bonus-damage values from the tooltip.
209
+ const label = params.alt ? stripWikiMarkup(params.alt).trim() : "Damage";
202
210
  return {
203
211
  type: "Damage",
204
- text: "Damage",
212
+ text: label,
205
213
  dmg_multiplier: coefficient,
206
214
  hit_count: hits,
207
215
  };
@@ -392,13 +400,13 @@ function mapWikiFactToApiFact(factType, positional, params, isWvw, isUniversal)
392
400
  // Many wiki fact types carry a percentage value (e.g. "damage reduction|33").
393
401
  // Without this, they fall through to generic Number and lose the % suffix.
394
402
  if (PERCENT_TYPES.has(type)) {
395
- const label = type.replace(/\b\w/g, (c) => c.toUpperCase());
403
+ const label = params.alt ? stripWikiMarkup(params.alt).trim() : type.replace(/\b\w/g, (c) => c.toUpperCase());
396
404
  return { type: "Percent", text: label, percent: pos0Num() };
397
405
  }
398
406
 
399
407
  // ── Unknown but has a numeric value ────────────────────────────────
400
408
  if (positional[0] && !isNaN(parseFloat(stripWikiMarkup(positional[0])))) {
401
- const label = type.replace(/\b\w/g, (c) => c.toUpperCase());
409
+ const label = params.alt ? stripWikiMarkup(params.alt).trim() : type.replace(/\b\w/g, (c) => c.toUpperCase());
402
410
  // Heuristic: fact type names containing "increase", "reduction", "chance",
403
411
  // or "rate" are almost always percentages in GW2 wiki templates.
404
412
  if (/(?:increase|reduction|chance|rate|cost|drain)$/.test(type)) {