refactor: simplify ChainResolver and RayCaster interfaces, enhance ChainDamageApplier logic

- Updated ChainResolver to remove unused shooterOrigin and shooterDirection parameters, streamlining the resolve method.
- Modified RayCaster interface to reflect changes in method signature, focusing on maxBlocks only.
- Enhanced ChainDamageApplier to utilize a new CauseIndexHolder for resolving the PHYSICAL damage cause index, improving clarity and performance.
- Refactored ChainDamageApplier.apply method to use HytaleEntityAdapter for target references, ensuring correct damage application.
- Adjusted tests in ChainResolverTest and ChainDamageApplierTest to align with the new method signatures and logic.

Tests: All tests passing. Build: ./gradlew clean build successful.
This commit is contained in:
2026-04-28 08:20:07 +02:00
parent 03754a0646
commit f6ca35bfc4
9 changed files with 101 additions and 143 deletions
@@ -12,15 +12,12 @@ public final class ChainResolver {
private ChainResolver() {}
/** Resolves the full chain; returns the primary plus up to maxTargets-1 nearest unique neighbors. */
public static List<ChainHit> resolve(
Vec3 shooterOrigin,
Vec3 shooterDirection,
double rayMaxBlocks,
public static List<ChainHit> resolve(double rayMaxBlocks,
RayCaster ray,
EntitySource neighbors,
ChainParameters params) {
Optional<ChainEntity> primaryOpt = ray.firstHit(shooterOrigin, shooterDirection, rayMaxBlocks);
Optional<ChainEntity> primaryOpt = ray.firstHit(rayMaxBlocks);
if (primaryOpt.isEmpty()) {
return List.of();
}
@@ -2,8 +2,8 @@ package com.mythlane.chainlightning.chain;
import java.util.Optional;
/** SAM that returns the first entity hit along a ray, or empty. */
/** SAM that returns the first entity along the caster's look ray, or empty. */
@FunctionalInterface
public interface RayCaster {
Optional<ChainEntity> firstHit(Vec3 origin, Vec3 direction, double maxBlocks);
Optional<ChainEntity> firstHit(double maxBlocks);
}
@@ -15,6 +15,7 @@ import java.util.logging.Level;
import java.util.logging.Logger;
/** Applies DamageSystems.executeDamage to each chain hit; injectable executor keeps unit tests off the Hytale runtime. */
@SuppressWarnings("deprecation")
public final class ChainDamageApplier {
private static final Logger LOGGER = Logger.getLogger(ChainDamageApplier.class.getName());
@@ -28,24 +29,28 @@ public final class ChainDamageApplier {
}
/** Lazy holder keeps DamageSystems.&lt;clinit&gt; out of the test classpath. */
@SuppressWarnings("deprecation")
private static final class DefaultHolder {
static final DamageExecutor INSTANCE = DamageSystems::executeDamage;
}
/** Lazy holder caches the asset-map index of PHYSICAL once the runtime is initialized. */
private static final class CauseIndexHolder {
static final int VALUE = computeIndex();
private static int computeIndex() {
DamageCause physical = DamageCause.PHYSICAL;
return physical == null ? 0 : DamageCause.getAssetMap().getIndex(physical.getId());
}
}
/** Production executor that delegates to DamageSystems.executeDamage. */
public static DamageExecutor defaultExecutor() {
return DefaultHolder.INSTANCE;
}
/** Resolves the PHYSICAL cause index at call time so the runtime asset map ordering is honored. */
@SuppressWarnings("deprecation")
/** PHYSICAL cause index resolved from the asset map; falls back to 0 in unit tests where the runtime is not booted. */
static int physicalDamageCauseIndex() {
DamageCause physical = DamageCause.PHYSICAL;
if (physical == null) {
return 0;
}
return DamageCause.getAssetMap().getIndex(physical.getId());
return CauseIndexHolder.VALUE;
}
private ChainDamageApplier() {}
@@ -64,17 +69,13 @@ public final class ChainDamageApplier {
@Nonnull CommandBuffer<EntityStore> commandBuffer,
@Nonnull DamageExecutor executor) {
int causeIndex = physicalDamageCauseIndex();
Damage.EntitySource source = new Damage.EntitySource(attacker);
int succeeded = 0;
for (ChainHit hit : hits) {
HytaleEntityAdapter adapter = (HytaleEntityAdapter) hit.target();
Ref<EntityStore> targetRef = adapter.ref();
Damage damage = new Damage(
new Damage.EntitySource(attacker),
causeIndex,
(float) hit.damageHp()
);
HytaleEntityAdapter adapter = HytaleEntityAdapter.from(hit);
Damage damage = new Damage(source, causeIndex, (float) hit.damageHp());
try {
executor.execute(targetRef, commandBuffer, damage);
executor.execute(adapter.ref(), commandBuffer, damage);
succeeded++;
} catch (Throwable t) {
LOGGER.log(Level.WARNING,
@@ -11,7 +11,6 @@ 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.List;
@@ -81,10 +80,7 @@ public final class ChainLightningSceptreInteraction extends SimpleInstantInterac
@Nonnull CommandBuffer<EntityStore> commandBuffer) {
HytalePlayerRayCaster ray = new HytalePlayerRayCaster(playerRef, commandBuffer);
HytaleEntitySource neighbors = new HytaleEntitySource(commandBuffer);
return ChainResolver.resolve(
Vec3.ZERO, Vec3.ZERO, RAY_MAX_BLOCKS,
ray, neighbors, ChainParameters.DEFAULT
);
return ChainResolver.resolve(RAY_MAX_BLOCKS, ray, neighbors, ChainParameters.DEFAULT);
}
/** VFX emit is best-effort: damage is already applied so a failure must not abort the cooldown step. */
@@ -9,6 +9,7 @@ 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.ChainHit;
import com.mythlane.chainlightning.chain.Vec3;
import javax.annotation.Nonnull;
@@ -54,6 +55,12 @@ public final class HytaleEntityAdapter implements ChainEntity {
return new HytaleEntityAdapter(ref, id, vec, alive);
}
/** Single cast point so callers stop reaching into ChainEntity to recover the runtime adapter. */
@Nonnull
public static HytaleEntityAdapter from(@Nonnull ChainHit hit) {
return (HytaleEntityAdapter) hit.target();
}
/** Underlying Hytale ref, exposed so DamageSystems and EffectControllerComponent can address the entity. */
@Nonnull
public Ref<EntityStore> ref() {
@@ -6,12 +6,11 @@ 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;
/** Phase 3 RayCaster delegating to TargetUtil; origin/direction args are ignored since TargetUtil derives them from playerRef. */
/** Phase 3 RayCaster delegating to TargetUtil; eye origin and look direction are derived from the playerRef inside Hytale. */
public final class HytalePlayerRayCaster implements RayCaster {
private final Ref<EntityStore> playerRef;
@@ -24,7 +23,7 @@ public final class HytalePlayerRayCaster implements RayCaster {
}
@Override
public Optional<ChainEntity> firstHit(Vec3 originIgnored, Vec3 directionIgnored, double maxBlocks) {
public Optional<ChainEntity> firstHit(double maxBlocks) {
Ref<EntityStore> target = TargetUtil.getTargetEntity(playerRef, (float) maxBlocks, accessor);
if (target == null) {
return Optional.empty();
@@ -37,28 +37,17 @@ public final class HytaleVfxEmitter {
ComponentAccessor<EntityStore> accessor = commandBuffer;
int applied = 0;
int skipped = 0;
for (int i = 0; i < hits.size(); i++) {
Ref<EntityStore> targetRef = ((HytaleEntityAdapter) hits.get(i).target()).ref();
if (targetRef == null || !targetRef.isValid()) {
skipped++;
continue;
}
for (ChainHit hit : hits) {
Ref<EntityStore> targetRef = HytaleEntityAdapter.from(hit).ref();
if (!targetRef.isValid()) continue;
EffectControllerComponent ecc = accessor.getComponent(targetRef, EffectControllerComponent.getComponentType());
if (ecc == null) {
skipped++;
continue;
}
boolean ok = ecc.addEffect(targetRef, entityEffect, accessor);
if (ok) {
if (ecc != null && ecc.addEffect(targetRef, entityEffect, accessor)) {
applied++;
} else {
skipped++;
}
}
LOGGER.info(String.format(
"[ChainLightning][Vfx] EntityEffect '%s' applied to %d/%d targets (skipped=%d, caster=%s)",
EFFECT_ID, applied, hits.size(), skipped, playerRef));
"[ChainLightning][Vfx] EntityEffect '%s' applied to %d/%d targets (caster=%s)",
EFFECT_ID, applied, hits.size(), playerRef));
}
}
@@ -13,16 +13,16 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
final class ChainResolverTest {
private static final Vec3 ORIGIN = new Vec3(0, 0, 0);
private static final Vec3 DIR = new Vec3(1, 0, 0);
private static final double RAY_MAX = 25.0;
private static final ChainParameters DEFAULT = ChainParameters.DEFAULT;
private static final ChainParameters TWO_HOPS = new ChainParameters(2, 8.0, new int[]{8, 6});
private static RayCaster rayMisses() {
return (o, d, max) -> Optional.empty();
return max -> Optional.empty();
}
private static RayCaster rayHits(ChainEntity e) {
return (o, d, max) -> Optional.of(e);
return max -> Optional.of(e);
}
private static EntitySource neighborsAlways(List<ChainEntity> candidates) {
@@ -35,16 +35,14 @@ final class ChainResolverTest {
@Test
void resolve_noPrimaryHit_returnsEmpty() {
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayMisses(), neighborsEmpty(), ChainParameters.DEFAULT);
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayMisses(), neighborsEmpty(), DEFAULT);
assertTrue(hits.isEmpty());
}
@Test
void resolve_primaryOnly_noNeighbors_returnsSingleHit() {
ChainEntity primary = entity("p", 10, 0, 0);
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsEmpty(), ChainParameters.DEFAULT);
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(primary), neighborsEmpty(), DEFAULT);
assertEquals(1, hits.size());
assertSame(primary, hits.get(0).target());
assertEquals(8, hits.get(0).damageHp());
@@ -60,8 +58,7 @@ final class ChainResolverTest {
ChainEntity e4 = entity("e4", 18, 0, 0);
List<ChainEntity> all = List.of(e0, e1, e2, e3, e4);
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(e0), neighborsAlways(all), ChainParameters.DEFAULT);
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(e0), neighborsAlways(all), DEFAULT);
assertEquals(5, hits.size());
assertSame(e0, hits.get(0).target()); assertEquals(8, hits.get(0).damageHp()); assertEquals(0, hits.get(0).hopIndex());
@@ -74,19 +71,19 @@ final class ChainResolverTest {
@Test
void resolve_moreThanFiveCandidates_stopsAtMaxTargets() {
ChainEntity primary = entity("e0", 10, 0, 0);
ChainEntity e1 = entity("e1", 11, 0, 0);
ChainEntity e2 = entity("e2", 12, 0, 0);
ChainEntity e3 = entity("e3", 13, 0, 0);
ChainEntity e4 = entity("e4", 14, 0, 0);
ChainEntity e5 = entity("e5", 15, 0, 0);
ChainEntity e6 = entity("e6", 16, 0, 0);
ChainEntity e7 = entity("e7", 17, 0, 0);
ChainEntity e8 = entity("e8", 18, 0, 0);
ChainEntity e9 = entity("e9", 19, 0, 0);
List<ChainEntity> all = List.of(primary, e1, e2, e3, e4, e5, e6, e7, e8, e9);
List<ChainEntity> all = List.of(
primary,
entity("e1", 11, 0, 0),
entity("e2", 12, 0, 0),
entity("e3", 13, 0, 0),
entity("e4", 14, 0, 0),
entity("e5", 15, 0, 0),
entity("e6", 16, 0, 0),
entity("e7", 17, 0, 0),
entity("e8", 18, 0, 0),
entity("e9", 19, 0, 0));
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsAlways(all), ChainParameters.DEFAULT);
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(primary), neighborsAlways(all), DEFAULT);
assertEquals(5, hits.size());
}
@@ -94,14 +91,12 @@ final class ChainResolverTest {
@Test
void resolve_candidatesOutsideRadius_excluded() {
ChainEntity primary = entity("p", 0, 0, 0);
ChainEntity far1 = entity("f1", 9, 0, 0);
ChainEntity far2 = entity("f2", 0, 9, 0);
ChainEntity far3 = entity("f3", 0, 0, 9);
List<ChainEntity> far = List.of(
entity("f1", 9, 0, 0),
entity("f2", 0, 9, 0),
entity("f3", 0, 0, 9));
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(primary),
neighborsAlways(List.of(far1, far2, far3)),
ChainParameters.DEFAULT);
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(primary), neighborsAlways(far), DEFAULT);
assertEquals(1, hits.size());
assertSame(primary, hits.get(0).target());
@@ -111,11 +106,8 @@ final class ChainResolverTest {
void resolve_noDoubleHit_visitedExcluded() {
ChainEntity a = entity("a", 0, 0, 0);
ChainEntity b = entity("b", 2, 0, 0);
List<ChainEntity> mutual = List.of(a, b);
ChainParameters p = new ChainParameters(5, 8.0, new int[]{8, 6, 4, 3, 2});
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(a), neighborsAlways(mutual), p);
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(a), neighborsAlways(List.of(a, b)), DEFAULT);
assertEquals(2, hits.size());
assertSame(a, hits.get(0).target());
@@ -129,9 +121,7 @@ final class ChainResolverTest {
ChainEntity far = entity("far", 7, 0, 0);
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(primary),
neighborsAlways(List.of(near, far)),
new ChainParameters(2, 8.0, new int[]{8, 6}));
RAY_MAX, rayHits(primary), neighborsAlways(List.of(near, far)), TWO_HOPS);
assertEquals(2, hits.size());
assertSame(near, hits.get(1).target());
@@ -144,14 +134,10 @@ final class ChainResolverTest {
ChainEntity alpha = entity("alpha", 0, 5, 0);
for (int i = 0; i < 100; i++) {
List<ChainEntity> order1 = List.of(zebra, alpha);
List<ChainEntity> order2 = List.of(alpha, zebra);
List<ChainHit> h1 = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsAlways(order1),
new ChainParameters(2, 8.0, new int[]{8, 6}));
RAY_MAX, rayHits(primary), neighborsAlways(List.of(zebra, alpha)), TWO_HOPS);
List<ChainHit> h2 = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsAlways(order2),
new ChainParameters(2, 8.0, new int[]{8, 6}));
RAY_MAX, rayHits(primary), neighborsAlways(List.of(alpha, zebra)), TWO_HOPS);
assertSame(alpha, h1.get(1).target(), "run " + i + " order1");
assertSame(alpha, h2.get(1).target(), "run " + i + " order2");
}
@@ -164,9 +150,7 @@ final class ChainResolverTest {
ChainEntity alive = entity("alive", 4, 0, 0);
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(primary),
neighborsAlways(List.of(deadOne, alive)),
new ChainParameters(2, 8.0, new int[]{8, 6}));
RAY_MAX, rayHits(primary), neighborsAlways(List.of(deadOne, alive)), TWO_HOPS);
assertEquals(2, hits.size());
assertSame(alive, hits.get(1).target());
@@ -175,15 +159,15 @@ final class ChainResolverTest {
@Test
void resolve_customMaxTargets_truncatesEarly() {
ChainEntity e0 = entity("e0", 0, 0, 0);
ChainEntity e1 = entity("e1", 1, 0, 0);
ChainEntity e2 = entity("e2", 2, 0, 0);
ChainEntity e3 = entity("e3", 3, 0, 0);
ChainEntity e4 = entity("e4", 4, 0, 0);
List<ChainEntity> all = List.of(e0, e1, e2, e3, e4);
List<ChainEntity> all = List.of(
e0,
entity("e1", 1, 0, 0),
entity("e2", 2, 0, 0),
entity("e3", 3, 0, 0),
entity("e4", 4, 0, 0));
ChainParameters p = new ChainParameters(3, 8.0, new int[]{10, 5, 1});
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(e0), neighborsAlways(all), p);
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(e0), neighborsAlways(all), p);
assertEquals(3, hits.size());
assertEquals(10, hits.get(0).damageHp());
@@ -23,12 +23,7 @@ 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);
Fixture f = new Fixture();
List<ChainHit> hits = List.of(
hit("a", 8, 0),
hit("b", 6, 1),
@@ -37,53 +32,42 @@ final class ChainDamageApplierTest {
hit("e", 2, 4)
);
ChainDamageApplier.apply(hits, attacker, buf, executor);
ChainDamageApplier.apply(hits, f.attacker, f.buf, f.executor);
verify(executor, times(5)).execute(
verify(f.executor, times(5)).execute(
ArgumentMatchers.any(),
ArgumentMatchers.eq(buf),
ArgumentMatchers.eq(f.buf),
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);
Fixture f = new Fixture();
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);
ChainDamageApplier.apply(hits, f.attacker, f.buf, f.executor);
verify(executor, times(3)).execute(
verify(f.executor, times(3)).execute(
ArgumentMatchers.any(),
ArgumentMatchers.eq(buf),
ArgumentMatchers.eq(f.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);
Fixture f = new Fixture();
ChainDamageApplier.apply(List.of(), attacker, buf, executor);
ChainDamageApplier.apply(List.of(), f.attacker, f.buf, f.executor);
verify(executor, never()).execute(
verify(f.executor, never()).execute(
ArgumentMatchers.any(),
ArgumentMatchers.any(),
ArgumentMatchers.any()
@@ -92,30 +76,22 @@ final class ChainDamageApplierTest {
@Test
void apply_passes_adapter_ref_not_attacker_ref() {
@SuppressWarnings("unchecked")
Ref<EntityStore> attacker = (Ref<EntityStore>) mock(Ref.class, "attacker");
Fixture f = new Fixture();
@SuppressWarnings("unchecked")
Ref<EntityStore> targetRef = (Ref<EntityStore>) mock(Ref.class, "target");
@SuppressWarnings("unchecked")
CommandBuffer<EntityStore> buf = (CommandBuffer<EntityStore>) mock(CommandBuffer.class);
ChainDamageApplier.DamageExecutor executor = mock(ChainDamageApplier.DamageExecutor.class);
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);
ChainDamageApplier.apply(List.of(new ChainHit(adapter, 8, 0)), f.attacker, f.buf, f.executor);
verify(executor, times(1)).execute(
verify(f.executor, times(1)).execute(
refCaptor.capture(),
ArgumentMatchers.eq(buf),
ArgumentMatchers.eq(f.buf),
ArgumentMatchers.any(Damage.class)
);
assertThat(refCaptor.getValue()).isSameAs(targetRef);
assertThat(refCaptor.getValue()).isNotSameAs(attacker);
assertThat(refCaptor.getValue()).isNotSameAs(f.attacker);
}
/** Builds a ChainHit backed by a HytaleEntityAdapter created via forTest to skip Hytale runtime init. */
@@ -125,4 +101,13 @@ final class ChainDamageApplierTest {
HytaleEntityAdapter adapter = HytaleEntityAdapter.forTest(ref, id, Vec3.ZERO, false);
return new ChainHit(adapter, dmg, hop);
}
/** Bundles the three mocks every test needs to keep test bodies focused on assertions. */
private static final class Fixture {
@SuppressWarnings("unchecked")
final Ref<EntityStore> attacker = (Ref<EntityStore>) mock(Ref.class, "attacker");
@SuppressWarnings("unchecked")
final CommandBuffer<EntityStore> buf = (CommandBuffer<EntityStore>) mock(CommandBuffer.class);
final ChainDamageApplier.DamageExecutor executor = mock(ChainDamageApplier.DamageExecutor.class);
}
}