@axiapps/gw2-data 0.1.0
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 +25 -0
- package/package.json +32 -0
- package/scripts/generate-fixtures.js +242 -0
- package/src/api/client.js +117 -0
- package/src/api/types.js +80 -0
- package/src/engine/attributes.js +525 -0
- package/src/engine/boons.js +156 -0
- package/src/engine/combos.js +103 -0
- package/src/engine/constants.js +298 -0
- package/src/engine/graph.js +24 -0
- package/src/engine/index.js +82 -0
- package/src/engine/modifiers.js +204 -0
- package/src/engine/overrides.js +13 -0
- package/src/engine/tooltips.js +59 -0
- package/src/facts/match.js +134 -0
- package/src/facts/merge.js +45 -0
- package/src/facts/normalize.js +27 -0
- package/src/index.js +60 -0
- package/src/wiki/cache.js +103 -0
- package/src/wiki/client.js +230 -0
- package/src/wiki/parser.js +599 -0
- package/src/wiki/relations.js +55 -0
- package/src/wiki/resolver.js +352 -0
- package/tests/api-client.test.js +138 -0
- package/tests/cache.test.js +108 -0
- package/tests/engine/attributes.test.js +252 -0
- package/tests/engine/boons.test.js +129 -0
- package/tests/engine/combos.test.js +76 -0
- package/tests/engine/constants.test.js +576 -0
- package/tests/engine/fixtures/berserker-thief.json +61 -0
- package/tests/engine/fixtures/berserker-warrior.json +113 -0
- package/tests/engine/fixtures/celestial-firebrand-wvw.json +94 -0
- package/tests/engine/fixtures/harrier-druid.json +119 -0
- package/tests/engine/fixtures/viper-mirage.json +104 -0
- package/tests/engine/graph.test.js +30 -0
- package/tests/engine/integration.test.js +111 -0
- package/tests/engine/modifiers.test.js +473 -0
- package/tests/engine/overrides.test.js +70 -0
- package/tests/engine/snapshot.test.js +53 -0
- package/tests/engine/test-utils.js +20 -0
- package/tests/engine/tooltips.test.js +62 -0
- package/tests/fixtures/capture.js +160 -0
- package/tests/fixtures/fixtures.json +839 -0
- package/tests/integration.test.js +100 -0
- package/tests/match.test.js +176 -0
- package/tests/merge.test.js +128 -0
- package/tests/normalize.test.js +78 -0
- package/tests/parser.test.js +506 -0
- package/tests/real-data.test.js +296 -0
- package/tests/relations.test.js +80 -0
- package/tests/resolver.test.js +721 -0
- package/tests/validate-live.js +191 -0
- package/tests/wiki-client.test.js +468 -0
- package/tests/wiki-integration.test.js +177 -0
- package/tests/wiki-live-validation.test.js +61 -0
- package/tests/wiki-snapshots.test.js +166 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
STAT_COMBOS_BY_LABEL,
|
|
5
|
+
getEffectiveStats,
|
|
6
|
+
SLOT_WEIGHTS,
|
|
7
|
+
TWO_HAND_WEIGHTS,
|
|
8
|
+
LAND_ONLY_SLOTS,
|
|
9
|
+
AQUATIC_SLOTS,
|
|
10
|
+
PROFESSION_BASE_HP,
|
|
11
|
+
PROFESSION_WEIGHT,
|
|
12
|
+
ARMOR_DEFENSE_BY_WEIGHT,
|
|
13
|
+
MIGHT_POWER_PER_STACK,
|
|
14
|
+
MIGHT_CONDI_PER_STACK,
|
|
15
|
+
FURY_CRIT_CHANCE,
|
|
16
|
+
FURY_CRIT_CHANCE_WVW,
|
|
17
|
+
STACKING_SIGIL_DEFS,
|
|
18
|
+
SIGNET_PASSIVE_BUFFS,
|
|
19
|
+
ALL_STAT_KEYS,
|
|
20
|
+
CONVERSION_TARGET_MAP,
|
|
21
|
+
} = require("./constants");
|
|
22
|
+
|
|
23
|
+
const { collectModifiers, isFuryTrait } = require("./modifiers");
|
|
24
|
+
const { loadOverrides } = require("./overrides");
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Two-handed weapon types (no GW2_WEAPONS_BY_ID map — check by type string)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
const TWO_HAND_WEAPON_TYPES = new Set([
|
|
30
|
+
"greatsword", "hammer", "longbow", "shortbow", "rifle", "staff",
|
|
31
|
+
"spear", "trident", "harpoon-gun",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Stat name normalization maps for text parsing
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
const FOOD_STAT_MAP = {
|
|
38
|
+
"Condition Damage": "ConditionDamage",
|
|
39
|
+
"Healing Power": "HealingPower",
|
|
40
|
+
"Healing": "HealingPower",
|
|
41
|
+
"Power": "Power",
|
|
42
|
+
"Precision": "Precision",
|
|
43
|
+
"Toughness": "Toughness",
|
|
44
|
+
"Vitality": "Vitality",
|
|
45
|
+
"Ferocity": "Ferocity",
|
|
46
|
+
"Concentration": "Concentration",
|
|
47
|
+
"Expertise": "Expertise",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Regex patterns for text parsing
|
|
51
|
+
const FOOD_REGEX = /\+(\d+)\s+(Condition Damage|Healing Power|Healing|Power|Precision|Toughness|Vitality|Ferocity|Concentration|Expertise|to All Attributes)/g;
|
|
52
|
+
const RUNE_REGEX = /\+(\d+)\s+(Condition Damage|Healing Power|Healing|Power|Precision|Toughness|Vitality|Ferocity|Concentration|Expertise|to All Stats)/;
|
|
53
|
+
const UTILITY_CONVERSION_REGEX = /Gain (Condition Damage|Healing Power|Power|Precision|Toughness|Vitality|Ferocity|Concentration|Expertise) Equal to (\d+(?:\.\d+)?)% of Your (Condition Damage|Healing Power|Power|Precision|Toughness|Vitality|Ferocity|Concentration|Expertise)/g;
|
|
54
|
+
const UTILITY_WRIT_REGEX = /Gain (\d+) (Condition Damage|Healing Power|Power|Precision|Toughness|Vitality|Ferocity|Concentration|Expertise) When Health/g;
|
|
55
|
+
const UTILITY_FLAT_REGEX = /\+(\d+)\s+(Condition Damage|Healing Power|Power|Precision|Toughness|Vitality|Ferocity|Concentration|Expertise)/g;
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Helper: create zeroed stat object
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
function zeroStats() {
|
|
61
|
+
const obj = {};
|
|
62
|
+
for (const key of ALL_STAT_KEYS) obj[key] = 0;
|
|
63
|
+
return obj;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Helper: add value to stat object in-place
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
function addStat(obj, stat, value) {
|
|
70
|
+
const key = CONVERSION_TARGET_MAP[stat] || stat;
|
|
71
|
+
if (key in obj) {
|
|
72
|
+
obj[key] += value;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// getExcludedSlots(underwaterMode, activeWeaponSet) — pure function
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
function getExcludedSlots(underwaterMode, activeWeaponSet) {
|
|
80
|
+
const isUnderwater = Boolean(underwaterMode);
|
|
81
|
+
const activeSet = Number(activeWeaponSet) || 1;
|
|
82
|
+
const excluded = new Set(isUnderwater ? LAND_ONLY_SLOTS : AQUATIC_SLOTS);
|
|
83
|
+
|
|
84
|
+
if (isUnderwater) {
|
|
85
|
+
excluded.add(activeSet === 2 ? "aquatic1" : "aquatic2");
|
|
86
|
+
} else {
|
|
87
|
+
if (activeSet === 2) {
|
|
88
|
+
excluded.add("mainhand1");
|
|
89
|
+
excluded.add("offhand1");
|
|
90
|
+
} else {
|
|
91
|
+
excluded.add("mainhand2");
|
|
92
|
+
excluded.add("offhand2");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return excluded;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// computeSlotStats(comboLabel, slotKey, weapons, gameMode) — pure function
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
function computeSlotStats(comboLabel, slotKey, weapons, gameMode) {
|
|
103
|
+
const combo = STAT_COMBOS_BY_LABEL.get(comboLabel);
|
|
104
|
+
if (!combo) return [];
|
|
105
|
+
|
|
106
|
+
// Determine slot weights
|
|
107
|
+
let weights = SLOT_WEIGHTS[slotKey];
|
|
108
|
+
if (!weights) return [];
|
|
109
|
+
|
|
110
|
+
// Check for two-handed weapon
|
|
111
|
+
const isTwoHand =
|
|
112
|
+
slotKey.startsWith("mainhand") &&
|
|
113
|
+
TWO_HAND_WEAPON_TYPES.has(weapons && weapons[slotKey]);
|
|
114
|
+
if (isTwoHand) {
|
|
115
|
+
weights = TWO_HAND_WEIGHTS;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const effectiveStats = getEffectiveStats(combo, gameMode);
|
|
119
|
+
const count = effectiveStats.length;
|
|
120
|
+
const result = [];
|
|
121
|
+
|
|
122
|
+
if (count <= 3) {
|
|
123
|
+
// p/s pattern: first stat is major (p), rest are minor (s)
|
|
124
|
+
effectiveStats.forEach((stat, i) => {
|
|
125
|
+
result.push({ stat, value: i === 0 ? weights.p : weights.s });
|
|
126
|
+
});
|
|
127
|
+
} else if (count === 4) {
|
|
128
|
+
// p4/p4/s4/s4 pattern: first 2 major, last 2 minor
|
|
129
|
+
effectiveStats.forEach((stat, i) => {
|
|
130
|
+
result.push({ stat, value: i < 2 ? weights.p4 : weights.s4 });
|
|
131
|
+
});
|
|
132
|
+
} else {
|
|
133
|
+
// >4 stats: celestial — all use c weight
|
|
134
|
+
effectiveStats.forEach((stat) => {
|
|
135
|
+
result.push({ stat, value: weights.c });
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Parse food buff text into stat contributions
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
function parseFoodBuff(buffText) {
|
|
146
|
+
const stats = zeroStats();
|
|
147
|
+
if (!buffText) return stats;
|
|
148
|
+
|
|
149
|
+
FOOD_REGEX.lastIndex = 0;
|
|
150
|
+
let match;
|
|
151
|
+
while ((match = FOOD_REGEX.exec(buffText)) !== null) {
|
|
152
|
+
const value = parseInt(match[1], 10);
|
|
153
|
+
const statName = match[2];
|
|
154
|
+
if (statName === "to All Attributes") {
|
|
155
|
+
for (const key of ALL_STAT_KEYS) stats[key] += value;
|
|
156
|
+
} else {
|
|
157
|
+
const key = FOOD_STAT_MAP[statName];
|
|
158
|
+
if (key) stats[key] += value;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return stats;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Parse rune bonus text (single bonus string)
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
function parseRuneBonus(bonusText) {
|
|
168
|
+
const match = RUNE_REGEX.exec(bonusText);
|
|
169
|
+
if (!match) return null;
|
|
170
|
+
const value = parseInt(match[1], 10);
|
|
171
|
+
const statName = match[2];
|
|
172
|
+
if (statName === "to All Stats") {
|
|
173
|
+
return { allStats: true, value };
|
|
174
|
+
}
|
|
175
|
+
const key = FOOD_STAT_MAP[statName];
|
|
176
|
+
if (!key) return null;
|
|
177
|
+
return { stat: key, value };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Parse utility buff text into stat contributions
|
|
182
|
+
// Uses preConvTotal for conversion-style bonuses
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
function parseUtilityBuff(buffText, preConvTotal) {
|
|
185
|
+
const stats = zeroStats();
|
|
186
|
+
if (!buffText) return stats;
|
|
187
|
+
|
|
188
|
+
// Pattern 1: conversion ("Gain [Stat] Equal to N% of Your [SourceStat]")
|
|
189
|
+
UTILITY_CONVERSION_REGEX.lastIndex = 0;
|
|
190
|
+
let match;
|
|
191
|
+
while ((match = UTILITY_CONVERSION_REGEX.exec(buffText)) !== null) {
|
|
192
|
+
const targetStat = FOOD_STAT_MAP[match[1]] || match[1];
|
|
193
|
+
const pct = parseFloat(match[2]);
|
|
194
|
+
const sourceStat = FOOD_STAT_MAP[match[3]] || match[3];
|
|
195
|
+
const sourceVal = preConvTotal[sourceStat] || 0;
|
|
196
|
+
const bonus = Math.round(sourceVal * pct / 100);
|
|
197
|
+
if (targetStat in stats) stats[targetStat] += bonus;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Pattern 2: writ ("Gain N [Stat] When Health")
|
|
201
|
+
UTILITY_WRIT_REGEX.lastIndex = 0;
|
|
202
|
+
while ((match = UTILITY_WRIT_REGEX.exec(buffText)) !== null) {
|
|
203
|
+
const value = parseInt(match[1], 10);
|
|
204
|
+
const key = FOOD_STAT_MAP[match[2]] || match[2];
|
|
205
|
+
if (key in stats) stats[key] += value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Pattern 3: flat ("+N [Stat]")
|
|
209
|
+
UTILITY_FLAT_REGEX.lastIndex = 0;
|
|
210
|
+
while ((match = UTILITY_FLAT_REGEX.exec(buffText)) !== null) {
|
|
211
|
+
const value = parseInt(match[1], 10);
|
|
212
|
+
const key = FOOD_STAT_MAP[match[2]] || match[2];
|
|
213
|
+
if (key in stats) stats[key] += value;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return stats;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// computeAttributes(ctx, catalogs) — main function
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
function computeAttributes(ctx, catalogs) {
|
|
223
|
+
const overrides = loadOverrides();
|
|
224
|
+
const gameMode = ctx.gameMode || "pve";
|
|
225
|
+
const underwaterMode = ctx.underwaterMode || false;
|
|
226
|
+
const activeWeaponSet = ctx.activeWeaponSet || 1;
|
|
227
|
+
const equipment = ctx.equipment || {};
|
|
228
|
+
const weapons = equipment.weapons || {};
|
|
229
|
+
|
|
230
|
+
// Get excluded slots
|
|
231
|
+
const excluded = getExcludedSlots(underwaterMode, activeWeaponSet);
|
|
232
|
+
|
|
233
|
+
// -------------------------------------------------------------------------
|
|
234
|
+
// Step 1: Base stats
|
|
235
|
+
// -------------------------------------------------------------------------
|
|
236
|
+
const base = {
|
|
237
|
+
Power: 1000,
|
|
238
|
+
Precision: 1000,
|
|
239
|
+
Toughness: 1000,
|
|
240
|
+
Vitality: 1000,
|
|
241
|
+
Ferocity: 0,
|
|
242
|
+
ConditionDamage: 0,
|
|
243
|
+
Expertise: 0,
|
|
244
|
+
Concentration: 0,
|
|
245
|
+
HealingPower: 0,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// -------------------------------------------------------------------------
|
|
249
|
+
// Step 2: Equipment slots
|
|
250
|
+
// -------------------------------------------------------------------------
|
|
251
|
+
const equipmentStats = zeroStats();
|
|
252
|
+
const slots = equipment.slots || {};
|
|
253
|
+
for (const [slotKey, comboLabel] of Object.entries(slots)) {
|
|
254
|
+
if (excluded.has(slotKey)) continue;
|
|
255
|
+
if (!comboLabel) continue;
|
|
256
|
+
const slotStats = computeSlotStats(comboLabel, slotKey, weapons, gameMode);
|
|
257
|
+
for (const { stat, value } of slotStats) {
|
|
258
|
+
if (stat in equipmentStats) equipmentStats[stat] += value;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// -------------------------------------------------------------------------
|
|
263
|
+
// Step 3: Food
|
|
264
|
+
// -------------------------------------------------------------------------
|
|
265
|
+
const foodStats = zeroStats();
|
|
266
|
+
if (equipment.food && catalogs.foodById) {
|
|
267
|
+
const food = catalogs.foodById.get(equipment.food);
|
|
268
|
+
if (food?.buff) {
|
|
269
|
+
const parsed = parseFoodBuff(food.buff);
|
|
270
|
+
for (const key of ALL_STAT_KEYS) foodStats[key] += parsed[key];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// -------------------------------------------------------------------------
|
|
275
|
+
// Step 4: Runes — count per rune ID across equipped slots, apply cumulative bonuses
|
|
276
|
+
// -------------------------------------------------------------------------
|
|
277
|
+
const runeStats = zeroStats();
|
|
278
|
+
if (equipment.runes && catalogs.runeById) {
|
|
279
|
+
// Count rune IDs (excluding excluded slots)
|
|
280
|
+
const runeCounts = new Map();
|
|
281
|
+
for (const [slotKey, runeId] of Object.entries(equipment.runes)) {
|
|
282
|
+
if (excluded.has(slotKey)) continue;
|
|
283
|
+
if (!runeId) continue;
|
|
284
|
+
const id = Number(runeId);
|
|
285
|
+
runeCounts.set(id, (runeCounts.get(id) || 0) + 1);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Apply cumulative bonuses up to count (max 6)
|
|
289
|
+
for (const [runeId, count] of runeCounts) {
|
|
290
|
+
const rune = catalogs.runeById.get(runeId);
|
|
291
|
+
if (!rune?.bonuses) continue;
|
|
292
|
+
const bonuses = rune.bonuses;
|
|
293
|
+
const applyCount = Math.min(count, 6, bonuses.length);
|
|
294
|
+
for (let i = 0; i < applyCount; i++) {
|
|
295
|
+
const parsed = parseRuneBonus(bonuses[i]);
|
|
296
|
+
if (!parsed) continue;
|
|
297
|
+
if (parsed.allStats) {
|
|
298
|
+
for (const key of ALL_STAT_KEYS) runeStats[key] += parsed.value;
|
|
299
|
+
} else {
|
|
300
|
+
runeStats[parsed.stat] += parsed.value;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// -------------------------------------------------------------------------
|
|
307
|
+
// Step 5: Infusions — per slot, flatMap arrays, lookup infixUpgrade.attributes
|
|
308
|
+
// -------------------------------------------------------------------------
|
|
309
|
+
const infusionStats = zeroStats();
|
|
310
|
+
if (equipment.infusions && catalogs.infusionById) {
|
|
311
|
+
for (const [slotKey, infusionIds] of Object.entries(equipment.infusions)) {
|
|
312
|
+
if (excluded.has(slotKey)) continue;
|
|
313
|
+
const ids = Array.isArray(infusionIds) ? infusionIds : [infusionIds];
|
|
314
|
+
for (const infId of ids) {
|
|
315
|
+
if (!infId) continue;
|
|
316
|
+
const inf = catalogs.infusionById.get(Number(infId));
|
|
317
|
+
if (!inf?.infixUpgrade?.attributes) continue;
|
|
318
|
+
for (const attr of inf.infixUpgrade.attributes) {
|
|
319
|
+
const key = CONVERSION_TARGET_MAP[attr.attribute] || attr.attribute;
|
|
320
|
+
if (key in infusionStats) infusionStats[key] += attr.modifier || 0;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// -------------------------------------------------------------------------
|
|
327
|
+
// Step 6: Enrichment
|
|
328
|
+
// -------------------------------------------------------------------------
|
|
329
|
+
const enrichmentStats = zeroStats();
|
|
330
|
+
if (equipment.enrichment && catalogs.enrichmentById) {
|
|
331
|
+
const enr = catalogs.enrichmentById.get(Number(equipment.enrichment));
|
|
332
|
+
if (enr?.infixUpgrade?.attributes) {
|
|
333
|
+
for (const attr of enr.infixUpgrade.attributes) {
|
|
334
|
+
const key = CONVERSION_TARGET_MAP[attr.attribute] || attr.attribute;
|
|
335
|
+
if (key in enrichmentStats) enrichmentStats[key] += attr.modifier || 0;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// -------------------------------------------------------------------------
|
|
341
|
+
// Step 7: Signets
|
|
342
|
+
// -------------------------------------------------------------------------
|
|
343
|
+
const signetStats = zeroStats();
|
|
344
|
+
if (ctx.skills) {
|
|
345
|
+
const skillIds = [
|
|
346
|
+
ctx.skills.healId,
|
|
347
|
+
...(ctx.skills.utilityIds || []),
|
|
348
|
+
ctx.skills.eliteId,
|
|
349
|
+
].filter(Boolean).map(Number);
|
|
350
|
+
|
|
351
|
+
for (const skillId of skillIds) {
|
|
352
|
+
const buff = SIGNET_PASSIVE_BUFFS.get(skillId);
|
|
353
|
+
if (buff && buff.stat in signetStats) {
|
|
354
|
+
signetStats[buff.stat] += buff.value;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// -------------------------------------------------------------------------
|
|
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.
|
|
365
|
+
// -------------------------------------------------------------------------
|
|
366
|
+
const preConvBase = zeroStats();
|
|
367
|
+
for (const key of ALL_STAT_KEYS) {
|
|
368
|
+
preConvBase[key] = (base[key] || 0) + equipmentStats[key] + foodStats[key] +
|
|
369
|
+
runeStats[key] + infusionStats[key] + enrichmentStats[key] + signetStats[key];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Parse utility buff using preConvBase as the "preConvTotal" for conversion
|
|
373
|
+
const utilityStats = zeroStats();
|
|
374
|
+
if (equipment.utility && catalogs.utilityById) {
|
|
375
|
+
const utility = catalogs.utilityById.get(equipment.utility);
|
|
376
|
+
if (utility?.buff) {
|
|
377
|
+
const parsed = parseUtilityBuff(utility.buff, preConvBase);
|
|
378
|
+
for (const key of ALL_STAT_KEYS) utilityStats[key] += parsed[key];
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
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
|
+
// -------------------------------------------------------------------------
|
|
389
|
+
// Step 10: Traits — flat bonuses from active traits
|
|
390
|
+
// -------------------------------------------------------------------------
|
|
391
|
+
const traitStats = zeroStats();
|
|
392
|
+
const modifiers = collectModifiers(ctx, catalogs, overrides);
|
|
393
|
+
const furyAssumed = Boolean(ctx.assumedBoons?.fury);
|
|
394
|
+
const berserkActive = Boolean(ctx.berserkActive);
|
|
395
|
+
|
|
396
|
+
for (const mod of modifiers) {
|
|
397
|
+
if (mod.type !== "flatBonus") continue;
|
|
398
|
+
// Apply if: passive (condition === null), fury (when assumed), or berserk (when toggled)
|
|
399
|
+
if (mod.condition === null
|
|
400
|
+
|| (mod.condition === "fury" && furyAssumed)
|
|
401
|
+
|| (mod.condition === "berserk" && berserkActive)) {
|
|
402
|
+
if (mod.target in traitStats) {
|
|
403
|
+
traitStats[mod.target] += mod.value;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// -------------------------------------------------------------------------
|
|
409
|
+
// Step 11: Conversions — trait conversion modifiers
|
|
410
|
+
// -------------------------------------------------------------------------
|
|
411
|
+
const conversionStats = zeroStats();
|
|
412
|
+
for (const mod of modifiers) {
|
|
413
|
+
if (mod.type !== "conversion") continue;
|
|
414
|
+
const sourceVal = preConvTotal[mod.sourceAttr] || 0;
|
|
415
|
+
const bonus = Math.floor(sourceVal * mod.percent / 100);
|
|
416
|
+
if (mod.target in conversionStats) {
|
|
417
|
+
conversionStats[mod.target] += bonus;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// -------------------------------------------------------------------------
|
|
422
|
+
// Step 12: Boons — Might stacks, fury crit (handled in derived)
|
|
423
|
+
// -------------------------------------------------------------------------
|
|
424
|
+
const boonStats = zeroStats();
|
|
425
|
+
const mightStacks = ctx.assumedBoons?.might || 0;
|
|
426
|
+
|
|
427
|
+
if (mightStacks > 0) {
|
|
428
|
+
// Check for mightModifier overrides from traits
|
|
429
|
+
let mightPower = MIGHT_POWER_PER_STACK;
|
|
430
|
+
let mightCondi = MIGHT_CONDI_PER_STACK;
|
|
431
|
+
for (const mod of modifiers) {
|
|
432
|
+
if (mod.type === "mightModifier") {
|
|
433
|
+
if (mod.power != null) mightPower = mod.power;
|
|
434
|
+
if (mod.condi != null) mightCondi = mod.condi;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
boonStats.Power += mightStacks * mightPower;
|
|
438
|
+
boonStats.ConditionDamage += mightStacks * mightCondi;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// -------------------------------------------------------------------------
|
|
442
|
+
// Step 13: Sigils — stacking sigil contributions
|
|
443
|
+
// -------------------------------------------------------------------------
|
|
444
|
+
const sigilStats = zeroStats();
|
|
445
|
+
const sigilStacks = ctx.sigilStacks || {};
|
|
446
|
+
for (const def of STACKING_SIGIL_DEFS) {
|
|
447
|
+
const stacks = sigilStacks[def.key] || 0;
|
|
448
|
+
if (stacks <= 0) continue;
|
|
449
|
+
if (def.stat) {
|
|
450
|
+
if (def.stat in sigilStats) sigilStats[def.stat] += stacks * def.perStack;
|
|
451
|
+
} else if (def.allStats) {
|
|
452
|
+
for (const stat of def.allStats) {
|
|
453
|
+
if (stat in sigilStats) sigilStats[stat] += stacks * def.perStack;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// Note: def.modifier (e.g., "Outgoing Healing") is not a stat — skip
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// -------------------------------------------------------------------------
|
|
460
|
+
// Step 14: Total
|
|
461
|
+
// -------------------------------------------------------------------------
|
|
462
|
+
const total = zeroStats();
|
|
463
|
+
const sources = [base, equipmentStats, foodStats, runeStats, infusionStats,
|
|
464
|
+
enrichmentStats, utilityStats, signetStats, traitStats, conversionStats,
|
|
465
|
+
boonStats, sigilStats];
|
|
466
|
+
for (const src of sources) {
|
|
467
|
+
for (const key of ALL_STAT_KEYS) {
|
|
468
|
+
total[key] += src[key] || 0;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// -------------------------------------------------------------------------
|
|
473
|
+
// Step 15: Derived stats
|
|
474
|
+
// -------------------------------------------------------------------------
|
|
475
|
+
const weightClass = PROFESSION_WEIGHT[ctx.profession] || "medium";
|
|
476
|
+
const profBaseHp = PROFESSION_BASE_HP[ctx.profession] || 0;
|
|
477
|
+
|
|
478
|
+
const health = profBaseHp + total.Vitality * 10;
|
|
479
|
+
|
|
480
|
+
// Fury crit bonus
|
|
481
|
+
let furyCritBonus = 0;
|
|
482
|
+
if (furyAssumed) {
|
|
483
|
+
furyCritBonus += gameMode === "wvw" ? FURY_CRIT_CHANCE_WVW : FURY_CRIT_CHANCE;
|
|
484
|
+
// Add any critChance modifiers from traits that have condition === "fury"
|
|
485
|
+
for (const mod of modifiers) {
|
|
486
|
+
if (mod.type === "critChance" && mod.condition === "fury") {
|
|
487
|
+
furyCritBonus += mod.value;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const critChance = Math.min(100, (total.Precision - 895) / 21 + furyCritBonus);
|
|
493
|
+
const critDamage = 150 + total.Ferocity / 15;
|
|
494
|
+
const conditionDuration = total.Expertise / 15;
|
|
495
|
+
const boonDuration = total.Concentration / 15;
|
|
496
|
+
const armor = total.Toughness + ARMOR_DEFENSE_BY_WEIGHT[weightClass];
|
|
497
|
+
|
|
498
|
+
const derived = {
|
|
499
|
+
health,
|
|
500
|
+
critChance,
|
|
501
|
+
critDamage,
|
|
502
|
+
conditionDuration,
|
|
503
|
+
boonDuration,
|
|
504
|
+
armor,
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
base,
|
|
509
|
+
equipment: equipmentStats,
|
|
510
|
+
food: foodStats,
|
|
511
|
+
runes: runeStats,
|
|
512
|
+
infusions: infusionStats,
|
|
513
|
+
enrichment: enrichmentStats,
|
|
514
|
+
utility: utilityStats,
|
|
515
|
+
signets: signetStats,
|
|
516
|
+
traits: traitStats,
|
|
517
|
+
conversions: conversionStats,
|
|
518
|
+
boons: boonStats,
|
|
519
|
+
sigils: sigilStats,
|
|
520
|
+
total,
|
|
521
|
+
derived,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
module.exports = { computeAttributes, computeSlotStats, getExcludedSlots };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
BOON_NAMES, CONDITION_NAMES, CONDITION_NAME_NORMALIZE,
|
|
5
|
+
BOON_DISPLAY_ORDER, BUFF_FACT_TYPES,
|
|
6
|
+
} = require("./constants");
|
|
7
|
+
|
|
8
|
+
function normalizeName(status) {
|
|
9
|
+
return CONDITION_NAME_NORMALIZE[status] || status;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isAllyTargeted(description, statusName, allBoonNames) {
|
|
13
|
+
if (!description) return false;
|
|
14
|
+
const desc = description.toLowerCase();
|
|
15
|
+
const statusLower = statusName.toLowerCase();
|
|
16
|
+
const sentences = desc.split(".");
|
|
17
|
+
|
|
18
|
+
// Check if boon name in ally sentence
|
|
19
|
+
let foundInAllySentence = false;
|
|
20
|
+
let foundInDescription = false;
|
|
21
|
+
for (const sentence of sentences) {
|
|
22
|
+
const trimmed = sentence.trim();
|
|
23
|
+
if (!trimmed) continue;
|
|
24
|
+
const hasAlly = /\balli(?:es|ed)?\b/.test(trimmed) || /\bally\b/.test(trimmed);
|
|
25
|
+
const hasBoon = trimmed.includes(statusLower);
|
|
26
|
+
if (hasBoon) foundInDescription = true;
|
|
27
|
+
if (hasBoon && hasAlly) foundInAllySentence = true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (foundInAllySentence) return true;
|
|
31
|
+
if (foundInDescription) return false;
|
|
32
|
+
|
|
33
|
+
// Boon not named in description — check for generic ally mention
|
|
34
|
+
const hasGenericAlly = /\balli(?:es|ed)?\b/.test(desc) || /\bally\b/.test(desc);
|
|
35
|
+
if (!hasGenericAlly) return false;
|
|
36
|
+
|
|
37
|
+
// But if specific boons ARE named with allies, this unnamed one is probably self
|
|
38
|
+
if (allBoonNames && allBoonNames.length > 0) {
|
|
39
|
+
for (const otherBoon of allBoonNames) {
|
|
40
|
+
const otherLower = otherBoon.toLowerCase();
|
|
41
|
+
if (otherLower === statusLower) continue;
|
|
42
|
+
for (const sentence of sentences) {
|
|
43
|
+
const trimmed = sentence.trim();
|
|
44
|
+
const hasAlly = /\balli(?:es|ed)?\b/.test(trimmed) || /\bally\b/.test(trimmed);
|
|
45
|
+
if (trimmed.includes(otherLower) && hasAlly) return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function analyzeBoons(skills, traits, overrides, activeTraitIds) {
|
|
54
|
+
const boonMap = new Map(); // name → { name, sources: [] }
|
|
55
|
+
const condMap = new Map();
|
|
56
|
+
|
|
57
|
+
// Check for Twisted Medicine override
|
|
58
|
+
const twistedMedicineOverride = overrides.get("trait:2220");
|
|
59
|
+
const hasTwistedMedicine = activeTraitIds && twistedMedicineOverride &&
|
|
60
|
+
activeTraitIds.has(2220);
|
|
61
|
+
|
|
62
|
+
function processEntity(entity, type) {
|
|
63
|
+
const facts = entity.facts || [];
|
|
64
|
+
const description = entity.description || "";
|
|
65
|
+
|
|
66
|
+
// Collect all boon names from this entity for ally classification
|
|
67
|
+
const entityBoonNames = [];
|
|
68
|
+
for (const fact of facts) {
|
|
69
|
+
if (!BUFF_FACT_TYPES.has(fact.type)) continue;
|
|
70
|
+
const name = normalizeName(fact.status);
|
|
71
|
+
if (BOON_NAMES.has(name)) entityBoonNames.push(name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Track section context from NoData facts
|
|
75
|
+
let currentContext = null;
|
|
76
|
+
|
|
77
|
+
for (const fact of facts) {
|
|
78
|
+
if (fact.type === "NoData" && fact.text) {
|
|
79
|
+
currentContext = fact.text;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!BUFF_FACT_TYPES.has(fact.type)) continue;
|
|
84
|
+
if (!fact.status) continue;
|
|
85
|
+
|
|
86
|
+
const name = normalizeName(fact.status);
|
|
87
|
+
const stacks = fact.apply_count || 1;
|
|
88
|
+
const duration = fact.duration || 0;
|
|
89
|
+
|
|
90
|
+
const isBoon = BOON_NAMES.has(name);
|
|
91
|
+
const isCondition = CONDITION_NAMES.has(name);
|
|
92
|
+
if (!isBoon && !isCondition) continue;
|
|
93
|
+
|
|
94
|
+
// Determine ally targeting
|
|
95
|
+
let isAlly = isAllyTargeted(description, name, entityBoonNames);
|
|
96
|
+
|
|
97
|
+
// Twisted Medicine override
|
|
98
|
+
if (hasTwistedMedicine && entity.categories) {
|
|
99
|
+
const cats = entity.categories.map((c) => c.toLowerCase());
|
|
100
|
+
const allyTargetedCats = twistedMedicineOverride.allyTargeted || [];
|
|
101
|
+
if (allyTargetedCats.some((c) => cats.includes(c.toLowerCase()))) {
|
|
102
|
+
isAlly = true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const source = {
|
|
107
|
+
type,
|
|
108
|
+
name: entity.name || "",
|
|
109
|
+
sourceName: entity.name || "",
|
|
110
|
+
stacks,
|
|
111
|
+
duration,
|
|
112
|
+
isAlly,
|
|
113
|
+
context: currentContext,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const map = isBoon ? boonMap : condMap;
|
|
117
|
+
if (!map.has(name)) {
|
|
118
|
+
map.set(name, { name, sources: [] });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Deduplicate by (sourceName, stacks, duration, context)
|
|
122
|
+
const entry = map.get(name);
|
|
123
|
+
const isDuplicate = entry.sources.some(
|
|
124
|
+
(s) => s.sourceName === source.sourceName &&
|
|
125
|
+
s.stacks === source.stacks &&
|
|
126
|
+
s.duration === source.duration &&
|
|
127
|
+
s.context === source.context
|
|
128
|
+
);
|
|
129
|
+
if (!isDuplicate) {
|
|
130
|
+
entry.sources.push(source);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const skill of skills || []) {
|
|
136
|
+
if (skill) processEntity(skill, "skill");
|
|
137
|
+
}
|
|
138
|
+
for (const trait of traits || []) {
|
|
139
|
+
if (trait) processEntity(trait, "trait");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Sort boons by display order
|
|
143
|
+
const boonOrder = new Map(BOON_DISPLAY_ORDER.map((name, i) => [name, i]));
|
|
144
|
+
const boons = [...boonMap.values()].sort((a, b) => {
|
|
145
|
+
const ai = boonOrder.has(a.name) ? boonOrder.get(a.name) : 999;
|
|
146
|
+
const bi = boonOrder.has(b.name) ? boonOrder.get(b.name) : 999;
|
|
147
|
+
return ai - bi;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Sort conditions alphabetically
|
|
151
|
+
const conditions = [...condMap.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
152
|
+
|
|
153
|
+
return { boons, conditions };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { analyzeBoons, isAllyTargeted, normalizeName };
|