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) : + *

    + *
  1. Ray-cast → cible primaire ou empty.
  2. + *
  3. Boucle BFS plus-proche-voisin jusqu'à maxTargets ou plus aucun candidat.
  4. + *
  5. Tie-breaker déterministe sur id() lexicographique.
  6. + *
  7. Anti-double-hit via Set<String> visited.
  8. + *
+ * + *

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