-
Notifications
You must be signed in to change notification settings - Fork 34
Why is Regex Bad for Triggers?
Regex is a very powerful text handling too in general. However, when it comes to triggers, Regex is not and never was the right tool for the job.
Let's take a simple task. We want to make a trigger for when the boss of P4S part 2 starts casting Heart Stake on you (and only you).
The log line would look something like this:
20|2022-03-29T19:58:58.1570000-07:00|40010363|Hesperos|6A2B|Heart Stake|108A7015|PLD Player|4.70|100.42|100.08|0.00|-2.81|4541079e7a27006f
We know that the 20
in the first field indicates an ability cast start, which is what we're looking for. 6A2B in the 5th field is the ability ID, which is better to use than the name because you don't have to worry about supporting other languages. Finally, the 7th and 8th fields give us the target ID and name. We don't really care about the rest of the fields.
So, one might be tempted to make a regex that looks something like this:
^20\|[^|]+\|[^|]+\|[^|]+\|6A2B\|[^|]+\|[^|]+\|(?<targetName>[^|]+)\|
However, we're just capturing the target name, we want the trigger to only fire if the target is the player. One option is to tell the user to fill in their own name. This is annoying for the end user, even more annoying if they have alts. It also falls apart for other things, like ignoring casts from fake actors, as such things rely on entity IDs which are not stable. The much more reasonable option is to use something like Triggernometry that lets us add additional conditions to the trigger, like this:
^20\|[^|]+\|[^|]+\|[^|]+\|6A2B\|[^|]+\|[^|]+\|(?<targetName>[^|]+)\|
where targetName = ${playerName}
Seems reasonable. However, the readability is poor. The [^|]+
is really just an obtuse way of saying "we do not care what is in this field". In addition, the actual conditions are split into two places - the '20' and '6A2B' are in the regex, while the 'target is me' condition is external to the regex.
So why don't we clean this up a bit:
^(?<lineType>[^|]+)\|[^|]+\|[^|]+\|[^|]+\|(?<abilityID>[^|]+)\|[^|]+\|[^|]+\|(?<targetName>[^|]+)\|
where lineType = 20
and abilityID = 6A2B
and targetName = ${playerName}
This is a bit more readable, since the important information is all pulled into capture groups with human readable names, and filtered through human readable conditions.
But wait, if we're no longer using the regex for conditions, but merely parsing, and every single 20-line has the same format, why are we even asking the user for a regex to begin with? Why not just give the user a list of pre-canned regices, with all the fields captured? There are only so many ACT events, right?
Right! The Cactbot log guide has exactly that - a collection of pre-canned regices for all of the relevant event types. Cactbot itself almost does that - it still builds a regex internally with the conditions built in, but you don't manually write it. Instead, it provides some helpers for building them out of human readable parameters:
{
id: 'P4S Heart Stake',
type: 'StartsUsing',
netRegex: NetRegexes.startsUsing({ id: '6A2B', source: 'Hesperos' }),
// ...
condition: Conditions.caresAboutPhysical(),
response: Responses.tankBuster(),
},
Don't get me wrong - that's still a hell of a lot better than writing a regex manually.
But...why not take it a step further? Cactbot has a mapping of all line types and their fields. So why not just use a simple string split on the |
character, map it up to fields, and then return a rich object with said fields? What value is regex really providing here?
Well, that's exactly what Triggevent does. It can do this for both triggers and overlays written in the codebase, as well as "Easy Triggers". The equivalent "Heart Stake" trigger looks like this, and even incorporates the NPC ID (which is stable, unlike the entity ID) to avoid any issues with fake/dummy actors:
I didn't even have to make that from scratch - I simply found a log where Heart Stake was used, right clicked on the event, and clicked "Make Easy Trigger". Then, I changed the condition from "Any Player" to "The Player" to make it only trigger when it is being cast on myself.
Oh, and the on-screen text? It even lists the duration remaining on the cast bar by default:
In addition, by using rich objects, we get to factor our own logic into them. For example, by keeping track of what status effects are on an entity, we can determine whether a buff application is an initial application or a refresh, despite there being no readily apparent difference in log lines. For example, the Death's Toll trigger uses this, since it is technically a "refresh" when a stack is consumed by getting hit. We only want to trigger on the initial buff application, hence the !buff.isRefresh()
condition:
@HandleEvents
public void deathsToll(EventContext context, BuffApplied buff) {
// Stack counting down would be considered a refresh
if (buff.getBuff().getId() == 0xACA) {
isDeathsToll = true; // Used to flip the callouts for placement of the Fledgling Flight markers
if (buff.getTarget().isThePlayer() && !buff.isRefresh()) {
long stacks = buff.getStacks();
ModifiableCallout<BuffApplied> callout = switch ((int) stacks) {
case 1 -> deathsToll1;
case 2 -> deathsToll2;
case 4 -> deathsToll4;
default -> deathsTollN;
};
context.accept(callout.getModified(buff));
}
}
}