@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,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 };