refactor: drop unused VFX scaffolding, single-line English doc, reduce debug logs

- Remove unused chain/ParticleTrail and chain/VolumeCurve (dead since the EntityEffect
  pivot replaced the per-particle emit path) plus their test suites.
- Drop Vec3.lerp (only consumer was ParticleTrail).
- Strip step-by-step "[N/9]" debug logs from the orchestrator and per-entity logs
  from HytaleEntitySource / HytalePlayerRayCaster / ChainDamageApplier; keep one
  summary log per click and warnings on failure.
- Extract resolveChain and tryEmitVfx helpers in ChainLightningSceptreInteraction
  so firstRun reads top-down (cooldown gate -> resolve -> damage -> vfx -> deduct).
- Translate every Java doc/comment to single-line English.

Tests: 30/30 green (29 baseline kept + 1 chain damage adapter test).
Build: ./gradlew shadowJar clean.
This commit is contained in:
2026-04-27 19:17:47 +02:00
parent ee9ac1ab53
commit 03754a0646
22 changed files with 118 additions and 683 deletions
@@ -7,24 +7,16 @@ import com.mythlane.chainlightning.sceptre.ChainLightningSceptreInteraction;
import java.util.logging.Level;
/**
* Entry point for the Chain Lightning Sceptre plugin.
*
* Phase 1 scope:
* - Register the {@code ChainLightningSceptre} interaction codec so the runtime
* can dispatch primary/secondary clicks on the chain_lightning_sceptre item.
* - No chain resolution, no VFX, no config — those land in Phases 2/3/4.
*/
/** Plugin entry point that registers the ChainLightningSceptre interaction codec. */
public class ChainLightningPlugin extends JavaPlugin {
public ChainLightningPlugin(JavaPluginInit init) {
super(init);
}
/** The interaction key must match the Type field in chain_lightning_sceptre_click.json. */
@Override
protected void setup() {
// The string "ChainLightningSceptre" MUST match the "Type" field in
// Server/Item/Interactions/chain_lightning_sceptre_click.json (case-sensitive).
getCodecRegistry(Interaction.CODEC).register(
"ChainLightningSceptre",
ChainLightningSceptreInteraction.class,
@@ -1,9 +1,6 @@
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.
*/
/** Minimal chain target contract — stable, mockable, no Hytale dependency. */
public interface ChainEntity {
String id();
Vec3 position();
@@ -1,11 +1,5 @@
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
*/
/** Resolved chain strike — hop 0 is the ray-cast primary target. */
public record ChainHit(ChainEntity target, int damageHp, int hopIndex) {
}
@@ -2,17 +2,9 @@ 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.
*/
/** Frozen chain-resolution parameters; defensively copies the damage curve to keep the record immutable. */
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});
@@ -33,7 +25,7 @@ public record ChainParameters(int maxTargets, double chainRadius, int[] damageCu
damageCurve = Arrays.copyOf(damageCurve, damageCurve.length);
}
/** Retourne une copie défensive du tableau de dégâts. */
/** Defensive copy so callers cannot mutate the internal array. */
@Override
public int[] damageCurve() {
return Arrays.copyOf(damageCurve, damageCurve.length);
@@ -1,37 +1,17 @@
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.
*/
/** Pure stateless BFS that builds the chain from a primary ray-cast hit, without any Hytale dependency. */
public final class ChainResolver {
private ChainResolver() {
// utility class — instantiation interdite
}
private ChainResolver() {}
/**
* 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.
*/
/** Resolves the full chain; returns the primary plus up to maxTargets-1 nearest unique neighbors. */
public static List<ChainHit> resolve(
Vec3 shooterOrigin,
Vec3 shooterDirection,
@@ -53,37 +33,36 @@ public final class ChainResolver {
hits.add(new ChainHit(primary, damageCurve[0], 0));
visited.add(primary.id());
double radiusSq = params.chainRadius() * params.chainRadius();
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;
}
}
}
ChainEntity next = nearestUnvisited(neighbors.nearby(current.position(), params.chainRadius()),
current.position(), radiusSq, visited);
if (next == null) {
break; // chaîne terminée plus tôt
break;
}
hits.add(new ChainHit(next, damageCurve[hopIndex], hopIndex));
visited.add(next.id());
}
return List.copyOf(hits);
}
/** Lexicographic id() tie-breaker keeps the BFS deterministic across ties. */
private static ChainEntity nearestUnvisited(List<ChainEntity> candidates, Vec3 from, double radiusSq, Set<String> visited) {
ChainEntity best = null;
double bestDistSq = Double.POSITIVE_INFINITY;
for (ChainEntity c : candidates) {
if (!c.isAlive() || visited.contains(c.id())) continue;
double d = c.position().distanceSquared(from);
if (d > radiusSq) continue;
if (d < bestDistSq) {
bestDistSq = d;
best = c;
} else if (d == bestDistSq && best != null && c.id().compareTo(best.id()) < 0) {
best = c;
}
}
return best;
}
}
@@ -2,10 +2,7 @@ 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.
*/
/** SAM that returns nearby entities; lets tests inject a synthetic graph and Phase 3 wire the spatial query. */
@FunctionalInterface
public interface EntitySource {
List<ChainEntity> nearby(Vec3 origin, double radius);
@@ -1,58 +0,0 @@
package com.mythlane.chainlightning.chain;
import java.util.ArrayList;
import java.util.List;
/**
* Pure-Java sampler for the electric trail between two endpoints.
* <p>
* Produces evenly-spaced interpolated points strictly between {@code from} and
* {@code to}; both endpoints are excluded by construction (the parametric
* interpolation parameter {@code t} never reaches 0 or 1).
* <p>
* Density is expressed in particles per block. The sample count for a given
* segment is {@code ceil(distance * density)}.
* <p>
* This class has ZERO Hytale imports — it lives behind the Phase 2 sealed
* frontier so it can be unit-tested without runtime dependencies.
*/
public final class ParticleTrail {
/** Default trail density in particles per block. Reduced from 4.0 (post 14:37 UAT) to keep
* total per-click emit count low enough that the client doesn't throttle at the particle
* budget. With 5-hop chain at 5 blocks each: 1.0 = ~25 arcs total instead of ~100. */
public static final double TRAIL_DENSITY = 1.0;
private ParticleTrail() {
// utility class — no instances
}
/**
* Sample interpolated points strictly between {@code from} and {@code to}.
* Endpoints are NOT included in the returned list. The list is freshly
* allocated on each call.
*
* @param from start endpoint (excluded from output)
* @param to end endpoint (excluded from output)
* @param density particles per block
* @return newly-allocated list of interpolated points; empty if the
* endpoints coincide or {@code density} produces a non-positive
* count.
*/
public static List<Vec3> sample(Vec3 from, Vec3 to, double density) {
double dist = from.distance(to);
if (dist <= 0) {
return List.of();
}
int count = (int) Math.ceil(dist * density);
if (count <= 0) {
return List.of();
}
List<Vec3> out = new ArrayList<>(count);
for (int i = 1; i <= count; i++) {
double t = (double) i / (count + 1);
out.add(from.lerp(to, t));
}
return out;
}
}
@@ -2,11 +2,7 @@ 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.
*/
/** SAM that returns the first entity hit along a ray, or empty. */
@FunctionalInterface
public interface RayCaster {
Optional<ChainEntity> firstHit(Vec3 origin, Vec3 direction, double maxBlocks);
@@ -1,16 +1,11 @@
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.
*/
/** Immutable 3D position used by the pure resolver, independent from the Hytale runtime. */
public record Vec3(double x, double y, double z) {
/** Origine (0, 0, 0) — utilisée comme placeholder par HytalePlayerRayCaster. */
public static final Vec3 ZERO = new Vec3(0.0, 0.0, 0.0);
/** Distance euclidienne au carré. Préférer cette méthode dans les boucles. */
/** Squared euclidean distance, preferred in hot loops to avoid sqrt. */
public double distanceSquared(Vec3 other) {
double dx = this.x - other.x;
double dy = this.y - other.y;
@@ -18,17 +13,8 @@ public record Vec3(double x, double y, double z) {
return dx * dx + dy * dy + dz * dz;
}
/** Distance euclidienne. Implique un sqrt — éviter dans les hot loops. */
/** Euclidean distance — only call outside of hot loops. */
public double distance(Vec3 other) {
return Math.sqrt(distanceSquared(other));
}
/** Linear interpolation: t=0 returns this, t=1 returns other. */
public Vec3 lerp(Vec3 other, double t) {
return new Vec3(
this.x + (other.x - this.x) * t,
this.y + (other.y - this.y) * t,
this.z + (other.z - this.z) * t
);
}
}
@@ -1,40 +0,0 @@
package com.mythlane.chainlightning.chain;
/**
* Pure-Java volume curve for the chain-lightning sound emission.
* <p>
* Each successive hop in the chain emits its sound at a progressively lower
* volume, per VFX-02. The curve is hard-coded to the spec values
* {@code [1.0, 0.8, 0.6, 0.5, 0.4]} indexed by hop number.
* <p>
* This class has ZERO Hytale imports — Phase 2 sealed-frontier rule preserved.
*/
public final class VolumeCurve {
/** Spec curve from VFX-02. Order and values are exact. */
private static final float[] CURVE = {1.0f, 0.8f, 0.6f, 0.5f, 0.4f};
private VolumeCurve() {
// utility class — no instances
}
/**
* Volume for a given hop index in the chain.
* <ul>
* <li>Indices 0..4 return the spec values {@code 1.0, 0.8, 0.6, 0.5, 0.4}.</li>
* <li>Indices &ge; 5 clamp to the last value {@code 0.4f}.</li>
* <li>Negative indices throw {@link IllegalArgumentException}.</li>
* </ul>
*
* @param hopIndex zero-based hop index
* @return volume scalar in the range (0, 1]
* @throws IllegalArgumentException if {@code hopIndex < 0}
*/
public static float volumeFor(int hopIndex) {
if (hopIndex < 0) {
throw new IllegalArgumentException("hopIndex must be >= 0, got " + hopIndex);
}
int clamped = Math.min(hopIndex, CURVE.length - 1);
return CURVE[clamped];
}
}
@@ -11,34 +11,15 @@ import com.mythlane.chainlightning.chain.ChainHit;
import javax.annotation.Nonnull;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Helper static qui applique {@link DamageSystems#executeDamage} pour chaque hit d'une chaine resolue.
*
* <p>Utilise {@link DamageCause#PHYSICAL} pour resoudre l'index au moment de l'appel (runtime).
* L'index est lu depuis l'asset map via {@code DamageCause.PHYSICAL} -- initialise par EntityModule
* au boot serveur, pattern identique aux builtins Hytale (DeployableTurretConfig, ProjectileComponent).
*
* <p><b>Pourquoi ne pas hardcoder index=0 :</b> {@code IndexedLookupTableAssetMap} assigne les index
* dans l'ordre de chargement filesystem des JSON -- non-deterministe. Index 0 != PHYSICAL en runtime
* reel (bug chain-no-damage : damage.getCause() retournait null -> NPE silencieuse dans ArmorDamageReduction).
*
* <p>Cast explicite {@code ChainHit.target() -> HytaleEntityAdapter} : Phase 3 garantit que c'est
* la SEULE implementation de ChainEntity produite par les adapters Phase 3.
*
* <p><b>Testabilite :</b> l'overload a {@link DamageExecutor} permet d'injecter un stub en test
* sans avoir a initialiser le runtime Hytale (DamageSystems possede un initialiseur statique
* dependant de PluginBase/HytaleLogger). En contexte de test, {@code DamageCause.PHYSICAL} est null ;
* {@link #physicalDamageCauseIndex()} retourne alors 0 comme index neutre (non verifie par les tests).
*/
/** Applies DamageSystems.executeDamage to each chain hit; injectable executor keeps unit tests off the Hytale runtime. */
public final class ChainDamageApplier {
private static final Logger LOGGER = Logger.getLogger(ChainDamageApplier.class.getName());
/**
* SAM injectable pour l'application des degats -- permet de stubber DamageSystems en test.
*/
/** SAM seam used to swap DamageSystems for a stub in tests. */
@FunctionalInterface
public interface DamageExecutor {
void execute(@Nonnull Ref<EntityStore> target,
@@ -46,33 +27,22 @@ public final class ChainDamageApplier {
@Nonnull Damage damage);
}
/**
* Executeur par defaut delegant a DamageSystems.executeDamage (utilise en production).
* Charge-holder pattern : DamageSystems n'est initialise que quand DEFAULT est acces,
* ce qui evite son chargement a l'initialisation de ChainDamageApplier en contexte de test.
*/
/** Lazy holder keeps DamageSystems.&lt;clinit&gt; out of the test classpath. */
@SuppressWarnings("deprecation")
private static final class DefaultHolder {
static final DamageExecutor INSTANCE = DamageSystems::executeDamage;
}
/** Retourne l'executeur par defaut (DamageSystems.executeDamage). Lazy-init. */
/** Production executor that delegates to DamageSystems.executeDamage. */
public static DamageExecutor defaultExecutor() {
return DefaultHolder.INSTANCE;
}
/**
* Resout l'index de la cause PHYSICAL au moment de l'appel.
*
* <p>En runtime Hytale, {@code DamageCause.PHYSICAL} est initialise par EntityModule au boot.
* En contexte de test unitaire (pas de runtime), il est null -- on retourne 0 comme index
* neutre (non interprete par les tests qui mockent le DamageExecutor).
*/
/** Resolves the PHYSICAL cause index at call time so the runtime asset map ordering is honored. */
@SuppressWarnings("deprecation")
static int physicalDamageCauseIndex() {
DamageCause physical = DamageCause.PHYSICAL;
if (physical == null) {
// Contexte de test : DamageCause.PHYSICAL non initialise, index neutre.
return 0;
}
return DamageCause.getAssetMap().getIndex(physical.getId());
@@ -80,14 +50,7 @@ public final class ChainDamageApplier {
private ChainDamageApplier() {}
/**
* Applique les degats via l'executeur par defaut (DamageSystems.executeDamage).
*
* @param hits liste resolue par ChainResolver, dans l'ordre BFS
* @param attacker ref du joueur declenchant la chaine (source des degats)
* @param commandBuffer buffer de commandes du tick courant
* @param accessor ComponentAccessor (souvent identique au commandBuffer)
*/
/** Production entry point — uses the default executor. */
public static void apply(@Nonnull List<ChainHit> hits,
@Nonnull Ref<EntityStore> attacker,
@Nonnull CommandBuffer<EntityStore> commandBuffer,
@@ -95,44 +58,29 @@ public final class ChainDamageApplier {
apply(hits, attacker, commandBuffer, defaultExecutor());
}
/**
* Applique les degats via un executeur injecte -- utilise en test pour eviter
* l'initialisation du runtime Hytale (DamageSystems.&lt;clinit&gt;).
*
* @param hits liste resolue par ChainResolver, dans l'ordre BFS
* @param attacker ref du joueur declenchant la chaine (source des degats)
* @param commandBuffer buffer de commandes du tick courant
* @param executor executeur de degats (DEFAULT en prod, stub en test)
*/
/** Test seam — accepts a stub executor to avoid initializing DamageSystems. */
public static void apply(@Nonnull List<ChainHit> hits,
@Nonnull Ref<EntityStore> attacker,
@Nonnull CommandBuffer<EntityStore> commandBuffer,
@Nonnull DamageExecutor executor) {
int causeIndex = physicalDamageCauseIndex();
LOGGER.info(String.format("[ChainLightning][Damage] apply START hits=%d attacker=ref:%d causeIndex=%d (PHYSICAL)",
hits.size(), attacker.getIndex(), causeIndex));
for (int i = 0; i < hits.size(); i++) {
ChainHit hit = hits.get(i);
int succeeded = 0;
for (ChainHit hit : hits) {
HytaleEntityAdapter adapter = (HytaleEntityAdapter) hit.target();
Ref<EntityStore> targetRef = adapter.ref();
// Utilise l'overload int avec l'index resolu depuis DamageCause.PHYSICAL au runtime.
// En runtime : causeIndex = index reel de Physical dans l'asset map (deterministe).
// En test : causeIndex = 0 (neutre, non verifie par les tests unitaires).
Damage damage = new Damage(
new Damage.EntitySource(attacker),
causeIndex,
(float) hit.damageHp()
);
LOGGER.info(String.format("[ChainLightning][Damage] [%d/%d] target=ref:%d (id=%s) amount=%dHP causeIndex=%d -> calling executeDamage...",
i + 1, hits.size(), targetRef.getIndex(), adapter.id(), hit.damageHp(), causeIndex));
try {
executor.execute(targetRef, commandBuffer, damage);
LOGGER.info(String.format("[ChainLightning][Damage] [%d/%d] executeDamage OK", i + 1, hits.size()));
succeeded++;
} catch (Throwable t) {
LOGGER.log(java.util.logging.Level.WARNING,
String.format("[ChainLightning][Damage] [%d/%d] executeDamage THREW", i + 1, hits.size()), t);
LOGGER.log(Level.WARNING,
String.format("[ChainLightning] damage failed on %s", adapter.id()), t);
}
}
LOGGER.info("[ChainLightning][Damage] apply DONE");
LOGGER.fine(String.format("[ChainLightning] damage applied to %d/%d targets", succeeded, hits.size()));
}
}
@@ -18,42 +18,19 @@ import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Phase 3 — Orchestrateur runtime du sceptre Chain Lightning.
*
* <p>Pipeline (per CONTEXT.md "Pipeline firstRun — séquence exacte") :
* <ol>
* <li>Récupérer (ou créer) le cooldown "chain_lightning_sceptre" (4.0s, 1 charge).</li>
* <li>Si {@code hasCooldown(false)} → return silencieux (refus UX).</li>
* <li>Extraire playerRef + commandBuffer depuis InteractionContext.</li>
* <li>Construire HytalePlayerRayCaster + HytaleEntitySource (frontière vers Phase 2).</li>
* <li>Appeler ChainResolver.resolve avec Vec3.ZERO placeholders pour origin/direction
* (la lambda ray-cast les ignore — TargetUtil reconstruit eye-origin en interne).</li>
* <li>Si hits.isEmpty() → log fine + return SANS consommer le cooldown (rater = re-cliquer
* immédiatement permis, decision CONTEXT).</li>
* <li>ChainDamageApplier.apply(hits, playerRef, commandBuffer, commandBuffer).</li>
* <li>cooldown.deductCharge() APRÈS succès (decision CONTEXT : pas de cooldown si rate).</li>
* <li>Log info structuré avec count + ids.</li>
* </ol>
*
* <p>Try/catch global wrappe les étapes 3-9 — toute exception est loggée mais non propagée
* (éviter de crash le tick serveur, decision CONTEXT "Pas de try/catch défensif partout").
*/
/** Runtime orchestrator: cooldown gate, chain resolution, damage, VFX emit, charge deduct. */
public final class ChainLightningSceptreInteraction extends SimpleInstantInteraction {
private static final Logger LOGGER = Logger.getLogger(ChainLightningSceptreInteraction.class.getName());
// --- Constantes cooldown (per RESEARCH Q3) ---
private static final String COOLDOWN_ID = "chain_lightning_sceptre";
private static final float COOLDOWN_TIME = 4.0f;
private static final float[] CHARGE_TIMES = new float[]{4.0f};
private static final boolean FORCE_CREATE = true;
private static final boolean INTERRUPT_RECHARGE = false;
private static final String COOLDOWN_ID = "chain_lightning_sceptre";
private static final float COOLDOWN_TIME = 4.0f;
private static final float[] CHARGE_TIMES = { 4.0f };
private static final boolean FORCE_CREATE = true;
private static final boolean INTERRUPT_RECHARGE = false;
// --- Constantes chaîne (per ROADMAP CHAIN-02 + CHAIN-03) ---
private static final double RAY_MAX_BLOCKS = 25.0;
private static final double RAY_MAX_BLOCKS = 25.0;
// --- BuilderCodec préservé tel quel depuis Phase 1 ---
@Nonnull
public static final BuilderCodec<ChainLightningSceptreInteraction> CODEC =
((BuilderCodec.Builder) BuilderCodec
@@ -67,101 +44,57 @@ public final class ChainLightningSceptreInteraction extends SimpleInstantInterac
public ChainLightningSceptreInteraction() {
}
/** Runs the chain pipeline once per click; silently no-ops while on cooldown. */
@Override
protected void firstRun(@Nonnull InteractionType type,
@Nonnull InteractionContext context,
@Nonnull CooldownHandler cooldownHandler) {
LOGGER.info(String.format("[ChainLightning][1/9] firstRun ENTRY type=%s", type));
// --- Étape 1 : récupérer le cooldown ---
CooldownHandler.Cooldown cooldown = cooldownHandler.getCooldown(
COOLDOWN_ID, COOLDOWN_TIME, CHARGE_TIMES, FORCE_CREATE, INTERRUPT_RECHARGE);
if (cooldown == null) {
LOGGER.warning("[ChainLightning][1/9] cooldown handler returned null — aborting");
if (cooldown == null || cooldown.hasCooldown(false)) {
return;
}
LOGGER.info(String.format("[ChainLightning][1/9] cooldown obtenu id=%s maxTime=%.1fs", COOLDOWN_ID, COOLDOWN_TIME));
// --- Étape 2 : check cooldown sans décompter ---
boolean onCooldown = cooldown.hasCooldown(false);
LOGGER.info(String.format("[ChainLightning][2/9] hasCooldown(false)=%s", onCooldown));
if (onCooldown) {
LOGGER.info("[ChainLightning][2/9] still on cooldown — silent refuse");
Ref<EntityStore> playerRef = context.getEntity();
CommandBuffer<EntityStore> commandBuffer = context.getCommandBuffer();
if (playerRef == null || commandBuffer == null) {
return;
}
try {
// --- Étape 3 : extraire player + commandBuffer ---
Ref<EntityStore> playerRef = context.getEntity();
CommandBuffer<EntityStore> commandBuffer = context.getCommandBuffer();
LOGGER.info(String.format("[ChainLightning][3/9] playerRef=%s commandBuffer=%s",
playerRef == null ? "null" : ("ref:" + playerRef.getIndex() + " valid=" + playerRef.isValid()),
commandBuffer == null ? "null" : commandBuffer.getClass().getSimpleName()));
if (playerRef == null || commandBuffer == null) {
LOGGER.warning("[ChainLightning][3/9] missing playerRef or commandBuffer — abort");
return;
}
// --- Étape 4 : construire les adapters ---
// CommandBuffer<EntityStore> implémente ComponentAccessor<EntityStore> — passé directement
HytalePlayerRayCaster ray = new HytalePlayerRayCaster(playerRef, commandBuffer);
HytaleEntitySource neighbors = new HytaleEntitySource(commandBuffer);
LOGGER.info("[ChainLightning][4/9] adapters built (RayCaster + EntitySource)");
// --- Étape 5 : résolution BFS (origin/direction = placeholders ignorés par le wrapper) ---
LOGGER.info(String.format("[ChainLightning][5/9] resolving chain rayMax=%.1f maxTargets=%d radius=%.1f",
RAY_MAX_BLOCKS, ChainParameters.DEFAULT.maxTargets(), ChainParameters.DEFAULT.chainRadius()));
List<ChainHit> hits = ChainResolver.resolve(
Vec3.ZERO, Vec3.ZERO, RAY_MAX_BLOCKS,
ray, neighbors, ChainParameters.DEFAULT
);
LOGGER.info(String.format("[ChainLightning][5/9] resolution returned %d hits", hits.size()));
// --- Étape 6 : pas de cible → return SANS cooldown ---
List<ChainHit> hits = resolveChain(playerRef, commandBuffer);
if (hits.isEmpty()) {
LOGGER.info("[ChainLightning][6/9] no target — re-click immediately allowed (no cooldown deducted)");
return;
}
// Détail des hits avant damage
for (int i = 0; i < hits.size(); i++) {
ChainHit h = hits.get(i);
LOGGER.info(String.format("[ChainLightning][6/9] hit[%d] target=%s damageHp=%d hopIndex=%d",
i, h.target().id(), h.damageHp(), h.hopIndex()));
}
// --- Étape 7 : appliquer les dégâts ---
LOGGER.info(String.format("[ChainLightning][7/9] applying damage to %d targets (attacker=ref:%d)",
hits.size(), playerRef.getIndex()));
ChainDamageApplier.apply(hits, playerRef, commandBuffer, commandBuffer);
LOGGER.info("[ChainLightning][7/9] damage application returned");
// --- Étape 7.5 : émettre VFX/SFX (best-effort) ---
// CONTEXT failure-mode decision : damage déjà appliqué — si l'emit échoue, log + continue
// vers le cooldown. Pas de propagation : le tick serveur ne doit pas crash sur un bug VFX.
try {
LOGGER.info(String.format("[ChainLightning][7.5/9] vfx emit START hits=%d", hits.size()));
HytaleVfxEmitter.playChainEffects(hits, playerRef, commandBuffer);
LOGGER.info("[ChainLightning][7.5/9] vfx emit DONE");
} catch (Throwable t) {
LOGGER.log(Level.WARNING, "[ChainLightning][7.5/9] vfx emit failed (damage already applied)", t);
}
// --- Étape 8 : démarrer le cooldown APRÈS succès ---
tryEmitVfx(hits, playerRef, commandBuffer);
cooldown.deductCharge();
LOGGER.info(String.format("[ChainLightning][8/9] cooldown deducted (next available in %.1fs)", COOLDOWN_TIME));
// --- Étape 9 : log structuré final ---
StringBuilder ids = new StringBuilder();
for (int i = 0; i < hits.size(); i++) {
if (i > 0) ids.append(',');
ids.append(hits.get(i).target().id());
}
LOGGER.info(String.format("[ChainLightning][9/9] DONE ref:%d chained %d targets [%s]",
playerRef.getIndex(), hits.size(), ids.toString()));
LOGGER.info(String.format("[ChainLightning] ref:%d chained %d targets",
playerRef.getIndex(), hits.size()));
} catch (Throwable t) {
// CONTEXT decision : try/catch global pour éviter crash tick serveur
LOGGER.log(Level.WARNING, "[ChainLightning] chain resolution failed", t);
LOGGER.log(Level.WARNING, "[ChainLightning] chain pipeline failed", t);
}
}
/** Builds the Hytale-bound adapters and runs the pure resolver against the live world. */
private static List<ChainHit> resolveChain(@Nonnull Ref<EntityStore> playerRef,
@Nonnull CommandBuffer<EntityStore> commandBuffer) {
HytalePlayerRayCaster ray = new HytalePlayerRayCaster(playerRef, commandBuffer);
HytaleEntitySource neighbors = new HytaleEntitySource(commandBuffer);
return ChainResolver.resolve(
Vec3.ZERO, Vec3.ZERO, RAY_MAX_BLOCKS,
ray, neighbors, ChainParameters.DEFAULT
);
}
/** VFX emit is best-effort: damage is already applied so a failure must not abort the cooldown step. */
private static void tryEmitVfx(@Nonnull List<ChainHit> hits,
@Nonnull Ref<EntityStore> playerRef,
@Nonnull CommandBuffer<EntityStore> commandBuffer) {
try {
HytaleVfxEmitter.playChainEffects(hits, playerRef, commandBuffer);
} catch (Throwable t) {
LOGGER.log(Level.WARNING, "[ChainLightning] vfx emit failed (damage already applied)", t);
}
}
}
@@ -13,16 +13,7 @@ import com.mythlane.chainlightning.chain.Vec3;
import javax.annotation.Nonnull;
/**
* Adapter immuable Ref&lt;EntityStore&gt; -&gt; ChainEntity (Phase 2 SAM).
*
* <p>Snapshot eager : la position et l'etat alive sont lus AU MOMENT de la creation.
* La resolution BFS de ChainResolver lit ces valeurs figees -- robuste face a un mob
* qui bouge ou meurt pendant la resolution.
*
* <p>Si TransformComponent est null (entite hors monde), l'adapter retourne un snapshot
* "mort" (alive=false, position=Vec3.ZERO) qui sera filtre par ChainResolver.
*/
/** Immutable Ref&lt;EntityStore&gt; -&gt; ChainEntity adapter; eager snapshot keeps BFS robust to mid-tick entity changes. */
public final class HytaleEntityAdapter implements ChainEntity {
private final Ref<EntityStore> ref;
@@ -37,23 +28,13 @@ public final class HytaleEntityAdapter implements ChainEntity {
this.alive = alive;
}
/**
* Constructeur package-private pour les tests : permet de construire un adapter
* sans passer par snapshot() (qui initialise TransformComponent.getComponentType()
* et donc le runtime Hytale complet).
*/
/** Test-only factory that bypasses Hytale TransformComponent initialization. */
static HytaleEntityAdapter forTest(@Nonnull Ref<EntityStore> ref, @Nonnull String id,
@Nonnull Vec3 position, boolean alive) {
return new HytaleEntityAdapter(ref, id, position, alive);
}
/**
* Projette un Ref&lt;EntityStore&gt; vers un ChainEntity en lisant TransformComponent + EntityStatMap.
*
* @param ref reference entite Hytale
* @param accessor ComponentAccessor (CommandBuffer implemente ComponentAccessor)
* @return adapter snapshot. Jamais null.
*/
/** Reads TransformComponent + EntityStatMap once and freezes the result for the chain resolver. */
@Nonnull
public static HytaleEntityAdapter snapshot(@Nonnull Ref<EntityStore> ref,
@Nonnull ComponentAccessor<EntityStore> accessor) {
@@ -73,24 +54,13 @@ public final class HytaleEntityAdapter implements ChainEntity {
return new HytaleEntityAdapter(ref, id, vec, alive);
}
/** Reference Hytale sous-jacente, exposee pour DamageSystems.executeDamage. */
/** Underlying Hytale ref, exposed so DamageSystems and EffectControllerComponent can address the entity. */
@Nonnull
public Ref<EntityStore> ref() {
return ref;
}
@Override
public String id() {
return id;
}
@Override
public Vec3 position() {
return position;
}
@Override
public boolean isAlive() {
return alive;
}
@Override public String id() { return id; }
@Override public Vec3 position() { return position; }
@Override public boolean isAlive() { return alive; }
}
@@ -12,21 +12,10 @@ import com.mythlane.chainlightning.chain.Vec3;
import javax.annotation.Nonnull;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Logger;
/**
* Implementation Phase 3 de {@link EntitySource} qui delegue a
* {@link TargetUtil#getAllEntitiesInSphere}.
*
* <p><b>Note importante :</b> {@code getAllEntitiesInSphere} retourne une liste THREAD-LOCALE
* (SpatialResource.getThreadLocalReferenceList). On la consomme immediatement en mappant chaque
* ref vers un {@link HytaleEntityAdapter} dans une nouvelle ArrayList -- la liste retournee est
* sure a conserver entre frames.
*/
/** Phase 3 EntitySource that copies TargetUtil's thread-local result into a stable list of snapshots. */
public final class HytaleEntitySource implements EntitySource {
private static final Logger LOGGER = Logger.getLogger(HytaleEntitySource.class.getName());
private final ComponentAccessor<EntityStore> accessor;
public HytaleEntitySource(@Nonnull ComponentAccessor<EntityStore> accessor) {
@@ -36,21 +25,14 @@ public final class HytaleEntitySource implements EntitySource {
@Override
public List<ChainEntity> nearby(Vec3 origin, double radius) {
Vector3d hytaleOrigin = new Vector3d(origin.x(), origin.y(), origin.z());
LOGGER.info(String.format("[ChainLightning][EntitySource] getAllEntitiesInSphere(origin=%s, radius=%.1f)",
origin, radius));
List<Ref<EntityStore>> refs = TargetUtil.getAllEntitiesInSphere(hytaleOrigin, radius, accessor);
LOGGER.info(String.format("[ChainLightning][EntitySource] Hytale returned %d refs", refs == null ? -1 : refs.size()));
if (refs == null) {
return new ArrayList<>();
if (refs == null || refs.isEmpty()) {
return List.of();
}
List<ChainEntity> snapshots = new ArrayList<>(refs.size());
for (Ref<EntityStore> ref : refs) {
ChainEntity adapter = HytaleEntityAdapter.snapshot(ref, accessor);
LOGGER.info(String.format("[ChainLightning][EntitySource] ref:%d -> id=%s pos=%s alive=%s",
ref.getIndex(), adapter.id(), adapter.position(), adapter.isAlive()));
snapshots.add(adapter);
snapshots.add(HytaleEntityAdapter.snapshot(ref, accessor));
}
LOGGER.info(String.format("[ChainLightning][EntitySource] returning %d snapshots", snapshots.size()));
return snapshots;
}
}
@@ -10,23 +10,10 @@ import com.mythlane.chainlightning.chain.Vec3;
import javax.annotation.Nonnull;
import java.util.Optional;
import java.util.logging.Logger;
/**
* Implementation Phase 3 de {@link RayCaster} qui delegue a {@link TargetUtil#getTargetEntity}.
*
* <p><b>Note importante :</b> les parametres {@code origin} et {@code direction} de
* {@link #firstHit} sont IGNORES. {@code TargetUtil.getTargetEntity} reconstruit lui-meme
* l'origine yeux + direction du regard depuis le {@code playerRef} (TransformComponent +
* ModelComponent.eyeHeight + HeadRotation lus en interne via {@code TargetUtil.getLook}).
*
* <p>Cette asymetrie est volontaire : preserve la SAM Phase 2 sans modification, tout en
* laissant Hytale calculer l'origine yeux precise.
*/
/** Phase 3 RayCaster delegating to TargetUtil; origin/direction args are ignored since TargetUtil derives them from playerRef. */
public final class HytalePlayerRayCaster implements RayCaster {
private static final Logger LOGGER = Logger.getLogger(HytalePlayerRayCaster.class.getName());
private final Ref<EntityStore> playerRef;
private final ComponentAccessor<EntityStore> accessor;
@@ -38,18 +25,10 @@ public final class HytalePlayerRayCaster implements RayCaster {
@Override
public Optional<ChainEntity> firstHit(Vec3 originIgnored, Vec3 directionIgnored, double maxBlocks) {
LOGGER.info(String.format("[ChainLightning][RayCast] TargetUtil.getTargetEntity(playerRef=ref:%d, maxBlocks=%.1f)",
playerRef.getIndex(), maxBlocks));
Ref<EntityStore> target = TargetUtil.getTargetEntity(playerRef, (float) maxBlocks, accessor);
if (target == null) {
LOGGER.info("[ChainLightning][RayCast] no entity hit — returning Optional.empty()");
return Optional.empty();
}
LOGGER.info(String.format("[ChainLightning][RayCast] HIT target=ref:%d valid=%s — snapshotting",
target.getIndex(), target.isValid()));
ChainEntity adapter = HytaleEntityAdapter.snapshot(target, accessor);
LOGGER.info(String.format("[ChainLightning][RayCast] snapshot id=%s pos=%s alive=%s",
adapter.id(), adapter.position(), adapter.isAlive()));
return Optional.of(adapter);
return Optional.of(HytaleEntityAdapter.snapshot(target, accessor));
}
}
@@ -12,45 +12,16 @@ import javax.annotation.Nonnull;
import java.util.List;
import java.util.logging.Logger;
/**
* Phase 4 — Adapter VFX/SFX qui applique un EntityEffect custom à chaque cible touchée.
*
* <p><b>Pivot 14:52 (POC EntityEffect bridge) :</b> les tentatives précédentes via
* {@code ParticleUtil.spawnParticleEffect("Splash", ...)} ne rendaient pas les particles côté
* client (asset sync plugin custom + budget client → 0/5 visible). On utilise maintenant le
* pattern canonique Cleric-Rod : on déclare un {@code EntityEffect} JSON (Server/Entity/Effects/
* Chain_Hit_Effect.json) qui contient des particles vanilla inline + EntityTopTint/BottomTint,
* et on applique cet effect à chaque target via {@link EffectControllerComponent#addEffect}.
*
* <p>Le rendu passe par la réplication ECS (le ECS state du target propage l'effet aux clients
* automatiquement), pas par un packet SpawnParticleSystem standalone. C'est le path éprouvé.
*
* <p><b>Fallback :</b> si le lookup de l'EntityEffect échoue (asset pas chargé), on log et skip.
* Pas de propagation : damage déjà appliqué côté caller, l'emit failure ne doit pas crash.
*
* <p><b>Hop-index ignoré pour POC :</b> l'EntityEffect est uniforme sur les 5 cibles. La courbe
* de volume (VFX-02) reste implémentée dans {@code VolumeCurve} mais n'est pas utilisée ici --
* une variante future pourrait définir 5 EntityEffects {@code Chain_Hit_Effect_0..4} avec des
* intensités décroissantes, ou patcher l'EntityEffect existant runtime (non supporté par l'API).
*/
/** Applies a custom EntityEffect to each chain target so the visual replicates via ECS sync. */
public final class HytaleVfxEmitter {
private static final Logger LOGGER = Logger.getLogger(HytaleVfxEmitter.class.getName());
/** Asset id du EntityEffect appliqué à chaque target touchée par la chaîne. */
private static final String EFFECT_ID = "Chain_Hit_Effect";
private HytaleVfxEmitter() {}
/**
* Applique l'EntityEffect {@code Chain_Hit_Effect} à chaque hit de la chaîne.
*
* @param hits liste résolue par ChainResolver, ordre BFS
* @param playerRef ref du caster (présent pour symétrie d'API future, non utilisé ici --
* l'EntityEffect rend automatiquement à tous les viewers en range)
* @param commandBuffer le CommandBuffer du tick courant (sert pour {@code getComponent} et
* pour propager les changes d'état ECS au tick end)
*/
/** Resolves the EntityEffect once and adds it to every valid target via EffectControllerComponent. */
public static void playChainEffects(@Nonnull List<ChainHit> hits,
@Nonnull Ref<EntityStore> playerRef,
@Nonnull CommandBuffer<EntityStore> commandBuffer) {
@@ -17,27 +17,22 @@ final class ChainResolverTest {
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(
@@ -45,7 +40,6 @@ final class ChainResolverTest {
assertTrue(hits.isEmpty());
}
// 2
@Test
void resolve_primaryOnly_noNeighbors_returnsSingleHit() {
ChainEntity primary = entity("p", 10, 0, 0);
@@ -57,11 +51,8 @@ final class ChainResolverTest {
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);
@@ -80,10 +71,8 @@ final class ChainResolverTest {
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);
@@ -102,10 +91,8 @@ final class ChainResolverTest {
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);
@@ -120,10 +107,8 @@ final class ChainResolverTest {
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);
@@ -137,10 +122,8 @@ final class ChainResolverTest {
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);
@@ -154,15 +137,12 @@ final class ChainResolverTest {
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);
@@ -177,10 +157,8 @@ final class ChainResolverTest {
}
}
// 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);
@@ -194,10 +172,8 @@ final class ChainResolverTest {
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);
@@ -1,82 +0,0 @@
package com.mythlane.chainlightning.chain;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class ParticleTrailTest {
private static final double EPS = 1e-9;
@Test
void zeroDistance() {
Vec3 p = new Vec3(2, 3, 4);
List<Vec3> out = ParticleTrail.sample(p, p, 4.0);
assertTrue(out.isEmpty());
}
@Test
void subBlockDistance() {
// distance 0.5 along X with density 4 → ceil(2.0) = 2
Vec3 from = new Vec3(0, 0, 0);
Vec3 to = new Vec3(0.5, 0, 0);
List<Vec3> out = ParticleTrail.sample(from, to, 4.0);
assertEquals(2, out.size());
// Both points strictly between endpoints
for (Vec3 v : out) {
assertTrue(from.distanceSquared(v) > EPS, "sample equals from");
assertTrue(to.distanceSquared(v) > EPS, "sample equals to");
}
}
@Test
void integerDistanceCount() {
Vec3 from = new Vec3(0, 0, 0);
Vec3 to = new Vec3(5, 0, 0);
List<Vec3> out = ParticleTrail.sample(from, to, 4.0);
assertEquals(20, out.size());
}
@Test
void fractionalDistanceCount() {
Vec3 from = new Vec3(0, 0, 0);
Vec3 to = new Vec3(3.7, 0, 0);
List<Vec3> out = ParticleTrail.sample(from, to, 4.0);
// ceil(3.7 * 4) = ceil(14.8) = 15
assertEquals(15, out.size());
}
@Test
void endpointsExcluded() {
Vec3 from = new Vec3(1, 2, 3);
Vec3 to = new Vec3(7, 8, 9);
List<Vec3> out = ParticleTrail.sample(from, to, 4.0);
assertTrue(out.size() > 0);
for (Vec3 v : out) {
assertTrue(from.distanceSquared(v) > EPS, "sample equals from endpoint");
assertTrue(to.distanceSquared(v) > EPS, "sample equals to endpoint");
}
}
@Test
void uniformSpacing() {
Vec3 from = new Vec3(0, 0, 0);
Vec3 to = new Vec3(5, 0, 0);
List<Vec3> out = ParticleTrail.sample(from, to, 4.0);
assertEquals(20, out.size());
double total = from.distance(to);
double expectedSpacing = total / (out.size() + 1);
// from -> first
assertEquals(expectedSpacing, from.distance(out.get(0)), EPS);
// last -> to
assertEquals(expectedSpacing, out.get(out.size() - 1).distance(to), EPS);
// consecutive
for (int i = 1; i < out.size(); i++) {
assertEquals(expectedSpacing, out.get(i - 1).distance(out.get(i)), EPS);
}
}
}
@@ -1,9 +1,6 @@
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.
*/
/** Test-only ChainEntity record with concise factories for alive and dead entities. */
record TestChainEntity(String id, Vec3 position, boolean isAlive) implements ChainEntity {
static TestChainEntity entity(String id, double x, double y, double z) {
@@ -41,34 +41,4 @@ final class Vec3Test {
assertEquals(12.0, a.distanceSquared(b), 1e-9);
assertTrue(a.distance(b) > 3.4 && a.distance(b) < 3.5);
}
@Test
void lerpAtZeroReturnsFrom() {
Vec3 from = Vec3.ZERO;
Vec3 to = new Vec3(10, 10, 10);
Vec3 result = from.lerp(to, 0.0);
assertEquals(from.x(), result.x(), 1e-9);
assertEquals(from.y(), result.y(), 1e-9);
assertEquals(from.z(), result.z(), 1e-9);
}
@Test
void lerpAtOneReturnsTo() {
Vec3 from = Vec3.ZERO;
Vec3 to = new Vec3(10, 10, 10);
Vec3 result = from.lerp(to, 1.0);
assertEquals(to.x(), result.x(), 1e-9);
assertEquals(to.y(), result.y(), 1e-9);
assertEquals(to.z(), result.z(), 1e-9);
}
@Test
void lerpAtHalfReturnsMidpoint() {
Vec3 from = new Vec3(0, 0, 0);
Vec3 to = new Vec3(10, 0, 0);
Vec3 result = from.lerp(to, 0.5);
assertEquals(5.0, result.x(), 1e-9);
assertEquals(0.0, result.y(), 1e-9);
assertEquals(0.0, result.z(), 1e-9);
}
}
@@ -1,31 +0,0 @@
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.assertThrows;
final class VolumeCurveTest {
@Test
void specValues() {
assertEquals(1.0f, VolumeCurve.volumeFor(0), 1e-6);
assertEquals(0.8f, VolumeCurve.volumeFor(1), 1e-6);
assertEquals(0.6f, VolumeCurve.volumeFor(2), 1e-6);
assertEquals(0.5f, VolumeCurve.volumeFor(3), 1e-6);
assertEquals(0.4f, VolumeCurve.volumeFor(4), 1e-6);
}
@Test
void indexClamp() {
assertEquals(0.4f, VolumeCurve.volumeFor(5), 1e-6);
assertEquals(0.4f, VolumeCurve.volumeFor(99), 1e-6);
assertEquals(0.4f, VolumeCurve.volumeFor(Integer.MAX_VALUE), 1e-6);
}
@Test
void negativeIndexThrows() {
assertThrows(IllegalArgumentException.class, () -> VolumeCurve.volumeFor(-1));
assertThrows(IllegalArgumentException.class, () -> VolumeCurve.volumeFor(Integer.MIN_VALUE));
}
}
@@ -1,7 +1,6 @@
package com.mythlane.chainlightning.sceptre;
import com.hypixel.hytale.component.CommandBuffer;
import com.hypixel.hytale.component.ComponentAccessor;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
@@ -9,6 +8,7 @@ import com.mythlane.chainlightning.chain.ChainHit;
import com.mythlane.chainlightning.chain.Vec3;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import java.util.List;
@@ -18,13 +18,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* Test unitaire de ChainDamageApplier -- verifie que chaque ChainHit produit exactement
* un appel a DamageExecutor avec le bon ref + amount.
*
* <p>Utilise l'overload ChainDamageApplier.apply(..., DamageExecutor) pour eviter d'initialiser
* le runtime Hytale (DamageSystems possede un initialiseur statique dependant de PluginBase).
*/
/** Verifies ChainDamageApplier emits one DamageExecutor call per hit; uses the executor seam to skip Hytale runtime init. */
final class ChainDamageApplierTest {
@Test
@@ -46,9 +40,9 @@ final class ChainDamageApplierTest {
ChainDamageApplier.apply(hits, attacker, buf, executor);
verify(executor, times(5)).execute(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(buf),
org.mockito.ArgumentMatchers.any(Damage.class)
ArgumentMatchers.any(),
ArgumentMatchers.eq(buf),
ArgumentMatchers.any(Damage.class)
);
}
@@ -71,8 +65,8 @@ final class ChainDamageApplierTest {
ChainDamageApplier.apply(hits, attacker, buf, executor);
verify(executor, times(3)).execute(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(buf),
ArgumentMatchers.any(),
ArgumentMatchers.eq(buf),
damageCaptor.capture()
);
@@ -90,9 +84,9 @@ final class ChainDamageApplierTest {
ChainDamageApplier.apply(List.of(), attacker, buf, executor);
verify(executor, never()).execute(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any()
ArgumentMatchers.any(),
ArgumentMatchers.any(),
ArgumentMatchers.any()
);
}
@@ -103,13 +97,9 @@ final class ChainDamageApplierTest {
@SuppressWarnings("unchecked")
Ref<EntityStore> targetRef = (Ref<EntityStore>) mock(Ref.class, "target");
@SuppressWarnings("unchecked")
ComponentAccessor<EntityStore> accessor = (ComponentAccessor<EntityStore>) mock(ComponentAccessor.class);
@SuppressWarnings("unchecked")
CommandBuffer<EntityStore> buf = (CommandBuffer<EntityStore>) mock(CommandBuffer.class);
ChainDamageApplier.DamageExecutor executor = mock(ChainDamageApplier.DamageExecutor.class);
// Construire un HytaleEntityAdapter via forTest pour eviter l'initialisation
// du runtime Hytale (TransformComponent.getComponentType() -> PluginBase -> HytaleLogger).
HytaleEntityAdapter adapter = HytaleEntityAdapter.forTest(targetRef, "target", Vec3.ZERO, false);
ChainHit hit = new ChainHit(adapter, 8, 0);
@@ -120,21 +110,18 @@ final class ChainDamageApplierTest {
verify(executor, times(1)).execute(
refCaptor.capture(),
org.mockito.ArgumentMatchers.eq(buf),
org.mockito.ArgumentMatchers.any(Damage.class)
ArgumentMatchers.eq(buf),
ArgumentMatchers.any(Damage.class)
);
assertThat(refCaptor.getValue()).isSameAs(targetRef);
assertThat(refCaptor.getValue()).isNotSameAs(attacker);
}
// --- helper : cree un ChainHit avec un HytaleEntityAdapter mock-friendly ---
/** Builds a ChainHit backed by a HytaleEntityAdapter created via forTest to skip Hytale runtime init. */
private static ChainHit hit(String id, int dmg, int hop) {
@SuppressWarnings("unchecked")
Ref<EntityStore> ref = (Ref<EntityStore>) mock(Ref.class, id);
@SuppressWarnings("unchecked")
ComponentAccessor<EntityStore> accessor = (ComponentAccessor<EntityStore>) mock(ComponentAccessor.class);
// Utiliser forTest pour eviter l'initialisation du runtime Hytale (TransformComponent -> PluginBase).
HytaleEntityAdapter adapter = HytaleEntityAdapter.forTest(ref, id, Vec3.ZERO, false);
return new ChainHit(adapter, dmg, hop);
}