@mks.haro/axicode 1.0.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/package.json +22 -0
- package/src/bitBuffer.js +70 -0
- package/src/index.js +609 -0
- package/src/relics.js +112 -0
- package/src/tables.js +94 -0
- package/src/z85.js +44 -0
- package/tests/axicode.test.js +349 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mks.haro/axicode",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Compact binary build code encoder/decoder for Guild Wars 2 builds (AxiCode format)",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "jest"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"guild-wars-2",
|
|
11
|
+
"gw2",
|
|
12
|
+
"build-code",
|
|
13
|
+
"encoder",
|
|
14
|
+
"decoder",
|
|
15
|
+
"axiforge"
|
|
16
|
+
],
|
|
17
|
+
"author": "darkharasho",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"jest": "^29.7.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/bitBuffer.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
class BitWriter {
|
|
4
|
+
constructor() {
|
|
5
|
+
this._bytes = [];
|
|
6
|
+
this._currentByte = 0;
|
|
7
|
+
this._bitPos = 7; // MSB first, counts down from 7 to 0
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
write(value, numBits) {
|
|
11
|
+
for (let i = numBits - 1; i >= 0; i--) {
|
|
12
|
+
const bit = (value >> i) & 1;
|
|
13
|
+
this._currentByte |= bit << this._bitPos;
|
|
14
|
+
this._bitPos--;
|
|
15
|
+
if (this._bitPos < 0) {
|
|
16
|
+
this._bytes.push(this._currentByte);
|
|
17
|
+
this._currentByte = 0;
|
|
18
|
+
this._bitPos = 7;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
toBytes() {
|
|
24
|
+
const out = [...this._bytes];
|
|
25
|
+
if (this._bitPos < 7) out.push(this._currentByte); // flush partial byte
|
|
26
|
+
return new Uint8Array(out);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
toPaddedBytes(alignment) {
|
|
30
|
+
const bytes = this.toBytes();
|
|
31
|
+
const remainder = bytes.length % alignment;
|
|
32
|
+
if (remainder === 0) return bytes;
|
|
33
|
+
const padded = new Uint8Array(bytes.length + (alignment - remainder));
|
|
34
|
+
padded.set(bytes);
|
|
35
|
+
return padded;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class BitReader {
|
|
40
|
+
constructor(buffer) {
|
|
41
|
+
this._buffer = buffer;
|
|
42
|
+
this._bytePos = 0;
|
|
43
|
+
this._bitPos = 7;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
read(numBits) {
|
|
47
|
+
let value = 0;
|
|
48
|
+
for (let i = 0; i < numBits; i++) {
|
|
49
|
+
if (this._bytePos >= this._buffer.length) {
|
|
50
|
+
throw new Error("Read past end of buffer");
|
|
51
|
+
}
|
|
52
|
+
const bit = (this._buffer[this._bytePos] >> this._bitPos) & 1;
|
|
53
|
+
value = (value << 1) | bit;
|
|
54
|
+
this._bitPos--;
|
|
55
|
+
if (this._bitPos < 0) {
|
|
56
|
+
this._bytePos++;
|
|
57
|
+
this._bitPos = 7;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
bitsRemaining() {
|
|
64
|
+
const totalBits = this._buffer.length * 8;
|
|
65
|
+
const consumed = this._bytePos * 8 + (7 - this._bitPos);
|
|
66
|
+
return totalBits - consumed;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { BitWriter, BitReader };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { BitWriter, BitReader } = require("./bitBuffer");
|
|
4
|
+
const { z85Encode, z85Decode } = require("./z85");
|
|
5
|
+
const {
|
|
6
|
+
professionToIndex, indexToProfession,
|
|
7
|
+
weaponToIndex, indexToWeapon, isWeaponTwoHanded,
|
|
8
|
+
statToIndex, indexToStat,
|
|
9
|
+
relicToIndex, indexToRelic,
|
|
10
|
+
foodToIndex, indexToFood,
|
|
11
|
+
utilityToIndex, indexToUtility,
|
|
12
|
+
legendStringToIndex, indexToLegendString,
|
|
13
|
+
} = require("./tables");
|
|
14
|
+
|
|
15
|
+
const SHARE_CODE_PREFIX = "<AxiForge:";
|
|
16
|
+
const CURRENT_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
const GAME_MODES = ["pve", "pvp", "wvw"];
|
|
19
|
+
const ATTUNEMENTS = ["Fire", "Water", "Air", "Earth"];
|
|
20
|
+
|
|
21
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function skillId(skill) {
|
|
24
|
+
return (skill && skill.id) ? skill.id : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseIntOrZero(v) {
|
|
28
|
+
const n = parseInt(v, 10);
|
|
29
|
+
return Number.isFinite(n) ? n : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Determine trait position (1=top, 2=mid, 3=bottom, 0=none) */
|
|
33
|
+
function traitPosition(spec, tier) {
|
|
34
|
+
const chosen = spec.majorChoices && spec.majorChoices[tier];
|
|
35
|
+
if (!chosen) return 0;
|
|
36
|
+
const tierTraits = spec.majorTraitsByTier && spec.majorTraitsByTier[tier];
|
|
37
|
+
if (!tierTraits) return 0;
|
|
38
|
+
const idx = tierTraits.findIndex(t => t.id === chosen);
|
|
39
|
+
return idx >= 0 ? idx + 1 : 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Check if all values in an array of strings are the same */
|
|
43
|
+
function allSame(arr) {
|
|
44
|
+
if (arr.length === 0) return true;
|
|
45
|
+
return arr.every(v => v === arr[0]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Collect all infusion values into a flat array for uniformity check */
|
|
49
|
+
function collectInfusionValues(infusions) {
|
|
50
|
+
const vals = [];
|
|
51
|
+
for (const key of ["head", "shoulders", "chest", "hands", "legs", "feet", "accessory1", "accessory2"]) {
|
|
52
|
+
if (infusions[key]) vals.push(String(infusions[key]));
|
|
53
|
+
}
|
|
54
|
+
for (const key of ["back", "ring1", "ring2", "mainhand1", "offhand1", "mainhand2", "offhand2"]) {
|
|
55
|
+
const v = infusions[key];
|
|
56
|
+
if (Array.isArray(v)) {
|
|
57
|
+
v.forEach(x => { if (x) vals.push(String(x)); });
|
|
58
|
+
} else if (v) {
|
|
59
|
+
vals.push(String(v));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return vals;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Determine if profession has non-default specific data */
|
|
66
|
+
function hasProfessionData(build) {
|
|
67
|
+
const prof = build.profession;
|
|
68
|
+
if (prof === "Revenant") {
|
|
69
|
+
return (build.selectedLegends && (build.selectedLegends[0] || build.selectedLegends[1]));
|
|
70
|
+
}
|
|
71
|
+
if (prof === "Ranger") {
|
|
72
|
+
const p = build.selectedPets;
|
|
73
|
+
return p && (p.terrestrial1 || p.terrestrial2);
|
|
74
|
+
}
|
|
75
|
+
if (prof === "Elementalist") {
|
|
76
|
+
return !!build.activeAttunement;
|
|
77
|
+
}
|
|
78
|
+
if (prof === "Engineer") {
|
|
79
|
+
return !!build.activeKit;
|
|
80
|
+
}
|
|
81
|
+
if (prof === "Warrior") {
|
|
82
|
+
return build.activeWeaponSet && build.activeWeaponSet !== 1;
|
|
83
|
+
}
|
|
84
|
+
if (prof === "Thief") {
|
|
85
|
+
const a = build.antiquaryArtifacts;
|
|
86
|
+
return a && (a.f2 || a.f3 || a.f4);
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Encoder ──────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
function encodeShareCode(build) {
|
|
94
|
+
const w = new BitWriter();
|
|
95
|
+
|
|
96
|
+
const weapons = build.equipment.weapons;
|
|
97
|
+
const runes = build.equipment.runes;
|
|
98
|
+
const infusions = build.equipment.infusions;
|
|
99
|
+
|
|
100
|
+
// Determine flags
|
|
101
|
+
const hasUnderwater = !!(
|
|
102
|
+
skillId(build.underwaterSkills && build.underwaterSkills.heal) ||
|
|
103
|
+
(build.underwaterSkills && build.underwaterSkills.utility &&
|
|
104
|
+
build.underwaterSkills.utility.some(s => skillId(s))) ||
|
|
105
|
+
skillId(build.underwaterSkills && build.underwaterSkills.elite) ||
|
|
106
|
+
weapons.aquatic1 || weapons.aquatic2
|
|
107
|
+
);
|
|
108
|
+
const hasOffhand1 = !!weapons.offhand1;
|
|
109
|
+
const hasOffhand2 = !!weapons.offhand2;
|
|
110
|
+
const hasWeaponSet2 = !!weapons.mainhand2;
|
|
111
|
+
const hasProfData = hasProfessionData(build);
|
|
112
|
+
|
|
113
|
+
// Per-slot stats: always 0 for v1 (uniform statPackage)
|
|
114
|
+
const perSlotStats = false;
|
|
115
|
+
|
|
116
|
+
// Per-slot runes: check if all 6 armor rune slots are the same
|
|
117
|
+
const runeSlots = [runes.head, runes.shoulders, runes.chest, runes.hands, runes.legs, runes.feet];
|
|
118
|
+
const perSlotRunes = !allSame(runeSlots.map(String));
|
|
119
|
+
|
|
120
|
+
// Per-slot infusions
|
|
121
|
+
const infValues = collectInfusionValues(infusions);
|
|
122
|
+
const perSlotInfusions = infValues.length > 0 && !allSame(infValues);
|
|
123
|
+
|
|
124
|
+
const flags =
|
|
125
|
+
(hasUnderwater ? 1 : 0) |
|
|
126
|
+
(hasOffhand1 ? 2 : 0) |
|
|
127
|
+
(hasOffhand2 ? 4 : 0) |
|
|
128
|
+
(hasWeaponSet2 ? 8 : 0) |
|
|
129
|
+
(hasProfData ? 16 : 0) |
|
|
130
|
+
(perSlotStats ? 32 : 0) |
|
|
131
|
+
(perSlotRunes ? 64 : 0) |
|
|
132
|
+
(perSlotInfusions ? 128 : 0);
|
|
133
|
+
|
|
134
|
+
// Header
|
|
135
|
+
w.write(CURRENT_VERSION, 4);
|
|
136
|
+
w.write(flags, 8);
|
|
137
|
+
|
|
138
|
+
// Core build
|
|
139
|
+
w.write(professionToIndex(build.profession), 4);
|
|
140
|
+
w.write(GAME_MODES.indexOf(build.gameMode), 2);
|
|
141
|
+
|
|
142
|
+
for (let i = 0; i < 3; i++) {
|
|
143
|
+
const spec = build.specializations[i];
|
|
144
|
+
if (spec && spec.id) {
|
|
145
|
+
w.write(spec.id, 7);
|
|
146
|
+
for (let tier = 1; tier <= 3; tier++) {
|
|
147
|
+
w.write(traitPosition(spec, tier), 2);
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
w.write(0, 7);
|
|
151
|
+
w.write(0, 6);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Skills
|
|
156
|
+
w.write(skillId(build.skills.heal), 17);
|
|
157
|
+
for (let i = 0; i < 3; i++) {
|
|
158
|
+
w.write(skillId(build.skills.utility[i]), 17);
|
|
159
|
+
}
|
|
160
|
+
w.write(skillId(build.skills.elite), 17);
|
|
161
|
+
|
|
162
|
+
// Equipment - Weapons
|
|
163
|
+
const mh1Idx = weaponToIndex(weapons.mainhand1);
|
|
164
|
+
w.write(mh1Idx, 5);
|
|
165
|
+
if (hasOffhand1) w.write(weaponToIndex(weapons.offhand1), 5);
|
|
166
|
+
if (hasWeaponSet2) {
|
|
167
|
+
const mh2Idx = weaponToIndex(weapons.mainhand2);
|
|
168
|
+
w.write(mh2Idx, 5);
|
|
169
|
+
if (hasOffhand2) w.write(weaponToIndex(weapons.offhand2), 5);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Equipment - Stats
|
|
173
|
+
if (!perSlotStats) {
|
|
174
|
+
w.write(statToIndex(build.equipment.statPackage), 5);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Equipment - Runes
|
|
178
|
+
if (!perSlotRunes) {
|
|
179
|
+
w.write(parseIntOrZero(runes.head), 17);
|
|
180
|
+
} else {
|
|
181
|
+
for (const slot of ["head", "shoulders", "chest", "hands", "legs", "feet"]) {
|
|
182
|
+
w.write(parseIntOrZero(runes[slot]), 17);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Equipment - Sigils
|
|
187
|
+
const mh1TwoHanded = isWeaponTwoHanded(mh1Idx);
|
|
188
|
+
const mh1Sigils = build.equipment.sigils.mainhand1 || [];
|
|
189
|
+
if (mh1TwoHanded) {
|
|
190
|
+
w.write(parseIntOrZero(mh1Sigils[0]), 17);
|
|
191
|
+
w.write(parseIntOrZero(mh1Sigils[1]), 17);
|
|
192
|
+
} else {
|
|
193
|
+
w.write(parseIntOrZero(mh1Sigils[0]), 17);
|
|
194
|
+
}
|
|
195
|
+
if (hasOffhand1) {
|
|
196
|
+
const oh1Sigils = build.equipment.sigils.offhand1 || [];
|
|
197
|
+
w.write(parseIntOrZero(oh1Sigils[0]), 17);
|
|
198
|
+
}
|
|
199
|
+
if (hasWeaponSet2) {
|
|
200
|
+
const mh2Idx = weaponToIndex(weapons.mainhand2);
|
|
201
|
+
const mh2TwoHanded = isWeaponTwoHanded(mh2Idx);
|
|
202
|
+
const mh2Sigils = build.equipment.sigils.mainhand2 || [];
|
|
203
|
+
if (mh2TwoHanded) {
|
|
204
|
+
w.write(parseIntOrZero(mh2Sigils[0]), 17);
|
|
205
|
+
w.write(parseIntOrZero(mh2Sigils[1]), 17);
|
|
206
|
+
} else {
|
|
207
|
+
w.write(parseIntOrZero(mh2Sigils[0]), 17);
|
|
208
|
+
}
|
|
209
|
+
if (hasOffhand2) {
|
|
210
|
+
const oh2Sigils = build.equipment.sigils.offhand2 || [];
|
|
211
|
+
w.write(parseIntOrZero(oh2Sigils[0]), 17);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Relic, Food, Utility, Enrichment
|
|
216
|
+
w.write(relicToIndex(build.equipment.relic), 7);
|
|
217
|
+
w.write(foodToIndex(build.equipment.food), 4);
|
|
218
|
+
w.write(utilityToIndex(build.equipment.utility), 3);
|
|
219
|
+
w.write(parseIntOrZero(build.equipment.enrichment), 17);
|
|
220
|
+
|
|
221
|
+
// Infusions
|
|
222
|
+
if (!perSlotInfusions) {
|
|
223
|
+
const uniformVal = infValues.length > 0 ? parseIntOrZero(infValues[0]) : 0;
|
|
224
|
+
w.write(uniformVal, 17);
|
|
225
|
+
} else {
|
|
226
|
+
const writeInfSlot = (val) => w.write(parseIntOrZero(val), 17);
|
|
227
|
+
const writeArraySlots = (arr, count) => {
|
|
228
|
+
for (let i = 0; i < count; i++) writeInfSlot(Array.isArray(arr) ? arr[i] : arr);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
for (const slot of ["head", "shoulders", "chest", "hands", "legs", "feet"]) {
|
|
232
|
+
writeInfSlot(infusions[slot]);
|
|
233
|
+
}
|
|
234
|
+
writeArraySlots(infusions.back, 2);
|
|
235
|
+
writeArraySlots(infusions.ring1, 3);
|
|
236
|
+
writeArraySlots(infusions.ring2, 3);
|
|
237
|
+
writeInfSlot(infusions.accessory1);
|
|
238
|
+
writeInfSlot(infusions.accessory2);
|
|
239
|
+
writeArraySlots(infusions.mainhand1, 2);
|
|
240
|
+
if (hasOffhand1) {
|
|
241
|
+
const oh1Inf = infusions.offhand1;
|
|
242
|
+
writeInfSlot(Array.isArray(oh1Inf) ? oh1Inf[0] : oh1Inf);
|
|
243
|
+
}
|
|
244
|
+
if (hasWeaponSet2) {
|
|
245
|
+
writeArraySlots(infusions.mainhand2, 2);
|
|
246
|
+
if (hasOffhand2) {
|
|
247
|
+
const oh2Inf = infusions.offhand2;
|
|
248
|
+
writeInfSlot(Array.isArray(oh2Inf) ? oh2Inf[0] : oh2Inf);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (hasUnderwater) {
|
|
252
|
+
writeInfSlot(infusions.breather || 0);
|
|
253
|
+
writeArraySlots(infusions.aquatic1 || [0, 0], 2);
|
|
254
|
+
writeArraySlots(infusions.aquatic2 || [0, 0], 2);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Underwater section
|
|
259
|
+
if (hasUnderwater) {
|
|
260
|
+
const uw = build.underwaterSkills || {};
|
|
261
|
+
w.write(skillId(uw.heal), 17);
|
|
262
|
+
const uwUtil = uw.utility || [null, null, null];
|
|
263
|
+
for (let i = 0; i < 3; i++) w.write(skillId(uwUtil[i]), 17);
|
|
264
|
+
w.write(skillId(uw.elite), 17);
|
|
265
|
+
|
|
266
|
+
const aq1Idx = weaponToIndex(weapons.aquatic1);
|
|
267
|
+
const aq2Idx = weaponToIndex(weapons.aquatic2);
|
|
268
|
+
w.write(aq1Idx, 5);
|
|
269
|
+
w.write(aq2Idx, 5);
|
|
270
|
+
|
|
271
|
+
const aq1Sigils = build.equipment.sigils.aquatic1 || [];
|
|
272
|
+
w.write(parseIntOrZero(aq1Sigils[0]), 17);
|
|
273
|
+
w.write(parseIntOrZero(aq1Sigils[1]), 17);
|
|
274
|
+
|
|
275
|
+
if (aq2Idx) {
|
|
276
|
+
const aq2Sigils = build.equipment.sigils.aquatic2 || [];
|
|
277
|
+
w.write(parseIntOrZero(aq2Sigils[0]), 17);
|
|
278
|
+
w.write(parseIntOrZero(aq2Sigils[1]), 17);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Profession-specific section
|
|
283
|
+
if (hasProfData) {
|
|
284
|
+
const prof = build.profession;
|
|
285
|
+
if (prof === "Revenant") {
|
|
286
|
+
const legs = build.selectedLegends || ["", ""];
|
|
287
|
+
w.write(legendStringToIndex(legs[0]), 3);
|
|
288
|
+
w.write(legendStringToIndex(legs[1]), 3);
|
|
289
|
+
w.write(0, 1);
|
|
290
|
+
w.write(build.allianceTacticsForm || 0, 1);
|
|
291
|
+
if (hasUnderwater) {
|
|
292
|
+
const uwLegs = build.selectedUnderwaterLegends || ["", ""];
|
|
293
|
+
w.write(legendStringToIndex(uwLegs[0]), 3);
|
|
294
|
+
w.write(legendStringToIndex(uwLegs[1]), 3);
|
|
295
|
+
}
|
|
296
|
+
} else if (prof === "Ranger") {
|
|
297
|
+
const pets = build.selectedPets || {};
|
|
298
|
+
w.write(pets.terrestrial1 || 0, 7);
|
|
299
|
+
w.write(pets.terrestrial2 || 0, 7);
|
|
300
|
+
if (hasUnderwater) {
|
|
301
|
+
w.write(pets.aquatic1 || 0, 7);
|
|
302
|
+
w.write(pets.aquatic2 || 0, 7);
|
|
303
|
+
}
|
|
304
|
+
} else if (prof === "Elementalist") {
|
|
305
|
+
const att1 = ATTUNEMENTS.indexOf(build.activeAttunement);
|
|
306
|
+
w.write(att1 >= 0 ? att1 : 0, 2);
|
|
307
|
+
const att2 = ATTUNEMENTS.indexOf(build.activeAttunement2);
|
|
308
|
+
w.write(att2 >= 0 ? att2 : 0, 2);
|
|
309
|
+
} else if (prof === "Engineer") {
|
|
310
|
+
w.write(build.activeKit || 0, 17);
|
|
311
|
+
} else if (prof === "Warrior") {
|
|
312
|
+
w.write(build.activeWeaponSet === 2 ? 1 : 0, 1);
|
|
313
|
+
} else if (prof === "Thief") {
|
|
314
|
+
const art = build.antiquaryArtifacts || {};
|
|
315
|
+
w.write(art.f2 || 0, 17);
|
|
316
|
+
w.write(art.f3 || 0, 17);
|
|
317
|
+
w.write(art.f4 || 0, 17);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Pad to 4-byte boundary for Z85, encode
|
|
322
|
+
const bytes = w.toPaddedBytes(4);
|
|
323
|
+
const payload = z85Encode(bytes);
|
|
324
|
+
|
|
325
|
+
// Label: elite spec name or profession name
|
|
326
|
+
let label = build.profession;
|
|
327
|
+
const eliteSpec = build.specializations.find(s => s && s.elite);
|
|
328
|
+
if (eliteSpec) label = eliteSpec.name;
|
|
329
|
+
|
|
330
|
+
return `<AxiForge:${label}:${payload}>`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── Decoder ──────────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
function parseShareCodeWrapper(code) {
|
|
336
|
+
if (!code.startsWith(SHARE_CODE_PREFIX) || !code.endsWith(">")) return null;
|
|
337
|
+
const inner = code.slice(SHARE_CODE_PREFIX.length, -1);
|
|
338
|
+
const colonIdx = inner.indexOf(":");
|
|
339
|
+
if (colonIdx < 1) return null;
|
|
340
|
+
const label = inner.slice(0, colonIdx);
|
|
341
|
+
const payload = inner.slice(colonIdx + 1);
|
|
342
|
+
if (!payload) return null;
|
|
343
|
+
return { label, payload };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function decodeShareCode(code) {
|
|
347
|
+
const parsed = parseShareCodeWrapper(code);
|
|
348
|
+
if (!parsed) throw new Error("Invalid build code format");
|
|
349
|
+
|
|
350
|
+
const payload = parsed.payload;
|
|
351
|
+
let bytes;
|
|
352
|
+
try {
|
|
353
|
+
bytes = z85Decode(payload);
|
|
354
|
+
} catch (e) {
|
|
355
|
+
throw new Error("Corrupted build code");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const r = new BitReader(bytes);
|
|
359
|
+
|
|
360
|
+
const version = r.read(4);
|
|
361
|
+
if (version !== 1) {
|
|
362
|
+
throw new Error("This build code requires a newer version of AxiForge");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const flags = r.read(8);
|
|
366
|
+
const hasUnderwater = !!(flags & 1);
|
|
367
|
+
const hasOffhand1 = !!(flags & 2);
|
|
368
|
+
const hasOffhand2 = !!(flags & 4);
|
|
369
|
+
const hasWeaponSet2 = !!(flags & 8);
|
|
370
|
+
const hasProfData = !!(flags & 16);
|
|
371
|
+
const perSlotStats = !!(flags & 32);
|
|
372
|
+
const perSlotRunes = !!(flags & 64);
|
|
373
|
+
const perSlotInfusions = !!(flags & 128);
|
|
374
|
+
|
|
375
|
+
// Core build
|
|
376
|
+
const profIdx = r.read(4);
|
|
377
|
+
const profession = indexToProfession(profIdx);
|
|
378
|
+
const gameModeIdx = r.read(2);
|
|
379
|
+
const gameMode = GAME_MODES[gameModeIdx] || "pve";
|
|
380
|
+
|
|
381
|
+
const specializations = [];
|
|
382
|
+
for (let i = 0; i < 3; i++) {
|
|
383
|
+
const id = r.read(7);
|
|
384
|
+
const t1 = r.read(2);
|
|
385
|
+
const t2 = r.read(2);
|
|
386
|
+
const t3 = r.read(2);
|
|
387
|
+
specializations.push({ id, traitChoices: [t1, t2, t3] });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Skills
|
|
391
|
+
const healId = r.read(17);
|
|
392
|
+
const utilityIds = [r.read(17), r.read(17), r.read(17)];
|
|
393
|
+
const eliteId = r.read(17);
|
|
394
|
+
|
|
395
|
+
// Weapons
|
|
396
|
+
const mh1Idx = r.read(5);
|
|
397
|
+
const weaponsOut = {
|
|
398
|
+
mainhand1: indexToWeapon(mh1Idx),
|
|
399
|
+
offhand1: "",
|
|
400
|
+
mainhand2: "",
|
|
401
|
+
offhand2: "",
|
|
402
|
+
aquatic1: "",
|
|
403
|
+
aquatic2: "",
|
|
404
|
+
};
|
|
405
|
+
let oh1Idx = 0;
|
|
406
|
+
if (hasOffhand1) {
|
|
407
|
+
oh1Idx = r.read(5);
|
|
408
|
+
weaponsOut.offhand1 = indexToWeapon(oh1Idx);
|
|
409
|
+
}
|
|
410
|
+
let mh2Idx = 0;
|
|
411
|
+
let oh2Idx = 0;
|
|
412
|
+
if (hasWeaponSet2) {
|
|
413
|
+
mh2Idx = r.read(5);
|
|
414
|
+
weaponsOut.mainhand2 = indexToWeapon(mh2Idx);
|
|
415
|
+
if (hasOffhand2) {
|
|
416
|
+
oh2Idx = r.read(5);
|
|
417
|
+
weaponsOut.offhand2 = indexToWeapon(oh2Idx);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Stats
|
|
422
|
+
let statPackage = "";
|
|
423
|
+
if (!perSlotStats) {
|
|
424
|
+
statPackage = indexToStat(r.read(5));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Runes
|
|
428
|
+
const runesOut = {};
|
|
429
|
+
const RUNE_SLOTS = ["head", "shoulders", "chest", "hands", "legs", "feet"];
|
|
430
|
+
if (!perSlotRunes) {
|
|
431
|
+
const runeId = String(r.read(17));
|
|
432
|
+
for (const slot of RUNE_SLOTS) runesOut[slot] = runeId;
|
|
433
|
+
} else {
|
|
434
|
+
for (const slot of RUNE_SLOTS) runesOut[slot] = String(r.read(17));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Sigils
|
|
438
|
+
const sigilsOut = { mainhand1: [], offhand1: [], mainhand2: [], offhand2: [], aquatic1: [], aquatic2: [] };
|
|
439
|
+
const mh1TwoHanded = isWeaponTwoHanded(mh1Idx);
|
|
440
|
+
if (mh1TwoHanded) {
|
|
441
|
+
sigilsOut.mainhand1 = [String(r.read(17)), String(r.read(17))];
|
|
442
|
+
} else {
|
|
443
|
+
sigilsOut.mainhand1 = [String(r.read(17))];
|
|
444
|
+
}
|
|
445
|
+
if (hasOffhand1) {
|
|
446
|
+
sigilsOut.offhand1 = [String(r.read(17))];
|
|
447
|
+
}
|
|
448
|
+
if (hasWeaponSet2) {
|
|
449
|
+
const mh2TwoHanded = isWeaponTwoHanded(mh2Idx);
|
|
450
|
+
if (mh2TwoHanded) {
|
|
451
|
+
sigilsOut.mainhand2 = [String(r.read(17)), String(r.read(17))];
|
|
452
|
+
} else {
|
|
453
|
+
sigilsOut.mainhand2 = [String(r.read(17))];
|
|
454
|
+
}
|
|
455
|
+
if (hasOffhand2) {
|
|
456
|
+
sigilsOut.offhand2 = [String(r.read(17))];
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Relic, Food, Utility, Enrichment
|
|
461
|
+
const relic = indexToRelic(r.read(7));
|
|
462
|
+
const foodEntry = indexToFood(r.read(4));
|
|
463
|
+
const food = foodEntry.label;
|
|
464
|
+
const utilEntry = indexToUtility(r.read(3));
|
|
465
|
+
const utility = utilEntry.label;
|
|
466
|
+
const enrichment = String(r.read(17));
|
|
467
|
+
|
|
468
|
+
// Infusions
|
|
469
|
+
const infusionsOut = {};
|
|
470
|
+
if (!perSlotInfusions) {
|
|
471
|
+
const infId = String(r.read(17));
|
|
472
|
+
for (const slot of ["head", "shoulders", "chest", "hands", "legs", "feet"]) {
|
|
473
|
+
infusionsOut[slot] = infId;
|
|
474
|
+
}
|
|
475
|
+
infusionsOut.back = [infId, infId];
|
|
476
|
+
infusionsOut.ring1 = [infId, infId, infId];
|
|
477
|
+
infusionsOut.ring2 = [infId, infId, infId];
|
|
478
|
+
infusionsOut.accessory1 = infId;
|
|
479
|
+
infusionsOut.accessory2 = infId;
|
|
480
|
+
infusionsOut.mainhand1 = [infId, infId];
|
|
481
|
+
infusionsOut.offhand1 = hasOffhand1 ? [infId] : [];
|
|
482
|
+
infusionsOut.mainhand2 = hasWeaponSet2 ? [infId, infId] : [];
|
|
483
|
+
infusionsOut.offhand2 = (hasWeaponSet2 && hasOffhand2) ? [infId] : [];
|
|
484
|
+
} else {
|
|
485
|
+
for (const slot of ["head", "shoulders", "chest", "hands", "legs", "feet"]) {
|
|
486
|
+
infusionsOut[slot] = String(r.read(17));
|
|
487
|
+
}
|
|
488
|
+
infusionsOut.back = [String(r.read(17)), String(r.read(17))];
|
|
489
|
+
infusionsOut.ring1 = [String(r.read(17)), String(r.read(17)), String(r.read(17))];
|
|
490
|
+
infusionsOut.ring2 = [String(r.read(17)), String(r.read(17)), String(r.read(17))];
|
|
491
|
+
infusionsOut.accessory1 = String(r.read(17));
|
|
492
|
+
infusionsOut.accessory2 = String(r.read(17));
|
|
493
|
+
infusionsOut.mainhand1 = [String(r.read(17)), String(r.read(17))];
|
|
494
|
+
if (hasOffhand1) infusionsOut.offhand1 = [String(r.read(17))];
|
|
495
|
+
else infusionsOut.offhand1 = [];
|
|
496
|
+
if (hasWeaponSet2) {
|
|
497
|
+
infusionsOut.mainhand2 = [String(r.read(17)), String(r.read(17))];
|
|
498
|
+
if (hasOffhand2) infusionsOut.offhand2 = [String(r.read(17))];
|
|
499
|
+
else infusionsOut.offhand2 = [];
|
|
500
|
+
} else {
|
|
501
|
+
infusionsOut.mainhand2 = [];
|
|
502
|
+
infusionsOut.offhand2 = [];
|
|
503
|
+
}
|
|
504
|
+
if (hasUnderwater) {
|
|
505
|
+
infusionsOut.breather = String(r.read(17));
|
|
506
|
+
infusionsOut.aquatic1 = [String(r.read(17)), String(r.read(17))];
|
|
507
|
+
infusionsOut.aquatic2 = [String(r.read(17)), String(r.read(17))];
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Underwater section
|
|
512
|
+
let underwaterSkills = { healId: 0, utilityIds: [0, 0, 0], eliteId: 0 };
|
|
513
|
+
if (hasUnderwater) {
|
|
514
|
+
const uwHealId = r.read(17);
|
|
515
|
+
const uwUtilIds = [r.read(17), r.read(17), r.read(17)];
|
|
516
|
+
const uwEliteId = r.read(17);
|
|
517
|
+
underwaterSkills = { healId: uwHealId, utilityIds: uwUtilIds, eliteId: uwEliteId };
|
|
518
|
+
|
|
519
|
+
const aq1Idx = r.read(5);
|
|
520
|
+
const aq2Idx = r.read(5);
|
|
521
|
+
weaponsOut.aquatic1 = indexToWeapon(aq1Idx);
|
|
522
|
+
weaponsOut.aquatic2 = indexToWeapon(aq2Idx);
|
|
523
|
+
|
|
524
|
+
sigilsOut.aquatic1 = [String(r.read(17)), String(r.read(17))];
|
|
525
|
+
if (aq2Idx) {
|
|
526
|
+
sigilsOut.aquatic2 = [String(r.read(17)), String(r.read(17))];
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Profession-specific
|
|
531
|
+
let selectedLegends = ["", ""];
|
|
532
|
+
let selectedUnderwaterLegends = ["", ""];
|
|
533
|
+
let selectedPets = { terrestrial1: 0, terrestrial2: 0, aquatic1: 0, aquatic2: 0 };
|
|
534
|
+
let activeAttunement = "";
|
|
535
|
+
let activeAttunement2 = "";
|
|
536
|
+
let activeKit = 0;
|
|
537
|
+
let activeWeaponSet = 1;
|
|
538
|
+
let allianceTacticsForm = 0;
|
|
539
|
+
let antiquaryArtifacts = { f2: 0, f3: 0, f4: 0 };
|
|
540
|
+
|
|
541
|
+
if (hasProfData) {
|
|
542
|
+
if (profession === "Revenant") {
|
|
543
|
+
selectedLegends = [indexToLegendString(r.read(3)), indexToLegendString(r.read(3))];
|
|
544
|
+
r.read(1);
|
|
545
|
+
allianceTacticsForm = r.read(1);
|
|
546
|
+
if (hasUnderwater) {
|
|
547
|
+
selectedUnderwaterLegends = [indexToLegendString(r.read(3)), indexToLegendString(r.read(3))];
|
|
548
|
+
}
|
|
549
|
+
} else if (profession === "Ranger") {
|
|
550
|
+
selectedPets = {
|
|
551
|
+
terrestrial1: r.read(7),
|
|
552
|
+
terrestrial2: r.read(7),
|
|
553
|
+
aquatic1: hasUnderwater ? r.read(7) : 0,
|
|
554
|
+
aquatic2: hasUnderwater ? r.read(7) : 0,
|
|
555
|
+
};
|
|
556
|
+
} else if (profession === "Elementalist") {
|
|
557
|
+
const att1Idx = r.read(2);
|
|
558
|
+
activeAttunement = ATTUNEMENTS[att1Idx] || "";
|
|
559
|
+
const att2Idx = r.read(2);
|
|
560
|
+
activeAttunement2 = ATTUNEMENTS[att2Idx] || "";
|
|
561
|
+
} else if (profession === "Engineer") {
|
|
562
|
+
activeKit = r.read(17);
|
|
563
|
+
} else if (profession === "Warrior") {
|
|
564
|
+
activeWeaponSet = r.read(1) === 1 ? 2 : 1;
|
|
565
|
+
} else if (profession === "Thief") {
|
|
566
|
+
antiquaryArtifacts = {
|
|
567
|
+
f2: r.read(17),
|
|
568
|
+
f3: r.read(17),
|
|
569
|
+
f4: r.read(17),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
profession,
|
|
576
|
+
gameMode,
|
|
577
|
+
specializations,
|
|
578
|
+
skills: { healId, utilityIds, eliteId },
|
|
579
|
+
underwaterSkills,
|
|
580
|
+
equipment: {
|
|
581
|
+
statPackage,
|
|
582
|
+
relic,
|
|
583
|
+
food,
|
|
584
|
+
utility,
|
|
585
|
+
enrichment: enrichment === "0" ? "" : enrichment,
|
|
586
|
+
weapons: weaponsOut,
|
|
587
|
+
runes: runesOut,
|
|
588
|
+
sigils: sigilsOut,
|
|
589
|
+
infusions: infusionsOut,
|
|
590
|
+
},
|
|
591
|
+
selectedLegends,
|
|
592
|
+
selectedUnderwaterLegends,
|
|
593
|
+
selectedPets,
|
|
594
|
+
activeAttunement,
|
|
595
|
+
activeAttunement2,
|
|
596
|
+
activeKit,
|
|
597
|
+
activeWeaponSet,
|
|
598
|
+
allianceTacticsForm,
|
|
599
|
+
antiquaryArtifacts,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ── Validator ────────────────────────────────────────────────────────────────
|
|
604
|
+
|
|
605
|
+
function isValidShareCode(text) {
|
|
606
|
+
return parseShareCodeWrapper(text) !== null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
module.exports = { encodeShareCode, decodeShareCode, isValidShareCode };
|
package/src/relics.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Alphabetically sorted relic labels.
|
|
3
|
+
// Source: GW2 in-game relics list
|
|
4
|
+
// MUST be kept in sync when relics are added/removed.
|
|
5
|
+
module.exports = [
|
|
6
|
+
"Relic of Agony",
|
|
7
|
+
"Relic of Akeem",
|
|
8
|
+
"Relic of Altruism",
|
|
9
|
+
"Relic of Antitoxin",
|
|
10
|
+
"Relic of Atrocity",
|
|
11
|
+
"Relic of Bava Nisos",
|
|
12
|
+
"Relic of Bloodstone",
|
|
13
|
+
"Relic of Castora",
|
|
14
|
+
"Relic of Cerus",
|
|
15
|
+
"Relic of Dagda",
|
|
16
|
+
"Relic of Durability",
|
|
17
|
+
"Relic of Dwayna",
|
|
18
|
+
"Relic of Evasion",
|
|
19
|
+
"Relic of Febe",
|
|
20
|
+
"Relic of Fire",
|
|
21
|
+
"Relic of Fireworks",
|
|
22
|
+
"Relic of Fog",
|
|
23
|
+
"Relic of Geysers",
|
|
24
|
+
"Relic of Isgarren",
|
|
25
|
+
"Relic of Karakosa",
|
|
26
|
+
"Relic of Leadership",
|
|
27
|
+
"Relic of Lyhr",
|
|
28
|
+
"Relic of Mabon",
|
|
29
|
+
"Relic of Mercy",
|
|
30
|
+
"Relic of Mistburn",
|
|
31
|
+
"Relic of Mosyn",
|
|
32
|
+
"Relic of Mount Balrior",
|
|
33
|
+
"Relic of Nayos",
|
|
34
|
+
"Relic of Nourys",
|
|
35
|
+
"Relic of Peitha",
|
|
36
|
+
"Relic of Resistance",
|
|
37
|
+
"Relic of Reunification",
|
|
38
|
+
"Relic of Rivers",
|
|
39
|
+
"Relic of Shackles",
|
|
40
|
+
"Relic of Sorrow",
|
|
41
|
+
"Relic of Speed",
|
|
42
|
+
"Relic of Surging",
|
|
43
|
+
"Relic of Thorns",
|
|
44
|
+
"Relic of Vampirism",
|
|
45
|
+
"Relic of Vass",
|
|
46
|
+
"Relic of Zakiros",
|
|
47
|
+
"Relic of the Adventurer",
|
|
48
|
+
"Relic of the Afflicted",
|
|
49
|
+
"Relic of the Alliance",
|
|
50
|
+
"Relic of the Aristocracy",
|
|
51
|
+
"Relic of the Astral Ward",
|
|
52
|
+
"Relic of the Beehive",
|
|
53
|
+
"Relic of the Biomancer",
|
|
54
|
+
"Relic of the Blightbringer",
|
|
55
|
+
"Relic of the Brawler",
|
|
56
|
+
"Relic of the Cavalier",
|
|
57
|
+
"Relic of the Centaur",
|
|
58
|
+
"Relic of the Chronomancer",
|
|
59
|
+
"Relic of the Citadel",
|
|
60
|
+
"Relic of the Claw",
|
|
61
|
+
"Relic of the Coral Heart",
|
|
62
|
+
"Relic of the Daredevil",
|
|
63
|
+
"Relic of the Deadeye",
|
|
64
|
+
"Relic of the Defender",
|
|
65
|
+
"Relic of the Demon Queen",
|
|
66
|
+
"Relic of the Dragonhunter",
|
|
67
|
+
"Relic of the Eagle",
|
|
68
|
+
"Relic of the Earth",
|
|
69
|
+
"Relic of the Firebrand",
|
|
70
|
+
"Relic of the First Revenant",
|
|
71
|
+
"Relic of the Flock",
|
|
72
|
+
"Relic of the Forest Dweller",
|
|
73
|
+
"Relic of the Founding",
|
|
74
|
+
"Relic of the Fractal",
|
|
75
|
+
"Relic of the Golemancer",
|
|
76
|
+
"Relic of the Herald",
|
|
77
|
+
"Relic of the Holosmith",
|
|
78
|
+
"Relic of the Ice",
|
|
79
|
+
"Relic of the Krait",
|
|
80
|
+
"Relic of the Lich",
|
|
81
|
+
"Relic of the Living City",
|
|
82
|
+
"Relic of the Midnight King",
|
|
83
|
+
"Relic of the Mirage",
|
|
84
|
+
"Relic of the Mist Stranger",
|
|
85
|
+
"Relic of the Mists Tide",
|
|
86
|
+
"Relic of the Monk",
|
|
87
|
+
"Relic of the Nautical Beast",
|
|
88
|
+
"Relic of the Necromancer",
|
|
89
|
+
"Relic of the Nightmare",
|
|
90
|
+
"Relic of the Ogre",
|
|
91
|
+
"Relic of the Pack",
|
|
92
|
+
"Relic of the Phenom",
|
|
93
|
+
"Relic of the Pirate Queen",
|
|
94
|
+
"Relic of the Privateer",
|
|
95
|
+
"Relic of the Reaper",
|
|
96
|
+
"Relic of the Scoundrel",
|
|
97
|
+
"Relic of the Scourge",
|
|
98
|
+
"Relic of the Sorcerer",
|
|
99
|
+
"Relic of the Steamshrieker",
|
|
100
|
+
"Relic of the Stormsinger",
|
|
101
|
+
"Relic of the Sunless",
|
|
102
|
+
"Relic of the Thief",
|
|
103
|
+
"Relic of the Trooper",
|
|
104
|
+
"Relic of the Twin Generals",
|
|
105
|
+
"Relic of the Unseen Invasion",
|
|
106
|
+
"Relic of the Warrior",
|
|
107
|
+
"Relic of the Water",
|
|
108
|
+
"Relic of the Wayfinder",
|
|
109
|
+
"Relic of the Weaver",
|
|
110
|
+
"Relic of the Wizard's Tower",
|
|
111
|
+
"Relic of the Zephyrite",
|
|
112
|
+
];
|
package/src/tables.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Canonical profession order — matches spec profession table.
|
|
4
|
+
const PROFESSIONS = [
|
|
5
|
+
"Guardian", "Warrior", "Engineer", "Ranger", "Thief",
|
|
6
|
+
"Elementalist", "Mesmer", "Necromancer", "Revenant",
|
|
7
|
+
];
|
|
8
|
+
const _profIdx = new Map(PROFESSIONS.map((p, i) => [p, i]));
|
|
9
|
+
function professionToIndex(name) { return _profIdx.get(name) ?? -1; }
|
|
10
|
+
function indexToProfession(idx) { return PROFESSIONS[idx] || ""; }
|
|
11
|
+
|
|
12
|
+
// Weapon type table — index 0 = empty, 1-19 = weapons.
|
|
13
|
+
const WEAPONS = [
|
|
14
|
+
"",
|
|
15
|
+
"axe", "dagger", "mace", "pistol", "sword", "scepter",
|
|
16
|
+
"focus", "shield", "torch", "warhorn",
|
|
17
|
+
"greatsword", "hammer", "longbow", "rifle", "shortbow", "staff",
|
|
18
|
+
"harpoon", "spear", "trident",
|
|
19
|
+
];
|
|
20
|
+
const _weapIdx = new Map(WEAPONS.map((w, i) => [w, i]));
|
|
21
|
+
function weaponToIndex(id) { return _weapIdx.get(id) ?? 0; }
|
|
22
|
+
function indexToWeapon(idx) { return WEAPONS[idx] || ""; }
|
|
23
|
+
const TWO_HANDED = new Set([11, 12, 13, 14, 15, 16, 18]);
|
|
24
|
+
function isWeaponTwoHanded(idx) { return TWO_HANDED.has(idx); }
|
|
25
|
+
|
|
26
|
+
// Stat combo table — index 0 = empty, 1-21 = stats.
|
|
27
|
+
const STAT_COMBOS_ORDERED = [
|
|
28
|
+
"",
|
|
29
|
+
"Berserker's", "Marauder's", "Assassin's", "Valkyrie", "Dragon's",
|
|
30
|
+
"Viper's", "Grieving", "Sinister", "Dire", "Rabid", "Carrion",
|
|
31
|
+
"Trailblazer's", "Knight's", "Soldier's", "Cleric's", "Minstrel's",
|
|
32
|
+
"Harrier's", "Ritualist's", "Seraph", "Zealot's", "Celestial",
|
|
33
|
+
];
|
|
34
|
+
const _statIdx = new Map(STAT_COMBOS_ORDERED.map((s, i) => [s, i]));
|
|
35
|
+
function statToIndex(label) { return _statIdx.get(label) ?? 0; }
|
|
36
|
+
function indexToStat(idx) { return STAT_COMBOS_ORDERED[idx] || ""; }
|
|
37
|
+
|
|
38
|
+
// Relic table — sorted alphabetically per spec.
|
|
39
|
+
const _relicLabels = require("./relics");
|
|
40
|
+
const RELICS_SORTED = ["", ..._relicLabels];
|
|
41
|
+
const _relicIdx = new Map(RELICS_SORTED.map((r, i) => [r, i]));
|
|
42
|
+
function relicToIndex(label) { return _relicIdx.get(label) ?? 0; }
|
|
43
|
+
function indexToRelic(idx) { return RELICS_SORTED[idx] || ""; }
|
|
44
|
+
|
|
45
|
+
// Food table — ordered by array position in constants.js GW2_FOOD.
|
|
46
|
+
const FOOD_ORDERED = [
|
|
47
|
+
{ label: "", id: 0 },
|
|
48
|
+
{ label: "Peppercorn-Crusted Sous-Vide Steak", id: 91734 },
|
|
49
|
+
{ label: "Cilantro Lime Sous-Vide Steak", id: 91805 },
|
|
50
|
+
{ label: "Bowl of Sweet and Spicy Butternut Squash Soup", id: 41569 },
|
|
51
|
+
{ label: "Plate of Truffle Steak Dinner", id: 12469 },
|
|
52
|
+
{ label: "Bowl of Fancy Potato and Leek Soup", id: 12485 },
|
|
53
|
+
{ label: "Plate of Beef Rendang", id: 86997 },
|
|
54
|
+
{ label: "Plate of Kimchi Pancakes", id: 96578 },
|
|
55
|
+
{ label: "Mint-Pear Cured Meat Flatbread", id: 91703 },
|
|
56
|
+
{ label: "Clove-Spiced Pear and Cured Meat Flatbread", id: 91784 },
|
|
57
|
+
{ label: "Mint and Veggie Flatbread", id: 91727 },
|
|
58
|
+
{ label: "Delicious Rice Ball", id: 68634 },
|
|
59
|
+
{ label: "Eggs Benedict with Mint-Parsley Sauce", id: 91758 },
|
|
60
|
+
{ label: "Bowl of Fruit Salad with Mint Garnish", id: 91690 },
|
|
61
|
+
{ label: "Bowl of Seaweed Salad", id: 12471 },
|
|
62
|
+
];
|
|
63
|
+
const _foodIdx = new Map(FOOD_ORDERED.map((f, i) => [f.label, i]));
|
|
64
|
+
function foodToIndex(label) { return _foodIdx.get(label) ?? 0; }
|
|
65
|
+
function indexToFood(idx) { return FOOD_ORDERED[idx] || FOOD_ORDERED[0]; }
|
|
66
|
+
|
|
67
|
+
// Utility buff table
|
|
68
|
+
const UTILITY_ORDERED = [
|
|
69
|
+
{ label: "", id: 0 },
|
|
70
|
+
{ label: "Superior Sharpening Stone", id: 78305 },
|
|
71
|
+
{ label: "Furious Sharpening Stone", id: 67530 },
|
|
72
|
+
{ label: "Bountiful Sharpening Stone", id: 67531 },
|
|
73
|
+
{ label: "Bountiful Maintenance Oil", id: 67528 },
|
|
74
|
+
{ label: "Furious Maintenance Oil", id: 67529 },
|
|
75
|
+
];
|
|
76
|
+
const _utilIdx = new Map(UTILITY_ORDERED.map((u, i) => [u.label, i]));
|
|
77
|
+
function utilityToIndex(label) { return _utilIdx.get(label) ?? 0; }
|
|
78
|
+
function indexToUtility(idx) { return UTILITY_ORDERED[idx] || UTILITY_ORDERED[0]; }
|
|
79
|
+
|
|
80
|
+
// Revenant legend string → index mapping.
|
|
81
|
+
const LEGEND_STRINGS = ["", "Legend1", "Legend2", "Legend3", "Legend4", "Legend5", "Legend6", "Legend7"];
|
|
82
|
+
const _legIdx = new Map(LEGEND_STRINGS.map((l, i) => [l, i]));
|
|
83
|
+
function legendStringToIndex(str) { return _legIdx.get(str) ?? 0; }
|
|
84
|
+
function indexToLegendString(idx) { return LEGEND_STRINGS[idx] || ""; }
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
PROFESSIONS, professionToIndex, indexToProfession,
|
|
88
|
+
WEAPONS, weaponToIndex, indexToWeapon, isWeaponTwoHanded,
|
|
89
|
+
STAT_COMBOS_ORDERED, statToIndex, indexToStat,
|
|
90
|
+
relicToIndex, indexToRelic,
|
|
91
|
+
FOOD_ORDERED, foodToIndex, indexToFood,
|
|
92
|
+
UTILITY_ORDERED, utilityToIndex, indexToUtility,
|
|
93
|
+
LEGEND_STRINGS, legendStringToIndex, indexToLegendString,
|
|
94
|
+
};
|
package/src/z85.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Z85 alphabet per ZeroMQ RFC 32
|
|
4
|
+
const Z85_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-:+=^!/*?&<>()[]{}@%$#";
|
|
5
|
+
const Z85_DECODE = new Uint8Array(128);
|
|
6
|
+
for (let i = 0; i < 85; i++) Z85_DECODE[Z85_CHARS.charCodeAt(i)] = i;
|
|
7
|
+
|
|
8
|
+
// Divisors for base-85 encoding of a 32-bit value
|
|
9
|
+
const DIVISORS = [85 * 85 * 85 * 85, 85 * 85 * 85, 85 * 85, 85, 1];
|
|
10
|
+
|
|
11
|
+
function z85Encode(buffer) {
|
|
12
|
+
if (buffer.length % 4 !== 0) {
|
|
13
|
+
throw new Error("Z85 input must be a multiple of 4 bytes");
|
|
14
|
+
}
|
|
15
|
+
let out = "";
|
|
16
|
+
for (let i = 0; i < buffer.length; i += 4) {
|
|
17
|
+
let value = ((buffer[i] << 24) | (buffer[i + 1] << 16) | (buffer[i + 2] << 8) | buffer[i + 3]) >>> 0;
|
|
18
|
+
for (let j = 0; j < 5; j++) {
|
|
19
|
+
const idx = Math.floor(value / DIVISORS[j]) % 85;
|
|
20
|
+
out += Z85_CHARS[idx];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function z85Decode(str) {
|
|
27
|
+
if (str.length % 5 !== 0) {
|
|
28
|
+
throw new Error("Z85 string must be a multiple of 5 characters");
|
|
29
|
+
}
|
|
30
|
+
const out = new Uint8Array((str.length / 5) * 4);
|
|
31
|
+
for (let i = 0, byteIdx = 0; i < str.length; i += 5, byteIdx += 4) {
|
|
32
|
+
let value = 0;
|
|
33
|
+
for (let j = 0; j < 5; j++) {
|
|
34
|
+
value = value * 85 + Z85_DECODE[str.charCodeAt(i + j)];
|
|
35
|
+
}
|
|
36
|
+
out[byteIdx] = (value >>> 24) & 0xFF;
|
|
37
|
+
out[byteIdx + 1] = (value >>> 16) & 0xFF;
|
|
38
|
+
out[byteIdx + 2] = (value >>> 8) & 0xFF;
|
|
39
|
+
out[byteIdx + 3] = value & 0xFF;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { z85Encode, z85Decode };
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { encodeShareCode, decodeShareCode, isValidShareCode } = require("../src/index");
|
|
3
|
+
|
|
4
|
+
// Minimal Warrior/Berserker build fixture
|
|
5
|
+
const BERSERKER_BUILD = {
|
|
6
|
+
profession: "Warrior",
|
|
7
|
+
gameMode: "pve",
|
|
8
|
+
specializations: [
|
|
9
|
+
{ id: 4, name: "Strength", elite: false, majorChoices: { 1: 1444, 2: 1449, 3: 1437 },
|
|
10
|
+
majorTraitsByTier: { 1: [{ id: 1444 }, { id: 1447 }, { id: 2000 }], 2: [{ id: 1449 }, { id: 1448 }, { id: 1453 }], 3: [{ id: 1437 }, { id: 1440 }, { id: 1454 }] } },
|
|
11
|
+
{ id: 36, name: "Discipline", elite: false, majorChoices: { 1: 1413, 2: 1489, 3: 1369 },
|
|
12
|
+
majorTraitsByTier: { 1: [{ id: 1413 }, { id: 1381 }, { id: 1415 }], 2: [{ id: 1489 }, { id: 1484 }, { id: 1709 }], 3: [{ id: 1369 }, { id: 1317 }, { id: 1657 }] } },
|
|
13
|
+
{ id: 18, name: "Berserker", elite: true, majorChoices: { 1: 2049, 2: 2039, 3: 2043 },
|
|
14
|
+
majorTraitsByTier: { 1: [{ id: 2049 }, { id: 2042 }, { id: 1928 }], 2: [{ id: 2039 }, { id: 2011 }, { id: 1977 }], 3: [{ id: 2043 }, { id: 2038 }, { id: 2060 }] } },
|
|
15
|
+
],
|
|
16
|
+
skills: {
|
|
17
|
+
heal: { id: 14402 }, utility: [{ id: 14404 }, { id: 14410 }, { id: 14405 }], elite: { id: 14355 },
|
|
18
|
+
},
|
|
19
|
+
underwaterSkills: { heal: null, utility: [null, null, null], elite: null },
|
|
20
|
+
equipment: {
|
|
21
|
+
statPackage: "Berserker's",
|
|
22
|
+
relic: "Relic of the Thief",
|
|
23
|
+
food: "Bowl of Sweet and Spicy Butternut Squash Soup",
|
|
24
|
+
utility: "Superior Sharpening Stone",
|
|
25
|
+
enrichment: "",
|
|
26
|
+
weapons: { mainhand1: "greatsword", offhand1: "", mainhand2: "axe", offhand2: "", aquatic1: "", aquatic2: "" },
|
|
27
|
+
runes: { head: "24836", shoulders: "24836", chest: "24836", hands: "24836", legs: "24836", feet: "24836" },
|
|
28
|
+
sigils: { mainhand1: ["24615", "24868"], offhand1: [], mainhand2: ["24615", ""], offhand2: [], aquatic1: [], aquatic2: [] },
|
|
29
|
+
infusions: { head: "49432", shoulders: "49432", chest: "49432", hands: "49432", legs: "49432", feet: "49432",
|
|
30
|
+
back: ["49432", "49432"], ring1: ["49432", "49432", "49432"], ring2: ["49432", "49432", "49432"],
|
|
31
|
+
accessory1: "49432", accessory2: "49432",
|
|
32
|
+
mainhand1: ["49432", "49432"], offhand1: [], mainhand2: ["49432", "49432"], offhand2: [] },
|
|
33
|
+
},
|
|
34
|
+
selectedLegends: ["", ""],
|
|
35
|
+
selectedPets: { terrestrial1: 0, terrestrial2: 0, aquatic1: 0, aquatic2: 0 },
|
|
36
|
+
activeAttunement: "",
|
|
37
|
+
activeAttunement2: "",
|
|
38
|
+
activeKit: 0,
|
|
39
|
+
activeWeaponSet: 1,
|
|
40
|
+
allianceTacticsForm: 0,
|
|
41
|
+
antiquaryArtifacts: { f2: 0, f3: 0, f4: 0 },
|
|
42
|
+
selectedUnderwaterLegends: ["", ""],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
describe("encodeShareCode", () => {
|
|
46
|
+
test("produces valid wrapper format", () => {
|
|
47
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
48
|
+
expect(code).toMatch(/^<AxiForge:[A-Za-z]+:.+>$/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("label is elite spec name", () => {
|
|
52
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
53
|
+
expect(code.startsWith("<AxiForge:Berserker:")).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("label is profession name for core build", () => {
|
|
57
|
+
const coreBuild = {
|
|
58
|
+
...BERSERKER_BUILD,
|
|
59
|
+
specializations: BERSERKER_BUILD.specializations.map(s => ({ ...s, elite: false, name: s.elite ? "Arms" : s.name })),
|
|
60
|
+
};
|
|
61
|
+
const code = encodeShareCode(coreBuild);
|
|
62
|
+
expect(code.startsWith("<AxiForge:Warrior:")).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("isValidShareCode", () => {
|
|
67
|
+
test("returns true for valid code", () => {
|
|
68
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
69
|
+
expect(isValidShareCode(code)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
test("returns false for random text", () => {
|
|
72
|
+
expect(isValidShareCode("not a share code")).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
test("returns false for GW2 chat link", () => {
|
|
75
|
+
expect(isValidShareCode("[&DQYlPSkvMBc=]")).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("decodeShareCode", () => {
|
|
80
|
+
test("round-trip: preserves profession", () => {
|
|
81
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
82
|
+
const decoded = decodeShareCode(code);
|
|
83
|
+
expect(decoded.profession).toBe("Warrior");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("round-trip: preserves game mode", () => {
|
|
87
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
88
|
+
const decoded = decodeShareCode(code);
|
|
89
|
+
expect(decoded.gameMode).toBe("pve");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("round-trip: preserves specialization IDs", () => {
|
|
93
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
94
|
+
const decoded = decodeShareCode(code);
|
|
95
|
+
expect(decoded.specializations[0].id).toBe(4);
|
|
96
|
+
expect(decoded.specializations[1].id).toBe(36);
|
|
97
|
+
expect(decoded.specializations[2].id).toBe(18);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("round-trip: preserves trait choices", () => {
|
|
101
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
102
|
+
const decoded = decodeShareCode(code);
|
|
103
|
+
expect(decoded.specializations[0].traitChoices).toEqual([1, 1, 1]);
|
|
104
|
+
expect(decoded.specializations[2].traitChoices).toEqual([1, 1, 1]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("round-trip: preserves skill IDs", () => {
|
|
108
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
109
|
+
const decoded = decodeShareCode(code);
|
|
110
|
+
expect(decoded.skills.healId).toBe(14402);
|
|
111
|
+
expect(decoded.skills.utilityIds).toEqual([14404, 14410, 14405]);
|
|
112
|
+
expect(decoded.skills.eliteId).toBe(14355);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("round-trip: preserves stat package", () => {
|
|
116
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
117
|
+
const decoded = decodeShareCode(code);
|
|
118
|
+
expect(decoded.equipment.statPackage).toBe("Berserker's");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("round-trip: preserves weapon types", () => {
|
|
122
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
123
|
+
const decoded = decodeShareCode(code);
|
|
124
|
+
expect(decoded.equipment.weapons.mainhand1).toBe("greatsword");
|
|
125
|
+
expect(decoded.equipment.weapons.mainhand2).toBe("axe");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("round-trip: preserves relic", () => {
|
|
129
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
130
|
+
const decoded = decodeShareCode(code);
|
|
131
|
+
expect(decoded.equipment.relic).toBe("Relic of the Thief");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("round-trip: preserves food", () => {
|
|
135
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
136
|
+
const decoded = decodeShareCode(code);
|
|
137
|
+
expect(decoded.equipment.food).toBe("Bowl of Sweet and Spicy Butternut Squash Soup");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("round-trip: preserves rune IDs", () => {
|
|
141
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
142
|
+
const decoded = decodeShareCode(code);
|
|
143
|
+
expect(decoded.equipment.runes.head).toBe("24836");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("round-trip: preserves sigils", () => {
|
|
147
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
148
|
+
const decoded = decodeShareCode(code);
|
|
149
|
+
expect(decoded.equipment.sigils.mainhand1).toEqual(["24615", "24868"]);
|
|
150
|
+
expect(decoded.equipment.sigils.mainhand2[0]).toBe("24615");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("round-trip: preserves infusions (uniform)", () => {
|
|
154
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
155
|
+
const decoded = decodeShareCode(code);
|
|
156
|
+
expect(decoded.equipment.infusions.head).toBe("49432");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("throws on invalid format", () => {
|
|
160
|
+
expect(() => decodeShareCode("not valid")).toThrow("Invalid build code format");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("throws on unknown version", () => {
|
|
164
|
+
expect(() => decodeShareCode("<AxiForge:Test:00000>")).toThrow();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── Profession-Specific Encoding Tests ───────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
describe("profession-specific round-trips", () => {
|
|
171
|
+
function makeBuild(overrides) {
|
|
172
|
+
const base = JSON.parse(JSON.stringify(BERSERKER_BUILD));
|
|
173
|
+
return Object.assign(base, overrides);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
test("Revenant (Vindicator) round-trip", () => {
|
|
177
|
+
const build = makeBuild({
|
|
178
|
+
profession: "Revenant",
|
|
179
|
+
specializations: [
|
|
180
|
+
BERSERKER_BUILD.specializations[0],
|
|
181
|
+
BERSERKER_BUILD.specializations[1],
|
|
182
|
+
{ id: 62, name: "Vindicator", elite: true, majorChoices: { 1: 2049, 2: 2039, 3: 2043 },
|
|
183
|
+
majorTraitsByTier: BERSERKER_BUILD.specializations[2].majorTraitsByTier },
|
|
184
|
+
],
|
|
185
|
+
selectedLegends: ["Legend2", "Legend7"],
|
|
186
|
+
selectedUnderwaterLegends: ["Legend3", "Legend4"],
|
|
187
|
+
allianceTacticsForm: 1,
|
|
188
|
+
});
|
|
189
|
+
const code = encodeShareCode(build);
|
|
190
|
+
expect(code.startsWith("<AxiForge:Vindicator:")).toBe(true);
|
|
191
|
+
|
|
192
|
+
const decoded = decodeShareCode(code);
|
|
193
|
+
expect(decoded.profession).toBe("Revenant");
|
|
194
|
+
expect(decoded.selectedLegends).toEqual(["Legend2", "Legend7"]);
|
|
195
|
+
expect(decoded.allianceTacticsForm).toBe(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("Ranger (Druid) round-trip", () => {
|
|
199
|
+
const build = makeBuild({
|
|
200
|
+
profession: "Ranger",
|
|
201
|
+
specializations: [
|
|
202
|
+
BERSERKER_BUILD.specializations[0],
|
|
203
|
+
BERSERKER_BUILD.specializations[1],
|
|
204
|
+
{ id: 5, name: "Druid", elite: true, majorChoices: { 1: 2049, 2: 2039, 3: 2043 },
|
|
205
|
+
majorTraitsByTier: BERSERKER_BUILD.specializations[2].majorTraitsByTier },
|
|
206
|
+
],
|
|
207
|
+
selectedPets: { terrestrial1: 46, terrestrial2: 59, aquatic1: 21, aquatic2: 40 },
|
|
208
|
+
});
|
|
209
|
+
const code = encodeShareCode(build);
|
|
210
|
+
const decoded = decodeShareCode(code);
|
|
211
|
+
expect(decoded.selectedPets.terrestrial1).toBe(46);
|
|
212
|
+
expect(decoded.selectedPets.terrestrial2).toBe(59);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("Elementalist (Weaver) round-trip", () => {
|
|
216
|
+
const build = makeBuild({
|
|
217
|
+
profession: "Elementalist",
|
|
218
|
+
specializations: [
|
|
219
|
+
BERSERKER_BUILD.specializations[0],
|
|
220
|
+
BERSERKER_BUILD.specializations[1],
|
|
221
|
+
{ id: 56, name: "Weaver", elite: true, majorChoices: { 1: 2049, 2: 2039, 3: 2043 },
|
|
222
|
+
majorTraitsByTier: BERSERKER_BUILD.specializations[2].majorTraitsByTier },
|
|
223
|
+
],
|
|
224
|
+
activeAttunement: "Fire",
|
|
225
|
+
activeAttunement2: "Water",
|
|
226
|
+
});
|
|
227
|
+
const code = encodeShareCode(build);
|
|
228
|
+
const decoded = decodeShareCode(code);
|
|
229
|
+
expect(decoded.activeAttunement).toBe("Fire");
|
|
230
|
+
expect(decoded.activeAttunement2).toBe("Water");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("Thief (Antiquary) round-trip", () => {
|
|
234
|
+
const build = makeBuild({
|
|
235
|
+
profession: "Thief",
|
|
236
|
+
specializations: [
|
|
237
|
+
BERSERKER_BUILD.specializations[0],
|
|
238
|
+
BERSERKER_BUILD.specializations[1],
|
|
239
|
+
{ id: 77, name: "Antiquary", elite: true, majorChoices: { 1: 2049, 2: 2039, 3: 2043 },
|
|
240
|
+
majorTraitsByTier: BERSERKER_BUILD.specializations[2].majorTraitsByTier },
|
|
241
|
+
],
|
|
242
|
+
antiquaryArtifacts: { f2: 76582, f3: 76702, f4: 77288 },
|
|
243
|
+
});
|
|
244
|
+
const code = encodeShareCode(build);
|
|
245
|
+
const decoded = decodeShareCode(code);
|
|
246
|
+
expect(decoded.antiquaryArtifacts).toEqual({ f2: 76582, f3: 76702, f4: 77288 });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("Engineer (Scrapper) round-trip", () => {
|
|
250
|
+
const build = makeBuild({
|
|
251
|
+
profession: "Engineer",
|
|
252
|
+
specializations: [
|
|
253
|
+
BERSERKER_BUILD.specializations[0],
|
|
254
|
+
BERSERKER_BUILD.specializations[1],
|
|
255
|
+
{ id: 43, name: "Scrapper", elite: true, majorChoices: { 1: 2049, 2: 2039, 3: 2043 },
|
|
256
|
+
majorTraitsByTier: BERSERKER_BUILD.specializations[2].majorTraitsByTier },
|
|
257
|
+
],
|
|
258
|
+
activeKit: 5812,
|
|
259
|
+
});
|
|
260
|
+
const code = encodeShareCode(build);
|
|
261
|
+
const decoded = decodeShareCode(code);
|
|
262
|
+
expect(decoded.activeKit).toBe(5812);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("Warrior active weapon set round-trip", () => {
|
|
266
|
+
const build = makeBuild({ activeWeaponSet: 2 });
|
|
267
|
+
const code = encodeShareCode(build);
|
|
268
|
+
const decoded = decodeShareCode(code);
|
|
269
|
+
expect(decoded.activeWeaponSet).toBe(2);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ── Per-Slot Mode Tests ──────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
describe("per-slot mode round-trips", () => {
|
|
276
|
+
function makeBuild(overrides) {
|
|
277
|
+
const base = JSON.parse(JSON.stringify(BERSERKER_BUILD));
|
|
278
|
+
return Object.assign(base, overrides);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
test("uniform runes round-trip", () => {
|
|
282
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
283
|
+
const decoded = decodeShareCode(code);
|
|
284
|
+
for (const slot of ["head", "shoulders", "chest", "hands", "legs", "feet"]) {
|
|
285
|
+
expect(decoded.equipment.runes[slot]).toBe("24836");
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("per-slot runes (mixed) round-trip", () => {
|
|
290
|
+
const build = makeBuild({});
|
|
291
|
+
build.equipment.runes = {
|
|
292
|
+
head: "24836", shoulders: "24836", chest: "24836",
|
|
293
|
+
hands: "24836", legs: "24836", feet: "24691",
|
|
294
|
+
};
|
|
295
|
+
const code = encodeShareCode(build);
|
|
296
|
+
const decoded = decodeShareCode(code);
|
|
297
|
+
expect(decoded.equipment.runes.head).toBe("24836");
|
|
298
|
+
expect(decoded.equipment.runes.feet).toBe("24691");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("uniform infusions round-trip", () => {
|
|
302
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
303
|
+
const decoded = decodeShareCode(code);
|
|
304
|
+
expect(decoded.equipment.infusions.head).toBe("49432");
|
|
305
|
+
expect(decoded.equipment.infusions.accessory1).toBe("49432");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("per-slot infusions (mixed) round-trip", () => {
|
|
309
|
+
const build = makeBuild({});
|
|
310
|
+
build.equipment.infusions.shoulders = "37131";
|
|
311
|
+
const code = encodeShareCode(build);
|
|
312
|
+
const decoded = decodeShareCode(code);
|
|
313
|
+
expect(decoded.equipment.infusions.head).toBe("49432");
|
|
314
|
+
expect(decoded.equipment.infusions.shoulders).toBe("37131");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("underwater skills and weapons round-trip", () => {
|
|
318
|
+
const build = makeBuild({
|
|
319
|
+
underwaterSkills: {
|
|
320
|
+
heal: { id: 14402 },
|
|
321
|
+
utility: [{ id: 14404 }, { id: 14410 }, { id: 14405 }],
|
|
322
|
+
elite: { id: 14355 },
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
build.equipment.weapons.aquatic1 = "spear";
|
|
326
|
+
build.equipment.sigils.aquatic1 = ["24615", "24868"];
|
|
327
|
+
const code = encodeShareCode(build);
|
|
328
|
+
const decoded = decodeShareCode(code);
|
|
329
|
+
expect(decoded.underwaterSkills.healId).toBe(14402);
|
|
330
|
+
expect(decoded.underwaterSkills.utilityIds).toEqual([14404, 14410, 14405]);
|
|
331
|
+
expect(decoded.underwaterSkills.eliteId).toBe(14355);
|
|
332
|
+
expect(decoded.equipment.weapons.aquatic1).toBe("spear");
|
|
333
|
+
expect(decoded.equipment.sigils.aquatic1).toEqual(["24615", "24868"]);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ── Error handling ───────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
describe("error handling", () => {
|
|
340
|
+
test("truncated payload throws", () => {
|
|
341
|
+
const code = encodeShareCode(BERSERKER_BUILD);
|
|
342
|
+
const truncated = code.slice(0, code.length - 10) + ">";
|
|
343
|
+
expect(() => decodeShareCode(truncated)).toThrow();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("invalid Z85 characters throws", () => {
|
|
347
|
+
expect(() => decodeShareCode("<AxiForge:Test:!!!invalid z85 chars!!!>")).toThrow();
|
|
348
|
+
});
|
|
349
|
+
});
|