From f9d28e0c91184ba6d8795a7081816e095383dde8 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:29:01 +0000 Subject: [PATCH 1/4] Add bog monster kill and lure rewards Ambient swamp_mobs and swamp_mobs_hill kills by a player roll a bonus loot table from rewards.yml. Unknown Keys are the main reward, and permadeath zones pay far more than outside, scaling with severity. Summoned parasitic worms roll nothing. Ambient keys are capped per player per rolling hour (40 by default, 0 turns it off). A cleared lure bursts owner-locked loot out of the lure for each committed player who is alive, online, on their committed character and has at least 3 lure kills. Outside a permadeath zone each item only survives with its keep chance. Lure kills persist with the infestation. Co-Authored-By: Claude Opus 5.5 --- .../infestations/Infestations.java | 4 +- .../database/InfestationData.java | 1 + .../database/InfestationDatabase.java | 12 + .../infestations/infestation/Infestation.java | 15 ++ .../infestation/InfestationManager.java | 24 ++ .../infestations/reward/KeyCap.java | 39 +++ .../infestations/reward/PermadeathZones.java | 52 ++++ .../infestations/reward/RewardConfig.java | 190 +++++++++++++++ .../infestations/reward/RewardDrops.java | 159 ++++++++++++ .../infestations/reward/RewardRoller.java | 147 +++++++++++ .../infestations/reward/RewardTable.java | 23 ++ .../tfminecraft/infestations/spawn/Keys.java | 13 + src/main/resources/messages.yml | 1 + src/main/resources/rewards.yml | 192 +++++++++++++++ .../InfestationsLifecycleTest.java | 9 +- .../infestations/InfestationsTestState.java | 6 +- .../database/InfestationDataTest.java | 15 +- .../infestation/InfestationManagerTest.java | 49 ++++ .../infestations/reward/KeyCapTest.java | 26 ++ .../reward/PermadeathZonesTest.java | 91 +++++++ .../infestations/reward/RewardConfigTest.java | 156 ++++++++++++ .../infestations/reward/RewardDropsTest.java | 230 ++++++++++++++++++ .../infestations/reward/RewardRollerTest.java | 78 ++++++ .../infestations/spawn/SpawnServicesTest.java | 1 + .../permadeath/PermadeathAreaLookup.java | 18 ++ 25 files changed, 1546 insertions(+), 5 deletions(-) create mode 100644 src/main/java/net/tfminecraft/infestations/reward/KeyCap.java create mode 100644 src/main/java/net/tfminecraft/infestations/reward/PermadeathZones.java create mode 100644 src/main/java/net/tfminecraft/infestations/reward/RewardConfig.java create mode 100644 src/main/java/net/tfminecraft/infestations/reward/RewardDrops.java create mode 100644 src/main/java/net/tfminecraft/infestations/reward/RewardRoller.java create mode 100644 src/main/java/net/tfminecraft/infestations/reward/RewardTable.java create mode 100644 src/main/resources/rewards.yml create mode 100644 src/test/java/net/tfminecraft/infestations/reward/KeyCapTest.java create mode 100644 src/test/java/net/tfminecraft/infestations/reward/PermadeathZonesTest.java create mode 100644 src/test/java/net/tfminecraft/infestations/reward/RewardConfigTest.java create mode 100644 src/test/java/net/tfminecraft/infestations/reward/RewardDropsTest.java create mode 100644 src/test/java/net/tfminecraft/infestations/reward/RewardRollerTest.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/permadeath/PermadeathAreaLookup.java diff --git a/src/main/java/net/tfminecraft/infestations/Infestations.java b/src/main/java/net/tfminecraft/infestations/Infestations.java index e10496d..fb881b0 100644 --- a/src/main/java/net/tfminecraft/infestations/Infestations.java +++ b/src/main/java/net/tfminecraft/infestations/Infestations.java @@ -87,6 +87,7 @@ private boolean loadConfigs() { ok &= configLoader.loadSafe(new File(getDataFolder(), "config.yml")); Messages.load(new File(getDataFolder(), "messages.yml")); ok &= groupLoader.loadSafe(new File(getDataFolder(), "groups.yml")); + ok &= net.tfminecraft.infestations.reward.RewardConfig.loadSafe(new File(getDataFolder(), "rewards.yml")); net.tfminecraft.infestations.spawn.SpawnLog.configure( Cache.loggingEnabled, Cache.wipeLog, getDataFolder()); if (ok) { @@ -127,7 +128,8 @@ private void createConfigs() { String[] defaultFiles = { "config.yml", "messages.yml", - "groups.yml" + "groups.yml", + "rewards.yml" }; for (String path : defaultFiles) { copyResourceIfMissing(path); diff --git a/src/main/java/net/tfminecraft/infestations/database/InfestationData.java b/src/main/java/net/tfminecraft/infestations/database/InfestationData.java index 1b07c23..dfc3965 100644 --- a/src/main/java/net/tfminecraft/infestations/database/InfestationData.java +++ b/src/main/java/net/tfminecraft/infestations/database/InfestationData.java @@ -24,6 +24,7 @@ public class InfestationData { public int lureReleased; public List committed = new ArrayList<>(); public Map logoutGraceUntil = new HashMap<>(); + public Map lureKills = new HashMap<>(); public List deathOnLogin = new ArrayList<>(); public Map logoutCharacter = new HashMap<>(); public Map> owedDeaths = new HashMap<>(); diff --git a/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java b/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java index 9ab3e80..7bddeff 100644 --- a/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java +++ b/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java @@ -121,6 +121,15 @@ private static Infestation fromData(InfestationData row) { } } } + if (row.lureKills != null) { + for (Map.Entry e : row.lureKills.entrySet()) { + if (e.getKey() == null || e.getValue() == null) continue; + try { + infestation.getLureKills().put(UUID.fromString(e.getKey()), e.getValue()); + } catch (IllegalArgumentException ignored) { + } + } + } if (row.deathOnLogin != null) { for (String id : row.deathOnLogin) { if (id == null) continue; @@ -178,6 +187,9 @@ private static InfestationData toData(Infestation infestation) { for (Map.Entry e : infestation.getLogoutGraceUntil().entrySet()) { row.logoutGraceUntil.put(e.getKey().toString(), e.getValue()); } + for (Map.Entry e : infestation.getLureKills().entrySet()) { + row.lureKills.put(e.getKey().toString(), e.getValue()); + } for (UUID id : infestation.getDeathOnLogin()) { row.deathOnLogin.add(id.toString()); } diff --git a/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java b/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java index 3d1edc1..d4369d1 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java @@ -41,6 +41,7 @@ public long getLureGeneration() { private int victoryAtTick; private final Set committed = new HashSet<>(); private final Map logoutGraceUntil = new HashMap<>(); + private final Map lureKills = new HashMap<>(); private final Set deathOnLogin = new HashSet<>(); /** The character each committed player logged off as, so a death on login reaches only that character. */ private final Map logoutCharacter = new HashMap<>(); @@ -133,6 +134,7 @@ public void placeLure(Block block, long joinEndsAt, int budget) { this.enemiesAlive = 0; this.committed.clear(); this.logoutGraceUntil.clear(); + this.lureKills.clear(); this.deathOnLogin.clear(); this.logoutCharacter.clear(); this.waveRetryAtTick = 0; @@ -154,6 +156,7 @@ public void clearLure() { enemiesAlive = 0; committed.clear(); logoutGraceUntil.clear(); + lureKills.clear(); deathOnLogin.clear(); logoutCharacter.clear(); waveRetryAtTick = 0; @@ -300,6 +303,18 @@ public Map getLogoutGraceUntil() { return logoutGraceUntil; } + public Map getLureKills() { + return lureKills; + } + + public void addLureKill(UUID id) { + lureKills.merge(id, 1, Integer::sum); + } + + public int lureKills(UUID id) { + return lureKills.getOrDefault(id, 0); + } + public Set getDeathOnLogin() { return deathOnLogin; } diff --git a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java index 0023c30..eb34e05 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -72,6 +72,7 @@ import net.tfminecraft.infestations.lure.LureFurniture; import net.tfminecraft.infestations.lure.LureHologram; import net.tfminecraft.infestations.map.InfestationMapExport; +import net.tfminecraft.infestations.reward.RewardDrops; import net.tfminecraft.infestations.spawn.AmbientSpawnService; import net.tfminecraft.infestations.spawn.Keys; import net.tfminecraft.infestations.spawn.LureSpawnQuota; @@ -838,6 +839,7 @@ private void victory(Infestation infestation) { loc.getWorld().spawnParticle(Particle.FIREWORK, loc.clone().add(0, 1, 0), 20, 0.3, 0.5, 0.3, 0.05); loc.getWorld().playSound(loc, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f, 1.1f); } + RewardDrops.lureVictory(infestation, loc, rewardable(infestation)); for (Player player : Bukkit.getOnlinePlayers()) { if (Provinces.within(player, provinceId)) { player.sendMessage(Messages.get("lure-victory")); @@ -848,6 +850,19 @@ private void victory(Infestation infestation) { InfestationMapExport.exportAsync(byProvince.values()); } + /** Committed players who are online, alive and on the character that joined the lure. */ + private List rewardable(Infestation infestation) { + List players = new ArrayList<>(); + for (UUID id : infestation.getCommitted()) { + Player player = Bukkit.getPlayer(id); + if (player != null && player.isOnline() && !player.isDead() + && !infestation.getDeathOnLogin().contains(id) && !heldByOtherCharacter(infestation, player)) { + players.add(player); + } + } + return players; + } + private void destroyLure(Infestation infestation, boolean victory) { LureHologram.remove(infestation); pluginFurnitureRemove = true; @@ -1140,10 +1155,14 @@ public void onDeath(EntityDeathEvent event) { return; } recentDeaths.put(entity.getUniqueId(), new RecentDeath(provinceId, kind, tick)); + Player killer = entity.getKiller(); if (Keys.KIND_AMBIENT.equals(kind)) { if (infestation.getAmbientAlive() > 0) { infestation.setAmbientAlive(infestation.getAmbientAlive() - 1); } + if (killer != null && !Keys.isSummoned(entity.getPersistentDataContainer())) { + RewardDrops.ambientKill(infestation, killer, entity.getLocation(), event.getDrops()); + } return; } if (Keys.KIND_LURE.equals(kind) && infestation.getPhase() == LurePhase.ACTIVE) { @@ -1151,6 +1170,9 @@ public void onDeath(EntityDeathEvent event) { infestation.setEnemiesAlive(infestation.getEnemiesAlive() - 1); } infestation.setLureRemaining(infestation.getLureRemaining() - 1); + if (killer != null) { + infestation.addLureKill(killer.getUniqueId()); + } } } @@ -1183,11 +1205,13 @@ private void adoptSummon(ActiveMob mob) { } if (infestation.getPhase() == LurePhase.ACTIVE && Keys.KIND_LURE.equals(death.kind())) { Keys.tagMob(living.getPersistentDataContainer(), death.provinceId(), Keys.KIND_LURE); + Keys.markSummoned(living.getPersistentDataContainer()); infestation.setEnemiesAlive(infestation.getEnemiesAlive() + 1); infestation.setLureRemaining(infestation.getLureRemaining() + 1); SpawnLog.line(infestation, "-", "summoned remaining=" + infestation.getLureRemaining()); } else if (infestation.getPhase() == LurePhase.NONE && Keys.KIND_AMBIENT.equals(death.kind())) { Keys.tagMob(living.getPersistentDataContainer(), death.provinceId(), Keys.KIND_AMBIENT); + Keys.markSummoned(living.getPersistentDataContainer()); infestation.setAmbientAlive(infestation.getAmbientAlive() + 1); } } diff --git a/src/main/java/net/tfminecraft/infestations/reward/KeyCap.java b/src/main/java/net/tfminecraft/infestations/reward/KeyCap.java new file mode 100644 index 0000000..60f68a0 --- /dev/null +++ b/src/main/java/net/tfminecraft/infestations/reward/KeyCap.java @@ -0,0 +1,39 @@ +package net.tfminecraft.infestations.reward; + +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Counts ambient key drops per player over a rolling hour. + */ +final class KeyCap { + + static final long WINDOW_MS = 3_600_000L; + + private final Map> given = new HashMap<>(); + + /** How many of {@code wanted} keys the player may still get. A cap of 0 or less means no cap. */ + int allow(UUID player, int wanted, int cap, long now) { + if (wanted <= 0) { + return 0; + } + if (cap <= 0) { + return wanted; + } + ArrayDeque times = given.computeIfAbsent(player, k -> new ArrayDeque<>()); + while (!times.isEmpty() && times.peekFirst() <= now - WINDOW_MS) { + times.pollFirst(); + } + int allowed = Math.min(wanted, cap - times.size()); + for (int i = 0; i < allowed; i++) { + times.addLast(now); + } + return Math.max(0, allowed); + } + + void clear() { + given.clear(); + } +} diff --git a/src/main/java/net/tfminecraft/infestations/reward/PermadeathZones.java b/src/main/java/net/tfminecraft/infestations/reward/PermadeathZones.java new file mode 100644 index 0000000..3e4df02 --- /dev/null +++ b/src/main/java/net/tfminecraft/infestations/reward/PermadeathZones.java @@ -0,0 +1,52 @@ +package net.tfminecraft.infestations.reward; + +import java.lang.reflect.Method; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.plugin.Plugin; + +import net.tfminecraft.infestations.Infestations; + +/** + * Asks RPCharacters whether a location is inside a permadeath zone. Uses reflection so Infestations + * never depends on RPCharacters. Without RPCharacters, or on any error, the location is outside a zone. + */ +public final class PermadeathZones { + + static final String LOOKUP = "net.tfminecraft.rpcharacters.permadeath.PermadeathAreaLookup"; + + private static ClassLoader loader; + private static Method lookup; + private static boolean warned; + + private PermadeathZones() {} + + public static boolean inZone(Location location) { + if (location == null) { + return false; + } + Plugin plugin = Bukkit.getPluginManager().getPlugin("RPCharacters"); + if (plugin == null || !plugin.isEnabled()) { + return false; + } + try { + return method(plugin.getClass().getClassLoader()).invoke(null, location) != null; + } catch (ReflectiveOperationException | RuntimeException | LinkageError ex) { + if (!warned) { + warned = true; + Infestations.plugin.getLogger().warning( + "[Infestations] Permadeath zone lookup failed, treating locations as outside zones: " + ex); + } + return false; + } + } + + static Method method(ClassLoader classLoader) throws ReflectiveOperationException { + if (lookup == null || loader != classLoader) { + lookup = Class.forName(LOOKUP, true, classLoader).getMethod("getPermadeathZoneAt", Location.class); + loader = classLoader; + } + return lookup; + } +} diff --git a/src/main/java/net/tfminecraft/infestations/reward/RewardConfig.java b/src/main/java/net/tfminecraft/infestations/reward/RewardConfig.java new file mode 100644 index 0000000..3a9a1eb --- /dev/null +++ b/src/main/java/net/tfminecraft/infestations/reward/RewardConfig.java @@ -0,0 +1,190 @@ +package net.tfminecraft.infestations.reward; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +import net.tfminecraft.infestations.Infestations; +import net.tfminecraft.infestations.infestation.Severity; + +/** + * Settings from rewards.yml. A failed load keeps the previous settings. + */ +public final class RewardConfig { + + public static final String OUTSIDE = "outside"; + + public static boolean enabled; + public static Set groups = Set.of(); + public static List coins = List.of(); + public static String keyItem = "m.loot.trial_key"; + public static int keyCapPerHour; + public static Map ambient = Map.of(); + public static int lureMinKills = 3; + public static RewardRoller.Keep lureKeep = new RewardRoller.Keep(1, Map.of()); + public static Map lure = Map.of(); + + private RewardConfig() {} + + public static boolean loadSafe(File file) { + YamlConfiguration config = new YamlConfiguration(); + try { + config.load(file); + } catch (IOException | InvalidConfigurationException ex) { + Infestations.plugin.getLogger().severe("[Infestations] Failed to load rewards.yml: " + ex.getMessage()); + return false; + } + enabled = config.getBoolean("enabled", true); + Set groupIds = new HashSet<>(); + for (String id : config.getStringList("groups")) { + if (id != null && !id.isBlank()) { + groupIds.add(id.trim().toLowerCase(Locale.ROOT)); + } + } + groups = Set.copyOf(groupIds); + + List coinList = new ArrayList<>(); + for (Map row : config.getMapList("coins")) { + String item = string(row.get("item")); + int value = integer(row.get("value"), 0); + if (item != null && value > 0) { + coinList.add(new RewardTable.Coin(item, value)); + } + } + coins = List.copyOf(coinList); + + keyItem = config.getString("ambient.key-item", "m.loot.trial_key"); + keyCapPerHour = Math.max(0, config.getInt("ambient.key-cap-per-hour", 0)); + Map ambientTables = new HashMap<>(); + putTable(ambientTables, config, "ambient", OUTSIDE); + for (Severity severity : Severity.values()) { + putTable(ambientTables, config, "ambient", severity.id()); + } + ambient = Map.copyOf(ambientTables); + + lureMinKills = Math.max(0, config.getInt("lure.min-kills", 3)); + Map keepItems = new HashMap<>(); + for (Map row : config.getMapList("lure.outside-zone.keep-items")) { + String item = string(row.get("item")); + if (item != null) { + keepItems.put(item, percent(row.get("keep-percent"), 100)); + } + } + lureKeep = new RewardRoller.Keep( + percent(config.get("lure.outside-zone.keep-percent"), 100), keepItems); + Map lureTables = new HashMap<>(); + for (Severity severity : Severity.values()) { + putTable(lureTables, config, "lure", severity.id()); + } + lure = Map.copyOf(lureTables); + return true; + } + + public static boolean appliesTo(String groupId) { + if (!enabled) { + return false; + } + return groups.isEmpty() || (groupId != null && groups.contains(groupId.toLowerCase(Locale.ROOT))); + } + + public static RewardTable ambient(Severity severity, boolean inZone) { + return ambient.get(inZone ? severity.id() : OUTSIDE); + } + + public static RewardTable lure(Severity severity) { + return lure.get(severity.id()); + } + + private static void putTable(Map out, YamlConfiguration config, String root, String tier) { + ConfigurationSection section = config.getConfigurationSection(root + "." + tier); + if (section == null) { + return; + } + List drops = new ArrayList<>(); + for (Map row : section.getMapList("drops")) { + String item = string(row.get("item")); + if (item == null) { + continue; + } + int[] range = range(row.get("amount")); + drops.add(new RewardTable.Entry(item, percent(row.get("percent"), 100), range[0], range[1])); + } + List gems = new ArrayList<>(); + for (Map row : section.getMapList("gems")) { + String item = string(row.get("item")); + int weight = integer(row.get("weight"), 0); + if (item != null && weight > 0) { + gems.add(new RewardTable.Weighted(item, weight)); + } + } + out.put(tier, new RewardTable(drops, gems)); + } + + static String string(Object raw) { + if (raw == null) { + return null; + } + String value = raw.toString().trim(); + return value.isEmpty() ? null : value; + } + + static int integer(Object raw, int fallback) { + if (raw instanceof Number number) { + return number.intValue(); + } + try { + return raw == null ? fallback : Integer.parseInt(raw.toString().trim()); + } catch (NumberFormatException ex) { + return fallback; + } + } + + /** Reads a 0-100 percentage and returns a 0-1 chance. */ + static double percent(Object raw, double fallback) { + double value = fallback; + if (raw instanceof Number number) { + value = number.doubleValue(); + } else if (raw != null) { + try { + value = Double.parseDouble(raw.toString().trim()); + } catch (NumberFormatException ex) { + value = fallback; + } + } + return Math.clamp(value / 100.0, 0.0, 1.0); + } + + /** Reads {@code 3}, {@code "3"} or {@code "2-5"}. Bad values fall back to 1. */ + static int[] range(Object raw) { + if (raw instanceof Number number) { + int n = Math.max(0, number.intValue()); + return new int[] {n, n}; + } + String text = string(raw); + if (text == null) { + return new int[] {1, 1}; + } + int dash = text.indexOf('-', 1); + try { + if (dash < 0) { + int n = Math.max(0, Integer.parseInt(text)); + return new int[] {n, n}; + } + int a = Math.max(0, Integer.parseInt(text.substring(0, dash).trim())); + int b = Math.max(0, Integer.parseInt(text.substring(dash + 1).trim())); + return new int[] {Math.min(a, b), Math.max(a, b)}; + } catch (NumberFormatException ex) { + return new int[] {1, 1}; + } + } +} diff --git a/src/main/java/net/tfminecraft/infestations/reward/RewardDrops.java b/src/main/java/net/tfminecraft/infestations/reward/RewardDrops.java new file mode 100644 index 0000000..967e456 --- /dev/null +++ b/src/main/java/net/tfminecraft/infestations/reward/RewardDrops.java @@ -0,0 +1,159 @@ +package net.tfminecraft.infestations.reward; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.function.LongSupplier; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Sound; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.Vector; + +import net.tfminecraft.infestations.Infestations; +import net.tfminecraft.infestations.Messages; +import net.tfminecraft.infestations.infestation.Infestation; +import net.tfminecraft.tlibs.TLibs; + +/** + * Pays rewards.yml loot for ambient kills and cleared lures. + */ +public final class RewardDrops { + + /** Ticks over which a lure burst is spread. */ + static final int BURST_TICKS = 30; + /** Most entities one rolled stack is split into during a burst. */ + static final int MAX_PIECES = 3; + static final String LURE_REWARD_FALLBACK = "{prefix}#c9a44aThe lure bursts open. Your spoils are marked for you."; + + static Random random = new Random(); + static RewardRoller roller = new RewardRoller(random); + static LongSupplier clock = System::currentTimeMillis; + static KeyCap keyCap = new KeyCap(); + static final Set warnedPaths = new HashSet<>(); + + private RewardDrops() {} + + /** Adds the roll for one ambient mob killed by {@code killer} to the death drops. */ + public static void ambientKill(Infestation infestation, Player killer, Location location, List drops) { + if (!RewardConfig.appliesTo(infestation.getGroupId())) { + return; + } + RewardTable table = RewardConfig.ambient(infestation.getSeverity(), PermadeathZones.inZone(location)); + if (table == null) { + return; + } + for (RewardRoller.Drop drop : roller.roll(table, RewardConfig.coins, null)) { + int amount = drop.amount(); + if (drop.item().equals(RewardConfig.keyItem)) { + amount = keyCap.allow(killer.getUniqueId(), amount, RewardConfig.keyCapPerHour, clock.getAsLong()); + } + drops.addAll(stacks(drop.item(), amount)); + } + } + + /** + * Gives each player with enough lure kills a burst of their own items from the lure. Outside a permadeath + * zone each item only survives with its keep chance. + */ + public static void lureVictory(Infestation infestation, Location location, Collection players) { + if (location == null || !RewardConfig.appliesTo(infestation.getGroupId())) { + return; + } + RewardTable table = RewardConfig.lure(infestation.getSeverity()); + if (table == null) { + return; + } + RewardRoller.Keep keep = PermadeathZones.inZone(location) ? null : RewardConfig.lureKeep; + for (Player player : players) { + if (infestation.lureKills(player.getUniqueId()) < RewardConfig.lureMinKills) { + continue; + } + List items = new ArrayList<>(); + for (RewardRoller.Drop drop : roller.roll(table, RewardConfig.coins, keep)) { + items.addAll(stacks(drop.item(), drop.amount())); + } + if (items.isEmpty()) { + continue; + } + burst(player.getUniqueId(), location, items); + player.sendMessage(Messages.getOrDefault("lure-reward", LURE_REWARD_FALLBACK)); + } + } + + /** Builds the item and splits it into stacks no larger than its max stack size. */ + static List stacks(String path, int amount) { + List out = new ArrayList<>(); + if (amount <= 0) { + return out; + } + ItemStack base; + try { + base = TLibs.getItemAPI().getCreator().getItemFromPath(path); + } catch (RuntimeException ex) { + base = null; + } + if (base == null || base.getType().isAir()) { + if (warnedPaths.add(path)) { + Infestations.plugin.getLogger().warning("[Infestations] rewards.yml item not found: " + path); + } + return out; + } + int max = Math.max(1, base.getMaxStackSize()); + for (int left = amount; left > 0; left -= max) { + ItemStack stack = base.clone(); + stack.setAmount(Math.min(max, left)); + out.add(stack); + } + return out; + } + + /** Throws the items out of the lure one by one over {@link #BURST_TICKS}, locked to their owner. */ + static void burst(UUID owner, Location location, List items) { + List pieces = new ArrayList<>(); + for (ItemStack stack : items) { + pieces.addAll(split(stack)); + } + Collections.shuffle(pieces, random); + Location from = location.clone().add(0, 1.0, 0); + int count = pieces.size(); + for (int i = 0; i < count; i++) { + ItemStack piece = pieces.get(i); + long delay = 1L + (long) i * BURST_TICKS / count; + Bukkit.getScheduler().runTaskLater(Infestations.plugin, () -> eject(from, piece, owner), delay); + } + } + + static List split(ItemStack stack) { + int amount = stack.getAmount(); + int parts = Math.min(MAX_PIECES, amount); + List out = new ArrayList<>(); + for (int i = 0; i < parts; i++) { + ItemStack piece = stack.clone(); + piece.setAmount(amount / parts + (i < amount % parts ? 1 : 0)); + out.add(piece); + } + return out; + } + + static void eject(Location from, ItemStack piece, UUID owner) { + World world = from.getWorld(); + world.dropItem(from, piece, item -> { + item.setOwner(owner); + item.setPickupDelay(15); + item.setVelocity(new Vector( + (random.nextDouble() - 0.5) * 0.3, + 0.35 + random.nextDouble() * 0.2, + (random.nextDouble() - 0.5) * 0.3)); + }); + world.playSound(from, Sound.BLOCK_VAULT_EJECT_ITEM, 0.8f, 0.9f + random.nextFloat() * 0.2f); + } +} diff --git a/src/main/java/net/tfminecraft/infestations/reward/RewardRoller.java b/src/main/java/net/tfminecraft/infestations/reward/RewardRoller.java new file mode 100644 index 0000000..e0e6329 --- /dev/null +++ b/src/main/java/net/tfminecraft/infestations/reward/RewardRoller.java @@ -0,0 +1,147 @@ +package net.tfminecraft.infestations.reward; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +/** + * Rolls a {@link RewardTable} into item paths and amounts. Has no Bukkit dependencies. + */ +public final class RewardRoller { + + /** Paid out as physical coins. */ + public static final String DENARS = "denars"; + /** One gem pouch per unit, tier picked from the table's gem weights. */ + public static final String GEM = "gem"; + + public record Drop(String item, int amount) {} + + /** Survival chance per unit for lures outside a permadeath zone. */ + public record Keep(double fallback, Map items) { + public Keep { + items = Map.copyOf(items); + } + + public double of(String item) { + return items.getOrDefault(item, fallback); + } + } + + private final Random random; + + public RewardRoller(Random random) { + this.random = random; + } + + /** + * Rolls every entry in the table. When {@code keep} is set, each unit survives with its keep chance; + * denars are thinned before they are turned into coins. + */ + public List roll(RewardTable table, List coins, Keep keep) { + Map out = new LinkedHashMap<>(); + int denars = 0; + for (RewardTable.Entry entry : table.drops()) { + if (!hit(entry.chance())) { + continue; + } + int amount = amount(entry.min(), entry.max()); + if (GEM.equals(entry.item())) { + for (int i = 0; i < amount; i++) { + String gem = pick(table.gems()); + if (gem != null) { + add(out, gem, kept(gem, 1, keep)); + } + } + } else if (DENARS.equals(entry.item())) { + denars += kept(DENARS, amount, keep); + } else { + add(out, entry.item(), kept(entry.item(), amount, keep)); + } + } + for (Drop coin : coins(denars, coins)) { + add(out, coin.item(), coin.amount()); + } + List drops = new ArrayList<>(); + out.forEach((item, amount) -> drops.add(new Drop(item, amount))); + return drops; + } + + /** Splits denars into coins, largest value first. Remainders smaller than the smallest coin are lost. */ + public static List coins(int denars, List coins) { + List out = new ArrayList<>(); + if (denars <= 0) { + return out; + } + List sorted = new ArrayList<>(coins); + sorted.sort(Comparator.comparingInt(RewardTable.Coin::value).reversed()); + int left = denars; + for (RewardTable.Coin coin : sorted) { + if (coin.value() <= 0) { + continue; + } + int count = left / coin.value(); + if (count > 0) { + out.add(new Drop(coin.item(), count)); + left -= count * coin.value(); + } + } + return out; + } + + private boolean hit(double chance) { + if (chance >= 1) { + return true; + } + return chance > 0 && random.nextDouble() < chance; + } + + private int amount(int min, int max) { + return max > min ? min + random.nextInt(max - min + 1) : min; + } + + private int kept(String item, int amount, Keep keep) { + if (keep == null) { + return amount; + } + double chance = keep.of(item); + if (chance >= 1) { + return amount; + } + int survived = 0; + for (int i = 0; i < amount; i++) { + if (hit(chance)) { + survived++; + } + } + return survived; + } + + private String pick(List gems) { + int total = 0; + for (RewardTable.Weighted gem : gems) { + total += Math.max(0, gem.weight()); + } + if (total <= 0) { + return null; + } + int roll = random.nextInt(total); + String chosen = null; + for (RewardTable.Weighted gem : gems) { + roll -= Math.max(0, gem.weight()); + if (roll < 0) { + chosen = gem.item(); + break; + } + } + return chosen; + } + + private static void add(Map out, String item, int amount) { + if (amount > 0) { + out.merge(item, amount, Integer::sum); + } + } +} diff --git a/src/main/java/net/tfminecraft/infestations/reward/RewardTable.java b/src/main/java/net/tfminecraft/infestations/reward/RewardTable.java new file mode 100644 index 0000000..2acd00a --- /dev/null +++ b/src/main/java/net/tfminecraft/infestations/reward/RewardTable.java @@ -0,0 +1,23 @@ +package net.tfminecraft.infestations.reward; + +import java.util.List; + +/** + * One block of rewards.yml: the drop entries and, for the {@code gem} entry, the gem tier weights. + */ +public record RewardTable(List drops, List gems) { + + public RewardTable { + drops = List.copyOf(drops); + gems = List.copyOf(gems); + } + + /** A drop entry. {@code chance} is 0-1; the amount is rolled between min and max. */ + public record Entry(String item, double chance, int min, int max) {} + + /** A gem tier and its weight. */ + public record Weighted(String item, int weight) {} + + /** A coin item and its denar value. */ + public record Coin(String item, int value) {} +} diff --git a/src/main/java/net/tfminecraft/infestations/spawn/Keys.java b/src/main/java/net/tfminecraft/infestations/spawn/Keys.java index 5e8dbde..6b9697b 100644 --- a/src/main/java/net/tfminecraft/infestations/spawn/Keys.java +++ b/src/main/java/net/tfminecraft/infestations/spawn/Keys.java @@ -41,6 +41,19 @@ public static String kind(PersistentDataContainer pdc) { return pdc.get(kind(), PersistentDataType.STRING); } + public static NamespacedKey summoned() { + return new NamespacedKey(Infestations.plugin, "summoned"); + } + + /** Marks a mob summoned by a dying infestation mob. Such mobs pay no kill rewards. */ + public static void markSummoned(PersistentDataContainer pdc) { + pdc.set(summoned(), PersistentDataType.BYTE, (byte) 1); + } + + public static boolean isSummoned(PersistentDataContainer pdc) { + return pdc.has(summoned(), PersistentDataType.BYTE); + } + public static void tagDisplay(PersistentDataContainer pdc, int provinceId) { pdc.set(displayProvince(), PersistentDataType.INTEGER, provinceId); } diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 426e501..4726560 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -33,6 +33,7 @@ lure-leave-or-join: "{prefix}#c9a44aA lure is activating. Leave or right-click t lure-nonjoiner: "{prefix}#c45c5cLeave this province. The lure has started without you." lure-nonjoiner-bar: "#c45c5cLeave this province. The lure has started without you." lure-victory: "{prefix}#7dba7aThe infestation is cleared." +lure-reward: "{prefix}#c9a44aThe lure bursts open. Your spoils are marked for you." lure-fail: "{prefix}#c45c5cThe lure failed. The infestation remains." hologram-joining: "#c9a44aLure - join {seconds}s" hologram-remaining: "#c45c5cRemaining: #f0e6d0{remaining}" diff --git a/src/main/resources/rewards.yml b/src/main/resources/rewards.yml new file mode 100644 index 0000000..c8b71e6 --- /dev/null +++ b/src/main/resources/rewards.yml @@ -0,0 +1,192 @@ +# Bonus loot for infestation kills and cleared lures. +# These drops come on top of the mob's own MythicMobs drop table. +# +# Items are TLibs item paths (m.. for MMOItems). +# Two special item names exist: +# denars - an amount of denars, paid out as physical coins from the coins list +# gem - one gem pouch per unit, its tier rolled from the table's gems weights +# +# percent: chance for the entry to drop, 0-100. Defaults to 100. +# amount: a number or a range like 4-6. Defaults to 1. +# +# Permadeath zones come from RPCharacters. When RPCharacters is missing, every +# location counts as outside a zone. + +enabled: true + +# Infestation groups that pay these rewards. An empty list means every group. +groups: + - swamp_mobs + - swamp_mobs_hill + +# Coin items and their denar value. Denars are paid in the largest coins first. +coins: + - {item: m.currency.pouch_of_coins, value: 100} + - {item: m.currency.stack_of_coins, value: 10} + - {item: m.currency.handful_of_coins, value: 5} + - {item: m.currency.gold_coin, value: 1} + +# One roll per ambient mob killed by a player. Mobs summoned by a dying +# infestation mob (parasitic worms) roll nothing. +ambient: + # Each player gets at most this many of key-item from ambient kills per + # rolling hour. 0 turns the cap off. Lure rewards ignore it. + key-item: m.loot.trial_key + key-cap-per-hour: 40 + + # Used for kills outside a permadeath zone, whatever the severity. + outside: + gems: + - {item: m.loot.basic_gemstone_pouch, weight: 90} + - {item: m.loot.polished_gemstone_pouch, weight: 10} + drops: + - {item: m.loot.trial_key, percent: 0.1} + - {item: gem, percent: 0.025} + - {item: denars, percent: 0.5} + - {item: m.currency.enchanted_dust, percent: 0.25} + - {item: m.materials.ignitium, percent: 0.05} + - {item: m.materials.raw_tin, percent: 0.005} + - {item: m.materials.abyssalite_fragment, percent: 0.005} + - {item: m.materials.mythril_fragment, percent: 0.0005} + - {item: m.materials.mythrilite, percent: 0.00025} + + mild: + gems: + - {item: m.loot.basic_gemstone_pouch, weight: 90} + - {item: m.loot.polished_gemstone_pouch, weight: 10} + drops: + - {item: m.loot.trial_key, percent: 2} + - {item: gem, percent: 0.625} + - {item: denars, percent: 10} + - {item: m.currency.enchanted_dust, percent: 5} + - {item: m.materials.ignitium, percent: 1} + - {item: m.materials.raw_tin, percent: 0.1} + - {item: m.materials.abyssalite_fragment, percent: 0.1} + - {item: m.materials.mythril_fragment, percent: 0.01} + - {item: m.materials.mythrilite, percent: 0.005} + + worrying: + gems: + - {item: m.loot.basic_gemstone_pouch, weight: 75} + - {item: m.loot.polished_gemstone_pouch, weight: 22} + - {item: m.loot.radiant_gemstone_pouch, weight: 3} + drops: + - {item: m.loot.trial_key, percent: 3} + - {item: gem, percent: 1} + - {item: denars, percent: 15} + - {item: m.currency.enchanted_dust, percent: 7.5} + - {item: m.materials.ignitium, percent: 1.5} + - {item: m.materials.raw_tin, percent: 0.15} + - {item: m.materials.abyssalite_fragment, percent: 0.15} + - {item: m.materials.mythril_fragment, percent: 0.015} + - {item: m.materials.mythrilite, percent: 0.0075} + + severe: + gems: + - {item: m.loot.basic_gemstone_pouch, weight: 60} + - {item: m.loot.polished_gemstone_pouch, weight: 30} + - {item: m.loot.radiant_gemstone_pouch, weight: 9} + - {item: m.loot.mythical_gemstone_pouch, weight: 1} + drops: + - {item: m.loot.trial_key, percent: 4.5} + - {item: gem, percent: 1.5} + - {item: denars, percent: 22.5} + - {item: m.currency.enchanted_dust, percent: 11.25} + - {item: m.materials.ignitium, percent: 2.25} + - {item: m.materials.raw_tin, percent: 0.225} + - {item: m.materials.abyssalite_fragment, percent: 0.225} + - {item: m.materials.mythril_fragment, percent: 0.0225} + - {item: m.materials.mythrilite, percent: 0.01125} + + extreme: + gems: + - {item: m.loot.basic_gemstone_pouch, weight: 45} + - {item: m.loot.polished_gemstone_pouch, weight: 35} + - {item: m.loot.radiant_gemstone_pouch, weight: 17} + - {item: m.loot.mythical_gemstone_pouch, weight: 3} + drops: + - {item: m.loot.trial_key, percent: 6} + - {item: gem, percent: 2} + - {item: denars, percent: 30} + - {item: m.currency.enchanted_dust, percent: 15} + - {item: m.materials.ignitium, percent: 3} + - {item: m.materials.raw_tin, percent: 0.3} + - {item: m.materials.abyssalite_fragment, percent: 0.3} + - {item: m.materials.mythril_fragment, percent: 0.03} + - {item: m.materials.mythrilite, percent: 0.015} + +# When a lure clears, each committed player who is alive and online and made at +# least min-kills lure kills gets their own burst of items from the lure. Only +# that player can pick their items up. +lure: + min-kills: 3 + + # Lures outside a permadeath zone roll the same table, then each item survives + # with keep-percent. keep-items overrides it per item. + outside-zone: + keep-percent: 10 + keep-items: + - {item: m.loot.trial_key, keep-percent: 5} + - {item: m.materials.raw_tin, keep-percent: 5} + - {item: m.materials.abyssalite_fragment, keep-percent: 5} + - {item: m.materials.mythril_fragment, keep-percent: 5} + - {item: m.materials.mythrilite, keep-percent: 5} + - {item: m.loot.radiant_gemstone_pouch, keep-percent: 0} + - {item: m.loot.mythical_gemstone_pouch, keep-percent: 0} + + mild: + drops: + - {item: m.loot.trial_key, amount: 4-6} + - {item: denars, amount: 30-50} + - {item: m.currency.enchanted_dust, amount: 6-10} + - {item: m.loot.basic_gemstone_pouch, amount: 3-4} + - {item: m.loot.polished_gemstone_pouch, percent: 25} + - {item: m.materials.ignitium, amount: 3-5} + - {item: m.materials.raw_tin, amount: 1} + - {item: m.materials.abyssalite_fragment, amount: 1} + - {item: m.materials.mythril_fragment, percent: 2} + - {item: m.materials.mythrilite, percent: 1} + + worrying: + drops: + - {item: m.loot.trial_key, amount: 7-10} + - {item: denars, amount: 60-100} + - {item: m.currency.enchanted_dust, amount: 12-18} + - {item: m.loot.basic_gemstone_pouch, amount: 4-6} + - {item: m.loot.polished_gemstone_pouch, amount: 1-2} + - {item: m.loot.radiant_gemstone_pouch, percent: 10} + - {item: m.materials.ignitium, amount: 6-9} + - {item: m.materials.raw_tin, amount: 1-2} + - {item: m.materials.abyssalite_fragment, amount: 1-2} + - {item: m.materials.mythril_fragment, percent: 4} + - {item: m.materials.mythrilite, percent: 2} + + severe: + drops: + - {item: m.loot.trial_key, amount: 10-14} + - {item: denars, amount: 120-180} + - {item: m.currency.enchanted_dust, amount: 20-28} + - {item: m.loot.basic_gemstone_pouch, amount: 5-7} + - {item: m.loot.polished_gemstone_pouch, amount: 2-3} + - {item: m.loot.radiant_gemstone_pouch, percent: 50} + - {item: m.loot.mythical_gemstone_pouch, percent: 3} + - {item: m.materials.ignitium, amount: 10-14} + - {item: m.materials.raw_tin, amount: 2-3} + - {item: m.materials.abyssalite_fragment, amount: 2-3} + - {item: m.materials.mythril_fragment, percent: 7} + - {item: m.materials.mythrilite, percent: 3.5} + + extreme: + drops: + - {item: m.loot.trial_key, amount: 14-20} + - {item: denars, amount: 200-300} + - {item: m.currency.enchanted_dust, amount: 30-40} + - {item: m.loot.basic_gemstone_pouch, amount: 6-8} + - {item: m.loot.polished_gemstone_pouch, amount: 3-4} + - {item: m.loot.radiant_gemstone_pouch, amount: 1-2} + - {item: m.loot.mythical_gemstone_pouch, percent: 12} + - {item: m.materials.ignitium, amount: 15-20} + - {item: m.materials.raw_tin, amount: 3-5} + - {item: m.materials.abyssalite_fragment, amount: 3-5} + - {item: m.materials.mythril_fragment, percent: 10} + - {item: m.materials.mythrilite, percent: 5} diff --git a/src/test/java/net/tfminecraft/infestations/InfestationsLifecycleTest.java b/src/test/java/net/tfminecraft/infestations/InfestationsLifecycleTest.java index 7293033..eb764d9 100644 --- a/src/test/java/net/tfminecraft/infestations/InfestationsLifecycleTest.java +++ b/src/test/java/net/tfminecraft/infestations/InfestationsLifecycleTest.java @@ -80,7 +80,7 @@ private Infestations controlled(Path data, Logger logger) { assertSame(plugin, Infestations.plugin); assertSame(managers.constructed().getFirst(), plugin.getInfestationManager()); verify(plugin.getInfestationManager()).start(); - for (String file : List.of("config.yml", "groups.yml", "messages.yml")) { + for (String file : List.of("config.yml", "groups.yml", "messages.yml", "rewards.yml")) { assertTrue(Files.isRegularFile(plugin.getDataFolder().toPath().resolve(file))); } for (String folder : List.of("Data", "MapAPI", "logs")) { @@ -214,4 +214,11 @@ private Infestations controlled(Path data, Logger logger) { assertFalse(plugin.reloadAll()); assertSame(running, plugin.getInfestationManager()); verify(running).reloadFromDisk(); } + + @Test void malformedRewardsMakeReloadFailWhileRetainingWorkingManager() throws Exception { + Files.writeString(plugin.getDataFolder().toPath().resolve("rewards.yml"), "ambient: [broken"); + InfestationManager running = plugin.getInfestationManager(); + assertFalse(plugin.reloadAll()); + assertSame(running, plugin.getInfestationManager()); verify(running).reloadFromDisk(); + } } diff --git a/src/test/java/net/tfminecraft/infestations/InfestationsTestState.java b/src/test/java/net/tfminecraft/infestations/InfestationsTestState.java index f9b3151..06e1d17 100644 --- a/src/test/java/net/tfminecraft/infestations/InfestationsTestState.java +++ b/src/test/java/net/tfminecraft/infestations/InfestationsTestState.java @@ -4,12 +4,16 @@ import java.util.*; import net.tfminecraft.infestations.cache.Cache; import net.tfminecraft.infestations.loader.GroupLoader; +import net.tfminecraft.infestations.reward.PermadeathZones; +import net.tfminecraft.infestations.reward.RewardConfig; +import net.tfminecraft.infestations.reward.RewardDrops; import net.tfminecraft.infestations.spawn.SpawnLog; public final class InfestationsTestState implements AutoCloseable { private final Map state = new LinkedHashMap<>(); public InfestationsTestState() throws Exception { - for (Class type : List.of(Infestations.class, Cache.class, GroupLoader.class, Messages.class, SpawnLog.class)) { + for (Class type : List.of(Infestations.class, Cache.class, GroupLoader.class, Messages.class, SpawnLog.class, + RewardConfig.class, RewardDrops.class, PermadeathZones.class)) { for (Field f : type.getDeclaredFields()) { if (!Modifier.isStatic(f.getModifiers())) continue; f.setAccessible(true); diff --git a/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java b/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java index bf9f2a5..3ec10cc 100644 --- a/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java +++ b/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java @@ -143,6 +143,9 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { infestation.getDeathOnLogin().add(PLAYER); infestation.getLogoutCharacter().put(PLAYER, "alice"); infestation.getOwedDeaths().put(PLAYER, Set.of("bob")); + infestation.addLureKill(PLAYER); + infestation.addLureKill(PLAYER); + assertEquals(2, infestation.lureKills(PLAYER)); infestation.setWaveRetryAtTick(5); infestation.setVictoryAtTick(6); infestation.setPendingSpawns(3); @@ -165,6 +168,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { assertTrue(infestation.getLogoutGraceUntil().isEmpty()); assertTrue(infestation.getDeathOnLogin().isEmpty()); assertTrue(infestation.getLogoutCharacter().isEmpty()); + assertEquals(0, infestation.lureKills(PLAYER)); assertEquals(Map.of(PLAYER, Set.of("bob")), infestation.getOwedDeaths(), "Owed deaths outlive lures"); assertEquals(0, infestation.getPendingSpawns()); assertEquals(0, infestation.getEnemiesAlive()); @@ -177,6 +181,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { infestation.getLogoutGraceUntil().put(PLAYER, 100L); infestation.getDeathOnLogin().add(PLAYER); infestation.getLogoutCharacter().put(PLAYER, "alice"); + infestation.addLureKill(PLAYER); infestation.clearLure(); assertSame(LurePhase.NONE, infestation.getPhase()); assertFalse(infestation.hasLure()); @@ -191,6 +196,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { assertTrue(infestation.getLogoutGraceUntil().isEmpty()); assertTrue(infestation.getDeathOnLogin().isEmpty()); assertTrue(infestation.getLogoutCharacter().isEmpty()); + assertTrue(infestation.getLureKills().isEmpty()); assertEquals(Map.of(PLAYER, Set.of("bob")), infestation.getOwedDeaths()); } @@ -255,6 +261,7 @@ void persistenceRoundTripKeepsRuntimeStateButRequeuesScheduledSpawns() throws Ex original.getDeathOnLogin().add(PLAYER); original.getLogoutCharacter().put(PLAYER, "alice"); original.getOwedDeaths().put(PLAYER, Set.of("bob", "carol")); + original.getLureKills().put(PLAYER, 12); InfestationDatabase.save(List.of(original)); assertTrue(Files.readString(InfestationDatabase.file().toPath()).contains("test-group")); Infestation loaded = InfestationDatabase.load().getFirst(); @@ -275,6 +282,7 @@ void persistenceRoundTripKeepsRuntimeStateButRequeuesScheduledSpawns() throws Ex assertEquals(Set.of(PLAYER), loaded.getDeathOnLogin()); assertEquals(Map.of(PLAYER, "alice"), loaded.getLogoutCharacter()); assertEquals(Map.of(PLAYER, Set.of("bob", "carol")), loaded.getOwedDeaths()); + assertEquals(Map.of(PLAYER, 12), loaded.getLureKills()); } @Test @@ -287,14 +295,17 @@ void malformedRowsAndLegacyFieldsDoNotDiscardValidNeighbors() throws Exception { "committed":["bad","%s"],"logoutGraceUntil":{"bad":5,"%s":6}, "deathOnLogin":["bad","%s"],"logoutCharacter":{"bad":"x","%s":"alice"}, "owedDeaths":{"bad":["x"],"%s":[null,"bob"]}, + "lureKills":{"bad":4,"null":2,"%s":5,"00000000-0000-0000-0000-000000000001":null}, "pendingSpawns":20,"lureReleased":3}, {"provinceId":4,"groupId":"legacy","severity":"worrying","committed":null, "logoutGraceUntil":null,"deathOnLogin":null,"logoutCharacter":{"%s":null}, - "owedDeaths":{"%s":null}}, + "owedDeaths":{"%s":null},"lureKills":null}, {"provinceId":5,"groupId":"older","severity":"mild","owedDeaths":null}] - """.formatted(PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER)); + """.formatted(PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER)); List loaded = InfestationDatabase.load(); assertEquals(3, loaded.size()); + assertEquals(Map.of(PLAYER, 5), loaded.getFirst().getLureKills()); + assertTrue(loaded.get(1).getLureKills().isEmpty()); assertSame(LurePhase.NONE, loaded.getFirst().getPhase()); assertEquals(Map.of(PLAYER, Set.of("bob")), loaded.getFirst().getOwedDeaths()); assertTrue(loaded.get(1).getOwedDeaths().isEmpty()); diff --git a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java index 268a9d3..9ef1c86 100644 --- a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java +++ b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java @@ -46,6 +46,7 @@ import net.tfminecraft.infestations.lure.LureFurniture; import net.tfminecraft.infestations.lure.LureHologram; import net.tfminecraft.infestations.map.InfestationMapExport; +import net.tfminecraft.infestations.reward.RewardDrops; import net.tfminecraft.infestations.spawn.*; import net.tfminecraft.infestations.utils.Characters; import net.tfminecraft.infestations.utils.Provinces; @@ -74,6 +75,7 @@ class InfestationManagerTest { private final List later = new ArrayList<>(); private final Map tags = new IdentityHashMap<>(); private final Map kinds = new IdentityHashMap<>(); + private final Set summoned = Collections.newSetFromMap(new IdentityHashMap<>()); private final Map provinces = new IdentityHashMap<>(); private final Map riverBanks = new IdentityHashMap<>(); private final Map online = new HashMap<>(); @@ -148,6 +150,8 @@ class InfestationManagerTest { kinds.put(i.getArgument(0), i.getArgument(2)); return null; }); + keys.when(() -> Keys.markSummoned(any())).thenAnswer(i -> summoned.add(i.getArgument(0))); + keys.when(() -> Keys.isSummoned(any())).thenAnswer(i -> summoned.contains(i.getArgument(0))); MockedConstruction ambientConstruction = scoped(mockConstruction(AmbientSpawnService.class)); MockedStatic randomApi = scoped(mockStatic(ThreadLocalRandom.class)); random = mock(ThreadLocalRandom.class); @@ -833,6 +837,49 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { assertEquals(18, state.getLureRemaining()); } + @Test void ambientKillsByPlayersRollRewardsUnlessSummonedAndLureKillsAreCounted() { + MockedStatic rewards = scoped(mockStatic(RewardDrops.class)); + Infestation state = lure(1, LurePhase.ACTIVE); state.setAmbientAlive(5); state.setEnemiesAlive(5); + Player killer = player(1); + EntityDeathEvent death = mock(EntityDeathEvent.class); + List drops = new ArrayList<>(); when(death.getDrops()).thenReturn(drops); + LivingEntity unkilled = mob(1, Keys.KIND_AMBIENT); + when(death.getEntity()).thenReturn(unkilled); manager.onDeath(death); + rewards.verifyNoInteractions(); + LivingEntity killed = mob(1, Keys.KIND_AMBIENT); when(killed.getKiller()).thenReturn(killer); + Location at = new Location(world, 1, 2, 3); when(killed.getLocation()).thenReturn(at); + when(death.getEntity()).thenReturn(killed); manager.onDeath(death); + rewards.verify(() -> RewardDrops.ambientKill(state, killer, at, drops)); + LivingEntity worm = mob(1, Keys.KIND_AMBIENT); when(worm.getKiller()).thenReturn(killer); + summoned.add(worm.getPersistentDataContainer()); + when(death.getEntity()).thenReturn(worm); manager.onDeath(death); + rewards.verify(() -> RewardDrops.ambientKill(any(), any(), any(), any()), times(1)); + LivingEntity lureMob = mob(1, Keys.KIND_LURE); when(lureMob.getKiller()).thenReturn(killer); + when(death.getEntity()).thenReturn(lureMob); manager.onDeath(death); + doReturn(mob(1, Keys.KIND_LURE)).when(death).getEntity(); manager.onDeath(death); + assertEquals(1, state.lureKills(killer.getUniqueId())); + assertEquals(Map.of(killer.getUniqueId(), 1), state.getLureKills()); + } + + @Test void lureVictoryRewardsOnlyLivingOnlinePlayersOnTheirCommittedCharacter() throws Exception { + MockedStatic rewards = scoped(mockStatic(RewardDrops.class)); + Infestation active = lure(1, LurePhase.ACTIVE); + Player fighter = player(1), dead = player(1), offline = player(1), owed = player(1), swapped = player(1); + Player sessionless = player(1); + UUID gone = UUID.randomUUID(); + characters.when(Characters::enabled).thenReturn(true); + for (Player p : List.of(fighter, dead, offline, owed, swapped)) current(p, UUID.randomUUID().toString()); + active.getCommitted().add(sessionless.getUniqueId()); + when(dead.isDead()).thenReturn(true); when(offline.isOnline()).thenReturn(false); + for (Player p : List.of(fighter, dead, offline, owed, swapped)) active.getCommitted().add(p.getUniqueId()); + active.getCommitted().add(gone); + active.getDeathOnLogin().add(owed.getUniqueId()); + active.getLogoutCharacter().put(swapped.getUniqueId(), UUID.randomUUID().toString()); + active.setLureRemaining(0); call("tickVictory", active); + atTick(60); assertEquals(true, call("tickVictory", active)); + rewards.verify(() -> RewardDrops.lureVictory(eq(active), any(Location.class), eq(List.of(fighter)))); + } + @Test void playerDeathRemovesCommitmentAndFailsActiveParty() { Infestation active = lure(1, LurePhase.ACTIVE); Infestation idle = infestation(2); infestation(3); Player p = player(1); active.getCommitted().add(p.getUniqueId()); idle.getCommitted().add(p.getUniqueId()); @@ -1256,6 +1303,7 @@ private static CharacterDeathEvent died(UUID owner, String character) { manager.onMythicSpawn(spawn); runLater(); assertEquals(1, tags.get(child.getPersistentDataContainer())); assertEquals(Keys.KIND_LURE, kinds.get(child.getPersistentDataContainer())); + assertTrue(summoned.contains(child.getPersistentDataContainer())); assertEquals(1, state.getEnemiesAlive()); assertEquals(20, state.getLureRemaining()); manager.onMythicSpawn(spawn); runLater(); assertEquals(1, state.getEnemiesAlive()); state.setPhase(LurePhase.NONE); atTick(119); tick.run(); @@ -1277,6 +1325,7 @@ private static CharacterDeathEvent died(UUID owner, String character) { when(child.isDead()).thenReturn(true); call("adoptSummon", activeMob); assertEquals(0, state.getAmbientAlive()); when(child.isDead()).thenReturn(false); call("adoptSummon", activeMob); assertEquals(1, state.getAmbientAlive()); assertEquals(Keys.KIND_AMBIENT, kinds.get(child.getPersistentDataContainer())); + assertTrue(summoned.contains(child.getPersistentDataContainer())); tags.clear(); state.setPhase(LurePhase.JOINING); call("adoptSummon", activeMob); assertTrue(tags.isEmpty()); when(activeMob.getEntity().getBukkitEntity()).thenReturn(mock(Entity.class)); call("adoptSummon", activeMob); when(activeMob.getEntity()).thenReturn(null); call("adoptSummon", activeMob); diff --git a/src/test/java/net/tfminecraft/infestations/reward/KeyCapTest.java b/src/test/java/net/tfminecraft/infestations/reward/KeyCapTest.java new file mode 100644 index 0000000..fa26115 --- /dev/null +++ b/src/test/java/net/tfminecraft/infestations/reward/KeyCapTest.java @@ -0,0 +1,26 @@ +package net.tfminecraft.infestations.reward; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class KeyCapTest { + private static final UUID A = UUID.randomUUID(); + private static final UUID B = UUID.randomUUID(); + + @Test void capsKeysPerPlayerOverARollingHour() { + KeyCap cap = new KeyCap(); + assertEquals(0, cap.allow(A, 0, 5, 0)); + assertEquals(9, cap.allow(A, 9, 0, 0)); + assertEquals(3, cap.allow(A, 3, 5, 1_000)); + assertEquals(2, cap.allow(A, 4, 5, 2_000)); + assertEquals(0, cap.allow(A, 1, 5, 3_000)); + assertEquals(1, cap.allow(B, 1, 5, 3_000)); + assertEquals(3, cap.allow(A, 4, 5, 1_000 + KeyCap.WINDOW_MS)); + assertEquals(0, cap.allow(A, 1, 2, 1_000 + KeyCap.WINDOW_MS)); + cap.clear(); + assertEquals(5, cap.allow(A, 5, 5, 0)); + } +} diff --git a/src/test/java/net/tfminecraft/infestations/reward/PermadeathZonesTest.java b/src/test/java/net/tfminecraft/infestations/reward/PermadeathZonesTest.java new file mode 100644 index 0000000..f807757 --- /dev/null +++ b/src/test/java/net/tfminecraft/infestations/reward/PermadeathZonesTest.java @@ -0,0 +1,91 @@ +package net.tfminecraft.infestations.reward; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Method; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.infestations.Infestations; +import net.tfminecraft.infestations.InfestationsTestState; +import net.tfminecraft.rpcharacters.permadeath.PermadeathAreaLookup; + +class PermadeathZonesTest { + private InfestationsTestState state; + private MockedStatic bukkit; + private PluginManager plugins; + private Logger logger; + private final Location location = new Location(null, 1, 2, 3); + + @BeforeEach void setup() throws Exception { + state = new InfestationsTestState(); + Infestations self = mock(Infestations.class); + logger = mock(Logger.class); + when(self.getLogger()).thenReturn(logger); + Infestations.plugin = self; + plugins = mock(PluginManager.class); + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + PermadeathAreaLookup.zone = null; + PermadeathAreaLookup.failure = null; + } + + @AfterEach void restore() throws Exception { + bukkit.close(); + PermadeathAreaLookup.zone = null; + PermadeathAreaLookup.failure = null; + state.close(); + } + + private void rpcharacters(boolean enabled) { + // Mocks of a plugin class keep that class, so the lookup resolves through the test class loader. + Infestations rp = mock(Infestations.class); + when(rp.isEnabled()).thenReturn(enabled); + when(plugins.getPlugin("RPCharacters")).thenReturn(rp); + } + + @Test void missingDisabledOrNoLocationIsOutside() { + assertFalse(PermadeathZones.inZone(null)); + assertFalse(PermadeathZones.inZone(location)); + rpcharacters(false); + PermadeathAreaLookup.zone = new Object(); + assertFalse(PermadeathZones.inZone(location)); + } + + @Test void asksTheLookupForTheZone() { + rpcharacters(true); + assertFalse(PermadeathZones.inZone(location)); + PermadeathAreaLookup.zone = new Object(); + assertTrue(PermadeathZones.inZone(location)); + verifyNoInteractions(logger); + } + + @Test void lookupFailuresWarnOnceAndCountAsOutside() { + rpcharacters(true); + PermadeathAreaLookup.failure = new IllegalStateException("boom"); + assertFalse(PermadeathZones.inZone(location)); + assertFalse(PermadeathZones.inZone(location)); + verify(logger, times(1)).warning(startsWith("[Infestations] Permadeath zone lookup failed")); + } + + @Test void methodIsCachedPerClassLoader() throws Exception { + ClassLoader test = getClass().getClassLoader(); + Method first = PermadeathZones.method(test); + assertSame(first, PermadeathZones.method(test)); + ClassLoader child = new ClassLoader(test) {}; + Method again = PermadeathZones.method(child); + assertEquals(first, again); + assertNotSame(first, again); + assertThrows(ClassNotFoundException.class, () -> PermadeathZones.method(new ClassLoader(null) {})); + } +} diff --git a/src/test/java/net/tfminecraft/infestations/reward/RewardConfigTest.java b/src/test/java/net/tfminecraft/infestations/reward/RewardConfigTest.java new file mode 100644 index 0000000..0b112e2 --- /dev/null +++ b/src/test/java/net/tfminecraft/infestations/reward/RewardConfigTest.java @@ -0,0 +1,156 @@ +package net.tfminecraft.infestations.reward; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import net.tfminecraft.infestations.Infestations; +import net.tfminecraft.infestations.InfestationsTestState; +import net.tfminecraft.infestations.infestation.Severity; +import net.tfminecraft.infestations.reward.RewardTable.Coin; +import net.tfminecraft.infestations.reward.RewardTable.Entry; +import net.tfminecraft.infestations.reward.RewardTable.Weighted; + +class RewardConfigTest { + @TempDir Path dir; + private InfestationsTestState state; + private Logger logger; + + @BeforeEach void setup() throws Exception { + state = new InfestationsTestState(); + Infestations plugin = mock(Infestations.class); + logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); + Infestations.plugin = plugin; + } + + @AfterEach void restore() throws Exception { state.close(); } + + private File write(String yaml) throws Exception { + Path file = dir.resolve("rewards.yml"); + Files.writeString(file, yaml); + return file.toFile(); + } + + @Test void bundledFileLoadsEveryTier() { + assertTrue(RewardConfig.loadSafe(new File("src/main/resources/rewards.yml"))); + assertTrue(RewardConfig.enabled); + assertEquals(Set.of("swamp_mobs", "swamp_mobs_hill"), RewardConfig.groups); + assertEquals(4, RewardConfig.coins.size()); + assertEquals("m.loot.trial_key", RewardConfig.keyItem); + assertEquals(40, RewardConfig.keyCapPerHour); + assertEquals(3, RewardConfig.lureMinKills); + assertEquals(0.05, RewardConfig.lureKeep.of("m.loot.trial_key"), 1e-9); + assertEquals(0.0, RewardConfig.lureKeep.of("m.loot.mythical_gemstone_pouch")); + assertEquals(0.1, RewardConfig.lureKeep.of("m.currency.enchanted_dust"), 1e-9); + for (Severity severity : Severity.values()) { + assertNotNull(RewardConfig.ambient(severity, true)); + assertNotNull(RewardConfig.lure(severity)); + assertSame(RewardConfig.ambient.get(RewardConfig.OUTSIDE), RewardConfig.ambient(severity, false)); + } + assertEquals(new Entry("m.loot.trial_key", 0.06, 1, 1), RewardConfig.ambient(Severity.EXTREME, true).drops().get(0)); + assertEquals(new Entry("m.loot.trial_key", 1, 14, 20), RewardConfig.lure(Severity.EXTREME).drops().get(0)); + assertTrue(RewardConfig.appliesTo("SWAMP_MOBS")); + assertFalse(RewardConfig.appliesTo("crypt")); + assertFalse(RewardConfig.appliesTo(null)); + } + + @Test void oddRowsAreSkippedAndMissingKeysUseDefaults() throws Exception { + assertTrue(RewardConfig.loadSafe(write(""" + groups: ['', ' ', ' Bog '] + coins: + - {item: '', value: 5} + - {item: gold, value: 0} + - {item: silver, value: '3'} + ambient: + key-cap-per-hour: -3 + mild: + gems: + - {item: basic, weight: 0} + - {weight: 4} + - {item: fine, weight: 2} + drops: + - {percent: 5} + - {item: key} + lure: + min-kills: -1 + outside-zone: + keep-items: + - {keep-percent: 50} + - {item: key} + severe: + drops: [] + """))); + assertTrue(RewardConfig.enabled); + assertEquals(Set.of("bog"), RewardConfig.groups); + assertFalse(RewardConfig.appliesTo("swamp")); + assertEquals(List.of(new Coin("silver", 3)), RewardConfig.coins); + assertEquals("m.loot.trial_key", RewardConfig.keyItem); + assertEquals(0, RewardConfig.keyCapPerHour); + assertEquals(Map.of(Severity.MILD.id(), new RewardTable(List.of(new Entry("key", 1, 1, 1)), + List.of(new Weighted("fine", 2)))), RewardConfig.ambient); + assertNull(RewardConfig.ambient(Severity.MILD, false)); + assertEquals(0, RewardConfig.lureMinKills); + assertEquals(1.0, RewardConfig.lureKeep.of("key")); + assertEquals(1.0, RewardConfig.lureKeep.of("other")); + assertEquals(Set.of(Severity.SEVERE.id()), RewardConfig.lure.keySet()); + assertNull(RewardConfig.lure(Severity.MILD)); + } + + @Test void emptyGroupsApplyEverywhereUnlessDisabled() throws Exception { + assertTrue(RewardConfig.loadSafe(write("groups: []\n"))); + assertTrue(RewardConfig.appliesTo(null)); + assertTrue(RewardConfig.appliesTo("anything")); + assertTrue(RewardConfig.loadSafe(write("enabled: false\n"))); + assertFalse(RewardConfig.appliesTo("anything")); + } + + @Test void badFilesKeepThePreviousSettings() throws Exception { + RewardConfig.keyCapPerHour = 7; + assertFalse(RewardConfig.loadSafe(write("ambient: [unclosed\n"))); + assertFalse(RewardConfig.loadSafe(dir.resolve("missing.yml").toFile())); + assertEquals(7, RewardConfig.keyCapPerHour); + verify(logger, times(2)).severe(startsWith("[Infestations] Failed to load rewards.yml: ")); + } + + @Test void valueHelpersParseAndFallBack() { + assertNull(RewardConfig.string(null)); + assertNull(RewardConfig.string(" ")); + assertEquals("a", RewardConfig.string(" a ")); + + assertEquals(4, RewardConfig.integer(4.9, 0)); + assertEquals(7, RewardConfig.integer(null, 7)); + assertEquals(12, RewardConfig.integer(" 12 ", 0)); + assertEquals(3, RewardConfig.integer("x", 3)); + + assertEquals(0.25, RewardConfig.percent(25, 100)); + assertEquals(0.005, RewardConfig.percent(" 0.5 ", 100), 1e-12); + assertEquals(1.0, RewardConfig.percent(null, 100)); + assertEquals(0.5, RewardConfig.percent("half", 50)); + assertEquals(1.0, RewardConfig.percent(250, 100)); + assertEquals(0.0, RewardConfig.percent(-5, 100)); + + assertArrayEquals(new int[] {3, 3}, RewardConfig.range(3)); + assertArrayEquals(new int[] {0, 0}, RewardConfig.range(-2)); + assertArrayEquals(new int[] {1, 1}, RewardConfig.range(null)); + assertArrayEquals(new int[] {4, 4}, RewardConfig.range(" 4 ")); + assertArrayEquals(new int[] {0, 0}, RewardConfig.range("-4")); + assertArrayEquals(new int[] {2, 5}, RewardConfig.range("2 - 5")); + assertArrayEquals(new int[] {2, 5}, RewardConfig.range("5-2")); + assertArrayEquals(new int[] {1, 1}, RewardConfig.range("a-b")); + assertArrayEquals(new int[] {1, 1}, RewardConfig.range("lots")); + } +} diff --git a/src/test/java/net/tfminecraft/infestations/reward/RewardDropsTest.java b/src/test/java/net/tfminecraft/infestations/reward/RewardDropsTest.java new file mode 100644 index 0000000..d8adaf5 --- /dev/null +++ b/src/test/java/net/tfminecraft/infestations/reward/RewardDropsTest.java @@ -0,0 +1,230 @@ +package net.tfminecraft.infestations.reward; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.World; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.mockbukkit.mockbukkit.MockBukkit; + +import net.tfminecraft.infestations.Infestations; +import net.tfminecraft.infestations.InfestationsTestState; +import net.tfminecraft.infestations.Messages; +import net.tfminecraft.infestations.infestation.Infestation; +import net.tfminecraft.infestations.infestation.Severity; +import net.tfminecraft.infestations.reward.RewardTable.Entry; +import net.tfminecraft.tlibs.TLibs; + +class RewardDropsTest { + private final List mocks = new ArrayList<>(); + private final List later = new ArrayList<>(); + private final List delays = new ArrayList<>(); + private InfestationsTestState state; + private Infestations plugin; + private Logger logger; + private World world; + private Location location; + private Infestation infestation; + private MockedStatic zones; + + @BeforeAll static void initializeRegistries() { MockBukkit.mock(); } + @AfterAll static void releaseRegistries() { MockBukkit.unmock(); } + + @BeforeEach void setup() throws Exception { + state = new InfestationsTestState(); + RewardDrops.warnedPaths.clear(); + plugin = mock(Infestations.class); + logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); + Infestations.plugin = plugin; + + MockedStatic bukkit = scoped(mockStatic(Bukkit.class, CALLS_REAL_METHODS)); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + when(scheduler.runTaskLater(eq(plugin), any(Runnable.class), anyLong())).thenAnswer(i -> { + later.add(i.getArgument(1)); delays.add(i.getArgument(2)); return null; }); + + MockedStatic tlibs = scoped(mockStatic(TLibs.class, RETURNS_DEEP_STUBS)); + when(TLibs.getItemAPI().getCreator().getItemFromPath(anyString())).thenAnswer(i -> switch (i.getArgument(0)) { + case "key" -> new ItemStack(Material.TRIAL_KEY); + case "stone" -> new ItemStack(Material.STONE); + case "boom" -> throw new IllegalStateException("no MMOItems"); + default -> null; + }); + MockedStatic messages = scoped(mockStatic(Messages.class)); + messages.when(() -> Messages.getOrDefault(anyString(), anyString())).thenAnswer(i -> i.getArgument(0)); + + zones = scoped(mockStatic(PermadeathZones.class)); + world = mock(World.class); + location = new Location(world, 10.5, 64, 20.5); + infestation = mock(Infestation.class); + when(infestation.getGroupId()).thenReturn("swamp_mobs"); + when(infestation.getSeverity()).thenReturn(Severity.SEVERE); + + RewardDrops.random = new Random(7); + RewardDrops.roller = new RewardRoller(RewardDrops.random); + RewardDrops.keyCap = new KeyCap(); + RewardDrops.clock = () -> 1_000L; + RewardConfig.enabled = true; + RewardConfig.groups = Set.of("swamp_mobs"); + RewardConfig.coins = List.of(); + RewardConfig.keyItem = "key"; + RewardConfig.keyCapPerHour = 3; + RewardConfig.lureMinKills = 3; + RewardConfig.lureKeep = new RewardRoller.Keep(0, Map.of()); + } + + @AfterEach void restore() throws Exception { + for (int i = mocks.size() - 1; i >= 0; i--) mocks.get(i).close(); + RewardDrops.warnedPaths.clear(); + state.close(); + } + + private T scoped(T resource) { mocks.add(resource); return resource; } + + private static RewardTable table(Entry... entries) { return new RewardTable(List.of(entries), List.of()); } + + private static int total(List stacks, Material type) { + return stacks.stream().filter(s -> s.getType() == type).mapToInt(ItemStack::getAmount).sum(); + } + + @Test void ambientKillsAddCappedKeysAndOtherLootFromTheZoneTable() { + Player killer = mock(Player.class); + when(killer.getUniqueId()).thenReturn(UUID.randomUUID()); + RewardConfig.ambient = Map.of( + RewardConfig.OUTSIDE, table(new Entry("stone", 1, 70, 70)), + Severity.SEVERE.id(), table(new Entry("key", 1, 2, 2), new Entry("stone", 1, 1, 1))); + zones.when(() -> PermadeathZones.inZone(location)).thenReturn(true); + + List drops = new ArrayList<>(); + RewardDrops.ambientKill(infestation, killer, location, drops); + RewardDrops.ambientKill(infestation, killer, location, drops); + assertEquals(3, total(drops, Material.TRIAL_KEY)); + assertEquals(2, total(drops, Material.STONE)); + + drops.clear(); + zones.when(() -> PermadeathZones.inZone(location)).thenReturn(false); + RewardDrops.ambientKill(infestation, killer, location, drops); + assertEquals(List.of(64, 6), drops.stream().map(ItemStack::getAmount).toList()); + } + + @Test void ambientKillsOutsideRewardGroupsOrWithoutATableDropNothing() { + Player killer = mock(Player.class); + List drops = new ArrayList<>(); + RewardConfig.ambient = Map.of(RewardConfig.OUTSIDE, table(new Entry("stone", 1, 1, 1))); + when(infestation.getGroupId()).thenReturn("crypt"); + RewardDrops.ambientKill(infestation, killer, location, drops); + when(infestation.getGroupId()).thenReturn("swamp_mobs"); + zones.when(() -> PermadeathZones.inZone(location)).thenReturn(true); + RewardDrops.ambientKill(infestation, killer, location, drops); + assertEquals(List.of(), drops); + } + + @Test void missingItemsWarnOncePerPathAndBuildNothing() { + assertEquals(List.of(), RewardDrops.stacks("stone", 0)); + assertEquals(List.of(), RewardDrops.stacks("gone", 2)); + assertEquals(List.of(), RewardDrops.stacks("gone", 2)); + assertEquals(List.of(), RewardDrops.stacks("boom", 1)); + verify(logger, times(1)).warning("[Infestations] rewards.yml item not found: gone"); + verify(logger, times(1)).warning("[Infestations] rewards.yml item not found: boom"); + } + + @Test void splitSpreadsAStackOverAtMostThreePieces() { + assertEquals(List.of(1), RewardDrops.split(new ItemStack(Material.STONE, 1)).stream().map(ItemStack::getAmount).toList()); + assertEquals(List.of(3, 2, 2), RewardDrops.split(new ItemStack(Material.STONE, 7)).stream().map(ItemStack::getAmount).toList()); + } + + @Test void lureVictoryBurstsOwnerLockedLootForPlayersWithEnoughKills() { + RewardConfig.lure = Map.of(Severity.SEVERE.id(), table(new Entry("key", 1, 4, 4), new Entry("stone", 1, 1, 1))); + zones.when(() -> PermadeathZones.inZone(location)).thenReturn(true); + Player fighter = mock(Player.class), idler = mock(Player.class); + UUID fighterId = UUID.randomUUID(), idlerId = UUID.randomUUID(); + when(fighter.getUniqueId()).thenReturn(fighterId); + when(idler.getUniqueId()).thenReturn(idlerId); + when(infestation.lureKills(fighterId)).thenReturn(3); + when(infestation.lureKills(idlerId)).thenReturn(2); + + RewardDrops.lureVictory(infestation, location, List.of(fighter, idler)); + verify(fighter).sendMessage("lure-reward"); + verify(idler, never()).sendMessage(anyString()); + assertEquals(4, later.size()); + assertEquals(List.of(1L, 8L, 16L, 23L), delays); + + List thrown = new ArrayList<>(); + Item entity = mock(Item.class); + when(world.dropItem(any(Location.class), any(ItemStack.class), any())).thenAnswer(i -> { + thrown.add(i.getArgument(1)); + i.>getArgument(2).accept(entity); + return entity; + }); + later.forEach(Runnable::run); + assertEquals(4, total(thrown, Material.TRIAL_KEY)); + assertEquals(1, total(thrown, Material.STONE)); + verify(entity, times(4)).setOwner(fighterId); + verify(entity, times(4)).setPickupDelay(15); + ArgumentCaptor velocity = ArgumentCaptor.forClass(Vector.class); + verify(entity, times(4)).setVelocity(velocity.capture()); + for (Vector v : velocity.getAllValues()) { + assertTrue(Math.abs(v.getX()) <= 0.15 && Math.abs(v.getZ()) <= 0.15); + assertTrue(v.getY() >= 0.35 && v.getY() <= 0.55); + } + ArgumentCaptor from = ArgumentCaptor.forClass(Location.class); + verify(world, times(4)).playSound(from.capture(), eq(Sound.BLOCK_VAULT_EJECT_ITEM), eq(0.8f), anyFloat()); + assertEquals(new Location(world, 10.5, 65, 20.5), from.getValue()); + } + + @Test void lureVictoryOutsideZonesThinsLootAndSkipsEmptyBursts() { + RewardConfig.lure = Map.of(Severity.SEVERE.id(), table(new Entry("key", 1, 4, 4))); + zones.when(() -> PermadeathZones.inZone(location)).thenReturn(false); + Player fighter = mock(Player.class); + UUID id = UUID.randomUUID(); + when(fighter.getUniqueId()).thenReturn(id); + when(infestation.lureKills(id)).thenReturn(10); + + RewardDrops.lureVictory(infestation, location, List.of(fighter)); + verify(fighter, never()).sendMessage(anyString()); + assertEquals(List.of(), later); + + RewardConfig.lureKeep = new RewardRoller.Keep(1, Map.of()); + RewardDrops.lureVictory(infestation, location, List.of(fighter)); + verify(fighter).sendMessage("lure-reward"); + assertEquals(3, later.size()); + } + + @Test void lureVictoryNeedsALocationARewardGroupAndATable() { + Player fighter = mock(Player.class); + RewardConfig.lure = Map.of(); + RewardDrops.lureVictory(infestation, null, List.of(fighter)); + RewardDrops.lureVictory(infestation, location, List.of(fighter)); + when(infestation.getGroupId()).thenReturn("crypt"); + RewardConfig.lure = Map.of(Severity.SEVERE.id(), table(new Entry("key", 1, 1, 1))); + RewardDrops.lureVictory(infestation, location, List.of(fighter)); + verifyNoInteractions(fighter); + assertEquals(List.of(), later); + } +} diff --git a/src/test/java/net/tfminecraft/infestations/reward/RewardRollerTest.java b/src/test/java/net/tfminecraft/infestations/reward/RewardRollerTest.java new file mode 100644 index 0000000..1cce469 --- /dev/null +++ b/src/test/java/net/tfminecraft/infestations/reward/RewardRollerTest.java @@ -0,0 +1,78 @@ +package net.tfminecraft.infestations.reward; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.infestations.reward.RewardRoller.Drop; +import net.tfminecraft.infestations.reward.RewardRoller.Keep; +import net.tfminecraft.infestations.reward.RewardTable.Coin; +import net.tfminecraft.infestations.reward.RewardTable.Entry; +import net.tfminecraft.infestations.reward.RewardTable.Weighted; + +class RewardRollerTest { + + /** Random that returns queued values. */ + static final class Script extends Random { + final Deque doubles = new ArrayDeque<>(); + final Deque ints = new ArrayDeque<>(); + Script d(double... values) { for (double v : values) doubles.add(v); return this; } + Script i(int... values) { for (int v : values) ints.add(v); return this; } + @Override public double nextDouble() { return doubles.remove(); } + @Override public int nextInt(int bound) { int v = ints.remove(); assertTrue(v < bound); return v; } + } + + private static final List COINS = List.of(new Coin("gold", 1), new Coin("pouch", 100), new Coin("stack", 10)); + + @Test void rollsHitsAmountsGemsAndCoinsWithoutKeep() { + Script random = new Script().d(0.5, 0.01).i(2, 1, 0); + RewardTable table = new RewardTable(List.of( + new Entry("miss", 0.1, 1, 1), + new Entry("key", 0.5, 1, 3), + new Entry("never", 0, 1, 1), + new Entry("gem", 1, 2, 2), + new Entry("denars", 1, 123, 123), + new Entry("key", 1, 1, 1)), + List.of(new Weighted("basic", 1), new Weighted("broken", -4), new Weighted("radiant", 9))); + // miss: 0.5 misses 0.1. key: 0.01 hits, amount 1+2. gems: roll 1 passes basic and broken to radiant, roll 0 is basic. + List drops = new RewardRoller(random).roll(table, COINS, null); + assertEquals(List.of(new Drop("key", 4), new Drop("radiant", 1), new Drop("basic", 1), + new Drop("pouch", 1), new Drop("stack", 2), new Drop("gold", 3)), drops); + assertTrue(random.doubles.isEmpty() && random.ints.isEmpty()); + } + + @Test void gemsWithoutWeightsAreSkipped() { + RewardTable table = new RewardTable(List.of(new Entry("gem", 1, 3, 3)), List.of(new Weighted("zero", 0))); + assertEquals(List.of(), new RewardRoller(new Script()).roll(table, COINS, null)); + } + + @Test void keepThinsEachUnitAndDenarsBeforeCoins() { + Script random = new Script().d(0.05, 0.5, 0.05, 0.5, 0.05, 0.05, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5).i(0, 0); + Keep keep = new Keep(0.1, Map.of("whole", 1.0, "gone", 0.0)); + RewardTable table = new RewardTable(List.of( + new Entry("whole", 1, 2, 2), + new Entry("gone", 1, 4, 4), + new Entry("thin", 1, 2, 2), + new Entry("gem", 1, 2, 2), + new Entry("denars", 1, 10, 10)), List.of(new Weighted("basic", 1))); + // whole keeps all, gone keeps none. thin: one of two. gems: one of two. denars: two of ten. + List drops = new RewardRoller(random).roll(table, List.of(new Coin("gold", 1)), keep); + assertEquals(List.of(new Drop("whole", 2), new Drop("thin", 1), new Drop("basic", 1), new Drop("gold", 2)), drops); + assertTrue(random.doubles.isEmpty() && random.ints.isEmpty()); + assertEquals(0.1, keep.of("other")); + assertEquals(1.0, keep.of("whole")); + } + + @Test void coinsUseLargestFirstAndSkipBadValues() { + assertEquals(List.of(), RewardRoller.coins(0, COINS)); + assertEquals(List.of(), RewardRoller.coins(-5, COINS)); + assertEquals(List.of(new Drop("five", 1)), + RewardRoller.coins(7, List.of(new Coin("bad", 0), new Coin("five", 5)))); + } +} diff --git a/src/test/java/net/tfminecraft/infestations/spawn/SpawnServicesTest.java b/src/test/java/net/tfminecraft/infestations/spawn/SpawnServicesTest.java index 0e8ad02..a82b0cf 100644 --- a/src/test/java/net/tfminecraft/infestations/spawn/SpawnServicesTest.java +++ b/src/test/java/net/tfminecraft/infestations/spawn/SpawnServicesTest.java @@ -153,6 +153,7 @@ private MockedStatic planner(Location spot) { assertEquals(11, Keys.displayProvince(pdc)); assertEquals("infestations", Keys.infestationId().getNamespace()); assertNotEquals(Keys.infestationId(), Keys.displayProvince()); + assertFalse(Keys.isSummoned(pdc)); Keys.markSummoned(pdc); assertTrue(Keys.isSummoned(pdc)); } @Test void plannerRejectsMissingOriginsZeroRequestsAndUnloadedAreas() { diff --git a/src/test/java/net/tfminecraft/rpcharacters/permadeath/PermadeathAreaLookup.java b/src/test/java/net/tfminecraft/rpcharacters/permadeath/PermadeathAreaLookup.java new file mode 100644 index 0000000..716b1ca --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/permadeath/PermadeathAreaLookup.java @@ -0,0 +1,18 @@ +package net.tfminecraft.rpcharacters.permadeath; + +import org.bukkit.Location; + +/** Stand-in for the RPCharacters lookup that PermadeathZones finds by reflection. */ +public final class PermadeathAreaLookup { + public static Object zone; + public static RuntimeException failure; + + private PermadeathAreaLookup() {} + + public static Object getPermadeathZoneAt(Location location) { + if (failure != null) { + throw failure; + } + return zone; + } +} From 509dfeaf7544fd3bec58b6cd2f13f445a7c685d9 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:36:46 +0000 Subject: [PATCH 2/4] Drop ambient rewards into the world so MythicMobs PreventOtherDrops keeps them The bog mobs set PreventOtherDrops, which clears the death event drop list after our listener adds the roll. Co-Authored-By: Claude Opus 5.5 --- .../infestation/InfestationManager.java | 2 +- .../infestations/reward/RewardDrops.java | 13 +++++++--- .../infestation/InfestationManagerTest.java | 5 ++-- .../infestations/reward/RewardDropsTest.java | 26 ++++++++++++------- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java index eb34e05..87bbf3a 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -1161,7 +1161,7 @@ public void onDeath(EntityDeathEvent event) { infestation.setAmbientAlive(infestation.getAmbientAlive() - 1); } if (killer != null && !Keys.isSummoned(entity.getPersistentDataContainer())) { - RewardDrops.ambientKill(infestation, killer, entity.getLocation(), event.getDrops()); + RewardDrops.ambientKill(infestation, killer, entity.getLocation()); } return; } diff --git a/src/main/java/net/tfminecraft/infestations/reward/RewardDrops.java b/src/main/java/net/tfminecraft/infestations/reward/RewardDrops.java index 967e456..9ea68b1 100644 --- a/src/main/java/net/tfminecraft/infestations/reward/RewardDrops.java +++ b/src/main/java/net/tfminecraft/infestations/reward/RewardDrops.java @@ -42,9 +42,12 @@ public final class RewardDrops { private RewardDrops() {} - /** Adds the roll for one ambient mob killed by {@code killer} to the death drops. */ - public static void ambientKill(Infestation infestation, Player killer, Location location, List drops) { - if (!RewardConfig.appliesTo(infestation.getGroupId())) { + /** + * Drops the roll for one ambient mob killed by {@code killer} at its death location. The items go straight + * into the world because MythicMobs PreventOtherDrops clears the death event's drop list. + */ + public static void ambientKill(Infestation infestation, Player killer, Location location) { + if (location == null || !RewardConfig.appliesTo(infestation.getGroupId())) { return; } RewardTable table = RewardConfig.ambient(infestation.getSeverity(), PermadeathZones.inZone(location)); @@ -56,7 +59,9 @@ public static void ambientKill(Infestation infestation, Player killer, Location if (drop.item().equals(RewardConfig.keyItem)) { amount = keyCap.allow(killer.getUniqueId(), amount, RewardConfig.keyCapPerHour, clock.getAsLong()); } - drops.addAll(stacks(drop.item(), amount)); + for (ItemStack stack : stacks(drop.item(), amount)) { + location.getWorld().dropItemNaturally(location, stack); + } } } diff --git a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java index 9ef1c86..ce32e58 100644 --- a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java +++ b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java @@ -842,18 +842,17 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { Infestation state = lure(1, LurePhase.ACTIVE); state.setAmbientAlive(5); state.setEnemiesAlive(5); Player killer = player(1); EntityDeathEvent death = mock(EntityDeathEvent.class); - List drops = new ArrayList<>(); when(death.getDrops()).thenReturn(drops); LivingEntity unkilled = mob(1, Keys.KIND_AMBIENT); when(death.getEntity()).thenReturn(unkilled); manager.onDeath(death); rewards.verifyNoInteractions(); LivingEntity killed = mob(1, Keys.KIND_AMBIENT); when(killed.getKiller()).thenReturn(killer); Location at = new Location(world, 1, 2, 3); when(killed.getLocation()).thenReturn(at); when(death.getEntity()).thenReturn(killed); manager.onDeath(death); - rewards.verify(() -> RewardDrops.ambientKill(state, killer, at, drops)); + rewards.verify(() -> RewardDrops.ambientKill(state, killer, at)); LivingEntity worm = mob(1, Keys.KIND_AMBIENT); when(worm.getKiller()).thenReturn(killer); summoned.add(worm.getPersistentDataContainer()); when(death.getEntity()).thenReturn(worm); manager.onDeath(death); - rewards.verify(() -> RewardDrops.ambientKill(any(), any(), any(), any()), times(1)); + rewards.verify(() -> RewardDrops.ambientKill(any(), any(), any()), times(1)); LivingEntity lureMob = mob(1, Keys.KIND_LURE); when(lureMob.getKiller()).thenReturn(killer); when(death.getEntity()).thenReturn(lureMob); manager.onDeath(death); doReturn(mob(1, Keys.KIND_LURE)).when(death).getEntity(); manager.onDeath(death); diff --git a/src/test/java/net/tfminecraft/infestations/reward/RewardDropsTest.java b/src/test/java/net/tfminecraft/infestations/reward/RewardDropsTest.java index d8adaf5..8bfda19 100644 --- a/src/test/java/net/tfminecraft/infestations/reward/RewardDropsTest.java +++ b/src/test/java/net/tfminecraft/infestations/reward/RewardDropsTest.java @@ -113,35 +113,43 @@ private static int total(List stacks, Material type) { return stacks.stream().filter(s -> s.getType() == type).mapToInt(ItemStack::getAmount).sum(); } - @Test void ambientKillsAddCappedKeysAndOtherLootFromTheZoneTable() { + private List naturalDrops() { + List dropped = new ArrayList<>(); + when(world.dropItemNaturally(eq(location), any(ItemStack.class))).thenAnswer(i -> { + dropped.add(i.getArgument(1)); return null; }); + return dropped; + } + + @Test void ambientKillsDropCappedKeysAndOtherLootFromTheZoneTableIntoTheWorld() { Player killer = mock(Player.class); when(killer.getUniqueId()).thenReturn(UUID.randomUUID()); RewardConfig.ambient = Map.of( RewardConfig.OUTSIDE, table(new Entry("stone", 1, 70, 70)), Severity.SEVERE.id(), table(new Entry("key", 1, 2, 2), new Entry("stone", 1, 1, 1))); zones.when(() -> PermadeathZones.inZone(location)).thenReturn(true); + List drops = naturalDrops(); - List drops = new ArrayList<>(); - RewardDrops.ambientKill(infestation, killer, location, drops); - RewardDrops.ambientKill(infestation, killer, location, drops); + RewardDrops.ambientKill(infestation, killer, location); + RewardDrops.ambientKill(infestation, killer, location); assertEquals(3, total(drops, Material.TRIAL_KEY)); assertEquals(2, total(drops, Material.STONE)); drops.clear(); zones.when(() -> PermadeathZones.inZone(location)).thenReturn(false); - RewardDrops.ambientKill(infestation, killer, location, drops); + RewardDrops.ambientKill(infestation, killer, location); assertEquals(List.of(64, 6), drops.stream().map(ItemStack::getAmount).toList()); } - @Test void ambientKillsOutsideRewardGroupsOrWithoutATableDropNothing() { + @Test void ambientKillsOutsideRewardGroupsWithoutATableOrLocationDropNothing() { Player killer = mock(Player.class); - List drops = new ArrayList<>(); + List drops = naturalDrops(); RewardConfig.ambient = Map.of(RewardConfig.OUTSIDE, table(new Entry("stone", 1, 1, 1))); + RewardDrops.ambientKill(infestation, killer, null); when(infestation.getGroupId()).thenReturn("crypt"); - RewardDrops.ambientKill(infestation, killer, location, drops); + RewardDrops.ambientKill(infestation, killer, location); when(infestation.getGroupId()).thenReturn("swamp_mobs"); zones.when(() -> PermadeathZones.inZone(location)).thenReturn(true); - RewardDrops.ambientKill(infestation, killer, location, drops); + RewardDrops.ambientKill(infestation, killer, location); assertEquals(List.of(), drops); } From c432395e74d776c344d5888ffc298855306581bb Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:04:39 +0000 Subject: [PATCH 3/4] Tie lure rewards to the joining character and prune idle key caps A lure now records the character each player joined with and saves it, so after a server restart only that character can claim the victory burst. Lures saved before this change have no record and keep the old check. The ambient key cap forgets players whose drops have all left the rolling hour, sweeping at most once an hour. Co-Authored-By: Claude Opus 5.5 --- .../database/InfestationData.java | 1 + .../database/InfestationDatabase.java | 27 ++++++++++++------ .../infestations/infestation/Infestation.java | 8 ++++++ .../infestation/InfestationManager.java | 15 +++++++++- .../infestations/reward/KeyCap.java | 28 +++++++++++++++++-- .../database/InfestationDataTest.java | 13 ++++++++- .../infestation/InfestationManagerTest.java | 10 +++++-- .../infestations/reward/KeyCapTest.java | 14 ++++++++++ 8 files changed, 99 insertions(+), 17 deletions(-) diff --git a/src/main/java/net/tfminecraft/infestations/database/InfestationData.java b/src/main/java/net/tfminecraft/infestations/database/InfestationData.java index dfc3965..8a6a12f 100644 --- a/src/main/java/net/tfminecraft/infestations/database/InfestationData.java +++ b/src/main/java/net/tfminecraft/infestations/database/InfestationData.java @@ -27,5 +27,6 @@ public class InfestationData { public Map lureKills = new HashMap<>(); public List deathOnLogin = new ArrayList<>(); public Map logoutCharacter = new HashMap<>(); + public Map joinCharacter = new HashMap<>(); public Map> owedDeaths = new HashMap<>(); } diff --git a/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java b/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java index 7bddeff..2778601 100644 --- a/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java +++ b/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java @@ -139,15 +139,8 @@ private static Infestation fromData(InfestationData row) { } } } - if (row.logoutCharacter != null) { - for (Map.Entry e : row.logoutCharacter.entrySet()) { - if (e.getKey() == null || e.getValue() == null) continue; - try { - infestation.getLogoutCharacter().put(UUID.fromString(e.getKey()), e.getValue()); - } catch (IllegalArgumentException ignored) { - } - } - } + readCharacters(row.logoutCharacter, infestation.getLogoutCharacter()); + readCharacters(row.joinCharacter, infestation.getJoinCharacter()); if (row.owedDeaths != null) { for (Map.Entry> e : row.owedDeaths.entrySet()) { if (e.getKey() == null || e.getValue() == null) continue; @@ -164,6 +157,19 @@ private static Infestation fromData(InfestationData row) { return infestation; } + private static void readCharacters(Map source, Map target) { + if (source == null) { + return; + } + for (Map.Entry e : source.entrySet()) { + if (e.getKey() == null || e.getValue() == null) continue; + try { + target.put(UUID.fromString(e.getKey()), e.getValue()); + } catch (IllegalArgumentException ignored) { + } + } + } + private static InfestationData toData(Infestation infestation) { InfestationData row = new InfestationData(); row.provinceId = infestation.getProvinceId(); @@ -196,6 +202,9 @@ private static InfestationData toData(Infestation infestation) { for (Map.Entry e : infestation.getLogoutCharacter().entrySet()) { row.logoutCharacter.put(e.getKey().toString(), e.getValue()); } + for (Map.Entry e : infestation.getJoinCharacter().entrySet()) { + row.joinCharacter.put(e.getKey().toString(), e.getValue()); + } for (Map.Entry> e : infestation.getOwedDeaths().entrySet()) { row.owedDeaths.put(e.getKey().toString(), new ArrayList<>(e.getValue())); } diff --git a/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java b/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java index d4369d1..4eca29a 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java @@ -45,6 +45,8 @@ public long getLureGeneration() { private final Set deathOnLogin = new HashSet<>(); /** The character each committed player logged off as, so a death on login reaches only that character. */ private final Map logoutCharacter = new HashMap<>(); + /** The character each player committed with, so only that character can claim the lure's rewards. */ + private final Map joinCharacter = new HashMap<>(); /** * Deaths still owed after their lure ended, by account: the characters that abandoned a running fight, * or {@link #ACCOUNT} on servers without characters. They outlive lures until settled; clearing the infestation forgives them. @@ -137,6 +139,7 @@ public void placeLure(Block block, long joinEndsAt, int budget) { this.lureKills.clear(); this.deathOnLogin.clear(); this.logoutCharacter.clear(); + this.joinCharacter.clear(); this.waveRetryAtTick = 0; this.lureActivatedAt = 0; this.lureReleased = 0; @@ -159,6 +162,7 @@ public void clearLure() { lureKills.clear(); deathOnLogin.clear(); logoutCharacter.clear(); + joinCharacter.clear(); waveRetryAtTick = 0; lureActivatedAt = 0; lureReleased = 0; @@ -323,6 +327,10 @@ public Map getLogoutCharacter() { return logoutCharacter; } + public Map getJoinCharacter() { + return joinCharacter; + } + public Map> getOwedDeaths() { return owedDeaths; } diff --git a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java index 87bbf3a..8934d58 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -856,13 +856,20 @@ private List rewardable(Infestation infestation) { for (UUID id : infestation.getCommitted()) { Player player = Bukkit.getPlayer(id); if (player != null && player.isOnline() && !player.isDead() - && !infestation.getDeathOnLogin().contains(id) && !heldByOtherCharacter(infestation, player)) { + && !infestation.getDeathOnLogin().contains(id) && !heldByOtherCharacter(infestation, player) + && onJoinCharacter(infestation, player)) { players.add(player); } } return players; } + /** Lures saved before join characters were recorded have none, so any character passes. */ + private static boolean onJoinCharacter(Infestation infestation, Player player) { + String joined = infestation.getJoinCharacter().get(player.getUniqueId()); + return joined == null || joined.equalsIgnoreCase(Characters.activeId(player)); + } + private void destroyLure(Infestation infestation, boolean victory) { LureHologram.remove(infestation); pluginFurnitureRemove = true; @@ -942,6 +949,12 @@ private void tryJoin(Player player, Infestation infestation) { infestation.getLogoutGraceUntil().remove(player.getUniqueId()); infestation.getDeathOnLogin().remove(player.getUniqueId()); infestation.getLogoutCharacter().remove(player.getUniqueId()); + String character = Characters.activeId(player); + if (character != null) { + infestation.getJoinCharacter().put(player.getUniqueId(), character); + } else { + infestation.getJoinCharacter().remove(player.getUniqueId()); + } player.sendMessage(Messages.get("lure-join")); save(); } diff --git a/src/main/java/net/tfminecraft/infestations/reward/KeyCap.java b/src/main/java/net/tfminecraft/infestations/reward/KeyCap.java index 60f68a0..b6e1683 100644 --- a/src/main/java/net/tfminecraft/infestations/reward/KeyCap.java +++ b/src/main/java/net/tfminecraft/infestations/reward/KeyCap.java @@ -13,6 +13,7 @@ final class KeyCap { static final long WINDOW_MS = 3_600_000L; private final Map> given = new HashMap<>(); + private long lastSweep; /** How many of {@code wanted} keys the player may still get. A cap of 0 or less means no cap. */ int allow(UUID player, int wanted, int cap, long now) { @@ -22,10 +23,9 @@ int allow(UUID player, int wanted, int cap, long now) { if (cap <= 0) { return wanted; } + sweep(now); ArrayDeque times = given.computeIfAbsent(player, k -> new ArrayDeque<>()); - while (!times.isEmpty() && times.peekFirst() <= now - WINDOW_MS) { - times.pollFirst(); - } + expire(times, now); int allowed = Math.min(wanted, cap - times.size()); for (int i = 0; i < allowed; i++) { times.addLast(now); @@ -33,6 +33,28 @@ int allow(UUID player, int wanted, int cap, long now) { return Math.max(0, allowed); } + /** Forgets players whose drops have all left the window, at most once per window. */ + private void sweep(long now) { + if (now - lastSweep < WINDOW_MS) { + return; + } + lastSweep = now; + given.values().removeIf(times -> { + expire(times, now); + return times.isEmpty(); + }); + } + + private static void expire(ArrayDeque times, long now) { + while (!times.isEmpty() && times.peekFirst() <= now - WINDOW_MS) { + times.pollFirst(); + } + } + + int tracked() { + return given.size(); + } + void clear() { given.clear(); } diff --git a/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java b/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java index 3ec10cc..a84d50d 100644 --- a/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java +++ b/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java @@ -142,6 +142,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { infestation.getLogoutGraceUntil().put(PLAYER, 100L); infestation.getDeathOnLogin().add(PLAYER); infestation.getLogoutCharacter().put(PLAYER, "alice"); + infestation.getJoinCharacter().put(PLAYER, "alice"); infestation.getOwedDeaths().put(PLAYER, Set.of("bob")); infestation.addLureKill(PLAYER); infestation.addLureKill(PLAYER); @@ -168,6 +169,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { assertTrue(infestation.getLogoutGraceUntil().isEmpty()); assertTrue(infestation.getDeathOnLogin().isEmpty()); assertTrue(infestation.getLogoutCharacter().isEmpty()); + assertTrue(infestation.getJoinCharacter().isEmpty()); assertEquals(0, infestation.lureKills(PLAYER)); assertEquals(Map.of(PLAYER, Set.of("bob")), infestation.getOwedDeaths(), "Owed deaths outlive lures"); assertEquals(0, infestation.getPendingSpawns()); @@ -181,6 +183,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { infestation.getLogoutGraceUntil().put(PLAYER, 100L); infestation.getDeathOnLogin().add(PLAYER); infestation.getLogoutCharacter().put(PLAYER, "alice"); + infestation.getJoinCharacter().put(PLAYER, "alice"); infestation.addLureKill(PLAYER); infestation.clearLure(); assertSame(LurePhase.NONE, infestation.getPhase()); @@ -196,6 +199,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { assertTrue(infestation.getLogoutGraceUntil().isEmpty()); assertTrue(infestation.getDeathOnLogin().isEmpty()); assertTrue(infestation.getLogoutCharacter().isEmpty()); + assertTrue(infestation.getJoinCharacter().isEmpty()); assertTrue(infestation.getLureKills().isEmpty()); assertEquals(Map.of(PLAYER, Set.of("bob")), infestation.getOwedDeaths()); } @@ -260,6 +264,7 @@ void persistenceRoundTripKeepsRuntimeStateButRequeuesScheduledSpawns() throws Ex original.getLogoutGraceUntil().put(PLAYER, 9876L); original.getDeathOnLogin().add(PLAYER); original.getLogoutCharacter().put(PLAYER, "alice"); + original.getJoinCharacter().put(PLAYER, "bob"); original.getOwedDeaths().put(PLAYER, Set.of("bob", "carol")); original.getLureKills().put(PLAYER, 12); InfestationDatabase.save(List.of(original)); @@ -281,6 +286,7 @@ void persistenceRoundTripKeepsRuntimeStateButRequeuesScheduledSpawns() throws Ex assertEquals(9876L, loaded.getLogoutGraceUntil().get(PLAYER)); assertEquals(Set.of(PLAYER), loaded.getDeathOnLogin()); assertEquals(Map.of(PLAYER, "alice"), loaded.getLogoutCharacter()); + assertEquals(Map.of(PLAYER, "bob"), loaded.getJoinCharacter()); assertEquals(Map.of(PLAYER, Set.of("bob", "carol")), loaded.getOwedDeaths()); assertEquals(Map.of(PLAYER, 12), loaded.getLureKills()); } @@ -294,14 +300,16 @@ void malformedRowsAndLegacyFieldsDoNotDiscardValidNeighbors() throws Exception { {"provinceId":3,"groupId":"valid","severity":"mild","phase":"old-phase", "committed":["bad","%s"],"logoutGraceUntil":{"bad":5,"%s":6}, "deathOnLogin":["bad","%s"],"logoutCharacter":{"bad":"x","%s":"alice"}, + "joinCharacter":{"bad":"x","%s":"bob"}, "owedDeaths":{"bad":["x"],"%s":[null,"bob"]}, "lureKills":{"bad":4,"null":2,"%s":5,"00000000-0000-0000-0000-000000000001":null}, "pendingSpawns":20,"lureReleased":3}, {"provinceId":4,"groupId":"legacy","severity":"worrying","committed":null, "logoutGraceUntil":null,"deathOnLogin":null,"logoutCharacter":{"%s":null}, + "joinCharacter":null, "owedDeaths":{"%s":null},"lureKills":null}, {"provinceId":5,"groupId":"older","severity":"mild","owedDeaths":null}] - """.formatted(PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER)); + """.formatted(PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER)); List loaded = InfestationDatabase.load(); assertEquals(3, loaded.size()); assertEquals(Map.of(PLAYER, 5), loaded.getFirst().getLureKills()); @@ -318,6 +326,9 @@ void malformedRowsAndLegacyFieldsDoNotDiscardValidNeighbors() throws Exception { assertTrue(loaded.get(1).getDeathOnLogin().isEmpty()); assertEquals(Map.of(PLAYER, "alice"), loaded.getFirst().getLogoutCharacter()); assertTrue(loaded.get(1).getLogoutCharacter().isEmpty()); + assertEquals(Map.of(PLAYER, "bob"), loaded.getFirst().getJoinCharacter()); + assertTrue(loaded.get(1).getJoinCharacter().isEmpty()); + assertTrue(loaded.get(2).getJoinCharacter().isEmpty()); } @Test diff --git a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java index ce32e58..3854975 100644 --- a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java +++ b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java @@ -864,13 +864,16 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { MockedStatic rewards = scoped(mockStatic(RewardDrops.class)); Infestation active = lure(1, LurePhase.ACTIVE); Player fighter = player(1), dead = player(1), offline = player(1), owed = player(1), swapped = player(1); - Player sessionless = player(1); + Player sessionless = player(1), rejoined = player(1); UUID gone = UUID.randomUUID(); characters.when(Characters::enabled).thenReturn(true); - for (Player p : List.of(fighter, dead, offline, owed, swapped)) current(p, UUID.randomUUID().toString()); + for (Player p : List.of(fighter, dead, offline, owed, swapped, rejoined)) current(p, UUID.randomUUID().toString()); active.getCommitted().add(sessionless.getUniqueId()); when(dead.isDead()).thenReturn(true); when(offline.isOnline()).thenReturn(false); - for (Player p : List.of(fighter, dead, offline, owed, swapped)) active.getCommitted().add(p.getUniqueId()); + for (Player p : List.of(fighter, dead, offline, owed, swapped, rejoined)) active.getCommitted().add(p.getUniqueId()); + active.getJoinCharacter().put(fighter.getUniqueId(), Characters.activeId(fighter).toUpperCase(Locale.ROOT)); + // A server restart ends sessions without recording a logout character; the join character still holds. + active.getJoinCharacter().put(rejoined.getUniqueId(), UUID.randomUUID().toString()); active.getCommitted().add(gone); active.getDeathOnLogin().add(owed.getUniqueId()); active.getLogoutCharacter().put(swapped.getUniqueId(), UUID.randomUUID().toString()); @@ -1113,6 +1116,7 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { verify(p).sendMessage("character-required"); start(p, ALICE); call("tryJoin", p, lure); assertTrue(lure.isCommitted(id)); + assertEquals(ALICE, lure.getJoinCharacter().get(id)); CharacterSession old = sessions.get(id); end(p, CharacterSessionEndEvent.Reason.SWITCH); assertFalse(manager.leaveLure(p)); diff --git a/src/test/java/net/tfminecraft/infestations/reward/KeyCapTest.java b/src/test/java/net/tfminecraft/infestations/reward/KeyCapTest.java index fa26115..2a388bc 100644 --- a/src/test/java/net/tfminecraft/infestations/reward/KeyCapTest.java +++ b/src/test/java/net/tfminecraft/infestations/reward/KeyCapTest.java @@ -23,4 +23,18 @@ class KeyCapTest { cap.clear(); assertEquals(5, cap.allow(A, 5, 5, 0)); } + + @Test void forgetsPlayersWhoseKeysLeftTheWindowButKeepsRecentOnes() { + KeyCap cap = new KeyCap(); + long w = KeyCap.WINDOW_MS; + cap.allow(A, 2, 5, 10); + cap.allow(B, 2, 5, w - 10); + assertEquals(2, cap.tracked()); + cap.allow(B, 1, 5, w + 10); + assertEquals(1, cap.tracked()); + assertEquals(2, cap.allow(B, 5, 5, w + 20)); + cap.allow(A, 1, 5, w + 30); + assertEquals(2, cap.tracked()); + assertEquals(0, cap.allow(B, 5, 5, w + 40)); + } } From 337746bd8c68f1907e524b9e8286bb963c8a148e Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:53:49 +0000 Subject: [PATCH 4/4] Reset lure kills when a different character joins Co-Authored-By: Claude Opus 5.5 --- .../infestations/infestation/InfestationManager.java | 5 +++++ .../infestations/infestation/InfestationManagerTest.java | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java index 8934d58..bcc2b7c 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -950,6 +950,11 @@ private void tryJoin(Player player, Infestation infestation) { infestation.getDeathOnLogin().remove(player.getUniqueId()); infestation.getLogoutCharacter().remove(player.getUniqueId()); String character = Characters.activeId(player); + String previous = infestation.getJoinCharacter().get(player.getUniqueId()); + // Lure kills are counted per account, so a different character starts from zero. + if (previous != null && character != null && !previous.equalsIgnoreCase(character)) { + infestation.getLureKills().remove(player.getUniqueId()); + } if (character != null) { infestation.getJoinCharacter().put(player.getUniqueId(), character); } else { diff --git a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java index 3854975..a065922 100644 --- a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java +++ b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java @@ -1134,6 +1134,13 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { assertTrue(lure.getLogoutGraceUntil().isEmpty()); assertTrue(lure.getLogoutCharacter().isEmpty()); assertTrue(manager.leaveLure(p)); + lure.getLureKills().put(id, 5); + start(p, ALICE.toUpperCase(Locale.ROOT)); call("tryJoin", p, lure); + assertEquals(5, lure.getLureKills().get(id)); + assertTrue(manager.leaveLure(p)); + start(p, BOB); call("tryJoin", p, lure); + assertFalse(lure.getLureKills().containsKey(id)); + assertEquals(BOB, lure.getJoinCharacter().get(id)); } @Test void onlyTheSavedDeathOfTheDepartedCharacterSettlesItsLure() {