diff --git a/docs/TimelineGuide.md b/docs/TimelineGuide.md index 170e3fd0e22..2e2b87f021f 100644 --- a/docs/TimelineGuide.md +++ b/docs/TimelineGuide.md @@ -73,6 +73,7 @@ some breaking changes were added, including: * `forcejump` keyword * `label` keyword * netregex sync syntax, e.g. `Ability { id: "1234", source: "That Mob" }` instead of `sync /etc/` +* `blockbegin` and `blockend` keywords ## How do Timelines Work @@ -81,7 +82,7 @@ There are two states: whether it is paused or not, and the current timeline time They start paused at time=0. As soon as any sync happens, it jumps to that time, then unpauses. -If it ever jumps to time=0, then it pauses again. +If it ever jumps to time=0 outside a block, then it pauses again. When playing, the timeline time advances in real time. In other words, if the timeline time is currently `360.2` @@ -106,9 +107,10 @@ If that line occurs outside the valid window, it is ignored. ## Timeline File Syntax Each line in a timeline file is considered its own timeline entry. -Ordering is irrelevant insofar as processing/usage of the file. -The fact that timeline files are ordered is as a convenience to the reader. -(Two lines with the same time do keep their relative ordering.) +Ordering is relevant insofar as it determines which block an entry belongs +to. It is expected that timeline files should be kept ordered as a matter of +convenience to the reader. Also, two lines with the same timestamp will keep +their relative order. That said, cactbot's linting tools require that timelines be be ordered by time to help with readability and accuracy. @@ -279,6 +281,10 @@ If you jump to time 0, the timeline will stop playback. The syntax for **jump** is `jump [number]` (e.g. `jump 204.2`) or `jump [label]` (e.g. `jump "Hieroglyphika"`). +To jump to a label within another block, use `jump "[blockname]:[label]"`. You +may also jump to the start of a block with `jump "[blockname]"`. Labels +within the block will take precedence over blocknames. + #### label **label** is simply a way to assign a name to a particular time in the timeline. @@ -295,6 +301,60 @@ Most timelines start with the line `hideall "--sync--"` to hide syncs that are just used to keep the timeline on track but should not be shown to the player. Timeline triggers can still match hidden entries. +#### blockbegin + +Timeline entries can be grouped together into logical "blocks" using +`blockbegin "name"` and `blockend`, where "name" is a label for the start of +the block. + +Blocks are intended to allow creating logical groupings of timeline entries +which belong together, to avoid needing to manually offset the entries. The +typical use-case is for timeline files contain multiple separate fights such +as dungeons or critical encounters in field content. + +A block beings at `0.0`, but note that jumping to the block does not pause +the timeline. + +The following is an example for how blocks can be used to separate +encounters. + +```text +# Lapis Manalis + +0.0 "--sync--" sync / 00:0839::The Silvan Throne will be sealed off/ window 0,1 jump "albion" +0.0 "--sync--" sync / 00:0839::The Forum Messorum will be sealed off/ window 0,1 jump "galatea" +0.0 "--sync--" sync / 00:0839::Deepspine will be sealed off/ window 0,1 jump "cagnazzo" + +blockbegin "albion" +0.0 "--sync--" sync / 00:0839::The Silvan Throne will be sealed off/ window 0,1 +6.0 "--sync--" sync / 1[56]:[^:]*:Albion:802C:/ +10.6 "Call of the Mountain" sync / 1[56]:[^:]*:Albion:7A7C:/ +# etc +blockend + +blockbegin "galatea" +0.0 "--sync--" sync / 00:0839::The Forum Messorum will be sealed off/ window 0,1 +10.2 "--sync--" sync / 1[56]:[^:]*:Galatea Magna:7F71:/ # manually added for early sync +15.2 "Waxing Cycle/Waning Cycle" sync / 1[56]:[^:]*:Galatea Magna:(7A91|7F6E):/ +#etc +blockend + +blockbegin "cagnazzo" +0.0 "--sync--" sync / 00:0839::Deepspine will be sealed off/ window 0,1 +15.6 "Stygian Deluge" sync / 1[56]:[^:]*:Cagnazzo:79A3:/ +25.4 "--sync--" sync / 1[56]:[^:]*:Cagnazzo:798F:/ +38.0 "Antediluvian 1" sync / 1[56]:[^:]*:Cagnazzo:7990:/ +#etc +blockend +``` + +At present, blocks have some caveats. Blocks are currently implemented +entirely within the timeline parser by determining a suitable time offset +and "unrolling" the blocks by adjusting all timeline entries within the +block using that offset. Thus, it may be possible for one block to continue +into the next. However, the automatically determined gap should be large +enough to prevent issues in practice. + #### other commands There are a number of other commands for generating alerts based on timeline entries. @@ -353,6 +413,8 @@ rather than using a wide window sync at the beginning of the loop for readabilit but do not remove them. (This preserves the ability ID for future maintainers.) * prefer to use `npcNameId` instead of `name` on `AddedCombatant` lines. * use `-la` with `make_timeline` to print an [ability table](../ui/raidboss/data/06-ew/dungeon/another_aloalo_island.txt#L92-L142) and fill it out. +* use `blockbegin` syntax for zones with multiple fights to avoid needing to + offset timestamps. * As always, be consistent with other timelines. ### Trigger Filenames @@ -1273,6 +1335,10 @@ There's plenty of feature work and fixes for timelines if you are interested in * `testsync` instead of comments (we use `#Ability { params }` to avoid sync issues, but it'd be nice to add a `testsync` command that verifies that the sync was hit in the window but does not resync for testing purposes) * handle multiple syncs at the same time: * clean up old timelines to use `label` and `forcejump` +* clean up old timelines to use `blockbegin` and `blockend` +* clean up old timelines to use `blockbegin` and `blockend` +* Refactor timelines to natively support blocks instead of unrolling them in + the parser ### Ability Table diff --git a/test/helper/test_timeline.ts b/test/helper/test_timeline.ts index 2e8a39fa42d..89869100a9c 100644 --- a/test/helper/test_timeline.ts +++ b/test/helper/test_timeline.ts @@ -126,6 +126,12 @@ class TimelineParserLint extends TimelineParser { return; } + // Blocks track sync times separately + if (first === 'blockbegin' || first === `blockend`) { + this.lastSyncTime = 0; + return; + } + // At this point, if `first` is not a time, it's not a valid timeline entry const time = parseFloat(first); if (isNaN(time)) { diff --git a/ui/raidboss/timeline_parser.ts b/ui/raidboss/timeline_parser.ts index 2d878bd1594..016acb76984 100644 --- a/ui/raidboss/timeline_parser.ts +++ b/ui/raidboss/timeline_parser.ts @@ -165,6 +165,8 @@ export type ParsedText = ParsedPopupText | ParsedTriggerText; export type Text = ParsedText & { time: number }; export const regexes = { + blockbegin: /^blockbegin\s+"(?[^"]*)"\s*$/, + blockend: /^blockend\s*$/, comment: /^\s*#/, commentLine: /#.*$/, durationCommand: /(?:[^#]*?\s)?(?duration\s+(?[0-9]+(?:\.[0-9]+)?))(\s.*)?$/, @@ -217,6 +219,15 @@ export class TimelineParser { // Map of encountered syncs to the label they are jumping to. private labelToSync: { [name: string]: Sync[] } = {}; + // Map of encountered block names to their time. + private blockToTime: { [name: string]: number } = {}; + // The current block, if any + private currentBlock: string | null = null; + // The current block offset + private blockOffset: number = 0; + // The largest time encountered so far, used to determine block offset + private largestEncounteredTime: number = 0; + constructor( text: string, replacements: TimelineReplacement[], @@ -255,6 +266,30 @@ export class TimelineParser { this.parse(text, triggers, styles ?? [], uniqueId); } + private labelToDestination(label: string): number | undefined { + // Labels with ':' are already scoped. + if (label.includes(':')) { + return this.labelToTime[label]; + } + + // First, try for a label within the current block + if (this.currentBlock !== undefined) { + const destWithScope = this.labelToTime[`${this.currentBlock}:${label}`]; + if (destWithScope !== undefined) { + return destWithScope; + } + } + + // Second, try for a label outside of any block + const globalDest = this.labelToTime[label]; + if (globalDest !== undefined) { + return globalDest; + } + + // Finally, try for an implicit block label + return this.blockToTime[label]; + } + protected parse( text: string, triggers: LooseTimelineTrigger[], @@ -334,13 +369,66 @@ export class TimelineParser { continue; } + match = regexes.blockbegin.exec(line); + if (match && match['groups']) { + const parsedLine = match['groups']; + if (parsedLine.name === undefined) + throw new UnreachableCode(); + const name = parsedLine.name; + const prevTime = this.blockToTime[name]; + if (prevTime !== undefined) { + const text = `Duplicate block ${name} already used`; + this.errors.push({ + error: text, + lineNumber: lineNumber, + }); + } + const prevLabel = this.labelToTime[name]; + if (prevLabel !== undefined) { + const text = `Duplicate label ${name} already used`; + this.errors.push({ + error: text, + lineNumber: lineNumber, + }); + } + + // Determine a block offset by using the largest previously + // encountered time, and rounding it up. Add a buffer of 500 + // seconds, then round up to the next thousands multiple. + const buffer = this.largestEncounteredTime + 500; + const seconds = Math.ceil(buffer / 1000) * 1000; + + this.blockToTime[name] = seconds; + this.currentBlock = name; + this.blockOffset = seconds; + // Also update largest time in case of an empty block + this.largestEncounteredTime = seconds; + + continue; + } + + match = regexes.blockend.exec(line); + if (match) { + if (this.currentBlock === null) { + this.errors.push({ + lineNumber: lineNumber, + error: 'blockend found without paired blockbegin', + }); + } + this.blockOffset = 0; + this.currentBlock = null; + continue; + } + match = regexes.label.exec(line); if (match && match['groups']) { const parsedLine = match['groups']; if (parsedLine.time === undefined || parsedLine.label === undefined) throw new UnreachableCode(); - const seconds = parseFloat(parsedLine.time); - const label = parsedLine.label; + const seconds = parseFloat(parsedLine.time) + this.blockOffset; + const label = this.currentBlock === null + ? parsedLine.label + : `${this.currentBlock}:${parsedLine.label}`; const prevTime = this.labelToTime[label]; if (prevTime !== undefined) { @@ -350,7 +438,16 @@ export class TimelineParser { lineNumber: lineNumber, }); } + const prevBlock = this.blockToTime[label]; + if (prevBlock !== undefined) { + const text = `Duplicate block ${label} already used`; + this.errors.push({ + error: text, + lineNumber: lineNumber, + }); + } this.labelToTime[label] = seconds; + this.largestEncounteredTime = Math.max(this.largestEncounteredTime, seconds); continue; } @@ -374,7 +471,7 @@ export class TimelineParser { // There can be # in the ability name, but probably not in the regex. line = line.replace(regexes.commentLine, '').trim(); - const seconds = parseFloat(parsedLine.time); + const seconds = parseFloat(parsedLine.time) + this.blockOffset; const e: Event = { id: `${++uniqueid}`, time: seconds, @@ -403,6 +500,14 @@ export class TimelineParser { } else { this.events.push(e); } + + this.largestEncounteredTime = Math.max(this.largestEncounteredTime, seconds); + } + + // Validate that the last block was closed + if (this.currentBlock !== null) { + const text = `Block named ${this.currentBlock} was not terminated with blockend`; + this.errors.push({ error: text }); } // Validate that all timeline triggers match something. @@ -426,7 +531,7 @@ export class TimelineParser { // Validate that all the jumps go to labels that exist. for (const [label, syncs] of Object.entries(this.labelToSync)) { - const destination = this.labelToTime[label]; + const destination = this.labelToDestination(label); if (destination === undefined) { const text = `No label named ${label} found to jump to`; for (const sync of syncs) {