@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,25 @@
1
+ {
2
+ "trait:1719": {
3
+ "implicitFury": true,
4
+ "description": "Roiling Mists: has fury crit bonus but no Buff(Fury) fact in API"
5
+ },
6
+ "trait:1016": {
7
+ "petStatOnly": true,
8
+ "description": "Fang and Claw: AttributeAdjust facts apply to pets, not player"
9
+ },
10
+ "trait:1765": {
11
+ "mightOverride": { "power": 40, "condi": 20 },
12
+ "description": "Notoriety: modifies Might per-stack values (+40P/+20CD instead of +30P/+30CD)"
13
+ },
14
+ "trait:2220": {
15
+ "allyTargeted": ["elixir"],
16
+ "description": "Twisted Medicine: elixir skills become ally-targeted"
17
+ },
18
+ "trait:1831": {
19
+ "description": "Primal Rage: grants access to Primal Bursts (no burst recharge reduction)"
20
+ },
21
+ "trait:2046": {
22
+ "berserkConditional": true,
23
+ "description": "Fatal Frenzy: stat bonuses only apply while in berserk mode"
24
+ }
25
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@axiapps/gw2-data",
3
+ "version": "0.1.0",
4
+ "description": "GW2 data library — wiki-sourced facts, GW2 API structural data, and stat computation",
5
+ "main": "src/index.js",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./wiki": "./src/wiki/client.js",
9
+ "./api": "./src/api/client.js",
10
+ "./facts": "./src/facts/merge.js",
11
+ "./engine": "./src/engine/index.js"
12
+ },
13
+ "scripts": {
14
+ "test": "jest"
15
+ },
16
+ "jest": {
17
+ "testEnvironment": "node",
18
+ "testMatch": ["**/packages/gw2-data/tests/**/*.test.js"],
19
+ "clearMocks": true,
20
+ "testTimeout": 15000
21
+ },
22
+ "license": "MIT",
23
+ "keywords": ["gw2", "guild-wars-2", "wiki", "api", "build-editor"],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/darkharasho/axiforge",
27
+ "directory": "packages/gw2-data"
28
+ },
29
+ "devDependencies": {
30
+ "jest": "^30.3.0"
31
+ }
32
+ }
@@ -0,0 +1,242 @@
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!");
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+
3
+ const { MemoryCache } = require("../wiki/cache");
4
+
5
+ const GW2_API_ROOT = "https://api.guildwars2.com";
6
+ const MAX_IDS_PER_REQUEST = 180;
7
+ const MAX_RETRIES = 3;
8
+ const MAX_CONCURRENT = 3;
9
+ const RATE_LIMIT_DELAY_MS = 2000;
10
+ const USER_AGENT = "@axiapps/gw2-data (https://github.com/darkharasho/axiforge)";
11
+
12
+ class Gw2ApiClient {
13
+ /**
14
+ * @param {Object} options
15
+ * @param {import('../wiki/cache').CacheAdapter} options.cache - Cache adapter
16
+ * @param {Function} [options.fetch] - Fetch implementation (defaults to global fetch)
17
+ * @param {string} [options.apiRoot] - GW2 API root URL
18
+ * @param {string} [options.lang] - Language code (default: "en")
19
+ */
20
+ constructor(options = {}) {
21
+ this._cache = options.cache || new MemoryCache();
22
+ this._fetch = options.fetch || globalThis.fetch;
23
+ this._apiRoot = options.apiRoot || GW2_API_ROOT;
24
+ this._lang = options.lang || "en";
25
+ this._queue = [];
26
+ this._activeRequests = 0;
27
+ }
28
+
29
+ async fetchJson(url) {
30
+ let lastError;
31
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
32
+ const res = await this._enqueue(() =>
33
+ this._fetch(url, {
34
+ headers: { "User-Agent": USER_AGENT },
35
+ })
36
+ );
37
+ if (res.ok) {
38
+ return res.json();
39
+ }
40
+ if (res.status === 429) {
41
+ await this._delay(RATE_LIMIT_DELAY_MS);
42
+ continue;
43
+ }
44
+ lastError = new Error(`HTTP ${res.status} ${res.statusText}: ${url}`);
45
+ if (res.status >= 500) {
46
+ await this._delay(500 * (attempt + 1));
47
+ continue;
48
+ }
49
+ throw lastError;
50
+ }
51
+ throw lastError;
52
+ }
53
+
54
+ async fetchByIds(endpoint, ids, lang) {
55
+ const dedupedIds = [...new Set(ids)];
56
+ const chunks = this._chunk(dedupedIds, MAX_IDS_PER_REQUEST);
57
+ const langParam = lang || this._lang;
58
+ const results = [];
59
+
60
+ for (const chunk of chunks) {
61
+ const url = `${this._apiRoot}${endpoint}?ids=${chunk.join(",")}&lang=${langParam}`;
62
+ const data = await this.fetchJson(url);
63
+ results.push(...data);
64
+ }
65
+
66
+ return results;
67
+ }
68
+
69
+ async fetchCached(key, url, ttlMs) {
70
+ const cached = await this._cache.get(key);
71
+ if (cached !== null) return cached;
72
+
73
+ const data = await this.fetchJson(url);
74
+ await this._cache.set(key, data, ttlMs);
75
+ return data;
76
+ }
77
+
78
+ _chunk(arr, size) {
79
+ const chunks = [];
80
+ for (let i = 0; i < arr.length; i += size) {
81
+ chunks.push(arr.slice(i, i + size));
82
+ }
83
+ return chunks;
84
+ }
85
+
86
+ _delay(ms) {
87
+ return new Promise((resolve) => setTimeout(resolve, ms));
88
+ }
89
+
90
+ _enqueue(fn) {
91
+ return new Promise((resolve, reject) => {
92
+ this._queue.push({ fn, resolve, reject });
93
+ this._drain();
94
+ });
95
+ }
96
+
97
+ _drain() {
98
+ while (this._queue.length > 0 && this._activeRequests < MAX_CONCURRENT) {
99
+ const { fn, resolve, reject } = this._queue.shift();
100
+ this._activeRequests++;
101
+ fn()
102
+ .then(resolve)
103
+ .catch(reject)
104
+ .finally(() => {
105
+ this._activeRequests--;
106
+ this._drain();
107
+ });
108
+ }
109
+ }
110
+ }
111
+
112
+ module.exports = {
113
+ Gw2ApiClient,
114
+ GW2_API_ROOT,
115
+ MAX_IDS_PER_REQUEST,
116
+ USER_AGENT,
117
+ };
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * @typedef {'pve'|'wvw'|'pvp'} GameMode
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} Fact
9
+ * @property {string} type - Fact type (Damage, Buff, AttributeAdjust, Recharge, etc.)
10
+ * @property {string} text - Display label
11
+ * @property {string} [icon] - Icon URL
12
+ * @property {number} [value] - Numeric value (AttributeAdjust, Number)
13
+ * @property {number} [duration] - Duration in seconds (Buff, Time)
14
+ * @property {number} [apply_count] - Stack count (Buff)
15
+ * @property {string} [status] - Buff/condition name (Buff)
16
+ * @property {number} [dmg_multiplier] - Damage coefficient (Damage)
17
+ * @property {number} [hit_count] - Number of hits (Damage)
18
+ * @property {number} [distance] - Distance/radius in units (Distance, Radius)
19
+ * @property {number} [percent] - Percentage value (Percent)
20
+ * @property {number} [coefficient] - Healing/barrier coefficient
21
+ * @property {string} [target] - Target attribute (AttributeAdjust, BuffConversion)
22
+ * @property {string} [source] - Source attribute (BuffConversion)
23
+ * @property {string} [finisher_type] - Combo finisher type (ComboFinisher)
24
+ * @property {string} [field_type] - Combo field type (ComboField)
25
+ * @property {boolean} [_splitFact] - Marked true when fact value comes from a balance split
26
+ * @property {boolean} [_traitedFact] - Marked true when fact is from traited_facts
27
+ * @property {boolean} [_newFact] - Marked true when fact was added by split (not in API)
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} ResolvedSkill
32
+ * @property {number} id - Skill ID
33
+ * @property {string} name - Skill name
34
+ * @property {string} description - Skill description
35
+ * @property {string} icon - Icon URL
36
+ * @property {string} [slot] - Slot type (Weapon_1-5, Heal, Utility, Elite, Profession_1-5)
37
+ * @property {number} [specialization] - Required specialization ID
38
+ * @property {string[]} [professions] - Professions that can use this skill
39
+ * @property {Fact[]} facts - Resolved facts for the requested game mode
40
+ * @property {Fact[]} [traited_facts] - Facts that change when specific traits are active
41
+ * @property {boolean} [hasSplit] - True if facts differ from PvE in this game mode
42
+ */
43
+
44
+ /**
45
+ * @typedef {Object} ResolvedTrait
46
+ * @property {number} id - Trait ID
47
+ * @property {string} name - Trait name
48
+ * @property {string} description - Trait description
49
+ * @property {string} icon - Icon URL
50
+ * @property {number} specialization - Specialization ID
51
+ * @property {number} tier - Trait tier (1=minor adept, 2=major adept, etc.)
52
+ * @property {number} order - Position in tier (0, 1, 2)
53
+ * @property {Fact[]} facts - Resolved facts for the requested game mode
54
+ * @property {Fact[]} [traited_facts] - Conditional facts
55
+ * @property {boolean} [hasSplit] - True if facts differ from PvE
56
+ */
57
+
58
+ /**
59
+ * @typedef {Object} SplitEntry
60
+ * @property {Fact[]} facts - Facts for this game mode
61
+ * @property {boolean} [complete] - If true, this is the full fact set (not partial)
62
+ */
63
+
64
+ /**
65
+ * @typedef {Object} WikiRelation
66
+ * @property {string} name - Related entity name
67
+ * @property {string} [icon] - Icon URL
68
+ * @property {string} [context] - Description of the relationship
69
+ */
70
+
71
+ /**
72
+ * @typedef {Object} CacheAdapter
73
+ * @property {(key: string) => any|null} get
74
+ * @property {(key: string, value: any, ttlMs: number) => void} set
75
+ * @property {(key: string) => void} invalidate
76
+ * @property {() => void} clear
77
+ * @property {(key: string) => boolean} has
78
+ */
79
+
80
+ module.exports = {};