feat(phase-2): pure-Java chain resolution algorithm
Adds com.mythlane.chainlightning.chain package with 7 types (Vec3, ChainEntity, EntitySource, RayCaster, ChainParameters, ChainHit, ChainResolver). Algorithm: ray-cast primary target then BFS hops with distanceSquared closest-neighbor selection, deterministic lexicographic tie-breaker on entity id, max 5 targets, 8-block radius, damage curve [8,6,4,3,2]. Strict no-Hytale-imports boundary — runtime adapters land in Phase 3. JUnit 5 suite: 25 tests green (Vec3 5 + ChainParameters 10 + ChainResolver 10). All 10 mandatory cases covered (no primary, primary-only, full chain, overflow, out-of-radius, no-double-hit, closest, tie-breaker determinism, dead entity, custom maxTargets).
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package com.mythlane.chainlightning.chain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
final class ChainParametersTest {
|
||||
|
||||
@Test
|
||||
void default_matchesSpecV1() {
|
||||
ChainParameters p = ChainParameters.DEFAULT;
|
||||
assertEquals(5, p.maxTargets());
|
||||
assertEquals(8.0, p.chainRadius(), 1e-9);
|
||||
assertArrayEquals(new int[]{8, 6, 4, 3, 2}, p.damageCurve());
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_clonesIncomingDamageCurve_externalMutationHasNoEffect() {
|
||||
int[] curve = new int[]{8, 6, 4, 3, 2};
|
||||
ChainParameters p = new ChainParameters(5, 8.0, curve);
|
||||
curve[0] = 999;
|
||||
assertEquals(8, p.damageCurve()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void accessor_returnsDefensiveCopy_externalMutationHasNoEffect() {
|
||||
ChainParameters p = ChainParameters.DEFAULT;
|
||||
int[] copy = p.damageCurve();
|
||||
copy[0] = 999;
|
||||
assertEquals(8, p.damageCurve()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_damageCurveTooShort_throws() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ChainParameters(5, 8.0, new int[]{8, 6, 4}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_maxTargetsZero_throws() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ChainParameters(0, 8.0, new int[]{8, 6, 4, 3, 2}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_negativeMaxTargets_throws() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ChainParameters(-1, 8.0, new int[]{8, 6, 4, 3, 2}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_chainRadiusZero_throws() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ChainParameters(5, 0.0, new int[]{8, 6, 4, 3, 2}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_negativeChainRadius_throws() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ChainParameters(5, -1.0, new int[]{8, 6, 4, 3, 2}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_nullDamageCurve_throws() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ChainParameters(5, 8.0, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_customMaxTargets2_acceptsCurveOfLength2() {
|
||||
ChainParameters p = new ChainParameters(2, 8.0, new int[]{10, 5});
|
||||
assertEquals(2, p.maxTargets());
|
||||
assertArrayEquals(new int[]{10, 5}, p.damageCurve());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.mythlane.chainlightning.chain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static com.mythlane.chainlightning.chain.TestChainEntity.dead;
|
||||
import static com.mythlane.chainlightning.chain.TestChainEntity.entity;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
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;
|
||||
|
||||
/** RayCaster qui retourne toujours empty. */
|
||||
private static RayCaster rayMisses() {
|
||||
return (o, d, max) -> Optional.empty();
|
||||
}
|
||||
|
||||
/** RayCaster qui retourne toujours la même entité. */
|
||||
private static RayCaster rayHits(ChainEntity e) {
|
||||
return (o, d, max) -> Optional.of(e);
|
||||
}
|
||||
|
||||
/** EntitySource qui retourne toujours la même liste de candidats (filtre par radius côté resolver). */
|
||||
private static EntitySource neighborsAlways(List<ChainEntity> candidates) {
|
||||
return (origin, radius) -> candidates;
|
||||
}
|
||||
|
||||
/** EntitySource vide. */
|
||||
private static EntitySource neighborsEmpty() {
|
||||
return (origin, radius) -> List.of();
|
||||
}
|
||||
|
||||
// 1
|
||||
@Test
|
||||
void resolve_noPrimaryHit_returnsEmpty() {
|
||||
List<ChainHit> hits = ChainResolver.resolve(
|
||||
ORIGIN, DIR, RAY_MAX, rayMisses(), neighborsEmpty(), ChainParameters.DEFAULT);
|
||||
assertTrue(hits.isEmpty());
|
||||
}
|
||||
|
||||
// 2
|
||||
@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);
|
||||
assertEquals(1, hits.size());
|
||||
assertSame(primary, hits.get(0).target());
|
||||
assertEquals(8, hits.get(0).damageHp());
|
||||
assertEquals(0, hits.get(0).hopIndex());
|
||||
}
|
||||
|
||||
// 3
|
||||
@Test
|
||||
void resolve_fullChainOfFive_appliesDamageCurveAndOrder() {
|
||||
// Chaîne linéaire e0→e1→e2→e3→e4 espacés de 2 blocs sur l'axe X. Chaque entité a comme
|
||||
// voisins TOUS les autres ; le resolver choisit toujours le plus proche non visité.
|
||||
ChainEntity e0 = entity("e0", 10, 0, 0);
|
||||
ChainEntity e1 = entity("e1", 12, 0, 0);
|
||||
ChainEntity e2 = entity("e2", 14, 0, 0);
|
||||
ChainEntity e3 = entity("e3", 16, 0, 0);
|
||||
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);
|
||||
|
||||
assertEquals(5, hits.size());
|
||||
assertSame(e0, hits.get(0).target()); assertEquals(8, hits.get(0).damageHp()); assertEquals(0, hits.get(0).hopIndex());
|
||||
assertSame(e1, hits.get(1).target()); assertEquals(6, hits.get(1).damageHp()); assertEquals(1, hits.get(1).hopIndex());
|
||||
assertSame(e2, hits.get(2).target()); assertEquals(4, hits.get(2).damageHp()); assertEquals(2, hits.get(2).hopIndex());
|
||||
assertSame(e3, hits.get(3).target()); assertEquals(3, hits.get(3).damageHp()); assertEquals(3, hits.get(3).hopIndex());
|
||||
assertSame(e4, hits.get(4).target()); assertEquals(2, hits.get(4).damageHp()); assertEquals(4, hits.get(4).hopIndex());
|
||||
}
|
||||
|
||||
// 4
|
||||
@Test
|
||||
void resolve_moreThanFiveCandidates_stopsAtMaxTargets() {
|
||||
// 10 candidats alignés à 1 bloc d'écart — on doit s'arrêter à 5
|
||||
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<ChainHit> hits = ChainResolver.resolve(
|
||||
ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsAlways(all), ChainParameters.DEFAULT);
|
||||
|
||||
assertEquals(5, hits.size());
|
||||
}
|
||||
|
||||
// 5
|
||||
@Test
|
||||
void resolve_candidatesOutsideRadius_excluded() {
|
||||
// Primary à origine, 3 candidats à 9 blocs (> radius 8) → aucun hop possible
|
||||
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<ChainHit> hits = ChainResolver.resolve(
|
||||
ORIGIN, DIR, RAY_MAX, rayHits(primary),
|
||||
neighborsAlways(List.of(far1, far2, far3)),
|
||||
ChainParameters.DEFAULT);
|
||||
|
||||
assertEquals(1, hits.size());
|
||||
assertSame(primary, hits.get(0).target());
|
||||
}
|
||||
|
||||
// 6
|
||||
@Test
|
||||
void resolve_noDoubleHit_visitedExcluded() {
|
||||
// A et B mutuellement voisins ; chaîne A→B et ne doit PAS revenir à A
|
||||
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);
|
||||
|
||||
assertEquals(2, hits.size());
|
||||
assertSame(a, hits.get(0).target());
|
||||
assertSame(b, hits.get(1).target());
|
||||
}
|
||||
|
||||
// 7
|
||||
@Test
|
||||
void resolve_picksClosestCandidate() {
|
||||
// Primary à origine ; un candidat à 3 blocs et un à 7 blocs → le plus proche choisi
|
||||
ChainEntity primary = entity("p", 0, 0, 0);
|
||||
ChainEntity near = entity("near", 3, 0, 0);
|
||||
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}));
|
||||
|
||||
assertEquals(2, hits.size());
|
||||
assertSame(near, hits.get(1).target());
|
||||
}
|
||||
|
||||
// 8
|
||||
@Test
|
||||
void resolve_tieBreaker_deterministicByEntityId() {
|
||||
// Deux candidats EXACTEMENT à la même distance (5) du primary
|
||||
ChainEntity primary = entity("p", 0, 0, 0);
|
||||
ChainEntity zebra = entity("zebra", 5, 0, 0);
|
||||
ChainEntity alpha = entity("alpha", 0, 5, 0);
|
||||
|
||||
// 100 runs avec ordre d'insertion variable → toujours alpha (id lexico < zebra)
|
||||
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}));
|
||||
List<ChainHit> h2 = ChainResolver.resolve(
|
||||
ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsAlways(order2),
|
||||
new ChainParameters(2, 8.0, new int[]{8, 6}));
|
||||
assertSame(alpha, h1.get(1).target(), "run " + i + " order1");
|
||||
assertSame(alpha, h2.get(1).target(), "run " + i + " order2");
|
||||
}
|
||||
}
|
||||
|
||||
// 9
|
||||
@Test
|
||||
void resolve_deadEntity_excluded() {
|
||||
// Primary à origine ; voisin mort à 2 blocs, voisin vivant à 4 blocs → choisit le vivant
|
||||
ChainEntity primary = entity("p", 0, 0, 0);
|
||||
ChainEntity deadOne = dead("dead", 2, 0, 0);
|
||||
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}));
|
||||
|
||||
assertEquals(2, hits.size());
|
||||
assertSame(alive, hits.get(1).target());
|
||||
}
|
||||
|
||||
// 10
|
||||
@Test
|
||||
void resolve_customMaxTargets_truncatesEarly() {
|
||||
// 5 candidats disponibles mais maxTargets=3 → 3 hits
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
assertEquals(3, hits.size());
|
||||
assertEquals(10, hits.get(0).damageHp());
|
||||
assertEquals(5, hits.get(1).damageHp());
|
||||
assertEquals(1, hits.get(2).damageHp());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.mythlane.chainlightning.chain;
|
||||
|
||||
/**
|
||||
* Helper test record implémentant ChainEntity. Permet construction concise dans
|
||||
* les tests : entity("e1", 0, 0, 0) pour un vivant, dead("e2", 5, 0, 0) pour un mort.
|
||||
*/
|
||||
record TestChainEntity(String id, Vec3 position, boolean isAlive) implements ChainEntity {
|
||||
|
||||
static TestChainEntity entity(String id, double x, double y, double z) {
|
||||
return new TestChainEntity(id, new Vec3(x, y, z), true);
|
||||
}
|
||||
|
||||
static TestChainEntity dead(String id, double x, double y, double z) {
|
||||
return new TestChainEntity(id, new Vec3(x, y, z), false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.mythlane.chainlightning.chain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
final class Vec3Test {
|
||||
|
||||
@Test
|
||||
void distanceSquared_zeroToZero_isZero() {
|
||||
Vec3 a = new Vec3(0, 0, 0);
|
||||
assertEquals(0.0, a.distanceSquared(a), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void distanceSquared_pythagorean345_is25() {
|
||||
Vec3 a = new Vec3(0, 0, 0);
|
||||
Vec3 b = new Vec3(3, 4, 0);
|
||||
assertEquals(25.0, a.distanceSquared(b), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void distanceSquared_isCommutative() {
|
||||
Vec3 a = new Vec3(1, 2, 3);
|
||||
Vec3 b = new Vec3(-4, 5, 6);
|
||||
assertEquals(a.distanceSquared(b), b.distanceSquared(a), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void distance_pythagorean345_is5() {
|
||||
Vec3 a = new Vec3(0, 0, 0);
|
||||
Vec3 b = new Vec3(3, 4, 0);
|
||||
assertEquals(5.0, a.distance(b), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void distanceSquared_negativeCoordinates_works() {
|
||||
Vec3 a = new Vec3(-1, -1, -1);
|
||||
Vec3 b = new Vec3(1, 1, 1);
|
||||
assertEquals(12.0, a.distanceSquared(b), 1e-9);
|
||||
assertTrue(a.distance(b) > 3.4 && a.distance(b) < 3.5);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user