@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,473 @@
1
+ "use strict";
2
+
3
+ const { collectActiveTraitIds, isFuryTrait, collectModifiers } = require("../../src/engine/modifiers");
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers — build minimal catalog and context objects for tests
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function makeCtx(specs = [], gameMode = "pve") {
10
+ return { specializations: specs, gameMode };
11
+ }
12
+
13
+ function makeCatalogs(traitMap = {}, specMap = {}) {
14
+ return {
15
+ traitById: new Map(Object.entries(traitMap).map(([k, v]) => [Number(k), v])),
16
+ specializationById: new Map(Object.entries(specMap).map(([k, v]) => [Number(k), v])),
17
+ };
18
+ }
19
+
20
+ function makeOverrides(entries = {}) {
21
+ return new Map(Object.entries(entries));
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // collectActiveTraitIds
26
+ // ---------------------------------------------------------------------------
27
+
28
+ describe("collectActiveTraitIds()", () => {
29
+ it("collects major trait choices", () => {
30
+ const ctx = makeCtx([
31
+ { id: 4, majorChoices: { 1: 1444, 2: 1449, 3: 1437 } },
32
+ ]);
33
+ const catalogs = makeCatalogs({}, {});
34
+ const ids = collectActiveTraitIds(ctx, catalogs);
35
+ expect(ids).toContain(1444);
36
+ expect(ids).toContain(1449);
37
+ expect(ids).toContain(1437);
38
+ expect(ids.size).toBe(3);
39
+ });
40
+
41
+ it("collects minor traits from spec data", () => {
42
+ const ctx = makeCtx([{ id: 4, majorChoices: {} }]);
43
+ const catalogs = makeCatalogs({}, { 4: { minorTraits: [100, 200, 300] } });
44
+ const ids = collectActiveTraitIds(ctx, catalogs);
45
+ expect(ids).toContain(100);
46
+ expect(ids).toContain(200);
47
+ expect(ids).toContain(300);
48
+ });
49
+
50
+ it("supports specializationId key (editor format)", () => {
51
+ const ctx = makeCtx([{ specializationId: 5, majorChoices: { 1: 555 } }]);
52
+ const catalogs = makeCatalogs({}, { 5: { minorTraits: [999] } });
53
+ const ids = collectActiveTraitIds(ctx, catalogs);
54
+ expect(ids).toContain(555);
55
+ expect(ids).toContain(999);
56
+ });
57
+
58
+ it("returns empty set with no specializations", () => {
59
+ const ids = collectActiveTraitIds(makeCtx([]), makeCatalogs());
60
+ expect(ids.size).toBe(0);
61
+ });
62
+
63
+ it("returns empty set when ctx.specializations is missing", () => {
64
+ const ids = collectActiveTraitIds({}, makeCatalogs());
65
+ expect(ids.size).toBe(0);
66
+ });
67
+ });
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // isFuryTrait
71
+ // ---------------------------------------------------------------------------
72
+
73
+ describe("isFuryTrait()", () => {
74
+ it("returns true for a trait with a Buff(Fury) fact", () => {
75
+ const trait = {
76
+ facts: [
77
+ { type: "Buff", status: "Fury" },
78
+ { type: "AttributeAdjust", target: "Ferocity", value: 150 },
79
+ ],
80
+ };
81
+ expect(isFuryTrait(trait, 9999, makeOverrides())).toBe(true);
82
+ });
83
+
84
+ it("returns true for the implicit fury trait (1719) via overrides", () => {
85
+ const overrides = makeOverrides({ "trait:1719": { implicitFury: true } });
86
+ const trait = { facts: [] }; // no Buff(Fury) fact
87
+ expect(isFuryTrait(trait, 1719, overrides)).toBe(true);
88
+ });
89
+
90
+ it("returns false for a non-fury trait", () => {
91
+ const trait = {
92
+ facts: [{ type: "AttributeAdjust", target: "Power", value: 120 }],
93
+ };
94
+ expect(isFuryTrait(trait, 1234, makeOverrides())).toBe(false);
95
+ });
96
+
97
+ it("returns false when facts array is empty", () => {
98
+ expect(isFuryTrait({ facts: [] }, 1234, makeOverrides())).toBe(false);
99
+ });
100
+ });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // collectModifiers
104
+ // ---------------------------------------------------------------------------
105
+
106
+ describe("collectModifiers()", () => {
107
+ it("collects flatBonus from AttributeAdjust facts", () => {
108
+ const traitId = 1001;
109
+ const trait = {
110
+ slot: "Major",
111
+ description: "Some trait",
112
+ facts: [
113
+ { type: "AttributeAdjust", target: "Power", value: 120 },
114
+ ],
115
+ };
116
+ const catalogs = makeCatalogs({ [traitId]: trait });
117
+ const ctx = makeCtx([{ id: 1, majorChoices: { 1: traitId } }]);
118
+
119
+ const mods = collectModifiers(ctx, catalogs, makeOverrides());
120
+ const flatMods = mods.filter((m) => m.type === "flatBonus");
121
+ expect(flatMods).toHaveLength(1);
122
+ expect(flatMods[0]).toMatchObject({
123
+ source: `trait:${traitId}`,
124
+ type: "flatBonus",
125
+ target: "Power",
126
+ value: 120,
127
+ condition: null,
128
+ });
129
+ });
130
+
131
+ it("classifies fury-gated bonuses with condition: 'fury'", () => {
132
+ const traitId = 1002;
133
+ const trait = {
134
+ slot: "Major",
135
+ description: "Fury trait",
136
+ facts: [
137
+ { type: "Buff", status: "Fury" },
138
+ { type: "AttributeAdjust", target: "Ferocity", value: 180 },
139
+ ],
140
+ };
141
+ const catalogs = makeCatalogs({ [traitId]: trait });
142
+ const ctx = makeCtx([{ id: 1, majorChoices: { 1: traitId } }]);
143
+
144
+ const mods = collectModifiers(ctx, catalogs, makeOverrides());
145
+ const flatMods = mods.filter((m) => m.type === "flatBonus");
146
+ expect(flatMods).toHaveLength(1);
147
+ expect(flatMods[0].condition).toBe("fury");
148
+ expect(flatMods[0].target).toBe("Ferocity");
149
+ });
150
+
151
+ it("excludes pet stat traits (trait 1016 via overrides)", () => {
152
+ const traitId = 1016;
153
+ const trait = {
154
+ slot: "Major",
155
+ description: "Pet trait",
156
+ facts: [
157
+ { type: "AttributeAdjust", target: "Power", value: 300 },
158
+ ],
159
+ };
160
+ const catalogs = makeCatalogs({ [traitId]: trait });
161
+ const ctx = makeCtx([{ id: 1, majorChoices: { 1: traitId } }]);
162
+ const overrides = makeOverrides({ "trait:1016": { petStatOnly: true } });
163
+
164
+ const mods = collectModifiers(ctx, catalogs, overrides);
165
+ expect(mods).toHaveLength(0);
166
+ });
167
+
168
+ it("collects conversion from BuffConversion facts", () => {
169
+ const traitId = 1003;
170
+ const trait = {
171
+ slot: "Major",
172
+ description: "Conversion trait",
173
+ facts: [
174
+ { type: "BuffConversion", source: "Toughness", target: "Power", percent: 10 },
175
+ ],
176
+ };
177
+ const catalogs = makeCatalogs({ [traitId]: trait });
178
+ const ctx = makeCtx([{ id: 1, majorChoices: { 1: traitId } }]);
179
+
180
+ const mods = collectModifiers(ctx, catalogs, makeOverrides());
181
+ const convMods = mods.filter((m) => m.type === "conversion");
182
+ expect(convMods).toHaveLength(1);
183
+ expect(convMods[0]).toMatchObject({
184
+ source: `trait:${traitId}`,
185
+ type: "conversion",
186
+ sourceAttr: "Toughness",
187
+ target: "Power",
188
+ percent: 10,
189
+ condition: null,
190
+ });
191
+ });
192
+
193
+ it("normalizes BoonDuration target via CONVERSION_TARGET_MAP", () => {
194
+ const traitId = 1004;
195
+ const trait = {
196
+ slot: "Major",
197
+ description: "Boon duration trait",
198
+ facts: [
199
+ { type: "BuffConversion", source: "Vitality", target: "BoonDuration", percent: 15 },
200
+ ],
201
+ };
202
+ const catalogs = makeCatalogs({ [traitId]: trait });
203
+ const ctx = makeCtx([{ id: 1, majorChoices: { 1: traitId } }]);
204
+
205
+ const mods = collectModifiers(ctx, catalogs, makeOverrides());
206
+ const convMods = mods.filter((m) => m.type === "conversion");
207
+ expect(convMods[0].target).toBe("Concentration");
208
+ });
209
+
210
+ it("collects critChance from Percent fact on fury trait", () => {
211
+ const traitId = 1005;
212
+ const trait = {
213
+ slot: "Major",
214
+ description: "Crit chance while furious",
215
+ facts: [
216
+ { type: "Buff", status: "Fury" },
217
+ { type: "Percent", text: "Critical Chance Increase", percent: 10 },
218
+ ],
219
+ };
220
+ const catalogs = makeCatalogs({ [traitId]: trait });
221
+ const ctx = makeCtx([{ id: 1, majorChoices: { 1: traitId } }]);
222
+
223
+ const mods = collectModifiers(ctx, catalogs, makeOverrides());
224
+ const critMods = mods.filter((m) => m.type === "critChance");
225
+ expect(critMods).toHaveLength(1);
226
+ expect(critMods[0]).toMatchObject({
227
+ source: `trait:${traitId}`,
228
+ type: "critChance",
229
+ value: 10,
230
+ condition: "fury",
231
+ });
232
+ });
233
+
234
+ it("collects mightModifier from overrides (trait 1765)", () => {
235
+ const traitId = 1765;
236
+ const trait = {
237
+ slot: "Major",
238
+ description: "Notoriety",
239
+ facts: [],
240
+ };
241
+ const catalogs = makeCatalogs({ [traitId]: trait });
242
+ const ctx = makeCtx([{ id: 1, majorChoices: { 1: traitId } }]);
243
+ const overrides = makeOverrides({
244
+ "trait:1765": { mightOverride: { power: 40, condi: 20 } },
245
+ });
246
+
247
+ const mods = collectModifiers(ctx, catalogs, overrides);
248
+ const mightMods = mods.filter((m) => m.type === "mightModifier");
249
+ expect(mightMods).toHaveLength(1);
250
+ expect(mightMods[0]).toMatchObject({
251
+ source: `trait:${traitId}`,
252
+ type: "mightModifier",
253
+ power: 40,
254
+ condi: 20,
255
+ condition: null,
256
+ });
257
+ });
258
+
259
+ it("handles WvW game mode indexing (uses second fact value)", () => {
260
+ const traitId = 1006;
261
+ const trait = {
262
+ slot: "Major",
263
+ description: "WvW trait",
264
+ facts: [
265
+ // Two AttributeAdjust facts for same target: [0]=PvE, [1]=WvW
266
+ { type: "AttributeAdjust", target: "Power", value: 150 },
267
+ { type: "AttributeAdjust", target: "Power", value: 100 },
268
+ ],
269
+ };
270
+ const catalogs = makeCatalogs({ [traitId]: trait });
271
+ const ctxWvw = makeCtx([{ id: 1, majorChoices: { 1: traitId } }], "wvw");
272
+
273
+ const mods = collectModifiers(ctxWvw, catalogs, makeOverrides());
274
+ const flatMods = mods.filter((m) => m.type === "flatBonus" && m.target === "Power");
275
+ expect(flatMods).toHaveLength(1);
276
+ expect(flatMods[0].value).toBe(100); // WvW value (index 1)
277
+ });
278
+
279
+ it("uses PvE value (index 0) by default", () => {
280
+ const traitId = 1007;
281
+ const trait = {
282
+ slot: "Major",
283
+ description: "PvE trait",
284
+ facts: [
285
+ { type: "AttributeAdjust", target: "Precision", value: 200 },
286
+ { type: "AttributeAdjust", target: "Precision", value: 150 },
287
+ ],
288
+ };
289
+ const catalogs = makeCatalogs({ [traitId]: trait });
290
+ const ctx = makeCtx([{ id: 1, majorChoices: { 1: traitId } }], "pve");
291
+
292
+ const mods = collectModifiers(ctx, catalogs, makeOverrides());
293
+ const flatMods = mods.filter((m) => m.type === "flatBonus" && m.target === "Precision");
294
+ expect(flatMods[0].value).toBe(200);
295
+ });
296
+
297
+ it("does not collect burstRecharge from Primal Rage (no recharge reduction)", () => {
298
+ const traitId = 1831;
299
+ const trait = { slot: "Major", description: "Primal Rage", facts: [] };
300
+ const catalogs = makeCatalogs({ [traitId]: trait });
301
+ const ctx = makeCtx([{ id: 1, majorChoices: { 1: traitId } }]);
302
+ const overrides = makeOverrides({ "trait:1831": { description: "no burst recharge" } });
303
+
304
+ const mods = collectModifiers(ctx, catalogs, overrides);
305
+ const burstMods = mods.filter((m) => m.type === "burstRecharge");
306
+ expect(burstMods).toHaveLength(0);
307
+ });
308
+
309
+ it("collects burstRecharge from minor trait with Recharge Reduced Percent fact", () => {
310
+ const traitId = 2001;
311
+ const trait = {
312
+ slot: "Minor",
313
+ description: "Reduces burst skill recharge",
314
+ facts: [
315
+ { type: "Percent", text: "Recharge Reduced", percent: 20 },
316
+ ],
317
+ };
318
+ const catalogs = makeCatalogs({ [traitId]: trait });
319
+ const ctx = makeCtx([{ id: 1, majorChoices: { 1: traitId } }]);
320
+
321
+ const mods = collectModifiers(ctx, catalogs, makeOverrides());
322
+ const burstMods = mods.filter((m) => m.type === "burstRecharge");
323
+ expect(burstMods).toHaveLength(1);
324
+ expect(burstMods[0].value).toBe(20);
325
+ });
326
+
327
+ it("returns empty array when no active traits", () => {
328
+ const catalogs = makeCatalogs({});
329
+ const ctx = makeCtx([]);
330
+ const mods = collectModifiers(ctx, catalogs, makeOverrides());
331
+ expect(mods).toEqual([]);
332
+ });
333
+
334
+ it("returns empty array when catalogs.traitById is missing", () => {
335
+ const ctx = makeCtx([{ id: 1, majorChoices: { 1: 999 } }]);
336
+ const mods = collectModifiers(ctx, {}, makeOverrides());
337
+ expect(mods).toEqual([]);
338
+ });
339
+
340
+ // Blood Reaction bug: GW2 API returns multiple BuffConversion facts for
341
+ // same source→target pair with different percentages (PvE/WvW variants).
342
+ // Only the first (PvE) or second (WvW) should be used — not all summed.
343
+ it("marks berserkConditional override traits with condition: 'berserk'", () => {
344
+ const traitId = 2046;
345
+ const trait = {
346
+ slot: "Minor",
347
+ description: "Fatal Frenzy",
348
+ facts: [
349
+ { type: "AttributeAdjust", target: "Power", value: 150 },
350
+ { type: "AttributeAdjust", target: "ConditionDamage", value: 300 },
351
+ ],
352
+ };
353
+ const catalogs = makeCatalogs({ [traitId]: trait });
354
+ const ctx = makeCtx([{ id: 1, majorChoices: { 1: traitId } }]);
355
+ const overrides = makeOverrides({ "trait:2046": { berserkConditional: true } });
356
+
357
+ const mods = collectModifiers(ctx, catalogs, overrides);
358
+ const flatMods = mods.filter((m) => m.type === "flatBonus");
359
+ expect(flatMods).toHaveLength(2);
360
+ expect(flatMods[0].condition).toBe("berserk");
361
+ expect(flatMods[1].condition).toBe("berserk");
362
+ });
363
+
364
+ it("uses wvwFacts for flatBonus when gameMode is wvw and wvwFacts exists", () => {
365
+ const traitId = 2046;
366
+ const trait = {
367
+ slot: "Minor",
368
+ description: "Fatal Frenzy",
369
+ facts: [
370
+ { type: "AttributeAdjust", target: "Power", value: 300 },
371
+ { type: "AttributeAdjust", target: "ConditionDamage", value: 300 },
372
+ ],
373
+ wvwFacts: [
374
+ { type: "AttributeAdjust", target: "Power", value: 150 },
375
+ { type: "AttributeAdjust", target: "ConditionDamage", value: 300 },
376
+ ],
377
+ };
378
+ const catalogs = makeCatalogs({ [traitId]: trait });
379
+ const overrides = makeOverrides({ "trait:2046": { berserkConditional: true } });
380
+
381
+ const wvwMods = collectModifiers(
382
+ makeCtx([{ id: 1, majorChoices: { 1: traitId } }], "wvw"),
383
+ catalogs, overrides
384
+ );
385
+ const wvwFlat = wvwMods.filter((m) => m.type === "flatBonus");
386
+ expect(wvwFlat).toHaveLength(2);
387
+ const powerMod = wvwFlat.find((m) => m.target === "Power");
388
+ const condiMod = wvwFlat.find((m) => m.target === "ConditionDamage");
389
+ expect(powerMod.value).toBe(150);
390
+ expect(condiMod.value).toBe(300);
391
+
392
+ // PvE should still use trait.facts
393
+ const pveMods = collectModifiers(
394
+ makeCtx([{ id: 1, majorChoices: { 1: traitId } }], "pve"),
395
+ catalogs, overrides
396
+ );
397
+ const pveFlat = pveMods.filter((m) => m.type === "flatBonus");
398
+ const pvePower = pveFlat.find((m) => m.target === "Power");
399
+ expect(pvePower.value).toBe(300);
400
+ });
401
+
402
+ it("uses wvwFacts for conversions when gameMode is wvw and wvwFacts exists", () => {
403
+ const traitId = 2011;
404
+ const trait = {
405
+ slot: "Major",
406
+ description: "Blood Reaction",
407
+ facts: [
408
+ { type: "BuffConversion", source: "Precision", target: "CritDamage", percent: 12 },
409
+ { type: "BuffConversion", source: "Power", target: "ConditionDamage", percent: 15 },
410
+ ],
411
+ wvwFacts: [
412
+ { type: "BuffConversion", source: "Precision", target: "CritDamage", percent: 5 },
413
+ { type: "BuffConversion", source: "Power", target: "ConditionDamage", percent: 10 },
414
+ ],
415
+ };
416
+ const catalogs = makeCatalogs({ [traitId]: trait });
417
+
418
+ const wvwMods = collectModifiers(
419
+ makeCtx([{ id: 1, majorChoices: { 1: traitId } }], "wvw"),
420
+ catalogs, makeOverrides()
421
+ );
422
+ const convMods = wvwMods.filter((m) => m.type === "conversion");
423
+ expect(convMods).toHaveLength(2);
424
+ expect(convMods.find((m) => m.target === "Ferocity").percent).toBe(5);
425
+ expect(convMods.find((m) => m.target === "ConditionDamage").percent).toBe(10);
426
+ });
427
+
428
+ it("deduplicates BuffConversion facts by source+target (PvE picks first)", () => {
429
+ const traitId = 2011;
430
+ const trait = {
431
+ slot: "Major",
432
+ description: "Blood Reaction",
433
+ facts: [
434
+ { type: "BuffConversion", source: "Precision", target: "CritDamage", percent: 12 },
435
+ { type: "BuffConversion", source: "Precision", target: "CritDamage", percent: 10 },
436
+ { type: "BuffConversion", source: "Precision", target: "CritDamage", percent: 5 },
437
+ { type: "BuffConversion", source: "Power", target: "ConditionDamage", percent: 10 },
438
+ { type: "BuffConversion", source: "Power", target: "ConditionDamage", percent: 15 },
439
+ ],
440
+ };
441
+ const catalogs = makeCatalogs({ [traitId]: trait });
442
+ const ctx = makeCtx([{ id: 1, majorChoices: { 1: traitId } }], "pve");
443
+
444
+ const mods = collectModifiers(ctx, catalogs, makeOverrides());
445
+ const convMods = mods.filter((m) => m.type === "conversion");
446
+ expect(convMods).toHaveLength(2);
447
+ expect(convMods[0]).toMatchObject({ sourceAttr: "Precision", target: "Ferocity", percent: 12 });
448
+ expect(convMods[1]).toMatchObject({ sourceAttr: "Power", target: "ConditionDamage", percent: 10 });
449
+ });
450
+
451
+ it("deduplicates BuffConversion facts by source+target (WvW picks second)", () => {
452
+ const traitId = 2011;
453
+ const trait = {
454
+ slot: "Major",
455
+ description: "Blood Reaction",
456
+ facts: [
457
+ { type: "BuffConversion", source: "Precision", target: "CritDamage", percent: 12 },
458
+ { type: "BuffConversion", source: "Precision", target: "CritDamage", percent: 10 },
459
+ { type: "BuffConversion", source: "Precision", target: "CritDamage", percent: 5 },
460
+ { type: "BuffConversion", source: "Power", target: "ConditionDamage", percent: 10 },
461
+ { type: "BuffConversion", source: "Power", target: "ConditionDamage", percent: 15 },
462
+ ],
463
+ };
464
+ const catalogs = makeCatalogs({ [traitId]: trait });
465
+ const ctx = makeCtx([{ id: 1, majorChoices: { 1: traitId } }], "wvw");
466
+
467
+ const mods = collectModifiers(ctx, catalogs, makeOverrides());
468
+ const convMods = mods.filter((m) => m.type === "conversion");
469
+ expect(convMods).toHaveLength(2);
470
+ expect(convMods[0]).toMatchObject({ sourceAttr: "Precision", target: "Ferocity", percent: 10 });
471
+ expect(convMods[1]).toMatchObject({ sourceAttr: "Power", target: "ConditionDamage", percent: 15 });
472
+ });
473
+ });
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+
3
+ const { loadOverrides, getOverride } = require("../../src/engine/overrides");
4
+
5
+ describe("overrides module", () => {
6
+ let overrides;
7
+
8
+ beforeAll(() => {
9
+ overrides = loadOverrides();
10
+ });
11
+
12
+ describe("loadOverrides()", () => {
13
+ it("returns a Map", () => {
14
+ expect(overrides).toBeInstanceOf(Map);
15
+ });
16
+
17
+ it("contains exactly 6 entries", () => {
18
+ expect(overrides.size).toBe(6);
19
+ });
20
+
21
+ it("trait:1719 has implicitFury: true", () => {
22
+ const entry = overrides.get("trait:1719");
23
+ expect(entry).toBeDefined();
24
+ expect(entry.implicitFury).toBe(true);
25
+ });
26
+
27
+ it("trait:1016 has petStatOnly: true", () => {
28
+ const entry = overrides.get("trait:1016");
29
+ expect(entry).toBeDefined();
30
+ expect(entry.petStatOnly).toBe(true);
31
+ });
32
+
33
+ it("trait:1765 has mightOverride with power and condi", () => {
34
+ const entry = overrides.get("trait:1765");
35
+ expect(entry).toBeDefined();
36
+ expect(entry.mightOverride).toEqual({ power: 40, condi: 20 });
37
+ });
38
+
39
+ it("trait:2220 has allyTargeted array containing 'elixir'", () => {
40
+ const entry = overrides.get("trait:2220");
41
+ expect(entry).toBeDefined();
42
+ expect(Array.isArray(entry.allyTargeted)).toBe(true);
43
+ expect(entry.allyTargeted).toContain("elixir");
44
+ });
45
+
46
+ it("trait:1831 has no burstRechargeReduction (Primal Rage only grants Primal Bursts)", () => {
47
+ const entry = overrides.get("trait:1831");
48
+ expect(entry).toBeDefined();
49
+ expect(entry.burstRechargeReduction).toBeUndefined();
50
+ });
51
+ });
52
+
53
+ describe("getOverride()", () => {
54
+ it("returns the entry for a known trait key", () => {
55
+ const result = getOverride(overrides, "trait:1719");
56
+ expect(result).not.toBeNull();
57
+ expect(result.implicitFury).toBe(true);
58
+ });
59
+
60
+ it("returns null for an unknown key", () => {
61
+ const result = getOverride(overrides, "trait:9999");
62
+ expect(result).toBeNull();
63
+ });
64
+
65
+ it("returns null for an empty string key", () => {
66
+ const result = getOverride(overrides, "");
67
+ expect(result).toBeNull();
68
+ });
69
+ });
70
+ });
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { computeAttributes } = require("../../src/engine/attributes");
6
+ const { hydrateCatalogs } = require("./test-utils");
7
+
8
+ const FIXTURE_DIR = path.join(__dirname, "fixtures");
9
+
10
+ const fixtureFiles = fs.readdirSync(FIXTURE_DIR).filter((f) => f.endsWith(".json"));
11
+
12
+ describe("snapshot fixtures", () => {
13
+ for (const file of fixtureFiles) {
14
+ const fixture = JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, file), "utf-8"));
15
+
16
+ describe(fixture.name, () => {
17
+ let result;
18
+
19
+ beforeAll(() => {
20
+ const catalogs = hydrateCatalogs(fixture.catalogs);
21
+ result = computeAttributes(fixture.ctx, catalogs);
22
+ });
23
+
24
+ test("total stats match expected", () => {
25
+ expect(result.total).toEqual(fixture.expected.total);
26
+ });
27
+
28
+ test("derived health matches", () => {
29
+ expect(result.derived.health).toBe(fixture.expected.derived.health);
30
+ });
31
+
32
+ test("derived critChance matches", () => {
33
+ expect(result.derived.critChance).toBeCloseTo(fixture.expected.derived.critChance, 1);
34
+ });
35
+
36
+ test("derived critDamage matches", () => {
37
+ expect(result.derived.critDamage).toBeCloseTo(fixture.expected.derived.critDamage, 1);
38
+ });
39
+
40
+ test("derived armor matches", () => {
41
+ expect(result.derived.armor).toBe(fixture.expected.derived.armor);
42
+ });
43
+
44
+ test("derived conditionDuration matches", () => {
45
+ expect(result.derived.conditionDuration).toBeCloseTo(fixture.expected.derived.conditionDuration, 1);
46
+ });
47
+
48
+ test("derived boonDuration matches", () => {
49
+ expect(result.derived.boonDuration).toBeCloseTo(fixture.expected.derived.boonDuration, 1);
50
+ });
51
+ });
52
+ }
53
+ });
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Hydrates raw JSON catalog data (arrays) into the Map-based shape
5
+ * expected by the engine's computeAttributes function.
6
+ */
7
+ function hydrateCatalogs(raw) {
8
+ return {
9
+ traitById: new Map((raw.traits || []).map((t) => [t.id, t])),
10
+ skillById: new Map((raw.skills || []).map((s) => [s.id, s])),
11
+ specializationById: new Map((raw.specializations || []).map((s) => [s.id, s])),
12
+ runeById: new Map((raw.runes || []).map((r) => [r.id, r])),
13
+ foodById: new Map((raw.foods || []).map((f) => [f.id, f])),
14
+ utilityById: new Map((raw.utilities || []).map((u) => [u.id, u])),
15
+ infusionById: new Map((raw.infusions || []).map((i) => [i.id, i])),
16
+ enrichmentById: new Map((raw.enrichments || []).map((e) => [e.id, e])),
17
+ };
18
+ }
19
+
20
+ module.exports = { hydrateCatalogs };
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+
3
+ const { computeTooltip } = require("../../src/engine/tooltips");
4
+ const { WEAPON_STRENGTH_MIDPOINT } = require("../../src/engine/constants");
5
+
6
+ describe("computeTooltip", () => {
7
+ const baseAttrs = {
8
+ total: { Power: 3000, Precision: 2000, Ferocity: 900 },
9
+ derived: { critChance: 57.6, critDamage: 210.0 },
10
+ };
11
+
12
+ test("computes damage from coefficient and weapon strength", () => {
13
+ const skill = {
14
+ id: 5489, name: "Fireball",
15
+ facts: [{ type: "Damage", dmg_multiplier: 0.75, hit_count: 1 }],
16
+ };
17
+ const result = computeTooltip(baseAttrs, skill, "staff", []);
18
+ expect(result.coefficient).toBe(0.75);
19
+ expect(result.hits).toBe(1);
20
+ expect(result.weaponStrength).toBe(WEAPON_STRENGTH_MIDPOINT.staff);
21
+ expect(result.damage).toBeGreaterThan(0);
22
+ });
23
+
24
+ test("multi-hit skill multiplies by hit count", () => {
25
+ const skill = {
26
+ id: 100, name: "Multi",
27
+ facts: [{ type: "Damage", dmg_multiplier: 0.5, hit_count: 3 }],
28
+ };
29
+ const result = computeTooltip(baseAttrs, skill, "sword", []);
30
+ expect(result.hits).toBe(3);
31
+ expect(result.totalDamage).toBe(result.damage * 3);
32
+ });
33
+
34
+ test("applies damage multiplier modifiers", () => {
35
+ const skill = {
36
+ id: 101, name: "Big Hit",
37
+ facts: [{ type: "Damage", dmg_multiplier: 1.0, hit_count: 1 }],
38
+ };
39
+ const mods = [{ source: "trait:500", type: "damageMultiplier", value: 10, condition: null }];
40
+ const withMods = computeTooltip(baseAttrs, skill, "greatsword", mods);
41
+ const withoutMods = computeTooltip(baseAttrs, skill, "greatsword", []);
42
+ expect(withMods.damage).toBeGreaterThan(withoutMods.damage);
43
+ });
44
+
45
+ test("returns null for skill with no Damage fact", () => {
46
+ const skill = { id: 102, name: "Heal", facts: [{ type: "AttributeAdjust" }] };
47
+ const result = computeTooltip(baseAttrs, skill, "staff", []);
48
+ expect(result).toBeNull();
49
+ });
50
+
51
+ test("uses correct weapon strength for weapon type", () => {
52
+ const skill = {
53
+ id: 103, name: "Shot",
54
+ facts: [{ type: "Damage", dmg_multiplier: 1.0, hit_count: 1 }],
55
+ };
56
+ const rifle = computeTooltip(baseAttrs, skill, "rifle", []);
57
+ const dagger = computeTooltip(baseAttrs, skill, "dagger", []);
58
+ expect(rifle.weaponStrength).toBe(1095.5);
59
+ expect(dagger.weaponStrength).toBe(952.5);
60
+ expect(rifle.damage).toBeGreaterThan(dagger.damage);
61
+ });
62
+ });