Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 70 additions & 4 deletions docs/TimelineGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: <https://github.com/quisquous/cactbot/issues/5479>
* 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

Expand Down
6 changes: 6 additions & 0 deletions test/helper/test_timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
113 changes: 109 additions & 4 deletions ui/raidboss/timeline_parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ export type ParsedText = ParsedPopupText | ParsedTriggerText;
export type Text = ParsedText & { time: number };

export const regexes = {
blockbegin: /^blockbegin\s+"(?<name>[^"]*)"\s*$/,
blockend: /^blockend\s*$/,
comment: /^\s*#/,
commentLine: /#.*$/,
durationCommand: /(?:[^#]*?\s)?(?<text>duration\s+(?<seconds>[0-9]+(?:\.[0-9]+)?))(\s.*)?$/,
Expand Down Expand Up @@ -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[],
Expand Down Expand Up @@ -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[],
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}

Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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) {
Expand Down
Loading