Skip to content
Merged
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/net/tfminecraft/magic/Cache.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ public final class Cache {
public static double castDriftMin = 0.01;
/** Seconds between repeat chat lines explaining why a weapon refused a spell. */
public static long refuseChatMillis = 30000L;
public static final int DEFAULT_CAST_WEAR = 1;
/** Vanilla durability a spent cast takes from the held mage weapon. 0 turns cast wear off. */
public static int castWear = DEFAULT_CAST_WEAR;
/**
* Extra MythicLib triggers treated as a cast, from {@code runes.keybinds}.
* Names are uppercase. {@code CAST} and {@code API} are always included in the check.
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/net/tfminecraft/magic/Magic.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import net.tfminecraft.magic.listener.ResonanceCastListener;
import net.tfminecraft.magic.listener.SacrificeListener;
import net.tfminecraft.magic.listener.SealedArtifactListener;
import net.tfminecraft.magic.listener.WeaponWearListener;
import net.tfminecraft.magic.loader.ChargesLoader;
import net.tfminecraft.magic.loader.ConfigLoader;
import net.tfminecraft.magic.loader.ElementsLoader;
Expand Down Expand Up @@ -120,6 +121,7 @@ public void onEnable() {
getServer().getPluginManager().registerEvents(new GearRefreshListener(), this);
if (isMmoStackPresent()) {
getServer().getPluginManager().registerEvents(new ResonanceCastListener(), this);
getServer().getPluginManager().registerEvents(new WeaponWearListener(), this);
getServer().getPluginManager().registerEvents(new CastDriftListener(), this);
getServer().getPluginManager().registerEvents(new GearAlignmentListener(), this);
}
Expand Down
45 changes: 45 additions & 0 deletions src/main/java/net/tfminecraft/magic/gear/WeaponWear.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package net.tfminecraft.magic.gear;

import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.Damageable;
import org.bukkit.inventory.meta.ItemMeta;

/**
* Vanilla durability on mage weapons. Spent casts and wand shots wear it, but the last
* point is never spent: MMOItems deletes a weapon whose vanilla durability runs out. A
* weapon on its last point is worn out and refuses spells and shots until it is repaired.
*/
public final class WeaponWear {

private WeaponWear() {}

/** Durability left, or -1 when the stack has no vanilla durability that can wear. */
public static int remaining(ItemStack stack) {
ItemMeta meta = stack == null ? null : stack.getItemMeta();
if (!(meta instanceof Damageable damageable) || meta.isUnbreakable()) {
return -1;
}
int max = damageable.hasMaxDamage() ? damageable.getMaxDamage() : stack.getType().getMaxDurability();
if (max <= 0) {
return -1;
}
return Math.max(0, max - damageable.getDamage());
}

/** True when only the last point, or none, is left. */
public static boolean isWorn(ItemStack stack) {
int left = remaining(stack);
return left >= 0 && left <= 1;
}

/** Spends up to {@code points} durability, stopping at the last point. */
public static void wear(ItemStack stack, int points) {
int left = remaining(stack);
if (points <= 0 || left <= 1) {
return;
}
Damageable meta = (Damageable) stack.getItemMeta();
meta.setDamage(meta.getDamage() + Math.min(points, left - 1));
stack.setItemMeta(meta);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,17 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;

import org.bukkit.GameMode;
import org.bukkit.Sound;
import org.bukkit.SoundCategory;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;

import io.lumine.mythic.lib.api.event.skill.PlayerCastSkillEvent;
import io.lumine.mythic.lib.api.item.NBTItem;
import io.lumine.mythic.lib.skill.Skill;
import net.Indyuce.mmoitems.api.interaction.util.DurabilityItem;
import net.tfminecraft.magic.util.MagicText;
import net.tfminecraft.magic.Cache;
import net.tfminecraft.magic.Magic;
Expand All @@ -28,6 +26,7 @@
import net.tfminecraft.magic.gear.GearProvenance;
import net.tfminecraft.magic.gear.WeaponRequirement;
import net.tfminecraft.magic.gear.WeaponRift;
import net.tfminecraft.magic.gear.WeaponWear;
import net.tfminecraft.magic.integration.SkillIdResolver;
import net.tfminecraft.magic.model.ElementDef;
import net.tfminecraft.magic.registry.ElementRegistry;
Expand All @@ -50,8 +49,9 @@
* is rolled only once refusal and overload have passed, and a failed roll spends mana
* and cooldown.
*
* <p>Every spent cast (success, overload whiff, or rift whiff) also costs one MMOItems
* durability on the held weapon.
* <p>Every spent cast (success, overload whiff, or rift whiff) also costs
* {@code cast.wear_per_cast} vanilla durability on the held weapon, never the last point.
* A weapon on its last point is worn out: it refuses every spell, for free, until repaired.
*/
public final class ResonanceCastListener implements Listener {

Expand Down Expand Up @@ -91,6 +91,11 @@ public void onPlayerCastSkill(PlayerCastSkillEvent event) {
broken(player, weapon, elementId);
return;
}
if (WeaponWear.isWorn(weapon)) {
event.setCancelled(true);
worn(player, weapon, elementId);
return;
}
if (refuse(player, weapon, elementId, spellTier)) {
event.setCancelled(true);
return;
Expand All @@ -114,16 +119,11 @@ public void onPlayerCastSkill(PlayerCastSkillEvent event) {
}

private static void wearWeapon(Player player, HeldSlot slot, ItemStack weapon) {
NBTItem nbt = NBTItem.get(weapon);
if (nbt.getInteger("MMOITEMS_MAX_DURABILITY") <= 0) {
if (Cache.castWear <= 0 || player.getGameMode() == GameMode.CREATIVE) {
return;
}
DurabilityItem durability = DurabilityItem.from(player, nbt,
slot == HeldSlot.MAIN_HAND ? EquipmentSlot.HAND : EquipmentSlot.OFF_HAND);
if (durability == null) {
return;
}
GearHand.setHeld(player, slot, durability.decreaseDurability(1).toItem());
WeaponWear.wear(weapon, Cache.castWear);
GearHand.setHeld(player, slot, weapon);
}

/** @return true when this spell cannot fire on this weapon for this caster */
Expand Down Expand Up @@ -197,6 +197,20 @@ private static void broken(Player player, ItemStack weapon, String elementId) {
}
}

/** A worn-out weapon refuses everything, so nothing is spent. Shares the refusal rate limiter. */
// Keep the existing legacy text representation, formatting, and exact-string comparisons.
@SuppressWarnings("deprecation")
private static void worn(Player player, ItemStack weapon, String elementId) {
player.sendTitle(
Messages.get("cast.worn_title"),
Messages.get("cast.worn_sub"),
0, 25, 10);
player.playSound(player.getLocation(), Sound.BLOCK_BEACON_DEACTIVATE, SoundCategory.PLAYERS, 0.7f, 0.6f);
if (claimChat(player, weapon, elementId)) {
player.sendMessage(Messages.get("cast.worn_chat"));
}
}

// Keep the existing legacy text representation, formatting, and exact-string comparisons.
@SuppressWarnings("deprecation")
private static void whiff(Player player, int rift) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package net.tfminecraft.magic.listener;

import org.bukkit.Sound;
import org.bukkit.SoundCategory;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerItemDamageEvent;
import org.bukkit.inventory.ItemStack;

import net.Indyuce.mmoitems.api.event.item.UntargetedWeaponUseEvent;
import net.tfminecraft.magic.Messages;
import net.tfminecraft.magic.gear.GearProvenance;
import net.tfminecraft.magic.gear.WeaponWear;

/**
* Keeps mage weapon wear to spells and wand shots.
*
* <p>An ArmourShop skin tags the weapon as an ItemsAdder item, and ItemsAdder then wears it
* on every melee hit through a {@link PlayerItemDamageEvent}. Vanilla never damages a stick
* or paper base, so on those that event is cancelled. A sword base keeps its vanilla wear,
* but wear that would reach the last point is cancelled outright: ItemsAdder only checks
* whether its event was cancelled and then applies its own amount, so lowering the amount
* would not protect the weapon.
*
* <p>MMOItems wears a wand by one per shot and deletes it when the durability runs out, so
* a worn-out wand refuses the shot instead.
*/
public final class WeaponWearListener implements Listener {

@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onItemDamage(PlayerItemDamageEvent event) {
ItemStack item = event.getItem();
if (!GearProvenance.isGear(item)) {
return;
}
if (item.getType().getMaxDurability() <= 0 || event.getDamage() >= WeaponWear.remaining(item)) {
event.setCancelled(true);
}
}

// Keep the existing legacy text representation, formatting, and exact-string comparisons.
@SuppressWarnings("deprecation")
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onWeaponShot(UntargetedWeaponUseEvent event) {
ItemStack item = event.getWeapon().getItem();
if (!GearProvenance.isGear(item) || !WeaponWear.isWorn(item)) {
return;
}
event.setCancelled(true);
Player player = event.getPlayer();
player.sendTitle(Messages.get("cast.worn_title"), Messages.get("cast.worn_sub"), 0, 25, 10);
player.playSound(player.getLocation(), Sound.BLOCK_BEACON_DEACTIVATE, SoundCategory.PLAYERS, 0.7f, 0.6f);
}
}
3 changes: 3 additions & 0 deletions src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ public boolean loadSafe(File configFile) {
long seconds = cast.getLong("refuse_chat_seconds", 30L);
Cache.refuseChatMillis = Math.max(0L, seconds) * 1000L;
}
Cache.castWear = Math.max(0, cast == null
? Cache.DEFAULT_CAST_WEAR
: cast.getInt("wear_per_cast", Cache.DEFAULT_CAST_WEAR));
loadCastTriggers(config.getStringList("runes.keybinds"));

ConfigurationSection artifacts = config.getConfigurationSection("artifacts");
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ meditation:
# spells out what you are short of, per player, weapon and element.
cast:
refuse_chat_seconds: 30
# Vanilla durability every spent cast (success or fumble) takes from the held mage weapon.
# The last point is never spent: a weapon on it refuses spells until repaired. 0 = off.
wear_per_cast: 1

# Drift from spending mana on a gated cast. 4 mana -> 0.04, 10 mana -> 0.10 (mana / mana_divisor).
# Surge adds corruption (negative equilibrium). Flow adds tranquility (positive).
Expand Down
7 changes: 7 additions & 0 deletions src/main/resources/messages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ cast:

broken_chat: "#ff5555This weapon is damaged. Right-click an empty station to reclaim its runes."

# Worn out: the weapon is on its last point of durability. Nothing is spent until it is repaired.
worn_title: "§7*Worn out*"

worn_sub: "#aaaaaa§oThis weapon needs repair"

worn_chat: "#ff5555This weapon is worn out. Repair it with a Magical Repair Kit before casting again."

resonance:

admin:
Expand Down
18 changes: 18 additions & 0 deletions src/test/java/net/tfminecraft/magic/ConfigurationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,24 @@ void runtimeConfigValidatesBoundsAndSupportsLegacyFields() throws Exception {
loader.load(Path.of("src/main/resources/config.yml").toFile());
}

@Test
void castWearReadsPerCastAmountFloorsAtZeroAndDefaultsToOne() throws Exception {
var loader = new ConfigLoader();
try {
assertTrue(loader.loadSafe(write("wear.yml", "cast: {wear_per_cast: 3}\n").toFile()));
assertEquals(3, Cache.castWear);
assertTrue(loader.loadSafe(write("wear-negative.yml", "cast: {wear_per_cast: -2}\n").toFile()));
assertEquals(0, Cache.castWear);
assertTrue(loader.loadSafe(write("wear-missing.yml", "cast: {refuse_chat_seconds: 5}\n").toFile()));
assertEquals(1, Cache.castWear);
Cache.castWear = 4;
assertTrue(loader.loadSafe(write("wear-absent.yml", "{}").toFile()));
assertEquals(1, Cache.castWear);
} finally {
Cache.castWear = Cache.DEFAULT_CAST_WEAR;
}
}

@Test
void reattuneFeeShareFallsBackToHalfWhenMissingNegativeOrNotANumber() throws Exception {
var loader = new ConfigLoader();
Expand Down
Loading
Loading