diff --git a/ADMIN-COMMANDS.md b/ADMIN-COMMANDS.md index b770b8a..5d986cd 100644 --- a/ADMIN-COMMANDS.md +++ b/ADMIN-COMMANDS.md @@ -78,3 +78,32 @@ semantics. `tools/dev-admin-values.cjs` exercises both this plugin and the RPCharacters Focus commands on TFMCDev01 through its existing Dev bot helpers. It uses only the dedicated `AdminValueProbe` character, removes its temporary operator status, resets its test values and clears its test inventory afterward. + +## Blood path + +Staff tools for the hidden blood magic progression (`bloodmagic.yml`). Player +targets must be online on the character being edited. Records and altars are +saved under `data/bloodmagic/`. + +```text +/magic blood list +/magic blood info +/magic blood stage +/magic blood path +/magic blood give +/magic blood whisper [amount] +/magic blood voice +/magic blood target > +/magic blood bonds +/magic blood altar +/magic blood reset +``` + +`whisper` without an amount wakes the voices at once; with an amount it adds to +the hidden meter. `voice` speaks the next line now. `target` manages the final +sacrifices: `reroll` drops the named target for a fresh pick, `set` names a living +character, `skip` counts the named target as done without a death. `bonds` lists +the character's ten closest bonds with their hidden scores. Altars cannot be +broken by anyone; `altar purify` removes one and puts back every block it +corrupted, and an altar purifies itself when its owner character dies. `reset` +purifies the altar and clears the whole path; items already handed out stay. diff --git a/pom.xml b/pom.xml index 279096d..f170c4a 100644 --- a/pom.xml +++ b/pom.xml @@ -13,7 +13,7 @@ UTF-8 full 0.2.0 - 2.16.0 + 2.31.0 2.6.0 @@ -88,6 +88,16 @@ + + + com.mojang + authlib + 7.0.61 + test + + ** + + local @@ -143,6 +153,20 @@ + + + com.sk89q.worldguard + worldguard-bukkit + 7.0.9 + provided + + + + org.bstats + bstats-bukkit + 3.0.1 + test + @@ -150,6 +174,14 @@ papermc https://repo.papermc.io/repository/maven-public/ + + enginehub + https://maven.enginehub.org/repo/ + + + minecraft-libraries + https://libraries.minecraft.net/ + diff --git a/src/main/java/net/tfminecraft/magic/Magic.java b/src/main/java/net/tfminecraft/magic/Magic.java index d3f6f90..105b608 100644 --- a/src/main/java/net/tfminecraft/magic/Magic.java +++ b/src/main/java/net/tfminecraft/magic/Magic.java @@ -22,6 +22,7 @@ import net.tfminecraft.magic.artifact.shrine.ShrineChargeService; import net.tfminecraft.magic.attunement.ArtifactDisplayIndex; import net.tfminecraft.magic.attunement.AuraLog; +import net.tfminecraft.magic.blood.BloodMagic; import net.tfminecraft.magic.listener.ArtifactFillChestListener; import net.tfminecraft.magic.listener.ArtifactListener; import net.tfminecraft.magic.listener.CastDriftListener; @@ -157,6 +158,7 @@ public void onEnable() { MagicTickService.register(ctx -> SpellModifierApplyService.syncChangedOnline( resonanceGuiManager.getSessionManager())); } + BloodMagic.start(this); MagicTickService.start(); getServer().getScheduler().runTask(this, ArtifactDisplayIndex::rebuildFromSaved); getServer().getScheduler().runTask(this, GearStationStore::load); @@ -197,6 +199,7 @@ public void onDisable() { shutdownStep("fill chests", ArtifactFillChestService::clearAll); shutdownStep("shrine charges", ShrineChargeService::clearAll); shutdownStep("sacrifice rites", SacrificeRiteService::clearAll); + shutdownStep("blood magic", BloodMagic::stop); shutdownStep("spell modifiers", () -> { if (isMmoStackPresent()) { SpellModifierApplyService.clearAll(); @@ -277,6 +280,7 @@ private boolean loadConfigs() { ok &= chargesLoader.loadSafe(new File(getDataFolder(), "charges.yml")); ok &= gearLoader.loadFolder(new File(getDataFolder(), "gear")); ok &= ArtifactConfigLoader.loadFolder(new File(getDataFolder(), "artifacts")); + ok &= BloodMagic.loadConfig(getDataFolder(), getLogger()); AuraLog.configure(Cache.loggingEnabled, Cache.wipeLog, getDataFolder()); if (ok) { getLogger().info("[Magic] Configs loaded."); @@ -304,6 +308,7 @@ private void createFolders() { mkdir("artifacts"); mkdir("gear"); mkdir("data/characters"); + mkdir("data/bloodmagic/characters"); } private void mkdir(String relativePath) { @@ -320,6 +325,16 @@ private void createConfigs() { "gui.yml", "skills.yml", "charges.yml", + "wildmagic/bloodmagic/settings.yml", + "wildmagic/bloodmagic/items.yml", + "wildmagic/bloodmagic/voices.yml", + "wildmagic/bloodmagic/animals.yml", + "wildmagic/bloodmagic/corruption.yml", + "wildmagic/bloodmagic/choice.yml", + "wildmagic/bloodmagic/armour.yml", + "wildmagic/bloodmagic/artefacts.yml", + "wildmagic/bloodmagic/fx.yml", + "wildmagic/bloodmagic/messages.yml", "gear/archetypes.yml", "gear/part-types.yml", "gear/parts.yml", diff --git a/src/main/java/net/tfminecraft/magic/Messages.java b/src/main/java/net/tfminecraft/magic/Messages.java index af7653f..df1165b 100644 --- a/src/main/java/net/tfminecraft/magic/Messages.java +++ b/src/main/java/net/tfminecraft/magic/Messages.java @@ -38,9 +38,44 @@ public static void loadFromResources() { } } + /** + * Lays a module's own message file over messages.yml: its live keys win over the main file, and its + * bundled copy (resourcePath in the jar) backs them up the same way messages.yml's does. + */ + public static void overlay(File file, String resourcePath) { + FileConfiguration part = readResource(resourcePath); + if (part != null) { + bundled = copyInto(part, bundled); + } + if (file == null || !file.isFile()) { + return; + } + FileConfiguration live = new YamlConfiguration(); + try { + live.load(file); + config = copyInto(live, config); + } catch (IOException | InvalidConfigurationException ex) { + Magic.plugin.getLogger().severe("[Magic] Failed to load " + file.getName() + ": " + ex.getMessage()); + } + } + + private static FileConfiguration copyInto(FileConfiguration from, FileConfiguration to) { + FileConfiguration target = to != null ? to : new YamlConfiguration(); + for (String key : from.getKeys(true)) { + if (!from.isConfigurationSection(key)) { + target.set(key, from.get(key)); + } + } + return target; + } + private static FileConfiguration readBundled() { + return readResource("messages.yml"); + } + + private static FileConfiguration readResource(String name) { try { - InputStream resource = Magic.plugin.getResource("messages.yml"); + InputStream resource = Magic.plugin.getResource(name); if (resource == null) { return null; } @@ -50,7 +85,7 @@ private static FileConfiguration readBundled() { return loaded; } } catch (Exception ex) { - Magic.plugin.getLogger().warning("[Magic] Failed to load bundled messages.yml: " + ex.getMessage()); + Magic.plugin.getLogger().warning("[Magic] Failed to load bundled " + name + ": " + ex.getMessage()); return null; } } diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarCandles.java b/src/main/java/net/tfminecraft/magic/blood/AltarCandles.java new file mode 100644 index 0000000..3926cc1 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AltarCandles.java @@ -0,0 +1,146 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Random; +import java.util.function.BiConsumer; + +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; + +/** + * The flames on the altar's candles are vanilla's own candle particles (fx.yml candles:): every few + * ticks a small flame on each wick of the altar's model, now and then a wisp of smoke, only while a + * player is near. The wick positions are in the model's pixels and follow the altar's facing and scale. + */ +public final class AltarCandles { + + /** A wick top in model pixels, and the highest altar level that still has that candle. */ + record Wick(double x, double y, double z, int maxLevel) {} + + private static boolean enabled; + private static int everyTicks; + private static double smokeChance; + private static double viewRange; + private static float yawOffset; + private static Particle flame; + private static List wicks = List.of(); + private static BukkitTask task; + private static long ticks; + static Random random = new Random(); + + static { + reset(); + } + + private AltarCandles() {} + + static void reset() { + enabled = false; + everyTicks = 8; + smokeChance = 0.1; + viewRange = 24; + yawOffset = 180; + flame = Particle.SMALL_FLAME; + wicks = List.of(); + } + + static void load(ConfigurationSection section, java.util.logging.Logger log) { + reset(); + if (section == null) { + return; + } + enabled = section.getBoolean("enabled", true); + everyTicks = Math.max(1, section.getInt("every_ticks", 8)); + smokeChance = Math.max(0, Math.min(1, section.getDouble("smoke_chance", 0.1))); + viewRange = Math.max(1, section.getDouble("view_range", 24)); + yawOffset = (float) section.getDouble("yaw_offset", 180); + String name = section.getString("particle", "SMALL_FLAME"); + try { + flame = Particle.valueOf(name.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + log.warning("[Magic] blood magic config fx.candles.particle: unknown particle '" + name + "'"); + } + List out = new ArrayList<>(); + for (Object raw : section.getList("wicks", List.of())) { + if (raw instanceof List list && list.size() >= 3 && list.get(0) instanceof Number x + && list.get(1) instanceof Number y && list.get(2) instanceof Number z) { + int max = list.size() > 3 && list.get(3) instanceof Number m ? m.intValue() : Integer.MAX_VALUE; + out.add(new Wick(x.doubleValue(), y.doubleValue(), z.doubleValue(), max)); + } else { + log.warning("[Magic] blood magic config fx.candles.wicks: '" + raw + "' is not [x, y, z, max_level]"); + } + } + wicks = List.copyOf(out); + } + + static void startTicking(Plugin plugin) { + stop(); + task = plugin.getServer().getScheduler().runTaskTimer(plugin, AltarCandles::tick, 20L, 1L); + } + + static void stop() { + if (task != null) { + task.cancel(); + task = null; + } + } + + /** Every tick; flames go up every every_ticks for altars with a player in view. */ + static void tick() { + if (!enabled || wicks.isEmpty() || ++ticks % everyTicks != 0) { + return; + } + for (BloodAltar altar : BloodAltars.all()) { + Location center = altar.center(); + if (center == null || !center.getWorld().isChunkLoaded(altar.getX() >> 4, altar.getZ() >> 4) + || !watched(center)) { + continue; + } + for (Wick wick : wicks) { + if (altar.getLevel() <= wick.maxLevel()) { + light(position(center, altar.getYaw(), wick)); + } + } + } + } + + private static void light(Location at) { + spawner.accept(flame, at); + if (random.nextDouble() < smokeChance) { + spawner.accept(Particle.SMOKE, at.clone().add(0, 0.05, 0)); + } + } + + /** Puts one particle at a spot for the players around it. */ + static BiConsumer spawner = (particle, at) -> at.getWorld().spawnParticle(particle, at, 1, 0, 0, 0, 0); + + private static boolean watched(Location center) { + for (Player player : center.getWorld().getPlayers()) { + if (player.getLocation().distanceSquared(center) <= viewRange * viewRange) { + return true; + } + } + return false; + } + + /** + * Where a wick of the model is in the world: the model is centred on the altar's block, scaled by + * altar.display_scale and turned by the altar's yaw (plus yaw_offset for how item displays face). + */ + static Location position(Location center, float yaw, Wick wick) { + double scale = BloodConfig.displayScale(); + double x = (wick.x() - 8) / 16 * scale; + double y = (wick.y() - 8) / 16 * scale; + double z = (wick.z() - 8) / 16 * scale; + double angle = Math.toRadians(-(yaw + yawOffset)); + double cos = Math.cos(angle); + double sin = Math.sin(angle); + return center.clone().add(x * cos + z * sin, y, -x * sin + z * cos); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java new file mode 100644 index 0000000..c7bdff7 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java @@ -0,0 +1,256 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.ListIterator; +import java.util.Random; +import java.util.Set; +import java.util.function.LongSupplier; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Tag; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Lightable; +import org.bukkit.block.data.Ageable; + +/** + * The room around an altar slowly turns. Only listed blocks change: exposed stone-like blocks + * are converted, empty floor and ceiling spots are decorated. It grows outward from the altar in + * connected patches: of a handful of random candidates, the one nearest the altar that touches what + * it already turned wins, so the room reddens in veins rather than speckles. Floor and ceiling + * decorations grow only on blocks it has already turned, and candles stand only on the spots right in + * front of the pedestals. Every change is journalled on the altar with the block + * it replaced, and purification puts back any block still as the altar left it. + */ +public final class AltarCorruption { + + private static final int ATTEMPTS = 24; + private static final int MAX_HEIGHT_REACH = 3; + private static final BlockFace[] FACES = { + BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST + }; + + static Random random = new Random(); + static LongSupplier clock = System::currentTimeMillis; + + private AltarCorruption() {} + + /** Changes up to count blocks within the altar level's reach and budget; returns how many changed. */ + public static int spread(BloodAltar altar, int count) { + Block center = altar.block(); + if (center == null || !center.getWorld().isChunkLoaded(altar.getX() >> 4, altar.getZ() >> 4)) { + return 0; + } + CorruptionConfig.Reach reach = CorruptionConfig.reach(altar.getLevel()); + List rules = CorruptionConfig.rules(altar.getLevel()); + if (rules.isEmpty()) { + return 0; + } + int made = 0; + for (int i = 0; i < count && altar.getCorrupted().size() < reach.maxBlocks(); i++) { + if (changeOne(altar, center, reach.radius(), rules)) { + made++; + } + } + return made; + } + + private static boolean changeOne(BloodAltar altar, Block center, int radius, List rules) { + int height = Math.min(radius, MAX_HEIGHT_REACH); + Block above = center.getRelative(BlockFace.UP); + Set turned = new HashSet<>(); + for (BloodAltar.CorruptedBlock entry : altar.getCorrupted()) { + turned.add(key(entry.x(), entry.y(), entry.z())); + } + Block best = null; + Material bestTo = null; + double bestScore = 0; + for (int attempt = 0; attempt < ATTEMPTS; attempt++) { + Block block = center.getRelative(random.nextInt(2 * radius + 1) - radius, + random.nextInt(2 * height + 1) - height, random.nextInt(2 * radius + 1) - radius); + if (block.equals(center) || block.equals(above) + || !block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4) + || AltarRegions.blocked(block.getLocation())) { + continue; + } + CorruptionConfig.Rule rule = rules.get(random.nextInt(rules.size())); + Material to = fits(rule, block, turned) ? material(rule, block, center) : null; + if (to == null) { + continue; + } + double score = score(block, center, turned); + if (best == null || score > bestScore) { + best = block; + bestTo = to; + bestScore = score; + } + } + if (best == null) { + return false; + } + change(altar, best, bestTo); + return true; + } + + /** Nearer the altar is better, touching what it already turned much better, with a little chance. */ + static double score(Block block, Block center, Set turned) { + double score = -0.6 * Math.sqrt(block.getLocation().distanceSquared(center.getLocation())); + for (BlockFace face : FACES) { + if (turned(turned, block.getRelative(face))) { + score += 4; + break; + } + } + return score + random.nextDouble() * 1.5; + } + + /** What the rule makes of the block; candles only on a spot right in front of a pedestal. */ + static Material material(CorruptionConfig.Rule rule, Block block, Block center) { + List allowed = new ArrayList<>(); + boolean candleSpot = candleSpot(block, center); + for (Material to : rule.to()) { + if (!Tag.CANDLES.isTagged(to) || candleSpot) { + allowed.add(to); + } + } + return allowed.isEmpty() ? null : allowed.get(random.nextInt(allowed.size())); + } + + /** One step from each pedestal toward the altar, on the altar's floor. */ + static boolean candleSpot(Block block, Block center) { + if (block.getY() != center.getY()) { + return false; + } + int dx = block.getX() - center.getX(); + int dz = block.getZ() - center.getZ(); + for (int[] pedestal : AltarPedestals.ring()) { + if (dx == pedestal[0] - Integer.signum(pedestal[0]) && dz == pedestal[1] - Integer.signum(pedestal[1])) { + return true; + } + } + return false; + } + + private static long key(int x, int y, int z) { + return ((long) x & 0x3FFFFFF) << 38 | ((long) z & 0x3FFFFFF) << 12 | (y & 0xFFF); + } + + static boolean fits(CorruptionConfig.Rule rule, Block block) { + return fits(rule, block, null); + } + + /** With turned set, decorations only grow on (or hang from) a block the altar has turned already. */ + static boolean fits(CorruptionConfig.Rule rule, Block block, Set turned) { + return switch (rule.on()) { + case CONVERT -> rule.from().contains(block.getType()) && exposed(block); + case FLOOR -> block.isEmpty() && rests(block.getRelative(BlockFace.DOWN)) + && (turned == null || turned(turned, block.getRelative(BlockFace.DOWN))); + case CEILING -> block.isEmpty() && rests(block.getRelative(BlockFace.UP)) + && (turned == null || turned(turned, block.getRelative(BlockFace.UP))); + }; + } + + private static boolean turned(Set turned, Block block) { + return turned.contains(key(block.getX(), block.getY(), block.getZ())); + } + + /** Solid stone to sit on or hang from; a barrier is furniture (a pedestal) or the altar, never decorated. */ + private static boolean rests(Block block) { + return block.getType().isSolid() && block.getType() != Material.BARRIER; + } + + private static boolean exposed(Block block) { + for (BlockFace face : FACES) { + if (block.getRelative(face).isEmpty()) { + return true; + } + } + return false; + } + + private static void change(BloodAltar altar, Block block, Material to) { + String original = block.getBlockData().getAsString(); + List journal = altar.getCorrupted(); + for (ListIterator it = journal.listIterator(); it.hasNext();) { + BloodAltar.CorruptedBlock entry = it.next(); + if (entry.x() == block.getX() && entry.y() == block.getY() && entry.z() == block.getZ()) { + original = entry.original(); + it.remove(); + break; + } + } + block.setType(to, false); + BlockData data = block.getBlockData(); + if (data instanceof Lightable lightable) { + lightable.setLit(true); + block.setBlockData(lightable, false); + } else if (data instanceof Ageable ageable) { + // Fully grown: vines and the like never spread beyond what the journal can undo. + ageable.setAge(ageable.getMaximumAge()); + block.setBlockData(ageable, false); + } + journal.add(new BloodAltar.CorruptedBlock(block.getX(), block.getY(), block.getZ(), original, to.name())); + BloodFx.play("corruption", block.getLocation().clone().add(0.5, 0.5, 0.5)); + } + + /** Whether an altar changed this block and it still is what the altar made it. */ + public static boolean isCorrupted(Block block) { + String world = block.getWorld().getName(); + for (BloodAltar altar : BloodAltars.all()) { + if (!world.equals(altar.getWorld())) { + continue; + } + for (BloodAltar.CorruptedBlock entry : altar.getCorrupted()) { + if (entry.x() == block.getX() && entry.y() == block.getY() && entry.z() == block.getZ()) { + return block.getType().name().equals(entry.placed()); + } + } + } + return false; + } + + /** Puts back every journalled block that still is what the altar made it, newest first. */ + public static void restore(BloodAltar altar) { + List journal = altar.getCorrupted(); + World world = Bukkit.getWorld(altar.getWorld()); + if (world != null) { + for (int i = journal.size() - 1; i >= 0; i--) { + BloodAltar.CorruptedBlock entry = journal.get(i); + Block block = world.getBlockAt(entry.x(), entry.y(), entry.z()); + if (!block.getType().name().equals(entry.placed())) { + continue; + } + try { + block.setBlockData(Bukkit.createBlockData(entry.original()), false); + } catch (IllegalArgumentException ex) { + block.setType(Material.AIR, false); + } + } + } + journal.clear(); + } + + /** The slow spread between sacrifices: a block (ambient_blocks) per altar every few minutes. */ + static void ambient() { + long now = clock.getAsLong(); + long every = CorruptionConfig.ambientEveryMinutes() * 60_000L; + boolean changed = false; + for (BloodAltar altar : BloodAltars.all()) { + if (now - altar.getLastSpreadAt() < every) { + continue; + } + if (spread(altar, CorruptionConfig.ambientBlocks()) > 0) { + altar.setLastSpreadAt(now); + changed = true; + } + } + if (changed) { + BloodAltars.save(); + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarDisplays.java b/src/main/java/net/tfminecraft/magic/blood/AltarDisplays.java new file mode 100644 index 0000000..69248a5 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AltarDisplays.java @@ -0,0 +1,120 @@ +package net.tfminecraft.magic.blood; + +import java.util.Collection; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Entity; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.util.Transformation; +import org.joml.AxisAngle4f; +import org.joml.Vector3f; + +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.util.ItemRef; + +/** The altar's look: a display entity on its block, swapped as the altar's level rises. */ +public final class AltarDisplays { + + private AltarDisplays() {} + + public static NamespacedKey key() { + return new NamespacedKey(Magic.plugin, "blood_altar"); + } + + /** + * Makes sure a loaded altar has its block and exactly one display showing its level. + * Returns true when the altar's saved fields changed and need writing. + */ + public static boolean ensure(BloodAltar altar) { + Block block = altar.block(); + if (block == null || !block.getWorld().isChunkLoaded(altar.getX() >> 4, altar.getZ() >> 4)) { + return false; + } + if (block.getType() != BloodConfig.altarBlock()) { + block.setType(BloodConfig.altarBlock(), false); + } + // Remembered so purifying clears it even after altar.block changes in the config. + boolean recorded = !BloodConfig.altarBlock().name().equals(altar.getBlockType()); + altar.setBlockType(BloodConfig.altarBlock().name()); + Entity existing = altar.getDisplayId() != null ? Bukkit.getEntity(altar.getDisplayId()) : null; + if (existing instanceof ItemDisplay display) { + display.setItemStack(model(altar.getLevel())); + return recorded; + } + ItemDisplay display = spawn(altar); + altar.setDisplayId(display.getUniqueId()); + return true; + } + + private static ItemDisplay spawn(BloodAltar altar) { + Location at = altar.center(); + at.setYaw(altar.getYaw()); + at.setPitch(0f); + return at.getWorld().spawn(at, ItemDisplay.class, display -> { + display.setItemStack(model(altar.getLevel())); + display.setPersistent(true); + float scale = BloodConfig.displayScale(); + display.setTransformation(new Transformation(new Vector3f(), new AxisAngle4f(), + new Vector3f(scale, scale, scale), new AxisAngle4f())); + display.getPersistentDataContainer().set(key(), PersistentDataType.STRING, altar.getId()); + }); + } + + private static ItemStack model(int level) { + return ItemRef.buildOrFallback(BloodConfig.model(level), Material.CHISELED_STONE_BRICKS); + } + + /** + * Removes tagged displays that belong to no altar, or are not the altar's current display. One whose + * altar is gone (a world that came back from before a purify, after a shutdown that never saved it) + * also takes the altar block and glow left under it, unless another altar stands there. + */ + public static void removeStrays(Collection entities) { + for (Entity entity : entities) { + String id = entity.getPersistentDataContainer().get(key(), PersistentDataType.STRING); + if (id == null) { + continue; + } + BloodAltar altar = BloodAltars.byId(id); + if (altar == null) { + clearLeftovers(entity.getLocation().getBlock()); + } + if (altar == null || !entity.getUniqueId().equals(altar.getDisplayId())) { + entity.remove(); + } + } + } + + private static void clearLeftovers(Block block) { + if (block.getType() != BloodConfig.altarBlock() || BloodAltars.at(block) != null) { + return; + } + block.setType(Material.AIR, false); + Block glow = block.getRelative(BlockFace.UP); + if (glow.getType() == Material.LIGHT) { + glow.setType(Material.AIR, false); + } + } + + /** Takes the altar out of the world: display gone, altar block cleared. */ + public static void remove(BloodAltar altar) { + if (altar.getDisplayId() != null) { + Entity entity = Bukkit.getEntity(altar.getDisplayId()); + if (entity != null) { + entity.remove(); + } + } + Block block = altar.block(); + Material placed = altar.getBlockType() != null ? Material.matchMaterial(altar.getBlockType()) : null; + if (block != null && block.getType() == (placed != null ? placed : BloodConfig.altarBlock())) { + block.setType(Material.AIR, false); + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarPedestals.java b/src/main/java/net/tfminecraft/magic/blood/AltarPedestals.java new file mode 100644 index 0000000..99a7412 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AltarPedestals.java @@ -0,0 +1,295 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.UnaryOperator; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Sound; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Entity; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; + +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.FurnitureType; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; +import net.tfminecraft.interactiblefurniture.furniture.SlotDefinition; +import net.tfminecraft.interactiblefurniture.loaders.FurnitureLoader; +import net.tfminecraft.interactiblefurniture.manager.FurnitureManager; +import net.tfminecraft.interactiblefurniture.manager.handlers.FurniturePlacementHandler; +import net.tfminecraft.interactiblefurniture.manager.handlers.InteractionHandler; +import net.tfminecraft.magic.meditation.MeditationCache; +import net.tfminecraft.magic.util.PedestalFx; + +/** + * The altar sets out its own meditation ring: an InteractibleFurniture pedestal on every free spot + * (open, on solid floor) at the meditation offsets around it. Those pedestals belong to the altar: + * nobody can pick them up, carry them off or break them, and they go when it is purified (whatever + * sits on them drops). Spots that are taken are left for the player's own pedestals. + */ +public final class AltarPedestals { + + /** Pedestals of purified altars that were not loaded then; they go when their chunk loads. */ + private static final Set LEFTOVERS = new LinkedHashSet<>(); + private static BloodStore store; + + private AltarPedestals() {} + + public static void init(BloodStore source) { + LEFTOVERS.clear(); + store = source; + LEFTOVERS.addAll(source.loadLeftovers()); + } + + public static void clear() { + LEFTOVERS.clear(); + store = null; + } + + static Set leftovers() { + return Set.copyOf(LEFTOVERS); + } + + /** The ring's offsets, the same as the meditation circle's. */ + static int[][] ring() { + int card = MeditationCache.cardinalOffset; + int diag = MeditationCache.diagonalOffset; + return new int[][] { + {card, 0}, {-card, 0}, {0, card}, {0, -card}, + {diag, diag}, {diag, -diag}, {-diag, diag}, {-diag, -diag} + }; + } + + /** IF's yaw for a piece placed by someone at the altar looking out toward it (north 0, east 270). */ + static float yaw(int dx, int dz) { + double bearing = Math.toDegrees(Math.atan2(dx, -dz)); + return (float) ((360 - Math.round(bearing)) % 360 + 360) % 360; + } + + /** InteractibleFurniture's manager, or null while the plugin is not running. */ + static FurnitureManager manager() { + Plugin loaded = Bukkit.getPluginManager().getPlugin("InteractibleFurniture"); + if (loaded == null || !loaded.isEnabled()) { + return null; + } + InteractibleFurniture plugin = InteractibleFurniture.getInstance(); + return plugin != null ? plugin.getFurnitureManager() : null; + } + + /** Sets out pedestals around the altar and remembers them; returns how many were placed. */ + public static int place(BloodAltar altar, Block center) { + FurnitureType type = FurnitureLoader.getByString(MeditationCache.pedestalId); + FurnitureManager manager = type != null ? manager() : null; + if (manager == null) { + return 0; + } + Map placed = manager.getPlacedFurniture(); + int count = 0; + for (int[] offset : ring()) { + Block spot = center.getRelative(offset[0], 0, offset[1]); + Block floor = spot.getRelative(BlockFace.DOWN); + if (!spot.isEmpty() || !floor.getType().isSolid()) { + continue; + } + float yaw = yaw(offset[0], offset[1]); + Location at = spot.getLocation().add(0.5, 0, 0.5); + ItemDisplay display = FurniturePlacementHandler.spawnDisplayEntity(type, at, yaw, BlockFace.UP); + if (display == null) { + continue; + } + Furniture furniture = new Furniture(type.getId(), display.getLocation(), display.getUniqueId(), + floor.getLocation(), BlockFace.UP); + furniture.setYaw(yaw); + FurniturePlacementHandler.placeBarrierBlocks(type.getLayers(), furniture, floor, BlockFace.UP); + placed.put(display.getUniqueId(), furniture); + InteractionHandler.spawnInteraction(furniture); + manager.persistFurniture(furniture); + altar.getPedestals().add(display.getUniqueId().toString()); + spot.getWorld().playSound(at, Sound.BLOCK_ANCIENT_DEBRIS_PLACE, 0.8f, 0.7f); + count++; + } + return count; + } + + /** + * Sets the item on one of the altar's loaded pedestals: the one showing the artefact with the + * replaced id (which goes), else the first empty one. False when none is loaded and free. + */ + public static boolean offer(BloodAltar altar, ItemStack item, String replaced) { + FurnitureManager manager = manager(); + if (manager == null || item == null) { + return false; + } + Map placed = manager.getPlacedFurniture(); + Furniture chosen = null; + for (String id : altar.getPedestals()) { + Furniture furniture = find(placed, id); + SlotDefinition slot = furniture != null ? itemSlot(furniture) : null; + if (slot == null || !(Bukkit.getEntity(furniture.getEntityId()) instanceof ItemDisplay)) { + continue; + } + Optional active = furniture.getActiveSlot(slot.getId()); + if (active.isEmpty()) { + chosen = chosen != null ? chosen : furniture; + } else if (replaced != null && replaced.equals(BloodArtefacts.id(shown(active.get())))) { + furniture.removeActiveSlot(slot.getId()); + chosen = furniture; + break; + } + } + if (chosen == null) { + return false; + } + SlotDefinition slot = itemSlot(chosen); + ItemDisplay display = (ItemDisplay) Bukkit.getEntity(chosen.getEntityId()); + chosen.getOrCreatePlacedSlot(slot.getId()).spawnDisplayStand(chosen.getLoc(), item, display, null); + manager.persistFurniture(chosen); + return true; + } + + /** A blood artefact on one of the altar's loaded pedestals, and the point above it. */ + public record Shown(Furniture furniture, PlacedSlot slot, ItemStack item, Location point) {} + + /** The blood artefacts on the altar's loaded pedestals. */ + static List artefacts(BloodAltar altar) { + FurnitureManager manager = manager(); + if (manager == null) { + return List.of(); + } + Map placed = manager.getPlacedFurniture(); + List out = new ArrayList<>(); + for (String id : altar.getPedestals()) { + Furniture furniture = find(placed, id); + SlotDefinition slot = furniture != null ? itemSlot(furniture) : null; + PlacedSlot active = slot != null ? furniture.getActiveSlot(slot.getId()).orElse(null) : null; + ItemStack item = active != null ? shown(active) : null; + Location point = BloodArtefacts.is(item) ? PedestalFx.artifactPoint(furniture) : null; + if (point != null) { + out.add(new Shown(furniture, active, item, point)); + } + } + return out; + } + + /** Shows each artefact on the altar's pedestals restyled (a charged one glows). */ + static void restyle(BloodAltar altar, UnaryOperator style) { + FurnitureManager manager = manager(); + for (Shown shown : artefacts(altar)) { + shown.slot().setCurrentItem(style.apply(shown.item().clone())); + manager.persistFurniture(shown.furniture()); + } + } + + /** The pedestal's first slot for an item (not for nested furniture), or null. */ + private static SlotDefinition itemSlot(Furniture furniture) { + FurnitureType type = furniture.getType(); + if (type == null) { + return null; + } + for (SlotDefinition slot : type.getSlots().values()) { + if (!slot.isFurnitureSlot()) { + return slot; + } + } + return null; + } + + /** What a slot shows: its item, or the item on its display stand. */ + private static ItemStack shown(PlacedSlot slot) { + if (slot.getCurrentItem() != null || slot.getDisplayStandId() == null) { + return slot.getCurrentItem(); + } + Entity stand = Bukkit.getEntity(slot.getDisplayStandId()); + return stand instanceof ItemDisplay display ? display.getItemStack() : null; + } + + /** + * Takes the altar's pedestals away; whatever sits on them drops. Pedestals that are not loaded + * stay bound and are removed when their chunk loads (see {@link #sweep()}). + */ + public static void remove(BloodAltar altar) { + FurnitureManager manager = manager(); + Map placed = manager != null ? manager.getPlacedFurniture() : Map.of(); + boolean left = false; + for (String id : List.copyOf(altar.getPedestals())) { + Furniture furniture = find(placed, id); + if (furniture != null) { + manager.removePlugin(furniture); + } else { + left |= LEFTOVERS.add(id); + } + } + altar.getPedestals().clear(); + if (left) { + save(); + } + } + + /** Removes leftover pedestals that have loaded since their altar was purified. */ + public static void sweep() { + FurnitureManager manager = LEFTOVERS.isEmpty() ? null : manager(); + if (manager == null) { + return; + } + Map placed = manager.getPlacedFurniture(); + boolean changed = false; + for (String id : List.copyOf(LEFTOVERS)) { + Furniture furniture = find(placed, id); + if (furniture != null) { + manager.removePlugin(furniture); + } + if (furniture != null || !id.matches("[0-9a-fA-F-]{36}")) { + LEFTOVERS.remove(id); + changed = true; + } + } + if (changed) { + save(); + } + } + + private static Furniture find(Map placed, String id) { + try { + return placed.get(UUID.fromString(id)); + } catch (IllegalArgumentException ex) { + return null; + } + } + + private static void save() { + if (store != null) { + store.saveLeftovers(LEFTOVERS); + } + } + + /** The altar's own pedestals, and those of purified altars still waiting to go, are not for taking. */ + public static boolean bound(Furniture furniture) { + return owner(furniture) != null + || (furniture != null && furniture.getEntityId() != null + && LEFTOVERS.contains(furniture.getEntityId().toString())); + } + + /** The altar a piece of furniture belongs to, or null. */ + public static BloodAltar owner(Furniture furniture) { + if (furniture == null || furniture.getEntityId() == null) { + return null; + } + String id = furniture.getEntityId().toString(); + for (BloodAltar altar : new ArrayList<>(BloodAltars.all())) { + if (altar.getPedestals().contains(id)) { + return altar; + } + } + return null; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarPlacement.java b/src/main/java/net/tfminecraft/magic/blood/AltarPlacement.java new file mode 100644 index 0000000..8991b34 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AltarPlacement.java @@ -0,0 +1,64 @@ +package net.tfminecraft.magic.blood; + +import org.bukkit.Tag; +import org.bukkit.block.Block; + +/** Where an altar may stand: free space on a solid floor, deep enough underground, outside regions. */ +public final class AltarPlacement { + + public enum Result { + OK, + WORLD, + OCCUPIED, + NO_FLOOR, + SKY, + SHALLOW, + REGION + } + + private AltarPlacement() {} + + public static Result check(Block target) { + if (!BloodConfig.worldAllowed(target.getWorld().getName())) { + return Result.WORLD; + } + if (!replaceable(target)) { + return Result.OCCUPIED; + } + if (BloodAltars.at(target) != null) { + return Result.OCCUPIED; + } + if (!target.getRelative(0, -1, 0).getType().isSolid()) { + return Result.NO_FLOOR; + } + if (BloodConfig.requireUnderground()) { + if (target.getLightFromSky() > 0) { + return Result.SKY; + } + if (cover(target) < BloodConfig.minCover()) { + return Result.SHALLOW; + } + } + if (AltarRegions.blocked(target.getLocation())) { + return Result.REGION; + } + return Result.OK; + } + + /** Air, or something placing a block would replace, like grass or snow layers. */ + public static boolean replaceable(Block block) { + return block.isEmpty() || Tag.REPLACEABLE.isTagged(block.getType()); + } + + /** Solid, light-blocking blocks above the target, up to the scan height or the world top. */ + static int cover(Block target) { + int top = Math.min(target.getWorld().getMaxHeight() - 1, target.getY() + BloodConfig.coverScanHeight()); + int count = 0; + for (int y = target.getY() + 1; y <= top; y++) { + if (target.getWorld().getBlockAt(target.getX(), y, target.getZ()).getType().isOccluding()) { + count++; + } + } + return count; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarRegions.java b/src/main/java/net/tfminecraft/magic/blood/AltarRegions.java new file mode 100644 index 0000000..ea1427d --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AltarRegions.java @@ -0,0 +1,44 @@ +package net.tfminecraft.magic.blood; + +import java.util.List; +import java.util.function.Function; + +import org.bukkit.Bukkit; +import org.bukkit.Location; + +/** Region lookup for altar placement. WorldGuard types stay out of this class. */ +public final class AltarRegions { + + private static Function> query; + + private AltarRegions() {} + + /** Replaces the lookup; null goes back to WorldGuard when it is enabled. */ + static void setQuery(Function> replacement) { + query = replacement; + } + + /** Ids of the regions covering the location; empty without WorldGuard. */ + public static List at(Location location) { + if (query == null) { + if (!Bukkit.getPluginManager().isPluginEnabled("WorldGuard")) { + return List.of(); + } + query = WorldGuardAltarRegions::regionsAt; + } + return query.apply(location); + } + + /** True when a region the config does not allow covers the location. */ + public static boolean blocked(Location location) { + if (!BloodConfig.denyRegions()) { + return false; + } + for (String id : at(location)) { + if (!BloodConfig.regionAllowed(id)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarRoom.java b/src/main/java/net/tfminecraft/magic/blood/AltarRoom.java new file mode 100644 index 0000000..4304936 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AltarRoom.java @@ -0,0 +1,254 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.TileState; +import org.bukkit.block.data.Levelled; +import org.bukkit.block.data.type.Candle; + +import net.tfminecraft.magic.meditation.MeditationCache; + +/** + * The altar is also a meditation circle, sat on in the middle of the eight pedestals (meditation + * cardinal and diagonal offsets). Placing it lays its chamber: open floor out to the farthest pedestal + * on every side, placement.room.height blocks high, with the floor, walls and ceiling relaid in the + * room template so every chamber starts the same, and hidden light in the top corners. Openings in + * the walls and ceiling (the way in) stay open. Too much rock, something that must not move, water or + * lava in or against the room, claimed ground or opening the rock to the sky refuses the stone instead. + * Afterwards the chamber, out to the corruption's reach, is locked: only staff may change its blocks. + */ +public final class AltarRoom { + + private static final BlockFace[] FACES = { + BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST + }; + private static final int LOCK_BELOW = 3; + + public enum Verdict { + OK, + TOO_MUCH, + BLOCKED, + SHALLOW, + /** No way out: the chamber is locked once laid, so it needs a doorway two blocks high in a wall. */ + SEALED + } + + /** + * What placing the altar would change: blocks carved to air, blocks laid, hidden lights in the top + * corners, lit candles in the floor corners, and the glow just above the altar (or null). + */ + public record Plan(Verdict verdict, List carve, Map lay, List lights, + List candles, Block glow) { + static Plan refused(Verdict verdict) { + return new Plan(verdict, List.of(), Map.of(), List.of(), List.of(), null); + } + + public void apply() { + for (Block block : carve) { + block.setType(Material.AIR, false); + } + for (Map.Entry entry : lay.entrySet()) { + entry.getKey().setType(entry.getValue(), false); + } + for (Block block : lights) { + light(block, RoomTemplate.light()); + } + for (Block block : candles) { + block.setType(RoomTemplate.candle(), false); + Candle data = (Candle) block.getBlockData(); + data.setCandles(RoomTemplate.candles()); + data.setLit(true); + block.setBlockData(data, false); + } + if (glow != null) { + light(glow, RoomTemplate.altarLight()); + } + } + + private static void light(Block block, int level) { + block.setType(Material.LIGHT, false); + Levelled data = (Levelled) block.getBlockData(); + data.setLevel(level); + block.setBlockData(data, false); + } + } + + private AltarRoom() {} + + /** Blocks from the altar to the room's edge: the farthest pedestal. */ + public static int halfWidth() { + return Math.max(MeditationCache.cardinalOffset, MeditationCache.diagonalOffset); + } + + /** The room's side, altar in the middle. */ + public static int size() { + return halfWidth() * 2 + 1; + } + + /** + * The plan for an altar at target. Floor holes take the template floor, or without one the stone + * the player stands on (else the altar's floor). + */ + public static Plan plan(Block target, Block standingOn) { + if (!BloodConfig.roomEnabled()) { + return new Plan(Verdict.OK, List.of(), Map.of(), List.of(), List.of(), null); + } + int half = halfWidth(); + int height = BloodConfig.roomHeight(); + World world = target.getWorld(); + Random random = new Random(target.getX() * 341873128712L + target.getY() * 132897987541L + target.getZ()); + Material holes = RoomTemplate.hasFloor() ? null : floorOf(standingOn, target.getRelative(0, -1, 0)); + List carve = new ArrayList<>(); + Map lay = new LinkedHashMap<>(); + List lights = new ArrayList<>(); + List candles = new ArrayList<>(); + int filled = 0; + boolean doorway = false; + for (int dx = -half - 1; dx <= half + 1; dx++) { + for (int dz = -half - 1; dz <= half + 1; dz++) { + boolean inside = Math.abs(dx) <= half && Math.abs(dz) <= half; + int x = target.getX() + dx; + int z = target.getZ() + dz; + if (AltarRegions.blocked(world.getBlockAt(x, target.getY() - 1, z).getLocation())) { + return Plan.refused(Verdict.BLOCKED); + } + for (int dy = -1; dy <= height; dy++) { + Block block = world.getBlockAt(x, target.getY() + dy, z); + if (block.equals(target)) { + continue; + } + if (inside && dy >= 0 && dy < height) { + if (!block.isEmpty()) { + if (!movable(block)) { + return Plan.refused(Verdict.BLOCKED); + } + carve.add(block); + } + boolean corner = Math.abs(dx) == half && Math.abs(dz) == half; + if (corner && RoomTemplate.light() > 0 && dy == height - 1) { + lights.add(block); + } + if (corner && RoomTemplate.candle() != null && dy == 0) { + candles.add(block); + } + continue; + } + // The shell: floor, ceiling and the wall ring around the room. + if (block.isLiquid() || BloodAltars.at(block) != null || AltarCorruption.isCorrupted(block)) { + return Plan.refused(Verdict.BLOCKED); + } + boolean floor = inside && dy == -1; + if (!block.getType().isSolid()) { + if (floor) { + if (!movable(block)) { + return Plan.refused(Verdict.BLOCKED); + } + lay.put(block, holes != null ? holes : RoomTemplate.floor(random)); + filled++; + } else if (touchesLiquid(block)) { + // An opening left in the wall or ceiling must not let water or lava in. + return Plan.refused(Verdict.BLOCKED); + } else if (dy == 0 && Math.abs(dx) != Math.abs(dz) + && !block.getRelative(0, 1, 0).getType().isSolid()) { + doorway = true; + } + continue; + } + Material laid = floor ? RoomTemplate.floor(random) + : inside ? RoomTemplate.ceiling(random) : RoomTemplate.wall(random); + if (laid != null && movable(block)) { + lay.put(block, laid); + } + } + if (inside && BloodConfig.requireUnderground() + && cover(world, x, target.getY() + height - 1, z) < BloodConfig.minCover()) { + return Plan.refused(Verdict.SHALLOW); + } + } + } + if (carve.size() + filled > BloodConfig.roomMaxChanges()) { + return Plan.refused(Verdict.TOO_MUCH); + } + if (!doorway) { + return Plan.refused(Verdict.SEALED); + } + Block glow = RoomTemplate.altarLight() > 0 ? target.getRelative(BlockFace.UP) : null; + return new Plan(Verdict.OK, List.copyOf(carve), Collections.unmodifiableMap(lay), List.copyOf(lights), + List.copyOf(candles), glow); + } + + /** The four outer corners of the chamber at the altar's height, for the claim check. */ + public static List corners(Block target) { + int edge = halfWidth() + 1; + return List.of(target.getRelative(edge, 0, edge), target.getRelative(edge, 0, -edge), + target.getRelative(-edge, 0, edge), target.getRelative(-edge, 0, -edge)); + } + + static boolean touchesLiquid(Block block) { + for (BlockFace face : FACES) { + if (block.getRelative(face).isLiquid()) { + return true; + } + } + return false; + } + + /** How far from an altar its chamber is locked: the room's walls, or the corruption's reach if further. */ + static int lockRadius() { + return Math.max(halfWidth() + 1, CorruptionConfig.maxRadius()); + } + + /** The altar whose locked chamber holds this block, or null. */ + public static BloodAltar lockedBy(Block block) { + int radius = lockRadius(); + int above = Math.max(BloodConfig.roomHeight(), LOCK_BELOW); + String world = block.getWorld().getName(); + for (BloodAltar altar : BloodAltars.all()) { + if (world.equals(altar.getWorld()) + && Math.abs(block.getX() - altar.getX()) <= radius && Math.abs(block.getZ() - altar.getZ()) <= radius + && block.getY() - altar.getY() >= -LOCK_BELOW && block.getY() - altar.getY() <= above) { + return altar; + } + } + return null; + } + + /** Neither liquid, unbreakable, a container or other block entity, an altar nor what one changed. */ + static boolean movable(Block block) { + if (block.isLiquid() || block.getType().getHardness() < 0 || block.getState() instanceof TileState) { + return false; + } + return BloodAltars.at(block) == null && !AltarCorruption.isCorrupted(block); + } + + /** The first of these that makes a plain floor: solid, light-blocking, breakable, no block entity. */ + static Material floorOf(Block... candidates) { + for (Block candidate : candidates) { + if (candidate != null && candidate.getType().isOccluding() && movable(candidate)) { + return candidate.getType(); + } + } + return Material.STONE; + } + + /** Light-blocking blocks above the room's top in this column, up to the scan height. */ + static int cover(World world, int x, int top, int z) { + int highest = Math.min(world.getMaxHeight() - 1, top + BloodConfig.coverScanHeight()); + int count = 0; + for (int y = top + 1; y <= highest; y++) { + if (world.getBlockAt(x, y, z).getType().isOccluding()) { + count++; + } + } + return count; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarService.java b/src/main/java/net/tfminecraft/magic/blood/AltarService.java new file mode 100644 index 0000000..9c8a242 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -0,0 +1,230 @@ +package net.tfminecraft.magic.blood; + +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.Bukkit; +import org.bukkit.block.BlockFace; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.inventory.EquipmentSlot; + +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.Messages; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; + +/** Placing, refreshing and purifying altars. */ +public final class AltarService { + + private AltarService() {} + + /** The id of the player's current character, or null without one. */ + public static String currentCharacter(Player player) { + CharacterSession session = Characters.currentSession(player).orElse(null); + return session != null ? session.ref().characterId().toString() : null; + } + + /** + * Tries to set the altar item in the player's hand down on the target block. + * Returns the message path that tells the player the outcome. + */ + public static String place(Player player, ItemStack hand, Block target) { + if (!BloodConfig.isEnabled()) { + return "blood.altar.inert"; + } + String character = currentCharacter(player); + if (character == null || !character.equalsIgnoreCase(BloodItems.owner(hand))) { + return "blood.altar.not_yours"; + } + BloodRecord record = BloodRecords.get(character); + if (record == null || !record.getStage().atLeast(BloodStage.ANIMAL)) { + return "blood.altar.inert"; + } + if (BloodAltars.ofCharacter(character) != null) { + return "blood.altar.already"; + } + AltarPlacement.Result result = AltarPlacement.check(target); + if (result != AltarPlacement.Result.OK) { + return "blood.altar.place." + result.name().toLowerCase(Locale.ROOT); + } + // Claims, factions and other protections get their say, and loggers see the altar block placed. + BlockPlaceEvent probe = new BlockPlaceEvent(target, target.getState(), target.getRelative(BlockFace.DOWN), + new ItemStack(BloodConfig.altarBlock()), player, true, EquipmentSlot.HAND); + Bukkit.getPluginManager().callEvent(probe); + if (probe.isCancelled() || !probe.canBuild()) { + return "blood.altar.place.region"; + } + // The altar is the middle of a meditation circle: its room is shaped to hold the pedestals. + AltarRoom.Plan room = AltarRoom.plan(target, player.getLocation().getBlock().getRelative(BlockFace.DOWN)); + if (room.verdict() != AltarRoom.Verdict.OK) { + return "blood.altar.place.room_" + room.verdict().name().toLowerCase(Locale.ROOT); + } + // The chamber reaches past the altar's block: claims and installations at its corners get their say too. + for (Block corner : AltarRoom.corners(target)) { + BlockPlaceEvent edge = new BlockPlaceEvent(corner, corner.getState(), corner.getRelative(BlockFace.DOWN), + new ItemStack(Material.STONE), player, true, EquipmentSlot.HAND); + Bukkit.getPluginManager().callEvent(edge); + if (edge.isCancelled() || !edge.canBuild()) { + return "blood.altar.place.region"; + } + } + room.apply(); + BloodAltar altar = BloodAltar.create(character, player.getUniqueId().toString(), target, + player.getLocation().getYaw() + 180f, System.currentTimeMillis()); + altar.setLevel(BloodProgress.level(record)); + AltarDisplays.ensure(altar); + AltarPedestals.place(altar, target); + BloodAltars.add(altar); + record.setAltarId(altar.getId()); + BloodRecords.save(record); + hand.setAmount(hand.getAmount() - 1); + BloodFx.play("altar_placed", altar.center(), player); + Magic.plugin.getLogger().info("[Magic] Blood altar " + altar.getId() + " placed by character " + + character + " at " + altar.blockKey()); + return "blood.altar.placed"; + } + + /** Re-reads the owner's progress into the altar's level and look. */ + public static void refresh(BloodAltar altar) { + int level = BloodProgress.level(BloodRecords.get(altar.getOwnerCharacter())); + boolean changed = level != altar.getLevel(); + if (level > altar.getLevel()) { + BloodFx.play("altar_awakens", altar.center()); + } + altar.setLevel(level); + changed |= AltarDisplays.ensure(altar); + if (changed) { + BloodAltars.save(); + } + } + + /** Refreshes the character's altar, if it has one. */ + public static void refreshFor(String characterId) { + BloodAltar altar = BloodAltars.ofCharacter(characterId); + if (altar != null) { + refresh(altar); + } + } + + /** Removes the altar from the world and from its owner's record. */ + public static void purify(BloodAltar altar, String reason) { + BloodFx.play("altar_purified", altar.center()); + AltarCorruption.restore(altar); + AltarDisplays.remove(altar); + AltarPedestals.remove(altar); + // The glow above the altar goes with it. + Block stone = altar.block(); + if (stone != null && stone.getRelative(BlockFace.UP).getType() == Material.LIGHT) { + stone.getRelative(BlockFace.UP).setType(Material.AIR, false); + } + BloodAltars.remove(altar); + BloodRecord record = BloodRecords.get(altar.getOwnerCharacter()); + if (record != null && altar.getId().equals(record.getAltarId())) { + record.setAltarId(null); + BloodRecords.save(record); + } + Magic.plugin.getLogger().info("[Magic] Blood altar " + altar.getId() + " of character " + + altar.getOwnerCharacter() + " at " + altar.blockKey() + " purified (" + reason + ")"); + } + + /** A dead character keeps nothing: the altar purifies itself and the path is forgotten. */ + public static void onCharacterDeath(String characterId) { + BloodAltar altar = BloodAltars.ofCharacter(characterId); + if (altar != null) { + purify(altar, "owner died"); + } + if (BloodRecords.get(characterId) != null) { + BloodRecords.delete(characterId); + Magic.plugin.getLogger().info("[Magic] Blood path of dead character " + characterId + " cleared"); + } + } + + /** Catches deaths missed while Magic was not listening, e.g. during a restart. */ + public static void sweepDead() { + if (!Characters.available()) { + return; + } + for (BloodAltar altar : BloodAltars.all()) { + if (isDead(altar.getOwnerCharacter())) { + onCharacterDeath(altar.getOwnerCharacter()); + } + } + for (BloodRecord record : BloodRecords.all()) { + if (isDead(record.getCharacterId())) { + onCharacterDeath(record.getCharacterId()); + } + } + } + + static boolean isDead(String characterId) { + if (characterId == null) { + return false; + } + UUID id; + try { + id = UUID.fromString(characterId); + } catch (IllegalArgumentException ex) { + return false; + } + Optional info = Characters.info(id); + return info.isPresent() && info.get().status() == CharacterStatus.DEAD; + } + + /** Tells a player who touched an altar what it feels like: to its owner, as far as they have come. */ + public static void touch(Player player, BloodAltar altar) { + String character = currentCharacter(player); + boolean owner = character != null && character.equalsIgnoreCase(altar.getOwnerCharacter()); + BloodFx.play(owner ? "altar_touch_owner" : "altar_touch_stranger", altar.center(), player); + BloodRecord record = owner ? BloodRecords.get(character) : null; + String key = stageKey(record); + if (record != null && record.getStage() == BloodStage.ASCENDED) { + key += "_" + BloodPool.state(record); + } + WhisperService.tell(player, owner ? "blood.altar.touch." + key : "blood.altar.stranger_touch"); + } + + /** The stage (and, once chosen, the path) a line is written for: animal, choice, hunt, final_true... */ + static String stageKey(BloodRecord record) { + if (record == null || !record.getStage().atLeast(BloodStage.CHOICE)) { + return "animal"; + } + return switch (record.getStage()) { + case CHOICE -> "choice"; + case HUNT -> "hunt"; + default -> record.getStage().name().toLowerCase(Locale.ROOT) + "_" + + (record.getPath() == BloodPath.DEVOTEE ? "devotee" : "true"); + }; + } + + /** + * A player sat down (GSit's /sit or any other seat) on top of an altar: the middle of its + * meditation circle. The stone answers its owner and chills anyone else. + */ + public static void sat(Player player, Entity seat) { + Block block = seat.getLocation().getBlock(); + BloodAltar altar = BloodAltars.at(block); + if (altar == null) { + altar = BloodAltars.at(block.getRelative(0, -1, 0)); + } + if (altar == null) { + return; + } + String character = currentCharacter(player); + boolean owner = character != null && character.equalsIgnoreCase(altar.getOwnerCharacter()); + BloodRecord record = owner ? BloodRecords.get(character) : null; + WhisperService.tell(player, owner ? "blood.altar.sit." + stageKey(record) : "blood.altar.sit_stranger"); + BloodFx.play(owner ? "altar_sit" : "altar_touch_stranger", seat.getLocation(), player); + if (record != null && !BloodArtefacts.ids(record).isEmpty()) { + WhisperService.tell(player, "blood.meditate.start." + + BloodMeditation.start(player, altar).name().toLowerCase(Locale.ROOT)); + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java b/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java new file mode 100644 index 0000000..462f532 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java @@ -0,0 +1,162 @@ +package net.tfminecraft.magic.blood; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.logging.Logger; + +import org.bukkit.SoundCategory; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.EntityType; + +/** The animal: section of the wildmagic/bloodmagic config. */ +public final class AnimalConfig { + + private static int speciesRequired; + private static long cooldownMillis; + private static double bindRange; + private static int bindSeconds; + private static boolean requireStolen; + private static boolean clearDrops; + private static Set species; + private static String traceModel; + private static long traceMillis; + private static long noteDelayMillis; + private static int bleedSeconds; + private static double stayRange; + private static Set heartbeats; + private static String music; + private static SoundCategory musicCategory; + private static float musicVolume; + + static { + reset(); + } + + private AnimalConfig() {} + + static void reset() { + speciesRequired = 7; + cooldownMillis = 24L * 3_600_000L; + bindRange = 4; + bindSeconds = 20; + requireStolen = true; + clearDrops = true; + species = Set.of(); + traceModel = "v.redstone"; + traceMillis = 6L * 3_600_000L; + noteDelayMillis = 30L * 60_000L; + bleedSeconds = 15; + stayRange = 4; + heartbeats = Set.of(0, 1, 2, 3, 5, 7, 10, 13); + music = ""; + musicCategory = SoundCategory.RECORDS; + musicVolume = 0.8f; + } + + static void load(ConfigurationSection section, Logger log) { + reset(); + if (section == null) { + return; + } + speciesRequired = Math.max(1, section.getInt("species_required", 7)); + cooldownMillis = (long) (Math.max(0, section.getDouble("cooldown_hours", 24)) * 3_600_000L); + bindRange = Math.max(1, section.getDouble("bind_range", 4)); + bindSeconds = Math.max(1, section.getInt("bind_seconds", 20)); + requireStolen = section.getBoolean("require_stolen", true); + clearDrops = section.getBoolean("clear_drops", true); + Set types = new HashSet<>(); + for (String raw : section.getStringList("species")) { + try { + types.add(EntityType.valueOf(raw.trim().toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException ex) { + log.warning("[Magic] blood magic config animal.species: unknown entity '" + raw + "'"); + } + } + species = Set.copyOf(types); + traceModel = section.getString("trace.model", "v.redstone"); + traceMillis = (long) (Math.max(0, section.getDouble("trace.hours", 6)) * 3_600_000L); + noteDelayMillis = (long) (Math.max(0, section.getDouble("note_delay_minutes", 30)) * 60_000L); + bleedSeconds = Math.max(1, section.getInt("rite.bleed_seconds", 15)); + stayRange = Math.max(1, section.getDouble("rite.stay_range", 4)); + if (section.isList("rite.heartbeats")) { + heartbeats = Set.copyOf(section.getIntegerList("rite.heartbeats")); + } + music = section.getString("rite.music.sound", "").trim(); + musicVolume = (float) Math.max(0, section.getDouble("rite.music.volume", 0.8)); + String category = section.getString("rite.music.category", "RECORDS"); + try { + musicCategory = SoundCategory.valueOf(category.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + log.warning("[Magic] blood magic config animal.rite.music.category: unknown '" + category + "'"); + } + } + + /** The music played to the mage alone while the animal bleeds; blank for none. */ + public static String music() { + return music; + } + + public static SoundCategory musicCategory() { + return musicCategory; + } + + public static float musicVolume() { + return musicVolume; + } + + /** Seconds a cut animal bleeds out on the stone before the sacrifice counts. */ + public static int bleedSeconds() { + return bleedSeconds; + } + + /** How far from the altar the mage may stand while it bleeds; further spills it. */ + public static double stayRange() { + return stayRange; + } + + /** Seconds into the bleeding when its heart beats, slowing toward the end. */ + public static Set heartbeats() { + return heartbeats; + } + + public static int speciesRequired() { + return speciesRequired; + } + + public static long cooldownMillis() { + return cooldownMillis; + } + + public static double bindRange() { + return bindRange; + } + + public static int bindSeconds() { + return bindSeconds; + } + + public static boolean requireStolen() { + return requireStolen; + } + + public static boolean clearDrops() { + return clearDrops; + } + + public static boolean isSpecies(EntityType type) { + return species.contains(type); + } + + public static String traceModel() { + return traceModel; + } + + public static long traceMillis() { + return traceMillis; + } + + public static long noteDelayMillis() { + return noteDelayMillis; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java new file mode 100644 index 0000000..41ed762 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java @@ -0,0 +1,364 @@ +package net.tfminecraft.magic.blood; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.function.LongSupplier; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.inventory.ItemStack; +import java.util.Collection; +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.Messages; + +/** + * Animal sacrifices. The mage binds a living animal onto their altar with their ritual knife; it hangs + * still on the stone until the same knife cuts it or it breaks free. The cut does not kill: the animal + * bleeds out on the stone (animal.rite.bleed_seconds) while the mage stays close, its heart slowing, + * and only then does the stone drink and the sacrifice count. Stepping away, switching character or + * leaving spills it, wasted. Seven different kinds, one per rolling day, taken from other keepers, + * finish the stage. + */ +public final class AnimalRiteService { + + public enum Bind { + OK, + NOT_STAGE, + NOT_KNIFE, + NO_ALTAR, + NOT_SPECIES, + KNOWN, + COOLDOWN, + NOT_STOLEN, + TOO_FAR, + BUSY + } + + /** A binding: whose, onto which altar, until when; cutAt is when it started to bleed (0 before). */ + private record Bound(UUID mage, String character, String altarId, long expiresAt, + List owners, boolean stolen, long cutAt, boolean draining) { + Bound cut(long at) { + return new Bound(mage, character, altarId, expiresAt, owners, stolen, at, false); + } + + Bound halfway() { + return new Bound(mage, character, altarId, expiresAt, owners, stolen, cutAt, true); + } + } + + private static final Map BOUND = new HashMap<>(); + static LongSupplier clock = System::currentTimeMillis; + + private AnimalRiteService() {} + + /** Tries to bind the animal onto the player's altar. */ + public static Bind bind(Player player, LivingEntity animal) { + String character = AltarService.currentCharacter(player); + BloodRecord record = BloodRecords.get(character); + if (!BloodConfig.isEnabled() || record == null || record.getStage() != BloodStage.ANIMAL) { + return Bind.NOT_STAGE; + } + ItemStack knife = player.getInventory().getItemInMainHand(); + if (!BloodItems.is(knife, BloodItemKind.RITUAL_KNIFE) || !character.equalsIgnoreCase(BloodItems.owner(knife))) { + return Bind.NOT_KNIFE; + } + BloodAltar altar = BloodAltars.ofCharacter(character); + Location top = altar != null ? altar.center() : null; + if (top == null) { + return Bind.NO_ALTAR; + } + if (!AnimalConfig.isSpecies(animal.getType())) { + return Bind.NOT_SPECIES; + } + if (record.hasSpecies(speciesKey(animal))) { + return Bind.KNOWN; + } + long now = clock.getAsLong(); + if (record.getLastAnimalAt() > 0 && now - record.getLastAnimalAt() < AnimalConfig.cooldownMillis()) { + return Bind.COOLDOWN; + } + List owners = HusbandryOwners.of(animal.getUniqueId()); + boolean stolen = !owners.isEmpty() + && owners.stream().noneMatch(owner -> owner.account().equals(player.getUniqueId())); + if (AnimalConfig.requireStolen() && !stolen) { + return Bind.NOT_STOLEN; + } + if (animal.getWorld() != top.getWorld() + || animal.getLocation().distance(top) > AnimalConfig.bindRange()) { + return Bind.TOO_FAR; + } + if (bindingOf(player.getUniqueId()) != null || isBound(animal.getUniqueId())) { + return Bind.BUSY; + } + // A lead comes off as it goes onto the stone, back to the hand that held it. + if (animal.isLeashed()) { + animal.setLeashHolder(null); + WhisperService.give(player, new ItemStack(Material.LEAD)); + } + if (!animal.teleport(top.clone().add(0, 0.5, 0))) { + return Bind.TOO_FAR; + } + long expires = now + AnimalConfig.bindSeconds() * 1000L; + animal.setAI(false); + // Saved on the animal so an unload, crash or restart can never leave it frozen for good. + animal.getPersistentDataContainer().set(boundKey(), PersistentDataType.LONG, expires); + BloodFx.play("animal_bound", top, player); + BOUND.put(animal.getUniqueId(), new Bound(player.getUniqueId(), character, altar.getId(), + expires, owners, stolen, 0, false)); + return Bind.OK; + } + + static String speciesKey(Entity animal) { + return animal.getType().name().toLowerCase(Locale.ROOT); + } + + /** The animal this mage holds on their altar right now, if any. */ + static UUID bindingOf(UUID mage) { + for (Map.Entry entry : BOUND.entrySet()) { + if (entry.getValue().mage().equals(mage)) { + return entry.getKey(); + } + } + return null; + } + + public static boolean isBound(UUID animal) { + return BOUND.containsKey(animal); + } + + /** + * Nothing but the rite touches an animal on the stone. The binding mage's own ritual knife makes + * the one cut that opens it; it bleeds from then on (see {@link #tick()}). Every other hit misses. + */ + public static void onHit(EntityDamageByEntityEvent event) { + Bound bound = BOUND.get(event.getEntity().getUniqueId()); + if (bound == null) { + return; + } + ItemStack knife = event.getDamager() instanceof Player player ? player.getInventory().getItemInMainHand() : null; + if (bound.cutAt() > 0 || knife == null || !event.getDamager().getUniqueId().equals(bound.mage()) + || !BloodItems.is(knife, BloodItemKind.RITUAL_KNIFE) || !bound.character().equalsIgnoreCase(BloodItems.owner(knife))) { + event.setCancelled(true); + return; + } + Player mage = (Player) event.getDamager(); + // A scratch: the animal flinches, but it is the bleeding that takes it. + event.setDamage(0.01); + BOUND.put(event.getEntity().getUniqueId(), bound.cut(clock.getAsLong())); + BloodFx.play("animal_cut", event.getEntity().getLocation(), mage); + WhisperService.tell(mage, "blood.animal.cut"); + if (!AnimalConfig.music().isEmpty()) { + mage.playSound(mage.getLocation(), AnimalConfig.music(), AnimalConfig.musicCategory(), AnimalConfig.musicVolume(), 1f); + } + } + + /** The rite's music ends with the rite, however it ends. */ + private static void silence(Player mage) { + if (mage != null && !AnimalConfig.music().isEmpty()) { + mage.stopSound(AnimalConfig.music(), AnimalConfig.musicCategory()); + } + } + + /** + * A bound animal died. At the rite's end the stone kills it itself (drained, the offering leaves + * nothing behind); any other death spills it, wasted. + */ + public static void onDeath(EntityDeathEvent event) { + LivingEntity animal = event.getEntity(); + if (animal.getPersistentDataContainer().has(drainedKey())) { + if (AnimalConfig.clearDrops()) { + event.getDrops().clear(); + event.setDroppedExp(0); + } + return; + } + Bound bound = BOUND.remove(animal.getUniqueId()); + Player mage = bound != null ? Bukkit.getPlayer(bound.mage()) : null; + if (mage != null) { + silence(mage); + mage.sendMessage(Messages.get("blood.animal.spoiled")); + } + } + + /** The rite is over for the animal: it dies on the stone, drained. */ + private static void drain(LivingEntity animal) { + animal.getPersistentDataContainer().remove(boundKey()); + animal.getPersistentDataContainer().set(drainedKey(), PersistentDataType.BYTE, (byte) 1); + animal.setHealth(0); + } + + static NamespacedKey drainedKey() { + return new NamespacedKey(Magic.plugin, "blood_drained"); + } + + private static void complete(Player mage, Bound bound, LivingEntity animal) { + BloodRecord record = BloodRecords.get(bound.character()); + BloodAltar altar = BloodAltars.byId(bound.altarId()); + if (record == null || altar == null || record.getStage() != BloodStage.ANIMAL) { + return; + } + record.addSpecies(speciesKey(animal)); + record.setLastAnimalAt(clock.getAsLong()); + boolean done = record.getSpecies().size() >= AnimalConfig.speciesRequired(); + if (done) { + record.setStage(BloodStage.CHOICE); + WhisperService.give(mage, BloodItems.create(BloodItemKind.BLOOD_KNIFE, bound.character())); + WhisperService.voiceSoon(mage); + } + BloodRecords.save(record); + AltarService.refresh(altar); + AltarCorruption.spread(altar, CorruptionConfig.blocksPerSacrifice()); + BloodAltars.save(); + Location center = altar.center(); + BloodFx.play("animal_sacrificed", center, mage); + if (done) { + BloodFx.play("blood_knife_given", center, mage); + } + if (bound.stolen()) { + StolenClues.leave(animal, bound.owners(), center); + } + WhisperService.tell(mage, done ? "blood.animal.complete" : "blood.animal.accepted"); + Magic.plugin.getLogger().info("[Magic] Character " + bound.character() + " sacrificed a " + + speciesKey(animal) + " (" + record.getSpecies().size() + "/" + AnimalConfig.speciesRequired() + ")"); + } + + /** + * Every second: a bound animal twitches on the stone and breaks free when its time is up; a cut one + * bleeds, its heart slowing, until the stone has drunk it, or until its mage steps away and spills it. + */ + static void tick() { + long now = clock.getAsLong(); + for (Iterator> it = BOUND.entrySet().iterator(); it.hasNext();) { + Map.Entry entry = it.next(); + Bound bound = entry.getValue(); + Entity entity = Bukkit.getEntity(entry.getKey()); + if (entity == null) { + // Unloaded: a cut one is lost; otherwise keep the binding until it runs out (the animal + // wakes from its tag when it loads). + if (bound.cutAt() > 0 || now >= bound.expiresAt()) { + it.remove(); + } + continue; + } + LivingEntity animal = (LivingEntity) entity; + if (animal.isDead()) { + it.remove(); + continue; + } + if (bound.cutAt() > 0) { + bleed(it, entry, animal, now); + continue; + } + if (now >= entry.getValue().expiresAt()) { + it.remove(); + wake(animal); + Player mage = Bukkit.getPlayer(entry.getValue().mage()); + if (mage != null) { + mage.sendMessage(Messages.get("blood.animal.escaped")); + } + BloodFx.play("animal_escaped", animal.getLocation()); + continue; + } + BloodFx.play("animal_binding", animal.getLocation()); + } + } + + private static void bleed(Iterator> it, Map.Entry entry, + LivingEntity animal, long now) { + Bound bound = entry.getValue(); + Player mage = Bukkit.getPlayer(bound.mage()); + BloodAltar altar = BloodAltars.byId(bound.altarId()); + Location center = altar != null ? altar.center() : null; + if (mage == null || center == null || mage.getWorld() != center.getWorld() + || mage.getLocation().distance(center) > AnimalConfig.stayRange() + || !bound.character().equalsIgnoreCase(AltarService.currentCharacter(mage))) { + it.remove(); + BloodFx.play("animal_spilled", animal.getLocation()); + drain(animal); + silence(mage); + if (mage != null) { + mage.sendMessage(Messages.get("blood.animal.left")); + } + return; + } + long bled = now - bound.cutAt(); + if (bled >= AnimalConfig.bleedSeconds() * 1000L) { + it.remove(); + silence(mage); + complete(mage, bound, animal); + drain(animal); + return; + } + int second = (int) (bled / 1000); + BloodFx.play("animal_bleeding", animal.getLocation(), mage); + if (AnimalConfig.heartbeats().contains(second)) { + BloodFx.play("animal_heartbeat", animal.getLocation(), mage); + } + if (!bound.draining() && second * 2 >= AnimalConfig.bleedSeconds()) { + entry.setValue(bound.halfway()); + WhisperService.tell(mage, "blood.animal.draining"); + } + } + + /** Lets every bound animal go, e.g. on shutdown. */ + public static void releaseAll() { + for (UUID id : BOUND.keySet()) { + if (Bukkit.getEntity(id) instanceof LivingEntity animal) { + wake(animal); + } + } + BOUND.clear(); + } + + /** Freshly loaded animals still tagged from a binding that is over get their AI back. */ + public static void wakeLoaded(Collection entities) { + long now = clock.getAsLong(); + for (Entity entity : entities) { + if (entity instanceof LivingEntity animal && animal.getPersistentDataContainer().has(boundKey())) { + Bound bound = BOUND.get(animal.getUniqueId()); + if (bound == null || now >= bound.expiresAt()) { + BOUND.remove(animal.getUniqueId()); + wake(animal); + } + } + } + } + + private static void wake(LivingEntity animal) { + animal.setAI(true); + animal.getPersistentDataContainer().remove(boundKey()); + } + + static NamespacedKey boundKey() { + return new NamespacedKey(Magic.plugin, "blood_bound"); + } + + /** Lets this mage's animal go, e.g. when they leave; one already cut bleeds out, wasted. */ + public static void releaseFor(UUID mage) { + UUID animal = bindingOf(mage); + if (animal == null) { + return; + } + Bound bound = BOUND.remove(animal); + if (Bukkit.getEntity(animal) instanceof LivingEntity living) { + if (bound.cutAt() > 0) { + drain(living); + } else { + wake(living); + } + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/ArtefactConfig.java b/src/main/java/net/tfminecraft/magic/blood/ArtefactConfig.java new file mode 100644 index 0000000..d7de461 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/ArtefactConfig.java @@ -0,0 +1,75 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +import org.bukkit.configuration.ConfigurationSection; + +/** + * The path's artefacts (artefacts.yml): the devotee's, handed out at the kneel, and the true path's, + * handed out when the hunt is done, with the ones its final sacrifices put in their place. + */ +public final class ArtefactConfig { + + /** One artefact: the item (a TLibs path), its name ({victim} for a final's) and lore. */ + public record Piece(String item, String name, List lore) {} + + private static final List DEVOTEE = new ArrayList<>(); + private static final List HUNT = new ArrayList<>(); + private static final List FINALS = new ArrayList<>(); + + private ArtefactConfig() {} + + static void reset() { + DEVOTEE.clear(); + HUNT.clear(); + FINALS.clear(); + } + + static void load(ConfigurationSection artefacts, Logger log) { + reset(); + if (artefacts == null) { + return; + } + read(artefacts.getMapList("devotee"), artefacts.getStringList("devotee_lore"), DEVOTEE, "devotee", log); + read(artefacts.getMapList("true.hunt"), artefacts.getStringList("true.lore"), HUNT, "true.hunt", log); + read(artefacts.getMapList("true.finals"), artefacts.getStringList("true.lore"), FINALS, "true.finals", log); + } + + private static void read(List> entries, List sharedLore, List out, String path, + Logger log) { + for (Map entry : entries) { + if (!(entry.get("item") instanceof String item) || item.isBlank()) { + log.warning("[Magic] blood magic config artefacts." + path + " entry needs an item"); + continue; + } + String name = entry.get("name") instanceof String raw ? raw : ""; + List lore = new ArrayList<>(); + if (entry.get("lore") instanceof List lines) { + for (Object line : lines) { + lore.add(String.valueOf(line)); + } + } else { + lore.addAll(sharedLore); + } + out.add(new Piece(item.trim(), name, List.copyOf(lore))); + } + } + + /** The artefacts the devotee path hands out at the kneel. */ + public static List devotee() { + return List.copyOf(DEVOTEE); + } + + /** The artefacts the true path hands out when the hunt is done. */ + public static List hunt() { + return List.copyOf(HUNT); + } + + /** The true path's final sacrifices' artefacts: the Nth takes the place of the Nth hunt artefact. */ + public static List finals() { + return List.copyOf(FINALS); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java b/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java new file mode 100644 index 0000000..4d17d1d --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java @@ -0,0 +1,179 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; + +/** A placed altar. Saved in data/bloodmagic/altars.json. */ +public final class BloodAltar { + + private String id; + private String ownerCharacter; + private String ownerUuid; + private String world; + /** The block the altar was placed as; null on altars saved before this was recorded. */ + private String blockType; + private int x; + private int y; + private int z; + private float yaw; + private int level; + private String displayId; + private long placedAt; + private long lastSpreadAt; + /** Blocks the altar changed, oldest first, so purification can put them back. */ + private List corrupted = new ArrayList<>(); + /** Furniture ids of the pedestals the altar set out around itself. */ + private List pedestals = new ArrayList<>(); + + /** One block the altar changed: where, what it was, and what the altar made it. */ + public record CorruptedBlock(int x, int y, int z, String original, String placed) {} + + public BloodAltar() {} + + public static BloodAltar create(String ownerCharacter, String ownerUuid, Block block, float yaw, long now) { + BloodAltar altar = new BloodAltar(); + altar.id = UUID.randomUUID().toString(); + altar.ownerCharacter = ownerCharacter; + altar.ownerUuid = ownerUuid; + altar.world = block.getWorld().getName(); + altar.x = block.getX(); + altar.y = block.getY(); + altar.z = block.getZ(); + altar.yaw = yaw; + altar.placedAt = now; + return altar; + } + + /** False when a hand edit left the file without the fields an altar needs. */ + public boolean isValid() { + return id != null && !id.isBlank() && ownerCharacter != null && !ownerCharacter.isBlank() + && world != null && !world.isBlank(); + } + + public String getId() { + return id; + } + + public String getOwnerCharacter() { + return ownerCharacter; + } + + public String getOwnerUuid() { + return ownerUuid; + } + + public String getBlockType() { + return blockType; + } + + public void setBlockType(String blockType) { + this.blockType = blockType; + } + + public String getWorld() { + return world; + } + + public int getX() { + return x; + } + + public int getY() { + return y; + } + + public int getZ() { + return z; + } + + public float getYaw() { + return yaw; + } + + public int getLevel() { + return level; + } + + public void setLevel(int level) { + this.level = Math.max(0, level); + } + + public UUID getDisplayId() { + if (displayId == null) { + return null; + } + try { + return UUID.fromString(displayId); + } catch (IllegalArgumentException ex) { + return null; + } + } + + public void setDisplayId(UUID displayId) { + this.displayId = displayId != null ? displayId.toString() : null; + } + + public long getPlacedAt() { + return placedAt; + } + + public long getLastSpreadAt() { + return lastSpreadAt; + } + + public void setLastSpreadAt(long lastSpreadAt) { + this.lastSpreadAt = lastSpreadAt; + } + + public List getCorrupted() { + if (corrupted == null) { + corrupted = new ArrayList<>(); + } + return corrupted; + } + + public List getPedestals() { + if (pedestals == null) { + pedestals = new ArrayList<>(); + } + return pedestals; + } + + public String blockKey() { + return key(world, x, y, z); + } + + public String chunkKey() { + return chunk(world, x >> 4, z >> 4); + } + + /** The altar block, or null when its world is not loaded. */ + public Block block() { + World loaded = Bukkit.getWorld(world); + return loaded != null ? loaded.getBlockAt(x, y, z) : null; + } + + /** Centre of the altar block, or null when its world is not loaded. */ + public Location center() { + Block block = block(); + return block != null ? block.getLocation().clone().add(0.5, 0.5, 0.5) : null; + } + + public static String key(String world, int x, int y, int z) { + return world + "," + x + "," + y + "," + z; + } + + public static String key(Block block) { + return key(block.getWorld().getName(), block.getX(), block.getY(), block.getZ()); + } + + public static String chunk(String world, int chunkX, int chunkZ) { + return world + "," + chunkX + "," + chunkZ; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodAltars.java b/src/main/java/net/tfminecraft/magic/blood/BloodAltars.java new file mode 100644 index 0000000..14cec83 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodAltars.java @@ -0,0 +1,89 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.bukkit.block.Block; + +/** Placed altars, indexed by id, block and owner. All lookups stay in memory. */ +public final class BloodAltars { + + private static final Map BY_ID = new LinkedHashMap<>(); + private static final Map BY_BLOCK = new LinkedHashMap<>(); + private static final Map BY_OWNER = new LinkedHashMap<>(); + private static BloodStore store; + + private BloodAltars() {} + + public static void init(BloodStore source) { + clearIndex(); + store = source; + for (BloodAltar altar : source.loadAltars()) { + index(altar); + } + } + + public static void clear() { + clearIndex(); + store = null; + } + + private static void clearIndex() { + BY_ID.clear(); + BY_BLOCK.clear(); + BY_OWNER.clear(); + } + + private static void index(BloodAltar altar) { + BY_ID.put(altar.getId(), altar); + BY_BLOCK.put(altar.blockKey(), altar); + BY_OWNER.put(altar.getOwnerCharacter().toLowerCase(Locale.ROOT), altar); + } + + public static BloodAltar at(Block block) { + return block != null ? BY_BLOCK.get(BloodAltar.key(block)) : null; + } + + public static BloodAltar byId(String id) { + return id != null ? BY_ID.get(id) : null; + } + + public static BloodAltar ofCharacter(String characterId) { + return characterId != null ? BY_OWNER.get(characterId.toLowerCase(Locale.ROOT)) : null; + } + + public static List inChunk(String world, int chunkX, int chunkZ) { + String chunk = BloodAltar.chunk(world, chunkX, chunkZ); + List out = new ArrayList<>(); + for (BloodAltar altar : BY_ID.values()) { + if (altar.chunkKey().equals(chunk)) { + out.add(altar); + } + } + return out; + } + + public static List all() { + return new ArrayList<>(BY_ID.values()); + } + + public static boolean add(BloodAltar altar) { + index(altar); + return save(); + } + + public static boolean remove(BloodAltar altar) { + BY_ID.remove(altar.getId()); + BY_BLOCK.remove(altar.blockKey()); + BY_OWNER.remove(altar.getOwnerCharacter().toLowerCase(Locale.ROOT)); + return save(); + } + + /** Writes every altar; called after any change to one of them. */ + public static boolean save() { + return store == null || store.saveAltars(new ArrayList<>(BY_ID.values())); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java b/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java new file mode 100644 index 0000000..da23484 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java @@ -0,0 +1,170 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.util.ItemRef; +import net.tfminecraft.magic.util.MagicText; + +/** + * Armour the blood path grants: unbreakable and bound to one character. Nobody else can wear it, + * and once its owner is dead it crumbles the moment anyone handles it. + */ +public final class BloodArmour { + + static final String KIND = "armour"; + private static final EquipmentSlot[] ARMOUR_SLOTS = { + EquipmentSlot.HEAD, EquipmentSlot.CHEST, EquipmentSlot.LEGS, EquipmentSlot.FEET + }; + + private BloodArmour() {} + + /** Builds the path's armour pieces bound to the character. Unknown paths in config are skipped. */ + public static List create(BloodPath path, String characterId) { + return create(path, characterId, List.of()); + } + + /** + * As above, and every piece with a name is named after a victim (character ids, most important + * first): the first named piece takes the first victim and so on down the body, wrapping round + * to the first again when there were fewer victims than pieces. + */ + public static List create(BloodPath path, String characterId, List victims) { + List out = new ArrayList<>(); + int named = 0; + for (ChoiceConfig.Piece piece : ChoiceConfig.armourFor(path)) { + ItemStack item = ItemRef.build(piece.item()); + if (item == null) { + continue; + } + item = BloodLooks.apply(item, piece.look()); + if (piece.name() != null && !victims.isEmpty()) { + rename(item, piece.name(), victims.get(named++ % victims.size())); + } + out.add(bind(item, characterId)); + } + return out; + } + + /** + * The hunt's victims, most important first: the most points, then the closest rank (strangers and + * older records without a rank last), then the earliest taken. + */ + static List victims(BloodRecord record) { + return record.getSacrifices().stream() + .filter(sacrifice -> BloodStage.HUNT.name().equals(sacrifice.stage())) + .sorted(Comparator.comparingInt(BloodRecord.Sacrifice::points).reversed() + .thenComparingInt(sacrifice -> sacrifice.rank() > 0 ? sacrifice.rank() : Integer.MAX_VALUE) + .thenComparingLong(BloodRecord.Sacrifice::at)) + .map(BloodRecord.Sacrifice::character) + .toList(); + } + + /** Names the piece after the victim; keeps the item's own name when the victim is unknown. */ + private static void rename(ItemStack item, String format, String victim) { + String name = FinalService.nameOf(victim); + if ("?".equals(name)) { + return; + } + ItemMeta meta = item.getItemMeta(); + meta.setDisplayName(MagicText.format(format.replace("{victim}", name.replace("\u00a7", "")))); + item.setItemMeta(meta); + } + + static ItemStack bind(ItemStack item, String characterId) { + ItemMeta meta = item.getItemMeta(); + meta.setUnbreakable(true); + meta.addItemFlags(ItemFlag.HIDE_UNBREAKABLE); + meta.getPersistentDataContainer().set(BloodItems.kindKey(), PersistentDataType.STRING, KIND); + meta.getPersistentDataContainer().set(BloodItems.ownerKey(), PersistentDataType.STRING, characterId); + item.setItemMeta(meta); + BloodLooks.protect(item); + return item; + } + + public static boolean is(ItemStack item) { + if (item == null || item.getType().isAir() || !item.hasItemMeta()) { + return false; + } + return KIND.equals(item.getItemMeta().getPersistentDataContainer().get(BloodItems.kindKey(), + PersistentDataType.STRING)); + } + + /** Hands the path's armour to the player; whatever does not fit drops at their feet. */ + public static void give(Player player, BloodPath path, String characterId) { + give(player, path, characterId, List.of()); + } + + /** As above, with the pieces named after these victims (see {@link #create(BloodPath, String, List)}). */ + public static void give(Player player, BloodPath path, String characterId, List victims) { + for (ItemStack piece : create(path, characterId, victims)) { + WhisperService.give(player, piece); + } + BloodFx.play("armour_given", player.getLocation(), player); + } + + /** + * Takes off any blood armour the player may not wear: another character's, or a dead owner's, + * which crumbles. Returns how many pieces came off. + */ + public static int enforce(Player player) { + String character = AltarService.currentCharacter(player); + PlayerInventory inventory = player.getInventory(); + int removed = 0; + for (EquipmentSlot slot : ARMOUR_SLOTS) { + ItemStack worn = inventory.getItem(slot); + if (!is(worn)) { + continue; + } + String owner = BloodItems.owner(worn); + if (AltarService.isDead(owner)) { + inventory.setItem(slot, null); + player.sendMessage(Messages.get("blood.armour.crumbles")); + BloodFx.play("armour_crumbles", player.getLocation(), player); + removed++; + } else if (character == null || !character.equalsIgnoreCase(owner)) { + inventory.setItem(slot, null); + WhisperService.give(player, worn); + player.sendMessage(Messages.get("blood.armour.refuses")); + removed++; + } + } + protectAll(player); + return removed; + } + + /** + * Skins (ArmourShop) put a new ItemsAdder look on a piece and drop its opt-out from ItemsAdder's + * auto update, which would one day rebuild it from the look and wipe the binding. Marks every + * blood piece the player carries again. + */ + public static void protectAll(Player player) { + PlayerInventory inventory = player.getInventory(); + for (int slot = 0; slot < inventory.getSize(); slot++) { + ItemStack item = inventory.getItem(slot); + if (!is(item)) { + continue; + } + ItemStack marked = item.clone(); + BloodLooks.protect(marked); + if (!marked.equals(item)) { + inventory.setItem(slot, marked); + } + } + } + + /** True when the item is blood armour whose owner died; the caller removes it. */ + public static boolean crumbles(ItemStack item) { + return is(item) && AltarService.isDead(BloodItems.owner(item)); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodArtefacts.java b/src/main/java/net/tfminecraft/magic/blood/BloodArtefacts.java new file mode 100644 index 0000000..f67d2cf --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodArtefacts.java @@ -0,0 +1,251 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.util.ItemRef; +import net.tfminecraft.magic.util.MagicText; + +/** + * The path's artefacts: bound to one character and set out on its altar's pedestals when the path's + * class is given (and, on the true path, one more in place of an older one at every final). Each + * holds a charge, filled in the altar's pool of blood (see {@link BloodCharging}) and drawn into blood + * resonance by meditating among them (see {@link BloodMeditation}); a charged one glows. Each holds a + * place; its id names the place and what fills it (devotee:3, true:hunt:3, true:final:3), so one whose + * place was filled again since is of no more use. They are not Magic artifacts: no aura, no wear, no + * use in mage gear. + */ +public final class BloodArtefacts { + + static final String KIND = "artefact"; + + private BloodArtefacts() {} + + static NamespacedKey idKey() { + return new NamespacedKey(Magic.plugin, "blood_artefact"); + } + + /** + * The artefacts the character holds a place for now, one id per place: none before the path's class + * is given. On the true path the Nth final sacrifice fills the Nth place anew. + */ + static List ids(BloodRecord record) { + List out = new ArrayList<>(); + if (record == null || !record.getStage().atLeast(BloodStage.FINAL)) { + return out; + } + if (record.getPath() == BloodPath.DEVOTEE) { + for (int i = 0; i < ArtefactConfig.devotee().size(); i++) { + out.add("devotee:" + i); + } + } else if (record.getPath() == BloodPath.TRUE) { + int finals = Math.min(record.getFinalsDone(), ArtefactConfig.finals().size()); + for (int i = 0; i < ArtefactConfig.hunt().size(); i++) { + out.add(i < finals ? "true:final:" + i : "true:hunt:" + i); + } + } + return out; + } + + /** The config piece an id stands for, or null. */ + static ArtefactConfig.Piece piece(String id) { + String[] parts = id == null ? new String[0] : id.split(":"); + List pieces; + if (parts.length == 2 && parts[0].equals("devotee")) { + pieces = ArtefactConfig.devotee(); + } else if (parts.length == 3 && parts[0].equals("true") && parts[1].equals("hunt")) { + pieces = ArtefactConfig.hunt(); + } else if (parts.length == 3 && parts[0].equals("true") && parts[1].equals("final")) { + pieces = ArtefactConfig.finals(); + } else { + return null; + } + try { + int index = Integer.parseInt(parts[parts.length - 1]); + return index >= 0 && index < pieces.size() ? pieces.get(index) : null; + } catch (NumberFormatException ex) { + return null; + } + } + + /** The most charge one artefact holds: an equal share of what the path's artefacts are worth. */ + static double maxCharge(BloodRecord record) { + int places = ids(record).size(); + int reached = record.getStage() == BloodStage.ASCENDED ? Integer.MAX_VALUE : record.getFinalsDone(); + return places == 0 ? 0 : PoolConfig.worth(record.getPath(), reached) / places; + } + + /** How many of the character's current artefacts hold any charge. */ + static int charged(BloodRecord record) { + int count = 0; + for (String id : ids(record)) { + if (record.getCharge(id) > 0) { + count++; + } + } + return count; + } + + /** A new artefact for this place, bound to the record's character; null for an unknown id. */ + @SuppressWarnings("deprecation") + static ItemStack create(BloodRecord record, String id) { + ArtefactConfig.Piece piece = piece(id); + if (piece == null) { + return null; + } + ItemStack item = ItemRef.buildOrFallback(piece.item(), Material.FERMENTED_SPIDER_EYE); + item.setAmount(1); + ItemMeta meta = item.getItemMeta(); + String name = piece.name(); + if (name.contains("{victim}")) { + String victim = victim(record, Integer.parseInt(id.substring(id.lastIndexOf(':') + 1))); + name = name.replace("{victim}", victim.replace("§", "")); + } + if (!name.isBlank()) { + meta.setDisplayName(MagicText.format(name)); + } + if (!piece.lore().isEmpty()) { + List lore = new ArrayList<>(); + for (String line : piece.lore()) { + lore.add(MagicText.format(line)); + } + meta.setLore(lore); + } + PersistentDataContainer pdc = meta.getPersistentDataContainer(); + pdc.set(BloodItems.kindKey(), PersistentDataType.STRING, KIND); + pdc.set(BloodItems.ownerKey(), PersistentDataType.STRING, record.getCharacterId()); + pdc.set(idKey(), PersistentDataType.STRING, id); + item.setItemMeta(meta); + BloodLooks.protect(item); + return styled(record, item); + } + + /** The artefact glowing while its place holds charge, plain once drained. */ + static ItemStack styled(BloodRecord record, ItemStack item) { + ItemMeta meta = item.getItemMeta(); + meta.setEnchantmentGlintOverride(record.getCharge(id(item)) > 0); + item.setItemMeta(meta); + return item; + } + + /** The name of the Nth final sacrifice's victim; "someone" when the final was skipped by staff. */ + static String victim(BloodRecord record, int index) { + int seen = 0; + for (BloodRecord.Sacrifice sacrifice : record.getSacrifices()) { + if (BloodStage.FINAL.name().equals(sacrifice.stage()) && seen++ == index) { + String name = FinalService.nameOf(sacrifice.character()); + return "?".equals(name) ? "someone" : name; + } + } + return "someone"; + } + + public static boolean is(ItemStack item) { + return KIND.equals(BloodItems.read(item, BloodItems.kindKey())); + } + + /** The artefact's id, or null for any other item. */ + static String id(ItemStack item) { + return is(item) ? BloodItems.read(item, idKey()) : null; + } + + /** + * Hands out every artefact the character holds a place for but was not given yet: onto the altar's + * pedestals (in place of the one it follows, else on a free one), or into the inventory when none is + * free or loaded. One that takes another's place starts without charge, and the old one's charge + * goes. Returns how many were handed out. + */ + public static int sync(Player player) { + String character = AltarService.currentCharacter(player); + BloodRecord record = BloodRecords.get(character); + List wanted = ids(record); + if (wanted.isEmpty() || wanted.equals(record.getArtefacts())) { + return 0; + } + BloodAltar altar = BloodAltars.ofCharacter(character); + List before = List.copyOf(record.getArtefacts()); + int given = 0; + for (int i = 0; i < wanted.size(); i++) { + String id = wanted.get(i); + String old = i < before.size() ? before.get(i) : null; + if (id.equals(old)) { + continue; + } + ItemStack item = create(record, id); + if (altar == null || !AltarPedestals.offer(altar, item, old)) { + WhisperService.give(player, item); + } + given++; + } + record.getArtefacts().clear(); + record.getArtefacts().addAll(wanted); + record.keepCharges(wanted); + record.getPending().retainAll(wanted); + BloodRecords.save(record); + if (given > 0) { + WhisperService.tell(player, given > 1 ? "blood.artefact.given" : "blood.artefact.replaced"); + } + Magic.plugin.getLogger().info("[Magic] Character " + character + " was given " + given + " blood artefact(s)"); + return given; + } + + /** + * Runs {@link #sync} and {@link #deliverPending} on the next tick, if the player is still online + * then. + */ + public static void syncSoon(Player player) { + Bukkit.getScheduler().runTask(Magic.plugin, () -> { + if (player.isOnline()) { + sync(player); + deliverPending(player); + } + }); + } + + /** + * Gives back the artefacts that finished charging while their owner was away (or the server + * stopped), and returns how many. + */ + public static int deliverPending(Player player) { + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); + if (record == null || record.getPending().isEmpty()) { + return 0; + } + int given = 0; + for (String id : List.copyOf(record.getPending())) { + if (BloodCharging.isCharging(record.getCharacterId(), id)) { + continue; + } + record.getPending().remove(id); + if (ids(record).contains(id)) { + WhisperService.give(player, create(record, id)); + given++; + } + } + BloodRecords.save(record); + if (given > 0) { + WhisperService.tell(player, "blood.artefact.returned"); + } + return given; + } + + /** Staff: gives the player a fresh copy of each of their current artefacts. */ + public static int regive(Player player) { + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); + List ids = ids(record); + for (String id : ids) { + WhisperService.give(player, create(record, id)); + } + return ids.size(); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodBoons.java b/src/main/java/net/tfminecraft/magic/blood/BloodBoons.java new file mode 100644 index 0000000..f342356 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodBoons.java @@ -0,0 +1,47 @@ +package net.tfminecraft.magic.blood; + +import java.util.Map; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.magic.integration.RpCharactersBridge; + +/** + * Stat boons earned by final sacrifices, added through MythicLib while the mage's character is + * played, and the devotee's mark (ChoiceConfig.devoteeTrait): a character bearing it has no mana. + * Without MythicLib nothing is applied. MythicLib types stay in {@link MythicBoons}. + */ +public final class BloodBoons { + + private BloodBoons() {} + + static boolean available() { + return Bukkit.getPluginManager().isPluginEnabled("MythicLib"); + } + + /** Re-applies the boons of the player's current character, replacing any earlier ones. */ + public static void apply(Player player) { + if (!available()) { + return; + } + MythicBoons.clear(player); + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); + Map boons = record != null + ? HuntConfig.boonsUpTo(record.getPath(), record.getFinalsDone()) : Map.of(); + if (!boons.isEmpty()) { + MythicBoons.add(player, boons); + } + String mark = ChoiceConfig.devoteeTrait(); + if (!mark.isBlank() && RpCharactersBridge.hasTrait(player, mark)) { + MythicBoons.noMana(player); + } + } + + /** Removes the player's boons, e.g. when they leave or switch character. */ + public static void clear(Player player) { + if (available()) { + MythicBoons.clear(player); + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodCharging.java b/src/main/java/net/tfminecraft/magic/blood/BloodCharging.java new file mode 100644 index 0000000..7ce38ea --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodCharging.java @@ -0,0 +1,174 @@ +package net.tfminecraft.magic.blood; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; +import org.bukkit.util.Transformation; +import org.joml.AxisAngle4f; +import org.joml.Vector3f; + +/** + * Charging an artefact in the altar's pool of blood. Its owner right-clicks the altar with it: the + * artefact sinks into the pool, turns in the blood while the stone's heart beats, and rises again + * glowing, back into its owner's hands to set on a pedestal. The charge is paid when it goes in (free + * while the path goes on, from the pool once it is complete) and kept in the record as pending, so a + * restart or a quit mid-charge still gives it back at the next session. One at a time per altar. + */ +public final class BloodCharging { + + /** What right-clicking the altar with an artefact came to. */ + public enum Start { + STARTED, + NOT_YOURS, + STALE, + FULL, + DRY, + BUSY + } + + private record Charging(UUID player, String character, String id, ItemDisplay display, Location base, + long startedAt) {} + + private static final double ABOVE = 0.6; + private static final double SUNK = -0.05; + private static final double RISEN = 0.95; + private static final Map BY_ALTAR = new HashMap<>(); + private static long ticks; + private static BukkitTask task; + + private BloodCharging() {} + + static void startTicking(Plugin plugin) { + if (task != null) { + task.cancel(); + } + task = plugin.getServer().getScheduler().runTaskTimer(plugin, BloodCharging::tick, 1L, 1L); + } + + /** The owner sets the held artefact in their altar's pool to charge. */ + public static Start start(Player player, BloodAltar altar, ItemStack held) { + String character = AltarService.currentCharacter(player); + BloodRecord record = BloodRecords.get(character); + if (record == null || !character.equalsIgnoreCase(altar.getOwnerCharacter()) + || !character.equalsIgnoreCase(BloodItems.owner(held))) { + return Start.NOT_YOURS; + } + String id = BloodArtefacts.id(held); + if (!BloodArtefacts.ids(record).contains(id)) { + held.setAmount(held.getAmount() - 1); + return Start.STALE; + } + if (BY_ALTAR.containsKey(altar.getId())) { + return Start.BUSY; + } + double missing = BloodArtefacts.maxCharge(record) - record.getCharge(id); + if (missing <= 0.0001) { + return Start.FULL; + } + double paid = BloodPool.pay(record, missing); + if (paid <= 0) { + return Start.DRY; + } + ItemStack shown = held.clone(); + shown.setAmount(1); + held.setAmount(held.getAmount() - 1); + record.setCharge(id, record.getCharge(id) + paid); + if (!record.getPending().contains(id)) { + record.getPending().add(id); + } + BloodRecords.save(record); + Location base = altar.center().add(0, 0.5, 0); + ItemDisplay display = base.getWorld().spawn(base.clone().add(0, ABOVE, 0), ItemDisplay.class, spawned -> { + spawned.setItemStack(shown); + spawned.setPersistent(false); + spawned.setTeleportDuration(2); + spawned.setTransformation(new Transformation(new Vector3f(), new AxisAngle4f(), + new Vector3f(0.5f, 0.5f, 0.5f), new AxisAngle4f())); + }); + BY_ALTAR.put(altar.getId(), new Charging(player.getUniqueId(), record.getCharacterId(), id, display, base, ticks)); + MeditationConfig.charge().play(player); + BloodFx.play("artefact_charge", base, player); + return Start.STARTED; + } + + /** Whether this character's artefact is in a pool right now. */ + static boolean isCharging(String character, String id) { + for (Charging charging : BY_ALTAR.values()) { + if (charging.character().equalsIgnoreCase(character) && charging.id().equals(id)) { + return true; + } + } + return false; + } + + /** Every tick: each artefact in a pool sinks, turns in the blood, then rises and goes back to its owner. */ + static void tick() { + ticks++; + int total = MeditationConfig.chargeTicks(); + for (Iterator it = BY_ALTAR.values().iterator(); it.hasNext();) { + Charging charging = it.next(); + long elapsed = ticks - charging.startedAt(); + if (elapsed >= total) { + it.remove(); + finish(charging); + continue; + } + double t = (double) elapsed / total; + double height = t < 0.3 ? ABOVE + (SUNK - ABOVE) * (t / 0.3) + : t < 0.75 ? SUNK : SUNK + (RISEN - SUNK) * ((t - 0.75) / 0.25); + // Moved every other tick; the client glides between (teleport duration 2). + if (elapsed % 2 == 0) { + Location at = charging.base().clone().add(0, height, 0); + at.setYaw((float) (elapsed * 6 % 360)); + charging.display().teleport(at); + } + if (elapsed % 4 == 0) { + charging.base().getWorld().spawnParticle(Particle.DUST, charging.base(), 6, 0.25, 0.05, 0.25, 0, + new Particle.DustOptions(Color.fromRGB(0x8a0303), 1.2f)); + } + if (elapsed % 20 == 10 && t < 0.75) { + BloodFx.play("artefact_charge_beat", charging.base()); + } + } + } + + private static void finish(Charging charging) { + charging.display().remove(); + BloodRecord record = BloodRecords.get(charging.character()); + Player player = Bukkit.getPlayer(charging.player()); + if (record == null || player == null + || !charging.character().equalsIgnoreCase(AltarService.currentCharacter(player))) { + return; + } + record.getPending().remove(charging.id()); + BloodRecords.save(record); + if (BloodArtefacts.ids(record).contains(charging.id())) { + WhisperService.give(player, BloodArtefacts.create(record, charging.id())); + WhisperService.tell(player, "blood.artefact.charge.done"); + BloodFx.play("artefact_charged", charging.base(), player); + } + } + + /** Ends every charge without handing anything back (it waits as pending), e.g. on shutdown. */ + static void clear() { + for (Charging charging : BY_ALTAR.values()) { + charging.display().remove(); + } + BY_ALTAR.clear(); + if (task != null) { + task.cancel(); + task = null; + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java new file mode 100644 index 0000000..c434ac0 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java @@ -0,0 +1,59 @@ +package net.tfminecraft.magic.blood; + +import org.bukkit.entity.Player; + +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.integration.RpCharactersBridge; + +/** + * The answer to the god's question. A devotee gives part of themself (permanent injuries), skips + * the hunt and is put into the devotee class (bound to it) with its armour at once. A mage who refuses keeps their class and + * hunts alone; from now on only their own dark self speaks to them. + */ +public final class BloodChoice { + + private BloodChoice() {} + + /** Whether the player may answer now: their character waits at the question. */ + public static boolean canAnswer(Player player) { + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); + return record != null && record.getStage() == BloodStage.CHOICE + && BloodAltars.ofCharacter(record.getCharacterId()) != null; + } + + /** Applies the answer; false when the player may no longer answer. */ + public static boolean answer(Player player, BloodPath path) { + if (path == BloodPath.NONE || !canAnswer(player)) { + return false; + } + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); + String character = record.getCharacterId(); + record.setPath(path); + if (path == BloodPath.DEVOTEE) { + String mark = ChoiceConfig.devoteeTrait(); + boolean marked = !mark.isBlank() && RpCharactersBridge.applyTrait(player, mark); + ClassUnlockBridge.assign(player, ChoiceConfig.classFor(BloodPath.DEVOTEE)); + BloodArmour.give(player, BloodPath.DEVOTEE, character); + WhisperService.tell(player, "blood.choice.devoted"); + BloodFx.play("devoted", player.getLocation(), player); + FinalService.begin(player, record); + BloodBoons.apply(player); + Magic.plugin.getLogger().info("[Magic] Character " + character + " gave themself to the god (" + + (marked ? "marked " + mark : "no mark") + ")"); + } else { + record.setStage(BloodStage.HUNT); + BloodRecords.save(record); + WhisperService.tell(player, "blood.choice.refused"); + BloodFx.play("refused", player.getLocation(), player); + Magic.plugin.getLogger().info("[Magic] Character " + character + " refused the god"); + } + BloodPerms.refresh(player); + WhisperService.voiceSoon(player); + BloodAltar altar = BloodAltars.ofCharacter(character); + AltarService.refresh(altar); + AltarCorruption.spread(altar, CorruptionConfig.blocksPerSacrifice()); + BloodAltars.save(); + return true; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java new file mode 100644 index 0000000..4ded977 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java @@ -0,0 +1,393 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.magic.Messages; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.Characters; + +/** + * Staff tools: /magic blood <list|info|stage|path|give|whisper|voice|target|bonds|altar|pool|reset>. + * Player targets must be online on the character being edited; altars can also be named by id. + */ +public final class BloodCommand { + + static final List SUBCOMMANDS = List.of( + "list", "info", "stage", "path", "give", "whisper", "voice", "target", "bonds", "altar", "pool", "reset"); + private static final List TARGET_ACTIONS = List.of("list", "reroll", "set", "skip"); + private static final String ARTEFACTS = "artefacts"; + private static final List ALTAR_ACTIONS = List.of("info", "tp", "refresh", "purify"); + + private BloodCommand() {} + + public static boolean execute(CommandSender sender, String[] args) { + String sub = args.length >= 2 ? args[1].toLowerCase(Locale.ROOT) : ""; + switch (sub) { + case "list" -> list(sender); + case "info" -> withTarget(sender, args, 3, (player, character) -> info(sender, player, character)); + case "stage" -> withTarget(sender, args, 4, (player, character) -> stage(sender, player, character, args[3])); + case "path" -> withTarget(sender, args, 4, (player, character) -> path(sender, player, character, args[3])); + case "give" -> withTarget(sender, args, 4, (player, character) -> give(sender, player, character, args[3])); + case "whisper" -> whisper(sender, args); + case "voice" -> withTarget(sender, args, 3, (player, character) -> voice(sender, player, character)); + case "target" -> target(sender, args); + case "bonds" -> withTarget(sender, args, 3, (player, character) -> bonds(sender, player)); + case "altar" -> altar(sender, args); + case "pool" -> pool(sender, args); + case "reset" -> withTarget(sender, args, 3, (player, character) -> reset(sender, player, character)); + default -> sender.sendMessage(Messages.get("blood.admin.usage")); + } + return true; + } + + private interface TargetAction { + void run(Player player, String character); + } + + private static void withTarget(CommandSender sender, String[] args, int length, TargetAction action) { + if (args.length != length) { + sender.sendMessage(Messages.get("blood.admin.usage")); + return; + } + Player player = Bukkit.getPlayerExact(args[2]); + if (player == null) { + sender.sendMessage(Messages.get("artifact.give.unknown_player", "player", args[2])); + return; + } + String character = AltarService.currentCharacter(player); + if (character == null) { + sender.sendMessage(Messages.get("resonance.admin.no_character", "player", player.getName())); + return; + } + action.run(player, character); + } + + private static void list(CommandSender sender) { + List altars = BloodAltars.all(); + sender.sendMessage(Messages.get("blood.admin.list_header", "count", String.valueOf(altars.size()))); + for (BloodAltar altar : altars) { + sender.sendMessage(Messages.get("blood.admin.list_line", "id", altar.getId(), + "character", altar.getOwnerCharacter(), "where", altar.blockKey(), + "level", String.valueOf(altar.getLevel()))); + } + } + + private static void info(CommandSender sender, Player player, String character) { + BloodRecord record = BloodRecords.get(character); + if (record == null) { + sender.sendMessage(Messages.get("blood.admin.no_record", "player", player.getName(), "character", character)); + return; + } + BloodAltar altar = BloodAltars.ofCharacter(character); + sender.sendMessage(Messages.get("blood.admin.info", "player", player.getName(), "character", character, + "stage", record.getStage().name(), "path", record.getPath().name(), + "whisper", String.format(Locale.ROOT, "%.1f", record.getWhisper()), + "species", String.join(", ", record.getSpecies()), + "species_count", String.valueOf(record.getSpecies().size()), + "hunt", String.valueOf(record.getHuntPoints()), + "finals", String.valueOf(record.getFinalsDone()), + "progress", String.valueOf(BloodProgress.of(record)), + "altar", altar != null ? altar.blockKey() + " L" + altar.getLevel() : "-")); + if (!BloodArtefacts.ids(record).isEmpty()) { + poolLine(sender, player, record); + } + } + + private static void poolLine(CommandSender sender, Player player, BloodRecord record) { + double resonance = BloodPool.resonance(record); + sender.sendMessage(Messages.get("blood.admin.pool", "player", player.getName(), + "pool", String.format(Locale.ROOT, "%.1f", BloodPool.level(record)), + "capacity", String.format(Locale.ROOT, "%.0f", PoolConfig.capacity()), + "draining", String.valueOf(record.getStage() == BloodStage.ASCENDED), + "resonance", String.format(Locale.ROOT, "%.1f", resonance), + "tier", String.valueOf(PoolConfig.band(resonance)), + "charged", String.valueOf(BloodArtefacts.charged(record)), + "total", String.valueOf(BloodArtefacts.ids(record).size()))); + } + + /** pool <player> shows the pool and blood resonance; pool <player> <amount> sets the pool. */ + private static void pool(CommandSender sender, String[] args) { + if (args.length != 3 && args.length != 4) { + sender.sendMessage(Messages.get("blood.admin.usage")); + return; + } + withTarget(sender, Arrays.copyOf(args, 3), 3, (player, character) -> { + BloodRecord record = BloodRecords.get(character); + if (record == null) { + sender.sendMessage(Messages.get("blood.admin.no_record", "player", player.getName(), "character", character)); + return; + } + if (args.length == 4) { + try { + record.setPool(Math.min(PoolConfig.capacity(), Double.parseDouble(args[3]))); + } catch (NumberFormatException ex) { + sender.sendMessage(Messages.get("values.invalid_amount")); + return; + } + saved(sender, record); + } + poolLine(sender, player, record); + }); + } + + private static void stage(CommandSender sender, Player player, String character, String raw) { + BloodStage stage = BloodStage.parse(raw); + if (stage == null) { + sender.sendMessage(Messages.get("blood.admin.bad_stage", "stages", names(BloodStage.values()))); + return; + } + BloodRecord record = BloodRecords.getOrCreate(character, player.getUniqueId().toString()); + record.setStage(stage); + saved(sender, record); + AltarService.refreshFor(character); + ClassRewards.apply(player); + BloodArtefacts.sync(player); + sender.sendMessage(Messages.get("blood.admin.stage_set", "player", player.getName(), "stage", stage.name())); + } + + private static void path(CommandSender sender, Player player, String character, String raw) { + BloodPath path = BloodPath.parse(raw); + if (path == null) { + sender.sendMessage(Messages.get("blood.admin.bad_path", "paths", names(BloodPath.values()))); + return; + } + BloodRecord record = BloodRecords.getOrCreate(character, player.getUniqueId().toString()); + record.setPath(path); + saved(sender, record); + BloodPerms.refresh(player); + ClassRewards.apply(player); + BloodArtefacts.sync(player); + AltarService.refreshFor(character); + sender.sendMessage(Messages.get("blood.admin.path_set", "player", player.getName(), "path", path.name())); + } + + private static void give(CommandSender sender, Player player, String character, String raw) { + if (raw.equalsIgnoreCase(ARTEFACTS)) { + sender.sendMessage(Messages.get("blood.admin.artefacts_given", "player", player.getName(), + "count", String.valueOf(BloodArtefacts.regive(player)))); + return; + } + BloodItemKind kind = BloodItemKind.parse(raw); + if (kind == null) { + sender.sendMessage(Messages.get("blood.admin.bad_item")); + return; + } + ItemStack item = BloodItems.create(kind, character); + for (ItemStack left : player.getInventory().addItem(item).values()) { + player.getWorld().dropItemNaturally(player.getLocation(), left); + } + sender.sendMessage(Messages.get("blood.admin.given", "player", player.getName(), "item", kind.key())); + } + + /** whisper <player> wakes the voices now; whisper <player> <amount> adds to the meter. */ + private static void whisper(CommandSender sender, String[] args) { + if (args.length != 3 && args.length != 4) { + sender.sendMessage(Messages.get("blood.admin.usage")); + return; + } + withTarget(sender, Arrays.copyOf(args, 3), 3, (player, character) -> { + if (args.length == 4) { + addWhisper(sender, player, character, args[3]); + return; + } + BloodRecord record = BloodRecords.getOrCreate(character, player.getUniqueId().toString()); + if (record.getStage() != BloodStage.NONE) { + sender.sendMessage(Messages.get("blood.admin.already_whispered", "player", player.getName())); + return; + } + WhisperService.awaken(record, player); + saved(sender, record); + sender.sendMessage(Messages.get("blood.admin.whispered", "player", player.getName())); + }); + } + + private static void addWhisper(CommandSender sender, Player player, String character, String raw) { + double amount; + try { + amount = Double.parseDouble(raw); + } catch (NumberFormatException ex) { + sender.sendMessage(Messages.get("values.invalid_amount")); + return; + } + boolean woke = WhisperService.addTo(character, player.getUniqueId().toString(), amount, player); + BloodRecord record = BloodRecords.get(character); + sender.sendMessage(Messages.get("blood.admin.whisper_added", "player", player.getName(), + "value", String.format(Locale.ROOT, "%.1f", record != null ? record.getWhisper() : 0), + "woke", String.valueOf(woke))); + } + + private static void voice(CommandSender sender, Player player, String character) { + BloodRecord record = BloodRecords.get(character); + if (record == null || record.getStage() == BloodStage.NONE) { + sender.sendMessage(Messages.get("blood.admin.no_voices", "player", player.getName())); + return; + } + WhisperService.speakNext(player, record); + sender.sendMessage(Messages.get("blood.admin.voiced", "player", player.getName())); + } + + /** target <player> list|reroll|skip, or target <player> set <character name>. */ + private static void target(CommandSender sender, String[] args) { + String action = args.length >= 4 ? args[3].toLowerCase(Locale.ROOT) : ""; + boolean set = action.equals("set"); + if (!TARGET_ACTIONS.contains(action) || args.length != (set ? 5 : 4)) { + sender.sendMessage(Messages.get("blood.admin.usage")); + return; + } + withTarget(sender, Arrays.copyOf(args, 3), 3, (player, character) -> { + BloodRecord record = BloodRecords.get(character); + if (record == null || record.getStage() != BloodStage.FINAL) { + sender.sendMessage(Messages.get("blood.admin.not_final", "player", player.getName())); + return; + } + switch (action) { + case "reroll" -> FinalService.reroll(player, record); + case "skip" -> FinalService.skip(player, record); + case "set" -> { + String id = livingCharacter(args[4]); + if (id == null) { + sender.sendMessage(Messages.get("blood.admin.no_character_named", "name", args[4])); + return; + } + FinalService.set(player, record, id); + } + default -> { } + } + List names = new ArrayList<>(); + for (String id : record.getFinalTargets()) { + names.add(FinalService.nameOf(id)); + } + sender.sendMessage(Messages.get("blood.admin.targets", "player", player.getName(), + "done", String.valueOf(record.getFinalsDone()), "targets", String.join(" > ", names))); + }); + } + + private static String livingCharacter(String name) { + for (CharacterInfo info : Characters.findByName(name)) { + if (info.alive()) { + return info.ref().characterId().toString(); + } + } + return null; + } + + private static void bonds(CommandSender sender, Player player) { + List ranking = BondService.ranking(player); + sender.sendMessage(Messages.get("blood.admin.bonds_header", "player", player.getName(), + "count", String.valueOf(ranking.size()))); + for (int i = 0; i < Math.min(10, ranking.size()); i++) { + sender.sendMessage(Messages.get("blood.admin.bonds_line", "rank", String.valueOf(i + 1), + "name", FinalService.nameOf(ranking.get(i).character()), + "score", String.format(Locale.ROOT, "%.1f", ranking.get(i).score()))); + } + } + + private static void reset(CommandSender sender, Player player, String character) { + BloodAltar altar = BloodAltars.ofCharacter(character); + if (altar != null) { + AltarService.purify(altar, "reset by " + sender.getName()); + } + BloodRecords.delete(character); + BloodPerms.refresh(player); + sender.sendMessage(Messages.get("blood.admin.reset", "player", player.getName())); + } + + private static void altar(CommandSender sender, String[] args) { + if (args.length != 4 || !ALTAR_ACTIONS.contains(args[2].toLowerCase(Locale.ROOT))) { + sender.sendMessage(Messages.get("blood.admin.usage")); + return; + } + BloodAltar altar = BloodAltars.byId(args[3]); + if (altar == null) { + Player player = Bukkit.getPlayerExact(args[3]); + String character = player != null ? AltarService.currentCharacter(player) : null; + altar = BloodAltars.ofCharacter(character); + } + if (altar == null) { + sender.sendMessage(Messages.get("blood.admin.no_altar", "target", args[3])); + return; + } + switch (args[2].toLowerCase(Locale.ROOT)) { + case "info" -> sender.sendMessage(Messages.get("blood.admin.list_line", "id", altar.getId(), + "character", altar.getOwnerCharacter(), "where", altar.blockKey(), + "level", String.valueOf(altar.getLevel()))); + case "tp" -> teleport(sender, altar); + case "refresh" -> { + AltarService.refresh(altar); + sender.sendMessage(Messages.get("blood.admin.altar_refreshed", "level", String.valueOf(altar.getLevel()))); + } + default -> { + AltarService.purify(altar, "purified by " + sender.getName()); + sender.sendMessage(Messages.get("blood.admin.altar_purified", "where", altar.blockKey())); + } + } + } + + private static void teleport(CommandSender sender, BloodAltar altar) { + Location center = altar.center(); + if (!(sender instanceof Player staff) || center == null) { + sender.sendMessage(Messages.get("blood.admin.tp_unavailable")); + return; + } + staff.teleport(center.add(0, 1, 0)); + } + + private static void saved(CommandSender sender, BloodRecord record) { + if (!BloodRecords.save(record)) { + sender.sendMessage(Messages.get("values.save_failed")); + } + } + + private static String names(Enum[] values) { + return String.join(", ", Arrays.stream(values).map(value -> value.name().toLowerCase(Locale.ROOT)).toList()); + } + + public static List complete(String[] args) { + if (args.length == 2) { + return SUBCOMMANDS; + } + String sub = args[1].toLowerCase(Locale.ROOT); + if (args.length == 3) { + return sub.equals("altar") ? ALTAR_ACTIONS + : sub.equals("list") ? List.of() : onlineNames(); + } + if (args.length == 4) { + return switch (sub) { + case "stage" -> lower(BloodStage.values()); + case "path" -> lower(BloodPath.values()); + case "give" -> Stream.concat(Arrays.stream(BloodItemKind.values()).map(BloodItemKind::key), + Stream.of(ARTEFACTS)).toList(); + case "altar" -> altarTargets(); + case "target" -> TARGET_ACTIONS; + default -> List.of(); + }; + } + return List.of(); + } + + private static List onlineNames() { + return Bukkit.getOnlinePlayers().stream().map(Player::getName).toList(); + } + + private static List lower(Enum[] values) { + return Arrays.stream(values).map(value -> value.name().toLowerCase(Locale.ROOT)).toList(); + } + + private static List altarTargets() { + List out = new ArrayList<>(onlineNames()); + for (BloodAltar altar : BloodAltars.all()) { + out.add(altar.getId()); + } + return out; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java new file mode 100644 index 0000000..e8a4f03 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java @@ -0,0 +1,298 @@ +package net.tfminecraft.magic.blood; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.logging.Logger; + +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +/** + * Settings from the wildmagic/bloodmagic folder (settings, items, voices, animals, corruption, choice, + * armour, artefacts and fx files, merged; messages.yml is read by Messages). Defaults keep the feature safe when + * a key is missing. + */ +public final class BloodConfig { + + /** A bound item the voices hand out. */ + public record ItemDef(String path, String name, List lore) {} + + private static boolean enabled; + private static final Map ITEMS = new EnumMap<>(BloodItemKind.class); + private static Material altarBlock = Material.BARRIER; + private static final TreeMap MODELS = new TreeMap<>(); + private static float displayScale = 1f; + private static final TreeMap LEVELS = new TreeMap<>(); + private static List worlds = List.of(); + private static boolean requireUnderground = true; + private static int minCover = 3; + private static int coverScanHeight = 48; + private static boolean denyRegions = true; + private static List allowedRegions = List.of(); + private static int purifyCheckMinutes = 10; + private static boolean roomEnabled = true; + private static int roomHeight = 3; + private static int roomMaxChanges = 150; + private static int huntPointsRequired = 7; + + static { + reset(); + } + + private BloodConfig() {} + + /** The files a source stands for: itself, or a folder's .yml files but messages.yml, by name. */ + static List parts(File source) { + if (source.isFile()) { + return List.of(source); + } + File[] files = source.listFiles((dir, name) -> name.endsWith(".yml") && !name.equals("messages.yml")); + List out = new ArrayList<>(files != null ? List.of(files) : List.of()); + out.sort((a, b) -> a.getName().compareTo(b.getName())); + return out; + } + + /** + * Loads the config folder (every .yml in it but messages.yml, merged) or a single file. A missing + * folder leaves the feature disabled; a broken file does too and returns false. + */ + public static boolean load(File source, Logger log) { + reset(); + if (source == null || !source.exists()) { + log.warning("[Magic] wildmagic/bloodmagic config missing; blood magic disabled"); + return true; + } + YamlConfiguration config = new YamlConfiguration(); + for (File part : parts(source)) { + YamlConfiguration loaded = new YamlConfiguration(); + try { + loaded.load(part); + } catch (IOException | InvalidConfigurationException ex) { + log.severe("[Magic] Failed to load blood magic config " + part.getName() + ": " + ex.getMessage()); + reset(); + return false; + } + for (String key : loaded.getKeys(true)) { + if (!loaded.isConfigurationSection(key)) { + config.set(key, loaded.get(key)); + } + } + } + enabled = config.getBoolean("enabled", false); + ConfigurationSection items = config.getConfigurationSection("items"); + for (BloodItemKind kind : BloodItemKind.values()) { + ConfigurationSection section = items != null ? items.getConfigurationSection(kind.key()) : null; + if (section == null) { + log.warning("[Magic] blood magic config items." + kind.key() + " missing; using a plain default"); + ITEMS.put(kind, new ItemDef(kind.fallbackPath(), "", List.of())); + continue; + } + ITEMS.put(kind, new ItemDef(section.getString("path", kind.fallbackPath()), + section.getString("name", ""), List.copyOf(section.getStringList("lore")))); + } + boolean ok = true; + MODELS.clear(); + String block = config.getString("altar.block", "BARRIER"); + Material material = Material.matchMaterial(block); + if (material == null || !material.isBlock()) { + log.warning("[Magic] blood magic config altar.block '" + block + "' is not a block; using BARRIER"); + ok = false; + } else { + altarBlock = material; + } + ok &= readIntMap(config.getConfigurationSection("altar.models"), "altar.models", log, + (level, value) -> MODELS.put(level, value)); + if (MODELS.isEmpty()) { + MODELS.put(0, BloodItemKind.ALTAR.fallbackPath()); + } + displayScale = (float) Math.max(0.1, config.getDouble("altar.display_scale", 1.0)); + ok &= readIntMap(config.getConfigurationSection("altar.levels"), "altar.levels", log, (level, value) -> { + try { + LEVELS.put(level, Integer.parseInt(value)); + } catch (NumberFormatException ex) { + log.warning("[Magic] blood magic config altar.levels." + level + " is not a number"); + } + }); + worlds = lower(config.getStringList("placement.worlds")); + requireUnderground = config.getBoolean("placement.require_underground", true); + minCover = Math.max(0, config.getInt("placement.min_cover", 3)); + coverScanHeight = Math.max(1, config.getInt("placement.cover_scan_height", 48)); + denyRegions = config.getBoolean("placement.deny_regions", true); + allowedRegions = lower(config.getStringList("placement.allowed_regions")); + purifyCheckMinutes = Math.max(1, config.getInt("placement.purify_check_minutes", 10)); + roomEnabled = config.getBoolean("placement.room.enabled", true); + roomHeight = Math.max(2, config.getInt("placement.room.height", 3)); + roomMaxChanges = Math.max(0, config.getInt("placement.room.max_changes", 150)); + RoomTemplate.load(config.getConfigurationSection("placement.room.template"), log); + huntPointsRequired = Math.max(1, config.getInt("hunt.points_required", 7)); + WhisperConfig.load(config.getConfigurationSection("whispers"), config.getConfigurationSection("voices"), log); + AnimalConfig.load(config.getConfigurationSection("animal"), log); + CorruptionConfig.load(config.getConfigurationSection("corruption"), log); + ChoiceConfig.load(config.getConfigurationSection("choice"), config.getConfigurationSection("classes"), + config.getConfigurationSection("armour")); + ChoiceConfig.loadPermissions(config.getConfigurationSection("permissions")); + HuntConfig.load(config.getConfigurationSection("hunt"), config.getConfigurationSection("bonds"), + config.getConfigurationSection("finals"), log); + PoolConfig.load(config.getConfigurationSection("pool"), log); + MeditationConfig.load(config.getConfigurationSection("meditation")); + ArtefactConfig.load(config.getConfigurationSection("artefacts"), log); + BloodFx.load(config.getConfigurationSection("fx"), log); + return ok; + } + + private interface IntEntry { + void accept(int level, String value); + } + + private static boolean readIntMap(ConfigurationSection section, String path, Logger log, IntEntry sink) { + if (section == null) { + return true; + } + boolean ok = true; + for (String key : section.getKeys(false)) { + try { + sink.accept(Integer.parseInt(key.trim()), section.getString(key, "")); + } catch (NumberFormatException ex) { + log.warning("[Magic] blood magic config " + path + " key '" + key + "' is not a level number"); + ok = false; + } + } + return ok; + } + + private static List lower(List raw) { + List out = new ArrayList<>(); + for (String value : raw) { + if (!value.isBlank()) { + out.add(value.trim().toLowerCase(Locale.ROOT)); + } + } + return Collections.unmodifiableList(out); + } + + /** Clears everything back to the disabled defaults. */ + public static void reset() { + roomEnabled = true; + roomHeight = 3; + roomMaxChanges = 150; + RoomTemplate.reset(); + enabled = false; + ITEMS.clear(); + for (BloodItemKind kind : BloodItemKind.values()) { + ITEMS.put(kind, new ItemDef(kind.fallbackPath(), "", List.of())); + } + altarBlock = Material.BARRIER; + MODELS.clear(); + MODELS.put(0, BloodItemKind.ALTAR.fallbackPath()); + displayScale = 1f; + LEVELS.clear(); + worlds = List.of(); + requireUnderground = true; + minCover = 3; + coverScanHeight = 48; + denyRegions = true; + allowedRegions = List.of(); + purifyCheckMinutes = 10; + huntPointsRequired = 7; + WhisperConfig.reset(); + AnimalConfig.reset(); + CorruptionConfig.reset(); + ChoiceConfig.reset(); + HuntConfig.reset(); + PoolConfig.reset(); + MeditationConfig.reset(); + ArtefactConfig.reset(); + BloodFx.reset(); + } + + public static boolean isEnabled() { + return enabled; + } + + public static void setEnabled(boolean value) { + enabled = value; + } + + public static ItemDef item(BloodItemKind kind) { + return ITEMS.get(kind); + } + + public static Material altarBlock() { + return altarBlock; + } + + /** The model for a level: the highest configured level at or below it. */ + public static String model(int level) { + Map.Entry entry = MODELS.floorEntry(level); + return entry != null ? entry.getValue() : MODELS.firstEntry().getValue(); + } + + public static float displayScale() { + return displayScale; + } + + /** The altar level reached with this much progress. */ + public static int level(int progress) { + int level = 0; + for (Map.Entry entry : LEVELS.entrySet()) { + if (progress >= entry.getValue()) { + level = Math.max(level, entry.getKey()); + } + } + return level; + } + + public static boolean worldAllowed(String world) { + return worlds.isEmpty() || (world != null && worlds.contains(world.toLowerCase(Locale.ROOT))); + } + + public static boolean requireUnderground() { + return requireUnderground; + } + + public static int minCover() { + return minCover; + } + + public static int coverScanHeight() { + return coverScanHeight; + } + + public static boolean denyRegions() { + return denyRegions; + } + + public static boolean regionAllowed(String regionId) { + return regionId != null && allowedRegions.contains(regionId.toLowerCase(Locale.ROOT)); + } + + /** Whether placing the altar shapes its meditation room (placement.room). */ + public static boolean roomEnabled() { + return roomEnabled; + } + + public static int roomHeight() { + return roomHeight; + } + + public static int roomMaxChanges() { + return roomMaxChanges; + } + + public static int purifyCheckMinutes() { + return purifyCheckMinutes; + } + + public static int huntPointsRequired() { + return huntPointsRequired; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodFx.java b/src/main/java/net/tfminecraft/magic/blood/BloodFx.java new file mode 100644 index 0000000..4113033 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodFx.java @@ -0,0 +1,283 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.logging.Logger; + +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.block.data.BlockData; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.magic.Magic; + +/** + * Sounds and particles for the blood path, by moment (fx.moments in the wildmagic/bloodmagic config). Each moment is a + * list of layers played together or after a delay: a sound, or particles in a shape (point, ring, + * column, spiral). Private layers reach only the player the moment is about; an effect never breaks + * the rite that plays it. + */ +public final class BloodFx { + + enum Shape { POINT, RING, COLUMN, SPIRAL } + + record Layer(String sound, float volume, float pitch, Particle particle, Object data, int count, + double spreadX, double spreadY, double spreadZ, double speed, double y, Shape shape, double radius, + int points, double height, long delay, boolean personal) {} + + private static final Map> MOMENTS = new HashMap<>(); + private static boolean enabled; + private static double hearRadius; + + static { + reset(); + } + + private BloodFx() {} + + static void reset() { + enabled = true; + hearRadius = 10; + MOMENTS.clear(); + AltarCandles.reset(); + } + + /** How far a shared sound carries: only players this close hear it. */ + static double hearRadius() { + return hearRadius; + } + + static void load(ConfigurationSection fx, Logger log) { + reset(); + if (fx == null) { + return; + } + enabled = fx.getBoolean("enabled", true); + hearRadius = Math.max(1, fx.getDouble("hear_radius", 10)); + AltarCandles.load(fx.getConfigurationSection("candles"), log); + ConfigurationSection moments = fx.getConfigurationSection("moments"); + if (moments == null) { + return; + } + for (String moment : moments.getKeys(false)) { + List layers = new ArrayList<>(); + for (Map raw : moments.getMapList(moment)) { + Layer layer = layer(raw); + if (layer != null) { + layers.add(layer); + } else { + log.warning("[Magic] blood magic config fx." + moment + " has a layer without a valid sound or particle: " + raw); + } + } + MOMENTS.put(moment.toLowerCase(Locale.ROOT), List.copyOf(layers)); + } + } + + /** The moment's layers; empty when it is not configured. */ + static List moment(String name) { + return MOMENTS.getOrDefault(name, List.of()); + } + + public static boolean isEnabled() { + return enabled; + } + + /** Plays the moment at the spot for everyone around; private layers are skipped. */ + public static void play(String moment, Location at) { + play(moment, at, null); + } + + /** Plays the moment at the spot; private layers reach only the viewer. */ + public static void play(String moment, Location at, Player viewer) { + if (!enabled || at == null || at.getWorld() == null) { + return; + } + for (Layer layer : moment(moment)) { + if (layer.personal() && viewer == null) { + continue; + } + Location spot = at.clone(); + if (layer.delay() > 0) { + Magic.plugin.getServer().getScheduler().runTaskLater(Magic.plugin, + () -> run(moment, layer, spot, viewer), layer.delay()); + } else { + run(moment, layer, spot, viewer); + } + } + } + + private static void run(String moment, Layer layer, Location at, Player viewer) { + try { + if (layer.sound() != null) { + if (layer.personal()) { + viewer.playSound(at, layer.sound(), layer.volume(), layer.pitch()); + } else { + // Shared sounds stay in the room: only players within hear_radius get them. + for (Player near : at.getWorld().getPlayers()) { + if (near.getLocation().distanceSquared(at) <= hearRadius * hearRadius) { + near.playSound(at, layer.sound(), layer.volume(), layer.pitch()); + } + } + } + return; + } + Object data = data(layer); + for (Location point : points(layer, at)) { + if (layer.personal()) { + viewer.spawnParticle(layer.particle(), point, layer.count(), layer.spreadX(), layer.spreadY(), + layer.spreadZ(), layer.speed(), data); + } else { + at.getWorld().spawnParticle(layer.particle(), point, layer.count(), layer.spreadX(), + layer.spreadY(), layer.spreadZ(), layer.speed(), data); + } + } + } catch (RuntimeException ex) { + Magic.plugin.getLogger().fine("[Magic] Blood effect " + moment + " failed: " + ex.getMessage()); + } + } + + /** Block and item data are built when played; config loading must not need a running server. */ + static Object data(Layer layer) { + if (layer.data() instanceof Material material) { + return layer.particle().getDataType() == BlockData.class ? material.createBlockData() : new ItemStack(material); + } + return layer.data(); + } + + static List points(Layer layer, Location at) { + Location base = at.clone().add(0, layer.y(), 0); + List out = new ArrayList<>(); + int points = layer.points(); + switch (layer.shape()) { + case RING -> { + for (int i = 0; i < points; i++) { + double angle = 2 * Math.PI * i / points; + out.add(base.clone().add(Math.cos(angle) * layer.radius(), 0, Math.sin(angle) * layer.radius())); + } + } + case COLUMN -> { + for (int i = 0; i < points; i++) { + out.add(base.clone().add(0, layer.height() * i / Math.max(1, points - 1), 0)); + } + } + case SPIRAL -> { + for (int i = 0; i < points; i++) { + double angle = 4 * Math.PI * i / points; + out.add(base.clone().add(Math.cos(angle) * layer.radius(), layer.height() * i / points, + Math.sin(angle) * layer.radius())); + } + } + default -> out.add(base); + } + return out; + } + + static Layer layer(Map raw) { + String sound = text(raw.get("sound")); + Particle particle = null; + Object data = null; + if (sound == null) { + String name = text(raw.get("particle")); + if (name == null) { + return null; + } + try { + particle = Particle.valueOf(name.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + return null; + } + data = data(particle, raw); + if (data == UNSUPPORTED) { + return null; + } + } else if (!sound.contains(":")) { + sound = "minecraft:" + sound; + } + double[] spread = spread(raw.get("spread")); + Shape shape; + try { + shape = Shape.valueOf(String.valueOf(raw.containsKey("shape") ? raw.get("shape") : "point").toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + shape = Shape.POINT; + } + return new Layer(sound, (float) number(raw, "volume", 1), (float) number(raw, "pitch", 1), particle, data, + (int) Math.max(0, number(raw, "count", 1)), spread[0], spread[1], spread[2], number(raw, "speed", 0), + number(raw, "y", 0), shape, number(raw, "radius", 1), (int) Math.max(1, number(raw, "points", 16)), + number(raw, "height", 1), (long) Math.max(0, number(raw, "delay", 0)), + Boolean.TRUE.equals(raw.get("private"))); + } + + private static final Object UNSUPPORTED = new Object(); + + /** The particle's extra data from the layer, null when it takes none, or UNSUPPORTED. */ + private static Object data(Particle particle, Map raw) { + Class type = particle.getDataType(); + Color color = color(raw.get("color"), Color.fromRGB(0x8a0303)); + float size = (float) number(raw, "size", 1); + if (type == Void.class) { + return null; + } + if (type == Particle.DustOptions.class) { + return new Particle.DustOptions(color, size); + } + if (type == Particle.DustTransition.class) { + return new Particle.DustTransition(color, color(raw.get("to"), Color.fromRGB(0x1a0000)), size); + } + if (type == BlockData.class) { + Material block = Material.matchMaterial(String.valueOf(raw.get("block"))); + return block != null && block.isBlock() ? block : UNSUPPORTED; + } + if (type == ItemStack.class) { + Material item = Material.matchMaterial(String.valueOf(raw.get("item"))); + return item != null && item.isItem() ? item : UNSUPPORTED; + } + if (type == Color.class) { + return color; + } + if (type == Float.class) { + return (float) number(raw, "value", 0); + } + if (type == Integer.class) { + return (int) number(raw, "value", 0); + } + return UNSUPPORTED; + } + + private static String text(Object value) { + return value instanceof String s && !s.isBlank() ? s.trim() : null; + } + + private static double number(Map raw, String key, double fallback) { + return raw.get(key) instanceof Number n ? n.doubleValue() : fallback; + } + + private static double[] spread(Object value) { + if (value instanceof Number n) { + return new double[] {n.doubleValue(), n.doubleValue(), n.doubleValue()}; + } + if (value instanceof List list && list.size() == 3 && list.get(0) instanceof Number x + && list.get(1) instanceof Number y && list.get(2) instanceof Number z) { + return new double[] {x.doubleValue(), y.doubleValue(), z.doubleValue()}; + } + return new double[] {0, 0, 0}; + } + + private static Color color(Object value, Color fallback) { + String hex = text(value); + if (hex == null) { + return fallback; + } + try { + return Color.fromRGB(Integer.parseInt(hex.startsWith("#") ? hex.substring(1) : hex, 16) & 0xFFFFFF); + } catch (NumberFormatException ex) { + return fallback; + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodItemKind.java b/src/main/java/net/tfminecraft/magic/blood/BloodItemKind.java new file mode 100644 index 0000000..28fc33d --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodItemKind.java @@ -0,0 +1,39 @@ +package net.tfminecraft.magic.blood; + +import java.util.Locale; + +/** Bound items of the blood path. */ +public enum BloodItemKind { + ALTAR("altar", "v.chiseled_stone_bricks"), + RITUAL_KNIFE("ritual_knife", "v.iron_sword"), + BLOOD_KNIFE("blood_knife", "v.netherite_sword"); + + private final String key; + private final String fallbackPath; + + BloodItemKind(String key, String fallbackPath) { + this.key = key; + this.fallbackPath = fallbackPath; + } + + public String key() { + return key; + } + + public String fallbackPath() { + return fallbackPath; + } + + public static BloodItemKind parse(String raw) { + if (raw == null) { + return null; + } + String wanted = raw.trim().toLowerCase(Locale.ROOT); + for (BloodItemKind kind : values()) { + if (kind.key.equals(wanted)) { + return kind; + } + } + return null; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodItems.java b/src/main/java/net/tfminecraft/magic/blood/BloodItems.java new file mode 100644 index 0000000..141fb43 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodItems.java @@ -0,0 +1,82 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.util.ItemRef; +import net.tfminecraft.magic.util.MagicText; + +/** Builds and reads the bound blood items. The kind and owner live in the item's PDC. */ +public final class BloodItems { + + private BloodItems() {} + + public static NamespacedKey kindKey() { + return new NamespacedKey(Magic.plugin, "blood_item"); + } + + public static NamespacedKey ownerKey() { + return new NamespacedKey(Magic.plugin, "blood_owner"); + } + + /** A new item of this kind bound to the character. */ + @SuppressWarnings("deprecation") + public static ItemStack create(BloodItemKind kind, String characterId) { + BloodConfig.ItemDef def = BloodConfig.item(kind); + ItemStack item = ItemRef.buildOrFallback(def.path(), Material.matchMaterial(kind.fallbackPath().substring(2))); + item.setAmount(1); + ItemMeta meta = item.getItemMeta(); + if (!def.name().isBlank()) { + meta.setDisplayName(MagicText.format(def.name())); + } + if (!def.lore().isEmpty()) { + List lore = new ArrayList<>(); + for (String line : def.lore()) { + lore.add(MagicText.format(line)); + } + meta.setLore(lore); + } + if (kind != BloodItemKind.ALTAR) { + meta.setUnbreakable(true); + meta.addItemFlags(ItemFlag.HIDE_UNBREAKABLE); + } + PersistentDataContainer pdc = meta.getPersistentDataContainer(); + pdc.set(kindKey(), PersistentDataType.STRING, kind.key()); + pdc.set(ownerKey(), PersistentDataType.STRING, characterId); + item.setItemMeta(meta); + BloodLooks.protect(item); + return item; + } + + /** The blood item kind, or null for any other item. */ + public static BloodItemKind kind(ItemStack item) { + String raw = read(item, kindKey()); + return raw != null ? BloodItemKind.parse(raw) : null; + } + + /** The character the item is bound to, or null. */ + public static String owner(ItemStack item) { + return read(item, ownerKey()); + } + + public static boolean is(ItemStack item, BloodItemKind kind) { + return kind != null && kind == kind(item); + } + + /** A string the item keeps under the key, or null. */ + static String read(ItemStack item, NamespacedKey key) { + if (item == null || item.getType().isAir() || !item.hasItemMeta()) { + return null; + } + return item.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java new file mode 100644 index 0000000..fd59f17 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -0,0 +1,421 @@ +package net.tfminecraft.magic.blood; + +import java.util.List; +import java.util.Locale; + +import org.bukkit.Bukkit; +import org.bukkit.block.Block; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockPistonExtendEvent; +import org.bukkit.event.block.BlockPistonRetractEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityMountEvent; +import org.bukkit.event.entity.EntityPlaceEvent; +import org.bukkit.event.hanging.HangingPlaceEvent; +import org.bukkit.event.player.PlayerBucketEmptyEvent; +import org.bukkit.event.player.PlayerBucketFillEvent; +import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.entity.PlayerLeashEntityEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerAnimationEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.world.EntitiesLoadEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; + +import com.destroystokyo.paper.event.player.PlayerArmorChangeEvent; + +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; +import net.tfminecraft.interactiblefurniture.events.FurniturePlaceEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemTakeEvent; +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.Messages; +import net.tfminecraft.rpcharacters.chat.CharacterChatEvent; +import net.tfminecraft.tlibs.character.CharacterDeathEvent; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; + +/** Altar placement, protection, looks on chunk load, dark deeds for the whisper meter, and deaths. */ +public final class BloodListener implements Listener { + + /** Staff who may still change an altar's chamber. */ + static final String BYPASS = "magic.blood.bypass"; + + @EventHandler(priority = EventPriority.HIGH) + public void onInteract(PlayerInteractEvent event) { + if (event.getHand() != EquipmentSlot.HAND || event.getAction() != Action.RIGHT_CLICK_BLOCK) { + return; + } + Block clicked = event.getClickedBlock(); + BloodAltar altar = BloodAltars.at(clicked); + if (altar != null) { + event.setCancelled(true); + Player player = event.getPlayer(); + ItemStack held = player.getInventory().getItemInMainHand(); + String character = AltarService.currentCharacter(player); + if (character != null && character.equalsIgnoreCase(altar.getOwnerCharacter()) + && BloodItems.is(held, BloodItemKind.BLOOD_KNIFE) && character.equalsIgnoreCase(BloodItems.owner(held)) + && BloodChoice.canAnswer(player)) { + QuestionService.start(player); + return; + } + Player carried = CarryService.carried(player); + if (carried != null && BloodItems.is(held, BloodItemKind.BLOOD_KNIFE)) { + layDown(player, carried); + return; + } + if (BloodArtefacts.is(held)) { + BloodCharging.Start start = BloodCharging.start(player, altar, held); + player.getInventory().setItemInMainHand(held.getAmount() > 0 ? held : null); + WhisperService.tell(player, "blood.artefact.charge." + start.name().toLowerCase(Locale.ROOT)); + return; + } + AltarService.touch(player, altar); + return; + } + ItemStack hand = event.getItem(); + if (!BloodItems.is(hand, BloodItemKind.ALTAR)) { + return; + } + event.setCancelled(true); + event.setUseItemInHand(Event.Result.DENY); + Block target = AltarPlacement.replaceable(clicked) ? clicked : clicked.getRelative(event.getBlockFace()); + event.getPlayer().sendMessage(Messages.get(AltarService.place(event.getPlayer(), hand, target), + "size", String.valueOf(AltarRoom.size()), "height", String.valueOf(BloodConfig.roomHeight()))); + } + + /** The ritual knife binds a living animal onto its owner's altar. */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onInteractEntity(PlayerInteractEntityEvent event) { + if (event.getHand() != EquipmentSlot.HAND) { + return; + } + ItemStack held = event.getPlayer().getInventory().getItemInMainHand(); + if (event.getRightClicked() instanceof Player victim && BloodItems.is(held, BloodItemKind.BLOOD_KNIFE)) { + event.setCancelled(true); + Player mage = event.getPlayer(); + if (victim.equals(CarryService.carried(mage))) { + if (mage.isSneaking()) { + CarryService.release(mage, true); + } else { + layDown(mage, victim); + } + return; + } + if (mage.isSneaking() && HuntConfig.carryEnabled()) { + CharacterRiteService.Bind start = CarryService.start(mage, victim); + if (start != CharacterRiteService.Bind.OK) { + WhisperService.tell(mage, "blood.rite.bind." + start.name().toLowerCase(Locale.ROOT)); + } + return; + } + layDown(mage, victim); + return; + } + if (!(event.getRightClicked() instanceof LivingEntity animal) || animal instanceof Player + || !BloodItems.is(held, BloodItemKind.RITUAL_KNIFE)) { + return; + } + event.setCancelled(true); + AnimalRiteService.Bind result = AnimalRiteService.bind(event.getPlayer(), animal); + WhisperService.tell(event.getPlayer(), "blood.animal.bind." + result.name().toLowerCase(Locale.ROOT)); + } + + private static void layDown(Player mage, Player victim) { + CharacterRiteService.Bind result = CharacterRiteService.bind(mage, victim); + WhisperService.tell(mage, "blood.rite.bind." + result.name().toLowerCase(Locale.ROOT)); + } + + /** The altar's own pedestals cannot be picked up, broken or carried off; purifying it removes them. */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onFurnitureBreak(FurnitureBreakEvent event) { + if (event.hasPlayer() && AltarPedestals.bound(event.getFurniture())) { + event.setCancelled(true); + event.getPlayer().sendMessage(Messages.get("blood.altar.pedestal_bound")); + } + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onFurnitureInteract(FurnitureInteractEvent event) { + Player player = event.getPlayer(); + if (player.isSneaking() && player.getInventory().getItemInMainHand().getType().isAir() + && AltarPedestals.bound(event.getFurniture())) { + event.setCancelled(true); + player.sendMessage(Messages.get("blood.altar.pedestal_bound")); + } + } + + /** A meditating mage's swing catches the drop of blood they aim at. */ + @EventHandler + public void onSwing(PlayerAnimationEvent event) { + BloodMeditation.swing(event.getPlayer()); + } + + /** Only the character an artefact is bound to may take it off a pedestal. */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onSlotTake(FurnitureSlotItemTakeEvent event) { + if (!BloodArtefacts.is(event.getItem())) { + return; + } + String character = AltarService.currentCharacter(event.getPlayer()); + if (character == null || !character.equalsIgnoreCase(BloodItems.owner(event.getItem()))) { + event.setCancelled(true); + event.getPlayer().sendMessage(Messages.get("blood.artefact.not_yours")); + } + } + + /** Sitting on the altar with /sit is how its owner meditates there; the stone answers. */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onMount(EntityMountEvent event) { + if (event.getEntity() instanceof Player player) { + AltarService.sat(player, event.getMount()); + } + } + + /** The answer to the god's question is spoken aloud, in roleplay chat. */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onCharacterChat(CharacterChatEvent event) { + QuestionService.onChat(event.getSender(), event.getChannel(), event.getMessage()); + } + + /** A skin may have been put on a blood piece in a menu: keep ItemsAdder off it. */ + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + if (event.getPlayer() instanceof Player player) { + BloodArmour.protectAll(player); + } + } + + /** Blood armour stays on its own character only; checked a tick after any armour change. */ + @EventHandler + public void onArmourChange(PlayerArmorChangeEvent event) { + if (BloodArmour.is(event.getNewItem())) { + Player player = event.getPlayer(); + Magic.plugin.getServer().getScheduler().runTask(Magic.plugin, () -> BloodArmour.enforce(player)); + } + } + + /** A dead owner's blood armour and artefacts crumble when someone tries to pick them up. */ + @EventHandler(ignoreCancelled = true) + public void onPickup(EntityPickupItemEvent event) { + ItemStack stack = event.getItem().getItemStack(); + if (BloodArmour.crumbles(stack) + || BloodArtefacts.is(stack) && AltarService.isDead(BloodItems.owner(stack))) { + event.setCancelled(true); + BloodFx.play("armour_crumbles", event.getItem().getLocation()); + event.getItem().remove(); + } + } + + @EventHandler(ignoreCancelled = true) + public void onLeash(PlayerLeashEntityEvent event) { + StolenClues.leashed(event.getEntity()); + } + + /** The altar item is never set down as an ordinary block. */ + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onPlace(BlockPlaceEvent event) { + if (BloodItems.kind(event.getItemInHand()) != null) { + event.setCancelled(true); + } else if (locked(event.getPlayer(), event.getBlock())) { + event.setCancelled(true); + } + } + + /** The altar's chamber is the stone's: nobody but staff places, breaks or hangs anything in it. */ + static boolean locked(Player player, Block block) { + if (AltarRoom.lockedBy(block) == null || (player != null && player.hasPermission(BYPASS))) { + return false; + } + if (player != null) { + player.sendMessage(Messages.get("blood.altar.room_locked")); + } + return true; + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onBucketEmpty(PlayerBucketEmptyEvent event) { + if (locked(event.getPlayer(), event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onBucketFill(PlayerBucketFillEvent event) { + if (locked(event.getPlayer(), event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onHang(HangingPlaceEvent event) { + if (locked(event.getPlayer(), event.getEntity().getLocation().getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onEntityPlace(EntityPlaceEvent event) { + if (locked(event.getPlayer(), event.getEntity().getLocation().getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onFurniturePlace(FurniturePlaceEvent event) { + if (locked(event.getPlayer(), event.getFurniture().getLoc().getBlock())) { + event.setCancelled(true); + } + } + + /** The altar, and every block it has changed around it, stand against tools. */ + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onBreak(BlockBreakEvent event) { + Block block = event.getBlock(); + if (BloodAltars.at(block) != null) { + event.setCancelled(true); + event.getPlayer().sendMessage(Messages.get("blood.altar.unbreakable")); + } else if (AltarCorruption.isCorrupted(block)) { + event.setCancelled(true); + WhisperService.tell(event.getPlayer(), "blood.corruption.unbreakable"); + BloodFx.play("corruption_unbreakable", block.getLocation().add(0.5, 0.5, 0.5), event.getPlayer()); + } else if (locked(event.getPlayer(), block)) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onPistonExtend(BlockPistonExtendEvent event) { + if (touchesAltar(event.getBlocks())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onPistonRetract(BlockPistonRetractEvent event) { + if (touchesAltar(event.getBlocks())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onEntityExplode(EntityExplodeEvent event) { + event.blockList().removeIf(BloodListener::guarded); + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onBlockExplode(BlockExplodeEvent event) { + event.blockList().removeIf(BloodListener::guarded); + } + + /** The altar, a block it has changed, or anything in its locked chamber. */ + private static boolean guarded(Block block) { + return BloodAltars.at(block) != null || AltarCorruption.isCorrupted(block) || AltarRoom.lockedBy(block) != null; + } + + @EventHandler + public void onEntitiesLoad(EntitiesLoadEvent event) { + AltarDisplays.removeStrays(event.getEntities()); + AnimalRiteService.wakeLoaded(event.getEntities()); + StolenClues.removeExpired(event.getEntities()); + AltarPedestals.sweep(); + for (BloodAltar altar : BloodAltars.inChunk(event.getWorld().getName(), + event.getChunk().getX(), event.getChunk().getZ())) { + AltarService.refresh(altar); + } + } + + @EventHandler + public void onCharacterDeath(CharacterDeathEvent event) { + String dead = event.getCharacter().characterId().toString(); + AltarService.onCharacterDeath(dead); + if (!CharacterRiteService.isBeingSacrificed(dead)) { + FinalService.onDeath(dead); + } + CharacterRef killer = event.getKillerCharacter(); + if (killer == null || killer.characterId().equals(event.getCharacter().characterId())) { + return; + } + String character = killer.characterId().toString(); + Player online = Bukkit.getPlayer(killer.ownerId()); + Player current = online != null && character.equalsIgnoreCase(AltarService.currentCharacter(online)) + ? online : null; + WhisperService.addTo(character, killer.ownerId().toString(), WhisperConfig.characterKill(), current); + } + + /** HIGHEST: after other damage plugins, so the ritual knife's stroke stays lethal on a bound animal. */ + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onAnimalHit(EntityDamageByEntityEvent event) { + AnimalRiteService.onHit(event); + } + + @EventHandler(ignoreCancelled = true) + public void onAnimalDeath(EntityDeathEvent event) { + AnimalRiteService.onDeath(event); + Player killer = event.getEntity().getKiller(); + if (killer != null) { + WhisperService.add(killer, WhisperService.animalKillWeight(killer, event.getEntity())); + } + } + + /** LOW: before RPCharacters closes the character session on quit. */ + @EventHandler(priority = EventPriority.LOW) + public void onQuit(PlayerQuitEvent event) { + CharacterRiteService.onQuit(event.getPlayer()); + WhisperService.forget(event.getPlayer().getUniqueId()); + AnimalRiteService.releaseFor(event.getPlayer().getUniqueId()); + QuestionService.forget(event.getPlayer().getUniqueId()); + CarryService.drop(event.getPlayer().getUniqueId()); + BloodSpellGate.forget(event.getPlayer().getUniqueId()); + BloodMeditation.stop(event.getPlayer().getUniqueId()); + BloodPerms.clear(event.getPlayer()); + } + + @EventHandler + public void onSessionStart(CharacterSessionStartEvent event) { + WhisperService.forget(event.getPlayer().getUniqueId()); + BloodNotes.deliver(event.getPlayer(), event.getSession().ref().characterId().toString(), + System.currentTimeMillis(), true); + BloodArmour.enforce(event.getPlayer()); + String character = event.getSession().ref().characterId().toString(); + BondService.load(character); + BloodBoons.apply(event.getPlayer()); + ClassRewards.applySoon(event.getPlayer()); + BloodArtefacts.syncSoon(event.getPlayer()); + BloodPerms.refresh(event.getPlayer()); + FinalService.check(event.getPlayer()); + } + + @EventHandler + public void onSessionEnd(CharacterSessionEndEvent event) { + BondService.unload(event.getSession().ref().characterId().toString()); + BloodBoons.clear(event.getPlayer()); + BloodPerms.clear(event.getPlayer()); + AnimalRiteService.releaseFor(event.getPlayer().getUniqueId()); + BloodMeditation.stop(event.getPlayer().getUniqueId()); + } + + private static boolean touchesAltar(List blocks) { + for (Block block : blocks) { + if (guarded(block)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodLooks.java b/src/main/java/net/tfminecraft/magic/blood/BloodLooks.java new file mode 100644 index 0000000..22d97f0 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodLooks.java @@ -0,0 +1,52 @@ +package net.tfminecraft.magic.blood; + +import java.util.Optional; + +import org.bukkit.Bukkit; +import org.bukkit.inventory.ItemStack; + +import de.tr7zw.nbtapi.NBT; +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.util.ItemRef; +import net.tfminecraft.tlibs.TLibs; + +/** ItemsAdder looks on blood items, kept safe from ItemsAdder rebuilding them later. */ +final class BloodLooks { + + private static final String COMPOUND = "itemsadder"; + + private BloodLooks() {} + + /** The item wearing the look (a TLibs ia. path); the item itself without one or when it fails. */ + static ItemStack apply(ItemStack item, String look) { + if (look == null) { + return item; + } + try { + ItemStack merged = TLibs.getItemAPI().getArmorMerger().merge(item, Optional.empty(), ItemRef.normalize(look)); + return merged == null || merged.getType().isAir() ? item : merged; + } catch (RuntimeException ex) { + Magic.plugin.getLogger().warning("[Magic] Failed to apply blood look " + look + ": " + ex.getMessage()); + return item; + } + } + + /** + * ItemsAdder rebuilds an item from its definition when that definition changes, which would wipe + * a bound piece's stats and lore. It skips items whose itemsadder compound has override_auto_update. + */ + static void protect(ItemStack item) { + if (!Bukkit.getPluginManager().isPluginEnabled("NBTAPI")) { + return; + } + try { + NBT.modify(item, nbt -> { + if (nbt.hasTag(COMPOUND)) { + nbt.getCompound(COMPOUND).setBoolean("override_auto_update", true); + } + }); + } catch (RuntimeException | LinkageError ex) { + // Without NBTAPI there is no opt-out; the item stays as it is. + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java new file mode 100644 index 0000000..00fee46 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -0,0 +1,148 @@ +package net.tfminecraft.magic.blood; + +import java.io.File; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; + +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.manager.MagicTickService; +import net.tfminecraft.magic.tick.MagicTickContext; + +/** Starts and stops the blood path: data, listener, and the dead-owner sweep. */ +public final class BloodMagic { + + private static final long AMBIENT_SECONDS = 3; + private static final double AMBIENT_RANGE = 24; + + private BloodMagic() {} + + /** Loads data once at enable. Config is loaded separately so a reload never re-reads data. */ + public static void start(Plugin plugin) { + BloodStore store = new BloodStore(new File(plugin.getDataFolder(), "data/bloodmagic"), plugin.getLogger()); + BloodRecords.init(store); + BloodAltars.init(store); + BloodNotes.init(store); + AltarPedestals.init(store); + BondService.init(store); + plugin.getServer().getPluginManager().registerEvents(new BloodListener(), plugin); + if (plugin.getServer().getPluginManager().isPluginEnabled("MMOCore")) { + plugin.getServer().getPluginManager().registerEvents(new MmoClassRewards(), plugin); + } + if (plugin.getServer().getPluginManager().isPluginEnabled("MythicLib")) { + plugin.getServer().getPluginManager().registerEvents(new BloodSpellGate(), plugin); + } + MagicTickService.register(BloodMagic::tick); + CarryService.startTicking(plugin); + BloodCharging.startTicking(plugin); + BloodMeditation.startTicking(plugin); + AltarCandles.startTicking(plugin); + plugin.getServer().getScheduler().runTask(plugin, BloodMagic::settle); + } + + /** + * First tick after enable: purify altars of owners who died while we were down, clear strays from + * chunks that loaded before this plugin did (spawn and force-loaded ones), redraw the rest. + */ + static void settle() { + AltarService.sweepDead(); + for (World world : Bukkit.getWorlds()) { + AltarDisplays.removeStrays(world.getEntitiesByClass(ItemDisplay.class)); + } + for (BloodAltar altar : BloodAltars.all()) { + AltarService.refresh(altar); + } + } + + static void tick(MagicTickContext context) { + if (context.everyMinutes(BloodConfig.purifyCheckMinutes())) { + AltarService.sweepDead(); + } + if (!BloodConfig.isEnabled()) { + // Switched off: rites end without a death, bound animals wake, drags let go. + CharacterRiteService.clear(); + AnimalRiteService.releaseAll(); + return; + } + if (context.every(AMBIENT_SECONDS)) { + ambient(); + } + WhisperService.tick(context); + AnimalRiteService.tick(); + QuestionService.tick(); + CharacterRiteService.tick(); + if (context.everyMinutes(HuntConfig.sampleMinutes())) { + BondService.sample(); + } + if (context.everyMinutes(10)) { + BondService.flush(); + BloodPool.flush(); + for (Player player : Bukkit.getOnlinePlayers()) { + FinalService.check(player); + } + } + if (context.everyMinutes(1)) { + AltarCorruption.ambient(); + BloodPool.tick(); + BloodNotes.tick(System.currentTimeMillis()); + StolenClues.tick(); + } + } + + /** Altars breathe while someone is near: per-level drips, mist and sounds (fx altar_ambient_N). */ + static void ambient() { + for (BloodAltar altar : BloodAltars.all()) { + Location center = altar.center(); + if (center == null || !center.isChunkLoaded()) { + continue; + } + for (Player player : center.getWorld().getPlayers()) { + if (player.getLocation().distanceSquared(center) <= AMBIENT_RANGE * AMBIENT_RANGE) { + BloodFx.play("altar_ambient_" + altar.getLevel(), center); + break; + } + } + } + } + + /** Where the blood path's config files live inside Magic's data folder. */ + public static final String FOLDER = "wildmagic/bloodmagic"; + + public static boolean loadConfig(File dataFolder, Logger log) { + if (new File(dataFolder, "bloodmagic.yml").isFile()) { + log.warning("[Magic] blood magic config is no longer read; the blood path's config lives in " + FOLDER + "/"); + } + File folder = new File(dataFolder, FOLDER); + Messages.overlay(new File(folder, "messages.yml"), FOLDER + "/messages.yml"); + boolean ok = BloodConfig.load(folder, log); + BloodPerms.refreshAll(); + return ok; + } + + public static void stop() { + for (Player player : Bukkit.getOnlinePlayers()) { + BloodBoons.clear(player); + } + BloodPerms.clearAll(); + CarryService.clear(); + BloodMeditation.clear(); + BloodCharging.clear(); + BloodPool.flush(); + CharacterRiteService.clear(); + BondService.clear(); + AnimalRiteService.releaseAll(); + StolenClues.clear(); + BloodNotes.clear(); + AltarPedestals.clear(); + QuestionService.clear(); + AltarCandles.stop(); + WhisperService.clear(); + BloodRecords.clear(); + BloodAltars.clear(); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java b/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java new file mode 100644 index 0000000..2477611 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java @@ -0,0 +1,333 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +import org.bukkit.Bukkit; +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; +import org.bukkit.util.Transformation; +import org.bukkit.util.Vector; +import org.joml.AxisAngle4f; +import org.joml.Vector3f; + +import net.tfminecraft.magic.util.ItemRef; + +/** + * Blood meditation, the wild magic way to draw resonance. The owner sits on their altar among its + * pedestals; the stone's heart beats, and with each beat a drop of blood rises from a charged artefact + * and drifts to the mage. Catching a drop (a swing at it) draws that artefact's charge into blood + * resonance: much on the beat, when every drop swells, and little off it, which also costs the mage + * some of their own blood. Drops left alone fall back. It ends when the mage stands up or walks off, + * when the artefacts are dry, or when resonance is as high as the artefacts allow, and only then is + * the record written. Its music and the heartbeat are heard by the mage alone. + */ +public final class BloodMeditation { + + /** What sitting down to meditate came to. */ + public enum Begin { + STARTED, + NOT_READY, + DRY, + FULL, + BUSY + } + + private static final class Drop { + final String id; + final ItemDisplay display; + final Location from; + final long born; + + Drop(String id, ItemDisplay display, Location from, long born) { + this.id = id; + this.display = display; + this.from = from; + this.born = born; + } + } + + private static final class Session { + final UUID player; + final String character; + final String altarId; + final long started; + final List drops = new ArrayList<>(); + long nextMusic; + + Session(UUID player, String character, String altarId, long started) { + this.player = player; + this.character = character; + this.altarId = altarId; + this.started = started; + } + } + + private static final double LEAVE_DISTANCE = 3; + private static final Map SESSIONS = new HashMap<>(); + private static long ticks; + private static BukkitTask task; + + private BloodMeditation() {} + + static void startTicking(Plugin plugin) { + if (task != null) { + task.cancel(); + } + task = plugin.getServer().getScheduler().runTaskTimer(plugin, BloodMeditation::tick, 1L, 1L); + } + + /** The altar's owner sat down on it: the meditation begins if there is charge to draw. */ + public static Begin start(Player player, BloodAltar altar) { + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); + if (record == null || BloodArtefacts.ids(record).isEmpty()) { + return Begin.NOT_READY; + } + if (SESSIONS.containsKey(player.getUniqueId())) { + return Begin.BUSY; + } + if (record.getResonance() >= BloodPool.cap(record)) { + return Begin.FULL; + } + if (charged(altar, record).isEmpty()) { + return Begin.DRY; + } + Session session = new Session(player.getUniqueId(), record.getCharacterId(), altar.getId(), ticks); + SESSIONS.put(player.getUniqueId(), session); + playMusic(player, session); + return Begin.STARTED; + } + + public static boolean isMeditating(UUID player) { + return SESSIONS.containsKey(player); + } + + /** The altar's artefacts that belong to this character, hold a place now and are charged. */ + static List charged(BloodAltar altar, BloodRecord record) { + List out = new ArrayList<>(); + List ids = BloodArtefacts.ids(record); + for (AltarPedestals.Shown shown : AltarPedestals.artefacts(altar)) { + String id = BloodArtefacts.id(shown.item()); + if (record.getCharacterId().equalsIgnoreCase(BloodItems.owner(shown.item())) && ids.contains(id) + && record.getCharge(id) > 0) { + out.add(shown); + } + } + return out; + } + + /** Every tick: the heart beats, drops rise and drift, and meditations that should end do. */ + static void tick() { + ticks++; + for (Session session : List.copyOf(SESSIONS.values())) { + Player player = Bukkit.getPlayer(session.player); + BloodAltar altar = BloodAltars.byId(session.altarId); + BloodRecord record = BloodRecords.get(session.character); + if (player == null || altar == null || record == null || !stillSitting(player, session, altar)) { + end(session, player, altar, record, "stood"); + continue; + } + long age = ticks - session.started; + int beat = MeditationConfig.beatTicks(); + if (age % beat == 0) { + beat(player, session, altar, record); + } else if (age % beat == 4) { + scale(session, MeditationConfig.dropScale()); + } + drift(player, session); + if (MeditationConfig.music().seconds() > 0 && ticks >= session.nextMusic) { + playMusic(player, session); + } + if (record.getResonance() >= BloodPool.cap(record)) { + end(session, player, altar, record, "full"); + } else if (session.drops.isEmpty() && age % beat == 1 && charged(altar, record).isEmpty()) { + end(session, player, altar, record, "dry"); + } + } + } + + private static boolean stillSitting(Player player, Session session, BloodAltar altar) { + Location center = altar.center(); + return player.getVehicle() != null + && session.character.equalsIgnoreCase(AltarService.currentCharacter(player)) + && center != null && player.getWorld().equals(center.getWorld()) + && player.getLocation().distance(center) <= LEAVE_DISTANCE; + } + + private static void playMusic(Player player, Session session) { + MeditationConfig.music().play(player); + int seconds = MeditationConfig.music().seconds(); + session.nextMusic = seconds > 0 ? ticks + seconds * 20L : Long.MAX_VALUE; + } + + /** A heartbeat: the drops swell, and a new one rises from a charged artefact if there is room. */ + private static void beat(Player player, Session session, BloodAltar altar, BloodRecord record) { + MeditationConfig.heartbeat().play(player); + List charged = session.drops.size() < MeditationConfig.maxDrops() + ? charged(altar, record) : List.of(); + if (charged.isEmpty()) { + scale(session, MeditationConfig.flareScale()); + return; + } + AltarPedestals.Shown source = charged.get(ThreadLocalRandom.current().nextInt(charged.size())); + Location from = source.point(); + ItemDisplay display = from.getWorld().spawn(from, ItemDisplay.class, spawned -> { + spawned.setItemStack(ItemRef.buildOrFallback(MeditationConfig.dropItem(), Material.REDSTONE)); + spawned.setPersistent(false); + spawned.setTeleportDuration(2); + }); + session.drops.add(new Drop(BloodArtefacts.id(source.item()), display, from, ticks)); + scale(session, MeditationConfig.flareScale()); + } + + private static void scale(Session session, float size) { + for (Drop drop : session.drops) { + drop.display.setTransformation(new Transformation(new Vector3f(), new AxisAngle4f(), + new Vector3f(size, size, size), new AxisAngle4f())); + } + } + + /** Where the drops gather: a little in front of the mage's chest. */ + private static Location gather(Player player) { + Location eye = player.getEyeLocation(); + return eye.clone().add(eye.getDirection().setY(0).normalize().multiply(1.4)).add(0, -0.3, 0); + } + + /** Drops drift from their artefact to the mage in an arc, wait there, then fall back. */ + private static void drift(Player player, Session session) { + long travel = (long) MeditationConfig.travelBeats() * MeditationConfig.beatTicks(); + long life = travel + (long) MeditationConfig.lingerBeats() * MeditationConfig.beatTicks(); + Location to = gather(player); + for (Iterator it = session.drops.iterator(); it.hasNext();) { + Drop drop = it.next(); + long age = ticks - drop.born; + if (age >= life) { + it.remove(); + spill(drop.display.getLocation()); + drop.display.remove(); + continue; + } + // Moved every other tick; the client glides between (teleport duration 2). + if (age % 2 == 0) { + double t = Math.min(1, (double) age / travel); + Vector path = to.toVector().subtract(drop.from.toVector()).multiply(t); + Location at = drop.from.clone().add(path).add(0, Math.sin(Math.PI * t) * 0.8, 0); + drop.display.teleport(at); + } + } + } + + private static void spill(Location at) { + at.getWorld().spawnParticle(Particle.DUST, at, 8, 0.1, 0.1, 0.1, 0, + new Particle.DustOptions(Color.fromRGB(0x5c0202), 1.0f)); + } + + /** + * The meditating mage swung: the drop they aimed at, if any, bursts and gives up its artefact's + * charge, more on the beat; off the beat it also takes some of the mage's health (never the last + * point). False when the player is not meditating. + */ + public static boolean swing(Player player) { + Session session = SESSIONS.get(player.getUniqueId()); + if (session == null) { + return false; + } + Drop drop = aimedAt(player, session); + BloodRecord record = BloodRecords.get(session.character); + if (drop == null || record == null) { + return true; + } + long phase = (ticks - session.started) % MeditationConfig.beatTicks(); + boolean onBeat = Math.min(phase, MeditationConfig.beatTicks() - phase) <= MeditationConfig.windowTicks(); + double want = onBeat ? MeditationConfig.onBeatDraw() : MeditationConfig.offBeatDraw(); + double drawn = Math.max(0, Math.min(want, Math.min(record.getCharge(drop.id), + BloodPool.cap(record) - record.getResonance()))); + record.setCharge(drop.id, record.getCharge(drop.id) - drawn); + record.setResonance(record.getResonance() + drawn); + session.drops.remove(drop); + Location at = drop.display.getLocation(); + drop.display.remove(); + at.getWorld().spawnParticle(Particle.DUST, at, onBeat ? 18 : 8, 0.15, 0.15, 0.15, 0, + new Particle.DustOptions(Color.fromRGB(onBeat ? 0xd10202 : 0x5c0202), onBeat ? 1.6f : 1.0f)); + if (onBeat) { + BloodFx.play("meditate_catch", at, player); + } else { + player.setHealth(Math.max(1, player.getHealth() - MeditationConfig.offBeatHealth())); + BloodFx.play("meditate_miss", at, player); + } + return true; + } + + /** The drop nearest the line the player looks along, within reach and close to that line. */ + private static Drop aimedAt(Player player, Session session) { + Location eye = player.getEyeLocation(); + Vector origin = eye.toVector(); + Vector direction = eye.getDirection().normalize(); + Drop best = null; + double bestDistance = Double.MAX_VALUE; + for (Drop drop : session.drops) { + Vector to = drop.display.getLocation().toVector().subtract(origin); + double along = to.dot(direction); + if (along < 0 || along > MeditationConfig.hitRange()) { + continue; + } + double off = to.clone().subtract(direction.clone().multiply(along)).length(); + if (off <= MeditationConfig.hitRadius() && off < bestDistance) { + best = drop; + bestDistance = off; + } + } + return best; + } + + /** Stops the player's meditation, e.g. on quit or a character switch. */ + public static void stop(UUID player) { + Session session = SESSIONS.get(player); + if (session != null) { + end(session, Bukkit.getPlayer(player), BloodAltars.byId(session.altarId), + BloodRecords.get(session.character), null); + } + } + + private static void end(Session session, Player player, BloodAltar altar, BloodRecord record, String why) { + SESSIONS.remove(session.player); + for (Drop drop : session.drops) { + drop.display.remove(); + } + session.drops.clear(); + if (record != null) { + BloodRecords.save(record); + } + if (altar != null && record != null) { + AltarPedestals.restyle(altar, item -> BloodArtefacts.styled(record, item)); + } + if (player != null) { + MeditationConfig.music().stop(player); + if (why != null) { + WhisperService.tell(player, "blood.meditate.end." + why); + } + } + } + + /** Ends every meditation, e.g. on shutdown. */ + static void clear() { + for (Session session : List.copyOf(SESSIONS.values())) { + stop(session.player); + } + if (task != null) { + task.cancel(); + task = null; + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodNotes.java b/src/main/java/net/tfminecraft/magic/blood/BloodNotes.java new file mode 100644 index 0000000..4c3a161 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodNotes.java @@ -0,0 +1,78 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +/** Vague notes owed to players, e.g. an owner whose animal was taken. Kept until delivered. */ +public final class BloodNotes { + + /** For an account, and only its named character when one is set. */ + public record Note(String account, String character, String text, long deliverAfter) {} + + private static final List NOTES = new ArrayList<>(); + private static BloodStore store; + + private BloodNotes() {} + + public static void init(BloodStore source) { + NOTES.clear(); + store = source; + NOTES.addAll(source.loadNotes()); + } + + public static void clear() { + NOTES.clear(); + store = null; + } + + public static void add(Note note) { + NOTES.add(note); + save(); + } + + public static List all() { + return new ArrayList<>(NOTES); + } + + /** Hands the player every note owed to them; ignoreDelay on a fresh login. */ + public static void deliver(Player player, String character, long now, boolean ignoreDelay) { + boolean changed = false; + String account = player.getUniqueId().toString(); + for (Iterator it = NOTES.iterator(); it.hasNext();) { + Note note = it.next(); + boolean mine = note.account().equalsIgnoreCase(account) + && (note.character() == null || note.character().equalsIgnoreCase(character)); + if (mine && (ignoreDelay || now >= note.deliverAfter())) { + player.sendMessage(note.text()); + it.remove(); + changed = true; + } + } + if (changed) { + save(); + } + } + + /** Minute tick: notes whose delay ran out reach owners who are online. */ + static void tick(long now) { + if (NOTES.isEmpty()) { + return; + } + for (Player player : Bukkit.getOnlinePlayers()) { + String character = AltarService.currentCharacter(player); + if (character != null) { + deliver(player, character, now, false); + } + } + } + + private static void save() { + if (store != null) { + store.saveNotes(new ArrayList<>(NOTES)); + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodPath.java b/src/main/java/net/tfminecraft/magic/blood/BloodPath.java new file mode 100644 index 0000000..edf9a67 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodPath.java @@ -0,0 +1,23 @@ +package net.tfminecraft.magic.blood; + +import java.util.Locale; + +/** The answer to the god's question. NONE until it is asked and answered. */ +public enum BloodPath { + NONE, + /** Gave themselves to the god: sacrifices feed the god, so less power returns. */ + DEVOTEE, + /** Refused the god: sacrifices feed the mage alone. */ + TRUE; + + public static BloodPath parse(String raw) { + if (raw == null) { + return null; + } + try { + return valueOf(raw.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + return null; + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodPerms.java b/src/main/java/net/tfminecraft/magic/blood/BloodPerms.java new file mode 100644 index 0000000..f7305ca --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodPerms.java @@ -0,0 +1,69 @@ +package net.tfminecraft.magic.blood; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.permissions.PermissionAttachment; + +import net.tfminecraft.magic.Magic; + +/** + * The permissions a path's character holds while it is played (permissions: in the wildmagic/bloodmagic config), e.g. + * to see that path's ArmourShop skins. It follows the character, not the account: switching to another + * character takes it away. + */ +public final class BloodPerms { + + private static final Map ATTACHED = new HashMap<>(); + + private BloodPerms() {} + + /** Gives the player their current character's path permissions, replacing any earlier ones. */ + public static void refresh(Player player) { + clear(player); + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); + List permissions = record != null ? ChoiceConfig.permissionsFor(record.getPath()) : List.of(); + if (permissions.isEmpty()) { + return; + } + PermissionAttachment attachment = player.addAttachment(Magic.plugin); + for (String permission : permissions) { + attachment.setPermission(permission, true); + } + ATTACHED.put(player.getUniqueId(), attachment); + } + + /** Takes the path permission away, e.g. when the player leaves or switches character. */ + public static void clear(Player player) { + PermissionAttachment attachment = ATTACHED.remove(player.getUniqueId()); + if (attachment != null) { + try { + player.removeAttachment(attachment); + } catch (IllegalArgumentException ex) { + // Already gone with the player's old session. + } + } + } + + /** After a config reload: every online player's permission from the new names. */ + public static void refreshAll() { + for (Player player : Bukkit.getOnlinePlayers()) { + refresh(player); + } + } + + public static void clearAll() { + for (Player player : Bukkit.getOnlinePlayers()) { + clear(player); + } + ATTACHED.clear(); + } + + static boolean holds(UUID player) { + return ATTACHED.containsKey(player); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodPool.java b/src/main/java/net/tfminecraft/magic/blood/BloodPool.java new file mode 100644 index 0000000..2d901c1 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodPool.java @@ -0,0 +1,115 @@ +package net.tfminecraft.magic.blood; + +import java.util.LinkedHashSet; +import java.util.Set; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +/** + * The altar's pool of blood and the blood resonance it feeds. Artefacts charge in the pool, and + * meditating among the charged ones draws their charge into blood resonance, up to what the path's + * artefacts are worth. While the path goes on, charging is free and resonance keeps. Once the path is + * complete, charging costs pool blood, resonance fades by the hour the character is played, and only + * people sacrificed on the altar fill the pool again. Resonance never sinks below the floor, so the + * lowest spells always work. + */ +public final class BloodPool { + + /** Records whose resonance faded since they were last written; saved with {@link #flush}. */ + private static final Set FADED = new LinkedHashSet<>(); + + private BloodPool() {} + + /** The pool's blood: full until the path is complete, and full when first measured after that. */ + static double level(BloodRecord record) { + Double pool = record.getStage() == BloodStage.ASCENDED ? record.getPool() : null; + return pool != null ? Math.min(pool, PoolConfig.capacity()) : PoolConfig.capacity(); + } + + /** The most blood resonance the path's artefacts can give now. */ + static double cap(BloodRecord record) { + return BloodArtefacts.maxCharge(record) * BloodArtefacts.ids(record).size(); + } + + /** The character's blood resonance now, never below the floor; the floor without a path's class. */ + public static double resonance(BloodRecord record) { + if (record == null) { + return PoolConfig.floor(); + } + return Math.max(PoolConfig.floor(), Math.min(record.getResonance(), cap(record))); + } + + /** How the pool looks to its owner: full, half, low or dry. */ + static String state(BloodRecord record) { + double level = level(record); + double capacity = PoolConfig.capacity(); + if (level <= 0) { + return "dry"; + } + return level >= capacity * 0.75 ? "full" : level >= capacity * 0.25 ? "half" : "low"; + } + + /** + * Takes the blood for this much charge from the pool and returns the charge it paid for: all of it + * while the path goes on, else as much as the pool holds. + */ + static double pay(BloodRecord record, double charge) { + double price = PoolConfig.bloodPerPoint(); + if (record.getStage() != BloodStage.ASCENDED || price <= 0) { + return charge; + } + double level = level(record); + double paid = Math.min(charge, level / price); + record.setPool(level - paid * price); + return paid; + } + + /** + * Every minute: mages online whose path is complete lose a share of their blood resonance (never + * below the floor) and hear it when a tier slips away. + */ + static void tick() { + double share = PoolConfig.decayPercent() / 100.0 / 60.0; + if (share <= 0) { + return; + } + for (Player player : Bukkit.getOnlinePlayers()) { + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); + if (record == null || record.getStage() != BloodStage.ASCENDED + || record.getResonance() <= PoolConfig.floor()) { + continue; + } + int before = PoolConfig.band(resonance(record)); + record.setResonance(Math.max(PoolConfig.floor(), record.getResonance() * (1 - share))); + if (PoolConfig.band(resonance(record)) < before) { + FADED.remove(record); + BloodRecords.save(record); + WhisperService.tell(player, "blood.pool.thinned"); + } else { + FADED.add(record); + } + } + } + + /** + * Writes the records whose resonance only faded a little since they were saved (every ten minutes + * and on shutdown, so the minute tick never touches the disk unless a tier slipped). + */ + static void flush() { + for (BloodRecord record : FADED) { + // A record reset or forgotten since is not written back. + if (BloodRecords.get(record.getCharacterId()) == record) { + BloodRecords.save(record); + } + } + FADED.clear(); + } + + /** A person died on the altar after the path was complete: their blood fills the pool by closeness. */ + static void fedPerson(Player mage, BloodRecord record, int rank) { + record.setPool(Math.min(PoolConfig.capacity(), level(record) + PoolConfig.refill(rank))); + BloodRecords.save(record); + WhisperService.tell(mage, "blood.pool.fed"); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodProgress.java b/src/main/java/net/tfminecraft/magic/blood/BloodProgress.java new file mode 100644 index 0000000..f3cd8f9 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodProgress.java @@ -0,0 +1,30 @@ +package net.tfminecraft.magic.blood; + +/** How far along a character is, as one number the altar's look follows. */ +public final class BloodProgress { + + private BloodProgress() {} + + /** + * One per animal species, one for answering the god, one per hunt point (devotees count + * the whole hunt, which their answer skips) and two per final sacrifice. + */ + public static int of(BloodRecord record) { + if (record == null) { + return 0; + } + int progress = record.getSpecies().size(); + if (record.getPath() != BloodPath.NONE) { + progress += 1; + } + progress += record.getPath() == BloodPath.DEVOTEE + ? BloodConfig.huntPointsRequired() + : Math.min(record.getHuntPoints(), BloodConfig.huntPointsRequired()); + progress += 2 * record.getFinalsDone(); + return progress; + } + + public static int level(BloodRecord record) { + return BloodConfig.level(of(record)); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java b/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java new file mode 100644 index 0000000..fb5b624 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java @@ -0,0 +1,293 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** One character's blood path, saved as data/bloodmagic/characters/<characterId>.json. */ +public final class BloodRecord { + + private String characterId; + private String ownerUuid; + private BloodStage stage = BloodStage.NONE; + private BloodPath path = BloodPath.NONE; + /** Hidden whisper meter, 0 and up. */ + private double whisper; + /** Index of the next voice line for the current stage. */ + private int whisperLine; + /** Animal species already sacrificed, lower-case entity type keys. */ + private List species = new ArrayList<>(); + private long lastAnimalAt; + private String altarId; + private int huntPoints; + private int finalsDone; + /** Account of the last character sacrificed, so the same player is never taken twice in a row. */ + private String lastVictimAccount; + /** Every character sacrificed at the altar, oldest first. */ + private List sacrifices = new ArrayList<>(); + /** Final targets still to come, least close first; the first is the one named now. */ + private List finalTargets = new ArrayList<>(); + /** No final target is named before this time (after a failed sacrifice). */ + private long nextFinalAt; + /** A failed sacrifice whose backlash waits for the mage to come online. */ + private boolean pendingBacklash; + /** The path's artefacts handed out so far, one id per place (see {@link BloodArtefacts}). */ + private List artefacts = new ArrayList<>(); + /** Charge each artefact holds (by id), filled in the altar's pool and drawn by meditation. */ + private Map charges = new HashMap<>(); + /** Artefacts charging in the pool, given back once done (also after a restart). */ + private List pending = new ArrayList<>(); + /** Blood resonance, drawn from charged artefacts in meditation. */ + private double resonance; + /** Blood in the altar's pool once the path is complete; null until then (a full pool). */ + private Double pool; + + /** + * One character sacrificed at the altar. rank: the victim's closeness rank when taken (1 = closest), + * 0 for a stranger or a record from before ranks were kept. + */ + public record Sacrifice(String character, String account, String stage, int points, long at, int rank) { + public Sacrifice(String character, String account, String stage, int points, long at) { + this(character, account, stage, points, at, 0); + } + } + + public BloodRecord() {} + + public static BloodRecord create(String characterId, String ownerUuid) { + BloodRecord record = new BloodRecord(); + record.characterId = characterId; + record.ownerUuid = ownerUuid; + return record; + } + + /** Repairs fields a hand edit or an older file may have left empty. */ + public void normalize() { + if (stage == null) { + stage = BloodStage.NONE; + } + if (path == null) { + path = BloodPath.NONE; + } + if (species == null) { + species = new ArrayList<>(); + } + if (sacrifices == null) { + sacrifices = new ArrayList<>(); + } + if (finalTargets == null) { + finalTargets = new ArrayList<>(); + } + if (artefacts == null) { + artefacts = new ArrayList<>(); + } + if (charges == null) { + charges = new HashMap<>(); + } + charges.values().removeIf(charge -> charge == null || charge.isNaN() || charge <= 0); + if (pending == null) { + pending = new ArrayList<>(); + } + if (resonance < 0 || Double.isNaN(resonance)) { + resonance = 0; + } + if (pool != null && (pool.isNaN() || pool < 0)) { + pool = 0.0; + } + if (whisper < 0 || Double.isNaN(whisper)) { + whisper = 0; + } + whisperLine = Math.max(0, whisperLine); + huntPoints = Math.max(0, huntPoints); + finalsDone = Math.max(0, finalsDone); + if (altarId != null && altarId.isBlank()) { + altarId = null; + } + } + + public String getCharacterId() { + return characterId; + } + + public void setCharacterId(String characterId) { + this.characterId = characterId; + } + + public String getOwnerUuid() { + return ownerUuid; + } + + public BloodStage getStage() { + return stage; + } + + /** Moves to a stage and restarts its voice lines. */ + public void setStage(BloodStage stage) { + if (this.stage != stage) { + whisperLine = 0; + } + this.stage = stage; + } + + public BloodPath getPath() { + return path; + } + + public void setPath(BloodPath path) { + this.path = path; + } + + public double getWhisper() { + return whisper; + } + + public void setWhisper(double whisper) { + this.whisper = Math.max(0, whisper); + } + + public int getWhisperLine() { + return whisperLine; + } + + public void setWhisperLine(int whisperLine) { + this.whisperLine = Math.max(0, whisperLine); + } + + public List getSpecies() { + return species; + } + + public boolean hasSpecies(String key) { + return key != null && species.contains(key.toLowerCase(Locale.ROOT)); + } + + public void addSpecies(String key) { + if (key != null && !hasSpecies(key)) { + species.add(key.toLowerCase(Locale.ROOT)); + } + } + + public long getLastAnimalAt() { + return lastAnimalAt; + } + + public void setLastAnimalAt(long lastAnimalAt) { + this.lastAnimalAt = lastAnimalAt; + } + + public String getAltarId() { + return altarId; + } + + public void setAltarId(String altarId) { + this.altarId = altarId; + } + + public int getHuntPoints() { + return huntPoints; + } + + public void setHuntPoints(int huntPoints) { + this.huntPoints = Math.max(0, huntPoints); + } + + public int getFinalsDone() { + return finalsDone; + } + + public void setFinalsDone(int finalsDone) { + this.finalsDone = Math.max(0, finalsDone); + } + + public String getLastVictimAccount() { + return lastVictimAccount; + } + + public void setLastVictimAccount(String lastVictimAccount) { + this.lastVictimAccount = lastVictimAccount; + } + + public List getSacrifices() { + return sacrifices; + } + + public List getFinalTargets() { + return finalTargets; + } + + /** The final target named now, or null. */ + public String currentTarget() { + return finalTargets.isEmpty() ? null : finalTargets.get(0); + } + + public long getNextFinalAt() { + return nextFinalAt; + } + + public void setNextFinalAt(long nextFinalAt) { + this.nextFinalAt = nextFinalAt; + } + + public boolean isPendingBacklash() { + return pendingBacklash; + } + + public void setPendingBacklash(boolean pendingBacklash) { + this.pendingBacklash = pendingBacklash; + } + + public List getArtefacts() { + return artefacts; + } + + /** The charge an artefact holds; 0 for none. */ + public double getCharge(String id) { + return charges.getOrDefault(id, 0.0); + } + + public void setCharge(String id, double charge) { + if (charge > 0) { + charges.put(id, charge); + } else { + charges.remove(id); + } + } + + /** Drops the charge of every artefact but these. */ + public void keepCharges(Collection ids) { + charges.keySet().retainAll(ids); + } + + public List getPending() { + return pending; + } + + public double getResonance() { + return resonance; + } + + public void setResonance(double resonance) { + this.resonance = Math.max(0, resonance); + } + + /** The pool's blood, or null while it was never measured (full). */ + public Double getPool() { + return pool; + } + + public void setPool(Double pool) { + this.pool = pool == null ? null : Math.max(0, pool); + } + + /** True when the record carries nothing worth keeping on disk. */ + public boolean isEmpty() { + return stage == BloodStage.NONE && path == BloodPath.NONE && whisper <= 0 + && species.isEmpty() && altarId == null && huntPoints == 0 && finalsDone == 0 + && sacrifices.isEmpty() && finalTargets.isEmpty() && !pendingBacklash + && artefacts.isEmpty() && charges.isEmpty() && pending.isEmpty() && resonance <= 0 + && pool == null; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodRecords.java b/src/main/java/net/tfminecraft/magic/blood/BloodRecords.java new file mode 100644 index 0000000..a0e7ad9 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodRecords.java @@ -0,0 +1,64 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** Every blood record, kept in memory and written through on each change. */ +public final class BloodRecords { + + private static final Map RECORDS = new HashMap<>(); + private static BloodStore store; + + private BloodRecords() {} + + public static void init(BloodStore source) { + RECORDS.clear(); + store = source; + for (BloodRecord record : source.loadRecords()) { + RECORDS.put(key(record.getCharacterId()), record); + } + } + + public static void clear() { + RECORDS.clear(); + store = null; + } + + public static BloodRecord get(String characterId) { + return characterId != null ? RECORDS.get(key(characterId)) : null; + } + + /** The character's record, created in memory when it has none yet. */ + public static BloodRecord getOrCreate(String characterId, String ownerUuid) { + return RECORDS.computeIfAbsent(key(characterId), id -> BloodRecord.create(characterId, ownerUuid)); + } + + /** Writes the record; an empty record leaves memory and disk. */ + public static boolean save(BloodRecord record) { + if (record.isEmpty()) { + RECORDS.remove(key(record.getCharacterId())); + } + return store == null || store.save(record); + } + + /** Forgets the character's whole blood path. */ + public static boolean delete(String characterId) { + BloodRecord record = RECORDS.remove(key(characterId)); + if (record == null) { + return true; + } + BloodRecord empty = BloodRecord.create(record.getCharacterId(), record.getOwnerUuid()); + return store == null || store.save(empty); + } + + public static List all() { + return new ArrayList<>(RECORDS.values()); + } + + private static String key(String characterId) { + return characterId.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodSpellGate.java b/src/main/java/net/tfminecraft/magic/blood/BloodSpellGate.java new file mode 100644 index 0000000..39260dd --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodSpellGate.java @@ -0,0 +1,58 @@ +package net.tfminecraft.magic.blood; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.LongSupplier; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import io.lumine.mythic.lib.api.event.skill.PlayerCastSkillEvent; +import io.lumine.mythic.lib.skill.Skill; +import net.tfminecraft.magic.Messages; + +/** + * Blood spells (pool.spells) only work while the caster's blood resonance reaches their tier. A + * refused spell is cancelled before it costs health or starts its cooldown. Characters without a + * blood path are never refused, so staff tests of the classes keep working. Registered only with + * MythicLib. + */ +public final class BloodSpellGate implements Listener { + + private static final long SAY_EVERY_MILLIS = 3000; + private static final Map SAID = new HashMap<>(); + static LongSupplier clock = System::currentTimeMillis; + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onCast(PlayerCastSkillEvent event) { + Player player = event.getPlayer(); + int tier = PoolConfig.tierOf(spell(event.getCast())); + if (tier <= 0 || player == null || !BloodConfig.isEnabled()) { + return; + } + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); + if (record == null || record.getPath() == BloodPath.NONE + || BloodPool.resonance(record) >= PoolConfig.need(tier)) { + return; + } + event.setCancelled(true); + long now = clock.getAsLong(); + Long last = SAID.get(player.getUniqueId()); + if (last == null || now - last >= SAY_EVERY_MILLIS) { + SAID.put(player.getUniqueId(), now); + player.sendMessage(Messages.get("blood.pool.too_thin")); + } + } + + /** The cast's MythicLib skill id, or null. */ + static String spell(Skill cast) { + return cast != null && cast.getHandler() != null ? cast.getHandler().getId() : null; + } + + static void forget(UUID player) { + SAID.remove(player); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodStage.java b/src/main/java/net/tfminecraft/magic/blood/BloodStage.java new file mode 100644 index 0000000..2688ddb --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodStage.java @@ -0,0 +1,37 @@ +package net.tfminecraft.magic.blood; + +import java.util.Locale; + +/** Where a character stands on the blood path. Order matters: later stages compare greater. */ +public enum BloodStage { + /** No voices yet. The whisper meter may still be filling. */ + NONE, + /** The voices speak and teach; they end by handing over the knife and the altar. */ + WHISPERED, + /** Animal sacrifices at the altar. */ + ANIMAL, + /** The blood knife is given and the god waits for an answer. */ + CHOICE, + /** True path only: character sacrifices for hunt points. */ + HUNT, + /** Named sacrifices of the closest characters, both paths. */ + FINAL, + /** Every final sacrifice is done. */ + ASCENDED; + + public boolean atLeast(BloodStage other) { + return compareTo(other) >= 0; + } + + /** Case-insensitive lookup; null when the name is unknown. */ + public static BloodStage parse(String raw) { + if (raw == null) { + return null; + } + try { + return valueOf(raw.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + return null; + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodStore.java b/src/main/java/net/tfminecraft/magic/blood/BloodStore.java new file mode 100644 index 0000000..028da1d --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodStore.java @@ -0,0 +1,208 @@ +package net.tfminecraft.magic.blood; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; + +/** + * Blood path files: one JSON per character plus one altars file. Both stay small, are read + * once at startup and written whole through a temp file, so a click never re-reads disk. + * A file that cannot be read is moved aside, never overwritten. + */ +public final class BloodStore { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Type ALTAR_LIST = new TypeToken>() {}.getType(); + private static final Type NOTE_LIST = new TypeToken>() {}.getType(); + private static final Type STRING_LIST = new TypeToken>() {}.getType(); + private static final Type BOND_MAP = new TypeToken>() {}.getType(); + + private final File characters; + private final File altars; + private final File notes; + private final File bonds; + private final File leftovers; + private final Logger log; + + public BloodStore(File root, Logger log) { + this.characters = new File(root, "characters"); + this.altars = new File(root, "altars.json"); + this.notes = new File(root, "notes.json"); + this.bonds = new File(root, "bonds"); + this.leftovers = new File(root, "leftover_pedestals.json"); + this.log = log; + } + + public List loadRecords() { + List out = new ArrayList<>(); + File[] files = characters.listFiles((dir, name) -> name.endsWith(".json")); + if (files == null) { + return out; + } + for (File file : files) { + BloodRecord record = read(file, BloodRecord.class); + if (record == null) { + continue; + } + if (record.getCharacterId() == null || record.getCharacterId().isBlank()) { + record.setCharacterId(file.getName().substring(0, file.getName().length() - 5)); + } + record.normalize(); + out.add(record); + } + return out; + } + + /** Writes the record, or deletes its file when nothing in it is worth keeping. */ + public boolean save(BloodRecord record) { + File file = new File(characters, record.getCharacterId() + ".json"); + if (record.isEmpty()) { + try { + Files.deleteIfExists(file.toPath()); + return true; + } catch (IOException ex) { + log.severe("[Magic] Failed to delete blood record " + file.getName() + ": " + ex.getMessage()); + return false; + } + } + return write(file, GSON.toJson(record)); + } + + public List loadAltars() { + List out = new ArrayList<>(); + if (!altars.isFile()) { + return out; + } + List loaded = read(altars, ALTAR_LIST); + if (loaded == null) { + return out; + } + for (BloodAltar altar : loaded) { + if (altar != null && altar.isValid()) { + out.add(altar); + } else { + log.warning("[Magic] Skipped an incomplete altar in altars.json"); + } + } + return out; + } + + public boolean saveAltars(List list) { + return write(altars, GSON.toJson(list, ALTAR_LIST)); + } + + public List loadNotes() { + List out = new ArrayList<>(); + if (!notes.isFile()) { + return out; + } + List loaded = read(notes, NOTE_LIST); + if (loaded != null) { + for (BloodNotes.Note note : loaded) { + if (note != null && note.account() != null && note.text() != null) { + out.add(note); + } + } + } + return out; + } + + public boolean saveNotes(List list) { + return write(notes, GSON.toJson(list, NOTE_LIST)); + } + + /** Furniture ids of purified altars' pedestals that still wait to be removed. */ + public List loadLeftovers() { + List out = new ArrayList<>(); + if (!leftovers.isFile()) { + return out; + } + List loaded = read(leftovers, STRING_LIST); + if (loaded != null) { + for (String id : loaded) { + if (id != null && !id.isBlank()) { + out.add(id); + } + } + } + return out; + } + + public boolean saveLeftovers(java.util.Collection ids) { + return write(leftovers, GSON.toJson(new ArrayList<>(ids), STRING_LIST)); + } + + /** Minutes the character spent near each other character; empty when there is no file yet. */ + public Map loadBonds(String characterId) { + File file = new File(bonds, characterId + ".json"); + Map out = new HashMap<>(); + if (!file.isFile()) { + return out; + } + Map loaded = read(file, BOND_MAP); + if (loaded != null) { + loaded.forEach((other, minutes) -> { + if (minutes != null && minutes > 0) { + out.put(other, minutes); + } + }); + } + return out; + } + + void warn(String message) { + log.warning(message); + } + + public boolean saveBonds(String characterId, Map minutes) { + return write(new File(bonds, characterId + ".json"), GSON.toJson(minutes, BOND_MAP)); + } + + private T read(File file, Type type) { + try { + T value = GSON.fromJson(Files.readString(file.toPath(), StandardCharsets.UTF_8), type); + if (value == null) { + throw new IOException("empty file"); + } + return value; + } catch (IOException | RuntimeException ex) { + log.severe("[Magic] Failed to read " + file.getName() + ": " + ex.getMessage()); + File rejected = new File(file.getParentFile(), file.getName() + ".rejected-" + UUID.randomUUID()); + try { + Files.move(file.toPath(), rejected.toPath()); + log.warning("[Magic] Preserved unreadable blood file at " + rejected.getName()); + } catch (IOException moveFailure) { + log.severe("[Magic] Could not preserve " + file.getName() + ": " + moveFailure.getMessage()); + } + return null; + } + } + + private boolean write(File file, String json) { + Path target = file.toPath(); + try { + Files.createDirectories(target.toAbsolutePath().getParent()); + Path temp = target.resolveSibling(file.getName() + ".tmp"); + Files.writeString(temp, json, StandardCharsets.UTF_8); + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + return true; + } catch (IOException ex) { + log.severe("[Magic] Failed to write " + file.getName() + ": " + ex.getMessage()); + return false; + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BondService.java b/src/main/java/net/tfminecraft/magic/blood/BondService.java new file mode 100644 index 0000000..5581712 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BondService.java @@ -0,0 +1,205 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.magic.integration.RpCharactersBridge; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; + +/** + * How much other characters matter to a character, never shown to players. Built from RP + * conversations (RPCharacters), time spent near each other (sampled here for every character, + * so history exists before anyone becomes a mage) and a will heir who is another player's + * character the two really spent time with. Characters of the same account never count. + */ +public final class BondService { + + /** Another character and how much they matter. */ + public record Ranked(String character, double score) {} + + private static final Map> MINUTES = new HashMap<>(); + private static final Set DIRTY = new HashSet<>(); + private static BloodStore store; + /** + * The periodic flush writes every changed character's file, which on a full server is dozens at + * once: they go to this thread, in order, so the main thread never waits on the disk (tests run + * them in place). + */ + static final Executor BACKGROUND = Executors.newSingleThreadExecutor(task -> { + Thread thread = new Thread(task, "Magic blood bonds"); + thread.setDaemon(true); + return thread; + }); + static Executor writer = BACKGROUND; + private static CompletableFuture writing = CompletableFuture.completedFuture(null); + + private BondService() {} + + public static void init(BloodStore source) { + MINUTES.clear(); + DIRTY.clear(); + store = source; + } + + /** Writes pending changes, waits for them, and forgets everything. */ + public static void clear() { + flush(); + writing.join(); + MINUTES.clear(); + store = null; + } + + private static Map of(String character) { + return MINUTES.computeIfAbsent(character.toLowerCase(Locale.ROOT), + id -> store != null ? store.loadBonds(id) : new HashMap<>()); + } + + /** Loads a character's bonds while it is played. */ + public static void load(String character) { + of(character); + } + + /** Saves and drops a character's bonds when it stops being played. */ + public static void unload(String character) { + String key = character.toLowerCase(Locale.ROOT); + if (DIRTY.remove(key) && store != null) { + store.saveBonds(key, MINUTES.get(key)); + } + MINUTES.remove(key); + } + + public static int minutes(String character, String other) { + return of(character).getOrDefault(other.toLowerCase(Locale.ROOT), 0); + } + + static void add(String character, String other, int minutes) { + String key = character.toLowerCase(Locale.ROOT); + of(character).merge(other.toLowerCase(Locale.ROOT), minutes, Integer::sum); + DIRTY.add(key); + } + + /** Every sample: characters near each other each gain the sample's minutes with the other. */ + static void sample() { + List players = new ArrayList<>(); + List characters = new ArrayList<>(); + for (Player player : Bukkit.getOnlinePlayers()) { + String character = AltarService.currentCharacter(player); + if (character != null) { + players.add(player); + characters.add(character); + } + } + double radius = HuntConfig.nearRadius(); + int minutes = HuntConfig.sampleMinutes(); + for (int i = 0; i < players.size(); i++) { + for (int j = i + 1; j < players.size(); j++) { + Player a = players.get(i); + Player b = players.get(j); + if (a.getWorld() == b.getWorld() && a.getLocation().distanceSquared(b.getLocation()) <= radius * radius) { + add(characters.get(i), characters.get(j), minutes); + add(characters.get(j), characters.get(i), minutes); + } + } + } + } + + /** Writes every changed character's bonds, off the main thread (copies taken now). */ + public static void flush() { + BloodStore target = store; + if (target != null) { + for (String key : DIRTY) { + Map copy = new HashMap<>(MINUTES.get(key)); + writing = writing.thenRunAsync(() -> target.saveBonds(key, copy), writer) + .exceptionally(failure -> { + target.warn("[Magic] Failed to write the bonds of " + key + ": " + failure.getMessage()); + return null; + }); + } + } + DIRTY.clear(); + } + + /** Living characters of other accounts the mage has any bond with, closest first. */ + public static List ranking(Player mage) { + String me = AltarService.currentCharacter(mage); + List out = new ArrayList<>(); + if (me == null) { + return out; + } + Map conversations = RpCharactersBridge.conversationCounts(mage); + Map near = of(me); + String heir = RpCharactersBridge.willHeir(mage); + Set candidates = new LinkedHashSet<>(); + conversations.keySet().forEach(id -> candidates.add(id.toLowerCase(Locale.ROOT))); + candidates.addAll(near.keySet()); + if (heir != null) { + candidates.add(heir.toLowerCase(Locale.ROOT)); + } + candidates.remove(me.toLowerCase(Locale.ROOT)); + for (String other : candidates) { + if (!livingOtherAccount(other, mage.getUniqueId())) { + continue; + } + int talks = count(conversations, other); + double hours = near.getOrDefault(other, 0) / 60.0; + double score = HuntConfig.conversationWeight() * talks + HuntConfig.hourWeight() * hours; + if (heir != null && heir.equalsIgnoreCase(other) + && (hours >= HuntConfig.heirMinHours() || talks >= HuntConfig.heirMinConversations())) { + score += HuntConfig.heirBonus(); + } + if (score > 0) { + out.add(new Ranked(other, score)); + } + } + out.sort(Comparator.comparingDouble(Ranked::score).reversed().thenComparing(Ranked::character)); + return out; + } + + private static int count(Map conversations, String other) { + for (Map.Entry entry : conversations.entrySet()) { + if (entry.getKey().equalsIgnoreCase(other)) { + return entry.getValue(); + } + } + return 0; + } + + /** 1-based closeness rank of the other character for the mage, or 0 when they share no bond. */ + public static int rank(Player mage, String other) { + List ranking = ranking(mage); + for (int i = 0; i < ranking.size(); i++) { + if (ranking.get(i).character().equalsIgnoreCase(other)) { + return i + 1; + } + } + return 0; + } + + static boolean livingOtherAccount(String character, UUID account) { + Optional info; + try { + info = Characters.info(UUID.fromString(character)); + } catch (IllegalArgumentException ex) { + return false; + } + return info.isPresent() && info.get().status() == CharacterStatus.ALIVE + && !info.get().ref().ownerId().equals(account); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/CarryService.java b/src/main/java/net/tfminecraft/magic/blood/CarryService.java new file mode 100644 index 0000000..65611aa --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/CarryService.java @@ -0,0 +1,231 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; +import org.bukkit.scheduler.BukkitTask; +import java.util.EnumSet; +import java.util.Set; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.data.Waterlogged; + +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.integration.RpCharactersBridge; + +/** + * Dragging a knocked-out character to the altar (hunt and finals). Sneak and right-click them with + * your blood knife: the body trails behind you along the path you walked. Right-click the altar or + * the body with the knife to lay it on the altar, sneak and right-click the body to let go. The drag + * breaks when either of you leaves or switches character, the body wakes up or you go down. + */ +public final class CarryService { + + private static final class Carry { + private final UUID victim; + private final String mageCharacter; + private final String victimCharacter; + private final Deque trail = new ArrayDeque<>(); + private int steps; + + private Carry(UUID victim, String mageCharacter, String victimCharacter) { + this.victim = victim; + this.mageCharacter = mageCharacter; + this.victimCharacter = victimCharacter; + } + } + + private static final Map CARRIES = new HashMap<>(); + private static final int TRAIL_LIMIT = 40; + private static final double STEP = 0.3; + private static final int DRAG_FX_EVERY = 3; + private static final Set UNSAFE = EnumSet.of(Material.WATER, Material.LAVA, Material.FIRE, + Material.SOUL_FIRE, Material.POWDER_SNOW, Material.CAMPFIRE, Material.SOUL_CAMPFIRE, Material.SWEET_BERRY_BUSH, + Material.CACTUS, Material.WITHER_ROSE, Material.KELP, Material.KELP_PLANT, Material.SEAGRASS, + Material.TALL_SEAGRASS, Material.BUBBLE_COLUMN); + private static final Set HOT = EnumSet.of(Material.LAVA, Material.MAGMA_BLOCK, Material.CAMPFIRE, + Material.SOUL_CAMPFIRE, Material.FIRE, Material.SOUL_FIRE); + /** Trail points are the mage's feet; on slabs, paths or soul sand those lie inside the ground block. */ + private static final double BODY_HEIGHT = 0.6; + private static BukkitTask task; + + private CarryService() {} + + /** The body follows every other tick; the one-second blood tick is too coarse for walking. */ + static void startTicking(Plugin plugin) { + if (task != null) { + task.cancel(); + } + task = plugin.getServer().getScheduler().runTaskTimer(plugin, CarryService::tick, 2L, 2L); + } + + /** Takes hold of a knocked-out character. Answers with the rite's bind results. */ + public static CharacterRiteService.Bind start(Player mage, Player victim) { + String character = AltarService.currentCharacter(mage); + CharacterRiteService.Bind ready = CharacterRiteService.readiness(mage, character, + BloodRecords.get(character)); + if (ready != null) { + return ready; + } + String victimCharacter = AltarService.currentCharacter(victim); + if (victimCharacter == null) { + return CharacterRiteService.Bind.NO_CHARACTER; + } + if (!RpCharactersBridge.isDown(victim)) { + return CharacterRiteService.Bind.NOT_DOWN; + } + // Only someone the stone would take may be dragged: no hauling bystanders into traps. + CharacterRiteService.Bind eligible = CharacterRiteService.eligibility(mage, BloodRecords.get(character), + victim, victimCharacter); + if (eligible != null) { + return eligible; + } + if (CARRIES.containsKey(mage.getUniqueId()) || carrier(victim.getUniqueId()) != null + || CharacterRiteService.isVictim(victim.getUniqueId())) { + return CharacterRiteService.Bind.BUSY; + } + CARRIES.put(mage.getUniqueId(), new Carry(victim.getUniqueId(), character, victimCharacter)); + WhisperService.tell(mage, "blood.carry.start"); + BloodFx.play("carry_start", victim.getLocation(), victim); + victim.sendMessage(Messages.get("blood.carry.dragged")); + return CharacterRiteService.Bind.OK; + } + + /** The body this mage drags, if it is online. */ + public static Player carried(Player mage) { + Carry carry = CARRIES.get(mage.getUniqueId()); + return carry != null ? Bukkit.getPlayer(carry.victim) : null; + } + + /** The mage dragging this player, or null. */ + static UUID carrier(UUID victim) { + for (Map.Entry entry : CARRIES.entrySet()) { + if (entry.getValue().victim.equals(victim)) { + return entry.getKey(); + } + } + return null; + } + + /** Lets go of the body; told tells the mage. */ + public static void release(Player mage, boolean told) { + Carry carry = CARRIES.remove(mage.getUniqueId()); + if (carry != null && told) { + mage.sendMessage(Messages.get("blood.carry.released")); + Player body = Bukkit.getPlayer(carry.victim); + BloodFx.play("carry_released", body != null ? body.getLocation() : mage.getLocation()); + } + } + + /** A player left: whatever they dragged, or whoever dragged them, is let go. */ + static void drop(UUID player) { + CARRIES.remove(player); + UUID mage = carrier(player); + if (mage != null) { + CARRIES.remove(mage); + Player holder = Bukkit.getPlayer(mage); + if (holder != null) { + holder.sendMessage(Messages.get("blood.carry.broken")); + } + } + } + + static void tick() { + if (!BloodConfig.isEnabled()) { + CARRIES.clear(); + return; + } + for (Iterator> it = CARRIES.entrySet().iterator(); it.hasNext();) { + Map.Entry entry = it.next(); + Carry carry = entry.getValue(); + Player mage = Bukkit.getPlayer(entry.getKey()); + Player victim = Bukkit.getPlayer(carry.victim); + if (!holds(mage, victim, carry)) { + it.remove(); + if (mage != null) { + mage.sendMessage(Messages.get("blood.carry.broken")); + } + continue; + } + follow(mage, victim, carry); + if (HuntConfig.carrySlowness() > 0) { + mage.addPotionEffect(new PotionEffect(PotionEffectType.SLOWNESS, 30, + HuntConfig.carrySlowness() - 1, false, false, false)); + } + } + } + + private static boolean holds(Player mage, Player victim, Carry carry) { + return mage != null && victim != null + && carry.mageCharacter.equalsIgnoreCase(AltarService.currentCharacter(mage)) + && carry.victimCharacter.equalsIgnoreCase(AltarService.currentCharacter(victim)) + && RpCharactersBridge.isDown(victim) && !RpCharactersBridge.isDown(mage) + && mage.getWorld() == victim.getWorld() + && mage.getLocation().distance(victim.getLocation()) <= HuntConfig.carryBreakDistance(); + } + + /** Puts the body on the trail point that lies the drag distance behind the mage. */ + private static void follow(Player mage, Player victim, Carry carry) { + Location here = mage.getLocation(); + Location last = carry.trail.peekLast(); + if (last != null && last.getWorld() != here.getWorld()) { + carry.trail.clear(); + last = null; + } + if (last == null || last.distanceSquared(here) >= STEP * STEP) { + carry.trail.addLast(here.clone()); + if (carry.trail.size() > TRAIL_LIMIT) { + carry.trail.removeFirst(); + } + } + Location spot = null; + for (Iterator back = carry.trail.descendingIterator(); back.hasNext();) { + Location point = back.next(); + if (point.distance(here) >= HuntConfig.carryDistance()) { + spot = safe(point) ? point : null; + break; + } + } + Location body = victim.getLocation(); + if (spot == null || spot.distanceSquared(body) < 0.01) { + return; + } + Location to = spot.clone(); + to.setYaw(body.getYaw()); + to.setPitch(body.getPitch()); + RpCharactersBridge.moveDowned(victim, to); + if (++carry.steps % DRAG_FX_EVERY == 0) { + BloodFx.play("carry_drag", to); + } + } + + /** The body is never laid in liquid, fire or a wall; the drag waits until the trail is safe again. */ + static boolean safe(Location spot) { + Block body = spot.clone().add(0, BODY_HEIGHT, 0).getBlock(); + Block ground = spot.clone().subtract(0, 0.1, 0).getBlock(); + return !body.getType().isSolid() && !hazard(body) && !hazard(spot.getBlock()) && !HOT.contains(ground.getType()); + } + + private static boolean hazard(Block block) { + return UNSAFE.contains(block.getType()) + || (block.getBlockData() instanceof Waterlogged wet && wet.isWaterlogged()); + } + + public static void clear() { + CARRIES.clear(); + if (task != null) { + task.cancel(); + task = null; + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java new file mode 100644 index 0000000..9fbe037 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java @@ -0,0 +1,332 @@ +package net.tfminecraft.magic.blood; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.LongSupplier; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.block.Block; +import org.bukkit.util.Vector; + +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.integration.RpCharactersBridge; + +/** + * Character sacrifices. A knocked-out character near the altar is dragged onto it with the mage's + * blood knife; once the short rite ends the character dies for good, whatever happens to the + * knockout meanwhile. In the hunt it earns points by how much the victim mattered (the closest + * are kept for the finals); in the finals only the named target counts. Once the path is complete, + * anyone the hunt would take fills the altar's pool of blood, by how close they were. + */ +public final class CharacterRiteService { + + public enum Bind { + OK, + NOT_STAGE, + NOT_KNIFE, + NO_ALTAR, + NO_CHARACTER, + NOT_DOWN, + TOO_FAR, + TOO_NEW, + REPEAT, + RESERVED, + WAITING, + NOT_TARGET, + BUSY, + BLOCKED + } + + private record Rite(UUID victim, String mageCharacter, String victimCharacter, String victimAccount, + int points, int rank, long endsAt) {} + + private static final Map RITES = new HashMap<>(); + /** The victim hears their own death in this many lines, spread over the rite (blood.rite.dying.N). */ + static final int DYING_LINES = 4; + /** How many of those lines each victim has heard. */ + private static final Map NARRATED = new HashMap<>(); + private static final Set SACRIFICING = new HashSet<>(); + private static final double LEAVE_SLACK = 4; + private static final double ESCAPE = 2.5; + private static final double BODY_HEIGHT = 1.2; + private static final double LOW_HEIGHT = 0.7; + private static final double ALTAR_TOP = 0.6; + static LongSupplier clock = System::currentTimeMillis; + + private CharacterRiteService() {} + + /** Tries to drag the knocked-out victim onto the mage's altar. */ + public static Bind bind(Player mage, Player victim) { + String character = AltarService.currentCharacter(mage); + BloodRecord record = BloodRecords.get(character); + Bind ready = readiness(mage, character, record); + if (ready != null) { + return ready; + } + BloodAltar altar = BloodAltars.ofCharacter(character); + Location top = altar != null ? altar.center() : null; + if (top == null) { + return Bind.NO_ALTAR; + } + String victimCharacter = AltarService.currentCharacter(victim); + if (victimCharacter == null) { + return Bind.NO_CHARACTER; + } + if (!RpCharactersBridge.isDown(victim)) { + return Bind.NOT_DOWN; + } + if (victim.getWorld() != top.getWorld() || victim.getLocation().distance(top) > HuntConfig.dragRange()) { + return Bind.TOO_FAR; + } + if (!clearPath(victim.getLocation(), top)) { + return Bind.BLOCKED; + } + Bind eligible = eligibility(mage, record, victim, victimCharacter); + if (eligible != null) { + return eligible; + } + UUID carrier = CarryService.carrier(victim.getUniqueId()); + if (RITES.containsKey(mage.getUniqueId()) || isVictim(victim.getUniqueId()) + || (carrier != null && !carrier.equals(mage.getUniqueId()))) { + return Bind.BUSY; + } + String victimAccount = victim.getUniqueId().toString(); + boolean ranked = record.getStage() == BloodStage.HUNT || record.getStage() == BloodStage.ASCENDED; + int rank = ranked ? BondService.rank(mage, victimCharacter) : 0; + int points = record.getStage() == BloodStage.HUNT ? HuntConfig.points(rank) : 0; + if (!RpCharactersBridge.moveDowned(victim, top.clone().add(0, 0.5, 0))) { + return Bind.BLOCKED; + } + CarryService.release(mage, false); + RITES.put(mage.getUniqueId(), new Rite(victim.getUniqueId(), character, victimCharacter, victimAccount, + points, rank, clock.getAsLong() + HuntConfig.riteSeconds() * 1000L)); + victim.sendMessage(Messages.get("blood.rite.dread")); + BloodFx.play("rite_bound", top, victim); + return Bind.OK; + } + + /** + * Whether this victim may be taken now: TOO_NEW, REPEAT (in the hunt and once the path is complete; + * a named final target must stay takeable), RESERVED (in the hunt the closest are kept for the + * finals), WAITING, NOT_TARGET; null when they may. + */ + static Bind eligibility(Player mage, BloodRecord record, Player victim, String victimCharacter) { + if (RpCharactersBridge.playtimeSeconds(victim) < HuntConfig.minPlaytimeHours() * 3600 + || RpCharactersBridge.characterAgeSeconds(victim) < HuntConfig.minAgeDays() * 86_400) { + return Bind.TOO_NEW; + } + if (record.getStage() == BloodStage.HUNT || record.getStage() == BloodStage.ASCENDED) { + if (HuntConfig.noRepeatPlayer() + && victim.getUniqueId().toString().equalsIgnoreCase(record.getLastVictimAccount())) { + return Bind.REPEAT; + } + if (record.getStage() == BloodStage.ASCENDED) { + return null; + } + int rank = BondService.rank(mage, victimCharacter); + return rank >= 1 && rank <= HuntConfig.finalCount() ? Bind.RESERVED : null; + } + if (clock.getAsLong() < record.getNextFinalAt()) { + return Bind.WAITING; + } + return victimCharacter.equalsIgnoreCase(record.currentTarget()) ? null : Bind.NOT_TARGET; + } + + /** + * No wall between the body and the altar: the rite never pulls anyone through stone. Two lines run + * to the altar top: one at chest height above the floor, where any solid block counts, and one just + * above slab and bed height, where only full blocks count. Slabs, stairs, pews and beds do not stop + * it; walls, windows, full blocks and ceilings do. Doors count as solid even when open, so a body goes + * through a door, or around a block in the way, by being dragged. + */ + static boolean clearPath(Location from, Location altarCenter) { + return clearLine(from.clone().add(0, BODY_HEIGHT, 0), altarCenter, false) + && clearLine(from.clone().add(0, LOW_HEIGHT, 0), altarCenter, true); + } + + private static boolean clearLine(Location start, Location altarCenter, boolean fullBlocksOnly) { + Vector line = altarCenter.clone().add(0, ALTAR_TOP, 0).toVector().subtract(start.toVector()); + int samples = (int) Math.ceil(line.length() / 0.25); + Block altarBlock = altarCenter.getBlock(); + for (int i = 1; i < samples; i++) { + Block block = start.clone().add(line.clone().multiply((double) i / samples)).getBlock(); + Material type = block.getType(); + if (!block.equals(altarBlock) && (fullBlocksOnly ? type.isOccluding() : type.isSolid())) { + return false; + } + } + return true; + } + + /** + * A victim who logs out on the altar does not escape: the rite finishes as they leave (their + * character session is still open at this point). A mage who logs out simply breaks the rite. + */ + static void onQuit(Player player) { + UUID id = player.getUniqueId(); + RITES.remove(id); + for (Iterator> it = RITES.entrySet().iterator(); it.hasNext();) { + Map.Entry entry = it.next(); + if (!entry.getValue().victim().equals(id)) { + continue; + } + it.remove(); + Player mage = Bukkit.getPlayer(entry.getKey()); + BloodAltar altar = BloodAltars.ofCharacter(entry.getValue().mageCharacter()); + if (BloodConfig.isEnabled() && altar != null && holds(mage, player, entry.getValue(), altar.center())) { + complete(mage, player, entry.getValue(), altar); + } + return; + } + } + + /** Mage near the altar, body on it, both still the same characters. */ + private static boolean holds(Player mage, Player victim, Rite rite, Location top) { + return mage != null && victim != null && top != null && mage.getWorld() == top.getWorld() + && mage.getLocation().distance(top) <= HuntConfig.dragRange() + LEAVE_SLACK + && victim.getWorld() == top.getWorld() && victim.getLocation().distance(top) <= ESCAPE + && rite.mageCharacter().equalsIgnoreCase(AltarService.currentCharacter(mage)) + && rite.victimCharacter().equalsIgnoreCase(AltarService.currentCharacter(victim)); + } + + /** NOT_STAGE or NOT_KNIFE when the mage may not take anyone now, else null. */ + static Bind readiness(Player mage, String character, BloodRecord record) { + if (!BloodConfig.isEnabled() || record == null + || (record.getStage() != BloodStage.HUNT && record.getStage() != BloodStage.FINAL + && record.getStage() != BloodStage.ASCENDED)) { + return Bind.NOT_STAGE; + } + ItemStack knife = mage.getInventory().getItemInMainHand(); + if (!BloodItems.is(knife, BloodItemKind.BLOOD_KNIFE) || !character.equalsIgnoreCase(BloodItems.owner(knife))) { + return Bind.NOT_KNIFE; + } + return null; + } + + static boolean isVictim(UUID player) { + for (Rite rite : RITES.values()) { + if (rite.victim().equals(player)) { + return true; + } + } + return false; + } + + /** Whether this character is dying on an altar right now (its death is not a failed final). */ + public static boolean isBeingSacrificed(String character) { + return character != null && SACRIFICING.contains(character.toLowerCase(Locale.ROOT)); + } + + /** Every second: the rite beats on, breaks when the mage walks off, and ends in death. */ + static void tick() { + long now = clock.getAsLong(); + for (Iterator> it = RITES.entrySet().iterator(); it.hasNext();) { + Map.Entry entry = it.next(); + Rite rite = entry.getValue(); + Player mage = Bukkit.getPlayer(entry.getKey()); + Player victim = Bukkit.getPlayer(rite.victim()); + BloodAltar altar = BloodAltars.ofCharacter(rite.mageCharacter()); + Location top = altar != null ? altar.center() : null; + if (!holds(mage, victim, rite, top)) { + it.remove(); + NARRATED.remove(rite.victim()); + if (mage != null) { + mage.sendMessage(Messages.get("blood.rite.broken")); + } + if (victim != null) { + victim.sendMessage(Messages.get("blood.rite.spared")); + } + if (top != null) { + BloodFx.play("rite_broken", top); + } + continue; + } + if (now < rite.endsAt()) { + BloodFx.play("rite_beat", top, victim); + narrate(victim, rite, now); + continue; + } + it.remove(); + complete(mage, victim, rite, altar); + } + } + + /** The victim hears their death come on, a line at a time as the rite runs. */ + private static void narrate(Player victim, Rite rite, long now) { + long total = HuntConfig.riteSeconds() * 1000L; + long elapsed = total - (rite.endsAt() - now); + int due = (int) Math.min(DYING_LINES, elapsed * (DYING_LINES + 1) / total); + int heard = NARRATED.getOrDefault(rite.victim(), 0); + for (int line = heard + 1; line <= due; line++) { + victim.sendMessage(Messages.get("blood.rite.dying." + line)); + } + NARRATED.put(rite.victim(), Math.max(heard, due)); + } + + private static void complete(Player mage, Player victim, Rite rite, BloodAltar altar) { + NARRATED.remove(rite.victim()); + victim.sendMessage(Messages.get("blood.rite.dying.last")); + String key = rite.victimCharacter().toLowerCase(Locale.ROOT); + SACRIFICING.add(key); + boolean died; + try { + died = RpCharactersBridge.sacrifice(victim, mage); + } finally { + SACRIFICING.remove(key); + } + if (!died) { + mage.sendMessage(Messages.get("blood.rite.refused")); + return; + } + BloodRecord record = BloodRecords.get(rite.mageCharacter()); + if (record == null) { + return; + } + BloodStage stage = record.getStage(); + record.getSacrifices().add(new BloodRecord.Sacrifice(key, rite.victimAccount(), stage.name(), + rite.points(), clock.getAsLong(), rite.rank())); + record.setLastVictimAccount(rite.victimAccount()); + BloodFx.play("rite_complete", altar.center(), mage); + Magic.plugin.getLogger().info("[Magic] Character " + rite.mageCharacter() + " sacrificed character " + + key + " in stage " + stage + " for " + rite.points() + " point(s)"); + if (stage == BloodStage.HUNT) { + record.setHuntPoints(record.getHuntPoints() + rite.points()); + if (record.getHuntPoints() >= BloodConfig.huntPointsRequired()) { + FinalService.begin(mage, record); + } else { + WhisperService.tell(mage, "blood.rite.taken"); + BloodRecords.save(record); + } + } else if (stage == BloodStage.FINAL && key.equalsIgnoreCase(record.currentTarget())) { + FinalService.completed(mage, record); + } else if (stage == BloodStage.ASCENDED) { + BloodPool.fedPerson(mage, record, rite.rank()); + } else { + BloodRecords.save(record); + } + AltarService.refresh(altar); + AltarCorruption.spread(altar, CorruptionConfig.blocksPerSacrifice()); + BloodAltars.save(); + } + + /** Ends every rite without a death, e.g. on shutdown. */ + public static void clear() { + RITES.clear(); + NARRATED.clear(); + SACRIFICING.clear(); + } + + static boolean hasRite(UUID mage) { + return RITES.containsKey(mage); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java new file mode 100644 index 0000000..4993c4c --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java @@ -0,0 +1,240 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.bukkit.SoundCategory; +import org.bukkit.configuration.ConfigurationSection; + +/** + * The god's question (choice:), the hidden classes (classes:) and the bound armour (armour:). The + * question is a dialogue: the voices ask, and the mage answers aloud in roleplay chat. + */ +public final class ChoiceConfig { + + /** One line of the question: who says it and what. */ + public record Line(WhisperConfig.Speaker speaker, String text) {} + + /** The words that mean an answer, and what the voices say around it. */ + public record Answer(List words, List confirm, List sealed) {} + + private static String devoteeTrait; + private static int answerSeconds; + private static int lineSeconds; + private static String music; + private static SoundCategory musicCategory; + private static float musicVolume; + private static int musicLoopSeconds; + private static List ask; + private static Line unclear; + private static Line walkedAway; + private static final Map ANSWERS = new EnumMap<>(BloodPath.class); + private static final Map CLASSES = new EnumMap<>(BloodPath.class); + private static final Map> ARMOUR = new EnumMap<>(BloodPath.class); + private static final Map> PERMISSIONS = new EnumMap<>(BloodPath.class); + + static { + reset(); + } + + private ChoiceConfig() {} + + static void reset() { + devoteeTrait = "manaless"; + answerSeconds = 90; + lineSeconds = 4; + music = ""; + musicCategory = SoundCategory.RECORDS; + musicVolume = 0.8f; + musicLoopSeconds = 0; + ask = List.of(); + unclear = null; + walkedAway = null; + ANSWERS.clear(); + ANSWERS.put(BloodPath.DEVOTEE, new Answer(List.of(), List.of(), List.of())); + ANSWERS.put(BloodPath.TRUE, new Answer(List.of(), List.of(), List.of())); + CLASSES.clear(); + ARMOUR.clear(); + PERMISSIONS.clear(); + } + + /** + * One bound armour piece: the item (TLibs path), an optional look (TLibs ia. path) put over it, and an + * optional name with {victim} for the true path's pieces made from the people they took. + */ + public record Piece(String item, String look, String name) { + public Piece(String item, String look) { + this(item, look, null); + } + } + + static void load(ConfigurationSection choice, ConfigurationSection classes, ConfigurationSection armour) { + reset(); + if (choice != null) { + devoteeTrait = choice.getString("devotee_trait", "manaless").trim(); + answerSeconds = Math.max(5, choice.getInt("answer_seconds", 90)); + lineSeconds = Math.max(1, choice.getInt("line_seconds", 4)); + music = choice.getString("music.sound", "").trim(); + musicVolume = (float) Math.max(0, choice.getDouble("music.volume", 0.8)); + musicLoopSeconds = Math.max(0, choice.getInt("music.loop_seconds", 0)); + try { + musicCategory = SoundCategory.valueOf(choice.getString("music.category", "RECORDS").trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + musicCategory = SoundCategory.RECORDS; + } + ask = lines(choice.getList("ask", List.of())); + unclear = first(lines(choice.getList("unclear", List.of()))); + walkedAway = first(lines(choice.getList("walked_away", List.of()))); + for (BloodPath path : List.of(BloodPath.DEVOTEE, BloodPath.TRUE)) { + String key = path == BloodPath.DEVOTEE ? "kneel" : "refuse"; + List words = new ArrayList<>(); + for (String word : choice.getStringList(key + ".words")) { + if (!word.isBlank()) { + words.add(QuestionService.normalize(word)); + } + } + ANSWERS.put(path, new Answer(List.copyOf(words), lines(choice.getList(key + ".confirm", List.of())), + lines(choice.getList(key + ".sealed", List.of())))); + } + } + for (BloodPath path : List.of(BloodPath.DEVOTEE, BloodPath.TRUE)) { + String key = path.name().toLowerCase(Locale.ROOT); + if (classes != null && classes.isString(key) && !classes.getString(key).isBlank()) { + CLASSES.put(path, classes.getString(key).trim()); + } + if (armour != null) { + List pieces = new ArrayList<>(); + for (Object entry : armour.getList(key, List.of())) { + Piece piece = piece(entry); + if (piece != null) { + pieces.add(piece); + } + } + ARMOUR.put(path, List.copyOf(pieces)); + } + } + } + + /** A plain item path, or { item: ..., look: ..., name: ... }; null when there is no item. */ + private static Piece piece(Object entry) { + if (entry instanceof String item && !item.isBlank()) { + return new Piece(item.trim(), null); + } + if (entry instanceof Map map && map.get("item") instanceof String item && !item.isBlank()) { + return new Piece(item.trim(), map.get("look") instanceof String look && !look.isBlank() ? look.trim() : null, + map.get("name") instanceof String name && !name.isBlank() ? name : null); + } + return null; + } + + /** Lines as a list of { god: text } or { self: text }; anything else is skipped. */ + private static List lines(List raw) { + List out = new ArrayList<>(); + for (Object entry : raw) { + if (entry instanceof Map map && map.size() == 1) { + Map.Entry only = map.entrySet().iterator().next(); + String who = String.valueOf(only.getKey()).trim().toUpperCase(Locale.ROOT); + if ((who.equals("GOD") || who.equals("SELF")) && only.getValue() instanceof String text && !text.isBlank()) { + out.add(new Line(WhisperConfig.Speaker.valueOf(who), text)); + } + } + } + return List.copyOf(out); + } + + private static Line first(List lines) { + return lines.isEmpty() ? null : lines.get(0); + } + + /** The question, said in order. */ + public static List ask() { + return ask; + } + + /** What the voices say to an answer they cannot make out; null for nothing. */ + public static Line unclear() { + return unclear; + } + + /** What the god says when the mage leaves the chamber or lets the time run out; null for nothing. */ + public static Line walkedAway() { + return walkedAway; + } + + public static Answer answer(BloodPath path) { + return ANSWERS.get(path); + } + + public static int answerSeconds() { + return answerSeconds; + } + + public static int lineSeconds() { + return lineSeconds; + } + + /** Played to the mage alone while the question lasts; blank for none. */ + public static String music() { + return music; + } + + public static SoundCategory musicCategory() { + return musicCategory; + } + + public static float musicVolume() { + return musicVolume; + } + + /** The track's length: it is started again this often while the question lasts; 0 plays it once. */ + public static int musicLoopSeconds() { + return musicLoopSeconds; + } + + /** + * The permanent injury (an RPCharacters trait) a devotee keeps as the mark of what they gave; blank + * for none. A character with it has no mana (see {@link BloodBoons}). + */ + public static String devoteeTrait() { + return devoteeTrait; + } + + /** The hidden MMOCore class a path unlocks, or null. */ + public static String classFor(BloodPath path) { + return CLASSES.get(path); + } + + /** Reads permissions: the permission(s) each path's character holds while played, one or a list. */ + static void loadPermissions(ConfigurationSection permissions) { + PERMISSIONS.clear(); + if (permissions == null) { + return; + } + for (BloodPath path : List.of(BloodPath.DEVOTEE, BloodPath.TRUE)) { + String key = path.name().toLowerCase(Locale.ROOT); + List names = new ArrayList<>(); + List raw = permissions.isList(key) ? permissions.getList(key) : List.of(permissions.get(key, "")); + for (Object entry : raw) { + if (entry instanceof String name && !name.isBlank()) { + names.add(name.trim()); + } + } + if (!names.isEmpty()) { + PERMISSIONS.put(path, List.copyOf(names)); + } + } + } + + /** The permissions a path's character holds while played; empty for none. */ + public static List permissionsFor(BloodPath path) { + return PERMISSIONS.getOrDefault(path, List.of()); + } + + /** The bound armour pieces a path receives. */ + public static List armourFor(BloodPath path) { + return ARMOUR.getOrDefault(path, List.of()); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/ClassRewards.java b/src/main/java/net/tfminecraft/magic/blood/ClassRewards.java new file mode 100644 index 0000000..a5af974 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/ClassRewards.java @@ -0,0 +1,51 @@ +package net.tfminecraft.magic.blood; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.magic.Magic; + +/** + * What the path's class earns from the final sacrifices (finals.class_rewards): skill points for the + * class's own pool in RPCharacters, and MMOCore skill slots and spells. Everything is worked out again + * from the record each time, so it is safe to repeat. Points follow the character even while it plays + * another class; slots and spells only open while it plays the path's class, so a class picked later + * gets everything earned so far when it is picked. Ascended characters get every listed reward. + */ +public final class ClassRewards { + + private ClassRewards() {} + + /** Brings the current character's class rewards up to date; says so only when something new arrived. */ + public static void apply(Player player) { + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); + if (record == null || record.getPath() == BloodPath.NONE || !record.getStage().atLeast(BloodStage.FINAL)) { + return; + } + String classId = ChoiceConfig.classFor(record.getPath()); + if (classId == null) { + return; + } + int reached = record.getStage() == BloodStage.ASCENDED ? Integer.MAX_VALUE : record.getFinalsDone(); + HuntConfig.ClassReward reward = HuntConfig.classRewardsUpTo(record.getPath(), reached); + int before = ClassUnlockBridge.skillPoints(player, classId); + boolean gained = before != reward.skillPoints() + && ClassUnlockBridge.setSkillPoints(player, classId, reward.skillPoints()) + && reward.skillPoints() > Math.max(0, before); + if (Bukkit.getPluginManager().isPluginEnabled("MMOCore")) { + gained |= MmoClassRewards.unlock(player, classId, reward.slots(), reward.spells()) > 0; + } + if (gained) { + WhisperService.tell(player, "blood.class.rewarded"); + } + } + + /** A tick later, once MMOCore and RPCharacters are done switching class or character. */ + public static void applySoon(Player player) { + Bukkit.getScheduler().runTask(Magic.plugin, () -> { + if (player.isOnline()) { + apply(player); + } + }); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/ClassUnlockBridge.java b/src/main/java/net/tfminecraft/magic/blood/ClassUnlockBridge.java new file mode 100644 index 0000000..fe6173b --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/ClassUnlockBridge.java @@ -0,0 +1,64 @@ +package net.tfminecraft.magic.blood; + +import org.bukkit.entity.Player; + +import net.tfminecraft.magic.Magic; + +/** + * Puts the player's active character into a hidden class through RPCharacters' ClassUnlocks (the + * character is bound to it; players never pick these classes themselves), and + * sets the class's own skill-point pool through ClassSkillPoints. Reached by reflection so Magic also + * runs against RPCharacters versions without them; then staff do it by hand, as the log says. + */ +public final class ClassUnlockBridge { + + static String unlocksClass = "net.tfminecraft.rpcharacters.classpick.ClassUnlocks"; + static String skillPointsClass = "net.tfminecraft.rpcharacters.classpick.ClassSkillPoints"; + + private ClassUnlockBridge() {} + + public static boolean assign(Player player, String classId) { + if (classId == null) { + return false; + } + try { + Object result = Class.forName(unlocksClass).getMethod("assign", Player.class, String.class) + .invoke(null, player, classId); + if (Boolean.TRUE.equals(result)) { + return true; + } + } catch (ReflectiveOperationException | LinkageError ex) { + // Falls through to the warning below. + } + Magic.plugin.getLogger().warning("[Magic] Could not assign class " + classId + " to " + + player.getName() + "; assign it by hand (/rpcharacter admin assignclass)"); + return false; + } + + /** The class's own skill-point total on the active character; -1 when it has none or RPCharacters can't say. */ + static int skillPoints(Player player, String classId) { + try { + Object result = Class.forName(skillPointsClass).getMethod("get", Player.class, String.class) + .invoke(null, player, classId); + return result instanceof Integer total ? total : -1; + } catch (ReflectiveOperationException | LinkageError ex) { + return -1; + } + } + + /** Gives the class its own skill-point pool with this lifetime total on the active character. */ + static boolean setSkillPoints(Player player, String classId, int total) { + try { + Object result = Class.forName(skillPointsClass).getMethod("set", Player.class, String.class, int.class) + .invoke(null, player, classId, total); + if (Boolean.TRUE.equals(result)) { + return true; + } + } catch (ReflectiveOperationException | LinkageError ex) { + // Falls through to the warning below. + } + Magic.plugin.getLogger().warning("[Magic] Could not set the " + classId + " skill points of " + + player.getName() + " to " + total + "; RPCharacters needs ClassSkillPoints"); + return false; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java b/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java new file mode 100644 index 0000000..807765d --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java @@ -0,0 +1,156 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.logging.Logger; + +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; + +/** The corruption: section of the wildmagic/bloodmagic config. */ +public final class CorruptionConfig { + + /** How far an altar of a level reaches and how many blocks it may have changed. */ + public record Reach(int radius, int maxBlocks) {} + + /** A conversion of exposed solid blocks, or a decoration placed into air. */ + public record Rule(int minLevel, Set from, Placement on, List to) {} + + public enum Placement { + CONVERT, + FLOOR, + CEILING + } + + private static final TreeMap LEVELS = new TreeMap<>(); + private static final List RULES = new ArrayList<>(); + private static int blocksPerSacrifice; + private static int ambientEveryMinutes; + private static int ambientBlocks; + + static { + reset(); + } + + private CorruptionConfig() {} + + static void reset() { + LEVELS.clear(); + LEVELS.put(0, new Reach(2, 4)); + RULES.clear(); + blocksPerSacrifice = 3; + ambientEveryMinutes = 30; + ambientBlocks = 1; + } + + static void load(ConfigurationSection section, Logger log) { + reset(); + if (section == null) { + return; + } + ConfigurationSection levels = section.getConfigurationSection("levels"); + if (levels != null) { + LEVELS.clear(); + for (String key : levels.getKeys(false)) { + try { + int level = Integer.parseInt(key.trim()); + LEVELS.put(level, new Reach(Math.max(1, levels.getInt(key + ".radius", 2)), + Math.max(0, levels.getInt(key + ".max_blocks", 4)))); + } catch (NumberFormatException ex) { + log.warning("[Magic] blood magic config corruption.levels key '" + key + "' is not a level number"); + } + } + if (LEVELS.isEmpty()) { + LEVELS.put(0, new Reach(2, 4)); + } + } + blocksPerSacrifice = Math.max(0, section.getInt("blocks_per_sacrifice", 3)); + ambientEveryMinutes = Math.max(1, section.getInt("ambient_every_minutes", 30)); + ambientBlocks = Math.max(1, section.getInt("ambient_blocks", 1)); + for (Map raw : section.getMapList("convert")) { + Rule rule = rule(raw, Placement.CONVERT, log); + if (rule != null) { + RULES.add(rule); + } + } + for (Map raw : section.getMapList("decorate")) { + Placement placement = "ceiling".equalsIgnoreCase(String.valueOf(raw.get("at"))) + ? Placement.CEILING : Placement.FLOOR; + Rule rule = rule(raw, placement, log); + if (rule != null) { + RULES.add(rule); + } + } + } + + private static Rule rule(Map raw, Placement placement, Logger log) { + int minLevel = raw.get("min_level") instanceof Number number ? number.intValue() : 0; + Set from = new HashSet<>(materials(raw.get("from"), log)); + List to = materials(raw.get("to"), log); + if (to.isEmpty() || (placement == Placement.CONVERT && from.isEmpty())) { + log.warning("[Magic] blood magic config corruption rule without usable blocks skipped"); + return null; + } + return new Rule(minLevel, Set.copyOf(from), placement, List.copyOf(to)); + } + + private static List materials(Object raw, Logger log) { + List out = new ArrayList<>(); + if (!(raw instanceof List list)) { + return out; + } + for (Object entry : list) { + Material material = Material.matchMaterial(String.valueOf(entry).trim().toUpperCase(Locale.ROOT)); + if (material == null || !material.isBlock()) { + log.warning("[Magic] blood magic config corruption: '" + entry + "' is not a block"); + } else { + out.add(material); + } + } + return out; + } + + /** The farthest any level reaches. */ + public static int maxRadius() { + int max = 0; + for (Reach reach : LEVELS.values()) { + max = Math.max(max, reach.radius()); + } + return max; + } + + /** The reach for a level: the highest configured level at or below it. */ + public static Reach reach(int level) { + Map.Entry entry = LEVELS.floorEntry(level); + return entry != null ? entry.getValue() : LEVELS.firstEntry().getValue(); + } + + /** Rules an altar of this level may use. */ + public static List rules(int level) { + List out = new ArrayList<>(); + for (Rule rule : RULES) { + if (level >= rule.minLevel()) { + out.add(rule); + } + } + return out; + } + + public static int blocksPerSacrifice() { + return blocksPerSacrifice; + } + + /** Blocks the slow spread changes each time. */ + public static int ambientBlocks() { + return ambientBlocks; + } + + public static int ambientEveryMinutes() { + return ambientEveryMinutes; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/FinalService.java b/src/main/java/net/tfminecraft/magic/blood/FinalService.java new file mode 100644 index 0000000..5c5e478 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/FinalService.java @@ -0,0 +1,271 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.LongSupplier; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.integration.RpCharactersBridge; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; + +/** + * The final sacrifices: the mage's closest characters, one named at a time from the least close + * to the closest. A named target that dies any other way is a failed sacrifice: backlash, a wait, + * then the next. Dead, vanished or long-absent targets are replaced so the stage never sticks. + */ +public final class FinalService { + + static LongSupplier clock = System::currentTimeMillis; + + private FinalService() {} + + /** Starts the finals: the path's class and armour (true path), its class pool, then the first target. */ + public static void begin(Player mage, BloodRecord record) { + record.setStage(BloodStage.FINAL); + if (record.getPath() == BloodPath.TRUE) { + ClassUnlockBridge.assign(mage, ChoiceConfig.classFor(BloodPath.TRUE)); + BloodArmour.give(mage, BloodPath.TRUE, record.getCharacterId(), BloodArmour.victims(record)); + WhisperService.tell(mage, "blood.hunt.complete"); + BloodFx.play("hunt_complete", mage.getLocation(), mage); + } + refill(mage, record); + BloodRecords.save(record); + ClassRewards.apply(mage); + BloodArtefacts.sync(mage); + name(mage, record); + Magic.plugin.getLogger().info("[Magic] Character " + record.getCharacterId() + " began the final sacrifices: " + + record.getFinalTargets()); + } + + /** + * Tops the target list up from the current closeness ranking, skipping anyone already + * sacrificed, and keeps it least close first so the last sacrifice is the closest. + */ + static boolean refill(Player mage, BloodRecord record) { + return refill(mage, record, Set.of()); + } + + private static boolean refill(Player mage, BloodRecord record, Set exclude) { + int wanted = HuntConfig.finalCount() - record.getFinalsDone(); + List ranking = BondService.ranking(mage); + Set taken = new HashSet<>(exclude); + for (BloodRecord.Sacrifice sacrifice : record.getSacrifices()) { + taken.add(sacrifice.character().toLowerCase(Locale.ROOT)); + } + List targets = record.getFinalTargets(); + boolean changed = false; + for (BondService.Ranked ranked : ranking) { + if (targets.size() >= wanted) { + break; + } + if (!taken.contains(ranked.character()) && !targets.contains(ranked.character())) { + targets.add(ranked.character()); + changed = true; + } + } + List order = new ArrayList<>(); + for (BondService.Ranked ranked : ranking) { + order.add(ranked.character()); + } + targets.sort(Comparator.comparingInt((String id) -> order.contains(id) ? order.indexOf(id) : order.size()) + .reversed()); + return changed; + } + + /** Tells the mage, in their voice, who is named now; or that there is nobody yet. */ + static void name(Player mage, BloodRecord record) { + String target = record.currentTarget(); + WhisperConfig.Speaker speaker = WhisperService.speaker(record); + if (target == null) { + WhisperService.speak(mage, speaker, Messages.getRaw(voiced("blood.final.nobody", speaker))); + return; + } + WhisperService.speak(mage, speaker, Messages.getRaw(voiced("blood.final.named", speaker)).replace("{target}", nameOf(target))); + BloodFx.play("target_named", mage.getLocation(), mage); + } + + /** The god and the dark self say the same thing in their own words: key_god or key_self. */ + static String voiced(String key, WhisperConfig.Speaker speaker) { + return key + (speaker == WhisperConfig.Speaker.SELF ? "_self" : "_god"); + } + + static String nameOf(String character) { + try { + return Characters.info(UUID.fromString(character)).map(CharacterInfo::name).orElse("?"); + } catch (IllegalArgumentException ex) { + return "?"; + } + } + + /** The named target was sacrificed at the altar. */ + static void completed(Player mage, BloodRecord record) { + if (!record.getFinalTargets().isEmpty()) { + record.getFinalTargets().remove(0); + } + record.setFinalsDone(record.getFinalsDone() + 1); + if (record.getFinalsDone() >= HuntConfig.finalCount()) { + record.setStage(BloodStage.ASCENDED); + record.getFinalTargets().clear(); + record.setPool(PoolConfig.capacity()); + WhisperService.tell(mage, "blood.final.ascended"); + BloodFx.play("ascended", mage.getLocation(), mage); + } else { + refill(mage, record); + WhisperService.tell(mage, "blood.final.taken"); + BloodFx.play("final_taken", mage.getLocation(), mage); + } + BloodRecords.save(record); + BloodBoons.apply(mage); + ClassRewards.apply(mage); + BloodArtefacts.sync(mage); + if (record.getStage() == BloodStage.FINAL) { + name(mage, record); + } + } + + /** + * A character died, not on the mage's altar. For every mage naming them now, it is a failed + * sacrifice; later targets just leave the list. + */ + public static void onDeath(String character) { + for (BloodRecord record : BloodRecords.all()) { + if (record.getStage() != BloodStage.FINAL || !containsIgnoreCase(record.getFinalTargets(), character)) { + continue; + } + boolean current = character.equalsIgnoreCase(record.currentTarget()); + record.getFinalTargets().removeIf(id -> id.equalsIgnoreCase(character)); + if (current) { + fail(record); + } + BloodRecords.save(record); + } + } + + private static void fail(BloodRecord record) { + record.setNextFinalAt(clock.getAsLong() + HuntConfig.failCooldownMillis()); + record.setPendingBacklash(true); + Magic.plugin.getLogger().info("[Magic] Character " + record.getCharacterId() + " lost a final target"); + Player mage = online(record); + if (mage != null) { + backlash(mage, record); + } + } + + /** The failed sacrifice strikes back: a healable injury and the voice's scorn. */ + static void backlash(Player mage, BloodRecord record) { + if (!record.isPendingBacklash()) { + return; + } + record.setPendingBacklash(false); + RpCharactersBridge.applyHealingInjury(mage); + BloodFx.play("backlash", mage.getLocation(), mage); + WhisperService.speak(mage, WhisperService.speaker(record), Messages.getRaw(voiced("blood.final.failed", WhisperService.speaker(record)))); + BloodRecords.save(record); + } + + /** + * Ten-minute check for an online mage in the finals: pending backlash, targets that died or + * vanished while nobody watched, long-absent targets, refills, and naming once the wait is over. + */ + public static void check(Player mage) { + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(mage)); + if (record == null || record.getStage() != BloodStage.FINAL) { + return; + } + backlash(mage, record); + String before = record.currentTarget(); + long now = clock.getAsLong(); + boolean changed = false; + for (String target : new ArrayList<>(record.getFinalTargets())) { + Optional info = info(target); + boolean gone = info.isEmpty() || info.get().status() != CharacterStatus.ALIVE; + boolean absent = info.isPresent() && info.get().lastPlayedAt() != null + && now - info.get().lastPlayedAt().toEpochMilli() > HuntConfig.inactiveMillis(); + if (gone || absent) { + record.getFinalTargets().remove(target); + changed = true; + if (gone && target.equals(before) && info.isPresent()) { + record.setNextFinalAt(now + HuntConfig.failCooldownMillis()); + record.setPendingBacklash(true); + backlash(mage, record); + } + } + } + if (now >= record.getNextFinalAt()) { + changed |= refill(mage, record); + } + if (changed) { + BloodRecords.save(record); + } + String after = record.currentTarget(); + if (after != null && !after.equals(before) && now >= record.getNextFinalAt()) { + name(mage, record); + } + } + + private static Optional info(String character) { + try { + return Characters.info(UUID.fromString(character)); + } catch (IllegalArgumentException ex) { + return Optional.empty(); + } + } + + private static Player online(BloodRecord record) { + if (record.getOwnerUuid() == null) { + return null; + } + Player player = Bukkit.getPlayer(UUID.fromString(record.getOwnerUuid())); + return player != null && record.getCharacterId().equalsIgnoreCase(AltarService.currentCharacter(player)) + ? player : null; + } + + private static boolean containsIgnoreCase(List list, String value) { + for (String entry : list) { + if (entry.equalsIgnoreCase(value)) { + return true; + } + } + return false; + } + + /** Staff: replaces the named target with a fresh pick, or with a given character. */ + public static void reroll(Player mage, BloodRecord record) { + Set exclude = new HashSet<>(); + if (!record.getFinalTargets().isEmpty()) { + exclude.add(record.getFinalTargets().remove(0)); + } + refill(mage, record, exclude); + BloodRecords.save(record); + name(mage, record); + } + + public static void set(Player mage, BloodRecord record, String character) { + record.getFinalTargets().removeIf(id -> id.equalsIgnoreCase(character)); + record.getFinalTargets().add(0, character.toLowerCase(Locale.ROOT)); + record.setNextFinalAt(0); + BloodRecords.save(record); + name(mage, record); + } + + /** Staff: counts the named target as done without a death, e.g. when a bug blocked it. */ + public static void skip(Player mage, BloodRecord record) { + String current = record.currentTarget(); + if (current != null) { + record.getSacrifices().add(new BloodRecord.Sacrifice(current, null, "SKIPPED", 0, clock.getAsLong())); + } + completed(mage, record); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java b/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java new file mode 100644 index 0000000..d616157 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java @@ -0,0 +1,320 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.logging.Logger; + +import org.bukkit.configuration.ConfigurationSection; + +/** Character sacrifices (hunt:), closeness (bonds:) and the final sacrifices (finals:). */ +public final class HuntConfig { + + /** Ranks up to and including upTo are worth this many hunt points. */ + public record RankPoints(int upTo, int points) {} + + /** + * What final sacrifices give the path's class: skill points for its own pool (a lifetime total), + * MMOCore skill slots to open and class skills (spells) to unlock. + */ + public record ClassReward(int skillPoints, List slots, List spells) { + static final ClassReward NONE = new ClassReward(0, List.of(), List.of()); + + ClassReward plus(ClassReward other) { + List allSlots = new ArrayList<>(slots); + other.slots.stream().filter(slot -> !allSlots.contains(slot)).forEach(allSlots::add); + List allSpells = new ArrayList<>(spells); + other.spells.stream().filter(spell -> !allSpells.contains(spell)).forEach(allSpells::add); + return new ClassReward(skillPoints + other.skillPoints, List.copyOf(allSlots), List.copyOf(allSpells)); + } + } + + private static double dragRange; + private static int riteSeconds; + private static boolean carryEnabled; + private static double carryDistance; + private static int carrySlowness; + private static double carryBreakDistance; + private static double minPlaytimeHours; + private static double minAgeDays; + private static boolean noRepeatPlayer; + private static int strangerPoints; + private static int defaultPoints; + private static final List RANK_POINTS = new ArrayList<>(); + + private static int sampleMinutes; + private static double nearRadius; + private static double conversationWeight; + private static double hourWeight; + private static double heirBonus; + private static double heirMinHours; + private static int heirMinConversations; + + private static int finalCount; + private static long failCooldownMillis; + private static long inactiveMillis; + private static final Map>> BOONS = new EnumMap<>(BloodPath.class); + private static final Map> REWARDS = new EnumMap<>(BloodPath.class); + + static { + reset(); + } + + private HuntConfig() {} + + static void reset() { + dragRange = 8; + riteSeconds = 10; + carryEnabled = true; + carryDistance = 1.2; + carrySlowness = 1; + carryBreakDistance = 6; + minPlaytimeHours = 10; + minAgeDays = 7; + noRepeatPlayer = true; + strangerPoints = 1; + defaultPoints = 1; + RANK_POINTS.clear(); + sampleMinutes = 5; + nearRadius = 16; + conversationWeight = 3; + hourWeight = 2; + heirBonus = 40; + heirMinHours = 10; + heirMinConversations = 20; + finalCount = 5; + failCooldownMillis = 72L * 3_600_000L; + inactiveMillis = 21L * 86_400_000L; + BOONS.clear(); + REWARDS.clear(); + } + + static void load(ConfigurationSection hunt, ConfigurationSection bonds, ConfigurationSection finals, Logger log) { + reset(); + if (hunt != null) { + dragRange = Math.max(1, hunt.getDouble("drag_range", 8)); + riteSeconds = Math.max(1, hunt.getInt("rite_seconds", 10)); + carryEnabled = hunt.getBoolean("carry.enabled", true); + carryDistance = Math.max(0.5, hunt.getDouble("carry.distance", 1.2)); + carrySlowness = Math.max(0, hunt.getInt("carry.slowness", 1)); + carryBreakDistance = Math.max(carryDistance + 1, hunt.getDouble("carry.break_distance", 6)); + minPlaytimeHours = Math.max(0, hunt.getDouble("victims.min_playtime_hours", 10)); + minAgeDays = Math.max(0, hunt.getDouble("victims.min_age_days", 7)); + noRepeatPlayer = hunt.getBoolean("victims.no_repeat_player", true); + strangerPoints = Math.max(0, hunt.getInt("points.stranger", 1)); + defaultPoints = Math.max(0, hunt.getInt("points.default", 1)); + for (Map raw : hunt.getMapList("points.ranks")) { + if (raw.get("up_to") instanceof Number upTo && raw.get("points") instanceof Number points) { + RANK_POINTS.add(new RankPoints(upTo.intValue(), Math.max(0, points.intValue()))); + } else { + log.warning("[Magic] blood magic config hunt.points.ranks entry needs up_to and points"); + } + } + RANK_POINTS.sort(Comparator.comparingInt(RankPoints::upTo)); + } + if (bonds != null) { + sampleMinutes = Math.max(1, bonds.getInt("sample_minutes", 5)); + nearRadius = Math.max(1, bonds.getDouble("near_radius", 16)); + conversationWeight = Math.max(0, bonds.getDouble("conversation_weight", 3)); + hourWeight = Math.max(0, bonds.getDouble("hour_weight", 2)); + heirBonus = Math.max(0, bonds.getDouble("heir_bonus", 40)); + heirMinHours = Math.max(0, bonds.getDouble("heir_min_hours", 10)); + heirMinConversations = Math.max(0, bonds.getInt("heir_min_conversations", 20)); + } + if (finals != null) { + finalCount = Math.max(1, finals.getInt("count", 5)); + failCooldownMillis = (long) (Math.max(0, finals.getDouble("fail_cooldown_hours", 72)) * 3_600_000L); + inactiveMillis = (long) (Math.max(1, finals.getDouble("inactive_days", 21)) * 86_400_000L); + for (BloodPath path : List.of(BloodPath.DEVOTEE, BloodPath.TRUE)) { + ConfigurationSection section = finals.getConfigurationSection( + "boons." + path.name().toLowerCase(Locale.ROOT)); + if (section != null) { + BOONS.put(path, boons(section, log)); + } + ConfigurationSection rewards = finals.getConfigurationSection( + "class_rewards." + path.name().toLowerCase(Locale.ROOT)); + if (rewards != null) { + REWARDS.put(path, rewards(rewards, log)); + } + } + } + } + + private static TreeMap rewards(ConfigurationSection section, Logger log) { + TreeMap out = new TreeMap<>(); + for (String key : section.getKeys(false)) { + ConfigurationSection reward = section.getConfigurationSection(key); + int index = index(key); + if (index < 1 || reward == null) { + log.warning("[Magic] blood magic config finals.class_rewards entry '" + key + "' skipped"); + continue; + } + List spells = new ArrayList<>(); + for (String spell : reward.getStringList("spells")) { + if (!spell.isBlank()) { + spells.add(spell.trim().toUpperCase(Locale.ROOT)); + } + } + List slots = new ArrayList<>(); + for (Integer slot : reward.getIntegerList("slots")) { + if (slot > 0) { + slots.add(slot); + } + } + out.put(index, new ClassReward(Math.max(0, reward.getInt("skill_points", 0)), List.copyOf(slots), + List.copyOf(spells))); + } + return out; + } + + private static int index(String key) { + try { + return Integer.parseInt(key.trim()); + } catch (NumberFormatException ex) { + return -1; + } + } + + private static TreeMap> boons(ConfigurationSection section, Logger log) { + TreeMap> out = new TreeMap<>(); + for (String key : section.getKeys(false)) { + ConfigurationSection stats = section.getConfigurationSection(key); + int index = index(key); + if (index < 1 || stats == null) { + log.warning("[Magic] blood magic config finals.boons entry '" + key + "' skipped"); + continue; + } + Map values = new LinkedHashMap<>(); + for (String stat : stats.getKeys(false)) { + values.put(stat, stats.getDouble(stat)); + } + out.put(index, values); + } + return out; + } + + public static double dragRange() { + return dragRange; + } + + public static boolean carryEnabled() { + return carryEnabled; + } + + /** Blocks the dragged body trails behind the mage. */ + public static double carryDistance() { + return carryDistance; + } + + /** Slowness level while dragging; 0 for none. */ + public static int carrySlowness() { + return carrySlowness; + } + + /** The drag breaks once the body is further away than this (teleports, pearls). */ + public static double carryBreakDistance() { + return carryBreakDistance; + } + + public static int riteSeconds() { + return riteSeconds; + } + + public static double minPlaytimeHours() { + return minPlaytimeHours; + } + + public static double minAgeDays() { + return minAgeDays; + } + + public static boolean noRepeatPlayer() { + return noRepeatPlayer; + } + + /** + * Hunt points for a victim at this closeness rank (1 = closest), or 0 for someone the mage + * never got to know. Ranks inside the final count are not huntable at all. + */ + public static int points(int rank) { + if (rank <= 0) { + return strangerPoints; + } + for (RankPoints tier : RANK_POINTS) { + if (rank <= tier.upTo()) { + return tier.points(); + } + } + return defaultPoints; + } + + public static int sampleMinutes() { + return sampleMinutes; + } + + public static double nearRadius() { + return nearRadius; + } + + public static double conversationWeight() { + return conversationWeight; + } + + public static double hourWeight() { + return hourWeight; + } + + public static double heirBonus() { + return heirBonus; + } + + public static double heirMinHours() { + return heirMinHours; + } + + public static int heirMinConversations() { + return heirMinConversations; + } + + public static int finalCount() { + return finalCount; + } + + public static long failCooldownMillis() { + return failCooldownMillis; + } + + public static long inactiveMillis() { + return inactiveMillis; + } + + /** All boons earned after this many final sacrifices on a path, summed per stat. */ + public static Map boonsUpTo(BloodPath path, int finalsDone) { + Map total = new LinkedHashMap<>(); + TreeMap> table = BOONS.get(path); + if (table == null) { + return total; + } + for (Map step : table.headMap(finalsDone, true).values()) { + step.forEach((stat, value) -> total.merge(stat, value, Double::sum)); + } + return total; + } + + /** Everything the path's class has earned after this many final sacrifices, added up. */ + public static ClassReward classRewardsUpTo(BloodPath path, int finalsDone) { + ClassReward total = ClassReward.NONE; + TreeMap table = REWARDS.get(path); + if (table != null) { + for (ClassReward step : table.headMap(finalsDone, true).values()) { + total = total.plus(step); + } + } + return total; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/HusbandryOwners.java b/src/main/java/net/tfminecraft/magic/blood/HusbandryOwners.java new file mode 100644 index 0000000..06a3483 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/HusbandryOwners.java @@ -0,0 +1,37 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Who keeps an animal, read from Cooking's husbandry records when Cooking is installed. + * Reflection keeps Cooking out of Magic's build; any mismatch reads as "nobody owns it". + */ +public final class HusbandryOwners { + + /** One keeper: the account, and the character when Cooking recorded one. */ + public record Owner(UUID account, UUID character) {} + + static String serviceClass = "net.tfminecraft.cooking.husbandry.HusbandryOwnershipService"; + + private HusbandryOwners() {} + + public static List of(UUID animal) { + List out = new ArrayList<>(); + try { + Class service = Class.forName(serviceClass); + Object owners = service.getMethod("listOwners", UUID.class).invoke(null, animal); + for (Object owner : (List) owners) { + UUID account = (UUID) owner.getClass().getMethod("playerUuid").invoke(owner); + UUID character = (UUID) owner.getClass().getMethod("characterUuid").invoke(owner); + if (account != null) { + out.add(new Owner(account, character)); + } + } + } catch (ReflectiveOperationException | ClassCastException | LinkageError ex) { + return List.of(); + } + return out; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/MeditationConfig.java b/src/main/java/net/tfminecraft/magic/blood/MeditationConfig.java new file mode 100644 index 0000000..9b5d232 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/MeditationConfig.java @@ -0,0 +1,193 @@ +package net.tfminecraft.magic.blood; + + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; + +/** + * Wild magic meditation and charging (settings.yml meditation:): the heartbeat the blood mage + * meditates to, the drops of blood it draws from the charged artefacts, what a catch on or off the + * beat gives, how long charging an artefact in the pool takes, and the private sounds and music. + */ +public final class MeditationConfig { + + /** + * A sound only its player hears: key (vanilla or a resource pack's), volume, pitch, and how long + * it runs before a loop plays it again (0: it plays once). + */ + public record Cue(String sound, float volume, float pitch, int seconds) { + static final Cue NONE = new Cue("", 0, 1, 0); + + boolean isSet() { + return !sound.isBlank(); + } + + /** Plays the sound to the player alone, where they stand. */ + void play(Player player) { + if (isSet()) { + player.playSound(player.getLocation(), sound, volume, pitch); + } + } + + void stop(Player player) { + if (isSet()) { + player.stopSound(sound); + } + } + } + + private static int beatTicks; + private static int windowTicks; + private static int travelBeats; + private static int lingerBeats; + private static int maxDrops; + private static double onBeatDraw; + private static double offBeatDraw; + private static double offBeatHealth; + private static double hitRange; + private static double hitRadius; + private static String dropItem; + private static float dropScale; + private static float flareScale; + private static int chargeTicks; + private static Cue heartbeat; + private static Cue music; + private static Cue charge; + + static { + reset(); + } + + private MeditationConfig() {} + + static void reset() { + beatTicks = 30; + windowTicks = 6; + travelBeats = 2; + lingerBeats = 2; + maxDrops = 4; + onBeatDraw = 3; + offBeatDraw = 1; + offBeatHealth = 1; + hitRange = 6; + hitRadius = 0.6; + dropItem = "v.redstone"; + dropScale = 0.3f; + flareScale = 0.55f; + chargeTicks = 160; + heartbeat = Cue.NONE; + music = Cue.NONE; + charge = Cue.NONE; + } + + static void load(ConfigurationSection meditation) { + reset(); + if (meditation == null) { + return; + } + beatTicks = Math.max(10, meditation.getInt("beat_ticks", 30)); + windowTicks = Math.max(1, Math.min(beatTicks / 2, meditation.getInt("window_ticks", 6))); + travelBeats = Math.max(1, meditation.getInt("drops.travel_beats", 2)); + lingerBeats = Math.max(1, meditation.getInt("drops.linger_beats", 2)); + maxDrops = Math.max(1, meditation.getInt("drops.max", 4)); + onBeatDraw = Math.max(0, meditation.getDouble("on_beat_draw", 3)); + offBeatDraw = Math.max(0, meditation.getDouble("off_beat_draw", 1)); + offBeatHealth = Math.max(0, meditation.getDouble("off_beat_health", 1)); + hitRange = Math.max(1, meditation.getDouble("hit_range", 6)); + hitRadius = Math.max(0.1, meditation.getDouble("hit_radius", 0.6)); + dropItem = meditation.getString("drops.item", "v.redstone"); + dropScale = (float) Math.max(0.05, meditation.getDouble("drops.scale", 0.3)); + flareScale = (float) Math.max(dropScale, meditation.getDouble("drops.flare_scale", 0.55)); + chargeTicks = Math.max(20, meditation.getInt("charge_ticks", 160)); + heartbeat = cue(meditation.getConfigurationSection("sounds.heartbeat")); + music = cue(meditation.getConfigurationSection("sounds.music")); + charge = cue(meditation.getConfigurationSection("sounds.charge")); + } + + private static Cue cue(ConfigurationSection section) { + if (section == null) { + return Cue.NONE; + } + return new Cue(section.getString("sound", "").trim(), (float) Math.max(0, section.getDouble("volume", 1)), + (float) Math.max(0.5, Math.min(2, section.getDouble("pitch", 1))), Math.max(0, section.getInt("seconds", 0))); + } + + /** Ticks between heartbeats. */ + public static int beatTicks() { + return beatTicks; + } + + /** A catch this many ticks from a beat counts as on the beat. */ + public static int windowTicks() { + return windowTicks; + } + + /** Beats a drop takes from its artefact to the mage. */ + public static int travelBeats() { + return travelBeats; + } + + /** Beats a drop waits in front of the mage before it falls back. */ + public static int lingerBeats() { + return lingerBeats; + } + + public static int maxDrops() { + return maxDrops; + } + + /** Charge drawn into blood resonance by a catch on the beat. */ + public static double onBeatDraw() { + return onBeatDraw; + } + + /** Charge drawn by a catch off the beat, which also costs the mage health. */ + public static double offBeatDraw() { + return offBeatDraw; + } + + public static double offBeatHealth() { + return offBeatHealth; + } + + public static double hitRange() { + return hitRange; + } + + public static double hitRadius() { + return hitRadius; + } + + /** The drop's look, a TLibs item path. */ + public static String dropItem() { + return dropItem; + } + + public static float dropScale() { + return dropScale; + } + + /** How large a drop swells on each beat. */ + public static float flareScale() { + return flareScale; + } + + /** How long an artefact takes to charge in the pool. */ + public static int chargeTicks() { + return chargeTicks; + } + + public static Cue heartbeat() { + return heartbeat; + } + + /** The meditation's music, looped while the mage meditates. */ + public static Cue music() { + return music; + } + + /** The cue while an artefact charges in the pool. */ + public static Cue charge() { + return charge; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/MmoClassRewards.java b/src/main/java/net/tfminecraft/magic/blood/MmoClassRewards.java new file mode 100644 index 0000000..b1ff1f1 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/MmoClassRewards.java @@ -0,0 +1,56 @@ +package net.tfminecraft.magic.blood; + +import java.util.Collection; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import net.Indyuce.mmocore.api.event.PlayerChangeClassEvent; +import net.Indyuce.mmocore.api.player.PlayerData; +import net.Indyuce.mmocore.api.player.profess.PlayerClass; +import net.Indyuce.mmocore.player.Unlockable; +import net.tfminecraft.magic.Magic; + +/** MMOCore side of {@link ClassRewards}; only loaded once MMOCore is known to be enabled. */ +final class MmoClassRewards implements Listener { + + /** + * Opens the slots and unlocks the spells while the player plays this class. MMOCore keeps them + * with that class's data. Returns how many were new. + */ + static int unlock(Player player, String classId, Collection slots, Collection spells) { + if (!PlayerData.has(player)) { + return 0; + } + PlayerData data = PlayerData.get(player); + PlayerClass profess = data.getProfess(); + if (profess == null || !profess.getId().equalsIgnoreCase(classId)) { + return 0; + } + int opened = 0; + for (int number : slots) { + opened += open(data, profess.getSkillSlot(number), classId, "slot " + number); + } + for (String spell : spells) { + opened += open(data, profess.getSkill(spell), classId, "spell " + spell); + } + return opened; + } + + private static int open(PlayerData data, Unlockable unlockable, String classId, String what) { + if (unlockable == null) { + Magic.plugin.getLogger().warning("[Magic] blood magic config finals.class_rewards: class " + classId + + " has no " + what); + return 0; + } + return !unlockable.isUnlockedByDefault() && data.unlock(unlockable) ? 1 : 0; + } + + /** A picked class gets what the character has earned for it. */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onClassChange(PlayerChangeClassEvent event) { + ClassRewards.applySoon(event.getPlayer()); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/MythicBoons.java b/src/main/java/net/tfminecraft/magic/blood/MythicBoons.java new file mode 100644 index 0000000..4c4a262 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/MythicBoons.java @@ -0,0 +1,45 @@ +package net.tfminecraft.magic.blood; + +import java.util.Map; + +import org.bukkit.entity.Player; + +import io.lumine.mythic.lib.api.player.MMOPlayerData; +import io.lumine.mythic.lib.api.stat.StatInstance; +import io.lumine.mythic.lib.api.stat.modifier.StatModifier; +import io.lumine.mythic.lib.player.modifier.ModifierType; + +/** MythicLib side of {@link BloodBoons}; only loaded once MythicLib is known to be enabled. */ +final class MythicBoons { + + static final String KEY = "magic_blood_boon"; + + private MythicBoons() {} + + static void add(Player player, Map boons) { + MMOPlayerData data = MMOPlayerData.getOrNull(player); + if (data == null) { + return; + } + boons.forEach((stat, value) -> new StatModifier(KEY, stat, value).register(data)); + } + + /** Takes away all max mana (-100%), under the same key as the boons. */ + static void noMana(Player player) { + MMOPlayerData data = MMOPlayerData.getOrNull(player); + if (data != null) { + new StatModifier(KEY, "MAX_MANA", -100, ModifierType.RELATIVE).register(data); + } + } + + /** Removes every blood boon by key, so nothing survives a reload or a forgotten stat. */ + static void clear(Player player) { + MMOPlayerData data = MMOPlayerData.getOrNull(player); + if (data == null) { + return; + } + for (StatInstance instance : data.getStatMap().getInstances()) { + instance.remove(KEY); + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/PoolConfig.java b/src/main/java/net/tfminecraft/magic/blood/PoolConfig.java new file mode 100644 index 0000000..f5da72e --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/PoolConfig.java @@ -0,0 +1,183 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.logging.Logger; + +import org.bukkit.configuration.ConfigurationSection; + +/** + * The pool of blood (settings.yml pool:): how much the altar's pool holds, what charging artefacts in + * it costs once the path is complete, how fast blood resonance fades then, what sacrifices pour back, + * what the path's artefacts are worth, and the blood resonance each spell tier needs. + */ +public final class PoolConfig { + + private record RankAmount(int upTo, double amount) {} + + private static double capacity; + private static double decayPercent; + private static double bloodPerPoint; + private static double floor; + private static double strangerRefill; + private static double defaultRefill; + private static final List RANK_REFILLS = new ArrayList<>(); + private static final Map> WORTH = new EnumMap<>(BloodPath.class); + private static final TreeMap TIERS = new TreeMap<>(); + private static final Map SPELLS = new HashMap<>(); + + static { + reset(); + } + + private PoolConfig() {} + + static void reset() { + capacity = 100; + decayPercent = 1; + bloodPerPoint = 2; + floor = 5; + strangerRefill = 40; + defaultRefill = 40; + RANK_REFILLS.clear(); + WORTH.clear(); + TIERS.clear(); + SPELLS.clear(); + } + + static void load(ConfigurationSection pool, Logger log) { + reset(); + if (pool == null) { + return; + } + capacity = Math.max(1, pool.getDouble("capacity", 100)); + decayPercent = Math.max(0, pool.getDouble("decay_percent_per_hour", 1)); + bloodPerPoint = Math.max(0, pool.getDouble("blood_per_point", 2)); + floor = Math.max(0, pool.getDouble("floor", 5)); + strangerRefill = Math.max(0, pool.getDouble("refill.people.stranger", 40)); + defaultRefill = Math.max(0, pool.getDouble("refill.people.default", 40)); + for (Map raw : pool.getMapList("refill.people.ranks")) { + if (raw.get("up_to") instanceof Number upTo && raw.get("amount") instanceof Number amount) { + RANK_REFILLS.add(new RankAmount(upTo.intValue(), Math.max(0, amount.doubleValue()))); + } else { + log.warning("[Magic] blood magic config pool.refill.people.ranks entry needs up_to and amount"); + } + } + RANK_REFILLS.sort(Comparator.comparingInt(RankAmount::upTo)); + for (BloodPath path : List.of(BloodPath.DEVOTEE, BloodPath.TRUE)) { + ConfigurationSection worth = pool.getConfigurationSection("worth." + path.name().toLowerCase(Locale.ROOT)); + if (worth != null) { + WORTH.put(path, numbers(worth, "pool.worth." + path.name().toLowerCase(Locale.ROOT), log)); + } + } + TIERS.putAll(numbers(pool.getConfigurationSection("tiers"), "pool.tiers", log)); + ConfigurationSection spells = pool.getConfigurationSection("spells"); + if (spells != null) { + for (String key : spells.getKeys(false)) { + int tier = index(key); + if (tier < 1) { + log.warning("[Magic] blood magic config pool.spells entry '" + key + "' skipped"); + continue; + } + for (String spell : spells.getStringList(key)) { + if (!spell.isBlank()) { + SPELLS.put(spell.trim().toUpperCase(Locale.ROOT), tier); + } + } + } + } + } + + private static TreeMap numbers(ConfigurationSection section, String path, Logger log) { + TreeMap out = new TreeMap<>(); + if (section == null) { + return out; + } + for (String key : section.getKeys(false)) { + int index = index(key); + if (index < 0 || !section.isDouble(key) && !section.isInt(key)) { + log.warning("[Magic] blood magic config " + path + " entry '" + key + "' skipped"); + continue; + } + out.put(index, Math.max(0, section.getDouble(key))); + } + return out; + } + + private static int index(String key) { + try { + return Integer.parseInt(key.trim()); + } catch (NumberFormatException ex) { + return -1; + } + } + + public static double capacity() { + return capacity; + } + + /** Share (percent) of their blood resonance a mage loses per hour played, once the path is complete. */ + public static double decayPercent() { + return decayPercent; + } + + /** Pool blood one point of artefact charge costs, once the path is complete. */ + public static double bloodPerPoint() { + return bloodPerPoint; + } + + /** Blood resonance never sinks below this. */ + public static double floor() { + return floor; + } + + /** Blood a person pours back, by their closeness rank to the mage (0 = a stranger). */ + public static double refill(int rank) { + if (rank <= 0) { + return strangerRefill; + } + for (RankAmount tier : RANK_REFILLS) { + if (rank <= tier.upTo()) { + return tier.amount(); + } + } + return defaultRefill; + } + + /** + * Blood resonance the path's artefacts give when every one is in the pool: the highest entry at + * or below the final sacrifices done; 0 for an unknown path. + */ + public static double worth(BloodPath path, int finalsDone) { + TreeMap table = WORTH.get(path); + Map.Entry entry = table != null ? table.floorEntry(Math.max(0, finalsDone)) : null; + return entry != null ? entry.getValue() : 0; + } + + /** Blood resonance a spell tier needs; 0 for a tier with no entry. */ + public static double need(int tier) { + return TIERS.getOrDefault(tier, 0.0); + } + + /** The highest tier this much resonance reaches; 0 below every tier. */ + public static int band(double resonance) { + int band = 0; + for (Map.Entry tier : TIERS.entrySet()) { + if (resonance >= tier.getValue()) { + band = Math.max(band, tier.getKey()); + } + } + return band; + } + + /** The spell's tier, 0 when it is not a blood spell. */ + public static int tierOf(String spell) { + return spell == null ? 0 : SPELLS.getOrDefault(spell.trim().toUpperCase(Locale.ROOT), 0); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/QuestionService.java b/src/main/java/net/tfminecraft/magic/blood/QuestionService.java new file mode 100644 index 0000000..0d34e26 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/QuestionService.java @@ -0,0 +1,209 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.function.LongSupplier; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.artifact.sacrifice.SacrificeRegistry; + +/** + * The god's question, as a dialogue. Right-clicking the altar with the blood knife in its chamber + * starts it: the god asks over a few lines (the mage's own voice cuts in), then waits for the mage to + * answer aloud in roleplay chat. An answer is asked back once ("say it again") and sealed by the + * second; until then the other answer may still be given. Leaving the chamber or letting the time run + * out ends it, and it can be started again. Optional music plays to the mage alone throughout. + */ +public final class QuestionService { + + private static final long UNCLEAR_PAUSE = 6_000L; + + private static final class Session { + final String altarId; + final Deque lines = new ArrayDeque<>(); + long nextLineAt; + long endsAt; + long lastUnclearAt; + long musicAgainAt; + BloodPath heard; + BloodPath sealed; + + Session(String altarId) { + this.altarId = altarId; + } + } + + private static final Map SESSIONS = new HashMap<>(); + static LongSupplier clock = System::currentTimeMillis; + + private QuestionService() {} + + public static boolean asking(UUID player) { + return SESSIONS.containsKey(player); + } + + /** The voices ask; nothing happens while the question is already being asked or cannot be. */ + public static void start(Player player) { + if (asking(player.getUniqueId()) || !BloodChoice.canAnswer(player)) { + return; + } + BloodAltar altar = BloodAltars.ofCharacter(AltarService.currentCharacter(player)); + if (!inChamber(player, altar.getId())) { + return; + } + Session session = new Session(altar.getId()); + session.lines.addAll(ChoiceConfig.ask()); + session.nextLineAt = clock.getAsLong(); + SESSIONS.put(player.getUniqueId(), session); + music(player, session, clock.getAsLong()); + BloodFx.play("question", altar.center(), player); + // In the chamber and at the question, as just checked: the first line is said now. + step(player, session, clock.getAsLong()); + } + + /** Every second: the next line when it is due, and the end when the mage walks away or waits too long. */ + static void tick() { + long now = clock.getAsLong(); + for (Iterator> it = SESSIONS.entrySet().iterator(); it.hasNext();) { + Map.Entry entry = it.next(); + Player player = Bukkit.getPlayer(entry.getKey()); + if (player == null) { + it.remove(); + continue; + } + if (!step(player, entry.getValue(), now)) { + it.remove(); + } + } + } + + /** Moves the dialogue on; false when it is over (the caller drops the session). */ + private static boolean step(Player player, Session session, long now) { + if (session.sealed == null && (!BloodChoice.canAnswer(player) || !inChamber(player, session.altarId))) { + say(player, ChoiceConfig.walkedAway()); + silence(player); + return false; + } + if (session.musicAgainAt > 0 && now >= session.musicAgainAt) { + silence(player); + music(player, session, now); + } + if (!session.lines.isEmpty()) { + if (now >= session.nextLineAt) { + say(player, session.lines.poll()); + session.nextLineAt = now + ChoiceConfig.lineSeconds() * 1000L; + if (session.lines.isEmpty() && session.sealed == null && session.endsAt == 0) { + player.sendMessage(Messages.get("blood.choice.prompt")); + session.endsAt = now + ChoiceConfig.answerSeconds() * 1000L; + } + } + return true; + } + if (session.sealed != null) { + if (now < session.nextLineAt) { + return true; + } + silence(player); + BloodChoice.answer(player, session.sealed); + return false; + } + if (session.endsAt > 0 && now >= session.endsAt) { + say(player, ChoiceConfig.walkedAway()); + silence(player); + return false; + } + return true; + } + + /** Roleplay speech while the god waits: the first answer is asked back, the second seals it. */ + public static void onChat(Player player, String channel, String message) { + Session session = SESSIONS.get(player.getUniqueId()); + if (session == null || session.sealed != null || !session.lines.isEmpty() || session.endsAt == 0 + || channel == null || !SacrificeRegistry.getRpChannel().equals(channel.trim().toLowerCase(Locale.ROOT)) + || !inChamber(player, session.altarId)) { + return; + } + long now = clock.getAsLong(); + BloodPath path = classify(message); + if (path == null) { + if (now - session.lastUnclearAt >= UNCLEAR_PAUSE) { + session.lastUnclearAt = now; + say(player, ChoiceConfig.unclear()); + } + return; + } + ChoiceConfig.Answer answer = ChoiceConfig.answer(path); + session.lines.clear(); + if (path == session.heard) { + session.sealed = path; + session.lines.addAll(answer.sealed()); + } else { + session.heard = path; + session.lines.addAll(answer.confirm()); + session.endsAt = now + ChoiceConfig.answerSeconds() * 1000L; + } + session.nextLineAt = now + 1500L; + } + + /** Refusal first, so "I will not kneel" refuses; null when the words say neither. */ + static BloodPath classify(String message) { + String said = " " + normalize(message) + " "; + for (BloodPath path : new BloodPath[] {BloodPath.TRUE, BloodPath.DEVOTEE}) { + for (String word : ChoiceConfig.answer(path).words()) { + if (said.contains(" " + word + " ")) { + return path; + } + } + } + return null; + } + + /** Lower case, letters and apostrophes only, single spaces. */ + static String normalize(String text) { + return text.toLowerCase(Locale.ROOT).replaceAll("[^a-z' ]", " ").replaceAll("\\s+", " ").trim(); + } + + static boolean inChamber(Player player, String altarId) { + BloodAltar altar = AltarRoom.lockedBy(player.getLocation().getBlock()); + return altar != null && altar.getId().equals(altarId); + } + + private static void say(Player player, ChoiceConfig.Line line) { + if (line != null) { + WhisperService.speak(player, line.speaker(), line.text()); + } + } + + /** Starts the music, and when it loops, when to start it again. */ + private static void music(Player player, Session session, long now) { + if (ChoiceConfig.music().isEmpty()) { + return; + } + player.playSound(player.getLocation(), ChoiceConfig.music(), ChoiceConfig.musicCategory(), + ChoiceConfig.musicVolume(), 1f); + session.musicAgainAt = ChoiceConfig.musicLoopSeconds() > 0 ? now + ChoiceConfig.musicLoopSeconds() * 1000L : 0; + } + + private static void silence(Player player) { + if (!ChoiceConfig.music().isEmpty()) { + player.stopSound(ChoiceConfig.music(), ChoiceConfig.musicCategory()); + } + } + + /** Ends the player's question, e.g. when they leave. */ + public static void forget(UUID player) { + SESSIONS.remove(player); + } + + static void clear() { + SESSIONS.clear(); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/RoomTemplate.java b/src/main/java/net/tfminecraft/magic/blood/RoomTemplate.java new file mode 100644 index 0000000..784ce65 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/RoomTemplate.java @@ -0,0 +1,113 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Random; +import java.util.logging.Logger; + +import org.bukkit.Material; +import org.bukkit.Tag; +import org.bukkit.configuration.ConfigurationSection; + +/** + * The chamber every altar lays around itself (placement.room.template), the same for everyone: + * the floor, walls and ceiling in these blocks (an entry repeated weighs more) and hidden light + * in the four top corners. An empty list leaves that part of the room as it was. + */ +public final class RoomTemplate { + + private static List floor = List.of(); + private static List walls = List.of(); + private static List ceiling = List.of(); + private static int light = 0; + private static Material candle; + private static int candles = 0; + private static int altarLight = 0; + + private RoomTemplate() {} + + static void reset() { + floor = List.of(); + walls = List.of(); + ceiling = List.of(); + light = 0; + candle = null; + candles = 0; + altarLight = 0; + } + + static void load(ConfigurationSection section, Logger log) { + reset(); + if (section == null) { + return; + } + floor = solids(section.getStringList("floor"), "floor", log); + walls = solids(section.getStringList("walls"), "walls", log); + ceiling = solids(section.getStringList("ceiling"), "ceiling", log); + light = Math.max(0, Math.min(15, section.getInt("light", 0))); + altarLight = Math.max(0, Math.min(15, section.getInt("altar_light", 0))); + String raw = section.getString("candles.block", ""); + Material type = raw.isBlank() ? null : Material.matchMaterial(raw.trim().toUpperCase(Locale.ROOT)); + if (type != null && Tag.CANDLES.isTagged(type)) { + candle = type; + candles = Math.max(1, Math.min(4, section.getInt("candles.count", 3))); + } else if (!raw.isBlank()) { + log.warning("[Magic] blood magic config placement.room.template.candles.block: '" + raw + "' is not a candle"); + } + } + + /** The candle stood lit in the four floor corners, or null for none. */ + static Material candle() { + return candle; + } + + static int candles() { + return candles; + } + + /** Light level glowing from the altar (an unseen light just above it); 0 for none. */ + static int altarLight() { + return altarLight; + } + + private static List solids(List raw, String part, Logger log) { + List out = new ArrayList<>(); + for (String name : raw) { + Material material = Material.matchMaterial(name.trim().toUpperCase(Locale.ROOT)); + if (material == null || !material.isBlock() || !material.isSolid()) { + log.warning("[Magic] blood magic config placement.room.template." + part + ": '" + name + + "' is not a solid block"); + continue; + } + out.add(material); + } + return List.copyOf(out); + } + + static boolean hasFloor() { + return !floor.isEmpty(); + } + + /** A floor block, or null when the template has no floor. */ + static Material floor(Random random) { + return pick(floor, random); + } + + static Material wall(Random random) { + return pick(walls, random); + } + + static Material ceiling(Random random) { + return pick(ceiling, random); + } + + /** Light level of the hidden lights in the top corners; 0 for none. */ + static int light() { + return light; + } + + private static Material pick(List from, Random random) { + return from.isEmpty() ? null : from.get(random.nextInt(from.size())); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/StolenClues.java b/src/main/java/net/tfminecraft/magic/blood/StolenClues.java new file mode 100644 index 0000000..a150f29 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/StolenClues.java @@ -0,0 +1,128 @@ +package net.tfminecraft.magic.blood; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.function.LongSupplier; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.entity.LivingEntity; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.util.Transformation; +import org.joml.AxisAngle4f; +import org.joml.Vector3f; + +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.util.ItemRef; + +/** + * A stolen animal leaves small clues: a trace where it was led away from, for a few hours, and a + * vague note to each of its keepers saying which way the drag marks lead. + */ +public final class StolenClues { + + private static final long LEASH_MEMORY = 3_600_000L; + private static final String[] DIRECTIONS = { + "south", "southwest", "west", "northwest", "north", "northeast", "east", "southeast" + }; + + private record Origin(Location location, long at) {} + + private static final Map LEASHED = new HashMap<>(); + static LongSupplier clock = System::currentTimeMillis; + + private StolenClues() {} + + public static NamespacedKey traceKey() { + return new NamespacedKey(Magic.plugin, "blood_trace"); + } + + /** Remembers where an animal stood when someone put it on a lead. */ + public static void leashed(Entity animal) { + LEASHED.put(animal.getUniqueId(), new Origin(animal.getLocation().clone(), clock.getAsLong())); + } + + /** Leaves the trace and the keepers' notes for an animal taken to the altar. */ + public static void leave(LivingEntity animal, List owners, Location altar) { + long now = clock.getAsLong(); + Origin origin = LEASHED.remove(animal.getUniqueId()); + String name = animal.getCustomName() != null ? animal.getCustomName() + : "your " + animal.getType().name().toLowerCase(Locale.ROOT).replace('_', ' '); + String text; + if (origin != null && origin.location().getWorld() == altar.getWorld()) { + trace(origin.location(), now); + String direction = Messages.get("blood.direction." + direction(origin.location(), altar)); + text = Messages.get("blood.animal.note", "animal", name, "direction", direction); + } else { + text = Messages.get("blood.animal.note_lost", "animal", name); + } + for (HusbandryOwners.Owner owner : owners) { + BloodNotes.add(new BloodNotes.Note(owner.account().toString(), + owner.character() != null ? owner.character().toString() : null, text, + now + AnimalConfig.noteDelayMillis())); + } + } + + /** Eight-point compass direction from one place to another. */ + static String direction(Location from, Location to) { + double angle = Math.toDegrees(Math.atan2(-(to.getX() - from.getX()), to.getZ() - from.getZ())); + int index = (int) Math.round((angle + 360) % 360 / 45.0) % 8; + return DIRECTIONS[index]; + } + + private static void trace(Location at, long now) { + Location spot = at.getBlock().getLocation().clone().add(0.5, 0.02, 0.5); + long expires = now + AnimalConfig.traceMillis(); + spot.getWorld().spawn(spot, ItemDisplay.class, display -> { + display.setItemStack(ItemRef.buildOrFallback(AnimalConfig.traceModel(), Material.REDSTONE)); + display.setPersistent(true); + display.setTransformation(new Transformation(new Vector3f(), + new AxisAngle4f((float) Math.toRadians(-90), 1f, 0f, 0f), + new Vector3f(0.6f, 0.6f, 0.6f), new AxisAngle4f())); + display.getPersistentDataContainer().set(traceKey(), PersistentDataType.LONG, expires); + }); + } + + /** Removes traces whose time is up among the given entities. */ + public static void removeExpired(Collection entities) { + long now = clock.getAsLong(); + for (Entity entity : entities) { + Long expires = entity.getPersistentDataContainer().get(traceKey(), PersistentDataType.LONG); + if (expires != null && now >= expires) { + entity.remove(); + } + } + } + + /** Minute tick: forget old leads and clear expired traces in loaded worlds. */ + static void tick() { + long now = clock.getAsLong(); + for (Iterator it = LEASHED.values().iterator(); it.hasNext();) { + if (now - it.next().at() > LEASH_MEMORY) { + it.remove(); + } + } + for (World world : Bukkit.getWorlds()) { + removeExpired(world.getEntitiesByClass(ItemDisplay.class)); + } + } + + static boolean remembers(UUID animal) { + return LEASHED.containsKey(animal); + } + + public static void clear() { + LEASHED.clear(); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/VoiceColours.java b/src/main/java/net/tfminecraft/magic/blood/VoiceColours.java new file mode 100644 index 0000000..10fe686 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/VoiceColours.java @@ -0,0 +1,79 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import net.md_5.bungee.api.ChatColor; + +/** + * The voices' colours drift as the path goes on: each voice has a gradient at the start and one at + * the end, and a line is drawn in the blend between them for how far the character has come. + */ +final class VoiceColours { + + private static final Pattern HEX = Pattern.compile("#[0-9a-fA-F]{6}"); + + private VoiceColours() {} + + static boolean valid(String hex) { + return hex != null && HEX.matcher(hex.trim()).matches(); + } + + /** The gradient stops at t (0 = start, 1 = end); both ends are resampled to the same stop count. */ + static List at(List start, List end, double t) { + if (start.isEmpty() || end.isEmpty()) { + return start.isEmpty() ? end : start; + } + double clamped = Math.max(0.0, Math.min(1.0, t)); + int stops = Math.max(start.size(), end.size()); + List out = new ArrayList<>(stops); + for (int i = 0; i < stops; i++) { + double position = stops == 1 ? 0.0 : (double) i / (stops - 1); + out.add(hex(lerp(sample(start, position), sample(end, position), clamped))); + } + return out; + } + + /** The text with a colour per character along the stops, the style codes repeated after each colour. */ + static String gradient(String text, List stops, String style) { + StringBuilder out = new StringBuilder(text.length() * 20); + int length = text.length(); + for (int i = 0; i < length; i++) { + char ch = text.charAt(i); + if (Character.isWhitespace(ch)) { + out.append(ch); + continue; + } + double position = length == 1 ? 0.0 : (double) i / (length - 1); + out.append(ChatColor.of(hex(sample(stops, position)))).append(style).append(ch); + } + return out.toString(); + } + + static int[] sample(List stops, double position) { + if (stops.size() == 1) { + return rgb(stops.get(0)); + } + double scaled = position * (stops.size() - 1); + int segment = Math.min((int) Math.floor(scaled), stops.size() - 2); + return lerp(rgb(stops.get(segment)), rgb(stops.get(segment + 1)), scaled - segment); + } + + private static int[] lerp(int[] from, int[] to, double t) { + return new int[] { + (int) Math.round(from[0] + (to[0] - from[0]) * t), + (int) Math.round(from[1] + (to[1] - from[1]) * t), + (int) Math.round(from[2] + (to[2] - from[2]) * t) + }; + } + + private static int[] rgb(String hex) { + int value = Integer.parseInt(hex.trim().substring(1), 16); + return new int[] {(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF}; + } + + static String hex(int[] rgb) { + return String.format("#%02x%02x%02x", rgb[0], rgb[1], rgb[2]); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java b/src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java new file mode 100644 index 0000000..1dbc30a --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java @@ -0,0 +1,258 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.List; +import java.util.Collections; +import java.util.TreeMap; +import java.util.NavigableMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.EntityType; + +/** The whisper meter and the two voices, from the whispers: and voices: sections of the wildmagic/bloodmagic config. */ +public final class WhisperConfig { + + /** Who speaks. The god until a mage refuses it; after that only the mage's own dark self. */ + public enum Speaker { + GOD, + SELF + } + + /** + * How a speaker sounds, and its lines per stage. With colours, a line is a gradient that drifts + * from the start stops to the end ones along the path, style (e.g. italics) after each colour; + * without, style is the whole prefix. + */ + public record Voice(String style, String sound, float pitch, Map> lines, + List start, List end, Map>> tiers) { + /** Every line of the stage, all tiers together. */ + public List lines(BloodStage stage) { + return lines.getOrDefault(stage, List.of()); + } + + /** Whether the stage's lines come in tiers by progress. */ + public boolean tiered(BloodStage stage) { + return tiers.containsKey(stage); + } + + /** The tier for this much progress: the highest one reached, else the lowest. */ + public List lines(BloodStage stage, int progress) { + NavigableMap> tier = tiers.get(stage); + if (tier == null) { + return lines(stage); + } + Map.Entry> reached = tier.floorEntry(progress); + return reached != null ? reached.getValue() : tier.firstEntry().getValue(); + } + + public boolean coloured() { + return !start.isEmpty() || !end.isEmpty(); + } + } + + private static double threshold = 30; + private static double decayPerHour = 1; + private static Set fastTraits = Set.of(); + private static double fastMultiplier = 3; + private static double animalKill = 2; + private static double aloneOrNightMultiplier = 2; + private static double characterKill = 15; + private static double corruption = 1; + private static double corruptionBelow = -40; + private static Set animals = Set.of(); + private static int minInterval = 15; + private static int maxInterval = 35; + private static boolean requireAlone = true; + private static double aloneRadius = 24; + private static final Map VOICES = new EnumMap<>(Speaker.class); + + static { + reset(); + } + + private WhisperConfig() {} + + static void reset() { + threshold = 30; + decayPerHour = 1; + fastTraits = Set.of(); + fastMultiplier = 3; + animalKill = 2; + aloneOrNightMultiplier = 2; + characterKill = 15; + corruption = 1; + corruptionBelow = -40; + animals = Set.of(); + minInterval = 15; + maxInterval = 35; + requireAlone = true; + aloneRadius = 24; + VOICES.clear(); + VOICES.put(Speaker.GOD, new Voice("", "", 1f, Map.of(), List.of(), List.of(), Map.of())); + VOICES.put(Speaker.SELF, new Voice("", "", 1f, Map.of(), List.of(), List.of(), Map.of())); + } + + static void load(ConfigurationSection whispers, ConfigurationSection voices, Logger log) { + reset(); + if (whispers != null) { + threshold = Math.max(1, whispers.getDouble("threshold", 30)); + decayPerHour = Math.max(0, whispers.getDouble("decay_per_hour", 1)); + Set traits = new HashSet<>(); + for (String trait : whispers.getStringList("fast_traits")) { + traits.add(trait.trim().toLowerCase(Locale.ROOT)); + } + fastTraits = Set.copyOf(traits); + fastMultiplier = Math.max(1, whispers.getDouble("fast_multiplier", 3)); + animalKill = Math.max(0, whispers.getDouble("sources.animal_kill", 2)); + aloneOrNightMultiplier = Math.max(1, whispers.getDouble("sources.alone_or_night_multiplier", 2)); + characterKill = Math.max(0, whispers.getDouble("sources.character_kill", 15)); + corruption = Math.max(0, whispers.getDouble("sources.corruption", 1)); + corruptionBelow = whispers.getDouble("sources.corruption_below", -40); + Set types = new HashSet<>(); + for (String raw : whispers.getStringList("animal_types")) { + try { + types.add(EntityType.valueOf(raw.trim().toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException ex) { + log.warning("[Magic] blood magic config whispers.animal_types: unknown entity '" + raw + "'"); + } + } + animals = Set.copyOf(types); + } + if (voices != null) { + List interval = voices.getIntegerList("interval_minutes"); + if (interval.size() == 2) { + minInterval = Math.max(1, Math.min(interval.get(0), interval.get(1))); + maxInterval = Math.max(minInterval, Math.max(interval.get(0), interval.get(1))); + } + requireAlone = voices.getBoolean("require_alone", true); + aloneRadius = Math.max(1, voices.getDouble("alone_radius", 24)); + for (Speaker speaker : Speaker.values()) { + ConfigurationSection section = voices.getConfigurationSection(speaker.name().toLowerCase(Locale.ROOT)); + if (section != null) { + VOICES.put(speaker, voice(section, log)); + } + } + } + } + + private static Voice voice(ConfigurationSection section, Logger log) { + Map> lines = new EnumMap<>(BloodStage.class); + Map>> tiers = new EnumMap<>(BloodStage.class); + ConfigurationSection stages = section.getConfigurationSection("lines"); + if (stages != null) { + for (String key : stages.getKeys(false)) { + BloodStage stage = BloodStage.parse(key); + if (stage == null) { + log.warning("[Magic] blood magic config voice lines: unknown stage '" + key + "'"); + continue; + } + ConfigurationSection tiered = stages.getConfigurationSection(key); + if (tiered == null) { + lines.put(stage, List.copyOf(new ArrayList<>(stages.getStringList(key)))); + continue; + } + NavigableMap> byProgress = new TreeMap<>(); + List all = new ArrayList<>(); + for (String from : tiered.getKeys(false)) { + try { + List tier = List.copyOf(tiered.getStringList(from)); + byProgress.put(Integer.parseInt(from.trim()), tier); + all.addAll(tier); + } catch (NumberFormatException ex) { + log.warning("[Magic] blood magic config voice lines." + key + ": tier '" + from + "' is not a number"); + } + } + if (!byProgress.isEmpty()) { + tiers.put(stage, Collections.unmodifiableNavigableMap(byProgress)); + lines.put(stage, List.copyOf(all)); + } + } + } + return new Voice(section.getString("style", ""), section.getString("sound", ""), + (float) section.getDouble("pitch", 1.0), lines, + colours(section, "colors.start", log), colours(section, "colors.end", log), tiers); + } + + private static List colours(ConfigurationSection section, String path, Logger log) { + List out = new ArrayList<>(); + for (String raw : section.getStringList(path)) { + if (VoiceColours.valid(raw)) { + out.add(raw.trim()); + } else { + log.warning("[Magic] blood magic config voice " + path + ": '" + raw + "' is not a #rrggbb colour"); + } + } + return List.copyOf(out); + } + + public static double threshold() { + return threshold; + } + + public static double decayPerHour() { + return decayPerHour; + } + + public static boolean fastTrait(Set traits) { + for (String trait : traits) { + if (trait != null && fastTraits.contains(trait.toLowerCase(Locale.ROOT))) { + return true; + } + } + return false; + } + + public static double fastMultiplier() { + return fastMultiplier; + } + + public static double animalKill() { + return animalKill; + } + + public static double aloneOrNightMultiplier() { + return aloneOrNightMultiplier; + } + + public static double characterKill() { + return characterKill; + } + + public static double corruption() { + return corruption; + } + + public static double corruptionBelow() { + return corruptionBelow; + } + + public static boolean isAnimal(EntityType type) { + return animals.contains(type); + } + + public static int minInterval() { + return minInterval; + } + + public static int maxInterval() { + return maxInterval; + } + + public static boolean requireAlone() { + return requireAlone; + } + + public static double aloneRadius() { + return aloneRadius; + } + + public static Voice voice(Speaker speaker) { + return VOICES.get(speaker); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/WhisperService.java b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java new file mode 100644 index 0000000..e4fd5cb --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java @@ -0,0 +1,350 @@ +package net.tfminecraft.magic.blood; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.function.LongSupplier; +import java.util.function.ToDoubleFunction; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.session.ResonanceSession; +import net.tfminecraft.magic.tick.MagicTickContext; +import net.tfminecraft.magic.util.MagicText; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.Characters; + +/** + * The whisper meter and the voices. Dark deeds fill a hidden meter; past its threshold the god + * starts to speak, teaches the rite in order, then hands over the knife and the altar. Later + * stages hear a line now and then. A mage who refused the god only hears their own dark self. + */ +public final class WhisperService { + + private static final long MINUTE = 60_000L; + private static final long FIRST_VOICE_DELAY = MINUTE; + private static final Map NEXT_VOICE = new HashMap<>(); + + static LongSupplier clock = System::currentTimeMillis; + static Random random = new Random(); + /** The player's equilibrium, NaN without a resonance session. */ + static ToDoubleFunction equilibrium = WhisperService::sessionEquilibrium; + + private WhisperService() {} + + /** Adds whisper weight to the player's current character. */ + public static void add(Player player, double amount) { + String character = AltarService.currentCharacter(player); + if (character != null) { + addTo(character, player.getUniqueId().toString(), amount, player); + } + } + + /** + * Adds whisper weight to a character, raised for fast-trait characters. The meter only fills + * before the voices start. Returns true when this woke them. + */ + public static boolean addTo(String character, String owner, double amount, Player online) { + if (!BloodConfig.isEnabled() || amount <= 0) { + return false; + } + BloodRecord record = BloodRecords.getOrCreate(character, owner); + if (record.getStage() != BloodStage.NONE) { + return false; + } + double gain = amount * (fastTrait(character) ? WhisperConfig.fastMultiplier() : 1); + record.setWhisper(record.getWhisper() + gain); + boolean woke = record.getWhisper() >= WhisperConfig.threshold(); + if (woke) { + awaken(record, online); + } + BloodRecords.save(record); + return woke; + } + + /** Starts the voices for the record; the first line comes a minute later. */ + public static void awaken(BloodRecord record, Player online) { + record.setStage(BloodStage.WHISPERED); + if (online != null) { + NEXT_VOICE.put(online.getUniqueId(), clock.getAsLong() + FIRST_VOICE_DELAY); + } + Magic.plugin.getLogger().info("[Magic] The voices woke for character " + record.getCharacterId()); + } + + static boolean fastTrait(String character) { + try { + return Characters.info(UUID.fromString(character)).map(CharacterInfo::traits) + .map(WhisperConfig::fastTrait).orElse(false); + } catch (IllegalArgumentException ex) { + return false; + } + } + + /** Whisper weight for killing a listed animal with a blade, more when alone or at night. */ + public static double animalKillWeight(Player killer, Entity animal) { + if (!WhisperConfig.isAnimal(animal.getType()) || !blade(killer.getInventory().getItemInMainHand())) { + return 0; + } + long time = animal.getWorld().getTime(); + boolean night = time >= 13_000 && time < 23_000; + return WhisperConfig.animalKill() * (night || alone(killer) ? WhisperConfig.aloneOrNightMultiplier() : 1); + } + + static boolean blade(ItemStack item) { + if (item == null || item.getType() == Material.AIR) { + return false; + } + String name = item.getType().name(); + return name.endsWith("_SWORD") || name.endsWith("_AXE") || BloodItems.kind(item) != null; + } + + /** No other player within the configured radius. */ + static boolean alone(Player player) { + double radius = WhisperConfig.aloneRadius(); + for (Player other : player.getWorld().getPlayers()) { + if (other != player && other.getLocation().distanceSquared(player.getLocation()) <= radius * radius) { + return false; + } + } + return true; + } + + /** Runs every second from the Magic tick; real work happens once a minute and every ten. */ + static void tick(MagicTickContext context) { + if (!BloodConfig.isEnabled() || !context.everyMinutes(1)) { + return; + } + boolean tenth = context.everyMinutes(10); + for (Player player : Bukkit.getOnlinePlayers()) { + String character = AltarService.currentCharacter(player); + if (character == null) { + continue; + } + if (tenth) { + tenMinutes(player, character); + } + BloodRecord record = BloodRecords.get(character); + if (record != null && record.getStage() != BloodStage.NONE) { + voiceIfDue(player, record); + } + } + } + + private static void tenMinutes(Player player, String character) { + double eq = equilibrium.applyAsDouble(player); + if (!Double.isNaN(eq) && eq <= WhisperConfig.corruptionBelow()) { + addTo(character, player.getUniqueId().toString(), WhisperConfig.corruption(), player); + } + BloodRecord record = BloodRecords.get(character); + if (record != null && record.getStage() == BloodStage.NONE && record.getWhisper() > 0) { + record.setWhisper(record.getWhisper() - WhisperConfig.decayPerHour() / 6.0); + BloodRecords.save(record); + } + } + + private static void voiceIfDue(Player player, BloodRecord record) { + long now = clock.getAsLong(); + Long due = NEXT_VOICE.get(player.getUniqueId()); + if (due == null) { + schedule(player, now); + return; + } + if (now < due || (WhisperConfig.requireAlone() && !alone(player)) || !atTheStone(player, record)) { + return; + } + speakNext(player, record); + schedule(player, now); + } + + private static void schedule(Player player, long now) { + int span = WhisperConfig.maxInterval() - WhisperConfig.minInterval(); + int minutes = WhisperConfig.minInterval() + (span > 0 ? random.nextInt(span + 1) : 0); + NEXT_VOICE.put(player.getUniqueId(), now + minutes * MINUTE); + } + + /** Says the next line for the record's stage. The teaching lines end in the gift. */ + public static void speakNext(Player player, BloodRecord record) { + WhisperConfig.Speaker speaker = speaker(record); + // From the knife on, the mage's own thoughts answer the god half the time. + if ((record.getStage() == BloodStage.ANIMAL || record.getStage() == BloodStage.CHOICE) + && !WhisperConfig.voice(WhisperConfig.Speaker.SELF).lines(record.getStage()).isEmpty() + && random.nextBoolean()) { + speaker = WhisperConfig.Speaker.SELF; + } + List lines = WhisperConfig.voice(speaker).lines(record.getStage()); + if (record.getStage() == BloodStage.WHISPERED) { + int index = record.getWhisperLine(); + if (index < lines.size()) { + speak(player, speaker, lines.get(index)); + record.setWhisperLine(index + 1); + } + if (record.getWhisperLine() >= lines.size()) { + gift(player, record); + } + BloodRecords.save(record); + return; + } + WhisperConfig.Voice voice = WhisperConfig.voice(speaker); + if (voice.tiered(record.getStage())) { + lines = voice.lines(record.getStage(), record.getSpecies().size()); + if (!lines.isEmpty()) { + speak(player, speaker, lines.get(random.nextInt(lines.size()))); + } + return; + } + if (!lines.isEmpty()) { + speak(player, speaker, lines.get(pick(lines.size(), stageProgress(record)))); + } + } + + /** + * The animal-stage voices come only to the altar's chamber, while the stone is ready for the next + * kind and no rite is under way; with no altar standing they keep quiet. Other stages, anywhere. + */ + static boolean atTheStone(Player player, BloodRecord record) { + if (record.getStage() != BloodStage.ANIMAL) { + return true; + } + BloodAltar altar = BloodAltars.ofCharacter(record.getCharacterId()); + if (altar == null) { + return false; + } + boolean ready = record.getLastAnimalAt() <= 0 + || clock.getAsLong() - record.getLastAnimalAt() >= AnimalConfig.cooldownMillis(); + return ready && AltarRoom.lockedBy(player.getLocation().getBlock()) == altar + && AnimalRiteService.bindingOf(player.getUniqueId()) == null; + } + + /** + * A line index. Lines are written from early to late: with a stage progress (0 to 1) the pick + * comes from a third of the list that slides along with it; without (below 0), from all of it. + */ + static int pick(int size, double stageProgress) { + if (stageProgress < 0) { + return random.nextInt(size); + } + int window = Math.max(1, (size + 2) / 3); + int start = (int) Math.round(Math.min(1.0, stageProgress) * (size - window)); + return start + random.nextInt(window); + } + + /** How far through its stage the record is; -1 for stages whose lines come in any order. */ + static double stageProgress(BloodRecord record) { + if (record.getStage() == BloodStage.ANIMAL) { + return (double) record.getSpecies().size() / Math.max(1, AnimalConfig.speciesRequired()); + } + return -1; + } + + /** How far along the whole path the record is, 0 to 1: what the voices' colours follow. */ + static double pathProgress(BloodRecord record) { + if (record == null) { + return 0; + } + if (record.getStage() == BloodStage.ASCENDED) { + return 1; + } + int whole = AnimalConfig.speciesRequired() + 1 + BloodConfig.huntPointsRequired() + 2 * HuntConfig.finalCount(); + return Math.min(1.0, (double) BloodProgress.of(record) / Math.max(1, whole)); + } + + /** The line in the speaker's style, coloured for how far the player's character has come. */ + static String voiced(Player player, WhisperConfig.Speaker speaker, String line) { + WhisperConfig.Voice voice = WhisperConfig.voice(speaker); + if (!voice.coloured()) { + return MagicText.format(voice.style() + line); + } + String character = AltarService.currentCharacter(player); + double progress = pathProgress(character != null ? BloodRecords.get(character) : null); + return VoiceColours.gradient(line, VoiceColours.at(voice.start(), voice.end(), progress), + MagicText.format(voice.style())); + } + + /** + * A blood message; one written as {god}... or {self}... is said in that voice's colours, and + * {stone}... (narration of the stone) in the colours of whoever speaks to the mage now: the god, + * or after refusing him, their own self. + */ + public static void tell(Player player, String key) { + String raw = Messages.getRaw(key); + if (raw != null && raw.startsWith("{stone}")) { + String character = AltarService.currentCharacter(player); + BloodRecord record = character != null ? BloodRecords.get(character) : null; + WhisperConfig.Speaker speaker = record != null ? speaker(record) : WhisperConfig.Speaker.GOD; + player.sendMessage(voiced(player, speaker, raw.substring("{stone}".length()))); + return; + } + for (WhisperConfig.Speaker speaker : WhisperConfig.Speaker.values()) { + String tag = "{" + speaker.name().toLowerCase(Locale.ROOT) + "}"; + if (raw != null && raw.startsWith(tag)) { + player.sendMessage(voiced(player, speaker, raw.substring(tag.length()))); + return; + } + } + player.sendMessage(Messages.get(key)); + } + + public static WhisperConfig.Speaker speaker(BloodRecord record) { + return record.getPath() == BloodPath.TRUE ? WhisperConfig.Speaker.SELF : WhisperConfig.Speaker.GOD; + } + + /** One line to this player alone, in the speaker's style and sound. */ + public static void speak(Player player, WhisperConfig.Speaker speaker, String line) { + WhisperConfig.Voice voice = WhisperConfig.voice(speaker); + player.sendMessage(voiced(player, speaker, line)); + if (!voice.sound().isBlank()) { + player.playSound(player.getLocation(), voice.sound(), 0.6f, voice.pitch()); + } + BloodFx.play(speaker == WhisperConfig.Speaker.SELF ? "voice_self" : "voice_god", player.getLocation(), player); + } + + /** The voices hand over the ritual knife and, unless one stands already, the altar. */ + static void gift(Player player, BloodRecord record) { + give(player, BloodItems.create(BloodItemKind.RITUAL_KNIFE, record.getCharacterId())); + if (BloodAltars.ofCharacter(record.getCharacterId()) == null) { + give(player, BloodItems.create(BloodItemKind.ALTAR, record.getCharacterId())); + } + record.setStage(BloodStage.ANIMAL); + BloodFx.play("gift", player.getLocation(), player); + Magic.plugin.getLogger().info("[Magic] Character " + record.getCharacterId() + + " was given the ritual knife and altar"); + } + + static void give(Player player, ItemStack item) { + for (ItemStack left : player.getInventory().addItem(item).values()) { + player.getWorld().dropItemNaturally(player.getLocation(), left); + } + } + + /** Forget the player's voice timer, e.g. after a quit or a character switch. */ + public static void forget(UUID player) { + NEXT_VOICE.remove(player); + } + + /** Lets the next minute tick speak to the player straight away. */ + public static void voiceSoon(Player player) { + NEXT_VOICE.put(player.getUniqueId(), clock.getAsLong()); + } + + static Long nextVoice(UUID player) { + return NEXT_VOICE.get(player); + } + + public static void clear() { + NEXT_VOICE.clear(); + } + + private static double sessionEquilibrium(Player player) { + ResonanceSession session = Magic.plugin.getResonanceGuiManager().getSessionManager().get(player); + return session != null ? session.getEquilibrium() : Double.NaN; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/WorldGuardAltarRegions.java b/src/main/java/net/tfminecraft/magic/blood/WorldGuardAltarRegions.java new file mode 100644 index 0000000..10e9e4a --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/WorldGuardAltarRegions.java @@ -0,0 +1,29 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import org.bukkit.Location; + +import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldguard.WorldGuard; +import com.sk89q.worldguard.protection.regions.ProtectedRegion; + +/** Only loaded once WorldGuard is known to be enabled. The global region is never listed. */ +final class WorldGuardAltarRegions { + + /** WorldEdit's adapter; tests swap it because it cannot initialise without a server. */ + static Function adapter = BukkitAdapter::adapt; + + private WorldGuardAltarRegions() {} + + static List regionsAt(Location location) { + List ids = new ArrayList<>(); + for (ProtectedRegion region : WorldGuard.getInstance().getPlatform().getRegionContainer() + .createQuery().getApplicableRegions(adapter.apply(location))) { + ids.add(region.getId()); + } + return ids; + } +} diff --git a/src/main/java/net/tfminecraft/magic/command/MagicCommand.java b/src/main/java/net/tfminecraft/magic/command/MagicCommand.java index 91dd691..3bd161f 100644 --- a/src/main/java/net/tfminecraft/magic/command/MagicCommand.java +++ b/src/main/java/net/tfminecraft/magic/command/MagicCommand.java @@ -37,6 +37,7 @@ import net.tfminecraft.magic.artifact.generate.ArtifactRoller; import net.tfminecraft.magic.artifact.shrine.ShrineChargeService; import net.tfminecraft.magic.attunement.AuraLog; +import net.tfminecraft.magic.blood.BloodCommand; import net.tfminecraft.magic.gear.GearRefresher; import net.tfminecraft.magic.gear.RuneKeybind; import net.tfminecraft.magic.model.ElementDef; @@ -49,7 +50,7 @@ public final class MagicCommand implements CommandExecutor, TabCompleter { private static final List SUBCOMMANDS = List.of( "reload", "open", "artifact", "fillchest", "resonance", "shrine", "refresh", - "player", "equilibrium", "castmode", "gear"); + "player", "equilibrium", "castmode", "gear", "blood"); @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { @@ -85,6 +86,9 @@ public boolean onCommand(CommandSender sender, Command command, String label, St if ("gear".equalsIgnoreCase(args[0])) { return HeldValuesCommand.execute(sender, args); } + if ("blood".equalsIgnoreCase(args[0])) { + return BloodCommand.execute(sender, args); + } if ("reload".equalsIgnoreCase(args[0])) { boolean ok = Magic.plugin.reloadAll(); @@ -697,7 +701,7 @@ public List onTabComplete(CommandSender sender, Command command, String options.add("rune"); } if (admin) { - options.addAll(isPlayer ? SUBCOMMANDS : List.of("reload", "artifact", "resonance", "player", "equilibrium", "castmode")); + options.addAll(isPlayer ? SUBCOMMANDS : List.of("reload", "artifact", "resonance", "player", "equilibrium", "castmode", "blood")); } return filterPrefix(options, args[0]); } @@ -728,6 +732,9 @@ public List onTabComplete(CommandSender sender, Command command, String if ("gear".equalsIgnoreCase(args[0])) { return isPlayer ? filterPrefix(HeldValuesCommand.complete(args), args[args.length - 1]) : Collections.emptyList(); } + if ("blood".equalsIgnoreCase(args[0])) { + return filterPrefix(BloodCommand.complete(args), args[args.length - 1]); + } if (!isPlayer && List.of("open", "refresh", "rune", "fillchest", "shrine").contains(args[0].toLowerCase(Locale.ROOT))) { return Collections.emptyList(); } diff --git a/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java b/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java index 77d6c8a..a19cc38 100644 --- a/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java +++ b/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java @@ -1,12 +1,20 @@ package net.tfminecraft.magic.integration; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Supplier; + +import org.bukkit.Location; import org.bukkit.entity.Player; import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.rpcharacters.RPCharacters; import net.tfminecraft.rpcharacters.identity.DisplayIdentityService; +import net.tfminecraft.rpcharacters.loaders.TraitLoader; +import net.tfminecraft.rpcharacters.objects.trait.Trait; import net.tfminecraft.rpcharacters.permadeath.PermadeathService; import net.tfminecraft.rpcharacters.permadeath.PermakillCause; +import net.tfminecraft.rpcharacters.utils.TraitChangeService; import net.tfminecraft.magic.artifact.sacrifice.SacrificeRegistry; import net.tfminecraft.magic.artifact.sacrifice.SacrificeTierDef; import net.tfminecraft.tlibs.character.CharacterSession; @@ -14,6 +22,8 @@ public final class RpCharactersBridge { + static Supplier knockoutManager = RPCharacters::getPvpKnockoutManager; + private RpCharactersBridge() {} public static boolean isAvailable() { @@ -37,6 +47,97 @@ public static String resolveRealName(RPCharacter character) { return DisplayIdentityService.colourPlain(character.getName(), character.getNameColour()); } + /** Counted RP exchanges of the player's active character with each other character id. */ + public static Map conversationCounts(Player player) { + RPCharacter character = getActiveCharacter(player); + return character != null ? new HashMap<>(character.getConversationCounts()) : Map.of(); + } + + /** The character id the active character's will names as heir, or null. */ + public static String willHeir(Player player) { + RPCharacter character = getActiveCharacter(player); + return character != null && character.getWill().named() ? character.getWill().heir().toString() : null; + } + + /** Whether the player lies knocked out. */ + public static boolean isDown(Player player) { + var knockouts = RPCharacters.getPvpKnockoutManager(); + return knockouts != null && knockouts.isDown(player); + } + + /** + * Moves a knocked-out player and keeps them down there. RPCharacters without moveDowned holds a + * knockout where it happened; there the player is only teleported and its freeze pulls them back. + */ + public static boolean moveDowned(Player player, Location to) { + Object knockouts = knockoutManager.get(); + if (knockouts != null) { + try { + Object moved = knockouts.getClass().getMethod("moveDowned", Player.class, Location.class) + .invoke(knockouts, player, to); + return Boolean.TRUE.equals(moved); + } catch (ReflectiveOperationException ex) { + // Older RPCharacters: a plain teleport below. + } + } + return player.teleport(to); + } + + /** Seconds since the active character was created, and its online playtime; 0 without one. */ + public static long characterAgeSeconds(Player player) { + RPCharacter character = getActiveCharacter(player); + return character != null ? RPCharacters.getCharacterAgeSeconds(character) : 0; + } + + public static long playtimeSeconds(Player player) { + RPCharacter character = getActiveCharacter(player); + return character != null ? character.getOnlinePlaytimeSeconds() : 0; + } + + /** Kills the victim's active character for good as a sacrifice by the mage. */ + public static boolean sacrifice(Player victim, Player mage) { + RPCharacter character = getActiveCharacter(victim); + return character != null && PermadeathService.killCharacter(victim, character, PermakillCause.SACRIFICE, mage); + } + + /** Gives the player's active character a random healable injury; false without one. */ + public static boolean applyHealingInjury(Player player) { + RPCharacter character = getActiveCharacter(player); + return character != null && PermadeathService.applyRandomInjury(player, character); + } + + /** + * Gives the player's active character this trait (a permanent injury, say) with its message, unless + * it already has it; false without an active character or for an unknown trait. + */ + public static boolean applyTrait(Player player, String traitId) { + RPCharacter character = getActiveCharacter(player); + Trait trait = traitId != null ? TraitLoader.getByString(traitId) : null; + if (character == null || trait == null) { + return false; + } + if (!hasTrait(character, traitId)) { + TraitChangeService.addTrait(player, character, trait); + TraitChangeService.sendGainedMessage(player, trait); + } + return true; + } + + /** Whether the player's active character has this trait. */ + public static boolean hasTrait(Player player, String traitId) { + RPCharacter character = getActiveCharacter(player); + return character != null && traitId != null && hasTrait(character, traitId); + } + + private static boolean hasTrait(RPCharacter character, String traitId) { + for (Trait trait : character.getTraits()) { + if (trait.getId().equalsIgnoreCase(traitId)) { + return true; + } + } + return false; + } + /** * @return false when required consequences are unavailable or permanent death was refused */ diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java index bf5c522..6e9d251 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java @@ -29,6 +29,7 @@ import net.tfminecraft.interactiblefurniture.furniture.Furniture; import net.tfminecraft.magic.Magic; import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.blood.BloodAltars; import net.tfminecraft.magic.profile.MagicProfileService; import net.tfminecraft.magic.session.ResonanceSession; import net.tfminecraft.magic.session.ResonanceSessionManager; @@ -166,7 +167,9 @@ private void finishSession(UUID playerId, Player player) { } private void tryStart(Player player) { - if (!player.isInsideVehicle()) { + // A blood altar's ring holds blood artefacts, and sitting there is blood meditation's + // (net.tfminecraft.magic.blood.BloodMeditation): this circle would re-read them every tick. + if (!player.isInsideVehicle() || BloodAltars.at(player.getLocation().getBlock()) != null) { return; } CharacterSession identity = Characters.currentSession(player).orElse(null); diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 649c80d..7a8db58 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -20,7 +20,7 @@ admin: no_permission: "#ff5555You do not have permission to use this command." - usage: "#aaaaaaUsage: /magic " + usage: "#aaaaaaUsage: /magic " values: save_failed: "#ff5555Could not save this Magic edit. The character values were restored; check console." diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 64ac9ac..216377f 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -4,7 +4,7 @@ name: Magic version: ${project.version} author: Drefvelin depend: [TLibs, ItemsAdder, RPCharacters, InteractibleFurniture] -softdepend: [MMOItems, MythicLib, MMOCore] +softdepend: [MMOItems, MythicLib, MMOCore, WorldGuard, Cooking] commands: resonance: @@ -49,6 +49,9 @@ permissions: magic.bloodmagic: description: Allows Bloodmagic resonance default: false + magic.blood.bypass: + description: Staff may still place and break blocks in a blood altar's locked chamber (grant per group) + default: op magic.weapon.give: description: Give completed weapons (default give-permission) default: op diff --git a/src/main/resources/wildmagic/bloodmagic/animals.yml b/src/main/resources/wildmagic/bloodmagic/animals.yml new file mode 100644 index 0000000..8051e16 --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/animals.yml @@ -0,0 +1,37 @@ +# The animal sacrifices. +# Part of wildmagic/bloodmagic (Magic's hidden blood path). Hidden content: never on the wiki. + +# Animal sacrifices: bind a living animal on the altar with the ritual knife, cut it with that knife, and +# stay by the stone while it bleeds out. Only then does the sacrifice count. +animal: + species_required: 7 + # Rolling window between two animal sacrifices. + cooldown_hours: 24 + # How close to the altar the animal must be when the knife binds it. + bind_range: 4 + # A bound animal breaks free after this many seconds. + bind_seconds: 20 + # The rite after the cut: it bleeds out on the stone this long while its mage stays within stay_range + # blocks of the altar (further, or switching character or leaving, spills it, wasted). Its heart beats + # at these seconds of the bleeding, slowing toward the end. + rite: + bleed_seconds: 15 + stay_range: 4 + heartbeats: [0, 1, 2, 3, 5, 7, 10, 13] + # Played to the mage alone from the cut, stopped when the stone drinks or the blood is spilled. + # sound: a resource pack sound id (blank for none); category: the client volume slider it follows. + music: + sound: tfmc_blood:rite.bleed + category: RECORDS + volume: 0.8 + # Only animals another player owns (Cooking husbandry) count. + require_stolen: true + # The offering leaves no meat or experience behind. + clear_drops: true + species: [COW, PIG, SHEEP, CHICKEN, GOAT, RABBIT, HORSE, DONKEY, MULE, LLAMA, CAMEL, MOOSHROOM, WOLF, FOX, CAT, OCELOT, PARROT] + # Where a stolen animal was led away from, a small trace stays this long. + trace: + model: ia.tfmc_blood:trace + hours: 6 + # The owner hears about it this many minutes later, or on their next login. + note_delay_minutes: 30 diff --git a/src/main/resources/wildmagic/bloodmagic/armour.yml b/src/main/resources/wildmagic/bloodmagic/armour.yml new file mode 100644 index 0000000..b27e0b2 --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/armour.yml @@ -0,0 +1,20 @@ +# The bound armour each path receives. +# Part of wildmagic/bloodmagic (Magic's hidden blood path). Hidden content: never on the wiki. + +# Bound, unbreakable armour. Devotees get theirs at the question; the true path when the hunt is done. +# Each piece is a TLibs item path (the stats), optionally with an ia. look put over it: +# - m.type.id or - { item: m.type.id, look: ia.namespace:id } +# A piece's name may use {victim}: the true path's armour is made from the hunt's victims, the most +# important (most points, then closest) on the first named piece and so on down; with fewer victims +# than pieces the names start over from the top. Without victims the item keeps its own name. +armour: + devotee: + - { item: m.mage_armors.bloodsworn_helmet, look: ia.tfmc_blood:bloodsworn_helmet } + - { item: m.mage_armors.bloodsworn_chestplate, look: ia.tfmc_blood:bloodsworn_chestplate } + - { item: m.mage_armors.bloodsworn_leggings, look: ia.tfmc_blood:bloodsworn_leggings } + - { item: m.mage_armors.bloodsworn_boots, look: ia.tfmc_blood:bloodsworn_boots } + "true": + - { item: m.mage_armors.sanguinar_helmet, look: ia.tfmc_blood:sanguinar_helmet, name: "#5c0202{victim} Skull" } + - { item: m.mage_armors.sanguinar_chestplate, look: ia.tfmc_blood:sanguinar_chestplate, name: "#5c0202{victim} Ribcage" } + - { item: m.mage_armors.sanguinar_leggings, look: ia.tfmc_blood:sanguinar_leggings, name: "#5c0202{victim} Hide" } + - { item: m.mage_armors.sanguinar_boots, look: ia.tfmc_blood:sanguinar_boots, name: "#5c0202{victim} Bones" } diff --git a/src/main/resources/wildmagic/bloodmagic/artefacts.yml b/src/main/resources/wildmagic/bloodmagic/artefacts.yml new file mode 100644 index 0000000..954f32a --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/artefacts.yml @@ -0,0 +1,42 @@ +# The path's artefacts. Part of wildmagic/bloodmagic (Magic's hidden blood path). Hidden content: +# never on the wiki. + +# Bound to one character and set out on its altar's pedestals: the devotee's when it kneels, the true +# path's when its hunt is done. Each only counts once its owner sinks it into the altar's pool of blood +# (right-click the altar with it), and each gives an equal share of the path's blood resonance +# (settings.yml pool.worth). They are not Magic artifacts: no aura, no wear, no use in mage gear. +# Each entry: { item: TLibs path, name, lore: [lines] }; without lore the path's shared lore is used. +artefacts: + devotee_lore: + - "§7§oA small gift from the Blood Father." + - "§7§oIt only wakes in the pool of blood." + devotee: + - { item: ia.tfmc_magic:oseni_ring_147, name: "#8a0303Clotted Ring" } + - { item: ia.tfmc_magic:oseni_ring_172, name: "#8a0303Ring of the Kneeling" } + - { item: ia.tfmc_magic:oseni_bracelet_152, name: "#8a0303Bloodstained Bracelet" } + - { item: ia.tfmc_magic:oseni_amulet_156, name: "#8a0303Amulet of Small Mercies" } + - { item: ia.tfmc_magic:oseni_amulet_159, name: "#8a0303Red Tear Amulet" } + - { item: ia.tfmc_magic:oseni_necklace_197, name: "#8a0303Vein Necklace" } + - { item: ia.tfmc_magic:oseni_gem_164, name: "#8a0303Clot Stone" } + - { item: ia.tfmc_magic:oseni_brooch_13, name: "#8a0303Brooch of the Father" } + "true": + lore: + - "§7§oTaken, not given." + - "§7§oIt only wakes in the pool of blood." + hunt: + - { item: ia.tfmc_magic:oseni_orb_0, name: "#5c0202Hunter's Orb" } + - { item: ia.tfmc_magic:bloodmagic_eye_orb_18, name: "#5c0202The Watching Eye" } + - { item: ia.tfmc_magic:oseni_book_20, name: "#5c0202Book of Names" } + - { item: ia.tfmc_magic:oseni_book_35, name: "#5c0202Book of Measures" } + - { item: ia.tfmc_magic:oseni_brooch_15, name: "#5c0202Brooch of the First Cut" } + - { item: ia.tfmc_magic:oseni_amulet_195, name: "#5c0202Amulet of Red Silence" } + - { item: ia.tfmc_magic:shared_brooch_17, name: "#5c0202Brooch of Hollow Ties" } + - { item: ia.tfmc_magic:bloodmagic_book_27, name: "#5c0202The Red Ledger" } + # The Nth final sacrifice's artefact takes the place of the Nth hunt artefact; {victim} is the name + # of that final's victim. + finals: + - { item: ia.tfmc_magic:necromancy_jar_117, name: "#5c0202Heart of {victim}", lore: ["§7§oAll that is left of them.", "§7§oIt only wakes in the pool of blood."] } + - { item: ia.tfmc_magic:necromancy_jar_117, name: "#5c0202Heart of {victim}", lore: ["§7§oAll that is left of them.", "§7§oIt only wakes in the pool of blood."] } + - { item: ia.tfmc_magic:necromancy_jar_117, name: "#5c0202Heart of {victim}", lore: ["§7§oAll that is left of them.", "§7§oIt only wakes in the pool of blood."] } + - { item: ia.tfmc_magic:necromancy_jar_117, name: "#5c0202Heart of {victim}", lore: ["§7§oAll that is left of them.", "§7§oIt only wakes in the pool of blood."] } + - { item: ia.tfmc_magic:bloodmagic_eye_orb_19, name: "#5c0202Eye of {victim}", lore: ["§7§oThe closest of them all.", "§7§oIt only wakes in the pool of blood."] } diff --git a/src/main/resources/wildmagic/bloodmagic/choice.yml b/src/main/resources/wildmagic/bloodmagic/choice.yml new file mode 100644 index 0000000..8a53c8c --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/choice.yml @@ -0,0 +1,51 @@ +# The god's question. +# Part of wildmagic/bloodmagic (Magic's hidden blood path). Hidden content: never on the wiki. + +# The god's question, as a dialogue: once the seventh kind is given, right-click your altar with your +# blood knife inside its chamber. The voices ask (ask:, one line every line_seconds), then wait for the +# answer spoken aloud in roleplay chat (the rp channel, artifacts/sacrifice.yml rp_channel) within +# answer_seconds, while the mage stays in the chamber. An answer is asked back once (confirm:) and +# sealed when it is said again (sealed:, then the path is chosen); until then the other answer may +# still be given. Leaving the chamber or letting the time run out ends it (walked_away:); it can be +# started again. Lines are { god: text } or { self: text }, said in that voice's colours. +choice: + answer_seconds: 90 + line_seconds: 5 + # Played to the mage alone while the question lasts. sound: a resource pack sound id (blank for none); + # loop_seconds: the track's length, to start it again for as long as the question goes on (0: once). + # The default is "Dark Horror Ambient Piano Choir" by ISAo (OGA-BY 3.0, see tfmc_blood CREDITS.md). + music: + sound: tfmc_blood:rite.question + category: RECORDS + volume: 0.8 + loop_seconds: 91 + ask: + - god: "Seven kinds have bled on my stone, and you did not look away." + - god: "Alaric stood where you stand. He knelt, and I carried him through three hundred winters." + - god: "Kneel to me, child. Leave a piece of yourself on this stone, and you will never walk alone again." + - self: "Or don't. Everything he gives, he owns. I could take all of it, and owe no one." + - god: "Speak, child. Aloud, so the stone hears it. Will you kneel?" + unclear: + - god: "Plainly, child. Kneel, or refuse me." + walked_away: + - god: "You turn your back on your father? Come back when you have found your tongue." + # Words that mean each answer: a whole word or phrase anywhere in what is said. Refusal is checked + # first, so "I will not kneel" refuses. + kneel: + words: [kneel, "i kneel", "yes", "i am yours", "i'm yours", "father", "i accept", "i will"] + confirm: + - god: "You would kneel? Say it once more, child, and the stone will hold you to it." + sealed: + - god: "Then kneel, my child." + - god: "There. Feel how the stone takes its share. It will never give it back." + refuse: + words: ["no", "never", "refuse", "not", "won't", "wont", "no one", "nobody", "myself", "alone"] + confirm: + - god: "You would refuse your father? Say it once more, and it is done." + - self: "Say it." + sealed: + - god: "...So be it. You will find the quiet very cold, child." + - self: "Finally. Just me." + # The permanent injury a devotee keeps as the mark of what they gave: an RPCharacters trait that only + # this gives (blank for none). A character bearing it has no mana: Magic takes away all max mana. + devotee_trait: manaless diff --git a/src/main/resources/wildmagic/bloodmagic/corruption.yml b/src/main/resources/wildmagic/bloodmagic/corruption.yml new file mode 100644 index 0000000..16f2f33 --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/corruption.yml @@ -0,0 +1,37 @@ +# How the altar changes the room around it. +# Part of wildmagic/bloodmagic (Magic's hidden blood path). Hidden content: never on the wiki. + +# How the altar changes the room. Every change is remembered and undone when the altar is purified. +corruption: + # Per altar level: reach in blocks and the most blocks it may have changed in total. + levels: + 0: { radius: 2, max_blocks: 4 } + 1: { radius: 3, max_blocks: 8 } + 2: { radius: 3, max_blocks: 14 } + 3: { radius: 4, max_blocks: 28 } + 4: { radius: 5, max_blocks: 45 } + 5: { radius: 6, max_blocks: 70 } + blocks_per_sacrifice: 3 + ambient_every_minutes: 30 + # Blocks the slow spread changes each time. + ambient_blocks: 1 + # Solid blocks with a face in the open turn into one of "to" (repeat an entry to weigh it). + # Every block placed here counts toward a bloodmagic shrine (artifacts/shrines.yml). + convert: + - { min_level: 0, from: [STONE, ANDESITE, DIORITE, GRANITE, TUFF], to: [REDSTONE_ORE] } + - { min_level: 0, from: [DEEPSLATE, COBBLED_DEEPSLATE], to: [DEEPSLATE_REDSTONE_ORE] } + - { min_level: 1, from: [STONE, COBBLESTONE, STONE_BRICKS, MOSSY_COBBLESTONE, CRACKED_STONE_BRICKS, REDSTONE_ORE], to: [NETHERRACK, NETHERRACK, CRACKED_NETHER_BRICKS] } + - { min_level: 2, from: [STONE, COBBLESTONE, STONE_BRICKS, DEEPSLATE, NETHERRACK, CRACKED_NETHER_BRICKS], to: [NETHER_BRICKS, CRACKED_NETHER_BRICKS, NETHERRACK] } + - { min_level: 3, from: [DIRT, COARSE_DIRT, ROOTED_DIRT, NETHERRACK, STONE, DEEPSLATE], to: [CRIMSON_NYLIUM] } + - { min_level: 4, from: [NETHER_BRICKS, CRACKED_NETHER_BRICKS, STONE_BRICKS, STONE], to: [RED_NETHER_BRICKS, NETHER_WART_BLOCK, CHISELED_NETHER_BRICKS] } + # The room turns outward from the altar in connected patches (blocks touching what it already turned + # win). Air spots get decorated: at floor = on top of a block it has turned, at ceiling = hanging under + # one. Candles only ever stand on the spots right in front of the pedestals; elsewhere a rule's other + # blocks are used, and a candle-only rule waits for one of those spots. + decorate: + - { min_level: 0, at: floor, to: [REDSTONE_WIRE, REDSTONE_WIRE, RED_CANDLE] } + - { min_level: 1, at: floor, to: [RED_CANDLE, REDSTONE_WIRE] } + - { min_level: 2, at: floor, to: [RED_CANDLE] } + - { min_level: 3, at: ceiling, to: [WEEPING_VINES] } + - { min_level: 3, at: floor, to: [CRIMSON_ROOTS, CRIMSON_FUNGUS] } + - { min_level: 4, at: floor, to: [NETHER_WART_BLOCK, RED_CANDLE] } diff --git a/src/main/resources/wildmagic/bloodmagic/fx.yml b/src/main/resources/wildmagic/bloodmagic/fx.yml new file mode 100644 index 0000000..f5e61fc --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/fx.yml @@ -0,0 +1,290 @@ +# Sounds and particles for every moment of the path. +# Part of wildmagic/bloodmagic (Magic's hidden blood path). Hidden content: never on the wiki. + +# Sounds and particles for every moment of the path. Each moment is a list of layers: +# sound: (volume, pitch) +# particle: (count, spread: n or [x, y, z], speed, y offset, color/to/size for dust, +# block for BLOCK/FALLING_DUST/BLOCK_CRUMBLE, item for ITEM, value for SCULK_CHARGE/SHRIEK) +# shape: point | ring | column | spiral (radius, points, height); delay: ticks; private: true = only the +# player the moment is about sees or hears it. A moment with no entry plays nothing. +fx: + enabled: true + # Vanilla candle flames over the wicks of the altar's candles (the model draws no flames of its own), + # every every_ticks while a player is within view_range, with a wisp of smoke at smoke_chance. + # wicks: [x, y, z, max_level] in the model's pixels, 1 px over the middle of each wick like vanilla, + # max_level the last altar level that still has that candle (the skull takes one at 4). yaw_offset + # turns the positions to match how item displays face. + candles: + enabled: true + particle: SMALL_FLAME + every_ticks: 8 + smoke_chance: 0.1 + view_range: 24 + yaw_offset: 180 + wicks: + - [14, 17, 2, 5] + - [14, 18, 14, 5] + - [2, 17, 14, 5] + - [2, 16, 2, 3] + # Shared sounds reach only players this many blocks away; private: true layers reach only + # the player the moment is about (the rite music in animals.yml is private too). + hear_radius: 10 + moments: + # --- The altar --- + altar_placed: + - { sound: block.stone.place, volume: 1.0, pitch: 0.5 } + - { sound: block.sculk_catalyst.bloom, volume: 0.6, pitch: 0.6, delay: 6 } + - { particle: BLOCK_CRUMBLE, block: stone_bricks, count: 30, spread: 0.4, y: 0.1 } + - { particle: SCULK_SOUL, shape: ring, radius: 0.9, points: 14, y: -0.4, speed: 0.02, delay: 6 } + - { sound: ambient.cave, volume: 0.5, pitch: 0.5, delay: 20, private: true } + altar_awakens: + - { sound: entity.warden.heartbeat, volume: 1.2, pitch: 0.5 } + - { sound: block.sculk_shrieker.shriek, volume: 0.5, pitch: 0.5, delay: 4 } + - { particle: DUST_COLOR_TRANSITION, color: "#c40c0c", to: "#1a0000", size: 1.6, shape: spiral, radius: 0.8, height: 2.2, points: 40, y: -0.5, delay: 4 } + - { particle: FALLING_DUST, block: redstone_block, count: 18, spread: [0.45, 0.1, 0.45], y: 0.55, delay: 10 } + altar_purified: + - { sound: block.beacon.deactivate, volume: 1.0, pitch: 0.6 } + - { sound: block.fire.extinguish, volume: 0.8, pitch: 0.5 } + - { particle: WHITE_ASH, count: 60, spread: [1.2, 0.8, 1.2], y: 0.5 } + - { particle: END_ROD, shape: column, height: 2.5, points: 12, speed: 0.02 } + - { particle: LARGE_SMOKE, count: 12, spread: 0.3, y: 0.4, speed: 0.02 } + altar_touch_owner: + - { sound: entity.warden.heartbeat, volume: 0.6, pitch: 1.2, private: true } + - { particle: DUST, color: "#8a0303", size: 1.0, count: 6, spread: 0.25, y: 0.6, private: true } + altar_touch_stranger: + - { sound: block.stone.hit, volume: 0.6, pitch: 0.6 } + - { sound: ambient.soul_sand_valley.mood, volume: 0.3, pitch: 1.6, private: true } + # Every 3 seconds while someone is within 24 blocks, by altar level (level 0 stays silent). + altar_ambient_1: + - { particle: FALLING_DUST, block: redstone_block, count: 1, spread: [0.35, 0, 0.35], y: 0.45 } + altar_ambient_2: + - { particle: FALLING_DUST, block: redstone_block, count: 2, spread: [0.4, 0, 0.4], y: 0.45 } + - { particle: DUST, color: "#4a0000", size: 1.2, count: 3, spread: 0.3, y: 0.7 } + altar_ambient_3: + - { particle: FALLING_DUST, block: redstone_block, count: 3, spread: [0.45, 0, 0.45], y: 0.45 } + - { particle: DUST, color: "#4a0000", size: 1.4, count: 4, spread: 0.35, y: 0.8 } + - { particle: CRIMSON_SPORE, count: 6, spread: 1.5, y: 0.8 } + - { sound: block.pointed_dripstone.drip_lava, volume: 0.4, pitch: 0.6 } + altar_ambient_4: + - { particle: FALLING_DUST, block: redstone_block, count: 4, spread: [0.45, 0, 0.45], y: 0.45 } + - { particle: DUST_COLOR_TRANSITION, color: "#8a0303", to: "#1a0000", size: 1.4, shape: ring, radius: 0.75, points: 10, y: 0.6 } + - { particle: CRIMSON_SPORE, count: 10, spread: 2.0, y: 1.0 } + - { sound: block.pointed_dripstone.drip_lava, volume: 0.5, pitch: 0.5 } + - { sound: entity.warden.heartbeat, volume: 0.25, pitch: 0.6, delay: 20 } + altar_ambient_5: + - { particle: FALLING_DUST, block: redstone_block, count: 6, spread: [0.5, 0, 0.5], y: 0.45 } + - { particle: DUST_COLOR_TRANSITION, color: "#c40c0c", to: "#1a0000", size: 1.6, shape: spiral, radius: 0.7, height: 1.6, points: 24, y: 0.4 } + - { particle: SCULK_SOUL, count: 2, spread: 0.3, y: 0.7, speed: 0.01 } + - { particle: CRIMSON_SPORE, count: 16, spread: 2.5, y: 1.0 } + - { sound: entity.warden.heartbeat, volume: 0.5, pitch: 0.5 } + - { sound: ambient.crimson_forest.additions, volume: 0.4, pitch: 0.6, delay: 30 } + corruption: + - { sound: block.sculk.spread, volume: 0.6, pitch: 0.6 } + - { particle: SCULK_CHARGE, value: 0, count: 4, spread: 0.4 } + - { particle: DUST, color: "#4a0000", size: 1.2, count: 6, spread: 0.45 } + + # --- The animals --- + animal_bound: + - { sound: block.chain.place, volume: 0.5, pitch: 0.6 } + - { sound: entity.warden.heartbeat, volume: 0.4, pitch: 1.4, delay: 5 } + - { particle: SCULK_SOUL, shape: ring, radius: 0.8, points: 12, y: 0.6, speed: 0.01 } + - { particle: DUST, color: "#4a0000", size: 1.3, shape: column, height: 1.4, points: 10, y: 0.5 } + animal_binding: + - { particle: DAMAGE_INDICATOR, count: 2, spread: 0.2, y: 0.6 } + - { particle: DUST, color: "#8a0303", size: 0.9, count: 3, spread: 0.3, y: 0.4 } + - { sound: entity.warden.heartbeat, volume: 0.3, pitch: 1.3 } + # The god's question begins at the altar. + question: + - { sound: entity.warden.heartbeat, volume: 0.5, pitch: 0.6 } + - { sound: block.sculk_shrieker.shriek, volume: 0.25, pitch: 0.5, delay: 8 } + - { particle: DUST_COLOR_TRANSITION, color: "#c40c0c", to: "#1a0000", size: 1.4, shape: ring, radius: 1.4, points: 24, y: 0.6 } + - { particle: SCULK_SOUL, shape: column, height: 1.6, points: 8, y: 0.6, speed: 0.02, delay: 8 } + animal_cut: + - { sound: entity.player.attack.sweep, volume: 0.5, pitch: 0.7 } + - { sound: item.trident.hit, volume: 0.4, pitch: 0.6 } + - { sound: entity.warden.heartbeat, volume: 0.5, pitch: 1.1, delay: 4 } + - { particle: SWEEP_ATTACK, count: 1, y: 0.6 } + - { particle: BLOCK, block: redstone_block, count: 30, spread: 0.3, y: 0.6, speed: 0.15 } + - { particle: DUST, color: "#8a0303", size: 1.4, count: 14, spread: 0.4, y: 0.4 } + # Every second while it bleeds: blood running down onto the stone, the stone drinking it in. + animal_bleeding: + - { particle: FALLING_DUST, block: redstone_block, count: 6, spread: [0.3, 0.1, 0.3], y: 0.3 } + - { particle: DUST, color: "#6e0000", size: 1.1, count: 5, spread: [0.35, 0.2, 0.35], y: 0.1 } + - { particle: DUST_COLOR_TRANSITION, color: "#c40c0c", to: "#1a0000", size: 1.0, shape: ring, radius: 0.9, points: 14, y: -0.4 } + - { particle: SCULK_SOUL, count: 1, spread: 0.3, y: -0.3, speed: 0.01 } + # Its heart, slower toward the end; only its mage hears it up close. + animal_heartbeat: + - { sound: entity.warden.heartbeat, volume: 0.5, pitch: 0.9 } + - { particle: DAMAGE_INDICATOR, count: 2, spread: 0.2, y: 0.6 } + animal_spilled: + - { sound: block.fire.extinguish, volume: 0.4, pitch: 0.6 } + - { sound: entity.generic.splash, volume: 0.4, pitch: 0.5 } + - { particle: BLOCK, block: redstone_block, count: 20, spread: [0.6, 0.1, 0.6], y: 0.0, speed: 0.05 } + - { particle: LARGE_SMOKE, count: 6, spread: 0.3, y: 0.4, speed: 0.01 } + animal_escaped: + - { sound: block.chain.break, volume: 0.5, pitch: 0.8 } + - { particle: POOF, count: 8, spread: 0.3, y: 0.5 } + altar_sit: + - { sound: entity.warden.heartbeat, volume: 0.6, pitch: 0.8, private: true } + - { particle: DUST, color: "#5c0202", size: 1.0, shape: ring, radius: 0.7, points: 10, y: 0.6, private: true } + corruption_unbreakable: + - { sound: entity.warden.heartbeat, volume: 0.8, pitch: 1.6, private: true } + - { sound: block.sculk_vein.break, volume: 0.7, pitch: 0.5, private: true } + - { particle: DUST, color: "#8a0303", size: 1.2, count: 14, spread: 0.45, private: true } + - { particle: DAMAGE_INDICATOR, count: 3, spread: 0.3, private: true } + animal_slain: + - { sound: entity.player.attack.sweep, volume: 0.5, pitch: 0.7 } + - { sound: item.trident.hit, volume: 0.4, pitch: 0.6 } + - { sound: entity.generic.drink, volume: 0.4, pitch: 0.5, delay: 2 } + - { particle: SWEEP_ATTACK, count: 1, y: 0.6 } + - { particle: BLOCK, block: redstone_block, count: 25, spread: 0.3, y: 0.6, speed: 0.15 } + - { particle: DUST, color: "#8a0303", size: 1.4, count: 12, spread: 0.4, y: 0.4 } + animal_sacrificed: + - { sound: entity.player.attack.crit, volume: 0.4, pitch: 0.5 } + - { sound: block.honey_block.slide, volume: 0.5, pitch: 0.5 } + - { sound: block.sculk_catalyst.bloom, volume: 0.4, pitch: 0.5, delay: 4 } + - { sound: entity.warden.heartbeat, volume: 0.5, pitch: 0.6, delay: 12 } + - { particle: BLOCK, block: redstone_block, count: 40, spread: 0.4, y: 0.7, speed: 0.1 } + - { particle: DAMAGE_INDICATOR, count: 12, spread: 0.3, y: 0.6, speed: 0.05 } + - { particle: DUST_COLOR_TRANSITION, color: "#c40c0c", to: "#1a0000", size: 1.5, shape: ring, radius: 1.2, points: 20, y: 0.6, delay: 4 } + - { particle: SCULK_SOUL, shape: column, height: 1.8, points: 8, y: 0.6, speed: 0.03, delay: 8 } + blood_knife_given: + - { sound: entity.warden.emerge, volume: 0.4, pitch: 0.7, delay: 20 } + - { sound: block.end_portal.spawn, volume: 0.25, pitch: 0.5, delay: 40 } + - { particle: DUST_COLOR_TRANSITION, color: "#c40c0c", to: "#1a0000", size: 2.0, shape: spiral, radius: 1.3, height: 3.5, points: 70, y: -0.5, delay: 20 } + - { particle: SCULK_SOUL, shape: ring, radius: 2.2, points: 30, y: -0.4, speed: 0.04, delay: 30 } + - { particle: SONIC_BOOM, count: 1, y: 1.4, delay: 40 } + - { sound: ambient.cave, volume: 0.4, pitch: 0.5, delay: 50 } + + # --- The question --- + question: + - { sound: block.portal.ambient, volume: 0.4, pitch: 0.5, private: true } + - { sound: entity.warden.nearby_closest, volume: 0.8, pitch: 0.5, private: true } + - { particle: DUST, color: "#1a0000", size: 2.0, shape: ring, radius: 1.0, points: 16, y: 0.1, private: true } + devoted: + - { sound: entity.warden.roar, volume: 0.7, pitch: 0.5 } + - { sound: entity.player.hurt, volume: 1.0, pitch: 0.6, delay: 10 } + - { sound: block.respawn_anchor.charge, volume: 0.8, pitch: 0.5, delay: 20 } + - { particle: DUST_COLOR_TRANSITION, color: "#c40c0c", to: "#4a0000", size: 1.8, shape: spiral, radius: 0.9, height: 2.4, points: 50 } + - { particle: DAMAGE_INDICATOR, count: 20, spread: [0.3, 0.6, 0.3], y: 1.0, speed: 0.1, delay: 10 } + - { particle: BLOCK, block: redstone_block, count: 30, spread: 0.4, y: 1.0, delay: 10 } + - { particle: SCULK_SOUL, shape: ring, radius: 1.6, points: 24, speed: 0.03, delay: 20 } + refused: + - { sound: block.respawn_anchor.deplete, volume: 1.0, pitch: 0.5 } + - { sound: block.candle.extinguish, volume: 1.0, pitch: 0.5, delay: 8 } + - { sound: entity.elder_guardian.curse, volume: 0.5, pitch: 0.5, delay: 30, private: true } + - { particle: LARGE_SMOKE, count: 30, spread: [0.6, 1.0, 0.6], y: 1.0, speed: 0.01 } + - { particle: SQUID_INK, shape: ring, radius: 1.4, points: 20, y: 0.2, delay: 8 } + + # --- Dragging and the rite --- + carry_start: + - { sound: entity.player.attack.knockback, volume: 0.8, pitch: 0.6 } + - { sound: block.chain.step, volume: 0.8, pitch: 0.6 } + - { sound: entity.warden.heartbeat, volume: 1.0, pitch: 1.6, private: true } + - { particle: DUST, color: "#8a0303", size: 1.2, count: 8, spread: 0.3, y: 0.2 } + # Every few steps: the scrape of a body and a smear of blood on the ground behind it. + carry_drag: + - { sound: block.mud.step, volume: 0.6, pitch: 0.6 } + - { particle: DUST, color: "#5c0202", size: 1.4, count: 3, spread: [0.25, 0, 0.25], y: 0.05 } + - { particle: BLOCK, block: redstone_block, count: 2, spread: 0.15, y: 0.1 } + carry_released: + - { sound: entity.player.small_fall, volume: 1.0, pitch: 0.6 } + - { particle: BLOCK_CRUMBLE, block: dirt, count: 10, spread: 0.3, y: 0.1 } + rite_bound: + - { sound: block.chain.place, volume: 1.0, pitch: 0.5 } + - { sound: block.sculk_shrieker.shriek, volume: 0.7, pitch: 0.5, delay: 6 } + - { particle: SCULK_SOUL, shape: ring, radius: 1.3, points: 20, y: -0.4, speed: 0.02 } + - { particle: DUST_COLOR_TRANSITION, color: "#8a0303", to: "#1a0000", size: 1.6, shape: column, height: 2.5, points: 16, y: 0.5, delay: 6 } + - { sound: entity.elder_guardian.curse, volume: 0.6, pitch: 0.4, private: true } + # Every second of the rite. + rite_beat: + - { sound: entity.warden.heartbeat, volume: 1.2, pitch: 0.7 } + - { particle: DAMAGE_INDICATOR, count: 4, spread: 0.3, y: 1.0 } + - { particle: DUST_COLOR_TRANSITION, color: "#c40c0c", to: "#1a0000", size: 1.4, shape: ring, radius: 1.0, points: 14, y: 0.6 } + - { particle: SCULK_SOUL, count: 2, spread: 0.6, y: 0.2, speed: 0.02 } + - { sound: entity.warden.heartbeat, volume: 1.0, pitch: 1.0, delay: 10, private: true } + rite_broken: + - { sound: block.chain.break, volume: 1.0, pitch: 0.6 } + - { sound: block.glass.break, volume: 0.6, pitch: 0.5 } + - { particle: LARGE_SMOKE, count: 16, spread: 0.4, y: 0.8, speed: 0.02 } + rite_complete: + - { sound: entity.warden.sonic_boom, volume: 0.8, pitch: 0.6 } + - { sound: block.honey_block.slide, volume: 1.0, pitch: 0.4 } + - { sound: entity.lightning_bolt.thunder, volume: 0.4, pitch: 0.5, delay: 6 } + - { sound: block.sculk_catalyst.bloom, volume: 1.0, pitch: 0.4, delay: 12 } + - { particle: SONIC_BOOM, count: 1, y: 1.0 } + - { particle: BLOCK, block: redstone_block, count: 80, spread: 0.6, y: 0.9, speed: 0.15 } + - { particle: DUST_COLOR_TRANSITION, color: "#c40c0c", to: "#1a0000", size: 2.0, shape: ring, radius: 1.5, points: 30, y: 0.5, delay: 3 } + - { particle: DUST_COLOR_TRANSITION, color: "#8a0303", to: "#1a0000", size: 2.0, shape: ring, radius: 2.6, points: 42, y: 0.3, delay: 7 } + - { particle: SCULK_SOUL, shape: spiral, radius: 0.6, height: 3.0, points: 30, y: 0.5, speed: 0.02, delay: 10 } + + # --- The hunt and the finals --- + hunt_complete: + - { sound: entity.warden.emerge, volume: 0.8, pitch: 0.6 } + - { sound: block.end_portal.spawn, volume: 0.5, pitch: 0.5, delay: 20 } + - { sound: entity.wither.ambient, volume: 0.4, pitch: 0.5, delay: 40, private: true } + - { particle: DUST_COLOR_TRANSITION, color: "#c40c0c", to: "#1a0000", size: 2.2, shape: spiral, radius: 1.2, height: 3.0, points: 70 } + - { particle: SOUL_FIRE_FLAME, shape: ring, radius: 1.8, points: 28, y: 0.1, speed: 0.01, delay: 20 } + - { particle: SCULK_SOUL, count: 30, spread: [1.0, 1.5, 1.0], y: 1.0, speed: 0.02, delay: 30 } + target_named: + - { sound: block.bell.resonate, volume: 0.6, pitch: 0.5, private: true } + - { sound: entity.elder_guardian.curse, volume: 0.4, pitch: 0.5, delay: 10, private: true } + - { particle: DUST, color: "#1a0000", size: 1.6, count: 20, spread: [0.6, 1.0, 0.6], y: 1.0, private: true } + final_taken: + - { sound: block.bell.use, volume: 0.8, pitch: 0.5, delay: 20 } + - { sound: entity.wither.ambient, volume: 0.3, pitch: 0.6, delay: 30, private: true } + - { particle: SOUL, shape: ring, radius: 1.2, points: 18, y: 0.1, speed: 0.02, delay: 20 } + ascended: + - { sound: entity.ender_dragon.growl, volume: 0.6, pitch: 0.5, delay: 20 } + - { sound: entity.wither.spawn, volume: 0.3, pitch: 0.5, delay: 40 } + - { sound: block.end_portal.spawn, volume: 0.6, pitch: 0.4, delay: 60 } + - { particle: DUST_COLOR_TRANSITION, color: "#ff1a1a", to: "#1a0000", size: 2.5, shape: spiral, radius: 1.5, height: 4.0, points: 90, delay: 20 } + - { particle: SONIC_BOOM, count: 1, y: 1.2, delay: 40 } + - { particle: SOUL_FIRE_FLAME, shape: ring, radius: 2.5, points: 40, y: 0.1, speed: 0.02, delay: 40 } + - { particle: CRIMSON_SPORE, count: 80, spread: [3.0, 2.0, 3.0], y: 1.5, delay: 60 } + backlash: + - { sound: entity.player.hurt, volume: 1.0, pitch: 0.5 } + - { sound: entity.elder_guardian.curse, volume: 0.6, pitch: 0.6, private: true } + - { sound: entity.witch.celebrate, volume: 0.6, pitch: 0.5, delay: 20, private: true } + - { particle: DAMAGE_INDICATOR, count: 12, spread: [0.3, 0.6, 0.3], y: 1.0, speed: 0.1 } + - { particle: SQUID_INK, count: 16, spread: 0.5, y: 1.0, speed: 0.02 } + + # --- The voices --- + voice_god: + - { particle: DUST, color: "#8a0303", size: 0.8, count: 6, spread: [0.6, 0.4, 0.6], y: 1.7, private: true } + - { sound: ambient.crimson_forest.mood, volume: 0.25, pitch: 0.6, private: true } + voice_self: + - { particle: SMOKE, count: 8, spread: [0.4, 0.3, 0.4], y: 1.6, speed: 0.005, private: true } + - { particle: REVERSE_PORTAL, count: 4, spread: [0.3, 0.2, 0.3], y: 1.7, speed: 0.01, private: true } + - { sound: block.portal.ambient, volume: 0.12, pitch: 1.8, private: true } + gift: + - { sound: entity.warden.nearby_closer, volume: 0.6, pitch: 0.6, private: true } + - { particle: DUST, color: "#4a0000", size: 1.5, shape: ring, radius: 0.8, points: 12, y: 0.2, private: true } + + # --- The armour --- + armour_given: + - { sound: item.armor.equip_leather, volume: 1.0, pitch: 0.6 } + - { sound: entity.warden.heartbeat, volume: 0.8, pitch: 0.8, delay: 6 } + - { particle: DUST_COLOR_TRANSITION, color: "#8a0303", to: "#1a0000", size: 1.4, shape: column, height: 2.0, points: 14 } + # --- Artefacts and meditation --- + artefact_charge: + - { sound: entity.generic.splash, volume: 0.5, pitch: 0.5 } + - { sound: block.lava.pop, volume: 0.6, pitch: 0.6, delay: 10 } + - { particle: DUST_COLOR_TRANSITION, color: "#c40c0c", to: "#1a0000", size: 1.4, shape: ring, radius: 0.5, points: 16, y: 0.1 } + artefact_charge_beat: + - { sound: entity.warden.heartbeat, volume: 0.6, pitch: 0.6 } + - { particle: FALLING_DUST, block: redstone_block, count: 4, spread: [0.3, 0.05, 0.3], y: 0.2 } + artefact_charged: + - { sound: block.respawn_anchor.charge, volume: 0.6, pitch: 0.6 } + - { sound: block.amethyst_block.resonate, volume: 0.5, pitch: 0.5, delay: 4, private: true } + - { particle: DUST_COLOR_TRANSITION, color: "#d10202", to: "#3a0000", size: 1.4, shape: spiral, radius: 0.5, height: 1.2, points: 24 } + meditate_catch: + - { sound: block.honey_block.break, volume: 0.6, pitch: 0.6, private: true } + - { sound: block.amethyst_cluster.hit, volume: 0.4, pitch: 0.5, private: true } + meditate_miss: + - { sound: entity.player.hurt, volume: 0.5, pitch: 0.7, private: true } + armour_crumbles: + - { sound: entity.item.break, volume: 1.0, pitch: 0.5 } + - { sound: block.soul_sand.break, volume: 0.8, pitch: 0.6 } + - { particle: ASH, count: 30, spread: [0.3, 0.6, 0.3], y: 1.0 } + - { particle: DUST, color: "#3a1a1a", size: 1.2, count: 12, spread: 0.4, y: 0.8 } diff --git a/src/main/resources/wildmagic/bloodmagic/items.yml b/src/main/resources/wildmagic/bloodmagic/items.yml new file mode 100644 index 0000000..7b0b0e1 --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/items.yml @@ -0,0 +1,24 @@ +# Items the voices hand to the character. +# Part of wildmagic/bloodmagic (Magic's hidden blood path). Hidden content: never on the wiki. + +# Items the voices hand to the character. TLibs item paths (v., ia., m.). +# Each item is bound to the character that received it. The ia.tfmc_blood items come from the +# tfmc_blood ItemsAdder pack (ServerAssets); load it before this config, a missing IA id builds dirt. +items: + altar: + path: ia.tfmc_blood:altar_0 + name: "#bfbfbfWeathered Altar Stone" + lore: + - "#8c8c8c§oA slab of old carved stone, worn smooth" + - "#8c8c8c§oby hands that are long gone." + - "#5c5c5c§oIt is heavier than it should be." + # Both knives are MMOItems lore weapons (name, lore, stats and look come from MMOItems): + # the plain knife shows as Hunting Knife, the blood knife as Ritual Knife. + ritual_knife: + path: m.lore_weapons.ritual_knife + name: "" + lore: [] + blood_knife: + path: m.lore_weapons.blood_knife + name: "" + lore: [] diff --git a/src/main/resources/wildmagic/bloodmagic/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml new file mode 100644 index 0000000..ce1971e --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -0,0 +1,318 @@ +# Every Blood Magic chat text: reactions, the rite, the voice naming a final target, notes. +# Part of wildmagic/bloodmagic (Magic's hidden blood path). Hidden content: never on the wiki. + +# Blood path. Hidden content: keep these lines vague and never quote them on the wiki. +blood: + + corruption: + unbreakable: "{stone}The stone pulses warm under your tool. It belongs to the altar now" + altar: + + placed: "#8c8c8c§oThe stone settles into the dark as if it had always been there. Somewhere below, something sighs" + + not_yours: "#8c8c8c§oThe stone is cold and heavy in your hands. It will not be laid down by you" + + inert: "#8c8c8c§oIt is only a stone. Nothing listens to it yet" + + already: "#8c8c8c§oYou already keep a stone. It would not share you" + + unbreakable: "#8c8c8c§oYour tools ring off the stone without leaving a scratch" + # The pedestals the altar set out around itself will not be picked up, carried or broken. + pedestal_bound: "#8c8c8c§oThe pedestal will not leave the stone's side" + # Placing or breaking anything in an altar's chamber (staff with magic.blood.bypass may). + room_locked: "#8c8c8c§oThe chamber belongs to the stone. It will not be changed by your hand" + + # Sitting on the altar (/sit) to meditate; right-clicking it gives owner_touch / stranger_touch. + # Lines starting {stone} follow whoever speaks to the mage now (the god's colours, or after refusing + # him their own self's); touch.* and sit.* are said to the altar's owner, by stage and path. + sit: + animal: "{stone}You sit on the stone. It is warm beneath you, and it is listening" + choice: "{stone}You sit on the stone and feel it hold its breath. It is waiting for you to speak" + hunt: "{stone}You sit on the cold stone and let it count the names with you" + final_devotee: "{stone}You sit on the stone and it hums beneath you, patient, full of your father" + final_true: "{stone}You sit on the stone. It is as still as you are now" + ascended_devotee: "{stone}You sit on the stone, and for a moment you hear him breathing, far below" + ascended_true: "{stone}You sit in the quiet you made. It fits you well" + touch: + animal: "{stone}The stone is warm under your palm, like skin. You feel it breathe" + choice: "{stone}The stone thrums under your hand. It is waiting for an answer" + hunt: "{stone}The stone is cold where you touch it, and hungry. It wants the ones you know" + final_devotee: "{stone}The stone purrs under your hand like something fed. Your father is pleased with you" + final_true: "{stone}The stone is quiet under your palm. Quiet like you" + # Once the path is complete, the touch tells how full the altar's pool of blood is. + ascended_devotee_full: "{stone}The stone is as warm as a heart. Somewhere far below, your father sleeps, content" + ascended_devotee_half: "{stone}The pool is lower than it was. Far below, your father stirs, hungry" + ascended_devotee_low: "{stone}Only a little blood is left in the pool. Your father is waiting, child" + ascended_devotee_dry: "{stone}The pool is dry and the stone is cold. Your father will not wait long" + ascended_true_full: "{stone}The pool is full. Nothing moves under your hand. Nothing needs to, yet" + ascended_true_half: "{stone}The pool has sunk. You can feel the hunger starting" + ascended_true_low: "{stone}Barely any blood is left in the pool. You need someone soon" + ascended_true_dry: "{stone}The pool is dry. Everything you took is slipping away" + sit_stranger: "#8c8c8c§oThe stone is cold under you. You cannot shake the feeling that it would rather you stood" + + stranger_touch: "#8c8c8c§oCold carved stone, dark in the seams. The longer you look at it, the less you want to touch it" + + place: + + world: "#8c8c8c§oNot here. The ground itself refuses it" + + occupied: "#8c8c8c§oThere is no room for the stone here" + + no_floor: "#8c8c8c§oIt needs firm rock beneath it" + + sky: "#8c8c8c§oNot under the open sky. It must never see the sun" + + shallow: "#8c8c8c§oNot deep enough. Take it further down, under more rock" + + room_too_much: "#8c8c8c§oThe stone wants a chamber of its own: open floor {size} by {size}, {height} high, with it in the middle. Too much rock is in the way here" + room_blocked: "#8c8c8c§oThe stone wants a chamber of its own, {size} by {size} and {height} high, but something here will not make way: water, a chest, another stone, or claimed ground" + room_shallow: "#8c8c8c§oCarving its chamber here would open the rock to the sky. Go deeper" + room_sealed: "#8c8c8c§oOnce laid, its chamber cannot be changed. Leave it a way out first: an opening two blocks high in a wall" + region: "#8c8c8c§oThis ground is claimed and watched. Find somewhere forgotten" + + animal: + + bind: + + ok: "{stone}The beast goes rigid on the stone. Its heart hammers against the blade. Now" + + not_stage: "#8c8c8c§oThe stone wants nothing from it. Not yet" + + not_knife: "#8c8c8c§oThis blade means nothing to the stone. Use the plain knife" + + no_altar: "#8c8c8c§oThere is no stone to lay it on" + + not_species: "#8c8c8c§oThe stone does not hunger for that" + + # Lines starting {god} or {self} are said in that voice's colours (voices.yml colors). + known: "{god}I have tasted that kind already, child. Bring me another" + + cooldown: "{god}Not yet, child. The stone is still drinking the last one" + + not_stolen: "{god}Not that one. A gift that costs nothing is no gift. Take what others keep" + + too_far: "{god}Too far. Lead it down to the stone" + + busy: "{god}One at a time. Finish what lies on the stone" + + spoiled: "#8c8c8c§oThe blood runs into the dirt, wasted. The stone did not drink" + # The rite: the cut, halfway through the bleeding, and stepping away before it is over. + cut: "{stone}The blade opens it. The blood runs down into the grooves of the stone, and the stone begins to drink. Stay" + draining: "{stone}The heartbeat under your hand slows. The stone is warm now, almost hot" + left: "#8c8c8c§oYou stepped away. The blood runs cold across the floor, wasted. The stone did not drink" + + escaped: "#8c8c8c§oThe beast thrashes free of the stone and bolts into the dark" + + accepted: "{stone}The stone drinks. When you take your hand away, it is warmer than before" + + complete: "{stone}Seven kinds. The stone is red to its heart, and something cold and winding rests in your hand that was not there before" + + note: "#aaaaaa§oSomething has taken {animal} from the pen. The rope was cut clean, and faint drag marks lead off to the {direction}" + + note_lost: "#aaaaaa§oSomething has taken {animal} from the pen. The rope was cut clean, and no one saw a thing" + + rite: + + bind: + + ok: "{stone}You haul them onto the stone. It is already warm. Already waiting" + + not_stage: "#8c8c8c§oThe stone does not want them. Not yet" + + not_knife: "#8c8c8c§oThat blade will not open the way. Use the one the stone gave you" + + no_altar: "#8c8c8c§oThere is no stone to lay them on" + + no_character: "#8c8c8c§oThere is nothing in them to take" + + not_down: "#8c8c8c§oThey still fight. Bring them down first" + + too_far: "#8c8c8c§oThe stone is too far. Crouch, take hold of them, and drag them closer" + + too_new: "#8c8c8c§oThin, new blood. The stone would spit it out" + + repeat: "#8c8c8c§oThat blood is still drying on the stone. Find someone else" + + reserved: "{self}Not them. Not yet. They are for later" + + waiting: "#8c8c8c§oThe stone sulks over what was stolen from it. It will take no one yet" + + not_target: "#8c8c8c§oNot this one. You know whose name it whispered" + + busy: "#8c8c8c§oOne at a time" + + blocked: "#8c8c8c§oStone lies between them and the altar. Drag them to it" + + dread: "#8a0303§oCold stone under your back. A blade above you. And below, something old and patient opens its mouth" + + # What the victim on the altar hears while the rite runs (dying.1 to dying.4, spread over the + # rite), as it ends (dying.last), or if it breaks before then (spared). + dying: + 1: "#8a0303§oThe blade opens you. Warmth runs out of you and into the stone, and the stone drinks" + 2: "#8a0303§oYour fingers go cold first, then your hands. You can hear your heart, loud, and slowing" + 3: "#8a0303§oThe light shrinks to a point above you. Somewhere below, something swallows" + 4: "#8a0303§oYou cannot feel the stone any more. You cannot feel anything at all" + last: "#5c0202§oYour heart beats once more, and the stone keeps it" + spared: "#8c8c8c§oThe blade lifts away. Whatever waited below sinks back into the stone, unfed" + + broken: "#8c8c8c§oThe rite comes apart. The stone goes cold and quiet, sulking" + + refused: "#8c8c8c§oThe stone will not take them" + + taken: "{stone}It is done. You feel heavier, and stronger, and very far from who you were" + + carry: + + start: "{stone}You take hold of them. Heavy, limp and warm. The stone is waiting" + + dragged: "#8c8c8c§oHands on you. You are being dragged over the ground, and you cannot stop it" + + released: "#8c8c8c§oYou let them fall" + + broken: "#8c8c8c§oYour grip slips. They are out of your hands" + + hunt: + + complete: "{stone}Seven measures of blood. Something in you closes like a fist. The stone knows your name now, and no father gave it" + + final: + + named_god: "I have chosen, child. {target}. Bring me {target}" + + named_self: "{target}. It has to be {target}. I think I always knew" + + nobody_god: "No one is close enough to you yet. Love someone, child. Then bring them to your father" + + nobody_self: "There is no one close enough yet. I will have to let someone in" + + taken: "{stone}A hollow opens where they used to be, and something cold flows in to fill it" + + ascended: "{stone}The last of them is gone. Nothing holds you now. Nothing ever will again" + + failed_god: "Someone else spilled what was mine, and you let them. You will bleed for it, child" + + failed_self: "Someone else got to them first. Everything I put into them, wasted" + + # The path's artefacts and the altar's pool of blood. + artefact: + given: "{stone}Gifts wait on the pedestals around the stone. Sink them into its pool of blood" + replaced: "{stone}Something new waits on a pedestal by the stone. Sink it into the pool" + not_yours: "#8c8c8c§oIt will not leave the pedestal for you" + # Right-clicking the altar with an artefact: it charges in the pool. + charge: + started: "{stone}It sinks into the pool without a ripple, and the blood begins to warm it" + not_yours: "#8c8c8c§oThe pool will not take it from you" + stale: "#8c8c8c§oIt crumbles to dried blood before it touches the pool" + full: "#8c8c8c§oIt is already full. The pool has nothing more to give it" + dry: "{stone}The pool is dry. It has nothing left to give" + busy: "#8c8c8c§oThe pool is already holding something. Wait for it to rise" + done: "{stone}It rises from the pool, warm and glowing. Set it back on its pedestal" + returned: "{stone}What you left in the pool has risen. It waits in your hands" + # Meditating on the altar (sit on it) among its charged artefacts. + meditate: + start: + started: "{stone}The stone's heart beats under you. Catch what it gives you, in time with it" + not_ready: "#8c8c8c§oThe stone is quiet. It has nothing to share with you yet" + dry: "{stone}The artefacts are dry. Charge them in the pool first" + full: "{stone}Your blood is as full as the artefacts can make it" + busy: "#8c8c8c§oYou are already listening to it" + end: + stood: "#8c8c8c§oThe heartbeat fades as you rise" + dry: "{stone}The artefacts are drained. The heartbeat slows and stops" + full: "{stone}Your blood is full. The stone lets you go" + pool: + fed: "{stone}The pool drinks them down and rises. You feel it in your hands again" + thinned: "{stone}Your blood runs thinner. Something you could call on, you no longer can" + too_thin: "#8c8c8c§oYour blood runs too thin for that" + + class: + + rewarded: "#8c8c8c§oSomething new stirs in your blood. Your skills have changed (/skills)" + + choice: + + # After the answer is sealed. prompt: once the question is asked (answer aloud, in roleplay). + prompt: "#8c8c8c§oSpeak aloud: kneel to him, or refuse him" + devoted: "{god}Something of you stays on the stone, and something of the Blood Father comes with you. It hurts. It will always hurt" + + refused: "{self}Lorvear falls silent. For the first time in weeks you are alone with your own thoughts. You are almost sure they are your own" + + armour: + + refuses: "#8c8c8c§oIt will not sit on you. It knows whose blood it was made for" + + crumbles: "#8c8c8c§oIt crumbles to rust and dried blood in your hands" + + direction: + + north: "north" + + northeast: "north-east" + + east: "east" + + southeast: "south-east" + + south: "south" + + southwest: "south-west" + + west: "west" + + northwest: "north-west" + + admin: + + usage: "#aaaaaaUsage: /magic blood list | info | stage | path | give | whisper [amount] | voice | target > | bonds | altar | pool [amount] | reset " + + no_record: "#aaaaaa{player} ({character}) has no blood path." + + info: "#d10202{player} #aaaaaa({character}) stage #ffffff{stage}#aaaaaa, path #ffffff{path}#aaaaaa, whisper #ffffff{whisper}#aaaaaa, species #ffffff{species_count}/7 #555555[{species}]#aaaaaa, hunt #ffffff{hunt}#aaaaaa, finals #ffffff{finals}#aaaaaa, progress #ffffff{progress}#aaaaaa, altar #ffffff{altar}" + + list_header: "#d10202Blood altars: #ffffff{count}" + + list_line: "#aaaaaa- #ffffff{id} #aaaaaacharacter #ffffff{character} #aaaaaaat #ffffff{where} #aaaaaalevel #ffffff{level}" + + bad_stage: "#ff5555Unknown stage. Use one of: {stages}" + + bad_path: "#ff5555Unknown path. Use one of: {paths}" + + bad_item: "#ff5555Unknown item. Use altar, ritual_knife or blood_knife." + + stage_set: "#55ff55{player} is now at stage {stage}." + + path_set: "#55ff55{player} is now on path {path}." + + given: "#55ff55Gave {player} a bound {item}." + artefacts_given: "#55ff55Gave {player} a copy of each of their {count} artefact(s)." + pool: "#d10202{player} #aaaaaapool #ffffff{pool}/{capacity} #aaaaaa(path complete #ffffff{draining}#aaaaaa), blood resonance #ffffff{resonance} #aaaaaa(tier #ffffff{tier}#aaaaaa), artefacts charged #ffffff{charged}/{total}" + + reset: "#55ff55{player}'s blood path was cleared and any altar purified." + + no_altar: "#ff5555No altar found for {target}." + + altar_refreshed: "#55ff55Altar refreshed at level {level}." + + altar_purified: "#55ff55Altar at {where} purified." + + tp_unavailable: "#ff5555Only in-game staff can teleport, and the altar's world must be loaded." + + whispered: "#55ff55The voices now speak to {player}. The first line comes within a minute." + + already_whispered: "#ff5555{player} already hears the voices." + + whisper_added: "#55ff55{player}'s whisper meter is now {value} (voices woke: {woke})." + + no_voices: "#ff5555{player} does not hear any voices." + + voiced: "#55ff55{player} heard the next line." + + not_final: "#ff5555{player} is not at the final sacrifices." + + no_character_named: "#ff5555No living character is called {name}." + + targets: "#d10202{player} #aaaaaafinals done #ffffff{done}#aaaaaa, targets #ffffff{targets}" + + bonds_header: "#d10202{player} #aaaaaahas bonds with #ffffff{count} #aaaaaacharacter(s):" + + bonds_line: "#aaaaaa{rank}. #ffffff{name} #555555({score})" diff --git a/src/main/resources/wildmagic/bloodmagic/settings.yml b/src/main/resources/wildmagic/bloodmagic/settings.yml new file mode 100644 index 0000000..25440f4 --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/settings.yml @@ -0,0 +1,234 @@ +# Blood Magic (Magic's hidden blood path), wildmagic/bloodmagic. Hidden content: never on the wiki. +# This folder holds everything the path uses, one topic per file: settings (this file: the switch, +# the altar, where it may stand, the hunt, bonds, finals, hidden classes, path permissions), items, +# voices, animals, corruption, choice, armour, fx and messages. Every .yml here but messages.yml is +# merged into one config, so a section may live in any of them. +# The jar does not overwrite existing live copies: copy new keys by hand after updates. +# The god is Lorvear, the first vampire, the Blood Father. The blade and class names are placeholders. + +enabled: true + +altar: + # The altar is a barrier block (unbreakable, piston and blast proof) with a display on it. + block: BARRIER + # Altar look per level. Level rises with the owner's progress (see levels below). + # model is a TLibs item path shown by the display entity. + models: + 0: ia.tfmc_blood:altar_0 + 1: ia.tfmc_blood:altar_1 + 2: ia.tfmc_blood:altar_2 + 3: ia.tfmc_blood:altar_3 + 4: ia.tfmc_blood:altar_4 + 5: ia.tfmc_blood:altar_5 + display_scale: 1.0 + # Progress needed for each altar level. Animal sacrifices count 1 each, + # the god's question +1, hunt points 1 each, final sacrifices 2 each. + levels: + 1: 2 + 2: 5 + 3: 8 + 4: 12 + 5: 18 + +placement: + # Worlds where an altar may stand. Empty = every world. + worlds: [] + # Underground: no sky light at the altar and at least min_cover solid blocks above it. + require_underground: true + min_cover: 3 + cover_scan_height: 48 + # Refuse any WorldGuard region except the ids listed here (needs WorldGuard). + deny_regions: true + allowed_regions: [] + # The altar is also a meditation circle, sat on in the middle of the eight pedestals (config.yml + # meditation.cardinal_offset / diagonal_offset, 4 and 3). Placing it lays its chamber: open floor out + # to the farthest pedestal on every side (9 by 9 by default), height blocks high, and the floor, the + # walls around it and the ceiling relaid in the template blocks so every chamber starts the same. + # Openings in the walls and ceiling (the way in) stay open. If more than max_changes blocks would be + # carved or filled, or the room would hit or open onto water or lava, a container, unbreakable blocks, + # another altar, claimed ground or open sky, the stone refuses and tells the player the size it needs. + # Then the chamber, out to the corruption's farthest reach, is locked: nobody but staff + # (magic.blood.bypass) may place, break or hang anything there. The room stays when the altar is purified. + room: + enabled: true + height: 3 + max_changes: 150 + # Blocks the chamber is laid in (repeat an entry to weigh it). Keep them blocks the corruption turns + # (corruption.yml convert) so the room still reddens. An empty list leaves that part as it was. + # candles: { block: , count: 1-4 } stands a lit cluster in each floor corner (none by default: + # candles come with the corruption and on the altar itself; red ones count toward a bloodmagic shrine). + # altar_light: the stone glows (an unseen light just above it). light: hidden light in the four top + # corners. Levels 0-15, 0 for none. + template: + floor: [STONE_BRICKS, STONE_BRICKS, STONE_BRICKS, CRACKED_STONE_BRICKS, STONE] + walls: [STONE, STONE, STONE, COBBLESTONE, ANDESITE] + ceiling: [STONE, STONE, STONE, COBBLESTONE] + altar_light: 7 + light: 0 + # How often (minutes) altars of dead owners are checked and purified. + purify_check_minutes: 10 + +hunt: + # True path: points of character sacrifices needed for the True class. + points_required: 7 + # A knocked-out character this close to the altar can be dragged onto it with the blood knife. + drag_range: 8 + # Seconds the rite takes. Once it starts, the character dies for good when it ends. + rite_seconds: 10 + # Sneak and right-click a knocked-out character with the blood knife to drag the body along behind you. + # Right-click your altar (or the body) with the knife to lay it on the altar; sneak-click the body to let go. + carry: + enabled: true + # Blocks the body trails behind you. + distance: 1.2 + # Slowness level while dragging (0 = none). + slowness: 1 + # The drag breaks if the body ends up further away than this (teleports, pearls). + break_distance: 6 + victims: + # Characters of the same account never count. These keep fresh alts out too. + min_playtime_hours: 10 + min_age_days: 7 + # The same player (any of their characters) may not be taken twice in a row. + no_repeat_player: true + # Hunt points by how close the victim was (rank 1 = closest). The closest finals.count are kept + # for the final sacrifices and cannot be taken in the hunt. Strangers (no bond at all) are worth "stranger". + points: + stranger: 1 + default: 1 + ranks: + - { up_to: 10, points: 3 } + - { up_to: 25, points: 2 } + +# Hidden closeness between characters. Never shown to players. +bonds: + # Every sample, characters within near_radius of each other add the sample's minutes together. + sample_minutes: 5 + near_radius: 16 + # Score = conversation_weight x counted RP exchanges (RPCharacters) + hour_weight x hours near each other. + conversation_weight: 3.0 + hour_weight: 2.0 + # A will heir adds this, only if the heir is another player's character the two really spent time with. + heir_bonus: 40 + heir_min_hours: 10 + heir_min_conversations: 20 + +# The final sacrifices: the closest characters, named one at a time from least close to closest. +finals: + count: 5 + # A named target that dies any other lethal way: backlash and this wait before the next is named. + fail_cooldown_hours: 72 + # A named target not seen for this long is quietly replaced. + inactive_days: 21 + # MythicLib stat boons per final sacrifice (they add up). Placeholder values. Only the true path + # gets boons (fran); a path left out here gets none. + boons: + "true": + 1: { MAX_HEALTH: 2 } + 2: { MAX_HEALTH: 2 } + 3: { MAX_HEALTH: 2 } + 4: { SPELL_DAMAGE: 4 } + 5: { MAX_HEALTH: 4 } + # What each final sacrifice gives the path's class (see classes: below), on top of the boons. + # Worked out again from the sacrifices every time, so a change here also reaches characters part + # way through; an ascended character gets every listed reward. + # skill_points: added to the class's own skill-point pool, which RPCharacters keeps apart from + # the character's other classes (placeholder amounts, balance later) + # slots: MMOCore skill slots it opens + # spells: MMOCore class skills it unlocks + class_rewards: + devotee: + 1: { skill_points: 2 } + 2: { skill_points: 2, slots: [3] } + 3: { skill_points: 2 } + 4: { skill_points: 2, slots: [4] } + 5: { skill_points: 2 } + "true": + 1: { slots: [2], spells: [SANGUINAR_EXSANGUINATE, SANGUINAR_OSSUARY] } + 2: { slots: [3], spells: [SANGUINAR_SANGUINE_POOL, SANGUINAR_HEMOPLAGUE] } + 3: { slots: [4], spells: [SANGUINAR_BLOOD_MOON] } + 4: { slots: [5], spells: [SANGUINAR_SANGUINE_CHAINS] } + 5: { slots: [6], spells: [SANGUINAR_RED_HARVEST, SANGUINAR_CRIMSON_TIDE] } + +# Hidden MMOCore classes (options.display: false). Players never pick them: RPCharacters puts the +# character into the devotee class when it kneels and into the true class when its hunt is done, and +# binds it there (/class refuses; staff release with /rpcharacter admin releaseclass). +classes: + devotee: bloodsworn + "true": sanguinar + +# Permissions a character on each path holds while it is played (one or a list), e.g. to see the +# hidden Wild Mage Sets skin category (magic.wild) and the path's skins in it. They follow the +# character: another character of the same player has none. +permissions: + devotee: [magic.blood.bloodsworn, magic.wild] + "true": magic.blood.sanguinar + +# The pool of blood in the altar and the blood resonance it feeds: what keeps the blood classes' +# spells working. Artefacts charge in the pool (right-click the altar with one); meditating on the +# altar among the charged ones draws their charge into blood resonance. Placeholder values. +pool: + # The pool stays full and charging is free until the path is complete. Then each point of charge + # costs blood_per_point of the pool's blood. + capacity: 100 + blood_per_point: 2 + # Once the path is complete, blood resonance loses this share (percent) of itself per hour the + # character is played (offline time never counts), so the highest tiers need topping up soonest. + decay_percent_per_hour: 1 + # Blood resonance never sinks below this, so the lowest tier always works. + floor: 5 + # Once the path is complete, a person sacrificed on the altar (same rules as the hunt's victims) + # fills the pool by how close they were to the mage (rank 1 = closest; strangers have no bond). + # Animals never refill it. + refill: + people: + stranger: 40 + default: 40 + ranks: + - { up_to: 10, amount: 100 } + - { up_to: 25, amount: 60 } + # The most blood resonance the path's artefacts can give (each one holds an equal share of charge), + # by final sacrifices done; an ascended mage has the last entry. + worth: + devotee: { 0: 20 } + "true": { 0: 20, 1: 35, 2: 55, 3: 75, 4: 80, 5: 95 } + # Blood resonance each spell tier needs: 1 Minor, 2 Lesser, 3 Greater, 4 Ascendant, 5 Exalted, + # 6 Transcendent. + tiers: { 1: 5, 2: 15, 3: 30, 4: 50, 5: 70, 6: 90 } + # The blood spells by tier (MythicLib skill ids). Spells not listed are never refused. + spells: + 1: [BLOODSWORN_BLOOD_BOLT, BLOODSWORN_OPEN_VEINS, BLOODSWORN_BLOOD_KISS, BLOODSWORN_CRIMSON_STEP] + 2: [BLOODSWORN_BLOOD_BOIL, BLOODSWORN_BLOODBEND, BLOODSWORN_MARK_OF_THE_BLOOD_FATHER, BLOODSWORN_CHALICE_OF_BLOOD] + 3: [SANGUINAR_EXSANGUINATE, SANGUINAR_OSSUARY] + 4: [SANGUINAR_SANGUINE_POOL, SANGUINAR_HEMOPLAGUE] + 5: [SANGUINAR_BLOOD_MOON, SANGUINAR_SANGUINE_CHAINS] + 6: [SANGUINAR_RED_HARVEST, SANGUINAR_CRIMSON_TIDE] + +# Wild magic meditation: sit on your altar among its charged artefacts. With each heartbeat a drop of +# blood rises from one and drifts to you; swing at a drop to catch it. A catch within window_ticks of +# a beat (every drop swells then) draws on_beat_draw charge into blood resonance; off the beat it draws +# off_beat_draw and costs off_beat_health of your own health (never the last point). Drops left alone +# fall back after linger_beats. Charging an artefact in the pool takes charge_ticks. +meditation: + beat_ticks: 30 + window_ticks: 6 + on_beat_draw: 3 + off_beat_draw: 1 + off_beat_health: 1 + hit_range: 6 + hit_radius: 0.6 + charge_ticks: 160 + drops: + item: v.redstone + scale: 0.3 + flare_scale: 0.55 + travel_beats: 2 + linger_beats: 2 + max: 4 + # Private sounds: sound (vanilla or resource pack key), volume, pitch, seconds (how long it runs + # before it loops; 0 plays it once). music loops while meditating, charge plays as an artefact goes + # into the pool. The tfmc_blood sounds are in the server pack (ServerAssets). + sounds: + heartbeat: { sound: entity.warden.heartbeat, volume: 0.9, pitch: 0.7 } + music: { sound: tfmc_blood:meditate.chant, volume: 0.7, pitch: 1.0, seconds: 24 } + charge: { sound: tfmc_blood:rite.charge, volume: 0.8, pitch: 1.0 } diff --git a/src/main/resources/wildmagic/bloodmagic/voices.yml b/src/main/resources/wildmagic/bloodmagic/voices.yml new file mode 100644 index 0000000..bf320f0 --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/voices.yml @@ -0,0 +1,149 @@ +# The whisper meter that wakes the voices, and every timed line the two voices say. +# Part of wildmagic/bloodmagic (Magic's hidden blood path). Hidden content: never on the wiki. + +# Hidden whisper meter. Dark deeds fill it; past threshold the voices start. It slowly drains. +whispers: + threshold: 30 + decay_per_hour: 1.0 + # Characters with one of these RPCharacters traits fill the meter faster (the evil fast route). + fast_traits: [heretic] + fast_multiplier: 3.0 + sources: + # Killing a listed animal with a blade. + animal_kill: 2.0 + # Animal kills count this many times more at night or with nobody near. + alone_or_night_multiplier: 2.0 + # A character you killed died for good. + character_kill: 15.0 + # Every ten minutes while your equilibrium is at or below corruption_below. + corruption: 1.0 + corruption_below: -40 + animal_types: [COW, PIG, SHEEP, CHICKEN, GOAT, RABBIT, HORSE, DONKEY, MULE, LLAMA, CAMEL, MOOSHROOM, WOLF, FOX, CAT, OCELOT, PARROT] + +# The two voices. The god is Lorvear, the first vampire, the Blood Father: ancient, regal and paternal, +# he calls the mage "child". The other voice is the mage's own thoughts: from the knife on it answers +# him half the time (animal and choice lines), and it is all a mage hears once they refuse him. +# Keep both inside blood magic: no other gods, orders or schools. +# whispered lines are said in order and end with the knife and altar. animal lines come in tiers by +# kinds given (the highest number reached; a plain list instead slides a third of the way along it). +# Animal-stage voices speak only in the altar's chamber, while it is ready for the next kind and no rite +# is under way (with no altar standing, they keep quiet). Other stages pick at random. +# Colours: each voice is a gradient that drifts from colors.start to colors.end over the whole path +# (kinds, the answer, the hunt, the finals). Lorvear starts dim and grows into the bright Bloodmagic +# reds; the mage's own voice starts pale and clear, as they are, and sinks into dark blood. +# style is put after every colour (italics). Without colors, style is the whole prefix as before. +voices: + # Minutes between lines, picked at random in this range. + interval_minutes: [15, 35] + # Only speak when no other player is within alone_radius blocks. + require_alone: true + alone_radius: 24 + god: + style: "§o" + colors: + start: ["#6b1f1f", "#561515"] + end: ["#aa0000", "#d10202", "#e80505"] + sound: minecraft:ambient.cave + pitch: 0.5 + lines: + whispered: + - "...there. Under the stone. A heartbeat that is not your own." + - "Do not be afraid. It has been so long since anyone listened." + - "So much warm blood walks the roads. Spilled in ditches and brawls and childbeds. All of it wasted." + - "One called Alaric listened, once, long before your grandfather's grandfather. He wasted nothing." + - "Go down, deep, where the sky cannot follow. No banner, no warden's eye. Lay the stone there." + - "Bring living beasts down to it. Hold them still with the plain knife, and let the stone drink." + - "Not beasts of your own. A gift that costs nothing is no gift. Take them from another's pen while the keeper sleeps." + - "Seven kinds, one each day and no sooner. I can wait. I have waited longer than your kingdoms have had names." + - "Look in your pack. Someone has been generous. Do not make them regret it." + # By kinds given: the lines under the highest number reached. + animal: + 0: + - "The stone is thirsty, child. Can you not hear it?" + - "Do not be afraid of the knife. Be afraid of a life where nothing ever bled for you." + - "Every pen in the valley is full, and every shepherd sleeps." + - "Lead it down gently. Fear sours the blood." + 1: + - "There. Was that so hard? Feel how the stone remembers you now." + - "Do not look away when it bleeds. Looking away is how blood is wasted." + - "Look into its eyes when you do it. I want to see what you see." + - "Wash your hands before you climb back up. They can smell it on you." + 3: + - "I remember the taste of the last one. Bring me a kind I have not had." + - "Your hands are steadier than they were. I noticed. I notice everything you give me." + - "You hold the knife the way I used to. Do you feel it? We are becoming alike." + 5: + - "Each kind you bring wakes a little more of me. Soon you will feel me waking too." + - "Almost seven, child. The stone is nearly red to its heart, and so are you." + - "When the last one bleeds, come down to me. I will have a gift for you, and a question." + choice: + - "Seven kinds have bled. Come down to the stone, and bring the winding blade. Your father has a question." + - "The stone is red to its heart. Kneel at it with the blade in your hand, child." + - "Do not keep me waiting. Nothing that lives has kept me waiting for long." + final: + - "You are my child now, and so are they. I will tell you which of them." + - "I have chosen for you. You need not think any more. Only go to the stone." + - "Do you remember their laugh? Remember it on the stone. It makes them sweeter." + - "They trust you. That is what makes it holy." + - "The grave is greedy, and the dead are cold. I prefer them warm, and given freely by someone they loved." + - "Every drop you give me, I remember. Every drop, I repay." + ascended: + - "All of them, given. Sleep now, my child. Your father will wake you when he is hungry." + - "You walk among them and none of them know. You are more mine than Alaric ever was." + - "Let them send their hunters. I have outlived every one of them." + self: + style: "§o" + colors: + start: ["#d8d2e0", "#bdb6c9"] + end: ["#8a0303", "#5c0202", "#4a0a14"] + sound: minecraft:entity.warden.heartbeat + pitch: 1.2 + lines: + animal: + 0: + - "What am I doing down here? It's only a stone. Only a voice." + - "I could still throw the knife in the river. Nobody would ever know." + - "Someone will wake to an empty pen and never know why. Someone who never did anything to me." + - "My hands won't stop shaking. Good. They should shake." + - "If I do this once, I don't think I get to be who I was." + 1: + - "I keep seeing its eyes. It didn't even struggle." + - "It's only an animal. People eat them every day. This is no different." + - "The farmer will buy another. The stone has no one else." + - "I told myself once would be enough. It wasn't." + 3: + - "My hands didn't shake this time. I noticed, and then I stopped caring that I noticed." + - "I've started counting the kinds without meaning to." + - "Nobody tells you how warm it is. Nobody tells you how good the warmth feels." + - "I sleep fine now. That should frighten me more than it does." + 5: + - "I washed my hands three times today. I didn't need to. I liked how red the water went." + - "I caught myself looking at a neighbour's goats and wondering how they'd bleed." + - "It's only work now. Hold, cut, wait. The stone is grateful. That's more than most are." + - "Seven kinds, and then it's finished. Then what? Then more, I hope." + choice: + - "Kneel? To him? I have never knelt to anyone." + - "Father. As if I need another one." + - "Everything he gives, he owns. What if I took it, and owed no one?" + - "He wants a child on its knees. I could be so much more than his child." + - "Listen past his voice. Underneath it there is a quiet. The quiet is honest." + hunt: + - "I don't need a father to tell me what I want." + - "Lorvear went quiet. Let him sulk under his stone. I can hear myself think." + - "Strangers are cheap. The ones I know are worth more. I knew that before anyone told me." + - "Nobody notices one more empty chair. Not at first." + - "Seven measures. I keep counting, even when I pretend I'm not." + - "They smiled at me today. They have no idea." + - "Warm, he wanted them. Why warm? Cold keeps." + final: + - "It has to be them. I've known it for days." + - "The closer they are, the more it gives. That's the whole trick, isn't it?" + - "I'll be quick. I owe them that much." + - "If I stop now, everything before was for nothing." + - "Their name keeps coming back. Of course it does." + - "Afterwards it is so still. I could live in that stillness." + ascended: + - "There is no one left I would miss. So this is what freedom feels like." + - "They would call me evil. They'd be right. And far too late." + - "Quiet. So quiet. No god, no father. I did it all myself." + - "...and in the quiet, something old and patient is smiling. It sounds like me." diff --git a/src/test/java/net/tfminecraft/magic/CompletionRegressionTest.java b/src/test/java/net/tfminecraft/magic/CompletionRegressionTest.java index 7d1a090..e453bab 100644 --- a/src/test/java/net/tfminecraft/magic/CompletionRegressionTest.java +++ b/src/test/java/net/tfminecraft/magic/CompletionRegressionTest.java @@ -24,7 +24,7 @@ List console(String... args) { } @Test void consoleOnlySeesExecutableRoutesAtEveryDepth() { - assertEquals(List.of("weapon", "reload", "artifact", "resonance", "player", "equilibrium", "castmode"), console("")); + assertEquals(List.of("weapon", "reload", "artifact", "resonance", "player", "equilibrium", "castmode", "blood"), console("")); assertEquals(List.of("reload", "resonance"), console("RE")); assertEquals(List.of("roll", "give", "path"), console("artifact", "")); for (String root : List.of("open", "refresh", "rune", "fillchest", "shrine")) { diff --git a/src/test/java/net/tfminecraft/magic/MeditationServiceTest.java b/src/test/java/net/tfminecraft/magic/MeditationServiceTest.java index b1497ab..26260ef 100644 --- a/src/test/java/net/tfminecraft/magic/MeditationServiceTest.java +++ b/src/test/java/net/tfminecraft/magic/MeditationServiceTest.java @@ -136,6 +136,21 @@ void sittingStartsTicksLocksAndLeavingPersistsAfterWindDown() { assertFalse(MeditationService.locksFurniture(post)); } + @Test + void aBloodAltarSeatIsLeftToBloodMeditation() { + var altar = net.tfminecraft.magic.blood.BloodAltar.create("mage", "o", player.getLocation().getBlock(), 0f, 1L); + net.tfminecraft.magic.blood.BloodAltars.add(altar); + try { + sit(); + service.start(); + tick(); + assertTrue(created.constructed().isEmpty()); + detection.verify(() -> MeditationCircle.detect(any()), never()); + } finally { + net.tfminecraft.magic.blood.BloodAltars.remove(altar); + } + } + @Test void noticesAreDeduplicatedUntilPlayerLeavesAndInvalidCircleCannotStart() { service.start(); diff --git a/src/test/java/net/tfminecraft/magic/TickAndMessagesTest.java b/src/test/java/net/tfminecraft/magic/TickAndMessagesTest.java index ef459fd..c11c321 100644 --- a/src/test/java/net/tfminecraft/magic/TickAndMessagesTest.java +++ b/src/test/java/net/tfminecraft/magic/TickAndMessagesTest.java @@ -89,6 +89,38 @@ void schedulerRunsAllHandlersDespiteFailureAndStopsCleanly() { assertEquals(stopped, MagicTickService.getElapsedSeconds()); } + @Test + void moduleMessagesLayOverTheMainFile() throws Exception { + when(Magic.plugin.getResource("messages.yml")).thenReturn(null); + when(Magic.plugin.getResource("mod/messages.yml")).thenAnswer(i -> new ByteArrayInputStream( + "blood:\n bundled: Bundled blood\n live: Bundled live\n".getBytes(StandardCharsets.UTF_8))); + for (String field : new String[] {"config", "bundled"}) { + var f = Messages.class.getDeclaredField(field); + f.setAccessible(true); + f.set(null, null); + } + Path mod = temp.resolve("mod.yml"); + Files.writeString(mod, "blood:\n live: Live blood\nshared: Module\n"); + Messages.overlay(mod.toFile(), "mod/messages.yml"); + assertEquals("Live blood", Messages.getRaw("blood.live")); + assertEquals("Bundled blood", Messages.getRaw("blood.bundled")); + + Path main = temp.resolve("messages.yml"); + Files.writeString(main, "shared: Main\nmain: Main\n"); + Messages.load(main.toFile()); + Messages.overlay(mod.toFile(), "mod/messages.yml"); + assertEquals("Module", Messages.getRaw("shared")); + assertEquals("Main", Messages.getRaw("main")); + assertEquals("Bundled blood", Messages.getRaw("blood.bundled")); + + Path broken = temp.resolve("broken.yml"); + Files.writeString(broken, "blood: [unclosed\n"); + Messages.overlay(broken.toFile(), "none.yml"); + Messages.overlay(null, "none.yml"); + Messages.overlay(temp.resolve("absent.yml").toFile(), "none.yml"); + assertEquals("Live blood", Messages.getRaw("blood.live")); + } + @Test void messagesLoadLiveOverrideAndBundledFallbackWithPlaceholders() throws Exception { String resource = "fallback: 'Bundled {name}'\nblank: ''\nlive: Bundled\n"; diff --git a/src/test/java/net/tfminecraft/magic/blood/AltarCandlesTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarCandlesTest.java new file mode 100644 index 0000000..c498359 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/AltarCandlesTest.java @@ -0,0 +1,123 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import org.bukkit.Location; +import org.bukkit.Particle; +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 org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class AltarCandlesTest { + @TempDir Path temp; + BloodFixture fx; + final List lit = new ArrayList<>(); + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + assertTrue(BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger)); + AltarCandles.spawner = (particle, at) -> lit.add(particle); + } + + @AfterEach + void cleanup() { + AltarCandles.stop(); + AltarCandles.spawner = (particle, at) -> at.getWorld().spawnParticle(particle, at, 1, 0, 0, 0, 0); + AltarCandles.random = new Random(); + fx.close(); + } + + void ticks(int count) { + for (int i = 0; i < count; i++) { + AltarCandles.tick(); + } + } + + @Test + void wicksFollowTheAltarsFacing() { + Location center = new Location(fx.world, 0.5, 10.5, 0.5); + AltarCandles.Wick wick = new AltarCandles.Wick(13.5, 16.4, 2.5, 5); + Location facingSouth = AltarCandles.position(center, 0f, wick); + assertEquals(0.5 - 5.5 / 16, facingSouth.getX(), 1e-9); + assertEquals(10.5 + 8.4 / 16, facingSouth.getY(), 1e-9); + assertEquals(0.5 + 5.5 / 16, facingSouth.getZ(), 1e-9); + Location facingWest = AltarCandles.position(center, 90f, wick); + assertEquals(0.5 - 5.5 / 16, facingWest.getX(), 1e-9); + assertEquals(0.5 - 5.5 / 16, facingWest.getZ(), 1e-9); + } + + @Test + void flamesBurnOnlyWhileSomeoneIsNearAndOnlyOnCandlesThatAreThere() throws Exception { + BloodAltar altar = BloodAltar.create("a", "o", fx.pocket(0, 10, 0), 0f, 1L); + BloodAltars.add(altar); + ticks(16); + assertTrue(lit.isEmpty()); + PlayerMock watcher = fx.player("watcher"); + AltarCandles.random = new Random() { + @Override public double nextDouble() { return 0.99; } + }; + ticks(8); + assertEquals(4, lit.size()); + assertFalse(lit.contains(Particle.SMOKE)); + lit.clear(); + altar.setLevel(4); + ticks(8); + assertEquals(3, lit.size()); + lit.clear(); + AltarCandles.random = new Random() { + @Override public double nextDouble() { return 0.0; } + }; + ticks(8); + assertEquals(6, lit.size()); + assertTrue(lit.contains(Particle.SMOKE)); + lit.clear(); + + watcher.teleport(new Location(fx.world, 100, 10, 100)); + ticks(8); + assertTrue(lit.isEmpty()); + watcher.teleport(new Location(fx.world, 0.5, 11, 0.5)); + fx.world.unloadChunk(0, 0); + ticks(8); + assertTrue(lit.isEmpty()); + fx.world.loadChunk(0, 0); + BloodAltars.add(BloodAltar.create("gone", "o2", new org.mockbukkit.mockbukkit.world.WorldMock().getBlockAt(0, 0, 0), 0f, 1L)); + ticks(8); + assertFalse(lit.isEmpty()); + + Files.writeString(temp.resolve("c.yml"), String.join("\n", "enabled: true", "fx:", " candles:", + " enabled: false", "")); + assertTrue(BloodConfig.load(temp.resolve("c.yml").toFile(), fx.logger)); + lit.clear(); + ticks(16); + assertTrue(lit.isEmpty()); + Files.writeString(temp.resolve("w.yml"), String.join("\n", "enabled: true", "fx:", " candles:", + " particle: nope", " every_ticks: 0", " smoke_chance: 3", " view_range: 0", + " wicks: [[1, 2, 3], [x, 1, 2], [4]]", "")); + assertTrue(BloodConfig.load(temp.resolve("w.yml").toFile(), fx.logger)); + lit.clear(); + ticks(1); + assertEquals(List.of(Particle.SMALL_FLAME, Particle.SMOKE), lit); + Files.writeString(temp.resolve("n.yml"), "enabled: true\nfx:\n enabled: true\n"); + assertTrue(BloodConfig.load(temp.resolve("n.yml").toFile(), fx.logger)); + lit.clear(); + ticks(16); + assertTrue(lit.isEmpty()); + + var plugin = MockBukkit.createMockPlugin("Candles"); + AltarCandles.startTicking(plugin); + AltarCandles.startTicking(plugin); + AltarCandles.stop(); + AltarCandles.stop(); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/AltarPedestalsTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarPedestalsTest.java new file mode 100644 index 0000000..46667ba --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/AltarPedestalsTest.java @@ -0,0 +1,333 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.inventory.ItemStack; +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 org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.FurnitureType; +import net.tfminecraft.interactiblefurniture.loaders.FurnitureLoader; +import net.tfminecraft.interactiblefurniture.manager.FurnitureManager; +import net.tfminecraft.interactiblefurniture.manager.handlers.FurniturePlacementHandler; +import net.tfminecraft.interactiblefurniture.manager.handlers.InteractionHandler; + +class AltarPedestalsTest { + @TempDir Path temp; + BloodFixture fx; + MockedStatic bridge; + MockedStatic placement; + MockedStatic interaction; + InteractibleFurniture plugin; + FurnitureManager manager; + final Map placed = new HashMap<>(); + int spawns; + org.bukkit.plugin.Plugin ifPlugin; + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + ifPlugin = org.mockbukkit.mockbukkit.MockBukkit.createMockPlugin("InteractibleFurniture"); + plugin = mock(InteractibleFurniture.class); + manager = mock(FurnitureManager.class); + when(plugin.getFurnitureManager()).thenReturn(manager); + when(manager.getPlacedFurniture()).thenReturn(placed); + bridge = mockStatic(InteractibleFurniture.class); + bridge.when(InteractibleFurniture::getInstance).thenReturn(plugin); + placement = mockStatic(FurniturePlacementHandler.class); + // The third free spot finds something already there. + placement.when(() -> FurniturePlacementHandler.spawnDisplayEntity(any(), any(), anyFloat(), any())) + .thenAnswer(i -> ++spawns == 3 ? null : fx.world.spawn(i.getArgument(1, Location.class), ItemDisplay.class)); + interaction = mockStatic(InteractionHandler.class); + FurnitureType type = mock(FurnitureType.class); + when(type.getId()).thenReturn("pedestal"); + when(type.getLayers()).thenReturn(List.of()); + FurnitureLoader.getMap().put("pedestal", type); + } + + @AfterEach + void cleanup() { + FurnitureLoader.getMap().remove("pedestal"); + interaction.close(); + placement.close(); + bridge.close(); + fx.close(); + } + + @Test + void theRingFacesTheAltar() { + assertEquals(0f, AltarPedestals.yaw(0, -4)); + assertEquals(270f, AltarPedestals.yaw(4, 0)); + assertEquals(180f, AltarPedestals.yaw(0, 4)); + assertEquals(90f, AltarPedestals.yaw(-4, 0)); + assertEquals(315f, AltarPedestals.yaw(3, -3)); + assertEquals(225f, AltarPedestals.yaw(3, 3)); + assertEquals(135f, AltarPedestals.yaw(-3, 3)); + assertEquals(45f, AltarPedestals.yaw(-3, -3)); + assertEquals(8, AltarPedestals.ring().length); + // An altar saved before it kept pedestals reads back with none. + assertTrue(new com.google.gson.Gson().fromJson("{\"pedestals\":null}", BloodAltar.class).getPedestals().isEmpty()); + } + + @Test + void theAltarSetsOutPedestalsOnFreeSpotsThatOnlyPurifyingRemoves() { + fx.open(-4, 10, -4, 4, 12, 4); + Block center = fx.world.getBlockAt(0, 10, 0); + fx.world.getBlockAt(4, 10, 0).setType(Material.CHEST); + fx.world.getBlockAt(-4, 9, 0).setType(Material.AIR); + BloodAltar altar = BloodAltar.create(BloodFixture.id("mage"), "o", center, 0f, 1L); + + assertEquals(5, AltarPedestals.place(altar, center)); + assertEquals(5, altar.getPedestals().size()); + assertEquals(5, placed.size()); + verify(manager, times(5)).persistFurniture(any()); + interaction.verify(() -> InteractionHandler.spawnInteraction(any()), times(5)); + placement.verify(() -> FurniturePlacementHandler.placeBarrierBlocks(any(), any(), any(), eq(BlockFace.UP)), times(5)); + Furniture first = placed.values().iterator().next(); + assertNull(AltarPedestals.owner(first)); + BloodAltars.add(altar); + assertSame(altar, AltarPedestals.owner(first)); + assertNull(AltarPedestals.owner(null)); + assertNull(AltarPedestals.owner(new Furniture("pedestal", center.getLocation(), null))); + Furniture stray = new Furniture("pedestal", center.getLocation(), UUID.randomUUID()); + assertNull(AltarPedestals.owner(stray)); + + BloodListener listener = new BloodListener(); + PlayerMock thief = fx.player("thief"); + FurnitureBreakEvent broken = new FurnitureBreakEvent(first, thief); + listener.onFurnitureBreak(broken); + assertTrue(broken.isCancelled()); + assertEquals("blood.altar.pedestal_bound", thief.nextMessage()); + FurnitureBreakEvent byPlugin = new FurnitureBreakEvent(first, null); + listener.onFurnitureBreak(byPlugin); + assertFalse(byPlugin.isCancelled()); + FurnitureBreakEvent other = new FurnitureBreakEvent(stray, thief); + listener.onFurnitureBreak(other); + assertFalse(other.isCancelled()); + + thief.setSneaking(true); + FurnitureInteractEvent carry = new FurnitureInteractEvent(thief, first); + listener.onFurnitureInteract(carry); + assertTrue(carry.isCancelled()); + assertEquals("blood.altar.pedestal_bound", thief.nextMessage()); + FurnitureInteractEvent strayCarry = new FurnitureInteractEvent(thief, stray); + listener.onFurnitureInteract(strayCarry); + assertFalse(strayCarry.isCancelled()); + thief.getInventory().setItemInMainHand(new ItemStack(Material.STICK)); + FurnitureInteractEvent withItem = new FurnitureInteractEvent(thief, first); + listener.onFurnitureInteract(withItem); + assertFalse(withItem.isCancelled()); + thief.setSneaking(false); + thief.getInventory().setItemInMainHand(null); + FurnitureInteractEvent use = new FurnitureInteractEvent(thief, first); + listener.onFurnitureInteract(use); + assertFalse(use.isCancelled()); + + // One pedestal is not loaded when the altar is purified: it stays bound until its chunk loads. + UUID unloaded = UUID.randomUUID(); + altar.getPedestals().add(unloaded.toString()); + AltarService.purify(altar, "test"); + verify(manager, times(5)).removePlugin(any()); + assertTrue(altar.getPedestals().isEmpty()); + assertEquals(java.util.Set.of(unloaded.toString()), AltarPedestals.leftovers()); + Furniture late = new Furniture("pedestal", center.getLocation(), unloaded); + assertTrue(AltarPedestals.bound(late)); + assertFalse(AltarPedestals.bound(stray)); + assertFalse(AltarPedestals.bound(null)); + assertFalse(AltarPedestals.bound(new Furniture("pedestal", center.getLocation(), null))); + BloodStore store = new BloodStore(new java.io.File(fx.data.toFile(), "blood"), fx.logger); + assertEquals(List.of(unloaded.toString()), store.loadLeftovers()); + + AltarPedestals.sweep(); + verify(manager, times(5)).removePlugin(any()); + placed.put(unloaded, late); + AltarPedestals.sweep(); + verify(manager).removePlugin(late); + assertTrue(AltarPedestals.leftovers().isEmpty()); + assertTrue(store.loadLeftovers().isEmpty()); + AltarPedestals.sweep(); + } + + @Test + void leftoversSurviveARestartAndBadIdsAreDropped() throws Exception { + java.io.File root = new java.io.File(fx.data.toFile(), "left"); + BloodStore store = new BloodStore(root, fx.logger); + assertTrue(store.loadLeftovers().isEmpty()); + root.mkdirs(); + java.nio.file.Files.writeString(new java.io.File(root, "leftover_pedestals.json").toPath(), + "[\"not-a-uuid\", null, \"\", \"" + UUID.randomUUID() + "\"]"); + AltarPedestals.init(store); + assertEquals(2, AltarPedestals.leftovers().size()); + AltarPedestals.sweep(); + assertEquals(1, AltarPedestals.leftovers().size()); + assertEquals(1, store.loadLeftovers().size()); + when(plugin.getFurnitureManager()).thenReturn(null); + AltarPedestals.sweep(); + assertEquals(1, AltarPedestals.leftovers().size()); + java.nio.file.Files.writeString(new java.io.File(root, "leftover_pedestals.json").toPath(), "{broken"); + assertTrue(store.loadLeftovers().isEmpty()); + AltarPedestals.clear(); + BloodAltar altar = BloodAltar.create("a", "o", fx.pocket(0, 10, 0), 0f, 1L); + altar.getPedestals().add(UUID.randomUUID().toString()); + AltarPedestals.remove(altar); + assertEquals(1, AltarPedestals.leftovers().size()); + altar.getPedestals().add(AltarPedestals.leftovers().iterator().next()); + AltarPedestals.remove(altar); + assertEquals(1, AltarPedestals.leftovers().size()); + } + + + Furniture pedestal(BloodAltar altar, FurnitureType type, boolean loaded, + net.tfminecraft.interactiblefurniture.furniture.PlacedSlot active) { + Furniture furniture = mock(Furniture.class); + UUID id = loaded ? fx.world.spawn(new Location(fx.world, 0, 10, 0), ItemDisplay.class).getUniqueId() : UUID.randomUUID(); + when(furniture.getEntityId()).thenReturn(id); + when(furniture.getType()).thenReturn(type); + when(furniture.getLoc()).thenReturn(new Location(fx.world, 2, 10, 0)); + when(furniture.getActiveSlot("item")).thenReturn(java.util.Optional.ofNullable(active)); + when(furniture.getOrCreatePlacedSlot("item")) + .thenReturn(mock(net.tfminecraft.interactiblefurniture.furniture.PlacedSlot.class)); + placed.put(id, furniture); + altar.getPedestals().add(id.toString()); + return furniture; + } + + net.tfminecraft.interactiblefurniture.furniture.PlacedSlot showing(ItemStack item, UUID stand) { + var slot = mock(net.tfminecraft.interactiblefurniture.furniture.PlacedSlot.class); + when(slot.getCurrentItem()).thenReturn(item); + when(slot.getDisplayStandId()).thenReturn(stand); + return slot; + } + + @Test + void artefactsGoInPlaceOfTheOneTheyFollowOrOnTheFirstFreePedestal() { + BloodConfig.load(new java.io.File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + BloodRecord record = BloodRecords.getOrCreate(BloodFixture.id("mage"), "o"); + record.setStage(BloodStage.FINAL); + record.setPath(BloodPath.DEVOTEE); + ItemStack fresh = BloodArtefacts.create(record, "devotee:5"); + var nested = mock(net.tfminecraft.interactiblefurniture.furniture.SlotDefinition.class); + when(nested.isFurnitureSlot()).thenReturn(true); + var slot = mock(net.tfminecraft.interactiblefurniture.furniture.SlotDefinition.class); + when(slot.getId()).thenReturn("item"); + Map slots = new java.util.LinkedHashMap<>(); + slots.put("nested", nested); + slots.put("item", slot); + FurnitureType type = mock(FurnitureType.class); + when(type.getSlots()).thenReturn(slots); + FurnitureType nestedOnly = mock(FurnitureType.class); + when(nestedOnly.getSlots()).thenReturn(Map.of("nested", nested)); + BloodAltar altar = BloodAltar.create("a", "o", fx.pocket(0, 10, 0), 0f, 1L); + altar.getPedestals().add(UUID.randomUUID().toString()); + pedestal(altar, null, true, null); + pedestal(altar, nestedOnly, true, null); + pedestal(altar, type, false, null); + Furniture other = pedestal(altar, type, true, showing(BloodArtefacts.create(record, "devotee:0"), null)); + pedestal(altar, type, true, showing(null, null)); + pedestal(altar, type, true, showing(null, UUID.randomUUID())); + ItemDisplay stand = fx.world.spawn(new Location(fx.world, 0, 10, 0), ItemDisplay.class); + stand.setItemStack(BloodArtefacts.create(record, "devotee:2")); + Furniture standing = pedestal(altar, type, true, showing(null, stand.getUniqueId())); + Furniture free = pedestal(altar, type, true, null); + Furniture later = pedestal(altar, type, true, null); + + assertTrue(AltarPedestals.offer(altar, fresh, null)); + verify(free.getOrCreatePlacedSlot("item")).spawnDisplayStand(any(), eq(fresh), any(), isNull()); + verify(manager).persistFurniture(free); + verify(later, never()).getOrCreatePlacedSlot(any()); + assertTrue(AltarPedestals.offer(altar, fresh, "devotee:2")); + verify(standing).removeActiveSlot("item"); + verify(standing.getOrCreatePlacedSlot("item")).spawnDisplayStand(any(), eq(fresh), any(), isNull()); + assertTrue(AltarPedestals.offer(altar, fresh, "devotee:0")); + verify(other).removeActiveSlot("item"); + assertTrue(AltarPedestals.offer(altar, fresh, "devotee:7"), "Nothing to replace: the first free one"); + + BloodAltar full = BloodAltar.create("b", "o", fx.pocket(5, 10, 5), 0f, 1L); + pedestal(full, type, true, showing(fresh, null)); + assertFalse(AltarPedestals.offer(full, fresh, "devotee:1")); + assertFalse(AltarPedestals.offer(full, null, null)); + when(plugin.getFurnitureManager()).thenReturn(null); + assertFalse(AltarPedestals.offer(full, fresh, null)); + } + + @Test + void theAltarFindsTheArtefactsOnItsPedestalsAndRestylesThem() { + BloodConfig.load(new java.io.File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + BloodRecord record = BloodRecords.getOrCreate(BloodFixture.id("mage"), "o"); + record.setStage(BloodStage.FINAL); + record.setPath(BloodPath.DEVOTEE); + var slot = mock(net.tfminecraft.interactiblefurniture.furniture.SlotDefinition.class); + when(slot.getId()).thenReturn("item"); + FurnitureType type = mock(FurnitureType.class); + when(type.getSlots()).thenReturn(Map.of("item", slot)); + BloodAltar altar = BloodAltar.create("a", "o", fx.pocket(0, 10, 0), 0f, 1L); + altar.getPedestals().add(UUID.randomUUID().toString()); + pedestal(altar, null, true, null); + pedestal(altar, type, true, null); + pedestal(altar, type, true, showing(new ItemStack(Material.STONE), null)); + var held = showing(BloodArtefacts.create(record, "devotee:0"), null); + Furniture holding = pedestal(altar, type, true, held); + ItemDisplay stand = fx.world.spawn(new Location(fx.world, 0, 10, 0), ItemDisplay.class); + stand.setItemStack(BloodArtefacts.create(record, "devotee:1")); + var standing = showing(null, stand.getUniqueId()); + pedestal(altar, type, true, standing); + Furniture nowhere = pedestal(altar, type, true, showing(BloodArtefacts.create(record, "devotee:2"), null)); + when(nowhere.getLoc()).thenReturn(null); + + List found = AltarPedestals.artefacts(altar); + assertEquals(List.of("devotee:0", "devotee:1"), found.stream().map(each -> BloodArtefacts.id(each.item())).toList()); + assertEquals(11.8, found.get(0).point().getY(), 1e-9); + AltarPedestals.restyle(altar, item -> { + item.setAmount(2); + return item; + }); + verify(held).setCurrentItem(argThat(item -> item.getAmount() == 2)); + verify(standing).setCurrentItem(any()); + verify(manager).persistFurniture(holding); + when(plugin.getFurnitureManager()).thenReturn(null); + assertTrue(AltarPedestals.artefacts(altar).isEmpty()); + AltarPedestals.restyle(altar, item -> item); + } + + @Test + void withoutThePedestalTypeOrFurniturePluginNothingIsPlaced() { + Block center = fx.pocket(0, 10, 0); + BloodAltar altar = BloodAltar.create("a", "o", center, 0f, 1L); + when(plugin.getFurnitureManager()).thenReturn(null); + assertEquals(0, AltarPedestals.place(altar, center)); + altar.getPedestals().add(UUID.randomUUID().toString()); + AltarPedestals.remove(altar); + assertTrue(altar.getPedestals().isEmpty()); + bridge.when(InteractibleFurniture::getInstance).thenReturn(null); + assertEquals(0, AltarPedestals.place(altar, center)); + altar.getPedestals().add("x"); + AltarPedestals.remove(altar); + assertTrue(altar.getPedestals().isEmpty()); + fx.server.getPluginManager().disablePlugin(ifPlugin); + assertNull(AltarPedestals.manager()); + FurnitureLoader.getMap().remove("pedestal"); + assertEquals(0, AltarPedestals.place(altar, center)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/AltarPlacementTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarPlacementTest.java new file mode 100644 index 0000000..d4544b2 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/AltarPlacementTest.java @@ -0,0 +1,111 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.List; + +import com.sk89q.worldguard.WorldGuard; +import com.sk89q.worldguard.protection.ApplicableRegionSet; +import com.sk89q.worldguard.protection.regions.ProtectedRegion; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.block.BlockMock; + +class AltarPlacementTest { + @TempDir Path temp; + + @Test + void deepDarkPocketOnSolidFloorPasses() { + try (BloodFixture fx = new BloodFixture(temp)) { + Block block = fx.pocket(0, 10, 0); + assertEquals(AltarPlacement.Result.OK, AltarPlacement.check(block)); + assertEquals(48, AltarPlacement.cover(block)); + } + } + + @Test + void eachRuleRefusesInOrder() throws Exception { + try (BloodFixture fx = new BloodFixture(temp)) { + Block block = fx.pocket(0, 10, 0); + java.nio.file.Files.writeString(temp.resolve("w.yml"), "enabled: true\nplacement:\n worlds: [elsewhere]\n"); + BloodConfig.load(temp.resolve("w.yml").toFile(), fx.logger); + assertEquals(AltarPlacement.Result.WORLD, AltarPlacement.check(block)); + BloodConfig.reset(); + + block.setType(Material.STONE); + assertEquals(AltarPlacement.Result.OCCUPIED, AltarPlacement.check(block)); + block.setType(Material.SHORT_GRASS); + assertEquals(AltarPlacement.Result.OK, AltarPlacement.check(block)); + block.setType(Material.AIR); + + BloodAltars.add(BloodAltar.create("c", "o", block, 0f, 1L)); + assertEquals(AltarPlacement.Result.OCCUPIED, AltarPlacement.check(block)); + BloodAltars.remove(BloodAltars.at(block)); + + block.getRelative(0, -1, 0).setType(Material.AIR); + assertEquals(AltarPlacement.Result.NO_FLOOR, AltarPlacement.check(block)); + block.getRelative(0, -1, 0).setType(Material.STONE); + + ((BlockMock) block).setLightFromSky((byte) 4); + assertEquals(AltarPlacement.Result.SKY, AltarPlacement.check(block)); + ((BlockMock) block).setLightFromSky((byte) 0); + + Block high = fx.pocket(5, 300, 5); + assertEquals(AltarPlacement.Result.SHALLOW, AltarPlacement.check(high)); + java.nio.file.Files.writeString(temp.resolve("u.yml"), "enabled: true\nplacement:\n require_underground: false\n"); + BloodConfig.load(temp.resolve("u.yml").toFile(), fx.logger); + assertEquals(AltarPlacement.Result.OK, AltarPlacement.check(high)); + BloodConfig.reset(); + + AltarRegions.setQuery(location -> List.of("town")); + assertEquals(AltarPlacement.Result.REGION, AltarPlacement.check(block)); + } + } + + @Test + void regionsHonourAllowListAndDenySwitch() throws Exception { + try (BloodFixture fx = new BloodFixture(temp)) { + Block block = fx.pocket(0, 10, 0); + AltarRegions.setQuery(location -> List.of("wilds", "town")); + assertTrue(AltarRegions.blocked(block.getLocation())); + java.nio.file.Files.writeString(temp.resolve("r.yml"), + "enabled: true\nplacement:\n allowed_regions: [wilds, town]\n"); + BloodConfig.load(temp.resolve("r.yml").toFile(), fx.logger); + assertFalse(AltarRegions.blocked(block.getLocation())); + java.nio.file.Files.writeString(temp.resolve("d.yml"), "enabled: true\nplacement:\n deny_regions: false\n"); + BloodConfig.load(temp.resolve("d.yml").toFile(), fx.logger); + assertFalse(AltarRegions.blocked(block.getLocation())); + } + } + + @Test + void withoutWorldGuardNothingIsARegionAndWithItRegionsAreAsked() { + try (BloodFixture fx = new BloodFixture(temp)) { + Block block = fx.pocket(0, 10, 0); + AltarRegions.setQuery(null); + assertEquals(List.of(), AltarRegions.at(block.getLocation())); + MockBukkit.createMockPlugin("WorldGuard"); + var region = mock(ProtectedRegion.class); + when(region.getId()).thenReturn("spawn"); + var set = mock(ApplicableRegionSet.class); + when(set.iterator()).thenReturn(List.of(region).iterator()); + var worldGuard = mock(WorldGuard.class, RETURNS_DEEP_STUBS); + when(worldGuard.getPlatform().getRegionContainer().createQuery().getApplicableRegions(any())) + .thenReturn(set); + var original = WorldGuardAltarRegions.adapter; + WorldGuardAltarRegions.adapter = location -> null; + try (var wg = mockStatic(WorldGuard.class)) { + wg.when(WorldGuard::getInstance).thenReturn(worldGuard); + assertEquals(List.of("spawn"), AltarRegions.at(block.getLocation())); + } finally { + WorldGuardAltarRegions.adapter = original; + } + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/AltarRoomTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarRoomTest.java new file mode 100644 index 0000000..5318ef6 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/AltarRoomTest.java @@ -0,0 +1,136 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.block.Block; +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 org.mockbukkit.mockbukkit.entity.PlayerMock; + +class AltarRoomTest { + @TempDir Path temp; + BloodFixture fx; + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + } + + @AfterEach + void cleanup() { + fx.close(); + } + + void config(String yaml) throws Exception { + Files.writeString(temp.resolve("r.yml"), "enabled: true\n" + yaml); + assertTrue(BloodConfig.load(temp.resolve("r.yml").toFile(), fx.logger)); + } + + @Test + void aNearlyOpenRoomIsCarvedAndItsFloorHolesFilled() throws Exception { + config("placement:\n min_cover: 1\n cover_scan_height: 4\n"); + assertEquals(9, AltarRoom.size()); + fx.open(-4, 10, -4, 4, 11, 4); + fx.open(5, 10, 2, 5, 11, 2); + Block target = fx.pocket(0, 10, 0); + Block hole = fx.world.getBlockAt(3, 9, 3); + hole.setType(Material.AIR); + Block standing = fx.world.getBlockAt(0, 30, 0); + standing.setType(Material.DEEPSLATE); + + AltarRoom.Plan plan = AltarRoom.plan(target, standing); + assertEquals(AltarRoom.Verdict.OK, plan.verdict()); + assertEquals(81, plan.carve().size()); + assertEquals(java.util.Map.of(hole, Material.DEEPSLATE), plan.lay()); + assertTrue(plan.lights().isEmpty()); + plan.apply(); + assertEquals(Material.AIR, fx.world.getBlockAt(4, 12, -4).getType()); + assertEquals(Material.AIR, fx.world.getBlockAt(0, 12, 0).getType()); + assertEquals(Material.DEEPSLATE, hole.getType()); + assertEquals(Material.STONE, fx.world.getBlockAt(5, 11, 0).getType()); + } + + @Test + void tooMuchRockSomethingThatWillNotMoveOrTheSkyRefuseIt() throws Exception { + config("placement:\n min_cover: 2\n cover_scan_height: 4\n room:\n max_changes: 100\n"); + Block target = fx.pocket(0, 10, 0); + assertEquals(AltarRoom.Verdict.TOO_MUCH, AltarRoom.plan(target, null).verdict()); + + fx.open(-4, 10, -4, 4, 12, 4); + assertEquals(AltarRoom.Verdict.SEALED, AltarRoom.plan(target, null).verdict()); + // A hole in the corner or one block high is no way out. + fx.open(5, 10, 5, 5, 11, 5); + fx.open(5, 10, -2, 5, 10, -2); + assertEquals(AltarRoom.Verdict.SEALED, AltarRoom.plan(target, null).verdict()); + fx.open(5, 10, 2, 5, 11, 2); + assertEquals(AltarRoom.Verdict.OK, AltarRoom.plan(target, null).verdict()); + for (Material stubborn : List.of(Material.WATER, Material.CHEST, Material.BEDROCK)) { + fx.world.getBlockAt(2, 11, 2).setType(stubborn); + assertEquals(AltarRoom.Verdict.BLOCKED, AltarRoom.plan(target, null).verdict(), stubborn.name()); + } + fx.world.getBlockAt(2, 11, 2).setType(Material.AIR); + fx.world.getBlockAt(-3, 9, 1).setType(Material.LAVA); + assertEquals(AltarRoom.Verdict.BLOCKED, AltarRoom.plan(target, null).verdict()); + fx.world.getBlockAt(-3, 9, 1).setType(Material.STONE); + + Block other = fx.world.getBlockAt(1, 10, -2); + BloodAltar rival = BloodAltar.create("rival", "o", other, 0f, 1L); + BloodAltars.add(rival); + other.setType(Material.STONE); + assertEquals(AltarRoom.Verdict.BLOCKED, AltarRoom.plan(target, null).verdict()); + BloodAltars.remove(rival); + other.setType(Material.NETHERRACK); + BloodAltar elsewhere = BloodAltar.create("far", "o", fx.pocket(40, 10, 40), 0f, 1L); + elsewhere.getCorrupted().add(new BloodAltar.CorruptedBlock(1, 10, -2, "minecraft:stone", "NETHERRACK")); + BloodAltars.add(elsewhere); + assertEquals(AltarRoom.Verdict.BLOCKED, AltarRoom.plan(target, null).verdict()); + other.setType(Material.AIR); + + AltarRegions.setQuery(location -> location.getBlockX() == 4 ? List.of("claim") : List.of()); + assertEquals(AltarRoom.Verdict.BLOCKED, AltarRoom.plan(target, null).verdict()); + AltarRegions.setQuery(location -> List.of()); + + for (int y = 13; y <= 16; y++) { + fx.world.getBlockAt(-4, y, 3).setType(Material.AIR); + } + assertEquals(AltarRoom.Verdict.SHALLOW, AltarRoom.plan(target, null).verdict()); + config("placement:\n require_underground: false\n"); + assertEquals(AltarRoom.Verdict.OK, AltarRoom.plan(target, null).verdict()); + config("placement:\n room:\n enabled: false\n"); + AltarRoom.Plan off = AltarRoom.plan(fx.pocket(-30, 10, -30), null); + assertEquals(AltarRoom.Verdict.OK, off.verdict()); + assertTrue(off.carve().isEmpty()); + } + + @Test + void theFloorIsThePlayersStoneElseTheAltarsElseStone() { + Block glass = fx.world.getBlockAt(0, 40, 0); + glass.setType(Material.GLASS); + Block barrier = fx.world.getBlockAt(1, 40, 0); + barrier.setType(Material.BARRIER); + Block tuff = fx.world.getBlockAt(2, 40, 0); + tuff.setType(Material.TUFF); + assertEquals(Material.TUFF, AltarRoom.floorOf(null, glass, barrier, tuff)); + assertEquals(Material.STONE, AltarRoom.floorOf(glass, barrier)); + int top = fx.world.getMaxHeight() - 2; + assertEquals(fx.world.getBlockAt(0, top + 1, 0).getType().isOccluding() ? 1 : 0, + AltarRoom.cover(fx.world, 0, top, 0)); + } + + @Test + void placingTheAltarShapesTheRoomOrSaysWhy() { + PlayerMock player = fx.player("mage"); + fx.record("mage", BloodStage.ANIMAL); + Block cramped = fx.pocket(0, 10, 0); + assertEquals("blood.altar.place.room_too_much", + AltarService.place(player, BloodItems.create(BloodItemKind.ALTAR, BloodFixture.id("mage")), cramped)); + assertNull(BloodAltars.at(cramped)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java new file mode 100644 index 0000000..616d169 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java @@ -0,0 +1,270 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class AltarServiceTest { + @TempDir Path temp; + + ItemStack altarItem(BloodFixture fx, String label) { + return BloodItems.create(BloodItemKind.ALTAR, BloodFixture.id(label)); + } + + @Test + void itemsCarryKindAndOwner() { + try (BloodFixture fx = new BloodFixture(temp)) { + ItemStack knife = BloodItems.create(BloodItemKind.RITUAL_KNIFE, "c1"); + assertEquals(BloodItemKind.RITUAL_KNIFE, BloodItems.kind(knife)); + assertEquals("c1", BloodItems.owner(knife)); + assertTrue(knife.getItemMeta().isUnbreakable()); + assertTrue(knife.getItemMeta().hasItemFlag(org.bukkit.inventory.ItemFlag.HIDE_UNBREAKABLE)); + assertTrue(BloodItems.is(knife, BloodItemKind.RITUAL_KNIFE)); + assertFalse(BloodItems.is(knife, null)); + assertFalse(BloodItems.is(knife, BloodItemKind.ALTAR)); + ItemStack altar = altarItem(fx, "a"); + assertFalse(altar.getItemMeta().isUnbreakable()); + assertNull(BloodItems.kind(null)); + assertNull(BloodItems.kind(new ItemStack(Material.AIR))); + assertNull(BloodItems.kind(new ItemStack(Material.STONE))); + ItemStack bare = mock(ItemStack.class); + when(bare.getType()).thenReturn(Material.STONE); + assertNull(BloodItems.owner(bare)); + ItemStack plain = new ItemStack(Material.STONE); + var meta = plain.getItemMeta(); + meta.setDisplayName("x"); + plain.setItemMeta(meta); + assertNull(BloodItems.owner(plain)); + ItemStack unknown = new ItemStack(Material.STONE); + meta = unknown.getItemMeta(); + meta.getPersistentDataContainer().set(BloodItems.kindKey(), PersistentDataType.STRING, "other"); + unknown.setItemMeta(meta); + assertNull(BloodItems.kind(unknown)); + + BloodConfig.load(new java.io.File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + ItemStack named = BloodItems.create(BloodItemKind.ALTAR, "c1"); + assertTrue(named.getItemMeta().hasDisplayName()); + assertEquals(3, named.getItemMeta().getLore().size()); + } + } + + @Test + void placingNeedsTheOwnerOnTheRightStageWithNoOtherAltar() { + try (BloodFixture fx = new BloodFixture(temp)) { + PlayerMock player = fx.player("mage"); + Block target = fx.room(0, 10, 0); + ItemStack item = altarItem(fx, "mage"); + BloodConfig.setEnabled(false); + assertEquals("blood.altar.inert", AltarService.place(player, item, target)); + BloodConfig.setEnabled(true); + assertEquals("blood.altar.not_yours", AltarService.place(player, altarItem(fx, "other"), target)); + fx.characters.end(player, net.tfminecraft.tlibs.character.CharacterSessionEndEvent.Reason.QUIT); + assertNull(AltarService.currentCharacter(player)); + assertEquals("blood.altar.not_yours", AltarService.place(player, item, target)); + fx.characters.start(player, "mage"); + assertEquals("blood.altar.inert", AltarService.place(player, item, target)); + fx.record("mage", BloodStage.WHISPERED); + assertEquals("blood.altar.inert", AltarService.place(player, item, target)); + fx.record("mage", BloodStage.ANIMAL); + target.getRelative(0, -1, 0).setType(Material.AIR); + assertEquals("blood.altar.place.no_floor", AltarService.place(player, item, target)); + target.getRelative(0, -1, 0).setType(Material.STONE); + + item.setAmount(2); + assertEquals("blood.altar.placed", AltarService.place(player, item, target)); + assertEquals(1, item.getAmount()); + assertEquals(Material.BARRIER, target.getType()); + BloodAltar altar = BloodAltars.at(target); + assertNotNull(altar); + assertEquals(altar.getId(), BloodRecords.get(BloodFixture.id("mage")).getAltarId()); + assertInstanceOf(ItemDisplay.class, Bukkit.getEntity(altar.getDisplayId())); + assertEquals("blood.altar.already", AltarService.place(player, item, fx.pocket(3, 10, 3))); + + AltarService.touch(player, altar); + assertEquals("blood.altar.touch.animal", player.nextMessage()); + PlayerMock other = fx.player("other"); + AltarService.touch(other, altar); + assertEquals("blood.altar.stranger_touch", other.nextMessage()); + PlayerMock none = fx.server.addPlayer(); + AltarService.touch(none, altar); + assertEquals("blood.altar.stranger_touch", none.nextMessage()); + BloodRecord record = BloodRecords.get(BloodFixture.id("mage")); + for (var stage : java.util.Map.of(BloodStage.ANIMAL, "animal", BloodStage.CHOICE, "choice", BloodStage.HUNT, "hunt", + BloodStage.FINAL, "final_true", BloodStage.ASCENDED, "ascended_true").entrySet()) { + record.setStage(stage.getKey()); + record.setPath(BloodPath.TRUE); + assertEquals(stage.getValue(), AltarService.stageKey(record)); + } + record.setStage(BloodStage.ASCENDED); + record.setPath(BloodPath.DEVOTEE); + assertEquals("ascended_devotee", AltarService.stageKey(record)); + record.setStage(BloodStage.WHISPERED); + assertEquals("animal", AltarService.stageKey(record)); + assertEquals("animal", AltarService.stageKey(null)); + } + } + + @Test + void displaysFollowLevelRestoreBlocksAndDropStrays() throws Exception { + try (BloodFixture fx = new BloodFixture(temp)) { + java.nio.file.Files.writeString(temp.resolve("l.yml"), + "enabled: true\naltar:\n models:\n 0: v.stone\n 1: v.dirt\n levels:\n 1: 1\n"); + BloodConfig.load(temp.resolve("l.yml").toFile(), fx.logger); + Block target = fx.pocket(0, 10, 0); + BloodRecord record = fx.record("mage", BloodStage.ANIMAL); + BloodAltar altar = BloodAltar.create(BloodFixture.id("mage"), "o", target, 0f, 1L); + BloodAltars.add(altar); + assertTrue(AltarDisplays.ensure(altar)); + ItemDisplay display = (ItemDisplay) Bukkit.getEntity(altar.getDisplayId()); + assertEquals(altar.getId(), display.getPersistentDataContainer().get(AltarDisplays.key(), PersistentDataType.STRING)); + + record.addSpecies("cow"); + AltarService.refresh(altar); + assertEquals(1, altar.getLevel()); + AltarService.refresh(altar); + AltarService.refreshFor(BloodFixture.id("mage")); + AltarService.refreshFor("nobody"); + + target.setType(Material.AIR); + assertFalse(AltarDisplays.ensure(altar)); + assertEquals(Material.BARRIER, target.getType()); + + display.remove(); + assertTrue(AltarDisplays.ensure(altar)); + UUID second = altar.getDisplayId(); + assertNotEquals(display.getUniqueId(), second); + PlayerMock squatter = fx.server.addPlayer(); + altar.setDisplayId(squatter.getUniqueId()); + assertTrue(AltarDisplays.ensure(altar)); + Bukkit.getEntity(altar.getDisplayId()).remove(); + altar.setDisplayId(second); + + ItemDisplay stray = fx.world.spawn(target.getLocation(), ItemDisplay.class); + stray.getPersistentDataContainer().set(AltarDisplays.key(), PersistentDataType.STRING, altar.getId()); + ItemDisplay orphan = fx.world.spawn(target.getLocation(), ItemDisplay.class); + orphan.getPersistentDataContainer().set(AltarDisplays.key(), PersistentDataType.STRING, "gone"); + ItemDisplay untagged = fx.world.spawn(target.getLocation(), ItemDisplay.class); + AltarDisplays.removeStrays(List.of(stray, orphan, untagged, Bukkit.getEntity(second))); + assertFalse(stray.isValid()); + assertFalse(orphan.isValid()); + assertTrue(untagged.isValid()); + assertTrue(Bukkit.getEntity(second).isValid()); + assertEquals(Material.BARRIER, target.getType(), "The live altar under an orphan keeps its block"); + Block bare = fx.pocket(4, 10, 0); + ItemDisplay lone = fx.world.spawn(bare.getLocation(), ItemDisplay.class); + lone.getPersistentDataContainer().set(AltarDisplays.key(), PersistentDataType.STRING, "gone"); + Block glowless = fx.pocket(4, 10, 3); + glowless.setType(Material.BARRIER); + ItemDisplay dim = fx.world.spawn(glowless.getLocation(), ItemDisplay.class); + dim.getPersistentDataContainer().set(AltarDisplays.key(), PersistentDataType.STRING, "gone"); + AltarDisplays.removeStrays(List.of(lone, dim)); + assertEquals(Material.AIR, bare.getType(), "Nothing to clear under it"); + assertEquals(Material.AIR, glowless.getType()); + + BloodAltar elsewhere = BloodAltar.create("x", "o", target, 0f, 1L); + var world = BloodAltar.class.getDeclaredField("world"); + world.setAccessible(true); + world.set(elsewhere, "gone"); + assertFalse(AltarDisplays.ensure(elsewhere)); + AltarDisplays.remove(elsewhere); + BloodAltar far = BloodAltar.create("x", "o", fx.world.getBlockAt(100000, 10, 100000), 0f, 1L); + fx.world.unloadChunk(100000 >> 4, 100000 >> 4); + assertFalse(AltarDisplays.ensure(far)); + + AltarDisplays.remove(altar); + assertEquals(Material.AIR, target.getType()); + AltarDisplays.remove(altar); + altar.setDisplayId(null); + target.setType(Material.STONE); + AltarDisplays.remove(altar); + assertEquals(Material.STONE, target.getType()); + } + } + + @Test + void purifyAndDeathClearAltarAndPath() { + try (BloodFixture fx = new BloodFixture(temp)) { + PlayerMock player = fx.player("mage"); + fx.record("mage", BloodStage.ANIMAL); + Block target = fx.room(0, 10, 0); + AltarService.place(player, altarItem(fx, "mage"), target); + BloodAltar altar = BloodAltars.at(target); + AltarService.purify(altar, "test"); + assertNull(BloodAltars.at(target)); + assertNull(BloodRecords.get(BloodFixture.id("mage")).getAltarId()); + assertEquals(Material.AIR, target.getType()); + + BloodAltar loose = BloodAltar.create("nobody", "o", fx.pocket(4, 10, 4), 0f, 1L); + BloodAltars.add(loose); + AltarService.purify(loose, "no record"); + BloodRecord other = fx.record("other", BloodStage.ANIMAL); + other.setAltarId("different"); + BloodAltar mismatched = BloodAltar.create(BloodFixture.id("other"), "o", fx.pocket(6, 10, 6), 0f, 1L); + BloodAltars.add(mismatched); + AltarService.purify(mismatched, "mismatch"); + assertEquals("different", other.getAltarId()); + + AltarService.place(player, altarItem(fx, "mage"), target); + AltarService.onCharacterDeath(BloodFixture.id("mage")); + assertNull(BloodAltars.ofCharacter(BloodFixture.id("mage"))); + assertNull(BloodRecords.get(BloodFixture.id("mage"))); + AltarService.onCharacterDeath(BloodFixture.id("mage")); + } + } + + @Test + void sweepFindsDeadOwnersOnly() { + try (BloodFixture fx = new BloodFixture(temp)) { + String dead = UUID.randomUUID().toString(); + String alive = UUID.randomUUID().toString(); + String recordOnly = UUID.randomUUID().toString(); + BloodAltars.add(BloodAltar.create(dead, "o", fx.pocket(0, 10, 0), 0f, 1L)); + BloodAltars.add(BloodAltar.create(alive, "o", fx.pocket(2, 10, 2), 0f, 1L)); + BloodAltars.add(BloodAltar.create("not-a-uuid", "o", fx.pocket(4, 10, 4), 0f, 1L)); + BloodRecords.getOrCreate(recordOnly, "o").setStage(BloodStage.WHISPERED); + BloodRecords.getOrCreate(alive, "o").setStage(BloodStage.ANIMAL); + try (var chars = mockStatic(Characters.class)) { + chars.when(Characters::available).thenReturn(false); + AltarService.sweepDead(); + assertEquals(3, BloodAltars.all().size()); + chars.when(Characters::available).thenReturn(true); + chars.when(() -> Characters.info(any())).thenReturn(Optional.empty()); + chars.when(() -> Characters.info(UUID.fromString(dead))).thenReturn(info(dead, CharacterStatus.DEAD)); + chars.when(() -> Characters.info(UUID.fromString(recordOnly))).thenReturn(info(recordOnly, CharacterStatus.DEAD)); + chars.when(() -> Characters.info(UUID.fromString(alive))).thenReturn(info(alive, CharacterStatus.ALIVE)); + AltarService.sweepDead(); + } + assertNull(BloodAltars.ofCharacter(dead)); + assertNotNull(BloodAltars.ofCharacter(alive)); + assertNotNull(BloodAltars.ofCharacter("not-a-uuid")); + assertNull(BloodRecords.get(recordOnly)); + assertNotNull(BloodRecords.get(alive)); + } + } + + static Optional info(String id, CharacterStatus status) { + return Optional.of(new CharacterInfo(new CharacterRef(UUID.randomUUID(), UUID.fromString(id)), "N", status, + null, Set.of(), 0, Instant.EPOCH)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java new file mode 100644 index 0000000..c9eb093 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java @@ -0,0 +1,516 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.damage.DamageSource; +import org.bukkit.damage.DamageType; +import org.bukkit.entity.Cow; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Pig; +import org.bukkit.entity.Sheep; +import org.bukkit.entity.Zombie; +import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.inventory.ItemStack; +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 org.mockbukkit.mockbukkit.entity.PlayerMock; + +class AnimalRiteTest { + @TempDir Path temp; + BloodFixture fx; + final AtomicLong now = new AtomicLong(10_000_000_000L); + PlayerMock mage; + String id; + BloodRecord record; + BloodAltar altar; + Block target; + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + AnimalRiteService.clock = now::get; + StolenClues.clock = now::get; + FakeHusbandry.use(); + mage = fx.player("mage"); + id = BloodFixture.id("mage"); + record = fx.record("mage", BloodStage.ANIMAL); + target = fx.room(0, 10, 0); + AltarService.place(mage, BloodItems.create(BloodItemKind.ALTAR, id), target); + altar = BloodAltars.ofCharacter(id); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, id)); + } + + @AfterEach + void cleanup() { + AnimalRiteService.releaseAll(); + StolenClues.clear(); + BloodNotes.clear(); + FakeHusbandry.restore(); + AnimalRiteService.clock = System::currentTimeMillis; + StolenClues.clock = System::currentTimeMillis; + fx.close(); + } + + T animal(Class type, UUID ownerAccount) { + T animal = fx.world.spawn(new Location(fx.world, 1.5, 10, 0.5), type); + if (ownerAccount != null) { + FakeHusbandry.OWNERS.put(animal.getUniqueId(), + List.of(new FakeHusbandry.Owner(ownerAccount, UUID.randomUUID()))); + } + return animal; + } + + org.bukkit.event.entity.EntityDamageByEntityEvent hit(org.bukkit.entity.Entity damager, LivingEntity animal) { + return new org.bukkit.event.entity.EntityDamageByEntityEvent(damager, animal, + org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, DamageSource.builder(DamageType.PLAYER_ATTACK).build(), 3); + } + + /** The mage's knife makes the cut. */ + void cut(LivingEntity animal) { + var stroke = hit(mage, animal); + AnimalRiteService.onHit(stroke); + assertFalse(stroke.isCancelled()); + assertEquals("blood.animal.cut", mage.nextMessage()); + } + + /** The animal bleeds out with its mage beside the altar; the halfway line is read here. */ + void bleedOut() { + for (int second = 0; second <= AnimalConfig.bleedSeconds(); second++) { + AnimalRiteService.tick(); + if (second * 2 == AnimalConfig.bleedSeconds() + 1 || second * 2 == AnimalConfig.bleedSeconds()) { + assertEquals("blood.animal.draining", mage.nextMessage()); + } + now.addAndGet(1000L); + } + } + + EntityDeathEvent death(LivingEntity animal, PlayerMock killer) { + if (killer != null) { + ((org.mockbukkit.mockbukkit.entity.LivingEntityMock) animal).setKiller(killer); + } + List drops = new ArrayList<>(List.of(new ItemStack(Material.BEEF))); + return new EntityDeathEvent(animal, DamageSource.builder(DamageType.GENERIC).build(), drops, 5); + } + + @Test + void animalsAreTaggedSoTheyAlwaysWakeAndCannotBeTakenTwice() throws Exception { + UUID keeper = UUID.randomUUID(); + Cow cow = animal(Cow.class, keeper); + assertEquals(AnimalRiteService.Bind.OK, AnimalRiteService.bind(mage, cow)); + assertTrue(cow.getPersistentDataContainer().has(AnimalRiteService.boundKey())); + PlayerMock rival = fx.player("rival"); + String rivalId = BloodFixture.id("rival"); + fx.record("rival", BloodStage.ANIMAL); + BloodAltars.add(BloodAltar.create(rivalId, rival.getUniqueId().toString(), fx.pocket(2, 10, 0), 0f, 1L)); + rival.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, rivalId)); + assertEquals(AnimalRiteService.Bind.BUSY, AnimalRiteService.bind(rival, cow)); + + AnimalRiteService.wakeLoaded(List.of(cow, fx.world.spawn(new Location(fx.world, 1, 10, 1), + org.bukkit.entity.ItemDisplay.class), animal(Cow.class, null))); + assertFalse(cow.hasAI()); + AnimalRiteService.releaseAll(); + assertTrue(cow.hasAI()); + assertFalse(cow.getPersistentDataContainer().has(AnimalRiteService.boundKey())); + + Cow stale = animal(Cow.class, keeper); + stale.setAI(false); + stale.getPersistentDataContainer().set(AnimalRiteService.boundKey(), + org.bukkit.persistence.PersistentDataType.LONG, now.get() + 5000); + AnimalRiteService.wakeLoaded(List.of(stale)); + assertTrue(stale.hasAI()); + + Sheep sheep = animal(Sheep.class, keeper); + assertEquals(AnimalRiteService.Bind.OK, AnimalRiteService.bind(mage, sheep)); + now.addAndGet(21_000L); + AnimalRiteService.wakeLoaded(List.of(sheep)); + assertTrue(sheep.hasAI()); + assertFalse(AnimalRiteService.isBound(sheep.getUniqueId())); + + Pig pig = spy(animal(Pig.class, keeper)); + doReturn(false).when(pig).teleport(any(Location.class)); + assertEquals(AnimalRiteService.Bind.TOO_FAR, AnimalRiteService.bind(mage, pig)); + assertTrue(pig.hasAI()); + + java.nio.file.Files.writeString(temp.resolve("off.yml"), "enabled: false\n"); + BloodConfig.load(temp.resolve("off.yml").toFile(), fx.logger); + assertEquals(AnimalRiteService.Bind.NOT_STAGE, AnimalRiteService.bind(mage, animal(Cow.class, keeper))); + } + + @Test + void bindingChecksStageKnifeAltarSpeciesCooldownOwnerAndRange() { + UUID stranger = UUID.randomUUID(); + Cow cow = animal(Cow.class, stranger); + record.setStage(BloodStage.CHOICE); + assertEquals(AnimalRiteService.Bind.NOT_STAGE, AnimalRiteService.bind(mage, cow)); + record.setStage(BloodStage.ANIMAL); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, "someone")); + assertEquals(AnimalRiteService.Bind.NOT_KNIFE, AnimalRiteService.bind(mage, cow)); + mage.getInventory().setItemInMainHand(new ItemStack(Material.IRON_SWORD)); + assertEquals(AnimalRiteService.Bind.NOT_KNIFE, AnimalRiteService.bind(mage, cow)); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, id)); + BloodAltars.remove(altar); + assertEquals(AnimalRiteService.Bind.NO_ALTAR, AnimalRiteService.bind(mage, cow)); + BloodAltars.add(altar); + assertEquals(AnimalRiteService.Bind.NOT_SPECIES, AnimalRiteService.bind(mage, animal(Zombie.class, stranger))); + record.addSpecies("cow"); + assertEquals(AnimalRiteService.Bind.KNOWN, AnimalRiteService.bind(mage, cow)); + record.getSpecies().clear(); + record.setLastAnimalAt(now.get() - 3_600_000L); + assertEquals(AnimalRiteService.Bind.COOLDOWN, AnimalRiteService.bind(mage, cow)); + record.setLastAnimalAt(now.get() - 25 * 3_600_000L); + assertEquals(AnimalRiteService.Bind.NOT_STOLEN, AnimalRiteService.bind(mage, animal(Pig.class, null))); + assertEquals(AnimalRiteService.Bind.NOT_STOLEN, AnimalRiteService.bind(mage, animal(Pig.class, mage.getUniqueId()))); + Cow far = animal(Cow.class, stranger); + far.teleport(new Location(fx.world, 30, 10, 30)); + assertEquals(AnimalRiteService.Bind.TOO_FAR, AnimalRiteService.bind(mage, far)); + var otherWorld = fx.server.addSimpleWorld("elsewhere"); + far.teleport(new Location(otherWorld, 0, 64, 0)); + assertEquals(AnimalRiteService.Bind.TOO_FAR, AnimalRiteService.bind(mage, far)); + + assertEquals(AnimalRiteService.Bind.OK, AnimalRiteService.bind(mage, cow)); + assertFalse(cow.hasAI()); + assertTrue(AnimalRiteService.isBound(cow.getUniqueId())); + assertEquals(11.0, cow.getLocation().getY(), 0.01); + assertEquals(AnimalRiteService.Bind.BUSY, AnimalRiteService.bind(mage, animal(Pig.class, stranger))); + } + + @Test + void wildAnimalsCountWhenTheConfigDoesNotRequireTheft() throws Exception { + Files.writeString(temp.resolve("w.yml"), "enabled: true\nanimal:\n require_stolen: false\n species: [PIG]\n"); + BloodConfig.load(temp.resolve("w.yml").toFile(), fx.logger); + Pig pig = animal(Pig.class, null); + assertEquals(AnimalRiteService.Bind.OK, AnimalRiteService.bind(mage, pig)); + cut(pig); + bleedOut(); + assertTrue(record.hasSpecies("pig")); + assertTrue(BloodNotes.all().isEmpty()); + } + + @Test + void onlyTheOwnKnifeCutsAndNothingElseTouchesIt() { + Cow cow = animal(Cow.class, null); + PlayerMock other = fx.player("other"); + org.bukkit.entity.Zombie zombie = fx.world.spawn(new Location(fx.world, 1, 10, 1), org.bukkit.entity.Zombie.class); + var loose = hit(mage, cow); + AnimalRiteService.onHit(loose); + assertEquals(3, loose.getDamage()); + assertFalse(loose.isCancelled()); + + record.setLastAnimalAt(0); + FakeHusbandry.OWNERS.put(cow.getUniqueId(), List.of(new FakeHusbandry.Owner(UUID.randomUUID(), UUID.randomUUID()))); + assertEquals(AnimalRiteService.Bind.OK, AnimalRiteService.bind(mage, cow)); + for (org.bukkit.entity.Entity damager : List.of(other, zombie)) { + var foreign = hit(damager, cow); + AnimalRiteService.onHit(foreign); + assertTrue(foreign.isCancelled()); + } + mage.getInventory().setItemInMainHand(new ItemStack(Material.IRON_SWORD)); + var plain = hit(mage, cow); + AnimalRiteService.onHit(plain); + assertTrue(plain.isCancelled()); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, BloodFixture.id("other"))); + var borrowed = hit(mage, cow); + AnimalRiteService.onHit(borrowed); + assertTrue(borrowed.isCancelled()); + + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, id)); + var stroke = hit(mage, cow); + new BloodListener().onAnimalHit(stroke); + assertFalse(stroke.isCancelled()); + assertTrue(stroke.getDamage() < 1); + assertEquals("blood.animal.cut", mage.nextMessage()); + assertTrue(AnimalRiteService.isBound(cow.getUniqueId())); + var again = hit(mage, cow); + AnimalRiteService.onHit(again); + assertTrue(again.isCancelled()); + + var onPlayer = hit(mage, other); + AnimalRiteService.onHit(onPlayer); + assertEquals(3, onPlayer.getDamage()); + } + + @Test + void aLedAnimalLosesItsLeadToTheMage() { + // MockBukkit cannot leash; the cow says it is led. + Cow cow = spy(animal(Cow.class, UUID.randomUUID())); + doReturn(true).when(cow).isLeashed(); + assertFalse(mage.getInventory().contains(Material.LEAD)); + assertEquals(AnimalRiteService.Bind.OK, AnimalRiteService.bind(mage, cow)); + org.mockito.Mockito.verify(cow).setLeashHolder(null); + assertTrue(mage.getInventory().contains(Material.LEAD)); + } + + @Test + void theRiteMusicIsConfigurable() throws Exception { + assertEquals("tfmc_blood:rite.bleed", AnimalConfig.music()); + assertEquals(org.bukkit.SoundCategory.RECORDS, AnimalConfig.musicCategory()); + assertEquals(0.8f, AnimalConfig.musicVolume()); + Files.writeString(temp.resolve("m.yml"), String.join("\n", "enabled: true", "animal:", " rite:", " music:", + " sound: ' '", " category: nope", " volume: -2", "")); + BloodConfig.load(temp.resolve("m.yml").toFile(), fx.logger); + assertEquals("", AnimalConfig.music()); + assertEquals(org.bukkit.SoundCategory.RECORDS, AnimalConfig.musicCategory()); + assertEquals(0f, AnimalConfig.musicVolume()); + Files.writeString(temp.resolve("m2.yml"), String.join("\n", "enabled: true", "animal:", " rite:", " music:", + " sound: x:y", " category: ambient", "")); + BloodConfig.load(temp.resolve("m2.yml").toFile(), fx.logger); + assertEquals(org.bukkit.SoundCategory.AMBIENT, AnimalConfig.musicCategory()); + // Without music nothing is played or stopped. + Files.writeString(temp.resolve("m3.yml"), "enabled: true\nanimal:\n require_stolen: false\n species: [COW]\n"); + BloodConfig.load(temp.resolve("m3.yml").toFile(), fx.logger); + Cow cow = animal(Cow.class, null); + AnimalRiteService.bind(mage, cow); + cut(cow); + bleedOut(); + assertTrue(record.hasSpecies("cow")); + } + + @Test + void steppingAwaySwitchingOrLeavingSpillsIt() { + UUID keeper = UUID.randomUUID(); + Cow cow = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, cow); + cut(cow); + AnimalRiteService.tick(); + mage.teleport(new Location(fx.world, 9.5, 10, 0.5)); + AnimalRiteService.tick(); + assertEquals("blood.animal.left", mage.nextMessage()); + assertFalse(AnimalRiteService.isBound(cow.getUniqueId())); + assertTrue(cow.getPersistentDataContainer().has(AnimalRiteService.drainedKey())); + assertTrue(record.getSpecies().isEmpty()); + + mage.teleport(new Location(fx.world, 0.5, 11, 0.5)); + Cow second = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, second); + cut(second); + fx.characters.start(mage, "switched"); + AnimalRiteService.tick(); + assertEquals("blood.animal.left", mage.nextMessage()); + fx.characters.start(mage, "mage"); + + Cow third = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, third); + cut(third); + var otherWorld = fx.server.addSimpleWorld("elsewhere"); + mage.teleport(new Location(otherWorld, 0, 64, 0)); + AnimalRiteService.tick(); + assertEquals("blood.animal.left", mage.nextMessage()); + mage.teleport(new Location(fx.world, 0.5, 11, 0.5)); + + Cow fourth = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, fourth); + cut(fourth); + AnimalRiteService.releaseFor(mage.getUniqueId()); + assertTrue(fourth.getPersistentDataContainer().has(AnimalRiteService.drainedKey())); + + Cow fifth = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, fifth); + cut(fifth); + fifth.remove(); + AnimalRiteService.tick(); + assertFalse(AnimalRiteService.isBound(fifth.getUniqueId())); + + Cow sixth = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, sixth); + cut(sixth); + mage.disconnect(); + AnimalRiteService.tick(); + assertFalse(AnimalRiteService.isBound(sixth.getUniqueId())); + assertTrue(record.getSpecies().isEmpty()); + } + + @Test + void theOwnKnifeKillCountsLeavesCluesAndCorrupts() { + UUID keeper = UUID.randomUUID(); + Cow cow = animal(Cow.class, keeper); + StolenClues.leashed(cow); + cow.teleport(new Location(fx.world, 1.5, 10, 0.5)); + assertEquals(AnimalRiteService.Bind.OK, AnimalRiteService.bind(mage, cow)); + cut(cow); + AnimalRiteService.tick(); + assertFalse(record.hasSpecies("cow")); + now.addAndGet(8000L); + AnimalRiteService.tick(); + assertEquals("blood.animal.draining", mage.nextMessage()); + AnimalRiteService.tick(); + assertNull(mage.nextMessage()); + now.addAndGet(AnimalConfig.bleedSeconds() * 1000L - 8000L); + AnimalRiteService.tick(); + assertTrue(record.hasSpecies("cow")); + assertEquals(now.get(), record.getLastAnimalAt()); + assertEquals("blood.animal.accepted", mage.nextMessage()); + assertTrue(cow.getPersistentDataContainer().has(AnimalRiteService.drainedKey())); + EntityDeathEvent event = death(cow, null); + AnimalRiteService.onDeath(event); + assertTrue(event.getDrops().isEmpty()); + assertEquals(0, event.getDroppedExp()); + assertEquals(1, BloodNotes.all().size()); + assertEquals(keeper.toString(), BloodNotes.all().get(0).account()); + assertFalse(AnimalRiteService.isBound(cow.getUniqueId())); + AnimalRiteService.onDeath(death(cow, mage)); + } + + @Test + void theSeventhKindFinishesTheStageAndGivesTheBloodKnife() throws Exception { + Files.writeString(temp.resolve("one.yml"), "enabled: true\nanimal:\n species_required: 1\n clear_drops: false\n species: [COW]\n"); + BloodConfig.load(temp.resolve("one.yml").toFile(), fx.logger); + Cow cow = animal(Cow.class, UUID.randomUUID()); + AnimalRiteService.bind(mage, cow); + cut(cow); + bleedOut(); + EntityDeathEvent event = death(cow, null); + AnimalRiteService.onDeath(event); + assertFalse(event.getDrops().isEmpty()); + assertEquals(BloodStage.CHOICE, record.getStage()); + assertTrue(mage.getInventory().containsAtLeast(BloodItems.create(BloodItemKind.BLOOD_KNIFE, id), 1)); + assertEquals("blood.animal.complete", mage.nextMessage()); + assertNotNull(WhisperService.nextVoice(mage.getUniqueId())); + WhisperService.clear(); + } + + @Test + void anyOtherDeathSpoilsTheRite() { + UUID keeper = UUID.randomUUID(); + Cow cow = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, cow); + AnimalRiteService.onDeath(death(cow, null)); + assertEquals("blood.animal.spoiled", mage.nextMessage()); + assertFalse(record.hasSpecies("cow")); + + PlayerMock other = fx.player("other"); + cow = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, cow); + AnimalRiteService.onDeath(death(cow, other)); + assertEquals("blood.animal.spoiled", mage.nextMessage()); + + cow = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, cow); + mage.getInventory().setItemInMainHand(new ItemStack(Material.STICK)); + AnimalRiteService.onDeath(death(cow, mage)); + assertEquals("blood.animal.spoiled", mage.nextMessage()); + + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, id)); + cow = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, cow); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, "x")); + AnimalRiteService.onDeath(death(cow, mage)); + assertEquals("blood.animal.spoiled", mage.nextMessage()); + + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, id)); + cow = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, cow); + fx.characters.start(mage, "switched"); + AnimalRiteService.onDeath(death(cow, mage)); + assertEquals("blood.animal.spoiled", mage.nextMessage()); + fx.characters.start(mage, "mage"); + + cow = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, cow); + mage.disconnect(); + AnimalRiteService.onDeath(death(cow, null)); + assertTrue(record.getSpecies().isEmpty()); + } + + @Test + void completionNeedsTheRecordAndAltarStill() { + UUID keeper = UUID.randomUUID(); + Cow cow = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, cow); + cut(cow); + record.setStage(BloodStage.CHOICE); + bleedOut(); + assertTrue(record.getSpecies().isEmpty()); + record.setStage(BloodStage.ANIMAL); + cow = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, cow); + cut(cow); + BloodAltars.remove(altar); + AnimalRiteService.tick(); + assertEquals("blood.animal.left", mage.nextMessage()); + assertTrue(record.getSpecies().isEmpty()); + BloodAltars.add(altar); + cow = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, cow); + cut(cow); + BloodRecords.delete(id); + bleedOut(); + } + + @Test + void boundAnimalsTwitchBreakFreeAndAreReleased() { + UUID keeper = UUID.randomUUID(); + Cow cow = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, cow); + AnimalRiteService.tick(); + assertTrue(AnimalRiteService.isBound(cow.getUniqueId())); + now.addAndGet(21_000L); + AnimalRiteService.tick(); + assertFalse(AnimalRiteService.isBound(cow.getUniqueId())); + assertTrue(cow.hasAI()); + assertEquals("blood.animal.escaped", mage.nextMessage()); + + record.getSpecies().clear(); + Cow second = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, second); + mage.disconnect(); + now.addAndGet(21_000L); + AnimalRiteService.tick(); + assertTrue(second.hasAI()); + + PlayerMock again = fx.player("mage"); + again.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, id)); + Cow third = animal(Cow.class, keeper); + AnimalRiteService.bind(again, third); + third.remove(); + AnimalRiteService.tick(); + assertTrue(AnimalRiteService.isBound(third.getUniqueId())); + now.addAndGet(21_000L); + AnimalRiteService.tick(); + assertFalse(AnimalRiteService.isBound(third.getUniqueId())); + + Cow fourth = animal(Cow.class, keeper); + AnimalRiteService.bind(again, fourth); + fourth.setHealth(0); + AnimalRiteService.tick(); + assertFalse(AnimalRiteService.isBound(fourth.getUniqueId())); + + Cow fifth = animal(Cow.class, keeper); + AnimalRiteService.bind(again, fifth); + AnimalRiteService.releaseFor(UUID.randomUUID()); + assertTrue(AnimalRiteService.isBound(fifth.getUniqueId())); + AnimalRiteService.releaseFor(again.getUniqueId()); + assertTrue(fifth.hasAI()); + Cow sixth = animal(Cow.class, keeper); + AnimalRiteService.bind(again, sixth); + sixth.remove(); + AnimalRiteService.releaseFor(again.getUniqueId()); + Cow seventh = animal(Cow.class, keeper); + AnimalRiteService.bind(again, seventh); + Cow eighth = animal(Cow.class, keeper); + seventh.remove(); + AnimalRiteService.releaseAll(); + Cow ninth = animal(Cow.class, keeper); + AnimalRiteService.bind(again, ninth); + AnimalRiteService.releaseAll(); + assertTrue(ninth.hasAI()); + assertNull(AnimalRiteService.bindingOf(again.getUniqueId())); + assertNotNull(eighth); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/ArtefactsAndPoolTest.java b/src/test/java/net/tfminecraft/magic/blood/ArtefactsAndPoolTest.java new file mode 100644 index 0000000..a4d4b8e --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/ArtefactsAndPoolTest.java @@ -0,0 +1,712 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +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 org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.command.ConsoleCommandSenderMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +import io.lumine.mythic.lib.api.event.skill.PlayerCastSkillEvent; +import io.lumine.mythic.lib.skill.Skill; +import io.lumine.mythic.lib.skill.handler.SkillHandler; +import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemTakeEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.magic.manager.MagicTickService; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; + +class ArtefactsAndPoolTest { + @TempDir Path temp; + BloodFixture fx; + PlayerMock mage; + String mageId; + BloodRecord record; + Block target; + final List logs = new ArrayList<>(); + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + fx.logger.addHandler(new java.util.logging.Handler() { + @Override public void publish(java.util.logging.LogRecord line) { logs.add(line.getMessage()); } + @Override public void flush() {} + @Override public void close() {} + }); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + mage = fx.player("mage"); + mageId = BloodFixture.id("mage"); + record = fx.record("mage", BloodStage.FINAL); + record.setPath(BloodPath.DEVOTEE); + target = fx.pocket(0, 10, 0); + } + + @AfterEach + void cleanup() { + BloodSpellGate.clock = System::currentTimeMillis; + fx.close(); + } + + String messages(PlayerMock player) { + StringBuilder out = new StringBuilder(); + String message; + while ((message = player.nextMessage()) != null) { + out.append(message).append('\n'); + } + return out.toString(); + } + + BloodAltar altar() { + BloodAltar altar = BloodAltar.create(mageId, mage.getUniqueId().toString(), target, 0f, 1L); + BloodAltars.add(altar); + return altar; + } + + void fill() { + record.setResonance(BloodPool.cap(record)); + } + + boolean glows(ItemStack item) { + return item.getItemMeta().hasEnchantmentGlintOverride() && item.getItemMeta().getEnchantmentGlintOverride(); + } + + ItemStack carried(PlayerMock player, String id) { + for (ItemStack item : player.getInventory().getContents()) { + if (id.equals(BloodArtefacts.id(item))) { + return item; + } + } + return null; + } + + PlayerInteractEvent click(PlayerMock player, ItemStack held) { + player.getInventory().setItemInMainHand(held); + return new PlayerInteractEvent(player, Action.RIGHT_CLICK_BLOCK, held, target, BlockFace.UP, EquipmentSlot.HAND); + } + + @Test + void theDefaultsSetOutThePoolTiersAndArtefacts() { + assertEquals(100, PoolConfig.capacity()); + assertEquals(1, PoolConfig.decayPercent()); + assertEquals(2, PoolConfig.bloodPerPoint()); + assertEquals(5, PoolConfig.floor()); + assertEquals(40, PoolConfig.refill(0)); + assertEquals(100, PoolConfig.refill(3)); + assertEquals(60, PoolConfig.refill(20)); + assertEquals(40, PoolConfig.refill(40)); + assertEquals(20, PoolConfig.worth(BloodPath.DEVOTEE, 4)); + assertEquals(20, PoolConfig.worth(BloodPath.TRUE, -1)); + assertEquals(55, PoolConfig.worth(BloodPath.TRUE, 2)); + assertEquals(95, PoolConfig.worth(BloodPath.TRUE, Integer.MAX_VALUE)); + assertEquals(0, PoolConfig.worth(BloodPath.NONE, 1)); + assertEquals(30, PoolConfig.need(3)); + assertEquals(0, PoolConfig.need(9)); + assertEquals(0, PoolConfig.band(4)); + assertEquals(2, PoolConfig.band(15)); + assertEquals(6, PoolConfig.band(95)); + assertEquals(1, PoolConfig.tierOf(" bloodsworn_blood_bolt ")); + assertEquals(6, PoolConfig.tierOf("SANGUINAR_CRIMSON_TIDE")); + assertEquals(0, PoolConfig.tierOf("FIREBALL")); + assertEquals(0, PoolConfig.tierOf(null)); + assertEquals(8, ArtefactConfig.devotee().size()); + assertEquals(8, ArtefactConfig.hunt().size()); + assertEquals(5, ArtefactConfig.finals().size()); + assertEquals(List.of("§7§oA small gift from the Blood Father.", "§7§oIt only wakes in the pool of blood."), + ArtefactConfig.devotee().get(0).lore()); + assertEquals("§7§oAll that is left of them.", ArtefactConfig.finals().get(0).lore().get(0)); + } + + @Test + void badEntriesAreSkippedAndMissingSectionsLeaveTheDefaults() throws Exception { + Files.writeString(temp.resolve("p.yml"), String.join("\n", + "enabled: true", + "pool:", + " capacity: 50", + " refill:", + " people:", + " ranks:", + " - { up_to: 5 }", + " - { amount: 3 }", + " - { up_to: 9, amount: 30 }", + " worth:", + " devotee: { x: 4, 1: lots, 2: 8, 3: 8.5 }", + " spells:", + " x: [A]", + " 3: [' ', ' b ']", + "artefacts:", + " devotee:", + " - { name: nothing }", + " - { item: ' ' }", + " - { item: m.a.b, lore: [1, two] }", + "")); + BloodConfig.load(temp.resolve("p.yml").toFile(), fx.logger); + assertEquals(50, PoolConfig.capacity()); + assertEquals(30, PoolConfig.refill(6)); + assertEquals(40, PoolConfig.refill(10)); + assertEquals(0, PoolConfig.worth(BloodPath.DEVOTEE, 1)); + assertEquals(8, PoolConfig.worth(BloodPath.DEVOTEE, 2)); + assertEquals(8.5, PoolConfig.worth(BloodPath.DEVOTEE, 3)); + assertEquals(0, PoolConfig.worth(BloodPath.TRUE, 2)); + assertEquals(0, PoolConfig.need(1)); + assertEquals(3, PoolConfig.tierOf("B")); + assertEquals(0, PoolConfig.tierOf("A")); + assertEquals(List.of(new ArtefactConfig.Piece("m.a.b", "", List.of("1", "two"))), ArtefactConfig.devotee()); + assertTrue(ArtefactConfig.hunt().isEmpty()); + assertTrue(logs.stream().anyMatch(line -> line.contains("pool.refill.people.ranks entry needs up_to and amount"))); + assertTrue(logs.stream().anyMatch(line -> line.contains("pool.worth.devotee entry 'x' skipped"))); + assertTrue(logs.stream().anyMatch(line -> line.contains("pool.worth.devotee entry '1' skipped"))); + assertTrue(logs.stream().anyMatch(line -> line.contains("pool.spells entry 'x' skipped"))); + assertTrue(logs.stream().anyMatch(line -> line.contains("artefacts.devotee entry needs an item"))); + + Files.writeString(temp.resolve("e.yml"), "enabled: true\n"); + BloodConfig.load(temp.resolve("e.yml").toFile(), fx.logger); + assertEquals(100, PoolConfig.capacity()); + assertTrue(ArtefactConfig.devotee().isEmpty()); + } + + @Test + void idsFollowThePathAndItsFinals() { + assertTrue(BloodArtefacts.ids(null).isEmpty()); + assertEquals(List.of("devotee:0", "devotee:1"), BloodArtefacts.ids(record).subList(0, 2)); + assertEquals(8, BloodArtefacts.ids(record).size()); + record.setStage(BloodStage.HUNT); + assertTrue(BloodArtefacts.ids(record).isEmpty()); + record.setStage(BloodStage.FINAL); + record.setPath(BloodPath.NONE); + assertTrue(BloodArtefacts.ids(record).isEmpty()); + record.setPath(BloodPath.TRUE); + assertEquals("true:hunt:0", BloodArtefacts.ids(record).get(0)); + record.setFinalsDone(2); + assertEquals(List.of("true:final:0", "true:final:1", "true:hunt:2"), BloodArtefacts.ids(record).subList(0, 3)); + record.setFinalsDone(9); + assertEquals(List.of("true:final:4", "true:hunt:5"), BloodArtefacts.ids(record).subList(4, 6)); + + assertNull(BloodArtefacts.piece(null)); + assertNull(BloodArtefacts.piece("x")); + assertNull(BloodArtefacts.piece("devotee:9")); + assertNull(BloodArtefacts.piece("devotee:-1")); + assertNull(BloodArtefacts.piece("devotee:x")); + assertNull(BloodArtefacts.piece("true:hunt")); + assertNull(BloodArtefacts.piece("true:other:1")); + assertNull(BloodArtefacts.piece("devotee:1:2")); + assertNull(BloodArtefacts.piece("false:hunt:1")); + assertEquals("#5c0202Hunter's Orb", BloodArtefacts.piece("true:hunt:0").name()); + assertEquals("#5c0202Eye of {victim}", BloodArtefacts.piece("true:final:4").name()); + } + + @Test + void artefactsAreBoundAndNamedAfterTheirFinalsVictims() throws Exception { + ItemStack ring = BloodArtefacts.create(record, "devotee:0"); + assertTrue(BloodArtefacts.is(ring)); + assertEquals("devotee:0", BloodArtefacts.id(ring)); + assertEquals(mageId, BloodItems.owner(ring)); + assertNull(BloodArtefacts.create(record, "nope")); + assertFalse(BloodArtefacts.is(null)); + assertFalse(BloodArtefacts.is(new ItemStack(Material.AIR))); + assertFalse(BloodArtefacts.is(new ItemStack(Material.STONE))); + assertFalse(BloodArtefacts.is(BloodItems.create(BloodItemKind.BLOOD_KNIFE, mageId))); + assertNull(BloodArtefacts.id(new ItemStack(Material.STONE))); + + record.setPath(BloodPath.TRUE); + String victim = UUID.randomUUID().toString(); + record.getSacrifices().add(new BloodRecord.Sacrifice("not-a-uuid", "a", "HUNT", 1, 1)); + try (MockedStatic chars = mockStatic(Characters.class, CALLS_REAL_METHODS)) { + chars.when(() -> Characters.info(UUID.fromString(victim))) + .thenReturn(AltarServiceTest.info(victim, CharacterStatus.DEAD)); + assertEquals("someone", BloodArtefacts.victim(record, 0)); + record.getSacrifices().add(new BloodRecord.Sacrifice("not-a-uuid", "a", "FINAL", 0, 2)); + record.getSacrifices().add(new BloodRecord.Sacrifice(victim, "b", "FINAL", 0, 3)); + assertEquals("someone", BloodArtefacts.victim(record, 0)); + assertEquals("N", BloodArtefacts.victim(record, 1)); + assertEquals("someone", BloodArtefacts.victim(record, 2)); + assertTrue(BloodArtefacts.create(record, "true:final:1").getItemMeta().getDisplayName().contains("Heart of N")); + } + + Files.writeString(temp.resolve("a.yml"), "enabled: true\nartefacts:\n devotee:\n - { item: m.a.b }\n"); + BloodConfig.load(temp.resolve("a.yml").toFile(), fx.logger); + record.setPath(BloodPath.DEVOTEE); + ItemStack plain = BloodArtefacts.create(record, "devotee:0"); + assertFalse(plain.getItemMeta().hasDisplayName()); + assertFalse(plain.getItemMeta().hasLore()); + } + + @Test + void syncHandsOutWhatIsMissingAndFinalsReplaceTheirPlace() { + PlayerMock bare = fx.server.addPlayer(); + assertEquals(0, BloodArtefacts.sync(bare)); + assertEquals(8, BloodArtefacts.sync(mage)); + assertTrue(messages(mage).contains("blood.artefact.given")); + assertEquals(8, BloodArtefacts.ids(record).stream().filter(id -> mage.getInventory().containsAtLeast( + BloodArtefacts.create(record, id), 1)).count()); + assertEquals(BloodArtefacts.ids(record), record.getArtefacts()); + assertEquals(0, BloodArtefacts.sync(mage)); + assertEquals("", messages(mage)); + + altar(); + record.setPath(BloodPath.TRUE); + record.setCharge("devotee:0", 2); + record.getPending().add("devotee:0"); + assertEquals(8, BloodArtefacts.sync(mage)); + assertEquals(0, record.getCharge("devotee:0"), "The old path's artefacts lose their charge"); + assertTrue(record.getPending().isEmpty()); + record.setCharge("true:hunt:0", 2); + record.setCharge("true:hunt:1", 2); + record.getPending().addAll(List.of("true:hunt:0", "true:hunt:1")); + record.setFinalsDone(1); + messages(mage); + assertEquals(1, BloodArtefacts.sync(mage)); + assertTrue(messages(mage).contains("blood.artefact.replaced")); + assertEquals(0, record.getCharge("true:hunt:0")); + assertEquals(2, record.getCharge("true:hunt:1")); + assertEquals(List.of("true:hunt:1"), record.getPending()); + record.getPending().clear(); + record.getArtefacts().add("true:hunt:8"); + assertEquals(0, BloodArtefacts.sync(mage), "A place the config dropped just goes"); + assertEquals(8, record.getArtefacts().size()); + assertEquals("", messages(mage)); + record.setFinalsDone(5); + try (MockedStatic pedestals = mockStatic(AltarPedestals.class, CALLS_REAL_METHODS)) { + pedestals.when(() -> AltarPedestals.offer(any(), any(), any())).thenReturn(true); + int carried = mage.getInventory().all(Material.FERMENTED_SPIDER_EYE).size(); + assertEquals(4, BloodArtefacts.sync(mage)); + assertEquals(carried, mage.getInventory().all(Material.FERMENTED_SPIDER_EYE).size(), "Set out on the pedestals"); + } + record.setFinalsDone(1); + record.getArtefacts().clear(); + record.getArtefacts().addAll(BloodArtefacts.ids(record)); + + record.setFinalsDone(2); + BloodArtefacts.syncSoon(mage); + assertEquals("true:hunt:1", record.getArtefacts().get(1)); + fx.server.getScheduler().performOneTick(); + assertEquals("true:final:1", record.getArtefacts().get(1)); + record.setFinalsDone(3); + BloodArtefacts.syncSoon(mage); + mage.disconnect(); + fx.server.getScheduler().performOneTick(); + assertEquals("true:hunt:2", record.getArtefacts().get(2)); + } + + @Test + void artefactsChargeInThePoolAndRiseGlowing() { + BloodListener listener = new BloodListener(); + altar(); + ItemStack ring = BloodArtefacts.create(record, "devotee:0"); + assertFalse(glows(ring)); + listener.onInteract(click(mage, ring)); + assertEquals("blood.artefact.charge.started", mage.nextMessage()); + assertTrue(mage.getInventory().getItemInMainHand().getType().isAir()); + assertEquals(2.5, record.getCharge("devotee:0")); + assertEquals(List.of("devotee:0"), record.getPending()); + assertTrue(BloodCharging.isCharging(mageId, "devotee:0")); + assertFalse(BloodCharging.isCharging(mageId, "devotee:1")); + assertFalse(BloodCharging.isCharging("other", "devotee:0")); + assertEquals(0, BloodArtefacts.deliverPending(mage), "Still in the pool"); + listener.onInteract(click(mage, BloodArtefacts.create(record, "devotee:1"))); + assertEquals("blood.artefact.charge.busy", mage.nextMessage()); + assertEquals(1, mage.getInventory().getItemInMainHand().getAmount()); + mage.getInventory().setItemInMainHand(null); + for (int i = 0; i < MeditationConfig.chargeTicks(); i++) { + BloodCharging.tick(); + } + assertEquals("blood.artefact.charge.done", mage.nextMessage()); + assertTrue(glows(carried(mage, "devotee:0"))); + assertTrue(record.getPending().isEmpty()); + listener.onInteract(click(mage, BloodArtefacts.create(record, "devotee:0"))); + assertEquals("blood.artefact.charge.full", mage.nextMessage()); + listener.onInteract(click(mage, BloodArtefacts.create(record, "true:hunt:0"))); + assertEquals("blood.artefact.charge.stale", mage.nextMessage()); + assertTrue(mage.getInventory().getItemInMainHand().getType().isAir()); + + record.setStage(BloodStage.ASCENDED); + record.setPool(0.0); + listener.onInteract(click(mage, BloodArtefacts.create(record, "devotee:1"))); + assertEquals("blood.artefact.charge.dry", mage.nextMessage()); + record.setPool(3.0); + listener.onInteract(click(mage, BloodArtefacts.create(record, "devotee:1"))); + assertEquals("blood.artefact.charge.started", mage.nextMessage()); + assertEquals(1.5, record.getCharge("devotee:1"), "Only what the pool could pay for"); + assertEquals(0, record.getPool()); + + // Away when it rises: it waits as pending and comes back next session. + mage.disconnect(); + for (int i = 0; i < MeditationConfig.chargeTicks(); i++) { + BloodCharging.tick(); + } + assertEquals(List.of("devotee:1"), record.getPending()); + PlayerMock back = fx.player("mage"); + assertEquals(1, BloodArtefacts.deliverPending(back)); + assertTrue(messages(back).contains("blood.artefact.returned")); + assertNotNull(carried(back, "devotee:1")); + assertEquals(0, BloodArtefacts.deliverPending(back)); + record.getPending().add("true:hunt:3"); + assertEquals(0, BloodArtefacts.deliverPending(back), "No longer one of its artefacts"); + assertTrue(record.getPending().isEmpty()); + assertEquals(0, BloodArtefacts.deliverPending(fx.server.addPlayer())); + + // Its place is filled anew, or its record goes, while it charges: nothing comes back. + record.setPool(100.0); + BloodAltar altar = BloodAltars.ofCharacter(mageId); + assertEquals(BloodCharging.Start.STARTED, BloodCharging.start(back, altar, BloodArtefacts.create(record, "devotee:2"))); + record.setPath(BloodPath.TRUE); + for (int i = 0; i < MeditationConfig.chargeTicks(); i++) { + BloodCharging.tick(); + } + assertNull(carried(back, "devotee:2")); + record.setPath(BloodPath.DEVOTEE); + assertEquals(BloodCharging.Start.STARTED, BloodCharging.start(back, altar, BloodArtefacts.create(record, "devotee:3"))); + BloodRecords.delete(mageId); + for (int i = 0; i < MeditationConfig.chargeTicks(); i++) { + BloodCharging.tick(); + } + assertNull(carried(back, "devotee:3")); + record = fx.record("mage", BloodStage.ASCENDED); + record.setPath(BloodPath.DEVOTEE); + record.setPool(100.0); + fx.characters.start(back, "elsewhere"); + assertEquals(BloodCharging.Start.NOT_YOURS, BloodCharging.start(back, altar, BloodArtefacts.create(record, "devotee:4"))); + fx.characters.start(back, "mage"); + assertEquals(BloodCharging.Start.STARTED, BloodCharging.start(back, altar, BloodArtefacts.create(record, "devotee:4"))); + fx.characters.start(back, "elsewhere"); + for (int i = 0; i < MeditationConfig.chargeTicks(); i++) { + BloodCharging.tick(); + } + assertEquals(List.of("devotee:4"), record.getPending(), "Played another character meanwhile"); + fx.characters.start(back, "mage"); + record.getPending().add("devotee:5"); + assertEquals(BloodCharging.Start.STARTED, BloodCharging.start(back, altar, BloodArtefacts.create(record, "devotee:5"))); + assertEquals(1, record.getPending().stream().filter("devotee:5"::equals).count()); + BloodCharging.clear(); + assertFalse(BloodCharging.isCharging(mageId, "devotee:5")); + + PlayerMock thief = fx.player("thief"); + ItemStack stolen = BloodArtefacts.create(record, "devotee:6"); + listener.onInteract(click(thief, stolen)); + assertEquals("blood.artefact.charge.not_yours", thief.nextMessage()); + assertEquals(1, thief.getInventory().getItemInMainHand().getAmount()); + BloodRecord own = fx.record("thief", BloodStage.FINAL); + own.setPath(BloodPath.DEVOTEE); + assertEquals(BloodCharging.Start.NOT_YOURS, BloodCharging.start(thief, altar, BloodArtefacts.create(own, "devotee:1"))); + assertEquals(BloodCharging.Start.NOT_YOURS, BloodCharging.start(back, altar, BloodArtefacts.create(own, "devotee:1"))); + assertEquals(BloodCharging.Start.NOT_YOURS, BloodCharging.start(fx.server.addPlayer(), altar, stolen)); + } + + @Test + void staffHandOutCopies() { + PlayerMock bare = fx.server.addPlayer(); + assertEquals(0, BloodArtefacts.regive(bare)); + record.setCharge("devotee:3", 1); + assertEquals(1, BloodArtefacts.charged(record)); + assertEquals(8, BloodArtefacts.regive(mage)); + assertTrue(glows(carried(mage, "devotee:3"))); + assertFalse(glows(carried(mage, "devotee:2"))); + } + + @Test + void pedestalsAndDroppedArtefactsAnswerOnlyToTheirOwner() { + BloodListener listener = new BloodListener(); + Furniture pedestal = new Furniture("pedestal", target.getLocation(), UUID.randomUUID()); + ItemStack ring = BloodArtefacts.create(record, "devotee:0"); + FurnitureSlotItemTakeEvent plain = new FurnitureSlotItemTakeEvent(mage, pedestal, null, new ItemStack(Material.STONE)); + listener.onSlotTake(plain); + assertFalse(plain.isCancelled()); + FurnitureSlotItemTakeEvent own = new FurnitureSlotItemTakeEvent(mage, pedestal, null, ring); + listener.onSlotTake(own); + assertFalse(own.isCancelled()); + PlayerMock thief = fx.player("thief"); + FurnitureSlotItemTakeEvent stolen = new FurnitureSlotItemTakeEvent(thief, pedestal, null, ring); + listener.onSlotTake(stolen); + assertTrue(stolen.isCancelled()); + assertEquals("blood.artefact.not_yours", thief.nextMessage()); + PlayerMock bare = fx.server.addPlayer(); + FurnitureSlotItemTakeEvent nobody = new FurnitureSlotItemTakeEvent(bare, pedestal, null, ring); + listener.onSlotTake(nobody); + assertTrue(nobody.isCancelled()); + + var lying = fx.world.dropItem(thief.getLocation(), ring); + var pickup = new org.bukkit.event.entity.EntityPickupItemEvent(thief, lying, 0); + listener.onPickup(pickup); + assertFalse(pickup.isCancelled()); + String dead = UUID.randomUUID().toString(); + BloodRecord gone = BloodRecords.getOrCreate(dead, "o"); + gone.setStage(BloodStage.FINAL); + gone.setPath(BloodPath.DEVOTEE); + var relic = fx.world.dropItem(thief.getLocation(), BloodArtefacts.create(gone, "devotee:2")); + try (MockedStatic chars = mockStatic(Characters.class, CALLS_REAL_METHODS)) { + chars.when(() -> Characters.info(UUID.fromString(dead))).thenReturn(AltarServiceTest.info(dead, CharacterStatus.DEAD)); + var relicPickup = new org.bukkit.event.entity.EntityPickupItemEvent(thief, relic, 0); + listener.onPickup(relicPickup); + assertTrue(relicPickup.isCancelled()); + assertFalse(relic.isValid()); + } + } + + @Test + void resonanceIsDrawnUpToWhatTheArtefactsAreWorth() throws Exception { + assertEquals(5, BloodPool.resonance(null)); + assertEquals(5, BloodPool.resonance(record), "Nothing drawn yet: the floor"); + record.setResonance(18); + assertEquals(18, BloodPool.resonance(record)); + record.setResonance(40); + assertEquals(20, BloodPool.resonance(record)); + record.setPath(BloodPath.NONE); + assertEquals(5, BloodPool.resonance(record)); + assertEquals(0, BloodArtefacts.maxCharge(record)); + + record.setPath(BloodPath.TRUE); + record.setStage(BloodStage.ASCENDED); + fill(); + assertEquals(95, BloodPool.resonance(record)); + assertEquals("full", BloodPool.state(record)); + record.setPool(120.0); + assertEquals(100, BloodPool.level(record)); + record.setPool(60.0); + assertEquals("half", BloodPool.state(record)); + record.setPool(10.0); + assertEquals("low", BloodPool.state(record)); + assertEquals(5, BloodPool.pay(record, 10), "The pool pays for what it holds"); + assertEquals(0, record.getPool()); + assertEquals("dry", BloodPool.state(record)); + record.setStage(BloodStage.FINAL); + assertEquals(100, BloodPool.level(record), "Only a complete path spends its pool"); + assertEquals(10, BloodPool.pay(record, 10)); + Files.writeString(temp.resolve("f.yml"), "enabled: true\npool:\n blood_per_point: 0\n"); + BloodConfig.load(temp.resolve("f.yml").toFile(), fx.logger); + record.setStage(BloodStage.ASCENDED); + assertEquals(10, BloodPool.pay(record, 10), "Free when blood costs nothing"); + } + + @Test + void onceThePathIsCompleteResonanceFadesAndSaysWhenATierSlips() throws Exception { + PlayerMock other = fx.server.addPlayer(); + PlayerMock waiting = fx.player("waiting"); + fx.record("waiting", BloodStage.ASCENDED); + record.setPath(BloodPath.TRUE); + record.setStage(BloodStage.ASCENDED); + BloodPool.tick(); + assertEquals(0, record.getResonance(), "At the floor nothing fades"); + record.setResonance(90.05); + BloodPool.tick(); + assertTrue(record.getResonance() < 90.05 && record.getResonance() >= 90); + assertNull(mage.nextMessage(), "Still the same tier"); + record.setResonance(90.005); + BloodPool.tick(); + assertTrue(record.getResonance() < 90); + assertEquals("blood.pool.thinned", mage.nextMessage()); + record.setResonance(5.0001); + BloodPool.tick(); + assertEquals(5, record.getResonance(), "Never below the floor"); + + // A small fade waits for the ten-minute flush; a record reset meanwhile is not written back. + record.setResonance(60); + BloodPool.tick(); + BloodStore disk = new BloodStore(new File(fx.data.toFile(), "blood"), fx.logger); + assertTrue(disk.loadRecords().stream().filter(r -> r.getCharacterId().equals(mageId)).findFirst() + .orElseThrow().getResonance() > 89, "Still what was written when the tier slipped"); + BloodPool.flush(); + assertTrue(disk.loadRecords().stream().anyMatch(r -> r.getCharacterId().equals(mageId) && r.getResonance() > 59)); + BloodPool.tick(); + BloodRecords.delete(mageId); + BloodPool.flush(); + assertTrue(disk.loadRecords().stream().noneMatch(r -> r.getCharacterId().equals(mageId)), "Stays deleted"); + record = fx.record("mage", BloodStage.ASCENDED); + record.setPath(BloodPath.TRUE); + record.setStage(BloodStage.FINAL); + record.setResonance(50); + BloodPool.tick(); + assertEquals(50, record.getResonance()); + assertNull(other.nextMessage()); + assertNull(waiting.nextMessage()); + + Files.writeString(temp.resolve("d.yml"), "enabled: true\npool:\n decay_percent_per_hour: 0\n"); + BloodConfig.load(temp.resolve("d.yml").toFile(), fx.logger); + record.setStage(BloodStage.ASCENDED); + BloodPool.tick(); + assertEquals(50, record.getResonance()); + } + + @Test + void spellsAboveTheCastersBloodResonanceAreRefused() { + BloodSpellGate gate = new BloodSpellGate(); + java.util.concurrent.atomic.AtomicLong now = new java.util.concurrent.atomic.AtomicLong(1000); + BloodSpellGate.clock = now::get; + PlayerMock bare = fx.server.addPlayer(); + PlayerMock outsider = fx.player("outsider"); + fx.record("outsider", BloodStage.FINAL); + assertFalse(cast(gate, mage, "FIREBALL")); + assertFalse(cast(gate, null, "BLOODSWORN_BLOOD_BOIL")); + assertFalse(cast(gate, bare, "BLOODSWORN_BLOOD_BOIL")); + assertFalse(cast(gate, outsider, "BLOODSWORN_BLOOD_BOIL"), "No path: never refused"); + assertFalse(cast(gate, mage, "BLOODSWORN_BLOOD_BOLT"), "The floor keeps Minor spells"); + assertTrue(cast(gate, mage, "BLOODSWORN_BLOOD_BOIL")); + assertEquals("blood.pool.too_thin", mage.nextMessage()); + assertTrue(cast(gate, mage, "BLOODSWORN_BLOOD_BOIL")); + assertNull(mage.nextMessage(), "Said once every few seconds"); + now.addAndGet(5000); + assertTrue(cast(gate, mage, "BLOODSWORN_BLOOD_BOIL")); + assertEquals("blood.pool.too_thin", mage.nextMessage()); + fill(); + assertFalse(cast(gate, mage, "BLOODSWORN_BLOOD_BOIL")); + BloodConfig.setEnabled(false); + record.setResonance(0); + assertFalse(cast(gate, mage, "BLOODSWORN_BLOOD_BOIL")); + BloodSpellGate.forget(mage.getUniqueId()); + + assertNull(BloodSpellGate.spell(null)); + assertNull(BloodSpellGate.spell(mock(Skill.class))); + } + + boolean cast(BloodSpellGate gate, PlayerMock player, String spell) { + PlayerCastSkillEvent event = mock(PlayerCastSkillEvent.class); + Skill skill = mock(Skill.class); + SkillHandler handler = mock(SkillHandler.class); + when(handler.getId()).thenReturn(spell); + doReturn(handler).when(skill).getHandler(); + when(event.getCast()).thenReturn(skill); + when(event.getPlayer()).thenReturn(player); + gate.onCast(event); + try { + verify(event).setCancelled(true); + return true; + } catch (AssertionError ex) { + return false; + } + } + + @Test + void theAltarTellsItsOwnerHowFullThePoolIs() { + BloodAltar altar = altar(); + record.setPath(BloodPath.TRUE); + record.setStage(BloodStage.ASCENDED); + AltarService.touch(mage, altar); + assertEquals("blood.altar.touch.ascended_true_full", mage.nextMessage()); + record.setPool(0.0); + record.setPath(BloodPath.DEVOTEE); + AltarService.touch(mage, altar); + assertEquals("blood.altar.touch.ascended_devotee_dry", mage.nextMessage()); + record.setStage(BloodStage.FINAL); + AltarService.touch(mage, altar); + assertEquals("blood.altar.touch.final_devotee", mage.nextMessage()); + } + + @Test + void staffSeeAndSetThePool() { + ConsoleCommandSenderMock console = (ConsoleCommandSenderMock) fx.server.getConsoleSender(); + String name = mage.getName(); + PlayerMock bare = fx.player("bare"); + assertTrue(BloodCommand.execute(console, "blood pool".split(" "))); + assertEquals("blood.admin.usage", console.nextMessage()); + assertTrue(BloodCommand.execute(console, ("blood pool " + bare.getName()).split(" "))); + assertEquals("blood.admin.no_record", console.nextMessage()); + assertTrue(BloodCommand.execute(console, ("blood pool " + name).split(" "))); + assertEquals("blood.admin.pool", console.nextMessage()); + assertTrue(BloodCommand.execute(console, ("blood pool " + name + " lots").split(" "))); + assertEquals("values.invalid_amount", console.nextMessage()); + record.setStage(BloodStage.ASCENDED); + assertTrue(BloodCommand.execute(console, ("blood pool " + name + " 250").split(" "))); + assertEquals(100, record.getPool()); + assertEquals("blood.admin.pool", console.nextMessage()); + assertTrue(BloodCommand.execute(console, ("blood info " + name).split(" "))); + assertEquals("blood.admin.info", console.nextMessage()); + assertEquals("blood.admin.pool", console.nextMessage()); + record.setPath(BloodPath.NONE); + assertTrue(BloodCommand.execute(console, ("blood info " + name).split(" "))); + assertEquals("blood.admin.info", console.nextMessage()); + assertNull(console.nextMessage()); + record.setPath(BloodPath.DEVOTEE); + assertTrue(BloodCommand.execute(console, ("blood give " + name + " artefacts").split(" "))); + assertEquals("blood.admin.artefacts_given", console.nextMessage()); + assertTrue(BloodArtefacts.is(mage.getInventory().getItem(0))); + assertTrue(BloodCommand.complete(new String[] {"blood", "give", "Mage", ""}).contains("artefacts")); + } + + @Test + void withMythicLibTheSpellGateListensAndTheMinuteTickDrains() { + MockBukkit.createMockPlugin("MythicLib"); + var plugin = MockBukkit.createMockPlugin("BloodHost"); + try { + BloodMagic.start(plugin); + assertTrue(org.bukkit.event.HandlerList.getRegisteredListeners(plugin).stream() + .anyMatch(listener -> listener.getListener() instanceof BloodSpellGate)); + } finally { + BloodCharging.startTicking(plugin); + BloodMeditation.startTicking(plugin); + MagicTickService.clearHandlers(); + BloodMagic.stop(); + } + } + + @Test + void recordsKeepTheirArtefactsChargesAndPool() { + BloodRecord fresh = BloodRecord.create("c", "o"); + assertTrue(fresh.isEmpty()); + fresh.getArtefacts().add("devotee:0"); + assertFalse(fresh.isEmpty()); + fresh.getArtefacts().clear(); + fresh.setCharge("devotee:0", 1); + assertFalse(fresh.isEmpty()); + fresh.setCharge("devotee:0", 0); + assertEquals(0, fresh.getCharge("devotee:0")); + assertTrue(fresh.isEmpty()); + fresh.getPending().add("devotee:0"); + assertFalse(fresh.isEmpty()); + fresh.getPending().clear(); + fresh.setResonance(3); + assertFalse(fresh.isEmpty()); + fresh.setResonance(-2); + assertEquals(0, fresh.getResonance()); + fresh.setPool(-4.0); + assertEquals(0, fresh.getPool()); + assertFalse(fresh.isEmpty()); + fresh.setPool(Double.NaN); + fresh.setResonance(Double.NaN); + fresh.normalize(); + assertEquals(0, fresh.getPool()); + assertEquals(0, fresh.getResonance()); + fresh.setPool(null); + assertNull(fresh.getPool()); + fresh.normalize(); + assertNull(fresh.getPool()); + fresh.setPool(3.0); + fresh.normalize(); + assertEquals(3, fresh.getPool()); + fresh.setCharge("a", 1); + fresh.setCharge("b", 2); + fresh.keepCharges(List.of("b")); + assertEquals(0, fresh.getCharge("a")); + assertEquals(2, fresh.getCharge("b")); + BloodRecord old = new com.google.gson.Gson().fromJson( + "{\"artefacts\":null,\"charges\":null,\"pending\":null,\"pool\":-3,\"resonance\":-1}", BloodRecord.class); + old.normalize(); + assertTrue(old.getArtefacts().isEmpty()); + assertTrue(old.getPending().isEmpty()); + assertEquals(0, old.getCharge("x")); + assertEquals(0, old.getPool()); + assertEquals(0, old.getResonance()); + BloodRecord odd = new com.google.gson.Gson().fromJson("{\"charges\":{\"a\":-1,\"b\":null,\"c\":2,\"d\":NaN}}", + BloodRecord.class); + odd.normalize(); + assertEquals(0, odd.getCharge("a")); + assertEquals(2, odd.getCharge("c")); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java new file mode 100644 index 0000000..d71d588 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java @@ -0,0 +1,208 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.command.ConsoleCommandSenderMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class BloodCommandTest { + @TempDir Path temp; + + static String[] args(String line) { + return line.split(" "); + } + + void run(org.bukkit.command.CommandSender sender, String line) { + assertTrue(BloodCommand.execute(sender, args(line))); + } + + @Test + void usageAndTargetErrors() { + try (BloodFixture fx = new BloodFixture(temp)) { + ConsoleCommandSenderMock console = (ConsoleCommandSenderMock) fx.server.getConsoleSender(); + run(console, "blood"); + assertEquals("blood.admin.usage", console.nextMessage()); + run(console, "blood nope"); + assertEquals("blood.admin.usage", console.nextMessage()); + run(console, "blood info"); + assertEquals("blood.admin.usage", console.nextMessage()); + run(console, "blood info Ghost"); + assertEquals("artifact.give.unknown_player", console.nextMessage()); + fx.server.addPlayer("Bare"); + run(console, "blood info Bare"); + assertEquals("resonance.admin.no_character", console.nextMessage()); + } + } + + @Test + void infoStagePathGiveAndReset() { + try (BloodFixture fx = new BloodFixture(temp)) { + ConsoleCommandSenderMock console = (ConsoleCommandSenderMock) fx.server.getConsoleSender(); + PlayerMock mage = fx.server.addPlayer("Mage"); + fx.characters.start(mage, "mage"); + mage.teleport(new org.bukkit.Location(fx.world, 0.5, 11, 0.5)); + run(console, "blood info Mage"); + assertEquals("blood.admin.no_record", console.nextMessage()); + + run(console, "blood stage Mage bogus"); + assertEquals("blood.admin.bad_stage", console.nextMessage()); + run(console, "blood stage Mage animal"); + assertEquals("blood.admin.stage_set", console.nextMessage()); + assertEquals(BloodStage.ANIMAL, BloodRecords.get(BloodFixture.id("mage")).getStage()); + run(console, "blood path Mage bogus"); + assertEquals("blood.admin.bad_path", console.nextMessage()); + run(console, "blood path Mage true"); + assertEquals("blood.admin.path_set", console.nextMessage()); + run(console, "blood info Mage"); + assertEquals("blood.admin.info", console.nextMessage()); + + run(console, "blood give Mage spoon"); + assertEquals("blood.admin.bad_item", console.nextMessage()); + run(console, "blood give Mage altar"); + assertEquals("blood.admin.given", console.nextMessage()); + assertTrue(mage.getInventory().containsAtLeast(BloodItems.create(BloodItemKind.ALTAR, BloodFixture.id("mage")), 1)); + for (int i = 0; i < mage.getInventory().getSize(); i++) { + mage.getInventory().setItem(i, new ItemStack(Material.DIRT, 64)); + } + int before = fx.world.getEntities().size(); + run(console, "blood give Mage ritual_knife"); + assertEquals(before + 1, fx.world.getEntities().size()); + console.nextMessage(); + + Block target = fx.room(0, 10, 0); + AltarService.place(mage, BloodItems.create(BloodItemKind.ALTAR, BloodFixture.id("mage")), target); + run(console, "blood info Mage"); + assertEquals("blood.admin.info", console.nextMessage()); + run(console, "blood list"); + assertEquals("blood.admin.list_header", console.nextMessage()); + assertEquals("blood.admin.list_line", console.nextMessage()); + run(console, "blood reset Mage"); + assertEquals("blood.admin.reset", console.nextMessage()); + assertNull(BloodRecords.get(BloodFixture.id("mage"))); + assertNull(BloodAltars.at(target)); + run(console, "blood reset Mage"); + assertEquals("blood.admin.reset", console.nextMessage()); + } + } + + @Test + void saveFailuresAreReported() throws Exception { + try (BloodFixture fx = new BloodFixture(temp)) { + ConsoleCommandSenderMock console = (ConsoleCommandSenderMock) fx.server.getConsoleSender(); + PlayerMock mage = fx.server.addPlayer("Mage"); + fx.characters.start(mage, "mage"); + java.nio.file.Path blocker = temp.resolve("blood/characters/" + BloodFixture.id("mage") + ".json"); + java.nio.file.Files.createDirectories(blocker.resolve("inside")); + run(console, "blood stage Mage animal"); + assertEquals("values.save_failed", console.nextMessage()); + } + } + + @Test + void altarActionsByPlayerOrId() { + try (BloodFixture fx = new BloodFixture(temp)) { + ConsoleCommandSenderMock console = (ConsoleCommandSenderMock) fx.server.getConsoleSender(); + PlayerMock mage = fx.server.addPlayer("Mage"); + fx.characters.start(mage, "mage"); + fx.record("mage", BloodStage.ANIMAL); + PlayerMock staff = fx.server.addPlayer("Staff"); + run(console, "blood altar info"); + assertEquals("blood.admin.usage", console.nextMessage()); + run(console, "blood altar dance Mage"); + assertEquals("blood.admin.usage", console.nextMessage()); + run(console, "blood altar info Mage"); + assertEquals("blood.admin.no_altar", console.nextMessage()); + run(console, "blood altar info Nobody"); + assertEquals("blood.admin.no_altar", console.nextMessage()); + Block target = fx.room(0, 10, 0); + AltarService.place(mage, BloodItems.create(BloodItemKind.ALTAR, BloodFixture.id("mage")), target); + BloodAltar altar = BloodAltars.at(target); + run(console, "blood altar info Mage"); + assertEquals("blood.admin.list_line", console.nextMessage()); + run(console, "blood altar refresh " + altar.getId()); + assertEquals("blood.admin.altar_refreshed", console.nextMessage()); + run(console, "blood altar tp Mage"); + assertEquals("blood.admin.tp_unavailable", console.nextMessage()); + run(staff, "blood altar tp Mage"); + assertEquals(11.5, staff.getLocation().getY(), 1.0); + run(console, "blood altar purify Mage"); + assertEquals("blood.admin.altar_purified", console.nextMessage()); + assertNull(BloodAltars.at(target)); + + BloodAltar lost = BloodAltar.create("x", "o", fx.pocket(5, 10, 5), 0f, 1L); + BloodAltars.add(lost); + try { + var world = BloodAltar.class.getDeclaredField("world"); + world.setAccessible(true); + world.set(lost, "unloaded"); + } catch (ReflectiveOperationException ex) { + fail(ex); + } + run(staff, "blood altar tp " + lost.getId()); + assertEquals("blood.admin.tp_unavailable", staff.nextMessage()); + } + } + + @Test + void completions() { + try (BloodFixture fx = new BloodFixture(temp)) { + fx.server.addPlayer("Mage"); + assertEquals(BloodCommand.SUBCOMMANDS, BloodCommand.complete(new String[] {"blood", ""})); + assertEquals(List.of("info", "tp", "refresh", "purify"), BloodCommand.complete(new String[] {"blood", "altar", ""})); + assertEquals(List.of(), BloodCommand.complete(new String[] {"blood", "list", ""})); + assertEquals(List.of("Mage"), BloodCommand.complete(new String[] {"blood", "info", ""})); + assertTrue(BloodCommand.complete(new String[] {"blood", "stage", "Mage", ""}).contains("hunt")); + assertTrue(BloodCommand.complete(new String[] {"blood", "path", "Mage", ""}).contains("devotee")); + assertTrue(BloodCommand.complete(new String[] {"blood", "give", "Mage", ""}).contains("blood_knife")); + BloodAltar altar = BloodAltar.create("x", "o", fx.pocket(0, 10, 0), 0f, 1L); + BloodAltars.add(altar); + assertEquals(List.of("Mage", altar.getId()), BloodCommand.complete(new String[] {"blood", "altar", "tp", ""})); + assertEquals(List.of(), BloodCommand.complete(new String[] {"blood", "info", "Mage", ""})); + assertEquals(List.of(), BloodCommand.complete(new String[] {"blood", "info", "Mage", "x", ""})); + } + } + + @Test + void whisperAndVoiceTools() { + try (BloodFixture fx = new BloodFixture(temp)) { + BloodConfig.load(new java.io.File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + ConsoleCommandSenderMock console = (ConsoleCommandSenderMock) fx.server.getConsoleSender(); + PlayerMock mage = fx.server.addPlayer("Mage"); + fx.characters.start(mage, "mage"); + run(console, "blood whisper"); + assertEquals("blood.admin.usage", console.nextMessage()); + run(console, "blood whisper Mage 1 2"); + assertEquals("blood.admin.usage", console.nextMessage()); + run(console, "blood voice Mage"); + assertEquals("blood.admin.no_voices", console.nextMessage()); + run(console, "blood whisper Mage lots"); + assertEquals("values.invalid_amount", console.nextMessage()); + run(console, "blood whisper Mage 5"); + assertEquals("blood.admin.whisper_added", console.nextMessage()); + run(console, "blood voice Mage"); + assertEquals("blood.admin.no_voices", console.nextMessage()); + run(console, "blood whisper Mage"); + assertEquals("blood.admin.whispered", console.nextMessage()); + assertEquals(BloodStage.WHISPERED, BloodRecords.get(BloodFixture.id("mage")).getStage()); + run(console, "blood whisper Mage"); + assertEquals("blood.admin.already_whispered", console.nextMessage()); + run(console, "blood whisper Mage 5"); + assertEquals("blood.admin.whisper_added", console.nextMessage()); + run(console, "blood voice Mage"); + assertEquals("blood.admin.voiced", console.nextMessage()); + assertNotNull(mage.nextMessage()); + BloodRecords.delete(BloodFixture.id("mage")); + run(console, "blood whisper Mage 0"); + assertEquals("blood.admin.whisper_added", console.nextMessage()); + WhisperService.clear(); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java new file mode 100644 index 0000000..8bccd70 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java @@ -0,0 +1,148 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Logger; + +import org.bukkit.Material; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class BloodConfigTest { + @TempDir Path temp; + final Logger log = Logger.getAnonymousLogger(); + + @AfterEach + void reset() { + BloodConfig.reset(); + } + + File write(String yaml) throws Exception { + File file = temp.resolve("bloodmagic.yml").toFile(); + Files.writeString(file.toPath(), yaml); + return file; + } + + @Test + void aFolderMergesItsFilesByNameAndSkipsMessages() throws Exception { + java.nio.file.Path dir = temp.resolve("bm"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("a.yml"), "enabled: true\naltar:\n display_scale: 2.0\n"); + Files.writeString(dir.resolve("b.yml"), "altar:\n display_scale: 3.0\n"); + Files.writeString(dir.resolve("messages.yml"), "enabled: false\n"); + Files.writeString(dir.resolve("notes.txt"), "enabled: false\n"); + assertTrue(BloodConfig.load(dir.toFile(), log)); + assertTrue(BloodConfig.isEnabled()); + assertEquals(3.0f, BloodConfig.displayScale()); + Files.writeString(dir.resolve("c.yml"), "altar: [unclosed\n"); + assertFalse(BloodConfig.load(dir.toFile(), log)); + assertFalse(BloodConfig.isEnabled()); + File odd = org.mockito.Mockito.mock(File.class); + org.mockito.Mockito.when(odd.listFiles(org.mockito.ArgumentMatchers.any(java.io.FilenameFilter.class))).thenReturn(null); + assertTrue(BloodConfig.parts(odd).isEmpty()); + assertTrue(BloodConfig.load(null, log)); + assertTrue(BloodConfig.load(temp.resolve("nowhere").toFile(), log)); + assertFalse(BloodConfig.isEnabled()); + } + + @Test + void bundledFileLoadsEverySection() { + File bundled = new File("src/main/resources/wildmagic/bloodmagic"); + assertTrue(BloodConfig.load(bundled, log)); + assertTrue(BloodConfig.isEnabled()); + assertEquals("m.lore_weapons.blood_knife", BloodConfig.item(BloodItemKind.BLOOD_KNIFE).path()); + assertFalse(BloodConfig.item(BloodItemKind.ALTAR).lore().isEmpty()); + assertEquals(Material.BARRIER, BloodConfig.altarBlock()); + assertEquals("ia.tfmc_blood:altar_0", BloodConfig.model(0)); + assertEquals("ia.tfmc_blood:altar_5", BloodConfig.model(9)); + assertEquals(1.0f, BloodConfig.displayScale()); + assertEquals(0, BloodConfig.level(1)); + assertEquals(2, BloodConfig.level(5)); + assertEquals(5, BloodConfig.level(40)); + assertTrue(BloodConfig.worldAllowed("anything")); + assertTrue(BloodConfig.requireUnderground()); + assertEquals(3, BloodConfig.minCover()); + assertEquals(48, BloodConfig.coverScanHeight()); + assertTrue(BloodConfig.denyRegions()); + assertFalse(BloodConfig.regionAllowed("spawn")); + assertEquals(10, BloodConfig.purifyCheckMinutes()); + assertEquals(7, BloodConfig.huntPointsRequired()); + } + + @Test + void missingOrBrokenFilesLeaveTheFeatureDisabled() throws Exception { + assertTrue(BloodConfig.load(null, log)); + assertTrue(BloodConfig.load(temp.resolve("none.yml").toFile(), log)); + assertFalse(BloodConfig.isEnabled()); + assertFalse(BloodConfig.load(write("enabled: [unclosed"), log)); + assertFalse(BloodConfig.isEnabled()); + } + + @Test + void emptyFileFallsBackToSafeDefaults() throws Exception { + assertTrue(BloodConfig.load(write("enabled: true\n"), log)); + assertTrue(BloodConfig.isEnabled()); + for (BloodItemKind kind : BloodItemKind.values()) { + assertEquals(kind.fallbackPath(), BloodConfig.item(kind).path()); + assertEquals("", BloodConfig.item(kind).name()); + } + assertEquals("v.chiseled_stone_bricks", BloodConfig.model(3)); + assertEquals(0, BloodConfig.level(100)); + } + + @Test + void badValuesAreReportedAndReplaced() throws Exception { + String yaml = String.join("\n", + "enabled: true", + "items:", + " altar:", + " name: plain", + "altar:", + " block: NOT_A_BLOCK", + " models:", + " x: v.stone", + " 2: v.dirt", + " levels:", + " 1: three", + " 2: 4", + " display_scale: 0", + "placement:", + " worlds: [Cave, ' ']", + " allowed_regions: [Wilds]", + " min_cover: -4", + " cover_scan_height: 0", + " purify_check_minutes: 0", + "hunt:", + " points_required: 0", + ""); + assertFalse(BloodConfig.load(write(yaml), log)); + assertEquals("v.chiseled_stone_bricks", BloodConfig.item(BloodItemKind.ALTAR).path()); + assertEquals("plain", BloodConfig.item(BloodItemKind.ALTAR).name()); + assertEquals(Material.BARRIER, BloodConfig.altarBlock()); + assertEquals("v.dirt", BloodConfig.model(0)); + assertEquals("v.dirt", BloodConfig.model(5)); + assertEquals(0.1f, BloodConfig.displayScale(), 1e-6); + assertEquals(2, BloodConfig.level(4)); + assertTrue(BloodConfig.worldAllowed("cave")); + assertFalse(BloodConfig.worldAllowed("other")); + assertFalse(BloodConfig.worldAllowed(null)); + assertTrue(BloodConfig.regionAllowed("WILDS")); + assertFalse(BloodConfig.regionAllowed(null)); + assertEquals(0, BloodConfig.minCover()); + assertEquals(1, BloodConfig.coverScanHeight()); + assertEquals(1, BloodConfig.purifyCheckMinutes()); + assertEquals(1, BloodConfig.huntPointsRequired()); + + assertFalse(BloodConfig.load(write("altar:\n block: DIAMOND\n"), log)); + assertEquals(Material.BARRIER, BloodConfig.altarBlock()); + assertTrue(BloodConfig.load(write("altar:\n block: obsidian\n"), log)); + assertEquals(Material.OBSIDIAN, BloodConfig.altarBlock()); + assertFalse(BloodConfig.isEnabled()); + BloodConfig.setEnabled(true); + assertTrue(BloodConfig.isEnabled()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodDomainTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodDomainTest.java new file mode 100644 index 0000000..bb1f0a3 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodDomainTest.java @@ -0,0 +1,207 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class BloodDomainTest { + @TempDir Path temp; + + @Test + void enumsParseCaseInsensitivelyAndRejectUnknownNames() { + assertEquals(BloodStage.HUNT, BloodStage.parse(" hunt ")); + assertNull(BloodStage.parse("nope")); + assertNull(BloodStage.parse(null)); + assertTrue(BloodStage.FINAL.atLeast(BloodStage.ANIMAL)); + assertFalse(BloodStage.NONE.atLeast(BloodStage.WHISPERED)); + assertEquals(BloodPath.DEVOTEE, BloodPath.parse("Devotee")); + assertNull(BloodPath.parse("other")); + assertNull(BloodPath.parse(null)); + assertEquals(BloodItemKind.RITUAL_KNIFE, BloodItemKind.parse(" RITUAL_knife")); + assertNull(BloodItemKind.parse("sword")); + assertNull(BloodItemKind.parse(null)); + assertEquals("blood_knife", BloodItemKind.BLOOD_KNIFE.key()); + assertEquals("v.iron_sword", BloodItemKind.RITUAL_KNIFE.fallbackPath()); + } + + @Test + void recordNormalizesBrokenFieldsAndKnowsWhenItIsEmpty() throws Exception { + BloodRecord record = new BloodRecord(); + for (String field : new String[] {"stage", "path", "species", "sacrifices", "finalTargets"}) { + var f = BloodRecord.class.getDeclaredField(field); + f.setAccessible(true); + f.set(record, null); + } + var whisper = BloodRecord.class.getDeclaredField("whisper"); + whisper.setAccessible(true); + whisper.set(record, Double.NaN); + var line = BloodRecord.class.getDeclaredField("whisperLine"); + line.setAccessible(true); + line.set(record, -3); + record.setAltarId(" "); + record.normalize(); + assertEquals(BloodStage.NONE, record.getStage()); + assertEquals(BloodPath.NONE, record.getPath()); + assertTrue(record.getSpecies().isEmpty()); + assertEquals(0, record.getWhisper()); + assertEquals(0, record.getWhisperLine()); + assertNull(record.getAltarId()); + assertTrue(record.isEmpty()); + whisper.set(record, -1.0); + record.normalize(); + assertEquals(0, record.getWhisper()); + record.setAltarId("a"); + record.normalize(); + assertEquals("a", record.getAltarId()); + + BloodRecord made = BloodRecord.create("c", "o"); + assertEquals("c", made.getCharacterId()); + assertEquals("o", made.getOwnerUuid()); + made.setCharacterId("d"); + assertEquals("d", made.getCharacterId()); + made.setWhisperLine(4); + made.setStage(BloodStage.NONE); + assertEquals(4, made.getWhisperLine()); + made.setStage(BloodStage.WHISPERED); + assertEquals(0, made.getWhisperLine()); + made.setWhisperLine(-1); + assertEquals(0, made.getWhisperLine()); + made.setWhisper(-5); + assertEquals(0, made.getWhisper()); + made.setWhisper(3); + assertEquals(3, made.getWhisper()); + made.addSpecies("COW"); + made.addSpecies("cow"); + made.addSpecies(null); + assertTrue(made.hasSpecies("Cow")); + assertFalse(made.hasSpecies(null)); + assertEquals(1, made.getSpecies().size()); + made.setLastAnimalAt(9); + assertEquals(9, made.getLastAnimalAt()); + made.setHuntPoints(-2); + made.setFinalsDone(-2); + assertEquals(0, made.getHuntPoints()); + assertEquals(0, made.getFinalsDone()); + made.setPath(BloodPath.TRUE); + assertEquals(BloodPath.TRUE, made.getPath()); + + assertEmptyOnlyWhenBlank(BloodRecord.create("x", "o")); + } + + private static void assertEmptyOnlyWhenBlank(BloodRecord blank) { + assertTrue(blank.isEmpty()); + BloodRecord r = BloodRecord.create("x", "o"); + r.setStage(BloodStage.WHISPERED); + assertFalse(r.isEmpty()); + r = BloodRecord.create("x", "o"); + r.setPath(BloodPath.TRUE); + assertFalse(r.isEmpty()); + r = BloodRecord.create("x", "o"); + r.setWhisper(1); + assertFalse(r.isEmpty()); + r = BloodRecord.create("x", "o"); + r.addSpecies("pig"); + assertFalse(r.isEmpty()); + r = BloodRecord.create("x", "o"); + r.setAltarId("a"); + assertFalse(r.isEmpty()); + r = BloodRecord.create("x", "o"); + r.setHuntPoints(1); + assertFalse(r.isEmpty()); + r = BloodRecord.create("x", "o"); + r.setFinalsDone(1); + assertFalse(r.isEmpty()); + r = BloodRecord.create("x", "o"); + r.getSacrifices().add(new BloodRecord.Sacrifice("c", "a", "HUNT", 1, 0)); + assertFalse(r.isEmpty()); + r = BloodRecord.create("x", "o"); + r.getFinalTargets().add("c"); + assertFalse(r.isEmpty()); + assertEquals("c", r.currentTarget()); + r = BloodRecord.create("x", "o"); + assertNull(r.currentTarget()); + r.setPendingBacklash(true); + assertFalse(r.isEmpty()); + } + + @Test + void altarKeysLocationAndValidity() throws Exception { + try (BloodFixture fx = new BloodFixture(temp)) { + var block = fx.world.getBlockAt(17, 10, -3); + BloodAltar altar = BloodAltar.create("char", "owner", block, 90f, 5L); + assertTrue(altar.isValid()); + assertEquals("char", altar.getOwnerCharacter()); + assertEquals("owner", altar.getOwnerUuid()); + assertEquals("cave", altar.getWorld()); + assertEquals(17, altar.getX()); + assertEquals(10, altar.getY()); + assertEquals(-3, altar.getZ()); + assertEquals(90f, altar.getYaw()); + assertEquals(5L, altar.getPlacedAt()); + assertEquals("cave,17,10,-3", altar.blockKey()); + assertEquals(BloodAltar.key(block), altar.blockKey()); + assertEquals("cave,1,-1", altar.chunkKey()); + assertEquals(block, altar.block()); + assertEquals(17.5, altar.center().getX()); + altar.setLevel(-2); + assertEquals(0, altar.getLevel()); + assertNull(altar.getDisplayId()); + UUID display = UUID.randomUUID(); + altar.setDisplayId(display); + assertEquals(display, altar.getDisplayId()); + altar.setDisplayId(null); + assertNull(altar.getDisplayId()); + var displayField = BloodAltar.class.getDeclaredField("displayId"); + displayField.setAccessible(true); + displayField.set(altar, "not-a-uuid"); + assertNull(altar.getDisplayId()); + + var world = BloodAltar.class.getDeclaredField("world"); + world.setAccessible(true); + world.set(altar, "gone"); + assertNull(altar.block()); + assertNull(altar.center()); + world.set(altar, " "); + assertFalse(altar.isValid()); + world.set(altar, null); + assertFalse(altar.isValid()); + BloodAltar empty = new BloodAltar(); + assertFalse(empty.isValid()); + var id = BloodAltar.class.getDeclaredField("id"); + id.setAccessible(true); + id.set(empty, " "); + assertFalse(empty.isValid()); + id.set(empty, "x"); + assertFalse(empty.isValid()); + var owner = BloodAltar.class.getDeclaredField("ownerCharacter"); + owner.setAccessible(true); + owner.set(empty, " "); + assertFalse(empty.isValid()); + owner.set(empty, "c"); + assertFalse(empty.isValid()); + } + } + + @Test + void progressCountsSpeciesAnswerHuntAndFinals() { + BloodConfig.reset(); + assertEquals(0, BloodProgress.of(null)); + BloodRecord record = BloodRecord.create("c", "o"); + record.addSpecies("cow"); + record.addSpecies("pig"); + assertEquals(2, BloodProgress.of(record)); + record.setPath(BloodPath.TRUE); + record.setHuntPoints(9); + assertEquals(2 + 1 + 7, BloodProgress.of(record)); + record.setHuntPoints(3); + record.setFinalsDone(2); + assertEquals(2 + 1 + 3 + 4, BloodProgress.of(record)); + record.setPath(BloodPath.DEVOTEE); + assertEquals(2 + 1 + 7 + 4, BloodProgress.of(record)); + assertEquals(0, BloodProgress.level(record)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java new file mode 100644 index 0000000..9870460 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java @@ -0,0 +1,158 @@ +package net.tfminecraft.magic.blood; + +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Path; +import java.util.UUID; +import java.util.logging.Logger; + +import net.tfminecraft.magic.CharacterTestSupport; +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.Messages; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockbukkit.mockbukkit.world.WorldMock; +import org.mockito.MockedStatic; + +/** MockBukkit server, a stone cave world, a mocked Magic plugin and echoing messages. */ +final class BloodFixture implements AutoCloseable { + final ServerMock server; + final WorldMock world; + final CharacterTestSupport characters; + final MockedStatic messages; + final Logger logger = Logger.getAnonymousLogger(); + final Path data; + + BloodFixture(Path data) { + this.data = data; + server = MockBukkit.mock(); + world = new DisplayWorld(server); + world.setName("cave"); + server.addWorld(world); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + when(Magic.plugin.getName()).thenReturn("Magic"); + when(Magic.plugin.getServer()).thenReturn(server); + when(Magic.plugin.getLogger()).thenReturn(logger); + when(Magic.plugin.getDataFolder()).thenReturn(data.toFile()); + when(Magic.plugin.isEnabled()).thenReturn(true); + characters = new CharacterTestSupport(); + messages = mockStatic(Messages.class, i -> i.getMethod().getReturnType() == void.class ? null : i.getArgument(0)); + BloodConfig.reset(); + BloodConfig.setEnabled(true); + BloodStore store = new BloodStore(new File(data.toFile(), "blood"), logger); + BloodRecords.init(store); + BloodAltars.init(store); + AltarPedestals.init(store); + AltarRegions.setQuery(location -> java.util.List.of()); + BondService.writer = Runnable::run; + } + + /** MockBukkit cannot build display entities through World.spawn; this world can. */ + static final class DisplayWorld extends WorldMock { + private final ServerMock server; + + DisplayWorld(ServerMock server) { + super(Material.STONE, -64, 320, 64); + this.server = server; + } + + @Override + public T spawn(Location location, Class type, + java.util.function.Consumer function, + org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason reason) { + if (type != org.bukkit.entity.ItemDisplay.class) { + return super.spawn(location, type, function, reason); + } + var display = new org.mockbukkit.mockbukkit.entity.ItemDisplayMock(server, UUID.randomUUID()); + display.setLocation(location); + server.registerEntity(display); + T typed = type.cast(display); + if (function != null) { + function.accept(typed); + } + return typed; + } + + @Override + public T spawn(Location location, Class type) { + return spawn(location, type, null, org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason.CUSTOM); + } + + @Override + public T spawn(Location location, Class type, boolean randomize, + java.util.function.Consumer function) { + return spawn(location, type, function, org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason.CUSTOM); + } + } + + /** A player playing the labelled character. */ + PlayerMock player(String label) { + PlayerMock player = server.addPlayer(); + player.teleport(new Location(world, 0.5, 11, 0.5)); + characters.start(player, label); + return player; + } + + /** + * An open chamber around the pocket at x, y, z, wide and high enough for the altar's meditation + * room, with a doorway out through the east wall. + */ + Block room(int x, int y, int z) { + int half = AltarRoom.halfWidth(); + open(x - half, y, z - half, x + half, y + BloodConfig.roomHeight() - 1, z + half); + open(x + half + 1, y, z + 2, x + half + 1, y + 1, z + 2); + return pocket(x, y, z); + } + + static String id(String label) { + return CharacterTestSupport.id(label); + } + + /** An air pocket at y with stone floor and the world's stone above it, sky light dark. */ + Block pocket(int x, int y, int z) { + world.loadChunk(x >> 4, z >> 4); + Block block = world.getBlockAt(x, y, z); + block.setType(Material.AIR); + world.getBlockAt(x, y - 1, z).setType(Material.STONE); + ((org.mockbukkit.mockbukkit.block.BlockMock) block).setLightFromSky((byte) 0); + return block; + } + + /** Hollows out the stone between the corners, so bodies have open air between them and the altar. */ + void open(int x1, int y1, int z1, int x2, int y2, int z2) { + for (int x = x1; x <= x2; x++) { + for (int z = z1; z <= z2; z++) { + world.loadChunk(x >> 4, z >> 4); + for (int y = y1; y <= y2; y++) { + world.getBlockAt(x, y, z).setType(Material.AIR); + } + } + } + } + + BloodRecord record(String label, BloodStage stage) { + BloodRecord record = BloodRecords.getOrCreate(id(label), UUID.randomUUID().toString()); + record.setStage(stage); + BloodRecords.save(record); + return record; + } + + @Override + public void close() { + AltarRegions.setQuery(null); + BloodRecords.clear(); + BloodAltars.clear(); + AltarPedestals.clear(); + BloodConfig.reset(); + messages.close(); + characters.unregister(); + Magic.plugin = null; + MockBukkit.unmock(); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodFxTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodFxTest.java new file mode 100644 index 0000000..0201d1d --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodFxTest.java @@ -0,0 +1,268 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +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.logging.Handler; +import java.util.logging.LogRecord; + +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.NamespacedKey; +import org.bukkit.Particle; +import org.bukkit.Registry; +import org.bukkit.World; +import org.bukkit.block.data.BlockData; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +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; + +class BloodFxTest { + @TempDir Path temp; + BloodFixture fx; + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + } + + @AfterEach + void cleanup() { + fx.close(); + } + + static BloodFx.Layer layer(Map raw) { + return BloodFx.layer(raw); + } + + @Test + void everyBundledEffectIsARealParticleOrSound() { + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(new File("src/main/resources/wildmagic/bloodmagic/fx.yml")); + var moments = yaml.getConfigurationSection("fx.moments"); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + assertTrue(BloodFx.isEnabled()); + int layers = 0; + for (String moment : moments.getKeys(false)) { + List loaded = BloodFx.moment(moment); + assertEquals(moments.getMapList(moment).size(), loaded.size(), moment); + for (BloodFx.Layer layer : loaded) { + if (layer.sound() != null) { + assertNotNull(Registry.SOUNDS.get(NamespacedKey.fromString(layer.sound())), moment + " " + layer.sound()); + } + layers++; + } + } + assertTrue(layers > 100); + assertTrue(BloodFx.moment("nothing").isEmpty()); + } + + @Test + void layersReadEveryField() { + BloodFx.Layer sound = layer(Map.of("sound", "entity.warden.heartbeat", "volume", 0.5, "pitch", 2, "delay", 4, + "private", true)); + assertEquals("minecraft:entity.warden.heartbeat", sound.sound()); + assertEquals(0.5f, sound.volume()); + assertEquals(2f, sound.pitch()); + assertEquals(4, sound.delay()); + assertTrue(sound.personal()); + assertEquals("custom:sting", layer(Map.of("sound", "custom:sting")).sound()); + + assertNull(layer(Map.of())); + assertNull(layer(Map.of("sound", " "))); + assertNull(layer(Map.of("particle", "NOT_A_PARTICLE"))); + assertNull(layer(Map.of("particle", 3))); + + BloodFx.Layer smoke = layer(Map.of("particle", "smoke", "count", -3, "spread", 0.5, "shape", "spiral", + "radius", 2, "points", 0, "height", 3, "y", 1.5, "speed", 0.1, "delay", -1)); + assertEquals(Particle.SMOKE, smoke.particle()); + assertNull(smoke.data()); + assertEquals(0, smoke.count()); + assertEquals(0.5, smoke.spreadY()); + assertEquals(BloodFx.Shape.SPIRAL, smoke.shape()); + assertEquals(1, smoke.points()); + assertEquals(0, smoke.delay()); + assertFalse(smoke.personal()); + assertEquals(BloodFx.Shape.POINT, layer(Map.of("particle", "SMOKE", "shape", "cube")).shape()); + assertEquals(1, layer(Map.of("particle", "SMOKE")).count()); + + BloodFx.Layer spread = layer(Map.of("particle", "SMOKE", "spread", List.of(1, 2, 3))); + assertEquals(1, spread.spreadX()); + assertEquals(3, spread.spreadZ()); + assertEquals(0, layer(Map.of("particle", "SMOKE", "spread", List.of(1, 2))).spreadX()); + assertEquals(0, layer(Map.of("particle", "SMOKE", "spread", List.of(1, "x", 3))).spreadX()); + assertEquals(0, layer(Map.of("particle", "SMOKE", "spread", List.of("x", 2, 3))).spreadX()); + assertEquals(0, layer(Map.of("particle", "SMOKE", "spread", List.of(1, 2, "x"))).spreadX()); + assertEquals(0, layer(Map.of("particle", "SMOKE", "spread", "wide")).spreadX()); + } + + @Test + void particleDataFollowsTheParticleType() { + Particle.DustOptions dust = (Particle.DustOptions) layer(Map.of("particle", "DUST", "color", "#ff0000", "size", 2)).data(); + assertEquals(Color.RED, dust.getColor()); + assertEquals(2f, dust.getSize()); + assertEquals(Color.fromRGB(0x8a0303), + ((Particle.DustOptions) layer(Map.of("particle", "DUST", "color", "nope")).data()).getColor()); + assertEquals(Color.fromRGB(0x00ff00), + ((Particle.DustOptions) layer(Map.of("particle", "DUST", "color", "00ff00")).data()).getColor()); + Particle.DustTransition transition = (Particle.DustTransition) layer(Map.of("particle", "DUST_COLOR_TRANSITION", + "color", "#ff0000", "to", "#0000ff")).data(); + assertEquals(Color.BLUE, transition.getToColor()); + assertEquals(Color.fromRGB(0x1a0000), + ((Particle.DustTransition) layer(Map.of("particle", "DUST_COLOR_TRANSITION")).data()).getToColor()); + assertInstanceOf(BlockData.class, BloodFx.data(layer(Map.of("particle", "BLOCK", "block", "redstone_block")))); + assertNull(layer(Map.of("particle", "BLOCK", "block", "diamond"))); + assertNull(layer(Map.of("particle", "BLOCK", "block", "nothing"))); + assertInstanceOf(ItemStack.class, BloodFx.data(layer(Map.of("particle", "ITEM", "item", "bone")))); + assertNull(BloodFx.data(layer(Map.of("particle", "SMOKE")))); + assertNull(layer(Map.of("particle", "ITEM", "item", "water"))); + assertNull(layer(Map.of("particle", "ITEM"))); + assertEquals(Color.RED, layer(Map.of("particle", "ENTITY_EFFECT", "color", "#ff0000")).data()); + assertEquals(0.5f, layer(Map.of("particle", "SCULK_CHARGE", "value", 0.5)).data()); + assertEquals(3, layer(Map.of("particle", "SHRIEK", "value", 3)).data()); + assertNull(layer(Map.of("particle", "VIBRATION"))); + } + + @Test + void shapesPlacePointsAroundTheSpot() { + Location at = new Location(fx.world, 0, 10, 0); + assertEquals(List.of(at.clone().add(0, 1, 0)), + BloodFx.points(layer(Map.of("particle", "SMOKE", "y", 1)), at)); + List ring = BloodFx.points(layer(Map.of("particle", "SMOKE", "shape", "ring", "radius", 2, "points", 4)), at); + assertEquals(4, ring.size()); + assertEquals(2, ring.get(0).getX(), 1e-9); + List column = BloodFx.points(layer(Map.of("particle", "SMOKE", "shape", "column", "height", 2, "points", 3)), at); + assertEquals(12, column.get(2).getY(), 1e-9); + assertEquals(1, BloodFx.points(layer(Map.of("particle", "SMOKE", "shape", "column", "points", 1)), at).size()); + List spiral = BloodFx.points(layer(Map.of("particle", "SMOKE", "shape", "spiral", "points", 8, "height", 4)), at); + assertEquals(8, spiral.size()); + assertEquals(13.5, spiral.get(7).getY(), 1e-9); + } + + @Test + void playingSendsLayersToTheWorldOrTheViewer() throws Exception { + Files.writeString(temp.resolve("fx.yml"), String.join("\n", + "enabled: true", + "fx:", + " moments:", + " test:", + " - { sound: block.stone.place }", + " - { sound: block.bell.use, private: true }", + " - { particle: SMOKE, count: 2 }", + " - { particle: SMOKE, private: true, shape: ring, points: 3 }", + " - { sound: block.chain.place, delay: 5 }", + " - { what: ever }", + "")); + var warnings = new java.util.ArrayList(); + Handler handler = new Handler() { + @Override public void publish(LogRecord record) { warnings.add(record.getMessage()); } + @Override public void flush() {} + @Override public void close() {} + }; + fx.logger.addHandler(handler); + BloodConfig.load(temp.resolve("fx.yml").toFile(), fx.logger); + fx.logger.removeHandler(handler); + assertTrue(warnings.stream().anyMatch(w -> w.contains("fx.test"))); + assertEquals(5, BloodFx.moment("test").size()); + + World world = mock(World.class); + Location at = new Location(world, 1, 2, 3); + Player viewer = mock(Player.class); + // Shared sounds reach only players within hear_radius (10 by default). + Player near = mock(Player.class); + when(near.getLocation()).thenReturn(new Location(world, 1, 2, 12)); + Player far = mock(Player.class); + when(far.getLocation()).thenReturn(new Location(world, 1, 2, 14)); + when(world.getPlayers()).thenReturn(java.util.List.of(near, far)); + assertEquals(10, BloodFx.hearRadius()); + BloodFx.play("test", at, viewer); + verify(near).playSound(any(Location.class), eq("minecraft:block.stone.place"), anyFloat(), anyFloat()); + verify(far, never()).playSound(any(Location.class), anyString(), anyFloat(), anyFloat()); + verify(world, never()).playSound(any(Location.class), anyString(), anyFloat(), anyFloat()); + verify(viewer).playSound(any(Location.class), eq("minecraft:block.bell.use"), anyFloat(), anyFloat()); + verify(world).spawnParticle(eq(Particle.SMOKE), any(Location.class), eq(2), anyDouble(), anyDouble(), anyDouble(), + anyDouble(), any()); + verify(viewer, times(3)).spawnParticle(eq(Particle.SMOKE), any(Location.class), anyInt(), anyDouble(), anyDouble(), + anyDouble(), anyDouble(), any()); + verify(near, never()).playSound(any(Location.class), eq("minecraft:block.chain.place"), anyFloat(), anyFloat()); + fx.server.getScheduler().performTicks(6); + verify(near).playSound(any(Location.class), eq("minecraft:block.chain.place"), anyFloat(), anyFloat()); + + World quiet = mock(World.class); + Player listener = mock(Player.class); + when(listener.getLocation()).thenReturn(new Location(quiet, 0, 0, 0)); + when(quiet.getPlayers()).thenReturn(java.util.List.of(listener)); + BloodFx.play("test", new Location(quiet, 0, 0, 0)); + verify(listener).playSound(any(Location.class), eq("minecraft:block.stone.place"), anyFloat(), anyFloat()); + verify(listener, never()).playSound(any(Location.class), eq("minecraft:block.bell.use"), anyFloat(), anyFloat()); + + World broken = mock(World.class); + Player deaf = mock(Player.class); + when(deaf.getLocation()).thenReturn(new Location(broken, 0, 0, 0)); + when(broken.getPlayers()).thenReturn(java.util.List.of(deaf)); + doThrow(new IllegalStateException("no sound")).when(deaf).playSound(any(Location.class), anyString(), anyFloat(), anyFloat()); + BloodFx.play("test", new Location(broken, 0, 0, 0)); + fx.server.getScheduler().performTicks(6); + verify(broken).spawnParticle(eq(Particle.SMOKE), any(Location.class), eq(2), anyDouble(), anyDouble(), anyDouble(), + anyDouble(), any()); + + BloodFx.play("test", null); + BloodFx.play("test", new Location(null, 0, 0, 0)); + BloodFx.play("unknown", at); + + Files.writeString(temp.resolve("wide.yml"), "enabled: true\nfx:\n hear_radius: 0\n"); + BloodConfig.load(temp.resolve("wide.yml").toFile(), fx.logger); + assertEquals(1, BloodFx.hearRadius()); + + Files.writeString(temp.resolve("off.yml"), "enabled: true\nfx:\n enabled: false\n moments:\n test:\n - { sound: a.b }\n"); + BloodConfig.load(temp.resolve("off.yml").toFile(), fx.logger); + assertFalse(BloodFx.isEnabled()); + World silent = mock(World.class); + BloodFx.play("test", new Location(silent, 0, 0, 0)); + verifyNoInteractions(silent); + + Files.writeString(temp.resolve("none.yml"), "enabled: true\nfx:\n enabled: true\n"); + BloodConfig.load(temp.resolve("none.yml").toFile(), fx.logger); + assertTrue(BloodFx.moment("test").isEmpty()); + Files.writeString(temp.resolve("empty.yml"), "enabled: true\n"); + BloodConfig.load(temp.resolve("empty.yml").toFile(), fx.logger); + assertTrue(BloodFx.isEnabled()); + } + + @Test + void altarsBreatheOnlyWhileSomeoneIsNear() throws Exception { + Files.writeString(temp.resolve("amb.yml"), + "enabled: true\nfx:\n moments:\n altar_ambient_2:\n - { particle: SMOKE, count: 7 }\n"); + BloodConfig.load(temp.resolve("amb.yml").toFile(), fx.logger); + BloodAltar near = BloodAltar.create("a", "o", fx.pocket(0, 10, 0), 0f, 1L); + near.setLevel(2); + BloodAltars.add(near); + BloodAltar lost = BloodAltar.create("b", "o", fx.pocket(4, 10, 0), 0f, 1L); + BloodAltars.add(lost); + var field = BloodAltar.class.getDeclaredField("world"); + field.setAccessible(true); + field.set(lost, "gone"); + BloodAltar unloaded = BloodAltar.create("c", "o", fx.world.getBlockAt(5000, 10, 5000), 0f, 1L); + fx.world.unloadChunk(5000 >> 4, 5000 >> 4); + BloodAltars.add(unloaded); + BloodMagic.ambient(); + var far = fx.server.addPlayer(); + far.teleport(new Location(fx.world, 200, 10, 0)); + var close = fx.server.addPlayer(); + close.teleport(new Location(fx.world, 3, 10, 0)); + BloodMagic.ambient(); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java new file mode 100644 index 0000000..401c6e9 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java @@ -0,0 +1,340 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import net.tfminecraft.tlibs.character.CharacterDeathEvent; +import net.tfminecraft.tlibs.character.CharacterRef; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Entity; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.event.Event; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockPistonExtendEvent; +import org.bukkit.event.block.BlockPistonRetractEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.world.EntitiesLoadEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class BloodListenerTest { + @TempDir Path temp; + final BloodListener listener = new BloodListener(); + + PlayerInteractEvent click(PlayerMock player, Action action, ItemStack item, Block block, BlockFace face, + EquipmentSlot hand) { + return new PlayerInteractEvent(player, action, item, block, face, hand); + } + + @Test + void rightClickWithAltarItemPlacesItAndTouchingAnAltarAnswers() { + try (BloodFixture fx = new BloodFixture(temp)) { + PlayerMock player = fx.player("mage"); + fx.record("mage", BloodStage.ANIMAL); + Block floor = fx.world.getBlockAt(0, 9, 0); + Block target = fx.room(0, 10, 0); + ItemStack item = BloodItems.create(BloodItemKind.ALTAR, BloodFixture.id("mage")); + + PlayerInteractEvent offHand = click(player, Action.RIGHT_CLICK_BLOCK, item, floor, BlockFace.UP, EquipmentSlot.OFF_HAND); + listener.onInteract(offHand); + assertFalse(offHand.isCancelled()); + PlayerInteractEvent left = click(player, Action.LEFT_CLICK_BLOCK, item, floor, BlockFace.UP, EquipmentSlot.HAND); + listener.onInteract(left); + assertFalse(left.isCancelled()); + PlayerInteractEvent plain = click(player, Action.RIGHT_CLICK_BLOCK, new ItemStack(Material.STONE), floor, + BlockFace.UP, EquipmentSlot.HAND); + listener.onInteract(plain); + assertFalse(plain.isCancelled()); + + PlayerInteractEvent place = click(player, Action.RIGHT_CLICK_BLOCK, item, floor, BlockFace.UP, EquipmentSlot.HAND); + listener.onInteract(place); + assertTrue(place.isCancelled()); + assertEquals(Event.Result.DENY, place.useItemInHand()); + assertEquals("blood.altar.placed", player.nextMessage()); + assertNotNull(BloodAltars.at(target)); + + PlayerInteractEvent touch = click(player, Action.RIGHT_CLICK_BLOCK, null, target, BlockFace.UP, EquipmentSlot.HAND); + listener.onInteract(touch); + assertTrue(touch.isCancelled()); + assertEquals("blood.altar.touch.animal", player.nextMessage()); + + Block grass = fx.room(30, 10, 30); + grass.setType(Material.SHORT_GRASS); + fx.record("other", BloodStage.ANIMAL); + PlayerMock other = fx.player("other"); + listener.onInteract(click(other, Action.RIGHT_CLICK_BLOCK, + BloodItems.create(BloodItemKind.ALTAR, BloodFixture.id("other")), grass, BlockFace.UP, EquipmentSlot.HAND)); + assertEquals("blood.altar.placed", other.nextMessage()); + assertNotNull(BloodAltars.at(grass)); + } + } + + @Test + void protectionStopsBreakingPistonsAndExplosions() { + try (BloodFixture fx = new BloodFixture(temp)) { + PlayerMock player = fx.player("mage"); + Block target = fx.pocket(0, 10, 0); + BloodAltars.add(BloodAltar.create("c", "o", target, 0f, 1L)); + // Outside the altar's locked chamber. + Block other = fx.world.getBlockAt(20, 10, 20); + + BlockBreakEvent breakAltar = new BlockBreakEvent(target, player); + listener.onBreak(breakAltar); + assertTrue(breakAltar.isCancelled()); + assertEquals("blood.altar.unbreakable", player.nextMessage()); + BlockBreakEvent breakOther = new BlockBreakEvent(other, player); + listener.onBreak(breakOther); + assertFalse(breakOther.isCancelled()); + + BlockPlaceEvent placeBound = new BlockPlaceEvent(other, other.getState(), other, + BloodItems.create(BloodItemKind.ALTAR, "x"), player, true, EquipmentSlot.HAND); + listener.onPlace(placeBound); + assertTrue(placeBound.isCancelled()); + BlockPlaceEvent placePlain = new BlockPlaceEvent(other, other.getState(), other, + new ItemStack(Material.STONE), player, true, EquipmentSlot.HAND); + listener.onPlace(placePlain); + assertFalse(placePlain.isCancelled()); + + BlockPistonExtendEvent extend = new BlockPistonExtendEvent(other, List.of(other, target), BlockFace.UP); + listener.onPistonExtend(extend); + assertTrue(extend.isCancelled()); + BlockPistonExtendEvent extendFree = new BlockPistonExtendEvent(other, List.of(other), BlockFace.UP); + listener.onPistonExtend(extendFree); + assertFalse(extendFree.isCancelled()); + BlockPistonRetractEvent retract = new BlockPistonRetractEvent(other, List.of(target), BlockFace.UP); + listener.onPistonRetract(retract); + assertTrue(retract.isCancelled()); + BlockPistonRetractEvent retractFree = new BlockPistonRetractEvent(other, List.of(), BlockFace.UP); + listener.onPistonRetract(retractFree); + assertFalse(retractFree.isCancelled()); + + Block changed = fx.world.getBlockAt(4, 10, 4); + changed.setType(Material.NETHERRACK); + BloodAltar altar = BloodAltars.at(target); + altar.getCorrupted().add(new BloodAltar.CorruptedBlock(5, 10, 5, "minecraft:stone", "NETHERRACK")); + altar.getCorrupted().add(new BloodAltar.CorruptedBlock(4, 11, 4, "minecraft:stone", "NETHERRACK")); + altar.getCorrupted().add(new BloodAltar.CorruptedBlock(4, 10, 6, "minecraft:stone", "NETHERRACK")); + altar.getCorrupted().add(new BloodAltar.CorruptedBlock(4, 10, 4, "minecraft:stone", "NETHERRACK")); + org.mockbukkit.mockbukkit.world.WorldMock elsewhere = new org.mockbukkit.mockbukkit.world.WorldMock(); + elsewhere.setName("elsewhere"); + fx.server.addWorld(elsewhere); + BloodAltars.add(BloodAltar.create("d", "o2", elsewhere.getBlockAt(0, 10, 0), 0f, 1L)); + BlockBreakEvent breakUntouched = new BlockBreakEvent(other, player); + listener.onBreak(breakUntouched); + assertFalse(breakUntouched.isCancelled()); + BlockBreakEvent breakChanged = new BlockBreakEvent(changed, player); + listener.onBreak(breakChanged); + assertTrue(breakChanged.isCancelled()); + assertEquals("blood.corruption.unbreakable", player.nextMessage()); + BlockPistonExtendEvent pushChanged = new BlockPistonExtendEvent(other, List.of(changed), BlockFace.UP); + listener.onPistonExtend(pushChanged); + assertTrue(pushChanged.isCancelled()); + List blowChanged = new ArrayList<>(List.of(changed, other)); + listener.onEntityExplode(new EntityExplodeEvent(player, target.getLocation(), blowChanged, 1f, null)); + assertEquals(List.of(other), blowChanged); + changed.setType(Material.STONE); + // No longer the altar's work, but still inside its locked chamber. + BlockBreakEvent breakRestored = new BlockBreakEvent(changed, player); + listener.onBreak(breakRestored); + assertTrue(breakRestored.isCancelled()); + assertEquals("blood.altar.room_locked", player.nextMessage()); + + List blown = new ArrayList<>(List.of(target, other)); + listener.onEntityExplode(new EntityExplodeEvent(player, target.getLocation(), blown, 1f, null)); + assertEquals(List.of(other), blown); + List blockBlown = new ArrayList<>(List.of(target, other)); + listener.onBlockExplode(new BlockExplodeEvent(other, other.getState(), blockBlown, 1f, null)); + assertEquals(List.of(other), blockBlown); + } + } + + @Test + void chunkLoadRedrawsAltarsAndDeathPurifies() { + try (BloodFixture fx = new BloodFixture(temp)) { + Block target = fx.pocket(0, 10, 0); + String owner = UUID.randomUUID().toString(); + BloodAltar altar = BloodAltar.create(owner, "o", target, 0f, 1L); + BloodAltars.add(altar); + ItemDisplay stray = fx.world.spawn(target.getLocation(), ItemDisplay.class); + stray.getPersistentDataContainer().set(AltarDisplays.key(), + org.bukkit.persistence.PersistentDataType.STRING, "gone"); + List entities = List.of(stray); + listener.onEntitiesLoad(new EntitiesLoadEvent(target.getChunk(), entities)); + assertFalse(stray.isValid()); + assertNotNull(altar.getDisplayId()); + + CharacterRef other = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + listener.onCharacterDeath(new CharacterDeathEvent(other, UUID.randomUUID(), Instant.EPOCH, null, null, "x")); + assertNotNull(BloodAltars.at(target)); + CharacterRef dead = new CharacterRef(UUID.randomUUID(), UUID.fromString(owner)); + listener.onCharacterDeath(new CharacterDeathEvent(dead, UUID.randomUUID(), Instant.EPOCH, null, null, "x")); + assertNull(BloodAltars.at(target)); + } + } + + @Test + void killsFeedTheKillersMeterAndSessionsResetVoiceTimers() { + try (BloodFixture fx = new BloodFixture(temp)) { + PlayerMock killer = fx.player("killer"); + String killerId = BloodFixture.id("killer"); + CharacterRef victim = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + listener.onCharacterDeath(new CharacterDeathEvent(victim, UUID.randomUUID(), Instant.EPOCH, null, null, "x")); + listener.onCharacterDeath(new CharacterDeathEvent(victim, UUID.randomUUID(), Instant.EPOCH, victim.ownerId(), victim, "x")); + assertNull(BloodRecords.get(victim.characterId().toString())); + CharacterRef killerRef = new CharacterRef(killer.getUniqueId(), UUID.fromString(killerId)); + listener.onCharacterDeath(new CharacterDeathEvent(victim, UUID.randomUUID(), Instant.EPOCH, + killer.getUniqueId(), killerRef, "x")); + assertEquals(15, BloodRecords.get(killerId).getWhisper()); + CharacterRef otherOfSameOwner = new CharacterRef(killer.getUniqueId(), UUID.randomUUID()); + listener.onCharacterDeath(new CharacterDeathEvent(victim, UUID.randomUUID(), Instant.EPOCH, + killer.getUniqueId(), otherOfSameOwner, "x")); + assertEquals(15, BloodRecords.get(otherOfSameOwner.characterId().toString()).getWhisper()); + CharacterRef offline = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + listener.onCharacterDeath(new CharacterDeathEvent(victim, UUID.randomUUID(), Instant.EPOCH, + offline.ownerId(), offline, "x")); + assertEquals(15, BloodRecords.get(offline.characterId().toString()).getWhisper()); + + BloodConfig.load(new java.io.File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + BloodConfig.setEnabled(true); + var cow = fx.world.spawn(killer.getLocation(), org.bukkit.entity.Cow.class); + killer.getInventory().setItemInMainHand(new ItemStack(Material.IRON_SWORD)); + listener.onAnimalDeath(new org.bukkit.event.entity.EntityDeathEvent(cow, + org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.GENERIC).build(), new ArrayList<>())); + cow.setKiller(killer); + fx.world.setTime(1000); + listener.onAnimalDeath(new org.bukkit.event.entity.EntityDeathEvent(cow, + org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.GENERIC).build(), new ArrayList<>())); + assertEquals(19, BloodRecords.get(killerId).getWhisper()); + + WhisperService.voiceSoon(killer); + listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(killer, "bye")); + assertNull(WhisperService.nextVoice(killer.getUniqueId())); + WhisperService.voiceSoon(killer); + listener.onSessionStart(new net.tfminecraft.tlibs.character.CharacterSessionStartEvent(killer, + new net.tfminecraft.tlibs.character.CharacterSession(killerRef, 99))); + assertNull(WhisperService.nextVoice(killer.getUniqueId())); + WhisperService.clear(); + } + } + + @Test + void ritualKnifeClicksBindAnimalsAndLeadsAreRemembered() { + try (BloodFixture fx = new BloodFixture(temp)) { + PlayerMock mage = fx.player("mage"); + var cow = fx.world.spawn(mage.getLocation(), org.bukkit.entity.Cow.class); + var stand = fx.world.spawn(mage.getLocation(), org.bukkit.entity.ArmorStand.class); + PlayerMock other = fx.server.addPlayer(); + var offHand = new org.bukkit.event.player.PlayerInteractEntityEvent(mage, cow, EquipmentSlot.OFF_HAND); + listener.onInteractEntity(offHand); + assertFalse(offHand.isCancelled()); + var plain = new org.bukkit.event.player.PlayerInteractEntityEvent(mage, cow, EquipmentSlot.HAND); + listener.onInteractEntity(plain); + assertFalse(plain.isCancelled()); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, BloodFixture.id("mage"))); + var atPlayer = new org.bukkit.event.player.PlayerInteractEntityEvent(mage, other, EquipmentSlot.HAND); + listener.onInteractEntity(atPlayer); + assertFalse(atPlayer.isCancelled()); + var item = fx.world.dropItem(mage.getLocation(), new ItemStack(Material.STONE)); + var atItem = new org.bukkit.event.player.PlayerInteractEntityEvent(mage, item, EquipmentSlot.HAND); + listener.onInteractEntity(atItem); + assertFalse(atItem.isCancelled()); + var atCow = new org.bukkit.event.player.PlayerInteractEntityEvent(mage, cow, EquipmentSlot.HAND); + listener.onInteractEntity(atCow); + assertTrue(atCow.isCancelled()); + assertEquals("blood.animal.bind.not_stage", mage.nextMessage()); + assertNotNull(stand); + + listener.onLeash(new org.bukkit.event.entity.PlayerLeashEntityEvent(cow, mage, mage, EquipmentSlot.HAND)); + assertTrue(StolenClues.remembers(cow.getUniqueId())); + StolenClues.clear(); + + var trace = fx.world.spawn(mage.getLocation(), ItemDisplay.class); + trace.getPersistentDataContainer().set(StolenClues.traceKey(), org.bukkit.persistence.PersistentDataType.LONG, 0L); + listener.onEntitiesLoad(new EntitiesLoadEvent(fx.world.getChunkAt(0, 0), List.of(trace))); + assertFalse(trace.isValid()); + + BloodNotes.add(new BloodNotes.Note(mage.getUniqueId().toString(), null, "taken", Long.MAX_VALUE)); + listener.onSessionStart(new net.tfminecraft.tlibs.character.CharacterSessionStartEvent(mage, + new net.tfminecraft.tlibs.character.CharacterSession( + new CharacterRef(mage.getUniqueId(), UUID.fromString(BloodFixture.id("mage"))), 5))); + assertEquals("taken", mage.nextMessage()); + BloodNotes.clear(); + } + } + + @Test + void theBloodKnifeOpensTheQuestionAndArmourIsPoliced() { + try (BloodFixture fx = new BloodFixture(temp)) { + PlayerMock mage = fx.player("mage"); + String id = BloodFixture.id("mage"); + Block target = fx.pocket(0, 10, 0); + BloodAltars.add(BloodAltar.create(id, "o", target, 0f, 1L)); + fx.record("mage", BloodStage.CHOICE); + PlayerInteractEvent touch = click(mage, Action.RIGHT_CLICK_BLOCK, null, target, BlockFace.UP, EquipmentSlot.HAND); + listener.onInteract(touch); + assertEquals("blood.altar.touch.choice", mage.nextMessage()); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, "someone")); + listener.onInteract(click(mage, Action.RIGHT_CLICK_BLOCK, null, target, BlockFace.UP, EquipmentSlot.HAND)); + assertEquals("blood.altar.touch.choice", mage.nextMessage()); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, id)); + listener.onInteract(click(mage, Action.RIGHT_CLICK_BLOCK, null, target, BlockFace.UP, EquipmentSlot.HAND)); + assertEquals("blood.altar.touch.choice", mage.nextMessage()); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, id)); + BloodRecords.get(id).setStage(BloodStage.HUNT); + listener.onInteract(click(mage, Action.RIGHT_CLICK_BLOCK, null, target, BlockFace.UP, EquipmentSlot.HAND)); + assertEquals("blood.altar.touch.hunt", mage.nextMessage()); + BloodRecords.get(id).setStage(BloodStage.CHOICE); + listener.onInteract(click(mage, Action.RIGHT_CLICK_BLOCK, null, target, BlockFace.UP, EquipmentSlot.HAND)); + assertTrue(QuestionService.asking(mage.getUniqueId())); + listener.onCharacterChat(new net.tfminecraft.rpcharacters.chat.CharacterChatEvent(mage, null, "rp", "I kneel", + "mage", java.util.Set.of(), false, false)); + QuestionService.clear(); + PlayerMock stranger = fx.player("stranger"); + stranger.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, BloodFixture.id("stranger"))); + listener.onInteract(click(stranger, Action.RIGHT_CLICK_BLOCK, null, target, BlockFace.UP, EquipmentSlot.HAND)); + assertEquals("blood.altar.stranger_touch", stranger.nextMessage()); + PlayerMock bare = fx.server.addPlayer(); + listener.onInteract(click(bare, Action.RIGHT_CLICK_BLOCK, null, target, BlockFace.UP, EquipmentSlot.HAND)); + assertEquals("blood.altar.stranger_touch", bare.nextMessage()); + + ItemStack foreign = BloodArmour.bind(new ItemStack(Material.LEATHER_HELMET), id); + stranger.getInventory().setHelmet(foreign); + listener.onArmourChange(new com.destroystokyo.paper.event.player.PlayerArmorChangeEvent(stranger, + com.destroystokyo.paper.event.player.PlayerArmorChangeEvent.SlotType.HEAD, new ItemStack(Material.AIR), foreign)); + when(net.tfminecraft.magic.Magic.plugin.isEnabled()).thenReturn(true); + fx.server.getScheduler().performOneTick(); + listener.onArmourChange(new com.destroystokyo.paper.event.player.PlayerArmorChangeEvent(stranger, + com.destroystokyo.paper.event.player.PlayerArmorChangeEvent.SlotType.HEAD, foreign, new ItemStack(Material.AIR))); + + var dropped = fx.world.dropItem(stranger.getLocation(), foreign); + var pickup = new org.bukkit.event.entity.EntityPickupItemEvent(stranger, dropped, 0); + listener.onPickup(pickup); + assertFalse(pickup.isCancelled()); + String dead = UUID.randomUUID().toString(); + var relic = fx.world.dropItem(stranger.getLocation(), BloodArmour.bind(new ItemStack(Material.LEATHER_BOOTS), dead)); + try (var chars = mockStatic(net.tfminecraft.tlibs.character.Characters.class, CALLS_REAL_METHODS)) { + chars.when(() -> net.tfminecraft.tlibs.character.Characters.info(UUID.fromString(dead))) + .thenReturn(AltarServiceTest.info(dead, net.tfminecraft.tlibs.character.CharacterStatus.DEAD)); + var relicPickup = new org.bukkit.event.entity.EntityPickupItemEvent(stranger, relic, 0); + listener.onPickup(relicPickup); + assertTrue(relicPickup.isCancelled()); + assertFalse(relic.isValid()); + } + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodLooksTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodLooksTest.java new file mode 100644 index 0000000..fb1d024 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodLooksTest.java @@ -0,0 +1,173 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; + +import de.tr7zw.nbtapi.NBT; +import de.tr7zw.nbtapi.iface.ReadWriteItemNBT; +import de.tr7zw.nbtapi.iface.ReadWriteNBT; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +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 org.mockbukkit.mockbukkit.MockBukkit; + +class BloodLooksTest { + @TempDir Path temp; + BloodFixture fx; + final String id = BloodFixture.id("mage"); + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + } + + @AfterEach + void cleanup() { + fx.close(); + } + + @Test + void armourPiecesTakeTheirLooks() throws Exception { + Files.writeString(temp.resolve("a.yml"), String.join("\n", + "enabled: true", + "armour:", + " devotee:", + " - m.a.plain", + " - { item: m.a.robe, look: ia.blood:robe }", + " - { item: ' ', look: ia.x:y }", + " - { look: ia.x:y }", + " - { item: m.a.hood, look: ' ' }", + " - { item: m.a.boots, look: 3 }", + " - 7", + " - ' '", + "")); + BloodConfig.load(temp.resolve("a.yml").toFile(), fx.logger); + assertEquals(List.of(new ChoiceConfig.Piece("m.a.plain", null), new ChoiceConfig.Piece("m.a.robe", "ia.blood:robe"), + new ChoiceConfig.Piece("m.a.hood", null), new ChoiceConfig.Piece("m.a.boots", null)), + ChoiceConfig.armourFor(BloodPath.DEVOTEE)); + + try (var libs = mockStatic(TLibs.class)) { + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator().getItemFromPath(any())).thenAnswer(i -> new ItemStack(Material.LEATHER_CHESTPLATE)); + when(api.getArmorMerger().merge(any(), any(), eq("ia.blood:robe"))).thenReturn(new ItemStack(Material.LEATHER_HELMET)); + List built = BloodArmour.create(BloodPath.DEVOTEE, id); + assertEquals(4, built.size()); + assertEquals(Material.LEATHER_HELMET, built.get(1).getType()); + assertTrue(BloodArmour.is(built.get(1))); + assertEquals(Material.LEATHER_CHESTPLATE, built.get(0).getType()); + + when(api.getArmorMerger().merge(any(), any(), eq("ia.blood:robe"))).thenReturn(null); + assertEquals(Material.LEATHER_CHESTPLATE, BloodArmour.create(BloodPath.DEVOTEE, id).get(1).getType()); + when(api.getArmorMerger().merge(any(), any(), eq("ia.blood:robe"))).thenReturn(new ItemStack(Material.AIR)); + assertEquals(Material.LEATHER_CHESTPLATE, BloodArmour.create(BloodPath.DEVOTEE, id).get(1).getType()); + when(api.getArmorMerger().merge(any(), any(), eq("ia.blood:robe"))).thenThrow(new IllegalStateException("no look")); + assertEquals(Material.LEATHER_CHESTPLATE, BloodArmour.create(BloodPath.DEVOTEE, id).get(1).getType()); + } + } + + @Test + void trueArmourIsNamedAfterTheHuntVictimsFromTheMostImportantDown() throws Exception { + Files.writeString(temp.resolve("n.yml"), String.join("\n", + "enabled: true", + "armour:", + " \"true\":", + " - { item: m.a.skull, name: '#5c0202{victim} Skull' }", + " - { item: m.a.ribs, name: '{victim} Ribcage' }", + " - { item: m.a.plain, name: ' ' }", + " - { item: m.a.hide, name: 3 }", + " - { item: m.a.bones, name: '{victim} Bones' }", + "")); + BloodConfig.load(temp.resolve("n.yml").toFile(), fx.logger); + assertEquals(new ChoiceConfig.Piece("m.a.skull", null, "#5c0202{victim} Skull"), ChoiceConfig.armourFor(BloodPath.TRUE).get(0)); + assertNull(ChoiceConfig.armourFor(BloodPath.TRUE).get(2).name()); + assertNull(ChoiceConfig.armourFor(BloodPath.TRUE).get(3).name()); + + String cara = BloodFixture.id("cara"); + String bram = BloodFixture.id("bram"); + String dell = BloodFixture.id("dell"); + String gone = BloodFixture.id("gone"); + BloodRecord record = BloodRecord.create(id, null); + record.getSacrifices().add(new BloodRecord.Sacrifice("stranger", null, "HUNT", 1, 1)); + record.getSacrifices().add(new BloodRecord.Sacrifice(bram, null, "HUNT", 3, 2, 9)); + record.getSacrifices().add(new BloodRecord.Sacrifice(cara, null, "HUNT", 3, 3, 7)); + record.getSacrifices().add(new BloodRecord.Sacrifice(dell, null, "HUNT", 2, 0, 12)); + record.getSacrifices().add(new BloodRecord.Sacrifice("late", null, "HUNT", 2, 5, 12)); + record.getSacrifices().add(new BloodRecord.Sacrifice("named", null, "FINAL", 0, 6)); + record.getSacrifices().add(new BloodRecord.Sacrifice("skipped", null, "SKIPPED", 0, 7)); + record.getSacrifices().add(new BloodRecord.Sacrifice("far", null, "HUNT", 1, 0, 30)); + assertEquals(List.of(cara, bram, dell, "late", "far", "stranger"), BloodArmour.victims(record)); + + try (var libs = mockStatic(TLibs.class); var chars = mockStatic(Characters.class)) { + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator().getItemFromPath(any())).thenAnswer(i -> new ItemStack(Material.LEATHER_CHESTPLATE)); + chars.when(() -> Characters.info(any())).thenReturn(Optional.empty()); + chars.when(() -> Characters.info(UUID.fromString(cara))).thenReturn(named(cara, "Cara")); + chars.when(() -> Characters.info(UUID.fromString(bram))).thenReturn(named(bram, "Br§cam")); + + List pieces = BloodArmour.create(BloodPath.TRUE, id, List.of(cara, bram)); + assertEquals(5, pieces.size()); + assertTrue(pieces.get(0).getItemMeta().getDisplayName().endsWith("Cara Skull")); + assertEquals("Brcam Ribcage", pieces.get(1).getItemMeta().getDisplayName()); + assertFalse(pieces.get(2).getItemMeta().hasDisplayName()); + assertFalse(pieces.get(3).getItemMeta().hasDisplayName()); + assertTrue(pieces.get(4).getItemMeta().getDisplayName().endsWith("Cara Bones")); + assertTrue(pieces.stream().allMatch(BloodArmour::is)); + + List unknown = BloodArmour.create(BloodPath.TRUE, id, List.of(gone)); + assertFalse(unknown.get(0).getItemMeta().hasDisplayName()); + assertFalse(BloodArmour.create(BloodPath.TRUE, id).get(0).getItemMeta().hasDisplayName()); + } + } + + private static Optional named(String id, String name) { + return Optional.of(new CharacterInfo(new CharacterRef(UUID.randomUUID(), UUID.fromString(id)), name, + CharacterStatus.DEAD, null, Set.of(), 0, Instant.EPOCH)); + } + + @Test + @SuppressWarnings("unchecked") + void itemsAdderIsToldToLeaveBoundItemsAlone() { + ItemStack item = new ItemStack(Material.STONE); + BloodLooks.protect(item); + MockBukkit.createMockPlugin("NBTAPI"); + try (var nbt = mockStatic(NBT.class)) { + ReadWriteItemNBT root = mock(ReadWriteItemNBT.class); + ReadWriteNBT compound = mock(ReadWriteNBT.class); + when(root.getCompound("itemsadder")).thenReturn(compound); + nbt.when(() -> NBT.modify(eq(item), any(Consumer.class))).thenAnswer(i -> { + ((Consumer) i.getArgument(1)).accept(root); + return null; + }); + BloodLooks.protect(item); + verify(compound, never()).setBoolean(any(), any()); + when(root.hasTag("itemsadder")).thenReturn(true); + BloodLooks.protect(item); + verify(compound).setBoolean("override_auto_update", true); + nbt.when(() -> NBT.modify(eq(item), any(Consumer.class))).thenThrow(new IllegalStateException("no nms")); + BloodLooks.protect(item); + nbt.when(() -> NBT.modify(eq(item), any(Consumer.class))).thenThrow(new NoClassDefFoundError("nbt")); + BloodLooks.protect(item); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java new file mode 100644 index 0000000..fd7438b --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java @@ -0,0 +1,88 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.UUID; + +import net.tfminecraft.magic.manager.MagicTickService; +import net.tfminecraft.magic.tick.MagicTickContext; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; + +class BloodMagicTest { + @TempDir Path temp; + + @Test + void withMmoCoreTheClassRewardsListenForClassChanges() throws Exception { + try (BloodFixture fx = new BloodFixture(temp)) { + MockBukkit.createMockPlugin("MMOCore"); + var plugin = MockBukkit.createMockPlugin("BloodHost"); + try { + BloodMagic.start(plugin); + assertTrue(org.bukkit.event.HandlerList.getRegisteredListeners(plugin).stream() + .anyMatch(listener -> listener.getListener() instanceof MmoClassRewards)); + } finally { + MagicTickService.clearHandlers(); + BloodMagic.stop(); + } + } + } + + @Test + void startLoadsDataSettlesAltarsAndSweepsOnSchedule() throws Exception { + try (BloodFixture fx = new BloodFixture(temp)) { + var plugin = MockBukkit.createMockPlugin("BloodHost"); + String owner = UUID.randomUUID().toString(); + Block target = fx.pocket(0, 10, 0); + BloodStore store = new BloodStore(new java.io.File(plugin.getDataFolder(), "data/bloodmagic"), fx.logger); + store.saveAltars(java.util.List.of(BloodAltar.create(owner, "o", target, 0f, 1L))); + BloodRecord record = BloodRecord.create(owner, "o"); + record.setStage(BloodStage.ANIMAL); + store.save(record); + + // A chunk loaded before the plugin (spawn, force-loaded) still holds a purified altar's display. + Block old = fx.pocket(6, 10, 0); + old.setType(Material.BARRIER); + old.getRelative(org.bukkit.block.BlockFace.UP).setType(Material.LIGHT); + var ghost = fx.world.spawn(old.getLocation().add(0.5, 0.5, 0.5), org.bukkit.entity.ItemDisplay.class); + ghost.getPersistentDataContainer().set(AltarDisplays.key(), org.bukkit.persistence.PersistentDataType.STRING, "purified"); + + try (var chars = mockStatic(Characters.class)) { + chars.when(Characters::available).thenReturn(true); + chars.when(() -> Characters.info(any())).thenReturn(java.util.Optional.empty()); + BloodMagic.start(plugin); + assertEquals(1, BloodAltars.all().size()); + assertEquals(BloodStage.ANIMAL, BloodRecords.get(owner).getStage()); + fx.server.getScheduler().performOneTick(); + assertEquals(Material.BARRIER, target.getType()); + assertFalse(ghost.isValid()); + assertEquals(Material.AIR, old.getType()); + assertEquals(Material.AIR, old.getRelative(org.bukkit.block.BlockFace.UP).getType()); + + BloodMagic.tick(MagicTickContext.between(0, 59)); + BloodMagic.tick(MagicTickContext.between(1, 2)); + assertEquals(1, BloodAltars.all().size()); + chars.when(() -> Characters.info(UUID.fromString(owner))) + .thenReturn(AltarServiceTest.info(owner, CharacterStatus.DEAD)); + BloodMagic.tick(MagicTickContext.between(599, 600)); + assertTrue(BloodAltars.all().isEmpty()); + } finally { + MagicTickService.clearHandlers(); + } + assertTrue(BloodMagic.loadConfig(temp.toFile(), fx.logger)); + java.nio.file.Files.writeString(temp.resolve("bloodmagic.yml"), "enabled: true\n"); + assertTrue(BloodMagic.loadConfig(temp.toFile(), fx.logger)); + assertFalse(BloodConfig.isEnabled()); + BloodMagic.stop(); + assertTrue(BloodAltars.all().isEmpty()); + assertTrue(BloodRecords.all().isEmpty()); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodPermsTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodPermsTest.java new file mode 100644 index 0000000..a6fd1b3 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodPermsTest.java @@ -0,0 +1,132 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.function.Consumer; + +import de.tr7zw.nbtapi.NBT; +import de.tr7zw.nbtapi.iface.ReadWriteItemNBT; +import org.bukkit.Material; +import org.bukkit.entity.HumanEntity; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +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 org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class BloodPermsTest { + @TempDir Path temp; + BloodFixture fx; + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + } + + @AfterEach + void cleanup() { + BloodPerms.clearAll(); + fx.close(); + } + + @Test + void aPathsCharacterHoldsItsPermissionOnlyWhilePlayed() throws Exception { + Files.writeString(temp.resolve("p.yml"), String.join("\n", + "enabled: true", + "permissions:", + " devotee: [magic.blood.bloodsworn, ' ', 4, magic.wild]", + " \"true\": ' '", + " none: magic.blood.none", + "")); + BloodConfig.load(temp.resolve("p.yml").toFile(), fx.logger); + assertEquals(List.of("magic.blood.bloodsworn", "magic.wild"), ChoiceConfig.permissionsFor(BloodPath.DEVOTEE)); + assertTrue(ChoiceConfig.permissionsFor(BloodPath.TRUE).isEmpty()); + assertTrue(ChoiceConfig.permissionsFor(BloodPath.NONE).isEmpty()); + Files.writeString(temp.resolve("q.yml"), "enabled: true\npermissions:\n devotee: 3\n \"true\": magic.blood.sanguinar\n"); + BloodConfig.load(temp.resolve("q.yml").toFile(), fx.logger); + assertTrue(ChoiceConfig.permissionsFor(BloodPath.DEVOTEE).isEmpty()); + assertEquals(List.of("magic.blood.sanguinar"), ChoiceConfig.permissionsFor(BloodPath.TRUE)); + Files.writeString(temp.resolve("r.yml"), "enabled: true\n"); + BloodConfig.load(temp.resolve("r.yml").toFile(), fx.logger); + assertTrue(ChoiceConfig.permissionsFor(BloodPath.TRUE).isEmpty()); + BloodConfig.load(temp.resolve("p.yml").toFile(), fx.logger); + + PlayerMock mage = fx.player("mage"); + BloodPerms.refresh(mage); + assertFalse(mage.hasPermission("magic.blood.bloodsworn")); + BloodRecord record = fx.record("mage", BloodStage.FINAL); + BloodPerms.refresh(mage); + assertFalse(BloodPerms.holds(mage.getUniqueId())); + + record.setPath(BloodPath.DEVOTEE); + BloodPerms.refresh(mage); + assertTrue(mage.hasPermission("magic.blood.bloodsworn")); + assertTrue(mage.hasPermission("magic.wild")); + BloodPerms.refresh(mage); + assertTrue(mage.hasPermission("magic.blood.bloodsworn")); + + BloodPerms.clear(mage); + assertFalse(mage.hasPermission("magic.blood.bloodsworn")); + BloodPerms.clear(mage); + + BloodPerms.refreshAll(); + assertTrue(mage.hasPermission("magic.blood.bloodsworn")); + mage.getEffectivePermissions().stream().map(p -> p.getAttachment()).filter(a -> a != null).distinct().toList() + .forEach(mage::removeAttachment); + BloodPerms.clear(mage); + assertFalse(BloodPerms.holds(mage.getUniqueId())); + + record.setPath(BloodPath.TRUE); + BloodPerms.refresh(mage); + assertFalse(BloodPerms.holds(mage.getUniqueId())); + record.setPath(BloodPath.DEVOTEE); + BloodPerms.refresh(mage); + BloodPerms.clearAll(); + assertFalse(mage.hasPermission("magic.blood.bloodsworn")); + } + + @Test + @SuppressWarnings("unchecked") + void closingAnInventoryMarksSkinnedBloodPiecesAgain() { + PlayerMock mage = fx.player("mage"); + ItemStack piece = BloodArmour.bind(new ItemStack(Material.LEATHER_HELMET), BloodFixture.id("mage")); + mage.getInventory().setItem(3, piece); + mage.getInventory().setItem(4, new ItemStack(Material.STONE)); + BloodListener listener = new BloodListener(); + InventoryView view = mock(InventoryView.class); + when(view.getPlayer()).thenReturn(mage); + listener.onInventoryClose(new InventoryCloseEvent(view)); + assertEquals(piece, mage.getInventory().getItem(3)); + + MockBukkit.createMockPlugin("NBTAPI"); + try (var nbt = mockStatic(NBT.class)) { + nbt.when(() -> NBT.modify(any(ItemStack.class), any(Consumer.class))).thenAnswer(i -> { + ItemStack item = i.getArgument(0); + ItemMeta meta = item.getItemMeta(); + meta.setLore(List.of("marked")); + item.setItemMeta(meta); + return null; + }); + listener.onInventoryClose(new InventoryCloseEvent(view)); + assertEquals(List.of("marked"), mage.getInventory().getItem(3).getItemMeta().getLore()); + assertTrue(BloodArmour.is(mage.getInventory().getItem(3))); + assertEquals(Material.STONE, mage.getInventory().getItem(4).getType()); + + InventoryView other = mock(InventoryView.class); + when(other.getPlayer()).thenReturn(mock(HumanEntity.class)); + listener.onInventoryClose(new InventoryCloseEvent(other)); + nbt.verify(() -> NBT.modify(any(ItemStack.class), any(Consumer.class)), times(1)); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodStoreTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodStoreTest.java new file mode 100644 index 0000000..ef91e0c --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodStoreTest.java @@ -0,0 +1,170 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.logging.Logger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; + +class BloodStoreTest { + @TempDir Path temp; + final Logger log = Logger.getAnonymousLogger(); + + @AfterEach + void clear() { + BloodRecords.clear(); + BloodAltars.clear(); + } + + File characters() { + return temp.resolve("characters").toFile(); + } + + @Test + void recordsRoundTripAndEmptyRecordsLeaveDisk() throws Exception { + BloodStore store = new BloodStore(temp.toFile(), log); + assertTrue(store.loadRecords().isEmpty()); + BloodRecord record = BloodRecord.create("c1", "o1"); + record.setStage(BloodStage.ANIMAL); + record.addSpecies("cow"); + assertTrue(store.save(record)); + List loaded = store.loadRecords(); + assertEquals(1, loaded.size()); + assertEquals(BloodStage.ANIMAL, loaded.get(0).getStage()); + assertTrue(loaded.get(0).hasSpecies("cow")); + assertTrue(store.save(BloodRecord.create("c1", "o1"))); + assertFalse(new File(characters(), "c1.json").exists()); + assertTrue(store.save(BloodRecord.create("never", "o1"))); + } + + @Test + void recordWithoutIdTakesTheFileNameAndUnreadableFilesMoveAside() throws Exception { + Files.createDirectories(characters().toPath()); + Files.writeString(characters().toPath().resolve("named.json"), "{\"stage\":\"HUNT\"}"); + Files.writeString(characters().toPath().resolve("blank.json"), "{\"characterId\":\" \",\"stage\":\"ANIMAL\"}"); + Files.writeString(characters().toPath().resolve("broken.json"), "{not json"); + Files.writeString(characters().toPath().resolve("null.json"), "null"); + Files.writeString(characters().toPath().resolve("skip.txt"), "x"); + BloodStore store = new BloodStore(temp.toFile(), log); + List loaded = store.loadRecords(); + assertEquals(2, loaded.size()); + loaded.sort(java.util.Comparator.comparing(BloodRecord::getCharacterId)); + assertEquals("blank", loaded.get(0).getCharacterId()); + assertEquals("named", loaded.get(1).getCharacterId()); + assertEquals(BloodStage.HUNT, loaded.get(1).getStage()); + String[] names = characters().list(); + assertEquals(2, java.util.Arrays.stream(names).filter(n -> n.contains(".rejected-")).count()); + } + + @Test + void anUnmovableUnreadableFileIsReportedAndSkipped() throws Exception { + Files.createDirectories(characters().toPath()); + Files.writeString(characters().toPath().resolve("broken.json"), "{"); + BloodStore store = new BloodStore(temp.toFile(), log); + try (var files = mockStatic(Files.class, Mockito.CALLS_REAL_METHODS)) { + files.when(() -> Files.move(any(Path.class), any(Path.class))).thenThrow(new IOException("locked")); + assertTrue(store.loadRecords().isEmpty()); + } + assertTrue(new File(characters(), "broken.json").exists()); + } + + @Test + void writeAndDeleteFailuresReturnFalse() throws Exception { + BloodStore store = new BloodStore(temp.toFile(), log); + Path blocker = characters().toPath().resolve("c2.json"); + Files.createDirectories(blocker); + Files.writeString(blocker.resolve("inside"), "x"); + BloodRecord record = BloodRecord.create("c2", "o"); + assertFalse(store.save(record)); + record.setStage(BloodStage.WHISPERED); + assertFalse(store.save(record)); + } + + @Test + void altarsRoundTripSkipIncompleteEntriesAndSurviveBadFiles() throws Exception { + try (BloodFixture fx = new BloodFixture(temp.resolve("fx"))) { + BloodStore store = new BloodStore(temp.toFile(), log); + assertTrue(store.loadAltars().isEmpty()); + BloodAltar altar = BloodAltar.create("c", "o", fx.world.getBlockAt(1, 2, 3), 0f, 1L); + assertTrue(store.saveAltars(List.of(altar))); + Path file = temp.resolve("altars.json"); + String json = Files.readString(file); + Files.writeString(file, json.replaceFirst("\\[", "[null, {\"id\":\"x\"},")); + List loaded = store.loadAltars(); + assertEquals(1, loaded.size()); + assertEquals("cave,1,2,3", loaded.get(0).blockKey()); + Files.writeString(file, "["); + assertTrue(store.loadAltars().isEmpty()); + assertFalse(file.toFile().exists()); + } + } + + @Test + void registriesIndexAndForget() throws Exception { + try (BloodFixture fx = new BloodFixture(temp.resolve("fx"))) { + BloodStore store = new BloodStore(temp.resolve("reg").toFile(), log); + BloodRecords.init(store); + BloodAltars.init(store); + assertNull(BloodRecords.get(null)); + BloodRecord record = BloodRecords.getOrCreate("ABC", "o"); + assertSame(record, BloodRecords.get("abc")); + assertTrue(BloodRecords.save(record)); + assertNull(BloodRecords.get("abc")); + record = BloodRecords.getOrCreate("abc", "o"); + record.setStage(BloodStage.ANIMAL); + assertTrue(BloodRecords.save(record)); + assertEquals(1, BloodRecords.all().size()); + BloodRecords.init(store); + assertEquals(BloodStage.ANIMAL, BloodRecords.get("abc").getStage()); + assertTrue(BloodRecords.delete("abc")); + assertTrue(BloodRecords.delete("abc")); + assertTrue(store.loadRecords().isEmpty()); + + var block = fx.world.getBlockAt(40, 5, 40); + BloodAltar altar = BloodAltar.create("Owner", "o", block, 0f, 1L); + assertTrue(BloodAltars.add(altar)); + assertSame(altar, BloodAltars.at(block)); + assertNull(BloodAltars.at(null)); + assertSame(altar, BloodAltars.byId(altar.getId())); + assertNull(BloodAltars.byId(null)); + assertSame(altar, BloodAltars.ofCharacter("owner")); + assertNull(BloodAltars.ofCharacter(null)); + assertEquals(List.of(altar), BloodAltars.inChunk("cave", 2, 2)); + assertTrue(BloodAltars.inChunk("cave", 0, 0).isEmpty()); + BloodAltars.init(store); + assertEquals(1, BloodAltars.all().size()); + assertTrue(BloodAltars.remove(BloodAltars.all().get(0))); + assertTrue(BloodAltars.all().isEmpty()); + + BloodRecords.clear(); + BloodAltars.clear(); + record = BloodRecords.getOrCreate("x", "o"); + record.setStage(BloodStage.ANIMAL); + assertTrue(BloodRecords.save(record)); + assertTrue(BloodRecords.delete("x")); + assertTrue(BloodAltars.add(altar)); + assertTrue(BloodAltars.save()); + + Path failing = temp.resolve("failing"); + BloodStore broken = new BloodStore(failing.toFile(), log); + BloodRecords.init(broken); + BloodAltars.init(broken); + Files.createDirectories(failing.resolve("altars.json/inside")); + assertFalse(BloodAltars.add(altar)); + record = BloodRecords.getOrCreate("y", "o"); + record.setStage(BloodStage.ANIMAL); + Files.createDirectories(failing.resolve("characters/y.json/inside")); + assertFalse(BloodRecords.delete("y")); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java b/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java new file mode 100644 index 0000000..f9db7f1 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java @@ -0,0 +1,222 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import io.lumine.mythic.lib.api.player.MMOPlayerData; +import io.lumine.mythic.lib.api.stat.StatInstance; +import io.lumine.mythic.lib.api.stat.StatMap; +import io.lumine.mythic.lib.api.stat.modifier.StatModifier; +import net.tfminecraft.magic.integration.RpCharactersBridge; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.tlibs.character.CharacterDeathEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.event.player.PlayerInteractEntityEvent; +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 org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.command.ConsoleCommandSenderMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +class BoonsAndToolsTest { + @TempDir Path temp; + BloodFixture fx; + PlayerMock mage; + String mageId; + BloodRecord record; + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + BondService.init(new BloodStore(temp.resolve("b").toFile(), fx.logger)); + mage = fx.server.addPlayer("Mage"); + fx.characters.start(mage, "mage"); + mageId = BloodFixture.id("mage"); + record = BloodRecords.getOrCreate(mageId, mage.getUniqueId().toString()); + record.setStage(BloodStage.FINAL); + record.setPath(BloodPath.TRUE); + } + + @AfterEach + void cleanup() { + BondService.clear(); + CharacterRiteService.clear(); + fx.close(); + } + + @Test + void boonsNeedMythicLibAndReplaceEachOther() { + record.setFinalsDone(4); // health and spell damage: two stats + BloodBoons.apply(mage); + BloodBoons.clear(mage); + MockBukkit.createMockPlugin("MythicLib"); + try (MockedStatic players = mockStatic(MMOPlayerData.class); + MockedConstruction modifiers = mockConstruction(StatModifier.class)) { + BloodBoons.apply(mage); + assertTrue(modifiers.constructed().isEmpty()); + MMOPlayerData data = mock(MMOPlayerData.class); + StatMap map = mock(StatMap.class); + StatInstance instance = mock(StatInstance.class); + when(data.getStatMap()).thenReturn(map); + when(map.getInstances()).thenReturn(List.of(instance)); + players.when(() -> MMOPlayerData.getOrNull(mage)).thenReturn(data); + BloodBoons.apply(mage); + assertEquals(2, modifiers.constructed().size()); + verify(modifiers.constructed().get(0)).register(data); + BloodBoons.apply(mage); + verify(instance, times(2)).remove(MythicBoons.KEY); + BloodBoons.clear(mage); + BloodBoons.clear(mage); + record.setFinalsDone(0); + BloodBoons.apply(mage); + assertEquals(4, modifiers.constructed().size()); + PlayerMock bare = fx.server.addPlayer(); + BloodBoons.apply(bare); + record.setFinalsDone(1); + BloodBoons.apply(mage); + players.when(() -> MMOPlayerData.getOrNull(mage)).thenReturn(null); + BloodBoons.clear(mage); + BloodBoons.apply(mage); + } + } + + @Test + void theDevoteesMarkTakesAllManaWhileTheCharacterBearsIt() throws Exception { + MockBukkit.createMockPlugin("MythicLib"); + List> made = new java.util.ArrayList<>(); + try (MockedStatic players = mockStatic(MMOPlayerData.class); + MockedConstruction modifiers = mockConstruction(StatModifier.class, + (modifier, context) -> made.add(context.arguments())); + MockedStatic bridge = mockStatic(RpCharactersBridge.class)) { + record.setFinalsDone(0); + bridge.when(() -> RpCharactersBridge.hasTrait(mage, "manaless")).thenReturn(true); + BloodBoons.apply(mage); + assertTrue(made.isEmpty(), "No MythicLib data yet"); + MMOPlayerData data = mock(MMOPlayerData.class); + StatMap map = mock(StatMap.class); + when(data.getStatMap()).thenReturn(map); + when(map.getInstances()).thenReturn(List.of()); + players.when(() -> MMOPlayerData.getOrNull(mage)).thenReturn(data); + BloodBoons.apply(mage); + assertEquals(List.of(List.of(MythicBoons.KEY, "MAX_MANA", -100.0, + io.lumine.mythic.lib.player.modifier.ModifierType.RELATIVE)), made); + verify(modifiers.constructed().get(0)).register(data); + java.nio.file.Files.writeString(temp.resolve("off.yml"), "enabled: true\nchoice:\n devotee_trait: ''\n"); + BloodConfig.load(temp.resolve("off.yml").toFile(), fx.logger); + BloodBoons.apply(mage); + assertEquals(1, made.size(), "No mark configured: mana stays"); + } + } + + @Test + void staffTargetAndBondTools() { + ConsoleCommandSenderMock console = (ConsoleCommandSenderMock) fx.server.getConsoleSender(); + UUID friend = UUID.randomUUID(); + CharacterInfo friendInfo = new CharacterInfo(new CharacterRef(UUID.randomUUID(), friend), "Friend", + CharacterStatus.ALIVE, null, Set.of(), 0, Instant.now()); + CharacterInfo deadInfo = new CharacterInfo(new CharacterRef(UUID.randomUUID(), UUID.randomUUID()), "Friend", + CharacterStatus.DEAD, null, Set.of(), 0, Instant.now()); + try (MockedStatic chars = mockStatic(Characters.class, CALLS_REAL_METHODS); + MockedStatic bridge = mockStatic(RpCharactersBridge.class)) { + chars.when(() -> Characters.info(friend)).thenReturn(Optional.of(friendInfo)); + chars.when(() -> Characters.findByName("Friend")).thenReturn(List.of(deadInfo, friendInfo)); + chars.when(() -> Characters.findByName("Nobody")).thenReturn(List.of(deadInfo)); + bridge.when(() -> RpCharactersBridge.conversationCounts(any())).thenReturn(Map.of(friend.toString(), 4)); + + assertTrue(BloodCommand.execute(console, new String[] {"blood", "target", "Mage"})); + assertEquals("blood.admin.usage", console.nextMessage()); + BloodCommand.execute(console, new String[] {"blood", "target", "Mage", "dance"}); + assertEquals("blood.admin.usage", console.nextMessage()); + BloodCommand.execute(console, new String[] {"blood", "target", "Mage", "set"}); + assertEquals("blood.admin.usage", console.nextMessage()); + BloodCommand.execute(console, new String[] {"blood", "target", "Mage", "list"}); + assertEquals("blood.admin.targets", console.nextMessage()); + BloodCommand.execute(console, new String[] {"blood", "target", "Mage", "reroll"}); + assertEquals("blood.admin.targets", console.nextMessage()); + assertEquals(List.of(friend.toString()), record.getFinalTargets()); + BloodCommand.execute(console, new String[] {"blood", "target", "Mage", "set", "Nobody"}); + assertEquals("blood.admin.no_character_named", console.nextMessage()); + BloodCommand.execute(console, new String[] {"blood", "target", "Mage", "set", "Friend"}); + assertEquals("blood.admin.targets", console.nextMessage()); + BloodCommand.execute(console, new String[] {"blood", "target", "Mage", "skip"}); + assertEquals("blood.admin.targets", console.nextMessage()); + assertEquals(1, record.getFinalsDone()); + record.setStage(BloodStage.HUNT); + BloodCommand.execute(console, new String[] {"blood", "target", "Mage", "list"}); + assertEquals("blood.admin.not_final", console.nextMessage()); + BloodRecords.delete(mageId); + BloodCommand.execute(console, new String[] {"blood", "target", "Mage", "list"}); + assertEquals("blood.admin.not_final", console.nextMessage()); + + BloodCommand.execute(console, new String[] {"blood", "bonds", "Mage"}); + assertEquals("blood.admin.bonds_header", console.nextMessage()); + assertEquals("blood.admin.bonds_line", console.nextMessage()); + } + assertEquals(List.of("list", "reroll", "set", "skip"), + BloodCommand.complete(new String[] {"blood", "target", "Mage", ""})); + } + + @Test + void listenerRoutesBloodKnifeClicksSessionsAndDeaths() { + BloodListener listener = new BloodListener(); + PlayerMock victim = fx.server.addPlayer(); + fx.characters.start(victim, "victim"); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, mageId)); + PlayerInteractEntityEvent click = new PlayerInteractEntityEvent(mage, victim, EquipmentSlot.HAND); + listener.onInteractEntity(click); + assertTrue(click.isCancelled()); + assertEquals("blood.rite.bind.no_altar", mage.nextMessage()); + PlayerInteractEntityEvent offHand = new PlayerInteractEntityEvent(mage, victim, EquipmentSlot.OFF_HAND); + listener.onInteractEntity(offHand); + assertFalse(offHand.isCancelled()); + + String target = BloodFixture.id("victim"); + record.getFinalTargets().add(target); + try (MockedStatic bridge = mockStatic(RpCharactersBridge.class)) { + listener.onCharacterDeath(new CharacterDeathEvent(new CharacterRef(victim.getUniqueId(), UUID.fromString(target)), + UUID.randomUUID(), Instant.EPOCH, null, null, "x")); + assertTrue(record.getFinalTargets().isEmpty()); + bridge.verify(() -> RpCharactersBridge.applyHealingInjury(mage)); + } + + BondService.add(mageId, target, 5); + CharacterSession session = new CharacterSession(new CharacterRef(mage.getUniqueId(), UUID.fromString(mageId)), 7); + listener.onSessionEnd(new CharacterSessionEndEvent(mage, session, CharacterSessionEndEvent.Reason.SWITCH)); + assertEquals(5, BondService.minutes(mageId, target)); + } + + @Test + void magicTicksSampleFlushAndCheckFinals() { + PlayerMock friend = fx.server.addPlayer(); + fx.characters.start(friend, "friend"); + friend.teleport(mage.getLocation()); + var original = WhisperService.equilibrium; + WhisperService.equilibrium = p -> Double.NaN; + try { + BloodMagic.tick(net.tfminecraft.magic.tick.MagicTickContext.between(599, 600)); + } finally { + WhisperService.equilibrium = original; + } + assertEquals(5, BondService.minutes(mageId, BloodFixture.id("friend"))); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/CarryTest.java b/src/test/java/net/tfminecraft/magic/blood/CarryTest.java new file mode 100644 index 0000000..492fd58 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/CarryTest.java @@ -0,0 +1,343 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicLong; + +import net.tfminecraft.magic.integration.RpCharactersBridge; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Player; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.potion.PotionEffectType; +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 org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +class CarryTest { + @TempDir Path temp; + BloodFixture fx; + MockedStatic bridge; + final AtomicLong now = new AtomicLong(1_000_000_000_000L); + PlayerMock mage; + PlayerMock victim; + String mageId; + BloodRecord record; + Block altarBlock; + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + BondService.init(new BloodStore(temp.resolve("bonds").toFile(), fx.logger)); + CharacterRiteService.clock = now::get; + bridge = mockStatic(RpCharactersBridge.class); + bridge.when(() -> RpCharactersBridge.isDown(any())).thenAnswer(i -> i.getArgument(0) != mage); + bridge.when(() -> RpCharactersBridge.playtimeSeconds(any())).thenReturn(100L * 3600); + bridge.when(() -> RpCharactersBridge.characterAgeSeconds(any())).thenReturn(100L * 86_400); + bridge.when(() -> RpCharactersBridge.conversationCounts(any())).thenAnswer(i -> new java.util.HashMap<>()); + bridge.when(() -> RpCharactersBridge.moveDowned(any(), any())).thenAnswer(i -> { + Player body = i.getArgument(0); + return body.teleport((Location) i.getArgument(1)); + }); + mage = fx.player("mage"); + mageId = BloodFixture.id("mage"); + record = BloodRecords.getOrCreate(mageId, mage.getUniqueId().toString()); + record.setStage(BloodStage.HUNT); + record.setPath(BloodPath.TRUE); + fx.open(-30, 10, -3, 45, 12, 4); + altarBlock = fx.pocket(0, 10, 0); + BloodAltars.add(BloodAltar.create(mageId, mage.getUniqueId().toString(), altarBlock, 0f, 1L)); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, mageId)); + mage.teleport(new Location(fx.world, 20.5, 11, 0.5)); + victim = fx.player("victim"); + victim.teleport(new Location(fx.world, 21.5, 11, 0.5)); + } + + @AfterEach + void cleanup() { + CarryService.clear(); + CharacterRiteService.clear(); + BondService.clear(); + CharacterRiteService.clock = System::currentTimeMillis; + bridge.close(); + fx.close(); + } + + void walk(double dx) { + mage.teleport(mage.getLocation().clone().add(dx, 0, 0)); + CarryService.tick(); + } + + @Test + void takingHoldChecksTheMageAndTheBody() { + PlayerMock nobody = fx.server.addPlayer(); + assertEquals(CharacterRiteService.Bind.NOT_STAGE, CarryService.start(nobody, victim)); + mage.getInventory().setItemInMainHand(new ItemStack(Material.STONE)); + assertEquals(CharacterRiteService.Bind.NOT_KNIFE, CarryService.start(mage, victim)); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, mageId)); + PlayerMock bare = fx.server.addPlayer(); + assertEquals(CharacterRiteService.Bind.NO_CHARACTER, CarryService.start(mage, bare)); + bridge.when(() -> RpCharactersBridge.isDown(victim)).thenReturn(false); + assertEquals(CharacterRiteService.Bind.NOT_DOWN, CarryService.start(mage, victim)); + bridge.when(() -> RpCharactersBridge.isDown(victim)).thenReturn(true); + + assertNull(CarryService.carried(mage)); + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(mage, victim)); + assertEquals("blood.carry.start", mage.nextMessage()); + assertEquals("blood.carry.dragged", victim.nextMessage()); + assertSame(victim, CarryService.carried(mage)); + assertEquals(mage.getUniqueId(), CarryService.carrier(victim.getUniqueId())); + assertEquals(CharacterRiteService.Bind.BUSY, CarryService.start(mage, fx.player("other"))); + + PlayerMock second = fx.player("second-mage"); + String secondId = BloodFixture.id("second-mage"); + BloodRecords.getOrCreate(secondId, "o").setStage(BloodStage.FINAL); + second.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, secondId)); + assertEquals(CharacterRiteService.Bind.NOT_TARGET, CarryService.start(second, victim)); + BloodRecords.get(secondId).setStage(BloodStage.HUNT); + assertEquals(CharacterRiteService.Bind.BUSY, CarryService.start(second, victim)); + + CarryService.release(mage, true); + assertEquals("blood.carry.released", mage.nextMessage()); + CarryService.release(mage, true); + assertNull(mage.nextMessage()); + assertNull(CarryService.carrier(victim.getUniqueId())); + + PlayerMock onAltar = fx.player("on-altar"); + onAltar.teleport(new Location(fx.world, 1.5, 10, 0.5)); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, onAltar)); + assertEquals(CharacterRiteService.Bind.BUSY, CarryService.start(second, onAltar)); + + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(second, victim)); + assertNull(CarryService.carrier(onAltar.getUniqueId())); + victim.disconnect(); + assertNull(CarryService.carried(second)); + second.nextMessage(); + CarryService.tick(); + assertEquals("blood.carry.broken", second.nextMessage()); + assertNull(CarryService.carrier(victim.getUniqueId())); + } + + @Test + void theBodyTrailsBehindAlongThePathWalked() { + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(mage, victim)); + CarryService.tick(); + assertEquals(21.5, victim.getLocation().getX(), 1e-9); + assertTrue(mage.hasPotionEffect(PotionEffectType.SLOWNESS)); + walk(-0.1); + walk(-0.5); + walk(-0.5); + walk(-0.5); + assertEquals(20.5, victim.getLocation().getX(), 1e-9); + CarryService.tick(); + assertEquals(20.5, victim.getLocation().getX(), 1e-9); + for (int i = 0; i < 50; i++) { + walk(-0.5); + } + assertTrue(mage.getLocation().getX() - victim.getLocation().getX() <= -1.2 + 1e-9); + assertTrue(victim.getLocation().distance(mage.getLocation()) < 2); + + World other = fx.server.addSimpleWorld("other"); + mage.teleport(new Location(other, 0.5, 64, 0.5)); + victim.teleport(new Location(other, 0.5, 64, 1.5)); + CarryService.tick(); + assertSame(victim, CarryService.carried(mage)); + walk(-2); + assertEquals(0.5, victim.getLocation().getX(), 1e-9); + assertEquals(64, victim.getLocation().getY(), 1e-9); + } + + @Test + void noSlownessWhenTurnedOff() throws Exception { + Files.writeString(temp.resolve("c.yml"), "enabled: true\nhunt:\n carry: { slowness: 0, distance: 0.1, break_distance: 0 }\n"); + BloodConfig.load(temp.resolve("c.yml").toFile(), fx.logger); + assertEquals(0.5, HuntConfig.carryDistance()); + assertEquals(1.5, HuntConfig.carryBreakDistance()); + assertEquals(0, HuntConfig.carrySlowness()); + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(mage, victim)); + CarryService.tick(); + assertFalse(mage.hasPotionEffect(PotionEffectType.SLOWNESS)); + } + + @Test + void theDragBreaksWhenAnythingChanges() { + Runnable[] breakers = { + () -> fx.characters.start(mage, "mage-alt"), + () -> fx.characters.start(victim, "victim-alt"), + () -> bridge.when(() -> RpCharactersBridge.isDown(victim)).thenReturn(false), + () -> bridge.when(() -> RpCharactersBridge.isDown(mage)).thenReturn(true), + () -> victim.teleport(new Location(fx.server.addSimpleWorld("w" + now.incrementAndGet()), 0, 64, 0)), + () -> victim.teleport(new Location(fx.world, 40.5, 11, 0.5)), + }; + for (Runnable breaker : breakers) { + fx.characters.start(mage, "mage"); + fx.characters.start(victim, "victim"); + bridge.when(() -> RpCharactersBridge.isDown(any())).thenAnswer(i -> i.getArgument(0) != mage); + victim.teleport(new Location(fx.world, 21.5, 11, 0.5)); + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(mage, victim)); + mage.nextMessage(); + victim.nextMessage(); + breaker.run(); + CarryService.tick(); + assertNull(CarryService.carried(mage)); + assertEquals("blood.carry.broken", mage.nextMessage()); + } + fx.characters.start(mage, "mage"); + fx.characters.start(victim, "victim"); + bridge.when(() -> RpCharactersBridge.isDown(any())).thenAnswer(i -> i.getArgument(0) != mage); + victim.teleport(new Location(fx.world, 21.5, 11, 0.5)); + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(mage, victim)); + mage.disconnect(); + CarryService.tick(); + assertNull(CarryService.carrier(victim.getUniqueId())); + } + + @Test + void leavingLetsGoAndTheTimerRuns() { + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(mage, victim)); + mage.nextMessage(); + CarryService.drop(victim.getUniqueId()); + assertEquals("blood.carry.broken", mage.nextMessage()); + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(mage, victim)); + CarryService.drop(mage.getUniqueId()); + assertNull(CarryService.carrier(victim.getUniqueId())); + CarryService.drop(mage.getUniqueId()); + PlayerMock gone = fx.player("gone"); + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(mage, gone)); + gone.disconnect(); + while (mage.nextMessage() != null) { + // drain earlier lines + } + CarryService.release(mage, true); + assertEquals("blood.carry.released", mage.nextMessage()); + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(mage, victim)); + mage.disconnect(); + CarryService.drop(victim.getUniqueId()); + assertNull(CarryService.carrier(victim.getUniqueId())); + + var plugin = org.mockbukkit.mockbukkit.MockBukkit.createMockPlugin("CarryTicks"); + CarryService.startTicking(plugin); + CarryService.startTicking(plugin); + PlayerMock again = fx.player("again"); + String againId = BloodFixture.id("again"); + BloodRecords.getOrCreate(againId, "o").setStage(BloodStage.HUNT); + again.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, againId)); + again.teleport(new Location(fx.world, 21.5, 11, 2.5)); + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(again, victim)); + bridge.when(() -> RpCharactersBridge.isDown(again)).thenReturn(false); + fx.server.getScheduler().performTicks(4); + assertTrue(again.hasPotionEffect(PotionEffectType.SLOWNESS)); + CarryService.clear(); + CarryService.clear(); + } + + @Test + void listenerTakesDragsAndLaysTheBodyDown() { + BloodListener listener = new BloodListener(); + mage.setSneaking(true); + PlayerInteractEntityEvent grab = new PlayerInteractEntityEvent(mage, victim, EquipmentSlot.HAND); + listener.onInteractEntity(grab); + assertTrue(grab.isCancelled()); + assertSame(victim, CarryService.carried(mage)); + assertEquals("blood.carry.start", mage.nextMessage()); + PlayerMock other = fx.player("other"); + listener.onInteractEntity(new PlayerInteractEntityEvent(mage, other, EquipmentSlot.HAND)); + assertEquals("blood.rite.bind.busy", mage.nextMessage()); + listener.onInteractEntity(new PlayerInteractEntityEvent(mage, victim, EquipmentSlot.HAND)); + assertEquals("blood.carry.released", mage.nextMessage()); + assertNull(CarryService.carried(mage)); + + listener.onInteractEntity(new PlayerInteractEntityEvent(mage, victim, EquipmentSlot.HAND)); + mage.nextMessage(); + mage.setSneaking(false); + listener.onInteractEntity(new PlayerInteractEntityEvent(mage, victim, EquipmentSlot.HAND)); + assertEquals("blood.rite.bind.too_far", mage.nextMessage()); + assertSame(victim, CarryService.carried(mage)); + + mage.getInventory().setItemInMainHand(new ItemStack(Material.STONE)); + PlayerInteractEvent touch = new PlayerInteractEvent(mage, Action.RIGHT_CLICK_BLOCK, mage.getInventory().getItemInMainHand(), + altarBlock, BlockFace.UP, EquipmentSlot.HAND); + listener.onInteract(touch); + assertNotEquals("blood.rite.bind.ok", mage.nextMessage()); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, mageId)); + victim.teleport(new Location(fx.world, 2.5, 10, 0.5)); + PlayerInteractEvent lay = new PlayerInteractEvent(mage, Action.RIGHT_CLICK_BLOCK, mage.getInventory().getItemInMainHand(), + altarBlock, BlockFace.UP, EquipmentSlot.HAND); + listener.onInteract(lay); + assertEquals("blood.rite.bind.ok", mage.nextMessage()); + assertNull(CarryService.carried(mage)); + assertTrue(CharacterRiteService.hasRite(mage.getUniqueId())); + assertEquals(0.5, victim.getLocation().getX(), 1e-9); + assertEquals(11.0, victim.getLocation().getY(), 1e-9); + CharacterRiteService.clear(); + + PlayerMock third = fx.player("third"); + third.teleport(new Location(fx.world, 1.5, 10, 1.5)); + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(mage, third)); + mage.nextMessage(); + listener.onInteractEntity(new PlayerInteractEntityEvent(mage, third, EquipmentSlot.HAND)); + assertEquals("blood.rite.bind.ok", mage.nextMessage()); + CharacterRiteService.clear(); + + try { + BloodConfig.load(writeCarryOff(), fx.logger); + } catch (Exception ex) { + fail(ex); + } + mage.setSneaking(true); + PlayerMock fourth = fx.player("fourth"); + fourth.teleport(new Location(fx.world, 30.5, 11, 0.5)); + listener.onInteractEntity(new PlayerInteractEntityEvent(mage, fourth, EquipmentSlot.HAND)); + assertEquals("blood.rite.bind.too_far", mage.nextMessage()); + assertNull(CarryService.carried(mage)); + assertFalse(HuntConfig.carryEnabled()); + + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(mage, fourth)); + assertEquals("blood.carry.start", mage.nextMessage()); + listener.onQuit(new PlayerQuitEvent(fourth, net.kyori.adventure.text.Component.empty(), + PlayerQuitEvent.QuitReason.DISCONNECTED)); + assertNull(CarryService.carrier(fourth.getUniqueId())); + assertEquals("blood.carry.broken", mage.nextMessage()); + } + + File writeCarryOff() throws Exception { + Path file = temp.resolve("off.yml"); + Files.writeString(file, "enabled: true\nhunt:\n carry: { enabled: false }\n"); + return file.toFile(); + } + + @Test + void theRiteBreaksWhenTheBodyGetsAway() { + victim.teleport(new Location(fx.world, 1.5, 10, 0.5)); + mage.teleport(new Location(fx.world, 2.5, 10, 0.5)); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, victim)); + victim.teleport(new Location(fx.world, 5.5, 10, 0.5)); + CharacterRiteService.tick(); + assertFalse(CharacterRiteService.hasRite(mage.getUniqueId())); + assertEquals("blood.rite.broken", mage.nextMessage()); + victim.teleport(new Location(fx.world, 1.5, 10, 0.5)); + record.setLastVictimAccount(null); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, victim)); + victim.teleport(new Location(fx.server.addSimpleWorld("away"), 0.5, 64, 0.5)); + CharacterRiteService.tick(); + assertFalse(CharacterRiteService.hasRite(mage.getUniqueId())); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java new file mode 100644 index 0000000..7b0fe16 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java @@ -0,0 +1,386 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import net.tfminecraft.magic.integration.RpCharactersBridge; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +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 org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +class ChoiceTest { + @TempDir Path temp; + BloodFixture fx; + PlayerMock mage; + String id; + BloodRecord record; + MockedStatic bridge; + + /** Stands in for RPCharacters' ClassUnlocks. */ + public static final class FakeUnlocks { + static boolean answer = true; + static String last; + + private FakeUnlocks() {} + + public static boolean assign(Player player, String classId) { + last = classId; + return answer; + } + } + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + mage = fx.player("mage"); + id = BloodFixture.id("mage"); + record = fx.record("mage", BloodStage.CHOICE); + record.addSpecies("cow"); + BloodAltars.add(BloodAltar.create(id, "o", fx.pocket(0, 10, 0), 0f, 1L)); + ClassUnlockBridge.unlocksClass = FakeUnlocks.class.getName(); + FakeUnlocks.answer = true; + FakeUnlocks.last = null; + bridge = mockStatic(RpCharactersBridge.class); + bridge.when(() -> RpCharactersBridge.applyTrait(any(), any())).thenReturn(true); + } + + @AfterEach + void cleanup() { + bridge.close(); + ClassUnlockBridge.unlocksClass = "net.tfminecraft.rpcharacters.classpick.ClassUnlocks"; + WhisperService.clear(); + QuestionService.clear(); + QuestionService.clock = System::currentTimeMillis; + fx.close(); + } + + final java.util.concurrent.atomic.AtomicLong now = new java.util.concurrent.atomic.AtomicLong(1_000_000L); + + /** The god's question in the mage's chamber: altar in the middle of an open room. */ + void chamber() { + BloodAltars.remove(BloodAltars.ofCharacter(id)); + BloodAltars.add(BloodAltar.create(id, "o", fx.room(0, 10, 0), 0f, 1L)); + mage.teleport(new org.bukkit.Location(fx.world, 2.5, 10, 2.5)); + QuestionService.clock = now::get; + } + + /** Lets the dialogue run a while, one tick a second. */ + void wait(int seconds) { + for (int i = 0; i < seconds; i++) { + now.addAndGet(1000L); + QuestionService.tick(); + } + } + + java.util.List heard() { + java.util.List out = new java.util.ArrayList<>(); + for (String m = mage.nextMessage(); m != null; m = mage.nextMessage()) { + out.add(WhisperServiceTest.plain(m)); + } + return out; + } + + void say(String words) { + QuestionService.onChat(mage, "rp", words); + } + + @Test + void bundledQuestionClassesAndArmourLoad() { + assertEquals(5, ChoiceConfig.ask().size()); + assertEquals(WhisperConfig.Speaker.SELF, ChoiceConfig.ask().get(3).speaker()); + assertNotNull(ChoiceConfig.unclear()); + assertNotNull(ChoiceConfig.walkedAway()); + assertEquals(90, ChoiceConfig.answerSeconds()); + assertEquals(5, ChoiceConfig.lineSeconds()); + assertEquals("tfmc_blood:rite.question", ChoiceConfig.music()); + assertEquals(91, ChoiceConfig.musicLoopSeconds()); + assertTrue(ChoiceConfig.answer(BloodPath.DEVOTEE).words().contains("i kneel")); + assertEquals(2, ChoiceConfig.answer(BloodPath.TRUE).sealed().size()); + assertEquals("manaless", ChoiceConfig.devoteeTrait()); + assertEquals("bloodsworn", ChoiceConfig.classFor(BloodPath.DEVOTEE)); + assertEquals("sanguinar", ChoiceConfig.classFor(BloodPath.TRUE)); + assertNull(ChoiceConfig.classFor(BloodPath.NONE)); + assertEquals(4, ChoiceConfig.armourFor(BloodPath.TRUE).size()); + assertTrue(ChoiceConfig.armourFor(BloodPath.NONE).isEmpty()); + } + + @Test + void missingSectionsFallBack() throws Exception { + Files.writeString(temp.resolve("e.yml"), String.join("\n", "enabled: true", "choice:", " devotee_trait: ' '", + " answer_seconds: 1", " line_seconds: 0", " music: { sound: ' x:y ', category: nope, volume: -1 }", + " ask: [plain, { god: ' ' }, { who: hi }, { god: a, self: b }, { self: mine }]", + "classes:", " devotee: ' '", " 'true': [x]", "")); + BloodConfig.load(temp.resolve("e.yml").toFile(), fx.logger); + assertEquals("", ChoiceConfig.devoteeTrait()); + assertEquals(5, ChoiceConfig.answerSeconds()); + assertEquals(1, ChoiceConfig.lineSeconds()); + assertEquals("x:y", ChoiceConfig.music()); + assertEquals(org.bukkit.SoundCategory.RECORDS, ChoiceConfig.musicCategory()); + assertEquals(0f, ChoiceConfig.musicVolume()); + assertEquals(1, ChoiceConfig.ask().size()); + assertNull(ChoiceConfig.unclear()); + assertTrue(ChoiceConfig.answer(BloodPath.DEVOTEE).words().isEmpty()); + assertNull(ChoiceConfig.classFor(BloodPath.DEVOTEE)); + assertNull(ChoiceConfig.classFor(BloodPath.TRUE)); + assertTrue(ChoiceConfig.armourFor(BloodPath.DEVOTEE).isEmpty()); + Files.writeString(temp.resolve("w.yml"), "enabled: true\nchoice:\n kneel:\n words: [' ', 'Yes!']\n"); + BloodConfig.load(temp.resolve("w.yml").toFile(), fx.logger); + assertEquals(java.util.List.of("yes"), ChoiceConfig.answer(BloodPath.DEVOTEE).words()); + BloodConfig.reset(); + assertTrue(ChoiceConfig.ask().isEmpty()); + } + + @Test + void answersAreReadFromWhatIsSaid() { + assertEquals(BloodPath.TRUE, QuestionService.classify("I kneel to no one.")); + assertEquals(BloodPath.TRUE, QuestionService.classify("I will NOT kneel")); + assertEquals(BloodPath.DEVOTEE, QuestionService.classify("Yes, father... I kneel.")); + assertNull(QuestionService.classify("I know what you are")); + assertNull(QuestionService.classify("hm")); + assertEquals("i'm yours", QuestionService.normalize(" I'M yours!! ")); + } + + @Test + void kneelingIsAskedBackThenSealed() { + chamber(); + QuestionService.start(mage); + assertTrue(QuestionService.asking(mage.getUniqueId())); + QuestionService.start(mage); + say("I kneel"); + wait(30); + java.util.List lines = heard(); + assertEquals(ChoiceConfig.ask().size() + 1, lines.size()); + assertEquals(ChoiceConfig.ask().get(0).text(), lines.get(0)); + assertEquals("blood.choice.prompt", lines.get(lines.size() - 1)); + say("hm?"); + say("what?"); + assertEquals(java.util.List.of(ChoiceConfig.unclear().text()), heard()); + QuestionService.onChat(mage, "ooc", "I kneel"); + QuestionService.onChat(mage, null, "I kneel"); + say("Yes, I kneel"); + wait(3); + assertEquals(ChoiceConfig.answer(BloodPath.DEVOTEE).confirm().get(0).text(), heard().get(0)); + say("I kneel. I am yours."); + wait(20); + assertEquals(BloodPath.DEVOTEE, record.getPath()); + assertFalse(QuestionService.asking(mage.getUniqueId())); + assertTrue(heard().contains(ChoiceConfig.answer(BloodPath.DEVOTEE).sealed().get(0).text())); + } + + @Test + void refusingCanBeChangedUntilItIsSaidTwice() throws Exception { + chamber(); + QuestionService.start(mage); + wait(30); + heard(); + say("No."); + wait(8); + say("Yes, father"); + wait(8); + say("No. Never."); + wait(12); + say("I refuse you"); + wait(20); + assertEquals(BloodPath.TRUE, record.getPath()); + assertEquals(BloodStage.HUNT, record.getStage()); + } + + @Test + void walkingAwayOrSilenceEndsTheQuestion() throws Exception { + chamber(); + mage.teleport(new org.bukkit.Location(fx.world, 40.5, 10, 40.5)); + QuestionService.start(mage); + assertFalse(QuestionService.asking(mage.getUniqueId())); + mage.teleport(new org.bukkit.Location(fx.world, 2.5, 10, 2.5)); + QuestionService.start(mage); + wait(2); + mage.teleport(new org.bukkit.Location(fx.world, 40.5, 10, 40.5)); + say("I kneel"); + wait(1); + assertFalse(QuestionService.asking(mage.getUniqueId())); + assertTrue(heard().contains(ChoiceConfig.walkedAway().text())); + + mage.teleport(new org.bukkit.Location(fx.world, 2.5, 10, 2.5)); + QuestionService.start(mage); + wait(30 + ChoiceConfig.answerSeconds()); + assertFalse(QuestionService.asking(mage.getUniqueId())); + assertEquals(BloodPath.NONE, record.getPath()); + + // A sealed answer stands even if the mage steps out while the god speaks. + QuestionService.start(mage); + wait(30); + say("I refuse"); + wait(8); + say("I refuse"); + mage.teleport(new org.bukkit.Location(fx.world, 40.5, 10, 40.5)); + wait(20); + assertEquals(BloodPath.TRUE, record.getPath()); + + record.setPath(BloodPath.NONE); + record.setStage(BloodStage.CHOICE); + mage.teleport(new org.bukkit.Location(fx.world, 2.5, 10, 2.5)); + Files.writeString(temp.resolve("m.yml"), "enabled: true\nchoice:\n music: { sound: 'tfmc_blood:x', loop_seconds: 3 }\n"); + BloodConfig.load(temp.resolve("m.yml").toFile(), fx.logger); + assertEquals(3, ChoiceConfig.musicLoopSeconds()); + QuestionService.start(mage); + wait(4); + mage.teleport(new org.bukkit.Location(fx.world, 40.5, 10, 40.5)); + wait(1); + assertFalse(QuestionService.asking(mage.getUniqueId())); + mage.teleport(new org.bukkit.Location(fx.world, 2.5, 10, 2.5)); + QuestionService.start(mage); + say("anything"); + wait(2); + mage.disconnect(); + wait(1); + assertFalse(QuestionService.asking(mage.getUniqueId())); + QuestionService.forget(mage.getUniqueId()); + record.setStage(BloodStage.HUNT); + QuestionService.start(mage); + assertFalse(QuestionService.asking(mage.getUniqueId())); + } + + @Test + void devoteesGiveThemselvesAndGetClassAndArmour() { + try (MockedStatic libs = mockStatic(TLibs.class)) { + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator().getItemFromPath(anyString())).thenAnswer(i -> new ItemStack(Material.LEATHER_HELMET)); + when(api.getArmorMerger().merge(any(), any(), anyString())).thenAnswer(i -> i.getArgument(0)); + assertTrue(BloodChoice.answer(mage, BloodPath.DEVOTEE)); + } + bridge.verify(() -> RpCharactersBridge.applyTrait(mage, "manaless")); + assertEquals(BloodPath.DEVOTEE, record.getPath()); + assertEquals(BloodStage.FINAL, record.getStage()); + assertEquals("bloodsworn", FakeUnlocks.last); + assertEquals(4, java.util.Arrays.stream(mage.getInventory().getContents()).filter(BloodArmour::is).count()); + assertEquals("blood.choice.devoted", mage.nextMessage()); + assertNotNull(WhisperService.nextVoice(mage.getUniqueId())); + assertFalse(BloodChoice.answer(mage, BloodPath.TRUE)); + assertEquals(BloodConfig.level(BloodProgress.of(record)), BloodAltars.ofCharacter(id).getLevel()); + } + + @Test + void refusingKeepsTheClassAndStartsTheHunt() { + assertFalse(BloodChoice.answer(mage, BloodPath.NONE)); + assertTrue(BloodChoice.answer(mage, BloodPath.TRUE)); + assertEquals(BloodStage.HUNT, record.getStage()); + assertNull(FakeUnlocks.last); + assertEquals("blood.choice.refused", mage.nextMessage()); + bridge.verify(() -> RpCharactersBridge.applyTrait(any(), any()), never()); + } + + @Test + void aMarkThatCannotBeGivenOrIsSwitchedOffStillLetsTheDevoteeKneel() throws Exception { + bridge.when(() -> RpCharactersBridge.applyTrait(any(), any())).thenReturn(false); + assertTrue(BloodChoice.answer(mage, BloodPath.DEVOTEE)); + assertEquals(BloodStage.FINAL, record.getStage()); + java.nio.file.Path copy = temp.resolve("nomark"); + java.nio.file.Files.createDirectories(copy); + for (java.io.File part : new java.io.File("src/main/resources/wildmagic/bloodmagic").listFiles()) { + java.nio.file.Files.copy(part.toPath(), copy.resolve(part.getName())); + } + java.nio.file.Files.writeString(copy.resolve("zz-nomark.yml"), "choice:\n devotee_trait: ''\n"); + BloodConfig.load(copy.toFile(), fx.logger); + record.setPath(BloodPath.NONE); + record.setStage(BloodStage.CHOICE); + bridge.clearInvocations(); + assertTrue(BloodChoice.answer(mage, BloodPath.DEVOTEE)); + bridge.verify(() -> RpCharactersBridge.applyTrait(any(), any()), never()); + } + + @Test + void onlyACharacterAtTheQuestionWithAnAltarMayAnswer() { + BloodAltars.remove(BloodAltars.ofCharacter(id)); + assertFalse(BloodChoice.canAnswer(mage)); + record.setStage(BloodStage.ANIMAL); + assertFalse(BloodChoice.canAnswer(mage)); + PlayerMock nobody = fx.server.addPlayer(); + assertFalse(BloodChoice.canAnswer(nobody)); + } + + @Test + void classUnlockBridgeReportsFailures() { + assertFalse(ClassUnlockBridge.assign(mage, null)); + FakeUnlocks.answer = false; + assertFalse(ClassUnlockBridge.assign(mage, "x")); + FakeUnlocks.answer = true; + assertTrue(ClassUnlockBridge.assign(mage, "x")); + ClassUnlockBridge.unlocksClass = "no.such.Unlocks"; + assertFalse(ClassUnlockBridge.assign(mage, "x")); + } + + @Test + void armourIsBoundUnbreakableAndOnlyItsOwnerWearsIt() { + ItemStack helmet = BloodArmour.bind(new ItemStack(Material.LEATHER_HELMET), id); + assertTrue(BloodArmour.is(helmet)); + assertTrue(helmet.getItemMeta().isUnbreakable()); + assertTrue(helmet.getItemMeta().hasItemFlag(org.bukkit.inventory.ItemFlag.HIDE_UNBREAKABLE)); + assertFalse(BloodArmour.is(null)); + assertFalse(BloodArmour.is(new ItemStack(Material.AIR))); + assertFalse(BloodArmour.is(new ItemStack(Material.STONE))); + assertFalse(BloodArmour.is(BloodItems.create(BloodItemKind.ALTAR, id))); + assertTrue(BloodArmour.create(BloodPath.DEVOTEE, id).isEmpty()); + ItemStack bareItem = mock(ItemStack.class); + when(bareItem.getType()).thenReturn(Material.LEATHER_HELMET); + assertFalse(BloodArmour.is(bareItem)); + ItemStack ownerless = new ItemStack(Material.LEATHER_HELMET); + var meta = ownerless.getItemMeta(); + meta.getPersistentDataContainer().set(BloodItems.kindKey(), org.bukkit.persistence.PersistentDataType.STRING, "armour"); + ownerless.setItemMeta(meta); + PlayerMock wearer = fx.player("wearer"); + wearer.getInventory().setHelmet(ownerless); + assertEquals(1, BloodArmour.enforce(wearer)); + + mage.getInventory().setHelmet(helmet); + mage.getInventory().setChestplate(new ItemStack(Material.LEATHER_CHESTPLATE)); + assertEquals(0, BloodArmour.enforce(mage)); + PlayerMock thief = fx.player("thief"); + thief.getInventory().setHelmet(helmet.clone()); + assertEquals(1, BloodArmour.enforce(thief)); + assertNull(thief.getInventory().getHelmet()); + assertEquals("blood.armour.refuses", thief.nextMessage()); + assertTrue(thief.getInventory().containsAtLeast(helmet, 1)); + PlayerMock bare = fx.server.addPlayer(); + bare.getInventory().setBoots(BloodArmour.bind(new ItemStack(Material.LEATHER_BOOTS), id)); + assertEquals(1, BloodArmour.enforce(bare)); + + String dead = UUID.randomUUID().toString(); + ItemStack relic = BloodArmour.bind(new ItemStack(Material.LEATHER_LEGGINGS), dead); + try (MockedStatic chars = mockStatic(Characters.class, CALLS_REAL_METHODS)) { + chars.when(() -> Characters.info(UUID.fromString(dead))).thenReturn(Optional.of(new CharacterInfo( + new CharacterRef(UUID.randomUUID(), UUID.fromString(dead)), "N", CharacterStatus.DEAD, null, Set.of(), 0, + Instant.EPOCH))); + assertTrue(BloodArmour.crumbles(relic)); + assertFalse(BloodArmour.crumbles(helmet)); + assertFalse(BloodArmour.crumbles(new ItemStack(Material.STONE))); + thief.getInventory().setLeggings(relic); + assertEquals(1, BloodArmour.enforce(thief)); + assertNull(thief.getInventory().getLeggings()); + assertEquals("blood.armour.crumbles", thief.nextMessage()); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/ClassRewardsTest.java b/src/test/java/net/tfminecraft/magic/blood/ClassRewardsTest.java new file mode 100644 index 0000000..5f1146e --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/ClassRewardsTest.java @@ -0,0 +1,241 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; +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 org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +import net.Indyuce.mmocore.api.event.PlayerChangeClassEvent; +import net.Indyuce.mmocore.api.player.PlayerData; +import net.Indyuce.mmocore.api.player.profess.PlayerClass; +import net.Indyuce.mmocore.skill.ClassSkill; +import net.Indyuce.mmocore.skill.binding.SkillSlot; + +class ClassRewardsTest { + @TempDir Path temp; + BloodFixture fx; + PlayerMock mage; + BloodRecord record; + + /** Stands in for RPCharacters' ClassSkillPoints. */ + public static final class FakePoints { + static final Map pools = new HashMap<>(); + static boolean answer = true; + + private FakePoints() {} + + public static int get(Player player, String classId) { + return pools.getOrDefault(classId, -1); + } + + public static boolean set(Player player, String classId, int total) { + if (answer) { + pools.put(classId, total); + } + return answer; + } + } + + /** A ClassSkillPoints whose get answers with something that is not a number. */ + public static final class OddPoints { + private OddPoints() {} + + public static String get(Player player, String classId) { + return "many"; + } + } + + final List logs = new java.util.ArrayList<>(); + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + fx.logger.addHandler(new java.util.logging.Handler() { + @Override public void publish(java.util.logging.LogRecord line) { logs.add(line.getMessage()); } + @Override public void flush() {} + @Override public void close() {} + }); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + mage = fx.player("mage"); + record = fx.record("mage", BloodStage.FINAL); + record.setPath(BloodPath.DEVOTEE); + ClassUnlockBridge.skillPointsClass = FakePoints.class.getName(); + FakePoints.pools.clear(); + FakePoints.answer = true; + } + + @AfterEach + void cleanup() { + ClassUnlockBridge.skillPointsClass = "net.tfminecraft.rpcharacters.classpick.ClassSkillPoints"; + fx.close(); + } + + String messages() { + StringBuilder out = new StringBuilder(); + String message; + while ((message = mage.nextMessage()) != null) { + out.append(message).append('\n'); + } + return out.toString(); + } + + @Test + void rewardsAddUpPerFinalAndAscensionGivesEverything() { + assertEquals(new HuntConfig.ClassReward(4, List.of(3), List.of()), + HuntConfig.classRewardsUpTo(BloodPath.DEVOTEE, 2)); + assertEquals(HuntConfig.ClassReward.NONE, HuntConfig.classRewardsUpTo(BloodPath.DEVOTEE, 0)); + assertEquals(HuntConfig.ClassReward.NONE, HuntConfig.classRewardsUpTo(BloodPath.NONE, 5)); + HuntConfig.ClassReward all = HuntConfig.classRewardsUpTo(BloodPath.TRUE, Integer.MAX_VALUE); + assertEquals(0, all.skillPoints()); + assertEquals(List.of(2, 3, 4, 5, 6), all.slots()); + assertEquals(8, all.spells().size()); + assertEquals(List.of("SANGUINAR_EXSANGUINATE", "SANGUINAR_OSSUARY"), HuntConfig.classRewardsUpTo(BloodPath.TRUE, 1).spells()); + } + + @Test + void badRewardEntriesAreSkippedAndRepeatsCountOnce() throws Exception { + Files.writeString(temp.resolve("r.yml"), String.join("\n", + "finals:", + " class_rewards:", + " devotee:", + " 0: { skill_points: 1 }", + " x: { skill_points: 1 }", + " 2: 5", + " 3: { skill_points: -4, slots: [3, 0, -1], spells: [' ', ' blood_bolt '] }", + " 4: { skill_points: 2, slots: [3, 4], spells: [BLOOD_BOLT, OPEN_VEINS] }", + "")); + BloodConfig.load(temp.resolve("r.yml").toFile(), fx.logger); + assertEquals(new HuntConfig.ClassReward(2, List.of(3, 4), List.of("BLOOD_BOLT", "OPEN_VEINS")), + HuntConfig.classRewardsUpTo(BloodPath.DEVOTEE, 9)); + assertEquals(HuntConfig.ClassReward.NONE, HuntConfig.classRewardsUpTo(BloodPath.TRUE, 9)); + } + + @Test + void pointsFollowTheSacrificesAndOnlyNewOnesAreAnnounced() { + ClassRewards.apply(mage); + assertEquals(0, FakePoints.pools.get("bloodsworn"), "Kneeling gives the class its own, empty pool"); + assertEquals("", messages()); + record.setFinalsDone(1); + ClassRewards.apply(mage); + assertEquals(2, FakePoints.pools.get("bloodsworn")); + assertTrue(messages().contains("blood.class.rewarded")); + ClassRewards.apply(mage); + assertEquals("", messages(), "Nothing new, nothing said"); + record.setStage(BloodStage.ASCENDED); + ClassRewards.apply(mage); + assertEquals(10, FakePoints.pools.get("bloodsworn")); + messages(); + FakePoints.pools.put("bloodsworn", 30); + ClassRewards.apply(mage); + assertEquals(10, FakePoints.pools.get("bloodsworn")); + assertEquals("", messages(), "Fewer points than before is no gift"); + FakePoints.pools.put("bloodsworn", 3); + FakePoints.answer = false; + ClassRewards.apply(mage); + assertEquals("", messages(), "A pool RPCharacters would not save is not announced"); + } + + @Test + void charactersOffTheFinalsGetNothing() { + PlayerMock stranger = fx.server.addPlayer("Stranger"); + ClassRewards.apply(stranger); + record.setStage(BloodStage.CHOICE); + ClassRewards.apply(mage); + record.setStage(BloodStage.FINAL); + record.setPath(BloodPath.NONE); + ClassRewards.apply(mage); + record.setPath(BloodPath.DEVOTEE); + ChoiceConfig.reset(); + ClassRewards.apply(mage); + assertTrue(FakePoints.pools.isEmpty()); + } + + @Test + void applySoonWaitsATickAndSkipsPlayersWhoLeft() { + ClassRewards.applySoon(mage); + assertTrue(FakePoints.pools.isEmpty()); + fx.server.getScheduler().performOneTick(); + assertEquals(0, FakePoints.pools.get("bloodsworn")); + FakePoints.pools.clear(); + ClassRewards.applySoon(mage); + mage.disconnect(); + fx.server.getScheduler().performOneTick(); + assertTrue(FakePoints.pools.isEmpty()); + } + + @Test + void theBridgeCopesWithoutClassSkillPoints() { + ClassUnlockBridge.skillPointsClass = OddPoints.class.getName(); + assertEquals(-1, ClassUnlockBridge.skillPoints(mage, "bloodsworn")); + assertFalse(ClassUnlockBridge.setSkillPoints(mage, "bloodsworn", 4)); + ClassUnlockBridge.skillPointsClass = "no.such.Points"; + assertEquals(-1, ClassUnlockBridge.skillPoints(mage, "bloodsworn")); + assertFalse(ClassUnlockBridge.setSkillPoints(mage, "bloodsworn", 4)); + FakePoints.answer = false; + ClassUnlockBridge.skillPointsClass = FakePoints.class.getName(); + assertFalse(ClassUnlockBridge.setSkillPoints(mage, "bloodsworn", 4)); + } + + @Test + void slotsAndSpellsOpenOnlyWhileThePathsClassIsPlayed() { + record.setPath(BloodPath.TRUE); + record.setFinalsDone(1); + MockBukkit.createMockPlugin("MMOCore"); + PlayerData data = mock(PlayerData.class); + PlayerClass sanguinar = mock(PlayerClass.class); + when(sanguinar.getId()).thenReturn("SANGUINAR"); + SkillSlot slot = mock(SkillSlot.class); + ClassSkill exsanguinate = mock(ClassSkill.class); + ClassSkill ossuary = mock(ClassSkill.class); + when(ossuary.isUnlockedByDefault()).thenReturn(true); + when(sanguinar.getSkillSlot(2)).thenReturn(slot); + when(sanguinar.getSkill("SANGUINAR_EXSANGUINATE")).thenReturn(exsanguinate); + when(sanguinar.getSkill("SANGUINAR_OSSUARY")).thenReturn(ossuary); + when(data.unlock(any())).thenReturn(true); + try (MockedStatic players = mockStatic(PlayerData.class)) { + ClassRewards.apply(mage); + assertEquals("", messages(), "No MMOCore data yet"); + players.when(() -> PlayerData.has(mage)).thenReturn(true); + players.when(() -> PlayerData.get((OfflinePlayer) mage)).thenReturn(data); + ClassRewards.apply(mage); + PlayerClass mageClass = mock(PlayerClass.class); + when(mageClass.getId()).thenReturn("MAGE"); + when(data.getProfess()).thenReturn(mageClass); + ClassRewards.apply(mage); + verify(data, never()).unlock(any()); + when(data.getProfess()).thenReturn(sanguinar); + ClassRewards.apply(mage); + verify(data).unlock(slot); + verify(data).unlock(exsanguinate); + verify(data, never()).unlock(ossuary); + assertTrue(messages().contains("blood.class.rewarded")); + when(data.unlock(any())).thenReturn(false); + ClassRewards.apply(mage); + assertEquals("", messages()); + when(sanguinar.getSkillSlot(2)).thenReturn(null); + ClassRewards.apply(mage); + assertTrue(logs.stream().anyMatch(line -> line.contains("class sanguinar has no slot 2"))); + + when(data.getPlayer()).thenReturn(mage); + new MmoClassRewards().onClassChange(new PlayerChangeClassEvent(data, sanguinar)); + fx.server.getScheduler().performOneTick(); + verify(data, atLeast(4)).unlock(exsanguinate); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java new file mode 100644 index 0000000..50ef35d --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java @@ -0,0 +1,380 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.data.Lightable; +import org.bukkit.entity.Cow; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.persistence.PersistentDataType; +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 org.mockbukkit.mockbukkit.entity.PlayerMock; + +class CorruptionAndCluesTest { + @TempDir Path temp; + BloodFixture fx; + final AtomicLong now = new AtomicLong(50_000_000L); + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + AltarCorruption.random = new Random(7); + AltarCorruption.clock = now::get; + StolenClues.clock = now::get; + } + + @AfterEach + void cleanup() { + AltarCorruption.random = new Random(); + AltarCorruption.clock = System::currentTimeMillis; + StolenClues.clock = System::currentTimeMillis; + StolenClues.clear(); + BloodNotes.clear(); + fx.close(); + } + + /** A 7x3x7 room of air with a stone floor and ceiling around the altar block. */ + Block room() { + fx.world.loadChunk(0, 0); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + fx.world.getBlockAt(x, 9, z).setType(Material.STONE); + fx.world.getBlockAt(x, 13, z).setType(Material.STONE); + for (int y = 10; y <= 12; y++) { + fx.world.getBlockAt(x, y, z).setType(Material.AIR); + } + } + } + return fx.world.getBlockAt(0, 10, 0); + } + + void config(String yaml) throws Exception { + Files.writeString(temp.resolve("c.yml"), "enabled: true\n" + yaml); + BloodConfig.load(temp.resolve("c.yml").toFile(), fx.logger); + } + + @Test + void spreadConvertsDecoratesWithinBudgetAndRestoreUndoesIt() throws Exception { + config(String.join("\n", "corruption:", " levels:", " 0: { radius: 3, max_blocks: 12 }", + " convert:", " - { min_level: 0, from: [STONE], to: [NETHERRACK] }", + " decorate:", " - { min_level: 0, at: floor, to: [RED_CANDLE] }", + " - { min_level: 0, at: ceiling, to: [WEEPING_VINES] }", "")); + Block center = room(); + center.setType(Material.BARRIER); + BloodAltar altar = BloodAltar.create("c", "o", center, 0f, 1L); + BloodAltars.add(altar); + int made = AltarCorruption.spread(altar, 40); + assertEquals(12, made); + assertEquals(made, altar.getCorrupted().size()); + assertEquals(Material.BARRIER, center.getType()); + assertTrue(center.getRelative(0, 1, 0).isEmpty()); + // Candles only grow before the pedestals, on turned ground; any that did are lit. + for (BloodAltar.CorruptedBlock entry : altar.getCorrupted()) { + Block block = fx.world.getBlockAt(entry.x(), entry.y(), entry.z()); + assertEquals(entry.placed(), block.getType().name()); + if (block.getType() == Material.RED_CANDLE) { + assertTrue(((Lightable) block.getBlockData()).isLit()); + } + } + assertEquals(0, AltarCorruption.spread(altar, 5)); + + BloodAltar.CorruptedBlock first = altar.getCorrupted().get(0); + Block changedByPlayer = fx.world.getBlockAt(first.x(), first.y(), first.z()); + changedByPlayer.setType(Material.GOLD_BLOCK); + BloodAltar.CorruptedBlock last = altar.getCorrupted().get(altar.getCorrupted().size() - 1); + altar.getCorrupted().set(altar.getCorrupted().size() - 1, + new BloodAltar.CorruptedBlock(last.x(), last.y(), last.z(), "minecraft:not_a_block", last.placed())); + AltarCorruption.restore(altar); + assertTrue(altar.getCorrupted().isEmpty()); + assertEquals(Material.GOLD_BLOCK, changedByPlayer.getType()); + assertEquals(Material.AIR, fx.world.getBlockAt(last.x(), last.y(), last.z()).getType()); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + if (!(x == first.x() && z == first.z())) { + assertNotEquals(Material.NETHERRACK, fx.world.getBlockAt(x, 9, z).getType()); + } + } + } + } + + @Test + void candlesStandOnlyBeforeThePedestalsAndTheRoomTurnsInPatches() throws Exception { + Block center = room(); + assertTrue(AltarCorruption.candleSpot(center.getRelative(3, 0, 0), center)); + assertTrue(AltarCorruption.candleSpot(center.getRelative(0, 0, -3), center)); + assertTrue(AltarCorruption.candleSpot(center.getRelative(-2, 0, 2), center)); + assertFalse(AltarCorruption.candleSpot(center.getRelative(2, 0, 0), center)); + assertFalse(AltarCorruption.candleSpot(center.getRelative(3, 1, 0), center)); + CorruptionConfig.Rule candles = new CorruptionConfig.Rule(0, java.util.Set.of(), CorruptionConfig.Placement.FLOOR, + java.util.List.of(Material.RED_CANDLE)); + CorruptionConfig.Rule mixed = new CorruptionConfig.Rule(0, java.util.Set.of(), CorruptionConfig.Placement.FLOOR, + java.util.List.of(Material.RED_CANDLE, Material.REDSTONE_WIRE)); + assertNull(AltarCorruption.material(candles, center.getRelative(1, 0, 1), center)); + assertEquals(Material.RED_CANDLE, AltarCorruption.material(candles, center.getRelative(2, 0, 2), center)); + for (int i = 0; i < 10; i++) { + assertEquals(Material.REDSTONE_WIRE, AltarCorruption.material(mixed, center.getRelative(1, 0, 1), center)); + } + AltarCorruption.random = new Random(1) { + @Override public double nextDouble() { return 0; } + }; + java.util.Set none = java.util.Set.of(); + assertTrue(AltarCorruption.score(center.getRelative(1, 0, 0), center, none) + > AltarCorruption.score(center.getRelative(3, 0, 0), center, none)); + Block far = center.getRelative(3, 0, 0); + BloodAltar altar = BloodAltar.create("p", "o", center, 0f, 1L); + altar.getCorrupted().add(new BloodAltar.CorruptedBlock(far.getX(), far.getY() - 1, far.getZ(), "minecraft:stone", "NETHERRACK")); + config(String.join("\n", "corruption:", " levels:", " 0: { radius: 3, max_blocks: 40 }", + " decorate:", " - { min_level: 0, at: floor, to: [RED_CANDLE] }", "")); + for (int i = 0; i < 30; i++) { + AltarCorruption.spread(altar, 1); + } + for (BloodAltar.CorruptedBlock entry : altar.getCorrupted()) { + if (entry.placed().equals("RED_CANDLE")) { + assertTrue(AltarCorruption.candleSpot(fx.world.getBlockAt(entry.x(), entry.y(), entry.z()), center), entry.toString()); + } + } + } + + @Test + void pedestalsAndTheAltarAreNeverDecorated() { + Block center = room(); + CorruptionConfig.Rule floor = new CorruptionConfig.Rule(0, java.util.Set.of(), CorruptionConfig.Placement.FLOOR, + java.util.List.of(Material.RED_CANDLE)); + CorruptionConfig.Rule ceiling = new CorruptionConfig.Rule(0, java.util.Set.of(), CorruptionConfig.Placement.CEILING, + java.util.List.of(Material.WEEPING_VINES)); + Block onStone = center.getRelative(2, 0, 0); + assertTrue(AltarCorruption.fits(floor, onStone)); + Block pedestal = center.getRelative(3, 0, 0); + pedestal.setType(Material.BARRIER); + assertFalse(AltarCorruption.fits(floor, pedestal.getRelative(0, 1, 0))); + Block under = center.getRelative(-2, 2, 0); + assertTrue(AltarCorruption.fits(ceiling, under)); + under.getRelative(0, 1, 0).setType(Material.BARRIER); + assertFalse(AltarCorruption.fits(ceiling, under)); + } + + @Test + void reconvertingKeepsTheFirstOriginal() throws Exception { + config(String.join("\n", "corruption:", " levels:", " 0: { radius: 1, max_blocks: 200 }", + " convert:", " - { min_level: 0, from: [STONE, NETHERRACK], to: [NETHERRACK] }", "")); + Block center = room(); + BloodAltar altar = BloodAltar.create("c", "o", center, 0f, 1L); + for (int i = 0; i < 40; i++) { + AltarCorruption.spread(altar, 1); + } + long distinct = altar.getCorrupted().stream().map(e -> e.x() + "," + e.y() + "," + e.z()).distinct().count(); + assertEquals(distinct, altar.getCorrupted().size()); + assertTrue(altar.getCorrupted().stream().allMatch(e -> e.original().contains("stone"))); + } + + @Test + void nothingHappensWithoutRulesLoadedChunksOrRoom() throws Exception { + Block center = room(); + BloodAltar altar = BloodAltar.create("c", "o", center, 0f, 1L); + assertEquals(0, AltarCorruption.spread(altar, 3)); + config(String.join("\n", "corruption:", " convert:", " - { min_level: 0, from: [GOLD_BLOCK], to: [NETHERRACK] }", "")); + assertEquals(0, AltarCorruption.spread(altar, 3)); + BloodAltar lost = BloodAltar.create("c", "o", center, 0f, 1L); + var world = BloodAltar.class.getDeclaredField("world"); + world.setAccessible(true); + world.set(lost, "gone"); + assertEquals(0, AltarCorruption.spread(lost, 3)); + lost.getCorrupted().add(new BloodAltar.CorruptedBlock(0, 0, 0, "minecraft:stone", "STONE")); + AltarCorruption.restore(lost); + assertTrue(lost.getCorrupted().isEmpty()); + BloodAltar far = BloodAltar.create("c", "o", fx.world.getBlockAt(5000, 10, 5000), 0f, 1L); + fx.world.unloadChunk(5000 >> 4, 5000 >> 4); + assertEquals(0, AltarCorruption.spread(far, 3)); + var corrupted = BloodAltar.class.getDeclaredField("corrupted"); + corrupted.setAccessible(true); + corrupted.set(far, null); + assertTrue(far.getCorrupted().isEmpty()); + } + + @Test + void edgeOfLoadedChunksIsLeftAlone() throws Exception { + config(String.join("\n", "corruption:", " levels:", " 0: { radius: 3, max_blocks: 40 }", + " decorate:", " - { min_level: 0, at: floor, to: [COBWEB] }", "")); + Block center = room(); + fx.world.unloadChunk(-1, 0); + fx.world.unloadChunk(-1, -1); + fx.world.unloadChunk(0, -1); + BloodAltar altar = BloodAltar.create("c", "o", center, 0f, 1L); + AltarCorruption.spread(altar, 30); + assertTrue(altar.getCorrupted().stream().allMatch(e -> e.x() >= 0 && e.z() >= 0)); + } + + @Test + void ambientSpreadWaitsItsTurnAndSavesOnlyOnChange() throws Exception { + config(String.join("\n", "corruption:", " ambient_every_minutes: 30", " levels:", + " 0: { radius: 3, max_blocks: 2 }", " convert:", " - { min_level: 0, from: [STONE], to: [NETHERRACK] }", "")); + assertEquals(1, CorruptionConfig.ambientBlocks()); + Block center = room(); + BloodAltar altar = BloodAltar.create("c", "o", center, 0f, 1L); + BloodAltars.add(altar); + AltarCorruption.ambient(); + assertEquals(1, altar.getCorrupted().size()); + assertEquals(now.get(), altar.getLastSpreadAt()); + AltarCorruption.ambient(); + assertEquals(1, altar.getCorrupted().size()); + now.addAndGet(31 * 60_000L); + AltarCorruption.ambient(); + now.addAndGet(31 * 60_000L); + AltarCorruption.ambient(); + assertEquals(2, altar.getCorrupted().size()); + } + + @Test + void configsParseAndRejectBadEntries() throws Exception { + config(String.join("\n", "animal:", " species_required: 0", " cooldown_hours: -1", " bind_range: 0", + " bind_seconds: 0", " species: [COW, UNICORN]", " trace:", " hours: -2", " note_delay_minutes: -1", + "corruption:", " levels:", " x: { radius: 1 }", " blocks_per_sacrifice: -1", " ambient_every_minutes: 0", + " convert:", " - { from: [NOT_A_BLOCK], to: [STONE] }", " - { min_level: 1, from: [STONE], to: [DIAMOND] }", + " - { min_level: 2, from: [STONE], to: [DIRT] }", " decorate:", " - { to: 'notalist' }", + " - { at: ceiling, to: [WEEPING_VINES] }", "")); + assertEquals(1, AnimalConfig.speciesRequired()); + assertEquals(0, AnimalConfig.cooldownMillis()); + assertEquals(1, AnimalConfig.bindRange()); + assertEquals(1, AnimalConfig.bindSeconds()); + assertTrue(AnimalConfig.isSpecies(org.bukkit.entity.EntityType.COW)); + assertEquals(0, AnimalConfig.traceMillis()); + assertEquals(0, AnimalConfig.noteDelayMillis()); + assertEquals("v.redstone", AnimalConfig.traceModel()); + assertTrue(AnimalConfig.requireStolen()); + assertTrue(AnimalConfig.clearDrops()); + assertEquals(new CorruptionConfig.Reach(2, 4), CorruptionConfig.reach(9)); + assertEquals(0, CorruptionConfig.blocksPerSacrifice()); + assertEquals(1, CorruptionConfig.ambientEveryMinutes()); + assertEquals(1, CorruptionConfig.rules(0).size()); + assertEquals(CorruptionConfig.Placement.CEILING, CorruptionConfig.rules(0).get(0).on()); + assertEquals(2, CorruptionConfig.rules(2).size()); + + config("corruption:\n levels:\n 3: { radius: 5, max_blocks: 9 }\n"); + assertEquals(new CorruptionConfig.Reach(5, 9), CorruptionConfig.reach(1)); + assertEquals(new CorruptionConfig.Reach(5, 9), CorruptionConfig.reach(4)); + config("enabled: true\n"); + assertEquals(new CorruptionConfig.Reach(2, 4), CorruptionConfig.reach(0)); + assertTrue(CorruptionConfig.rules(5).isEmpty()); + + assertTrue(BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger)); + assertEquals(7, AnimalConfig.speciesRequired()); + assertEquals(24 * 3_600_000L, AnimalConfig.cooldownMillis()); + assertEquals(new CorruptionConfig.Reach(3, 14), CorruptionConfig.reach(2)); + assertFalse(CorruptionConfig.rules(4).isEmpty()); + } + + @Test + void husbandryOwnersComeFromCookingOrNobody() { + UUID animal = UUID.randomUUID(); + assertTrue(HusbandryOwners.of(animal).isEmpty()); + FakeHusbandry.use(); + UUID account = UUID.randomUUID(); + FakeHusbandry.OWNERS.put(animal, List.of(new FakeHusbandry.Owner(account, null), + new FakeHusbandry.Owner(null, UUID.randomUUID()))); + assertEquals(List.of(new HusbandryOwners.Owner(account, null)), HusbandryOwners.of(animal)); + HusbandryOwners.serviceClass = FakeHusbandry.Broken.class.getName(); + assertTrue(HusbandryOwners.of(animal).isEmpty()); + FakeHusbandry.restore(); + } + + @Test + void stolenAnimalsLeaveATraceAndNotesThatArriveLater() { + PlayerMock keeper = fx.player("keeper"); + String keeperChar = BloodFixture.id("keeper"); + Cow cow = fx.world.spawn(new Location(fx.world, 0.5, 10, -20.5), Cow.class); + cow.setCustomName("Bessie"); + StolenClues.leashed(cow); + assertTrue(StolenClues.remembers(cow.getUniqueId())); + Location altar = new Location(fx.world, 20.5, 10.5, -0.5); + StolenClues.leave(cow, List.of(new HusbandryOwners.Owner(keeper.getUniqueId(), UUID.fromString(keeperChar)), + new HusbandryOwners.Owner(UUID.randomUUID(), null)), altar); + assertFalse(StolenClues.remembers(cow.getUniqueId())); + List traces = fx.world.getEntitiesByClass(ItemDisplay.class).stream() + .filter(d -> d.getPersistentDataContainer().has(StolenClues.traceKey(), PersistentDataType.LONG)).toList(); + assertEquals(1, traces.size()); + StolenClues.tick(); + assertTrue(traces.get(0).isValid()); + assertEquals(2, BloodNotes.all().size()); + assertEquals("blood.animal.note", BloodNotes.all().get(0).text()); + + BloodNotes.tick(now.get()); + assertNull(keeper.nextMessage()); + BloodNotes.tick(now.get() + 31 * 60_000L); + assertEquals("blood.animal.note", keeper.nextMessage()); + assertEquals(1, BloodNotes.all().size()); + + Cow wild = fx.world.spawn(new Location(fx.world, 0.5, 10, 0.5), Cow.class); + StolenClues.leave(wild, List.of(new HusbandryOwners.Owner(keeper.getUniqueId(), null)), altar); + assertEquals("blood.animal.note_lost", BloodNotes.all().get(1).text()); + BloodNotes.deliver(keeper, "another", now.get(), true); + assertEquals("blood.animal.note_lost", keeper.nextMessage()); + BloodNotes.deliver(keeper, "another", now.get(), true); + assertNull(keeper.nextMessage()); + BloodNotes.add(new BloodNotes.Note(keeper.getUniqueId().toString(), "someone-else", "not yours", 0)); + BloodNotes.deliver(keeper, "another", now.get(), true); + assertNull(keeper.nextMessage()); + + Cow travelled = fx.world.spawn(new Location(fx.world, 0.5, 10, 0.5), Cow.class); + StolenClues.leashed(travelled); + Location elsewhere = new Location(fx.server.addSimpleWorld("far"), 0, 64, 0); + StolenClues.leave(travelled, List.of(), elsewhere); + + now.addAndGet(7 * 3_600_000L); + StolenClues.tick(); + assertFalse(traces.get(0).isValid()); + Cow forgotten = fx.world.spawn(new Location(fx.world, 0.5, 10, 0.5), Cow.class); + StolenClues.leashed(forgotten); + StolenClues.tick(); + assertTrue(StolenClues.remembers(forgotten.getUniqueId())); + now.addAndGet(2 * 3_600_000L); + StolenClues.tick(); + assertFalse(StolenClues.remembers(forgotten.getUniqueId())); + } + + @Test + void compassPointsFollowMinecraftAxes() { + Location origin = new Location(fx.world, 0, 0, 0); + assertEquals("south", StolenClues.direction(origin, new Location(fx.world, 0, 0, 10))); + assertEquals("north", StolenClues.direction(origin, new Location(fx.world, 0, 0, -10))); + assertEquals("east", StolenClues.direction(origin, new Location(fx.world, 10, 0, 0))); + assertEquals("west", StolenClues.direction(origin, new Location(fx.world, -10, 0, 0))); + assertEquals("northeast", StolenClues.direction(origin, new Location(fx.world, 10, 0, -10))); + assertEquals("southwest", StolenClues.direction(origin, new Location(fx.world, -10, 0, 10))); + } + + @Test + void notesSurviveARestartAndBadEntriesAreDropped() throws Exception { + BloodStore store = new BloodStore(temp.resolve("n").toFile(), fx.logger); + BloodNotes.init(store); + assertTrue(BloodNotes.all().isEmpty()); + BloodNotes.add(new BloodNotes.Note(UUID.randomUUID().toString(), null, "hi", 0)); + Path file = temp.resolve("n/notes.json"); + Files.writeString(file, Files.readString(file).replaceFirst("\\[", "[null, {\"text\":\"x\"}, {\"account\":\"a\"},")); + BloodNotes.init(store); + assertEquals(1, BloodNotes.all().size()); + Files.writeString(file, "["); + BloodNotes.init(store); + assertTrue(BloodNotes.all().isEmpty()); + BloodNotes.tick(0); + PlayerMock nobody = fx.server.addPlayer(); + BloodNotes.add(new BloodNotes.Note(nobody.getUniqueId().toString(), null, "lost", 0)); + BloodNotes.tick(0); + assertNull(nobody.nextMessage()); + BloodNotes.clear(); + BloodNotes.add(new BloodNotes.Note("x", null, "unsaved", 0)); + assertEquals(1, BloodNotes.all().size()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/FakeHusbandry.java b/src/test/java/net/tfminecraft/magic/blood/FakeHusbandry.java new file mode 100644 index 0000000..2f99bee --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/FakeHusbandry.java @@ -0,0 +1,54 @@ +package net.tfminecraft.magic.blood; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** Stands in for Cooking's HusbandryOwnershipService, which Magic reaches by reflection. */ +public final class FakeHusbandry { + public static final Map> OWNERS = new HashMap<>(); + + private FakeHusbandry() {} + + public static List listOwners(UUID animal) { + return OWNERS.getOrDefault(animal, List.of()); + } + + /** Same accessors as Cooking's HusbandryOwner. */ + public static final class Owner { + private final UUID account; + private final UUID character; + + public Owner(UUID account, UUID character) { + this.account = account; + this.character = character; + } + + public UUID playerUuid() { + return account; + } + + public UUID characterUuid() { + return character; + } + } + + /** A service whose answer is not a list. */ + public static final class Broken { + private Broken() {} + + public static Object listOwners(UUID animal) { + return "nope"; + } + } + + static void use() { + HusbandryOwners.serviceClass = FakeHusbandry.class.getName(); + } + + static void restore() { + HusbandryOwners.serviceClass = "net.tfminecraft.cooking.husbandry.HusbandryOwnershipService"; + OWNERS.clear(); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java new file mode 100644 index 0000000..838c402 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java @@ -0,0 +1,627 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +import net.tfminecraft.magic.integration.RpCharactersBridge; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; +import org.bukkit.Location; +import org.bukkit.block.Block; +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 org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +class HuntAndFinalsTest { + @TempDir Path temp; + BloodFixture fx; + MockedStatic bridge; + MockedStatic chars; + final AtomicLong now = new AtomicLong(1_000_000_000_000L); + final Map infos = new HashMap<>(); + final Map talks = new HashMap<>(); + PlayerMock mage; + String mageId; + BloodRecord record; + Block target; + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + BondService.init(new BloodStore(temp.resolve("bonds-root").toFile(), fx.logger)); + CharacterRiteService.clock = now::get; + FinalService.clock = now::get; + ClassUnlockBridge.unlocksClass = ChoiceTest.FakeUnlocks.class.getName(); + ChoiceTest.FakeUnlocks.answer = true; + ChoiceTest.FakeUnlocks.last = null; + bridge = mockStatic(RpCharactersBridge.class); + bridge.when(() -> RpCharactersBridge.isDown(any())).thenReturn(true); + bridge.when(() -> RpCharactersBridge.playtimeSeconds(any())).thenReturn(100L * 3600); + bridge.when(() -> RpCharactersBridge.characterAgeSeconds(any())).thenReturn(100L * 86_400); + bridge.when(() -> RpCharactersBridge.sacrifice(any(), any())).thenReturn(true); + bridge.when(() -> RpCharactersBridge.moveDowned(any(), any())).thenReturn(true); + bridge.when(() -> RpCharactersBridge.conversationCounts(any())).thenAnswer(i -> new HashMap<>(talks)); + chars = mockStatic(Characters.class, CALLS_REAL_METHODS); + chars.when(() -> Characters.info(any())).thenAnswer(i -> { + UUID id = i.getArgument(0); + return Optional.ofNullable(infos.get(id)); + }); + mage = fx.player("mage"); + mageId = BloodFixture.id("mage"); + alive(mage, "mage", "Mage"); + record = BloodRecords.getOrCreate(mageId, mage.getUniqueId().toString()); + record.setStage(BloodStage.HUNT); + record.setPath(BloodPath.TRUE); + fx.open(-30, 10, -3, 45, 12, 4); + target = fx.pocket(0, 10, 0); + BloodAltars.add(BloodAltar.create(mageId, mage.getUniqueId().toString(), target, 0f, 1L)); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, mageId)); + } + + @AfterEach + void cleanup() { + CharacterRiteService.clear(); + BondService.clear(); + chars.close(); + bridge.close(); + ClassUnlockBridge.unlocksClass = "net.tfminecraft.rpcharacters.classpick.ClassUnlocks"; + CharacterRiteService.clock = System::currentTimeMillis; + FinalService.clock = System::currentTimeMillis; + WhisperService.clear(); + fx.close(); + } + + void alive(PlayerMock player, String label, String name) { + UUID id = UUID.fromString(BloodFixture.id(label)); + infos.put(id, new CharacterInfo(new CharacterRef(player.getUniqueId(), id), name, CharacterStatus.ALIVE, null, + Set.of(), 0, Instant.ofEpochMilli(now.get()))); + } + + String offline(String name, CharacterStatus status, Instant lastPlayed) { + UUID id = UUID.randomUUID(); + infos.put(id, new CharacterInfo(new CharacterRef(UUID.randomUUID(), id), name, status, null, Set.of(), 0, + lastPlayed)); + return id.toString(); + } + + PlayerMock victim(String label) { + PlayerMock victim = fx.player(label); + victim.teleport(new Location(fx.world, 1.5, 10, 0.5)); + alive(victim, label, label); + return victim; + } + + void runRite() { + now.addAndGet(11_000L); + CharacterRiteService.tick(); + } + + @Test + void bondsGrowFromTimeTogetherAndPersist() throws Exception { + PlayerMock friend = victim("friend"); + PlayerMock far = victim("far"); + far.teleport(new Location(fx.world, 500, 10, 500)); + PlayerMock elsewhere = victim("elsewhere"); + elsewhere.teleport(new Location(fx.server.addSimpleWorld("other"), 0, 64, 0)); + fx.server.addPlayer(); + BondService.sample(); + String friendId = BloodFixture.id("friend"); + assertEquals(5, BondService.minutes(mageId, friendId)); + assertEquals(5, BondService.minutes(friendId, mageId)); + assertEquals(0, BondService.minutes(mageId, BloodFixture.id("far"))); + assertEquals(0, BondService.minutes(mageId, BloodFixture.id("elsewhere"))); + BondService.flush(); + BondService.unload(mageId); + BondService.unload(mageId); + assertEquals(5, BondService.minutes(mageId, friendId)); + BondService.add(mageId, friendId, 1); + BondService.unload(mageId); + assertEquals(6, BondService.minutes(mageId, friendId)); + BondService.load(mageId); + + Path file = temp.resolve("bonds-root/bonds/" + mageId + ".json"); + Files.writeString(file, "{\"" + friendId + "\": 7, \"x\": null, \"y\": 0}"); + BondService.unload(mageId); + assertEquals(7, BondService.minutes(mageId, friendId)); + BondService.clear(); + BondService.add(mageId, friendId, 1); + BondService.flush(); + assertEquals(1, BondService.minutes(mageId, friendId)); + BondService.unload(mageId); + assertEquals(0, BondService.minutes(mageId, friendId)); + BondService.add(mageId, friendId, 2); + BondService.unload(mageId); + BondService.init(new BloodStore(temp.resolve("bonds-root").toFile(), fx.logger)); + Files.writeString(file, "{broken"); + assertEquals(0, BondService.minutes(mageId, friendId)); + } + + @Test + void bondFlushesWriteOffTheMainThreadAndAFailedWriteIsReported() { + PlayerMock friend = victim("friend"); + String friendId = BloodFixture.id("friend"); + BondService.writer = BondService.BACKGROUND; + try { + BondService.sample(); + BondService.flush(); + BondService.clear(); + assertTrue(Files.exists(temp.resolve("bonds-root/bonds/" + mageId + ".json")), "Written by the bonds thread"); + BloodStore failing = spy(new BloodStore(temp.resolve("failing").toFile(), fx.logger)); + doThrow(new IllegalStateException("disk gone")).when(failing).saveBonds(any(), any()); + BondService.init(failing); + BondService.add(mageId, friendId, 1); + BondService.flush(); + BondService.clear(); + verify(failing).warn(argThat(line -> line.contains("Failed to write the bonds of " + mageId))); + } finally { + BondService.writer = Runnable::run; + BondService.init(new BloodStore(temp.resolve("bonds-root").toFile(), fx.logger)); + } + assertNotNull(friend); + } + + @Test + void rankingWeighsTalksTimeAndQualifiedHeirsButNotOwnAccountsOrTheDead() { + String talker = offline("Talker", CharacterStatus.ALIVE, Instant.EPOCH); + String sitter = offline("Sitter", CharacterStatus.ALIVE, Instant.EPOCH); + String heir = offline("Heir", CharacterStatus.ALIVE, Instant.EPOCH); + String dead = offline("Dead", CharacterStatus.DEAD, Instant.EPOCH); + UUID ownAlt = UUID.randomUUID(); + infos.put(ownAlt, new CharacterInfo(new CharacterRef(mage.getUniqueId(), ownAlt), "Alt", CharacterStatus.ALIVE, + null, Set.of(), 0, Instant.EPOCH)); + talks.put(talker.toUpperCase(), 10); + talks.put(dead, 50); + talks.put(ownAlt.toString(), 50); + talks.put("not-a-uuid", 50); + talks.put(mageId, 99); + BondService.add(mageId, sitter, 600); + BondService.add(mageId, heir, 60); + bridge.when(() -> RpCharactersBridge.willHeir(mage)).thenReturn(heir); + List ranking = BondService.ranking(mage); + assertEquals(List.of(talker, sitter, heir), ranking.stream().map(BondService.Ranked::character).toList()); + assertEquals(30, ranking.get(0).score()); + BondService.add(mageId, heir, 600); + assertEquals(heir, BondService.ranking(mage).get(0).character()); + assertEquals(1, BondService.rank(mage, heir.toUpperCase())); + assertEquals(0, BondService.rank(mage, dead)); + String quietHeir = offline("Quiet", CharacterStatus.ALIVE, Instant.EPOCH); + bridge.when(() -> RpCharactersBridge.willHeir(mage)).thenReturn(quietHeir); + talks.put(quietHeir, 25); + assertEquals(quietHeir, BondService.ranking(mage).get(0).character()); + String silent = offline("Silent", CharacterStatus.ALIVE, Instant.EPOCH); + talks.put(silent, 0); + assertEquals(0, BondService.rank(mage, silent)); + PlayerMock nobody = fx.server.addPlayer(); + assertTrue(BondService.ranking(nobody).isEmpty()); + assertFalse(BondService.livingOtherAccount("bad", mage.getUniqueId())); + assertFalse(BondService.livingOtherAccount(UUID.randomUUID().toString(), mage.getUniqueId())); + } + + @Test + void bindingTheVictimChecksEveryRule() throws Exception { + PlayerMock victim = victim("victim"); + String victimId = BloodFixture.id("victim"); + PlayerMock stranger = fx.player("no-record"); + assertEquals(CharacterRiteService.Bind.NOT_STAGE, CharacterRiteService.bind(stranger, victim)); + record.setStage(BloodStage.ANIMAL); + assertEquals(CharacterRiteService.Bind.NOT_STAGE, CharacterRiteService.bind(mage, victim)); + record.setStage(BloodStage.HUNT); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, mageId)); + assertEquals(CharacterRiteService.Bind.NOT_KNIFE, CharacterRiteService.bind(mage, victim)); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, "x")); + assertEquals(CharacterRiteService.Bind.NOT_KNIFE, CharacterRiteService.bind(mage, victim)); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, mageId)); + BloodAltar altar = BloodAltars.ofCharacter(mageId); + BloodAltars.remove(altar); + assertEquals(CharacterRiteService.Bind.NO_ALTAR, CharacterRiteService.bind(mage, victim)); + BloodAltars.add(altar); + PlayerMock bare = fx.server.addPlayer(); + assertEquals(CharacterRiteService.Bind.NO_CHARACTER, CharacterRiteService.bind(mage, bare)); + bridge.when(() -> RpCharactersBridge.isDown(victim)).thenReturn(false); + assertEquals(CharacterRiteService.Bind.NOT_DOWN, CharacterRiteService.bind(mage, victim)); + bridge.when(() -> RpCharactersBridge.isDown(victim)).thenReturn(true); + victim.teleport(new Location(fx.world, 40, 10, 40)); + assertEquals(CharacterRiteService.Bind.TOO_FAR, CharacterRiteService.bind(mage, victim)); + victim.teleport(new Location(fx.server.addSimpleWorld("away"), 0, 10, 0)); + assertEquals(CharacterRiteService.Bind.TOO_FAR, CharacterRiteService.bind(mage, victim)); + victim.teleport(new Location(fx.world, 1.5, 10, 0.5)); + bridge.when(() -> RpCharactersBridge.playtimeSeconds(victim)).thenReturn(60L); + assertEquals(CharacterRiteService.Bind.TOO_NEW, CharacterRiteService.bind(mage, victim)); + bridge.when(() -> RpCharactersBridge.playtimeSeconds(victim)).thenReturn(100L * 3600); + bridge.when(() -> RpCharactersBridge.characterAgeSeconds(victim)).thenReturn(60L); + assertEquals(CharacterRiteService.Bind.TOO_NEW, CharacterRiteService.bind(mage, victim)); + bridge.when(() -> RpCharactersBridge.characterAgeSeconds(victim)).thenReturn(100L * 86_400); + record.setLastVictimAccount(victim.getUniqueId().toString()); + assertEquals(CharacterRiteService.Bind.REPEAT, CharacterRiteService.bind(mage, victim)); + record.setLastVictimAccount(null); + talks.put(victimId, 5); + assertEquals(CharacterRiteService.Bind.RESERVED, CharacterRiteService.bind(mage, victim)); + talks.clear(); + + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, victim)); + assertEquals("blood.rite.dread", victim.nextMessage()); + bridge.verify(() -> RpCharactersBridge.moveDowned(eq(victim), argThat(at -> at.getY() == 11.0))); + assertTrue(CharacterRiteService.hasRite(mage.getUniqueId())); + assertEquals(CharacterRiteService.Bind.BUSY, CharacterRiteService.bind(mage, victim)); + PlayerMock second = fx.player("second-mage"); + String secondId = BloodFixture.id("second-mage"); + BloodRecords.getOrCreate(secondId, "o").setStage(BloodStage.HUNT); + BloodAltars.add(BloodAltar.create(secondId, "o", fx.pocket(2, 10, 0), 0f, 1L)); + second.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, secondId)); + assertEquals(CharacterRiteService.Bind.BUSY, CharacterRiteService.bind(second, victim)); + PlayerMock otherVictim = victim("other-victim"); + otherVictim.teleport(new Location(fx.world, 2.5, 10, 0.5)); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(second, otherVictim)); + Files.writeString(temp.resolve("nr.yml"), "enabled: true\nhunt:\n victims: { no_repeat_player: false }\n"); + BloodConfig.load(temp.resolve("nr.yml").toFile(), fx.logger); + CharacterRiteService.clear(); + record.setLastVictimAccount(victim.getUniqueId().toString()); + victim.teleport(new Location(fx.world, 1.5, 10, 0.5)); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, victim)); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + record.setLastVictimAccount(null); + + record.setStage(BloodStage.FINAL); + CharacterRiteService.clear(); + record.setNextFinalAt(now.get() + 1000); + assertEquals(CharacterRiteService.Bind.WAITING, CharacterRiteService.bind(mage, victim)); + record.setNextFinalAt(0); + assertEquals(CharacterRiteService.Bind.NOT_TARGET, CharacterRiteService.bind(mage, victim)); + record.getFinalTargets().add(victimId); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, victim)); + } + + @Test + void huntSacrificesAddPointsAndTheSeventhPointOpensTheFinals() throws Exception { + Files.writeString(temp.resolve("p.yml"), "enabled: true\nhunt:\n points_required: 2\n points: { stranger: 1 }\nfinals:\n count: 2\nclasses:\n 'true': sanguinar\n"); + BloodConfig.load(temp.resolve("p.yml").toFile(), fx.logger); + String close = offline("Close", CharacterStatus.ALIVE, Instant.ofEpochMilli(now.get())); + String closer = offline("Closer", CharacterStatus.ALIVE, Instant.ofEpochMilli(now.get())); + talks.put(close, 3); + talks.put(closer, 9); + PlayerMock first = victim("first"); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, first)); + CharacterRiteService.tick(); + assertTrue(CharacterRiteService.hasRite(mage.getUniqueId())); + runRite(); + assertEquals(1, record.getHuntPoints()); + assertEquals("blood.rite.taken", mage.nextMessage()); + assertEquals(first.getUniqueId().toString(), record.getLastVictimAccount()); + assertEquals(1, record.getSacrifices().size()); + assertEquals(CharacterRiteService.Bind.REPEAT, CharacterRiteService.bind(mage, first)); + + PlayerMock secondVictim = victim("second"); + talks.put(BloodFixture.id("second"), 1); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, secondVictim)); + runRite(); + assertEquals(BloodStage.FINAL, record.getStage()); + assertEquals("sanguinar", ChoiceTest.FakeUnlocks.last); + assertEquals(List.of(close, closer), record.getFinalTargets()); + assertEquals("blood.hunt.complete", mage.nextMessage()); + assertNotNull(mage.nextMessage()); + } + + @Test + void finalsTakeTheNamedTargetThenTheNextUntilAscension() throws Exception { + Files.writeString(temp.resolve("f.yml"), "enabled: true\nfinals:\n count: 2\n"); + BloodConfig.load(temp.resolve("f.yml").toFile(), fx.logger); + PlayerMock lesser = victim("lesser"); + PlayerMock dearest = victim("dearest"); + String lesserId = BloodFixture.id("lesser"); + String dearestId = BloodFixture.id("dearest"); + talks.put(lesserId, 2); + talks.put(dearestId, 20); + record.setPath(BloodPath.DEVOTEE); + FinalService.begin(mage, record); + assertNull(ChoiceTest.FakeUnlocks.last); + assertEquals(List.of(lesserId, dearestId), record.getFinalTargets()); + assertNotNull(mage.nextMessage()); + + assertEquals(CharacterRiteService.Bind.NOT_TARGET, CharacterRiteService.bind(mage, dearest)); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, lesser)); + runRite(); + assertEquals(1, record.getFinalsDone()); + assertEquals(List.of(dearestId), record.getFinalTargets()); + assertEquals("blood.final.taken", mage.nextMessage()); + assertNotNull(mage.nextMessage()); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, dearest)); + runRite(); + assertEquals(BloodStage.ASCENDED, record.getStage()); + assertEquals("blood.final.ascended", mage.nextMessage()); + assertTrue(record.getFinalTargets().isEmpty()); + assertEquals(100, record.getPool(), "Ascension starts with a full pool"); + } + + @Test + void theVictimHearsTheirDeathComeOnLineByLine() { + PlayerMock stranger = victim("stranger"); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, stranger)); + List heard = new java.util.ArrayList<>(); + for (int second = 1; second <= 10; second++) { + now.addAndGet(1000L); + CharacterRiteService.tick(); + } + String line; + while ((line = stranger.nextMessage()) != null) { + heard.add(line); + } + assertEquals(List.of("blood.rite.dread", "blood.rite.dying.1", "blood.rite.dying.2", "blood.rite.dying.3", + "blood.rite.dying.4", "blood.rite.dying.last"), + heard.stream().filter(said -> said.startsWith("blood.rite.d")).toList()); + } + + @Test + void onceThePathIsCompletePeopleRefillThePoolByCloseness() { + record.setStage(BloodStage.ASCENDED); + record.setPool(10.0); + PlayerMock stranger = victim("stranger"); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, stranger)); + runRite(); + assertEquals(50, record.getPool()); + assertEquals("blood.pool.fed", mage.nextMessage()); + assertEquals(BloodStage.ASCENDED.name(), record.getSacrifices().get(0).stage()); + assertEquals(CharacterRiteService.Bind.REPEAT, CharacterRiteService.bind(mage, stranger)); + PlayerMock dearest = victim("dearest"); + talks.put(BloodFixture.id("dearest"), 40); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, dearest)); + runRite(); + assertEquals(100, record.getPool(), "The closest fill it to the brim"); + bridge.when(() -> RpCharactersBridge.playtimeSeconds(any())).thenReturn(0L); + assertEquals(CharacterRiteService.Bind.TOO_NEW, CharacterRiteService.bind(mage, victim("fresh"))); + } + + @Test + void ritesBreakWhenTheMageLeavesOrAnyoneSwitchesAndRefusedDeathsCountNothing() { + PlayerMock victim = victim("victim"); + CharacterRiteService.bind(mage, victim); + mage.teleport(new Location(fx.world, 60, 10, 60)); + CharacterRiteService.tick(); + assertFalse(CharacterRiteService.hasRite(mage.getUniqueId())); + assertEquals("blood.rite.broken", mage.nextMessage()); + mage.teleport(new Location(fx.world, 0.5, 11, 0.5)); + + victim.teleport(new Location(fx.world, 1.5, 10, 0.5)); + CharacterRiteService.bind(mage, victim); + fx.characters.start(victim, "victim-alt"); + CharacterRiteService.tick(); + assertFalse(CharacterRiteService.hasRite(mage.getUniqueId())); + fx.characters.start(victim, "victim"); + + victim.teleport(new Location(fx.world, 1.5, 10, 0.5)); + CharacterRiteService.bind(mage, victim); + fx.characters.start(mage, "mage-alt"); + CharacterRiteService.tick(); + assertFalse(CharacterRiteService.hasRite(mage.getUniqueId())); + fx.characters.start(mage, "mage"); + + victim.teleport(new Location(fx.world, 1.5, 10, 0.5)); + CharacterRiteService.bind(mage, victim); + BloodAltar altar = BloodAltars.ofCharacter(mageId); + BloodAltars.remove(altar); + CharacterRiteService.tick(); + assertFalse(CharacterRiteService.hasRite(mage.getUniqueId())); + BloodAltars.add(altar); + + victim.teleport(new Location(fx.world, 1.5, 10, 0.5)); + CharacterRiteService.bind(mage, victim); + mage.teleport(new Location(fx.server.addSimpleWorld("void"), 0, 10, 0)); + CharacterRiteService.tick(); + assertFalse(CharacterRiteService.hasRite(mage.getUniqueId())); + mage.teleport(new Location(fx.world, 0.5, 11, 0.5)); + + victim.teleport(new Location(fx.world, 1.5, 10, 0.5)); + CharacterRiteService.bind(mage, victim); + victim.disconnect(); + CharacterRiteService.tick(); + assertFalse(CharacterRiteService.hasRite(mage.getUniqueId())); + + PlayerMock other = victim("other"); + CharacterRiteService.bind(mage, other); + mage.disconnect(); + CharacterRiteService.tick(); + + PlayerMock again = fx.player("mage"); + again.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, mageId)); + other.teleport(new Location(fx.world, 1.5, 10, 0.5)); + bridge.when(() -> RpCharactersBridge.sacrifice(any(), any())).thenReturn(false); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(again, other)); + runRite(); + assertEquals("blood.rite.refused", again.nextMessage()); + assertEquals(0, record.getHuntPoints()); + + bridge.when(() -> RpCharactersBridge.sacrifice(any(), any())).thenAnswer(i -> { + assertTrue(CharacterRiteService.isBeingSacrificed(BloodFixture.id("other"))); + new BloodListener().onCharacterDeath(new net.tfminecraft.tlibs.character.CharacterDeathEvent( + new CharacterRef(other.getUniqueId(), UUID.fromString(BloodFixture.id("other"))), UUID.randomUUID(), + Instant.EPOCH, null, null, "SACRIFICE")); + return true; + }); + other.teleport(new Location(fx.world, 1.5, 10, 0.5)); + CharacterRiteService.bind(again, other); + BloodRecords.delete(mageId); + runRite(); + bridge.when(() -> RpCharactersBridge.sacrifice(any(), any())).thenReturn(true); + assertFalse(CharacterRiteService.isBeingSacrificed(BloodFixture.id("other"))); + assertFalse(CharacterRiteService.isBeingSacrificed(null)); + + BloodRecord fresh = fx.record("mage", BloodStage.FINAL); + fresh.getFinalTargets().add(BloodFixture.id("other")); + PlayerMock third = victim("third"); + CharacterRiteService.bind(again, third); + fresh.getFinalTargets().add(0, BloodFixture.id("third")); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(again, third)); + fresh.getFinalTargets().remove(0); + runRite(); + assertEquals(0, fresh.getFinalsDone()); + assertEquals(1, fresh.getSacrifices().size()); + PlayerMock fourth = victim("fourth"); + fresh.getFinalTargets().add(0, BloodFixture.id("fourth")); + fresh.setLastVictimAccount(null); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(again, fourth)); + fresh.setStage(BloodStage.ASCENDED); + runRite(); + assertEquals(2, fresh.getSacrifices().size()); + } + + @Test + void targetsDyingElsewhereFailTheSacrificeAndLaterOnesJustLeave() { + String a = offline("A", CharacterStatus.ALIVE, Instant.ofEpochMilli(now.get())); + String b = offline("B", CharacterStatus.ALIVE, Instant.ofEpochMilli(now.get())); + record.setStage(BloodStage.FINAL); + record.getFinalTargets().addAll(List.of(a, b)); + BloodRecord other = BloodRecords.getOrCreate(UUID.randomUUID().toString(), UUID.randomUUID().toString()); + other.setStage(BloodStage.HUNT); + FinalService.onDeath(b); + assertEquals(List.of(a), record.getFinalTargets()); + assertFalse(record.isPendingBacklash()); + FinalService.onDeath(a.toUpperCase()); + assertTrue(record.getFinalTargets().isEmpty()); + assertEquals(now.get() + 72 * 3_600_000L, record.getNextFinalAt()); + bridge.verify(() -> RpCharactersBridge.applyHealingInjury(mage)); + assertFalse(record.isPendingBacklash()); + assertNotNull(mage.nextMessage()); + FinalService.onDeath(UUID.randomUUID().toString()); + + BloodRecord offlineMage = BloodRecords.getOrCreate(UUID.randomUUID().toString(), UUID.randomUUID().toString()); + offlineMage.setStage(BloodStage.FINAL); + offlineMage.getFinalTargets().add(a); + FinalService.onDeath(a); + assertTrue(offlineMage.isPendingBacklash()); + BloodRecord ownerless = BloodRecords.getOrCreate(UUID.randomUUID().toString(), null); + ownerless.setStage(BloodStage.FINAL); + ownerless.getFinalTargets().add(b); + FinalService.onDeath(b); + assertTrue(ownerless.isPendingBacklash()); + BloodRecord switched = BloodRecords.getOrCreate(UUID.randomUUID().toString(), mage.getUniqueId().toString()); + switched.setStage(BloodStage.FINAL); + switched.getFinalTargets().add(b); + FinalService.onDeath(b); + assertTrue(switched.isPendingBacklash()); + } + + @Test + void checksCatchMissedDeathsAbsenceWaitsAndRefill() throws Exception { + Files.writeString(temp.resolve("c.yml"), "enabled: true\nfinals:\n count: 2\n fail_cooldown_hours: 1\n inactive_days: 1\n"); + BloodConfig.load(temp.resolve("c.yml").toFile(), fx.logger); + PlayerMock nobody = fx.server.addPlayer(); + FinalService.check(nobody); + FinalService.check(mage); + record.setStage(BloodStage.FINAL); + record.setPendingBacklash(true); + String dead = offline("Dead", CharacterStatus.DEAD, Instant.ofEpochMilli(now.get())); + String gone = UUID.randomUUID().toString(); + String absent = offline("Absent", CharacterStatus.ALIVE, Instant.ofEpochMilli(now.get() - 2 * 86_400_000L)); + String unseen = offline("Unseen", CharacterStatus.ALIVE, null); + record.getFinalTargets().addAll(List.of(dead, gone, absent, unseen, "not-a-uuid")); + FinalService.check(mage); + bridge.verify(() -> RpCharactersBridge.applyHealingInjury(mage), times(2)); + assertEquals(List.of(unseen), record.getFinalTargets()); + assertEquals(now.get() + 3_600_000L, record.getNextFinalAt()); + + String fresh = offline("Fresh", CharacterStatus.ALIVE, Instant.ofEpochMilli(now.get())); + talks.put(fresh, 4); + FinalService.check(mage); + assertEquals(List.of(unseen), record.getFinalTargets()); + now.addAndGet(2 * 3_600_000L); + FinalService.check(mage); + assertEquals(2, record.getFinalTargets().size()); + FinalService.check(mage); + + record.getFinalTargets().clear(); + record.getFinalTargets().add(gone); + FinalService.check(mage); + assertEquals(List.of(fresh), record.getFinalTargets()); + assertEquals(fresh, record.currentTarget()); + } + + @Test + void staffCanRerollSetAndSkipTargets() { + String a = offline("A", CharacterStatus.ALIVE, Instant.ofEpochMilli(now.get())); + String b = offline("B", CharacterStatus.ALIVE, Instant.ofEpochMilli(now.get())); + talks.put(a, 2); + talks.put(b, 1); + record.setStage(BloodStage.FINAL); + record.getFinalTargets().add(a); + FinalService.reroll(mage, record); + assertEquals(List.of(b), record.getFinalTargets()); + record.getFinalTargets().clear(); + FinalService.reroll(mage, record); + assertEquals(List.of(b, a), record.getFinalTargets()); + FinalService.set(mage, record, a.toUpperCase()); + assertEquals(List.of(a, b), record.getFinalTargets()); + FinalService.skip(mage, record); + assertEquals(1, record.getFinalsDone()); + assertEquals(List.of(b), record.getFinalTargets()); + record.getFinalTargets().clear(); + talks.clear(); + FinalService.skip(mage, record); + assertEquals(2, record.getFinalsDone()); + FinalService.name(mage, record); + record.setNextFinalAt(0); + FinalService.check(mage); + assertNull(record.currentTarget()); + assertEquals("?", FinalService.nameOf("bad")); + assertEquals("?", FinalService.nameOf(UUID.randomUUID().toString())); + assertEquals("A", FinalService.nameOf(a)); + FinalService.backlash(mage, record); + } + + @Test + void bridgeHelpersReadTheActiveCharacter() { + bridge.close(); + try (var rp = mockStatic(net.tfminecraft.rpcharacters.RPCharacters.class); + var permadeath = mockStatic(net.tfminecraft.rpcharacters.permadeath.PermadeathService.class)) { + assertEquals(Map.of(), RpCharactersBridge.conversationCounts(mage)); + assertNull(RpCharactersBridge.willHeir(mage)); + assertEquals(0, RpCharactersBridge.characterAgeSeconds(mage)); + assertEquals(0, RpCharactersBridge.playtimeSeconds(mage)); + assertFalse(RpCharactersBridge.sacrifice(mage, mage)); + assertFalse(RpCharactersBridge.applyHealingInjury(mage)); + assertFalse(RpCharactersBridge.isDown(mage)); + var knockouts = mock(net.tfminecraft.rpcharacters.pvp.PvpKnockoutManager.class); + rp.when(net.tfminecraft.rpcharacters.RPCharacters::getPvpKnockoutManager).thenReturn(knockouts); + when(knockouts.isDown(mage)).thenReturn(true); + assertTrue(RpCharactersBridge.isDown(mage)); + var character = mock(net.tfminecraft.rpcharacters.objects.RPCharacter.class); + when(character.getId()).thenReturn(mageId); + when(character.getConversationCounts()).thenReturn(Map.of("x", 2)); + when(character.getWill()).thenReturn(net.tfminecraft.rpcharacters.will.CharacterWill.NONE); + when(character.getOnlinePlaytimeSeconds()).thenReturn(50); + rp.when(() -> net.tfminecraft.rpcharacters.RPCharacters.getActiveCharacter(mage)).thenReturn(character); + rp.when(() -> net.tfminecraft.rpcharacters.RPCharacters.getCharacterAgeSeconds(character)).thenReturn(70); + assertEquals(Map.of("x", 2), RpCharactersBridge.conversationCounts(mage)); + assertNull(RpCharactersBridge.willHeir(mage)); + UUID heir = UUID.randomUUID(); + when(character.getWill()).thenReturn(net.tfminecraft.rpcharacters.will.CharacterWill.NONE.change(heir, 1L, "r")); + assertEquals(heir.toString(), RpCharactersBridge.willHeir(mage)); + assertEquals(70, RpCharactersBridge.characterAgeSeconds(mage)); + assertEquals(50, RpCharactersBridge.playtimeSeconds(mage)); + permadeath.when(() -> net.tfminecraft.rpcharacters.permadeath.PermadeathService.killCharacter(mage, character, + net.tfminecraft.rpcharacters.permadeath.PermakillCause.SACRIFICE, mage)).thenReturn(true); + assertTrue(RpCharactersBridge.sacrifice(mage, mage)); + permadeath.when(() -> net.tfminecraft.rpcharacters.permadeath.PermadeathService.applyRandomInjury(mage, character)) + .thenReturn(true); + assertTrue(RpCharactersBridge.applyHealingInjury(mage)); + } + bridge = mockStatic(RpCharactersBridge.class); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java b/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java new file mode 100644 index 0000000..b057eb5 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java @@ -0,0 +1,109 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.logging.Logger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class HuntConfigTest { + @TempDir Path temp; + final Logger log = Logger.getAnonymousLogger(); + + @AfterEach + void reset() { + BloodConfig.reset(); + } + + @Test + void bundledHuntBondsAndFinalsLoad() { + assertTrue(BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), log)); + assertEquals(8, HuntConfig.dragRange()); + assertEquals(10, HuntConfig.riteSeconds()); + assertEquals(10, HuntConfig.minPlaytimeHours()); + assertEquals(7, HuntConfig.minAgeDays()); + assertTrue(HuntConfig.noRepeatPlayer()); + assertEquals(1, HuntConfig.points(0)); + assertEquals(3, HuntConfig.points(6)); + assertEquals(3, HuntConfig.points(10)); + assertEquals(2, HuntConfig.points(11)); + assertEquals(1, HuntConfig.points(40)); + assertEquals(5, HuntConfig.sampleMinutes()); + assertEquals(16, HuntConfig.nearRadius()); + assertEquals(3, HuntConfig.conversationWeight()); + assertEquals(2, HuntConfig.hourWeight()); + assertEquals(40, HuntConfig.heirBonus()); + assertEquals(10, HuntConfig.heirMinHours()); + assertEquals(20, HuntConfig.heirMinConversations()); + assertEquals(5, HuntConfig.finalCount()); + assertEquals(72 * 3_600_000L, HuntConfig.failCooldownMillis()); + assertEquals(21 * 86_400_000L, HuntConfig.inactiveMillis()); + assertEquals(Map.of(), HuntConfig.boonsUpTo(BloodPath.TRUE, 0)); + assertEquals(Map.of("MAX_HEALTH", 6.0), HuntConfig.boonsUpTo(BloodPath.TRUE, 3)); + assertEquals(Map.of(), HuntConfig.boonsUpTo(BloodPath.NONE, 5)); + assertEquals(Map.of(), HuntConfig.boonsUpTo(BloodPath.DEVOTEE, 5), "Devotees get no boons"); + } + + @Test + void badEntriesAreClampedOrSkipped() throws Exception { + String yaml = String.join("\n", + "hunt:", + " drag_range: 0", + " rite_seconds: 0", + " victims: { min_playtime_hours: -1, min_age_days: -1, no_repeat_player: false }", + " points:", + " stranger: -1", + " default: -1", + " ranks:", + " - { up_to: 20, points: 2 }", + " - { up_to: 8, points: -4 }", + " - { up_to: x }", + " - { up_to: 3 }", + "bonds: { sample_minutes: 0, near_radius: 0, conversation_weight: -1, hour_weight: -1, heir_bonus: -1, heir_min_hours: -1, heir_min_conversations: -1 }", + "finals:", + " count: 0", + " fail_cooldown_hours: -1", + " inactive_days: 0", + " boons:", + " devotee:", + " 0: { MAX_HEALTH: 1 }", + " x: { MAX_HEALTH: 1 }", + " 2: 5", + " 3: { MAX_HEALTH: 1.5 }", + ""); + Files.writeString(temp.resolve("h.yml"), yaml); + BloodConfig.load(temp.resolve("h.yml").toFile(), log); + assertEquals(1, HuntConfig.dragRange()); + assertEquals(1, HuntConfig.riteSeconds()); + assertEquals(0, HuntConfig.minPlaytimeHours()); + assertEquals(0, HuntConfig.minAgeDays()); + assertFalse(HuntConfig.noRepeatPlayer()); + assertEquals(0, HuntConfig.points(0)); + assertEquals(0, HuntConfig.points(5)); + assertEquals(2, HuntConfig.points(15)); + assertEquals(0, HuntConfig.points(99)); + assertEquals(1, HuntConfig.sampleMinutes()); + assertEquals(1, HuntConfig.nearRadius()); + assertEquals(0, HuntConfig.conversationWeight()); + assertEquals(0, HuntConfig.hourWeight()); + assertEquals(0, HuntConfig.heirBonus()); + assertEquals(0, HuntConfig.heirMinHours()); + assertEquals(0, HuntConfig.heirMinConversations()); + assertEquals(1, HuntConfig.finalCount()); + assertEquals(0, HuntConfig.failCooldownMillis()); + assertEquals(86_400_000L, HuntConfig.inactiveMillis()); + assertEquals(Map.of("MAX_HEALTH", 1.5), HuntConfig.boonsUpTo(BloodPath.DEVOTEE, 5)); + assertEquals(Map.of(), HuntConfig.boonsUpTo(BloodPath.TRUE, 5)); + + Files.writeString(temp.resolve("f.yml"), "finals:\n count: 3\n"); + BloodConfig.load(temp.resolve("f.yml").toFile(), log); + assertEquals(3, HuntConfig.finalCount()); + assertEquals(Map.of(), HuntConfig.boonsUpTo(BloodPath.DEVOTEE, 5)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/InjuryBridgeTest.java b/src/test/java/net/tfminecraft/magic/blood/InjuryBridgeTest.java new file mode 100644 index 0000000..7f5e0de --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/InjuryBridgeTest.java @@ -0,0 +1,55 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import net.tfminecraft.magic.integration.RpCharactersBridge; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.loaders.TraitLoader; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.objects.trait.Trait; +import net.tfminecraft.rpcharacters.utils.TraitChangeService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class InjuryBridgeTest { + @TempDir Path temp; + + @Test + void aTraitGoesToTheActiveCharacterOnceAndIsSeenThere() { + Trait manaless = mock(Trait.class); + when(manaless.getId()).thenReturn("manaless"); + Trait other = mock(Trait.class); + when(other.getId()).thenReturn("one_handed"); + List owned = new ArrayList<>(List.of(other)); + try (BloodFixture fx = new BloodFixture(temp); + var rp = mockStatic(RPCharacters.class); + var changes = mockStatic(TraitChangeService.class)) { + TraitLoader.oList.add(manaless); + var player = fx.player("mage"); + assertFalse(RpCharactersBridge.applyTrait(player, "manaless"), "No active character"); + assertFalse(RpCharactersBridge.hasTrait(player, "manaless")); + RPCharacter character = mock(RPCharacter.class); + when(character.getId()).thenReturn(BloodFixture.id("mage")); + when(character.getTraits()).thenReturn(owned); + rp.when(() -> RPCharacters.getActiveCharacter(player)).thenReturn(character); + assertFalse(RpCharactersBridge.applyTrait(player, "nonexistent")); + assertFalse(RpCharactersBridge.applyTrait(player, null)); + assertFalse(RpCharactersBridge.hasTrait(player, null)); + assertFalse(RpCharactersBridge.hasTrait(player, "manaless")); + assertTrue(RpCharactersBridge.applyTrait(player, "manaless")); + changes.verify(() -> TraitChangeService.addTrait(player, character, manaless)); + changes.verify(() -> TraitChangeService.sendGainedMessage(player, manaless)); + owned.add(manaless); + assertTrue(RpCharactersBridge.hasTrait(player, "MANALESS")); + assertTrue(RpCharactersBridge.applyTrait(player, "manaless"), "Already marked"); + changes.verify(() -> TraitChangeService.addTrait(any(), any(), any()), times(1)); + } finally { + TraitLoader.oList.remove(manaless); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/MagicBloodRouteTest.java b/src/test/java/net/tfminecraft/magic/blood/MagicBloodRouteTest.java new file mode 100644 index 0000000..d2d2a63 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/MagicBloodRouteTest.java @@ -0,0 +1,25 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; + +import net.tfminecraft.magic.command.MagicCommand; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.command.ConsoleCommandSenderMock; + +class MagicBloodRouteTest { + @TempDir Path temp; + + @Test + void magicCommandRoutesBloodToStaffTools() { + try (BloodFixture fx = new BloodFixture(temp)) { + ConsoleCommandSenderMock console = (ConsoleCommandSenderMock) fx.server.getConsoleSender(); + MagicCommand command = new MagicCommand(); + assertTrue(command.onCommand(console, null, "magic", new String[] {"blood", "list"})); + assertEquals("blood.admin.list_header", console.nextMessage()); + assertEquals(java.util.List.of("list"), command.onTabComplete(console, null, "magic", new String[] {"blood", "li"})); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/MeditationTest.java b/src/test/java/net/tfminecraft/magic/blood/MeditationTest.java new file mode 100644 index 0000000..ec34dc4 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/MeditationTest.java @@ -0,0 +1,312 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.UnaryOperator; + +import org.bukkit.Location; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.Entity; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.inventory.ItemStack; +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 org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; + +class MeditationTest { + @TempDir Path temp; + BloodFixture fx; + PlayerMock mage; + String mageId; + BloodRecord record; + BloodAltar altar; + MockedStatic pedestals; + final List shown = new ArrayList<>(); + int restyled; + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + mage = fx.player("mage"); + mageId = BloodFixture.id("mage"); + record = fx.record("mage", BloodStage.FINAL); + record.setPath(BloodPath.DEVOTEE); + fx.open(-5, 10, -5, 5, 12, 5); + altar = BloodAltar.create(mageId, mage.getUniqueId().toString(), fx.pocket(0, 10, 0), 0f, 1L); + BloodAltars.add(altar); + pedestals = mockStatic(AltarPedestals.class, CALLS_REAL_METHODS); + pedestals.when(() -> AltarPedestals.artefacts(any())).thenAnswer(i -> List.copyOf(shown)); + pedestals.when(() -> AltarPedestals.restyle(any(), any())).thenAnswer(i -> { + UnaryOperator style = i.getArgument(1); + for (AltarPedestals.Shown each : shown) { + style.apply(each.item().clone()); + } + restyled++; + return null; + }); + } + + @AfterEach + void cleanup() { + BloodMeditation.clear(); + pedestals.close(); + fx.close(); + } + + void place(BloodRecord owner, String id, double charge) { + owner.setCharge(id, charge); + shown.add(new AltarPedestals.Shown(mock(Furniture.class), mock(PlacedSlot.class), + BloodArtefacts.create(owner, id), new Location(fx.world, 3.5, 11.8, 0.5))); + } + + void sit(PlayerMock player) { + ArmorStand seat = fx.world.spawn(player.getLocation(), ArmorStand.class); + seat.addPassenger(player); + } + + void tick(int times) { + for (int i = 0; i < times; i++) { + BloodMeditation.tick(); + } + } + + long drops() { + return fx.world.getEntitiesByClass(ItemDisplay.class).stream().filter(Entity::isValid).count(); + } + + String messages(PlayerMock player) { + StringBuilder out = new StringBuilder(); + String message; + while ((message = player.nextMessage()) != null) { + out.append(message).append('\n'); + } + return out.toString(); + } + + @Test + void onlyAMageWithChargedArtefactsOnThePedestalsCanBegin() { + PlayerMock bare = fx.server.addPlayer(); + assertEquals(BloodMeditation.Begin.NOT_READY, BloodMeditation.start(bare, altar)); + record.setStage(BloodStage.HUNT); + assertEquals(BloodMeditation.Begin.NOT_READY, BloodMeditation.start(mage, altar)); + record.setStage(BloodStage.FINAL); + assertEquals(BloodMeditation.Begin.DRY, BloodMeditation.start(mage, altar)); + BloodRecord other = fx.record("other", BloodStage.FINAL); + other.setPath(BloodPath.DEVOTEE); + place(other, "devotee:0", 2); + place(record, "devotee:1", 0); + place(record, "true:hunt:0", 2); + assertEquals(BloodMeditation.Begin.DRY, BloodMeditation.start(mage, altar), + "Someone else's, an empty one and one of another path are no use"); + place(record, "devotee:2", 2); + record.setResonance(20); + assertEquals(BloodMeditation.Begin.FULL, BloodMeditation.start(mage, altar)); + record.setResonance(0); + assertEquals(BloodMeditation.Begin.STARTED, BloodMeditation.start(mage, altar)); + assertTrue(BloodMeditation.isMeditating(mage.getUniqueId())); + assertEquals(BloodMeditation.Begin.BUSY, BloodMeditation.start(mage, altar)); + mage.assertSoundHeard("tfmc_blood:meditate.chant"); + assertFalse(BloodMeditation.swing(bare)); + } + + @Test + void dropsRiseOnTheBeatAndCatchingThemDrawsCharge() { + sit(mage); + place(record, "devotee:0", 2.5); + assertEquals(BloodMeditation.Begin.STARTED, BloodMeditation.start(mage, altar)); + tick(29); + assertEquals(0, drops()); + tick(1); + assertEquals(1, drops(), "The first beat sends a drop"); + mage.assertSoundHeard("entity.warden.heartbeat"); + tick(4); + assertTrue(BloodMeditation.swing(mage), "Nothing in reach yet: the swing does nothing"); + assertEquals(0, record.getResonance()); + // Two beats later the first drop waits in front of the mage; caught between beats. + tick(71); + double before = mage.getHealth(); + assertTrue(BloodMeditation.swing(mage)); + assertEquals(1, record.getResonance(), "Off the beat: a little"); + assertEquals(before - 1, mage.getHealth(), "and some of the mage's own blood"); + // The second drop arrives right on a beat. + tick(45); + assertTrue(BloodMeditation.swing(mage)); + assertEquals(2.5, record.getResonance(), "On the beat: all that is left in it"); + assertEquals(before - 1, mage.getHealth()); + assertEquals(0, record.getCharge("devotee:0")); + mage.setHealth(1); + tick(45); + assertTrue(BloodMeditation.swing(mage)); + assertEquals(1, mage.getHealth(), "Never the last point of health"); + assertEquals(2.5, record.getResonance(), "A dry artefact's drop gives nothing"); + tick(20); + BloodRecords.delete(mageId); + assertTrue(BloodMeditation.swing(mage)); + } + + @Test + void itEndsWhenTheMageStandsTheArtefactsRunDryOrTheBloodIsFull() throws Exception { + sit(mage); + place(record, "devotee:0", 1); + BloodMeditation.start(mage, altar); + tick(90); + BloodMeditation.swing(mage); + assertEquals(1, record.getResonance()); + messages(mage); + tick(130); + assertFalse(BloodMeditation.isMeditating(mage.getUniqueId())); + assertTrue(messages(mage).contains("blood.meditate.end.dry")); + assertTrue(restyled > 0, "The drained artefacts stop glowing"); + + record.setCharge("devotee:0", 2.5); + record.setResonance(19); + BloodMeditation.start(mage, altar); + tick(90); + BloodMeditation.swing(mage); + tick(1); + assertTrue(messages(mage).contains("blood.meditate.end.full")); + assertEquals(20, record.getResonance()); + + record.setResonance(0); + BloodMeditation.start(mage, altar); + tick(35); + assertEquals(1, drops()); + mage.leaveVehicle(); + tick(1); + assertTrue(messages(mage).contains("blood.meditate.end.stood")); + assertEquals(0, drops(), "Its drops go with it"); + + sit(mage); + BloodMeditation.start(mage, altar); + mage.teleport(new Location(fx.world, 4.5, 11, 0.5)); + sit(mage); + tick(1); + assertFalse(BloodMeditation.isMeditating(mage.getUniqueId()), "Walked off"); + mage.teleport(new Location(fx.world, 0.5, 11, 0.5)); + sit(mage); + BloodMeditation.start(mage, altar); + fx.characters.start(mage, "elsewhere"); + tick(1); + assertFalse(BloodMeditation.isMeditating(mage.getUniqueId()), "Switched character"); + fx.characters.start(mage, "mage"); + var away = fx.server.addSimpleWorld("away"); + BloodMeditation.start(mage, altar); + mage.teleport(new Location(away, 0.5, 11, 0.5)); + sit(mage); + tick(1); + assertFalse(BloodMeditation.isMeditating(mage.getUniqueId()), "Another world"); + mage.teleport(new Location(fx.world, 0.5, 11, 0.5)); + sit(mage); + BloodMeditation.start(mage, altar); + BloodRecords.delete(mageId); + tick(1); + assertFalse(BloodMeditation.isMeditating(mage.getUniqueId()), "Its record went"); + record = fx.record("mage", BloodStage.FINAL); + record.setPath(BloodPath.DEVOTEE); + record.setCharge("devotee:0", 2.5); + BloodMeditation.start(mage, altar); + BloodAltars.remove(altar); + tick(1); + assertFalse(BloodMeditation.isMeditating(mage.getUniqueId()), "Its altar went"); + BloodAltars.add(altar); + BloodAltar lost = new com.google.gson.Gson().fromJson( + "{\"id\":\"lost\",\"ownerCharacter\":\"" + mageId + "\",\"world\":\"nowhere\",\"x\":0,\"y\":10,\"z\":0}", + BloodAltar.class); + BloodAltars.add(lost); + BloodMeditation.start(mage, lost); + tick(1); + assertFalse(BloodMeditation.isMeditating(mage.getUniqueId()), "Its world is not loaded"); + BloodMeditation.start(mage, altar); + mage.disconnect(); + tick(1); + assertFalse(BloodMeditation.isMeditating(mage.getUniqueId()), "Gone"); + + PlayerMock again = fx.player("mage"); + sit(again); + BloodMeditation.start(again, altar); + BloodMeditation.stop(again.getUniqueId()); + assertFalse(messages(again).contains("blood.meditate.end"), "Stopped quietly"); + BloodMeditation.stop(again.getUniqueId()); + BloodMeditation.start(again, altar); + BloodMeditation.clear(); + assertFalse(BloodMeditation.isMeditating(again.getUniqueId())); + } + + @Test + void dropsLeftAloneFallBackAndTheMusicLoops() throws Exception { + sit(mage); + place(record, "devotee:0", 2.5); + BloodMeditation.start(mage, altar); + tick(30); + assertEquals(1, drops()); + tick(120); + assertTrue(drops() <= 4); + mage.clearSounds(); + tick(600); + mage.assertSoundHeard("tfmc_blood:meditate.chant"); + assertTrue(drops() <= 4, "Never more than drops.max at once"); + + Files.writeString(temp.resolve("m.yml"), String.join("\n", + "enabled: true", + "pool: { worth: { devotee: { 0: 20 } } }", + "artefacts: { devotee: [ { item: m.a.b } ] }", + "meditation: { sounds: { music: { sound: x.y, seconds: 0 } } }", + "")); + BloodConfig.load(temp.resolve("m.yml").toFile(), fx.logger); + BloodMeditation.clear(); + shown.clear(); + place(record, "devotee:0", 20); + BloodMeditation.start(mage, altar); + mage.clearSounds(); + tick(700); + assertTrue(mage.getHeardSounds().stream().noneMatch(sound -> sound.getSound().equals("x.y")), "Played once"); + } + + @Test + void quietCuesPlayNothing() { + MeditationConfig.Cue.NONE.play(mage); + MeditationConfig.Cue.NONE.stop(mage); + assertTrue(mage.getHeardSounds().isEmpty()); + } + + @Test + void dropsBehindOrOutOfReachCannotBeCaught() { + sit(mage); + record.setCharge("devotee:0", 2.5); + shown.add(new AltarPedestals.Shown(mock(Furniture.class), mock(PlacedSlot.class), + BloodArtefacts.create(record, "devotee:0"), new Location(fx.world, 0.5, 12.6, 9.5))); + BloodMeditation.start(mage, altar); + tick(30); + new BloodListener().onSwing(new org.bukkit.event.player.PlayerAnimationEvent(mage, + org.bukkit.event.player.PlayerAnimationType.ARM_SWING)); + assertEquals(0, record.getResonance(), "Too far off"); + mage.setRotation(180, 0); + tick(1); + BloodMeditation.swing(mage); + assertEquals(0, record.getResonance(), "Behind the mage"); + } + + @Test + void sittingOnTheAltarStartsTheMeditationForItsOwner() { + ArmorStand seat = fx.world.spawn(new Location(fx.world, 0.5, 10.5, 0.5), ArmorStand.class); + AltarService.sat(mage, seat); + assertTrue(messages(mage).contains("blood.meditate.start.dry")); + record.setPath(BloodPath.NONE); + AltarService.sat(mage, seat); + assertFalse(messages(mage).contains("blood.meditate")); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java b/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java new file mode 100644 index 0000000..6ca8270 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java @@ -0,0 +1,265 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import net.tfminecraft.magic.integration.RpCharactersBridge; +import net.tfminecraft.magic.tick.MagicTickContext; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockPlaceEvent; +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 org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +/** The fixes from the first review round: rite paths, quits, carry safety, protections and the off switch. */ +class ReviewFixesTest { + @TempDir Path temp; + BloodFixture fx; + MockedStatic bridge; + final AtomicLong now = new AtomicLong(1_000_000_000_000L); + PlayerMock mage; + PlayerMock victim; + String mageId; + BloodRecord record; + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + BondService.init(new BloodStore(temp.resolve("bonds").toFile(), fx.logger)); + CharacterRiteService.clock = now::get; + bridge = mockStatic(RpCharactersBridge.class); + bridge.when(() -> RpCharactersBridge.isDown(any())).thenAnswer(i -> i.getArgument(0) != mage); + bridge.when(() -> RpCharactersBridge.playtimeSeconds(any())).thenReturn(100L * 3600); + bridge.when(() -> RpCharactersBridge.characterAgeSeconds(any())).thenReturn(100L * 86_400); + bridge.when(() -> RpCharactersBridge.conversationCounts(any())).thenAnswer(i -> new HashMap<>()); + bridge.when(() -> RpCharactersBridge.sacrifice(any(), any())).thenReturn(true); + bridge.when(() -> RpCharactersBridge.moveDowned(any(), any())).thenAnswer(i -> { + Player body = i.getArgument(0); + return body.teleport((Location) i.getArgument(1)); + }); + fx.open(-10, 10, -3, 20, 12, 4); + mage = fx.player("mage"); + mageId = BloodFixture.id("mage"); + record = BloodRecords.getOrCreate(mageId, mage.getUniqueId().toString()); + record.setStage(BloodStage.HUNT); + record.setPath(BloodPath.TRUE); + BloodAltars.add(BloodAltar.create(mageId, mage.getUniqueId().toString(), fx.pocket(0, 10, 0), 0f, 1L)); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, mageId)); + mage.teleport(new Location(fx.world, 2.5, 10, 0.5)); + victim = fx.player("victim"); + victim.teleport(new Location(fx.world, 1.5, 10, 0.5)); + } + + @AfterEach + void cleanup() { + CarryService.clear(); + CharacterRiteService.clear(); + BondService.clear(); + CharacterRiteService.clock = System::currentTimeMillis; + bridge.close(); + fx.close(); + } + + @Test + void theRiteNeedsAClearLineAndAFreeBody() { + victim.teleport(new Location(fx.world, 3.5, 10, 0.5)); + fx.world.getBlockAt(2, 10, 0).setType(Material.OAK_STAIRS); + assertTrue(CharacterRiteService.clearPath(victim.getLocation(), BloodAltars.ofCharacter(mageId).center())); + fx.world.getBlockAt(2, 11, 0).setType(Material.STONE); + assertEquals(CharacterRiteService.Bind.BLOCKED, CharacterRiteService.bind(mage, victim)); + fx.world.getBlockAt(2, 11, 0).setType(Material.AIR); + // A window: a full wall at floor level with only the layer above open is still a wall. + fx.world.getBlockAt(2, 10, 0).setType(Material.STONE); + assertEquals(CharacterRiteService.Bind.BLOCKED, CharacterRiteService.bind(mage, victim)); + fx.world.getBlockAt(2, 10, 0).setType(Material.OAK_SLAB); + assertTrue(CharacterRiteService.clearPath(victim.getLocation(), BloodAltars.ofCharacter(mageId).center())); + fx.world.getBlockAt(2, 10, 0).setType(Material.AIR); + // A body lying a step below the altar: the line clips the altar's own block, which never counts. + fx.world.getBlockAt(2, 9, 0).setType(Material.AIR); + assertTrue(CharacterRiteService.clearPath(new Location(fx.world, 2.5, 9, 0.5), BloodAltars.ofCharacter(mageId).center())); + bridge.when(() -> RpCharactersBridge.moveDowned(any(), any())).thenReturn(false); + assertEquals(CharacterRiteService.Bind.BLOCKED, CharacterRiteService.bind(mage, victim)); + assertFalse(CharacterRiteService.hasRite(mage.getUniqueId())); + bridge.when(() -> RpCharactersBridge.moveDowned(any(), any())).thenAnswer(i -> { + Player body = i.getArgument(0); + return body.teleport((Location) i.getArgument(1)); + }); + + PlayerMock rival = fx.player("rival"); + String rivalId = BloodFixture.id("rival"); + BloodRecords.getOrCreate(rivalId, rival.getUniqueId().toString()).setStage(BloodStage.HUNT); + rival.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, rivalId)); + rival.teleport(new Location(fx.world, 4.5, 10, 0.5)); + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(rival, victim)); + assertEquals(CharacterRiteService.Bind.BUSY, CharacterRiteService.bind(mage, victim)); + CarryService.release(rival, false); + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(mage, victim)); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, victim)); + } + + @Test + void aNamedFinalTargetIsNeverBlockedByTheRepeatRule() { + record.setStage(BloodStage.FINAL); + record.getFinalTargets().add(BloodFixture.id("victim")); + record.setLastVictimAccount(victim.getUniqueId().toString()); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, victim)); + } + + @Test + void loggingOutOnTheAltarDoesNotSaveTheVictim() { + PlayerMock bystander = fx.player("bystander"); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, victim)); + CharacterRiteService.onQuit(bystander); + assertTrue(CharacterRiteService.hasRite(mage.getUniqueId())); + CharacterRiteService.onQuit(victim); + assertFalse(CharacterRiteService.hasRite(mage.getUniqueId())); + assertEquals(1, record.getSacrifices().size()); + bridge.verify(() -> RpCharactersBridge.sacrifice(victim, mage)); + + PlayerMock second = fx.player("second"); + second.teleport(new Location(fx.world, 1.5, 10, 0.5)); + record.setLastVictimAccount(null); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, second)); + CharacterRiteService.onQuit(mage); + assertFalse(CharacterRiteService.hasRite(mage.getUniqueId())); + + PlayerMock third = fx.player("third"); + third.teleport(new Location(fx.world, 1.5, 10, 0.5)); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, third)); + mage.disconnect(); + CharacterRiteService.onQuit(third); + assertEquals(1, record.getSacrifices().size()); + + PlayerMock fled = fx.player("fled"); + fled.teleport(new Location(fx.world, 1.5, 10, 0.5)); + PlayerMock back = fx.player("mage"); + back.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, mageId)); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(back, fled)); + fled.teleport(new Location(fx.world, 9.5, 10, 0.5)); + CharacterRiteService.onQuit(fled); + assertEquals(1, record.getSacrifices().size()); + PlayerMock late = fx.player("late"); + late.teleport(new Location(fx.world, 1.5, 10, 0.5)); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(back, late)); + BloodConfig.setEnabled(false); + CharacterRiteService.onQuit(late); + BloodConfig.setEnabled(true); + assertEquals(1, record.getSacrifices().size()); + back.disconnect(); + + PlayerMock again = fx.player("mage"); + again.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.BLOOD_KNIFE, mageId)); + PlayerMock fourth = fx.player("fourth"); + fourth.teleport(new Location(fx.world, 1.5, 10, 0.5)); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(again, fourth)); + BloodAltars.remove(BloodAltars.ofCharacter(mageId)); + CharacterRiteService.onQuit(fourth); + assertEquals(1, record.getSacrifices().size()); + } + + @Test + void bodiesAreNeverLaidInLiquidFireOrWalls() { + Block feet = fx.world.getBlockAt(5, 10, 0); + Location spot = feet.getLocation().clone().add(0.5, 0, 0.5); + assertTrue(CarryService.safe(spot)); + feet.setType(Material.STONE); + assertFalse(CarryService.safe(spot)); + feet.setType(Material.WATER); + assertFalse(CarryService.safe(spot)); + feet.setType(Material.AIR); + feet.getRelative(org.bukkit.block.BlockFace.DOWN).setType(Material.LAVA); + assertFalse(CarryService.safe(spot)); + feet.getRelative(org.bukkit.block.BlockFace.DOWN).setType(Material.MAGMA_BLOCK); + assertFalse(CarryService.safe(spot)); + feet.getRelative(org.bukkit.block.BlockFace.DOWN).setType(Material.STONE); + feet.setType(Material.STONE_SLAB); + Location onSlab = spot.clone().add(0, 0.5, 0); + assertTrue(CarryService.safe(onSlab)); + org.bukkit.block.data.type.Slab wet = (org.bukkit.block.data.type.Slab) Material.STONE_SLAB.createBlockData(); + wet.setWaterlogged(true); + feet.setBlockData(wet); + assertFalse(CarryService.safe(onSlab)); + feet.setType(Material.KELP_PLANT); + assertFalse(CarryService.safe(spot)); + feet.setType(Material.AIR); + + fx.world.getBlockAt(7, 9, 0).setType(Material.LAVA); + victim.teleport(new Location(fx.world, 6.5, 10, 0.5)); + mage.teleport(new Location(fx.world, 7.5, 10, 0.5)); + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(mage, victim)); + CarryService.tick(); + mage.teleport(new Location(fx.world, 6.0, 10, 0.5)); + CarryService.tick(); + mage.teleport(new Location(fx.world, 5.5, 10, 0.5)); + CarryService.tick(); + assertEquals(6.5, victim.getLocation().getX(), 1e-9); + mage.teleport(new Location(fx.world, 4.0, 10, 0.5)); + CarryService.tick(); + assertEquals(5.5, victim.getLocation().getX(), 1e-9); + } + + @Test + void protectionsCanRefuseTheAltar() { + PlayerMock builder = fx.player("builder"); + String builderId = BloodFixture.id("builder"); + fx.record("builder", BloodStage.ANIMAL); + var plugin = MockBukkit.createMockPlugin("Claims"); + Listener deny = new Listener() { + @EventHandler public void place(BlockPlaceEvent event) { event.setCancelled(true); } + }; + fx.server.getPluginManager().registerEvents(deny, plugin); + assertEquals("blood.altar.place.region", + AltarService.place(builder, BloodItems.create(BloodItemKind.ALTAR, builderId), fx.pocket(30, 10, 30))); + org.bukkit.event.HandlerList.unregisterAll(deny); + Listener noBuild = new Listener() { + @EventHandler public void place(BlockPlaceEvent event) { event.setBuild(false); } + }; + fx.server.getPluginManager().registerEvents(noBuild, plugin); + assertEquals("blood.altar.place.region", + AltarService.place(builder, BloodItems.create(BloodItemKind.ALTAR, builderId), fx.pocket(30, 10, 30))); + org.bukkit.event.HandlerList.unregisterAll(noBuild); + } + + @Test + void corruptionStaysOutOfProtectedLand() { + BloodAltar altar = BloodAltars.ofCharacter(mageId); + AltarRegions.setQuery(location -> List.of("town")); + assertEquals(0, AltarCorruption.spread(altar, 5)); + AltarRegions.setQuery(location -> List.of()); + } + + @Test + void theOffSwitchStopsTheTickAndStopClearsBoons() throws Exception { + Files.writeString(temp.resolve("off.yml"), "enabled: false\n"); + BloodConfig.load(temp.resolve("off.yml").toFile(), fx.logger); + assertEquals(CharacterRiteService.Bind.NOT_STAGE, CharacterRiteService.bind(mage, victim)); + BloodConfig.setEnabled(true); + assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, victim)); + assertEquals(CharacterRiteService.Bind.OK, CarryService.start(mage, fx.player("dragged"))); + BloodConfig.setEnabled(false); + BloodMagic.tick(MagicTickContext.between(599, 600)); + assertFalse(CharacterRiteService.hasRite(mage.getUniqueId())); + CarryService.tick(); + assertNull(CarryService.carried(mage)); + BloodMagic.stop(); + assertNull(BloodRecords.get(mageId)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/RoomLockTest.java b/src/test/java/net/tfminecraft/magic/blood/RoomLockTest.java new file mode 100644 index 0000000..1a765c2 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/RoomLockTest.java @@ -0,0 +1,278 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.Levelled; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Hanging; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityPlaceEvent; +import org.bukkit.event.hanging.HangingPlaceEvent; +import org.bukkit.event.player.PlayerBucketEmptyEvent; +import org.bukkit.event.player.PlayerBucketFillEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +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 org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import net.tfminecraft.interactiblefurniture.events.FurniturePlaceEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; + +class RoomLockTest { + @TempDir Path temp; + BloodFixture fx; + final BloodListener listener = new BloodListener(); + + static final String TEMPLATE = String.join("\n", + "placement:", + " min_cover: 1", + " cover_scan_height: 4", + " room:", + " template:", + " floor: [STONE_BRICKS]", + " walls: [COBBLESTONE, UNOBTAINIUM, WATER]", + " ceiling: [ANDESITE]", + " light: 40", + " candles: { block: BLACK_CANDLE, count: 9 }", + " altar_light: 6", + ""); + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + } + + @AfterEach + void cleanup() { + fx.close(); + } + + void config(String yaml) throws Exception { + Files.writeString(temp.resolve("r.yml"), "enabled: true\n" + yaml); + assertTrue(BloodConfig.load(temp.resolve("r.yml").toFile(), fx.logger)); + } + + @Test + void everyChamberIsLaidInTheTemplateWithItsWayInLeftOpen() throws Exception { + config(TEMPLATE); + assertEquals(15, RoomTemplate.light()); + Block target = fx.room(0, 10, 0); + Block door = fx.world.getBlockAt(5, 10, 0); + door.setType(Material.AIR); + Block hole = fx.world.getBlockAt(2, 9, 2); + hole.setType(Material.AIR); + Block bedrock = fx.world.getBlockAt(-5, 11, 0); + bedrock.setType(Material.BEDROCK); + + AltarRoom.Plan plan = AltarRoom.plan(target, null); + assertEquals(AltarRoom.Verdict.OK, plan.verdict()); + assertTrue(plan.carve().isEmpty()); + assertEquals(4, plan.lights().size()); + assertEquals(Material.STONE_BRICKS, plan.lay().get(hole)); + assertFalse(plan.lay().containsKey(door)); + assertFalse(plan.lay().containsKey(bedrock)); + plan.apply(); + assertEquals(Material.STONE_BRICKS, fx.world.getBlockAt(0, 9, 3).getType()); + assertEquals(Material.STONE_BRICKS, hole.getType()); + assertEquals(Material.COBBLESTONE, fx.world.getBlockAt(5, 11, 0).getType()); + assertEquals(Material.COBBLESTONE, fx.world.getBlockAt(-5, 9, -5).getType()); + assertEquals(Material.ANDESITE, fx.world.getBlockAt(1, 13, 1).getType()); + assertEquals(Material.AIR, door.getType()); + assertEquals(Material.BEDROCK, bedrock.getType()); + Block corner = fx.world.getBlockAt(4, 12, -4); + assertEquals(Material.LIGHT, corner.getType()); + assertEquals(15, ((Levelled) corner.getBlockData()).getLevel()); + Block candle = fx.world.getBlockAt(-4, 10, 4); + assertEquals(Material.BLACK_CANDLE, candle.getType()); + assertEquals(4, ((org.bukkit.block.data.type.Candle) candle.getBlockData()).getCandles()); + assertTrue(((org.bukkit.block.data.type.Candle) candle.getBlockData()).isLit()); + Block glow = target.getRelative(BlockFace.UP); + assertEquals(Material.LIGHT, glow.getType()); + assertEquals(6, ((Levelled) glow.getBlockData()).getLevel()); + BloodAltar altar = BloodAltar.create("g", "o", target, 0f, 1L); + BloodAltars.add(altar); + AltarService.purify(altar, "test"); + assertEquals(Material.AIR, glow.getType()); + assertEquals(4, AltarRoom.corners(target).size()); + assertTrue(AltarRoom.corners(target).contains(fx.world.getBlockAt(5, 10, 5))); + } + + @Test + void waterOrLavaInOrAgainstTheChamberRefusesIt() throws Exception { + config(TEMPLATE); + Block target = fx.room(0, 10, 0); + Block door = fx.world.getBlockAt(5, 10, 0); + door.setType(Material.AIR); + fx.world.getBlockAt(6, 10, 0).setType(Material.WATER); + assertEquals(AltarRoom.Verdict.BLOCKED, AltarRoom.plan(target, null).verdict()); + fx.world.getBlockAt(6, 10, 0).setType(Material.STONE); + assertEquals(AltarRoom.Verdict.OK, AltarRoom.plan(target, null).verdict()); + fx.world.getBlockAt(-5, 11, 2).setType(Material.LAVA); + assertEquals(AltarRoom.Verdict.BLOCKED, AltarRoom.plan(target, null).verdict()); + fx.world.getBlockAt(-5, 11, 2).setType(Material.STONE); + + BloodAltar rival = BloodAltar.create("r", "o", fx.pocket(30, 10, 30), 0f, 1L); + rival.getCorrupted().add(new BloodAltar.CorruptedBlock(0, 13, 0, "minecraft:stone", "NETHERRACK")); + BloodAltars.add(rival); + fx.world.getBlockAt(0, 13, 0).setType(Material.NETHERRACK); + assertEquals(AltarRoom.Verdict.BLOCKED, AltarRoom.plan(target, null).verdict()); + fx.world.getBlockAt(0, 13, 0).setType(Material.STONE); + BloodAltars.remove(rival); + BloodAltars.add(BloodAltar.create("r2", "o", fx.world.getBlockAt(-5, 10, -5), 0f, 1L)); + assertEquals(AltarRoom.Verdict.BLOCKED, AltarRoom.plan(target, null).verdict()); + BloodAltars.clear(); + BloodAltars.init(new BloodStore(temp.resolve("b2").toFile(), fx.logger)); + + // A floor hole holding something that must not move. + Block portal = fx.world.getBlockAt(1, 9, 1); + portal.setType(Material.END_PORTAL); + assertEquals(AltarRoom.Verdict.BLOCKED, AltarRoom.plan(target, null).verdict()); + portal.setType(Material.STONE); + AltarRegions.setQuery(location -> location.getBlockX() == -5 ? List.of("claim") : List.of()); + assertEquals(AltarRoom.Verdict.BLOCKED, AltarRoom.plan(target, null).verdict()); + } + + @Test + void theChamberIsLockedToAllButStaff() throws Exception { + config("corruption:\n levels:\n 0: { radius: 2, max_blocks: 4 }\n 5: { radius: 7, max_blocks: 70 }\n"); + assertEquals(7, CorruptionConfig.maxRadius()); + assertEquals(7, AltarRoom.lockRadius()); + Block target = fx.room(0, 10, 0); + BloodAltar altar = BloodAltar.create(BloodFixture.id("mage"), "o", target, 0f, 1L); + BloodAltars.add(altar); + assertSame(altar, AltarRoom.lockedBy(fx.world.getBlockAt(7, 7, -7))); + assertSame(altar, AltarRoom.lockedBy(fx.world.getBlockAt(-7, 13, 7))); + assertNull(AltarRoom.lockedBy(fx.world.getBlockAt(8, 10, 0))); + assertNull(AltarRoom.lockedBy(fx.world.getBlockAt(0, 10, -8))); + assertNull(AltarRoom.lockedBy(fx.world.getBlockAt(0, 6, 0))); + assertNull(AltarRoom.lockedBy(fx.world.getBlockAt(0, 14, 0))); + org.mockbukkit.mockbukkit.world.WorldMock elsewhere = new org.mockbukkit.mockbukkit.world.WorldMock(); + elsewhere.setName("elsewhere"); + fx.server.addWorld(elsewhere); + assertNull(AltarRoom.lockedBy(elsewhere.getBlockAt(0, 10, 0))); + + PlayerMock mage = fx.player("mage"); + Block wall = fx.world.getBlockAt(5, 11, 0); + BlockBreakEvent breakWall = new BlockBreakEvent(wall, mage); + listener.onBreak(breakWall); + assertTrue(breakWall.isCancelled()); + assertEquals("blood.altar.room_locked", mage.nextMessage()); + Block air = fx.world.getBlockAt(2, 10, 2); + BlockPlaceEvent torch = new BlockPlaceEvent(air, air.getState(), air.getRelative(BlockFace.DOWN), + new ItemStack(Material.TORCH), mage, true, EquipmentSlot.HAND); + listener.onPlace(torch); + assertTrue(torch.isCancelled()); + assertEquals("blood.altar.room_locked", mage.nextMessage()); + + PlayerBucketEmptyEvent pour = mock(PlayerBucketEmptyEvent.class); + when(pour.getPlayer()).thenReturn(mage); + when(pour.getBlock()).thenReturn(air); + listener.onBucketEmpty(pour); + verify(pour).setCancelled(true); + PlayerBucketFillEvent scoop = mock(PlayerBucketFillEvent.class); + when(scoop.getPlayer()).thenReturn(mage); + when(scoop.getBlock()).thenReturn(air); + listener.onBucketFill(scoop); + verify(scoop).setCancelled(true); + Entity frame = mock(Hanging.class); + when(frame.getLocation()).thenReturn(air.getLocation()); + HangingPlaceEvent hang = mock(HangingPlaceEvent.class); + when(hang.getEntity()).thenReturn((Hanging) frame); + listener.onHang(hang); + verify(hang).setCancelled(true); + ArmorStand stand = fx.world.spawn(air.getLocation(), ArmorStand.class); + EntityPlaceEvent standPlace = mock(EntityPlaceEvent.class); + when(standPlace.getEntity()).thenReturn(stand); + when(standPlace.getPlayer()).thenReturn(mage); + listener.onEntityPlace(standPlace); + verify(standPlace).setCancelled(true); + FurniturePlaceEvent furniture = new FurniturePlaceEvent(new Furniture("chair", air.getLocation(), java.util.UUID.randomUUID()), mage); + listener.onFurniturePlace(furniture); + assertTrue(furniture.isCancelled()); + mage.nextMessage(); + mage.nextMessage(); + mage.nextMessage(); + mage.nextMessage(); + + Block outside = fx.world.getBlockAt(20, 10, 20); + BlockPlaceEvent away = new BlockPlaceEvent(outside, outside.getState(), outside.getRelative(BlockFace.DOWN), + new ItemStack(Material.TORCH), mage, true, EquipmentSlot.HAND); + listener.onPlace(away); + assertFalse(away.isCancelled()); + FurniturePlaceEvent farFurniture = new FurniturePlaceEvent(new Furniture("chair", outside.getLocation(), java.util.UUID.randomUUID()), mage); + listener.onFurniturePlace(farFurniture); + assertFalse(farFurniture.isCancelled()); + HangingPlaceEvent farHang = mock(HangingPlaceEvent.class); + Entity farFrame = mock(Hanging.class); + when(farFrame.getLocation()).thenReturn(outside.getLocation()); + when(farHang.getEntity()).thenReturn((Hanging) farFrame); + listener.onHang(farHang); + verify(farHang, never()).setCancelled(true); + PlayerBucketEmptyEvent farPour = mock(PlayerBucketEmptyEvent.class); + when(farPour.getBlock()).thenReturn(outside); + listener.onBucketEmpty(farPour); + verify(farPour, never()).setCancelled(true); + PlayerBucketFillEvent farScoop = mock(PlayerBucketFillEvent.class); + when(farScoop.getBlock()).thenReturn(outside); + listener.onBucketFill(farScoop); + verify(farScoop, never()).setCancelled(true); + EntityPlaceEvent farStand = mock(EntityPlaceEvent.class); + when(farStand.getEntity()).thenReturn(fx.world.spawn(outside.getLocation(), ArmorStand.class)); + listener.onEntityPlace(farStand); + verify(farStand, never()).setCancelled(true); + + mage.addAttachment(MockBukkit.createMockPlugin(), BloodListener.BYPASS, true); + BlockBreakEvent staffBreak = new BlockBreakEvent(wall, mage); + listener.onBreak(staffBreak); + assertFalse(staffBreak.isCancelled()); + assertNull(mage.nextMessage()); + assertTrue(BloodListener.locked(null, wall)); + } + + @Test + void claimsAtTheChambersCornersRefuseThePlacement() { + PlayerMock player = fx.player("mage"); + fx.record("mage", BloodStage.ANIMAL); + Block target = fx.room(0, 10, 0); + Set denied = Set.of(5); + fx.server.getPluginManager().registerEvents(new Listener() { + @EventHandler + public void onPlace(BlockPlaceEvent event) { + if (denied.contains(event.getBlock().getX()) && event.getBlock().getZ() == -5) { + event.setCancelled(true); + } + } + }, MockBukkit.createMockPlugin()); + assertEquals("blood.altar.place.region", + AltarService.place(player, BloodItems.create(BloodItemKind.ALTAR, BloodFixture.id("mage")), target)); + assertNull(BloodAltars.at(target)); + } + + @Test + void badTemplateEntriesAreSkipped() throws Exception { + config("placement:\n room:\n template:\n floor: [AIR]\n light: -3\n candles: { block: STONE }\n"); + assertNull(RoomTemplate.candle()); + assertFalse(RoomTemplate.hasFloor()); + assertEquals(0, RoomTemplate.light()); + assertNull(RoomTemplate.wall(new java.util.Random())); + assertNull(RoomTemplate.ceiling(new java.util.Random())); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java new file mode 100644 index 0000000..b834bcc --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java @@ -0,0 +1,268 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Random; + +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.entity.ArmorStand; +import org.bukkit.event.entity.EntityMountEvent; +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 org.mockbukkit.mockbukkit.entity.PlayerMock; + +import net.md_5.bungee.api.ChatColor; +import net.tfminecraft.magic.Messages; + +class VoiceTest { + @TempDir Path temp; + BloodFixture fx; + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + assertTrue(BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger)); + } + + @AfterEach + void cleanup() { + WhisperService.random = new Random(); + fx.close(); + } + + @SuppressWarnings("deprecation") + static String plain(String message) { + return ChatColor.stripColor(message); + } + + @Test + void coloursBlendFromStartToEndAndKeepTheStyle() { + assertTrue(VoiceColours.valid(" #aBc123 ")); + assertFalse(VoiceColours.valid("red")); + assertFalse(VoiceColours.valid(null)); + assertEquals(List.of("#000000"), VoiceColours.at(List.of(), List.of("#000000"), 0.5)); + assertEquals(List.of("#ffffff"), VoiceColours.at(List.of("#ffffff"), List.of(), 0.5)); + assertEquals(List.of("#000000", "#808080"), + VoiceColours.at(List.of("#000000"), List.of("#000000", "#ffffff"), 0.5)); + assertEquals(List.of("#ffffff", "#ffffff"), + VoiceColours.at(List.of("#000000"), List.of("#ffffff", "#ffffff"), 7)); + String line = VoiceColours.gradient("a b", List.of("#ff0000", "#0000ff"), "§o"); + assertTrue(line.startsWith(ChatColor.of("#ff0000") + "§oa ")); + assertTrue(line.endsWith(ChatColor.of("#0000ff") + "§ob")); + assertEquals(ChatColor.of("#00ff00") + "x", VoiceColours.gradient("x", List.of("#00ff00"), "")); + } + + @Test + void theVoicesDriftAlongThePath() { + PlayerMock mage = fx.player("mage"); + BloodRecord record = fx.record("mage", BloodStage.ANIMAL); + WhisperConfig.Voice god = WhisperConfig.voice(WhisperConfig.Speaker.GOD); + assertTrue(god.coloured()); + assertEquals(0, WhisperService.pathProgress(null)); + assertEquals(0, WhisperService.pathProgress(record)); + String early = WhisperService.voiced(mage, WhisperConfig.Speaker.GOD, "child"); + assertTrue(early.startsWith(ChatColor.of(god.start().get(0)).toString())); + assertEquals("child", plain(early)); + for (String kind : List.of("cow", "pig", "sheep", "goat")) { + record.addSpecies(kind); + } + double middle = WhisperService.pathProgress(record); + assertTrue(middle > 0 && middle < 1); + assertNotEquals(early, WhisperService.voiced(mage, WhisperConfig.Speaker.GOD, "child")); + record.setStage(BloodStage.ASCENDED); + assertEquals(1, WhisperService.pathProgress(record)); + assertTrue(WhisperService.voiced(mage, WhisperConfig.Speaker.GOD, "child") + .startsWith(ChatColor.of(god.end().get(0)).toString())); + record.setStage(BloodStage.FINAL); + record.setFinalsDone(99); + assertEquals(1, WhisperService.pathProgress(record)); + + PlayerMock stranger = fx.server.addPlayer(); + assertTrue(WhisperService.voiced(stranger, WhisperConfig.Speaker.SELF, "me") + .startsWith(ChatColor.of(WhisperConfig.voice(WhisperConfig.Speaker.SELF).start().get(0)).toString())); + } + + @Test + void animalLinesFollowTheKindsAndBothVoicesSpeak() { + PlayerMock mage = fx.player("mage"); + BloodRecord record = fx.record("mage", BloodStage.ANIMAL); + List self = WhisperConfig.voice(WhisperConfig.Speaker.SELF).lines(BloodStage.ANIMAL); + List god = WhisperConfig.voice(WhisperConfig.Speaker.GOD).lines(BloodStage.ANIMAL); + WhisperService.random = new Random() { + boolean flip; + @Override public boolean nextBoolean() { return flip = !flip; } + @Override public int nextInt(int bound) { return 0; } + }; + WhisperConfig.Voice selfVoice = WhisperConfig.voice(WhisperConfig.Speaker.SELF); + assertTrue(selfVoice.tiered(BloodStage.ANIMAL)); + WhisperService.speakNext(mage, record); + assertEquals(selfVoice.lines(BloodStage.ANIMAL, 0).get(0), plain(mage.nextMessage())); + WhisperService.speakNext(mage, record); + assertEquals(god.get(0), plain(mage.nextMessage())); + for (String kind : List.of("cow", "pig", "sheep", "goat", "horse", "rabbit", "chicken")) { + record.addSpecies(kind); + } + assertEquals(1.0, WhisperService.stageProgress(record)); + WhisperService.speakNext(mage, record); + assertEquals(selfVoice.lines(BloodStage.ANIMAL, 7).get(0), plain(mage.nextMessage())); + assertEquals(selfVoice.lines(BloodStage.ANIMAL, 5), selfVoice.lines(BloodStage.ANIMAL, 6)); + assertNotEquals(selfVoice.lines(BloodStage.ANIMAL, 0), selfVoice.lines(BloodStage.ANIMAL, 1)); + assertTrue(self.containsAll(selfVoice.lines(BloodStage.ANIMAL, 3))); + assertEquals(selfVoice.lines(BloodStage.CHOICE), selfVoice.lines(BloodStage.CHOICE, 3)); + + assertEquals(0, WhisperService.pick(5, -1)); + assertEquals(0, WhisperService.pick(1, 0.5)); + assertEquals(6, WhisperService.pick(9, 2.0)); + record.setStage(BloodStage.HUNT); + assertEquals(-1, WhisperService.stageProgress(record)); + } + + @Test + void tiersStartAtTheLowestAndBadTiersAreSkipped() throws Exception { + Files.writeString(temp.resolve("tiers.yml"), String.join("\n", + "enabled: true", + "voices:", + " god:", + " lines:", + " animal:", + " 2: [later]", + " soon: [never]", + " hunt:", + " nope: [never]", + "")); + assertTrue(BloodConfig.load(temp.resolve("tiers.yml").toFile(), fx.logger)); + WhisperConfig.Voice god = WhisperConfig.voice(WhisperConfig.Speaker.GOD); + assertEquals(List.of("later"), god.lines(BloodStage.ANIMAL, 0)); + assertFalse(god.tiered(BloodStage.HUNT)); + assertTrue(god.lines(BloodStage.HUNT).isEmpty()); + PlayerMock mage = fx.player("mage"); + BloodRecord record = fx.record("mage", BloodStage.ANIMAL); + WhisperService.random = new Random() { + @Override public boolean nextBoolean() { return false; } + }; + WhisperService.speakNext(mage, record); + assertEquals("later", plain(mage.nextMessage())); + Files.writeString(temp.resolve("empty.yml"), "enabled: true\nvoices:\n god:\n lines:\n animal:\n 0: []\n"); + assertTrue(BloodConfig.load(temp.resolve("empty.yml").toFile(), fx.logger)); + WhisperService.speakNext(mage, record); + assertNull(mage.nextMessage()); + } + + @Test + void animalVoicesWaitForTheChamberAndAReadyStone() { + PlayerMock mage = fx.player("mage"); + BloodRecord record = fx.record("mage", BloodStage.ANIMAL); + // No altar standing: the animal-stage voices keep quiet. + assertFalse(WhisperService.atTheStone(mage, record)); + Block center = fx.room(0, 10, 0); + BloodAltar altar = BloodAltar.create(BloodFixture.id("mage"), "o", center, 0f, 1L); + BloodAltars.add(altar); + mage.teleport(new Location(fx.world, 2.5, 10, 2.5)); + assertTrue(WhisperService.atTheStone(mage, record)); + mage.teleport(new Location(fx.world, 40.5, 10, 2.5)); + assertFalse(WhisperService.atTheStone(mage, record)); + mage.teleport(new Location(fx.world, 2.5, 10, 2.5)); + record.setLastAnimalAt(System.currentTimeMillis()); + assertFalse(WhisperService.atTheStone(mage, record)); + record.setLastAnimalAt(1); + assertTrue(WhisperService.atTheStone(mage, record)); + record.setStage(BloodStage.HUNT); + mage.teleport(new Location(fx.world, 40.5, 10, 2.5)); + assertTrue(WhisperService.atTheStone(mage, record)); + } + + @Test + void stoneLinesFollowWhoeverSpeaksToTheMage() { + PlayerMock mage = fx.player("mage"); + BloodRecord record = fx.record("mage", BloodStage.HUNT); + fx.messages.when(() -> Messages.getRaw("st")).thenReturn("{stone}cold"); + WhisperService.tell(mage, "st"); + assertTrue(mage.nextMessage().startsWith(ChoiceTestColours.start(WhisperConfig.Speaker.GOD))); + record.setPath(BloodPath.TRUE); + WhisperService.tell(mage, "st"); + String self = mage.nextMessage(); + assertEquals("cold", plain(self)); + assertFalse(self.startsWith(ChoiceTestColours.start(WhisperConfig.Speaker.GOD))); + PlayerMock stranger = fx.server.addPlayer(); + WhisperService.tell(stranger, "st"); + assertTrue(stranger.nextMessage().startsWith(ChoiceTestColours.start(WhisperConfig.Speaker.GOD))); + } + + /** The first colour a voice starts a line with, at the start of the path. */ + static final class ChoiceTestColours { + static String start(WhisperConfig.Speaker speaker) { + return ChatColor.of(WhisperConfig.voice(speaker).start().get(0)).toString(); + } + } + + @Test + void messagesMarkedWithAVoiceAreSaidInIt() { + PlayerMock mage = fx.player("mage"); + fx.record("mage", BloodStage.ANIMAL); + fx.messages.when(() -> Messages.getRaw("g")).thenReturn("{god}Not yet, child"); + fx.messages.when(() -> Messages.getRaw("s")).thenReturn("{self}Not them"); + WhisperService.tell(mage, "g"); + String said = mage.nextMessage(); + assertEquals("Not yet, child", plain(said)); + assertTrue(said.startsWith(ChatColor.of(WhisperConfig.voice(WhisperConfig.Speaker.GOD).start().get(0)).toString())); + WhisperService.tell(mage, "s"); + assertEquals("Not them", plain(mage.nextMessage())); + WhisperService.tell(mage, "blood.altar.inert"); + assertEquals("blood.altar.inert", mage.nextMessage()); + fx.messages.when(() -> Messages.getRaw("n")).thenReturn(null); + WhisperService.tell(mage, "n"); + assertEquals("n", mage.nextMessage()); + } + + @Test + void colourlessVoicesUseTheirStyleAsBefore() throws Exception { + Files.writeString(temp.resolve("plain.yml"), String.join("\n", + "enabled: true", + "voices:", + " god:", + " style: '#111111'", + " colors:", + " start: [red]", + " end: []", + " lines:", + " animal: [hush]", + "")); + assertTrue(BloodConfig.load(temp.resolve("plain.yml").toFile(), fx.logger)); + assertFalse(WhisperConfig.voice(WhisperConfig.Speaker.GOD).coloured()); + PlayerMock mage = fx.player("mage"); + BloodRecord record = fx.record("mage", BloodStage.ANIMAL); + WhisperService.speakNext(mage, record); + assertEquals(net.tfminecraft.magic.util.MagicText.format("#111111hush"), mage.nextMessage()); + } + + @Test + void sittingOnTheAltarAnswersItsOwnerAndChillsOthers() { + PlayerMock mage = fx.player("mage"); + PlayerMock stranger = fx.player("stranger"); + Block target = fx.pocket(0, 10, 0); + BloodAltars.add(BloodAltar.create(BloodFixture.id("mage"), "o", target, 0f, 1L)); + BloodListener listener = new BloodListener(); + + ArmorStand onTop = fx.world.spawn(new Location(fx.world, 0.5, 11.2, 0.5), ArmorStand.class); + listener.onMount(new EntityMountEvent(mage, onTop)); + assertEquals("blood.altar.sit.animal", mage.nextMessage()); + ArmorStand inside = fx.world.spawn(new Location(fx.world, 0.5, 10.6, 0.5), ArmorStand.class); + listener.onMount(new EntityMountEvent(stranger, inside)); + assertEquals("blood.altar.sit_stranger", stranger.nextMessage()); + PlayerMock nobody = fx.server.addPlayer(); + listener.onMount(new EntityMountEvent(nobody, onTop)); + assertEquals("blood.altar.sit_stranger", nobody.nextMessage()); + + ArmorStand elsewhere = fx.world.spawn(new Location(fx.world, 8.5, 11.2, 8.5), ArmorStand.class); + listener.onMount(new EntityMountEvent(mage, elsewhere)); + assertNull(mage.nextMessage()); + listener.onMount(new EntityMountEvent(onTop, elsewhere)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/WhisperConfigTest.java b/src/test/java/net/tfminecraft/magic/blood/WhisperConfigTest.java new file mode 100644 index 0000000..84702d3 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/WhisperConfigTest.java @@ -0,0 +1,100 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.logging.Logger; + +import org.bukkit.entity.EntityType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class WhisperConfigTest { + @TempDir Path temp; + final Logger log = Logger.getAnonymousLogger(); + + @AfterEach + void reset() { + BloodConfig.reset(); + } + + @Test + void bundledVoicesAndSourcesLoad() { + assertTrue(BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), log)); + assertEquals(30, WhisperConfig.threshold()); + assertEquals(1, WhisperConfig.decayPerHour()); + assertTrue(WhisperConfig.fastTrait(Set.of("HERETIC"))); + assertFalse(WhisperConfig.fastTrait(Set.of("thief"))); + assertFalse(WhisperConfig.fastTrait(java.util.Collections.singleton(null))); + assertEquals(3, WhisperConfig.fastMultiplier()); + assertEquals(2, WhisperConfig.animalKill()); + assertEquals(2, WhisperConfig.aloneOrNightMultiplier()); + assertEquals(15, WhisperConfig.characterKill()); + assertEquals(1, WhisperConfig.corruption()); + assertEquals(-40, WhisperConfig.corruptionBelow()); + assertTrue(WhisperConfig.isAnimal(EntityType.COW)); + assertFalse(WhisperConfig.isAnimal(EntityType.ZOMBIE)); + assertEquals(15, WhisperConfig.minInterval()); + assertEquals(35, WhisperConfig.maxInterval()); + assertTrue(WhisperConfig.requireAlone()); + assertEquals(24, WhisperConfig.aloneRadius()); + var god = WhisperConfig.voice(WhisperConfig.Speaker.GOD); + assertEquals(9, god.lines(BloodStage.WHISPERED).size()); + assertTrue(god.lines(BloodStage.HUNT).isEmpty()); + assertEquals("minecraft:ambient.cave", god.sound()); + assertEquals(0.5f, god.pitch()); + assertFalse(WhisperConfig.voice(WhisperConfig.Speaker.SELF).lines(BloodStage.HUNT).isEmpty()); + } + + @Test + void defaultsAndBadEntriesAreSafe() throws Exception { + BloodConfig.reset(); + assertEquals("", WhisperConfig.voice(WhisperConfig.Speaker.GOD).style()); + assertTrue(WhisperConfig.voice(WhisperConfig.Speaker.SELF).lines(BloodStage.FINAL).isEmpty()); + String yaml = String.join("\n", + "whispers:", + " threshold: 0", + " decay_per_hour: -1", + " fast_multiplier: 0", + " animal_types: [COW, DRAGONISH]", + " sources:", + " animal_kill: -1", + " alone_or_night_multiplier: 0", + " character_kill: -1", + " corruption: -1", + "voices:", + " interval_minutes: [40, 0]", + " alone_radius: 0", + " self:", + " lines:", + " nowhere: [x]", + " hunt: [y]", + " god:", + " style: s", + ""); + Files.writeString(temp.resolve("b.yml"), yaml); + BloodConfig.load(temp.resolve("b.yml").toFile(), log); + assertEquals(1, WhisperConfig.threshold()); + assertEquals(0, WhisperConfig.decayPerHour()); + assertEquals(1, WhisperConfig.fastMultiplier()); + assertTrue(WhisperConfig.isAnimal(EntityType.COW)); + assertEquals(0, WhisperConfig.animalKill()); + assertEquals(1, WhisperConfig.aloneOrNightMultiplier()); + assertEquals(0, WhisperConfig.characterKill()); + assertEquals(0, WhisperConfig.corruption()); + assertEquals(1, WhisperConfig.minInterval()); + assertEquals(40, WhisperConfig.maxInterval()); + assertEquals(1, WhisperConfig.aloneRadius()); + assertEquals(1, WhisperConfig.voice(WhisperConfig.Speaker.SELF).lines(BloodStage.HUNT).size()); + assertEquals("s", WhisperConfig.voice(WhisperConfig.Speaker.GOD).style()); + assertTrue(WhisperConfig.voice(WhisperConfig.Speaker.GOD).lines(BloodStage.ANIMAL).isEmpty()); + + Files.writeString(temp.resolve("c.yml"), "voices:\n interval_minutes: [3]\n"); + BloodConfig.load(temp.resolve("c.yml").toFile(), log); + assertEquals(15, WhisperConfig.minInterval()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java b/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java new file mode 100644 index 0000000..c669795 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java @@ -0,0 +1,302 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Optional; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +import net.tfminecraft.magic.manager.ResonanceGuiManager; +import net.tfminecraft.magic.session.ResonanceSession; +import net.tfminecraft.magic.session.ResonanceSessionManager; +import net.tfminecraft.magic.tick.MagicTickContext; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.Cow; +import org.bukkit.entity.Zombie; +import org.bukkit.inventory.ItemStack; +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 org.mockbukkit.mockbukkit.entity.PlayerMock; + +class WhisperServiceTest { + @TempDir Path temp; + BloodFixture fx; + final AtomicLong now = new AtomicLong(1_000_000L); + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); + WhisperService.clock = now::get; + WhisperService.random = new Random(1); + WhisperService.equilibrium = player -> Double.NaN; + } + + @AfterEach + void cleanup() { + WhisperService.clear(); + WhisperService.clock = System::currentTimeMillis; + WhisperService.random = new Random(); + fx.close(); + } + + static MagicTickContext minute(long minutes) { + return MagicTickContext.between(minutes * 60 - 1, minutes * 60); + } + + @Test + void deedsFillTheMeterUntilTheVoicesWake() { + PlayerMock player = fx.player("mage"); + String id = BloodFixture.id("mage"); + BloodConfig.setEnabled(false); + assertFalse(WhisperService.addTo(id, "o", 5, player)); + BloodConfig.setEnabled(true); + assertFalse(WhisperService.addTo(id, "o", 0, player)); + WhisperService.add(player, 10); + assertEquals(10, BloodRecords.get(id).getWhisper()); + assertFalse(WhisperService.addTo(id, "o", 10, player)); + assertTrue(WhisperService.addTo(id, "o", 10, player)); + assertEquals(BloodStage.WHISPERED, BloodRecords.get(id).getStage()); + assertEquals(now.get() + 60_000L, WhisperService.nextVoice(player.getUniqueId())); + assertFalse(WhisperService.addTo(id, "o", 99, player)); + + PlayerMock bare = fx.server.addPlayer(); + WhisperService.add(bare, 50); + assertTrue(WhisperService.addTo(UUID.randomUUID().toString(), "o", 50, null)); + } + + @Test + void fastTraitCharactersFillFaster() { + String id = UUID.randomUUID().toString(); + try (var chars = mockStatic(Characters.class)) { + chars.when(() -> Characters.info(any())).thenReturn(Optional.of(new CharacterInfo( + new CharacterRef(UUID.randomUUID(), UUID.fromString(id)), "N", CharacterStatus.ALIVE, null, + Set.of("Heretic"), 0, Instant.EPOCH))); + WhisperService.addTo(id, "o", 2, null); + assertEquals(6, BloodRecords.get(id).getWhisper()); + chars.when(() -> Characters.info(any())).thenReturn(Optional.empty()); + WhisperService.addTo(id, "o", 2, null); + assertEquals(8, BloodRecords.get(id).getWhisper()); + } + assertFalse(WhisperService.fastTrait("not-a-uuid")); + } + + @Test + void animalKillsWeighBladesNightAndSolitude() { + PlayerMock player = fx.player("mage"); + Cow cow = fx.world.spawn(player.getLocation(), Cow.class); + Zombie zombie = fx.world.spawn(player.getLocation(), Zombie.class); + fx.world.setTime(1000); + assertEquals(0, WhisperService.animalKillWeight(player, cow)); + player.getInventory().setItemInMainHand(new ItemStack(Material.IRON_SWORD)); + assertEquals(0, WhisperService.animalKillWeight(player, zombie)); + assertEquals(4, WhisperService.animalKillWeight(player, cow)); + PlayerMock witness = fx.server.addPlayer(); + witness.teleport(player.getLocation()); + assertEquals(2, WhisperService.animalKillWeight(player, cow)); + fx.world.setTime(15000); + assertEquals(4, WhisperService.animalKillWeight(player, cow)); + fx.world.setTime(23500); + assertEquals(2, WhisperService.animalKillWeight(player, cow)); + witness.teleport(new Location(fx.world, 500, 11, 500)); + assertTrue(WhisperService.alone(player)); + + assertFalse(WhisperService.blade(null)); + assertFalse(WhisperService.blade(new ItemStack(Material.AIR))); + assertFalse(WhisperService.blade(new ItemStack(Material.STICK))); + assertTrue(WhisperService.blade(new ItemStack(Material.STONE_AXE))); + assertTrue(WhisperService.blade(BloodItems.create(BloodItemKind.ALTAR, "x"))); + } + + @Test + void teachingLinesRunInOrderAndEndInTheGift() { + PlayerMock player = fx.player("mage"); + String id = BloodFixture.id("mage"); + BloodRecord record = fx.record("mage", BloodStage.WHISPERED); + int lines = WhisperConfig.voice(WhisperConfig.Speaker.GOD).lines(BloodStage.WHISPERED).size(); + for (int i = 0; i < lines - 1; i++) { + WhisperService.speakNext(player, record); + assertNotNull(player.nextMessage()); + } + assertEquals(BloodStage.WHISPERED, record.getStage()); + WhisperService.speakNext(player, record); + assertEquals(BloodStage.ANIMAL, record.getStage()); + assertTrue(player.getInventory().containsAtLeast(BloodItems.create(BloodItemKind.RITUAL_KNIFE, id), 1)); + assertTrue(player.getInventory().containsAtLeast(BloodItems.create(BloodItemKind.ALTAR, id), 1)); + + record.setStage(BloodStage.WHISPERED); + record.setWhisperLine(lines + 3); + BloodAltars.add(BloodAltar.create(id, "o", fx.pocket(0, 10, 0), 0f, 1L)); + player.getInventory().clear(); + WhisperService.speakNext(player, record); + assertEquals(BloodStage.ANIMAL, record.getStage()); + assertFalse(player.getInventory().containsAtLeast(BloodItems.create(BloodItemKind.ALTAR, id), 1)); + } + + @SuppressWarnings("deprecation") + static String plain(String message) { + return net.md_5.bungee.api.ChatColor.stripColor(message); + } + + @Test + void atTheQuestionTheVoidSpeaksBackHalfTheTime() throws Exception { + PlayerMock player = fx.player("mage"); + BloodRecord record = fx.record("mage", BloodStage.CHOICE); + java.util.List self = WhisperConfig.voice(WhisperConfig.Speaker.SELF).lines(BloodStage.CHOICE); + java.util.List god = WhisperConfig.voice(WhisperConfig.Speaker.GOD).lines(BloodStage.CHOICE); + WhisperService.random = new java.util.Random() { + boolean flip; + @Override public boolean nextBoolean() { return flip = !flip; } + }; + WhisperService.speakNext(player, record); + assertTrue(self.contains(plain(player.nextMessage()))); + WhisperService.speakNext(player, record); + assertTrue(god.contains(plain(player.nextMessage()))); + Files.writeString(temp.resolve("godonly.yml"), + "enabled: true\nvoices:\n god:\n style: '#111111'\n lines:\n choice: [kneel]\n"); + BloodConfig.load(temp.resolve("godonly.yml").toFile(), fx.logger); + WhisperService.speakNext(player, record); + WhisperService.speakNext(player, record); + assertTrue(player.nextMessage().endsWith("kneel")); + assertTrue(player.nextMessage().endsWith("kneel")); + } + + @Test + void laterStagesPickALineAndTheTruePathHearsOnlyItself() throws Exception { + PlayerMock player = fx.player("mage"); + BloodRecord record = fx.record("mage", BloodStage.ANIMAL); + assertEquals(WhisperConfig.Speaker.GOD, WhisperService.speaker(record)); + WhisperService.speakNext(player, record); + assertNotNull(player.nextMessage()); + record.setPath(BloodPath.TRUE); + record.setStage(BloodStage.HUNT); + assertEquals(WhisperConfig.Speaker.SELF, WhisperService.speaker(record)); + WhisperService.speakNext(player, record); + assertNotNull(player.nextMessage()); + record.setStage(BloodStage.ASCENDED); + WhisperService.speakNext(player, record); + assertNotNull(player.nextMessage()); + record.setPath(BloodPath.NONE); + record.setStage(BloodStage.HUNT); + WhisperService.speakNext(player, record); + assertNull(player.nextMessage()); + + Files.writeString(temp.resolve("quiet.yml"), + "enabled: true\nvoices:\n god:\n style: ''\n lines:\n whispered: []\n"); + BloodConfig.load(temp.resolve("quiet.yml").toFile(), fx.logger); + record.setPath(BloodPath.NONE); + record.setStage(BloodStage.WHISPERED); + WhisperService.speakNext(player, record); + assertEquals(BloodStage.ANIMAL, record.getStage()); + WhisperService.speak(player, WhisperConfig.Speaker.GOD, "plain"); + assertEquals("plain", player.nextMessage()); + } + + @Test + void giftDropsWhatDoesNotFit() { + PlayerMock player = fx.player("mage"); + for (int i = 0; i < player.getInventory().getSize(); i++) { + player.getInventory().setItem(i, new ItemStack(Material.DIRT, 64)); + } + int before = fx.world.getEntities().size(); + WhisperService.give(player, new ItemStack(Material.STICK)); + assertTrue(fx.world.getEntities().size() >= before); + } + + @Test + void minuteTicksDrainCorruptAndSpeakWhenAloneAndDue() { + PlayerMock player = fx.player("mage"); + String id = BloodFixture.id("mage"); + fx.server.addPlayer(); + BloodConfig.setEnabled(false); + WhisperService.tick(minute(10)); + BloodConfig.setEnabled(true); + WhisperService.tick(MagicTickContext.between(5, 6)); + WhisperService.tick(minute(10)); + assertNull(BloodRecords.get(id)); + + WhisperService.equilibrium = p -> -80; + WhisperService.tick(minute(10)); + assertEquals(1 - 1 / 6.0, BloodRecords.get(id).getWhisper(), 1e-9); + WhisperService.equilibrium = p -> 0; + WhisperService.tick(minute(20)); + assertEquals(1 - 2 / 6.0, BloodRecords.get(id).getWhisper(), 1e-9); + BloodRecords.get(id).setWhisper(0); + WhisperService.tick(minute(30)); + WhisperService.equilibrium = p -> Double.NaN; + WhisperService.tick(minute(40)); + + BloodRecord record = BloodRecords.getOrCreate(id, "o"); + record.setStage(BloodStage.CHOICE); + record.setWhisper(3); + WhisperService.tick(minute(50)); + assertEquals(3, record.getWhisper()); + WhisperService.forget(player.getUniqueId()); + WhisperService.tick(minute(41)); + Long due = WhisperService.nextVoice(player.getUniqueId()); + assertNotNull(due); + assertTrue(due >= now.get() + 15 * 60_000L && due <= now.get() + 35 * 60_000L); + WhisperService.tick(minute(42)); + assertNull(player.nextMessage()); + now.set(due); + PlayerMock witness = fx.server.addPlayer(); + witness.teleport(player.getLocation()); + WhisperService.tick(minute(43)); + assertNull(player.nextMessage()); + witness.teleport(new Location(fx.world, 900, 11, 900)); + WhisperService.tick(minute(44)); + assertNotNull(player.nextMessage()); + + WhisperService.voiceSoon(player); + assertEquals(now.get(), WhisperService.nextVoice(player.getUniqueId())); + WhisperService.forget(player.getUniqueId()); + assertNull(WhisperService.nextVoice(player.getUniqueId())); + } + + @Test + void fixedIntervalAndCrowdedVoicesWhenSolitudeIsNotRequired() throws Exception { + Files.writeString(temp.resolve("v.yml"), String.join("\n", "enabled: true", "voices:", + " interval_minutes: [5, 5]", " require_alone: false", " god:", " lines:", + " choice: [hello]", "")); + BloodConfig.load(temp.resolve("v.yml").toFile(), fx.logger); + PlayerMock player = fx.player("mage"); + PlayerMock witness = fx.server.addPlayer(); + witness.teleport(player.getLocation()); + fx.record("mage", BloodStage.CHOICE); + WhisperService.voiceSoon(player); + WhisperService.tick(minute(1)); + assertEquals("hello", player.nextMessage()); + assertEquals(now.get() + 5 * 60_000L, WhisperService.nextVoice(player.getUniqueId())); + } + + @Test + void sessionEquilibriumComesFromTheResonanceSession() throws Exception { + PlayerMock player = fx.player("mage"); + var gui = mock(ResonanceGuiManager.class); + var sessions = mock(ResonanceSessionManager.class); + when(net.tfminecraft.magic.Magic.plugin.getResonanceGuiManager()).thenReturn(gui); + when(gui.getSessionManager()).thenReturn(sessions); + var method = WhisperService.class.getDeclaredMethod("sessionEquilibrium", org.bukkit.entity.Player.class); + method.setAccessible(true); + assertTrue(Double.isNaN((double) method.invoke(null, player))); + var session = new ResonanceSession(); + session.setEquilibrium(-12); + when(sessions.get(player)).thenReturn(session); + assertEquals(-12, (double) method.invoke(null, player)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/integration/KnockoutMoveTest.java b/src/test/java/net/tfminecraft/magic/integration/KnockoutMoveTest.java new file mode 100644 index 0000000..6fe695c --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/integration/KnockoutMoveTest.java @@ -0,0 +1,93 @@ +package net.tfminecraft.magic.integration; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class KnockoutMoveTest { + ServerMock server; + PlayerMock player; + Location to; + + /** Stands in for RPCharacters' knockout manager with moveDowned. */ + public static final class FakeKnockouts { + boolean answer; + Location moved; + + public boolean moveDowned(Player player, Location to) { + moved = to; + return answer; + } + } + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + player = server.addPlayer(); + to = new Location(player.getWorld(), 7.5, 70, 3.5); + } + + @AfterEach + void cleanup() { + RpCharactersBridge.knockoutManager = net.tfminecraft.rpcharacters.RPCharacters::getPvpKnockoutManager; + MockBukkit.unmock(); + } + + @Test + void movesThroughRpCharactersWhenItCan() { + FakeKnockouts knockouts = new FakeKnockouts(); + RpCharactersBridge.knockoutManager = () -> knockouts; + assertFalse(RpCharactersBridge.moveDowned(player, to)); + assertSame(to, knockouts.moved); + knockouts.answer = true; + assertTrue(RpCharactersBridge.moveDowned(player, to)); + assertNotEquals(7.5, player.getLocation().getX()); + } + + @Test + void downedSacrificeAndHealingInjuryNeedRpCharacters() { + try (var rp = mockStatic(net.tfminecraft.rpcharacters.RPCharacters.class); + var bridge = mockStatic(RpCharactersBridge.class, CALLS_REAL_METHODS); + var death = mockStatic(net.tfminecraft.rpcharacters.permadeath.PermadeathService.class)) { + assertFalse(RpCharactersBridge.isDown(player)); + var knockouts = mock(net.tfminecraft.rpcharacters.pvp.PvpKnockoutManager.class); + rp.when(net.tfminecraft.rpcharacters.RPCharacters::getPvpKnockoutManager).thenReturn(knockouts); + assertFalse(RpCharactersBridge.isDown(player)); + when(knockouts.isDown(player)).thenReturn(true); + assertTrue(RpCharactersBridge.isDown(player)); + + bridge.when(() -> RpCharactersBridge.getActiveCharacter(any())).thenReturn(null); + assertFalse(RpCharactersBridge.sacrifice(player, player)); + assertFalse(RpCharactersBridge.applyHealingInjury(player)); + var character = mock(net.tfminecraft.rpcharacters.objects.RPCharacter.class); + bridge.when(() -> RpCharactersBridge.getActiveCharacter(any())).thenReturn(character); + assertFalse(RpCharactersBridge.sacrifice(player, player)); + assertFalse(RpCharactersBridge.applyHealingInjury(player)); + death.when(() -> net.tfminecraft.rpcharacters.permadeath.PermadeathService.killCharacter(any(), any(), any(), any())) + .thenReturn(true); + death.when(() -> net.tfminecraft.rpcharacters.permadeath.PermadeathService.applyRandomInjury(any(), any())) + .thenReturn(true); + assertTrue(RpCharactersBridge.sacrifice(player, player)); + assertTrue(RpCharactersBridge.applyHealingInjury(player)); + } + } + + @Test + void olderRpCharactersOrNoneOnlyTeleports() { + RpCharactersBridge.knockoutManager = Object::new; + assertTrue(RpCharactersBridge.moveDowned(player, to)); + assertEquals(7.5, player.getLocation().getX()); + RpCharactersBridge.knockoutManager = () -> null; + Location other = to.clone().add(1, 0, 0); + assertTrue(RpCharactersBridge.moveDowned(player, other)); + assertEquals(8.5, player.getLocation().getX()); + } +}