@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,230 @@
1
+ "use strict";
2
+
3
+ const { MemoryCache } = require("./cache");
4
+ const {
5
+ parseSplitGrouping,
6
+ parseWikitextFacts,
7
+ parseInfoboxParams,
8
+ } = require("./parser");
9
+
10
+ const WIKI_API_ROOT = "https://wiki.guildwars2.com/api.php";
11
+ const USER_AGENT = "@axiapps/gw2-data (https://github.com/darkharasho/axiforge)";
12
+ const DEFAULT_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
13
+ const RATE_LIMIT_MS = 200;
14
+ const MISSING_SENTINEL = "__WIKI_MISSING__";
15
+
16
+ class WikiClient {
17
+ constructor(options = {}) {
18
+ this._cache = options.cache || new MemoryCache();
19
+ this._fetch = options.fetch || globalThis.fetch;
20
+ this._wikiApiRoot = options.wikiApiRoot || WIKI_API_ROOT;
21
+ this._cacheTTL = options.cacheTTL || DEFAULT_TTL_MS;
22
+ this._lastFetchTimestamp = null;
23
+ this._lastRequestTime = 0;
24
+ }
25
+
26
+ async _rateLimitedFetch(url) {
27
+ const now = Date.now();
28
+ const elapsed = now - this._lastRequestTime;
29
+ if (elapsed < RATE_LIMIT_MS) {
30
+ await new Promise((r) => setTimeout(r, RATE_LIMIT_MS - elapsed));
31
+ }
32
+ this._lastRequestTime = Date.now();
33
+ return this._fetch(url, {
34
+ headers: { "User-Agent": USER_AGENT },
35
+ });
36
+ }
37
+
38
+ async getWikitext(title) {
39
+ const cacheKey = `wikitext:${title}`;
40
+ const cached = await this._cache.get(cacheKey);
41
+ if (cached === MISSING_SENTINEL) return null;
42
+ if (cached !== null) return cached;
43
+
44
+ const url =
45
+ `${this._wikiApiRoot}?action=query&titles=${encodeURIComponent(title)}` +
46
+ `&prop=revisions&rvprop=content&format=json&formatversion=1`;
47
+
48
+ const res = await this._rateLimitedFetch(url);
49
+ if (!res.ok) return null;
50
+
51
+ const data = await res.json();
52
+ const pages = data.query?.pages;
53
+ if (!pages) return null;
54
+
55
+ const page = Object.values(pages)[0];
56
+ if (page.missing) {
57
+ await this._cache.set(cacheKey, MISSING_SENTINEL, this._cacheTTL);
58
+ return null;
59
+ }
60
+
61
+ const wikitext = page.revisions?.[0]?.["*"] || null;
62
+ if (wikitext) {
63
+ await this._cache.set(cacheKey, wikitext, this._cacheTTL);
64
+ }
65
+ return wikitext;
66
+ }
67
+
68
+ /**
69
+ * Batch-fetch wikitext for multiple page titles.
70
+ * Uses MediaWiki's multi-title query (up to 50 per request).
71
+ * Checks cache first; only uncached titles are fetched.
72
+ *
73
+ * @param {string[]} titles
74
+ * @returns {Promise<Map<string, string|null>>} title → wikitext (null if page missing)
75
+ */
76
+ async getWikitextBatch(titles) {
77
+ const BATCH_SIZE = 50;
78
+ const result = new Map();
79
+
80
+ // Check cache first, collect uncached titles
81
+ const uncached = [];
82
+ for (const title of titles) {
83
+ const cacheKey = `wikitext:${title}`;
84
+ const cached = await this._cache.get(cacheKey);
85
+ if (cached === MISSING_SENTINEL) {
86
+ result.set(title, null);
87
+ } else if (cached !== null) {
88
+ result.set(title, cached);
89
+ } else {
90
+ uncached.push(title);
91
+ }
92
+ }
93
+
94
+ // Fetch uncached in batches of 50
95
+ for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
96
+ const batch = uncached.slice(i, i + BATCH_SIZE);
97
+ const titlesParam = batch.map((t) => encodeURIComponent(t)).join("|");
98
+ const url =
99
+ `${this._wikiApiRoot}?action=query&titles=${titlesParam}` +
100
+ `&prop=revisions&rvprop=content&format=json&formatversion=1`;
101
+
102
+ const res = await this._fetch(url, {
103
+ headers: { "User-Agent": USER_AGENT },
104
+ });
105
+
106
+ if (!res.ok) {
107
+ // Mark all in this batch as null
108
+ for (const title of batch) {
109
+ result.set(title, null);
110
+ }
111
+ continue;
112
+ }
113
+
114
+ const data = await res.json();
115
+ const pages = data.query?.pages || {};
116
+
117
+ // Build a normalized-title lookup from the API response
118
+ const normalized = new Map();
119
+ for (const n of data.query?.normalized || []) {
120
+ normalized.set(n.to, n.from);
121
+ }
122
+
123
+ // Map response pages back to requested titles
124
+ const responseByTitle = new Map();
125
+ for (const page of Object.values(pages)) {
126
+ const responseTitle = page.title;
127
+ const wikitext = page.missing ? null : (page.revisions?.[0]?.["*"] || null);
128
+ responseByTitle.set(responseTitle, wikitext);
129
+ }
130
+
131
+ for (const title of batch) {
132
+ // MediaWiki may normalize the title (e.g. underscores → spaces)
133
+ // Try exact match first, then check if our title was the "from" in normalized
134
+ let wikitext = responseByTitle.get(title);
135
+ if (wikitext === undefined) {
136
+ // Check if this title was normalized to something else
137
+ for (const [to, from] of normalized) {
138
+ if (from === title) {
139
+ wikitext = responseByTitle.get(to);
140
+ break;
141
+ }
142
+ }
143
+ }
144
+ if (wikitext === undefined) wikitext = null;
145
+
146
+ result.set(title, wikitext);
147
+ const cacheKey = `wikitext:${title}`;
148
+ if (wikitext !== null) {
149
+ await this._cache.set(cacheKey, wikitext, this._cacheTTL);
150
+ } else {
151
+ await this._cache.set(cacheKey, MISSING_SENTINEL, this._cacheTTL);
152
+ }
153
+ }
154
+ }
155
+
156
+ return result;
157
+ }
158
+
159
+ /**
160
+ * Search for wiki pages whose titles start with a given prefix.
161
+ * Uses MediaWiki's prefixsearch API.
162
+ *
163
+ * @param {string} prefix - Title prefix to search for
164
+ * @param {number} [limit=10] - Max results
165
+ * @returns {Promise<string[]>} Array of matching page titles
166
+ */
167
+ async prefixSearch(prefix, limit = 10) {
168
+ const url =
169
+ `${this._wikiApiRoot}?action=query&list=prefixsearch` +
170
+ `&pssearch=${encodeURIComponent(prefix)}&pslimit=${limit}` +
171
+ `&format=json`;
172
+
173
+ const res = await this._rateLimitedFetch(url);
174
+ if (!res.ok) return [];
175
+
176
+ const data = await res.json();
177
+ const results = data.query?.prefixsearch || [];
178
+ return results.map((r) => r.title);
179
+ }
180
+
181
+ async getRecentChanges(since) {
182
+ const url =
183
+ `${this._wikiApiRoot}?action=query&list=recentchanges` +
184
+ `&rcnamespace=0&rcprop=title|timestamp&rclimit=500` +
185
+ `&rcend=${encodeURIComponent(since)}&format=json`;
186
+
187
+ const res = await this._rateLimitedFetch(url);
188
+ if (!res.ok) return [];
189
+
190
+ const data = await res.json();
191
+ const changes = data.query?.recentchanges || [];
192
+ return [...new Set(changes.map((c) => c.title))];
193
+ }
194
+
195
+ async refresh() {
196
+ if (!this._lastFetchTimestamp) {
197
+ this._lastFetchTimestamp = new Date().toISOString();
198
+ return [];
199
+ }
200
+
201
+ const changed = await this.getRecentChanges(this._lastFetchTimestamp);
202
+ for (const title of changed) {
203
+ await this._cache.invalidate(`wikitext:${title}`);
204
+ await this._cache.invalidate(`facts:${title}`);
205
+ }
206
+ this._lastFetchTimestamp = new Date().toISOString();
207
+ return changed;
208
+ }
209
+
210
+ parseFacts(wikitext) {
211
+ const splitMatch = wikitext.match(/\|\s*split\s*=\s*(.+)/i);
212
+ let splitGrouping = null;
213
+ let wvwGroupedWithPvp = false;
214
+
215
+ if (splitMatch) {
216
+ splitGrouping = parseSplitGrouping(splitMatch[1].trim());
217
+ wvwGroupedWithPvp = splitGrouping.wvwGroupedWithPvp;
218
+ }
219
+
220
+ let { facts, hasPveOnly } = parseWikitextFacts(wikitext, wvwGroupedWithPvp);
221
+
222
+ if (facts.length === 0 && splitGrouping?.wvwHasSplit) {
223
+ facts = parseInfoboxParams(wikitext, wvwGroupedWithPvp);
224
+ }
225
+
226
+ return { facts, hasPveOnly, splitGrouping };
227
+ }
228
+ }
229
+
230
+ module.exports = { WikiClient, WIKI_API_ROOT };