diff --git a/pom.xml b/pom.xml index 73d2c98..bd220ef 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ nu.nerd DragonFight - 1.2.0 + 1.3.0 Custom dragon fight. https://github.com/NerdNu/${project.name} @@ -17,8 +17,17 @@ UTF-8 - 1.8 - 1.8 + + 17 + 17 + 17 + true @@ -32,12 +41,21 @@ org.spigotmc spigot-api - 1.15.2-R0.1-SNAPSHOT + 1.18.1-R0.1-SNAPSHOT nu.nerd BeastMaster - 2.14.0 + 2.17.0 + + + + org.projectlombok + lombok + 1.18.20 @@ -49,5 +67,13 @@ true + + + org.apache.maven.plugins + maven-compiler-plugin + 3.9.0 + + + diff --git a/src/main/java/nu/nerd/df/Configuration.java b/src/main/java/nu/nerd/df/Configuration.java index 3420e3d..6974417 100644 --- a/src/main/java/nu/nerd/df/Configuration.java +++ b/src/main/java/nu/nerd/df/Configuration.java @@ -24,13 +24,38 @@ public class Configuration { */ public String DEBUG_PREFIX; + /** + * Current stage number: 0 to 11. + * + * Stage 0 is before the fight, Stage 1 => first crystal removed and boss + * spawned. Stage 10 => final boss spawned. Stage 11: dragon. In Stage N, N + * crystals have been removed. + */ + public int STAGE_NUMBER; + + /** + * The new value of STAGE_NUMBER, when there is a transition between stages. + * + * Spawn animations for the dragon and for stage bosses take time. To handle + * server restarts during these spawn sequences, we need to record the fact + * that it is underway and at least the new stage number. But in this + * plugin, we record both as that makes backwards stage transitions feasible + * (although not currently implemented). + * + * When NEW_STAGE_NUMBER != STAGE_NUMBER, the fight is part-way through + * transitioning from STAGE_NUMBER to NEW_STAGE_NUMBER. The STAGE_NUMBER + * will be set to NEW_STAGE_NUMBER when the transition is complete, in a few + * seconds. + */ + public int NEW_STAGE_NUMBER; + /** * Total boss maximum health. * * This needs to be preserved across restarts, since a stage fight may have - * a random number of bosses, some of which may have died. THere is no easy - * way to work infer the total maximum boss health points in the stage after - * a restart. + * a random number of bosses, some of which may have died. There is no easy + * way to infer the total maximum boss health points in the stage after a + * restart. */ public double TOTAL_BOSS_MAX_HEALTH; @@ -102,15 +127,18 @@ public Stage getStage(int stageNumber) { // ------------------------------------------------------------------------ /** - * Reload the configuration. + * Reload the fight state. + * + * The fight state changes as the plugin runs, so only call this method when + * loading the plugin. */ - public void reload() { + public void reloadFightState() { DragonFight.PLUGIN.reloadConfig(); FileConfiguration config = DragonFight.PLUGIN.getConfig(); Logger logger = DragonFight.PLUGIN.getLogger(); - LOG_PREFIX = config.getString("settings.log-prefix"); - DEBUG_PREFIX = config.getString("settings.debug-prefix"); + STAGE_NUMBER = config.getInt("state.stage-number", 0); + NEW_STAGE_NUMBER = config.getInt("state.new-stage-number", 0); TOTAL_BOSS_MAX_HEALTH = config.getDouble("state.total-boss-max-health"); try { @@ -131,6 +159,22 @@ public void reload() { logger.warning("Unclaimed dragon prize registered to invalid UUID: " + key); } } + } + + // ------------------------------------------------------------------------ + /** + * Reload the configuration. + * + * This method is the counterpart to {@link #reloadFightState}. This method + * only loads the settings of the fight that are not modified as the fight + * progresses. + */ + public void reloadConfiguration() { + DragonFight.PLUGIN.reloadConfig(); + FileConfiguration config = DragonFight.PLUGIN.getConfig(); + + LOG_PREFIX = config.getString("settings.log-prefix"); + DEBUG_PREFIX = config.getString("settings.debug-prefix"); for (int stageNumber = 1; stageNumber <= 11; ++stageNumber) { getStage(stageNumber).load(getStageSection(stageNumber)); @@ -147,6 +191,8 @@ public void save() { config.set("settings.log-prefix", LOG_PREFIX); config.set("settings.debug-prefix", DEBUG_PREFIX); + config.set("state.stage-number", STAGE_NUMBER); + config.set("state.new-stage-number", NEW_STAGE_NUMBER); config.set("state.total-boss-max-health", TOTAL_BOSS_MAX_HEALTH); config.set("state.fight-owner", (FIGHT_OWNER == null) ? null : FIGHT_OWNER.toString()); diff --git a/src/main/java/nu/nerd/df/DragonFight.java b/src/main/java/nu/nerd/df/DragonFight.java index 3e19d89..e55ca3b 100644 --- a/src/main/java/nu/nerd/df/DragonFight.java +++ b/src/main/java/nu/nerd/df/DragonFight.java @@ -38,7 +38,8 @@ public class DragonFight extends JavaPlugin { public void onEnable() { PLUGIN = this; saveDefaultConfig(); - CONFIG.reload(); + CONFIG.reloadConfiguration(); + CONFIG.reloadFightState(); addCommandExecutor(new DFExecutor()); addCommandExecutor(new DragonExecutor()); @@ -61,7 +62,7 @@ public void onDisable() { // ------------------------------------------------------------------------ /** * Add the specified CommandExecutor and set it as its own TabCompleter. - * + * * @param executor the CommandExecutor. */ protected void addCommandExecutor(ExecutorBase executor) { diff --git a/src/main/java/nu/nerd/df/DragonUtil.java b/src/main/java/nu/nerd/df/DragonUtil.java index eec2598..fc7e445 100644 --- a/src/main/java/nu/nerd/df/DragonUtil.java +++ b/src/main/java/nu/nerd/df/DragonUtil.java @@ -16,7 +16,7 @@ // -------------------------------------------------------------------------- /** * DragonFight utility functions. - * + * * Note that the DragonFight plugin also references BeastMaster's DragonUtil * class, so to avoid a qualified name, we name this class differently. */ @@ -24,7 +24,7 @@ public class DragonUtil { // ------------------------------------------------------------------------ /** * Return the world where the dragon fight occurs. - * + * * @return the world where the dragon fight occurs. */ public static World getFightWorld() { @@ -35,7 +35,7 @@ public static World getFightWorld() { /** * Return true if the specified world is the one where the dragon fight can * occur. - * + * * @param world the world. * @return true if the specified world is the one where the dragon fight can * occur. @@ -48,9 +48,9 @@ public static boolean isFightWorld(World world) { // ------------------------------------------------------------------------ /** * Remove a specific dragon. - * + * * Because Entity.remove() would be too easy. - * + * * @param dragon the ender dragon to remove. */ public static void removeDragon(EnderDragon dragon) { @@ -77,9 +77,9 @@ public static void removeAllDragons() { /** * Return true if an entity has the specified scoreboard tag or BeastMaster * group. - * + * * @param entity the entity. - * @param tag the tag or group to look for. + * @param tag the tag or group to look for. * @return true if the entity has the tag or group. */ public static boolean hasTagOrGroup(Entity entity, String tag) { @@ -102,7 +102,7 @@ public static boolean hasTagOrGroup(Entity entity, String tag) { * Return the magnitude of the X and Z components of location, i.e. the * distance between loc and the world origin in the XZ plane (ignoring Y * coordinate). - * + * * @param loc the location. * @return sqrt(x^2 + z^2). */ @@ -114,11 +114,11 @@ public static double getMagnitude2D(Location loc) { /** * Return true if the specified location is on the circle where the centre * of the obsidian pillars are placed. - * + * * The pillars about placed in a circle about 40 blocks from (0,0). The * largest pillars have a radius of 5 blocks, including the centre block, so * ensure that we block placements up to 6 blocks away (6/40 = 0.15). - * + * * Note that: * - * + * * @param loc the location. * @return if the location is about the right range from the origin of the * world to be the centre of a pillar. */ public static boolean isOnPillarCircle(Location loc) { - return Math.abs(getMagnitude2D(loc) - DragonUtil.PILLAR_CIRCLE_RADIUS) < 0.15 * DragonUtil.PILLAR_CIRCLE_RADIUS; + return loc.getWorld().getName().equals("world_the_end") && + Math.abs(getMagnitude2D(loc) - DragonUtil.PILLAR_CIRCLE_RADIUS) < 0.15 * DragonUtil.PILLAR_CIRCLE_RADIUS; } // ------------------------------------------------------------------------ /** * The World where the fight occurs. - * + * * It could be configurable, but for now is constant. */ private static final String FIGHT_WORLD = "world_the_end"; diff --git a/src/main/java/nu/nerd/df/FightState.java b/src/main/java/nu/nerd/df/FightState.java index 266db96..ad7d5a1 100644 --- a/src/main/java/nu/nerd/df/FightState.java +++ b/src/main/java/nu/nerd/df/FightState.java @@ -7,7 +7,6 @@ import java.util.Iterator; import java.util.List; import java.util.Set; -import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -40,6 +39,8 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.block.Action; +import org.bukkit.event.entity.CreatureSpawnEvent; +import org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason; import org.bukkit.event.entity.EnderDragonChangePhaseEvent; import org.bukkit.event.entity.EntityCombustEvent; import org.bukkit.event.entity.EntityDamageEvent; @@ -78,6 +79,50 @@ * * Define the term "arena" to mean the space within the circle of obsidian * pillars in the end. + * + * The vanilla/Spigot DragonBattle class has various idiosyncrasies that this + * plugin must work around: + * + * + * + * EnderDragon SpawnReason possibilities: + * + * */ public class FightState implements Listener { // ------------------------------------------------------------------------ @@ -86,9 +131,14 @@ public class FightState implements Listener { */ public void onEnable() { defineBeastMasterObjects(); - discoverFightState(); - log("Detected stage: " + _stageNumber); - reconfigureDragonBossBar(); + + // Force load chunks in the end, then wait a few seconds and hope that + // the entities are loaded. There is no way to force-load entities. + preLoadEndChunks(); + Bukkit.getScheduler().runTaskLater(DragonFight.PLUGIN, () -> { + recoverFightState(); + reconfigureDragonBossBar(); + }, 100); Bukkit.getScheduler().scheduleSyncRepeatingTask(DragonFight.PLUGIN, _tracker, TrackerTask.PERIOD_TICKS, TrackerTask.PERIOD_TICKS); // Every 30 seconds remove extra dragons. YOLO. @@ -106,32 +156,108 @@ public void onEnable() { public void onDisable() { Bukkit.getScheduler().cancelTasks(DragonFight.PLUGIN); - // If restarting while the dragon is spawning, blow it all away and log - // it so the admins can refund. + // If 4 crystals are placed and there is a restart, I'm not sure if + // the dragon will respawn after the restart. Log the situation so an + // admin refund is possible. List spawningCrystals = getDragonSpawnCrystals(); if (spawningCrystals.size() == 4) { - log("Stopping the fight due to restart."); + log("The server is restarting during dragon spawn (4 crystals placed)."); cmdStop(Bukkit.getConsoleSender()); } } // ------------------------------------------------------------------------ /** - * Implement the /dragon info command. + * Return true if a dragon fight is currently underway. * - * This command is for ordinary players to check the fight status. + * @return true if a fight is happening. */ - public void cmdPlayerInfo(CommandSender sender) { - if (_stageNumber == 0 || DragonFight.CONFIG.FIGHT_OWNER == null) { - sender.sendMessage(ChatColor.DARK_PURPLE + "Nobody is fighting the dragon right now."); - return; - } else { - sender.sendMessage(ChatColor.DARK_PURPLE + "The current fight stage is " + - ChatColor.LIGHT_PURPLE + _stageNumber + ChatColor.DARK_PURPLE + "."); - OfflinePlayer fightOwner = Bukkit.getOfflinePlayer(DragonFight.CONFIG.FIGHT_OWNER); - sender.sendMessage(ChatColor.DARK_PURPLE + "The final drops are owned by " + - ChatColor.LIGHT_PURPLE + fightOwner.getName() + ChatColor.DARK_PURPLE + "."); + public boolean isFightHappening() { + return DragonFight.CONFIG.STAGE_NUMBER > 0; + } + + // ------------------------------------------------------------------------ + /** + * Return true when the stage number is changing to + * {@link Configuration#NEW_STAGE_NUMBER}. + * + * @return true when the stage number is changing. + */ + public boolean isStageNumberChanging() { + return getStageNumber() != getNewStageNumber(); + } + + // ------------------------------------------------------------------------ + /** + * Finish the change of stage number to + * {@link Configuration#NEW_STAGE_NUMBER}. + */ + public void finishStageNumberChange() { + setStageNumber(getNewStageNumber()); + } + + // ------------------------------------------------------------------------ + /** + * Immediately change to the specified stage number. + * + * {@link Configuration#NEW_STAGE_NUMBER} is also immediately set to the new + * stage number to signify the transition is complete. + * + * @param stageNumber the new stage number. + */ + public void immediatelyChangeStageNumber(int stageNumber) { + setStageNumber(stageNumber); + setNewStageNumber(stageNumber); + } + + // ------------------------------------------------------------------------ + /** + * Get the current fight stage number. + * + * @return 0 for no fight, or 1 to 11 for the various fight stages. + */ + public int getStageNumber() { + return DragonFight.CONFIG.STAGE_NUMBER; + } + + // ------------------------------------------------------------------------ + /** + * Set the fight stage number. + * + * @param stageNumber the new stage number. + */ + public void setStageNumber(int stageNumber) { + if (stageNumber < 0 || stageNumber > 11) { + throw new IllegalArgumentException("invalid stage number: " + stageNumber); } + DragonFight.CONFIG.STAGE_NUMBER = stageNumber; + } + + // ------------------------------------------------------------------------ + /** + * Get the new fight stage number. + * + * The stage number changes to this new value after mob spawn animations. + * + * @return 0 for no fight, or 1 to 11 for the various fight stages. + */ + public int getNewStageNumber() { + return DragonFight.CONFIG.NEW_STAGE_NUMBER; + } + + // ------------------------------------------------------------------------ + /** + * Set the new fight stage number. + * + * The stage number changes to this new value after mob spawn animations. + * + * @param stageNumber the new stage number. + */ + public void setNewStageNumber(int stageNumber) { + if (stageNumber < 0 || stageNumber > 11) { + throw new IllegalArgumentException("invalid stage number: " + stageNumber); + } + DragonFight.CONFIG.NEW_STAGE_NUMBER = stageNumber; } // ------------------------------------------------------------------------ @@ -145,8 +271,12 @@ public void cmdPlayerInfo(CommandSender sender) { */ public void cmdInfo(CommandSender sender) { sender.sendMessage(ChatColor.DARK_PURPLE + "The current fight stage is " + - ChatColor.LIGHT_PURPLE + _stageNumber + ChatColor.DARK_PURPLE + "."); - if (_stageNumber == 0) { + ChatColor.LIGHT_PURPLE + getStageNumber() + ChatColor.DARK_PURPLE + "."); + if (isStageNumberChanging()) { + sender.sendMessage(ChatColor.DARK_PURPLE + "The fight stage is changing to " + + ChatColor.LIGHT_PURPLE + DragonFight.CONFIG.NEW_STAGE_NUMBER + ChatColor.DARK_PURPLE + "."); + } + if (!isFightHappening()) { return; } @@ -159,7 +289,7 @@ public void cmdInfo(CommandSender sender) { ChatColor.LIGHT_PURPLE + fightOwner.getName() + ChatColor.DARK_PURPLE + "."); } - if (_stageNumber >= 1 && _stageNumber <= 10) { + if (getStageNumber() >= 1 && getStageNumber() <= 10) { sender.sendMessage(ChatColor.DARK_PURPLE + "The current total boss health is " + ChatColor.LIGHT_PURPLE + String.format("%.1f", getTotalBossHealth()) + ChatColor.DARK_PURPLE + " out of " + @@ -232,14 +362,16 @@ public void cmdStop(CommandSender sender) { sender.sendMessage(ChatColor.DARK_PURPLE + "Removed pillar crystals: " + ChatColor.LIGHT_PURPLE + _crystals.size()); _crystals.clear(); - cleanUp(sender); - _stageNumber = 0; + cleanUpMobsAndProjectiles(sender); // Immediately hide the boss bar, rather than waiting for update. if (_bossBar != null) { _bossBar.setVisible(false); } + // Clear stage numbers prior to config save. + immediatelyChangeStageNumber(0); + // Nobody owns the drops, even if a dragon randomly spawns for funsies. DragonFight.CONFIG.FIGHT_OWNER = null; DragonFight.CONFIG.save(); @@ -262,17 +394,17 @@ public void cmdNextStage(CommandSender sender) { return; } - int nextStage = (_stageNumber + 1) % 12; + int nextStage = (getStageNumber() + 1) % 12; if (nextStage == 0) { cmdStop(sender); return; } - cleanUp(sender); + cleanUpMobsAndProjectiles(sender); despawnPillarCrystals(1); // Skip to the next stage. - startStage(sender, _stageNumber + 1, getBossSpawnLocation()); + startStage(sender, getStageNumber() + 1, getBossSpawnLocation()); } // ------------------------------------------------------------------------ @@ -300,65 +432,30 @@ public void cmdSkipToStage(CommandSender sender, int stageNumber) { return; } - if (stageNumber < _stageNumber) { + if (stageNumber < getStageNumber()) { sender.sendMessage(ChatColor.RED + "Skipping backwards is not currently supported."); return; } - if (stageNumber == _stageNumber) { + if (stageNumber == getStageNumber()) { sender.sendMessage(ChatColor.DARK_PURPLE + "You're already in stage " + ChatColor.LIGHT_PURPLE + stageNumber + ChatColor.DARK_PURPLE + "."); return; } - // So from here on, stageNumber > _stageNumber. + // So from here on, stageNumber > getStageNumber(). World fightWorld = DragonUtil.getFightWorld(); DragonBattle battle = fightWorld.getEnderDragonBattle(); if (battle.getEnderDragon() == null) { sender.sendMessage(ChatColor.RED + "You have to spawn the dragon first, using end crystals."); return; - } else { - // Going from stage > 0 to a higher number. - cleanUp(sender); } - int skippedStages = stageNumber - _stageNumber; - despawnPillarCrystals(skippedStages); - startStage(sender, stageNumber, getBossSpawnLocation()); - } + cleanUpMobsAndProjectiles(sender); - // ------------------------------------------------------------------------ - /** - * Implement the /dragon prize command. - * - * @param sender the command sender, for message sending. - */ - public void cmdDragonPrize(CommandSender sender) { - Player player = (Player) sender; - UUID playerUuid = player.getUniqueId(); - - int unclaimed = DragonFight.CONFIG.getUnclaimedPrizes(playerUuid); - if (unclaimed <= 0) { - sender.sendMessage(ChatColor.DARK_PURPLE + "You don't have any unclaimed dragon prizes."); - } else { - List prizes = generatePrizes(); - if (givePrizes(player, prizes)) { - DragonFight.CONFIG.incUnclaimedPrizes(playerUuid, -1); - DragonFight.CONFIG.save(); - - if (--unclaimed > 0) { - String prizeCount = ChatColor.LIGHT_PURPLE + Integer.toString(unclaimed) + - ChatColor.DARK_PURPLE + " unclaimed prize" + (unclaimed > 1 ? "s" : ""); - sender.sendMessage(ChatColor.DARK_PURPLE + "You still have " + prizeCount + "."); - } - } else { - // The item(s) did not fit. - String slots = ChatColor.LIGHT_PURPLE + Integer.toString(prizes.size()) + - ChatColor.DARK_PURPLE + " inventory slot" + (prizes.size() > 1 ? "s" : ""); - player.sendMessage(ChatColor.DARK_PURPLE + "You need at least " + slots + " empty."); - player.sendMessage(ChatColor.DARK_PURPLE + "Make some room in your inventory and try again."); - } - } + int skippedStages = stageNumber - getStageNumber(); + despawnPillarCrystals(skippedStages); + startStage(sender, stageNumber, stageNumber == 11 ? null : getBossSpawnLocation()); } // ------------------------------------------------------------------------ @@ -388,7 +485,7 @@ public static void debug(String message) { * Clean up mobs and projectiles and message the command sender with tallies * of entities removed. */ - protected void cleanUp(CommandSender sender) { + protected void cleanUpMobsAndProjectiles(CommandSender sender) { int projectileCount = 0; int bossCount = 0; int supportCount = 0; @@ -413,37 +510,74 @@ protected void cleanUp(CommandSender sender) { sender.sendMessage(ChatColor.DARK_PURPLE + "Removed projectiles: " + ChatColor.LIGHT_PURPLE + projectileCount); } + // ------------------------------------------------------------------------ + /** + * Load relevant end chunks when the plugin loads. + * + * As of Minecraft 1.17, loading a chunk does not immediately load the + * entities therein; they are loaded asynchronously, some time later. There + * is no method within the 1.18 Bukkit API to forcibly load those entities + * synchronously. + * + * On startup, we need to add all the end crystals to the {@link #_crystals} + * array. So this method starts the ball rolling on loading them and we + * check for them a few seconds later in {@link recoverFightState()}. + * + * References: + *
    + *
  • https://github.com/PaperMC/Paper/issues/5872
  • + *
  • https://hub.spigotmc.org/jira/browse/SPIGOT-6547
  • + *
  • https://hub.spigotmc.org/jira/browse/SPIGOT-6934
  • + *
