@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.
- package/data/overrides.json +25 -0
- package/package.json +32 -0
- package/scripts/generate-fixtures.js +242 -0
- package/src/api/client.js +117 -0
- package/src/api/types.js +80 -0
- package/src/engine/attributes.js +525 -0
- package/src/engine/boons.js +156 -0
- package/src/engine/combos.js +103 -0
- package/src/engine/constants.js +298 -0
- package/src/engine/graph.js +24 -0
- package/src/engine/index.js +82 -0
- package/src/engine/modifiers.js +204 -0
- package/src/engine/overrides.js +13 -0
- package/src/engine/tooltips.js +59 -0
- package/src/facts/match.js +134 -0
- package/src/facts/merge.js +45 -0
- package/src/facts/normalize.js +27 -0
- package/src/index.js +60 -0
- package/src/wiki/cache.js +103 -0
- package/src/wiki/client.js +230 -0
- package/src/wiki/parser.js +599 -0
- package/src/wiki/relations.js +55 -0
- package/src/wiki/resolver.js +352 -0
- package/tests/api-client.test.js +138 -0
- package/tests/cache.test.js +108 -0
- package/tests/engine/attributes.test.js +252 -0
- package/tests/engine/boons.test.js +129 -0
- package/tests/engine/combos.test.js +76 -0
- package/tests/engine/constants.test.js +576 -0
- package/tests/engine/fixtures/berserker-thief.json +61 -0
- package/tests/engine/fixtures/berserker-warrior.json +113 -0
- package/tests/engine/fixtures/celestial-firebrand-wvw.json +94 -0
- package/tests/engine/fixtures/harrier-druid.json +119 -0
- package/tests/engine/fixtures/viper-mirage.json +104 -0
- package/tests/engine/graph.test.js +30 -0
- package/tests/engine/integration.test.js +111 -0
- package/tests/engine/modifiers.test.js +473 -0
- package/tests/engine/overrides.test.js +70 -0
- package/tests/engine/snapshot.test.js +53 -0
- package/tests/engine/test-utils.js +20 -0
- package/tests/engine/tooltips.test.js +62 -0
- package/tests/fixtures/capture.js +160 -0
- package/tests/fixtures/fixtures.json +839 -0
- package/tests/integration.test.js +100 -0
- package/tests/match.test.js +176 -0
- package/tests/merge.test.js +128 -0
- package/tests/normalize.test.js +78 -0
- package/tests/parser.test.js +506 -0
- package/tests/real-data.test.js +296 -0
- package/tests/relations.test.js +80 -0
- package/tests/resolver.test.js +721 -0
- package/tests/validate-live.js +191 -0
- package/tests/wiki-client.test.js +468 -0
- package/tests/wiki-integration.test.js +177 -0
- package/tests/wiki-live-validation.test.js +61 -0
- package/tests/wiki-snapshots.test.js +166 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { stripWikiMarkup } = require("../facts/normalize");
|
|
4
|
+
|
|
5
|
+
// Wiki uses human-readable attribute names; normalize to API stat keys.
|
|
6
|
+
const WIKI_ATTR_MAP = {
|
|
7
|
+
"Condition Damage": "ConditionDamage",
|
|
8
|
+
"Healing Power": "HealingPower",
|
|
9
|
+
"Boon Duration": "BoonDuration",
|
|
10
|
+
"Condition Duration": "ConditionDuration",
|
|
11
|
+
"Critical Damage": "CritDamage",
|
|
12
|
+
};
|
|
13
|
+
function normalizeAttr(name) {
|
|
14
|
+
return WIKI_ATTR_MAP[name] || name;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Fact types to silently ignore
|
|
18
|
+
const SKIP_TYPES = new Set([
|
|
19
|
+
"text",
|
|
20
|
+
"pierces",
|
|
21
|
+
"explosion",
|
|
22
|
+
"blocks missiles",
|
|
23
|
+
"reflect",
|
|
24
|
+
"block",
|
|
25
|
+
"combat",
|
|
26
|
+
"combat only",
|
|
27
|
+
"enemy",
|
|
28
|
+
"ally",
|
|
29
|
+
"condition effect ignored",
|
|
30
|
+
"condition removed",
|
|
31
|
+
"breaks enemy targeting",
|
|
32
|
+
"cannot critical hit",
|
|
33
|
+
"capture",
|
|
34
|
+
"dismounts",
|
|
35
|
+
"misc",
|
|
36
|
+
"blade",
|
|
37
|
+
"launch",
|
|
38
|
+
"knockback",
|
|
39
|
+
"pull",
|
|
40
|
+
"knockdown",
|
|
41
|
+
"float",
|
|
42
|
+
"sink",
|
|
43
|
+
"daze",
|
|
44
|
+
"stun",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const BOONS = new Set([
|
|
48
|
+
"aegis",
|
|
49
|
+
"alacrity",
|
|
50
|
+
"fury",
|
|
51
|
+
"might",
|
|
52
|
+
"protection",
|
|
53
|
+
"quickness",
|
|
54
|
+
"regeneration",
|
|
55
|
+
"resistance",
|
|
56
|
+
"resolution",
|
|
57
|
+
"stability",
|
|
58
|
+
"swiftness",
|
|
59
|
+
"vigor",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const CONDITIONS = new Set([
|
|
63
|
+
"bleeding",
|
|
64
|
+
"blind",
|
|
65
|
+
"burning",
|
|
66
|
+
"chilled",
|
|
67
|
+
"confusion",
|
|
68
|
+
"crippled",
|
|
69
|
+
"fear",
|
|
70
|
+
"immobilize",
|
|
71
|
+
"poison",
|
|
72
|
+
"slow",
|
|
73
|
+
"taunt",
|
|
74
|
+
"torment",
|
|
75
|
+
"vulnerability",
|
|
76
|
+
"weakness",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
// Wiki fact types whose numeric value is a percentage (e.g. "damage reduction|33" → 33%).
|
|
80
|
+
// These fall through to the generic Number handler without this set, losing the % symbol.
|
|
81
|
+
const PERCENT_TYPES = new Set([
|
|
82
|
+
"critical chance",
|
|
83
|
+
"critical chance increase",
|
|
84
|
+
"damage increase",
|
|
85
|
+
"damage reduction",
|
|
86
|
+
"condition damage increase",
|
|
87
|
+
"condition duration increase",
|
|
88
|
+
"condition duration reduction",
|
|
89
|
+
"attack speed increase",
|
|
90
|
+
"movement speed increase",
|
|
91
|
+
"movement speed reduction",
|
|
92
|
+
"endurance recovery rate",
|
|
93
|
+
"healing effectiveness increase",
|
|
94
|
+
"healing effectiveness reduction",
|
|
95
|
+
"incoming damage increase",
|
|
96
|
+
"incoming damage reduction",
|
|
97
|
+
"incoming healing increase",
|
|
98
|
+
"incoming healing reduction",
|
|
99
|
+
"life force cost",
|
|
100
|
+
"life force drain",
|
|
101
|
+
"outgoing damage increase",
|
|
102
|
+
"outgoing damage reduction",
|
|
103
|
+
"outgoing healing increase",
|
|
104
|
+
"outgoing healing reduction",
|
|
105
|
+
"strike damage increase",
|
|
106
|
+
"strike damage reduction",
|
|
107
|
+
"condition damage reduction",
|
|
108
|
+
"boon duration increase",
|
|
109
|
+
"maximum health increase",
|
|
110
|
+
"maximum health reduction",
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Split a string on `|` while respecting `{{...}}` nesting.
|
|
115
|
+
* @param {string} s
|
|
116
|
+
* @returns {string[]}
|
|
117
|
+
*/
|
|
118
|
+
function splitRespectingTemplates(s) {
|
|
119
|
+
const parts = [];
|
|
120
|
+
let depth = 0;
|
|
121
|
+
let current = "";
|
|
122
|
+
|
|
123
|
+
for (let i = 0; i < s.length; i++) {
|
|
124
|
+
const ch = s[i];
|
|
125
|
+
if (ch === "{" && s[i + 1] === "{") {
|
|
126
|
+
depth++;
|
|
127
|
+
current += "{{";
|
|
128
|
+
i++; // skip second {
|
|
129
|
+
} else if (ch === "}" && s[i + 1] === "}") {
|
|
130
|
+
depth--;
|
|
131
|
+
current += "}}";
|
|
132
|
+
i++; // skip second }
|
|
133
|
+
} else if (ch === "|" && depth === 0) {
|
|
134
|
+
parts.push(current);
|
|
135
|
+
current = "";
|
|
136
|
+
} else {
|
|
137
|
+
current += ch;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
parts.push(current);
|
|
141
|
+
return parts;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Analyze a `| split = ...` field value to determine WvW grouping.
|
|
146
|
+
* @param {string} splitField e.g. "pve, wvw, pvp" or "pve, wvw pvp"
|
|
147
|
+
* @returns {{ wvwHasSplit: boolean, wvwGroupedWithPvp: boolean }}
|
|
148
|
+
*/
|
|
149
|
+
function parseSplitGrouping(splitField) {
|
|
150
|
+
if (!splitField) return { wvwHasSplit: false, wvwGroupedWithPvp: false };
|
|
151
|
+
|
|
152
|
+
// Each comma-separated token is a "group" (possibly space-separated modes)
|
|
153
|
+
const groups = splitField.split(",").map((g) => g.trim().toLowerCase());
|
|
154
|
+
|
|
155
|
+
// Does WvW appear alone in its own comma-separated group?
|
|
156
|
+
const wvwAloneGroup = groups.find((g) => g === "wvw");
|
|
157
|
+
// Does WvW appear grouped with PvP (same comma-group)?
|
|
158
|
+
const wvwPvpGroup = groups.find((g) => g.includes("wvw") && g.includes("pvp"));
|
|
159
|
+
// Does WvW appear grouped with PvE (same comma-group) but NOT alone?
|
|
160
|
+
const wvwPveGroup = groups.find(
|
|
161
|
+
(g) => g.includes("wvw") && g.includes("pve") && !g.includes("pvp")
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
if (wvwAloneGroup) {
|
|
165
|
+
return { wvwHasSplit: true, wvwGroupedWithPvp: false };
|
|
166
|
+
}
|
|
167
|
+
if (wvwPvpGroup) {
|
|
168
|
+
return { wvwHasSplit: true, wvwGroupedWithPvp: true };
|
|
169
|
+
}
|
|
170
|
+
if (wvwPveGroup) {
|
|
171
|
+
// WvW grouped with PvE means no real WvW split
|
|
172
|
+
return { wvwHasSplit: false, wvwGroupedWithPvp: false };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { wvwHasSplit: false, wvwGroupedWithPvp: false };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Convert a single wiki fact into GW2 API fact format.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} factType - e.g. "damage", "recharge", "might"
|
|
182
|
+
* @param {string[]} positional - positional pipe args (stripped)
|
|
183
|
+
* @param {Object} params - named params e.g. { coefficient: "0.8", hits: "3" }
|
|
184
|
+
* @param {boolean} isWvw - whether this is WvW context
|
|
185
|
+
* @param {boolean} isUniversal - whether fact applies to all modes
|
|
186
|
+
* @returns {Object|null}
|
|
187
|
+
*/
|
|
188
|
+
function mapWikiFactToApiFact(factType, positional, params, isWvw, isUniversal) {
|
|
189
|
+
const type = factType.toLowerCase().trim();
|
|
190
|
+
|
|
191
|
+
if (SKIP_TYPES.has(type)) return null;
|
|
192
|
+
|
|
193
|
+
// Helper to get numeric positional[0]
|
|
194
|
+
const pos0Num = () => parseFloat(stripWikiMarkup(positional[0]) || "0");
|
|
195
|
+
|
|
196
|
+
// ── Damage ───────────────────────────────────────────────────────────
|
|
197
|
+
if (type === "damage") {
|
|
198
|
+
// Coefficient may come as a named param or as positional[0]
|
|
199
|
+
const rawCoefficient = params.coefficient || positional[0] || "0";
|
|
200
|
+
const coefficient = parseFloat(stripWikiMarkup(rawCoefficient));
|
|
201
|
+
const hits = parseInt(stripWikiMarkup(params.hits) || "1", 10);
|
|
202
|
+
return {
|
|
203
|
+
type: "Damage",
|
|
204
|
+
text: "Damage",
|
|
205
|
+
dmg_multiplier: coefficient,
|
|
206
|
+
hit_count: hits,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Recharge / Cooldown ───────────────────────────────────────────────
|
|
211
|
+
if (
|
|
212
|
+
type === "recharge" ||
|
|
213
|
+
type === "cooldown" ||
|
|
214
|
+
type === "recharge time"
|
|
215
|
+
) {
|
|
216
|
+
return { type: "Recharge", text: "Recharge", value: pos0Num() };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Duration (standalone) ─────────────────────────────────────────────
|
|
220
|
+
if (type === "duration" || type === "duration alt") {
|
|
221
|
+
return { type: "Time", text: "Duration", duration: pos0Num() };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Range ─────────────────────────────────────────────────────────────
|
|
225
|
+
if (type === "range") {
|
|
226
|
+
const val = pos0Num();
|
|
227
|
+
if (val <= 1) return null; // boolean flag artifact
|
|
228
|
+
return { type: "Range", text: "Range", value: val };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Radius variants ───────────────────────────────────────────────────
|
|
232
|
+
if (
|
|
233
|
+
type === "radius" ||
|
|
234
|
+
type === "blast radius" ||
|
|
235
|
+
type === "healing radius" ||
|
|
236
|
+
type === "barrier radius"
|
|
237
|
+
) {
|
|
238
|
+
return { type: "Radius", text: "Radius", distance: pos0Num() };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Targets ───────────────────────────────────────────────────────────
|
|
242
|
+
if (type === "targets") {
|
|
243
|
+
return { type: "Number", text: "Number of Targets", value: pos0Num() };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Conditions Removed ────────────────────────────────────────────────
|
|
247
|
+
if (type === "conditions removed") {
|
|
248
|
+
return { type: "Number", text: "Conditions Removed", value: pos0Num() };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Healing ───────────────────────────────────────────────────────────
|
|
252
|
+
if (type === "healing") {
|
|
253
|
+
// Base may come as named param or positional[0]: {{skill fact|healing|372|coefficient=0.25}}
|
|
254
|
+
const base = parseInt(stripWikiMarkup(params.base || positional[0]) || "0", 10);
|
|
255
|
+
const coefficient = parseFloat(stripWikiMarkup(params.coefficient) || "0");
|
|
256
|
+
return {
|
|
257
|
+
type: "AttributeAdjust",
|
|
258
|
+
text: "Healing",
|
|
259
|
+
target: "Healing",
|
|
260
|
+
value: base,
|
|
261
|
+
coefficient,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Barrier ───────────────────────────────────────────────────────────
|
|
266
|
+
if (type === "barrier") {
|
|
267
|
+
const base = parseInt(stripWikiMarkup(params.base || positional[0]) || "0", 10);
|
|
268
|
+
const coefficient = parseFloat(stripWikiMarkup(params.coefficient) || "0");
|
|
269
|
+
return {
|
|
270
|
+
type: "AttributeAdjust",
|
|
271
|
+
text: "Barrier",
|
|
272
|
+
target: "Barrier",
|
|
273
|
+
value: base,
|
|
274
|
+
coefficient,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Defiance / CC ─────────────────────────────────────────────────────
|
|
279
|
+
if (type === "defiance break" || type === "defiance bar") {
|
|
280
|
+
return { type: "Number", text: "Defiance Break", value: pos0Num() };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Percent / Recharge Reduced ────────────────────────────────────────
|
|
284
|
+
if (type === "percent" || type === "recharge reduced") {
|
|
285
|
+
return { type: "Percent", text: type === "recharge reduced" ? "Recharge Reduced" : "Percent", percent: pos0Num() };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Combo ─────────────────────────────────────────────────────────────
|
|
289
|
+
if (type === "combo") {
|
|
290
|
+
const val = positional[0] ? stripWikiMarkup(positional[0]).trim() : "";
|
|
291
|
+
// If it's a field name (water, fire, light, etc.) treat as ComboField
|
|
292
|
+
const FINISHERS = new Set(["blast", "leap", "projectile", "whirl"]);
|
|
293
|
+
const lowerVal = val.toLowerCase();
|
|
294
|
+
if (FINISHERS.has(lowerVal)) {
|
|
295
|
+
return {
|
|
296
|
+
type: "ComboFinisher",
|
|
297
|
+
text: "Combo Finisher",
|
|
298
|
+
finisher_type: val.charAt(0).toUpperCase() + val.slice(1),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
// Otherwise it's a combo field
|
|
302
|
+
return {
|
|
303
|
+
type: "ComboField",
|
|
304
|
+
text: "Combo Field",
|
|
305
|
+
field_type: val.charAt(0).toUpperCase() + val.slice(1),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Stun Break ────────────────────────────────────────────────────────
|
|
310
|
+
if (type === "stun break" || type === "breaks stun" || type === "breakstun") {
|
|
311
|
+
return { type: "StunBreak", text: "Stun Break", value: true };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Unblockable ───────────────────────────────────────────────────────
|
|
315
|
+
if (type === "unblockable") {
|
|
316
|
+
return { type: "Unblockable", text: "Unblockable", value: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Effect ────────────────────────────────────────────────────────────
|
|
320
|
+
// {{skill fact|effect|Signet of Fury (effect)|desc=180 [[Precision]]|effect bonus=Precision}}
|
|
321
|
+
// {{skill fact|effect|Signet of Fury (effect)|alt=Active Bonus|4|desc=360 [[Precision]], 360 [[Ferocity]]}}
|
|
322
|
+
if (type === "effect") {
|
|
323
|
+
const status = positional[0] ? stripWikiMarkup(positional[0]).trim() : "";
|
|
324
|
+
const duration = parseFloat(stripWikiMarkup(positional[1]) || "0");
|
|
325
|
+
const alt = params.alt ? stripWikiMarkup(params.alt).trim() : "";
|
|
326
|
+
const desc = params.desc ? stripWikiMarkup(params.desc).trim() : "";
|
|
327
|
+
const fact = {
|
|
328
|
+
type: "Buff",
|
|
329
|
+
text: alt || status,
|
|
330
|
+
status,
|
|
331
|
+
duration,
|
|
332
|
+
apply_count: parseInt(stripWikiMarkup(params.stacks) || "1", 10),
|
|
333
|
+
};
|
|
334
|
+
if (desc) fact.description = desc;
|
|
335
|
+
return fact;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Attribute Conversion (gain) ──────────────────────────────────────
|
|
339
|
+
// Named: {{skill fact|gain|source=Power|target=Condition Damage|percent=10}}
|
|
340
|
+
// Positional: {{skill fact|Gain|Ferocity|Precision|12}} (target, source, percent)
|
|
341
|
+
if (type === "gain") {
|
|
342
|
+
const source = normalizeAttr(stripWikiMarkup(params.source || positional[1]) || "");
|
|
343
|
+
const target = normalizeAttr(stripWikiMarkup(params.target || positional[0]) || "");
|
|
344
|
+
const percent = parseFloat(stripWikiMarkup(params.percent || positional[2]) || "0");
|
|
345
|
+
return {
|
|
346
|
+
type: "BuffConversion",
|
|
347
|
+
text: "Attribute Conversion",
|
|
348
|
+
source,
|
|
349
|
+
target,
|
|
350
|
+
percent,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── Flat attribute bonus ──────────────────────────────────────────────
|
|
355
|
+
// {{skill fact|attribute|Concentration|120}}
|
|
356
|
+
if (type === "attribute") {
|
|
357
|
+
const attrName = stripWikiMarkup(positional[0]) || "";
|
|
358
|
+
const value = parseInt(stripWikiMarkup(positional[1] || params.base) || "0", 10);
|
|
359
|
+
return {
|
|
360
|
+
type: "AttributeAdjust",
|
|
361
|
+
text: attrName,
|
|
362
|
+
target: normalizeAttr(attrName),
|
|
363
|
+
value,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── Boons ─────────────────────────────────────────────────────────────
|
|
368
|
+
if (BOONS.has(type)) {
|
|
369
|
+
const statusName = type.charAt(0).toUpperCase() + type.slice(1);
|
|
370
|
+
return {
|
|
371
|
+
type: "Buff",
|
|
372
|
+
text: statusName,
|
|
373
|
+
status: statusName,
|
|
374
|
+
duration: pos0Num(),
|
|
375
|
+
apply_count: parseInt(stripWikiMarkup(params.stacks) || "1", 10),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── Conditions ────────────────────────────────────────────────────────
|
|
380
|
+
if (CONDITIONS.has(type)) {
|
|
381
|
+
const statusName = type.charAt(0).toUpperCase() + type.slice(1);
|
|
382
|
+
return {
|
|
383
|
+
type: "Buff",
|
|
384
|
+
text: statusName,
|
|
385
|
+
status: statusName,
|
|
386
|
+
duration: pos0Num(),
|
|
387
|
+
apply_count: parseInt(stripWikiMarkup(params.stacks) || "1", 10),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── Known percentage fact types ────────────────────────────────────
|
|
392
|
+
// Many wiki fact types carry a percentage value (e.g. "damage reduction|33").
|
|
393
|
+
// Without this, they fall through to generic Number and lose the % suffix.
|
|
394
|
+
if (PERCENT_TYPES.has(type)) {
|
|
395
|
+
const label = type.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
396
|
+
return { type: "Percent", text: label, percent: pos0Num() };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Unknown but has a numeric value ────────────────────────────────
|
|
400
|
+
if (positional[0] && !isNaN(parseFloat(stripWikiMarkup(positional[0])))) {
|
|
401
|
+
const label = type.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
402
|
+
// Heuristic: fact type names containing "increase", "reduction", "chance",
|
|
403
|
+
// or "rate" are almost always percentages in GW2 wiki templates.
|
|
404
|
+
if (/(?:increase|reduction|chance|rate|cost|drain)$/.test(type)) {
|
|
405
|
+
return { type: "Percent", text: label, percent: pos0Num() };
|
|
406
|
+
}
|
|
407
|
+
return { type: "Number", text: label, value: pos0Num() };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Parse `{{skill fact|...}}` and `{{trait fact|...}}` templates from wikitext.
|
|
415
|
+
*
|
|
416
|
+
* @param {string} wikitext
|
|
417
|
+
* @param {boolean} wvwGroupedWithPvp
|
|
418
|
+
* @returns {{ facts: Object[], hasPveOnly: boolean }}
|
|
419
|
+
*/
|
|
420
|
+
function parseWikitextFacts(wikitext, wvwGroupedWithPvp) {
|
|
421
|
+
const facts = [];
|
|
422
|
+
let hasPveOnly = false;
|
|
423
|
+
|
|
424
|
+
// Match all skill fact / trait fact templates
|
|
425
|
+
const templateRe = /\{\{(?:skill|trait) fact\|([^{}]*(?:\{\{[^{}]*\}\}[^{}]*)*)\}\}/gi;
|
|
426
|
+
let match;
|
|
427
|
+
|
|
428
|
+
while ((match = templateRe.exec(wikitext)) !== null) {
|
|
429
|
+
const inner = match[1];
|
|
430
|
+
const parts = splitRespectingTemplates(inner);
|
|
431
|
+
|
|
432
|
+
// First positional is the fact type
|
|
433
|
+
const factType = (parts[0] || "").trim().toLowerCase();
|
|
434
|
+
|
|
435
|
+
// Parse named params and remaining positionals
|
|
436
|
+
const positional = [];
|
|
437
|
+
const params = {};
|
|
438
|
+
|
|
439
|
+
for (let i = 1; i < parts.length; i++) {
|
|
440
|
+
const part = parts[i];
|
|
441
|
+
const eqIdx = part.indexOf("=");
|
|
442
|
+
if (eqIdx !== -1) {
|
|
443
|
+
const key = part.slice(0, eqIdx).trim().toLowerCase();
|
|
444
|
+
const val = part.slice(eqIdx + 1).trim();
|
|
445
|
+
params[key] = val;
|
|
446
|
+
} else {
|
|
447
|
+
positional.push(part.trim());
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Determine game mode filter.
|
|
452
|
+
// Real wiki data uses compound modes like "pvp wvw" or "wvw pvp" meaning
|
|
453
|
+
// "this fact applies to both PvP and WvW".
|
|
454
|
+
const gameMode = (params["game mode"] || "").toLowerCase().trim();
|
|
455
|
+
const gameModeTokens = gameMode ? gameMode.split(/\s+/) : [];
|
|
456
|
+
|
|
457
|
+
const mentionsWvw = gameModeTokens.includes("wvw");
|
|
458
|
+
const mentionsPvp = gameModeTokens.includes("pvp");
|
|
459
|
+
const mentionsPve = gameModeTokens.includes("pve");
|
|
460
|
+
const isUniversal = gameModeTokens.length === 0;
|
|
461
|
+
|
|
462
|
+
// Track PvE-only facts
|
|
463
|
+
if (mentionsPve && !mentionsWvw && !mentionsPvp) {
|
|
464
|
+
hasPveOnly = true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Decide whether to include this fact in WvW output:
|
|
468
|
+
// - universal facts (no game mode): always include
|
|
469
|
+
// - facts mentioning wvw: include
|
|
470
|
+
// - facts mentioning only pvp: include only if wvwGroupedWithPvp
|
|
471
|
+
// - facts mentioning only pve: skip
|
|
472
|
+
let include = false;
|
|
473
|
+
if (isUniversal) include = true;
|
|
474
|
+
else if (mentionsWvw) include = true;
|
|
475
|
+
else if (mentionsPvp && wvwGroupedWithPvp) include = true;
|
|
476
|
+
|
|
477
|
+
if (!include) continue;
|
|
478
|
+
|
|
479
|
+
// Strip wiki markup from positionals
|
|
480
|
+
const cleanPositionals = positional.map((p) => stripWikiMarkup(p));
|
|
481
|
+
|
|
482
|
+
const fact = mapWikiFactToApiFact(factType, cleanPositionals, params, mentionsWvw, isUniversal);
|
|
483
|
+
if (fact) facts.push(fact);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return { facts, hasPveOnly };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Parse all {{skill fact|...}} / {{trait fact|...}} templates, tagging each
|
|
491
|
+
* with the game modes it applies to.
|
|
492
|
+
*
|
|
493
|
+
* @param {string} wikitext
|
|
494
|
+
* @returns {{ facts: Object[], hasPveOnly: boolean }}
|
|
495
|
+
* Each fact has `_modes: string[]` — subset of ["pve", "wvw", "pvp"].
|
|
496
|
+
* Universal facts (no game mode tag) get _modes: ["pve", "wvw", "pvp"].
|
|
497
|
+
*/
|
|
498
|
+
function parseAllTaggedFacts(wikitext) {
|
|
499
|
+
const facts = [];
|
|
500
|
+
let hasPveOnly = false;
|
|
501
|
+
|
|
502
|
+
const templateRe = /\{\{(?:skill|trait) fact\|([^{}]*(?:\{\{[^{}]*\}\}[^{}]*)*)\}\}/gi;
|
|
503
|
+
let match;
|
|
504
|
+
|
|
505
|
+
while ((match = templateRe.exec(wikitext)) !== null) {
|
|
506
|
+
const inner = match[1];
|
|
507
|
+
const parts = splitRespectingTemplates(inner);
|
|
508
|
+
|
|
509
|
+
const factType = (parts[0] || "").trim().toLowerCase();
|
|
510
|
+
|
|
511
|
+
const positional = [];
|
|
512
|
+
const params = {};
|
|
513
|
+
|
|
514
|
+
for (let i = 1; i < parts.length; i++) {
|
|
515
|
+
const part = parts[i];
|
|
516
|
+
const eqIdx = part.indexOf("=");
|
|
517
|
+
if (eqIdx !== -1) {
|
|
518
|
+
const key = part.slice(0, eqIdx).trim().toLowerCase();
|
|
519
|
+
const val = part.slice(eqIdx + 1).trim();
|
|
520
|
+
params[key] = val;
|
|
521
|
+
} else {
|
|
522
|
+
positional.push(part.trim());
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const gameMode = (params["game mode"] || "").toLowerCase().trim();
|
|
527
|
+
const gameModeTokens = gameMode ? gameMode.split(/\s+/) : [];
|
|
528
|
+
|
|
529
|
+
const mentionsWvw = gameModeTokens.includes("wvw");
|
|
530
|
+
const mentionsPvp = gameModeTokens.includes("pvp");
|
|
531
|
+
const mentionsPve = gameModeTokens.includes("pve");
|
|
532
|
+
const isUniversal = gameModeTokens.length === 0;
|
|
533
|
+
|
|
534
|
+
if (mentionsPve && !mentionsWvw && !mentionsPvp) {
|
|
535
|
+
hasPveOnly = true;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Build _modes array
|
|
539
|
+
const modes = [];
|
|
540
|
+
if (isUniversal) {
|
|
541
|
+
modes.push("pve", "wvw", "pvp");
|
|
542
|
+
} else {
|
|
543
|
+
if (mentionsPve) modes.push("pve");
|
|
544
|
+
if (mentionsWvw) modes.push("wvw");
|
|
545
|
+
if (mentionsPvp) modes.push("pvp");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const cleanPositionals = positional.map((p) => stripWikiMarkup(p));
|
|
549
|
+
const fact = mapWikiFactToApiFact(factType, cleanPositionals, params, mentionsWvw, isUniversal);
|
|
550
|
+
if (fact) {
|
|
551
|
+
fact._modes = modes;
|
|
552
|
+
facts.push(fact);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return { facts, hasPveOnly };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Fallback parser for infobox-style params like `| recharge wvw = 25`.
|
|
561
|
+
*
|
|
562
|
+
* @param {string} wikitext
|
|
563
|
+
* @param {boolean} wvwGroupedWithPvp
|
|
564
|
+
* @returns {Object[]}
|
|
565
|
+
*/
|
|
566
|
+
function parseInfoboxParams(wikitext, wvwGroupedWithPvp) {
|
|
567
|
+
const facts = [];
|
|
568
|
+
const suffix = wvwGroupedWithPvp ? "pvp" : "wvw";
|
|
569
|
+
|
|
570
|
+
// Match lines like: | recharge wvw = 25
|
|
571
|
+
const lineRe = /\|\s*(recharge)\s+(\w+)\s*=\s*([^\n|]+)/gi;
|
|
572
|
+
let match;
|
|
573
|
+
|
|
574
|
+
while ((match = lineRe.exec(wikitext)) !== null) {
|
|
575
|
+
const paramName = match[1].toLowerCase();
|
|
576
|
+
const mode = match[2].toLowerCase();
|
|
577
|
+
const rawValue = match[3].trim();
|
|
578
|
+
|
|
579
|
+
if (mode !== suffix) continue;
|
|
580
|
+
|
|
581
|
+
const value = parseFloat(stripWikiMarkup(rawValue));
|
|
582
|
+
if (isNaN(value)) continue;
|
|
583
|
+
|
|
584
|
+
if (paramName === "recharge") {
|
|
585
|
+
facts.push({ type: "Recharge", text: "Recharge", value });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return facts;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
module.exports = {
|
|
593
|
+
splitRespectingTemplates,
|
|
594
|
+
parseSplitGrouping,
|
|
595
|
+
mapWikiFactToApiFact,
|
|
596
|
+
parseWikitextFacts,
|
|
597
|
+
parseInfoboxParams,
|
|
598
|
+
parseAllTaggedFacts,
|
|
599
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function parseRelatedItems(html) {
|
|
4
|
+
if (!html) return [];
|
|
5
|
+
const items = [];
|
|
6
|
+
const liPattern = /<li[^>]*>([\s\S]*?)<\/li>/gi;
|
|
7
|
+
let liMatch;
|
|
8
|
+
while ((liMatch = liPattern.exec(html)) !== null) {
|
|
9
|
+
const content = liMatch[1];
|
|
10
|
+
const linkPattern = /<a[^>]*title="([^"]+)"[^>]*>/g;
|
|
11
|
+
let name = null;
|
|
12
|
+
let linkMatch;
|
|
13
|
+
while ((linkMatch = linkPattern.exec(content)) !== null) {
|
|
14
|
+
name = linkMatch[1];
|
|
15
|
+
}
|
|
16
|
+
if (!name) continue;
|
|
17
|
+
let icon = null;
|
|
18
|
+
const imgMatch = content.match(/src="([^"]+)"/);
|
|
19
|
+
if (imgMatch) {
|
|
20
|
+
icon = imgMatch[1];
|
|
21
|
+
if (icon.startsWith("//")) icon = `https:${icon}`;
|
|
22
|
+
}
|
|
23
|
+
let context = null;
|
|
24
|
+
const dashIdx = content.indexOf("\u2014");
|
|
25
|
+
if (dashIdx >= 0) {
|
|
26
|
+
context = content.slice(dashIdx + 1).replace(/<[^>]+>/g, "").trim();
|
|
27
|
+
}
|
|
28
|
+
items.push({ name, ...(icon && { icon }), ...(context && { context }) });
|
|
29
|
+
}
|
|
30
|
+
return items;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseRelatedGroups(html) {
|
|
34
|
+
if (!html) return [];
|
|
35
|
+
const parts = html.split(/<h4[^>]*>/i);
|
|
36
|
+
if (parts.length <= 1) {
|
|
37
|
+
const items = parseRelatedItems(html);
|
|
38
|
+
return items.length ? [{ groupName: "", items }] : [];
|
|
39
|
+
}
|
|
40
|
+
const groups = [];
|
|
41
|
+
for (let i = 1; i < parts.length; i++) {
|
|
42
|
+
const headingEnd = parts[i].indexOf("</h4>");
|
|
43
|
+
const groupName = headingEnd >= 0
|
|
44
|
+
? parts[i].slice(0, headingEnd).replace(/<[^>]+>/g, "").trim()
|
|
45
|
+
: "";
|
|
46
|
+
const body = headingEnd >= 0 ? parts[i].slice(headingEnd + 5) : parts[i];
|
|
47
|
+
const items = parseRelatedItems(body);
|
|
48
|
+
if (items.length) {
|
|
49
|
+
groups.push({ groupName, items });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return groups;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { parseRelatedItems, parseRelatedGroups };
|