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