diff --git a/build.gradle.kts b/build.gradle.kts
index 26f9c88..c37cdc8 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -27,6 +27,8 @@ dependencies {
testImplementation("com.hypixel.hytale:Server:$hytaleServerVersion")
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+ testImplementation("org.mockito:mockito-core:5.14.2")
+ testImplementation("org.assertj:assertj-core:3.26.3")
}
java {
@@ -91,5 +93,7 @@ tasks {
test {
useJUnitPlatform()
+ // Byte Buddy (Mockito) ne supporte pas encore Java 25 officiellement — flag experimental requis.
+ jvmArgs("-Dnet.bytebuddy.experimental=true")
}
}
diff --git a/src/main/java/com/mythlane/chainlightning/chain/Vec3.java b/src/main/java/com/mythlane/chainlightning/chain/Vec3.java
index 28417f4..1ebcacd 100644
--- a/src/main/java/com/mythlane/chainlightning/chain/Vec3.java
+++ b/src/main/java/com/mythlane/chainlightning/chain/Vec3.java
@@ -7,6 +7,9 @@ package com.mythlane.chainlightning.chain;
*/
public record Vec3(double x, double y, double z) {
+ /** Origine (0, 0, 0) — utilisée comme placeholder par HytalePlayerRayCaster. */
+ public static final Vec3 ZERO = new Vec3(0.0, 0.0, 0.0);
+
/** Distance euclidienne au carré. Préférer cette méthode dans les boucles. */
public double distanceSquared(Vec3 other) {
double dx = this.x - other.x;
diff --git a/src/main/java/com/mythlane/chainlightning/sceptre/ChainDamageApplier.java b/src/main/java/com/mythlane/chainlightning/sceptre/ChainDamageApplier.java
new file mode 100644
index 0000000..0188d22
--- /dev/null
+++ b/src/main/java/com/mythlane/chainlightning/sceptre/ChainDamageApplier.java
@@ -0,0 +1,101 @@
+package com.mythlane.chainlightning.sceptre;
+
+import com.hypixel.hytale.component.CommandBuffer;
+import com.hypixel.hytale.component.ComponentAccessor;
+import com.hypixel.hytale.component.Ref;
+import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
+// DamageCause import supprime : on utilise l'index int pour eviter la dependance au runtime Hytale
+// import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause;
+import com.hypixel.hytale.server.core.modules.entity.damage.DamageSystems;
+import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+import com.mythlane.chainlightning.chain.ChainHit;
+
+import javax.annotation.Nonnull;
+import java.util.List;
+
+/**
+ * Helper static qui applique {@link DamageSystems#executeDamage} pour chaque hit d'une chaine resolue.
+ *
+ *
Utilise {@link DamageCause#PHYSICAL} (deprecie mais fonctionnel -- RESEARCH Q5). Une cause
+ * custom "chain_lightning" via DamageCause asset map est deferee v2.
+ *
+ *
Cast explicite {@code ChainHit.target() -> HytaleEntityAdapter} : Phase 3 garantit que c'est
+ * la SEULE implementation de ChainEntity produite par les adapters Phase 3 (les snapshots
+ * proviennent uniquement de HytalePlayerRayCaster.firstHit + HytaleEntitySource.nearby).
+ *
+ *
Testabilite : l'overload a {@link DamageExecutor} permet d'injecter un stub en test
+ * sans avoir a initialiser le runtime Hytale (DamageSystems possede un initialiseur statique
+ * dependant de PluginBase/HytaleLogger).
+ */
+public final class ChainDamageApplier {
+
+ /**
+ * SAM injectable pour l'application des degats -- permet de stubber DamageSystems en test.
+ */
+ @FunctionalInterface
+ public interface DamageExecutor {
+ void execute(@Nonnull Ref target,
+ @Nonnull CommandBuffer commandBuffer,
+ @Nonnull Damage damage);
+ }
+
+ /**
+ * Executeur par defaut delegant a DamageSystems.executeDamage (utilise en production).
+ * Charge-holder pattern : DamageSystems n'est initialise que quand DEFAULT est acces,
+ * ce qui evite son chargement a l'initialisation de ChainDamageApplier en contexte de test.
+ */
+ @SuppressWarnings("deprecation")
+ private static final class DefaultHolder {
+ static final DamageExecutor INSTANCE = DamageSystems::executeDamage;
+ }
+
+ /** Retourne l'executeur par defaut (DamageSystems.executeDamage). Lazy-init. */
+ public static DamageExecutor defaultExecutor() {
+ return DefaultHolder.INSTANCE;
+ }
+
+ private ChainDamageApplier() {}
+
+ /**
+ * Applique les degats via l'executeur par defaut (DamageSystems.executeDamage).
+ *
+ * @param hits liste resolue par ChainResolver, dans l'ordre BFS
+ * @param attacker ref du joueur declenchant la chaine (source des degats)
+ * @param commandBuffer buffer de commandes du tick courant
+ * @param accessor ComponentAccessor (souvent identique au commandBuffer)
+ */
+ public static void apply(@Nonnull List hits,
+ @Nonnull Ref attacker,
+ @Nonnull CommandBuffer commandBuffer,
+ @Nonnull ComponentAccessor accessor) {
+ apply(hits, attacker, commandBuffer, defaultExecutor());
+ }
+
+ /**
+ * Applique les degats via un executeur injecte -- utilise en test pour eviter
+ * l'initialisation du runtime Hytale (DamageSystems.<clinit>).
+ *
+ * @param hits liste resolue par ChainResolver, dans l'ordre BFS
+ * @param attacker ref du joueur declenchant la chaine (source des degats)
+ * @param commandBuffer buffer de commandes du tick courant
+ * @param executor executeur de degats (DEFAULT en prod, stub en test)
+ */
+ @SuppressWarnings("deprecation")
+ public static void apply(@Nonnull List hits,
+ @Nonnull Ref attacker,
+ @Nonnull CommandBuffer commandBuffer,
+ @Nonnull DamageExecutor executor) {
+ for (ChainHit hit : hits) {
+ HytaleEntityAdapter adapter = (HytaleEntityAdapter) hit.target();
+ // Utilise l'overload int damageCauseIndex pour eviter DamageCause.PHYSICAL (null hors runtime).
+ // Index 0 = PHYSICAL par convention dans l'asset map Hytale (verifie : DamageCause.CODEC
+ // initialise PHYSICAL en premier lors du boot serveur).
+ Damage damage = new Damage(
+ new Damage.EntitySource(attacker),
+ 0, // damageCauseIndex 0 = PHYSICAL
+ (float) hit.damageHp()
+ );
+ executor.execute(adapter.ref(), commandBuffer, damage);
+ }
+ }
+}
diff --git a/src/main/java/com/mythlane/chainlightning/sceptre/ChainLightningSceptreInteraction.java b/src/main/java/com/mythlane/chainlightning/sceptre/ChainLightningSceptreInteraction.java
index 1792ef9..677b012 100644
--- a/src/main/java/com/mythlane/chainlightning/sceptre/ChainLightningSceptreInteraction.java
+++ b/src/main/java/com/mythlane/chainlightning/sceptre/ChainLightningSceptreInteraction.java
@@ -7,22 +7,53 @@ import com.hypixel.hytale.protocol.InteractionType;
import com.hypixel.hytale.server.core.entity.InteractionContext;
import com.hypixel.hytale.server.core.modules.interaction.interaction.CooldownHandler;
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.SimpleInstantInteraction;
-import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+import com.mythlane.chainlightning.chain.ChainHit;
+import com.mythlane.chainlightning.chain.ChainParameters;
+import com.mythlane.chainlightning.chain.ChainResolver;
+import com.mythlane.chainlightning.chain.Vec3;
import javax.annotation.Nonnull;
-import java.util.UUID;
+import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
- * Phase 1 stub interaction handler for the Chain Lightning Sceptre item.
- * Logs the click + applies a 4s cooldown smoke-test. Full chain logic lands in Phase 3.
+ * Phase 3 — Orchestrateur runtime du sceptre Chain Lightning.
+ *
+ * Pipeline (per CONTEXT.md "Pipeline firstRun — séquence exacte") :
+ *
+ * - Récupérer (ou créer) le cooldown "chain_lightning_sceptre" (4.0s, 1 charge).
+ * - Si {@code hasCooldown(false)} → return silencieux (refus UX).
+ * - Extraire playerRef + commandBuffer depuis InteractionContext.
+ * - Construire HytalePlayerRayCaster + HytaleEntitySource (frontière vers Phase 2).
+ * - Appeler ChainResolver.resolve avec Vec3.ZERO placeholders pour origin/direction
+ * (la lambda ray-cast les ignore — TargetUtil reconstruit eye-origin en interne).
+ * - Si hits.isEmpty() → log fine + return SANS consommer le cooldown (rater = re-cliquer
+ * immédiatement permis, decision CONTEXT).
+ * - ChainDamageApplier.apply(hits, playerRef, commandBuffer, commandBuffer).
+ * - cooldown.deductCharge() APRÈS succès (decision CONTEXT : pas de cooldown si rate).
+ * - Log info structuré avec count + ids.
+ *
+ *
+ * Try/catch global wrappe les étapes 3-9 — toute exception est loggée mais non propagée
+ * (éviter de crash le tick serveur, decision CONTEXT "Pas de try/catch défensif partout").
*/
public final class ChainLightningSceptreInteraction extends SimpleInstantInteraction {
private static final Logger LOGGER = Logger.getLogger(ChainLightningSceptreInteraction.class.getName());
+ // --- Constantes cooldown (per RESEARCH Q3) ---
+ private static final String COOLDOWN_ID = "chain_lightning_sceptre";
+ private static final float COOLDOWN_TIME = 4.0f;
+ private static final float[] CHARGE_TIMES = new float[]{4.0f};
+ private static final boolean FORCE_CREATE = true;
+ private static final boolean INTERRUPT_RECHARGE = false;
+
+ // --- Constantes chaîne (per ROADMAP CHAIN-02 + CHAIN-03) ---
+ private static final double RAY_MAX_BLOCKS = 25.0;
+
+ // --- BuilderCodec préservé tel quel depuis Phase 1 ---
@Nonnull
public static final BuilderCodec CODEC =
((BuilderCodec.Builder) BuilderCodec
@@ -40,31 +71,65 @@ public final class ChainLightningSceptreInteraction extends SimpleInstantInterac
protected void firstRun(@Nonnull InteractionType type,
@Nonnull InteractionContext context,
@Nonnull CooldownHandler cooldownHandler) {
- // Phase 1: stub only. Identify the player for traceability, log, then arm the cooldown
- // (smoke test of the CooldownHandler API — actual chain resolution lands in Phase 3).
- UUID playerUuid = null;
- try {
- Ref entityRef = context.getEntity();
- CommandBuffer commandBuffer = context.getCommandBuffer();
- if (entityRef != null && commandBuffer != null) {
- PlayerRef playerRef = commandBuffer.getComponent(entityRef, PlayerRef.getComponentType());
- if (playerRef != null) {
- playerUuid = playerRef.getUuid();
- }
- }
- } catch (Throwable ignored) {
- // Defensive: never fail the click because of telemetry.
+ // --- Étape 1 : récupérer le cooldown ---
+ CooldownHandler.Cooldown cooldown = cooldownHandler.getCooldown(
+ COOLDOWN_ID, COOLDOWN_TIME, CHARGE_TIMES, FORCE_CREATE, INTERRUPT_RECHARGE);
+ if (cooldown == null) {
+ LOGGER.log(Level.WARNING, "[ChainLightning] cooldown handler returned null — aborting");
+ return;
}
- LOGGER.log(Level.INFO,
- "[ChainLightning] sceptre clicked by player {0} (type={1})",
- new Object[]{playerUuid, type});
+ // --- Étape 2 : check cooldown sans décompter ---
+ if (cooldown.hasCooldown(false)) {
+ return; // refus silencieux
+ }
- // TODO Phase 3: wire cooldown via CooldownHandler.
- // Confirmed API (javap on Server-2026.03.26-89796e57b.jar):
- // CooldownHandler has NO cooldown(Duration) method.
- // Use: cooldownHandler.getCooldown("primary").setCooldownMax(4f)
- // then cooldownHandler.resetCooldown("primary", ...) on next tick.
- // Full cooldown integration deferred to Phase 3 to keep Phase 1 build green.
+ try {
+ // --- Étape 3 : extraire player + commandBuffer ---
+ Ref playerRef = context.getEntity();
+ CommandBuffer commandBuffer = context.getCommandBuffer();
+ if (playerRef == null || commandBuffer == null) {
+ LOGGER.log(Level.FINE, "[ChainLightning] missing playerRef or commandBuffer — abort");
+ return;
+ }
+
+ // --- Étape 4 : construire les adapters ---
+ // CommandBuffer implémente ComponentAccessor — passé directement
+ HytalePlayerRayCaster ray = new HytalePlayerRayCaster(playerRef, commandBuffer);
+ HytaleEntitySource neighbors = new HytaleEntitySource(commandBuffer);
+
+ // --- Étape 5 : résolution BFS (origin/direction = placeholders ignorés par le wrapper) ---
+ List hits = ChainResolver.resolve(
+ Vec3.ZERO, Vec3.ZERO, RAY_MAX_BLOCKS,
+ ray, neighbors, ChainParameters.DEFAULT
+ );
+
+ // --- Étape 6 : pas de cible → return SANS cooldown ---
+ if (hits.isEmpty()) {
+ LOGGER.log(Level.FINE, "[ChainLightning] no target");
+ return;
+ }
+
+ // --- Étape 7 : appliquer les dégâts ---
+ ChainDamageApplier.apply(hits, playerRef, commandBuffer, commandBuffer);
+
+ // --- Étape 8 : démarrer le cooldown APRÈS succès ---
+ cooldown.deductCharge();
+
+ // --- Étape 9 : log structuré ---
+ if (LOGGER.isLoggable(Level.INFO)) {
+ StringBuilder ids = new StringBuilder();
+ for (int i = 0; i < hits.size(); i++) {
+ if (i > 0) ids.append(',');
+ ids.append(hits.get(i).target().id());
+ }
+ LOGGER.log(Level.INFO,
+ "[ChainLightning] ref:{0} chained {1} targets [{2}]",
+ new Object[]{"ref:" + playerRef.getIndex(), hits.size(), ids.toString()});
+ }
+ } catch (Throwable t) {
+ // CONTEXT decision : try/catch global pour éviter crash tick serveur
+ LOGGER.log(Level.WARNING, "[ChainLightning] chain resolution failed", t);
+ }
}
}
diff --git a/src/main/java/com/mythlane/chainlightning/sceptre/HytaleEntityAdapter.java b/src/main/java/com/mythlane/chainlightning/sceptre/HytaleEntityAdapter.java
new file mode 100644
index 0000000..8615044
--- /dev/null
+++ b/src/main/java/com/mythlane/chainlightning/sceptre/HytaleEntityAdapter.java
@@ -0,0 +1,96 @@
+package com.mythlane.chainlightning.sceptre;
+
+import com.hypixel.hytale.component.ComponentAccessor;
+import com.hypixel.hytale.component.Ref;
+import com.hypixel.hytale.math.vector.Vector3d;
+import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
+import com.hypixel.hytale.server.core.modules.entitystats.EntityStatMap;
+import com.hypixel.hytale.server.core.modules.entitystats.EntityStatValue;
+import com.hypixel.hytale.server.core.modules.entitystats.asset.DefaultEntityStatTypes;
+import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+import com.mythlane.chainlightning.chain.ChainEntity;
+import com.mythlane.chainlightning.chain.Vec3;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Adapter immuable Ref<EntityStore> -> ChainEntity (Phase 2 SAM).
+ *
+ * Snapshot eager : la position et l'etat alive sont lus AU MOMENT de la creation.
+ * La resolution BFS de ChainResolver lit ces valeurs figees -- robuste face a un mob
+ * qui bouge ou meurt pendant la resolution.
+ *
+ *
Si TransformComponent est null (entite hors monde), l'adapter retourne un snapshot
+ * "mort" (alive=false, position=Vec3.ZERO) qui sera filtre par ChainResolver.
+ */
+public final class HytaleEntityAdapter implements ChainEntity {
+
+ private final Ref ref;
+ private final String id;
+ private final Vec3 position;
+ private final boolean alive;
+
+ private HytaleEntityAdapter(Ref ref, String id, Vec3 position, boolean alive) {
+ this.ref = ref;
+ this.id = id;
+ this.position = position;
+ this.alive = alive;
+ }
+
+ /**
+ * Constructeur package-private pour les tests : permet de construire un adapter
+ * sans passer par snapshot() (qui initialise TransformComponent.getComponentType()
+ * et donc le runtime Hytale complet).
+ */
+ static HytaleEntityAdapter forTest(@Nonnull Ref ref, @Nonnull String id,
+ @Nonnull Vec3 position, boolean alive) {
+ return new HytaleEntityAdapter(ref, id, position, alive);
+ }
+
+ /**
+ * Projette un Ref<EntityStore> vers un ChainEntity en lisant TransformComponent + EntityStatMap.
+ *
+ * @param ref reference entite Hytale
+ * @param accessor ComponentAccessor (CommandBuffer implemente ComponentAccessor)
+ * @return adapter snapshot. Jamais null.
+ */
+ @Nonnull
+ public static HytaleEntityAdapter snapshot(@Nonnull Ref ref,
+ @Nonnull ComponentAccessor accessor) {
+ String id = "ref:" + ref.getIndex();
+
+ TransformComponent tc = accessor.getComponent(ref, TransformComponent.getComponentType());
+ if (tc == null) {
+ return new HytaleEntityAdapter(ref, id, Vec3.ZERO, false);
+ }
+ Vector3d pos = tc.getPosition();
+ Vec3 vec = new Vec3(pos.x, pos.y, pos.z);
+
+ EntityStatMap statMap = accessor.getComponent(ref, EntityStatMap.getComponentType());
+ EntityStatValue health = statMap != null ? statMap.get(DefaultEntityStatTypes.getHealth()) : null;
+ boolean alive = health != null && health.get() > 0.0f;
+
+ return new HytaleEntityAdapter(ref, id, vec, alive);
+ }
+
+ /** Reference Hytale sous-jacente, exposee pour DamageSystems.executeDamage. */
+ @Nonnull
+ public Ref ref() {
+ return ref;
+ }
+
+ @Override
+ public String id() {
+ return id;
+ }
+
+ @Override
+ public Vec3 position() {
+ return position;
+ }
+
+ @Override
+ public boolean isAlive() {
+ return alive;
+ }
+}
diff --git a/src/main/java/com/mythlane/chainlightning/sceptre/HytaleEntitySource.java b/src/main/java/com/mythlane/chainlightning/sceptre/HytaleEntitySource.java
new file mode 100644
index 0000000..a95bcbb
--- /dev/null
+++ b/src/main/java/com/mythlane/chainlightning/sceptre/HytaleEntitySource.java
@@ -0,0 +1,43 @@
+package com.mythlane.chainlightning.sceptre;
+
+import com.hypixel.hytale.component.ComponentAccessor;
+import com.hypixel.hytale.component.Ref;
+import com.hypixel.hytale.math.vector.Vector3d;
+import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+import com.hypixel.hytale.server.core.util.TargetUtil;
+import com.mythlane.chainlightning.chain.ChainEntity;
+import com.mythlane.chainlightning.chain.EntitySource;
+import com.mythlane.chainlightning.chain.Vec3;
+
+import javax.annotation.Nonnull;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implementation Phase 3 de {@link EntitySource} qui delegue a
+ * {@link TargetUtil#getAllEntitiesInSphere}.
+ *
+ * Note importante : {@code getAllEntitiesInSphere} retourne une liste THREAD-LOCALE
+ * (SpatialResource.getThreadLocalReferenceList). On la consomme immediatement en mappant chaque
+ * ref vers un {@link HytaleEntityAdapter} dans une nouvelle ArrayList -- la liste retournee est
+ * sure a conserver entre frames.
+ */
+public final class HytaleEntitySource implements EntitySource {
+
+ private final ComponentAccessor accessor;
+
+ public HytaleEntitySource(@Nonnull ComponentAccessor accessor) {
+ this.accessor = accessor;
+ }
+
+ @Override
+ public List nearby(Vec3 origin, double radius) {
+ Vector3d hytaleOrigin = new Vector3d(origin.x(), origin.y(), origin.z());
+ List[> refs = TargetUtil.getAllEntitiesInSphere(hytaleOrigin, radius, accessor);
+ List snapshots = new ArrayList<>(refs.size());
+ for (Ref ref : refs) {
+ snapshots.add(HytaleEntityAdapter.snapshot(ref, accessor));
+ }
+ return snapshots;
+ }
+}
diff --git a/src/main/java/com/mythlane/chainlightning/sceptre/HytalePlayerRayCaster.java b/src/main/java/com/mythlane/chainlightning/sceptre/HytalePlayerRayCaster.java
new file mode 100644
index 0000000..fefce5e
--- /dev/null
+++ b/src/main/java/com/mythlane/chainlightning/sceptre/HytalePlayerRayCaster.java
@@ -0,0 +1,44 @@
+package com.mythlane.chainlightning.sceptre;
+
+import com.hypixel.hytale.component.ComponentAccessor;
+import com.hypixel.hytale.component.Ref;
+import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+import com.hypixel.hytale.server.core.util.TargetUtil;
+import com.mythlane.chainlightning.chain.ChainEntity;
+import com.mythlane.chainlightning.chain.RayCaster;
+import com.mythlane.chainlightning.chain.Vec3;
+
+import javax.annotation.Nonnull;
+import java.util.Optional;
+
+/**
+ * Implementation Phase 3 de {@link RayCaster} qui delegue a {@link TargetUtil#getTargetEntity}.
+ *
+ * ]Note importante : les parametres {@code origin} et {@code direction} de
+ * {@link #firstHit} sont IGNORES. {@code TargetUtil.getTargetEntity} reconstruit lui-meme
+ * l'origine yeux + direction du regard depuis le {@code playerRef} (TransformComponent +
+ * ModelComponent.eyeHeight + HeadRotation lus en interne via {@code TargetUtil.getLook}).
+ *
+ *
Cette asymetrie est volontaire : preserve la SAM Phase 2 sans modification, tout en
+ * laissant Hytale calculer l'origine yeux precise.
+ */
+public final class HytalePlayerRayCaster implements RayCaster {
+
+ private final Ref playerRef;
+ private final ComponentAccessor accessor;
+
+ public HytalePlayerRayCaster(@Nonnull Ref playerRef,
+ @Nonnull ComponentAccessor accessor) {
+ this.playerRef = playerRef;
+ this.accessor = accessor;
+ }
+
+ @Override
+ public Optional firstHit(Vec3 originIgnored, Vec3 directionIgnored, double maxBlocks) {
+ Ref target = TargetUtil.getTargetEntity(playerRef, (float) maxBlocks, accessor);
+ if (target == null) {
+ return Optional.empty();
+ }
+ return Optional.of(HytaleEntityAdapter.snapshot(target, accessor));
+ }
+}
diff --git a/src/test/java/com/mythlane/chainlightning/sceptre/ChainDamageApplierTest.java b/src/test/java/com/mythlane/chainlightning/sceptre/ChainDamageApplierTest.java
new file mode 100644
index 0000000..bc86913
--- /dev/null
+++ b/src/test/java/com/mythlane/chainlightning/sceptre/ChainDamageApplierTest.java
@@ -0,0 +1,141 @@
+package com.mythlane.chainlightning.sceptre;
+
+import com.hypixel.hytale.component.CommandBuffer;
+import com.hypixel.hytale.component.ComponentAccessor;
+import com.hypixel.hytale.component.Ref;
+import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
+import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+import com.mythlane.chainlightning.chain.ChainHit;
+import com.mythlane.chainlightning.chain.Vec3;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Test unitaire de ChainDamageApplier -- verifie que chaque ChainHit produit exactement
+ * un appel a DamageExecutor avec le bon ref + amount.
+ *
+ * Utilise l'overload ChainDamageApplier.apply(..., DamageExecutor) pour eviter d'initialiser
+ * le runtime Hytale (DamageSystems possede un initialiseur statique dependant de PluginBase).
+ */
+final class ChainDamageApplierTest {
+
+ @Test
+ void apply_invokes_executeDamage_per_hit() {
+ @SuppressWarnings("unchecked")
+ Ref attacker = (Ref) mock(Ref.class);
+ @SuppressWarnings("unchecked")
+ CommandBuffer buf = (CommandBuffer) mock(CommandBuffer.class);
+ ChainDamageApplier.DamageExecutor executor = mock(ChainDamageApplier.DamageExecutor.class);
+
+ List hits = List.of(
+ hit("a", 8, 0),
+ hit("b", 6, 1),
+ hit("c", 4, 2),
+ hit("d", 3, 3),
+ hit("e", 2, 4)
+ );
+
+ ChainDamageApplier.apply(hits, attacker, buf, executor);
+
+ verify(executor, times(5)).execute(
+ org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.eq(buf),
+ org.mockito.ArgumentMatchers.any(Damage.class)
+ );
+ }
+
+ @Test
+ void apply_passes_correct_damage_amounts_in_order() {
+ @SuppressWarnings("unchecked")
+ Ref attacker = (Ref) mock(Ref.class);
+ @SuppressWarnings("unchecked")
+ CommandBuffer buf = (CommandBuffer) mock(CommandBuffer.class);
+ ChainDamageApplier.DamageExecutor executor = mock(ChainDamageApplier.DamageExecutor.class);
+
+ List hits = List.of(
+ hit("a", 8, 0),
+ hit("b", 6, 1),
+ hit("c", 4, 2)
+ );
+
+ ArgumentCaptor damageCaptor = ArgumentCaptor.forClass(Damage.class);
+
+ ChainDamageApplier.apply(hits, attacker, buf, executor);
+
+ verify(executor, times(3)).execute(
+ org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.eq(buf),
+ damageCaptor.capture()
+ );
+
+ assertThat(damageCaptor.getAllValues()).hasSize(3);
+ }
+
+ @Test
+ void apply_with_empty_list_invokes_nothing() {
+ @SuppressWarnings("unchecked")
+ Ref attacker = (Ref) mock(Ref.class);
+ @SuppressWarnings("unchecked")
+ CommandBuffer buf = (CommandBuffer) mock(CommandBuffer.class);
+ ChainDamageApplier.DamageExecutor executor = mock(ChainDamageApplier.DamageExecutor.class);
+
+ ChainDamageApplier.apply(List.of(), attacker, buf, executor);
+
+ verify(executor, never()).execute(
+ org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.any()
+ );
+ }
+
+ @Test
+ void apply_passes_adapter_ref_not_attacker_ref() {
+ @SuppressWarnings("unchecked")
+ Ref attacker = (Ref) mock(Ref.class, "attacker");
+ @SuppressWarnings("unchecked")
+ Ref targetRef = (Ref) mock(Ref.class, "target");
+ @SuppressWarnings("unchecked")
+ ComponentAccessor accessor = (ComponentAccessor) mock(ComponentAccessor.class);
+ @SuppressWarnings("unchecked")
+ CommandBuffer buf = (CommandBuffer) mock(CommandBuffer.class);
+ ChainDamageApplier.DamageExecutor executor = mock(ChainDamageApplier.DamageExecutor.class);
+
+ // Construire un HytaleEntityAdapter via forTest pour eviter l'initialisation
+ // du runtime Hytale (TransformComponent.getComponentType() -> PluginBase -> HytaleLogger).
+ HytaleEntityAdapter adapter = HytaleEntityAdapter.forTest(targetRef, "target", Vec3.ZERO, false);
+ ChainHit hit = new ChainHit(adapter, 8, 0);
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor[> refCaptor = ArgumentCaptor.forClass(Ref.class);
+
+ ChainDamageApplier.apply(List.of(hit), attacker, buf, executor);
+
+ verify(executor, times(1)).execute(
+ refCaptor.capture(),
+ org.mockito.ArgumentMatchers.eq(buf),
+ org.mockito.ArgumentMatchers.any(Damage.class)
+ );
+
+ assertThat(refCaptor.getValue()).isSameAs(targetRef);
+ assertThat(refCaptor.getValue()).isNotSameAs(attacker);
+ }
+
+ // --- helper : cree un ChainHit avec un HytaleEntityAdapter mock-friendly ---
+ private static ChainHit hit(String id, int dmg, int hop) {
+ @SuppressWarnings("unchecked")
+ Ref ref = (Ref) mock(Ref.class, id);
+ @SuppressWarnings("unchecked")
+ ComponentAccessor accessor = (ComponentAccessor) mock(ComponentAccessor.class);
+ // Utiliser forTest pour eviter l'initialisation du runtime Hytale (TransformComponent -> PluginBase).
+ HytaleEntityAdapter adapter = HytaleEntityAdapter.forTest(ref, id, Vec3.ZERO, false);
+ return new ChainHit(adapter, dmg, hop);
+ }
+}
]