-
Notifications
You must be signed in to change notification settings - Fork 33
Groovy Examples
Before you begin, there are a few possible gotchas to be aware of.
The "Scratch" script, as well as the "Example" script are NOT saved. If you wish to save their contents, you must "Save As".
Putting text into a script does nothing on its own. You must run the script for it to have any effect. For things that are supposed to be persistent, run them and then check the "Run on startup" box.
I recommend making the script itself idempotent, so that it can be run as many times as needed for debugging purposes.
When configuring a trigger with an expression in braces, or a groovy filter/action in Easy Triggers, the same basic rules apply.
Everything in the dependency injection container is exposed as a global variable - take the class name and lowercase the first letter.
There are also a few aliases, such as state
for xivState
.
In addition, Groovy will translate properties into methods automatically (e.g. state.player.name
is equivalent to state.getPlayer().getName()
.
To put it all together, the expression pullTracker.currentPull.combatDuration.toSeconds()
would tell you the number of elapsed seconds in a pull.
pullTracker
would give you the instance of PullTracker, then calls the getCurrentPull() method, then the
getCombatDuration() method on
the Pull object, and finally the toSeconds()
method on the Duration object.
Closures in Groovy in some cases are used as Java functional interfaces. e.g. if a Java method expects a Function<String, String>
, then a closure which takes a string argument such as { it.toUpperCase() }
or { myStr -> myStr.toLowerCase() }
will be automatically converted to the appropriate Java type.
However, closures can also have custom delegates, which allow for domain-specific language to be defined for that closure. Here are a few examples:
-
groovyTriggers.add(...)
uses GroovyTriggers.builder as its delegate (see below for examples) -
displayControl.table(...)
uses DisplayControl.TableDSL as its delegate. -
amHelper.mark(...)
uses AmHelper.Builder as its delegate.
You can now make triggers from within a script.
groovyTriggers.add
registers the trigger. You must run the script at least one time, or it won't do anything.
Running the script again will overwrite the old handler, so as long you keep the named
parameter the same,
you can keep modifying your script and running it to apply the changes, without continually adding more and
more event handlers. To make it persistent, check the "Run on Startup" box.
Here is an example of a simple (non-sequential) trigger:
groovyTriggers.add {
named "foo"
type AbilityCastStart
when { it.abilityIdMatches(0x5EF8) }
then { event, context ->
context.accept(new TtsRequest(event.ability.name))
log.info "bar"
}
}
This will call out the name of the ability when you start casing Dosis III.
Now, a sequential trigger:
groovyTriggers.add {
// Name should be unique
named "Sequential Trigger Test"
// Start on casting Broil IV
when { AbilityCastStart acs -> acs.abilityIdMatches(0x6509) }
sequence { e1, s ->
// Call out "foo" and a constantly updating text display defined as a GroovyString,
// plus a color in long form
callout { tts "foo" text "${->e1.estimatedRemainingDuration.toMillis()}" color new Color(0, 255, 255) }
// Then wait for the cast to snapshot
snapshot = s.waitEvent(AbilityUsedEvent, aue -> aue.abilityIdMatches(0x6509))
// Do another callout, no TTS, just text. Color in short form, plus a status icon, and a specific duration
callout { text "${snapshot.damage}" color 255,0,255 statusIcon 0x77F replaces last duration 1000 }
}
}
This is a more in-depth example. This starts when you begin casting Broil IV. It will immediately call out TTS "foo", and if you have the callout overlay enabled, will display a rapidly-changing number showing you how many milliseconds are left on the cast bar (the "->" in a Groovy string indicates that the value should be re-evaluated every time), with the text having a cyan color. Note that you can accomplish the same without a Groovy string - any closure that takes no arguments and returns a string should work.
Then, it waits for the cast to snapshot (AbilityUsedEvent), and saves that to a local variable so that we can
use it. It will then do a text-only callout with the amount of damage dealt (in this case, since that value won't change,
there is no reason to use "->" to turn it into a closure - a fixed value is fine). This callout will also have an icon.
Furthermore, replaces last
causes it to replace the earlier text, rather than displaying both. It will remain for 1000
milliseconds, and then disappear.
Example for sniper cannon (P3 transition) in TOP:
import gg.xp.xivsupport.events.triggers.marks.adv.*
// Define priority
prio = [Job.SCH, Job.DRK, Job.MNK, Job.DRG, Job.DNC, Job.SMN, Job.GNB, Job.SGE]
def mark(player, sign) {
eventMaster.pushEvent(new SpecificAutoMarkRequest(player, sign))
}
// Define our function
def sniperAM(dryRun = false) {
log.info "Sniper AM Start"
// Get party list
party = state.partyList
// Sort party list according to our priority
party.sort { member -> prio.indexOf(member.job) }
// Start with empty lists for each mechanic
sniper = []
hpSniper = []
nothing = []
// Categorize players according to their debuff
party.each { member ->
if (statusEffectRepository.isStatusOnTarget(member, 0xD61)) {
sniper += member
}
else if (statusEffectRepository.isStatusOnTarget(member, 0xD62)) {
hpSniper += member
}
else {
nothing += member
}
}
log.info "Sniper: {}, HP: {}, Nothing: {}", sniper, hpSniper, nothing
// Trigger the AMs
if (!dryRun) {
sniper.each { player -> mark player, MarkerSign.ATTACK_NEXT }
mark hpSniper[0], MarkerSign.BIND1
mark hpSniper[1], MarkerSign.IGNORE1
mark nothing[0], MarkerSign.BIND2
mark nothing[1], MarkerSign.IGNORE2
}
log.info "Sniper AM Done"
// For testing, return the values
return ["Sniper": sniper.collect{it.name}, "High Power Sniper": hpSniper.collect{it.name}, "Nothing": nothing.collect{it.name}]
}
// The actual trigger
groovyTriggers.add {
// Name should be unique
named "Sniper Cannon AM"
type BuffApplied
when { it.buffIdMatches(0xD61) }
sequence { e1, s ->
s.waitMs(100)
sniperAM()
}
}
// Run the function once. This isn't strictly needed, but helps with performance,
// and also will be more likely to discover any problems in your function before
// running it for real.
// The dryRun parameter causes it to not actually do any marking. For testing
// the script, you'd want to import a log, play it until the part where you'd expect
// the AM to fire, and then you can run this as many times as needed.
// This also returns the data, so you can inspect it in the Groovy tab.
sniperAM(true)
Dump all abilities in a certain pull and their timestamp relative to the pull start into CSV format. The pull must be finished, so if you are replaying, you will need to make sure you have replayed past the end of the pull.
// Insert the correct pull number here, from the Pulls tab
pull = pullTracker.getPull(1)
// Change this to the player you want
playerName = "WAR Main"
start = pull.getStart().getHappenedAt()
combatStart = pull.getCombatStart().getHappenedAt()
end = pull.getEnd().getHappenedAt()
rawEventStorage.getEvents()
.stream()
.filter(e -> e instanceof AbilityUsedEvent)
.filter(e -> e.getHappenedAt().isAfter(start) && e.getHappenedAt().isBefore(end))
.filter(e -> e.getSource().getName().equals(playerName))
.map(e -> "${e.getAbility().getName()},${java.time.Duration.between(combatStart, e.getHappenedAt()).toMillis() / 1000}")
.collect(java.util.stream.Collectors.joining("\n", "Ability,Time\n", ""))
propertiesFilePersistenceProvider.@properties
If you have switched branches and some of your Easy Triggers are gone, it may simply be that they are not compatible with the version you switched to.
To retrieve them, do this:
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
failed = propertiesFilePersistenceProvider.get("easy-triggers.failed-triggers", String, null)
list = new ObjectMapper().readValue(failed, new TypeReference<List<String>>() {})
return list.toString()
You can then copy and paste the output into the "Import Triggers" window. If there is still an error, you may need to remove some of them from the resulting list.
Finally, you can clear out the failed trigger storage by doing propertiesFilePersistenceProvider.delete("easy-triggers.failed-triggers")
.
If the return value of the script is a Component
, it will be displayed:
bar = new gg.xp.xivsupport.gui.tables.renderers.HpBar()
bar.setData(new gg.xp.xivsupport.models.HitPoints(51677, 51677), 0, 5000)
return bar
Note that components can only be in one parent container at a time - if you do this with a component that you did not create, you are "stealing" it from wherever you found it, and it might not return to its proper place until you restart the program.
Want to see a bit more than just what's in the Library tab? Try something like this:
[0xB13, 0xB14, 0xB15, 0xB16].collect{StatusEffectLibrary.forId(it)}
There is an object called globals
that acts as a global variable namespace. You can insert your own stuff into this. If your object is a closure that takes an Event and returns a boolean, you can use it as a freeform search filter to implement whatever advanced logic you need. In addition, you should check the "Run on Startup" box to always have this available.
Example: I want to see NPC casts and abilities, but also callouts. This would not be possible with the graphical filters.
def isNpc(XivCombatant combatant) {
return combatant.type == CombatantType.NPC || combatant.type == CombatantType.FAKE
}
globals.bossMech = event -> {
(event instanceof AbilityCastStart && isNpc(event.source))
|| (event instanceof AbilityUsedEvent && isNpc(event.source) && event.name != 'attack')
|| (event instanceof CalloutEvent)
}
globals.noPlayerAbilities = event -> {
if ((event instanceof HasAbility || event instanceof HasStatusEffect) && event instanceof HasSourceEntity) {
if (!isNpc(event.source)) {
return false
}
}
if (event instanceof StatusEffectList && !isNpc(event.target)) {
return false;
}
if (event instanceof TickEvent) {
return false;
}
return true;
}
Don't forget to enable "Run on startup". This defines two scriptlets: The first will display only castbars and ability uses from NPCs (it will also filter out abilities with the name 'attack' i.e. autoattacks, generally uninteresting for trigger debugging), and callouts. The second is more permissive - it will display everything that is not a player ability, buff, tick, etc.
You use it like any other function, e.g. noPlayerAbilities(event)
on its own, or use boolean logic to combine with other conditions. Here is an example of the basic use case:
This can be useful while prototyping new OP functionality:
// Send pre-formatted JSON (note backslashes must be double-escaped):
actWsLogSource.client.send('{"call":"playSound","file":"C:\\\\Users\\\\Foo\\\\Documents\\\\sonar.wav"}')
// Have Triggevent format the JSON for you, avoid the toothpicks:
callStr = actWsLogSource.mapper.writeValueAsString([call: "say", text: "Foo Bar"])
actWsLogSource.client.send(callStr)
These examples use Groovy on the "Groovy" tab.
Note that if the script expects data, and you'd like to run the script on a log file, you'll likely want to import and play the log file - the script will not advance the log for you.
To see a report of all instances of damage from enemies, including damage types and amounts:
rawEventStorage.getEventsOfType(AbilityUsedEvent.class)
.findAll{it.source.type == CombatantType.NPC}
.findAll{it.damage > 0}
.collect{event -> event.effects
.findAll{effect -> effect instanceof DamageEffect}
.collect{effect -> "${event.source.name}'s ${event.ability.name.toString()} (0x${Long.toString(event.ability.id, 16)}) did ${effect.amount} ${effect.damageAspect} ${effect.damageType} damage to ${event.target.name}"}}
.flatten()
Note that this operates on snapshots - change AbilityUsedEvent to AbilityResolvedEvent if you want to restrict it to abilities that actually landed.
The result will be a table that looks like this:
To see abilities that a player used relative to combat start:
// Insert the correct pull number here, from the Pulls tab
pull = pullTracker.getPull(1)
// Change this to the player you want
playerName = "WAR Main"
start = pull.start.happenedAt
combatStart = pull.combatStart.happenedAt
end = pull.end.happenedAt
rawEventStorage.getEventsOfType(AbilityUsedEvent.class)
.findAll{e -> e.happenedAt.isAfter(start) && e.happenedAt.isBefore(end)}
.findAll{e -> e.source.name == playerName}
.collect{e -> "${e.ability.name},${java.time.Duration.between(combatStart, e.happenedAt).toMillis() / 1000}"}
.join('\n')
This will be formatted like a CSV file.
If the script returns an AWT component, it will be displayed, e.g. to display an HP bar:
bar = new gg.xp.xivsupport.gui.tables.renderers.HpBar()
bar.setData(new HitPoints(51677, 51677), 0, 5000)
return bar
Map<Long, Long> results = new HashMap<>();
rawEventStorage.getEventsOfType(ACTLogLineEvent.class).each({
line = it.lineNumber;
Long countBefore = results.get(line);
Long countAfter = null == countBefore ? 1 : countBefore + 1;
results.put(line, countAfter);
})
return results;
This gives you a mapping of line numbers (0, 1, 20, 21, etc) to the count of that line type.
[0xB13, 0xB14, 0xB15, 0xB16].collect{StatusEffectLibrary.forId(it)}
Shows the lowest HP of a player during a pull, excluding 0 HP (dead):
// Insert the correct pull number here, from the Pulls tab
pull = pullTracker.getPull(3)
// Change this to the player you want
playerName = "Scholar Player"
start = pull.start.happenedAt
combatStart = pull.combatStart.happenedAt
end = pull.end.happenedAt
rawEventStorage.getEventsOfType(HasTargetEntity.class)
.findAll{e -> e.target.name == playerName}
.findAll{e -> e.happenedAt.isAfter(start) && e.happenedAt.isBefore(end)}
.collect{e -> e.target.hp.current}
.findAll{hp -> hp > 0}
.min()