-
Notifications
You must be signed in to change notification settings - Fork 1
/
TooltipsV2.ts
1616 lines (1369 loc) · 68.2 KB
/
TooltipsV2.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//TODO(Rennorb): Provide a clean way to construct custom tooltips. Currently with the old version we manipulate the cache before the hook function gets called, which really isn't the the best.
//TODO(Rennorb): Defiance break on single effect tooltips.
//TODO(Rennorb): Change anything percent related to use fractions instead of integers (0.2 instead of 20).
// The only thing this is good for is to make drawing the facts easier. Since we do quite a few calculations this swap would reduce conversions quite a bit.
//TODO(Rennorb) @correctness: Split up incoming / outgoing effects. Mostly relevant for healing.
//TODO(Rennorb) @correctness: implement processing for trait / skill buffs to properly show certain flip skills and chains aswell as properly do trait overrides for skills
export const VERSION = 0;
let tooltip : HTMLElement
let lastTooltipTarget : HTMLElement | undefined
let cyclePos : number = 0
let lastMouseX : number
let lastMouseY : number
export const contexts : Context[] = []; //@debug
export let config : Config = null!;
function activateSubTooltip(tooltipIndex : number) {
const tooltips = tooltip.children as HTMLCollectionOf<HTMLElement>;
for(let index = 0; index < tooltips.length; index++) {
tooltips[index].classList.toggle('active', index === tooltipIndex);
}
}
function updateAnchorElement(cyclerGw2object : HTMLElement, tooltipIndex : number) {
if(cyclerGw2object.firstElementChild?.nodeName != 'A') return;
const tooltip_ = (tooltip.children as HTMLCollectionOf<HTMLElement>)[tooltipIndex];
if(tooltip_) {
//NOTE(Rennorb): All tooltips have a title.
const name = tooltip_.querySelector('.title-text')!.textContent;
// filter out unresolved and empty names
if(name && !name.startsWith('((') && !name.startsWith('<#')) {
(cyclerGw2object.firstElementChild as HTMLAnchorElement).href = WIKI_SEARCH_URL + name;
}
}
}
function scrollSubTooltipIntoView(tooltipIndex : number, animate = false) {
const tooltips = (tooltip.children as HTMLCollectionOf<HTMLElement>)[tooltipIndex];
tooltip.style.transition = animate ? 'transform 0.25s' : '';
tooltip.style.transform = `translate(0, -${tooltips.offsetTop + tooltips.offsetHeight}px)`;
}
//NOTE(Rennorb): If the tooltip doesn't fit on screen its probably because we have many and they don't fit even if collapsed.
// In that case we fit the currently active one on screen instead of the whole list.
function positionTooltip(animate = false) {
const wpadminbar = document.getElementById('wpadminbar'); //TODO(Rennorb) @hardcoded: this accounts for the wordpress bar that might exist.
const topBarHeight = wpadminbar ? wpadminbar.offsetHeight : 0;
//using actual css margins in js is pain
const marginX = 22;
const marginY = 13;
//some space form the cursor
const offsetX = 6;
const offsetY = 6;
const currentSubTooltip = tooltip.children[cyclePos] as HTMLElement;
let tooltipXpos = lastMouseX + offsetX;
if(tooltipXpos + tooltip.offsetWidth > document.documentElement.clientWidth - marginX) {
tooltipXpos = document.documentElement.clientWidth - (tooltip.offsetWidth + marginX);
}
let tooltipYpos = lastMouseY - offsetY;
if(tooltipYpos - currentSubTooltip.offsetHeight < document.documentElement.scrollTop + topBarHeight + marginY) {
if(animate) {
tooltip.style.transition += ', top 0.25s';
setTimeout(() => tooltip.style.transition = '', 250);
}
tooltipYpos = document.documentElement.scrollTop + topBarHeight + marginY + currentSubTooltip.offsetHeight;
}
tooltip.style.left = `${tooltipXpos}px`;
tooltip.style.top = `${tooltipYpos}px`;
}
type GW2ObjectMap = {
[k in `${Exclude<V2ObjectType, 'attribute'>}s`] : Map<APIResponseTypeMap[k]['id'], HTMLElement[]>
} & {
attributes : Map<string, HTMLElement[]>,
}
export async function hookDocument(scope : ScopeElement, _unused? : any) : Promise<GW2ObjectMap> {
const buildNodes = document.getElementsByClassName('gw2-build-wrapper');
if(config.autoCollectSelectedTraits) {
if(buildNodes.length) for(const target of buildNodes)
Collect.allTraits(target)
else {
console.warn("[gw2-tooltips] [collect] `config.autoCollectSelectedTraits` is active, but no element with class `gw2-build` could be found to use as source. Build information will not be collected as there is no way to tell which objects belong to the build definition and which ones are just in some arbitrary text.");
}
}
const gw2Objects = await hookDOMSubtreeSlim(scope);
if(config.autoInferWeaponSetAssociation) {
for(const buildNode of buildNodes) {
for(const [i, setNode] of buildNode.querySelectorAll('.weapon-set').entries()) {
for(const objNode of setNode.getElementsByTagName('GW2OBJECT'))
objNode.setAttribute('weapon-set', String(i));
}
const skillIdsBySet = [];
for(const [i, setSkillsNode] of buildNode.querySelectorAll('.skills-weapon-set').entries()) {
const skillIds : number[] = [];
const chainIds = (skill : API.Skill, context : Context, adjustTraitedSkillIds : boolean) => {
if(adjustTraitedSkillIds) {
const replacementSkill = findTraitedOverride(skill, context);
if(replacementSkill) skill = replacementSkill;
}
skillIds.push(skill.id);
let palette, group, i;
[palette, group, i, context] = guessGroupAndContext(skill, context);
if(group) {
let candidate = group.candidates[i];
//in case we catch a chain in the middle
while(candidate.previous_chain_skill_index) {
const otherCandidate = group.candidates[candidate.previous_chain_skill_index];
if(!canBeSelected(otherCandidate, context)) break;
skillIds.push(otherCandidate.skill);
candidate = otherCandidate;
}
//remaining chain
for(let j = 0; j < i; j++) {
const otherCandidate = group.candidates[j];
if(otherCandidate.previous_chain_skill_index != i) continue;
if(!canBeSelected(otherCandidate, context)) continue;
skillIds.push(otherCandidate.skill);
i = j;
j = -1;
}
}
if(skill.bundle_skills) for(const subSkillId of skill.bundle_skills) {
const subSkillInChain = APICache.storage.skills.get(subSkillId);
if(subSkillInChain)
skillIds.push(subSkillId);
}
if(skill.related_skills) for(const subSkillId of skill.related_skills) {
const subSkillInChain = APICache.storage.skills.get(subSkillId);
if(subSkillInChain && subSkillInChain.palettes.some(pid => {
const palette = APICache.storage.palettes.get(pid);
return palette && VALID_CHAIN_PALETTES.includes(palette.type);
})) {
skillIds.push(subSkillId);
}
}
if(skill.ambush_skills) for(const { id: subSkillId } of skill.ambush_skills) {
const subSkillInChain = APICache.storage.skills.get(subSkillId);
if(subSkillInChain)
skillIds.push(subSkillId);
}
}
for(const objNode of setSkillsNode.children) {
objNode.setAttribute('weapon-set', String(i));
const skill = APICache.storage.skills.get(+String(objNode.getAttribute('objid'))!);
const context = contexts[+String(objNode.getAttribute('context-set')) || 0];
const adjustTraitedSkillIds = objNode.classList.contains('auto-transform');
if(skill) chainIds(skill, context, adjustTraitedSkillIds);
else {
console.warn("[gw2-tooltips] [collect] failed to find skill for object ", objNode);
}
}
skillIdsBySet.push(skillIds);
}
//only run do this charade if there are actually multiple different weapon sets
if(skillIdsBySet.length > 1 && (skillIdsBySet[0][0] != skillIdsBySet[1][0] || skillIdsBySet[0][skillIdsBySet[0].length - 1] != skillIdsBySet[1][skillIdsBySet[1].length - 1])) {
console.info("[gw2-tooltips] [collect] Will mark the following skills as belonging to weapon sets: ", skillIdsBySet);
const descriptionNode = buildNode.parentElement!.nextElementSibling?.nextElementSibling as HTMLElement;
if(descriptionNode) for(const skillNode of descriptionNode.querySelectorAll('gw2object[type=skill]')) {
const id = +String(skillNode.getAttribute('objid')) || 0;
if(id) for(const [i, skills] of skillIdsBySet.entries()) {
if(skills.includes(id)) skillNode.setAttribute('weapon-set', String(i));
}
}
}
}
}
if(config.autoCollectRuneCounts) {
//TODO(Rennorb) @correctness: this might not work properly with multiple builds on one page
if(buildNodes.length) for(const target of buildNodes)
Collect.allUpgradeCounts(target)
else {
console.warn("[gw2-tooltips] [collect] `config.autoCollectRuneCounts` is active, but no element with class `gw2-build` could be found to use as source. Upgrades will not be collected as there is no way to tell which upgrades belongs to the build and which ones are just in some arbitrary text.");
}
}
if(config.autoCollectStatSources) {
if(buildNodes.length) for(const target of buildNodes)
Collect.allStatSources(target)
else {
console.warn("[gw2-tooltips] [collect] `config.autoCollectStatSources` is active, but no element with class `gw2-build` could be found to use as source. Build information will not be collected as there is no way to tell which objects belong to the build definition and which ones are just in some arbitrary text.");
}
}
if(config.autoCollectSelectedTraits) {
Collect.traitEffects(contexts);
}
if(config.autoInferEquipmentUpgrades) {
const targets = document.querySelectorAll('.weapon, .armor, .trinket');
if(targets.length)
inferItemUpgrades(targets)
else {
console.warn("[gw2-tooltips] [collect] `config.autoInferEquipmentUpgrades` is active, but no wrapper elements element with class `'weapon`, `armor` or `trinket` could be found to use as source. No elements will be updated");
}
}
for(const { character } of contexts) {
Collect.hoistGeneralSources(character);
}
if(config.autoRecomputeCharacterAttributes) {
for(const context of contexts) {
for(const weaponSetId of context.character.statsWithWeapons.keys()) {
recomputeAttributesFromMods(context, weaponSetId);
}
}
}
for(const [attribute, elements] of gw2Objects.attributes) {
for(const element of elements) {
inflateAttribute(element, attribute as API.BaseAttribute | API.ComputedAttribute);
}
}
return gw2Objects;
}
/**
* Does **NOT** run any auto-processing functions. Also does not inflate attribute elements, as those might depend on attribute recalculation.
* Use `hookDocument` if you want a convenient way to hook large trees and apply all auto-procs, or run the procs you want yourself.
*/
export async function hookDOMSubtreeSlim(scope : ScopeElement) : Promise<GW2ObjectMap> {
//NOTE(Rennorb): need to use an array since there might be multiple occurrences of the same id in a given scope
const objectsToGet : GW2ObjectMap = {
skills : new Map<number, HTMLElement[]>(),
traits : new Map<number, HTMLElement[]>(),
items : new Map<number, HTMLElement[]>(),
specializations: new Map<number, HTMLElement[]>(),
pets : new Map<number, HTMLElement[]>(),
'pvp/amulets' : new Map<number, HTMLElement[]>(),
skins : new Map<number, HTMLElement[]>(),
attributes : new Map<string, HTMLElement[]>(),
professions : new Map<API.Profession['id'], HTMLElement[]>(),
}
const statsToGet = new Set<number>();
const _legacy_effectErrorStore = new Set<string>();
for(const gw2Object of scope.getElementsByTagName('gw2object') as HTMLCollectionOf<HTMLElement>) {
const stats = +String(gw2Object.getAttribute('stats'));
if(!isNaN(stats)) statsToGet.add(stats);
let objIdRaw = gw2Object.getAttribute('objId');
if(objIdRaw == null) continue;
//TODO(Rennorb) @cleanup: this is literally just for naming 'convenience'.
// Unfortunately i don't think we can get rid of this as the api eps use plural forms. allow singular forms on the api side to get rid of this?
let type = (gw2Object.getAttribute('type') || 'skill') + 's' as `${V2ObjectType | LegacyCompat.ObjectType}s`;
if(type === 'attributes') {
if(objIdRaw != null) {
const elementsWithThisId = objectsToGet.attributes.get(objIdRaw);
if(elementsWithThisId) elementsWithThisId.push(gw2Object);
else objectsToGet.attributes.set(objIdRaw, [gw2Object]);
}
}
else {
let objId : number | string = +objIdRaw;
if(type === 'effects') {
//NOTE(Rennorb): weapon swaps are completely synthesized
if(config.legacyCompatibility) {
type = 'skills';
objId = transformEffectToSkillObject(gw2Object, _legacy_effectErrorStore);
}
else {
continue;
}
}
if((!isNaN(objId) && type in objectsToGet) || (type == 'professions' && (objId = objIdRaw[0].toUpperCase() + objIdRaw.slice(1) /* TODO @cleanup */), PROFESSIONS.includes(objId))) {
const map : Map<APIObjectId, HTMLElement[]> = objectsToGet[type];
const elementsWithThisId = map.get(objId);
if(elementsWithThisId) elementsWithThisId.push(gw2Object);
else map.set(objId, [gw2Object]);
const inlineTraits = gw2Object.getAttribute('with-traits');
if(inlineTraits) {
for(const traitStr of inlineTraits.split(',')) {
const traitId = +traitStr;
if(!traitId) continue;
//NOTE(Rennorb): Don't add the element for inflating, just create the key so it gets cached.
if(!objectsToGet.traits.has(traitId)) objectsToGet.traits.set(traitId, []);
}
}
let inlineSkinId;
if(type == 'items' && !isNaN(inlineSkinId = +String(gw2Object.getAttribute('skin')))) {
//NOTE(Rennorb): Don't add the element for inflating, just create the key so it gets cached.
if(!objectsToGet.skins.has(inlineSkinId)) objectsToGet.skins.set(inlineSkinId, []);
}
const inlineSlottedUpgradeIds = gw2Object.getAttribute('slotted');
if(inlineSlottedUpgradeIds) {
for(const idStr of inlineSlottedUpgradeIds.split(',')) {
const id = +idStr;
//NOTE(Rennorb): Don't add the element for inflating, just create the key so it gets cached.
if(id && !isNaN(id) && !objectsToGet.items.has(id)) objectsToGet.items.set(id, []);
}
}
}
else {
continue;
}
}
attachMouseListeners(gw2Object);
}
if(_legacy_effectErrorStore.size) {
console.error("[gw2-tooltips] [legacy-compat] Some effects could not be translated into skills: ", Array.from(_legacy_effectErrorStore));
}
if(statsToGet.size > 0) APICache.ensureExistence('itemstats', statsToGet.values(), config.validateApiResponses);
await Promise.all(Object.entries(objectsToGet as Omit<typeof objectsToGet, 'attributes'>).map(async ([key, values]) => {
if(values.size === 0 || key as any === 'attributes') return;
let inflator;
switch(key) {
case 'skills' : inflator = inflateSkill; break;
case 'items' : inflator = inflateItem; break;
case 'specializations': inflator = inflateSpecialization; break;
case 'professions' : inflator = inflateProfession; break;
default : inflator = inflateGenericIcon; break;
}
const cache : Map<APIObjectId, APIResponse> = APICache.storage[key];
await APICache.ensureExistence(key, values.keys(), config.validateApiResponses)
for(const [id, objects] of values) {
const data = cache.get(id);
if(!objects || !data) continue;
for(const gw2Object of objects)
inflator(gw2Object, data as any);
}
}));
config.afterDownload?.call(globalThis);
return objectsToGet;
}
export function attachMouseListeners(target : HTMLElement) {
target.addEventListener('mouseenter', (e) => showTooltipOn(e.target as HTMLElement));
target.addEventListener('mouseleave', hideTooltip);
}
export function hideTooltip() {
tooltip.style.display = 'none';
tooltip.style.transform = '';
}
function showTooltipOn(element : HTMLElement, visibleIndex = 0) {
const type = (element.getAttribute('type') || 'skill') as V2ObjectType | LegacyCompat.ObjectType;
if(type == 'effect' || type == 'profession') return;
let objId : number | API.BaseAttribute | API.ComputedAttribute;
let params : AttributeParams | TooltipParams;
const objIdRaw = String(element.getAttribute('objId'));
let context = contexts[+String(element.getAttribute('context-set')) || 0];
if(type === 'attribute') {
objId = objIdRaw as API.BaseAttribute | API.ComputedAttribute;
params = { type };
}
else {
context = specializeContextFromInlineAttribs(context, element);
objId = +objIdRaw;
let weaponSet : number | undefined = +String(element.getAttribute('weapon-set')); if(isNaN(weaponSet)) weaponSet = undefined;
if(type === 'item' || type === 'skin') {
params = { type, weaponSet, element,
statSetId : +String(element.getAttribute('stats')) || undefined,
stackSize : +String(element.getAttribute('count')) || undefined,
slottedItems: element.getAttribute('slotted')?.split(',')
.map(id => APICache.storage.items.get(+id || 0) || MISSING_ITEM)
.filter(i => 'subtype' in i) as API.Items.UpgradeComponent[] | undefined,
};
}
else {
params = { type, weaponSet,
adjustTraitedSkillIds: element.classList.contains('auto-transform'),
};
}
}
lastTooltipTarget = element;
showTooltipFor(objId as any, params as any, context, visibleIndex);
if(tooltip.childElementCount > 1) {
element.classList.add('cycler')
element.title = 'Right-click to cycle through tooltips'
if(config.adjustWikiLinks) updateAnchorElement(element, cyclePos); // reset the element in case it was modified before
}
}
type TooltipParams = SkillParams | ItemParams | SpecializationParams
type AttributeParams = { type : 'attribute' }
type SpecializationParams = { type : 'specialization' }
type ItemParams = {
type : 'item' | 'skin',
weaponSet? : number,
statSetId? : number,
stackSize? : number,
slottedItems? : API.Items.UpgradeComponent[],
element : Element | { getAttribute : (attr : 'skin') => string | undefined }
}
type SkillParams = {
type : Exclude<V2ObjectType, 'attribute' | 'specialization' | 'profession' | 'item' | 'skin'>,
weaponSet? : number,
adjustTraitedSkillIds? : boolean,
}
export function showTooltipFor(objId : API.BaseAttribute | API.ComputedAttribute, params : AttributeParams, context : Context, visibleIndex? : number) : void;
export function showTooltipFor(objId : number, params : TooltipParams, context : Context, visibleIndex? : number) : void;
export function showTooltipFor(objId : number | API.BaseAttribute | API.ComputedAttribute, params : AttributeParams | TooltipParams, context : Context, visibleIndex = 0) : void {
if(params.type === 'attribute') {
//TODO(Rennorb): should we actually reset this every time?
cyclePos = visibleIndex;
tooltip.replaceChildren(generateAttributeTooltip(objId as API.BaseAttribute | API.ComputedAttribute, context));
tooltip.style.display = ''; //empty value resets actual value to use stylesheet
scrollSubTooltipIntoView(cyclePos);
return;
}
else if(params.type === 'specialization') {
//TODO(Rennorb): should we actually reset this every time?
cyclePos = visibleIndex;
const data = APICache.storage.specializations.get(objId as number);
if(!data) return;
tooltip.replaceChildren(generateSpecializationTooltip(data));
tooltip.style.display = ''; //empty value resets actual value to use stylesheet
scrollSubTooltipIntoView(cyclePos);
return;
}
const data = APICache.storage[(params.type + 's') as `${typeof params.type}s`].get(objId as number);
if(!data) return;
if('palettes' in data) {
//NOTE(Rennorb): This is here so we can look at underwater skills from a land context and vice versa.
if(context.underwater) {
if(!data.flags.includes('UsableUnderWater') && data.flags.includes('UsableLand')) {
if(!context.cloned) context = Object.assign({}, context);
context.underwater = false;
}
}
else if(!context.underwater) {
if(!data.flags.includes('UsableLand') && data.flags.includes('UsableUnderWater')) {
if(!context.cloned) context = Object.assign({}, context);
context.underwater = true;
}
}
}
const [innerTooltips, initialActiveIndex] = generateToolTipList(data, params, context);
//TODO(Rennorb): should we actually reset this every time?
cyclePos = visibleIndex > 0 ? visibleIndex : initialActiveIndex;
tooltip.replaceChildren(...innerTooltips);
tooltip.style.display = ''; //empty value resets actual value to use stylesheet
if(tooltip.childElementCount > 1) {
activateSubTooltip(cyclePos)
}
else if(tooltip.firstElementChild) {
tooltip.firstElementChild.classList.add('active');
}
scrollSubTooltipIntoView(cyclePos)
}
// TODO(Rennorb) @cleanup: split this into the inflator system aswell. its getting to convoluted already
function generateToolTip(apiObject : SupportedTTTypes, slotName : string | undefined, iconMode : IconRenderMode, context : Context, weaponSet? : number) : HTMLElement {
const headerElements = [];
if(iconMode == IconRenderMode.SHOW || (iconMode == IconRenderMode.FILTER_DEV_ICONS && !IsDevIcon(apiObject.icon)))
headerElements.push(newImg(apiObject.icon));
headerElements.push(
newElm('span.title-text', apiObject.name ? fromHTML(GW2Text2HTML(resolveInflections(apiObject.name, 1, context.character))) : `<#${apiObject.id}>`),
newElm('div.flexbox-fill'), // split, now the right side
);
const currentContextInformation = resolveTraitsAndOverrides(apiObject, context);
pushCostAndRestrictionLabels(headerElements, apiObject, currentContextInformation, context);
const secondHeaderRow = [];
if(slotName) secondHeaderRow.push(newElm('span', `( ${slotName} )`));
if(weaponSet !== undefined) secondHeaderRow.push(newElm('span', `( Weapon Set ${weaponSet + 1} )`));
secondHeaderRow.push(newElm('div.flexbox-fill')); // split, now the right side
pushGamemodeSplitLabels(secondHeaderRow, apiObject, context);
const parts : HTMLElement[] = [newElm('h4.title', ...headerElements)];
if(secondHeaderRow.length > 1) parts.push(newElm('h4.detail', ...secondHeaderRow));
if('description' in apiObject && apiObject.description) {
parts.push(newElm('p.description', fromHTML(GW2Text2HTML(apiObject.description))))
}
pushFacts(parts, apiObject, currentContextInformation, context, weaponSet === undefined ? context.character.selectedWeaponSet : weaponSet);
const tooltip = newElm('div.tooltip', ...parts)
tooltip.dataset.id = String(apiObject.id)
return tooltip;
}
function pushCostAndRestrictionLabels(destinationArray : Node[], sourceObject : SupportedTTTypes, specializedContextInformation : API.ContextInformation, context : Context) {
if('flags' in sourceObject && sourceObject.flags!.includes('DisallowUnderwater')) {
destinationArray.push(newImg(ICONS.NoUnderwater, 'iconsmall'));
}
if(specializedContextInformation.activation) {
const value = formatFraction(specializedContextInformation.activation / 1000, config);
if (value != '0') { //in case we rounded down a fractional value just above 0
destinationArray.push(newElm('span.property',
value,
newImg(ICONS.Activation, 'iconsmall')
));
}
}
if(specializedContextInformation.resource_cost) {
destinationArray.push(newElm('span.property',
String(specializedContextInformation.resource_cost),
//TODO(Rennorb) @correctness: see reaper shroud
newImg(ICONS['Resource'+context.character.profession as keyof typeof ICONS] || ICONS.ResourceThief, 'iconsmall')
));
}
if(specializedContextInformation.endurance_cost) {
destinationArray.push(newElm('span.property',
String(Math.round(specializedContextInformation.endurance_cost)),
newImg(ICONS.CostEndurance, 'iconsmall')
));
}
if(specializedContextInformation.upkeep_cost) {
destinationArray.push(newElm('span.property',
String(specializedContextInformation.upkeep_cost),
newImg(ICONS.CostUpkeep, 'iconsmall')
));
}
if(specializedContextInformation.recharge) {
const value = formatFraction(specializedContextInformation.recharge / 1000, config);
if (value != '0') {
destinationArray.push(newElm('span.property',
value,
newImg(ICONS.Recharge, 'iconsmall')
));
}
}
if(specializedContextInformation.supply_cost) {
destinationArray.push(newElm('span.property',
String(specializedContextInformation.supply_cost),
newImg(ICONS.CostSupply, 'iconsmall')
));
}
}
function pushGamemodeSplitLabels(destinationArray : Node[], SourceObject : SupportedTTTypes, context : Context) {
if('override_groups' in SourceObject && SourceObject.override_groups) {
const baseContext = new Set<API.GameMode>(['Pve', 'Pvp', 'Wvw']);
for(const override of SourceObject.override_groups) {
for(const context of override.context) {
baseContext.delete(context as API.GameMode);
}
}
const splits = [Array.from(baseContext), ...SourceObject.override_groups.map(o => o.context)]
const splits_html : string[] = [];
for(const mode of ['Pve', 'Pvp', 'Wvw'] as API.GameMode[]) { //loop to keep sorting vaguely correct
let split;
for(let i = 0; i < splits.length; i++) {
if(splits[i].includes(mode)) {
split = splits.splice(i, 1)[0];
break;
}
}
if(!split) continue;
const text = split.join('/');
if(split.includes(context.gameMode))
splits_html.push(`<span style="color: var(--gw2-tt-color-text-accent) !important;">${text}</span>`);
else
splits_html.push(text);
}
destinationArray.push(newElm('span', '( ', fromHTML(splits_html.join(' | ')), ' )'));
}
}
function pushFacts(destinationArray : Node[], sourceObject : SupportedTTTypes, specializedContextInformation : API.ContextInformation, context : Context, weaponSet : number) {
if(specializedContextInformation.blocks) {
//NOTE(Rennorb): 690.5 is the midpoint weapon strength for slot skills (except bundles).
//TODO(Rennorb) @hardcoded @correctness: This value is hardcoded for usage with traits as they currently don't have any pointer that would provide weapon strength information.
// This will probably fail in some cases where damage facts on traits reference bundle skills (e.g. kits).
//TODO(Rennorb) @correctness: is this even correct for relics?
let weaponStrength = 690.5;
if('palettes' in sourceObject) for(const pid of sourceObject.palettes) {
const palette = APICache.storage.palettes.get(pid);
if(!palette) continue;
const criteria = context.character.profession
? ((s : API.SlotGroup) => s.profession === context.character.profession)
: ((s : API.SlotGroup) => s.profession);
if(palette.groups.some(criteria)) {
weaponStrength = getWeaponStrength(palette);
break;
}
}
destinationArray.push(...generateFacts(specializedContextInformation.blocks, weaponStrength, context, weaponSet))
}
}
export function resolveTraitsAndOverrides(apiObject : SupportedTTTypes & { blocks? : API.ContextGroup['blocks'], override_groups? : API.ContextInformation['override_groups'] }, context : Context) : API.ContextInformation {
let override = apiObject.override_groups?.find(g => g.context.includes(context.gameMode));
let result = Object.assign({}, apiObject, override);
result.blocks = structuredClone(apiObject.blocks); // always have to clone this because we later on manipulate the facts
if(!result.blocks) return result;
if(override?.blocks) {
const end = Math.max(result.blocks.length, override.blocks.length);
for(let blockId = 0; blockId < end; blockId++) {
let baseBlock = result.blocks[blockId];
const overrideBlock = override.blocks[blockId];
if (overrideBlock) {
//NOTE(Rennorb): Don't shortcut a lot of these, even if we only have an override block that may still use the insert logic.
//TODO(Rennorb) @cleanup: We probably want to add logic on the server to do that processing in that case.
if(!baseBlock) {
baseBlock = result.blocks[blockId] = {
description: overrideBlock.description,
trait_requirements: overrideBlock.trait_requirements,
};
}
if(!overrideBlock.facts) continue;
//NOTE(Rennorb): trait restrictions only exist on the (first) base block
//TODO(Rennorb): description and trait requirements cannot be overridden. so is this the wrong structure then?
const facts = result.blocks[blockId].facts = baseBlock.facts ?? []; // No need to clone here, we already structure cloned the whole thing.
for(const fact of overrideBlock.facts) {
if(fact.requires_trait?.some(t => !context.character.traits.has(t))) continue;
if(fact.insert_before !== undefined) {
//this marker is to later on disambiguate between trait and gamemode overrides
if(fact.skip_next) fact.__gamemode_override_marker = true;
facts.splice(fact.insert_before, 0, fact);
}
else facts.push(fact);
}
}
// else (baseBlock && !overrideBlock) -> we already have the base block in the array
}
}
const finalBlocks = [];
for(const block of result.blocks) {
if(block.trait_requirements?.some(t => !context.character.traits.has(t))) continue;
if(block.facts) {
const finalFacts = [];
let to_skip = 0;
for(let i = 0; i < block.facts.length; i++) {
const fact = block.facts[i];
if(fact.requires_trait?.some(t => !context.character.traits.has(t))) continue;
if(to_skip-- > 0) continue;
finalFacts.push(fact);
to_skip = fact.skip_next || 0;
}
block.facts = finalFacts;
}
finalBlocks.push(block);
}
result.blocks = finalBlocks;
return result;
}
function getWeaponStrength({ weapon_type, type : palette_type } : API.Palette) : number {
if(!weapon_type) {
if(palette_type === 'Bundle') {
return 922.5
}
//NOTE(Rennorb): The default value. Im not 100% sure if this is correct in all cases.
return 690.5
}
else {
return LUT_WEAPON_STRENGTH[weapon_type];
}
}
function generateToolTipList<T extends keyof SupportedTTTypeMap>(initialAPIObject : SupportedTTTypeMap[T], params : SkillParams | ItemParams, context : Context) : [HTMLElement[], number] {
let subiconRenderMode = IconRenderMode.SHOW;
//NOTE(Rennorb): This is a bit sad, but we have to hide or at least filter icons for skills attached to traits and relics, as those often don't come with actual icons because they never were meant to be seen (they don't show in game).
if(params.type === 'trait') subiconRenderMode = IconRenderMode.FILTER_DEV_ICONS;
else if((initialAPIObject as API.Item).type === 'Relic') subiconRenderMode = IconRenderMode.HIDE_ICON;
let initialActiveIndex = 0;
const tooltipChain : HTMLElement[] = []
const paletteSkills = [];
let palette, group, slot : string | undefined = undefined;
if(params.type === 'skill') {
//TODO(Rennorb): cleanup is this necessary? The root element already gets replaced automatically. It would be if we have skills where some skill in the chain needs to be replaced.
if(params.adjustTraitedSkillIds) {
const replacementSkill = findTraitedOverride(initialAPIObject as API.Skill, context);
if(replacementSkill) (initialAPIObject as API.Skill) = replacementSkill;
}
//find skillchain
let i;
[palette, group, i, context] = guessGroupAndContext(initialAPIObject as API.Skill, context);
if(group) {
slot = refineSlotName(palette!, group.slot);
let candidate = group.candidates[i];
//in case we catch a chain in the middle
const insertAtIndex = tooltipChain.length;
while(candidate.previous_chain_skill_index) {
const otherCandidate = group.candidates[candidate.previous_chain_skill_index];
if(!canBeSelected(otherCandidate, context)) break;
let skill = APICache.storage.skills.get(otherCandidate.skill);
if(!skill) {
console.warn(`[gw2-tooltips] Chain skill #${otherCandidate.skill} is missing from the cache. The query was caused by `, lastTooltipTarget);
skill = MISSING_SKILL;
}
tooltipChain.splice(insertAtIndex, 0, generateToolTip(skill, slot, IconRenderMode.SHOW, context, params.weaponSet));
candidate = otherCandidate;
}
}
//now ourself
tooltipChain.push(generateToolTip(initialAPIObject, slot, IconRenderMode.SHOW, context, params.weaponSet));
//remaining chain
for(let j = 0; j < i; j++) {
const otherCandidate = group!.candidates[j];
if(otherCandidate.previous_chain_skill_index != i) continue;
if(!canBeSelected(otherCandidate, context)) continue;
let skill = APICache.storage.skills.get(otherCandidate.skill);
if(!skill) {
console.warn(`[gw2-tooltips] Chain skill #${otherCandidate.skill} is missing from the cache. The query was caused by `, lastTooltipTarget);
skill = MISSING_SKILL;
}
paletteSkills.push(skill.id);
tooltipChain.push(generateToolTip(skill, slot, IconRenderMode.SHOW, context, params.weaponSet));
i = j;
j = -1;
}
}
else {
if(params.type === 'skin' || params.type === 'item') {
const skin = getActiveSkin(initialAPIObject as API.Items.Armor, params.element);
tooltipChain.push(generateItemTooltip(initialAPIObject as API.Item | API.Skin, context, params.weaponSet === undefined ? context.character.selectedWeaponSet : params.weaponSet, skin, params.statSetId, params.slottedItems, params.stackSize));
}
else {
let slotName = undefined;
if('slot' in initialAPIObject) {
slotName = initialAPIObject.slot
if('specialization' in initialAPIObject) (APICache.storage.specializations.get(initialAPIObject.specialization!)?.name || initialAPIObject.specialization!) + ' - ' + slotName;
}
tooltipChain.push(generateToolTip(initialAPIObject, slotName, IconRenderMode.SHOW, context, params.weaponSet));
}
}
if('bundle_skills' in initialAPIObject) {
for(const subSkillId of initialAPIObject.bundle_skills!) {
const subSkillInChain = APICache.storage.skills.get(subSkillId);
if(subSkillInChain && canBeUsedOnCurrentTerrain(subSkillInChain, context)) {
const [palette, group] = guessGroupAndContext(subSkillInChain, context); //@perf
tooltipChain.push(generateToolTip(subSkillInChain, refineSlotName(palette!, group?.slot), IconRenderMode.SHOW, context, params.weaponSet));
}
}
}
if('related_skills' in initialAPIObject) {
for(const subSkillId of initialAPIObject.related_skills!) {
// prevent duplicates in skillchain
//TODO(Rennorb): Should we just exclude palette sourced skills form the related ones?
if(paletteSkills.includes(subSkillId)) continue;
const subSkillInChain = APICache.storage.skills.get(subSkillId);
if(subSkillInChain && canBeUsedOnCurrentTerrain(subSkillInChain, context) && ((params.type != 'skill') || subSkillInChain.palettes.some(pid => {
const palette = APICache.storage.palettes.get(pid);
return palette && VALID_CHAIN_PALETTES.includes(palette.type);
}))) {
const [palette, group] = guessGroupAndContext(subSkillInChain, context); //@perf
tooltipChain.push(generateToolTip(subSkillInChain, refineSlotName(palette!, group?.slot), subiconRenderMode, context, params.weaponSet));
}
}
}
if('ambush_skills' in initialAPIObject) {
for(const { id: subSkillId, spec } of initialAPIObject.ambush_skills!) {
const subSkillInChain = APICache.storage.skills.get(subSkillId);
if(subSkillInChain && canBeUsedOnCurrentTerrain(subSkillInChain, context) && (!spec || context.character.specializations.has(spec))) {
if(!slot) {
[palette, group] = guessGroupAndContext(subSkillInChain, context);
slot = refineSlotName(palette!, group?.slot);
}
tooltipChain.push(generateToolTip(subSkillInChain, slot, subiconRenderMode, context, params.weaponSet));
break; // only one ambush skill
}
}
}
//pet skills
if('skills' in initialAPIObject) for(const petSkillId of initialAPIObject.skills) {
initialActiveIndex = 1;
let petSkill = APICache.storage.skills.get(petSkillId);
if(!petSkill) {
console.warn(`[gw2-tooltips] pet skill #${petSkillId} is missing from the cache. The query was caused by `, lastTooltipTarget);
petSkill = MISSING_SKILL;
}
const [palette, group] = guessGroupAndContext(petSkill, context);
tooltipChain.push(generateToolTip(petSkill, refineSlotName(palette!, group?.slot), subiconRenderMode, context, params.weaponSet));
}
if('skills_ai' in initialAPIObject) for(const petSkillId of initialAPIObject.skills_ai) {
let petSkill = APICache.storage.skills.get(petSkillId);
if(!petSkill) {
console.warn(`[gw2-tooltips] pet skill #${petSkillId} is missing from the cache. The query was caused by `, lastTooltipTarget);
petSkill = MISSING_SKILL;
}
const [palette, group] = guessGroupAndContext(petSkill, context);
let slotName = refineSlotName(palette!, group?.slot);
if(slotName) slotName = 'AI '+slotName;
tooltipChain.push(generateToolTip(petSkill, slotName, subiconRenderMode, context, params.weaponSet));
}
if(context.character.specializations.has(SPECIALIZATIONS.Soulbeast) && 'skills_soulbeast' in initialAPIObject) for(const petSkillId of initialAPIObject.skills_soulbeast) {
let petSkill = APICache.storage.skills.get(petSkillId);
if(!petSkill) {
console.warn(`[gw2-tooltips] pet skill #${petSkillId} is missing from the cache. The query was caused by `, lastTooltipTarget);
petSkill = MISSING_SKILL;
}
const [palette, group] = guessGroupAndContext(petSkill, context);
tooltipChain.push(generateToolTip(petSkill, refineSlotName(palette!, group?.slot), subiconRenderMode, context, params.weaponSet));
}
tooltip.append(...tooltipChain);
return [tooltipChain, initialActiveIndex]
}
function refineSlotName(palette : API.Palette, slot : string | undefined) : string | undefined {
if(!slot) return undefined;
if(palette.type == 'Bundle' && slot.includes('_')) {
return 'Bundle ' + slot.substring(slot.lastIndexOf('_') + 1);
}
if(slot.startsWith('Weapon') && palette.weapon_type) {
return localizeInternalName(palette.weapon_type) + ' ' + slot.substring(slot.lastIndexOf('_') + 1);
}
return slot.replace(/(\S+?)_(\d)/, "$1 $2");
}
function guessGroupAndContext(skill : API.Skill, context : Context) : [API.Palette, API.SlotGroup, number, Context] | [undefined, undefined, -1, Context] {
let fallback : [API.Palette, API.SlotGroup, number, Context] | undefined = undefined;
for(const pid of skill.palettes) {
const palette = APICache.storage.palettes.get(pid);
if(!palette) {
console.warn(`[gw2-tooltips] Palette #${pid} is missing from the cache. The query was caused by `, skill);
continue;
}
if(!VALID_CHAIN_PALETTES.includes(palette.type)) continue;
for(const group of palette.groups) {
if(context.character.profession && group.profession && group.profession != context.character.profession) continue;
for(const [i, candidate] of group.candidates.entries()) {
if(candidate.skill != skill.id) continue;
// track the first match as a fallback
if(!fallback) fallback = [palette, group, i, context];
if(canBeSelected(candidate, context)) return [palette, group, i, context];
}
}
}
if(fallback) {
fallback[3] = transmuteContext(fallback[1].candidates[fallback[2]], context);
return fallback;
}
// no profession check
for(const pid of skill.palettes) {
const palette = APICache.storage.palettes.get(pid);
if(!palette) continue;
if(!VALID_CHAIN_PALETTES.includes(palette.type)) continue;
for(const group of palette.groups) {
for(const [i, candidate] of group.candidates.entries()) {
if(candidate.skill == skill.id) return [palette, group, i, transmuteContext(candidate, context)];
}
}
}
// ultra slow path in case we look at npc stuff. no pallet type filters
for(const pid of skill.palettes) {
const palette = APICache.storage.palettes.get(pid);
if(!palette) continue;
for(const group of palette.groups) {
for(const [i, candidate] of group.candidates.entries()) {
if(candidate.skill == skill.id) return [palette, group, i, transmuteContext(candidate, context)];
}
}
}
return [undefined, undefined, -1, context];
}
function transmuteContext(targetCandidate : API.SkillInfo, context : Context, clone = true) : Context {
//cannot structured clone because of the custom elements
if(clone) {
const character = Object.assign({}, context.character, { specializations: structuredClone(context.character.specializations), traits: structuredClone(context.character.traits) });
context = Object.assign({}, context, { character });
}
if(targetCandidate.specialization) context.character.specializations.add(targetCandidate.specialization);
if(targetCandidate.trait) context.character.traits.add(targetCandidate.trait);
return context;
};
function canBeSelected(info : API.SkillInfo, context : Context) : boolean {
return (info.specialization === undefined || context.character.specializations.has(info.specialization)) &&
(info.trait === undefined || context.character.traits.has(info.trait)) &&
(context.underwater ? info.usability.includes('UsableUnderWater') : info.usability.includes('UsableLand')) &&
(context.character.level >= (info.min_level || 0))
}
function canBeUsedOnCurrentTerrain(skill : API.Skill, context : Context) : boolean {
return context.underwater ? skill.flags.includes('UsableUnderWater') : skill.flags.includes('UsableLand')
}
export function findTraitedOverride(skill : API.Skill, context : Context) : API.Skill | undefined {
for(const pid of skill.palettes) {
const palette = APICache.storage.palettes.get(pid);
if(!palette) {
console.warn(`[gw2-tooltips] Palette #${pid} is missing from the cache. The query was caused by `, skill);
continue;
}
for(const group of palette.groups) {
if(context.character.profession && group.profession && group.profession != context.character.profession) continue;
const end = group.candidates.findIndex(c => c.skill == skill.id);
if(end == -1) continue;
for(let i = 0; i < end; i++) {