-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscript.js
More file actions
4294 lines (3950 loc) · 193 KB
/
script.js
File metadata and controls
4294 lines (3950 loc) · 193 KB
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
// Storage key for localStorage
const STORAGE_KEY = 'levelingplan';
const SETTINGS_KEY = 'settings';
const CONFIG_KEY = 'config';
// HEROIC_QUESTS_BASE / EPIC_QUESTS_BASE are provided from external files (heroic.js / epic.js)
// and should be included before script.js in index.html.
if (typeof HEROIC_QUESTS_BASE === 'undefined') {
window.HEROIC_QUESTS_BASE = [];
console.error('HEROIC_QUESTS_BASE not found. Ensure heroic.js is included before script.js');
}
if (typeof EPIC_QUESTS_BASE === 'undefined') {
window.EPIC_QUESTS_BASE = [];
console.error('EPIC_QUESTS_BASE not found. Ensure epic.js is included before script.js');
}
if (typeof HEROIC_UKENBURGER_CONFIG === 'undefined') {
window.HEROIC_UKENBURGER_CONFIG = [];
console.warn('HEROIC_UKENBURGER_CONFIG not found. Ensure heroic_ukenburger_config.js is included before script.js');
}
if (typeof EPIC_UKENBURGER_CONFIG === 'undefined') {
window.EPIC_UKENBURGER_CONFIG = [];
console.warn('EPIC_UKENBURGER_CONFIG not found. Ensure epic_ukenburger_config.js is included before script.js');
}
// User-saved custom config slots — only written to when the user explicitly saves a Custom config.
window.HEROIC_CUSTOM_CONFIG = [];
window.EPIC_CUSTOM_CONFIG = [];
// Tracks which preset is currently applied to build HEROIC_QUESTS / EPIC_QUESTS.
window.ACTIVE_QUESTS_PRESET = 'ukenburger';
// Rebuild HEROIC_QUESTS from whichever config source is currently active.
function _rebuildHeroicQuests() {
const src = ACTIVE_QUESTS_PRESET === 'custom' ? HEROIC_CUSTOM_CONFIG : HEROIC_UKENBURGER_CONFIG;
const cfgMap = Object.fromEntries(src.map(c => [c.name, c]));
window.HEROIC_QUESTS = window.HEROIC_QUESTS_BASE.map(
base => Object.assign({}, base, cfgMap[base.name] || {})
);
}
// Rebuild EPIC_QUESTS from whichever config source is currently active.
function _rebuildEpicQuests() {
const src = ACTIVE_QUESTS_PRESET === 'custom' ? EPIC_CUSTOM_CONFIG : EPIC_UKENBURGER_CONFIG;
const cfgMap = Object.fromEntries(src.map(c => [c.name, c]));
window.EPIC_QUESTS = window.EPIC_QUESTS_BASE.map(
base => Object.assign({}, base, cfgMap[base.name] || {})
);
}
function _computeQuestXP(mode) {
const tomeBonus = getLearningTomeBonus(mode);
for (const q of (mode === 'heroic' ? window.HEROIC_QUESTS : window.EPIC_QUESTS)) {
if (q.baseXP != null) {
const xpmods = (q.xpmods != null && q.xpmods !== '') ? Number(q.xpmods) : 0;
const optXP = (q.optionalXP != null && q.optionalXP !== '') ? Number(q.optionalXP) : 0;
q.xp = Math.round(q.baseXP * (1 + xpmods + optXP + getQuickQuestVariableBonus(mode, q.difficulty, false, tomeBonus)));
}
// else: saga / custom-xp entry — .xp stays as defined in the base data
}
}
// Initial build (default: Ukenburger)
_rebuildHeroicQuests();
_rebuildEpicQuests();
// Cache for config textarea content per mode to preserve user edits when switching
const CONFIG_TEXTAREA_CACHE = {
heroic: null,
epic: null
};
// Current preset in the config dropdown: 'ukenburger' or 'custom'
let CONFIG_PRESET = 'ukenburger';
// Whether the textarea content has been modified since the last preset load/save
let CONFIG_DIRTY = false;
// Whether the dirty state was caused by an automatic preset jump (used to highlight the UI)
let CONFIG_DIRTY_HIGHLIGHT = false;
// Converts a config array to the tab-separated textarea string format.
function _configToTextareaLines(configArr) {
return configArr
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(q => {
const travel = q.travelTime != null ? q.travelTime : '';
const qtime = q.qTime != null ? q.qTime : '';
const bonus = (q.xpmods !== null && q.xpmods !== undefined && q.xpmods !== '')
? Math.round(Number(q.xpmods) * 100) : '';
const opt = (q.optionalXP !== null && q.optionalXP !== undefined && q.optionalXP !== '')
? Math.round(Number(q.optionalXP) * 100) : '';
return `${q.name}\t${travel}\t${qtime}\t${bonus}\t${opt}`;
})
.join('\n');
}
// Parses the tab-separated textarea text into a config array.
function _parseConfigLines(text) {
const lines = text.split('\n');
const result = [];
for (const line of lines) {
if (!line.trim()) continue;
const parts = line.split('\t');
const name = parts[0] ? parts[0].trim() : '';
if (!name) continue;
const entry = { name };
const travelRaw = parts[1] != null ? parts[1].trim() : '';
const qtimeRaw = parts[2] != null ? parts[2].trim() : '';
const bonusRaw = parts[3] != null ? parts[3].trim() : '';
const optRaw = parts[4] != null ? parts[4].trim() : '';
if (travelRaw !== '') { const v = parseFloat(travelRaw); if (isFinite(v)) entry.travelTime = v; }
if (qtimeRaw !== '') { const v = parseFloat(qtimeRaw); if (isFinite(v)) entry.qTime = v; }
if (bonusRaw !== '') { const v = parseFloat(bonusRaw); if (isFinite(v)) entry.xpmods = v / 100; }
if (optRaw !== '') { const v = parseFloat(optRaw); if (isFinite(v)) entry.optionalXP = v / 100; }
result.push(entry);
}
return result;
}
// Marks the config as dirty (user has unsaved edits) and switches dropdown to Custom+yellow.
function _markConfigDirty() {
CONFIG_DIRTY = true;
CONFIG_PRESET = 'custom';
const sel = document.getElementById('config-preset');
if (sel) {
const prev = sel.value;
sel.value = 'custom';
// Only show the yellow highlight when the preset was auto-switched
// from something else (e.g. 'ukenburger') to 'custom'. If the user
// was already editing a custom preset, don't highlight.
// Use existing highlight logic but preserve an already-set highlight.
CONFIG_DIRTY_HIGHLIGHT = CONFIG_DIRTY_HIGHLIGHT || (prev !== 'custom');
_updateConfigPresetVisual();
}
const applyBtn = document.getElementById('config-apply-btn');
if (applyBtn) {
applyBtn.disabled = false;
}
}
// Clears the dirty state and resets dropdown styling.
function _clearConfigDirty() {
CONFIG_DIRTY = false;
const sel = document.getElementById('config-preset');
if (sel) {
CONFIG_DIRTY_HIGHLIGHT = false;
_updateConfigPresetVisual();
}
const applyBtn = document.getElementById('config-apply-btn');
if (applyBtn) {
applyBtn.disabled = true;
}
}
// Update the visual styling for the config preset select and its custom option.
// - The `option[value="custom"]` is highlighted when `CONFIG_DIRTY` is true.
// - The select itself displays the highlight only when `custom` is selected
// and `CONFIG_DIRTY` is true. This ensures `Ukenburger` keeps its default
// appearance unless `custom` is both highlighted and selected.
function _updateConfigPresetVisual() {
const sel = document.getElementById('config-preset');
if (!sel) return;
const customOpt = sel.querySelector('option[value="custom"]');
const ukenOpt = sel.querySelector('option[value="ukenburger"]');
if (customOpt) {
if (CONFIG_DIRTY_HIGHLIGHT) {
customOpt.style.backgroundColor = '#e6c200';
customOpt.style.color = '#000';
} else {
customOpt.style.backgroundColor = '';
customOpt.style.color = '';
}
}
if (ukenOpt) {
if (CONFIG_DIRTY_HIGHLIGHT) {
// Force Ukenburger to remain visually normal while Custom is highlighted
ukenOpt.style.backgroundColor = '#fff';
ukenOpt.style.color = '#000';
} else {
ukenOpt.style.backgroundColor = '';
ukenOpt.style.color = '';
}
}
if (sel.value === 'custom' && CONFIG_DIRTY_HIGHLIGHT) {
sel.style.backgroundColor = '#e6c200';
sel.style.color = '#000';
} else {
sel.style.backgroundColor = '';
sel.style.color = '';
}
}
// Centralize logic for enabling/disabling the Apply button.
function _updateApplyButtonState() {
const applyBtn = document.getElementById('config-apply-btn');
if (!applyBtn) return;
// Apply should be enabled when there are unsaved edits, or when the
// selected preset differs from the currently active preset.
applyBtn.disabled = !(CONFIG_DIRTY || CONFIG_PRESET !== ACTIVE_QUESTS_PRESET);
}
// Ensure the select updates its visuals when the user changes it,
// and enable the Apply button when the preset selection represents
// a change that can be applied (dirty textarea or preset differs).
(function attachConfigPresetListener() {
function onChangeHandler(e) {
const sel = e.target || document.getElementById('config-preset');
if (!sel) return;
const newPreset = sel.value;
// If switching away from custom with unsaved changes, ask for confirmation
if (CONFIG_DIRTY && CONFIG_PRESET === 'custom' && newPreset !== 'custom') {
if (!confirm('Discard pending changes to Custom config?')) {
// Reset the dropdown to the previous preset
sel.value = CONFIG_PRESET;
return;
}
}
// Update in-memory preset to reflect the selection
CONFIG_PRESET = newPreset;
_updateConfigPresetVisual();
_updateApplyButtonState();
}
function attachTo(sel) {
// Listen to several events to cover browser differences
sel.addEventListener('change', onChangeHandler);
sel.addEventListener('input', onChangeHandler);
sel.addEventListener('click', onChangeHandler);
}
const sel = document.getElementById('config-preset');
if (sel) {
attachTo(sel);
_updateConfigPresetVisual();
_updateApplyButtonState();
} else {
document.addEventListener('DOMContentLoaded', () => {
const s = document.getElementById('config-preset');
if (s) {
attachTo(s);
_updateConfigPresetVisual();
_updateApplyButtonState();
}
});
}
})();
// Update visual states for the quest name column in the config overlay.
// Rules:
// - If the quest name does not appear anywhere in the textarea -> add `.missing` (red)
// - If the quest name appears somewhere but not on the same line index -> add `.mismatch` (orange)
// - If any quest name has errors, make the header red as well
function _updateConfigQuestNameHighlights(namesCol, textarea, headerEl) {
if (!namesCol || !textarea) return;
const nameEls = Array.from(namesCol.querySelectorAll('.config-quest-name'));
const lines = textarea.value.split('\n');
const normalize = s => (s || '').toString().trim().toLowerCase();
// Extract the quest name portion (first tab-separated column) for each line
const lineNames = lines.map(l => normalize((l || '').split('\t')[0] || ''));
const nameToIndexes = new Map();
lineNames.forEach((n, idx) => {
if (!n) return;
const arr = nameToIndexes.get(n) || [];
arr.push(idx);
nameToIndexes.set(n, arr);
});
nameEls.forEach((el, idx) => {
const questName = normalize(el.textContent || '');
el.classList.remove('missing', 'mismatch');
const found = nameToIndexes.get(questName) || [];
if (found.length === 0) {
el.classList.add('missing');
} else if (!found.includes(idx)) {
el.classList.add('mismatch');
}
});
// Check if any quest names have errors and update header accordingly
const hasErrors = nameEls.some(el => el.classList.contains('missing') || el.classList.contains('mismatch'));
if (headerEl) {
headerEl.classList.toggle('has-errors', hasErrors);
}
}
// Returns the active mode ('heroic' or 'epic') based on the toggle.
function getCurrentMode() {
return document.getElementById('mode-switch')?.checked ? 'epic' : 'heroic';
}
// Returns the quest source for the active mode.
function getActiveQuests() {
return getCurrentMode() === 'epic' ? EPIC_QUESTS : HEROIC_QUESTS;
}
const HEROIC_XP_THRESHOLDS = [
{ lvl: 1, xp: 0 },
{ lvl: 2, xp: 8000 },
{ lvl: 3, xp: 32000 },
{ lvl: 4, xp: 80000 },
{ lvl: 5, xp: 144000 },
{ lvl: 6, xp: 224000 },
{ lvl: 7, xp: 320000 },
{ lvl: 8, xp: 450000 },
{ lvl: 9, xp: 610000 },
{ lvl: 10, xp: 800000 },
{ lvl: 11, xp: 1020000 },
{ lvl: 12, xp: 1260000 },
{ lvl: 13, xp: 1520000 },
{ lvl: 14, xp: 1800000 },
{ lvl: 15, xp: 2100000 },
{ lvl: 16, xp: 2420000 },
{ lvl: 17, xp: 2750000 },
{ lvl: 18, xp: 3090000 },
{ lvl: 19, xp: 3440000 },
{ lvl: 20, xp: 3800000 }
];
const EPIC_XP_THRESHOLDS = [
{ lvl: 20, xp: 0 },
{ lvl: 21, xp: 600000 },
{ lvl: 22, xp: 1250000 },
{ lvl: 23, xp: 1950000 },
{ lvl: 24, xp: 2700000 },
{ lvl: 25, xp: 3500000 },
{ lvl: 26, xp: 4350000 },
{ lvl: 27, xp: 5250000 },
{ lvl: 28, xp: 6200000 },
{ lvl: 29, xp: 7200000 },
{ lvl: 30, xp: 8250000 }
];
function getActiveXpThresholds() {
return getCurrentMode() === 'epic' ? EPIC_XP_THRESHOLDS : HEROIC_XP_THRESHOLDS;
}
function isTwelveTokensActive() {
return getCurrentMode() === 'epic' && document.getElementById('twelve-tokens')?.checked === true;
}
// Look up the cumulative XP needed to reach a given character level in the
// currently-active mode. Returns undefined if the level is not in the table.
function getXpForLevel(lvl) {
const table = getActiveXpThresholds();
const entry = table.find(e => e.lvl === lvl);
return entry ? entry.xp : undefined;
}
function getPlayerLevelForXP(xp) {
const table = getActiveXpThresholds();
for (let i = table.length - 1; i >= 0; i--) {
if (xp >= table[i].xp) {
return table[i].lvl;
}
}
return table[0]?.lvl ?? 1;
}
// Data structure. The level plan is kept per-mode; `data.levelplan` always
// references the array for the currently-active mode (see setActiveMode()).
let data = {
levelplanByMode: { heroic: [], epic: [] },
levelplan: [],
quests: [],
special: []
};
// Switch the active mode: re-point data.levelplan at the per-mode array,
// rebuild the quests pool from the active quest source, recompute the
// xpMin table, and re-render. Does NOT save settings (caller handles that).
function setActiveMode(mode) {
if (mode !== 'heroic' && mode !== 'epic') mode = 'heroic';
data.levelplan = data.levelplanByMode[mode];
rebuildQuestsFromLevelplan();
computeXpMinTable();
}
// Initialize the app
document.addEventListener('DOMContentLoaded', () => {
initializeApp();
});
// Initialize the app with data from JSON
function initializeApp() {
// Always set up special palette (not persisted).
data.special = [
{ name: 'Take Level', xp: 0, level: '', source: 'special', isTakeLevel: true },
{ name: 'Custom XP', xp: 0, qTime: 0, travelTime: 0, source: 'special', isCustom: true },
{ name: 'XP Pot', xp: 0, source: 'special', isXpPot: true }
];
// loadSettings() must run before hydrating the level plan (and before setActiveMode())
loadSettings();
_computeQuestXP('heroic');
_computeQuestXP('epic');
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored);
const heroicStored = Array.isArray(parsed?.heroic) ? parsed.heroic : [];
const epicStored = Array.isArray(parsed?.epic) ? parsed.epic : [];
data.levelplanByMode.heroic = hydrateLevelplan(heroicStored, HEROIC_QUESTS, 'heroic');
data.levelplanByMode.epic = hydrateLevelplan(epicStored, EPIC_QUESTS, 'epic');
} catch (e) {
console.error('Error loading data from storage:', e);
data.levelplanByMode.heroic = [];
data.levelplanByMode.epic = [];
}
}
setActiveMode(getCurrentMode());
checkRequirements();
renderLists();
setupDragListeners();
// Measure permanent scrollbar width and expose as CSS variable so the
// levelplan list-header right margin can match the scrollbar gutter.
const lpList = document.getElementById('levelplan');
if (lpList) {
const sw = lpList.offsetWidth - lpList.clientWidth;
document.documentElement.style.setProperty('--lp-scrollbar-width', sw + 'px');
}
// levelplan list-header right margin can match the scrollbar gutter.
const questsList = document.getElementById('quests');
if (questsList) {
const sw = questsList.offsetWidth - questsList.clientWidth;
document.documentElement.style.setProperty('--quests-scrollbar-width', sw + 'px');
}
// Re-render lists when XP multiplier changes, and persist.
const multiplierInput = document.getElementById('xp-multiplier');
if (multiplierInput) {
multiplierInput.addEventListener('input', () => {
saveSettings();
renderLists();
});
}
// Populate and wire up the Tome of Learning dropdown.
// (loadSettings already restores the saved value; this is a safe fallback
// for first-run when there are no saved settings yet.)
populateLearningTomeSelect();
const learningTomeSel = document.getElementById('learning-tome');
if (learningTomeSel) {
learningTomeSel.addEventListener('change', () => {
saveSettings();
const mode = getCurrentMode();
_computeQuestXP(mode);
if (mode === 'epic') {
// Invalidate xpMin table cache since quest XP values changed
_xpMinTableCache.epic = null;
computeXpMinTable();
// Re-hydrate levelplan items so they pick up new XP values
const epicSerial = serialiseLevelplan(data.levelplanByMode.epic);
data.levelplanByMode.epic = hydrateLevelplan(epicSerial, EPIC_QUESTS, 'epic');
} else {
// Invalidate xpMin table cache since quest XP values changed
_xpMinTableCache.heroic = null;
computeXpMinTable();
// Re-hydrate levelplan items so they pick up new XP values
const heroicSerial = serialiseLevelplan(data.levelplanByMode.heroic);
data.levelplanByMode.heroic = hydrateLevelplan(heroicSerial, HEROIC_QUESTS, 'heroic');
}
data.levelplan = data.levelplanByMode[mode];
rebuildQuestsFromLevelplan();
renderLists();
});
}
// Compact spinner buttons for numeric controls.
document.querySelectorAll('.compact-spinner, .vertical-spinner').forEach(holder => {
holder.querySelectorAll('.spin-btn').forEach(btn => {
btn.addEventListener('click', () => {
const dir = parseInt(btn.dataset.dir, 10) || 0;
const input = holder.querySelector('.spin-input-compact');
if (!input) return;
const step = parseFloat(input.step) || 1;
// If the quests level filter is empty and we're in epic mode,
// jump to 20 on the first button press instead of starting at 0/1.
if ((input.value === '' || input.value == null || String(input.value).trim() === '')
&& input.id === 'quests-level-filter') {
if (getCurrentMode() === 'epic') {
input.value = dir === 1 ? 20 : 36;
} else {
input.value = dir === 1 ? 1 : 20;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
const cur = parseFloat(input.value) || 0;
const next = cur + dir * step;
input.value = Number.isInteger(step) ? Math.round(next) : next.toFixed((step + '').includes('.') ? (step + '').split('.')[1].length : 0);
input.dispatchEvent(new Event('input', { bubbles: true }));
});
});
});
// Patron view select: populate from HEROIC_QUESTS patron fields and persist selection
const patronSelect = document.getElementById('patron-view');
if (patronSelect) {
populatePatronViewSelect();
patronSelect.addEventListener('change', () => {
// Uncheck patron filter when switching back to "All Favors"
if (patronSelect.value === 'None') {
const cb = document.getElementById('quests-patron-filter');
if (cb) cb.checked = false;
} else {
// Switching to a specific patron — uncheck Twelve Tokens
const tokensCb = document.getElementById('twelve-tokens');
if (tokensCb) tokensCb.checked = false;
}
saveSettings();
renderLists();
});
}
const twelveTokensCb = document.getElementById('twelve-tokens');
if (twelveTokensCb) {
twelveTokensCb.addEventListener('change', () => {
if (twelveTokensCb.checked) {
// Reset Patron View to "None" when Twelve Tokens is enabled
const patronSel = document.getElementById('patron-view');
if (patronSel) {
patronSel.value = 'None';
}
} else {
// Manual uncheck: turn off the favor/patron filter checkbox
const patronCb = document.getElementById('quests-patron-filter');
if (patronCb) patronCb.checked = false;
}
saveSettings();
renderLists();
});
}
const xpminFilterInput = document.getElementById('quests-xpmin-filter');
if (xpminFilterInput) {
xpminFilterInput.addEventListener('input', () => {
renderList('quests');
});
}
const patronFilterCb = document.getElementById('quests-patron-filter');
if (patronFilterCb) {
patronFilterCb.addEventListener('change', () => { renderList('quests'); });
}
// VIP for Sagas checkbox — re-render both lists and persist
const vipSagasCb = document.getElementById('vip-sagas-header');
if (vipSagasCb) {
vipSagasCb.addEventListener('change', () => {
saveSettings();
renderLists();
});
}
// Default slayer bonus dropdown — re-render both lists and persist
const defaultSlayerBonusSel = document.getElementById('default-slayer-bonus');
if (defaultSlayerBonusSel) {
defaultSlayerBonusSel.addEventListener('change', () => {
saveSettings();
renderLists();
});
}
// Heroic / Epic mode switch (UI state only, persisted)
const modeSwitch = document.getElementById('mode-switch');
if (modeSwitch) {
const modeGroup = modeSwitch.closest('.mode-switch');
const applyModeClass = () => {
if (!modeGroup) return;
modeGroup.classList.toggle('is-epic', modeSwitch.checked);
modeGroup.classList.toggle('is-heroic', !modeSwitch.checked);
const tokensLabel = document.getElementById('twelve-tokens-label');
const tokensCb = document.getElementById('twelve-tokens');
const patronCb = document.getElementById('quests-patron-filter');
const patronSel = document.getElementById('patron-view');
const patronSelected = patronSel && patronSel.value && patronSel.value !== 'None';
const tokensWasChecked = tokensCb ? tokensCb.checked : false;
if (tokensLabel) tokensLabel.style.display = modeSwitch.checked ? '' : 'none';
if (!modeSwitch.checked) {
// switching to heroic: hide tokens and clear the tokens checkbox
if (tokensCb) tokensCb.checked = false;
// Only clear the favor/patron filter if Twelve Tokens was active
// (so the filter checkbox was being used for tokens) and no
// patron is selected — otherwise leave the patron filter alone.
if (tokensWasChecked && patronCb && !patronSelected) {
patronCb.checked = false;
}
}
};
applyModeClass();
modeSwitch.addEventListener('change', () => {
applyModeClass();
// Repopulate the learning tome dropdown for the new mode and
// restore the per-mode saved value from localStorage.
const rawSettings = localStorage.getItem(SETTINGS_KEY);
let savedTomeForMode = '0';
if (rawSettings) {
try {
const parsed = JSON.parse(rawSettings);
const newMode = document.getElementById('mode-switch')?.checked ? 'epic' : 'heroic';
savedTomeForMode = (parsed.learningTomeByMode && parsed.learningTomeByMode[newMode]) || '0';
} catch (e) { /* ignore */ }
}
populateLearningTomeSelect(savedTomeForMode);
saveSettings();
_computeQuestXP(document.getElementById('mode-switch')?.checked ? 'epic' : 'heroic');
setActiveMode(getCurrentMode());
// Clear quest filters when switching modes
const levelInput = document.getElementById('quests-level-filter');
if (levelInput) levelInput.value = '';
const nameInput = document.getElementById('quests-name-filter');
if (nameInput) nameInput.value = '';
const xpminInput = document.getElementById('quests-xpmin-filter');
if (xpminInput) xpminInput.value = '';
renderLists();
setupDragListeners();
});
}
const xpminClearBtn = document.getElementById('quests-xpmin-clear');
if (xpminClearBtn) {
xpminClearBtn.addEventListener('click', () => {
const input = document.getElementById('quests-xpmin-filter');
if (input) { input.value = ''; renderList('quests'); }
});
}
const levelFilterInput = document.getElementById('quests-level-filter');
if (levelFilterInput) {
levelFilterInput.addEventListener('input', () => { renderList('quests'); });
}
const levelClearBtn = document.getElementById('quests-level-clear');
if (levelClearBtn) {
levelClearBtn.addEventListener('click', () => {
const input = document.getElementById('quests-level-filter');
if (input) { input.value = ''; renderList('quests'); }
});
}
const nameFilterInput = document.getElementById('quests-name-filter');
if (nameFilterInput) {
nameFilterInput.addEventListener('input', () => { renderList('quests'); });
}
const nameClearBtn = document.getElementById('quests-name-clear');
if (nameClearBtn) {
nameClearBtn.addEventListener('click', () => {
const input = document.getElementById('quests-name-filter');
if (input) { input.value = ''; renderList('quests'); }
});
}
// Config mode switch (Heroic / Epic slider)
const configModeSwitch = document.getElementById('config-mode-switch');
if (configModeSwitch) {
configModeSwitch.addEventListener('change', () => {
// 'change' fires after the checkbox toggles; previous mode is the opposite of current
const newMode = _getConfigMode();
const previousMode = newMode === 'epic' ? 'heroic' : 'epic';
const currentTextarea = document.querySelector('.config-quest-textarea');
if (currentTextarea) {
CONFIG_TEXTAREA_CACHE[previousMode] = currentTextarea.value;
}
_syncConfigModeSwitch();
renderConfigList();
});
}
// Config preset dropdown (Ukenburger / Custom)
const configPresetSelect = document.getElementById('config-preset');
if (configPresetSelect) {
configPresetSelect.addEventListener('change', () => {
const newPreset = configPresetSelect.value;
if (newPreset === 'ukenburger') {
CONFIG_TEXTAREA_CACHE.heroic = _configToTextareaLines(HEROIC_UKENBURGER_CONFIG);
CONFIG_TEXTAREA_CACHE.epic = _configToTextareaLines(EPIC_UKENBURGER_CONFIG);
} else {
// If no custom config saved yet, fall back to ukenburger as a starting point
CONFIG_TEXTAREA_CACHE.heroic = _configToTextareaLines(
HEROIC_CUSTOM_CONFIG.length > 0 ? HEROIC_CUSTOM_CONFIG : HEROIC_UKENBURGER_CONFIG
);
CONFIG_TEXTAREA_CACHE.epic = _configToTextareaLines(
EPIC_CUSTOM_CONFIG.length > 0 ? EPIC_CUSTOM_CONFIG : EPIC_UKENBURGER_CONFIG
);
}
CONFIG_PRESET = newPreset;
_clearConfigDirty();
renderConfigList();
});
}
// Leveling Plan search box
initLevelplanSearch();
}
// Per-level xpMin thresholds computed by accumulating XP (best-first) up to 75%
// and 200% of the XP span for the pool range. Populated by computeXpMinTable().
window.XPMIN_THRESHOLDS_BY_LEVEL = {};
// Cache for the computed XP/min table, keyed by mode. Set to null to force recompute.
const _xpMinTableCache = { heroic: null, epic: null };
// Pool ranges and XP span for each quest level. Both the candidate quest pool
// and the xpNeeded (xp[poolMax] - xp[poolMin]) use the same range.
const HEROIC_POOL_RANGES = {
1: { poolMin: 1, poolMax: 3 },
2: { poolMin: 1, poolMax: 4 },
3: { poolMin: 1, poolMax: 5 },
4: { poolMin: 2, poolMax: 6 },
5: { poolMin: 3, poolMax: 7 },
6: { poolMin: 4, poolMax: 8 },
7: { poolMin: 5, poolMax: 9 },
8: { poolMin: 6, poolMax: 10 },
9: { poolMin: 7, poolMax: 11 },
10: { poolMin: 8, poolMax: 12 },
11: { poolMin: 9, poolMax: 13 },
12: { poolMin: 10, poolMax: 14 },
13: { poolMin: 11, poolMax: 15 },
14: { poolMin: 12, poolMax: 16 },
15: { poolMin: 13, poolMax: 17 },
16: { poolMin: 14, poolMax: 18 },
17: { poolMin: 15, poolMax: 19 },
18: { poolMin: 16, poolMax: 20 },
19: { poolMin: 16, poolMax: 20 },
20: { poolMin: 16, poolMax: 20 },
};
// Pool ranges and XP span for each quest level. Both the candidate quest pool
// and the xpNeeded (xp[poolMax] - xp[poolMin]) use the same range.
const EPIC_POOL_RANGES = {
20: { poolMin: 20, poolMax: 29 },
21: { poolMin: 20, poolMax: 29 },
22: { poolMin: 20, poolMax: 29 },
23: { poolMin: 20, poolMax: 29 },
24: { poolMin: 20, poolMax: 29 },
25: { poolMin: 21, poolMax: 29 },
26: { poolMin: 22, poolMax: 36 },
27: { poolMin: 23, poolMax: 36 },
28: { poolMin: 24, poolMax: 36 },
29: { poolMin: 25, poolMax: 36 },
30: { poolMin: 25, poolMax: 36 },
31: { poolMin: 25, poolMax: 36 },
32: { poolMin: 25, poolMax: 36 },
33: { poolMin: 25, poolMax: 36 },
34: { poolMin: 25, poolMax: 36 },
35: { poolMin: 25, poolMax: 36 },
36: { poolMin: 25, poolMax: 36 }
};
// Compute a table of aggregate xpMin per level at two XP-accumulation thresholds.
// Pool range and xpNeeded both come from HEROIC_POOL_RANGES.
// Quests are ranked by individual xpMin (best first) and XP is accumulated until
// reaching 150% (xpminThresholdGood) and 300% (xpminThresholdDecent) of xpNeeded.
// The aggregate xpMin at each crossing = sum(xp) / sum(effectiveTime).
function computeXpMinTable() {
const mode = getCurrentMode();
if (_xpMinTableCache[mode] !== null) {
window.XPMIN_THRESHOLDS_BY_LEVEL = _xpMinTableCache[mode];
return;
}
const activeQuests = getActiveQuests();
const poolRanges = mode === 'epic' ? EPIC_POOL_RANGES : HEROIC_POOL_RANGES;
const levels = [...new Set(activeQuests.map(q => q.lvl))].filter(l => l != null && l > 0).sort((a, b) => a - b);
window.XPMIN_THRESHOLDS_BY_LEVEL = {};
const rows = [];
for (const level of levels) {
const range = poolRanges[level];
if (!range) continue;
const { poolMin, poolMax } = range;
// XP span: xp at poolMax level minus xp at poolMin level, looked up
// in the active mode's XP threshold table. Clamp pool bounds to the
// table's range so pool ranges that extend past the available levels
// (e.g. epic ranges go to lvl 36 but EPIC_XP_THRESHOLDS stops at 30)
// still produce a usable XP span.
const xpTable = getActiveXpThresholds();
const tableMin = xpTable[0].lvl;
const tableMax = xpTable[xpTable.length - 1].lvl;
const clampedMin = Math.max(poolMin, tableMin);
const clampedMax = Math.min(poolMax, tableMax);
const xpLowVal = getXpForLevel(clampedMin);
const xpHighVal = getXpForLevel(clampedMax);
if (xpLowVal === undefined || xpHighVal === undefined) continue;
const xpNeeded = xpHighVal - xpLowVal;
if (xpNeeded <= 0) continue;
// Candidate pool: valid quests within the pool range
const candidates = activeQuests.filter(q => {
if (q.lvl === undefined || q.lvl === null) return false;
if (q.lvl < poolMin || q.lvl > poolMax) return false;
if (!q.xp || q.xp <= 0) return false;
const effectiveTime = (q.qTime || 0) + (q.travelTime || 0);
return effectiveTime > 0;
}).map(q => {
const effectiveTime = (q.qTime || 0) + (q.travelTime || 0);
return { name: q.name, xp: q.xp, effectiveTime, xpMin: q.xp / effectiveTime };
});
if (candidates.length === 0) continue;
// Sort by individual xpMin descending (best quests first)
candidates.sort((a, b) => b.xpMin - a.xpMin);
// Walk from best to worst, recording the aggregate xpMin when cumulative
// XP first crosses 150% and 300% of xpNeeded.
const xpThresholds = mode === 'epic' ? [0.75 * xpNeeded, 1.5 * xpNeeded] : [1.5 * xpNeeded, 3.0 * xpNeeded];
const results = {};
let cumXP = 0, cumTime = 0, ti = 0;
for (const q of candidates) {
cumXP += q.xp;
cumTime += q.effectiveTime;
while (ti < xpThresholds.length && cumXP >= xpThresholds[ti]) {
results[ti] = cumTime > 0 ? Math.round(cumXP / cumTime) : 0;
ti++;
}
if (ti >= xpThresholds.length) break;
}
// Fill any thresholds not reached (pool covers less XP than needed)
while (ti < xpThresholds.length) {
results[ti] = cumTime > 0 ? Math.round(cumXP / cumTime) : 0;
ti++;
}
rows.push({
level,
poolMin,
poolMax,
xpNeeded,
questsInPool: candidates.length,
xpminThresholdGood: results[0],
xpminThresholdDecent: results[1]
});
window.XPMIN_THRESHOLDS_BY_LEVEL[level] = {
xpminThresholdGood: results[0],
xpminThresholdDecent: results[1]
};
}
_xpMinTableCache[mode] = window.XPMIN_THRESHOLDS_BY_LEVEL;
console.log('=== XP/min table by level ===');
console.table(rows);
}
// Reset the level plan for the currently active mode (the other mode is left intact).
function loadInitialData() {
const mode = getCurrentMode();
data.levelplanByMode[mode] = [];
data.levelplan = data.levelplanByMode[mode];
rebuildQuestsFromLevelplan();
data.special = [
{ name: 'Take Level', xp: 0, level: '', source: 'special', isTakeLevel: true },
{ name: 'Custom XP', xp: 0, qTime: 0, travelTime: 0, source: 'special', isCustom: true },
{ name: 'XP Pot', xp: 0, source: 'special', isXpPot: true }
];
saveToStorage();
}
// Rebuild data.quests from the active quest source, excluding any quest currently in the levelplan.
// Elite copies do NOT remove the original from quests (they are independent copies).
function rebuildQuestsFromLevelplan() {
const lpNames = new Set(
data.levelplan
.filter(i => !i.isTakeLevel && !i.isCustom && !i.isXpPot && !i.isXpPotStart && !i.isXpPotEnd && !i.isEliteCopy && i.name !== undefined)
.map(i => i.name)
);
data.quests = getActiveQuests()
.map((q, i) => ({ ...q, id: i, source: 'quests' }))
.filter(q => !lpNames.has(q.name));
}
// Hydrate a stored (minimal) levelplan array into full item objects, using the
// supplied quest source as the source of truth for all derivable fields.
function hydrateLevelplan(stored, questSource, mode) {
const source = questSource || getActiveQuests();
const initialByName = new Map(source.map(q => [q.name, q]));
return stored.map(entry => {
if (entry.takeLevel) {
return { name: 'Take Level', xp: 0, level: '', source: 'special', isTakeLevel: true };
}
if (entry.xpPotStart) {
return { name: 'Start XP Pot', source: 'special', isXpPotStart: true, ...(entry.pct != null ? { pct: entry.pct } : {}) };
}
if (entry.xpPotEnd) {
return { name: 'End XP Pot', source: 'special', isXpPotEnd: true, ...(entry.pct != null ? { pct: entry.pct } : {}) };
}
if (entry.xpPot) {
return { name: 'XP Pot', xp: 0, source: 'special', isXpPot: true };
}
if (entry.custom) {
return {
name: entry.name || 'Custom XP',
xp: entry.xp || 0,
qTime: entry.qTime || 0,
travelTime: 0,
source: 'special',
isCustom: true
};
}
const base = initialByName.get(entry.name);
if (!base) return null; // unknown quest — drop silently
if (entry.elite) {
const eliteXP = base.baseXP * ( 1 + base.optionalXP + base.xpmods + getQuestVariableBonus(mode, 'E', true));
return { ...base, name: base.name + ' (repeat)', xp: eliteXP, travelTime: 0.0, isEliteCopy: true, difficulty: 'E', source: 'quests', patron: null, favor: null };
}
const id = source.indexOf(base);
const result = { ...base, id, source: 'quests' };
// Restore slayer bonus if present
if (entry.slayerBonus) {
result.slayerBonus = entry.slayerBonus;
}
return result;
}).filter(Boolean);
}
// Check that every requirement name resolves to a known quest name within the
// same mode. Cross-mode requirements (an epic quest depending on a heroic
// quest or vice-versa) are considered broken and will be reported.
function checkRequirements() {
const heroicNames = new Set(HEROIC_QUESTS_BASE.map(q => q.name));
const epicNames = new Set(EPIC_QUESTS_BASE.map(q => q.name));
const broken = []; // { quest, reqName, mode, foundIn }
// Validate heroic quests reference only heroic requirements
for (const quest of HEROIC_QUESTS_BASE) {
if (!Array.isArray(quest.requirements)) continue;
for (const reqName of quest.requirements) {
if (!heroicNames.has(reqName)) {
const foundIn = epicNames.has(reqName) ? 'epic' : null;
broken.push({ quest: quest.name, reqName, mode: 'heroic', foundIn });
}
}
}
// Validate epic quests reference only epic requirements
for (const quest of EPIC_QUESTS_BASE) {
if (!Array.isArray(quest.requirements)) continue;
for (const reqName of quest.requirements) {
if (!epicNames.has(reqName)) {
const foundIn = heroicNames.has(reqName) ? 'heroic' : null;
broken.push({ quest: quest.name, reqName, mode: 'epic', foundIn });
}
}
}
if (broken.length === 0) return;
// Build a visible banner
const banner = document.createElement('div');
banner.style.cssText = [
'position:fixed', 'top:0', 'left:0', 'right:0', 'z-index:99999',
'background:#b00020', 'color:#fff', 'font-family:monospace',
'font-size:13px', 'padding:12px 16px', 'box-shadow:0 4px 12px rgba(0,0,0,.5)',
'white-space:pre-wrap', 'max-height:40vh', 'overflow-y:auto'
].join(';');
const lines = [
`⚠ BROKEN PREREQS DETECTED (${broken.length}) — fix quest files`,
''
];
for (const b of broken) {
let note = ` "${b.quest}"\n → unknown prereq: "${b.reqName}"`;
if (b.mode === 'epic') {
note += b.foundIn === 'heroic'
? ' (found in heroic.js; epic quests must only require epic quests)'
: ' (not found in epic.js)';
} else {
note += b.foundIn === 'epic'
? ' (found in epic.js; heroic quests must only require heroic quests)'
: ' (not found in heroic.js)';
}
lines.push(note);
}
banner.textContent = lines.join('\n');
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕ Dismiss';
closeBtn.style.cssText = 'float:right;background:#fff;color:#b00020;border:none;padding:4px 10px;cursor:pointer;font-weight:bold;margin-left:16px;';
closeBtn.onclick = () => banner.remove();
banner.prepend(closeBtn);
document.body.prepend(banner);
console.error('Broken prereqs:', broken);
}
// The Tome of Learning options per mode.
const LEARNING_TOME_OPTIONS = {
heroic: [
{ label: 'No Heroic Tome of Learning', bonus: 0 , repeatBonus: 0 },
{ label: 'Heroic Lesser Tome 25%', bonus: 0.25, repeatBonus: 0.1 },
{ label: 'Heroic Greater Tome 50%', bonus: 0.50, repeatBonus: 0.2 }
],
epic: [
{ label: 'No Epic Tome of Learning', bonus: 0, repeatBonus: 0 },
{ label: 'Epic Lesser Tome 15%', bonus: 0.15, repeatBonus: 0.05 },
{ label: 'Epic Greater Tome 25%', bonus: 0.25, repeatBonus: 0.1 }
]
};