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
This commit is contained in:
@@ -23,25 +23,11 @@ import com.hypixel.hytale.server.core.modules.interaction.interaction.config.Int
|
|||||||
import java.util.concurrent.ScheduledFuture;
|
import java.util.concurrent.ScheduledFuture;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
|
|
||||||
/**
|
/** Entry point for the Gravity Flip plugin (Hytale Server API setup/shutdown lifecycle). */
|
||||||
* Entry point for the Gravity Flip plugin.
|
|
||||||
*
|
|
||||||
* <p>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.
|
|
||||||
*/
|
|
||||||
public class GravityFlipPlugin extends JavaPlugin {
|
public class GravityFlipPlugin extends JavaPlugin {
|
||||||
|
|
||||||
/**
|
// Persisted region store at <dataDirectory>/regions.json. The named withConfig(name, codec)
|
||||||
* Persisted region store, materialised on disk as
|
// overload is REQUIRED — the 1-arg overload hardcodes the filename to config.json.
|
||||||
* {@code <dataDirectory>/regions.json} (i.e. {@code Plugins/GravityFlip/regions.json}).
|
|
||||||
*
|
|
||||||
* <p>The named {@code withConfig(name, codec)} overload is REQUIRED — the
|
|
||||||
* 1-arg overload hardcodes the filename to {@code config.json}.
|
|
||||||
*/
|
|
||||||
private final Config<GravityFlipConfig> configHolder =
|
private final Config<GravityFlipConfig> configHolder =
|
||||||
withConfig("regions", GravityFlipConfig.CODEC);
|
withConfig("regions", GravityFlipConfig.CODEC);
|
||||||
|
|
||||||
@@ -58,28 +44,18 @@ public class GravityFlipPlugin extends JavaPlugin {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void setup() {
|
protected void setup() {
|
||||||
// NOTE: do NOT call configHolder.get() here — it blocks until preLoad() completes.
|
// Do NOT call configHolder.get() here — it blocks until preLoad() completes.
|
||||||
// Safe call sites are start() and any later lifecycle phase (incl. tick loop).
|
// Safe call sites are start() and any later lifecycle phase.
|
||||||
//
|
//
|
||||||
// World acquisition note (Phase 02-02): the plan called for a PrepareUniverseEvent
|
// World acquisition: PrepareUniverseEvent only exposes a WorldConfigProvider, not a
|
||||||
// listener that stashes a World reference. Empirically, PrepareUniverseEvent
|
// Universe/World. We use a Supplier<World> that resolves Universe.get().getDefaultWorld()
|
||||||
// (com.hypixel.hytale.server.core.event.events.PrepareUniverseEvent) only carries
|
// lazily on each tick; until the universe is ready, the supplier returns null and the
|
||||||
// a WorldConfigProvider — it does NOT expose a Universe or World. We therefore use
|
// tick is a no-op.
|
||||||
// a Supplier<World> that resolves Universe.get().getDefaultWorld() lazily on each
|
|
||||||
// tick (matching the MythWorld WorldBorderManager precedent). Until the universe
|
|
||||||
// is ready, the supplier returns null and the tick is a no-op.
|
|
||||||
// Plan 03-04 : enregistrer le FallDamageSuppressorSystem DANS setup() (fenêtre ECS
|
|
||||||
// de registration). Pattern identique à FlockPlugin.java → entityStoreRegistry.registerSystem(...).
|
|
||||||
this.fallDamageGuard = new FallDamageGuard();
|
this.fallDamageGuard = new FallDamageGuard();
|
||||||
getEntityStoreRegistry().registerSystem(new FallDamageSuppressorSystem(
|
getEntityStoreRegistry().registerSystem(new FallDamageSuppressorSystem(
|
||||||
fallDamageGuard,
|
fallDamageGuard,
|
||||||
th -> getLogger().at(Level.WARNING).withCause(th).log("fallDamageSuppressor handle failed")));
|
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();
|
this.wandSelectionStore = new WandSelectionStore();
|
||||||
GravityFlipWandInteraction.bindStore(this.wandSelectionStore);
|
GravityFlipWandInteraction.bindStore(this.wandSelectionStore);
|
||||||
getCodecRegistry(Interaction.CODEC).register(
|
getCodecRegistry(Interaction.CODEC).register(
|
||||||
@@ -107,25 +83,20 @@ public class GravityFlipPlugin extends JavaPlugin {
|
|||||||
this.tickLoop = new RegionTickLoop(registry, gravityApplier, regionVisualizer, th ->
|
this.tickLoop = new RegionTickLoop(registry, gravityApplier, regionVisualizer, th ->
|
||||||
getLogger().at(Level.WARNING).withCause(th).log("detectTick failed"));
|
getLogger().at(Level.WARNING).withCause(th).log("detectTick failed"));
|
||||||
|
|
||||||
// Lazy world resolution — see setup() comment.
|
|
||||||
this.tickLoop.startWithDelay(2_000L, () -> {
|
this.tickLoop.startWithDelay(2_000L, () -> {
|
||||||
Universe u = Universe.get();
|
Universe u = Universe.get();
|
||||||
return u == null ? null : u.getDefaultWorld();
|
return u == null ? null : u.getDefaultWorld();
|
||||||
});
|
});
|
||||||
|
|
||||||
// TaskRegistry registration: registerTask only accepts ScheduledFuture<Void>; the
|
// TaskRegistry.registerTask only accepts ScheduledFuture<Void>; the scheduler returns
|
||||||
// scheduler returns ScheduledFuture<?>. Cast via raw types per Mythlane idiom; the
|
// ScheduledFuture<?>. Cast via raw types; the fallback keeps teardown deterministic
|
||||||
// try/catch falls back to manual shutdown() if registration fails (deterministic
|
// because shutdown() always invokes tickLoop.stop().
|
||||||
// either way because shutdown() always invokes tickLoop.stop()).
|
|
||||||
try {
|
try {
|
||||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||||
ScheduledFuture<Void> vf = (ScheduledFuture) tickLoop.future();
|
ScheduledFuture<Void> vf = (ScheduledFuture) tickLoop.future();
|
||||||
getTaskRegistry().registerTask(vf);
|
getTaskRegistry().registerTask(vf);
|
||||||
} catch (Throwable ignored) { /* manual shutdown() fallback */ }
|
} 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));
|
getCommandRegistry().registerCommand(new GravityFlipCommand(this));
|
||||||
|
|
||||||
getLogger().at(Level.INFO).log(
|
getLogger().at(Level.INFO).log(
|
||||||
@@ -135,28 +106,22 @@ public class GravityFlipPlugin extends JavaPlugin {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void shutdown() {
|
protected void shutdown() {
|
||||||
// Plan 03-05 : clear des debug shapes cote clients AVANT tickLoop.stop().
|
// Clear debug shapes on clients BEFORE stopping the tick loop. If the universe is already
|
||||||
// Si l'Universe est deja fermee, world==null => shapes expireront via TTL (acceptable).
|
// torn down, world==null and shapes will expire via TTL (acceptable).
|
||||||
if (regionVisualizer != null) {
|
if (regionVisualizer != null) {
|
||||||
Universe u = Universe.get();
|
Universe u = Universe.get();
|
||||||
World w = (u == null) ? null : u.getDefaultWorld();
|
World w = (u == null) ? null : u.getDefaultWorld();
|
||||||
if (w != null) regionVisualizer.clearAll(w);
|
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();
|
if (tickLoop != null) tickLoop.stop();
|
||||||
// No auto-save contract: any mutation made during the session must already
|
// No auto-save contract: mutations made during the session must already have been
|
||||||
// have been persisted via configHolder().save() by the command handler that
|
// persisted via configHolder().save() by the command handler that performed them.
|
||||||
// performed it. See configHolder() javadoc.
|
|
||||||
getLogger().at(Level.INFO).log("Gravity Flip disabled");
|
getLogger().at(Level.INFO).log("Gravity Flip disabled");
|
||||||
super.shutdown();
|
super.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Showcase zone seeded on first run (10x20x10 box at (0,100,0)..(10,120,10) with Torch_Fire particles). */
|
||||||
* 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}.
|
|
||||||
*/
|
|
||||||
private static GravityFlipRegion buildShowcaseRegion() {
|
private static GravityFlipRegion buildShowcaseRegion() {
|
||||||
GravityFlipRegion r = new GravityFlipRegion(
|
GravityFlipRegion r = new GravityFlipRegion(
|
||||||
"demo-gravity-flip",
|
"demo-gravity-flip",
|
||||||
@@ -170,25 +135,17 @@ public class GravityFlipPlugin extends JavaPlugin {
|
|||||||
return r;
|
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; }
|
public RegionRegistry regions() { return registry; }
|
||||||
|
|
||||||
/**
|
/** Per-player wand selection store; returns null until {@link #setup()} has run. */
|
||||||
* Per-player wand selection store. Populated by
|
|
||||||
* {@link GravityFlipWandInteraction}; consumed by
|
|
||||||
* {@code /gravityflip define} (Phase 04-02+).
|
|
||||||
* <p>Returns {@code null} until {@link #setup()} has run.
|
|
||||||
*/
|
|
||||||
public WandSelectionStore wandSelections() { return wandSelectionStore; }
|
public WandSelectionStore wandSelections() { return wandSelectionStore; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accessor for the region config holder. <strong>SAVE CONTRACT:</strong> any
|
* Accessor for the region config holder. SAVE CONTRACT: any caller that mutates
|
||||||
* caller that mutates {@code configHolder().get().getRegions()} MUST call
|
* {@code configHolder().get().getRegions()} MUST call {@code configHolder().save()} afterwards.
|
||||||
* {@code configHolder().save()} afterwards. There is no lifecycle hook that
|
* {@code Config.get()} returns a SHARED MUTABLE reference; concurrent writers corrupt state —
|
||||||
* auto-saves on shutdown. {@code Config.get()} returns a SHARED MUTABLE
|
* {@code RegionRegistry} snapshots it into an {@code AtomicReference} for tick-loop reads.
|
||||||
* reference; concurrent writers corrupt state — Phase 02-02's
|
|
||||||
* {@code RegionRegistry} snapshots it into an {@code AtomicReference} for
|
|
||||||
* tick-loop reads.
|
|
||||||
*/
|
*/
|
||||||
public Config<GravityFlipConfig> configHolder() {
|
public Config<GravityFlipConfig> configHolder() {
|
||||||
return configHolder;
|
return configHolder;
|
||||||
|
|||||||
@@ -9,20 +9,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/** Root config wrapping the persisted list of {@link GravityFlipRegion}s (persisted as regions.json). */
|
||||||
* Root config wrapping the persisted list of {@link GravityFlipRegion}s.
|
|
||||||
*
|
|
||||||
* <p>Persisted as {@code <dataDirectory>/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.
|
|
||||||
*
|
|
||||||
* <p>The decoded list is wrapped in a <em>mutable</em> {@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.
|
|
||||||
*/
|
|
||||||
public final class GravityFlipConfig {
|
public final class GravityFlipConfig {
|
||||||
|
|
||||||
public static final BuilderCodec<GravityFlipConfig> CODEC =
|
public static final BuilderCodec<GravityFlipConfig> CODEC =
|
||||||
@@ -30,7 +17,7 @@ public final class GravityFlipConfig {
|
|||||||
.append(
|
.append(
|
||||||
new KeyedCodec<>("Regions",
|
new KeyedCodec<>("Regions",
|
||||||
new ArrayCodec<>(GravityFlipRegion.CODEC, GravityFlipRegion[]::new)),
|
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, arr) -> c.regions = new ArrayList<>(Arrays.asList(arr)),
|
||||||
c -> c.regions.toArray(GravityFlipRegion[]::new)
|
c -> c.regions.toArray(GravityFlipRegion[]::new)
|
||||||
).add()
|
).add()
|
||||||
|
|||||||
@@ -6,56 +6,30 @@ import java.util.UUID;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thread-safe tracker that decides whether fall damage should be suppressed for a given UUID
|
* 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.
|
* based on current in-region membership and a post-exit grace window.
|
||||||
*
|
|
||||||
* <p>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.
|
|
||||||
*
|
|
||||||
* <p><b>Precedence rule (multi-region) :</b> 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.
|
|
||||||
*
|
|
||||||
* <p><b>State machine :</b>
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code markInRegion(uuid, region)} → currentRegion[uuid]=region ; grace entries cleared.</li>
|
|
||||||
* <li>{@code markExit(uuid, region, nowMs)} → currentRegion[uuid] removed ; grace entry stored
|
|
||||||
* (region + timestamp).</li>
|
|
||||||
* <li>{@code shouldSuppressFallDamage(uuid, nowMs)} :
|
|
||||||
* <ol>
|
|
||||||
* <li>If current region != null AND current region {@code FallDamage==false} → true.</li>
|
|
||||||
* <li>Else if grace entry present AND grace region {@code FallDamage==false} AND
|
|
||||||
* {@code nowMs - exitMs <= gracePeriodMs} → true.</li>
|
|
||||||
* <li>Else → false.</li>
|
|
||||||
* </ol>
|
|
||||||
* </li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
*/
|
||||||
public final class FallDamageGuard {
|
public final class FallDamageGuard {
|
||||||
|
|
||||||
/** Entity currently inside a region (cleared on exit). */
|
|
||||||
private final ConcurrentHashMap<UUID, GravityFlipRegion> currentRegionByUuid =
|
private final ConcurrentHashMap<UUID, GravityFlipRegion> currentRegionByUuid =
|
||||||
new ConcurrentHashMap<>();
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
/** Timestamp (ms) at which the entity last exited a region. */
|
|
||||||
private final ConcurrentHashMap<UUID, Long> exitTimestampByUuid =
|
private final ConcurrentHashMap<UUID, Long> exitTimestampByUuid =
|
||||||
new ConcurrentHashMap<>();
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
/** Region referenced at the moment of exit (used to read FallDamage + GracePeriodMs). */
|
|
||||||
private final ConcurrentHashMap<UUID, GravityFlipRegion> regionAtExitByUuid =
|
private final ConcurrentHashMap<UUID, GravityFlipRegion> regionAtExitByUuid =
|
||||||
new ConcurrentHashMap<>();
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Marks an entity as currently inside the given region (clears any stale grace entry). */
|
||||||
public void markInRegion(UUID uuid, GravityFlipRegion region) {
|
public void markInRegion(UUID uuid, GravityFlipRegion region) {
|
||||||
if (uuid == null || region == null) return;
|
if (uuid == null || region == null) return;
|
||||||
currentRegionByUuid.put(uuid, region);
|
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);
|
exitTimestampByUuid.remove(uuid);
|
||||||
regionAtExitByUuid.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) {
|
public void markExit(UUID uuid, GravityFlipRegion region, long nowMs) {
|
||||||
if (uuid == null || region == null) return;
|
if (uuid == null || region == null) return;
|
||||||
currentRegionByUuid.remove(uuid);
|
currentRegionByUuid.remove(uuid);
|
||||||
@@ -63,6 +37,7 @@ public final class FallDamageGuard {
|
|||||||
regionAtExitByUuid.put(uuid, region);
|
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) {
|
public boolean shouldSuppressFallDamage(UUID uuid, long nowMs) {
|
||||||
if (uuid == null) return false;
|
if (uuid == null) return false;
|
||||||
|
|
||||||
@@ -74,11 +49,11 @@ public final class FallDamageGuard {
|
|||||||
Long exitMs = exitTimestampByUuid.get(uuid);
|
Long exitMs = exitTimestampByUuid.get(uuid);
|
||||||
GravityFlipRegion exitRegion = regionAtExitByUuid.get(uuid);
|
GravityFlipRegion exitRegion = regionAtExitByUuid.get(uuid);
|
||||||
if (exitMs == null || exitRegion == null) return false;
|
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();
|
return (nowMs - exitMs) <= exitRegion.getGracePeriodMs();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Test hooks / diagnostics (package-private) ----
|
// Test hooks (package-private).
|
||||||
|
|
||||||
int trackedInRegionCount() { return currentRegionByUuid.size(); }
|
int trackedInRegionCount() { return currentRegionByUuid.size(); }
|
||||||
int trackedGraceCount() { return exitTimestampByUuid.size(); }
|
int trackedGraceCount() { return exitTimestampByUuid.size(); }
|
||||||
|
|||||||
@@ -17,21 +17,7 @@ import javax.annotation.Nonnull;
|
|||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/** Cancels {@link Damage} events of cause {@code Fall} for any UUID suppressed by {@link FallDamageGuard}. */
|
||||||
* ECS {@link DamageEventSystem} that cancels {@link Damage} events of cause {@code Fall}
|
|
||||||
* for any entity whose UUID is currently suppressed by {@link FallDamageGuard}.
|
|
||||||
*
|
|
||||||
* <p>Registered in {@code GravityFlipPlugin.setup()} via
|
|
||||||
* {@code getEntityStoreRegistry().registerSystem(new FallDamageSuppressorSystem(guard, errorHandler))}.
|
|
||||||
*
|
|
||||||
* <p>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}.
|
|
||||||
*
|
|
||||||
* <p>FALL_INDEX is resolved lazily via {@code DamageCause.getAssetMap().getIndex("Fall")} and
|
|
||||||
* cached for the life of the server — the asset map is built once during {@code EntityModule}
|
|
||||||
* setup and stable thereafter.
|
|
||||||
*/
|
|
||||||
public final class FallDamageSuppressorSystem extends DamageEventSystem {
|
public final class FallDamageSuppressorSystem extends DamageEventSystem {
|
||||||
|
|
||||||
private final FallDamageGuard guard;
|
private final FallDamageGuard guard;
|
||||||
|
|||||||
@@ -28,44 +28,12 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tick-driven service that toggles the native {@code PhysicsValues.invertedGravity} flag on every
|
* Tick-driven service that toggles {@code PhysicsValues.invertedGravity} on every entity currently
|
||||||
* entity present in an enabled {@link GravityFlipRegion}. Mutations are queued via
|
* inside an enabled region, wakes up players/NPCs so the new settings take effect, and drives the
|
||||||
* {@code CommandBuffer.replaceComponent(...)} inside a {@code Store.forEachEntityParallel(...)}
|
* fall-damage guard on entry/exit transitions.
|
||||||
* lambda — the ECS engine commits them on the main thread after the parallel pass.
|
|
||||||
*
|
|
||||||
* <p>Phase 03-02: wake-ups per-entity :
|
|
||||||
* <ul>
|
|
||||||
* <li>Players: {@code MovementManager.setDefaultSettings + applyDefaultSettings + update(packetHandler)}</li>
|
|
||||||
* <li>NPCs: {@code Role.getActiveMotionController().updatePhysicsValues(PhysicsValues)}</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>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}.
|
|
||||||
*
|
|
||||||
* <p>Phase 03-04:
|
|
||||||
* <ul>
|
|
||||||
* <li>Per-region tuning consumed : {@code AffectPlayers} / {@code AffectNpcs} / {@code AffectItems}
|
|
||||||
* filter BEFORE any wake / cmdBuf flip ; {@code VerticalForce} replaces the hardcoded 0.1.</li>
|
|
||||||
* <li>{@link FallDamageGuard} notified on entry (pass 1) and exit (pass 2) with the
|
|
||||||
* first-matched region to drive {@link FallDamageSuppressorSystem}.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p><b>Hotpath note:</b> 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.
|
|
||||||
*
|
|
||||||
* <p><b>Multi-region precedence :</b> for an entity simultaneously inside N regions, the FIRST
|
|
||||||
* region encountered in the iteration of {@code snapshot.byRegion().keySet()} (Java insertion
|
|
||||||
* order via the underlying {@code LinkedHashMap}) drives the config values read this tick
|
|
||||||
* (VerticalForce, AffectXxx, FallDamage, GracePeriodMs). Rule applies consistently to
|
|
||||||
* {@link FallDamageGuard#markInRegion} (same first-matched region) and {@link FallDamageGuard#markExit}
|
|
||||||
* (last-known first-matched region at the previous in-region tick).
|
|
||||||
*/
|
*/
|
||||||
public final class GravityApplier {
|
public final class GravityApplier {
|
||||||
// Lazy ComponentType holders — pattern identique à RegionRegistry.transform()
|
// Lazy ComponentType holders — avoids Hytale PluginBase static init during tests.
|
||||||
// (évite static-init Hytale PluginBase pendant les tests).
|
|
||||||
private static volatile ComponentType<EntityStore, PhysicsValues> physicsType;
|
private static volatile ComponentType<EntityStore, PhysicsValues> physicsType;
|
||||||
private static volatile ComponentType<EntityStore, UUIDComponent> uuidType;
|
private static volatile ComponentType<EntityStore, UUIDComponent> uuidType;
|
||||||
private static volatile ComponentType<EntityStore, TransformComponent> transformType;
|
private static volatile ComponentType<EntityStore, TransformComponent> transformType;
|
||||||
@@ -145,7 +113,7 @@ public final class GravityApplier {
|
|||||||
return t;
|
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<UUID> previouslyInverted = ConcurrentHashMap.newKeySet();
|
private final Set<UUID> previouslyInverted = ConcurrentHashMap.newKeySet();
|
||||||
/** First-matched region per UUID at the previous tick — consulted in Pass 2 for markExit. */
|
/** First-matched region per UUID at the previous tick — consulted in Pass 2 for markExit. */
|
||||||
private final ConcurrentHashMap<UUID, GravityFlipRegion> lastKnownRegion = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<UUID, GravityFlipRegion> lastKnownRegion = new ConcurrentHashMap<>();
|
||||||
@@ -162,21 +130,17 @@ public final class GravityApplier {
|
|||||||
this.guard = guard == null ? new FallDamageGuard() : guard;
|
this.guard = guard == null ? new FallDamageGuard() : guard;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Builds a new PhysicsValues copying mass/drag from the source and setting invertedGravity. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
static PhysicsValues buildPhysicsValuesWithFlag(PhysicsValues source, boolean target) {
|
static PhysicsValues buildPhysicsValuesWithFlag(PhysicsValues source, boolean target) {
|
||||||
FlaggedDecision d = buildFlaggedDecision(source.getMass(), source.getDragCoefficient(), target);
|
FlaggedDecision d = buildFlaggedDecision(source.getMass(), source.getDragCoefficient(), target);
|
||||||
return new PhysicsValues(d.mass, d.drag, d.invertedGravity);
|
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) {
|
static FlaggedDecision buildFlaggedDecision(double mass, double drag, boolean target) {
|
||||||
return new FlaggedDecision(mass, drag, target);
|
return new FlaggedDecision(mass, drag, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Holder pure-data pour la décomposition testable de {@link #buildPhysicsValuesWithFlag}. */
|
|
||||||
static final class FlaggedDecision {
|
static final class FlaggedDecision {
|
||||||
final double mass;
|
final double mass;
|
||||||
final double drag;
|
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) {
|
public void apply(World world, RegionSnapshot snapshot) {
|
||||||
if (world == null || snapshot == null) return;
|
if (world == null || snapshot == null) return;
|
||||||
world.execute(() -> applyOnWorldThread(world, snapshot));
|
world.execute(() -> applyOnWorldThread(world, snapshot));
|
||||||
@@ -205,8 +169,9 @@ public final class GravityApplier {
|
|||||||
ComponentType<EntityStore, Player> PLT = playerType();
|
ComponentType<EntityStore, Player> PLT = playerType();
|
||||||
ComponentType<EntityStore, NPCEntity> NPCT = npcEntityType();
|
ComponentType<EntityStore, NPCEntity> NPCT = npcEntityType();
|
||||||
|
|
||||||
// PASS 1 — pour chaque entité avec PhysicsValues : si dans une région activée, queue le flip ON
|
// PASS 1 — flip ON for every entity with PhysicsValues that is inside an enabled region.
|
||||||
// via cmdBuf.replaceComponent ET wake-up MovementManager / MotionController.
|
// Pass 1 and Pass 2 cannot be fused: restore requires the complete currentlyInRegion set
|
||||||
|
// to diff against previouslyInverted.
|
||||||
Set<UUID> currentlyInRegion = ConcurrentHashMap.newKeySet();
|
Set<UUID> currentlyInRegion = ConcurrentHashMap.newKeySet();
|
||||||
store.forEachEntityParallel(PHYST, (index, chunk, cmdBuf) -> {
|
store.forEachEntityParallel(PHYST, (index, chunk, cmdBuf) -> {
|
||||||
TransformComponent t;
|
TransformComponent t;
|
||||||
@@ -222,7 +187,7 @@ public final class GravityApplier {
|
|||||||
com.hypixel.hytale.math.vector.Vector3d pos = t.getPosition();
|
com.hypixel.hytale.math.vector.Vector3d pos = t.getPosition();
|
||||||
double x = pos.x, y = pos.y, z = pos.z;
|
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;
|
GravityFlipRegion matchedRegion = null;
|
||||||
for (GravityFlipRegion r : enabledRegions) {
|
for (GravityFlipRegion r : enabledRegions) {
|
||||||
if (r.asBox().containsPosition(x, y, z)) { matchedRegion = r; break; }
|
if (r.asBox().containsPosition(x, y, z)) { matchedRegion = r; break; }
|
||||||
@@ -231,7 +196,6 @@ public final class GravityApplier {
|
|||||||
|
|
||||||
UUID u = uc.getUuid();
|
UUID u = uc.getUuid();
|
||||||
|
|
||||||
// --- Plan 03-04 : AffectXxx filters applied BEFORE wake ---
|
|
||||||
EntityKind kind = classify(chunk, index, MMT, PRT, PLT, NPCT);
|
EntityKind kind = classify(chunk, index, MMT, PRT, PLT, NPCT);
|
||||||
boolean allowed;
|
boolean allowed;
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
@@ -240,8 +204,7 @@ public final class GravityApplier {
|
|||||||
default: allowed = matchedRegion.isAffectItems(); break;
|
default: allowed = matchedRegion.isAffectItems(); break;
|
||||||
}
|
}
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
// Entité filtrée : ne PAS la compter dans currentlyInRegion, et
|
// Filtered entity behaves as if outside the region.
|
||||||
// ne PAS notifier le guard — le filtre se comporte comme hors-zone.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,19 +212,17 @@ public final class GravityApplier {
|
|||||||
lastKnownRegion.put(u, matchedRegion);
|
lastKnownRegion.put(u, matchedRegion);
|
||||||
guard.markInRegion(u, matchedRegion);
|
guard.markInRegion(u, matchedRegion);
|
||||||
|
|
||||||
// --- Flip ECS native (plan 03-01) ---
|
|
||||||
if (!v.isInvertedGravity()) {
|
if (!v.isInvertedGravity()) {
|
||||||
Ref<EntityStore> ref = chunk.getReferenceTo(index);
|
Ref<EntityStore> ref = chunk.getReferenceTo(index);
|
||||||
cmdBuf.replaceComponent(ref, PHYST,
|
cmdBuf.replaceComponent(ref, PHYST,
|
||||||
new PhysicsValues(v.getMass(), v.getDragCoefficient(), true));
|
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,
|
wakePlayerOrNpc(chunk, index, v, true, matchedRegion,
|
||||||
MMT, PRT, PLT, NPCT);
|
MMT, PRT, PLT, NPCT);
|
||||||
});
|
});
|
||||||
|
|
||||||
// PASS 2 — restore : pour chaque UUID dans previouslyInverted \ currentlyInRegion.
|
// PASS 2 — restore: for every UUID in previouslyInverted \ currentlyInRegion.
|
||||||
Set<UUID> toRestore = ConcurrentHashMap.newKeySet();
|
Set<UUID> toRestore = ConcurrentHashMap.newKeySet();
|
||||||
toRestore.addAll(previouslyInverted);
|
toRestore.addAll(previouslyInverted);
|
||||||
toRestore.removeAll(currentlyInRegion);
|
toRestore.removeAll(currentlyInRegion);
|
||||||
@@ -284,11 +245,8 @@ public final class GravityApplier {
|
|||||||
new PhysicsValues(v.getMass(), v.getDragCoefficient(), false));
|
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);
|
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);
|
GravityFlipRegion lastRegion = lastKnownRegion.remove(u);
|
||||||
if (lastRegion != null) {
|
if (lastRegion != null) {
|
||||||
guard.markExit(u, lastRegion, now);
|
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.clear();
|
||||||
previouslyInverted.addAll(currentlyInRegion);
|
previouslyInverted.addAll(currentlyInRegion);
|
||||||
} catch (Throwable th) {
|
} catch (Throwable th) {
|
||||||
@@ -306,7 +264,7 @@ public final class GravityApplier {
|
|||||||
|
|
||||||
private enum EntityKind { PLAYER, NPC, OTHER }
|
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<EntityStore> chunk, int index,
|
private EntityKind classify(ArchetypeChunk<EntityStore> chunk, int index,
|
||||||
ComponentType<EntityStore, MovementManager> MMT,
|
ComponentType<EntityStore, MovementManager> MMT,
|
||||||
ComponentType<EntityStore, PlayerRef> PRT,
|
ComponentType<EntityStore, PlayerRef> PRT,
|
||||||
@@ -328,15 +286,7 @@ public final class GravityApplier {
|
|||||||
return EntityKind.OTHER;
|
return EntityKind.OTHER;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Wakes up the entity so the new PhysicsValues take effect (player movement refresh or NPC controller update). */
|
||||||
* 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)
|
|
||||||
*
|
|
||||||
* <p>Plan 03-04 : le paramètre {@code matchedRegion} (non-null en entrée, null en sortie)
|
|
||||||
* fournit {@code VerticalForce} — remplace le hardcode 0.1 du plan 03-03.
|
|
||||||
*/
|
|
||||||
private void wakePlayerOrNpc(
|
private void wakePlayerOrNpc(
|
||||||
ArchetypeChunk<EntityStore> chunk, int index,
|
ArchetypeChunk<EntityStore> chunk, int index,
|
||||||
PhysicsValues sourceValues, boolean targetFlag,
|
PhysicsValues sourceValues, boolean targetFlag,
|
||||||
@@ -347,7 +297,7 @@ public final class GravityApplier {
|
|||||||
ComponentType<EntityStore, NPCEntity> NPCT) {
|
ComponentType<EntityStore, NPCEntity> NPCT) {
|
||||||
PhysicsValues targetValues = buildPhysicsValuesWithFlag(sourceValues, targetFlag);
|
PhysicsValues targetValues = buildPhysicsValuesWithFlag(sourceValues, targetFlag);
|
||||||
|
|
||||||
// --- Branche joueur ---
|
// Player branch.
|
||||||
MovementManager mm = null;
|
MovementManager mm = null;
|
||||||
try { mm = chunk.getComponent(index, MMT); } catch (Throwable ignored) {}
|
try { mm = chunk.getComponent(index, MMT); } catch (Throwable ignored) {}
|
||||||
if (mm != null) {
|
if (mm != null) {
|
||||||
@@ -363,11 +313,11 @@ public final class GravityApplier {
|
|||||||
} catch (Throwable th) {
|
} catch (Throwable th) {
|
||||||
errorHandler.accept(th);
|
errorHandler.accept(th);
|
||||||
}
|
}
|
||||||
return; // un joueur n'est pas un NPC
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Branche NPC ---
|
// NPC branch.
|
||||||
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) {
|
||||||
@@ -378,9 +328,7 @@ public final class GravityApplier {
|
|||||||
if (active != null) {
|
if (active != null) {
|
||||||
active.updatePhysicsValues(targetValues);
|
active.updatePhysicsValues(targetValues);
|
||||||
|
|
||||||
// Plan 03-04 : seed forceVelocity.y paramétré par VerticalForce (remplace
|
// Seed forceVelocity.y only on entry — on exit the native damping zeroes it.
|
||||||
// le hardcode 0.1 de Plan 03-03). Uniquement en entrée (targetFlag=true) —
|
|
||||||
// à la sortie le damping natif zéroe forceVelocity.
|
|
||||||
if (targetFlag && matchedRegion != null) {
|
if (targetFlag && matchedRegion != null) {
|
||||||
double vf = matchedRegion.getVerticalForce();
|
double vf = matchedRegion.getVerticalForce();
|
||||||
try {
|
try {
|
||||||
@@ -399,10 +347,10 @@ public final class GravityApplier {
|
|||||||
return;
|
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<UUID> previous, Set<UUID> current) {
|
public static DiffResult diff(Set<UUID> previous, Set<UUID> current) {
|
||||||
Set<UUID> toFlip = new HashSet<>(current);
|
Set<UUID> toFlip = new HashSet<>(current);
|
||||||
toFlip.removeAll(previous);
|
toFlip.removeAll(previous);
|
||||||
@@ -417,17 +365,14 @@ public final class GravityApplier {
|
|||||||
DiffResult(Set<UUID> f, Set<UUID> r) { this.toFlip = f; this.toRestore = r; }
|
DiffResult(Set<UUID> f, Set<UUID> 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<UUID> previouslyInvertedView() {
|
Set<UUID> previouslyInvertedView() {
|
||||||
return Collections.unmodifiableSet(previouslyInverted);
|
return Collections.unmodifiableSet(previouslyInverted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Test-only tracker override; never call from production code. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
void __updateTrackerForTest(Set<UUID> newState) {
|
void __updateTrackerForTest(Set<UUID> newState) {
|
||||||
previouslyInverted.clear();
|
previouslyInverted.clear();
|
||||||
previouslyInverted.addAll(newState);
|
previouslyInverted.addAll(newState);
|
||||||
|
|||||||
@@ -7,53 +7,7 @@ import com.hypixel.hytale.codec.validation.Validators;
|
|||||||
import com.hypixel.hytale.math.shape.Box;
|
import com.hypixel.hytale.math.shape.Box;
|
||||||
import com.hypixel.hytale.math.vector.Vector3d;
|
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 is stored
|
|
||||||
* on disk as up to 9 keys :
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code Name} — non-null string identifier</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 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>
|
|
||||||
* <li>{@code VisualColor} — <b>optional</b> (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).</li>
|
|
||||||
* <li>{@code VisualMode} — <b>optional</b>, default {@code "Outline"}. One of
|
|
||||||
* {@code "Outline"} / {@code "Faces"} / {@code "Both"} / {@code "None"}. Unknown value
|
|
||||||
* falls back to {@code "Outline"}.</li>
|
|
||||||
* <li>{@code VisualRefreshMs} — <b>optional</b>, default {@code 1000}. Emission period in ms
|
|
||||||
* for the debug shape (refresh cadence ; TTL = refreshMs * 1.2 to avoid flicker).</li>
|
|
||||||
* <li>{@code VisualOpacity} — <b>optional</b>, default {@code 0.5}. Cube opacity in
|
|
||||||
* {@code [0.0, 1.0]} (clamped in {@code RegionVisualizer}).</li>
|
|
||||||
* </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
|
|
||||||
* {@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).
|
|
||||||
*
|
|
||||||
* <p>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).
|
|
||||||
*/
|
|
||||||
public final class GravityFlipRegion {
|
public final class GravityFlipRegion {
|
||||||
|
|
||||||
public static final BuilderCodec<GravityFlipRegion> CODEC =
|
public static final BuilderCodec<GravityFlipRegion> CODEC =
|
||||||
@@ -66,7 +20,7 @@ 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) ---
|
// Optional tuning fields (no nonNull validator => absence preserves Java defaults).
|
||||||
.append(new KeyedCodec<>("FallDamage", Codec.BOOLEAN),
|
.append(new KeyedCodec<>("FallDamage", Codec.BOOLEAN),
|
||||||
(r, v) -> r.fallDamage = v, r -> r.fallDamage).add()
|
(r, v) -> r.fallDamage = v, r -> r.fallDamage).add()
|
||||||
.append(new KeyedCodec<>("GracePeriodMs", Codec.INTEGER),
|
.append(new KeyedCodec<>("GracePeriodMs", Codec.INTEGER),
|
||||||
@@ -79,7 +33,6 @@ public final class GravityFlipRegion {
|
|||||||
(r, v) -> r.affectNpcs = v, r -> r.affectNpcs).add()
|
(r, v) -> r.affectNpcs = v, r -> r.affectNpcs).add()
|
||||||
.append(new KeyedCodec<>("AffectItems", Codec.BOOLEAN),
|
.append(new KeyedCodec<>("AffectItems", Codec.BOOLEAN),
|
||||||
(r, v) -> r.affectItems = v, r -> r.affectItems).add()
|
(r, v) -> r.affectItems = v, r -> r.affectItems).add()
|
||||||
// --- Plan 03-05 : 4 optional visualization fields ---
|
|
||||||
.append(new KeyedCodec<>("VisualColor", Codec.STRING),
|
.append(new KeyedCodec<>("VisualColor", Codec.STRING),
|
||||||
(r, v) -> r.visualColor = v, r -> r.visualColor).add()
|
(r, v) -> r.visualColor = v, r -> r.visualColor).add()
|
||||||
.append(new KeyedCodec<>("VisualMode", Codec.STRING),
|
.append(new KeyedCodec<>("VisualMode", Codec.STRING),
|
||||||
@@ -88,7 +41,6 @@ public final class GravityFlipRegion {
|
|||||||
(r, v) -> r.visualRefreshMs = v, r -> r.visualRefreshMs).add()
|
(r, v) -> r.visualRefreshMs = v, r -> r.visualRefreshMs).add()
|
||||||
.append(new KeyedCodec<>("VisualOpacity", Codec.DOUBLE),
|
.append(new KeyedCodec<>("VisualOpacity", Codec.DOUBLE),
|
||||||
(r, v) -> r.visualOpacity = v, r -> r.visualOpacity).add()
|
(r, v) -> r.visualOpacity = v, r -> r.visualOpacity).add()
|
||||||
// --- Plan 03-06 : 2 optional particle-mode fields ---
|
|
||||||
.append(new KeyedCodec<>("VisualParticleId", Codec.STRING),
|
.append(new KeyedCodec<>("VisualParticleId", Codec.STRING),
|
||||||
(r, v) -> r.visualParticleId = v, r -> r.visualParticleId).add()
|
(r, v) -> r.visualParticleId = v, r -> r.visualParticleId).add()
|
||||||
.append(new KeyedCodec<>("VisualParticleDensity", Codec.DOUBLE),
|
.append(new KeyedCodec<>("VisualParticleDensity", Codec.DOUBLE),
|
||||||
@@ -100,7 +52,6 @@ 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;
|
boolean fallDamage = false;
|
||||||
int gracePeriodMs = 2500;
|
int gracePeriodMs = 2500;
|
||||||
double verticalForce = 0.1;
|
double verticalForce = 0.1;
|
||||||
@@ -108,15 +59,12 @@ public final class GravityFlipRegion {
|
|||||||
boolean affectNpcs = true;
|
boolean affectNpcs = true;
|
||||||
boolean affectItems = true;
|
boolean affectItems = true;
|
||||||
|
|
||||||
// Plan 03-05 : visualization fields — defaults applied when key absent in BSON.
|
|
||||||
String visualColor = "#00FFFF";
|
String visualColor = "#00FFFF";
|
||||||
String visualMode = "Outline";
|
String visualMode = "Outline";
|
||||||
int visualRefreshMs = 1000;
|
int visualRefreshMs = 1000;
|
||||||
double visualOpacity = 0.5;
|
double visualOpacity = 0.5;
|
||||||
|
|
||||||
// Plan 03-06 : particle-mode fields — default switched to Torch_Fire after UAT
|
// Torch_Fire chosen because Dust_Sparkles_Fine is effectively invisible in-world.
|
||||||
// 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.
|
|
||||||
String visualParticleId = "Torch_Fire";
|
String visualParticleId = "Torch_Fire";
|
||||||
double visualParticleDensity = 0.3;
|
double visualParticleDensity = 0.3;
|
||||||
|
|
||||||
@@ -138,8 +86,6 @@ 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 boolean isFallDamage() { return fallDamage; }
|
||||||
public int getGracePeriodMs() { return gracePeriodMs; }
|
public int getGracePeriodMs() { return gracePeriodMs; }
|
||||||
public double getVerticalForce() { return verticalForce; }
|
public double getVerticalForce() { return verticalForce; }
|
||||||
@@ -154,8 +100,6 @@ public final class GravityFlipRegion {
|
|||||||
public void setAffectNpcs(boolean v) { this.affectNpcs = v; }
|
public void setAffectNpcs(boolean v) { this.affectNpcs = v; }
|
||||||
public void setAffectItems(boolean v) { this.affectItems = v; }
|
public void setAffectItems(boolean v) { this.affectItems = v; }
|
||||||
|
|
||||||
// --- Plan 03-05 getters / setters ---
|
|
||||||
|
|
||||||
public String getVisualColor() { return visualColor; }
|
public String getVisualColor() { return visualColor; }
|
||||||
public String getVisualMode() { return visualMode; }
|
public String getVisualMode() { return visualMode; }
|
||||||
public int getVisualRefreshMs() { return visualRefreshMs; }
|
public int getVisualRefreshMs() { return visualRefreshMs; }
|
||||||
@@ -166,14 +110,12 @@ public final class GravityFlipRegion {
|
|||||||
public void setVisualRefreshMs(int v) { this.visualRefreshMs = v; }
|
public void setVisualRefreshMs(int v) { this.visualRefreshMs = v; }
|
||||||
public void setVisualOpacity(double v) { this.visualOpacity = v; }
|
public void setVisualOpacity(double v) { this.visualOpacity = v; }
|
||||||
|
|
||||||
// --- Plan 03-06 getters / setters ---
|
|
||||||
|
|
||||||
public String getVisualParticleId() { return visualParticleId; }
|
public String getVisualParticleId() { return visualParticleId; }
|
||||||
public double getVisualParticleDensity() { return visualParticleDensity; }
|
public double getVisualParticleDensity() { return visualParticleDensity; }
|
||||||
|
|
||||||
public void setVisualParticleId(String v) { this.visualParticleId = v; }
|
public void setVisualParticleId(String v) { this.visualParticleId = v; }
|
||||||
public void setVisualParticleDensity(double v) { this.visualParticleDensity = 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; }
|
public Box asBox() { return box; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,32 +22,13 @@ import java.util.concurrent.ConcurrentLinkedQueue;
|
|||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory index of {@link GravityFlipRegion}s with two layers of atomic publication:
|
* 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.
|
||||||
* <ol>
|
|
||||||
* <li>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.</li>
|
|
||||||
* <li>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)}.</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* <p><strong>Threading contract:</strong> 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.
|
|
||||||
*/
|
*/
|
||||||
public final class RegionRegistry {
|
public final class RegionRegistry {
|
||||||
|
|
||||||
/**
|
// Lazy init: TransformComponent.getComponentType() triggers Hytale PluginBase static init,
|
||||||
* Canonical ECS query handle: ComponentType IS-A Query, so passed directly to forEachEntityParallel.
|
// which fails under JUL unless the log manager system property is set — avoided in tests.
|
||||||
* 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)}.
|
|
||||||
*/
|
|
||||||
private static volatile ComponentType<EntityStore, TransformComponent> transformType;
|
private static volatile ComponentType<EntityStore, TransformComponent> transformType;
|
||||||
|
|
||||||
private static ComponentType<EntityStore, TransformComponent> transform() {
|
private static ComponentType<EntityStore, TransformComponent> transform() {
|
||||||
@@ -67,16 +48,10 @@ public final class RegionRegistry {
|
|||||||
private final GravityFlipConfig config;
|
private final GravityFlipConfig config;
|
||||||
private final Config<GravityFlipConfig> holder; // nullable in tests
|
private final Config<GravityFlipConfig> holder; // nullable in tests
|
||||||
|
|
||||||
/** Immutable snapshot of the region list, swapped atomically on every mutation. */
|
|
||||||
private final AtomicReference<List<GravityFlipRegion>> regionsSnapshot;
|
private final AtomicReference<List<GravityFlipRegion>> regionsSnapshot;
|
||||||
|
|
||||||
private final Object mutationLock = new Object();
|
private final Object mutationLock = new Object();
|
||||||
/**
|
// Keyed by Object (not World) because Mockito cannot mock World under JDK 25 in tests.
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
private final ConcurrentHashMap<Object, AtomicReference<RegionSnapshot>> snapshots = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<Object, AtomicReference<RegionSnapshot>> snapshots = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public RegionRegistry(GravityFlipConfig cfg) {
|
public RegionRegistry(GravityFlipConfig cfg) {
|
||||||
@@ -89,14 +64,12 @@ public final class RegionRegistry {
|
|||||||
this.regionsSnapshot = new AtomicReference<>(List.copyOf(cfg.getRegions()));
|
this.regionsSnapshot = new AtomicReference<>(List.copyOf(cfg.getRegions()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Region list (atomic snapshot reads) ----------
|
/** Returns the current immutable region-list snapshot. */
|
||||||
|
|
||||||
/** Returns the current immutable region-list snapshot. Safe to call from any thread. */
|
|
||||||
public Collection<GravityFlipRegion> all() {
|
public Collection<GravityFlipRegion> all() {
|
||||||
return regionsSnapshot.get();
|
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<GravityFlipRegion> enabled() {
|
List<GravityFlipRegion> enabled() {
|
||||||
List<GravityFlipRegion> out = new ArrayList<>();
|
List<GravityFlipRegion> out = new ArrayList<>();
|
||||||
for (GravityFlipRegion r : regionsSnapshot.get()) {
|
for (GravityFlipRegion r : regionsSnapshot.get()) {
|
||||||
@@ -105,6 +78,7 @@ public final class RegionRegistry {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Adds a region; throws IllegalArgumentException if the name already exists. */
|
||||||
public void add(GravityFlipRegion r) {
|
public void add(GravityFlipRegion r) {
|
||||||
synchronized (mutationLock) {
|
synchronized (mutationLock) {
|
||||||
for (GravityFlipRegion x : regionsSnapshot.get()) {
|
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) {
|
public boolean remove(String name) {
|
||||||
synchronized (mutationLock) {
|
synchronized (mutationLock) {
|
||||||
boolean removed = config.getRegions().removeIf(x -> x.getName().equals(name));
|
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) {
|
public boolean setEnabled(String name, boolean enabled) {
|
||||||
synchronized (mutationLock) {
|
synchronized (mutationLock) {
|
||||||
for (GravityFlipRegion x : config.getRegions()) {
|
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) {
|
public void refreshFromConfig(GravityFlipConfig cfg) {
|
||||||
synchronized (mutationLock) {
|
synchronized (mutationLock) {
|
||||||
regionsSnapshot.set(List.copyOf(cfg.getRegions()));
|
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<Void> save() {
|
public CompletableFuture<Void> save() {
|
||||||
return holder == null ? CompletableFuture.completedFuture(null) : holder.save();
|
return holder == null ? CompletableFuture.completedFuture(null) : holder.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Per-world snapshot (occupancy) ----------
|
/** Iterates the ECS for {@code world} and publishes a fresh per-region occupancy snapshot. */
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* <p>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).
|
|
||||||
*/
|
|
||||||
public void refreshFor(World world) {
|
public void refreshFor(World world) {
|
||||||
List<GravityFlipRegion> enabled = enabled();
|
List<GravityFlipRegion> enabled = enabled();
|
||||||
Map<GravityFlipRegion, Collection<Ref<EntityStore>>> byRegion = new ConcurrentHashMap<>();
|
Map<GravityFlipRegion, Collection<Ref<EntityStore>>> byRegion = new ConcurrentHashMap<>();
|
||||||
@@ -168,25 +135,20 @@ public final class RegionRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (enabled.isEmpty()) {
|
if (enabled.isEmpty()) {
|
||||||
// Aucun travail ECS → publication directe depuis le thread appelant.
|
|
||||||
publishSnapshot(world, snapshotOf(world, byRegion));
|
publishSnapshot(world, snapshotOf(world, byRegion));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// THREADING (fix WorldThread assert 2026-04-23) : `Store.forEachEntityParallel` exige la
|
// Store.forEachEntityParallel requires the WorldThread (assertThread), so dispatch via
|
||||||
// WorldThread. On dispatche scan + publication via `world.execute(Runnable)` pour satisfaire
|
// world.execute(Runnable). Publication becomes asynchronous (<= 1 tick of lag) — tolerable
|
||||||
// `assertThread`. Conséquence : la publication devient asynchrone (1 tick décalé max) côté
|
// because RegionTickLoop runs @100ms, so snapshot freshness stays <= 100ms in the worst case.
|
||||||
// consumers de `currentSnapshot(world)` — tolérable car le RegionTickLoop tourne @100ms, donc
|
|
||||||
// la fraîcheur du snapshot reste ≤ 100ms dans le pire cas.
|
|
||||||
world.execute(() -> {
|
world.execute(() -> {
|
||||||
try {
|
try {
|
||||||
Store<EntityStore> store = world.getEntityStore().getStore();
|
Store<EntityStore> store = world.getEntityStore().getStore();
|
||||||
ComponentType<EntityStore, TransformComponent> TRANSFORM = transform();
|
ComponentType<EntityStore, TransformComponent> 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) -> {
|
store.forEachEntityParallel(TRANSFORM, (index, chunk, cmdBuf) -> {
|
||||||
TransformComponent t = chunk.getComponent(index, TRANSFORM);
|
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();
|
com.hypixel.hytale.math.vector.Vector3d pos = t.getPosition();
|
||||||
// Copy to locals — getPosition() returns a backing field; never mutated here.
|
// Copy to locals — getPosition() returns a backing field; never mutated here.
|
||||||
double x = pos.x, y = pos.y, z = pos.z;
|
double x = pos.x, y = pos.y, z = pos.z;
|
||||||
@@ -200,35 +162,29 @@ public final class RegionRegistry {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (Throwable th) {
|
} catch (Throwable th) {
|
||||||
// Swallow — publish whatever we collected (possibly empty). The tick loop's
|
// Swallow — keeps the scheduler alive across transient ECS-state errors
|
||||||
// errorHandler already routes uncaught throwables; this catch keeps the
|
// (e.g., world being torn down). Publish whatever we collected so far.
|
||||||
// scheduler alive across transient ECS-state errors (e.g., world being torn down).
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publication intra-Runnable : garantit que la table byRegion est complète quand
|
|
||||||
// on la fige dans snapshotOf(...).
|
|
||||||
publishSnapshot(world, snapshotOf(world, byRegion));
|
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) {
|
public RegionSnapshot currentSnapshot(World world) {
|
||||||
if (world == null) return null;
|
if (world == null) return null;
|
||||||
AtomicReference<RegionSnapshot> ref = snapshots.get(world);
|
AtomicReference<RegionSnapshot> ref = snapshots.get(world);
|
||||||
return ref == null ? null : ref.get();
|
return ref == null ? null : ref.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@link #refreshFor} helper: publishes an already-built snapshot for the given world. */
|
|
||||||
void publishSnapshot(World world, RegionSnapshot snap) {
|
void publishSnapshot(World world, RegionSnapshot snap) {
|
||||||
publishSnapshotByKey(world, snap);
|
publishSnapshotByKey(world, snap);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test hook: publish a snapshot keyed by an arbitrary object reference. */
|
|
||||||
void publishSnapshotByKey(Object key, RegionSnapshot snap) {
|
void publishSnapshotByKey(Object key, RegionSnapshot snap) {
|
||||||
snapshots.computeIfAbsent(key, k -> new AtomicReference<>()).set(snap);
|
snapshots.computeIfAbsent(key, k -> new AtomicReference<>()).set(snap);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test hook: read a snapshot keyed by an arbitrary object reference. */
|
|
||||||
RegionSnapshot currentSnapshotByKey(Object key) {
|
RegionSnapshot currentSnapshotByKey(Object key) {
|
||||||
AtomicReference<RegionSnapshot> ref = snapshots.get(key);
|
AtomicReference<RegionSnapshot> ref = snapshots.get(key);
|
||||||
return ref == null ? null : ref.get();
|
return ref == null ? null : ref.get();
|
||||||
@@ -238,7 +194,7 @@ public final class RegionRegistry {
|
|||||||
Map<GravityFlipRegion, Collection<Ref<EntityStore>>> byRegion) {
|
Map<GravityFlipRegion, Collection<Ref<EntityStore>>> byRegion) {
|
||||||
long tick = 0L;
|
long tick = 0L;
|
||||||
try { tick = w.getTick(); } catch (Throwable ignored) {}
|
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<GravityFlipRegion, Collection<Ref<EntityStore>>> frozen =
|
final Map<GravityFlipRegion, Collection<Ref<EntityStore>>> frozen =
|
||||||
Collections.unmodifiableMap(byRegion);
|
Collections.unmodifiableMap(byRegion);
|
||||||
return new RegionSnapshot() {
|
return new RegionSnapshot() {
|
||||||
|
|||||||
@@ -7,16 +7,7 @@ import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/** Immutable snapshot of entity occupancy across all enabled regions for one {@link World}. */
|
||||||
* Immutable snapshot of entity occupancy across all enabled regions for one {@link World}.
|
|
||||||
*
|
|
||||||
* <p>Published by {@code RegionRegistry.refreshFor(world)} via an {@code AtomicReference}
|
|
||||||
* so off-thread consumers (Phase 3 physics) can read a stable snapshot without locking.
|
|
||||||
*
|
|
||||||
* <p><strong>Contract for consumers (Phase 3):</strong> {@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.
|
|
||||||
*/
|
|
||||||
public interface RegionSnapshot {
|
public interface RegionSnapshot {
|
||||||
|
|
||||||
/** Read-only map: enabled region -> entity refs currently inside its AABB. */
|
/** Read-only map: enabled region -> entity refs currently inside its AABB. */
|
||||||
|
|||||||
@@ -13,17 +13,8 @@ import java.util.function.Consumer;
|
|||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dedicated single-thread daemon scheduler that polls for entity-in-region detection at 100ms.
|
* Dedicated single-thread daemon scheduler that polls for entity-in-region detection at 100ms
|
||||||
*
|
* and drives gravity application + visualization.
|
||||||
* <p><strong>Threading contract:</strong> 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.
|
|
||||||
*
|
|
||||||
* <p><strong>Lifecycle:</strong> {@link #stop()} uses the
|
|
||||||
* {@code shutdown -> awaitTermination(5s) -> shutdownNow} idiom (VotePipe / MythWorld precedent) so a
|
|
||||||
* plugin reload never leaks the scheduler thread.
|
|
||||||
*/
|
*/
|
||||||
public final class RegionTickLoop {
|
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) {
|
public void start(World world) {
|
||||||
startWithDelay(INITIAL_DELAY_MS, 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) {
|
public void startWithDelay(long initialDelayMs, World world) {
|
||||||
startWithDelay(initialDelayMs, () -> world);
|
startWithDelay(initialDelayMs, () -> world);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Starts with a custom initial delay and a supplier that resolves the World lazily each tick. */
|
||||||
* Start with a custom initial delay and a {@link Supplier} of the {@link World} to tick.
|
|
||||||
*
|
|
||||||
* <p>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.
|
|
||||||
*/
|
|
||||||
public void startWithDelay(long initialDelayMs, Supplier<World> worldSupplier) {
|
public void startWithDelay(long initialDelayMs, Supplier<World> worldSupplier) {
|
||||||
Runnable tick = () -> {
|
Runnable tick = () -> {
|
||||||
World w = worldSupplier.get();
|
World w = worldSupplier.get();
|
||||||
@@ -90,7 +75,7 @@ public final class RegionTickLoop {
|
|||||||
scheduleGuarded(initialDelayMs, tick);
|
scheduleGuarded(initialDelayMs, tick);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test-friendly overload: schedule an arbitrary runnable. */
|
/** Test-friendly overload: schedules an arbitrary runnable. */
|
||||||
public void startWithDelay(long initialDelayMs, Runnable tick) {
|
public void startWithDelay(long initialDelayMs, Runnable tick) {
|
||||||
scheduleGuarded(initialDelayMs, tick);
|
scheduleGuarded(initialDelayMs, tick);
|
||||||
}
|
}
|
||||||
@@ -104,6 +89,7 @@ public final class RegionTickLoop {
|
|||||||
guarded, initialDelayMs, PERIOD_MS, TimeUnit.MILLISECONDS);
|
guarded, initialDelayMs, PERIOD_MS, TimeUnit.MILLISECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stops the scheduler with the shutdown -> awaitTermination(5s) -> shutdownNow idiom. */
|
||||||
public void stop() {
|
public void stop() {
|
||||||
scheduler.shutdown();
|
scheduler.shutdown();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,40 +7,19 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plan 03-06 Task 2 — helper pur qui génère la liste des points d'émission de
|
* Generates evenly-distributed emission points along the 12 edges of an AABB, with the 8 corners
|
||||||
* particules le long des 12 arêtes d'une {@link Box} AABB, sans aucune
|
* included exactly once. Pure helper with no World dependency (JVM-testable).
|
||||||
* dépendance sur {@code World} (testable en JVM standard).
|
|
||||||
*
|
|
||||||
* <p><b>Contract:</b> {@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.
|
|
||||||
*
|
|
||||||
* <p><b>Density:</b> 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).
|
|
||||||
*
|
|
||||||
* <p><b>Dédup des coins :</b> 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.
|
|
||||||
*/
|
*/
|
||||||
public final class ParticleEdgeEmitter {
|
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;
|
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;
|
public static final double MAX_DENSITY = 10.0;
|
||||||
|
|
||||||
private ParticleEdgeEmitter() {}
|
private ParticleEdgeEmitter() {}
|
||||||
|
|
||||||
/**
|
/** Returns edge-points for the given box (8 deduped corners + interior points), density clamped to [0.1, 10]. */
|
||||||
* @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.
|
|
||||||
*/
|
|
||||||
public static List<Vector3d> edgePoints(Box box, double density) {
|
public static List<Vector3d> edgePoints(Box box, double density) {
|
||||||
double d = clamp(density, MIN_DENSITY, MAX_DENSITY);
|
double d = clamp(density, MIN_DENSITY, MAX_DENSITY);
|
||||||
|
|
||||||
@@ -49,7 +28,7 @@ public final class ParticleEdgeEmitter {
|
|||||||
|
|
||||||
List<Vector3d> out = new ArrayList<>();
|
List<Vector3d> out = new ArrayList<>();
|
||||||
|
|
||||||
// 1) Émettre explicitement les 8 coins (dédupliqués par construction).
|
// 1) Emit the 8 corners once.
|
||||||
double[][] corners = new double[][] {
|
double[][] corners = new double[][] {
|
||||||
{x0, y0, z0}, {x1, y0, z0}, {x0, y0, z1}, {x1, y0, z1},
|
{x0, y0, z0}, {x1, y0, z0}, {x0, y0, z1}, {x1, y0, z1},
|
||||||
{x0, y1, z0}, {x1, y1, z0}, {x0, y1, z1}, {x1, y1, 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]));
|
out.add(new Vector3d(c[0], c[1], c[2]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Pour chaque arête, émettre les points INTÉRIEURS (sans endpoints).
|
// 2) Emit interior points (without endpoints) for each of the 12 edges.
|
||||||
// 4 arêtes bas (Y=y0) : varient sur X ou Z.
|
|
||||||
addInteriorLineX(out, x0, x1, y0, z0, d);
|
addInteriorLineX(out, x0, x1, y0, z0, d);
|
||||||
addInteriorLineX(out, x0, x1, y0, z1, d);
|
addInteriorLineX(out, x0, x1, y0, z1, d);
|
||||||
addInteriorLineZ(out, x0, y0, z0, z1, d);
|
addInteriorLineZ(out, x0, y0, z0, z1, d);
|
||||||
addInteriorLineZ(out, x1, 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, z0, d);
|
||||||
addInteriorLineX(out, x0, x1, y1, z1, d);
|
addInteriorLineX(out, x0, x1, y1, z1, d);
|
||||||
addInteriorLineZ(out, x0, y1, z0, z1, d);
|
addInteriorLineZ(out, x0, y1, z0, z1, d);
|
||||||
addInteriorLineZ(out, x1, 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, x0, y0, y1, z0, d);
|
||||||
addInteriorLineY(out, x1, y0, y1, z0, d);
|
addInteriorLineY(out, x1, y0, y1, z0, d);
|
||||||
addInteriorLineY(out, x0, y0, y1, z1, d);
|
addInteriorLineY(out, x0, y0, y1, z1, d);
|
||||||
@@ -78,12 +54,10 @@ public final class ParticleEdgeEmitter {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Points intérieurs (sans endpoints) d'une arête parallèle à X. */
|
|
||||||
private static void addInteriorLineX(List<Vector3d> out,
|
private static void addInteriorLineX(List<Vector3d> out,
|
||||||
double xMin, double xMax,
|
double xMin, double xMax,
|
||||||
double y, double z, double density) {
|
double y, double z, double density) {
|
||||||
int n = pointCount(xMax - xMin, density);
|
int n = pointCount(xMax - xMin, density);
|
||||||
// n = total points incl. endpoints ; intérieurs = n-2.
|
|
||||||
for (int i = 1; i < n - 1; i++) {
|
for (int i = 1; i < n - 1; i++) {
|
||||||
double t = (double) i / (double) (n - 1);
|
double t = (double) i / (double) (n - 1);
|
||||||
out.add(new Vector3d(xMin + t * (xMax - xMin), y, z));
|
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) {
|
static int pointCount(double length, double density) {
|
||||||
double l = Math.max(0.0, length);
|
double l = Math.max(0.0, length);
|
||||||
int n = (int) Math.ceil(l * density);
|
int n = (int) Math.ceil(l * density);
|
||||||
|
|||||||
@@ -24,71 +24,31 @@ import java.util.function.Consumer;
|
|||||||
import java.util.function.LongSupplier;
|
import java.util.function.LongSupplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plan 03-05 + 03-06 : service qui émet soit des cubes {@link DebugUtils} (modes
|
* Emits either DebugUtils cubes (Outline/Faces/Both modes) or edge particles (Particles mode)
|
||||||
* Outline/Faces/Both) soit des particules le long des 12 arêtes de l'AABB
|
* to materialise each gravity-flip region on clients. Throttled per-region via VisualRefreshMs.
|
||||||
* (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}.
|
|
||||||
*
|
|
||||||
* <p><b>Mapping VisualMode → render path</b> :
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code "Outline"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NO_SOLID}.</li>
|
|
||||||
* <li>{@code "Faces"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NO_WIREFRAME}.</li>
|
|
||||||
* <li>{@code "Both"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NONE}.</li>
|
|
||||||
* <li>{@code "Particles"} → particules le long des 12 arêtes AABB (Plan 03-06).</li>
|
|
||||||
* <li>{@code "None"} → skip (aucune émission).</li>
|
|
||||||
* <li>autre / null → fallback {@code "Outline"}.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p><b>Particles render path (03-06)</b> : on n'utilise pas
|
|
||||||
* {@code ParticleUtil.spawnParticleEffect} directement car sa signature exige un
|
|
||||||
* {@code ComponentAccessor<EntityStore>} 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.
|
|
||||||
*
|
|
||||||
* <p><b>Runtime id validation</b> : 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}.
|
|
||||||
*
|
|
||||||
* <p><b>Threading</b> : {@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}).
|
|
||||||
*
|
|
||||||
* <p><b>Anti-DoS</b> : {@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]}.
|
|
||||||
*/
|
*/
|
||||||
public final class RegionVisualizer {
|
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;
|
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";
|
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
|
@FunctionalInterface
|
||||||
public interface DebugEmitter {
|
public interface DebugEmitter {
|
||||||
void emit(World world, DebugShape shape, Matrix4d matrix,
|
void emit(World world, DebugShape shape, Matrix4d matrix,
|
||||||
Vector3f color, float opacity, float time, int flags);
|
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
|
@FunctionalInterface
|
||||||
public interface ParticleEmitter {
|
public interface ParticleEmitter {
|
||||||
void emit(World world, String id, Vector3d pos);
|
void emit(World world, String id, Vector3d pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Testability: executor injectable (prod = {@code world::execute}). */
|
/** Testability: injectable world executor (prod = {@code world::execute}). */
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface WorldExecutor {
|
public interface WorldExecutor {
|
||||||
void execute(World world, Runnable r);
|
void execute(World world, Runnable r);
|
||||||
@@ -98,18 +58,16 @@ public final class RegionVisualizer {
|
|||||||
(world, shape, matrix, color, opacity, time, flags) ->
|
(world, shape, matrix, color, opacity, time, flags) ->
|
||||||
DebugUtils.add(world, shape, matrix, color, opacity, time, flags);
|
DebugUtils.add(world, shape, matrix, color, opacity, time, flags);
|
||||||
|
|
||||||
/**
|
// Direct SpawnParticleSystem broadcast to world.getPlayerRefs() — avoids needing a
|
||||||
* Prod particle emitter : broadcast direct de {@link SpawnParticleSystem} à
|
// ComponentAccessor inside a world.execute(...) lambda.
|
||||||
* {@code world.getPlayerRefs()} (sans ComponentAccessor — voir javadoc de classe).
|
|
||||||
*/
|
|
||||||
private static final ParticleEmitter DEFAULT_PARTICLE_EMITTER =
|
private static final ParticleEmitter DEFAULT_PARTICLE_EMITTER =
|
||||||
(world, id, pos) -> {
|
(world, id, pos) -> {
|
||||||
SpawnParticleSystem packet = new SpawnParticleSystem(
|
SpawnParticleSystem packet = new SpawnParticleSystem(
|
||||||
id,
|
id,
|
||||||
new Position(pos.x, pos.y, pos.z),
|
new Position(pos.x, pos.y, pos.z),
|
||||||
null, // no rotation
|
null,
|
||||||
1.0f, // default scale
|
1.0f,
|
||||||
null); // no color override
|
null);
|
||||||
for (PlayerRef playerRef : world.getPlayerRefs()) {
|
for (PlayerRef playerRef : world.getPlayerRefs()) {
|
||||||
playerRef.getPacketHandler().writeNoCache(packet);
|
playerRef.getPacketHandler().writeNoCache(packet);
|
||||||
}
|
}
|
||||||
@@ -124,7 +82,7 @@ public final class RegionVisualizer {
|
|||||||
private final WorldExecutor executor;
|
private final WorldExecutor executor;
|
||||||
private final LongSupplier clock;
|
private final LongSupplier clock;
|
||||||
private final Map<String, Long> lastEmitMs = new ConcurrentHashMap<>();
|
private final Map<String, Long> 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<String, Boolean> warnedInvalidIds = ConcurrentHashMap.newKeySet();
|
private final KeySetView<String, Boolean> warnedInvalidIds = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
public RegionVisualizer(Consumer<Throwable> errorHandler) {
|
public RegionVisualizer(Consumer<Throwable> errorHandler) {
|
||||||
@@ -132,7 +90,6 @@ public final class RegionVisualizer {
|
|||||||
DEFAULT_EXECUTOR, System::currentTimeMillis);
|
DEFAULT_EXECUTOR, System::currentTimeMillis);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Package-private ctor pour tests (emitter DebugUtils + executor + clock injectés). */
|
|
||||||
RegionVisualizer(Consumer<Throwable> errorHandler,
|
RegionVisualizer(Consumer<Throwable> errorHandler,
|
||||||
DebugEmitter emitter,
|
DebugEmitter emitter,
|
||||||
WorldExecutor executor,
|
WorldExecutor executor,
|
||||||
@@ -140,7 +97,6 @@ public final class RegionVisualizer {
|
|||||||
this(errorHandler, emitter, DEFAULT_PARTICLE_EMITTER, executor, clock);
|
this(errorHandler, emitter, DEFAULT_PARTICLE_EMITTER, executor, clock);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Package-private ctor pour tests (tous les emitters + executor + clock injectés). */
|
|
||||||
RegionVisualizer(Consumer<Throwable> errorHandler,
|
RegionVisualizer(Consumer<Throwable> errorHandler,
|
||||||
DebugEmitter emitter,
|
DebugEmitter emitter,
|
||||||
ParticleEmitter particleEmitter,
|
ParticleEmitter particleEmitter,
|
||||||
@@ -153,11 +109,7 @@ public final class RegionVisualizer {
|
|||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Emits debug visualisation for each eligible region in the snapshot; never throws. */
|
||||||
* É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).
|
|
||||||
*/
|
|
||||||
public void visualize(World world, RegionSnapshot snapshot) {
|
public void visualize(World world, RegionSnapshot snapshot) {
|
||||||
if (snapshot == null) return;
|
if (snapshot == null) return;
|
||||||
try {
|
try {
|
||||||
@@ -193,6 +145,7 @@ public final class RegionVisualizer {
|
|||||||
int flags = flagsForMode(mode);
|
int flags = flagsForMode(mode);
|
||||||
Vector3f color = parseColor(r.getVisualColor());
|
Vector3f color = parseColor(r.getVisualColor());
|
||||||
Matrix4d matrix = matrixFromBox(r.getBox());
|
Matrix4d matrix = matrixFromBox(r.getBox());
|
||||||
|
// TTL = refreshMs * 1.2 to avoid flicker between emissions.
|
||||||
float ttlSeconds = refreshMs * 1.2f / 1000f;
|
float ttlSeconds = refreshMs * 1.2f / 1000f;
|
||||||
float opacity = (float) clamp(r.getVisualOpacity(), 0.0, 1.0);
|
float opacity = (float) clamp(r.getVisualOpacity(), 0.0, 1.0);
|
||||||
|
|
||||||
@@ -229,13 +182,7 @@ public final class RegionVisualizer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Validates {@code VisualParticleId} against the asset map; warns once and falls back to the default id on unknown. */
|
||||||
* 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é).
|
|
||||||
*/
|
|
||||||
String resolveParticleId(String requested) {
|
String resolveParticleId(String requested) {
|
||||||
if (requested == null || requested.isEmpty()) {
|
if (requested == null || requested.isEmpty()) {
|
||||||
return DEFAULT_PARTICLE_ID;
|
return DEFAULT_PARTICLE_ID;
|
||||||
@@ -251,12 +198,12 @@ public final class RegionVisualizer {
|
|||||||
}
|
}
|
||||||
return DEFAULT_PARTICLE_ID;
|
return DEFAULT_PARTICLE_ID;
|
||||||
} catch (Throwable th) {
|
} catch (Throwable th) {
|
||||||
// AssetMap indisponible (tests hors serveur) — fail-open.
|
// AssetMap unavailable (tests outside server runtime) — fail-open.
|
||||||
return requested;
|
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) {
|
public void clearAll(World world) {
|
||||||
if (world == null) return;
|
if (world == null) return;
|
||||||
try {
|
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) {
|
static Vector3f parseColor(String hex) {
|
||||||
if (hex == null || hex.length() != 7 || hex.charAt(0) != '#') {
|
if (hex == null || hex.length() != 7 || hex.charAt(0) != '#') {
|
||||||
return new Vector3f(DebugUtils.COLOR_CYAN);
|
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) {
|
static String normalizeMode(String mode) {
|
||||||
if ("Outline".equals(mode) || "Faces".equals(mode) || "Both".equals(mode)
|
if ("Outline".equals(mode) || "Faces".equals(mode) || "Both".equals(mode)
|
||||||
|| "None".equals(mode) || "Particles".equals(mode)) {
|
|| "None".equals(mode) || "Particles".equals(mode)) {
|
||||||
@@ -298,19 +245,19 @@ public final class RegionVisualizer {
|
|||||||
return "Outline";
|
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) {
|
static int flagsForMode(String mode) {
|
||||||
switch (normalizeMode(mode)) {
|
switch (normalizeMode(mode)) {
|
||||||
case "Faces": return DebugUtils.FLAG_NO_WIREFRAME;
|
case "Faces": return DebugUtils.FLAG_NO_WIREFRAME;
|
||||||
case "Both": return DebugUtils.FLAG_NONE;
|
case "Both": return DebugUtils.FLAG_NONE;
|
||||||
case "None": return DebugUtils.FLAG_NONE; // sentinel — caller skip avant.
|
case "None": return DebugUtils.FLAG_NONE;
|
||||||
case "Particles": return DebugUtils.FLAG_NONE; // unused — particles ne passent pas par DebugUtils.
|
case "Particles": return DebugUtils.FLAG_NONE;
|
||||||
case "Outline":
|
case "Outline":
|
||||||
default: return DebugUtils.FLAG_NO_SOLID;
|
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) {
|
static Matrix4d matrixFromBox(Box box) {
|
||||||
Vector3d min = box.min;
|
Vector3d min = box.min;
|
||||||
Vector3d max = box.max;
|
Vector3d max = box.max;
|
||||||
|
|||||||
@@ -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.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
/**
|
/** Round-trip tests for {@link GravityFlipConfig#CODEC} — non-null regions list, order preserved, list remains mutable. */
|
||||||
* Round-trip tests for {@link GravityFlipConfig#CODEC}. Critical guarantees:
|
|
||||||
* <ul>
|
|
||||||
* <li>Regions list is always non-null (empty by default).</li>
|
|
||||||
* <li>List elements survive encode -> decode in order.</li>
|
|
||||||
* <li>Decoded list is MUTABLE — Phase 4 command handlers depend on this.
|
|
||||||
* Guard against an accidental {@code List.of(arr)} regression.</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
class GravityFlipConfigCodecTest {
|
class GravityFlipConfigCodecTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -49,7 +41,6 @@ class GravityFlipConfigCodecTest {
|
|||||||
@Test
|
@Test
|
||||||
void roundTripOfEmptyListYieldsNonNullEmptyList() {
|
void roundTripOfEmptyListYieldsNonNullEmptyList() {
|
||||||
GravityFlipConfig src = new GravityFlipConfig();
|
GravityFlipConfig src = new GravityFlipConfig();
|
||||||
// src.regions is the default empty ArrayList.
|
|
||||||
GravityFlipConfig decoded = roundTrip(src);
|
GravityFlipConfig decoded = roundTrip(src);
|
||||||
|
|
||||||
assertNotNull(decoded.getRegions(), "decoded regions list must never be null");
|
assertNotNull(decoded.getRegions(), "decoded regions list must never be null");
|
||||||
@@ -63,8 +54,7 @@ class GravityFlipConfigCodecTest {
|
|||||||
|
|
||||||
GravityFlipConfig decoded = roundTrip(src);
|
GravityFlipConfig decoded = roundTrip(src);
|
||||||
|
|
||||||
// CRITICAL: must not throw UnsupportedOperationException.
|
// Must not throw UnsupportedOperationException — command handlers depend on a mutable list.
|
||||||
// Phase 4 commands (define / delete / toggle) all mutate this list.
|
|
||||||
assertDoesNotThrow(() -> decoded.getRegions().add(region("added", 2, 2, 2, 3, 3, 3)));
|
assertDoesNotThrow(() -> decoded.getRegions().add(region("added", 2, 2, 2, 3, 3, 3)));
|
||||||
assertDoesNotThrow(() -> decoded.getRegions().remove(0));
|
assertDoesNotThrow(() -> decoded.getRegions().remove(0));
|
||||||
assertTrue(decoded.getRegions() instanceof ArrayList,
|
assertTrue(decoded.getRegions() instanceof ArrayList,
|
||||||
@@ -86,7 +76,6 @@ class GravityFlipConfigCodecTest {
|
|||||||
return GravityFlipConfig.CODEC.decode(encoded, info);
|
return GravityFlipConfig.CODEC.decode(encoded, info);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suppress unused-import warning if List is not directly referenced in any final assertion.
|
|
||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
private static List<GravityFlipRegion> typeAnchor() { return null; }
|
private static List<GravityFlipRegion> typeAnchor() { return null; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ import java.util.UUID;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
/**
|
/** Pure-data tests for {@link FallDamageGuard} — entry, in-region, exit, grace-window, re-entry. */
|
||||||
* Pure-data tests for {@link FallDamageGuard} — no Hytale runtime dependency.
|
|
||||||
* Covers entry / in-region / exit / grace-window / re-entry / FallDamage=true override.
|
|
||||||
*/
|
|
||||||
class FallDamageGuardTest {
|
class FallDamageGuardTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -69,8 +66,7 @@ class FallDamageGuardTest {
|
|||||||
GravityFlipRegion region = region(false, 2500);
|
GravityFlipRegion region = region(false, 2500);
|
||||||
guard.markInRegion(uuid, region);
|
guard.markInRegion(uuid, region);
|
||||||
guard.markExit(uuid, region, 1000L);
|
guard.markExit(uuid, region, 1000L);
|
||||||
guard.markInRegion(uuid, region); // re-enter
|
guard.markInRegion(uuid, region);
|
||||||
// In-region again with FallDamage=false → immediate suppression, grace reset.
|
|
||||||
assertTrue(guard.shouldSuppressFallDamage(uuid, 1500L));
|
assertTrue(guard.shouldSuppressFallDamage(uuid, 1500L));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +78,6 @@ class FallDamageGuardTest {
|
|||||||
GravityFlipRegion allowed = region(true, 2500);
|
GravityFlipRegion allowed = region(true, 2500);
|
||||||
guard.markInRegion(uuid, suppressed);
|
guard.markInRegion(uuid, suppressed);
|
||||||
guard.markExit(uuid, suppressed, 1000L);
|
guard.markExit(uuid, suppressed, 1000L);
|
||||||
// New region has FallDamage=true → override immediately.
|
|
||||||
guard.markInRegion(uuid, allowed);
|
guard.markInRegion(uuid, allowed);
|
||||||
assertFalse(guard.shouldSuppressFallDamage(uuid, 1500L));
|
assertFalse(guard.shouldSuppressFallDamage(uuid, 1500L));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,7 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
/**
|
/** Pure-diff + tracker-semantics tests for {@link GravityApplier}. */
|
||||||
* Pure-diff + tracker-semantics tests for {@link GravityApplier}.
|
|
||||||
*
|
|
||||||
* <p>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}.
|
|
||||||
*/
|
|
||||||
class GravityApplierDiffTest {
|
class GravityApplierDiffTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -73,16 +66,12 @@ class GravityApplierDiffTest {
|
|||||||
applier.__updateTrackerForTest(new HashSet<>(Set.of(c)));
|
applier.__updateTrackerForTest(new HashSet<>(Set.of(c)));
|
||||||
assertEquals(Set.of(c), applier.previouslyInvertedView());
|
assertEquals(Set.of(c), applier.previouslyInvertedView());
|
||||||
|
|
||||||
// View is immutable.
|
|
||||||
Set<UUID> view = applier.previouslyInvertedView();
|
Set<UUID> view = applier.previouslyInvertedView();
|
||||||
assertThrows(UnsupportedOperationException.class, () -> view.add(UUID.randomUUID()));
|
assertThrows(UnsupportedOperationException.class, () -> view.add(UUID.randomUUID()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE (Rule 3 deviation — Plan 03-02) : les tests suivants ciblent la seam pure
|
// These tests target the pure seam buildFlaggedDecision because PhysicsValues static init
|
||||||
// `buildFlaggedDecision(double, double, boolean)` au lieu de `buildPhysicsValuesWithFlag`
|
// triggers ExceptionInInitializerError outside the Hytale runtime.
|
||||||
// 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.
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void buildFlaggedDecisionPreservesMassAndDrag() {
|
void buildFlaggedDecisionPreservesMassAndDrag() {
|
||||||
|
|||||||
@@ -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.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
/**
|
/** Round-trip tests for {@link GravityFlipRegion#CODEC} covering legacy + optional tuning + visualization fields. */
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* <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 {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -68,11 +60,9 @@ 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
|
@Test
|
||||||
void roundTripPreservesDefaultsWhenNewFieldsAbsent() {
|
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(
|
GravityFlipRegion src = new GravityFlipRegion(
|
||||||
"legacy",
|
"legacy",
|
||||||
new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)),
|
new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)),
|
||||||
@@ -80,7 +70,6 @@ class GravityFlipRegionCodecTest {
|
|||||||
|
|
||||||
GravityFlipRegion decoded = roundTrip(src);
|
GravityFlipRegion decoded = roundTrip(src);
|
||||||
|
|
||||||
// Tous les 6 nouveaux champs doivent exposer leurs defaults Java.
|
|
||||||
assertFalse(decoded.isFallDamage(), "default FallDamage=false");
|
assertFalse(decoded.isFallDamage(), "default FallDamage=false");
|
||||||
assertEquals(2500, decoded.getGracePeriodMs(), "default GracePeriodMs=2500");
|
assertEquals(2500, decoded.getGracePeriodMs(), "default GracePeriodMs=2500");
|
||||||
assertEquals(0.1, decoded.getVerticalForce(), 1e-9, "default VerticalForce=0.1");
|
assertEquals(0.1, decoded.getVerticalForce(), 1e-9, "default VerticalForce=0.1");
|
||||||
@@ -119,7 +108,6 @@ class GravityFlipRegionCodecTest {
|
|||||||
src.setAffectPlayers(false);
|
src.setAffectPlayers(false);
|
||||||
GravityFlipRegion decoded = roundTrip(src);
|
GravityFlipRegion decoded = roundTrip(src);
|
||||||
assertFalse(decoded.isAffectPlayers());
|
assertFalse(decoded.isAffectPlayers());
|
||||||
// Les autres filtres restent à true (non-clobber).
|
|
||||||
assertTrue(decoded.isAffectNpcs());
|
assertTrue(decoded.isAffectNpcs());
|
||||||
assertTrue(decoded.isAffectItems());
|
assertTrue(decoded.isAffectItems());
|
||||||
}
|
}
|
||||||
@@ -144,8 +132,6 @@ class GravityFlipRegionCodecTest {
|
|||||||
assertFalse(decoded.isAffectItems());
|
assertFalse(decoded.isAffectItems());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Plan 03-05 : 4 visualization fields ----------
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void roundTripPreservesVisualFields() {
|
void roundTripPreservesVisualFields() {
|
||||||
GravityFlipRegion src = baseRegion();
|
GravityFlipRegion src = baseRegion();
|
||||||
@@ -164,8 +150,6 @@ class GravityFlipRegionCodecTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void roundTripPreservesVisualDefaultsWhenFieldsAbsent() {
|
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(
|
GravityFlipRegion src = new GravityFlipRegion(
|
||||||
"legacy-viz",
|
"legacy-viz",
|
||||||
new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)),
|
new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)),
|
||||||
|
|||||||
@@ -18,14 +18,8 @@ import java.util.concurrent.atomic.AtomicReference;
|
|||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure-math + concurrency tests for {@link RegionRegistry}.
|
* Pure-math + concurrency tests for {@link RegionRegistry}. Snapshot tests use the package-private
|
||||||
*
|
* publishSnapshotByKey / currentSnapshotByKey hooks because Mockito cannot mock World under JDK 25.
|
||||||
* <p>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}.
|
|
||||||
*/
|
*/
|
||||||
class RegionRegistryTest {
|
class RegionRegistryTest {
|
||||||
|
|
||||||
@@ -114,11 +108,9 @@ class RegionRegistryTest {
|
|||||||
cfg.getRegions().add(new GravityFlipRegion("a", box(), true));
|
cfg.getRegions().add(new GravityFlipRegion("a", box(), true));
|
||||||
RegionRegistry reg = new RegionRegistry(cfg);
|
RegionRegistry reg = new RegionRegistry(cfg);
|
||||||
|
|
||||||
// Reader captures the immutable list before the swap.
|
|
||||||
var before = reg.enabled();
|
var before = reg.enabled();
|
||||||
assertEquals(1, before.size());
|
assertEquals(1, before.size());
|
||||||
|
|
||||||
// Mutator swaps via refreshFromConfig.
|
|
||||||
cfg.getRegions().add(new GravityFlipRegion("b", box(), true));
|
cfg.getRegions().add(new GravityFlipRegion("b", box(), true));
|
||||||
reg.refreshFromConfig(cfg);
|
reg.refreshFromConfig(cfg);
|
||||||
|
|
||||||
@@ -128,7 +120,6 @@ class RegionRegistryTest {
|
|||||||
assertEquals(1, before.size());
|
assertEquals(1, before.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal RegionSnapshot for the publish/read tests; world() is unused (returns null). */
|
|
||||||
private static final class StubSnapshot implements RegionSnapshot {
|
private static final class StubSnapshot implements RegionSnapshot {
|
||||||
private final Map<GravityFlipRegion, Collection<Ref<EntityStore>>> by;
|
private final Map<GravityFlipRegion, Collection<Ref<EntityStore>>> by;
|
||||||
private final long tick;
|
private final long tick;
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ import java.util.concurrent.atomic.AtomicReference;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
/**
|
/** Scheduler-timing tests for {@link RegionTickLoop} using the Runnable overload. */
|
||||||
* 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}.
|
|
||||||
*/
|
|
||||||
class RegionTickLoopTest {
|
class RegionTickLoopTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -32,7 +29,7 @@ class RegionTickLoopTest {
|
|||||||
RegionTickLoop loop = new RegionTickLoop(reg, t -> {});
|
RegionTickLoop loop = new RegionTickLoop(reg, t -> {});
|
||||||
AtomicInteger count = new AtomicInteger();
|
AtomicInteger count = new AtomicInteger();
|
||||||
loop.startWithDelay(0L, (Runnable) count::incrementAndGet);
|
loop.startWithDelay(0L, (Runnable) count::incrementAndGet);
|
||||||
Thread.sleep(300); // ~3 ticks
|
Thread.sleep(300);
|
||||||
long t0 = System.nanoTime();
|
long t0 = System.nanoTime();
|
||||||
loop.stop();
|
loop.stop();
|
||||||
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
|
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
|
||||||
@@ -52,7 +49,7 @@ class RegionTickLoopTest {
|
|||||||
int n = count.incrementAndGet();
|
int n = count.incrementAndGet();
|
||||||
if (n == 1) throw new RuntimeException("boom on first tick");
|
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();
|
loop.stop();
|
||||||
assertTrue(count.get() >= 3, "scheduler died after first throw; count=" + count.get());
|
assertTrue(count.get() >= 3, "scheduler died after first throw; count=" + count.get());
|
||||||
assertNotNull(capturedFirst.get(), "errorHandler was not invoked");
|
assertNotNull(capturedFirst.get(), "errorHandler was not invoked");
|
||||||
|
|||||||
@@ -10,23 +10,18 @@ import java.util.Set;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
/**
|
/** Tests for {@link ParticleEdgeEmitter} — 12-edge AABB emission, no diagonals, corner dedup, density clamping. */
|
||||||
* Tests for {@link ParticleEdgeEmitter}. Verifies the 12-edge AABB emission
|
|
||||||
* contract — no diagonals, no interior points, corner-dedup, density clamping.
|
|
||||||
*/
|
|
||||||
class ParticleEdgeEmitterTest {
|
class ParticleEdgeEmitterTest {
|
||||||
|
|
||||||
private static final double EPS = 1e-9;
|
private static final double EPS = 1e-9;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unitBox_density1_returnsExactly8Corners() {
|
void unitBox_density1_returnsExactly8Corners() {
|
||||||
// 1x1x1 box, density=1 → each edge of length 1 → ceil(1*1)=1 → max(2,1)=2
|
// 1x1x1 box, density=1 -> each edge of length 1 -> 2 points/edge (endpoints), deduped -> 8 corners.
|
||||||
// points per edge (endpoints only), dedup → 8 corners total.
|
|
||||||
Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1));
|
Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1));
|
||||||
List<Vector3d> pts = ParticleEdgeEmitter.edgePoints(b, 1.0);
|
List<Vector3d> pts = ParticleEdgeEmitter.edgePoints(b, 1.0);
|
||||||
assertEquals(8, pts.size(), "unit box at density=1 should emit exactly 8 corner points");
|
assertEquals(8, pts.size(), "unit box at density=1 should emit exactly 8 corner points");
|
||||||
|
|
||||||
// All 8 canonical corners present.
|
|
||||||
Set<String> expected = new HashSet<>();
|
Set<String> expected = new HashSet<>();
|
||||||
for (double x : new double[]{0, 1})
|
for (double x : new double[]{0, 1})
|
||||||
for (double y : new double[]{0, 1})
|
for (double y : new double[]{0, 1})
|
||||||
@@ -39,30 +34,26 @@ class ParticleEdgeEmitterTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void largeBox_density1_allPointsOnBoxSurfaceAndOnEdges() {
|
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));
|
Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(10, 10, 10));
|
||||||
List<Vector3d> pts = ParticleEdgeEmitter.edgePoints(b, 1.0);
|
List<Vector3d> pts = ParticleEdgeEmitter.edgePoints(b, 1.0);
|
||||||
|
|
||||||
// Edge membership: each point must lie on ≥ 2 of the 6 box planes
|
// Edge membership: each point must lie on at least 2 of the 6 box planes.
|
||||||
// (i.e. at least 2 of its coords are on {min, max} of their axis).
|
|
||||||
for (Vector3d p : pts) {
|
for (Vector3d p : pts) {
|
||||||
int onPlane = 0;
|
int onPlane = 0;
|
||||||
if (approx(p.x, 0) || approx(p.x, 10)) onPlane++;
|
if (approx(p.x, 0) || approx(p.x, 10)) onPlane++;
|
||||||
if (approx(p.y, 0) || approx(p.y, 10)) onPlane++;
|
if (approx(p.y, 0) || approx(p.y, 10)) onPlane++;
|
||||||
if (approx(p.z, 0) || approx(p.z, 10)) onPlane++;
|
if (approx(p.z, 0) || approx(p.z, 10)) onPlane++;
|
||||||
assertTrue(onPlane >= 2,
|
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<String> keys = new HashSet<>();
|
Set<String> keys = new HashSet<>();
|
||||||
for (Vector3d p : pts) {
|
for (Vector3d p : pts) {
|
||||||
assertTrue(keys.add(key(p.x, p.y, p.z)),
|
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.
|
// 10 points/edge (incl. endpoints) -> 8 interior/edge. Total = 8 corners + 12 * 8 = 104.
|
||||||
// Total = 8 corners + 12 edges * 8 interior = 8 + 96 = 104.
|
|
||||||
assertEquals(104, pts.size());
|
assertEquals(104, pts.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,12 +61,9 @@ class ParticleEdgeEmitterTest {
|
|||||||
void density_zeroClampedToMin_density1000ClampedToMax() {
|
void density_zeroClampedToMin_density1000ClampedToMax() {
|
||||||
Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1));
|
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<Vector3d> lo = ParticleEdgeEmitter.edgePoints(b, 0.0);
|
List<Vector3d> lo = ParticleEdgeEmitter.edgePoints(b, 0.0);
|
||||||
assertEquals(8, lo.size(), "density=0 should clamp to MIN_DENSITY, yielding 8 corners on unit box");
|
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<Vector3d> hi = ParticleEdgeEmitter.edgePoints(b, 1000.0);
|
List<Vector3d> hi = ParticleEdgeEmitter.edgePoints(b, 1000.0);
|
||||||
assertEquals(104, hi.size(), "density=1000 should clamp to MAX_DENSITY=10");
|
assertEquals(104, hi.size(), "density=1000 should clamp to MAX_DENSITY=10");
|
||||||
}
|
}
|
||||||
@@ -87,14 +75,11 @@ class ParticleEdgeEmitterTest {
|
|||||||
assertEquals(8, pts.size());
|
assertEquals(8, pts.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- helpers ----------
|
|
||||||
|
|
||||||
private static boolean approx(double a, double b) {
|
private static boolean approx(double a, double b) {
|
||||||
return Math.abs(a - b) < EPS;
|
return Math.abs(a - b) < EPS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String key(double x, double y, double z) {
|
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);
|
return String.format("%.6f,%.6f,%.6f", x, y, z);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,7 @@ import java.util.concurrent.atomic.AtomicLong;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
/**
|
/** Unit tests for {@link RegionVisualizer} with injected WorldExecutor/DebugEmitter so World is never touched. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
class RegionVisualizerTest {
|
class RegionVisualizerTest {
|
||||||
|
|
||||||
private static final class Call {
|
private static final class Call {
|
||||||
@@ -38,8 +34,6 @@ class RegionVisualizerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- parseColor ----------
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void parseColor_validHex() {
|
void parseColor_validHex() {
|
||||||
Vector3f c = RegionVisualizer.parseColor("#FF8800");
|
Vector3f c = RegionVisualizer.parseColor("#FF8800");
|
||||||
@@ -59,8 +53,6 @@ class RegionVisualizerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- normalizeMode / flagsForMode ----------
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void parseMode_unknown_fallsBackToOutline() {
|
void parseMode_unknown_fallsBackToOutline() {
|
||||||
assertEquals("Outline", RegionVisualizer.normalizeMode("Blah"));
|
assertEquals("Outline", RegionVisualizer.normalizeMode("Blah"));
|
||||||
@@ -76,7 +68,6 @@ class RegionVisualizerTest {
|
|||||||
assertEquals(DebugUtils.FLAG_NO_WIREFRAME, RegionVisualizer.flagsForMode("Faces"));
|
assertEquals(DebugUtils.FLAG_NO_WIREFRAME, RegionVisualizer.flagsForMode("Faces"));
|
||||||
assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Both"));
|
assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Both"));
|
||||||
assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Particles"));
|
assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Particles"));
|
||||||
// unknown → Outline
|
|
||||||
assertEquals(DebugUtils.FLAG_NO_SOLID, RegionVisualizer.flagsForMode("xxx"));
|
assertEquals(DebugUtils.FLAG_NO_SOLID, RegionVisualizer.flagsForMode("xxx"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +76,6 @@ class RegionVisualizerTest {
|
|||||||
assertEquals("Particles", RegionVisualizer.normalizeMode("Particles"));
|
assertEquals("Particles", RegionVisualizer.normalizeMode("Particles"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Particles branch ----------
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void visualize_particlesMode_callsParticleEmitterOncePerEdgePoint() {
|
void visualize_particlesMode_callsParticleEmitterOncePerEdgePoint() {
|
||||||
List<String> particleCalls = new ArrayList<>();
|
List<String> particleCalls = new ArrayList<>();
|
||||||
@@ -98,14 +87,12 @@ class RegionVisualizerTest {
|
|||||||
(w, r) -> r.run(),
|
(w, r) -> r.run(),
|
||||||
() -> 0L);
|
() -> 0L);
|
||||||
|
|
||||||
// unit box at density=1 → 8 corner points emitted.
|
|
||||||
GravityFlipRegion r = region("pz", "#FFFFFF", "Particles", 1000, 0.5);
|
GravityFlipRegion r = region("pz", "#FFFFFF", "Particles", 1000, 0.5);
|
||||||
r.setVisualParticleId("Dust_Sparkles_Fine");
|
r.setVisualParticleId("Dust_Sparkles_Fine");
|
||||||
r.setVisualParticleDensity(1.0);
|
r.setVisualParticleDensity(1.0);
|
||||||
|
|
||||||
viz.visualize(null, snapshotOf(r));
|
viz.visualize(null, snapshotOf(r));
|
||||||
assertEquals(8, particleCalls.size(), "unit box + density=1 → 8 corner emissions");
|
assertEquals(8, particleCalls.size(), "unit box + density=1 -> 8 corner emissions");
|
||||||
// All calls use the requested id (validation falls open in test context).
|
|
||||||
for (String call : particleCalls) {
|
for (String call : particleCalls) {
|
||||||
assertTrue(call.startsWith("Dust_Sparkles_Fine@"), "unexpected call: " + call);
|
assertTrue(call.startsWith("Dust_Sparkles_Fine@"), "unexpected call: " + call);
|
||||||
}
|
}
|
||||||
@@ -113,31 +100,26 @@ class RegionVisualizerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void particleDefaults_absentInConstructedRegion() {
|
void particleDefaults_absentInConstructedRegion() {
|
||||||
// Defaults must match the codec's documented defaults (03-06).
|
|
||||||
GravityFlipRegion r = new GravityFlipRegion("x",
|
GravityFlipRegion r = new GravityFlipRegion("x",
|
||||||
new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)), true);
|
new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)), true);
|
||||||
assertEquals("Torch_Fire", r.getVisualParticleId());
|
assertEquals("Torch_Fire", r.getVisualParticleId());
|
||||||
assertEquals(0.3, r.getVisualParticleDensity(), 1e-9);
|
assertEquals(0.3, r.getVisualParticleDensity(), 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- matrixFromBox ----------
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void matrix_boxNonCubic() {
|
void matrix_boxNonCubic() {
|
||||||
Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(2, 4, 6));
|
Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(2, 4, 6));
|
||||||
Matrix4d m = RegionVisualizer.matrixFromBox(b);
|
Matrix4d m = RegionVisualizer.matrixFromBox(b);
|
||||||
double[] d = m.getData();
|
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(2.0, d[0], 1e-9);
|
||||||
assertEquals(4.0, d[5], 1e-9);
|
assertEquals(4.0, d[5], 1e-9);
|
||||||
assertEquals(6.0, d[10], 1e-9);
|
assertEquals(6.0, d[10], 1e-9);
|
||||||
assertEquals(1.0, d[12], 1e-9); // center x = 1
|
assertEquals(1.0, d[12], 1e-9);
|
||||||
assertEquals(2.0, d[13], 1e-9); // center y = 2
|
assertEquals(2.0, d[13], 1e-9);
|
||||||
assertEquals(3.0, d[14], 1e-9); // center z = 3
|
assertEquals(3.0, d[14], 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- visualize : throttling / modes / skip ----------
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void visualize_throttlingSkipsSecondCallWithinWindow() {
|
void visualize_throttlingSkipsSecondCallWithinWindow() {
|
||||||
List<Call> calls = new ArrayList<>();
|
List<Call> calls = new ArrayList<>();
|
||||||
@@ -152,15 +134,15 @@ class RegionVisualizerTest {
|
|||||||
RegionSnapshot snap = snapshotOf(r);
|
RegionSnapshot snap = snapshotOf(r);
|
||||||
|
|
||||||
viz.visualize(null, snap);
|
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);
|
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);
|
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
|
@Test
|
||||||
@@ -193,7 +175,7 @@ class RegionVisualizerTest {
|
|||||||
assertEquals(DebugShape.Cube, c.shape);
|
assertEquals(DebugShape.Cube, c.shape);
|
||||||
assertEquals(DebugUtils.FLAG_NO_WIREFRAME, c.flags);
|
assertEquals(DebugUtils.FLAG_NO_WIREFRAME, c.flags);
|
||||||
assertEquals(0.75f, c.opacity, 1e-6);
|
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);
|
assertEquals(1.2f, c.time, 1e-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,14 +183,14 @@ class RegionVisualizerTest {
|
|||||||
void visualize_clampsOpacityOutOfRange() {
|
void visualize_clampsOpacityOutOfRange() {
|
||||||
List<Call> calls = new ArrayList<>();
|
List<Call> calls = new ArrayList<>();
|
||||||
RegionVisualizer viz = newViz(calls, 0L);
|
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));
|
viz.visualize(null, snapshotOf(r));
|
||||||
assertEquals(1.0f, calls.get(0).opacity, 1e-6);
|
assertEquals(1.0f, calls.get(0).opacity, 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void visualize_clampsRefreshFloorBelowMin() {
|
void visualize_clampsRefreshFloorBelowMin() {
|
||||||
// refreshMs = 10 < MIN_REFRESH_MS (100) → effectif = 100ms
|
// refreshMs=10 < MIN_REFRESH_MS(100) -> effective = 100ms.
|
||||||
List<Call> calls = new ArrayList<>();
|
List<Call> calls = new ArrayList<>();
|
||||||
AtomicLong clock = new AtomicLong(0L);
|
AtomicLong clock = new AtomicLong(0L);
|
||||||
RegionVisualizer viz = new RegionVisualizer(
|
RegionVisualizer viz = new RegionVisualizer(
|
||||||
@@ -218,16 +200,14 @@ class RegionVisualizerTest {
|
|||||||
clock::get);
|
clock::get);
|
||||||
GravityFlipRegion r = region("z1", "#00FF00", "Outline", 10, 0.5);
|
GravityFlipRegion r = region("z1", "#00FF00", "Outline", 10, 0.5);
|
||||||
viz.visualize(null, snapshotOf(r));
|
viz.visualize(null, snapshotOf(r));
|
||||||
clock.set(50L); // 50ms < 100 plancher
|
clock.set(50L);
|
||||||
viz.visualize(null, snapshotOf(r));
|
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);
|
clock.set(150L);
|
||||||
viz.visualize(null, snapshotOf(r));
|
viz.visualize(null, snapshotOf(r));
|
||||||
assertEquals(2, calls.size());
|
assertEquals(2, calls.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- helpers ----------
|
|
||||||
|
|
||||||
private static RegionVisualizer newViz(List<Call> calls, long now) {
|
private static RegionVisualizer newViz(List<Call> calls, long now) {
|
||||||
AtomicLong clock = new AtomicLong(now);
|
AtomicLong clock = new AtomicLong(now);
|
||||||
return new RegionVisualizer(
|
return new RegionVisualizer(
|
||||||
|
|||||||
Reference in New Issue
Block a user