@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.
Files changed (56) hide show
  1. package/data/overrides.json +25 -0
  2. package/package.json +32 -0
  3. package/scripts/generate-fixtures.js +242 -0
  4. package/src/api/client.js +117 -0
  5. package/src/api/types.js +80 -0
  6. package/src/engine/attributes.js +525 -0
  7. package/src/engine/boons.js +156 -0
  8. package/src/engine/combos.js +103 -0
  9. package/src/engine/constants.js +298 -0
  10. package/src/engine/graph.js +24 -0
  11. package/src/engine/index.js +82 -0
  12. package/src/engine/modifiers.js +204 -0
  13. package/src/engine/overrides.js +13 -0
  14. package/src/engine/tooltips.js +59 -0
  15. package/src/facts/match.js +134 -0
  16. package/src/facts/merge.js +45 -0
  17. package/src/facts/normalize.js +27 -0
  18. package/src/index.js +60 -0
  19. package/src/wiki/cache.js +103 -0
  20. package/src/wiki/client.js +230 -0
  21. package/src/wiki/parser.js +599 -0
  22. package/src/wiki/relations.js +55 -0
  23. package/src/wiki/resolver.js +352 -0
  24. package/tests/api-client.test.js +138 -0
  25. package/tests/cache.test.js +108 -0
  26. package/tests/engine/attributes.test.js +252 -0
  27. package/tests/engine/boons.test.js +129 -0
  28. package/tests/engine/combos.test.js +76 -0
  29. package/tests/engine/constants.test.js +576 -0
  30. package/tests/engine/fixtures/berserker-thief.json +61 -0
  31. package/tests/engine/fixtures/berserker-warrior.json +113 -0
  32. package/tests/engine/fixtures/celestial-firebrand-wvw.json +94 -0
  33. package/tests/engine/fixtures/harrier-druid.json +119 -0
  34. package/tests/engine/fixtures/viper-mirage.json +104 -0
  35. package/tests/engine/graph.test.js +30 -0
  36. package/tests/engine/integration.test.js +111 -0
  37. package/tests/engine/modifiers.test.js +473 -0
  38. package/tests/engine/overrides.test.js +70 -0
  39. package/tests/engine/snapshot.test.js +53 -0
  40. package/tests/engine/test-utils.js +20 -0
  41. package/tests/engine/tooltips.test.js +62 -0
  42. package/tests/fixtures/capture.js +160 -0
  43. package/tests/fixtures/fixtures.json +839 -0
  44. package/tests/integration.test.js +100 -0
  45. package/tests/match.test.js +176 -0
  46. package/tests/merge.test.js +128 -0
  47. package/tests/normalize.test.js +78 -0
  48. package/tests/parser.test.js +506 -0
  49. package/tests/real-data.test.js +296 -0
  50. package/tests/relations.test.js +80 -0
  51. package/tests/resolver.test.js +721 -0
  52. package/tests/validate-live.js +191 -0
  53. package/tests/wiki-client.test.js +468 -0
  54. package/tests/wiki-integration.test.js +177 -0
  55. package/tests/wiki-live-validation.test.js +61 -0
  56. 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 };