@axiapps/gw2-data 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +156 -0
  2. package/data/overrides.json +24 -0
  3. package/package.json +3 -1
  4. package/src/engine/attributes.js +102 -23
  5. package/src/engine/boons.js +19 -1
  6. package/src/engine/constants.js +27 -2
  7. package/src/engine/index.js +4 -4
  8. package/src/engine/modifiers.js +57 -21
  9. package/src/wiki/parser.js +17 -9
  10. package/scripts/generate-fixtures.js +0 -242
  11. package/tests/api-client.test.js +0 -138
  12. package/tests/cache.test.js +0 -108
  13. package/tests/engine/attributes.test.js +0 -252
  14. package/tests/engine/boons.test.js +0 -129
  15. package/tests/engine/combos.test.js +0 -76
  16. package/tests/engine/constants.test.js +0 -576
  17. package/tests/engine/fixtures/berserker-thief.json +0 -61
  18. package/tests/engine/fixtures/berserker-warrior.json +0 -113
  19. package/tests/engine/fixtures/celestial-firebrand-wvw.json +0 -94
  20. package/tests/engine/fixtures/harrier-druid.json +0 -119
  21. package/tests/engine/fixtures/viper-mirage.json +0 -104
  22. package/tests/engine/graph.test.js +0 -30
  23. package/tests/engine/integration.test.js +0 -111
  24. package/tests/engine/modifiers.test.js +0 -473
  25. package/tests/engine/overrides.test.js +0 -70
  26. package/tests/engine/snapshot.test.js +0 -53
  27. package/tests/engine/test-utils.js +0 -20
  28. package/tests/engine/tooltips.test.js +0 -62
  29. package/tests/fixtures/capture.js +0 -160
  30. package/tests/fixtures/fixtures.json +0 -839
  31. package/tests/integration.test.js +0 -100
  32. package/tests/match.test.js +0 -176
  33. package/tests/merge.test.js +0 -128
  34. package/tests/normalize.test.js +0 -78
  35. package/tests/parser.test.js +0 -506
  36. package/tests/real-data.test.js +0 -296
  37. package/tests/relations.test.js +0 -80
  38. package/tests/resolver.test.js +0 -721
  39. package/tests/validate-live.js +0 -191
  40. package/tests/wiki-client.test.js +0 -468
  41. package/tests/wiki-integration.test.js +0 -177
  42. package/tests/wiki-live-validation.test.js +0 -61
  43. package/tests/wiki-snapshots.test.js +0 -166
@@ -11,7 +11,11 @@ const WIKI_ATTR_MAP = {
11
11
  "Critical Damage": "CritDamage",
12
12
  };
13
13
  function normalizeAttr(name) {
14
- return WIKI_ATTR_MAP[name] || name;
14
+ if (!name) return name;
15
+ // Wiki templates inconsistently use lowercase ("power") or capitalized ("Power")
16
+ // attribute names. Title-case before lookup so both forms produce the API stat key.
17
+ const titleCased = name.replace(/\b\w/g, (c) => c.toUpperCase());
18
+ return WIKI_ATTR_MAP[titleCased] || titleCased;
15
19
  }
16
20
 
17
21
  // Fact types to silently ignore
