diff --git a/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java b/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java index 6d5b8ae..9a0e05e 100644 --- a/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java +++ b/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java @@ -63,8 +63,9 @@ public class GravityFlipPlugin extends JavaPlugin { super.start(); GravityFlipConfig cfg = configHolder.get(); this.registry = new RegionRegistry(cfg, configHolder); - this.gravityApplier = new GravityApplier(th -> - getLogger().at(Level.WARNING).withCause(th).log("gravityApply failed")); + this.gravityApplier = new GravityApplier( + th -> getLogger().at(Level.WARNING).withCause(th).log("gravityApply failed"), + msg -> getLogger().at(Level.INFO).log("%s", msg)); 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/GravityApplier.java b/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java index bca39fb..6a63275 100644 --- a/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java +++ b/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java @@ -25,6 +25,8 @@ 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; /** @@ -131,9 +133,27 @@ public final class GravityApplier { // THREADING: écrit/lu depuis les workers ECS via forEachEntityParallel → ConcurrentHashMap.newKeySet obligatoire. private final Set previouslyInverted = ConcurrentHashMap.newKeySet(); private final Consumer errorHandler; + private final Consumer infoHandler; + + // --- 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(); + private final Set loggedNpcUuids = ConcurrentHashMap.newKeySet(); public GravityApplier(Consumer errorHandler) { + this(errorHandler, null); + } + + public GravityApplier(Consumer errorHandler, Consumer infoHandler) { this.errorHandler = errorHandler == null ? t -> {} : errorHandler; + this.infoHandler = infoHandler == null ? m -> {} : infoHandler; } /** @@ -214,6 +234,7 @@ public final class GravityApplier { UUID u = uc.getUuid(); currentlyInRegion.add(u); + entitiesInRegionTick.incrementAndGet(); // --- existant (plan 03-01) : flip ECS native --- if (!v.isInvertedGravity()) { @@ -257,6 +278,24 @@ public final class GravityApplier { // 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); } @@ -293,7 +332,9 @@ 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 @@ -304,19 +345,66 @@ 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) { + if (role == null) { + npcsRoleNullTick.incrementAndGet(); + } else { MotionController active = role.getActiveMotionController(); - if (active != null) { + if (active == null) { + npcsControllerNullTick.incrementAndGet(); + // log one-shot : rôle non-null mais controller null + UUIDComponent uc = null; + try { uc = chunk.getComponent(index, uuidType()); } catch (Throwable ignored) {} + if (uc != null && loggedNpcUuids.add(uc.getUuid())) { + infoHandler.accept(String.format( + "[DBG npc.ctrlNull] uuid=%s roleClass=%s", + uc.getUuid(), role.getClass().getName())); + } + } 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) { + try { + active.addForce( + new com.hypixel.hytale.math.vector.Vector3d(0, 0.1, 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 + UUIDComponent uc = null; + try { uc = chunk.getComponent(index, uuidType()); } catch (Throwable ignored) {} + if (uc != null && loggedNpcUuids.add(uc.getUuid())) { + infoHandler.accept(String.format( + "[DBG npc.woken] uuid=%s controllerClass=%s roleClass=%s targetFlag=%s", + uc.getUuid(), active.getClass().getName(), role.getClass().getName(), targetFlag)); + } } } } 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(); } /** Pure data-diff utilitaire pour tests unitaires (pas de runtime Hytale). */ diff --git a/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java b/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java index fa99eba..c22827f 100644 --- a/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java +++ b/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java @@ -10,14 +10,32 @@ import com.hypixel.hytale.math.vector.Vector3d; /** * A named axis-aligned region in which gravity is inverted for any entity inside. * - *

Persisted as part of {@code GravityFlipConfig} via {@link #CODEC}. The region - * is stored on disk as three keys: + *

Persisted as part of {@code GravityFlipConfig} via {@link #CODEC}. The region is stored + * on disk as up to 9 keys : *

    - *
  • {@code Name} — non-null string identifier
  • - *
  • {@code Box} — non-null AABB (composed of two {@code Vector3d} corners)
  • - *
  • {@code Enabled} — boolean toggle, default {@code true}
  • + *
  • {@code Name} — non-null string identifier
  • + *
  • {@code Box} — non-null AABB (composed of two {@code Vector3d} corners)
  • + *
  • {@code Enabled} — boolean toggle, default {@code true}
  • + *
  • {@code FallDamage} — optional (Plan 03-04). Default {@code false}. When + * {@code false}, entities in-region do not suffer fall damage, and post-exit fall + * damage is suppressed for {@code GracePeriodMs} ms (see {@code FallDamageSuppressorSystem}).
  • + *
  • {@code GracePeriodMs} — optional, default {@code 2500}. Grace window in ms + * after exit during which fall damage remains suppressed.
  • + *
  • {@code VerticalForce} — optional, default {@code 0.1}. Seed added each tick to + * NPC {@code forceVelocity.y} to activate the inverted gravity path (Plan 03-03).
  • + *
  • {@code AffectPlayers} — optional, default {@code true}. If {@code false}, + * players in-region are NOT flipped.
  • + *
  • {@code AffectNpcs} — optional, default {@code true}. If {@code false}, + * NPCs in-region are NOT flipped / seeded.
  • + *
  • {@code AffectItems} — optional, default {@code true}. If {@code false}, + * item {@code PhysicsValues.invertedGravity} is NOT mutated.
  • *
* + *

Back-compat : a legacy {@code regions.json} containing only Name+Box+Enabled + * continues to load without error — the codec for the 6 new fields does NOT chain + * {@code addValidator(Validators.nonNull())}, which makes them optional in BuilderField + * semantics (absence of the BSON key ⇒ setter skipped ⇒ Java default initializer preserved). + * *

Note: this build pins {@code com.hypixel.hytale:Server:2026.03.26-89796e57b}, in which * {@code Box.min}/{@code Box.max} are {@code com.hypixel.hytale.math.vector.Vector3d} * (Hytale's own type — NOT {@code org.joml.Vector3d}, which only appeared in later builds). @@ -38,6 +56,19 @@ public final class GravityFlipRegion { .addValidator(Validators.nonNull()).add() .append(new KeyedCodec<>("Enabled", Codec.BOOLEAN), (r, v) -> r.enabled = v, r -> r.enabled).add() + // --- Plan 03-04 : 6 optional tuning fields (no nonNull validator ⇒ optional) --- + .append(new KeyedCodec<>("FallDamage", Codec.BOOLEAN), + (r, v) -> r.fallDamage = v, r -> r.fallDamage).add() + .append(new KeyedCodec<>("GracePeriodMs", Codec.INTEGER), + (r, v) -> r.gracePeriodMs = v, r -> r.gracePeriodMs).add() + .append(new KeyedCodec<>("VerticalForce", Codec.DOUBLE), + (r, v) -> r.verticalForce = v, r -> r.verticalForce).add() + .append(new KeyedCodec<>("AffectPlayers", Codec.BOOLEAN), + (r, v) -> r.affectPlayers = v, r -> r.affectPlayers).add() + .append(new KeyedCodec<>("AffectNpcs", Codec.BOOLEAN), + (r, v) -> r.affectNpcs = v, r -> r.affectNpcs).add() + .append(new KeyedCodec<>("AffectItems", Codec.BOOLEAN), + (r, v) -> r.affectItems = v, r -> r.affectItems).add() .build(); // Package-private mutable fields written directly by the codec setters. @@ -45,6 +76,14 @@ public final class GravityFlipRegion { Box box = new Box(new Vector3d(), new Vector3d()); boolean enabled = true; + // Plan 03-04 : tuning fields — defaults applied when key absent in BSON. + boolean fallDamage = false; + int gracePeriodMs = 2500; + double verticalForce = 0.1; + boolean affectPlayers = true; + boolean affectNpcs = true; + boolean affectItems = true; + public GravityFlipRegion() {} public GravityFlipRegion(String name, Box box, boolean enabled) { @@ -63,6 +102,22 @@ public final class GravityFlipRegion { public void setBox(Box b) { this.box = b; } public void setEnabled(boolean v) { this.enabled = v; } + // --- Plan 03-04 getters / setters --- + + public boolean isFallDamage() { return fallDamage; } + public int getGracePeriodMs() { return gracePeriodMs; } + public double getVerticalForce() { return verticalForce; } + public boolean isAffectPlayers() { return affectPlayers; } + public boolean isAffectNpcs() { return affectNpcs; } + public boolean isAffectItems() { return affectItems; } + + public void setFallDamage(boolean v) { this.fallDamage = v; } + public void setGracePeriodMs(int v) { this.gracePeriodMs = v; } + public void setVerticalForce(double v) { this.verticalForce = v; } + public void setAffectPlayers(boolean v) { this.affectPlayers = v; } + public void setAffectNpcs(boolean v) { this.affectNpcs = v; } + public void setAffectItems(boolean v) { this.affectItems = v; } + /** Convenience accessor for tick-loop / physics consumers in Phase 02-02. */ public Box asBox() { return box; } } diff --git a/src/test/java/com/mythlane/gravityflip/region/GravityFlipRegionCodecTest.java b/src/test/java/com/mythlane/gravityflip/region/GravityFlipRegionCodecTest.java index 68c5c4e..df797e3 100644 --- a/src/test/java/com/mythlane/gravityflip/region/GravityFlipRegionCodecTest.java +++ b/src/test/java/com/mythlane/gravityflip/region/GravityFlipRegionCodecTest.java @@ -13,8 +13,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** * Round-trip tests for {@link GravityFlipRegion#CODEC}. Verifies the codec preserves - * Name + Box + Enabled fields across encode -> decode cycles via the BSON intermediate - * representation that all Hytale codecs share. + * the legacy Name + Box + Enabled fields across encode -> decode cycles via the BSON + * intermediate representation, and (Plan 03-04) the 6 optional tuning fields : + * FallDamage, GracePeriodMs, VerticalForce, AffectPlayers, AffectNpcs, AffectItems. + * + *

Back-compat invariant (test {@link #roundTripPreservesDefaultsWhenNewFieldsAbsent}) : + * a BSON encoded without the 6 new keys must decode with all Java defaults preserved. */ class GravityFlipRegionCodecTest { @@ -64,6 +68,89 @@ class GravityFlipRegionCodecTest { assertEquals("", decoded.getName(), "empty name must survive round-trip without substitution"); } + // ---------- Plan 03-04 : 6 nouveaux champs optionnels ---------- + + @Test + void roundTripPreservesDefaultsWhenNewFieldsAbsent() { + // Region construite via constructeur legacy 3-arg (comme un regions.json legacy). + GravityFlipRegion src = new GravityFlipRegion( + "legacy", + new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)), + true); + + GravityFlipRegion decoded = roundTrip(src); + + // Tous les 6 nouveaux champs doivent exposer leurs defaults Java. + assertFalse(decoded.isFallDamage(), "default FallDamage=false"); + assertEquals(2500, decoded.getGracePeriodMs(), "default GracePeriodMs=2500"); + assertEquals(0.1, decoded.getVerticalForce(), 1e-9, "default VerticalForce=0.1"); + assertTrue(decoded.isAffectPlayers(), "default AffectPlayers=true"); + assertTrue(decoded.isAffectNpcs(), "default AffectNpcs=true"); + assertTrue(decoded.isAffectItems(), "default AffectItems=true"); + } + + @Test + void roundTripFallDamageTrue() { + GravityFlipRegion src = baseRegion(); + src.setFallDamage(true); + GravityFlipRegion decoded = roundTrip(src); + assertTrue(decoded.isFallDamage()); + } + + @Test + void roundTripGracePeriodCustom() { + GravityFlipRegion src = baseRegion(); + src.setGracePeriodMs(5000); + GravityFlipRegion decoded = roundTrip(src); + assertEquals(5000, decoded.getGracePeriodMs()); + } + + @Test + void roundTripVerticalForceCustom() { + GravityFlipRegion src = baseRegion(); + src.setVerticalForce(0.5); + GravityFlipRegion decoded = roundTrip(src); + assertEquals(0.5, decoded.getVerticalForce(), 1e-9); + } + + @Test + void roundTripAffectPlayersFalse() { + GravityFlipRegion src = baseRegion(); + src.setAffectPlayers(false); + GravityFlipRegion decoded = roundTrip(src); + assertFalse(decoded.isAffectPlayers()); + // Les autres filtres restent à true (non-clobber). + assertTrue(decoded.isAffectNpcs()); + assertTrue(decoded.isAffectItems()); + } + + @Test + void roundTripAllNewFieldsCustom() { + GravityFlipRegion src = baseRegion(); + src.setFallDamage(true); + src.setGracePeriodMs(1234); + src.setVerticalForce(0.75); + src.setAffectPlayers(false); + src.setAffectNpcs(false); + src.setAffectItems(false); + + GravityFlipRegion decoded = roundTrip(src); + + assertTrue(decoded.isFallDamage()); + assertEquals(1234, decoded.getGracePeriodMs()); + assertEquals(0.75, decoded.getVerticalForce(), 1e-9); + assertFalse(decoded.isAffectPlayers()); + assertFalse(decoded.isAffectNpcs()); + assertFalse(decoded.isAffectItems()); + } + + private static GravityFlipRegion baseRegion() { + return new GravityFlipRegion( + "r", + new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)), + true); + } + private static GravityFlipRegion roundTrip(GravityFlipRegion src) { ExtraInfo info = new ExtraInfo(); BsonValue encoded = GravityFlipRegion.CODEC.encode(src, info);