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..8a6a12f 100644 --- a/src/main/java/net/tfminecraft/infestations/database/InfestationData.java +++ b/src/main/java/net/tfminecraft/infestations/database/InfestationData.java @@ -24,7 +24,9 @@ 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 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 9ab3e80..2778601 100644 --- a/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java +++ b/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java @@ -121,24 +121,26 @@ private static Infestation fromData(InfestationData row) { } } } - if (row.deathOnLogin != null) { - for (String id : row.deathOnLogin) { - if (id == null) continue; + if (row.lureKills != null) { + for (Map.Entry e : row.lureKills.entrySet()) { + if (e.getKey() == null || e.getValue() == null) continue; try { - infestation.getDeathOnLogin().add(UUID.fromString(id)); + infestation.getLureKills().put(UUID.fromString(e.getKey()), e.getValue()); } catch (IllegalArgumentException ignored) { } } } - if (row.logoutCharacter != null) { - for (Map.Entry e : row.logoutCharacter.entrySet()) { - if (e.getKey() == null || e.getValue() == null) continue; + if (row.deathOnLogin != null) { + for (String id : row.deathOnLogin) { + if (id == null) continue; try { - infestation.getLogoutCharacter().put(UUID.fromString(e.getKey()), e.getValue()); + infestation.getDeathOnLogin().add(UUID.fromString(id)); } 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; @@ -155,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(); @@ -178,12 +193,18 @@ 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()); } 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 3d1edc1..4eca29a 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java @@ -41,9 +41,12 @@ 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<>(); + /** 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. @@ -133,8 +136,10 @@ 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.joinCharacter.clear(); this.waveRetryAtTick = 0; this.lureActivatedAt = 0; this.lureReleased = 0; @@ -154,8 +159,10 @@ public void clearLure() { enemiesAlive = 0; committed.clear(); logoutGraceUntil.clear(); + lureKills.clear(); deathOnLogin.clear(); logoutCharacter.clear(); + joinCharacter.clear(); waveRetryAtTick = 0; lureActivatedAt = 0; lureReleased = 0; @@ -300,6 +307,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; } @@ -308,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 0023c30..bcc2b7c 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,26 @@ 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) + && 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; @@ -927,6 +949,17 @@ 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); + 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 { + infestation.getJoinCharacter().remove(player.getUniqueId()); + } player.sendMessage(Messages.get("lure-join")); save(); } @@ -1140,10 +1173,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()); + } return; } if (Keys.KIND_LURE.equals(kind) && infestation.getPhase() == LurePhase.ACTIVE) { @@ -1151,6 +1188,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 +1223,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..b6e1683 --- /dev/null +++ b/src/main/java/net/tfminecraft/infestations/reward/KeyCap.java @@ -0,0 +1,61 @@ +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<>(); + 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) { + if (wanted <= 0) { + return 0; + } + if (cap <= 0) { + return wanted; + } + sweep(now); + ArrayDeque times = given.computeIfAbsent(player, k -> new ArrayDeque<>()); + expire(times, now); + int allowed = Math.min(wanted, cap - times.size()); + for (int i = 0; i < allowed; i++) { + times.addLast(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/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..9ea68b1 --- /dev/null +++ b/src/main/java/net/tfminecraft/infestations/reward/RewardDrops.java @@ -0,0 +1,164 @@ +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() {} + + /** + * 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)); + 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()); + } + for (ItemStack stack : stacks(drop.item(), amount)) { + location.getWorld().dropItemNaturally(location, stack); + } + } + } + + /** + * 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..a84d50d 100644 --- a/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java +++ b/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java @@ -142,7 +142,11 @@ 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); + assertEquals(2, infestation.lureKills(PLAYER)); infestation.setWaveRetryAtTick(5); infestation.setVictoryAtTick(6); infestation.setPendingSpawns(3); @@ -165,6 +169,8 @@ 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()); assertEquals(0, infestation.getEnemiesAlive()); @@ -177,6 +183,8 @@ 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()); assertFalse(infestation.hasLure()); @@ -191,6 +199,8 @@ 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()); } @@ -254,7 +264,9 @@ 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)); assertTrue(Files.readString(InfestationDatabase.file().toPath()).contains("test-group")); Infestation loaded = InfestationDatabase.load().getFirst(); @@ -274,7 +286,9 @@ 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()); } @Test @@ -286,15 +300,20 @@ 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}, - "owedDeaths":{"%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)); + """.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()); + 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()); @@ -307,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 268a9d3..a065922 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,51 @@ 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); + 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)); + 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()), 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), rejoined = player(1); + UUID gone = UUID.randomUUID(); + characters.when(Characters::enabled).thenReturn(true); + 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, 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()); + 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()); @@ -1067,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)); @@ -1084,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() { @@ -1256,6 +1313,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 +1335,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..2a388bc --- /dev/null +++ b/src/test/java/net/tfminecraft/infestations/reward/KeyCapTest.java @@ -0,0 +1,40 @@ +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)); + } + + @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)); + } +} 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..8bfda19 --- /dev/null +++ b/src/test/java/net/tfminecraft/infestations/reward/RewardDropsTest.java @@ -0,0 +1,238 @@ +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(); + } + + 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(); + + 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); + assertEquals(List.of(64, 6), drops.stream().map(ItemStack::getAmount).toList()); + } + + @Test void ambientKillsOutsideRewardGroupsWithoutATableOrLocationDropNothing() { + Player killer = mock(Player.class); + 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); + when(infestation.getGroupId()).thenReturn("swamp_mobs"); + zones.when(() -> PermadeathZones.inZone(location)).thenReturn(true); + RewardDrops.ambientKill(infestation, killer, location); + 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; + } +}