entry = phaseThresholds.pollFirstEntry();
+ if (entry == null) {
+ break;
+ }
+
+ phaseTasks.cancelAll();
+ phaseTasks = new LifecycleTaskManager();
+ isPhaseCallbackRunning = true;
+ try {
+ entry.getValue().run();
+ } finally {
+ isPhaseCallbackRunning = false;
+ }
+ 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() && (!isPhaseCallbackRunning || manager != phaseTasks)) {
+ 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() && (!isPhaseCallbackRunning || manager != phaseTasks)) {
+ 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..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
@@ -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,174 @@ 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 extends BlightedEntity> 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 an unmodifiable list of all registered entity definitions.
*
- * @return list of cloned entity prototypes
+ * @return unmodifiable list of registered entity definitions
*/
+ @NonNull
public static List getAll() {
- return REGISTRY.getAll().stream().map(BlightedEntity::clone).toList();
+ return List.copyOf(REGISTRY.getAll());
}
/**
- * Returns a list of cloned instances of all registered spawnable entity prototypes.
+ * Returns an unmodifiable list of all registered spawnable entity definitions.
*
- * @return list of cloned spawnable entity prototypes
+ * @return unmodifiable list of registered spawnable entity definitions
*/
+ @NonNull
public static List getSpawnables() {
return REGISTRY.getAll().stream()
.filter(SpawnableEntity.class::isInstance)
- .map(entity -> ((SpawnableEntity) entity).clone())
+ .map(SpawnableEntity.class::cast)
.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 extends BlightedEntity> 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 extends BlightedEntity> 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..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
@@ -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;
@@ -20,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}.
*
@@ -34,14 +39,19 @@ 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
- @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,114 +63,89 @@ 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.
+ * 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 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);
+ public AncientCreature(@NonNull String entityId, @NonNull String name, @NonNull EntityType entityType) {
+ super(entityId, name, entityType);
+ boss();
}
/**
- * Creates an ancient creature using the default time allowance.
+ * Sets the countdown time allowance to defeat this ancient creature.
*
- * @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 duration time allowance
+ * @return this creature
*/
- public AncientCreature(@NonNull String name, int maxHealth, int damage, int defense, EntityType entityType) {
- this(name, maxHealth, damage, defense, entityType, DEFAULT_TIME_ALLOWANCE_SECONDS);
- }
-
- /**
- * Creates an ancient creature using the specified time allowance.
- *
- * @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
- */
- public AncientCreature(@NonNull String name, int maxHealth, EntityType entityType, int timeAllowanceSeconds) {
- this(name, maxHealth, 1, 0, entityType, 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;
}
/**
- * Creates an ancient creature using the specified damage and time allowance.
+ * Sets the countdown time allowance in seconds to defeat this ancient creature.
*
- * @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
+ * @param seconds time allowance in seconds
+ * @return this creature
*/
- public AncientCreature(@NonNull String name, int maxHealth, int damage, EntityType entityType, int timeAllowanceSeconds) {
- this(name, maxHealth, damage, 0, entityType, timeAllowanceSeconds);
+ public AncientCreature timeAllowance(int seconds) {
+ if (seconds <= 0) {
+ throw new IllegalArgumentException("timeAllowance must be positive, got: " + seconds);
+ }
+ return timeAllowance(Duration.ofSeconds(seconds));
}
/**
- * Creates an ancient creature using the specified combat stats and time allowance.
+ * Sets the player who summoned this creature.
*
- * @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
+ * @param player summoning player, or {@code null} for unknown
+ * @return this creature
*/
- public AncientCreature(@NonNull String name, int maxHealth, int damage, int defense, EntityType entityType, int timeAllowanceSeconds) {
- super(name, maxHealth, damage, defense, entityType);
- this.timeAllowance = timeAllowanceSeconds;
- this.remainingSeconds = timeAllowanceSeconds;
- setBoss(true);
+ public AncientCreature summoner(@Nullable Player player) {
+ this.summonerName = (player != null) ? player.getName() : "Unknown";
+ updateHologramText();
+ return this;
}
/**
- * Sets the player who summoned this creature.
+ * Sets the summoner name directly.
*
- * 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 player summoning player, or {@code null}
+ * @param summonerName summoner name
+ * @return this creature
*/
- public void setSummoner(@Nullable Player player) {
- this.summonerName = (player != null) ? player.getName() : "Unknown";
+ 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.
- *
- * The attached hologram is updated immediately after the ability name
- * changes.
+ * 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);
}
/**
@@ -192,10 +177,10 @@ 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)) {
+ if (persistentDataContainer.has(getHologramKey(), PersistentDataType.BYTE)) {
this.hologram = display;
break;
}
@@ -231,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());
@@ -294,4 +279,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..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
@@ -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,8 +313,8 @@ 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();
- creature.setSummoner(player);
+ AncientCreature creature = ritual.getSummonedCreature().createInstance();
+ creature.summoner(player);
creature.spawn(location);
}
}
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 d9fd0ae2..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
@@ -4,7 +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.spawnable.condition.SpawnCondition;
+import fr.moussax.blightedSMP.engine.entities.registry.EntitiesRegistry;
import fr.moussax.blightedSMP.engine.entities.spawnable.engine.SpawnMode;
import lombok.Getter;
import org.bukkit.Location;
@@ -14,31 +14,39 @@
import org.bukkit.entity.EntityType;
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;
- @Getter
- private final double spawnProbability;
+ 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 final SpawnMode spawnMode;
+ private double spawnProbability = DEFAULT_SPAWN_PROBABILITY;
- private SpawnProfile spawnProfile;
+ @Getter
+ private SpawnMode spawnMode = DEFAULT_SPAWN_MODE;
@Getter
private double affixChance = 0.0;
@@ -46,118 +54,78 @@ public abstract class SpawnableEntity extends BlightedEntity {
@Getter
private int maxAffixes = 1;
- /**
- * Sets the chance for this entity to spawn with random elite affixes.
- *
- * @param affixChance probability in the range {@code [0.0, 1.0]}
- * @throws IllegalArgumentException if {@code affixChance} is outside {@code [0.0, 1.0]}
- */
- public void setAffixChance(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;
- }
+ private boolean eliteAuraStarted = false;
+
+ @Getter
+ private SpawnProfile spawnProfile = new SpawnProfile();
/**
- * Sets the maximum number of elite affixes this entity can roll when spawned.
+ * Constructs a spawnable entity with identity requirements.
+ * The default spawn probability is 0.01 in REPLACEMENT mode.
*
- * @param maxAffixes maximum affix count
+ * @param entityId unique entity identifier
+ * @param name display name
+ * @param entityType underlying a Minecraft entity type
*/
- public void setMaxAffixes(int maxAffixes) {
- this.maxAffixes = Math.max(1, maxAffixes);
+ protected SpawnableEntity(@NonNull String entityId, @NonNull String name, @NonNull EntityType entityType) {
+ super(entityId, name, entityType);
}
/**
- * Constructs a spawnable entity with default damage and defense attributes in replacement mode.
+ * Configures spawning rules, probability, mode, and affixes using a fluent builder consumer.
*
- * @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 consumer action configuring the spawning builder
*/
- protected SpawnableEntity(String entityId, String name, int maxHealth, EntityType entityType, double probability) {
- this(entityId, name, maxHealth, 1, 0, entityType, probability, SpawnMode.REPLACEMENT);
+ 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();
}
/**
- * Constructs a spawnable entity with default damage and defense attributes in the specified spawn mode.
+ * Sets elite affix rolling chance.
*
- * @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
+ * @param affixChance probability in [0.0, 1.0]
+ * @return this entity
*/
- protected SpawnableEntity(
- String entityId, String name, int maxHealth, EntityType entityType, double probability, SpawnMode mode) {
- this(entityId, name, maxHealth, 1, 0, entityType, probability, mode);
+ 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;
}
/**
- * Constructs a spawnable entity with custom attack damage and zero defense in the specified spawn mode.
+ * Sets maximum number of rollable elite affixes.
*
- * @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
+ * @param maxAffixes maximum affixes
+ * @return this entity
*/
- 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 SpawnableEntity maxAffixes(int maxAffixes) {
+ this.maxAffixes = Math.max(1, maxAffixes);
+ return this;
}
/**
- * Constructs a spawnable entity with full attribute specifications and spawn profile initialization.
+ * Evaluates whether this entity can spawn at the specified location and world.
*
- * @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 location location to evaluate
+ * @param world world to evaluate
+ * @return {@code true} if all spawn profile conditions are satisfied, {@code false} otherwise
*/
- 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();
+ 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);
@@ -172,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) {
@@ -202,8 +165,6 @@ protected void onRehydrate(LivingEntity existing) {
}
}
- private boolean eliteAuraStarted = false;
-
private void applyAffix(EntityComponent affix) {
addComponent(affix);
if (!eliteAuraStarted) {
@@ -233,40 +194,13 @@ private void startEliteAura() {
});
}
- /**
- * Hook method implemented by subclasses to register environment spawn conditions.
- */
- protected abstract void defineSpawnConditions();
-
- /**
- * Adds a spawn condition rule to this entity's spawn profile.
- *
- * @param condition spawn condition rule to add
- */
- protected void addCondition(SpawnCondition condition) {
- 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) {
- 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/SpawningBuilder.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningBuilder.java
new file mode 100644
index 00000000..bee32c1b
--- /dev/null
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningBuilder.java
@@ -0,0 +1,265 @@
+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 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).
+ *
+ * @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/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/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/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..f60df4cd
--- /dev/null
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityAttributeManager.java
@@ -0,0 +1,198 @@
+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,
+ * 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 && liveEntity.isValid() && !liveEntity.isDead()) {
+ AttributeInstance instance = liveEntity.getAttribute(attribute);
+ if (instance != null) {
+ instance.setBaseValue(value);
+ }
+ }
+ }
+
+ /**
+ * 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.getBaseValue();
+ }
+ }
+ 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..a8e2d908
--- /dev/null
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/state/EntityCooldowns.java
@@ -0,0 +1,109 @@
+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.concurrent.atomic.AtomicBoolean;
+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) {
+ Objects.requireNonNull(abilityKey, "abilityKey cannot be null");
+ long now = timeSupplier.getAsLong();
+ 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();
+ }
+
+ /**
+ * 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++;
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..11712215
--- /dev/null
+++ b/blighted-smp/src/test/java/fr/moussax/blightedSMP/engine/entities/spawnable/SpawningRuleTest.java
@@ -0,0 +1,141 @@
+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));
+ }
+
+ @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());
+ }
+}
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));
+ }
+}
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
+