forked from quisquous/cactbot
-
Notifications
You must be signed in to change notification settings - Fork 0
/
popup-text.ts
1522 lines (1348 loc) · 53.8 KB
/
popup-text.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
import { Lang } from '../../resources/languages';
import NetRegexes from '../../resources/netregexes';
import { UnreachableCode } from '../../resources/not_reached';
import { callOverlayHandler, addOverlayListener } from '../../resources/overlay_plugin_api';
import PartyTracker from '../../resources/party';
import { addPlayerChangedOverrideListener, PlayerChangedDetail } from '../../resources/player_override';
import Regexes from '../../resources/regexes';
import Util from '../../resources/util';
import ZoneId from '../../resources/zone_id';
import { RaidbossData } from '../../types/data';
import { EventResponses, LogEvent } from '../../types/event';
import { Job, Role } from '../../types/job';
import { Matches } from '../../types/net_matches';
import {
LooseTrigger, OutputStrings, TimelineField, TimelineFunc, LooseTriggerSet,
ResponseField, TriggerAutoConfig, TriggerField, TriggerOutput,
Output, RaidbossFileData, ResponseOutput, PartialTriggerOutput, DataInitializeFunc,
GeneralNetRegexTrigger, RegexTrigger,
} from '../../types/trigger';
import AutoplayHelper from './autoplay_helper';
import BrowserTTSEngine from './browser_tts_engine';
import { PerTriggerAutoConfig, PerTriggerOption, RaidbossOptions } from './raidboss_options';
import { TimelineLoader } from './timeline';
import { TimelineReplacement } from './timeline_parser';
const isRaidbossLooseTimelineTrigger =
(trigger: LooseTrigger): trigger is ProcessedTimelineTrigger => {
return 'isTimelineTrigger' in trigger;
};
export const isNetRegexTrigger = (trigger?: LooseTrigger):
trigger is Partial<GeneralNetRegexTrigger<RaidbossData, 'None'>> => {
if (trigger && !isRaidbossLooseTimelineTrigger(trigger))
return 'netRegex' in trigger;
return false;
};
export const isRegexTrigger = (trigger?: LooseTrigger):
trigger is Partial<RegexTrigger<RaidbossData>> => {
if (trigger && !isRaidbossLooseTimelineTrigger(trigger))
return 'regex' in trigger;
return false;
};
export type ProcessedTrigger = LooseTrigger & {
filename?: string;
localRegex?: RegExp;
localNetRegex?: RegExp;
output?: Output;
};
type ProcessedTimelineTrigger = ProcessedTrigger & {
isTimelineTrigger?: true;
};
type ProcessedTriggerSet = LooseTriggerSet & {
filename?: string;
timelineTriggers?: ProcessedTimelineTrigger[];
triggers?: ProcessedTrigger[];
};
// There should be (at most) six lines of instructions.
const raidbossInstructions: { [lang in Lang]: string[] } = {
en: [
'Instructions as follows:',
'This is debug text for resizing.',
'It goes away when you lock the overlay',
'along with the blue background.',
'Timelines and triggers will show up in supported zones.',
'Test raidboss with a /countdown in Summerford Farms.',
],
de: [
'Anweisungen wie folgt:',
'Dies ist ein Debug-Text zur Größenänderung.',
'Er verschwindet, wenn du das Overlay sperrst,',
'zusammen mit dem blauen Hintergrund.',
'Timeline und Trigger werden in den unterstützten Zonen angezeigt.',
'Testen Sie Raidboss mit einem /countdown in Sommerfurt-Höfe.',
],
fr: [
'Instructions :',
'Ceci est un texte de test pour redimensionner.',
'Il disparaitra \(ainsi que le fond bleu\) quand',
'l\'overlay sera bloqué.',
'Les timelines et triggers seront affichés dans les zones supportées.',
'Testez raidboss avec un /countdown aux Vergers d\'Estival',
],
ja: [
'操作手順:',
'デバッグ用のテキストです。',
'青色のオーバーレイを',
'ロックすれば消える。',
'サポートするゾーンにタイムラインとトリガーテキストが表示できる。',
'サマーフォード庄に/countdownコマンドを実行し、raidbossをテストできる。',
],
cn: [
'请按以下步骤操作:',
'这是供用户调整悬浮窗大小的调试用文本',
'当你锁定此蓝色背景的悬浮窗',
'该文本即会消失。',
'在支持的区域中会自动加载时间轴和触发器。',
'可在盛夏农庄使用/countdown命令测试该raidboss模块。',
],
ko: [
'<조작 설명>',
'크기 조정을 위한 디버그 창입니다',
'파란 배경과 이 텍스트는',
'오버레이를 위치잠금하면 사라집니다',
'지원되는 구역에서 타임라인과 트리거가 표시됩니다',
'여름여울 농장에서 초읽기를 실행하여 테스트 해볼 수 있습니다',
],
};
// Because apparently people don't understand uppercase greek letters,
// add a special case to not uppercase them.
const triggerUpperCase = (str: string): string => {
return str.replace(/[^αβγδ]/g, (x) => x.toUpperCase());
};
// Disable no-explicit-any due to catch clauses requiring any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onTriggerException = (trigger: ProcessedTrigger, e: any) => {
// When a fight ends and there are open promises, from delaySeconds or promise itself,
// all promises will be rejected. In this case there is no error; simply return without logging.
if (!e)
return;
let str = 'Error in trigger: ' + (trigger.id ? trigger.id : '[unknown trigger id]');
if (trigger.filename)
str += ' (' + trigger.filename + ')';
console.error(str);
if (e instanceof Error) {
const lines = e.stack?.split('\n') ?? [];
for (let i = 0; i < lines.length; ++i)
console.error(lines[i]);
}
};
const sounds = ['Alarm', 'Alert', 'Info', 'Long', 'Pull'] as const;
const soundStrs: readonly string[] = sounds;
type Sound = typeof sounds[number];
type SoundType = `${Sound}Sound`;
type SoundTypeVolume = `${SoundType}Volume`;
const texts = ['info', 'alert', 'alarm'] as const;
export type Text = typeof texts[number];
type TextUpper = `${Capitalize<Text>}`;
export type TextText = `${Text}Text`;
type TextUpperText = `${TextUpper}Text`;
type TextMap = {
[text in Text]: {
text: TextText;
upperText: TextUpperText;
upperSound: SoundType;
upperSoundVolume: SoundTypeVolume;
rumbleDuration: `${TextUpper}RumbleDuration`;
rumbleWeak: `${TextUpper}RumbleWeak`;
rumbleStrong: `${TextUpper}RumbleStrong`;
}
};
const textMap: TextMap = {
info: {
text: 'infoText',
upperText: 'InfoText',
upperSound: 'InfoSound',
upperSoundVolume: 'InfoSoundVolume',
rumbleDuration: 'InfoRumbleDuration',
rumbleWeak: 'InfoRumbleWeak',
rumbleStrong: 'InfoRumbleStrong',
},
alert: {
text: 'alertText',
upperText: 'AlertText',
upperSound: 'AlertSound',
upperSoundVolume: 'AlertSoundVolume',
rumbleDuration: 'AlertRumbleDuration',
rumbleWeak: 'AlertRumbleWeak',
rumbleStrong: 'AlertRumbleStrong',
},
alarm: {
text: 'alarmText',
upperText: 'AlarmText',
upperSound: 'AlarmSound',
upperSoundVolume: 'AlarmSoundVolume',
rumbleDuration: 'AlarmRumbleDuration',
rumbleWeak: 'AlarmRumbleWeak',
rumbleStrong: 'AlarmRumbleStrong',
},
};
// Helper for handling trigger overrides.
//
// asList will return a list of triggers in the same order as append was called, except:
// If a later trigger has the same id as a previous trigger, it will replace the previous trigger
// and appear in the same order that the previous trigger appeared.
// e.g. a, b1, c, b2 (where b1 and b2 share the same id) yields [a, b2, c] as the final list.
//
// JavaScript dictionaries are *almost* ordered automatically as we would want,
// but want to handle missing ids and integer ids (you shouldn't, but just in case).
class OrderedTriggerList {
triggers: ProcessedTrigger[] = [];
idToIndex: { [id: string]: number } = {};
push(trigger: ProcessedTrigger) {
const idx = trigger.id !== undefined ? this.idToIndex[trigger.id] : undefined;
if (idx !== undefined && trigger.id !== undefined) {
const oldTrigger = this.triggers[idx];
if (oldTrigger === undefined)
throw new UnreachableCode();
// TODO: be verbose now while this is fresh, but hide this output behind debug flags later.
const triggerFile =
(trigger: ProcessedTrigger) => trigger.filename ? `'${trigger.filename}'` : 'user override';
const oldFile = triggerFile(oldTrigger);
const newFile = triggerFile(trigger);
console.log(`Overriding '${trigger.id}' from ${oldFile} with ${newFile}.`);
this.triggers[idx] = trigger;
return;
}
// Normal case of a new trigger, with no overriding.
if (trigger.id !== undefined)
this.idToIndex[trigger.id] = this.triggers.length;
this.triggers.push(trigger);
}
asList() {
return this.triggers;
}
}
const isObject = (x: unknown): x is { [key: string]: unknown } => x instanceof Object;
// User trigger may pass anything as parameters
type TriggerParams = { [key: string]: unknown };
class TriggerOutputProxy {
public outputStrings: OutputStrings;
public overrideStrings: OutputStrings = {};
public responseOutputStrings: { [outputName: string]: unknown } = {};
public unknownValue = '???';
private constructor(
public trigger: ProcessedTrigger,
public displayLang: Lang,
public perTriggerAutoConfig?: PerTriggerAutoConfig) {
this.outputStrings = trigger.outputStrings ?? {};
if (trigger.id && perTriggerAutoConfig) {
const config = perTriggerAutoConfig[trigger.id];
if (config && config.OutputStrings)
this.overrideStrings = config.OutputStrings;
}
return new Proxy(this, {
// Response output string subtlety:
// Take this example response:
//
// response: (data, matches, output) => {
// return {
// alarmText: output.someAlarm(),
// outputStrings: { someAlarm: 'string' }, // <- impossible
// };
// },
//
// Because the object being returned is evaluated all at once, the object
// cannot simultaneously define outputStrings and use those outputStrings.
// So, instead, responses need to set `output.responseOutputStrings`.
// HOWEVER, this also has its own issues! This value is set for the trigger
// (which may have multiple active in flight instances). This *should* be
// ok because we guarantee that response/alarmText/alertText/infoText/tts
// are evaluated sequentially for a single trigger before any other trigger
// instance evaluates that set of triggers. Finally, for ease of automating
// the config ui, the response should return the exact same set of
// outputStrings every time. Thank you for coming to my TED talk.
set(target, property, value): boolean {
if (property === 'responseOutputStrings') {
if (isObject(value)) {
target[property] = value;
return true;
}
console.error(`Invalid responseOutputStrings on trigger ${target.trigger.id ?? 'Unknown'}`);
return false;
}
// Be kind to user triggers that do weird things, and just console error this
// instead of throwing an exception.
console.error(`Invalid property '${String(property)}' on output.`);
return false;
},
get(target, name) {
// TODO: add a test that verifies nobody does this.
if (name === 'toJSON' || typeof name !== 'string')
return '{}';
// Because output.func() must exist at the time of trigger eval,
// always provide a function even before we know which keys are valid.
return (params: TriggerParams) => {
const id = target.trigger.id ?? 'Unknown Trigger';
// Priority: per-trigger config from ui > response > built-in trigger
// Ideally, response provides everything and trigger provides nothing,
// or there's no response and trigger provides everything. Having
// this well-defined smooths out the collision edge cases.
let str = target.getReplacement(target.overrideStrings[name], params, name, id);
if (str === undefined) {
const responseString = target.responseOutputStrings[name];
if (isObject(responseString))
str = target.getReplacement(responseString, params, name, id);
}
if (str === undefined)
str = target.getReplacement(target.outputStrings[name], params, name, id);
if (str === undefined) {
console.error(`Trigger ${target.trigger.id ?? ''} has missing outputString ${name}.`);
return target.unknownValue;
}
return str;
};
},
});
}
getReplacement(
// Can't use optional modifier for this arg since the others aren't optional
template: { [lang: string]: unknown } | string | undefined,
params: TriggerParams,
name: string,
id: string): string | undefined {
if (!template)
return;
let value: unknown;
if (typeof template === 'string')
// user config
value = template;
else
value = template[this.displayLang] ?? template['en'];
if (typeof value !== 'string') {
console.error(`Trigger ${id} has invalid outputString ${name}.`, JSON.stringify(template));
return;
}
return value.replace(/\${\s*([^}\s]+)\s*}/g, (_fullMatch: string, key: string) => {
if (params && key in params) {
const str = params[key];
switch (typeof str) {
case 'string':
return str;
case 'number':
return str.toString();
}
console.error(`Trigger ${id} has non-string param value ${key}.`);
return this.unknownValue;
}
console.error(`Trigger ${id} can't replace ${key} in ${JSON.stringify(template)}.`);
return this.unknownValue;
});
}
static makeOutput(
trigger: ProcessedTrigger,
displayLang: Lang,
perTriggerAutoConfig?: PerTriggerAutoConfig): Output {
// `Output` is the common type used for the trigger data interface to support arbitrary
// string keys and always returns a string. However, TypeScript doesn't have good support
// for the Proxy representing this structure so we need to cast Proxy => unknown => Output
return new TriggerOutputProxy(trigger, displayLang,
perTriggerAutoConfig) as unknown as Output;
}
}
export type RaidbossTriggerField =
TriggerField<RaidbossData, Matches, TriggerOutput<RaidbossData, Matches>> |
TriggerField<RaidbossData, Matches, PartialTriggerOutput<RaidbossData, Matches>>;
export type RaidbossTriggerOutput = TriggerOutput<RaidbossData, Matches> |
PartialTriggerOutput<RaidbossData, Matches>;
const defaultOutput = TriggerOutputProxy.makeOutput({}, 'en');
export interface TriggerHelper {
valueOrFunction: (f: RaidbossTriggerField) => RaidbossTriggerOutput;
trigger: ProcessedTrigger;
now: number;
triggerOptions: PerTriggerOption;
triggerAutoConfig: TriggerAutoConfig;
// This setting only suppresses output, trigger still runs for data/logic purposes
userSuppressedOutput: boolean;
matches: Matches;
response?: ResponseOutput<RaidbossData, Matches>;
// Default options
soundUrl?: string;
soundVol?: number;
triggerSoundVol?: number;
defaultTTSText?: string;
textAlertsEnabled: boolean;
soundAlertsEnabled: boolean;
spokenAlertsEnabled: boolean;
groupSpokenAlertsEnabled: boolean;
duration?: {
fromConfig?: number;
fromTrigger?: number;
alarmText: number;
alertText: number;
infoText: number;
};
ttsText?: string;
rumbleDurationMs?: number;
rumbleWeak?: number;
rumbleStrong?: number;
output: Output;
}
const wipeCactbotEcho = NetRegexes.echo({ line: 'cactbot wipe.*?' });
const wipeEndEcho = NetRegexes.echo({ line: 'end' });
const wipeFadeIn = NetRegexes.network6d({ command: '40000010' });
const isWipe = (line: string): boolean => {
if (
wipeCactbotEcho.test(line) ||
wipeEndEcho.test(line) ||
wipeFadeIn.test(line)
)
return true;
return false;
};
export class PopupText {
protected triggers: ProcessedTrigger[] = [];
protected netTriggers: ProcessedTrigger[] = [];
protected timers: { [triggerId: number]: boolean } = {};
protected triggerSuppress: { [triggerId: string]: number } = {};
protected currentTriggerID = 0;
protected inCombat = false;
protected resetWhenOutOfCombat = true;
// These are deliberately `| null` for raidemulator extendability reasons
protected infoText: HTMLElement | null;
protected alertText: HTMLElement | null;
protected alarmText: HTMLElement | null;
protected parserLang: Lang;
protected displayLang: Lang;
protected ttsEngine?: BrowserTTSEngine;
protected ttsSay: (text: string) => void;
protected partyTracker = new PartyTracker();
protected readonly kMaxRowsOfText = 2;
protected data: RaidbossData;
protected me = '';
protected job: Job = 'NONE';
protected role: Role = 'none';
protected triggerSets: ProcessedTriggerSet[] = [];
protected zoneName = '';
protected zoneId = -1;
protected dataInitializers: {
file: string;
func: DataInitializeFunc<RaidbossData>;
}[] = [];
constructor(
protected options: RaidbossOptions,
protected timelineLoader: TimelineLoader,
protected raidbossDataFiles: RaidbossFileData) {
this.options = options;
this.timelineLoader = timelineLoader;
this.ProcessDataFiles(raidbossDataFiles);
this.infoText = document.getElementById('popup-text-info');
this.alertText = document.getElementById('popup-text-alert');
this.alarmText = document.getElementById('popup-text-alarm');
this.parserLang = this.options.ParserLanguage ?? 'en';
this.displayLang = this.options.AlertsLanguage ?? this.options.DisplayLanguage ?? this.options.ParserLanguage ?? 'en';
if (this.options.IsRemoteRaidboss) {
this.ttsEngine = new BrowserTTSEngine(this.displayLang);
this.ttsSay = (text) => {
this.ttsEngine?.play(this.options.TransformTts(text));
};
} else {
this.ttsSay = (text) => {
void callOverlayHandler({
call: 'cactbotSay',
text: this.options.TransformTts(text),
});
};
}
this.data = this.getDataObject();
// check to see if we need user interaction to play audio
// only if audio is enabled in options
if (this.options.AudioAllowed)
AutoplayHelper.CheckAndPrompt();
this.Reset();
this.AddDebugInstructions();
this.HookOverlays();
}
AddDebugInstructions(): void {
raidbossInstructions[this.displayLang].forEach((line, i) => {
const elem = document.getElementById(`instructions-${i}`);
if (!elem)
return;
elem.innerHTML = line;
});
}
HookOverlays(): void {
addOverlayListener('PartyChanged', (e) => {
this.partyTracker.onPartyChanged(e);
});
addPlayerChangedOverrideListener((e: PlayerChangedDetail) => {
this.OnPlayerChange(e);
}, this.options.PlayerNameOverride);
addOverlayListener('ChangeZone', (e) => {
this.OnChangeZone(e);
});
addOverlayListener('onInCombatChangedEvent', (e) => {
this.OnInCombatChange(e.detail.inGameCombat);
});
addOverlayListener('onLogEvent', (e) => {
this.OnLog(e);
});
addOverlayListener('LogLine', (e) => {
this.OnNetLog(e);
});
}
OnPlayerChange(e: PlayerChangedDetail): void {
if (this.job !== e.detail.job || this.me !== e.detail.name)
this.OnJobChange(e);
this.data.currentHP = e.detail.currentHP;
}
ProcessDataFiles(files: RaidbossFileData): void {
this.triggerSets = [];
for (const [filename, json] of Object.entries(files)) {
if (!filename.endsWith('.js') && !filename.endsWith('.ts'))
continue;
if (typeof json !== 'object') {
console.log('Unexpected JSON from ' + filename + ', expected an array');
continue;
}
if (!json.triggers) {
console.log('Unexpected JSON from ' + filename + ', expected a triggers');
continue;
}
if (typeof json.triggers !== 'object' || !(json.triggers.length >= 0)) {
console.log('Unexpected JSON from ' + filename + ', expected triggers to be an array');
continue;
}
const processedSet = {
filename: filename,
...json,
};
this.triggerSets.push(processedSet);
}
// User triggers must come last so that they override built-in files.
this.triggerSets.push(...this.options.Triggers);
}
OnChangeZone(e: EventResponses['ChangeZone']): void {
if (this.zoneName !== e.zoneName) {
this.zoneName = e.zoneName;
this.zoneId = e.zoneID;
this.ReloadTimelines();
}
}
ReloadTimelines(): void {
if (!this.triggerSets || !this.me || !this.zoneName || !this.timelineLoader.IsReady())
return;
// Drop the triggers and timelines from the previous zone, so we can add new ones.
this.triggers = [];
this.netTriggers = [];
this.dataInitializers = [];
let timelineFiles = [];
let timelines: string[] = [];
const replacements: TimelineReplacement[] = [];
const timelineStyles = [];
this.resetWhenOutOfCombat = true;
const orderedTriggers = new OrderedTriggerList();
// Some user timelines may rely on having valid init data
// Don't use `this.Reset()` since that clears other things as well
this.data = this.getDataObject();
// Recursively/iteratively process timeline entries for triggers.
// Functions get called with data, arrays get iterated, strings get appended.
const addTimeline = (function(this: PopupText, obj: TimelineField | TimelineFunc | undefined) {
if (Array.isArray(obj)) {
for (const objVal of obj)
addTimeline(objVal);
} else if (typeof obj === 'function') {
addTimeline(obj(this.data));
} else if (obj) {
timelines.push(obj);
}
}).bind(this);
// construct something like regexDe or regexFr.
const langSuffix = this.parserLang.charAt(0).toUpperCase() + this.parserLang.slice(1);
const regexParserLang = 'regex' + langSuffix;
const netRegexParserLang = 'netRegex' + langSuffix;
for (const set of this.triggerSets) {
// zoneRegex can be undefined, a regex, or translatable object of regex.
const haveZoneRegex = 'zoneRegex' in set;
const haveZoneId = 'zoneId' in set;
if (!haveZoneRegex && !haveZoneId || haveZoneRegex && haveZoneId) {
console.error(`Trigger set must include exactly one of zoneRegex or zoneId property`);
continue;
}
if (haveZoneId && set.zoneId === undefined) {
const filename = set.filename ? `'${set.filename}'` : '(user file)';
console.error(`Trigger set has zoneId, but with nothing specified in ${filename}. ` +
`Did you misspell the ZoneId.ZoneName?`);
continue;
}
if (set.zoneId) {
if (set.zoneId !== ZoneId.MatchAll && set.zoneId !== this.zoneId && !(typeof set.zoneId === 'object' && set.zoneId.includes(this.zoneId)))
continue;
} else if (set.zoneRegex) {
let zoneRegex = set.zoneRegex;
if (typeof zoneRegex !== 'object') {
console.error('zoneRegex must be translatable object or regexp: ' + JSON.stringify(set.zoneRegex));
continue;
} else if (!(zoneRegex instanceof RegExp)) {
const parserLangRegex = zoneRegex[this.parserLang];
if (parserLangRegex) {
zoneRegex = parserLangRegex;
} else if (zoneRegex['en']) {
zoneRegex = zoneRegex['en'];
} else {
console.error('unknown zoneRegex parser language: ' + JSON.stringify(set.zoneRegex));
continue;
}
if (!(zoneRegex instanceof RegExp)) {
console.error('zoneRegex must be regexp: ' + JSON.stringify(set.zoneRegex));
continue;
}
}
if (this.zoneName.search(Regexes.parse(zoneRegex)) < 0)
continue;
}
if (this.options.Debug) {
if (set.filename)
console.log('Loading ' + set.filename);
else
console.log('Loading user triggers for zone');
}
const setFilename = set.filename ?? 'Unknown';
if (set.initData) {
this.dataInitializers.push({
file: setFilename,
func: set.initData,
});
}
// Adjust triggers for the parser language.
if (set.triggers && this.options.AlertsEnabled) {
for (const trigger of set.triggers) {
// Add an additional resolved regex here to save
// time later. This will clobber each time we
// load this, but that's ok.
trigger.filename = setFilename;
const id = trigger.id;
if (!isRegexTrigger(trigger) && !isNetRegexTrigger(trigger)) {
console.error(`Trigger ${id}: has no regex property specified`);
continue;
}
this.ProcessTrigger(trigger);
let found = false;
const triggerObject: { [key: string]: unknown } = trigger;
// parser-language-based regex takes precedence.
if (isRegexTrigger(trigger)) {
const regex = triggerObject[regexParserLang] ?? trigger.regex;
if (regex instanceof RegExp) {
trigger.localRegex = Regexes.parse(regex);
orderedTriggers.push(trigger);
found = true;
}
}
if (isNetRegexTrigger(trigger)) {
const netRegex = triggerObject[netRegexParserLang] ?? trigger.netRegex;
if (netRegex instanceof RegExp) {
trigger.localNetRegex = Regexes.parse(netRegex);
orderedTriggers.push(trigger);
found = true;
}
}
if (!found) {
console.error('Trigger ' + trigger.id + ': missing regex and netRegex');
continue;
}
}
}
if (set.overrideTimelineFile) {
const filename = set.filename ? `'${set.filename}'` : '(user file)';
console.log(`Overriding timeline from ${filename}.`);
// If the timeline file override is set, all previously loaded timeline info is dropped.
// Styles, triggers, and translations are kept, as they may still apply to the new one.
timelineFiles = [];
timelines = [];
}
// And set the timeline files/timelines from each set that matches.
if (set.timelineFile) {
if (set.filename) {
const dir = set.filename.substring(0, set.filename.lastIndexOf('/'));
timelineFiles.push(dir + '/' + set.timelineFile);
} else {
// Note: For user files, this should get handled by raidboss_config.js,
// where `timelineFile` should get converted to `timeline`.
console.error('Can\'t specify timelineFile in non-manifest file:' + set.timelineFile);
}
}
if (set.timeline)
addTimeline(set.timeline);
if (set.timelineReplace)
replacements.push(...set.timelineReplace);
if (set.timelineTriggers) {
for (const trigger of set.timelineTriggers) {
this.ProcessTrigger(trigger);
trigger.isTimelineTrigger = true;
orderedTriggers.push(trigger);
}
}
if (set.timelineStyles)
timelineStyles.push(...set.timelineStyles);
if (set.resetWhenOutOfCombat !== undefined)
this.resetWhenOutOfCombat &&= set.resetWhenOutOfCombat;
}
// Store all the collected triggers in order, and filter out disabled triggers.
const filterEnabled = (trigger: LooseTrigger) => !('disabled' in trigger && trigger.disabled);
const allTriggers = orderedTriggers.asList().filter(filterEnabled);
this.triggers = allTriggers.filter(isRegexTrigger);
this.netTriggers = allTriggers.filter(isNetRegexTrigger);
const timelineTriggers = allTriggers.filter(isRaidbossLooseTimelineTrigger);
this.Reset();
this.timelineLoader.SetTimelines(
timelineFiles,
timelines,
replacements,
timelineTriggers,
timelineStyles,
this.zoneId,
);
}
ProcessTrigger(trigger: ProcessedTrigger | ProcessedTimelineTrigger): void {
// These properties are used internally by ReloadTimelines only and should
// not exist on user triggers. However, the trigger objects themselves are
// reused when reloading pages, and so it is impossible to verify that
// these properties don't exist. Therefore, just delete them silently.
if (isRaidbossLooseTimelineTrigger(trigger))
delete trigger.isTimelineTrigger;
delete trigger.localRegex;
delete trigger.localNetRegex;
trigger.output = TriggerOutputProxy.makeOutput(trigger, this.options.DisplayLanguage,
this.options.PerTriggerAutoConfig);
}
OnJobChange(e: PlayerChangedDetail): void {
this.me = e.detail.name;
this.job = e.detail.job;
this.role = Util.jobToRole(this.job);
this.ReloadTimelines();
}
OnInCombatChange(inCombat: boolean): void {
if (this.inCombat === inCombat)
return;
if (this.resetWhenOutOfCombat)
this.SetInCombat(inCombat);
}
SetInCombat(inCombat: boolean): void {
if (this.inCombat === inCombat)
return;
// Stop timers when stopping combat to stop any active timers that
// are delayed. However, also reset when starting combat.
// This prevents late attacks from affecting |data| which
// throws off the next run, potentially.
this.inCombat = inCombat;
if (!this.inCombat) {
this.StopTimers();
this.timelineLoader.StopCombat();
}
if (this.inCombat)
this.Reset();
}
ShortNamify(name?: string): string {
// TODO: make this unique among the party in case of first name collisions.
// TODO: probably this should be a general cactbot utility.
if (typeof name !== 'string') {
if (typeof name !== 'undefined')
console.error('called ShortNamify with non-string');
return '???';
}
const nick = this.options.PlayerNicks[name];
if (nick)
return nick;
const idx = name.indexOf(' ');
return idx < 0 ? name : name.substr(0, idx);
}
Reset(): void {
Util.clearWatchCombatants();
this.data = this.getDataObject();
this.StopTimers();
this.triggerSuppress = {};
for (const initObj of this.dataInitializers) {
const init = initObj.func;
const data = init();
if (typeof data === 'object') {
this.data = {
...data,
...this.data,
};
} else {
console.log(`Error in file: ${initObj.file}: these triggers may not work;
initData function returned invalid object: ${init.toString()}`);
}
}
}
StopTimers(): void {
this.timers = {};
}
OnLog(e: LogEvent): void {
// This could conceivably be determined based on the line's contents as well, but
// not sure if that's worth the effort
const currentTime = +new Date();
for (const log of e.detail.logs) {
for (const trigger of this.triggers) {
const r = trigger.localRegex?.exec(log);
if (r)
this.OnTrigger(trigger, r, currentTime);
}
}
}
OnNetLog(e: EventResponses['LogLine']): void {
const log = e.rawLine;
// This could conceivably be determined based on `new Date(e.line[1])` as well, but
// not sure if that's worth the effort
const currentTime = +new Date();
if (isWipe(log)) {
// isWipe can be called with `/e end` to stop the timeline due to e.g. countdown but no pull
// However, `this.inCombat` will already be `false` in that case preventing the timeline from
// getting stopped. If we're not inCombat and we've hit the wipe conditions defined by
// `isWipe`, just set it to true first and then to false
if (!this.inCombat)
this.SetInCombat(true);
this.SetInCombat(false);
}
for (const trigger of this.netTriggers) {
const r = trigger.localNetRegex?.exec(log);
if (r)
this.OnTrigger(trigger, r, currentTime);
}
}
OnTrigger(
trigger: ProcessedTrigger,
matches: RegExpExecArray | null,
currentTime: number): void {
try {
this.OnTriggerInternal(trigger, matches, currentTime);
} catch (e) {
onTriggerException(trigger, e);
}
}
OnTriggerInternal(
trigger: ProcessedTrigger,
matches: RegExpExecArray | null,
currentTime: number): void {
if (this._onTriggerInternalCheckSuppressed(trigger, currentTime))
return;
let groups: Matches = {};
// If using named groups, treat matches.groups as matches
// so triggers can do things like matches.target.
if (matches && matches.groups) {
groups = matches.groups;
} else if (matches) {
// If there are no matching groups, reproduce the old js logic where
// groups ended up as the original RegExpExecArray object
matches.forEach((value, idx) => {
groups[idx] = value;
});
}
// Set up a helper object so we don't have to throw
// a ton of info back and forth between subfunctions
const triggerHelper = this._onTriggerInternalGetHelper(trigger, groups, currentTime);
if (!this._onTriggerInternalCondition(triggerHelper))
return;
this._onTriggerInternalPreRun(triggerHelper);
// Evaluate for delay here, but run delay later
const delayPromise = this._onTriggerInternalDelaySeconds(triggerHelper);
this._onTriggerInternalDurationSeconds(triggerHelper);
this._onTriggerInternalSuppressSeconds(triggerHelper);
const triggerPostDelay = () => {
const promise = this._onTriggerInternalPromise(triggerHelper);
const triggerPostPromise = () => {
this._onTriggerInternalSound(triggerHelper);
this._onTriggerInternalSoundVolume(triggerHelper);
this._onTriggerInternalResponse(triggerHelper);
this._onTriggerInternalAlarmText(triggerHelper);
this._onTriggerInternalAlertText(triggerHelper);
this._onTriggerInternalInfoText(triggerHelper);
// Rumble isn't a trigger function, so only needs to be ordered
// after alarm/alert/info.
this._onTriggerInternalRumble(triggerHelper);
// Priority audio order:
// * user disabled (play nothing)
// * if tts options are enabled globally or for this trigger:
// * user TTS triggers tts override
// * tts entries in the trigger
// * default alarm tts
// * default alert tts
// * default info tts
// * if sound options are enabled globally or for this trigger:
// * user trigger sound overrides
// * sound entries in the trigger
// * alarm noise
// * alert noise
// * info noise
// * else, nothing
//
// In general, tts comes before sounds and user overrides come
// before defaults. If a user trigger or tts entry is specified as
// being valid but empty, this will take priority over the default
// tts texts from alarm/alert/info and will prevent tts from playing
// and allowing sounds to be played instead.
this._onTriggerInternalTTS(triggerHelper);
this._onTriggerInternalPlayAudio(triggerHelper);
this._onTriggerInternalRun(triggerHelper);
};
// The trigger body must run synchronously when there is no promise.
if (promise)
promise.then(triggerPostPromise, (e) => onTriggerException(trigger, e));
else
triggerPostPromise();
};