Compare commits
12 Commits
6f8efa94c5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d67b8a7667 | |||
| cc3bb767f7 | |||
| 51a19d5f62 | |||
| f6ca35bfc4 | |||
| 03754a0646 | |||
| ee9ac1ab53 | |||
| 4ffa0e28ef | |||
| 994f66682c | |||
| ac4ed623b9 | |||
| a4427d91a7 | |||
| ddc08fb14c | |||
| 8d868a28ca |
@@ -55,3 +55,7 @@ logs/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Phase 4 — temporary asset sources (not committed)
|
||||||
|
*.zip
|
||||||
|
note
|
||||||
|
|||||||
@@ -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;
|
import java.util.logging.Level;
|
||||||
|
|
||||||
/**
|
/** Plugin entry point that registers the ChainLightningSceptre interaction codec. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
public class ChainLightningPlugin extends JavaPlugin {
|
public class ChainLightningPlugin extends JavaPlugin {
|
||||||
|
|
||||||
public ChainLightningPlugin(JavaPluginInit init) {
|
public ChainLightningPlugin(JavaPluginInit init) {
|
||||||
super(init);
|
super(init);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The interaction key must match the Type field in chain_lightning_sceptre_click.json. */
|
||||||
@Override
|
@Override
|
||||||
protected void setup() {
|
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(
|
getCodecRegistry(Interaction.CODEC).register(
|
||||||
"ChainLightningSceptre",
|
"ChainLightningSceptre",
|
||||||
ChainLightningSceptreInteraction.class,
|
ChainLightningSceptreInteraction.class,
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package com.mythlane.chainlightning.chain;
|
package com.mythlane.chainlightning.chain;
|
||||||
|
|
||||||
/**
|
/** Minimal chain target contract — stable, mockable, no Hytale dependency. */
|
||||||
* Contrat minimal d'une cible de chaîne. Stable, mockable, sans dépendance Hytale.
|
|
||||||
* Phase 3 adaptera l'entité Hytale vers ChainEntity à la frontière.
|
|
||||||
*/
|
|
||||||
public interface ChainEntity {
|
public interface ChainEntity {
|
||||||
String id();
|
String id();
|
||||||
Vec3 position();
|
Vec3 position();
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
package com.mythlane.chainlightning.chain;
|
package com.mythlane.chainlightning.chain;
|
||||||
|
|
||||||
/**
|
/** Resolved chain strike — hop 0 is the ray-cast primary target. */
|
||||||
* Une frappe résolue de la chaîne. Hop 0 = cible primaire (ray-cast).
|
|
||||||
*
|
|
||||||
* @param target entité touchée
|
|
||||||
* @param damageHp dégâts à appliquer (issus de ChainParameters.damageCurve[hopIndex])
|
|
||||||
* @param hopIndex position dans la chaîne, 0-indexed
|
|
||||||
*/
|
|
||||||
public record ChainHit(ChainEntity target, int damageHp, int hopIndex) {
|
public record ChainHit(ChainEntity target, int damageHp, int hopIndex) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,9 @@ package com.mythlane.chainlightning.chain;
|
|||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
/**
|
/** Frozen chain-resolution parameters; defensively copies the damage curve to keep the record immutable. */
|
||||||
* Paramètres figés de la résolution de chaîne. Spec v1 : DEFAULT = (5, 8.0, [8,6,4,3,2]).
|
|
||||||
*
|
|
||||||
* <p>Le record clone défensivement le tableau damageCurve à la construction et expose
|
|
||||||
* une copie via {@link #damageCurve()} pour empêcher la mutation externe.
|
|
||||||
*
|
|
||||||
* <p>Implémente CHAIN-03 (courbe de dégâts) — D-CHAIN-03 dans CONTEXT.md.
|
|
||||||
*/
|
|
||||||
public record ChainParameters(int maxTargets, double chainRadius, int[] damageCurve) {
|
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 =
|
public static final ChainParameters DEFAULT =
|
||||||
new ChainParameters(5, 8.0, new int[]{8, 6, 4, 3, 2});
|
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);
|
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
|
@Override
|
||||||
public int[] damageCurve() {
|
public int[] damageCurve() {
|
||||||
return Arrays.copyOf(damageCurve, damageCurve.length);
|
return Arrays.copyOf(damageCurve, damageCurve.length);
|
||||||
|
|||||||
@@ -1,46 +1,23 @@
|
|||||||
package com.mythlane.chainlightning.chain;
|
package com.mythlane.chainlightning.chain;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/** Pure stateless BFS that builds the chain from a primary ray-cast hit, without any Hytale dependency. */
|
||||||
* 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<String> visited.</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* <p>Aucun side-effect — fonction pure. Aucune dépendance sur le runtime Hytale.
|
|
||||||
*/
|
|
||||||
public final class ChainResolver {
|
public final class ChainResolver {
|
||||||
|
|
||||||
private ChainResolver() {
|
private ChainResolver() {}
|
||||||
// utility class — instantiation interdite
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/** Resolves the full chain; returns the primary plus up to maxTargets-1 nearest unique neighbors. */
|
||||||
* Résout la chaîne complète à partir du tir initial.
|
public static List<ChainHit> resolve(double rayMaxBlocks,
|
||||||
*
|
RayCaster ray,
|
||||||
* @return liste immuable de hits dans l'ordre de la chaîne (hop 0 = primary).
|
EntitySource neighbors,
|
||||||
* Empty si ray-cast ne touche rien.
|
ChainParameters params) {
|
||||||
*/
|
|
||||||
public static List<ChainHit> resolve(
|
|
||||||
Vec3 shooterOrigin,
|
|
||||||
Vec3 shooterDirection,
|
|
||||||
double rayMaxBlocks,
|
|
||||||
RayCaster ray,
|
|
||||||
EntitySource neighbors,
|
|
||||||
ChainParameters params) {
|
|
||||||
|
|
||||||
Optional<ChainEntity> primaryOpt = ray.firstHit(shooterOrigin, shooterDirection, rayMaxBlocks);
|
Optional<ChainEntity> primaryOpt = ray.firstHit(rayMaxBlocks);
|
||||||
if (primaryOpt.isEmpty()) {
|
if (primaryOpt.isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
@@ -53,37 +30,36 @@ public final class ChainResolver {
|
|||||||
hits.add(new ChainHit(primary, damageCurve[0], 0));
|
hits.add(new ChainHit(primary, damageCurve[0], 0));
|
||||||
visited.add(primary.id());
|
visited.add(primary.id());
|
||||||
|
|
||||||
|
double radiusSq = params.chainRadius() * params.chainRadius();
|
||||||
for (int hopIndex = 1; hopIndex < params.maxTargets(); hopIndex++) {
|
for (int hopIndex = 1; hopIndex < params.maxTargets(); hopIndex++) {
|
||||||
ChainEntity current = hits.get(hits.size() - 1).target();
|
ChainEntity current = hits.get(hits.size() - 1).target();
|
||||||
List<ChainEntity> candidates = neighbors.nearby(current.position(), params.chainRadius());
|
ChainEntity next = nearestUnvisited(neighbors.nearby(current.position(), params.chainRadius()),
|
||||||
|
current.position(), radiusSq, visited);
|
||||||
ChainEntity next = null;
|
|
||||||
double bestDistSq = Double.POSITIVE_INFINITY;
|
|
||||||
|
|
||||||
for (ChainEntity c : candidates) {
|
|
||||||
if (!c.isAlive()) continue;
|
|
||||||
if (visited.contains(c.id())) continue;
|
|
||||||
double d = c.position().distanceSquared(current.position());
|
|
||||||
if (d > params.chainRadius() * params.chainRadius()) continue;
|
|
||||||
if (d < bestDistSq) {
|
|
||||||
bestDistSq = d;
|
|
||||||
next = c;
|
|
||||||
} else if (d == bestDistSq && next != null) {
|
|
||||||
// tie-breaker lexicographique sur id()
|
|
||||||
if (c.id().compareTo(next.id()) < 0) {
|
|
||||||
next = c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next == null) {
|
if (next == null) {
|
||||||
break; // chaîne terminée plus tôt
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
hits.add(new ChainHit(next, damageCurve[hopIndex], hopIndex));
|
hits.add(new ChainHit(next, damageCurve[hopIndex], hopIndex));
|
||||||
visited.add(next.id());
|
visited.add(next.id());
|
||||||
}
|
}
|
||||||
|
|
||||||
return List.copyOf(hits);
|
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;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/** SAM that returns nearby entities; lets tests inject a synthetic graph and Phase 3 wire the spatial query. */
|
||||||
* Source d'entités voisines. SAM permettant aux tests de fournir un graphe synthétique
|
|
||||||
* et à Phase 3 de brancher la spatial query Hytale.
|
|
||||||
*/
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface EntitySource {
|
public interface EntitySource {
|
||||||
List<ChainEntity> nearby(Vec3 origin, double radius);
|
List<ChainEntity> nearby(Vec3 origin, double radius);
|
||||||
|
|||||||
@@ -2,12 +2,8 @@ package com.mythlane.chainlightning.chain;
|
|||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/** SAM that returns the first entity along the caster's look ray, or empty. */
|
||||||
* Ray-cast retournant la première entité touchée le long du rayon, ou empty.
|
|
||||||
* SAM permettant aux tests de fournir un résultat fixe et à Phase 3 de brancher
|
|
||||||
* l'API Hytale réelle.
|
|
||||||
*/
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface RayCaster {
|
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;
|
package com.mythlane.chainlightning.chain;
|
||||||
|
|
||||||
/**
|
/** Immutable 3D position used by the pure resolver, independent from the Hytale runtime. */
|
||||||
* Position 3D pure-Java, indépendante de l'API Hytale.
|
|
||||||
* Utilisée par ChainResolver pour calculs de distance ; comparaisons en distanceSquared
|
|
||||||
* pour éviter les sqrt dans la boucle BFS.
|
|
||||||
*/
|
|
||||||
public record Vec3(double x, double y, double z) {
|
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);
|
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) {
|
public double distanceSquared(Vec3 other) {
|
||||||
double dx = this.x - other.x;
|
double dx = this.x - other.x;
|
||||||
double dy = this.y - other.y;
|
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;
|
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) {
|
public double distance(Vec3 other) {
|
||||||
return Math.sqrt(distanceSquared(other));
|
return Math.sqrt(distanceSquared(other));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,34 +11,16 @@ import com.mythlane.chainlightning.chain.ChainHit;
|
|||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.logging.Level;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
/**
|
/** Applies DamageSystems.executeDamage to each chain hit; injectable executor keeps unit tests off the Hytale runtime. */
|
||||||
* Helper static qui applique {@link DamageSystems#executeDamage} pour chaque hit d'une chaine resolue.
|
@SuppressWarnings("deprecation")
|
||||||
*
|
|
||||||
* <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).
|
|
||||||
*/
|
|
||||||
public final class ChainDamageApplier {
|
public final class ChainDamageApplier {
|
||||||
|
|
||||||
private static final Logger LOGGER = Logger.getLogger(ChainDamageApplier.class.getName());
|
private static final Logger LOGGER = Logger.getLogger(ChainDamageApplier.class.getName());
|
||||||
|
|
||||||
/**
|
/** SAM seam used to swap DamageSystems for a stub in tests. */
|
||||||
* SAM injectable pour l'application des degats -- permet de stubber DamageSystems en test.
|
|
||||||
*/
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface DamageExecutor {
|
public interface DamageExecutor {
|
||||||
void execute(@Nonnull Ref<EntityStore> target,
|
void execute(@Nonnull Ref<EntityStore> target,
|
||||||
@@ -46,48 +28,34 @@ public final class ChainDamageApplier {
|
|||||||
@Nonnull Damage damage);
|
@Nonnull Damage damage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Lazy holder keeps DamageSystems.<clinit> out of the test classpath. */
|
||||||
* 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")
|
|
||||||
private static final class DefaultHolder {
|
private static final class DefaultHolder {
|
||||||
static final DamageExecutor INSTANCE = DamageSystems::executeDamage;
|
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() {
|
public static DamageExecutor defaultExecutor() {
|
||||||
return DefaultHolder.INSTANCE;
|
return DefaultHolder.INSTANCE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** PHYSICAL cause index resolved from the asset map; falls back to 0 in unit tests where the runtime is not booted. */
|
||||||
* 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")
|
|
||||||
static int physicalDamageCauseIndex() {
|
static int physicalDamageCauseIndex() {
|
||||||
DamageCause physical = DamageCause.PHYSICAL;
|
return CauseIndexHolder.VALUE;
|
||||||
if (physical == null) {
|
|
||||||
// Contexte de test : DamageCause.PHYSICAL non initialise, index neutre.
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return DamageCause.getAssetMap().getIndex(physical.getId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ChainDamageApplier() {}
|
private ChainDamageApplier() {}
|
||||||
|
|
||||||
/**
|
/** Production entry point — uses the default executor. */
|
||||||
* 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)
|
|
||||||
*/
|
|
||||||
public static void apply(@Nonnull List<ChainHit> hits,
|
public static void apply(@Nonnull List<ChainHit> hits,
|
||||||
@Nonnull Ref<EntityStore> attacker,
|
@Nonnull Ref<EntityStore> attacker,
|
||||||
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
||||||
@@ -95,44 +63,25 @@ public final class ChainDamageApplier {
|
|||||||
apply(hits, attacker, commandBuffer, defaultExecutor());
|
apply(hits, attacker, commandBuffer, defaultExecutor());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Test seam — accepts a stub executor to avoid initializing DamageSystems. */
|
||||||
* Applique les degats via un executeur injecte -- utilise en test pour eviter
|
|
||||||
* l'initialisation du runtime Hytale (DamageSystems.<clinit>).
|
|
||||||
*
|
|
||||||
* @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)
|
|
||||||
*/
|
|
||||||
public static void apply(@Nonnull List<ChainHit> hits,
|
public static void apply(@Nonnull List<ChainHit> hits,
|
||||||
@Nonnull Ref<EntityStore> attacker,
|
@Nonnull Ref<EntityStore> attacker,
|
||||||
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
||||||
@Nonnull DamageExecutor executor) {
|
@Nonnull DamageExecutor executor) {
|
||||||
int causeIndex = physicalDamageCauseIndex();
|
int causeIndex = physicalDamageCauseIndex();
|
||||||
LOGGER.info(String.format("[ChainLightning][Damage] apply START hits=%d attacker=ref:%d causeIndex=%d (PHYSICAL)",
|
Damage.EntitySource source = new Damage.EntitySource(attacker);
|
||||||
hits.size(), attacker.getIndex(), causeIndex));
|
int succeeded = 0;
|
||||||
for (int i = 0; i < hits.size(); i++) {
|
for (ChainHit hit : hits) {
|
||||||
ChainHit hit = hits.get(i);
|
HytaleEntityAdapter adapter = HytaleEntityAdapter.from(hit);
|
||||||
HytaleEntityAdapter adapter = (HytaleEntityAdapter) hit.target();
|
Damage damage = new Damage(source, causeIndex, (float) hit.damageHp());
|
||||||
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 {
|
try {
|
||||||
executor.execute(targetRef, commandBuffer, damage);
|
executor.execute(adapter.ref(), commandBuffer, damage);
|
||||||
LOGGER.info(String.format("[ChainLightning][Damage] [%d/%d] executeDamage OK", i + 1, hits.size()));
|
succeeded++;
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
LOGGER.log(java.util.logging.Level.WARNING,
|
LOGGER.log(Level.WARNING,
|
||||||
String.format("[ChainLightning][Damage] [%d/%d] executeDamage THREW", i + 1, hits.size()), t);
|
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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-98
@@ -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.ChainHit;
|
||||||
import com.mythlane.chainlightning.chain.ChainParameters;
|
import com.mythlane.chainlightning.chain.ChainParameters;
|
||||||
import com.mythlane.chainlightning.chain.ChainResolver;
|
import com.mythlane.chainlightning.chain.ChainResolver;
|
||||||
import com.mythlane.chainlightning.chain.Vec3;
|
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
/**
|
/** Runtime orchestrator: cooldown gate, chain resolution, damage, VFX emit, charge deduct. */
|
||||||
* 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").
|
|
||||||
*/
|
|
||||||
public final class ChainLightningSceptreInteraction extends SimpleInstantInteraction {
|
public final class ChainLightningSceptreInteraction extends SimpleInstantInteraction {
|
||||||
|
|
||||||
private static final Logger LOGGER = Logger.getLogger(ChainLightningSceptreInteraction.class.getName());
|
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 String COOLDOWN_ID = "chain_lightning_sceptre";
|
private static final float COOLDOWN_TIME = 4.0f;
|
||||||
private static final float COOLDOWN_TIME = 4.0f;
|
private static final float[] CHARGE_TIMES = { 4.0f };
|
||||||
private static final float[] CHARGE_TIMES = new float[]{4.0f};
|
private static final boolean FORCE_CREATE = true;
|
||||||
private static final boolean FORCE_CREATE = true;
|
private static final boolean INTERRUPT_RECHARGE = false;
|
||||||
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
|
@Nonnull
|
||||||
public static final BuilderCodec<ChainLightningSceptreInteraction> CODEC =
|
public static final BuilderCodec<ChainLightningSceptreInteraction> CODEC =
|
||||||
((BuilderCodec.Builder) BuilderCodec
|
((BuilderCodec.Builder) BuilderCodec
|
||||||
@@ -67,90 +43,57 @@ public final class ChainLightningSceptreInteraction extends SimpleInstantInterac
|
|||||||
public ChainLightningSceptreInteraction() {
|
public ChainLightningSceptreInteraction() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Runs the chain pipeline once per click; silently no-ops while on cooldown. */
|
||||||
@Override
|
@Override
|
||||||
protected void firstRun(@Nonnull InteractionType type,
|
protected void firstRun(@Nonnull InteractionType type,
|
||||||
@Nonnull InteractionContext context,
|
@Nonnull InteractionContext context,
|
||||||
@Nonnull CooldownHandler cooldownHandler) {
|
@Nonnull CooldownHandler cooldownHandler) {
|
||||||
LOGGER.info(String.format("[ChainLightning][1/9] firstRun ENTRY type=%s", type));
|
if (type != InteractionType.Secondary) {
|
||||||
|
return;
|
||||||
// --- Étape 1 : récupérer le cooldown ---
|
}
|
||||||
CooldownHandler.Cooldown cooldown = cooldownHandler.getCooldown(
|
CooldownHandler.Cooldown cooldown = cooldownHandler.getCooldown(
|
||||||
COOLDOWN_ID, COOLDOWN_TIME, CHARGE_TIMES, FORCE_CREATE, INTERRUPT_RECHARGE);
|
COOLDOWN_ID, COOLDOWN_TIME, CHARGE_TIMES, FORCE_CREATE, INTERRUPT_RECHARGE);
|
||||||
if (cooldown == null) {
|
if (cooldown == null || cooldown.hasCooldown(false)) {
|
||||||
LOGGER.warning("[ChainLightning][1/9] cooldown handler returned null — aborting");
|
|
||||||
return;
|
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 ---
|
Ref<EntityStore> playerRef = context.getEntity();
|
||||||
boolean onCooldown = cooldown.hasCooldown(false);
|
CommandBuffer<EntityStore> commandBuffer = context.getCommandBuffer();
|
||||||
LOGGER.info(String.format("[ChainLightning][2/9] hasCooldown(false)=%s", onCooldown));
|
if (playerRef == null || commandBuffer == null) {
|
||||||
if (onCooldown) {
|
|
||||||
LOGGER.info("[ChainLightning][2/9] still on cooldown — silent refuse");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// --- Étape 3 : extraire player + commandBuffer ---
|
List<ChainHit> hits = resolveChain(playerRef, 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 ---
|
|
||||||
if (hits.isEmpty()) {
|
if (hits.isEmpty()) {
|
||||||
LOGGER.info("[ChainLightning][6/9] no target — re-click immediately allowed (no cooldown deducted)");
|
|
||||||
return;
|
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);
|
ChainDamageApplier.apply(hits, playerRef, commandBuffer, commandBuffer);
|
||||||
LOGGER.info("[ChainLightning][7/9] damage application returned");
|
tryEmitVfx(hits, playerRef, commandBuffer);
|
||||||
|
|
||||||
// --- Étape 8 : démarrer le cooldown APRÈS succès ---
|
|
||||||
cooldown.deductCharge();
|
cooldown.deductCharge();
|
||||||
LOGGER.info(String.format("[ChainLightning][8/9] cooldown deducted (next available in %.1fs)", COOLDOWN_TIME));
|
LOGGER.info(String.format("[ChainLightning] ref:%d chained %d targets",
|
||||||
|
playerRef.getIndex(), hits.size()));
|
||||||
// --- É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()));
|
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
// CONTEXT decision : try/catch global pour éviter crash tick serveur
|
LOGGER.log(Level.WARNING, "[ChainLightning] chain pipeline failed", t);
|
||||||
LOGGER.log(Level.WARNING, "[ChainLightning] chain resolution 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(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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.modules.entitystats.asset.DefaultEntityStatTypes;
|
||||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
import com.mythlane.chainlightning.chain.ChainEntity;
|
import com.mythlane.chainlightning.chain.ChainEntity;
|
||||||
|
import com.mythlane.chainlightning.chain.ChainHit;
|
||||||
import com.mythlane.chainlightning.chain.Vec3;
|
import com.mythlane.chainlightning.chain.Vec3;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
/**
|
/** Immutable Ref<EntityStore> -> ChainEntity adapter; eager snapshot keeps BFS robust to mid-tick entity changes. */
|
||||||
* Adapter immuable Ref<EntityStore> -> 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.
|
|
||||||
*/
|
|
||||||
public final class HytaleEntityAdapter implements ChainEntity {
|
public final class HytaleEntityAdapter implements ChainEntity {
|
||||||
|
|
||||||
private final Ref<EntityStore> ref;
|
private final Ref<EntityStore> ref;
|
||||||
@@ -37,23 +29,13 @@ public final class HytaleEntityAdapter implements ChainEntity {
|
|||||||
this.alive = alive;
|
this.alive = alive;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Test-only factory that bypasses Hytale TransformComponent initialization. */
|
||||||
* 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).
|
|
||||||
*/
|
|
||||||
static HytaleEntityAdapter forTest(@Nonnull Ref<EntityStore> ref, @Nonnull String id,
|
static HytaleEntityAdapter forTest(@Nonnull Ref<EntityStore> ref, @Nonnull String id,
|
||||||
@Nonnull Vec3 position, boolean alive) {
|
@Nonnull Vec3 position, boolean alive) {
|
||||||
return new HytaleEntityAdapter(ref, id, position, alive);
|
return new HytaleEntityAdapter(ref, id, position, alive);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Reads TransformComponent + EntityStatMap once and freezes the result for the chain resolver. */
|
||||||
* Projette un Ref<EntityStore> vers un ChainEntity en lisant TransformComponent + EntityStatMap.
|
|
||||||
*
|
|
||||||
* @param ref reference entite Hytale
|
|
||||||
* @param accessor ComponentAccessor (CommandBuffer implemente ComponentAccessor)
|
|
||||||
* @return adapter snapshot. Jamais null.
|
|
||||||
*/
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
public static HytaleEntityAdapter snapshot(@Nonnull Ref<EntityStore> ref,
|
public static HytaleEntityAdapter snapshot(@Nonnull Ref<EntityStore> ref,
|
||||||
@Nonnull ComponentAccessor<EntityStore> accessor) {
|
@Nonnull ComponentAccessor<EntityStore> accessor) {
|
||||||
@@ -73,24 +55,19 @@ public final class HytaleEntityAdapter implements ChainEntity {
|
|||||||
return new HytaleEntityAdapter(ref, id, vec, alive);
|
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
|
@Nonnull
|
||||||
public Ref<EntityStore> ref() {
|
public Ref<EntityStore> ref() {
|
||||||
return ref;
|
return ref;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override public String id() { return id; }
|
||||||
public String id() {
|
@Override public Vec3 position() { return position; }
|
||||||
return id;
|
@Override public boolean isAlive() { return alive; }
|
||||||
}
|
|
||||||
|
|
||||||
@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 javax.annotation.Nonnull;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.logging.Logger;
|
|
||||||
|
|
||||||
/**
|
/** Phase 3 EntitySource that copies TargetUtil's thread-local result into a stable list of snapshots. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
public final class HytaleEntitySource implements EntitySource {
|
public final class HytaleEntitySource implements EntitySource {
|
||||||
|
|
||||||
private static final Logger LOGGER = Logger.getLogger(HytaleEntitySource.class.getName());
|
|
||||||
|
|
||||||
private final ComponentAccessor<EntityStore> accessor;
|
private final ComponentAccessor<EntityStore> accessor;
|
||||||
|
|
||||||
public HytaleEntitySource(@Nonnull ComponentAccessor<EntityStore> accessor) {
|
public HytaleEntitySource(@Nonnull ComponentAccessor<EntityStore> accessor) {
|
||||||
@@ -36,21 +25,14 @@ public final class HytaleEntitySource implements EntitySource {
|
|||||||
@Override
|
@Override
|
||||||
public List<ChainEntity> nearby(Vec3 origin, double radius) {
|
public List<ChainEntity> nearby(Vec3 origin, double radius) {
|
||||||
Vector3d hytaleOrigin = new Vector3d(origin.x(), origin.y(), origin.z());
|
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);
|
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 || refs.isEmpty()) {
|
||||||
if (refs == null) {
|
return List.of();
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
}
|
||||||
List<ChainEntity> snapshots = new ArrayList<>(refs.size());
|
List<ChainEntity> snapshots = new ArrayList<>(refs.size());
|
||||||
for (Ref<EntityStore> ref : refs) {
|
for (Ref<EntityStore> ref : refs) {
|
||||||
ChainEntity adapter = HytaleEntityAdapter.snapshot(ref, accessor);
|
snapshots.add(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);
|
|
||||||
}
|
}
|
||||||
LOGGER.info(String.format("[ChainLightning][EntitySource] returning %d snapshots", snapshots.size()));
|
|
||||||
return snapshots;
|
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.hypixel.hytale.server.core.util.TargetUtil;
|
||||||
import com.mythlane.chainlightning.chain.ChainEntity;
|
import com.mythlane.chainlightning.chain.ChainEntity;
|
||||||
import com.mythlane.chainlightning.chain.RayCaster;
|
import com.mythlane.chainlightning.chain.RayCaster;
|
||||||
import com.mythlane.chainlightning.chain.Vec3;
|
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.logging.Logger;
|
|
||||||
|
|
||||||
/**
|
/** Phase 3 RayCaster delegating to TargetUtil; eye origin and look direction are derived from the playerRef inside Hytale. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
public final class HytalePlayerRayCaster implements RayCaster {
|
public final class HytalePlayerRayCaster implements RayCaster {
|
||||||
|
|
||||||
private static final Logger LOGGER = Logger.getLogger(HytalePlayerRayCaster.class.getName());
|
|
||||||
|
|
||||||
private final Ref<EntityStore> playerRef;
|
private final Ref<EntityStore> playerRef;
|
||||||
private final ComponentAccessor<EntityStore> accessor;
|
private final ComponentAccessor<EntityStore> accessor;
|
||||||
|
|
||||||
@@ -37,19 +23,11 @@ public final class HytalePlayerRayCaster implements RayCaster {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<ChainEntity> firstHit(Vec3 originIgnored, Vec3 directionIgnored, double maxBlocks) {
|
public Optional<ChainEntity> firstHit(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);
|
Ref<EntityStore> target = TargetUtil.getTargetEntity(playerRef, (float) maxBlocks, accessor);
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
LOGGER.info("[ChainLightning][RayCast] no entity hit — returning Optional.empty()");
|
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
LOGGER.info(String.format("[ChainLightning][RayCast] HIT target=ref:%d valid=%s — snapshotting",
|
return Optional.of(HytaleEntityAdapter.snapshot(target, accessor));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
"Compatible": true
|
||||||
},
|
},
|
||||||
"Interactions": {
|
"Interactions": {
|
||||||
"Primary": "chain_lightning_sceptre_root",
|
|
||||||
"Secondary": "chain_lightning_sceptre_root"
|
"Secondary": "chain_lightning_sceptre_root"
|
||||||
},
|
},
|
||||||
"IconProperties": {
|
"IconProperties": {
|
||||||
|
|||||||
@@ -13,55 +13,44 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
|
|
||||||
final class ChainResolverTest {
|
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 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() {
|
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) {
|
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) {
|
private static EntitySource neighborsAlways(List<ChainEntity> candidates) {
|
||||||
return (origin, radius) -> candidates;
|
return (origin, radius) -> candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** EntitySource vide. */
|
|
||||||
private static EntitySource neighborsEmpty() {
|
private static EntitySource neighborsEmpty() {
|
||||||
return (origin, radius) -> List.of();
|
return (origin, radius) -> List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1
|
|
||||||
@Test
|
@Test
|
||||||
void resolve_noPrimaryHit_returnsEmpty() {
|
void resolve_noPrimaryHit_returnsEmpty() {
|
||||||
List<ChainHit> hits = ChainResolver.resolve(
|
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayMisses(), neighborsEmpty(), DEFAULT);
|
||||||
ORIGIN, DIR, RAY_MAX, rayMisses(), neighborsEmpty(), ChainParameters.DEFAULT);
|
|
||||||
assertTrue(hits.isEmpty());
|
assertTrue(hits.isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2
|
|
||||||
@Test
|
@Test
|
||||||
void resolve_primaryOnly_noNeighbors_returnsSingleHit() {
|
void resolve_primaryOnly_noNeighbors_returnsSingleHit() {
|
||||||
ChainEntity primary = entity("p", 10, 0, 0);
|
ChainEntity primary = entity("p", 10, 0, 0);
|
||||||
List<ChainHit> hits = ChainResolver.resolve(
|
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(primary), neighborsEmpty(), DEFAULT);
|
||||||
ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsEmpty(), ChainParameters.DEFAULT);
|
|
||||||
assertEquals(1, hits.size());
|
assertEquals(1, hits.size());
|
||||||
assertSame(primary, hits.get(0).target());
|
assertSame(primary, hits.get(0).target());
|
||||||
assertEquals(8, hits.get(0).damageHp());
|
assertEquals(8, hits.get(0).damageHp());
|
||||||
assertEquals(0, hits.get(0).hopIndex());
|
assertEquals(0, hits.get(0).hopIndex());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3
|
|
||||||
@Test
|
@Test
|
||||||
void resolve_fullChainOfFive_appliesDamageCurveAndOrder() {
|
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 e0 = entity("e0", 10, 0, 0);
|
||||||
ChainEntity e1 = entity("e1", 12, 0, 0);
|
ChainEntity e1 = entity("e1", 12, 0, 0);
|
||||||
ChainEntity e2 = entity("e2", 14, 0, 0);
|
ChainEntity e2 = entity("e2", 14, 0, 0);
|
||||||
@@ -69,8 +58,7 @@ final class ChainResolverTest {
|
|||||||
ChainEntity e4 = entity("e4", 18, 0, 0);
|
ChainEntity e4 = entity("e4", 18, 0, 0);
|
||||||
List<ChainEntity> all = List.of(e0, e1, e2, e3, e4);
|
List<ChainEntity> all = List.of(e0, e1, e2, e3, e4);
|
||||||
|
|
||||||
List<ChainHit> hits = ChainResolver.resolve(
|
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(e0), neighborsAlways(all), DEFAULT);
|
||||||
ORIGIN, DIR, RAY_MAX, rayHits(e0), neighborsAlways(all), ChainParameters.DEFAULT);
|
|
||||||
|
|
||||||
assertEquals(5, hits.size());
|
assertEquals(5, hits.size());
|
||||||
assertSame(e0, hits.get(0).target()); assertEquals(8, hits.get(0).damageHp()); assertEquals(0, hits.get(0).hopIndex());
|
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());
|
assertSame(e4, hits.get(4).target()); assertEquals(2, hits.get(4).damageHp()); assertEquals(4, hits.get(4).hopIndex());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4
|
|
||||||
@Test
|
@Test
|
||||||
void resolve_moreThanFiveCandidates_stopsAtMaxTargets() {
|
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 primary = entity("e0", 10, 0, 0);
|
||||||
ChainEntity e1 = entity("e1", 11, 0, 0);
|
List<ChainEntity> all = List.of(
|
||||||
ChainEntity e2 = entity("e2", 12, 0, 0);
|
primary,
|
||||||
ChainEntity e3 = entity("e3", 13, 0, 0);
|
entity("e1", 11, 0, 0),
|
||||||
ChainEntity e4 = entity("e4", 14, 0, 0);
|
entity("e2", 12, 0, 0),
|
||||||
ChainEntity e5 = entity("e5", 15, 0, 0);
|
entity("e3", 13, 0, 0),
|
||||||
ChainEntity e6 = entity("e6", 16, 0, 0);
|
entity("e4", 14, 0, 0),
|
||||||
ChainEntity e7 = entity("e7", 17, 0, 0);
|
entity("e5", 15, 0, 0),
|
||||||
ChainEntity e8 = entity("e8", 18, 0, 0);
|
entity("e6", 16, 0, 0),
|
||||||
ChainEntity e9 = entity("e9", 19, 0, 0);
|
entity("e7", 17, 0, 0),
|
||||||
List<ChainEntity> all = List.of(primary, e1, e2, e3, e4, e5, e6, e7, e8, e9);
|
entity("e8", 18, 0, 0),
|
||||||
|
entity("e9", 19, 0, 0));
|
||||||
|
|
||||||
List<ChainHit> hits = ChainResolver.resolve(
|
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(primary), neighborsAlways(all), DEFAULT);
|
||||||
ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsAlways(all), ChainParameters.DEFAULT);
|
|
||||||
|
|
||||||
assertEquals(5, hits.size());
|
assertEquals(5, hits.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5
|
|
||||||
@Test
|
@Test
|
||||||
void resolve_candidatesOutsideRadius_excluded() {
|
void resolve_candidatesOutsideRadius_excluded() {
|
||||||
// Primary à origine, 3 candidats à 9 blocs (> radius 8) → aucun hop possible
|
|
||||||
ChainEntity primary = entity("p", 0, 0, 0);
|
ChainEntity primary = entity("p", 0, 0, 0);
|
||||||
ChainEntity far1 = entity("f1", 9, 0, 0);
|
List<ChainEntity> far = List.of(
|
||||||
ChainEntity far2 = entity("f2", 0, 9, 0);
|
entity("f1", 9, 0, 0),
|
||||||
ChainEntity far3 = entity("f3", 0, 0, 9);
|
entity("f2", 0, 9, 0),
|
||||||
|
entity("f3", 0, 0, 9));
|
||||||
|
|
||||||
List<ChainHit> hits = ChainResolver.resolve(
|
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(primary), neighborsAlways(far), DEFAULT);
|
||||||
ORIGIN, DIR, RAY_MAX, rayHits(primary),
|
|
||||||
neighborsAlways(List.of(far1, far2, far3)),
|
|
||||||
ChainParameters.DEFAULT);
|
|
||||||
|
|
||||||
assertEquals(1, hits.size());
|
assertEquals(1, hits.size());
|
||||||
assertSame(primary, hits.get(0).target());
|
assertSame(primary, hits.get(0).target());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6
|
|
||||||
@Test
|
@Test
|
||||||
void resolve_noDoubleHit_visitedExcluded() {
|
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 a = entity("a", 0, 0, 0);
|
||||||
ChainEntity b = entity("b", 2, 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(RAY_MAX, rayHits(a), neighborsAlways(List.of(a, b)), DEFAULT);
|
||||||
List<ChainHit> hits = ChainResolver.resolve(
|
|
||||||
ORIGIN, DIR, RAY_MAX, rayHits(a), neighborsAlways(mutual), p);
|
|
||||||
|
|
||||||
assertEquals(2, hits.size());
|
assertEquals(2, hits.size());
|
||||||
assertSame(a, hits.get(0).target());
|
assertSame(a, hits.get(0).target());
|
||||||
assertSame(b, hits.get(1).target());
|
assertSame(b, hits.get(1).target());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7
|
|
||||||
@Test
|
@Test
|
||||||
void resolve_picksClosestCandidate() {
|
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 primary = entity("p", 0, 0, 0);
|
||||||
ChainEntity near = entity("near", 3, 0, 0);
|
ChainEntity near = entity("near", 3, 0, 0);
|
||||||
ChainEntity far = entity("far", 7, 0, 0);
|
ChainEntity far = entity("far", 7, 0, 0);
|
||||||
|
|
||||||
List<ChainHit> hits = ChainResolver.resolve(
|
List<ChainHit> hits = ChainResolver.resolve(
|
||||||
ORIGIN, DIR, RAY_MAX, rayHits(primary),
|
RAY_MAX, rayHits(primary), neighborsAlways(List.of(near, far)), TWO_HOPS);
|
||||||
neighborsAlways(List.of(near, far)),
|
|
||||||
new ChainParameters(2, 8.0, new int[]{8, 6}));
|
|
||||||
|
|
||||||
assertEquals(2, hits.size());
|
assertEquals(2, hits.size());
|
||||||
assertSame(near, hits.get(1).target());
|
assertSame(near, hits.get(1).target());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8
|
|
||||||
@Test
|
@Test
|
||||||
void resolve_tieBreaker_deterministicByEntityId() {
|
void resolve_tieBreaker_deterministicByEntityId() {
|
||||||
// Deux candidats EXACTEMENT à la même distance (5) du primary
|
|
||||||
ChainEntity primary = entity("p", 0, 0, 0);
|
ChainEntity primary = entity("p", 0, 0, 0);
|
||||||
ChainEntity zebra = entity("zebra", 5, 0, 0);
|
ChainEntity zebra = entity("zebra", 5, 0, 0);
|
||||||
ChainEntity alpha = entity("alpha", 0, 5, 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++) {
|
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(
|
List<ChainHit> h1 = ChainResolver.resolve(
|
||||||
ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsAlways(order1),
|
RAY_MAX, rayHits(primary), neighborsAlways(List.of(zebra, alpha)), TWO_HOPS);
|
||||||
new ChainParameters(2, 8.0, new int[]{8, 6}));
|
|
||||||
List<ChainHit> h2 = ChainResolver.resolve(
|
List<ChainHit> h2 = ChainResolver.resolve(
|
||||||
ORIGIN, DIR, RAY_MAX, rayHits(primary), neighborsAlways(order2),
|
RAY_MAX, rayHits(primary), neighborsAlways(List.of(alpha, zebra)), TWO_HOPS);
|
||||||
new ChainParameters(2, 8.0, new int[]{8, 6}));
|
|
||||||
assertSame(alpha, h1.get(1).target(), "run " + i + " order1");
|
assertSame(alpha, h1.get(1).target(), "run " + i + " order1");
|
||||||
assertSame(alpha, h2.get(1).target(), "run " + i + " order2");
|
assertSame(alpha, h2.get(1).target(), "run " + i + " order2");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9
|
|
||||||
@Test
|
@Test
|
||||||
void resolve_deadEntity_excluded() {
|
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 primary = entity("p", 0, 0, 0);
|
||||||
ChainEntity deadOne = dead("dead", 2, 0, 0);
|
ChainEntity deadOne = dead("dead", 2, 0, 0);
|
||||||
ChainEntity alive = entity("alive", 4, 0, 0);
|
ChainEntity alive = entity("alive", 4, 0, 0);
|
||||||
|
|
||||||
List<ChainHit> hits = ChainResolver.resolve(
|
List<ChainHit> hits = ChainResolver.resolve(
|
||||||
ORIGIN, DIR, RAY_MAX, rayHits(primary),
|
RAY_MAX, rayHits(primary), neighborsAlways(List.of(deadOne, alive)), TWO_HOPS);
|
||||||
neighborsAlways(List.of(deadOne, alive)),
|
|
||||||
new ChainParameters(2, 8.0, new int[]{8, 6}));
|
|
||||||
|
|
||||||
assertEquals(2, hits.size());
|
assertEquals(2, hits.size());
|
||||||
assertSame(alive, hits.get(1).target());
|
assertSame(alive, hits.get(1).target());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10
|
|
||||||
@Test
|
@Test
|
||||||
void resolve_customMaxTargets_truncatesEarly() {
|
void resolve_customMaxTargets_truncatesEarly() {
|
||||||
// 5 candidats disponibles mais maxTargets=3 → 3 hits
|
|
||||||
ChainEntity e0 = entity("e0", 0, 0, 0);
|
ChainEntity e0 = entity("e0", 0, 0, 0);
|
||||||
ChainEntity e1 = entity("e1", 1, 0, 0);
|
List<ChainEntity> all = List.of(
|
||||||
ChainEntity e2 = entity("e2", 2, 0, 0);
|
e0,
|
||||||
ChainEntity e3 = entity("e3", 3, 0, 0);
|
entity("e1", 1, 0, 0),
|
||||||
ChainEntity e4 = entity("e4", 4, 0, 0);
|
entity("e2", 2, 0, 0),
|
||||||
List<ChainEntity> all = List.of(e0, e1, e2, e3, e4);
|
entity("e3", 3, 0, 0),
|
||||||
|
entity("e4", 4, 0, 0));
|
||||||
|
|
||||||
ChainParameters p = new ChainParameters(3, 8.0, new int[]{10, 5, 1});
|
ChainParameters p = new ChainParameters(3, 8.0, new int[]{10, 5, 1});
|
||||||
List<ChainHit> hits = ChainResolver.resolve(
|
List<ChainHit> hits = ChainResolver.resolve(RAY_MAX, rayHits(e0), neighborsAlways(all), p);
|
||||||
ORIGIN, DIR, RAY_MAX, rayHits(e0), neighborsAlways(all), p);
|
|
||||||
|
|
||||||
assertEquals(3, hits.size());
|
assertEquals(3, hits.size());
|
||||||
assertEquals(10, hits.get(0).damageHp());
|
assertEquals(10, hits.get(0).damageHp());
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package com.mythlane.chainlightning.chain;
|
package com.mythlane.chainlightning.chain;
|
||||||
|
|
||||||
/**
|
/** Test-only ChainEntity record with concise factories for alive and dead entities. */
|
||||||
* Helper test record implémentant ChainEntity. Permet construction concise dans
|
|
||||||
* les tests : entity("e1", 0, 0, 0) pour un vivant, dead("e2", 5, 0, 0) pour un mort.
|
|
||||||
*/
|
|
||||||
record TestChainEntity(String id, Vec3 position, boolean isAlive) implements ChainEntity {
|
record TestChainEntity(String id, Vec3 position, boolean isAlive) implements ChainEntity {
|
||||||
|
|
||||||
static TestChainEntity entity(String id, double x, double y, double z) {
|
static TestChainEntity entity(String id, double x, double y, double z) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.mythlane.chainlightning.sceptre;
|
package com.mythlane.chainlightning.sceptre;
|
||||||
|
|
||||||
import com.hypixel.hytale.component.CommandBuffer;
|
import com.hypixel.hytale.component.CommandBuffer;
|
||||||
import com.hypixel.hytale.component.ComponentAccessor;
|
|
||||||
import com.hypixel.hytale.component.Ref;
|
import com.hypixel.hytale.component.Ref;
|
||||||
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
|
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
|
||||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
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 com.mythlane.chainlightning.chain.Vec3;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.ArgumentMatchers;
|
||||||
|
|
||||||
import java.util.List;
|
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.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
/**
|
/** Verifies ChainDamageApplier emits one DamageExecutor call per hit; uses the executor seam to skip Hytale runtime init. */
|
||||||
* 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).
|
|
||||||
*/
|
|
||||||
final class ChainDamageApplierTest {
|
final class ChainDamageApplierTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void apply_invokes_executeDamage_per_hit() {
|
void apply_invokes_executeDamage_per_hit() {
|
||||||
@SuppressWarnings("unchecked")
|
Fixture f = new Fixture();
|
||||||
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);
|
|
||||||
|
|
||||||
List<ChainHit> hits = List.of(
|
List<ChainHit> hits = List.of(
|
||||||
hit("a", 8, 0),
|
hit("a", 8, 0),
|
||||||
hit("b", 6, 1),
|
hit("b", 6, 1),
|
||||||
@@ -43,99 +32,82 @@ final class ChainDamageApplierTest {
|
|||||||
hit("e", 2, 4)
|
hit("e", 2, 4)
|
||||||
);
|
);
|
||||||
|
|
||||||
ChainDamageApplier.apply(hits, attacker, buf, executor);
|
ChainDamageApplier.apply(hits, f.attacker, f.buf, f.executor);
|
||||||
|
|
||||||
verify(executor, times(5)).execute(
|
verify(f.executor, times(5)).execute(
|
||||||
org.mockito.ArgumentMatchers.any(),
|
ArgumentMatchers.any(),
|
||||||
org.mockito.ArgumentMatchers.eq(buf),
|
ArgumentMatchers.eq(f.buf),
|
||||||
org.mockito.ArgumentMatchers.any(Damage.class)
|
ArgumentMatchers.any(Damage.class)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void apply_passes_correct_damage_amounts_in_order() {
|
void apply_passes_correct_damage_amounts_in_order() {
|
||||||
@SuppressWarnings("unchecked")
|
Fixture f = new Fixture();
|
||||||
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);
|
|
||||||
|
|
||||||
List<ChainHit> hits = List.of(
|
List<ChainHit> hits = List.of(
|
||||||
hit("a", 8, 0),
|
hit("a", 8, 0),
|
||||||
hit("b", 6, 1),
|
hit("b", 6, 1),
|
||||||
hit("c", 4, 2)
|
hit("c", 4, 2)
|
||||||
);
|
);
|
||||||
|
|
||||||
ArgumentCaptor<Damage> damageCaptor = ArgumentCaptor.forClass(Damage.class);
|
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(
|
verify(f.executor, times(3)).execute(
|
||||||
org.mockito.ArgumentMatchers.any(),
|
ArgumentMatchers.any(),
|
||||||
org.mockito.ArgumentMatchers.eq(buf),
|
ArgumentMatchers.eq(f.buf),
|
||||||
damageCaptor.capture()
|
damageCaptor.capture()
|
||||||
);
|
);
|
||||||
|
|
||||||
assertThat(damageCaptor.getAllValues()).hasSize(3);
|
assertThat(damageCaptor.getAllValues()).hasSize(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void apply_with_empty_list_invokes_nothing() {
|
void apply_with_empty_list_invokes_nothing() {
|
||||||
@SuppressWarnings("unchecked")
|
Fixture f = new Fixture();
|
||||||
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);
|
|
||||||
|
|
||||||
ChainDamageApplier.apply(List.of(), attacker, buf, executor);
|
ChainDamageApplier.apply(List.of(), f.attacker, f.buf, f.executor);
|
||||||
|
|
||||||
verify(executor, never()).execute(
|
verify(f.executor, never()).execute(
|
||||||
org.mockito.ArgumentMatchers.any(),
|
ArgumentMatchers.any(),
|
||||||
org.mockito.ArgumentMatchers.any(),
|
ArgumentMatchers.any(),
|
||||||
org.mockito.ArgumentMatchers.any()
|
ArgumentMatchers.any()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void apply_passes_adapter_ref_not_attacker_ref() {
|
void apply_passes_adapter_ref_not_attacker_ref() {
|
||||||
@SuppressWarnings("unchecked")
|
Fixture f = new Fixture();
|
||||||
Ref<EntityStore> attacker = (Ref<EntityStore>) mock(Ref.class, "attacker");
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Ref<EntityStore> targetRef = (Ref<EntityStore>) mock(Ref.class, "target");
|
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);
|
HytaleEntityAdapter adapter = HytaleEntityAdapter.forTest(targetRef, "target", Vec3.ZERO, false);
|
||||||
ChainHit hit = new ChainHit(adapter, 8, 0);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
ArgumentCaptor<Ref<EntityStore>> refCaptor = ArgumentCaptor.forClass(Ref.class);
|
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(),
|
refCaptor.capture(),
|
||||||
org.mockito.ArgumentMatchers.eq(buf),
|
ArgumentMatchers.eq(f.buf),
|
||||||
org.mockito.ArgumentMatchers.any(Damage.class)
|
ArgumentMatchers.any(Damage.class)
|
||||||
);
|
);
|
||||||
|
|
||||||
assertThat(refCaptor.getValue()).isSameAs(targetRef);
|
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) {
|
private static ChainHit hit(String id, int dmg, int hop) {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Ref<EntityStore> ref = (Ref<EntityStore>) mock(Ref.class, id);
|
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);
|
HytaleEntityAdapter adapter = HytaleEntityAdapter.forTest(ref, id, Vec3.ZERO, false);
|
||||||
return new ChainHit(adapter, dmg, hop);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user