feat(phase-3): runtime integration — chain damage application + cooldown
Wires the pure ChainResolver (Phase 2) to the live Hytale runtime via four sceptre/ adapters: - HytaleEntityAdapter — eager snapshot Ref<EntityStore> -> ChainEntity via TransformComponent.getPosition() and EntityStatMap.get(health) > 0 - HytalePlayerRayCaster — captures playerRef, delegates to TargetUtil.getTargetEntity (auto eye-origin + head-rotation) - HytaleEntitySource — wraps TargetUtil.getAllEntitiesInSphere - ChainDamageApplier — fires DamageSystems.executeDamage per hit; injectable DamageExecutor SAM keeps the helper unit-testable. ChainLightningSceptreInteraction.firstRun is rewritten end-to-end: cooldown gate (hasCooldown(false) -> deductCharge() only on success), ray-cast -> BFS -> damage application, structured logging, try/catch wrapper to keep a runtime fault from killing the server tick. API corrections discovered against the decompiled jar: - Ref has no uuid() — use "ref:" + getIndex() for the chain id - DamageCause.PHYSICAL is @Nullable until runtime — use the int-index overload of Damage with index 0 - Static mock of DamageSystems crashes class init — abstracted behind a DamageExecutor SAM with a default lazy holder Tests: 33/33 green (25 from Phase 2 + 4 ChainDamageApplier tests + 4 fixture sanity). ./gradlew build SUCCESSFUL, JAR auto-deployed. MANUAL UAT (10 items) pending in-game.
This commit is contained in:
@@ -27,6 +27,8 @@ dependencies {
|
||||
testImplementation("com.hypixel.hytale:Server:$hytaleServerVersion")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
testImplementation("org.mockito:mockito-core:5.14.2")
|
||||
testImplementation("org.assertj:assertj-core:3.26.3")
|
||||
}
|
||||
|
||||
java {
|
||||
@@ -91,5 +93,7 @@ tasks {
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
// Byte Buddy (Mockito) ne supporte pas encore Java 25 officiellement — flag experimental requis.
|
||||
jvmArgs("-Dnet.bytebuddy.experimental=true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ package com.mythlane.chainlightning.chain;
|
||||
*/
|
||||
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. */
|
||||
public double distanceSquared(Vec3 other) {
|
||||
double dx = this.x - other.x;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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;
|
||||
// DamageCause import supprime : on utilise l'index int pour eviter la dependance au runtime Hytale
|
||||
// import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.DamageSystems;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import com.mythlane.chainlightning.chain.ChainHit;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Helper static qui applique {@link DamageSystems#executeDamage} pour chaque hit d'une chaine resolue.
|
||||
*
|
||||
* <p>Utilise {@link DamageCause#PHYSICAL} (deprecie mais fonctionnel -- RESEARCH Q5). Une cause
|
||||
* custom "chain_lightning" via DamageCause asset map est deferee v2.
|
||||
*
|
||||
* <p>Cast explicite {@code ChainHit.target() -> HytaleEntityAdapter} : Phase 3 garantit que c'est
|
||||
* la SEULE implementation de ChainEntity produite par les adapters Phase 3 (les snapshots
|
||||
* proviennent uniquement de HytalePlayerRayCaster.firstHit + HytaleEntitySource.nearby).
|
||||
*
|
||||
* <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).
|
||||
*/
|
||||
public final class ChainDamageApplier {
|
||||
|
||||
/**
|
||||
* SAM injectable pour l'application des degats -- permet de stubber DamageSystems en test.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface DamageExecutor {
|
||||
void execute(@Nonnull Ref<EntityStore> target,
|
||||
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
||||
@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")
|
||||
private static final class DefaultHolder {
|
||||
static final DamageExecutor INSTANCE = DamageSystems::executeDamage;
|
||||
}
|
||||
|
||||
/** Retourne l'executeur par defaut (DamageSystems.executeDamage). Lazy-init. */
|
||||
public static DamageExecutor defaultExecutor() {
|
||||
return DefaultHolder.INSTANCE;
|
||||
}
|
||||
|
||||
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)
|
||||
*/
|
||||
public static void apply(@Nonnull List<ChainHit> hits,
|
||||
@Nonnull Ref<EntityStore> attacker,
|
||||
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
||||
@Nonnull ComponentAccessor<EntityStore> accessor) {
|
||||
apply(hits, attacker, commandBuffer, defaultExecutor());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
@SuppressWarnings("deprecation")
|
||||
public static void apply(@Nonnull List<ChainHit> hits,
|
||||
@Nonnull Ref<EntityStore> attacker,
|
||||
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
||||
@Nonnull DamageExecutor executor) {
|
||||
for (ChainHit hit : hits) {
|
||||
HytaleEntityAdapter adapter = (HytaleEntityAdapter) hit.target();
|
||||
// Utilise l'overload int damageCauseIndex pour eviter DamageCause.PHYSICAL (null hors runtime).
|
||||
// Index 0 = PHYSICAL par convention dans l'asset map Hytale (verifie : DamageCause.CODEC
|
||||
// initialise PHYSICAL en premier lors du boot serveur).
|
||||
Damage damage = new Damage(
|
||||
new Damage.EntitySource(attacker),
|
||||
0, // damageCauseIndex 0 = PHYSICAL
|
||||
(float) hit.damageHp()
|
||||
);
|
||||
executor.execute(adapter.ref(), commandBuffer, damage);
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
-25
@@ -7,22 +7,53 @@ import com.hypixel.hytale.protocol.InteractionType;
|
||||
import com.hypixel.hytale.server.core.entity.InteractionContext;
|
||||
import com.hypixel.hytale.server.core.modules.interaction.interaction.CooldownHandler;
|
||||
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.SimpleInstantInteraction;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
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.UUID;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Phase 1 stub interaction handler for the Chain Lightning Sceptre item.
|
||||
* Logs the click + applies a 4s cooldown smoke-test. Full chain logic lands in Phase 3.
|
||||
* 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 {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(ChainLightningSceptreInteraction.class.getName());
|
||||
|
||||
// --- Constantes cooldown (per RESEARCH Q3) ---
|
||||
private static final String COOLDOWN_ID = "chain_lightning_sceptre";
|
||||
private static final float COOLDOWN_TIME = 4.0f;
|
||||
private static final float[] CHARGE_TIMES = new float[]{4.0f};
|
||||
private static final boolean FORCE_CREATE = true;
|
||||
private static final boolean INTERRUPT_RECHARGE = false;
|
||||
|
||||
// --- 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
|
||||
@@ -40,31 +71,65 @@ public final class ChainLightningSceptreInteraction extends SimpleInstantInterac
|
||||
protected void firstRun(@Nonnull InteractionType type,
|
||||
@Nonnull InteractionContext context,
|
||||
@Nonnull CooldownHandler cooldownHandler) {
|
||||
// Phase 1: stub only. Identify the player for traceability, log, then arm the cooldown
|
||||
// (smoke test of the CooldownHandler API — actual chain resolution lands in Phase 3).
|
||||
UUID playerUuid = null;
|
||||
// --- É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.log(Level.WARNING, "[ChainLightning] cooldown handler returned null — aborting");
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Étape 2 : check cooldown sans décompter ---
|
||||
if (cooldown.hasCooldown(false)) {
|
||||
return; // refus silencieux
|
||||
}
|
||||
|
||||
try {
|
||||
Ref<EntityStore> entityRef = context.getEntity();
|
||||
// --- Étape 3 : extraire player + commandBuffer ---
|
||||
Ref<EntityStore> playerRef = context.getEntity();
|
||||
CommandBuffer<EntityStore> commandBuffer = context.getCommandBuffer();
|
||||
if (entityRef != null && commandBuffer != null) {
|
||||
PlayerRef playerRef = commandBuffer.getComponent(entityRef, PlayerRef.getComponentType());
|
||||
if (playerRef != null) {
|
||||
playerUuid = playerRef.getUuid();
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
// Defensive: never fail the click because of telemetry.
|
||||
if (playerRef == null || commandBuffer == null) {
|
||||
LOGGER.log(Level.FINE, "[ChainLightning] 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);
|
||||
|
||||
// --- Étape 5 : résolution BFS (origin/direction = placeholders ignorés par le wrapper) ---
|
||||
List<ChainHit> hits = ChainResolver.resolve(
|
||||
Vec3.ZERO, Vec3.ZERO, RAY_MAX_BLOCKS,
|
||||
ray, neighbors, ChainParameters.DEFAULT
|
||||
);
|
||||
|
||||
// --- Étape 6 : pas de cible → return SANS cooldown ---
|
||||
if (hits.isEmpty()) {
|
||||
LOGGER.log(Level.FINE, "[ChainLightning] no target");
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Étape 7 : appliquer les dégâts ---
|
||||
ChainDamageApplier.apply(hits, playerRef, commandBuffer, commandBuffer);
|
||||
|
||||
// --- Étape 8 : démarrer le cooldown APRÈS succès ---
|
||||
cooldown.deductCharge();
|
||||
|
||||
// --- Étape 9 : log structuré ---
|
||||
if (LOGGER.isLoggable(Level.INFO)) {
|
||||
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.log(Level.INFO,
|
||||
"[ChainLightning] sceptre clicked by player {0} (type={1})",
|
||||
new Object[]{playerUuid, type});
|
||||
|
||||
// TODO Phase 3: wire cooldown via CooldownHandler.
|
||||
// Confirmed API (javap on Server-2026.03.26-89796e57b.jar):
|
||||
// CooldownHandler has NO cooldown(Duration) method.
|
||||
// Use: cooldownHandler.getCooldown("primary").setCooldownMax(4f)
|
||||
// then cooldownHandler.resetCooldown("primary", ...) on next tick.
|
||||
// Full cooldown integration deferred to Phase 3 to keep Phase 1 build green.
|
||||
"[ChainLightning] ref:{0} chained {1} targets [{2}]",
|
||||
new Object[]{"ref:" + playerRef.getIndex(), hits.size(), ids.toString()});
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
// CONTEXT decision : try/catch global pour éviter crash tick serveur
|
||||
LOGGER.log(Level.WARNING, "[ChainLightning] chain resolution failed", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.mythlane.chainlightning.sceptre;
|
||||
|
||||
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.modules.entity.component.TransformComponent;
|
||||
import com.hypixel.hytale.server.core.modules.entitystats.EntityStatMap;
|
||||
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.Vec3;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
private final Ref<EntityStore> ref;
|
||||
private final String id;
|
||||
private final Vec3 position;
|
||||
private final boolean alive;
|
||||
|
||||
private HytaleEntityAdapter(Ref<EntityStore> ref, String id, Vec3 position, boolean alive) {
|
||||
this.ref = ref;
|
||||
this.id = id;
|
||||
this.position = position;
|
||||
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).
|
||||
*/
|
||||
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<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
|
||||
public static HytaleEntityAdapter snapshot(@Nonnull Ref<EntityStore> ref,
|
||||
@Nonnull ComponentAccessor<EntityStore> accessor) {
|
||||
String id = "ref:" + ref.getIndex();
|
||||
|
||||
TransformComponent tc = accessor.getComponent(ref, TransformComponent.getComponentType());
|
||||
if (tc == null) {
|
||||
return new HytaleEntityAdapter(ref, id, Vec3.ZERO, false);
|
||||
}
|
||||
Vector3d pos = tc.getPosition();
|
||||
Vec3 vec = new Vec3(pos.x, pos.y, pos.z);
|
||||
|
||||
EntityStatMap statMap = accessor.getComponent(ref, EntityStatMap.getComponentType());
|
||||
EntityStatValue health = statMap != null ? statMap.get(DefaultEntityStatTypes.getHealth()) : null;
|
||||
boolean alive = health != null && health.get() > 0.0f;
|
||||
|
||||
return new HytaleEntityAdapter(ref, id, vec, alive);
|
||||
}
|
||||
|
||||
/** Reference Hytale sous-jacente, exposee pour DamageSystems.executeDamage. */
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.mythlane.chainlightning.sceptre;
|
||||
|
||||
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.universe.world.storage.EntityStore;
|
||||
import com.hypixel.hytale.server.core.util.TargetUtil;
|
||||
import com.mythlane.chainlightning.chain.ChainEntity;
|
||||
import com.mythlane.chainlightning.chain.EntitySource;
|
||||
import com.mythlane.chainlightning.chain.Vec3;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
private final ComponentAccessor<EntityStore> accessor;
|
||||
|
||||
public HytaleEntitySource(@Nonnull ComponentAccessor<EntityStore> accessor) {
|
||||
this.accessor = accessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChainEntity> nearby(Vec3 origin, double radius) {
|
||||
Vector3d hytaleOrigin = new Vector3d(origin.x(), origin.y(), origin.z());
|
||||
List<Ref<EntityStore>> refs = TargetUtil.getAllEntitiesInSphere(hytaleOrigin, radius, accessor);
|
||||
List<ChainEntity> snapshots = new ArrayList<>(refs.size());
|
||||
for (Ref<EntityStore> ref : refs) {
|
||||
snapshots.add(HytaleEntityAdapter.snapshot(ref, accessor));
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.mythlane.chainlightning.sceptre;
|
||||
|
||||
import com.hypixel.hytale.component.ComponentAccessor;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
private final Ref<EntityStore> playerRef;
|
||||
private final ComponentAccessor<EntityStore> accessor;
|
||||
|
||||
public HytalePlayerRayCaster(@Nonnull Ref<EntityStore> playerRef,
|
||||
@Nonnull ComponentAccessor<EntityStore> accessor) {
|
||||
this.playerRef = playerRef;
|
||||
this.accessor = accessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ChainEntity> firstHit(Vec3 originIgnored, Vec3 directionIgnored, double maxBlocks) {
|
||||
Ref<EntityStore> target = TargetUtil.getTargetEntity(playerRef, (float) maxBlocks, accessor);
|
||||
if (target == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(HytaleEntityAdapter.snapshot(target, accessor));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
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;
|
||||
import com.mythlane.chainlightning.chain.ChainHit;
|
||||
import com.mythlane.chainlightning.chain.Vec3;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
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).
|
||||
*/
|
||||
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);
|
||||
|
||||
List<ChainHit> hits = List.of(
|
||||
hit("a", 8, 0),
|
||||
hit("b", 6, 1),
|
||||
hit("c", 4, 2),
|
||||
hit("d", 3, 3),
|
||||
hit("e", 2, 4)
|
||||
);
|
||||
|
||||
ChainDamageApplier.apply(hits, attacker, buf, executor);
|
||||
|
||||
verify(executor, times(5)).execute(
|
||||
org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.eq(buf),
|
||||
org.mockito.ArgumentMatchers.any(Damage.class)
|
||||
);
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
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);
|
||||
|
||||
verify(executor, times(3)).execute(
|
||||
org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.eq(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);
|
||||
|
||||
ChainDamageApplier.apply(List.of(), attacker, buf, executor);
|
||||
|
||||
verify(executor, never()).execute(
|
||||
org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.any()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void apply_passes_adapter_ref_not_attacker_ref() {
|
||||
@SuppressWarnings("unchecked")
|
||||
Ref<EntityStore> attacker = (Ref<EntityStore>) mock(Ref.class, "attacker");
|
||||
@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);
|
||||
|
||||
verify(executor, times(1)).execute(
|
||||
refCaptor.capture(),
|
||||
org.mockito.ArgumentMatchers.eq(buf),
|
||||
org.mockito.ArgumentMatchers.any(Damage.class)
|
||||
);
|
||||
|
||||
assertThat(refCaptor.getValue()).isSameAs(targetRef);
|
||||
assertThat(refCaptor.getValue()).isNotSameAs(attacker);
|
||||
}
|
||||
|
||||
// --- helper : cree un ChainHit avec un HytaleEntityAdapter mock-friendly ---
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user