diff --git a/src/main/java/io/blert/challenges/inferno/InfernoChallenge.java b/src/main/java/io/blert/challenges/inferno/InfernoChallenge.java index 6a6d790..b7c8927 100644 --- a/src/main/java/io/blert/challenges/inferno/InfernoChallenge.java +++ b/src/main/java/io/blert/challenges/inferno/InfernoChallenge.java @@ -31,6 +31,7 @@ import io.blert.util.Location; import io.blert.util.Tick; import java.util.List; +import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -99,6 +100,14 @@ public boolean hasLogged() { return hasLogged; } + @Override + public Optional getOverallTicks() { + if (hasLogged || getState().isInactive() || challengeStartTick == -1) { + return Optional.empty(); + } + return Optional.of(OverallTicks.local(recordedDurationTicks())); + } + @Override public boolean containsLocation(WorldPoint worldPoint) { return worldPoint.getRegionID() == INFERNO_REGION_ID diff --git a/src/main/java/io/blert/challenges/tfa/ArchiveChallenge.java b/src/main/java/io/blert/challenges/tfa/ArchiveChallenge.java index a28d41d..4f5a97a 100644 --- a/src/main/java/io/blert/challenges/tfa/ArchiveChallenge.java +++ b/src/main/java/io/blert/challenges/tfa/ArchiveChallenge.java @@ -41,20 +41,21 @@ import io.blert.util.Tick; import java.util.Arrays; import java.util.List; +import java.util.Optional; import java.util.function.BiConsumer; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import javax.annotation.Nullable; import joptsimple.internal.Strings; +import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.runelite.api.ChatMessageType; import net.runelite.api.Client; +import net.runelite.api.GameState; import net.runelite.api.Player; import net.runelite.api.coords.WorldPoint; -import net.runelite.api.events.ChatMessage; -import net.runelite.api.events.NpcSpawned; -import net.runelite.api.events.VarbitChanged; +import net.runelite.api.events.*; import net.runelite.client.callback.ClientThread; import net.runelite.client.util.Text; import org.apache.commons.lang3.tuple.Pair; @@ -63,6 +64,7 @@ public class ArchiveChallenge extends RecordableChallenge { private static final int TFA_PARTY_VARBIT = 16386; private static final int TFA_PARTY_LIST_COMPONENT_ID = 63897605; + private static final int TFA_RAID_TIMER_SCRIPT_ID = 10036; private static final int TFA_VARBIT_NO_PARTY = 0; private static final int TFA_VARBIT_IN_PARTY = 1; @@ -73,8 +75,6 @@ public class ArchiveChallenge extends RecordableChallenge { Pattern.compile("(.+) has entered the Fractured Archive\\. Step inside to join (her|him|them)\\.\\.\\."); private static final String RAID_WIPE_MESSAGE = "You failed to survive the Fractured Archive."; private static final String RAID_ABANDON_MESSAGE = "You abandon the raid and leave the Fractured Archive."; - private static final Pattern BOSS_COMPLETION_TOTAL_REGEX = Pattern.compile("Challenge complete: .+?\\. Duration: " - + Tick.TIME_STRING_REGEX + "\\. Total: (?" + Tick.TIME_STRING_REGEX + ")"); private static final Pattern RAID_COMPLETION_REGEX = Pattern.compile("The Fractured Archive: Total completion time: (" + Tick.TIME_STRING_REGEX + ").*"); @@ -86,8 +86,16 @@ public class ArchiveChallenge extends RecordableChallenge { private @Nullable DeferredTask deferredTask = null; - // Ticks since Guardian start, from the latest boss completion message. - private int reportedOverallTime = -1; + @AllArgsConstructor + private static class RaidTimer { + private final int clientTick; + private final int ticks; + private final boolean stopped; + } + + // The raid timer as of the latest run of its script. + @Nullable + private RaidTimer raidTimer = null; public ArchiveChallenge(Client client, ClientThread clientThread) { super(Challenge.TFA, client, clientThread); @@ -105,37 +113,13 @@ public boolean containsLocation(WorldPoint worldPoint) { } @Override - protected void onInitialize() { - // TODO(frolv): Temporary, for comparing TFA lifecycle against captures. Remove once verified. - addEventHandler((clientTick, event) -> { - switch (event.getType()) { - case CHALLENGE_START: - case CHALLENGE_UPDATE: - case CHALLENGE_END: - case PLAYER_DEATH: - log.warn("TFA-VERIFY tick={} state={} {}", clientTick, getState(), event); - break; - case STAGE_UPDATE: - StageUpdateEvent update = (StageUpdateEvent) event; - log.warn( - "TFA-VERIFY tick={} state={} {} inGameTicks={} accurate={} overall={}", - clientTick, - getState(), - event, - update.getInGameTicks().orElse(-1), - update.isAccurate(), - reportedOverallTime); - break; - default: - break; - } - }); - } + protected void onInitialize() {} @Override protected void onTerminate() { clearBossDataTracker(); resetParty(); + raidTimer = null; // The same instance records the next raid, so drop anything still // pending from this one. @@ -204,6 +188,21 @@ protected Stage getStage() { return bossDataTracker != null ? bossDataTracker.getStage() : null; } + /** + * Returns the raid's overall time in ticks, from the start of Guardian, if known. + */ + @Override + public Optional getOverallTicks() { + if (raidTimer == null) { + return Optional.empty(); + } + // The raid timer is always precise. + if (raidTimer.stopped) { + return Optional.of(OverallTicks.server(raidTimer.ticks, true)); + } + return Optional.of(OverallTicks.server(raidTimer.ticks + client.getTickCount() - raidTimer.clientTick, true)); + } + /** * Invokes a callback for each username displayed in the TFA orb list. * @@ -224,7 +223,6 @@ public void forEachOrb(BiConsumer callback) { private void queueRaidStart(boolean reinitializeParty) { log.info("Starting new TFA raid"); setState(ChallengeState.PREPARING); - reportedOverallTime = -1; if (reinitializeParty) { // The in-raid party list is written 7 ticks after the player's entry message; @@ -276,6 +274,7 @@ private void queueRaidEnd(ChallengeState state, int overallTime) { clearBossDataTracker(); clearPendingEvents(); resetParty(); + raidTimer = null; deferredTask = null; log.info("TFA raid ended before it started"); return; @@ -294,6 +293,7 @@ private void endRaid(ChallengeState state, int overallTime) { clearBossDataTracker(); setState(state); resetParty(); + raidTimer = null; getClientThread().invokeAtTickEnd(() -> dispatchEvent(new ChallengeEndEvent(-1, overallTime, soft))); } @@ -370,6 +370,27 @@ public void onVarbitChanged(VarbitChanged varbit) { super.onVarbitChanged(varbit); } + @Override + public void onScriptPreFired(ScriptPreFired event) { + if (event.getScriptId() == TFA_RAID_TIMER_SCRIPT_ID) { + // The script runs every tick with the raid's overall time in ticks, + // and whether the timer is stopped. + Object[] args = event.getScriptEvent().getArguments(); + raidTimer = new RaidTimer(client.getTickCount(), (int) args[1], (int) args[2] == 1); + } + + super.onScriptPreFired(event); + } + + @Override + public void onGameStateChanged(GameStateChanged event) { + if (event.getGameState() == GameState.LOGIN_SCREEN) { + raidTimer = null; + } + + super.onGameStateChanged(event); + } + @Override public void onChatMessage(ChatMessage message) { updateLocation(); @@ -396,13 +417,6 @@ public void onChatMessage(ChatMessage message) { return; } - Matcher matcher = BOSS_COMPLETION_TOTAL_REGEX.matcher(stripped); - if (matcher.find()) { - reportedOverallTime = Tick.fromTimeString(matcher.group("total")) - .map(Pair::getLeft) - .orElse(-1); - } - if (getState() == ChallengeState.ACTIVE && (stripped.equals(RAID_WIPE_MESSAGE) || stripped.equals(RAID_ABANDON_MESSAGE))) { if (bossDataTracker != null) { @@ -413,7 +427,7 @@ public void onChatMessage(ChatMessage message) { queueRaidEnd(ChallengeState.COMPLETE); } - matcher = RAID_COMPLETION_REGEX.matcher(stripped); + Matcher matcher = RAID_COMPLETION_REGEX.matcher(stripped); if (getState() == ChallengeState.ACTIVE && matcher.matches()) { int overallTime = Tick.fromTimeString(matcher.group(1)).map(Pair::getLeft).orElse(-1); diff --git a/src/main/java/io/blert/challenges/tfa/bosses/BossDataTracker.java b/src/main/java/io/blert/challenges/tfa/bosses/BossDataTracker.java index 179b7b4..94736a0 100644 --- a/src/main/java/io/blert/challenges/tfa/bosses/BossDataTracker.java +++ b/src/main/java/io/blert/challenges/tfa/bosses/BossDataTracker.java @@ -45,8 +45,8 @@ public abstract class BossDataTracker extends DataTracker { private static final int TFA_ORB1_VARBIT = 16387; private static final Pattern BOSS_START_REGEX = Pattern.compile("Challenge started: (.+)\\."); - private static final Pattern BOSS_END_REGEX = - Pattern.compile("Challenge complete: (.+?)\\. Duration: (" + Tick.TIME_STRING_REGEX + ")"); + private static final Pattern BOSS_END_REGEX = Pattern.compile("Challenge complete: (.+?)\\. Duration: (?" + + Tick.TIME_STRING_REGEX + ")(\\. Total: (?" + Tick.TIME_STRING_REGEX + "))?"); // Boss names as they appear in the start and completion messages. private static final Map BOSS_NAMES = Map.of( @@ -145,7 +145,7 @@ protected final void onMessage(ChatMessage chatMessage) { matcher = BOSS_END_REGEX.matcher(stripped); if (matcher.find() && isBossStartMessage(matcher.group(1))) { completionReceived = true; - finish(matcher.group(2)); + finish(matcher.group("duration"), matcher.group("total")); } } diff --git a/src/main/java/io/blert/client/WebSocketEventHandler.java b/src/main/java/io/blert/client/WebSocketEventHandler.java index a85ffdb..e51fb33 100644 --- a/src/main/java/io/blert/client/WebSocketEventHandler.java +++ b/src/main/java/io/blert/client/WebSocketEventHandler.java @@ -27,6 +27,7 @@ import io.blert.BlertPluginPanel; import io.blert.core.Challenge; import io.blert.core.ChallengeMode; +import io.blert.core.OverallTicks; import io.blert.core.RecordableChallenge; import io.blert.core.Stage; import io.blert.events.*; @@ -416,6 +417,7 @@ void updateChallenge(@Nullable ChallengeUpdateEvent challenge, @Nullable StageUp stageUpdate.recordedTicks = stage.getTick(); stageUpdate.gameTicksPrecise = stage.isGameTicksPrecise(); stage.getInGameTicks().ifPresent(t -> stageUpdate.gameServerTicks = t); + stage.getOverallTicks().ifPresent(t -> stageUpdate.overallTicks = translateOverallTicks(t)); challengeUpdate.stageUpdate = stageUpdate; } @@ -1072,4 +1074,16 @@ private static int translateStageStatus(StageUpdateEvent.Status status) { throw new NotImplementedException("Stage status translation not implemented for " + status); } } + + private static ChallengeUpdate.OverallTicks translateOverallTicks(OverallTicks overallTicks) { + ChallengeUpdate.OverallTicks json = new ChallengeUpdate.OverallTicks(); + if (overallTicks instanceof OverallTicks.Server) { + json.server = new ChallengeUpdate.OverallTicks.Server(); + json.server.ticks = overallTicks.getTicks(); + json.server.precise = ((OverallTicks.Server) overallTicks).isPrecise(); + } else { + json.local = overallTicks.getTicks(); + } + return json; + } } diff --git a/src/main/java/io/blert/core/DataTracker.java b/src/main/java/io/blert/core/DataTracker.java index 6ad0ad7..b76db71 100644 --- a/src/main/java/io/blert/core/DataTracker.java +++ b/src/main/java/io/blert/core/DataTracker.java @@ -35,6 +35,7 @@ import net.runelite.api.coords.WorldPoint; import net.runelite.api.events.*; import net.runelite.client.callback.ClientThread; +import org.apache.commons.lang3.tuple.Pair; @Slf4j public abstract class DataTracker implements RuneliteEventHandler { @@ -50,6 +51,8 @@ private static class PendingCompletion { final boolean completion; final int inGameStageTicks; final boolean gameTicksPrecise; + final int inGameOverallTicks; + final boolean overallTicksPrecise; } @Getter(AccessLevel.PROTECTED) @@ -419,7 +422,7 @@ protected void start(int tickOffset) { // are interested in. npcScanPending = true; - dispatchEvent(StageUpdateEvent.started(getStage(), recordingConfig.getVersion())); + dispatchEvent(StageUpdateEvent.started(getStage(), challenge.getOverallTicks(), recordingConfig.getVersion())); } /** @@ -434,32 +437,60 @@ protected void finish(boolean completion) { } protected void finish(String inGameTime) { + finish(inGameTime, null); + } + + /** + * Marks the stage as complete with known times from the game server. + * + * @param inGameTime The stage's in-game time. + * @param inGameOverallTime The challenge's in-game overall time at the end of the stage, or null if not displayed. + */ + protected void finish(String inGameTime, @Nullable String inGameOverallTime) { var ticks = Tick.fromTimeString(inGameTime); - if (ticks.isPresent()) { - finish(true, ticks.get().getLeft(), ticks.get().getRight()); - } else { - finish(true); - } + Optional> overallTicks = + inGameOverallTime != null ? Tick.fromTimeString(inGameOverallTime) : Optional.empty(); + finish( + true, + ticks.map(Pair::getLeft).orElse(-1), + ticks.map(Pair::getRight).orElse(false), + overallTicks.map(Pair::getLeft).orElse(-1), + overallTicks.map(Pair::getRight).orElse(false)); + } + + protected void finish(boolean completion, int inGameStageTicks, boolean gameTicksPrecise) { + finish(completion, inGameStageTicks, gameTicksPrecise, -1, false); } /** * Marks the stage as complete, queueing cleanup to run at the end of the tick. * - * @param completion Whether the stage was completed successfully. - * @param inGameStageTicks The number of in-game ticks the stage took to complete, or -1 if the in-game timer is not - * available. If provided, it is used to verify the accuracy of the recorded stage time. - * @param gameTicksPrecise Whether the in-game tick timer is precise or rounded. + * @param completion Whether the stage was completed successfully. + * @param inGameStageTicks The number of in-game ticks the stage took to complete, or -1 if the in-game timer is + * not available. If provided, it is used to verify the accuracy of the recorded stage + * time. + * @param gameTicksPrecise Whether the in-game tick timer is precise or rounded. + * @param inGameOverallTicks The challenge's in-game overall time in ticks at the end of the stage, or -1 if not + * available. If provided, it is used to verify the challenge's overall time. + * @param overallTicksPrecise Whether the in-game overall time is precise or rounded. */ - protected void finish(boolean completion, int inGameStageTicks, boolean gameTicksPrecise) { + protected void finish( + boolean completion, + int inGameStageTicks, + boolean gameTicksPrecise, + int inGameOverallTicks, + boolean overallTicksPrecise) { switch (state) { case TERMINATING: log.debug("Forcefully terminating stage {}", stage); - pendingCompletion = new PendingCompletion(completion, inGameStageTicks, gameTicksPrecise); + pendingCompletion = new PendingCompletion( + completion, inGameStageTicks, gameTicksPrecise, inGameOverallTicks, overallTicksPrecise); completeStage(false); break; case IN_PROGRESS: if (pendingCompletion == null) { - pendingCompletion = new PendingCompletion(completion, inGameStageTicks, gameTicksPrecise); + pendingCompletion = new PendingCompletion( + completion, inGameStageTicks, gameTicksPrecise, inGameOverallTicks, overallTicksPrecise); } break; default: @@ -519,11 +550,18 @@ private void completeStage(boolean waitToDispatch) { log.info("Stage {} finished, status: {}", stage, status); Optional gameTicks = inGameStageTicks == -1 ? Optional.empty() : Optional.of(inGameStageTicks); + Optional overallTicks = + checkOverallTicks(pending.inGameOverallTicks, pending.overallTicksPrecise); Runnable dispatch = () -> challenge.dispatchEvent( completion ? StageUpdateEvent.completed( - getStage(), lastRecordedRoomTick, accurate, gameTicks, gameTicksPrecise) - : StageUpdateEvent.wiped(getStage(), lastRecordedRoomTick)); + getStage(), + lastRecordedRoomTick, + accurate, + gameTicks, + gameTicksPrecise, + overallTicks) + : StageUpdateEvent.wiped(getStage(), lastRecordedRoomTick, overallTicks)); if (waitToDispatch) { // Don't send the final room status immediately; allow other pending subscribers to run and dispatch @@ -536,6 +574,35 @@ private void completeStage(boolean waitToDispatch) { } } + /** + * Verifies the challenge's overall time against a time reported in game. + * + * @param inGameOverallTicks The in-game reported time, or -1 if not known. + * @param overallTicksPrecise Whether the in-game time is precise or rounded. + * @return The overall time to report. + */ + private Optional checkOverallTicks(int inGameOverallTicks, boolean overallTicksPrecise) { + Optional overall = challenge.getOverallTicks(); + if (inGameOverallTicks == -1) { + return overall; + } + if (overall.isEmpty()) { + return Optional.of(OverallTicks.server(inGameOverallTicks, overallTicksPrecise)); + } + + int ticks = overall.get().getTicks(); + if (overallTicksPrecise && ticks != inGameOverallTicks) { + log.warn( + "Stage {} overall time mismatch: in-game overall ticks = {}, challenge ticks = {}", + stage, + inGameOverallTicks, + ticks); + return Optional.of(OverallTicks.server(inGameOverallTicks, true)); + } + + return overall; + } + private void updatePlayers() { int tick = getTick(); diff --git a/src/main/java/io/blert/core/OverallTicks.java b/src/main/java/io/blert/core/OverallTicks.java new file mode 100644 index 0000000..2df65e3 --- /dev/null +++ b/src/main/java/io/blert/core/OverallTicks.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 Alexei Frolov + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the “Software”), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +package io.blert.core; + +import lombok.Getter; + +@Getter +public abstract class OverallTicks { + private final int ticks; + + /** + * An overall time reported by the game server. + */ + @Getter + public static final class Server extends OverallTicks { + /** + * Whether the time is precise, or rounded to the nearest second. + */ + private final boolean precise; + + private Server(int ticks, boolean precise) { + super(ticks); + this.precise = precise; + } + } + + /** + * An overall time measured by the plugin. + */ + public static final class Local extends OverallTicks { + private Local(int ticks) { + super(ticks); + } + } + + public static OverallTicks server(int ticks, boolean precise) { + return new Server(ticks, precise); + } + + public static OverallTicks local(int ticks) { + return new Local(ticks); + } + + private OverallTicks(int ticks) { + this.ticks = ticks; + } +} diff --git a/src/main/java/io/blert/core/RecordableChallenge.java b/src/main/java/io/blert/core/RecordableChallenge.java index 0c129fd..b604c87 100644 --- a/src/main/java/io/blert/core/RecordableChallenge.java +++ b/src/main/java/io/blert/core/RecordableChallenge.java @@ -127,6 +127,13 @@ protected RecordableChallenge(Challenge challenge, Client client, ClientThread c protected abstract @Nullable Stage getStage(); + /** + * Returns the challenge's overall time in ticks, if known. + */ + public Optional getOverallTicks() { + return Optional.empty(); + } + public String getName() { return challenge.getName(); } diff --git a/src/main/java/io/blert/events/StageUpdateEvent.java b/src/main/java/io/blert/events/StageUpdateEvent.java index 21b3bc6..ea6e0e2 100644 --- a/src/main/java/io/blert/events/StageUpdateEvent.java +++ b/src/main/java/io/blert/events/StageUpdateEvent.java @@ -23,6 +23,7 @@ package io.blert.events; +import io.blert.core.OverallTicks; import io.blert.core.Stage; import java.util.Optional; import lombok.Getter; @@ -40,25 +41,41 @@ public enum Status { private final boolean accurate; private final Optional inGameTicks; private final boolean gameTicksPrecise; + private final Optional overallTicks; private final Optional recordingConfigVersion; public static StageUpdateEvent entered(Stage stage) { - return new StageUpdateEvent(stage, 0, Status.ENTERED, false, Optional.empty(), false, Optional.empty()); + return new StageUpdateEvent( + stage, 0, Status.ENTERED, false, Optional.empty(), false, Optional.empty(), Optional.empty()); } - public static StageUpdateEvent started(Stage stage, int recordingConfigVersion) { + public static StageUpdateEvent started( + Stage stage, Optional overallTicks, int recordingConfigVersion) { return new StageUpdateEvent( - stage, 0, Status.STARTED, false, Optional.empty(), false, Optional.of(recordingConfigVersion)); + stage, + 0, + Status.STARTED, + false, + Optional.empty(), + false, + overallTicks, + Optional.of(recordingConfigVersion)); } public static StageUpdateEvent completed( - Stage stage, int tick, boolean accurate, Optional inGameTicks, boolean gameTicksPrecise) { + Stage stage, + int tick, + boolean accurate, + Optional inGameTicks, + boolean gameTicksPrecise, + Optional overallTicks) { return new StageUpdateEvent( - stage, tick, Status.COMPLETED, accurate, inGameTicks, gameTicksPrecise, Optional.empty()); + stage, tick, Status.COMPLETED, accurate, inGameTicks, gameTicksPrecise, overallTicks, Optional.empty()); } - public static StageUpdateEvent wiped(Stage stage, int tick) { - return new StageUpdateEvent(stage, tick, Status.WIPED, false, Optional.empty(), false, Optional.empty()); + public static StageUpdateEvent wiped(Stage stage, int tick, Optional overallTicks) { + return new StageUpdateEvent( + stage, tick, Status.WIPED, false, Optional.empty(), false, overallTicks, Optional.empty()); } private StageUpdateEvent( @@ -68,12 +85,14 @@ private StageUpdateEvent( boolean accurate, Optional inGameTicks, boolean gameTicksPrecise, + Optional overallTicks, Optional recordingConfigVersion) { super(EventType.STAGE_UPDATE, stage, tick, null); this.status = status; this.accurate = accurate; this.inGameTicks = inGameTicks; this.gameTicksPrecise = gameTicksPrecise; + this.overallTicks = overallTicks; this.recordingConfigVersion = recordingConfigVersion; } diff --git a/src/main/java/io/blert/json/ChallengeUpdate.java b/src/main/java/io/blert/json/ChallengeUpdate.java index 7a61f12..9b738f0 100644 --- a/src/main/java/io/blert/json/ChallengeUpdate.java +++ b/src/main/java/io/blert/json/ChallengeUpdate.java @@ -42,5 +42,16 @@ public static class StageUpdate { public int recordedTicks; public Integer gameServerTicks; public boolean gameTicksPrecise; + public OverallTicks overallTicks; + } + + public static class OverallTicks { + public static class Server { + public int ticks; + public boolean precise; + } + + public Server server; + public Integer local; } }