@kar-mi/spirit-vale-tools-character 0.5.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/dist/index.js ADDED
@@ -0,0 +1,1290 @@
1
+ // @bun
2
+ // src/decoder.ts
3
+ import { characterDataParameter, CURRENT_GAME_BUILD_FINGERPRINT, decodeFieldRun } from "@kar-mi/spirit-vale-tools-capture";
4
+ import { readSignedPackedWhole } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
5
+ import { resolveFishNetItem } from "@kar-mi/spirit-vale-tools-items";
6
+ import { resolveFishNetSkill } from "@kar-mi/spirit-vale-tools-skills";
7
+
8
+ // src/stat-names.ts
9
+ var STAT_NAMES = {
10
+ 0: "STR",
11
+ 1: "VIT",
12
+ 2: "AGI",
13
+ 3: "DEX",
14
+ 4: "INT",
15
+ 5: "LUK",
16
+ 6: "All stats",
17
+ 7: "HP",
18
+ 8: "MP",
19
+ 9: "ATK",
20
+ 10: "MATK",
21
+ 11: "DEF",
22
+ 12: "MDEF",
23
+ 13: "Hit",
24
+ 14: "Flee",
25
+ 15: "Critical chance",
26
+ 16: "Flat DEF",
27
+ 17: "Flat MDEF",
28
+ 18: "Weapon element",
29
+ 19: "Armor element",
30
+ 20: "Health on hit",
31
+ 21: "Mana on hit",
32
+ 22: "Kill leech",
33
+ 23: "Kill MP leech",
34
+ 25: "Range",
35
+ 26: "Status immunity",
36
+ 39: "Autocast on hit",
37
+ 40: "Autocast on attack",
38
+ 41: "Autocast chance",
39
+ 42: "Granted skill",
40
+ 43: "Skill level",
41
+ 44: "Damage to element",
42
+ 45: "Damage to boss",
43
+ 46: "Element damage",
44
+ 47: "Melee damage",
45
+ 48: "Magic damage",
46
+ 49: "Skill damage",
47
+ 50: "Skill cost",
48
+ 51: "Skill cooldown",
49
+ 52: "Critical damage",
50
+ 53: "Element resistance",
51
+ 54: "Weapon ATK",
52
+ 55: "Weapon MATK",
53
+ 56: "Damage from element",
54
+ 57: "Damage from magic",
55
+ 58: "Damage from melee",
56
+ 59: "HP regeneration",
57
+ 60: "Maximum HP regeneration",
58
+ 61: "MP regeneration",
59
+ 62: "Maximum MP regeneration",
60
+ 63: "Attack speed",
61
+ 64: "Cast speed",
62
+ 65: "Move speed",
63
+ 66: "After-cast delay",
64
+ 67: "Healing",
65
+ 69: "ATK multiplier",
66
+ 70: "MATK multiplier",
67
+ 71: "HP multiplier",
68
+ 72: "MP multiplier",
69
+ 73: "DEF multiplier",
70
+ 74: "MDEF multiplier",
71
+ 75: "HP regeneration multiplier",
72
+ 76: "MP regeneration multiplier",
73
+ 77: "Flee multiplier",
74
+ 78: "Hit multiplier",
75
+ 79: "Critical multiplier",
76
+ 80: "Double attack",
77
+ 81: "Energy shield",
78
+ 82: "Cast while moving",
79
+ 83: "Attack while casting",
80
+ 84: "Spell echo",
81
+ 85: "Block",
82
+ 86: "Reflect damage",
83
+ 87: "Dual wield",
84
+ 88: "Experience rate",
85
+ 89: "Drop rate",
86
+ 90: "MP cost",
87
+ 91: "Life bond",
88
+ 92: "Summon stat share",
89
+ 93: "Summon damage share",
90
+ 94: "Spell reflect",
91
+ 95: "Shield block",
92
+ 96: "Wand range",
93
+ 97: "Leech chance",
94
+ 98: "Leech",
95
+ 99: "MP leech chance",
96
+ 100: "MP leech",
97
+ 101: "Weight limit",
98
+ 102: "Ranged damage",
99
+ 103: "Damage from ranged",
100
+ 104: "Skill cooldown",
101
+ 113: "HP regeneration rate",
102
+ 114: "MP regeneration rate",
103
+ 120: "Critical defence",
104
+ 121: "Perfect dodge",
105
+ 142: "Status damage",
106
+ 151: "MATK per STR",
107
+ 152: "ATK per STR",
108
+ 159: "Attack-speed multiplier",
109
+ 160: "Cast-speed multiplier",
110
+ 161: "Move-speed multiplier",
111
+ 162: "Skill charges",
112
+ 163: "Auto fire",
113
+ 164: "Close-range damage",
114
+ 165: "Far-range damage",
115
+ 166: "Damage to summons",
116
+ 167: "Damage from summons",
117
+ 168: "Summon spell echo",
118
+ 169: "Summon damage",
119
+ 170: "Summon damage reduction",
120
+ 171: "Summon leech",
121
+ 172: "Summon cost share",
122
+ 173: "Summon hit",
123
+ 174: "Summon autoattack damage",
124
+ 175: "Summon resistance",
125
+ 176: "HP siphon",
126
+ 177: "MP siphon",
127
+ 178: "Healing share",
128
+ 179: "Death share",
129
+ 180: "Autocast on death",
130
+ 181: "Autocast on kill",
131
+ 182: "Cooldown recovery",
132
+ 183: "Status max stacks",
133
+ 184: "Two-handed stance bonus",
134
+ 185: "Attack-speed limit",
135
+ 186: "Autoattack MATK",
136
+ 187: "Cast-time reduction",
137
+ 188: "Cast-time reduction limit",
138
+ 189: "Cast range",
139
+ 190: "Dodge recovery",
140
+ 191: "Flat attack speed",
141
+ 192: "Threat multiplier",
142
+ 193: "DEF pierce",
143
+ 194: "MDEF pierce",
144
+ 195: "Perfect hit",
145
+ 196: "Healing to barrier",
146
+ 197: "Skill pull",
147
+ 198: "Skill damage at low HP",
148
+ 199: "Skill range",
149
+ 200: "Skill instances",
150
+ 201: "Skill critical chance",
151
+ 202: "Skill leap",
152
+ 203: "Skill threat",
153
+ 204: "Skill cast-time multiplier",
154
+ 205: "Skill-hits multiplier",
155
+ 206: "Skill-duration multiplier",
156
+ 207: "Skill damage received",
157
+ 208: "Skill damage vs. status",
158
+ 209: "Skill replacement",
159
+ 210: "Status replacement",
160
+ 211: "Status duration",
161
+ 212: "Skill max instances",
162
+ 213: "Skill autocast",
163
+ 214: "Skill applies status",
164
+ 215: "Skill consumes status",
165
+ 216: "Skill health recovery",
166
+ 217: "Skill mana recovery",
167
+ 218: "Damage vs. status",
168
+ 219: "Set attack speed"
169
+ };
170
+ var PERCENT_STATS = new Set([44, 45, 46, 47, 48, 49, 52, 56, 57, 58, 63, 64, 65, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 86, 88, 89, 90, 97, 98, 99, 100, 102, 103, 104, 113, 114, 119, 121, 138, 139, 140, 142, 159, 160, 161, 164, 165, 166, 167, 169, 170, 171, 175, 182, 184, 187, 192, 201, 204, 205, 206, 207, 208]);
171
+
172
+ // src/decoder.ts
173
+ var ARCHETYPES = {
174
+ [-1]: "Novice",
175
+ 0: "Warrior",
176
+ 1: "Mage",
177
+ 2: "Rogue",
178
+ 3: "Knight",
179
+ 4: "Summoner",
180
+ 5: "Acolyte",
181
+ 6: "Scout",
182
+ 10: "Paladin",
183
+ 11: "Dragon Knight",
184
+ 12: "Berserker",
185
+ 13: "Revenant",
186
+ 14: "Priest",
187
+ 15: "Monk",
188
+ 16: "Wizard",
189
+ 17: "Chronomancer",
190
+ 18: "Druid",
191
+ 19: "Warlock",
192
+ 20: "Assassin",
193
+ 21: "Shinobi",
194
+ 22: "Gunslinger",
195
+ 23: "Ranger",
196
+ 24: "Jester",
197
+ 25: "Nightshade",
198
+ 26: "Necromancer",
199
+ 27: "Spellblade",
200
+ 28: "Blade Master",
201
+ 29: "Mechanist",
202
+ 30: "Alchemist",
203
+ 31: "Weaver"
204
+ };
205
+ var ARCHETYPE_IDS = new Map(Object.entries(ARCHETYPES).map(([id, name]) => [name, Number(id)]));
206
+ var EQUIP_SLOTS = ["Main hand", "Off hand", "Head", "Legs", "Feet", "Chest", "Left accessory", "Right accessory", "Eyewear", "Back"];
207
+ var ARTIFACT_SLOTS = ["Rune", "Jewel", "Scroll", "Relic"];
208
+ var LOADOUTS = ["Normal", "Secondary", "Heavy"];
209
+ function resolveCharacterArchetypeId(name) {
210
+ return ARCHETYPE_IDS.get(name);
211
+ }
212
+ function decodeCharacterRpcPayload(payload, includesUpdateType, now = new Date) {
213
+ let startOffset = 0;
214
+ let updateType = 327417855;
215
+ if (includesUpdateType) {
216
+ const decoded = readSignedPackedWhole(payload, 0);
217
+ updateType = decoded.value;
218
+ startOffset = decoded.nextOffset;
219
+ }
220
+ const run = decodeFieldRun(payload, [characterDataParameter()], startOffset);
221
+ const tree = buildFieldTree(run.fields);
222
+ const data = child(tree, "data");
223
+ if (!data)
224
+ throw new Error("CharacterData payload is null");
225
+ const name = leafString(data, "Name") ?? "Unknown character";
226
+ const title = leafString(data, "Title") || undefined;
227
+ const archetypeNames = listValues(data, "Archetypes").map((value) => ARCHETYPES[Number(value)] ?? "Unknown");
228
+ const level = leafNumber(data, "Level", 0);
229
+ const experience = leafNumber(data, "Exp", 0);
230
+ const jobLevel = leafNumber(data, "JobLevel", 0);
231
+ const jobExperience = leafNumber(data, "JobExp", 0);
232
+ const rawAttributes = listValues(data, "Attributes").map((value) => Number(value));
233
+ if (rawAttributes.length < 6)
234
+ throw new Error("CharacterData attributes are incomplete");
235
+ const attributes = {
236
+ STR: rawAttributes[0],
237
+ VIT: rawAttributes[1],
238
+ AGI: rawAttributes[2],
239
+ DEX: rawAttributes[3],
240
+ INT: rawAttributes[4],
241
+ LUK: rawAttributes[5]
242
+ };
243
+ for (const [label, value] of Object.entries(attributes)) {
244
+ if (!Number.isInteger(value) || value < 0 || value > 1e5)
245
+ throw new Error(`invalid ${label} attribute`);
246
+ }
247
+ const equipped = listItems(data, "Equips", (item) => readEquipmentSlot(item)).filter((value) => value !== undefined);
248
+ const activeIndex = leafNumber(data, "ActiveLoadout", 0);
249
+ const loadouts = [
250
+ listItems(data, "LoadoutNormal", (item) => readEquipmentSlot(item)).filter((value) => value !== undefined),
251
+ listItems(data, "LoadoutSecondary", (item) => readEquipmentSlot(item)).filter((value) => value !== undefined),
252
+ listItems(data, "LoadoutHeavy", (item) => readEquipmentSlot(item)).filter((value) => value !== undefined)
253
+ ];
254
+ const artifacts = listItems(data, "Artifacts", (item) => readArtifactData(item)).filter((value) => value !== undefined);
255
+ const equipment = loadouts[activeIndex]?.length ? loadouts[activeIndex] : equipped;
256
+ const { skills, currentWeight, ...history } = readCharacterHistory(data, run.complete, equipped, artifacts);
257
+ return {
258
+ updateType,
259
+ ...currentWeight === undefined ? {} : { currentWeight },
260
+ snapshot: {
261
+ schemaVersion: 1,
262
+ buildFingerprint: CURRENT_GAME_BUILD_FINGERPRINT,
263
+ name,
264
+ ...title ? { title } : {},
265
+ archetypes: archetypeNames,
266
+ level,
267
+ experience,
268
+ jobLevel,
269
+ jobExperience,
270
+ attributes,
271
+ activeLoadout: LOADOUTS[activeIndex] ?? "Normal",
272
+ equipment,
273
+ artifacts,
274
+ ...loadouts.some((set) => set.length) ? { loadouts } : {},
275
+ skills,
276
+ ...history,
277
+ updatedAt: now.toISOString(),
278
+ source: "live"
279
+ }
280
+ };
281
+ }
282
+ function readEquipmentSlot(node) {
283
+ if (!node)
284
+ return;
285
+ const slotIndex = leafNumber(node, "Slot", -1);
286
+ return readEquipmentData(child(node, "Equip"), slotIndex);
287
+ }
288
+ function readEquipmentData(node, slotIndex) {
289
+ if (!node)
290
+ return;
291
+ const rawStats = listItems(node, "Substats", (item) => readRawSubstat(item));
292
+ const cardsBySlot = listValues(node, "Cards").map((value) => typeof value === "string" && value ? value : null);
293
+ const cards = cardsBySlot.filter((value) => Boolean(value));
294
+ const chaosType = leafNumber(node, "ChaosType", -1);
295
+ const refine = leafNumber(node, "Refine", 0);
296
+ const itemId = leafString(node, "Id") ?? "Unknown equipment";
297
+ const substatGroup = resolveFishNetItem(2, itemId)?.substatGroup;
298
+ const substats = rawStats.flatMap((stat, index) => stat ? [convertSubstat(stat.type, stat.roll, slotIndex, false, substatGroup, { index, qualifier: stat.valueStr })] : []);
299
+ return { slot: EQUIP_SLOTS[slotIndex] ?? `Slot ${slotIndex}`, itemId, refine, cards, substats, chaosType, cardsBySlot };
300
+ }
301
+ function readArtifactData(node) {
302
+ if (!node)
303
+ return;
304
+ const rawStats = listItems(node, "Substats", (item) => readRawSubstat(item));
305
+ const slotIndex = leafNumber(node, "Slot", -1);
306
+ const gems = listItems(node, "Gems", (item) => readRefinableItem(item)).filter((value) => value !== undefined);
307
+ const refine = leafNumber(node, "Refine", 0);
308
+ const itemId = leafString(node, "Id") ?? "Unknown artifact";
309
+ const substats = rawStats.flatMap((stat, index) => stat ? [convertSubstat(stat.type, stat.roll, slotIndex, true, undefined, { index, qualifier: stat.valueStr })] : []);
310
+ return { slot: ARTIFACT_SLOTS[slotIndex] ?? `Artifact ${slotIndex}`, itemId, refine, gems, substats };
311
+ }
312
+ function readCharacterHistory(data, decodeComplete, equipped, artifacts) {
313
+ if (!decodeComplete)
314
+ return { skills: [] };
315
+ const equippedWeight = equipped.reduce((total, item) => total + equipmentWeight(item.itemId), 0) + artifacts.length * 10;
316
+ try {
317
+ const { skills, assigned } = readSkillSystem(child(data, "Skills"));
318
+ const grimoires = listItems(data, "Grimoires", (item) => readEquipmentData(item, -1)).map((item, index) => item ? { ...item, slot: `Grimoire ${index + 1}` } : undefined).filter((item) => item !== undefined);
319
+ let inventoryWeight = 0;
320
+ const inventory = child(data, "Inventory");
321
+ if (inventory) {
322
+ dictionaryValues(inventory, "Equips", (item) => {
323
+ const equip = readEquipmentData(item, -1);
324
+ if (equip)
325
+ inventoryWeight += equipmentWeight(equip.itemId);
326
+ });
327
+ dictionaryValues(inventory, "Artifacts", (item) => {
328
+ if (readArtifactData(item))
329
+ inventoryWeight += 10;
330
+ });
331
+ dictionaryValues(inventory, "Cards", (item) => {
332
+ inventoryWeight += readStackableCount(item);
333
+ });
334
+ dictionaryValues(inventory, "Gems", (item) => {
335
+ readRefinableItem(item);
336
+ });
337
+ dictionaryValues(inventory, "Junks", (item) => {
338
+ inventoryWeight += readStackableCount(item);
339
+ });
340
+ dictionaryValues(inventory, "Consumables", (item) => {
341
+ inventoryWeight += readStackableCount(item);
342
+ });
343
+ dictionaryValues(inventory, "Cosmetics", () => {
344
+ return;
345
+ });
346
+ }
347
+ const playtimeSeconds = leafInt64Number(data, "Playtime", 0);
348
+ const monsterKills = leafNumber(data, "MonsterKills", 0);
349
+ const bossKills = leafNumber(data, "BossKills", 0);
350
+ const deaths = leafNumber(data, "Deaths", 0);
351
+ return {
352
+ skills,
353
+ ...assigned.length ? { assignedSkills: assigned } : {},
354
+ grimoires,
355
+ currentWeight: equippedWeight + inventoryWeight,
356
+ playtimeSeconds,
357
+ monsterKills,
358
+ bossKills,
359
+ deaths
360
+ };
361
+ } catch {
362
+ return { skills: [] };
363
+ }
364
+ }
365
+ function readSkillSystem(node) {
366
+ if (!node)
367
+ return { skills: [], assigned: [] };
368
+ const allocated = listItems(node, "Skills", (item) => readSkill(item));
369
+ const assigned = listItems(node, "Assigned", (item) => readSkill(item));
370
+ const copy = readSkill(child(node, "SkillCopy"));
371
+ if (copy)
372
+ allocated.push(copy);
373
+ const unique = new Map;
374
+ for (const skill of allocated) {
375
+ if (!skill)
376
+ continue;
377
+ const existing = unique.get(skill.id);
378
+ if (!existing || skill.level > existing.level)
379
+ unique.set(skill.id, skill);
380
+ }
381
+ return {
382
+ skills: [...unique.values()].sort((left, right) => left.displayName.localeCompare(right.displayName)),
383
+ assigned: assigned.filter((skill) => skill !== undefined)
384
+ };
385
+ }
386
+ function readSkill(node) {
387
+ if (!node)
388
+ return;
389
+ const id = leafString(node, "Id");
390
+ const level = leafNumber(node, "Level", -1);
391
+ if (!id || level < 0)
392
+ return;
393
+ const definition = resolveFishNetSkill(id);
394
+ return {
395
+ id,
396
+ displayName: definition?.displayName ?? id,
397
+ level,
398
+ effects: (definition?.effects ?? []).map((effect) => ({
399
+ type: effect.type,
400
+ label: effect.label ?? STAT_NAMES[effect.type] ?? `Stat ${effect.type}`,
401
+ value: effect.value + (effect.valuePerLevel ?? 0) * level,
402
+ percent: PERCENT_STATS.has(effect.type)
403
+ }))
404
+ };
405
+ }
406
+ function readStackableCount(node) {
407
+ if (!node)
408
+ return 0;
409
+ return Math.max(0, leafNumber(node, "Count", 0));
410
+ }
411
+ function readRefinableItem(node) {
412
+ if (!node)
413
+ return;
414
+ const refine = leafNumber(node, "Refine", 0);
415
+ const id = leafString(node, "Id") ?? undefined;
416
+ return id ? { id, refine } : undefined;
417
+ }
418
+ function equipmentWeight(itemId) {
419
+ return resolveFishNetItem(2, itemId)?.weight ?? 10;
420
+ }
421
+ function readRawSubstat(node) {
422
+ if (!node)
423
+ return;
424
+ const type = leafNumber(node, "Type", 0);
425
+ const roll = leafNumber(node, "Value", 0);
426
+ const valueStr = leafString(node, "ValueStr") ?? "";
427
+ return { type, roll, valueStr };
428
+ }
429
+ function rescaleSubstats(snapshot, resolveItem = resolveFishNetItem) {
430
+ return {
431
+ ...snapshot,
432
+ equipment: snapshot.equipment.map((item) => {
433
+ const substatGroup = resolveItem(2, item.itemId)?.substatGroup;
434
+ const slotIndex = equipSlotIndex(item.slot);
435
+ return { ...item, substats: item.substats.map((stat) => convertSubstat(stat.type, stat.roll, slotIndex, false, substatGroup, stat)) };
436
+ }),
437
+ artifacts: snapshot.artifacts.map((item) => ({
438
+ ...item,
439
+ substats: item.substats.map((stat) => convertSubstat(stat.type, stat.roll, ARTIFACT_SLOTS.indexOf(item.slot), true, undefined, stat))
440
+ }))
441
+ };
442
+ }
443
+ function equipSlotIndex(slot) {
444
+ const index = EQUIP_SLOTS.indexOf(slot);
445
+ if (index >= 0)
446
+ return index;
447
+ const parsed = /^Slot (-?\d+)$/.exec(slot);
448
+ return parsed ? Number(parsed[1]) : -1;
449
+ }
450
+ function convertSubstat(type, roll, slot, artifact, substatGroup, positional) {
451
+ const name = STAT_NAMES[type] ?? `Stat ${type}`;
452
+ const cap = substatCap(type, slot, artifact, substatGroup);
453
+ const value = cap === undefined ? undefined : roundAwayFromZero(cap * (2 / 3 + roll / 300));
454
+ return {
455
+ type,
456
+ name,
457
+ roll,
458
+ ...value === undefined ? {} : { value },
459
+ percent: PERCENT_STATS.has(type),
460
+ ...positional?.qualifier ? { qualifier: positional.qualifier } : {},
461
+ ...positional?.index === undefined ? {} : { index: positional.index }
462
+ };
463
+ }
464
+ function substatCap(type, slot, artifact, substatGroup) {
465
+ if (type >= 0 && type <= 5)
466
+ return 3;
467
+ if (artifact)
468
+ return { 69: 2, 70: 2, 71: 2, 72: 2 }[type];
469
+ const weaponCaps = {
470
+ Magic: { 69: 5, 70: 5, 47: 5, 48: 5, 64: 10, 90: -10, 63: 10, 9: 5, 10: 5, 182: 10, 67: 10, 189: 1 },
471
+ Melee: { 69: 5, 70: 5, 47: 5, 48: 5, 15: 10, 13: 20, 63: 10, 9: 5, 10: 5, 52: 10, 98: 5, 130: 1, 80: 20 },
472
+ Ranged: { 69: 5, 70: 5, 102: 5, 48: 5, 15: 10, 13: 20, 63: 10, 9: 5, 10: 5, 52: 10, 98: 5, 25: 1, 80: 20 }
473
+ };
474
+ const weaponCap = substatGroup ? weaponCaps[substatGroup]?.[type] : undefined;
475
+ if (weaponCap !== undefined)
476
+ return weaponCap;
477
+ const caps = {
478
+ 2: { 69: 2, 70: 2, 71: 2, 72: 2, 9: 3, 10: 3, 11: 5, 12: 5 },
479
+ 3: { 75: 25, 76: 25, 98: 5, 64: 10, 14: 15, 121: 5, 90: -10 },
480
+ 4: { 63: 10, 65: 10, 64: 10, 185: 1 },
481
+ 5: { 71: 10, 72: 10, 11: 10, 12: 10, 73: 5, 74: 5, 58: -5, 57: -5, 68: 10, 121: 5 },
482
+ 6: { 71: 2, 72: 2, 69: 2, 70: 2, 15: 5, 13: 10, 63: 5 },
483
+ 7: { 71: 2, 72: 2, 69: 2, 70: 2, 15: 5, 13: 10, 63: 5 },
484
+ 8: { 71: 2, 72: 2, 69: 2, 70: 2, 9: 3, 10: 3, 11: 5, 12: 5 },
485
+ 9: { 71: 2, 72: 2, 69: 2, 70: 2, 15: 5, 13: 10, 63: 5 }
486
+ };
487
+ return caps[slot]?.[type];
488
+ }
489
+ function roundAwayFromZero(value) {
490
+ return value < 0 ? -Math.round(-value) : Math.round(value);
491
+ }
492
+ function buildFieldTree(fields) {
493
+ const root = {};
494
+ for (const field of fields) {
495
+ setFieldPath(root, parseFieldPath(field.name), field.value);
496
+ }
497
+ return root;
498
+ }
499
+ function parseFieldPath(name) {
500
+ const path = [];
501
+ for (const segment of name.split(".")) {
502
+ const match = /^(.+)\[(\d+)\]$/.exec(segment);
503
+ if (match)
504
+ path.push(match[1], Number(match[2]));
505
+ else
506
+ path.push(segment);
507
+ }
508
+ return path;
509
+ }
510
+ function setFieldPath(root, path, value) {
511
+ let node = root;
512
+ for (let index = 0;index < path.length - 1; index += 1) {
513
+ const key = String(path[index]);
514
+ const existing = node[key];
515
+ if (!existing || typeof existing !== "object")
516
+ node[key] = {};
517
+ node = node[key];
518
+ }
519
+ node[String(path[path.length - 1])] = value;
520
+ }
521
+ function child(node, key) {
522
+ const value = node?.[key];
523
+ return value && typeof value === "object" ? value : undefined;
524
+ }
525
+ function leaf(node, key) {
526
+ return node?.[key];
527
+ }
528
+ function leafString(node, key) {
529
+ const value = leaf(node, key);
530
+ return typeof value === "string" ? value : null;
531
+ }
532
+ function leafNumber(node, key, fallback) {
533
+ const value = leaf(node, key);
534
+ return typeof value === "number" ? value : fallback;
535
+ }
536
+ function leafInt64Number(node, key, fallback) {
537
+ const value = leaf(node, key);
538
+ if (typeof value === "number")
539
+ return value;
540
+ if (typeof value === "string" && /^-?\d+$/.test(value))
541
+ return Number(value);
542
+ return fallback;
543
+ }
544
+ function listLength(node, key) {
545
+ const value = child(node, key)?.["length"];
546
+ return typeof value === "number" ? value : 0;
547
+ }
548
+ function listItems(node, key, map) {
549
+ const length = listLength(node, key);
550
+ const container = child(node, key);
551
+ return Array.from({ length }, (_unused, index) => map(child(container, String(index)), index));
552
+ }
553
+ function listValues(node, key) {
554
+ const length = listLength(node, key);
555
+ const container = child(node, key);
556
+ return Array.from({ length }, (_unused, index) => container?.[String(index)]);
557
+ }
558
+ function dictionaryValues(node, key, visit) {
559
+ const length = listLength(node, key);
560
+ const container = child(node, key);
561
+ for (let index = 0;index < length; index += 1)
562
+ visit(child(container, String(index)));
563
+ }
564
+ // src/formulas.ts
565
+ import { resolveFishNetItem as resolveFishNetItem2 } from "@kar-mi/spirit-vale-tools-items";
566
+ import { resolveFishNetSkill as resolveFishNetSkill2 } from "@kar-mi/spirit-vale-tools-skills";
567
+ var rounded = Math.round;
568
+ var integer = Math.floor;
569
+ var BASIC_SUBSTAT_TYPES = new Set([0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 13, 14, 15, 52, 65, 69, 70, 71, 72, 77, 78, 79, 120, 121]);
570
+ var BASE_MOVE_SPEED = 7.5;
571
+ function calculateCharacterStats(level, baseAttributes, substats = [], archetypes = []) {
572
+ const base = calculateBasicStats(level, baseAttributes, [], archetypes);
573
+ const total = calculateBasicStats(level, baseAttributes, substats, archetypes);
574
+ return total.map((stat, index) => ({ ...stat, tab: "basic", base: base[index].value, gear: stat.value - base[index].value }));
575
+ }
576
+ function aggregateGearSubstats(equipment, artifacts, resolveItem = resolveFishNetItem2) {
577
+ const totals = new Map;
578
+ for (const stat of materializeGearStats(equipment, artifacts, resolveItem)) {
579
+ const entry = totals.get(stat.type) ?? { type: stat.type, name: STAT_NAMES[stat.type] ?? stat.name, total: 0, percent: stat.percent, unresolvedRolls: 0 };
580
+ if (stat.value === undefined)
581
+ entry.unresolvedRolls += 1;
582
+ else
583
+ entry.total += stat.value;
584
+ totals.set(stat.type, entry);
585
+ }
586
+ return [...totals.values()].sort((left, right) => left.name.localeCompare(right.name) || left.type - right.type);
587
+ }
588
+ function materializeGearStats(equipment, artifacts, resolveItem = resolveFishNetItem2) {
589
+ const stats = [...equipment, ...artifacts].flatMap((item) => item.substats);
590
+ for (const item of equipment) {
591
+ stats.push(...itemEffects(2, item.itemId, item.refine, undefined, resolveItem));
592
+ for (const card of item.cards)
593
+ stats.push(...itemEffects(4, card, item.refine, undefined, resolveItem));
594
+ }
595
+ for (const artifact of artifacts) {
596
+ stats.push(...itemEffects(3, artifact.itemId, artifact.refine, artifact.slot, resolveItem));
597
+ for (const gem of artifact.gems)
598
+ stats.push(...itemEffects(5, gem.id, gem.refine, undefined, resolveItem));
599
+ }
600
+ const sets = new Map;
601
+ for (const artifact of artifacts)
602
+ sets.set(artifact.itemId, (sets.get(artifact.itemId) ?? 0) + 1);
603
+ for (const [itemId, pieces] of sets) {
604
+ const set = resolveItem(3, itemId)?.artifactSet;
605
+ if (!set)
606
+ continue;
607
+ stats.push(...effectsToStats(set.perPieceBase, 1), ...effectsToStats(set.perPiece, pieces));
608
+ if (pieces >= set.requiredPieces)
609
+ stats.push(...effectsToStats(set.fullSet, 1));
610
+ }
611
+ return stats;
612
+ }
613
+ function materializeSkillStats(skills, resolveSkill = resolveFishNetSkill2) {
614
+ return skills.flatMap((skill) => (resolveSkill(skill.id)?.effects ?? []).map((effect) => ({
615
+ type: effect.type,
616
+ name: effect.label ?? STAT_NAMES[effect.type] ?? `Stat ${effect.type}`,
617
+ roll: 0,
618
+ value: effect.value + (effect.valuePerLevel ?? 0) * skill.level,
619
+ percent: PERCENT_STATS.has(effect.type)
620
+ })));
621
+ }
622
+ function resolveCharacterHealingTraits(snapshot, resolveItem = resolveFishNetItem2, resolveSkill = resolveFishNetSkill2) {
623
+ const stats = [
624
+ ...materializeGearStats(snapshot.equipment, snapshot.artifacts, resolveItem),
625
+ ...materializeSkillStats(snapshot.skills, resolveSkill)
626
+ ];
627
+ return {
628
+ hasSiphonHealth: stats.some((stat) => stat.type === 176 && (stat.value ?? 0) > 0),
629
+ hasHealthLeech: stats.some((stat) => stat.type === 98 && (stat.value ?? 0) > 0)
630
+ };
631
+ }
632
+ function calculateWeightLimit(snapshot, resolveItem = resolveFishNetItem2, resolveSkill = resolveFishNetSkill2) {
633
+ const bonuses = [
634
+ ...materializeGearStats(snapshot.equipment, snapshot.artifacts, resolveItem),
635
+ ...materializeSkillStats(snapshot.skills, resolveSkill)
636
+ ].reduce((total, stat) => total + (stat.type === 101 ? stat.value ?? 0 : 0), 0);
637
+ return Math.round(2000 + snapshot.level * 30 + bonuses);
638
+ }
639
+ function itemEffects(itemType, itemId, refine, artifactSlot, resolveItem) {
640
+ const definition = resolveItem(itemType, itemId);
641
+ const slotEffects = artifactSlot && isArtifactSlot(artifactSlot) ? definition?.artifactSlotEffects?.[artifactSlot] ?? [] : [];
642
+ const slotRefineEffects = artifactSlot && isArtifactSlot(artifactSlot) ? definition?.artifactSlotRefineEffects?.[artifactSlot] ?? [] : [];
643
+ return [...effectsToStats(definition?.effects ?? [], 1), ...effectsToStats(slotEffects, 1), ...effectsToStats(definition?.refineEffects ?? [], refine), ...effectsToStats(slotRefineEffects, refine)];
644
+ }
645
+ function isArtifactSlot(value) {
646
+ return ["Rune", "Jewel", "Scroll", "Relic"].includes(value);
647
+ }
648
+ function effectsToStats(effects, multiplier) {
649
+ return effects.filter((effect) => effect.target === undefined).map((effect) => ({
650
+ type: effect.type,
651
+ name: STAT_NAMES[effect.type] ?? `Stat ${effect.type}`,
652
+ roll: 0,
653
+ value: effect.value * multiplier,
654
+ percent: PERCENT_STATS.has(effect.type)
655
+ }));
656
+ }
657
+ function calculateAdvancedGearStats(totals) {
658
+ return totals.filter((stat) => !BASIC_SUBSTAT_TYPES.has(stat.type)).map((stat) => ({
659
+ id: `gear-stat-${stat.type}`,
660
+ label: stat.name,
661
+ category: advancedCategory(stat.type),
662
+ tab: "advanced",
663
+ base: 0,
664
+ gear: stat.total,
665
+ value: stat.total,
666
+ ...stat.percent ? { unit: "%" } : {},
667
+ formula: "Gear-granted value (no base formula known)",
668
+ inputs: { Gear: stat.total, UnresolvedRolls: stat.unresolvedRolls }
669
+ }));
670
+ }
671
+ function calculateBasicStats(level, baseAttributes, substats, archetypes) {
672
+ const bonus = (type) => substats.reduce((total, stat) => total + (stat.type === type ? stat.value ?? 0 : 0), 0);
673
+ const STR = baseAttributes.STR + bonus(0), VIT = baseAttributes.VIT + bonus(1), AGI = baseAttributes.AGI + bonus(2);
674
+ const DEX = baseAttributes.DEX + bonus(3), INT = baseAttributes.INT + bonus(4), LUK = baseAttributes.LUK + bonus(5);
675
+ const meleeBase = STR * 1.5 + integer(DEX / 5) + integer(LUK / 5);
676
+ const rangedBase = DEX + integer(STR / 5) + integer(LUK / 5);
677
+ const magicBase = INT * 1.5 + integer(DEX / 5) + integer(LUK / 5);
678
+ const meleeAttack = rounded((meleeBase * (1 + integer(STR / 10) / 100) + bonus(9)) * (1 + bonus(69) / 100));
679
+ const rangedAttack = rounded((rangedBase * (1 + integer(DEX / 10) / 100) + bonus(9)) * (1 + bonus(69) / 100));
680
+ const magicAttack = rounded((magicBase * (1 + integer(INT / 10) / 100) + bonus(10)) * (1 + bonus(70) / 100));
681
+ const hit = rounded((level + DEX * 2 + integer(LUK / 3) + 25 + bonus(13)) * (1 + bonus(78) / 100));
682
+ const flee = rounded((20 + level + AGI + integer(LUK / 5) + integer(AGI / 10) * 3 + bonus(14)) * (1 + bonus(77) / 100));
683
+ const critChance = rounded((LUK / 3 + integer(LUK / 10) + bonus(15)) * (1 + bonus(79) / 100));
684
+ const critDamage = 120 + integer(LUK / 5) + bonus(52);
685
+ const critDefence = integer(LUK / 5) + bonus(120);
686
+ const perfectDodge = clamp(rounded(LUK / 10) + bonus(121), 0, 100);
687
+ const healthGrowth = ARCHETYPE_HEALTH_GROWTH[archetypes[0] ?? ""] ?? 0;
688
+ const cappedLevel = Math.min(level, 130);
689
+ const levelSum = cappedLevel * (cappedLevel + 1) / 2;
690
+ const maxHealth = Math.max(1, rounded(((200 + level * 10 + levelSum * healthGrowth) * (1 + VIT / 100) + bonus(7)) * (1 + bonus(71) / 100)));
691
+ const maxMana = Math.max(1, rounded(((45 + level * 5) * (1 + INT / 100) + bonus(8)) * (1 + bonus(72) / 100)));
692
+ const healthRegen = rounded(0.5 * (maxHealth / 200 + VIT / 5) * (2 + VIT / 100));
693
+ const manaRegen = rounded(0.5 * (maxMana / 100 + INT / 5) * (2 + INT / 100));
694
+ const moveSpeed = Math.round(BASE_MOVE_SPEED * (1 + bonus(65) / 100) * 1000) / 1000;
695
+ const statusResist = (label, value) => stat(`resist-${label.toLowerCase()}`, `${label} resistance`, "Defense", clamp(rounded(value * 2 / 3), 0, 100), "%", "clamp(round(attribute \xD7 2 / 3), 0, 100)", { attribute: value });
696
+ return [
697
+ stat("melee-attack", "Melee attack scaling", "Offense", meleeAttack, undefined, "round((attribute scaling + ATK) \xD7 (1 + ATK multiplier / 100))", { STR, DEX, LUK, ATK: bonus(9), AtkMult: bonus(69) }),
698
+ stat("ranged-attack", "Ranged attack scaling", "Offense", rangedAttack, undefined, "round((attribute scaling + ATK) \xD7 (1 + ATK multiplier / 100))", { STR, DEX, LUK, ATK: bonus(9), AtkMult: bonus(69) }),
699
+ stat("magic-attack", "Magic attack scaling", "Offense", magicAttack, undefined, "round((attribute scaling + MATK) \xD7 (1 + MATK multiplier / 100))", { INT, DEX, LUK, MATK: bonus(10), MatkMult: bonus(70) }),
700
+ stat("hit", "Hit", "Accuracy", hit, undefined, "round((level + DEX \xD7 2 + floor(LUK / 3) + 25 + Hit) \xD7 Hit multiplier)", { level, DEX, LUK, Hit: bonus(13), HitMult: bonus(78) }),
701
+ stat("flee", "Flee", "Accuracy", flee, undefined, "round((20 + level + AGI + floor(LUK / 5) + floor(AGI / 10) \xD7 3 + Flee) \xD7 Flee multiplier)", { level, AGI, LUK, Flee: bonus(14), FleeMult: bonus(77) }),
702
+ stat("crit-chance", "Critical chance", "Accuracy", critChance, "%", "round(LUK / 3 + floor(LUK / 10))", { LUK }),
703
+ stat("crit-damage", "Critical damage", "Accuracy", critDamage, "%", "120 + floor(LUK / 5)", { LUK }),
704
+ stat("crit-defence", "Critical defence", "Defense", critDefence, "%", "floor(LUK / 5)", { LUK }),
705
+ stat("perfect-dodge", "Perfect dodge", "Defense", perfectDodge, "%", "clamp(round(LUK / 10), 0, 100)", { LUK }),
706
+ statusResist("STR", STR),
707
+ statusResist("VIT", VIT),
708
+ statusResist("AGI", AGI),
709
+ statusResist("DEX", DEX),
710
+ statusResist("INT", INT),
711
+ statusResist("LUK", LUK),
712
+ stat("max-health", "Maximum HP", "Resources", maxHealth, undefined, "round(((200 + level \xD7 10 + capped level sum \xD7 archetype growth) \xD7 (1 + VIT / 100) + HP) \xD7 HP multiplier)", { level, VIT, LevelSum: levelSum, ArchetypeGrowth: healthGrowth, HP: bonus(7), HpMult: bonus(71) }),
713
+ stat("max-mana", "Maximum MP", "Resources", maxMana, undefined, "round((45 + level \xD7 5) \xD7 (1 + INT / 100))", { level, INT }),
714
+ stat("health-regen", "Base HP regeneration", "Recovery", healthRegen, undefined, "round(0.5 \xD7 (MaxHP / 200 + VIT / 5) \xD7 (2 + VIT / 100))", { MaxHP: maxHealth, VIT }),
715
+ stat("mana-regen", "Base MP regeneration", "Recovery", manaRegen, undefined, "round(0.5 \xD7 (MaxMP / 100 + INT / 5) \xD7 (2 + INT / 100))", { MaxMP: maxMana, INT }),
716
+ stat("move-speed", "Move speed", "Speed", moveSpeed, undefined, "7.5 \xD7 (1 + Move speed % / 100)", { MoveSpeedPct: bonus(65) })
717
+ ];
718
+ }
719
+ function advancedCategory(type) {
720
+ if ([63, 64, 65, 66, 104, 159, 160, 185, 187, 188].includes(type))
721
+ return "Speed";
722
+ if ([20, 21, 22, 23, 59, 60, 61, 62, 67, 75, 76, 97, 98, 99, 100, 113, 114, 176, 177].includes(type))
723
+ return "Sustain";
724
+ if ([11, 12, 16, 17, 26, 58, 73, 74, 86, 103].includes(type))
725
+ return "Mitigation";
726
+ return "Utility";
727
+ }
728
+ var ARCHETYPE_HEALTH_GROWTH = { Warrior: 1.3, Knight: 1, Rogue: 0.85, Scout: 0.75, Acolyte: 0.75, Summoner: 0.7, Mage: 0.5, Weaver: 0.5 };
729
+ function stat(id, label, category, value, unit, formula, inputs) {
730
+ return { id, label, category, value, ...unit ? { unit } : {}, formula, inputs };
731
+ }
732
+ function clamp(value, minimum, maximum) {
733
+ return Math.min(maximum, Math.max(minimum, value));
734
+ }
735
+ // src/record-decoder.ts
736
+ function decodeCharacterRecordSync(packet) {
737
+ if (packet.packetName !== "syncType" || packet.payload.length === 0)
738
+ return;
739
+ switch (packet.networkBehaviourType) {
740
+ case "HealthComponent": {
741
+ const values = readPackedPairs(packet.payload);
742
+ return recordUpdate({ currentHealth: values.get(0), maxHealth: values.get(1) });
743
+ }
744
+ case "SkillsComponent": {
745
+ const values = readPackedPairs(packet.payload);
746
+ return recordUpdate({ currentMana: values.get(0), maxMana: values.get(1) });
747
+ }
748
+ case "MoveComponent":
749
+ return recordUpdate({ moveSpeed: readMoveSpeed(packet.payload) });
750
+ default:
751
+ return;
752
+ }
753
+ }
754
+ function decodeCharacterSpawnRecords(entries) {
755
+ if (!entries)
756
+ return;
757
+ const update = {};
758
+ for (const entry of entries) {
759
+ const value = entry.fields.find((field) => field.name === entry.name)?.value;
760
+ if (typeof value !== "number")
761
+ continue;
762
+ if (entry.networkBehaviourType === "HealthComponent") {
763
+ if (entry.name === "healthSync")
764
+ update.currentHealth = value;
765
+ else if (entry.name === "maxHealthSync")
766
+ update.maxHealth = value;
767
+ } else if (entry.networkBehaviourType === "SkillsComponent") {
768
+ if (entry.name === "manaSync")
769
+ update.currentMana = value;
770
+ else if (entry.name === "maxManaSync")
771
+ update.maxMana = value;
772
+ } else if (entry.networkBehaviourType === "MoveComponent" && entry.name === "MoveSpeed") {
773
+ update.moveSpeed = value;
774
+ }
775
+ }
776
+ return recordUpdate(update);
777
+ }
778
+ function recordUpdate(update) {
779
+ const entries = Object.entries(update).filter(([, value]) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1e8);
780
+ return entries.length === 0 ? undefined : Object.fromEntries(entries);
781
+ }
782
+ function readPackedPairs(payload) {
783
+ const values = new Map;
784
+ let offset = 0;
785
+ try {
786
+ while (offset < payload.length) {
787
+ const index = payload.readUInt8(offset);
788
+ offset += 1;
789
+ const { value, next } = readPacked(payload, offset);
790
+ offset = next;
791
+ values.set(index, value);
792
+ }
793
+ } catch {}
794
+ return values;
795
+ }
796
+ function readMoveSpeed(payload) {
797
+ let offset = 0;
798
+ while (offset < payload.length) {
799
+ const index = payload.readUInt8(offset);
800
+ offset += 1;
801
+ if (index === 1) {
802
+ if (offset + 4 > payload.length)
803
+ return;
804
+ return payload.readFloatLE(offset);
805
+ }
806
+ if (index === 0)
807
+ offset += 1;
808
+ else
809
+ return;
810
+ }
811
+ return;
812
+ }
813
+ function readPacked(payload, start) {
814
+ let raw = 0n;
815
+ let shift = 0n;
816
+ let offset = start;
817
+ for (let count = 0;count < 10; count += 1) {
818
+ if (offset >= payload.length)
819
+ throw new Error("truncated packed integer");
820
+ const byte = payload[offset++];
821
+ raw |= BigInt(byte & 127) << shift;
822
+ if ((byte & 128) === 0) {
823
+ const value = Number(raw >> 1n ^ -(raw & 1n));
824
+ if (!Number.isSafeInteger(value))
825
+ throw new Error("packed integer exceeds safe range");
826
+ return { value, next: offset };
827
+ }
828
+ shift += 7n;
829
+ }
830
+ throw new Error("invalid packed integer");
831
+ }
832
+ // src/tracker.ts
833
+ var CHARACTER_RPCS = new Set(["LoadCharacter_T", "CharacterCallback_T"]);
834
+ var MAX_PENDING_RECORD_OBJECTS = 4096;
835
+ var REGEN_CADENCE_MAX_GAP_MS = 1750;
836
+ var REGEN_SETTLE_QUIET_MS = 2250;
837
+ var RECORDED_STATS = [
838
+ ["max-health", ["maxHealth", "normalizedMaxHp"]],
839
+ ["max-mana", ["maxMana", "normalizedMaxMp"]],
840
+ ["move-speed", ["moveSpeed"]]
841
+ ];
842
+
843
+ class FishNetCharacterTracker {
844
+ snapshot;
845
+ identity;
846
+ unsupportedDetail;
847
+ currentWeight;
848
+ localObjectId;
849
+ localConnectionId;
850
+ records = {};
851
+ pendingRecords = new Map;
852
+ listeners = new Set;
853
+ healthRegen;
854
+ manaRegen;
855
+ now;
856
+ schedule;
857
+ cancel;
858
+ constructor(initial, timing = {}) {
859
+ this.now = timing.now ?? Date.now;
860
+ this.schedule = timing.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs));
861
+ this.cancel = timing.cancel ?? ((handle) => clearTimeout(handle));
862
+ if (initial)
863
+ this.snapshot = { ...initial, source: "cached" };
864
+ }
865
+ consume(packet) {
866
+ if (packet.packetName === "authenticated" || packet.packetName === "disconnect") {
867
+ if (this.localConnectionId === undefined || packet.connectionId === this.localConnectionId) {
868
+ this.releaseLocalObject();
869
+ }
870
+ return false;
871
+ }
872
+ if (packet.packetName === "objectDespawn" && packet.objectId !== undefined) {
873
+ this.pendingRecords.delete(pendingKey(packet.connectionId, packet.objectId));
874
+ if (this.isLocalObject(packet))
875
+ this.releaseLocalObject();
876
+ return false;
877
+ }
878
+ if (packet.packetName === "objectSpawn" && packet.objectId !== undefined) {
879
+ const key = pendingKey(packet.connectionId, packet.objectId);
880
+ this.pendingRecords.delete(key);
881
+ const update = decodeCharacterSpawnRecords(packet.spawnSyncEntries);
882
+ if (update)
883
+ Object.assign(this.pendingRecordsFor(key), update, { updatedAt: new Date().toISOString() });
884
+ }
885
+ if (packet.packetName === "serverRpc" && packet.objectId !== undefined) {
886
+ const objectChanged = this.localObjectId !== packet.objectId || this.localConnectionId !== packet.connectionId;
887
+ this.localObjectId = packet.objectId;
888
+ this.localConnectionId = packet.connectionId;
889
+ const pending = this.pendingRecords.get(pendingKey(packet.connectionId, packet.objectId));
890
+ if (pending) {
891
+ this.resetRegenSequences();
892
+ this.records = { ...pending };
893
+ this.applyAuthoritativeMaxima();
894
+ } else if (objectChanged) {
895
+ this.resetRegenSequences();
896
+ this.records = {};
897
+ }
898
+ this.pendingRecords.clear();
899
+ if (objectChanged || pending)
900
+ this.publish();
901
+ return false;
902
+ }
903
+ if (packet.packetName === "syncType") {
904
+ const identityChanged = this.consumeIdentitySync(packet);
905
+ const recordChanged = this.consumeRecordSync(packet);
906
+ return identityChanged || recordChanged;
907
+ }
908
+ if (packet.rpcName === undefined || !CHARACTER_RPCS.has(packet.rpcName))
909
+ return false;
910
+ try {
911
+ const decoded = decodeCharacterRpcPayload(packet.payload, packet.rpcName === "CharacterCallback_T");
912
+ if (this.snapshot && this.snapshot.name !== decoded.snapshot.name) {
913
+ this.currentWeight = undefined;
914
+ this.records = {};
915
+ this.resetRegenSequences();
916
+ }
917
+ this.snapshot = mergeSnapshot(this.snapshot, decoded.snapshot, decoded.updateType);
918
+ if (decoded.currentWeight !== undefined)
919
+ this.currentWeight = decoded.currentWeight;
920
+ this.unsupportedDetail = undefined;
921
+ } catch (error) {
922
+ this.unsupportedDetail = `Character data isn't recognized: ${errorMessage(error)}. Change maps or channels to request a fresh update.`.slice(0, 240);
923
+ }
924
+ this.publish();
925
+ return true;
926
+ }
927
+ consumeIdentitySync(packet) {
928
+ if (packet.networkBehaviourType !== "StatusComponent" || !this.isLocalObject(packet) || !packet.decodedFields)
929
+ return false;
930
+ let name;
931
+ let level;
932
+ let jobLevel;
933
+ for (const field of packet.decodedFields) {
934
+ if (field.name === "DisplayName" && typeof field.value === "string")
935
+ name = field.value;
936
+ else if (field.name === "Level" && typeof field.value === "number")
937
+ level = field.value;
938
+ else if (field.name === "JobLevel" && typeof field.value === "number")
939
+ jobLevel = field.value;
940
+ }
941
+ if (name === undefined && level === undefined && jobLevel === undefined)
942
+ return false;
943
+ this.identity = {
944
+ name: name ?? this.identity?.name ?? "",
945
+ level: level ?? this.identity?.level,
946
+ jobLevel: jobLevel ?? this.identity?.jobLevel
947
+ };
948
+ if (!this.identity.name)
949
+ return false;
950
+ this.publish();
951
+ return true;
952
+ }
953
+ consumeRecordSync(packet) {
954
+ if (packet.objectId === undefined)
955
+ return false;
956
+ const update = decodeCharacterRecordSync(packet);
957
+ if (!update)
958
+ return false;
959
+ if (!this.isLocalObject(packet)) {
960
+ const key = pendingKey(packet.connectionId, packet.objectId);
961
+ Object.assign(this.pendingRecordsFor(key), update, { updatedAt: new Date().toISOString() });
962
+ return false;
963
+ }
964
+ this.applyLocalRecordUpdate(update);
965
+ this.publish();
966
+ return true;
967
+ }
968
+ pendingRecordsFor(key) {
969
+ let records = this.pendingRecords.get(key);
970
+ if (!records) {
971
+ if (this.pendingRecords.size >= MAX_PENDING_RECORD_OBJECTS) {
972
+ const oldestKey = this.pendingRecords.keys().next().value;
973
+ if (oldestKey !== undefined)
974
+ this.pendingRecords.delete(oldestKey);
975
+ }
976
+ records = {};
977
+ this.pendingRecords.set(key, records);
978
+ }
979
+ return records;
980
+ }
981
+ isLocalObject(packet) {
982
+ return this.localObjectId !== undefined && packet.objectId === this.localObjectId && packet.connectionId === this.localConnectionId;
983
+ }
984
+ releaseLocalObject() {
985
+ this.localObjectId = undefined;
986
+ this.localConnectionId = undefined;
987
+ this.pendingRecords.clear();
988
+ this.resetRegenSequences();
989
+ this.identity = undefined;
990
+ }
991
+ setCached(snapshot) {
992
+ this.snapshot = snapshot ? { ...snapshot, source: "cached" } : undefined;
993
+ this.currentWeight = undefined;
994
+ this.records = {};
995
+ this.pendingRecords.clear();
996
+ this.resetRegenSequences();
997
+ this.unsupportedDetail = undefined;
998
+ this.publish();
999
+ }
1000
+ current() {
1001
+ return this.snapshot ? structuredClone(this.snapshot) : undefined;
1002
+ }
1003
+ currentObjectId() {
1004
+ return this.localObjectId;
1005
+ }
1006
+ rekeyPendingObject(connectionId, fromObjectId, toObjectId) {
1007
+ if (fromObjectId === toObjectId)
1008
+ return;
1009
+ const fromKey = pendingKey(connectionId, fromObjectId);
1010
+ const candidate = this.pendingRecords.get(fromKey);
1011
+ if (!candidate)
1012
+ return;
1013
+ this.pendingRecords.delete(fromKey);
1014
+ Object.assign(this.pendingRecordsFor(pendingKey(connectionId, toObjectId)), candidate);
1015
+ }
1016
+ applyLocalRecordUpdate(update) {
1017
+ const previousHealth = this.records.currentHealth;
1018
+ const previousMana = this.records.currentMana;
1019
+ Object.assign(this.records, update, { updatedAt: new Date().toISOString() });
1020
+ this.applyAuthoritativeMaxima();
1021
+ if (update.maxHealth === undefined && update.currentHealth !== undefined && this.records.maxHealth === undefined) {
1022
+ this.observeRegen("health", previousHealth, update.currentHealth);
1023
+ }
1024
+ if (update.maxMana === undefined && update.currentMana !== undefined && this.records.maxMana === undefined) {
1025
+ this.observeRegen("mana", previousMana, update.currentMana);
1026
+ }
1027
+ }
1028
+ applyAuthoritativeMaxima() {
1029
+ if (this.records.maxHealth !== undefined) {
1030
+ this.records.normalizedMaxHp = this.records.maxHealth;
1031
+ this.clearRegen("health");
1032
+ }
1033
+ if (this.records.maxMana !== undefined) {
1034
+ this.records.normalizedMaxMp = this.records.maxMana;
1035
+ this.clearRegen("mana");
1036
+ }
1037
+ }
1038
+ observeRegen(kind, previous, current) {
1039
+ const normalizedKey = kind === "health" ? "normalizedMaxHp" : "normalizedMaxMp";
1040
+ const knownMaximum = this.records[normalizedKey];
1041
+ if (knownMaximum !== undefined && current <= knownMaximum)
1042
+ return;
1043
+ if (knownMaximum !== undefined)
1044
+ delete this.records[normalizedKey];
1045
+ if (previous === undefined || current < previous) {
1046
+ this.clearRegen(kind);
1047
+ return;
1048
+ }
1049
+ const now = this.now();
1050
+ let sequence = kind === "health" ? this.healthRegen : this.manaRegen;
1051
+ if (current > previous) {
1052
+ const continues = sequence !== undefined && now - sequence.lastObservedAtMs <= REGEN_CADENCE_MAX_GAP_MS;
1053
+ this.clearRegenTimer(sequence);
1054
+ sequence = {
1055
+ lastValue: current,
1056
+ lastObservedAtMs: now,
1057
+ consecutiveIncreases: continues ? sequence.consecutiveIncreases + 1 : 1
1058
+ };
1059
+ if (kind === "health")
1060
+ this.healthRegen = sequence;
1061
+ else
1062
+ this.manaRegen = sequence;
1063
+ } else if (sequence) {
1064
+ this.clearRegenTimer(sequence);
1065
+ sequence.lastObservedAtMs = now;
1066
+ sequence.lastValue = current;
1067
+ } else {
1068
+ return;
1069
+ }
1070
+ if (sequence.consecutiveIncreases < 2)
1071
+ return;
1072
+ sequence.timer = this.schedule(() => this.settleRegen(kind, sequence), REGEN_SETTLE_QUIET_MS);
1073
+ }
1074
+ settleRegen(kind, sequence) {
1075
+ if ((kind === "health" ? this.healthRegen : this.manaRegen) !== sequence)
1076
+ return;
1077
+ const currentKey = kind === "health" ? "currentHealth" : "currentMana";
1078
+ const maxKey = kind === "health" ? "maxHealth" : "maxMana";
1079
+ const normalizedKey = kind === "health" ? "normalizedMaxHp" : "normalizedMaxMp";
1080
+ if (this.records[maxKey] !== undefined || this.records[currentKey] !== sequence.lastValue)
1081
+ return;
1082
+ this.records[normalizedKey] = sequence.lastValue;
1083
+ this.records.updatedAt = new Date().toISOString();
1084
+ this.clearRegen(kind);
1085
+ this.publish();
1086
+ }
1087
+ clearRegen(kind) {
1088
+ const sequence = kind === "health" ? this.healthRegen : this.manaRegen;
1089
+ this.clearRegenTimer(sequence);
1090
+ if (kind === "health")
1091
+ this.healthRegen = undefined;
1092
+ else
1093
+ this.manaRegen = undefined;
1094
+ }
1095
+ clearRegenTimer(sequence) {
1096
+ if (sequence?.timer !== undefined)
1097
+ this.cancel(sequence.timer);
1098
+ }
1099
+ resetRegenSequences() {
1100
+ this.clearRegen("health");
1101
+ this.clearRegen("mana");
1102
+ }
1103
+ currentArchetypeId() {
1104
+ const archetype = this.snapshot?.archetypes.at(-1);
1105
+ return archetype === undefined ? undefined : resolveCharacterArchetypeId(archetype);
1106
+ }
1107
+ state() {
1108
+ const snapshot = this.snapshot ? rescaleSubstats(structuredClone(this.snapshot)) : undefined;
1109
+ const records = Object.keys(this.records).length > 0 ? { records: { ...this.records } } : {};
1110
+ const weight = snapshot && this.currentWeight !== undefined ? { weight: { current: this.currentWeight, maximum: calculateWeightLimit(snapshot) } } : {};
1111
+ const identity = this.identity ? { identity: { ...this.identity } } : {};
1112
+ if (this.unsupportedDetail)
1113
+ return {
1114
+ ...snapshot ? { snapshot } : {},
1115
+ ...identity,
1116
+ stats: snapshot ? this.applyRecords(calculateStats(snapshot)) : [],
1117
+ gearTotals: snapshot ? calculateGearTotals(snapshot) : [],
1118
+ ...records,
1119
+ ...weight,
1120
+ status: "unsupported",
1121
+ statusDetail: this.unsupportedDetail
1122
+ };
1123
+ if (!snapshot)
1124
+ return {
1125
+ ...identity,
1126
+ status: "waiting",
1127
+ statusDetail: "Waiting for the game to send your character\u2026 Change maps or channels to request an update.",
1128
+ stats: [],
1129
+ gearTotals: [],
1130
+ ...records,
1131
+ ...weight
1132
+ };
1133
+ return {
1134
+ snapshot,
1135
+ ...identity,
1136
+ stats: this.applyRecords(calculateStats(snapshot)),
1137
+ gearTotals: calculateGearTotals(snapshot),
1138
+ ...records,
1139
+ ...weight,
1140
+ status: snapshot.source,
1141
+ statusDetail: snapshot.source === "live" ? "Live character data" : "Last known character"
1142
+ };
1143
+ }
1144
+ applyRecords(stats) {
1145
+ for (const [statId, recordKeys] of RECORDED_STATS) {
1146
+ const value = recordKeys.map((key) => this.records[key]).find((candidate) => typeof candidate === "number");
1147
+ if (typeof value !== "number")
1148
+ continue;
1149
+ const stat2 = stats.find((entry) => entry.id === statId);
1150
+ if (stat2)
1151
+ stat2.record = value;
1152
+ }
1153
+ return stats;
1154
+ }
1155
+ subscribe(listener) {
1156
+ this.listeners.add(listener);
1157
+ return () => this.listeners.delete(listener);
1158
+ }
1159
+ publish() {
1160
+ const state = this.state();
1161
+ for (const listener of this.listeners)
1162
+ listener(state);
1163
+ }
1164
+ }
1165
+ function pendingKey(connectionId, objectId) {
1166
+ return `${connectionId} ${objectId}`;
1167
+ }
1168
+ function calculateStats(snapshot) {
1169
+ const gearTotals = calculateGearTotals(snapshot);
1170
+ return calculateCharacterStats(snapshot.level, snapshot.attributes, [...materializeGearStats(snapshot.equipment, snapshot.artifacts), ...materializeSkillStats(snapshot.skills)], snapshot.archetypes).concat(calculateAdvancedGearStats(gearTotals));
1171
+ }
1172
+ function calculateGearTotals(snapshot) {
1173
+ return aggregateGearSubstats(snapshot.equipment, snapshot.artifacts);
1174
+ }
1175
+ function errorMessage(error) {
1176
+ return error instanceof Error ? error.message : String(error);
1177
+ }
1178
+ function mergeSnapshot(previous, next, updateType) {
1179
+ if (!previous || previous.name !== next.name || updateType === 327417855)
1180
+ return next;
1181
+ const merged = { ...previous, updatedAt: next.updatedAt, source: "live" };
1182
+ if (updateType & (1 | 2))
1183
+ Object.assign(merged, { level: next.level, experience: next.experience, jobLevel: next.jobLevel, jobExperience: next.jobExperience });
1184
+ if (updateType & 4)
1185
+ merged.attributes = next.attributes;
1186
+ if (updateType & (32 | 64 | 4194304 | 33554528)) {
1187
+ merged.activeLoadout = next.activeLoadout;
1188
+ merged.equipment = next.equipment;
1189
+ merged.artifacts = next.artifacts;
1190
+ }
1191
+ if (updateType & 131072)
1192
+ merged.archetypes = next.archetypes;
1193
+ if (updateType & 8388608)
1194
+ merged.title = next.title;
1195
+ if (updateType & 16777216)
1196
+ merged.name = next.name;
1197
+ if (next.playtimeSeconds !== undefined)
1198
+ merged.playtimeSeconds = next.playtimeSeconds;
1199
+ if (next.monsterKills !== undefined)
1200
+ merged.monsterKills = next.monsterKills;
1201
+ if (next.bossKills !== undefined)
1202
+ merged.bossKills = next.bossKills;
1203
+ if (next.deaths !== undefined)
1204
+ merged.deaths = next.deaths;
1205
+ return merged;
1206
+ }
1207
+ // src/inspect-roster.ts
1208
+ var INSPECT_RPC = "Inspect_T";
1209
+ var DEFAULT_LIMIT = 24;
1210
+
1211
+ class FishNetInspectRoster {
1212
+ limit;
1213
+ characters = new Map;
1214
+ listeners = new Set;
1215
+ localName;
1216
+ constructor(limit = DEFAULT_LIMIT) {
1217
+ this.limit = limit;
1218
+ }
1219
+ setLocalName(name) {
1220
+ this.localName = name;
1221
+ }
1222
+ consume(packet, now = new Date) {
1223
+ if (packet.rpcName !== INSPECT_RPC)
1224
+ return false;
1225
+ let snapshot;
1226
+ try {
1227
+ ({ snapshot } = decodeCharacterRpcPayload(packet.payload, false, now));
1228
+ } catch {
1229
+ return false;
1230
+ }
1231
+ if (!snapshot.name)
1232
+ return false;
1233
+ if (snapshot.name === this.localName)
1234
+ return false;
1235
+ this.characters.delete(snapshot.name);
1236
+ this.characters.set(snapshot.name, { snapshot, inspectedAt: now.toISOString() });
1237
+ while (this.characters.size > this.limit) {
1238
+ const oldest = this.characters.keys().next();
1239
+ if (oldest.done)
1240
+ break;
1241
+ this.characters.delete(oldest.value);
1242
+ }
1243
+ this.publish();
1244
+ return true;
1245
+ }
1246
+ list() {
1247
+ return [...this.characters.values()].sort((left, right) => right.inspectedAt.localeCompare(left.inspectedAt)).map((entry) => structuredClone(entry));
1248
+ }
1249
+ get(name) {
1250
+ const entry = this.characters.get(name);
1251
+ return entry ? structuredClone(entry.snapshot) : undefined;
1252
+ }
1253
+ clear() {
1254
+ if (!this.characters.size)
1255
+ return;
1256
+ this.characters.clear();
1257
+ this.publish();
1258
+ }
1259
+ subscribe(listener) {
1260
+ this.listeners.add(listener);
1261
+ return () => {
1262
+ this.listeners.delete(listener);
1263
+ };
1264
+ }
1265
+ publish() {
1266
+ const roster = this.list();
1267
+ for (const listener of this.listeners)
1268
+ listener(roster);
1269
+ }
1270
+ }
1271
+ // src/types.ts
1272
+ var ATTRIBUTE_NAMES = ["STR", "VIT", "AGI", "DEX", "INT", "LUK"];
1273
+ export {
1274
+ ATTRIBUTE_NAMES,
1275
+ FishNetCharacterTracker,
1276
+ FishNetInspectRoster,
1277
+ PERCENT_STATS,
1278
+ STAT_NAMES,
1279
+ aggregateGearSubstats,
1280
+ calculateCharacterStats,
1281
+ calculateWeightLimit,
1282
+ decodeCharacterRecordSync,
1283
+ decodeCharacterRpcPayload,
1284
+ decodeCharacterSpawnRecords,
1285
+ materializeGearStats,
1286
+ materializeSkillStats,
1287
+ rescaleSubstats,
1288
+ resolveCharacterArchetypeId,
1289
+ resolveCharacterHealingTraits
1290
+ };