@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.
- package/data/overrides.json +24 -0
- package/package.json +4 -1
- package/src/engine/attributes.js +102 -23
- package/src/engine/boons.js +19 -1
- package/src/engine/constants.js +27 -2
- package/src/engine/index.js +4 -4
- package/src/engine/modifiers.js +57 -21
- package/src/index.js +5 -0
- package/src/wiki/parser.js +17 -9
- package/scripts/generate-fixtures.js +0 -242
- package/tests/api-client.test.js +0 -138
- package/tests/cache.test.js +0 -108
- package/tests/engine/attributes.test.js +0 -252
- package/tests/engine/boons.test.js +0 -129
- package/tests/engine/combos.test.js +0 -76
- package/tests/engine/constants.test.js +0 -576
- package/tests/engine/fixtures/berserker-thief.json +0 -61
- package/tests/engine/fixtures/berserker-warrior.json +0 -113
- package/tests/engine/fixtures/celestial-firebrand-wvw.json +0 -94
- package/tests/engine/fixtures/harrier-druid.json +0 -119
- package/tests/engine/fixtures/viper-mirage.json +0 -104
- package/tests/engine/graph.test.js +0 -30
- package/tests/engine/integration.test.js +0 -111
- package/tests/engine/modifiers.test.js +0 -473
- package/tests/engine/overrides.test.js +0 -70
- package/tests/engine/snapshot.test.js +0 -53
- package/tests/engine/test-utils.js +0 -20
- package/tests/engine/tooltips.test.js +0 -62
- package/tests/fixtures/capture.js +0 -160
- package/tests/fixtures/fixtures.json +0 -839
- package/tests/integration.test.js +0 -100
- package/tests/match.test.js +0 -176
- package/tests/merge.test.js +0 -128
- package/tests/normalize.test.js +0 -78
- package/tests/parser.test.js +0 -506
- package/tests/real-data.test.js +0 -296
- package/tests/relations.test.js +0 -80
- package/tests/resolver.test.js +0 -721
- package/tests/validate-live.js +0 -191
- package/tests/wiki-client.test.js +0 -468
- package/tests/wiki-integration.test.js +0 -177
- package/tests/wiki-live-validation.test.js +0 -61
- package/tests/wiki-snapshots.test.js +0 -166
package/data/overrides.json
CHANGED
|
@@ -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.
|
|
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": {
|
package/src/engine/attributes.js
CHANGED
|
@@ -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
|
|
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
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
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,
|
package/src/engine/boons.js
CHANGED
|
@@ -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
|
|
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) {
|
package/src/engine/constants.js
CHANGED
|
@@ -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;
|
|
178
|
-
const FURY_CRIT_CHANCE_WVW = 20;
|
|
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,
|
package/src/engine/index.js
CHANGED
|
@@ -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
|
};
|
package/src/engine/modifiers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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.
|
|
170
|
-
|
|
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
|
|
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,
|
package/src/wiki/parser.js
CHANGED
|
@@ -11,7 +11,11 @@ const WIKI_ATTR_MAP = {
|
|
|
11
11
|
"Critical Damage": "CritDamage",
|
|
12
12
|
};
|
|
13
13
|
function normalizeAttr(name) {
|
|
14
|
-
|
|
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:
|
|
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)) {
|