@axiapps/gw2-data 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -0
- package/data/overrides.json +24 -0
- package/package.json +3 -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/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/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# @axiapps/gw2-data
|
|
2
|
+
|
|
3
|
+
Data library for Guild Wars 2 — wiki scraping, GW2 API access, and stat computation engine. Used by [AxiForge](https://axiforge.com).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @axiapps/gw2-data
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Modules
|
|
12
|
+
|
|
13
|
+
The package exposes four subpath imports:
|
|
14
|
+
|
|
15
|
+
| Import | Description |
|
|
16
|
+
|--------|-------------|
|
|
17
|
+
| `@axiapps/gw2-data/wiki` | Wiki scraping and wikitext parsing |
|
|
18
|
+
| `@axiapps/gw2-data/api` | GW2 API client with batching and retries |
|
|
19
|
+
| `@axiapps/gw2-data/facts` | Fact merging for PvE/WvW balance splits |
|
|
20
|
+
| `@axiapps/gw2-data/engine` | Stat computation, tooltips, boon analysis |
|
|
21
|
+
|
|
22
|
+
## Wiki Client
|
|
23
|
+
|
|
24
|
+
Fetch and parse skill/trait data from the GW2 Wiki with built-in caching and rate limiting.
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
const { WikiClient } = require("@axiapps/gw2-data/wiki");
|
|
28
|
+
|
|
29
|
+
const wiki = new WikiClient();
|
|
30
|
+
|
|
31
|
+
// Fetch wikitext for a single page
|
|
32
|
+
const wikitext = await wiki.getWikitext("Fireball");
|
|
33
|
+
|
|
34
|
+
// Batch-fetch multiple pages (up to 50 per request)
|
|
35
|
+
const pages = await wiki.getWikitextBatch(["Fireball", "Meteor Shower", "Lava Font"]);
|
|
36
|
+
|
|
37
|
+
// Parse skill/trait facts from wikitext
|
|
38
|
+
const { facts, hasPveOnly, splitGrouping } = wiki.parseFacts(wikitext);
|
|
39
|
+
|
|
40
|
+
// Search for pages by prefix
|
|
41
|
+
const matches = await wiki.prefixSearch("Fire", 10);
|
|
42
|
+
|
|
43
|
+
// Invalidate cache for recently changed pages
|
|
44
|
+
const changed = await wiki.refresh();
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Constructor Options
|
|
48
|
+
|
|
49
|
+
| Option | Default | Description |
|
|
50
|
+
|--------|---------|-------------|
|
|
51
|
+
| `cache` | `MemoryCache` | Cache adapter (MemoryCache or DiskCache) |
|
|
52
|
+
| `fetch` | `globalThis.fetch` | Fetch implementation |
|
|
53
|
+
| `wikiApiRoot` | `https://wiki.guildwars2.com/api.php` | Wiki API endpoint |
|
|
54
|
+
| `cacheTTL` | 4 hours | Cache TTL in milliseconds |
|
|
55
|
+
|
|
56
|
+
## GW2 API Client
|
|
57
|
+
|
|
58
|
+
Fetch data from the official GW2 API with automatic chunking, retries, and 429 handling.
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
const { Gw2ApiClient } = require("@axiapps/gw2-data/api");
|
|
62
|
+
|
|
63
|
+
const api = new Gw2ApiClient();
|
|
64
|
+
|
|
65
|
+
// Fetch skills by ID (automatically chunked into 180-ID batches)
|
|
66
|
+
const skills = await api.fetchByIds("/v2/skills", [5489, 5536, 5501]);
|
|
67
|
+
|
|
68
|
+
// Fetch with caching
|
|
69
|
+
const data = await api.fetchCached("my-key", "https://api.guildwars2.com/v2/skills/5489", 3600000);
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Constructor Options
|
|
73
|
+
|
|
74
|
+
| Option | Default | Description |
|
|
75
|
+
|--------|---------|-------------|
|
|
76
|
+
| `cache` | `MemoryCache` | Cache adapter |
|
|
77
|
+
| `fetch` | `globalThis.fetch` | Fetch implementation |
|
|
78
|
+
| `apiRoot` | `https://api.guildwars2.com` | API root URL |
|
|
79
|
+
| `lang` | `"en"` | Language code |
|
|
80
|
+
|
|
81
|
+
## Fact Merging
|
|
82
|
+
|
|
83
|
+
Merge base (PvE) facts with WvW/PvP split facts, marking which values changed.
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
const { mergeFacts } = require("@axiapps/gw2-data/facts");
|
|
87
|
+
|
|
88
|
+
const merged = mergeFacts(baseFacts, splitFacts);
|
|
89
|
+
// Changed facts have _splitFact: true
|
|
90
|
+
// New facts (WvW-only) have _newFact: true
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Engine
|
|
94
|
+
|
|
95
|
+
Compute final build stats, skill tooltips, boon uptime, and combo interactions.
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
const {
|
|
99
|
+
computeAttributes,
|
|
100
|
+
collectModifiers,
|
|
101
|
+
computeTooltip,
|
|
102
|
+
analyzeBoons,
|
|
103
|
+
analyzeCombos,
|
|
104
|
+
STAT_COMBOS_BY_LABEL,
|
|
105
|
+
} = require("@axiapps/gw2-data/engine");
|
|
106
|
+
|
|
107
|
+
// Compute final attributes for a build
|
|
108
|
+
const attrs = computeAttributes({
|
|
109
|
+
profession: "Elementalist",
|
|
110
|
+
specializations: [...],
|
|
111
|
+
equipment: { statPackage: "Berserker" },
|
|
112
|
+
}, catalogs);
|
|
113
|
+
|
|
114
|
+
// Collect all stat modifiers from traits, sigils, etc.
|
|
115
|
+
const modifiers = collectModifiers(ctx, catalogs, overrides);
|
|
116
|
+
|
|
117
|
+
// Compute a skill tooltip with modifiers applied
|
|
118
|
+
const tooltip = computeTooltip(attrs, skill, "Staff", modifiers);
|
|
119
|
+
|
|
120
|
+
// Analyze boon output across a skill set
|
|
121
|
+
const boons = analyzeBoons(skills, traits, overrides);
|
|
122
|
+
|
|
123
|
+
// Detect combo field/finisher interactions
|
|
124
|
+
const combos = analyzeCombos(skills, traits);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### StatEngine Class
|
|
128
|
+
|
|
129
|
+
For repeated computations, use the class to avoid re-loading overrides:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
const { StatEngine } = require("@axiapps/gw2-data/engine");
|
|
133
|
+
|
|
134
|
+
const engine = new StatEngine(catalogs);
|
|
135
|
+
const attrs = engine.computeAttributes(ctx);
|
|
136
|
+
const tooltip = engine.computeTooltip(ctx, skill, "Sword");
|
|
137
|
+
const boons = engine.analyzeBoons(skills, traits);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Caching
|
|
141
|
+
|
|
142
|
+
Both clients accept a cache adapter. Two are included:
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
const { MemoryCache, DiskCache } = require("@axiapps/gw2-data/wiki");
|
|
146
|
+
|
|
147
|
+
// In-memory (default)
|
|
148
|
+
const wiki = new WikiClient({ cache: new MemoryCache() });
|
|
149
|
+
|
|
150
|
+
// Disk-backed (persists across restarts)
|
|
151
|
+
const wiki = new WikiClient({ cache: new DiskCache("/tmp/gw2-wiki-cache") });
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiapps/gw2-data",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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": {
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"clearMocks": true,
|
|
20
20
|
"testTimeout": 15000
|
|
21
21
|
},
|
|
22
|
+
"files": ["src", "data", "README.md"],
|
|
23
|
+
"publishConfig": { "access": "public" },
|
|
22
24
|
"license": "MIT",
|
|
23
25
|
"keywords": ["gw2", "guild-wars-2", "wiki", "api", "build-editor"],
|
|
24
26
|
"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
|
}
|