From ac5bb77db722a6c36192e46ae93242c4d99fe589 Mon Sep 17 00:00:00 2001 From: Moussa Date: Fri, 2 Oct 2026 08:39:55 +0200 Subject: [PATCH 01/13] refactor(entities): modernize architecture and replace cloneable prototypes with factories Replace Cloneable prototypes with factory suppliers and createInstance() to eliminate mutable state bleed across world instances. Decompose the BlightedEntity orchestrator into dedicated subsystem managers and fluent builders for boss bars, equipment, defenses, phases, attributes, and attachments, and consolidate registry lookups into EntitiesRegistry. --- .../commands/impl/ButcherCommand.java | 4 +- .../commands/impl/SpawnCustomMobCommand.java | 6 +- .../entities/bosses/CorruptedChampion.java | 63 +- .../BlightswornArcherArchetype.java | 36 +- .../blightsworn/BlightswornBogged.java | 7 +- .../BlightswornBruteArchetype.java | 38 +- .../blightsworn/BlightswornCreature.java | 30 +- .../blightsworn/BlightswornDrowned.java | 41 +- .../BlightswornEliteArchetype.java | 43 +- .../factions/blightsworn/BlightswornHusk.java | 4 +- .../blightsworn/BlightswornParched.java | 6 +- .../blightsworn/BlightswornPiglin.java | 6 +- .../blightsworn/BlightswornSkeleton.java | 6 +- .../blightsworn/BlightswornStray.java | 6 +- .../BlightswornWitherSkeleton.java | 6 +- .../blightsworn/BlightswornZombie.java | 4 +- .../BlightswornZombifiedPiglin.java | 6 +- .../factions/celestial/CelestialCreature.java | 10 +- .../content/entities/powerful/Endersent.java | 60 +- .../content/entities/powerful/Illusioner.java | 13 +- .../content/entities/powerful/Watchling.java | 46 +- .../engine/entities/BlightedEntity.java | 1212 +++++++++-------- .../engine/entities/EntityManager.java | 15 +- .../attachment/EntityAttachmentManager.java | 401 ++++++ .../engine/entities/boss/BossBarBuilder.java | 50 + .../boss/EntityBossBarController.java | 197 +++ .../components/EntityComponentManager.java | 150 ++ .../entities/defense/DefensesBuilder.java | 41 + .../equipment/EntityEquipmentBuilder.java | 170 +++ .../equipment/EntityEquipmentHolder.java | 366 +++++ .../listeners/BlightedEntitiesListener.java | 66 +- .../listeners/EntityComponentListener.java | 2 +- .../listeners/SpawnableEntitiesListener.java | 8 +- .../entities/phases/EntityPhaseManager.java | 193 +++ .../entities/phases/EntityPhasesBuilder.java | 30 + .../{ => phases}/LifecycleTaskManager.java | 76 +- .../entities/registry/EntitiesRegistry.java | 175 ++- .../registry/SpawnableEntitiesRegistry.java | 51 - .../entities/rituals/AncientCreature.java | 92 +- .../rituals/menu/AncientRitualsMenu.java | 2 - .../rituals/menu/RitualAltarMenu.java | 5 +- .../entities/spawnable/SpawnableEntity.java | 151 +- .../spawnable/engine/BlightedSpawnEngine.java | 6 +- .../state/EntityAttributeManager.java | 195 +++ .../state/EntityAttributesBuilder.java | 182 +++ .../entities/state/EntityCooldowns.java | 100 ++ .../engine/entities/util/EntityEffects.java | 168 +++ .../entities/util/EntitySpatialQueries.java | 178 +++ .../engine/loot/results/EntityResult.java | 2 +- .../engine/player/PlayerListener.java | 6 +- .../engine/quest/BlightedQuestListener.java | 4 +- .../blightedSMP/server/BlightedServer.java | 4 +- 52 files changed, 3478 insertions(+), 1261 deletions(-) create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/attachment/EntityAttachmentManager.java create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/boss/BossBarBuilder.java create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/boss/EntityBossBarController.java create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/components/EntityComponentManager.java create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/defense/DefensesBuilder.java create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/equipment/EntityEquipmentBuilder.java create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/equipment/EntityEquipmentHolder.java create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhaseManager.java create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhasesBuilder.java rename blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/{ => phases}/LifecycleTaskManager.java (52%) delete mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/SpawnableEntitiesRegistry.java create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributeManager.java create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributesBuilder.java create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldowns.java create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/util/EntityEffects.java create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/util/EntitySpatialQueries.java diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/ButcherCommand.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/ButcherCommand.java index edb8cb8f..763b746d 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/ButcherCommand.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/ButcherCommand.java @@ -2,7 +2,7 @@ import fr.moussax.blightedSMP.commands.AdminCommand; import fr.moussax.blightedSMP.engine.entities.BlightedEntity; -import fr.moussax.blightedSMP.engine.entities.listeners.BlightedEntitiesListener; +import fr.moussax.blightedSMP.engine.entities.EntityManager; import org.bukkit.Location; import org.bukkit.command.Command; import org.bukkit.entity.LivingEntity; @@ -75,7 +75,7 @@ private int butcher(Player player, double radius) { } private void removeEntity(LivingEntity entity) { - BlightedEntity blighted = BlightedEntitiesListener.getBlightedEntity(entity); + BlightedEntity blighted = EntityManager.getBlightedEntity(entity); if (blighted != null) { blighted.cleanup(); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/SpawnCustomMobCommand.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/SpawnCustomMobCommand.java index e1842270..f74f9ac0 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/SpawnCustomMobCommand.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/SpawnCustomMobCommand.java @@ -20,15 +20,13 @@ protected boolean executeAdmin(Player player, Command command, String label, Str return false; } - BlightedEntity prototype = EntitiesRegistry.get(args[0].toUpperCase()); + BlightedEntity entity = EntitiesRegistry.create(args[0].toUpperCase()); - if (prototype == null) { + if (entity == null) { warn(player, "Unable to find §4" + args[0].toUpperCase() + " §cinto the registry."); return false; } - BlightedEntity entity = prototype.clone(); - if (entity instanceof AncientCreature ancientCreature) { ancientCreature.setSummoner(player); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/bosses/CorruptedChampion.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/bosses/CorruptedChampion.java index a19f5885..157b3647 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/bosses/CorruptedChampion.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/bosses/CorruptedChampion.java @@ -51,22 +51,24 @@ public class CorruptedChampion extends BlightedEntity { private int currentPhase = 1; public CorruptedChampion() { - super("Corrupted Champion", 350, MELEE_DAMAGE, EntityType.ZOMBIE); - addAttribute(Attribute.SCALE, 4.0); - addAttribute(Attribute.SPAWN_REINFORCEMENTS, 0.0); - addAttribute(Attribute.KNOCKBACK_RESISTANCE, 0.40); - setBoss(true); - - armor = new ItemStack[]{ - new ItemStack(Material.GOLDEN_BOOTS), - new ItemStack(Material.IRON_LEGGINGS), - new ItemBuilder(Material.GOLDEN_CHESTPLATE).setArmorTrim(TrimMaterial.GOLD, TrimPattern.SNOUT).toItemStack(), - new ItemBuilder(Material.PLAYER_HEAD) + super("CORRUPTED_CHAMPION", "Corrupted Champion", EntityType.ZOMBIE); + attributes(attributes -> attributes + .maxHealth(350) + .attackDamage(MELEE_DAMAGE) + .scale(4.0) + .knockbackResistance(0.40) + .set(Attribute.SPAWN_REINFORCEMENTS, 0.0) + ); + boss(); + equipment(equipment -> equipment + .helmet(new ItemBuilder(Material.PLAYER_HEAD) .setCustomSkullTexture("eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMWFiNTRjMWNlOTQyYzIzMjFkNmRiYjgzMjQ1ZWJmM2ZmZDY5NmJmOWQxZjAyNDY3MzY0NzFmNjdmNzJiYTI1MCJ9fX0=") - .toItemStack() - }; - - itemInMainHand = new ItemBuilder(Material.NETHERITE_SWORD).glow().toItemStack(); + .toItemStack()) + .chestplate(new ItemBuilder(Material.GOLDEN_CHESTPLATE).setArmorTrim(TrimMaterial.GOLD, TrimPattern.SNOUT).toItemStack()) + .leggings(Material.IRON_LEGGINGS) + .boots(Material.GOLDEN_BOOTS) + .mainHand(new ItemBuilder(Material.NETHERITE_SWORD).glow().toItemStack()) + ); } private static ItemStack createRocketBoots() { @@ -137,7 +139,7 @@ protected long onPhaseTransition(double healthThreshold) { return 0L; } - setBootsEquipped(armor != null && armor.length > 0 ? armor[0] : new ItemStack(Material.GOLDEN_BOOTS)); + setBoots(new ItemStack(Material.GOLDEN_BOOTS)); if (healthThreshold == 0.66) { currentPhase = 2; @@ -277,7 +279,7 @@ private void executeStomp() { } setPerformingAbility(true); - setBootsEquipped(RED_BOOTS); + setBoots(RED_BOOTS); if (entity instanceof Mob mob) { mob.setAI(false); @@ -330,7 +332,7 @@ public void run() { private void cleanup() { cancel(); - setBootsEquipped(armor != null && armor.length > 0 ? armor[0] : new ItemStack(Material.GOLDEN_BOOTS)); + setBoots(new ItemStack(Material.GOLDEN_BOOTS)); if (entity instanceof Mob mob) { mob.setAI(true); } @@ -724,9 +726,9 @@ public Giant spawnInvertedSword(Location spawnLocation, double scale) { Location targetLocation = spawnLocation.clone(); targetLocation.setPitch(0); targetLocation.setYaw(spawnLocation.getYaw()); - Giant giant = Objects.requireNonNull(spawnLocation.getWorld()).spawn(targetLocation, Giant.class, g -> { - configureSwordGiant(g, scale); - g.setCustomName("Dinnerbone"); + Giant giant = Objects.requireNonNull(spawnLocation.getWorld()).spawn(targetLocation, Giant.class, giantEntity -> { + configureSwordGiant(giantEntity, scale); + giantEntity.setCustomName("Dinnerbone"); } ); return giant; @@ -751,13 +753,6 @@ private void configureSwordGiant(Giant giant, double scale) { Objects.requireNonNull(giant.getEquipment()).setItemInMainHand(new ItemBuilder(Material.NETHERITE_SWORD).glow().toItemStack()); } - private void setBootsEquipped(ItemStack bootsItem) { - if (entity == null || entity.getEquipment() == null) { - return; - } - entity.getEquipment().setBoots(bootsItem); - } - private boolean tryStartAbility() { if (isPerformingAbility()) { return false; @@ -770,18 +765,6 @@ private void endAbility() { setPerformingAbility(false); } - @Override - public String getEntityId() { - return "CORRUPTED_CHAMPION"; - } - - @Override - public CorruptedChampion clone() { - CorruptedChampion clone = (CorruptedChampion) super.clone(); - clone.activeStabs = new CopyOnWriteArrayList<>(); - return clone; - } - @Override public LivingEntity spawn(Location location) { super.spawn(location); diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornArcherArchetype.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornArcherArchetype.java index 5cb3e141..0f35fccc 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornArcherArchetype.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornArcherArchetype.java @@ -165,29 +165,21 @@ private boolean isSafeEvasionDirection(Vector direction) { @Override protected void equipEnragedArmor(EntityEquipment equipment) { - if (equipment == null) return; - - equipment.setHelmet(new ItemBuilder(Material.LEATHER_HELMET) - .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.SENTRY) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() - ); - equipment.setChestplate(new ItemBuilder(Material.LEATHER_CHESTPLATE) - .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.SENTRY) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() + setArmor( + new ItemBuilder(Material.LEATHER_HELMET) + .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.SENTRY) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.LEATHER_CHESTPLATE) + .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.SENTRY) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack(), + new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack() ); - equipment.setLeggings(new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack()); - equipment.setBoots(new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack()); } - @Override - public BlightswornArcherArchetype clone() { - BlightswornArcherArchetype clone = (BlightswornArcherArchetype) super.clone(); - clone.isAiming = false; - clone.nextScatterTick = 0; - return clone; - } + } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBogged.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBogged.java index 91e70f1a..6c858f45 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBogged.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBogged.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; @@ -24,8 +23,8 @@ public BlightswornBogged() { super("BLIGHTSWORN_BOGGED", "Blightsworn Bogged", EntityType.BOGGED); setDamage(6); setDroppedExp(12); - itemInMainHand = new ItemStack(Material.BOW); - setLootTable(new EntityLootTableBuilder() + setItemInMainHand(new ItemStack(Material.BOW)); + loot(table -> table .addLoot(Material.BONE, 2, 4, 1.0) .addLoot(Material.ARROW, 2, 5, 1.0) .addLoot(Material.TIPPED_ARROW, @@ -37,7 +36,7 @@ public BlightswornBogged() { ) .addDamagedItem(Material.BOW, 0.10, 0.75, 0.15, RARE) .addGems(5, 0.04, VERY_RARE) - .build()); + ); } @Override diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBruteArchetype.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBruteArchetype.java index bbcceee9..f27024c4 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBruteArchetype.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBruteArchetype.java @@ -156,31 +156,19 @@ private void spawnBlightResidue() { @Override protected void equipEnragedArmor(EntityEquipment equipment) { - if (equipment == null) return; - - equipment.setHelmet(new ItemBuilder(Material.LEATHER_HELMET) - .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.FLOW) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() - ); - equipment.setChestplate(new ItemBuilder(Material.LEATHER_CHESTPLATE) - .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.SNOUT) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() + setArmor( + new ItemBuilder(Material.LEATHER_HELMET) + .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.FLOW) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.LEATHER_CHESTPLATE) + .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.SNOUT) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack(), + new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack() ); - equipment.setLeggings(new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack()); - equipment.setBoots(new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack()); - } - - @Override - public BlightswornBruteArchetype clone() { - BlightswornBruteArchetype clone = (BlightswornBruteArchetype) super.clone(); - clone.blightStacks = 0; - clone.isSurging = false; - clone.isRecovering = false; - clone.isPhaseTwo = false; - return clone; } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornCreature.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornCreature.java index 1f34809d..c3e88f15 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornCreature.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornCreature.java @@ -1,6 +1,6 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.listeners.BlightedEntitiesListener; +import fr.moussax.blightedSMP.engine.entities.EntityManager; import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; import fr.moussax.bedrock.utils.ItemBuilder; import lombok.Getter; @@ -40,7 +40,9 @@ protected BlightswornCreature(String entityId, String name, EntityType entityTyp } protected BlightswornCreature(String entityId, String name, EntityType entityType, int maxHealth, double spawnProbability) { - super(entityId, name, maxHealth, entityType, spawnProbability); + super(entityId, name, entityType); + setMaxHealth(maxHealth); + setSpawnProbability(spawnProbability); setupDefaultArmor(); } @@ -83,18 +85,18 @@ protected void onRehydrate(LivingEntity existing) { protected abstract void equipEnragedArmor(EntityEquipment equipment); private void setupDefaultArmor() { - armor = new ItemStack[]{ - new ItemStack(Material.AIR), - new ItemStack(Material.AIR), - new ItemBuilder(Material.LEATHER_CHESTPLATE) + setArmor( + new ItemBuilder(Material.LEATHER_HELMET) .setLeatherColor(PHASE_ONE_COLOR) .unbreakable() .toItemStack(), - new ItemBuilder(Material.LEATHER_HELMET) + new ItemBuilder(Material.LEATHER_CHESTPLATE) .setLeatherColor(PHASE_ONE_COLOR) .unbreakable() - .toItemStack() - }; + .toItemStack(), + null, + null + ); } private void evaluateResonance() { @@ -103,7 +105,7 @@ private void evaluateResonance() { boolean hasNearbyAlly = false; for (Entity nearby : entity.getNearbyEntities(12, 12, 12)) { - var blighted = BlightedEntitiesListener.getBlightedEntity(nearby); + var blighted = EntityManager.getBlightedEntity(nearby); if (!(blighted instanceof BlightswornCreature ally) || !ally.isAlive()) continue; hasNearbyAlly = true; @@ -145,12 +147,4 @@ public void setResonating(boolean resonating) { baseSpeedBeforeResonance = -1; } } - - @Override - public BlightswornCreature clone() { - BlightswornCreature clone = (BlightswornCreature) super.clone(); - clone.isResonating = false; - clone.baseSpeedBeforeResonance = -1; - return clone; - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornDrowned.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornDrowned.java index d51c9826..8165a3b0 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornDrowned.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornDrowned.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import fr.moussax.bedrock.utils.ItemBuilder; import org.bukkit.GameMode; @@ -36,16 +35,15 @@ public BlightswornDrowned() { super("BLIGHTSWORN_DROWNED", "Blightsworn Drowned", EntityType.DROWNED, MAX_HEALTH); setDamage(6); setDroppedExp(10); - setLootTable(new EntityLootTableBuilder() + loot(table -> table .addLoot(Material.ROTTEN_FLESH, 2, 5, 1.0) .addLoot(Material.COPPER_INGOT, 1, 3, 0.4) .addLoot(Material.NAUTILUS_SHELL, 0.08, RARE) .addDamagedItem(Material.TRIDENT, 0.05, 0.80, 0.02, VERY_RARE) .addGems(5, 0.04, VERY_RARE) - .build() ); - itemInMainHand = new ItemBuilder(Material.TRIDENT).unbreakable().toItemStack(); + setItemInMainHand(new ItemBuilder(Material.TRIDENT).unbreakable().toItemStack()); } @Override @@ -149,22 +147,20 @@ private void eruptGeyser(Location center, boolean isPhaseTwo) { @Override protected void equipEnragedArmor(EntityEquipment equipment) { - if (equipment == null) return; - - equipment.setHelmet(new ItemBuilder(Material.LEATHER_HELMET) - .setArmorTrim(TrimMaterial.DIAMOND, TrimPattern.SILENCE) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() - ); - equipment.setChestplate(new ItemBuilder(Material.LEATHER_CHESTPLATE) - .setArmorTrim(TrimMaterial.DIAMOND, TrimPattern.SILENCE) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() + setArmor( + new ItemBuilder(Material.LEATHER_HELMET) + .setArmorTrim(TrimMaterial.DIAMOND, TrimPattern.SILENCE) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.LEATHER_CHESTPLATE) + .setArmorTrim(TrimMaterial.DIAMOND, TrimPattern.SILENCE) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack(), + new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack() ); - equipment.setLeggings(new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack()); - equipment.setBoots(new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack()); } @Override @@ -182,11 +178,4 @@ protected void defineSpawnConditions() { .and(SpawnRules.notInLiquid().negate()) ); } - - @Override - public BlightswornDrowned clone() { - BlightswornDrowned clone = (BlightswornDrowned) super.clone(); - clone.nextRepelTick = 0; - return clone; - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornEliteArchetype.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornEliteArchetype.java index 65d56963..3ca8a382 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornEliteArchetype.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornEliteArchetype.java @@ -66,42 +66,33 @@ private void triggerBlightWard() { BLIGHT_DUST ); - double wardSpeed = Objects.requireNonNull( - entity.getAttribute(Attribute.MOVEMENT_SPEED)).getBaseValue() * WARD_SPEED_MULTIPLIER; - Objects.requireNonNull(entity.getAttribute(Attribute.MOVEMENT_SPEED)).setBaseValue(wardSpeed); + double baseSpeed = getAttributeValue(Attribute.MOVEMENT_SPEED); + setAttribute(Attribute.MOVEMENT_SPEED, baseSpeed * WARD_SPEED_MULTIPLIER); addCoreDelayedAction(WARD_DURATION_TICKS, () -> { if (!isAlive()) return; isWarded = false; - Objects.requireNonNull(entity.getAttribute(Attribute.MOVEMENT_SPEED)) - .setBaseValue(wardSpeed / WARD_SPEED_MULTIPLIER); + setAttribute(Attribute.MOVEMENT_SPEED, baseSpeed); }); } @Override protected void equipEnragedArmor(EntityEquipment equipment) { - if (equipment == null) return; - - equipment.setHelmet(new ItemBuilder(Material.LEATHER_HELMET) - .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.FLOW) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() - ); - equipment.setChestplate(new ItemBuilder(Material.LEATHER_CHESTPLATE) - .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.FLOW) - .setLeatherColor(PHASE_TWO_COLOR) - .unbreakable() - .toItemStack() + setArmor( + new ItemBuilder(Material.LEATHER_HELMET) + .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.FLOW) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.LEATHER_CHESTPLATE) + .setArmorTrim(TrimMaterial.AMETHYST, TrimPattern.FLOW) + .setLeatherColor(PHASE_TWO_COLOR) + .unbreakable() + .toItemStack(), + new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack(), + new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack() ); - equipment.setLeggings(new ItemBuilder(Material.IRON_LEGGINGS).unbreakable().toItemStack()); - equipment.setBoots(new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack()); } - @Override - public BlightswornEliteArchetype clone() { - BlightswornEliteArchetype clone = (BlightswornEliteArchetype) super.clone(); - clone.isWarded = false; - return clone; - } + } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornHusk.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornHusk.java index 1a977e00..46e60376 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornHusk.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornHusk.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Material; import org.bukkit.block.Biome; @@ -18,12 +17,11 @@ public BlightswornHusk() { super("BLIGHTSWORN_HUSK", "Blightsworn Husk", EntityType.HUSK); setDamage(6); setDroppedExp(12); - setLootTable(new EntityLootTableBuilder() + loot(table -> table .addLoot(Material.ROTTEN_FLESH, 2, 5, 1.0) .addLoot(Material.SAND, 1, 3, 0.3) .addLoot(Material.IRON_INGOT, 1, 2, 0.1, RARE) .addGems(5, 0.04, VERY_RARE) - .build() ); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornParched.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornParched.java index 82bf55f5..33ad65d2 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornParched.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornParched.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; @@ -20,15 +19,14 @@ public final class BlightswornParched extends BlightswornArcherArchetype { public BlightswornParched() { super("BLIGHTSWORN_PARCHED", "Blightsworn Parched", EntityType.PARCHED); - itemInMainHand = new ItemStack(Material.BOW); + setItemInMainHand(new ItemStack(Material.BOW)); setDamage(6); setDroppedExp(12); - setLootTable(new EntityLootTableBuilder() + loot(table -> table .addLoot(Material.BONE, 2, 5, 1.0) .addLoot(Material.ARROW, 2, 5, 1.0) .addDamagedItem(Material.BOW, 0.10, 0.75, 0.15, RARE) .addGems(5, 0.04, VERY_RARE) - .build() ); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornPiglin.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornPiglin.java index 59923163..a81f0e13 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornPiglin.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornPiglin.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; @@ -24,13 +23,12 @@ public final class BlightswornPiglin extends BlightswornEliteArchetype { public BlightswornPiglin() { super("BLIGHTSWORN_PIGLIN", "Blightsworn Piglin", EntityType.PIGLIN); - itemInMainHand = new ItemStack(Material.GOLDEN_SWORD); - setLootTable(new EntityLootTableBuilder() + setItemInMainHand(new ItemStack(Material.GOLDEN_SWORD)); + loot(table -> table .addLoot(Material.GOLD_NUGGET, 2, 6, 1.0) .addLoot(Material.GOLD_INGOT, 1, 3, 0.4) .addDamagedItem(Material.CROSSBOW, 0.10, 0.80, 0.1, RARE) .addGems(5, 0.04, VERY_RARE) - .build() ); setDamage(8); setDroppedExp(16); diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java index 5034ec0e..1c6ddd17 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; @@ -20,15 +19,14 @@ public final class BlightswornSkeleton extends BlightswornArcherArchetype { public BlightswornSkeleton() { super("BLIGHTSWORN_SKELETON", "Blightsworn Skeleton", EntityType.SKELETON); - itemInMainHand = new ItemStack(Material.BOW); + setItemInMainHand(new ItemStack(Material.BOW)); setDamage(6); setDroppedExp(12); - setLootTable(new EntityLootTableBuilder() + loot(table -> table .addLoot(Material.BONE, 2, 5, 1.0) .addLoot(Material.ARROW, 2, 5, 1.0) .addDamagedItem(Material.BOW, 0.1, 0.8, 0.15, RARE) .addGems(5, 0.04, VERY_RARE) - .build() ); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornStray.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornStray.java index 932b8f5f..490032ca 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornStray.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornStray.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Material; import org.bukkit.block.Biome; @@ -16,10 +15,10 @@ public final class BlightswornStray extends BlightswornArcherArchetype { public BlightswornStray() { super("BLIGHTSWORN_STRAY", "Blightsworn Stray", EntityType.STRAY); - itemInMainHand = new ItemStack(Material.BOW); + setItemInMainHand(new ItemStack(Material.BOW)); setDamage(6); setDroppedExp(12); - setLootTable(new EntityLootTableBuilder() + loot(table -> table .maxDrops(4) .addLoot(Material.BONE, 2, 5, 1.0) .addLoot(Material.ARROW, 2, 5, 1.0) @@ -31,7 +30,6 @@ public BlightswornStray() { 0.4 ) .addGems(5, 0.04, VERY_RARE) - .build() ); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornWitherSkeleton.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornWitherSkeleton.java index 4a48cade..b1e7341f 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornWitherSkeleton.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornWitherSkeleton.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; @@ -24,18 +23,17 @@ public final class BlightswornWitherSkeleton extends BlightswornEliteArchetype { public BlightswornWitherSkeleton() { super("BLIGHTSWORN_WITHER_SKELETON", "Blightsworn Wither Skeleton", EntityType.WITHER_SKELETON); - setLootTable(new EntityLootTableBuilder() + loot(table -> table .maxDrops(4) .addLoot(Material.BONE, 2, 5, 1.0) .addLoot(Material.COAL, 1, 3, 0.5) .addLoot(Material.WITHER_SKELETON_SKULL, 0.03, VERY_RARE) .addGems(5, 0.04, VERY_RARE) - .build() ); setDamage(8); setDroppedExp(20); - itemInMainHand = new ItemStack(Material.STONE_SWORD); + setItemInMainHand(new ItemStack(Material.STONE_SWORD)); } @Override diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java index 3b1bbaf6..8694f549 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Material; import org.bukkit.block.Biome; @@ -15,14 +14,13 @@ public BlightswornZombie() { super("BLIGHTSWORN_ZOMBIE", "Blightsworn Zombie", EntityType.ZOMBIE); setDamage(6); setDroppedExp(12); - setLootTable(new EntityLootTableBuilder() + loot(table -> table .maxDrops(3) .addLoot(Material.ROTTEN_FLESH, 1, 2, 1.0) .addLoot(Material.POTATO, 0.025) .addLoot(Material.CARROT, 0.025) .addLoot(Material.IRON_INGOT, 0.02, RARE) .addGems(2, 0.01, VERY_RARE) - .build() ); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombifiedPiglin.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombifiedPiglin.java index 30a4e971..aaad7983 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombifiedPiglin.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombifiedPiglin.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; @@ -22,16 +21,15 @@ public final class BlightswornZombifiedPiglin extends BlightswornEliteArchetype public BlightswornZombifiedPiglin() { super("BLIGHTSWORN_ZOMBIFIED_PIGLIN", "Blightsworn Zombified Piglin", EntityType.ZOMBIFIED_PIGLIN); - itemInMainHand = new ItemStack(Material.GOLDEN_SWORD); + setItemInMainHand(new ItemStack(Material.GOLDEN_SWORD)); setDamage(8); setDroppedExp(16); - setLootTable(new EntityLootTableBuilder() + loot(table -> table .maxDrops(3) .addLoot(Material.ROTTEN_FLESH, 2, 6, 1.0) .addLoot(Material.GOLD_NUGGET, 2, 6, 1.0) .addLoot(Material.GOLD_INGOT, 1, 2, 0.15, RARE) .addGems(5, 0.04, VERY_RARE) - .build() ); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/celestial/CelestialCreature.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/celestial/CelestialCreature.java index 7579fd35..62998089 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/celestial/CelestialCreature.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/celestial/CelestialCreature.java @@ -6,10 +6,16 @@ import org.bukkit.entity.LivingEntity; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; - public abstract class CelestialCreature extends SpawnableEntity { + + protected CelestialCreature(String entityId, String name, EntityType entityType) { + super(entityId, name, entityType); + } + protected CelestialCreature(String entityId, String name, int maxHealth, EntityType entityType, double probability) { - super(entityId, name, maxHealth, entityType, probability); + super(entityId, name, entityType); + setMaxHealth(maxHealth); + setSpawnProbability(probability); } @Override diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Endersent.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Endersent.java index ecf4d69a..5827d3e1 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Endersent.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Endersent.java @@ -3,11 +3,13 @@ import fr.moussax.blightedSMP.content.utils.ai.EndermanAI; import fr.moussax.blightedSMP.engine.entities.BlightedEntity; import fr.moussax.blightedSMP.engine.entities.attachment.AttachmentRole; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry; import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; -import org.bukkit.*; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.Sound; import org.bukkit.attribute.Attribute; import org.bukkit.block.Biome; import org.bukkit.entity.EntityType; @@ -15,7 +17,6 @@ import org.bukkit.entity.Player; import org.bukkit.event.entity.EntityDamageEvent; -import java.util.Objects; import java.util.Random; import static fr.moussax.blightedSMP.engine.loot.decorators.EntityLootRarity.*; @@ -26,26 +27,28 @@ public class Endersent extends SpawnableEntity { private int projectileHits = 0; private boolean enraged = false; private boolean isEscaping = false; - private long lastTeleportSmash = 0; private int escapeTicks = 0; public Endersent() { - super("ENDERSENT", "Endersent", 200, EntityType.ENDERMAN, 0.002); + super("ENDERSENT", "Endersent", EntityType.ENDERMAN); + setMaxHealth(200); setDamage(20); setDroppedExp(40); + setSpawnProbability(0.002); - addAttribute(Attribute.FOLLOW_RANGE, 60); - addAttribute(Attribute.SCALE, 2); - addAttribute(Attribute.KNOCKBACK_RESISTANCE, 1.0); - addAttribute(Attribute.MOVEMENT_SPEED, 0.25); + attributes(attributes -> attributes + .followRange(60) + .scale(2) + .knockbackResistance(1.0) + .movementSpeed(0.25) + ); - setLootTable(new EntityLootTableBuilder() + loot(table -> table .maxDrops(2) .addLoot(Material.ENDER_PEARL, 4, 8, 1.0) .addLoot(Material.ENDER_EYE, 1, 3, 0.31) .addLoot("ENCHANTED_ENDER_PEARL", 1, 4, 0.11, RARE) .addGems(30, 0.03, VERY_RARE) - .build() ); setBoss(true); @@ -64,7 +67,7 @@ private void tickCombat() { private void tickSmash() { if (!enraged || isEscaping) return; - if (System.currentTimeMillis() - lastTeleportSmash < SMASH_COOLDOWN) return; + if (!isCooldownReady("smash", SMASH_COOLDOWN)) return; Player target = getNearestPlayer(30); if (target == null) return; @@ -75,20 +78,20 @@ private void tickSmash() { } private void performTeleportSmash(Player target) { - lastTeleportSmash = System.currentTimeMillis(); + triggerCooldown("smash"); - entity.swingMainHand(); + swingMainHand(); Location behind = target.getLocation().add(target.getLocation().getDirection().multiply(-1.5)); behind.setY(target.getLocation().getY()); entity.teleport(behind); - entity.getWorld().playSound(entity.getLocation(), Sound.ENTITY_ENDERMAN_TELEPORT, 1.0f, 1.0f); + playSound(Sound.ENTITY_ENDERMAN_TELEPORT, 1.0f, 1.0f); addCoreDelayedAction(9L, () -> { if (!isAlive()) return; - entity.getWorld().spawnParticle(Particle.EXPLOSION, entity.getLocation(), 1); - entity.getWorld().playSound(entity.getLocation(), Sound.ENTITY_GENERIC_EXPLODE, 1.0f, 0.8f); + spawnParticle(Particle.EXPLOSION, 1); + playSound(Sound.ENTITY_GENERIC_EXPLODE, 1.0f, 0.8f); damageNearbyPlayers(3.0, 14.0); }); } @@ -125,8 +128,8 @@ private void startDeadlyEscape() { Location location = entity.getLocation(); - Objects.requireNonNull(location.getWorld()).spawnParticle(Particle.EXPLOSION_EMITTER, location, 1); - location.getWorld().playSound(location, Sound.ENTITY_GENERIC_EXPLODE, 1.0f, 0.5f); + spawnParticle(location, Particle.EXPLOSION_EMITTER, 1); + playSound(location, Sound.ENTITY_GENERIC_EXPLODE, 1.0f, 0.5f); damageNearbyPlayers(4.0, 18.0); @@ -137,9 +140,9 @@ private void startDeadlyEscape() { int count = 3 + new Random().nextInt(4); for (int i = 0; i < count; i++) { - BlightedEntity prototype = EntitiesRegistry.get("WATCHLING"); - if (prototype == null) continue; - LivingEntity wEntity = prototype.spawn( + BlightedEntity watchling = EntitiesRegistry.create("WATCHLING"); + if (watchling == null) continue; + LivingEntity wEntity = watchling.spawn( location.clone().add((Math.random() - 0.5) * 2, 0, (Math.random() - 0.5) * 2) ); addAttachment(wEntity, AttachmentRole.SUBORDINATE); @@ -162,7 +165,7 @@ private void endDeadlyEscape() { entity.setInvulnerable(false); entity.setAI(true); - entity.getWorld().playSound(reappearLoc, Sound.ENTITY_ENDERMAN_TELEPORT, 1.0f, 0.5f); + playSound(reappearLoc, Sound.ENTITY_ENDERMAN_TELEPORT, 1.0f, 0.5f); } @Override @@ -174,15 +177,4 @@ protected void onConfigureAI(LivingEntity spawned) { protected void defineSpawnConditions() { addCondition(SpawnRules.biome(Biome.END_MIDLANDS)); } - - @Override - public Endersent clone() { - Endersent clone = (Endersent) super.clone(); - clone.projectileHits = 0; - clone.enraged = false; - clone.isEscaping = false; - clone.lastTeleportSmash = 0; - clone.escapeTicks = 0; - return clone; - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Illusioner.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Illusioner.java index a4b8a84e..aadfcf1c 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Illusioner.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Illusioner.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.powerful; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.components.impl.ShieldComponent; import fr.moussax.blightedSMP.engine.entities.rituals.AncientCreature; import fr.moussax.blightedSMP.engine.loot.decorators.EntityLootRarity; @@ -13,10 +12,11 @@ public class Illusioner extends AncientCreature { public Illusioner() { - super("Ancient Dummy", 20, EntityType.ILLUSIONER, 30); + super("ANCIENT_ILLUSIONER", "Ancient Dummy", EntityType.ILLUSIONER); setDamage(12); + setTimeAllowance(30); - this.lootTable = new EntityLootTableBuilder() + loot(table -> table .maxDrops(4) .addLoot(Material.SPECTRAL_ARROW, 4, 12, 0.6) .addLoot(Material.GLASS_BOTTLE, 1, 2, 0.4) @@ -27,7 +27,7 @@ public Illusioner() { List.of(Enchantment.QUICK_CHARGE, Enchantment.PIERCING, Enchantment.POWER), 1, 7, 0.07, EntityLootRarity.RARE ) - .build(); + ); } @Override @@ -38,9 +38,4 @@ protected void onDefineBehavior() { addComponent(new ShieldComponent(120)); } } - - @Override - public String getEntityId() { - return "ANCIENT_ILLUSIONER"; - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Watchling.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Watchling.java index 69af090b..9e09b1a1 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Watchling.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Watchling.java @@ -1,7 +1,6 @@ package fr.moussax.blightedSMP.content.entities.powerful; import fr.moussax.blightedSMP.content.utils.ai.EndermanAI; -import fr.moussax.blightedSMP.engine.entities.EntityLootTableBuilder; import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; @@ -11,7 +10,6 @@ import org.bukkit.block.Biome; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.Mob; import org.bukkit.entity.Player; import org.bukkit.generator.structure.Structure; @@ -23,21 +21,24 @@ public class Watchling extends SpawnableEntity { private static final long TELEPORT_COOLDOWN = 4000; private final Random random = new Random(); - private long lastTeleportTime = 0; public Watchling() { - super("WATCHLING", "§dWatchling", 20, EntityType.ENDERMAN, 0.001); - addAttribute(Attribute.SCALE, 0.7); - addAttribute(Attribute.MOVEMENT_SPEED, 0.35); - addAttribute(Attribute.FOLLOW_RANGE, 50); + super("WATCHLING", "§dWatchling", EntityType.ENDERMAN); + setMaxHealth(20); setDamage(10); setDroppedExp(10); + setSpawnProbability(0.001); - setLootTable(new EntityLootTableBuilder() + attributes(attributes -> attributes + .scale(0.7) + .movementSpeed(0.35) + .followRange(50) + ); + + loot(table -> table .maxDrops(2) .addLoot(Material.ENDER_PEARL, 1, 2, 1.0) .addGems(5, 0.03, VERY_RARE) - .build() ); } @@ -47,22 +48,18 @@ protected void onDefineBehavior() { } private void handleCombatLogic() { - if (!isAlive() || !(entity instanceof Mob mob) || !(mob.getTarget() instanceof Player target)) return; + if (!isAlive()) return; + Player target = getTargetPlayer(); + if (target == null) return; double distance = entity.getLocation().distance(target.getLocation()); - if (distance > 6 && distance < 20 && canTeleport()) { + if (distance > 6 && distance < 20 && checkAndTriggerCooldown("teleport", TELEPORT_COOLDOWN)) { teleportToTarget(target); } } - private boolean canTeleport() { - return System.currentTimeMillis() - lastTeleportTime > TELEPORT_COOLDOWN; - } - private void teleportToTarget(Player target) { - lastTeleportTime = System.currentTimeMillis(); - Location location = target.getLocation().add( (random.nextDouble() - 0.5) * 2, 0, @@ -70,7 +67,7 @@ private void teleportToTarget(Player target) { ); entity.teleport(location); - entity.getWorld().playSound(location, Sound.ENTITY_ENDERMAN_TELEPORT, 1.0f, 1.5f); + playSound(location, Sound.ENTITY_ENDERMAN_TELEPORT, 1.0f, 1.5f); if (random.nextDouble() < 0.3) { performHeavyAttack(target); @@ -78,14 +75,14 @@ private void teleportToTarget(Player target) { } private void performHeavyAttack(Player target) { - entity.swingMainHand(); - entity.swingOffHand(); + swingMainHand(); + swingOffHand(); addCoreDelayedAction(10L, () -> { if (!isAlive() || target.getLocation().distance(entity.getLocation()) > 3) return; target.damage(getDamage() * 2, entity); - entity.getWorld().playSound(entity.getLocation(), Sound.ENTITY_ZOMBIE_ATTACK_IRON_DOOR, 0.5f, 1.2f); + playSound(Sound.ENTITY_ZOMBIE_ATTACK_IRON_DOOR, 0.5f, 1.2f); if (random.nextBoolean()) { executeEscapeTeleport(); @@ -118,11 +115,4 @@ protected void defineSpawnConditions() { .or(SpawnRules.insideStructure(Structure.END_CITY)) ); } - - @Override - public Watchling clone() { - Watchling clone = (Watchling) super.clone(); - clone.lastTeleportTime = 0; - return clone; - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/BlightedEntity.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/BlightedEntity.java index 3c5fcf1a..a0e11845 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/BlightedEntity.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/BlightedEntity.java @@ -1,14 +1,27 @@ package fr.moussax.blightedSMP.engine.entities; -import fr.moussax.bedrock.utils.debug.Log; import fr.moussax.blightedSMP.BlightedSMP; import fr.moussax.blightedSMP.engine.entities.attachment.AttachmentRole; import fr.moussax.blightedSMP.engine.entities.attachment.EntityAttachment; -import fr.moussax.blightedSMP.engine.entities.components.AffixRegistry; +import fr.moussax.blightedSMP.engine.entities.attachment.EntityAttachmentManager; +import fr.moussax.blightedSMP.engine.entities.boss.BossBarBuilder; +import fr.moussax.blightedSMP.engine.entities.boss.EntityBossBarController; import fr.moussax.blightedSMP.engine.entities.components.EntityComponent; +import fr.moussax.blightedSMP.engine.entities.components.EntityComponentManager; import fr.moussax.blightedSMP.engine.entities.defense.DamageType; +import fr.moussax.blightedSMP.engine.entities.defense.DefensesBuilder; import fr.moussax.blightedSMP.engine.entities.defense.EntityDefenses; import fr.moussax.blightedSMP.engine.entities.defense.EntityImmunity; +import fr.moussax.blightedSMP.engine.entities.equipment.EntityEquipmentBuilder; +import fr.moussax.blightedSMP.engine.entities.equipment.EntityEquipmentHolder; +import fr.moussax.blightedSMP.engine.entities.phases.EntityPhaseManager; +import fr.moussax.blightedSMP.engine.entities.phases.EntityPhasesBuilder; +import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry; +import fr.moussax.blightedSMP.engine.entities.state.EntityAttributeManager; +import fr.moussax.blightedSMP.engine.entities.state.EntityAttributesBuilder; +import fr.moussax.blightedSMP.engine.entities.state.EntityCooldowns; +import fr.moussax.blightedSMP.engine.entities.util.EntityEffects; +import fr.moussax.blightedSMP.engine.entities.util.EntitySpatialQueries; import fr.moussax.blightedSMP.engine.loot.LootContext; import fr.moussax.blightedSMP.engine.loot.LootTable; import fr.moussax.blightedSMP.engine.player.BlightedPlayer; @@ -17,22 +30,22 @@ import org.bukkit.*; import org.bukkit.attribute.Attribute; import org.bukkit.attribute.AttributeInstance; -import org.bukkit.attribute.AttributeModifier; import org.bukkit.block.Biome; import org.bukkit.boss.BarColor; import org.bukkit.boss.BarStyle; -import org.bukkit.boss.BossBar; import org.bukkit.entity.*; import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.event.entity.EntityDamageEvent; -import org.bukkit.inventory.EntityEquipment; import org.bukkit.inventory.ItemStack; import org.bukkit.persistence.PersistentDataType; import org.bukkit.util.Vector; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; -import java.util.*; -import java.util.concurrent.CopyOnWriteArraySet; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Set; import java.util.concurrent.ThreadLocalRandom; import java.util.function.Consumer; @@ -45,28 +58,27 @@ *

Instances are stateful and bound to one entity at runtime. API methods must * be called from the server thread unless stated otherwise.

*/ -public abstract class BlightedEntity implements Cloneable { +public abstract class BlightedEntity { public static final NamespacedKey ENTITY_ID_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_entity_id"); - public static final NamespacedKey ATTACHMENT_OWNER_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_owner"); - public static final NamespacedKey ATTACHMENT_ROLE_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_role"); - public static final NamespacedKey ATTACHMENT_OFFSET_X_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_offset_x"); - public static final NamespacedKey ATTACHMENT_OFFSET_Y_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_offset_y"); - public static final NamespacedKey ATTACHMENT_OFFSET_Z_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_offset_z"); - public static final NamespacedKey ATTACHMENT_SYNC_YAW_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_sync_yaw"); - public static final NamespacedKey ATTACHMENT_SYNC_PITCH_KEY = new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_sync_pitch"); public static final String FAST_PASS_TAG = "blighted_opt"; - private static final double BOSS_BAR_RANGE = 60.0; - - private NavigableMap phaseThresholds = new TreeMap<>(Collections.reverseOrder()); - private LifecycleTaskManager coreTasks = new LifecycleTaskManager(); - private LifecycleTaskManager phaseTasks = new LifecycleTaskManager(); - private Map components = new HashMap<>(); - protected Set attachments = new CopyOnWriteArraySet<>(); - - private long lastDamageTick = -1L; - private UUID lastDamagerUuid = null; + @Getter + protected EntityAttachmentManager attachmentManager = new EntityAttachmentManager(this); + @Getter + protected EntityEquipmentHolder equipmentHolder = new EntityEquipmentHolder(); + @Getter + protected EntityCooldowns cooldowns = new EntityCooldowns(); + @Getter + protected EntityComponentManager componentManager = new EntityComponentManager(); + @Getter + protected EntityPhaseManager phaseManager = new EntityPhaseManager(this, this::onPhaseTransition); + @Getter + protected EntityAttributeManager attributes = new EntityAttributeManager(); + @Nullable + protected EntityBossBarController bossBarController; + @Getter + private EntityDefenses defenses = EntityDefenses.fromClass(getClass()); @Getter protected String entityId; @@ -79,21 +91,17 @@ public abstract class BlightedEntity implements Cloneable { @Getter protected int maxHealth; @Getter - @Setter protected int damage; - @Setter + @Getter protected int defense; @Setter @Getter protected int droppedExp = 0; - protected ItemStack itemInMainHand; - protected ItemStack itemInOffHand; - protected ItemStack[] armor; - - @Setter + @Getter protected LootTable lootTable; + @Getter @Setter protected boolean isBoss = false; @@ -101,55 +109,46 @@ public abstract class BlightedEntity implements Cloneable { @Setter protected boolean isPerformingAbility = false; - protected BossBar bossBar; - protected BarColor bossBarColor = BarColor.RED; - protected BarStyle bossBarStyle = BarStyle.SOLID; - protected Map attributes = new HashMap<>(); - @Getter - private EntityDefenses defenses = EntityDefenses.fromClass(getClass()); - private boolean runtimeInitialized = false; - private boolean componentsInitialized = false; - /** - * Creates an entity with default damage and defense values. - * - * @param name entity display name - * @param maxHealth maximum health - * @param entityType Bukkit entity type - */ - public BlightedEntity(@NonNull String name, int maxHealth, EntityType entityType) { - this(name, maxHealth, 1, 0, entityType); - } + public static final int DEFAULT_MAX_HEALTH = 20; + public static final int DEFAULT_DAMAGE = 2; + public static final int DEFAULT_DEFENSE = 0; /** - * Creates an entity with the given health and damage values. + * Creates an entity definition with basic identity requirements. + * Combat stats and behavior can be configured using fluent setters. * + * @param entityId unique entity identifier * @param name entity display name - * @param maxHealth maximum health - * @param damage base attack damage * @param entityType Bukkit entity type */ - public BlightedEntity(@NonNull String name, int maxHealth, int damage, EntityType entityType) { - this(name, maxHealth, damage, 0, entityType); + public BlightedEntity(@NonNull String entityId, @NonNull String name, @NonNull EntityType entityType) { + this.entityId = Objects.requireNonNull(entityId, "entityId cannot be null"); + this.name = Objects.requireNonNull(name, "name cannot be null"); + this.entityType = Objects.requireNonNull(entityType, "entityType cannot be null"); + this.maxHealth = DEFAULT_MAX_HEALTH; + this.damage = DEFAULT_DAMAGE; + this.defense = DEFAULT_DEFENSE; } /** - * Creates an entity with the given base combat stats. + * Obtains or creates the boss bar controller for this entity. * - * @param name entity display name - * @param maxHealth maximum health - * @param damage base attack damage - * @param defense base armor value - * @param entityType Bukkit entity type + * @return the boss bar controller */ - public BlightedEntity(@NonNull String name, int maxHealth, int damage, int defense, EntityType entityType) { - this.name = name; - this.maxHealth = maxHealth; - this.damage = damage; - this.defense = defense; - this.entityType = entityType; + @NonNull + public EntityBossBarController getOrCreateBossBar() { + if (bossBarController == null) { + bossBarController = new EntityBossBarController( + BlightedSMP.getInstance(), + () -> entity, + () -> name, + () -> entityType + ); + } + return bossBarController; } /** @@ -168,16 +167,16 @@ public LivingEntity spawn(Location location) { entity.addScoreboardTag(FAST_PASS_TAG); entity.getPersistentDataContainer().set(ENTITY_ID_KEY, PersistentDataType.STRING, getEntityId()); - initializeAttributes(); + attributes.initialize(entity, maxHealth, damage, defense, isBoss); configureEquipment(); onConfigureAI(entity); if (isBoss) { - createBossBar(); + getOrCreateBossBar().create(); } EntityManager.registerEntity(entity, this); - initComponents(); + componentManager.init(entity); initRuntime(); return entity; @@ -194,22 +193,21 @@ public void attachToExisting(LivingEntity existing) { entity.addScoreboardTag(FAST_PASS_TAG); } - rehydrateAttributes(); + attributes.rehydrate(existing, maxHealth, damage, defense, isBoss); onConfigureAI(existing); if (isBoss) { - createBossBar(); + getOrCreateBossBar().create(); } EntityManager.registerEntity(existing, this); - initComponents(); + componentManager.init(existing); onRehydrate(existing); if (!runtimeInitialized) { initRuntime(); } else { - coreTasks.scheduleAll(); - phaseTasks.scheduleAll(); + phaseManager.scheduleAllTasks(); } } @@ -228,17 +226,12 @@ public void kill() { */ public void cleanup() { Location currentLocation = entity != null ? entity.getLocation() : null; - if (currentLocation != null) { - for (EntityComponent component : components.values()) { - component.onDeath(this, currentLocation); - } - } + componentManager.onDeath(this, currentLocation); removeBossBar(); killAllAttachments(); - destroyComponents(); - coreTasks.cancelAll(); - phaseTasks.cancelAll(); + componentManager.destroy(entity); + phaseManager.cancelAllTasks(); EntityManager.unregisterEntity(entity); } @@ -294,8 +287,8 @@ protected void onRehydrate(LivingEntity existing) { * @param healthPercentage threshold in the range {@code 0.0–1.0} * @param onTransition action executed when the phase is triggered */ - protected final void registerPhase(double healthPercentage, Runnable onTransition) { - phaseThresholds.put(healthPercentage, onTransition); + public final void registerPhase(double healthPercentage, Runnable onTransition) { + phaseManager.registerPhase(healthPercentage, onTransition); } /** @@ -314,28 +307,7 @@ protected long onPhaseTransition(double healthThreshold) { * @param currentHealth current health value */ public final void evaluatePhases(double currentHealth) { - if (phaseThresholds.isEmpty()) return; - double healthPercentage = currentHealth / maxHealth; - - while (!phaseThresholds.isEmpty() && healthPercentage <= phaseThresholds.firstKey()) { - Map.Entry entry = phaseThresholds.pollFirstEntry(); - if (entry == null) break; - - phaseTasks.cancelAll(); - phaseTasks = new LifecycleTaskManager(); - entry.getValue().run(); - long transitionDuration = onPhaseTransition(entry.getKey()); - - if (transitionDuration > 0) { - setPerformingAbility(true); - addCoreDelayedAction(transitionDuration, () -> { - setPerformingAbility(false); - phaseTasks.scheduleAll(); - }); - } else { - phaseTasks.scheduleAll(); - } - } + phaseManager.evaluatePhases(currentHealth); } /** @@ -347,7 +319,7 @@ public final void evaluatePhases(double currentHealth) { */ @SuppressWarnings("SameParameterValue") protected final void addCoreAbility(long delayTicks, long periodTicks, Runnable action) { - scheduleAbility(coreTasks, delayTicks, periodTicks, action); + phaseManager.addCoreAbility(delayTicks, periodTicks, action); } /** @@ -358,7 +330,7 @@ protected final void addCoreAbility(long delayTicks, long periodTicks, Runnable * @param action task action */ protected final void addPhaseAbility(long delayTicks, long periodTicks, Runnable action) { - scheduleAbility(phaseTasks, delayTicks, periodTicks, action); + phaseManager.addPhaseAbility(delayTicks, periodTicks, action); } /** @@ -368,7 +340,7 @@ protected final void addPhaseAbility(long delayTicks, long periodTicks, Runnable * @param action task action */ protected final void addCoreDelayedAction(long delayTicks, Runnable action) { - scheduleDelayedAction(coreTasks, delayTicks, action); + phaseManager.addCoreDelayedAction(delayTicks, action); } /** @@ -379,7 +351,7 @@ protected final void addCoreDelayedAction(long delayTicks, Runnable action) { */ @SuppressWarnings("SameParameterValue") protected final void addPhaseDelayedAction(long delayTicks, Runnable action) { - scheduleDelayedAction(phaseTasks, delayTicks, action); + phaseManager.addPhaseDelayedAction(delayTicks, action); } /** @@ -430,6 +402,46 @@ public boolean hasLineOfSight(Entity target) { return entity.hasLineOfSight(target); } + /** + * Returns the current Bukkit target of this entity if it is a {@link Mob}. + * + * @return current target living entity, or {@code null} if none or not a Mob + */ + @Nullable + public LivingEntity getTarget() { + return entity instanceof Mob mob ? mob.getTarget() : null; + } + + /** + * Returns the current Bukkit target of this entity if it is a {@link Player}. + * + * @return current target player, or {@code null} if none or target is not a Player + */ + @Nullable + public Player getTargetPlayer() { + return entity instanceof Mob mob && mob.getTarget() instanceof Player player ? player : null; + } + + /** + * Returns whether this mob currently has an active target. + * + * @return {@code true} if targeting an entity + */ + public boolean hasTarget() { + return getTarget() != null; + } + + /** + * Sets the target of this entity if it is a {@link Mob}. + * + * @param target new target entity, or {@code null} to clear + */ + public void setTarget(@Nullable LivingEntity target) { + if (entity instanceof Mob mob) { + mob.setTarget(target); + } + } + /** * Returns nearby survival-mode players around the entity. * @@ -437,7 +449,7 @@ public boolean hasLineOfSight(Entity target) { * @return nearby players, or an empty list if unavailable */ public List getNearbyPlayers(double radius) { - return getNearbyPlayers(entity != null ? entity.getLocation() : null, radius); + return EntitySpatialQueries.getNearbyPlayers(entity != null ? entity.getLocation() : null, radius); } /** @@ -448,18 +460,7 @@ public List getNearbyPlayers(double radius) { * @return nearby players, or an empty list if the center is invalid */ public List getNearbyPlayers(Location center, double radius) { - if (!isAlive() || center == null || center.getWorld() == null) return Collections.emptyList(); - return center - .getWorld() - .getNearbyEntities( - center, - radius, - radius, - radius, - entity -> entity instanceof Player player && player.getGameMode() == GameMode.SURVIVAL) - .stream() - .map(entity -> (Player) entity) - .toList(); + return EntitySpatialQueries.getNearbyPlayers(center, radius); } /** @@ -469,7 +470,7 @@ public List getNearbyPlayers(Location center, double radius) { * @return nearest player, or {@code null} if none exists */ public Player getNearestPlayer(double radius) { - return getNearestPlayer(entity != null ? entity.getLocation() : null, radius); + return EntitySpatialQueries.getNearestPlayer(entity != null ? entity.getLocation() : null, radius); } /** @@ -480,10 +481,7 @@ public Player getNearestPlayer(double radius) { * @return nearest player, or {@code null} if none exists */ public Player getNearestPlayer(Location center, double radius) { - if (center == null) return null; - return getNearbyPlayers(center, radius).stream() - .min(Comparator.comparingDouble(player -> player.getLocation().distanceSquared(center))) - .orElse(null); + return EntitySpatialQueries.getNearestPlayer(center, radius); } /** @@ -493,7 +491,7 @@ public Player getNearestPlayer(Location center, double radius) { * @return nearby BlightedMC players */ public List getNearbyBlightedPlayers(double radius) { - return getNearbyBlightedPlayers(entity != null ? entity.getLocation() : null, radius); + return EntitySpatialQueries.getNearbyBlightedPlayers(entity != null ? entity.getLocation() : null, radius); } /** @@ -504,10 +502,7 @@ public List getNearbyBlightedPlayers(double radius) { * @return nearby BlightedMC players */ public List getNearbyBlightedPlayers(Location center, double radius) { - return getNearbyPlayers(center, radius).stream() - .map(BlightedPlayer::get) - .filter(Objects::nonNull) - .toList(); + return EntitySpatialQueries.getNearbyBlightedPlayers(center, radius); } /** @@ -517,7 +512,7 @@ public List getNearbyBlightedPlayers(Location center, double rad * @return nearest BlightedMC player, or {@code null} */ public BlightedPlayer getNearestBlightedPlayer(double radius) { - return getNearestBlightedPlayer(entity != null ? entity.getLocation() : null, radius); + return EntitySpatialQueries.getNearestBlightedPlayer(entity != null ? entity.getLocation() : null, radius); } /** @@ -528,8 +523,7 @@ public BlightedPlayer getNearestBlightedPlayer(double radius) { * @return nearest BlightedMC player, or {@code null} */ public BlightedPlayer getNearestBlightedPlayer(Location center, double radius) { - Player target = getNearestPlayer(center, radius); - return target != null ? BlightedPlayer.get(target) : null; + return EntitySpatialQueries.getNearestBlightedPlayer(center, radius); } /** @@ -555,10 +549,7 @@ public void meleeAttackNearestPlayer(double radius) { * @param damageAmount damage dealt */ public void damageNearbyPlayers(Location center, double radius, double damageAmount) { - List hitPlayers = getNearbyPlayers(center, radius); - for (Player player : hitPlayers) { - player.damage(damageAmount, entity); - } + EntitySpatialQueries.damageNearbyPlayers(center, radius, damageAmount, entity); } /** @@ -568,7 +559,7 @@ public void damageNearbyPlayers(Location center, double radius, double damageAmo * @param damageAmount damage dealt */ public void damageNearbyPlayers(double radius, double damageAmount) { - damageNearbyPlayers(entity != null ? entity.getLocation() : null, radius, damageAmount); + EntitySpatialQueries.damageNearbyPlayers(entity != null ? entity.getLocation() : null, radius, damageAmount, entity); } /** @@ -587,17 +578,8 @@ public void damageAndKnockbackNearbyPlayers( double knockbackStrength, double verticalKnockback ) { - List hitPlayers = getNearbyPlayers(center, radius); - for (Player player : hitPlayers) { - player.damage(damageAmount, entity); - Vector knockbackVector = player.getLocation().toVector().subtract(center.toVector()).setY(0); - if (knockbackVector.lengthSquared() > 0.001) { - knockbackVector.normalize().multiply(knockbackStrength).setY(verticalKnockback); - } else { - knockbackVector = new Vector(0, verticalKnockback, 0); - } - player.setVelocity(knockbackVector); - } + EntitySpatialQueries.damageAndKnockbackNearbyPlayers( + center, radius, damageAmount, knockbackStrength, verticalKnockback, entity); } /** @@ -607,10 +589,7 @@ public void damageAndKnockbackNearbyPlayers( * @param cooldownTicks duration in ticks to put the shield on cooldown */ public void disableShieldIfBlocking(Player target, int cooldownTicks) { - if (target != null && target.isBlocking()) { - target.setCooldown(Material.SHIELD, cooldownTicks); - target.getWorld().playSound(target.getLocation(), Sound.ITEM_SHIELD_BREAK, 1.0f, 0.8f); - } + EntitySpatialQueries.disableShieldIfBlocking(target, cooldownTicks); } /** @@ -619,15 +598,224 @@ public void disableShieldIfBlocking(Player target, int cooldownTicks) { * @param target target location */ public void faceLocation(Location target) { - if (!isAlive() || target == null) { - return; - } - Vector direction = target.toVector().subtract(entity.getLocation().toVector()).setY(0); - if (direction.lengthSquared() > 0.001) { - Location location = entity.getLocation(); - location.setYaw((float) Math.toDegrees(-Math.atan2(direction.getX(), direction.getZ()))); - entity.teleport(location); - } + EntitySpatialQueries.faceLocation(entity, target); + } + + /** + * Configures this entity's loot table using a fluent builder consumer. + * + * @param consumer action configuring the entity loot table builder + */ + public void loot(@NonNull Consumer consumer) { + EntityLootTableBuilder builder = new EntityLootTableBuilder(); + consumer.accept(builder); + this.lootTable = builder.build(); + } + + /** + * Configures this entity's equipment using a fluent builder consumer. + * + * @param consumer action configuring the entity equipment builder + */ + public void equipment(@NonNull Consumer consumer) { + EntityEquipmentBuilder builder = new EntityEquipmentBuilder(); + consumer.accept(builder); + equipmentHolder.applyBuilder(builder, entity); + } + + /** + * Marks this entity as a boss and configures its boss bar appearance using a fluent consumer. + * + * @param consumer action configuring the boss bar builder + */ + public void boss(@NonNull Consumer consumer) { + this.isBoss = true; + BossBarBuilder builder = new BossBarBuilder(); + consumer.accept(builder); + getOrCreateBossBar().configure(builder); + } + + /** + * Marks this entity as a boss with default boss bar appearance. + */ + public void boss() { + this.isBoss = true; + getOrCreateBossBar(); + } + + /** + * Configures this entity's defenses (immunities and resistances) using a fluent consumer. + * + * @param consumer action configuring the defense builder + */ + public void defenses(@NonNull Consumer consumer) { + consumer.accept(new DefensesBuilder(this)); + } + + /** + * Configures this entity's health-based phase transitions using a fluent consumer. + * + * @param consumer action configuring the phase builder + */ + public void phases(@NonNull Consumer consumer) { + consumer.accept(new EntityPhasesBuilder(this)); + } + + /** + * Configures mob attributes using a fluent consumer builder with typed convenience methods. + * + * @param consumer action configuring the attributes builder + */ + public void attributes(@NonNull Consumer consumer) { + EntityAttributesBuilder builder = new EntityAttributesBuilder(); + consumer.accept(builder); + builder.applyTo(this.attributes, this.entity); + } + + /** + * Plays a sound effect at the entity's current location. + * + * @param sound sound to play + * @param volume volume level + * @param pitch pitch modifier + */ + public void playSound(@NonNull Sound sound, float volume, float pitch) { + EntityEffects.playSound(entity, sound, volume, pitch); + } + + /** + * Plays a sound effect at the entity's current location with default volume and pitch (1.0f). + * + * @param sound sound to play + */ + public void playSound(@NonNull Sound sound) { + EntityEffects.playSound(entity, sound, 1.0f, 1.0f); + } + + /** + * Plays a sound effect at a specific world location. + * + * @param location location to play the sound at + * @param sound sound to play + * @param volume volume level + * @param pitch pitch modifier + */ + public void playSound(@NonNull Location location, @NonNull Sound sound, float volume, float pitch) { + EntityEffects.playSound(location, sound, volume, pitch); + } + + /** + * Spawns particles at the entity's current location. + * + * @param particle particle type + * @param count number of particles + */ + public void spawnParticle(@NonNull Particle particle, int count) { + EntityEffects.spawnParticle(entity, particle, count); + } + + /** + * Spawns particles at the entity's current location with random positional offsets. + * + * @param particle particle type + * @param count number of particles + * @param offsetX maximum X axis offset + * @param offsetY maximum Y axis offset + * @param offsetZ maximum Z axis offset + */ + public void spawnParticle(@NonNull Particle particle, int count, double offsetX, double offsetY, double offsetZ) { + EntityEffects.spawnParticle(entity, particle, count, offsetX, offsetY, offsetZ); + } + + /** + * Spawns particles at the entity's current location with custom particle data (e.g. DustOptions). + * + * @param particle particle type + * @param count number of particles + * @param data particle data object, or null + * @param particle data type + */ + public void spawnParticle(@NonNull Particle particle, int count, @Nullable T data) { + EntityEffects.spawnParticle(entity, particle, count, data); + } + + /** + * Spawns particles at a specific world location. + * + * @param location location to spawn particles at + * @param particle particle type + * @param count number of particles + */ + public void spawnParticle(@NonNull Location location, @NonNull Particle particle, int count) { + EntityEffects.spawnParticle(location, particle, count); + } + + /** + * Spawns particles at a specific world location with custom particle data (e.g. DustOptions). + * + * @param location location to spawn particles at + * @param particle particle type + * @param count number of particles + * @param data particle data object, or null + * @param particle data type + */ + public void spawnParticle(@NonNull Location location, @NonNull Particle particle, int count, @Nullable T data) { + EntityEffects.spawnParticle(location, particle, count, data); + } + + /** + * Triggers the entity's main-hand swinging animation. + */ + public void swingMainHand() { + EntityEffects.swingMainHand(entity); + } + + /** + * Triggers the entity's off-hand swinging animation. + */ + public void swingOffHand() { + EntityEffects.swingOffHand(entity); + } + + /** + * Checks whether the cooldown for the specified ability key has expired. + * + * @param abilityKey unique identifier for the ability + * @param cooldownMillis cooldown duration in milliseconds + * @return {@code true} if the cooldown has elapsed and the ability can run + */ + public boolean isCooldownReady(@NonNull String abilityKey, long cooldownMillis) { + return cooldowns.isReady(abilityKey, cooldownMillis); + } + + /** + * Sets or resets the cooldown timestamp for the specified ability to current system time. + * + * @param abilityKey unique identifier for the ability + */ + public void triggerCooldown(@NonNull String abilityKey) { + cooldowns.trigger(abilityKey); + } + + /** + * Checks if the ability cooldown has expired, and if so, immediately records + * the current timestamp and returns {@code true}. + * + * @param abilityKey unique identifier for the ability + * @param cooldownMillis cooldown duration in milliseconds + * @return {@code true} if the cooldown was ready and was triggered + */ + public boolean checkAndTriggerCooldown(@NonNull String abilityKey, long cooldownMillis) { + return cooldowns.checkAndTrigger(abilityKey, cooldownMillis); + } + + /** + * Resets any recorded cooldown timestamp for the specified ability key. + * + * @param abilityKey unique identifier for the ability + */ + public void resetCooldown(@NonNull String abilityKey) { + cooldowns.reset(abilityKey); } /** @@ -639,7 +827,7 @@ public void setMainHandEquipped(boolean equipped) { if (!isAlive() || entity.getEquipment() == null) { return; } - entity.getEquipment().setItemInMainHand(equipped ? itemInMainHand : null); + entity.getEquipment().setItemInMainHand(equipped ? equipmentHolder.getItemInMainHand() : null); } /** @@ -651,7 +839,208 @@ public void setOffHandEquipped(boolean equipped) { if (!isAlive() || entity.getEquipment() == null) { return; } - entity.getEquipment().setItemInOffHand(equipped ? itemInOffHand : null); + entity.getEquipment().setItemInOffHand(equipped ? equipmentHolder.getItemInOffHand() : null); + } + + /** + * Sets the maximum health for this entity. + * If the entity is currently active in the world, dynamically updates + * the live {@link Attribute#MAX_HEALTH} and clamps current health. + * + * @param maxHealth maximum health value + */ + public void setMaxHealth(double maxHealth) { + this.maxHealth = (int) Math.round(maxHealth); + setAttribute(Attribute.MAX_HEALTH, maxHealth); + if (isAlive() && entity != null) { + entity.setHealth(Math.min(entity.getHealth(), maxHealth)); + } + } + + /** + * Sets the live entity's current health, clamped between 0 and maximum health. + * + * @param health target health value + */ + public void setHealth(double health) { + attributes.setHealth(entity, health, this.maxHealth); + } + + /** + * Returns the live entity's current health, or {@code 0.0} if unspawned or dead. + * + * @return current health + */ + public double getHealth() { + return attributes.getHealth(entity); + } + + /** + * Sets the base attack damage value. Updates the live entity if active. + * + * @param damage attack damage + */ + public void setDamage(int damage) { + this.damage = damage; + setAttribute(Attribute.ATTACK_DAMAGE, damage); + } + + /** + * Sets the base defense/armor value. Updates the live entity if active. + * + * @param defense defense value + */ + public void setDefense(int defense) { + this.defense = defense; + if (defense > 0 || isAlive()) { + setAttribute(Attribute.ARMOR, defense); + } + } + + /** + * Sets the entity's helmet. Updates live equipment if the entity is active. + * + * @param helmet helmet item or null to unequip + */ + public void setHelmet(@Nullable ItemStack helmet) { + equipmentHolder.setHelmet(helmet, entity); + } + + /** + * Sets the entity's chestplate. Updates live equipment if the entity is active. + * + * @param chestplate chestplate item or null to unequip + */ + public void setChestplate(@Nullable ItemStack chestplate) { + equipmentHolder.setChestplate(chestplate, entity); + } + + /** + * Sets the entity's leggings. Updates live equipment if the entity is active. + * + * @param leggings leggings item or null to unequip + */ + public void setLeggings(@Nullable ItemStack leggings) { + equipmentHolder.setLeggings(leggings, entity); + } + + /** + * Sets the entity's boots. Updates live equipment if the entity is active. + * + * @param boots boots item or null to unequip + */ + public void setBoots(@Nullable ItemStack boots) { + equipmentHolder.setBoots(boots, entity); + } + + /** + * Sets all four armor slots in natural top-to-bottom order (helmet to boots). + * Any slot may be null to leave it unequipped. + * + * @param helmet helmet item or null + * @param chestplate chestplate item or null + * @param leggings leggings item or null + * @param boots boots item or null + */ + public void setArmor( + @Nullable ItemStack helmet, + @Nullable ItemStack chestplate, + @Nullable ItemStack leggings, + @Nullable ItemStack boots + ) { + equipmentHolder.setArmor(helmet, chestplate, leggings, boots, entity); + } + + /** + * Clears all armor slots. + */ + public void clearArmor() { + equipmentHolder.clearArmor(entity); + } + + /** + * Clears all equipment slots (armor, main-hand, and off-hand). + */ + public void clearEquipment() { + equipmentHolder.clearEquipment(entity); + } + + /** + * Sets the main-hand item. Updates live equipment if the entity is active. + * + * @param item main-hand item or null to unequip + */ + public void setItemInMainHand(@Nullable ItemStack item) { + equipmentHolder.setMainHand(item, entity); + } + + /** + * Sets the off-hand item. Updates live equipment if the entity is active. + * + * @param item off-hand item or null to unequip + */ + public void setItemInOffHand(@Nullable ItemStack item) { + equipmentHolder.setOffHand(item, entity); + } + + /** + * Returns the helmet item from the live entity if active, or from the configured template. + * + * @return current helmet item, or null + */ + @Nullable + public ItemStack getHelmet() { + return equipmentHolder.getHelmet(entity); + } + + /** + * Returns the chestplate item from the live entity if active, or from the configured template. + * + * @return current chestplate item, or null + */ + @Nullable + public ItemStack getChestplate() { + return equipmentHolder.getChestplate(entity); + } + + /** + * Returns the leggings item from the live entity if active, or from the configured template. + * + * @return current leggings item, or null + */ + @Nullable + public ItemStack getLeggings() { + return equipmentHolder.getLeggings(entity); + } + + /** + * Returns the boots item from the live entity if active, or from the configured template. + * + * @return current boots item, or null + */ + @Nullable + public ItemStack getBoots() { + return equipmentHolder.getBoots(entity); + } + + /** + * Returns the main hand item from the live entity if active, or from the configured template. + * + * @return current main hand item, or null + */ + @Nullable + public ItemStack getItemInMainHand() { + return equipmentHolder.getMainHand(entity); + } + + /** + * Returns the off hand item from the live entity if active, or from the configured template. + * + * @return current off hand item, or null + */ + @Nullable + public ItemStack getItemInOffHand() { + return equipmentHolder.getOffHand(entity); } /** @@ -662,18 +1051,7 @@ public void setOffHandEquipped(boolean equipped) { * @return the created ItemDisplay attachment */ public ItemDisplay attachItemDisplay(Vector offset, Consumer configurator) { - if (!isAlive()) { - return null; - } - Vector localOffset = offset != null ? offset : new Vector(0, 0, 0); - Location spawnLoc = entity.getLocation().clone().add(localOffset); - ItemDisplay display = entity.getWorld().spawn(spawnLoc, ItemDisplay.class, itemDisplay -> { - if (configurator != null) { - configurator.accept(itemDisplay); - } - }); - addAttachment(display, AttachmentRole.VISUAL, localOffset, true, false); - return display; + return attachmentManager.attachItemDisplay(offset, configurator); } /** @@ -684,18 +1062,7 @@ public ItemDisplay attachItemDisplay(Vector offset, Consumer config * @return the created BlockDisplay attachment */ public BlockDisplay attachBlockDisplay(Vector offset, Consumer configurator) { - if (!isAlive()) { - return null; - } - Vector localOffset = offset != null ? offset : new Vector(0, 0, 0); - Location spawnLoc = entity.getLocation().clone().add(localOffset); - BlockDisplay display = entity.getWorld().spawn(spawnLoc, BlockDisplay.class, d -> { - if (configurator != null) { - configurator.accept(d); - } - }); - addAttachment(display, AttachmentRole.VISUAL, localOffset, true, false); - return display; + return attachmentManager.attachBlockDisplay(offset, configurator); } /** @@ -708,20 +1075,7 @@ public BlockDisplay attachBlockDisplay(Vector offset, Consumer con * @return the created Interaction attachment */ public Interaction attachHitbox(Vector offset, float width, float height, Consumer configurator) { - if (!isAlive()) { - return null; - } - Vector localOffset = offset != null ? offset : new Vector(0, 0, 0); - Location spawnLocation = entity.getLocation().clone().add(localOffset); - Interaction interaction = entity.getWorld().spawn(spawnLocation, Interaction.class, i -> { - i.setInteractionWidth(width); - i.setInteractionHeight(height); - if (configurator != null) { - configurator.accept(i); - } - }); - addAttachment(interaction, AttachmentRole.HITBOX, localOffset, true, false); - return interaction; + return attachmentManager.attachHitbox(offset, width, height, configurator); } /** @@ -730,7 +1084,7 @@ public Interaction attachHitbox(Vector offset, float width, float height, Consum * @param attachmentEntity entity to attach */ public void addAttachment(Entity attachmentEntity) { - addAttachment(attachmentEntity, AttachmentRole.SUBORDINATE, new Vector(0, 0, 0), true, false); + attachmentManager.addAttachment(attachmentEntity); } /** @@ -740,7 +1094,7 @@ public void addAttachment(Entity attachmentEntity) { * @param role attachment role */ public void addAttachment(Entity attachmentEntity, AttachmentRole role) { - addAttachment(attachmentEntity, role, new Vector(0, 0, 0), true, false); + attachmentManager.addAttachment(attachmentEntity, role); } /** @@ -751,7 +1105,7 @@ public void addAttachment(Entity attachmentEntity, AttachmentRole role) { * @param offset local 3D offset */ public void addAttachment(Entity attachmentEntity, AttachmentRole role, Vector offset) { - addAttachment(attachmentEntity, role, offset, true, false); + attachmentManager.addAttachment(attachmentEntity, role, offset); } /** @@ -764,111 +1118,21 @@ public void addAttachment(Entity attachmentEntity, AttachmentRole role, Vector o * @param syncPitch whether vertical rotation follows base pitch */ public void addAttachment(Entity attachmentEntity, AttachmentRole role, Vector offset, boolean syncYaw, boolean syncPitch) { - if (attachmentEntity == null) { - return; - } - Vector vector = offset != null ? offset : new Vector(0, 0, 0); - attachments.add(new EntityAttachment(attachmentEntity, role, vector, syncYaw, syncPitch)); - EntityManager.registerAttachment(attachmentEntity, this); - - if (entity != null) { - attachmentEntity.getPersistentDataContainer().set(ATTACHMENT_OWNER_KEY, PersistentDataType.STRING, entity.getUniqueId().toString()); - attachmentEntity.getPersistentDataContainer().set(ATTACHMENT_ROLE_KEY, PersistentDataType.STRING, role.name()); - attachmentEntity.getPersistentDataContainer().set(ATTACHMENT_OFFSET_X_KEY, PersistentDataType.DOUBLE, vector.getX()); - attachmentEntity.getPersistentDataContainer().set(ATTACHMENT_OFFSET_Y_KEY, PersistentDataType.DOUBLE, vector.getY()); - attachmentEntity.getPersistentDataContainer().set(ATTACHMENT_OFFSET_Z_KEY, PersistentDataType.DOUBLE, vector.getZ()); - attachmentEntity.getPersistentDataContainer().set(ATTACHMENT_SYNC_YAW_KEY, PersistentDataType.BYTE, (byte) (syncYaw ? 1 : 0)); - attachmentEntity.getPersistentDataContainer().set(ATTACHMENT_SYNC_PITCH_KEY, PersistentDataType.BYTE, (byte) (syncPitch ? 1 : 0)); - } - - if (attachmentEntity instanceof Display display) { - display.setTeleportDuration(1); - display.setInterpolationDuration(1); - } - - if (attachmentEntity instanceof LivingEntity living) { - EntityEquipment equipment = living.getEquipment(); - if (equipment != null) { - zeroEquipmentDropChances(equipment); - } - } - attachmentEntity.addScoreboardTag(FAST_PASS_TAG); + attachmentManager.addAttachment(attachmentEntity, role, offset, syncYaw, syncPitch); } /** * Synchronizes all registered attachments to their relative world position based on base location and facing yaw. */ public void syncAttachments() { - if (!isAlive() || attachments.isEmpty()) { - return; - } - - Location baseLocation = entity.getLocation(); - double radians = Math.toRadians(baseLocation.getYaw()); - double cos = Math.cos(radians); - double sin = Math.sin(radians); - - for (EntityAttachment attachment : attachments) { - Entity attachedEntity = attachment.entity(); - if (attachedEntity == null || !attachedEntity.isValid() || attachedEntity.isDead()) { - attachments.remove(attachment); - if (attachedEntity != null) { - EntityManager.unregisterAttachment(attachedEntity); - } - continue; - } - - if (attachment.role() == AttachmentRole.SUBORDINATE) { - continue; - } - - if (entity != null && entity.getPassengers().contains(attachedEntity)) { - continue; - } - - Vector offset = attachment.localOffset(); - double xPrime = baseLocation.getX() + (offset.getX() * cos - offset.getZ() * sin); - double zPrime = baseLocation.getZ() + (offset.getX() * sin + offset.getZ() * cos); - double yPrime = baseLocation.getY() + offset.getY(); - - Location currentLocation = attachedEntity.getLocation(); - float targetYaw = attachment.syncYaw() ? baseLocation.getYaw() : currentLocation.getYaw(); - float targetPitch = attachment.syncPitch() ? baseLocation.getPitch() : currentLocation.getPitch(); - - if (currentLocation.getWorld() == baseLocation.getWorld() - && Math.abs(currentLocation.getX() - xPrime) < 0.001 - && Math.abs(currentLocation.getY() - yPrime) < 0.001 - && Math.abs(currentLocation.getZ() - zPrime) < 0.001 - && Math.abs(currentLocation.getYaw() - targetYaw) < 0.1f - && Math.abs(currentLocation.getPitch() - targetPitch) < 0.1f) { - continue; - } - - Location targetLocation = new Location(baseLocation.getWorld(), xPrime, yPrime, zPrime, targetYaw, targetPitch); - - attachedEntity.teleport(targetLocation); - } + attachmentManager.syncAttachments(); } /** * Removes and destroys all attached entities. */ public void killAllAttachments() { - if (attachments.isEmpty()) { - return; - } - - for (EntityAttachment attachment : attachments) { - Entity attachmentEntity = attachment.entity(); - - if (attachmentEntity == null) { - continue; - } - - EntityManager.unregisterAttachment(attachmentEntity); - attachmentEntity.remove(); - } - attachments.clear(); + attachmentManager.killAllAttachments(); } /** @@ -877,21 +1141,7 @@ public void killAllAttachments() { * @param role attachment role to remove */ public void killAttachments(AttachmentRole role) { - if (attachments.isEmpty() || role == null) { - return; - } - - attachments.removeIf(attachment -> { - if (attachment.role() != role) { - return false; - } - Entity attachmentEntity = attachment.entity(); - if (attachmentEntity != null) { - EntityManager.unregisterAttachment(attachmentEntity); - attachmentEntity.remove(); - } - return true; - }); + attachmentManager.killAttachments(role); } /** @@ -900,12 +1150,7 @@ public void killAttachments(AttachmentRole role) { * @return {@code true} if a living body attachment exists */ public boolean hasLivingBodyAttachment() { - for (EntityAttachment attachment : attachments) { - if (attachment.entity() instanceof LivingEntity living && !living.isDead()) { - return true; - } - } - return false; + return attachmentManager.hasLivingBodyAttachment(); } /** @@ -914,15 +1159,7 @@ public boolean hasLivingBodyAttachment() { * @return {@code true} if a non-dead subordinate attachment exists */ public boolean hasSubordinateAttachments() { - for (EntityAttachment attachment : attachments) { - if (attachment.role() == AttachmentRole.SUBORDINATE - && attachment.entity() != null - && attachment.entity().isValid() - && !attachment.entity().isDead()) { - return true; - } - } - return false; + return attachmentManager.hasSubordinateAttachments(); } /** @@ -932,20 +1169,7 @@ public boolean hasSubordinateAttachments() { * @return {@code true} if damage was already processed this tick by the same attacker */ public boolean shouldBlockSameTickDamage(Entity damager) { - if (damager == null) { - return false; - } - - long currentTick = entity != null ? entity.getWorld().getGameTime() : System.currentTimeMillis(); - UUID damagerUuid = damager.getUniqueId(); - - if (currentTick == lastDamageTick && Objects.equals(damagerUuid, lastDamagerUuid)) { - return true; - } - - lastDamageTick = currentTick; - lastDamagerUuid = damagerUuid; - return false; + return attachmentManager.shouldBlockSameTickDamage(damager); } /** @@ -956,10 +1180,7 @@ public boolean shouldBlockSameTickDamage(Entity damager) { * @param component component to register */ public void addComponent(EntityComponent component) { - components.put(component.getId(), component); - if (componentsInitialized && entity != null) { - component.onInit(entity); - } + componentManager.addComponent(component, entity); } /** @@ -968,7 +1189,7 @@ public void addComponent(EntityComponent component) { * @return active entity attachments */ public Set getAttachments() { - return Collections.unmodifiableSet(attachments); + return attachmentManager.getAttachments(); } /** @@ -978,9 +1199,8 @@ public Set getAttachments() { * @param expected component type * @return component, or {@code null} if not registered */ - @SuppressWarnings("unchecked") public T getComponent(String id) { - return (T) components.get(id); + return componentManager.getComponent(id); } /** @@ -991,15 +1211,7 @@ public T getComponent(String id) { * @return matching component instance, or {@code null} if not registered */ public T getComponent(Class componentClass) { - if (componentClass == null) { - return null; - } - for (EntityComponent component : components.values()) { - if (componentClass.isInstance(component)) { - return componentClass.cast(component); - } - } - return null; + return componentManager.getComponent(componentClass); } /** @@ -1008,14 +1220,14 @@ public T getComponent(Class componentClass) { * @return registered components */ public Collection getComponents() { - return new ArrayList<>(components.values()); + return componentManager.getComponents(); } /** * Updates the boss bar progress to match the entity's health. */ public void updateBossBar() { - if (bossBar == null) { + if (bossBarController == null || !bossBarController.isActive()) { return; } if (!isAlive()) { @@ -1027,7 +1239,7 @@ public void updateBossBar() { ? maxHealthAttribute.getValue() : Math.max(1, this.maxHealth); double progress = entity.getHealth() / maxHealth; - bossBar.setProgress(Math.clamp(progress, 0.0, 1.0)); + bossBarController.updateProgress(progress); } /** @@ -1037,23 +1249,16 @@ public void updateBossBar() { * @param style bar style */ public void setBossBarAppearance(BarColor color, BarStyle style) { - this.bossBarColor = color; - this.bossBarStyle = style; - if (bossBar != null) { - bossBar.setColor(color); - bossBar.setStyle(style); - } + getOrCreateBossBar().setAppearance(color, style); } /** * Removes the boss bar and all of its viewers. */ public void removeBossBar() { - if (bossBar == null) { - return; + if (bossBarController != null) { + bossBarController.remove(); } - bossBar.removeAll(); - bossBar = null; } /** @@ -1074,13 +1279,25 @@ public void dropLoot(Location location, BlightedPlayer player) { } /** - * Sets an additional Bukkit attribute value. + * Sets an attribute base value. If the entity is currently active, + * updates the live Bukkit AttributeInstance immediately. * - * @param attribute attribute to configure + * @param attribute target attribute * @param value base attribute value */ - public void addAttribute(Attribute attribute, double value) { - attributes.put(attribute, value); + public void setAttribute(Attribute attribute, double value) { + attributes.setAttribute(attribute, value, entity); + } + + /** + * Returns the effective base value for an attribute, querying the live + * entity if active, or the configured attribute map if unspawned. + * + * @param attribute target attribute + * @return effective base value, or 0.0 if not configured + */ + public double getAttributeValue(Attribute attribute) { + return attributes.getAttributeValue(attribute, entity); } /** @@ -1105,19 +1322,6 @@ public double getResistancePercent(LivingEntity target, EntityDamageEvent event) return defenses.getResistancePercent(target, event); } - private void initComponents() { - if (componentsInitialized) { - return; - } - componentsInitialized = true; - components.values().forEach(component -> component.onInit(entity)); - } - - private void destroyComponents() { - components.values().forEach(component -> component.onDestroy(entity)); - componentsInitialized = false; - } - /** * Initializes runtime systems (behavior, tasks, phases). Executed once per instance. */ @@ -1126,92 +1330,17 @@ private void initRuntime() { return; } onDefineBehavior(); - if (bossBar != null) startBossBarTask(); addCoreAbility(1L, 1L, this::syncAttachments); - addCoreAbility(5L, 5L, () -> { - for (EntityComponent component : components.values()) { - component.onTick(this); - } - }); + addCoreAbility(5L, 5L, () -> componentManager.tick(this)); runtimeInitialized = true; - coreTasks.scheduleAll(); - evaluatePhases(maxHealth); - } - - private void initializeAttributes() { - setAttribute(Attribute.MAX_HEALTH, maxHealth); - setAttribute(Attribute.ATTACK_DAMAGE, damage); - if (defense > 0) { - setAttribute(Attribute.ARMOR, defense); - } - attributes.forEach(this::setAttribute); - - AttributeInstance maxHealthAttribute = entity.getAttribute(Attribute.MAX_HEALTH); - if (maxHealthAttribute != null) { - entity.setHealth(maxHealthAttribute.getValue()); - } - - lockEntityProperties(); - } - - private void rehydrateAttributes() { - setAttribute(Attribute.MAX_HEALTH, maxHealth); - setAttribute(Attribute.ATTACK_DAMAGE, damage); - if (defense > 0) { - setAttribute(Attribute.ARMOR, defense); - } - attributes.forEach(this::setAttribute); - - AttributeInstance maxHealthAttribute = entity.getAttribute(Attribute.MAX_HEALTH); - if (maxHealthAttribute != null) { - entity.setHealth(Math.min(entity.getHealth(), maxHealthAttribute.getValue())); - } - - lockEntityProperties(); - } - - private void lockEntityProperties() { - entity.setRemoveWhenFarAway(!isBoss); - entity.setPersistent(isBoss); - entity.setCanPickupItems(false); - } - - private void setAttribute(Attribute attribute, double value) { - AttributeInstance instance = entity.getAttribute(attribute); - if (instance == null) { - return; - } - for (AttributeModifier modifier : new ArrayList<>(instance.getModifiers())) { - instance.removeModifier(modifier); - } - instance.setBaseValue(value); + phaseManager.scheduleAllCoreTasks(); + phaseManager.evaluatePhases(maxHealth); } private void configureEquipment() { - if (armor == null && itemInMainHand == null && itemInOffHand == null) { - return; - } - EntityEquipment equipment = entity.getEquipment(); - if (equipment == null) { - return; - } - - if (armor != null) equipment.setArmorContents(armor); - if (itemInMainHand != null) equipment.setItemInMainHand(itemInMainHand); - if (itemInOffHand != null) equipment.setItemInOffHand(itemInOffHand); - - zeroEquipmentDropChances(equipment); - } - - private void zeroEquipmentDropChances(EntityEquipment equipment) { - equipment.setHelmetDropChance(0f); - equipment.setChestplateDropChance(0f); - equipment.setLeggingsDropChance(0f); - equipment.setBootsDropChance(0f); - equipment.setItemInMainHandDropChance(0f); - equipment.setItemInOffHandDropChance(0f); + equipmentHolder.applyTo(entity); } /** @@ -1233,142 +1362,35 @@ public void addResistance(DamageType type, double percent) { defenses.addResistance(type, percent); } - private void scheduleAbility(LifecycleTaskManager manager, long delayTicks, long periodTicks, Runnable action) { - manager.addRepeatingTask(() -> { - if (!isAlive()) { - return; - } - try { - action.run(); - } catch (Exception exception) { - Log.warn("BlightedEntity", "Ability threw an exception on entity '" + name + "': " + exception.getMessage()); - } - }, delayTicks, periodTicks); - if (canScheduleTask()) manager.scheduleLast(); - } - - private void scheduleDelayedAction(LifecycleTaskManager manager, long delayTicks, Runnable action) { - manager.addDelayedTask(() -> { - if (!isAlive()) { - return; - } - try { - action.run(); - } catch (Exception exception) { - Log.warn("BlightedEntity", "Delayed action threw an exception on entity '" + name + "': " + exception.getMessage()); - } - }, delayTicks); - if (canScheduleTask()) manager.scheduleLast(); - } - - private boolean canScheduleTask() { - return entity != null && !entity.isDead() && runtimeInitialized; - } - - private void createBossBar() { - if (bossBar != null) { - return; - } - if (entityType == EntityType.WITHER || entityType == EntityType.ENDER_DRAGON) { - return; - } - bossBar = Bukkit.createBossBar("§f§l" + name, bossBarColor, bossBarStyle); - bossBar.setProgress(1.0); - } - - private void startBossBarTask() { - addCoreAbility(10L, 20L, this::manageBossBarViewers); - } - - private void manageBossBarViewers() { - if (bossBar == null) { - return; - } - if (!isAlive()) { - removeBossBar(); - return; - } - - updateBossBar(); - - World world = entity.getWorld(); - Location entityLocation = entity.getLocation(); - double rangeSquared = BOSS_BAR_RANGE * BOSS_BAR_RANGE; - - for (Player player : new ArrayList<>(bossBar.getPlayers())) { - if (!player.isOnline() - || player.getWorld() != world - || player.getLocation().distanceSquared(entityLocation) > rangeSquared) { - bossBar.removePlayer(player); - } - } - - for (Player player : world.getPlayers()) { - if (player.getLocation().distanceSquared(entityLocation) <= rangeSquared - && !bossBar.getPlayers().contains(player)) { - bossBar.addPlayer(player); - } - } - } - /** * Returns whether the bound entity is currently valid and alive. * * @return {@code true} if the entity can still be used */ - protected boolean isAlive() { + public boolean isAlive() { return entity != null && entity.isValid() && !entity.isDead(); } /** - * Creates a detached copy of this entity definition. - * - *

The cloned instance is not bound to a Bukkit entity and has no active - * runtime tasks or boss bar.

+ * Creates a pristine, unspawned instance of this entity ready for world spawning. + * Instantiates via {@link fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry}, + * or invokes the zero-argument constructor directly. * - * @return detached clone + * @return fresh unspawned entity instance */ - @Override - public BlightedEntity clone() { - try { - BlightedEntity clone = (BlightedEntity) super.clone(); - clone.entity = null; - clone.bossBar = null; - clone.runtimeInitialized = false; - clone.componentsInitialized = false; - clone.attributes = new HashMap<>(this.attributes); - clone.attachments = new CopyOnWriteArraySet<>(); - clone.defenses = this.defenses.copy(); - clone.coreTasks = new LifecycleTaskManager(); - clone.phaseTasks = new LifecycleTaskManager(); - clone.armor = cloneArmor(); - clone.itemInMainHand = cloneItem(this.itemInMainHand); - clone.itemInOffHand = cloneItem(this.itemInOffHand); - clone.phaseThresholds = new TreeMap<>(this.phaseThresholds); - clone.components = new HashMap<>(); - - for (Map.Entry entry : this.components.entrySet()) { - EntityComponent fresh = AffixRegistry.getAffixById(entry.getKey()); - clone.components.put(entry.getKey(), fresh != null ? fresh : entry.getValue()); - } - - return clone; - } catch (CloneNotSupportedException e) { - throw new RuntimeException("Clone failed", e); + @NonNull + public BlightedEntity createInstance() { + BlightedEntity fresh = EntitiesRegistry.create(getEntityId()); + if (fresh != null) { + return fresh; } - } - - private ItemStack[] cloneArmor() { - if (this.armor == null) { - return null; + try { + var constructor = getClass().getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (Exception exception) { + throw new IllegalStateException("Failed to instantiate entity of type " + getClass().getName() + + ". Ensure a public zero-argument constructor is present or register a factory.", exception); } - - ItemStack[] cloned = new ItemStack[this.armor.length]; - for (int i = 0; i < this.armor.length; i++) cloned[i] = cloneItem(this.armor[i]); - return cloned; - } - - private ItemStack cloneItem(ItemStack item) { - return item != null ? item.clone() : null; } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityManager.java index d6760754..ad0fa7b8 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityManager.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityManager.java @@ -19,7 +19,9 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import static fr.moussax.blightedSMP.engine.entities.BlightedEntity.*; +import static fr.moussax.blightedSMP.engine.entities.BlightedEntity.ENTITY_ID_KEY; +import static fr.moussax.blightedSMP.engine.entities.BlightedEntity.FAST_PASS_TAG; +import static fr.moussax.blightedSMP.engine.entities.attachment.EntityAttachmentManager.*; /** * Central runtime entity manager tracking active {@link BlightedEntity} instances, @@ -198,10 +200,10 @@ public static void rehydrateChunk(Chunk chunk) { } String entityId = persistentDataContainer.get(ENTITY_ID_KEY, PersistentDataType.STRING); - BlightedEntity prototype = EntitiesRegistry.get(entityId); - if (prototype == null) continue; + BlightedEntity blighted = EntitiesRegistry.create(entityId); + if (blighted == null) continue; - prototype.clone().attachToExisting(living); + blighted.attachToExisting(living); } // Pass 2: re-register attachment entities carrying ATTACHMENT_OWNER_KEY. @@ -246,9 +248,8 @@ public static void rehydrateChunk(Chunk chunk) { boolean syncYaw = yawByte == null || yawByte == 1; boolean syncPitch = pitchByte != null && pitchByte == 1; - owner.attachments.removeIf(attachment -> attachment.entity() != null - && attachment.entity().getUniqueId().equals(entity.getUniqueId())); - owner.attachments.add(new EntityAttachment(entity, role, offset, syncYaw, syncPitch)); + owner.getAttachmentManager().registerRehydratedAttachment( + new EntityAttachment(entity, role, offset, syncYaw, syncPitch)); registerAttachment(entity, owner); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/attachment/EntityAttachmentManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/attachment/EntityAttachmentManager.java new file mode 100644 index 00000000..96d07024 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/attachment/EntityAttachmentManager.java @@ -0,0 +1,401 @@ +package fr.moussax.blightedSMP.engine.entities.attachment; + +import fr.moussax.blightedSMP.BlightedSMP; +import fr.moussax.blightedSMP.engine.entities.BlightedEntity; +import fr.moussax.blightedSMP.engine.entities.EntityManager; +import fr.moussax.blightedSMP.engine.entities.equipment.EntityEquipmentHolder; +import org.bukkit.Location; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.*; +import org.bukkit.inventory.EntityEquipment; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.util.Vector; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.function.Consumer; + +/** + * Manages multipart and visual attachments bound to a parent {@link BlightedEntity}. + * + *

Handles local 3D offset calculations, relative yaw/pitch rotation tracking, + * persistent data tagging, and lifecycle cleanup.

+ */ +public final class EntityAttachmentManager { + + public static final NamespacedKey ATTACHMENT_OWNER_KEY = + new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_owner"); + public static final NamespacedKey ATTACHMENT_ROLE_KEY = + new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_role"); + public static final NamespacedKey ATTACHMENT_OFFSET_X_KEY = + new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_offset_x"); + public static final NamespacedKey ATTACHMENT_OFFSET_Y_KEY = + new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_offset_y"); + public static final NamespacedKey ATTACHMENT_OFFSET_Z_KEY = + new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_offset_z"); + public static final NamespacedKey ATTACHMENT_SYNC_YAW_KEY = + new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_sync_yaw"); + public static final NamespacedKey ATTACHMENT_SYNC_PITCH_KEY = + new NamespacedKey(BlightedSMP.getInstance(), "blighted_attachment_sync_pitch"); + + private final BlightedEntity owner; + private final Set attachments = new CopyOnWriteArraySet<>(); + + public EntityAttachmentManager(@NonNull BlightedEntity owner) { + this.owner = Objects.requireNonNull(owner, "owner cannot be null"); + } + + /** + * Attaches a non-interactive {@link ItemDisplay} entity with local 3D translation offset. + * + * @param offset local offset relative to base entity origin and facing yaw + * @param configurator optional configuration consumer + * @return the created ItemDisplay attachment, or null if entity is not alive + */ + @Nullable + public ItemDisplay attachItemDisplay(@Nullable Vector offset, @Nullable Consumer configurator) { + LivingEntity live = owner.getEntity(); + if (live == null || !live.isValid() || live.isDead()) { + return null; + } + + Vector localOffset = offset != null ? offset : new Vector(0, 0, 0); + Location spawnLoc = live.getLocation().clone().add(localOffset); + ItemDisplay display = live.getWorld().spawn(spawnLoc, ItemDisplay.class, itemDisplay -> { + if (configurator != null) { + configurator.accept(itemDisplay); + } + }); + addAttachment(display, AttachmentRole.VISUAL, localOffset, true, false); + return display; + } + + /** + * Attaches a non-interactive {@link BlockDisplay} entity with local 3D translation offset. + * + * @param offset local offset relative to base entity origin and facing yaw + * @param configurator optional configuration consumer + * @return the created BlockDisplay attachment, or null if entity is not alive + */ + @Nullable + public BlockDisplay attachBlockDisplay(@Nullable Vector offset, @Nullable Consumer configurator) { + LivingEntity live = owner.getEntity(); + if (live == null || !live.isValid() || live.isDead()) { + return null; + } + + Vector localOffset = offset != null ? offset : new Vector(0, 0, 0); + Location spawnLoc = live.getLocation().clone().add(localOffset); + BlockDisplay display = live.getWorld().spawn(spawnLoc, BlockDisplay.class, blockDisplay -> { + if (configurator != null) { + configurator.accept(blockDisplay); + } + }); + addAttachment(display, AttachmentRole.VISUAL, localOffset, true, false); + return display; + } + + /** + * Attaches a multipart hittable {@link Interaction} hitbox entity. + * + * @param offset local offset relative to base entity origin and facing yaw + * @param width hitbox width + * @param height hitbox height + * @param configurator optional configuration consumer + * @return the created Interaction attachment, or null if entity is not alive + */ + @Nullable + public Interaction attachHitbox( + @Nullable Vector offset, + float width, + float height, + @Nullable Consumer configurator + ) { + LivingEntity live = owner.getEntity(); + if (live == null || !live.isValid() || live.isDead()) { + return null; + } + + Vector localOffset = offset != null ? offset : new Vector(0, 0, 0); + Location spawnLocation = live.getLocation().clone().add(localOffset); + Interaction interaction = live.getWorld().spawn(spawnLocation, Interaction.class, hitbox -> { + hitbox.setInteractionWidth(width); + hitbox.setInteractionHeight(height); + if (configurator != null) { + configurator.accept(hitbox); + } + }); + addAttachment(interaction, AttachmentRole.HITBOX, localOffset, true, false); + return interaction; + } + + /** + * Attaches an entity using {@link AttachmentRole#SUBORDINATE}. + * + * @param attachmentEntity entity to attach + */ + public void addAttachment(@Nullable Entity attachmentEntity) { + addAttachment(attachmentEntity, AttachmentRole.SUBORDINATE, new Vector(0, 0, 0), true, false); + } + + /** + * Attaches an entity with the given role. + * + * @param attachmentEntity entity to attach + * @param role attachment role + */ + public void addAttachment(@Nullable Entity attachmentEntity, @NonNull AttachmentRole role) { + addAttachment(attachmentEntity, role, new Vector(0, 0, 0), true, false); + } + + /** + * Attaches an entity with the given role and local offset. + * + * @param attachmentEntity entity to attach + * @param role attachment role + * @param offset local 3D offset + */ + public void addAttachment(@Nullable Entity attachmentEntity, @NonNull AttachmentRole role, @Nullable Vector offset) { + addAttachment(attachmentEntity, role, offset, true, false); + } + + /** + * Attaches an entity with full offset and rotation synchronization configuration. + * + * @param attachmentEntity entity to attach + * @param role attachment role + * @param offset local 3D offset + * @param syncYaw whether horizontal rotation follows base yaw + * @param syncPitch whether vertical rotation follows base pitch + */ + public void addAttachment( + @Nullable Entity attachmentEntity, + @NonNull AttachmentRole role, + @Nullable Vector offset, + boolean syncYaw, + boolean syncPitch + ) { + if (attachmentEntity == null) { + return; + } + + Vector vector = offset != null ? offset : new Vector(0, 0, 0); + attachments.add(new EntityAttachment(attachmentEntity, role, vector, syncYaw, syncPitch)); + EntityManager.registerAttachment(attachmentEntity, owner); + + LivingEntity live = owner.getEntity(); + if (live != null) { + attachmentEntity.getPersistentDataContainer().set( + ATTACHMENT_OWNER_KEY, PersistentDataType.STRING, live.getUniqueId().toString()); + attachmentEntity.getPersistentDataContainer().set( + ATTACHMENT_ROLE_KEY, PersistentDataType.STRING, role.name()); + attachmentEntity.getPersistentDataContainer().set( + ATTACHMENT_OFFSET_X_KEY, PersistentDataType.DOUBLE, vector.getX()); + attachmentEntity.getPersistentDataContainer().set( + ATTACHMENT_OFFSET_Y_KEY, PersistentDataType.DOUBLE, vector.getY()); + attachmentEntity.getPersistentDataContainer().set( + ATTACHMENT_OFFSET_Z_KEY, PersistentDataType.DOUBLE, vector.getZ()); + attachmentEntity.getPersistentDataContainer().set( + ATTACHMENT_SYNC_YAW_KEY, PersistentDataType.BYTE, (byte) (syncYaw ? 1 : 0)); + attachmentEntity.getPersistentDataContainer().set( + ATTACHMENT_SYNC_PITCH_KEY, PersistentDataType.BYTE, (byte) (syncPitch ? 1 : 0)); + } + + if (attachmentEntity instanceof Display display) { + display.setTeleportDuration(1); + display.setInterpolationDuration(1); + } + + if (attachmentEntity instanceof LivingEntity living) { + EntityEquipment equipment = living.getEquipment(); + if (equipment != null) { + EntityEquipmentHolder.zeroEquipmentDropChances(equipment); + } + } + attachmentEntity.addScoreboardTag(BlightedEntity.FAST_PASS_TAG); + } + + /** + * Synchronizes all registered attachments to their relative world position based on base location and facing yaw. + */ + public void syncAttachments() { + LivingEntity live = owner.getEntity(); + if (live == null || !live.isValid() || live.isDead() || attachments.isEmpty()) { + return; + } + + Location baseLocation = live.getLocation(); + double radians = Math.toRadians(baseLocation.getYaw()); + double cos = Math.cos(radians); + double sin = Math.sin(radians); + + for (EntityAttachment attachment : attachments) { + Entity attachedEntity = attachment.entity(); + if (attachedEntity == null || !attachedEntity.isValid() || attachedEntity.isDead()) { + attachments.remove(attachment); + if (attachedEntity != null) { + EntityManager.unregisterAttachment(attachedEntity); + } + continue; + } + + if (attachment.role() == AttachmentRole.SUBORDINATE) { + continue; + } + + if (live.getPassengers().contains(attachedEntity)) { + continue; + } + + Vector offset = attachment.localOffset(); + double xPrime = baseLocation.getX() + (offset.getX() * cos - offset.getZ() * sin); + double zPrime = baseLocation.getZ() + (offset.getX() * sin + offset.getZ() * cos); + double yPrime = baseLocation.getY() + offset.getY(); + + Location currentLocation = attachedEntity.getLocation(); + float targetYaw = attachment.syncYaw() ? baseLocation.getYaw() : currentLocation.getYaw(); + float targetPitch = attachment.syncPitch() ? baseLocation.getPitch() : currentLocation.getPitch(); + + if (currentLocation.getWorld() == baseLocation.getWorld() + && Math.abs(currentLocation.getX() - xPrime) < 0.001 + && Math.abs(currentLocation.getY() - yPrime) < 0.001 + && Math.abs(currentLocation.getZ() - zPrime) < 0.001 + && Math.abs(currentLocation.getYaw() - targetYaw) < 0.1f + && Math.abs(currentLocation.getPitch() - targetPitch) < 0.1f) { + continue; + } + + Location targetLocation = new Location(baseLocation.getWorld(), xPrime, yPrime, zPrime, targetYaw, targetPitch); + attachedEntity.teleport(targetLocation); + } + } + + /** + * Removes and destroys all attached entities. + */ + public void killAllAttachments() { + if (attachments.isEmpty()) { + return; + } + + for (EntityAttachment attachment : attachments) { + Entity attachmentEntity = attachment.entity(); + if (attachmentEntity == null) { + continue; + } + + EntityManager.unregisterAttachment(attachmentEntity); + attachmentEntity.remove(); + } + attachments.clear(); + } + + /** + * Removes and destroys attached entities matching the specified role. + * + * @param role attachment role to remove + */ + public void killAttachments(@Nullable AttachmentRole role) { + if (attachments.isEmpty() || role == null) { + return; + } + + attachments.removeIf(attachment -> { + if (attachment.role() != role) { + return false; + } + Entity attachmentEntity = attachment.entity(); + if (attachmentEntity != null) { + EntityManager.unregisterAttachment(attachmentEntity); + attachmentEntity.remove(); + } + return true; + }); + } + + /** + * Checks whether a living body attachment is currently present. + * + * @return {@code true} if a living body attachment exists + */ + public boolean hasLivingBodyAttachment() { + for (EntityAttachment attachment : attachments) { + if (attachment.entity() instanceof LivingEntity living && !living.isDead()) { + return true; + } + } + return false; + } + + /** + * Checks whether subordinate attachments are present. + * + * @return {@code true} if subordinate attachments exist + */ + public boolean hasSubordinateAttachments() { + for (EntityAttachment attachment : attachments) { + if (attachment.role() == AttachmentRole.SUBORDINATE && attachment.entity().isValid()) { + return true; + } + } + return false; + } + + private long lastDamageTick = -1; + private java.util.UUID lastDamagerUuid = null; + + /** + * Prevents multi-hitbox attachments from applying double damage from the same attack in the same tick. + * + * @param damager attacking entity + * @return {@code true} if damage should be blocked + */ + public boolean shouldBlockSameTickDamage(@Nullable Entity damager) { + if (damager == null) { + return false; + } + LivingEntity live = owner.getEntity(); + long currentTick = live != null + ? live.getWorld().getGameTime() + : System.currentTimeMillis(); + java.util.UUID damagerUuid = damager.getUniqueId(); + + if (currentTick == lastDamageTick && java.util.Objects.equals(damagerUuid, lastDamagerUuid)) { + return true; + } + + lastDamageTick = currentTick; + lastDamagerUuid = damagerUuid; + return false; + } + + /** + * Returns an unmodifiable snapshot view of all registered attachments. + * + * @return registered attachments + */ + public Set getAttachments() { + return Collections.unmodifiableSet(attachments); + } + + /** + * Re-registers an attachment discovered during chunk rehydration. + * + * @param attachment rehydrated entity attachment + */ + public void registerRehydratedAttachment(@NonNull EntityAttachment attachment) { + if (attachment.entity() != null) { + attachments.removeIf(existingAttachment -> existingAttachment.entity() != null + && existingAttachment.entity().getUniqueId().equals(attachment.entity().getUniqueId())); + } + attachments.add(attachment); + } + + public void clear() { + attachments.clear(); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/boss/BossBarBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/boss/BossBarBuilder.java new file mode 100644 index 00000000..4b648194 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/boss/BossBarBuilder.java @@ -0,0 +1,50 @@ +package fr.moussax.blightedSMP.engine.entities.boss; + +import lombok.Getter; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +import org.jspecify.annotations.NonNull; + +/** + * Fluent builder for configuring boss bar appearance and view distance. + */ +@Getter +public final class BossBarBuilder { + + private BarColor color = BarColor.RED; + private BarStyle style = BarStyle.SOLID; + private double viewRadius = EntityBossBarController.DEFAULT_VIEW_RADIUS; + + /** + * Sets the bar display color. + * + * @param color boss bar color + * @return this builder + */ + public BossBarBuilder color(@NonNull BarColor color) { + this.color = color; + return this; + } + + /** + * Sets the segmentation style of the boss bar. + * + * @param style boss bar style + * @return this builder + */ + public BossBarBuilder style(@NonNull BarStyle style) { + this.style = style; + return this; + } + + /** + * Sets the player detection radius in blocks within which the boss bar is visible. + * + * @param viewRadius visibility radius in blocks + * @return this builder + */ + public BossBarBuilder viewRadius(double viewRadius) { + this.viewRadius = viewRadius; + return this; + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/boss/EntityBossBarController.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/boss/EntityBossBarController.java new file mode 100644 index 00000000..0640ad11 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/boss/EntityBossBarController.java @@ -0,0 +1,197 @@ +package fr.moussax.blightedSMP.engine.entities.boss; + +import lombok.Getter; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +import org.bukkit.boss.BossBar; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Manages the lifecycle, appearance, periodic progress synchronization, and proximity viewer tracking of an entity's {@link BossBar}. + */ +public final class EntityBossBarController { + + public static final double DEFAULT_VIEW_RADIUS = 60.0; + private static final long SCAN_PERIOD_TICKS = 20L; + + private final Plugin plugin; + private final Supplier liveEntitySupplier; + private final Supplier titleSupplier; + private final Supplier entityTypeSupplier; + + private BossBar bossBar; + @Getter + private BarColor color = BarColor.RED; + @Getter + private BarStyle style = BarStyle.SOLID; + @Getter + private double viewRadius = DEFAULT_VIEW_RADIUS; + private BukkitTask viewerTask; + + public EntityBossBarController( + @NonNull Plugin plugin, + @NonNull Supplier liveEntitySupplier, + @NonNull Supplier titleSupplier, + @NonNull Supplier entityTypeSupplier + ) { + this.plugin = Objects.requireNonNull(plugin, "plugin cannot be null"); + this.liveEntitySupplier = Objects.requireNonNull(liveEntitySupplier, "liveEntitySupplier cannot be null"); + this.titleSupplier = Objects.requireNonNull(titleSupplier, "titleSupplier cannot be null"); + this.entityTypeSupplier = Objects.requireNonNull(entityTypeSupplier, "entityTypeSupplier cannot be null"); + } + + /** + * Creates and initializes the Bukkit boss bar if not already created. + * Wither and Ender Dragon types manage their own boss bars natively and are skipped. + */ + public void create() { + if (bossBar != null) { + return; + } + + EntityType type = entityTypeSupplier.get(); + if (type == EntityType.WITHER || type == EntityType.ENDER_DRAGON) { + return; + } + + bossBar = Bukkit.createBossBar("§f§l" + titleSupplier.get(), color, style); + bossBar.setProgress(1.0); + startViewerTask(); + } + + /** + * Updates the boss bar fill progress based on the entity's current health ratio. + * + * @param progress value in range [0.0, 1.0] + */ + public void updateProgress(double progress) { + if (bossBar == null) { + return; + } + bossBar.setProgress(Math.clamp(progress, 0.0, 1.0)); + } + + /** + * Configures the boss bar's color and segment style. + * + * @param color bar color + * @param style bar style + */ + public void setAppearance(@NonNull BarColor color, @NonNull BarStyle style) { + this.color = Objects.requireNonNull(color, "color cannot be null"); + this.style = Objects.requireNonNull(style, "style cannot be null"); + if (bossBar != null) { + bossBar.setColor(color); + bossBar.setStyle(style); + } + } + + /** + * Configures settings from a {@link BossBarBuilder}. + * + * @param builder boss bar builder + */ + public void configure(@NonNull BossBarBuilder builder) { + Objects.requireNonNull(builder, "builder cannot be null"); + setAppearance(builder.getColor(), builder.getStyle()); + this.viewRadius = builder.getViewRadius(); + } + + /** + * Removes all viewers, cancels the periodic scanning task, and destroys the boss bar. + */ + public void remove() { + if (viewerTask != null) { + viewerTask.cancel(); + viewerTask = null; + } + if (bossBar != null) { + bossBar.removeAll(); + bossBar = null; + } + } + + /** + * Checks if the boss bar is currently active. + * + * @return {@code true} if active + */ + public boolean isActive() { + return bossBar != null; + } + + @Nullable + public BossBar getBukkitBar() { + return bossBar; + } + + public void setViewRadius(double viewRadius) { + this.viewRadius = Math.max(1.0, viewRadius); + } + + private void startViewerTask() { + if (viewerTask != null) { + viewerTask.cancel(); + } + viewerTask = Bukkit.getScheduler().runTaskTimer(plugin, this::manageViewers, 0L, SCAN_PERIOD_TICKS); + } + + private void manageViewers() { + if (bossBar == null) { + if (viewerTask != null) { + viewerTask.cancel(); + viewerTask = null; + } + return; + } + + LivingEntity entity = liveEntitySupplier.get(); + if (entity == null || !entity.isValid() || entity.isDead()) { + remove(); + return; + } + + // Periodic health synchronization + AttributeInstance maxHealthAttribute = entity.getAttribute(Attribute.MAX_HEALTH); + double maxHealth = maxHealthAttribute != null ? Math.max(1.0, maxHealthAttribute.getValue()) : 20.0; + double currentHealth = Math.clamp(entity.getHealth(), 0.0, maxHealth); + bossBar.setProgress(currentHealth / maxHealth); + + Location entityLocation = entity.getLocation(); + if (entityLocation.getWorld() == null) { + return; + } + + double radiusSquared = viewRadius * viewRadius; + + for (Player player : new ArrayList<>(bossBar.getPlayers())) { + if (!player.isOnline() + || !player.getWorld().equals(entityLocation.getWorld()) + || player.getLocation().distanceSquared(entityLocation) > radiusSquared) { + bossBar.removePlayer(player); + } + } + + for (Player player : entityLocation.getWorld().getPlayers()) { + if (player.isValid() + && player.getLocation().distanceSquared(entityLocation) <= radiusSquared + && !bossBar.getPlayers().contains(player)) { + bossBar.addPlayer(player); + } + } + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/components/EntityComponentManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/components/EntityComponentManager.java new file mode 100644 index 00000000..bf2e0d99 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/components/EntityComponentManager.java @@ -0,0 +1,150 @@ +package fr.moussax.blightedSMP.engine.entities.components; + +import fr.moussax.blightedSMP.engine.entities.BlightedEntity; +import lombok.Getter; +import org.bukkit.Location; +import org.bukkit.entity.LivingEntity; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.*; + +/** + * Manages the registration, lifecycle, lookup, and periodic ticking of {@link EntityComponent}s. + */ +public final class EntityComponentManager { + + private final Map components = new HashMap<>(); + @Getter + private boolean initialized = false; + + public EntityComponentManager() { + } + + private EntityComponentManager(Map source) { + for (Map.Entry entry : source.entrySet()) { + EntityComponent fresh = AffixRegistry.getAffixById(entry.getKey()); + this.components.put(entry.getKey(), fresh != null ? fresh : entry.getValue()); + } + } + + /** + * Registers a component. If components have already been initialized, the component + * is immediately initialized against the live entity. + * + * @param component component to register + * @param liveEntity current live entity, or null if unspawned + */ + public void addComponent(@NonNull EntityComponent component, @Nullable LivingEntity liveEntity) { + Objects.requireNonNull(component, "component cannot be null"); + components.put(component.getId(), component); + if (initialized && liveEntity != null && liveEntity.isValid() && !liveEntity.isDead()) { + component.onInit(liveEntity); + } + } + + /** + * Retrieves a registered component by its unique string identifier. + * + * @param id component identifier + * @param expected component type + * @return component instance, or null if not registered + */ + @Nullable + @SuppressWarnings("unchecked") + public T getComponent(@NonNull String id) { + return (T) components.get(id); + } + + /** + * Retrieves the first registered component matching the specified class. + * + * @param componentClass class to match + * @param expected component type + * @return matching component instance, or null if none registered + */ + @Nullable + public T getComponent(@Nullable Class componentClass) { + if (componentClass == null) { + return null; + } + for (EntityComponent component : components.values()) { + if (componentClass.isInstance(component)) { + return componentClass.cast(component); + } + } + return null; + } + + /** + * Returns a snapshot of all currently registered components. + * + * @return list of registered components + */ + @NonNull + public Collection getComponents() { + return new ArrayList<>(components.values()); + } + + /** + * Initializes all registered components against the live entity. + * + * @param liveEntity bound entity + */ + public void init(@Nullable LivingEntity liveEntity) { + if (initialized || liveEntity == null) { + return; + } + initialized = true; + for (EntityComponent component : components.values()) { + component.onInit(liveEntity); + } + } + + /** + * Destroys all registered components. + * + * @param liveEntity bound entity + */ + public void destroy(@Nullable LivingEntity liveEntity) { + for (EntityComponent component : components.values()) { + component.onDestroy(liveEntity); + } + initialized = false; + } + + /** + * Executes periodic tick logic for all components. + * + * @param entity owner entity + */ + public void tick(@NonNull BlightedEntity entity) { + for (EntityComponent component : components.values()) { + component.onTick(entity); + } + } + + /** + * Notifies all components of the entity's death. + * + * @param entity owner entity + * @param location death location + */ + public void onDeath(@NonNull BlightedEntity entity, @Nullable Location location) { + if (location != null) { + for (EntityComponent component : components.values()) { + component.onDeath(entity, location); + } + } + } + + /** + * Creates an uninitialized detached copy of this component manager with freshly instantiated affixes. + * + * @return detached copy + */ + @NonNull + public EntityComponentManager copy() { + return new EntityComponentManager(this.components); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/defense/DefensesBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/defense/DefensesBuilder.java new file mode 100644 index 00000000..66b18376 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/defense/DefensesBuilder.java @@ -0,0 +1,41 @@ +package fr.moussax.blightedSMP.engine.entities.defense; + +import fr.moussax.blightedSMP.engine.entities.BlightedEntity; +import org.jspecify.annotations.NonNull; + +/** + * Fluent builder for configuring mob damage immunities and resistances. + */ +public final class DefensesBuilder { + + private final BlightedEntity entity; + + public DefensesBuilder(@NonNull BlightedEntity entity) { + this.entity = entity; + } + + /** + * Registers total damage immunity against the specified damage types. + * + * @param types damage types to completely negate + * @return this builder + */ + public DefensesBuilder immune(@NonNull DamageType... types) { + for (DamageType type : types) { + entity.addImmunity(type); + } + return this; + } + + /** + * Registers proportional damage reduction against the specified damage type. + * + * @param type damage type to resist + * @param percent percentage of damage resisted in range [0.0, 100.0] (e.g. 50.0 for 50% reduction) + * @return this builder + */ + public DefensesBuilder resist(@NonNull DamageType type, double percent) { + entity.addResistance(type, percent); + return this; + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/equipment/EntityEquipmentBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/equipment/EntityEquipmentBuilder.java new file mode 100644 index 00000000..e8668998 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/equipment/EntityEquipmentBuilder.java @@ -0,0 +1,170 @@ +package fr.moussax.blightedSMP.engine.entities.equipment; + +import lombok.Getter; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Fluent builder for configuring mob armor and hand equipment. + */ +@Getter +public final class EntityEquipmentBuilder { + + private ItemStack helmet; + private ItemStack chestplate; + private ItemStack leggings; + private ItemStack boots; + private ItemStack mainHand; + private ItemStack offHand; + + /** + * Sets the helmet item stack. + * + * @param helmet helmet item, or {@code null} to clear + * @return this builder + */ + public EntityEquipmentBuilder helmet(@Nullable ItemStack helmet) { + this.helmet = helmet; + return this; + } + + /** + * Sets the helmet to a new item of the given material. + * + * @param material helmet material + * @return this builder + */ + public EntityEquipmentBuilder helmet(@NonNull Material material) { + return helmet(new ItemStack(material)); + } + + /** + * Sets the chestplate item stack. + * + * @param chestplate chestplate item, or {@code null} to clear + * @return this builder + */ + public EntityEquipmentBuilder chestplate(@Nullable ItemStack chestplate) { + this.chestplate = chestplate; + return this; + } + + /** + * Sets the chestplate to a new item of the given material. + * + * @param material chestplate material + * @return this builder + */ + public EntityEquipmentBuilder chestplate(@NonNull Material material) { + return chestplate(new ItemStack(material)); + } + + /** + * Sets the leggings item stack. + * + * @param leggings leggings item, or {@code null} to clear + * @return this builder + */ + public EntityEquipmentBuilder leggings(@Nullable ItemStack leggings) { + this.leggings = leggings; + return this; + } + + /** + * Sets the leggings to a new item of the given material. + * + * @param material leggings material + * @return this builder + */ + public EntityEquipmentBuilder leggings(@NonNull Material material) { + return leggings(new ItemStack(material)); + } + + /** + * Sets the boots item stack. + * + * @param boots boots item, or {@code null} to clear + * @return this builder + */ + public EntityEquipmentBuilder boots(@Nullable ItemStack boots) { + this.boots = boots; + return this; + } + + /** + * Sets the boots to a new item of the given material. + * + * @param material boots material + * @return this builder + */ + public EntityEquipmentBuilder boots(@NonNull Material material) { + return boots(new ItemStack(material)); + } + + /** + * Sets all four armor slots in a single call. + * + * @param helmet helmet item, or {@code null} + * @param chestplate chestplate item, or {@code null} + * @param leggings leggings item, or {@code null} + * @param boots boots item, or {@code null} + * @return this builder + */ + public EntityEquipmentBuilder armor( + @Nullable ItemStack helmet, + @Nullable ItemStack chestplate, + @Nullable ItemStack leggings, + @Nullable ItemStack boots + ) { + this.helmet = helmet; + this.chestplate = chestplate; + this.leggings = leggings; + this.boots = boots; + return this; + } + + /** + * Sets the main hand item stack. + * + * @param mainHand main hand item, or {@code null} to clear + * @return this builder + */ + public EntityEquipmentBuilder mainHand(@Nullable ItemStack mainHand) { + this.mainHand = mainHand; + return this; + } + + /** + * Sets the main hand to a new item of the given material. + * + * @param material main hand material + * @return this builder + */ + public EntityEquipmentBuilder mainHand(@NonNull Material material) { + return mainHand(new ItemStack(material)); + } + + /** + * Sets the off hand item stack. + * + * @param offHand off hand item, or {@code null} to clear + * @return this builder + */ + public EntityEquipmentBuilder offHand(@Nullable ItemStack offHand) { + this.offHand = offHand; + return this; + } + + /** + * Sets the off hand to a new item of the given material. + * + * @param material off hand material + * @return this builder + */ + public EntityEquipmentBuilder offHand(@NonNull Material material) { + return offHand(new ItemStack(material)); + } + +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/equipment/EntityEquipmentHolder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/equipment/EntityEquipmentHolder.java new file mode 100644 index 00000000..80e4c681 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/equipment/EntityEquipmentHolder.java @@ -0,0 +1,366 @@ +package fr.moussax.blightedSMP.engine.entities.equipment; + +import org.bukkit.entity.LivingEntity; +import org.bukkit.inventory.EntityEquipment; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Manages equipment state for custom entities, holding template items and synchronizing with live Bukkit entities. + */ +public final class EntityEquipmentHolder { + + private ItemStack[] armor; + private ItemStack itemInMainHand; + private ItemStack itemInOffHand; + + public EntityEquipmentHolder() { + } + + private void ensureArmorArray() { + if (armor == null) { + armor = new ItemStack[4]; + } + } + + /** + * Sets the helmet item and synchronizes it with the live entity if active. + * + * @param helmet helmet item, or {@code null} to clear + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void setHelmet(@Nullable ItemStack helmet, @Nullable LivingEntity liveEntity) { + ensureArmorArray(); + this.armor[3] = helmet; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().setHelmet(helmet); + } + } + + /** + * Sets the chestplate item and synchronizes it with the live entity if active. + * + * @param chestplate chestplate item, or {@code null} to clear + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void setChestplate(@Nullable ItemStack chestplate, @Nullable LivingEntity liveEntity) { + ensureArmorArray(); + this.armor[2] = chestplate; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().setChestplate(chestplate); + } + } + + /** + * Sets the leggings item and synchronizes it with the live entity if active. + * + * @param leggings leggings item, or {@code null} to clear + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void setLeggings(@Nullable ItemStack leggings, @Nullable LivingEntity liveEntity) { + ensureArmorArray(); + this.armor[1] = leggings; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().setLeggings(leggings); + } + } + + /** + * Sets the boots item and synchronizes it with the live entity if active. + * + * @param boots boots item, or {@code null} to clear + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void setBoots(@Nullable ItemStack boots, @Nullable LivingEntity liveEntity) { + ensureArmorArray(); + this.armor[0] = boots; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().setBoots(boots); + } + } + + /** + * Sets all four armor slots and synchronizes them with the live entity if active. + * + * @param helmet helmet item, or {@code null} + * @param chestplate chestplate item, or {@code null} + * @param leggings leggings item, or {@code null} + * @param boots boots item, or {@code null} + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void setArmor( + @Nullable ItemStack helmet, + @Nullable ItemStack chestplate, + @Nullable ItemStack leggings, + @Nullable ItemStack boots, + @Nullable LivingEntity liveEntity + ) { + setHelmet(helmet, liveEntity); + setChestplate(chestplate, liveEntity); + setLeggings(leggings, liveEntity); + setBoots(boots, liveEntity); + } + + /** + * Clears all armor slots and strips armor from the live entity if active. + * + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void clearArmor(@Nullable LivingEntity liveEntity) { + this.armor = null; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().setArmorContents(new ItemStack[4]); + } + } + + /** + * Clears all armor and hand items and empties equipment on the live entity if active. + * + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void clearEquipment(@Nullable LivingEntity liveEntity) { + this.armor = null; + this.itemInMainHand = null; + this.itemInOffHand = null; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().clear(); + } + } + + /** + * Sets the main hand item and synchronizes it with the live entity if active. + * + * @param item main hand item, or {@code null} to clear + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void setMainHand(@Nullable ItemStack item, @Nullable LivingEntity liveEntity) { + this.itemInMainHand = item; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().setItemInMainHand(item); + } + } + + /** + * Sets the off hand item and synchronizes it with the live entity if active. + * + * @param item off hand item, or {@code null} to clear + * @param liveEntity live entity to update, or {@code null} if unspawned + */ + public void setOffHand(@Nullable ItemStack item, @Nullable LivingEntity liveEntity) { + this.itemInOffHand = item; + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + liveEntity.getEquipment().setItemInOffHand(item); + } + } + + /** + * Toggles whether the configured main hand item is currently equipped on the live entity. + * + * @param liveEntity live entity to update, or {@code null} + * @param equipped {@code true} to equip the item, {@code false} to temporarily unequip + */ + public void setMainHandEquipped(@Nullable LivingEntity liveEntity, boolean equipped) { + if (!isEntityActive(liveEntity) || liveEntity.getEquipment() == null) { + return; + } + liveEntity.getEquipment().setItemInMainHand(equipped ? itemInMainHand : null); + } + + /** + * Toggles whether the configured off hand item is currently equipped on the live entity. + * + * @param liveEntity live entity to update, or {@code null} + * @param equipped {@code true} to equip the item, {@code false} to temporarily unequip + */ + public void setOffHandEquipped(@Nullable LivingEntity liveEntity, boolean equipped) { + if (!isEntityActive(liveEntity) || liveEntity.getEquipment() == null) { + return; + } + liveEntity.getEquipment().setItemInOffHand(equipped ? itemInOffHand : null); + } + + /** + * Returns the template item held in the main hand. + * + * @return main hand template item, or {@code null} if empty + */ + @Nullable + public ItemStack getItemInMainHand() { + return itemInMainHand; + } + + /** + * Returns the template item held in the off hand. + * + * @return off hand template item, or {@code null} if empty + */ + @Nullable + public ItemStack getItemInOffHand() { + return itemInOffHand; + } + + /** + * Retrieves the current helmet from the live entity if active, or from stored template. + * + * @param liveEntity active live entity, or {@code null} + * @return helmet item, or {@code null} if empty + */ + @Nullable + public ItemStack getHelmet(@Nullable LivingEntity liveEntity) { + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + return liveEntity.getEquipment().getHelmet(); + } + return armor != null && armor.length > 3 ? armor[3] : null; + } + + /** + * Retrieves the current chestplate from the live entity if active, or from stored template. + * + * @param liveEntity active live entity, or {@code null} + * @return chestplate item, or {@code null} if empty + */ + @Nullable + public ItemStack getChestplate(@Nullable LivingEntity liveEntity) { + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + return liveEntity.getEquipment().getChestplate(); + } + return armor != null && armor.length > 2 ? armor[2] : null; + } + + /** + * Retrieves the current leggings from the live entity if active, or from stored template. + * + * @param liveEntity active live entity, or {@code null} + * @return leggings item, or {@code null} if empty + */ + @Nullable + public ItemStack getLeggings(@Nullable LivingEntity liveEntity) { + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + return liveEntity.getEquipment().getLeggings(); + } + return armor != null && armor.length > 1 ? armor[1] : null; + } + + /** + * Retrieves the current boots from the live entity if active, or from stored template. + * + * @param liveEntity active live entity, or {@code null} + * @return boots item, or {@code null} if empty + */ + @Nullable + public ItemStack getBoots(@Nullable LivingEntity liveEntity) { + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + return liveEntity.getEquipment().getBoots(); + } + return armor != null && armor.length > 0 ? armor[0] : null; + } + + /** + * Retrieves the current main hand item from the live entity if active, or from stored template. + * + * @param liveEntity active live entity, or {@code null} + * @return main hand item, or {@code null} if empty + */ + @Nullable + public ItemStack getMainHand(@Nullable LivingEntity liveEntity) { + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + return liveEntity.getEquipment().getItemInMainHand(); + } + return itemInMainHand; + } + + /** + * Retrieves the current off hand item from the live entity if active, or from stored template. + * + * @param liveEntity active live entity, or {@code null} + * @return off hand item, or {@code null} if empty + */ + @Nullable + public ItemStack getOffHand(@Nullable LivingEntity liveEntity) { + if (isEntityActive(liveEntity) && liveEntity.getEquipment() != null) { + return liveEntity.getEquipment().getItemInOffHand(); + } + return itemInOffHand; + } + + /** + * Applies configuration from an {@link EntityEquipmentBuilder}. + * + * @param builder configured builder + * @param liveEntity optional active entity to update immediately + */ + public void applyBuilder(@NonNull EntityEquipmentBuilder builder, @Nullable LivingEntity liveEntity) { + if (builder.getHelmet() != null) setHelmet(builder.getHelmet(), liveEntity); + if (builder.getChestplate() != null) setChestplate(builder.getChestplate(), liveEntity); + if (builder.getLeggings() != null) setLeggings(builder.getLeggings(), liveEntity); + if (builder.getBoots() != null) setBoots(builder.getBoots(), liveEntity); + if (builder.getMainHand() != null) setMainHand(builder.getMainHand(), liveEntity); + if (builder.getOffHand() != null) setOffHand(builder.getOffHand(), liveEntity); + } + + /** + * Applies all configured equipment to the newly spawned or rehydrated entity and zeroes drop chances. + * + * @param entity target living entity + */ + public void applyTo(@NonNull LivingEntity entity) { + EntityEquipment equipment = entity.getEquipment(); + if (equipment == null) { + return; + } + + if (armor != null) { + equipment.setArmorContents(armor); + } + if (itemInMainHand != null) { + equipment.setItemInMainHand(itemInMainHand); + } + if (itemInOffHand != null) { + equipment.setItemInOffHand(itemInOffHand); + } + + zeroEquipmentDropChances(equipment); + } + + /** + * Ensures equipment items do not drop naturally when the entity dies. + * Custom drops are controlled through the loot system. + * + * @param equipment entity equipment to zero + */ + public static void zeroEquipmentDropChances(@NonNull EntityEquipment equipment) { + equipment.setHelmetDropChance(0f); + equipment.setChestplateDropChance(0f); + equipment.setLeggingsDropChance(0f); + equipment.setBootsDropChance(0f); + equipment.setItemInMainHandDropChance(0f); + equipment.setItemInOffHandDropChance(0f); + } + + /** + * Creates a deep copy of this equipment holder with cloned item stacks. + * + * @return cloned equipment holder + */ + public EntityEquipmentHolder copy() { + EntityEquipmentHolder copy = new EntityEquipmentHolder(); + if (this.armor != null) { + copy.armor = new ItemStack[4]; + for (int i = 0; i < 4; i++) { + copy.armor[i] = cloneItem(this.armor[i]); + } + } + copy.itemInMainHand = cloneItem(this.itemInMainHand); + copy.itemInOffHand = cloneItem(this.itemInOffHand); + return copy; + } + + private static ItemStack cloneItem(ItemStack item) { + return item != null ? item.clone() : null; + } + + private static boolean isEntityActive(LivingEntity entity) { + return entity != null && entity.isValid() && !entity.isDead(); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/BlightedEntitiesListener.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/BlightedEntitiesListener.java index bbe25ea9..8227d142 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/BlightedEntitiesListener.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/BlightedEntitiesListener.java @@ -8,7 +8,6 @@ import fr.moussax.blightedSMP.engine.entities.defense.EntityImmunity; import fr.moussax.blightedSMP.engine.player.BlightedPlayer; import org.bukkit.Bukkit; -import org.bukkit.Chunk; import org.bukkit.Sound; import org.bukkit.entity.Entity; import org.bukkit.entity.LivingEntity; @@ -24,12 +23,13 @@ import org.bukkit.persistence.PersistentDataType; import org.bukkit.util.Vector; -import java.util.Collection; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import static fr.moussax.blightedSMP.engine.entities.BlightedEntity.*; +import static fr.moussax.blightedSMP.engine.entities.BlightedEntity.FAST_PASS_TAG; +import static fr.moussax.blightedSMP.engine.entities.attachment.EntityAttachmentManager.ATTACHMENT_OWNER_KEY; +import static fr.moussax.blightedSMP.engine.entities.attachment.EntityAttachmentManager.ATTACHMENT_ROLE_KEY; /** * Event listener handling damage, potion effects, health updates, death, and chunk loading @@ -43,54 +43,6 @@ public BlightedEntitiesListener() { EntityManager.initialize(); } - /** - * Delegates entity registration to {@link EntityManager#registerEntity(LivingEntity, BlightedEntity)}. - * - * @param entity living entity to track - * @param blighted blighted entity wrapper instance - */ - public static void registerEntity(LivingEntity entity, BlightedEntity blighted) { - EntityManager.registerEntity(entity, blighted); - } - - /** - * Delegates entity unregistration to {@link EntityManager#unregisterEntity(LivingEntity)}. - * - * @param entity living entity to stop tracking - */ - public static void unregisterEntity(LivingEntity entity) { - EntityManager.unregisterEntity(entity); - } - - /** - * Delegates attachment registration to {@link EntityManager#registerAttachment(Entity, BlightedEntity)}. - * - * @param attachment attachment entity - * @param owner owning blighted entity wrapper - */ - public static void registerAttachment(Entity attachment, BlightedEntity owner) { - EntityManager.registerAttachment(attachment, owner); - } - - /** - * Delegates attachment unregistration to {@link EntityManager#unregisterAttachment(Entity)}. - * - * @param attachment attachment entity to unregister - */ - public static void unregisterAttachment(Entity attachment) { - EntityManager.unregisterAttachment(attachment); - } - - /** - * Delegates entity lookup to {@link EntityManager#getBlightedEntity(Entity)}. - * - * @param entity target entity - * @return blighted entity wrapper, or {@code null} if untracked - */ - public static BlightedEntity getBlightedEntity(Entity entity) { - return EntityManager.getBlightedEntity(entity); - } - @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onEntityDamage(EntityDamageEvent event) { if (event instanceof EntityDamageByEntityEvent damageByEntity) { @@ -163,7 +115,7 @@ private void handleDamageDealt(EntityDamageByEntityEvent event) { ? shooter : rawDamager; - BlightedEntity damager = getBlightedEntity(source); + BlightedEntity damager = EntityManager.getBlightedEntity(source); if (damager != null) { damager.onDamageDealt(event); } @@ -333,15 +285,7 @@ public void onEntityDeath(EntityDeathEvent event) { @EventHandler public void onChunkLoad(ChunkLoadEvent event) { - Bukkit.getScheduler().runTaskLater(BlightedSMP.getInstance(), () -> rehydrateChunk(event.getChunk()), 1L); - } - - public static Collection getActiveEntities() { - return EntityManager.getActiveEntities(); - } - - public static void rehydrateChunk(Chunk chunk) { - EntityManager.rehydrateChunk(chunk); + Bukkit.getScheduler().runTaskLater(BlightedSMP.getInstance(), () -> EntityManager.rehydrateChunk(event.getChunk()), 1L); } private Player getPlayerDamager(Entity damager) { diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/EntityComponentListener.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/EntityComponentListener.java index b01f6f17..90f44796 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/EntityComponentListener.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/EntityComponentListener.java @@ -9,7 +9,7 @@ import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityDamageByEntityEvent; -import static fr.moussax.blightedSMP.engine.entities.listeners.BlightedEntitiesListener.getBlightedEntity; +import static fr.moussax.blightedSMP.engine.entities.EntityManager.getBlightedEntity; public final class EntityComponentListener implements Listener { diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/SpawnableEntitiesListener.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/SpawnableEntitiesListener.java index 065ed9ef..5eadcd71 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/SpawnableEntitiesListener.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/SpawnableEntitiesListener.java @@ -1,6 +1,6 @@ package fr.moussax.blightedSMP.engine.entities.listeners; -import fr.moussax.blightedSMP.engine.entities.registry.SpawnableEntitiesRegistry; +import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry; import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; import fr.moussax.blightedSMP.engine.entities.spawnable.engine.SpawnEvaluator; import fr.moussax.blightedSMP.engine.entities.spawnable.engine.SpawnMode; @@ -43,7 +43,7 @@ public void onCreatureSpawn(CreatureSpawnEvent event) { SpawnableEntity selected = SpawnEvaluator.selectCandidate(candidates, location, world, ThreadLocalRandom.current()); if (selected != null) { event.setCancelled(true); - selected.clone().spawn(location); + selected.createInstance().spawn(location); } } @@ -54,10 +54,10 @@ public synchronized void rebuildCache() { if (!cacheDirty) return; Map> newCache = new EnumMap<>(EntityType.class); - for (SpawnableEntity entity : SpawnableEntitiesRegistry.getAll()) { + for (SpawnableEntity entity : EntitiesRegistry.getSpawnables()) { SpawnMode mode = entity.getSpawnMode(); if (mode == SpawnMode.REPLACEMENT || mode == SpawnMode.HYBRID) { - newCache.computeIfAbsent(entity.getEntityType(), k -> new ArrayList<>()).add(entity); + newCache.computeIfAbsent(entity.getEntityType(), entityType -> new ArrayList<>()).add(entity); } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhaseManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhaseManager.java new file mode 100644 index 00000000..fb4b0ab2 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhaseManager.java @@ -0,0 +1,193 @@ +package fr.moussax.blightedSMP.engine.entities.phases; + +import fr.moussax.bedrock.utils.debug.Log; +import fr.moussax.blightedSMP.engine.entities.BlightedEntity; +import org.jspecify.annotations.NonNull; + +import java.util.*; +import java.util.function.DoubleToLongFunction; + +/** + * Manages health-based phase transitions, core abilities, and phase-bound lifecycle tasks for an entity. + */ +public final class EntityPhaseManager { + + private final BlightedEntity owner; + private final DoubleToLongFunction transitionHandler; + private final NavigableMap phaseThresholds; + private LifecycleTaskManager coreTasks = new LifecycleTaskManager(); + private LifecycleTaskManager phaseTasks = new LifecycleTaskManager(); + + public EntityPhaseManager(@NonNull BlightedEntity owner, @NonNull DoubleToLongFunction transitionHandler) { + this(owner, transitionHandler, new TreeMap<>(Collections.reverseOrder())); + } + + private EntityPhaseManager( + @NonNull BlightedEntity owner, + @NonNull DoubleToLongFunction transitionHandler, + @NonNull NavigableMap thresholds + ) { + this.owner = Objects.requireNonNull(owner, "owner cannot be null"); + this.transitionHandler = Objects.requireNonNull(transitionHandler, "transitionHandler cannot be null"); + this.phaseThresholds = new TreeMap<>(thresholds); + } + + /** + * Registers a phase triggered at or below the given health percentage. + * + * @param healthPercentage threshold in the range {@code 0.0–1.0} + * @param onTransition action executed when the phase is triggered + */ + public void registerPhase(double healthPercentage, @NonNull Runnable onTransition) { + phaseThresholds.put(healthPercentage, Objects.requireNonNull(onTransition, "onTransition cannot be null")); + } + + /** + * Evaluates registered phases against the entity's current health. + * + * @param currentHealth current health value + */ + public void evaluatePhases(double currentHealth) { + if (phaseThresholds.isEmpty()) { + return; + } + + double maxHealth = Math.max(1, owner.getMaxHealth()); + double healthPercentage = currentHealth / maxHealth; + + while (!phaseThresholds.isEmpty() && healthPercentage <= phaseThresholds.firstKey()) { + Map.Entry entry = phaseThresholds.pollFirstEntry(); + if (entry == null) { + break; + } + + phaseTasks.cancelAll(); + phaseTasks = new LifecycleTaskManager(); + entry.getValue().run(); + long transitionDuration = transitionHandler.applyAsLong(entry.getKey()); + + if (transitionDuration > 0) { + owner.setPerformingAbility(true); + addCoreDelayedAction(transitionDuration, () -> { + owner.setPerformingAbility(false); + phaseTasks.scheduleAll(); + }); + } else { + phaseTasks.scheduleAll(); + } + } + } + + /** + * Registers a repeating task in the core lifecycle. + * + * @param delayTicks initial delay in server ticks + * @param periodTicks execution interval in server ticks + * @param action task action + */ + public void addCoreAbility(long delayTicks, long periodTicks, @NonNull Runnable action) { + scheduleAbility(coreTasks, delayTicks, periodTicks, action); + } + + /** + * Registers a repeating task in the current phase lifecycle. + * + * @param delayTicks initial delay in server ticks + * @param periodTicks execution interval in server ticks + * @param action task action + */ + public void addPhaseAbility(long delayTicks, long periodTicks, @NonNull Runnable action) { + scheduleAbility(phaseTasks, delayTicks, periodTicks, action); + } + + /** + * Registers a delayed task in the core lifecycle. + * + * @param delayTicks delay in server ticks + * @param action task action + */ + public void addCoreDelayedAction(long delayTicks, @NonNull Runnable action) { + scheduleDelayedAction(coreTasks, delayTicks, action); + } + + /** + * Registers a delayed task in the current phase lifecycle. + * + * @param delayTicks delay in server ticks + * @param action task action + */ + public void addPhaseDelayedAction(long delayTicks, @NonNull Runnable action) { + scheduleDelayedAction(phaseTasks, delayTicks, action); + } + + /** + * Schedules all core and phase tasks with the server scheduler. + */ + public void scheduleAllTasks() { + coreTasks.scheduleAll(); + phaseTasks.scheduleAll(); + } + + /** + * Schedules all core tasks with the server scheduler. + */ + public void scheduleAllCoreTasks() { + coreTasks.scheduleAll(); + } + + /** + * Cancels all scheduled core and phase tasks. + */ + public void cancelAllTasks() { + coreTasks.cancelAll(); + phaseTasks.cancelAll(); + } + + private void scheduleAbility(LifecycleTaskManager manager, long delayTicks, long periodTicks, Runnable action) { + manager.addRepeatingTask(() -> { + if (!owner.isAlive()) { + return; + } + try { + action.run(); + } catch (Exception exception) { + Log.warn("EntityPhaseManager", "Ability threw an exception on entity '" + owner.getName() + "': " + exception.getMessage()); + } + }, delayTicks, periodTicks); + if (canScheduleTask()) { + manager.scheduleLast(); + } + } + + private void scheduleDelayedAction(LifecycleTaskManager manager, long delayTicks, Runnable action) { + manager.addDelayedTask(() -> { + if (!owner.isAlive()) { + return; + } + try { + action.run(); + } catch (Exception exception) { + Log.warn("EntityPhaseManager", "Delayed action threw an exception on entity '" + owner.getName() + "': " + exception.getMessage()); + } + }, delayTicks); + if (canScheduleTask()) { + manager.scheduleLast(); + } + } + + private boolean canScheduleTask() { + return owner.getEntity() != null && !owner.getEntity().isDead() && owner.isRuntimeInitialized(); + } + + /** + * Creates a detached copy of this phase manager bound to a new owner and transition handler. + * + * @param newOwner target entity instance + * @param newHandler transition callback + * @return detached phase manager + */ + @NonNull + public EntityPhaseManager copy(@NonNull BlightedEntity newOwner, @NonNull DoubleToLongFunction newHandler) { + return new EntityPhaseManager(newOwner, newHandler, this.phaseThresholds); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhasesBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhasesBuilder.java new file mode 100644 index 00000000..dfd2882b --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhasesBuilder.java @@ -0,0 +1,30 @@ +package fr.moussax.blightedSMP.engine.entities.phases; + +import fr.moussax.blightedSMP.engine.entities.BlightedEntity; +import org.jspecify.annotations.NonNull; + +import java.util.Objects; + +/** + * Fluent builder for configuring mob health phase transitions. + */ +public final class EntityPhasesBuilder { + + private final BlightedEntity entity; + + public EntityPhasesBuilder(@NonNull BlightedEntity entity) { + this.entity = Objects.requireNonNull(entity, "entity cannot be null"); + } + + /** + * Registers an action executed when the entity's health percentage drops to or below the threshold. + * + * @param healthThreshold health percentage in range [0.0, 1.0] (e.g. 0.50 for 50% HP) + * @param action action to execute + * @return this builder + */ + public EntityPhasesBuilder at(double healthThreshold, @NonNull Runnable action) { + entity.getPhaseManager().registerPhase(healthThreshold, action); + return this; + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/LifecycleTaskManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/LifecycleTaskManager.java similarity index 52% rename from blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/LifecycleTaskManager.java rename to blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/LifecycleTaskManager.java index 234a6f42..8e541a5e 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/LifecycleTaskManager.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/LifecycleTaskManager.java @@ -1,16 +1,15 @@ -package fr.moussax.blightedSMP.engine.entities; +package fr.moussax.blightedSMP.engine.entities.phases; import fr.moussax.blightedSMP.BlightedSMP; import org.bukkit.Bukkit; -import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.scheduler.BukkitTask; import java.util.ArrayList; import java.util.List; -import java.util.function.Supplier; +import java.util.Objects; /** - * Manages scheduler tasks bound to the lifecycle of a {@link BlightedEntity}. + * Manages scheduler tasks bound to the lifecycle of a {@link fr.moussax.blightedSMP.engine.entities.BlightedEntity}. *

* Allows entities to register delayed or repeating tasks that are automatically * scheduled on initialization and canceled on destruction. @@ -28,7 +27,7 @@ public final class LifecycleTaskManager { */ public void addRepeatingTask(Runnable action, long delayTicks, long periodTicks) { ensureList(); - tasks.add(new ScheduledTask(action, null, delayTicks, periodTicks, true)); + tasks.add(new ScheduledTask(Objects.requireNonNull(action, "action cannot be null"), delayTicks, periodTicks, true)); } /** @@ -39,30 +38,7 @@ public void addRepeatingTask(Runnable action, long delayTicks, long periodTicks) */ public void addDelayedTask(Runnable action, long delayTicks) { ensureList(); - tasks.add(new ScheduledTask(action, null, delayTicks, 0L, false)); - } - - /** - * Adds a repeating task using a {@link BukkitRunnable} supplier (for backwards compatibility). - * - * @param factory supplier creating a new {@link BukkitRunnable} instance - * @param delayTicks initial delay before the first execution, in ticks - * @param periodTicks interval between consecutive runs, in ticks - */ - public void addRepeatingTask(Supplier factory, long delayTicks, long periodTicks) { - ensureList(); - tasks.add(new ScheduledTask(null, factory, delayTicks, periodTicks, true)); - } - - /** - * Adds a delayed task using a {@link BukkitRunnable} supplier (for backwards compatibility). - * - * @param factory supplier creating a new {@link BukkitRunnable} instance - * @param delayTicks delay before execution, in ticks - */ - public void addDelayedTask(Supplier factory, long delayTicks) { - ensureList(); - tasks.add(new ScheduledTask(null, factory, delayTicks, 0L, false)); + tasks.add(new ScheduledTask(Objects.requireNonNull(action, "action cannot be null"), delayTicks, 0L, false)); } /** @@ -106,21 +82,13 @@ private void onTaskComplete(ScheduledTask task) { private static final class ScheduledTask { private final Runnable action; - private final Supplier factory; private final long delayTicks; private final long periodTicks; private final boolean repeating; private BukkitTask currentTask; - private BukkitRunnable currentRunnable; - - private ScheduledTask( - Runnable action, - Supplier factory, - long delayTicks, - long periodTicks, - boolean repeating) { + + private ScheduledTask(Runnable action, long delayTicks, long periodTicks, boolean repeating) { this.action = action; - this.factory = factory; this.delayTicks = delayTicks; this.periodTicks = periodTicks; this.repeating = repeating; @@ -132,29 +100,13 @@ private void schedule(LifecycleTaskManager manager) { var plugin = BlightedSMP.getInstance(); if (repeating) { - if (action != null) { - currentTask = Bukkit.getScheduler().runTaskTimer(plugin, action, delayTicks, periodTicks); - } else if (factory != null) { - currentRunnable = factory.get(); - if (currentRunnable != null) { - currentTask = currentRunnable.runTaskTimer(plugin, delayTicks, periodTicks); - } - } + currentTask = Bukkit.getScheduler().runTaskTimer(plugin, action, delayTicks, periodTicks); return; } - Runnable taskAction = action != null ? action : () -> { - if (factory != null) { - currentRunnable = factory.get(); - if (currentRunnable != null) { - currentRunnable.run(); - } - } - }; - currentTask = Bukkit.getScheduler().runTaskLater(plugin, () -> { try { - taskAction.run(); + action.run(); } finally { manager.onTaskComplete(ScheduledTask.this); } @@ -169,15 +121,7 @@ private void cancel() { } currentTask = null; } - if (currentRunnable != null) { - try { - if (!currentRunnable.isCancelled()) { - currentRunnable.cancel(); - } - } catch (Exception _) { - } - currentRunnable = null; - } } } } + diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/EntitiesRegistry.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/EntitiesRegistry.java index e0e93b1c..53703f74 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/EntitiesRegistry.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/EntitiesRegistry.java @@ -5,32 +5,39 @@ import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; import fr.moussax.blightedSMP.registry.EngineRegistry; import fr.moussax.blightedSMP.registry.RegistryModule; +import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; +import java.util.function.Supplier; /** - * Central registry for prototype {@link BlightedEntity} instances. + * Central registry for {@link BlightedEntity} definitions and instance factories. + * Instantiates pristine entities on demand using registered factories or zero-argument constructors. */ public final class EntitiesRegistry { private static final EngineRegistry REGISTRY = new EngineRegistry<>("EntitiesRegistry", BlightedEntity::getEntityId); + private static final Map> FACTORIES = new ConcurrentHashMap<>(); private static final List onRegisterCallbacks = new ArrayList<>(); private EntitiesRegistry() { } /** - * Adds callback executed when a prototype entity is registered. + * Adds callback executed when an entity is registered. * * @param callback callback runnable */ - public static void addOnRegisterCallback(Runnable callback) { - onRegisterCallbacks.add(callback); + public static void addOnRegisterCallback(@NonNull Runnable callback) { + onRegisterCallbacks.add(Objects.requireNonNull(callback, "callback cannot be null")); } /** @@ -44,76 +51,182 @@ public static void initialize(List>> mod } /** - * Registers entity prototype and executes registration callbacks. + * Registers an entity definition with a supplier factory. + * All subsequent creations will invoke this factory to produce pristine, unshared instances. + * + * @param factory entity factory + * @param entity type + */ + public static void register(@NonNull Supplier factory) { + Objects.requireNonNull(factory, "factory cannot be null"); + T sample = factory.get(); + if (sample == null) { + return; + } + REGISTRY.register(sample); + FACTORIES.put(sample.getEntityId(), factory); + notifyCallbacks(); + } + + /** + * Registers an entity prototype and binds an automatic zero-argument constructor factory. * * @param entity entity prototype to register */ - public static void register(BlightedEntity entity) { - if (entity == null) return; + public static void register(@Nullable BlightedEntity entity) { + if (entity == null) { + return; + } REGISTRY.register(entity); + registerDefaultFactory(entity); + notifyCallbacks(); + } - onRegisterCallbacks.forEach(callback -> { + /** + * Binds an explicit factory supplier for a specific entity ID. + * + * @param entityId target entity ID + * @param factory supplier factory + * @param entity type + */ + public static void registerFactory(@NonNull String entityId, @NonNull Supplier factory) { + FACTORIES.put( + Objects.requireNonNull(entityId, "entityId cannot be null"), + Objects.requireNonNull(factory, "factory cannot be null") + ); + } + + /** + * Creates and returns a fresh, independent instance of the registered entity + * ready to be spawned in the world. Prefers the registered factory supplier, + * falling back to zero-argument constructor reflection. + * + * @param entityId target entity identifier + * @param expected entity type + * @return fresh entity instance, or {@code null} if untracked + */ + @Nullable + @SuppressWarnings("unchecked") + public static T create(@Nullable String entityId) { + if (entityId == null) { + return null; + } + Supplier factory = FACTORIES.get(entityId); + if (factory != null) { try { - callback.run(); + return (T) factory.get(); } catch (Throwable throwable) { - Log.error("EntitiesRegistry", "Failed to execute onRegister callback: " + throwable.getMessage()); + Log.error("EntitiesRegistry", "Failed to create entity from factory '" + entityId + "': " + throwable.getMessage()); } - }); + } + return (T) instantiate(REGISTRY.get(entityId)); } /** - * Retrieves cloned instance of registered entity prototype by ID. + * Creates and returns a fresh, independent instance of the registered spawnable entity + * ready to be spawned in the world. * * @param entityId target entity identifier - * @return cloned entity instance, or {@code null} if untracked + * @return fresh spawnable instance, or {@code null} if untracked or not spawnable */ @Nullable - public static BlightedEntity get(String entityId) { - BlightedEntity prototype = REGISTRY.get(entityId); - return prototype != null ? prototype.clone() : null; + public static SpawnableEntity createSpawnable(@Nullable String entityId) { + BlightedEntity entity = create(entityId); + if (entity instanceof SpawnableEntity spawnable) { + return spawnable; + } + return null; } /** - * Retrieves a cloned instance of a registered spawnable entity prototype by ID. + * Retrieves the registered definition template for read-only inspection. + * Must not be spawned directly into the world. * * @param entityId target entity identifier - * @return cloned spawnable entity instance, or {@code null} if untracked or not spawnable + * @return registered definition template, or {@code null} if untracked */ @Nullable - public static SpawnableEntity getSpawnable(String entityId) { - BlightedEntity entity = REGISTRY.get(entityId); - if (!(entity instanceof SpawnableEntity spawnable)) { - return null; - } - return spawnable.clone(); + public static BlightedEntity getPrototype(@Nullable String entityId) { + return entityId != null ? REGISTRY.get(entityId) : null; } /** - * Returns a list of cloned instances of all registered entity prototypes. + * Returns a list of fresh instances of all registered entities. * - * @return list of cloned entity prototypes + * @return list of fresh entity instances */ + @NonNull public static List getAll() { - return REGISTRY.getAll().stream().map(BlightedEntity::clone).toList(); + return REGISTRY.getAll().stream() + .map(entity -> { + BlightedEntity fresh = create(entity.getEntityId()); + return fresh != null ? fresh : entity; + }) + .toList(); } /** - * Returns a list of cloned instances of all registered spawnable entity prototypes. + * Returns a list of fresh instances of all registered spawnable entities. * - * @return list of cloned spawnable entity prototypes + * @return list of fresh spawnable entity instances */ + @NonNull public static List getSpawnables() { return REGISTRY.getAll().stream() .filter(SpawnableEntity.class::isInstance) - .map(entity -> ((SpawnableEntity) entity).clone()) + .map(entity -> { + SpawnableEntity fresh = createSpawnable(entity.getEntityId()); + return fresh != null ? fresh : (SpawnableEntity) entity; + }) .toList(); } /** - * Clears all registered entity prototypes and registration callbacks. + * Clears all registered entity prototypes, factories, and registration callbacks. */ public static void clear() { REGISTRY.clear(); + FACTORIES.clear(); onRegisterCallbacks.clear(); } + + private static BlightedEntity instantiate(@Nullable BlightedEntity prototype) { + if (prototype == null) { + return null; + } + Class clazz = prototype.getClass(); + try { + var constructor = clazz.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (Throwable throwable) { + Log.error("EntitiesRegistry", "Failed to instantiate entity '" + prototype.getEntityId() + + "' (" + clazz.getName() + "). Ensure a public zero-arg constructor exists."); + return null; + } + } + + private static void registerDefaultFactory(@NonNull BlightedEntity entity) { + Class clazz = entity.getClass(); + FACTORIES.put(entity.getEntityId(), () -> { + try { + var constructor = clazz.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (Throwable throwable) { + throw new IllegalStateException("Failed to instantiate entity '" + entity.getEntityId() + + "' (" + clazz.getName() + "). Ensure a public zero-arg constructor exists or register a factory.", throwable); + } + }); + } + + private static void notifyCallbacks() { + onRegisterCallbacks.forEach(callback -> { + try { + callback.run(); + } catch (Throwable throwable) { + Log.error("EntitiesRegistry", "Failed to execute onRegister callback: " + throwable.getMessage()); + } + }); + } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/SpawnableEntitiesRegistry.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/SpawnableEntitiesRegistry.java deleted file mode 100644 index 7b53859a..00000000 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/SpawnableEntitiesRegistry.java +++ /dev/null @@ -1,51 +0,0 @@ -package fr.moussax.blightedSMP.engine.entities.registry; - -import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; -import org.jspecify.annotations.Nullable; - -import java.util.List; - -/** - * Convenience view delegating spawnable entity queries to {@link EntitiesRegistry}. - */ -public final class SpawnableEntitiesRegistry { - - private SpawnableEntitiesRegistry() { - } - - /** - * Retrieves a cloned instance of a registered spawnable entity prototype by ID. - * - * @param entityId target entity identifier - * @return cloned spawnable entity instance, or {@code null} if untracked or not spawnable - */ - @Nullable - public static SpawnableEntity get(String entityId) { - return EntitiesRegistry.getSpawnable(entityId); - } - - /** - * Returns a list of cloned instances of all registered spawnable entity prototypes. - * - * @return list of cloned spawnable entity prototypes - */ - public static List getAll() { - return EntitiesRegistry.getSpawnables(); - } - - /** - * Returns total number of registered spawnable entity prototypes. - * - * @return registered spawnable entity count - */ - public static int count() { - return EntitiesRegistry.getSpawnables().size(); - } - - /** - * No-op delegate for compatibility; delegate clearing is handled by {@link EntitiesRegistry#clear()}. - */ - public static void clear() { - // No-op: delegate handled by EntitiesRegistry.clear() - } -} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/AncientCreature.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/AncientCreature.java index 28ab4e4d..3c681dca 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/AncientCreature.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/AncientCreature.java @@ -5,6 +5,8 @@ import fr.moussax.blightedSMP.content.sound.BlightedSounds; import fr.moussax.blightedSMP.engine.entities.BlightedEntity; import fr.moussax.blightedSMP.engine.entities.attachment.AttachmentRole; +import fr.moussax.blightedSMP.engine.entities.attachment.EntityAttachmentManager; +import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry; import lombok.Getter; import lombok.Setter; import org.bukkit.Bukkit; @@ -38,10 +40,9 @@ public abstract class AncientCreature extends BlightedEntity { new NamespacedKey(BlightedSMP.getInstance(), "ancient_creature_hologram"); @Getter - @Setter - protected int timeAllowance; + protected int timeAllowance = DEFAULT_TIME_ALLOWANCE_SECONDS; @Getter - protected int remainingSeconds; + protected int remainingSeconds = DEFAULT_TIME_ALLOWANCE_SECONDS; @Getter @Setter protected String summonerName = "Unknown"; @@ -53,81 +54,26 @@ public abstract class AncientCreature extends BlightedEntity { private boolean isCollapsing = false; /** - * Creates an ancient creature using the default time allowance. - * - * @param name creature display name - * @param maxHealth creature maximum health - * @param entityType Bukkit entity type - */ - public AncientCreature(@NonNull String name, int maxHealth, EntityType entityType) { - this(name, maxHealth, 1, 0, entityType, DEFAULT_TIME_ALLOWANCE_SECONDS); - } - - /** - * Creates an ancient creature using the default time allowance. - * - * @param name creature display name - * @param maxHealth creature maximum health - * @param damage base attack damage - * @param entityType Bukkit entity type - */ - public AncientCreature(@NonNull String name, int maxHealth, int damage, EntityType entityType) { - this(name, maxHealth, damage, 0, entityType, DEFAULT_TIME_ALLOWANCE_SECONDS); - } - - /** - * Creates an ancient creature using the default time allowance. + * Creates an ancient creature with identity requirements using the default time allowance. * + * @param entityId unique entity identifier * @param name creature display name - * @param maxHealth creature maximum health - * @param damage base attack damage - * @param defense base armor value * @param entityType Bukkit entity type */ - public AncientCreature(@NonNull String name, int maxHealth, int damage, int defense, EntityType entityType) { - this(name, maxHealth, damage, defense, entityType, DEFAULT_TIME_ALLOWANCE_SECONDS); + public AncientCreature(@NonNull String entityId, @NonNull String name, @NonNull EntityType entityType) { + super(entityId, name, entityType); + boss(); } /** - * Creates an ancient creature using the specified time allowance. + * Sets the time allowance to defeat this creature, in seconds. + * Also updates the remaining countdown seconds if the creature has not collapsed. * - * @param name creature display name - * @param maxHealth creature maximum health - * @param entityType Bukkit entity type - * @param timeAllowanceSeconds time allowed to defeat the creature, in seconds + * @param timeAllowanceSeconds time allowed in seconds */ - public AncientCreature(@NonNull String name, int maxHealth, EntityType entityType, int timeAllowanceSeconds) { - this(name, maxHealth, 1, 0, entityType, timeAllowanceSeconds); - } - - /** - * Creates an ancient creature using the specified damage and time allowance. - * - * @param name creature display name - * @param maxHealth creature maximum health - * @param damage base attack damage - * @param entityType Bukkit entity type - * @param timeAllowanceSeconds time allowed to defeat the creature, in seconds - */ - public AncientCreature(@NonNull String name, int maxHealth, int damage, EntityType entityType, int timeAllowanceSeconds) { - this(name, maxHealth, damage, 0, entityType, timeAllowanceSeconds); - } - - /** - * Creates an ancient creature using the specified combat stats and time allowance. - * - * @param name creature display name - * @param maxHealth creature maximum health - * @param damage base attack damage - * @param defense base armor value - * @param entityType Bukkit entity type - * @param timeAllowanceSeconds time allowed to defeat the creature, in seconds - */ - public AncientCreature(@NonNull String name, int maxHealth, int damage, int defense, EntityType entityType, int timeAllowanceSeconds) { - super(name, maxHealth, damage, defense, entityType); + public void setTimeAllowance(int timeAllowanceSeconds) { this.timeAllowance = timeAllowanceSeconds; this.remainingSeconds = timeAllowanceSeconds; - setBoss(true); } /** @@ -192,7 +138,7 @@ protected void onRehydrate(LivingEntity existing) { if (!(nearby instanceof TextDisplay display)) continue; PersistentDataContainer persistentDataContainer = display.getPersistentDataContainer(); - String attachedOwner = persistentDataContainer.get(ATTACHMENT_OWNER_KEY, PersistentDataType.STRING); + String attachedOwner = persistentDataContainer.get(EntityAttachmentManager.ATTACHMENT_OWNER_KEY, PersistentDataType.STRING); if (!ownerUuid.equals(attachedOwner)) continue; if (persistentDataContainer.has(HOLOGRAM_KEY, PersistentDataType.BYTE)) { @@ -294,4 +240,14 @@ public void onDeath(Location location) { super.onDeath(location); BlightedSounds.ANCIENT_MOB_DEFEAT.play(location); } + + @NonNull + @Override + public AncientCreature createInstance() { + BlightedEntity fresh = EntitiesRegistry.create(getEntityId()); + if (fresh instanceof AncientCreature ancient) { + return ancient; + } + return (AncientCreature) super.createInstance(); + } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java index 4821a16e..25b32711 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java @@ -8,8 +8,6 @@ import fr.moussax.blightedSMP.engine.entities.rituals.registry.RitualRegistry; import fr.moussax.blightedSMP.engine.items.recipes.CraftingObject; import fr.moussax.blightedSMP.utils.Utilities; -import org.bukkit.ChatColor; -import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.jspecify.annotations.NonNull; diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java index 9995c710..28c130d0 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java @@ -17,7 +17,6 @@ import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; -import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -146,7 +145,7 @@ private void checkRequirements(Player player) { } boolean hasItems = requiredCounts.entrySet().stream() - .allMatch(e -> inventoryCounts.getOrDefault(e.getKey(), 0) >= e.getValue()); + .allMatch(entry -> inventoryCounts.getOrDefault(entry.getKey(), 0) >= entry.getValue()); boolean hasGems = blightedPlayer.hasGems(ritual.getGemsCost()); boolean hasXp = player.getLevel() >= ritual.getLevelCost(); @@ -314,7 +313,7 @@ private void handleFinalImpact(Location location) { Bukkit.broadcastMessage("§5 ☤ §dThe §4" + ritual.getSummonedCreature().getName() + " §dhas been summoned by §f" + player.getName() + "§d."); - AncientCreature creature = (AncientCreature) ritual.getSummonedCreature().clone(); + AncientCreature creature = ritual.getSummonedCreature().createInstance(); creature.setSummoner(player); creature.spawn(location); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnableEntity.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnableEntity.java index d9fd0ae2..36295454 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnableEntity.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnableEntity.java @@ -4,6 +4,7 @@ import fr.moussax.blightedSMP.engine.entities.BlightedEntity; import fr.moussax.blightedSMP.engine.entities.components.AffixRegistry; import fr.moussax.blightedSMP.engine.entities.components.EntityComponent; +import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnCondition; import fr.moussax.blightedSMP.engine.entities.spawnable.engine.SpawnMode; import lombok.Getter; @@ -14,9 +15,11 @@ import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; import org.bukkit.persistence.PersistentDataType; +import org.jspecify.annotations.NonNull; import java.util.ArrayList; import java.util.List; +import java.util.Objects; /** * Abstract base class for custom entities capable of spawning naturally in the world. @@ -33,13 +36,35 @@ public abstract class SpawnableEntity extends BlightedEntity { new NamespacedKey(BlightedSMP.getInstance(), "blighted_active_affix"); @Getter - private final double spawnProbability; + private double spawnProbability; @Getter - private final SpawnMode spawnMode; + private SpawnMode spawnMode; private SpawnProfile spawnProfile; + /** + * Sets the natural spawn probability in the range {@code [0.0, 1.0]}. + * + * @param spawnProbability probability in [0.0, 1.0] + * @throws IllegalArgumentException if outside [0.0, 1.0] + */ + public void setSpawnProbability(double spawnProbability) { + if (spawnProbability < 0.0 || spawnProbability > 1.0) { + throw new IllegalArgumentException("spawnProbability must be in [0.0, 1.0], got: " + spawnProbability); + } + this.spawnProbability = spawnProbability; + } + + /** + * Sets the spawn mode. + * + * @param spawnMode spawn mode + */ + public void setSpawnMode(@NonNull SpawnMode spawnMode) { + this.spawnMode = Objects.requireNonNull(spawnMode, "spawnMode cannot be null"); + } + @Getter private double affixChance = 0.0; @@ -68,88 +93,21 @@ public void setMaxAffixes(int maxAffixes) { this.maxAffixes = Math.max(1, maxAffixes); } - /** - * Constructs a spawnable entity with default damage and defense attributes in replacement mode. - * - * @param entityId unique entity identifier - * @param name display name - * @param maxHealth maximum health - * @param entityType underlying a Minecraft entity type - * @param probability spawn probability in range {@code [0.0, 1.0]} - */ - protected SpawnableEntity(String entityId, String name, int maxHealth, EntityType entityType, double probability) { - this(entityId, name, maxHealth, 1, 0, entityType, probability, SpawnMode.REPLACEMENT); - } - - /** - * Constructs a spawnable entity with default damage and defense attributes in the specified spawn mode. - * - * @param entityId unique entity identifier - * @param name display name - * @param maxHealth maximum health - * @param entityType underlying a Minecraft entity type - * @param probability spawn probability in range {@code [0.0, 1.0]} - * @param mode spawn mode - */ - protected SpawnableEntity( - String entityId, String name, int maxHealth, EntityType entityType, double probability, SpawnMode mode) { - this(entityId, name, maxHealth, 1, 0, entityType, probability, mode); - } - - /** - * Constructs a spawnable entity with custom attack damage and zero defense in the specified spawn mode. - * - * @param entityId unique entity identifier - * @param name display name - * @param maxHealth maximum health - * @param damage base attack damage - * @param entityType underlying a Minecraft entity type - * @param probability spawn probability in range {@code [0.0, 1.0]} - * @param mode spawn mode - */ - protected SpawnableEntity( - String entityId, - String name, - int maxHealth, - int damage, - EntityType entityType, - double probability, - SpawnMode mode) { - this(entityId, name, maxHealth, damage, 0, entityType, probability, mode); - } + public static final double DEFAULT_SPAWN_PROBABILITY = 0.01; + public static final SpawnMode DEFAULT_SPAWN_MODE = SpawnMode.REPLACEMENT; /** - * Constructs a spawnable entity with full attribute specifications and spawn profile initialization. + * Constructs a spawnable entity with basic identity requirements. + * Default spawn probability is 0.01 in REPLACEMENT mode. * - * @param entityId unique entity identifier - * @param name display name - * @param maxHealth maximum health - * @param damage base attack damage - * @param defense base armor defense - * @param entityType underlying a Minecraft entity type - * @param probability spawn probability in range {@code [0.0, 1.0]} - * @param mode spawn mode - * @throws IllegalArgumentException if {@code probability} is outside {@code [0.0, 1.0]} + * @param entityId unique entity identifier + * @param name display name + * @param entityType underlying Minecraft entity type */ - protected SpawnableEntity( - String entityId, - String name, - int maxHealth, - int damage, - int defense, - EntityType entityType, - double probability, - SpawnMode mode) { - super(name, maxHealth, damage, defense, entityType); - if (probability < 0.0 || probability > 1.0) { - throw new IllegalArgumentException("spawnProbability must be in [0.0, 1.0], got: " + probability); - } - - this.entityId = entityId; - this.spawnProbability = probability; - this.spawnMode = mode; - this.spawnProfile = new SpawnProfile(); - defineSpawnConditions(); + protected SpawnableEntity(@NonNull String entityId, @NonNull String name, @NonNull EntityType entityType) { + super(entityId, name, entityType); + this.spawnProbability = DEFAULT_SPAWN_PROBABILITY; + this.spawnMode = DEFAULT_SPAWN_MODE; } /** @@ -235,15 +193,27 @@ private void startEliteAura() { /** * Hook method implemented by subclasses to register environment spawn conditions. + * Invoked lazily when the spawn profile is first needed. */ - protected abstract void defineSpawnConditions(); + protected void defineSpawnConditions() { + } + + private void ensureSpawnProfile() { + if (spawnProfile == null) { + spawnProfile = new SpawnProfile(); + defineSpawnConditions(); + } + } /** * Adds a spawn condition rule to this entity's spawn profile. * * @param condition spawn condition rule to add */ - protected void addCondition(SpawnCondition condition) { + protected void addCondition(@NonNull SpawnCondition condition) { + if (spawnProfile == null) { + spawnProfile = new SpawnProfile(); + } spawnProfile.addCondition(condition); } @@ -255,18 +225,17 @@ protected void addCondition(SpawnCondition condition) { * @return {@code true} if all spawn profile conditions are satisfied, {@code false} otherwise */ public boolean canSpawnAt(Location location, World world) { + ensureSpawnProfile(); return spawnProfile.canSpawn(location, world); } - /** - * Creates an independent clone of this spawnable entity definition. - * - * @return cloned entity definition - */ + @NonNull @Override - public SpawnableEntity clone() { - SpawnableEntity cloned = (SpawnableEntity) super.clone(); - cloned.spawnProfile = this.spawnProfile != null ? this.spawnProfile.copy() : new SpawnProfile(); - return cloned; + public SpawnableEntity createInstance() { + SpawnableEntity fresh = EntitiesRegistry.createSpawnable(getEntityId()); + if (fresh != null) { + return fresh; + } + return (SpawnableEntity) super.createInstance(); } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/BlightedSpawnEngine.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/BlightedSpawnEngine.java index b5379013..f8e8d6a3 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/BlightedSpawnEngine.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/BlightedSpawnEngine.java @@ -1,7 +1,7 @@ package fr.moussax.blightedSMP.engine.entities.spawnable.engine; import fr.moussax.blightedSMP.BlightedSMP; -import fr.moussax.blightedSMP.engine.entities.registry.SpawnableEntitiesRegistry; +import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry; import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; import org.bukkit.Chunk; import org.bukkit.Location; @@ -79,7 +79,7 @@ public void run() { private void refreshCaches() { cachedIndependentEntities.clear(); - for (SpawnableEntity entity : SpawnableEntitiesRegistry.getAll()) { + for (SpawnableEntity entity : EntitiesRegistry.getSpawnables()) { SpawnMode mode = entity.getSpawnMode(); if (mode == SpawnMode.INDEPENDENT || mode == SpawnMode.HYBRID) { cachedIndependentEntities.add(entity); @@ -127,7 +127,7 @@ private void attemptSpawnInChunk(Chunk chunk, World world, ThreadLocalRandom ran SpawnableEntity selected = SpawnEvaluator.selectCandidate(cachedIndependentEntities, location, world, random); if (selected != null) { - selected.clone().spawn(location); + selected.createInstance().spawn(location); return; // Stop scanning column once an entity spawns } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributeManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributeManager.java new file mode 100644 index 00000000..e393f23a --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributeManager.java @@ -0,0 +1,195 @@ +package fr.moussax.blightedSMP.engine.entities.state; + +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.attribute.AttributeModifier; +import org.bukkit.entity.LivingEntity; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Manages custom attribute configuration, modifier stripping, health clamping, + * and live entity attribute synchronization. + */ +public final class EntityAttributeManager { + + private final Map attributes; + + public EntityAttributeManager() { + this(new HashMap<>()); + } + + private EntityAttributeManager(@NonNull Map attributes) { + this.attributes = new HashMap<>(attributes); + } + + /** + * Sets an attribute base value. If the entity is currently active, strips all + * existing modifiers and updates the live Bukkit AttributeInstance immediately. + * + * @param attribute target attribute + * @param value base attribute value + * @param liveEntity live entity, or null if unspawned + */ + public void setAttribute(@NonNull Attribute attribute, double value, @Nullable LivingEntity liveEntity) { + Objects.requireNonNull(attribute, "attribute cannot be null"); + attributes.put(attribute, value); + if (liveEntity != null) { + applyAttributeToLiveEntity(attribute, value, liveEntity); + } + } + + /** + * Returns the effective base value for an attribute, querying the live entity if active, + * or the configured attribute map if unspawned. + * + * @param attribute target attribute + * @param liveEntity live entity, or null if unspawned + * @return effective base value, or 0.0 if not configured + */ + public double getAttributeValue(@NonNull Attribute attribute, @Nullable LivingEntity liveEntity) { + Objects.requireNonNull(attribute, "attribute cannot be null"); + if (liveEntity != null && liveEntity.isValid() && !liveEntity.isDead()) { + AttributeInstance instance = liveEntity.getAttribute(attribute); + if (instance != null) { + return instance.getValue(); + } + } + return attributes.getOrDefault(attribute, 0.0); + } + + /** + * Applies combat stats and all configured attributes to a live entity on initial spawn. + * Sets health to max health and locks persistence properties. + * + * @param liveEntity live entity + * @param maxHealth maximum health + * @param damage attack damage + * @param defense armor defense + * @param isBoss whether entity is a boss + */ + public void initialize( + @NonNull LivingEntity liveEntity, + int maxHealth, + int damage, + int defense, + boolean isBoss + ) { + applyBaseAttributes(liveEntity, maxHealth, damage, defense); + + AttributeInstance maxHealthAttribute = liveEntity.getAttribute(Attribute.MAX_HEALTH); + if (maxHealthAttribute != null) { + liveEntity.setHealth(maxHealthAttribute.getValue()); + } + + lockEntityProperties(liveEntity, isBoss); + } + + /** + * Rehydrates combat stats and attributes when binding to an existing world entity. + * Clamps health to current max health and locks persistence properties. + * + * @param liveEntity live entity + * @param maxHealth maximum health + * @param damage attack damage + * @param defense armor defense + * @param isBoss whether entity is a boss + */ + public void rehydrate( + @NonNull LivingEntity liveEntity, + int maxHealth, + int damage, + int defense, + boolean isBoss + ) { + applyBaseAttributes(liveEntity, maxHealth, damage, defense); + + AttributeInstance maxHealthAttribute = liveEntity.getAttribute(Attribute.MAX_HEALTH); + if (maxHealthAttribute != null) { + liveEntity.setHealth(Math.min(liveEntity.getHealth(), maxHealthAttribute.getValue())); + } + + lockEntityProperties(liveEntity, isBoss); + } + + /** + * Sets the live entity's current health, clamped between 0 and maximum health. + * + * @param liveEntity live entity + * @param health target health value + * @param fallbackMaxHealth fallback maximum health + */ + public void setHealth(@Nullable LivingEntity liveEntity, double health, double fallbackMaxHealth) { + if (liveEntity != null && liveEntity.isValid() && !liveEntity.isDead()) { + AttributeInstance maxHealthAttribute = liveEntity.getAttribute(Attribute.MAX_HEALTH); + double max = maxHealthAttribute != null ? maxHealthAttribute.getValue() : fallbackMaxHealth; + liveEntity.setHealth(Math.max(0.0, Math.min(health, max))); + } + } + + /** + * Returns the live entity's current health, or {@code 0.0} if unspawned or dead. + * + * @param liveEntity live entity + * @return current health + */ + public double getHealth(@Nullable LivingEntity liveEntity) { + return liveEntity != null && liveEntity.isValid() && !liveEntity.isDead() ? liveEntity.getHealth() : 0.0; + } + + /** + * Applies standard Minecraft persistence and despawn settings based on boss status. + * + * @param liveEntity target entity + * @param isBoss whether the entity is a boss + */ + public void lockEntityProperties(@NonNull LivingEntity liveEntity, boolean isBoss) { + liveEntity.setRemoveWhenFarAway(!isBoss); + liveEntity.setPersistent(isBoss); + liveEntity.setCanPickupItems(false); + } + + private void applyBaseAttributes(@NonNull LivingEntity liveEntity, int maxHealth, int damage, int defense) { + if (maxHealth > 0) { + attributes.put(Attribute.MAX_HEALTH, (double) maxHealth); + } + if (damage > 0) { + attributes.put(Attribute.ATTACK_DAMAGE, (double) damage); + } + if (defense > 0) { + attributes.put(Attribute.ARMOR, (double) defense); + } + + for (Map.Entry entry : new HashMap<>(attributes).entrySet()) { + applyAttributeToLiveEntity(entry.getKey(), entry.getValue(), liveEntity); + } + } + + private void applyAttributeToLiveEntity(@NonNull Attribute attribute, double value, @NonNull LivingEntity liveEntity) { + if (!liveEntity.isValid() || liveEntity.isDead()) { + return; + } + AttributeInstance instance = liveEntity.getAttribute(attribute); + if (instance != null) { + for (AttributeModifier modifier : new ArrayList<>(instance.getModifiers())) { + instance.removeModifier(modifier); + } + instance.setBaseValue(value); + } + } + + /** + * Creates a deep copy of this attributes manager. + * + * @return detached copy + */ + @NonNull + public EntityAttributeManager copy() { + return new EntityAttributeManager(this.attributes); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributesBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributesBuilder.java new file mode 100644 index 00000000..8f35f3e0 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributesBuilder.java @@ -0,0 +1,182 @@ +package fr.moussax.blightedSMP.engine.entities.state; + +import org.bukkit.attribute.Attribute; +import org.bukkit.entity.LivingEntity; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Fluent builder for configuring mob attributes with typed convenience methods. + */ +public final class EntityAttributesBuilder { + + private final Map attributes = new HashMap<>(); + + /** + * Sets an arbitrary Minecraft attribute value. + * + * @param attribute target attribute + * @param value base attribute value + * @return this builder + */ + public EntityAttributesBuilder set(@NonNull Attribute attribute, double value) { + this.attributes.put(Objects.requireNonNull(attribute, "attribute cannot be null"), value); + return this; + } + + /** + * Sets maximum health. + * + * @param maxHealth maximum health + * @return this builder + */ + public EntityAttributesBuilder maxHealth(double maxHealth) { + return set(Attribute.MAX_HEALTH, maxHealth); + } + + /** + * Sets attack damage. + * + * @param damage attack damage + * @return this builder + */ + public EntityAttributesBuilder attackDamage(double damage) { + return set(Attribute.ATTACK_DAMAGE, damage); + } + + /** + * Sets base armor rating. + * + * @param armor armor value + * @return this builder + */ + public EntityAttributesBuilder armor(double armor) { + return set(Attribute.ARMOR, armor); + } + + /** + * Sets base armor toughness rating. + * + * @param toughness armor toughness value + * @return this builder + */ + public EntityAttributesBuilder armorToughness(double toughness) { + return set(Attribute.ARMOR_TOUGHNESS, toughness); + } + + /** + * Sets movement speed. + * + * @param speed movement speed (vanilla default for zombies is ~0.23) + * @return this builder + */ + public EntityAttributesBuilder movementSpeed(double speed) { + return set(Attribute.MOVEMENT_SPEED, speed); + } + + /** + * Sets knockback resistance ratio (0.0 to 1.0). + * + * @param resistance knockback resistance + * @return this builder + */ + public EntityAttributesBuilder knockbackResistance(double resistance) { + return set(Attribute.KNOCKBACK_RESISTANCE, resistance); + } + + /** + * Sets entity scale multiplier (1.0 is default size). + * + * @param scale scale multiplier + * @return this builder + */ + public EntityAttributesBuilder scale(double scale) { + return set(Attribute.SCALE, scale); + } + + /** + * Sets player follow range in blocks. + * + * @param range target follow range + * @return this builder + */ + public EntityAttributesBuilder followRange(double range) { + return set(Attribute.FOLLOW_RANGE, range); + } + + /** + * Sets attack speed multiplier. + * + * @param attackSpeed attack speed + * @return this builder + */ + public EntityAttributesBuilder attackSpeed(double attackSpeed) { + return set(Attribute.ATTACK_SPEED, attackSpeed); + } + + /** + * Sets flying speed multiplier. + * + * @param flyingSpeed flying speed + * @return this builder + */ + public EntityAttributesBuilder flyingSpeed(double flyingSpeed) { + return set(Attribute.FLYING_SPEED, flyingSpeed); + } + + /** + * Sets step height in blocks (e.g. 1.0 allows stepping up 1 full block without jumping). + * + * @param stepHeight step height in blocks + * @return this builder + */ + public EntityAttributesBuilder stepHeight(double stepHeight) { + return set(Attribute.STEP_HEIGHT, stepHeight); + } + + /** + * Sets safe fall distance in blocks before taking fall damage. + * + * @param distance safe fall distance + * @return this builder + */ + public EntityAttributesBuilder safeFallDistance(double distance) { + return set(Attribute.SAFE_FALL_DISTANCE, distance); + } + + /** + * Sets fall damage multiplier. + * + * @param multiplier fall damage multiplier (0.0 disables fall damage) + * @return this builder + */ + public EntityAttributesBuilder fallDamageMultiplier(double multiplier) { + return set(Attribute.FALL_DAMAGE_MULTIPLIER, multiplier); + } + + /** + * Returns an unmodifiable snapshot of configured attributes. + * + * @return map of attributes + */ + @NonNull + public Map getAttributes() { + return Collections.unmodifiableMap(attributes); + } + + /** + * Applies all attributes from this builder to the given manager and live entity. + * + * @param manager attribute manager + * @param liveEntity live entity, or null if unspawned + */ + public void applyTo(@NonNull EntityAttributeManager manager, @Nullable LivingEntity liveEntity) { + Objects.requireNonNull(manager, "manager cannot be null"); + attributes.forEach((attribute, value) -> manager.setAttribute(attribute, value, liveEntity)); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldowns.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldowns.java new file mode 100644 index 00000000..3ea456f8 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldowns.java @@ -0,0 +1,100 @@ +package fr.moussax.blightedSMP.engine.entities.state; + +import org.jspecify.annotations.NonNull; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.LongSupplier; + +/** + * Tracks named ability cooldowns with thread-safe timestamp management. + */ +public final class EntityCooldowns { + + private final LongSupplier timeSupplier; + private final Map cooldowns = new ConcurrentHashMap<>(); + + /** + * Creates a cooldown tracker using {@link System#currentTimeMillis()} as time source. + */ + public EntityCooldowns() { + this(System::currentTimeMillis); + } + + /** + * Creates a cooldown tracker with an injectable time source for deterministic testing. + * + * @param timeSupplier time supplier providing current epoch milliseconds + */ + public EntityCooldowns(@NonNull LongSupplier timeSupplier) { + this.timeSupplier = Objects.requireNonNull(timeSupplier, "timeSupplier cannot be null"); + } + + /** + * Checks if the specified ability cooldown has elapsed. + * + * @param abilityKey unique identifier for the ability + * @param cooldownMillis duration in milliseconds + * @return {@code true} if ready to use, {@code false} otherwise + */ + public boolean isReady(@NonNull String abilityKey, long cooldownMillis) { + Long lastTriggered = cooldowns.get(abilityKey); + return lastTriggered == null || (timeSupplier.getAsLong() - lastTriggered) >= cooldownMillis; + } + + /** + * Triggers the cooldown for the specified ability, setting its timestamp to the current time. + * + * @param abilityKey unique identifier for the ability + */ + public void trigger(@NonNull String abilityKey) { + cooldowns.put(abilityKey, timeSupplier.getAsLong()); + } + + /** + * Atomically checks if the ability is ready and triggers it if so. + * + * @param abilityKey unique identifier for the ability + * @param cooldownMillis duration in milliseconds + * @return {@code true} if ready and triggered, {@code false} otherwise + */ + public boolean checkAndTrigger(@NonNull String abilityKey, long cooldownMillis) { + long now = timeSupplier.getAsLong(); + return cooldowns.compute(abilityKey, (_, last) -> + (last == null || (now - last) >= cooldownMillis) ? now : last + ) == now; + } + + /** + * Resets the cooldown for the specified ability, allowing immediate reuse. + * + * @param abilityKey unique identifier for the ability + */ + public void reset(@NonNull String abilityKey) { + cooldowns.remove(abilityKey); + } + + /** + * Resets all tracked ability cooldowns. + */ + public void resetAll() { + cooldowns.clear(); + } + + /** + * Returns the remaining cooldown duration in milliseconds. + * + * @param abilityKey unique identifier for the ability + * @param cooldownMillis total cooldown duration in milliseconds + * @return remaining milliseconds, or 0 if cooldown has expired + */ + public long getRemainingMillis(@NonNull String abilityKey, long cooldownMillis) { + Long lastTriggered = cooldowns.get(abilityKey); + if (lastTriggered == null) { + return 0; + } + long elapsed = timeSupplier.getAsLong() - lastTriggered; + return Math.max(0, cooldownMillis - elapsed); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/util/EntityEffects.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/util/EntityEffects.java new file mode 100644 index 00000000..6823564f --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/util/EntityEffects.java @@ -0,0 +1,168 @@ +package fr.moussax.blightedSMP.engine.entities.util; + +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.entity.LivingEntity; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +/** + * Utility functions for dispatching sound effects, particle clouds, and animation states. + */ +public final class EntityEffects { + + private EntityEffects() { + } + + /** + * Plays a sound effect at the entity's current location. + * + * @param entity target entity + * @param sound sound to play + * @param volume volume level + * @param pitch pitch modifier + */ + public static void playSound(@Nullable LivingEntity entity, @NonNull Sound sound, float volume, float pitch) { + Objects.requireNonNull(sound, "sound cannot be null"); + if (entity != null && entity.isValid()) { + entity.getWorld().playSound(entity.getLocation(), sound, volume, pitch); + } + } + + /** + * Plays a sound effect at a specific world location. + * + * @param location location to play the sound at + * @param sound sound to play + * @param volume volume level + * @param pitch pitch modifier + */ + public static void playSound(@NonNull Location location, @NonNull Sound sound, float volume, float pitch) { + Objects.requireNonNull(location, "location cannot be null"); + Objects.requireNonNull(sound, "sound cannot be null"); + if (location.getWorld() != null) { + location.getWorld().playSound(location, sound, volume, pitch); + } + } + + /** + * Spawns particles at the entity's current location. + * + * @param entity target entity + * @param particle particle type + * @param count number of particles + */ + public static void spawnParticle(@Nullable LivingEntity entity, @NonNull Particle particle, int count) { + Objects.requireNonNull(particle, "particle cannot be null"); + if (entity != null && entity.isValid()) { + entity.getWorld().spawnParticle(particle, entity.getLocation(), count); + } + } + + /** + * Spawns particles at the entity's current location with random positional offsets. + * + * @param entity target entity + * @param particle particle type + * @param count number of particles + * @param offsetX maximum X axis offset + * @param offsetY maximum Y axis offset + * @param offsetZ maximum Z axis offset + */ + public static void spawnParticle( + @Nullable LivingEntity entity, + @NonNull Particle particle, + int count, + double offsetX, + double offsetY, + double offsetZ + ) { + Objects.requireNonNull(particle, "particle cannot be null"); + if (entity != null && entity.isValid()) { + entity.getWorld().spawnParticle(particle, entity.getLocation(), count, offsetX, offsetY, offsetZ); + } + } + + /** + * Spawns particles at the entity's current location with custom particle data. + * + * @param entity target entity + * @param particle particle type + * @param count number of particles + * @param data particle data object, or null + * @param particle data type + */ + public static void spawnParticle( + @Nullable LivingEntity entity, + @NonNull Particle particle, + int count, + @Nullable T data + ) { + Objects.requireNonNull(particle, "particle cannot be null"); + if (entity != null && entity.isValid()) { + entity.getWorld().spawnParticle(particle, entity.getLocation(), count, 0.0, 0.0, 0.0, data); + } + } + + /** + * Spawns particles at a specific world location. + * + * @param location location to spawn particles at + * @param particle particle type + * @param count number of particles + */ + public static void spawnParticle(@NonNull Location location, @NonNull Particle particle, int count) { + Objects.requireNonNull(location, "location cannot be null"); + Objects.requireNonNull(particle, "particle cannot be null"); + if (location.getWorld() != null) { + location.getWorld().spawnParticle(particle, location, count); + } + } + + /** + * Spawns particles at a specific world location with custom particle data. + * + * @param location location to spawn particles at + * @param particle particle type + * @param count number of particles + * @param data particle data object, or null + * @param particle data type + */ + public static void spawnParticle( + @NonNull Location location, + @NonNull Particle particle, + int count, + @Nullable T data + ) { + Objects.requireNonNull(location, "location cannot be null"); + Objects.requireNonNull(particle, "particle cannot be null"); + if (location.getWorld() != null) { + location.getWorld().spawnParticle(particle, location, count, 0.0, 0.0, 0.0, data); + } + } + + /** + * Triggers the entity's main-hand swinging animation. + * + * @param entity target entity + */ + public static void swingMainHand(@Nullable LivingEntity entity) { + if (entity != null && entity.isValid()) { + entity.swingMainHand(); + } + } + + /** + * Triggers the entity's off-hand swinging animation. + * + * @param entity target entity + */ + public static void swingOffHand(@Nullable LivingEntity entity) { + if (entity != null && entity.isValid()) { + entity.swingOffHand(); + } + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/util/EntitySpatialQueries.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/util/EntitySpatialQueries.java new file mode 100644 index 00000000..dd96ae08 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/util/EntitySpatialQueries.java @@ -0,0 +1,178 @@ +package fr.moussax.blightedSMP.engine.entities.util; + +import fr.moussax.blightedSMP.engine.player.BlightedPlayer; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +/** + * Utility methods for spatial searches, targeting queries, and area-of-effect combat mechanics. + */ +public final class EntitySpatialQueries { + + private EntitySpatialQueries() { + } + + /** + * Finds survival-mode players within a spherical radius around a center location. + * + * @param center search center + * @param radius search radius + * @return matching survival players, or empty list if center is invalid + */ + public static List getNearbyPlayers(@Nullable Location center, double radius) { + if (center == null || center.getWorld() == null || radius <= 0) { + return Collections.emptyList(); + } + return center.getWorld() + .getNearbyEntities( + center, + radius, + radius, + radius, + entity -> entity instanceof Player player && player.getGameMode() == GameMode.SURVIVAL + ) + .stream() + .map(entity -> (Player) entity) + .toList(); + } + + /** + * Finds the nearest survival-mode player to a center location within the given radius. + * + * @param center search center + * @param radius search radius + * @return nearest player, or {@code null} if none found + */ + @Nullable + public static Player getNearestPlayer(@Nullable Location center, double radius) { + if (center == null) { + return null; + } + return getNearbyPlayers(center, radius).stream() + .min(Comparator.comparingDouble(player -> player.getLocation().distanceSquared(center))) + .orElse(null); + } + + /** + * Finds nearby {@link BlightedPlayer} instances around a center location. + * + * @param center search center + * @param radius search radius + * @return list of nearby BlightedPlayer instances + */ + public static List getNearbyBlightedPlayers(@Nullable Location center, double radius) { + return getNearbyPlayers(center, radius).stream() + .map(BlightedPlayer::get) + .filter(Objects::nonNull) + .toList(); + } + + /** + * Finds the nearest {@link BlightedPlayer} to a center location. + * + * @param center search center + * @param radius search radius + * @return nearest BlightedPlayer, or {@code null} + */ + @Nullable + public static BlightedPlayer getNearestBlightedPlayer(@Nullable Location center, double radius) { + Player target = getNearestPlayer(center, radius); + return target != null ? BlightedPlayer.get(target) : null; + } + + /** + * Damages all nearby survival-mode players around a center location. + * + * @param center damage center + * @param radius damage radius + * @param damageAmount damage dealt + * @param source attacking entity responsible for the damage + */ + public static void damageNearbyPlayers( + @Nullable Location center, + double radius, + double damageAmount, + @Nullable Entity source + ) { + if (center == null) return; + List hitPlayers = getNearbyPlayers(center, radius); + for (Player player : hitPlayers) { + player.damage(damageAmount, source); + } + } + + /** + * Damages and knocks back all nearby survival-mode players away from the center. + * + * @param center effect center + * @param radius effect radius + * @param damageAmount damage dealt + * @param knockbackStrength horizontal knockback strength + * @param verticalKnockback vertical velocity + * @param source attacking entity responsible for the effect + */ + public static void damageAndKnockbackNearbyPlayers( + @Nullable Location center, + double radius, + double damageAmount, + double knockbackStrength, + double verticalKnockback, + @Nullable Entity source + ) { + if (center == null) return; + List hitPlayers = getNearbyPlayers(center, radius); + for (Player player : hitPlayers) { + player.damage(damageAmount, source); + Vector knockbackVector = player.getLocation().toVector().subtract(center.toVector()).setY(0); + if (knockbackVector.lengthSquared() > 0.001) { + knockbackVector.normalize().multiply(knockbackStrength).setY(verticalKnockback); + } else { + knockbackVector = new Vector(0, verticalKnockback, 0); + } + player.setVelocity(knockbackVector); + } + } + + /** + * Disables a target player's shield if they are currently blocking. + * + * @param target target player + * @param cooldownTicks duration in ticks to put shield on cooldown + */ + public static void disableShieldIfBlocking(@Nullable Player target, int cooldownTicks) { + if (target != null && target.isBlocking()) { + target.setCooldown(Material.SHIELD, cooldownTicks); + target.getWorld().playSound(target.getLocation(), Sound.ITEM_SHIELD_BREAK, 1.0f, 0.8f); + } + } + + /** + * Rotates an entity on the horizontal plane to face a target location. + * + * @param entity entity to rotate + * @param target target location + */ + public static void faceLocation(@Nullable LivingEntity entity, @Nullable Location target) { + if (entity == null || !entity.isValid() || entity.isDead() || target == null) { + return; + } + Vector direction = target.toVector().subtract(entity.getLocation().toVector()).setY(0); + if (direction.lengthSquared() > 0.001) { + Location location = entity.getLocation(); + location.setYaw((float) Math.toDegrees(-Math.atan2(direction.getX(), direction.getZ()))); + entity.teleport(location); + } + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/loot/results/EntityResult.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/loot/results/EntityResult.java index e746321d..c6423f6d 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/loot/results/EntityResult.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/loot/results/EntityResult.java @@ -59,7 +59,7 @@ public static EntityResult vanilla(EntityType entityType, Consumer */ public static EntityResult blighted(fr.moussax.blightedSMP.engine.entities.BlightedEntity blightedEntity) { Objects.requireNonNull(blightedEntity, "blightedEntity cannot be null"); - return custom(blightedEntity.clone()::spawn); + return custom(loc -> blightedEntity.createInstance().spawn(loc)); } /** diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java index 6c076ca9..0d3bb541 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java @@ -3,7 +3,7 @@ import fr.moussax.bedrock.ui.title.TimeableTitle; import fr.moussax.bedrock.ui.title.Title; import fr.moussax.blightedSMP.engine.entities.BlightedEntity; -import fr.moussax.blightedSMP.engine.entities.listeners.BlightedEntitiesListener; +import fr.moussax.blightedSMP.engine.entities.EntityManager; import fr.moussax.blightedSMP.engine.items.registry.ItemRegistry; import org.bukkit.ChatColor; import org.bukkit.Sound; @@ -82,7 +82,7 @@ public void onPlayerDeath(PlayerDeathEvent event) { if (killer == null) return; - BlightedEntity blighted = BlightedEntitiesListener.getBlightedEntity(killer); + BlightedEntity blighted = EntityManager.getBlightedEntity(killer); if (blighted == null) return; String victimName = event.getEntity().getName(); @@ -104,7 +104,7 @@ public void onPlayerDeath(PlayerDeathEvent event) { } private void clearTargetedMobs(Player targetPlayer) { - for (BlightedEntity blighted : BlightedEntitiesListener.getActiveEntities()) { + for (BlightedEntity blighted : EntityManager.getActiveEntities()) { LivingEntity entity = blighted.getEntity(); if (entity instanceof Mob mob && targetPlayer.equals(mob.getTarget())) { mob.setTarget(null); diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/quest/BlightedQuestListener.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/quest/BlightedQuestListener.java index ec97b8c1..e1072ee0 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/quest/BlightedQuestListener.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/quest/BlightedQuestListener.java @@ -3,7 +3,7 @@ import fr.moussax.blightedSMP.BlightedSMP; import fr.moussax.blightedSMP.engine.items.BlightedItem; import fr.moussax.blightedSMP.engine.entities.BlightedEntity; -import fr.moussax.blightedSMP.engine.entities.listeners.BlightedEntitiesListener; +import fr.moussax.blightedSMP.engine.entities.EntityManager; import fr.moussax.blightedSMP.content.entities.factions.blightsworn.BlightswornCreature; import fr.moussax.bedrock.text.Messenger; import org.bukkit.Bukkit; @@ -39,7 +39,7 @@ public void onEntityDeath(EntityDeathEvent event) { Player killer = deadEntity.getKiller(); if (killer != null) { - BlightedEntity blightedEntity = BlightedEntitiesListener.getBlightedEntity(deadEntity); + BlightedEntity blightedEntity = EntityManager.getBlightedEntity(deadEntity); if (blightedEntity instanceof BlightswornCreature) { incrementCodexTrappedSouls(killer); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/server/BlightedServer.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/server/BlightedServer.java index c06df5aa..f17e88b0 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/server/BlightedServer.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/server/BlightedServer.java @@ -1,6 +1,6 @@ package fr.moussax.blightedSMP.server; -import fr.moussax.blightedSMP.engine.entities.listeners.BlightedEntitiesListener; +import fr.moussax.blightedSMP.engine.entities.EntityManager; import fr.moussax.bedrock.utils.debug.Log; import org.bukkit.*; import org.bukkit.plugin.java.JavaPlugin; @@ -58,7 +58,7 @@ public void run() { Chunk chunk = iterator.next(); if (chunk.isLoaded()) { - BlightedEntitiesListener.rehydrateChunk(chunk); + EntityManager.rehydrateChunk(chunk); } currentBatch++; From e966e77e25174b1a9bccf16f91838249ed5d61b6 Mon Sep 17 00:00:00 2001 From: Moussa Date: Fri, 2 Oct 2026 14:14:31 +0200 Subject: [PATCH 02/13] refactor(spawning): introduce fluent SpawningBuilder and composable spawn rules Add SpawningBuilder for declarative probability, mode, affixes, and condition configuration. Introduce short-circuiting SpawnCondition combinators (anyOf, allOf, noneOf) and prune unused spawner-scanning rules. --- blighted-smp/pom.xml | 10 + .../entities/spawnable/SpawnProfile.java | 45 ++-- .../entities/spawnable/SpawnableEntity.java | 165 +++++------- .../entities/spawnable/SpawningBuilder.java | 238 ++++++++++++++++++ .../spawnable/condition/SpawnCondition.java | 70 ++++++ .../spawnable/condition/SpawnRules.java | 180 +++---------- .../spawnable/engine/SpawnEvaluator.java | 2 +- .../entities/spawnable/SpawningRuleTest.java | 128 ++++++++++ 8 files changed, 571 insertions(+), 267 deletions(-) create mode 100644 blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningBuilder.java create mode 100644 blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningRuleTest.java diff --git a/blighted-smp/pom.xml b/blighted-smp/pom.xml index 3f363543..849d001d 100644 --- a/blighted-smp/pom.xml +++ b/blighted-smp/pom.xml @@ -33,6 +33,16 @@ org.projectlombok lombok + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnProfile.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnProfile.java index 28002b92..fbf6a11b 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnProfile.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnProfile.java @@ -1,12 +1,12 @@ package fr.moussax.blightedSMP.engine.entities.spawnable; import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnCondition; -import lombok.NoArgsConstructor; import org.bukkit.Location; import org.bukkit.World; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -15,22 +15,22 @@ *

All registered conditions must be satisfied for spawning to be allowed. * Evaluation fails fast on the first failing condition.

*/ -@NoArgsConstructor -public final class SpawnProfile { +public record SpawnProfile(List conditions) { - private final List conditions = new ArrayList<>(); - - private SpawnProfile(List conditions) { - this.conditions.addAll(conditions); + /** + * Constructs a spawn profile with an immutable snapshot of conditions. + * + * @param conditions conditions to evaluate + */ + public SpawnProfile(@NonNull List conditions) { + this.conditions = List.copyOf(conditions); } /** - * Adds a spawn condition rule to this profile. - * - * @param condition condition rule to add + * Constructs an empty spawn profile permitting spawning everywhere. */ - public void addCondition(@NonNull SpawnCondition condition) { - conditions.add(condition); + public SpawnProfile() { + this(Collections.emptyList()); } /** @@ -40,19 +40,26 @@ public void addCondition(@NonNull SpawnCondition condition) { * @param world target spawn world * @return {@code true} if all conditions pass, {@code false} if any condition fails */ - public boolean canSpawn(Location location, World world) { + public boolean canSpawn(@Nullable Location location, @Nullable World world) { + if (location == null || world == null) { + return false; + } for (SpawnCondition condition : conditions) { - if (!condition.testCanSpawnAt(location, world)) return false; + if (!condition.testCanSpawnAt(location, world)) { + return false; + } } return true; } /** - * Creates an independent copy of this spawn profile and its conditions. + * Returns an unmodifiable snapshot of registered spawn conditions. * - * @return a new spawn profile instance with identical conditions + * @return list of spawn conditions */ - public SpawnProfile copy() { - return new SpawnProfile(this.conditions); + @Override + @NonNull + public List conditions() { + return conditions; } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnableEntity.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnableEntity.java index 36295454..713bc855 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnableEntity.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawnableEntity.java @@ -5,7 +5,6 @@ import fr.moussax.blightedSMP.engine.entities.components.AffixRegistry; import fr.moussax.blightedSMP.engine.entities.components.EntityComponent; import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnCondition; import fr.moussax.blightedSMP.engine.entities.spawnable.engine.SpawnMode; import lombok.Getter; import org.bukkit.Location; @@ -16,106 +15,117 @@ import org.bukkit.entity.LivingEntity; import org.bukkit.persistence.PersistentDataType; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.function.Consumer; /** * Abstract base class for custom entities capable of spawning naturally in the world. * *

Extends {@link BlightedEntity} with spawn probabilities, spawn evaluation rules - * ({@link SpawnProfile}), spawn modes ({@link SpawnMode}), and optional elite affix rolling.

+ * ({@link SpawnProfile}), spawn modes ({@link SpawnMode}), and elite affix mechanics.

*/ public abstract class SpawnableEntity extends BlightedEntity { - /** - * Persistent data key storing comma-separated active affix IDs assigned to this entity. - */ - public static final NamespacedKey AFFIXES_KEY = - new NamespacedKey(BlightedSMP.getInstance(), "blighted_active_affix"); + private static NamespacedKey affixesKey; + + public static NamespacedKey getAffixesKey() { + if (affixesKey == null) { + affixesKey = new NamespacedKey(BlightedSMP.getInstance(), "blighted_active_affix"); + } + return affixesKey; + } + + public static final double DEFAULT_SPAWN_PROBABILITY = SpawningBuilder.DEFAULT_PROBABILITY; + public static final SpawnMode DEFAULT_SPAWN_MODE = SpawningBuilder.DEFAULT_MODE; @Getter - private double spawnProbability; + private double spawnProbability = DEFAULT_SPAWN_PROBABILITY; @Getter - private SpawnMode spawnMode; + private SpawnMode spawnMode = DEFAULT_SPAWN_MODE; - private SpawnProfile spawnProfile; + @Getter + private double affixChance = 0.0; + + @Getter + private int maxAffixes = 1; + + private boolean eliteAuraStarted = false; + + @Getter + private SpawnProfile spawnProfile = new SpawnProfile(); /** - * Sets the natural spawn probability in the range {@code [0.0, 1.0]}. + * Constructs a spawnable entity with identity requirements. + * The default spawn probability is 0.01 in REPLACEMENT mode. * - * @param spawnProbability probability in [0.0, 1.0] - * @throws IllegalArgumentException if outside [0.0, 1.0] + * @param entityId unique entity identifier + * @param name display name + * @param entityType underlying a Minecraft entity type */ - public void setSpawnProbability(double spawnProbability) { - if (spawnProbability < 0.0 || spawnProbability > 1.0) { - throw new IllegalArgumentException("spawnProbability must be in [0.0, 1.0], got: " + spawnProbability); - } - this.spawnProbability = spawnProbability; + protected SpawnableEntity(@NonNull String entityId, @NonNull String name, @NonNull EntityType entityType) { + super(entityId, name, entityType); } /** - * Sets the spawn mode. + * Configures spawning rules, probability, mode, and affixes using a fluent builder consumer. * - * @param spawnMode spawn mode + * @param consumer action configuring the spawning builder */ - public void setSpawnMode(@NonNull SpawnMode spawnMode) { - this.spawnMode = Objects.requireNonNull(spawnMode, "spawnMode cannot be null"); + public void spawning(@NonNull Consumer consumer) { + Objects.requireNonNull(consumer, "consumer cannot be null"); + SpawningBuilder builder = new SpawningBuilder(); + builder.probability(this.spawnProbability); + builder.mode(this.spawnMode); + builder.affixes(this.affixChance, this.maxAffixes); + consumer.accept(builder); + this.spawnProbability = builder.getProbability(); + this.spawnMode = builder.getMode(); + this.affixChance = builder.getAffixChance(); + this.maxAffixes = builder.getMaxAffixes(); + this.spawnProfile = builder.buildProfile(); } - @Getter - private double affixChance = 0.0; - - @Getter - private int maxAffixes = 1; - /** - * Sets the chance for this entity to spawn with random elite affixes. + * Sets elite affix rolling chance. * - * @param affixChance probability in the range {@code [0.0, 1.0]} - * @throws IllegalArgumentException if {@code affixChance} is outside {@code [0.0, 1.0]} + * @param affixChance probability in [0.0, 1.0] + * @return this entity */ - public void setAffixChance(double affixChance) { + public SpawnableEntity affixChance(double affixChance) { if (affixChance < 0.0 || affixChance > 1.0) { throw new IllegalArgumentException("affixChance must be in [0.0, 1.0], got: " + affixChance); } this.affixChance = affixChance; + return this; } /** - * Sets the maximum number of elite affixes this entity can roll when spawned. + * Sets maximum number of rollable elite affixes. * - * @param maxAffixes maximum affix count + * @param maxAffixes maximum affixes + * @return this entity */ - public void setMaxAffixes(int maxAffixes) { + public SpawnableEntity maxAffixes(int maxAffixes) { this.maxAffixes = Math.max(1, maxAffixes); + return this; } - public static final double DEFAULT_SPAWN_PROBABILITY = 0.01; - public static final SpawnMode DEFAULT_SPAWN_MODE = SpawnMode.REPLACEMENT; - /** - * Constructs a spawnable entity with basic identity requirements. - * Default spawn probability is 0.01 in REPLACEMENT mode. + * Evaluates whether this entity can spawn at the specified location and world. * - * @param entityId unique entity identifier - * @param name display name - * @param entityType underlying Minecraft entity type + * @param location location to evaluate + * @param world world to evaluate + * @return {@code true} if all spawn profile conditions are satisfied, {@code false} otherwise */ - protected SpawnableEntity(@NonNull String entityId, @NonNull String name, @NonNull EntityType entityType) { - super(entityId, name, entityType); - this.spawnProbability = DEFAULT_SPAWN_PROBABILITY; - this.spawnMode = DEFAULT_SPAWN_MODE; + public boolean canSpawnAt(@Nullable Location location, @Nullable World world) { + return spawnProfile.canSpawn(location, world); } - /** - * Spawns this entity at the specified location and rolls for elite affix assignment. - * - * @param location target spawn location - * @return spawned living entity - */ @Override public LivingEntity spawn(Location location) { LivingEntity spawned = super.spawn(location); @@ -130,23 +140,18 @@ public LivingEntity spawn(Location location) { } spawned.getPersistentDataContainer() - .set(AFFIXES_KEY, PersistentDataType.STRING, String.join(",", affixIds)); + .set(getAffixesKey(), PersistentDataType.STRING, String.join(",", affixIds)); } } return spawned; } - /** - * Rehydrates an existing entity state, restoring persistent affixes if present. - * - * @param existing existing living entity instance in the world - */ @Override protected void onRehydrate(LivingEntity existing) { super.onRehydrate(existing); - String persistentAffixes = existing.getPersistentDataContainer().get(AFFIXES_KEY, PersistentDataType.STRING); + String persistentAffixes = existing.getPersistentDataContainer().get(getAffixesKey(), PersistentDataType.STRING); if (persistentAffixes != null && !persistentAffixes.isEmpty()) { String[] affixIds = persistentAffixes.split(","); for (String affixId : affixIds) { @@ -160,8 +165,6 @@ protected void onRehydrate(LivingEntity existing) { } } - private boolean eliteAuraStarted = false; - private void applyAffix(EntityComponent affix) { addComponent(affix); if (!eliteAuraStarted) { @@ -191,44 +194,6 @@ private void startEliteAura() { }); } - /** - * Hook method implemented by subclasses to register environment spawn conditions. - * Invoked lazily when the spawn profile is first needed. - */ - protected void defineSpawnConditions() { - } - - private void ensureSpawnProfile() { - if (spawnProfile == null) { - spawnProfile = new SpawnProfile(); - defineSpawnConditions(); - } - } - - /** - * Adds a spawn condition rule to this entity's spawn profile. - * - * @param condition spawn condition rule to add - */ - protected void addCondition(@NonNull SpawnCondition condition) { - if (spawnProfile == null) { - spawnProfile = new SpawnProfile(); - } - spawnProfile.addCondition(condition); - } - - /** - * Evaluates whether this entity can spawn at the specified location and world. - * - * @param location location to evaluate - * @param world world to evaluate - * @return {@code true} if all spawn profile conditions are satisfied, {@code false} otherwise - */ - public boolean canSpawnAt(Location location, World world) { - ensureSpawnProfile(); - return spawnProfile.canSpawn(location, world); - } - @NonNull @Override public SpawnableEntity createInstance() { diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningBuilder.java new file mode 100644 index 00000000..f4379242 --- /dev/null +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningBuilder.java @@ -0,0 +1,238 @@ +package fr.moussax.blightedSMP.engine.entities.spawnable; + +import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnCondition; +import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; +import fr.moussax.blightedSMP.engine.entities.spawnable.engine.SpawnMode; +import lombok.Getter; +import org.bukkit.World.Environment; +import org.bukkit.block.Biome; +import org.bukkit.generator.structure.Structure; +import org.jspecify.annotations.NonNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Fluent builder for configuring mob natural spawn rules, probability, and mode. + */ +@Getter +public final class SpawningBuilder { + + public static final double DEFAULT_PROBABILITY = 0.01; + public static final SpawnMode DEFAULT_MODE = SpawnMode.REPLACEMENT; + + private double probability = DEFAULT_PROBABILITY; + private SpawnMode mode = DEFAULT_MODE; + private double affixChance = 0.0; + private int maxAffixes = 1; + private final List conditions = new ArrayList<>(); + + /** + * Sets the spawn probability in range [0.0, 1.0]. + * + * @param probability spawn probability + * @return this builder + */ + public SpawningBuilder probability(double probability) { + if (probability < 0.0 || probability > 1.0) { + throw new IllegalArgumentException("probability must be in [0.0, 1.0], got: " + probability); + } + this.probability = probability; + return this; + } + + /** + * Sets the integration mode with Minecraft spawning mechanisms. + * + * @param mode spawn mode + * @return this builder + */ + public SpawningBuilder mode(@NonNull SpawnMode mode) { + this.mode = Objects.requireNonNull(mode, "mode cannot be null"); + return this; + } + + /** + * Sets elite affix roll chance and maximum rollable affixes. + * + * @param chance probability in [0.0, 1.0] + * @param maxAffixes maximum affixes + * @return this builder + */ + public SpawningBuilder affixes(double chance, int maxAffixes) { + if (chance < 0.0 || chance > 1.0) { + throw new IllegalArgumentException("affixChance must be in [0.0, 1.0], got: " + chance); + } + this.affixChance = chance; + this.maxAffixes = Math.max(1, maxAffixes); + return this; + } + + /** + * Sets elite affix roll chance with up to 1 rolled affix. + * + * @param chance probability in [0.0, 1.0] + * @return this builder + */ + public SpawningBuilder affixes(double chance) { + return affixes(chance, 1); + } + + /** + * Adds one or more spawn conditions evaluated with AND semantics. + * + * @param conditions conditions to enforce + * @return this builder + */ + public SpawningBuilder condition(@NonNull SpawnCondition... conditions) { + for (SpawnCondition condition : conditions) { + this.conditions.add(Objects.requireNonNull(condition, "condition cannot be null")); + } + return this; + } + + /** + * Convenience method adding a biome restriction rule. + * + * @param biomes allowed biomes + * @return this builder + */ + public SpawningBuilder biomes(@NonNull Biome... biomes) { + return condition(SpawnRules.biome(biomes)); + } + + /** + * Convenience method requiring the entity to spawn inside a structure. + * + * @param structure target structure + * @return this builder + */ + public SpawningBuilder insideStructure(@NonNull Structure structure) { + return condition(SpawnRules.insideStructure(structure)); + } + + /** + * Adds an OR condition group where at least one condition must pass. + * + * @param conditions conditions to evaluate + * @return this builder + */ + public SpawningBuilder anyOf(@NonNull SpawnCondition... conditions) { + return condition(SpawnCondition.anyOf(conditions)); + } + + /** + * Adds an AND condition group where all conditions must pass. + * + * @param conditions conditions to evaluate + * @return this builder + */ + public SpawningBuilder allOf(@NonNull SpawnCondition... conditions) { + return condition(SpawnCondition.allOf(conditions)); + } + + /** + * Adds a NOR condition group where none of the conditions may pass. + * + * @param conditions conditions that must not pass + * @return this builder + */ + public SpawningBuilder noneOf(@NonNull SpawnCondition... conditions) { + return condition(SpawnCondition.noneOf(conditions)); + } + + /** + * Adds an environment requirement. + * + * @param environment required world environment + * @return this builder + */ + public SpawningBuilder environment(@NonNull Environment environment) { + return condition(SpawnRules.environment(environment)); + } + + /** + * Adds standard Overworld hostile monster conditions (darkness, non-liquid). + * + * @return this builder + */ + public SpawningBuilder overworldHostile() { + return condition(SpawnRules.overworldHostile()); + } + + /** + * Adds surface-only Overworld hostile monster conditions (sky exposed, darkness, non-liquid). + * + * @return this builder + */ + public SpawningBuilder overworldSurfaceHostile() { + return condition(SpawnRules.overworldSurfaceHostile()); + } + + /** + * Adds Nether hostile monster conditions (non-liquid, light level <= 11). + * + * @return this builder + */ + public SpawningBuilder netherHostile() { + return condition(SpawnRules.netherHostile()); + } + + /** + * Adds a liquid requirement (water or lava). + * + * @return this builder + */ + public SpawningBuilder inLiquid() { + return condition(SpawnRules.inLiquid()); + } + + /** + * Adds a non-liquid requirement. + * + * @return this builder + */ + public SpawningBuilder notInLiquid() { + return condition(SpawnRules.notInLiquid()); + } + + /** + * Adds a requirement that the spawn location is exposed to the sky. + * + * @return this builder + */ + public SpawningBuilder skyExposed() { + return condition(SpawnRules.skyExposed()); + } + + /** + * Adds a maximum block light requirement. + * + * @param max maximum block light level + * @return this builder + */ + public SpawningBuilder maxBlockLight(int max) { + return condition(SpawnRules.maxBlockLight(max)); + } + + /** + * Adds a maximum total light level requirement. + * + * @param max maximum total light level + * @return this builder + */ + public SpawningBuilder maxLightLevel(int max) { + return condition(SpawnRules.maxLightLevel(max)); + } + + /** + * Builds an immutable {@link SpawnProfile} with the configured conditions. + * + * @return immutable spawn profile + */ + @NonNull + public SpawnProfile buildProfile() { + return new SpawnProfile(this.conditions); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnCondition.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnCondition.java index 692c0689..f322cd5f 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnCondition.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnCondition.java @@ -49,4 +49,74 @@ default SpawnCondition or(SpawnCondition other) { default SpawnCondition negate() { return (location, world) -> !this.testCanSpawnAt(location, world); } + + /** + * Creates a condition requiring at least one of the provided conditions to pass (logical OR). + * + *

Short-circuits upon the first matching condition. If no conditions are supplied, + * this condition always fails.

+ * + * @param conditions conditions to evaluate + * @return composite OR condition + */ + static SpawnCondition anyOf(SpawnCondition... conditions) { + if (conditions.length == 0) { + return (_, _) -> false; + } + if (conditions.length == 1) { + return java.util.Objects.requireNonNull(conditions[0], "condition cannot be null"); + } + SpawnCondition[] copy = conditions.clone(); + for (SpawnCondition condition : copy) { + java.util.Objects.requireNonNull(condition, "condition cannot be null"); + } + return (location, world) -> { + for (SpawnCondition condition : copy) { + if (condition.testCanSpawnAt(location, world)) { + return true; + } + } + return false; + }; + } + + /** + * Creates a condition requiring all provided conditions to pass (logical AND). + * + *

Short-circuits upon the first failing condition. If no conditions are supplied, + * this condition always passes (vacuous truth).

+ * + * @param conditions conditions to evaluate + * @return composite AND condition + */ + static SpawnCondition allOf(SpawnCondition... conditions) { + if (conditions.length == 0) { + return (_, _) -> true; + } + if (conditions.length == 1) { + return java.util.Objects.requireNonNull(conditions[0], "condition cannot be null"); + } + SpawnCondition[] copy = conditions.clone(); + for (SpawnCondition condition : copy) { + java.util.Objects.requireNonNull(condition, "condition cannot be null"); + } + return (location, world) -> { + for (SpawnCondition condition : copy) { + if (!condition.testCanSpawnAt(location, world)) { + return false; + } + } + return true; + }; + } + + /** + * Creates a condition requiring none of the provided conditions to pass (logical NOR). + * + * @param conditions conditions that must not pass + * @return composite NOR condition + */ + static SpawnCondition noneOf(SpawnCondition... conditions) { + return anyOf(conditions).negate(); + } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnRules.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnRules.java index 135bc790..1a5fddce 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnRules.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/condition/SpawnRules.java @@ -1,17 +1,15 @@ package fr.moussax.blightedSMP.engine.entities.spawnable.condition; -import org.bukkit.Material; -import org.bukkit.Raid; import org.bukkit.World; +import org.bukkit.World.Environment; import org.bukkit.block.Biome; -import org.bukkit.block.Block; -import org.bukkit.block.data.type.TrialSpawner; -import org.bukkit.block.data.type.TrialSpawner.State; import org.bukkit.generator.structure.GeneratedStructure; import org.bukkit.generator.structure.Structure; import org.bukkit.generator.structure.StructurePiece; +import org.jspecify.annotations.NonNull; import java.util.Collection; +import java.util.Objects; import java.util.Set; /** @@ -32,7 +30,7 @@ private SpawnRules() { * @param allowed biomes permitted for spawning * @return biome spawn condition predicate */ - public static SpawnCondition biome(Biome... allowed) { + public static SpawnCondition biome(@NonNull Biome... allowed) { Set biomeSet = Set.of(allowed); return (location, _) -> biomeSet.contains(location.getBlock().getBiome()); } @@ -43,28 +41,36 @@ public static SpawnCondition biome(Biome... allowed) { * @param environment target world environment * @return environment spawn condition predicate */ - public static SpawnCondition environment(World.Environment environment) { + public static SpawnCondition environment(@NonNull Environment environment) { + Objects.requireNonNull(environment, "environment cannot be null"); return (_, world) -> world.getEnvironment() == environment; } /** - * Creates a condition permitting spawning at or above a minimum Y coordinate. + * Creates a condition permitting spawning only in the Overworld environment. * - * @param minY minimum Y coordinate - * @return minimum height spawn condition predicate + * @return Overworld environment spawn condition predicate */ - public static SpawnCondition atLeastY(int minY) { - return (location, world) -> location.getBlockY() >= minY; + public static SpawnCondition overworld() { + return environment(World.Environment.NORMAL); } /** - * Creates a condition permitting spawning at or below a maximum Y coordinate. + * Creates a condition permitting spawning only in the Nether environment. * - * @param maxY maximum Y coordinate - * @return maximum height spawn condition predicate + * @return Nether environment spawn condition predicate */ - public static SpawnCondition atMostY(int maxY) { - return (location, _) -> location.getBlockY() <= maxY; + public static SpawnCondition nether() { + return environment(World.Environment.NETHER); + } + + /** + * Creates a condition permitting spawning only in The End environment. + * + * @return The End environment spawn condition predicate + */ + public static SpawnCondition theEnd() { + return environment(World.Environment.THE_END); } /** @@ -97,51 +103,21 @@ public static SpawnCondition skyExposed() { } /** - * Creates a condition permitting spawning only outside liquid blocks (water or lava). - * - * @return non-liquid spawn condition predicate - */ - public static SpawnCondition notInLiquid() { - return (location, world) -> !location.getBlock().isLiquid(); - } - - /** - * Creates a condition permitting spawning during nighttime hours (ticks 13000 to 23000). + * Creates a condition permitting spawning inside liquid blocks (water or lava). * - * @return nighttime spawn condition predicate + * @return liquid spawn condition predicate */ - public static SpawnCondition nightTime() { - return (_, world) -> { - long time = world.getTime(); - return time >= 13000 && time <= 23000; - }; + public static SpawnCondition inLiquid() { + return (location, _) -> location.getBlock().isLiquid(); } /** - * Creates a condition permitting spawning only during clear weather. - * - * @return clear sky spawn condition predicate - */ - public static SpawnCondition clearSky() { - return (location, world) -> !world.hasStorm(); - } - - /** - * Creates a condition permitting spawning during rain or stormy weather. - * - * @return rain spawn condition predicate - */ - public static SpawnCondition isRaining() { - return (location, world) -> world.hasStorm(); - } - - /** - * Creates a condition permitting spawning during thunder storms. + * Creates a condition permitting spawning only outside liquid blocks (water or lava). * - * @return thunder spawn condition predicate + * @return non-liquid spawn condition predicate */ - public static SpawnCondition isThundering() { - return (location, world) -> world.isThundering(); + public static SpawnCondition notInLiquid() { + return inLiquid().negate(); } /** @@ -150,7 +126,8 @@ public static SpawnCondition isThundering() { * @param structure world structure type * @return structure spawn condition predicate */ - public static SpawnCondition insideStructure(Structure structure) { + public static SpawnCondition insideStructure(@NonNull Structure structure) { + Objects.requireNonNull(structure, "structure cannot be null"); return (location, world) -> { int chunkX = location.getBlockX() >> 4; int chunkZ = location.getBlockZ() >> 4; @@ -168,97 +145,6 @@ public static SpawnCondition insideStructure(Structure structure) { }; } - /** - * Creates a condition permitting spawning inside Trial Chambers structures. - * - * @return trial chamber structure spawn condition predicate - */ - public static SpawnCondition insideTrialChamber() { - return insideStructure(Structure.TRIAL_CHAMBERS); - } - - /** - * Creates a condition permitting spawning near an active raid within a specified block radius. - * - * @param radius search radius in blocks - * @return active raid spawn condition predicate - */ - public static SpawnCondition nearActiveRaid(int radius) { - return (location, world) -> { - Raid nearest = world.locateNearestRaid(location, radius); - return nearest != null && nearest.isStarted(); - }; - } - - /** - * Creates a condition permitting spawning only when no active raid is within the specified radius. - * - * @param radius search radius in blocks - * @return no nearby raid spawn condition predicate - */ - public static SpawnCondition noNearbyRaid(int radius) { - return nearActiveRaid(radius).negate(); - } - - /** - * Creates a condition permitting spawning near an active Trial Spawner within a specified block radius. - * - * @param radius search radius in blocks - * @return active trial spawner spawn condition predicate - */ - public static SpawnCondition nearActiveTrialSpawner(int radius) { - return (location, world) -> { - int originX = location.getBlockX(); - int originY = location.getBlockY(); - int originZ = location.getBlockZ(); - - for (int dx = -radius; dx <= radius; dx++) { - for (int dy = -radius; dy <= radius; dy++) { - for (int dz = -radius; dz <= radius; dz++) { - Block block = world.getBlockAt(originX + dx, originY + dy, originZ + dz); - if (block.getType() != Material.TRIAL_SPAWNER) continue; - - if (block.getBlockData() instanceof TrialSpawner trialData - && trialData.getTrialSpawnerState() == State.ACTIVE) { - return true; - } - } - } - } - return false; - }; - } - - /** - * Creates a condition permitting spawning near an active Ominous Trial Spawner within a specified block radius. - * - * @param radius search radius in blocks - * @return active ominous trial spawner spawn condition predicate - */ - public static SpawnCondition nearOminousTrialSpawner(int radius) { - return (location, world) -> { - int originX = location.getBlockX(); - int originY = location.getBlockY(); - int originZ = location.getBlockZ(); - - for (int dx = -radius; dx <= radius; dx++) { - for (int dy = -radius; dy <= radius; dy++) { - for (int dz = -radius; dz <= radius; dz++) { - Block block = world.getBlockAt(originX + dx, originY + dy, originZ + dz); - if (block.getType() != Material.TRIAL_SPAWNER) continue; - - if (block.getBlockData() instanceof TrialSpawner trialData - && trialData.isOminous() - && trialData.getTrialSpawnerState() == State.ACTIVE) { - return true; - } - } - } - } - return false; - }; - } - /** * Composite condition for standard Overworld hostile monster spawning (darkness, non-liquid). * diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/SpawnEvaluator.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/SpawnEvaluator.java index 5e022d3e..299562ac 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/SpawnEvaluator.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/engine/SpawnEvaluator.java @@ -57,6 +57,6 @@ public static SpawnableEntity selectCandidate(List candidates, } } - return null; + return eligible.getLast(); } } diff --git a/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningRuleTest.java b/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningRuleTest.java new file mode 100644 index 00000000..9c87bc13 --- /dev/null +++ b/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningRuleTest.java @@ -0,0 +1,128 @@ +package fr.moussax.blightedSMP.engine.entities.spawnable; + +import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnCondition; +import fr.moussax.blightedSMP.engine.entities.spawnable.engine.SpawnMode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +class SpawningRuleTest { + + private final SpawnCondition alwaysTrue = (_, _) -> true; + private final SpawnCondition alwaysFalse = (_, _) -> false; + + @Test + @DisplayName("anyOf should pass if at least one condition passes") + void testAnyOf() { + assertFalse(SpawnCondition.anyOf().testCanSpawnAt(null, null), "Empty anyOf should evaluate to false"); + + assertTrue(SpawnCondition.anyOf(alwaysTrue).testCanSpawnAt(null, null)); + assertFalse(SpawnCondition.anyOf(alwaysFalse).testCanSpawnAt(null, null)); + + assertTrue(SpawnCondition.anyOf(alwaysFalse, alwaysTrue, alwaysFalse).testCanSpawnAt(null, null)); + assertFalse(SpawnCondition.anyOf(alwaysFalse, alwaysFalse).testCanSpawnAt(null, null)); + + // Short-circuiting verification + AtomicBoolean evaluated = new AtomicBoolean(false); + SpawnCondition probe = (_, _) -> { + evaluated.set(true); + return true; + }; + + boolean result = SpawnCondition.anyOf(alwaysTrue, probe).testCanSpawnAt(null, null); + assertTrue(result); + assertFalse(evaluated.get(), "anyOf should short-circuit upon first matching condition"); + } + + @Test + @DisplayName("allOf should pass only if all conditions pass") + void testAllOf() { + assertTrue(SpawnCondition.allOf().testCanSpawnAt(null, null), "Empty allOf should evaluate to true"); + + assertTrue(SpawnCondition.allOf(alwaysTrue, alwaysTrue).testCanSpawnAt(null, null)); + assertFalse(SpawnCondition.allOf(alwaysTrue, alwaysFalse).testCanSpawnAt(null, null)); + + // Short-circuiting verification + AtomicBoolean evaluated = new AtomicBoolean(false); + SpawnCondition probe = (_, _) -> { + evaluated.set(true); + return true; + }; + + boolean result = SpawnCondition.allOf(alwaysFalse, probe).testCanSpawnAt(null, null); + assertFalse(result); + assertFalse(evaluated.get(), "allOf should short-circuit upon first failing condition"); + } + + @Test + @DisplayName("noneOf should pass only if no conditions pass") + void testNoneOf() { + assertTrue(SpawnCondition.noneOf(alwaysFalse, alwaysFalse).testCanSpawnAt(null, null)); + assertFalse(SpawnCondition.noneOf(alwaysFalse, alwaysTrue).testCanSpawnAt(null, null)); + } + + @Test + @DisplayName("Default combinators and, or, negate should evaluate correctly") + void testDefaultCombinators() { + assertTrue(alwaysTrue.and(alwaysTrue).testCanSpawnAt(null, null)); + assertFalse(alwaysTrue.and(alwaysFalse).testCanSpawnAt(null, null)); + + assertTrue(alwaysFalse.or(alwaysTrue).testCanSpawnAt(null, null)); + assertFalse(alwaysFalse.or(alwaysFalse).testCanSpawnAt(null, null)); + + assertFalse(alwaysTrue.negate().testCanSpawnAt(null, null)); + assertTrue(alwaysFalse.negate().testCanSpawnAt(null, null)); + } + + @Test + @DisplayName("SpawningBuilder correctly configures probability, mode, and conditions") + void testSpawningBuilder() { + SpawningBuilder builder = new SpawningBuilder(); + + assertEquals(SpawningBuilder.DEFAULT_PROBABILITY, builder.getProbability()); + assertEquals(SpawningBuilder.DEFAULT_MODE, builder.getMode()); + + builder.probability(0.05) + .mode(SpawnMode.INDEPENDENT) + .anyOf(alwaysTrue, alwaysFalse) + .condition(alwaysTrue); + + assertEquals(0.05, builder.getProbability(), 1e-6); + assertEquals(SpawnMode.INDEPENDENT, builder.getMode()); + assertEquals(2, builder.getConditions().size()); + + SpawnProfile profile = builder.buildProfile(); + assertNotNull(profile); + assertEquals(2, profile.conditions().size()); + } + + @Test + @DisplayName("SpawningBuilder should reject out-of-range probabilities") + void testProbabilityValidation() { + SpawningBuilder builder = new SpawningBuilder(); + assertThrows(IllegalArgumentException.class, () -> builder.probability(-0.01)); + assertThrows(IllegalArgumentException.class, () -> builder.probability(1.01)); + } + + @Test + @DisplayName("SpawningBuilder should properly configure and validate elite affixes") + void testAffixesConfiguration() { + SpawningBuilder builder = new SpawningBuilder(); + assertEquals(0.0, builder.getAffixChance()); + assertEquals(1, builder.getMaxAffixes()); + + builder.affixes(0.25, 3); + assertEquals(0.25, builder.getAffixChance(), 1e-6); + assertEquals(3, builder.getMaxAffixes()); + + builder.affixes(0.10); + assertEquals(0.10, builder.getAffixChance(), 1e-6); + assertEquals(1, builder.getMaxAffixes()); + + assertThrows(IllegalArgumentException.class, () -> builder.affixes(-0.1)); + assertThrows(IllegalArgumentException.class, () -> builder.affixes(1.1)); + } +} From 95be0146f4aac271eab2b22251f3eca29264c175 Mon Sep 17 00:00:00 2001 From: Moussa Date: Fri, 2 Oct 2026 14:14:55 +0200 Subject: [PATCH 03/13] refactor(entities): modernize AncientCreature and attributes configuration Replace legacy JavaBean setters on AncientCreature with fluent timeAllowance, summoner, and activeAbility methods. Synchronize max health, attack damage, and defense in BlightedEntity attributes consumer. --- .../commands/impl/SpawnCustomMobCommand.java | 2 +- .../engine/entities/BlightedEntity.java | 12 +++ .../entities/rituals/AncientCreature.java | 83 ++++++++++++++----- .../rituals/menu/RitualAltarMenu.java | 2 +- 4 files changed, 75 insertions(+), 24 deletions(-) diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/SpawnCustomMobCommand.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/SpawnCustomMobCommand.java index f74f9ac0..fe9d5004 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/SpawnCustomMobCommand.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/SpawnCustomMobCommand.java @@ -28,7 +28,7 @@ protected boolean executeAdmin(Player player, Command command, String label, Str } if (entity instanceof AncientCreature ancientCreature) { - ancientCreature.setSummoner(player); + ancientCreature.summoner(player); } try { diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/BlightedEntity.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/BlightedEntity.java index a0e11845..4e4cdcd7 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/BlightedEntity.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/BlightedEntity.java @@ -670,6 +670,18 @@ public void attributes(@NonNull Consumer consumer) { EntityAttributesBuilder builder = new EntityAttributesBuilder(); consumer.accept(builder); builder.applyTo(this.attributes, this.entity); + Double configuredMaxHealth = builder.getAttributes().get(Attribute.MAX_HEALTH); + if (configuredMaxHealth != null) { + this.maxHealth = (int) Math.round(configuredMaxHealth); + } + Double configuredDamage = builder.getAttributes().get(Attribute.ATTACK_DAMAGE); + if (configuredDamage != null) { + this.damage = (int) Math.round(configuredDamage); + } + Double configuredDefense = builder.getAttributes().get(Attribute.ARMOR); + if (configuredDefense != null) { + this.defense = (int) Math.round(configuredDefense); + } } /** diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/AncientCreature.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/AncientCreature.java index 3c681dca..fbfa9eef 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/AncientCreature.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/AncientCreature.java @@ -22,6 +22,9 @@ import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; +import java.time.Duration; +import java.util.Objects; + /** * Base class for ancient creatures summoned through an {@link AncientRitual}. * @@ -36,8 +39,14 @@ public abstract class AncientCreature extends BlightedEntity { private static final int DEFAULT_TIME_ALLOWANCE_SECONDS = 240; private static final double HOLOGRAM_RESCAN_RADIUS = 8.0; - private static final NamespacedKey HOLOGRAM_KEY = - new NamespacedKey(BlightedSMP.getInstance(), "ancient_creature_hologram"); + private static NamespacedKey hologramKey; + + private static NamespacedKey getHologramKey() { + if (hologramKey == null) { + hologramKey = new NamespacedKey(BlightedSMP.getInstance(), "ancient_creature_hologram"); + } + return hologramKey; + } @Getter protected int timeAllowance = DEFAULT_TIME_ALLOWANCE_SECONDS; @@ -66,47 +75,77 @@ public AncientCreature(@NonNull String entityId, @NonNull String name, @NonNull } /** - * Sets the time allowance to defeat this creature, in seconds. - * Also updates the remaining countdown seconds if the creature has not collapsed. + * Sets the countdown time allowance to defeat this ancient creature. * - * @param timeAllowanceSeconds time allowed in seconds + * @param duration time allowance + * @return this creature */ - public void setTimeAllowance(int timeAllowanceSeconds) { - this.timeAllowance = timeAllowanceSeconds; - this.remainingSeconds = timeAllowanceSeconds; + public AncientCreature timeAllowance(@NonNull Duration duration) { + Objects.requireNonNull(duration, "duration cannot be null"); + if (duration.toSeconds() <= 0) { + throw new IllegalArgumentException("timeAllowance must be at least 1 second, got: " + duration); + } + this.timeAllowance = (int) duration.toSeconds(); + this.remainingSeconds = this.timeAllowance; + return this; } /** - * Sets the player who summoned this creature. + * Sets the countdown time allowance in seconds to defeat this ancient creature. * - *

The player's name is stored for display in the creature's hologram. - * If no player is supplied, the summoner is set to {@code "Unknown"}.

+ * @param seconds time allowance in seconds + * @return this creature + */ + public AncientCreature timeAllowance(int seconds) { + if (seconds <= 0) { + throw new IllegalArgumentException("timeAllowance must be positive, got: " + seconds); + } + return timeAllowance(Duration.ofSeconds(seconds)); + } + + /** + * Sets the player who summoned this creature. * - * @param player summoning player, or {@code null} + * @param player summoning player, or {@code null} for unknown + * @return this creature */ - public void setSummoner(@Nullable Player player) { + public AncientCreature summoner(@Nullable Player player) { this.summonerName = (player != null) ? player.getName() : "Unknown"; + updateHologramText(); + return this; } /** - * Sets the name of the ability currently active on this creature. + * Sets the summoner name directly. * - *

The attached hologram is updated immediately after the ability name - * changes.

+ * @param summonerName summoner name + * @return this creature + */ + public AncientCreature summoner(@NonNull String summonerName) { + this.summonerName = Objects.requireNonNull(summonerName, "summonerName cannot be null"); + updateHologramText(); + return this; + } + + /** + * Sets the name of the ability currently active on this creature and updates the hologram. * * @param abilityName active ability name, or {@code null} when no ability is active + * @return this creature */ - public void setActiveAbilityName(@Nullable String abilityName) { + public AncientCreature activeAbility(@Nullable String abilityName) { this.activeAbilityName = abilityName; updateHologramText(); + return this; } /** * Clears the currently active ability and updates the hologram. + * + * @return this creature */ - public void clearActiveAbility() { - this.activeAbilityName = null; - updateHologramText(); + public AncientCreature clearActiveAbility() { + return activeAbility(null); } /** @@ -141,7 +180,7 @@ protected void onRehydrate(LivingEntity existing) { String attachedOwner = persistentDataContainer.get(EntityAttachmentManager.ATTACHMENT_OWNER_KEY, PersistentDataType.STRING); if (!ownerUuid.equals(attachedOwner)) continue; - if (persistentDataContainer.has(HOLOGRAM_KEY, PersistentDataType.BYTE)) { + if (persistentDataContainer.has(getHologramKey(), PersistentDataType.BYTE)) { this.hologram = display; break; } @@ -177,7 +216,7 @@ private void createHologram(Location spawnLocation) { ); display.setTransformation(transformation); - display.getPersistentDataContainer().set(HOLOGRAM_KEY, PersistentDataType.BYTE, (byte) 1); + display.getPersistentDataContainer().set(getHologramKey(), PersistentDataType.BYTE, (byte) 1); this.hologram = display; this.hologram.setText(buildHologramContent()); diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java index 28c130d0..0d487c94 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java @@ -314,7 +314,7 @@ private void handleFinalImpact(Location location) { Bukkit.broadcastMessage("§5 ☤ §dThe §4" + ritual.getSummonedCreature().getName() + " §dhas been summoned by §f" + player.getName() + "§d."); AncientCreature creature = ritual.getSummonedCreature().createInstance(); - creature.setSummoner(player); + creature.summoner(player); creature.spawn(location); } } From 6e0e352829e7109cbe19f9e179f00b4d6aa67984 Mon Sep 17 00:00:00 2001 From: Moussa Date: Fri, 2 Oct 2026 14:15:13 +0200 Subject: [PATCH 04/13] refactor(loot): streamline EntityLootTableBuilder with concise drop methods Replace verbose addLoot, addGems, addDamagedItem, and addEnchantedBook methods with drop, gems, damagedItem, and enchantedBook. Optimize AmountProvider usage for fixed drop quantities. --- .../entities/EntityLootTableBuilder.java | 653 +++++------------- 1 file changed, 174 insertions(+), 479 deletions(-) diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityLootTableBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityLootTableBuilder.java index ccd2278e..b143d712 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityLootTableBuilder.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/EntityLootTableBuilder.java @@ -4,6 +4,7 @@ import fr.moussax.blightedSMP.engine.items.BlightedItem; import fr.moussax.blightedSMP.engine.loot.LootCondition; import fr.moussax.blightedSMP.engine.loot.LootEntry; +import fr.moussax.blightedSMP.engine.loot.LootResult; import fr.moussax.blightedSMP.engine.loot.LootTable; import fr.moussax.blightedSMP.engine.loot.decorators.EntityLootRarity; import fr.moussax.blightedSMP.engine.loot.decorators.FeedbackSpecification; @@ -15,17 +16,16 @@ import org.bukkit.Material; import org.bukkit.Sound; import org.bukkit.enchantments.Enchantment; +import org.jspecify.annotations.NonNull; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.Consumer; import java.util.function.Function; /** - * Fluent builder for creating {@link LootTable} instances associated with blighted entities. - * - *

Entries added through this builder are selected probabilistically using a - * {@link LootingAwareProbabilisticStrategy} and wrapped with rarity-based feedback.

+ * Fluent builder for configuring probabilistic entity loot tables with rarity feedback. */ public final class EntityLootTableBuilder { @@ -45,571 +45,266 @@ public final class EntityLootTableBuilder { private int maxDrops = 3; /** - * Adds custom blighted item drop with fixed quantity of 1. + * Sets the maximum number of loot drops allowed per roll. * - * @param item custom blighted item - * @param dropChance selection probability (0.0 to 1.0) + * @param maxDrops maximum drop count * @return this builder */ - public EntityLootTableBuilder addLoot(BlightedItem item, double dropChance) { - if (item == null) return this; - return addLoot( - item.getItemId(), - 1, - 1, - dropChance, - EntityLootRarity.COMMON, - LootCondition.alwaysTrue() - ); + public EntityLootTableBuilder maxDrops(int maxDrops) { + this.maxDrops = maxDrops; + return this; } - /** - * Adds custom blighted item drop with fixed quantity of 1 and rarity feedback. - * - * @param item custom blighted item - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot(BlightedItem item, double dropChance, EntityLootRarity rarity) { - if (item == null) return this; - return addLoot(item.getItemId(), 1, 1, dropChance, rarity, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull Material material, double dropChance) { + return drop(material, 1, 1, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); } - /** - * Adds custom blighted item drop with quantity range. - * - * @param item custom blighted item - * @param minAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addLoot(BlightedItem item, int minAmount, int maximumAmount, double dropChance) { - if (item == null) return this; - return addLoot(item.getItemId(), minAmount, maximumAmount, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull Material material, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(material, 1, 1, dropChance, rarity, LootCondition.alwaysTrue()); } - /** - * Adds custom blighted item drop with quantity range and rarity feedback. - * - * @param item custom blighted item - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot(BlightedItem item, int minimumAmount, int maximumAmount, double dropChance, EntityLootRarity rarity) { - if (item == null) return this; - return addLoot(item.getItemId(), minimumAmount, maximumAmount, dropChance, rarity, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull Material material, int count, double dropChance) { + return drop(material, count, count, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); } - /** - * Adds custom blighted item drop with quantity range, rarity feedback, and eligibility condition. - * - * @param item custom blighted item - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @param condition eligibility condition - * @return this builder - */ - public EntityLootTableBuilder addLoot( - BlightedItem item, - int minimumAmount, - int maximumAmount, + public EntityLootTableBuilder drop(@NonNull Material material, int count, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(material, count, count, dropChance, rarity, LootCondition.alwaysTrue()); + } + + public EntityLootTableBuilder drop(@NonNull Material material, int minAmount, int maxAmount, double dropChance) { + return drop(material, minAmount, maxAmount, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + } + + public EntityLootTableBuilder drop(@NonNull Material material, int minAmount, int maxAmount, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(material, minAmount, maxAmount, dropChance, rarity, LootCondition.alwaysTrue()); + } + + public EntityLootTableBuilder drop( + @NonNull Material material, + int minAmount, + int maxAmount, double dropChance, - EntityLootRarity rarity, - LootCondition condition + @NonNull EntityLootRarity rarity, + @NonNull LootCondition condition ) { - if (item == null) return this; - return addLoot(item.getItemId(), minimumAmount, maximumAmount, dropChance, rarity, condition); + Objects.requireNonNull(material, "material cannot be null"); + return registerEntry(ItemResult.of(material), minAmount, maxAmount, dropChance, rarity, condition); } - /** - * Adds registered item drop with fixed quantity of 1. - * - * @param itemId registered item identifier - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addLoot(String itemId, double dropChance) { - return addLoot(itemId, 1, 1, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull Material material, @NonNull Consumer modifier, double dropChance) { + return drop(material, modifier, 1, 1, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); } - /** - * Adds registered item drop with fixed quantity of 1 and rarity feedback. - * - * @param itemId registered item identifier - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot(String itemId, double dropChance, EntityLootRarity rarity) { - return addLoot(itemId, 1, 1, dropChance, rarity, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull Material material, @NonNull Consumer modifier, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(material, modifier, 1, 1, dropChance, rarity, LootCondition.alwaysTrue()); } - /** - * Adds registered item drop with quantity range. - * - * @param itemId registered item identifier - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addLoot(String itemId, int minimumAmount, int maximumAmount, double dropChance) { - return addLoot(itemId, minimumAmount, maximumAmount, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull Material material, @NonNull Consumer modifier, int minAmount, int maxAmount, double dropChance) { + return drop(material, modifier, minAmount, maxAmount, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); } - /** - * Adds registered item drop with quantity range and rarity feedback. - * - * @param itemId registered item identifier - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot( - String itemId, - int minimumAmount, - int maximumAmount, - double dropChance, - EntityLootRarity rarity - ) { - return addLoot(itemId, minimumAmount, maximumAmount, dropChance, rarity, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull Material material, @NonNull Consumer modifier, int minAmount, int maxAmount, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(material, modifier, minAmount, maxAmount, dropChance, rarity, LootCondition.alwaysTrue()); } - /** - * Adds registered item drop with quantity range, rarity feedback, and eligibility condition. - * - * @param itemId registered item identifier - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @param condition eligibility condition - * @return this builder - */ - public EntityLootTableBuilder addLoot( - String itemId, - int minimumAmount, - int maximumAmount, + public EntityLootTableBuilder drop( + @NonNull Material material, + @NonNull Consumer modifier, + int minAmount, + int maxAmount, double dropChance, - EntityLootRarity rarity, - LootCondition condition + @NonNull EntityLootRarity rarity, + @NonNull LootCondition condition ) { - builder.addEntry( - LootEntry.probabilistic( - new GenericFeedbackDecorator<>( - ItemResult.of(itemId), rarity, ENTITY_FEEDBACK_MAPPER - ), - dropChance, - AmountProvider.range(minimumAmount, maximumAmount), - condition - ) - ); - return this; + Objects.requireNonNull(material, "material cannot be null"); + Objects.requireNonNull(modifier, "modifier cannot be null"); + return registerEntry(ItemResult.of(material, modifier), minAmount, maxAmount, dropChance, rarity, condition); } - /** - * Adds vanilla material item drop with fixed quantity of 1. - * - * @param material item material - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addLoot(Material material, double dropChance) { - return addLoot(material, 1, 1, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull String itemId, double dropChance) { + return drop(itemId, 1, 1, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); } - /** - * Adds vanilla material item drop with fixed quantity of 1 and rarity feedback. - * - * @param material item material - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot(Material material, double dropChance, EntityLootRarity rarity) { - return addLoot(material, 1, 1, dropChance, rarity, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull String itemId, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(itemId, 1, 1, dropChance, rarity, LootCondition.alwaysTrue()); } - /** - * Adds vanilla material item drop with quantity range. - * - * @param material item material - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addLoot( - Material material, - int minimumAmount, - int maximumAmount, - double dropChance - ) { - return addLoot(material, minimumAmount, maximumAmount, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull String itemId, int count, double dropChance) { + return drop(itemId, count, count, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); } - /** - * Adds vanilla material item drop with quantity range and rarity feedback. - * - * @param material item material - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot( - Material material, - int minimumAmount, - int maximumAmount, - double dropChance, - EntityLootRarity rarity - ) { - return addLoot(material, minimumAmount, maximumAmount, dropChance, rarity, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull String itemId, int count, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(itemId, count, count, dropChance, rarity, LootCondition.alwaysTrue()); } - /** - * Adds vanilla material item drop with quantity range, rarity feedback, and eligibility condition. - * - * @param material item material - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @param condition eligibility condition - * @return this builder - */ - public EntityLootTableBuilder addLoot( - Material material, - int minimumAmount, - int maximumAmount, - double dropChance, - EntityLootRarity rarity, - LootCondition condition - ) { - builder.addEntry( - LootEntry.probabilistic( - new GenericFeedbackDecorator<>( - ItemResult.of(material), rarity, ENTITY_FEEDBACK_MAPPER - ), - dropChance, - AmountProvider.range(minimumAmount, maximumAmount), - condition - ) - ); - return this; + public EntityLootTableBuilder drop(@NonNull String itemId, int minAmount, int maxAmount, double dropChance) { + return drop(itemId, minAmount, maxAmount, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); } - /** - * Adds modified material item drop with fixed quantity of 1. - * - * @param material item material - * @param modifier item builder modification function - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addLoot(Material material, Consumer modifier, double dropChance) { - return addLoot(material, modifier, 1, 1, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull String itemId, int minAmount, int maxAmount, double dropChance, @NonNull EntityLootRarity rarity) { + return drop(itemId, minAmount, maxAmount, dropChance, rarity, LootCondition.alwaysTrue()); } - /** - * Adds modified material item drop with fixed quantity of 1 and rarity feedback. - * - * @param material item material - * @param modifier item builder modification function - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot( - Material material, - Consumer modifier, + public EntityLootTableBuilder drop( + @NonNull String itemId, + int minAmount, + int maxAmount, double dropChance, - EntityLootRarity rarity + @NonNull EntityLootRarity rarity, + @NonNull LootCondition condition ) { - return addLoot(material, modifier, 1, 1, dropChance, rarity, LootCondition.alwaysTrue()); + Objects.requireNonNull(itemId, "itemId cannot be null"); + return registerEntry(ItemResult.of(itemId), minAmount, maxAmount, dropChance, rarity, condition); } - /** - * Adds modified material item drop with quantity range. - * - * @param material item material - * @param modifier item builder modification function - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addLoot( - Material material, - Consumer modifier, - int minimumAmount, - int maximumAmount, - double dropChance - ) { - return addLoot(material, modifier, minimumAmount, maximumAmount, dropChance, EntityLootRarity.COMMON, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull BlightedItem item, double dropChance) { + Objects.requireNonNull(item, "item cannot be null"); + return drop(item.getItemId(), dropChance); } - /** - * Adds modified material item drop with quantity range and rarity feedback. - * - * @param material item material - * @param modifier item builder modification function - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addLoot( - Material material, - Consumer modifier, - int minimumAmount, - int maximumAmount, - double dropChance, - EntityLootRarity rarity - ) { - return addLoot(material, modifier, minimumAmount, maximumAmount, dropChance, rarity, LootCondition.alwaysTrue()); + public EntityLootTableBuilder drop(@NonNull BlightedItem item, double dropChance, @NonNull EntityLootRarity rarity) { + Objects.requireNonNull(item, "item cannot be null"); + return drop(item.getItemId(), dropChance, rarity); } - /** - * Adds modified material item drop with quantity range, rarity feedback, and eligibility condition. - * - * @param material item material - * @param modifier item builder modification function - * @param minimumAmount minimum drop quantity - * @param maximumAmount maximum drop quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @param condition eligibility condition - * @return this builder - */ - public EntityLootTableBuilder addLoot( - Material material, - Consumer modifier, - int minimumAmount, - int maximumAmount, + public EntityLootTableBuilder drop(@NonNull BlightedItem item, int count, double dropChance) { + Objects.requireNonNull(item, "item cannot be null"); + return drop(item.getItemId(), count, dropChance); + } + + public EntityLootTableBuilder drop(@NonNull BlightedItem item, int count, double dropChance, @NonNull EntityLootRarity rarity) { + Objects.requireNonNull(item, "item cannot be null"); + return drop(item.getItemId(), count, dropChance, rarity); + } + + public EntityLootTableBuilder drop(@NonNull BlightedItem item, int minAmount, int maxAmount, double dropChance) { + Objects.requireNonNull(item, "item cannot be null"); + return drop(item.getItemId(), minAmount, maxAmount, dropChance); + } + + public EntityLootTableBuilder drop(@NonNull BlightedItem item, int minAmount, int maxAmount, double dropChance, @NonNull EntityLootRarity rarity) { + Objects.requireNonNull(item, "item cannot be null"); + return drop(item.getItemId(), minAmount, maxAmount, dropChance, rarity); + } + + public EntityLootTableBuilder drop( + @NonNull BlightedItem item, + int minAmount, + int maxAmount, double dropChance, - EntityLootRarity rarity, - LootCondition condition + @NonNull EntityLootRarity rarity, + @NonNull LootCondition condition ) { - builder.addEntry( - LootEntry.probabilistic( - new GenericFeedbackDecorator<>( - ItemResult.of(material, modifier), rarity, ENTITY_FEEDBACK_MAPPER - ), - dropChance, - AmountProvider.range(minimumAmount, maximumAmount), - condition - ) - ); - return this; + Objects.requireNonNull(item, "item cannot be null"); + return drop(item.getItemId(), minAmount, maxAmount, dropChance, rarity, condition); } - /** - * Adds enchanted book drop selected from an enchantment map pool. - * - * @param enchantmentPool map of candidate enchantments to levels - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addEnchantedBook(Map enchantmentPool, double dropChance) { - return addEnchantedBook(enchantmentPool, dropChance, EntityLootRarity.COMMON); + public EntityLootTableBuilder gems(int gems, double dropChance) { + return gems(gems, dropChance, EntityLootRarity.COMMON); } - /** - * Adds enchanted book drop selected from an enchantment map pool with rarity feedback. - * - * @param enchantmentPool map of candidate enchantments to levels - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addEnchantedBook( - Map enchantmentPool, + public EntityLootTableBuilder gems(int gems, double dropChance, @NonNull EntityLootRarity rarity) { + Objects.requireNonNull(rarity, "rarity cannot be null"); + return registerEntry(new GemsResult(), gems, gems, dropChance, rarity, LootCondition.alwaysTrue()); + } + + public EntityLootTableBuilder damagedItem( + @NonNull Material material, + double minPercentage, + double maxPercentage, + double dropChance + ) { + return damagedItem(material, minPercentage, maxPercentage, dropChance, EntityLootRarity.COMMON); + } + + public EntityLootTableBuilder damagedItem( + @NonNull Material material, + double minPercentage, + double maxPercentage, double dropChance, - EntityLootRarity rarity + @NonNull EntityLootRarity rarity ) { - builder.addEntry( - LootEntry.probabilistic( - new GenericFeedbackDecorator<>( - ItemResult.randomEnchantedBook(enchantmentPool), rarity, ENTITY_FEEDBACK_MAPPER - ), - dropChance, - AmountProvider.fixed(1), - LootCondition.alwaysTrue() - ) + Objects.requireNonNull(material, "material cannot be null"); + Objects.requireNonNull(rarity, "rarity cannot be null"); + return registerEntry( + ItemResult.randomDurability(material, minPercentage, maxPercentage), + 1, 1, dropChance, rarity, LootCondition.alwaysTrue() ); - return this; } - /** - * Adds enchanted book drop with level range selected from candidate enchantments. - * - * @param enchantments list of candidate enchantments - * @param minimumLevel minimum enchantment level - * @param maximumLevel maximum enchantment level - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addEnchantedBook( - List enchantments, - int minimumLevel, - int maximumLevel, + public EntityLootTableBuilder enchantedBook( + @NonNull List enchantments, + int minLevel, + int maxLevel, double dropChance ) { - return addEnchantedBook(enchantments, minimumLevel, maximumLevel, dropChance, EntityLootRarity.COMMON); + return enchantedBook(enchantments, minLevel, maxLevel, dropChance, EntityLootRarity.COMMON); } - /** - * Adds enchanted book drop with level range selected from candidate enchantments with rarity feedback. - * - * @param enchantments list of candidate enchantments - * @param minimumLevel minimum enchantment level - * @param maximumLevel maximum enchantment level - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addEnchantedBook( - List enchantments, - int minimumLevel, - int maximumLevel, + public EntityLootTableBuilder enchantedBook( + @NonNull List enchantments, + int minLevel, + int maxLevel, double dropChance, - EntityLootRarity rarity + @NonNull EntityLootRarity rarity ) { - builder.addEntry( - LootEntry.probabilistic( - new GenericFeedbackDecorator<>( - ItemResult.randomEnchantedBook(enchantments, minimumLevel, maximumLevel), rarity, ENTITY_FEEDBACK_MAPPER - ), - dropChance, - AmountProvider.fixed(1), - LootCondition.alwaysTrue() - ) + Objects.requireNonNull(enchantments, "enchantments cannot be null"); + Objects.requireNonNull(rarity, "rarity cannot be null"); + return registerEntry( + ItemResult.randomEnchantedBook(enchantments, minLevel, maxLevel), + 1, 1, dropChance, rarity, LootCondition.alwaysTrue() ); - return this; } - /** - * Adds item drop with durability rolled in a percentage range. - * - * @param material item material - * @param minimumPercentage minimum durability percentage - * @param maximumPercentage maximum durability percentage - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addDamagedItem( - Material material, - double minimumPercentage, - double maximumPercentage, + public EntityLootTableBuilder enchantedBook( + @NonNull Map enchantmentPool, double dropChance ) { - return addDamagedItem(material, minimumPercentage, maximumPercentage, dropChance, EntityLootRarity.COMMON); + return enchantedBook(enchantmentPool, dropChance, EntityLootRarity.COMMON); } - /** - * Adds item drop with durability rolled in a percentage range with rarity feedback. - * - * @param material item material - * @param minimumPercentage minimum durability percentage - * @param maximumPercentage maximum durability percentage - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addDamagedItem( - Material material, - double minimumPercentage, - double maximumPercentage, + public EntityLootTableBuilder enchantedBook( + @NonNull Map enchantmentPool, double dropChance, - EntityLootRarity rarity + @NonNull EntityLootRarity rarity ) { - builder.addEntry( - LootEntry.probabilistic( - new GenericFeedbackDecorator<>( - ItemResult.randomDurability( - material, - minimumPercentage, - maximumPercentage - ), rarity, ENTITY_FEEDBACK_MAPPER - ), - dropChance, - AmountProvider.fixed(1), - LootCondition.alwaysTrue() - ) + Objects.requireNonNull(enchantmentPool, "enchantmentPool cannot be null"); + Objects.requireNonNull(rarity, "rarity cannot be null"); + return registerEntry( + ItemResult.randomEnchantedBook(enchantmentPool), + 1, 1, dropChance, rarity, LootCondition.alwaysTrue() ); - return this; } - /** - * Adds gem reward drop. - * - * @param gems gem reward quantity - * @param dropChance selection probability (0.0 to 1.0) - * @return this builder - */ - public EntityLootTableBuilder addGems(int gems, double dropChance) { - return addGems(gems, dropChance, EntityLootRarity.COMMON); - } + private EntityLootTableBuilder registerEntry( + LootResult result, + int minAmount, + int maxAmount, + double dropChance, + EntityLootRarity rarity, + LootCondition condition + ) { + AmountProvider amountProvider = (minAmount == maxAmount) + ? AmountProvider.fixed(minAmount) + : AmountProvider.range(minAmount, maxAmount); - /** - * Adds gem reward drop with rarity feedback. - * - * @param gems gem reward quantity - * @param dropChance selection probability (0.0 to 1.0) - * @param rarity rarity tier for feedback - * @return this builder - */ - public EntityLootTableBuilder addGems(int gems, double dropChance, EntityLootRarity rarity) { builder.addEntry( LootEntry.probabilistic( - new GenericFeedbackDecorator<>(new GemsResult(), rarity, ENTITY_FEEDBACK_MAPPER), + new GenericFeedbackDecorator<>(result, rarity, ENTITY_FEEDBACK_MAPPER), dropChance, - AmountProvider.fixed(gems), - LootCondition.alwaysTrue() + amountProvider, + condition ) ); return this; } - /** - * Sets maximum number of loot drops allowed per roll. - * - * @param maxDrops maximum drop count - * @return this builder - */ - public EntityLootTableBuilder maxDrops(int maxDrops) { - this.maxDrops = maxDrops; - return this; - } - - /** - * Sets maximum number of loot drops allowed per roll. - * - * @param maxDrops maximum drop count - * @return this builder - */ - public EntityLootTableBuilder setMaxDrops(int maxDrops) { - this.maxDrops = maxDrops; - return this; - } - /** * Constructs configured {@link LootTable} instance. * * @return new entity loot table */ + @NonNull public LootTable build() { return builder .selectionStrategy(new LootingAwareProbabilisticStrategy(maxDrops)) From ad10ebd203cfc259b40244beb2ada7874da2e8ed Mon Sep 17 00:00:00 2001 From: Moussa Date: Fri, 2 Oct 2026 14:15:30 +0200 Subject: [PATCH 05/13] refactor(content/entities): migrate mob definitions to fluent equipment, spawning, and loot builders Update all custom mob content classes to use declarative equipment, spawning, and loot configuration builders, simplifying mob constructors. --- .../blightsworn/BlightswornBogged.java | 27 ++++--- .../blightsworn/BlightswornCreature.java | 2 +- .../blightsworn/BlightswornDrowned.java | 44 ++++++------ .../factions/blightsworn/BlightswornHusk.java | 22 +++--- .../blightsworn/BlightswornParched.java | 25 ++++--- .../blightsworn/BlightswornPiglin.java | 24 +++---- .../blightsworn/BlightswornSkeleton.java | 71 +++---------------- .../blightsworn/BlightswornStray.java | 46 ++++++------ .../BlightswornWitherSkeleton.java | 24 +++---- .../blightsworn/BlightswornZombie.java | 69 +++--------------- .../BlightswornZombifiedPiglin.java | 31 ++++---- .../factions/celestial/CelestialCreature.java | 2 +- .../content/entities/powerful/Endersent.java | 23 +++--- .../content/entities/powerful/Illusioner.java | 22 +++--- .../content/entities/powerful/Watchling.java | 24 +++---- 15 files changed, 168 insertions(+), 288 deletions(-) diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBogged.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBogged.java index 6c858f45..04ff7c2d 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBogged.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornBogged.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; @@ -9,7 +8,6 @@ import org.bukkit.entity.Arrow; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; -import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.PotionMeta; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; @@ -23,19 +21,25 @@ public BlightswornBogged() { super("BLIGHTSWORN_BOGGED", "Blightsworn Bogged", EntityType.BOGGED); setDamage(6); setDroppedExp(12); - setItemInMainHand(new ItemStack(Material.BOW)); - loot(table -> table - .addLoot(Material.BONE, 2, 4, 1.0) - .addLoot(Material.ARROW, 2, 5, 1.0) - .addLoot(Material.TIPPED_ARROW, + equipment(eq -> eq.mainHand(Material.BOW)); + + spawning(spawn -> spawn + .biomes(Biome.SWAMP, Biome.MANGROVE_SWAMP) + .overworldHostile() + ); + + loot(loot -> loot + .drop(Material.BONE, 2, 4, 1.0) + .drop(Material.ARROW, 2, 5, 1.0) + .drop(Material.TIPPED_ARROW, builder -> builder.setItemMeta( meta -> ((PotionMeta) meta).setBasePotionType(PotionType.POISON)), 1, 3, 0.4 ) - .addDamagedItem(Material.BOW, 0.10, 0.75, 0.15, RARE) - .addGems(5, 0.04, VERY_RARE) + .damagedItem(Material.BOW, 0.10, 0.75, 0.15, RARE) + .gems(5, 0.04, VERY_RARE) ); } @@ -53,9 +57,4 @@ protected void onEnrage(LivingEntity entity) { entity.getWorld().spawnParticle(Particle.SNEEZE, location, 50, 0.5, 1.0, 0.5, 0.05); entity.getWorld().spawnParticle(Particle.DUST, location, 30, 0.5, 1.0, 0.5, 0.0, BLIGHT_DUST); } - - @Override - protected void defineSpawnConditions() { - addCondition(SpawnRules.biome(Biome.SWAMP, Biome.MANGROVE_SWAMP).and(SpawnRules.overworldHostile())); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornCreature.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornCreature.java index c3e88f15..34941d79 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornCreature.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornCreature.java @@ -42,7 +42,7 @@ protected BlightswornCreature(String entityId, String name, EntityType entityTyp protected BlightswornCreature(String entityId, String name, EntityType entityType, int maxHealth, double spawnProbability) { super(entityId, name, entityType); setMaxHealth(maxHealth); - setSpawnProbability(spawnProbability); + spawning(spawn -> spawn.probability(spawnProbability)); setupDefaultArmor(); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornDrowned.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornDrowned.java index 8165a3b0..96e57b99 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornDrowned.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornDrowned.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import fr.moussax.bedrock.utils.ItemBuilder; import org.bukkit.GameMode; import org.bukkit.Location; @@ -35,15 +34,28 @@ public BlightswornDrowned() { super("BLIGHTSWORN_DROWNED", "Blightsworn Drowned", EntityType.DROWNED, MAX_HEALTH); setDamage(6); setDroppedExp(10); - loot(table -> table - .addLoot(Material.ROTTEN_FLESH, 2, 5, 1.0) - .addLoot(Material.COPPER_INGOT, 1, 3, 0.4) - .addLoot(Material.NAUTILUS_SHELL, 0.08, RARE) - .addDamagedItem(Material.TRIDENT, 0.05, 0.80, 0.02, VERY_RARE) - .addGems(5, 0.04, VERY_RARE) + loot(loot -> loot + .drop(Material.ROTTEN_FLESH, 2, 5, 1.0) + .drop(Material.COPPER_INGOT, 1, 3, 0.4) + .drop(Material.NAUTILUS_SHELL, 0.08, RARE) + .damagedItem(Material.TRIDENT, 0.05, 0.80, 0.02, VERY_RARE) + .gems(5, 0.04, VERY_RARE) ); - setItemInMainHand(new ItemBuilder(Material.TRIDENT).unbreakable().toItemStack()); + equipment(eq -> eq.mainHand(new ItemBuilder(Material.TRIDENT).unbreakable().toItemStack())); + + spawning(spawn -> spawn + .biomes( + Biome.RIVER, Biome.FROZEN_RIVER, + Biome.OCEAN, Biome.COLD_OCEAN, Biome.FROZEN_OCEAN, + Biome.LUKEWARM_OCEAN, Biome.WARM_OCEAN, Biome.DEEP_OCEAN, + Biome.DEEP_COLD_OCEAN, Biome.DEEP_FROZEN_OCEAN, + Biome.DEEP_LUKEWARM_OCEAN, Biome.DRIPSTONE_CAVES + ) + .maxBlockLight(0) + .maxLightLevel(7) + .inLiquid() + ); } @Override @@ -162,20 +174,4 @@ protected void equipEnragedArmor(EntityEquipment equipment) { new ItemBuilder(Material.IRON_BOOTS).unbreakable().toItemStack() ); } - - @Override - protected void defineSpawnConditions() { - addCondition( - SpawnRules.biome( - Biome.RIVER, Biome.FROZEN_RIVER, - Biome.OCEAN, Biome.COLD_OCEAN, Biome.FROZEN_OCEAN, - Biome.LUKEWARM_OCEAN, Biome.WARM_OCEAN, Biome.DEEP_OCEAN, - Biome.DEEP_COLD_OCEAN, Biome.DEEP_FROZEN_OCEAN, - Biome.DEEP_LUKEWARM_OCEAN, Biome.DRIPSTONE_CAVES - ) - .and(SpawnRules.maxBlockLight(0)) - .and(SpawnRules.maxLightLevel(7)) - .and(SpawnRules.notInLiquid().negate()) - ); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornHusk.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornHusk.java index 46e60376..05017890 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornHusk.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornHusk.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Material; import org.bukkit.block.Biome; import org.bukkit.entity.EntityType; @@ -17,11 +16,17 @@ public BlightswornHusk() { super("BLIGHTSWORN_HUSK", "Blightsworn Husk", EntityType.HUSK); setDamage(6); setDroppedExp(12); - loot(table -> table - .addLoot(Material.ROTTEN_FLESH, 2, 5, 1.0) - .addLoot(Material.SAND, 1, 3, 0.3) - .addLoot(Material.IRON_INGOT, 1, 2, 0.1, RARE) - .addGems(5, 0.04, VERY_RARE) + + spawning(spawn -> spawn + .biomes(Biome.DESERT) + .overworldSurfaceHostile() + ); + + loot(loot -> loot + .drop(Material.ROTTEN_FLESH, 2, 5, 1.0) + .drop(Material.SAND, 1, 3, 0.3) + .drop(Material.IRON_INGOT, 1, 2, 0.1, RARE) + .gems(5, 0.04, VERY_RARE) ); } @@ -36,9 +41,4 @@ public void onDamageDealt(EntityDamageByEntityEvent event) { protected void applySurgeHitEffects(Player player) { player.addPotionEffect(new PotionEffect(PotionEffectType.HUNGER, 80, 1)); } - - @Override - protected void defineSpawnConditions() { - addCondition(SpawnRules.biome(Biome.DESERT).and(SpawnRules.overworldSurfaceHostile())); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornParched.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornParched.java index 33ad65d2..654b3e9f 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornParched.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornParched.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; @@ -9,7 +8,6 @@ import org.bukkit.entity.Arrow; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; -import org.bukkit.inventory.ItemStack; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; @@ -19,14 +17,20 @@ public final class BlightswornParched extends BlightswornArcherArchetype { public BlightswornParched() { super("BLIGHTSWORN_PARCHED", "Blightsworn Parched", EntityType.PARCHED); - setItemInMainHand(new ItemStack(Material.BOW)); setDamage(6); setDroppedExp(12); - loot(table -> table - .addLoot(Material.BONE, 2, 5, 1.0) - .addLoot(Material.ARROW, 2, 5, 1.0) - .addDamagedItem(Material.BOW, 0.10, 0.75, 0.15, RARE) - .addGems(5, 0.04, VERY_RARE) + equipment(eq -> eq.mainHand(Material.BOW)); + + spawning(spawn -> spawn + .biomes(Biome.DESERT) + .overworldSurfaceHostile() + ); + + loot(loot -> loot + .drop(Material.BONE, 2, 5, 1.0) + .drop(Material.ARROW, 2, 5, 1.0) + .damagedItem(Material.BOW, 0.10, 0.75, 0.15, RARE) + .gems(5, 0.04, VERY_RARE) ); } @@ -50,9 +54,4 @@ protected void onEnrage(LivingEntity entity) { entity.getWorld().spawnParticle(Particle.FLAME, location, 30, 0.5, 1.0, 0.5, 0.05); entity.getWorld().spawnParticle(Particle.DUST, location, 30, 0.5, 1.0, 0.5, 0.0, BLIGHT_DUST); } - - @Override - protected void defineSpawnConditions() { - addCondition(SpawnRules.biome(Biome.DESERT).and(SpawnRules.overworldSurfaceHostile())); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornPiglin.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornPiglin.java index a81f0e13..5b0236e5 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornPiglin.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornPiglin.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; @@ -10,7 +9,6 @@ import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Piglin; import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; import org.bukkit.util.Vector; @@ -23,15 +21,20 @@ public final class BlightswornPiglin extends BlightswornEliteArchetype { public BlightswornPiglin() { super("BLIGHTSWORN_PIGLIN", "Blightsworn Piglin", EntityType.PIGLIN); - setItemInMainHand(new ItemStack(Material.GOLDEN_SWORD)); - loot(table -> table - .addLoot(Material.GOLD_NUGGET, 2, 6, 1.0) - .addLoot(Material.GOLD_INGOT, 1, 3, 0.4) - .addDamagedItem(Material.CROSSBOW, 0.10, 0.80, 0.1, RARE) - .addGems(5, 0.04, VERY_RARE) + equipment(eq -> eq.mainHand(Material.GOLDEN_SWORD)); + loot(loot -> loot + .drop(Material.GOLD_NUGGET, 2, 6, 1.0) + .drop(Material.GOLD_INGOT, 1, 3, 0.4) + .damagedItem(Material.CROSSBOW, 0.10, 0.80, 0.1, RARE) + .gems(5, 0.04, VERY_RARE) ); setDamage(8); setDroppedExp(16); + + spawning(spawn -> spawn + .biomes(Biome.NETHER_WASTES, Biome.CRIMSON_FOREST) + .netherHostile() + ); } @Override @@ -97,9 +100,4 @@ private void applyHuntingSnare() { ); }); } - - @Override - protected void defineSpawnConditions() { - addCondition(SpawnRules.biome(Biome.NETHER_WASTES, Biome.CRIMSON_FOREST).and(SpawnRules.netherHostile())); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java index 1c6ddd17..a9b72577 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java @@ -1,15 +1,12 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; import org.bukkit.Sound; -import org.bukkit.block.Biome; import org.bukkit.entity.Arrow; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; -import org.bukkit.inventory.ItemStack; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; @@ -19,14 +16,17 @@ public final class BlightswornSkeleton extends BlightswornArcherArchetype { public BlightswornSkeleton() { super("BLIGHTSWORN_SKELETON", "Blightsworn Skeleton", EntityType.SKELETON); - setItemInMainHand(new ItemStack(Material.BOW)); setDamage(6); setDroppedExp(12); - loot(table -> table - .addLoot(Material.BONE, 2, 5, 1.0) - .addLoot(Material.ARROW, 2, 5, 1.0) - .addDamagedItem(Material.BOW, 0.1, 0.8, 0.15, RARE) - .addGems(5, 0.04, VERY_RARE) + equipment(eq -> eq.mainHand(Material.BOW)); + + spawning(spawn -> spawn.overworldHostile()); + + loot(loot -> loot + .drop(Material.BONE, 2, 5, 1.0) + .drop(Material.ARROW, 2, 5, 1.0) + .damagedItem(Material.BOW, 0.1, 0.8, 0.15, RARE) + .gems(5, 0.04, VERY_RARE) ); } @@ -44,57 +44,4 @@ protected void onEnrage(LivingEntity entity) { entity.getWorld().spawnParticle(Particle.CRIT, location, 30, 0.5, 1.0, 0.5, 0.05); entity.getWorld().spawnParticle(Particle.DUST, location, 30, 0.5, 1.0, 0.5, 0.0, BLIGHT_DUST); } - - @Override - protected void defineSpawnConditions() { - addCondition( - SpawnRules.biome( - Biome.PLAINS, - Biome.SUNFLOWER_PLAINS, - Biome.FOREST, - Biome.FLOWER_FOREST, - Biome.BIRCH_FOREST, - Biome.OLD_GROWTH_BIRCH_FOREST, - Biome.DARK_FOREST, - Biome.TAIGA, - Biome.OLD_GROWTH_SPRUCE_TAIGA, - Biome.SNOWY_TAIGA, - Biome.BAMBOO_JUNGLE, - Biome.JUNGLE, - Biome.SPARSE_JUNGLE, - Biome.WINDSWEPT_HILLS, - Biome.WINDSWEPT_FOREST, - Biome.WINDSWEPT_GRAVELLY_HILLS, - Biome.WINDSWEPT_SAVANNA, - Biome.STONY_PEAKS, - Biome.JAGGED_PEAKS, - Biome.FROZEN_PEAKS, - Biome.SNOWY_SLOPES, - Biome.MEADOW, - Biome.GROVE, - Biome.SAVANNA, - Biome.SAVANNA_PLATEAU, - Biome.SWAMP, - Biome.MANGROVE_SWAMP, - Biome.BEACH, - Biome.SNOWY_BEACH, - Biome.STONY_SHORE, - Biome.RIVER, - Biome.FROZEN_RIVER, - Biome.OCEAN, - Biome.COLD_OCEAN, - Biome.FROZEN_OCEAN, - Biome.LUKEWARM_OCEAN, - Biome.WARM_OCEAN, - Biome.DEEP_OCEAN, - Biome.DEEP_COLD_OCEAN, - Biome.DEEP_FROZEN_OCEAN, - Biome.DEEP_LUKEWARM_OCEAN, - Biome.LUSH_CAVES, - Biome.DRIPSTONE_CAVES, - Biome.SOUL_SAND_VALLEY - ) - .and(SpawnRules.overworldHostile()) - ); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornStray.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornStray.java index 490032ca..027600b9 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornStray.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornStray.java @@ -1,12 +1,10 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Material; import org.bukkit.block.Biome; import org.bukkit.entity.Arrow; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; -import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.PotionMeta; import org.bukkit.potion.PotionType; @@ -15,21 +13,36 @@ public final class BlightswornStray extends BlightswornArcherArchetype { public BlightswornStray() { super("BLIGHTSWORN_STRAY", "Blightsworn Stray", EntityType.STRAY); - setItemInMainHand(new ItemStack(Material.BOW)); setDamage(6); setDroppedExp(12); - loot(table -> table + equipment(eq -> eq.mainHand(Material.BOW)); + + spawning(spawn -> spawn + .biomes( + Biome.SNOWY_PLAINS, + Biome.ICE_SPIKES, + Biome.FROZEN_OCEAN, + Biome.DEEP_FROZEN_OCEAN, + Biome.FROZEN_RIVER, + Biome.SNOWY_SLOPES, + Biome.JAGGED_PEAKS, + Biome.FROZEN_PEAKS + ) + .overworldSurfaceHostile() + ); + + loot(loot -> loot .maxDrops(4) - .addLoot(Material.BONE, 2, 5, 1.0) - .addLoot(Material.ARROW, 2, 5, 1.0) - .addLoot(Material.TIPPED_ARROW, builder -> builder.setItemMeta( + .drop(Material.BONE, 2, 5, 1.0) + .drop(Material.ARROW, 2, 5, 1.0) + .drop(Material.TIPPED_ARROW, builder -> builder.setItemMeta( meta -> ((PotionMeta) meta).setBasePotionType(PotionType.SLOWNESS) ), 1, 3, 0.4 ) - .addGems(5, 0.04, VERY_RARE) + .gems(5, 0.04, VERY_RARE) ); } @@ -40,21 +53,4 @@ protected void applyArrowEffects(Arrow arrow, boolean isPhaseTwo) { @Override protected void onEnrage(LivingEntity entity) { } - - @Override - protected void defineSpawnConditions() { - addCondition( - SpawnRules.biome( - Biome.SNOWY_PLAINS, - Biome.ICE_SPIKES, - Biome.FROZEN_OCEAN, - Biome.DEEP_FROZEN_OCEAN, - Biome.FROZEN_RIVER, - Biome.SNOWY_SLOPES, - Biome.JAGGED_PEAKS, - Biome.FROZEN_PEAKS - ) - .and(SpawnRules.overworldSurfaceHostile()) - ); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornWitherSkeleton.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornWitherSkeleton.java index b1e7341f..37500ace 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornWitherSkeleton.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornWitherSkeleton.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; @@ -10,7 +9,6 @@ import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; import org.bukkit.generator.structure.Structure; -import org.bukkit.inventory.ItemStack; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; import org.bukkit.util.Vector; @@ -23,17 +21,22 @@ public final class BlightswornWitherSkeleton extends BlightswornEliteArchetype { public BlightswornWitherSkeleton() { super("BLIGHTSWORN_WITHER_SKELETON", "Blightsworn Wither Skeleton", EntityType.WITHER_SKELETON); - loot(table -> table + loot(loot -> loot .maxDrops(4) - .addLoot(Material.BONE, 2, 5, 1.0) - .addLoot(Material.COAL, 1, 3, 0.5) - .addLoot(Material.WITHER_SKELETON_SKULL, 0.03, VERY_RARE) - .addGems(5, 0.04, VERY_RARE) + .drop(Material.BONE, 2, 5, 1.0) + .drop(Material.COAL, 1, 3, 0.5) + .drop(Material.WITHER_SKELETON_SKULL, 0.03, VERY_RARE) + .gems(5, 0.04, VERY_RARE) ); setDamage(8); setDroppedExp(20); - setItemInMainHand(new ItemStack(Material.STONE_SWORD)); + equipment(eq -> eq.mainHand(Material.STONE_SWORD)); + + spawning(spawn -> spawn + .insideStructure(Structure.FORTRESS) + .maxBlockLight(0) + ); } @Override @@ -90,9 +93,4 @@ private void spawnWitherResidue() { cloud.setParticle(Particle.SMOKE); cloud.addCustomEffect(new PotionEffect(PotionEffectType.WITHER, 60, 0), true); } - - @Override - protected void defineSpawnConditions() { - addCondition(SpawnRules.insideStructure(Structure.FORTRESS).and(SpawnRules.maxBlockLight(0))); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java index 8694f549..f286eb9c 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java @@ -1,8 +1,6 @@ package fr.moussax.blightedSMP.content.entities.factions.blightsworn; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Material; -import org.bukkit.block.Biome; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; @@ -14,69 +12,20 @@ public BlightswornZombie() { super("BLIGHTSWORN_ZOMBIE", "Blightsworn Zombie", EntityType.ZOMBIE); setDamage(6); setDroppedExp(12); - loot(table -> table + + spawning(spawn -> spawn.overworldHostile()); + + loot(loot -> loot .maxDrops(3) - .addLoot(Material.ROTTEN_FLESH, 1, 2, 1.0) - .addLoot(Material.POTATO, 0.025) - .addLoot(Material.CARROT, 0.025) - .addLoot(Material.IRON_INGOT, 0.02, RARE) - .addGems(2, 0.01, VERY_RARE) + .drop(Material.ROTTEN_FLESH, 1, 2, 1.0) + .drop(Material.POTATO, 0.025) + .drop(Material.CARROT, 0.025) + .drop(Material.IRON_INGOT, 0.02, RARE) + .gems(2, 0.01, VERY_RARE) ); } @Override protected void applySurgeHitEffects(Player player) { } - - @Override - protected void defineSpawnConditions() { - addCondition( - SpawnRules.biome( - Biome.PLAINS, - Biome.SUNFLOWER_PLAINS, - Biome.FOREST, - Biome.FLOWER_FOREST, - Biome.BIRCH_FOREST, - Biome.OLD_GROWTH_BIRCH_FOREST, - Biome.DARK_FOREST, - Biome.TAIGA, - Biome.OLD_GROWTH_SPRUCE_TAIGA, - Biome.SNOWY_TAIGA, - Biome.BAMBOO_JUNGLE, - Biome.JUNGLE, - Biome.SPARSE_JUNGLE, - Biome.WINDSWEPT_HILLS, - Biome.WINDSWEPT_FOREST, - Biome.WINDSWEPT_GRAVELLY_HILLS, - Biome.WINDSWEPT_SAVANNA, - Biome.STONY_PEAKS, - Biome.JAGGED_PEAKS, - Biome.FROZEN_PEAKS, - Biome.SNOWY_SLOPES, - Biome.MEADOW, - Biome.GROVE, - Biome.SAVANNA, - Biome.SAVANNA_PLATEAU, - Biome.SWAMP, - Biome.MANGROVE_SWAMP, - Biome.BEACH, - Biome.SNOWY_BEACH, - Biome.STONY_SHORE, - Biome.RIVER, - Biome.FROZEN_RIVER, - Biome.OCEAN, - Biome.COLD_OCEAN, - Biome.FROZEN_OCEAN, - Biome.LUKEWARM_OCEAN, - Biome.WARM_OCEAN, - Biome.DEEP_OCEAN, - Biome.DEEP_COLD_OCEAN, - Biome.DEEP_FROZEN_OCEAN, - Biome.DEEP_LUKEWARM_OCEAN, - Biome.LUSH_CAVES, - Biome.DRIPSTONE_CAVES - ) - .and(SpawnRules.overworldHostile()) - ); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombifiedPiglin.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombifiedPiglin.java index aaad7983..1e56d3cc 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombifiedPiglin.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombifiedPiglin.java @@ -10,7 +10,6 @@ import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; import org.bukkit.generator.structure.Structure; -import org.bukkit.inventory.ItemStack; import org.bukkit.util.Vector; import static fr.moussax.blightedSMP.engine.loot.decorators.EntityLootRarity.*; @@ -21,15 +20,24 @@ public final class BlightswornZombifiedPiglin extends BlightswornEliteArchetype public BlightswornZombifiedPiglin() { super("BLIGHTSWORN_ZOMBIFIED_PIGLIN", "Blightsworn Zombified Piglin", EntityType.ZOMBIFIED_PIGLIN); - setItemInMainHand(new ItemStack(Material.GOLDEN_SWORD)); setDamage(8); setDroppedExp(16); - loot(table -> table + equipment(eq -> eq.mainHand(Material.GOLDEN_SWORD)); + + spawning(spawn -> spawn + .anyOf( + SpawnRules.biome(Biome.NETHER_WASTES, Biome.CRIMSON_FOREST), + SpawnRules.insideStructure(Structure.FORTRESS) + ) + .netherHostile() + ); + + loot(loot -> loot .maxDrops(3) - .addLoot(Material.ROTTEN_FLESH, 2, 6, 1.0) - .addLoot(Material.GOLD_NUGGET, 2, 6, 1.0) - .addLoot(Material.GOLD_INGOT, 1, 2, 0.15, RARE) - .addGems(5, 0.04, VERY_RARE) + .drop(Material.ROTTEN_FLESH, 2, 6, 1.0) + .drop(Material.GOLD_NUGGET, 2, 6, 1.0) + .drop(Material.GOLD_INGOT, 1, 2, 0.15, RARE) + .gems(5, 0.04, VERY_RARE) ); } @@ -91,13 +99,4 @@ private void executePhaseTwoIgnition() { player.setFireTicks(80); // 4 seconds of fire }); } - - @Override - protected void defineSpawnConditions() { - addCondition( - SpawnRules.biome(Biome.NETHER_WASTES, Biome.CRIMSON_FOREST) - .or(SpawnRules.insideStructure(Structure.FORTRESS)) - .and(SpawnRules.netherHostile()) - ); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/celestial/CelestialCreature.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/celestial/CelestialCreature.java index 62998089..0d607b42 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/celestial/CelestialCreature.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/celestial/CelestialCreature.java @@ -15,7 +15,7 @@ protected CelestialCreature(String entityId, String name, EntityType entityType) protected CelestialCreature(String entityId, String name, int maxHealth, EntityType entityType, double probability) { super(entityId, name, entityType); setMaxHealth(maxHealth); - setSpawnProbability(probability); + spawning(spawn -> spawn.probability(probability)); } @Override diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Endersent.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Endersent.java index 5827d3e1..9d45c09a 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Endersent.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Endersent.java @@ -5,7 +5,6 @@ import fr.moussax.blightedSMP.engine.entities.attachment.AttachmentRole; import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry; import fr.moussax.blightedSMP.engine.entities.spawnable.SpawnableEntity; -import fr.moussax.blightedSMP.engine.entities.spawnable.condition.SpawnRules; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; @@ -34,7 +33,10 @@ public Endersent() { setMaxHealth(200); setDamage(20); setDroppedExp(40); - setSpawnProbability(0.002); + spawning(spawn -> spawn + .probability(0.002) + .biomes(Biome.END_MIDLANDS) + ); attributes(attributes -> attributes .followRange(60) @@ -43,15 +45,15 @@ public Endersent() { .movementSpeed(0.25) ); - loot(table -> table + loot(loot -> loot .maxDrops(2) - .addLoot(Material.ENDER_PEARL, 4, 8, 1.0) - .addLoot(Material.ENDER_EYE, 1, 3, 0.31) - .addLoot("ENCHANTED_ENDER_PEARL", 1, 4, 0.11, RARE) - .addGems(30, 0.03, VERY_RARE) + .drop(Material.ENDER_PEARL, 4, 8, 1.0) + .drop(Material.ENDER_EYE, 1, 3, 0.31) + .drop("ENCHANTED_ENDER_PEARL", 1, 4, 0.11, RARE) + .gems(30, 0.03, VERY_RARE) ); - setBoss(true); + boss(); } @Override @@ -172,9 +174,4 @@ private void endDeadlyEscape() { protected void onConfigureAI(LivingEntity spawned) { EndermanAI.init(spawned); } - - @Override - protected void defineSpawnConditions() { - addCondition(SpawnRules.biome(Biome.END_MIDLANDS)); - } } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Illusioner.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Illusioner.java index aadfcf1c..de79c3a4 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Illusioner.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Illusioner.java @@ -7,23 +7,27 @@ import org.bukkit.enchantments.Enchantment; import org.bukkit.entity.EntityType; +import java.time.Duration; import java.util.List; public class Illusioner extends AncientCreature { public Illusioner() { super("ANCIENT_ILLUSIONER", "Ancient Dummy", EntityType.ILLUSIONER); - setDamage(12); - setTimeAllowance(30); + timeAllowance(Duration.ofSeconds(30)); - loot(table -> table + attributes(attr -> attr + .attackDamage(12) + ); + + loot(loot -> loot .maxDrops(4) - .addLoot(Material.SPECTRAL_ARROW, 4, 12, 0.6) - .addLoot(Material.GLASS_BOTTLE, 1, 2, 0.4) - .addLoot(Material.TOTEM_OF_UNDYING, 0.02, EntityLootRarity.VERY_RARE) - .addLoot(Material.OMINOUS_BOTTLE, 0.15) - .addGems(12, 0.25) - .addEnchantedBook( + .drop(Material.SPECTRAL_ARROW, 4, 12, 0.6) + .drop(Material.GLASS_BOTTLE, 1, 2, 0.4) + .drop(Material.TOTEM_OF_UNDYING, 0.02, EntityLootRarity.VERY_RARE) + .drop(Material.OMINOUS_BOTTLE, 0.15) + .gems(12, 0.25) + .enchantedBook( List.of(Enchantment.QUICK_CHARGE, Enchantment.PIERCING, Enchantment.POWER), 1, 7, 0.07, EntityLootRarity.RARE ) diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Watchling.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Watchling.java index 9e09b1a1..e5aab4c4 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Watchling.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/powerful/Watchling.java @@ -27,7 +27,13 @@ public Watchling() { setMaxHealth(20); setDamage(10); setDroppedExp(10); - setSpawnProbability(0.001); + spawning(spawn -> spawn + .probability(0.001) + .anyOf( + SpawnRules.biome(Biome.END_BARRENS, Biome.END_MIDLANDS), + SpawnRules.insideStructure(Structure.END_CITY) + ) + ); attributes(attributes -> attributes .scale(0.7) @@ -35,10 +41,10 @@ public Watchling() { .followRange(50) ); - loot(table -> table - .maxDrops(2) - .addLoot(Material.ENDER_PEARL, 1, 2, 1.0) - .addGems(5, 0.03, VERY_RARE) + loot(loot -> loot + .maxDrops(2) + .drop(Material.ENDER_PEARL, 1, 2, 1.0) + .gems(5, 0.03, VERY_RARE) ); } @@ -107,12 +113,4 @@ private void executeEscapeTeleport() { protected void onConfigureAI(LivingEntity spawned) { EndermanAI.init(spawned); } - - @Override - protected void defineSpawnConditions() { - addCondition(SpawnRules - .biome(Biome.END_BARRENS, Biome.END_MIDLANDS) - .or(SpawnRules.insideStructure(Structure.END_CITY)) - ); - } } From 5caee328a6ec7928912dd6b006f9fc498900e660 Mon Sep 17 00:00:00 2001 From: Moussa Date: Fri, 2 Oct 2026 14:25:59 +0200 Subject: [PATCH 06/13] fix(entities): respect damage cancellation and prevent premature attachment removal Exit early in handleBlightedEntityDamage if components cancel the damage event, and remove premature killAllAttachments invocation so attachments like AncientCreature holograms persist until actual death. --- .../entities/listeners/BlightedEntitiesListener.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/BlightedEntitiesListener.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/BlightedEntitiesListener.java index 8227d142..571fb10c 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/BlightedEntitiesListener.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/listeners/BlightedEntitiesListener.java @@ -159,8 +159,12 @@ private void handleBlightedEntityDamage(BlightedEntity blighted, LivingEntity en for (var component : blighted.getComponents()) { component.onDamageTaken(blighted, event); } - double remainingHealth = entity.getHealth() - event.getFinalDamage(); + if (event.isCancelled()) { + return; + } + + double remainingHealth = entity.getHealth() - event.getFinalDamage(); if (remainingHealth > 0) { Bukkit.getScheduler().runTaskLater(BlightedSMP.getInstance(), () -> { @@ -169,9 +173,7 @@ private void handleBlightedEntityDamage(BlightedEntity blighted, LivingEntity en blighted.evaluatePhases(entity.getHealth()); } }, 1L); - return; } - blighted.killAllAttachments(); } private void flashHurtAndCancelKnockback(BlightedEntity owner, Entity hitEntity) { From 2f8002bdee16ce1b415349aca63bae2b5f3de8d1 Mon Sep 17 00:00:00 2001 From: Moussa Date: Fri, 2 Oct 2026 14:26:15 +0200 Subject: [PATCH 07/13] fix(abilities): track hit entities per flight phase in BonemerangAbility Prevent the bonemerang projectile from dealing damage on every tick by tracking damaged entities during outbound and return flight phases. --- .../items/abilities/BonemerangAbility.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/abilities/BonemerangAbility.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/abilities/BonemerangAbility.java index e52f6b64..5ee662d1 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/abilities/BonemerangAbility.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/abilities/BonemerangAbility.java @@ -20,7 +20,10 @@ import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; +import java.util.HashSet; import java.util.Map; +import java.util.Set; +import java.util.UUID; public class BonemerangAbility implements AbilityManager { @@ -64,11 +67,6 @@ public boolean triggerAbility(PlayerInteractEvent event) { return true; } - @Override - public int getCooldownSeconds() { - return 2; - } - private void reduceStackInHand(Player player, ItemStack hand) { if (hand.getAmount() <= 1) { player.getInventory().setItemInMainHand(null); @@ -99,6 +97,7 @@ private void launchProjectile(Player player, ItemStack toRestore) { Vector direction = player.getLocation().getDirection().normalize(); String playerName = player.getName(); + Set hitEntities = new HashSet<>(); new BukkitRunnable() { int tick = 0; @@ -123,11 +122,12 @@ public void run() { if (tick == OUTBOUND_TICKS) { returning = true; + hitEntities.clear(); } updateProjectilePosition(projectile, player, direction, returning); updateProjectileRotation(projectile, tick); - damageNearbyEntities(projectile, player); + damageNearbyEntities(projectile, player, hitEntities); spawnParticles(projectile, returning); if (tick >= OUTBOUND_TICKS + RETURN_TICKS) { @@ -172,9 +172,9 @@ private void updateProjectileRotation(ArmorStand projectile, int tick) { )); } - private void damageNearbyEntities(ArmorStand projectile, Player player) { + private void damageNearbyEntities(ArmorStand projectile, Player player, Set hitEntities) { for (Entity entity : projectile.getNearbyEntities(COLLISION_RADIUS, 1, COLLISION_RADIUS)) { - if (entity != player && entity instanceof LivingEntity damageable) { + if (entity != player && entity instanceof LivingEntity damageable && hitEntities.add(entity.getUniqueId())) { damageable.damage(DAMAGE_AMOUNT, player); } } @@ -219,4 +219,4 @@ private boolean replaceNuggetWithOriginal(Inventory inventory, ItemStack origina } return false; } -} \ No newline at end of file +} From 2241904ee330935726dec10b4450a8d24ee0769c Mon Sep 17 00:00:00 2001 From: Moussa Date: Fri, 2 Oct 2026 15:07:54 +0200 Subject: [PATCH 08/13] fix(entities): return read-only definitions from EntitiesRegistry queries --- .../entities/registry/EntitiesRegistry.java | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/EntitiesRegistry.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/EntitiesRegistry.java index 53703f74..2a07648b 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/EntitiesRegistry.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/registry/EntitiesRegistry.java @@ -151,33 +151,25 @@ public static BlightedEntity getPrototype(@Nullable String entityId) { } /** - * Returns a list of fresh instances of all registered entities. + * Returns an unmodifiable list of all registered entity definitions. * - * @return list of fresh entity instances + * @return unmodifiable list of registered entity definitions */ @NonNull public static List getAll() { - return REGISTRY.getAll().stream() - .map(entity -> { - BlightedEntity fresh = create(entity.getEntityId()); - return fresh != null ? fresh : entity; - }) - .toList(); + return List.copyOf(REGISTRY.getAll()); } /** - * Returns a list of fresh instances of all registered spawnable entities. + * Returns an unmodifiable list of all registered spawnable entity definitions. * - * @return list of fresh spawnable entity instances + * @return unmodifiable list of registered spawnable entity definitions */ @NonNull public static List getSpawnables() { return REGISTRY.getAll().stream() .filter(SpawnableEntity.class::isInstance) - .map(entity -> { - SpawnableEntity fresh = createSpawnable(entity.getEntityId()); - return fresh != null ? fresh : (SpawnableEntity) entity; - }) + .map(SpawnableEntity.class::cast) .toList(); } From 9458fadb4b52be4da9e1ed2c0c4fd357799fac61 Mon Sep 17 00:00:00 2001 From: Moussa Date: Fri, 2 Oct 2026 15:07:56 +0200 Subject: [PATCH 09/13] fix(entities): preserve modifiers in setAttribute and return base value --- .../entities/state/EntityAttributeManager.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributeManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributeManager.java index e393f23a..f60df4cd 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributeManager.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributeManager.java @@ -29,8 +29,8 @@ private EntityAttributeManager(@NonNull Map attributes) { } /** - * Sets an attribute base value. If the entity is currently active, strips all - * existing modifiers and updates the live Bukkit AttributeInstance immediately. + * Sets an attribute base value. If the entity is currently active, + * updates the live Bukkit AttributeInstance immediately. * * @param attribute target attribute * @param value base attribute value @@ -39,8 +39,11 @@ private EntityAttributeManager(@NonNull Map attributes) { public void setAttribute(@NonNull Attribute attribute, double value, @Nullable LivingEntity liveEntity) { Objects.requireNonNull(attribute, "attribute cannot be null"); attributes.put(attribute, value); - if (liveEntity != null) { - applyAttributeToLiveEntity(attribute, value, liveEntity); + if (liveEntity != null && liveEntity.isValid() && !liveEntity.isDead()) { + AttributeInstance instance = liveEntity.getAttribute(attribute); + if (instance != null) { + instance.setBaseValue(value); + } } } @@ -57,7 +60,7 @@ public double getAttributeValue(@NonNull Attribute attribute, @Nullable LivingEn if (liveEntity != null && liveEntity.isValid() && !liveEntity.isDead()) { AttributeInstance instance = liveEntity.getAttribute(attribute); if (instance != null) { - return instance.getValue(); + return instance.getBaseValue(); } } return attributes.getOrDefault(attribute, 0.0); From 807f7736c21e5897176a224ea0f6da4771abab77 Mon Sep 17 00:00:00 2001 From: Moussa Date: Fri, 2 Oct 2026 15:08:01 +0200 Subject: [PATCH 10/13] fix(spawning): restrict BlightswornSkeleton and BlightswornZombie to Overworld --- .../blightsworn/BlightswornSkeleton.java | 5 +++- .../blightsworn/BlightswornZombie.java | 5 +++- .../entities/spawnable/SpawningBuilder.java | 27 +++++++++++++++++++ .../entities/spawnable/SpawningRuleTest.java | 13 +++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java index a9b72577..cd91233f 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornSkeleton.java @@ -20,7 +20,10 @@ public BlightswornSkeleton() { setDroppedExp(12); equipment(eq -> eq.mainHand(Material.BOW)); - spawning(spawn -> spawn.overworldHostile()); + spawning(spawn -> spawn + .overworld() + .overworldHostile() + ); loot(loot -> loot .drop(Material.BONE, 2, 5, 1.0) diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java index f286eb9c..078e8cbe 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/factions/blightsworn/BlightswornZombie.java @@ -13,7 +13,10 @@ public BlightswornZombie() { setDamage(6); setDroppedExp(12); - spawning(spawn -> spawn.overworldHostile()); + spawning(spawn -> spawn + .overworld() + .overworldHostile() + ); loot(loot -> loot .maxDrops(3) diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningBuilder.java index f4379242..bee32c1b 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningBuilder.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningBuilder.java @@ -152,6 +152,33 @@ public SpawningBuilder environment(@NonNull Environment environment) { return condition(SpawnRules.environment(environment)); } + /** + * Adds an Overworld environment requirement. + * + * @return this builder + */ + public SpawningBuilder overworld() { + return condition(SpawnRules.overworld()); + } + + /** + * Adds a Nether environment requirement. + * + * @return this builder + */ + public SpawningBuilder nether() { + return condition(SpawnRules.nether()); + } + + /** + * Adds The End environment requirement. + * + * @return this builder + */ + public SpawningBuilder theEnd() { + return condition(SpawnRules.theEnd()); + } + /** * Adds standard Overworld hostile monster conditions (darkness, non-liquid). * diff --git a/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningRuleTest.java b/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningRuleTest.java index 9c87bc13..11712215 100644 --- a/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningRuleTest.java +++ b/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningRuleTest.java @@ -125,4 +125,17 @@ void testAffixesConfiguration() { assertThrows(IllegalArgumentException.class, () -> builder.affixes(-0.1)); assertThrows(IllegalArgumentException.class, () -> builder.affixes(1.1)); } + + @Test + @DisplayName("SpawningBuilder should configure environment conditions") + void testEnvironmentConditions() { + SpawningBuilder builder = new SpawningBuilder(); + builder.overworld() + .nether() + .theEnd() + .environment(org.bukkit.World.Environment.CUSTOM); + + assertEquals(4, builder.getConditions().size()); + assertEquals(4, builder.buildProfile().conditions().size()); + } } From 5cce2a98971ccc5d3a9f1f988574b50977a39c31 Mon Sep 17 00:00:00 2001 From: Moussa Date: Fri, 2 Oct 2026 15:08:04 +0200 Subject: [PATCH 11/13] fix(phases): defer phase task scheduling during transition callbacks --- .../engine/entities/phases/EntityPhaseManager.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhaseManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhaseManager.java index fb4b0ab2..bc84db6b 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhaseManager.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/phases/EntityPhaseManager.java @@ -17,6 +17,7 @@ public final class EntityPhaseManager { private final NavigableMap phaseThresholds; private LifecycleTaskManager coreTasks = new LifecycleTaskManager(); private LifecycleTaskManager phaseTasks = new LifecycleTaskManager(); + private boolean isPhaseCallbackRunning; public EntityPhaseManager(@NonNull BlightedEntity owner, @NonNull DoubleToLongFunction transitionHandler) { this(owner, transitionHandler, new TreeMap<>(Collections.reverseOrder())); @@ -63,7 +64,12 @@ public void evaluatePhases(double currentHealth) { phaseTasks.cancelAll(); phaseTasks = new LifecycleTaskManager(); - entry.getValue().run(); + isPhaseCallbackRunning = true; + try { + entry.getValue().run(); + } finally { + isPhaseCallbackRunning = false; + } long transitionDuration = transitionHandler.applyAsLong(entry.getKey()); if (transitionDuration > 0) { @@ -154,7 +160,7 @@ private void scheduleAbility(LifecycleTaskManager manager, long delayTicks, long Log.warn("EntityPhaseManager", "Ability threw an exception on entity '" + owner.getName() + "': " + exception.getMessage()); } }, delayTicks, periodTicks); - if (canScheduleTask()) { + if (canScheduleTask() && (!isPhaseCallbackRunning || manager != phaseTasks)) { manager.scheduleLast(); } } @@ -170,7 +176,7 @@ private void scheduleDelayedAction(LifecycleTaskManager manager, long delayTicks Log.warn("EntityPhaseManager", "Delayed action threw an exception on entity '" + owner.getName() + "': " + exception.getMessage()); } }, delayTicks); - if (canScheduleTask()) { + if (canScheduleTask() && (!isPhaseCallbackRunning || manager != phaseTasks)) { manager.scheduleLast(); } } From 70c243a3aeac96573425fa2b93a3be562e0ae641 Mon Sep 17 00:00:00 2001 From: Moussa Date: Fri, 2 Oct 2026 15:08:09 +0200 Subject: [PATCH 12/13] fix(entities): accurately track cooldown acceptance in checkAndTrigger --- .../entities/state/EntityCooldowns.java | 15 +++- .../entities/state/EntityCooldownsTest.java | 82 +++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldownsTest.java diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldowns.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldowns.java index 3ea456f8..a8e2d908 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldowns.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldowns.java @@ -5,6 +5,7 @@ import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.LongSupplier; /** @@ -60,10 +61,18 @@ public void trigger(@NonNull String abilityKey) { * @return {@code true} if ready and triggered, {@code false} otherwise */ public boolean checkAndTrigger(@NonNull String abilityKey, long cooldownMillis) { + Objects.requireNonNull(abilityKey, "abilityKey cannot be null"); long now = timeSupplier.getAsLong(); - return cooldowns.compute(abilityKey, (_, last) -> - (last == null || (now - last) >= cooldownMillis) ? now : last - ) == now; + AtomicBoolean triggered = new AtomicBoolean(false); + cooldowns.compute(abilityKey, (_, last) -> { + if (last == null || (now - last) >= cooldownMillis) { + triggered.set(true); + return now; + } + triggered.set(false); + return last; + }); + return triggered.get(); } /** diff --git a/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldownsTest.java b/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldownsTest.java new file mode 100644 index 00000000..b0f94b81 --- /dev/null +++ b/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldownsTest.java @@ -0,0 +1,82 @@ +package fr.moussax.blightedSMP.engine.entities.state; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; + +class EntityCooldownsTest { + + @Test + @DisplayName("checkAndTrigger should reject subsequent calls within the same millisecond") + void testCheckAndTriggerSameMillisecond() { + AtomicLong currentTime = new AtomicLong(1000L); + EntityCooldowns cooldowns = new EntityCooldowns(currentTime::get); + + assertTrue(cooldowns.checkAndTrigger("slam", 500L), "Initial trigger should be accepted"); + assertFalse(cooldowns.checkAndTrigger("slam", 500L), "Same millisecond trigger must be rejected"); + + currentTime.set(1200L); + assertFalse(cooldowns.checkAndTrigger("slam", 500L), "Trigger before cooldown expires must be rejected"); + + currentTime.set(1500L); + assertTrue(cooldowns.checkAndTrigger("slam", 500L), "Trigger after cooldown expires must be accepted"); + assertFalse(cooldowns.checkAndTrigger("slam", 500L), "Immediate second trigger must be rejected"); + } + + @Test + @DisplayName("isReady and trigger should manage timestamps accurately") + void testIsReadyAndTrigger() { + AtomicLong currentTime = new AtomicLong(1000L); + EntityCooldowns cooldowns = new EntityCooldowns(currentTime::get); + + assertTrue(cooldowns.isReady("dash", 300L)); + assertEquals(0L, cooldowns.getRemainingMillis("dash", 300L)); + + cooldowns.trigger("dash"); + assertFalse(cooldowns.isReady("dash", 300L)); + assertEquals(300L, cooldowns.getRemainingMillis("dash", 300L)); + + currentTime.addAndGet(100L); + assertFalse(cooldowns.isReady("dash", 300L)); + assertEquals(200L, cooldowns.getRemainingMillis("dash", 300L)); + + currentTime.addAndGet(200L); + assertTrue(cooldowns.isReady("dash", 300L)); + assertEquals(0L, cooldowns.getRemainingMillis("dash", 300L)); + } + + @Test + @DisplayName("reset and resetAll should clear cooldowns") + void testReset() { + AtomicLong currentTime = new AtomicLong(1000L); + EntityCooldowns cooldowns = new EntityCooldowns(currentTime::get); + + cooldowns.trigger("abilityA"); + cooldowns.trigger("abilityB"); + + assertFalse(cooldowns.isReady("abilityA", 1000L)); + assertFalse(cooldowns.isReady("abilityB", 1000L)); + + cooldowns.reset("abilityA"); + assertTrue(cooldowns.isReady("abilityA", 1000L)); + assertFalse(cooldowns.isReady("abilityB", 1000L)); + + cooldowns.resetAll(); + assertTrue(cooldowns.isReady("abilityB", 1000L)); + } + + @Test + @DisplayName("Null checks throw NullPointerException") + void testNullChecks() { + EntityCooldowns cooldowns = new EntityCooldowns(); + + assertThrows(NullPointerException.class, () -> cooldowns.checkAndTrigger(null, 100L)); + assertThrows(NullPointerException.class, () -> cooldowns.isReady(null, 100L)); + assertThrows(NullPointerException.class, () -> cooldowns.trigger(null)); + assertThrows(NullPointerException.class, () -> cooldowns.reset(null)); + assertThrows(NullPointerException.class, () -> cooldowns.getRemainingMillis(null, 100L)); + } +} From 895e5f76ff18566d2e32662b04fb6945d5025fa8 Mon Sep 17 00:00:00 2001 From: Moussa Date: Fri, 2 Oct 2026 15:08:13 +0200 Subject: [PATCH 13/13] build: pin maven-surefire-plugin to 3.5.4 with JUnit 5 support --- blighted-smp/pom.xml | 4 ++++ pom.xml | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/blighted-smp/pom.xml b/blighted-smp/pom.xml index 849d001d..082bc8a1 100644 --- a/blighted-smp/pom.xml +++ b/blighted-smp/pom.xml @@ -65,6 +65,10 @@ + + org.apache.maven.plugins + maven-surefire-plugin + diff --git a/pom.xml b/pom.xml index c5c245ea..e47de564 100644 --- a/pom.xml +++ b/pom.xml @@ -101,6 +101,11 @@ maven-shade-plugin 3.5.3 + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 +