@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,252 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { computeAttributes, computeSlotStats, getExcludedSlots } = require("../../src/engine/attributes");
|
|
4
|
+
|
|
5
|
+
function makeCtx(overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
profession: "Warrior",
|
|
8
|
+
specializations: [],
|
|
9
|
+
equipment: {
|
|
10
|
+
slots: {},
|
|
11
|
+
weapons: {},
|
|
12
|
+
runes: {},
|
|
13
|
+
infusions: {},
|
|
14
|
+
enrichment: null,
|
|
15
|
+
food: null,
|
|
16
|
+
utility: null,
|
|
17
|
+
},
|
|
18
|
+
gameMode: "pve",
|
|
19
|
+
underwaterMode: false,
|
|
20
|
+
activeWeaponSet: 1,
|
|
21
|
+
skills: {},
|
|
22
|
+
assumedBoons: null,
|
|
23
|
+
sigilStacks: null,
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeCatalogs(overrides = {}) {
|
|
29
|
+
return {
|
|
30
|
+
traitById: new Map(),
|
|
31
|
+
skillById: new Map(),
|
|
32
|
+
specializationById: new Map(),
|
|
33
|
+
runeById: new Map(),
|
|
34
|
+
foodById: new Map(),
|
|
35
|
+
utilityById: new Map(),
|
|
36
|
+
infusionById: new Map(),
|
|
37
|
+
enrichmentById: new Map(),
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("getExcludedSlots", () => {
|
|
43
|
+
test("excludes aquatic slots in land mode", () => {
|
|
44
|
+
const excluded = getExcludedSlots(false, 1);
|
|
45
|
+
expect(excluded.has("breather")).toBe(true);
|
|
46
|
+
expect(excluded.has("aquatic1")).toBe(true);
|
|
47
|
+
expect(excluded.has("aquatic2")).toBe(true);
|
|
48
|
+
expect(excluded.has("head")).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("excludes land weapon slots in land mode for inactive set", () => {
|
|
52
|
+
const excluded = getExcludedSlots(false, 1);
|
|
53
|
+
expect(excluded.has("mainhand2")).toBe(true);
|
|
54
|
+
expect(excluded.has("offhand2")).toBe(true);
|
|
55
|
+
expect(excluded.has("mainhand1")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("excludes land-only slots in underwater mode", () => {
|
|
59
|
+
const excluded = getExcludedSlots(true, 1);
|
|
60
|
+
expect(excluded.has("head")).toBe(true);
|
|
61
|
+
expect(excluded.has("mainhand1")).toBe(true);
|
|
62
|
+
expect(excluded.has("breather")).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("computeSlotStats", () => {
|
|
67
|
+
test("3-stat combo returns major + minor stats", () => {
|
|
68
|
+
const result = computeSlotStats("Berserker's", "chest", {}, "pve");
|
|
69
|
+
expect(result).toEqual([
|
|
70
|
+
{ stat: "Power", value: 141 },
|
|
71
|
+
{ stat: "Precision", value: 101 },
|
|
72
|
+
{ stat: "Ferocity", value: 101 },
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("4-stat combo returns 2 major + 2 minor stats", () => {
|
|
77
|
+
const result = computeSlotStats("Marauder's", "chest", {}, "pve");
|
|
78
|
+
expect(result).toEqual([
|
|
79
|
+
{ stat: "Power", value: 121 },
|
|
80
|
+
{ stat: "Precision", value: 121 },
|
|
81
|
+
{ stat: "Vitality", value: 66 },
|
|
82
|
+
{ stat: "Ferocity", value: 66 },
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("Celestial uses c weight for all stats", () => {
|
|
87
|
+
const result = computeSlotStats("Celestial", "chest", {}, "pve");
|
|
88
|
+
expect(result.every((r) => r.value === 66)).toBe(true);
|
|
89
|
+
expect(result).toHaveLength(9);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("WvW Celestial excludes Expertise and Concentration", () => {
|
|
93
|
+
const result = computeSlotStats("Celestial", "chest", {}, "wvw");
|
|
94
|
+
expect(result.find((r) => r.stat === "Expertise")).toBeUndefined();
|
|
95
|
+
expect(result.find((r) => r.stat === "Concentration")).toBeUndefined();
|
|
96
|
+
expect(result).toHaveLength(7);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("two-handed weapon uses TWO_HAND_WEIGHTS", () => {
|
|
100
|
+
const weapons = { mainhand1: "greatsword" };
|
|
101
|
+
const result = computeSlotStats("Berserker's", "mainhand1", weapons, "pve");
|
|
102
|
+
expect(result[0]).toEqual({ stat: "Power", value: 251 });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("computeAttributes", () => {
|
|
107
|
+
test("empty build returns base stats only", () => {
|
|
108
|
+
const result = computeAttributes(makeCtx(), makeCatalogs());
|
|
109
|
+
expect(result.base.Power).toBe(1000);
|
|
110
|
+
expect(result.base.Precision).toBe(1000);
|
|
111
|
+
expect(result.base.Toughness).toBe(1000);
|
|
112
|
+
expect(result.base.Vitality).toBe(1000);
|
|
113
|
+
expect(result.base.Ferocity).toBe(0);
|
|
114
|
+
expect(result.total.Power).toBe(1000);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("equipment slots contribute to total", () => {
|
|
118
|
+
const ctx = makeCtx({
|
|
119
|
+
equipment: { slots: { chest: "Berserker's" }, weapons: {}, runes: {}, infusions: {} },
|
|
120
|
+
});
|
|
121
|
+
const result = computeAttributes(ctx, makeCatalogs());
|
|
122
|
+
expect(result.equipment.Power).toBe(141);
|
|
123
|
+
expect(result.total.Power).toBe(1141);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("food flat bonuses parsed from buff text", () => {
|
|
127
|
+
const catalogs = makeCatalogs({
|
|
128
|
+
foodById: new Map([[100, { name: "Steak", buff: "+100 Power" }]]),
|
|
129
|
+
});
|
|
130
|
+
const ctx = makeCtx({ equipment: { slots: {}, weapons: {}, runes: {}, infusions: {}, food: 100 } });
|
|
131
|
+
const result = computeAttributes(ctx, catalogs);
|
|
132
|
+
expect(result.food.Power).toBe(100);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("food 'to All Attributes' adds to all stats", () => {
|
|
136
|
+
const catalogs = makeCatalogs({
|
|
137
|
+
foodById: new Map([[101, { name: "Feast", buff: "+50 to All Attributes" }]]),
|
|
138
|
+
});
|
|
139
|
+
const ctx = makeCtx({ equipment: { slots: {}, weapons: {}, runes: {}, infusions: {}, food: 101 } });
|
|
140
|
+
const result = computeAttributes(ctx, catalogs);
|
|
141
|
+
expect(result.food.Power).toBe(50);
|
|
142
|
+
expect(result.food.Ferocity).toBe(50);
|
|
143
|
+
expect(result.food.HealingPower).toBe(50);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("rune bonuses are cumulative per piece", () => {
|
|
147
|
+
const catalogs = makeCatalogs({
|
|
148
|
+
runeById: new Map([[24836, { name: "Scholar", bonuses: ["+25 Power", "+35 Ferocity", "+50 Power"] }]]),
|
|
149
|
+
});
|
|
150
|
+
const ctx = makeCtx({
|
|
151
|
+
equipment: { slots: {}, weapons: {}, runes: { head: 24836, shoulders: 24836, chest: 24836 }, infusions: {} },
|
|
152
|
+
});
|
|
153
|
+
const result = computeAttributes(ctx, catalogs);
|
|
154
|
+
expect(result.runes.Power).toBe(75);
|
|
155
|
+
expect(result.runes.Ferocity).toBe(35);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("infusion attributes added", () => {
|
|
159
|
+
const catalogs = makeCatalogs({
|
|
160
|
+
infusionById: new Map([[49431, { name: "+5 Power", infixUpgrade: { attributes: [{ attribute: "Power", modifier: 5 }] } }]]),
|
|
161
|
+
});
|
|
162
|
+
const ctx = makeCtx({
|
|
163
|
+
equipment: { slots: {}, weapons: {}, infusions: { chest: [49431, 49431] }, runes: {} },
|
|
164
|
+
});
|
|
165
|
+
const result = computeAttributes(ctx, catalogs);
|
|
166
|
+
expect(result.infusions.Power).toBe(10);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("assumed Might boons add Power and ConditionDamage", () => {
|
|
170
|
+
const ctx = makeCtx({ assumedBoons: { might: 25 } });
|
|
171
|
+
const result = computeAttributes(ctx, makeCatalogs());
|
|
172
|
+
expect(result.boons.Power).toBe(750);
|
|
173
|
+
expect(result.boons.ConditionDamage).toBe(750);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("derived health uses profession base HP", () => {
|
|
177
|
+
const ctx = makeCtx({ profession: "Warrior" });
|
|
178
|
+
const result = computeAttributes(ctx, makeCatalogs());
|
|
179
|
+
// Warrior base HP = 9212, base Vitality = 1000, health = 9212 + 1000*10 = 19212
|
|
180
|
+
expect(result.derived.health).toBe(19212);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("derived crit chance formula", () => {
|
|
184
|
+
const ctx = makeCtx();
|
|
185
|
+
const result = computeAttributes(ctx, makeCatalogs());
|
|
186
|
+
// Base precision 1000: critChance = (1000 - 895) / 21 = 5%
|
|
187
|
+
expect(result.derived.critChance).toBeCloseTo(5, 0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("derived armor includes weight class defense", () => {
|
|
191
|
+
const ctx = makeCtx({ profession: "Warrior" });
|
|
192
|
+
const result = computeAttributes(ctx, makeCatalogs());
|
|
193
|
+
// Warrior = heavy = 1271 defense, base toughness 1000
|
|
194
|
+
expect(result.derived.armor).toBe(2271);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("trait conversions applied after base stats", () => {
|
|
198
|
+
const catalogs = makeCatalogs({
|
|
199
|
+
traitById: new Map([[500, {
|
|
200
|
+
id: 500,
|
|
201
|
+
facts: [{ type: "BuffConversion", source: "Vitality", target: "Power", percent: 10 }],
|
|
202
|
+
}]]),
|
|
203
|
+
specializationById: new Map([[4, { id: 4, minorTraits: [] }]]),
|
|
204
|
+
});
|
|
205
|
+
const ctx = makeCtx({
|
|
206
|
+
specializations: [{ id: 4, majorChoices: { 1: 500 } }],
|
|
207
|
+
});
|
|
208
|
+
const result = computeAttributes(ctx, catalogs);
|
|
209
|
+
// base Vitality = 1000, 10% = floor(100) = 100
|
|
210
|
+
expect(result.conversions.Power).toBe(100);
|
|
211
|
+
expect(result.total.Power).toBe(1100);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("berserk-conditional trait bonuses only apply when berserkActive", () => {
|
|
215
|
+
const catalogs = makeCatalogs({
|
|
216
|
+
traitById: new Map([[2046, {
|
|
217
|
+
id: 2046,
|
|
218
|
+
slot: "Minor",
|
|
219
|
+
description: "Fatal Frenzy",
|
|
220
|
+
facts: [
|
|
221
|
+
{ type: "AttributeAdjust", target: "Power", value: 150 },
|
|
222
|
+
{ type: "AttributeAdjust", target: "ConditionDamage", value: 300 },
|
|
223
|
+
],
|
|
224
|
+
}]]),
|
|
225
|
+
specializationById: new Map([[18, { id: 18, minorTraits: [2046] }]]),
|
|
226
|
+
});
|
|
227
|
+
const baseCtx = {
|
|
228
|
+
specializations: [{ id: 18, majorChoices: {} }],
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Without berserk: bonuses NOT applied
|
|
232
|
+
const resultOff = computeAttributes(makeCtx({ ...baseCtx, berserkActive: false }), catalogs);
|
|
233
|
+
expect(resultOff.traits.Power).toBe(0);
|
|
234
|
+
expect(resultOff.traits.ConditionDamage).toBe(0);
|
|
235
|
+
|
|
236
|
+
// With berserk: bonuses applied
|
|
237
|
+
const resultOn = computeAttributes(makeCtx({ ...baseCtx, berserkActive: true }), catalogs);
|
|
238
|
+
expect(resultOn.traits.Power).toBe(150);
|
|
239
|
+
expect(resultOn.traits.ConditionDamage).toBe(300);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("signet passive buffs added", () => {
|
|
243
|
+
const ctx = makeCtx({
|
|
244
|
+
skills: { healId: null, utilityIds: [], eliteId: null },
|
|
245
|
+
equipment: { slots: {}, weapons: {}, runes: {}, infusions: {} },
|
|
246
|
+
});
|
|
247
|
+
// Bane Signet (9093) = +180 Power
|
|
248
|
+
ctx.skills.utilityIds = [9093];
|
|
249
|
+
const result = computeAttributes(ctx, makeCatalogs());
|
|
250
|
+
expect(result.signets.Power).toBe(180);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { analyzeBoons, isAllyTargeted, normalizeName } = require("../../src/engine/boons");
|
|
4
|
+
|
|
5
|
+
describe("normalizeName", () => {
|
|
6
|
+
test("normalizes Blind to Blinded", () => {
|
|
7
|
+
expect(normalizeName("Blind")).toBe("Blinded");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("normalizes Cripple to Crippled", () => {
|
|
11
|
+
expect(normalizeName("Cripple")).toBe("Crippled");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("returns unknown names unchanged", () => {
|
|
15
|
+
expect(normalizeName("Might")).toBe("Might");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("isAllyTargeted", () => {
|
|
20
|
+
test("returns true when boon name appears in ally sentence", () => {
|
|
21
|
+
expect(isAllyTargeted("Grant Might to nearby allies.", "Might", [])).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("returns false when boon is in description but not with ally word", () => {
|
|
25
|
+
expect(isAllyTargeted("Gain Might. Attack enemies.", "Might", [])).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("returns false with no description", () => {
|
|
29
|
+
expect(isAllyTargeted(null, "Might", [])).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("returns true for generic ally mention when boon not named", () => {
|
|
33
|
+
expect(isAllyTargeted("Grant boons to allies.", "Fury", [])).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("returns false for unnamed boon when specific boons named with allies", () => {
|
|
37
|
+
expect(isAllyTargeted("Grant might to allies.", "Fury", ["Might"])).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("analyzeBoons", () => {
|
|
42
|
+
test("extracts boon from skill with Buff fact", () => {
|
|
43
|
+
const skills = [{
|
|
44
|
+
name: "For Great Justice!",
|
|
45
|
+
description: "Grant Might and Fury to yourself and allies.",
|
|
46
|
+
icon: "",
|
|
47
|
+
facts: [
|
|
48
|
+
{ type: "Buff", status: "Might", apply_count: 3, duration: 8 },
|
|
49
|
+
{ type: "Buff", status: "Fury", apply_count: 1, duration: 8 },
|
|
50
|
+
],
|
|
51
|
+
}];
|
|
52
|
+
const result = analyzeBoons(skills, [], new Map());
|
|
53
|
+
expect(result.boons.length).toBe(2);
|
|
54
|
+
expect(result.boons.find((b) => b.name === "Might")).toBeDefined();
|
|
55
|
+
expect(result.boons.find((b) => b.name === "Fury")).toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("extracts condition from skill", () => {
|
|
59
|
+
const skills = [{
|
|
60
|
+
name: "Sword of Justice",
|
|
61
|
+
description: "Create a Sword of Justice.",
|
|
62
|
+
icon: "",
|
|
63
|
+
facts: [{ type: "Buff", status: "Burning", apply_count: 2, duration: 3 }],
|
|
64
|
+
}];
|
|
65
|
+
const result = analyzeBoons(skills, [], new Map());
|
|
66
|
+
expect(result.conditions.length).toBe(1);
|
|
67
|
+
expect(result.conditions[0].name).toBe("Burning");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("deduplicates by source + stacks + duration + context", () => {
|
|
71
|
+
const skill = {
|
|
72
|
+
name: "Skill A", description: "", icon: "",
|
|
73
|
+
facts: [
|
|
74
|
+
{ type: "Buff", status: "Might", apply_count: 3, duration: 8 },
|
|
75
|
+
{ type: "Buff", status: "Might", apply_count: 3, duration: 8 },
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
const result = analyzeBoons([skill], [], new Map());
|
|
79
|
+
const might = result.boons.find((b) => b.name === "Might");
|
|
80
|
+
expect(might.sources).toHaveLength(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("handles NoData section context for conditional facts", () => {
|
|
84
|
+
const skill = {
|
|
85
|
+
name: "Skill B", description: "", icon: "",
|
|
86
|
+
facts: [
|
|
87
|
+
{ type: "NoData", text: "On Critical Hit" },
|
|
88
|
+
{ type: "Buff", status: "Fury", apply_count: 1, duration: 4 },
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
const result = analyzeBoons([skill], [], new Map());
|
|
92
|
+
const fury = result.boons.find((b) => b.name === "Fury");
|
|
93
|
+
expect(fury.sources[0].context).toBe("On Critical Hit");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("applies Twisted Medicine ally override for Elixir skills", () => {
|
|
97
|
+
const overrides = new Map([
|
|
98
|
+
["trait:2220", { allyTargeted: ["elixir"] }],
|
|
99
|
+
]);
|
|
100
|
+
const skills = [{
|
|
101
|
+
name: "Elixir B", description: "Drink Elixir B.", icon: "",
|
|
102
|
+
categories: ["Elixir"],
|
|
103
|
+
facts: [{ type: "Buff", status: "Fury", apply_count: 1, duration: 5 }],
|
|
104
|
+
}];
|
|
105
|
+
const traits = [{ id: 2220, name: "Twisted Medicine", facts: [] }];
|
|
106
|
+
const result = analyzeBoons(skills, traits, overrides, new Set([2220]));
|
|
107
|
+
const fury = result.boons.find((b) => b.name === "Fury");
|
|
108
|
+
expect(fury.sources[0].isAlly).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("sorts boons by display order, conditions alphabetically", () => {
|
|
112
|
+
const skills = [{
|
|
113
|
+
name: "Multi", description: "", icon: "",
|
|
114
|
+
facts: [
|
|
115
|
+
{ type: "Buff", status: "Vigor", apply_count: 1, duration: 5 },
|
|
116
|
+
{ type: "Buff", status: "Aegis", apply_count: 1, duration: 3 },
|
|
117
|
+
{ type: "Buff", status: "Weakness", apply_count: 1, duration: 3 },
|
|
118
|
+
{ type: "Buff", status: "Burning", apply_count: 1, duration: 3 },
|
|
119
|
+
],
|
|
120
|
+
}];
|
|
121
|
+
const result = analyzeBoons(skills, [], new Map());
|
|
122
|
+
// Aegis comes before Vigor in GW2 display order
|
|
123
|
+
const boonNames = result.boons.map((b) => b.name);
|
|
124
|
+
expect(boonNames.indexOf("Aegis")).toBeLessThan(boonNames.indexOf("Vigor"));
|
|
125
|
+
// Conditions sorted alphabetically
|
|
126
|
+
const condNames = result.conditions.map((c) => c.name);
|
|
127
|
+
expect(condNames).toEqual([...condNames].sort());
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { analyzeCombos } = require("../../src/engine/combos");
|
|
4
|
+
|
|
5
|
+
describe("analyzeCombos", () => {
|
|
6
|
+
test("extracts combo field from skill facts", () => {
|
|
7
|
+
const skills = [{
|
|
8
|
+
name: "Flame Wall", icon: "", description: "", facts: [
|
|
9
|
+
{ type: "ComboField", field_type: "Fire" },
|
|
10
|
+
{ type: "Time", duration: 5 },
|
|
11
|
+
{ type: "Radius", distance: 240 },
|
|
12
|
+
],
|
|
13
|
+
}];
|
|
14
|
+
const result = analyzeCombos(skills, []);
|
|
15
|
+
expect(result.fields).toHaveLength(1);
|
|
16
|
+
expect(result.fields[0]).toMatchObject({
|
|
17
|
+
fieldType: "Fire", sourceName: "Flame Wall", duration: 5, radius: 240,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("extracts combo finisher from skill facts", () => {
|
|
22
|
+
const skills = [{
|
|
23
|
+
name: "Mighty Blow", icon: "", description: "", facts: [
|
|
24
|
+
{ type: "ComboFinisher", finisher_type: "Blast", percent: 100 },
|
|
25
|
+
],
|
|
26
|
+
}];
|
|
27
|
+
const result = analyzeCombos(skills, []);
|
|
28
|
+
expect(result.finishers).toHaveLength(1);
|
|
29
|
+
expect(result.finishers[0]).toMatchObject({
|
|
30
|
+
finisherType: "Blast", sourceName: "Mighty Blow", hitCount: 1, percent: 100,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("groups multiple finishers of same type on one skill", () => {
|
|
35
|
+
const skills = [{
|
|
36
|
+
name: "Whirling Strike", icon: "", description: "", facts: [
|
|
37
|
+
{ type: "ComboFinisher", finisher_type: "Whirl" },
|
|
38
|
+
{ type: "ComboFinisher", finisher_type: "Whirl" },
|
|
39
|
+
{ type: "ComboFinisher", finisher_type: "Whirl" },
|
|
40
|
+
],
|
|
41
|
+
}];
|
|
42
|
+
const result = analyzeCombos(skills, []);
|
|
43
|
+
expect(result.finishers).toHaveLength(1);
|
|
44
|
+
expect(result.finishers[0].hitCount).toBe(3);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("deduplicates fields by (type, sourceName)", () => {
|
|
48
|
+
const skills = [
|
|
49
|
+
{ name: "Flame Wall", icon: "", description: "", facts: [{ type: "ComboField", field_type: "Fire" }] },
|
|
50
|
+
{ name: "Flame Wall", icon: "", description: "", facts: [{ type: "ComboField", field_type: "Fire" }] },
|
|
51
|
+
];
|
|
52
|
+
const result = analyzeCombos(skills, []);
|
|
53
|
+
expect(result.fields).toHaveLength(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("extracts fields from traits too", () => {
|
|
57
|
+
const traits = [{
|
|
58
|
+
name: "Healing Trait", icon: "", description: "", facts: [
|
|
59
|
+
{ type: "ComboField", field_type: "Water" },
|
|
60
|
+
],
|
|
61
|
+
}];
|
|
62
|
+
const result = analyzeCombos([], traits);
|
|
63
|
+
expect(result.fields).toHaveLength(1);
|
|
64
|
+
expect(result.fields[0].fieldType).toBe("Water");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("tracks finisher percent below 100", () => {
|
|
68
|
+
const skills = [{
|
|
69
|
+
name: "Leap", icon: "", description: "", facts: [
|
|
70
|
+
{ type: "ComboFinisher", finisher_type: "Leap", percent: 50 },
|
|
71
|
+
],
|
|
72
|
+
}];
|
|
73
|
+
const result = analyzeCombos(skills, []);
|
|
74
|
+
expect(result.finishers[0].percent).toBe(50);
|
|
75
|
+
});
|
|
76
|
+
});
|