Compare commits

..

12 Commits

Author SHA1 Message Date
kayjaydee d67b8a7667 feat: add README.md for Hytale ChainLightning Sceptre plugin
- Introduced a comprehensive README.md detailing the ChainLightning Sceptre plugin for Hytale.
- Included sections on functionality, quick start instructions, development setup, known limitations, and a call for collaboration.
- Highlighted the proof of concept nature of the project and the need for visual and audio asset contributions.

Build: README added successfully.
2026-04-28 08:59:00 +02:00
kayjaydee cc3bb767f7 feat: enhance HytaleVfxEmitter to spawn Splash particles and apply tint effects
- Updated HytaleVfxEmitter to emit Splash particles directly using ParticleUtil, ensuring client-side effect deduplication is bypassed.
- Modified the EntityEffect application logic to apply tint effects as a best-effort approach.
- Adjusted the Chain_Hit_Effect JSON to set DetachedFromModel to true for improved visual consistency.

Tests: All tests passing. Build: ./gradlew clean build successful.
2026-04-28 08:40:32 +02:00
kayjaydee 51a19d5f62 fix: update ChainLightningSceptreInteraction to handle only secondary interactions and adjust Chain_Hit_Effect duration
- Added a check in ChainLightningSceptreInteraction to return early if the interaction type is not Secondary.
- Reduced the duration of Chain_Hit_Effect from 0.6 to 0.05 for improved gameplay balance.
- Removed the primary interaction from chain_lightning_sceptre.json to streamline item functionality.

Tests: All tests passing. Build: ./gradlew clean build successful.
2026-04-28 08:30:58 +02:00
kayjaydee f6ca35bfc4 refactor: simplify ChainResolver and RayCaster interfaces, enhance ChainDamageApplier logic
- Updated ChainResolver to remove unused shooterOrigin and shooterDirection parameters, streamlining the resolve method.
- Modified RayCaster interface to reflect changes in method signature, focusing on maxBlocks only.
- Enhanced ChainDamageApplier to utilize a new CauseIndexHolder for resolving the PHYSICAL damage cause index, improving clarity and performance.
- Refactored ChainDamageApplier.apply method to use HytaleEntityAdapter for target references, ensuring correct damage application.
- Adjusted tests in ChainResolverTest and ChainDamageApplierTest to align with the new method signatures and logic.

Tests: All tests passing. Build: ./gradlew clean build successful.
2026-04-28 08:20:07 +02:00
kayjaydee 03754a0646 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.
2026-04-27 19:17:47 +02:00
kayjaydee ee9ac1ab53 feat(phase-4): VFX via EntityEffect bridge — chain hits glow blue
Working POC visual : chaque cible touchée par la chaîne reçoit l'EntityEffect
`Chain_Hit_Effect` (Server/Entity/Effects/) appliqué via
`EffectControllerComponent.addEffect`. L'effet contient EntityTopTint/BottomTint
bleu + un Splash particle scale 4 tinted, duration 0.6s.

## Pourquoi pas ParticleUtil.spawnParticleEffect

Tentatives extensives via le path standalone SpawnParticleSystem packet (3-arg
auto-broadcast et 7-arg explicit playerRef) ont échoué — particles invisibles
côté client malgré delivery confirmée serveur. Pattern Java→custom particle
non supporté en l'état Hytale 2026.03.26-89796e57b (asset sync plugin custom
pas wired). Vanilla "Splash" via le path standalone : 0/5 visible.

## EntityEffect = path ECS replication

`EffectControllerComponent.addEffect` ajoute un `ActiveEntityEffect` au target,
propagé aux clients via la sync ECS automatique (path Cleric-Rod / canonique).
Le client lookup l'EntityEffect par index dans son map (broadcast au connect)
et applique ApplicationEffects (tints + particles inline) sur le modèle de
l'entity. C'est ce path qui rend.

## Limitations POC

- VFX-02 (atténuation sonore) non livré : SoundEvent custom subit le même
  problème de sync que ParticleSystem custom. Path standalone SoundUtil
  également cassé pour assets plugin. EntityEffect a un WorldSoundEventId
  field qu'on pourrait remplir avec un vanilla, deferred.
- VolumeCurve.java + ParticleTrail.java conservés (33 tests JUnit verts) mais
  inutilisés runtime — la courbe de volume était hop-indexed et l'EntityEffect
  est uniforme. Garder pour usage futur si Hytale fix le sync.
