feat(03-04): ajoute 6 champs optionnels sur GravityFlipRegion (Task 1)

- POJO: FallDamage (false), GracePeriodMs (2500), VerticalForce (0.1),
  AffectPlayers/Npcs/Items (true) avec getters/setters
- CODEC: 6 .append sans nonNull validator (sémantique optionnelle)
- Tests: 6 round-trip + back-compat defaults (9 tests total)
This commit is contained in:
2026-04-23 14:00:12 +02:00
parent ffb716ca1c
commit a834c59b66
4 changed files with 242 additions and 11 deletions
@@ -63,8 +63,9 @@ public class GravityFlipPlugin extends JavaPlugin {
super.start(); super.start();
GravityFlipConfig cfg = configHolder.get(); GravityFlipConfig cfg = configHolder.get();
this.registry = new RegionRegistry(cfg, configHolder); this.registry = new RegionRegistry(cfg, configHolder);
this.gravityApplier = new GravityApplier(th -> this.gravityApplier = new GravityApplier(
getLogger().at(Level.WARNING).withCause(th).log("gravityApply failed")); 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 -> this.tickLoop = new RegionTickLoop(registry, gravityApplier, th ->
getLogger().at(Level.WARNING).withCause(th).log("detectTick failed")); getLogger().at(Level.WARNING).withCause(th).log("detectTick failed"));
@@ -25,6 +25,8 @@ import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer; 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. // THREADING: écrit/lu depuis les workers ECS via forEachEntityParallel → ConcurrentHashMap.newKeySet obligatoire.
private final Set<UUID> previouslyInverted = ConcurrentHashMap.newKeySet(); private final Set<UUID> previouslyInverted = ConcurrentHashMap.newKeySet();
private final Consumer<Throwable> errorHandler; private final Consumer<Throwable> errorHandler;
private final Consumer<String> 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<UUID> loggedNpcUuids = ConcurrentHashMap.newKeySet();
public GravityApplier(Consumer<Throwable> errorHandler) { public GravityApplier(Consumer<Throwable> errorHandler) {
this(errorHandler, null);
}
public GravityApplier(Consumer<Throwable> errorHandler, Consumer<String> infoHandler) {
this.errorHandler = errorHandler == null ? t -> {} : errorHandler; this.errorHandler = errorHandler == null ? t -> {} : errorHandler;
this.infoHandler = infoHandler == null ? m -> {} : infoHandler;
} }
/** /**
@@ -214,6 +234,7 @@ public final class GravityApplier {
UUID u = uc.getUuid(); UUID u = uc.getUuid();
currentlyInRegion.add(u); currentlyInRegion.add(u);
entitiesInRegionTick.incrementAndGet();
// --- existant (plan 03-01) : flip ECS native --- // --- existant (plan 03-01) : flip ECS native ---
if (!v.isInvertedGravity()) { 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. // Update tracker — ces ops sont sur le tick thread après la fin du pass parallel.
previouslyInverted.clear(); previouslyInverted.clear();
previouslyInverted.addAll(currentlyInRegion); 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) { } catch (Throwable th) {
errorHandler.accept(th); errorHandler.accept(th);
} }
@@ -293,7 +332,9 @@ public final class GravityApplier {
mm.applyDefaultSettings(); mm.applyDefaultSettings();
PacketHandler ph = pr.getPacketHandler(); PacketHandler ph = pr.getPacketHandler();
mm.update(ph); mm.update(ph);
playersWokenTick.incrementAndGet();
} catch (Throwable th) { } catch (Throwable th) {
wakeExceptionsTick.incrementAndGet();
errorHandler.accept(th); errorHandler.accept(th);
} }
return; // un joueur n'est pas un NPC — court-circuit return; // un joueur n'est pas un NPC — court-circuit
@@ -304,19 +345,66 @@ public final class GravityApplier {
NPCEntity npc = null; NPCEntity npc = null;
try { npc = chunk.getComponent(index, NPCT); } catch (Throwable ignored) {} try { npc = chunk.getComponent(index, NPCT); } catch (Throwable ignored) {}
if (npc != null) { if (npc != null) {
npcsSeenTick.incrementAndGet();
try { try {
Role role = npc.getRole(); Role role = npc.getRole();
if (role != null) { if (role == null) {
npcsRoleNullTick.incrementAndGet();
} else {
MotionController active = role.getActiveMotionController(); 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); 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) { } catch (Throwable th) {
wakeExceptionsTick.incrementAndGet();
errorHandler.accept(th); errorHandler.accept(th);
} }
return;
} }
// sinon : item / autre — pas de wake-up, le cmdBuf.replaceComponent du pass 1 suffit // 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). */ /** Pure data-diff utilitaire pour tests unitaires (pas de runtime Hytale). */
@@ -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. * A named axis-aligned region in which gravity is inverted for any entity inside.
* *
* <p>Persisted as part of {@code GravityFlipConfig} via {@link #CODEC}. The region * <p>Persisted as part of {@code GravityFlipConfig} via {@link #CODEC}. The region is stored
* is stored on disk as three keys: * on disk as up to 9 keys :
* <ul> * <ul>
* <li>{@code Name} — non-null string identifier</li> * <li>{@code Name} — non-null string identifier</li>
* <li>{@code Box} — non-null AABB (composed of two {@code Vector3d} corners)</li> * <li>{@code Box} — non-null AABB (composed of two {@code Vector3d} corners)</li>
* <li>{@code Enabled} — boolean toggle, default {@code true}</li> * <li>{@code Enabled} — boolean toggle, default {@code true}</li>
* <li>{@code FallDamage} — <b>optional</b> (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}).</li>
* <li>{@code GracePeriodMs} — <b>optional</b>, default {@code 2500}. Grace window in ms
* after exit during which fall damage remains suppressed.</li>
* <li>{@code VerticalForce} — <b>optional</b>, default {@code 0.1}. Seed added each tick to
* NPC {@code forceVelocity.y} to activate the inverted gravity path (Plan 03-03).</li>
* <li>{@code AffectPlayers} — <b>optional</b>, default {@code true}. If {@code false},
* players in-region are NOT flipped.</li>
* <li>{@code AffectNpcs} — <b>optional</b>, default {@code true}. If {@code false},
* NPCs in-region are NOT flipped / seeded.</li>
* <li>{@code AffectItems} — <b>optional</b>, default {@code true}. If {@code false},
* item {@code PhysicsValues.invertedGravity} is NOT mutated.</li>
* </ul> * </ul>
* *
* <p><b>Back-compat :</b> 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).
*
* <p>Note: this build pins {@code com.hypixel.hytale:Server:2026.03.26-89796e57b}, in which * <p>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} * {@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). * (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() .addValidator(Validators.nonNull()).add()
.append(new KeyedCodec<>("Enabled", Codec.BOOLEAN), .append(new KeyedCodec<>("Enabled", Codec.BOOLEAN),
(r, v) -> r.enabled = v, r -> r.enabled).add() (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(); .build();
// Package-private mutable fields written directly by the codec setters. // 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()); Box box = new Box(new Vector3d(), new Vector3d());
boolean enabled = true; 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() {}
public GravityFlipRegion(String name, Box box, boolean enabled) { 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 setBox(Box b) { this.box = b; }
public void setEnabled(boolean v) { this.enabled = v; } 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. */ /** Convenience accessor for tick-loop / physics consumers in Phase 02-02. */
public Box asBox() { return box; } public Box asBox() { return box; }
} }
@@ -13,8 +13,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
/** /**
* Round-trip tests for {@link GravityFlipRegion#CODEC}. Verifies the codec preserves * Round-trip tests for {@link GravityFlipRegion#CODEC}. Verifies the codec preserves
* Name + Box + Enabled fields across encode -> decode cycles via the BSON intermediate * the legacy Name + Box + Enabled fields across encode -> decode cycles via the BSON
* representation that all Hytale codecs share. * intermediate representation, and (Plan 03-04) the 6 optional tuning fields :
* FallDamage, GracePeriodMs, VerticalForce, AffectPlayers, AffectNpcs, AffectItems.
*
* <p>Back-compat invariant (test {@link #roundTripPreservesDefaultsWhenNewFieldsAbsent}) :
* a BSON encoded without the 6 new keys must decode with all Java defaults preserved.
*/ */
class GravityFlipRegionCodecTest { class GravityFlipRegionCodecTest {
@@ -64,6 +68,89 @@ class GravityFlipRegionCodecTest {
assertEquals("", decoded.getName(), "empty name must survive round-trip without substitution"); 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) { private static GravityFlipRegion roundTrip(GravityFlipRegion src) {
ExtraInfo info = new ExtraInfo(); ExtraInfo info = new ExtraInfo();
BsonValue encoded = GravityFlipRegion.CODEC.encode(src, info); BsonValue encoded = GravityFlipRegion.CODEC.encode(src, info);