Compare commits

...

10 Commits

Author SHA1 Message Date
kayjaydee cc6c022e15 docs: update contact information in README.md
- Changed the contact email for inquiries regarding the project license from socials@mythlane.com to contact@mythlane.com for better accessibility.
2026-04-28 09:00:13 +02:00
kayjaydee 7b67410698 docs: remove demo section and screenshots from README.md
- Eliminated the demo section and associated YouTube video placeholder for a more concise README.
- Removed screenshots table to streamline content and focus on essential project information.
2026-04-26 18:18:41 +02:00
kayjaydee 2b23d87c80 docs: update README.md for clarity and structure
- Simplified project description and improved readability.
- Changed section titles for better organization (e.g., "Showcase" to "Demo").
- Reformatted screenshots into a markdown table for a cleaner layout.
- Enhanced the "What Does It Do?" section to clearly outline features.
- Streamlined the Quick Start and Commands sections for easier navigation.
2026-04-24 18:11:02 +02:00
kayjaydee c20bf42c36 refactor(command): simplify region lookup in GravityFlip commands
- Replaced manual region search loops with a new `find` method in RegionRegistry for improved readability and efficiency.
- Updated `GravityFlipToggleSubCommand` and `GravityFlipTpSubCommand` to utilize the new method for finding regions by name.
- Enhanced the `add` method in RegionRegistry to use the `find` method for checking existing region names.
2026-04-24 18:07:34 +02:00
kayjaydee 24ee43b3a3 docs: replace SHOWCASE.md with comprehensive README.md
- Replace portfolio-only SHOWCASE with unified README as sole project entry page
- Add full user guide: commands table, wand flow, region anatomy (all 15 config fields sourced from GravityFlipRegion.CODEC), visual modes, regions.json path
- Add developer section: prerequisites (JDK 25, Hytale API), project structure, build/test/deploy commands, extension snippets
- Add architecture overview with ASCII diagram (tick loop, wand flow, persistence)
- Correct subcommand list (wand/define/list/delete/toggle/tp) to match GravityFlipCommand source
- Preserve showcase content: video placeholder, screenshot grid (HTML table), 12 showcase regions table, feature list
- Add shields.io badges, tech-stack, roadmap, credits, license sections
2026-04-24 17:35:14 +02:00
kayjaydee 0b191c6504 docs: add SHOWCASE.md portfolio page
- Portfolio-ready showcase with YouTube placeholder, screenshots gallery, feature list
- Command reference table for the 6 subcommands
- Inventory of 12 bundled showcase regions with coordinates and purpose
- Install / development / license sections
- docs/screenshots/.gitkeep to reserve asset location
2026-04-24 17:31:34 +02:00
kayjaydee 6a830ed285 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
2026-04-24 17:25:38 +02:00
kayjaydee 6b28dc2d2a refactor(command): clean GSD comments and translate user-facing messages to English 2026-04-24 17:25:31 +02:00
kayjaydee 15fc0702f1 refactor(wand): clean GSD comments and translate to English
- Reduce javadocs to one-liners, remove REQ/plan/phase references