- TRAIL_DENSITY abaissé 4.0 → 1.0 (réduction du spam packet pendant les
  itérations diagnostic, pas critique vu qu'inutilisé).
- Phase 1 items en snake_case toujours warns au boot — pas bloquant, fix
  cosmétique reporté.

## Files

NEW : `src/main/resources/Server/Entity/Effects/Chain_Hit_Effect.json`
NEW : `src/main/java/com/mythlane/chainlightning/sceptre/HytaleVfxEmitter.java`
      (réécrit pour utiliser EffectControllerComponent.addEffect)
MOD : `ChainLightningSceptreInteraction.java` étape 7.5 — passe playerRef
MOD : `ParticleTrail.TRAIL_DENSITY` 4.0 → 1.0
DEL : `Server/Particles/Chain_Spark.{particlesystem,particlespawner}` +
      `Common/Particles/Chain_Spark.png` (dropped — inutilisés par EntityEffect
      qui référence vanilla Splash)
.gitignore : ignore *.zip + note (asset source temporaires)

Tests : 41/41 verts (29 baseline + 12 Phase 4 pure-Java).
Build : `./gradlew shadowJar` clean.
UAT 17:05 : confirmé visuel — mobs glow bleu sur hit chain.
2026-04-27 19:08:39 +02:00
kayjaydee 4ffa0e28ef merge(04-01): pure ParticleTrail + VolumeCurve + JUnit (Wave 1) 2026-04-27 13:11:00 +02:00
kayjaydee 994f66682c fix(04-00): rename particle assets to PascalCase per Hytale validator
Server WARN: "Asset key 'chain_spark' has incorrect format! Expected: 'Chain_Spark'".
Hytale validator derives expected asset key from filename via PascalCase
normalization. Renamed all three files + internal SpawnerId + Texture refs.

This contradicts the GravityFlip snake_case rule for Particles specifically —
filename casing IS the asset key.
2026-04-27 13:08:19 +02:00
kayjaydee ac4ed623b9 fix(04-00): move particle texture to Common/Particles per validator rule
CommonAssetValidator.TEXTURE_PARTICLES (CommonAssetValidator.java:25)
requires PNGs to live under Common/Particles/, not Server/Particles/.
Server crash log confirmed: "Common Asset 'Particles/chain_spark.png'
doesn't exist!" → cascading ParticleSpawner + ParticleSystem failure.

Spike-relevant finding: assets split into Server/ (engine-side configs:
.particlesystem, .particlespawner) and Common/ (shared textures: PNG).
2026-04-27 13:07:02 +02:00
kayjaydee a4427d91a7 feat(04-00): minimal chain_spark particle assets for codec spike
- Author chain_spark.particlesystem (Spawners[1] -> SpawnerId=chain_spark)
- Author chain_spark.particlespawner (required co-asset, Sphere/BlendAdd, embedded Particle with Animation[0,100])
- Bundle 4x4 placeholder chain_spark.png in Particles/ to satisfy CommonAssetValidator.TEXTURE_PARTICLES
- ShadowJar packages all three at exact case-sensitive path Server/Particles/

Deviation from plan: research assumed only one .particlesystem file needed. Decompiled codec
shows ParticleSpawner is a SEPARATE asset (.particlespawner) referenced by SpawnerId, plus
Particle.Texture is validated against CommonAssetRegistry (must exist as Particles/<name>.png).
Both required for the spike to validate end-to-end. Documented in SPIKE-FINDINGS.
2026-04-27 13:01:40 +02:00
kayjaydee ddc08fb14c feat(04-01): add VolumeCurve with hardcoded VFX-02 spec curve
- VolumeCurve.volumeFor(int) returns spec values [1.0, 0.8, 0.6, 0.5, 0.4]
- High indices clamp to 0.4f (last value)
- Negative indices throw IllegalArgumentException
- 3 JUnit cases (specValues, indexClamp, negativeIndexThrows), all green
- Zero Hytale imports — chain/ frontier preserved
2026-04-27 13:01:38 +02:00
kayjaydee 8d868a28ca feat(04-01): add Vec3.lerp and ParticleTrail sampler with JUnit suite
- Vec3.lerp(other, t) for linear interpolation between two points
- ParticleTrail.sample(from, to, density) emits density-based interpolated
  points strictly between endpoints (endpoints excluded by construction)
- TRAIL_DENSITY = 4.0 particles per block
- 6 ParticleTrail tests + 3 Vec3.lerp tests, all green
- Zero Hytale imports — chain/ frontier preserved
2026-04-27 13:00:47 +02:00
21 changed files with 420 additions and 510 deletions
+4
View File
@@ -55,3 +55,7 @@ logs/
.env
.env.local
*.local
# Phase 4 — temporary asset sources (not committed)
*.zip
note
+127
View File
@@ -0,0 +1,127 @@
# Hytale ChainLightning Sceptre
A magical sceptre for **Hytale** that fires chain lightning at mobs — right-click a target and the bolt jumps to up to 5 nearby enemies within 8 blocks, dealing decreasing damage with every hop.
<p align="center">
<img src="https://img.shields.io/badge/Java-25-orange" alt="Java 25"/>
<img src="https://img.shields.io/badge/Gradle-Shadow-green" alt="Gradle Shadow"/>
<img src="https://img.shields.io/badge/Hytale-2026.03.26-blueviolet" alt="Hytale 2026.03.26"/>
<img src="https://img.shields.io/badge/status-proof--of--concept-yellow" alt="POC"/>
<img src="https://img.shields.io/badge/license-TBD-lightgrey" alt="License"/>
</p>
---
## ⚠️ Proof of concept — and I need help
This is first and foremost a **proof of concept** I built to learn the Hytale Plugin API while it's still in early access. I'm a Java dev, **not a 3D / VFX artist** — I don't know how to author proper custom assets (models, particle systems, SFX). Everything visual here reuses **builtin Hytale assets**:
- The wand model is the builtin `Items/Weapons/Wand/Wood.blockymodel`
- The chain-hit particles use the builtin `Splash` system
- Sounds use the builtin `ISS_Weapons_Wand` set
→ The visuals are placeholder and the plugin is rough around the edges. **If you're a 3D artist, VFX artist, sound designer, or Hytale plugin dev and want to make this look great — please reach out.** Issues, PRs, or a quick email are all welcome.
---
## What Does It Do?
- **Right-click a mob**: chain lightning resolves, jumping up to **5 targets** within **8 blocks** of each previous hop.
- **Damage falls off per hop** (configurable in code, tested via JUnit).
- **Left-click**: normal attack (engine fallback — sceptre still works as a basic weapon).
- **4-second cooldown** between casts.
---
## Quick Start
1. Drop `hytale-chain-lightning-<version>.jar` into your server's `Plugins/` folder.
2. Boot the server.
3. Spawn the item with the standard Hytale `/item give` flow (item id `chain_lightning_sceptre`).
4. Equip, face a mob, **right-click**.
---
## Stack
- **Java 25** (records, sealed types, modern API).
- **Gradle 8 + Shadow** for the fat jar.
- **Hytale Plugin API** core (`Interaction`, `EntityEffect`, `ParticleUtil`, `EffectControllerComponent`).
- **JUnit 5** for the pure chain-resolution algorithm.
---
## How It Works
```
Player right-clicks mob
ChainLightningSceptreInteraction.firstRun(Secondary)
├─ Cooldown gate (4s)
├─ HytalePlayerRayCaster ──> primary target ref
├─ HytaleEntitySource ──> nearby entity snapshot
ChainResolver.resolve() (pure Java, JUnit-tested)
│ BFS over hops, max 5 targets, max 8 blocks/hop
ChainHit list
├─ ChainDamageApplier.apply() ──> dégressive damage per hop
└─ HytaleVfxEmitter.playChainEffects()
├─ ParticleUtil.spawnParticleEffect("Splash", pos) (direct broadcast — bypasses client-side effect dedup)
└─ EffectControllerComponent.addEffect("Chain_Hit_Effect") (blue tint, 0.05s)
```
The chain-resolver is decoupled from Hytale via small interfaces (`RayCaster`, `EntitySource`, `ChainEntity`), so the algorithm is unit-tested without a running server. Hytale-bound adapters live in `sceptre/`.
---
## Known Limitations
- **Splash particles sometimes silent on client** — server logs confirm `5/5` `SpawnParticleSystem` packets per cast every time, but the early-access Hytale renderer appears to throttle/cull duplicate `systemId` particles at nearby positions. Likely a client-side issue, can't fix from the plugin.
- **No custom model** — sceptre reuses the builtin wand. A real 3D model would help a lot.
- **No custom particle system** — `Splash` is functional but not *electric*. A proper arc/branch particle would sell the fantasy.
- **No custom SFX** — uses the generic wand sound set.
---
## Development
* JDK 25, Gradle, Hytale Plugin API (from https://maven.hytale.com/release), local dev server.
* Source: `src/main/java/com/mythlane/chainlightning/`
* Tests: `src/test/java/` (pure algorithm, no server needed).
### Building
```bash
./gradlew shadowJar # fat jar
./gradlew test # unit tests
```
---
## Help Wanted
Genuinely open to help. If any of these match you:
- **3D artist** — design and model a proper sceptre (`.blockymodel`).
- **VFX artist** — author an electric arc / branching lightning particle system that beats `Splash`.
- **Sound designer** — cast / hit / loop SFX.
- **Hytale plugin dev** — review the API usage, suggest better patterns, or pair on the splash-on-client mystery.
Open an issue, send a PR, or email **contact@mythlane.com**.
---
## Credits
Made by [Mythlane](https://mythlane.com)
---
## License
License to be decided. Ping contact@mythlane.com for questions.
@@ -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,46 +1,23 @@
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.
*/
public static List<ChainHit> resolve(
Vec3 shooterOrigin,
Vec3 shooterDirection,
double rayMaxBlocks,
/** Resolves the full chain; returns the primary plus up to maxTargets-1 nearest unique neighbors. */
public static List<ChainHit> resolve(double rayMaxBlocks,
RayCaster ray,
EntitySource neighbors,
ChainParameters params) {
Optional<ChainEntity> primaryOpt = ray.firstHit(shooterOrigin, shooterDirection, rayMaxBlocks);
Optional<ChainEntity> primaryOpt = ray.firstHit(rayMaxBlocks);
if (primaryOpt.isEmpty()) {
return List.of();
}
@@ -53,37 +30,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);
@@ -2,12 +2,8 @@ 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 along the caster's look ray, or empty. */
@FunctionalInterface
public interface RayCaster {
Optional<ChainEntity> firstHit(Vec3 origin, Vec3 direction, double maxBlocks);
Optional<ChainEntity> firstHit(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,7 +13,7 @@ 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));
}
@@ -11,34 +11,16 @@ 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. */
@SuppressWarnings("deprecation")
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,48 +28,34 @@ 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.
*/
@SuppressWarnings("deprecation")
/** Lazy holder keeps DamageSystems.&lt;clinit&gt; out of the test classpath. */
private static final class DefaultHolder {
static final DamageExecutor INSTANCE = DamageSystems::executeDamage;
}
/** Retourne l'executeur par defaut (DamageSystems.executeDamage). Lazy-init. */
/** Lazy holder caches the asset-map index of PHYSICAL once the runtime is initialized. */
private static final class CauseIndexHolder {
static final int VALUE = computeIndex();
private static int computeIndex() {
DamageCause physical = DamageCause.PHYSICAL;
return physical == null ? 0 : DamageCause.getAssetMap().getIndex(physical.getId());
}
}
/** 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).
*/
@SuppressWarnings("deprecation")
/** PHYSICAL cause index resolved from the asset map; falls back to 0 in unit tests where the runtime is not booted. */
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());
return CauseIndexHolder.VALUE;
}
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 +63,25 @@ 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);
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));
Damage.EntitySource source = new Damage.EntitySource(attacker);
int succeeded = 0;
for (ChainHit hit : hits) {
HytaleEntityAdapter adapter = HytaleEntityAdapter.from(hit);
Damage damage = new Damage(source, causeIndex, (float) hit.damageHp());
try {
executor.execute(targetRef, commandBuffer, damage);
LOGGER.info(String.format("[ChainLightning][Damage] [%d/%d] executeDamage OK", i + 1, hits.size()));
executor.execute(adapter.ref(), commandBuffer, damage);
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()));
}
}
@@ -11,49 +11,25 @@ import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import com.mythlane.chainlightning.chain.ChainHit;
import com.mythlane.chainlightning.chain.ChainParameters;
import com.mythlane.chainlightning.chain.ChainResolver;
import com.mythlane.chainlightning.chain.Vec3;
import javax.annotation.Nonnull;
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 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;
// --- BuilderCodec préservé tel quel depuis Phase 1 ---
@Nonnull
public static final BuilderCodec<ChainLightningSceptreInteraction> CODEC =
((BuilderCodec.Builder) BuilderCodec
@@ -67,90 +43,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 (type != InteractionType.Secondary) {
return;
}
CooldownHandler.Cooldown cooldown = cooldownHandler.getCooldown(
COOLDOWN_ID, COOLDOWN_TIME, CHARGE_TIMES, FORCE_CREATE, INTERRUPT_RECHARGE);
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");
List<ChainHit> hits = resolveChain(playerRef, commandBuffer);
if (hits.isEmpty()) {
return;
}
ChainDamageApplier.apply(hits, playerRef, commandBuffer, commandBuffer);
tryEmitVfx(hits, playerRef, commandBuffer);
cooldown.deductCharge();
LOGGER.info(String.format("[ChainLightning] ref:%d chained %d targets",
playerRef.getIndex(), hits.size()));
} catch (Throwable t) {
LOGGER.log(Level.WARNING, "[ChainLightning] chain pipeline failed", t);
}
}
// --- Étape 4 : construire les adapters ---
// CommandBuffer<EntityStore> implémente ComponentAccessor<EntityStore> — passé directement
/** 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);
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 ---
if (hits.isEmpty()) {
LOGGER.info("[ChainLightning][6/9] no target — re-click immediately allowed (no cooldown deducted)");
return;
return ChainResolver.resolve(RAY_MAX_BLOCKS, ray, neighbors, ChainParameters.DEFAULT);
}
// 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 8 : démarrer le cooldown APRÈS succès ---
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()));
/** 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) {
// CONTEXT decision : try/catch global pour éviter crash tick serveur
LOGGER.log(Level.WARNING, "[ChainLightning] chain resolution failed", t);
LOGGER.log(Level.WARNING, "[ChainLightning] vfx emit failed (damage already applied)", t);
}
}
}
@@ -9,20 +9,12 @@ import com.hypixel.hytale.server.core.modules.entitystats.EntityStatValue;
import com.hypixel.hytale.server.core.modules.entitystats.asset.DefaultEntityStatTypes;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import com.mythlane.chainlightning.chain.ChainEntity;
import com.mythlane.chainlightning.chain.ChainHit;
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 +29,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 +55,19 @@ public final class HytaleEntityAdapter implements ChainEntity {
return new HytaleEntityAdapter(ref, id, vec, alive);
}
/** Reference Hytale sous-jacente, exposee pour DamageSystems.executeDamage. */
/** Single cast point so callers stop reaching into ChainEntity to recover the runtime adapter. */
@Nonnull
public static HytaleEntityAdapter from(@Nonnull ChainHit hit) {
return (HytaleEntityAdapter) hit.target();
}
/** 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;
}
}
@@ -6,27 +6,13 @@ import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import com.hypixel.hytale.server.core.util.TargetUtil;
import com.mythlane.chainlightning.chain.ChainEntity;
import com.mythlane.chainlightning.chain.RayCaster;
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; eye origin and look direction are derived from the playerRef inside Hytale. */
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;
@@ -37,19 +23,11 @@ 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));
public Optional<ChainEntity> firstHit(double 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));
}
}
@@ -0,0 +1,64 @@
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.math.vector.Vector3d;
import com.hypixel.hytale.server.core.asset.type.entityeffect.config.EntityEffect;
import com.hypixel.hytale.server.core.entity.effect.EffectControllerComponent;
import com.hypixel.hytale.server.core.universe.world.ParticleUtil;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import com.mythlane.chainlightning.chain.ChainHit;
import com.mythlane.chainlightning.chain.Vec3;
import javax.annotation.Nonnull;
import java.util.List;
import java.util.logging.Logger;
/** Applies tint EntityEffect AND emits Splash particles directly via ParticleUtil to bypass client-side effect dedup. */
public final class HytaleVfxEmitter {
private static final Logger LOGGER = Logger.getLogger(HytaleVfxEmitter.class.getName());
private static final String EFFECT_ID = "Chain_Hit_Effect";
private static final String PARTICLE_ID = "Splash";
private HytaleVfxEmitter() {}
public static void playChainEffects(@Nonnull List<ChainHit> hits,
@Nonnull Ref<EntityStore> playerRef,
@Nonnull CommandBuffer<EntityStore> commandBuffer) {
if (hits.isEmpty()) return;
EntityEffect entityEffect = EntityEffect.getAssetMap().getAsset(EFFECT_ID);
ComponentAccessor<EntityStore> accessor = commandBuffer;
int applied = 0;
for (ChainHit hit : hits) {
Ref<EntityStore> targetRef = HytaleEntityAdapter.from(hit).ref();
if (!targetRef.isValid()) continue;
// Direct particle spawn — fires on every call, no client-side dedup.
Vec3 p = hit.target().position();
Vector3d pos = new Vector3d(p.x(), p.y() + 1.0, p.z());
try {
ParticleUtil.spawnParticleEffect(PARTICLE_ID, pos, accessor);
} catch (Throwable t) {
LOGGER.warning("[ChainLightning][Vfx] particle spawn failed: " + t.getMessage());
}
// Optional tint via EntityEffect (best-effort).
if (entityEffect != null) {
EffectControllerComponent ecc = accessor.getComponent(
targetRef, EffectControllerComponent.getComponentType());
if (ecc != null && ecc.addEffect(targetRef, entityEffect, accessor)) {
applied++;
}
}
}
LOGGER.info(String.format(
"[ChainLightning][Vfx] particles=%d/%d, tint applied=%d (caster=%s)",
hits.size(), hits.size(), applied, playerRef));
}
}
@@ -0,0 +1,19 @@
{
"Name": "entity_effect.chain_lightning.hit",
"Duration": 0.05,
"OverlapBehavior": "Overwrite",
"Debuff": true,
"ApplicationEffects": {
"EntityTopTint": "#B0DCFF",
"EntityBottomTint": "#66B3FF",
"Particles": [
{
"SystemId": "Splash",
"TargetEntityPart": "Entity",
"Scale": 4.0,
"Color": "#B0DCFF",
"DetachedFromModel": true
}
]
}
}
@@ -16,7 +16,6 @@
"Compatible": true
},
"Interactions": {
"Primary": "chain_lightning_sceptre_root",
"Secondary": "chain_lightning_sceptre_root"
},
"IconProperties": {
@@ -13,55 +13,44 @@ 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;
private static final ChainParameters DEFAULT = ChainParameters.DEFAULT;
private static final ChainParameters TWO_HOPS = new ChainParameters(2, 8.0, new int[]{8, 6});
/** RayCaster qui retourne toujours empty. */
private static RayCaster rayMisses() {
return (o, d, max) -> Optional.empty();
return max -> Optional.empty();
}
/** RayCaster qui retourne toujours la même entité. */
private static RayCaster rayHits(ChainEntity e) {
return (o, d, max) -> Optional.of(e);
return max -> Optional.of(e);
}
/** EntitySource qui retourne toujours la même liste de candidats (filtre par radius côté resolver). */
private static EntitySource neighborsAlways(List<ChainEntity> candidates) {
return (origin, radius) -> candidates;
}
/** EntitySource vide. */
private static EntitySource neighborsEmpty() {
return (origin, radius) -> List.of();
}
// 1
@Test
void resolve_noPrimaryHit_returnsEmpty() {
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayMisses(), neighborsEmpty(), ChainParameters.DEFAULT);
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayMisses(), neighborsEmpty(), DEFAULT);
assertTrue(hits.isEmpty());
}
// 2
@Test
void resolve_primaryOnly_noNeighbors_returnsSingleHit() {
ChainEntity primary = entity("p", 10, 0, 0);
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsEmpty(), ChainParameters.DEFAULT);
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(primary), neighborsEmpty(), 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);
@@ -69,8 +58,7 @@ final class ChainResolverTest {
ChainEntity e4 = entity("e4", 18, 0, 0);
List<ChainEntity> all = List.of(e0, e1, e2, e3, e4);
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(e0), neighborsAlways(all), ChainParameters.DEFAULT);
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(e0), neighborsAlways(all), DEFAULT);
assertEquals(5, hits.size());
assertSame(e0, hits.get(0).target()); assertEquals(8, hits.get(0).damageHp()); assertEquals(0, hits.get(0).hopIndex());
@@ -80,134 +68,106 @@ 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);
ChainEntity e3 = entity("e3", 13, 0, 0);
ChainEntity e4 = entity("e4", 14, 0, 0);
ChainEntity e5 = entity("e5", 15, 0, 0);
ChainEntity e6 = entity("e6", 16, 0, 0);
ChainEntity e7 = entity("e7", 17, 0, 0);
ChainEntity e8 = entity("e8", 18, 0, 0);
ChainEntity e9 = entity("e9", 19, 0, 0);
List<ChainEntity> all = List.of(primary, e1, e2, e3, e4, e5, e6, e7, e8, e9);
List<ChainEntity> all = List.of(
primary,
entity("e1", 11, 0, 0),
entity("e2", 12, 0, 0),
entity("e3", 13, 0, 0),
entity("e4", 14, 0, 0),
entity("e5", 15, 0, 0),
entity("e6", 16, 0, 0),
entity("e7", 17, 0, 0),
entity("e8", 18, 0, 0),
entity("e9", 19, 0, 0));
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsAlways(all), ChainParameters.DEFAULT);
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(primary), neighborsAlways(all), 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<ChainEntity> far = List.of(
entity("f1", 9, 0, 0),
entity("f2", 0, 9, 0),
entity("f3", 0, 0, 9));
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(primary),
neighborsAlways(List.of(far1, far2, far3)),
ChainParameters.DEFAULT);
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(primary), neighborsAlways(far), DEFAULT);
assertEquals(1, hits.size());
assertSame(primary, hits.get(0).target());
}
// 6
@Test
void resolve_noDoubleHit_visitedExcluded() {
// A et B mutuellement voisins ; chaîne A→B et ne doit PAS revenir à A
ChainEntity a = entity("a", 0, 0, 0);
ChainEntity b = entity("b", 2, 0, 0);
List<ChainEntity> mutual = List.of(a, b);
ChainParameters p = new ChainParameters(5, 8.0, new int[]{8, 6, 4, 3, 2});
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(a), neighborsAlways(mutual), p);
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(a), neighborsAlways(List.of(a, b)), DEFAULT);
assertEquals(2, hits.size());
assertSame(a, hits.get(0).target());
assertSame(b, hits.get(1).target());
}
// 7
@Test
void resolve_picksClosestCandidate() {
// Primary à origine ; un candidat à 3 blocs et un à 7 blocs → le plus proche choisi
ChainEntity primary = entity("p", 0, 0, 0);
ChainEntity near = entity("near", 3, 0, 0);
ChainEntity far = entity("far", 7, 0, 0);
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(primary),
neighborsAlways(List.of(near, far)),
new ChainParameters(2, 8.0, new int[]{8, 6}));
RAY_MAX, rayHits(primary), neighborsAlways(List.of(near, far)), TWO_HOPS);
assertEquals(2, hits.size());
assertSame(near, hits.get(1).target());
}
// 8
@Test
void resolve_tieBreaker_deterministicByEntityId() {
// Deux candidats EXACTEMENT à la même distance (5) du primary
ChainEntity primary = entity("p", 0, 0, 0);
ChainEntity zebra = entity("zebra", 5, 0, 0);
ChainEntity alpha = entity("alpha", 0, 5, 0);
// 100 runs avec ordre d'insertion variable → toujours alpha (id lexico < zebra)
for (int i = 0; i < 100; i++) {
List<ChainEntity> order1 = List.of(zebra, alpha);
List<ChainEntity> order2 = List.of(alpha, zebra);
List<ChainHit> h1 = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsAlways(order1),
new ChainParameters(2, 8.0, new int[]{8, 6}));
RAY_MAX, rayHits(primary), neighborsAlways(List.of(zebra, alpha)), TWO_HOPS);
List<ChainHit> h2 = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsAlways(order2),
new ChainParameters(2, 8.0, new int[]{8, 6}));
RAY_MAX, rayHits(primary), neighborsAlways(List.of(alpha, zebra)), TWO_HOPS);
assertSame(alpha, h1.get(1).target(), "run " + i + " order1");
assertSame(alpha, h2.get(1).target(), "run " + i + " order2");
}
}
// 9
@Test
void resolve_deadEntity_excluded() {
// Primary à origine ; voisin mort à 2 blocs, voisin vivant à 4 blocs → choisit le vivant
ChainEntity primary = entity("p", 0, 0, 0);
ChainEntity deadOne = dead("dead", 2, 0, 0);
ChainEntity alive = entity("alive", 4, 0, 0);
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(primary),
neighborsAlways(List.of(deadOne, alive)),
new ChainParameters(2, 8.0, new int[]{8, 6}));
RAY_MAX, rayHits(primary), neighborsAlways(List.of(deadOne, alive)), TWO_HOPS);
assertEquals(2, hits.size());
assertSame(alive, hits.get(1).target());
}
// 10
@Test
void resolve_customMaxTargets_truncatesEarly() {
// 5 candidats disponibles mais maxTargets=3 → 3 hits
ChainEntity e0 = entity("e0", 0, 0, 0);
ChainEntity e1 = entity("e1", 1, 0, 0);
ChainEntity e2 = entity("e2", 2, 0, 0);
ChainEntity e3 = entity("e3", 3, 0, 0);
ChainEntity e4 = entity("e4", 4, 0, 0);
List<ChainEntity> all = List.of(e0, e1, e2, e3, e4);
List<ChainEntity> all = List.of(
e0,
entity("e1", 1, 0, 0),
entity("e2", 2, 0, 0),
entity("e3", 3, 0, 0),
entity("e4", 4, 0, 0));
ChainParameters p = new ChainParameters(3, 8.0, new int[]{10, 5, 1});
List<ChainHit> hits = ChainResolver.resolve(
ORIGIN, DIR, RAY_MAX, rayHits(e0), neighborsAlways(all), p);
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(e0), neighborsAlways(all), p);
assertEquals(3, hits.size());
assertEquals(10, hits.get(0).damageHp());
@@ -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) {
@@ -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,23 +18,12 @@ 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
void apply_invokes_executeDamage_per_hit() {
@SuppressWarnings("unchecked")
Ref<EntityStore> attacker = (Ref<EntityStore>) mock(Ref.class);
@SuppressWarnings("unchecked")
CommandBuffer<EntityStore> buf = (CommandBuffer<EntityStore>) mock(CommandBuffer.class);
ChainDamageApplier.DamageExecutor executor = mock(ChainDamageApplier.DamageExecutor.class);
Fixture f = new Fixture();
List<ChainHit> hits = List.of(
hit("a", 8, 0),
hit("b", 6, 1),
@@ -43,99 +32,82 @@ final class ChainDamageApplierTest {
hit("e", 2, 4)
);
ChainDamageApplier.apply(hits, attacker, buf, executor);
ChainDamageApplier.apply(hits, f.attacker, f.buf, f.executor);
verify(executor, times(5)).execute(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(buf),
org.mockito.ArgumentMatchers.any(Damage.class)
verify(f.executor, times(5)).execute(
ArgumentMatchers.any(),
ArgumentMatchers.eq(f.buf),
ArgumentMatchers.any(Damage.class)
);
}
@Test
void apply_passes_correct_damage_amounts_in_order() {
@SuppressWarnings("unchecked")
Ref<EntityStore> attacker = (Ref<EntityStore>) mock(Ref.class);
@SuppressWarnings("unchecked")
CommandBuffer<EntityStore> buf = (CommandBuffer<EntityStore>) mock(CommandBuffer.class);
ChainDamageApplier.DamageExecutor executor = mock(ChainDamageApplier.DamageExecutor.class);
Fixture f = new Fixture();
List<ChainHit> hits = List.of(
hit("a", 8, 0),
hit("b", 6, 1),
hit("c", 4, 2)
);
ArgumentCaptor<Damage> damageCaptor = ArgumentCaptor.forClass(Damage.class);
ChainDamageApplier.apply(hits, attacker, buf, executor);
ChainDamageApplier.apply(hits, f.attacker, f.buf, f.executor);
verify(executor, times(3)).execute(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(buf),
verify(f.executor, times(3)).execute(
ArgumentMatchers.any(),
ArgumentMatchers.eq(f.buf),
damageCaptor.capture()
);
assertThat(damageCaptor.getAllValues()).hasSize(3);
}
@Test
void apply_with_empty_list_invokes_nothing() {
@SuppressWarnings("unchecked")
Ref<EntityStore> attacker = (Ref<EntityStore>) mock(Ref.class);
@SuppressWarnings("unchecked")
CommandBuffer<EntityStore> buf = (CommandBuffer<EntityStore>) mock(CommandBuffer.class);
ChainDamageApplier.DamageExecutor executor = mock(ChainDamageApplier.DamageExecutor.class);
Fixture f = new Fixture();
ChainDamageApplier.apply(List.of(), attacker, buf, executor);
ChainDamageApplier.apply(List.of(), f.attacker, f.buf, f.executor);
verify(executor, never()).execute(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any()
verify(f.executor, never()).execute(
ArgumentMatchers.any(),
ArgumentMatchers.any(),
ArgumentMatchers.any()
);
}
@Test
void apply_passes_adapter_ref_not_attacker_ref() {
@SuppressWarnings("unchecked")
Ref<EntityStore> attacker = (Ref<EntityStore>) mock(Ref.class, "attacker");
Fixture f = new Fixture();
@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);
@SuppressWarnings("unchecked")
ArgumentCaptor<Ref<EntityStore>> refCaptor = ArgumentCaptor.forClass(Ref.class);
ChainDamageApplier.apply(List.of(hit), attacker, buf, executor);
ChainDamageApplier.apply(List.of(new ChainHit(adapter, 8, 0)), f.attacker, f.buf, f.executor);
verify(executor, times(1)).execute(
verify(f.executor, times(1)).execute(
refCaptor.capture(),
org.mockito.ArgumentMatchers.eq(buf),
org.mockito.ArgumentMatchers.any(Damage.class)
ArgumentMatchers.eq(f.buf),
ArgumentMatchers.any(Damage.class)
);
assertThat(refCaptor.getValue()).isSameAs(targetRef);
assertThat(refCaptor.getValue()).isNotSameAs(attacker);
assertThat(refCaptor.getValue()).isNotSameAs(f.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);
}
/** Bundles the three mocks every test needs to keep test bodies focused on assertions. */
private static final class Fixture {
@SuppressWarnings("unchecked")
final Ref<EntityStore> attacker = (Ref<EntityStore>) mock(Ref.class, "attacker");
@SuppressWarnings("unchecked")
final CommandBuffer<EntityStore> buf = (CommandBuffer<EntityStore>) mock(CommandBuffer.class);
final ChainDamageApplier.DamageExecutor executor = mock(ChainDamageApplier.DamageExecutor.class);
}
}