From 84fdbab9923f7cef2d373756aa055e8afe155fce Mon Sep 17 00:00:00 2001 From: Lexer747 Date: Sat, 1 Aug 2026 12:02:03 +0100 Subject: [PATCH 1/7] v1 --- .../AttackTimerMetronomePlugin.java | 122 ++++--------- src/main/java/com/attacktimer/Damage.java | 94 ++++++++++ .../VariableSpeed/BloodMoonSet.java | 4 +- .../attacktimer/VariableSpeed/EyeOfAyak.java | 4 +- .../VariableSpeed/IVariableSpeed.java | 2 + .../VariableSpeed/PurgingStaffSpec.java | 17 +- .../VariableSpeed/RapidAttackStyle.java | 4 +- .../VariableSpeed/RedKerisSpec.java | 4 +- .../VariableSpeed/RoyalTitans.java | 165 ++++++++++++++++++ .../attacktimer/VariableSpeed/Scurrius.java | 17 +- .../VariableSpeed/TombsOfAmascut.java | 4 +- .../VariableSpeed/TormentedDemons.java | 4 +- .../VariableSpeed/VariableSpeed.java | 19 +- .../com/attacktimer/testdata/PunishTest.txt | 6 +- .../attacktimer/testdata/PunishWastedTest.txt | 6 +- .../testdata/PunishWastedWrongStyleTest.txt | 6 +- .../com/attacktimer/testdata/basicTest.txt | 64 +++---- .../attacktimer/testdata/eatingFoodTest.txt | 26 +-- 18 files changed, 390 insertions(+), 178 deletions(-) create mode 100644 src/main/java/com/attacktimer/Damage.java create mode 100644 src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java diff --git a/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java b/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java index dbbc801..593c57b 100644 --- a/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java +++ b/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java @@ -29,6 +29,7 @@ */ import com.attacktimer.ClientUtils.Utils; +import com.attacktimer.VariableSpeed.State.TickCount; import com.attacktimer.VariableSpeed.VariableSpeed; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableMap; @@ -50,7 +51,6 @@ import net.runelite.api.Client; import net.runelite.api.NPC; import net.runelite.api.Player; -import net.runelite.api.Skill; import net.runelite.api.events.ChatMessage; import net.runelite.api.events.FakeXpDrop; import net.runelite.api.events.GameTick; @@ -133,13 +133,10 @@ public enum AttackState public int pendingEatDelayTicks = 0; private ArrayDeque specialPercentageEvents = new ArrayDeque(); - private Map> combatExpEarned = Map.of( - Skill.MAGIC, new ArrayDeque(), - Skill.RANGED, new ArrayDeque(), - Skill.DEFENCE, new ArrayDeque(), - Skill.STRENGTH, new ArrayDeque(), - Skill.ATTACK, new ArrayDeque() - ); + private static final Damage DAMAGE = new Damage(); + private int dmgDealt = -1; + + public static final TickCount TC = new TickCount(); private static final int UI_HIDE_DEBOUNCE_TICKS_MAX = 1; private static final int ATTACK_DELAY_NONE = 0; @@ -224,32 +221,28 @@ public void onSoundEffectPlayed(SoundEffectPlayed event) @Subscribe protected void onFakeXpDrop(FakeXpDrop event) { - if (!combatExpEarned.containsKey(event.getSkill())) + if (DAMAGE.onXpDrop(event, TC)) { - return; - } - combatExpEarned.get(event.getSkill()).addLast(event.getXp()); - if (inPreAttackWindow()) - { - // We recompute attack speed here incase the hitsplat mattered (e.g. purging staff) - logStateTrace("onFakeXpDrop"); - performAttack(); + if (inPreAttackWindow()) + { + // We recompute attack speed here incase the hitsplat mattered (e.g. purging staff) + logStateTrace("onFakeXpDrop"); + performAttack(); + } } } @Subscribe protected void onStatChanged(StatChanged event) { - if (!combatExpEarned.containsKey(event.getSkill())) + if (DAMAGE.onXpDrop(event, TC)) { - return; - } - combatExpEarned.get(event.getSkill()).addLast(event.getXp()); - if (inPreAttackWindow()) - { - // We recompute attack speed here incase the hitsplat mattered (e.g. purging staff) - logStateTrace("onStatChanged"); - performAttack(); + if (inPreAttackWindow()) + { + // We recompute attack speed here incase the hitsplat mattered (e.g. purging staff) + logStateTrace("onFakeXpDrop"); + performAttack(); + } } } @@ -261,52 +254,6 @@ AttackTimerMetronomeConfig provideConfig(ConfigManager configManager) return configManager.getConfig(AttackTimerMetronomeConfig.class); } - private int computeDamage(AttackStyle attackStyle, AttackProcedure atkType, AnimationData curAnimation) - { - // https://oldschool.runescape.wiki/w/Combat#Experience_gain - switch (atkType) - { - case POWERED_STAVE: - // TODO not needed for any variable speed - return -1; - case MANUAL_AUTO_CAST: - if (attackStyle == AttackStyle.DEFENSIVE_CASTING || attackStyle == AttackStyle.DEFENSIVE) - { - // just use the defense exp to compute the damage - return Utils.getLastDelta(combatExpEarned.get(Skill.DEFENCE)); - } - else - { - // deduct the fixed exp based on the spell - // (for now this only works for dark demon bane which awkwardly gives fractional exp) - final var mageExp = Utils.getLastDelta(combatExpEarned.get(Skill.MAGIC)); - if (curAnimation != AnimationData.MAGIC_ARCEUUS_DEMONBANE) - { - return -1; - } - return (int) Math.ceil(((double) mageExp - 43.5D) / 2.0D); - } - case MELEE_OR_RANGE: - switch (attackStyle) - { - case ACCURATE: - final var attackExp = Utils.getLastDelta(combatExpEarned.get(Skill.ATTACK)); - return (int) ((double) attackExp / 4.0D); - case AGGRESSIVE: - final var strExp = Utils.getLastDelta(combatExpEarned.get(Skill.STRENGTH)); - return (int) ((double) strExp / 4.0D); - case DEFENSIVE: - final var defExp = Utils.getLastDelta(combatExpEarned.get(Skill.DEFENCE)); - return (int) ((double) defExp / 4.0D); - default: - // TODO not needed for any variable speed - return -1; - } - } - return -1; - } - - private int getWeaponId() { final int weaponId = Utils.getWeaponId(client); @@ -334,7 +281,7 @@ private void setAttackDelay() AnimationData curAnimation = AnimationData.fromId(client.getLocalPlayer().getAnimation()); PoweredStaves stave = PoweredStaves.getPoweredStaves(weaponId, curAnimation); boolean matchesSpellbook = matchesSpellbook(curAnimation); - attackDelayHoldoffTicks = getWeaponSpeed(weaponId, stave, curAnimation, matchesSpellbook); + attackDelayHoldoffTicks = getWeaponSpeed(weaponId, stave, curAnimation, currentSpellBook, matchesSpellbook); lastUsedWeaponId = weaponId; } @@ -359,37 +306,34 @@ private int getMagicBaseSpeed(int weaponId) return NON_STANDARD_MAGIC_WEAPON_SPEEDS.getOrDefault(weaponId, 5); } - private int getWeaponSpeed(int weaponId, PoweredStaves stave, AnimationData curAnimation, boolean matchesSpellbook) + private int getWeaponSpeed(int weaponId, PoweredStaves stave, AnimationData curAnimation, Spellbook spellbook, boolean matchesSpellbook) { - var specDelta = Utils.getLastDelta(specialPercentageEvents); - int damageDealt = -1; + final var specDelta = Utils.getLastDelta(specialPercentageEvents); + dmgDealt = DAMAGE.compute(TC); if (stave != null && stave.getAnimations().contains(curAnimation)) { isUsingMagic = true; - damageDealt = computeDamage(Utils.getAttackStyle(client), AttackProcedure.POWERED_STAVE, curAnimation); // We are currently dealing with a staves in which case we can make decisions based on the // spellbook flag. We can only improve this by using a deprecated API to check the projectile // matches the stave rather than a manual spell, but this is good enough for now. - return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.POWERED_STAVE, damageDealt, specDelta, 4); + return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.POWERED_STAVE, spellbook, dmgDealt, specDelta, 4); } if (matchesSpellbook && isManualCasting(curAnimation)) { isUsingMagic = true; - damageDealt = computeDamage(Utils.getAttackStyle(client), AttackProcedure.MANUAL_AUTO_CAST, curAnimation); // You can cast with anything equipped in which case we shouldn't look to invent for speed. - return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MANUAL_AUTO_CAST, damageDealt, specDelta,getMagicBaseSpeed(weaponId)); + return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MANUAL_AUTO_CAST, spellbook, dmgDealt, specDelta,getMagicBaseSpeed(weaponId)); } isUsingMagic = false; - damageDealt = computeDamage(Utils.getAttackStyle(client), AttackProcedure.MELEE_OR_RANGE, curAnimation); ItemStats weaponStats = getWeaponStats(weaponId); if (weaponStats == null) { - return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, damageDealt, specDelta, 4); // Assume barehanded == 4t + return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, spellbook, dmgDealt, specDelta, 4); // Assume barehanded == 4t } // Deadline for next available attack. - return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, damageDealt, specDelta, weaponStats.getEquipment().getAspeed()); + return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, spellbook, dmgDealt, specDelta, weaponStats.getEquipment().getAspeed()); } private static final List SPECIAL_NPCS = Arrays.asList(10507, 9435, 9438, 9441, 9444); // Combat Dummy + Nightmare Pillars @@ -598,9 +542,11 @@ public void onGameTick(GameTick tick) break; case DELAYED_FIRST_TICK: // we stay in this state for one tick to allow for 0-ticking + logStateTrace("onGameTick DELAYED_FIRST_TICK"); attackState = AttackState.DELAYED; // fallthrough case DELAYED: + logStateTrace("onGameTick DELAYED"); if (attackDelayHoldoffTicks <= 0) { // Eligible for a new attack if (isAttacking) @@ -625,13 +571,7 @@ public void onGameTick(GameTick tick) { specialPercentageEvents.removeFirst(); } - for (var q : combatExpEarned.values()) - { - if (q.size() > 5) - { - q.removeFirst(); - } - } + DAMAGE.expire(); } @@ -685,6 +625,7 @@ private StringBuilder getState() sb.append("tickPeriod: "); sb.append(this.tickPeriod);sb.append(SEPARATOR); sb.append("uiHideDebounceTickCount: "); sb.append(this.uiHideDebounceTickCount);sb.append(SEPARATOR); sb.append("attackDelayHoldoffTicks: "); sb.append(this.attackDelayHoldoffTicks);sb.append(SEPARATOR); + sb.append("dmgDealt: "); sb.append(this.dmgDealt);sb.append(SEPARATOR); sb.append("attackState: "); sb.append(this.attackState);sb.append(SEPARATOR); sb.append("renderedState: "); sb.append(this.renderedState);sb.append(SEPARATOR); sb.append("lastTarget: "); sb.append(this.lastTarget == null ? "null" : this.lastTarget.getName());sb.append("\n"); @@ -698,7 +639,6 @@ private StringBuilder getState() private static final String SEPARATOR = ", "; - public void onRender() { final int delta = VariableSpeed.SHADOW_CRASH.onRender(client, attackDelayHoldoffTicks, isUsingMagic, config.debugLogs()); diff --git a/src/main/java/com/attacktimer/Damage.java b/src/main/java/com/attacktimer/Damage.java new file mode 100644 index 0000000..3fcb25d --- /dev/null +++ b/src/main/java/com/attacktimer/Damage.java @@ -0,0 +1,94 @@ +package com.attacktimer; + +/* + * Copyright (c) 2026, Lexer747 + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import com.attacktimer.ClientUtils.Utils; +import com.attacktimer.VariableSpeed.State.TickCount; +import java.util.ArrayDeque; +import net.runelite.api.Skill; +import net.runelite.api.events.FakeXpDrop; +import net.runelite.api.events.StatChanged; + +public class Damage +{ + private static final double MODIFIER = 1; // this is where NPC specific modifiers go + private static final double GLOBAL_MODIFIER = 1; // this is where global specific modifiers go i.e. leagues + + private ArrayDeque hpExpEarned = new ArrayDeque(); + private ArrayDeque hpExpEarnedTickCount = new ArrayDeque(); + + public boolean onXpDrop(StatChanged event, TickCount tc) + { + final var skill = event.getSkill(); + if (skill != Skill.HITPOINTS) + { + return false; + } + hpExpEarnedTickCount.addLast(tc.get()); + hpExpEarned.addLast(event.getXp()); + return true; + } + public boolean onXpDrop(FakeXpDrop event, TickCount tc) + { + final var skill = event.getSkill(); + if (skill != Skill.HITPOINTS) + { + return false; + } + hpExpEarnedTickCount.addLast(tc.get()); + hpExpEarned.addLast(event.getXp()); + return true; + } + + public int compute(TickCount tc) + { + if (hpExpEarnedTickCount.isEmpty()) + { + return -1; + } + final var lastTc = hpExpEarnedTickCount.getLast(); + if (!tc.isWithinNTicks(lastTc, 1)) + { + // In this case the last exp tick wasn't this tick in which case we hit a 0. + return 0; + } + // https://oldschool.runescape.wiki/w/Combat#Experience_gain + final var xp = (double) Utils.getLastDelta(hpExpEarned); + return (int) Math.round(xp * (3.0d / 4.0d) * MODIFIER * GLOBAL_MODIFIER); + } + + public void expire() + { + if (hpExpEarnedTickCount.size() > 5) + { + hpExpEarnedTickCount.removeFirst(); + } + if (hpExpEarned.size() > 5) + { + hpExpEarned.removeFirst(); + } + } +} diff --git a/src/main/java/com/attacktimer/VariableSpeed/BloodMoonSet.java b/src/main/java/com/attacktimer/VariableSpeed/BloodMoonSet.java index e3ad62e..e2b2192 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/BloodMoonSet.java +++ b/src/main/java/com/attacktimer/VariableSpeed/BloodMoonSet.java @@ -27,6 +27,7 @@ import com.attacktimer.AnimationData; import com.attacktimer.AttackProcedure; +import com.attacktimer.Spellbook; import net.runelite.api.Client; public class BloodMoonSet implements IVariableSpeed @@ -34,7 +35,8 @@ public class BloodMoonSet implements IVariableSpeed private static final int BLOOD_MOON_SET_ANIM_ID = 2792; public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, - final int damageDealt, final int lastSpecDelta, final int baseSpeed, final int curSpeed) + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) { if (client.getLocalPlayer().hasSpotAnim(BLOOD_MOON_SET_ANIM_ID)) { diff --git a/src/main/java/com/attacktimer/VariableSpeed/EyeOfAyak.java b/src/main/java/com/attacktimer/VariableSpeed/EyeOfAyak.java index 50ddc57..93317ea 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/EyeOfAyak.java +++ b/src/main/java/com/attacktimer/VariableSpeed/EyeOfAyak.java @@ -27,12 +27,14 @@ import com.attacktimer.AnimationData; import com.attacktimer.AttackProcedure; +import com.attacktimer.Spellbook; import net.runelite.api.Client; public class EyeOfAyak implements IVariableSpeed { public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, - final int damageDealt, final int lastSpecDelta, final int baseSpeed, final int curSpeed) + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) { // https://oldschool.runescape.wiki/w/Eye_of_ayak#Charged // https://oldschool.runescape.wiki/w/Eye_of_ayak#Special_attack diff --git a/src/main/java/com/attacktimer/VariableSpeed/IVariableSpeed.java b/src/main/java/com/attacktimer/VariableSpeed/IVariableSpeed.java index f3ee74d..276ab87 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/IVariableSpeed.java +++ b/src/main/java/com/attacktimer/VariableSpeed/IVariableSpeed.java @@ -27,6 +27,7 @@ import com.attacktimer.AnimationData; import com.attacktimer.AttackProcedure; +import com.attacktimer.Spellbook; import com.attacktimer.VariableSpeed.State.IStateTracker; import net.runelite.api.Client; @@ -56,6 +57,7 @@ public int apply( final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, diff --git a/src/main/java/com/attacktimer/VariableSpeed/PurgingStaffSpec.java b/src/main/java/com/attacktimer/VariableSpeed/PurgingStaffSpec.java index d54bae3..804a91f 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/PurgingStaffSpec.java +++ b/src/main/java/com/attacktimer/VariableSpeed/PurgingStaffSpec.java @@ -28,6 +28,7 @@ import com.attacktimer.AnimationData; import com.attacktimer.AttackProcedure; import com.attacktimer.ClientUtils.Utils; +import com.attacktimer.Spellbook; import com.attacktimer.VariableSpeed.State.Yama; import net.runelite.api.Client; import net.runelite.api.NPC; @@ -47,7 +48,8 @@ public class PurgingStaffSpec implements IVariableSpeed // https://oldschool.runescape.wiki/w/Purging_staff#Special_attack public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, - final int damageDealt, final int lastSpecDelta, final int baseSpeed, final int curSpeed) + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) { // For now the plugin only works for yama if (!this.yama.inYamaRegion) @@ -57,24 +59,17 @@ public int apply(final Client client, final AnimationData curAnimation, final At var target = Utils.getTargetNPC(client); var flare = Yama.isEitherVoidFlare(target, lastTarget); lastTarget = target; - if (flare == null) - { - return curSpeed; - } - if (yama == null) + if (flare == null || yama == null) { return curSpeed; } yama.dealVoidFlareDamage(flare, damageDealt); - if (lastSpecDelta != -250) + if (lastSpecDelta != -250 || Utils.getWeaponId(client) != PURGING_STAFF_ID || spellbook != Spellbook.ARCEUUS) { // not using the spec - return curSpeed; - } - if (Utils.getWeaponId(client) != PURGING_STAFF_ID) - { // not using a purging staff + // not on the arceuss spellbook return curSpeed; } diff --git a/src/main/java/com/attacktimer/VariableSpeed/RapidAttackStyle.java b/src/main/java/com/attacktimer/VariableSpeed/RapidAttackStyle.java index 2b5c80c..2e9aafd 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/RapidAttackStyle.java +++ b/src/main/java/com/attacktimer/VariableSpeed/RapidAttackStyle.java @@ -30,13 +30,15 @@ import com.attacktimer.AttackProcedure; import com.attacktimer.AttackStyle; import com.attacktimer.ClientUtils.Utils; +import com.attacktimer.Spellbook; import net.runelite.api.Client; import net.runelite.api.gameval.VarPlayerID; public class RapidAttackStyle implements IVariableSpeed { public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, - final int damageDealt, final int lastSpecDelta, final int baseSpeed, final int curSpeed) + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) { // index 1 == rapid final boolean isRapid = client.getVarpValue(VarPlayerID.COM_MODE) == 1; diff --git a/src/main/java/com/attacktimer/VariableSpeed/RedKerisSpec.java b/src/main/java/com/attacktimer/VariableSpeed/RedKerisSpec.java index 257e2a4..7dbbb8b 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/RedKerisSpec.java +++ b/src/main/java/com/attacktimer/VariableSpeed/RedKerisSpec.java @@ -27,12 +27,14 @@ import com.attacktimer.AnimationData; import com.attacktimer.AttackProcedure; +import com.attacktimer.Spellbook; import net.runelite.api.Client; public class RedKerisSpec implements IVariableSpeed { public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, - final int damageDealt, final int lastSpecDelta, final int baseSpeed, final int curSpeed) + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) { // https://oldschool.runescape.wiki/w/Keris_partisan_of_corruption#Special_attack if (lastSpecDelta != -750 || curAnimation != AnimationData.MELEE_RED_KERIS_SPEC) diff --git a/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java b/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java new file mode 100644 index 0000000..f953469 --- /dev/null +++ b/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java @@ -0,0 +1,165 @@ +package com.attacktimer.VariableSpeed; + +/* + * Copyright (c) 2026, Lexer747 + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import com.attacktimer.AnimationData; +import com.attacktimer.AttackProcedure; +import com.attacktimer.ClientUtils.Utils; +import com.attacktimer.Spellbook; +import com.google.common.collect.ImmutableSet; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; +import net.runelite.api.Client; +import net.runelite.api.NPC; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.events.GameTick; + +public class RoyalTitans implements IVariableSpeed +{ + + private static final int ROYAL_TITANS_REGION_ID = 11669; + + private static final int FIRE_ELEMENTAL_ID = 14150; + private static final int ICE_ELEMENTAL_ID = 14151; + + private static final int HP_FUDGE = 1; + private static final int ELEMENTAL_HP = 40 - HP_FUDGE; + + private Set iceElementals = new HashSet(); + private Set fireElementals = new HashSet(); + + private static final Set STANDARD_SPELLS = new ImmutableSet.Builder() + .add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST) + .add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF) + .add(AnimationData.MAGIC_STANDARD_STRIKE_MANUAL) + .add(AnimationData.MAGIC_STANDARD_STRIKE_STAFF) + .add(AnimationData.MAGIC_STANDARD_SURGE_STAFF) + .add(AnimationData.MAGIC_STANDARD_WAVE) + .add(AnimationData.MAGIC_STANDARD_WAVE_STAFF) + .build(); + + @Override + public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) + { + if (earlyExit(client, atkType, damageDealt, spellbook)) + { + return curSpeed; + } + final int targetId = Utils.getTargetId(client); + if (spellbook != Spellbook.STANDARD || + !isElemental(targetId) || + !STANDARD_SPELLS.contains(curAnimation)) + { + return curSpeed; + } + // Awkwardly you only got awarded the exp (and therefore computed damage) against the one of the + // elementals, hence the 3x3 AoE isn't seen in the damage dealt. + if (damageDealt < ELEMENTAL_HP) + { + // Don't bother computing partial damage assume most players are one-shotting + return curSpeed; + } + + // Compute the number of elementals in the a 3x3 from our target: + final var set = targetId == FIRE_ELEMENTAL_ID ? fireElementals : iceElementals; + int count = 1; + final var iter = set.iterator(); + if (!iter.hasNext()) + { + return curSpeed - count; + } + final var reference = iter.next().getWorldLocation(); + if (isNpcNearReference(iter, reference)) count++; + if (isNpcNearReference(iter, reference)) count++; + return curSpeed - count; + } + + private static boolean earlyExit(final Client client, final AttackProcedure atkType, final int damageDealt, + final Spellbook spellbook) + { + return inRegion(client) && + atkType != AttackProcedure.MANUAL_AUTO_CAST && + damageDealt <= 0 && + spellbook != Spellbook.STANDARD; + } + + @Override + public void onGameTick(Client client, GameTick tick) + { + if (!inRegion(client)) + { + return; + } + fireElementals.removeIf(npc -> npc.isDead()); + iceElementals.removeIf(npc -> npc.isDead()); + addElementals(client); + } + + private void addElementals(Client client) + { + for (NPC npc : client.getTopLevelWorldView().npcs()) + { + final int id = npc.getId(); + if (!isElemental(id)) + { + continue; + } + if (id == FIRE_ELEMENTAL_ID && !fireElementals.contains(npc)) + { + fireElementals.add(npc); + } + else if (id == ICE_ELEMENTAL_ID && !iceElementals.contains(npc)) + { + iceElementals.add(npc); + } + } + } + + private boolean isNpcNearReference(final Iterator iter, final WorldPoint reference) + { + if (iter.hasNext()) + { + if (reference.distanceTo2D(iter.next().getWorldLocation()) <= 1) + { + return true; + } + } + return false; + } + + private static boolean isElemental(int id) + { + return id == FIRE_ELEMENTAL_ID || id == ICE_ELEMENTAL_ID; + } + + private static boolean inRegion(final Client client) + { + return Utils.getLocation(client).getRegionID() != ROYAL_TITANS_REGION_ID; + } +} diff --git a/src/main/java/com/attacktimer/VariableSpeed/Scurrius.java b/src/main/java/com/attacktimer/VariableSpeed/Scurrius.java index e6b1e6a..36b2d7e 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/Scurrius.java +++ b/src/main/java/com/attacktimer/VariableSpeed/Scurrius.java @@ -28,14 +28,14 @@ import com.attacktimer.AnimationData; import com.attacktimer.AttackProcedure; import com.attacktimer.ClientUtils.Utils; +import com.attacktimer.Spellbook; import net.runelite.api.Client; import net.runelite.api.coords.WorldPoint; /** - * Scurrius: https://oldschool.runescape.wiki/w/Scurrius/Strategies#Strategies - * - * When attacking the giant rats summoned by Scurrius and attacking them with a bone weapon the player has no - * attack delay. + * Scurrius: https://oldschool.runescape.wiki/w/Scurrius/Strategies#Strategies When attacking the + * giant rats summoned by Scurrius and attacking them with a bone weapon the player has no attack + * delay. */ public class Scurrius implements IVariableSpeed { @@ -52,7 +52,8 @@ public class Scurrius implements IVariableSpeed private static final int SCURRIUS_MIN_Y = 9859; private static final int SCURRIUS_MAX_Y = 9876; - private static boolean attackingGiantRatWithBoneWeapon(final int equipped, final int regionId, final int x, final int y, final int target) + private static boolean attackingGiantRatWithBoneWeapon(final int equipped, final int regionId, final int x, + final int y, final int target) { final boolean correctWeapon = equipped == BONE_STAFF_ID || equipped == BONE_MACE_ID || equipped == BONE_BOW_ID; final boolean correctCoords = x >= SCURRIUS_MIN_X && x <= SCURRIUS_MAX_X && y >= SCURRIUS_MIN_Y && y <= SCURRIUS_MAX_Y; @@ -62,12 +63,14 @@ private static boolean attackingGiantRatWithBoneWeapon(final int equipped, final } public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, - final int damageDealt, final int lastSpecDelta, final int baseSpeed, final int curSpeed) + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) { final WorldPoint location = Utils.getLocation(client); final int weaponId = Utils.getWeaponId(client); final int targetId = Utils.getTargetId(client); - if (attackingGiantRatWithBoneWeapon(weaponId, location.getRegionID(), location.getX(), location.getY(), targetId)) + if (attackingGiantRatWithBoneWeapon(weaponId, location.getRegionID(), location.getX(), location.getY(), + targetId)) { return 1; } diff --git a/src/main/java/com/attacktimer/VariableSpeed/TombsOfAmascut.java b/src/main/java/com/attacktimer/VariableSpeed/TombsOfAmascut.java index 26c95fe..6fb07f8 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/TombsOfAmascut.java +++ b/src/main/java/com/attacktimer/VariableSpeed/TombsOfAmascut.java @@ -29,6 +29,7 @@ import com.attacktimer.AttackProcedure; import com.attacktimer.AttackType; import com.attacktimer.ClientUtils.Utils; +import com.attacktimer.Spellbook; import net.runelite.api.Client; /** @@ -41,7 +42,8 @@ public class TombsOfAmascut implements IVariableSpeed private static final int ENERGY_SIPHON_ID = 11772; public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, - final int damageDealt, final int lastSpecDelta, final int baseSpeed, final int curSpeed) + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) { final int targetId = Utils.getTargetId(client); final AttackType attkType = Utils.getAttackType(client); diff --git a/src/main/java/com/attacktimer/VariableSpeed/TormentedDemons.java b/src/main/java/com/attacktimer/VariableSpeed/TormentedDemons.java index e3d732f..21f18f4 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/TormentedDemons.java +++ b/src/main/java/com/attacktimer/VariableSpeed/TormentedDemons.java @@ -29,6 +29,7 @@ import com.attacktimer.AttackProcedure; import com.attacktimer.AttackType; import com.attacktimer.ClientUtils.Utils; +import com.attacktimer.Spellbook; import com.attacktimer.VariableSpeed.State.TickCount; import com.attacktimer.WeaponType; import java.util.ArrayList; @@ -65,7 +66,8 @@ public class TormentedDemons implements IVariableSpeed } public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, - final int damageDealt, final int lastSpecDelta, final int baseSpeed, final int curSpeed) + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) { int targetId = Utils.getTargetId(client); if (!isTormentedDemon(targetId)) diff --git a/src/main/java/com/attacktimer/VariableSpeed/VariableSpeed.java b/src/main/java/com/attacktimer/VariableSpeed/VariableSpeed.java index c4442f1..da1c712 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/VariableSpeed.java +++ b/src/main/java/com/attacktimer/VariableSpeed/VariableSpeed.java @@ -27,9 +27,10 @@ import com.attacktimer.AnimationData; import com.attacktimer.AttackProcedure; +import com.attacktimer.AttackTimerMetronomePlugin; +import com.attacktimer.Spellbook; import com.attacktimer.VariableSpeed.State.IStateTracker; import com.attacktimer.VariableSpeed.State.MarkOfDarkness; -import com.attacktimer.VariableSpeed.State.TickCount; import com.attacktimer.VariableSpeed.State.Yama; import net.runelite.api.Client; import net.runelite.api.events.ChatMessage; @@ -38,16 +39,16 @@ public class VariableSpeed { /** - * computeSpeed will forward the client, animation data and current weapon speed to all the known classes - * which can affect the base speed of a weapon. See implementations of IVariableSpeed. + * computeSpeed will forward the client, animation data and current weapon speed to all the known + * classes which can affect the base speed of a weapon. See implementations of IVariableSpeed. */ public static int computeSpeed(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, - final int damageDealt, final int lastSpecDelta, final int baseSpeed) + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed) { int newSpeed = baseSpeed; for (IVariableSpeed i : TO_APPLY) { - newSpeed = i.apply(client, curAnimation, atkType, damageDealt, lastSpecDelta, baseSpeed, newSpeed); + newSpeed = i.apply(client, curAnimation, atkType, spellbook, damageDealt, lastSpecDelta, baseSpeed, newSpeed); } return newSpeed; } @@ -78,12 +79,11 @@ public static void onChatMessage(final Client client, final ChatMessage event) private static final Yama YAMA = new Yama(); private static final MarkOfDarkness MARK_OF_DARKNESS = new MarkOfDarkness(); - private static final TickCount TC = new TickCount(); private static final IStateTracker[] TO_TRACK = { // State tracking, these do not contribute themselves to any variable speed weapon/mechanic but // provide state tracking which is shared across more than one variable speed weapon/mechanic. - TC, + AttackTimerMetronomePlugin.TC, YAMA, MARK_OF_DARKNESS, }; @@ -98,7 +98,8 @@ public static void onChatMessage(final Client client, final ChatMessage event) new RedKerisSpec(), new PurgingStaffSpec(YAMA), new EyeOfAyak(), - new TormentedDemons(TC), + new TormentedDemons(AttackTimerMetronomePlugin.TC), + new RoyalTitans(), // Overriding modifiers: new Scurrius(), @@ -107,6 +108,6 @@ public static void onChatMessage(final Client client, final ChatMessage event) // Variable speed that doesn't neatly fit in to the IVariable speed pattern (it's not weapon related // but boss related). - public static final ShadowCrash SHADOW_CRASH = new ShadowCrash(YAMA, MARK_OF_DARKNESS, TC); + public static final ShadowCrash SHADOW_CRASH = new ShadowCrash(YAMA, MARK_OF_DARKNESS, AttackTimerMetronomePlugin.TC); } diff --git a/src/test/java/com/attacktimer/testdata/PunishTest.txt b/src/test/java/com/attacktimer/testdata/PunishTest.txt index 1ed8462..e4ac33d 100644 --- a/src/test/java/com/attacktimer/testdata/PunishTest.txt +++ b/src/test/java/com/attacktimer/testdata/PunishTest.txt @@ -1,6 +1,6 @@ -tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null +tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 0, uiHideDebounceTickCount: -1, attackDelayHoldoffTicks: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null +tickPeriod: 0, uiHideDebounceTickCount: -1, attackDelayHoldoffTicks: -1, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: -1, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 diff --git a/src/test/java/com/attacktimer/testdata/PunishWastedTest.txt b/src/test/java/com/attacktimer/testdata/PunishWastedTest.txt index 1ed8462..e4ac33d 100644 --- a/src/test/java/com/attacktimer/testdata/PunishWastedTest.txt +++ b/src/test/java/com/attacktimer/testdata/PunishWastedTest.txt @@ -1,6 +1,6 @@ -tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null +tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 0, uiHideDebounceTickCount: -1, attackDelayHoldoffTicks: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null +tickPeriod: 0, uiHideDebounceTickCount: -1, attackDelayHoldoffTicks: -1, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: -1, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 diff --git a/src/test/java/com/attacktimer/testdata/PunishWastedWrongStyleTest.txt b/src/test/java/com/attacktimer/testdata/PunishWastedWrongStyleTest.txt index cc9f6a5..047348d 100644 --- a/src/test/java/com/attacktimer/testdata/PunishWastedWrongStyleTest.txt +++ b/src/test/java/com/attacktimer/testdata/PunishWastedWrongStyleTest.txt @@ -1,6 +1,6 @@ -tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null +tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 0, uiHideDebounceTickCount: -1, attackDelayHoldoffTicks: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null +tickPeriod: 0, uiHideDebounceTickCount: -1, attackDelayHoldoffTicks: -1, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 8, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 7, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +tickPeriod: 8, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 7, dmgDealt: -1, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 diff --git a/src/test/java/com/attacktimer/testdata/basicTest.txt b/src/test/java/com/attacktimer/testdata/basicTest.txt index 2556b25..f1fc5d5 100644 --- a/src/test/java/com/attacktimer/testdata/basicTest.txt +++ b/src/test/java/com/attacktimer/testdata/basicTest.txt @@ -1,69 +1,69 @@ -tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null +tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 [TEST MESSAGE] 1. Start by setting up the player and plugin [TEST MESSAGE] 2. Mock an attack animation -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: -1, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 [TEST MESSAGE] 3. Check that the plugin has registered the attack [TEST MESSAGE] 4. Check that the plugin counts down correctly -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: -1, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: -1, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: -1, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 [TEST MESSAGE] 5. Check that the plugin is back to a waiting state and it still counts down -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: -1, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: -2, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: -2, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -1, attackDelayHoldoffTicks: -3, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -1, attackDelayHoldoffTicks: -3, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -2, attackDelayHoldoffTicks: -4, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -2, attackDelayHoldoffTicks: -4, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -3, attackDelayHoldoffTicks: -5, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -3, attackDelayHoldoffTicks: -5, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -4, attackDelayHoldoffTicks: -6, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -4, attackDelayHoldoffTicks: -6, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -5, attackDelayHoldoffTicks: -7, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -5, attackDelayHoldoffTicks: -7, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -6, attackDelayHoldoffTicks: -8, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -6, attackDelayHoldoffTicks: -8, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -7, attackDelayHoldoffTicks: -9, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -7, attackDelayHoldoffTicks: -9, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -8, attackDelayHoldoffTicks: -10, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -8, attackDelayHoldoffTicks: -10, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -9, attackDelayHoldoffTicks: -11, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -9, attackDelayHoldoffTicks: -11, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -10, attackDelayHoldoffTicks: -12, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -10, attackDelayHoldoffTicks: -12, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -11, attackDelayHoldoffTicks: -13, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -11, attackDelayHoldoffTicks: -13, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -12, attackDelayHoldoffTicks: -14, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -12, attackDelayHoldoffTicks: -14, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -13, attackDelayHoldoffTicks: -15, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -13, attackDelayHoldoffTicks: -15, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -14, attackDelayHoldoffTicks: -16, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -14, attackDelayHoldoffTicks: -16, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -15, attackDelayHoldoffTicks: -17, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -15, attackDelayHoldoffTicks: -17, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -16, attackDelayHoldoffTicks: -18, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -16, attackDelayHoldoffTicks: -18, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -17, attackDelayHoldoffTicks: -19, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -17, attackDelayHoldoffTicks: -19, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -18, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -18, attackDelayHoldoffTicks: -20, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -19, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -19, attackDelayHoldoffTicks: -20, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -20, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -20, attackDelayHoldoffTicks: -20, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -20, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -20, attackDelayHoldoffTicks: -20, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -20, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -20, attackDelayHoldoffTicks: -20, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -20, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -20, attackDelayHoldoffTicks: -20, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -20, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -20, attackDelayHoldoffTicks: -20, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: -20, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: -20, attackDelayHoldoffTicks: -20, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 diff --git a/src/test/java/com/attacktimer/testdata/eatingFoodTest.txt b/src/test/java/com/attacktimer/testdata/eatingFoodTest.txt index 5ae163e..0d5adea 100644 --- a/src/test/java/com/attacktimer/testdata/eatingFoodTest.txt +++ b/src/test/java/com/attacktimer/testdata/eatingFoodTest.txt @@ -1,34 +1,34 @@ -tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null +tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 [TEST MESSAGE] 1. Start by setting up the player and plugin [TEST MESSAGE] 2. Mock an attack animation -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: -1, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 [TEST MESSAGE] 3. Check that the plugin has registered the attack -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: -1, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 [TEST MESSAGE] Perform an eat -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: -1, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 3, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 [TEST MESSAGE] Next game tick -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 5, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 5, dmgDealt: -1, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 [TEST MESSAGE] Perform a fast eat -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 5, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 5, dmgDealt: -1, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 2, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 [TEST MESSAGE] Next game tick -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 6, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 6, dmgDealt: -1, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 [TEST MESSAGE] 4. Check that the plugin counts down correctly -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 5, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 5, dmgDealt: -1, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 4, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 4, dmgDealt: -1, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: -1, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: -1, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: -1, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: -1, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: mockedNpc pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 From 0181a1b14ebe1dcbf24bd4b162842b60ddf9430e Mon Sep 17 00:00:00 2001 From: Lexer747 Date: Sat, 1 Aug 2026 13:47:26 +0100 Subject: [PATCH 2/7] v2 --- .../AttackTimerMetronomePlugin.java | 338 ++++++++++-------- .../VariableSpeed/RoyalTitans.java | 119 ++++-- .../VariableSpeed/State/IStateTracker.java | 18 + .../attacktimer/VariableSpeed/State/Yama.java | 29 +- .../VariableSpeed/TormentedDemons.java | 34 +- .../VariableSpeed/VariableSpeed.java | 26 ++ 6 files changed, 365 insertions(+), 199 deletions(-) diff --git a/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java b/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java index 593c57b..9b9c5e4 100644 --- a/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java +++ b/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java @@ -1,6 +1,5 @@ package com.attacktimer; - /* * Copyright (c) 2022, Nick Graves * Copyright (c) 2024-2026, Lexer747 @@ -55,6 +54,8 @@ import net.runelite.api.events.FakeXpDrop; import net.runelite.api.events.GameTick; import net.runelite.api.events.InteractingChanged; +import net.runelite.api.events.NpcDespawned; +import net.runelite.api.events.NpcSpawned; import net.runelite.api.events.SoundEffectPlayed; import net.runelite.api.events.StatChanged; import net.runelite.api.events.VarbitChanged; @@ -72,18 +73,15 @@ import net.runelite.client.ui.overlay.OverlayManager; @Slf4j -@PluginDescriptor( - name = "Attack Timer Metronome", - description = "Shows a visual cue on an overlay every game tick to help timing based activities", - tags = {"timers", "overlays", "tick", "skilling"} -) +@PluginDescriptor(name = "Attack Timer Metronome", description = "Shows a visual cue on an overlay every game tick to help timing based activities", tags = +{ + "timers", "overlays", "tick", "skilling" +}) public class AttackTimerMetronomePlugin extends Plugin { public enum AttackState { - NOT_ATTACKING, - DELAYED_FIRST_TICK, - DELAYED, + NOT_ATTACKING, DELAYED_FIRST_TICK, DELAYED, } @Inject @@ -116,8 +114,10 @@ public enum AttackState public int attackDelayHoldoffTicks = ATTACK_DELAY_NONE; public AttackState attackState = AttackState.NOT_ATTACKING; - // The state of the renderer, will lag a few cycles behind the plugin's state. "cycles" in this comment - // refers to the client.getGameCycle() method, a cycle occurs every 20ms, meaning 30 of them occur per + // The state of the renderer, will lag a few cycles behind the plugin's state. "cycles" in this + // comment + // refers to the client.getGameCycle() method, a cycle occurs every 20ms, meaning 30 of them occur + // per // game tick. public AttackState renderedState = AttackState.NOT_ATTACKING; @@ -142,44 +142,35 @@ public enum AttackState private static final int ATTACK_DELAY_NONE = 0; public static final int DEFAULT_SIZE_UNIT_PX = 25; - public static final int SALAMANDER_SET_ANIM_ID = 952; // Used by all 4 types of salamander https://oldschool.runescape.wiki/w/Salamander + public static final int SALAMANDER_SET_ANIM_ID = 952; // Used by all 4 types of salamander + // https://oldschool.runescape.wiki/w/Salamander private static final int TWINFLAME_STAFF_WEAPON_ID = 30634; private static final int ECHO_VENATOR_BOW_WEAPON_ID = 30434; private static final int VENATOR_BOW_WEAPON_ID = 27610; private static final int HALLOWFELL_ID = 34027; // https://oldschool.runescape.wiki/w/Hallowfell - // Add other weapons here if in the Runelite dev shell this prints a different value to it's actual speed: + // Add other weapons here if in the Runelite dev shell this prints a different value to it's actual + // speed: // - // var itemManager = inject(ItemManager.class); - // log.info("Speed {}", itemManager.getItemStats().getEquipment().getAspeed()); - private static final Map NON_STANDARD_ATTACK_SPEEDS = - new ImmutableMap.Builder() - .put(HALLOWFELL_ID, 6) - .build(); + // var itemManager = inject(ItemManager.class); + // log.info("Speed {}", itemManager.getItemStats().getEquipment().getAspeed()); + private static final Map NON_STANDARD_ATTACK_SPEEDS = new ImmutableMap.Builder() + .put(HALLOWFELL_ID, 6).build(); // These animations are the ones which exceed the duration of their attack cooldown // so in this case DO NOT fall back the animation as it is un-reliable. private static final Set UNRELIABLE_ANIMATIONS = new ImmutableSet.Builder() - .add(AnimationData.RANGED_BLOWPIPE) - .add(AnimationData.RANGED_BLAZING_BLOWPIPE) - .add(AnimationData.MAGIC_EYE_OF_AYAK ) - .add(AnimationData.MAGIC_EYE_OF_AYAK_SPEC) - .build(); + .add(AnimationData.RANGED_BLOWPIPE).add(AnimationData.RANGED_BLAZING_BLOWPIPE) + .add(AnimationData.MAGIC_EYE_OF_AYAK).add(AnimationData.MAGIC_EYE_OF_AYAK_SPEC).build(); - - private static final Map NON_STANDARD_MAGIC_WEAPON_SPEEDS = - new ImmutableMap.Builder() - .put(TWINFLAME_STAFF_WEAPON_ID, 6) - .build(); + private static final Map NON_STANDARD_MAGIC_WEAPON_SPEEDS = new ImmutableMap.Builder() + .put(TWINFLAME_STAFF_WEAPON_ID, 6).build(); // Map of problematic itemIds to equivalent working ones. // The Echo Venator Bow's ItemStats are returning null, so use the regular bow instead. - private static final Map WEAPON_ID_MAPPING_WORKAROUNDS = - new ImmutableMap.Builder() - .put(ECHO_VENATOR_BOW_WEAPON_ID, VENATOR_BOW_WEAPON_ID) - .build(); - + private static final Map WEAPON_ID_MAPPING_WORKAROUNDS = new ImmutableMap.Builder() + .put(ECHO_VENATOR_BOW_WEAPON_ID, VENATOR_BOW_WEAPON_ID).build(); // https://oldschool.runescape.wiki/w/Food/Fast_foods#Food_Delays // These constants are not to be confused with eat delay. @@ -187,14 +178,14 @@ public enum AttackState private final int DEFAULT_FOOD_ATTACK_DELAY_TICKS = 3; private final int FAST_EAT_ATTACK_DELAY_TICKS = 2; - public static final int EQUIPPING_MONOTONIC = 384; // From empirical testing this clientint seems to always increase whenever the player equips an item + public static final int EQUIPPING_MONOTONIC = 384; // From empirical testing this clientint seems to always increase + // whenever the player equips an item public static final Dimension DEFAULT_SIZE = new Dimension(DEFAULT_SIZE_UNIT_PX, DEFAULT_SIZE_UNIT_PX); - // region subscribers @Subscribe - public void onVarbitChanged(VarbitChanged varbitChanged) + public void onVarbitChanged(final VarbitChanged varbitChanged) { if (varbitChanged.getVarbitId() == VarbitID.SPELLBOOK) { @@ -206,21 +197,26 @@ public void onVarbitChanged(VarbitChanged varbitChanged) } } - // onSoundEffectPlayed used to track spell casts, for when the player casts a spell on first tick coming - // off cooldown, in some cases (e.g. ice barrage) the player will have no animation. Also they don't have + // onSoundEffectPlayed used to track spell casts, for when the player casts a spell on first tick + // coming + // off cooldown, in some cases (e.g. ice barrage) the player will have no animation. Also they don't + // have // a projectile to detect instead :/ @Subscribe - public void onSoundEffectPlayed(SoundEffectPlayed event) + public void onSoundEffectPlayed(final SoundEffectPlayed event) { - if (!config.enableMetronome()) return; + if (!config.enableMetronome()) + return; // event.getSource() will be null if the player cast a spell, it's only for area sounds. soundEffectTick = client.getTickCount(); soundEffectId = event.getSoundId(); } @Subscribe - protected void onFakeXpDrop(FakeXpDrop event) + protected void onFakeXpDrop(final FakeXpDrop event) { + if (!config.enableMetronome()) + return; if (DAMAGE.onXpDrop(event, TC)) { if (inPreAttackWindow()) @@ -233,8 +229,10 @@ protected void onFakeXpDrop(FakeXpDrop event) } @Subscribe - protected void onStatChanged(StatChanged event) + protected void onStatChanged(final StatChanged event) { + if (!config.enableMetronome()) + return; if (DAMAGE.onXpDrop(event, TC)) { if (inPreAttackWindow()) @@ -246,6 +244,62 @@ protected void onStatChanged(StatChanged event) } } + @Subscribe + public void onNpcSpawned(final NpcSpawned npcSpawned) + { + if (!config.enableMetronome()) + return; + log.debug("[AttackTimer] onNpcSpawned {}", npcSpawned.getNpc().getName()); + VariableSpeed.onNpcSpawned(client, npcSpawned); + }; + + @Subscribe + public void onNpcDespawned(final NpcDespawned npcDespawned) + { + if (!config.enableMetronome()) + return; + log.debug("[AttackTimer] onNpcDespawned {}", npcDespawned.getNpc().getName()); + VariableSpeed.onNpcDespawned(client, npcDespawned); + }; + + @Subscribe + public void onConfigChanged(ConfigChanged event) + { + if (event.getGroup().equals("attacktimermetronome")) + { + attackDelayHoldoffTicks = 0; + } + } + + @Subscribe + public void onChatMessage(final ChatMessage event) + { + if (!config.enableMetronome()) + return; + final String message = event.getMessage(); + + if (EAT_MESSAGE.matcher(message).find()) + { + int attackDelay; + if (FAST_EAT.matcher(message).find()) + { + attackDelay = FAST_EAT_ATTACK_DELAY_TICKS; + } + else if (SLOW_FOOD.matcher(message).find()) + { + attackDelay = SLOW_FOOD_ATTACK_DELAY_TICKS; + } + else + { + attackDelay = DEFAULT_FOOD_ATTACK_DELAY_TICKS; + } + + // We should always add eat delay + pendingEatDelayTicks += attackDelay; + } + VariableSpeed.onChatMessage(client, event); + } + // endregion @Provides @@ -286,7 +340,8 @@ private void setAttackDelay() } // matchesSpellbook tries two methods, matching the animation the spell book based on the enum of - // pre-coded matches, and then the second set of matches against the known sound id of the spell (which + // pre-coded matches, and then the second set of matches against the known sound id of the spell + // (which // unfortunately doesn't work if the player has them disabled). private boolean matchesSpellbook(AnimationData curAnimation) { @@ -306,7 +361,8 @@ private int getMagicBaseSpeed(int weaponId) return NON_STANDARD_MAGIC_WEAPON_SPEEDS.getOrDefault(weaponId, 5); } - private int getWeaponSpeed(int weaponId, PoweredStaves stave, AnimationData curAnimation, Spellbook spellbook, boolean matchesSpellbook) + private int getWeaponSpeed(int weaponId, PoweredStaves stave, AnimationData curAnimation, Spellbook spellbook, + boolean matchesSpellbook) { final var specDelta = Utils.getLastDelta(specialPercentageEvents); dmgDealt = DAMAGE.compute(TC); @@ -316,27 +372,32 @@ private int getWeaponSpeed(int weaponId, PoweredStaves stave, AnimationData curA // We are currently dealing with a staves in which case we can make decisions based on the // spellbook flag. We can only improve this by using a deprecated API to check the projectile // matches the stave rather than a manual spell, but this is good enough for now. - return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.POWERED_STAVE, spellbook, dmgDealt, specDelta, 4); + return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.POWERED_STAVE, spellbook, dmgDealt, + specDelta, 4); } if (matchesSpellbook && isManualCasting(curAnimation)) { isUsingMagic = true; // You can cast with anything equipped in which case we shouldn't look to invent for speed. - return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MANUAL_AUTO_CAST, spellbook, dmgDealt, specDelta,getMagicBaseSpeed(weaponId)); + return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MANUAL_AUTO_CAST, spellbook, + dmgDealt, specDelta, getMagicBaseSpeed(weaponId)); } isUsingMagic = false; ItemStats weaponStats = getWeaponStats(weaponId); if (weaponStats == null) { - return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, spellbook, dmgDealt, specDelta, 4); // Assume barehanded == 4t + return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, spellbook, dmgDealt, + specDelta, 4); // Assume barehanded == 4t } // Deadline for next available attack. - return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, spellbook, dmgDealt, specDelta, weaponStats.getEquipment().getAspeed()); + return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, spellbook, dmgDealt, + specDelta, weaponStats.getEquipment().getAspeed()); } - private static final List SPECIAL_NPCS = Arrays.asList(10507, 9435, 9438, 9441, 9444); // Combat Dummy + Nightmare Pillars + // Combat Dummy + Nightmare Pillars + private static final List SPECIAL_NPCS = Arrays.asList(10507, 9435, 9438, 9441, 9444); private boolean isPlayerAttacking() { @@ -347,20 +408,22 @@ private boolean isPlayerAttacking() return false; } - // Not walking is either ANY player animation or the edge cases which don't trigger an animation, e.g Salamander. + // Not walking is either ANY player animation or the edge cases which don't trigger an animation, e.g + // Salamander. final boolean notWalking = animationId != -1 || getSalamanderAttack(); - // Testing if we are attacking by checking the target is more future - // proof to new weapons which don't need custom code and the weapon - // stats are enough. + // Testing if we are attacking by checking the target is more future proof to new weapons which don't + // need custom code and the weapon stats are enough. final Actor target = localPlayer.getInteracting(); if (target != null && (target instanceof NPC)) { final NPC npc = (NPC) target; - final boolean containsAttackOption = Arrays.stream(npc.getComposition().getActions()).anyMatch("Attack"::equals); + final boolean containsAttackOption = Arrays.stream(npc.getComposition().getActions()) + .anyMatch("Attack"::equals); final Integer health = npcManager.getHealth(npc.getId()); final boolean hasHealthAndLevel = health != null && health > 0 && target.getCombatLevel() > 0; - final boolean attackingNPC = hasHealthAndLevel || SPECIAL_NPCS.contains(npc.getId()) || containsAttackOption; + final boolean attackingNPC = hasHealthAndLevel || SPECIAL_NPCS.contains(npc.getId()) + || containsAttackOption; // just having a target is not enough the player may be out of range, we must wait for any // animation which isn't running/walking/etc return attackingNPC && notWalking; @@ -393,7 +456,9 @@ private boolean isManualCasting(AnimationData curId) // to detect this type of attack as a cast, only sound is an indication that the player is on // cooldown, melee attacks, etc will trigger an animation overwriting the last frame of the blowpipe's // idle animation. - final boolean castingFromSound = client.getTickCount() == soundEffectTick ? CastingSoundData.isCastingSound(soundEffectId) : false; + final boolean castingFromSound = client.getTickCount() == soundEffectTick + ? CastingSoundData.isCastingSound(soundEffectId) + : false; final boolean castingFromAnimation = AnimationData.isManualCasting(curId); return castingFromSound || castingFromAnimation; } @@ -419,71 +484,48 @@ public int getWeaponPeriod() public boolean isAttackCooldownPending() { - return attackState == AttackState.DELAYED - || attackState == AttackState.DELAYED_FIRST_TICK - || uiHideDebounceTickCount > 0; + return attackState == AttackState.DELAYED || attackState == AttackState.DELAYED_FIRST_TICK + || uiHideDebounceTickCount > 0; } - private static final String GENERIC_EAT = "You eat"; // unfortunately you don't get any message when full HP private static final String VAMPYRIUM_EAT = "Your stomach doesn't like it... but it heals some health"; // https://oldschool.runescape.wiki/w/Stymphike_tartare - private static final String BARBARIAN_POTIONS = "You drink the lumpy potion"; // barbarian potions https://oldschool.runescape.wiki/w/Barbarian_Training#Barbarian_potions - private static final String JUG_OF_WINE = "You drink the wine"; // Wine https://oldschool.runescape.wiki/w/Jug_of_wine + private static final String BARBARIAN_POTIONS = "You drink the lumpy potion"; // barbarian potions + // https://oldschool.runescape.wiki/w/Barbarian_Training#Barbarian_potions + private static final String JUG_OF_WINE = "You drink the wine"; // Wine + // https://oldschool.runescape.wiki/w/Jug_of_wine // Match only the start of the line with `^` and the Pattern.MULTILINE - private static final Pattern EAT_MESSAGE = Pattern - .compile("^(" + GENERIC_EAT + "|" + BARBARIAN_POTIONS + "|" + JUG_OF_WINE + "|" + VAMPYRIUM_EAT + ")", Pattern.MULTILINE & Pattern.CASE_INSENSITIVE); + private static final Pattern EAT_MESSAGE = Pattern.compile( + "^(" + GENERIC_EAT + "|" + BARBARIAN_POTIONS + "|" + JUG_OF_WINE + "|" + VAMPYRIUM_EAT + ")", + Pattern.MULTILINE & Pattern.CASE_INSENSITIVE); // - private static final Pattern SLOW_FOOD = Pattern - .compile("^(" + VAMPYRIUM_EAT + ")", Pattern.MULTILINE & Pattern.CASE_INSENSITIVE); + private static final Pattern SLOW_FOOD = Pattern.compile("^(" + VAMPYRIUM_EAT + ")", + Pattern.MULTILINE & Pattern.CASE_INSENSITIVE); - - // gnome foods are also fast eats (Note these are not the food names as the wiki lists them, but the name + // gnome foods are also fast eats (Note these are not the food names as the wiki lists them, but the + // name // as written in chat), also pre-made and handmade have the same chat message. private static final String FAST_GNOME_FOOD = "worm hole|tangled toads legs|veg ball|chocolate bomb|worm crunchies|toad crunchies|" + "choc chip crunchies|spicy crunchies|fruit batta|cheese and tomato batta|toad batta|vegetable batta|worm batta"; private static final String FAST_FOOD = "karambwan|halibut"; - // Unfortunately these have just the generic "You eat the food." so there is no easy way to tell if you + // Unfortunately these have just the generic "You eat the food." so there is no easy way to tell if + // you // have the quicker eat delay. https://oldschool.runescape.wiki/w/Crystal_paddlefish and // https://oldschool.runescape.wiki/w/Corrupted_paddlefish - private static final Pattern FAST_EAT = Pattern.compile("(" + FAST_FOOD + "|" + FAST_GNOME_FOOD + ")", Pattern.CASE_INSENSITIVE); - - @Subscribe - public void onChatMessage(ChatMessage event) - { - if (!config.enableMetronome()) return; - final String message = event.getMessage(); - - if (EAT_MESSAGE.matcher(message).find()) - { - int attackDelay; - if (FAST_EAT.matcher(message).find()) - { - attackDelay = FAST_EAT_ATTACK_DELAY_TICKS; - } - else if (SLOW_FOOD.matcher(message).find()) - { - attackDelay = SLOW_FOOD_ATTACK_DELAY_TICKS; - } - else - { - attackDelay = DEFAULT_FOOD_ATTACK_DELAY_TICKS; - } - - // We should always add eat delay - pendingEatDelayTicks += attackDelay; - } - VariableSpeed.onChatMessage(client, event); - } + private static final Pattern FAST_EAT = Pattern.compile("(" + FAST_FOOD + "|" + FAST_GNOME_FOOD + ")", + Pattern.CASE_INSENSITIVE); - // onInteractingChanged is the driver for detecting if the player attacked out side the usual tick window + // onInteractingChanged is the driver for detecting if the player attacked out side the usual tick + // window // of the onGameTick events. @Subscribe public void onInteractingChanged(InteractingChanged interactingChanged) { - if (!config.enableMetronome()) return; + if (!config.enableMetronome()) + return; Actor source = interactingChanged.getSource(); Actor target = interactingChanged.getTarget(); @@ -493,21 +535,21 @@ public void onInteractingChanged(InteractingChanged interactingChanged) { switch (attackState) { - case NOT_ATTACKING: - isUsingMagic = false; - // If not previously attacking, this action can result in a queued attack or - // an instant attack. If its queued, don't trigger the cooldown yet. - if (isPlayerAttacking()) - { - logStateTrace("onInteractingChanged"); - performAttack(); - } - break; - case DELAYED_FIRST_TICK: - // fallthrough - case DELAYED: - // Don't reset tick counter or tick period. - break; + case NOT_ATTACKING: + isUsingMagic = false; + // If not previously attacking, this action can result in a queued attack or + // an instant attack. If its queued, don't trigger the cooldown yet. + if (isPlayerAttacking()) + { + logStateTrace("onInteractingChanged"); + performAttack(); + } + break; + case DELAYED_FIRST_TICK: + // fallthrough + case DELAYED: + // Don't reset tick counter or tick period. + break; } } @@ -524,41 +566,42 @@ private void applyAndClearEats() @Subscribe public void onGameTick(GameTick tick) { - if (!config.enableMetronome()) return; + if (!config.enableMetronome()) + return; VariableSpeed.onGameTick(client, tick); boolean isAttacking = isPlayerAttacking(); switch (attackState) { - case NOT_ATTACKING: + case NOT_ATTACKING: + if (isAttacking) + { + logStateTrace("onGameTick"); + performAttack(); // Sets state to DELAYED_FIRST_TICK. + } + else + { + uiHideDebounceTickCount = Math.max(-20, uiHideDebounceTickCount - 1); + } + break; + case DELAYED_FIRST_TICK: + // we stay in this state for one tick to allow for 0-ticking + logStateTrace("onGameTick DELAYED_FIRST_TICK"); + attackState = AttackState.DELAYED; + // fallthrough + case DELAYED: + logStateTrace("onGameTick DELAYED"); + if (attackDelayHoldoffTicks <= 0) + { // Eligible for a new attack if (isAttacking) { logStateTrace("onGameTick"); - performAttack(); // Sets state to DELAYED_FIRST_TICK. + performAttack(); } else { - uiHideDebounceTickCount = Math.max(-20, uiHideDebounceTickCount - 1); - } - break; - case DELAYED_FIRST_TICK: - // we stay in this state for one tick to allow for 0-ticking - logStateTrace("onGameTick DELAYED_FIRST_TICK"); - attackState = AttackState.DELAYED; - // fallthrough - case DELAYED: - logStateTrace("onGameTick DELAYED"); - if (attackDelayHoldoffTicks <= 0) - { // Eligible for a new attack - if (isAttacking) - { - logStateTrace("onGameTick"); - performAttack(); - } - else - { - attackState = AttackState.NOT_ATTACKING; - } + attackState = AttackState.NOT_ATTACKING; } + } } // This needs to come after performAttack as it's an additive affect @@ -574,16 +617,6 @@ public void onGameTick(GameTick tick) DAMAGE.expire(); } - - @Subscribe - public void onConfigChanged(ConfigChanged event) - { - if (event.getGroup().equals("attacktimermetronome")) - { - attackDelayHoldoffTicks = 0; - } - } - @Override protected void startUp() throws Exception { @@ -615,7 +648,7 @@ public void logStateTrace(String trace) return; } StringBuilder sb = getState(); - log.debug("["+trace+"]: "+sb.toString()); + log.debug("[" + trace + "]: " + sb.toString()); } private StringBuilder getState() @@ -641,7 +674,8 @@ private StringBuilder getState() public void onRender() { - final int delta = VariableSpeed.SHADOW_CRASH.onRender(client, attackDelayHoldoffTicks, isUsingMagic, config.debugLogs()); + final int delta = VariableSpeed.SHADOW_CRASH.onRender(client, attackDelayHoldoffTicks, isUsingMagic, + config.debugLogs()); if (delta != 0) { diff --git a/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java b/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java index f953469..8dc13b1 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java +++ b/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java @@ -28,16 +28,21 @@ import com.attacktimer.AnimationData; import com.attacktimer.AttackProcedure; import com.attacktimer.ClientUtils.Utils; +import com.attacktimer.VariableSpeed.State.IStateTracker; import com.attacktimer.Spellbook; import com.google.common.collect.ImmutableSet; import java.util.HashSet; import java.util.Iterator; import java.util.Set; +import lombok.extern.slf4j.Slf4j; import net.runelite.api.Client; import net.runelite.api.NPC; import net.runelite.api.coords.WorldPoint; import net.runelite.api.events.GameTick; +import net.runelite.api.events.NpcDespawned; +import net.runelite.api.events.NpcSpawned; +@Slf4j public class RoyalTitans implements IVariableSpeed { @@ -52,11 +57,11 @@ public class RoyalTitans implements IVariableSpeed private Set iceElementals = new HashSet(); private Set fireElementals = new HashSet(); + private boolean removeDead = false; + private static final Set STANDARD_SPELLS = new ImmutableSet.Builder() .add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST) .add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF) - .add(AnimationData.MAGIC_STANDARD_STRIKE_MANUAL) - .add(AnimationData.MAGIC_STANDARD_STRIKE_STAFF) .add(AnimationData.MAGIC_STANDARD_SURGE_STAFF) .add(AnimationData.MAGIC_STANDARD_WAVE) .add(AnimationData.MAGIC_STANDARD_WAVE_STAFF) @@ -78,32 +83,55 @@ public int apply(final Client client, final AnimationData curAnimation, final At { return curSpeed; } + final NPC target = Utils.getTargetNPC(client); + if (target == null) + { + return curSpeed; + } + // We are now in the royal titan region, attacking an elemental using magic and one of the standard + // spells which could one shot it. + log.debug("[RoyalTitans] Attacking elemental With correct spell"); // Awkwardly you only got awarded the exp (and therefore computed damage) against the one of the // elementals, hence the 3x3 AoE isn't seen in the damage dealt. if (damageDealt < ELEMENTAL_HP) { + log.debug("[RoyalTitans] didn't do enough damage"); // Don't bother computing partial damage assume most players are one-shotting return curSpeed; } - + log.debug("[RoyalTitans] enough damage"); // Compute the number of elementals in the a 3x3 from our target: final var set = targetId == FIRE_ELEMENTAL_ID ? fireElementals : iceElementals; + log.debug("[RoyalTitans] elemental set: {}", set); int count = 1; - final var iter = set.iterator(); - if (!iter.hasNext()) + final var reference = target.getWorldLocation(); + log.debug("[RoyalTitans] reference {}", reference); + for (final NPC elemental : set) { - return curSpeed - count; + if (elemental == target) + { + log.debug("[RoyalTitans] distance check skipped - is target"); + continue; + } + final WorldPoint worldLocation = elemental.getWorldLocation(); + final int distanceTo2D = reference.distanceTo2D(worldLocation); + log.debug("[RoyalTitans] distance check new {}, distance {}", worldLocation, distanceTo2D); + if (distanceTo2D <= 1) + { + count++; + } } - final var reference = iter.next().getWorldLocation(); - if (isNpcNearReference(iter, reference)) count++; - if (isNpcNearReference(iter, reference)) count++; + log.debug("[RoyalTitans] success, reduced by {}", count); + // despawn happens much later than is dead (hence how Entity Hider works) so we need to remove them + // now if we succeeded in apply. Deferred till the next onGameTick is called. + removeDead = true; return curSpeed - count; } private static boolean earlyExit(final Client client, final AttackProcedure atkType, final int damageDealt, final Spellbook spellbook) { - return inRegion(client) && + return notInRegion(client) && atkType != AttackProcedure.MANUAL_AUTO_CAST && damageDealt <= 0 && spellbook != Spellbook.STANDARD; @@ -112,45 +140,60 @@ private static boolean earlyExit(final Client client, final AttackProcedure atkT @Override public void onGameTick(Client client, GameTick tick) { - if (!inRegion(client)) + if (removeDead) { - return; + var before = iceElementals.size() + fireElementals.size(); + var removed = iceElementals.removeIf(npc -> npc.isDead()); + removed |= fireElementals.removeIf(npc -> npc.isDead()); + var after = iceElementals.size() + fireElementals.size(); + if (removed) + { + log.debug("[RoyalTitans] removed dead elementals in onGameTick - before {}, after {}", before, after); + removeDead = false; + } } - fireElementals.removeIf(npc -> npc.isDead()); - iceElementals.removeIf(npc -> npc.isDead()); - addElementals(client); } - private void addElementals(Client client) + @Override + public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned) { - for (NPC npc : client.getTopLevelWorldView().npcs()) + if (notInRegion(client)) { - final int id = npc.getId(); - if (!isElemental(id)) - { - continue; - } - if (id == FIRE_ELEMENTAL_ID && !fireElementals.contains(npc)) - { - fireElementals.add(npc); - } - else if (id == ICE_ELEMENTAL_ID && !iceElementals.contains(npc)) - { - iceElementals.add(npc); - } + return; + } + final NPC npc = npcSpawned.getNpc(); + final int id = npc.getId(); + if (id == ICE_ELEMENTAL_ID) + { + log.debug("[RoyalTitans] added ice elemental"); + iceElementals.add(npc); + } + else if (id == FIRE_ELEMENTAL_ID) + { + log.debug("[RoyalTitans] added fire elemental"); + fireElementals.add(npc); } } - private boolean isNpcNearReference(final Iterator iter, final WorldPoint reference) + @Override + public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned) { - if (iter.hasNext()) + if (notInRegion(client)) { - if (reference.distanceTo2D(iter.next().getWorldLocation()) <= 1) - { - return true; - } + return; + } + final NPC npc = npcDespawned.getNpc(); + final int id = npc.getId(); + if (id == ICE_ELEMENTAL_ID) + { + log.debug("[RoyalTitans] removed ice elemental"); + iceElementals.remove(npc); + } + else if (id == FIRE_ELEMENTAL_ID) + { + log.debug("[RoyalTitans] removed fire elemental"); + fireElementals.remove(npc); } - return false; } private static boolean isElemental(int id) @@ -158,7 +201,7 @@ private static boolean isElemental(int id) return id == FIRE_ELEMENTAL_ID || id == ICE_ELEMENTAL_ID; } - private static boolean inRegion(final Client client) + private static boolean notInRegion(final Client client) { return Utils.getLocation(client).getRegionID() != ROYAL_TITANS_REGION_ID; } diff --git a/src/main/java/com/attacktimer/VariableSpeed/State/IStateTracker.java b/src/main/java/com/attacktimer/VariableSpeed/State/IStateTracker.java index 9c5f4d2..b7a06b7 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/State/IStateTracker.java +++ b/src/main/java/com/attacktimer/VariableSpeed/State/IStateTracker.java @@ -28,6 +28,8 @@ import net.runelite.api.Client; import net.runelite.api.events.ChatMessage; import net.runelite.api.events.GameTick; +import net.runelite.api.events.NpcDespawned; +import net.runelite.api.events.NpcSpawned; public interface IStateTracker { @@ -50,4 +52,20 @@ default public void onGameTick(final Client client, final GameTick tick) */ default public void onChatMessage(final Client client, final ChatMessage event) {}; + + /** + * TODO + * + * @param npcSpawned + */ + default public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned) + {}; + + /** + * TODO + * + * @param npcDespawned + */ + default public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned) + {}; } diff --git a/src/main/java/com/attacktimer/VariableSpeed/State/Yama.java b/src/main/java/com/attacktimer/VariableSpeed/State/Yama.java index 52c7fe3..90c4dfb 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/State/Yama.java +++ b/src/main/java/com/attacktimer/VariableSpeed/State/Yama.java @@ -33,6 +33,7 @@ import net.runelite.api.NPC; import net.runelite.api.events.ChatMessage; import net.runelite.api.events.GameTick; +import net.runelite.api.events.NpcSpawned; /** * Yama tracks various bits of state related to the boss https://oldschool.runescape.wiki/w/Yama. @@ -54,6 +55,7 @@ public class Yama implements IStateTracker public boolean inYamaRegion; public NPC lastTarget; + @Override public void onGameTick(Client client, GameTick tick) { inYamaRegion = Utils.isInRegionId(client, YAMA_REGION_ID); @@ -67,16 +69,9 @@ public void onGameTick(Client client, GameTick tick) yama.determineYamaPhase(); return; } - for (NPC npc : client.getTopLevelWorldView().npcs()) - { - if (npc.getId() == YAMA_ID) - { - yama = new YamaData(npc); - return; - } - } } + @Override public void onChatMessage(Client client, ChatMessage event) { if (!inYamaRegion || yama == null) @@ -89,6 +84,24 @@ public void onChatMessage(Client client, ChatMessage event) } } + @Override + public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned) + { + if (!inYamaRegion) + { + return; + } + if (yama != null) + { + return; + } + final NPC npc = npcSpawned.getNpc(); + if (npc.getId() == YAMA_ID) + { + yama = new YamaData(npc); + } + } + public static NPC isEitherVoidFlare(NPC a, NPC b) { if (a != null && a.getId() == VOID_FLARE_ID) diff --git a/src/main/java/com/attacktimer/VariableSpeed/TormentedDemons.java b/src/main/java/com/attacktimer/VariableSpeed/TormentedDemons.java index 21f18f4..2337c93 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/TormentedDemons.java +++ b/src/main/java/com/attacktimer/VariableSpeed/TormentedDemons.java @@ -39,6 +39,8 @@ import net.runelite.api.Client; import net.runelite.api.NPC; import net.runelite.api.events.GameTick; +import net.runelite.api.events.NpcDespawned; +import net.runelite.api.events.NpcSpawned; /** * TormentedDemons is the variable speed implementation for the "punish" attack a player can do against a @@ -136,6 +138,7 @@ private static boolean isTormentedDemon(int targetId) private Map tormentedDemons = new HashMap(); + @Override public void onGameTick(Client client, GameTick tick) { for (NPC npc : client.getTopLevelWorldView().npcs()) @@ -156,7 +159,7 @@ public void onGameTick(Client client, GameTick tick) } } // Only check for staleness every so often - if (tickCount.get() % 100 == 0) + if (tickCount.get() % 100 == 0 && tormentedDemons.entrySet().size() > 0) { var toDelete = new ArrayList(); for (Entry td : tormentedDemons.entrySet()) @@ -173,6 +176,35 @@ public void onGameTick(Client client, GameTick tick) } } + @Override + public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned) + { + final NPC npc = npcSpawned.getNpc(); + if (isTormentedDemon(npc.getId())) + { + boolean isVulnerable = npc.hasSpotAnim(TORMENTED_DEMON_VULN_SPOT_ANIM); + if (tormentedDemons.containsKey(npc)) + { + DemonData d = tormentedDemons.get(npc); + d.update(tickCount.get(), isVulnerable); + } + else + { + tormentedDemons.put(npc, new DemonData(tickCount.get(), isVulnerable)); + } + } + } + + @Override + public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned) + { + final NPC npc = npcDespawned.getNpc(); + if (isTormentedDemon(npc.getId())) + { + tormentedDemons.remove(npc); + } + } + /** * DemonData is an internal helper class containing the last ticks in which a demon was noticed by the * client, the tick it was vulnerable (if ever) and the tick in which it was attacked while vulnerable. diff --git a/src/main/java/com/attacktimer/VariableSpeed/VariableSpeed.java b/src/main/java/com/attacktimer/VariableSpeed/VariableSpeed.java index da1c712..15ef745 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/VariableSpeed.java +++ b/src/main/java/com/attacktimer/VariableSpeed/VariableSpeed.java @@ -35,6 +35,8 @@ import net.runelite.api.Client; import net.runelite.api.events.ChatMessage; import net.runelite.api.events.GameTick; +import net.runelite.api.events.NpcDespawned; +import net.runelite.api.events.NpcSpawned; public class VariableSpeed { @@ -77,6 +79,30 @@ public static void onChatMessage(final Client client, final ChatMessage event) } } + public static void onNpcSpawned(final Client client, final NpcSpawned npcSpawned) + { + for (IStateTracker i : TO_TRACK) + { + i.onNpcSpawned(client, npcSpawned); + } + for (IStateTracker i : TO_APPLY) + { + i.onNpcSpawned(client, npcSpawned); + } + } + + public static void onNpcDespawned(final Client client, final NpcDespawned npcDespawned) + { + for (IStateTracker i : TO_TRACK) + { + i.onNpcDespawned(client, npcDespawned); + } + for (IStateTracker i : TO_APPLY) + { + i.onNpcDespawned(client, npcDespawned); + } + } + private static final Yama YAMA = new Yama(); private static final MarkOfDarkness MARK_OF_DARKNESS = new MarkOfDarkness(); From 5ac471054e3a4236fce603465448f750c2eecc53 Mon Sep 17 00:00:00 2001 From: Lexer747 Date: Sat, 1 Aug 2026 18:20:57 +0100 Subject: [PATCH 3/7] v3 --- .../AttackTimerMetronomePlugin.java | 21 ++++--- .../com/attacktimer/ClientUtils/Utils.java | 9 +-- .../VariableSpeed/RoyalTitans.java | 56 ++++++++++++++++++- .../attacktimer/VariableSpeed/Scurrius.java | 2 +- 4 files changed, 73 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java b/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java index 9b9c5e4..d5fddf6 100644 --- a/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java +++ b/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java @@ -145,7 +145,7 @@ public enum AttackState public static final int SALAMANDER_SET_ANIM_ID = 952; // Used by all 4 types of salamander // https://oldschool.runescape.wiki/w/Salamander - private static final int TWINFLAME_STAFF_WEAPON_ID = 30634; + public static final int TWINFLAME_STAFF_WEAPON_ID = 30634; private static final int ECHO_VENATOR_BOW_WEAPON_ID = 30434; private static final int VENATOR_BOW_WEAPON_ID = 27610; private static final int HALLOWFELL_ID = 34027; // https://oldschool.runescape.wiki/w/Hallowfell @@ -156,21 +156,28 @@ public enum AttackState // var itemManager = inject(ItemManager.class); // log.info("Speed {}", itemManager.getItemStats().getEquipment().getAspeed()); private static final Map NON_STANDARD_ATTACK_SPEEDS = new ImmutableMap.Builder() - .put(HALLOWFELL_ID, 6).build(); + .put(HALLOWFELL_ID, 6) + .build(); // These animations are the ones which exceed the duration of their attack cooldown // so in this case DO NOT fall back the animation as it is un-reliable. private static final Set UNRELIABLE_ANIMATIONS = new ImmutableSet.Builder() - .add(AnimationData.RANGED_BLOWPIPE).add(AnimationData.RANGED_BLAZING_BLOWPIPE) - .add(AnimationData.MAGIC_EYE_OF_AYAK).add(AnimationData.MAGIC_EYE_OF_AYAK_SPEC).build(); + .add(AnimationData.RANGED_BLOWPIPE) + .add(AnimationData.RANGED_BLAZING_BLOWPIPE) + .add(AnimationData.MAGIC_EYE_OF_AYAK) + .add(AnimationData.MAGIC_EYE_OF_AYAK_SPEC) + .build(); private static final Map NON_STANDARD_MAGIC_WEAPON_SPEEDS = new ImmutableMap.Builder() - .put(TWINFLAME_STAFF_WEAPON_ID, 6).build(); + .put(TWINFLAME_STAFF_WEAPON_ID, 6) + .build(); // Map of problematic itemIds to equivalent working ones. // The Echo Venator Bow's ItemStats are returning null, so use the regular bow instead. - private static final Map WEAPON_ID_MAPPING_WORKAROUNDS = new ImmutableMap.Builder() - .put(ECHO_VENATOR_BOW_WEAPON_ID, VENATOR_BOW_WEAPON_ID).build(); + private static final Map WEAPON_ID_MAPPING_WORKAROUNDS = new ImmutableMap.Builder().put( + ECHO_VENATOR_BOW_WEAPON_ID, + VENATOR_BOW_WEAPON_ID + ).build(); // https://oldschool.runescape.wiki/w/Food/Fast_foods#Food_Delays // These constants are not to be confused with eat delay. diff --git a/src/main/java/com/attacktimer/ClientUtils/Utils.java b/src/main/java/com/attacktimer/ClientUtils/Utils.java index 939a1ee..b8acbd7 100644 --- a/src/main/java/com/attacktimer/ClientUtils/Utils.java +++ b/src/main/java/com/attacktimer/ClientUtils/Utils.java @@ -63,12 +63,13 @@ public static int getWeaponId(Client client) } // getLocation will return the current world point of the player accounting for instances. - public static WorldPoint getLocation(Client client) + // + // For computing tile based distances you probably don't want this and instead should use + // client.getLocalPlayer().getWorldLocation(). + public static WorldPoint getLocalLocation(Client client) { - WorldPoint location = client.getLocalPlayer().getWorldLocation(); final LocalPoint localPoint = client.getLocalPlayer().getLocalLocation(); - location = WorldPoint.fromLocalInstance(client, localPoint); - return location; + return WorldPoint.fromLocalInstance(client, localPoint); } // returns ACCURATE for unknown weapons/styles diff --git a/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java b/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java index 8dc13b1..6137dea 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java +++ b/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java @@ -37,6 +37,7 @@ import lombok.extern.slf4j.Slf4j; import net.runelite.api.Client; import net.runelite.api.NPC; +import net.runelite.api.coords.LocalPoint; import net.runelite.api.coords.WorldPoint; import net.runelite.api.events.GameTick; import net.runelite.api.events.NpcDespawned; @@ -45,6 +46,7 @@ @Slf4j public class RoyalTitans implements IVariableSpeed { + private static final int TWINFLAME_STAFF_WEAPON_ID = 30634; private static final int ROYAL_TITANS_REGION_ID = 11669; @@ -121,11 +123,59 @@ public int apply(final Client client, final AnimationData curAnimation, final At count++; } } - log.debug("[RoyalTitans] success, reduced by {}", count); + log.debug("[RoyalTitans] found AoE will kill: {}", count); + // Now compute the travel delay, we are only awarded the improved tick delay when the projectile lands + // (this can be pre-computed) so if we kill 3 elementals 10 tiles away we don't see the full 3 tick + // improvement but in fact we see the 3 ticks awarded 4 ticks after we attacked. And because of + // https://oldschool.runescape.wiki/w/Hit_delay#Processing_order_delay: + // + // > NPCs are processed earlier than players each tick, so this effect will make all hits on NPCs + // > delayed by an additional one tick compared to the numbers listed in this article. + // + // This means the 3 tick reduction is awarded on tick 5, by which time we're already off-cool down + // (assuming manual cast). And if we use the twin-flame (6 tick) we will only get a single tick of the + // improvement we earned. + // + // Therefore this is generalised as: + // + // We calculate the tick on which the reductions take effect (hitDelay + 1). A reduction of N ticks + // only benefits us if it lands **before** the natural attack timer expires. + // + // Effective ready tick = max(hitDelay + 1, curSpeed - elementals_killed) + + // NOTE: This is probably why the purging staff has that bug, because it only awards the 3 ticks of + // reduction when the spell lands (which is always 2 ticks for dark demon bane). + + final boolean isTwinflame = Utils.getWeaponId(client) == TWINFLAME_STAFF_WEAPON_ID && + curAnimation == AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF; + final int extraHitOffset = isTwinflame ? 1 : 0; + + final WorldPoint playerLoc = client.getLocalPlayer().getWorldLocation(); + final int distance = playerLoc.distanceTo2D(reference); + log.debug("[RoyalTitans] player {} - distance to target {}", playerLoc, distance); + + // https://oldschool.runescape.wiki/w/Hit_delay#Magic + + final int hitDelay = 2 + (distance / 3) + extraHitOffset; + + // Remaining cooldown when the projectile actually impacts: + final int remainingAtImpact = Math.max(0, curSpeed - hitDelay); + + // Apply reduction to remaining cooldown: + final int remainingAfterReduction = Math.max(0, remainingAtImpact - count); + + // Total ticks waited = travel time + remaining cooldown after reduction + final int finalSpeed = Math.min(curSpeed, hitDelay + remainingAfterReduction); + + log.debug("[RoyalTitans] distance: {}, hitDelay: {}, remainingAtImpact: {}, remainingAfterReduction: {}, finalSpeed: {}", + distance, hitDelay, remainingAtImpact, remainingAfterReduction, finalSpeed); + + // despawn happens much later than is dead (hence how Entity Hider works) so we need to remove them // now if we succeeded in apply. Deferred till the next onGameTick is called. removeDead = true; - return curSpeed - count; + log.debug("[RoyalTitans] success, final cool down {}", finalSpeed); + return finalSpeed; } private static boolean earlyExit(final Client client, final AttackProcedure atkType, final int damageDealt, @@ -203,6 +253,6 @@ private static boolean isElemental(int id) private static boolean notInRegion(final Client client) { - return Utils.getLocation(client).getRegionID() != ROYAL_TITANS_REGION_ID; + return Utils.getLocalLocation(client).getRegionID() != ROYAL_TITANS_REGION_ID; } } diff --git a/src/main/java/com/attacktimer/VariableSpeed/Scurrius.java b/src/main/java/com/attacktimer/VariableSpeed/Scurrius.java index 36b2d7e..1e86fd1 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/Scurrius.java +++ b/src/main/java/com/attacktimer/VariableSpeed/Scurrius.java @@ -66,7 +66,7 @@ public int apply(final Client client, final AnimationData curAnimation, final At final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, final int curSpeed) { - final WorldPoint location = Utils.getLocation(client); + final WorldPoint location = Utils.getLocalLocation(client); final int weaponId = Utils.getWeaponId(client); final int targetId = Utils.getTargetId(client); if (attackingGiantRatWithBoneWeapon(weaponId, location.getRegionID(), location.getX(), location.getY(), From 55f22e56d6d8d5eb4de2b04f487d0a2b45e9a998 Mon Sep 17 00:00:00 2001 From: Lexer747 Date: Sat, 1 Aug 2026 20:21:03 +0100 Subject: [PATCH 4/7] clean up --- .../AttackTimerMetronomePlugin.java | 82 ++++++++----------- .../com/attacktimer/ClientUtils/Utils.java | 2 +- src/main/java/com/attacktimer/Damage.java | 43 ++++++---- .../VariableSpeed/PurgingStaffSpec.java | 4 +- .../VariableSpeed/RoyalTitans.java | 80 ++++++++++-------- .../attacktimer/VariableSpeed/Scurrius.java | 12 +-- .../VariableSpeed/State/IStateTracker.java | 9 +- .../VariableSpeed/TormentedDemons.java | 70 +++------------- .../VariableSpeed/VariableSpeed.java | 22 ++--- .../com/attacktimer/IntegrationTests.java | 1 - .../com/attacktimer/TormentedDemonsTest.java | 3 - 11 files changed, 144 insertions(+), 184 deletions(-) diff --git a/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java b/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java index d5fddf6..0148da6 100644 --- a/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java +++ b/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java @@ -73,15 +73,18 @@ import net.runelite.client.ui.overlay.OverlayManager; @Slf4j -@PluginDescriptor(name = "Attack Timer Metronome", description = "Shows a visual cue on an overlay every game tick to help timing based activities", tags = -{ - "timers", "overlays", "tick", "skilling" -}) +@PluginDescriptor( + name = "Attack Timer Metronome", + description = "Shows a visual cue on an overlay every game tick to help timing based activities", + tags = {"timers", "overlays", "tick", "skilling"} +) public class AttackTimerMetronomePlugin extends Plugin { public enum AttackState { - NOT_ATTACKING, DELAYED_FIRST_TICK, DELAYED, + NOT_ATTACKING, + DELAYED_FIRST_TICK, + DELAYED, } @Inject @@ -114,10 +117,8 @@ public enum AttackState public int attackDelayHoldoffTicks = ATTACK_DELAY_NONE; public AttackState attackState = AttackState.NOT_ATTACKING; - // The state of the renderer, will lag a few cycles behind the plugin's state. "cycles" in this - // comment - // refers to the client.getGameCycle() method, a cycle occurs every 20ms, meaning 30 of them occur - // per + // The state of the renderer, will lag a few cycles behind the plugin's state. "cycles" in this comment + // refers to the client.getGameCycle() method, a cycle occurs every 20ms, meaning 30 of them occur per // game tick. public AttackState renderedState = AttackState.NOT_ATTACKING; @@ -145,16 +146,15 @@ public enum AttackState public static final int SALAMANDER_SET_ANIM_ID = 952; // Used by all 4 types of salamander // https://oldschool.runescape.wiki/w/Salamander - public static final int TWINFLAME_STAFF_WEAPON_ID = 30634; + private static final int TWINFLAME_STAFF_WEAPON_ID = 30634; private static final int ECHO_VENATOR_BOW_WEAPON_ID = 30434; private static final int VENATOR_BOW_WEAPON_ID = 27610; private static final int HALLOWFELL_ID = 34027; // https://oldschool.runescape.wiki/w/Hallowfell - // Add other weapons here if in the Runelite dev shell this prints a different value to it's actual - // speed: + // Add other weapons here if in the Runelite dev shell this prints a different value to it's actual speed: // - // var itemManager = inject(ItemManager.class); - // log.info("Speed {}", itemManager.getItemStats().getEquipment().getAspeed()); + // var itemManager = inject(ItemManager.class); + // log.info("Speed {}", itemManager.getItemStats().getEquipment().getAspeed()); private static final Map NON_STANDARD_ATTACK_SPEEDS = new ImmutableMap.Builder() .put(HALLOWFELL_ID, 6) .build(); @@ -204,10 +204,8 @@ public void onVarbitChanged(final VarbitChanged varbitChanged) } } - // onSoundEffectPlayed used to track spell casts, for when the player casts a spell on first tick - // coming - // off cooldown, in some cases (e.g. ice barrage) the player will have no animation. Also they don't - // have + // onSoundEffectPlayed used to track spell casts, for when the player casts a spell on first tick coming + // off cooldown, in some cases (e.g. ice barrage) the player will have no animation. Also they don't have // a projectile to detect instead :/ @Subscribe public void onSoundEffectPlayed(final SoundEffectPlayed event) @@ -256,7 +254,6 @@ public void onNpcSpawned(final NpcSpawned npcSpawned) { if (!config.enableMetronome()) return; - log.debug("[AttackTimer] onNpcSpawned {}", npcSpawned.getNpc().getName()); VariableSpeed.onNpcSpawned(client, npcSpawned); }; @@ -265,7 +262,6 @@ public void onNpcDespawned(final NpcDespawned npcDespawned) { if (!config.enableMetronome()) return; - log.debug("[AttackTimer] onNpcDespawned {}", npcDespawned.getNpc().getName()); VariableSpeed.onNpcDespawned(client, npcDespawned); }; @@ -347,8 +343,7 @@ private void setAttackDelay() } // matchesSpellbook tries two methods, matching the animation the spell book based on the enum of - // pre-coded matches, and then the second set of matches against the known sound id of the spell - // (which + // pre-coded matches, and then the second set of matches against the known sound id of the spell (which // unfortunately doesn't work if the player has them disabled). private boolean matchesSpellbook(AnimationData curAnimation) { @@ -368,8 +363,7 @@ private int getMagicBaseSpeed(int weaponId) return NON_STANDARD_MAGIC_WEAPON_SPEEDS.getOrDefault(weaponId, 5); } - private int getWeaponSpeed(int weaponId, PoweredStaves stave, AnimationData curAnimation, Spellbook spellbook, - boolean matchesSpellbook) + private int getWeaponSpeed(int weaponId, PoweredStaves stave, AnimationData curAnimation, Spellbook spellbook, boolean matchesSpellbook) { final var specDelta = Utils.getLastDelta(specialPercentageEvents); dmgDealt = DAMAGE.compute(TC); @@ -379,28 +373,26 @@ private int getWeaponSpeed(int weaponId, PoweredStaves stave, AnimationData curA // We are currently dealing with a staves in which case we can make decisions based on the // spellbook flag. We can only improve this by using a deprecated API to check the projectile // matches the stave rather than a manual spell, but this is good enough for now. - return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.POWERED_STAVE, spellbook, dmgDealt, - specDelta, 4); + return VariableSpeed.compute(client, curAnimation, AttackProcedure.POWERED_STAVE, spellbook, dmgDealt, specDelta, 4); } if (matchesSpellbook && isManualCasting(curAnimation)) { isUsingMagic = true; // You can cast with anything equipped in which case we shouldn't look to invent for speed. - return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MANUAL_AUTO_CAST, spellbook, - dmgDealt, specDelta, getMagicBaseSpeed(weaponId)); + return VariableSpeed.compute(client, curAnimation, AttackProcedure.MANUAL_AUTO_CAST, spellbook, dmgDealt, specDelta, getMagicBaseSpeed(weaponId)); } isUsingMagic = false; - ItemStats weaponStats = getWeaponStats(weaponId); + final ItemStats weaponStats = getWeaponStats(weaponId); if (weaponStats == null) { - return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, spellbook, dmgDealt, - specDelta, 4); // Assume barehanded == 4t + // Assume barehanded == 4t + return VariableSpeed.compute(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, spellbook, dmgDealt, specDelta, 4); } // Deadline for next available attack. - return VariableSpeed.computeSpeed(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, spellbook, dmgDealt, - specDelta, weaponStats.getEquipment().getAspeed()); + final int aspeed = weaponStats.getEquipment().getAspeed(); + return VariableSpeed.compute(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, spellbook, dmgDealt, specDelta, aspeed); } // Combat Dummy + Nightmare Pillars @@ -415,8 +407,7 @@ private boolean isPlayerAttacking() return false; } - // Not walking is either ANY player animation or the edge cases which don't trigger an animation, e.g - // Salamander. + // Not walking is either ANY player animation or the edge cases which don't trigger an animation, e.g Salamander. final boolean notWalking = animationId != -1 || getSalamanderAttack(); // Testing if we are attacking by checking the target is more future proof to new weapons which don't @@ -491,8 +482,9 @@ public int getWeaponPeriod() public boolean isAttackCooldownPending() { - return attackState == AttackState.DELAYED || attackState == AttackState.DELAYED_FIRST_TICK - || uiHideDebounceTickCount > 0; + return attackState == AttackState.DELAYED + || attackState == AttackState.DELAYED_FIRST_TICK + || uiHideDebounceTickCount > 0; } private static final String GENERIC_EAT = "You eat"; @@ -512,21 +504,18 @@ public boolean isAttackCooldownPending() private static final Pattern SLOW_FOOD = Pattern.compile("^(" + VAMPYRIUM_EAT + ")", Pattern.MULTILINE & Pattern.CASE_INSENSITIVE); - // gnome foods are also fast eats (Note these are not the food names as the wiki lists them, but the - // name + // gnome foods are also fast eats (Note these are not the food names as the wiki lists them, but the name // as written in chat), also pre-made and handmade have the same chat message. private static final String FAST_GNOME_FOOD = "worm hole|tangled toads legs|veg ball|chocolate bomb|worm crunchies|toad crunchies|" + "choc chip crunchies|spicy crunchies|fruit batta|cheese and tomato batta|toad batta|vegetable batta|worm batta"; private static final String FAST_FOOD = "karambwan|halibut"; - // Unfortunately these have just the generic "You eat the food." so there is no easy way to tell if - // you + // Unfortunately these have just the generic "You eat the food." so there is no easy way to tell if you // have the quicker eat delay. https://oldschool.runescape.wiki/w/Crystal_paddlefish and // https://oldschool.runescape.wiki/w/Corrupted_paddlefish private static final Pattern FAST_EAT = Pattern.compile("(" + FAST_FOOD + "|" + FAST_GNOME_FOOD + ")", Pattern.CASE_INSENSITIVE); - // onInteractingChanged is the driver for detecting if the player attacked out side the usual tick - // window + // onInteractingChanged is the driver for detecting if the player attacked out side the usual tick window // of the onGameTick events. @Subscribe public void onInteractingChanged(InteractingChanged interactingChanged) @@ -617,11 +606,11 @@ public void onGameTick(GameTick tick) // clamp the attackDelayHoldoffTicks at -20, this is so we correctly account for eats even when not // attacking, but don't count down forever. attackDelayHoldoffTicks = Math.max(-20, attackDelayHoldoffTicks - 1); - if (specialPercentageEvents.size() > 5) + while (specialPercentageEvents.size() > 5) { specialPercentageEvents.removeFirst(); } - DAMAGE.expire(); + DAMAGE.cleanup(); } @Override @@ -681,8 +670,7 @@ private StringBuilder getState() public void onRender() { - final int delta = VariableSpeed.SHADOW_CRASH.onRender(client, attackDelayHoldoffTicks, isUsingMagic, - config.debugLogs()); + final int delta = VariableSpeed.SHADOW_CRASH.onRender(client, attackDelayHoldoffTicks, isUsingMagic, config.debugLogs()); if (delta != 0) { diff --git a/src/main/java/com/attacktimer/ClientUtils/Utils.java b/src/main/java/com/attacktimer/ClientUtils/Utils.java index b8acbd7..15114a3 100644 --- a/src/main/java/com/attacktimer/ClientUtils/Utils.java +++ b/src/main/java/com/attacktimer/ClientUtils/Utils.java @@ -2,7 +2,7 @@ /* * Copyright (c) 2022, Nick Graves - * Copyright (c) 2024, Lexer747 + * Copyright (c) 2024-2026, Lexer747 * All rights reserved. * * Redistribution and use in source and binary forms, with or without diff --git a/src/main/java/com/attacktimer/Damage.java b/src/main/java/com/attacktimer/Damage.java index 3fcb25d..46ec24d 100644 --- a/src/main/java/com/attacktimer/Damage.java +++ b/src/main/java/com/attacktimer/Damage.java @@ -32,37 +32,50 @@ import net.runelite.api.events.FakeXpDrop; import net.runelite.api.events.StatChanged; +/** + * Damage is a helper store that from the HP exp alone can compute the predicted damage of an attack. + * + * It works by storing the queue of hp exp drops then computing the delta of those drops then the normal + * damage formula (hpExp * 3/4 == damage). + * + * TODO npc exp modifiers + * + * TODO global modifiers + */ public class Damage { - private static final double MODIFIER = 1; // this is where NPC specific modifiers go - private static final double GLOBAL_MODIFIER = 1; // this is where global specific modifiers go i.e. leagues + private static final double MODIFIER = 1; + private static final double GLOBAL_MODIFIER = 1; private ArrayDeque hpExpEarned = new ArrayDeque(); private ArrayDeque hpExpEarnedTickCount = new ArrayDeque(); public boolean onXpDrop(StatChanged event, TickCount tc) { - final var skill = event.getSkill(); - if (skill != Skill.HITPOINTS) - { - return false; - } - hpExpEarnedTickCount.addLast(tc.get()); - hpExpEarned.addLast(event.getXp()); - return true; + return onXpDrop(event.getSkill(), event.getXp(), tc); } + public boolean onXpDrop(FakeXpDrop event, TickCount tc) { - final var skill = event.getSkill(); + return onXpDrop(event.getSkill(), event.getXp(), tc); + } + + private boolean onXpDrop(Skill skill, int xp, TickCount tc) + { if (skill != Skill.HITPOINTS) { return false; } hpExpEarnedTickCount.addLast(tc.get()); - hpExpEarned.addLast(event.getXp()); + hpExpEarned.addLast(xp); return true; } + /** + * compute determines from the previous hp exp drops how much damage the player has dealt on this tick. + * @param tc the tick count state + * @return the amount of damage dealt this tick + */ public int compute(TickCount tc) { if (hpExpEarnedTickCount.isEmpty()) @@ -80,13 +93,13 @@ public int compute(TickCount tc) return (int) Math.round(xp * (3.0d / 4.0d) * MODIFIER * GLOBAL_MODIFIER); } - public void expire() + public void cleanup() { - if (hpExpEarnedTickCount.size() > 5) + while (hpExpEarnedTickCount.size() > 5) { hpExpEarnedTickCount.removeFirst(); } - if (hpExpEarned.size() > 5) + while (hpExpEarned.size() > 5) { hpExpEarned.removeFirst(); } diff --git a/src/main/java/com/attacktimer/VariableSpeed/PurgingStaffSpec.java b/src/main/java/com/attacktimer/VariableSpeed/PurgingStaffSpec.java index 804a91f..7113ec2 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/PurgingStaffSpec.java +++ b/src/main/java/com/attacktimer/VariableSpeed/PurgingStaffSpec.java @@ -56,8 +56,8 @@ public int apply(final Client client, final AnimationData curAnimation, final At { return curSpeed; } - var target = Utils.getTargetNPC(client); - var flare = Yama.isEitherVoidFlare(target, lastTarget); + final var target = Utils.getTargetNPC(client); + final var flare = Yama.isEitherVoidFlare(target, lastTarget); lastTarget = target; if (flare == null || yama == null) { diff --git a/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java b/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java index 6137dea..a83667f 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java +++ b/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java @@ -28,22 +28,23 @@ import com.attacktimer.AnimationData; import com.attacktimer.AttackProcedure; import com.attacktimer.ClientUtils.Utils; -import com.attacktimer.VariableSpeed.State.IStateTracker; import com.attacktimer.Spellbook; import com.google.common.collect.ImmutableSet; import java.util.HashSet; -import java.util.Iterator; import java.util.Set; -import lombok.extern.slf4j.Slf4j; import net.runelite.api.Client; import net.runelite.api.NPC; -import net.runelite.api.coords.LocalPoint; import net.runelite.api.coords.WorldPoint; import net.runelite.api.events.GameTick; import net.runelite.api.events.NpcDespawned; import net.runelite.api.events.NpcSpawned; -@Slf4j +/** + * RoyalTitans: https://oldschool.runescape.wiki/w/Royal_Titans/Strategies + * + * For each elemental killed, the player receives a 1-tick reduction to their attack delay, allowing spells to + * be cast consecutively much quicker than usual. + */ public class RoyalTitans implements IVariableSpeed { private static final int TWINFLAME_STAFF_WEAPON_ID = 30634; @@ -61,7 +62,7 @@ public class RoyalTitans implements IVariableSpeed private boolean removeDead = false; - private static final Set STANDARD_SPELLS = new ImmutableSet.Builder() + private static final Set ONE_SHOT_SPELLS = new ImmutableSet.Builder() .add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST) .add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF) .add(AnimationData.MAGIC_STANDARD_SURGE_STAFF) @@ -79,9 +80,9 @@ public int apply(final Client client, final AnimationData curAnimation, final At return curSpeed; } final int targetId = Utils.getTargetId(client); - if (spellbook != Spellbook.STANDARD || - !isElemental(targetId) || - !STANDARD_SPELLS.contains(curAnimation)) + if (spellbook != Spellbook.STANDARD + || !isElemental(targetId) + || !ONE_SHOT_SPELLS.contains(curAnimation)) { return curSpeed; } @@ -92,38 +93,37 @@ public int apply(final Client client, final AnimationData curAnimation, final At } // We are now in the royal titan region, attacking an elemental using magic and one of the standard // spells which could one shot it. - log.debug("[RoyalTitans] Attacking elemental With correct spell"); + + // Note that the twinflame second spell does not give Magic or Hitpoints experience and therefore will + // not be computed properly by the caller. + final boolean wieldingTwinflame = Utils.getWeaponId(client) == TWINFLAME_STAFF_WEAPON_ID; + final int computedDamage = wieldingTwinflame ? damageDealt + ((damageDealt * 4) / 10) : damageDealt; + // Awkwardly you only got awarded the exp (and therefore computed damage) against the one of the // elementals, hence the 3x3 AoE isn't seen in the damage dealt. - if (damageDealt < ELEMENTAL_HP) + if (computedDamage < ELEMENTAL_HP) { - log.debug("[RoyalTitans] didn't do enough damage"); // Don't bother computing partial damage assume most players are one-shotting return curSpeed; } - log.debug("[RoyalTitans] enough damage"); // Compute the number of elementals in the a 3x3 from our target: final var set = targetId == FIRE_ELEMENTAL_ID ? fireElementals : iceElementals; - log.debug("[RoyalTitans] elemental set: {}", set); int count = 1; final var reference = target.getWorldLocation(); - log.debug("[RoyalTitans] reference {}", reference); for (final NPC elemental : set) { if (elemental == target) { - log.debug("[RoyalTitans] distance check skipped - is target"); continue; } final WorldPoint worldLocation = elemental.getWorldLocation(); final int distanceTo2D = reference.distanceTo2D(worldLocation); - log.debug("[RoyalTitans] distance check new {}, distance {}", worldLocation, distanceTo2D); + // 3x3 is 1 distance https://en.wikipedia.org/wiki/Chebyshev_distance if (distanceTo2D <= 1) { count++; } } - log.debug("[RoyalTitans] found AoE will kill: {}", count); // Now compute the travel delay, we are only awarded the improved tick delay when the projectile lands // (this can be pre-computed) so if we kill 3 elementals 10 tiles away we don't see the full 3 tick // improvement but in fact we see the 3 ticks awarded 4 ticks after we attacked. And because of @@ -146,16 +146,34 @@ public int apply(final Client client, final AnimationData curAnimation, final At // NOTE: This is probably why the purging staff has that bug, because it only awards the 3 ticks of // reduction when the spell lands (which is always 2 ticks for dark demon bane). - final boolean isTwinflame = Utils.getWeaponId(client) == TWINFLAME_STAFF_WEAPON_ID && + // NOTE: this has one edge case also not covered (as well as the same eating one as purging staff) + // which is that if the awarded bonus is granted and we already started our next weapon cooldown then + // the bonus is applied to that attack instead. + + final boolean isTwinflameKillOnSecondProjectile = wieldingTwinflame && curAnimation == AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF; - final int extraHitOffset = isTwinflame ? 1 : 0; + final int extraHitOffset = isTwinflameKillOnSecondProjectile ? 1 : 0; // add an extra tick of delay for the second projectile. + // https://oldschool.runescape.wiki/w/Hit_delay#Magic However it's not quite the formula, unclear to + // me whether the wiki is wrong or if its simply a case of fence post errors counting the tiles. + // https://en.wikipedia.org/wiki/Off-by-one_error#Fencepost_error + // + // from my testing the hit delay was 1 less on the transitions in the table therefore: + // + // Distance Hit delay + // (tiles) (wiki) (measured) + // 1 1 1 + // 2 2 1 (diff) + // 3 2 2 + // 4 2 2 + // 5 3 2 (diff) + // 6 3 3 + // 7 3 3 + // 8 4 3 (diff) + // 9 4 4 + // 10 4 4 final WorldPoint playerLoc = client.getLocalPlayer().getWorldLocation(); final int distance = playerLoc.distanceTo2D(reference); - log.debug("[RoyalTitans] player {} - distance to target {}", playerLoc, distance); - - // https://oldschool.runescape.wiki/w/Hit_delay#Magic - final int hitDelay = 2 + (distance / 3) + extraHitOffset; // Remaining cooldown when the projectile actually impacts: @@ -167,14 +185,9 @@ public int apply(final Client client, final AnimationData curAnimation, final At // Total ticks waited = travel time + remaining cooldown after reduction final int finalSpeed = Math.min(curSpeed, hitDelay + remainingAfterReduction); - log.debug("[RoyalTitans] distance: {}, hitDelay: {}, remainingAtImpact: {}, remainingAfterReduction: {}, finalSpeed: {}", - distance, hitDelay, remainingAtImpact, remainingAfterReduction, finalSpeed); - - // despawn happens much later than is dead (hence how Entity Hider works) so we need to remove them // now if we succeeded in apply. Deferred till the next onGameTick is called. removeDead = true; - log.debug("[RoyalTitans] success, final cool down {}", finalSpeed); return finalSpeed; } @@ -190,15 +203,14 @@ private static boolean earlyExit(final Client client, final AttackProcedure atkT @Override public void onGameTick(Client client, GameTick tick) { + // if we killed some elementals last tick remove them now. if (removeDead) { - var before = iceElementals.size() + fireElementals.size(); + // don't clear the set(s) do it using the runelite API. var removed = iceElementals.removeIf(npc -> npc.isDead()); removed |= fireElementals.removeIf(npc -> npc.isDead()); - var after = iceElementals.size() + fireElementals.size(); if (removed) { - log.debug("[RoyalTitans] removed dead elementals in onGameTick - before {}, after {}", before, after); removeDead = false; } } @@ -215,12 +227,10 @@ public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned) final int id = npc.getId(); if (id == ICE_ELEMENTAL_ID) { - log.debug("[RoyalTitans] added ice elemental"); iceElementals.add(npc); } else if (id == FIRE_ELEMENTAL_ID) { - log.debug("[RoyalTitans] added fire elemental"); fireElementals.add(npc); } } @@ -236,12 +246,10 @@ public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned) final int id = npc.getId(); if (id == ICE_ELEMENTAL_ID) { - log.debug("[RoyalTitans] removed ice elemental"); iceElementals.remove(npc); } else if (id == FIRE_ELEMENTAL_ID) { - log.debug("[RoyalTitans] removed fire elemental"); fireElementals.remove(npc); } } diff --git a/src/main/java/com/attacktimer/VariableSpeed/Scurrius.java b/src/main/java/com/attacktimer/VariableSpeed/Scurrius.java index 1e86fd1..26c2a9f 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/Scurrius.java +++ b/src/main/java/com/attacktimer/VariableSpeed/Scurrius.java @@ -1,7 +1,7 @@ package com.attacktimer.VariableSpeed; /* - * Copyright (c) 2024, Lexer747 + * Copyright (c) 2024-2026, Lexer747 * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -33,9 +33,10 @@ import net.runelite.api.coords.WorldPoint; /** - * Scurrius: https://oldschool.runescape.wiki/w/Scurrius/Strategies#Strategies When attacking the - * giant rats summoned by Scurrius and attacking them with a bone weapon the player has no attack - * delay. + * Scurrius: https://oldschool.runescape.wiki/w/Scurrius/Strategies#Strategies + * + * When attacking the giant rats summoned by Scurrius and attacking them with a bone weapon the player has no + * attack delay. */ public class Scurrius implements IVariableSpeed { @@ -69,8 +70,7 @@ public int apply(final Client client, final AnimationData curAnimation, final At final WorldPoint location = Utils.getLocalLocation(client); final int weaponId = Utils.getWeaponId(client); final int targetId = Utils.getTargetId(client); - if (attackingGiantRatWithBoneWeapon(weaponId, location.getRegionID(), location.getX(), location.getY(), - targetId)) + if (attackingGiantRatWithBoneWeapon(weaponId, location.getRegionID(), location.getX(), location.getY(), targetId)) { return 1; } diff --git a/src/main/java/com/attacktimer/VariableSpeed/State/IStateTracker.java b/src/main/java/com/attacktimer/VariableSpeed/State/IStateTracker.java index b7a06b7..c0c9906 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/State/IStateTracker.java +++ b/src/main/java/com/attacktimer/VariableSpeed/State/IStateTracker.java @@ -54,17 +54,18 @@ default public void onChatMessage(final Client client, final ChatMessage event) {}; /** - * TODO + * subscribe to when npcs are spawned * - * @param npcSpawned + * @param npcSpawned the npc which has spawned */ default public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned) {}; /** - * TODO + * subscribe to when npcs are despawned, note this is not when the NPC is dead but after the death + * animation has completed or out of render distance. * - * @param npcDespawned + * @param npcDespawned the npc which has despawned */ default public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned) {}; diff --git a/src/main/java/com/attacktimer/VariableSpeed/TormentedDemons.java b/src/main/java/com/attacktimer/VariableSpeed/TormentedDemons.java index 2337c93..0ee8c6c 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/TormentedDemons.java +++ b/src/main/java/com/attacktimer/VariableSpeed/TormentedDemons.java @@ -32,7 +32,6 @@ import com.attacktimer.Spellbook; import com.attacktimer.VariableSpeed.State.TickCount; import com.attacktimer.WeaponType; -import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import java.util.Map.Entry; @@ -71,7 +70,7 @@ public int apply(final Client client, final AnimationData curAnimation, final At final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, final int curSpeed) { - int targetId = Utils.getTargetId(client); + final int targetId = Utils.getTargetId(client); if (!isTormentedDemon(targetId)) { return curSpeed; @@ -141,38 +140,10 @@ private static boolean isTormentedDemon(int targetId) @Override public void onGameTick(Client client, GameTick tick) { - for (NPC npc : client.getTopLevelWorldView().npcs()) + for (final Entry td : tormentedDemons.entrySet()) { - if (!isTormentedDemon(npc.getId())) - { - continue; - } - boolean isVulnerable = npc.hasSpotAnim(TORMENTED_DEMON_VULN_SPOT_ANIM); - if (tormentedDemons.containsKey(npc)) - { - DemonData d = tormentedDemons.get(npc); - d.update(tickCount.get(), isVulnerable); - } - else - { - tormentedDemons.put(npc, new DemonData(tickCount.get(), isVulnerable)); - } - } - // Only check for staleness every so often - if (tickCount.get() % 100 == 0 && tormentedDemons.entrySet().size() > 0) - { - var toDelete = new ArrayList(); - for (Entry td : tormentedDemons.entrySet()) - { - if (td.getValue().isStale(tickCount.get())) - { - toDelete.add(td.getKey()); - } - } - for (NPC td : toDelete) - { - tormentedDemons.remove(td); - } + final boolean isVulnerable = td.getKey().hasSpotAnim(TORMENTED_DEMON_VULN_SPOT_ANIM); + td.getValue().update(tickCount.get(), isVulnerable); } } @@ -180,18 +151,15 @@ public void onGameTick(Client client, GameTick tick) public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned) { final NPC npc = npcSpawned.getNpc(); + maybeInsertDemon(npc); + } + + private void maybeInsertDemon(final NPC npc) + { if (isTormentedDemon(npc.getId())) { - boolean isVulnerable = npc.hasSpotAnim(TORMENTED_DEMON_VULN_SPOT_ANIM); - if (tormentedDemons.containsKey(npc)) - { - DemonData d = tormentedDemons.get(npc); - d.update(tickCount.get(), isVulnerable); - } - else - { - tormentedDemons.put(npc, new DemonData(tickCount.get(), isVulnerable)); - } + final boolean isVulnerable = npc.hasSpotAnim(TORMENTED_DEMON_VULN_SPOT_ANIM); + tormentedDemons.put(npc, new DemonData(tickCount.get(), isVulnerable)); } } @@ -214,14 +182,12 @@ class DemonData // VulTicksAfterEnd is just a guess the wiki isn't clear how long this period is, from testing 10 // ticks feels about right. private static final int VULN_TICKS_AFTER_END = 10; - private int lastSpotted; private Integer vulnerableStart; private Integer vulnerableFinish; private int attacked; DemonData(int tick, boolean vuln) { - lastSpotted = tick; this.update(tick, vuln); this.attacked = -1; } @@ -230,7 +196,6 @@ void update(int tick, boolean vuln) { // NOTE: we can't use the chat message "The demon's spell binds you" because this doesn't trigger // at the start of any kill. - lastSpotted = tick; if (vuln && this.vulnerableStart == null) { this.vulnerableStart = Integer.valueOf(tick); @@ -260,17 +225,6 @@ boolean isVulnerable(int tick) return (this.vulnerableFinish + VULN_TICKS_AFTER_END) > tick; } - // isStale returns true if the last time this demon was spotted by the client was too long ago. - boolean isStale(int tick) - { - if (this.lastSpotted + 50 < tick) - { - // Last update was over 50 ticks ago, this is stale - return true; - } - return false; - } - // vulnConsumed returns true if the demon was already attack, false if not and the vuln is still usable boolean vulnConsumed(int tick) { @@ -299,7 +253,7 @@ void consumeVuln(int tick) @Override public String toString() { - return "created: " + String.valueOf(this.lastSpotted) + " vulnerableStart: " + String.valueOf(this.vulnerableStart) + " vulnerableFinish: " + String.valueOf(this.vulnerableFinish); + return "vulnerableStart: " + String.valueOf(this.vulnerableStart) + " vulnerableFinish: " + String.valueOf(this.vulnerableFinish); } } diff --git a/src/main/java/com/attacktimer/VariableSpeed/VariableSpeed.java b/src/main/java/com/attacktimer/VariableSpeed/VariableSpeed.java index 15ef745..c513ad8 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/VariableSpeed.java +++ b/src/main/java/com/attacktimer/VariableSpeed/VariableSpeed.java @@ -1,7 +1,7 @@ package com.attacktimer.VariableSpeed; /* - * Copyright (c) 2024-2025, Lexer747 + * Copyright (c) 2024-2026, Lexer747 * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -44,11 +44,11 @@ public class VariableSpeed * computeSpeed will forward the client, animation data and current weapon speed to all the known * classes which can affect the base speed of a weapon. See implementations of IVariableSpeed. */ - public static int computeSpeed(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + public static int compute(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed) { int newSpeed = baseSpeed; - for (IVariableSpeed i : TO_APPLY) + for (final IVariableSpeed i : TO_APPLY) { newSpeed = i.apply(client, curAnimation, atkType, spellbook, damageDealt, lastSpecDelta, baseSpeed, newSpeed); } @@ -57,11 +57,11 @@ public static int computeSpeed(final Client client, final AnimationData curAnima public static void onGameTick(final Client client, final GameTick tick) { - for (IStateTracker i : TO_TRACK) + for (final IStateTracker i : TO_TRACK) { i.onGameTick(client, tick); } - for (IStateTracker i : TO_APPLY) + for (final IStateTracker i : TO_APPLY) { i.onGameTick(client, tick); } @@ -69,11 +69,11 @@ public static void onGameTick(final Client client, final GameTick tick) public static void onChatMessage(final Client client, final ChatMessage event) { - for (IStateTracker i : TO_TRACK) + for (final IStateTracker i : TO_TRACK) { i.onChatMessage(client, event); } - for (IStateTracker i : TO_APPLY) + for (final IStateTracker i : TO_APPLY) { i.onChatMessage(client, event); } @@ -81,11 +81,11 @@ public static void onChatMessage(final Client client, final ChatMessage event) public static void onNpcSpawned(final Client client, final NpcSpawned npcSpawned) { - for (IStateTracker i : TO_TRACK) + for (final IStateTracker i : TO_TRACK) { i.onNpcSpawned(client, npcSpawned); } - for (IStateTracker i : TO_APPLY) + for (final IStateTracker i : TO_APPLY) { i.onNpcSpawned(client, npcSpawned); } @@ -93,11 +93,11 @@ public static void onNpcSpawned(final Client client, final NpcSpawned npcSpawned public static void onNpcDespawned(final Client client, final NpcDespawned npcDespawned) { - for (IStateTracker i : TO_TRACK) + for (final IStateTracker i : TO_TRACK) { i.onNpcDespawned(client, npcDespawned); } - for (IStateTracker i : TO_APPLY) + for (final IStateTracker i : TO_APPLY) { i.onNpcDespawned(client, npcDespawned); } diff --git a/src/test/java/com/attacktimer/IntegrationTests.java b/src/test/java/com/attacktimer/IntegrationTests.java index 5a5c367..32755f4 100644 --- a/src/test/java/com/attacktimer/IntegrationTests.java +++ b/src/test/java/com/attacktimer/IntegrationTests.java @@ -143,7 +143,6 @@ public Player pluginMockSetup() throws Exception when(mockedWorldView.getPlane()).thenReturn(mockedPlane); WorldPoint worldPoint = new WorldPoint(0, 0, mockedPlane); LocalPoint localPoint = new LocalPoint(0, 0, mockedPlane); - when(mockedPlayer.getWorldLocation()).thenReturn(worldPoint); when(mockedPlayer.getLocalLocation()).thenReturn(localPoint); // -- NPCs IndexedObjectSet mockedNpcs = mock(IndexedObjectSet.class); diff --git a/src/test/java/com/attacktimer/TormentedDemonsTest.java b/src/test/java/com/attacktimer/TormentedDemonsTest.java index e209301..b9c69a0 100644 --- a/src/test/java/com/attacktimer/TormentedDemonsTest.java +++ b/src/test/java/com/attacktimer/TormentedDemonsTest.java @@ -44,7 +44,6 @@ import net.runelite.api.Varbits; import net.runelite.api.WorldView; import net.runelite.api.coords.LocalPoint; -import net.runelite.api.coords.WorldPoint; import net.runelite.client.game.ItemEquipmentStats; import net.runelite.client.game.ItemStats; import org.junit.Test; @@ -138,9 +137,7 @@ public Player pluginMockSetup() throws Exception when(mockedClient.getWorldView(0)).thenReturn(mockedWorldView); int mockedPlane = 0; when(mockedWorldView.getPlane()).thenReturn(mockedPlane); - WorldPoint worldPoint = new WorldPoint(0, 0, mockedPlane); LocalPoint localPoint = new LocalPoint(0, 0, mockedPlane); - when(mockedPlayer.getWorldLocation()).thenReturn(worldPoint); when(mockedPlayer.getLocalLocation()).thenReturn(localPoint); // -- NPCs worldViewNPCiter(td); From 5bb058d86e7dd093a72509c310180f35ffec0072 Mon Sep 17 00:00:00 2001 From: Lexer747 Date: Sun, 2 Aug 2026 10:48:26 +0100 Subject: [PATCH 5/7] add royal titan test case --- src/main/java/com/attacktimer/Damage.java | 20 +- .../VariableSpeed/RoyalTitans.java | 8 +- .../VariableSpeed/State/TickCount.java | 1 + .../com/attacktimer/IntegrationTests.java | 78 ++++- .../java/com/attacktimer/RoyalTitansTest.java | 304 ++++++++++++++++++ .../com/attacktimer/TormentedDemonsTest.java | 45 +-- .../testdata/AoEKillManualCast.txt | 74 +++++ .../com/attacktimer/testdata/PunishTest.txt | 2 +- .../attacktimer/testdata/PunishWastedTest.txt | 2 +- .../testdata/PunishWastedWrongStyleTest.txt | 2 +- .../testdata/SingleKillManualCast.txt | 88 +++++ 11 files changed, 563 insertions(+), 61 deletions(-) create mode 100644 src/test/java/com/attacktimer/RoyalTitansTest.java create mode 100644 src/test/java/com/attacktimer/testdata/AoEKillManualCast.txt create mode 100644 src/test/java/com/attacktimer/testdata/SingleKillManualCast.txt diff --git a/src/main/java/com/attacktimer/Damage.java b/src/main/java/com/attacktimer/Damage.java index 46ec24d..7a1054f 100644 --- a/src/main/java/com/attacktimer/Damage.java +++ b/src/main/java/com/attacktimer/Damage.java @@ -52,22 +52,26 @@ public class Damage public boolean onXpDrop(StatChanged event, TickCount tc) { - return onXpDrop(event.getSkill(), event.getXp(), tc); + if (event.getSkill() != Skill.HITPOINTS) + { + return false; + } + hpExpEarnedTickCount.addLast(tc.get()); + hpExpEarned.addLast(event.getXp()); + return true; } public boolean onXpDrop(FakeXpDrop event, TickCount tc) { - return onXpDrop(event.getSkill(), event.getXp(), tc); - } - - private boolean onXpDrop(Skill skill, int xp, TickCount tc) - { - if (skill != Skill.HITPOINTS) + if (event.getSkill() != Skill.HITPOINTS) { return false; } hpExpEarnedTickCount.addLast(tc.get()); - hpExpEarned.addLast(xp); + hpExpEarnedTickCount.addLast(tc.get()); + // Fake exp doesn't have a delta like real xp + hpExpEarned.addLast(0); + hpExpEarned.addLast(event.getXp()); return true; } diff --git a/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java b/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java index a83667f..f718884 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java +++ b/src/main/java/com/attacktimer/VariableSpeed/RoyalTitans.java @@ -194,10 +194,10 @@ public int apply(final Client client, final AnimationData curAnimation, final At private static boolean earlyExit(final Client client, final AttackProcedure atkType, final int damageDealt, final Spellbook spellbook) { - return notInRegion(client) && - atkType != AttackProcedure.MANUAL_AUTO_CAST && - damageDealt <= 0 && - spellbook != Spellbook.STANDARD; + return notInRegion(client) + || atkType != AttackProcedure.MANUAL_AUTO_CAST + || damageDealt <= 0 + || spellbook != Spellbook.STANDARD; } @Override diff --git a/src/main/java/com/attacktimer/VariableSpeed/State/TickCount.java b/src/main/java/com/attacktimer/VariableSpeed/State/TickCount.java index 20eff49..ee8f0ca 100644 --- a/src/main/java/com/attacktimer/VariableSpeed/State/TickCount.java +++ b/src/main/java/com/attacktimer/VariableSpeed/State/TickCount.java @@ -40,6 +40,7 @@ public int get() return tickCount; } + @Override public void onGameTick(Client client, GameTick tick) { tickCount++; diff --git a/src/test/java/com/attacktimer/IntegrationTests.java b/src/test/java/com/attacktimer/IntegrationTests.java index 32755f4..1a85f84 100644 --- a/src/test/java/com/attacktimer/IntegrationTests.java +++ b/src/test/java/com/attacktimer/IntegrationTests.java @@ -41,18 +41,15 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.util.Collections; import java.util.EnumSet; import net.runelite.api.Client; import net.runelite.api.EnumComposition; import net.runelite.api.EnumID; -import net.runelite.api.IndexedObjectSet; import net.runelite.api.NPC; import net.runelite.api.NPCComposition; import net.runelite.api.Player; import net.runelite.api.WorldView; import net.runelite.api.coords.LocalPoint; -import net.runelite.api.coords.WorldPoint; import net.runelite.api.events.GameTick; import net.runelite.client.config.ConfigManager; import net.runelite.client.game.ItemManager; @@ -141,13 +138,9 @@ public Player pluginMockSetup() throws Exception when(mockedClient.getWorldView(0)).thenReturn(mockedWorldView); int mockedPlane = 0; when(mockedWorldView.getPlane()).thenReturn(mockedPlane); - WorldPoint worldPoint = new WorldPoint(0, 0, mockedPlane); LocalPoint localPoint = new LocalPoint(0, 0, mockedPlane); when(mockedPlayer.getLocalLocation()).thenReturn(localPoint); // -- NPCs - IndexedObjectSet mockedNpcs = mock(IndexedObjectSet.class); - when(mockedNpcs.iterator()).thenReturn(Collections.emptyIterator()); - when(mockedWorldView.npcs()).thenReturn(mockedNpcs); // -- Attack Styles EnumComposition mockedWeaponEnum = mock(EnumComposition.class); when(mockedClient.getEnum(EnumID.WEAPON_STYLES)).thenReturn(mockedWeaponEnum); @@ -158,6 +151,18 @@ public Player pluginMockSetup() throws Exception return mockedPlayer; } + protected void setNPCMock(NPC npc, int mockedNpcId) + { + NPCComposition mockedCompositions = mock(NPCComposition.class); + when(npc.getComposition()).thenReturn(mockedCompositions); + when(npc.getId()).thenReturn(mockedNpcId); + String[] actions = { + "Attack", "Examine", + }; + when(mockedCompositions.getActions()).thenReturn(actions); + when(mockedNpcManager.getHealth(mockedNpcId)).thenReturn(1); + } + protected void performStateVerificationOrUpdate(ByteArrayDataOutput channel, Path path) throws IOException { var actualBytes = channel.toByteArray(); @@ -194,18 +199,71 @@ protected void writeTestMessage(String message, ByteArrayDataOutput file) file.write(SUFFIX); } + protected bounds getWorldForRegionId(int id) + { + // getRegionId is -> ((x >> 6) << 8) | (y >> 6) + // + // so working backwards the bottom 6 bits are the y coord and the top 6 bits are the x coord. + final var res = new bounds(); + res.minX = ((id >> 8) & 0xFFFFFF) * 64; + res.maxX = res.minX + 63; + + res.minY = (id & 0xFF) * 64; + res.maxY = res.minY + 63; + return res; + } + + protected int[][][] createInstanceTemplateChunks(int regionId) + { + // 4 planes, 13x13 scene chunks (104x104 tiles) + final int[][][] templateChunks = new int[4][13][13]; + + final int regionX = (regionId >> 8) & 0xFF; + final int regionY = regionId & 0xFF; + + final int baseWorldChunkX = (regionX * 64) / 8; // region lower-left chunk X + final int baseWorldChunkY = (regionY * 64) / 8; // region lower-left chunk Y + + for (int plane = 0; plane < 4; plane++) + { + for (int x = 0; x < 13; x++) + { + for (int y = 0; y < 13; y++) + { + final int worldChunkX = baseWorldChunkX + x; + final int worldChunkY = baseWorldChunkY + y; + + // Pack plane, chunkX, chunkY, and rotation (0) + templateChunks[plane][x][y] = (plane << 27) | (worldChunkX << 14) | (worldChunkY << 3); + } + } + } + return templateChunks; + } + protected static final int NO_ANIMATION = -1; protected static final String TESTDATA = "src/test/java/com/attacktimer/testdata/"; private static final byte[] PREFIX = "[TEST MESSAGE] ".getBytes(StandardCharsets.UTF_8); private static final byte[] SUFFIX = "\n".getBytes(StandardCharsets.UTF_8); - // This needs at least one public test to keep mockito happy but having real tests in this file would - // result in any future test which extends this test class also having to run and make that test pass. + // This needs at least one public test to keep mockito happy but having real tests in this file + // would + // result in any future test which extends this test class also having to run and make that test + // pass. // - // This does cause test inflation in that the summary will make it look like we have more tests than we + // This does cause test inflation in that the summary will make it look like we have more tests than + // we // really do, but I don't have a nicer way to not repeat all the injector boiler plate. @Test public void noTest() {} + + protected class bounds + { + int minX; + int maxX; + int minY; + int maxY; + }; } \ No newline at end of file diff --git a/src/test/java/com/attacktimer/RoyalTitansTest.java b/src/test/java/com/attacktimer/RoyalTitansTest.java new file mode 100644 index 0000000..9c97cc3 --- /dev/null +++ b/src/test/java/com/attacktimer/RoyalTitansTest.java @@ -0,0 +1,304 @@ +package com.attacktimer; + +/* + * Copyright (c) 2026, Lexer747 + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.attacktimer.AttackTimerMetronomePlugin.AttackState; +import com.google.common.io.ByteArrayDataOutput; +import com.google.common.io.ByteStreams; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.HashSet; +import java.util.Set; +import net.runelite.api.NPC; +import net.runelite.api.Player; +import net.runelite.api.Skill; +import net.runelite.api.WorldView; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.events.FakeXpDrop; +import net.runelite.api.events.NpcDespawned; +import net.runelite.api.events.NpcSpawned; +import org.junit.Test; + +public class RoyalTitansTest extends IntegrationTests +{ + @Test + public void SingleKillManualCast() throws Exception + { + int expected = 3; + final int xp = 60; + + ByteArrayDataOutput channel = ByteStreams.newDataOutput(); + underTest.writeState(channel); + + writeTestMessage("distance 1", channel); + runSingleTest(channel, 1, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + writeTestMessage("distance 2", channel); + runSingleTest(channel, 2, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + writeTestMessage("distance 3", channel); + runSingleTest(channel, 3, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + writeTestMessage("distance 4", channel); + runSingleTest(channel, 4, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + writeTestMessage("distance 5", channel); + runSingleTest(channel, 5, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + writeTestMessage("distance 6", channel); + runSingleTest(channel, 6, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + writeTestMessage("distance 7", channel); + runSingleTest(channel, 7, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + writeTestMessage("distance 8", channel); + runSingleTest(channel, 8, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + expected = 4; + writeTestMessage("distance 9", channel); + runSingleTest(channel, 9, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + writeTestMessage("distance 10", channel); + runSingleTest(channel, 10, expected, xp); + + performStateVerificationOrUpdate(channel, Paths.get(TESTDATA + "SingleKillManualCast.txt")); + } + + private void runSingleTest(ByteArrayDataOutput channel, int distance, int expected, int xp) + throws Exception, IOException + { + final Player player = pluginMockSetup(); + // Ensure we're using correct magic + when(player.getAnimation()).thenReturn(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF.animationId); + + final var it = iceElementals.iterator(); + final NPC target = it.next(); + // Set up hit delay by setting distance + final var bounds = getWorldForRegionId(11669); + final var wp = new WorldPoint(bounds.minX + 128, bounds.minY + 128, 0); + when(target.getWorldLocation()).thenReturn(wp); + when(player.getWorldLocation()).thenReturn(wp.dx(distance)); + when(player.getInteracting()).thenReturn(target); + while (it.hasNext()) + { + final var splashed = it.next(); + // need to mock all the other elementals, ensure they are off screen: + when(splashed.getWorldLocation()).thenReturn(wp.dx(128)); + } + + // Ensure we deal enough damage + underTest.onFakeXpDrop(new FakeXpDrop(Skill.HITPOINTS, xp)); + + onGameTick(channel); + + assertSame(AttackState.DELAYED_FIRST_TICK, underTest.attackState); + assertSame(expected, underTest.attackDelayHoldoffTicks); + } + + @Test + public void AoEKillManualCast() throws Exception + { + // We start quicker than the single kills and expect decay as they get further away due to hit delay + int expected = 1; + final int xp = 60; + + ByteArrayDataOutput channel = ByteStreams.newDataOutput(); + underTest.writeState(channel); + + writeTestMessage("distance 1", channel); + runAoETest(channel, 1, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + writeTestMessage("distance 2", channel); + runAoETest(channel, 2, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + expected = 2; + writeTestMessage("distance 3", channel); + runAoETest(channel, 3, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + writeTestMessage("distance 4", channel); + runAoETest(channel, 4, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + writeTestMessage("distance 5", channel); + runAoETest(channel, 5, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + expected = 3; + writeTestMessage("distance 6", channel); + runAoETest(channel, 6, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + writeTestMessage("distance 7", channel); + runAoETest(channel, 7, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + writeTestMessage("distance 8", channel); + runAoETest(channel, 8, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + expected = 4; + writeTestMessage("distance 9", channel); + runAoETest(channel, 9, expected, xp); + for (int i = 0; i < expected; i++) + { + onGameTick(channel); + } + writeTestMessage("distance 10", channel); + runAoETest(channel, 10, expected, xp); + + performStateVerificationOrUpdate(channel, Paths.get(TESTDATA + "AoEKillManualCast.txt")); + } + + private void runAoETest(ByteArrayDataOutput channel, int distance, int expected, int xp) + throws Exception, IOException + { + final Player player = pluginMockSetup(); + // Ensure we're using correct magic + when(player.getAnimation()).thenReturn(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF.animationId); + + when(mockedClient.getLocalPlayer()).thenReturn(player); + final var it = iceElementals.iterator(); + final NPC target = it.next(); + // Set up hit delay by setting distance + final var bounds = getWorldForRegionId(11669); + final var wp = new WorldPoint(bounds.minX + 128, bounds.minY + 128, 0); + when(target.getWorldLocation()).thenReturn(wp); + when(player.getWorldLocation()).thenReturn(wp.dx(distance)); + when(player.getInteracting()).thenReturn(target); + while (it.hasNext()) + { + final var splashed = it.next(); + when(splashed.getWorldLocation()).thenReturn(wp); + } + + // Ensure we deal enough damage + underTest.onFakeXpDrop(new FakeXpDrop(Skill.HITPOINTS, xp)); + + onGameTick(channel); + + assertSame(AttackState.DELAYED_FIRST_TICK, underTest.attackState); + assertSame(expected, underTest.attackDelayHoldoffTicks); + + // clean up: + for (final var elemental : iceElementals) + { + underTest.onNpcDespawned(new NpcDespawned(elemental)); + } + } + + @Override + public Player pluginMockSetup() throws Exception + { + // enable the plugin + when(mockedConfig.enableMetronome()).thenReturn(true); + // Create player + Player mockedPlayer = mock(Player.class); + when(mockedPlayer.getAnimation()).thenReturn(-1); + + // need some extra mocks to stop the plugin running into an exception on the + // client APIs + // -- Mock World + + WorldView mockedWorldView = mock(WorldView.class); + when(mockedWorldView.isInstance()).thenReturn(true); + when(mockedWorldView.getInstanceTemplateChunks()).thenReturn(createInstanceTemplateChunks(11669)); + int mockedPlane = 0; + LocalPoint localPoint = LocalPoint.fromScene(30, 30, mockedWorldView); + when(mockedClient.getLocalPlayer()).thenReturn(mockedPlayer); + when(mockedPlayer.getLocalLocation()).thenReturn(localPoint); + when(mockedClient.getTopLevelWorldView()).thenReturn(mockedWorldView); + when(mockedClient.getWorldView(0)).thenReturn(mockedWorldView); + when(mockedWorldView.getPlane()).thenReturn(mockedPlane); + + // Finally turn the plugin "on" + underTest.startUp(); + + // Create the elementals + for (int i = 0; i < 3; i++) + { + final var ice = mock(NPC.class); + iceElementals.add(ice); + setNPCMock(ice, 14151); + underTest.onNpcSpawned(new NpcSpawned(ice)); + } + + return mockedPlayer; + } + + protected Set iceElementals = new HashSet(); + protected Set fireElementals = new HashSet(); +} diff --git a/src/test/java/com/attacktimer/TormentedDemonsTest.java b/src/test/java/com/attacktimer/TormentedDemonsTest.java index b9c69a0..7daff31 100644 --- a/src/test/java/com/attacktimer/TormentedDemonsTest.java +++ b/src/test/java/com/attacktimer/TormentedDemonsTest.java @@ -33,17 +33,15 @@ import com.google.common.io.ByteArrayDataOutput; import com.google.common.io.ByteStreams; import java.nio.file.Paths; -import java.util.ArrayList; import net.runelite.api.EnumComposition; import net.runelite.api.EnumID; -import net.runelite.api.IndexedObjectSet; import net.runelite.api.NPC; -import net.runelite.api.NPCComposition; import net.runelite.api.Player; import net.runelite.api.VarPlayer; import net.runelite.api.Varbits; import net.runelite.api.WorldView; import net.runelite.api.coords.LocalPoint; +import net.runelite.api.events.NpcSpawned; import net.runelite.client.game.ItemEquipmentStats; import net.runelite.client.game.ItemStats; import org.junit.Test; @@ -115,15 +113,8 @@ public Player pluginMockSetup() throws Exception // Create the tormented demon td = mock(NPC.class); - NPCComposition mockedCompositions = mock(NPCComposition.class); - when(td.getComposition()).thenReturn(mockedCompositions); int mockedNpcId = 13600; - when(td.getId()).thenReturn(mockedNpcId); - String[] actions = { - "Attack", "Examine", - }; - when(mockedCompositions.getActions()).thenReturn(actions); - when(mockedNpcManager.getHealth(mockedNpcId)).thenReturn(1); + setNPCMock(td, mockedNpcId); // set the player as "attacking" the NPC when(mockedClient.getLocalPlayer()).thenReturn(mockedPlayer); @@ -132,40 +123,22 @@ public Player pluginMockSetup() throws Exception // need some extra mocks to stop the plugin running into an exception on the // client APIs // -- Mock World - mockedWorldView = mock(WorldView.class); - when(mockedClient.getTopLevelWorldView()).thenReturn(mockedWorldView); - when(mockedClient.getWorldView(0)).thenReturn(mockedWorldView); + WorldView mockedWorldView = mock(WorldView.class); int mockedPlane = 0; - when(mockedWorldView.getPlane()).thenReturn(mockedPlane); LocalPoint localPoint = new LocalPoint(0, 0, mockedPlane); when(mockedPlayer.getLocalLocation()).thenReturn(localPoint); + when(mockedClient.getTopLevelWorldView()).thenReturn(mockedWorldView); + when(mockedClient.getWorldView(0)).thenReturn(mockedWorldView); + when(mockedWorldView.getPlane()).thenReturn(mockedPlane); // -- NPCs - worldViewNPCiter(td); // Finally turn the plugin "on" underTest.startUp(); + + // Only spawn the demon once the plugin is on. + underTest.onNpcSpawned(new NpcSpawned(td)); return mockedPlayer; } - protected WorldView mockedWorldView; protected NPC td; - - private void worldViewNPCiter(NPC mockedTarget) - { - // do a bit of redirection trickery to get a IndexedObjectSet of a non empty list. - var npcs = new ArrayList(1); - npcs.add(mockedTarget); - IndexedObjectSet npcsType = mock(IndexedObjectSet.class); - when(npcsType.iterator()).thenReturn(npcs.iterator()); - when(mockedWorldView.npcs()).thenReturn(npcsType); - } - - @Override - protected void onGameTick(ByteArrayDataOutput file) - { - super.onGameTick(file); - // Since an iterator is stateful and consumed by the plugin we re-mock it each time so it's always - // fresh. - worldViewNPCiter(td); - } } diff --git a/src/test/java/com/attacktimer/testdata/AoEKillManualCast.txt b/src/test/java/com/attacktimer/testdata/AoEKillManualCast.txt new file mode 100644 index 0000000..3c0d925 --- /dev/null +++ b/src/test/java/com/attacktimer/testdata/AoEKillManualCast.txt @@ -0,0 +1,74 @@ +tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 1 +tickPeriod: 2, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 2, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 2 +tickPeriod: 2, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 2, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 3 +tickPeriod: 3, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 3, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 3, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 4 +tickPeriod: 3, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 3, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 3, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 5 +tickPeriod: 3, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 3, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 3, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 6 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 7 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 8 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 9 +tickPeriod: 5, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 4, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 5, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 5, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 5, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 5, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 10 +tickPeriod: 5, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 4, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 diff --git a/src/test/java/com/attacktimer/testdata/PunishTest.txt b/src/test/java/com/attacktimer/testdata/PunishTest.txt index e4ac33d..a0a3f88 100644 --- a/src/test/java/com/attacktimer/testdata/PunishTest.txt +++ b/src/test/java/com/attacktimer/testdata/PunishTest.txt @@ -2,5 +2,5 @@ tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, dmgDealt: pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 tickPeriod: 0, uiHideDebounceTickCount: -1, attackDelayHoldoffTicks: -1, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: -1, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 0, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 diff --git a/src/test/java/com/attacktimer/testdata/PunishWastedTest.txt b/src/test/java/com/attacktimer/testdata/PunishWastedTest.txt index e4ac33d..a0a3f88 100644 --- a/src/test/java/com/attacktimer/testdata/PunishWastedTest.txt +++ b/src/test/java/com/attacktimer/testdata/PunishWastedTest.txt @@ -2,5 +2,5 @@ tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, dmgDealt: pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 tickPeriod: 0, uiHideDebounceTickCount: -1, attackDelayHoldoffTicks: -1, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: -1, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 0, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 diff --git a/src/test/java/com/attacktimer/testdata/PunishWastedWrongStyleTest.txt b/src/test/java/com/attacktimer/testdata/PunishWastedWrongStyleTest.txt index 047348d..60d5438 100644 --- a/src/test/java/com/attacktimer/testdata/PunishWastedWrongStyleTest.txt +++ b/src/test/java/com/attacktimer/testdata/PunishWastedWrongStyleTest.txt @@ -2,5 +2,5 @@ tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, dmgDealt: pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 tickPeriod: 0, uiHideDebounceTickCount: -1, attackDelayHoldoffTicks: -1, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 -tickPeriod: 8, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 7, dmgDealt: -1, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +tickPeriod: 8, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 7, dmgDealt: 0, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 diff --git a/src/test/java/com/attacktimer/testdata/SingleKillManualCast.txt b/src/test/java/com/attacktimer/testdata/SingleKillManualCast.txt new file mode 100644 index 0000000..758fdc1 --- /dev/null +++ b/src/test/java/com/attacktimer/testdata/SingleKillManualCast.txt @@ -0,0 +1,88 @@ +tickPeriod: 0, uiHideDebounceTickCount: 0, attackDelayHoldoffTicks: 0, dmgDealt: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 2 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 3 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 4 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 5 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 6 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 7 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 8 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 9 +tickPeriod: 5, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 4, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 5, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 3, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 5, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 2, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 5, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 1, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 5, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 0, dmgDealt: 45, attackState: DELAYED, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] distance 10 +tickPeriod: 5, uiHideDebounceTickCount: 1, attackDelayHoldoffTicks: 4, dmgDealt: 45, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, lastTarget: null +pendingEatDelayTicks: 0, currentSpellBook: STANDARD, soundEffectTick: -1, soundEffectId: -1 From e8dbfa0cf35a0a64762b533bcf85cc5754a6ccce Mon Sep 17 00:00:00 2001 From: Lexer747 Date: Fri, 7 Aug 2026 16:41:58 +0100 Subject: [PATCH 6/7] version bump --- README.md | 4 ++++ runelite-plugin.properties | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bdfdfbb..4802c4c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ Ticks until next attack may be enabled over your player's head. ## Updates +## 1.2.11 + +* Support for Royal Titans attack cooldown speedup when killing elementals + ## 1.2.8 - 1.2.10 * Fixes for Wyrmscraig (Hallowfell) diff --git a/runelite-plugin.properties b/runelite-plugin.properties index f832d09..dc04804 100644 --- a/runelite-plugin.properties +++ b/runelite-plugin.properties @@ -1,7 +1,7 @@ displayName=AttackTimer author=ngraves95,Lexer747 build=standard -version=1.2.10 +version=1.2.11 description=A plugin to countdown until your next attack tags=pvm,timer,attack,combat,weapon plugins=com.attacktimer.AttackTimerMetronomePlugin \ No newline at end of file From 0b1dfd6cb143fc5900bbf2e9ee4908b910e74fc1 Mon Sep 17 00:00:00 2001 From: Lexer747 Date: Fri, 7 Aug 2026 16:47:31 +0100 Subject: [PATCH 7/7] fix comment indentation --- src/test/java/com/attacktimer/IntegrationTests.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/attacktimer/IntegrationTests.java b/src/test/java/com/attacktimer/IntegrationTests.java index 1a85f84..6fb3bd5 100644 --- a/src/test/java/com/attacktimer/IntegrationTests.java +++ b/src/test/java/com/attacktimer/IntegrationTests.java @@ -247,13 +247,10 @@ protected int[][][] createInstanceTemplateChunks(int regionId) private static final byte[] PREFIX = "[TEST MESSAGE] ".getBytes(StandardCharsets.UTF_8); private static final byte[] SUFFIX = "\n".getBytes(StandardCharsets.UTF_8); - // This needs at least one public test to keep mockito happy but having real tests in this file - // would - // result in any future test which extends this test class also having to run and make that test - // pass. + // This needs at least one public test to keep mockito happy but having real tests in this file would + // result in any future test which extends this test class also having to run and make that test pass. // - // This does cause test inflation in that the summary will make it look like we have more tests than - // we + // This does cause test inflation in that the summary will make it look like we have more tests than we // really do, but I don't have a nicer way to not repeat all the injector boiler plate. @Test public void noTest()