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:
2026-04-26 19:29:20 +02:00
parent edca00fa4a
commit cd5d0bedd3
11 changed files with 553 additions and 0 deletions
@@ -0,0 +1,11 @@
package com.mythlane.chainlightning.chain;
/**
* Contrat minimal d'une cible de chaîne. Stable, mockable, sans dépendance Hytale.
* Phase 3 adaptera l'entité Hytale vers ChainEntity à la frontière.
*/
public interface ChainEntity {
String id();
Vec3 position();
boolean isAlive();
}
@@ -0,0 +1,11 @@
package com.mythlane.chainlightning.chain;
/**
* Une frappe résolue de la chaîne. Hop 0 = cible primaire (ray-cast).
*
* @param target entité touchée
* @param damageHp dégâts à appliquer (issus de ChainParameters.damageCurve[hopIndex])
* @param hopIndex position dans la chaîne, 0-indexed
*/
public record ChainHit(ChainEntity target, int damageHp, int hopIndex) {
}
@@ -0,0 +1,41 @@
package com.mythlane.chainlightning.chain;
import java.util.Arrays;
/**
* Paramètres figés de la résolution de chaîne. Spec v1 : DEFAULT = (5, 8.0, [8,6,4,3,2]).
*
* <p>Le record clone défensivement le tableau damageCurve à la construction et expose
* une copie via {@link #damageCurve()} pour empêcher la mutation externe.
*
* <p>Implémente CHAIN-03 (courbe de dégâts) — D-CHAIN-03 dans CONTEXT.md.
*/
public record ChainParameters(int maxTargets, double chainRadius, int[] damageCurve) {
/** Configuration v1 figée par spec : 5 cibles max, rayon 8 blocs, damages [8,6,4,3,2]. */
public static final ChainParameters DEFAULT =
new ChainParameters(5, 8.0, new int[]{8, 6, 4, 3, 2});
public ChainParameters {
if (maxTargets <= 0) {
throw new IllegalArgumentException("maxTargets must be > 0, got " + maxTargets);
}
if (chainRadius <= 0.0) {
throw new IllegalArgumentException("chainRadius must be > 0, got " + chainRadius);
}
if (damageCurve == null) {
throw new IllegalArgumentException("damageCurve must not be null");
}
if (damageCurve.length < maxTargets) {
throw new IllegalArgumentException(
"damageCurve.length (" + damageCurve.length + ") must be >= maxTargets (" + maxTargets + ")");
}
damageCurve = Arrays.copyOf(damageCurve, damageCurve.length);
}
/** Retourne une copie défensive du tableau de dégâts. */
@Override
public int[] damageCurve() {
return Arrays.copyOf(damageCurve, damageCurve.length);
}
}
@@ -0,0 +1,89 @@
package com.mythlane.chainlightning.chain;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* Résolveur pur stateless de la chaîne d'éclair.
*
* <p>Algorithme (CHAIN-01 + CHAIN-02) :
* <ol>
* <li>Ray-cast → cible primaire ou empty.</li>
* <li>Boucle BFS plus-proche-voisin jusqu'à maxTargets ou plus aucun candidat.</li>
* <li>Tie-breaker déterministe sur id() lexicographique.</li>
* <li>Anti-double-hit via Set&lt;String&gt; visited.</li>
* </ol>
*
* <p>Aucun side-effect — fonction pure. Aucune dépendance sur le runtime Hytale.
*/
public final class ChainResolver {
private ChainResolver() {
// utility class — instantiation interdite
}
/**
* Résout la chaîne complète à partir du tir initial.
*
* @return liste immuable de hits dans l'ordre de la chaîne (hop 0 = primary).
* Empty si ray-cast ne touche rien.
*/
public static List<ChainHit> resolve(
Vec3 shooterOrigin,
Vec3 shooterDirection,
double rayMaxBlocks,
RayCaster ray,
EntitySource neighbors,
ChainParameters params) {
Optional<ChainEntity> primaryOpt = ray.firstHit(shooterOrigin, shooterDirection, rayMaxBlocks);
if (primaryOpt.isEmpty()) {
return List.of();
}
int[] damageCurve = params.damageCurve();
ChainEntity primary = primaryOpt.get();
List<ChainHit> hits = new ArrayList<>(params.maxTargets());
Set<String> visited = new HashSet<>();
hits.add(new ChainHit(primary, damageCurve[0], 0));
visited.add(primary.id());
for (int hopIndex = 1; hopIndex < params.maxTargets(); hopIndex++) {
ChainEntity current = hits.get(hits.size() - 1).target();
List<ChainEntity> candidates = neighbors.nearby(current.position(), params.chainRadius());
ChainEntity next = null;
double bestDistSq = Double.POSITIVE_INFINITY;
for (ChainEntity c : candidates) {
if (!c.isAlive()) continue;
if (visited.contains(c.id())) continue;
double d = c.position().distanceSquared(current.position());
if (d > params.chainRadius() * params.chainRadius()) continue;
if (d < bestDistSq) {
bestDistSq = d;
next = c;
} else if (d == bestDistSq && next != null) {
// tie-breaker lexicographique sur id()
if (c.id().compareTo(next.id()) < 0) {
next = c;
}
}
}
if (next == null) {
break; // chaîne terminée plus tôt
}
hits.add(new ChainHit(next, damageCurve[hopIndex], hopIndex));
visited.add(next.id());
}
return List.copyOf(hits);
}
}
@@ -0,0 +1,12 @@
package com.mythlane.chainlightning.chain;
import java.util.List;
/**
* Source d'entités voisines. SAM permettant aux tests de fournir un graphe synthétique
* et à Phase 3 de brancher la spatial query Hytale.
*/
@FunctionalInterface
public interface EntitySource {
List<ChainEntity> nearby(Vec3 origin, double radius);
}
@@ -0,0 +1,13 @@
package com.mythlane.chainlightning.chain;
import java.util.Optional;
/**
* Ray-cast retournant la première entité touchée le long du rayon, ou empty.
* SAM permettant aux tests de fournir un résultat fixe et à Phase 3 de brancher
* l'API Hytale réelle.
*/
@FunctionalInterface
public interface RayCaster {
Optional<ChainEntity> firstHit(Vec3 origin, Vec3 direction, double maxBlocks);
}
@@ -0,0 +1,22 @@
package com.mythlane.chainlightning.chain;
/**
* Position 3D pure-Java, indépendante de l'API Hytale.
* Utilisée par ChainResolver pour calculs de distance ; comparaisons en distanceSquared
* pour éviter les sqrt dans la boucle BFS.
*/
public record Vec3(double x, double y, double z) {
/** Distance euclidienne au carré. Préférer cette méthode dans les boucles. */
public double distanceSquared(Vec3 other) {
double dx = this.x - other.x;
double dy = this.y - other.y;
double dz = this.z - other.z;
return dx * dx + dy * dy + dz * dz;
}
/** Distance euclidienne. Implique un sqrt — éviter dans les hot loops. */
public double distance(Vec3 other) {
return Math.sqrt(distanceSquared(other));
}
}
@@ -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);
}
}