diff --git a/timelines/src/main/java/gg/xp/xivsupport/timelines/CactbotEventTypes.java b/timelines/src/main/java/gg/xp/xivsupport/timelines/CactbotEventTypes.java new file mode 100644 index 000000000000..29e7175b918e --- /dev/null +++ b/timelines/src/main/java/gg/xp/xivsupport/timelines/CactbotEventTypes.java @@ -0,0 +1,201 @@ +package gg.xp.xivsupport.timelines; + +import gg.xp.reevent.events.Event; +import gg.xp.xivsupport.events.actlines.events.AbilityUsedEvent; +import gg.xp.xivsupport.events.actlines.events.ChatLineEvent; +import gg.xp.xivsupport.events.actlines.events.NameIdPair; +import gg.xp.xivsupport.events.actlines.events.SystemLogMessageEvent; +import gg.xp.xivsupport.events.state.InCombatChangeEvent; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +public enum CactbotEventTypes { + + GameLog(ChatLineEvent.class, Map.of( + "code", intConv(ChatLineEvent::getCode, 16), + "line", strConv(ChatLineEvent::getLine), + // TODO + "message", strConv(ChatLineEvent::getLine), + "echo", strConv(ChatLineEvent::getLine), + "dialog", strConv(ChatLineEvent::getLine) + )), + StartsUsing(AbilityUsedEvent.class, Map.of( + "sourceId", id(AbilityUsedEvent::getSource), + "source", name(AbilityUsedEvent::getSource), + "targetId", id(AbilityUsedEvent::getTarget), + "target", name(AbilityUsedEvent::getTarget), + "id", id(AbilityUsedEvent::getAbility), + "ability", name(AbilityUsedEvent::getAbility) + )), + Ability(AbilityUsedEvent.class, Map.of( + "sourceId", id(AbilityUsedEvent::getSource), + "source", name(AbilityUsedEvent::getSource), + "targetId", id(AbilityUsedEvent::getTarget), + "target", name(AbilityUsedEvent::getTarget), + "id", id(AbilityUsedEvent::getAbility), + "ability", name(AbilityUsedEvent::getAbility) + )), + InCombat(InCombatChangeEvent.class, Map.of( + // TODO: kind of fake + "inACTCombat", boolToInt(InCombatChangeEvent::isInCombat), + "inGameCombat", boolToInt(InCombatChangeEvent::isInCombat) + )), + SystemLogMessage(SystemLogMessageEvent.class, Map.of( + "instance", intConv(SystemLogMessageEvent::getUnknown, 16), + "id", intConv(SystemLogMessageEvent::getId, 16), + "param0", intConv(SystemLogMessageEvent::getParam0, 16), + "param1", intConv(SystemLogMessageEvent::getParam1, 16), + "param2", intConv(SystemLogMessageEvent::getParam2, 16) + )) + + + // TODO: the rest of the events + ; + + + private final Holder data; + + CactbotEventTypes(Class eventType, Map> condMap) { + this.data = new Holder<>(eventType, condMap); + } + + public Class eventType() { + return data.eventType; + } + + /** + * Represents a conversion from some field (possibly nested) on an event, to a predicate that matches events. + * All cactbot netregices use string values regardless of the underlying data type, so this always takes a string. + * + * @param The event type. + */ + @FunctionalInterface + private interface ConvToCondition { + /** + * Example: on a 21-line, we want to check if the ability ID is "12AB". + * We would call this with "12AB" as the argument, and it should return a predicate that checks that + * a given AbilityUsedEvent has an ability ID of 0x12AB. + * + * @param input The input string. + * @return The resulting predicate. + */ + Predicate convert(String input); + } + + /** + * Make a combined predicate based on the map of values. + * + * @param values The values + * @return The combined predicate + */ + public Predicate make(Map values) { + return this.data.make(values); + } + + /** + * Convenience function for quickly making a ConvToCondition on an integer/long field. + * If the input string in the resulting ConvToCondition is a plain number (and not something that would require + * us to actually do regex), then the numbers will be compared directly. + * + * @param getter A function for getting the required value out of our event. + * @param base The numerical base, typically 10 or 16 + * @param The event type + * @return The condition matching the above requirements. + */ + private static ConvToCondition intConv(Function getter, int base) { + return intConv(getter, base, 0); + } + + /** + * Convenience function for quickly making a ConvToCondition on an integer/long field. + * If the input string in the resulting ConvToCondition is a plain number (and not something that would require + * us to actually do regex), then the numbers will be compared directly. + *

+ * This version of the method allows you to specify that the number should be left-padded to a minimum number of + * characters, with zeroes. e.g. if the input is "00", and the value is "0", then in order for that to match, you + * would need to specify minDigits == 2. + * + * @param getter A function for getting the required value out of our event. + * @param base The numerical base, typically 10 or 16 + * @param minDigits If ACT would left-pad the number with zeroes, then you should specify the minimum length + * of the number here so that the value can be similarly padded out. + * @param The event type + * @return The condition matching the above requirements. + */ + private static ConvToCondition intConv(Function getter, int base, int minDigits) { + return str -> { + try { + // Fast path - input is a number literal, so do a direct number comparison + long parsed = Long.parseLong(str, base); + return item -> getter.apply(item) == parsed; + } + catch (NumberFormatException ignored) { + // Slow path - input is a regex, so compile to regex first + Pattern pattern = Pattern.compile(str, Pattern.CASE_INSENSITIVE); + return item -> { + String asString = Long.toString(getter.apply(item), base); + if (minDigits > 1) { + asString = StringUtils.leftPad(asString, minDigits, '0'); + } + return pattern.matcher(asString).matches(); + }; + } + }; + } + + private static ConvToCondition strConv(Function getter) { + return str -> { + Pattern pattern = Pattern.compile(str); + return item -> pattern.matcher(getter.apply(item)).matches(); + }; + } + + private static ConvToCondition id(Function getter) { + return intConv(e -> getter.apply(e).getId(), 16); + } + + private static ConvToCondition name(Function getter) { + return strConv(e -> getter.apply(e).getName()); + } + + private static ConvToCondition boolToInt(Function getter) { + return str -> switch (str) { + case "0" -> (item -> !getter.apply(item)); + case "1" -> (getter::apply); + default -> throw new IllegalArgumentException("Expected 0 or 1, got '%s'".formatted(str)); + }; + } + + + private static class Holder { + private static final Logger log = LoggerFactory.getLogger(CactbotEventTypes.class); + private final Class eventType; + private final Map> condMap; + + Holder(Class eventType, Map> condMap) { + this.eventType = eventType; + this.condMap = condMap; + } + + + public Predicate make(Map values) { + Predicate combined = eventType::isInstance; + for (var entry : values.entrySet()) { + ConvToCondition convToCondition = this.condMap.get(entry.getKey()); + if (convToCondition == null) { + throw new IllegalArgumentException("Unknown condition: " + entry); + } + Predicate converted = convToCondition.convert(entry.getValue()); + combined = combined.and(converted); + } + //noinspection unchecked - the first check is the type check + return (Predicate) combined; + } + } +} diff --git a/timelines/src/main/java/gg/xp/xivsupport/timelines/CustomTimelineEntry.java b/timelines/src/main/java/gg/xp/xivsupport/timelines/CustomTimelineEntry.java index 73ce3cdee834..c27e94833571 100644 --- a/timelines/src/main/java/gg/xp/xivsupport/timelines/CustomTimelineEntry.java +++ b/timelines/src/main/java/gg/xp/xivsupport/timelines/CustomTimelineEntry.java @@ -195,6 +195,12 @@ public int hashCode() { return Objects.hash(time, name, sync, duration, windowStart, windowEnd, jump, jumpLabel, forceJump, icon, replaces, enabled, callout, calloutPreTime, getEnabledJobs()); } + @Override + public @Nullable EventSyncController eventSyncController() { + // TODO + return null; + } + @Override public String toString() { return "CustomTimelineEntry{" + diff --git a/timelines/src/main/java/gg/xp/xivsupport/timelines/CustomTimelineLabel.java b/timelines/src/main/java/gg/xp/xivsupport/timelines/CustomTimelineLabel.java index a08790f043c5..cee48c45b980 100644 --- a/timelines/src/main/java/gg/xp/xivsupport/timelines/CustomTimelineLabel.java +++ b/timelines/src/main/java/gg/xp/xivsupport/timelines/CustomTimelineLabel.java @@ -38,6 +38,11 @@ public boolean isLabel() { return true; } + @Override + public @Nullable EventSyncController eventSyncController() { + return null; + } + @Override public double time() { return time; diff --git a/timelines/src/main/java/gg/xp/xivsupport/timelines/EventSyncController.java b/timelines/src/main/java/gg/xp/xivsupport/timelines/EventSyncController.java new file mode 100644 index 000000000000..3a9f6b8794cc --- /dev/null +++ b/timelines/src/main/java/gg/xp/xivsupport/timelines/EventSyncController.java @@ -0,0 +1,13 @@ +package gg.xp.xivsupport.timelines; + +import gg.xp.reevent.events.Event; + +public interface EventSyncController { + boolean shouldSync(Event event); + + Class eventType(); + + default boolean isEditable() { + return false; + }; +} diff --git a/timelines/src/main/java/gg/xp/xivsupport/timelines/FileEventSyncController.java b/timelines/src/main/java/gg/xp/xivsupport/timelines/FileEventSyncController.java new file mode 100644 index 000000000000..ccf76908f261 --- /dev/null +++ b/timelines/src/main/java/gg/xp/xivsupport/timelines/FileEventSyncController.java @@ -0,0 +1,35 @@ +package gg.xp.xivsupport.timelines; + +import gg.xp.reevent.events.Event; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Predicate; + +public class FileEventSyncController implements EventSyncController { + + private final Class eventType; + private final Predicate predicate; + private final Map original; + + public FileEventSyncController(Class eventType, Predicate predicate, Map original) { + this.eventType = eventType; + this.predicate = predicate; + this.original = new HashMap<>(original); + } + + @Override + public boolean shouldSync(Event event) { + return predicate.test(event); + } + + @Override + public Class eventType() { + return eventType; + } + + @Override + public String toString() { + return eventType.getSimpleName() + original; + } +} diff --git a/timelines/src/main/java/gg/xp/xivsupport/timelines/TextFileLabelEntry.java b/timelines/src/main/java/gg/xp/xivsupport/timelines/TextFileLabelEntry.java index 49c395110514..be7c2e8ba8be 100644 --- a/timelines/src/main/java/gg/xp/xivsupport/timelines/TextFileLabelEntry.java +++ b/timelines/src/main/java/gg/xp/xivsupport/timelines/TextFileLabelEntry.java @@ -10,6 +10,7 @@ public record TextFileLabelEntry( double time, String name ) implements TimelineEntry, Serializable { + @Override public String toString() { return "TextFileLabelEntry{" + @@ -62,4 +63,9 @@ public double calloutPreTime() { public boolean isLabel() { return true; } + + @Override + public @Nullable EventSyncController eventSyncController() { + return null; + } } diff --git a/timelines/src/main/java/gg/xp/xivsupport/timelines/TextFileTimelineEntry.java b/timelines/src/main/java/gg/xp/xivsupport/timelines/TextFileTimelineEntry.java index 1d61685152b1..de0d7f7de9d1 100644 --- a/timelines/src/main/java/gg/xp/xivsupport/timelines/TextFileTimelineEntry.java +++ b/timelines/src/main/java/gg/xp/xivsupport/timelines/TextFileTimelineEntry.java @@ -1,9 +1,11 @@ package gg.xp.xivsupport.timelines; +import gg.xp.reevent.events.Event; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.Serializable; +import java.util.function.Predicate; import java.util.regex.Pattern; public record TextFileTimelineEntry( @@ -14,8 +16,9 @@ public record TextFileTimelineEntry( @NotNull TimelineWindow timelineWindow, @Nullable Double jump, @Nullable String jumpLabel, - boolean forceJump -) implements TimelineEntry, Serializable { + boolean forceJump, + EventSyncController eventSyncController) implements TimelineEntry, Serializable { + @Override public String toString() { return "TextFileTimelineEntry{" + @@ -27,6 +30,7 @@ public String toString() { ", jump=" + jump + ", jumpLabel='" + jumpLabel + '\'' + ", forceJump=" + forceJump + + ", syncCtrl=" + eventSyncController + '}'; } diff --git a/timelines/src/main/java/gg/xp/xivsupport/timelines/TimelineEntry.java b/timelines/src/main/java/gg/xp/xivsupport/timelines/TimelineEntry.java index 4318f2ea88e1..1c0c8d63a341 100644 --- a/timelines/src/main/java/gg/xp/xivsupport/timelines/TimelineEntry.java +++ b/timelines/src/main/java/gg/xp/xivsupport/timelines/TimelineEntry.java @@ -1,6 +1,7 @@ package gg.xp.xivsupport.timelines; import com.fasterxml.jackson.annotation.JsonIgnore; +import gg.xp.reevent.events.Event; import gg.xp.xivdata.data.*; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -52,11 +53,34 @@ default boolean shouldSync(double currentTime, String line) { return sync.matcher(line).find(); } + @Nullable EventSyncController eventSyncController(); + + default boolean hasEventSync() { + return eventSyncController() != null; + }; + + default @Nullable Class eventSyncType() { + EventSyncController esc = eventSyncController(); + return esc == null ? null : esc.eventType(); + } + + default boolean shouldSync(double currentTime, Event event) { + EventSyncController syncControl = eventSyncController(); + if (syncControl == null) { + return false; + } + boolean timesMatch = (currentTime >= getMinTime() && currentTime <= getMaxTime()); + if (!timesMatch) { + return false; + } + return syncControl.shouldSync(event); + } + /** * @return true if this timeline entry would ever cause a sync */ default boolean canSync() { - return sync() != null; + return sync() != null || hasEventSync(); } /** @@ -320,7 +344,7 @@ default Stream makeTriggerTimelineEntries() { } String uniqueName = makeUniqueName(); String hideAllLine = "hideall \"%s\"".formatted(uniqueName); - String actualTimelineLine = new TextFileTimelineEntry(time(), uniqueName, null, null, TimelineWindow.DEFAULT, null, null, false).toTextFormat(); + String actualTimelineLine = new TextFileTimelineEntry(time(), uniqueName, null, null, TimelineWindow.DEFAULT, null, null, false, null).toTextFormat(); return Stream.of(hideAllLine, actualTimelineLine); } diff --git a/timelines/src/main/java/gg/xp/xivsupport/timelines/TimelineManager.java b/timelines/src/main/java/gg/xp/xivsupport/timelines/TimelineManager.java index 43a8911281f4..dd67e28f3fed 100644 --- a/timelines/src/main/java/gg/xp/xivsupport/timelines/TimelineManager.java +++ b/timelines/src/main/java/gg/xp/xivsupport/timelines/TimelineManager.java @@ -1,6 +1,7 @@ package gg.xp.xivsupport.timelines; import com.fasterxml.jackson.databind.ObjectMapper; +import gg.xp.reevent.events.BaseEvent; import gg.xp.reevent.events.CurrentTimeSource; import gg.xp.reevent.events.EventContext; import gg.xp.reevent.events.EventMaster; @@ -238,10 +239,10 @@ public void changeZone(EventContext context, ZoneChangeEvent zoneChangeEvent) { } @HandleEvents(order = 40_000) - public void actLine(EventContext context, ACTLogLineEvent event) { + public void actLine(EventContext context, BaseEvent event) { TimelineProcessor currentTimeline = this.currentTimeline; if (currentTimeline != null) { - currentTimeline.processActLine(event); + currentTimeline.processEvent(event); } } diff --git a/timelines/src/main/java/gg/xp/xivsupport/timelines/TimelineParser.java b/timelines/src/main/java/gg/xp/xivsupport/timelines/TimelineParser.java index 85469607a1d9..5fbbbc55833a 100644 --- a/timelines/src/main/java/gg/xp/xivsupport/timelines/TimelineParser.java +++ b/timelines/src/main/java/gg/xp/xivsupport/timelines/TimelineParser.java @@ -1,5 +1,11 @@ package gg.xp.xivsupport.timelines; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.json.JsonReadFeature; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import gg.xp.reevent.events.Event; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; @@ -7,17 +13,43 @@ import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; -public class TimelineParser { +public final class TimelineParser { private static final Logger log = LoggerFactory.getLogger(TimelineParser.class); // TODO: there are also windows that do not have a start + end, only a single number - private static final Pattern timelinePatternLong = Pattern.compile("^(?