feat(phase-3): runtime integration — chain damage application + cooldown

Wires the pure ChainResolver (Phase 2) to the live Hytale runtime via
four sceptre/ adapters:
- HytaleEntityAdapter — eager snapshot Ref<EntityStore> -> 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.
This commit is contained in:
2026-04-27 12:14:58 +02:00
parent cd5d0bedd3
commit 8725b8a1c7
8 changed files with 524 additions and 27 deletions
+4
View File
@@ -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")
}
}
@@ -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;
@@ -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.
*
* <p>Utilise {@link DamageCause#PHYSICAL} (deprecie mais fonctionnel -- RESEARCH Q5). Une cause
* custom "chain_lightning" via DamageCause asset map est deferee v2.
*
* <p>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).
*
* <p><b>Testabilite :</b> 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<EntityStore> target,
@Nonnull CommandBuffer<EntityStore> 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<ChainHit> hits,
@Nonnull Ref<EntityStore> attacker,
@Nonnull CommandBuffer<EntityStore> commandBuffer,
@Nonnull ComponentAccessor<EntityStore> accessor) {
apply(hits, attacker, commandBuffer, defaultExecutor());
}
/**
* Applique les degats via un executeur injecte -- utilise en test pour eviter
* l'initialisation du runtime Hytale (DamageSystems.&lt;clinit&gt;).
*
* @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<ChainHit> hits,
@Nonnull Ref<EntityStore> attacker,
@Nonnull CommandBuffer<EntityStore> 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);
}
}
}
@@ -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.
*
* <p>Pipeline (per CONTEXT.md "Pipeline firstRun — séquence exacte") :
* <ol>
* <li>Récupérer (ou créer) le cooldown "chain_lightning_sceptre" (4.0s, 1 charge).</li>
* <li>Si {@code hasCooldown(false)} → return silencieux (refus UX).</li>
* <li>Extraire playerRef + commandBuffer depuis InteractionContext.</li>
* <li>Construire HytalePlayerRayCaster + HytaleEntitySource (frontière vers Phase 2).</li>
* <li>Appeler ChainResolver.resolve avec Vec3.ZERO placeholders pour origin/direction
* (la lambda ray-cast les ignore — TargetUtil reconstruit eye-origin en interne).</li>
* <li>Si hits.isEmpty() → log fine + return SANS consommer le cooldown (rater = re-cliquer
* immédiatement permis, decision CONTEXT).</li>
* <li>ChainDamageApplier.apply(hits, playerRef, commandBuffer, commandBuffer).</li>
* <li>cooldown.deductCharge() APRÈS succès (decision CONTEXT : pas de cooldown si rate).</li>
* <li>Log info structuré avec count + ids.</li>
* </ol>
*
* <p>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<ChainLightningSceptreInteraction> 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<EntityStore> entityRef = context.getEntity();
CommandBuffer<EntityStore> 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<EntityStore> playerRef = context.getEntity();
CommandBuffer<EntityStore> 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<EntityStore> implémente ComponentAccessor<EntityStore> — 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<ChainHit> 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);
}
}
}
@@ -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&lt;EntityStore&gt; -&gt; ChainEntity (Phase 2 SAM).
*
* <p>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.
*
* <p>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<EntityStore> ref;
private final String id;
private final Vec3 position;
private final boolean alive;
private HytaleEntityAdapter(Ref<EntityStore> 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<EntityStore> ref, @Nonnull String id,
@Nonnull Vec3 position, boolean alive) {
return new HytaleEntityAdapter(ref, id, position, alive);
}
/**
* Projette un Ref&lt;EntityStore&gt; 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<EntityStore> ref,
@Nonnull ComponentAccessor<EntityStore> 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<EntityStore> ref() {
return ref;
}
@Override
public String id() {
return id;
}
@Override
public Vec3 position() {
return position;
}
@Override
public boolean isAlive() {
return alive;
}
}
@@ -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}.
*
* <p><b>Note importante :</b> {@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<EntityStore> accessor;
public HytaleEntitySource(@Nonnull ComponentAccessor<EntityStore> accessor) {
this.accessor = accessor;
}
@Override
public List<ChainEntity> nearby(Vec3 origin, double radius) {
Vector3d hytaleOrigin = new Vector3d(origin.x(), origin.y(), origin.z());
List<Ref<EntityStore>> refs = TargetUtil.getAllEntitiesInSphere(hytaleOrigin, radius, accessor);
List<ChainEntity> snapshots = new ArrayList<>(refs.size());
for (Ref<EntityStore> ref : refs) {
snapshots.add(HytaleEntityAdapter.snapshot(ref, accessor));
}
return snapshots;
}
}
@@ -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}.
*
* <p><b>Note importante :</b> 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}).
*
* <p>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<EntityStore> playerRef;
private final ComponentAccessor<EntityStore> accessor;
public HytalePlayerRayCaster(@Nonnull Ref<EntityStore> playerRef,
@Nonnull ComponentAccessor<EntityStore> accessor) {
this.playerRef = playerRef;
this.accessor = accessor;
}
@Override
public Optional<ChainEntity> firstHit(Vec3 originIgnored, Vec3 directionIgnored, double maxBlocks) {
Ref<EntityStore> target = TargetUtil.getTargetEntity(playerRef, (float) maxBlocks, accessor);
if (target == null) {
return Optional.empty();
}
return Optional.of(HytaleEntityAdapter.snapshot(target, accessor));
}
}
@@ -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.
*
* <p>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<EntityStore> attacker = (Ref<EntityStore>) mock(Ref.class);
@SuppressWarnings("unchecked")
CommandBuffer<EntityStore> buf = (CommandBuffer<EntityStore>) mock(CommandBuffer.class);
ChainDamageApplier.DamageExecutor executor = mock(ChainDamageApplier.DamageExecutor.class);
List<ChainHit> 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<EntityStore> attacker = (Ref<EntityStore>) mock(Ref.class);
@SuppressWarnings("unchecked")
CommandBuffer<EntityStore> buf = (CommandBuffer<EntityStore>) mock(CommandBuffer.class);
ChainDamageApplier.DamageExecutor executor = mock(ChainDamageApplier.DamageExecutor.class);
List<ChainHit> hits = List.of(
hit("a", 8, 0),
hit("b", 6, 1),
hit("c", 4, 2)
);
ArgumentCaptor<Damage> 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<EntityStore> attacker = (Ref<EntityStore>) mock(Ref.class);
@SuppressWarnings("unchecked")
CommandBuffer<EntityStore> buf = (CommandBuffer<EntityStore>) 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<EntityStore> attacker = (Ref<EntityStore>) mock(Ref.class, "attacker");
@SuppressWarnings("unchecked")
Ref<EntityStore> targetRef = (Ref<EntityStore>) mock(Ref.class, "target");
@SuppressWarnings("unchecked")
ComponentAccessor<EntityStore> accessor = (ComponentAccessor<EntityStore>) mock(ComponentAccessor.class);
@SuppressWarnings("unchecked")
CommandBuffer<EntityStore> buf = (CommandBuffer<EntityStore>) 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<Ref<EntityStore>> 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<EntityStore> ref = (Ref<EntityStore>) mock(Ref.class, id);
@SuppressWarnings("unchecked")
ComponentAccessor<EntityStore> accessor = (ComponentAccessor<EntityStore>) 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);
}
}