Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/main/java/studio/magemonkey/fabled/Fabled.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
import studio.magemonkey.fabled.data.io.IOManager;
import studio.magemonkey.fabled.data.io.PlayerLoader;
import studio.magemonkey.fabled.data.sql.SQLManager;
import studio.magemonkey.fabled.dynamic.condition.AttackIndicatorCondition;
import studio.magemonkey.fabled.dynamic.DynamicClass;
import studio.magemonkey.fabled.dynamic.DynamicSkill;
import studio.magemonkey.fabled.exception.FabledNotEnabledException;
Expand Down Expand Up @@ -642,6 +643,8 @@ public void onEnable() {
listen(new ToolListener(), true);
listen(new KillListener(), true);
listen(new AddonListener(), true);
Bukkit.getPluginManager().registerEvents(
new AttackIndicatorCondition.ShootListener(), this);
listen(new ClickListener(), true);
listen(new BarListener(), settings.isSkillBarEnabled());
listen(new ComboListener(), settings.isCombosEnabled());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,73 @@
package studio.magemonkey.fabled.dynamic.condition;

import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityShootBowEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.CrossbowMeta;
import org.bukkit.metadata.FixedMetadataValue;
import studio.magemonkey.fabled.Fabled;

import java.lang.reflect.Method;

public class AttackIndicatorCondition extends ConditionComponent {
private static final String MIN = "min";
private static final String MAX = "max";
private static final String MIN = "min";
private static final String MAX = "max";
private static final String WEAPON = "weapon";

// Metadata key — set by ShootListener when player fires bow/crossbow.
// Read by getIndicatorValue when normal polling fails (post-launch state).
private static final String LAST_SHOOT_FORCE_META = "fabled_last_shoot_force";

// Paper-only APIs accessed via reflection — runtime is Paper, but compile classpath is Spigot.
private static Method ACTIVE_ITEM_METHOD;
private static Method IS_CHARGED_METHOD;

private static ItemStack getActiveItemSafe(Player player) {
try {
if (ACTIVE_ITEM_METHOD == null) {
ACTIVE_ITEM_METHOD = Player.class.getMethod("getActiveItem");
}
return (ItemStack) ACTIVE_ITEM_METHOD.invoke(player);
} catch (Throwable ignored) {
return player.getInventory().getItemInMainHand();
}
}

private static boolean isChargedSafe(CrossbowMeta meta) {
try {
if (IS_CHARGED_METHOD == null) {
IS_CHARGED_METHOD = CrossbowMeta.class.getMethod("isCharged");
}
return (Boolean) IS_CHARGED_METHOD.invoke(meta);
} catch (Throwable ignored) {
return meta.hasChargedProjectiles();
}
}

/**
* Static listener that captures bow/crossbow shoot force as metadata on the shooter.
* EntityShootBowEvent fires BEFORE ProjectileLaunchEvent (Launch trigger). Metadata is
* read by AttackIndicator on the same tick, then cleared one tick later.
*/
public static class ShootListener implements Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onShoot(EntityShootBowEvent e) {
if (!(e.getEntity() instanceof Player)) return;
final Player p = (Player) e.getEntity();
p.setMetadata(LAST_SHOOT_FORCE_META,
new FixedMetadataValue(Fabled.inst(), (double) e.getForce()));
Bukkit.getScheduler().runTaskLater(Fabled.inst(),
() -> p.removeMetadata(LAST_SHOOT_FORCE_META, Fabled.inst()), 1L);
}
}

@Override
public String getKey() {
Expand All @@ -15,12 +77,57 @@ public String getKey() {
@Override
public boolean test(LivingEntity caster, int level, LivingEntity target) {
double min = parseValues(target, MIN, level, settings.getFloat(MIN, 0));
double max = parseValues(target, MAX, level, settings.getFloat(MAX, 0));
double max = parseValues(target, MAX, level, settings.getFloat(MAX, 1));

if (!(caster instanceof Player))
return false;
Player player = (Player) caster;

return player.getAttackCooldown() >= min && player.getAttackCooldown() <= max;
double value = getIndicatorValue(player);
return value >= min && value <= max;
}

private double getIndicatorValue(Player player) {
String weapon = settings.getString(WEAPON, "auto").toLowerCase();
ItemStack main = player.getInventory().getItemInMainHand();
Material type = main.getType();

// Post-launch path — Launch trigger fires after release/fire, so polling player state
// returns 0. Use force snapshot captured by ShootListener on EntityShootBowEvent.
if (player.hasMetadata(LAST_SHOOT_FORCE_META)) {
try {
if ((weapon.equals("auto") && (type == Material.BOW || type == Material.CROSSBOW))
|| weapon.equals("bow") || weapon.equals("crossbow")) {
return Math.min(1.0, player.getMetadata(LAST_SHOOT_FORCE_META).get(0).asDouble());
}
} catch (Exception ignored) { /* fall through to polling path */ }
}

if (weapon.equals("bow") || (weapon.equals("auto") && type == Material.BOW)) {
if (!player.isHandRaised() || getActiveItemSafe(player).getType() != Material.BOW)
return 0.0;
return Math.min(1.0, player.getItemInUseTicks() / 20.0);
}

if (weapon.equals("crossbow") || (weapon.equals("auto") && type == Material.CROSSBOW)) {
ItemStack crossbow = (type == Material.CROSSBOW) ? main : player.getInventory().getItemInOffHand();
if (crossbow.getType() != Material.CROSSBOW)
return 0.0;
if (crossbow.getItemMeta() instanceof CrossbowMeta) {
CrossbowMeta meta = (CrossbowMeta) crossbow.getItemMeta();
if (isChargedSafe(meta))
return 1.0;
}
// Partially loaded (player is drawing crossbow)
if (player.isHandRaised() && getActiveItemSafe(player).getType() == Material.CROSSBOW) {
Enchantment quickCharge = Enchantment.getByKey(NamespacedKey.minecraft("quick_charge"));
int qcLevel = (quickCharge != null) ? crossbow.getEnchantmentLevel(quickCharge) : 0;
int loadTicks = Math.max(5, 25 - qcLevel * 5);
return Math.min(1.0, player.getItemInUseTicks() / (double) loadTicks);
}
return 0.0;
}

return player.getAttackCooldown();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package studio.magemonkey.fabled.dynamic.condition;

import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.CrossbowMeta;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import studio.magemonkey.codex.mccore.config.parse.DataSection;
import studio.magemonkey.fabled.testutil.MockedTest;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Covers the weapon-mode targeting and range check in AttackIndicatorCondition.
* <p>
* Not covered here: the ShootListener metadata-capture path (needs a real
* EntityShootBowEvent, whose constructor isn't exercised anywhere else in this codebase
* to model against) and the Paper-only reflection fallbacks for getActiveItem /
* CrossbowMeta#isCharged - those should get manual/in-game verification since MockBukkit's
* support for them is unverified here.
*/
public class AttackIndicatorConditionTest extends MockedTest {
private Player player;

@BeforeEach
void setup() {
player = genPlayer("Travja");
}

private AttackIndicatorCondition getCondition(String weapon, double min, double max) {
AttackIndicatorCondition condition = new AttackIndicatorCondition();
DataSection config = new DataSection();
DataSection data = new DataSection();
if (weapon != null) data.set("weapon", weapon);
data.set("min-base", min);
data.set("min-scale", 0);
data.set("max-base", max);
data.set("max-scale", 0);
config.set("data", data);
condition.load(null, config);
return condition;
}

@Test
void test_chargedCrossbow_reportsFullValue() {
ItemStack crossbow = new ItemStack(Material.CROSSBOW);
CrossbowMeta meta = (CrossbowMeta) crossbow.getItemMeta();
meta.setChargedProjectiles(List.of(new ItemStack(Material.ARROW)));
crossbow.setItemMeta(meta);
player.getInventory().setItemInMainHand(crossbow);

AttackIndicatorCondition condition = getCondition("crossbow", 0.9, 1.0);
assertTrue(condition.test(player, 1, player));
}

@Test
void test_unchargedCrossbow_notWithinHighRange() {
player.getInventory().setItemInMainHand(new ItemStack(Material.CROSSBOW));

AttackIndicatorCondition condition = getCondition("crossbow", 0.9, 1.0);
assertFalse(condition.test(player, 1, player));
}

@Test
void test_nonCasterTarget_alwaysFalse() {
AttackIndicatorCondition condition = getCondition(null, 0.0, 1.0);
// AttackIndicatorCondition only evaluates against a Player caster; a non-player
// caster short-circuits to false regardless of range.
assertFalse(condition.test(null, 1, player));
}
}
Loading