diff --git a/src/main/java/com/mythlane/chainlightning/chain/ChainEntity.java b/src/main/java/com/mythlane/chainlightning/chain/ChainEntity.java
new file mode 100644
index 0000000..765c7e6
--- /dev/null
+++ b/src/main/java/com/mythlane/chainlightning/chain/ChainEntity.java
@@ -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();
+}
diff --git a/src/main/java/com/mythlane/chainlightning/chain/ChainHit.java b/src/main/java/com/mythlane/chainlightning/chain/ChainHit.java
new file mode 100644
index 0000000..e019a0a
--- /dev/null
+++ b/src/main/java/com/mythlane/chainlightning/chain/ChainHit.java
@@ -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) {
+}
diff --git a/src/main/java/com/mythlane/chainlightning/chain/ChainParameters.java b/src/main/java/com/mythlane/chainlightning/chain/ChainParameters.java
new file mode 100644
index 0000000..5869794
--- /dev/null
+++ b/src/main/java/com/mythlane/chainlightning/chain/ChainParameters.java
@@ -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]).
+ *
+ *
Le record clone défensivement le tableau damageCurve à la construction et expose
+ * une copie via {@link #damageCurve()} pour empêcher la mutation externe.
+ *
+ *
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);
+ }
+}
diff --git a/src/main/java/com/mythlane/chainlightning/chain/ChainResolver.java b/src/main/java/com/mythlane/chainlightning/chain/ChainResolver.java
new file mode 100644
index 0000000..6fab78b
--- /dev/null
+++ b/src/main/java/com/mythlane/chainlightning/chain/ChainResolver.java
@@ -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.
+ *
+ *
Algorithme (CHAIN-01 + CHAIN-02) :
+ *
+ * - Ray-cast → cible primaire ou empty.
+ * - Boucle BFS plus-proche-voisin jusqu'à maxTargets ou plus aucun candidat.
+ * - Tie-breaker déterministe sur id() lexicographique.
+ * - Anti-double-hit via Set<String> visited.
+ *
+ *
+ * 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 resolve(
+ Vec3 shooterOrigin,
+ Vec3 shooterDirection,
+ double rayMaxBlocks,
+ RayCaster ray,
+ EntitySource neighbors,
+ ChainParameters params) {
+
+ Optional primaryOpt = ray.firstHit(shooterOrigin, shooterDirection, rayMaxBlocks);
+ if (primaryOpt.isEmpty()) {
+ return List.of();
+ }
+
+ int[] damageCurve = params.damageCurve();
+ ChainEntity primary = primaryOpt.get();
+ List hits = new ArrayList<>(params.maxTargets());
+ Set 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 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);
+ }
+}
diff --git a/src/main/java/com/mythlane/chainlightning/chain/EntitySource.java b/src/main/java/com/mythlane/chainlightning/chain/EntitySource.java
new file mode 100644
index 0000000..420ae2e
--- /dev/null
+++ b/src/main/java/com/mythlane/chainlightning/chain/EntitySource.java
@@ -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 nearby(Vec3 origin, double radius);
+}
diff --git a/src/main/java/com/mythlane/chainlightning/chain/RayCaster.java b/src/main/java/com/mythlane/chainlightning/chain/RayCaster.java
new file mode 100644
index 0000000..db2b4e8
--- /dev/null
+++ b/src/main/java/com/mythlane/chainlightning/chain/RayCaster.java
@@ -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 firstHit(Vec3 origin, Vec3 direction, double maxBlocks);
+}
diff --git a/src/main/java/com/mythlane/chainlightning/chain/Vec3.java b/src/main/java/com/mythlane/chainlightning/chain/Vec3.java
new file mode 100644
index 0000000..28417f4
--- /dev/null
+++ b/src/main/java/com/mythlane/chainlightning/chain/Vec3.java
@@ -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));
+ }
+}
diff --git a/src/test/java/com/mythlane/chainlightning/chain/ChainParametersTest.java b/src/test/java/com/mythlane/chainlightning/chain/ChainParametersTest.java
new file mode 100644
index 0000000..0bea2b0
--- /dev/null
+++ b/src/test/java/com/mythlane/chainlightning/chain/ChainParametersTest.java
@@ -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());
+ }
+}
diff --git a/src/test/java/com/mythlane/chainlightning/chain/ChainResolverTest.java b/src/test/java/com/mythlane/chainlightning/chain/ChainResolverTest.java
new file mode 100644
index 0000000..9ef1bcd
--- /dev/null
+++ b/src/test/java/com/mythlane/chainlightning/chain/ChainResolverTest.java
@@ -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 candidates) {
+ return (origin, radius) -> candidates;
+ }
+
+ /** EntitySource vide. */
+ private static EntitySource neighborsEmpty() {
+ return (origin, radius) -> List.of();
+ }
+
+ // 1
+ @Test
+ void resolve_noPrimaryHit_returnsEmpty() {
+ List 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 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 all = List.of(e0, e1, e2, e3, e4);
+
+ List 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 all = List.of(primary, e1, e2, e3, e4, e5, e6, e7, e8, e9);
+
+ List 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 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 mutual = List.of(a, b);
+
+ ChainParameters p = new ChainParameters(5, 8.0, new int[]{8, 6, 4, 3, 2});
+ List 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 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 order1 = List.of(zebra, alpha);
+ List order2 = List.of(alpha, zebra);
+ List h1 = ChainResolver.resolve(
+ ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsAlways(order1),
+ new ChainParameters(2, 8.0, new int[]{8, 6}));
+ List 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 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 all = List.of(e0, e1, e2, e3, e4);
+
+ ChainParameters p = new ChainParameters(3, 8.0, new int[]{10, 5, 1});
+ List 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());
+ }
+}
diff --git a/src/test/java/com/mythlane/chainlightning/chain/TestChainEntity.java b/src/test/java/com/mythlane/chainlightning/chain/TestChainEntity.java
new file mode 100644
index 0000000..7da16e3
--- /dev/null
+++ b/src/test/java/com/mythlane/chainlightning/chain/TestChainEntity.java
@@ -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);
+ }
+}
diff --git a/src/test/java/com/mythlane/chainlightning/chain/Vec3Test.java b/src/test/java/com/mythlane/chainlightning/chain/Vec3Test.java
new file mode 100644
index 0000000..85542f1
--- /dev/null
+++ b/src/test/java/com/mythlane/chainlightning/chain/Vec3Test.java
@@ -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);
+ }
+}