@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,204 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { CONVERSION_TARGET_MAP } = require("./constants");
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Modifiers module — collects active trait IDs and classifies facts into
|
|
7
|
+
// typed modifier objects. Pure functions — no DOM, no renderer state.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Collect all active trait IDs — major choices + minor (auto-selected) traits.
|
|
12
|
+
*
|
|
13
|
+
* @param {Object} ctx - Build context
|
|
14
|
+
* @param {Array} ctx.specializations - Array of specialization objects
|
|
15
|
+
* @param {Object} catalogs - Catalog data
|
|
16
|
+
* @param {Object} catalogs.specializationById - Map<number, { minorTraits: number[] }>
|
|
17
|
+
* @returns {Set<number>}
|
|
18
|
+
*/
|
|
19
|
+
function collectActiveTraitIds(ctx, catalogs) {
|
|
20
|
+
const ids = new Set();
|
|
21
|
+
if (!ctx || !Array.isArray(ctx.specializations)) return ids;
|
|
22
|
+
|
|
23
|
+
for (const spec of ctx.specializations) {
|
|
24
|
+
if (!spec) continue;
|
|
25
|
+
|
|
26
|
+
// Collect major trait choices
|
|
27
|
+
for (const id of Object.values(spec.majorChoices || {})) {
|
|
28
|
+
const n = Number(id);
|
|
29
|
+
if (n) ids.add(n);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Collect minor traits from catalog
|
|
33
|
+
const specId = Number(spec.specializationId || spec.id) || 0;
|
|
34
|
+
const specData = (specId && catalogs?.specializationById)
|
|
35
|
+
? catalogs.specializationById.get(specId)
|
|
36
|
+
: null;
|
|
37
|
+
for (const minorId of specData?.minorTraits || []) {
|
|
38
|
+
if (minorId) ids.add(Number(minorId));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return ids;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check whether a trait is fury-related: has a Buff(Fury) fact or is marked
|
|
47
|
+
* with implicitFury in overrides.
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} trait - Trait object with facts array
|
|
50
|
+
* @param {number} traitId - The trait's ID
|
|
51
|
+
* @param {Map} overrides - Overrides map (string key → object)
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
function isFuryTrait(trait, traitId, overrides) {
|
|
55
|
+
const override = overrides?.get(`trait:${traitId}`);
|
|
56
|
+
if (override?.implicitFury) return true;
|
|
57
|
+
return trait.facts?.some((f) => f.type === "Buff" && f.status === "Fury") || false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Collect typed modifier objects from active traits.
|
|
62
|
+
*
|
|
63
|
+
* Modifier types emitted:
|
|
64
|
+
* - flatBonus: { source, type, target, value, condition }
|
|
65
|
+
* - conversion: { source, type, sourceAttr, target, percent, condition }
|
|
66
|
+
* - critChance: { source, type, value, condition }
|
|
67
|
+
* - mightModifier: { source, type, power, condi, condition }
|
|
68
|
+
* - burstRecharge: { source, type, value, condition }
|
|
69
|
+
*
|
|
70
|
+
* @param {Object} ctx - Build context (has .specializations, .gameMode)
|
|
71
|
+
* @param {Object} catalogs - Catalog data (has .traitById, .specializationById)
|
|
72
|
+
* @param {Map} overrides - Overrides map from loadOverrides()
|
|
73
|
+
* @returns {Array<Object>}
|
|
74
|
+
*/
|
|
75
|
+
function collectModifiers(ctx, catalogs, overrides) {
|
|
76
|
+
const modifiers = [];
|
|
77
|
+
if (!catalogs?.traitById) return modifiers;
|
|
78
|
+
|
|
79
|
+
const gameMode = ctx?.gameMode || "pve";
|
|
80
|
+
const activeTraitIds = collectActiveTraitIds(ctx, catalogs);
|
|
81
|
+
if (!activeTraitIds.size) return modifiers;
|
|
82
|
+
|
|
83
|
+
for (const traitId of activeTraitIds) {
|
|
84
|
+
const source = `trait:${traitId}`;
|
|
85
|
+
const override = overrides?.get(source) || null;
|
|
86
|
+
|
|
87
|
+
// 1. Skip pet-stat-only traits
|
|
88
|
+
if (override?.petStatOnly) continue;
|
|
89
|
+
|
|
90
|
+
const trait = catalogs.traitById.get(traitId);
|
|
91
|
+
|
|
92
|
+
// 2. mightOverride — emit mightModifier (no trait facts needed)
|
|
93
|
+
if (override?.mightOverride) {
|
|
94
|
+
modifiers.push({
|
|
95
|
+
source,
|
|
96
|
+
type: "mightModifier",
|
|
97
|
+
power: override.mightOverride.power,
|
|
98
|
+
condi: override.mightOverride.condi,
|
|
99
|
+
condition: null,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. burstRechargeReduction from overrides
|
|
104
|
+
if (override?.burstRechargeReduction != null) {
|
|
105
|
+
modifiers.push({
|
|
106
|
+
source,
|
|
107
|
+
type: "burstRecharge",
|
|
108
|
+
value: override.burstRechargeReduction,
|
|
109
|
+
condition: null,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!trait?.facts) continue;
|
|
114
|
+
|
|
115
|
+
// Use per-mode facts when the wiki pipeline has separated them;
|
|
116
|
+
// fall back to trait.facts (which may contain mixed-mode duplicates).
|
|
117
|
+
const modeFacts = gameMode === "wvw" && trait.wvwFacts ? trait.wvwFacts
|
|
118
|
+
: gameMode === "pvp" && trait.pvpFacts ? trait.pvpFacts
|
|
119
|
+
: trait.facts;
|
|
120
|
+
|
|
121
|
+
const fury = isFuryTrait(trait, traitId, overrides);
|
|
122
|
+
const condition = override?.berserkConditional ? "berserk"
|
|
123
|
+
: fury ? "fury" : null;
|
|
124
|
+
|
|
125
|
+
// 4. AttributeAdjust facts — flatBonus
|
|
126
|
+
const byTarget = new Map();
|
|
127
|
+
for (const fact of modeFacts) {
|
|
128
|
+
if (fact.type !== "AttributeAdjust" || !fact.target || fact.value == null) continue;
|
|
129
|
+
if (!byTarget.has(fact.target)) byTarget.set(fact.target, []);
|
|
130
|
+
byTarget.get(fact.target).push(fact.value);
|
|
131
|
+
}
|
|
132
|
+
for (const [target, values] of byTarget) {
|
|
133
|
+
const statKey = CONVERSION_TARGET_MAP[target] || target;
|
|
134
|
+
const idx = gameMode === "wvw" ? Math.min(1, values.length - 1) : 0;
|
|
135
|
+
modifiers.push({
|
|
136
|
+
source,
|
|
137
|
+
type: "flatBonus",
|
|
138
|
+
target: statKey,
|
|
139
|
+
value: values[idx],
|
|
140
|
+
condition,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 5. BuffConversion / AttributeConversion facts — conversion
|
|
145
|
+
// Group by source+target pair and pick PvE (index 0) or WvW (index 1),
|
|
146
|
+
// mirroring the dedup logic used for AttributeAdjust facts above.
|
|
147
|
+
const convByPair = new Map();
|
|
148
|
+
for (const fact of modeFacts) {
|
|
149
|
+
if (fact.type !== "AttributeConversion" && fact.type !== "BuffConversion") continue;
|
|
150
|
+
if (!fact.source || !fact.target || !fact.percent) continue;
|
|
151
|
+
const pairKey = `${fact.source}|${fact.target}`;
|
|
152
|
+
if (!convByPair.has(pairKey)) convByPair.set(pairKey, []);
|
|
153
|
+
convByPair.get(pairKey).push(fact);
|
|
154
|
+
}
|
|
155
|
+
for (const [, facts] of convByPair) {
|
|
156
|
+
const idx = gameMode === "wvw" ? Math.min(1, facts.length - 1) : 0;
|
|
157
|
+
const fact = facts[idx];
|
|
158
|
+
const targetKey = CONVERSION_TARGET_MAP[fact.target] || fact.target;
|
|
159
|
+
modifiers.push({
|
|
160
|
+
source,
|
|
161
|
+
type: "conversion",
|
|
162
|
+
sourceAttr: fact.source,
|
|
163
|
+
target: targetKey,
|
|
164
|
+
percent: fact.percent,
|
|
165
|
+
condition: null,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 6. Fury traits with "Critical Chance Increase" Percent facts — critChance
|
|
170
|
+
if (fury) {
|
|
171
|
+
const critFacts = modeFacts.filter(
|
|
172
|
+
(f) => f.type === "Percent" && f.text === "Critical Chance Increase" && f.percent
|
|
173
|
+
);
|
|
174
|
+
if (critFacts.length > 0) {
|
|
175
|
+
const idx = gameMode === "wvw" ? Math.min(1, critFacts.length - 1) : 0;
|
|
176
|
+
modifiers.push({
|
|
177
|
+
source,
|
|
178
|
+
type: "critChance",
|
|
179
|
+
value: critFacts[idx].percent,
|
|
180
|
+
condition: "fury",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 7. Minor traits with "burst" in description and "Recharge Reduced" Percent facts
|
|
186
|
+
if (trait.slot === "Minor" && trait.description?.toLowerCase().includes("burst")) {
|
|
187
|
+
const rechargeFacts = modeFacts.filter(
|
|
188
|
+
(f) => f.type === "Percent" && f.text === "Recharge Reduced" && f.percent
|
|
189
|
+
);
|
|
190
|
+
for (const fact of rechargeFacts) {
|
|
191
|
+
modifiers.push({
|
|
192
|
+
source,
|
|
193
|
+
type: "burstRecharge",
|
|
194
|
+
value: fact.percent,
|
|
195
|
+
condition: null,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return modifiers;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { collectActiveTraitIds, isFuryTrait, collectModifiers };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const raw = require("../../data/overrides.json");
|
|
4
|
+
|
|
5
|
+
function loadOverrides() {
|
|
6
|
+
return new Map(Object.entries(raw));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getOverride(overrides, key) {
|
|
10
|
+
return overrides.get(key) || null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { loadOverrides, getOverride };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { WEAPON_STRENGTH_MIDPOINT } = require("./constants");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Compute tooltip damage for a skill.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} attributes - Result from computeAttributes() (needs .total and .derived)
|
|
9
|
+
* @param {Object} skill - Skill object with facts array
|
|
10
|
+
* @param {string} weaponType - Equipped weapon type (e.g. "greatsword", "staff")
|
|
11
|
+
* @param {Object[]} modifiers - Active modifiers from collectModifiers()
|
|
12
|
+
* @returns {Object|null} Tooltip result or null if skill has no Damage fact
|
|
13
|
+
*/
|
|
14
|
+
function computeTooltip(attributes, skill, weaponType, modifiers) {
|
|
15
|
+
const facts = skill.facts || [];
|
|
16
|
+
const damageFact = facts.find((f) => f.type === "Damage");
|
|
17
|
+
if (!damageFact) return null;
|
|
18
|
+
|
|
19
|
+
const coefficient = damageFact.dmg_multiplier || 0;
|
|
20
|
+
const hits = damageFact.hit_count || 1;
|
|
21
|
+
const weaponStrength = WEAPON_STRENGTH_MIDPOINT[weaponType] || 0;
|
|
22
|
+
const power = attributes.total.Power || 0;
|
|
23
|
+
|
|
24
|
+
// Target armor (standard PvE target: 2597)
|
|
25
|
+
const targetArmor = 2597;
|
|
26
|
+
|
|
27
|
+
// Collect applicable damage multipliers
|
|
28
|
+
let damageMultiplier = 1;
|
|
29
|
+
const appliedModifiers = [];
|
|
30
|
+
for (const mod of modifiers) {
|
|
31
|
+
if (mod.type === "damageMultiplier" && mod.condition === null) {
|
|
32
|
+
damageMultiplier *= (1 + mod.value / 100);
|
|
33
|
+
appliedModifiers.push(mod);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Effective power with crit
|
|
38
|
+
const critChance = Math.min(100, attributes.derived.critChance || 0) / 100;
|
|
39
|
+
const critDamage = (attributes.derived.critDamage || 150) / 100;
|
|
40
|
+
const critMultiplier = 1 + critChance * (critDamage - 1);
|
|
41
|
+
|
|
42
|
+
const damage = Math.round(
|
|
43
|
+
coefficient * weaponStrength * power * damageMultiplier * critMultiplier / targetArmor
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
damage,
|
|
48
|
+
totalDamage: damage * hits,
|
|
49
|
+
coefficient,
|
|
50
|
+
hits,
|
|
51
|
+
weaponStrength,
|
|
52
|
+
power,
|
|
53
|
+
critMultiplier,
|
|
54
|
+
damageMultiplier,
|
|
55
|
+
modifiers: appliedModifiers,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { computeTooltip };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { normalizeFactType } = require("./normalize");
|
|
4
|
+
|
|
5
|
+
const VALUE_KEYS = [
|
|
6
|
+
"value", "distance", "duration", "apply_count", "dmg_multiplier",
|
|
7
|
+
"hit_count", "percent", "coefficient", "finisher_type", "field_type",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const STOP_WORDS = new Set([
|
|
11
|
+
"the", "and", "per", "for", "with", "from", "based", "gain",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
function splitGroupKey(fact) {
|
|
15
|
+
const normType = normalizeFactType(fact.type);
|
|
16
|
+
const qualifier = fact.target || fact.status || "";
|
|
17
|
+
return `${normType}:${qualifier}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function valueChanged(before, after) {
|
|
21
|
+
for (const key of VALUE_KEYS) {
|
|
22
|
+
if (key === "hit_count" && before[key] === undefined) continue;
|
|
23
|
+
if (before[key] !== after[key]) return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function extractKeywords(text) {
|
|
29
|
+
if (!text) return new Set();
|
|
30
|
+
const words = text.toLowerCase().split(/\s+/);
|
|
31
|
+
return new Set(words.filter((w) => w.length >= 3 && !STOP_WORDS.has(w)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildMatchTables(baseFacts, splitFacts) {
|
|
35
|
+
const baseToSplit = new Map();
|
|
36
|
+
const splitToBase = new Map();
|
|
37
|
+
const baseMatched = new Set();
|
|
38
|
+
const splitMatched = new Set();
|
|
39
|
+
|
|
40
|
+
// Pass 1: Exact text + normalized type
|
|
41
|
+
for (let si = 0; si < splitFacts.length; si++) {
|
|
42
|
+
if (splitMatched.has(si)) continue;
|
|
43
|
+
const sf = splitFacts[si];
|
|
44
|
+
const normSplitType = normalizeFactType(sf.type);
|
|
45
|
+
for (let bi = 0; bi < baseFacts.length; bi++) {
|
|
46
|
+
if (baseMatched.has(bi)) continue;
|
|
47
|
+
const bf = baseFacts[bi];
|
|
48
|
+
if (normalizeFactType(bf.type) === normSplitType && bf.text === sf.text) {
|
|
49
|
+
baseToSplit.set(bi, si);
|
|
50
|
+
splitToBase.set(si, bi);
|
|
51
|
+
baseMatched.add(bi);
|
|
52
|
+
splitMatched.add(si);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Pass 1.5: Cross-type exact text match
|
|
59
|
+
for (let si = 0; si < splitFacts.length; si++) {
|
|
60
|
+
if (splitMatched.has(si)) continue;
|
|
61
|
+
const sf = splitFacts[si];
|
|
62
|
+
for (let bi = 0; bi < baseFacts.length; bi++) {
|
|
63
|
+
if (baseMatched.has(bi)) continue;
|
|
64
|
+
const bf = baseFacts[bi];
|
|
65
|
+
if (bf.text === sf.text) {
|
|
66
|
+
baseToSplit.set(bi, si);
|
|
67
|
+
splitToBase.set(si, bi);
|
|
68
|
+
baseMatched.add(bi);
|
|
69
|
+
splitMatched.add(si);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Pass 2: Type-group positional match
|
|
76
|
+
const baseGroups = new Map();
|
|
77
|
+
const splitGroups = new Map();
|
|
78
|
+
for (let bi = 0; bi < baseFacts.length; bi++) {
|
|
79
|
+
if (baseMatched.has(bi)) continue;
|
|
80
|
+
const key = splitGroupKey(baseFacts[bi]);
|
|
81
|
+
if (!baseGroups.has(key)) baseGroups.set(key, []);
|
|
82
|
+
baseGroups.get(key).push(bi);
|
|
83
|
+
}
|
|
84
|
+
for (let si = 0; si < splitFacts.length; si++) {
|
|
85
|
+
if (splitMatched.has(si)) continue;
|
|
86
|
+
const key = splitGroupKey(splitFacts[si]);
|
|
87
|
+
if (!splitGroups.has(key)) splitGroups.set(key, []);
|
|
88
|
+
splitGroups.get(key).push(si);
|
|
89
|
+
}
|
|
90
|
+
for (const [key, splitIndices] of splitGroups) {
|
|
91
|
+
const baseIndices = baseGroups.get(key);
|
|
92
|
+
if (!baseIndices) continue;
|
|
93
|
+
const pairs = Math.min(baseIndices.length, splitIndices.length);
|
|
94
|
+
for (let i = 0; i < pairs; i++) {
|
|
95
|
+
const bi = baseIndices[i];
|
|
96
|
+
const si = splitIndices[i];
|
|
97
|
+
baseToSplit.set(bi, si);
|
|
98
|
+
splitToBase.set(si, bi);
|
|
99
|
+
baseMatched.add(bi);
|
|
100
|
+
splitMatched.add(si);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Pass 3: Keyword overlap
|
|
105
|
+
for (let si = 0; si < splitFacts.length; si++) {
|
|
106
|
+
if (splitMatched.has(si)) continue;
|
|
107
|
+
const splitWords = extractKeywords(splitFacts[si].text);
|
|
108
|
+
if (splitWords.size === 0) continue;
|
|
109
|
+
let bestBi = -1;
|
|
110
|
+
let bestScore = 0;
|
|
111
|
+
for (let bi = 0; bi < baseFacts.length; bi++) {
|
|
112
|
+
if (baseMatched.has(bi)) continue;
|
|
113
|
+
const baseWords = extractKeywords(baseFacts[bi].text);
|
|
114
|
+
let shared = 0;
|
|
115
|
+
for (const w of splitWords) {
|
|
116
|
+
if (baseWords.has(w)) shared++;
|
|
117
|
+
}
|
|
118
|
+
if (shared > bestScore) {
|
|
119
|
+
bestScore = shared;
|
|
120
|
+
bestBi = bi;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (bestBi >= 0 && bestScore >= 1) {
|
|
124
|
+
baseToSplit.set(bestBi, si);
|
|
125
|
+
splitToBase.set(si, bestBi);
|
|
126
|
+
baseMatched.add(bestBi);
|
|
127
|
+
splitMatched.add(si);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { baseToSplit, splitToBase };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { buildMatchTables, splitGroupKey, valueChanged, VALUE_KEYS };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { buildMatchTables, valueChanged, VALUE_KEYS } = require("./match");
|
|
4
|
+
|
|
5
|
+
function mergeFacts(baseFacts, splitFacts, { complete = false } = {}) {
|
|
6
|
+
if (!splitFacts || splitFacts.length === 0) {
|
|
7
|
+
return baseFacts;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { baseToSplit, splitToBase } = buildMatchTables(baseFacts, splitFacts);
|
|
11
|
+
const result = [];
|
|
12
|
+
|
|
13
|
+
for (let bi = 0; bi < baseFacts.length; bi++) {
|
|
14
|
+
const si = baseToSplit.get(bi);
|
|
15
|
+
if (si !== undefined) {
|
|
16
|
+
const merged = { ...baseFacts[bi] };
|
|
17
|
+
const splitFact = splitFacts[si];
|
|
18
|
+
let changed = false;
|
|
19
|
+
for (const key of VALUE_KEYS) {
|
|
20
|
+
if (splitFact[key] !== undefined) {
|
|
21
|
+
if (merged[key] !== splitFact[key]) {
|
|
22
|
+
changed = true;
|
|
23
|
+
}
|
|
24
|
+
merged[key] = splitFact[key];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (changed) {
|
|
28
|
+
merged._splitFact = true;
|
|
29
|
+
}
|
|
30
|
+
result.push(merged);
|
|
31
|
+
} else if (!complete) {
|
|
32
|
+
result.push({ ...baseFacts[bi] });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (let si = 0; si < splitFacts.length; si++) {
|
|
37
|
+
if (!splitToBase.has(si)) {
|
|
38
|
+
result.push({ ...splitFacts[si], _newFact: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { mergeFacts };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const TYPE_ALIASES = {
|
|
4
|
+
Distance: "Radius",
|
|
5
|
+
PrefixedBuff: "Buff",
|
|
6
|
+
ApplyBuffCondition: "Buff",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function normalizeFactType(type) {
|
|
10
|
+
return TYPE_ALIASES[type] || type;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function stripGw2Markup(text) {
|
|
14
|
+
if (!text) return text;
|
|
15
|
+
return text.replace(/<c[^>]*>(.*?)<\/c>/g, "$1");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stripWikiMarkup(text) {
|
|
19
|
+
if (!text) return text;
|
|
20
|
+
let result = text.replace(/\{\{fraction\|([^}]+)\}\}/g, "$1");
|
|
21
|
+
result = result.replace(/\{\{[^}]*\}\}/g, "");
|
|
22
|
+
result = result.replace(/\[\[[^\]]*\|([^\]]+)\]\]/g, "$1");
|
|
23
|
+
result = result.replace(/\[\[([^\]|]+)\]\]/g, "$1");
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { normalizeFactType, stripGw2Markup, stripWikiMarkup };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { WikiClient, WIKI_API_ROOT } = require("./wiki/client");
|
|
4
|
+
const { MemoryCache, DiskCache } = require("./wiki/cache");
|
|
5
|
+
const { Gw2ApiClient, GW2_API_ROOT } = require("./api/client");
|
|
6
|
+
const {
|
|
7
|
+
parseSplitGrouping,
|
|
8
|
+
parseWikitextFacts,
|
|
9
|
+
mapWikiFactToApiFact,
|
|
10
|
+
parseInfoboxParams,
|
|
11
|
+
} = require("./wiki/parser");
|
|
12
|
+
const { parseRelatedItems, parseRelatedGroups } = require("./wiki/relations");
|
|
13
|
+
const { mergeFacts } = require("./facts/merge");
|
|
14
|
+
const { buildMatchTables, valueChanged, VALUE_KEYS } = require("./facts/match");
|
|
15
|
+
const { normalizeFactType, stripGw2Markup, stripWikiMarkup } = require("./facts/normalize");
|
|
16
|
+
const engine = require("./engine");
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
// Wiki layer
|
|
20
|
+
WikiClient,
|
|
21
|
+
WIKI_API_ROOT,
|
|
22
|
+
|
|
23
|
+
// GW2 API layer
|
|
24
|
+
Gw2ApiClient,
|
|
25
|
+
GW2_API_ROOT,
|
|
26
|
+
|
|
27
|
+
// Cache
|
|
28
|
+
MemoryCache,
|
|
29
|
+
DiskCache,
|
|
30
|
+
|
|
31
|
+
// Parser
|
|
32
|
+
parseSplitGrouping,
|
|
33
|
+
parseWikitextFacts,
|
|
34
|
+
mapWikiFactToApiFact,
|
|
35
|
+
parseInfoboxParams,
|
|
36
|
+
|
|
37
|
+
// Relations
|
|
38
|
+
parseRelatedItems,
|
|
39
|
+
parseRelatedGroups,
|
|
40
|
+
|
|
41
|
+
// Facts
|
|
42
|
+
mergeFacts,
|
|
43
|
+
buildMatchTables,
|
|
44
|
+
valueChanged,
|
|
45
|
+
VALUE_KEYS,
|
|
46
|
+
normalizeFactType,
|
|
47
|
+
stripGw2Markup,
|
|
48
|
+
stripWikiMarkup,
|
|
49
|
+
|
|
50
|
+
// Engine
|
|
51
|
+
StatEngine: engine.StatEngine,
|
|
52
|
+
computeAttributes: engine.computeAttributes,
|
|
53
|
+
computeSlotStats: engine.computeSlotStats,
|
|
54
|
+
collectModifiers: engine.collectModifiers,
|
|
55
|
+
computeTooltip: engine.computeTooltip,
|
|
56
|
+
analyzeBoons: engine.analyzeBoons,
|
|
57
|
+
analyzeCombos: engine.analyzeCombos,
|
|
58
|
+
loadOverrides: engine.loadOverrides,
|
|
59
|
+
buildInteractionGraph: engine.buildInteractionGraph,
|
|
60
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} CacheAdapter
|
|
5
|
+
* @property {(key: string) => any|null} get - Get value by key, null if missing/expired
|
|
6
|
+
* @property {(key: string, value: any, ttlMs: number) => void} set - Set value with TTL in milliseconds
|
|
7
|
+
* @property {(key: string) => void} invalidate - Remove a specific key
|
|
8
|
+
* @property {() => void} clear - Remove all entries
|
|
9
|
+
* @property {(key: string) => boolean} has - Check if key exists and is not expired
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
class MemoryCache {
|
|
13
|
+
constructor() {
|
|
14
|
+
this._store = new Map();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get(key) {
|
|
18
|
+
const entry = this._store.get(key);
|
|
19
|
+
if (!entry) return null;
|
|
20
|
+
if (Date.now() >= entry.expiresAt) {
|
|
21
|
+
this._store.delete(key);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return entry.value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
set(key, value, ttlMs) {
|
|
28
|
+
this._store.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
invalidate(key) {
|
|
32
|
+
this._store.delete(key);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
clear() {
|
|
36
|
+
this._store.clear();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
has(key) {
|
|
40
|
+
return this.get(key) !== null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fs = require("node:fs/promises");
|
|
45
|
+
const path = require("node:path");
|
|
46
|
+
|
|
47
|
+
class DiskCache {
|
|
48
|
+
constructor(dir) {
|
|
49
|
+
this._dir = dir;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_filePath(key) {
|
|
53
|
+
const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
54
|
+
return path.join(this._dir, `${safeKey}.json`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async get(key) {
|
|
58
|
+
try {
|
|
59
|
+
const raw = await fs.readFile(this._filePath(key), "utf-8");
|
|
60
|
+
const entry = JSON.parse(raw);
|
|
61
|
+
if (Date.now() >= entry.expiresAt) {
|
|
62
|
+
await this.invalidate(key);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return entry.value;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async set(key, value, ttlMs) {
|
|
72
|
+
const entry = { value, expiresAt: Date.now() + ttlMs };
|
|
73
|
+
await fs.mkdir(this._dir, { recursive: true });
|
|
74
|
+
await fs.writeFile(this._filePath(key), JSON.stringify(entry), "utf-8");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async invalidate(key) {
|
|
78
|
+
try {
|
|
79
|
+
await fs.unlink(this._filePath(key));
|
|
80
|
+
} catch {
|
|
81
|
+
// File may not exist
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async clear() {
|
|
86
|
+
try {
|
|
87
|
+
const files = await fs.readdir(this._dir);
|
|
88
|
+
await Promise.all(
|
|
89
|
+
files
|
|
90
|
+
.filter((f) => f.endsWith(".json"))
|
|
91
|
+
.map((f) => fs.unlink(path.join(this._dir, f)))
|
|
92
|
+
);
|
|
93
|
+
} catch {
|
|
94
|
+
// Directory may not exist
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async has(key) {
|
|
99
|
+
return (await this.get(key)) !== null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { MemoryCache, DiskCache };
|