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