From d43aa4a98c30406621b26e0a3e3a91cebfe751a4 Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Thu, 23 Apr 2026 14:03:58 +0200 Subject: [PATCH] feat(03-04): FallDamageSuppressorSystem + wiring + cleanup [DBG throttle] (Task 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FallDamageSuppressorSystem: subclass DamageEventSystem, cancel Damage cause=Fall quand guard.shouldSuppressFallDamage; FALL_INDEX lazy via DamageCause.getAssetMap().getIndex; groupe inspectDamageGroup - GravityApplier: 3-arg constructor avec FallDamageGuard; first-match region, filtres AffectPlayers/Npcs/Items avant wake, seed addForce paramétré par VerticalForce (remplace hardcode 0.1); notifie guard.markInRegion / markExit (via lastKnownRegion map pour Pass 2) - Cleanup: retire les AtomicInteger counters + log [DBG tick=...] throttle 1s; conserve logs one-shot [DBG npc.woken]/[DBG npc.ctrlNull] - GravityFlipPlugin.setup(): instancie FallDamageGuard, registerSystem sur entityStoreRegistry; start() passe guard au GravityApplier - Imports Query + CommandBuffer alignés sur FlockMembershipSystems --- .../gravityflip/GravityFlipPlugin.java | 13 +- .../physics/FallDamageSuppressorSystem.java | 96 +++++++++ .../gravityflip/physics/GravityApplier.java | 200 ++++++++++-------- 3 files changed, 218 insertions(+), 91 deletions(-) create mode 100644 src/main/java/com/mythlane/gravityflip/physics/FallDamageSuppressorSystem.java diff --git a/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java b/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java index 9a0e05e..591bfb8 100644 --- a/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java +++ b/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java @@ -6,6 +6,8 @@ import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.util.Config; import com.mythlane.gravityflip.config.GravityFlipConfig; +import com.mythlane.gravityflip.physics.FallDamageGuard; +import com.mythlane.gravityflip.physics.FallDamageSuppressorSystem; import com.mythlane.gravityflip.physics.GravityApplier; import com.mythlane.gravityflip.region.RegionRegistry; import com.mythlane.gravityflip.tick.RegionTickLoop; @@ -38,6 +40,7 @@ public class GravityFlipPlugin extends JavaPlugin { private RegionRegistry registry; private RegionTickLoop tickLoop; private GravityApplier gravityApplier; + private FallDamageGuard fallDamageGuard; public GravityFlipPlugin(JavaPluginInit init) { super(init); @@ -55,6 +58,13 @@ public class GravityFlipPlugin extends JavaPlugin { // a Supplier that resolves Universe.get().getDefaultWorld() lazily on each // tick (matching the MythWorld WorldBorderManager precedent). Until the universe // is ready, the supplier returns null and the tick is a no-op. + // Plan 03-04 : enregistrer le FallDamageSuppressorSystem DANS setup() (fenêtre ECS + // de registration). Pattern identique à FlockPlugin.java → entityStoreRegistry.registerSystem(...). + this.fallDamageGuard = new FallDamageGuard(); + getEntityStoreRegistry().registerSystem(new FallDamageSuppressorSystem( + fallDamageGuard, + th -> getLogger().at(Level.WARNING).withCause(th).log("fallDamageSuppressor handle failed"))); + getLogger().at(Level.INFO).log("Gravity Flip enabled"); } @@ -65,7 +75,8 @@ public class GravityFlipPlugin extends JavaPlugin { this.registry = new RegionRegistry(cfg, configHolder); this.gravityApplier = new GravityApplier( th -> getLogger().at(Level.WARNING).withCause(th).log("gravityApply failed"), - msg -> getLogger().at(Level.INFO).log("%s", msg)); + msg -> getLogger().at(Level.INFO).log("%s", msg), + fallDamageGuard); this.tickLoop = new RegionTickLoop(registry, gravityApplier, th -> getLogger().at(Level.WARNING).withCause(th).log("detectTick failed")); diff --git a/src/main/java/com/mythlane/gravityflip/physics/FallDamageSuppressorSystem.java b/src/main/java/com/mythlane/gravityflip/physics/FallDamageSuppressorSystem.java new file mode 100644 index 0000000..1f0d609 --- /dev/null +++ b/src/main/java/com/mythlane/gravityflip/physics/FallDamageSuppressorSystem.java @@ -0,0 +1,96 @@ +package com.mythlane.gravityflip.physics; + +import com.hypixel.hytale.component.Archetype; +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.SystemGroup; +import com.hypixel.hytale.component.query.Query; +import com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.modules.entity.damage.Damage; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageEventSystem; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageModule; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.function.Consumer; + +/** + * ECS {@link DamageEventSystem} that cancels {@link Damage} events of cause {@code Fall} + * for any entity whose UUID is currently suppressed by {@link FallDamageGuard}. + * + *

Registered in {@code GravityFlipPlugin.setup()} via + * {@code getEntityStoreRegistry().registerSystem(new FallDamageSuppressorSystem(guard, errorHandler))}. + * + *

Dispatched in {@link DamageModule#getInspectDamageGroup()} — same group as + * {@code FlockMembershipSystems.OnDamageReceived} (reference template), fires during the + * damage inspection pass before {@code DamageSystems.ApplyDamage} consumes {@code Damage.amount}. + * + *

FALL_INDEX is resolved lazily via {@code DamageCause.getAssetMap().getIndex("Fall")} and + * cached for the life of the server — the asset map is built once during {@code EntityModule} + * setup and stable thereafter. + */ +public final class FallDamageSuppressorSystem extends DamageEventSystem { + + private final FallDamageGuard guard; + private final Consumer errorHandler; + private volatile int fallIndexCache = -1; + + public FallDamageSuppressorSystem(FallDamageGuard guard, Consumer errorHandler) { + this.guard = guard; + this.errorHandler = errorHandler == null ? t -> {} : errorHandler; + } + + @Override + @Nullable + public SystemGroup getGroup() { + return DamageModule.get().getInspectDamageGroup(); + } + + @Override + @Nonnull + public Query getQuery() { + return Archetype.empty(); + } + + @Override + public void handle(int index, + @Nonnull ArchetypeChunk chunk, + @Nonnull Store store, + @Nonnull CommandBuffer cmdBuf, + @Nonnull Damage damage) { + try { + int fall = fallIndex(); + if (fall < 0) return; + if (damage.getDamageCauseIndex() != fall) return; + + UUIDComponent uc; + try { + uc = chunk.getComponent(index, UUIDComponent.getComponentType()); + } catch (Throwable ignored) { return; } + if (uc == null) return; + + if (guard.shouldSuppressFallDamage(uc.getUuid(), System.currentTimeMillis())) { + damage.setCancelled(true); + } + } catch (Throwable th) { + errorHandler.accept(th); + } + } + + private int fallIndex() { + int i = fallIndexCache; + if (i < 0) { + try { + i = DamageCause.getAssetMap().getIndex("Fall"); + fallIndexCache = i; + } catch (Throwable th) { + errorHandler.accept(th); + return -1; + } + } + return i; + } +} diff --git a/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java b/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java index 6a63275..fb72556 100644 --- a/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java +++ b/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java @@ -25,8 +25,6 @@ import java.util.HashSet; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; /** @@ -35,18 +33,31 @@ import java.util.function.Consumer; * {@code CommandBuffer.replaceComponent(...)} inside a {@code Store.forEachEntityParallel(...)} * lambda — the ECS engine commits them on the main thread after the parallel pass. * - *

Phase 03-02: in addition to the ECS toggle (still required for items, consumed by - * {@code ItemPrePhysicsSystem}), we wake up the per-entity cached movement settings: + *

Phase 03-02: wake-ups per-entity : *

* - *

Dedup: a thread-safe {@link Set}{@code } tracks entities flipped at the previous tick. - * Entities that left an active region are restored to {@code invertedGravity=false} in a second - * pass (trade-off documented in the plan — second O(N) scan accepted v1). + *

Phase 03-03: seed {@code addForce(0, +0.1, 0)} on NPCs each tick in-region to activate + * {@code computeNewFallSpeed} path which honours {@code invertedGravity}. + * + *

Phase 03-04: + *

+ * + *

Multi-region precedence : for an entity simultaneously inside N regions, the FIRST + * region encountered in the iteration of {@code snapshot.byRegion().keySet()} (Java insertion + * order via the underlying {@code LinkedHashMap}) drives the config values read this tick + * (VerticalForce, AffectXxx, FallDamage, GracePeriodMs). Rule applies consistently to + * {@link FallDamageGuard#markInRegion} (same first-matched region) and {@link FallDamageGuard#markExit} + * (last-known first-matched region at the previous in-region tick). */ public final class GravityApplier { // Lazy ComponentType holders — pattern identique à RegionRegistry.transform() @@ -132,38 +143,33 @@ public final class GravityApplier { // THREADING: écrit/lu depuis les workers ECS via forEachEntityParallel → ConcurrentHashMap.newKeySet obligatoire. private final Set previouslyInverted = ConcurrentHashMap.newKeySet(); + /** First-matched region per UUID at the previous tick — consulted in Pass 2 for markExit. */ + private final ConcurrentHashMap lastKnownRegion = new ConcurrentHashMap<>(); + private final Consumer errorHandler; private final Consumer infoHandler; + private final FallDamageGuard guard; - // --- Debug diag (throttled counters + per-UUID one-shot) --- - private final AtomicLong tickCounter = new AtomicLong(); - private final AtomicInteger entitiesInRegionTick = new AtomicInteger(); - private final AtomicInteger playersWokenTick = new AtomicInteger(); - private final AtomicInteger npcsSeenTick = new AtomicInteger(); - private final AtomicInteger npcsRoleNullTick = new AtomicInteger(); - private final AtomicInteger npcsControllerNullTick = new AtomicInteger(); - private final AtomicInteger npcsWokenTick = new AtomicInteger(); - private final AtomicInteger otherEntitiesTick = new AtomicInteger(); - private final AtomicInteger wakeExceptionsTick = new AtomicInteger(); + /** One-shot per-UUID log tracking (diagnostic prod) — retained after Plan 03-04 cleanup. */ private final Set loggedNpcUuids = ConcurrentHashMap.newKeySet(); public GravityApplier(Consumer errorHandler) { - this(errorHandler, null); + this(errorHandler, null, new FallDamageGuard()); } public GravityApplier(Consumer errorHandler, Consumer infoHandler) { + this(errorHandler, infoHandler, new FallDamageGuard()); + } + + public GravityApplier(Consumer errorHandler, Consumer infoHandler, FallDamageGuard guard) { this.errorHandler = errorHandler == null ? t -> {} : errorHandler; this.infoHandler = infoHandler == null ? m -> {} : infoHandler; + this.guard = guard == null ? new FallDamageGuard() : guard; } /** * Construit un nouveau {@link PhysicsValues} en copiant mass/drag de la source et en fixant * {@code invertedGravity=target}. Pure data — pas d'effet de bord. - * - *

NOTE test : le constructeur statique de {@link PhysicsValues} dépend du runtime Hytale - * (ModuleRegistry / PluginBase) et échoue hors serveur. La décomposition pure est exposée par - * {@link #buildFlaggedDecision(double, double, boolean)} pour les tests unitaires — Rule 3 - * fallback documenté dans le plan 03-02 (section Task 1 action). */ static PhysicsValues buildPhysicsValuesWithFlag(PhysicsValues source, boolean target) { FlaggedDecision d = buildFlaggedDecision(source.getMass(), source.getDragCoefficient(), target); @@ -188,11 +194,6 @@ public final class GravityApplier { /** Tick entry point. NO-OP si world ou snapshot est null. */ public void apply(World world, RegionSnapshot snapshot) { if (world == null || snapshot == null) return; - // THREADING (fix WorldThread assert 2026-04-23) : `Store.forEachEntityParallel` exige - // d'être initié depuis la WorldThread (assertThread @Store.java:2362). Le tick loop tourne - // sur `GravityFlip-Detect` (ScheduledExecutorService) → on dispatch tout le travail ECS - // via `world.execute(Runnable)`. Le CommandBuffer reste utilisé côté mutations, mais - // ne résout PAS l'assert côté appelant (amendement D-04 du plan 03-01). world.execute(() -> applyOnWorldThread(world, snapshot)); } @@ -226,34 +227,51 @@ public final class GravityApplier { com.hypixel.hytale.math.vector.Vector3d pos = t.getPosition(); double x = pos.x, y = pos.y, z = pos.z; - boolean inAnyRegion = false; + // First-match wins for multi-region precedence (Plan 03-04). + GravityFlipRegion matchedRegion = null; for (GravityFlipRegion r : enabledRegions) { - if (r.asBox().containsPosition(x, y, z)) { inAnyRegion = true; break; } + if (r.asBox().containsPosition(x, y, z)) { matchedRegion = r; break; } } - if (!inAnyRegion) return; + if (matchedRegion == null) return; UUID u = uc.getUuid(); - currentlyInRegion.add(u); - entitiesInRegionTick.incrementAndGet(); - // --- existant (plan 03-01) : flip ECS native --- + // --- Plan 03-04 : AffectXxx filters applied BEFORE wake --- + EntityKind kind = classify(chunk, index, MMT, PRT, PLT, NPCT); + boolean allowed; + switch (kind) { + case PLAYER: allowed = matchedRegion.isAffectPlayers(); break; + case NPC: allowed = matchedRegion.isAffectNpcs(); break; + default: allowed = matchedRegion.isAffectItems(); break; + } + if (!allowed) { + // Entité filtrée : ne PAS la compter dans currentlyInRegion, et + // ne PAS notifier le guard — le filtre se comporte comme hors-zone. + return; + } + + currentlyInRegion.add(u); + lastKnownRegion.put(u, matchedRegion); + guard.markInRegion(u, matchedRegion); + + // --- Flip ECS native (plan 03-01) --- if (!v.isInvertedGravity()) { Ref ref = chunk.getReferenceTo(index); cmdBuf.replaceComponent(ref, PHYST, new PhysicsValues(v.getMass(), v.getDragCoefficient(), true)); } - // --- NOUVEAU (plan 03-02) : wake-up joueur/NPC dans le même lambda --- - wakePlayerOrNpc(chunk, index, v, true, MMT, PRT, PLT, NPCT); + // --- Wake-up joueur/NPC (plan 03-02) + seed VerticalForce (plan 03-03 paramétré 03-04) --- + wakePlayerOrNpc(chunk, index, v, true, matchedRegion, + MMT, PRT, PLT, NPCT); }); - // PASS 2 — restore : pour chaque UUID dans previouslyInverted \ currentlyInRegion, - // re-localiser l'entité (second scan) et queue le flip OFF via cmdBuf + - // wake-up joueur/NPC avec flag=false. + // PASS 2 — restore : pour chaque UUID dans previouslyInverted \ currentlyInRegion. Set toRestore = ConcurrentHashMap.newKeySet(); toRestore.addAll(previouslyInverted); toRestore.removeAll(currentlyInRegion); if (!toRestore.isEmpty()) { + long now = System.currentTimeMillis(); store.forEachEntityParallel(PHYST, (index, chunk, cmdBuf) -> { UUIDComponent uc; PhysicsValues v; @@ -262,7 +280,8 @@ public final class GravityApplier { v = chunk.getComponent(index, PHYST); } catch (Throwable ignored) { return; } if (uc == null || v == null) return; - if (!toRestore.contains(uc.getUuid())) return; + UUID u = uc.getUuid(); + if (!toRestore.contains(u)) return; if (v.isInvertedGravity()) { Ref ref = chunk.getReferenceTo(index); @@ -270,49 +289,63 @@ public final class GravityApplier { new PhysicsValues(v.getMass(), v.getDragCoefficient(), false)); } - // --- NOUVEAU (plan 03-02) : wake-up joueur/NPC avec flag=false --- - wakePlayerOrNpc(chunk, index, v, false, MMT, PRT, PLT, NPCT); + // Wake-up avec flag=false pour restaurer les settings natifs. + wakePlayerOrNpc(chunk, index, v, false, null, MMT, PRT, PLT, NPCT); + + // Plan 03-04 : notifier guard.markExit avec la région last-known + // (première région matchée au tick précédent). + GravityFlipRegion lastRegion = lastKnownRegion.remove(u); + if (lastRegion != null) { + guard.markExit(u, lastRegion, now); + } }); } // Update tracker — ces ops sont sur le tick thread après la fin du pass parallel. previouslyInverted.clear(); previouslyInverted.addAll(currentlyInRegion); - - // --- DEBUG : dump 1×/sec (10 ticks @100ms) --- - long tick = tickCounter.incrementAndGet(); - if (tick % 10 == 0) { - int inReg = entitiesInRegionTick.getAndSet(0); - int players = playersWokenTick.getAndSet(0); - int npcs = npcsSeenTick.getAndSet(0); - int roleNull = npcsRoleNullTick.getAndSet(0); - int ctrlNull = npcsControllerNullTick.getAndSet(0); - int npcsWoken = npcsWokenTick.getAndSet(0); - int others = otherEntitiesTick.getAndSet(0); - int excs = wakeExceptionsTick.getAndSet(0); - if (inReg > 0 || npcs > 0 || players > 0 || excs > 0) { - infoHandler.accept(String.format( - "[DBG tick=%d] entitiesInRegion=%d players=%d npcs=%d (roleNull=%d ctrlNull=%d woken=%d) others=%d excs=%d regions=%d", - tick, inReg, players, npcs, roleNull, ctrlNull, npcsWoken, others, excs, enabledRegions.size())); - } - } } catch (Throwable th) { errorHandler.accept(th); } } + private enum EntityKind { PLAYER, NPC, OTHER } + + /** Classify the entity at {@code index} into player / NPC / other (items fall into other). */ + private EntityKind classify(ArchetypeChunk chunk, int index, + ComponentType MMT, + ComponentType PRT, + ComponentType PLT, + ComponentType NPCT) { + try { + MovementManager mm = chunk.getComponent(index, MMT); + if (mm != null) { + PlayerRef pr = null; Player pl = null; + try { pr = chunk.getComponent(index, PRT); } catch (Throwable ignored) {} + try { pl = chunk.getComponent(index, PLT); } catch (Throwable ignored) {} + if (pr != null && pl != null) return EntityKind.PLAYER; + } + } catch (Throwable ignored) {} + try { + NPCEntity npc = chunk.getComponent(index, NPCT); + if (npc != null) return EntityKind.NPC; + } catch (Throwable ignored) {} + return EntityKind.OTHER; + } + /** * Wake-up dans le MÊME lambda parallèle : * - joueur (MovementManager + PlayerRef + Player) → setDefaultSettings + applyDefaultSettings + update(ph) * - NPC (NPCEntity avec role non-null et active controller non-null) → updatePhysicsValues(targetValues) * - sinon (items, autres) : no-op (le flip cmdBuf.replaceComponent du pass 1 suffit) * - *

On NE relit PAS le PhysicsValues via l'ECS — le cmdBuf n'est pas encore commit. On construit - * localement le PhysicsValues cible via {@link #buildPhysicsValuesWithFlag}. + *

Plan 03-04 : le paramètre {@code matchedRegion} (non-null en entrée, null en sortie) + * fournit {@code VerticalForce} — remplace le hardcode 0.1 du plan 03-03. */ private void wakePlayerOrNpc( ArchetypeChunk chunk, int index, PhysicsValues sourceValues, boolean targetFlag, + GravityFlipRegion matchedRegion, ComponentType MMT, ComponentType PRT, ComponentType PLT, @@ -332,12 +365,10 @@ public final class GravityApplier { mm.applyDefaultSettings(); PacketHandler ph = pr.getPacketHandler(); mm.update(ph); - playersWokenTick.incrementAndGet(); } catch (Throwable th) { - wakeExceptionsTick.incrementAndGet(); errorHandler.accept(th); } - return; // un joueur n'est pas un NPC — court-circuit + return; // un joueur n'est pas un NPC } } @@ -345,16 +376,14 @@ public final class GravityApplier { NPCEntity npc = null; try { npc = chunk.getComponent(index, NPCT); } catch (Throwable ignored) {} if (npc != null) { - npcsSeenTick.incrementAndGet(); try { Role role = npc.getRole(); if (role == null) { - npcsRoleNullTick.incrementAndGet(); + // rôle non résolu — rien à faire, no-op } else { MotionController active = role.getActiveMotionController(); if (active == null) { - npcsControllerNullTick.incrementAndGet(); - // log one-shot : rôle non-null mais controller null + // log one-shot : rôle non-null mais controller null (utile diag prod) UUIDComponent uc = null; try { uc = chunk.getComponent(index, uuidType()); } catch (Throwable ignored) {} if (uc != null && loggedNpcUuids.add(uc.getUuid())) { @@ -364,29 +393,22 @@ public final class GravityApplier { } } else { active.updatePhysicsValues(targetValues); - npcsWokenTick.incrementAndGet(); - // --- NOUVEAU (plan 03-03) : seed forceVelocity.y pour activer le path - // MotionControllerWalk ligne ~881 qui appelle computeNewFallSpeed — - // seule fonction qui HONORE movementSettings.invertedGravity pour NPCs. - // Le path normal WALKING/DESCENDING utilise this.gravity (non-inverse, - // clampé ≥0), d'où l'absence d'effet du flip natif sur les moutons. - // Un seed de +0.1 à chaque tick suffit pour que forceVelocity.y ≠ 0 ; - // ensuite computeNewFallSpeed applique l'accel terminal inversée. - // Au sortie (targetFlag=false), on n'ajoute rien — la damping - // +epsilon (cf. MotionControllerBase line 635-637) zéroe naturellement. - if (targetFlag) { + // Plan 03-04 : seed forceVelocity.y paramétré par VerticalForce (remplace + // le hardcode 0.1 de Plan 03-03). Uniquement en entrée (targetFlag=true) — + // à la sortie le damping natif zéroe forceVelocity. + if (targetFlag && matchedRegion != null) { + double vf = matchedRegion.getVerticalForce(); try { active.addForce( - new com.hypixel.hytale.math.vector.Vector3d(0, 0.1, 0), + new com.hypixel.hytale.math.vector.Vector3d(0, vf, 0), null); } catch (Throwable th) { - wakeExceptionsTick.incrementAndGet(); errorHandler.accept(th); } } - // log one-shot : première fois qu'on voit cet UUID NPC — on dump le controller class + // log one-shot : première fois qu'on voit cet UUID NPC UUIDComponent uc = null; try { uc = chunk.getComponent(index, uuidType()); } catch (Throwable ignored) {} if (uc != null && loggedNpcUuids.add(uc.getUuid())) { @@ -397,14 +419,12 @@ public final class GravityApplier { } } } catch (Throwable th) { - wakeExceptionsTick.incrementAndGet(); errorHandler.accept(th); } return; } - // sinon : item / autre — pas de wake-up, le cmdBuf.replaceComponent du pass 1 suffit - otherEntitiesTick.incrementAndGet(); + // sinon : item / autre — no-op } /** Pure data-diff utilitaire pour tests unitaires (pas de runtime Hytale). */