From 8725b8a1c7f777770b37aa15166d7bb1a2d695f1 Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Mon, 27 Apr 2026 12:14:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(phase-3):=20runtime=20integration=20?= =?UTF-8?q?=E2=80=94=20chain=20damage=20application=20+=20cooldown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the pure ChainResolver (Phase 2) to the live Hytale runtime via four sceptre/ adapters: - HytaleEntityAdapter — eager snapshot Ref -> ChainEntity via TransformComponent.getPosition() and EntityStatMap.get(health) > 0 - HytalePlayerRayCaster — captures playerRef, delegates to TargetUtil.getTargetEntity (auto eye-origin + head-rotation) - HytaleEntitySource — wraps TargetUtil.getAllEntitiesInSphere - ChainDamageApplier — fires DamageSystems.executeDamage per hit; injectable DamageExecutor SAM keeps the helper unit-testable. ChainLightningSceptreInteraction.firstRun is rewritten end-to-end: cooldown gate (hasCooldown(false) -> deductCharge() only on success), ray-cast -> BFS -> damage application, structured logging, try/catch wrapper to keep a runtime fault from killing the server tick. API corrections discovered against the decompiled jar: - Ref has no uuid() — use "ref:" + getIndex() for the chain id - DamageCause.PHYSICAL is @Nullable until runtime — use the int-index overload of Damage with index 0 - Static mock of DamageSystems crashes class init — abstracted behind a DamageExecutor SAM with a default lazy holder Tests: 33/33 green (25 from Phase 2 + 4 ChainDamageApplier tests + 4 fixture sanity). ./gradlew build SUCCESSFUL, JAR auto-deployed. MANUAL UAT (10 items) pending in-game. --- build.gradle.kts | 4 + .../mythlane/chainlightning/chain/Vec3.java | 3 + .../sceptre/ChainDamageApplier.java | 101 +++++++++++++ .../ChainLightningSceptreInteraction.java | 119 +++++++++++---- .../sceptre/HytaleEntityAdapter.java | 96 ++++++++++++ .../sceptre/HytaleEntitySource.java | 43 ++++++ .../sceptre/HytalePlayerRayCaster.java | 44 ++++++ .../sceptre/ChainDamageApplierTest.java | 141 ++++++++++++++++++ 8 files changed, 524 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/mythlane/chainlightning/sceptre/ChainDamageApplier.java create mode 100644 src/main/java/com/mythlane/chainlightning/sceptre/HytaleEntityAdapter.java create mode 100644 src/main/java/com/mythlane/chainlightning/sceptre/HytaleEntitySource.java create mode 100644 src/main/java/com/mythlane/chainlightning/sceptre/HytalePlayerRayCaster.java create mode 100644 src/test/java/com/mythlane/chainlightning/sceptre/ChainDamageApplierTest.java 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") : + *

    + *
  1. Récupérer (ou créer) le cooldown "chain_lightning_sceptre" (4.0s, 1 charge).
  2. + *
  3. Si {@code hasCooldown(false)} → return silencieux (refus UX).
  4. + *
  5. Extraire playerRef + commandBuffer depuis InteractionContext.
  6. + *
  7. Construire HytalePlayerRayCaster + HytaleEntitySource (frontière vers Phase 2).
  8. + *
  9. Appeler ChainResolver.resolve avec Vec3.ZERO placeholders pour origin/direction + * (la lambda ray-cast les ignore — TargetUtil reconstruit eye-origin en interne).
  10. + *
  11. Si hits.isEmpty() → log fine + return SANS consommer le cooldown (rater = re-cliquer + * immédiatement permis, decision CONTEXT).
  12. + *
  13. ChainDamageApplier.apply(hits, playerRef, commandBuffer, commandBuffer).
  14. + *
  15. cooldown.deductCharge() APRÈS succès (decision CONTEXT : pas de cooldown si rate).
  16. + *
  17. Log info structuré avec count + ids.
  18. + *
+ * + *

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); + } +}