+ */ + protected void preLoadEndChunks() { + World fightWorld = DragonUtil.getFightWorld(); + + // Preload chunks to ensure we find the crystals. + int chunkRange = (int) Math.ceil(TRACKED_RADIUS / 16); + for (int x = -chunkRange; x <= chunkRange; ++x) { + for (int z = -chunkRange; z <= chunkRange; ++z) { + Chunk chunk = fightWorld.getChunkAt(x, z); + chunk.addPluginChunkTicket(DragonFight.PLUGIN); + chunk.setForceLoaded(true); + chunk.load(); + } + } + } + // ------------------------------------------------------------------------ /** * When the plugin initialises, infer the state of the fight, including the * stage number, based on the presence of crystals, the dragon and bosses. + * Then reconcile the inferred stage number with that saved in the config. * * Find the ender crystals that are part of the current dragon fight. * * There is no ChunkLoadEvent for spawn chunks, so we need to scan loaded * chunks on startup. */ - protected void discoverFightState() { + protected void recoverFightState() { World fightWorld = DragonUtil.getFightWorld(); DragonBattle battle = fightWorld.getEnderDragonBattle(); + if (battle.getEnderDragon() != null) { + log("Dragon " + battle.getEnderDragon().getUniqueId() + " exists."); + } else { + log("No dragon exists."); + } + // Preload chunks to ensure we find the crystals. int chunkRange = (int) Math.ceil(TRACKED_RADIUS / 16); for (int x = -chunkRange; x <= chunkRange; ++x) { for (int z = -chunkRange; z <= chunkRange; ++z) { Chunk chunk = fightWorld.getChunkAt(x, z); - // Keep the chunk loaded until we can get some consistency - // checking implemented. Even if not the real problem. - fightWorld.loadChunk(chunk); - for (Entity entity : chunk.getEntities()) { - if (entity instanceof EnderCrystal && - entity.getScoreboardTags().contains(PILLAR_CRYSTAL_TAG)) { - log("Loaded crystal " + entity.getUniqueId() + " at " + Util.formatLocation(entity.getLocation())); + if (entity instanceof EnderCrystal && entity.getScoreboardTags().contains(PILLAR_CRYSTAL_TAG)) { _crystals.add((EnderCrystal) entity); - // In case the restart happened immediately after the - // crystal spawned. + // In case the restart happened immediately after + // the crystal spawned. entity.setInvulnerable(true); + } else if (entity instanceof LivingEntity) { // Find bosses within the discoverable range. if (DragonUtil.hasTagOrGroup(entity, BOSS_TAG)) { @@ -454,43 +588,22 @@ protected void discoverFightState() { } } log("Discovered bosses: " + _bosses.size()); + log("Discovered crystals: " + _crystals.size()); - if (battle.getEnderDragon() != null) { - log("Dragon " + battle.getEnderDragon().getUniqueId() + " exists."); - } else { - log("No dragon exists."); + if (getStageNumber() < 0 || getStageNumber() > 11) { + log("Stage number " + getStageNumber() + " is out of bounds. Setting to 0."); + immediatelyChangeStageNumber(0); } - // Work out what stage we're in. - // The dragon can randomly despawn. Make a best guess. - // Eventually will rewrite to load from config. - if (battle.getEnderDragon() == null && _bosses.isEmpty() && _crystals.isEmpty()) { - log("No dragon, bosses or pillar crystals. Guess stage 0."); - _stageNumber = 0; - } else { - // We hope that vanilla code respawns the dragon at some point. - _stageNumber = 10 - _crystals.size(); - log("Intial guess stage " + _stageNumber); - } - - // A restart during the stage start spawn sequence can leave us - // without bosses. - if (battle.getEnderDragon() != null && _bosses.isEmpty()) { - if (_stageNumber == 0) { - // We have a dragon and 10 pillar crystals. Start stage 1. - log("Restarting stage 1 spawn sequence after restart."); - nextStage(); - } else if (_stageNumber < 10) { // _stageNumber 1-9 - // 1 - 9 pillar crystals and no bosses. - log("Restarting stage " + (_stageNumber + 1) + " spawn sequence after restart."); - nextStage(); - } else if (_stageNumber == 10) { - // We have a dragon, 0 pillar crystals and no bosses. - // The difference between stage 10 and 11 is that in 11 there - // are no boss mobs. - // Show titles again. Make dragon vulnerable. - log("In stage " + 11 + "."); - startStage11(); + log("Current stage: " + getStageNumber()); + if (isStageNumberChanging()) { + log("Transitioning from stage " + getStageNumber() + " to stage " + getNewStageNumber()); + if (getNewStageNumber() != getStageNumber() + 1) { + log("Too many skipped stages! Going direct to " + getNewStageNumber() + "."); + startStage(null, getNewStageNumber(), getNewStageNumber() == 11 ? null : getBossSpawnLocation()); + } else { + // Normal path... show boss spawn animations. + animateNextStage(); } } } @@ -675,11 +788,10 @@ protected void onPlayerLogin(PlayerLoginEvent event) { * * Track any mobs that spawn with the boss group. * - * Handle end crystals spawns during the pillar-summoning phase only with - * {@link #onPillarCrystalSpawn(EnderCrystal)}. + * @param event the event. */ @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) - protected void onEntitySpawn(EntitySpawnEvent event) { + protected void onCreatureSpawn(CreatureSpawnEvent event) { Location loc = event.getLocation(); World world = loc.getWorld(); if (!DragonUtil.isFightWorld(world)) { @@ -687,22 +799,38 @@ protected void onEntitySpawn(EntitySpawnEvent event) { } Entity entity = event.getEntity(); - if (entity instanceof EnderDragon) { - // Tag dragon as fight entity so its projectiles inherit that tag. - entity.getScoreboardTags().add(ENTITY_TAG); - onDragonSpawn((EnderDragon) entity); - } - // Track the bosses. - if (entity instanceof LivingEntity && DragonUtil.hasTagOrGroup(entity, BOSS_TAG)) { + // Track the bosses. These might be dragons, but are not THE dragon. + if (DragonUtil.hasTagOrGroup(entity, BOSS_TAG)) { LivingEntity boss = (LivingEntity) entity; MobType bossMobType = BeastMaster.getMobType(boss); log("Boss spawned: " + bossMobType.getId()); _bosses.add(boss); DragonFight.CONFIG.TOTAL_BOSS_MAX_HEALTH += boss.getMaxHealth(); + } else { + // Pass vanilla (DragonBattle) dragon spawns (not plugin spawns) to + // onDragonSpawn(). + SpawnReason reason = event.getSpawnReason(); + if (reason == SpawnReason.DEFAULT && entity instanceof EnderDragon) { + onDragonSpawn((EnderDragon) entity, reason); + } + } + } + + // ------------------------------------------------------------------------ + /** + * Handle end crystals spawns during the pillar-summoning phase only with + * {@link #onPillarCrystalSpawn(EnderCrystal)}. + */ + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + protected void onEntitySpawn(EntitySpawnEvent event) { + Location loc = event.getLocation(); + World world = loc.getWorld(); + if (!DragonUtil.isFightWorld(world)) { + return; } - DragonBattle battle = world.getEnderDragonBattle(); + Entity entity = event.getEntity(); if (entity instanceof EnderCrystal) { // Make the summoning crystals glowing and invulnerable to prevent // the player from initiating the fight and then destroying the @@ -714,6 +842,7 @@ protected void onEntitySpawn(EntitySpawnEvent event) { } // Register and protect crystals spawned on the pillars. + DragonBattle battle = world.getEnderDragonBattle(); if (battle.getRespawnPhase() == RespawnPhase.SUMMONING_PILLARS && DragonUtil.isOnPillarCircle(entity.getLocation())) { onPillarCrystalSpawn((EnderCrystal) entity); @@ -781,7 +910,12 @@ protected void onProjectileLaunch(ProjectileLaunchEvent event) { // ------------------------------------------------------------------------ /** * Protect the End Crystals on the pillars and the four crystals that spawn - * the dragon. + * the dragon, and protect the dragon in stages 1 to 10. + * + * You might think that you could just call setInvulnerable(true) on the + * dragon to make it invulnerable. Perhaps in a periodic task like the + * {@link FightState#Tracker}. You would be wrong; having setInvulnerable() + * work as advertised is just wishful thinking. * * I had some problems with setInvulnerable(); it doesn't just work * on the 10 pillar crystals. If you think you can just set them @@ -806,7 +940,12 @@ public void onEntityDamageEarly(EntityDamageEvent event) { return; } - if (entity instanceof EnderCrystal) { + if (entity instanceof EnderDragon && getStageNumber() < 11) { + DragonBattle battle = DragonUtil.getFightWorld().getEnderDragonBattle(); + if (entity == battle.getEnderDragon()) { + event.setCancelled(true); + } + } else if (entity instanceof EnderCrystal) { if (_crystals.contains(entity) || DragonUtil.hasTagOrGroup(entity, PILLAR_CRYSTAL_TAG) || DragonUtil.hasTagOrGroup(entity, SPAWNING_CRYSTAL_TAG)) { @@ -911,8 +1050,6 @@ protected void onEntityDeath(EntityDeathEvent event) { if (event.getEntity() instanceof EnderDragon) { onDragonDeath((EnderDragon) event.getEntity()); - // TODO: clean up associated entities? - _stageNumber = 0; return; } @@ -923,13 +1060,9 @@ protected void onEntityDeath(EntityDeathEvent event) { log("Boss died: " + bossMobType.getId()); } - if (_stageNumber != 0 && bossDied && _bosses.isEmpty()) { - if (_stageNumber == 10) { - // Just the dragon in stage 11. - startStage11(); - } else { - nextStage(); - } + // The fight is going and all the bosses are dead. Next stage! + if (isFightHappening() && bossDied && _bosses.isEmpty()) { + animateNextStage(); } } @@ -1060,35 +1193,32 @@ protected void onPillarCrystalSpawn(EnderCrystal crystal) { // ------------------------------------------------------------------------ /** - * When the dragon spawns, schedule the start of phase 1. + * This method responds to an ender dragon spawning. * - * We need to be careful about badly-timed restarts (as always). Since we - * detect phases on reload by the absence of crystals, remove the crystal - * before adding the boss. + * At the time this method is called, the dragon entity is not yet + * associated with the Bukkit DragonBattle instance, because we're executing + * in the context of a CreatureSpawnEvent, which is effectively a + * synchronous function call from World.spawn(). We would need to wait for 1 + * tick if we needed to access the dragon through the DragonBattle's methods + * (note the called methods too). * - * Experimentation reveals that at the time the dragon spawns, the spawning - * crystals still exist and the RespawnPhase is NONE. + * @param dragon the dragon. + * @param reason the reason for spawning. */ - protected void onDragonSpawn(EnderDragon dragon) { - log("Dragon " + dragon.getUniqueId() + " spawned."); + protected void onDragonSpawn(EnderDragon dragon, SpawnReason reason) { + log("Dragon spawned (" + reason + "): " + dragon.getUniqueId()); + + // Tag dragon as fight entity so its projectiles inherit that tag. + dragon.getScoreboardTags().add(ENTITY_TAG); + + // Doesn't do anything. See onEntityDamageEarly(). + dragon.setInvulnerable(true); // Remove surplus dragons after this one is added to the world. Bukkit.getScheduler().runTaskLater(DragonFight.PLUGIN, () -> removeSurplusDragons(), 2); - // debug("Dragon spawned. Spawning crystals: " + - // getDragonSpawnCrystals()); - // debug("Respawn phase: " + - if (_crystals.size() == 0) { - // Observed once in testing. Don't set invulnerable. - log("Dragon spawned but there were no ender crystals?!"); - } else { - // The dragon is invulnerable for stages 1 through 10. - // NOTE: creative mode trumps invulnerability. - dragon.setInvulnerable(true); - } - // Setting the crystals invulnerable before the dragon spawns does not - // work. But Minecraft prevents them from being da maged. + // work. But Minecraft prevents them from being damaged. // Cannot set them invulnerable this tick either. for (EnderCrystal crystal : _crystals) { Bukkit.getScheduler().runTaskLater(DragonFight.PLUGIN, @@ -1096,22 +1226,8 @@ protected void onDragonSpawn(EnderDragon dragon) { } reconfigureDragonBossBar(); - // Since extra dragons can spawn randomly mid-fight, we should only - // advance to the next stage if there are no bosses. - if (_stageNumber == 0 && _bosses.isEmpty()) { - log("Stage 0, no bosses, new dragon."); - // We may have previously inferred the stage to be 0 based on an - // absence of the dragon, crystals and bosses. if there are 10 - // crystals and no bosses, we're going from stage 0 to stage 1. - if (_crystals.size() == 10) { - log("All 10 crystals, no bosses, new dragon. Go go to stage 1."); - nextStage(); - } else if (_crystals.size() == 0) { - log("No crystals, no bosses, new dragon. Must be in stage 11."); - startStage11(); - } - } else { - log("Stay in this stage. Bosses: " + _bosses.size() + ", Crystals: " + _crystals.size()); + if (getStageNumber() == 0) { + animateNextStage(); } } @@ -1129,11 +1245,16 @@ protected void onDragonSpawn(EnderDragon dragon) { protected void onDragonDeath(EnderDragon dragon) { log("The dragon died."); - if (_stageNumber != 11) { + if (getStageNumber() != 11) { log("But we're not in stage 11, so it doesn't count."); return; } + // In stage 11, the dragon died. Signify that the fight is over before + // saving the config. + immediatelyChangeStageNumber(0); + // TODO: clean up associated entities? + if (DragonFight.CONFIG.FIGHT_OWNER == null) { log("But nobody owns the fight, so it doesn't count."); return; @@ -1184,7 +1305,7 @@ protected void onDragonDeath(EnderDragon dragon) { * @param prizes the list of items to give. * @return true if all prizes could fit; false if they were deferred. */ - protected static boolean givePrizes(Player player, List prizes) { + public static boolean givePrizes(Player player, List prizes) { if (prizes.size() == 0) { log("No dragon drops have been configured!"); return true; @@ -1223,7 +1344,7 @@ protected static boolean givePrizes(Player player, List prizes) { * * @return a list of ItemStacks. */ - protected static List generatePrizes() { + public static List generatePrizes() { ArrayList prizes = new ArrayList<>(); DropSet dropSet = BeastMaster.LOOTS.getDropSet("df-dragon-drops"); Drop drop = dropSet.chooseOneDrop(true); @@ -1304,12 +1425,22 @@ protected void reconfigureDragonBossBar() { // ------------------------------------------------------------------------ /** - * Start the next stage. + * Start the next boss stage by showing the boss spawn animations. * * This method is called to do the boss spawning sequence for stages 1 to * 10. */ - protected void nextStage() { + protected void animateNextStage() { + setNewStageNumber(getStageNumber() + 1); + + // When going to stage 11 (dragon only) there are no bosses to spawn. + // Just call startStage() and return. + if (getNewStageNumber() == 11) { + startStage(null, getNewStageNumber(), null); + return; + } + + // From here forward, getNewStageNumber() is between 1 and 10. // Remove a random crystal. Random order due to hashing UUID. EnderCrystal replacedCrystal = _crystals.iterator().next(); @@ -1344,11 +1475,11 @@ protected void nextStage() { playSound(bossSpawnLocation, Sound.BLOCK_BEACON_ACTIVATE); }, STAGE_START_DELAY * 80 / 100); - // Remove the crystal and spawn the boss. Only called for stage 1-10. + // Remove the crystal and spawn the boss. Bukkit.getScheduler().scheduleSyncDelayedTask(DragonFight.PLUGIN, () -> { _crystals.remove(replacedCrystal); replacedCrystal.remove(); - startStage(null, _stageNumber + 1, bossSpawnLocation); + startStage(null, getNewStageNumber(), bossSpawnLocation); }, STAGE_START_DELAY); } @@ -1373,23 +1504,29 @@ protected void despawnPillarCrystals(int count) { /** * Begin the specified boss stage (1 to 11 only). * + * In the case of stages 1 through 10, the boss mob is spawned. + * * @param sender the command sender for messages, or null if * unused. - * @param stageNumber the stage number from 1 to 10. + * @param stageNumber the stage number from 1 to 11. * @param bossSpawnLocation the location where the bosses are spawned. */ protected void startStage(CommandSender sender, int stageNumber, Location bossSpawnLocation) { + // The new stage number will (in many but not all cases) have already + // been set to stageNumber arg. Ensure that all stage numbers are in + // sync. + immediatelyChangeStageNumber(stageNumber); + if (sender != null) { sender.sendMessage(ChatColor.DARK_PURPLE + "Starting stage: " + ChatColor.LIGHT_PURPLE + stageNumber); } - _stageNumber = stageNumber; - Stage stage = DragonFight.CONFIG.getStage(_stageNumber); - log("Beginning stage: " + _stageNumber); + Stage stage = DragonFight.CONFIG.getStage(getStageNumber()); + log("Beginning stage: " + getStageNumber()); // Spawn boss or bosses. Only valid in stages 1 to 10. - if (_stageNumber >= 1 && _stageNumber <= 10) { - DragonFight.CONFIG.TOTAL_BOSS_MAX_HEALTH = 0; + DragonFight.CONFIG.TOTAL_BOSS_MAX_HEALTH = 0; + if (getStageNumber() >= 1 && getStageNumber() <= 10) { DropResults results = new DropResults(); DropSet dropSet = BeastMaster.LOOTS.getDropSet(stage.getDropSetId()); if (dropSet != null) { @@ -1412,14 +1549,6 @@ protected void startStage(CommandSender sender, int stageNumber, Location bossSp stage.announce(nearby); } - // ------------------------------------------------------------------------ - /** - * Show the stage 11 titles and set the dragon vulnerable again. - */ - protected void startStage11() { - startStage(null, 11, null); - } - // ------------------------------------------------------------------------ /** * Choose a random location to spawn the boss with a 3x3x3 volume of air @@ -1506,7 +1635,7 @@ protected Set getNearbyPlayers() { * some test bosses. */ protected void updateBossBar() { - if (_stageNumber >= 1 && _stageNumber <= 11) { + if (getStageNumber() >= 1 && getStageNumber() <= 11) { // Vanilla/Bukkit code reuses the dragon boss bar across fights, so // set its colour across all stages. Stage stage = DragonFight.CONFIG.getStage(11); @@ -1514,7 +1643,7 @@ protected void updateBossBar() { battle.getBossBar().setColor(stage.getBarColor()); } - if (_bosses.size() == 0 || _stageNumber < 1 || _stageNumber > 10) { + if (_bosses.size() == 0 || getStageNumber() < 1 || getStageNumber() > 10) { if (_bossBar != null) { _bossBar.setVisible(false); } @@ -1522,7 +1651,7 @@ protected void updateBossBar() { } // We have a non-zero number of bosses. Stage 1 to 10. Show the bar. - Stage stage = DragonFight.CONFIG.getStage(_stageNumber); + Stage stage = DragonFight.CONFIG.getStage(getStageNumber()); if (_bossBar == null) { _bossBar = Bukkit.createBossBar("", BarColor.WHITE, BarStyle.SOLID, new BarFlag[0]); } @@ -1599,7 +1728,8 @@ protected void removeSurplusDragons() { } if (retainedDragon != null) { - retainedDragon.setInvulnerable(_stageNumber != 11); + // Doesn't do anything. See onEntityDamageEarly(). + retainedDragon.setInvulnerable(getStageNumber() != 11); } } @@ -1688,12 +1818,16 @@ public void run() { private static final String ENTITY_TAG = "df-entity"; /** - * Group of summoned boss mobs on spawn. + * Group tag of summoned boss mobs on spawn. + * + * Note that the final boss enderdragon is not allowed to have this tag. + * Note also that per-stage boss mobs can be enderdragons, but they are not + * the final boss. */ private static final String BOSS_TAG = "df-boss"; /** - * Group of support mobs summoned by bosses. + * Group tag of support mobs summoned by bosses. */ private static final String SUPPORT_TAG = "df-support"; @@ -1771,16 +1905,6 @@ public void run() { */ protected HashSet _bosses = new HashSet<>(); - /** - * Current stage number. - * - * in Stage N, N crystals have been removed. - * - * Stage 0 is before the fight, Stage 1 => first crystal removed and boss - * spawned. Stage 10 => final boss spawned. Stage 11: dragon. - */ - int _stageNumber; - /** * Tracks mobs to enforce boundaries and update boss bars. */ diff --git a/src/main/java/nu/nerd/df/commands/DragonExecutor.java b/src/main/java/nu/nerd/df/commands/DragonExecutor.java index 0c83e26..e415c0c 100644 --- a/src/main/java/nu/nerd/df/commands/DragonExecutor.java +++ b/src/main/java/nu/nerd/df/commands/DragonExecutor.java @@ -1,11 +1,19 @@ package nu.nerd.df.commands; +import java.util.List; +import java.util.UUID; + +import org.bukkit.Bukkit; import org.bukkit.ChatColor; +import org.bukkit.OfflinePlayer; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; import nu.nerd.beastmaster.commands.ExecutorBase; import nu.nerd.df.DragonFight; +import nu.nerd.df.FightState; // ---------------------------------------------------------------------------- /** @@ -32,7 +40,7 @@ public boolean onCommand(CommandSender sender, Command command, String label, St } if (args.length == 1 && args[0].equalsIgnoreCase("info")) { - DragonFight.FIGHT.cmdPlayerInfo(sender); + cmdInfo(sender); return true; } @@ -40,11 +48,69 @@ public boolean onCommand(CommandSender sender, Command command, String label, St if (!isInGame(sender)) { return true; } - DragonFight.FIGHT.cmdDragonPrize(sender); + cmdDragonPrize(sender); return true; } sender.sendMessage(ChatColor.RED + "Invalid arguments. Try /" + command.getName() + " help."); return true; } + + // ------------------------------------------------------------------------ + /** + * Implement the /dragon info command. + * + * This command is for ordinary players to check the fight status. + */ + public void cmdInfo(CommandSender sender) { + if (!DragonFight.FIGHT.isFightHappening() && !DragonFight.FIGHT.isStageNumberChanging()) { + sender.sendMessage(ChatColor.DARK_PURPLE + "Nobody is fighting the dragon right now."); + } else { + sender.sendMessage(ChatColor.DARK_PURPLE + "The current fight stage is " + + ChatColor.LIGHT_PURPLE + DragonFight.FIGHT.getStageNumber() + + ChatColor.DARK_PURPLE + "."); + if (DragonFight.CONFIG.FIGHT_OWNER == null) { + sender.sendMessage(ChatColor.DARK_PURPLE + "But mysteriously, nobody owns the final drops. :/"); + } else { + OfflinePlayer fightOwner = Bukkit.getOfflinePlayer(DragonFight.CONFIG.FIGHT_OWNER); + sender.sendMessage(ChatColor.DARK_PURPLE + "The final drops are owned by " + + ChatColor.LIGHT_PURPLE + fightOwner.getName() + ChatColor.DARK_PURPLE + "."); + } + } + } + + // ------------------------------------------------------------------------ + /** + * Implement the /dragon prize command. + * + * @param sender the command sender, for message sending. + */ + public void cmdDragonPrize(CommandSender sender) { + Player player = (Player) sender; + UUID playerUuid = player.getUniqueId(); + + int unclaimed = DragonFight.CONFIG.getUnclaimedPrizes(playerUuid); + if (unclaimed <= 0) { + sender.sendMessage(ChatColor.DARK_PURPLE + "You don't have any unclaimed dragon prizes."); + } else { + List prizes = FightState.generatePrizes(); + if (FightState.givePrizes(player, prizes)) { + DragonFight.CONFIG.incUnclaimedPrizes(playerUuid, -1); + DragonFight.CONFIG.save(); + + if (--unclaimed > 0) { + String prizeCount = ChatColor.LIGHT_PURPLE + Integer.toString(unclaimed) + + ChatColor.DARK_PURPLE + " unclaimed prize" + (unclaimed > 1 ? "s" : ""); + sender.sendMessage(ChatColor.DARK_PURPLE + "You still have " + prizeCount + "."); + } + } else { + // The item(s) did not fit. + String slots = ChatColor.LIGHT_PURPLE + Integer.toString(prizes.size()) + + ChatColor.DARK_PURPLE + " inventory slot" + (prizes.size() > 1 ? "s" : ""); + player.sendMessage(ChatColor.DARK_PURPLE + "You need at least " + slots + " empty."); + player.sendMessage(ChatColor.DARK_PURPLE + "Make some room in your inventory and try again."); + } + } + } + } // class DragonExecutor \ No newline at end of file diff --git a/src/main/java/nu/nerd/df/commands/DragonFightExecutor.java b/src/main/java/nu/nerd/df/commands/DragonFightExecutor.java index f459ff9..ceff269 100644 --- a/src/main/java/nu/nerd/df/commands/DragonFightExecutor.java +++ b/src/main/java/nu/nerd/df/commands/DragonFightExecutor.java @@ -32,7 +32,7 @@ public boolean onCommand(CommandSender sender, Command command, String label, St } if (args.length == 1 && args[0].equalsIgnoreCase("reload")) { - DragonFight.CONFIG.reload(); + DragonFight.CONFIG.reloadConfiguration(); sender.sendMessage(ChatColor.DARK_PURPLE + "DragonFight configuration reloaded."); return true; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 36af87d..329837a 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -3,6 +3,8 @@ settings: debug-prefix: '&5[DragonFight &fDBG&5]&f' state: + stage-number: 0 + new-stage-number: 0 total-boss-max-health: 0 fight-owner: unclaimed-prizes: {} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 288085d..3239f25 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -5,7 +5,7 @@ authors: [] description: ${project.description} website: ${project.url} main: nu.nerd.df.DragonFight -api-version: 1.15 +api-version: 1.18 depend: [BeastMaster] permissions: