From 7f11ca9dfd4bd4b40a822e9651476f283fea1f40 Mon Sep 17 00:00:00 2001 From: Jacob Keller Date: Sat, 20 Dec 2025 01:15:09 -0800 Subject: [PATCH] timeline: add support for timeline blocks Add new `blockbegin` and `blockend` keywords for the timeline file. These implement basic "block" support by allowing a timeline to identify collections of entries that belong together. To start a block, the timeline should use `blockbegin "name"` where "name" is a unique block name. This also creates an implicit label for the start of the block. To end a block the `blockend` keyword on its own line should be used. To avoid complexity with making the actual timeline logic handle blocks, implement support via a clever hack in the timeline parser to offset all entries. Keep track of the current block, and the largest time offset found so far. When a `blockbegin` keyword is found, use the largest known offset to generate a new "offset". To ensure a decent gap, add 500 seconds to that time then round up to the next multiple of 1000. For all entries until the next `blockend`, add this offset to their encoded time. This essentially automatically offsets blocks in the timeline instead of requiring manual offset configuration. Entries outside of blocks do not get modified. For jumps by label, add "block scoping" where a label is prefixed by the blockname separated by a ':'. Currently no timelines actually use ':' in their label name so this should be safe. A jump with a ':' in its name will always jump to the block label without implicit handling. Labels without ':' will get implicit handling in the following priority, with the first existing option being chosen 1) A label within the same block 2) A label outside any block 3) The start of a block by name This means that timeline files can re-use labels across blocks, and that blocks get implicit labels that do not override explicit ones. This solution is very much a hack, but it avoids a massive refactor that would be required for explicit block handling within many parts of the codebase. Signed-off-by: Jacob Keller --- docs/TimelineGuide.md | 74 +++++++++++++++++++-- test/helper/test_timeline.ts | 6 ++ ui/raidboss/timeline_parser.ts | 113 +++++++++++++++++++++++++++++++-- 3 files changed, 185 insertions(+), 8 deletions(-) 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) {