From 2110f6d1fbc573f114753c2a198e5f6b444241f8 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:30:18 +0100 Subject: [PATCH 01/55] Add blood path records, bound items and the underground blood altar Blood magic phase 1: per-character blood records and altars kept in memory and written whole, bound altar/knife items, a barrier-block altar with a level-based display that nobody can break, push or blow up, placement only underground and outside WorldGuard regions, self-purification when the owner character dies, and /magic blood staff tools. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 18 ++ .../java/net/tfminecraft/magic/Magic.java | 6 + .../magic/blood/AltarDisplays.java | 97 +++++++ .../magic/blood/AltarPlacement.java | 64 +++++ .../tfminecraft/magic/blood/AltarRegions.java | 44 ++++ .../tfminecraft/magic/blood/AltarService.java | 146 +++++++++++ .../tfminecraft/magic/blood/BloodAltar.java | 137 ++++++++++ .../tfminecraft/magic/blood/BloodAltars.java | 89 +++++++ .../tfminecraft/magic/blood/BloodCommand.java | 226 ++++++++++++++++ .../tfminecraft/magic/blood/BloodConfig.java | 222 ++++++++++++++++ .../magic/blood/BloodItemKind.java | 39 +++ .../tfminecraft/magic/blood/BloodItems.java | 78 ++++++ .../magic/blood/BloodListener.java | 112 ++++++++ .../tfminecraft/magic/blood/BloodMagic.java | 48 ++++ .../tfminecraft/magic/blood/BloodPath.java | 23 ++ .../magic/blood/BloodProgress.java | 30 +++ .../tfminecraft/magic/blood/BloodRecord.java | 155 +++++++++++ .../tfminecraft/magic/blood/BloodRecords.java | 64 +++++ .../tfminecraft/magic/blood/BloodStage.java | 37 +++ .../tfminecraft/magic/blood/BloodStore.java | 130 ++++++++++ .../magic/blood/WorldGuardAltarRegions.java | 29 +++ .../magic/command/MagicCommand.java | 11 +- src/main/resources/bloodmagic.yml | 63 +++++ src/main/resources/messages.yml | 69 ++++- src/main/resources/plugin.yml | 2 +- .../magic/CompletionRegressionTest.java | 2 +- .../magic/blood/AltarPlacementTest.java | 111 ++++++++ .../magic/blood/AltarServiceTest.java | 241 ++++++++++++++++++ .../magic/blood/BloodCommandTest.java | 172 +++++++++++++ .../magic/blood/BloodConfigTest.java | 126 +++++++++ .../magic/blood/BloodDomainTest.java | 196 ++++++++++++++ .../tfminecraft/magic/blood/BloodFixture.java | 132 ++++++++++ .../magic/blood/BloodListenerTest.java | 157 ++++++++++++ .../magic/blood/BloodMagicTest.java | 58 +++++ .../magic/blood/BloodStoreTest.java | 170 ++++++++++++ .../magic/blood/MagicBloodRouteTest.java | 25 ++ 36 files changed, 3324 insertions(+), 5 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/blood/AltarDisplays.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/AltarPlacement.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/AltarRegions.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/AltarService.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodAltar.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodAltars.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodCommand.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodConfig.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodItemKind.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodItems.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodListener.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodMagic.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodPath.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodProgress.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodRecord.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodRecords.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodStage.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodStore.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/WorldGuardAltarRegions.java create mode 100644 src/main/resources/bloodmagic.yml create mode 100644 src/test/java/net/tfminecraft/magic/blood/AltarPlacementTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/BloodDomainTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/BloodFixture.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/BloodStoreTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/MagicBloodRouteTest.java diff --git a/pom.xml b/pom.xml index 279096d..ce39fa9 100644 --- a/pom.xml +++ b/pom.xml @@ -143,6 +143,20 @@ + + + com.sk89q.worldguard + worldguard-bukkit + 7.0.9 + provided + + + + org.bstats + bstats-bukkit + 3.0.1 + test + @@ -150,6 +164,10 @@ papermc https://repo.papermc.io/repository/maven-public/ + + enginehub + https://maven.enginehub.org/repo/ + diff --git a/src/main/java/net/tfminecraft/magic/Magic.java b/src/main/java/net/tfminecraft/magic/Magic.java index d3f6f90..fe107ce 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,7 @@ private void createConfigs() { "gui.yml", "skills.yml", "charges.yml", + "bloodmagic.yml", "gear/archetypes.yml", "gear/part-types.yml", "gear/parts.yml", 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..4b7c606 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AltarDisplays.java @@ -0,0 +1,97 @@ +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.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); + } + Entity existing = altar.getDisplayId() != null ? Bukkit.getEntity(altar.getDisplayId()) : null; + if (existing instanceof ItemDisplay display) { + display.setItemStack(model(altar.getLevel())); + return false; + } + 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. */ + 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 || !entity.getUniqueId().equals(altar.getDisplayId())) { + entity.remove(); + } + } + } + + /** 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(); + if (block != null && block.getType() == BloodConfig.altarBlock()) { + block.setType(Material.AIR, false); + } + } +} 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/AltarService.java b/src/main/java/net/tfminecraft/magic/blood/AltarService.java new file mode 100644 index 0000000..b70cc3e --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -0,0 +1,146 @@ +package net.tfminecraft.magic.blood; + +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Sound; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +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); + } + BloodAltar altar = BloodAltar.create(character, player.getUniqueId().toString(), target, + player.getLocation().getYaw() + 180f, System.currentTimeMillis()); + altar.setLevel(BloodProgress.level(record)); + AltarDisplays.ensure(altar); + BloodAltars.add(altar); + record.setAltarId(altar.getId()); + BloodRecords.save(record); + hand.setAmount(hand.getAmount() - 1); + target.getWorld().playSound(target.getLocation(), Sound.BLOCK_STONE_PLACE, 1f, 0.6f); + 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(); + 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) { + AltarDisplays.remove(altar); + 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) { + 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; + } + + /** The player-facing line for an altar a player touched. */ + public static String touch(Player player, BloodAltar altar) { + String character = currentCharacter(player); + return character != null && character.equalsIgnoreCase(altar.getOwnerCharacter()) + ? Messages.get("blood.altar.owner_touch") + : Messages.get("blood.altar.stranger_touch"); + } +} 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..0cbe188 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java @@ -0,0 +1,137 @@ +package net.tfminecraft.magic.blood; + +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; + private int x; + private int y; + private int z; + private float yaw; + private int level; + private String displayId; + private long placedAt; + + 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 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 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().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/BloodCommand.java b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java new file mode 100644 index 0000000..d74df02 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java @@ -0,0 +1,226 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +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; + +/** + * Staff tools: /magic blood <list|info|stage|path|give|altar|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", "altar", "reset"); + 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 "altar" -> altar(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() : "-")); + } + + 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); + 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); + 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) { + 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())); + } + + 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); + 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" -> Arrays.stream(BloodItemKind.values()).map(BloodItemKind::key).toList(); + case "altar" -> altarTargets(); + 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..cf96fec --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java @@ -0,0 +1,222 @@ +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 bloodmagic.yml. 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 int huntPointsRequired = 7; + + private BloodConfig() {} + + /** Loads the file; a missing or broken file leaves the feature disabled. Returns false on error. */ + public static boolean load(File file, Logger log) { + reset(); + if (file == null || !file.isFile()) { + log.warning("[Magic] bloodmagic.yml missing; blood magic disabled"); + return true; + } + YamlConfiguration config = new YamlConfiguration(); + try { + config.load(file); + } catch (IOException | InvalidConfigurationException ex) { + log.severe("[Magic] Failed to load bloodmagic.yml: " + ex.getMessage()); + return false; + } + 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] bloodmagic.yml 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] bloodmagic.yml 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] bloodmagic.yml 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)); + huntPointsRequired = Math.max(1, config.getInt("hunt.points_required", 7)); + 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] bloodmagic.yml " + 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() { + 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; + } + + 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)); + } + + public static int purifyCheckMinutes() { + return purifyCheckMinutes; + } + + public static int huntPointsRequired() { + return huntPointsRequired; + } +} 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..225e73a --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodItems.java @@ -0,0 +1,78 @@ +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.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); + } + PersistentDataContainer pdc = meta.getPersistentDataContainer(); + pdc.set(kindKey(), PersistentDataType.STRING, kind.key()); + pdc.set(ownerKey(), PersistentDataType.STRING, characterId); + item.setItemMeta(meta); + 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); + } + + private 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..c2fb046 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -0,0 +1,112 @@ +package net.tfminecraft.magic.blood; + +import java.util.List; + +import org.bukkit.block.Block; +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.EntityExplodeEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.world.EntitiesLoadEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.magic.Messages; +import net.tfminecraft.tlibs.character.CharacterDeathEvent; + +/** Altar placement, protection, looks on chunk load, and the owner's death. */ +public final class BloodListener implements Listener { + + @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); + event.getPlayer().sendMessage(AltarService.touch(event.getPlayer(), 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))); + } + + /** 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); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onBreak(BlockBreakEvent event) { + if (BloodAltars.at(event.getBlock()) != null) { + event.setCancelled(true); + event.getPlayer().sendMessage(Messages.get("blood.altar.unbreakable")); + } + } + + @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(block -> BloodAltars.at(block) != null); + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onBlockExplode(BlockExplodeEvent event) { + event.blockList().removeIf(block -> BloodAltars.at(block) != null); + } + + @EventHandler + public void onEntitiesLoad(EntitiesLoadEvent event) { + AltarDisplays.removeStrays(event.getEntities()); + for (BloodAltar altar : BloodAltars.inChunk(event.getWorld().getName(), + event.getChunk().getX(), event.getChunk().getZ())) { + AltarService.refresh(altar); + } + } + + @EventHandler + public void onCharacterDeath(CharacterDeathEvent event) { + AltarService.onCharacterDeath(event.getCharacter().characterId().toString()); + } + + private static boolean touchesAltar(List blocks) { + for (Block block : blocks) { + if (BloodAltars.at(block) != null) { + return true; + } + } + return false; + } +} 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..f26b1f2 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -0,0 +1,48 @@ +package net.tfminecraft.magic.blood; + +import java.io.File; +import java.util.logging.Logger; + +import org.bukkit.plugin.Plugin; + +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 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); + plugin.getServer().getPluginManager().registerEvents(new BloodListener(), plugin); + MagicTickService.register(BloodMagic::tick); + plugin.getServer().getScheduler().runTask(plugin, BloodMagic::settle); + } + + /** First tick after enable: purify altars of owners who died while we were down, redraw the rest. */ + static void settle() { + AltarService.sweepDead(); + for (BloodAltar altar : BloodAltars.all()) { + AltarService.refresh(altar); + } + } + + static void tick(MagicTickContext context) { + if (context.everyMinutes(BloodConfig.purifyCheckMinutes())) { + AltarService.sweepDead(); + } + } + + public static boolean loadConfig(File dataFolder, Logger log) { + return BloodConfig.load(new File(dataFolder, "bloodmagic.yml"), log); + } + + public static void stop() { + BloodRecords.clear(); + BloodAltars.clear(); + } +} 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/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..d34a506 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java @@ -0,0 +1,155 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** 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; + + 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 (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); + } + + /** 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; + } +} 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/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..db6c26b --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodStore.java @@ -0,0 +1,130 @@ +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.List; +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 final File characters; + private final File altars; + private final Logger log; + + public BloodStore(File root, Logger log) { + this.characters = new File(root, "characters"); + this.altars = new File(root, "altars.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)); + } + + 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/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/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml new file mode 100644 index 0000000..1aaa227 --- /dev/null +++ b/src/main/resources/bloodmagic.yml @@ -0,0 +1,63 @@ +# Blood Magic progression. Hidden content: never document it on the wiki. +# Jar does not overwrite an existing live copy. Copy new keys by hand after updates. +# Names here are placeholders until the lore is settled. + +enabled: true + +# Items the voices hand to the character. TLibs item paths (v., ia., m.). +# Each item is bound to the character that received it. +items: + altar: + path: v.chiseled_stone_bricks + name: "#bfbfbfWeathered Altar Stone" + lore: + - "#8c8c8c§oA plain slab of carved stone." + ritual_knife: + path: v.iron_sword + name: "#bfbfbfSkinning Knife" + lore: + - "#8c8c8c§oAn ordinary knife, kept very sharp." + blood_knife: + path: m.lore_weapons.bloodmagic_dagger + name: "#8a0303Vein-Reaver" + lore: + - "#5c0202§oIt is warm to the touch." + +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: v.chiseled_stone_bricks + 1: v.cracked_stone_bricks + 2: v.chiseled_polished_blackstone + 3: v.red_nether_bricks + 4: v.chiseled_nether_bricks + 5: v.crimson_nylium + 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 + +hunt: + # True path: points of character sacrifices needed for the True class. + points_required: 7 + +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: [] + # How often (minutes) altars of dead owners are checked and purified. + purify_check_minutes: 10 diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 649c80d..f639242 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." @@ -444,3 +444,70 @@ gear: failed: "#ff5555Could not refresh that weapon. Check console." ok: "#55ff55Weapon refreshed against the live gear config." + +# Blood path. Hidden content: keep these lines vague and never quote them on the wiki. +blood: + + altar: + + placed: "#8c8c8c§oThe stone settles. The air around it grows still and close" + + not_yours: "#8c8c8c§oThe stone will not settle for you" + + inert: "#8c8c8c§oIt is only a stone" + + already: "#8c8c8c§oYou already keep an altar" + + unbreakable: "#8c8c8c§oThe stone does not yield" + + owner_touch: "#8a0303§oThe stone is warm, like skin" + + stranger_touch: "#8c8c8c§oA cold, carved stone. Something about it makes you uneasy" + + place: + + world: "#8c8c8c§oThis is no place for it" + + occupied: "#8c8c8c§oThere is no room for the stone here" + + no_floor: "#8c8c8c§oThe stone needs solid ground beneath it" + + sky: "#8c8c8c§oIt must not see the sky" + + shallow: "#8c8c8c§oNot deep enough. It must lie far beneath the earth" + + region: "#8c8c8c§oThis ground is watched. Find somewhere forgotten" + + admin: + + usage: "#aaaaaaUsage: /magic blood list | info | stage | path | give | altar | 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}." + + 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." diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 64ac9ac..c38511e 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] commands: resonance: 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/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/AltarServiceTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java new file mode 100644 index 0000000..2670a90 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.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.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(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/bloodmagic.yml"), fx.logger); + ItemStack named = BloodItems.create(BloodItemKind.ALTAR, "c1"); + assertTrue(named.getItemMeta().hasDisplayName()); + assertEquals(1, named.getItemMeta().getLore().size()); + } + } + + @Test + void placingNeedsTheOwnerOnTheRightStageWithNoOtherAltar() { + try (BloodFixture fx = new BloodFixture(temp)) { + PlayerMock player = fx.player("mage"); + Block target = fx.pocket(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))); + + assertEquals("blood.altar.owner_touch", AltarService.touch(player, altar)); + assertEquals("blood.altar.stranger_touch", AltarService.touch(fx.player("other"), altar)); + PlayerMock none = fx.server.addPlayer(); + assertEquals("blood.altar.stranger_touch", AltarService.touch(none, altar)); + } + } + + @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()); + + 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.pocket(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/BloodCommandTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java new file mode 100644 index 0000000..d0cc719 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java @@ -0,0 +1,172 @@ +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.pocket(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.pocket(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", ""})); + } + } +} 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..fd873ac --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java @@ -0,0 +1,126 @@ +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 bundledFileLoadsEverySection() { + File bundled = new File("src/main/resources/bloodmagic.yml"); + assertTrue(BloodConfig.load(bundled, log)); + assertTrue(BloodConfig.isEnabled()); + assertEquals("m.lore_weapons.bloodmagic_dagger", BloodConfig.item(BloodItemKind.BLOOD_KNIFE).path()); + assertFalse(BloodConfig.item(BloodItemKind.ALTAR).lore().isEmpty()); + assertEquals(Material.BARRIER, BloodConfig.altarBlock()); + assertEquals("v.chiseled_stone_bricks", BloodConfig.model(0)); + assertEquals("v.crimson_nylium", 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..8ff368a --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodDomainTest.java @@ -0,0 +1,196 @@ +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"}) { + 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()); + } + + @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..6a2e767 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java @@ -0,0 +1,132 @@ +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.getArgument(0)); + BloodConfig.reset(); + BloodConfig.setEnabled(true); + BloodStore store = new BloodStore(new File(data.toFile(), "blood"), logger); + BloodRecords.init(store); + BloodAltars.init(store); + AltarRegions.setQuery(location -> java.util.List.of()); + } + + /** 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; + } + + 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; + } + + 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(); + BloodConfig.reset(); + messages.close(); + characters.unregister(); + Magic.plugin = null; + MockBukkit.unmock(); + } +} 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..a647bd5 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java @@ -0,0 +1,157 @@ +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.pocket(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.owner_touch", player.nextMessage()); + + Block grass = fx.pocket(5, 10, 5); + 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)); + Block other = fx.world.getBlockAt(3, 10, 3); + + 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()); + + 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)); + } + } +} 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..a0d08ac --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java @@ -0,0 +1,58 @@ +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 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); + + 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()); + + BloodMagic.tick(MagicTickContext.between(0, 59)); + 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)); + BloodMagic.stop(); + assertTrue(BloodAltars.all().isEmpty()); + assertTrue(BloodRecords.all().isEmpty()); + } + } +} 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/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"})); + } + } +} From 5fc068642d3bf5e05dd02d0c5155bb4a9d60e7f8 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:37:51 +0100 Subject: [PATCH 02/55] Add the whisper meter and the blood path voices Blood magic phase 2: dark deeds (blade animal kills, more at night or alone; characters you killed for good; deep corruption) fill a hidden per-character meter, faster for configured evil traits. Past the threshold the god teaches the rite in order and hands over the bound ritual knife and altar. Later stages hear occasional lines; true-path mages only hear their own dark self, in a different style and sound. Staff can wake the voices or push a line. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/BloodCommand.java | 54 +++- .../tfminecraft/magic/blood/BloodConfig.java | 6 + .../magic/blood/BloodListener.java | 35 ++- .../tfminecraft/magic/blood/BloodMagic.java | 2 + .../magic/blood/WhisperConfig.java | 197 +++++++++++++ .../magic/blood/WhisperService.java | 245 ++++++++++++++++ src/main/resources/bloodmagic.yml | 64 +++++ src/main/resources/messages.yml | 12 +- .../magic/blood/BloodCommandTest.java | 36 +++ .../magic/blood/BloodListenerTest.java | 45 +++ .../magic/blood/WhisperConfigTest.java | 100 +++++++ .../magic/blood/WhisperServiceTest.java | 270 ++++++++++++++++++ 12 files changed, 1062 insertions(+), 4 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/WhisperService.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/WhisperConfigTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java index d74df02..755769e 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java @@ -14,12 +14,13 @@ import net.tfminecraft.magic.Messages; /** - * Staff tools: /magic blood <list|info|stage|path|give|altar|reset>. + * Staff tools: /magic blood <list|info|stage|path|give|whisper|voice|altar|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", "altar", "reset"); + static final List SUBCOMMANDS = List.of( + "list", "info", "stage", "path", "give", "whisper", "voice", "altar", "reset"); private static final List ALTAR_ACTIONS = List.of("info", "tp", "refresh", "purify"); private BloodCommand() {} @@ -32,6 +33,8 @@ public static boolean execute(CommandSender sender, String[] args) { 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 "altar" -> altar(sender, args); case "reset" -> withTarget(sender, args, 3, (player, character) -> reset(sender, player, character)); default -> sender.sendMessage(Messages.get("blood.admin.usage")); @@ -128,6 +131,53 @@ private static void give(CommandSender sender, Player player, String character, 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())); + } + private static void reset(CommandSender sender, Player player, String character) { BloodAltar altar = BloodAltars.ofCharacter(character); if (altar != null) { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java index cf96fec..bb5048a 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java @@ -37,6 +37,10 @@ public record ItemDef(String path, String name, List lore) {} private static int purifyCheckMinutes = 10; private static int huntPointsRequired = 7; + static { + reset(); + } + private BloodConfig() {} /** Loads the file; a missing or broken file leaves the feature disabled. Returns false on error. */ @@ -96,6 +100,7 @@ public static boolean load(File file, Logger log) { allowedRegions = lower(config.getStringList("placement.allowed_regions")); purifyCheckMinutes = Math.max(1, config.getInt("placement.purify_check_minutes", 10)); huntPointsRequired = Math.max(1, config.getInt("hunt.points_required", 7)); + WhisperConfig.load(config.getConfigurationSection("whispers"), config.getConfigurationSection("voices"), log); return ok; } @@ -149,6 +154,7 @@ public static void reset() { allowedRegions = List.of(); purifyCheckMinutes = 10; huntPointsRequired = 7; + WhisperConfig.reset(); } public static boolean isEnabled() { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index c2fb046..7833335 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -2,7 +2,9 @@ import java.util.List; +import org.bukkit.Bukkit; import org.bukkit.block.Block; +import org.bukkit.entity.Player; import org.bukkit.event.Event; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -13,16 +15,20 @@ import org.bukkit.event.block.BlockPistonExtendEvent; import org.bukkit.event.block.BlockPistonRetractEvent; import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityDeathEvent; import org.bukkit.event.entity.EntityExplodeEvent; 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 net.tfminecraft.magic.Messages; import net.tfminecraft.tlibs.character.CharacterDeathEvent; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; -/** Altar placement, protection, looks on chunk load, and the owner's death. */ +/** Altar placement, protection, looks on chunk load, dark deeds for the whisper meter, and deaths. */ public final class BloodListener implements Listener { @EventHandler(priority = EventPriority.HIGH) @@ -99,6 +105,33 @@ public void onEntitiesLoad(EntitiesLoadEvent event) { @EventHandler public void onCharacterDeath(CharacterDeathEvent event) { AltarService.onCharacterDeath(event.getCharacter().characterId().toString()); + 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); + } + + @EventHandler(ignoreCancelled = true) + public void onAnimalDeath(EntityDeathEvent event) { + Player killer = event.getEntity().getKiller(); + if (killer != null) { + WhisperService.add(killer, WhisperService.animalKillWeight(killer, event.getEntity())); + } + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + WhisperService.forget(event.getPlayer().getUniqueId()); + } + + @EventHandler + public void onSessionStart(CharacterSessionStartEvent event) { + WhisperService.forget(event.getPlayer().getUniqueId()); } private static boolean touchesAltar(List blocks) { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index f26b1f2..e09af4a 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -35,6 +35,7 @@ static void tick(MagicTickContext context) { if (context.everyMinutes(BloodConfig.purifyCheckMinutes())) { AltarService.sweepDead(); } + WhisperService.tick(context); } public static boolean loadConfig(File dataFolder, Logger log) { @@ -42,6 +43,7 @@ public static boolean loadConfig(File dataFolder, Logger log) { } public static void stop() { + WhisperService.clear(); BloodRecords.clear(); BloodAltars.clear(); } 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..5c650ee --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java @@ -0,0 +1,197 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.List; +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 bloodmagic.yml. */ +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. */ + public record Voice(String style, String sound, float pitch, Map> lines) { + public List lines(BloodStage stage) { + return lines.getOrDefault(stage, List.of()); + } + } + + 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())); + VOICES.put(Speaker.SELF, new Voice("", "", 1f, 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] bloodmagic.yml 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); + 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] bloodmagic.yml voice lines: unknown stage '" + key + "'"); + continue; + } + lines.put(stage, List.copyOf(new ArrayList<>(stages.getStringList(key)))); + } + } + return new Voice(section.getString("style", ""), section.getString("sound", ""), + (float) section.getDouble("pitch", 1.0), lines); + } + + 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..cb38228 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java @@ -0,0 +1,245 @@ +package net.tfminecraft.magic.blood; + +import java.util.HashMap; +import java.util.List; +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.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))) { + 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); + 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; + } + if (!lines.isEmpty()) { + speak(player, speaker, lines.get(random.nextInt(lines.size()))); + } + } + + 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(MagicText.format(voice.style() + line)); + if (!voice.sound().isBlank()) { + player.playSound(player.getLocation(), voice.sound(), 0.6f, voice.pitch()); + } + } + + /** 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); + 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/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index 1aaa227..f427286 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -61,3 +61,67 @@ placement: allowed_regions: [] # How often (minutes) altars of dead owners are checked and purified. purify_check_minutes: 10 + +# 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 speaks until a mage refuses it; then only the mage's own dark self. +# whispered lines are said in order and end with the knife and altar; other stages pick at random. +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: "#8a0303§o" + sound: minecraft:ambient.cave + pitch: 0.5 + lines: + whispered: + - "...can you hear it? Beneath the floor. Beneath the stone." + - "So much warm blood walks above us. All of it wasted." + - "Listen, little one. We will teach you, if you are hungry enough." + - "Lay a stone deep in the earth, where no sky can see it." + - "Bring beasts to the stone while they still breathe. Hold them, and open them with a plain blade." + - "Not your own beasts. Take what others keep. Seven kinds, one each day." + - "Look in your pack. We have been generous." + animal: + - "The stone is thirsty." + - "Another kind. We have tasted that one." + - "Their pens are full and their owners sleep." + - "Lead it down to the stone. Hold it still." + choice: + - "Come to the stone. We have something to ask of you." + final: + - "You are ours now. Feed us." + - "We remember every drop you give." + - "We have chosen for you. Go to the stone." + self: + style: "#7a7a7a§o" + sound: minecraft:entity.warden.heartbeat + pitch: 1.2 + lines: + hunt: + - "We need no god. Only more." + - "Who would miss them? Better: who would we miss?" + - "The stone is quiet now. It is only us." + final: + - "It has to be them. You know it does." + - "The closer they are, the more it gives." diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index f639242..0d950f0 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -480,7 +480,7 @@ blood: admin: - usage: "#aaaaaaUsage: /magic blood list | info | stage | path | give | altar | reset " + usage: "#aaaaaaUsage: /magic blood list | info | stage | path | give | whisper [amount] | voice | altar | reset " no_record: "#aaaaaa{player} ({character}) has no blood path." @@ -511,3 +511,13 @@ blood: 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." diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java index d0cc719..0d7de50 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java @@ -169,4 +169,40 @@ void completions() { 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/bloodmagic.yml"), 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/BloodListenerTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java index a647bd5..e826d32 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java @@ -154,4 +154,49 @@ void chunkLoadRedrawsAltarsAndDeathPurifies() { 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/bloodmagic.yml"), 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(); + } + } } 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..ad5b2cf --- /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/bloodmagic.yml"), 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(7, 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..f6a0563 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.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.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/bloodmagic.yml"), 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)); + } + + @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); + 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.ANIMAL); + 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:", + " animal: [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.ANIMAL); + 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)); + } +} From 128f6933f3a62e7d42959124a40e36f8507b52e8 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:50:16 +0100 Subject: [PATCH 03/55] Add animal sacrifices, altar corruption and stolen-animal clues Blood magic phase 3: the ritual knife binds a living animal still on its owner's altar and only that knife's kill counts. Seven kinds, one per rolling day, taken from other Cooking husbandry keepers (read by reflection, so Cooking stays optional). Each offering raises the altar's level and look and corrupts the room a little; the room also turns slowly between offerings. Every changed block is journalled and put back when the altar purifies. A stolen animal leaves a trace where it was led away and a vague note for its keepers. The seventh kind hands over the blood knife and moves the mage to the god's question. Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/AltarCorruption.java | 149 ++++++++ .../tfminecraft/magic/blood/AltarService.java | 1 + .../tfminecraft/magic/blood/AnimalConfig.java | 108 ++++++ .../magic/blood/AnimalRiteService.java | 214 ++++++++++++ .../tfminecraft/magic/blood/BloodAltar.java | 25 +- .../tfminecraft/magic/blood/BloodConfig.java | 4 + .../magic/blood/BloodListener.java | 27 ++ .../tfminecraft/magic/blood/BloodMagic.java | 10 + .../tfminecraft/magic/blood/BloodNotes.java | 78 +++++ .../tfminecraft/magic/blood/BloodStore.java | 23 ++ .../magic/blood/CorruptionConfig.java | 139 ++++++++ .../magic/blood/HusbandryOwners.java | 37 ++ .../tfminecraft/magic/blood/StolenClues.java | 128 +++++++ src/main/resources/bloodmagic.yml | 48 +++ src/main/resources/messages.yml | 54 +++ src/main/resources/plugin.yml | 2 +- .../magic/blood/AnimalRiteTest.java | 290 ++++++++++++++++ .../magic/blood/BloodListenerTest.java | 45 +++ .../magic/blood/CorruptionAndCluesTest.java | 325 ++++++++++++++++++ .../magic/blood/FakeHusbandry.java | 54 +++ 20 files changed, 1759 insertions(+), 2 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodNotes.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/HusbandryOwners.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/StolenClues.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/FakeHusbandry.java 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..d56ac41 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java @@ -0,0 +1,149 @@ +package net.tfminecraft.magic.blood; + +import java.util.List; +import java.util.ListIterator; +import java.util.Random; +import java.util.function.LongSupplier; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +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; + +/** + * The room around an altar slowly turns. Only listed blocks change: exposed stone-like blocks + * are converted, empty floor and ceiling spots are decorated. 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); + 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)) { + continue; + } + CorruptionConfig.Rule rule = rules.get(random.nextInt(rules.size())); + if (fits(rule, block)) { + change(altar, block, rule.to().get(random.nextInt(rule.to().size()))); + return true; + } + } + return false; + } + + static boolean fits(CorruptionConfig.Rule rule, Block block) { + return switch (rule.on()) { + case CONVERT -> rule.from().contains(block.getType()) && exposed(block); + case FLOOR -> block.isEmpty() && block.getRelative(BlockFace.DOWN).getType().isSolid(); + case CEILING -> block.isEmpty() && block.getRelative(BlockFace.UP).getType().isSolid(); + }; + } + + 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); + } + journal.add(new BloodAltar.CorruptedBlock(block.getX(), block.getY(), block.getZ(), original, to.name())); + } + + /** 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: one block 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, 1) > 0) { + altar.setLastSpreadAt(now); + changed = true; + } + } + if (changed) { + BloodAltars.save(); + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarService.java b/src/main/java/net/tfminecraft/magic/blood/AltarService.java index b70cc3e..cb5259d 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -85,6 +85,7 @@ public static void refreshFor(String characterId) { /** Removes the altar from the world and from its owner's record. */ public static void purify(BloodAltar altar, String reason) { + AltarCorruption.restore(altar); AltarDisplays.remove(altar); BloodAltars.remove(altar); BloodRecord record = BloodRecords.get(altar.getOwnerCharacter()); 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..35d93aa --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java @@ -0,0 +1,108 @@ +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.configuration.ConfigurationSection; +import org.bukkit.entity.EntityType; + +/** The animal: section of bloodmagic.yml. */ +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; + + 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; + } + + 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] bloodmagic.yml 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); + } + + 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..8531e4e --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java @@ -0,0 +1,214 @@ +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.Particle; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.inventory.ItemStack; + +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 kills it or it breaks free. 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 + } + + private record Bound(UUID mage, String character, String altarId, long expiresAt, + List owners, boolean stolen) {} + + 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 (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) { + return Bind.BUSY; + } + animal.setAI(false); + animal.teleport(top.clone().add(0, 0.5, 0)); + BOUND.put(animal.getUniqueId(), new Bound(player.getUniqueId(), character, altar.getId(), + now + AnimalConfig.bindSeconds() * 1000L, owners, stolen)); + 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); + } + + /** A bound animal died: the sacrifice counts only by the binding mage's own knife. */ + public static void onDeath(EntityDeathEvent event) { + LivingEntity animal = event.getEntity(); + Bound bound = BOUND.remove(animal.getUniqueId()); + if (bound == null) { + return; + } + Player killer = animal.getKiller(); + ItemStack knife = killer != null ? killer.getInventory().getItemInMainHand() : null; + if (killer == null || !killer.getUniqueId().equals(bound.mage()) + || !BloodItems.is(knife, BloodItemKind.RITUAL_KNIFE) + || !bound.character().equalsIgnoreCase(BloodItems.owner(knife)) + || !bound.character().equalsIgnoreCase(AltarService.currentCharacter(killer))) { + Player mage = Bukkit.getPlayer(bound.mage()); + if (mage != null) { + mage.sendMessage(Messages.get("blood.animal.spoiled")); + } + return; + } + if (AnimalConfig.clearDrops()) { + event.getDrops().clear(); + event.setDroppedExp(0); + } + complete(killer, bound, animal); + } + + 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(); + center.getWorld().spawnParticle(Particle.DAMAGE_INDICATOR, center.clone().add(0, 0.6, 0), 14, 0.3, 0.2, 0.3, 0.05); + center.getWorld().playSound(center, "minecraft:entity.warden.heartbeat", 1f, 0.6f); + if (bound.stolen()) { + StolenClues.leave(animal, bound.owners(), center); + } + mage.sendMessage(Messages.get(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. */ + static void tick() { + long now = clock.getAsLong(); + for (Iterator> it = BOUND.entrySet().iterator(); it.hasNext();) { + Map.Entry entry = it.next(); + Entity entity = Bukkit.getEntity(entry.getKey()); + if (!(entity instanceof LivingEntity animal) || animal.isDead()) { + it.remove(); + continue; + } + if (now >= entry.getValue().expiresAt()) { + it.remove(); + animal.setAI(true); + Player mage = Bukkit.getPlayer(entry.getValue().mage()); + if (mage != null) { + mage.sendMessage(Messages.get("blood.animal.escaped")); + } + continue; + } + animal.getWorld().spawnParticle(Particle.DAMAGE_INDICATOR, animal.getLocation().clone().add(0, 0.5, 0), 2, 0.2, 0.2, 0.2, 0); + } + } + + /** 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) { + animal.setAI(true); + } + } + BOUND.clear(); + } + + /** Lets this mage's animal go, e.g. when they leave. */ + public static void releaseFor(UUID mage) { + UUID animal = bindingOf(mage); + if (animal == null) { + return; + } + BOUND.remove(animal); + if (Bukkit.getEntity(animal) instanceof LivingEntity living) { + living.setAI(true); + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java b/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java index 0cbe188..439410c 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java @@ -1,5 +1,7 @@ package net.tfminecraft.magic.blood; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import org.bukkit.Bukkit; @@ -21,6 +23,12 @@ public final class BloodAltar { 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<>(); + + /** 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() {} @@ -103,6 +111,21 @@ 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 String blockKey() { return key(world, x, y, z); } @@ -120,7 +143,7 @@ public Block block() { /** Centre of the altar block, or null when its world is not loaded. */ public Location center() { Block block = block(); - return block != null ? block.getLocation().add(0.5, 0.5, 0.5) : null; + 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) { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java index bb5048a..7ad72e9 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java @@ -101,6 +101,8 @@ public static boolean load(File file, Logger log) { purifyCheckMinutes = Math.max(1, config.getInt("placement.purify_check_minutes", 10)); 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); return ok; } @@ -155,6 +157,8 @@ public static void reset() { purifyCheckMinutes = 10; huntPointsRequired = 7; WhisperConfig.reset(); + AnimalConfig.reset(); + CorruptionConfig.reset(); } public static boolean isEnabled() { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index 7833335..6a01076 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -1,9 +1,11 @@ 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; @@ -17,6 +19,8 @@ import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityDeathEvent; import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.entity.PlayerLeashEntityEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.world.EntitiesLoadEvent; @@ -53,6 +57,24 @@ public void onInteract(PlayerInteractEvent event) { event.getPlayer().sendMessage(Messages.get(AltarService.place(event.getPlayer(), hand, target))); } + /** 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 || !(event.getRightClicked() instanceof LivingEntity animal) + || animal instanceof Player + || !BloodItems.is(event.getPlayer().getInventory().getItemInMainHand(), BloodItemKind.RITUAL_KNIFE)) { + return; + } + event.setCancelled(true); + AnimalRiteService.Bind result = AnimalRiteService.bind(event.getPlayer(), animal); + event.getPlayer().sendMessage(Messages.get("blood.animal.bind." + result.name().toLowerCase(Locale.ROOT))); + } + + @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) { @@ -96,6 +118,7 @@ public void onBlockExplode(BlockExplodeEvent event) { @EventHandler public void onEntitiesLoad(EntitiesLoadEvent event) { AltarDisplays.removeStrays(event.getEntities()); + StolenClues.removeExpired(event.getEntities()); for (BloodAltar altar : BloodAltars.inChunk(event.getWorld().getName(), event.getChunk().getX(), event.getChunk().getZ())) { AltarService.refresh(altar); @@ -118,6 +141,7 @@ public void onCharacterDeath(CharacterDeathEvent 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())); @@ -127,11 +151,14 @@ public void onAnimalDeath(EntityDeathEvent event) { @EventHandler public void onQuit(PlayerQuitEvent event) { WhisperService.forget(event.getPlayer().getUniqueId()); + AnimalRiteService.releaseFor(event.getPlayer().getUniqueId()); } @EventHandler public void onSessionStart(CharacterSessionStartEvent event) { WhisperService.forget(event.getPlayer().getUniqueId()); + BloodNotes.deliver(event.getPlayer(), event.getSession().ref().characterId().toString(), + System.currentTimeMillis(), true); } private static boolean touchesAltar(List blocks) { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index e09af4a..4ab6453 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -18,6 +18,7 @@ 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); plugin.getServer().getPluginManager().registerEvents(new BloodListener(), plugin); MagicTickService.register(BloodMagic::tick); plugin.getServer().getScheduler().runTask(plugin, BloodMagic::settle); @@ -36,6 +37,12 @@ static void tick(MagicTickContext context) { AltarService.sweepDead(); } WhisperService.tick(context); + AnimalRiteService.tick(); + if (context.everyMinutes(1)) { + AltarCorruption.ambient(); + BloodNotes.tick(System.currentTimeMillis()); + StolenClues.tick(); + } } public static boolean loadConfig(File dataFolder, Logger log) { @@ -43,6 +50,9 @@ public static boolean loadConfig(File dataFolder, Logger log) { } public static void stop() { + AnimalRiteService.releaseAll(); + StolenClues.clear(); + BloodNotes.clear(); WhisperService.clear(); BloodRecords.clear(); BloodAltars.clear(); 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/BloodStore.java b/src/main/java/net/tfminecraft/magic/blood/BloodStore.java index db6c26b..638d74a 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodStore.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodStore.java @@ -25,14 +25,17 @@ 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 final File characters; private final File altars; + private final File notes; 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.log = log; } @@ -94,6 +97,26 @@ 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)); + } + private T read(File file, Type type) { try { T value = GSON.fromJson(Files.readString(file.toPath(), StandardCharsets.UTF_8), type); 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..46020d0 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java @@ -0,0 +1,139 @@ +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 bloodmagic.yml. */ +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; + + static { + reset(); + } + + private CorruptionConfig() {} + + static void reset() { + LEVELS.clear(); + LEVELS.put(0, new Reach(2, 4)); + RULES.clear(); + blocksPerSacrifice = 3; + ambientEveryMinutes = 30; + } + + 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] bloodmagic.yml 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)); + 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] bloodmagic.yml 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] bloodmagic.yml corruption: '" + entry + "' is not a block"); + } else { + out.add(material); + } + } + return out; + } + + /** 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; + } + + public static int ambientEveryMinutes() { + return ambientEveryMinutes; + } +} 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/StolenClues.java b/src/main/java/net/tfminecraft/magic/blood/StolenClues.java new file mode 100644 index 0000000..205be47 --- /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() + : 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/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index f427286..22064b2 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -125,3 +125,51 @@ voices: final: - "It has to be them. You know it does." - "The closer they are, the more it gives." + +# Animal sacrifices: bind a living animal on the altar with the ritual knife, then kill it with that knife. +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 + # 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: v.redstone + hours: 6 + # The owner hears about it this many minutes later, or on their next login. + note_delay_minutes: 30 + +# 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 + # Solid blocks with a face in the open turn into one of "to" (repeat an entry to weigh it). + convert: + - { min_level: 0, from: [STONE, COBBLESTONE, ANDESITE, DIORITE, GRANITE, STONE_BRICKS, DEEPSLATE, COBBLED_DEEPSLATE, TUFF], to: [CRACKED_STONE_BRICKS, MOSSY_COBBLESTONE] } + - { min_level: 2, from: [STONE, COBBLESTONE, STONE_BRICKS, CRACKED_STONE_BRICKS, MOSSY_COBBLESTONE, DEEPSLATE, DIRT], 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, BONE_BLOCK, NETHER_WART_BLOCK] } + # Air spots get decorated: at floor = on top of a solid block, at ceiling = hanging under one. + decorate: + - { min_level: 0, at: floor, to: [REDSTONE_WIRE, REDSTONE_WIRE, RED_CANDLE] } + - { min_level: 1, at: floor, to: [COBWEB] } + - { min_level: 2, at: floor, to: [WITHER_ROSE] } + - { min_level: 3, at: ceiling, to: [WEEPING_VINES] } + - { min_level: 3, at: floor, to: [CRIMSON_ROOTS, CRIMSON_FUNGUS] } + - { min_level: 4, at: floor, to: [SKELETON_SKULL, BONE_BLOCK] } diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 0d950f0..e04501e 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -478,6 +478,60 @@ blood: region: "#8c8c8c§oThis ground is watched. Find somewhere forgotten" + animal: + + bind: + + ok: "#8a0303§oIt goes still on the stone. Its heart hammers. Now" + + not_stage: "#8c8c8c§oThe stone wants nothing from it" + + not_knife: "#8c8c8c§oIt is only a knife in your hand" + + no_altar: "#8c8c8c§oThere is no stone to lay it on" + + not_species: "#8c8c8c§oThe stone does not want that" + + known: "#8a0303§oWe have tasted that kind. Bring another" + + cooldown: "#8a0303§oNot yet. The stone is still drinking" + + not_stolen: "#8a0303§oNot that one. Take what others keep" + + too_far: "#8a0303§oBring it closer to the stone" + + busy: "#8a0303§oOne at a time" + + spoiled: "#8c8c8c§oThe blood spills wasted. The stone did not drink" + + escaped: "#8c8c8c§oThe beast tears free of the stone" + + accepted: "#8a0303§oThe stone drinks. It is warmer than before" + + complete: "#8a0303§oSeven kinds. The stone is red to its heart. Something new rests in your hand" + + note: "#aaaaaa§oSomething took {animal} while no one was watching. Drag marks lead {direction}" + + note_lost: "#aaaaaa§oSomething took {animal} while no one was watching" + + 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 | altar | reset " diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index c38511e..8092642 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, WorldGuard] +softdepend: [MMOItems, MythicLib, MMOCore, WorldGuard, Cooking] commands: resonance: 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..c85ad9d --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java @@ -0,0 +1,290 @@ +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.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.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/bloodmagic.yml"), 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.pocket(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; + } + + 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 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)); + AnimalRiteService.onDeath(death(pig, mage)); + assertTrue(record.hasSpecies("pig")); + assertTrue(BloodNotes.all().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)); + EntityDeathEvent event = death(cow, mage); + AnimalRiteService.onDeath(event); + assertTrue(event.getDrops().isEmpty()); + assertEquals(0, event.getDroppedExp()); + assertTrue(record.hasSpecies("cow")); + assertEquals(now.get(), record.getLastAnimalAt()); + assertEquals("blood.animal.accepted", mage.nextMessage()); + 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); + EntityDeathEvent event = death(cow, mage); + 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); + record.setStage(BloodStage.CHOICE); + AnimalRiteService.onDeath(death(cow, mage)); + assertTrue(record.getSpecies().isEmpty()); + record.setStage(BloodStage.ANIMAL); + cow = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, cow); + BloodAltars.remove(altar); + AnimalRiteService.onDeath(death(cow, mage)); + assertTrue(record.getSpecies().isEmpty()); + BloodAltars.add(altar); + cow = animal(Cow.class, keeper); + AnimalRiteService.bind(mage, cow); + BloodRecords.delete(id); + AnimalRiteService.onDeath(death(cow, mage)); + } + + @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(); + 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/BloodListenerTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java index e826d32..d26033a 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java @@ -199,4 +199,49 @@ void killsFeedTheKillersMeterAndSessionsResetVoiceTimers() { 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(); + } + } } 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..b71cf89 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java @@ -0,0 +1,325 @@ +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()); + boolean candle = false; + 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) { + candle = true; + assertTrue(((Lightable) block.getBlockData()).isLit()); + } + } + assertTrue(candle); + 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 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 }", " decorate:", " - { min_level: 0, at: floor, to: [COBWEB] }", "")); + 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/bloodmagic.yml"), 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(); + } +} From fb99be81443b4ce451a79cf79c1e33861f8e7af2 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:02:02 +0100 Subject: [PATCH 04/55] Add the god's question, the devotee mark and bound blood armour Blood magic phase 4: right-clicking the altar with one's own blood knife opens the god's question with a confirmation step. Devotees keep permanent injuries as their mark, skip the hunt, unlock the devotee class (through RPCharacters' ClassUnlocks when present) and get bound armour. Refusing keeps the current class and starts the hunt. Blood armour is unbreakable, will not stay on any other character, and crumbles once its owner is dead. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/AltarService.java | 3 + .../tfminecraft/magic/blood/BloodArmour.java | 97 +++++++ .../tfminecraft/magic/blood/BloodChoice.java | 60 ++++ .../tfminecraft/magic/blood/BloodConfig.java | 3 + .../magic/blood/BloodListener.java | 46 ++- .../tfminecraft/magic/blood/ChoiceConfig.java | 104 +++++++ .../tfminecraft/magic/blood/ChoiceMenu.java | 112 ++++++++ .../magic/blood/ClassUnlockBridge.java | 35 +++ .../magic/integration/RpCharactersBridge.java | 6 + src/main/resources/bloodmagic.yml | 45 +++ src/main/resources/messages.yml | 12 + .../magic/blood/BloodListenerTest.java | 69 +++++ .../tfminecraft/magic/blood/ChoiceTest.java | 268 ++++++++++++++++++ .../magic/blood/InjuryBridgeTest.java | 33 +++ 14 files changed, 892 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodArmour.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodChoice.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/ClassUnlockBridge.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/InjuryBridgeTest.java diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarService.java b/src/main/java/net/tfminecraft/magic/blood/AltarService.java index cb5259d..a104729 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -127,6 +127,9 @@ public static void sweepDead() { } static boolean isDead(String characterId) { + if (characterId == null) { + return false; + } UUID id; try { id = UUID.fromString(characterId); 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..b124ca3 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java @@ -0,0 +1,97 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.EquipmentSlot; +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; + +/** + * 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) { + List out = new ArrayList<>(); + for (String ref : ChoiceConfig.armourFor(path)) { + ItemStack item = ItemRef.build(ref); + if (item != null) { + out.add(bind(item, characterId)); + } + } + return out; + } + + static ItemStack bind(ItemStack item, String characterId) { + ItemMeta meta = item.getItemMeta(); + meta.setUnbreakable(true); + meta.getPersistentDataContainer().set(BloodItems.kindKey(), PersistentDataType.STRING, KIND); + meta.getPersistentDataContainer().set(BloodItems.ownerKey(), PersistentDataType.STRING, characterId); + item.setItemMeta(meta); + 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) { + for (ItemStack piece : create(path, characterId)) { + WhisperService.give(player, piece); + } + } + + /** + * 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")); + removed++; + } else if (character == null || !character.equalsIgnoreCase(owner)) { + inventory.setItem(slot, null); + WhisperService.give(player, worn); + player.sendMessage(Messages.get("blood.armour.refuses")); + removed++; + } + } + return removed; + } + + /** 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/BloodChoice.java b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java new file mode 100644 index 0000000..3d1532c --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java @@ -0,0 +1,60 @@ +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 gets the devotee class and 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) { + int marks = 0; + for (int i = 0; i < ChoiceConfig.devoteeInjuries(); i++) { + if (RpCharactersBridge.applyPermanentInjury(player)) { + marks++; + } + } + record.setStage(BloodStage.FINAL); + BloodRecords.save(record); + ClassUnlockBridge.unlock(player, ChoiceConfig.classFor(BloodPath.DEVOTEE)); + BloodArmour.give(player, BloodPath.DEVOTEE, character); + player.sendMessage(Messages.get("blood.choice.devoted")); + Magic.plugin.getLogger().info("[Magic] Character " + character + " gave themself to the god (" + + marks + " permanent injuries)"); + } else { + record.setStage(BloodStage.HUNT); + BloodRecords.save(record); + player.sendMessage(Messages.get("blood.choice.refused")); + Magic.plugin.getLogger().info("[Magic] Character " + character + " refused the god"); + } + 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/BloodConfig.java b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java index 7ad72e9..d0dd1cb 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java @@ -103,6 +103,8 @@ public static boolean load(File file, Logger log) { 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")); return ok; } @@ -159,6 +161,7 @@ public static void reset() { WhisperConfig.reset(); AnimalConfig.reset(); CorruptionConfig.reset(); + ChoiceConfig.reset(); } public static boolean isEnabled() { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index 6a01076..5707c84 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -18,6 +18,9 @@ import org.bukkit.event.block.BlockPistonRetractEvent; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; import org.bukkit.event.entity.EntityExplodeEvent; import org.bukkit.event.entity.PlayerLeashEntityEvent; import org.bukkit.event.player.PlayerInteractEntityEvent; @@ -27,6 +30,9 @@ import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; +import com.destroystokyo.paper.event.player.PlayerArmorChangeEvent; + +import net.tfminecraft.magic.Magic; import net.tfminecraft.magic.Messages; import net.tfminecraft.tlibs.character.CharacterDeathEvent; import net.tfminecraft.tlibs.character.CharacterRef; @@ -44,7 +50,16 @@ public void onInteract(PlayerInteractEvent event) { BloodAltar altar = BloodAltars.at(clicked); if (altar != null) { event.setCancelled(true); - event.getPlayer().sendMessage(AltarService.touch(event.getPlayer(), altar)); + 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)) { + ChoiceMenu.open(player); + return; + } + player.sendMessage(AltarService.touch(player, altar)); return; } ItemStack hand = event.getItem(); @@ -70,6 +85,34 @@ public void onInteractEntity(PlayerInteractEntityEvent event) { event.getPlayer().sendMessage(Messages.get("blood.animal.bind." + result.name().toLowerCase(Locale.ROOT))); } + @EventHandler + public void onMenuClick(InventoryClickEvent event) { + ChoiceMenu.onClick(event); + } + + @EventHandler + public void onMenuDrag(InventoryDragEvent event) { + ChoiceMenu.onDrag(event); + } + + /** 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 crumbles when someone tries to pick it up. */ + @EventHandler(ignoreCancelled = true) + public void onPickup(EntityPickupItemEvent event) { + if (BloodArmour.crumbles(event.getItem().getItemStack())) { + event.setCancelled(true); + event.getItem().remove(); + } + } + @EventHandler(ignoreCancelled = true) public void onLeash(PlayerLeashEntityEvent event) { StolenClues.leashed(event.getEntity()); @@ -159,6 +202,7 @@ 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()); } private static boolean touchesAltar(List blocks) { 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..4f3acbd --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java @@ -0,0 +1,104 @@ +package net.tfminecraft.magic.blood; + +import java.util.EnumMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.bukkit.configuration.ConfigurationSection; + +/** The god's question (choice:), the hidden classes (classes:) and the bound armour (armour:). */ +public final class ChoiceConfig { + + /** A menu button. */ + public record Button(String item, String name, List lore) {} + + public enum Slot { + QUESTION("question", "v.paper"), + DEVOTEE("devotee", "v.red_dye"), + TRUE("true", "v.iron_sword"), + CONFIRM("confirm", "v.redstone"), + BACK("back", "v.barrier"); + + private final String key; + private final String fallback; + + Slot(String key, String fallback) { + this.key = key; + this.fallback = fallback; + } + } + + private static String title; + private static final Map BUTTONS = new EnumMap<>(Slot.class); + private static int devoteeInjuries; + private static final Map CLASSES = new EnumMap<>(BloodPath.class); + private static final Map> ARMOUR = new EnumMap<>(BloodPath.class); + + static { + reset(); + } + + private ChoiceConfig() {} + + static void reset() { + title = "..."; + BUTTONS.clear(); + for (Slot slot : Slot.values()) { + BUTTONS.put(slot, new Button(slot.fallback, "", List.of())); + } + devoteeInjuries = 2; + CLASSES.clear(); + ARMOUR.clear(); + } + + static void load(ConfigurationSection choice, ConfigurationSection classes, ConfigurationSection armour) { + reset(); + if (choice != null) { + title = choice.getString("title", "..."); + devoteeInjuries = Math.max(0, choice.getInt("devotee_injuries", 2)); + for (Slot slot : Slot.values()) { + if (slot == Slot.QUESTION) { + BUTTONS.put(slot, new Button(slot.fallback, "", List.copyOf(choice.getStringList("question")))); + continue; + } + ConfigurationSection button = choice.getConfigurationSection(slot.key); + if (button != null) { + BUTTONS.put(slot, new Button(button.getString("item", slot.fallback), + button.getString("name", ""), List.copyOf(button.getStringList("lore")))); + } + } + } + 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) { + ARMOUR.put(path, List.copyOf(armour.getStringList(key))); + } + } + } + + public static String title() { + return title; + } + + public static Button button(Slot slot) { + return BUTTONS.get(slot); + } + + public static int devoteeInjuries() { + return devoteeInjuries; + } + + /** The hidden MMOCore class a path unlocks, or null. */ + public static String classFor(BloodPath path) { + return CLASSES.get(path); + } + + /** TLibs item paths of the bound armour 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/ChoiceMenu.java b/src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java new file mode 100644 index 0000000..80b003c --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java @@ -0,0 +1,112 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import net.tfminecraft.magic.util.ItemRef; +import net.tfminecraft.magic.util.MagicText; + +/** The god's question as a small menu: two answers, then a confirmation, since there is no undo. */ +public final class ChoiceMenu implements InventoryHolder { + + static final int SIZE = 27; + static final int QUESTION_SLOT = 4; + static final int LEFT_SLOT = 11; + static final int MIDDLE_SLOT = 13; + static final int RIGHT_SLOT = 15; + + /** NONE while the player still picks; the picked path while they confirm. */ + private final BloodPath pending; + private Inventory inventory; + + private ChoiceMenu(BloodPath pending) { + this.pending = pending; + } + + public BloodPath pending() { + return pending; + } + + @Override + public Inventory getInventory() { + return inventory; + } + + /** Opens the question for the player. */ + public static void open(Player player) { + show(player, BloodPath.NONE); + } + + private static void show(Player player, BloodPath pending) { + ChoiceMenu menu = new ChoiceMenu(pending); + Inventory inventory = Bukkit.createInventory(menu, SIZE, MagicText.format(ChoiceConfig.title())); + menu.inventory = inventory; + inventory.setItem(QUESTION_SLOT, button(ChoiceConfig.Slot.QUESTION)); + if (pending == BloodPath.NONE) { + inventory.setItem(LEFT_SLOT, button(ChoiceConfig.Slot.DEVOTEE)); + inventory.setItem(RIGHT_SLOT, button(ChoiceConfig.Slot.TRUE)); + } else { + inventory.setItem(LEFT_SLOT, button(ChoiceConfig.Slot.CONFIRM)); + inventory.setItem(MIDDLE_SLOT, button(pending == BloodPath.DEVOTEE + ? ChoiceConfig.Slot.DEVOTEE : ChoiceConfig.Slot.TRUE)); + inventory.setItem(RIGHT_SLOT, button(ChoiceConfig.Slot.BACK)); + } + player.openInventory(inventory); + } + + @SuppressWarnings("deprecation") + static ItemStack button(ChoiceConfig.Slot slot) { + ChoiceConfig.Button def = ChoiceConfig.button(slot); + ItemStack item = ItemRef.buildOrFallback(def.item(), Material.PAPER); + ItemMeta meta = item.getItemMeta(); + meta.setDisplayName(def.name().isBlank() ? " " : MagicText.format(def.name())); + List lore = new ArrayList<>(); + for (String line : def.lore()) { + lore.add(MagicText.format(line)); + } + meta.setLore(lore); + item.setItemMeta(meta); + return item; + } + + /** Clicks inside the menu never move items; the two answer slots lead on. */ + public static void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof ChoiceMenu menu)) { + return; + } + event.setCancelled(true); + if (event.getClickedInventory() != event.getInventory()) { + return; + } + Player player = (Player) event.getWhoClicked(); + int slot = event.getRawSlot(); + if (menu.pending == BloodPath.NONE) { + if (slot == LEFT_SLOT || slot == RIGHT_SLOT) { + show(player, slot == LEFT_SLOT ? BloodPath.DEVOTEE : BloodPath.TRUE); + } + return; + } + if (slot == RIGHT_SLOT) { + show(player, BloodPath.NONE); + } else if (slot == LEFT_SLOT) { + player.closeInventory(); + BloodChoice.answer(player, menu.pending); + } + } + + public static void onDrag(InventoryDragEvent event) { + if (event.getInventory().getHolder() instanceof ChoiceMenu) { + event.setCancelled(true); + } + } +} 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..7291638 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/ClassUnlockBridge.java @@ -0,0 +1,35 @@ +package net.tfminecraft.magic.blood; + +import org.bukkit.entity.Player; + +import net.tfminecraft.magic.Magic; + +/** + * Unlocks a hidden class for the player's active character through RPCharacters' ClassUnlocks. + * Reached by reflection so Magic also runs against RPCharacters versions without it; then staff + * unlock the class by hand, as the log says. + */ +public final class ClassUnlockBridge { + + static String unlocksClass = "net.tfminecraft.rpcharacters.classpick.ClassUnlocks"; + + private ClassUnlockBridge() {} + + public static boolean unlock(Player player, String classId) { + if (classId == null) { + return false; + } + try { + Object result = Class.forName(unlocksClass).getMethod("unlock", 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 unlock class " + classId + " for " + + player.getName() + "; unlock it by hand (/rpcharacter admin unlockclass)"); + return false; + } +} diff --git a/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java b/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java index 77d6c8a..eee2d4f 100644 --- a/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java +++ b/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java @@ -37,6 +37,12 @@ public static String resolveRealName(RPCharacter character) { return DisplayIdentityService.colourPlain(character.getName(), character.getNameColour()); } + /** Gives the player's active character a random permanent injury; false without one. */ + public static boolean applyPermanentInjury(Player player) { + RPCharacter character = getActiveCharacter(player); + return character != null && PermadeathService.applyRandomPermanentInjury(player, character); + } + /** * @return false when required consequences are unavailable or permanent death was refused */ diff --git a/src/main/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index 22064b2..2e8339b 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -173,3 +173,48 @@ corruption: - { min_level: 3, at: ceiling, to: [WEEPING_VINES] } - { min_level: 3, at: floor, to: [CRIMSON_ROOTS, CRIMSON_FUNGUS] } - { min_level: 4, at: floor, to: [SKELETON_SKULL, BONE_BLOCK] } + +# The god's question: right-click your altar with your blood knife once the seventh kind is given. +choice: + title: "#5c0202Will you give yourself?" + question: + - "#8a0303§oSeven kinds have bled for us." + - "#8a0303§oGive us yourself, and we will carry you." + - "#8a0303§oOr keep yourself, and carry it all alone." + devotee: + item: v.red_dye + name: "#8a0303I give myself" + lore: + - "#8c8c8c§oPart of you stays on the stone." + "true": + item: v.iron_sword + name: "#bfbfbfI take for myself" + lore: + - "#8c8c8c§oNo god. Only you, and what you take." + confirm: + item: v.redstone + name: "#ff5555There is no going back" + back: + item: v.barrier + name: "#aaaaaaNot yet" + # Permanent injuries a devotee keeps as the mark of what they gave. + devotee_injuries: 2 + +# Hidden MMOCore classes (options.display: false) unlocked for the character through RPCharacters. +classes: + devotee: bloodsworn + "true": sanguinar + +# Bound, unbreakable armour (TLibs item paths). Devotees get theirs at the question; the true path +# when the hunt is done. Placeholder pieces until the real sets exist. +armour: + devotee: + - m.mage_armors.bloodmagic_mage_helmet + - m.mage_armors.bloodmagic_mage_chestplate + - m.mage_armors.bloodmagic_mage_leggings + - m.mage_armors.bloodmagic_mage_boots + "true": + - m.mage_armors.bloodmagic_mage_helmet + - m.mage_armors.bloodmagic_mage_chestplate + - m.mage_armors.bloodmagic_mage_leggings + - m.mage_armors.bloodmagic_mage_boots diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index e04501e..c11214e 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -514,6 +514,18 @@ blood: note_lost: "#aaaaaa§oSomething took {animal} while no one was watching" + choice: + + devoted: "#8a0303§oSomething of you stays on the stone, and something of the stone goes with you" + + refused: "#7a7a7a§oThe voice falls silent. For the first time in days you are alone with your thoughts. They are not kind" + + armour: + + refuses: "#8c8c8c§oIt will not sit on you" + + crumbles: "#8c8c8c§oIt crumbles to rust and dried blood in your hands" + direction: north: "north" diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java index d26033a..dd7b060 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java @@ -244,4 +244,73 @@ void ritualKnifeClicksBindAnimalsAndLeadsAreRemembered() { 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.owner_touch", 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.owner_touch", 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.owner_touch", 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.owner_touch", mage.nextMessage()); + BloodRecords.get(id).setStage(BloodStage.CHOICE); + listener.onInteract(click(mage, Action.RIGHT_CLICK_BLOCK, null, target, BlockFace.UP, EquipmentSlot.HAND)); + assertInstanceOf(ChoiceMenu.class, mage.getOpenInventory().getTopInventory().getHolder()); + 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()); + + var view = mage.getOpenInventory(); + var menuClick = new org.bukkit.event.inventory.InventoryClickEvent(view, + org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, + org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL); + listener.onMenuClick(menuClick); + assertTrue(menuClick.isCancelled()); + var drag = mock(org.bukkit.event.inventory.InventoryDragEvent.class); + when(drag.getInventory()).thenReturn(view.getTopInventory()); + listener.onMenuDrag(drag); + verify(drag).setCancelled(true); + + 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/ChoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java new file mode 100644 index 0000000..121be76 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.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.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.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.InventoryView; +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 unlock(Player player, String classId) { + last = classId; + return answer; + } + } + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + BloodConfig.load(new File("src/main/resources/bloodmagic.yml"), 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.applyPermanentInjury(any())).thenReturn(true); + } + + @AfterEach + void cleanup() { + bridge.close(); + ClassUnlockBridge.unlocksClass = "net.tfminecraft.rpcharacters.classpick.ClassUnlocks"; + WhisperService.clear(); + fx.close(); + } + + InventoryClickEvent click(int slot) { + InventoryView view = mage.getOpenInventory(); + return new InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, slot, ClickType.LEFT, InventoryAction.PICKUP_ALL); + } + + @Test + void bundledQuestionClassesAndArmourLoad() { + assertEquals("#5c0202Will you give yourself?", ChoiceConfig.title()); + assertEquals(3, ChoiceConfig.button(ChoiceConfig.Slot.QUESTION).lore().size()); + assertEquals("v.iron_sword", ChoiceConfig.button(ChoiceConfig.Slot.TRUE).item()); + assertEquals(2, ChoiceConfig.devoteeInjuries()); + 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"), "enabled: true\nchoice:\n devotee_injuries: -3\nclasses:\n devotee: ' '\n 'true': [x]\n"); + BloodConfig.load(temp.resolve("e.yml").toFile(), fx.logger); + assertEquals("...", ChoiceConfig.title()); + assertEquals(0, ChoiceConfig.devoteeInjuries()); + assertEquals("v.red_dye", ChoiceConfig.button(ChoiceConfig.Slot.DEVOTEE).item()); + assertNull(ChoiceConfig.classFor(BloodPath.DEVOTEE)); + assertNull(ChoiceConfig.classFor(BloodPath.TRUE)); + assertTrue(ChoiceConfig.armourFor(BloodPath.DEVOTEE).isEmpty()); + BloodConfig.reset(); + assertEquals("...", ChoiceConfig.title()); + } + + @Test + void theMenuAsksThenConfirmsAndGoesBack() { + ChoiceMenu.open(mage); + ChoiceMenu menu = (ChoiceMenu) mage.getOpenInventory().getTopInventory().getHolder(); + assertEquals(BloodPath.NONE, menu.pending()); + assertSame(mage.getOpenInventory().getTopInventory(), menu.getInventory()); + ChoiceMenu.onClick(click(0)); + ChoiceMenu.onClick(click(ChoiceMenu.RIGHT_SLOT)); + menu = (ChoiceMenu) mage.getOpenInventory().getTopInventory().getHolder(); + assertEquals(BloodPath.TRUE, menu.pending()); + ChoiceMenu.onClick(click(0)); + ChoiceMenu.onClick(click(ChoiceMenu.RIGHT_SLOT)); + menu = (ChoiceMenu) mage.getOpenInventory().getTopInventory().getHolder(); + assertEquals(BloodPath.NONE, menu.pending()); + ChoiceMenu.onClick(click(ChoiceMenu.LEFT_SLOT)); + menu = (ChoiceMenu) mage.getOpenInventory().getTopInventory().getHolder(); + assertEquals(BloodPath.DEVOTEE, menu.pending()); + InventoryClickEvent below = click(40); + ChoiceMenu.onClick(below); + assertTrue(below.isCancelled()); + ChoiceMenu.onClick(click(ChoiceMenu.LEFT_SLOT)); + assertEquals(BloodPath.DEVOTEE, record.getPath()); + + InventoryDragEvent drag = mock(InventoryDragEvent.class); + when(drag.getInventory()).thenReturn(fx.server.createInventory(null, 9)); + ChoiceMenu.onDrag(drag); + verify(drag, never()).setCancelled(true); + mage.openInventory(fx.server.createInventory(null, 9)); + InventoryClickEvent elsewhere = click(0); + ChoiceMenu.onClick(elsewhere); + assertFalse(elsewhere.isCancelled()); + ChoiceMenu.open(mage); + InventoryDragEvent menuDrag = mock(InventoryDragEvent.class); + when(menuDrag.getInventory()).thenReturn(mage.getOpenInventory().getTopInventory()); + ChoiceMenu.onDrag(menuDrag); + verify(menuDrag).setCancelled(true); + } + + @Test + void buttonsWithoutNamesStayBlank() throws Exception { + Files.writeString(temp.resolve("b.yml"), "enabled: true\nchoice:\n back:\n item: v.stone\n"); + BloodConfig.load(temp.resolve("b.yml").toFile(), fx.logger); + assertEquals(" ", ChoiceMenu.button(ChoiceConfig.Slot.BACK).getItemMeta().getDisplayName()); + } + + @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)); + assertTrue(BloodChoice.answer(mage, BloodPath.DEVOTEE)); + } + bridge.verify(() -> RpCharactersBridge.applyPermanentInjury(mage), times(2)); + 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() { + bridge.when(() -> RpCharactersBridge.applyPermanentInjury(any())).thenReturn(false); + 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.applyPermanentInjury(any()), never()); + } + + @Test + void injuriesThatFailAreCounted() { + bridge.when(() -> RpCharactersBridge.applyPermanentInjury(any())).thenReturn(false); + assertTrue(BloodChoice.answer(mage, BloodPath.DEVOTEE)); + assertEquals(BloodStage.FINAL, record.getStage()); + } + + @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.unlock(mage, null)); + FakeUnlocks.answer = false; + assertFalse(ClassUnlockBridge.unlock(mage, "x")); + FakeUnlocks.answer = true; + assertTrue(ClassUnlockBridge.unlock(mage, "x")); + ClassUnlockBridge.unlocksClass = "no.such.Unlocks"; + assertFalse(ClassUnlockBridge.unlock(mage, "x")); + } + + @Test + void armourIsBoundUnbreakableAndOnlyItsOwnerWearsIt() { + ItemStack helmet = BloodArmour.bind(new ItemStack(Material.LEATHER_HELMET), id); + assertTrue(BloodArmour.is(helmet)); + assertTrue(helmet.getItemMeta().isUnbreakable()); + 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/InjuryBridgeTest.java b/src/test/java/net/tfminecraft/magic/blood/InjuryBridgeTest.java new file mode 100644 index 0000000..b487670 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/InjuryBridgeTest.java @@ -0,0 +1,33 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; + +import net.tfminecraft.magic.integration.RpCharactersBridge; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.permadeath.PermadeathService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class InjuryBridgeTest { + @TempDir Path temp; + + @Test + void permanentInjuryNeedsTheActiveCharacter() { + try (BloodFixture fx = new BloodFixture(temp); + var rp = mockStatic(RPCharacters.class); + var permadeath = mockStatic(PermadeathService.class)) { + var player = fx.player("mage"); + assertFalse(RpCharactersBridge.applyPermanentInjury(player)); + RPCharacter character = mock(RPCharacter.class); + when(character.getId()).thenReturn(BloodFixture.id("mage")); + rp.when(() -> RPCharacters.getActiveCharacter(player)).thenReturn(character); + assertFalse(RpCharactersBridge.applyPermanentInjury(player)); + permadeath.when(() -> PermadeathService.applyRandomPermanentInjury(player, character)).thenReturn(true); + assertTrue(RpCharactersBridge.applyPermanentInjury(player)); + } + } +} From 878504699812bc31736801bd1b79daac828ad54e Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:19:26 +0100 Subject: [PATCH 05/55] Add the hunt, hidden bonds and the final sacrifices Blood magic phases 5 and 6: a knocked-out character near the altar is dragged onto it with the mage's blood knife and dies for good when the short rite ends. Hunt sacrifices earn points by how much the victim mattered to the mage, from a hidden bond score: counted RP conversations, time spent near each other (sampled for every character) and a will heir who is another player's character the two spent real time with. Same-account characters never count; fresh characters and taking the same player twice in a row are refused. The closest characters are kept for the finals, named one at a time from least close to closest. A named target dying any other way is a failed sacrifice with backlash and a wait; dead, vanished or long-absent targets are replaced, and staff can list, reroll, set or skip targets. Finals grant MythicLib stat boons; reaching the hunt goal unlocks the true class and armour. Builds against RPCharacters 2.31.0. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 2 +- .../tfminecraft/magic/blood/BloodBoons.java | 42 ++ .../tfminecraft/magic/blood/BloodChoice.java | 3 +- .../tfminecraft/magic/blood/BloodCommand.java | 66 +- .../tfminecraft/magic/blood/BloodConfig.java | 3 + .../magic/blood/BloodListener.java | 32 +- .../tfminecraft/magic/blood/BloodMagic.java | 15 + .../tfminecraft/magic/blood/BloodRecord.java | 59 +- .../tfminecraft/magic/blood/BloodStore.java | 27 + .../tfminecraft/magic/blood/BondService.java | 183 ++++++ .../magic/blood/CharacterRiteService.java | 211 +++++++ .../tfminecraft/magic/blood/FinalService.java | 256 ++++++++ .../tfminecraft/magic/blood/HuntConfig.java | 224 +++++++ .../tfminecraft/magic/blood/MythicBoons.java | 44 ++ .../magic/integration/RpCharactersBridge.java | 44 ++ src/main/resources/bloodmagic.yml | 53 ++ src/main/resources/messages.yml | 66 +- .../magic/blood/BloodDomainTest.java | 13 +- .../magic/blood/BoonsAndToolsTest.java | 194 ++++++ .../magic/blood/HuntAndFinalsTest.java | 562 ++++++++++++++++++ .../magic/blood/HuntConfigTest.java | 108 ++++ 21 files changed, 2195 insertions(+), 12 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodBoons.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BondService.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/FinalService.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/HuntConfig.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/MythicBoons.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java diff --git a/pom.xml b/pom.xml index ce39fa9..5d8c9a4 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 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..73156b6 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodBoons.java @@ -0,0 +1,42 @@ +package net.tfminecraft.magic.blood; + +import java.util.Map; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +/** + * Stat boons earned by final sacrifices, added through MythicLib while the mage's character is + * played. 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)); + if (record == null) { + return; + } + Map boons = HuntConfig.boonsUpTo(record.getPath(), record.getFinalsDone()); + if (!boons.isEmpty()) { + MythicBoons.add(player, boons); + } + } + + /** 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/BloodChoice.java b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java index 3d1532c..5205e80 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java @@ -37,11 +37,10 @@ public static boolean answer(Player player, BloodPath path) { marks++; } } - record.setStage(BloodStage.FINAL); - BloodRecords.save(record); ClassUnlockBridge.unlock(player, ChoiceConfig.classFor(BloodPath.DEVOTEE)); BloodArmour.give(player, BloodPath.DEVOTEE, character); player.sendMessage(Messages.get("blood.choice.devoted")); + FinalService.begin(player, record); Magic.plugin.getLogger().info("[Magic] Character " + character + " gave themself to the god (" + marks + " permanent injuries)"); } else { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java index 755769e..b282ad9 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java @@ -12,15 +12,18 @@ 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|altar|reset>. + * Staff tools: /magic blood <list|info|stage|path|give|whisper|voice|target|bonds|altar|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", "altar", "reset"); + "list", "info", "stage", "path", "give", "whisper", "voice", "target", "bonds", "altar", "reset"); + private static final List TARGET_ACTIONS = List.of("list", "reroll", "set", "skip"); private static final List ALTAR_ACTIONS = List.of("info", "tp", "refresh", "purify"); private BloodCommand() {} @@ -35,6 +38,8 @@ public static boolean execute(CommandSender sender, String[] args) { 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 "reset" -> withTarget(sender, args, 3, (player, character) -> reset(sender, player, character)); default -> sender.sendMessage(Messages.get("blood.admin.usage")); @@ -178,6 +183,62 @@ private static void voice(CommandSender sender, Player player, String character) 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) { @@ -252,6 +313,7 @@ public static List complete(String[] args) { case "path" -> lower(BloodPath.values()); case "give" -> Arrays.stream(BloodItemKind.values()).map(BloodItemKind::key).toList(); case "altar" -> altarTargets(); + case "target" -> TARGET_ACTIONS; default -> List.of(); }; } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java index d0dd1cb..3cb5c17 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java @@ -105,6 +105,8 @@ public static boolean load(File file, Logger log) { CorruptionConfig.load(config.getConfigurationSection("corruption"), log); ChoiceConfig.load(config.getConfigurationSection("choice"), config.getConfigurationSection("classes"), config.getConfigurationSection("armour")); + HuntConfig.load(config.getConfigurationSection("hunt"), config.getConfigurationSection("bonds"), + config.getConfigurationSection("finals"), log); return ok; } @@ -162,6 +164,7 @@ public static void reset() { AnimalConfig.reset(); CorruptionConfig.reset(); ChoiceConfig.reset(); + HuntConfig.reset(); } public static boolean isEnabled() { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index 5707c84..33871fe 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -36,6 +36,7 @@ import net.tfminecraft.magic.Messages; 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. */ @@ -75,9 +76,18 @@ public void onInteract(PlayerInteractEvent event) { /** 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 || !(event.getRightClicked() instanceof LivingEntity animal) - || animal instanceof Player - || !BloodItems.is(event.getPlayer().getInventory().getItemInMainHand(), BloodItemKind.RITUAL_KNIFE)) { + 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); + CharacterRiteService.Bind result = CharacterRiteService.bind(event.getPlayer(), victim); + event.getPlayer().sendMessage(Messages.get("blood.rite.bind." + result.name().toLowerCase(Locale.ROOT))); + return; + } + if (!(event.getRightClicked() instanceof LivingEntity animal) || animal instanceof Player + || !BloodItems.is(held, BloodItemKind.RITUAL_KNIFE)) { return; } event.setCancelled(true); @@ -170,7 +180,11 @@ public void onEntitiesLoad(EntitiesLoadEvent event) { @EventHandler public void onCharacterDeath(CharacterDeathEvent event) { - AltarService.onCharacterDeath(event.getCharacter().characterId().toString()); + 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; @@ -203,6 +217,16 @@ public void onSessionStart(CharacterSessionStartEvent event) { 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()); + FinalService.check(event.getPlayer()); + } + + @EventHandler + public void onSessionEnd(CharacterSessionEndEvent event) { + BondService.unload(event.getSession().ref().characterId().toString()); + BloodBoons.clear(event.getPlayer()); } private static boolean touchesAltar(List blocks) { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index 4ab6453..ead41a0 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -3,6 +3,8 @@ import java.io.File; import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; import net.tfminecraft.magic.manager.MagicTickService; @@ -19,6 +21,7 @@ public static void start(Plugin plugin) { BloodRecords.init(store); BloodAltars.init(store); BloodNotes.init(store); + BondService.init(store); plugin.getServer().getPluginManager().registerEvents(new BloodListener(), plugin); MagicTickService.register(BloodMagic::tick); plugin.getServer().getScheduler().runTask(plugin, BloodMagic::settle); @@ -38,6 +41,16 @@ static void tick(MagicTickContext context) { } WhisperService.tick(context); AnimalRiteService.tick(); + CharacterRiteService.tick(); + if (context.everyMinutes(HuntConfig.sampleMinutes())) { + BondService.sample(); + } + if (context.everyMinutes(10)) { + BondService.flush(); + for (Player player : Bukkit.getOnlinePlayers()) { + FinalService.check(player); + } + } if (context.everyMinutes(1)) { AltarCorruption.ambient(); BloodNotes.tick(System.currentTimeMillis()); @@ -50,6 +63,8 @@ public static boolean loadConfig(File dataFolder, Logger log) { } public static void stop() { + CharacterRiteService.clear(); + BondService.clear(); AnimalRiteService.releaseAll(); StolenClues.clear(); BloodNotes.clear(); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java b/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java index d34a506..b350a7c 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java @@ -21,6 +21,19 @@ public final class BloodRecord { 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; + + /** One character sacrificed at the altar. */ + public record Sacrifice(String character, String account, String stage, int points, long at) {} public BloodRecord() {} @@ -42,6 +55,12 @@ public void normalize() { if (species == null) { species = new ArrayList<>(); } + if (sacrifices == null) { + sacrifices = new ArrayList<>(); + } + if (finalTargets == null) { + finalTargets = new ArrayList<>(); + } if (whisper < 0 || Double.isNaN(whisper)) { whisper = 0; } @@ -147,9 +166,47 @@ 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; + } + /** 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; + && species.isEmpty() && altarId == null && huntPoints == 0 && finalsDone == 0 + && sacrifices.isEmpty() && finalTargets.isEmpty() && !pendingBacklash; } } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodStore.java b/src/main/java/net/tfminecraft/magic/blood/BloodStore.java index 638d74a..22454df 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodStore.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodStore.java @@ -8,7 +8,9 @@ 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; @@ -26,16 +28,19 @@ 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 BOND_MAP = new TypeToken>() {}.getType(); private final File characters; private final File altars; private final File notes; + private final File bonds; 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.log = log; } @@ -117,6 +122,28 @@ public boolean saveNotes(List list) { return write(notes, GSON.toJson(list, NOTE_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; + } + + 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); 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..2cc1430 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BondService.java @@ -0,0 +1,183 @@ +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 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; + + private BondService() {} + + public static void init(BloodStore source) { + MINUTES.clear(); + DIRTY.clear(); + store = source; + } + + /** Writes pending changes and forgets everything. */ + public static void clear() { + flush(); + 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. */ + public static void flush() { + if (store != null) { + for (String key : DIRTY) { + store.saveBonds(key, MINUTES.get(key)); + } + } + 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/CharacterRiteService.java b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java new file mode 100644 index 0000000..187ad55 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java @@ -0,0 +1,211 @@ +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.Particle; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +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. + */ +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 + } + + private record Rite(UUID victim, String mageCharacter, String victimCharacter, String victimAccount, + int points, long endsAt) {} + + private static final Map RITES = new HashMap<>(); + private static final Set SACRIFICING = new HashSet<>(); + private static final double LEAVE_SLACK = 4; + 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); + if (record == null || (record.getStage() != BloodStage.HUNT && record.getStage() != BloodStage.FINAL)) { + 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; + } + 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; + } + String victimAccount = victim.getUniqueId().toString(); + if (RpCharactersBridge.playtimeSeconds(victim) < HuntConfig.minPlaytimeHours() * 3600 + || RpCharactersBridge.characterAgeSeconds(victim) < HuntConfig.minAgeDays() * 86_400) { + return Bind.TOO_NEW; + } + if (HuntConfig.noRepeatPlayer() && victimAccount.equalsIgnoreCase(record.getLastVictimAccount())) { + return Bind.REPEAT; + } + int points = 0; + if (record.getStage() == BloodStage.HUNT) { + int rank = BondService.rank(mage, victimCharacter); + if (rank >= 1 && rank <= HuntConfig.finalCount()) { + return Bind.RESERVED; + } + points = HuntConfig.points(rank); + } else { + if (clock.getAsLong() < record.getNextFinalAt()) { + return Bind.WAITING; + } + if (!victimCharacter.equalsIgnoreCase(record.currentTarget())) { + return Bind.NOT_TARGET; + } + } + if (RITES.containsKey(mage.getUniqueId()) || isVictim(victim.getUniqueId())) { + return Bind.BUSY; + } + victim.teleport(top.clone().add(0, 0.5, 0)); + RITES.put(mage.getUniqueId(), new Rite(victim.getUniqueId(), character, victimCharacter, victimAccount, + points, clock.getAsLong() + HuntConfig.riteSeconds() * 1000L)); + victim.sendMessage(Messages.get("blood.rite.dread")); + return Bind.OK; + } + + 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 (mage == null || victim == null || top == null || mage.getWorld() != top.getWorld() + || mage.getLocation().distance(top) > HuntConfig.dragRange() + LEAVE_SLACK + || !rite.mageCharacter().equalsIgnoreCase(AltarService.currentCharacter(mage)) + || !rite.victimCharacter().equalsIgnoreCase(AltarService.currentCharacter(victim))) { + it.remove(); + if (mage != null) { + mage.sendMessage(Messages.get("blood.rite.broken")); + } + continue; + } + if (now < rite.endsAt()) { + top.getWorld().spawnParticle(Particle.DAMAGE_INDICATOR, top.clone().add(0, 1, 0), 6, 0.3, 0.4, 0.3, 0); + victim.playSound(victim.getLocation(), "minecraft:entity.warden.heartbeat", 1f, 1f); + continue; + } + it.remove(); + complete(mage, victim, rite, altar); + } + } + + private static void complete(Player mage, Player victim, Rite rite, BloodAltar altar) { + 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())); + record.setLastVictimAccount(rite.victimAccount()); + Location top = altar.center(); + top.getWorld().playSound(top, "minecraft:entity.warden.sonic_boom", 0.6f, 0.6f); + 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 { + mage.sendMessage(Messages.get("blood.rite.taken")); + BloodRecords.save(record); + } + } else if (stage == BloodStage.FINAL && key.equalsIgnoreCase(record.currentTarget())) { + FinalService.completed(mage, record); + } 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(); + SACRIFICING.clear(); + } + + static boolean hasRite(UUID mage) { + return RITES.containsKey(mage); + } +} 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..57cecf7 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/FinalService.java @@ -0,0 +1,256 @@ +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), then the first target. */ + public static void begin(Player mage, BloodRecord record) { + record.setStage(BloodStage.FINAL); + if (record.getPath() == BloodPath.TRUE) { + ClassUnlockBridge.unlock(mage, ChoiceConfig.classFor(BloodPath.TRUE)); + BloodArmour.give(mage, BloodPath.TRUE, record.getCharacterId()); + mage.sendMessage(Messages.get("blood.hunt.complete")); + } + refill(mage, record); + BloodRecords.save(record); + 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("blood.final.nobody")); + return; + } + WhisperService.speak(mage, speaker, Messages.getRaw("blood.final.named").replace("{target}", nameOf(target))); + } + + 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(); + mage.sendMessage(Messages.get("blood.final.ascended")); + } else { + refill(mage, record); + mage.sendMessage(Messages.get("blood.final.taken")); + } + BloodRecords.save(record); + BloodBoons.apply(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); + WhisperService.speak(mage, WhisperService.speaker(record), Messages.getRaw("blood.final.failed")); + 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..a981e3a --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java @@ -0,0 +1,224 @@ +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) {} + + private static double dragRange; + private static int riteSeconds; + 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); + + static { + reset(); + } + + private HuntConfig() {} + + static void reset() { + dragRange = 8; + riteSeconds = 10; + 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(); + } + + 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)); + 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] bloodmagic.yml 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)); + } + } + } + } + + 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; + try { + index = Integer.parseInt(key.trim()); + } catch (NumberFormatException ex) { + index = -1; + } + if (index < 1 || stats == null) { + log.warning("[Magic] bloodmagic.yml 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 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; + } +} 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..0c473a8 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/MythicBoons.java @@ -0,0 +1,44 @@ +package net.tfminecraft.magic.blood; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.entity.Player; + +import io.lumine.mythic.lib.api.player.MMOPlayerData; +import io.lumine.mythic.lib.api.stat.modifier.StatModifier; + +/** 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 static final Map> APPLIED = new HashMap<>(); + + private MythicBoons() {} + + static void add(Player player, Map boons) { + MMOPlayerData data = MMOPlayerData.getOrNull(player); + if (data == null) { + return; + } + Set stats = APPLIED.computeIfAbsent(player.getUniqueId(), id -> new HashSet<>()); + boons.forEach((stat, value) -> { + new StatModifier(KEY, stat, value).register(data); + stats.add(stat); + }); + } + + static void clear(Player player) { + Set stats = APPLIED.remove(player.getUniqueId()); + MMOPlayerData data = MMOPlayerData.getOrNull(player); + if (stats == null || data == null) { + return; + } + for (String stat : stats) { + data.getStatMap().getInstance(stat).remove(KEY); + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java b/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java index eee2d4f..33dfcb0 100644 --- a/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java +++ b/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java @@ -1,5 +1,8 @@ package net.tfminecraft.magic.integration; +import java.util.HashMap; +import java.util.Map; + import org.bukkit.entity.Player; import net.tfminecraft.rpcharacters.objects.RPCharacter; @@ -37,6 +40,47 @@ 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); + } + + /** 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 a random permanent injury; false without one. */ public static boolean applyPermanentInjury(Player player) { RPCharacter character = getActiveCharacter(player); diff --git a/src/main/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index 2e8339b..1b316c2 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -48,6 +48,59 @@ altar: 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 + 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. + boons: + devotee: + 1: { MAX_HEALTH: 1 } + 2: { MAX_MANA: 10 } + 3: { MAX_HEALTH: 1 } + 4: { SPELL_DAMAGE: 2 } + 5: { MAX_HEALTH: 2 } + "true": + 1: { MAX_HEALTH: 2 } + 2: { MAX_MANA: 20 } + 3: { MAX_HEALTH: 2 } + 4: { SPELL_DAMAGE: 4 } + 5: { MAX_HEALTH: 4 } placement: # Worlds where an altar may stand. Empty = every world. diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index c11214e..c7e028a 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -514,6 +514,60 @@ blood: note_lost: "#aaaaaa§oSomething took {animal} while no one was watching" + rite: + + bind: + + ok: "#8a0303§oYou drag them onto the stone. It is already warm" + + not_stage: "#8c8c8c§oThe stone does not want them. Not yet" + + not_knife: "#8c8c8c§oThat blade will not open the way" + + 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. Bring them closer" + + too_new: "#8c8c8c§oThin blood. The stone would not drink it" + + repeat: "#8c8c8c§oThat blood is still on the stone. Find another" + + reserved: "#7a7a7a§oNot them. Not yet. They are for later" + + waiting: "#8c8c8c§oThe stone is sullen. It will not take anyone yet" + + not_target: "#8c8c8c§oNot this one. You know who it must be" + + busy: "#8c8c8c§oOne at a time" + + dread: "#8a0303§oStone under your back. A blade above you. Something below is hungry" + + broken: "#8c8c8c§oYou step away and the rite comes apart" + + refused: "#8c8c8c§oThe stone will not take them" + + taken: "#7a7a7a§oIt is done. You feel heavier, and stronger" + + hunt: + + complete: "#7a7a7a§oSeven measures of blood. You are something else now, and the stone knows your name" + + final: + + named: "{target}. It has to be {target}" + + nobody: "There is no one close enough yet" + + taken: "#7a7a7a§oA hollow opens in you where they were, and something cold fills it" + + ascended: "#5c0202§oThe last of them is gone. Nothing holds you now" + + failed: "Someone else spilled what was yours. You will pay for that" + choice: devoted: "#8a0303§oSomething of you stays on the stone, and something of the stone goes with you" @@ -546,7 +600,7 @@ blood: admin: - usage: "#aaaaaaUsage: /magic blood list | info | stage | path | give | whisper [amount] | voice | altar | reset " + usage: "#aaaaaaUsage: /magic blood list | info | stage | path | give | whisper [amount] | voice | target > | bonds | altar | reset " no_record: "#aaaaaa{player} ({character}) has no blood path." @@ -587,3 +641,13 @@ blood: 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/test/java/net/tfminecraft/magic/blood/BloodDomainTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodDomainTest.java index 8ff368a..bb1f0a3 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodDomainTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodDomainTest.java @@ -31,7 +31,7 @@ void enumsParseCaseInsensitivelyAndRejectUnknownNames() { @Test void recordNormalizesBrokenFieldsAndKnowsWhenItIsEmpty() throws Exception { BloodRecord record = new BloodRecord(); - for (String field : new String[] {"stage", "path", "species"}) { + for (String field : new String[] {"stage", "path", "species", "sacrifices", "finalTargets"}) { var f = BloodRecord.class.getDeclaredField(field); f.setAccessible(true); f.set(record, null); @@ -115,6 +115,17 @@ private static void assertEmptyOnlyWhenBlank(BloodRecord blank) { 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 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..f953948 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java @@ -0,0 +1,194 @@ +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/bloodmagic.yml"), 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(2); + 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.getInstance(any())).thenReturn(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 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/HuntAndFinalsTest.java b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java new file mode 100644 index 0000000..1d3d0c8 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java @@ -0,0 +1,562 @@ +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/bloodmagic.yml"), 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.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); + 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 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()); + assertEquals(11.0, victim.getLocation().getY(), 0.01); + 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/bloodmagic.yml"), 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()); + } + + @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..7f5e990 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java @@ -0,0 +1,108 @@ +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/bloodmagic.yml"), 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", 4.0, "MAX_MANA", 20.0), HuntConfig.boonsUpTo(BloodPath.TRUE, 3)); + assertEquals(Map.of(), HuntConfig.boonsUpTo(BloodPath.NONE, 5)); + } + + @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)); + } +} From 2ead4077fed0ef0e3c3ad761dd42967384b45203 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:20:48 +0100 Subject: [PATCH 06/55] Document the blood path staff commands Co-Authored-By: Claude Opus 5.5 --- ADMIN-COMMANDS.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) 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. From 7f12c28ebcc974d1ab0998625e60739600ebd5c5 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:30:36 +0100 Subject: [PATCH 07/55] Drag knocked-out victims to the altar and give the blood path real looks Sneak and right-click a knocked-out character with the blood knife to drag the body along; RPCharacters keeps it down where it is moved (moveDowned), so the rite now also holds the victim on the altar. The items, altar stages, trace and the two bound armour sets use the tfmc_blood ItemsAdder pack, and bound items opt out of ItemsAdder's auto_update. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 14 + .../tfminecraft/magic/blood/BloodArmour.java | 7 +- .../tfminecraft/magic/blood/BloodItems.java | 1 + .../magic/blood/BloodListener.java | 30 +- .../tfminecraft/magic/blood/BloodLooks.java | 52 +++ .../tfminecraft/magic/blood/BloodMagic.java | 2 + .../tfminecraft/magic/blood/CarryService.java | 187 ++++++++++ .../magic/blood/CharacterRiteService.java | 26 +- .../tfminecraft/magic/blood/ChoiceConfig.java | 30 +- .../tfminecraft/magic/blood/HuntConfig.java | 31 ++ .../magic/integration/RpCharactersBridge.java | 22 ++ src/main/resources/bloodmagic.yml | 54 +-- src/main/resources/messages.yml | 14 +- .../magic/blood/BloodConfigTest.java | 6 +- .../magic/blood/BloodLooksTest.java | 105 ++++++ .../tfminecraft/magic/blood/CarryTest.java | 332 ++++++++++++++++++ .../tfminecraft/magic/blood/ChoiceTest.java | 1 + .../magic/blood/HuntAndFinalsTest.java | 2 +- .../magic/integration/KnockoutMoveTest.java | 93 +++++ 19 files changed, 966 insertions(+), 43 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodLooks.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/CarryService.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/BloodLooksTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/CarryTest.java create mode 100644 src/test/java/net/tfminecraft/magic/integration/KnockoutMoveTest.java diff --git a/pom.xml b/pom.xml index 5d8c9a4..f170c4a 100644 --- a/pom.xml +++ b/pom.xml @@ -88,6 +88,16 @@ + + + com.mojang + authlib + 7.0.61 + test + + ** + + local @@ -168,6 +178,10 @@ enginehub https://maven.enginehub.org/repo/ + + minecraft-libraries + https://libraries.minecraft.net/ + diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java b/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java index b124ca3..2e4a458 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java @@ -29,10 +29,10 @@ 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) { List out = new ArrayList<>(); - for (String ref : ChoiceConfig.armourFor(path)) { - ItemStack item = ItemRef.build(ref); + for (ChoiceConfig.Piece piece : ChoiceConfig.armourFor(path)) { + ItemStack item = ItemRef.build(piece.item()); if (item != null) { - out.add(bind(item, characterId)); + out.add(bind(BloodLooks.apply(item, piece.look()), characterId)); } } return out; @@ -44,6 +44,7 @@ static ItemStack bind(ItemStack item, String characterId) { meta.getPersistentDataContainer().set(BloodItems.kindKey(), PersistentDataType.STRING, KIND); meta.getPersistentDataContainer().set(BloodItems.ownerKey(), PersistentDataType.STRING, characterId); item.setItemMeta(meta); + BloodLooks.protect(item); return item; } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodItems.java b/src/main/java/net/tfminecraft/magic/blood/BloodItems.java index 225e73a..545f1df 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodItems.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodItems.java @@ -51,6 +51,7 @@ public static ItemStack create(BloodItemKind kind, String characterId) { pdc.set(kindKey(), PersistentDataType.STRING, kind.key()); pdc.set(ownerKey(), PersistentDataType.STRING, characterId); item.setItemMeta(meta); + BloodLooks.protect(item); return item; } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index 33871fe..bc06f55 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -60,6 +60,11 @@ public void onInteract(PlayerInteractEvent event) { ChoiceMenu.open(player); return; } + Player carried = CarryService.carried(player); + if (carried != null && BloodItems.is(held, BloodItemKind.BLOOD_KNIFE)) { + layDown(player, carried); + return; + } player.sendMessage(AltarService.touch(player, altar)); return; } @@ -82,8 +87,23 @@ public void onInteractEntity(PlayerInteractEntityEvent event) { ItemStack held = event.getPlayer().getInventory().getItemInMainHand(); if (event.getRightClicked() instanceof Player victim && BloodItems.is(held, BloodItemKind.BLOOD_KNIFE)) { event.setCancelled(true); - CharacterRiteService.Bind result = CharacterRiteService.bind(event.getPlayer(), victim); - event.getPlayer().sendMessage(Messages.get("blood.rite.bind." + result.name().toLowerCase(Locale.ROOT))); + 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) { + mage.sendMessage(Messages.get("blood.rite.bind." + start.name().toLowerCase(Locale.ROOT))); + } + return; + } + layDown(mage, victim); return; } if (!(event.getRightClicked() instanceof LivingEntity animal) || animal instanceof Player @@ -95,6 +115,11 @@ public void onInteractEntity(PlayerInteractEntityEvent event) { event.getPlayer().sendMessage(Messages.get("blood.animal.bind." + result.name().toLowerCase(Locale.ROOT))); } + private static void layDown(Player mage, Player victim) { + CharacterRiteService.Bind result = CharacterRiteService.bind(mage, victim); + mage.sendMessage(Messages.get("blood.rite.bind." + result.name().toLowerCase(Locale.ROOT))); + } + @EventHandler public void onMenuClick(InventoryClickEvent event) { ChoiceMenu.onClick(event); @@ -209,6 +234,7 @@ public void onAnimalDeath(EntityDeathEvent event) { public void onQuit(PlayerQuitEvent event) { WhisperService.forget(event.getPlayer().getUniqueId()); AnimalRiteService.releaseFor(event.getPlayer().getUniqueId()); + CarryService.drop(event.getPlayer().getUniqueId()); } @EventHandler 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 index ead41a0..c06db4f 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -24,6 +24,7 @@ public static void start(Plugin plugin) { BondService.init(store); plugin.getServer().getPluginManager().registerEvents(new BloodListener(), plugin); MagicTickService.register(BloodMagic::tick); + CarryService.startTicking(plugin); plugin.getServer().getScheduler().runTask(plugin, BloodMagic::settle); } @@ -63,6 +64,7 @@ public static boolean loadConfig(File dataFolder, Logger log) { } public static void stop() { + CarryService.clear(); CharacterRiteService.clear(); BondService.clear(); AnimalRiteService.releaseAll(); 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..4ef7f46 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/CarryService.java @@ -0,0 +1,187 @@ +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 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 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 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; + } + 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)); + mage.sendMessage(Messages.get("blood.carry.start")); + 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) { + if (CARRIES.remove(mage.getUniqueId()) != null && told) { + mage.sendMessage(Messages.get("blood.carry.released")); + } + } + + /** 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() { + 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 = point; + 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); + } + + 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 index 187ad55..d5d4019 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java @@ -49,6 +49,7 @@ private record Rite(UUID victim, String mageCharacter, String victimCharacter, S private static final Map RITES = new HashMap<>(); private static final Set SACRIFICING = new HashSet<>(); private static final double LEAVE_SLACK = 4; + private static final double ESCAPE = 2.5; static LongSupplier clock = System::currentTimeMillis; private CharacterRiteService() {} @@ -57,12 +58,9 @@ private CharacterRiteService() {} public static Bind bind(Player mage, Player victim) { String character = AltarService.currentCharacter(mage); BloodRecord record = BloodRecords.get(character); - if (record == null || (record.getStage() != BloodStage.HUNT && record.getStage() != BloodStage.FINAL)) { - 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; + Bind ready = readiness(mage, character, record); + if (ready != null) { + return ready; } BloodAltar altar = BloodAltars.ofCharacter(character); Location top = altar != null ? altar.center() : null; @@ -105,13 +103,26 @@ public static Bind bind(Player mage, Player victim) { if (RITES.containsKey(mage.getUniqueId()) || isVictim(victim.getUniqueId())) { return Bind.BUSY; } - victim.teleport(top.clone().add(0, 0.5, 0)); + CarryService.release(mage, false); + RpCharactersBridge.moveDowned(victim, top.clone().add(0, 0.5, 0)); RITES.put(mage.getUniqueId(), new Rite(victim.getUniqueId(), character, victimCharacter, victimAccount, points, clock.getAsLong() + HuntConfig.riteSeconds() * 1000L)); victim.sendMessage(Messages.get("blood.rite.dread")); return Bind.OK; } + /** 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 (record == null || (record.getStage() != BloodStage.HUNT && record.getStage() != BloodStage.FINAL)) { + 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)) { @@ -138,6 +149,7 @@ static void tick() { Location top = altar != null ? altar.center() : null; if (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))) { it.remove(); diff --git a/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java index 4f3acbd..965c77c 100644 --- a/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java @@ -1,5 +1,6 @@ package net.tfminecraft.magic.blood; +import java.util.ArrayList; import java.util.EnumMap; import java.util.List; import java.util.Locale; @@ -33,7 +34,7 @@ public enum Slot { private static final Map BUTTONS = new EnumMap<>(Slot.class); private static int devoteeInjuries; private static final Map CLASSES = new EnumMap<>(BloodPath.class); - private static final Map> ARMOUR = new EnumMap<>(BloodPath.class); + private static final Map> ARMOUR = new EnumMap<>(BloodPath.class); static { reset(); @@ -52,6 +53,9 @@ static void reset() { ARMOUR.clear(); } + /** One bound armour piece: the item (TLibs path) and an optional look (TLibs ia. path) put over it. */ + public record Piece(String item, String look) {} + static void load(ConfigurationSection choice, ConfigurationSection classes, ConfigurationSection armour) { reset(); if (choice != null) { @@ -75,11 +79,29 @@ static void load(ConfigurationSection choice, ConfigurationSection classes, Conf CLASSES.put(path, classes.getString(key).trim()); } if (armour != null) { - ARMOUR.put(path, List.copyOf(armour.getStringList(key))); + 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: ... }; 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); + } + return null; + } + public static String title() { return title; } @@ -97,8 +119,8 @@ public static String classFor(BloodPath path) { return CLASSES.get(path); } - /** TLibs item paths of the bound armour a path receives. */ - public static List armourFor(BloodPath path) { + /** 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/HuntConfig.java b/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java index a981e3a..e3f1a4c 100644 --- a/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java @@ -20,6 +20,10 @@ public record RankPoints(int upTo, int points) {} 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; @@ -49,6 +53,10 @@ private HuntConfig() {} static void reset() { dragRange = 8; riteSeconds = 10; + carryEnabled = true; + carryDistance = 1.2; + carrySlowness = 1; + carryBreakDistance = 6; minPlaytimeHours = 10; minAgeDays = 7; noRepeatPlayer = true; @@ -73,6 +81,10 @@ static void load(ConfigurationSection hunt, ConfigurationSection bonds, Configur 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); @@ -137,6 +149,25 @@ 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; } diff --git a/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java b/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java index 33dfcb0..e391d59 100644 --- a/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java +++ b/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java @@ -2,7 +2,9 @@ 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; @@ -17,6 +19,8 @@ public final class RpCharactersBridge { + static Supplier knockoutManager = RPCharacters::getPvpKnockoutManager; + private RpCharactersBridge() {} public static boolean isAvailable() { @@ -58,6 +62,24 @@ public static boolean isDown(Player player) { 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); diff --git a/src/main/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index 1b316c2..6ee24ce 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -5,20 +5,21 @@ enabled: true # Items the voices hand to the character. TLibs item paths (v., ia., m.). -# Each item is bound to the character that received it. +# 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: v.chiseled_stone_bricks + path: ia.tfmc_blood:altar_0 name: "#bfbfbfWeathered Altar Stone" lore: - "#8c8c8c§oA plain slab of carved stone." ritual_knife: - path: v.iron_sword + path: ia.tfmc_blood:ritual_knife name: "#bfbfbfSkinning Knife" lore: - "#8c8c8c§oAn ordinary knife, kept very sharp." blood_knife: - path: m.lore_weapons.bloodmagic_dagger + path: ia.tfmc_blood:blood_knife name: "#8a0303Vein-Reaver" lore: - "#5c0202§oIt is warm to the touch." @@ -29,12 +30,12 @@ altar: # 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: v.chiseled_stone_bricks - 1: v.cracked_stone_bricks - 2: v.chiseled_polished_blackstone - 3: v.red_nether_bricks - 4: v.chiseled_nether_bricks - 5: v.crimson_nylium + 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. @@ -52,6 +53,16 @@ hunt: 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 @@ -195,7 +206,7 @@ animal: 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: v.redstone + 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 @@ -258,16 +269,17 @@ classes: devotee: bloodsworn "true": sanguinar -# Bound, unbreakable armour (TLibs item paths). Devotees get theirs at the question; the true path -# when the hunt is done. Placeholder pieces until the real sets exist. +# 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 } armour: devotee: - - m.mage_armors.bloodmagic_mage_helmet - - m.mage_armors.bloodmagic_mage_chestplate - - m.mage_armors.bloodmagic_mage_leggings - - m.mage_armors.bloodmagic_mage_boots + - { 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": - - m.mage_armors.bloodmagic_mage_helmet - - m.mage_armors.bloodmagic_mage_chestplate - - m.mage_armors.bloodmagic_mage_leggings - - m.mage_armors.bloodmagic_mage_boots + - { item: m.mage_armors.sanguinar_helmet, look: ia.tfmc_blood:sanguinar_helmet } + - { item: m.mage_armors.sanguinar_chestplate, look: ia.tfmc_blood:sanguinar_chestplate } + - { item: m.mage_armors.sanguinar_leggings, look: ia.tfmc_blood:sanguinar_leggings } + - { item: m.mage_armors.sanguinar_boots, look: ia.tfmc_blood:sanguinar_boots } diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index c7e028a..3a4bbac 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -530,7 +530,7 @@ blood: not_down: "#8c8c8c§oThey still fight. Bring them down first" - too_far: "#8c8c8c§oThe stone is too far. Bring them closer" + too_far: "#8c8c8c§oThe stone is too far. Crouch and take hold of them, and drag them closer" too_new: "#8c8c8c§oThin blood. The stone would not drink it" @@ -546,12 +546,22 @@ blood: dread: "#8a0303§oStone under your back. A blade above you. Something below is hungry" - broken: "#8c8c8c§oYou step away and the rite comes apart" + broken: "#8c8c8c§oThe rite comes apart" refused: "#8c8c8c§oThe stone will not take them" taken: "#7a7a7a§oIt is done. You feel heavier, and stronger" + carry: + + start: "#8a0303§oYou take hold of them. Heavy. The stone is waiting" + + dragged: "#8c8c8c§oHands on you. You are being dragged somewhere" + + released: "#8c8c8c§oYou let them fall" + + broken: "#8c8c8c§oYour grip is gone" + hunt: complete: "#7a7a7a§oSeven measures of blood. You are something else now, and the stone knows your name" diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java index fd873ac..130a91f 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java @@ -32,11 +32,11 @@ void bundledFileLoadsEverySection() { File bundled = new File("src/main/resources/bloodmagic.yml"); assertTrue(BloodConfig.load(bundled, log)); assertTrue(BloodConfig.isEnabled()); - assertEquals("m.lore_weapons.bloodmagic_dagger", BloodConfig.item(BloodItemKind.BLOOD_KNIFE).path()); + assertEquals("ia.tfmc_blood:blood_knife", BloodConfig.item(BloodItemKind.BLOOD_KNIFE).path()); assertFalse(BloodConfig.item(BloodItemKind.ALTAR).lore().isEmpty()); assertEquals(Material.BARRIER, BloodConfig.altarBlock()); - assertEquals("v.chiseled_stone_bricks", BloodConfig.model(0)); - assertEquals("v.crimson_nylium", BloodConfig.model(9)); + 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)); 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..1630206 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodLooksTest.java @@ -0,0 +1,105 @@ +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.util.List; +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.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 + @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/CarryTest.java b/src/test/java/net/tfminecraft/magic/blood/CarryTest.java new file mode 100644 index 0000000..fc89b1d --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/CarryTest.java @@ -0,0 +1,332 @@ +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/bloodmagic.yml"), 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); + 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.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()); + 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/bloodmagic.yml"), 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 index 121be76..bbb651b 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java @@ -164,6 +164,7 @@ void devoteesGiveThemselvesAndGetClassAndArmour() { 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.applyPermanentInjury(mage), times(2)); diff --git a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java index 1d3d0c8..6a92eba 100644 --- a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java @@ -233,7 +233,7 @@ void bindingTheVictimChecksEveryRule() throws Exception { assertEquals(CharacterRiteService.Bind.OK, CharacterRiteService.bind(mage, victim)); assertEquals("blood.rite.dread", victim.nextMessage()); - assertEquals(11.0, victim.getLocation().getY(), 0.01); + 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"); 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()); + } +} From df12a4004f0fd379a206f2281fca34f63bf53e44 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:47:33 +0100 Subject: [PATCH 08/55] Give every blood path moment its own sounds and particles BloodFx plays configurable moments (fx: in bloodmagic.yml): layered sounds and particles in points, rings, columns and spirals, with delays and private layers for the player the moment is about. Altars breathe by level while someone is near; the rite, the drag, the question, the finals, the voices and the armour all have their own. Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/AltarCorruption.java | 1 + .../tfminecraft/magic/blood/AltarService.java | 13 +- .../magic/blood/AnimalRiteService.java | 11 +- .../tfminecraft/magic/blood/BloodArmour.java | 2 + .../tfminecraft/magic/blood/BloodChoice.java | 2 + .../tfminecraft/magic/blood/BloodConfig.java | 2 + .../net/tfminecraft/magic/blood/BloodFx.java | 268 ++++++++++++++++++ .../magic/blood/BloodListener.java | 1 + .../tfminecraft/magic/blood/BloodMagic.java | 23 ++ .../tfminecraft/magic/blood/CarryService.java | 11 +- .../magic/blood/CharacterRiteService.java | 11 +- .../tfminecraft/magic/blood/ChoiceMenu.java | 1 + .../tfminecraft/magic/blood/FinalService.java | 5 + .../magic/blood/WhisperService.java | 2 + src/main/resources/bloodmagic.yml | 206 ++++++++++++++ .../tfminecraft/magic/blood/BloodFxTest.java | 249 ++++++++++++++++ .../tfminecraft/magic/blood/CarryTest.java | 8 + 17 files changed, 801 insertions(+), 15 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodFx.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/BloodFxTest.java diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java index d56ac41..cf14241 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java @@ -105,6 +105,7 @@ private static void change(BloodAltar altar, Block block, Material to) { block.setBlockData(lightable, 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)); } /** Puts back every journalled block that still is what the altar made it, newest first. */ diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarService.java b/src/main/java/net/tfminecraft/magic/blood/AltarService.java index a104729..d4f40d2 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -4,7 +4,6 @@ import java.util.Optional; import java.util.UUID; -import org.bukkit.Sound; import org.bukkit.block.Block; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -58,7 +57,7 @@ public static String place(Player player, ItemStack hand, Block target) { record.setAltarId(altar.getId()); BloodRecords.save(record); hand.setAmount(hand.getAmount() - 1); - target.getWorld().playSound(target.getLocation(), Sound.BLOCK_STONE_PLACE, 1f, 0.6f); + 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"; @@ -68,6 +67,9 @@ public static String place(Player player, ItemStack hand, Block target) { 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) { @@ -85,6 +87,7 @@ public static void refreshFor(String characterId) { /** 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); BloodAltars.remove(altar); @@ -143,8 +146,8 @@ static boolean isDead(String characterId) { /** The player-facing line for an altar a player touched. */ public static String touch(Player player, BloodAltar altar) { String character = currentCharacter(player); - return character != null && character.equalsIgnoreCase(altar.getOwnerCharacter()) - ? Messages.get("blood.altar.owner_touch") - : Messages.get("blood.altar.stranger_touch"); + boolean owner = character != null && character.equalsIgnoreCase(altar.getOwnerCharacter()); + BloodFx.play(owner ? "altar_touch_owner" : "altar_touch_stranger", altar.center(), player); + return owner ? Messages.get("blood.altar.owner_touch") : Messages.get("blood.altar.stranger_touch"); } } diff --git a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java index 8531e4e..f9392a7 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java @@ -10,7 +10,6 @@ import org.bukkit.Bukkit; import org.bukkit.Location; -import org.bukkit.Particle; import org.bukkit.entity.Entity; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; @@ -89,6 +88,7 @@ public static Bind bind(Player player, LivingEntity animal) { } animal.setAI(false); animal.teleport(top.clone().add(0, 0.5, 0)); + BloodFx.play("animal_bound", top, player); BOUND.put(animal.getUniqueId(), new Bound(player.getUniqueId(), character, altar.getId(), now + AnimalConfig.bindSeconds() * 1000L, owners, stolen)); return Bind.OK; @@ -157,8 +157,10 @@ private static void complete(Player mage, Bound bound, LivingEntity animal) { AltarCorruption.spread(altar, CorruptionConfig.blocksPerSacrifice()); BloodAltars.save(); Location center = altar.center(); - center.getWorld().spawnParticle(Particle.DAMAGE_INDICATOR, center.clone().add(0, 0.6, 0), 14, 0.3, 0.2, 0.3, 0.05); - center.getWorld().playSound(center, "minecraft:entity.warden.heartbeat", 1f, 0.6f); + BloodFx.play("animal_sacrificed", center, mage); + if (done) { + BloodFx.play("blood_knife_given", center, mage); + } if (bound.stolen()) { StolenClues.leave(animal, bound.owners(), center); } @@ -184,9 +186,10 @@ static void tick() { if (mage != null) { mage.sendMessage(Messages.get("blood.animal.escaped")); } + BloodFx.play("animal_escaped", animal.getLocation()); continue; } - animal.getWorld().spawnParticle(Particle.DAMAGE_INDICATOR, animal.getLocation().clone().add(0, 0.5, 0), 2, 0.2, 0.2, 0.2, 0); + BloodFx.play("animal_binding", animal.getLocation()); } } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java b/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java index 2e4a458..3724231 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java @@ -61,6 +61,7 @@ public static void give(Player player, BloodPath path, String characterId) { for (ItemStack piece : create(path, characterId)) { WhisperService.give(player, piece); } + BloodFx.play("armour_given", player.getLocation(), player); } /** @@ -80,6 +81,7 @@ public static int enforce(Player player) { 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); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java index 5205e80..3c87263 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java @@ -40,6 +40,7 @@ public static boolean answer(Player player, BloodPath path) { ClassUnlockBridge.unlock(player, ChoiceConfig.classFor(BloodPath.DEVOTEE)); BloodArmour.give(player, BloodPath.DEVOTEE, character); player.sendMessage(Messages.get("blood.choice.devoted")); + BloodFx.play("devoted", player.getLocation(), player); FinalService.begin(player, record); Magic.plugin.getLogger().info("[Magic] Character " + character + " gave themself to the god (" + marks + " permanent injuries)"); @@ -47,6 +48,7 @@ public static boolean answer(Player player, BloodPath path) { record.setStage(BloodStage.HUNT); BloodRecords.save(record); player.sendMessage(Messages.get("blood.choice.refused")); + BloodFx.play("refused", player.getLocation(), player); Magic.plugin.getLogger().info("[Magic] Character " + character + " refused the god"); } WhisperService.voiceSoon(player); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java index 3cb5c17..c80d0fc 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java @@ -107,6 +107,7 @@ public static boolean load(File file, Logger log) { config.getConfigurationSection("armour")); HuntConfig.load(config.getConfigurationSection("hunt"), config.getConfigurationSection("bonds"), config.getConfigurationSection("finals"), log); + BloodFx.load(config.getConfigurationSection("fx"), log); return ok; } @@ -165,6 +166,7 @@ public static void reset() { CorruptionConfig.reset(); ChoiceConfig.reset(); HuntConfig.reset(); + BloodFx.reset(); } public static boolean isEnabled() { 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..b734aad --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodFx.java @@ -0,0 +1,268 @@ +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 bloodmagic.yml). 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; + + static { + reset(); + } + + private BloodFx() {} + + static void reset() { + enabled = true; + MOMENTS.clear(); + } + + static void load(ConfigurationSection fx, Logger log) { + reset(); + if (fx == null) { + return; + } + enabled = fx.getBoolean("enabled", true); + 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] bloodmagic.yml 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 { + at.getWorld().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/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index bc06f55..1772494 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -144,6 +144,7 @@ public void onArmourChange(PlayerArmorChangeEvent event) { public void onPickup(EntityPickupItemEvent event) { if (BloodArmour.crumbles(event.getItem().getItemStack())) { event.setCancelled(true); + BloodFx.play("armour_crumbles", event.getItem().getLocation()); event.getItem().remove(); } } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index c06db4f..82ddf1a 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -4,6 +4,7 @@ import java.util.logging.Logger; import org.bukkit.Bukkit; +import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; @@ -13,6 +14,9 @@ /** 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. */ @@ -40,6 +44,9 @@ static void tick(MagicTickContext context) { if (context.everyMinutes(BloodConfig.purifyCheckMinutes())) { AltarService.sweepDead(); } + if (context.every(AMBIENT_SECONDS)) { + ambient(); + } WhisperService.tick(context); AnimalRiteService.tick(); CharacterRiteService.tick(); @@ -59,6 +66,22 @@ static void tick(MagicTickContext context) { } } + /** 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; + } + } + } + } + public static boolean loadConfig(File dataFolder, Logger log) { return BloodConfig.load(new File(dataFolder, "bloodmagic.yml"), log); } diff --git a/src/main/java/net/tfminecraft/magic/blood/CarryService.java b/src/main/java/net/tfminecraft/magic/blood/CarryService.java index 4ef7f46..115b66a 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CarryService.java +++ b/src/main/java/net/tfminecraft/magic/blood/CarryService.java @@ -31,6 +31,7 @@ private static final class Carry { 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; @@ -42,6 +43,7 @@ private Carry(UUID victim, String mageCharacter, String 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 BukkitTask task; private CarryService() {} @@ -75,6 +77,7 @@ public static CharacterRiteService.Bind start(Player mage, Player victim) { } CARRIES.put(mage.getUniqueId(), new Carry(victim.getUniqueId(), character, victimCharacter)); mage.sendMessage(Messages.get("blood.carry.start")); + BloodFx.play("carry_start", victim.getLocation(), victim); victim.sendMessage(Messages.get("blood.carry.dragged")); return CharacterRiteService.Bind.OK; } @@ -97,8 +100,11 @@ static UUID carrier(UUID victim) { /** Lets go of the body; told tells the mage. */ public static void release(Player mage, boolean told) { - if (CARRIES.remove(mage.getUniqueId()) != null && 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()); } } @@ -175,6 +181,9 @@ private static void follow(Player mage, Player victim, Carry carry) { to.setYaw(body.getYaw()); to.setPitch(body.getPitch()); RpCharactersBridge.moveDowned(victim, to); + if (++carry.steps % DRAG_FX_EVERY == 0) { + BloodFx.play("carry_drag", to); + } } public static void clear() { diff --git a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java index d5d4019..9b6fb1e 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java @@ -11,7 +11,6 @@ import org.bukkit.Bukkit; import org.bukkit.Location; -import org.bukkit.Particle; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -108,6 +107,7 @@ public static Bind bind(Player mage, Player victim) { RITES.put(mage.getUniqueId(), new Rite(victim.getUniqueId(), character, victimCharacter, victimAccount, points, clock.getAsLong() + HuntConfig.riteSeconds() * 1000L)); victim.sendMessage(Messages.get("blood.rite.dread")); + BloodFx.play("rite_bound", top, victim); return Bind.OK; } @@ -156,11 +156,13 @@ static void tick() { if (mage != null) { mage.sendMessage(Messages.get("blood.rite.broken")); } + if (top != null) { + BloodFx.play("rite_broken", top); + } continue; } if (now < rite.endsAt()) { - top.getWorld().spawnParticle(Particle.DAMAGE_INDICATOR, top.clone().add(0, 1, 0), 6, 0.3, 0.4, 0.3, 0); - victim.playSound(victim.getLocation(), "minecraft:entity.warden.heartbeat", 1f, 1f); + BloodFx.play("rite_beat", top, victim); continue; } it.remove(); @@ -189,8 +191,7 @@ private static void complete(Player mage, Player victim, Rite rite, BloodAltar a record.getSacrifices().add(new BloodRecord.Sacrifice(key, rite.victimAccount(), stage.name(), rite.points(), clock.getAsLong())); record.setLastVictimAccount(rite.victimAccount()); - Location top = altar.center(); - top.getWorld().playSound(top, "minecraft:entity.warden.sonic_boom", 0.6f, 0.6f); + 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) { diff --git a/src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java b/src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java index 80b003c..cc6a2aa 100644 --- a/src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java +++ b/src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java @@ -44,6 +44,7 @@ public Inventory getInventory() { /** Opens the question for the player. */ public static void open(Player player) { + BloodFx.play("question", player.getLocation(), player); show(player, BloodPath.NONE); } diff --git a/src/main/java/net/tfminecraft/magic/blood/FinalService.java b/src/main/java/net/tfminecraft/magic/blood/FinalService.java index 57cecf7..7939e29 100644 --- a/src/main/java/net/tfminecraft/magic/blood/FinalService.java +++ b/src/main/java/net/tfminecraft/magic/blood/FinalService.java @@ -38,6 +38,7 @@ public static void begin(Player mage, BloodRecord record) { ClassUnlockBridge.unlock(mage, ChoiceConfig.classFor(BloodPath.TRUE)); BloodArmour.give(mage, BloodPath.TRUE, record.getCharacterId()); mage.sendMessage(Messages.get("blood.hunt.complete")); + BloodFx.play("hunt_complete", mage.getLocation(), mage); } refill(mage, record); BloodRecords.save(record); @@ -90,6 +91,7 @@ static void name(Player mage, BloodRecord record) { return; } WhisperService.speak(mage, speaker, Messages.getRaw("blood.final.named").replace("{target}", nameOf(target))); + BloodFx.play("target_named", mage.getLocation(), mage); } static String nameOf(String character) { @@ -110,9 +112,11 @@ static void completed(Player mage, BloodRecord record) { record.setStage(BloodStage.ASCENDED); record.getFinalTargets().clear(); mage.sendMessage(Messages.get("blood.final.ascended")); + BloodFx.play("ascended", mage.getLocation(), mage); } else { refill(mage, record); mage.sendMessage(Messages.get("blood.final.taken")); + BloodFx.play("final_taken", mage.getLocation(), mage); } BloodRecords.save(record); BloodBoons.apply(mage); @@ -156,6 +160,7 @@ static void backlash(Player mage, BloodRecord record) { } record.setPendingBacklash(false); RpCharactersBridge.applyHealingInjury(mage); + BloodFx.play("backlash", mage.getLocation(), mage); WhisperService.speak(mage, WhisperService.speaker(record), Messages.getRaw("blood.final.failed")); BloodRecords.save(record); } diff --git a/src/main/java/net/tfminecraft/magic/blood/WhisperService.java b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java index cb38228..e48a2fd 100644 --- a/src/main/java/net/tfminecraft/magic/blood/WhisperService.java +++ b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java @@ -201,6 +201,7 @@ public static void speak(Player player, WhisperConfig.Speaker speaker, String li 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. */ @@ -210,6 +211,7 @@ static void gift(Player player, BloodRecord record) { 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"); } diff --git a/src/main/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index 6ee24ce..1c797a7 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -283,3 +283,209 @@ armour: - { item: m.mage_armors.sanguinar_chestplate, look: ia.tfmc_blood:sanguinar_chestplate } - { item: m.mage_armors.sanguinar_leggings, look: ia.tfmc_blood:sanguinar_leggings } - { item: m.mage_armors.sanguinar_boots, look: ia.tfmc_blood:sanguinar_boots } + +# 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 + 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: 1.0, pitch: 0.6 } + - { sound: entity.warden.heartbeat, volume: 0.8, 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.5, pitch: 1.3 } + animal_escaped: + - { sound: block.chain.break, volume: 0.8, pitch: 0.8 } + - { particle: POOF, count: 8, spread: 0.3, y: 0.5 } + animal_sacrificed: + - { sound: entity.player.attack.crit, volume: 1.0, pitch: 0.5 } + - { sound: block.honey_block.slide, volume: 1.0, pitch: 0.5 } + - { sound: block.sculk_catalyst.bloom, volume: 0.8, pitch: 0.5, delay: 4 } + - { sound: entity.warden.heartbeat, volume: 1.0, 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.7, pitch: 0.7, delay: 20 } + - { sound: block.end_portal.spawn, volume: 0.4, 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: 1.0, pitch: 0.5, delay: 50, private: true } + + # --- 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 } + 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 } + 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/test/java/net/tfminecraft/magic/blood/BloodFxTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodFxTest.java new file mode 100644 index 0000000..a69d126 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodFxTest.java @@ -0,0 +1,249 @@ +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/bloodmagic.yml")); + var moments = yaml.getConfigurationSection("fx.moments"); + BloodConfig.load(new File("src/main/resources/bloodmagic.yml"), 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); + BloodFx.play("test", at, viewer); + verify(world).playSound(any(Location.class), eq("minecraft:block.stone.place"), 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(world, never()).playSound(any(Location.class), eq("minecraft:block.chain.place"), anyFloat(), anyFloat()); + fx.server.getScheduler().performTicks(6); + verify(world).playSound(any(Location.class), eq("minecraft:block.chain.place"), anyFloat(), anyFloat()); + + World quiet = mock(World.class); + BloodFx.play("test", new Location(quiet, 0, 0, 0)); + verify(quiet).playSound(any(Location.class), eq("minecraft:block.stone.place"), anyFloat(), anyFloat()); + verify(quiet, never()).playSound(any(Location.class), eq("minecraft:block.bell.use"), anyFloat(), anyFloat()); + + World broken = mock(World.class); + doThrow(new IllegalStateException("no sound")).when(broken).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("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/CarryTest.java b/src/test/java/net/tfminecraft/magic/blood/CarryTest.java index fc89b1d..0005fde 100644 --- a/src/test/java/net/tfminecraft/magic/blood/CarryTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/CarryTest.java @@ -216,6 +216,14 @@ void leavingLetsGoAndTheTimerRuns() { 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()); From 9bcbb3102708a09ef41d45777fdfa069d31da7cd Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:40:32 +0100 Subject: [PATCH 09/55] Write the blood path's voices and lore as finished roleplay The god speaks as a hungry choir ("we"), the dark self as the mage's own cold thoughts ("I"); final target, nobody and failed lines now have one version per voice. Rewrites the teaching lines, stage whispers, ascended lines, every altar, rite and drag message, the question menu and the item lore; unnamed stolen animals read "your cow". Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/FinalService.java | 11 +- .../tfminecraft/magic/blood/StolenClues.java | 2 +- src/main/resources/bloodmagic.yml | 99 +++++++++++------ src/main/resources/messages.yml | 104 +++++++++--------- .../magic/blood/AltarServiceTest.java | 2 +- .../tfminecraft/magic/blood/ChoiceTest.java | 2 +- .../magic/blood/WhisperConfigTest.java | 2 +- .../magic/blood/WhisperServiceTest.java | 3 + 8 files changed, 138 insertions(+), 87 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/FinalService.java b/src/main/java/net/tfminecraft/magic/blood/FinalService.java index 7939e29..84b9d36 100644 --- a/src/main/java/net/tfminecraft/magic/blood/FinalService.java +++ b/src/main/java/net/tfminecraft/magic/blood/FinalService.java @@ -87,13 +87,18 @@ 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("blood.final.nobody")); + WhisperService.speak(mage, speaker, Messages.getRaw(voiced("blood.final.nobody", speaker))); return; } - WhisperService.speak(mage, speaker, Messages.getRaw("blood.final.named").replace("{target}", nameOf(target))); + 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("?"); @@ -161,7 +166,7 @@ static void backlash(Player mage, BloodRecord record) { record.setPendingBacklash(false); RpCharactersBridge.applyHealingInjury(mage); BloodFx.play("backlash", mage.getLocation(), mage); - WhisperService.speak(mage, WhisperService.speaker(record), Messages.getRaw("blood.final.failed")); + WhisperService.speak(mage, WhisperService.speaker(record), Messages.getRaw(voiced("blood.final.failed", WhisperService.speaker(record)))); BloodRecords.save(record); } diff --git a/src/main/java/net/tfminecraft/magic/blood/StolenClues.java b/src/main/java/net/tfminecraft/magic/blood/StolenClues.java index 205be47..a150f29 100644 --- a/src/main/java/net/tfminecraft/magic/blood/StolenClues.java +++ b/src/main/java/net/tfminecraft/magic/blood/StolenClues.java @@ -58,7 +58,7 @@ public static void leave(LivingEntity animal, List owners long now = clock.getAsLong(); Origin origin = LEASHED.remove(animal.getUniqueId()); String name = animal.getCustomName() != null ? animal.getCustomName() - : animal.getType().name().toLowerCase(Locale.ROOT).replace('_', ' '); + : "your " + animal.getType().name().toLowerCase(Locale.ROOT).replace('_', ' '); String text; if (origin != null && origin.location().getWorld() == altar.getWorld()) { trace(origin.location(), now); diff --git a/src/main/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index 1c797a7..00cb61d 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -1,6 +1,6 @@ # Blood Magic progression. Hidden content: never document it on the wiki. # Jar does not overwrite an existing live copy. Copy new keys by hand after updates. -# Names here are placeholders until the lore is settled. +# Names (the god, the blade, the classes) are placeholders until the lore is settled. enabled: true @@ -12,17 +12,22 @@ items: path: ia.tfmc_blood:altar_0 name: "#bfbfbfWeathered Altar Stone" lore: - - "#8c8c8c§oA plain slab of carved stone." + - "#8c8c8c§oA slab of old carved stone, worn smooth" + - "#8c8c8c§oby hands that are long gone." + - "#5c5c5c§oIt is heavier than it should be." ritual_knife: path: ia.tfmc_blood:ritual_knife name: "#bfbfbfSkinning Knife" lore: - - "#8c8c8c§oAn ordinary knife, kept very sharp." + - "#8c8c8c§oA plain knife with a well-worn grip." + - "#8c8c8c§oKept very, very sharp." blood_knife: path: ia.tfmc_blood:blood_knife name: "#8a0303Vein-Reaver" lore: - - "#5c0202§oIt is warm to the touch." + - "#5c0202§oCurved like a cupped hand." + - "#5c0202§oIt is warm to the touch," + - "#5c0202§oand if you hold it still, it beats." altar: # The altar is a barrier block (unbreakable, piston and blast proof) with a display on it. @@ -145,7 +150,8 @@ whispers: 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 speaks until a mage refuses it; then only the mage's own dark self. +# The two voices. The god is a choir: everything that ever bled on the stone, speaking as "we", red and +# hungry. A mage who refuses it hears only their own dark self: "I", grey and cold, their own thoughts. # whispered lines are said in order and end with the knife and altar; other stages pick at random. voices: # Minutes between lines, picked at random in this range. @@ -159,36 +165,59 @@ voices: pitch: 0.5 lines: whispered: - - "...can you hear it? Beneath the floor. Beneath the stone." - - "So much warm blood walks above us. All of it wasted." - - "Listen, little one. We will teach you, if you are hungry enough." - - "Lay a stone deep in the earth, where no sky can see it." - - "Bring beasts to the stone while they still breathe. Hold them, and open them with a plain blade." - - "Not your own beasts. Take what others keep. Seven kinds, one each day." - - "Look in your pack. We have been generous." + - "...there. You felt it, didn't you? Under the floor. Under the stone. Something listening back." + - "Hush. Do not be afraid, little one. We have waited so long for someone who listens." + - "So much warm blood walks the roads above us. Spilled in ditches, in brawls, in childbeds. Wasted." + - "We could teach you to waste none of it. We could teach you to grow." + - "Go down, deep, where the sky cannot follow. No lord's banner, no warden's eye. Lay our stone there." + - "Bring living beasts down to the stone. Hold them still with the plain knife, and open them while they breathe." + - "Not beasts of your own. A gift costs nothing. Lead them from another's pen while the keeper sleeps." + - "Seven kinds of beast, one each day and no sooner. We like to savour." + - "Look in your pack, little one. We have been generous. Do not make us regret it." animal: - - "The stone is thirsty." - - "Another kind. We have tasted that one." - - "Their pens are full and their owners sleep." - - "Lead it down to the stone. Hold it still." + - "The stone is thirsty. Can you not hear it?" + - "Every pen in the valley is full, and every shepherd sleeps." + - "Lead it down gently. Fear sours the blood." + - "Look into its eyes when you do it. We want to see what you see." + - "We remember the taste of the last one. Bring us a kind we have not had." + - "Wash your hands before you go up. They can smell it on you." + - "Count with us. How many kinds now? Not enough. Never enough." choice: - - "Come to the stone. We have something to ask of you." + - "Seven kinds have bled. Come down to the stone, and bring the new blade. We have a question for you." + - "The stone is red to its heart. Kneel at it with the curved blade in your hand." + - "Do not keep us waiting. Nothing has ever made us wait." final: - - "You are ours now. Feed us." - - "We remember every drop you give." - - "We have chosen for you. Go to the stone." + - "You are ours, little one, and so are they. We will tell you who." + - "We 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." + - "Every drop you gave us, we remember. Every drop you give, we repay." + ascended: + - "All of them, given. Sleep now, little one. We will wake you when we are hungry." + - "You walk among them and none of them know. We love that about you." + - "We are always beneath you. Always listening. Always fed." self: style: "#7a7a7a§o" sound: minecraft:entity.warden.heartbeat pitch: 1.2 lines: hunt: - - "We need no god. Only more." - - "Who would miss them? Better: who would we miss?" - - "The stone is quiet now. It is only us." + - "I don't need a god to tell me what I want." + - "The stone went quiet. Good. Now 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." final: - - "It has to be them. You know it does." - - "The closer they are, the more it gives." + - "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." + ascended: + - "There is no one left I would miss. So this is what freedom feels like." + - "Quiet. So quiet. And I did it all myself." + - "I could start again. Learn new names. Let new people close. Just in case." # Animal sacrifices: bind a living animal on the altar with the ritual knife, then kill it with that knife. animal: @@ -240,27 +269,35 @@ corruption: # The god's question: right-click your altar with your blood knife once the seventh kind is given. choice: - title: "#5c0202Will you give yourself?" + title: "#5c0202What will you give?" question: - - "#8a0303§oSeven kinds have bled for us." - - "#8a0303§oGive us yourself, and we will carry you." - - "#8a0303§oOr keep yourself, and carry it all alone." + - "#8a0303§oSeven kinds have bled for us, and you did not flinch." + - "#8a0303§oGive yourself to us, and we will carry you all the way." + - "#8a0303§oOr keep yourself, and carry the weight alone. Forever." devotee: item: v.red_dye name: "#8a0303I give myself" lore: - - "#8c8c8c§oPart of you stays on the stone." + - "#8c8c8c§oPart of you stays on the stone," + - "#8c8c8c§oa wound that will never close." + - "#8c8c8c§oThe god walks with you, and chooses for you." "true": item: v.iron_sword name: "#bfbfbfI take for myself" lore: - - "#8c8c8c§oNo god. Only you, and what you take." + - "#8c8c8c§oNo god. No voices. No one to blame." + - "#8c8c8c§oOnly you, and what you take" + - "#8c8c8c§ofrom the people who love you." confirm: item: v.redstone name: "#ff5555There is no going back" + lore: + - "#8c8c8c§oThe stone will remember what you chose." back: item: v.barrier name: "#aaaaaaNot yet" + lore: + - "#8c8c8c§oIt will wait. It is very good at waiting." # Permanent injuries a devotee keeps as the mark of what they gave. devotee_injuries: 2 diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 3a4bbac..b55a83a 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -450,79 +450,79 @@ blood: altar: - placed: "#8c8c8c§oThe stone settles. The air around it grows still and close" + placed: "#8c8c8c§oThe stone settles into the dark as if it had always been there. Somewhere below, something sighs" - not_yours: "#8c8c8c§oThe stone will not settle for you" + 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" + inert: "#8c8c8c§oIt is only a stone. Nothing listens to it yet" - already: "#8c8c8c§oYou already keep an altar" + already: "#8c8c8c§oYou already keep a stone. It would not share you" - unbreakable: "#8c8c8c§oThe stone does not yield" + unbreakable: "#8c8c8c§oYour tools ring off the stone without leaving a scratch" - owner_touch: "#8a0303§oThe stone is warm, like skin" + owner_touch: "#8a0303§oThe stone is warm under your palm, like skin. You feel it breathe" - stranger_touch: "#8c8c8c§oA cold, carved stone. Something about it makes you uneasy" + 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§oThis is no place for it" + world: "#8c8c8c§oNot here. The ground itself refuses it" occupied: "#8c8c8c§oThere is no room for the stone here" - no_floor: "#8c8c8c§oThe stone needs solid ground beneath it" + no_floor: "#8c8c8c§oIt needs firm rock beneath it" - sky: "#8c8c8c§oIt must not see the sky" + sky: "#8c8c8c§oNot under the open sky. It must never see the sun" - shallow: "#8c8c8c§oNot deep enough. It must lie far beneath the earth" + shallow: "#8c8c8c§oNot deep enough. Take it further down, under more rock" - region: "#8c8c8c§oThis ground is watched. Find somewhere forgotten" + region: "#8c8c8c§oThis ground is claimed and watched. Find somewhere forgotten" animal: bind: - ok: "#8a0303§oIt goes still on the stone. Its heart hammers. Now" + ok: "#8a0303§oThe beast goes rigid on the stone. Its heart hammers against the blade. Now" - not_stage: "#8c8c8c§oThe stone wants nothing from it" + not_stage: "#8c8c8c§oThe stone wants nothing from it. Not yet" - not_knife: "#8c8c8c§oIt is only a knife in your hand" + 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 want that" + not_species: "#8c8c8c§oThe stone does not hunger for that" - known: "#8a0303§oWe have tasted that kind. Bring another" + known: "#8a0303§oWe have tasted that kind already. Bring us another" - cooldown: "#8a0303§oNot yet. The stone is still drinking" + cooldown: "#8a0303§oNot yet. The stone is still drinking the last one" - not_stolen: "#8a0303§oNot that one. Take what others keep" + not_stolen: "#8a0303§oNot that one. A gift costs nothing. Take what others keep" - too_far: "#8a0303§oBring it closer to the stone" + too_far: "#8a0303§oToo far. Lead it down to the stone" - busy: "#8a0303§oOne at a time" + busy: "#8a0303§oOne at a time. Finish what lies on the stone" - spoiled: "#8c8c8c§oThe blood spills wasted. The stone did not drink" + spoiled: "#8c8c8c§oThe blood runs into the dirt, wasted. The stone did not drink" - escaped: "#8c8c8c§oThe beast tears free of the stone" + escaped: "#8c8c8c§oThe beast thrashes free of the stone and bolts into the dark" - accepted: "#8a0303§oThe stone drinks. It is warmer than before" + accepted: "#8a0303§oThe stone drinks. When you take your hand away, it is warmer than before" - complete: "#8a0303§oSeven kinds. The stone is red to its heart. Something new rests in your hand" + complete: "#8a0303§oSeven kinds. The stone is red to its heart, and something cold and curved rests in your hand that was not there before" - note: "#aaaaaa§oSomething took {animal} while no one was watching. Drag marks lead {direction}" + 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 took {animal} while no one was watching" + note_lost: "#aaaaaa§oSomething has taken {animal} from the pen. The rope was cut clean, and no one saw a thing" rite: bind: - ok: "#8a0303§oYou drag them onto the stone. It is already warm" + ok: "#8a0303§oYou 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" + 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" @@ -530,63 +530,69 @@ blood: not_down: "#8c8c8c§oThey still fight. Bring them down first" - too_far: "#8c8c8c§oThe stone is too far. Crouch and take hold of them, and drag them closer" + too_far: "#8c8c8c§oThe stone is too far. Crouch, take hold of them, and drag them closer" - too_new: "#8c8c8c§oThin blood. The stone would not drink it" + too_new: "#8c8c8c§oThin, new blood. The stone would spit it out" - repeat: "#8c8c8c§oThat blood is still on the stone. Find another" + repeat: "#8c8c8c§oThat blood is still drying on the stone. Find someone else" reserved: "#7a7a7a§oNot them. Not yet. They are for later" - waiting: "#8c8c8c§oThe stone is sullen. It will not take anyone yet" + 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 who it must be" + not_target: "#8c8c8c§oNot this one. You know whose name it whispered" busy: "#8c8c8c§oOne at a time" - dread: "#8a0303§oStone under your back. A blade above you. Something below is hungry" + dread: "#8a0303§oCold stone under your back. A blade above you. And below, something vast and patient opens its mouth" - broken: "#8c8c8c§oThe rite comes apart" + broken: "#8c8c8c§oThe rite comes apart. The stone goes cold and quiet, sulking" refused: "#8c8c8c§oThe stone will not take them" - taken: "#7a7a7a§oIt is done. You feel heavier, and stronger" + taken: "#7a7a7a§oIt is done. You feel heavier, and stronger, and very far from who you were" carry: - start: "#8a0303§oYou take hold of them. Heavy. The stone is waiting" + start: "#8a0303§oYou take hold of them. Heavy, limp and warm. The stone is waiting" - dragged: "#8c8c8c§oHands on you. You are being dragged somewhere" + 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 is gone" + broken: "#8c8c8c§oYour grip slips. They are out of your hands" hunt: - complete: "#7a7a7a§oSeven measures of blood. You are something else now, and the stone knows your name" + complete: "#7a7a7a§oSeven measures of blood. Something in you closes like a fist. The stone knows your name now, and no god gave it" final: - named: "{target}. It has to be {target}" + named_god: "We have chosen. {target}. Bring us {target}" - nobody: "There is no one close enough yet" + named_self: "{target}. It has to be {target}. I think I always knew" - taken: "#7a7a7a§oA hollow opens in you where they were, and something cold fills it" + nobody_god: "No one is close enough to you yet. Love someone, little one. Then bring them to us" - ascended: "#5c0202§oThe last of them is gone. Nothing holds you now" + nobody_self: "There is no one close enough yet. I will have to let someone in" - failed: "Someone else spilled what was yours. You will pay for that" + taken: "#7a7a7a§oA hollow opens where they used to be, and something cold flows in to fill it" + + ascended: "#5c0202§oThe last of them is gone. Nothing holds you now. Nothing ever will again" + + failed_god: "Someone else spilled what was ours, and you let them. You will bleed for it" + + failed_self: "Someone else got to them first. Everything I put into them, wasted" choice: - devoted: "#8a0303§oSomething of you stays on the stone, and something of the stone goes with you" + devoted: "#8a0303§oSomething of you stays on the stone, and something of the stone comes with you. It hurts. It will always hurt" - refused: "#7a7a7a§oThe voice falls silent. For the first time in days you are alone with your thoughts. They are not kind" + refused: "#7a7a7a§oThe voices fall silent. For the first time in weeks you are alone with your own thoughts. They are not kind" armour: - refuses: "#8c8c8c§oIt will not sit on you" + 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" diff --git a/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java index 2670a90..5eac6b9 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java @@ -64,7 +64,7 @@ void itemsCarryKindAndOwner() { BloodConfig.load(new java.io.File("src/main/resources/bloodmagic.yml"), fx.logger); ItemStack named = BloodItems.create(BloodItemKind.ALTAR, "c1"); assertTrue(named.getItemMeta().hasDisplayName()); - assertEquals(1, named.getItemMeta().getLore().size()); + assertEquals(3, named.getItemMeta().getLore().size()); } } diff --git a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java index bbb651b..acbfac7 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java @@ -88,7 +88,7 @@ InventoryClickEvent click(int slot) { @Test void bundledQuestionClassesAndArmourLoad() { - assertEquals("#5c0202Will you give yourself?", ChoiceConfig.title()); + assertEquals("#5c0202What will you give?", ChoiceConfig.title()); assertEquals(3, ChoiceConfig.button(ChoiceConfig.Slot.QUESTION).lore().size()); assertEquals("v.iron_sword", ChoiceConfig.button(ChoiceConfig.Slot.TRUE).item()); assertEquals(2, ChoiceConfig.devoteeInjuries()); diff --git a/src/test/java/net/tfminecraft/magic/blood/WhisperConfigTest.java b/src/test/java/net/tfminecraft/magic/blood/WhisperConfigTest.java index ad5b2cf..ae40a38 100644 --- a/src/test/java/net/tfminecraft/magic/blood/WhisperConfigTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/WhisperConfigTest.java @@ -43,7 +43,7 @@ void bundledVoicesAndSourcesLoad() { assertTrue(WhisperConfig.requireAlone()); assertEquals(24, WhisperConfig.aloneRadius()); var god = WhisperConfig.voice(WhisperConfig.Speaker.GOD); - assertEquals(7, god.lines(BloodStage.WHISPERED).size()); + assertEquals(9, god.lines(BloodStage.WHISPERED).size()); assertTrue(god.lines(BloodStage.HUNT).isEmpty()); assertEquals("minecraft:ambient.cave", god.sound()); assertEquals(0.5f, god.pitch()); diff --git a/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java b/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java index f6a0563..74cde4c 100644 --- a/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java @@ -162,6 +162,9 @@ void laterStagesPickALineAndTheTruePathHearsOnlyItself() throws Exception { assertNotNull(player.nextMessage()); record.setStage(BloodStage.ASCENDED); WhisperService.speakNext(player, record); + assertNotNull(player.nextMessage()); + record.setStage(BloodStage.CHOICE); + WhisperService.speakNext(player, record); assertNull(player.nextMessage()); Files.writeString(temp.resolve("quiet.yml"), From 262a7c013717339fcaf8e1ea5f7dcd1e206e136c Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:47:32 +0100 Subject: [PATCH 10/55] Make Lorvear, the Blood Father, the voice of the blood path The god is now Lorvear, the first vampire: paternal and regal, he calls the mage child and speaks of Alaric, the Reaper and the Lady of the Tower, as in the Codex. The question menu asks the mage to kneel to him or to no one; the dark self answers to no father. Co-Authored-By: Claude Opus 5.5 --- src/main/resources/bloodmagic.yml | 80 ++++++++++--------- src/main/resources/messages.yml | 20 ++--- .../tfminecraft/magic/blood/ChoiceTest.java | 2 +- 3 files changed, 52 insertions(+), 50 deletions(-) diff --git a/src/main/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index 00cb61d..2ca288c 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -1,6 +1,6 @@ # Blood Magic progression. Hidden content: never document it on the wiki. # Jar does not overwrite an existing live copy. Copy new keys by hand after updates. -# Names (the god, the blade, the classes) are placeholders until the lore is settled. +# The god is Lorvear, the first vampire, the Blood Father. The blade and class names are placeholders. enabled: true @@ -25,7 +25,7 @@ items: path: ia.tfmc_blood:blood_knife name: "#8a0303Vein-Reaver" lore: - - "#5c0202§oCurved like a cupped hand." + - "#5c0202§oCurved like a fang, and as old as one." - "#5c0202§oIt is warm to the touch," - "#5c0202§oand if you hold it still, it beats." @@ -150,8 +150,9 @@ whispers: 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 a choir: everything that ever bled on the stone, speaking as "we", red and -# hungry. A mage who refuses it hears only their own dark self: "I", grey and cold, their own thoughts. +# The two voices. The god is Lorvear, the first vampire, the Blood Father: ancient, regal and paternal, +# he speaks in red and calls the mage "child". A mage who refuses him hears only their own dark self: +# grey, cold, never speaking to anyone but themself. # whispered lines are said in order and end with the knife and altar; other stages pick at random. voices: # Minutes between lines, picked at random in this range. @@ -165,45 +166,46 @@ voices: pitch: 0.5 lines: whispered: - - "...there. You felt it, didn't you? Under the floor. Under the stone. Something listening back." - - "Hush. Do not be afraid, little one. We have waited so long for someone who listens." - - "So much warm blood walks the roads above us. Spilled in ditches, in brawls, in childbeds. Wasted." - - "We could teach you to waste none of it. We could teach you to grow." - - "Go down, deep, where the sky cannot follow. No lord's banner, no warden's eye. Lay our stone there." + - "...there. You heard it. A heartbeat under the stone that is not your own." + - "Hush, child. I am Lorvear. The first to drink, and father to all who drank after. Some still call me the Blood Father." + - "So much warm blood walks the roads of Cerrith. Spilled in ditches and brawls and childbeds. Wasted on the ground." + - "I could teach you to waste none of it. I taught Alaric. I taught a hundred before him. I can teach you." + - "Go down, deep, where the sky cannot follow. No lord's banner, no warden's eye. Lay my stone there." - "Bring living beasts down to the stone. Hold them still with the plain knife, and open them while they breathe." - - "Not beasts of your own. A gift costs nothing. Lead them from another's pen while the keeper sleeps." - - "Seven kinds of beast, one each day and no sooner. We like to savour." - - "Look in your pack, little one. We have been generous. Do not make us regret it." + - "Not beasts of your own, child. A gift that costs nothing is no gift. Lead them from another's pen while the keeper sleeps." + - "Seven kinds of beast, one each day and no sooner. I was old before the first kingdom rose. I can be patient." + - "Look in your pack. Your father has been generous. Do not make him regret it." animal: - - "The stone is thirsty. Can you not hear it?" + - "The stone is thirsty, child. Can you not hear it?" - "Every pen in the valley is full, and every shepherd sleeps." - "Lead it down gently. Fear sours the blood." - - "Look into its eyes when you do it. We want to see what you see." - - "We remember the taste of the last one. Bring us a kind we have not had." - - "Wash your hands before you go up. They can smell it on you." - - "Count with us. How many kinds now? Not enough. Never enough." + - "Look into its eyes when you do it. I want to see what you see." + - "I remember the taste of the last one. Bring me a kind I have not had." + - "Wash your hands before you climb back up. They can smell it on you." + - "The Lady in her tower would call this wickedness. Let her. She has never been hungry." choice: - - "Seven kinds have bled. Come down to the stone, and bring the new blade. We have a question for you." - - "The stone is red to its heart. Kneel at it with the curved blade in your hand." - - "Do not keep us waiting. Nothing has ever made us wait." + - "Seven kinds have bled. Come down to the stone, and bring the curved 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 in all of Cerrith has ever kept me waiting." final: - - "You are ours, little one, and so are they. We will tell you who." - - "We have chosen for you. You need not think any more. Only go to the stone." + - "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." - - "Every drop you gave us, we remember. Every drop you give, we repay." + - "The Reaper hoards the dead. 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, little one. We will wake you when we are hungry." - - "You walk among them and none of them know. We love that about you." - - "We are always beneath you. Always listening. Always fed." + - "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 the Lady send her hunters. I have outlived every one of them." self: style: "#7a7a7a§o" sound: minecraft:entity.warden.heartbeat pitch: 1.2 lines: hunt: - - "I don't need a god to tell me what I want." - - "The stone went quiet. Good. Now I can hear myself think." + - "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." @@ -216,8 +218,8 @@ voices: - "Their name keeps coming back. Of course it does." ascended: - "There is no one left I would miss. So this is what freedom feels like." - - "Quiet. So quiet. And I did it all myself." - - "I could start again. Learn new names. Let new people close. Just in case." + - "The Lady would call me evil. She'd be right. And far too late." + - "Quiet. So quiet. No god, no father. I did it all myself." # Animal sacrifices: bind a living animal on the altar with the ritual knife, then kill it with that knife. animal: @@ -269,23 +271,23 @@ corruption: # The god's question: right-click your altar with your blood knife once the seventh kind is given. choice: - title: "#5c0202What will you give?" + title: "#5c0202The Blood Father asks" question: - - "#8a0303§oSeven kinds have bled for us, and you did not flinch." - - "#8a0303§oGive yourself to us, and we will carry you all the way." - - "#8a0303§oOr keep yourself, and carry the weight alone. Forever." + - "#8a0303§oSeven kinds have bled for me, child, and you did not flinch." + - "#8a0303§oKneel, and be my child. I will carry you all the way." + - "#8a0303§oOr stand alone, and carry the weight yourself. Forever." devotee: item: v.red_dye - name: "#8a0303I give myself" + name: "#8a0303I kneel to Lorvear" lore: - "#8c8c8c§oPart of you stays on the stone," - "#8c8c8c§oa wound that will never close." - - "#8c8c8c§oThe god walks with you, and chooses for you." + - "#8c8c8c§oThe Blood Father walks with you, and chooses for you." "true": item: v.iron_sword - name: "#bfbfbfI take for myself" + name: "#bfbfbfI kneel to no one" lore: - - "#8c8c8c§oNo god. No voices. No one to blame." + - "#8c8c8c§oNo father. No voices. No one to blame." - "#8c8c8c§oOnly you, and what you take" - "#8c8c8c§ofrom the people who love you." confirm: @@ -297,7 +299,7 @@ choice: item: v.barrier name: "#aaaaaaNot yet" lore: - - "#8c8c8c§oIt will wait. It is very good at waiting." + - "#8c8c8c§oHe will wait. He is very good at waiting." # Permanent injuries a devotee keeps as the mark of what they gave. devotee_injuries: 2 diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index b55a83a..09e7ec2 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -492,11 +492,11 @@ blood: not_species: "#8c8c8c§oThe stone does not hunger for that" - known: "#8a0303§oWe have tasted that kind already. Bring us another" + known: "#8a0303§oI have tasted that kind already, child. Bring me another" - cooldown: "#8a0303§oNot yet. The stone is still drinking the last one" + cooldown: "#8a0303§oNot yet, child. The stone is still drinking the last one" - not_stolen: "#8a0303§oNot that one. A gift costs nothing. Take what others keep" + not_stolen: "#8a0303§oNot that one. A gift that costs nothing is no gift. Take what others keep" too_far: "#8a0303§oToo far. Lead it down to the stone" @@ -544,7 +544,7 @@ blood: busy: "#8c8c8c§oOne at a time" - dread: "#8a0303§oCold stone under your back. A blade above you. And below, something vast and patient opens its mouth" + dread: "#8a0303§oCold stone under your back. A blade above you. And below, something old and patient opens its mouth" broken: "#8c8c8c§oThe rite comes apart. The stone goes cold and quiet, sulking" @@ -564,15 +564,15 @@ blood: hunt: - complete: "#7a7a7a§oSeven measures of blood. Something in you closes like a fist. The stone knows your name now, and no god gave it" + complete: "#7a7a7a§oSeven measures of blood. Something in you closes like a fist. The stone knows your name now, and no father gave it" final: - named_god: "We have chosen. {target}. Bring us {target}" + 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, little one. Then bring them to us" + 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" @@ -580,15 +580,15 @@ blood: ascended: "#5c0202§oThe last of them is gone. Nothing holds you now. Nothing ever will again" - failed_god: "Someone else spilled what was ours, and you let them. You will bleed for it" + 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" choice: - devoted: "#8a0303§oSomething of you stays on the stone, and something of the stone comes with you. It hurts. It will always hurt" + devoted: "#8a0303§oSomething of you stays on the stone, and something of the Blood Father comes with you. It hurts. It will always hurt" - refused: "#7a7a7a§oThe voices fall silent. For the first time in weeks you are alone with your own thoughts. They are not kind" + refused: "#7a7a7a§oLorvear falls silent. For the first time in weeks you are alone with your own thoughts. They are not kind" armour: diff --git a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java index acbfac7..bf75813 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java @@ -88,7 +88,7 @@ InventoryClickEvent click(int slot) { @Test void bundledQuestionClassesAndArmourLoad() { - assertEquals("#5c0202What will you give?", ChoiceConfig.title()); + assertEquals("#5c0202The Blood Father asks", ChoiceConfig.title()); assertEquals(3, ChoiceConfig.button(ChoiceConfig.Slot.QUESTION).lore().size()); assertEquals("v.iron_sword", ChoiceConfig.button(ChoiceConfig.Slot.TRUE).item()); assertEquals(2, ChoiceConfig.devoteeInjuries()); From def05d55643c8d190ba8b8023be8a99cfd22aedd Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:52:12 +0100 Subject: [PATCH 11/55] Let the Void Reaper speak as the mage's own dark self The self voice is the Void Reaper working on the mage unseen: at the god's question it argues against kneeling half the time, and it is all a mage hears after refusing Lorvear. It only hints at the quiet and the cold; grey with a touch of void in its colour and effects. Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/WhisperService.java | 6 +++++ src/main/resources/bloodmagic.yml | 20 +++++++++++--- src/main/resources/messages.yml | 4 +-- .../magic/blood/WhisperServiceTest.java | 26 ++++++++++++++++++- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/WhisperService.java b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java index e48a2fd..f5f2947 100644 --- a/src/main/java/net/tfminecraft/magic/blood/WhisperService.java +++ b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java @@ -172,6 +172,12 @@ private static void schedule(Player player, long now) { /** 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); + // At the question the mage's "own" voice starts arguing back: the Void Reaper, unseen. + if (record.getStage() == BloodStage.CHOICE + && !WhisperConfig.voice(WhisperConfig.Speaker.SELF).lines(BloodStage.CHOICE).isEmpty() + && random.nextBoolean()) { + speaker = WhisperConfig.Speaker.SELF; + } List lines = WhisperConfig.voice(speaker).lines(record.getStage()); if (record.getStage() == BloodStage.WHISPERED) { int index = record.getWhisperLine(); diff --git a/src/main/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index 2ca288c..7ff2254 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -151,8 +151,10 @@ whispers: 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 speaks in red and calls the mage "child". A mage who refuses him hears only their own dark self: -# grey, cold, never speaking to anyone but themself. +# he speaks in red and calls the mage "child". The other voice sounds like the mage's own thoughts, but it +# is the Void Reaper, working on them unseen: it argues against kneeling at the question (choice lines, +# half the time) and is all a mage hears once they refuse. It never names itself; it only hints at the +# quiet, the cold and the empty. Grey with a touch of the void. # whispered lines are said in order and end with the knife and altar; other stages pick at random. voices: # Minutes between lines, picked at random in this range. @@ -199,10 +201,16 @@ voices: - "You walk among them and none of them know. You are more mine than Alaric ever was." - "Let the Lady send her hunters. I have outlived every one of them." self: - style: "#7a7a7a§o" + style: "#6e6878§o" sound: minecraft:entity.warden.heartbeat pitch: 1.2 lines: + 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." @@ -210,16 +218,19 @@ voices: - "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." - "The Lady would call me evil. She'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." # Animal sacrifices: bind a living animal on the altar with the ritual knife, then kill it with that knife. animal: @@ -287,6 +298,7 @@ choice: item: v.iron_sword name: "#bfbfbfI kneel to no one" lore: + - "#6e6878§oI never needed him. I never did." - "#8c8c8c§oNo father. No voices. No one to blame." - "#8c8c8c§oOnly you, and what you take" - "#8c8c8c§ofrom the people who love you." @@ -514,6 +526,8 @@ fx: - { 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 } diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 09e7ec2..79b5d4e 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -536,7 +536,7 @@ blood: repeat: "#8c8c8c§oThat blood is still drying on the stone. Find someone else" - reserved: "#7a7a7a§oNot them. Not yet. They are for later" + reserved: "#6e6878§oNot them. Not yet. They are for later" waiting: "#8c8c8c§oThe stone sulks over what was stolen from it. It will take no one yet" @@ -588,7 +588,7 @@ blood: devoted: "#8a0303§oSomething of you stays on the stone, and something of the Blood Father comes with you. It hurts. It will always hurt" - refused: "#7a7a7a§oLorvear falls silent. For the first time in weeks you are alone with your own thoughts. They are not kind" + refused: "#6e6878§oLorvear falls silent. For the first time in weeks you are alone with your own thoughts. You are almost sure they are your own" armour: diff --git a/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java b/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java index 74cde4c..f175898 100644 --- a/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java @@ -148,6 +148,29 @@ void teachingLinesRunInOrderAndEndInTheGift() { assertFalse(player.getInventory().containsAtLeast(BloodItems.create(BloodItemKind.ALTAR, id), 1)); } + @Test + void atTheQuestionTheVoidSpeaksBackHalfTheTime() throws Exception { + PlayerMock player = fx.player("mage"); + BloodRecord record = fx.record("mage", BloodStage.CHOICE); + String self = net.tfminecraft.magic.util.MagicText.format(WhisperConfig.voice(WhisperConfig.Speaker.SELF).style()); + String god = net.tfminecraft.magic.util.MagicText.format(WhisperConfig.voice(WhisperConfig.Speaker.GOD).style()); + WhisperService.random = new java.util.Random() { + boolean flip; + @Override public boolean nextBoolean() { return flip = !flip; } + }; + WhisperService.speakNext(player, record); + assertTrue(player.nextMessage().startsWith(self)); + WhisperService.speakNext(player, record); + assertTrue(player.nextMessage().startsWith(god)); + 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"); @@ -163,7 +186,8 @@ void laterStagesPickALineAndTheTruePathHearsOnlyItself() throws Exception { record.setStage(BloodStage.ASCENDED); WhisperService.speakNext(player, record); assertNotNull(player.nextMessage()); - record.setStage(BloodStage.CHOICE); + record.setPath(BloodPath.NONE); + record.setStage(BloodStage.HUNT); WhisperService.speakNext(player, record); assertNull(player.nextMessage()); From c152a6ff2df4a049791b203af1f6b9d39212d8a7 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:13:40 +0100 Subject: [PATCH 12/55] Close the holes found in review - A victim who logs out on the altar is sacrificed as they leave. - The no-repeat rule only applies to the hunt, so a named final target can always be taken; carrying needs the same eligibility as the rite. - The rite needs a clear line from the body to the altar and refuses a body another mage is dragging; dragged bodies are never laid in liquid, fire or walls. - Boons are removed by key, also on disable; the question menu acts a tick after the click. - Bound animals are tagged so they always wake after an unload, a second mage cannot take a bound animal, and a refused teleport does not bind. - Corruption skips protected regions; placing the altar fires a BlockPlaceEvent so claims can refuse it and loggers record it. - enabled: false stops everything but the dead-owner sweep; corruption plants are fully grown; purifying clears the block the altar was. Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/AltarCorruption.java | 8 +- .../magic/blood/AltarDisplays.java | 8 +- .../tfminecraft/magic/blood/AltarService.java | 11 + .../magic/blood/AnimalRiteService.java | 55 ++++- .../tfminecraft/magic/blood/BloodAltar.java | 10 + .../magic/blood/BloodListener.java | 6 +- .../tfminecraft/magic/blood/BloodMagic.java | 6 + .../tfminecraft/magic/blood/CarryService.java | 22 +- .../magic/blood/CharacterRiteService.java | 100 +++++++-- .../tfminecraft/magic/blood/ChoiceMenu.java | 17 +- .../tfminecraft/magic/blood/MythicBoons.java | 20 +- src/main/resources/bloodmagic.yml | 2 +- src/main/resources/messages.yml | 2 + .../magic/blood/AnimalRiteTest.java | 51 +++++ .../tfminecraft/magic/blood/BloodFixture.java | 12 + .../magic/blood/BoonsAndToolsTest.java | 2 +- .../tfminecraft/magic/blood/CarryTest.java | 3 + .../tfminecraft/magic/blood/ChoiceTest.java | 4 + .../magic/blood/HuntAndFinalsTest.java | 1 + .../magic/blood/ReviewFixesTest.java | 211 ++++++++++++++++++ 20 files changed, 494 insertions(+), 57 deletions(-) create mode 100644 src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java index cf14241..9bede5f 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java @@ -12,6 +12,7 @@ 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 @@ -58,7 +59,8 @@ private static boolean changeOne(BloodAltar altar, Block center, int radius, Lis 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)) { + || !block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4) + || AltarRegions.blocked(block.getLocation())) { continue; } CorruptionConfig.Rule rule = rules.get(random.nextInt(rules.size())); @@ -103,6 +105,10 @@ private static void change(BloodAltar altar, Block block, Material to) { 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)); diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarDisplays.java b/src/main/java/net/tfminecraft/magic/blood/AltarDisplays.java index 4b7c606..2e4d851 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarDisplays.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarDisplays.java @@ -39,10 +39,13 @@ public static boolean ensure(BloodAltar altar) { 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 false; + return recorded; } ItemDisplay display = spawn(altar); altar.setDisplayId(display.getUniqueId()); @@ -90,7 +93,8 @@ public static void remove(BloodAltar altar) { } } Block block = altar.block(); - if (block != null && block.getType() == BloodConfig.altarBlock()) { + 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/AltarService.java b/src/main/java/net/tfminecraft/magic/blood/AltarService.java index d4f40d2..f96bdba 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -7,6 +7,10 @@ import org.bukkit.block.Block; 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; @@ -49,6 +53,13 @@ public static String place(Player player, ItemStack hand, Block 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"; + } BloodAltar altar = BloodAltar.create(character, player.getUniqueId().toString(), target, player.getLocation().getYaw() + 180f, System.currentTimeMillis()); altar.setLevel(BloodProgress.level(record)); diff --git a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java index f9392a7..f2c4fb5 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java @@ -15,6 +15,9 @@ import org.bukkit.entity.Player; 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; @@ -51,7 +54,7 @@ private AnimalRiteService() {} public static Bind bind(Player player, LivingEntity animal) { String character = AltarService.currentCharacter(player); BloodRecord record = BloodRecords.get(character); - if (record == null || record.getStage() != BloodStage.ANIMAL) { + if (!BloodConfig.isEnabled() || record == null || record.getStage() != BloodStage.ANIMAL) { return Bind.NOT_STAGE; } ItemStack knife = player.getInventory().getItemInMainHand(); @@ -83,14 +86,19 @@ public static Bind bind(Player player, LivingEntity animal) { || animal.getLocation().distance(top) > AnimalConfig.bindRange()) { return Bind.TOO_FAR; } - if (bindingOf(player.getUniqueId()) != null) { + if (bindingOf(player.getUniqueId()) != null || isBound(animal.getUniqueId())) { return Bind.BUSY; } + if (!animal.teleport(top.clone().add(0, 0.5, 0))) { + return Bind.TOO_FAR; + } + long expires = now + AnimalConfig.bindSeconds() * 1000L; animal.setAI(false); - animal.teleport(top.clone().add(0, 0.5, 0)); + // 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(), - now + AnimalConfig.bindSeconds() * 1000L, owners, stolen)); + expires, owners, stolen)); return Bind.OK; } @@ -175,13 +183,21 @@ static void tick() { for (Iterator> it = BOUND.entrySet().iterator(); it.hasNext();) { Map.Entry entry = it.next(); Entity entity = Bukkit.getEntity(entry.getKey()); - if (!(entity instanceof LivingEntity animal) || animal.isDead()) { + if (entity == null) { + // Unloaded: keep the binding until it runs out; the animal wakes from its tag when it loads. + if (now >= entry.getValue().expiresAt()) { + it.remove(); + } + continue; + } + LivingEntity animal = (LivingEntity) entity; + if (animal.isDead()) { it.remove(); continue; } if (now >= entry.getValue().expiresAt()) { it.remove(); - animal.setAI(true); + wake(animal); Player mage = Bukkit.getPlayer(entry.getValue().mage()); if (mage != null) { mage.sendMessage(Messages.get("blood.animal.escaped")); @@ -197,12 +213,35 @@ static void tick() { public static void releaseAll() { for (UUID id : BOUND.keySet()) { if (Bukkit.getEntity(id) instanceof LivingEntity animal) { - animal.setAI(true); + 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. */ public static void releaseFor(UUID mage) { UUID animal = bindingOf(mage); @@ -211,7 +250,7 @@ public static void releaseFor(UUID mage) { } BOUND.remove(animal); if (Bukkit.getEntity(animal) instanceof LivingEntity living) { - living.setAI(true); + wake(living); } } } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java b/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java index 439410c..382e516 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java @@ -16,6 +16,8 @@ public final class BloodAltar { 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; @@ -64,6 +66,14 @@ public String getOwnerUuid() { return ownerUuid; } + public String getBlockType() { + return blockType; + } + + public void setBlockType(String blockType) { + this.blockType = blockType; + } + public String getWorld() { return world; } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index 1772494..d6d367a 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -197,6 +197,7 @@ public void onBlockExplode(BlockExplodeEvent event) { @EventHandler public void onEntitiesLoad(EntitiesLoadEvent event) { AltarDisplays.removeStrays(event.getEntities()); + AnimalRiteService.wakeLoaded(event.getEntities()); StolenClues.removeExpired(event.getEntities()); for (BloodAltar altar : BloodAltars.inChunk(event.getWorld().getName(), event.getChunk().getX(), event.getChunk().getZ())) { @@ -231,8 +232,10 @@ public void onAnimalDeath(EntityDeathEvent event) { } } - @EventHandler + /** 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()); CarryService.drop(event.getPlayer().getUniqueId()); @@ -254,6 +257,7 @@ public void onSessionStart(CharacterSessionStartEvent event) { public void onSessionEnd(CharacterSessionEndEvent event) { BondService.unload(event.getSession().ref().characterId().toString()); BloodBoons.clear(event.getPlayer()); + AnimalRiteService.releaseFor(event.getPlayer().getUniqueId()); } private static boolean touchesAltar(List blocks) { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index 82ddf1a..2fa0e14 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -44,6 +44,9 @@ static void tick(MagicTickContext context) { if (context.everyMinutes(BloodConfig.purifyCheckMinutes())) { AltarService.sweepDead(); } + if (!BloodConfig.isEnabled()) { + return; + } if (context.every(AMBIENT_SECONDS)) { ambient(); } @@ -87,6 +90,9 @@ public static boolean loadConfig(File dataFolder, Logger log) { } public static void stop() { + for (Player player : Bukkit.getOnlinePlayers()) { + BloodBoons.clear(player); + } CarryService.clear(); CharacterRiteService.clear(); BondService.clear(); diff --git a/src/main/java/net/tfminecraft/magic/blood/CarryService.java b/src/main/java/net/tfminecraft/magic/blood/CarryService.java index 115b66a..2851570 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CarryService.java +++ b/src/main/java/net/tfminecraft/magic/blood/CarryService.java @@ -14,6 +14,10 @@ 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.BlockFace; import net.tfminecraft.magic.Messages; import net.tfminecraft.magic.integration.RpCharactersBridge; @@ -44,6 +48,9 @@ private Carry(UUID victim, String mageCharacter, String victimCharacter) { 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); private static BukkitTask task; private CarryService() {} @@ -71,6 +78,12 @@ public static CharacterRiteService.Bind start(Player mage, Player victim) { 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; @@ -169,7 +182,7 @@ private static void follow(Player mage, Player victim, Carry carry) { for (Iterator back = carry.trail.descendingIterator(); back.hasNext();) { Location point = back.next(); if (point.distance(here) >= HuntConfig.carryDistance()) { - spot = point; + spot = safe(point) ? point : null; break; } } @@ -186,6 +199,13 @@ private static void follow(Player mage, Player victim, Carry carry) { } } + /** 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) { + Material feet = spot.getBlock().getType(); + Material below = spot.getBlock().getRelative(BlockFace.DOWN).getType(); + return !feet.isSolid() && !UNSAFE.contains(feet) && below != Material.LAVA && below != Material.MAGMA_BLOCK; + } + public static void clear() { CARRIES.clear(); if (task != null) { diff --git a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java index 9b6fb1e..bd21b70 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java @@ -13,6 +13,8 @@ import org.bukkit.Location; 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; @@ -39,7 +41,8 @@ public enum Bind { RESERVED, WAITING, NOT_TARGET, - BUSY + BUSY, + BLOCKED } private record Rite(UUID victim, String mageCharacter, String victimCharacter, String victimAccount, @@ -76,32 +79,20 @@ public static Bind bind(Player mage, Player victim) { if (victim.getWorld() != top.getWorld() || victim.getLocation().distance(top) > HuntConfig.dragRange()) { return Bind.TOO_FAR; } - String victimAccount = victim.getUniqueId().toString(); - if (RpCharactersBridge.playtimeSeconds(victim) < HuntConfig.minPlaytimeHours() * 3600 - || RpCharactersBridge.characterAgeSeconds(victim) < HuntConfig.minAgeDays() * 86_400) { - return Bind.TOO_NEW; + if (!clearPath(victim.getLocation(), top)) { + return Bind.BLOCKED; } - if (HuntConfig.noRepeatPlayer() && victimAccount.equalsIgnoreCase(record.getLastVictimAccount())) { - return Bind.REPEAT; + Bind eligible = eligibility(mage, record, victim, victimCharacter); + if (eligible != null) { + return eligible; } - int points = 0; - if (record.getStage() == BloodStage.HUNT) { - int rank = BondService.rank(mage, victimCharacter); - if (rank >= 1 && rank <= HuntConfig.finalCount()) { - return Bind.RESERVED; - } - points = HuntConfig.points(rank); - } else { - if (clock.getAsLong() < record.getNextFinalAt()) { - return Bind.WAITING; - } - if (!victimCharacter.equalsIgnoreCase(record.currentTarget())) { - return Bind.NOT_TARGET; - } - } - if (RITES.containsKey(mage.getUniqueId()) || isVictim(victim.getUniqueId())) { + 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(); + int points = record.getStage() == BloodStage.HUNT ? HuntConfig.points(BondService.rank(mage, victimCharacter)) : 0; CarryService.release(mage, false); RpCharactersBridge.moveDowned(victim, top.clone().add(0, 0.5, 0)); RITES.put(mage.getUniqueId(), new Rite(victim.getUniqueId(), character, victimCharacter, victimAccount, @@ -111,9 +102,70 @@ public static Bind bind(Player mage, Player victim) { return Bind.OK; } + /** + * Whether this victim may be taken now: TOO_NEW, REPEAT (hunt only; a named final target must stay + * takeable), RESERVED (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) { + if (HuntConfig.noRepeatPlayer() + && victim.getUniqueId().toString().equalsIgnoreCase(record.getLastVictimAccount())) { + return Bind.REPEAT; + } + 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 or floor between the body and the altar: the rite never pulls anyone through stone. */ + static boolean clearPath(Location from, Location altarCenter) { + Location start = from.clone().add(0, 0.5, 0); + Vector line = altarCenter.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(); + if (!block.equals(altarBlock) && block.getType().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 (mage != null && altar != null) { + complete(mage, player, entry.getValue(), altar); + } + return; + } + } + /** 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 (record == null || (record.getStage() != BloodStage.HUNT && record.getStage() != BloodStage.FINAL)) { + if (!BloodConfig.isEnabled() || record == null + || (record.getStage() != BloodStage.HUNT && record.getStage() != BloodStage.FINAL)) { return Bind.NOT_STAGE; } ItemStack knife = mage.getInventory().getItemInMainHand(); diff --git a/src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java b/src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java index cc6a2aa..1cd517a 100644 --- a/src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java +++ b/src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java @@ -13,6 +13,7 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import net.tfminecraft.magic.Magic; import net.tfminecraft.magic.util.ItemRef; import net.tfminecraft.magic.util.MagicText; @@ -91,20 +92,28 @@ public static void onClick(InventoryClickEvent event) { } Player player = (Player) event.getWhoClicked(); int slot = event.getRawSlot(); + // Inventories are not opened, closed or filled inside a click event; it all happens a tick later. if (menu.pending == BloodPath.NONE) { if (slot == LEFT_SLOT || slot == RIGHT_SLOT) { - show(player, slot == LEFT_SLOT ? BloodPath.DEVOTEE : BloodPath.TRUE); + later(() -> show(player, slot == LEFT_SLOT ? BloodPath.DEVOTEE : BloodPath.TRUE)); } return; } if (slot == RIGHT_SLOT) { - show(player, BloodPath.NONE); + later(() -> show(player, BloodPath.NONE)); } else if (slot == LEFT_SLOT) { - player.closeInventory(); - BloodChoice.answer(player, menu.pending); + BloodPath pending = menu.pending; + later(() -> { + player.closeInventory(); + BloodChoice.answer(player, pending); + }); } } + private static void later(Runnable action) { + Magic.plugin.getServer().getScheduler().runTask(Magic.plugin, action); + } + public static void onDrag(InventoryDragEvent event) { if (event.getInventory().getHolder() instanceof ChoiceMenu) { event.setCancelled(true); diff --git a/src/main/java/net/tfminecraft/magic/blood/MythicBoons.java b/src/main/java/net/tfminecraft/magic/blood/MythicBoons.java index 0c473a8..22b75ce 100644 --- a/src/main/java/net/tfminecraft/magic/blood/MythicBoons.java +++ b/src/main/java/net/tfminecraft/magic/blood/MythicBoons.java @@ -1,21 +1,17 @@ package net.tfminecraft.magic.blood; -import java.util.HashMap; -import java.util.HashSet; import java.util.Map; -import java.util.Set; -import java.util.UUID; 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; /** 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 static final Map> APPLIED = new HashMap<>(); private MythicBoons() {} @@ -24,21 +20,17 @@ static void add(Player player, Map boons) { if (data == null) { return; } - Set stats = APPLIED.computeIfAbsent(player.getUniqueId(), id -> new HashSet<>()); - boons.forEach((stat, value) -> { - new StatModifier(KEY, stat, value).register(data); - stats.add(stat); - }); + boons.forEach((stat, value) -> new StatModifier(KEY, stat, value).register(data)); } + /** Removes every blood boon by key, so nothing survives a reload or a forgotten stat. */ static void clear(Player player) { - Set stats = APPLIED.remove(player.getUniqueId()); MMOPlayerData data = MMOPlayerData.getOrNull(player); - if (stats == null || data == null) { + if (data == null) { return; } - for (String stat : stats) { - data.getStatMap().getInstance(stat).remove(KEY); + for (StatInstance instance : data.getStatMap().getInstances()) { + instance.remove(KEY); } } } diff --git a/src/main/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index 7ff2254..b7a4fdb 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -277,7 +277,7 @@ corruption: - { min_level: 1, at: floor, to: [COBWEB] } - { min_level: 2, at: floor, to: [WITHER_ROSE] } - { min_level: 3, at: ceiling, to: [WEEPING_VINES] } - - { min_level: 3, at: floor, to: [CRIMSON_ROOTS, CRIMSON_FUNGUS] } + - { min_level: 3, at: floor, to: [CRIMSON_ROOTS, NETHER_SPROUTS] } - { min_level: 4, at: floor, to: [SKELETON_SKULL, BONE_BLOCK] } # The god's question: right-click your altar with your blood knife once the seventh kind is given. diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 79b5d4e..0125fc0 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -544,6 +544,8 @@ blood: 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" broken: "#8c8c8c§oThe rite comes apart. The stone goes cold and quiet, sulking" diff --git a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java index c85ad9d..dc68dfd 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java @@ -1,6 +1,9 @@ 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; @@ -18,6 +21,7 @@ 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; @@ -81,6 +85,50 @@ EntityDeathEvent death(LivingEntity animal, PlayerMock killer) { 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(); @@ -257,6 +305,9 @@ void boundAnimalsTwitchBreakFreeAndAreReleased() { 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); diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java index 6a2e767..d155183 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java @@ -111,6 +111,18 @@ Block pocket(int x, int y, int z) { 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); diff --git a/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java b/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java index f953948..4047d90 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java @@ -78,7 +78,7 @@ void boonsNeedMythicLibAndReplaceEachOther() { StatMap map = mock(StatMap.class); StatInstance instance = mock(StatInstance.class); when(data.getStatMap()).thenReturn(map); - when(map.getInstance(any())).thenReturn(instance); + when(map.getInstances()).thenReturn(List.of(instance)); players.when(() -> MMOPlayerData.getOrNull(mage)).thenReturn(data); BloodBoons.apply(mage); assertEquals(2, modifiers.constructed().size()); diff --git a/src/test/java/net/tfminecraft/magic/blood/CarryTest.java b/src/test/java/net/tfminecraft/magic/blood/CarryTest.java index 0005fde..dc0f76f 100644 --- a/src/test/java/net/tfminecraft/magic/blood/CarryTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/CarryTest.java @@ -61,6 +61,7 @@ void setup() { 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)); @@ -109,6 +110,8 @@ void takingHoldChecksTheMageAndTheBody() { 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); diff --git a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java index bf75813..c26137b 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java @@ -121,19 +121,23 @@ void theMenuAsksThenConfirmsAndGoesBack() { assertSame(mage.getOpenInventory().getTopInventory(), menu.getInventory()); ChoiceMenu.onClick(click(0)); ChoiceMenu.onClick(click(ChoiceMenu.RIGHT_SLOT)); + fx.server.getScheduler().performTicks(1); menu = (ChoiceMenu) mage.getOpenInventory().getTopInventory().getHolder(); assertEquals(BloodPath.TRUE, menu.pending()); ChoiceMenu.onClick(click(0)); ChoiceMenu.onClick(click(ChoiceMenu.RIGHT_SLOT)); + fx.server.getScheduler().performTicks(1); menu = (ChoiceMenu) mage.getOpenInventory().getTopInventory().getHolder(); assertEquals(BloodPath.NONE, menu.pending()); ChoiceMenu.onClick(click(ChoiceMenu.LEFT_SLOT)); + fx.server.getScheduler().performTicks(1); menu = (ChoiceMenu) mage.getOpenInventory().getTopInventory().getHolder(); assertEquals(BloodPath.DEVOTEE, menu.pending()); InventoryClickEvent below = click(40); ChoiceMenu.onClick(below); assertTrue(below.isCancelled()); ChoiceMenu.onClick(click(ChoiceMenu.LEFT_SLOT)); + fx.server.getScheduler().performTicks(1); assertEquals(BloodPath.DEVOTEE, record.getPath()); InventoryDragEvent drag = mock(InventoryDragEvent.class); diff --git a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java index 6a92eba..07a2b8a 100644 --- a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java @@ -70,6 +70,7 @@ void setup() { 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)); 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..5091ec7 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java @@ -0,0 +1,211 @@ +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/bloodmagic.yml"), 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.STONE); + assertEquals(CharacterRiteService.Bind.BLOCKED, CharacterRiteService.bind(mage, victim)); + fx.world.getBlockAt(2, 10, 0).setType(Material.AIR); + + 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 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)); + + 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(); + mage.teleport(new Location(fx.world, 4.0, 10, 0.5)); + CarryService.tick(); + assertEquals(6.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)); + BloodMagic.tick(MagicTickContext.between(599, 600)); + BloodMagic.stop(); + assertNull(BloodRecords.get(mageId)); + } +} From 36e8d81633855196013b685453db6de7eb41a5a8 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:31:39 +0100 Subject: [PATCH 13/55] Tighten the review fixes - Switching the feature off ends rites, drags and animal bindings, and a victim's logout only finishes a rite that still holds. - Drag safety is judged at body height, so slabs, paths and soul sand are fine while water plants, waterlogged blocks and hot ground are not. - The rite's line of sight runs a body's height up to the altar top, so slabs, pews and the altar's own bone blocks no longer block it. - A refused move does not start a rite. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/BloodMagic.java | 3 ++ .../tfminecraft/magic/blood/CarryService.java | 25 +++++++-- .../magic/blood/CharacterRiteService.java | 33 ++++++++---- .../magic/blood/HuntAndFinalsTest.java | 1 + .../magic/blood/ReviewFixesTest.java | 52 ++++++++++++++++++- 5 files changed, 97 insertions(+), 17 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index 2fa0e14..aeb959d 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -45,6 +45,9 @@ static void tick(MagicTickContext context) { 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)) { diff --git a/src/main/java/net/tfminecraft/magic/blood/CarryService.java b/src/main/java/net/tfminecraft/magic/blood/CarryService.java index 2851570..80fdd01 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CarryService.java +++ b/src/main/java/net/tfminecraft/magic/blood/CarryService.java @@ -17,7 +17,8 @@ import java.util.EnumSet; import java.util.Set; import org.bukkit.Material; -import org.bukkit.block.BlockFace; +import org.bukkit.block.Block; +import org.bukkit.block.data.Waterlogged; import net.tfminecraft.magic.Messages; import net.tfminecraft.magic.integration.RpCharactersBridge; @@ -50,7 +51,12 @@ private Carry(UUID victim, String mageCharacter, String victimCharacter) { 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.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() {} @@ -135,6 +141,10 @@ static void drop(UUID player) { } 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(); @@ -201,9 +211,14 @@ private static void follow(Player mage, Player victim, Carry carry) { /** 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) { - Material feet = spot.getBlock().getType(); - Material below = spot.getBlock().getRelative(BlockFace.DOWN).getType(); - return !feet.isSolid() && !UNSAFE.contains(feet) && below != Material.LAVA && below != Material.MAGMA_BLOCK; + 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() { diff --git a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java index bd21b70..95f447d 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java @@ -52,6 +52,8 @@ private record Rite(UUID victim, String mageCharacter, String victimCharacter, S 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 ALTAR_TOP = 0.6; static LongSupplier clock = System::currentTimeMillis; private CharacterRiteService() {} @@ -93,8 +95,10 @@ public static Bind bind(Player mage, Player victim) { } String victimAccount = victim.getUniqueId().toString(); int points = record.getStage() == BloodStage.HUNT ? HuntConfig.points(BondService.rank(mage, victimCharacter)) : 0; + if (!RpCharactersBridge.moveDowned(victim, top.clone().add(0, 0.5, 0))) { + return Bind.BLOCKED; + } CarryService.release(mage, false); - RpCharactersBridge.moveDowned(victim, top.clone().add(0, 0.5, 0)); RITES.put(mage.getUniqueId(), new Rite(victim.getUniqueId(), character, victimCharacter, victimAccount, points, clock.getAsLong() + HuntConfig.riteSeconds() * 1000L)); victim.sendMessage(Messages.get("blood.rite.dread")); @@ -125,10 +129,14 @@ static Bind eligibility(Player mage, BloodRecord record, Player victim, String v return victimCharacter.equalsIgnoreCase(record.currentTarget()) ? null : Bind.NOT_TARGET; } - /** No wall or floor between the body and the altar: the rite never pulls anyone through stone. */ + /** + * No wall between the body and the altar: the rite never pulls anyone through stone. The line runs + * a body's height above the floor to the top of the altar, so slabs, pews, chests and the altar's own + * bone blocks do not count; walls and ceilings do. + */ static boolean clearPath(Location from, Location altarCenter) { - Location start = from.clone().add(0, 0.5, 0); - Vector line = altarCenter.toVector().subtract(start.toVector()); + Location start = from.clone().add(0, BODY_HEIGHT, 0); + 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++) { @@ -155,13 +163,22 @@ static void onQuit(Player player) { it.remove(); Player mage = Bukkit.getPlayer(entry.getKey()); BloodAltar altar = BloodAltars.ofCharacter(entry.getValue().mageCharacter()); - if (mage != null && altar != null) { + 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 @@ -199,11 +216,7 @@ static void tick() { Player victim = Bukkit.getPlayer(rite.victim()); BloodAltar altar = BloodAltars.ofCharacter(rite.mageCharacter()); Location top = altar != null ? altar.center() : null; - if (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))) { + if (!holds(mage, victim, rite, top)) { it.remove(); if (mage != null) { mage.sendMessage(Messages.get("blood.rite.broken")); diff --git a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java index 07a2b8a..deb0856 100644 --- a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java @@ -58,6 +58,7 @@ void setup() { 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 -> { diff --git a/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java b/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java index 5091ec7..61f2955 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java @@ -81,9 +81,21 @@ void cleanup() { @Test void theRiteNeedsAClearLineAndAFreeBody() { victim.teleport(new Location(fx.world, 3.5, 10, 0.5)); - fx.world.getBlockAt(2, 10, 0).setType(Material.STONE); + fx.world.getBlockAt(2, 10, 0).setType(Material.BONE_BLOCK); + 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, 10, 0).setType(Material.AIR); + fx.world.getBlockAt(2, 11, 0).setType(Material.AIR); + // A body lying a step below the altar: the line clips the altar's own block, which never counts. + 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"); @@ -130,6 +142,23 @@ void loggingOutOnTheAltarDoesNotSaveTheVictim() { 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"); @@ -154,6 +183,17 @@ void bodiesAreNeverLaidInLiquidFireOrWalls() { 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)); @@ -164,9 +204,10 @@ void bodiesAreNeverLaidInLiquidFireOrWalls() { 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(6.5, victim.getLocation().getX(), 1e-9); + assertEquals(5.5, victim.getLocation().getX(), 1e-9); } @Test @@ -204,7 +245,14 @@ 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)); } From 1aebfd287781020b3cc7284b46e50090caf2d32e Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:40:15 +0100 Subject: [PATCH 14/55] Check the floor layer too before pulling a body to the altar A second line just above slab height only counts full blocks, so a body can no longer be pulled through a one-block window while slabs, stairs and beds still do not get in the way. Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/CharacterRiteService.java | 19 ++++++++++++++----- .../magic/blood/ReviewFixesTest.java | 10 ++++++++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java index 95f447d..26a61e7 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java @@ -11,6 +11,7 @@ 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; @@ -53,6 +54,7 @@ private record Rite(UUID victim, String mageCharacter, String victimCharacter, S 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; @@ -130,18 +132,25 @@ static Bind eligibility(Player mage, BloodRecord record, Player victim, String v } /** - * No wall between the body and the altar: the rite never pulls anyone through stone. The line runs - * a body's height above the floor to the top of the altar, so slabs, pews, chests and the altar's own - * bone blocks do not count; walls and ceilings do. + * 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) { - Location start = from.clone().add(0, BODY_HEIGHT, 0); + 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(); - if (!block.equals(altarBlock) && block.getType().isSolid()) { + Material type = block.getType(); + if (!block.equals(altarBlock) && (fullBlocksOnly ? type.isOccluding() : type.isSolid())) { return false; } } diff --git a/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java b/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java index 61f2955..b23ed48 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java @@ -81,13 +81,19 @@ void cleanup() { @Test void theRiteNeedsAClearLineAndAFreeBody() { victim.teleport(new Location(fx.world, 3.5, 10, 0.5)); - fx.world.getBlockAt(2, 10, 0).setType(Material.BONE_BLOCK); + 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, 10, 0).setType(Material.AIR); 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)); From 92eac59d8930efaa361ae4cd88ebec1f41e8694d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:15:32 +0100 Subject: [PATCH 15/55] Name the Sanguinar armour after the hunt's victims The true path's armour is made from the people it took: each piece with a {victim} name in bloodmagic.yml is named after a hunt victim, the most important (most points, then the closest rank) on the skull and so on down to the boots. With fewer victims than pieces the names start over from the top; an unknown victim leaves the item's own name. Character sacrifices now keep the victim's closeness rank so the order can tell victims worth the same points apart. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/BloodArmour.java | 53 ++++++++++++++- .../tfminecraft/magic/blood/BloodRecord.java | 11 ++- .../magic/blood/CharacterRiteService.java | 9 +-- .../tfminecraft/magic/blood/ChoiceConfig.java | 16 +++-- .../tfminecraft/magic/blood/FinalService.java | 2 +- src/main/resources/bloodmagic.yml | 11 +-- .../magic/blood/BloodLooksTest.java | 68 +++++++++++++++++++ 7 files changed, 152 insertions(+), 18 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java b/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java index 3724231..d3a62e8 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java @@ -1,6 +1,7 @@ package net.tfminecraft.magic.blood; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import org.bukkit.entity.Player; @@ -12,6 +13,7 @@ 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, @@ -28,16 +30,56 @@ 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) { - out.add(bind(BloodLooks.apply(item, piece.look()), characterId)); + 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); @@ -58,7 +100,12 @@ public static boolean is(ItemStack item) { /** 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) { - for (ItemStack piece : create(path, 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); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java b/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java index b350a7c..967de96 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java @@ -32,8 +32,15 @@ public final class BloodRecord { /** A failed sacrifice whose backlash waits for the mage to come online. */ private boolean pendingBacklash; - /** One character sacrificed at the altar. */ - public record Sacrifice(String character, String account, String stage, int points, long at) {} + /** + * 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() {} diff --git a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java index 26a61e7..cb2e995 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java @@ -47,7 +47,7 @@ public enum Bind { } private record Rite(UUID victim, String mageCharacter, String victimCharacter, String victimAccount, - int points, long endsAt) {} + int points, int rank, long endsAt) {} private static final Map RITES = new HashMap<>(); private static final Set SACRIFICING = new HashSet<>(); @@ -96,13 +96,14 @@ public static Bind bind(Player mage, Player victim) { return Bind.BUSY; } String victimAccount = victim.getUniqueId().toString(); - int points = record.getStage() == BloodStage.HUNT ? HuntConfig.points(BondService.rank(mage, victimCharacter)) : 0; + int rank = record.getStage() == BloodStage.HUNT ? 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, clock.getAsLong() + HuntConfig.riteSeconds() * 1000L)); + points, rank, clock.getAsLong() + HuntConfig.riteSeconds() * 1000L)); victim.sendMessage(Messages.get("blood.rite.dread")); BloodFx.play("rite_bound", top, victim); return Bind.OK; @@ -263,7 +264,7 @@ private static void complete(Player mage, Player victim, Rite rite, BloodAltar a } BloodStage stage = record.getStage(); record.getSacrifices().add(new BloodRecord.Sacrifice(key, rite.victimAccount(), stage.name(), - rite.points(), clock.getAsLong())); + 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 " diff --git a/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java index 965c77c..edc8c78 100644 --- a/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java @@ -53,8 +53,15 @@ static void reset() { ARMOUR.clear(); } - /** One bound armour piece: the item (TLibs path) and an optional look (TLibs ia. path) put over it. */ - public record Piece(String item, String look) {} + /** + * 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(); @@ -91,13 +98,14 @@ static void load(ConfigurationSection choice, ConfigurationSection classes, Conf } } - /** A plain item path, or { item: ..., look: ... }; null when there is no item. */ + /** 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); + 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; } diff --git a/src/main/java/net/tfminecraft/magic/blood/FinalService.java b/src/main/java/net/tfminecraft/magic/blood/FinalService.java index 84b9d36..0c6c19c 100644 --- a/src/main/java/net/tfminecraft/magic/blood/FinalService.java +++ b/src/main/java/net/tfminecraft/magic/blood/FinalService.java @@ -36,7 +36,7 @@ public static void begin(Player mage, BloodRecord record) { record.setStage(BloodStage.FINAL); if (record.getPath() == BloodPath.TRUE) { ClassUnlockBridge.unlock(mage, ChoiceConfig.classFor(BloodPath.TRUE)); - BloodArmour.give(mage, BloodPath.TRUE, record.getCharacterId()); + BloodArmour.give(mage, BloodPath.TRUE, record.getCharacterId(), BloodArmour.victims(record)); mage.sendMessage(Messages.get("blood.hunt.complete")); BloodFx.play("hunt_complete", mage.getLocation(), mage); } diff --git a/src/main/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index b7a4fdb..8b01c4d 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -323,6 +323,9 @@ classes: # 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 } @@ -330,10 +333,10 @@ armour: - { 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 } - - { item: m.mage_armors.sanguinar_chestplate, look: ia.tfmc_blood:sanguinar_chestplate } - - { item: m.mage_armors.sanguinar_leggings, look: ia.tfmc_blood:sanguinar_leggings } - - { item: m.mage_armors.sanguinar_boots, look: ia.tfmc_blood:sanguinar_boots } + - { 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" } # Sounds and particles for every moment of the path. Each moment is a list of layers: # sound: (volume, pitch) diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodLooksTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodLooksTest.java index 1630206..fb1d024 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodLooksTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodLooksTest.java @@ -6,13 +6,21 @@ 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; @@ -77,6 +85,66 @@ void armourPiecesTakeTheirLooks() throws Exception { } } + @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() { From 547b82455616ae836f822f33a95f1e87d294ae18 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:58:29 +0100 Subject: [PATCH 16/55] Give blood-path characters a permission for their skins permissions: in bloodmagic.yml names a permission each path's character holds while it is played (magic.blood.bloodsworn, magic.blood.sanguinar), so ArmourShop can show the Bloodsworn skins only to Bloodsworn. It follows the character: set on session start, the choice and staff path/reset, removed on session end and quit. ArmourShop skins replace a piece's ItemsAdder compound and drop its override_auto_update, so blood armour is marked again whenever an inventory closes, armour changes or a session starts. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/BloodArmour.java | 21 +++ .../tfminecraft/magic/blood/BloodChoice.java | 1 + .../tfminecraft/magic/blood/BloodCommand.java | 2 + .../tfminecraft/magic/blood/BloodConfig.java | 1 + .../magic/blood/BloodListener.java | 12 ++ .../tfminecraft/magic/blood/BloodMagic.java | 5 +- .../tfminecraft/magic/blood/BloodPerms.java | 66 +++++++++ .../tfminecraft/magic/blood/ChoiceConfig.java | 18 +++ src/main/resources/bloodmagic.yml | 6 + .../magic/blood/BloodMagicTest.java | 1 + .../magic/blood/BloodPermsTest.java | 127 ++++++++++++++++++ 11 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodPerms.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/BloodPermsTest.java diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java b/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java index d3a62e8..ade1871 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java @@ -137,9 +137,30 @@ public static int enforce(Player player) { 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/BloodChoice.java b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java index 3c87263..8d69ebf 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java @@ -51,6 +51,7 @@ public static boolean answer(Player player, BloodPath path) { 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); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java index b282ad9..1195cd6 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java @@ -119,6 +119,7 @@ private static void path(CommandSender sender, Player player, String character, BloodRecord record = BloodRecords.getOrCreate(character, player.getUniqueId().toString()); record.setPath(path); saved(sender, record); + BloodPerms.refresh(player); AltarService.refreshFor(character); sender.sendMessage(Messages.get("blood.admin.path_set", "player", player.getName(), "path", path.name())); } @@ -245,6 +246,7 @@ private static void reset(CommandSender sender, Player player, String character) AltarService.purify(altar, "reset by " + sender.getName()); } BloodRecords.delete(character); + BloodPerms.refresh(player); sender.sendMessage(Messages.get("blood.admin.reset", "player", player.getName())); } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java index c80d0fc..8379170 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java @@ -105,6 +105,7 @@ public static boolean load(File file, Logger 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); BloodFx.load(config.getConfigurationSection("fx"), log); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index d6d367a..e4a0be4 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -20,6 +20,7 @@ import org.bukkit.event.entity.EntityDeathEvent; import org.bukkit.event.entity.EntityPickupItemEvent; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.event.inventory.InventoryDragEvent; import org.bukkit.event.entity.EntityExplodeEvent; import org.bukkit.event.entity.PlayerLeashEntityEvent; @@ -130,6 +131,14 @@ public void onMenuDrag(InventoryDragEvent event) { ChoiceMenu.onDrag(event); } + /** A skin may have been put on a blood piece in that 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) { @@ -239,6 +248,7 @@ public void onQuit(PlayerQuitEvent event) { WhisperService.forget(event.getPlayer().getUniqueId()); AnimalRiteService.releaseFor(event.getPlayer().getUniqueId()); CarryService.drop(event.getPlayer().getUniqueId()); + BloodPerms.clear(event.getPlayer()); } @EventHandler @@ -250,6 +260,7 @@ public void onSessionStart(CharacterSessionStartEvent event) { String character = event.getSession().ref().characterId().toString(); BondService.load(character); BloodBoons.apply(event.getPlayer()); + BloodPerms.refresh(event.getPlayer()); FinalService.check(event.getPlayer()); } @@ -257,6 +268,7 @@ public void onSessionStart(CharacterSessionStartEvent event) { 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()); } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index aeb959d..dc24947 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -89,13 +89,16 @@ static void ambient() { } public static boolean loadConfig(File dataFolder, Logger log) { - return BloodConfig.load(new File(dataFolder, "bloodmagic.yml"), log); + boolean ok = BloodConfig.load(new File(dataFolder, "bloodmagic.yml"), log); + BloodPerms.refreshAll(); + return ok; } public static void stop() { for (Player player : Bukkit.getOnlinePlayers()) { BloodBoons.clear(player); } + BloodPerms.clearAll(); CarryService.clear(); CharacterRiteService.clear(); BondService.clear(); 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..670866f --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodPerms.java @@ -0,0 +1,66 @@ +package net.tfminecraft.magic.blood; + +import java.util.HashMap; +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 permission a path's character holds while it is played (permissions: in bloodmagic.yml), 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 permission, replacing any earlier one. */ + public static void refresh(Player player) { + clear(player); + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); + String permission = record != null ? ChoiceConfig.permissionFor(record.getPath()) : null; + if (permission == null) { + return; + } + PermissionAttachment attachment = player.addAttachment(Magic.plugin); + 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/ChoiceConfig.java b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java index edc8c78..abc5a1b 100644 --- a/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java @@ -35,6 +35,7 @@ public enum Slot { private static int devoteeInjuries; 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(); @@ -51,6 +52,7 @@ static void reset() { devoteeInjuries = 2; CLASSES.clear(); ARMOUR.clear(); + PERMISSIONS.clear(); } /** @@ -127,6 +129,22 @@ public static String classFor(BloodPath path) { return CLASSES.get(path); } + /** Reads permissions: the permission each path's character holds while played. */ + static void loadPermissions(ConfigurationSection permissions) { + PERMISSIONS.clear(); + for (BloodPath path : List.of(BloodPath.DEVOTEE, BloodPath.TRUE)) { + String key = path.name().toLowerCase(Locale.ROOT); + if (permissions != null && permissions.isString(key) && !permissions.getString(key).isBlank()) { + PERMISSIONS.put(path, permissions.getString(key).trim()); + } + } + } + + /** The permission a path's character holds while played, or null. */ + public static String permissionFor(BloodPath path) { + return PERMISSIONS.get(path); + } + /** The bound armour pieces a path receives. */ public static List armourFor(BloodPath path) { return ARMOUR.getOrDefault(path, List.of()); diff --git a/src/main/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index 8b01c4d..2bed367 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -320,6 +320,12 @@ classes: devotee: bloodsworn "true": sanguinar +# Permissions a character on each path holds while it is played, e.g. to see the path's +# ArmourShop skin category. They follow the character: another character of the same player has none. +permissions: + devotee: magic.blood.bloodsworn + "true": magic.blood.sanguinar + # 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 } diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java index a0d08ac..a534f84 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java @@ -41,6 +41,7 @@ void startLoadsDataSettlesAltarsAndSweepsOnSchedule() throws Exception { assertEquals(Material.BARRIER, target.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)); 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..1068612 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/BloodPermsTest.java @@ -0,0 +1,127 @@ +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", + " \"true\": ' '", + " none: magic.blood.none", + "")); + BloodConfig.load(temp.resolve("p.yml").toFile(), fx.logger); + assertEquals("magic.blood.bloodsworn", ChoiceConfig.permissionFor(BloodPath.DEVOTEE)); + assertNull(ChoiceConfig.permissionFor(BloodPath.TRUE)); + assertNull(ChoiceConfig.permissionFor(BloodPath.NONE)); + Files.writeString(temp.resolve("q.yml"), "enabled: true\npermissions:\n devotee: 3\n"); + BloodConfig.load(temp.resolve("q.yml").toFile(), fx.logger); + assertNull(ChoiceConfig.permissionFor(BloodPath.DEVOTEE)); + 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")); + 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().filter(p -> p.getAttachment() != null) + .forEach(p -> mage.removeAttachment(p.getAttachment())); + 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)); + } + } +} From b8a3a0048cf178df64b25e9a40cfc3c4bca3cc3d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:25:51 +0100 Subject: [PATCH 17/55] Let a blood path hold more than one permission permissions: in bloodmagic.yml takes one permission or a list, so a Bloodsworn character can hold both magic.blood.bloodsworn (its skins) and magic.wild (the hidden Wild Mage Sets skin category). Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/BloodPerms.java | 13 ++++++---- .../tfminecraft/magic/blood/ChoiceConfig.java | 24 +++++++++++++------ src/main/resources/bloodmagic.yml | 7 +++--- .../magic/blood/BloodPermsTest.java | 21 +++++++++------- 4 files changed, 42 insertions(+), 23 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodPerms.java b/src/main/java/net/tfminecraft/magic/blood/BloodPerms.java index 670866f..5620b02 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodPerms.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodPerms.java @@ -1,6 +1,7 @@ package net.tfminecraft.magic.blood; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -11,7 +12,7 @@ import net.tfminecraft.magic.Magic; /** - * The permission a path's character holds while it is played (permissions: in bloodmagic.yml), e.g. + * The permissions a path's character holds while it is played (permissions: in bloodmagic.yml), e.g. * to see that path's ArmourShop skins. It follows the character, not the account: switching to another * character takes it away. */ @@ -21,16 +22,18 @@ public final class BloodPerms { private BloodPerms() {} - /** Gives the player their current character's path permission, replacing any earlier one. */ + /** 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)); - String permission = record != null ? ChoiceConfig.permissionFor(record.getPath()) : null; - if (permission == null) { + List permissions = record != null ? ChoiceConfig.permissionsFor(record.getPath()) : List.of(); + if (permissions.isEmpty()) { return; } PermissionAttachment attachment = player.addAttachment(Magic.plugin); - attachment.setPermission(permission, true); + for (String permission : permissions) { + attachment.setPermission(permission, true); + } ATTACHED.put(player.getUniqueId(), attachment); } diff --git a/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java index abc5a1b..054bc78 100644 --- a/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java @@ -35,7 +35,7 @@ public enum Slot { private static int devoteeInjuries; 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); + private static final Map> PERMISSIONS = new EnumMap<>(BloodPath.class); static { reset(); @@ -129,20 +129,30 @@ public static String classFor(BloodPath path) { return CLASSES.get(path); } - /** Reads permissions: the permission each path's character holds while played. */ + /** 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); - if (permissions != null && permissions.isString(key) && !permissions.getString(key).isBlank()) { - PERMISSIONS.put(path, permissions.getString(key).trim()); + 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 permission a path's character holds while played, or null. */ - public static String permissionFor(BloodPath path) { - return PERMISSIONS.get(path); + /** 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. */ diff --git a/src/main/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index 2bed367..1add3b9 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -320,10 +320,11 @@ classes: devotee: bloodsworn "true": sanguinar -# Permissions a character on each path holds while it is played, e.g. to see the path's -# ArmourShop skin category. They follow the character: another character of the same player has none. +# 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 + devotee: [magic.blood.bloodsworn, magic.wild] "true": magic.blood.sanguinar # Bound, unbreakable armour. Devotees get theirs at the question; the true path when the hunt is done. diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodPermsTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodPermsTest.java index 1068612..a6fd1b3 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodPermsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodPermsTest.java @@ -45,17 +45,21 @@ void aPathsCharacterHoldsItsPermissionOnlyWhilePlayed() throws Exception { Files.writeString(temp.resolve("p.yml"), String.join("\n", "enabled: true", "permissions:", - " devotee: magic.blood.bloodsworn", + " devotee: [magic.blood.bloodsworn, ' ', 4, magic.wild]", " \"true\": ' '", " none: magic.blood.none", "")); BloodConfig.load(temp.resolve("p.yml").toFile(), fx.logger); - assertEquals("magic.blood.bloodsworn", ChoiceConfig.permissionFor(BloodPath.DEVOTEE)); - assertNull(ChoiceConfig.permissionFor(BloodPath.TRUE)); - assertNull(ChoiceConfig.permissionFor(BloodPath.NONE)); - Files.writeString(temp.resolve("q.yml"), "enabled: true\npermissions:\n devotee: 3\n"); + 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); - assertNull(ChoiceConfig.permissionFor(BloodPath.DEVOTEE)); + 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"); @@ -68,6 +72,7 @@ void aPathsCharacterHoldsItsPermissionOnlyWhilePlayed() throws Exception { 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")); @@ -77,8 +82,8 @@ void aPathsCharacterHoldsItsPermissionOnlyWhilePlayed() throws Exception { BloodPerms.refreshAll(); assertTrue(mage.hasPermission("magic.blood.bloodsworn")); - mage.getEffectivePermissions().stream().filter(p -> p.getAttachment() != null) - .forEach(p -> mage.removeAttachment(p.getAttachment())); + mage.getEffectivePermissions().stream().map(p -> p.getAttachment()).filter(a -> a != null).distinct().toList() + .forEach(mage::removeAttachment); BloodPerms.clear(mage); assertFalse(BloodPerms.holds(mage.getUniqueId())); From 58cf35bb74ba1550cd694c7da30cbc6f25d5b484 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:11:11 +0100 Subject: [PATCH 18/55] Open bound animals in one stroke and quiet the first voice The binding mage's own ritual knife now kills a bound animal with one hit (the hit is made lethal at HIGHEST), with a new animal_slain moment: a sweep, a wet strike and a spray of blood. Any other hit keeps its damage, on people too. The Ritual Knife is now an MMOItems lore weapon (m.lore_weapons.ritual_knife), so its name, lore and look come from MMOItems. The voice's first lines no longer name Lorvear: only small hints, like Alaric. Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/AnimalRiteService.java | 19 +++++++++ .../magic/blood/BloodListener.java | 7 ++++ src/main/resources/bloodmagic.yml | 34 +++++++++------- .../magic/blood/AnimalRiteTest.java | 40 +++++++++++++++++++ 4 files changed, 86 insertions(+), 14 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java index f2c4fb5..0821a9d 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java @@ -13,6 +13,7 @@ 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; @@ -120,6 +121,24 @@ public static boolean isBound(UUID animal) { return BOUND.containsKey(animal); } + /** + * The binding mage's own ritual knife opens a bound animal in one stroke: the hit is made lethal, + * so the death and the sacrifice follow at once. Every other hit keeps its own damage. + */ + public static void onHit(EntityDamageByEntityEvent event) { + Bound bound = BOUND.get(event.getEntity().getUniqueId()); + if (bound == null || !(event.getDamager() instanceof Player player) || !player.getUniqueId().equals(bound.mage())) { + return; + } + LivingEntity animal = (LivingEntity) event.getEntity(); + ItemStack knife = player.getInventory().getItemInMainHand(); + if (!BloodItems.is(knife, BloodItemKind.RITUAL_KNIFE) || !bound.character().equalsIgnoreCase(BloodItems.owner(knife))) { + return; + } + event.setDamage(animal.getHealth() + animal.getAbsorptionAmount() + 1000); + BloodFx.play("animal_slain", animal.getLocation(), player); + } + /** A bound animal died: the sacrifice counts only by the binding mage's own knife. */ public static void onDeath(EntityDeathEvent event) { LivingEntity animal = event.getEntity(); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index e4a0be4..3572581 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -17,6 +17,7 @@ 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.EntityDeathEvent; import org.bukkit.event.entity.EntityPickupItemEvent; import org.bukkit.event.inventory.InventoryClickEvent; @@ -232,6 +233,12 @@ public void onCharacterDeath(CharacterDeathEvent event) { 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); diff --git a/src/main/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index 1add3b9..42c7155 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -15,12 +15,11 @@ items: - "#8c8c8c§oA slab of old carved stone, worn smooth" - "#8c8c8c§oby hands that are long gone." - "#5c5c5c§oIt is heavier than it should be." + # The Ritual Knife is an MMOItems lore weapon (name, lore and look come from MMOItems). ritual_knife: - path: ia.tfmc_blood:ritual_knife - name: "#bfbfbfSkinning Knife" - lore: - - "#8c8c8c§oA plain knife with a well-worn grip." - - "#8c8c8c§oKept very, very sharp." + path: m.lore_weapons.ritual_knife + name: "" + lore: [] blood_knife: path: ia.tfmc_blood:blood_knife name: "#8a0303Vein-Reaver" @@ -168,15 +167,15 @@ voices: pitch: 0.5 lines: whispered: - - "...there. You heard it. A heartbeat under the stone that is not your own." - - "Hush, child. I am Lorvear. The first to drink, and father to all who drank after. Some still call me the Blood Father." - - "So much warm blood walks the roads of Cerrith. Spilled in ditches and brawls and childbeds. Wasted on the ground." - - "I could teach you to waste none of it. I taught Alaric. I taught a hundred before him. I can teach you." - - "Go down, deep, where the sky cannot follow. No lord's banner, no warden's eye. Lay my stone there." - - "Bring living beasts down to the stone. Hold them still with the plain knife, and open them while they breathe." - - "Not beasts of your own, child. A gift that costs nothing is no gift. Lead them from another's pen while the keeper sleeps." - - "Seven kinds of beast, one each day and no sooner. I was old before the first kingdom rose. I can be patient." - - "Look in your pack. Your father has been generous. Do not make him regret it." + - "...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." animal: - "The stone is thirsty, child. Can you not hear it?" - "Every pen in the valley is full, and every shepherd sleeps." @@ -420,6 +419,13 @@ fx: animal_escaped: - { sound: block.chain.break, volume: 0.8, pitch: 0.8 } - { particle: POOF, count: 8, spread: 0.3, y: 0.5 } + animal_slain: + - { sound: entity.player.attack.sweep, volume: 1.0, pitch: 0.7 } + - { sound: item.trident.hit, volume: 1.0, pitch: 0.6 } + - { sound: entity.generic.drink, volume: 0.8, 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: 1.0, pitch: 0.5 } - { sound: block.honey_block.slide, volume: 1.0, pitch: 0.5 } diff --git a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java index dc68dfd..68b8d53 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java @@ -178,6 +178,46 @@ void wildAnimalsCountWhenTheConfigDoesNotRequireTheft() throws Exception { assertTrue(BloodNotes.all().isEmpty()); } + @Test + void theOwnKnifeOpensABoundAnimalInOneStroke() { + 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 hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(mage, cow, + org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, DamageSource.builder(DamageType.PLAYER_ATTACK).build(), 3); + AnimalRiteService.onHit(hit); + assertEquals(3, hit.getDamage()); + + 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 = new org.bukkit.event.entity.EntityDamageByEntityEvent(damager, cow, + org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, DamageSource.builder(DamageType.PLAYER_ATTACK).build(), 3); + AnimalRiteService.onHit(foreign); + assertEquals(3, foreign.getDamage()); + } + mage.getInventory().setItemInMainHand(new ItemStack(Material.IRON_SWORD)); + var plain = new org.bukkit.event.entity.EntityDamageByEntityEvent(mage, cow, + org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, DamageSource.builder(DamageType.PLAYER_ATTACK).build(), 3); + AnimalRiteService.onHit(plain); + assertEquals(3, plain.getDamage()); + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, BloodFixture.id("other"))); + AnimalRiteService.onHit(plain); + assertEquals(3, plain.getDamage()); + + mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, id)); + var stroke = new org.bukkit.event.entity.EntityDamageByEntityEvent(mage, cow, + org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, DamageSource.builder(DamageType.PLAYER_ATTACK).build(), 3); + new BloodListener().onAnimalHit(stroke); + assertTrue(stroke.getDamage() >= cow.getHealth() + 1000); + + var onPlayer = new org.bukkit.event.entity.EntityDamageByEntityEvent(mage, other, + org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, DamageSource.builder(DamageType.PLAYER_ATTACK).build(), 3); + AnimalRiteService.onHit(onPlayer); + assertEquals(3, onPlayer.getDamage()); + } + @Test void theOwnKnifeKillCountsLeavesCluesAndCorrupts() { UUID keeper = UUID.randomUUID(); From 250254edfef624e173060e842f7ea41448cc009b Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:27:50 +0100 Subject: [PATCH 19/55] Corrupt the altar room only with blocks a bloodmagic shrine counts Redstone ore and dust, netherrack and nether bricks, crimson nylium, roots, fungus and weeping vines, nether wart blocks and red candles; no more cobwebs, wither roses, skulls, mossy or cracked stone or bone blocks. Co-Authored-By: Claude Opus 5.5 --- src/main/resources/bloodmagic.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/main/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml index 42c7155..d721a5c 100644 --- a/src/main/resources/bloodmagic.yml +++ b/src/main/resources/bloodmagic.yml @@ -265,19 +265,22 @@ corruption: blocks_per_sacrifice: 3 ambient_every_minutes: 30 # 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, COBBLESTONE, ANDESITE, DIORITE, GRANITE, STONE_BRICKS, DEEPSLATE, COBBLED_DEEPSLATE, TUFF], to: [CRACKED_STONE_BRICKS, MOSSY_COBBLESTONE] } - - { min_level: 2, from: [STONE, COBBLESTONE, STONE_BRICKS, CRACKED_STONE_BRICKS, MOSSY_COBBLESTONE, DEEPSLATE, DIRT], to: [NETHER_BRICKS, CRACKED_NETHER_BRICKS, NETHERRACK] } + - { 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, BONE_BLOCK, NETHER_WART_BLOCK] } + - { min_level: 4, from: [NETHER_BRICKS, CRACKED_NETHER_BRICKS, STONE_BRICKS, STONE], to: [RED_NETHER_BRICKS, NETHER_WART_BLOCK, CHISELED_NETHER_BRICKS] } # Air spots get decorated: at floor = on top of a solid block, at ceiling = hanging under one. decorate: - { min_level: 0, at: floor, to: [REDSTONE_WIRE, REDSTONE_WIRE, RED_CANDLE] } - - { min_level: 1, at: floor, to: [COBWEB] } - - { min_level: 2, at: floor, to: [WITHER_ROSE] } + - { 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, NETHER_SPROUTS] } - - { min_level: 4, at: floor, to: [SKELETON_SKULL, BONE_BLOCK] } + - { min_level: 3, at: floor, to: [CRIMSON_ROOTS, CRIMSON_FUNGUS] } + - { min_level: 4, at: floor, to: [NETHER_WART_BLOCK, RED_CANDLE] } # The god's question: right-click your altar with your blood knife once the seventh kind is given. choice: From 6e8f937de14f7634c3a1311917e7de99eb73ba89 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:52:14 +0100 Subject: [PATCH 20/55] Move the blood path's config into wildmagic/bloodmagic bloodmagic.yml and the blood: block of messages.yml become one folder, wildmagic/bloodmagic, with a file per topic: settings, items, voices, animals, corruption, choice, armour, fx and messages. Every .yml there but messages.yml is merged into one config; messages.yml is laid over Magic's messages (live keys first, its bundled copy as fallback). A leftover bloodmagic.yml is reported and no longer read. wildmagic/ is meant to take the other wild schools the same way. Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/magic/Magic.java | 10 +- .../java/net/tfminecraft/magic/Messages.java | 39 +- .../tfminecraft/magic/blood/AnimalConfig.java | 4 +- .../tfminecraft/magic/blood/BloodConfig.java | 55 +- .../net/tfminecraft/magic/blood/BloodFx.java | 4 +- .../tfminecraft/magic/blood/BloodMagic.java | 11 +- .../tfminecraft/magic/blood/BloodPerms.java | 2 +- .../magic/blood/CorruptionConfig.java | 8 +- .../tfminecraft/magic/blood/HuntConfig.java | 4 +- .../magic/blood/WhisperConfig.java | 6 +- src/main/resources/bloodmagic.yml | 563 ------------------ src/main/resources/messages.yml | 225 ------- .../wildmagic/bloodmagic/animals.yml | 23 + .../resources/wildmagic/bloodmagic/armour.yml | 20 + .../resources/wildmagic/bloodmagic/choice.yml | 37 ++ .../wildmagic/bloodmagic/corruption.yml | 32 + .../resources/wildmagic/bloodmagic/fx.yml | 217 +++++++ .../resources/wildmagic/bloodmagic/items.yml | 26 + .../wildmagic/bloodmagic/messages.yml | 227 +++++++ .../wildmagic/bloodmagic/settings.yml | 123 ++++ .../resources/wildmagic/bloodmagic/voices.yml | 103 ++++ .../magic/TickAndMessagesTest.java | 32 + .../magic/blood/AltarServiceTest.java | 2 +- .../magic/blood/AnimalRiteTest.java | 2 +- .../magic/blood/BloodCommandTest.java | 2 +- .../magic/blood/BloodConfigTest.java | 24 +- .../tfminecraft/magic/blood/BloodFixture.java | 2 +- .../tfminecraft/magic/blood/BloodFxTest.java | 4 +- .../magic/blood/BloodListenerTest.java | 2 +- .../magic/blood/BloodMagicTest.java | 3 + .../magic/blood/BoonsAndToolsTest.java | 2 +- .../tfminecraft/magic/blood/CarryTest.java | 4 +- .../tfminecraft/magic/blood/ChoiceTest.java | 2 +- .../magic/blood/CorruptionAndCluesTest.java | 2 +- .../magic/blood/HuntAndFinalsTest.java | 4 +- .../magic/blood/HuntConfigTest.java | 2 +- .../magic/blood/ReviewFixesTest.java | 2 +- .../magic/blood/WhisperConfigTest.java | 2 +- .../magic/blood/WhisperServiceTest.java | 2 +- 39 files changed, 995 insertions(+), 839 deletions(-) delete mode 100644 src/main/resources/bloodmagic.yml create mode 100644 src/main/resources/wildmagic/bloodmagic/animals.yml create mode 100644 src/main/resources/wildmagic/bloodmagic/armour.yml create mode 100644 src/main/resources/wildmagic/bloodmagic/choice.yml create mode 100644 src/main/resources/wildmagic/bloodmagic/corruption.yml create mode 100644 src/main/resources/wildmagic/bloodmagic/fx.yml create mode 100644 src/main/resources/wildmagic/bloodmagic/items.yml create mode 100644 src/main/resources/wildmagic/bloodmagic/messages.yml create mode 100644 src/main/resources/wildmagic/bloodmagic/settings.yml create mode 100644 src/main/resources/wildmagic/bloodmagic/voices.yml diff --git a/src/main/java/net/tfminecraft/magic/Magic.java b/src/main/java/net/tfminecraft/magic/Magic.java index fe107ce..b9798e5 100644 --- a/src/main/java/net/tfminecraft/magic/Magic.java +++ b/src/main/java/net/tfminecraft/magic/Magic.java @@ -325,7 +325,15 @@ private void createConfigs() { "gui.yml", "skills.yml", "charges.yml", - "bloodmagic.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/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/AnimalConfig.java b/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java index 35d93aa..f7d2ede 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java @@ -8,7 +8,7 @@ import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.EntityType; -/** The animal: section of bloodmagic.yml. */ +/** The animal: section of the wildmagic/bloodmagic config. */ public final class AnimalConfig { private static int speciesRequired; @@ -57,7 +57,7 @@ static void load(ConfigurationSection section, Logger log) { try { types.add(EntityType.valueOf(raw.trim().toUpperCase(Locale.ROOT))); } catch (IllegalArgumentException ex) { - log.warning("[Magic] bloodmagic.yml animal.species: unknown entity '" + raw + "'"); + log.warning("[Magic] blood magic config animal.species: unknown entity '" + raw + "'"); } } species = Set.copyOf(types); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java index 8379170..5b68ef6 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java @@ -16,7 +16,11 @@ import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.YamlConfiguration; -/** Settings from bloodmagic.yml. Defaults keep the feature safe when a key is missing. */ +/** + * Settings from the wildmagic/bloodmagic folder (settings, items, voices, animals, corruption, choice, + * armour 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. */ @@ -43,26 +47,49 @@ public record ItemDef(String path, String name, List lore) {} private BloodConfig() {} - /** Loads the file; a missing or broken file leaves the feature disabled. Returns false on error. */ - public static boolean load(File file, Logger log) { + /** 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 (file == null || !file.isFile()) { - log.warning("[Magic] bloodmagic.yml missing; blood magic disabled"); + if (source == null || !source.exists()) { + log.warning("[Magic] wildmagic/bloodmagic config missing; blood magic disabled"); return true; } YamlConfiguration config = new YamlConfiguration(); - try { - config.load(file); - } catch (IOException | InvalidConfigurationException ex) { - log.severe("[Magic] Failed to load bloodmagic.yml: " + ex.getMessage()); - return false; + 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] bloodmagic.yml items." + kind.key() + " missing; using a plain default"); + log.warning("[Magic] blood magic config items." + kind.key() + " missing; using a plain default"); ITEMS.put(kind, new ItemDef(kind.fallbackPath(), "", List.of())); continue; } @@ -74,7 +101,7 @@ public static boolean load(File file, Logger log) { String block = config.getString("altar.block", "BARRIER"); Material material = Material.matchMaterial(block); if (material == null || !material.isBlock()) { - log.warning("[Magic] bloodmagic.yml altar.block '" + block + "' is not a block; using BARRIER"); + log.warning("[Magic] blood magic config altar.block '" + block + "' is not a block; using BARRIER"); ok = false; } else { altarBlock = material; @@ -89,7 +116,7 @@ public static boolean load(File file, Logger log) { try { LEVELS.put(level, Integer.parseInt(value)); } catch (NumberFormatException ex) { - log.warning("[Magic] bloodmagic.yml altar.levels." + level + " is not a number"); + log.warning("[Magic] blood magic config altar.levels." + level + " is not a number"); } }); worlds = lower(config.getStringList("placement.worlds")); @@ -125,7 +152,7 @@ private static boolean readIntMap(ConfigurationSection section, String path, Log try { sink.accept(Integer.parseInt(key.trim()), section.getString(key, "")); } catch (NumberFormatException ex) { - log.warning("[Magic] bloodmagic.yml " + path + " key '" + key + "' is not a level number"); + log.warning("[Magic] blood magic config " + path + " key '" + key + "' is not a level number"); ok = false; } } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodFx.java b/src/main/java/net/tfminecraft/magic/blood/BloodFx.java index b734aad..02ff626 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodFx.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodFx.java @@ -19,7 +19,7 @@ import net.tfminecraft.magic.Magic; /** - * Sounds and particles for the blood path, by moment (fx.moments in bloodmagic.yml). Each moment is a + * 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. @@ -63,7 +63,7 @@ static void load(ConfigurationSection fx, Logger log) { if (layer != null) { layers.add(layer); } else { - log.warning("[Magic] bloodmagic.yml fx." + moment + " has a layer without a valid sound or particle: " + raw); + 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)); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index dc24947..ac8f11b 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -8,6 +8,7 @@ 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; @@ -88,8 +89,16 @@ static void ambient() { } } + /** 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) { - boolean ok = BloodConfig.load(new File(dataFolder, "bloodmagic.yml"), 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; } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodPerms.java b/src/main/java/net/tfminecraft/magic/blood/BloodPerms.java index 5620b02..f7305ca 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodPerms.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodPerms.java @@ -12,7 +12,7 @@ import net.tfminecraft.magic.Magic; /** - * The permissions a path's character holds while it is played (permissions: in bloodmagic.yml), e.g. + * 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. */ diff --git a/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java b/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java index 46020d0..f11049f 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java @@ -12,7 +12,7 @@ import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; -/** The corruption: section of bloodmagic.yml. */ +/** 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. */ @@ -60,7 +60,7 @@ static void load(ConfigurationSection section, Logger log) { 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] bloodmagic.yml corruption.levels key '" + key + "' is not a level number"); + log.warning("[Magic] blood magic config corruption.levels key '" + key + "' is not a level number"); } } if (LEVELS.isEmpty()) { @@ -90,7 +90,7 @@ private static Rule rule(Map raw, Placement placement, Logger log) { 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] bloodmagic.yml corruption rule without usable blocks skipped"); + 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)); @@ -104,7 +104,7 @@ private static List materials(Object raw, Logger log) { for (Object entry : list) { Material material = Material.matchMaterial(String.valueOf(entry).trim().toUpperCase(Locale.ROOT)); if (material == null || !material.isBlock()) { - log.warning("[Magic] bloodmagic.yml corruption: '" + entry + "' is not a block"); + log.warning("[Magic] blood magic config corruption: '" + entry + "' is not a block"); } else { out.add(material); } diff --git a/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java b/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java index e3f1a4c..511451c 100644 --- a/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java @@ -94,7 +94,7 @@ static void load(ConfigurationSection hunt, ConfigurationSection bonds, Configur 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] bloodmagic.yml hunt.points.ranks entry needs up_to and points"); + log.warning("[Magic] blood magic config hunt.points.ranks entry needs up_to and points"); } } RANK_POINTS.sort(Comparator.comparingInt(RankPoints::upTo)); @@ -133,7 +133,7 @@ private static TreeMap> boons(ConfigurationSection index = -1; } if (index < 1 || stats == null) { - log.warning("[Magic] bloodmagic.yml finals.boons entry '" + key + "' skipped"); + log.warning("[Magic] blood magic config finals.boons entry '" + key + "' skipped"); continue; } Map values = new LinkedHashMap<>(); diff --git a/src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java b/src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java index 5c650ee..71f22c1 100644 --- a/src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java @@ -12,7 +12,7 @@ import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.EntityType; -/** The whisper meter and the two voices, from the whispers: and voices: sections of bloodmagic.yml. */ +/** 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. */ @@ -91,7 +91,7 @@ static void load(ConfigurationSection whispers, ConfigurationSection voices, Log try { types.add(EntityType.valueOf(raw.trim().toUpperCase(Locale.ROOT))); } catch (IllegalArgumentException ex) { - log.warning("[Magic] bloodmagic.yml whispers.animal_types: unknown entity '" + raw + "'"); + log.warning("[Magic] blood magic config whispers.animal_types: unknown entity '" + raw + "'"); } } animals = Set.copyOf(types); @@ -120,7 +120,7 @@ private static Voice voice(ConfigurationSection section, Logger log) { for (String key : stages.getKeys(false)) { BloodStage stage = BloodStage.parse(key); if (stage == null) { - log.warning("[Magic] bloodmagic.yml voice lines: unknown stage '" + key + "'"); + log.warning("[Magic] blood magic config voice lines: unknown stage '" + key + "'"); continue; } lines.put(stage, List.copyOf(new ArrayList<>(stages.getStringList(key)))); diff --git a/src/main/resources/bloodmagic.yml b/src/main/resources/bloodmagic.yml deleted file mode 100644 index d721a5c..0000000 --- a/src/main/resources/bloodmagic.yml +++ /dev/null @@ -1,563 +0,0 @@ -# Blood Magic progression. Hidden content: never document it on the wiki. -# Jar does not overwrite an existing live copy. 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 - -# 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." - # The Ritual Knife is an MMOItems lore weapon (name, lore and look come from MMOItems). - ritual_knife: - path: m.lore_weapons.ritual_knife - name: "" - lore: [] - blood_knife: - path: ia.tfmc_blood:blood_knife - name: "#8a0303Vein-Reaver" - lore: - - "#5c0202§oCurved like a fang, and as old as one." - - "#5c0202§oIt is warm to the touch," - - "#5c0202§oand if you hold it still, it beats." - -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 - -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. - boons: - devotee: - 1: { MAX_HEALTH: 1 } - 2: { MAX_MANA: 10 } - 3: { MAX_HEALTH: 1 } - 4: { SPELL_DAMAGE: 2 } - 5: { MAX_HEALTH: 2 } - "true": - 1: { MAX_HEALTH: 2 } - 2: { MAX_MANA: 20 } - 3: { MAX_HEALTH: 2 } - 4: { SPELL_DAMAGE: 4 } - 5: { MAX_HEALTH: 4 } - -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: [] - # How often (minutes) altars of dead owners are checked and purified. - purify_check_minutes: 10 - -# 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 speaks in red and calls the mage "child". The other voice sounds like the mage's own thoughts, but it -# is the Void Reaper, working on them unseen: it argues against kneeling at the question (choice lines, -# half the time) and is all a mage hears once they refuse. It never names itself; it only hints at the -# quiet, the cold and the empty. Grey with a touch of the void. -# whispered lines are said in order and end with the knife and altar; other stages pick at random. -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: "#8a0303§o" - 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." - animal: - - "The stone is thirsty, child. Can you not hear it?" - - "Every pen in the valley is full, and every shepherd sleeps." - - "Lead it down gently. Fear sours the blood." - - "Look into its eyes when you do it. I want to see what you see." - - "I remember the taste of the last one. Bring me a kind I have not had." - - "Wash your hands before you climb back up. They can smell it on you." - - "The Lady in her tower would call this wickedness. Let her. She has never been hungry." - choice: - - "Seven kinds have bled. Come down to the stone, and bring the curved 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 in all of Cerrith has ever kept me waiting." - 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 Reaper hoards the dead. 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 the Lady send her hunters. I have outlived every one of them." - self: - style: "#6e6878§o" - sound: minecraft:entity.warden.heartbeat - pitch: 1.2 - lines: - 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." - - "The Lady would call me evil. She'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." - -# Animal sacrifices: bind a living animal on the altar with the ritual knife, then kill it with that knife. -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 - # 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 - -# 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 - # 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] } - # Air spots get decorated: at floor = on top of a solid block, at ceiling = hanging under one. - 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] } - -# The god's question: right-click your altar with your blood knife once the seventh kind is given. -choice: - title: "#5c0202The Blood Father asks" - question: - - "#8a0303§oSeven kinds have bled for me, child, and you did not flinch." - - "#8a0303§oKneel, and be my child. I will carry you all the way." - - "#8a0303§oOr stand alone, and carry the weight yourself. Forever." - devotee: - item: v.red_dye - name: "#8a0303I kneel to Lorvear" - lore: - - "#8c8c8c§oPart of you stays on the stone," - - "#8c8c8c§oa wound that will never close." - - "#8c8c8c§oThe Blood Father walks with you, and chooses for you." - "true": - item: v.iron_sword - name: "#bfbfbfI kneel to no one" - lore: - - "#6e6878§oI never needed him. I never did." - - "#8c8c8c§oNo father. No voices. No one to blame." - - "#8c8c8c§oOnly you, and what you take" - - "#8c8c8c§ofrom the people who love you." - confirm: - item: v.redstone - name: "#ff5555There is no going back" - lore: - - "#8c8c8c§oThe stone will remember what you chose." - back: - item: v.barrier - name: "#aaaaaaNot yet" - lore: - - "#8c8c8c§oHe will wait. He is very good at waiting." - # Permanent injuries a devotee keeps as the mark of what they gave. - devotee_injuries: 2 - -# Hidden MMOCore classes (options.display: false) unlocked for the character through RPCharacters. -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 - -# 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" } - -# 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 - 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: 1.0, pitch: 0.6 } - - { sound: entity.warden.heartbeat, volume: 0.8, 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.5, pitch: 1.3 } - animal_escaped: - - { sound: block.chain.break, volume: 0.8, pitch: 0.8 } - - { particle: POOF, count: 8, spread: 0.3, y: 0.5 } - animal_slain: - - { sound: entity.player.attack.sweep, volume: 1.0, pitch: 0.7 } - - { sound: item.trident.hit, volume: 1.0, pitch: 0.6 } - - { sound: entity.generic.drink, volume: 0.8, 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: 1.0, pitch: 0.5 } - - { sound: block.honey_block.slide, volume: 1.0, pitch: 0.5 } - - { sound: block.sculk_catalyst.bloom, volume: 0.8, pitch: 0.5, delay: 4 } - - { sound: entity.warden.heartbeat, volume: 1.0, 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.7, pitch: 0.7, delay: 20 } - - { sound: block.end_portal.spawn, volume: 0.4, 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: 1.0, pitch: 0.5, delay: 50, private: true } - - # --- 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 } - 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/messages.yml b/src/main/resources/messages.yml index 0125fc0..7a8db58 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -444,228 +444,3 @@ gear: failed: "#ff5555Could not refresh that weapon. Check console." ok: "#55ff55Weapon refreshed against the live gear config." - -# Blood path. Hidden content: keep these lines vague and never quote them on the wiki. -blood: - - 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" - - owner_touch: "#8a0303§oThe stone is warm under your palm, like skin. You feel it breathe" - - 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" - - region: "#8c8c8c§oThis ground is claimed and watched. Find somewhere forgotten" - - animal: - - bind: - - ok: "#8a0303§oThe 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" - - known: "#8a0303§oI have tasted that kind already, child. Bring me another" - - cooldown: "#8a0303§oNot yet, child. The stone is still drinking the last one" - - not_stolen: "#8a0303§oNot that one. A gift that costs nothing is no gift. Take what others keep" - - too_far: "#8a0303§oToo far. Lead it down to the stone" - - busy: "#8a0303§oOne at a time. Finish what lies on the stone" - - spoiled: "#8c8c8c§oThe blood runs into the dirt, wasted. The stone did not drink" - - escaped: "#8c8c8c§oThe beast thrashes free of the stone and bolts into the dark" - - accepted: "#8a0303§oThe stone drinks. When you take your hand away, it is warmer than before" - - complete: "#8a0303§oSeven kinds. The stone is red to its heart, and something cold and curved 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: "#8a0303§oYou 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: "#6e6878§oNot 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" - - broken: "#8c8c8c§oThe rite comes apart. The stone goes cold and quiet, sulking" - - refused: "#8c8c8c§oThe stone will not take them" - - taken: "#7a7a7a§oIt is done. You feel heavier, and stronger, and very far from who you were" - - carry: - - start: "#8a0303§oYou 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: "#7a7a7a§oSeven 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: "#7a7a7a§oA hollow opens where they used to be, and something cold flows in to fill it" - - ascended: "#5c0202§oThe 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" - - choice: - - devoted: "#8a0303§oSomething of you stays on the stone, and something of the Blood Father comes with you. It hurts. It will always hurt" - - refused: "#6e6878§oLorvear 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 | 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}." - - 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/animals.yml b/src/main/resources/wildmagic/bloodmagic/animals.yml new file mode 100644 index 0000000..2c687b9 --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/animals.yml @@ -0,0 +1,23 @@ +# 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, then kill it with that knife. +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 + # 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/choice.yml b/src/main/resources/wildmagic/bloodmagic/choice.yml new file mode 100644 index 0000000..fd8ca3b --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/choice.yml @@ -0,0 +1,37 @@ +# The god's question and its menu. +# Part of wildmagic/bloodmagic (Magic's hidden blood path). Hidden content: never on the wiki. + +# The god's question: right-click your altar with your blood knife once the seventh kind is given. +choice: + title: "#5c0202The Blood Father asks" + question: + - "#8a0303§oSeven kinds have bled for me, child, and you did not flinch." + - "#8a0303§oKneel, and be my child. I will carry you all the way." + - "#8a0303§oOr stand alone, and carry the weight yourself. Forever." + devotee: + item: v.red_dye + name: "#8a0303I kneel to Lorvear" + lore: + - "#8c8c8c§oPart of you stays on the stone," + - "#8c8c8c§oa wound that will never close." + - "#8c8c8c§oThe Blood Father walks with you, and chooses for you." + "true": + item: v.iron_sword + name: "#bfbfbfI kneel to no one" + lore: + - "#6e6878§oI never needed him. I never did." + - "#8c8c8c§oNo father. No voices. No one to blame." + - "#8c8c8c§oOnly you, and what you take" + - "#8c8c8c§ofrom the people who love you." + confirm: + item: v.redstone + name: "#ff5555There is no going back" + lore: + - "#8c8c8c§oThe stone will remember what you chose." + back: + item: v.barrier + name: "#aaaaaaNot yet" + lore: + - "#8c8c8c§oHe will wait. He is very good at waiting." + # Permanent injuries a devotee keeps as the mark of what they gave. + devotee_injuries: 2 diff --git a/src/main/resources/wildmagic/bloodmagic/corruption.yml b/src/main/resources/wildmagic/bloodmagic/corruption.yml new file mode 100644 index 0000000..0924824 --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/corruption.yml @@ -0,0 +1,32 @@ +# 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 + # 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] } + # Air spots get decorated: at floor = on top of a solid block, at ceiling = hanging under one. + 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..3eb91d1 --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/fx.yml @@ -0,0 +1,217 @@ +# 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 + 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: 1.0, pitch: 0.6 } + - { sound: entity.warden.heartbeat, volume: 0.8, 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.5, pitch: 1.3 } + animal_escaped: + - { sound: block.chain.break, volume: 0.8, pitch: 0.8 } + - { particle: POOF, count: 8, spread: 0.3, y: 0.5 } + animal_slain: + - { sound: entity.player.attack.sweep, volume: 1.0, pitch: 0.7 } + - { sound: item.trident.hit, volume: 1.0, pitch: 0.6 } + - { sound: entity.generic.drink, volume: 0.8, 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: 1.0, pitch: 0.5 } + - { sound: block.honey_block.slide, volume: 1.0, pitch: 0.5 } + - { sound: block.sculk_catalyst.bloom, volume: 0.8, pitch: 0.5, delay: 4 } + - { sound: entity.warden.heartbeat, volume: 1.0, 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.7, pitch: 0.7, delay: 20 } + - { sound: block.end_portal.spawn, volume: 0.4, 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: 1.0, pitch: 0.5, delay: 50, private: true } + + # --- 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 } + 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..78ff95e --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/items.yml @@ -0,0 +1,26 @@ +# 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." + # The Ritual Knife is an MMOItems lore weapon (name, lore and look come from MMOItems). + ritual_knife: + path: m.lore_weapons.ritual_knife + name: "" + lore: [] + blood_knife: + path: ia.tfmc_blood:blood_knife + name: "#8a0303Vein-Reaver" + lore: + - "#5c0202§oCurved like a fang, and as old as one." + - "#5c0202§oIt is warm to the touch," + - "#5c0202§oand if you hold it still, it beats." diff --git a/src/main/resources/wildmagic/bloodmagic/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml new file mode 100644 index 0000000..58dbfc8 --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -0,0 +1,227 @@ +# 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: + + 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" + + owner_touch: "#8a0303§oThe stone is warm under your palm, like skin. You feel it breathe" + + 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" + + region: "#8c8c8c§oThis ground is claimed and watched. Find somewhere forgotten" + + animal: + + bind: + + ok: "#8a0303§oThe 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" + + known: "#8a0303§oI have tasted that kind already, child. Bring me another" + + cooldown: "#8a0303§oNot yet, child. The stone is still drinking the last one" + + not_stolen: "#8a0303§oNot that one. A gift that costs nothing is no gift. Take what others keep" + + too_far: "#8a0303§oToo far. Lead it down to the stone" + + busy: "#8a0303§oOne at a time. Finish what lies on the stone" + + spoiled: "#8c8c8c§oThe blood runs into the dirt, wasted. The stone did not drink" + + escaped: "#8c8c8c§oThe beast thrashes free of the stone and bolts into the dark" + + accepted: "#8a0303§oThe stone drinks. When you take your hand away, it is warmer than before" + + complete: "#8a0303§oSeven kinds. The stone is red to its heart, and something cold and curved 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: "#8a0303§oYou 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: "#6e6878§oNot 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" + + broken: "#8c8c8c§oThe rite comes apart. The stone goes cold and quiet, sulking" + + refused: "#8c8c8c§oThe stone will not take them" + + taken: "#7a7a7a§oIt is done. You feel heavier, and stronger, and very far from who you were" + + carry: + + start: "#8a0303§oYou 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: "#7a7a7a§oSeven 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: "#7a7a7a§oA hollow opens where they used to be, and something cold flows in to fill it" + + ascended: "#5c0202§oThe 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" + + choice: + + devoted: "#8a0303§oSomething of you stays on the stone, and something of the Blood Father comes with you. It hurts. It will always hurt" + + refused: "#6e6878§oLorvear 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 | 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}." + + 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..576d376 --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/settings.yml @@ -0,0 +1,123 @@ +# 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: [] + # 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. + boons: + devotee: + 1: { MAX_HEALTH: 1 } + 2: { MAX_MANA: 10 } + 3: { MAX_HEALTH: 1 } + 4: { SPELL_DAMAGE: 2 } + 5: { MAX_HEALTH: 2 } + "true": + 1: { MAX_HEALTH: 2 } + 2: { MAX_MANA: 20 } + 3: { MAX_HEALTH: 2 } + 4: { SPELL_DAMAGE: 4 } + 5: { MAX_HEALTH: 4 } + +# Hidden MMOCore classes (options.display: false) unlocked for the character through RPCharacters. +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 diff --git a/src/main/resources/wildmagic/bloodmagic/voices.yml b/src/main/resources/wildmagic/bloodmagic/voices.yml new file mode 100644 index 0000000..af5a1bd --- /dev/null +++ b/src/main/resources/wildmagic/bloodmagic/voices.yml @@ -0,0 +1,103 @@ +# 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 speaks in red and calls the mage "child". The other voice sounds like the mage's own thoughts, but it +# is the Void Reaper, working on them unseen: it argues against kneeling at the question (choice lines, +# half the time) and is all a mage hears once they refuse. It never names itself; it only hints at the +# quiet, the cold and the empty. Grey with a touch of the void. +# whispered lines are said in order and end with the knife and altar; other stages pick at random. +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: "#8a0303§o" + 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." + animal: + - "The stone is thirsty, child. Can you not hear it?" + - "Every pen in the valley is full, and every shepherd sleeps." + - "Lead it down gently. Fear sours the blood." + - "Look into its eyes when you do it. I want to see what you see." + - "I remember the taste of the last one. Bring me a kind I have not had." + - "Wash your hands before you climb back up. They can smell it on you." + - "The Lady in her tower would call this wickedness. Let her. She has never been hungry." + choice: + - "Seven kinds have bled. Come down to the stone, and bring the curved 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 in all of Cerrith has ever kept me waiting." + 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 Reaper hoards the dead. 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 the Lady send her hunters. I have outlived every one of them." + self: + style: "#6e6878§o" + sound: minecraft:entity.warden.heartbeat + pitch: 1.2 + lines: + 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." + - "The Lady would call me evil. She'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/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/AltarServiceTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java index 5eac6b9..e2834f9 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java @@ -61,7 +61,7 @@ void itemsCarryKindAndOwner() { unknown.setItemMeta(meta); assertNull(BloodItems.kind(unknown)); - BloodConfig.load(new java.io.File("src/main/resources/bloodmagic.yml"), fx.logger); + 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()); diff --git a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java index 68b8d53..c483c5f 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java @@ -44,7 +44,7 @@ class AnimalRiteTest { @BeforeEach void setup() { fx = new BloodFixture(temp); - BloodConfig.load(new File("src/main/resources/bloodmagic.yml"), fx.logger); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); AnimalRiteService.clock = now::get; StolenClues.clock = now::get; FakeHusbandry.use(); diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java index 0d7de50..68d601c 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java @@ -173,7 +173,7 @@ void completions() { @Test void whisperAndVoiceTools() { try (BloodFixture fx = new BloodFixture(temp)) { - BloodConfig.load(new java.io.File("src/main/resources/bloodmagic.yml"), fx.logger); + 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"); diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java index 130a91f..22e72d4 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java @@ -27,9 +27,31 @@ File write(String yaml) throws Exception { 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/bloodmagic.yml"); + File bundled = new File("src/main/resources/wildmagic/bloodmagic"); assertTrue(BloodConfig.load(bundled, log)); assertTrue(BloodConfig.isEnabled()); assertEquals("ia.tfmc_blood:blood_knife", BloodConfig.item(BloodItemKind.BLOOD_KNIFE).path()); diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java index d155183..52b0378 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java @@ -42,7 +42,7 @@ final class BloodFixture implements AutoCloseable { when(Magic.plugin.getDataFolder()).thenReturn(data.toFile()); when(Magic.plugin.isEnabled()).thenReturn(true); characters = new CharacterTestSupport(); - messages = mockStatic(Messages.class, i -> i.getArgument(0)); + 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); diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodFxTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodFxTest.java index a69d126..d2f0c4c 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodFxTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodFxTest.java @@ -51,9 +51,9 @@ static BloodFx.Layer layer(Map raw) { @Test void everyBundledEffectIsARealParticleOrSound() { - YamlConfiguration yaml = YamlConfiguration.loadConfiguration(new File("src/main/resources/bloodmagic.yml")); + 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/bloodmagic.yml"), fx.logger); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); assertTrue(BloodFx.isEnabled()); int layers = 0; for (String moment : moments.getKeys(false)) { diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java index dd7b060..ffd205d 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java @@ -177,7 +177,7 @@ void killsFeedTheKillersMeterAndSessionsResetVoiceTimers() { offline.ownerId(), offline, "x")); assertEquals(15, BloodRecords.get(offline.characterId().toString()).getWhisper()); - BloodConfig.load(new java.io.File("src/main/resources/bloodmagic.yml"), fx.logger); + 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)); diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java index a534f84..0850696 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java @@ -51,6 +51,9 @@ void startLoadsDataSettlesAltarsAndSweepsOnSchedule() throws Exception { 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/BoonsAndToolsTest.java b/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java index 4047d90..4479006 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java @@ -47,7 +47,7 @@ class BoonsAndToolsTest { @BeforeEach void setup() { fx = new BloodFixture(temp); - BloodConfig.load(new File("src/main/resources/bloodmagic.yml"), fx.logger); + 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"); diff --git a/src/test/java/net/tfminecraft/magic/blood/CarryTest.java b/src/test/java/net/tfminecraft/magic/blood/CarryTest.java index dc0f76f..492fd58 100644 --- a/src/test/java/net/tfminecraft/magic/blood/CarryTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/CarryTest.java @@ -44,7 +44,7 @@ class CarryTest { @BeforeEach void setup() { fx = new BloodFixture(temp); - BloodConfig.load(new File("src/main/resources/bloodmagic.yml"), fx.logger); + 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); @@ -309,7 +309,7 @@ void listenerTakesDragsAndLaysTheBodyDown() { assertNull(CarryService.carried(mage)); assertFalse(HuntConfig.carryEnabled()); - BloodConfig.load(new File("src/main/resources/bloodmagic.yml"), fx.logger); + 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(), diff --git a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java index c26137b..9317f36 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java @@ -60,7 +60,7 @@ public static boolean unlock(Player player, String classId) { @BeforeEach void setup() { fx = new BloodFixture(temp); - BloodConfig.load(new File("src/main/resources/bloodmagic.yml"), fx.logger); + 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); diff --git a/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java index b71cf89..269f225 100644 --- a/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java @@ -214,7 +214,7 @@ void configsParseAndRejectBadEntries() throws Exception { assertEquals(new CorruptionConfig.Reach(2, 4), CorruptionConfig.reach(0)); assertTrue(CorruptionConfig.rules(5).isEmpty()); - assertTrue(BloodConfig.load(new File("src/main/resources/bloodmagic.yml"), fx.logger)); + 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)); diff --git a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java index deb0856..418f519 100644 --- a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java @@ -46,7 +46,7 @@ class HuntAndFinalsTest { @BeforeEach void setup() { fx = new BloodFixture(temp); - BloodConfig.load(new File("src/main/resources/bloodmagic.yml"), fx.logger); + 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; @@ -253,7 +253,7 @@ void bindingTheVictimChecksEveryRule() throws Exception { 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/bloodmagic.yml"), fx.logger); + BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), fx.logger); record.setLastVictimAccount(null); record.setStage(BloodStage.FINAL); diff --git a/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java b/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java index 7f5e990..b681d07 100644 --- a/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java @@ -23,7 +23,7 @@ void reset() { @Test void bundledHuntBondsAndFinalsLoad() { - assertTrue(BloodConfig.load(new File("src/main/resources/bloodmagic.yml"), log)); + assertTrue(BloodConfig.load(new File("src/main/resources/wildmagic/bloodmagic"), log)); assertEquals(8, HuntConfig.dragRange()); assertEquals(10, HuntConfig.riteSeconds()); assertEquals(10, HuntConfig.minPlaytimeHours()); diff --git a/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java b/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java index b23ed48..6ca8270 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ReviewFixesTest.java @@ -42,7 +42,7 @@ class ReviewFixesTest { @BeforeEach void setup() { fx = new BloodFixture(temp); - BloodConfig.load(new File("src/main/resources/bloodmagic.yml"), fx.logger); + 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); diff --git a/src/test/java/net/tfminecraft/magic/blood/WhisperConfigTest.java b/src/test/java/net/tfminecraft/magic/blood/WhisperConfigTest.java index ae40a38..84702d3 100644 --- a/src/test/java/net/tfminecraft/magic/blood/WhisperConfigTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/WhisperConfigTest.java @@ -24,7 +24,7 @@ void reset() { @Test void bundledVoicesAndSourcesLoad() { - assertTrue(BloodConfig.load(new File("src/main/resources/bloodmagic.yml"), log)); + 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"))); diff --git a/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java b/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java index f175898..e825a63 100644 --- a/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java @@ -41,7 +41,7 @@ class WhisperServiceTest { @BeforeEach void setup() { fx = new BloodFixture(temp); - BloodConfig.load(new File("src/main/resources/bloodmagic.yml"), fx.logger); + 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; From ddca7b6255bb03d07194c7416e80a1cf71877e66 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:57:32 +0100 Subject: [PATCH 21/55] Guard every block the altar has changed Breaking, pushing or blowing up a block the altar changed (while it is still what the altar made it) fails like the altar itself: a warm pulse, a heartbeat and a short line. Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/AltarCorruption.java | 16 ++++++++++ .../magic/blood/BloodListener.java | 19 +++++++++--- .../resources/wildmagic/bloodmagic/fx.yml | 5 ++++ .../wildmagic/bloodmagic/messages.yml | 2 ++ .../magic/blood/BloodListenerTest.java | 29 +++++++++++++++++++ 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java index 9bede5f..396cb15 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java @@ -114,6 +114,22 @@ private static void change(BloodAltar altar, Block block, Material to) { 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(); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index 3572581..45b9c32 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -172,11 +172,17 @@ public void onPlace(BlockPlaceEvent event) { } } + /** The altar, and every block it has changed around it, stand against tools. */ @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) public void onBreak(BlockBreakEvent event) { - if (BloodAltars.at(event.getBlock()) != null) { + 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); + event.getPlayer().sendMessage(Messages.get("blood.corruption.unbreakable")); + BloodFx.play("corruption_unbreakable", block.getLocation().add(0.5, 0.5, 0.5), event.getPlayer()); } } @@ -196,12 +202,17 @@ public void onPistonRetract(BlockPistonRetractEvent event) { @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) public void onEntityExplode(EntityExplodeEvent event) { - event.blockList().removeIf(block -> BloodAltars.at(block) != null); + event.blockList().removeIf(BloodListener::guarded); } @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) public void onBlockExplode(BlockExplodeEvent event) { - event.blockList().removeIf(block -> BloodAltars.at(block) != null); + event.blockList().removeIf(BloodListener::guarded); + } + + /** The altar or a block it has changed. */ + private static boolean guarded(Block block) { + return BloodAltars.at(block) != null || AltarCorruption.isCorrupted(block); } @EventHandler @@ -281,7 +292,7 @@ public void onSessionEnd(CharacterSessionEndEvent event) { private static boolean touchesAltar(List blocks) { for (Block block : blocks) { - if (BloodAltars.at(block) != null) { + if (guarded(block)) { return true; } } diff --git a/src/main/resources/wildmagic/bloodmagic/fx.yml b/src/main/resources/wildmagic/bloodmagic/fx.yml index 3eb91d1..0dc4b34 100644 --- a/src/main/resources/wildmagic/bloodmagic/fx.yml +++ b/src/main/resources/wildmagic/bloodmagic/fx.yml @@ -76,6 +76,11 @@ fx: animal_escaped: - { sound: block.chain.break, volume: 0.8, pitch: 0.8 } - { particle: POOF, count: 8, spread: 0.3, y: 0.5 } + 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: 1.0, pitch: 0.7 } - { sound: item.trident.hit, volume: 1.0, pitch: 0.6 } diff --git a/src/main/resources/wildmagic/bloodmagic/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml index 58dbfc8..af903b4 100644 --- a/src/main/resources/wildmagic/bloodmagic/messages.yml +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -4,6 +4,8 @@ # Blood path. Hidden content: keep these lines vague and never quote them on the wiki. blood: + corruption: + unbreakable: "#8a0303§oThe 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" diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java index ffd205d..1697261 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java @@ -122,6 +122,35 @@ void protectionStopsBreakingPistonsAndExplosions() { 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); + BlockBreakEvent breakRestored = new BlockBreakEvent(changed, player); + listener.onBreak(breakRestored); + assertFalse(breakRestored.isCancelled()); + List blown = new ArrayList<>(List.of(target, other)); listener.onEntityExplode(new EntityExplodeEvent(player, target.getLocation(), blown, 1f, null)); assertEquals(List.of(other), blown); From d1d4ab5ccc445abf59d469bcc58d8aae6a929a4c Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:06:16 +0100 Subject: [PATCH 22/55] Shape the altar's room for the meditation circle The altar is the middle of a meditation circle, so placing it shapes the room around it: open floor out to the farthest pedestal (9 by 9 with the default offsets), placement.room.height high. Small gaps are carved and floor holes filled with the stone the player stands on; too many changes, liquid, containers, unbreakable blocks, another altar or its changes, claimed ground or opening the rock to the sky refuse the stone with the size it needs. Runs after the protection probe; the room stays when the altar is purified. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/AltarRoom.java | 130 ++++++++++++++++++ .../tfminecraft/magic/blood/AltarService.java | 6 + .../tfminecraft/magic/blood/BloodConfig.java | 22 +++ .../magic/blood/BloodListener.java | 3 +- .../wildmagic/bloodmagic/messages.yml | 3 + .../wildmagic/bloodmagic/settings.yml | 11 ++ .../magic/blood/AltarRoomTest.java | 129 +++++++++++++++++ .../magic/blood/AltarServiceTest.java | 4 +- .../magic/blood/AnimalRiteTest.java | 2 +- .../magic/blood/BloodCommandTest.java | 4 +- .../tfminecraft/magic/blood/BloodFixture.java | 7 + .../magic/blood/BloodListenerTest.java | 4 +- 12 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/blood/AltarRoom.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/AltarRoomTest.java 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..1882642 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AltarRoom.java @@ -0,0 +1,130 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.TileState; + +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). Its room must give them open floor: a square reaching the farthest + * pedestal on every side, placement.room.height blocks high. Small gaps are carved and floor holes + * filled with the stone the player stands on; too much rock, something that must not move, claimed + * ground or opening the rock to the sky refuses the stone instead. + */ +public final class AltarRoom { + + public enum Verdict { + OK, + TOO_MUCH, + BLOCKED, + SHALLOW + } + + /** What placing the altar would change: blocks carved to air and floor holes filled. */ + public record Plan(Verdict verdict, List carve, List fill, Material floor) { + static Plan refused(Verdict verdict) { + return new Plan(verdict, List.of(), List.of(), Material.STONE); + } + + public void apply() { + for (Block block : carve) { + block.setType(Material.AIR, false); + } + for (Block block : fill) { + block.setType(floor, 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 filled like standingOn (else the altar's floor). */ + public static Plan plan(Block target, Block standingOn) { + if (!BloodConfig.roomEnabled()) { + return new Plan(Verdict.OK, List.of(), List.of(), Material.STONE); + } + int half = halfWidth(); + int height = BloodConfig.roomHeight(); + World world = target.getWorld(); + List carve = new ArrayList<>(); + List fill = new ArrayList<>(); + for (int x = target.getX() - half; x <= target.getX() + half; x++) { + for (int z = target.getZ() - half; z <= target.getZ() + half; z++) { + Block floor = world.getBlockAt(x, target.getY() - 1, z); + if (!floor.getType().isSolid()) { + if (!movable(floor)) { + return Plan.refused(Verdict.BLOCKED); + } + fill.add(floor); + } + for (int y = target.getY(); y < target.getY() + height; y++) { + Block block = world.getBlockAt(x, y, z); + if (block.isEmpty() || block.equals(target)) { + continue; + } + if (!movable(block)) { + return Plan.refused(Verdict.BLOCKED); + } + carve.add(block); + } + if (BloodConfig.requireUnderground() + && cover(world, x, target.getY() + height - 1, z) < BloodConfig.minCover()) { + return Plan.refused(Verdict.SHALLOW); + } + if (AltarRegions.blocked(floor.getLocation())) { + return Plan.refused(Verdict.BLOCKED); + } + } + } + if (carve.size() + fill.size() > BloodConfig.roomMaxChanges()) { + return Plan.refused(Verdict.TOO_MUCH); + } + return new Plan(Verdict.OK, List.copyOf(carve), List.copyOf(fill), floorOf(standingOn, target.getRelative(0, -1, 0))); + } + + /** 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 index f96bdba..0e292e6 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -60,6 +60,12 @@ public static String place(Player player, ItemStack hand, Block target) { 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); + } + room.apply(); BloodAltar altar = BloodAltar.create(character, player.getUniqueId().toString(), target, player.getLocation().getYaw() + 180f, System.currentTimeMillis()); altar.setLevel(BloodProgress.level(record)); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java index 5b68ef6..0bfff1e 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java @@ -39,6 +39,9 @@ public record ItemDef(String path, String name, List lore) {} 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 { @@ -126,6 +129,9 @@ public static boolean load(File source, Logger log) { 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)); 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); @@ -171,6 +177,9 @@ private static List lower(List raw) { /** Clears everything back to the disabled defaults. */ public static void reset() { + roomEnabled = true; + roomHeight = 3; + roomMaxChanges = 150; enabled = false; ITEMS.clear(); for (BloodItemKind kind : BloodItemKind.values()) { @@ -258,6 +267,19 @@ 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; } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index 45b9c32..7c0ec24 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -77,7 +77,8 @@ public void onInteract(PlayerInteractEvent event) { 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))); + 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. */ diff --git a/src/main/resources/wildmagic/bloodmagic/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml index af903b4..6e7d24a 100644 --- a/src/main/resources/wildmagic/bloodmagic/messages.yml +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -34,6 +34,9 @@ blood: 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" region: "#8c8c8c§oThis ground is claimed and watched. Find somewhere forgotten" animal: diff --git a/src/main/resources/wildmagic/bloodmagic/settings.yml b/src/main/resources/wildmagic/bloodmagic/settings.yml index 576d376..528afa5 100644 --- a/src/main/resources/wildmagic/bloodmagic/settings.yml +++ b/src/main/resources/wildmagic/bloodmagic/settings.yml @@ -40,6 +40,17 @@ placement: # 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 shapes the room: open floor out + # to the farthest pedestal on every side (9 by 9 by default), height blocks high. Small gaps are + # carved and floor holes filled with the stone the player stands on. If more than max_changes blocks + # would change, or the room would hit 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. + # The carved room stays when the altar is purified. + room: + enabled: true + height: 3 + max_changes: 150 # How often (minutes) altars of dead owners are checked and purified. purify_check_minutes: 10 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..33fef81 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/AltarRoomTest.java @@ -0,0 +1,129 @@ +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); + 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(List.of(hole), plan.fill()); + assertEquals(Material.DEEPSLATE, plan.floor()); + 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.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 index e2834f9..26161a1 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java @@ -72,7 +72,7 @@ void itemsCarryKindAndOwner() { void placingNeedsTheOwnerOnTheRightStageWithNoOtherAltar() { try (BloodFixture fx = new BloodFixture(temp)) { PlayerMock player = fx.player("mage"); - Block target = fx.pocket(0, 10, 0); + Block target = fx.room(0, 10, 0); ItemStack item = altarItem(fx, "mage"); BloodConfig.setEnabled(false); assertEquals("blood.altar.inert", AltarService.place(player, item, target)); @@ -178,7 +178,7 @@ void purifyAndDeathClearAltarAndPath() { try (BloodFixture fx = new BloodFixture(temp)) { PlayerMock player = fx.player("mage"); fx.record("mage", BloodStage.ANIMAL); - Block target = fx.pocket(0, 10, 0); + Block target = fx.room(0, 10, 0); AltarService.place(player, altarItem(fx, "mage"), target); BloodAltar altar = BloodAltars.at(target); AltarService.purify(altar, "test"); diff --git a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java index c483c5f..e15b3e4 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java @@ -51,7 +51,7 @@ void setup() { mage = fx.player("mage"); id = BloodFixture.id("mage"); record = fx.record("mage", BloodStage.ANIMAL); - target = fx.pocket(0, 10, 0); + 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)); diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java index 68d601c..d71d588 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodCommandTest.java @@ -77,7 +77,7 @@ void infoStagePathGiveAndReset() { assertEquals(before + 1, fx.world.getEntities().size()); console.nextMessage(); - Block target = fx.pocket(0, 10, 0); + 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()); @@ -122,7 +122,7 @@ void altarActionsByPlayerOrId() { assertEquals("blood.admin.no_altar", console.nextMessage()); run(console, "blood altar info Nobody"); assertEquals("blood.admin.no_altar", console.nextMessage()); - Block target = fx.pocket(0, 10, 0); + 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"); diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java index 52b0378..c9154fd 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java @@ -97,6 +97,13 @@ PlayerMock player(String label) { return player; } + /** An open chamber around the pocket at x, y, z, wide and high enough for the altar's meditation room. */ + 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); + return pocket(x, y, z); + } + static String id(String label) { return CharacterTestSupport.id(label); } diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java index 1697261..1b731e7 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java @@ -47,7 +47,7 @@ void rightClickWithAltarItemPlacesItAndTouchingAnAltarAnswers() { PlayerMock player = fx.player("mage"); fx.record("mage", BloodStage.ANIMAL); Block floor = fx.world.getBlockAt(0, 9, 0); - Block target = fx.pocket(0, 10, 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); @@ -73,7 +73,7 @@ void rightClickWithAltarItemPlacesItAndTouchingAnAltarAnswers() { assertTrue(touch.isCancelled()); assertEquals("blood.altar.owner_touch", player.nextMessage()); - Block grass = fx.pocket(5, 10, 5); + Block grass = fx.room(5, 10, 5); grass.setType(Material.SHORT_GRASS); fx.record("other", BloodStage.ANIMAL); PlayerMock other = fx.player("other"); From ae614ee65d7770834665bd4d054040c309b22170 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:11:56 +0100 Subject: [PATCH 23/55] Sit on the altar to meditate The altar is the middle of its meditation circle: its owner right-clicks it with an empty hand and sits on top (an invisible, unsaved display), so meditation starts there like on any seat. The seat goes when left, when the altar is purified and when Magic stops. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/AltarSeat.java | 85 +++++++++++++ .../tfminecraft/magic/blood/AltarService.java | 1 + .../magic/blood/BloodListener.java | 12 ++ .../tfminecraft/magic/blood/BloodMagic.java | 1 + .../resources/wildmagic/bloodmagic/fx.yml | 3 + .../wildmagic/bloodmagic/messages.yml | 1 + .../magic/blood/AltarSeatTest.java | 113 ++++++++++++++++++ .../magic/blood/BloodListenerTest.java | 4 +- 8 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/blood/AltarSeat.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/AltarSeatTest.java diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarSeat.java b/src/main/java/net/tfminecraft/magic/blood/AltarSeat.java new file mode 100644 index 0000000..8012ce1 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AltarSeat.java @@ -0,0 +1,85 @@ +package net.tfminecraft.magic.blood; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.bukkit.Location; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Entity; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.entity.Player; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.magic.Magic; + +/** + * The altar is the middle of a meditation circle: its owner sits on it (right-click with an empty + * hand) and meditation starts there like on any seat. The seat is an invisible, unsaved display on + * top of the altar; it goes as soon as it is left. + */ +public final class AltarSeat { + + private static final Map SEATS = new HashMap<>(); + + private AltarSeat() {} + + static NamespacedKey key() { + return new NamespacedKey(Magic.plugin, "altar_seat"); + } + + /** Sits the player on top of the altar; false when they already ride something or it is not loaded. */ + public static boolean sit(Player player, BloodAltar altar) { + Location center = altar.center(); + if (player.isInsideVehicle() || center == null) { + return false; + } + clear(altar); + Location top = center.clone().add(0, 0.5, 0); + top.setYaw(player.getLocation().getYaw()); + ItemDisplay seat = top.getWorld().spawn(top, ItemDisplay.class, display -> { + display.setPersistent(false); + display.getPersistentDataContainer().set(key(), PersistentDataType.STRING, altar.getId()); + }); + seat.addPassenger(player); + SEATS.put(altar.getId(), seat); + BloodFx.play("altar_sit", center, player); + return true; + } + + public static boolean isSeat(Entity entity) { + return entity != null && entity.getPersistentDataContainer().has(key(), PersistentDataType.STRING); + } + + /** A seat that was left goes. */ + public static void left(Entity vehicle) { + if (!isSeat(vehicle)) { + return; + } + SEATS.values().remove(vehicle); + vehicle.remove(); + } + + /** Takes the altar's seat away, whoever sits on it, e.g. when the altar is purified. */ + public static void clear(BloodAltar altar) { + Entity seat = SEATS.remove(altar.getId()); + if (seat != null) { + unseat(seat); + } + } + + static void clearAll() { + List seats = List.copyOf(SEATS.values()); + SEATS.clear(); + for (Entity seat : seats) { + unseat(seat); + } + } + + private static void unseat(Entity seat) { + for (Entity passenger : List.copyOf(seat.getPassengers())) { + passenger.leaveVehicle(); + } + seat.remove(); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarService.java b/src/main/java/net/tfminecraft/magic/blood/AltarService.java index 0e292e6..10d6cbb 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -106,6 +106,7 @@ public static void refreshFor(String characterId) { public static void purify(BloodAltar altar, String reason) { BloodFx.play("altar_purified", altar.center()); AltarCorruption.restore(altar); + AltarSeat.clear(altar); AltarDisplays.remove(altar); BloodAltars.remove(altar); BloodRecord record = BloodRecords.get(altar.getOwnerCharacter()); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index 7c0ec24..39accc2 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -18,6 +18,7 @@ import org.bukkit.event.block.BlockPistonRetractEvent; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDismountEvent; import org.bukkit.event.entity.EntityDeathEvent; import org.bukkit.event.entity.EntityPickupItemEvent; import org.bukkit.event.inventory.InventoryClickEvent; @@ -67,6 +68,12 @@ public void onInteract(PlayerInteractEvent event) { layDown(player, carried); return; } + // An empty hand sits the owner on their altar, the middle of its meditation circle. + if (character != null && character.equalsIgnoreCase(altar.getOwnerCharacter()) + && held.getType().isAir() && AltarSeat.sit(player, altar)) { + player.sendMessage(Messages.get("blood.altar.sit")); + return; + } player.sendMessage(AltarService.touch(player, altar)); return; } @@ -123,6 +130,11 @@ private static void layDown(Player mage, Player victim) { mage.sendMessage(Messages.get("blood.rite.bind." + result.name().toLowerCase(Locale.ROOT))); } + @EventHandler + public void onDismount(EntityDismountEvent event) { + AltarSeat.left(event.getDismounted()); + } + @EventHandler public void onMenuClick(InventoryClickEvent event) { ChoiceMenu.onClick(event); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index ac8f11b..66836fe 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -108,6 +108,7 @@ public static void stop() { BloodBoons.clear(player); } BloodPerms.clearAll(); + AltarSeat.clearAll(); CarryService.clear(); CharacterRiteService.clear(); BondService.clear(); diff --git a/src/main/resources/wildmagic/bloodmagic/fx.yml b/src/main/resources/wildmagic/bloodmagic/fx.yml index 0dc4b34..ec9fec5 100644 --- a/src/main/resources/wildmagic/bloodmagic/fx.yml +++ b/src/main/resources/wildmagic/bloodmagic/fx.yml @@ -76,6 +76,9 @@ fx: animal_escaped: - { sound: block.chain.break, volume: 0.8, 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 } diff --git a/src/main/resources/wildmagic/bloodmagic/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml index 6e7d24a..a4463ee 100644 --- a/src/main/resources/wildmagic/bloodmagic/messages.yml +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -18,6 +18,7 @@ blood: unbreakable: "#8c8c8c§oYour tools ring off the stone without leaving a scratch" + sit: "#8a0303§oYou kneel on the stone. It is warm, and it is listening" owner_touch: "#8a0303§oThe stone is warm under your palm, like skin. You feel it breathe" stranger_touch: "#8c8c8c§oCold carved stone, dark in the seams. The longer you look at it, the less you want to touch it" diff --git a/src/test/java/net/tfminecraft/magic/blood/AltarSeatTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarSeatTest.java new file mode 100644 index 0000000..f84eeec --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/AltarSeatTest.java @@ -0,0 +1,113 @@ +package net.tfminecraft.magic.blood; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; + +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.block.Action; +import org.bukkit.event.entity.EntityDismountEvent; +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.entity.PlayerMock; + +class AltarSeatTest { + @TempDir Path temp; + BloodFixture fx; + final BloodListener listener = new BloodListener(); + + @BeforeEach + void setup() { + fx = new BloodFixture(temp); + } + + @AfterEach + void cleanup() { + AltarSeat.clearAll(); + fx.close(); + } + + PlayerInteractEvent click(PlayerMock player, Block block) { + return new PlayerInteractEvent(player, Action.RIGHT_CLICK_BLOCK, player.getInventory().getItemInMainHand(), + block, BlockFace.UP, EquipmentSlot.HAND); + } + + @Test + void theOwnerSitsOnTheAltarWithAnEmptyHandAndTheSeatGoesWhenLeft() { + PlayerMock mage = fx.player("mage"); + Block target = fx.room(0, 10, 0); + BloodAltar altar = BloodAltar.create(BloodFixture.id("mage"), mage.getUniqueId().toString(), target, 0f, 1L); + BloodAltars.add(altar); + + mage.getInventory().setItemInMainHand(new ItemStack(Material.STONE)); + listener.onInteract(click(mage, target)); + assertEquals("blood.altar.owner_touch", mage.nextMessage()); + assertFalse(mage.isInsideVehicle()); + PlayerMock stranger = fx.player("stranger"); + listener.onInteract(click(stranger, target)); + assertEquals("blood.altar.stranger_touch", stranger.nextMessage()); + PlayerMock nobody = fx.server.addPlayer(); + listener.onInteract(click(nobody, target)); + assertEquals("blood.altar.stranger_touch", nobody.nextMessage()); + + mage.getInventory().setItemInMainHand(null); + listener.onInteract(click(mage, target)); + assertEquals("blood.altar.sit", mage.nextMessage()); + Entity seat = mage.getVehicle(); + assertInstanceOf(ItemDisplay.class, seat); + assertTrue(AltarSeat.isSeat(seat)); + assertFalse(seat.isPersistent()); + assertEquals(target.getY() + 1.0, seat.getLocation().getY(), 1e-9); + + listener.onInteract(click(mage, target)); + assertEquals("blood.altar.owner_touch", mage.nextMessage()); + + listener.onDismount(new EntityDismountEvent(mage, seat)); + assertTrue(seat.isDead()); + AltarSeat.left(mage); + assertFalse(AltarSeat.isSeat(null)); + + mage.leaveVehicle(); + assertTrue(AltarSeat.sit(mage, altar)); + Entity second = mage.getVehicle(); + mage.leaveVehicle(); + assertTrue(AltarSeat.sit(mage, altar)); + assertTrue(second.isDead()); + AltarService.purify(altar, "test"); + assertFalse(mage.isInsideVehicle()); + AltarSeat.clear(altar); + } + + @Test + void seatsThatAreGoneOrAltarsNotLoadedAreSkipped() { + PlayerMock mage = fx.player("mage"); + BloodAltar altar = BloodAltar.create(BloodFixture.id("mage"), "o", fx.room(0, 10, 0), 0f, 1L); + BloodAltar unloaded = spy(altar); + doReturn(null).when(unloaded).center(); + assertFalse(AltarSeat.sit(mage, unloaded)); + + assertTrue(AltarSeat.sit(mage, altar)); + Entity seat = mage.getVehicle(); + mage.leaveVehicle(); + seat.remove(); + AltarSeat.clear(altar); + + assertTrue(AltarSeat.sit(mage, altar)); + BloodAltar other = BloodAltar.create("other", "o", fx.room(20, 10, 20), 0f, 1L); + PlayerMock rival = fx.player("rival"); + assertTrue(AltarSeat.sit(rival, other)); + rival.getVehicle().remove(); + AltarSeat.clearAll(); + assertFalse(mage.isInsideVehicle()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java index 1b731e7..89c09e2 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java @@ -71,7 +71,7 @@ void rightClickWithAltarItemPlacesItAndTouchingAnAltarAnswers() { PlayerInteractEvent touch = click(player, Action.RIGHT_CLICK_BLOCK, null, target, BlockFace.UP, EquipmentSlot.HAND); listener.onInteract(touch); assertTrue(touch.isCancelled()); - assertEquals("blood.altar.owner_touch", player.nextMessage()); + assertEquals("blood.altar.sit", player.nextMessage()); Block grass = fx.room(5, 10, 5); grass.setType(Material.SHORT_GRASS); @@ -284,7 +284,7 @@ void theBloodKnifeOpensTheQuestionAndArmourIsPoliced() { 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.owner_touch", mage.nextMessage()); + assertEquals("blood.altar.sit", 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.owner_touch", mage.nextMessage()); From a0a721043d9e2574bcbc14dbf45b70254e0091b8 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:19:07 +0100 Subject: [PATCH 24/55] Describe the blood knife as winding, not curved Co-Authored-By: Claude Opus 5.5 --- src/main/resources/wildmagic/bloodmagic/items.yml | 4 ++-- src/main/resources/wildmagic/bloodmagic/messages.yml | 2 +- src/main/resources/wildmagic/bloodmagic/voices.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/resources/wildmagic/bloodmagic/items.yml b/src/main/resources/wildmagic/bloodmagic/items.yml index 78ff95e..fe24ad8 100644 --- a/src/main/resources/wildmagic/bloodmagic/items.yml +++ b/src/main/resources/wildmagic/bloodmagic/items.yml @@ -12,7 +12,7 @@ items: - "#8c8c8c§oA slab of old carved stone, worn smooth" - "#8c8c8c§oby hands that are long gone." - "#5c5c5c§oIt is heavier than it should be." - # The Ritual Knife is an MMOItems lore weapon (name, lore and look come from MMOItems). + # The plain knife (shown as Hunting Knife) is an MMOItems lore weapon (name, lore and look come from MMOItems). ritual_knife: path: m.lore_weapons.ritual_knife name: "" @@ -21,6 +21,6 @@ items: path: ia.tfmc_blood:blood_knife name: "#8a0303Vein-Reaver" lore: - - "#5c0202§oCurved like a fang, and as old as one." + - "#5c0202§oIt winds like a vein, and it is older than you." - "#5c0202§oIt is warm to the touch," - "#5c0202§oand if you hold it still, it beats." diff --git a/src/main/resources/wildmagic/bloodmagic/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml index a4463ee..8c590cd 100644 --- a/src/main/resources/wildmagic/bloodmagic/messages.yml +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -70,7 +70,7 @@ blood: accepted: "#8a0303§oThe stone drinks. When you take your hand away, it is warmer than before" - complete: "#8a0303§oSeven kinds. The stone is red to its heart, and something cold and curved rests in your hand that was not there before" + complete: "#8a0303§oSeven 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}" diff --git a/src/main/resources/wildmagic/bloodmagic/voices.yml b/src/main/resources/wildmagic/bloodmagic/voices.yml index af5a1bd..2c9df85 100644 --- a/src/main/resources/wildmagic/bloodmagic/voices.yml +++ b/src/main/resources/wildmagic/bloodmagic/voices.yml @@ -56,7 +56,7 @@ voices: - "Wash your hands before you climb back up. They can smell it on you." - "The Lady in her tower would call this wickedness. Let her. She has never been hungry." choice: - - "Seven kinds have bled. Come down to the stone, and bring the curved blade. Your father has a question." + - "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 in all of Cerrith has ever kept me waiting." final: From 5e8125c428728db96add0bc4d25d18e334b37ebb Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:24:52 +0100 Subject: [PATCH 25/55] Vein-Reaver from MMOItems, hide Unbreakable on blood items Co-Authored-By: Claude Opus 5.5 --- .../net/tfminecraft/magic/blood/BloodArmour.java | 2 ++ .../java/net/tfminecraft/magic/blood/BloodItems.java | 2 ++ src/main/resources/wildmagic/bloodmagic/items.yml | 12 +++++------- .../tfminecraft/magic/blood/AltarServiceTest.java | 1 + .../net/tfminecraft/magic/blood/BloodConfigTest.java | 2 +- .../java/net/tfminecraft/magic/blood/ChoiceTest.java | 1 + 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java b/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java index ade1871..da23484 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodArmour.java @@ -6,6 +6,7 @@ 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; @@ -83,6 +84,7 @@ private static void rename(ItemStack item, String format, String victim) { 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); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodItems.java b/src/main/java/net/tfminecraft/magic/blood/BloodItems.java index 545f1df..049012d 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodItems.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodItems.java @@ -5,6 +5,7 @@ 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; @@ -46,6 +47,7 @@ public static ItemStack create(BloodItemKind kind, String characterId) { } if (kind != BloodItemKind.ALTAR) { meta.setUnbreakable(true); + meta.addItemFlags(ItemFlag.HIDE_UNBREAKABLE); } PersistentDataContainer pdc = meta.getPersistentDataContainer(); pdc.set(kindKey(), PersistentDataType.STRING, kind.key()); diff --git a/src/main/resources/wildmagic/bloodmagic/items.yml b/src/main/resources/wildmagic/bloodmagic/items.yml index fe24ad8..fb5a3ba 100644 --- a/src/main/resources/wildmagic/bloodmagic/items.yml +++ b/src/main/resources/wildmagic/bloodmagic/items.yml @@ -12,15 +12,13 @@ items: - "#8c8c8c§oA slab of old carved stone, worn smooth" - "#8c8c8c§oby hands that are long gone." - "#5c5c5c§oIt is heavier than it should be." - # The plain knife (shown as Hunting Knife) is an MMOItems lore weapon (name, lore and look come from MMOItems). + # 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 Vein-Reaver. ritual_knife: path: m.lore_weapons.ritual_knife name: "" lore: [] blood_knife: - path: ia.tfmc_blood:blood_knife - name: "#8a0303Vein-Reaver" - lore: - - "#5c0202§oIt winds like a vein, and it is older than you." - - "#5c0202§oIt is warm to the touch," - - "#5c0202§oand if you hold it still, it beats." + path: m.lore_weapons.blood_knife + name: "" + lore: [] diff --git a/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java index 26161a1..a664565 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java @@ -39,6 +39,7 @@ void itemsCarryKindAndOwner() { 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)); diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java index 22e72d4..8bccd70 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodConfigTest.java @@ -54,7 +54,7 @@ void bundledFileLoadsEverySection() { File bundled = new File("src/main/resources/wildmagic/bloodmagic"); assertTrue(BloodConfig.load(bundled, log)); assertTrue(BloodConfig.isEnabled()); - assertEquals("ia.tfmc_blood:blood_knife", BloodConfig.item(BloodItemKind.BLOOD_KNIFE).path()); + 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)); diff --git a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java index 9317f36..7809459 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java @@ -226,6 +226,7 @@ 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))); From a87181ae169291cec7f7213fd62ef2f991806bc6 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:31:47 +0100 Subject: [PATCH 26/55] Note the blood knife's new name Co-Authored-By: Claude Opus 5.5 --- src/main/resources/wildmagic/bloodmagic/items.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/wildmagic/bloodmagic/items.yml b/src/main/resources/wildmagic/bloodmagic/items.yml index fb5a3ba..7b0b0e1 100644 --- a/src/main/resources/wildmagic/bloodmagic/items.yml +++ b/src/main/resources/wildmagic/bloodmagic/items.yml @@ -13,7 +13,7 @@ items: - "#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 Vein-Reaver. + # the plain knife shows as Hunting Knife, the blood knife as Ritual Knife. ritual_knife: path: m.lore_weapons.ritual_knife name: "" From ae49624f9af0230fb8dcb2cd1e6c8312e24a820f Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:52:54 +0100 Subject: [PATCH 27/55] Meditate on the altar with /sit; both voices in the animal stage, drifting colours Right-clicking the altar no longer seats its owner: sitting on it (GSit /sit or any seat on top) is how they meditate there, and the stone answers with its own line (a colder one for anyone else). The plugin-made seat is gone. From the knife on, the mage's own thoughts answer Lorvear half the time. Animal lines are written from early to late and the pick follows the kinds given. Each voice now has a start and an end gradient (voices.yml colors) blended by how far the character has come: Lorvear grows from dim red into the bright Bloodmagic reds, the mage's own voice from pale and clear into dark blood. Messages written as {god}... or {self}... (the animal and rite reactions) are said in those colours. No line names another god or order any more. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/AltarSeat.java | 85 -------- .../tfminecraft/magic/blood/AltarService.java | 21 +- .../magic/blood/BloodListener.java | 23 +-- .../tfminecraft/magic/blood/BloodMagic.java | 1 - .../tfminecraft/magic/blood/VoiceColours.java | 79 ++++++++ .../magic/blood/WhisperConfig.java | 32 ++- .../magic/blood/WhisperService.java | 70 ++++++- .../wildmagic/bloodmagic/messages.yml | 17 +- .../resources/wildmagic/bloodmagic/voices.yml | 53 +++-- .../magic/blood/AltarSeatTest.java | 113 ----------- .../magic/blood/BloodListenerTest.java | 4 +- .../tfminecraft/magic/blood/VoiceTest.java | 185 ++++++++++++++++++ .../magic/blood/WhisperServiceTest.java | 13 +- 13 files changed, 446 insertions(+), 250 deletions(-) delete mode 100644 src/main/java/net/tfminecraft/magic/blood/AltarSeat.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/VoiceColours.java delete mode 100644 src/test/java/net/tfminecraft/magic/blood/AltarSeatTest.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/VoiceTest.java diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarSeat.java b/src/main/java/net/tfminecraft/magic/blood/AltarSeat.java deleted file mode 100644 index 8012ce1..0000000 --- a/src/main/java/net/tfminecraft/magic/blood/AltarSeat.java +++ /dev/null @@ -1,85 +0,0 @@ -package net.tfminecraft.magic.blood; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.bukkit.Location; -import org.bukkit.NamespacedKey; -import org.bukkit.entity.Entity; -import org.bukkit.entity.ItemDisplay; -import org.bukkit.entity.Player; -import org.bukkit.persistence.PersistentDataType; - -import net.tfminecraft.magic.Magic; - -/** - * The altar is the middle of a meditation circle: its owner sits on it (right-click with an empty - * hand) and meditation starts there like on any seat. The seat is an invisible, unsaved display on - * top of the altar; it goes as soon as it is left. - */ -public final class AltarSeat { - - private static final Map SEATS = new HashMap<>(); - - private AltarSeat() {} - - static NamespacedKey key() { - return new NamespacedKey(Magic.plugin, "altar_seat"); - } - - /** Sits the player on top of the altar; false when they already ride something or it is not loaded. */ - public static boolean sit(Player player, BloodAltar altar) { - Location center = altar.center(); - if (player.isInsideVehicle() || center == null) { - return false; - } - clear(altar); - Location top = center.clone().add(0, 0.5, 0); - top.setYaw(player.getLocation().getYaw()); - ItemDisplay seat = top.getWorld().spawn(top, ItemDisplay.class, display -> { - display.setPersistent(false); - display.getPersistentDataContainer().set(key(), PersistentDataType.STRING, altar.getId()); - }); - seat.addPassenger(player); - SEATS.put(altar.getId(), seat); - BloodFx.play("altar_sit", center, player); - return true; - } - - public static boolean isSeat(Entity entity) { - return entity != null && entity.getPersistentDataContainer().has(key(), PersistentDataType.STRING); - } - - /** A seat that was left goes. */ - public static void left(Entity vehicle) { - if (!isSeat(vehicle)) { - return; - } - SEATS.values().remove(vehicle); - vehicle.remove(); - } - - /** Takes the altar's seat away, whoever sits on it, e.g. when the altar is purified. */ - public static void clear(BloodAltar altar) { - Entity seat = SEATS.remove(altar.getId()); - if (seat != null) { - unseat(seat); - } - } - - static void clearAll() { - List seats = List.copyOf(SEATS.values()); - SEATS.clear(); - for (Entity seat : seats) { - unseat(seat); - } - } - - private static void unseat(Entity seat) { - for (Entity passenger : List.copyOf(seat.getPassengers())) { - passenger.leaveVehicle(); - } - seat.remove(); - } -} diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarService.java b/src/main/java/net/tfminecraft/magic/blood/AltarService.java index 10d6cbb..6e53261 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -5,6 +5,7 @@ import java.util.UUID; import org.bukkit.block.Block; +import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.Bukkit; @@ -106,7 +107,6 @@ public static void refreshFor(String characterId) { public static void purify(BloodAltar altar, String reason) { BloodFx.play("altar_purified", altar.center()); AltarCorruption.restore(altar); - AltarSeat.clear(altar); AltarDisplays.remove(altar); BloodAltars.remove(altar); BloodRecord record = BloodRecords.get(altar.getOwnerCharacter()); @@ -168,4 +168,23 @@ public static String touch(Player player, BloodAltar altar) { BloodFx.play(owner ? "altar_touch_owner" : "altar_touch_stranger", altar.center(), player); return owner ? Messages.get("blood.altar.owner_touch") : Messages.get("blood.altar.stranger_touch"); } + + /** + * 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()); + player.sendMessage(Messages.get(owner ? "blood.altar.sit" : "blood.altar.sit_stranger")); + BloodFx.play(owner ? "altar_sit" : "altar_touch_stranger", seat.getLocation(), player); + } } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index 39accc2..a34ce8d 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -18,7 +18,7 @@ import org.bukkit.event.block.BlockPistonRetractEvent; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityDamageByEntityEvent; -import org.bukkit.event.entity.EntityDismountEvent; +import org.bukkit.event.entity.EntityMountEvent; import org.bukkit.event.entity.EntityDeathEvent; import org.bukkit.event.entity.EntityPickupItemEvent; import org.bukkit.event.inventory.InventoryClickEvent; @@ -68,12 +68,6 @@ public void onInteract(PlayerInteractEvent event) { layDown(player, carried); return; } - // An empty hand sits the owner on their altar, the middle of its meditation circle. - if (character != null && character.equalsIgnoreCase(altar.getOwnerCharacter()) - && held.getType().isAir() && AltarSeat.sit(player, altar)) { - player.sendMessage(Messages.get("blood.altar.sit")); - return; - } player.sendMessage(AltarService.touch(player, altar)); return; } @@ -109,7 +103,7 @@ public void onInteractEntity(PlayerInteractEntityEvent event) { if (mage.isSneaking() && HuntConfig.carryEnabled()) { CharacterRiteService.Bind start = CarryService.start(mage, victim); if (start != CharacterRiteService.Bind.OK) { - mage.sendMessage(Messages.get("blood.rite.bind." + start.name().toLowerCase(Locale.ROOT))); + WhisperService.tell(mage, "blood.rite.bind." + start.name().toLowerCase(Locale.ROOT)); } return; } @@ -122,17 +116,20 @@ public void onInteractEntity(PlayerInteractEntityEvent event) { } event.setCancelled(true); AnimalRiteService.Bind result = AnimalRiteService.bind(event.getPlayer(), animal); - event.getPlayer().sendMessage(Messages.get("blood.animal.bind." + result.name().toLowerCase(Locale.ROOT))); + 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); - mage.sendMessage(Messages.get("blood.rite.bind." + result.name().toLowerCase(Locale.ROOT))); + WhisperService.tell(mage, "blood.rite.bind." + result.name().toLowerCase(Locale.ROOT)); } - @EventHandler - public void onDismount(EntityDismountEvent event) { - AltarSeat.left(event.getDismounted()); + /** 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()); + } } @EventHandler diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index 66836fe..ac8f11b 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -108,7 +108,6 @@ public static void stop() { BloodBoons.clear(player); } BloodPerms.clearAll(); - AltarSeat.clearAll(); CarryService.clear(); CharacterRiteService.clear(); BondService.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 index 71f22c1..f3393de 100644 --- a/src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java @@ -21,11 +21,20 @@ public enum Speaker { SELF } - /** How a speaker sounds, and its lines per stage. */ - public record Voice(String style, String sound, float pitch, Map> lines) { + /** + * 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) { public List lines(BloodStage stage) { return lines.getOrDefault(stage, List.of()); } + + public boolean coloured() { + return !start.isEmpty() || !end.isEmpty(); + } } private static double threshold = 30; @@ -66,8 +75,8 @@ static void reset() { requireAlone = true; aloneRadius = 24; VOICES.clear(); - VOICES.put(Speaker.GOD, new Voice("", "", 1f, Map.of())); - VOICES.put(Speaker.SELF, new Voice("", "", 1f, Map.of())); + VOICES.put(Speaker.GOD, new Voice("", "", 1f, Map.of(), List.of(), List.of())); + VOICES.put(Speaker.SELF, new Voice("", "", 1f, Map.of(), List.of(), List.of())); } static void load(ConfigurationSection whispers, ConfigurationSection voices, Logger log) { @@ -127,7 +136,20 @@ private static Voice voice(ConfigurationSection section, Logger log) { } } return new Voice(section.getString("style", ""), section.getString("sound", ""), - (float) section.getDouble("pitch", 1.0), lines); + (float) section.getDouble("pitch", 1.0), lines, + colours(section, "colors.start", log), colours(section, "colors.end", log)); + } + + 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() { diff --git a/src/main/java/net/tfminecraft/magic/blood/WhisperService.java b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java index f5f2947..749c8a8 100644 --- a/src/main/java/net/tfminecraft/magic/blood/WhisperService.java +++ b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java @@ -2,6 +2,7 @@ import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Random; import java.util.UUID; @@ -15,6 +16,7 @@ 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; @@ -172,9 +174,9 @@ private static void schedule(Player player, long now) { /** 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); - // At the question the mage's "own" voice starts arguing back: the Void Reaper, unseen. - if (record.getStage() == BloodStage.CHOICE - && !WhisperConfig.voice(WhisperConfig.Speaker.SELF).lines(BloodStage.CHOICE).isEmpty() + // 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; } @@ -192,10 +194,68 @@ public static void speakNext(Player player, BloodRecord record) { return; } if (!lines.isEmpty()) { - speak(player, speaker, lines.get(random.nextInt(lines.size()))); + speak(player, speaker, lines.get(pick(lines.size(), stageProgress(record)))); } } + /** + * 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. */ + public static void tell(Player player, String key) { + String raw = Messages.getRaw(key); + 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; } @@ -203,7 +263,7 @@ public static WhisperConfig.Speaker speaker(BloodRecord record) { /** 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(MagicText.format(voice.style() + line)); + player.sendMessage(voiced(player, speaker, line)); if (!voice.sound().isBlank()) { player.playSound(player.getLocation(), voice.sound(), 0.6f, voice.pitch()); } diff --git a/src/main/resources/wildmagic/bloodmagic/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml index 8c590cd..fcebe99 100644 --- a/src/main/resources/wildmagic/bloodmagic/messages.yml +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -18,7 +18,9 @@ blood: unbreakable: "#8c8c8c§oYour tools ring off the stone without leaving a scratch" - sit: "#8a0303§oYou kneel on the stone. It is warm, and it is listening" + # Sitting on the altar (/sit) to meditate; right-clicking it gives owner_touch / stranger_touch. + sit: "#8a0303§oYou sit on the stone. It is warm beneath you, and it is listening" + sit_stranger: "#8c8c8c§oThe stone is cold under you. You cannot shake the feeling that it would rather you stood" owner_touch: "#8a0303§oThe stone is warm under your palm, like skin. You feel it breathe" stranger_touch: "#8c8c8c§oCold carved stone, dark in the seams. The longer you look at it, the less you want to touch it" @@ -54,15 +56,16 @@ blood: not_species: "#8c8c8c§oThe stone does not hunger for that" - known: "#8a0303§oI have tasted that kind already, child. Bring me another" + # 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: "#8a0303§oNot yet, child. The stone is still drinking the last one" + cooldown: "{god}Not yet, child. The stone is still drinking the last one" - not_stolen: "#8a0303§oNot that one. A gift that costs nothing is no gift. Take what others keep" + not_stolen: "{god}Not that one. A gift that costs nothing is no gift. Take what others keep" - too_far: "#8a0303§oToo far. Lead it down to the stone" + too_far: "{god}Too far. Lead it down to the stone" - busy: "#8a0303§oOne at a time. Finish what lies on 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" @@ -98,7 +101,7 @@ blood: repeat: "#8c8c8c§oThat blood is still drying on the stone. Find someone else" - reserved: "#6e6878§oNot them. Not yet. They are for later" + 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" diff --git a/src/main/resources/wildmagic/bloodmagic/voices.yml b/src/main/resources/wildmagic/bloodmagic/voices.yml index 2c9df85..7e3bec1 100644 --- a/src/main/resources/wildmagic/bloodmagic/voices.yml +++ b/src/main/resources/wildmagic/bloodmagic/voices.yml @@ -21,11 +21,16 @@ whispers: 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 speaks in red and calls the mage "child". The other voice sounds like the mage's own thoughts, but it -# is the Void Reaper, working on them unseen: it argues against kneeling at the question (choice lines, -# half the time) and is all a mage hears once they refuse. It never names itself; it only hints at the -# quiet, the cold and the empty. Grey with a touch of the void. -# whispered lines are said in order and end with the knife and altar; other stages pick at random. +# 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 are written from +# early to late: the pick comes from a third of the list that slides along with the kinds given. +# 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] @@ -33,7 +38,10 @@ voices: require_alone: true alone_radius: 24 god: - style: "#8a0303§o" + style: "§o" + colors: + start: ["#6b1f1f", "#561515"] + end: ["#aa0000", "#d10202", "#e80505"] sound: minecraft:ambient.cave pitch: 0.5 lines: @@ -49,32 +57,49 @@ voices: - "Look in your pack. Someone has been generous. Do not make them regret it." animal: - "The stone is thirsty, child. Can you not hear it?" - - "Every pen in the valley is full, and every shepherd sleeps." - "Lead it down gently. Fear sours the blood." + - "Every pen in the valley is full, and every shepherd sleeps." + - "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." - - "I remember the taste of the last one. Bring me a kind I have not had." - "Wash your hands before you climb back up. They can smell it on you." - - "The Lady in her tower would call this wickedness. Let her. She has never been hungry." + - "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." + - "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." 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 in all of Cerrith has ever kept me waiting." + - "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 Reaper hoards the dead. I prefer them warm, and given freely by someone they loved." + - "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 the Lady send her hunters. I have outlived every one of them." + - "Let them send their hunters. I have outlived every one of them." self: - style: "#6e6878§o" + style: "§o" + colors: + start: ["#d8d2e0", "#bdb6c9"] + end: ["#8a0303", "#5c0202", "#4a0a14"] sound: minecraft:entity.warden.heartbeat pitch: 1.2 lines: + animal: + - "What am I doing down here? It's only a stone. Only a voice." + - "Someone will wake to an empty pen tomorrow. Someone who never did anything to me." + - "I could throw the knife in the river. I could. I just haven't yet." + - "My hands shook the first time. They don't shake as much now." + - "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've started counting the kinds without meaning to. Four. Five." + - "Nobody tells you how warm it is. Nobody tells you how good the warmth feels." + - "I washed my hands three times today. I didn't need to. I liked how red the water went." + - "Seven kinds, and then it's finished. Then I'll stop. Won't I?" choice: - "Kneel? To him? I have never knelt to anyone." - "Father. As if I need another one." @@ -98,6 +123,6 @@ voices: - "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." - - "The Lady would call me evil. She'd be right. And far too late." + - "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/blood/AltarSeatTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarSeatTest.java deleted file mode 100644 index f84eeec..0000000 --- a/src/test/java/net/tfminecraft/magic/blood/AltarSeatTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package net.tfminecraft.magic.blood; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import java.nio.file.Path; - -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.block.Action; -import org.bukkit.event.entity.EntityDismountEvent; -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.entity.PlayerMock; - -class AltarSeatTest { - @TempDir Path temp; - BloodFixture fx; - final BloodListener listener = new BloodListener(); - - @BeforeEach - void setup() { - fx = new BloodFixture(temp); - } - - @AfterEach - void cleanup() { - AltarSeat.clearAll(); - fx.close(); - } - - PlayerInteractEvent click(PlayerMock player, Block block) { - return new PlayerInteractEvent(player, Action.RIGHT_CLICK_BLOCK, player.getInventory().getItemInMainHand(), - block, BlockFace.UP, EquipmentSlot.HAND); - } - - @Test - void theOwnerSitsOnTheAltarWithAnEmptyHandAndTheSeatGoesWhenLeft() { - PlayerMock mage = fx.player("mage"); - Block target = fx.room(0, 10, 0); - BloodAltar altar = BloodAltar.create(BloodFixture.id("mage"), mage.getUniqueId().toString(), target, 0f, 1L); - BloodAltars.add(altar); - - mage.getInventory().setItemInMainHand(new ItemStack(Material.STONE)); - listener.onInteract(click(mage, target)); - assertEquals("blood.altar.owner_touch", mage.nextMessage()); - assertFalse(mage.isInsideVehicle()); - PlayerMock stranger = fx.player("stranger"); - listener.onInteract(click(stranger, target)); - assertEquals("blood.altar.stranger_touch", stranger.nextMessage()); - PlayerMock nobody = fx.server.addPlayer(); - listener.onInteract(click(nobody, target)); - assertEquals("blood.altar.stranger_touch", nobody.nextMessage()); - - mage.getInventory().setItemInMainHand(null); - listener.onInteract(click(mage, target)); - assertEquals("blood.altar.sit", mage.nextMessage()); - Entity seat = mage.getVehicle(); - assertInstanceOf(ItemDisplay.class, seat); - assertTrue(AltarSeat.isSeat(seat)); - assertFalse(seat.isPersistent()); - assertEquals(target.getY() + 1.0, seat.getLocation().getY(), 1e-9); - - listener.onInteract(click(mage, target)); - assertEquals("blood.altar.owner_touch", mage.nextMessage()); - - listener.onDismount(new EntityDismountEvent(mage, seat)); - assertTrue(seat.isDead()); - AltarSeat.left(mage); - assertFalse(AltarSeat.isSeat(null)); - - mage.leaveVehicle(); - assertTrue(AltarSeat.sit(mage, altar)); - Entity second = mage.getVehicle(); - mage.leaveVehicle(); - assertTrue(AltarSeat.sit(mage, altar)); - assertTrue(second.isDead()); - AltarService.purify(altar, "test"); - assertFalse(mage.isInsideVehicle()); - AltarSeat.clear(altar); - } - - @Test - void seatsThatAreGoneOrAltarsNotLoadedAreSkipped() { - PlayerMock mage = fx.player("mage"); - BloodAltar altar = BloodAltar.create(BloodFixture.id("mage"), "o", fx.room(0, 10, 0), 0f, 1L); - BloodAltar unloaded = spy(altar); - doReturn(null).when(unloaded).center(); - assertFalse(AltarSeat.sit(mage, unloaded)); - - assertTrue(AltarSeat.sit(mage, altar)); - Entity seat = mage.getVehicle(); - mage.leaveVehicle(); - seat.remove(); - AltarSeat.clear(altar); - - assertTrue(AltarSeat.sit(mage, altar)); - BloodAltar other = BloodAltar.create("other", "o", fx.room(20, 10, 20), 0f, 1L); - PlayerMock rival = fx.player("rival"); - assertTrue(AltarSeat.sit(rival, other)); - rival.getVehicle().remove(); - AltarSeat.clearAll(); - assertFalse(mage.isInsideVehicle()); - } -} diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java index 89c09e2..1b731e7 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java @@ -71,7 +71,7 @@ void rightClickWithAltarItemPlacesItAndTouchingAnAltarAnswers() { PlayerInteractEvent touch = click(player, Action.RIGHT_CLICK_BLOCK, null, target, BlockFace.UP, EquipmentSlot.HAND); listener.onInteract(touch); assertTrue(touch.isCancelled()); - assertEquals("blood.altar.sit", player.nextMessage()); + assertEquals("blood.altar.owner_touch", player.nextMessage()); Block grass = fx.room(5, 10, 5); grass.setType(Material.SHORT_GRASS); @@ -284,7 +284,7 @@ void theBloodKnifeOpensTheQuestionAndArmourIsPoliced() { 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.sit", mage.nextMessage()); + assertEquals("blood.altar.owner_touch", 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.owner_touch", mage.nextMessage()); 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..24d21bc --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java @@ -0,0 +1,185 @@ +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; } + }; + WhisperService.speakNext(mage, record); + assertEquals(self.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); + int window = (self.size() + 2) / 3; + assertEquals(self.get(self.size() - window), plain(mage.nextMessage())); + + 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 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", 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/WhisperServiceTest.java b/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java index e825a63..21fc0ac 100644 --- a/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java @@ -148,20 +148,25 @@ void teachingLinesRunInOrderAndEndInTheGift() { 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); - String self = net.tfminecraft.magic.util.MagicText.format(WhisperConfig.voice(WhisperConfig.Speaker.SELF).style()); - String god = net.tfminecraft.magic.util.MagicText.format(WhisperConfig.voice(WhisperConfig.Speaker.GOD).style()); + 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(player.nextMessage().startsWith(self)); + assertTrue(self.contains(plain(player.nextMessage()))); WhisperService.speakNext(player, record); - assertTrue(player.nextMessage().startsWith(god)); + 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); From 5d4d6f3efb2e150c98538da5a98f2a86bf8a53c1 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:12:24 +0100 Subject: [PATCH 28/55] The altar sets out its own pedestal ring Placing the altar puts an InteractibleFurniture pedestal on every free spot of its meditation ring (open, on solid floor; taken spots are left for the player's own). They belong to the altar: nobody can pick them up, carry them off or break them. Purifying the altar removes them and drops whatever sits on them; pedestals that are not loaded then stay bound in data/bloodmagic/leftover_pedestals.json and go when their chunk loads. Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/AltarPedestals.java | 196 ++++++++++++++++ .../tfminecraft/magic/blood/AltarService.java | 2 + .../tfminecraft/magic/blood/BloodAltar.java | 9 + .../magic/blood/BloodListener.java | 22 ++ .../tfminecraft/magic/blood/BloodMagic.java | 2 + .../tfminecraft/magic/blood/BloodStore.java | 24 ++ .../wildmagic/bloodmagic/messages.yml | 2 + .../magic/blood/AltarPedestalsTest.java | 220 ++++++++++++++++++ .../tfminecraft/magic/blood/BloodFixture.java | 2 + 9 files changed, 479 insertions(+) create mode 100644 src/main/java/net/tfminecraft/magic/blood/AltarPedestals.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/AltarPedestalsTest.java 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..0f5a637 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/AltarPedestals.java @@ -0,0 +1,196 @@ +package net.tfminecraft.magic.blood; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +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.ItemDisplay; +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.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; + +/** + * 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; + } + + /** + * 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/AltarService.java b/src/main/java/net/tfminecraft/magic/blood/AltarService.java index 6e53261..7f75f3c 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -71,6 +71,7 @@ public static String place(Player player, ItemStack hand, Block 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); @@ -108,6 +109,7 @@ public static void purify(BloodAltar altar, String reason) { BloodFx.play("altar_purified", altar.center()); AltarCorruption.restore(altar); AltarDisplays.remove(altar); + AltarPedestals.remove(altar); BloodAltars.remove(altar); BloodRecord record = BloodRecords.get(altar.getOwnerCharacter()); if (record != null && altar.getId().equals(record.getAltarId())) { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java b/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java index 382e516..4d17d1d 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodAltar.java @@ -28,6 +28,8 @@ public final class BloodAltar { 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) {} @@ -136,6 +138,13 @@ public List getCorrupted() { return corrupted; } + public List getPedestals() { + if (pedestals == null) { + pedestals = new ArrayList<>(); + } + return pedestals; + } + public String blockKey() { return key(world, x, y, z); } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index a34ce8d..68c1952 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -35,6 +35,8 @@ import com.destroystokyo.paper.event.player.PlayerArmorChangeEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; import net.tfminecraft.magic.Magic; import net.tfminecraft.magic.Messages; import net.tfminecraft.tlibs.character.CharacterDeathEvent; @@ -124,6 +126,25 @@ private static void layDown(Player mage, Player 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")); + } + } + /** 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) { @@ -230,6 +251,7 @@ 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); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index ac8f11b..3a1b01a 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -26,6 +26,7 @@ public static void start(Plugin plugin) { BloodRecords.init(store); BloodAltars.init(store); BloodNotes.init(store); + AltarPedestals.init(store); BondService.init(store); plugin.getServer().getPluginManager().registerEvents(new BloodListener(), plugin); MagicTickService.register(BloodMagic::tick); @@ -114,6 +115,7 @@ public static void stop() { AnimalRiteService.releaseAll(); StolenClues.clear(); BloodNotes.clear(); + AltarPedestals.clear(); WhisperService.clear(); BloodRecords.clear(); BloodAltars.clear(); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodStore.java b/src/main/java/net/tfminecraft/magic/blood/BloodStore.java index 22454df..75af643 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodStore.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodStore.java @@ -28,12 +28,14 @@ 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) { @@ -41,6 +43,7 @@ public BloodStore(File root, Logger log) { 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; } @@ -122,6 +125,27 @@ 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"); diff --git a/src/main/resources/wildmagic/bloodmagic/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml index fcebe99..888d55f 100644 --- a/src/main/resources/wildmagic/bloodmagic/messages.yml +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -17,6 +17,8 @@ blood: 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" # Sitting on the altar (/sit) to meditate; right-clicking it gives owner_touch / stranger_touch. sit: "#8a0303§oYou sit on the stone. It is warm beneath you, and it is listening" 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..a35f925 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/AltarPedestalsTest.java @@ -0,0 +1,220 @@ +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()); + } + + @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/BloodFixture.java b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java index c9154fd..ecbed89 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java @@ -48,6 +48,7 @@ final class BloodFixture implements AutoCloseable { 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()); } @@ -142,6 +143,7 @@ public void close() { AltarRegions.setQuery(null); BloodRecords.clear(); BloodAltars.clear(); + AltarPedestals.clear(); BloodConfig.reset(); messages.close(); characters.unregister(); From 0649834688e7ca91ff0352626bef44138fa00f26 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:20:53 +0100 Subject: [PATCH 29/55] Corruption never decorates on or under a barrier Pedestals (their barrier block) and the altar are not floor or ceiling for candles, redstone, roots or vines, so nothing grows over an artifact. Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/AltarCorruption.java | 9 +++++++-- .../magic/blood/CorruptionAndCluesTest.java | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java index 396cb15..e413c05 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java @@ -75,11 +75,16 @@ private static boolean changeOne(BloodAltar altar, Block center, int radius, Lis static boolean fits(CorruptionConfig.Rule rule, Block block) { return switch (rule.on()) { case CONVERT -> rule.from().contains(block.getType()) && exposed(block); - case FLOOR -> block.isEmpty() && block.getRelative(BlockFace.DOWN).getType().isSolid(); - case CEILING -> block.isEmpty() && block.getRelative(BlockFace.UP).getType().isSolid(); + case FLOOR -> block.isEmpty() && rests(block.getRelative(BlockFace.DOWN)); + case CEILING -> block.isEmpty() && rests(block.getRelative(BlockFace.UP)); }; } + /** 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()) { diff --git a/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java index 269f225..38ad48d 100644 --- a/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java @@ -112,6 +112,24 @@ void spreadConvertsDecoratesWithinBudgetAndRestoreUndoesIt() throws Exception { } } + @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 }", From 841ee37132161819634917f92c14a99ec47fec4e Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:43:42 +0100 Subject: [PATCH 30/55] The altar lays a default chamber and locks it Placing the altar relays its chamber's floor, walls and ceiling in placement.room.template (the same for every player, blocks the corruption turns) with hidden light in the top corners; openings in the walls and ceiling stay open. It refuses when water or lava is in or against the shell, on any WorldGuard region in the 11x11, when a claim or installation denies a corner, and when the chamber would have no doorway two blocks high (it is locked once laid). After that nobody but staff (magic.blood.bypass, under magic.admin) may place, break, pour, hang or set furniture in the chamber out to the corruption's reach; pistons and explosions leave it alone too. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/AltarRoom.java | 162 ++++++++--- .../tfminecraft/magic/blood/AltarService.java | 10 + .../tfminecraft/magic/blood/BloodConfig.java | 2 + .../magic/blood/BloodListener.java | 62 +++- .../magic/blood/CorruptionConfig.java | 9 + .../tfminecraft/magic/blood/RoomTemplate.java | 83 ++++++ src/main/resources/plugin.yml | 4 + .../wildmagic/bloodmagic/messages.yml | 3 + .../wildmagic/bloodmagic/settings.yml | 22 +- .../magic/blood/AltarRoomTest.java | 11 +- .../tfminecraft/magic/blood/BloodFixture.java | 6 +- .../magic/blood/BloodListenerTest.java | 9 +- .../tfminecraft/magic/blood/RoomLockTest.java | 264 ++++++++++++++++++ 13 files changed, 601 insertions(+), 46 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/blood/RoomTemplate.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/RoomLockTest.java diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarRoom.java b/src/main/java/net/tfminecraft/magic/blood/AltarRoom.java index 1882642..3e6ec5d 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarRoom.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarRoom.java @@ -1,43 +1,64 @@ 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 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). Its room must give them open floor: a square reaching the farthest - * pedestal on every side, placement.room.height blocks high. Small gaps are carved and floor holes - * filled with the stone the player stands on; too much rock, something that must not move, claimed - * ground or opening the rock to the sky refuses the stone instead. + * 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 + 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 and floor holes filled. */ - public record Plan(Verdict verdict, List carve, List fill, Material floor) { + /** What placing the altar would change: blocks carved to air, blocks laid, and hidden lights. */ + public record Plan(Verdict verdict, List carve, Map lay, List lights) { static Plan refused(Verdict verdict) { - return new Plan(verdict, List.of(), List.of(), Material.STONE); + return new Plan(verdict, List.of(), Map.of(), List.of()); } public void apply() { for (Block block : carve) { block.setType(Material.AIR, false); } - for (Block block : fill) { - block.setType(floor, false); + for (Map.Entry entry : lay.entrySet()) { + entry.getKey().setType(entry.getValue(), false); + } + for (Block block : lights) { + block.setType(Material.LIGHT, false); + Levelled data = (Levelled) block.getBlockData(); + data.setLevel(RoomTemplate.light()); + block.setBlockData(data, false); } } } @@ -54,48 +75,125 @@ public static int size() { return halfWidth() * 2 + 1; } - /** The plan for an altar at target, floor holes filled like standingOn (else the altar's floor). */ + /** + * 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(), List.of(), Material.STONE); + return new Plan(Verdict.OK, List.of(), Map.of(), List.of()); } 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<>(); - List fill = new ArrayList<>(); - for (int x = target.getX() - half; x <= target.getX() + half; x++) { - for (int z = target.getZ() - half; z <= target.getZ() + half; z++) { - Block floor = world.getBlockAt(x, target.getY() - 1, z); - if (!floor.getType().isSolid()) { - if (!movable(floor)) { - return Plan.refused(Verdict.BLOCKED); - } - fill.add(floor); + Map lay = new LinkedHashMap<>(); + List lights = 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 y = target.getY(); y < target.getY() + height; y++) { - Block block = world.getBlockAt(x, y, z); - if (block.isEmpty() || block.equals(target)) { + 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); + } + if (RoomTemplate.light() > 0 && Math.abs(dx) == half && Math.abs(dz) == half && dy == height - 1) { + lights.add(block); + } continue; } - if (!movable(block)) { + // 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); } - carve.add(block); + 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 (BloodConfig.requireUnderground() + if (inside && BloodConfig.requireUnderground() && cover(world, x, target.getY() + height - 1, z) < BloodConfig.minCover()) { return Plan.refused(Verdict.SHALLOW); } - if (AltarRegions.blocked(floor.getLocation())) { - return Plan.refused(Verdict.BLOCKED); - } } } - if (carve.size() + fill.size() > BloodConfig.roomMaxChanges()) { + if (carve.size() + filled > BloodConfig.roomMaxChanges()) { return Plan.refused(Verdict.TOO_MUCH); } - return new Plan(Verdict.OK, List.copyOf(carve), List.copyOf(fill), floorOf(standingOn, target.getRelative(0, -1, 0))); + if (!doorway) { + return Plan.refused(Verdict.SEALED); + } + return new Plan(Verdict.OK, List.copyOf(carve), Collections.unmodifiableMap(lay), List.copyOf(lights)); + } + + /** 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. */ diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarService.java b/src/main/java/net/tfminecraft/magic/blood/AltarService.java index 7f75f3c..d719e1f 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -4,6 +4,7 @@ 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; @@ -66,6 +67,15 @@ public static String place(Player player, ItemStack hand, Block target) { 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()); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java index 0bfff1e..c37a088 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java @@ -132,6 +132,7 @@ public static boolean load(File source, Logger log) { 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); @@ -180,6 +181,7 @@ public static void reset() { roomEnabled = true; roomHeight = 3; roomMaxChanges = 150; + RoomTemplate.reset(); enabled = false; ITEMS.clear(); for (BloodItemKind kind : BloodItemKind.values()) { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index 68c1952..d5cb1df 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -19,6 +19,10 @@ 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.InventoryClickEvent; @@ -37,6 +41,7 @@ import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; +import net.tfminecraft.interactiblefurniture.events.FurniturePlaceEvent; import net.tfminecraft.magic.Magic; import net.tfminecraft.magic.Messages; import net.tfminecraft.tlibs.character.CharacterDeathEvent; @@ -47,6 +52,9 @@ /** 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) { @@ -200,6 +208,54 @@ public void onLeash(PlayerLeashEntityEvent event) { 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); } } @@ -214,6 +270,8 @@ public void onBreak(BlockBreakEvent event) { event.setCancelled(true); event.getPlayer().sendMessage(Messages.get("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); } } @@ -241,9 +299,9 @@ public void onBlockExplode(BlockExplodeEvent event) { event.blockList().removeIf(BloodListener::guarded); } - /** The altar or a block it has changed. */ + /** 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); + return BloodAltars.at(block) != null || AltarCorruption.isCorrupted(block) || AltarRoom.lockedBy(block) != null; } @EventHandler diff --git a/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java b/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java index f11049f..3fcdf28 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java @@ -112,6 +112,15 @@ private static List materials(Object raw, Logger log) { 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); 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..714e4de --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/RoomTemplate.java @@ -0,0 +1,83 @@ +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.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 RoomTemplate() {} + + static void reset() { + floor = List.of(); + walls = List.of(); + ceiling = List.of(); + light = 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))); + } + + 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/resources/plugin.yml b/src/main/resources/plugin.yml index 8092642..bd20e1a 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -28,6 +28,7 @@ permissions: default: op children: magic.admin.reload: true + magic.blood.bypass: true magic.admin.reload: description: Legacy permission alias for magic.admin default: op @@ -49,6 +50,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 + default: op magic.weapon.give: description: Give completed weapons (default give-permission) default: op diff --git a/src/main/resources/wildmagic/bloodmagic/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml index 888d55f..61f237a 100644 --- a/src/main/resources/wildmagic/bloodmagic/messages.yml +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -19,6 +19,8 @@ blood: 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. sit: "#8a0303§oYou sit on the stone. It is warm beneath you, and it is listening" @@ -42,6 +44,7 @@ blood: 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: diff --git a/src/main/resources/wildmagic/bloodmagic/settings.yml b/src/main/resources/wildmagic/bloodmagic/settings.yml index 528afa5..88df084 100644 --- a/src/main/resources/wildmagic/bloodmagic/settings.yml +++ b/src/main/resources/wildmagic/bloodmagic/settings.yml @@ -41,16 +41,26 @@ placement: 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 shapes the room: open floor out - # to the farthest pedestal on every side (9 by 9 by default), height blocks high. Small gaps are - # carved and floor holes filled with the stone the player stands on. If more than max_changes blocks - # would change, or the room would hit 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. - # The carved room stays when the altar is purified. + # 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. light: hidden light level in the four top + # corners, 0 for none. An empty list leaves that part as it was. + template: + floor: [STONE_BRICKS, STONE_BRICKS, STONE_BRICKS, CRACKED_STONE_BRICKS, STONE] + walls: [STONE, STONE, STONE, COBBLESTONE, ANDESITE] + ceiling: [STONE, STONE, STONE, COBBLESTONE] + light: 9 # How often (minutes) altars of dead owners are checked and purified. purify_check_minutes: 10 diff --git a/src/test/java/net/tfminecraft/magic/blood/AltarRoomTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarRoomTest.java index 33fef81..5318ef6 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AltarRoomTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AltarRoomTest.java @@ -38,6 +38,7 @@ 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); @@ -47,8 +48,8 @@ void aNearlyOpenRoomIsCarvedAndItsFloorHolesFilled() throws Exception { AltarRoom.Plan plan = AltarRoom.plan(target, standing); assertEquals(AltarRoom.Verdict.OK, plan.verdict()); assertEquals(81, plan.carve().size()); - assertEquals(List.of(hole), plan.fill()); - assertEquals(Material.DEEPSLATE, plan.floor()); + 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()); @@ -63,6 +64,12 @@ void tooMuchRockSomethingThatWillNotMoveOrTheSkyRefuseIt() throws Exception { 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); diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java index ecbed89..cdbd862 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java @@ -98,10 +98,14 @@ PlayerMock player(String label) { return player; } - /** An open chamber around the pocket at x, y, z, wide and high enough for the altar's meditation room. */ + /** + * 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); } diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java index 1b731e7..4403293 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java @@ -73,7 +73,7 @@ void rightClickWithAltarItemPlacesItAndTouchingAnAltarAnswers() { assertTrue(touch.isCancelled()); assertEquals("blood.altar.owner_touch", player.nextMessage()); - Block grass = fx.room(5, 10, 5); + Block grass = fx.room(30, 10, 30); grass.setType(Material.SHORT_GRASS); fx.record("other", BloodStage.ANIMAL); PlayerMock other = fx.player("other"); @@ -90,7 +90,8 @@ void protectionStopsBreakingPistonsAndExplosions() { PlayerMock player = fx.player("mage"); Block target = fx.pocket(0, 10, 0); BloodAltars.add(BloodAltar.create("c", "o", target, 0f, 1L)); - Block other = fx.world.getBlockAt(3, 10, 3); + // Outside the altar's locked chamber. + Block other = fx.world.getBlockAt(20, 10, 20); BlockBreakEvent breakAltar = new BlockBreakEvent(target, player); listener.onBreak(breakAltar); @@ -147,9 +148,11 @@ void protectionStopsBreakingPistonsAndExplosions() { 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); - assertFalse(breakRestored.isCancelled()); + 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)); 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..5a511d7 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/RoomLockTest.java @@ -0,0 +1,264 @@ +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", + ""); + + @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()); + 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"); + assertFalse(RoomTemplate.hasFloor()); + assertEquals(0, RoomTemplate.light()); + assertNull(RoomTemplate.wall(new java.util.Random())); + assertNull(RoomTemplate.ceiling(new java.util.Random())); + } +} From 033fc38080f51939e62b5392593e47d90edc2ea8 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:04:05 +0100 Subject: [PATCH 31/55] Animal rite bleeds out; voices wait for the chamber and grow colder; candles The ritual knife's cut no longer kills: the bound animal bleeds out on the stone (animal.rite.bleed_seconds, 15) with blood running into the stone and a slowing heartbeat, a line at the cut and halfway, while its mage stays within stay_range of the altar. Only then does the stone drink and the kind count. Stepping away, switching character or leaving spills it, wasted; nothing else can hurt a bound animal. Animal-stage voice lines now come in tiers by kinds given (lines.animal.), from the mage's doubts before the first kind to numbness by the fifth. Once the altar stands they speak only in its chamber, while the stone is ready for the next kind and no rite is under way. The chamber template stands lit black candles in the floor corners and the altar glows (an unseen light above it, removed on purify); no hidden ceiling lights by default. magic.blood.bypass is no longer a child of magic.admin. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/AltarRoom.java | 44 +++-- .../tfminecraft/magic/blood/AltarService.java | 5 + .../tfminecraft/magic/blood/AnimalConfig.java | 26 +++ .../magic/blood/AnimalRiteService.java | 144 ++++++++++++----- .../tfminecraft/magic/blood/RoomTemplate.java | 30 ++++ .../magic/blood/WhisperConfig.java | 49 +++++- .../magic/blood/WhisperService.java | 28 +++- src/main/resources/plugin.yml | 3 +- .../wildmagic/bloodmagic/animals.yml | 10 +- .../resources/wildmagic/bloodmagic/fx.yml | 22 +++ .../wildmagic/bloodmagic/messages.yml | 4 + .../wildmagic/bloodmagic/settings.yml | 10 +- .../resources/wildmagic/bloodmagic/voices.yml | 67 +++++--- .../magic/blood/AnimalRiteTest.java | 153 ++++++++++++++---- .../tfminecraft/magic/blood/RoomLockTest.java | 16 +- .../tfminecraft/magic/blood/VoiceTest.java | 64 +++++++- 16 files changed, 563 insertions(+), 112 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarRoom.java b/src/main/java/net/tfminecraft/magic/blood/AltarRoom.java index 3e6ec5d..4304936 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarRoom.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarRoom.java @@ -13,6 +13,7 @@ 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; @@ -41,10 +42,14 @@ public enum Verdict { SEALED } - /** What placing the altar would change: blocks carved to air, blocks laid, and hidden lights. */ - public record Plan(Verdict verdict, List carve, Map lay, List lights) { + /** + * 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()); + return new Plan(verdict, List.of(), Map.of(), List.of(), List.of(), null); } public void apply() { @@ -55,11 +60,25 @@ public void apply() { entry.getKey().setType(entry.getValue(), false); } for (Block block : lights) { - block.setType(Material.LIGHT, false); - Levelled data = (Levelled) block.getBlockData(); - data.setLevel(RoomTemplate.light()); + 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); } } @@ -81,7 +100,7 @@ public static int size() { */ public static Plan plan(Block target, Block standingOn) { if (!BloodConfig.roomEnabled()) { - return new Plan(Verdict.OK, List.of(), Map.of(), List.of()); + return new Plan(Verdict.OK, List.of(), Map.of(), List.of(), List.of(), null); } int half = halfWidth(); int height = BloodConfig.roomHeight(); @@ -91,6 +110,7 @@ public static Plan plan(Block target, Block standingOn) { 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++) { @@ -113,9 +133,13 @@ public static Plan plan(Block target, Block standingOn) { } carve.add(block); } - if (RoomTemplate.light() > 0 && Math.abs(dx) == half && Math.abs(dz) == half && dy == height - 1) { + 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. @@ -157,7 +181,9 @@ && cover(world, x, target.getY() + height - 1, z) < BloodConfig.minCover()) { if (!doorway) { return Plan.refused(Verdict.SEALED); } - return new Plan(Verdict.OK, List.copyOf(carve), Collections.unmodifiableMap(lay), List.copyOf(lights)); + 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. */ diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarService.java b/src/main/java/net/tfminecraft/magic/blood/AltarService.java index d719e1f..8f393af 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -120,6 +120,11 @@ public static void purify(BloodAltar altar, String reason) { 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())) { diff --git a/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java b/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java index f7d2ede..834eef4 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java @@ -21,6 +21,9 @@ public final class AnimalConfig { private static String traceModel; private static long traceMillis; private static long noteDelayMillis; + private static int bleedSeconds; + private static double stayRange; + private static Set heartbeats; static { reset(); @@ -39,6 +42,9 @@ static void reset() { 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); } static void load(ConfigurationSection section, Logger log) { @@ -64,6 +70,26 @@ static void load(ConfigurationSection section, Logger log) { 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")); + } + } + + /** 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() { diff --git a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java index 0821a9d..8373cad 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java @@ -24,9 +24,12 @@ 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 kills it or it breaks free. Seven different - * kinds, one per rolling day, taken from other keepers, finish the stage. + * 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 { @@ -43,8 +46,17 @@ public enum Bind { 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) {} + 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; @@ -99,7 +111,7 @@ public static Bind bind(Player player, LivingEntity animal) { 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)); + expires, owners, stolen, 0, false)); return Bind.OK; } @@ -122,47 +134,57 @@ public static boolean isBound(UUID animal) { } /** - * The binding mage's own ritual knife opens a bound animal in one stroke: the hit is made lethal, - * so the death and the sacrifice follow at once. Every other hit keeps its own damage. + * 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 || !(event.getDamager() instanceof Player player) || !player.getUniqueId().equals(bound.mage())) { + if (bound == null) { return; } - LivingEntity animal = (LivingEntity) event.getEntity(); - ItemStack knife = player.getInventory().getItemInMainHand(); - if (!BloodItems.is(knife, BloodItemKind.RITUAL_KNIFE) || !bound.character().equalsIgnoreCase(BloodItems.owner(knife))) { + 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; } - event.setDamage(animal.getHealth() + animal.getAbsorptionAmount() + 1000); - BloodFx.play("animal_slain", animal.getLocation(), player); + 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); + mage.sendMessage(Messages.get("blood.animal.cut")); } - /** A bound animal died: the sacrifice counts only by the binding mage's own knife. */ + /** + * 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(); - Bound bound = BOUND.remove(animal.getUniqueId()); - if (bound == null) { - return; - } - Player killer = animal.getKiller(); - ItemStack knife = killer != null ? killer.getInventory().getItemInMainHand() : null; - if (killer == null || !killer.getUniqueId().equals(bound.mage()) - || !BloodItems.is(knife, BloodItemKind.RITUAL_KNIFE) - || !bound.character().equalsIgnoreCase(BloodItems.owner(knife)) - || !bound.character().equalsIgnoreCase(AltarService.currentCharacter(killer))) { - Player mage = Bukkit.getPlayer(bound.mage()); - if (mage != null) { - mage.sendMessage(Messages.get("blood.animal.spoiled")); + if (animal.getPersistentDataContainer().has(drainedKey())) { + if (AnimalConfig.clearDrops()) { + event.getDrops().clear(); + event.setDroppedExp(0); } return; } - if (AnimalConfig.clearDrops()) { - event.getDrops().clear(); - event.setDroppedExp(0); + Bound bound = BOUND.remove(animal.getUniqueId()); + Player mage = bound != null ? Bukkit.getPlayer(bound.mage()) : null; + if (mage != null) { + mage.sendMessage(Messages.get("blood.animal.spoiled")); } - complete(killer, bound, animal); + } + + /** 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) { @@ -196,15 +218,20 @@ private static void complete(Player mage, Bound bound, LivingEntity animal) { + speciesKey(animal) + " (" + record.getSpecies().size() + "/" + AnimalConfig.speciesRequired() + ")"); } - /** Every second: a bound animal twitches on the stone, and breaks free when its time is up. */ + /** + * 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: keep the binding until it runs out; the animal wakes from its tag when it loads. - if (now >= entry.getValue().expiresAt()) { + // 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; @@ -214,6 +241,10 @@ static void tick() { it.remove(); continue; } + if (bound.cutAt() > 0) { + bleed(it, entry, animal, now); + continue; + } if (now >= entry.getValue().expiresAt()) { it.remove(); wake(animal); @@ -228,6 +259,41 @@ static void tick() { } } + 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); + if (mage != null) { + mage.sendMessage(Messages.get("blood.animal.left")); + } + return; + } + long bled = now - bound.cutAt(); + if (bled >= AnimalConfig.bleedSeconds() * 1000L) { + it.remove(); + 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()); + mage.sendMessage(Messages.get("blood.animal.draining")); + } + } + /** Lets every bound animal go, e.g. on shutdown. */ public static void releaseAll() { for (UUID id : BOUND.keySet()) { @@ -261,15 +327,19 @@ static NamespacedKey boundKey() { return new NamespacedKey(Magic.plugin, "blood_bound"); } - /** Lets this mage's animal go, e.g. when they leave. */ + /** 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.remove(animal); + Bound bound = BOUND.remove(animal); if (Bukkit.getEntity(animal) instanceof LivingEntity living) { - wake(living); + if (bound.cutAt() > 0) { + drain(living); + } else { + wake(living); + } } } } diff --git a/src/main/java/net/tfminecraft/magic/blood/RoomTemplate.java b/src/main/java/net/tfminecraft/magic/blood/RoomTemplate.java index 714e4de..784ce65 100644 --- a/src/main/java/net/tfminecraft/magic/blood/RoomTemplate.java +++ b/src/main/java/net/tfminecraft/magic/blood/RoomTemplate.java @@ -7,6 +7,7 @@ import java.util.logging.Logger; import org.bukkit.Material; +import org.bukkit.Tag; import org.bukkit.configuration.ConfigurationSection; /** @@ -20,6 +21,9 @@ public final class RoomTemplate { 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() {} @@ -28,6 +32,9 @@ static void reset() { walls = List.of(); ceiling = List.of(); light = 0; + candle = null; + candles = 0; + altarLight = 0; } static void load(ConfigurationSection section, Logger log) { @@ -39,6 +46,29 @@ static void load(ConfigurationSection section, Logger 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) { diff --git a/src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java b/src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java index f3393de..1dbc30a 100644 --- a/src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/WhisperConfig.java @@ -4,6 +4,9 @@ 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; @@ -27,11 +30,27 @@ public enum Speaker { * without, style is the whole prefix. */ public record Voice(String style, String sound, float pitch, Map> lines, - List start, List end) { + 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(); } @@ -75,8 +94,8 @@ static void reset() { requireAlone = true; aloneRadius = 24; VOICES.clear(); - VOICES.put(Speaker.GOD, new Voice("", "", 1f, Map.of(), List.of(), List.of())); - VOICES.put(Speaker.SELF, new Voice("", "", 1f, Map.of(), List.of(), List.of())); + 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) { @@ -124,6 +143,7 @@ static void load(ConfigurationSection whispers, ConfigurationSection voices, 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)) { @@ -132,12 +152,31 @@ private static Voice voice(ConfigurationSection section, Logger log) { log.warning("[Magic] blood magic config voice lines: unknown stage '" + key + "'"); continue; } - lines.put(stage, List.copyOf(new ArrayList<>(stages.getStringList(key)))); + 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)); + colours(section, "colors.start", log), colours(section, "colors.end", log), tiers); } private static List colours(ConfigurationSection section, String path, Logger log) { diff --git a/src/main/java/net/tfminecraft/magic/blood/WhisperService.java b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java index 749c8a8..44d1122 100644 --- a/src/main/java/net/tfminecraft/magic/blood/WhisperService.java +++ b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java @@ -158,7 +158,7 @@ private static void voiceIfDue(Player player, BloodRecord record) { schedule(player, now); return; } - if (now < due || (WhisperConfig.requireAlone() && !alone(player))) { + if (now < due || (WhisperConfig.requireAlone() && !alone(player)) || !atTheStone(player, record)) { return; } speakNext(player, record); @@ -193,11 +193,37 @@ public static void speakNext(Player player, BloodRecord 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)))); } } + /** + * Once the altar stands, the animal-stage voices come only to its chamber, while the stone is ready + * for the next kind and no rite is under way. Before the altar, and in 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 true; + } + 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. diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index bd20e1a..216377f 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -28,7 +28,6 @@ permissions: default: op children: magic.admin.reload: true - magic.blood.bypass: true magic.admin.reload: description: Legacy permission alias for magic.admin default: op @@ -51,7 +50,7 @@ permissions: description: Allows Bloodmagic resonance default: false magic.blood.bypass: - description: Staff may still place and break blocks in a blood altar's locked chamber + 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) diff --git a/src/main/resources/wildmagic/bloodmagic/animals.yml b/src/main/resources/wildmagic/bloodmagic/animals.yml index 2c687b9..c77ddcb 100644 --- a/src/main/resources/wildmagic/bloodmagic/animals.yml +++ b/src/main/resources/wildmagic/bloodmagic/animals.yml @@ -1,7 +1,8 @@ # 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, then kill it with that knife. +# 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. @@ -10,6 +11,13 @@ animal: 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] # Only animals another player owns (Cooking husbandry) count. require_stolen: true # The offering leaves no meat or experience behind. diff --git a/src/main/resources/wildmagic/bloodmagic/fx.yml b/src/main/resources/wildmagic/bloodmagic/fx.yml index ec9fec5..359aa72 100644 --- a/src/main/resources/wildmagic/bloodmagic/fx.yml +++ b/src/main/resources/wildmagic/bloodmagic/fx.yml @@ -73,6 +73,28 @@ fx: - { 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.5, pitch: 1.3 } + animal_cut: + - { sound: entity.player.attack.sweep, volume: 1.0, pitch: 0.7 } + - { sound: item.trident.hit, volume: 1.0, pitch: 0.6 } + - { sound: entity.warden.heartbeat, volume: 1.2, pitch: 1.1, delay: 4, private: true } + - { 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: 1.0, pitch: 0.9, private: true } + - { particle: DAMAGE_INDICATOR, count: 2, spread: 0.2, y: 0.6 } + animal_spilled: + - { sound: block.fire.extinguish, volume: 0.6, pitch: 0.6 } + - { sound: entity.generic.splash, volume: 0.5, 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.8, pitch: 0.8 } - { particle: POOF, count: 8, spread: 0.3, y: 0.5 } diff --git a/src/main/resources/wildmagic/bloodmagic/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml index 61f237a..01dba8c 100644 --- a/src/main/resources/wildmagic/bloodmagic/messages.yml +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -73,6 +73,10 @@ blood: 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: "#8a0303§oThe blade opens it. The blood runs down into the grooves of the stone, and the stone begins to drink. Stay" + draining: "#8a0303§oThe 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" diff --git a/src/main/resources/wildmagic/bloodmagic/settings.yml b/src/main/resources/wildmagic/bloodmagic/settings.yml index 88df084..da071e6 100644 --- a/src/main/resources/wildmagic/bloodmagic/settings.yml +++ b/src/main/resources/wildmagic/bloodmagic/settings.yml @@ -54,13 +54,17 @@ placement: 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. light: hidden light level in the four top - # corners, 0 for none. An empty list leaves that part as it was. + # (corruption.yml convert) so the room still reddens. An empty list leaves that part as it was. + # candles: a lit cluster (count 1-4) in each floor corner. Not red candles: those 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] - light: 9 + candles: { block: BLACK_CANDLE, count: 3 } + altar_light: 7 + light: 0 # How often (minutes) altars of dead owners are checked and purified. purify_check_minutes: 10 diff --git a/src/main/resources/wildmagic/bloodmagic/voices.yml b/src/main/resources/wildmagic/bloodmagic/voices.yml index 7e3bec1..fe51655 100644 --- a/src/main/resources/wildmagic/bloodmagic/voices.yml +++ b/src/main/resources/wildmagic/bloodmagic/voices.yml @@ -24,9 +24,10 @@ whispers: # 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 are written from -# early to late: the pick comes from a third of the list that slides along with the kinds given. -# Other stages pick at random. +# 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). +# Once the altar stands, animal-stage voices speak only in its chamber, while it is ready for the next +# kind and no rite is under way. 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. @@ -55,17 +56,26 @@ voices: - "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: - - "The stone is thirsty, child. Can you not hear it?" - - "Lead it down gently. Fear sours the blood." - - "Every pen in the valley is full, and every shepherd sleeps." - - "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." - - "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." - - "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." + 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." @@ -90,16 +100,27 @@ voices: pitch: 1.2 lines: animal: - - "What am I doing down here? It's only a stone. Only a voice." - - "Someone will wake to an empty pen tomorrow. Someone who never did anything to me." - - "I could throw the knife in the river. I could. I just haven't yet." - - "My hands shook the first time. They don't shake as much now." - - "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've started counting the kinds without meaning to. Four. Five." - - "Nobody tells you how warm it is. Nobody tells you how good the warmth feels." - - "I washed my hands three times today. I didn't need to. I liked how red the water went." - - "Seven kinds, and then it's finished. Then I'll stop. Won't I?" + 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." diff --git a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java index e15b3e4..da19124 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java @@ -77,6 +77,30 @@ T animal(Class type, UUID ownerAccount) { 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); @@ -173,51 +197,109 @@ void wildAnimalsCountWhenTheConfigDoesNotRequireTheft() throws Exception { BloodConfig.load(temp.resolve("w.yml").toFile(), fx.logger); Pig pig = animal(Pig.class, null); assertEquals(AnimalRiteService.Bind.OK, AnimalRiteService.bind(mage, pig)); - AnimalRiteService.onDeath(death(pig, mage)); + cut(pig); + bleedOut(); assertTrue(record.hasSpecies("pig")); assertTrue(BloodNotes.all().isEmpty()); } @Test - void theOwnKnifeOpensABoundAnimalInOneStroke() { + 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 hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(mage, cow, - org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, DamageSource.builder(DamageType.PLAYER_ATTACK).build(), 3); - AnimalRiteService.onHit(hit); - assertEquals(3, hit.getDamage()); + 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 = new org.bukkit.event.entity.EntityDamageByEntityEvent(damager, cow, - org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, DamageSource.builder(DamageType.PLAYER_ATTACK).build(), 3); + var foreign = hit(damager, cow); AnimalRiteService.onHit(foreign); - assertEquals(3, foreign.getDamage()); + assertTrue(foreign.isCancelled()); } mage.getInventory().setItemInMainHand(new ItemStack(Material.IRON_SWORD)); - var plain = new org.bukkit.event.entity.EntityDamageByEntityEvent(mage, cow, - org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, DamageSource.builder(DamageType.PLAYER_ATTACK).build(), 3); + var plain = hit(mage, cow); AnimalRiteService.onHit(plain); - assertEquals(3, plain.getDamage()); + assertTrue(plain.isCancelled()); mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, BloodFixture.id("other"))); - AnimalRiteService.onHit(plain); - assertEquals(3, plain.getDamage()); + var borrowed = hit(mage, cow); + AnimalRiteService.onHit(borrowed); + assertTrue(borrowed.isCancelled()); mage.getInventory().setItemInMainHand(BloodItems.create(BloodItemKind.RITUAL_KNIFE, id)); - var stroke = new org.bukkit.event.entity.EntityDamageByEntityEvent(mage, cow, - org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, DamageSource.builder(DamageType.PLAYER_ATTACK).build(), 3); + var stroke = hit(mage, cow); new BloodListener().onAnimalHit(stroke); - assertTrue(stroke.getDamage() >= cow.getHealth() + 1000); + 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 = new org.bukkit.event.entity.EntityDamageByEntityEvent(mage, other, - org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, DamageSource.builder(DamageType.PLAYER_ATTACK).build(), 3); + var onPlayer = hit(mage, other); AnimalRiteService.onHit(onPlayer); assertEquals(3, onPlayer.getDamage()); } + @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(); @@ -225,13 +307,24 @@ void theOwnKnifeKillCountsLeavesCluesAndCorrupts() { StolenClues.leashed(cow); cow.teleport(new Location(fx.world, 1.5, 10, 0.5)); assertEquals(AnimalRiteService.Bind.OK, AnimalRiteService.bind(mage, cow)); - EntityDeathEvent event = death(cow, mage); - AnimalRiteService.onDeath(event); - assertTrue(event.getDrops().isEmpty()); - assertEquals(0, event.getDroppedExp()); + 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())); @@ -244,7 +337,9 @@ void theSeventhKindFinishesTheStageAndGivesTheBloodKnife() throws Exception { BloodConfig.load(temp.resolve("one.yml").toFile(), fx.logger); Cow cow = animal(Cow.class, UUID.randomUUID()); AnimalRiteService.bind(mage, cow); - EntityDeathEvent event = death(cow, mage); + cut(cow); + bleedOut(); + EntityDeathEvent event = death(cow, null); AnimalRiteService.onDeath(event); assertFalse(event.getDrops().isEmpty()); assertEquals(BloodStage.CHOICE, record.getStage()); @@ -302,20 +397,24 @@ void completionNeedsTheRecordAndAltarStill() { UUID keeper = UUID.randomUUID(); Cow cow = animal(Cow.class, keeper); AnimalRiteService.bind(mage, cow); + cut(cow); record.setStage(BloodStage.CHOICE); - AnimalRiteService.onDeath(death(cow, mage)); + bleedOut(); assertTrue(record.getSpecies().isEmpty()); record.setStage(BloodStage.ANIMAL); cow = animal(Cow.class, keeper); AnimalRiteService.bind(mage, cow); + cut(cow); BloodAltars.remove(altar); - AnimalRiteService.onDeath(death(cow, mage)); + 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); - AnimalRiteService.onDeath(death(cow, mage)); + bleedOut(); } @Test diff --git a/src/test/java/net/tfminecraft/magic/blood/RoomLockTest.java b/src/test/java/net/tfminecraft/magic/blood/RoomLockTest.java index 5a511d7..1a765c2 100644 --- a/src/test/java/net/tfminecraft/magic/blood/RoomLockTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/RoomLockTest.java @@ -51,6 +51,8 @@ class RoomLockTest { " walls: [COBBLESTONE, UNOBTAINIUM, WATER]", " ceiling: [ANDESITE]", " light: 40", + " candles: { block: BLACK_CANDLE, count: 9 }", + " altar_light: 6", ""); @BeforeEach @@ -98,6 +100,17 @@ void everyChamberIsLaidInTheTemplateWithItsWayInLeftOpen() throws Exception { 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))); } @@ -255,7 +268,8 @@ public void onPlace(BlockPlaceEvent event) { @Test void badTemplateEntriesAreSkipped() throws Exception { - config("placement:\n room:\n template:\n floor: [AIR]\n light: -3\n"); + 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())); diff --git a/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java index 24d21bc..2930697 100644 --- a/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java @@ -100,8 +100,10 @@ void animalLinesFollowTheKindsAndBothVoicesSpeak() { @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(self.get(0), plain(mage.nextMessage())); + 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")) { @@ -109,8 +111,11 @@ void animalLinesFollowTheKindsAndBothVoicesSpeak() { } assertEquals(1.0, WhisperService.stageProgress(record)); WhisperService.speakNext(mage, record); - int window = (self.size() + 2) / 3; - assertEquals(self.get(self.size() - window), plain(mage.nextMessage())); + 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)); @@ -119,6 +124,59 @@ void animalLinesFollowTheKindsAndBothVoicesSpeak() { 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); + assertTrue(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 messagesMarkedWithAVoiceAreSaidInIt() { PlayerMock mage = fx.player("mage"); From d668abb49e2f9a86a139d9d6b533ad3efa5bb249 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:19:57 +0100 Subject: [PATCH 32/55] No candles in the chamber by default Candles come with the corruption and on the altar model itself; the template option stays. Co-Authored-By: Claude Opus 5.5 --- src/main/resources/wildmagic/bloodmagic/settings.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/resources/wildmagic/bloodmagic/settings.yml b/src/main/resources/wildmagic/bloodmagic/settings.yml index da071e6..1c86f80 100644 --- a/src/main/resources/wildmagic/bloodmagic/settings.yml +++ b/src/main/resources/wildmagic/bloodmagic/settings.yml @@ -55,14 +55,14 @@ placement: 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: a lit cluster (count 1-4) in each floor corner. Not red candles: those 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. + # 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] - candles: { block: BLACK_CANDLE, count: 3 } altar_light: 7 light: 0 # How often (minutes) altars of dead owners are checked and purified. From 37a9db446acce54409aed757f3c521ad5f29d005 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:49:43 +0100 Subject: [PATCH 33/55] Rite music while the animal bleeds; voices keep quiet without an altar animal.rite.music (default tfmc_blood:rite.bleed, RECORDS, 0.8) plays to the mage alone from the cut and stops when the stone drinks, the blood is spilled or the animal dies otherwise. Animal-stage voices no longer speak anywhere while no altar stands. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/AnimalConfig.java | 28 +++++++++++++++++++ .../magic/blood/AnimalRiteService.java | 13 +++++++++ .../magic/blood/WhisperService.java | 6 ++-- .../wildmagic/bloodmagic/animals.yml | 6 ++++ .../resources/wildmagic/bloodmagic/voices.yml | 4 +-- .../magic/blood/AnimalRiteTest.java | 25 +++++++++++++++++ .../tfminecraft/magic/blood/VoiceTest.java | 3 +- .../magic/blood/WhisperServiceTest.java | 6 ++-- 8 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java b/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java index 834eef4..462f532 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/AnimalConfig.java @@ -5,6 +5,7 @@ import java.util.Set; import java.util.logging.Logger; +import org.bukkit.SoundCategory; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.EntityType; @@ -24,6 +25,9 @@ public final class AnimalConfig { 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(); @@ -45,6 +49,9 @@ static void reset() { 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) { @@ -75,6 +82,27 @@ static void load(ConfigurationSection section, Logger log) { 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. */ diff --git a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java index 8373cad..d130d86 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java @@ -154,6 +154,16 @@ public static void onHit(EntityDamageByEntityEvent event) { BOUND.put(event.getEntity().getUniqueId(), bound.cut(clock.getAsLong())); BloodFx.play("animal_cut", event.getEntity().getLocation(), mage); mage.sendMessage(Messages.get("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()); + } } /** @@ -172,6 +182,7 @@ public static void onDeath(EntityDeathEvent event) { 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")); } } @@ -271,6 +282,7 @@ private static void bleed(Iterator> it, Map.Entry> it, Map.Entry= AnimalConfig.bleedSeconds() * 1000L) { it.remove(); + silence(mage); complete(mage, bound, animal); drain(animal); return; diff --git a/src/main/java/net/tfminecraft/magic/blood/WhisperService.java b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java index 44d1122..ab3e044 100644 --- a/src/main/java/net/tfminecraft/magic/blood/WhisperService.java +++ b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java @@ -207,8 +207,8 @@ public static void speakNext(Player player, BloodRecord record) { } /** - * Once the altar stands, the animal-stage voices come only to its chamber, while the stone is ready - * for the next kind and no rite is under way. Before the altar, and in other stages, anywhere. + * 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) { @@ -216,7 +216,7 @@ static boolean atTheStone(Player player, BloodRecord record) { } BloodAltar altar = BloodAltars.ofCharacter(record.getCharacterId()); if (altar == null) { - return true; + return false; } boolean ready = record.getLastAnimalAt() <= 0 || clock.getAsLong() - record.getLastAnimalAt() >= AnimalConfig.cooldownMillis(); diff --git a/src/main/resources/wildmagic/bloodmagic/animals.yml b/src/main/resources/wildmagic/bloodmagic/animals.yml index c77ddcb..8051e16 100644 --- a/src/main/resources/wildmagic/bloodmagic/animals.yml +++ b/src/main/resources/wildmagic/bloodmagic/animals.yml @@ -18,6 +18,12 @@ animal: 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. diff --git a/src/main/resources/wildmagic/bloodmagic/voices.yml b/src/main/resources/wildmagic/bloodmagic/voices.yml index fe51655..bf320f0 100644 --- a/src/main/resources/wildmagic/bloodmagic/voices.yml +++ b/src/main/resources/wildmagic/bloodmagic/voices.yml @@ -26,8 +26,8 @@ whispers: # 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). -# Once the altar stands, animal-stage voices speak only in its chamber, while it is ready for the next -# kind and no rite is under way. Other stages pick at random. +# 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. diff --git a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java index da19124..cf99731 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java @@ -246,6 +246,31 @@ void onlyTheOwnKnifeCutsAndNothingElseTouchesIt() { assertEquals(3, onPlayer.getDamage()); } + @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(); diff --git a/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java index 2930697..edf3dd2 100644 --- a/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java @@ -159,7 +159,8 @@ void tiersStartAtTheLowestAndBadTiersAreSkipped() throws Exception { void animalVoicesWaitForTheChamberAndAReadyStone() { PlayerMock mage = fx.player("mage"); BloodRecord record = fx.record("mage", BloodStage.ANIMAL); - assertTrue(WhisperService.atTheStone(mage, record)); + // 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); diff --git a/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java b/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java index 21fc0ac..c669795 100644 --- a/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/WhisperServiceTest.java @@ -242,7 +242,7 @@ void minuteTicksDrainCorruptAndSpeakWhenAloneAndDue() { WhisperService.tick(minute(40)); BloodRecord record = BloodRecords.getOrCreate(id, "o"); - record.setStage(BloodStage.ANIMAL); + record.setStage(BloodStage.CHOICE); record.setWhisper(3); WhisperService.tick(minute(50)); assertEquals(3, record.getWhisper()); @@ -272,12 +272,12 @@ void minuteTicksDrainCorruptAndSpeakWhenAloneAndDue() { 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:", - " animal: [hello]", "")); + " 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.ANIMAL); + fx.record("mage", BloodStage.CHOICE); WhisperService.voiceSoon(player); WhisperService.tick(minute(1)); assertEquals("hello", player.nextMessage()); From 23e9a443ac25a55d3dada45fe96ce349d59c78d2 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:59:25 +0100 Subject: [PATCH 34/55] Rite sounds heard nearby, quietly; shared sounds stay in the room Every animal rite sound (binding, the cut, the heartbeat, the stone drinking, spilling, the blood knife's arrival) is now heard by players nearby instead of the mage alone, at lower volumes. Shared blood effect sounds reach only players within fx.hear_radius (10) instead of the vanilla 16 blocks. The rite music stays private to the mage. Co-Authored-By: Claude Opus 5.5 --- .../net/tfminecraft/magic/blood/BloodFx.java | 15 ++++++- .../resources/wildmagic/bloodmagic/fx.yml | 43 ++++++++++--------- .../tfminecraft/magic/blood/BloodFxTest.java | 31 ++++++++++--- 3 files changed, 62 insertions(+), 27 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodFx.java b/src/main/java/net/tfminecraft/magic/blood/BloodFx.java index 02ff626..9f29a88 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodFx.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodFx.java @@ -34,6 +34,7 @@ record Layer(String sound, float volume, float pitch, Particle particle, Object private static final Map> MOMENTS = new HashMap<>(); private static boolean enabled; + private static double hearRadius; static { reset(); @@ -43,15 +44,22 @@ private BloodFx() {} static void reset() { enabled = true; + hearRadius = 10; MOMENTS.clear(); } + /** 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)); ConfigurationSection moments = fx.getConfigurationSection("moments"); if (moments == null) { return; @@ -109,7 +117,12 @@ private static void run(String moment, Layer layer, Location at, Player viewer) if (layer.personal()) { viewer.playSound(at, layer.sound(), layer.volume(), layer.pitch()); } else { - at.getWorld().playSound(at, layer.sound(), layer.volume(), layer.pitch()); + // 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; } diff --git a/src/main/resources/wildmagic/bloodmagic/fx.yml b/src/main/resources/wildmagic/bloodmagic/fx.yml index 359aa72..22271a2 100644 --- a/src/main/resources/wildmagic/bloodmagic/fx.yml +++ b/src/main/resources/wildmagic/bloodmagic/fx.yml @@ -9,6 +9,9 @@ # player the moment is about sees or hears it. A moment with no entry plays nothing. fx: enabled: true + # 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: @@ -65,18 +68,18 @@ fx: # --- The animals --- animal_bound: - - { sound: block.chain.place, volume: 1.0, pitch: 0.6 } - - { sound: entity.warden.heartbeat, volume: 0.8, pitch: 1.4, delay: 5 } + - { 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.5, pitch: 1.3 } + - { sound: entity.warden.heartbeat, volume: 0.3, pitch: 1.3 } animal_cut: - - { sound: entity.player.attack.sweep, volume: 1.0, pitch: 0.7 } - - { sound: item.trident.hit, volume: 1.0, pitch: 0.6 } - - { sound: entity.warden.heartbeat, volume: 1.2, pitch: 1.1, delay: 4, private: true } + - { 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 } @@ -88,15 +91,15 @@ fx: - { 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: 1.0, pitch: 0.9, private: true } + - { 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.6, pitch: 0.6 } - - { sound: entity.generic.splash, volume: 0.5, pitch: 0.5 } + - { 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.8, pitch: 0.8 } + - { 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 } @@ -107,28 +110,28 @@ fx: - { 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: 1.0, pitch: 0.7 } - - { sound: item.trident.hit, volume: 1.0, pitch: 0.6 } - - { sound: entity.generic.drink, volume: 0.8, pitch: 0.5, delay: 2 } + - { 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: 1.0, pitch: 0.5 } - - { sound: block.honey_block.slide, volume: 1.0, pitch: 0.5 } - - { sound: block.sculk_catalyst.bloom, volume: 0.8, pitch: 0.5, delay: 4 } - - { sound: entity.warden.heartbeat, volume: 1.0, pitch: 0.6, delay: 12 } + - { 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.7, pitch: 0.7, delay: 20 } - - { sound: block.end_portal.spawn, volume: 0.4, pitch: 0.5, delay: 40 } + - { 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: 1.0, pitch: 0.5, delay: 50, private: true } + - { sound: ambient.cave, volume: 0.4, pitch: 0.5, delay: 50 } # --- The question --- question: diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodFxTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodFxTest.java index d2f0c4c..0201d1d 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodFxTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodFxTest.java @@ -181,24 +181,39 @@ void playingSendsLayersToTheWorldOrTheViewer() throws Exception { 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(world).playSound(any(Location.class), eq("minecraft:block.stone.place"), anyFloat(), anyFloat()); + 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(world, never()).playSound(any(Location.class), eq("minecraft:block.chain.place"), anyFloat(), anyFloat()); + verify(near, never()).playSound(any(Location.class), eq("minecraft:block.chain.place"), anyFloat(), anyFloat()); fx.server.getScheduler().performTicks(6); - verify(world).playSound(any(Location.class), eq("minecraft:block.chain.place"), anyFloat(), anyFloat()); + 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(quiet).playSound(any(Location.class), eq("minecraft:block.stone.place"), anyFloat(), anyFloat()); - verify(quiet, never()).playSound(any(Location.class), eq("minecraft:block.bell.use"), anyFloat(), anyFloat()); + 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); - doThrow(new IllegalStateException("no sound")).when(broken).playSound(any(Location.class), anyString(), anyFloat(), anyFloat()); + 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(), @@ -208,6 +223,10 @@ void playingSendsLayersToTheWorldOrTheViewer() throws Exception { 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()); From 52ca34a56c0708d758a91e4b4e718fe2d3abd931 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:05:48 +0100 Subject: [PATCH 35/55] Read animal owners from BreedingBuddies, then Cooking BreedingBuddies has taken husbandry over from Cooking (on Dev already; Cooking's husbandry is disabled there), so the stolen-animal check now asks BreedingBuddies' HusbandryOwnershipService first and falls back to Cooking's. Without this every animal would read as unowned once BreedingBuddies runs, and no stolen kind could ever count. Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/HusbandryOwners.java | 23 +++++++++++++++---- src/main/resources/plugin.yml | 2 +- .../magic/blood/CorruptionAndCluesTest.java | 4 ++-- .../magic/blood/FakeHusbandry.java | 4 ++-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/HusbandryOwners.java b/src/main/java/net/tfminecraft/magic/blood/HusbandryOwners.java index 06a3483..9ea2ea9 100644 --- a/src/main/java/net/tfminecraft/magic/blood/HusbandryOwners.java +++ b/src/main/java/net/tfminecraft/magic/blood/HusbandryOwners.java @@ -5,22 +5,37 @@ 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". + * Who keeps an animal, read from the husbandry records: BreedingBuddies when it is installed, else + * Cooking (which kept them before BreedingBuddies took husbandry over). Reflection keeps both 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"; + static final List SERVICES = List.of( + "net.tfminecraft.breedingbuddies.husbandry.HusbandryOwnershipService", + "net.tfminecraft.cooking.husbandry.HusbandryOwnershipService"); + static List serviceClasses = SERVICES; private HusbandryOwners() {} public static List of(UUID animal) { List out = new ArrayList<>(); + Class service = null; + for (String name : serviceClasses) { + try { + service = Class.forName(name); + break; + } catch (ClassNotFoundException | LinkageError ex) { + // not installed: try the next keeper of records + } + } + if (service == null) { + return List.of(); + } 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); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 216377f..9a2ad98 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, WorldGuard, Cooking] +softdepend: [MMOItems, MythicLib, MMOCore, WorldGuard, Cooking, BreedingBuddies] commands: resonance: diff --git a/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java index 38ad48d..a68611f 100644 --- a/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java @@ -240,7 +240,7 @@ void configsParseAndRejectBadEntries() throws Exception { } @Test - void husbandryOwnersComeFromCookingOrNobody() { + void husbandryOwnersComeFromBreedingBuddiesOrCookingOrNobody() { UUID animal = UUID.randomUUID(); assertTrue(HusbandryOwners.of(animal).isEmpty()); FakeHusbandry.use(); @@ -248,7 +248,7 @@ void husbandryOwnersComeFromCookingOrNobody() { 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(); + HusbandryOwners.serviceClasses = List.of(FakeHusbandry.Broken.class.getName()); assertTrue(HusbandryOwners.of(animal).isEmpty()); FakeHusbandry.restore(); } diff --git a/src/test/java/net/tfminecraft/magic/blood/FakeHusbandry.java b/src/test/java/net/tfminecraft/magic/blood/FakeHusbandry.java index 2f99bee..68a0c36 100644 --- a/src/test/java/net/tfminecraft/magic/blood/FakeHusbandry.java +++ b/src/test/java/net/tfminecraft/magic/blood/FakeHusbandry.java @@ -44,11 +44,11 @@ public static Object listOwners(UUID animal) { } static void use() { - HusbandryOwners.serviceClass = FakeHusbandry.class.getName(); + HusbandryOwners.serviceClasses = List.of("net.example.Missing", FakeHusbandry.class.getName()); } static void restore() { - HusbandryOwners.serviceClass = "net.tfminecraft.cooking.husbandry.HusbandryOwnershipService"; + HusbandryOwners.serviceClasses = HusbandryOwners.SERVICES; OWNERS.clear(); } } From abb6a3a4a772616ed4abac352e6538184fa0930d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:10:35 +0100 Subject: [PATCH 36/55] Revert "Read animal owners from BreedingBuddies, then Cooking" BreedingBuddies runs on Dev only; Main (the reference) keeps husbandry in Cooking, so the stolen-animal check stays on Cooking's records for now. This reverts commit 52ca34a. Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/HusbandryOwners.java | 23 ++++--------------- src/main/resources/plugin.yml | 2 +- .../magic/blood/CorruptionAndCluesTest.java | 4 ++-- .../magic/blood/FakeHusbandry.java | 4 ++-- 4 files changed, 9 insertions(+), 24 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/HusbandryOwners.java b/src/main/java/net/tfminecraft/magic/blood/HusbandryOwners.java index 9ea2ea9..06a3483 100644 --- a/src/main/java/net/tfminecraft/magic/blood/HusbandryOwners.java +++ b/src/main/java/net/tfminecraft/magic/blood/HusbandryOwners.java @@ -5,37 +5,22 @@ import java.util.UUID; /** - * Who keeps an animal, read from the husbandry records: BreedingBuddies when it is installed, else - * Cooking (which kept them before BreedingBuddies took husbandry over). Reflection keeps both out of - * Magic's build; any mismatch reads as "nobody owns it". + * 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 final List SERVICES = List.of( - "net.tfminecraft.breedingbuddies.husbandry.HusbandryOwnershipService", - "net.tfminecraft.cooking.husbandry.HusbandryOwnershipService"); - static List serviceClasses = SERVICES; + static String serviceClass = "net.tfminecraft.cooking.husbandry.HusbandryOwnershipService"; private HusbandryOwners() {} public static List of(UUID animal) { List out = new ArrayList<>(); - Class service = null; - for (String name : serviceClasses) { - try { - service = Class.forName(name); - break; - } catch (ClassNotFoundException | LinkageError ex) { - // not installed: try the next keeper of records - } - } - if (service == null) { - return List.of(); - } 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); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 9a2ad98..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, WorldGuard, Cooking, BreedingBuddies] +softdepend: [MMOItems, MythicLib, MMOCore, WorldGuard, Cooking] commands: resonance: diff --git a/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java index a68611f..38ad48d 100644 --- a/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java @@ -240,7 +240,7 @@ void configsParseAndRejectBadEntries() throws Exception { } @Test - void husbandryOwnersComeFromBreedingBuddiesOrCookingOrNobody() { + void husbandryOwnersComeFromCookingOrNobody() { UUID animal = UUID.randomUUID(); assertTrue(HusbandryOwners.of(animal).isEmpty()); FakeHusbandry.use(); @@ -248,7 +248,7 @@ void husbandryOwnersComeFromBreedingBuddiesOrCookingOrNobody() { 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.serviceClasses = List.of(FakeHusbandry.Broken.class.getName()); + HusbandryOwners.serviceClass = FakeHusbandry.Broken.class.getName(); assertTrue(HusbandryOwners.of(animal).isEmpty()); FakeHusbandry.restore(); } diff --git a/src/test/java/net/tfminecraft/magic/blood/FakeHusbandry.java b/src/test/java/net/tfminecraft/magic/blood/FakeHusbandry.java index 68a0c36..2f99bee 100644 --- a/src/test/java/net/tfminecraft/magic/blood/FakeHusbandry.java +++ b/src/test/java/net/tfminecraft/magic/blood/FakeHusbandry.java @@ -44,11 +44,11 @@ public static Object listOwners(UUID animal) { } static void use() { - HusbandryOwners.serviceClasses = List.of("net.example.Missing", FakeHusbandry.class.getName()); + HusbandryOwners.serviceClass = FakeHusbandry.class.getName(); } static void restore() { - HusbandryOwners.serviceClasses = HusbandryOwners.SERVICES; + HusbandryOwners.serviceClass = "net.tfminecraft.cooking.husbandry.HusbandryOwnershipService"; OWNERS.clear(); } } From d82c96e21454f57248ac6fa71ebd6f4b2e2c0f77 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:24:04 +0100 Subject: [PATCH 37/55] Leads come off on the stone; corruption grows in patches, candles before the pedestals Binding a led animal unleashes it and hands the lead back (or drops it if the inventory is full). The corruption now picks, of its random candidates, the one nearest the altar that touches what it already turned, so the room reddens in connected veins; floor and ceiling decorations grow only on blocks it has turned, and candles stand only on the eight spots right in front of the pedestals. Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/AltarCorruption.java | 91 +++++++++++++++++-- .../magic/blood/AnimalRiteService.java | 6 ++ .../wildmagic/bloodmagic/corruption.yml | 5 +- .../magic/blood/AnimalRiteTest.java | 11 +++ .../magic/blood/CorruptionAndCluesTest.java | 44 ++++++++- 5 files changed, 146 insertions(+), 11 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java index e413c05..e909066 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java @@ -1,12 +1,16 @@ 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; @@ -16,8 +20,12 @@ /** * The room around an altar slowly turns. Only listed blocks change: exposed stone-like blocks - * are converted, empty floor and ceiling spots are decorated. Every change is journalled on the - * altar with the block it replaced, and purification puts back any block still as the altar left it. + * 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 { @@ -55,6 +63,13 @@ public static int spread(BloodAltar altar, int count) { 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); @@ -64,22 +79,86 @@ private static boolean changeOne(BloodAltar altar, Block center, int radius, Lis continue; } CorruptionConfig.Rule rule = rules.get(random.nextInt(rules.size())); - if (fits(rule, block)) { - change(altar, block, rule.to().get(random.nextInt(rule.to().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)); - case CEILING -> block.isEmpty() && rests(block.getRelative(BlockFace.UP)); + 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; diff --git a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java index d130d86..af8d2e8 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java @@ -10,6 +10,7 @@ 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; @@ -102,6 +103,11 @@ public static Bind bind(Player player, LivingEntity animal) { 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; } diff --git a/src/main/resources/wildmagic/bloodmagic/corruption.yml b/src/main/resources/wildmagic/bloodmagic/corruption.yml index 0924824..8b6f6ae 100644 --- a/src/main/resources/wildmagic/bloodmagic/corruption.yml +++ b/src/main/resources/wildmagic/bloodmagic/corruption.yml @@ -22,7 +22,10 @@ corruption: - { 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] } - # Air spots get decorated: at floor = on top of a solid block, at ceiling = hanging under one. + # 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] } diff --git a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java index cf99731..c9eb093 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AnimalRiteTest.java @@ -246,6 +246,17 @@ void onlyTheOwnKnifeCutsAndNothingElseTouchesIt() { 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()); diff --git a/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java index 38ad48d..67eecaf 100644 --- a/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java @@ -81,16 +81,14 @@ void spreadConvertsDecoratesWithinBudgetAndRestoreUndoesIt() throws Exception { assertEquals(made, altar.getCorrupted().size()); assertEquals(Material.BARRIER, center.getType()); assertTrue(center.getRelative(0, 1, 0).isEmpty()); - boolean candle = false; + // 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) { - candle = true; assertTrue(((Lightable) block.getBlockData()).isLit()); } } - assertTrue(candle); assertEquals(0, AltarCorruption.spread(altar, 5)); BloodAltar.CorruptedBlock first = altar.getCorrupted().get(0); @@ -112,6 +110,44 @@ void spreadConvertsDecoratesWithinBudgetAndRestoreUndoesIt() throws Exception { } } + @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(); @@ -184,7 +220,7 @@ void edgeOfLoadedChunksIsLeftAlone() throws Exception { @Test void ambientSpreadWaitsItsTurnAndSavesOnlyOnChange() throws Exception { config(String.join("\n", "corruption:", " ambient_every_minutes: 30", " levels:", - " 0: { radius: 3, max_blocks: 2 }", " decorate:", " - { min_level: 0, at: floor, to: [COBWEB] }", "")); + " 0: { radius: 3, max_blocks: 2 }", " convert:", " - { min_level: 0, from: [STONE], to: [NETHERRACK] }", "")); Block center = room(); BloodAltar altar = BloodAltar.create("c", "o", center, 0f, 1L); BloodAltars.add(altar); From bdd05ee28b74ee9635f81ae015d1eb7d8031bbd4 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:46:27 +0100 Subject: [PATCH 38/55] The god's question as a spoken dialogue; altar lines by stage; stone narration follows the path The answer to Lorvear is no longer a menu. Right-clicking the altar with the blood knife in its chamber starts the question: the god asks over a few lines (the mage's own voice cuts in), then waits for the answer spoken aloud in RP chat. The answer is asked back once and sealed when said again; leaving the chamber or letting the time run out ends it and it can be asked again. Lines, answer words, timing and optional music live in choice.yml. Touching and sitting on the altar now say something different at each stage and path. Narration of the stone is written {stone} and drawn in the colours of whoever speaks to the mage now: Lorvear's, or after refusing him the mage's own; the red narration lines use it, so nothing stays crimson after the refusal. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/AltarService.java | 23 +- .../magic/blood/AnimalRiteService.java | 6 +- .../tfminecraft/magic/blood/BloodChoice.java | 4 +- .../magic/blood/BloodListener.java | 24 +- .../tfminecraft/magic/blood/BloodMagic.java | 2 + .../tfminecraft/magic/blood/CarryService.java | 2 +- .../magic/blood/CharacterRiteService.java | 2 +- .../tfminecraft/magic/blood/ChoiceConfig.java | 144 ++++++++---- .../tfminecraft/magic/blood/ChoiceMenu.java | 122 ---------- .../tfminecraft/magic/blood/FinalService.java | 6 +- .../magic/blood/QuestionService.java | 197 ++++++++++++++++ .../magic/blood/WhisperService.java | 13 +- .../resources/wildmagic/bloodmagic/choice.yml | 74 +++--- .../resources/wildmagic/bloodmagic/fx.yml | 6 + .../wildmagic/bloodmagic/messages.yml | 50 ++-- .../magic/blood/AltarServiceTest.java | 23 +- .../magic/blood/BloodListenerTest.java | 26 +-- .../tfminecraft/magic/blood/ChoiceTest.java | 213 +++++++++++++----- .../tfminecraft/magic/blood/VoiceTest.java | 26 ++- 19 files changed, 647 insertions(+), 316 deletions(-) delete mode 100644 src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/QuestionService.java diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarService.java b/src/main/java/net/tfminecraft/magic/blood/AltarService.java index 8f393af..e39152e 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -178,12 +178,26 @@ static boolean isDead(String characterId) { return info.isPresent() && info.get().status() == CharacterStatus.DEAD; } - /** The player-facing line for an altar a player touched. */ - public static String touch(Player player, BloodAltar altar) { + /** 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); - return owner ? Messages.get("blood.altar.owner_touch") : Messages.get("blood.altar.stranger_touch"); + WhisperService.tell(player, owner ? "blood.altar.touch." + stageKey(BloodRecords.get(character)) + : "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"); + }; } /** @@ -201,7 +215,8 @@ public static void sat(Player player, Entity seat) { } String character = currentCharacter(player); boolean owner = character != null && character.equalsIgnoreCase(altar.getOwnerCharacter()); - player.sendMessage(Messages.get(owner ? "blood.altar.sit" : "blood.altar.sit_stranger")); + WhisperService.tell(player, owner ? "blood.altar.sit." + stageKey(BloodRecords.get(character)) + : "blood.altar.sit_stranger"); BloodFx.play(owner ? "altar_sit" : "altar_touch_stranger", seat.getLocation(), player); } } diff --git a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java index af8d2e8..41ed762 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AnimalRiteService.java @@ -159,7 +159,7 @@ public static void onHit(EntityDamageByEntityEvent event) { event.setDamage(0.01); BOUND.put(event.getEntity().getUniqueId(), bound.cut(clock.getAsLong())); BloodFx.play("animal_cut", event.getEntity().getLocation(), mage); - mage.sendMessage(Messages.get("blood.animal.cut")); + WhisperService.tell(mage, "blood.animal.cut"); if (!AnimalConfig.music().isEmpty()) { mage.playSound(mage.getLocation(), AnimalConfig.music(), AnimalConfig.musicCategory(), AnimalConfig.musicVolume(), 1f); } @@ -230,7 +230,7 @@ private static void complete(Player mage, Bound bound, LivingEntity animal) { if (bound.stolen()) { StolenClues.leave(animal, bound.owners(), center); } - mage.sendMessage(Messages.get(done ? "blood.animal.complete" : "blood.animal.accepted")); + 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() + ")"); } @@ -309,7 +309,7 @@ private static void bleed(Iterator> it, Map.Entry= AnimalConfig.bleedSeconds()) { entry.setValue(bound.halfway()); - mage.sendMessage(Messages.get("blood.animal.draining")); + WhisperService.tell(mage, "blood.animal.draining"); } } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java index 8d69ebf..8f89313 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java @@ -39,7 +39,7 @@ public static boolean answer(Player player, BloodPath path) { } ClassUnlockBridge.unlock(player, ChoiceConfig.classFor(BloodPath.DEVOTEE)); BloodArmour.give(player, BloodPath.DEVOTEE, character); - player.sendMessage(Messages.get("blood.choice.devoted")); + WhisperService.tell(player, "blood.choice.devoted"); BloodFx.play("devoted", player.getLocation(), player); FinalService.begin(player, record); Magic.plugin.getLogger().info("[Magic] Character " + character + " gave themself to the god (" @@ -47,7 +47,7 @@ public static boolean answer(Player player, BloodPath path) { } else { record.setStage(BloodStage.HUNT); BloodRecords.save(record); - player.sendMessage(Messages.get("blood.choice.refused")); + WhisperService.tell(player, "blood.choice.refused"); BloodFx.play("refused", player.getLocation(), player); Magic.plugin.getLogger().info("[Magic] Character " + character + " refused the god"); } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index d5cb1df..e073559 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -25,9 +25,7 @@ import org.bukkit.event.player.PlayerBucketFillEvent; import org.bukkit.event.entity.EntityDeathEvent; import org.bukkit.event.entity.EntityPickupItemEvent; -import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; -import org.bukkit.event.inventory.InventoryDragEvent; import org.bukkit.event.entity.EntityExplodeEvent; import org.bukkit.event.entity.PlayerLeashEntityEvent; import org.bukkit.event.player.PlayerInteractEntityEvent; @@ -44,6 +42,7 @@ import net.tfminecraft.interactiblefurniture.events.FurniturePlaceEvent; 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; @@ -70,7 +69,7 @@ public void onInteract(PlayerInteractEvent event) { if (character != null && character.equalsIgnoreCase(altar.getOwnerCharacter()) && BloodItems.is(held, BloodItemKind.BLOOD_KNIFE) && character.equalsIgnoreCase(BloodItems.owner(held)) && BloodChoice.canAnswer(player)) { - ChoiceMenu.open(player); + QuestionService.start(player); return; } Player carried = CarryService.carried(player); @@ -78,7 +77,7 @@ public void onInteract(PlayerInteractEvent event) { layDown(player, carried); return; } - player.sendMessage(AltarService.touch(player, altar)); + AltarService.touch(player, altar); return; } ItemStack hand = event.getItem(); @@ -161,17 +160,13 @@ public void onMount(EntityMountEvent event) { } } - @EventHandler - public void onMenuClick(InventoryClickEvent event) { - ChoiceMenu.onClick(event); - } - - @EventHandler - public void onMenuDrag(InventoryDragEvent event) { - ChoiceMenu.onDrag(event); + /** 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 that menu: keep ItemsAdder off it. */ + /** 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) { @@ -268,7 +263,7 @@ public void onBreak(BlockBreakEvent event) { event.getPlayer().sendMessage(Messages.get("blood.altar.unbreakable")); } else if (AltarCorruption.isCorrupted(block)) { event.setCancelled(true); - event.getPlayer().sendMessage(Messages.get("blood.corruption.unbreakable")); + 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); @@ -355,6 +350,7 @@ 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()); BloodPerms.clear(event.getPlayer()); } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index 3a1b01a..9ca2ac2 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -57,6 +57,7 @@ static void tick(MagicTickContext context) { } WhisperService.tick(context); AnimalRiteService.tick(); + QuestionService.tick(); CharacterRiteService.tick(); if (context.everyMinutes(HuntConfig.sampleMinutes())) { BondService.sample(); @@ -116,6 +117,7 @@ public static void stop() { StolenClues.clear(); BloodNotes.clear(); AltarPedestals.clear(); + QuestionService.clear(); WhisperService.clear(); BloodRecords.clear(); BloodAltars.clear(); diff --git a/src/main/java/net/tfminecraft/magic/blood/CarryService.java b/src/main/java/net/tfminecraft/magic/blood/CarryService.java index 80fdd01..65611aa 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CarryService.java +++ b/src/main/java/net/tfminecraft/magic/blood/CarryService.java @@ -95,7 +95,7 @@ public static CharacterRiteService.Bind start(Player mage, Player victim) { return CharacterRiteService.Bind.BUSY; } CARRIES.put(mage.getUniqueId(), new Carry(victim.getUniqueId(), character, victimCharacter)); - mage.sendMessage(Messages.get("blood.carry.start")); + WhisperService.tell(mage, "blood.carry.start"); BloodFx.play("carry_start", victim.getLocation(), victim); victim.sendMessage(Messages.get("blood.carry.dragged")); return CharacterRiteService.Bind.OK; diff --git a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java index cb2e995..841fb30 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java @@ -274,7 +274,7 @@ private static void complete(Player mage, Player victim, Rite rite, BloodAltar a if (record.getHuntPoints() >= BloodConfig.huntPointsRequired()) { FinalService.begin(mage, record); } else { - mage.sendMessage(Messages.get("blood.rite.taken")); + WhisperService.tell(mage, "blood.rite.taken"); BloodRecords.save(record); } } else if (stage == BloodStage.FINAL && key.equalsIgnoreCase(record.currentTarget())) { diff --git a/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java index 054bc78..3c08623 100644 --- a/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java @@ -6,33 +6,31 @@ 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 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 { - /** A menu button. */ - public record Button(String item, String name, List lore) {} + /** One line of the question: who says it and what. */ + public record Line(WhisperConfig.Speaker speaker, String text) {} - public enum Slot { - QUESTION("question", "v.paper"), - DEVOTEE("devotee", "v.red_dye"), - TRUE("true", "v.iron_sword"), - CONFIRM("confirm", "v.redstone"), - BACK("back", "v.barrier"); + /** The words that mean an answer, and what the voices say around it. */ + public record Answer(List words, List confirm, List sealed) {} - private final String key; - private final String fallback; - - Slot(String key, String fallback) { - this.key = key; - this.fallback = fallback; - } - } - - private static String title; - private static final Map BUTTONS = new EnumMap<>(Slot.class); private static int devoteeInjuries; + private static int answerSeconds; + private static int lineSeconds; + private static String music; + private static SoundCategory musicCategory; + private static float musicVolume; + 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); @@ -44,12 +42,18 @@ public enum Slot { private ChoiceConfig() {} static void reset() { - title = "..."; - BUTTONS.clear(); - for (Slot slot : Slot.values()) { - BUTTONS.put(slot, new Button(slot.fallback, "", List.of())); - } devoteeInjuries = 2; + answerSeconds = 90; + lineSeconds = 4; + music = ""; + musicCategory = SoundCategory.RECORDS; + musicVolume = 0.8f; + 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(); @@ -68,18 +72,29 @@ public Piece(String item, String look) { static void load(ConfigurationSection choice, ConfigurationSection classes, ConfigurationSection armour) { reset(); if (choice != null) { - title = choice.getString("title", "..."); devoteeInjuries = Math.max(0, choice.getInt("devotee_injuries", 2)); - for (Slot slot : Slot.values()) { - if (slot == Slot.QUESTION) { - BUTTONS.put(slot, new Button(slot.fallback, "", List.copyOf(choice.getStringList("question")))); - continue; - } - ConfigurationSection button = choice.getConfigurationSection(slot.key); - if (button != null) { - BUTTONS.put(slot, new Button(button.getString("item", slot.fallback), - button.getString("name", ""), List.copyOf(button.getStringList("lore")))); + 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)); + 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)) { @@ -112,12 +127,63 @@ private static Piece piece(Object entry) { return null; } - public static String title() { - return title; + /** 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 Button button(Slot slot) { - return BUTTONS.get(slot); + public static float musicVolume() { + return musicVolume; } public static int devoteeInjuries() { diff --git a/src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java b/src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java deleted file mode 100644 index 1cd517a..0000000 --- a/src/main/java/net/tfminecraft/magic/blood/ChoiceMenu.java +++ /dev/null @@ -1,122 +0,0 @@ -package net.tfminecraft.magic.blood; - -import java.util.ArrayList; -import java.util.List; - -import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.entity.Player; -import org.bukkit.event.inventory.InventoryClickEvent; -import org.bukkit.event.inventory.InventoryDragEvent; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.InventoryHolder; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; - -import net.tfminecraft.magic.Magic; -import net.tfminecraft.magic.util.ItemRef; -import net.tfminecraft.magic.util.MagicText; - -/** The god's question as a small menu: two answers, then a confirmation, since there is no undo. */ -public final class ChoiceMenu implements InventoryHolder { - - static final int SIZE = 27; - static final int QUESTION_SLOT = 4; - static final int LEFT_SLOT = 11; - static final int MIDDLE_SLOT = 13; - static final int RIGHT_SLOT = 15; - - /** NONE while the player still picks; the picked path while they confirm. */ - private final BloodPath pending; - private Inventory inventory; - - private ChoiceMenu(BloodPath pending) { - this.pending = pending; - } - - public BloodPath pending() { - return pending; - } - - @Override - public Inventory getInventory() { - return inventory; - } - - /** Opens the question for the player. */ - public static void open(Player player) { - BloodFx.play("question", player.getLocation(), player); - show(player, BloodPath.NONE); - } - - private static void show(Player player, BloodPath pending) { - ChoiceMenu menu = new ChoiceMenu(pending); - Inventory inventory = Bukkit.createInventory(menu, SIZE, MagicText.format(ChoiceConfig.title())); - menu.inventory = inventory; - inventory.setItem(QUESTION_SLOT, button(ChoiceConfig.Slot.QUESTION)); - if (pending == BloodPath.NONE) { - inventory.setItem(LEFT_SLOT, button(ChoiceConfig.Slot.DEVOTEE)); - inventory.setItem(RIGHT_SLOT, button(ChoiceConfig.Slot.TRUE)); - } else { - inventory.setItem(LEFT_SLOT, button(ChoiceConfig.Slot.CONFIRM)); - inventory.setItem(MIDDLE_SLOT, button(pending == BloodPath.DEVOTEE - ? ChoiceConfig.Slot.DEVOTEE : ChoiceConfig.Slot.TRUE)); - inventory.setItem(RIGHT_SLOT, button(ChoiceConfig.Slot.BACK)); - } - player.openInventory(inventory); - } - - @SuppressWarnings("deprecation") - static ItemStack button(ChoiceConfig.Slot slot) { - ChoiceConfig.Button def = ChoiceConfig.button(slot); - ItemStack item = ItemRef.buildOrFallback(def.item(), Material.PAPER); - ItemMeta meta = item.getItemMeta(); - meta.setDisplayName(def.name().isBlank() ? " " : MagicText.format(def.name())); - List lore = new ArrayList<>(); - for (String line : def.lore()) { - lore.add(MagicText.format(line)); - } - meta.setLore(lore); - item.setItemMeta(meta); - return item; - } - - /** Clicks inside the menu never move items; the two answer slots lead on. */ - public static void onClick(InventoryClickEvent event) { - if (!(event.getInventory().getHolder() instanceof ChoiceMenu menu)) { - return; - } - event.setCancelled(true); - if (event.getClickedInventory() != event.getInventory()) { - return; - } - Player player = (Player) event.getWhoClicked(); - int slot = event.getRawSlot(); - // Inventories are not opened, closed or filled inside a click event; it all happens a tick later. - if (menu.pending == BloodPath.NONE) { - if (slot == LEFT_SLOT || slot == RIGHT_SLOT) { - later(() -> show(player, slot == LEFT_SLOT ? BloodPath.DEVOTEE : BloodPath.TRUE)); - } - return; - } - if (slot == RIGHT_SLOT) { - later(() -> show(player, BloodPath.NONE)); - } else if (slot == LEFT_SLOT) { - BloodPath pending = menu.pending; - later(() -> { - player.closeInventory(); - BloodChoice.answer(player, pending); - }); - } - } - - private static void later(Runnable action) { - Magic.plugin.getServer().getScheduler().runTask(Magic.plugin, action); - } - - public static void onDrag(InventoryDragEvent event) { - if (event.getInventory().getHolder() instanceof ChoiceMenu) { - event.setCancelled(true); - } - } -} diff --git a/src/main/java/net/tfminecraft/magic/blood/FinalService.java b/src/main/java/net/tfminecraft/magic/blood/FinalService.java index 0c6c19c..8f04909 100644 --- a/src/main/java/net/tfminecraft/magic/blood/FinalService.java +++ b/src/main/java/net/tfminecraft/magic/blood/FinalService.java @@ -37,7 +37,7 @@ public static void begin(Player mage, BloodRecord record) { if (record.getPath() == BloodPath.TRUE) { ClassUnlockBridge.unlock(mage, ChoiceConfig.classFor(BloodPath.TRUE)); BloodArmour.give(mage, BloodPath.TRUE, record.getCharacterId(), BloodArmour.victims(record)); - mage.sendMessage(Messages.get("blood.hunt.complete")); + WhisperService.tell(mage, "blood.hunt.complete"); BloodFx.play("hunt_complete", mage.getLocation(), mage); } refill(mage, record); @@ -116,11 +116,11 @@ static void completed(Player mage, BloodRecord record) { if (record.getFinalsDone() >= HuntConfig.finalCount()) { record.setStage(BloodStage.ASCENDED); record.getFinalTargets().clear(); - mage.sendMessage(Messages.get("blood.final.ascended")); + WhisperService.tell(mage, "blood.final.ascended"); BloodFx.play("ascended", mage.getLocation(), mage); } else { refill(mage, record); - mage.sendMessage(Messages.get("blood.final.taken")); + WhisperService.tell(mage, "blood.final.taken"); BloodFx.play("final_taken", mage.getLocation(), mage); } BloodRecords.save(record); 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..0b28f2e --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/QuestionService.java @@ -0,0 +1,197 @@ +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; + 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); + if (!ChoiceConfig.music().isEmpty()) { + player.playSound(player.getLocation(), ChoiceConfig.music(), ChoiceConfig.musicCategory(), + ChoiceConfig.musicVolume(), 1f); + } + 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.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()); + } + } + + 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/WhisperService.java b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java index ab3e044..e4fd5cb 100644 --- a/src/main/java/net/tfminecraft/magic/blood/WhisperService.java +++ b/src/main/java/net/tfminecraft/magic/blood/WhisperService.java @@ -269,9 +269,20 @@ static String voiced(Player player, WhisperConfig.Speaker speaker, String line) MagicText.format(voice.style())); } - /** A blood message; one written as {god}... or {self}... is said in that voice's colours. */ + /** + * 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)) { diff --git a/src/main/resources/wildmagic/bloodmagic/choice.yml b/src/main/resources/wildmagic/bloodmagic/choice.yml index fd8ca3b..f0c3864 100644 --- a/src/main/resources/wildmagic/bloodmagic/choice.yml +++ b/src/main/resources/wildmagic/bloodmagic/choice.yml @@ -1,37 +1,47 @@ -# The god's question and its menu. +# The god's question. # Part of wildmagic/bloodmagic (Magic's hidden blood path). Hidden content: never on the wiki. -# The god's question: right-click your altar with your blood knife once the seventh kind is given. +# 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: - title: "#5c0202The Blood Father asks" - question: - - "#8a0303§oSeven kinds have bled for me, child, and you did not flinch." - - "#8a0303§oKneel, and be my child. I will carry you all the way." - - "#8a0303§oOr stand alone, and carry the weight yourself. Forever." - devotee: - item: v.red_dye - name: "#8a0303I kneel to Lorvear" - lore: - - "#8c8c8c§oPart of you stays on the stone," - - "#8c8c8c§oa wound that will never close." - - "#8c8c8c§oThe Blood Father walks with you, and chooses for you." - "true": - item: v.iron_sword - name: "#bfbfbfI kneel to no one" - lore: - - "#6e6878§oI never needed him. I never did." - - "#8c8c8c§oNo father. No voices. No one to blame." - - "#8c8c8c§oOnly you, and what you take" - - "#8c8c8c§ofrom the people who love you." - confirm: - item: v.redstone - name: "#ff5555There is no going back" - lore: - - "#8c8c8c§oThe stone will remember what you chose." - back: - item: v.barrier - name: "#aaaaaaNot yet" - lore: - - "#8c8c8c§oHe will wait. He is very good at waiting." + answer_seconds: 90 + line_seconds: 5 + # Played to the mage alone while the question lasts. sound: a resource pack sound id (blank for none). + music: + sound: "" + category: RECORDS + volume: 0.8 + 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." # Permanent injuries a devotee keeps as the mark of what they gave. devotee_injuries: 2 diff --git a/src/main/resources/wildmagic/bloodmagic/fx.yml b/src/main/resources/wildmagic/bloodmagic/fx.yml index 22271a2..0cfb7c7 100644 --- a/src/main/resources/wildmagic/bloodmagic/fx.yml +++ b/src/main/resources/wildmagic/bloodmagic/fx.yml @@ -76,6 +76,12 @@ fx: - { 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 } diff --git a/src/main/resources/wildmagic/bloodmagic/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml index 01dba8c..c03f07b 100644 --- a/src/main/resources/wildmagic/bloodmagic/messages.yml +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -5,7 +5,7 @@ blood: corruption: - unbreakable: "#8a0303§oThe stone pulses warm under your tool. It belongs to the altar now" + 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" @@ -23,9 +23,25 @@ blood: 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. - sit: "#8a0303§oYou sit on the stone. It is warm beneath you, and it is listening" + # 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" + ascended_devotee: "{stone}The stone is as warm as a heart. Somewhere far below, your father sleeps, content" + ascended_true: "{stone}Nothing moves under your hand. Nothing needs to, anymore" sit_stranger: "#8c8c8c§oThe stone is cold under you. You cannot shake the feeling that it would rather you stood" - owner_touch: "#8a0303§oThe stone is warm under your palm, like skin. You feel it breathe" stranger_touch: "#8c8c8c§oCold carved stone, dark in the seams. The longer you look at it, the less you want to touch it" @@ -51,7 +67,7 @@ blood: bind: - ok: "#8a0303§oThe beast goes rigid on the stone. Its heart hammers against the blade. Now" + 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" @@ -74,15 +90,15 @@ blood: 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: "#8a0303§oThe blade opens it. The blood runs down into the grooves of the stone, and the stone begins to drink. Stay" - draining: "#8a0303§oThe heartbeat under your hand slows. The stone is warm now, almost hot" + 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: "#8a0303§oThe stone drinks. When you take your hand away, it is warmer than before" + accepted: "{stone}The stone drinks. When you take your hand away, it is warmer than before" - complete: "#8a0303§oSeven kinds. The stone is red to its heart, and something cold and winding rests in your hand that was not there 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}" @@ -92,7 +108,7 @@ blood: bind: - ok: "#8a0303§oYou haul them onto the stone. It is already warm. Already waiting" + 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" @@ -126,11 +142,11 @@ blood: refused: "#8c8c8c§oThe stone will not take them" - taken: "#7a7a7a§oIt is done. You feel heavier, and stronger, and very far from who you were" + taken: "{stone}It is done. You feel heavier, and stronger, and very far from who you were" carry: - start: "#8a0303§oYou take hold of them. Heavy, limp and warm. The stone is waiting" + 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" @@ -140,7 +156,7 @@ blood: hunt: - complete: "#7a7a7a§oSeven measures of blood. Something in you closes like a fist. The stone knows your name now, and no father gave it" + 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: @@ -152,9 +168,9 @@ blood: nobody_self: "There is no one close enough yet. I will have to let someone in" - taken: "#7a7a7a§oA hollow opens where they used to be, and something cold flows in to fill it" + taken: "{stone}A hollow opens where they used to be, and something cold flows in to fill it" - ascended: "#5c0202§oThe last of them is gone. Nothing holds you now. Nothing ever will again" + 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" @@ -162,9 +178,11 @@ blood: choice: - devoted: "#8a0303§oSomething of you stays on the stone, and something of the Blood Father comes with you. It hurts. It will always hurt" + # 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: "#6e6878§oLorvear falls silent. For the first time in weeks you are alone with your own thoughts. You are almost sure they are your own" + 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: diff --git a/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java index a664565..2c2433e 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java @@ -101,10 +101,27 @@ void placingNeedsTheOwnerOnTheRightStageWithNoOtherAltar() { assertInstanceOf(ItemDisplay.class, Bukkit.getEntity(altar.getDisplayId())); assertEquals("blood.altar.already", AltarService.place(player, item, fx.pocket(3, 10, 3))); - assertEquals("blood.altar.owner_touch", AltarService.touch(player, altar)); - assertEquals("blood.altar.stranger_touch", AltarService.touch(fx.player("other"), altar)); + 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(); - assertEquals("blood.altar.stranger_touch", AltarService.touch(none, altar)); + 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)); } } diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java index 4403293..401c6e9 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodListenerTest.java @@ -71,7 +71,7 @@ void rightClickWithAltarItemPlacesItAndTouchingAnAltarAnswers() { PlayerInteractEvent touch = click(player, Action.RIGHT_CLICK_BLOCK, null, target, BlockFace.UP, EquipmentSlot.HAND); listener.onInteract(touch); assertTrue(touch.isCancelled()); - assertEquals("blood.altar.owner_touch", player.nextMessage()); + assertEquals("blood.altar.touch.animal", player.nextMessage()); Block grass = fx.room(30, 10, 30); grass.setType(Material.SHORT_GRASS); @@ -287,20 +287,23 @@ void theBloodKnifeOpensTheQuestionAndArmourIsPoliced() { 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.owner_touch", mage.nextMessage()); + 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.owner_touch", mage.nextMessage()); + 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.owner_touch", mage.nextMessage()); + 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.owner_touch", mage.nextMessage()); + 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)); - assertInstanceOf(ChoiceMenu.class, mage.getOpenInventory().getTopInventory().getHolder()); + 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)); @@ -309,17 +312,6 @@ void theBloodKnifeOpensTheQuestionAndArmourIsPoliced() { listener.onInteract(click(bare, Action.RIGHT_CLICK_BLOCK, null, target, BlockFace.UP, EquipmentSlot.HAND)); assertEquals("blood.altar.stranger_touch", bare.nextMessage()); - var view = mage.getOpenInventory(); - var menuClick = new org.bukkit.event.inventory.InventoryClickEvent(view, - org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, - org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL); - listener.onMenuClick(menuClick); - assertTrue(menuClick.isCancelled()); - var drag = mock(org.bukkit.event.inventory.InventoryDragEvent.class); - when(drag.getInventory()).thenReturn(view.getTopInventory()); - listener.onMenuDrag(drag); - verify(drag).setCancelled(true); - ItemStack foreign = BloodArmour.bind(new ItemStack(Material.LEATHER_HELMET), id); stranger.getInventory().setHelmet(foreign); listener.onArmourChange(new com.destroystokyo.paper.event.player.PlayerArmorChangeEvent(stranger, diff --git a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java index 7809459..a6a90ab 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java @@ -22,12 +22,6 @@ import net.tfminecraft.tlibs.objects.api.ItemAPI; import org.bukkit.Material; import org.bukkit.entity.Player; -import org.bukkit.event.inventory.ClickType; -import org.bukkit.event.inventory.InventoryAction; -import org.bukkit.event.inventory.InventoryClickEvent; -import org.bukkit.event.inventory.InventoryDragEvent; -import org.bukkit.event.inventory.InventoryType; -import org.bukkit.inventory.InventoryView; import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -78,19 +72,52 @@ void cleanup() { bridge.close(); ClassUnlockBridge.unlocksClass = "net.tfminecraft.rpcharacters.classpick.ClassUnlocks"; WhisperService.clear(); + QuestionService.clear(); + QuestionService.clock = System::currentTimeMillis; fx.close(); } - InventoryClickEvent click(int slot) { - InventoryView view = mage.getOpenInventory(); - return new InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, slot, ClickType.LEFT, InventoryAction.PICKUP_ALL); + 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("#5c0202The Blood Father asks", ChoiceConfig.title()); - assertEquals(3, ChoiceConfig.button(ChoiceConfig.Slot.QUESTION).lore().size()); - assertEquals("v.iron_sword", ChoiceConfig.button(ChoiceConfig.Slot.TRUE).item()); + 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("", ChoiceConfig.music()); + assertTrue(ChoiceConfig.answer(BloodPath.DEVOTEE).words().contains("i kneel")); + assertEquals(2, ChoiceConfig.answer(BloodPath.TRUE).sealed().size()); assertEquals(2, ChoiceConfig.devoteeInjuries()); assertEquals("bloodsworn", ChoiceConfig.classFor(BloodPath.DEVOTEE)); assertEquals("sanguinar", ChoiceConfig.classFor(BloodPath.TRUE)); @@ -101,65 +128,137 @@ void bundledQuestionClassesAndArmourLoad() { @Test void missingSectionsFallBack() throws Exception { - Files.writeString(temp.resolve("e.yml"), "enabled: true\nchoice:\n devotee_injuries: -3\nclasses:\n devotee: ' '\n 'true': [x]\n"); + Files.writeString(temp.resolve("e.yml"), String.join("\n", "enabled: true", "choice:", " devotee_injuries: -3", + " 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.title()); assertEquals(0, ChoiceConfig.devoteeInjuries()); - assertEquals("v.red_dye", ChoiceConfig.button(ChoiceConfig.Slot.DEVOTEE).item()); + 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(); - assertEquals("...", ChoiceConfig.title()); + assertTrue(ChoiceConfig.ask().isEmpty()); } @Test - void theMenuAsksThenConfirmsAndGoesBack() { - ChoiceMenu.open(mage); - ChoiceMenu menu = (ChoiceMenu) mage.getOpenInventory().getTopInventory().getHolder(); - assertEquals(BloodPath.NONE, menu.pending()); - assertSame(mage.getOpenInventory().getTopInventory(), menu.getInventory()); - ChoiceMenu.onClick(click(0)); - ChoiceMenu.onClick(click(ChoiceMenu.RIGHT_SLOT)); - fx.server.getScheduler().performTicks(1); - menu = (ChoiceMenu) mage.getOpenInventory().getTopInventory().getHolder(); - assertEquals(BloodPath.TRUE, menu.pending()); - ChoiceMenu.onClick(click(0)); - ChoiceMenu.onClick(click(ChoiceMenu.RIGHT_SLOT)); - fx.server.getScheduler().performTicks(1); - menu = (ChoiceMenu) mage.getOpenInventory().getTopInventory().getHolder(); - assertEquals(BloodPath.NONE, menu.pending()); - ChoiceMenu.onClick(click(ChoiceMenu.LEFT_SLOT)); - fx.server.getScheduler().performTicks(1); - menu = (ChoiceMenu) mage.getOpenInventory().getTopInventory().getHolder(); - assertEquals(BloodPath.DEVOTEE, menu.pending()); - InventoryClickEvent below = click(40); - ChoiceMenu.onClick(below); - assertTrue(below.isCancelled()); - ChoiceMenu.onClick(click(ChoiceMenu.LEFT_SLOT)); - fx.server.getScheduler().performTicks(1); + 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())); + } - InventoryDragEvent drag = mock(InventoryDragEvent.class); - when(drag.getInventory()).thenReturn(fx.server.createInventory(null, 9)); - ChoiceMenu.onDrag(drag); - verify(drag, never()).setCancelled(true); - mage.openInventory(fx.server.createInventory(null, 9)); - InventoryClickEvent elsewhere = click(0); - ChoiceMenu.onClick(elsewhere); - assertFalse(elsewhere.isCancelled()); - ChoiceMenu.open(mage); - InventoryDragEvent menuDrag = mock(InventoryDragEvent.class); - when(menuDrag.getInventory()).thenReturn(mage.getOpenInventory().getTopInventory()); - ChoiceMenu.onDrag(menuDrag); - verify(menuDrag).setCancelled(true); + @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 buttonsWithoutNamesStayBlank() throws Exception { - Files.writeString(temp.resolve("b.yml"), "enabled: true\nchoice:\n back:\n item: v.stone\n"); - BloodConfig.load(temp.resolve("b.yml").toFile(), fx.logger); - assertEquals(" ", ChoiceMenu.button(ChoiceConfig.Slot.BACK).getItemMeta().getDisplayName()); + 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' }\n"); + BloodConfig.load(temp.resolve("m.yml").toFile(), fx.logger); + QuestionService.start(mage); + wait(2); + 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 diff --git a/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java index edf3dd2..b834bcc 100644 --- a/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/VoiceTest.java @@ -178,6 +178,30 @@ void animalVoicesWaitForTheChamberAndAReadyStone() { 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"); @@ -228,7 +252,7 @@ void sittingOnTheAltarAnswersItsOwnerAndChillsOthers() { 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", mage.nextMessage()); + 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()); From 9b96dece48dd0a7aa8f181dcea5208499c6680c7 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:15:13 +0100 Subject: [PATCH 39/55] Question music by default, looping while the question lasts choice.music defaults to tfmc_blood:rite.question with loop_seconds 91: the track is started again for as long as the question goes on, and stopped when it ends. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/ChoiceConfig.java | 8 ++++++++ .../magic/blood/QuestionService.java | 20 +++++++++++++++---- .../resources/wildmagic/bloodmagic/choice.yml | 7 +++++-- .../tfminecraft/magic/blood/ChoiceTest.java | 8 +++++--- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java index 3c08623..27c4280 100644 --- a/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java @@ -27,6 +27,7 @@ public record Answer(List words, List confirm, List sealed) 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; @@ -48,6 +49,7 @@ static void reset() { music = ""; musicCategory = SoundCategory.RECORDS; musicVolume = 0.8f; + musicLoopSeconds = 0; ask = List.of(); unclear = null; walkedAway = null; @@ -77,6 +79,7 @@ static void load(ConfigurationSection choice, ConfigurationSection classes, Conf 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) { @@ -186,6 +189,11 @@ 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; + } + public static int devoteeInjuries() { return devoteeInjuries; } diff --git a/src/main/java/net/tfminecraft/magic/blood/QuestionService.java b/src/main/java/net/tfminecraft/magic/blood/QuestionService.java index 0b28f2e..0d34e26 100644 --- a/src/main/java/net/tfminecraft/magic/blood/QuestionService.java +++ b/src/main/java/net/tfminecraft/magic/blood/QuestionService.java @@ -32,6 +32,7 @@ private static final class Session { long nextLineAt; long endsAt; long lastUnclearAt; + long musicAgainAt; BloodPath heard; BloodPath sealed; @@ -62,10 +63,7 @@ public static void start(Player player) { session.lines.addAll(ChoiceConfig.ask()); session.nextLineAt = clock.getAsLong(); SESSIONS.put(player.getUniqueId(), session); - if (!ChoiceConfig.music().isEmpty()) { - player.playSound(player.getLocation(), ChoiceConfig.music(), ChoiceConfig.musicCategory(), - ChoiceConfig.musicVolume(), 1f); - } + 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()); @@ -94,6 +92,10 @@ private static boolean step(Player player, Session session, long now) { 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()); @@ -180,6 +182,16 @@ private static void say(Player player, ChoiceConfig.Line line) { } } + /** 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()); diff --git a/src/main/resources/wildmagic/bloodmagic/choice.yml b/src/main/resources/wildmagic/bloodmagic/choice.yml index f0c3864..8fabd09 100644 --- a/src/main/resources/wildmagic/bloodmagic/choice.yml +++ b/src/main/resources/wildmagic/bloodmagic/choice.yml @@ -11,11 +11,14 @@ 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). + # 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: "" + 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." diff --git a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java index a6a90ab..6c7bb87 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java @@ -115,7 +115,8 @@ void bundledQuestionClassesAndArmourLoad() { assertNotNull(ChoiceConfig.walkedAway()); assertEquals(90, ChoiceConfig.answerSeconds()); assertEquals(5, ChoiceConfig.lineSeconds()); - assertEquals("", ChoiceConfig.music()); + 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(2, ChoiceConfig.devoteeInjuries()); @@ -241,10 +242,11 @@ void walkingAwayOrSilenceEndsTheQuestion() throws Exception { 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' }\n"); + 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(2); + wait(4); mage.teleport(new org.bukkit.Location(fx.world, 40.5, 10, 40.5)); wait(1); assertFalse(QuestionService.asking(mage.getUniqueId())); From 92c1a11b79b6a2cc6112c8c90fb45409542de950 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:19:36 +0100 Subject: [PATCH 40/55] corruption.ambient_blocks: how many blocks the slow spread changes each time Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/magic/blood/AltarCorruption.java | 4 ++-- .../net/tfminecraft/magic/blood/CorruptionConfig.java | 8 ++++++++ src/main/resources/wildmagic/bloodmagic/corruption.yml | 2 ++ .../tfminecraft/magic/blood/CorruptionAndCluesTest.java | 1 + 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java index e909066..c7bdff7 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarCorruption.java @@ -235,7 +235,7 @@ public static void restore(BloodAltar altar) { journal.clear(); } - /** The slow spread between sacrifices: one block per altar every few minutes. */ + /** 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; @@ -244,7 +244,7 @@ static void ambient() { if (now - altar.getLastSpreadAt() < every) { continue; } - if (spread(altar, 1) > 0) { + if (spread(altar, CorruptionConfig.ambientBlocks()) > 0) { altar.setLastSpreadAt(now); changed = true; } diff --git a/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java b/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java index 3fcdf28..807765d 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/CorruptionConfig.java @@ -31,6 +31,7 @@ public enum Placement { private static final List RULES = new ArrayList<>(); private static int blocksPerSacrifice; private static int ambientEveryMinutes; + private static int ambientBlocks; static { reset(); @@ -44,6 +45,7 @@ static void reset() { RULES.clear(); blocksPerSacrifice = 3; ambientEveryMinutes = 30; + ambientBlocks = 1; } static void load(ConfigurationSection section, Logger log) { @@ -69,6 +71,7 @@ static void load(ConfigurationSection section, Logger log) { } 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) { @@ -142,6 +145,11 @@ 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/resources/wildmagic/bloodmagic/corruption.yml b/src/main/resources/wildmagic/bloodmagic/corruption.yml index 8b6f6ae..16f2f33 100644 --- a/src/main/resources/wildmagic/bloodmagic/corruption.yml +++ b/src/main/resources/wildmagic/bloodmagic/corruption.yml @@ -13,6 +13,8 @@ corruption: 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: diff --git a/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java index 67eecaf..50ef35d 100644 --- a/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/CorruptionAndCluesTest.java @@ -221,6 +221,7 @@ void edgeOfLoadedChunksIsLeftAlone() throws Exception { 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); From 28da311f3d357f80dd403d0b9c885e1e08288607 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:42:38 +0100 Subject: [PATCH 41/55] Light the blood altar's candles with vanilla flame particles The altar model's candles now carry only wicks. Magic spawns a small flame (and now and then a wisp of smoke) over each wick while a player is near, following the altar's facing, scale and the candle the skull takes at level 4 (fx.yml candles:). Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/AltarCandles.java | 146 ++++++++++++++++++ .../net/tfminecraft/magic/blood/BloodFx.java | 2 + .../tfminecraft/magic/blood/BloodMagic.java | 2 + .../resources/wildmagic/bloodmagic/fx.yml | 17 ++ .../magic/blood/AltarCandlesTest.java | 123 +++++++++++++++ 5 files changed, 290 insertions(+) create mode 100644 src/main/java/net/tfminecraft/magic/blood/AltarCandles.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/AltarCandlesTest.java 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/BloodFx.java b/src/main/java/net/tfminecraft/magic/blood/BloodFx.java index 9f29a88..4113033 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodFx.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodFx.java @@ -46,6 +46,7 @@ static void reset() { enabled = true; hearRadius = 10; MOMENTS.clear(); + AltarCandles.reset(); } /** How far a shared sound carries: only players this close hear it. */ @@ -60,6 +61,7 @@ static void load(ConfigurationSection fx, Logger log) { } 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; diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index 9ca2ac2..bc2114c 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -31,6 +31,7 @@ public static void start(Plugin plugin) { plugin.getServer().getPluginManager().registerEvents(new BloodListener(), plugin); MagicTickService.register(BloodMagic::tick); CarryService.startTicking(plugin); + AltarCandles.startTicking(plugin); plugin.getServer().getScheduler().runTask(plugin, BloodMagic::settle); } @@ -118,6 +119,7 @@ public static void stop() { BloodNotes.clear(); AltarPedestals.clear(); QuestionService.clear(); + AltarCandles.stop(); WhisperService.clear(); BloodRecords.clear(); BloodAltars.clear(); diff --git a/src/main/resources/wildmagic/bloodmagic/fx.yml b/src/main/resources/wildmagic/bloodmagic/fx.yml index 0cfb7c7..40b109d 100644 --- a/src/main/resources/wildmagic/bloodmagic/fx.yml +++ b/src/main/resources/wildmagic/bloodmagic/fx.yml @@ -9,6 +9,23 @@ # 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 (the wick tops in .build/blood-art/altar.py), + # 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: + - [13.5, 16.4, 2.5, 5] + - [13.5, 17.4, 14.5, 5] + - [1.5, 16.4, 14.5, 5] + - [1.5, 15.4, 2.5, 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 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(); + } +} From 618c3c13e8a297895a7eb91968239bfb780a4cd9 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:09:14 +0100 Subject: [PATCH 42/55] Raise the altar candle flames to sit on the wick tips Like vanilla candles, each flame now spawns 1 px above its wick top instead of 0.4 px, so it no longer sinks into the wick. Co-Authored-By: Claude Opus 5.5 --- src/main/resources/wildmagic/bloodmagic/fx.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/resources/wildmagic/bloodmagic/fx.yml b/src/main/resources/wildmagic/bloodmagic/fx.yml index 40b109d..030e1e9 100644 --- a/src/main/resources/wildmagic/bloodmagic/fx.yml +++ b/src/main/resources/wildmagic/bloodmagic/fx.yml @@ -11,7 +11,7 @@ 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 (the wick tops in .build/blood-art/altar.py), + # wicks: [x, y, z, max_level] in the model's pixels, 1 px over each wick top like vanilla candles, # 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: @@ -22,10 +22,10 @@ fx: view_range: 24 yaw_offset: 180 wicks: - - [13.5, 16.4, 2.5, 5] - - [13.5, 17.4, 14.5, 5] - - [1.5, 16.4, 14.5, 5] - - [1.5, 15.4, 2.5, 3] + - [13.5, 17, 2.5, 5] + - [13.5, 18, 14.5, 5] + - [1.5, 17, 14.5, 5] + - [1.5, 16, 2.5, 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 From fa5878b550946a1d5a9e931fe4be92175db8e024 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:17:31 +0100 Subject: [PATCH 43/55] Centre the altar candle flames over the new vanilla-style wicks Co-Authored-By: Claude Opus 5.5 --- src/main/resources/wildmagic/bloodmagic/fx.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/resources/wildmagic/bloodmagic/fx.yml b/src/main/resources/wildmagic/bloodmagic/fx.yml index 030e1e9..4d8fab0 100644 --- a/src/main/resources/wildmagic/bloodmagic/fx.yml +++ b/src/main/resources/wildmagic/bloodmagic/fx.yml @@ -11,7 +11,7 @@ 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 each wick top like vanilla candles, + # 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: @@ -22,10 +22,10 @@ fx: view_range: 24 yaw_offset: 180 wicks: - - [13.5, 17, 2.5, 5] - - [13.5, 18, 14.5, 5] - - [1.5, 17, 14.5, 5] - - [1.5, 16, 2.5, 3] + - [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 From 98178f9edb07ce1d82bcc779cd09969b7fede83e Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:56:24 +0100 Subject: [PATCH 44/55] Assign the blood classes and give them their final-sacrifice rewards The devotee answer and the end of the hunt now put the character into the path's class through RPCharacters (bound to it; players never pick it). finals.class_rewards lists what each final sacrifice gives the class: points for its own skill-point pool, MMOCore slots and spells. ClassRewards works it out from the record on every final, session start, class change and staff stage/path change; ascended characters get all. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/BloodChoice.java | 4 +- .../tfminecraft/magic/blood/BloodCommand.java | 2 + .../magic/blood/BloodListener.java | 1 + .../tfminecraft/magic/blood/BloodMagic.java | 3 + .../tfminecraft/magic/blood/ClassRewards.java | 51 ++++ .../magic/blood/ClassUnlockBridge.java | 43 +++- .../tfminecraft/magic/blood/FinalService.java | 6 +- .../tfminecraft/magic/blood/HuntConfig.java | 77 +++++- .../magic/blood/MmoClassRewards.java | 56 ++++ .../wildmagic/bloodmagic/messages.yml | 4 + .../wildmagic/bloodmagic/settings.yml | 24 +- .../magic/blood/BloodMagicTest.java | 16 ++ .../tfminecraft/magic/blood/ChoiceTest.java | 10 +- .../magic/blood/ClassRewardsTest.java | 241 ++++++++++++++++++ 14 files changed, 515 insertions(+), 23 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/blood/ClassRewards.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/MmoClassRewards.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/ClassRewardsTest.java diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java index 8f89313..d77f132 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java @@ -8,7 +8,7 @@ /** * The answer to the god's question. A devotee gives part of themself (permanent injuries), skips - * the hunt and gets the devotee class and armour at once. A mage who refuses keeps their class and + * 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 { @@ -37,7 +37,7 @@ public static boolean answer(Player player, BloodPath path) { marks++; } } - ClassUnlockBridge.unlock(player, ChoiceConfig.classFor(BloodPath.DEVOTEE)); + 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); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java index 1195cd6..27504a6 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java @@ -107,6 +107,7 @@ private static void stage(CommandSender sender, Player player, String character, record.setStage(stage); saved(sender, record); AltarService.refreshFor(character); + ClassRewards.apply(player); sender.sendMessage(Messages.get("blood.admin.stage_set", "player", player.getName(), "stage", stage.name())); } @@ -120,6 +121,7 @@ private static void path(CommandSender sender, Player player, String character, record.setPath(path); saved(sender, record); BloodPerms.refresh(player); + ClassRewards.apply(player); AltarService.refreshFor(character); sender.sendMessage(Messages.get("blood.admin.path_set", "player", player.getName(), "path", path.name())); } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index e073559..4227912 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -364,6 +364,7 @@ public void onSessionStart(CharacterSessionStartEvent event) { String character = event.getSession().ref().characterId().toString(); BondService.load(character); BloodBoons.apply(event.getPlayer()); + ClassRewards.applySoon(event.getPlayer()); BloodPerms.refresh(event.getPlayer()); FinalService.check(event.getPlayer()); } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index bc2114c..40bb519 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -29,6 +29,9 @@ public static void start(Plugin plugin) { 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); + } MagicTickService.register(BloodMagic::tick); CarryService.startTicking(plugin); AltarCandles.startTicking(plugin); 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 index 7291638..fe6173b 100644 --- a/src/main/java/net/tfminecraft/magic/blood/ClassUnlockBridge.java +++ b/src/main/java/net/tfminecraft/magic/blood/ClassUnlockBridge.java @@ -5,22 +5,24 @@ import net.tfminecraft.magic.Magic; /** - * Unlocks a hidden class for the player's active character through RPCharacters' ClassUnlocks. - * Reached by reflection so Magic also runs against RPCharacters versions without it; then staff - * unlock the class by hand, as the log says. + * 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 unlock(Player player, String classId) { + public static boolean assign(Player player, String classId) { if (classId == null) { return false; } try { - Object result = Class.forName(unlocksClass).getMethod("unlock", Player.class, String.class) + Object result = Class.forName(unlocksClass).getMethod("assign", Player.class, String.class) .invoke(null, player, classId); if (Boolean.TRUE.equals(result)) { return true; @@ -28,8 +30,35 @@ public static boolean unlock(Player player, String classId) { } catch (ReflectiveOperationException | LinkageError ex) { // Falls through to the warning below. } - Magic.plugin.getLogger().warning("[Magic] Could not unlock class " + classId + " for " - + player.getName() + "; unlock it by hand (/rpcharacter admin unlockclass)"); + 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/FinalService.java b/src/main/java/net/tfminecraft/magic/blood/FinalService.java index 8f04909..ad67cdc 100644 --- a/src/main/java/net/tfminecraft/magic/blood/FinalService.java +++ b/src/main/java/net/tfminecraft/magic/blood/FinalService.java @@ -31,17 +31,18 @@ public final class FinalService { private FinalService() {} - /** Starts the finals: the path's class and armour (true path), then the first target. */ + /** 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.unlock(mage, ChoiceConfig.classFor(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); name(mage, record); Magic.plugin.getLogger().info("[Magic] Character " + record.getCharacterId() + " began the final sacrifices: " + record.getFinalTargets()); @@ -125,6 +126,7 @@ static void completed(Player mage, BloodRecord record) { } BloodRecords.save(record); BloodBoons.apply(mage); + ClassRewards.apply(mage); if (record.getStage() == BloodStage.FINAL) { name(mage, record); } diff --git a/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java b/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java index 511451c..d616157 100644 --- a/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/HuntConfig.java @@ -18,6 +18,22 @@ 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; @@ -43,6 +59,7 @@ public record RankPoints(int upTo, int points) {} 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(); @@ -74,6 +91,7 @@ static void reset() { 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) { @@ -118,7 +136,47 @@ static void load(ConfigurationSection hunt, ConfigurationSection bonds, Configur 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; } } @@ -126,12 +184,7 @@ private static TreeMap> boons(ConfigurationSection TreeMap> out = new TreeMap<>(); for (String key : section.getKeys(false)) { ConfigurationSection stats = section.getConfigurationSection(key); - int index; - try { - index = Integer.parseInt(key.trim()); - } catch (NumberFormatException ex) { - index = -1; - } + int index = index(key); if (index < 1 || stats == null) { log.warning("[Magic] blood magic config finals.boons entry '" + key + "' skipped"); continue; @@ -252,4 +305,16 @@ public static Map boonsUpTo(BloodPath path, int finalsDone) { } 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/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/resources/wildmagic/bloodmagic/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml index c03f07b..6a6c48c 100644 --- a/src/main/resources/wildmagic/bloodmagic/messages.yml +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -176,6 +176,10 @@ blood: failed_self: "Someone else got to them first. Everything I put into them, wasted" + 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). diff --git a/src/main/resources/wildmagic/bloodmagic/settings.yml b/src/main/resources/wildmagic/bloodmagic/settings.yml index 1c86f80..cc87836 100644 --- a/src/main/resources/wildmagic/bloodmagic/settings.yml +++ b/src/main/resources/wildmagic/bloodmagic/settings.yml @@ -134,8 +134,30 @@ finals: 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: 4 } + 2: { skill_points: 4, slots: [3] } + 3: { skill_points: 4 } + 4: { skill_points: 4, slots: [4] } + 5: { skill_points: 4 } + "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) unlocked for the character through RPCharacters. +# 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 diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java index 0850696..bd9051a 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java @@ -19,6 +19,22 @@ 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)) { diff --git a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java index 6c7bb87..f037994 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java @@ -45,7 +45,7 @@ public static final class FakeUnlocks { private FakeUnlocks() {} - public static boolean unlock(Player player, String classId) { + public static boolean assign(Player player, String classId) { last = classId; return answer; } @@ -313,13 +313,13 @@ void onlyACharacterAtTheQuestionWithAnAltarMayAnswer() { @Test void classUnlockBridgeReportsFailures() { - assertFalse(ClassUnlockBridge.unlock(mage, null)); + assertFalse(ClassUnlockBridge.assign(mage, null)); FakeUnlocks.answer = false; - assertFalse(ClassUnlockBridge.unlock(mage, "x")); + assertFalse(ClassUnlockBridge.assign(mage, "x")); FakeUnlocks.answer = true; - assertTrue(ClassUnlockBridge.unlock(mage, "x")); + assertTrue(ClassUnlockBridge.assign(mage, "x")); ClassUnlockBridge.unlocksClass = "no.such.Unlocks"; - assertFalse(ClassUnlockBridge.unlock(mage, "x")); + assertFalse(ClassUnlockBridge.assign(mage, "x")); } @Test 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..d270a79 --- /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(8, 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(4, 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(20, FakePoints.pools.get("bloodsworn")); + messages(); + FakePoints.pools.put("bloodsworn", 30); + ClassRewards.apply(mage); + assertEquals(20, 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); + } + } +} From 036bedac87b58952bff84d45556d7258e272af67 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:11:32 +0100 Subject: [PATCH 45/55] Give Bloodsworn 2 skill points per final sacrifice fran's balance: 10 points over the five finals instead of 20. Co-Authored-By: Claude Opus 5.5 --- src/main/resources/wildmagic/bloodmagic/settings.yml | 10 +++++----- .../net/tfminecraft/magic/blood/ClassRewardsTest.java | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/resources/wildmagic/bloodmagic/settings.yml b/src/main/resources/wildmagic/bloodmagic/settings.yml index cc87836..fb4603a 100644 --- a/src/main/resources/wildmagic/bloodmagic/settings.yml +++ b/src/main/resources/wildmagic/bloodmagic/settings.yml @@ -143,11 +143,11 @@ finals: # spells: MMOCore class skills it unlocks class_rewards: devotee: - 1: { skill_points: 4 } - 2: { skill_points: 4, slots: [3] } - 3: { skill_points: 4 } - 4: { skill_points: 4, slots: [4] } - 5: { skill_points: 4 } + 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] } diff --git a/src/test/java/net/tfminecraft/magic/blood/ClassRewardsTest.java b/src/test/java/net/tfminecraft/magic/blood/ClassRewardsTest.java index d270a79..5f1146e 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ClassRewardsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ClassRewardsTest.java @@ -97,7 +97,7 @@ String messages() { @Test void rewardsAddUpPerFinalAndAscensionGivesEverything() { - assertEquals(new HuntConfig.ClassReward(8, List.of(3), List.of()), + 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)); @@ -133,17 +133,17 @@ void pointsFollowTheSacrificesAndOnlyNewOnesAreAnnounced() { assertEquals("", messages()); record.setFinalsDone(1); ClassRewards.apply(mage); - assertEquals(4, FakePoints.pools.get("bloodsworn")); + 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(20, FakePoints.pools.get("bloodsworn")); + assertEquals(10, FakePoints.pools.get("bloodsworn")); messages(); FakePoints.pools.put("bloodsworn", 30); ClassRewards.apply(mage); - assertEquals(20, FakePoints.pools.get("bloodsworn")); + assertEquals(10, FakePoints.pools.get("bloodsworn")); assertEquals("", messages(), "Fewer points than before is no gift"); FakePoints.pools.put("bloodsworn", 3); FakePoints.answer = false; From 143f0ffcc81968ea4a9d275dc18e73188a119959 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:23:30 +0100 Subject: [PATCH 46/55] Give final-sacrifice boons to the true path only fran: devotees get their class rewards but no stat boons. Co-Authored-By: Claude Opus 5.5 --- src/main/resources/wildmagic/bloodmagic/settings.yml | 9 ++------- .../java/net/tfminecraft/magic/blood/HuntConfigTest.java | 1 + 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/main/resources/wildmagic/bloodmagic/settings.yml b/src/main/resources/wildmagic/bloodmagic/settings.yml index fb4603a..a6338ea 100644 --- a/src/main/resources/wildmagic/bloodmagic/settings.yml +++ b/src/main/resources/wildmagic/bloodmagic/settings.yml @@ -120,14 +120,9 @@ finals: 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. + # 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: - devotee: - 1: { MAX_HEALTH: 1 } - 2: { MAX_MANA: 10 } - 3: { MAX_HEALTH: 1 } - 4: { SPELL_DAMAGE: 2 } - 5: { MAX_HEALTH: 2 } "true": 1: { MAX_HEALTH: 2 } 2: { MAX_MANA: 20 } diff --git a/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java b/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java index b681d07..03c5bce 100644 --- a/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java @@ -47,6 +47,7 @@ void bundledHuntBondsAndFinalsLoad() { assertEquals(Map.of(), HuntConfig.boonsUpTo(BloodPath.TRUE, 0)); assertEquals(Map.of("MAX_HEALTH", 4.0, "MAX_MANA", 20.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 From 84598e1319cff02934dec6c82ccdfb75b0ce5665 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:26:20 +0100 Subject: [PATCH 47/55] Make the true path's mana boon a health boon Sanguinar has no mana, so final 2 gives 2 max health instead of 20 max mana. Co-Authored-By: Claude Opus 5.5 --- src/main/resources/wildmagic/bloodmagic/settings.yml | 2 +- src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/wildmagic/bloodmagic/settings.yml b/src/main/resources/wildmagic/bloodmagic/settings.yml index a6338ea..af8e76e 100644 --- a/src/main/resources/wildmagic/bloodmagic/settings.yml +++ b/src/main/resources/wildmagic/bloodmagic/settings.yml @@ -125,7 +125,7 @@ finals: boons: "true": 1: { MAX_HEALTH: 2 } - 2: { MAX_MANA: 20 } + 2: { MAX_HEALTH: 2 } 3: { MAX_HEALTH: 2 } 4: { SPELL_DAMAGE: 4 } 5: { MAX_HEALTH: 4 } diff --git a/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java b/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java index 03c5bce..b057eb5 100644 --- a/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/HuntConfigTest.java @@ -45,7 +45,7 @@ void bundledHuntBondsAndFinalsLoad() { 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", 4.0, "MAX_MANA", 20.0), HuntConfig.boonsUpTo(BloodPath.TRUE, 3)); + 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"); } From 6a9ff3292c3c01f26b61dc5dfcc0cf9e876def57 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:30:09 +0100 Subject: [PATCH 48/55] Test the boon stats with two kinds after the mana boon went Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java b/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java index 4479006..a4386da 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java @@ -66,7 +66,7 @@ void cleanup() { @Test void boonsNeedMythicLibAndReplaceEachOther() { - record.setFinalsDone(2); + record.setFinalsDone(4); // health and spell damage: two stats BloodBoons.apply(mage); BloodBoons.clear(mage); MockBukkit.createMockPlugin("MythicLib"); From 16e18e106c118725588e259c1e73e79f7b185712 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:59:01 +0100 Subject: [PATCH 49/55] Give the blood paths artefacts and a pool of blood that needs feeding Each path now sets out 8 bound artefacts on its altar's pedestals when its class is given: the devotee's at the kneel, the true path's when the hunt is done, and each true-path final puts one named after its victim in place of an older one. An artefact only counts once sunk into the altar's pool of blood, and each gives an equal share of the path's blood resonance (devotee 20, true path 20 rising to 95 with the finals). Blood spells now need that resonance for their tier (Minor 5 up to Transcendent 90); a refused spell costs nothing. The pool stays full while the path goes on. Once it is complete, the sunk artefacts drink it by the hour the character is played, and only people sacrificed on the altar fill it again (by closeness, same rules as the hunt). Resonance never falls below the Minor floor, and nothing earned is taken away. Staff get /magic blood pool and give artefacts. Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/magic/Magic.java | 1 + .../magic/blood/AltarPedestals.java | 64 ++ .../tfminecraft/magic/blood/AltarService.java | 8 +- .../magic/blood/ArtefactConfig.java | 75 +++ .../magic/blood/BloodArtefacts.java | 238 +++++++ .../tfminecraft/magic/blood/BloodCommand.java | 57 +- .../tfminecraft/magic/blood/BloodConfig.java | 6 +- .../tfminecraft/magic/blood/BloodItems.java | 3 +- .../magic/blood/BloodListener.java | 30 +- .../tfminecraft/magic/blood/BloodMagic.java | 4 + .../tfminecraft/magic/blood/BloodPool.java | 77 +++ .../tfminecraft/magic/blood/BloodRecord.java | 35 +- .../magic/blood/BloodSpellGate.java | 58 ++ .../magic/blood/CharacterRiteService.java | 21 +- .../tfminecraft/magic/blood/FinalService.java | 3 + .../tfminecraft/magic/blood/PoolConfig.java | 175 ++++++ .../wildmagic/bloodmagic/artefacts.yml | 42 ++ .../wildmagic/bloodmagic/messages.yml | 32 +- .../wildmagic/bloodmagic/settings.yml | 36 ++ .../magic/blood/AltarPedestalsTest.java | 73 +++ .../magic/blood/ArtefactsAndPoolTest.java | 586 ++++++++++++++++++ .../magic/blood/HuntAndFinalsTest.java | 21 + 22 files changed, 1626 insertions(+), 19 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/blood/ArtefactConfig.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodArtefacts.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodPool.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodSpellGate.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/PoolConfig.java create mode 100644 src/main/resources/wildmagic/bloodmagic/artefacts.yml create mode 100644 src/test/java/net/tfminecraft/magic/blood/ArtefactsAndPoolTest.java diff --git a/src/main/java/net/tfminecraft/magic/Magic.java b/src/main/java/net/tfminecraft/magic/Magic.java index b9798e5..105b608 100644 --- a/src/main/java/net/tfminecraft/magic/Magic.java +++ b/src/main/java/net/tfminecraft/magic/Magic.java @@ -332,6 +332,7 @@ private void createConfigs() { "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", diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarPedestals.java b/src/main/java/net/tfminecraft/magic/blood/AltarPedestals.java index 0f5a637..45351c4 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarPedestals.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarPedestals.java @@ -4,6 +4,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -12,12 +13,16 @@ 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; @@ -114,6 +119,65 @@ public static int place(BloodAltar altar, Block center) { 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; + } + + /** 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()}). diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarService.java b/src/main/java/net/tfminecraft/magic/blood/AltarService.java index e39152e..5261e55 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -183,8 +183,12 @@ 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); - WhisperService.tell(player, owner ? "blood.altar.touch." + stageKey(BloodRecords.get(character)) - : "blood.altar.stranger_touch"); + 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... */ 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/BloodArtefacts.java b/src/main/java/net/tfminecraft/magic/blood/BloodArtefacts.java new file mode 100644 index 0000000..893f88f --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodArtefacts.java @@ -0,0 +1,238 @@ +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, 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), and only + * of use once sunk into the altar's pool of blood. Each holds a place; its id names the place and what + * fills it (devotee:3, true:hunt:3, true:final:3), so an artefact whose place was filled again since + * dissolves instead of sinking. They are not Magic artifacts: no aura, no wear, no use in mage gear. + */ +public final class BloodArtefacts { + + static final String KIND = "artefact"; + + /** What sinking an artefact into the pool came to. */ + public enum Sink { + SUNK, + ALL_SUNK, + NOT_YOURS, + STALE, + ALREADY + } + + 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; + } + } + + /** How many of the character's artefacts are in the pool now. */ + static int sunk(BloodRecord record) { + int count = 0; + for (String id : ids(record)) { + if (record.getSunk().contains(id)) { + 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 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 follows an artefact in the pool takes that one's place there: the old one + * stops counting. 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.getSunk().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} 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); + } + }); + } + + /** + * The player sinks the held artefact into their own altar's pool. Anything but NOT_YOURS uses the + * item up: an artefact whose place was filled anew, or a copy of one already in the pool, crumbles. + */ + public static Sink sink(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 Sink.NOT_YOURS; + } + String id = id(held); + held.setAmount(held.getAmount() - 1); + if (!ids(record).contains(id)) { + return Sink.STALE; + } + if (record.getSunk().contains(id)) { + return Sink.ALREADY; + } + record.getSunk().add(id); + BloodRecords.save(record); + return sunk(record) >= ids(record).size() ? Sink.ALL_SUNK : Sink.SUNK; + } + + /** Staff: gives the player every one of their current artefacts that is not in the pool. */ + public static int regive(Player player) { + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); + int given = 0; + for (String id : ids(record)) { + if (!record.getSunk().contains(id)) { + WhisperService.give(player, create(record, id)); + given++; + } + } + return given; + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java index 27504a6..dd0a3ea 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java @@ -4,6 +4,7 @@ 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; @@ -16,14 +17,15 @@ import net.tfminecraft.tlibs.character.Characters; /** - * Staff tools: /magic blood <list|info|stage|path|give|whisper|voice|target|bonds|altar|reset>. + * 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", "reset"); + "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() {} @@ -41,6 +43,7 @@ public static boolean execute(CommandSender sender, String[] args) { 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")); } @@ -95,6 +98,46 @@ private static void info(CommandSender sender, Player player, String character) "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)), + "sunk", String.valueOf(BloodArtefacts.sunk(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) { @@ -108,6 +151,7 @@ private static void stage(CommandSender sender, Player player, String character, 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())); } @@ -122,11 +166,17 @@ private static void path(CommandSender sender, Player player, String character, 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")); @@ -315,7 +365,8 @@ public static List complete(String[] args) { return switch (sub) { case "stage" -> lower(BloodStage.values()); case "path" -> lower(BloodPath.values()); - case "give" -> Arrays.stream(BloodItemKind.values()).map(BloodItemKind::key).toList(); + 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(); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java index c37a088..f02bfdf 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java @@ -18,7 +18,7 @@ /** * Settings from the wildmagic/bloodmagic folder (settings, items, voices, animals, corruption, choice, - * armour and fx files, merged; messages.yml is read by Messages). Defaults keep the feature safe when + * 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 { @@ -142,6 +142,8 @@ public static boolean load(File source, Logger log) { ChoiceConfig.loadPermissions(config.getConfigurationSection("permissions")); HuntConfig.load(config.getConfigurationSection("hunt"), config.getConfigurationSection("bonds"), config.getConfigurationSection("finals"), log); + PoolConfig.load(config.getConfigurationSection("pool"), log); + ArtefactConfig.load(config.getConfigurationSection("artefacts"), log); BloodFx.load(config.getConfigurationSection("fx"), log); return ok; } @@ -205,6 +207,8 @@ public static void reset() { CorruptionConfig.reset(); ChoiceConfig.reset(); HuntConfig.reset(); + PoolConfig.reset(); + ArtefactConfig.reset(); BloodFx.reset(); } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodItems.java b/src/main/java/net/tfminecraft/magic/blood/BloodItems.java index 049012d..141fb43 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodItems.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodItems.java @@ -72,7 +72,8 @@ public static boolean is(ItemStack item, BloodItemKind kind) { return kind != null && kind == kind(item); } - private static String read(ItemStack item, NamespacedKey key) { + /** 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; } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index 4227912..7e88f5f 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -40,6 +40,7 @@ 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; @@ -77,6 +78,14 @@ public void onInteract(PlayerInteractEvent event) { layDown(player, carried); return; } + if (BloodArtefacts.is(held)) { + BloodArtefacts.Sink sunk = BloodArtefacts.sink(player, altar, held); + player.getInventory().setItemInMainHand(held.getAmount() > 0 ? held : null); + WhisperService.tell(player, "blood.artefact.sink." + sunk.name().toLowerCase(Locale.ROOT)); + BloodFx.play(sunk == BloodArtefacts.Sink.NOT_YOURS ? "altar_touch_stranger" : "artefact_sunk", + altar.center(), player); + return; + } AltarService.touch(player, altar); return; } @@ -152,6 +161,19 @@ public void onFurnitureInteract(FurnitureInteractEvent event) { } } + /** 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) { @@ -183,10 +205,12 @@ public void onArmourChange(PlayerArmorChangeEvent event) { } } - /** A dead owner's blood armour crumbles when someone tries to pick it up. */ + /** A dead owner's blood armour and artefacts crumble when someone tries to pick them up. */ @EventHandler(ignoreCancelled = true) public void onPickup(EntityPickupItemEvent event) { - if (BloodArmour.crumbles(event.getItem().getItemStack())) { + 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(); @@ -352,6 +376,7 @@ public void onQuit(PlayerQuitEvent event) { AnimalRiteService.releaseFor(event.getPlayer().getUniqueId()); QuestionService.forget(event.getPlayer().getUniqueId()); CarryService.drop(event.getPlayer().getUniqueId()); + BloodSpellGate.forget(event.getPlayer().getUniqueId()); BloodPerms.clear(event.getPlayer()); } @@ -365,6 +390,7 @@ public void onSessionStart(CharacterSessionStartEvent event) { BondService.load(character); BloodBoons.apply(event.getPlayer()); ClassRewards.applySoon(event.getPlayer()); + BloodArtefacts.syncSoon(event.getPlayer()); BloodPerms.refresh(event.getPlayer()); FinalService.check(event.getPlayer()); } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index 40bb519..76c0bc5 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -32,6 +32,9 @@ public static void start(Plugin 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); AltarCandles.startTicking(plugin); @@ -74,6 +77,7 @@ static void tick(MagicTickContext context) { } if (context.everyMinutes(1)) { AltarCorruption.ambient(); + BloodPool.tick(); BloodNotes.tick(System.currentTimeMillis()); StolenClues.tick(); } 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..c54c5c0 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodPool.java @@ -0,0 +1,77 @@ +package net.tfminecraft.magic.blood; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +/** + * The altar's pool of blood and the blood resonance it holds up. The artefacts sunk in the pool give + * the resonance (their share of what the path's artefacts are worth); the pool caps it at its level. + * It stays full while the path goes on. Once the path is complete, the sunk artefacts drink it by the + * hour the character is played, and only people sacrificed on the altar fill it again. Resonance never + * sinks below the floor, so the lowest spells always work. + */ +public final class BloodPool { + + 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 character's blood resonance now; the floor without a path's class. */ + public static double resonance(BloodRecord record) { + if (record == null) { + return PoolConfig.floor(); + } + int places = BloodArtefacts.ids(record).size(); + int reached = record.getStage() == BloodStage.ASCENDED ? Integer.MAX_VALUE : record.getFinalsDone(); + double artefacts = places == 0 ? 0 + : PoolConfig.worth(record.getPath(), reached) * BloodArtefacts.sunk(record) / places; + return Math.max(PoolConfig.floor(), Math.min(artefacts, level(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"; + } + + /** Every minute: the pools of mages online whose path is complete drain; they hear it thin. */ + static void tick() { + double drain = PoolConfig.drainPerHour() / 60.0; + if (drain <= 0) { + return; + } + for (Player player : Bukkit.getOnlinePlayers()) { + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); + if (record == null || record.getStage() != BloodStage.ASCENDED || BloodArtefacts.sunk(record) == 0) { + continue; + } + double level = level(record); + if (level <= 0) { + continue; + } + int before = PoolConfig.band(resonance(record)); + record.setPool(Math.max(0, level - drain)); + BloodRecords.save(record); + if (record.getPool() <= 0) { + WhisperService.tell(player, "blood.pool.dry"); + } else if (PoolConfig.band(resonance(record)) < before) { + WhisperService.tell(player, "blood.pool.thinned"); + } + } + } + + /** 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/BloodRecord.java b/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java index 967de96..ad71495 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java @@ -31,6 +31,12 @@ public final class BloodRecord { 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<>(); + /** Ids of the artefacts sunk into the altar's pool of blood. */ + private List sunk = new ArrayList<>(); + /** 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), @@ -68,6 +74,15 @@ public void normalize() { if (finalTargets == null) { finalTargets = new ArrayList<>(); } + if (artefacts == null) { + artefacts = new ArrayList<>(); + } + if (sunk == null) { + sunk = new ArrayList<>(); + } + if (pool != null && (pool.isNaN() || pool < 0)) { + pool = 0.0; + } if (whisper < 0 || Double.isNaN(whisper)) { whisper = 0; } @@ -210,10 +225,28 @@ public void setPendingBacklash(boolean pendingBacklash) { this.pendingBacklash = pendingBacklash; } + public List getArtefacts() { + return artefacts; + } + + public List getSunk() { + return sunk; + } + + /** 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; + && sacrifices.isEmpty() && finalTargets.isEmpty() && !pendingBacklash + && artefacts.isEmpty() && sunk.isEmpty() && pool == null; } } 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/CharacterRiteService.java b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java index 841fb30..af96686 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java @@ -25,7 +25,8 @@ * 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. + * 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 { @@ -96,7 +97,8 @@ public static Bind bind(Player mage, Player victim) { return Bind.BUSY; } String victimAccount = victim.getUniqueId().toString(); - int rank = record.getStage() == BloodStage.HUNT ? BondService.rank(mage, victimCharacter) : 0; + 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; @@ -110,19 +112,23 @@ public static Bind bind(Player mage, Player victim) { } /** - * Whether this victim may be taken now: TOO_NEW, REPEAT (hunt only; a named final target must stay - * takeable), RESERVED (the closest are kept for the finals), WAITING, NOT_TARGET; null when they may. + * 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) { + 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; } @@ -192,7 +198,8 @@ private static boolean holds(Player mage, Player victim, Rite rite, Location top /** 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.HUNT && record.getStage() != BloodStage.FINAL + && record.getStage() != BloodStage.ASCENDED)) { return Bind.NOT_STAGE; } ItemStack knife = mage.getInventory().getItemInMainHand(); @@ -279,6 +286,8 @@ private static void complete(Player mage, Player victim, Rite rite, BloodAltar a } } 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); } diff --git a/src/main/java/net/tfminecraft/magic/blood/FinalService.java b/src/main/java/net/tfminecraft/magic/blood/FinalService.java index ad67cdc..5c5e478 100644 --- a/src/main/java/net/tfminecraft/magic/blood/FinalService.java +++ b/src/main/java/net/tfminecraft/magic/blood/FinalService.java @@ -43,6 +43,7 @@ public static void begin(Player mage, BloodRecord record) { 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()); @@ -117,6 +118,7 @@ static void completed(Player mage, BloodRecord record) { 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 { @@ -127,6 +129,7 @@ static void completed(Player mage, BloodRecord record) { BloodRecords.save(record); BloodBoons.apply(mage); ClassRewards.apply(mage); + BloodArtefacts.sync(mage); if (record.getStage() == BloodStage.FINAL) { name(mage, record); } 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..b16c8a8 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/PoolConfig.java @@ -0,0 +1,175 @@ +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 and how fast its artefacts + * drink it once the path is complete, 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 drainPerHour; + 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; + drainPerHour = 0.5; + 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)); + drainPerHour = Math.max(0, pool.getDouble("drain_per_hour", 0.5)); + 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; + } + + /** Blood the pool loses per hour its character is played, once the path is complete. */ + public static double drainPerHour() { + return drainPerHour; + } + + /** 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/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/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml index 6a6c48c..1bba83f 100644 --- a/src/main/resources/wildmagic/bloodmagic/messages.yml +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -39,8 +39,15 @@ blood: 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" - ascended_devotee: "{stone}The stone is as warm as a heart. Somewhere far below, your father sleeps, content" - ascended_true: "{stone}Nothing moves under your hand. Nothing needs to, anymore" + # 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" @@ -176,6 +183,23 @@ blood: 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" + sink: + sunk: "{stone}It sinks into the pool without a ripple, and the blood grows warmer" + all_sunk: "{stone}The last of them sinks. The pool is whole, and so are you" + not_yours: "#8c8c8c§oThe pool will not take it from you" + stale: "#8c8c8c§oIt crumbles to dried blood before it touches the pool" + already: "#8c8c8c§oThe pool already holds one like it. This one crumbles in your hand" + 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" + dry: "{stone}The pool is dry. Feed it, or lose what it gave you" + too_thin: "#8c8c8c§oYour blood runs too thin for that" + class: rewarded: "#8c8c8c§oSomething new stirs in your blood. Your skills have changed (/skills)" @@ -214,7 +238,7 @@ blood: admin: - usage: "#aaaaaaUsage: /magic blood list | info | stage | path | give | whisper [amount] | voice | target > | bonds | altar | reset " + 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." @@ -235,6 +259,8 @@ blood: path_set: "#55ff55{player} is now on path {path}." given: "#55ff55Gave {player} a bound {item}." + artefacts_given: "#55ff55Gave {player} {count} artefact(s) that are not in the pool yet." + pool: "#d10202{player} #aaaaaapool #ffffff{pool}/{capacity} #aaaaaa(draining #ffffff{draining}#aaaaaa), blood resonance #ffffff{resonance} #aaaaaa(tier #ffffff{tier}#aaaaaa), artefacts in the pool #ffffff{sunk}/{total}" reset: "#55ff55{player}'s blood path was cleared and any altar purified." diff --git a/src/main/resources/wildmagic/bloodmagic/settings.yml b/src/main/resources/wildmagic/bloodmagic/settings.yml index af8e76e..f9b9784 100644 --- a/src/main/resources/wildmagic/bloodmagic/settings.yml +++ b/src/main/resources/wildmagic/bloodmagic/settings.yml @@ -163,3 +163,39 @@ classes: permissions: devotee: [magic.blood.bloodsworn, magic.wild] "true": magic.blood.sanguinar + +# The pool of blood in the altar and the blood resonance it holds up: what keeps the blood classes' +# spells working. Placeholder values. +pool: + # The pool stays full until the path is complete. Then the artefacts sunk in it drink it, this much + # per hour the character is played (offline time never counts). + capacity: 100 + drain_per_hour: 0.5 + # 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 } + # Blood resonance with every one of the path's artefacts in the pool (each one gives an equal share), + # by final sacrifices done; an ascended mage has the last entry. The pool's level caps it. + 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] diff --git a/src/test/java/net/tfminecraft/magic/blood/AltarPedestalsTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarPedestalsTest.java index a35f925..d439017 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AltarPedestalsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AltarPedestalsTest.java @@ -198,6 +198,79 @@ void leftoversSurviveARestartAndBadIdsAreDropped() throws Exception { 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.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 withoutThePedestalTypeOrFurniturePluginNothingIsPlaced() { Block center = fx.pocket(0, 10, 0); 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..5d10f15 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/ArtefactsAndPoolTest.java @@ -0,0 +1,586 @@ +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 sinkAll() { + record.getSunk().clear(); + record.getSunk().addAll(BloodArtefacts.ids(record)); + } + + 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(0.5, PoolConfig.drainPerHour()); + 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.getSunk().add("devotee:0"); + assertEquals(8, BloodArtefacts.sync(mage)); + assertTrue(record.getSunk().isEmpty(), "The old path's artefacts leave the pool"); + record.getSunk().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(List.of("true:hunt:1"), record.getSunk()); + 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 onlyTheOwnersCurrentArtefactsSinkIntoTheirOwnPool() { + BloodListener listener = new BloodListener(); + altar(); + BloodArtefacts.sync(mage); + messages(mage); + List ids = BloodArtefacts.ids(record); + for (int i = 0; i < ids.size() - 1; i++) { + listener.onInteract(click(mage, BloodArtefacts.create(record, ids.get(i)))); + assertEquals("blood.artefact.sink.sunk", mage.nextMessage()); + assertTrue(mage.getInventory().getItemInMainHand().getType().isAir()); + } + assertEquals(7, BloodArtefacts.sunk(record)); + ItemStack two = BloodArtefacts.create(record, ids.get(0)); + two.setAmount(2); + listener.onInteract(click(mage, two)); + assertEquals("blood.artefact.sink.already", mage.nextMessage()); + assertEquals(1, mage.getInventory().getItemInMainHand().getAmount()); + listener.onInteract(click(mage, BloodArtefacts.create(record, ids.get(7)))); + assertEquals("blood.artefact.sink.all_sunk", mage.nextMessage()); + record.setPath(BloodPath.TRUE); + listener.onInteract(click(mage, BloodArtefacts.create(record, "devotee:1"))); + assertEquals("blood.artefact.sink.stale", mage.nextMessage(), "The old path's artefact"); + + PlayerMock thief = fx.player("thief"); + ItemStack stolen = BloodArtefacts.create(record, "devotee:1"); + listener.onInteract(click(thief, stolen)); + assertEquals("blood.artefact.sink.not_yours", thief.nextMessage()); + assertEquals(1, thief.getInventory().getItemInMainHand().getAmount()); + BloodRecord own = fx.record("thief", BloodStage.FINAL); + own.setPath(BloodPath.DEVOTEE); + listener.onInteract(click(thief, BloodArtefacts.create(own, "devotee:1"))); + assertEquals("blood.artefact.sink.not_yours", thief.nextMessage(), "Not the thief's altar"); + BloodAltar mine = BloodAltars.ofCharacter(mageId); + assertEquals(BloodArtefacts.Sink.NOT_YOURS, BloodArtefacts.sink(mage, mine, BloodArtefacts.create(own, "devotee:1"))); + PlayerMock bare = fx.server.addPlayer(); + assertEquals(BloodArtefacts.Sink.NOT_YOURS, BloodArtefacts.sink(bare, BloodAltars.ofCharacter(mageId), stolen)); + PlayerMock lost = fx.player("lost"); + assertEquals(BloodArtefacts.Sink.NOT_YOURS, BloodArtefacts.sink(lost, BloodAltars.ofCharacter(mageId), stolen)); + } + + @Test + void staffHandOutWhatIsNotInThePool() { + PlayerMock bare = fx.server.addPlayer(); + assertEquals(0, BloodArtefacts.regive(bare)); + sinkAll(); + record.getSunk().remove("devotee:3"); + assertEquals(1, BloodArtefacts.regive(mage)); + assertEquals("devotee:3", BloodArtefacts.id(mage.getInventory().getItem(0))); + } + + @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 resonanceIsTheSunkShareHeldDownByThePool() { + assertEquals(5, BloodPool.resonance(null)); + assertEquals(5, BloodPool.resonance(record), "Nothing sunk yet: the floor"); + sinkAll(); + assertEquals(20, BloodPool.resonance(record)); + record.getSunk().subList(0, 2).clear(); + assertEquals(15, BloodPool.resonance(record)); + record.setPath(BloodPath.NONE); + assertEquals(5, BloodPool.resonance(record)); + + record.setPath(BloodPath.TRUE); + record.setStage(BloodStage.ASCENDED); + sinkAll(); + assertEquals(95, BloodPool.resonance(record)); + assertEquals("full", BloodPool.state(record)); + record.setPool(120.0); + assertEquals(100, BloodPool.level(record)); + record.setPool(60.0); + assertEquals(60, BloodPool.resonance(record)); + assertEquals("half", BloodPool.state(record)); + record.setPool(10.0); + assertEquals("low", BloodPool.state(record)); + record.setPool(0.0); + assertEquals("dry", BloodPool.state(record)); + assertEquals(5, BloodPool.resonance(record)); + record.setStage(BloodStage.FINAL); + assertEquals(100, BloodPool.level(record), "Only a complete path drains its pool"); + } + + @Test + void thePoolDrainsOnceThePathIsCompleteAndSaysWhenPowerSlips() 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(); + assertNull(record.getPool(), "Nothing sunk, nothing drinks"); + sinkAll(); + record.setPool(90.004); + BloodPool.tick(); + assertTrue(record.getPool() < 90); + assertEquals("blood.pool.thinned", mage.nextMessage()); + BloodPool.tick(); + assertNull(mage.nextMessage(), "Still the same tier"); + record.setPool(0.005); + BloodPool.tick(); + assertEquals(0, record.getPool()); + assertEquals("blood.pool.dry", mage.nextMessage()); + BloodPool.tick(); + assertNull(mage.nextMessage()); + assertEquals(0, record.getPool()); + record.setStage(BloodStage.FINAL); + record.setPool(50.0); + BloodPool.tick(); + assertEquals(50, record.getPool()); + assertNull(other.nextMessage()); + assertNull(waiting.nextMessage()); + + Files.writeString(temp.resolve("d.yml"), "enabled: true\npool:\n drain_per_hour: 0\n"); + BloodConfig.load(temp.resolve("d.yml").toFile(), fx.logger); + record.setStage(BloodStage.ASCENDED); + BloodPool.tick(); + assertEquals(50, record.getPool()); + } + + @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()); + sinkAll(); + assertFalse(cast(gate, mage, "BLOODSWORN_BLOOD_BOIL")); + BloodConfig.setEnabled(false); + record.getSunk().clear(); + 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 { + MagicTickService.clearHandlers(); + BloodMagic.stop(); + } + } + + @Test + void recordsKeepTheirArtefactsAndPool() { + BloodRecord fresh = BloodRecord.create("c", "o"); + assertTrue(fresh.isEmpty()); + fresh.getArtefacts().add("devotee:0"); + assertFalse(fresh.isEmpty()); + fresh.getArtefacts().clear(); + fresh.getSunk().add("devotee:0"); + assertFalse(fresh.isEmpty()); + fresh.getSunk().clear(); + fresh.setPool(-4.0); + assertEquals(0, fresh.getPool()); + assertFalse(fresh.isEmpty()); + fresh.setPool(Double.NaN); + fresh.normalize(); + assertEquals(0, fresh.getPool()); + fresh.setPool(null); + assertNull(fresh.getPool()); + fresh.normalize(); + assertNull(fresh.getPool()); + fresh.setPool(3.0); + fresh.normalize(); + assertEquals(3, fresh.getPool()); + BloodRecord old = new com.google.gson.Gson().fromJson("{\"artefacts\":null,\"sunk\":null,\"pool\":-3}", + BloodRecord.class); + old.normalize(); + assertTrue(old.getArtefacts().isEmpty()); + assertTrue(old.getSunk().isEmpty()); + assertEquals(0, old.getPool()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java index 418f519..a02e55b 100644 --- a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java @@ -324,6 +324,27 @@ void finalsTakeTheNamedTargetThenTheNextUntilAscension() throws Exception { 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 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 From aab41b7091e048610e0ffa33fb94545ee7a89042 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:27:46 +0100 Subject: [PATCH 50/55] Charge blood artefacts in the pool and meditate on the altar for resonance Artefacts no longer sink into the pool for good. Right-clicking the altar with one sets it in the pool to charge: it sinks, turns in the blood while the stone's heart beats, and rises glowing back into its owner's hands to set on a pedestal again. Charging is free while the path goes on and costs pool blood once it is complete; an artefact still charging when its owner leaves (or the server stops) comes back at the next session. Blood resonance now comes from wild magic meditation: sitting on the altar among the charged artefacts, a drop of blood rises from one with each heartbeat and drifts to the mage, and catching it draws its charge, much on the beat and a little off it (which also costs the mage some health). Once the path is complete, resonance fades by a share of itself per hour played. The meditation, the charging and the altar room each have a private sound loop (vanilla placeholders until the music is chosen). Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/AltarPedestals.java | 35 ++ .../tfminecraft/magic/blood/AltarService.java | 8 +- .../magic/blood/BloodAmbience.java | 62 ++++ .../magic/blood/BloodArtefacts.java | 105 +++--- .../magic/blood/BloodCharging.java | 171 +++++++++ .../tfminecraft/magic/blood/BloodCommand.java | 2 +- .../tfminecraft/magic/blood/BloodConfig.java | 2 + .../magic/blood/BloodListener.java | 16 +- .../tfminecraft/magic/blood/BloodMagic.java | 6 + .../magic/blood/BloodMeditation.java | 329 +++++++++++++++++ .../tfminecraft/magic/blood/BloodPool.java | 61 +-- .../tfminecraft/magic/blood/BloodRecord.java | 55 ++- .../magic/blood/MeditationConfig.java | 201 ++++++++++ .../tfminecraft/magic/blood/PoolConfig.java | 26 +- .../resources/wildmagic/bloodmagic/fx.yml | 17 + .../wildmagic/bloodmagic/messages.yml | 29 +- .../wildmagic/bloodmagic/settings.yml | 48 ++- .../magic/blood/AltarPedestalsTest.java | 40 ++ .../magic/blood/ArtefactsAndPoolTest.java | 261 +++++++++---- .../magic/blood/MeditationTest.java | 349 ++++++++++++++++++ 20 files changed, 1642 insertions(+), 181 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodAmbience.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodCharging.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java create mode 100644 src/main/java/net/tfminecraft/magic/blood/MeditationConfig.java create mode 100644 src/test/java/net/tfminecraft/magic/blood/MeditationTest.java diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarPedestals.java b/src/main/java/net/tfminecraft/magic/blood/AltarPedestals.java index 45351c4..99a7412 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarPedestals.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarPedestals.java @@ -7,6 +7,7 @@ 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; @@ -28,6 +29,7 @@ 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 @@ -155,6 +157,39 @@ public static boolean offer(BloodAltar altar, ItemStack item, String replaced) { 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(); diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarService.java b/src/main/java/net/tfminecraft/magic/blood/AltarService.java index 5261e55..9c8a242 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarService.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarService.java @@ -219,8 +219,12 @@ public static void sat(Player player, Entity seat) { } String character = currentCharacter(player); boolean owner = character != null && character.equalsIgnoreCase(altar.getOwnerCharacter()); - WhisperService.tell(player, owner ? "blood.altar.sit." + stageKey(BloodRecords.get(character)) - : "blood.altar.sit_stranger"); + 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/BloodAmbience.java b/src/main/java/net/tfminecraft/magic/blood/BloodAmbience.java new file mode 100644 index 0000000..a7d9ef2 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodAmbience.java @@ -0,0 +1,62 @@ +package net.tfminecraft.magic.blood; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +/** + * The altar room's ambience (meditation.sounds.room): its owner alone hears it, looped, while inside + * the room and not meditating there (the meditation has its own music). It stops when they leave. + */ +public final class BloodAmbience { + + /** When each listener's loop plays next (epoch millis). */ + private static final Map NEXT = new HashMap<>(); + + private BloodAmbience() {} + + /** Every second: starts, loops or stops each owner's room ambience. */ + static void tick(long now) { + MeditationConfig.Cue room = MeditationConfig.room(); + if (!room.isSet()) { + return; + } + for (Player player : Bukkit.getOnlinePlayers()) { + UUID id = player.getUniqueId(); + if (!inOwnRoom(player) || BloodMeditation.isMeditating(id)) { + if (NEXT.remove(id) != null) { + room.stop(player); + } + continue; + } + Long next = NEXT.get(id); + if (next == null || now >= next) { + room.play(player); + NEXT.put(id, room.seconds() > 0 ? now + room.seconds() * 1000L : Long.MAX_VALUE); + } + } + } + + private static boolean inOwnRoom(Player player) { + BloodAltar altar = BloodAltars.ofCharacter(AltarService.currentCharacter(player)); + return altar != null && altar == AltarRoom.lockedBy(player.getLocation().getBlock()); + } + + /** The meditation's music takes over: the room's ambience stops and starts again afterwards. */ + static void pause(Player player) { + if (NEXT.remove(player.getUniqueId()) != null) { + MeditationConfig.room().stop(player); + } + } + + static void forget(UUID player) { + NEXT.remove(player); + } + + static void clear() { + NEXT.clear(); + } +} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodArtefacts.java b/src/main/java/net/tfminecraft/magic/blood/BloodArtefacts.java index 893f88f..f67d2cf 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodArtefacts.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodArtefacts.java @@ -17,25 +17,18 @@ import net.tfminecraft.magic.util.MagicText; /** - * The path's artefacts: bound to one character, 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), and only - * of use once sunk into the altar's pool of blood. Each holds a place; its id names the place and what - * fills it (devotee:3, true:hunt:3, true:final:3), so an artefact whose place was filled again since - * dissolves instead of sinking. They are not Magic artifacts: no aura, no wear, no use in mage gear. + * 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"; - /** What sinking an artefact into the pool came to. */ - public enum Sink { - SUNK, - ALL_SUNK, - NOT_YOURS, - STALE, - ALREADY - } - private BloodArtefacts() {} static NamespacedKey idKey() { @@ -85,11 +78,18 @@ static ArtefactConfig.Piece piece(String id) { } } - /** How many of the character's artefacts are in the pool now. */ - static int sunk(BloodRecord record) { + /** 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.getSunk().contains(id)) { + if (record.getCharge(id) > 0) { count++; } } @@ -127,6 +127,14 @@ static ItemStack create(BloodRecord record, String id) { 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; } @@ -154,8 +162,8 @@ static String id(ItemStack item) { /** * 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 follows an artefact in the pool takes that one's place there: the old one - * stops counting. Returns how many were handed out. + * 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); @@ -181,7 +189,8 @@ public static int sync(Player player) { } record.getArtefacts().clear(); record.getArtefacts().addAll(wanted); - record.getSunk().retainAll(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"); @@ -190,49 +199,53 @@ public static int sync(Player player) { return given; } - /** Runs {@link #sync} on the next tick, if the player is still online then. */ + /** + * 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); } }); } /** - * The player sinks the held artefact into their own altar's pool. Anything but NOT_YOURS uses the - * item up: an artefact whose place was filled anew, or a copy of one already in the pool, crumbles. + * Gives back the artefacts that finished charging while their owner was away (or the server + * stopped), and returns how many. */ - public static Sink sink(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 Sink.NOT_YOURS; - } - String id = id(held); - held.setAmount(held.getAmount() - 1); - if (!ids(record).contains(id)) { - return Sink.STALE; + public static int deliverPending(Player player) { + BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); + if (record == null || record.getPending().isEmpty()) { + return 0; } - if (record.getSunk().contains(id)) { - return Sink.ALREADY; + 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++; + } } - record.getSunk().add(id); BloodRecords.save(record); - return sunk(record) >= ids(record).size() ? Sink.ALL_SUNK : Sink.SUNK; + if (given > 0) { + WhisperService.tell(player, "blood.artefact.returned"); + } + return given; } - /** Staff: gives the player every one of their current artefacts that is not in the pool. */ + /** 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)); - int given = 0; - for (String id : ids(record)) { - if (!record.getSunk().contains(id)) { - WhisperService.give(player, create(record, id)); - given++; - } + List ids = ids(record); + for (String id : ids) { + WhisperService.give(player, create(record, id)); } - return given; + return ids.size(); } } 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..c2cd91e --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodCharging.java @@ -0,0 +1,171 @@ +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); + Location at = charging.base().clone().add(0, height, 0); + at.setYaw((float) (elapsed * 6 % 360)); + charging.display().teleport(at); + if (elapsed % 4 == 0) { + at.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/BloodCommand.java b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java index dd0a3ea..4ded977 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodCommand.java @@ -111,7 +111,7 @@ private static void poolLine(CommandSender sender, Player player, BloodRecord re "draining", String.valueOf(record.getStage() == BloodStage.ASCENDED), "resonance", String.format(Locale.ROOT, "%.1f", resonance), "tier", String.valueOf(PoolConfig.band(resonance)), - "sunk", String.valueOf(BloodArtefacts.sunk(record)), + "charged", String.valueOf(BloodArtefacts.charged(record)), "total", String.valueOf(BloodArtefacts.ids(record).size()))); } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java index f02bfdf..e8a4f03 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodConfig.java @@ -143,6 +143,7 @@ public static boolean load(File source, Logger log) { 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; @@ -208,6 +209,7 @@ public static void reset() { ChoiceConfig.reset(); HuntConfig.reset(); PoolConfig.reset(); + MeditationConfig.reset(); ArtefactConfig.reset(); BloodFx.reset(); } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index 7e88f5f..cdd6bba 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -29,6 +29,7 @@ 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; @@ -79,11 +80,9 @@ public void onInteract(PlayerInteractEvent event) { return; } if (BloodArtefacts.is(held)) { - BloodArtefacts.Sink sunk = BloodArtefacts.sink(player, altar, held); + BloodCharging.Start start = BloodCharging.start(player, altar, held); player.getInventory().setItemInMainHand(held.getAmount() > 0 ? held : null); - WhisperService.tell(player, "blood.artefact.sink." + sunk.name().toLowerCase(Locale.ROOT)); - BloodFx.play(sunk == BloodArtefacts.Sink.NOT_YOURS ? "altar_touch_stranger" : "artefact_sunk", - altar.center(), player); + WhisperService.tell(player, "blood.artefact.charge." + start.name().toLowerCase(Locale.ROOT)); return; } AltarService.touch(player, altar); @@ -161,6 +160,12 @@ public void onFurnitureInteract(FurnitureInteractEvent event) { } } + /** 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) { @@ -377,6 +382,8 @@ public void onQuit(PlayerQuitEvent event) { QuestionService.forget(event.getPlayer().getUniqueId()); CarryService.drop(event.getPlayer().getUniqueId()); BloodSpellGate.forget(event.getPlayer().getUniqueId()); + BloodMeditation.stop(event.getPlayer().getUniqueId()); + BloodAmbience.forget(event.getPlayer().getUniqueId()); BloodPerms.clear(event.getPlayer()); } @@ -401,6 +408,7 @@ public void onSessionEnd(CharacterSessionEndEvent event) { BloodBoons.clear(event.getPlayer()); BloodPerms.clear(event.getPlayer()); AnimalRiteService.releaseFor(event.getPlayer().getUniqueId()); + BloodMeditation.stop(event.getPlayer().getUniqueId()); } private static boolean touchesAltar(List blocks) { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index 76c0bc5..f768ed9 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -37,6 +37,8 @@ public static void start(Plugin plugin) { } MagicTickService.register(BloodMagic::tick); CarryService.startTicking(plugin); + BloodCharging.startTicking(plugin); + BloodMeditation.startTicking(plugin); AltarCandles.startTicking(plugin); plugin.getServer().getScheduler().runTask(plugin, BloodMagic::settle); } @@ -63,6 +65,7 @@ static void tick(MagicTickContext context) { ambient(); } WhisperService.tick(context); + BloodAmbience.tick(System.currentTimeMillis()); AnimalRiteService.tick(); QuestionService.tick(); CharacterRiteService.tick(); @@ -119,6 +122,9 @@ public static void stop() { } BloodPerms.clearAll(); CarryService.clear(); + BloodMeditation.clear(); + BloodCharging.clear(); + BloodAmbience.clear(); CharacterRiteService.clear(); BondService.clear(); AnimalRiteService.releaseAll(); 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..b159848 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java @@ -0,0 +1,329 @@ +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. 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); + BloodAmbience.pause(player); + 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(1); + }); + 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; + } + 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); + BloodRecords.save(record); + 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 (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/BloodPool.java b/src/main/java/net/tfminecraft/magic/blood/BloodPool.java index c54c5c0..25fcbbd 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodPool.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodPool.java @@ -4,11 +4,12 @@ import org.bukkit.entity.Player; /** - * The altar's pool of blood and the blood resonance it holds up. The artefacts sunk in the pool give - * the resonance (their share of what the path's artefacts are worth); the pool caps it at its level. - * It stays full while the path goes on. Once the path is complete, the sunk artefacts drink it by the - * hour the character is played, and only people sacrificed on the altar fill it again. Resonance never - * sinks below the floor, so the lowest spells always work. + * 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 { @@ -20,16 +21,17 @@ static double level(BloodRecord record) { return pool != null ? Math.min(pool, PoolConfig.capacity()) : PoolConfig.capacity(); } - /** The character's blood resonance now; the floor without a path's class. */ + /** 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(); } - int places = BloodArtefacts.ids(record).size(); - int reached = record.getStage() == BloodStage.ASCENDED ? Integer.MAX_VALUE : record.getFinalsDone(); - double artefacts = places == 0 ? 0 - : PoolConfig.worth(record.getPath(), reached) * BloodArtefacts.sunk(record) / places; - return Math.max(PoolConfig.floor(), Math.min(artefacts, level(record))); + return Math.max(PoolConfig.floor(), Math.min(record.getResonance(), cap(record))); } /** How the pool looks to its owner: full, half, low or dry. */ @@ -42,27 +44,40 @@ static String state(BloodRecord record) { return level >= capacity * 0.75 ? "full" : level >= capacity * 0.25 ? "half" : "low"; } - /** Every minute: the pools of mages online whose path is complete drain; they hear it thin. */ + /** + * 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 drain = PoolConfig.drainPerHour() / 60.0; - if (drain <= 0) { + 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 || BloodArtefacts.sunk(record) == 0) { - continue; - } - double level = level(record); - if (level <= 0) { + if (record == null || record.getStage() != BloodStage.ASCENDED + || record.getResonance() <= PoolConfig.floor()) { continue; } int before = PoolConfig.band(resonance(record)); - record.setPool(Math.max(0, level - drain)); + record.setResonance(Math.max(PoolConfig.floor(), record.getResonance() * (1 - share))); BloodRecords.save(record); - if (record.getPool() <= 0) { - WhisperService.tell(player, "blood.pool.dry"); - } else if (PoolConfig.band(resonance(record)) < before) { + if (PoolConfig.band(resonance(record)) < before) { WhisperService.tell(player, "blood.pool.thinned"); } } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java b/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java index ad71495..fb5b624 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodRecord.java @@ -1,8 +1,11 @@ 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 { @@ -33,8 +36,12 @@ public final class BloodRecord { private boolean pendingBacklash; /** The path's artefacts handed out so far, one id per place (see {@link BloodArtefacts}). */ private List artefacts = new ArrayList<>(); - /** Ids of the artefacts sunk into the altar's pool of blood. */ - private List sunk = 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; @@ -77,8 +84,15 @@ public void normalize() { if (artefacts == null) { artefacts = new ArrayList<>(); } - if (sunk == null) { - sunk = 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; @@ -229,8 +243,34 @@ public List getArtefacts() { return artefacts; } - public List getSunk() { - return sunk; + /** 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). */ @@ -247,6 +287,7 @@ 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() && sunk.isEmpty() && pool == null; + && artefacts.isEmpty() && charges.isEmpty() && pending.isEmpty() && resonance <= 0 + && pool == null; } } 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..dbd6cff --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/blood/MeditationConfig.java @@ -0,0 +1,201 @@ +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 room; + 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; + room = 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")); + room = cue(meditation.getConfigurationSection("sounds.room")); + 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; + } + + /** Ambience the altar's owner hears inside its room, looped. */ + public static Cue room() { + return room; + } + + /** 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/PoolConfig.java b/src/main/java/net/tfminecraft/magic/blood/PoolConfig.java index b16c8a8..f5da72e 100644 --- a/src/main/java/net/tfminecraft/magic/blood/PoolConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/PoolConfig.java @@ -13,16 +13,17 @@ import org.bukkit.configuration.ConfigurationSection; /** - * The pool of blood (settings.yml pool:): how much the altar's pool holds and how fast its artefacts - * drink it once the path is complete, what sacrifices pour back, what the path's artefacts are worth, - * and the blood resonance each spell tier needs. + * 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 drainPerHour; + private static double decayPercent; + private static double bloodPerPoint; private static double floor; private static double strangerRefill; private static double defaultRefill; @@ -39,7 +40,8 @@ private PoolConfig() {} static void reset() { capacity = 100; - drainPerHour = 0.5; + decayPercent = 1; + bloodPerPoint = 2; floor = 5; strangerRefill = 40; defaultRefill = 40; @@ -55,7 +57,8 @@ static void load(ConfigurationSection pool, Logger log) { return; } capacity = Math.max(1, pool.getDouble("capacity", 100)); - drainPerHour = Math.max(0, pool.getDouble("drain_per_hour", 0.5)); + 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)); @@ -119,9 +122,14 @@ public static double capacity() { return capacity; } - /** Blood the pool loses per hour its character is played, once the path is complete. */ - public static double drainPerHour() { - return drainPerHour; + /** 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. */ diff --git a/src/main/resources/wildmagic/bloodmagic/fx.yml b/src/main/resources/wildmagic/bloodmagic/fx.yml index 4d8fab0..f5e61fc 100644 --- a/src/main/resources/wildmagic/bloodmagic/fx.yml +++ b/src/main/resources/wildmagic/bloodmagic/fx.yml @@ -266,6 +266,23 @@ fx: - { 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 } diff --git a/src/main/resources/wildmagic/bloodmagic/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml index 1bba83f..8799489 100644 --- a/src/main/resources/wildmagic/bloodmagic/messages.yml +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -188,16 +188,31 @@ blood: 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" - sink: - sunk: "{stone}It sinks into the pool without a ripple, and the blood grows warmer" - all_sunk: "{stone}The last of them sinks. The pool is whole, and so are 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" - already: "#8c8c8c§oThe pool already holds one like it. This one crumbles in your hand" + 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" - dry: "{stone}The pool is dry. Feed it, or lose what it gave you" too_thin: "#8c8c8c§oYour blood runs too thin for that" class: @@ -259,8 +274,8 @@ blood: path_set: "#55ff55{player} is now on path {path}." given: "#55ff55Gave {player} a bound {item}." - artefacts_given: "#55ff55Gave {player} {count} artefact(s) that are not in the pool yet." - pool: "#d10202{player} #aaaaaapool #ffffff{pool}/{capacity} #aaaaaa(draining #ffffff{draining}#aaaaaa), blood resonance #ffffff{resonance} #aaaaaa(tier #ffffff{tier}#aaaaaa), artefacts in the pool #ffffff{sunk}/{total}" + 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." diff --git a/src/main/resources/wildmagic/bloodmagic/settings.yml b/src/main/resources/wildmagic/bloodmagic/settings.yml index f9b9784..c1459c5 100644 --- a/src/main/resources/wildmagic/bloodmagic/settings.yml +++ b/src/main/resources/wildmagic/bloodmagic/settings.yml @@ -164,13 +164,17 @@ permissions: devotee: [magic.blood.bloodsworn, magic.wild] "true": magic.blood.sanguinar -# The pool of blood in the altar and the blood resonance it holds up: what keeps the blood classes' -# spells working. Placeholder values. +# 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 until the path is complete. Then the artefacts sunk in it drink it, this much - # per hour the character is played (offline time never counts). + # 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 - drain_per_hour: 0.5 + 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) @@ -183,8 +187,8 @@ pool: ranks: - { up_to: 10, amount: 100 } - { up_to: 25, amount: 60 } - # Blood resonance with every one of the path's artefacts in the pool (each one gives an equal share), - # by final sacrifices done; an ascended mage has the last entry. The pool's level caps it. + # 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 } @@ -199,3 +203,33 @@ pool: 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, room while the altar's owner is + # inside its room, charge plays as an artefact goes into the pool. + sounds: + heartbeat: { sound: entity.warden.heartbeat, volume: 0.9, pitch: 0.7 } + music: { sound: ambient.crimson_forest.loop, volume: 0.6, pitch: 0.6, seconds: 30 } + room: { sound: ambient.soul_sand_valley.loop, volume: 0.25, pitch: 0.5, seconds: 30 } + charge: { sound: block.beacon.power_select, volume: 0.6, pitch: 0.5 } diff --git a/src/test/java/net/tfminecraft/magic/blood/AltarPedestalsTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarPedestalsTest.java index d439017..46667ba 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AltarPedestalsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AltarPedestalsTest.java @@ -205,6 +205,7 @@ Furniture pedestal(BloodAltar altar, FurnitureType type, boolean loaded, 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)); @@ -271,6 +272,45 @@ void artefactsGoInPlaceOfTheOneTheyFollowOrOnTheFirstFreePedestal() { 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); diff --git a/src/test/java/net/tfminecraft/magic/blood/ArtefactsAndPoolTest.java b/src/test/java/net/tfminecraft/magic/blood/ArtefactsAndPoolTest.java index 5d10f15..e4b914b 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ArtefactsAndPoolTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ArtefactsAndPoolTest.java @@ -82,9 +82,21 @@ BloodAltar altar() { return altar; } - void sinkAll() { - record.getSunk().clear(); - record.getSunk().addAll(BloodArtefacts.ids(record)); + 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) { @@ -95,7 +107,8 @@ PlayerInteractEvent click(PlayerMock player, ItemStack held) { @Test void theDefaultsSetOutThePoolTiersAndArtefacts() { assertEquals(100, PoolConfig.capacity()); - assertEquals(0.5, PoolConfig.drainPerHour()); + assertEquals(1, PoolConfig.decayPercent()); + assertEquals(2, PoolConfig.bloodPerPoint()); assertEquals(5, PoolConfig.floor()); assertEquals(40, PoolConfig.refill(0)); assertEquals(100, PoolConfig.refill(3)); @@ -251,15 +264,22 @@ void syncHandsOutWhatIsMissingAndFinalsReplaceTheirPlace() { altar(); record.setPath(BloodPath.TRUE); - record.getSunk().add("devotee:0"); + record.setCharge("devotee:0", 2); + record.getPending().add("devotee:0"); assertEquals(8, BloodArtefacts.sync(mage)); - assertTrue(record.getSunk().isEmpty(), "The old path's artefacts leave the pool"); - record.getSunk().addAll(List.of("true:hunt:0", "true:hunt:1")); + 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(List.of("true:hunt:1"), record.getSunk()); + 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()); @@ -288,54 +308,118 @@ void syncHandsOutWhatIsMissingAndFinalsReplaceTheirPlace() { } @Test - void onlyTheOwnersCurrentArtefactsSinkIntoTheirOwnPool() { + void artefactsChargeInThePoolAndRiseGlowing() { BloodListener listener = new BloodListener(); altar(); - BloodArtefacts.sync(mage); - messages(mage); - List ids = BloodArtefacts.ids(record); - for (int i = 0; i < ids.size() - 1; i++) { - listener.onInteract(click(mage, BloodArtefacts.create(record, ids.get(i)))); - assertEquals("blood.artefact.sink.sunk", mage.nextMessage()); - assertTrue(mage.getInventory().getItemInMainHand().getType().isAir()); - } - assertEquals(7, BloodArtefacts.sunk(record)); - ItemStack two = BloodArtefacts.create(record, ids.get(0)); - two.setAmount(2); - listener.onInteract(click(mage, two)); - assertEquals("blood.artefact.sink.already", mage.nextMessage()); + 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()); - listener.onInteract(click(mage, BloodArtefacts.create(record, ids.get(7)))); - assertEquals("blood.artefact.sink.all_sunk", mage.nextMessage()); - record.setPath(BloodPath.TRUE); + 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.sink.stale", mage.nextMessage(), "The old path's artefact"); + 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:1"); + ItemStack stolen = BloodArtefacts.create(record, "devotee:6"); listener.onInteract(click(thief, stolen)); - assertEquals("blood.artefact.sink.not_yours", thief.nextMessage()); + 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); - listener.onInteract(click(thief, BloodArtefacts.create(own, "devotee:1"))); - assertEquals("blood.artefact.sink.not_yours", thief.nextMessage(), "Not the thief's altar"); - BloodAltar mine = BloodAltars.ofCharacter(mageId); - assertEquals(BloodArtefacts.Sink.NOT_YOURS, BloodArtefacts.sink(mage, mine, BloodArtefacts.create(own, "devotee:1"))); - PlayerMock bare = fx.server.addPlayer(); - assertEquals(BloodArtefacts.Sink.NOT_YOURS, BloodArtefacts.sink(bare, BloodAltars.ofCharacter(mageId), stolen)); - PlayerMock lost = fx.player("lost"); - assertEquals(BloodArtefacts.Sink.NOT_YOURS, BloodArtefacts.sink(lost, BloodAltars.ofCharacter(mageId), stolen)); + 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 staffHandOutWhatIsNotInThePool() { + void staffHandOutCopies() { PlayerMock bare = fx.server.addPlayer(); assertEquals(0, BloodArtefacts.regive(bare)); - sinkAll(); - record.getSunk().remove("devotee:3"); - assertEquals(1, BloodArtefacts.regive(mage)); - assertEquals("devotee:3", BloodArtefacts.id(mage.getInventory().getItem(0))); + 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 @@ -378,70 +462,72 @@ void pedestalsAndDroppedArtefactsAnswerOnlyToTheirOwner() { } @Test - void resonanceIsTheSunkShareHeldDownByThePool() { + void resonanceIsDrawnUpToWhatTheArtefactsAreWorth() throws Exception { assertEquals(5, BloodPool.resonance(null)); - assertEquals(5, BloodPool.resonance(record), "Nothing sunk yet: the floor"); - sinkAll(); + 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.getSunk().subList(0, 2).clear(); - assertEquals(15, 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); - sinkAll(); + 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(60, BloodPool.resonance(record)); assertEquals("half", BloodPool.state(record)); record.setPool(10.0); assertEquals("low", BloodPool.state(record)); - record.setPool(0.0); + assertEquals(5, BloodPool.pay(record, 10), "The pool pays for what it holds"); + assertEquals(0, record.getPool()); assertEquals("dry", BloodPool.state(record)); - assertEquals(5, BloodPool.resonance(record)); record.setStage(BloodStage.FINAL); - assertEquals(100, BloodPool.level(record), "Only a complete path drains its pool"); + 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 thePoolDrainsOnceThePathIsCompleteAndSaysWhenPowerSlips() throws Exception { + 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(); - assertNull(record.getPool(), "Nothing sunk, nothing drinks"); - sinkAll(); - record.setPool(90.004); - BloodPool.tick(); - assertTrue(record.getPool() < 90); - assertEquals("blood.pool.thinned", mage.nextMessage()); + 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.setPool(0.005); + record.setResonance(90.005); BloodPool.tick(); - assertEquals(0, record.getPool()); - assertEquals("blood.pool.dry", mage.nextMessage()); + assertTrue(record.getResonance() < 90); + assertEquals("blood.pool.thinned", mage.nextMessage()); + record.setResonance(5.0001); BloodPool.tick(); - assertNull(mage.nextMessage()); - assertEquals(0, record.getPool()); + assertEquals(5, record.getResonance(), "Never below the floor"); record.setStage(BloodStage.FINAL); - record.setPool(50.0); + record.setResonance(50); BloodPool.tick(); - assertEquals(50, record.getPool()); + assertEquals(50, record.getResonance()); assertNull(other.nextMessage()); assertNull(waiting.nextMessage()); - Files.writeString(temp.resolve("d.yml"), "enabled: true\npool:\n drain_per_hour: 0\n"); + 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.getPool()); + assertEquals(50, record.getResonance()); } @Test @@ -464,10 +550,10 @@ void spellsAboveTheCastersBloodResonanceAreRefused() { now.addAndGet(5000); assertTrue(cast(gate, mage, "BLOODSWORN_BLOOD_BOIL")); assertEquals("blood.pool.too_thin", mage.nextMessage()); - sinkAll(); + fill(); assertFalse(cast(gate, mage, "BLOODSWORN_BLOOD_BOIL")); BloodConfig.setEnabled(false); - record.getSunk().clear(); + record.setResonance(0); assertFalse(cast(gate, mage, "BLOODSWORN_BLOOD_BOIL")); BloodSpellGate.forget(mage.getUniqueId()); @@ -548,27 +634,40 @@ void withMythicLibTheSpellGateListensAndTheMinuteTickDrains() { 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 recordsKeepTheirArtefactsAndPool() { + void recordsKeepTheirArtefactsChargesAndPool() { BloodRecord fresh = BloodRecord.create("c", "o"); assertTrue(fresh.isEmpty()); fresh.getArtefacts().add("devotee:0"); assertFalse(fresh.isEmpty()); fresh.getArtefacts().clear(); - fresh.getSunk().add("devotee:0"); + fresh.setCharge("devotee:0", 1); assertFalse(fresh.isEmpty()); - fresh.getSunk().clear(); + 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(); @@ -576,11 +675,23 @@ void recordsKeepTheirArtefactsAndPool() { fresh.setPool(3.0); fresh.normalize(); assertEquals(3, fresh.getPool()); - BloodRecord old = new com.google.gson.Gson().fromJson("{\"artefacts\":null,\"sunk\":null,\"pool\":-3}", - BloodRecord.class); + 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.getSunk().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/MeditationTest.java b/src/test/java/net/tfminecraft/magic/blood/MeditationTest.java new file mode 100644 index 0000000..3fc7c47 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/blood/MeditationTest.java @@ -0,0 +1,349 @@ +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(); + BloodAmbience.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("ambient.crimson_forest.loop"); + 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("ambient.crimson_forest.loop"); + 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 theRoomHumsForItsOwnerOnlyAndHushesForMeditation() throws Exception { + long now = 1_000_000; + BloodAmbience.tick(now); + mage.assertSoundHeard("ambient.soul_sand_valley.loop"); + mage.clearSounds(); + BloodAmbience.tick(now + 1000); + assertTrue(mage.getHeardSounds().isEmpty(), "Still playing"); + BloodAmbience.tick(now + 31_000); + mage.assertSoundHeard("ambient.soul_sand_valley.loop"); + PlayerMock stranger = fx.player("stranger"); + BloodAmbience.tick(now + 32_000); + assertTrue(stranger.getHeardSounds().isEmpty()); + mage.teleport(new Location(fx.world, 40.5, 11, 0.5)); + BloodAmbience.tick(now + 33_000); + BloodAmbience.tick(now + 34_000); + mage.teleport(new Location(fx.world, 0.5, 11, 0.5)); + BloodAmbience.tick(now + 35_000); + sit(mage); + place(record, "devotee:0", 2.5); + BloodMeditation.start(mage, altar); + BloodAmbience.tick(now + 36_000); + BloodAmbience.pause(mage); + BloodMeditation.clear(); + BloodAmbience.tick(now + 37_000); + BloodAmbience.forget(mage.getUniqueId()); + + Files.writeString(temp.resolve("r.yml"), "enabled: true\nmeditation: { sounds: { room: { sound: r.s } } }\n"); + BloodConfig.load(temp.resolve("r.yml").toFile(), fx.logger); + mage.clearSounds(); + BloodAmbience.tick(now + 40_000); + BloodAmbience.tick(now + 400_000); + assertEquals(1, mage.getHeardSounds().stream().filter(sound -> sound.getSound().equals("r.s")).count(), + "No length: it plays once"); + Files.writeString(temp.resolve("q.yml"), "enabled: true\n"); + BloodConfig.load(temp.resolve("q.yml").toFile(), fx.logger); + mage.clearSounds(); + BloodAmbience.tick(now + 500_000); + assertTrue(mage.getHeardSounds().isEmpty()); + MeditationConfig.Cue.NONE.play(mage); + MeditationConfig.Cue.NONE.stop(mage); + } + + @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")); + } +} From debdd7ba80fde37a21de78b35e6cb4451c84227f Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:23:01 +0100 Subject: [PATCH 51/55] Use the chant for blood meditation and charging; drop the room ambience fran picked the catacomb chant: tfmc_blood:meditate.chant loops every 24 s while meditating and tfmc_blood:rite.charge plays as an artefact goes into the pool (both in the ServerAssets blood pack). The altar room gets no ambience, so BloodAmbience goes. Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/BloodAmbience.java | 62 ------------------- .../magic/blood/BloodListener.java | 1 - .../tfminecraft/magic/blood/BloodMagic.java | 2 - .../magic/blood/BloodMeditation.java | 1 - .../magic/blood/MeditationConfig.java | 8 --- .../wildmagic/bloodmagic/settings.yml | 9 ++- .../magic/blood/MeditationTest.java | 45 ++------------ 7 files changed, 8 insertions(+), 120 deletions(-) delete mode 100644 src/main/java/net/tfminecraft/magic/blood/BloodAmbience.java diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodAmbience.java b/src/main/java/net/tfminecraft/magic/blood/BloodAmbience.java deleted file mode 100644 index a7d9ef2..0000000 --- a/src/main/java/net/tfminecraft/magic/blood/BloodAmbience.java +++ /dev/null @@ -1,62 +0,0 @@ -package net.tfminecraft.magic.blood; - -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; - -/** - * The altar room's ambience (meditation.sounds.room): its owner alone hears it, looped, while inside - * the room and not meditating there (the meditation has its own music). It stops when they leave. - */ -public final class BloodAmbience { - - /** When each listener's loop plays next (epoch millis). */ - private static final Map NEXT = new HashMap<>(); - - private BloodAmbience() {} - - /** Every second: starts, loops or stops each owner's room ambience. */ - static void tick(long now) { - MeditationConfig.Cue room = MeditationConfig.room(); - if (!room.isSet()) { - return; - } - for (Player player : Bukkit.getOnlinePlayers()) { - UUID id = player.getUniqueId(); - if (!inOwnRoom(player) || BloodMeditation.isMeditating(id)) { - if (NEXT.remove(id) != null) { - room.stop(player); - } - continue; - } - Long next = NEXT.get(id); - if (next == null || now >= next) { - room.play(player); - NEXT.put(id, room.seconds() > 0 ? now + room.seconds() * 1000L : Long.MAX_VALUE); - } - } - } - - private static boolean inOwnRoom(Player player) { - BloodAltar altar = BloodAltars.ofCharacter(AltarService.currentCharacter(player)); - return altar != null && altar == AltarRoom.lockedBy(player.getLocation().getBlock()); - } - - /** The meditation's music takes over: the room's ambience stops and starts again afterwards. */ - static void pause(Player player) { - if (NEXT.remove(player.getUniqueId()) != null) { - MeditationConfig.room().stop(player); - } - } - - static void forget(UUID player) { - NEXT.remove(player); - } - - static void clear() { - NEXT.clear(); - } -} diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java index cdd6bba..fd59f17 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodListener.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodListener.java @@ -383,7 +383,6 @@ public void onQuit(PlayerQuitEvent event) { CarryService.drop(event.getPlayer().getUniqueId()); BloodSpellGate.forget(event.getPlayer().getUniqueId()); BloodMeditation.stop(event.getPlayer().getUniqueId()); - BloodAmbience.forget(event.getPlayer().getUniqueId()); BloodPerms.clear(event.getPlayer()); } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index f768ed9..2544d66 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -65,7 +65,6 @@ static void tick(MagicTickContext context) { ambient(); } WhisperService.tick(context); - BloodAmbience.tick(System.currentTimeMillis()); AnimalRiteService.tick(); QuestionService.tick(); CharacterRiteService.tick(); @@ -124,7 +123,6 @@ public static void stop() { CarryService.clear(); BloodMeditation.clear(); BloodCharging.clear(); - BloodAmbience.clear(); CharacterRiteService.clear(); BondService.clear(); AnimalRiteService.releaseAll(); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java b/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java index b159848..3368125 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java @@ -105,7 +105,6 @@ public static Begin start(Player player, BloodAltar altar) { } Session session = new Session(player.getUniqueId(), record.getCharacterId(), altar.getId(), ticks); SESSIONS.put(player.getUniqueId(), session); - BloodAmbience.pause(player); playMusic(player, session); return Begin.STARTED; } diff --git a/src/main/java/net/tfminecraft/magic/blood/MeditationConfig.java b/src/main/java/net/tfminecraft/magic/blood/MeditationConfig.java index dbd6cff..9b5d232 100644 --- a/src/main/java/net/tfminecraft/magic/blood/MeditationConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/MeditationConfig.java @@ -52,7 +52,6 @@ void stop(Player player) { private static int chargeTicks; private static Cue heartbeat; private static Cue music; - private static Cue room; private static Cue charge; static { @@ -78,7 +77,6 @@ static void reset() { chargeTicks = 160; heartbeat = Cue.NONE; music = Cue.NONE; - room = Cue.NONE; charge = Cue.NONE; } @@ -103,7 +101,6 @@ static void load(ConfigurationSection meditation) { chargeTicks = Math.max(20, meditation.getInt("charge_ticks", 160)); heartbeat = cue(meditation.getConfigurationSection("sounds.heartbeat")); music = cue(meditation.getConfigurationSection("sounds.music")); - room = cue(meditation.getConfigurationSection("sounds.room")); charge = cue(meditation.getConfigurationSection("sounds.charge")); } @@ -189,11 +186,6 @@ public static Cue music() { return music; } - /** Ambience the altar's owner hears inside its room, looped. */ - public static Cue room() { - return room; - } - /** The cue while an artefact charges in the pool. */ public static Cue charge() { return charge; diff --git a/src/main/resources/wildmagic/bloodmagic/settings.yml b/src/main/resources/wildmagic/bloodmagic/settings.yml index c1459c5..25440f4 100644 --- a/src/main/resources/wildmagic/bloodmagic/settings.yml +++ b/src/main/resources/wildmagic/bloodmagic/settings.yml @@ -226,10 +226,9 @@ meditation: 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, room while the altar's owner is - # inside its room, charge plays as an artefact goes into the pool. + # 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: ambient.crimson_forest.loop, volume: 0.6, pitch: 0.6, seconds: 30 } - room: { sound: ambient.soul_sand_valley.loop, volume: 0.25, pitch: 0.5, seconds: 30 } - charge: { sound: block.beacon.power_select, volume: 0.6, pitch: 0.5 } + 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/test/java/net/tfminecraft/magic/blood/MeditationTest.java b/src/test/java/net/tfminecraft/magic/blood/MeditationTest.java index 3fc7c47..ec34dc4 100644 --- a/src/test/java/net/tfminecraft/magic/blood/MeditationTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/MeditationTest.java @@ -63,7 +63,6 @@ record = fx.record("mage", BloodStage.FINAL); @AfterEach void cleanup() { BloodMeditation.clear(); - BloodAmbience.clear(); pedestals.close(); fx.close(); } @@ -120,7 +119,7 @@ void onlyAMageWithChargedArtefactsOnThePedestalsCanBegin() { assertEquals(BloodMeditation.Begin.STARTED, BloodMeditation.start(mage, altar)); assertTrue(BloodMeditation.isMeditating(mage.getUniqueId())); assertEquals(BloodMeditation.Begin.BUSY, BloodMeditation.start(mage, altar)); - mage.assertSoundHeard("ambient.crimson_forest.loop"); + mage.assertSoundHeard("tfmc_blood:meditate.chant"); assertFalse(BloodMeditation.swing(bare)); } @@ -258,7 +257,7 @@ void dropsLeftAloneFallBackAndTheMusicLoops() throws Exception { assertTrue(drops() <= 4); mage.clearSounds(); tick(600); - mage.assertSoundHeard("ambient.crimson_forest.loop"); + 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", @@ -278,46 +277,10 @@ void dropsLeftAloneFallBackAndTheMusicLoops() throws Exception { } @Test - void theRoomHumsForItsOwnerOnlyAndHushesForMeditation() throws Exception { - long now = 1_000_000; - BloodAmbience.tick(now); - mage.assertSoundHeard("ambient.soul_sand_valley.loop"); - mage.clearSounds(); - BloodAmbience.tick(now + 1000); - assertTrue(mage.getHeardSounds().isEmpty(), "Still playing"); - BloodAmbience.tick(now + 31_000); - mage.assertSoundHeard("ambient.soul_sand_valley.loop"); - PlayerMock stranger = fx.player("stranger"); - BloodAmbience.tick(now + 32_000); - assertTrue(stranger.getHeardSounds().isEmpty()); - mage.teleport(new Location(fx.world, 40.5, 11, 0.5)); - BloodAmbience.tick(now + 33_000); - BloodAmbience.tick(now + 34_000); - mage.teleport(new Location(fx.world, 0.5, 11, 0.5)); - BloodAmbience.tick(now + 35_000); - sit(mage); - place(record, "devotee:0", 2.5); - BloodMeditation.start(mage, altar); - BloodAmbience.tick(now + 36_000); - BloodAmbience.pause(mage); - BloodMeditation.clear(); - BloodAmbience.tick(now + 37_000); - BloodAmbience.forget(mage.getUniqueId()); - - Files.writeString(temp.resolve("r.yml"), "enabled: true\nmeditation: { sounds: { room: { sound: r.s } } }\n"); - BloodConfig.load(temp.resolve("r.yml").toFile(), fx.logger); - mage.clearSounds(); - BloodAmbience.tick(now + 40_000); - BloodAmbience.tick(now + 400_000); - assertEquals(1, mage.getHeardSounds().stream().filter(sound -> sound.getSound().equals("r.s")).count(), - "No length: it plays once"); - Files.writeString(temp.resolve("q.yml"), "enabled: true\n"); - BloodConfig.load(temp.resolve("q.yml").toFile(), fx.logger); - mage.clearSounds(); - BloodAmbience.tick(now + 500_000); - assertTrue(mage.getHeardSounds().isEmpty()); + void quietCuesPlayNothing() { MeditationConfig.Cue.NONE.play(mage); MeditationConfig.Cue.NONE.stop(mage); + assertTrue(mage.getHeardSounds().isEmpty()); } @Test From db20771bfea26d57afabfe1b731d405764edc80d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:40:33 +0100 Subject: [PATCH 52/55] Keep blood magic's disk writes off the main thread's busy paths - Bond flushes (every ten minutes, one file per changed character, so dozens on a full server) are written on a background thread in order, from copies taken on the main thread; shutdown waits for them. A failed write is logged. - Fading blood resonance is written every ten minutes and on shutdown, or at once when a tier slips, instead of every minute per mage. A record reset or forgotten meanwhile is not written back. - A meditation writes its record once, when it ends, not on every catch. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/BloodMagic.java | 2 ++ .../magic/blood/BloodMeditation.java | 8 +++-- .../tfminecraft/magic/blood/BloodPool.java | 25 +++++++++++++++- .../tfminecraft/magic/blood/BloodStore.java | 4 +++ .../tfminecraft/magic/blood/BondService.java | 30 ++++++++++++++++--- .../magic/blood/ArtefactsAndPoolTest.java | 15 ++++++++++ .../tfminecraft/magic/blood/BloodFixture.java | 1 + .../magic/blood/HuntAndFinalsTest.java | 24 +++++++++++++++ 8 files changed, 101 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index 2544d66..7c6e85f 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -73,6 +73,7 @@ static void tick(MagicTickContext context) { } if (context.everyMinutes(10)) { BondService.flush(); + BloodPool.flush(); for (Player player : Bukkit.getOnlinePlayers()) { FinalService.check(player); } @@ -123,6 +124,7 @@ public static void stop() { CarryService.clear(); BloodMeditation.clear(); BloodCharging.clear(); + BloodPool.flush(); CharacterRiteService.clear(); BondService.clear(); AnimalRiteService.releaseAll(); diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java b/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java index 3368125..df4a70e 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java @@ -30,8 +30,8 @@ * 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. Its music and the - * heartbeat are heard by the mage alone. + * 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 { @@ -252,7 +252,6 @@ public static boolean swing(Player player) { BloodPool.cap(record) - record.getResonance()))); record.setCharge(drop.id, record.getCharge(drop.id) - drawn); record.setResonance(record.getResonance() + drawn); - BloodRecords.save(record); session.drops.remove(drop); Location at = drop.display.getLocation(); drop.display.remove(); @@ -304,6 +303,9 @@ private static void end(Session session, Player player, BloodAltar altar, BloodR 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)); } diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodPool.java b/src/main/java/net/tfminecraft/magic/blood/BloodPool.java index 25fcbbd..2d901c1 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodPool.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodPool.java @@ -1,5 +1,8 @@ package net.tfminecraft.magic.blood; +import java.util.LinkedHashSet; +import java.util.Set; + import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -13,6 +16,9 @@ */ 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. */ @@ -76,11 +82,28 @@ static void tick() { } int before = PoolConfig.band(resonance(record)); record.setResonance(Math.max(PoolConfig.floor(), record.getResonance() * (1 - share))); - BloodRecords.save(record); 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. */ diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodStore.java b/src/main/java/net/tfminecraft/magic/blood/BloodStore.java index 75af643..028da1d 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodStore.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodStore.java @@ -164,6 +164,10 @@ public Map loadBonds(String characterId) { 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)); } diff --git a/src/main/java/net/tfminecraft/magic/blood/BondService.java b/src/main/java/net/tfminecraft/magic/blood/BondService.java index 2cc1430..5581712 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BondService.java +++ b/src/main/java/net/tfminecraft/magic/blood/BondService.java @@ -11,6 +11,9 @@ 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; @@ -34,6 +37,18 @@ 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() {} @@ -43,9 +58,10 @@ public static void init(BloodStore source) { store = source; } - /** Writes pending changes and forgets everything. */ + /** Writes pending changes, waits for them, and forgets everything. */ public static void clear() { flush(); + writing.join(); MINUTES.clear(); store = null; } @@ -104,11 +120,17 @@ static void sample() { } } - /** Writes every changed character's bonds. */ + /** Writes every changed character's bonds, off the main thread (copies taken now). */ public static void flush() { - if (store != null) { + BloodStore target = store; + if (target != null) { for (String key : DIRTY) { - store.saveBonds(key, MINUTES.get(key)); + 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(); diff --git a/src/test/java/net/tfminecraft/magic/blood/ArtefactsAndPoolTest.java b/src/test/java/net/tfminecraft/magic/blood/ArtefactsAndPoolTest.java index e4b914b..a4d4b8e 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ArtefactsAndPoolTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ArtefactsAndPoolTest.java @@ -516,6 +516,21 @@ void onceThePathIsCompleteResonanceFadesAndSaysWhenATierSlips() throws Exception 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(); diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java index cdbd862..9870460 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodFixture.java @@ -50,6 +50,7 @@ final class BloodFixture implements AutoCloseable { 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. */ diff --git a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java index a02e55b..93595de 100644 --- a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java @@ -155,6 +155,30 @@ void bondsGrowFromTimeTogetherAndPersist() throws Exception { 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); From c35ed6b441ba903d3e6e06d03ce84e5359a1cedc Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:52:41 +0100 Subject: [PATCH 53/55] Clear stray altar displays from chunks that load before Magic A shutdown that never saves the world can bring a purified altar's chunk back from before the purify: its display, altar block and glow return with no altar behind them. Strays were only swept as chunks loaded, which misses spawn and force-loaded chunks that load before the plugin. Settling after enable now sweeps every loaded world too, and a stray whose altar is gone also takes the altar block and glow under it (unless another altar stands there). Co-Authored-By: Claude Opus 5.5 --- .../magic/blood/AltarDisplays.java | 21 ++++++++++++++++++- .../tfminecraft/magic/blood/BloodMagic.java | 10 ++++++++- .../magic/blood/AltarServiceTest.java | 11 ++++++++++ .../magic/blood/BloodMagicTest.java | 10 +++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/AltarDisplays.java b/src/main/java/net/tfminecraft/magic/blood/AltarDisplays.java index 2e4d851..69248a5 100644 --- a/src/main/java/net/tfminecraft/magic/blood/AltarDisplays.java +++ b/src/main/java/net/tfminecraft/magic/blood/AltarDisplays.java @@ -7,6 +7,7 @@ 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; @@ -70,7 +71,11 @@ 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. */ + /** + * 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); @@ -78,12 +83,26 @@ public static void removeStrays(Collection entities) { 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) { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java index 7c6e85f..00fee46 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMagic.java @@ -5,6 +5,8 @@ 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; @@ -43,9 +45,15 @@ public static void start(Plugin plugin) { plugin.getServer().getScheduler().runTask(plugin, BloodMagic::settle); } - /** First tick after enable: purify altars of owners who died while we were down, redraw the rest. */ + /** + * 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); } diff --git a/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java index 2c2433e..616d169 100644 --- a/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/AltarServiceTest.java @@ -170,6 +170,17 @@ void displaysFollowLevelRestoreBlocksAndDropStrays() throws Exception { 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"); diff --git a/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java b/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java index bd9051a..fd7438b 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BloodMagicTest.java @@ -47,6 +47,13 @@ void startLoadsDataSettlesAltarsAndSweepsOnSchedule() throws Exception { 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()); @@ -55,6 +62,9 @@ void startLoadsDataSettlesAltarsAndSweepsOnSchedule() throws Exception { 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)); From 83e53501f34599c95f14d9afb1bcb2d0e7e494f1 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:06:33 +0100 Subject: [PATCH 54/55] Leave blood altar seats to blood meditation and move its displays half as often Sitting on a blood altar also ran Magic's own meditation start check every tick: the altar's pedestals form a full ring, so it re-read the lore of all eight blood artefacts each tick (about 0.06 ms per sitter) and could tell a blood mage there were no artifacts. Blood altar seats are now skipped. The charging artefact and the meditation drops move every other tick with a two-tick teleport duration, halving their teleport events. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/BloodCharging.java | 11 +++++++---- .../tfminecraft/magic/blood/BloodMeditation.java | 13 ++++++++----- .../magic/meditation/MeditationService.java | 5 ++++- .../tfminecraft/magic/MeditationServiceTest.java | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodCharging.java b/src/main/java/net/tfminecraft/magic/blood/BloodCharging.java index c2cd91e..7ce38ea 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodCharging.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodCharging.java @@ -127,11 +127,14 @@ static void tick() { 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); - Location at = charging.base().clone().add(0, height, 0); - at.setYaw((float) (elapsed * 6 % 360)); - charging.display().teleport(at); + // 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) { - at.getWorld().spawnParticle(Particle.DUST, charging.base(), 6, 0.25, 0.05, 0.25, 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) { diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java b/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java index df4a70e..2477611 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodMeditation.java @@ -185,7 +185,7 @@ private static void beat(Player player, Session session, BloodAltar altar, Blood ItemDisplay display = from.getWorld().spawn(from, ItemDisplay.class, spawned -> { spawned.setItemStack(ItemRef.buildOrFallback(MeditationConfig.dropItem(), Material.REDSTONE)); spawned.setPersistent(false); - spawned.setTeleportDuration(1); + spawned.setTeleportDuration(2); }); session.drops.add(new Drop(BloodArtefacts.id(source.item()), display, from, ticks)); scale(session, MeditationConfig.flareScale()); @@ -218,10 +218,13 @@ private static void drift(Player player, Session session) { drop.display.remove(); continue; } - 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); + // 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); + } } } 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/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(); From 2479d18e27bdc81b6931ad8a5129bf43664af6be Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:31:43 +0100 Subject: [PATCH 55/55] Mark devotees Manaless and let the victim on the altar hear their death Kneeling no longer gives two random permanent injuries: the devotee gets the one RPCharacters trait choice.devotee_trait names (manaless: -2 to every attribute, given only here), and while a character bears it Magic takes away all max mana (a -100% MAX_MANA modifier under the boons' key). A character dying on the altar now hears it happen: four lines spread over the rite (blood.rite.dying.1-4), one as it ends (dying.last), and another if the rite breaks first (spared). Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/blood/BloodBoons.java | 15 +++++--- .../tfminecraft/magic/blood/BloodChoice.java | 11 +++--- .../magic/blood/CharacterRiteService.java | 24 +++++++++++++ .../tfminecraft/magic/blood/ChoiceConfig.java | 14 +++++--- .../tfminecraft/magic/blood/MythicBoons.java | 9 +++++ .../magic/integration/RpCharactersBridge.java | 35 ++++++++++++++++-- .../resources/wildmagic/bloodmagic/choice.yml | 5 +-- .../wildmagic/bloodmagic/messages.yml | 10 ++++++ .../magic/blood/BoonsAndToolsTest.java | 28 +++++++++++++++ .../tfminecraft/magic/blood/ChoiceTest.java | 29 ++++++++++----- .../magic/blood/HuntAndFinalsTest.java | 18 ++++++++++ .../magic/blood/InjuryBridgeTest.java | 36 +++++++++++++++---- 12 files changed, 196 insertions(+), 38 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodBoons.java b/src/main/java/net/tfminecraft/magic/blood/BloodBoons.java index 73156b6..f342356 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodBoons.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodBoons.java @@ -5,9 +5,12 @@ 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. Without MythicLib nothing is applied. MythicLib types stay in {@link MythicBoons}. + * 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 { @@ -24,13 +27,15 @@ public static void apply(Player player) { } MythicBoons.clear(player); BloodRecord record = BloodRecords.get(AltarService.currentCharacter(player)); - if (record == null) { - return; - } - Map boons = HuntConfig.boonsUpTo(record.getPath(), record.getFinalsDone()); + 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. */ diff --git a/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java index d77f132..c434ac0 100644 --- a/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java +++ b/src/main/java/net/tfminecraft/magic/blood/BloodChoice.java @@ -31,19 +31,16 @@ public static boolean answer(Player player, BloodPath path) { String character = record.getCharacterId(); record.setPath(path); if (path == BloodPath.DEVOTEE) { - int marks = 0; - for (int i = 0; i < ChoiceConfig.devoteeInjuries(); i++) { - if (RpCharactersBridge.applyPermanentInjury(player)) { - marks++; - } - } + 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 (" - + marks + " permanent injuries)"); + + (marked ? "marked " + mark : "no mark") + ")"); } else { record.setStage(BloodStage.HUNT); BloodRecords.save(record); diff --git a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java index af96686..9fbe037 100644 --- a/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java +++ b/src/main/java/net/tfminecraft/magic/blood/CharacterRiteService.java @@ -51,6 +51,10 @@ private record Rite(UUID victim, String mageCharacter, String victimCharacter, S 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; @@ -235,9 +239,13 @@ static void tick() { 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); } @@ -245,6 +253,7 @@ static void tick() { } if (now < rite.endsAt()) { BloodFx.play("rite_beat", top, victim); + narrate(victim, rite, now); continue; } it.remove(); @@ -252,7 +261,21 @@ static void tick() { } } + /** 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; @@ -299,6 +322,7 @@ private static void complete(Player mage, Player victim, Rite rite, BloodAltar a /** Ends every rite without a death, e.g. on shutdown. */ public static void clear() { RITES.clear(); + NARRATED.clear(); SACRIFICING.clear(); } diff --git a/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java index 27c4280..4993c4c 100644 --- a/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java +++ b/src/main/java/net/tfminecraft/magic/blood/ChoiceConfig.java @@ -21,7 +21,7 @@ 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 int devoteeInjuries; + private static String devoteeTrait; private static int answerSeconds; private static int lineSeconds; private static String music; @@ -43,7 +43,7 @@ public record Answer(List words, List confirm, List sealed) private ChoiceConfig() {} static void reset() { - devoteeInjuries = 2; + devoteeTrait = "manaless"; answerSeconds = 90; lineSeconds = 4; music = ""; @@ -74,7 +74,7 @@ public Piece(String item, String look) { static void load(ConfigurationSection choice, ConfigurationSection classes, ConfigurationSection armour) { reset(); if (choice != null) { - devoteeInjuries = Math.max(0, choice.getInt("devotee_injuries", 2)); + 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(); @@ -194,8 +194,12 @@ public static int musicLoopSeconds() { return musicLoopSeconds; } - public static int devoteeInjuries() { - return devoteeInjuries; + /** + * 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. */ diff --git a/src/main/java/net/tfminecraft/magic/blood/MythicBoons.java b/src/main/java/net/tfminecraft/magic/blood/MythicBoons.java index 22b75ce..4c4a262 100644 --- a/src/main/java/net/tfminecraft/magic/blood/MythicBoons.java +++ b/src/main/java/net/tfminecraft/magic/blood/MythicBoons.java @@ -7,6 +7,7 @@ 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 { @@ -23,6 +24,14 @@ static void add(Player player, Map boons) { 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); diff --git a/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java b/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java index e391d59..a19cc38 100644 --- a/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java +++ b/src/main/java/net/tfminecraft/magic/integration/RpCharactersBridge.java @@ -10,8 +10,11 @@ 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; @@ -103,10 +106,36 @@ public static boolean applyHealingInjury(Player player) { return character != null && PermadeathService.applyRandomInjury(player, character); } - /** Gives the player's active character a random permanent injury; false without one. */ - public static boolean applyPermanentInjury(Player player) { + /** + * 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 && PermadeathService.applyRandomPermanentInjury(player, character); + 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; } /** diff --git a/src/main/resources/wildmagic/bloodmagic/choice.yml b/src/main/resources/wildmagic/bloodmagic/choice.yml index 8fabd09..8a53c8c 100644 --- a/src/main/resources/wildmagic/bloodmagic/choice.yml +++ b/src/main/resources/wildmagic/bloodmagic/choice.yml @@ -46,5 +46,6 @@ choice: sealed: - god: "...So be it. You will find the quiet very cold, child." - self: "Finally. Just me." - # Permanent injuries a devotee keeps as the mark of what they gave. - devotee_injuries: 2 + # 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/messages.yml b/src/main/resources/wildmagic/bloodmagic/messages.yml index 8799489..ce1971e 100644 --- a/src/main/resources/wildmagic/bloodmagic/messages.yml +++ b/src/main/resources/wildmagic/bloodmagic/messages.yml @@ -145,6 +145,16 @@ blood: 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" diff --git a/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java b/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java index a4386da..f9db7f1 100644 --- a/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/BoonsAndToolsTest.java @@ -100,6 +100,34 @@ void boonsNeedMythicLibAndReplaceEachOther() { } } + @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(); diff --git a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java index f037994..7b0fe16 100644 --- a/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/ChoiceTest.java @@ -64,7 +64,7 @@ record = fx.record("mage", BloodStage.CHOICE); FakeUnlocks.answer = true; FakeUnlocks.last = null; bridge = mockStatic(RpCharactersBridge.class); - bridge.when(() -> RpCharactersBridge.applyPermanentInjury(any())).thenReturn(true); + bridge.when(() -> RpCharactersBridge.applyTrait(any(), any())).thenReturn(true); } @AfterEach @@ -119,7 +119,7 @@ void bundledQuestionClassesAndArmourLoad() { assertEquals(91, ChoiceConfig.musicLoopSeconds()); assertTrue(ChoiceConfig.answer(BloodPath.DEVOTEE).words().contains("i kneel")); assertEquals(2, ChoiceConfig.answer(BloodPath.TRUE).sealed().size()); - assertEquals(2, ChoiceConfig.devoteeInjuries()); + assertEquals("manaless", ChoiceConfig.devoteeTrait()); assertEquals("bloodsworn", ChoiceConfig.classFor(BloodPath.DEVOTEE)); assertEquals("sanguinar", ChoiceConfig.classFor(BloodPath.TRUE)); assertNull(ChoiceConfig.classFor(BloodPath.NONE)); @@ -129,12 +129,12 @@ void bundledQuestionClassesAndArmourLoad() { @Test void missingSectionsFallBack() throws Exception { - Files.writeString(temp.resolve("e.yml"), String.join("\n", "enabled: true", "choice:", " devotee_injuries: -3", + 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(0, ChoiceConfig.devoteeInjuries()); + assertEquals("", ChoiceConfig.devoteeTrait()); assertEquals(5, ChoiceConfig.answerSeconds()); assertEquals(1, ChoiceConfig.lineSeconds()); assertEquals("x:y", ChoiceConfig.music()); @@ -272,7 +272,7 @@ void devoteesGiveThemselvesAndGetClassAndArmour() { when(api.getArmorMerger().merge(any(), any(), anyString())).thenAnswer(i -> i.getArgument(0)); assertTrue(BloodChoice.answer(mage, BloodPath.DEVOTEE)); } - bridge.verify(() -> RpCharactersBridge.applyPermanentInjury(mage), times(2)); + bridge.verify(() -> RpCharactersBridge.applyTrait(mage, "manaless")); assertEquals(BloodPath.DEVOTEE, record.getPath()); assertEquals(BloodStage.FINAL, record.getStage()); assertEquals("bloodsworn", FakeUnlocks.last); @@ -285,20 +285,31 @@ void devoteesGiveThemselvesAndGetClassAndArmour() { @Test void refusingKeepsTheClassAndStartsTheHunt() { - bridge.when(() -> RpCharactersBridge.applyPermanentInjury(any())).thenReturn(false); 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.applyPermanentInjury(any()), never()); + bridge.verify(() -> RpCharactersBridge.applyTrait(any(), any()), never()); } @Test - void injuriesThatFailAreCounted() { - bridge.when(() -> RpCharactersBridge.applyPermanentInjury(any())).thenReturn(false); + 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 diff --git a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java index 93595de..838c402 100644 --- a/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/HuntAndFinalsTest.java @@ -351,6 +351,24 @@ void finalsTakeTheNamedTargetThenTheNextUntilAscension() throws Exception { 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); diff --git a/src/test/java/net/tfminecraft/magic/blood/InjuryBridgeTest.java b/src/test/java/net/tfminecraft/magic/blood/InjuryBridgeTest.java index b487670..7f5e0de 100644 --- a/src/test/java/net/tfminecraft/magic/blood/InjuryBridgeTest.java +++ b/src/test/java/net/tfminecraft/magic/blood/InjuryBridgeTest.java @@ -4,11 +4,15 @@ 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.permadeath.PermadeathService; +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; @@ -16,18 +20,36 @@ class InjuryBridgeTest { @TempDir Path temp; @Test - void permanentInjuryNeedsTheActiveCharacter() { + 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 permadeath = mockStatic(PermadeathService.class)) { + var changes = mockStatic(TraitChangeService.class)) { + TraitLoader.oList.add(manaless); var player = fx.player("mage"); - assertFalse(RpCharactersBridge.applyPermanentInjury(player)); + 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.applyPermanentInjury(player)); - permadeath.when(() -> PermadeathService.applyRandomPermanentInjury(player, character)).thenReturn(true); - assertTrue(RpCharactersBridge.applyPermanentInjury(player)); + 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); } } }