From 6a830ed28537933c2f9b066f508684af3d6653b4 Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Fri, 24 Apr 2026 17:25:38 +0200 Subject: [PATCH] refactor: clean GSD comments and translate remaining Java sources to English - Reduce javadocs to one-liners across config/region/physics/tick/viz/plugin root - Translate residual French comments; no behavioural change - Tests adjusted where assertions referenced French strings --- .../gravityflip/GravityFlipPlugin.java | 91 ++++----------- .../gravityflip/config/GravityFlipConfig.java | 17 +-- .../gravityflip/physics/FallDamageGuard.java | 41 ++----- .../physics/FallDamageSuppressorSystem.java | 16 +-- .../gravityflip/physics/GravityApplier.java | 107 +++++------------- .../gravityflip/region/GravityFlipRegion.java | 66 +---------- .../gravityflip/region/RegionRegistry.java | 86 ++++---------- .../gravityflip/region/RegionSnapshot.java | 11 +- .../gravityflip/tick/RegionTickLoop.java | 28 ++--- .../gravityflip/viz/ParticleEdgeEmitter.java | 42 ++----- .../gravityflip/viz/RegionVisualizer.java | 103 ++++------------- .../config/GravityFlipConfigCodecTest.java | 15 +-- .../physics/FallDamageGuardTest.java | 9 +- .../physics/GravityApplierDiffTest.java | 17 +-- .../region/GravityFlipRegionCodecTest.java | 20 +--- .../region/RegionRegistryTest.java | 13 +-- .../gravityflip/tick/RegionTickLoopTest.java | 9 +- .../viz/ParticleEdgeEmitterTest.java | 27 +---- .../gravityflip/viz/RegionVisualizerTest.java | 52 +++------ 19 files changed, 163 insertions(+), 607 deletions(-) diff --git a/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java b/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java index 6c422c3..6f015fd 100644 --- a/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java +++ b/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java @@ -23,25 +23,11 @@ import com.hypixel.hytale.server.core.modules.interaction.interaction.config.Int import java.util.concurrent.ScheduledFuture; import java.util.logging.Level; -/** - * Entry point for the Gravity Flip plugin. - * - *

Extends {@link JavaPlugin} from the resolved Hytale Server API - * ({@code com.hypixel.hytale:Server:2026.03.26-89796e57b}). Lifecycle hooks - * for this version are {@code setup()} / {@code shutdown()} (not the legacy - * {@code onEnable()} / {@code onDisable()}). See - * {@code .planning/phases/01-scaffold-load/JAVAPLUGIN_RESOLUTION.md} for the - * empirical resolution. - */ +/** Entry point for the Gravity Flip plugin (Hytale Server API setup/shutdown lifecycle). */ public class GravityFlipPlugin extends JavaPlugin { - /** - * Persisted region store, materialised on disk as - * {@code /regions.json} (i.e. {@code Plugins/GravityFlip/regions.json}). - * - *

The named {@code withConfig(name, codec)} overload is REQUIRED — the - * 1-arg overload hardcodes the filename to {@code config.json}. - */ + // Persisted region store at /regions.json. The named withConfig(name, codec) + // overload is REQUIRED — the 1-arg overload hardcodes the filename to config.json. private final Config configHolder = withConfig("regions", GravityFlipConfig.CODEC); @@ -58,28 +44,18 @@ public class GravityFlipPlugin extends JavaPlugin { @Override protected void setup() { - // NOTE: do NOT call configHolder.get() here — it blocks until preLoad() completes. - // Safe call sites are start() and any later lifecycle phase (incl. tick loop). + // Do NOT call configHolder.get() here — it blocks until preLoad() completes. + // Safe call sites are start() and any later lifecycle phase. // - // World acquisition note (Phase 02-02): the plan called for a PrepareUniverseEvent - // listener that stashes a World reference. Empirically, PrepareUniverseEvent - // (com.hypixel.hytale.server.core.event.events.PrepareUniverseEvent) only carries - // a WorldConfigProvider — it does NOT expose a Universe or World. We therefore use - // 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(...). + // World acquisition: PrepareUniverseEvent only exposes a WorldConfigProvider, not a + // Universe/World. We use a Supplier that resolves Universe.get().getDefaultWorld() + // lazily on each tick; until the universe is ready, the supplier returns null and the + // tick is a no-op. this.fallDamageGuard = new FallDamageGuard(); getEntityStoreRegistry().registerSystem(new FallDamageSuppressorSystem( fallDamageGuard, th -> getLogger().at(Level.WARNING).withCause(th).log("fallDamageSuppressor handle failed"))); - // Plan 04-01 : Gravity Flip wand. - // Interaction binding pattern per 04-00 SPIKE-RESULT (Finding 3) — same shape as - // InstancesPlugin.java:158 / ExitInstanceInteraction. The JSON Item at - // src/main/resources/Items/gravityflip_wand.json references this Type in - // Interactions.Primary / Interactions.Secondary. this.wandSelectionStore = new WandSelectionStore(); GravityFlipWandInteraction.bindStore(this.wandSelectionStore); getCodecRegistry(Interaction.CODEC).register( @@ -107,25 +83,20 @@ public class GravityFlipPlugin extends JavaPlugin { this.tickLoop = new RegionTickLoop(registry, gravityApplier, regionVisualizer, th -> getLogger().at(Level.WARNING).withCause(th).log("detectTick failed")); - // Lazy world resolution — see setup() comment. this.tickLoop.startWithDelay(2_000L, () -> { Universe u = Universe.get(); return u == null ? null : u.getDefaultWorld(); }); - // TaskRegistry registration: registerTask only accepts ScheduledFuture; the - // scheduler returns ScheduledFuture. Cast via raw types per Mythlane idiom; the - // try/catch falls back to manual shutdown() if registration fails (deterministic - // either way because shutdown() always invokes tickLoop.stop()). + // TaskRegistry.registerTask only accepts ScheduledFuture; the scheduler returns + // ScheduledFuture. Cast via raw types; the fallback keeps teardown deterministic + // because shutdown() always invokes tickLoop.stop(). try { @SuppressWarnings({"unchecked", "rawtypes"}) ScheduledFuture vf = (ScheduledFuture) tickLoop.future(); getTaskRegistry().registerTask(vf); } catch (Throwable ignored) { /* manual shutdown() fallback */ } - // Plan 04-02 : enregistrer la commande racine /gravityflip + sous-commande `wand`. - // Pattern : CommandRegistry.register(...) ajoute automatiquement un shutdownTask qui - // unregister au teardown (cf. CommandRegistry base class). Pas de cleanup manuel. getCommandRegistry().registerCommand(new GravityFlipCommand(this)); getLogger().at(Level.INFO).log( @@ -135,28 +106,22 @@ public class GravityFlipPlugin extends JavaPlugin { @Override protected void shutdown() { - // Plan 03-05 : clear des debug shapes cote clients AVANT tickLoop.stop(). - // Si l'Universe est deja fermee, world==null => shapes expireront via TTL (acceptable). + // Clear debug shapes on clients BEFORE stopping the tick loop. If the universe is already + // torn down, world==null and shapes will expire via TTL (acceptable). if (regionVisualizer != null) { Universe u = Universe.get(); World w = (u == null) ? null : u.getDefaultWorld(); if (w != null) regionVisualizer.clearAll(w); } - // Stop the detector BEFORE super.shutdown() so no tick races plugin teardown. + // Stop the detector BEFORE super.shutdown() so no tick races the plugin teardown. if (tickLoop != null) tickLoop.stop(); - // No auto-save contract: any mutation made during the session must already - // have been persisted via configHolder().save() by the command handler that - // performed it. See configHolder() javadoc. + // No auto-save contract: mutations made during the session must already have been + // persisted via configHolder().save() by the command handler that performed them. getLogger().at(Level.INFO).log("Gravity Flip disabled"); super.shutdown(); } - /** - * Showcase zone seeded on first run (when {@code regions.json} is absent or empty). - * 10×20×10 box at {@code (0,100,0)..(10,120,10)} with Torch_Fire particles and a - * gentle upward force — lets a fresh install demonstrate the feature immediately. - * Users are free to edit or delete this zone via {@code regions.json}. - */ + /** Showcase zone seeded on first run (10x20x10 box at (0,100,0)..(10,120,10) with Torch_Fire particles). */ private static GravityFlipRegion buildShowcaseRegion() { GravityFlipRegion r = new GravityFlipRegion( "demo-gravity-flip", @@ -170,25 +135,17 @@ public class GravityFlipPlugin extends JavaPlugin { return r; } - /** Exposed for Phase 3 (gravity physics) and Phase 4 (commands). */ + /** Exposes the region registry to commands and physics code. */ public RegionRegistry regions() { return registry; } - /** - * Per-player wand selection store. Populated by - * {@link GravityFlipWandInteraction}; consumed by - * {@code /gravityflip define} (Phase 04-02+). - *

Returns {@code null} until {@link #setup()} has run. - */ + /** Per-player wand selection store; returns null until {@link #setup()} has run. */ public WandSelectionStore wandSelections() { return wandSelectionStore; } /** - * Accessor for the region config holder. SAVE CONTRACT: any - * caller that mutates {@code configHolder().get().getRegions()} MUST call - * {@code configHolder().save()} afterwards. There is no lifecycle hook that - * auto-saves on shutdown. {@code Config.get()} returns a SHARED MUTABLE - * reference; concurrent writers corrupt state — Phase 02-02's - * {@code RegionRegistry} snapshots it into an {@code AtomicReference} for - * tick-loop reads. + * Accessor for the region config holder. SAVE CONTRACT: any caller that mutates + * {@code configHolder().get().getRegions()} MUST call {@code configHolder().save()} afterwards. + * {@code Config.get()} returns a SHARED MUTABLE reference; concurrent writers corrupt state — + * {@code RegionRegistry} snapshots it into an {@code AtomicReference} for tick-loop reads. */ public Config configHolder() { return configHolder; diff --git a/src/main/java/com/mythlane/gravityflip/config/GravityFlipConfig.java b/src/main/java/com/mythlane/gravityflip/config/GravityFlipConfig.java index c44d093..0b79f37 100644 --- a/src/main/java/com/mythlane/gravityflip/config/GravityFlipConfig.java +++ b/src/main/java/com/mythlane/gravityflip/config/GravityFlipConfig.java @@ -9,20 +9,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -/** - * Root config wrapping the persisted list of {@link GravityFlipRegion}s. - * - *

Persisted as {@code /regions.json} via the named - * {@code withConfig("regions", GravityFlipConfig.CODEC)} overload on - * {@code PluginBase}. The 1-arg overload would hardcode {@code config.json} — - * deliberately avoided. - * - *

The decoded list is wrapped in a mutable {@code ArrayList} - * (NOT {@code List.of(arr)}, which is immutable) so Phase 4 command handlers - * can {@code add}/{@code remove} regions at runtime. Callers that mutate the - * list MUST call {@code Config.save()} afterwards — there is no auto-save - * lifecycle hook. - */ +/** Root config wrapping the persisted list of {@link GravityFlipRegion}s (persisted as regions.json). */ public final class GravityFlipConfig { public static final BuilderCodec CODEC = @@ -30,7 +17,7 @@ public final class GravityFlipConfig { .append( new KeyedCodec<>("Regions", new ArrayCodec<>(GravityFlipRegion.CODEC, GravityFlipRegion[]::new)), - // Decode setter: wrap in a MUTABLE ArrayList so commands can add/remove. + // Decode into a MUTABLE ArrayList so commands can add/remove at runtime. (c, arr) -> c.regions = new ArrayList<>(Arrays.asList(arr)), c -> c.regions.toArray(GravityFlipRegion[]::new) ).add() diff --git a/src/main/java/com/mythlane/gravityflip/physics/FallDamageGuard.java b/src/main/java/com/mythlane/gravityflip/physics/FallDamageGuard.java index 3b7c890..cb2d60d 100644 --- a/src/main/java/com/mythlane/gravityflip/physics/FallDamageGuard.java +++ b/src/main/java/com/mythlane/gravityflip/physics/FallDamageGuard.java @@ -6,56 +6,30 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; /** - * Thread-safe tracker that decides whether fall damage should be suppressed for a given UUID - * based on (a) current in-region membership, (b) post-exit grace window. - * - *

Pure-data service : no Hytale runtime dependency (no ECS, no PhysicsValues), injected - * via constructor into {@link GravityApplier} (populator) and - * {@link FallDamageSuppressorSystem} (consumer) — no static mutable state. - * - *

Precedence rule (multi-region) : when {@link #markInRegion} is called, the LAST - * call for a given UUID within a single tick wins. {@link GravityApplier} takes care of - * passing the FIRST matched region (iteration order of {@code enabledRegions}), so for an - * entity simultaneously in N regions the first-match region's {@code FallDamage} - * and {@code GracePeriodMs} drive suppression. - * - *

State machine : - *

+ * Thread-safe tracker that decides whether fall damage should be suppressed for a given UUID, + * based on current in-region membership and a post-exit grace window. */ public final class FallDamageGuard { - /** Entity currently inside a region (cleared on exit). */ private final ConcurrentHashMap currentRegionByUuid = new ConcurrentHashMap<>(); - /** Timestamp (ms) at which the entity last exited a region. */ private final ConcurrentHashMap exitTimestampByUuid = new ConcurrentHashMap<>(); - /** Region referenced at the moment of exit (used to read FallDamage + GracePeriodMs). */ private final ConcurrentHashMap regionAtExitByUuid = new ConcurrentHashMap<>(); + /** Marks an entity as currently inside the given region (clears any stale grace entry). */ public void markInRegion(UUID uuid, GravityFlipRegion region) { if (uuid == null || region == null) return; currentRegionByUuid.put(uuid, region); - // Re-entry ⇒ discard any stale grace entry (grace is a post-exit concept). + // Re-entry discards any stale grace entry — grace is a post-exit concept. exitTimestampByUuid.remove(uuid); regionAtExitByUuid.remove(uuid); } + /** Records the exit timestamp and the region the entity just left. */ public void markExit(UUID uuid, GravityFlipRegion region, long nowMs) { if (uuid == null || region == null) return; currentRegionByUuid.remove(uuid); @@ -63,6 +37,7 @@ public final class FallDamageGuard { regionAtExitByUuid.put(uuid, region); } + /** Returns true iff fall damage must be cancelled for this UUID at the given time. */ public boolean shouldSuppressFallDamage(UUID uuid, long nowMs) { if (uuid == null) return false; @@ -74,11 +49,11 @@ public final class FallDamageGuard { Long exitMs = exitTimestampByUuid.get(uuid); GravityFlipRegion exitRegion = regionAtExitByUuid.get(uuid); if (exitMs == null || exitRegion == null) return false; - if (exitRegion.isFallDamage()) return false; // region allowed fall damage → no grace + if (exitRegion.isFallDamage()) return false; return (nowMs - exitMs) <= exitRegion.getGracePeriodMs(); } - // ---- Test hooks / diagnostics (package-private) ---- + // Test hooks (package-private). int trackedInRegionCount() { return currentRegionByUuid.size(); } int trackedGraceCount() { return exitTimestampByUuid.size(); } diff --git a/src/main/java/com/mythlane/gravityflip/physics/FallDamageSuppressorSystem.java b/src/main/java/com/mythlane/gravityflip/physics/FallDamageSuppressorSystem.java index 1f0d609..8ac9787 100644 --- a/src/main/java/com/mythlane/gravityflip/physics/FallDamageSuppressorSystem.java +++ b/src/main/java/com/mythlane/gravityflip/physics/FallDamageSuppressorSystem.java @@ -17,21 +17,7 @@ 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. - */ +/** Cancels {@link Damage} events of cause {@code Fall} for any UUID suppressed by {@link FallDamageGuard}. */ public final class FallDamageSuppressorSystem extends DamageEventSystem { private final FallDamageGuard guard; diff --git a/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java b/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java index 99277ee..b4dbe5e 100644 --- a/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java +++ b/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java @@ -28,44 +28,12 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; /** - * Tick-driven service that toggles the native {@code PhysicsValues.invertedGravity} flag on every - * entity present in an enabled {@link GravityFlipRegion}. Mutations are queued via - * {@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: wake-ups per-entity : - *

    - *
  • Players: {@code MovementManager.setDefaultSettings + applyDefaultSettings + update(packetHandler)}
  • - *
  • NPCs: {@code Role.getActiveMotionController().updatePhysicsValues(PhysicsValues)}
  • - *
- * - *

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: - *

    - *
  • Per-region tuning consumed : {@code AffectPlayers} / {@code AffectNpcs} / {@code AffectItems} - * filter BEFORE any wake / cmdBuf flip ; {@code VerticalForce} replaces the hardcoded 0.1.
  • - *
  • {@link FallDamageGuard} notified on entry (pass 1) and exit (pass 2) with the - * first-matched region to drive {@link FallDamageSuppressorSystem}.
  • - *
- * - *

Hotpath note: Pass 1 (flip) and Pass 2 (restore) each iterate - * {@code forEachEntityParallel}. They CANNOT be fused: restore requires the complete - * {@code currentlyInRegion} set (computed during Pass 1) to diff against - * {@code previouslyInverted}. Merging would either miss restores or double-visit - * entities with inconsistent per-chunk state. - * - *

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). + * Tick-driven service that toggles {@code PhysicsValues.invertedGravity} on every entity currently + * inside an enabled region, wakes up players/NPCs so the new settings take effect, and drives the + * fall-damage guard on entry/exit transitions. */ public final class GravityApplier { - // Lazy ComponentType holders — pattern identique à RegionRegistry.transform() - // (évite static-init Hytale PluginBase pendant les tests). + // Lazy ComponentType holders — avoids Hytale PluginBase static init during tests. private static volatile ComponentType physicsType; private static volatile ComponentType uuidType; private static volatile ComponentType transformType; @@ -145,7 +113,7 @@ public final class GravityApplier { return t; } - // THREADING: écrit/lu depuis les workers ECS via forEachEntityParallel → ConcurrentHashMap.newKeySet obligatoire. + // Written/read from ECS worker threads via forEachEntityParallel — concurrent collection required. 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<>(); @@ -162,21 +130,17 @@ public final class GravityApplier { 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. - */ + /** Builds a new PhysicsValues copying mass/drag from the source and setting invertedGravity. */ static PhysicsValues buildPhysicsValuesWithFlag(PhysicsValues source, boolean target) { FlaggedDecision d = buildFlaggedDecision(source.getMass(), source.getDragCoefficient(), target); return new PhysicsValues(d.mass, d.drag, d.invertedGravity); } - /** Pure-data seam pour tests unitaires (aucune dépendance sur PhysicsValues). */ + /** Pure-data seam for unit tests — PhysicsValues static init is unavailable outside the server runtime. */ static FlaggedDecision buildFlaggedDecision(double mass, double drag, boolean target) { return new FlaggedDecision(mass, drag, target); } - /** Holder pure-data pour la décomposition testable de {@link #buildPhysicsValuesWithFlag}. */ static final class FlaggedDecision { final double mass; final double drag; @@ -186,7 +150,7 @@ public final class GravityApplier { } } - /** Tick entry point. NO-OP si world ou snapshot est null. */ + /** Tick entry point; no-op when world or snapshot is null. */ public void apply(World world, RegionSnapshot snapshot) { if (world == null || snapshot == null) return; world.execute(() -> applyOnWorldThread(world, snapshot)); @@ -205,8 +169,9 @@ public final class GravityApplier { ComponentType PLT = playerType(); ComponentType NPCT = npcEntityType(); - // PASS 1 — pour chaque entité avec PhysicsValues : si dans une région activée, queue le flip ON - // via cmdBuf.replaceComponent ET wake-up MovementManager / MotionController. + // PASS 1 — flip ON for every entity with PhysicsValues that is inside an enabled region. + // Pass 1 and Pass 2 cannot be fused: restore requires the complete currentlyInRegion set + // to diff against previouslyInverted. Set currentlyInRegion = ConcurrentHashMap.newKeySet(); store.forEachEntityParallel(PHYST, (index, chunk, cmdBuf) -> { TransformComponent t; @@ -222,7 +187,7 @@ public final class GravityApplier { com.hypixel.hytale.math.vector.Vector3d pos = t.getPosition(); double x = pos.x, y = pos.y, z = pos.z; - // First-match wins for multi-region precedence (Plan 03-04). + // First-match wins for multi-region precedence. GravityFlipRegion matchedRegion = null; for (GravityFlipRegion r : enabledRegions) { if (r.asBox().containsPosition(x, y, z)) { matchedRegion = r; break; } @@ -231,7 +196,6 @@ public final class GravityApplier { UUID u = uc.getUuid(); - // --- Plan 03-04 : AffectXxx filters applied BEFORE wake --- EntityKind kind = classify(chunk, index, MMT, PRT, PLT, NPCT); boolean allowed; switch (kind) { @@ -240,8 +204,7 @@ public final class GravityApplier { 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. + // Filtered entity behaves as if outside the region. return; } @@ -249,19 +212,17 @@ public final class GravityApplier { 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)); } - // --- 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. + // PASS 2 — restore: for every UUID in previouslyInverted \ currentlyInRegion. Set toRestore = ConcurrentHashMap.newKeySet(); toRestore.addAll(previouslyInverted); toRestore.removeAll(currentlyInRegion); @@ -284,11 +245,8 @@ public final class GravityApplier { new PhysicsValues(v.getMass(), v.getDragCoefficient(), false)); } - // 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); @@ -296,7 +254,7 @@ public final class GravityApplier { }); } - // Update tracker — ces ops sont sur le tick thread après la fin du pass parallel. + // Tick-thread update after the parallel pass completes. previouslyInverted.clear(); previouslyInverted.addAll(currentlyInRegion); } catch (Throwable th) { @@ -306,7 +264,7 @@ public final class GravityApplier { private enum EntityKind { PLAYER, NPC, OTHER } - /** Classify the entity at {@code index} into player / NPC / other (items fall into other). */ + /** Classifies the entity at {@code index} into player / NPC / other (items fall into other). */ private EntityKind classify(ArchetypeChunk chunk, int index, ComponentType MMT, ComponentType PRT, @@ -328,15 +286,7 @@ public final class GravityApplier { 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) - * - *

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. - */ + /** Wakes up the entity so the new PhysicsValues take effect (player movement refresh or NPC controller update). */ private void wakePlayerOrNpc( ArchetypeChunk chunk, int index, PhysicsValues sourceValues, boolean targetFlag, @@ -347,7 +297,7 @@ public final class GravityApplier { ComponentType NPCT) { PhysicsValues targetValues = buildPhysicsValuesWithFlag(sourceValues, targetFlag); - // --- Branche joueur --- + // Player branch. MovementManager mm = null; try { mm = chunk.getComponent(index, MMT); } catch (Throwable ignored) {} if (mm != null) { @@ -363,11 +313,11 @@ public final class GravityApplier { } catch (Throwable th) { errorHandler.accept(th); } - return; // un joueur n'est pas un NPC + return; } } - // --- Branche NPC --- + // NPC branch. NPCEntity npc = null; try { npc = chunk.getComponent(index, NPCT); } catch (Throwable ignored) {} if (npc != null) { @@ -378,9 +328,7 @@ public final class GravityApplier { if (active != null) { active.updatePhysicsValues(targetValues); - // 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. + // Seed forceVelocity.y only on entry — on exit the native damping zeroes it. if (targetFlag && matchedRegion != null) { double vf = matchedRegion.getVerticalForce(); try { @@ -399,10 +347,10 @@ public final class GravityApplier { return; } - // sinon : item / autre — no-op + // Items / other: no-op. } - /** Pure data-diff utilitaire pour tests unitaires (pas de runtime Hytale). */ + /** Pure data-diff helper for unit tests. */ public static DiffResult diff(Set previous, Set current) { Set toFlip = new HashSet<>(current); toFlip.removeAll(previous); @@ -417,17 +365,14 @@ public final class GravityApplier { DiffResult(Set f, Set r) { this.toFlip = f; this.toRestore = r; } } - // ---- Test hooks (package-private) ---- + // Test hooks (package-private). - /** Vue immuable du tracker — pour tests unitaires sémantiques (résolution WARNING 2). */ + /** Immutable view of the internal tracker for unit tests. */ Set previouslyInvertedView() { return Collections.unmodifiableSet(previouslyInverted); } - /** - * Force une valeur du tracker hors runtime ECS — pour tester la sémantique sans dépendre de World/Store. - * Package-private : NE PAS appeler depuis le code de production. - */ + /** Test-only tracker override; never call from production code. */ void __updateTrackerForTest(Set newState) { previouslyInverted.clear(); previouslyInverted.addAll(newState); diff --git a/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java b/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java index c6be72b..4cdc29c 100644 --- a/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java +++ b/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java @@ -7,53 +7,7 @@ import com.hypixel.hytale.codec.validation.Validators; import com.hypixel.hytale.math.shape.Box; 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 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 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.
  • - *
  • {@code VisualColor} — optional (Plan 03-05). Default {@code "#00FFFF"} (cyan). - * Hex string {@code #RRGGBB} rendered as debug cube color. Fallback to cyan on invalid hex - * (parsing + validation lives in {@code RegionVisualizer}, NOT here).
  • - *
  • {@code VisualMode} — optional, default {@code "Outline"}. One of - * {@code "Outline"} / {@code "Faces"} / {@code "Both"} / {@code "None"}. Unknown value - * falls back to {@code "Outline"}.
  • - *
  • {@code VisualRefreshMs} — optional, default {@code 1000}. Emission period in ms - * for the debug shape (refresh cadence ; TTL = refreshMs * 1.2 to avoid flicker).
  • - *
  • {@code VisualOpacity} — optional, default {@code 0.5}. Cube opacity in - * {@code [0.0, 1.0]} (clamped in {@code RegionVisualizer}).
  • - *
- * - *

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). - * - *

Fields are package-private mutable so the codec can write into them directly, - * mirroring the canonical {@code MythLoggerConfig} pattern. Public getters/setters - * are provided for runtime callers (Phase 4 commands). - */ +/** A named axis-aligned region in which gravity is inverted for any entity inside. */ public final class GravityFlipRegion { public static final BuilderCodec CODEC = @@ -66,7 +20,7 @@ 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) --- + // Optional tuning fields (no nonNull validator => absence preserves Java defaults). .append(new KeyedCodec<>("FallDamage", Codec.BOOLEAN), (r, v) -> r.fallDamage = v, r -> r.fallDamage).add() .append(new KeyedCodec<>("GracePeriodMs", Codec.INTEGER), @@ -79,7 +33,6 @@ public final class GravityFlipRegion { (r, v) -> r.affectNpcs = v, r -> r.affectNpcs).add() .append(new KeyedCodec<>("AffectItems", Codec.BOOLEAN), (r, v) -> r.affectItems = v, r -> r.affectItems).add() - // --- Plan 03-05 : 4 optional visualization fields --- .append(new KeyedCodec<>("VisualColor", Codec.STRING), (r, v) -> r.visualColor = v, r -> r.visualColor).add() .append(new KeyedCodec<>("VisualMode", Codec.STRING), @@ -88,7 +41,6 @@ public final class GravityFlipRegion { (r, v) -> r.visualRefreshMs = v, r -> r.visualRefreshMs).add() .append(new KeyedCodec<>("VisualOpacity", Codec.DOUBLE), (r, v) -> r.visualOpacity = v, r -> r.visualOpacity).add() - // --- Plan 03-06 : 2 optional particle-mode fields --- .append(new KeyedCodec<>("VisualParticleId", Codec.STRING), (r, v) -> r.visualParticleId = v, r -> r.visualParticleId).add() .append(new KeyedCodec<>("VisualParticleDensity", Codec.DOUBLE), @@ -100,7 +52,6 @@ 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; @@ -108,15 +59,12 @@ public final class GravityFlipRegion { boolean affectNpcs = true; boolean affectItems = true; - // Plan 03-05 : visualization fields — defaults applied when key absent in BSON. String visualColor = "#00FFFF"; String visualMode = "Outline"; int visualRefreshMs = 1000; double visualOpacity = 0.5; - // Plan 03-06 : particle-mode fields — default switched to Torch_Fire after UAT - // showed Dust_Sparkles_Fine is invisible in-world. Torch_Fire is a persistent flame - // that renders reliably at any altitude. Density 0.3 = ~12 points per 40m edge. + // Torch_Fire chosen because Dust_Sparkles_Fine is effectively invisible in-world. String visualParticleId = "Torch_Fire"; double visualParticleDensity = 0.3; @@ -138,8 +86,6 @@ 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; } @@ -154,8 +100,6 @@ public final class GravityFlipRegion { public void setAffectNpcs(boolean v) { this.affectNpcs = v; } public void setAffectItems(boolean v) { this.affectItems = v; } - // --- Plan 03-05 getters / setters --- - public String getVisualColor() { return visualColor; } public String getVisualMode() { return visualMode; } public int getVisualRefreshMs() { return visualRefreshMs; } @@ -166,14 +110,12 @@ public final class GravityFlipRegion { public void setVisualRefreshMs(int v) { this.visualRefreshMs = v; } public void setVisualOpacity(double v) { this.visualOpacity = v; } - // --- Plan 03-06 getters / setters --- - public String getVisualParticleId() { return visualParticleId; } public double getVisualParticleDensity() { return visualParticleDensity; } public void setVisualParticleId(String v) { this.visualParticleId = v; } public void setVisualParticleDensity(double v) { this.visualParticleDensity = v; } - /** Convenience accessor for tick-loop / physics consumers in Phase 02-02. */ + /** Convenience accessor for tick-loop / physics consumers. */ public Box asBox() { return box; } } diff --git a/src/main/java/com/mythlane/gravityflip/region/RegionRegistry.java b/src/main/java/com/mythlane/gravityflip/region/RegionRegistry.java index 0dce941..dea0dca 100644 --- a/src/main/java/com/mythlane/gravityflip/region/RegionRegistry.java +++ b/src/main/java/com/mythlane/gravityflip/region/RegionRegistry.java @@ -22,32 +22,13 @@ import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicReference; /** - * In-memory index of {@link GravityFlipRegion}s with two layers of atomic publication: - * - *

    - *
  1. An {@link AtomicReference} holding an immutable snapshot of the current region list, - * written by CRUD methods and {@link #refreshFromConfig(GravityFlipConfig)} on the command - * thread, read by the tick loop on the scheduler thread without locking.
  2. - *
  3. A per-{@link World} {@code AtomicReference<RegionSnapshot>} published by - * {@link #refreshFor(World)} every tick; off-thread consumers (Phase 3 physics) read it via - * {@link #currentSnapshot(World)}.
  4. - *
- * - *

Threading contract: the tick loop NEVER calls {@code config.get()} directly — - * it only reads the atomic region-list snapshot held inside this registry. Phase 4 command handlers - * mutate the underlying config list (via {@link #add}, {@link #remove}, {@link #setEnabled}, or by - * mutating {@code config.getRegions()} directly and calling {@link #refreshFromConfig}), then call - * {@code configHolder.save().join()} to persist. + * In-memory index of {@link GravityFlipRegion}s with atomic publication for lock-free reads + * from the tick loop. Mutations go through CRUD methods which swap an immutable snapshot. */ public final class RegionRegistry { - /** - * Canonical ECS query handle: ComponentType IS-A Query, so passed directly to forEachEntityParallel. - * Lazily initialised because {@code TransformComponent.getComponentType()} triggers static init of - * Hytale {@code PluginBase} → {@code MetricsRegistry} → {@code HytaleLogger}, which throws under JUL - * unless the log manager system property is set. Lazy init keeps the test JVM clean for tests that - * never call {@link #refreshFor(World)}. - */ + // Lazy init: TransformComponent.getComponentType() triggers Hytale PluginBase static init, + // which fails under JUL unless the log manager system property is set — avoided in tests. private static volatile ComponentType transformType; private static ComponentType transform() { @@ -67,16 +48,10 @@ public final class RegionRegistry { private final GravityFlipConfig config; private final Config holder; // nullable in tests - /** Immutable snapshot of the region list, swapped atomically on every mutation. */ private final AtomicReference> regionsSnapshot; private final Object mutationLock = new Object(); - /** - * Snapshot store keyed by {@link World} reference. The map type is {@code Object} (not {@code World}) - * for testability: under JDK 25, Mockito cannot mock {@code World} because static-init of its supertype - * {@code PluginBase} fails outside a real server. Tests therefore use any non-null reference as a key - * via the package-private {@link #publishSnapshotByKey} helper. - */ + // Keyed by Object (not World) because Mockito cannot mock World under JDK 25 in tests. private final ConcurrentHashMap> snapshots = new ConcurrentHashMap<>(); public RegionRegistry(GravityFlipConfig cfg) { @@ -89,14 +64,12 @@ public final class RegionRegistry { this.regionsSnapshot = new AtomicReference<>(List.copyOf(cfg.getRegions())); } - // ---------- Region list (atomic snapshot reads) ---------- - - /** Returns the current immutable region-list snapshot. Safe to call from any thread. */ + /** Returns the current immutable region-list snapshot. */ public Collection all() { return regionsSnapshot.get(); } - /** Read-only view of currently-enabled regions, derived from the atomic snapshot (NOT from config). */ + /** Read-only view of currently-enabled regions. */ List enabled() { List out = new ArrayList<>(); for (GravityFlipRegion r : regionsSnapshot.get()) { @@ -105,6 +78,7 @@ public final class RegionRegistry { return out; } + /** Adds a region; throws IllegalArgumentException if the name already exists. */ public void add(GravityFlipRegion r) { synchronized (mutationLock) { for (GravityFlipRegion x : regionsSnapshot.get()) { @@ -117,6 +91,7 @@ public final class RegionRegistry { } } + /** Removes the region with the given name; returns true iff it existed. */ public boolean remove(String name) { synchronized (mutationLock) { boolean removed = config.getRegions().removeIf(x -> x.getName().equals(name)); @@ -125,6 +100,7 @@ public final class RegionRegistry { } } + /** Toggles the enabled flag on the named region; returns true iff it existed. */ public boolean setEnabled(String name, boolean enabled) { synchronized (mutationLock) { for (GravityFlipRegion x : config.getRegions()) { @@ -138,28 +114,19 @@ public final class RegionRegistry { } } - /** Phase 4 hook: re-snapshot the region list after a command handler has mutated the underlying config. */ + /** Re-snapshots the region list after the underlying config was mutated externally. */ public void refreshFromConfig(GravityFlipConfig cfg) { synchronized (mutationLock) { regionsSnapshot.set(List.copyOf(cfg.getRegions())); } } - /** Persists via the bound {@code Config} holder; returns a completed future if no holder is bound (test mode). */ + /** Persists via the bound {@code Config} holder (completed future if none is bound). */ public CompletableFuture save() { return holder == null ? CompletableFuture.completedFuture(null) : holder.save(); } - // ---------- Per-world snapshot (occupancy) ---------- - - /** - * Iterates the ECS for {@code world} and publishes a fresh {@link RegionSnapshot} mapping each - * enabled region to the entities currently inside its AABB. Safe to call from a scheduler thread. - * - *

READ-ONLY across the trust boundary: TransformComponent positions are only read (copied to - * local doubles before {@link Box#containsPosition(double, double, double)}). Any MUTATIONS must - * go through {@code World.execute(Runnable)} or a {@code CommandBuffer} (Phase 3 concern). - */ + /** Iterates the ECS for {@code world} and publishes a fresh per-region occupancy snapshot. */ public void refreshFor(World world) { List enabled = enabled(); Map>> byRegion = new ConcurrentHashMap<>(); @@ -168,25 +135,20 @@ public final class RegionRegistry { } if (enabled.isEmpty()) { - // Aucun travail ECS → publication directe depuis le thread appelant. publishSnapshot(world, snapshotOf(world, byRegion)); return; } - // THREADING (fix WorldThread assert 2026-04-23) : `Store.forEachEntityParallel` exige la - // WorldThread. On dispatche scan + publication via `world.execute(Runnable)` pour satisfaire - // `assertThread`. Conséquence : la publication devient asynchrone (1 tick décalé max) côté - // consumers de `currentSnapshot(world)` — tolérable car le RegionTickLoop tourne @100ms, donc - // la fraîcheur du snapshot reste ≤ 100ms dans le pire cas. + // Store.forEachEntityParallel requires the WorldThread (assertThread), so dispatch via + // world.execute(Runnable). Publication becomes asynchronous (<= 1 tick of lag) — tolerable + // because RegionTickLoop runs @100ms, so snapshot freshness stays <= 100ms in the worst case. world.execute(() -> { try { Store store = world.getEntityStore().getStore(); ComponentType TRANSFORM = transform(); - // ComponentType IS-A Query, so TRANSFORM is passed directly (no builder). + // ComponentType IS-A Query, so TRANSFORM is passed directly. store.forEachEntityParallel(TRANSFORM, (index, chunk, cmdBuf) -> { TransformComponent t = chunk.getComponent(index, TRANSFORM); - // Pinned API 2026.03.26 returns com.hypixel.hytale.math.vector.Vector3d - // (Hytale's own type), NOT org.joml.Vector3d. Same deviation as Phase 02-01. com.hypixel.hytale.math.vector.Vector3d pos = t.getPosition(); // Copy to locals — getPosition() returns a backing field; never mutated here. double x = pos.x, y = pos.y, z = pos.z; @@ -200,35 +162,29 @@ public final class RegionRegistry { } }); } catch (Throwable th) { - // Swallow — publish whatever we collected (possibly empty). The tick loop's - // errorHandler already routes uncaught throwables; this catch keeps the - // scheduler alive across transient ECS-state errors (e.g., world being torn down). + // Swallow — keeps the scheduler alive across transient ECS-state errors + // (e.g., world being torn down). Publish whatever we collected so far. } - // Publication intra-Runnable : garantit que la table byRegion est complète quand - // on la fige dans snapshotOf(...). publishSnapshot(world, snapshotOf(world, byRegion)); }); } - /** Off-thread consumer entry point. Returns {@code null} if no snapshot has been published yet. */ + /** Off-thread consumer entry point; returns null if no snapshot has been published yet. */ public RegionSnapshot currentSnapshot(World world) { if (world == null) return null; AtomicReference ref = snapshots.get(world); return ref == null ? null : ref.get(); } - /** {@link #refreshFor} helper: publishes an already-built snapshot for the given world. */ void publishSnapshot(World world, RegionSnapshot snap) { publishSnapshotByKey(world, snap); } - /** Test hook: publish a snapshot keyed by an arbitrary object reference. */ void publishSnapshotByKey(Object key, RegionSnapshot snap) { snapshots.computeIfAbsent(key, k -> new AtomicReference<>()).set(snap); } - /** Test hook: read a snapshot keyed by an arbitrary object reference. */ RegionSnapshot currentSnapshotByKey(Object key) { AtomicReference ref = snapshots.get(key); return ref == null ? null : ref.get(); @@ -238,7 +194,7 @@ public final class RegionRegistry { Map>> byRegion) { long tick = 0L; try { tick = w.getTick(); } catch (Throwable ignored) {} - final long tickId = Math.max(tick, 1L); // tests assert tickId() > 0 + final long tickId = Math.max(tick, 1L); final Map>> frozen = Collections.unmodifiableMap(byRegion); return new RegionSnapshot() { diff --git a/src/main/java/com/mythlane/gravityflip/region/RegionSnapshot.java b/src/main/java/com/mythlane/gravityflip/region/RegionSnapshot.java index db96f73..071c868 100644 --- a/src/main/java/com/mythlane/gravityflip/region/RegionSnapshot.java +++ b/src/main/java/com/mythlane/gravityflip/region/RegionSnapshot.java @@ -7,16 +7,7 @@ import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import java.util.Collection; import java.util.Map; -/** - * Immutable snapshot of entity occupancy across all enabled regions for one {@link World}. - * - *

Published by {@code RegionRegistry.refreshFor(world)} via an {@code AtomicReference} - * so off-thread consumers (Phase 3 physics) can read a stable snapshot without locking. - * - *

Contract for consumers (Phase 3): {@link Ref} handles are read-only. - * Mutations to the underlying components MUST go through {@code World.execute(Runnable)} or - * a {@code CommandBuffer} — never directly from the consumer thread. - */ +/** Immutable snapshot of entity occupancy across all enabled regions for one {@link World}. */ public interface RegionSnapshot { /** Read-only map: enabled region -> entity refs currently inside its AABB. */ diff --git a/src/main/java/com/mythlane/gravityflip/tick/RegionTickLoop.java b/src/main/java/com/mythlane/gravityflip/tick/RegionTickLoop.java index a584607..98c5c8d 100644 --- a/src/main/java/com/mythlane/gravityflip/tick/RegionTickLoop.java +++ b/src/main/java/com/mythlane/gravityflip/tick/RegionTickLoop.java @@ -13,17 +13,8 @@ import java.util.function.Consumer; import java.util.function.Supplier; /** - * Dedicated single-thread daemon scheduler that polls for entity-in-region detection at 100ms. - * - *

Threading contract: the tick reads {@link RegionRegistry#refreshFor(World)} — - * which itself only consumes the registry's own atomic region-list snapshot, NEVER {@code config.get()} - * (a shared mutable reference). Phase 4 command handlers mutate the in-memory list on the server thread, - * then invoke {@code registry.refreshFromConfig(config.get())} to atomic-swap the region-list snapshot - * the detect thread reads, and {@code config.save().join()} to persist. - * - *

Lifecycle: {@link #stop()} uses the - * {@code shutdown -> awaitTermination(5s) -> shutdownNow} idiom (VotePipe / MythWorld precedent) so a - * plugin reload never leaks the scheduler thread. + * Dedicated single-thread daemon scheduler that polls for entity-in-region detection at 100ms + * and drives gravity application + visualization. */ public final class RegionTickLoop { @@ -58,23 +49,17 @@ public final class RegionTickLoop { }); } - /** Start with the canonical 2s initial delay, polling at 100ms. */ + /** Starts with the canonical 2s initial delay, polling at 100ms. */ public void start(World world) { startWithDelay(INITIAL_DELAY_MS, world); } - /** Start with a custom initial delay (used by tests + the plugin's lazy-world-supplier path). */ + /** Starts with a custom initial delay (used by tests + the plugin's lazy-world-supplier path). */ public void startWithDelay(long initialDelayMs, World world) { startWithDelay(initialDelayMs, () -> world); } - /** - * Start with a custom initial delay and a {@link Supplier} of the {@link World} to tick. - * - *

The supplier is invoked on every tick — this lets the plugin defer World resolution - * (via {@code Universe.get().getDefaultWorld()}) until after the universe is ready, without - * needing a separate event listener. Each tick a {@code null} supplier result is a no-op. - */ + /** Starts with a custom initial delay and a supplier that resolves the World lazily each tick. */ public void startWithDelay(long initialDelayMs, Supplier worldSupplier) { Runnable tick = () -> { World w = worldSupplier.get(); @@ -90,7 +75,7 @@ public final class RegionTickLoop { scheduleGuarded(initialDelayMs, tick); } - /** Test-friendly overload: schedule an arbitrary runnable. */ + /** Test-friendly overload: schedules an arbitrary runnable. */ public void startWithDelay(long initialDelayMs, Runnable tick) { scheduleGuarded(initialDelayMs, tick); } @@ -104,6 +89,7 @@ public final class RegionTickLoop { guarded, initialDelayMs, PERIOD_MS, TimeUnit.MILLISECONDS); } + /** Stops the scheduler with the shutdown -> awaitTermination(5s) -> shutdownNow idiom. */ public void stop() { scheduler.shutdown(); try { diff --git a/src/main/java/com/mythlane/gravityflip/viz/ParticleEdgeEmitter.java b/src/main/java/com/mythlane/gravityflip/viz/ParticleEdgeEmitter.java index 4a6a083..0cec97a 100644 --- a/src/main/java/com/mythlane/gravityflip/viz/ParticleEdgeEmitter.java +++ b/src/main/java/com/mythlane/gravityflip/viz/ParticleEdgeEmitter.java @@ -7,40 +7,19 @@ import java.util.ArrayList; import java.util.List; /** - * Plan 03-06 Task 2 — helper pur qui génère la liste des points d'émission de - * particules le long des 12 arêtes d'une {@link Box} AABB, sans aucune - * dépendance sur {@code World} (testable en JVM standard). - * - *

Contract: {@link #edgePoints(Box, double)} retourne une liste de - * {@link Vector3d} répartis uniformément sur les 12 arêtes de la box, avec les - * 8 coins inclus exactement une fois (déduplication inter-arêtes). Les 4 arêtes - * verticales, les 4 arêtes du plancher (Y=minY) et les 4 arêtes du plafond - * (Y=maxY) sont couvertes — aucune diagonale, aucun point intérieur. - * - *

Density: particules par mètre. Pour chaque arête de longueur {@code L} - * on émet {@code max(2, ceil(L * density))} points uniformément espacés, - * endpoints inclus. {@code density} est clampé à {@code [0.1, 10.0]} pour borner - * la charge réseau (threat : valeur pathologique type 10 000 → DoS clients). - * - *

Dédup des coins : les 12 arêtes partagent leurs endpoints ; on - * émet chaque coin exactement 1 fois en excluant l'endpoint "fin" de chaque - * arête et en n'émettant les 8 coins qu'une fois via un parcours explicite. + * Generates evenly-distributed emission points along the 12 edges of an AABB, with the 8 corners + * included exactly once. Pure helper with no World dependency (JVM-testable). */ public final class ParticleEdgeEmitter { - /** Plancher de densité (particules/m) — évite density ≤ 0 qui produirait l'ensemble vide. */ + /** Density floor (particles/m) — prevents density <= 0 from producing the empty set. */ public static final double MIN_DENSITY = 0.1; - /** Plafond de densité (particules/m) — borne la charge réseau par box. */ + /** Density ceiling (particles/m) — bounds network load against pathological values. */ public static final double MAX_DENSITY = 10.0; private ParticleEdgeEmitter() {} - /** - * @param box la box dont on veut matérialiser les 12 arêtes. - * @param density particules/mètre (clampé à {@code [0.1, 10.0]}). - * @return liste de points uniformément répartis sur les 12 arêtes, 8 coins - * dédupliqués. Jamais null. Taille ≥ 8. - */ + /** Returns edge-points for the given box (8 deduped corners + interior points), density clamped to [0.1, 10]. */ public static List edgePoints(Box box, double density) { double d = clamp(density, MIN_DENSITY, MAX_DENSITY); @@ -49,7 +28,7 @@ public final class ParticleEdgeEmitter { List out = new ArrayList<>(); - // 1) Émettre explicitement les 8 coins (dédupliqués par construction). + // 1) Emit the 8 corners once. double[][] corners = new double[][] { {x0, y0, z0}, {x1, y0, z0}, {x0, y0, z1}, {x1, y0, z1}, {x0, y1, z0}, {x1, y1, z0}, {x0, y1, z1}, {x1, y1, z1}, @@ -58,18 +37,15 @@ public final class ParticleEdgeEmitter { out.add(new Vector3d(c[0], c[1], c[2])); } - // 2) Pour chaque arête, émettre les points INTÉRIEURS (sans endpoints). - // 4 arêtes bas (Y=y0) : varient sur X ou Z. + // 2) Emit interior points (without endpoints) for each of the 12 edges. addInteriorLineX(out, x0, x1, y0, z0, d); addInteriorLineX(out, x0, x1, y0, z1, d); addInteriorLineZ(out, x0, y0, z0, z1, d); addInteriorLineZ(out, x1, y0, z0, z1, d); - // 4 arêtes haut (Y=y1). addInteriorLineX(out, x0, x1, y1, z0, d); addInteriorLineX(out, x0, x1, y1, z1, d); addInteriorLineZ(out, x0, y1, z0, z1, d); addInteriorLineZ(out, x1, y1, z0, z1, d); - // 4 arêtes verticales (varient sur Y). addInteriorLineY(out, x0, y0, y1, z0, d); addInteriorLineY(out, x1, y0, y1, z0, d); addInteriorLineY(out, x0, y0, y1, z1, d); @@ -78,12 +54,10 @@ public final class ParticleEdgeEmitter { return out; } - /** Points intérieurs (sans endpoints) d'une arête parallèle à X. */ private static void addInteriorLineX(List out, double xMin, double xMax, double y, double z, double density) { int n = pointCount(xMax - xMin, density); - // n = total points incl. endpoints ; intérieurs = n-2. for (int i = 1; i < n - 1; i++) { double t = (double) i / (double) (n - 1); out.add(new Vector3d(xMin + t * (xMax - xMin), y, z)); @@ -110,7 +84,7 @@ public final class ParticleEdgeEmitter { } } - /** {@code max(2, ceil(length * density))} — 2 endpoints garantis. */ + /** Returns max(2, ceil(length * density)) — guarantees 2 endpoints. */ static int pointCount(double length, double density) { double l = Math.max(0.0, length); int n = (int) Math.ceil(l * density); diff --git a/src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java b/src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java index 19e352f..910decf 100644 --- a/src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java +++ b/src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java @@ -24,71 +24,31 @@ import java.util.function.Consumer; import java.util.function.LongSupplier; /** - * Plan 03-05 + 03-06 : service qui émet soit des cubes {@link DebugUtils} (modes - * Outline/Faces/Both) soit des particules le long des 12 arêtes de l'AABB - * (mode Particles) pour matérialiser chaque région de gravity-flip côté clients. - * Ne crée aucun scheduler ; {@link #visualize(World, RegionSnapshot)} est appelé - * à chaque tick par {@code RegionTickLoop}, avec throttling par région via - * {@code VisualRefreshMs}. - * - *

Mapping VisualMode → render path : - *

    - *
  • {@code "Outline"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NO_SOLID}.
  • - *
  • {@code "Faces"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NO_WIREFRAME}.
  • - *
  • {@code "Both"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NONE}.
  • - *
  • {@code "Particles"} → particules le long des 12 arêtes AABB (Plan 03-06).
  • - *
  • {@code "None"} → skip (aucune émission).
  • - *
  • autre / null → fallback {@code "Outline"}.
  • - *
- * - *

Particles render path (03-06) : on n'utilise pas - * {@code ParticleUtil.spawnParticleEffect} directement car sa signature exige un - * {@code ComponentAccessor} non trivial à résoudre depuis un - * {@code world.execute(...)} lambda côté plugin. À la place, on émet le packet - * {@link SpawnParticleSystem} directement vers {@code world.getPlayerRefs()} - * (exact même pattern que {@link DebugUtils#add} — cf. lignes 59-61 du - * décompilé). L'accessor n'est utilisé par ParticleUtil que pour résoudre les - * {@code PlayerRef} via {@code SpatialResource} ; puisque {@code World} expose - * déjà les {@code PlayerRef} directement, ce raccourci est sémantiquement - * équivalent pour un broadcast world-wide. - * - *

Runtime id validation : avant la première émission pour un - * {@code VisualParticleId} donné, on vérifie que l'id existe dans - * {@code ParticleSystem.getAssetMap()} (pattern précédent : - * {@code ParticleSystemExistsValidator}). Id invalide → warning loggé une fois - * via {@code errorHandler}, fallback sur {@value #DEFAULT_PARTICLE_ID}. - * - *

Threading : {@code lastEmitMs} est un {@link ConcurrentHashMap} car - * {@code RegionTickLoop} tourne sur un thread daemon séparé. Les émissions - * packet sont wrappées dans {@code world.execute(...)} pour respecter l'assert - * WorldThread (pattern identique à {@code RegionRegistry.refreshFor}). - * - *

Anti-DoS : {@code VisualRefreshMs} est clampé à un plancher pour - * empêcher une valeur pathologique de saturer les clients. {@code density} est - * clampé côté {@link ParticleEdgeEmitter} à {@code [0.1, 10.0]}. + * Emits either DebugUtils cubes (Outline/Faces/Both modes) or edge particles (Particles mode) + * to materialise each gravity-flip region on clients. Throttled per-region via VisualRefreshMs. */ public final class RegionVisualizer { - /** Plancher anti-DoS sur {@code VisualRefreshMs} (threat T-03-05-03). */ + /** Anti-DoS floor on VisualRefreshMs. */ static final int MIN_REFRESH_MS = 100; - /** Fallback asset-id lorsqu'une VisualParticleId invalide est détectée. */ + /** Fallback asset id used when an invalid VisualParticleId is detected. */ public static final String DEFAULT_PARTICLE_ID = "Torch_Fire"; - /** Testability: DebugUtils emitter injectable (prod = {@link DebugUtils#add} wrapper). */ + /** Testability: injectable DebugUtils emitter (prod = {@link DebugUtils#add} wrapper). */ @FunctionalInterface public interface DebugEmitter { void emit(World world, DebugShape shape, Matrix4d matrix, Vector3f color, float opacity, float time, int flags); } - /** Testability: particle emitter injectable (prod = direct-packet broadcast). */ + /** Testability: injectable particle emitter (prod = direct-packet broadcast). */ @FunctionalInterface public interface ParticleEmitter { void emit(World world, String id, Vector3d pos); } - /** Testability: executor injectable (prod = {@code world::execute}). */ + /** Testability: injectable world executor (prod = {@code world::execute}). */ @FunctionalInterface public interface WorldExecutor { void execute(World world, Runnable r); @@ -98,18 +58,16 @@ public final class RegionVisualizer { (world, shape, matrix, color, opacity, time, flags) -> DebugUtils.add(world, shape, matrix, color, opacity, time, flags); - /** - * Prod particle emitter : broadcast direct de {@link SpawnParticleSystem} à - * {@code world.getPlayerRefs()} (sans ComponentAccessor — voir javadoc de classe). - */ + // Direct SpawnParticleSystem broadcast to world.getPlayerRefs() — avoids needing a + // ComponentAccessor inside a world.execute(...) lambda. private static final ParticleEmitter DEFAULT_PARTICLE_EMITTER = (world, id, pos) -> { SpawnParticleSystem packet = new SpawnParticleSystem( id, new Position(pos.x, pos.y, pos.z), - null, // no rotation - 1.0f, // default scale - null); // no color override + null, + 1.0f, + null); for (PlayerRef playerRef : world.getPlayerRefs()) { playerRef.getPacketHandler().writeNoCache(packet); } @@ -124,7 +82,7 @@ public final class RegionVisualizer { private final WorldExecutor executor; private final LongSupplier clock; private final Map lastEmitMs = new ConcurrentHashMap<>(); - /** Ids déjà warned comme invalides — évite le log-spam par tick. */ + /** Ids already warned as invalid — avoids log spam per tick. */ private final KeySetView warnedInvalidIds = ConcurrentHashMap.newKeySet(); public RegionVisualizer(Consumer errorHandler) { @@ -132,7 +90,6 @@ public final class RegionVisualizer { DEFAULT_EXECUTOR, System::currentTimeMillis); } - /** Package-private ctor pour tests (emitter DebugUtils + executor + clock injectés). */ RegionVisualizer(Consumer errorHandler, DebugEmitter emitter, WorldExecutor executor, @@ -140,7 +97,6 @@ public final class RegionVisualizer { this(errorHandler, emitter, DEFAULT_PARTICLE_EMITTER, executor, clock); } - /** Package-private ctor pour tests (tous les emitters + executor + clock injectés). */ RegionVisualizer(Consumer errorHandler, DebugEmitter emitter, ParticleEmitter particleEmitter, @@ -153,11 +109,7 @@ public final class RegionVisualizer { this.clock = clock; } - /** - * Émet la visualisation debug pour chaque région éligible du {@code snapshot}, - * en respectant mode / couleur / opacity / throttling. Ne propage aucune - * exception : tout throw est routé vers l'errorHandler (le tick ne doit pas mourir). - */ + /** Emits debug visualisation for each eligible region in the snapshot; never throws. */ public void visualize(World world, RegionSnapshot snapshot) { if (snapshot == null) return; try { @@ -193,6 +145,7 @@ public final class RegionVisualizer { int flags = flagsForMode(mode); Vector3f color = parseColor(r.getVisualColor()); Matrix4d matrix = matrixFromBox(r.getBox()); + // TTL = refreshMs * 1.2 to avoid flicker between emissions. float ttlSeconds = refreshMs * 1.2f / 1000f; float opacity = (float) clamp(r.getVisualOpacity(), 0.0, 1.0); @@ -229,13 +182,7 @@ public final class RegionVisualizer { } } - /** - * Runtime validation de {@code VisualParticleId}. Id valide → retourne tel quel. - * Id inconnu → warn-once (dédup via {@link #warnedInvalidIds}) et fallback - * sur {@link #DEFAULT_PARTICLE_ID}. Si la validation elle-même throw (ex : - * AssetMap indisponible hors contexte serveur), on laisse passer l'id tel quel - * (fail-open — le serveur loggera l'erreur par packet si vraiment cassé). - */ + /** Validates {@code VisualParticleId} against the asset map; warns once and falls back to the default id on unknown. */ String resolveParticleId(String requested) { if (requested == null || requested.isEmpty()) { return DEFAULT_PARTICLE_ID; @@ -251,12 +198,12 @@ public final class RegionVisualizer { } return DEFAULT_PARTICLE_ID; } catch (Throwable th) { - // AssetMap indisponible (tests hors serveur) — fail-open. + // AssetMap unavailable (tests outside server runtime) — fail-open. return requested; } } - /** Clear immédiat de toutes les shapes debug côté clients (appelé au shutdown). */ + /** Clears every debug shape on clients (called at shutdown). */ public void clearAll(World world) { if (world == null) return; try { @@ -272,9 +219,9 @@ public final class RegionVisualizer { } } - // ---------- helpers (package-private pour tests) ---------- + // Helpers (package-private for tests). - /** Parse {@code #RRGGBB} → Vector3f(r/255, g/255, b/255) ; fallback COLOR_CYAN sur toute erreur. */ + /** Parses {@code #RRGGBB} into a normalised Vector3f; falls back to cyan on any parse error. */ static Vector3f parseColor(String hex) { if (hex == null || hex.length() != 7 || hex.charAt(0) != '#') { return new Vector3f(DebugUtils.COLOR_CYAN); @@ -289,7 +236,7 @@ public final class RegionVisualizer { } } - /** Normalise le mode : inconnu/null → "Outline". */ + /** Normalises the mode; unknown/null falls back to "Outline". */ static String normalizeMode(String mode) { if ("Outline".equals(mode) || "Faces".equals(mode) || "Both".equals(mode) || "None".equals(mode) || "Particles".equals(mode)) { @@ -298,19 +245,19 @@ public final class RegionVisualizer { return "Outline"; } - /** Mapping VisualMode → flags DebugUtils. "None"/"Particles" n'utilisent pas les flags. */ + /** Maps VisualMode to DebugUtils flags. "None"/"Particles" do not use the flags. */ static int flagsForMode(String mode) { switch (normalizeMode(mode)) { case "Faces": return DebugUtils.FLAG_NO_WIREFRAME; case "Both": return DebugUtils.FLAG_NONE; - case "None": return DebugUtils.FLAG_NONE; // sentinel — caller skip avant. - case "Particles": return DebugUtils.FLAG_NONE; // unused — particles ne passent pas par DebugUtils. + case "None": return DebugUtils.FLAG_NONE; + case "Particles": return DebugUtils.FLAG_NONE; case "Outline": default: return DebugUtils.FLAG_NO_SOLID; } } - /** Identity.translate(center).scale(sizeX, sizeY, sizeZ) pour une Box non-cubique. */ + /** Identity.translate(center).scale(sizeX, sizeY, sizeZ) for a non-cubic box. */ static Matrix4d matrixFromBox(Box box) { Vector3d min = box.min; Vector3d max = box.max; diff --git a/src/test/java/com/mythlane/gravityflip/config/GravityFlipConfigCodecTest.java b/src/test/java/com/mythlane/gravityflip/config/GravityFlipConfigCodecTest.java index e22656e..75fad1b 100644 --- a/src/test/java/com/mythlane/gravityflip/config/GravityFlipConfigCodecTest.java +++ b/src/test/java/com/mythlane/gravityflip/config/GravityFlipConfigCodecTest.java @@ -15,15 +15,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -/** - * Round-trip tests for {@link GravityFlipConfig#CODEC}. Critical guarantees: - *

    - *
  • Regions list is always non-null (empty by default).
  • - *
  • List elements survive encode -> decode in order.
  • - *
  • Decoded list is MUTABLE — Phase 4 command handlers depend on this. - * Guard against an accidental {@code List.of(arr)} regression.
  • - *
- */ +/** Round-trip tests for {@link GravityFlipConfig#CODEC} — non-null regions list, order preserved, list remains mutable. */ class GravityFlipConfigCodecTest { @Test @@ -49,7 +41,6 @@ class GravityFlipConfigCodecTest { @Test void roundTripOfEmptyListYieldsNonNullEmptyList() { GravityFlipConfig src = new GravityFlipConfig(); - // src.regions is the default empty ArrayList. GravityFlipConfig decoded = roundTrip(src); assertNotNull(decoded.getRegions(), "decoded regions list must never be null"); @@ -63,8 +54,7 @@ class GravityFlipConfigCodecTest { GravityFlipConfig decoded = roundTrip(src); - // CRITICAL: must not throw UnsupportedOperationException. - // Phase 4 commands (define / delete / toggle) all mutate this list. + // Must not throw UnsupportedOperationException — command handlers depend on a mutable list. assertDoesNotThrow(() -> decoded.getRegions().add(region("added", 2, 2, 2, 3, 3, 3))); assertDoesNotThrow(() -> decoded.getRegions().remove(0)); assertTrue(decoded.getRegions() instanceof ArrayList, @@ -86,7 +76,6 @@ class GravityFlipConfigCodecTest { return GravityFlipConfig.CODEC.decode(encoded, info); } - // Suppress unused-import warning if List is not directly referenced in any final assertion. @SuppressWarnings("unused") private static List typeAnchor() { return null; } } diff --git a/src/test/java/com/mythlane/gravityflip/physics/FallDamageGuardTest.java b/src/test/java/com/mythlane/gravityflip/physics/FallDamageGuardTest.java index 44c197f..8392443 100644 --- a/src/test/java/com/mythlane/gravityflip/physics/FallDamageGuardTest.java +++ b/src/test/java/com/mythlane/gravityflip/physics/FallDamageGuardTest.java @@ -10,10 +10,7 @@ import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -/** - * Pure-data tests for {@link FallDamageGuard} — no Hytale runtime dependency. - * Covers entry / in-region / exit / grace-window / re-entry / FallDamage=true override. - */ +/** Pure-data tests for {@link FallDamageGuard} — entry, in-region, exit, grace-window, re-entry. */ class FallDamageGuardTest { @Test @@ -69,8 +66,7 @@ class FallDamageGuardTest { GravityFlipRegion region = region(false, 2500); guard.markInRegion(uuid, region); guard.markExit(uuid, region, 1000L); - guard.markInRegion(uuid, region); // re-enter - // In-region again with FallDamage=false → immediate suppression, grace reset. + guard.markInRegion(uuid, region); assertTrue(guard.shouldSuppressFallDamage(uuid, 1500L)); } @@ -82,7 +78,6 @@ class FallDamageGuardTest { GravityFlipRegion allowed = region(true, 2500); guard.markInRegion(uuid, suppressed); guard.markExit(uuid, suppressed, 1000L); - // New region has FallDamage=true → override immediately. guard.markInRegion(uuid, allowed); assertFalse(guard.shouldSuppressFallDamage(uuid, 1500L)); } diff --git a/src/test/java/com/mythlane/gravityflip/physics/GravityApplierDiffTest.java b/src/test/java/com/mythlane/gravityflip/physics/GravityApplierDiffTest.java index 2ea7063..2a21b15 100644 --- a/src/test/java/com/mythlane/gravityflip/physics/GravityApplierDiffTest.java +++ b/src/test/java/com/mythlane/gravityflip/physics/GravityApplierDiffTest.java @@ -8,14 +8,7 @@ import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; -/** - * Pure-diff + tracker-semantics tests for {@link GravityApplier}. - * - *

Pas de mocks (pattern Phase 02-02 deviation #4). Pas de runtime Hytale requis — - * les tests 5 et 6 utilisent le hook package-private {@code __updateTrackerForTest} et - * la vue {@code previouslyInvertedView()} pour valider la sémantique du tracker sans - * toucher à {@code World} / {@code Store}. - */ +/** Pure-diff + tracker-semantics tests for {@link GravityApplier}. */ class GravityApplierDiffTest { @Test @@ -73,16 +66,12 @@ class GravityApplierDiffTest { applier.__updateTrackerForTest(new HashSet<>(Set.of(c))); assertEquals(Set.of(c), applier.previouslyInvertedView()); - // View is immutable. Set view = applier.previouslyInvertedView(); assertThrows(UnsupportedOperationException.class, () -> view.add(UUID.randomUUID())); } - // NOTE (Rule 3 deviation — Plan 03-02) : les tests suivants ciblent la seam pure - // `buildFlaggedDecision(double, double, boolean)` au lieu de `buildPhysicsValuesWithFlag` - // parce que le static init de `PhysicsValues` déclenche un `ExceptionInInitializerError` - // hors runtime Hytale (dépendance ModuleRegistry). La décomposition pure garantit la - // sémantique attendue (mass/drag préservés, flag = target) sans couplage ECS. + // These tests target the pure seam buildFlaggedDecision because PhysicsValues static init + // triggers ExceptionInInitializerError outside the Hytale runtime. @Test void buildFlaggedDecisionPreservesMassAndDrag() { diff --git a/src/test/java/com/mythlane/gravityflip/region/GravityFlipRegionCodecTest.java b/src/test/java/com/mythlane/gravityflip/region/GravityFlipRegionCodecTest.java index a8e1ff7..d89271d 100644 --- a/src/test/java/com/mythlane/gravityflip/region/GravityFlipRegionCodecTest.java +++ b/src/test/java/com/mythlane/gravityflip/region/GravityFlipRegionCodecTest.java @@ -11,15 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -/** - * Round-trip tests for {@link GravityFlipRegion#CODEC}. Verifies the codec preserves - * 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. - */ +/** Round-trip tests for {@link GravityFlipRegion#CODEC} covering legacy + optional tuning + visualization fields. */ class GravityFlipRegionCodecTest { @Test @@ -68,11 +60,9 @@ 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). + // Legacy 3-arg constructor simulates an old regions.json. GravityFlipRegion src = new GravityFlipRegion( "legacy", new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)), @@ -80,7 +70,6 @@ class GravityFlipRegionCodecTest { 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"); @@ -119,7 +108,6 @@ class GravityFlipRegionCodecTest { src.setAffectPlayers(false); GravityFlipRegion decoded = roundTrip(src); assertFalse(decoded.isAffectPlayers()); - // Les autres filtres restent à true (non-clobber). assertTrue(decoded.isAffectNpcs()); assertTrue(decoded.isAffectItems()); } @@ -144,8 +132,6 @@ class GravityFlipRegionCodecTest { assertFalse(decoded.isAffectItems()); } - // ---------- Plan 03-05 : 4 visualization fields ---------- - @Test void roundTripPreservesVisualFields() { GravityFlipRegion src = baseRegion(); @@ -164,8 +150,6 @@ class GravityFlipRegionCodecTest { @Test void roundTripPreservesVisualDefaultsWhenFieldsAbsent() { - // Region construite via constructeur legacy 3-arg — simule un regions.json legacy - // (ni 03-04 ni 03-05 présents). GravityFlipRegion src = new GravityFlipRegion( "legacy-viz", new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)), diff --git a/src/test/java/com/mythlane/gravityflip/region/RegionRegistryTest.java b/src/test/java/com/mythlane/gravityflip/region/RegionRegistryTest.java index 7fc7074..47ba059 100644 --- a/src/test/java/com/mythlane/gravityflip/region/RegionRegistryTest.java +++ b/src/test/java/com/mythlane/gravityflip/region/RegionRegistryTest.java @@ -18,14 +18,8 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; /** - * Pure-math + concurrency tests for {@link RegionRegistry}. - * - *

JDK 25 + Mockito + Hytale's {@code World} class is a bad combination — Mockito's inline - * MockMaker (the only one that can mock final classes) triggers static init of the supertype - * {@code PluginBase}, which fails outside a real server because {@code HytaleLogger} requires - * the JUL log manager to be set first. Therefore all snapshot tests use the package-private - * {@code publishSnapshotByKey} / {@code currentSnapshotByKey} hooks with {@code Object} - * sentinels, never a real or mocked {@code World}. + * Pure-math + concurrency tests for {@link RegionRegistry}. Snapshot tests use the package-private + * publishSnapshotByKey / currentSnapshotByKey hooks because Mockito cannot mock World under JDK 25. */ class RegionRegistryTest { @@ -114,11 +108,9 @@ class RegionRegistryTest { cfg.getRegions().add(new GravityFlipRegion("a", box(), true)); RegionRegistry reg = new RegionRegistry(cfg); - // Reader captures the immutable list before the swap. var before = reg.enabled(); assertEquals(1, before.size()); - // Mutator swaps via refreshFromConfig. cfg.getRegions().add(new GravityFlipRegion("b", box(), true)); reg.refreshFromConfig(cfg); @@ -128,7 +120,6 @@ class RegionRegistryTest { assertEquals(1, before.size()); } - /** Minimal RegionSnapshot for the publish/read tests; world() is unused (returns null). */ private static final class StubSnapshot implements RegionSnapshot { private final Map>> by; private final long tick; diff --git a/src/test/java/com/mythlane/gravityflip/tick/RegionTickLoopTest.java b/src/test/java/com/mythlane/gravityflip/tick/RegionTickLoopTest.java index e822e99..8c36639 100644 --- a/src/test/java/com/mythlane/gravityflip/tick/RegionTickLoopTest.java +++ b/src/test/java/com/mythlane/gravityflip/tick/RegionTickLoopTest.java @@ -9,10 +9,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; -/** - * Scheduler-timing tests for {@link RegionTickLoop}. Use the {@code Runnable} overload so the - * tests don't wait the 2s production initial delay and don't need a real {@code World}. - */ +/** Scheduler-timing tests for {@link RegionTickLoop} using the Runnable overload. */ class RegionTickLoopTest { @Test @@ -32,7 +29,7 @@ class RegionTickLoopTest { RegionTickLoop loop = new RegionTickLoop(reg, t -> {}); AtomicInteger count = new AtomicInteger(); loop.startWithDelay(0L, (Runnable) count::incrementAndGet); - Thread.sleep(300); // ~3 ticks + Thread.sleep(300); long t0 = System.nanoTime(); loop.stop(); long elapsedMs = (System.nanoTime() - t0) / 1_000_000; @@ -52,7 +49,7 @@ class RegionTickLoopTest { int n = count.incrementAndGet(); if (n == 1) throw new RuntimeException("boom on first tick"); }); - Thread.sleep(500); // expect ~5 ticks despite the first throwing + Thread.sleep(500); loop.stop(); assertTrue(count.get() >= 3, "scheduler died after first throw; count=" + count.get()); assertNotNull(capturedFirst.get(), "errorHandler was not invoked"); diff --git a/src/test/java/com/mythlane/gravityflip/viz/ParticleEdgeEmitterTest.java b/src/test/java/com/mythlane/gravityflip/viz/ParticleEdgeEmitterTest.java index 30ea361..e6d5d91 100644 --- a/src/test/java/com/mythlane/gravityflip/viz/ParticleEdgeEmitterTest.java +++ b/src/test/java/com/mythlane/gravityflip/viz/ParticleEdgeEmitterTest.java @@ -10,23 +10,18 @@ import java.util.Set; import static org.junit.jupiter.api.Assertions.*; -/** - * Tests for {@link ParticleEdgeEmitter}. Verifies the 12-edge AABB emission - * contract — no diagonals, no interior points, corner-dedup, density clamping. - */ +/** Tests for {@link ParticleEdgeEmitter} — 12-edge AABB emission, no diagonals, corner dedup, density clamping. */ class ParticleEdgeEmitterTest { private static final double EPS = 1e-9; @Test void unitBox_density1_returnsExactly8Corners() { - // 1x1x1 box, density=1 → each edge of length 1 → ceil(1*1)=1 → max(2,1)=2 - // points per edge (endpoints only), dedup → 8 corners total. + // 1x1x1 box, density=1 -> each edge of length 1 -> 2 points/edge (endpoints), deduped -> 8 corners. Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)); List pts = ParticleEdgeEmitter.edgePoints(b, 1.0); assertEquals(8, pts.size(), "unit box at density=1 should emit exactly 8 corner points"); - // All 8 canonical corners present. Set expected = new HashSet<>(); for (double x : new double[]{0, 1}) for (double y : new double[]{0, 1}) @@ -39,30 +34,26 @@ class ParticleEdgeEmitterTest { @Test void largeBox_density1_allPointsOnBoxSurfaceAndOnEdges() { - // 10x10x10 box, density=1 → 11 points per edge (incl. endpoints). Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(10, 10, 10)); List pts = ParticleEdgeEmitter.edgePoints(b, 1.0); - // Edge membership: each point must lie on ≥ 2 of the 6 box planes - // (i.e. at least 2 of its coords are on {min, max} of their axis). + // Edge membership: each point must lie on at least 2 of the 6 box planes. for (Vector3d p : pts) { int onPlane = 0; if (approx(p.x, 0) || approx(p.x, 10)) onPlane++; if (approx(p.y, 0) || approx(p.y, 10)) onPlane++; if (approx(p.z, 0) || approx(p.z, 10)) onPlane++; assertTrue(onPlane >= 2, - "point " + p + " must be on ≥ 2 box planes (edge membership), was on " + onPlane); + "point " + p + " must be on >= 2 box planes (edge membership), was on " + onPlane); } - // Sanity: no duplicate points (corners must be deduped). Set keys = new HashSet<>(); for (Vector3d p : pts) { assertTrue(keys.add(key(p.x, p.y, p.z)), - "duplicate point " + p + " — corners should be dedup'd"); + "duplicate point " + p + " — corners should be deduped"); } - // Expected count: ceil(10*1) = 10 points/edge (incl. endpoints) → 8 interior/edge. - // Total = 8 corners + 12 edges * 8 interior = 8 + 96 = 104. + // 10 points/edge (incl. endpoints) -> 8 interior/edge. Total = 8 corners + 12 * 8 = 104. assertEquals(104, pts.size()); } @@ -70,12 +61,9 @@ class ParticleEdgeEmitterTest { void density_zeroClampedToMin_density1000ClampedToMax() { Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)); - // density=0 → clamp to 0.1 → per-edge ceil(1*0.1)=1 → max(2,1)=2 endpoints only. List lo = ParticleEdgeEmitter.edgePoints(b, 0.0); assertEquals(8, lo.size(), "density=0 should clamp to MIN_DENSITY, yielding 8 corners on unit box"); - // density=1000 → clamp to 10 → per-edge ceil(1*10)=10 → 10 total points/edge. - // 8 corners + 12 * 8 interior = 8 + 96 = 104. List hi = ParticleEdgeEmitter.edgePoints(b, 1000.0); assertEquals(104, hi.size(), "density=1000 should clamp to MAX_DENSITY=10"); } @@ -87,14 +75,11 @@ class ParticleEdgeEmitterTest { assertEquals(8, pts.size()); } - // ---------- helpers ---------- - private static boolean approx(double a, double b) { return Math.abs(a - b) < EPS; } private static String key(double x, double y, double z) { - // Round to 6 decimals to avoid floating-point noise in the dedup check. return String.format("%.6f,%.6f,%.6f", x, y, z); } } diff --git a/src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java b/src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java index 94d2465..1f0bd42 100644 --- a/src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java +++ b/src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java @@ -23,11 +23,7 @@ import java.util.concurrent.atomic.AtomicLong; import static org.junit.jupiter.api.Assertions.*; -/** - * Tests unitaires pour {@link RegionVisualizer}. Le {@code World} n'est jamais touché - * (pas mockable sous JDK 25) : le {@code WorldExecutor} injecté exécute la lambda - * inline sans ré-entrer dans World, et le {@code DebugEmitter} pousse dans une liste. - */ +/** Unit tests for {@link RegionVisualizer} with injected WorldExecutor/DebugEmitter so World is never touched. */ class RegionVisualizerTest { private static final class Call { @@ -38,8 +34,6 @@ class RegionVisualizerTest { } } - // ---------- parseColor ---------- - @Test void parseColor_validHex() { Vector3f c = RegionVisualizer.parseColor("#FF8800"); @@ -59,8 +53,6 @@ class RegionVisualizerTest { } } - // ---------- normalizeMode / flagsForMode ---------- - @Test void parseMode_unknown_fallsBackToOutline() { assertEquals("Outline", RegionVisualizer.normalizeMode("Blah")); @@ -76,7 +68,6 @@ class RegionVisualizerTest { assertEquals(DebugUtils.FLAG_NO_WIREFRAME, RegionVisualizer.flagsForMode("Faces")); assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Both")); assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Particles")); - // unknown → Outline assertEquals(DebugUtils.FLAG_NO_SOLID, RegionVisualizer.flagsForMode("xxx")); } @@ -85,8 +76,6 @@ class RegionVisualizerTest { assertEquals("Particles", RegionVisualizer.normalizeMode("Particles")); } - // ---------- Particles branch ---------- - @Test void visualize_particlesMode_callsParticleEmitterOncePerEdgePoint() { List particleCalls = new ArrayList<>(); @@ -98,14 +87,12 @@ class RegionVisualizerTest { (w, r) -> r.run(), () -> 0L); - // unit box at density=1 → 8 corner points emitted. GravityFlipRegion r = region("pz", "#FFFFFF", "Particles", 1000, 0.5); r.setVisualParticleId("Dust_Sparkles_Fine"); r.setVisualParticleDensity(1.0); viz.visualize(null, snapshotOf(r)); - assertEquals(8, particleCalls.size(), "unit box + density=1 → 8 corner emissions"); - // All calls use the requested id (validation falls open in test context). + assertEquals(8, particleCalls.size(), "unit box + density=1 -> 8 corner emissions"); for (String call : particleCalls) { assertTrue(call.startsWith("Dust_Sparkles_Fine@"), "unexpected call: " + call); } @@ -113,31 +100,26 @@ class RegionVisualizerTest { @Test void particleDefaults_absentInConstructedRegion() { - // Defaults must match the codec's documented defaults (03-06). GravityFlipRegion r = new GravityFlipRegion("x", new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)), true); assertEquals("Torch_Fire", r.getVisualParticleId()); assertEquals(0.3, r.getVisualParticleDensity(), 1e-9); } - // ---------- matrixFromBox ---------- - @Test void matrix_boxNonCubic() { Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(2, 4, 6)); Matrix4d m = RegionVisualizer.matrixFromBox(b); double[] d = m.getData(); - // column-major : scale en diag [0][5][10], translation en [12][13][14] + // column-major: scale on diagonal [0][5][10], translation on [12][13][14]. assertEquals(2.0, d[0], 1e-9); assertEquals(4.0, d[5], 1e-9); assertEquals(6.0, d[10], 1e-9); - assertEquals(1.0, d[12], 1e-9); // center x = 1 - assertEquals(2.0, d[13], 1e-9); // center y = 2 - assertEquals(3.0, d[14], 1e-9); // center z = 3 + assertEquals(1.0, d[12], 1e-9); + assertEquals(2.0, d[13], 1e-9); + assertEquals(3.0, d[14], 1e-9); } - // ---------- visualize : throttling / modes / skip ---------- - @Test void visualize_throttlingSkipsSecondCallWithinWindow() { List calls = new ArrayList<>(); @@ -152,15 +134,15 @@ class RegionVisualizerTest { RegionSnapshot snap = snapshotOf(r); viz.visualize(null, snap); - assertEquals(1, calls.size(), "premier tick émet"); + assertEquals(1, calls.size(), "first tick emits"); - clock.set(1_500L); // +500ms < 1000 refreshMs + clock.set(1_500L); viz.visualize(null, snap); - assertEquals(1, calls.size(), "deuxième tick throttle"); + assertEquals(1, calls.size(), "second tick is throttled"); - clock.set(2_100L); // +1100ms >= 1000 + clock.set(2_100L); viz.visualize(null, snap); - assertEquals(2, calls.size(), "troisième tick ré-émet après refreshMs"); + assertEquals(2, calls.size(), "third tick re-emits after refreshMs"); } @Test @@ -193,7 +175,7 @@ class RegionVisualizerTest { assertEquals(DebugShape.Cube, c.shape); assertEquals(DebugUtils.FLAG_NO_WIREFRAME, c.flags); assertEquals(0.75f, c.opacity, 1e-6); - // TTL = 1000 * 1.2 / 1000 = 1.2s + // TTL = 1000 * 1.2 / 1000 = 1.2s. assertEquals(1.2f, c.time, 1e-3); } @@ -201,14 +183,14 @@ class RegionVisualizerTest { void visualize_clampsOpacityOutOfRange() { List calls = new ArrayList<>(); RegionVisualizer viz = newViz(calls, 0L); - GravityFlipRegion r = region("z1", "#00FF00", "Both", 1000, 2.5); // > 1 → clamp à 1 + GravityFlipRegion r = region("z1", "#00FF00", "Both", 1000, 2.5); viz.visualize(null, snapshotOf(r)); assertEquals(1.0f, calls.get(0).opacity, 1e-6); } @Test void visualize_clampsRefreshFloorBelowMin() { - // refreshMs = 10 < MIN_REFRESH_MS (100) → effectif = 100ms + // refreshMs=10 < MIN_REFRESH_MS(100) -> effective = 100ms. List calls = new ArrayList<>(); AtomicLong clock = new AtomicLong(0L); RegionVisualizer viz = new RegionVisualizer( @@ -218,16 +200,14 @@ class RegionVisualizerTest { clock::get); GravityFlipRegion r = region("z1", "#00FF00", "Outline", 10, 0.5); viz.visualize(null, snapshotOf(r)); - clock.set(50L); // 50ms < 100 plancher + clock.set(50L); viz.visualize(null, snapshotOf(r)); - assertEquals(1, calls.size(), "plancher 100ms protège contre flood"); + assertEquals(1, calls.size(), "100ms floor protects against flood"); clock.set(150L); viz.visualize(null, snapshotOf(r)); assertEquals(2, calls.size()); } - // ---------- helpers ---------- - private static RegionVisualizer newViz(List calls, long now) { AtomicLong clock = new AtomicLong(now); return new RegionVisualizer(