@@ -61,14 +65,14 @@ const BOONS = new Set([
61
65
 
62
66
  const CONDITIONS = new Set([
63
67
  "bleeding",
64
- "blind",
68
+ "blind", "blinded",
65
69
  "burning",
66
- "chilled",
70
+ "chill", "chilled",
67
71
  "confusion",
68
- "crippled",
72
+ "cripple", "crippled",
69
73
  "fear",
70
- "immobilize",
71
- "poison",
74
+ "immobilize", "immobilized",
75
+ "poison", "poisoned",
72
76
  "slow",
73
77
  "taunt",
74
78
  "torment",
@@ -199,9 +203,13 @@ function mapWikiFactToApiFact(factType, positional, params, isWvw, isUniversal)
199
203
  const rawCoefficient = params.coefficient || positional[0] || "0";
200
204
  const coefficient = parseFloat(stripWikiMarkup(rawCoefficient));
201
205
  const hits = parseInt(stripWikiMarkup(params.hits) || "1", 10);
206
+ // `alt=` carries the conditional damage label (e.g. "Damage to Controlled Foes").
207
+ // Without this, the conditional variant collapses with the base "Damage" fact
208
+ // during dedup, hiding bonus-damage values from the tooltip.
209
+ const label = params.alt ? stripWikiMarkup(params.alt).trim() : "Damage";
202
210
  return {
203
211
  type: "Damage",
204
- text: "Damage",
212
+ text: label,
205
213
  dmg_multiplier: coefficient,
206
214
  hit_count: hits,
207
215
  };
@@ -392,13 +400,13 @@ function mapWikiFactToApiFact(factType, positional, params, isWvw, isUniversal)
392
400
  // Many wiki fact types carry a percentage value (e.g. "damage reduction|33").
393
401
  // Without this, they fall through to generic Number and lose the % suffix.
394
402
  if (PERCENT_TYPES.has(type)) {
395
- const label = type.replace(/\b\w/g, (c) => c.toUpperCase());
403
+ const label = params.alt ? stripWikiMarkup(params.alt).trim() : type.replace(/\b\w/g, (c) => c.toUpperCase());
396
404
  return { type: "Percent", text: label, percent: pos0Num() };
397
405
  }
398
406
 
399
407
  // ── Unknown but has a numeric value ────────────────────────────────
400
408
  if (positional[0] && !isNaN(parseFloat(stripWikiMarkup(positional[0])))) {
401
- const label = type.replace(/\b\w/g, (c) => c.toUpperCase());
409
+ const label = params.alt ? stripWikiMarkup(params.alt).trim() : type.replace(/\b\w/g, (c) => c.toUpperCase());
402
410
  // Heuristic: fact type names containing "increase", "reduction", "chance",
403
411
  // or "rate" are almost always percentages in GW2 wiki templates.
404
412
  if (/(?:increase|reduction|chance|rate|cost|drain)$/.test(type)) {
@@ -1,242 +0,0 @@
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("../tests/engine/test-utils");
7
-
8
- const FIXTURE_DIR = path.join(__dirname, "../tests/engine/fixtures");
9
-
10
- const fixtures = [
11
- {
12
- name: "Berserker Warrior",
13
- description: "Heavy armor, 3-stat, full ascended, signets, Might+Fury assumed",
14
- ctx: {
15
- profession: "Warrior",
16
- specializations: [
17
- { id: 4, majorChoices: { 1: 1444, 2: 1449, 3: 1437 } },
18
- ],
19
- equipment: {
20
- slots: {
21
- head: "Berserker's", shoulders: "Berserker's", chest: "Berserker's",
22
- gloves: "Berserker's", legs: "Berserker's", boots: "Berserker's",
23
- mainhand1: "Berserker's", offhand1: "Berserker's",
24
- back: "Berserker's", accessory1: "Berserker's", accessory2: "Berserker's",
25
- amulet: "Berserker's", ring1: "Berserker's", ring2: "Berserker's",
26
- },
27
- weapons: { mainhand1: "greatsword" },
28
- runes: {},
29
- infusions: {},
30
- enrichment: null,
31
- food: null,
32
- utility: null,
33
- },
34
- gameMode: "pve",
35
- underwaterMode: false,
36
- activeWeaponSet: 1,
37
- skills: { healId: null, utilityIds: [9093], eliteId: null },
38
- assumedBoons: { might: 25, fury: true },
39
- sigilStacks: null,
40
- },
41
- catalogs: {
42
- traits: [
43
- { id: 1444, facts: [{ type: "AttributeAdjust", target: "Power", value: 120 }] },
44
- { id: 1449, facts: [] },
45
- { id: 1437, facts: [] },
46
- ],
47
- specializations: [{ id: 4, minorTraits: [] }],
48
- skills: [],
49
- runes: [],
50
- foods: [],
51
- utilities: [],
52
- infusions: [],
53
- enrichments: [],
54
- },
55
- },
56
- {
57
- name: "Viper Mirage",
58
- description: "Medium armor, 4-stat, trait conversions, food + utility",
59
- ctx: {
60
- profession: "Mesmer",
61
- specializations: [
62
- { id: 24, majorChoices: { 1: 700 } },
63
- ],
64
- equipment: {
65
- slots: {
66
- head: "Viper's", shoulders: "Viper's", chest: "Viper's",
67
- gloves: "Viper's", legs: "Viper's", boots: "Viper's",
68
- mainhand1: "Viper's",
69
- back: "Viper's", accessory1: "Viper's", accessory2: "Viper's",
70
- amulet: "Viper's", ring1: "Viper's", ring2: "Viper's",
71
- },
72
- weapons: { mainhand1: "axe" },
73
- runes: {},
74
- infusions: {},
75
- enrichment: null,
76
- food: 91805,
77
- utility: null,
78
- },
79
- gameMode: "pve",
80
- underwaterMode: false,
81
- activeWeaponSet: 1,
82
- skills: { healId: null, utilityIds: [], eliteId: null },
83
- assumedBoons: null,
84
- sigilStacks: null,
85
- },
86
- catalogs: {
87
- traits: [
88
- { id: 700, facts: [{ type: "BuffConversion", source: "Vitality", target: "ConditionDamage", percent: 10 }] },
89
- ],
90
- specializations: [{ id: 24, minorTraits: [] }],
91
- skills: [],
92
- runes: [],
93
- foods: [{ id: 91805, name: "Plate of Beef Rendang", buff: "+100 Expertise\n+70 Condition Damage" }],
94
- utilities: [],
95
- infusions: [],
96
- enrichments: [],
97
- },
98
- },
99
- {
100
- name: "Celestial Firebrand WvW",
101
- description: "Heavy armor, 9-stat, WvW Celestial exclusion, rune bonuses",
102
- ctx: {
103
- profession: "Guardian",
104
- specializations: [],
105
- equipment: {
106
- slots: {
107
- head: "Celestial", shoulders: "Celestial", chest: "Celestial",
108
- gloves: "Celestial", legs: "Celestial", boots: "Celestial",
109
- mainhand1: "Celestial",
110
- back: "Celestial", accessory1: "Celestial", accessory2: "Celestial",
111
- amulet: "Celestial", ring1: "Celestial", ring2: "Celestial",
112
- },
113
- weapons: { mainhand1: "axe" },
114
- runes: {
115
- head: 24836, shoulders: 24836, chest: 24836,
116
- gloves: 24836, legs: 24836, boots: 24836,
117
- },
118
- infusions: {},
119
- enrichment: null,
120
- food: null,
121
- utility: null,
122
- },
123
- gameMode: "wvw",
124
- underwaterMode: false,
125
- activeWeaponSet: 1,
126
- skills: { healId: null, utilityIds: [], eliteId: null },
127
- assumedBoons: null,
128
- sigilStacks: null,
129
- },
130
- catalogs: {
131
- traits: [],
132
- specializations: [],
133
- skills: [],
134
- runes: [{ id: 24836, name: "Superior Rune of the Scholar", bonuses: ["+25 Power", "+35 Ferocity", "+50 Power", "+65 Ferocity", "+100 Power", "+125 Ferocity"] }],
135
- foods: [],
136
- utilities: [],
137
- infusions: [],
138
- enrichments: [],
139
- },
140
- },
141
- {
142
- name: "Harrier Druid",
143
- description: "Medium armor, 3-stat healing, enrichment, infusions",
144
- ctx: {
145
- profession: "Ranger",
146
- specializations: [],
147
- equipment: {
148
- slots: {
149
- head: "Harrier's", shoulders: "Harrier's", chest: "Harrier's",
150
- gloves: "Harrier's", legs: "Harrier's", boots: "Harrier's",
151
- mainhand1: "Harrier's",
152
- back: "Harrier's", accessory1: "Harrier's", accessory2: "Harrier's",
153
- amulet: "Harrier's", ring1: "Harrier's", ring2: "Harrier's",
154
- },
155
- weapons: { mainhand1: "staff" },
156
- runes: {},
157
- infusions: {
158
- head: [49432], shoulders: [49432], chest: [49432],
159
- gloves: [49432], legs: [49432], boots: [49432],
160
- },
161
- enrichment: 78061,
162
- food: null,
163
- utility: null,
164
- },
165
- gameMode: "pve",
166
- underwaterMode: false,
167
- activeWeaponSet: 1,
168
- skills: { healId: null, utilityIds: [], eliteId: null },
169
- assumedBoons: null,
170
- sigilStacks: null,
171
- },
172
- catalogs: {
173
- traits: [],
174
- specializations: [],
175
- skills: [],
176
- runes: [],
177
- foods: [],
178
- utilities: [],
179
- infusions: [{ id: 49432, name: "+5 Healing Power Infusion", infixUpgrade: { attributes: [{ attribute: "Healing", modifier: 5 }] } }],
180
- enrichments: [{ id: 78061, name: "+10 Concentration Enrichment", infixUpgrade: { attributes: [{ attribute: "BoonDuration", modifier: 10 }] } }],
181
- },
182
- },
183
- {
184
- name: "Berserker Thief",
185
- description: "Medium armor, sparse gear (testing empty slots gracefully)",
186
- ctx: {
187
- profession: "Thief",
188
- specializations: [],
189
- equipment: {
190
- slots: { chest: "Berserker's", legs: "Berserker's" },
191
- weapons: {},
192
- runes: {},
193
- infusions: {},
194
- enrichment: null,
195
- food: null,
196
- utility: null,
197
- },
198
- gameMode: "pve",
199
- underwaterMode: false,
200
- activeWeaponSet: 1,
201
- skills: { healId: null, utilityIds: [], eliteId: null },
202
- assumedBoons: null,
203
- sigilStacks: null,
204
- },
205
- catalogs: {
206
- traits: [],
207
- specializations: [],
208
- skills: [],
209
- runes: [],
210
- foods: [],
211
- utilities: [],
212
- infusions: [],
213
- enrichments: [],
214
- },
215
- },
216
- ];
217
-
218
- // Generate fixtures
219
- if (!fs.existsSync(FIXTURE_DIR)) {
220
- fs.mkdirSync(FIXTURE_DIR, { recursive: true });
221
- }
222
-
223
- for (const fixture of fixtures) {
224
- const catalogs = hydrateCatalogs(fixture.catalogs);
225
- const result = computeAttributes(fixture.ctx, catalogs);
226
- const output = {
227
- name: fixture.name,
228
- description: fixture.description,
229
- ctx: fixture.ctx,
230
- catalogs: fixture.catalogs,
231
- expected: {
232
- total: result.total,
233
- derived: result.derived,
234
- },
235
- };
236
- const filename = fixture.name.toLowerCase().replace(/\s+/g, "-") + ".json";
237
- const filepath = path.join(FIXTURE_DIR, filename);
238
- fs.writeFileSync(filepath, JSON.stringify(output, null, 2) + "\n");
239
- console.log(`Generated: ${filename}`);
240
- }
241
-
242
- console.log("Done!");
@@ -1,138 +0,0 @@
1
- "use strict";
2
-
3
- const { Gw2ApiClient } = require("../src/api/client");
4
- const { MemoryCache } = require("../src/wiki/cache");
5
-
6
- describe("Gw2ApiClient", () => {
7
- let client;
8
- let mockFetch;
9
-
10
- beforeEach(() => {
11
- mockFetch = jest.fn();
12
- client = new Gw2ApiClient({
13
- cache: new MemoryCache(),
14
- fetch: mockFetch,
15
- });
16
- });
17
-
18
- describe("fetchJson", () => {
19
- test("returns parsed JSON on success", async () => {
20
- mockFetch.mockResolvedValueOnce({
21
- ok: true,
22
- json: async () => ({ name: "Fireball" }),
23
- });
24
- const result = await client.fetchJson("https://api.guildwars2.com/v2/skills/5489");
25
- expect(result).toEqual({ name: "Fireball" });
26
- });
27
-
28
- test("retries on 429 with delay", async () => {
29
- mockFetch
30
- .mockResolvedValueOnce({ ok: false, status: 429, statusText: "Too Many Requests" })
31
- .mockResolvedValueOnce({
32
- ok: true,
33
- json: async () => ({ name: "Fireball" }),
34
- });
35
- const result = await client.fetchJson("https://api.guildwars2.com/v2/skills/5489");
36
- expect(result).toEqual({ name: "Fireball" });
37
- expect(mockFetch).toHaveBeenCalledTimes(2);
38
- });
39
-
40
- test("throws after max retries", async () => {
41
- mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" });
42
- await expect(
43
- client.fetchJson("https://api.guildwars2.com/v2/skills/5489")
44
- ).rejects.toThrow("500");
45
- });
46
-
47
- test("throws immediately on 4xx (non-429) without retrying", async () => {
48
- mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: "Not Found" });
49
- await expect(
50
- client.fetchJson("https://api.guildwars2.com/v2/skills/99999")
51
- ).rejects.toThrow("404");
52
- expect(mockFetch).toHaveBeenCalledTimes(1);
53
- });
54
- });
55
-
56
- describe("fetchByIds", () => {
57
- test("fetches single chunk of IDs", async () => {
58
- mockFetch.mockResolvedValueOnce({
59
- ok: true,
60
- json: async () => [
61
- { id: 1, name: "Skill A" },
62
- { id: 2, name: "Skill B" },
63
- ],
64
- });
65
- const result = await client.fetchByIds("/v2/skills", [1, 2]);
66
- expect(result).toHaveLength(2);
67
- expect(result[0].name).toBe("Skill A");
68
- });
69
-
70
- test("chunks large ID lists into batches of 180", async () => {
71
- const ids = Array.from({ length: 200 }, (_, i) => i + 1);
72
- mockFetch
73
- .mockResolvedValueOnce({
74
- ok: true,
75
- json: async () => ids.slice(0, 180).map((id) => ({ id })),
76
- })
77
- .mockResolvedValueOnce({
78
- ok: true,
79
- json: async () => ids.slice(180).map((id) => ({ id })),
80
- });
81
- const result = await client.fetchByIds("/v2/skills", ids);
82
- expect(result).toHaveLength(200);
83
- expect(mockFetch).toHaveBeenCalledTimes(2);
84
- });
85
-
86
- test("deduplicates IDs before fetching", async () => {
87
- mockFetch.mockResolvedValueOnce({
88
- ok: true,
89
- json: async () => [{ id: 1, name: "Skill A" }],
90
- });
91
- const result = await client.fetchByIds("/v2/skills", [1, 1, 1]);
92
- expect(result).toHaveLength(1);
93
- expect(mockFetch).toHaveBeenCalledTimes(1);
94
- expect(mockFetch.mock.calls[0][0]).toContain("ids=1");
95
- expect(mockFetch.mock.calls[0][0]).not.toContain("ids=1,1");
96
- });
97
- });
98
-
99
- describe("fetchCached", () => {
100
- test("returns cached value on hit", async () => {
101
- const cache = new MemoryCache();
102
- cache.set("test-key", { cached: true }, 60000);
103
- client = new Gw2ApiClient({ cache, fetch: mockFetch });
104
-
105
- const result = await client.fetchCached("test-key", "https://example.com", 60000);
106
- expect(result).toEqual({ cached: true });
107
- expect(mockFetch).not.toHaveBeenCalled();
108
- });
109
-
110
- test("fetches and caches on miss", async () => {
111
- mockFetch.mockResolvedValueOnce({
112
- ok: true,
113
- json: async () => ({ fresh: true }),
114
- });
115
- const result = await client.fetchCached("test-key", "https://example.com", 60000);
116
- expect(result).toEqual({ fresh: true });
117
- const result2 = await client.fetchCached("test-key", "https://example.com", 60000);
118
- expect(result2).toEqual({ fresh: true });
119
- expect(mockFetch).toHaveBeenCalledTimes(1);
120
- });
121
- });
122
-
123
- describe("constructor defaults", () => {
124
- test("defaults to MemoryCache when no cache provided", async () => {
125
- const defaultClient = new Gw2ApiClient({ fetch: mockFetch });
126
- mockFetch.mockResolvedValueOnce({
127
- ok: true,
128
- json: async () => ({ id: 1 }),
129
- });
130
- const result = await defaultClient.fetchCached("k", "https://example.com", 60000);
131
- expect(result).toEqual({ id: 1 });
132
- // Second call should hit cache
133
- const result2 = await defaultClient.fetchCached("k", "https://example.com", 60000);
134
- expect(result2).toEqual({ id: 1 });
135
- expect(mockFetch).toHaveBeenCalledTimes(1);
136
- });
137
- });
138
- });
@@ -1,108 +0,0 @@
1
- "use strict";
2
-
3
- const { MemoryCache } = require("../src/wiki/cache");
4
-
5
- describe("MemoryCache", () => {
6
- let cache;
7
-
8
- beforeEach(() => {
9
- cache = new MemoryCache();
10
- });
11
-
12
- test("get returns null for missing key", () => {
13
- expect(cache.get("missing")).toBeNull();
14
- });
15
-
16
- test("set and get round-trips a value", () => {
17
- cache.set("key1", { data: "hello" }, 60000);
18
- expect(cache.get("key1")).toEqual({ data: "hello" });
19
- });
20
-
21
- test("get returns null for expired entry", () => {
22
- cache.set("key1", "value", 1); // 1ms TTL
23
- // Advance past TTL
24
- jest.useFakeTimers();
25
- jest.advanceTimersByTime(10);
26
- expect(cache.get("key1")).toBeNull();
27
- jest.useRealTimers();
28
- });
29
-
30
- test("invalidate removes a specific key", () => {
31
- cache.set("key1", "value1", 60000);
32
- cache.set("key2", "value2", 60000);
33
- cache.invalidate("key1");
34
- expect(cache.get("key1")).toBeNull();
35
- expect(cache.get("key2")).toBe("value2");
36
- });
37
-
38
- test("clear removes all entries", () => {
39
- cache.set("key1", "value1", 60000);
40
- cache.set("key2", "value2", 60000);
41
- cache.clear();
42
- expect(cache.get("key1")).toBeNull();
43
- expect(cache.get("key2")).toBeNull();
44
- });
45
-
46
- test("has returns true for valid entry, false for missing/expired", () => {
47
- cache.set("key1", "value", 60000);
48
- expect(cache.has("key1")).toBe(true);
49
- expect(cache.has("missing")).toBe(false);
50
- });
51
- });
52
-
53
- const fs = require("node:fs/promises");
54
- const path = require("node:path");
55
- const os = require("node:os");
56
- const { DiskCache } = require("../src/wiki/cache");
57
-
58
- describe("DiskCache", () => {
59
- let cache;
60
- let tmpDir;
61
-
62
- beforeEach(async () => {
63
- tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gw2-data-cache-"));
64
- cache = new DiskCache(tmpDir);
65
- });
66
-
67
- afterEach(async () => {
68
- await fs.rm(tmpDir, { recursive: true, force: true });
69
- });
70
-
71
- test("get returns null for missing key", async () => {
72
- expect(await cache.get("missing")).toBeNull();
73
- });
74
-
75
- test("set and get round-trips a value", async () => {
76
- await cache.set("key1", { data: "hello" }, 60000);
77
- expect(await cache.get("key1")).toEqual({ data: "hello" });
78
- });
79
-
80
- test("get returns null for expired entry", async () => {
81
- await cache.set("key1", "value", 1);
82
- // Wait for TTL to expire
83
- await new Promise((r) => setTimeout(r, 10));
84
- expect(await cache.get("key1")).toBeNull();
85
- });
86
-
87
- test("invalidate removes a specific key", async () => {
88
- await cache.set("key1", "value1", 60000);
89
- await cache.set("key2", "value2", 60000);
90
- await cache.invalidate("key1");
91
- expect(await cache.get("key1")).toBeNull();
92
- expect(await cache.get("key2")).toBe("value2");
93
- });
94
-
95
- test("clear removes all entries", async () => {
96
- await cache.set("key1", "value1", 60000);
97
- await cache.set("key2", "value2", 60000);
98
- await cache.clear();
99
- expect(await cache.get("key1")).toBeNull();
100
- expect(await cache.get("key2")).toBeNull();
101
- });
102
-
103
- test("persists across instances", async () => {
104
- await cache.set("key1", "value1", 60000);
105
- const cache2 = new DiskCache(tmpDir);
106
- expect(await cache2.get("key1")).toBe("value1");
107
- });
108
- });