- No behavioural change
2026-04-24 17:25:24 +02:00
kayjaydee b8754617d4 refactor: clean GSD references + shrink comments in wand/ 2026-04-24 17:11:54 +02:00
33 changed files with 433 additions and 914 deletions
+192
View File
@@ -0,0 +1,192 @@
# Hytale Gravity Flip
Gravity Flip lets you create anti-gravity regions on your Hytale server using a wand — no scripting, no file edits, no server restarts. Want to walk on the ceiling? Place two corners, name the region, and flip the world.
<p align="center">
<img src="https://img.shields.io/badge/Java-25-orange" alt="Java 25"/>
<img src="https://img.shields.io/badge/Gradle-Shadow-green" alt="Gradle Shadow"/>
<img src="https://img.shields.io/badge/Hytale-2026.03.26-blueviolet" alt="Hytale 2026.03.26"/>
<img src="https://img.shields.io/badge/v1-shipped-brightgreen" alt="v1 shipped"/>
<img src="https://img.shields.io/badge/license-TBD-lightgrey" alt="License"/>
</p>
---
## What Does It Do?
- Mark a region in game with a wand tool.
- Instantly flips gravity *inside your box* for players, items, and/or mobs, depending on your config.
- Everything is persisted to disk, no fiddling with configs.
- Toggle regions, delete, teleport, etc., all via `/gravityflip` commands.
- Visualize with outlines or particles. Toggle between modes.
- Built for builders: no code, minimal ceremony.
---
## Quick Start
1. Copy `hytale-gravity-flip-<version>.jar` into your server's `mods` folder.
2. Run your server.
3. Use `/gravityflip wand` in-game to get the Gravity Flip Wand.
4. Left-click a block (corner 1), right-click another (corner 2).
5. Run `/gravityflip define <region_name>` — done. Gravity now flips inside that box.
---
## Commands
All commands are subcommands of `/gravityflip`:
| Command | What it does |
| ------------------------------- | --------------------------------------------------------- |
| `/gravityflip wand` | Gives you the wand. |
| `/gravityflip define <name>` | Defines a region from your selection. |
| `/gravityflip list` | Lists all registered regions and their info. |
| `/gravityflip delete <name>` | Removes a region. |
| `/gravityflip toggle <name>` | Enables/disables a region. |
| `/gravityflip tp <name>` | Teleports you to a region's center. |
---
### How The Wand Works
- Left-click = set pos1
- Right-click = set pos2
- Each player gets their own selection — it sticks until changed/logged out.
- When both corners are set, run `/gravityflip define <name>`
---
## Region Fields
Gravity Flip regions are saved as JSON. Each region needs a `Name` and a `Box` (AABB). The rest have defaults.
| Field | Type | Default | What it means |
|-------------------------|---------|-------------|-------------------------------------------------|
| Name | string | required | The name/id you use in commands. |
| Box | array | required | `{min: [x,y,z], max: [x,y,z]}` |
| Enabled | bool | true | If off, region does nothing. |
| FallDamage | bool | false | Keep vanilla fall damage? (usually off) |
| GracePeriodMs | int | 2500 | Smooths the gravity transition (ms). |
| VerticalForce | float | 0.1 | How strong the anti-grav is. |
| AffectPlayers | bool | true | Flip players? |
| AffectNpcs | bool | true | Flip mobs/NPCs? |
| AffectItems | bool | true | Flip dropped items? |
| VisualColor | string | #00FFFF | Outline/particle color. |
| VisualMode | string | Outline | Outline, Particles, or None. |
| VisualRefreshMs | int | 1000 | How often visuals are updated (ms). |
| VisualOpacity | float | 0.5 | Outline transparency (01) |
| VisualParticleId | string | Torch_Fire | Particle id for "Particles" mode. |
| VisualParticleDensity | float | 0.3 | Particle density along edges. |
---
## Visualization
- **Outline**: Wireframe box for editing and debugging.
- **Particles**: Edges emit particles.
- **None**: Go "invisible" for production/live use.
---
## The Region File
Sits at `Server/mods/Mythlane_GravityFlip/regions.json`. Changing regions via commands saves instantly. If you hand-edit, restart the server. First run always seeds a "demo" region.
---
## Prebuilt Regions (for dev/testing)
| Name | Min | Max | Purpose |
|----------------------|---------------|---------------|----------------------------------------|
| tutorial_walk_on_ceiling | (10,81,10) | (15,86,15) | Simple walk-on-ceiling, cyan outline |
| item_fountain | (20,80,10) | (23,100,13) | Items float up, for demos |
| mob_chamber | (30,81,10) | (40,89,20) | Mobs only, red outline |
| full_chaos | (-15,81,10) | (0,91,25) | Players, mobs, items — chaos, purple |
| gentle_lift | (-40,80,-20) | (-20,85,0) | Weak antigrav |
| strong_launch | (-15,80,-15) | (-10,83,-10) | Launch pad settings |
| grace_period_demo | (15,81,-20) | (23,87,-12) | Entry anim with grace period |
| no_grace | (25,81,-20) | (33,87,-12) | Instant flip, for comparison |
| fall_damage_off | (40,110,0) | (50,120,10) | Fall damage off, high up |
| disabled_example | (-30,81,0) | (-25,86,5) | Persisted but disabled |
| showcase_arena | (-80,81,-80) | (-50,96,-50) | 30x15x30 gold-outlined box |
| dense_particles | (50,81,50) | (58,89,58) | Ultra-dense particle effect |
---
## Installing
```bash
./gradlew shadowJar
cp build/libs/hytale-gravity-flip-*.jar <HytaleServer>/Server/mods/
# Restart your server
```
On first start, `/regions.json` is seeded with a demo region. Use the wand/commands to manage regions.
---
## Development
* Requires **JDK 25**, Gradle, Hytale Plugin API (from https://maven.hytale.com/release), and a dev server.
* Code lives in `src/main/java/com/mythlane/gravityflip/`
* Unit tests: pure-data logic is covered in `src/test/java/`
### Building
```bash
./gradlew shadowJar # Build the fat jar with gson relocation
./gradlew test # Run unit tests
```
For dev fast-deploy, see `copyJarToDevServer` gradle task.
### Extending
- New subcommand? Add a class, register in `GravityFlipCommand`.
- New wand interaction? Register in the plugin `setup()` and hook it in assets.
- New region field? Add to the codec; existing JSON will keep defaults.
---
## How It Works
```
Player/NPC/Item
|
v (checked every tick)
RegionTickLoop
| <---- Regions loaded from regions.json
GravityApplier + FallDamageGuard
|
region effect
Wand:
Player clicks -> WandSelectionStore -> define region -> registry saves to JSON
```
- Tightly integrated Hytale plugin — lifecycle hooks, asset registration, everything inside the jar.
- Tick loop: runs 10x/sec, checks if entities are in zones, flips gravity as needed.
- Threading: regions are snapshotted for fast concurrent reads.
- Plugin self-seeds demo region so you never boot to an empty world.
---
## Stack
- Java 25, modern features (records, sealed types, etc).
- Gradle 8 + shadowJar (Gson relocated out of the global space).
- Hytale Plugin API core (JavaPlugin, Config, Interaction.CODEC).
- JUnit 5 for testing data and codecs.
---
## Credits
Made by [Mythlane](https://mythlane.com). Plugin layout/modeling borrowed from our VotePipe and related plugins.
---
## License
License to be decided. Ping contact@mythlane.com for questions.
View File
@@ -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;
@@ -2,38 +2,19 @@ package com.mythlane.gravityflip.command;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /** Pure-data validation helpers for the define sub-command (region name regex, componentwise min/max). */
* Pure-data validation helpers for {@code /gravityflip define <name>}.
*
* <p>No Hytale runtime dependency — same philosophy as {@code FallDamageGuard}
* and {@code WandSelectionStore}. Testable with JUnit alone.
*
* <p>Exposes :
* <ul>
* <li>{@link #isValidName(String)} — region name regex gate.</li>
* <li>{@link #componentwiseMin(int[], int[])} / {@link #componentwiseMax(int[], int[])}
* — so the builder can click pos1/pos2 in any order.</li>
* </ul>
*
* <p>See {@code DefineValidationTest} for the accepted/rejected-name corpus and
* the rationale for the inflate-max-by-1 convention applied in
* {@code GravityFlipDefineSubCommand}.
*/
public final class DefineValidation { public final class DefineValidation {
private static final Pattern NAME = Pattern.compile("^[a-zA-Z0-9_-]{1,32}$"); private static final Pattern NAME = Pattern.compile("^[a-zA-Z0-9_-]{1,32}$");
private DefineValidation() {} private DefineValidation() {}
/** /** Returns true iff the name matches {@code ^[a-zA-Z0-9_-]{1,32}$}. */
* Returns {@code true} iff {@code n} matches {@code ^[a-zA-Z0-9_-]{1,32}$}.
* Rejects {@code null}, blank, spaces, path separators, non-ASCII.
*/
public static boolean isValidName(String n) { public static boolean isValidName(String n) {
return n != null && NAME.matcher(n).matches(); return n != null && NAME.matcher(n).matches();
} }
/** Returns {@code {min(a.x,b.x), min(a.y,b.y), min(a.z,b.z)}}. */ /** Returns the per-axis minimum of two 3D integer vectors. */
public static int[] componentwiseMin(int[] a, int[] b) { public static int[] componentwiseMin(int[] a, int[] b) {
return new int[]{ return new int[]{
Math.min(a[0], b[0]), Math.min(a[0], b[0]),
@@ -42,7 +23,7 @@ public final class DefineValidation {
}; };
} }
/** Returns {@code {max(a.x,b.x), max(a.y,b.y), max(a.z,b.z)}}. */ /** Returns the per-axis maximum of two 3D integer vectors. */
public static int[] componentwiseMax(int[] a, int[] b) { public static int[] componentwiseMax(int[] a, int[] b) {
return new int[]{ return new int[]{
Math.max(a[0], b[0]), Math.max(a[0], b[0]),
@@ -3,23 +3,11 @@ package com.mythlane.gravityflip.command;
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
import com.mythlane.gravityflip.GravityFlipPlugin; import com.mythlane.gravityflip.GravityFlipPlugin;
/** /** Root command {@code /gravityflip} — aggregates all sub-commands. */
* Root command {@code /gravityflip}. Aggregates all Gravity Flip sub-commands.
*
* <p>Extends {@link AbstractCommandCollection} — pattern sourced from
* {@code builtin/teleport/commands/teleport/TeleportCommand} and
* {@code modules/debug/commands/DebugCommand}. When invoked without a
* sub-command, the base class emits a usage message listing registered
* sub-commands (no extra work required here).
*
* <p>The plugin reference is stored for future sub-commands (04-03 define,
* 04-04 list/delete/toggle/tp) that need {@code plugin.wandSelections()} or
* {@code plugin.configHolder()}.
*/
public final class GravityFlipCommand extends AbstractCommandCollection { public final class GravityFlipCommand extends AbstractCommandCollection {
public GravityFlipCommand(GravityFlipPlugin plugin) { public GravityFlipCommand(GravityFlipPlugin plugin) {
super("gravityflip", "Commandes de gestion des zones Gravity Flip"); super("gravityflip", "Gravity Flip region management commands");
this.addSubCommand(new GravityFlipWandSubCommand()); this.addSubCommand(new GravityFlipWandSubCommand());
this.addSubCommand(new GravityFlipDefineSubCommand(plugin)); this.addSubCommand(new GravityFlipDefineSubCommand(plugin));
this.addSubCommand(new GravityFlipListSubCommand(plugin)); this.addSubCommand(new GravityFlipListSubCommand(plugin));
@@ -20,42 +20,16 @@ import javax.annotation.Nonnull;
import java.util.UUID; import java.util.UUID;
import java.util.logging.Level; import java.util.logging.Level;
/** /** Creates and persists a new region from the caller's wand selection. */
* {@code /gravityflip define <name>} — consume the caller's wand selection
* (pos1 + pos2) to create and persist a new {@link GravityFlipRegion}.
*
* <p>Flow :
* <ol>
* <li>Validate {@code name} via {@link DefineValidation#isValidName}.</li>
* <li>Read the caller's selection from {@link WandSelectionStore}. Bail out
* if either pos1 or pos2 is unset (builder must click 2 blocks first).</li>
* <li>Compute componentwise min/max so the builder can click in any order.
* Inflate max by +1 per axis so the max block is INSIDE the AABB (a block
* occupies the unit cube between {@code (x,y,z)} and {@code (x+1,y+1,z+1)};
* without inflation, a player standing on the max block is OUT of the
* region — see {@code DefineValidationTest#boxFromCorners_inflateMax_includesMaxBlock}).</li>
* <li>{@code registry.add(region)} — throws {@link IllegalArgumentException}
* on duplicate name (T-04-03-05 mitigation — synchronized mutationLock).</li>
* <li>{@code configHolder.save().join()} forces durability before responding.
* Cost : a few ms disk. Acceptable in an interactive command.</li>
* <li>Clear the caller's selection — {@code define} consumes it, next define
* requires re-clicking 2 blocks (avoid chained accidental defines).</li>
* </ol>
*
* <p>On save failure the region remains in-memory (registry is already updated).
* We report this truthfully rather than silently rolling back — operators can
* inspect logs and the region is still active until restart.
*/
public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand { public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand {
private final GravityFlipPlugin plugin; private final GravityFlipPlugin plugin;
/** Required STRING arg; full validation applied via {@link DefineValidation#isValidName}. */
private final RequiredArg<String> nameArg = private final RequiredArg<String> nameArg =
this.withRequiredArg("name", "Nom de la région (a-z0-9_-, 1-32 chars)", ArgTypes.STRING); this.withRequiredArg("name", "Region name (a-z0-9_-, 1-32 chars)", ArgTypes.STRING);
public GravityFlipDefineSubCommand(GravityFlipPlugin plugin) { public GravityFlipDefineSubCommand(GravityFlipPlugin plugin) {
super("define", "Créer une région Gravity Flip à partir de la sélection wand"); super("define", "Create a Gravity Flip region from the wand selection");
this.plugin = plugin; this.plugin = plugin;
} }
@@ -68,7 +42,7 @@ public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand {
String name = nameArg.get(ctx); String name = nameArg.get(ctx);
if (!DefineValidation.isValidName(name)) { if (!DefineValidation.isValidName(name)) {
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Nom invalide — attendu [a-zA-Z0-9_-]{1,32}.")); "[gravityflip] Invalid name — expected [a-zA-Z0-9_-]{1,32}."));
return; return;
} }
@@ -76,14 +50,13 @@ public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand {
WandSelectionStore.Selection sel = plugin.wandSelections().get(uuid); WandSelectionStore.Selection sel = plugin.wandSelections().get(uuid);
if (sel.pos1 == null || sel.pos2 == null) { if (sel.pos1 == null || sel.pos2 == null) {
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Sélection incomplète — left-click puis right-click un bloc avec le wand avant define.")); "[gravityflip] Incomplete selection — left-click then right-click a block with the wand before define."));
return; return;
} }
int[] mn = DefineValidation.componentwiseMin(sel.pos1, sel.pos2); int[] mn = DefineValidation.componentwiseMin(sel.pos1, sel.pos2);
int[] mx = DefineValidation.componentwiseMax(sel.pos1, sel.pos2); int[] mx = DefineValidation.componentwiseMax(sel.pos1, sel.pos2);
// Inflate max by +1 per axis so the block at maxBlock is inside the AABB // Inflate max by +1 per axis so the max block is INSIDE the AABB (blocks occupy the unit cube [x,x+1]).
// (see DefineValidationTest#boxFromCorners_inflateMax_includesMaxBlock).
Box box = new Box( Box box = new Box(
new Vector3d(mn[0], mn[1], mn[2]), new Vector3d(mn[0], mn[1], mn[2]),
new Vector3d(mx[0] + 1.0, mx[1] + 1.0, mx[2] + 1.0)); new Vector3d(mx[0] + 1.0, mx[1] + 1.0, mx[2] + 1.0));
@@ -93,7 +66,7 @@ public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand {
plugin.regions().add(region); plugin.regions().add(region);
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Une région nommée '" + name + "' existe déjà.")); "[gravityflip] A region named '" + name + "' already exists."));
return; return;
} }
@@ -103,14 +76,14 @@ public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand {
plugin.getLogger().at(Level.WARNING).withCause(th) plugin.getLogger().at(Level.WARNING).withCause(th)
.log("[define] save failed for region '" + name + "'"); .log("[define] save failed for region '" + name + "'");
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Région créée (en mémoire) mais persistance échouée — voir logs.")); "[gravityflip] Region created (in memory) but persistence failed — see logs."));
return; return;
} }
plugin.wandSelections().clear(uuid); plugin.wandSelections().clear(uuid);
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Région '" + name + "' créée : " "[gravityflip] Region '" + name + "' created: "
+ "(" + mn[0] + "," + mn[1] + "," + mn[2] + ") " + "(" + mn[0] + "," + mn[1] + "," + mn[2] + ") -> "
+ "(" + (mx[0] + 1) + "," + (mx[1] + 1) + "," + (mx[2] + 1) + ")")); + "(" + (mx[0] + 1) + "," + (mx[1] + 1) + "," + (mx[2] + 1) + ")"));
} }
} }
@@ -10,30 +10,16 @@ import com.mythlane.gravityflip.GravityFlipPlugin;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import java.util.logging.Level; import java.util.logging.Level;
/** /** Deletes a Gravity Flip region and persists the change. */
* {@code /gravityflip delete <name>} — supprime la région nommée et persiste.
*
* <p>Étend {@link CommandBase} pour permettre l'usage depuis la console serveur
* (opération admin, pas besoin d'un joueur caller).
*
* <p>Flow :
* <ol>
* <li>{@code registry.remove(name)} — renvoie {@code false} si nom inconnu → message
* d'erreur clair et retour précoce (pas de save inutile).</li>
* <li>{@code configHolder.save().join()} force la durabilité. Même pattern que
* {@code GravityFlipDefineSubCommand} : sur échec disque, on reporte truthfully
* ("en mémoire OK, persistance échouée") plutôt que silent rollback.</li>
* </ol>
*/
public final class GravityFlipDeleteSubCommand extends CommandBase { public final class GravityFlipDeleteSubCommand extends CommandBase {
private final GravityFlipPlugin plugin; private final GravityFlipPlugin plugin;
private final RequiredArg<String> nameArg = private final RequiredArg<String> nameArg =
this.withRequiredArg("name", "Nom de la région à supprimer", ArgTypes.STRING); this.withRequiredArg("name", "Name of the region to delete", ArgTypes.STRING);
public GravityFlipDeleteSubCommand(GravityFlipPlugin plugin) { public GravityFlipDeleteSubCommand(GravityFlipPlugin plugin) {
super("delete", "Supprime une région Gravity Flip"); super("delete", "Delete a Gravity Flip region");
this.plugin = plugin; this.plugin = plugin;
} }
@@ -41,7 +27,7 @@ public final class GravityFlipDeleteSubCommand extends CommandBase {
protected void executeSync(@Nonnull CommandContext ctx) { protected void executeSync(@Nonnull CommandContext ctx) {
String name = nameArg.get(ctx); String name = nameArg.get(ctx);
if (!plugin.regions().remove(name)) { if (!plugin.regions().remove(name)) {
ctx.sendMessage(Message.raw("[gravityflip] Région '" + name + "' introuvable.")); ctx.sendMessage(Message.raw("[gravityflip] Region '" + name + "' not found."));
return; return;
} }
try { try {
@@ -50,9 +36,9 @@ public final class GravityFlipDeleteSubCommand extends CommandBase {
plugin.getLogger().at(Level.WARNING).withCause(th) plugin.getLogger().at(Level.WARNING).withCause(th)
.log("[delete] save failed for region '" + name + "'"); .log("[delete] save failed for region '" + name + "'");
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Suppression en mémoire OK, persistance échouée — voir logs.")); "[gravityflip] Deleted in memory, but persistence failed — see logs."));
return; return;
} }
ctx.sendMessage(Message.raw("[gravityflip] Région '" + name + "' supprimée.")); ctx.sendMessage(Message.raw("[gravityflip] Region '" + name + "' deleted."));
} }
} }
@@ -10,29 +10,13 @@ import com.mythlane.gravityflip.region.GravityFlipRegion;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import java.util.Collection; import java.util.Collection;
/** /** Lists all persisted Gravity Flip regions. */
* {@code /gravityflip list} — liste toutes les régions Gravity Flip persistées.
*
* <p>Étend {@link CommandBase} (et non {@code AbstractPlayerCommand}) pour
* fonctionner aussi depuis la console serveur : cette commande est admin-only
* de par sa permission auto-générée ({@code mythlane.gravityflip.command.gravityflip.list}),
* mais n'a pas besoin d'un {@code PlayerRef} — seulement de {@code ctx.sendMessage(...)}.
*
* <p>Format de sortie (une ligne par région) :
* <pre>
* - test-zone-1 : (10,64,10) → (21,71,21) [enabled]
* </pre>
*
* <p>Threat surface : T-04-04-01 (Information Disclosure) accepté — les coords
* des régions sont visibles pour tout opérateur avec la permission list ; c'est
* la spec attendue pour un outil builder.
*/
public final class GravityFlipListSubCommand extends CommandBase { public final class GravityFlipListSubCommand extends CommandBase {
private final GravityFlipPlugin plugin; private final GravityFlipPlugin plugin;
public GravityFlipListSubCommand(GravityFlipPlugin plugin) { public GravityFlipListSubCommand(GravityFlipPlugin plugin) {
super("list", "Liste toutes les régions Gravity Flip"); super("list", "List all Gravity Flip regions");
this.plugin = plugin; this.plugin = plugin;
} }
@@ -40,15 +24,15 @@ public final class GravityFlipListSubCommand extends CommandBase {
protected void executeSync(@Nonnull CommandContext ctx) { protected void executeSync(@Nonnull CommandContext ctx) {
Collection<GravityFlipRegion> all = plugin.regions().all(); Collection<GravityFlipRegion> all = plugin.regions().all();
if (all.isEmpty()) { if (all.isEmpty()) {
ctx.sendMessage(Message.raw("[gravityflip] Aucune région définie.")); ctx.sendMessage(Message.raw("[gravityflip] No regions defined."));
return; return;
} }
ctx.sendMessage(Message.raw("[gravityflip] " + all.size() + " région(s) :")); ctx.sendMessage(Message.raw("[gravityflip] " + all.size() + " region(s):"));
for (GravityFlipRegion r : all) { for (GravityFlipRegion r : all) {
Vector3d mn = r.getMin(); Vector3d mn = r.getMin();
Vector3d mx = r.getMax(); Vector3d mx = r.getMax();
ctx.sendMessage(Message.raw(String.format( ctx.sendMessage(Message.raw(String.format(
" - %s : (%.0f,%.0f,%.0f) (%.0f,%.0f,%.0f) [%s]", " - %s : (%.0f,%.0f,%.0f) -> (%.0f,%.0f,%.0f) [%s]",
r.getName(), mn.x, mn.y, mn.z, mx.x, mx.y, mx.z, r.getName(), mn.x, mn.y, mn.z, mx.x, mx.y, mx.z,
r.isEnabled() ? "enabled" : "disabled"))); r.isEnabled() ? "enabled" : "disabled")));
} }
@@ -11,50 +11,31 @@ import com.mythlane.gravityflip.region.GravityFlipRegion;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import java.util.logging.Level; import java.util.logging.Level;
/** /** Enables/disables a Gravity Flip region and persists the change. */
* {@code /gravityflip toggle <name>} — flippe le flag {@code enabled} d'une région
* sans toucher aux corners ni aux autres champs.
*
* <p>Étend {@link CommandBase} (utilisable console + joueur).
*
* <p>Flow : lookup région → lecture {@code isEnabled()} → {@code registry.setEnabled(name, !current)}
* → {@code configHolder.save().join()} → message avec le nouvel état.
*
* <p>Race (T-04-04-04) : lookup + setEnabled non atomique entre eux. Deux toggles
* simultanés sur le même nom produisent un état final indéterminé mais cohérent
* sur disque ({@code RegionRegistry.setEnabled} est synchronisé sur {@code mutationLock}).
* Acceptable v1 (single-operator builder).
*/
public final class GravityFlipToggleSubCommand extends CommandBase { public final class GravityFlipToggleSubCommand extends CommandBase {
private final GravityFlipPlugin plugin; private final GravityFlipPlugin plugin;
private final RequiredArg<String> nameArg = private final RequiredArg<String> nameArg =
this.withRequiredArg("name", "Nom de la région à toggler", ArgTypes.STRING); this.withRequiredArg("name", "Name of the region to toggle", ArgTypes.STRING);
public GravityFlipToggleSubCommand(GravityFlipPlugin plugin) { public GravityFlipToggleSubCommand(GravityFlipPlugin plugin) {
super("toggle", "Active/désactive une région Gravity Flip"); super("toggle", "Enable/disable a Gravity Flip region");
this.plugin = plugin; this.plugin = plugin;
} }
@Override @Override
protected void executeSync(@Nonnull CommandContext ctx) { protected void executeSync(@Nonnull CommandContext ctx) {
String name = nameArg.get(ctx); String name = nameArg.get(ctx);
GravityFlipRegion found = null; GravityFlipRegion found = plugin.regions().find(name);
for (GravityFlipRegion r : plugin.regions().all()) {
if (r.getName().equals(name)) {
found = r;
break;
}
}
if (found == null) { if (found == null) {
ctx.sendMessage(Message.raw("[gravityflip] Région '" + name + "' introuvable.")); ctx.sendMessage(Message.raw("[gravityflip] Region '" + name + "' not found."));
return; return;
} }
boolean next = !found.isEnabled(); boolean next = !found.isEnabled();
if (!plugin.regions().setEnabled(name, next)) { if (!plugin.regions().setEnabled(name, next)) {
// Course ultra-rare : région supprimée entre all() et setEnabled(). // Rare race: region removed between all() and setEnabled().
ctx.sendMessage(Message.raw("[gravityflip] Région '" + name + "' introuvable.")); ctx.sendMessage(Message.raw("[gravityflip] Region '" + name + "' not found."));
return; return;
} }
try { try {
@@ -63,10 +44,10 @@ public final class GravityFlipToggleSubCommand extends CommandBase {
plugin.getLogger().at(Level.WARNING).withCause(th) plugin.getLogger().at(Level.WARNING).withCause(th)
.log("[toggle] save failed for region '" + name + "'"); .log("[toggle] save failed for region '" + name + "'");
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Toggle en mémoire OK, persistance échouée — voir logs.")); "[gravityflip] Toggled in memory, but persistence failed — see logs."));
return; return;
} }
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Région '" + name + "' " + (next ? "activée" : "désactivée") + ".")); "[gravityflip] Region '" + name + "' " + (next ? "enabled" : "disabled") + "."));
} }
} }
@@ -20,37 +20,16 @@ import com.mythlane.gravityflip.region.GravityFlipRegion;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
/** /** Teleports the calling player to the center of a Gravity Flip region's AABB. */
* {@code /gravityflip tp <name>} — téléporte le joueur appelant au centre de
* l'AABB d'une région Gravity Flip. Outil debug/builder (CMD-06).
*
* <p>Étend {@link AbstractPlayerCommand} : tp ne fait sens que pour un joueur,
* la base class gère automatiquement le message "must be player" pour un appel console.
*
* <p>Pattern Teleport copié ligne-à-ligne sur
* {@code com.hypixel.hytale.builtin.teleport.commands.teleport.variant.TeleportToCoordinatesCommand}
* (lignes 48-68) :
* <ol>
* <li>Lire {@link TransformComponent} (rotation corps) et {@link HeadRotation}
* (rotation tête) pour préserver l'orientation du joueur.</li>
* <li>Calculer le centre componentwise : {@code c = (min + max) / 2}.</li>
* <li>Construire via {@code Teleport.createForPlayer(pos, rotation).setHeadRotation(...)}.</li>
* <li>{@code store.addComponent(ref, Teleport.getComponentType(), teleport)} —
* le {@code TeleportSystem} du core consomme le composant au prochain tick.</li>
* </ol>
*
* <p>T-04-04-02 : permission auto-générée {@code mythlane.gravityflip.command.gravityflip.tp}
* sert de gate. Pas de restriction GameMode v1 (builder tool).
*/
public final class GravityFlipTpSubCommand extends AbstractPlayerCommand { public final class GravityFlipTpSubCommand extends AbstractPlayerCommand {
private final GravityFlipPlugin plugin; private final GravityFlipPlugin plugin;
private final RequiredArg<String> nameArg = private final RequiredArg<String> nameArg =
this.withRequiredArg("name", "Nom de la région cible", ArgTypes.STRING); this.withRequiredArg("name", "Name of the target region", ArgTypes.STRING);
public GravityFlipTpSubCommand(GravityFlipPlugin plugin) { public GravityFlipTpSubCommand(GravityFlipPlugin plugin) {
super("tp", "Téléporte au centre d'une région Gravity Flip"); super("tp", "Teleport to the center of a Gravity Flip region");
this.plugin = plugin; this.plugin = plugin;
} }
@@ -61,15 +40,9 @@ public final class GravityFlipTpSubCommand extends AbstractPlayerCommand {
@Nonnull PlayerRef playerRef, @Nonnull PlayerRef playerRef,
@Nonnull World world) { @Nonnull World world) {
String name = nameArg.get(ctx); String name = nameArg.get(ctx);
GravityFlipRegion target = null; GravityFlipRegion target = plugin.regions().find(name);
for (GravityFlipRegion r : plugin.regions().all()) {
if (r.getName().equals(name)) {
target = r;
break;
}
}
if (target == null) { if (target == null) {
ctx.sendMessage(Message.raw("[gravityflip] Région '" + name + "' introuvable.")); ctx.sendMessage(Message.raw("[gravityflip] Region '" + name + "' not found."));
return; return;
} }
@@ -82,13 +55,12 @@ public final class GravityFlipTpSubCommand extends AbstractPlayerCommand {
TransformComponent tc = store.getComponent(ref, TransformComponent.getComponentType()); TransformComponent tc = store.getComponent(ref, TransformComponent.getComponentType());
if (tc == null) { if (tc == null) {
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] TransformComponent manquant — tp impossible.")); "[gravityflip] TransformComponent missing — teleport impossible."));
return; return;
} }
HeadRotation hr = store.getComponent(ref, HeadRotation.getComponentType()); HeadRotation hr = store.getComponent(ref, HeadRotation.getComponentType());
// Préserve l'orientation courante : body rotation depuis Transform, head rotation // Preserve current orientation: body rotation from Transform, head rotation when available.
// si disponible (sinon on laisse la default côté Teleport.createForPlayer).
Vector3f bodyRotation = tc.getRotation().clone(); Vector3f bodyRotation = tc.getRotation().clone();
Teleport teleport = Teleport.createForPlayer( Teleport teleport = Teleport.createForPlayer(
new Vector3d(cx, cy, cz), bodyRotation); new Vector3d(cx, cy, cz), bodyRotation);
@@ -98,7 +70,7 @@ public final class GravityFlipTpSubCommand extends AbstractPlayerCommand {
store.addComponent(ref, Teleport.getComponentType(), teleport); store.addComponent(ref, Teleport.getComponentType(), teleport);
ctx.sendMessage(Message.raw(String.format( ctx.sendMessage(Message.raw(String.format(
"[gravityflip] Téléporté au centre de '%s' : (%.0f,%.0f,%.0f)", "[gravityflip] Teleported to the center of '%s': (%.0f,%.0f,%.0f)",
name, cx, cy, cz))); name, cx, cy, cz)));
} }
} }
@@ -14,27 +14,13 @@ import com.hypixel.hytale.server.core.universe.world.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
/** /** {@code /gravityflip wand} — gives a Gravity Flip Wand to the calling player. */
* {@code /gravityflip wand} — donne un Gravity Flip Wand au joueur appelant.
*
* <p>Pattern copié ligne-à-ligne sur
* {@code com.hypixel.hytale.server.core.command.commands.player.inventory.GiveCommand}
* (cf. GiveCommand.java:47-76). La seule différence : l'item est résolu par
* constante ({@link #WAND_ITEM_ID}) au lieu d'un {@code RequiredArg<Item>}.
*
* <p>L'item {@code gravityflip_wand} est enregistré via le JSON bundle
* {@code src/main/resources/Items/gravityflip_wand.json} (Phase 04-01).
* Si l'asset n'est pas chargé (JSON absent / pas picked-up par l'AssetStore
* core), le lookup renvoie {@code null} et la commande envoie un message
* d'erreur clair au lieu de crasher (T-04-02 scope).
*/
public final class GravityFlipWandSubCommand extends AbstractPlayerCommand { public final class GravityFlipWandSubCommand extends AbstractPlayerCommand {
/** ItemID du wand défini dans {@code Items/gravityflip_wand.json} (Phase 04-01). */
private static final String WAND_ITEM_ID = "gravityflip_wand"; private static final String WAND_ITEM_ID = "gravityflip_wand";
public GravityFlipWandSubCommand() { public GravityFlipWandSubCommand() {
super("wand", "Obtenir un Gravity Flip Wand"); super("wand", "Get a Gravity Flip Wand");
} }
@Override @Override
@@ -47,22 +33,22 @@ public final class GravityFlipWandSubCommand extends AbstractPlayerCommand {
if (item == null) { if (item == null) {
context.sendMessage(Message.raw( context.sendMessage(Message.raw(
"[gravityflip] Item '" + WAND_ITEM_ID "[gravityflip] Item '" + WAND_ITEM_ID
+ "' introuvable — asset pas chargé ?")); + "' not found — asset not loaded?"));
return; return;
} }
Player playerComponent = store.getComponent(ref, Player.getComponentType()); Player playerComponent = store.getComponent(ref, Player.getComponentType());
if (playerComponent == null) { if (playerComponent == null) {
context.sendMessage(Message.raw("[gravityflip] Player component manquant.")); context.sendMessage(Message.raw("[gravityflip] Player component missing."));
return; return;
} }
ItemStack stack = new ItemStack(item.getId(), 1, null); ItemStack stack = new ItemStack(item.getId(), 1, null);
ItemStackTransaction transaction = playerComponent.giveItem(stack, ref, store); ItemStackTransaction transaction = playerComponent.giveItem(stack, ref, store);
ItemStack remainder = transaction.getRemainder(); ItemStack remainder = transaction.getRemainder();
if (remainder == null || remainder.isEmpty()) { if (remainder == null || remainder.isEmpty()) {
context.sendMessage(Message.raw("[gravityflip] Wand ajouté à ton inventaire.")); context.sendMessage(Message.raw("[gravityflip] Wand added to your inventory."));
} else { } else {
context.sendMessage(Message.raw( context.sendMessage(Message.raw(
"[gravityflip] Inventaire plein — impossible d'ajouter le wand.")); "[gravityflip] Inventory full — cannot add the wand."));
} }
} }
} }
@@ -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&lt;RegionSnapshot&gt;} 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,18 +78,27 @@ public final class RegionRegistry {
return out; return out;
} }
/** Returns the region with the given name from the current snapshot, or null. */
public GravityFlipRegion find(String name) {
if (name == null) return null;
for (GravityFlipRegion x : regionsSnapshot.get()) {
if (x.getName().equals(name)) return x;
}
return null;
}
/** 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()) { if (find(r.getName()) != null) {
if (x.getName().equals(r.getName())) { throw new IllegalArgumentException("region name already exists: " + r.getName());
throw new IllegalArgumentException("region name already exists: " + r.getName());
}
} }
config.getRegions().add(r); config.getRegions().add(r);
regionsSnapshot.set(List.copyOf(config.getRegions())); regionsSnapshot.set(List.copyOf(config.getRegions()));
} }
} }
/** 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 +107,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 +121,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 +142,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 +169,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 +201,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,39 +49,32 @@ 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();
if (w == null) return; if (w == null) return;
registry.refreshFor(w); registry.refreshFor(w);
if (gravityApplier != null) { if (gravityApplier != null || regionVisualizer != null) {
gravityApplier.apply(w, registry.currentSnapshot(w)); var snapshot = registry.currentSnapshot(w);
} if (gravityApplier != null) gravityApplier.apply(w, snapshot);
if (regionVisualizer != null) { if (regionVisualizer != null) regionVisualizer.visualize(w, snapshot);
regionVisualizer.visualize(w, registry.currentSnapshot(w));
} }
}; };
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 +88,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,38 +15,7 @@ import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import java.util.UUID; import java.util.UUID;
/** /** Wand interaction: Primary click sets pos1, Secondary click sets pos2 in the shared selection store. */
* Gravity Flip wand interaction — reads Primary / Secondary clicks and pushes
* the targeted block position into a {@link WandSelectionStore} keyed by the
* clicker's player UUID.
*
* <p><b>Binding :</b> registered at {@code setup()} time via
* {@code getCodecRegistry(Interaction.CODEC).register("GravityFlipWand", …)} —
* same pattern as {@code ExitInstanceInteraction} (cf. 04-00 SPIKE-RESULT,
* Finding 3). The matching {@code "Type": "GravityFlipWand"} reference in
* {@code Items/gravityflip_wand.json} wires the click packet to this class.
*
* <p><b>One class for both click types :</b> {@link #firstRun} receives the
* {@link InteractionType}. Primary → {@code setPos1}, Secondary → {@code setPos2}.
* Centralising both in one class keeps a single registration entry and a
* single CODEC — any other split would duplicate wiring for no gain.
*
* <p><b>Store injection :</b> the Hytale CODEC instantiates interactions via a
* no-arg constructor, so we cannot inject the store through the constructor.
* Instead, {@link #bindStore(WandSelectionStore)} installs a {@code volatile}
* reference at plugin start. If the store is not bound (mis-wired plugin),
* {@link #firstRun} is a safe no-op — fail-silent.
*
* <p><b>Threat surface :</b>
* <ul>
* <li>Chat feedback uses {@link PlayerRef#sendMessage} — directed to the
* clicker only, no broadcast (T-04-01-03).</li>
* <li>{@code TargetBlock} coordinates are stored raw as {@code int[]} — no
* numeric processing in this plan; {@code /gravityflip define} (04-03)
* is responsible for clamping / validating before region creation
* (T-04-01-01).</li>
* </ul>
*/
public final class GravityFlipWandInteraction extends SimpleInstantInteraction { public final class GravityFlipWandInteraction extends SimpleInstantInteraction {
@Nonnull @Nonnull
@@ -60,19 +29,14 @@ public final class GravityFlipWandInteraction extends SimpleInstantInteraction {
+ "Secondary click sets pos2, for the clicker's selection.")) + "Secondary click sets pos2, for the clicker's selection."))
.build(); .build();
/** // Volatile: writer (setup thread) must publish safely to reader threads (interaction dispatch).
* Store shared by every instance of this interaction — injected at plugin
* start via {@link #bindStore}. Volatile so the writer thread ({@code setup()})
* publishes a safe reference to the reader threads (interaction dispatch).
*/
private static volatile WandSelectionStore STORE; private static volatile WandSelectionStore STORE;
/** Wire the selection store before any click can be processed. Call once at {@code setup()}. */ /** Wires the selection store before any click can be processed. */
public static void bindStore(WandSelectionStore store) { public static void bindStore(WandSelectionStore store) {
STORE = store; STORE = store;
} }
/** Required no-arg constructor used by the CODEC factory. */
public GravityFlipWandInteraction() { public GravityFlipWandInteraction() {
} }
@@ -82,7 +46,6 @@ public final class GravityFlipWandInteraction extends SimpleInstantInteraction {
@Nonnull CooldownHandler cooldownHandler) { @Nonnull CooldownHandler cooldownHandler) {
WandSelectionStore store = STORE; WandSelectionStore store = STORE;
if (store == null) { if (store == null) {
// Plugin mis-wired (bindStore never called). Silent no-op — don't crash the click.
return; return;
} }
@@ -94,7 +57,6 @@ public final class GravityFlipWandInteraction extends SimpleInstantInteraction {
PlayerRef playerRef = commandBuffer.getComponent(entityRef, PlayerRef.getComponentType()); PlayerRef playerRef = commandBuffer.getComponent(entityRef, PlayerRef.getComponentType());
if (playerRef == null) { if (playerRef == null) {
// Clicker is not a player (mob, arrow, …) — ignore.
return; return;
} }
@@ -114,6 +76,5 @@ public final class GravityFlipWandInteraction extends SimpleInstantInteraction {
playerRef.sendMessage(Message.raw( playerRef.sendMessage(Message.raw(
"[gravityflip] pos2 set: (%d, %d, %d)".formatted(bp.x, bp.y, bp.z))); "[gravityflip] pos2 set: (%d, %d, %d)".formatted(bp.x, bp.y, bp.z)));
} }
// Any other InteractionType (Ability1, Pick, Equipped, …) is ignored.
} }
} }
@@ -3,29 +3,12 @@ package com.mythlane.gravityflip.wand;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /** Thread-safe per-player wand selection store (pos1/pos2 keyed by player UUID). */
* Thread-safe per-player wand selection store. Tracks the two corner positions
* ({@code pos1} = Primary click, {@code pos2} = Secondary click) of each
* builder's current selection, keyed by player {@link UUID}.
*
* <p><b>Pure-data :</b> no Hytale runtime dependency — same philosophy as
* {@code FallDamageGuard}. Testable with JUnit alone, reusable by future
* {@code /gravityflip define} command (Phase 04-02+) without runtime coupling.
*
* <p><b>Thread-safety :</b> backed by a {@link ConcurrentHashMap} whose
* {@code compute(...)} mutations are atomic. Safe for concurrent
* {@link #setPos1}/{@link #setPos2} calls on the same UUID (STRIDE T-04-01-04).
*
* <p><b>Lifecycle :</b> in-memory only — selections are discarded on plugin
* shutdown. Conscious design : a builder will not quit mid-{@code define}.
*/
public final class WandSelectionStore { public final class WandSelectionStore {
/** Immutable holder for a (possibly partial) selection. */ /** Immutable holder for a (possibly partial) selection. */
public static final class Selection { public static final class Selection {
/** {@code {x,y,z}} of the Primary click, or {@code null} if unset. */
public final int[] pos1; public final int[] pos1;
/** {@code {x,y,z}} of the Secondary click, or {@code null} if unset. */
public final int[] pos2; public final int[] pos2;
Selection(int[] pos1, int[] pos2) { Selection(int[] pos1, int[] pos2) {
@@ -43,7 +26,7 @@ public final class WandSelectionStore {
private final ConcurrentHashMap<UUID, Selection> byUuid = new ConcurrentHashMap<>(); private final ConcurrentHashMap<UUID, Selection> byUuid = new ConcurrentHashMap<>();
/** Record the Primary-click corner for {@code uuid}. Keeps any existing pos2. */ /** Records the Primary-click corner for the given player. */
public void setPos1(UUID uuid, int x, int y, int z) { public void setPos1(UUID uuid, int x, int y, int z) {
if (uuid == null) return; if (uuid == null) return;
byUuid.compute(uuid, (k, prev) -> new Selection( byUuid.compute(uuid, (k, prev) -> new Selection(
@@ -51,7 +34,7 @@ public final class WandSelectionStore {
prev != null ? prev.pos2 : null)); prev != null ? prev.pos2 : null));
} }
/** Record the Secondary-click corner for {@code uuid}. Keeps any existing pos1. */ /** Records the Secondary-click corner for the given player. */
public void setPos2(UUID uuid, int x, int y, int z) { public void setPos2(UUID uuid, int x, int y, int z) {
if (uuid == null) return; if (uuid == null) return;
byUuid.compute(uuid, (k, prev) -> new Selection( byUuid.compute(uuid, (k, prev) -> new Selection(
@@ -59,23 +42,20 @@ public final class WandSelectionStore {
new int[]{x, y, z})); new int[]{x, y, z}));
} }
/** /** Returns the current selection for the given player (never null). */
* Return the current selection for {@code uuid}. Never returns {@code null} :
* an unknown UUID yields a {@link Selection} with both corners {@code null}.
*/
public Selection get(UUID uuid) { public Selection get(UUID uuid) {
if (uuid == null) return EMPTY; if (uuid == null) return EMPTY;
Selection s = byUuid.get(uuid); Selection s = byUuid.get(uuid);
return s != null ? s : EMPTY; return s != null ? s : EMPTY;
} }
/** Forget the selection for {@code uuid} (e.g. after {@code /gravityflip define}). */ /** Forgets the selection for the given player. */
public void clear(UUID uuid) { public void clear(UUID uuid) {
if (uuid == null) return; if (uuid == null) return;
byUuid.remove(uuid); byUuid.remove(uuid);
} }
/** Diagnostic : number of players with an in-flight selection. */ /** Number of players with an in-flight selection. */
public int size() { public int size() {
return byUuid.size(); return byUuid.size();
} }
@@ -6,21 +6,9 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals;
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 DefineValidation} — name regex, componentwise min/max, inflate-max-by-1 convention. */
* Pure-data tests for {@link DefineValidation} — no Hytale runtime dependency.
*
* <p>Covers :
* <ul>
* <li>Name regex accepts alnum + underscore + dash, 1..32 chars.</li>
* <li>Name regex rejects blank, spaces, special chars, non-ASCII, over-length.</li>
* <li>Componentwise min/max return smallest/largest per axis.</li>
* <li>Inflate-max-by-1 convention (max block is INSIDE the AABB only if we add 1 per axis).</li>
* </ul>
*/
class DefineValidationTest { class DefineValidationTest {
// ---------- name regex ----------
@Test @Test
void validName_acceptsAlnumUnderscoreDash() { void validName_acceptsAlnumUnderscoreDash() {
assertTrue(DefineValidation.isValidName("abc")); assertTrue(DefineValidation.isValidName("abc"));
@@ -36,7 +24,7 @@ class DefineValidationTest {
assertFalse(DefineValidation.isValidName("")); assertFalse(DefineValidation.isValidName(""));
assertFalse(DefineValidation.isValidName(" ")); assertFalse(DefineValidation.isValidName(" "));
assertFalse(DefineValidation.isValidName("my zone")); assertFalse(DefineValidation.isValidName("my zone"));
assertFalse(DefineValidation.isValidName("nom avec espaces")); assertFalse(DefineValidation.isValidName("name with spaces"));
assertFalse(DefineValidation.isValidName("a\tb")); assertFalse(DefineValidation.isValidName("a\tb"));
assertFalse(DefineValidation.isValidName(" leading")); assertFalse(DefineValidation.isValidName(" leading"));
assertFalse(DefineValidation.isValidName("trailing ")); assertFalse(DefineValidation.isValidName("trailing "));
@@ -47,8 +35,6 @@ class DefineValidationTest {
assertFalse(DefineValidation.isValidName("has.dot")); assertFalse(DefineValidation.isValidName("has.dot"));
} }
// ---------- componentwise min/max ----------
@Test @Test
void componentwiseMin_returnsSmallestPerAxis() { void componentwiseMin_returnsSmallestPerAxis() {
int[] a = {5, 10, -3}; int[] a = {5, 10, -3};
@@ -75,22 +61,13 @@ class DefineValidationTest {
DefineValidation.componentwiseMax(b, a)); DefineValidation.componentwiseMax(b, a));
} }
// ---------- inflate-max convention (block inclusion) ---------- /** Blocks occupy the unit cube [x,x+1], so max must be inflated by +1 per axis to include the max block. */
/**
* A block occupies the cube between (x,y,z) and (x+1,y+1,z+1). If an AABB's max is the
* raw block coord (maxBlockX, maxBlockY, maxBlockZ), a player standing on top of that
* block is OUTSIDE the AABB. We therefore inflate max by +1 per axis.
*/
@Test @Test
void boxFromCorners_inflateMax_includesMaxBlock() { void boxFromCorners_inflateMax_includesMaxBlock() {
int[] mn = {0, 64, 0}; int[] mn = {0, 64, 0};
int[] mx = {10, 70, 10}; int[] mx = {10, 70, 10};
// Player feet at (10.5, 70.5, 10.5) — standing in the maxBlock cube.
double px = 10.5, py = 70.5, pz = 10.5; double px = 10.5, py = 70.5, pz = 10.5;
// Without inflate: max = (10,70,10) — player OUT.
assertFalse(px < mx[0] && py < mx[1] && pz < mx[2]); assertFalse(px < mx[0] && py < mx[1] && pz < mx[2]);
// With inflate: max = (11,71,11) — player IN.
int[] mxInflated = {mx[0] + 1, mx[1] + 1, mx[2] + 1}; int[] mxInflated = {mx[0] + 1, mx[1] + 1, mx[2] + 1};
assertTrue(px < mxInflated[0] && py < mxInflated[1] && pz < mxInflated[2]); assertTrue(px < mxInflated[0] && py < mxInflated[1] && pz < mxInflated[2]);
} }
@@ -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(
@@ -12,11 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
/** /** Tests for {@link WandSelectionStore}: set/get per UUID, overwrite, clear, concurrency. */
* Pure-data tests for {@link WandSelectionStore} — no Hytale runtime dependency.
* Covers set/get per UUID, overwrite semantics, unknown UUID default, clear,
* and concurrent writes.
*/
class WandSelectionStoreTest { class WandSelectionStoreTest {
@Test @Test