Compare commits
10 Commits
4a9602fee3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cc6c022e15 | |||
| 7b67410698 | |||
| 2b23d87c80 | |||
| c20bf42c36 | |||
| 24ee43b3a3 | |||
| 0b191c6504 | |||
| 6a830ed285 | |||
| 6b28dc2d2a | |||
| 15fc0702f1 | |||
| b8754617d4 |
@@ -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 (0–1) |
|
||||
| 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.
|
||||
@@ -23,25 +23,11 @@ import com.hypixel.hytale.server.core.modules.interaction.interaction.config.Int
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Entry point for the Gravity Flip plugin.
|
||||
*
|
||||
* <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.
|
||||
*/
|
||||
/** Entry point for the Gravity Flip plugin (Hytale Server API setup/shutdown lifecycle). */
|
||||
public class GravityFlipPlugin extends JavaPlugin {
|
||||
|
||||
/**
|
||||
* Persisted region store, materialised on disk as
|
||||
* {@code <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}.
|
||||
*/
|
||||
// Persisted region store at <dataDirectory>/regions.json. The named withConfig(name, codec)
|
||||
// overload is REQUIRED — the 1-arg overload hardcodes the filename to config.json.
|
||||
private final Config<GravityFlipConfig> configHolder =
|
||||
withConfig("regions", GravityFlipConfig.CODEC);
|
||||
|
||||
@@ -58,28 +44,18 @@ public class GravityFlipPlugin extends JavaPlugin {
|
||||
|
||||
@Override
|
||||
protected void setup() {
|
||||
// NOTE: do NOT call configHolder.get() here — it blocks until preLoad() completes.
|
||||
// Safe call sites are start() and any later lifecycle phase (incl. tick loop).
|
||||
// Do NOT call configHolder.get() here — it blocks until preLoad() completes.
|
||||
// Safe call sites are start() and any later lifecycle phase.
|
||||
//
|
||||
// World acquisition note (Phase 02-02): the plan called for a PrepareUniverseEvent
|
||||
// listener that stashes a World reference. Empirically, PrepareUniverseEvent
|
||||
// (com.hypixel.hytale.server.core.event.events.PrepareUniverseEvent) only carries
|
||||
// a WorldConfigProvider — it does NOT expose a Universe or World. We therefore use
|
||||
// a Supplier<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(...).
|
||||
// World acquisition: PrepareUniverseEvent only exposes a WorldConfigProvider, not a
|
||||
// Universe/World. We use a Supplier<World> that resolves Universe.get().getDefaultWorld()
|
||||
// lazily on each tick; until the universe is ready, the supplier returns null and the
|
||||
// tick is a no-op.
|
||||
this.fallDamageGuard = new FallDamageGuard();
|
||||
getEntityStoreRegistry().registerSystem(new FallDamageSuppressorSystem(
|
||||
fallDamageGuard,
|
||||
th -> getLogger().at(Level.WARNING).withCause(th).log("fallDamageSuppressor handle failed")));
|
||||
|
||||
// Plan 04-01 : Gravity Flip wand.
|
||||
// Interaction binding pattern per 04-00 SPIKE-RESULT (Finding 3) — same shape as
|
||||
// InstancesPlugin.java:158 / ExitInstanceInteraction. The JSON Item at
|
||||
// src/main/resources/Items/gravityflip_wand.json references this Type in
|
||||
// Interactions.Primary / Interactions.Secondary.
|
||||
this.wandSelectionStore = new WandSelectionStore();
|
||||
GravityFlipWandInteraction.bindStore(this.wandSelectionStore);
|
||||
getCodecRegistry(Interaction.CODEC).register(
|
||||
@@ -107,25 +83,20 @@ public class GravityFlipPlugin extends JavaPlugin {
|
||||
this.tickLoop = new RegionTickLoop(registry, gravityApplier, regionVisualizer, th ->
|
||||
getLogger().at(Level.WARNING).withCause(th).log("detectTick failed"));
|
||||
|
||||
// Lazy world resolution — see setup() comment.
|
||||
this.tickLoop.startWithDelay(2_000L, () -> {
|
||||
Universe u = Universe.get();
|
||||
return u == null ? null : u.getDefaultWorld();
|
||||
});
|
||||
|
||||
// TaskRegistry registration: registerTask only accepts ScheduledFuture<Void>; the
|
||||
// scheduler returns ScheduledFuture<?>. Cast via raw types per Mythlane idiom; the
|
||||
// try/catch falls back to manual shutdown() if registration fails (deterministic
|
||||
// either way because shutdown() always invokes tickLoop.stop()).
|
||||
// TaskRegistry.registerTask only accepts ScheduledFuture<Void>; the scheduler returns
|
||||
// ScheduledFuture<?>. Cast via raw types; the fallback keeps teardown deterministic
|
||||
// because shutdown() always invokes tickLoop.stop().
|
||||
try {
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
ScheduledFuture<Void> vf = (ScheduledFuture) tickLoop.future();
|
||||
getTaskRegistry().registerTask(vf);
|
||||
} catch (Throwable ignored) { /* manual shutdown() fallback */ }
|
||||
|
||||
// Plan 04-02 : enregistrer la commande racine /gravityflip + sous-commande `wand`.
|
||||
// Pattern : CommandRegistry.register(...) ajoute automatiquement un shutdownTask qui
|
||||
// unregister au teardown (cf. CommandRegistry base class). Pas de cleanup manuel.
|
||||
getCommandRegistry().registerCommand(new GravityFlipCommand(this));
|
||||
|
||||
getLogger().at(Level.INFO).log(
|
||||
@@ -135,28 +106,22 @@ public class GravityFlipPlugin extends JavaPlugin {
|
||||
|
||||
@Override
|
||||
protected void shutdown() {
|
||||
// Plan 03-05 : clear des debug shapes cote clients AVANT tickLoop.stop().
|
||||
// Si l'Universe est deja fermee, world==null => shapes expireront via TTL (acceptable).
|
||||
// Clear debug shapes on clients BEFORE stopping the tick loop. If the universe is already
|
||||
// torn down, world==null and shapes will expire via TTL (acceptable).
|
||||
if (regionVisualizer != null) {
|
||||
Universe u = Universe.get();
|
||||
World w = (u == null) ? null : u.getDefaultWorld();
|
||||
if (w != null) regionVisualizer.clearAll(w);
|
||||
}
|
||||
// Stop the detector BEFORE super.shutdown() so no tick races plugin teardown.
|
||||
// Stop the detector BEFORE super.shutdown() so no tick races the plugin teardown.
|
||||
if (tickLoop != null) tickLoop.stop();
|
||||
// No auto-save contract: any mutation made during the session must already
|
||||
// have been persisted via configHolder().save() by the command handler that
|
||||
// performed it. See configHolder() javadoc.
|
||||
// No auto-save contract: mutations made during the session must already have been
|
||||
// persisted via configHolder().save() by the command handler that performed them.
|
||||
getLogger().at(Level.INFO).log("Gravity Flip disabled");
|
||||
super.shutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Showcase zone seeded on first run (when {@code regions.json} is absent or empty).
|
||||
* 10×20×10 box at {@code (0,100,0)..(10,120,10)} with Torch_Fire particles and a
|
||||
* gentle upward force — lets a fresh install demonstrate the feature immediately.
|
||||
* Users are free to edit or delete this zone via {@code regions.json}.
|
||||
*/
|
||||
/** Showcase zone seeded on first run (10x20x10 box at (0,100,0)..(10,120,10) with Torch_Fire particles). */
|
||||
private static GravityFlipRegion buildShowcaseRegion() {
|
||||
GravityFlipRegion r = new GravityFlipRegion(
|
||||
"demo-gravity-flip",
|
||||
@@ -170,25 +135,17 @@ public class GravityFlipPlugin extends JavaPlugin {
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Exposed for Phase 3 (gravity physics) and Phase 4 (commands). */
|
||||
/** Exposes the region registry to commands and physics code. */
|
||||
public RegionRegistry regions() { return registry; }
|
||||
|
||||
/**
|
||||
* Per-player wand selection store. Populated by
|
||||
* {@link GravityFlipWandInteraction}; consumed by
|
||||
* {@code /gravityflip define} (Phase 04-02+).
|
||||
* <p>Returns {@code null} until {@link #setup()} has run.
|
||||
*/
|
||||
/** Per-player wand selection store; returns null until {@link #setup()} has run. */
|
||||
public WandSelectionStore wandSelections() { return wandSelectionStore; }
|
||||
|
||||
/**
|
||||
* Accessor for the region config holder. <strong>SAVE CONTRACT:</strong> any
|
||||
* caller that mutates {@code configHolder().get().getRegions()} MUST call
|
||||
* {@code configHolder().save()} afterwards. There is no lifecycle hook that
|
||||
* auto-saves on shutdown. {@code Config.get()} returns a SHARED MUTABLE
|
||||
* reference; concurrent writers corrupt state — Phase 02-02's
|
||||
* {@code RegionRegistry} snapshots it into an {@code AtomicReference} for
|
||||
* tick-loop reads.
|
||||
* Accessor for the region config holder. SAVE CONTRACT: any caller that mutates
|
||||
* {@code configHolder().get().getRegions()} MUST call {@code configHolder().save()} afterwards.
|
||||
* {@code Config.get()} returns a SHARED MUTABLE reference; concurrent writers corrupt state —
|
||||
* {@code RegionRegistry} snapshots it into an {@code AtomicReference} for tick-loop reads.
|
||||
*/
|
||||
public Config<GravityFlipConfig> configHolder() {
|
||||
return configHolder;
|
||||
|
||||
@@ -2,38 +2,19 @@ package com.mythlane.gravityflip.command;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 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}.
|
||||
*/
|
||||
/** Pure-data validation helpers for the define sub-command (region name regex, componentwise min/max). */
|
||||
public final class DefineValidation {
|
||||
|
||||
private static final Pattern NAME = Pattern.compile("^[a-zA-Z0-9_-]{1,32}$");
|
||||
|
||||
private DefineValidation() {}
|
||||
|
||||
/**
|
||||
* Returns {@code true} iff {@code n} matches {@code ^[a-zA-Z0-9_-]{1,32}$}.
|
||||
* Rejects {@code null}, blank, spaces, path separators, non-ASCII.
|
||||
*/
|
||||
/** Returns true iff the name matches {@code ^[a-zA-Z0-9_-]{1,32}$}. */
|
||||
public static boolean isValidName(String n) {
|
||||
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) {
|
||||
return new int[]{
|
||||
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) {
|
||||
return new int[]{
|
||||
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.mythlane.gravityflip.GravityFlipPlugin;
|
||||
|
||||
/**
|
||||
* 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()}.
|
||||
*/
|
||||
/** Root command {@code /gravityflip} — aggregates all sub-commands. */
|
||||
public final class GravityFlipCommand extends AbstractCommandCollection {
|
||||
|
||||
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 GravityFlipDefineSubCommand(plugin));
|
||||
this.addSubCommand(new GravityFlipListSubCommand(plugin));
|
||||
|
||||
@@ -20,42 +20,16 @@ import javax.annotation.Nonnull;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* {@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.
|
||||
*/
|
||||
/** Creates and persists a new region from the caller's wand selection. */
|
||||
public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand {
|
||||
|
||||
private final GravityFlipPlugin plugin;
|
||||
|
||||
/** Required STRING arg; full validation applied via {@link DefineValidation#isValidName}. */
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -68,7 +42,7 @@ public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand {
|
||||
String name = nameArg.get(ctx);
|
||||
if (!DefineValidation.isValidName(name)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -76,14 +50,13 @@ public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand {
|
||||
WandSelectionStore.Selection sel = plugin.wandSelections().get(uuid);
|
||||
if (sel.pos1 == null || sel.pos2 == null) {
|
||||
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;
|
||||
}
|
||||
|
||||
int[] mn = DefineValidation.componentwiseMin(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
|
||||
// (see DefineValidationTest#boxFromCorners_inflateMax_includesMaxBlock).
|
||||
// Inflate max by +1 per axis so the max block is INSIDE the AABB (blocks occupy the unit cube [x,x+1]).
|
||||
Box box = new Box(
|
||||
new Vector3d(mn[0], mn[1], mn[2]),
|
||||
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);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
ctx.sendMessage(Message.raw(
|
||||
"[gravityflip] Une région nommée '" + name + "' existe déjà."));
|
||||
"[gravityflip] A region named '" + name + "' already exists."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,14 +76,14 @@ public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand {
|
||||
plugin.getLogger().at(Level.WARNING).withCause(th)
|
||||
.log("[define] save failed for region '" + name + "'");
|
||||
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;
|
||||
}
|
||||
|
||||
plugin.wandSelections().clear(uuid);
|
||||
ctx.sendMessage(Message.raw(
|
||||
"[gravityflip] Région '" + name + "' créée : "
|
||||
+ "(" + mn[0] + "," + mn[1] + "," + mn[2] + ") → "
|
||||
"[gravityflip] Region '" + name + "' created: "
|
||||
+ "(" + mn[0] + "," + mn[1] + "," + mn[2] + ") -> "
|
||||
+ "(" + (mx[0] + 1) + "," + (mx[1] + 1) + "," + (mx[2] + 1) + ")"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,30 +10,16 @@ import com.mythlane.gravityflip.GravityFlipPlugin;
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* {@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>
|
||||
*/
|
||||
/** Deletes a Gravity Flip region and persists the change. */
|
||||
public final class GravityFlipDeleteSubCommand extends CommandBase {
|
||||
|
||||
private final GravityFlipPlugin plugin;
|
||||
|
||||
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) {
|
||||
super("delete", "Supprime une région Gravity Flip");
|
||||
super("delete", "Delete a Gravity Flip region");
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@@ -41,7 +27,7 @@ public final class GravityFlipDeleteSubCommand extends CommandBase {
|
||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
||||
String name = nameArg.get(ctx);
|
||||
if (!plugin.regions().remove(name)) {
|
||||
ctx.sendMessage(Message.raw("[gravityflip] Région '" + name + "' introuvable."));
|
||||
ctx.sendMessage(Message.raw("[gravityflip] Region '" + name + "' not found."));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -50,9 +36,9 @@ public final class GravityFlipDeleteSubCommand extends CommandBase {
|
||||
plugin.getLogger().at(Level.WARNING).withCause(th)
|
||||
.log("[delete] save failed for region '" + name + "'");
|
||||
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;
|
||||
}
|
||||
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 java.util.Collection;
|
||||
|
||||
/**
|
||||
* {@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.
|
||||
*/
|
||||
/** Lists all persisted Gravity Flip regions. */
|
||||
public final class GravityFlipListSubCommand extends CommandBase {
|
||||
|
||||
private final 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;
|
||||
}
|
||||
|
||||
@@ -40,15 +24,15 @@ public final class GravityFlipListSubCommand extends CommandBase {
|
||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
||||
Collection<GravityFlipRegion> all = plugin.regions().all();
|
||||
if (all.isEmpty()) {
|
||||
ctx.sendMessage(Message.raw("[gravityflip] Aucune région définie."));
|
||||
ctx.sendMessage(Message.raw("[gravityflip] No regions defined."));
|
||||
return;
|
||||
}
|
||||
ctx.sendMessage(Message.raw("[gravityflip] " + all.size() + " région(s) :"));
|
||||
ctx.sendMessage(Message.raw("[gravityflip] " + all.size() + " region(s):"));
|
||||
for (GravityFlipRegion r : all) {
|
||||
Vector3d mn = r.getMin();
|
||||
Vector3d mx = r.getMax();
|
||||
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.isEnabled() ? "enabled" : "disabled")));
|
||||
}
|
||||
|
||||
@@ -11,50 +11,31 @@ import com.mythlane.gravityflip.region.GravityFlipRegion;
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* {@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).
|
||||
*/
|
||||
/** Enables/disables a Gravity Flip region and persists the change. */
|
||||
public final class GravityFlipToggleSubCommand extends CommandBase {
|
||||
|
||||
private final GravityFlipPlugin plugin;
|
||||
|
||||
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) {
|
||||
super("toggle", "Active/désactive une région Gravity Flip");
|
||||
super("toggle", "Enable/disable a Gravity Flip region");
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
||||
String name = nameArg.get(ctx);
|
||||
GravityFlipRegion found = null;
|
||||
for (GravityFlipRegion r : plugin.regions().all()) {
|
||||
if (r.getName().equals(name)) {
|
||||
found = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
GravityFlipRegion found = plugin.regions().find(name);
|
||||
if (found == null) {
|
||||
ctx.sendMessage(Message.raw("[gravityflip] Région '" + name + "' introuvable."));
|
||||
ctx.sendMessage(Message.raw("[gravityflip] Region '" + name + "' not found."));
|
||||
return;
|
||||
}
|
||||
boolean next = !found.isEnabled();
|
||||
if (!plugin.regions().setEnabled(name, next)) {
|
||||
// Course ultra-rare : région supprimée entre all() et setEnabled().
|
||||
ctx.sendMessage(Message.raw("[gravityflip] Région '" + name + "' introuvable."));
|
||||
// Rare race: region removed between all() and setEnabled().
|
||||
ctx.sendMessage(Message.raw("[gravityflip] Region '" + name + "' not found."));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -63,10 +44,10 @@ public final class GravityFlipToggleSubCommand extends CommandBase {
|
||||
plugin.getLogger().at(Level.WARNING).withCause(th)
|
||||
.log("[toggle] save failed for region '" + name + "'");
|
||||
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;
|
||||
}
|
||||
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;
|
||||
|
||||
/**
|
||||
* {@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).
|
||||
*/
|
||||
/** Teleports the calling player to the center of a Gravity Flip region's AABB. */
|
||||
public final class GravityFlipTpSubCommand extends AbstractPlayerCommand {
|
||||
|
||||
private final GravityFlipPlugin plugin;
|
||||
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -61,15 +40,9 @@ public final class GravityFlipTpSubCommand extends AbstractPlayerCommand {
|
||||
@Nonnull PlayerRef playerRef,
|
||||
@Nonnull World world) {
|
||||
String name = nameArg.get(ctx);
|
||||
GravityFlipRegion target = null;
|
||||
for (GravityFlipRegion r : plugin.regions().all()) {
|
||||
if (r.getName().equals(name)) {
|
||||
target = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
GravityFlipRegion target = plugin.regions().find(name);
|
||||
if (target == null) {
|
||||
ctx.sendMessage(Message.raw("[gravityflip] Région '" + name + "' introuvable."));
|
||||
ctx.sendMessage(Message.raw("[gravityflip] Region '" + name + "' not found."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,13 +55,12 @@ public final class GravityFlipTpSubCommand extends AbstractPlayerCommand {
|
||||
TransformComponent tc = store.getComponent(ref, TransformComponent.getComponentType());
|
||||
if (tc == null) {
|
||||
ctx.sendMessage(Message.raw(
|
||||
"[gravityflip] TransformComponent manquant — tp impossible."));
|
||||
"[gravityflip] TransformComponent missing — teleport impossible."));
|
||||
return;
|
||||
}
|
||||
HeadRotation hr = store.getComponent(ref, HeadRotation.getComponentType());
|
||||
|
||||
// Préserve l'orientation courante : body rotation depuis Transform, head rotation
|
||||
// si disponible (sinon on laisse la default côté Teleport.createForPlayer).
|
||||
// Preserve current orientation: body rotation from Transform, head rotation when available.
|
||||
Vector3f bodyRotation = tc.getRotation().clone();
|
||||
Teleport teleport = Teleport.createForPlayer(
|
||||
new Vector3d(cx, cy, cz), bodyRotation);
|
||||
@@ -98,7 +70,7 @@ public final class GravityFlipTpSubCommand extends AbstractPlayerCommand {
|
||||
store.addComponent(ref, Teleport.getComponentType(), teleport);
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,27 +14,13 @@ import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* {@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).
|
||||
*/
|
||||
/** {@code /gravityflip wand} — gives a Gravity Flip Wand to the calling player. */
|
||||
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";
|
||||
|
||||
public GravityFlipWandSubCommand() {
|
||||
super("wand", "Obtenir un Gravity Flip Wand");
|
||||
super("wand", "Get a Gravity Flip Wand");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -47,22 +33,22 @@ public final class GravityFlipWandSubCommand extends AbstractPlayerCommand {
|
||||
if (item == null) {
|
||||
context.sendMessage(Message.raw(
|
||||
"[gravityflip] Item '" + WAND_ITEM_ID
|
||||
+ "' introuvable — asset pas chargé ?"));
|
||||
+ "' not found — asset not loaded?"));
|
||||
return;
|
||||
}
|
||||
Player playerComponent = store.getComponent(ref, Player.getComponentType());
|
||||
if (playerComponent == null) {
|
||||
context.sendMessage(Message.raw("[gravityflip] Player component manquant."));
|
||||
context.sendMessage(Message.raw("[gravityflip] Player component missing."));
|
||||
return;
|
||||
}
|
||||
ItemStack stack = new ItemStack(item.getId(), 1, null);
|
||||
ItemStackTransaction transaction = playerComponent.giveItem(stack, ref, store);
|
||||
ItemStack remainder = transaction.getRemainder();
|
||||
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 {
|
||||
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.List;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Root config wrapping the persisted list of {@link GravityFlipRegion}s (persisted as regions.json). */
|
||||
public final class GravityFlipConfig {
|
||||
|
||||
public static final BuilderCodec<GravityFlipConfig> CODEC =
|
||||
@@ -30,7 +17,7 @@ public final class GravityFlipConfig {
|
||||
.append(
|
||||
new KeyedCodec<>("Regions",
|
||||
new ArrayCodec<>(GravityFlipRegion.CODEC, GravityFlipRegion[]::new)),
|
||||
// Decode setter: wrap in a MUTABLE ArrayList so commands can add/remove.
|
||||
// Decode into a MUTABLE ArrayList so commands can add/remove at runtime.
|
||||
(c, arr) -> c.regions = new ArrayList<>(Arrays.asList(arr)),
|
||||
c -> c.regions.toArray(GravityFlipRegion[]::new)
|
||||
).add()
|
||||
|
||||
@@ -6,56 +6,30 @@ import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Thread-safe tracker that decides whether fall damage should be suppressed for a given UUID
|
||||
* based on (a) current in-region membership, (b) post-exit grace window.
|
||||
*
|
||||
* <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>
|
||||
* Thread-safe tracker that decides whether fall damage should be suppressed for a given UUID,
|
||||
* based on current in-region membership and a post-exit grace window.
|
||||
*/
|
||||
public final class FallDamageGuard {
|
||||
|
||||
/** Entity currently inside a region (cleared on exit). */
|
||||
private final ConcurrentHashMap<UUID, GravityFlipRegion> currentRegionByUuid =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/** Timestamp (ms) at which the entity last exited a region. */
|
||||
private final ConcurrentHashMap<UUID, Long> exitTimestampByUuid =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/** Region referenced at the moment of exit (used to read FallDamage + GracePeriodMs). */
|
||||
private final ConcurrentHashMap<UUID, GravityFlipRegion> regionAtExitByUuid =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/** Marks an entity as currently inside the given region (clears any stale grace entry). */
|
||||
public void markInRegion(UUID uuid, GravityFlipRegion region) {
|
||||
if (uuid == null || region == null) return;
|
||||
currentRegionByUuid.put(uuid, region);
|
||||
// Re-entry ⇒ discard any stale grace entry (grace is a post-exit concept).
|
||||
// Re-entry discards any stale grace entry — grace is a post-exit concept.
|
||||
exitTimestampByUuid.remove(uuid);
|
||||
regionAtExitByUuid.remove(uuid);
|
||||
}
|
||||
|
||||
/** Records the exit timestamp and the region the entity just left. */
|
||||
public void markExit(UUID uuid, GravityFlipRegion region, long nowMs) {
|
||||
if (uuid == null || region == null) return;
|
||||
currentRegionByUuid.remove(uuid);
|
||||
@@ -63,6 +37,7 @@ public final class FallDamageGuard {
|
||||
regionAtExitByUuid.put(uuid, region);
|
||||
}
|
||||
|
||||
/** Returns true iff fall damage must be cancelled for this UUID at the given time. */
|
||||
public boolean shouldSuppressFallDamage(UUID uuid, long nowMs) {
|
||||
if (uuid == null) return false;
|
||||
|
||||
@@ -74,11 +49,11 @@ public final class FallDamageGuard {
|
||||
Long exitMs = exitTimestampByUuid.get(uuid);
|
||||
GravityFlipRegion exitRegion = regionAtExitByUuid.get(uuid);
|
||||
if (exitMs == null || exitRegion == null) return false;
|
||||
if (exitRegion.isFallDamage()) return false; // region allowed fall damage → no grace
|
||||
if (exitRegion.isFallDamage()) return false;
|
||||
return (nowMs - exitMs) <= exitRegion.getGracePeriodMs();
|
||||
}
|
||||
|
||||
// ---- Test hooks / diagnostics (package-private) ----
|
||||
// Test hooks (package-private).
|
||||
|
||||
int trackedInRegionCount() { return currentRegionByUuid.size(); }
|
||||
int trackedGraceCount() { return exitTimestampByUuid.size(); }
|
||||
|
||||
@@ -17,21 +17,7 @@ import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* ECS {@link DamageEventSystem} that cancels {@link Damage} events of cause {@code Fall}
|
||||
* for any entity whose UUID is currently suppressed by {@link FallDamageGuard}.
|
||||
*
|
||||
* <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.
|
||||
*/
|
||||
/** Cancels {@link Damage} events of cause {@code Fall} for any UUID suppressed by {@link FallDamageGuard}. */
|
||||
public final class FallDamageSuppressorSystem extends DamageEventSystem {
|
||||
|
||||
private final FallDamageGuard guard;
|
||||
|
||||
@@ -28,44 +28,12 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Tick-driven service that toggles the native {@code PhysicsValues.invertedGravity} flag on every
|
||||
* entity present in an enabled {@link GravityFlipRegion}. Mutations are queued via
|
||||
* {@code CommandBuffer.replaceComponent(...)} inside a {@code Store.forEachEntityParallel(...)}
|
||||
* lambda — the ECS engine commits them on the main thread after the parallel pass.
|
||||
*
|
||||
* <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).
|
||||
* Tick-driven service that toggles {@code PhysicsValues.invertedGravity} on every entity currently
|
||||
* inside an enabled region, wakes up players/NPCs so the new settings take effect, and drives the
|
||||
* fall-damage guard on entry/exit transitions.
|
||||
*/
|
||||
public final class GravityApplier {
|
||||
// Lazy ComponentType holders — pattern identique à RegionRegistry.transform()
|
||||
// (évite static-init Hytale PluginBase pendant les tests).
|
||||
// Lazy ComponentType holders — avoids Hytale PluginBase static init during tests.
|
||||
private static volatile ComponentType<EntityStore, PhysicsValues> physicsType;
|
||||
private static volatile ComponentType<EntityStore, UUIDComponent> uuidType;
|
||||
private static volatile ComponentType<EntityStore, TransformComponent> transformType;
|
||||
@@ -145,7 +113,7 @@ public final class GravityApplier {
|
||||
return t;
|
||||
}
|
||||
|
||||
// THREADING: écrit/lu depuis les workers ECS via forEachEntityParallel → ConcurrentHashMap.newKeySet obligatoire.
|
||||
// Written/read from ECS worker threads via forEachEntityParallel — concurrent collection required.
|
||||
private final Set<UUID> previouslyInverted = ConcurrentHashMap.newKeySet();
|
||||
/** First-matched region per UUID at the previous tick — consulted in Pass 2 for markExit. */
|
||||
private final ConcurrentHashMap<UUID, GravityFlipRegion> lastKnownRegion = new ConcurrentHashMap<>();
|
||||
@@ -162,21 +130,17 @@ public final class GravityApplier {
|
||||
this.guard = guard == null ? new FallDamageGuard() : guard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit un nouveau {@link PhysicsValues} en copiant mass/drag de la source et en fixant
|
||||
* {@code invertedGravity=target}. Pure data — pas d'effet de bord.
|
||||
*/
|
||||
/** Builds a new PhysicsValues copying mass/drag from the source and setting invertedGravity. */
|
||||
static PhysicsValues buildPhysicsValuesWithFlag(PhysicsValues source, boolean target) {
|
||||
FlaggedDecision d = buildFlaggedDecision(source.getMass(), source.getDragCoefficient(), target);
|
||||
return new PhysicsValues(d.mass, d.drag, d.invertedGravity);
|
||||
}
|
||||
|
||||
/** Pure-data seam pour tests unitaires (aucune dépendance sur PhysicsValues). */
|
||||
/** Pure-data seam for unit tests — PhysicsValues static init is unavailable outside the server runtime. */
|
||||
static FlaggedDecision buildFlaggedDecision(double mass, double drag, boolean target) {
|
||||
return new FlaggedDecision(mass, drag, target);
|
||||
}
|
||||
|
||||
/** Holder pure-data pour la décomposition testable de {@link #buildPhysicsValuesWithFlag}. */
|
||||
static final class FlaggedDecision {
|
||||
final double mass;
|
||||
final double drag;
|
||||
@@ -186,7 +150,7 @@ public final class GravityApplier {
|
||||
}
|
||||
}
|
||||
|
||||
/** Tick entry point. NO-OP si world ou snapshot est null. */
|
||||
/** Tick entry point; no-op when world or snapshot is null. */
|
||||
public void apply(World world, RegionSnapshot snapshot) {
|
||||
if (world == null || snapshot == null) return;
|
||||
world.execute(() -> applyOnWorldThread(world, snapshot));
|
||||
@@ -205,8 +169,9 @@ public final class GravityApplier {
|
||||
ComponentType<EntityStore, Player> PLT = playerType();
|
||||
ComponentType<EntityStore, NPCEntity> NPCT = npcEntityType();
|
||||
|
||||
// PASS 1 — pour chaque entité avec PhysicsValues : si dans une région activée, queue le flip ON
|
||||
// via cmdBuf.replaceComponent ET wake-up MovementManager / MotionController.
|
||||
// PASS 1 — flip ON for every entity with PhysicsValues that is inside an enabled region.
|
||||
// Pass 1 and Pass 2 cannot be fused: restore requires the complete currentlyInRegion set
|
||||
// to diff against previouslyInverted.
|
||||
Set<UUID> currentlyInRegion = ConcurrentHashMap.newKeySet();
|
||||
store.forEachEntityParallel(PHYST, (index, chunk, cmdBuf) -> {
|
||||
TransformComponent t;
|
||||
@@ -222,7 +187,7 @@ public final class GravityApplier {
|
||||
com.hypixel.hytale.math.vector.Vector3d pos = t.getPosition();
|
||||
double x = pos.x, y = pos.y, z = pos.z;
|
||||
|
||||
// First-match wins for multi-region precedence (Plan 03-04).
|
||||
// First-match wins for multi-region precedence.
|
||||
GravityFlipRegion matchedRegion = null;
|
||||
for (GravityFlipRegion r : enabledRegions) {
|
||||
if (r.asBox().containsPosition(x, y, z)) { matchedRegion = r; break; }
|
||||
@@ -231,7 +196,6 @@ public final class GravityApplier {
|
||||
|
||||
UUID u = uc.getUuid();
|
||||
|
||||
// --- Plan 03-04 : AffectXxx filters applied BEFORE wake ---
|
||||
EntityKind kind = classify(chunk, index, MMT, PRT, PLT, NPCT);
|
||||
boolean allowed;
|
||||
switch (kind) {
|
||||
@@ -240,8 +204,7 @@ public final class GravityApplier {
|
||||
default: allowed = matchedRegion.isAffectItems(); break;
|
||||
}
|
||||
if (!allowed) {
|
||||
// Entité filtrée : ne PAS la compter dans currentlyInRegion, et
|
||||
// ne PAS notifier le guard — le filtre se comporte comme hors-zone.
|
||||
// Filtered entity behaves as if outside the region.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -249,19 +212,17 @@ public final class GravityApplier {
|
||||
lastKnownRegion.put(u, matchedRegion);
|
||||
guard.markInRegion(u, matchedRegion);
|
||||
|
||||
// --- Flip ECS native (plan 03-01) ---
|
||||
if (!v.isInvertedGravity()) {
|
||||
Ref<EntityStore> ref = chunk.getReferenceTo(index);
|
||||
cmdBuf.replaceComponent(ref, PHYST,
|
||||
new PhysicsValues(v.getMass(), v.getDragCoefficient(), true));
|
||||
}
|
||||
|
||||
// --- Wake-up joueur/NPC (plan 03-02) + seed VerticalForce (plan 03-03 paramétré 03-04) ---
|
||||
wakePlayerOrNpc(chunk, index, v, true, matchedRegion,
|
||||
MMT, PRT, PLT, NPCT);
|
||||
});
|
||||
|
||||
// PASS 2 — restore : pour chaque UUID dans previouslyInverted \ currentlyInRegion.
|
||||
// PASS 2 — restore: for every UUID in previouslyInverted \ currentlyInRegion.
|
||||
Set<UUID> toRestore = ConcurrentHashMap.newKeySet();
|
||||
toRestore.addAll(previouslyInverted);
|
||||
toRestore.removeAll(currentlyInRegion);
|
||||
@@ -284,11 +245,8 @@ public final class GravityApplier {
|
||||
new PhysicsValues(v.getMass(), v.getDragCoefficient(), false));
|
||||
}
|
||||
|
||||
// Wake-up avec flag=false pour restaurer les settings natifs.
|
||||
wakePlayerOrNpc(chunk, index, v, false, null, MMT, PRT, PLT, NPCT);
|
||||
|
||||
// Plan 03-04 : notifier guard.markExit avec la région last-known
|
||||
// (première région matchée au tick précédent).
|
||||
GravityFlipRegion lastRegion = lastKnownRegion.remove(u);
|
||||
if (lastRegion != null) {
|
||||
guard.markExit(u, lastRegion, now);
|
||||
@@ -296,7 +254,7 @@ public final class GravityApplier {
|
||||
});
|
||||
}
|
||||
|
||||
// Update tracker — ces ops sont sur le tick thread après la fin du pass parallel.
|
||||
// Tick-thread update after the parallel pass completes.
|
||||
previouslyInverted.clear();
|
||||
previouslyInverted.addAll(currentlyInRegion);
|
||||
} catch (Throwable th) {
|
||||
@@ -306,7 +264,7 @@ public final class GravityApplier {
|
||||
|
||||
private enum EntityKind { PLAYER, NPC, OTHER }
|
||||
|
||||
/** Classify the entity at {@code index} into player / NPC / other (items fall into other). */
|
||||
/** Classifies the entity at {@code index} into player / NPC / other (items fall into other). */
|
||||
private EntityKind classify(ArchetypeChunk<EntityStore> chunk, int index,
|
||||
ComponentType<EntityStore, MovementManager> MMT,
|
||||
ComponentType<EntityStore, PlayerRef> PRT,
|
||||
@@ -328,15 +286,7 @@ public final class GravityApplier {
|
||||
return EntityKind.OTHER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake-up dans le MÊME lambda parallèle :
|
||||
* - joueur (MovementManager + PlayerRef + Player) → setDefaultSettings + applyDefaultSettings + update(ph)
|
||||
* - NPC (NPCEntity avec role non-null et active controller non-null) → updatePhysicsValues(targetValues)
|
||||
* - sinon (items, autres) : no-op (le flip cmdBuf.replaceComponent du pass 1 suffit)
|
||||
*
|
||||
* <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.
|
||||
*/
|
||||
/** Wakes up the entity so the new PhysicsValues take effect (player movement refresh or NPC controller update). */
|
||||
private void wakePlayerOrNpc(
|
||||
ArchetypeChunk<EntityStore> chunk, int index,
|
||||
PhysicsValues sourceValues, boolean targetFlag,
|
||||
@@ -347,7 +297,7 @@ public final class GravityApplier {
|
||||
ComponentType<EntityStore, NPCEntity> NPCT) {
|
||||
PhysicsValues targetValues = buildPhysicsValuesWithFlag(sourceValues, targetFlag);
|
||||
|
||||
// --- Branche joueur ---
|
||||
// Player branch.
|
||||
MovementManager mm = null;
|
||||
try { mm = chunk.getComponent(index, MMT); } catch (Throwable ignored) {}
|
||||
if (mm != null) {
|
||||
@@ -363,11 +313,11 @@ public final class GravityApplier {
|
||||
} catch (Throwable th) {
|
||||
errorHandler.accept(th);
|
||||
}
|
||||
return; // un joueur n'est pas un NPC
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Branche NPC ---
|
||||
// NPC branch.
|
||||
NPCEntity npc = null;
|
||||
try { npc = chunk.getComponent(index, NPCT); } catch (Throwable ignored) {}
|
||||
if (npc != null) {
|
||||
@@ -378,9 +328,7 @@ public final class GravityApplier {
|
||||
if (active != null) {
|
||||
active.updatePhysicsValues(targetValues);
|
||||
|
||||
// Plan 03-04 : seed forceVelocity.y paramétré par VerticalForce (remplace
|
||||
// le hardcode 0.1 de Plan 03-03). Uniquement en entrée (targetFlag=true) —
|
||||
// à la sortie le damping natif zéroe forceVelocity.
|
||||
// Seed forceVelocity.y only on entry — on exit the native damping zeroes it.
|
||||
if (targetFlag && matchedRegion != null) {
|
||||
double vf = matchedRegion.getVerticalForce();
|
||||
try {
|
||||
@@ -399,10 +347,10 @@ public final class GravityApplier {
|
||||
return;
|
||||
}
|
||||
|
||||
// sinon : item / autre — no-op
|
||||
// Items / other: no-op.
|
||||
}
|
||||
|
||||
/** Pure data-diff utilitaire pour tests unitaires (pas de runtime Hytale). */
|
||||
/** Pure data-diff helper for unit tests. */
|
||||
public static DiffResult diff(Set<UUID> previous, Set<UUID> current) {
|
||||
Set<UUID> toFlip = new HashSet<>(current);
|
||||
toFlip.removeAll(previous);
|
||||
@@ -417,17 +365,14 @@ public final class GravityApplier {
|
||||
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() {
|
||||
return Collections.unmodifiableSet(previouslyInverted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force une valeur du tracker hors runtime ECS — pour tester la sémantique sans dépendre de World/Store.
|
||||
* Package-private : NE PAS appeler depuis le code de production.
|
||||
*/
|
||||
/** Test-only tracker override; never call from production code. */
|
||||
void __updateTrackerForTest(Set<UUID> newState) {
|
||||
previouslyInverted.clear();
|
||||
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.vector.Vector3d;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
/** A named axis-aligned region in which gravity is inverted for any entity inside. */
|
||||
public final class GravityFlipRegion {
|
||||
|
||||
public static final BuilderCodec<GravityFlipRegion> CODEC =
|
||||
@@ -66,7 +20,7 @@ public final class GravityFlipRegion {
|
||||
.addValidator(Validators.nonNull()).add()
|
||||
.append(new KeyedCodec<>("Enabled", Codec.BOOLEAN),
|
||||
(r, v) -> r.enabled = v, r -> r.enabled).add()
|
||||
// --- Plan 03-04 : 6 optional tuning fields (no nonNull validator ⇒ optional) ---
|
||||
// Optional tuning fields (no nonNull validator => absence preserves Java defaults).
|
||||
.append(new KeyedCodec<>("FallDamage", Codec.BOOLEAN),
|
||||
(r, v) -> r.fallDamage = v, r -> r.fallDamage).add()
|
||||
.append(new KeyedCodec<>("GracePeriodMs", Codec.INTEGER),
|
||||
@@ -79,7 +33,6 @@ public final class GravityFlipRegion {
|
||||
(r, v) -> r.affectNpcs = v, r -> r.affectNpcs).add()
|
||||
.append(new KeyedCodec<>("AffectItems", Codec.BOOLEAN),
|
||||
(r, v) -> r.affectItems = v, r -> r.affectItems).add()
|
||||
// --- Plan 03-05 : 4 optional visualization fields ---
|
||||
.append(new KeyedCodec<>("VisualColor", Codec.STRING),
|
||||
(r, v) -> r.visualColor = v, r -> r.visualColor).add()
|
||||
.append(new KeyedCodec<>("VisualMode", Codec.STRING),
|
||||
@@ -88,7 +41,6 @@ public final class GravityFlipRegion {
|
||||
(r, v) -> r.visualRefreshMs = v, r -> r.visualRefreshMs).add()
|
||||
.append(new KeyedCodec<>("VisualOpacity", Codec.DOUBLE),
|
||||
(r, v) -> r.visualOpacity = v, r -> r.visualOpacity).add()
|
||||
// --- Plan 03-06 : 2 optional particle-mode fields ---
|
||||
.append(new KeyedCodec<>("VisualParticleId", Codec.STRING),
|
||||
(r, v) -> r.visualParticleId = v, r -> r.visualParticleId).add()
|
||||
.append(new KeyedCodec<>("VisualParticleDensity", Codec.DOUBLE),
|
||||
@@ -100,7 +52,6 @@ public final class GravityFlipRegion {
|
||||
Box box = new Box(new Vector3d(), new Vector3d());
|
||||
boolean enabled = true;
|
||||
|
||||
// Plan 03-04 : tuning fields — defaults applied when key absent in BSON.
|
||||
boolean fallDamage = false;
|
||||
int gracePeriodMs = 2500;
|
||||
double verticalForce = 0.1;
|
||||
@@ -108,15 +59,12 @@ public final class GravityFlipRegion {
|
||||
boolean affectNpcs = true;
|
||||
boolean affectItems = true;
|
||||
|
||||
// Plan 03-05 : visualization fields — defaults applied when key absent in BSON.
|
||||
String visualColor = "#00FFFF";
|
||||
String visualMode = "Outline";
|
||||
int visualRefreshMs = 1000;
|
||||
double visualOpacity = 0.5;
|
||||
|
||||
// Plan 03-06 : particle-mode fields — default switched to Torch_Fire after UAT
|
||||
// showed Dust_Sparkles_Fine is invisible in-world. Torch_Fire is a persistent flame
|
||||
// that renders reliably at any altitude. Density 0.3 = ~12 points per 40m edge.
|
||||
// Torch_Fire chosen because Dust_Sparkles_Fine is effectively invisible in-world.
|
||||
String visualParticleId = "Torch_Fire";
|
||||
double visualParticleDensity = 0.3;
|
||||
|
||||
@@ -138,8 +86,6 @@ public final class GravityFlipRegion {
|
||||
public void setBox(Box b) { this.box = b; }
|
||||
public void setEnabled(boolean v) { this.enabled = v; }
|
||||
|
||||
// --- Plan 03-04 getters / setters ---
|
||||
|
||||
public boolean isFallDamage() { return fallDamage; }
|
||||
public int getGracePeriodMs() { return gracePeriodMs; }
|
||||
public double getVerticalForce() { return verticalForce; }
|
||||
@@ -154,8 +100,6 @@ public final class GravityFlipRegion {
|
||||
public void setAffectNpcs(boolean v) { this.affectNpcs = v; }
|
||||
public void setAffectItems(boolean v) { this.affectItems = v; }
|
||||
|
||||
// --- Plan 03-05 getters / setters ---
|
||||
|
||||
public String getVisualColor() { return visualColor; }
|
||||
public String getVisualMode() { return visualMode; }
|
||||
public int getVisualRefreshMs() { return visualRefreshMs; }
|
||||
@@ -166,14 +110,12 @@ public final class GravityFlipRegion {
|
||||
public void setVisualRefreshMs(int v) { this.visualRefreshMs = v; }
|
||||
public void setVisualOpacity(double v) { this.visualOpacity = v; }
|
||||
|
||||
// --- Plan 03-06 getters / setters ---
|
||||
|
||||
public String getVisualParticleId() { return visualParticleId; }
|
||||
public double getVisualParticleDensity() { return visualParticleDensity; }
|
||||
|
||||
public void setVisualParticleId(String v) { this.visualParticleId = v; }
|
||||
public void setVisualParticleDensity(double v) { this.visualParticleDensity = v; }
|
||||
|
||||
/** Convenience accessor for tick-loop / physics consumers in Phase 02-02. */
|
||||
/** Convenience accessor for tick-loop / physics consumers. */
|
||||
public Box asBox() { return box; }
|
||||
}
|
||||
|
||||
@@ -22,32 +22,13 @@ import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* In-memory index of {@link GravityFlipRegion}s with two layers of atomic publication:
|
||||
*
|
||||
* <ol>
|
||||
* <li>An {@link AtomicReference} holding an immutable snapshot of the current region list,
|
||||
* written by CRUD methods and {@link #refreshFromConfig(GravityFlipConfig)} on the command
|
||||
* thread, read by the tick loop on the scheduler thread without locking.</li>
|
||||
* <li>A per-{@link World} {@code AtomicReference<RegionSnapshot>} published by
|
||||
* {@link #refreshFor(World)} every tick; off-thread consumers (Phase 3 physics) read it via
|
||||
* {@link #currentSnapshot(World)}.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><strong>Threading contract:</strong> the tick loop NEVER calls {@code config.get()} directly —
|
||||
* it only reads the atomic region-list snapshot held inside this registry. Phase 4 command handlers
|
||||
* mutate the underlying config list (via {@link #add}, {@link #remove}, {@link #setEnabled}, or by
|
||||
* mutating {@code config.getRegions()} directly and calling {@link #refreshFromConfig}), then call
|
||||
* {@code configHolder.save().join()} to persist.
|
||||
* In-memory index of {@link GravityFlipRegion}s with atomic publication for lock-free reads
|
||||
* from the tick loop. Mutations go through CRUD methods which swap an immutable snapshot.
|
||||
*/
|
||||
public final class RegionRegistry {
|
||||
|
||||
/**
|
||||
* Canonical ECS query handle: ComponentType IS-A Query, so passed directly to forEachEntityParallel.
|
||||
* Lazily initialised because {@code TransformComponent.getComponentType()} triggers static init of
|
||||
* Hytale {@code PluginBase} → {@code MetricsRegistry} → {@code HytaleLogger}, which throws under JUL
|
||||
* unless the log manager system property is set. Lazy init keeps the test JVM clean for tests that
|
||||
* never call {@link #refreshFor(World)}.
|
||||
*/
|
||||
// Lazy init: TransformComponent.getComponentType() triggers Hytale PluginBase static init,
|
||||
// which fails under JUL unless the log manager system property is set — avoided in tests.
|
||||
private static volatile ComponentType<EntityStore, TransformComponent> transformType;
|
||||
|
||||
private static ComponentType<EntityStore, TransformComponent> transform() {
|
||||
@@ -67,16 +48,10 @@ public final class RegionRegistry {
|
||||
private final GravityFlipConfig config;
|
||||
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 Object mutationLock = new Object();
|
||||
/**
|
||||
* Snapshot store keyed by {@link World} reference. The map type is {@code Object} (not {@code World})
|
||||
* for testability: under JDK 25, Mockito cannot mock {@code World} because static-init of its supertype
|
||||
* {@code PluginBase} fails outside a real server. Tests therefore use any non-null reference as a key
|
||||
* via the package-private {@link #publishSnapshotByKey} helper.
|
||||
*/
|
||||
// Keyed by Object (not World) because Mockito cannot mock World under JDK 25 in tests.
|
||||
private final ConcurrentHashMap<Object, AtomicReference<RegionSnapshot>> snapshots = new ConcurrentHashMap<>();
|
||||
|
||||
public RegionRegistry(GravityFlipConfig cfg) {
|
||||
@@ -89,14 +64,12 @@ public final class RegionRegistry {
|
||||
this.regionsSnapshot = new AtomicReference<>(List.copyOf(cfg.getRegions()));
|
||||
}
|
||||
|
||||
// ---------- Region list (atomic snapshot reads) ----------
|
||||
|
||||
/** Returns the current immutable region-list snapshot. Safe to call from any thread. */
|
||||
/** Returns the current immutable region-list snapshot. */
|
||||
public Collection<GravityFlipRegion> all() {
|
||||
return regionsSnapshot.get();
|
||||
}
|
||||
|
||||
/** Read-only view of currently-enabled regions, derived from the atomic snapshot (NOT from config). */
|
||||
/** Read-only view of currently-enabled regions. */
|
||||
List<GravityFlipRegion> enabled() {
|
||||
List<GravityFlipRegion> out = new ArrayList<>();
|
||||
for (GravityFlipRegion r : regionsSnapshot.get()) {
|
||||
@@ -105,18 +78,27 @@ public final class RegionRegistry {
|
||||
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) {
|
||||
synchronized (mutationLock) {
|
||||
for (GravityFlipRegion x : regionsSnapshot.get()) {
|
||||
if (x.getName().equals(r.getName())) {
|
||||
throw new IllegalArgumentException("region name already exists: " + r.getName());
|
||||
}
|
||||
if (find(r.getName()) != null) {
|
||||
throw new IllegalArgumentException("region name already exists: " + r.getName());
|
||||
}
|
||||
config.getRegions().add(r);
|
||||
regionsSnapshot.set(List.copyOf(config.getRegions()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes the region with the given name; returns true iff it existed. */
|
||||
public boolean remove(String name) {
|
||||
synchronized (mutationLock) {
|
||||
boolean removed = config.getRegions().removeIf(x -> x.getName().equals(name));
|
||||
@@ -125,6 +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) {
|
||||
synchronized (mutationLock) {
|
||||
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) {
|
||||
synchronized (mutationLock) {
|
||||
regionsSnapshot.set(List.copyOf(cfg.getRegions()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Persists via the bound {@code Config} holder; returns a completed future if no holder is bound (test mode). */
|
||||
/** Persists via the bound {@code Config} holder (completed future if none is bound). */
|
||||
public CompletableFuture<Void> save() {
|
||||
return holder == null ? CompletableFuture.completedFuture(null) : holder.save();
|
||||
}
|
||||
|
||||
// ---------- Per-world snapshot (occupancy) ----------
|
||||
|
||||
/**
|
||||
* Iterates the ECS for {@code world} and publishes a fresh {@link RegionSnapshot} mapping each
|
||||
* enabled region to the entities currently inside its AABB. Safe to call from a scheduler thread.
|
||||
*
|
||||
* <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).
|
||||
*/
|
||||
/** Iterates the ECS for {@code world} and publishes a fresh per-region occupancy snapshot. */
|
||||
public void refreshFor(World world) {
|
||||
List<GravityFlipRegion> enabled = enabled();
|
||||
Map<GravityFlipRegion, Collection<Ref<EntityStore>>> byRegion = new ConcurrentHashMap<>();
|
||||
@@ -168,25 +142,20 @@ public final class RegionRegistry {
|
||||
}
|
||||
|
||||
if (enabled.isEmpty()) {
|
||||
// Aucun travail ECS → publication directe depuis le thread appelant.
|
||||
publishSnapshot(world, snapshotOf(world, byRegion));
|
||||
return;
|
||||
}
|
||||
|
||||
// THREADING (fix WorldThread assert 2026-04-23) : `Store.forEachEntityParallel` exige la
|
||||
// WorldThread. On dispatche scan + publication via `world.execute(Runnable)` pour satisfaire
|
||||
// `assertThread`. Conséquence : la publication devient asynchrone (1 tick décalé max) côté
|
||||
// consumers de `currentSnapshot(world)` — tolérable car le RegionTickLoop tourne @100ms, donc
|
||||
// la fraîcheur du snapshot reste ≤ 100ms dans le pire cas.
|
||||
// Store.forEachEntityParallel requires the WorldThread (assertThread), so dispatch via
|
||||
// world.execute(Runnable). Publication becomes asynchronous (<= 1 tick of lag) — tolerable
|
||||
// because RegionTickLoop runs @100ms, so snapshot freshness stays <= 100ms in the worst case.
|
||||
world.execute(() -> {
|
||||
try {
|
||||
Store<EntityStore> store = world.getEntityStore().getStore();
|
||||
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) -> {
|
||||
TransformComponent t = chunk.getComponent(index, TRANSFORM);
|
||||
// Pinned API 2026.03.26 returns com.hypixel.hytale.math.vector.Vector3d
|
||||
// (Hytale's own type), NOT org.joml.Vector3d. Same deviation as Phase 02-01.
|
||||
com.hypixel.hytale.math.vector.Vector3d pos = t.getPosition();
|
||||
// Copy to locals — getPosition() returns a backing field; never mutated here.
|
||||
double x = pos.x, y = pos.y, z = pos.z;
|
||||
@@ -200,35 +169,29 @@ public final class RegionRegistry {
|
||||
}
|
||||
});
|
||||
} catch (Throwable th) {
|
||||
// Swallow — publish whatever we collected (possibly empty). The tick loop's
|
||||
// errorHandler already routes uncaught throwables; this catch keeps the
|
||||
// scheduler alive across transient ECS-state errors (e.g., world being torn down).
|
||||
// Swallow — keeps the scheduler alive across transient ECS-state errors
|
||||
// (e.g., world being torn down). Publish whatever we collected so far.
|
||||
}
|
||||
|
||||
// Publication intra-Runnable : garantit que la table byRegion est complète quand
|
||||
// on la fige dans snapshotOf(...).
|
||||
publishSnapshot(world, snapshotOf(world, byRegion));
|
||||
});
|
||||
}
|
||||
|
||||
/** Off-thread consumer entry point. Returns {@code null} if no snapshot has been published yet. */
|
||||
/** Off-thread consumer entry point; returns null if no snapshot has been published yet. */
|
||||
public RegionSnapshot currentSnapshot(World world) {
|
||||
if (world == null) return null;
|
||||
AtomicReference<RegionSnapshot> ref = snapshots.get(world);
|
||||
return ref == null ? null : ref.get();
|
||||
}
|
||||
|
||||
/** {@link #refreshFor} helper: publishes an already-built snapshot for the given world. */
|
||||
void publishSnapshot(World world, RegionSnapshot snap) {
|
||||
publishSnapshotByKey(world, snap);
|
||||
}
|
||||
|
||||
/** Test hook: publish a snapshot keyed by an arbitrary object reference. */
|
||||
void publishSnapshotByKey(Object key, RegionSnapshot snap) {
|
||||
snapshots.computeIfAbsent(key, k -> new AtomicReference<>()).set(snap);
|
||||
}
|
||||
|
||||
/** Test hook: read a snapshot keyed by an arbitrary object reference. */
|
||||
RegionSnapshot currentSnapshotByKey(Object key) {
|
||||
AtomicReference<RegionSnapshot> ref = snapshots.get(key);
|
||||
return ref == null ? null : ref.get();
|
||||
@@ -238,7 +201,7 @@ public final class RegionRegistry {
|
||||
Map<GravityFlipRegion, Collection<Ref<EntityStore>>> byRegion) {
|
||||
long tick = 0L;
|
||||
try { tick = w.getTick(); } catch (Throwable ignored) {}
|
||||
final long tickId = Math.max(tick, 1L); // tests assert tickId() > 0
|
||||
final long tickId = Math.max(tick, 1L);
|
||||
final Map<GravityFlipRegion, Collection<Ref<EntityStore>>> frozen =
|
||||
Collections.unmodifiableMap(byRegion);
|
||||
return new RegionSnapshot() {
|
||||
|
||||
@@ -7,16 +7,7 @@ import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Immutable snapshot of entity occupancy across all enabled regions for one {@link World}.
|
||||
*
|
||||
* <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.
|
||||
*/
|
||||
/** Immutable snapshot of entity occupancy across all enabled regions for one {@link World}. */
|
||||
public interface RegionSnapshot {
|
||||
|
||||
/** Read-only map: enabled region -> entity refs currently inside its AABB. */
|
||||
|
||||
@@ -13,17 +13,8 @@ import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Dedicated single-thread daemon scheduler that polls for entity-in-region detection at 100ms.
|
||||
*
|
||||
* <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.
|
||||
* Dedicated single-thread daemon scheduler that polls for entity-in-region detection at 100ms
|
||||
* and drives gravity application + visualization.
|
||||
*/
|
||||
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) {
|
||||
startWithDelay(INITIAL_DELAY_MS, world);
|
||||
}
|
||||
|
||||
/** Start with a custom initial delay (used by tests + the plugin's lazy-world-supplier path). */
|
||||
/** Starts with a custom initial delay (used by tests + the plugin's lazy-world-supplier path). */
|
||||
public void startWithDelay(long initialDelayMs, World world) {
|
||||
startWithDelay(initialDelayMs, () -> world);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start with a custom initial delay and a {@link Supplier} of the {@link World} to tick.
|
||||
*
|
||||
* <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.
|
||||
*/
|
||||
/** Starts with a custom initial delay and a supplier that resolves the World lazily each tick. */
|
||||
public void startWithDelay(long initialDelayMs, Supplier<World> worldSupplier) {
|
||||
Runnable tick = () -> {
|
||||
World w = worldSupplier.get();
|
||||
if (w == null) return;
|
||||
registry.refreshFor(w);
|
||||
if (gravityApplier != null) {
|
||||
gravityApplier.apply(w, registry.currentSnapshot(w));
|
||||
}
|
||||
if (regionVisualizer != null) {
|
||||
regionVisualizer.visualize(w, registry.currentSnapshot(w));
|
||||
if (gravityApplier != null || regionVisualizer != null) {
|
||||
var snapshot = registry.currentSnapshot(w);
|
||||
if (gravityApplier != null) gravityApplier.apply(w, snapshot);
|
||||
if (regionVisualizer != null) regionVisualizer.visualize(w, snapshot);
|
||||
}
|
||||
};
|
||||
scheduleGuarded(initialDelayMs, tick);
|
||||
}
|
||||
|
||||
/** Test-friendly overload: schedule an arbitrary runnable. */
|
||||
/** Test-friendly overload: schedules an arbitrary runnable. */
|
||||
public void startWithDelay(long initialDelayMs, Runnable tick) {
|
||||
scheduleGuarded(initialDelayMs, tick);
|
||||
}
|
||||
@@ -104,6 +88,7 @@ public final class RegionTickLoop {
|
||||
guarded, initialDelayMs, PERIOD_MS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/** Stops the scheduler with the shutdown -> awaitTermination(5s) -> shutdownNow idiom. */
|
||||
public void stop() {
|
||||
scheduler.shutdown();
|
||||
try {
|
||||
|
||||
@@ -7,40 +7,19 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Plan 03-06 Task 2 — helper pur qui génère la liste des points d'émission de
|
||||
* particules le long des 12 arêtes d'une {@link Box} AABB, sans aucune
|
||||
* dépendance sur {@code World} (testable en JVM standard).
|
||||
*
|
||||
* <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.
|
||||
* Generates evenly-distributed emission points along the 12 edges of an AABB, with the 8 corners
|
||||
* included exactly once. Pure helper with no World dependency (JVM-testable).
|
||||
*/
|
||||
public final class ParticleEdgeEmitter {
|
||||
|
||||
/** Plancher de densité (particules/m) — évite density ≤ 0 qui produirait l'ensemble vide. */
|
||||
/** Density floor (particles/m) — prevents density <= 0 from producing the empty set. */
|
||||
public static final double MIN_DENSITY = 0.1;
|
||||
/** Plafond de densité (particules/m) — borne la charge réseau par box. */
|
||||
/** Density ceiling (particles/m) — bounds network load against pathological values. */
|
||||
public static final double MAX_DENSITY = 10.0;
|
||||
|
||||
private ParticleEdgeEmitter() {}
|
||||
|
||||
/**
|
||||
* @param box la box dont on veut matérialiser les 12 arêtes.
|
||||
* @param density particules/mètre (clampé à {@code [0.1, 10.0]}).
|
||||
* @return liste de points uniformément répartis sur les 12 arêtes, 8 coins
|
||||
* dédupliqués. Jamais null. Taille ≥ 8.
|
||||
*/
|
||||
/** Returns edge-points for the given box (8 deduped corners + interior points), density clamped to [0.1, 10]. */
|
||||
public static List<Vector3d> edgePoints(Box box, double density) {
|
||||
double d = clamp(density, MIN_DENSITY, MAX_DENSITY);
|
||||
|
||||
@@ -49,7 +28,7 @@ public final class ParticleEdgeEmitter {
|
||||
|
||||
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[][] {
|
||||
{x0, y0, z0}, {x1, y0, z0}, {x0, y0, z1}, {x1, y0, z1},
|
||||
{x0, y1, z0}, {x1, y1, z0}, {x0, y1, z1}, {x1, y1, z1},
|
||||
@@ -58,18 +37,15 @@ public final class ParticleEdgeEmitter {
|
||||
out.add(new Vector3d(c[0], c[1], c[2]));
|
||||
}
|
||||
|
||||
// 2) Pour chaque arête, émettre les points INTÉRIEURS (sans endpoints).
|
||||
// 4 arêtes bas (Y=y0) : varient sur X ou Z.
|
||||
// 2) Emit interior points (without endpoints) for each of the 12 edges.
|
||||
addInteriorLineX(out, x0, x1, y0, z0, d);
|
||||
addInteriorLineX(out, x0, x1, y0, z1, d);
|
||||
addInteriorLineZ(out, x0, y0, z0, z1, d);
|
||||
addInteriorLineZ(out, x1, y0, z0, z1, d);
|
||||
// 4 arêtes haut (Y=y1).
|
||||
addInteriorLineX(out, x0, x1, y1, z0, d);
|
||||
addInteriorLineX(out, x0, x1, y1, z1, d);
|
||||
addInteriorLineZ(out, x0, y1, z0, z1, d);
|
||||
addInteriorLineZ(out, x1, y1, z0, z1, d);
|
||||
// 4 arêtes verticales (varient sur Y).
|
||||
addInteriorLineY(out, x0, y0, y1, z0, d);
|
||||
addInteriorLineY(out, x1, y0, y1, z0, d);
|
||||
addInteriorLineY(out, x0, y0, y1, z1, d);
|
||||
@@ -78,12 +54,10 @@ public final class ParticleEdgeEmitter {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Points intérieurs (sans endpoints) d'une arête parallèle à X. */
|
||||
private static void addInteriorLineX(List<Vector3d> out,
|
||||
double xMin, double xMax,
|
||||
double y, double z, double density) {
|
||||
int n = pointCount(xMax - xMin, density);
|
||||
// n = total points incl. endpoints ; intérieurs = n-2.
|
||||
for (int i = 1; i < n - 1; i++) {
|
||||
double t = (double) i / (double) (n - 1);
|
||||
out.add(new Vector3d(xMin + t * (xMax - xMin), y, z));
|
||||
@@ -110,7 +84,7 @@ public final class ParticleEdgeEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/** {@code max(2, ceil(length * density))} — 2 endpoints garantis. */
|
||||
/** Returns max(2, ceil(length * density)) — guarantees 2 endpoints. */
|
||||
static int pointCount(double length, double density) {
|
||||
double l = Math.max(0.0, length);
|
||||
int n = (int) Math.ceil(l * density);
|
||||
|
||||
@@ -24,71 +24,31 @@ import java.util.function.Consumer;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/**
|
||||
* Plan 03-05 + 03-06 : service qui émet soit des cubes {@link DebugUtils} (modes
|
||||
* Outline/Faces/Both) soit des particules le long des 12 arêtes de l'AABB
|
||||
* (mode Particles) pour matérialiser chaque région de gravity-flip côté clients.
|
||||
* Ne crée aucun scheduler ; {@link #visualize(World, RegionSnapshot)} est appelé
|
||||
* à chaque tick par {@code RegionTickLoop}, avec throttling par région via
|
||||
* {@code VisualRefreshMs}.
|
||||
*
|
||||
* <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]}.
|
||||
* Emits either DebugUtils cubes (Outline/Faces/Both modes) or edge particles (Particles mode)
|
||||
* to materialise each gravity-flip region on clients. Throttled per-region via VisualRefreshMs.
|
||||
*/
|
||||
public final class RegionVisualizer {
|
||||
|
||||
/** Plancher anti-DoS sur {@code VisualRefreshMs} (threat T-03-05-03). */
|
||||
/** Anti-DoS floor on VisualRefreshMs. */
|
||||
static final int MIN_REFRESH_MS = 100;
|
||||
|
||||
/** Fallback asset-id lorsqu'une VisualParticleId invalide est détectée. */
|
||||
/** Fallback asset id used when an invalid VisualParticleId is detected. */
|
||||
public static final String DEFAULT_PARTICLE_ID = "Torch_Fire";
|
||||
|
||||
/** Testability: DebugUtils emitter injectable (prod = {@link DebugUtils#add} wrapper). */
|
||||
/** Testability: injectable DebugUtils emitter (prod = {@link DebugUtils#add} wrapper). */
|
||||
@FunctionalInterface
|
||||
public interface DebugEmitter {
|
||||
void emit(World world, DebugShape shape, Matrix4d matrix,
|
||||
Vector3f color, float opacity, float time, int flags);
|
||||
}
|
||||
|
||||
/** Testability: particle emitter injectable (prod = direct-packet broadcast). */
|
||||
/** Testability: injectable particle emitter (prod = direct-packet broadcast). */
|
||||
@FunctionalInterface
|
||||
public interface ParticleEmitter {
|
||||
void emit(World world, String id, Vector3d pos);
|
||||
}
|
||||
|
||||
/** Testability: executor injectable (prod = {@code world::execute}). */
|
||||
/** Testability: injectable world executor (prod = {@code world::execute}). */
|
||||
@FunctionalInterface
|
||||
public interface WorldExecutor {
|
||||
void execute(World world, Runnable r);
|
||||
@@ -98,18 +58,16 @@ public final class RegionVisualizer {
|
||||
(world, shape, matrix, color, opacity, time, flags) ->
|
||||
DebugUtils.add(world, shape, matrix, color, opacity, time, flags);
|
||||
|
||||
/**
|
||||
* Prod particle emitter : broadcast direct de {@link SpawnParticleSystem} à
|
||||
* {@code world.getPlayerRefs()} (sans ComponentAccessor — voir javadoc de classe).
|
||||
*/
|
||||
// Direct SpawnParticleSystem broadcast to world.getPlayerRefs() — avoids needing a
|
||||
// ComponentAccessor inside a world.execute(...) lambda.
|
||||
private static final ParticleEmitter DEFAULT_PARTICLE_EMITTER =
|
||||
(world, id, pos) -> {
|
||||
SpawnParticleSystem packet = new SpawnParticleSystem(
|
||||
id,
|
||||
new Position(pos.x, pos.y, pos.z),
|
||||
null, // no rotation
|
||||
1.0f, // default scale
|
||||
null); // no color override
|
||||
null,
|
||||
1.0f,
|
||||
null);
|
||||
for (PlayerRef playerRef : world.getPlayerRefs()) {
|
||||
playerRef.getPacketHandler().writeNoCache(packet);
|
||||
}
|
||||
@@ -124,7 +82,7 @@ public final class RegionVisualizer {
|
||||
private final WorldExecutor executor;
|
||||
private final LongSupplier clock;
|
||||
private final Map<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();
|
||||
|
||||
public RegionVisualizer(Consumer<Throwable> errorHandler) {
|
||||
@@ -132,7 +90,6 @@ public final class RegionVisualizer {
|
||||
DEFAULT_EXECUTOR, System::currentTimeMillis);
|
||||
}
|
||||
|
||||
/** Package-private ctor pour tests (emitter DebugUtils + executor + clock injectés). */
|
||||
RegionVisualizer(Consumer<Throwable> errorHandler,
|
||||
DebugEmitter emitter,
|
||||
WorldExecutor executor,
|
||||
@@ -140,7 +97,6 @@ public final class RegionVisualizer {
|
||||
this(errorHandler, emitter, DEFAULT_PARTICLE_EMITTER, executor, clock);
|
||||
}
|
||||
|
||||
/** Package-private ctor pour tests (tous les emitters + executor + clock injectés). */
|
||||
RegionVisualizer(Consumer<Throwable> errorHandler,
|
||||
DebugEmitter emitter,
|
||||
ParticleEmitter particleEmitter,
|
||||
@@ -153,11 +109,7 @@ public final class RegionVisualizer {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Émet la visualisation debug pour chaque région éligible du {@code snapshot},
|
||||
* en respectant mode / couleur / opacity / throttling. Ne propage aucune
|
||||
* exception : tout throw est routé vers l'errorHandler (le tick ne doit pas mourir).
|
||||
*/
|
||||
/** Emits debug visualisation for each eligible region in the snapshot; never throws. */
|
||||
public void visualize(World world, RegionSnapshot snapshot) {
|
||||
if (snapshot == null) return;
|
||||
try {
|
||||
@@ -193,6 +145,7 @@ public final class RegionVisualizer {
|
||||
int flags = flagsForMode(mode);
|
||||
Vector3f color = parseColor(r.getVisualColor());
|
||||
Matrix4d matrix = matrixFromBox(r.getBox());
|
||||
// TTL = refreshMs * 1.2 to avoid flicker between emissions.
|
||||
float ttlSeconds = refreshMs * 1.2f / 1000f;
|
||||
float opacity = (float) clamp(r.getVisualOpacity(), 0.0, 1.0);
|
||||
|
||||
@@ -229,13 +182,7 @@ public final class RegionVisualizer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime validation de {@code VisualParticleId}. Id valide → retourne tel quel.
|
||||
* Id inconnu → warn-once (dédup via {@link #warnedInvalidIds}) et fallback
|
||||
* sur {@link #DEFAULT_PARTICLE_ID}. Si la validation elle-même throw (ex :
|
||||
* AssetMap indisponible hors contexte serveur), on laisse passer l'id tel quel
|
||||
* (fail-open — le serveur loggera l'erreur par packet si vraiment cassé).
|
||||
*/
|
||||
/** Validates {@code VisualParticleId} against the asset map; warns once and falls back to the default id on unknown. */
|
||||
String resolveParticleId(String requested) {
|
||||
if (requested == null || requested.isEmpty()) {
|
||||
return DEFAULT_PARTICLE_ID;
|
||||
@@ -251,12 +198,12 @@ public final class RegionVisualizer {
|
||||
}
|
||||
return DEFAULT_PARTICLE_ID;
|
||||
} catch (Throwable th) {
|
||||
// AssetMap indisponible (tests hors serveur) — fail-open.
|
||||
// AssetMap unavailable (tests outside server runtime) — fail-open.
|
||||
return requested;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear immédiat de toutes les shapes debug côté clients (appelé au shutdown). */
|
||||
/** Clears every debug shape on clients (called at shutdown). */
|
||||
public void clearAll(World world) {
|
||||
if (world == null) return;
|
||||
try {
|
||||
@@ -272,9 +219,9 @@ public final class RegionVisualizer {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- helpers (package-private pour tests) ----------
|
||||
// Helpers (package-private for tests).
|
||||
|
||||
/** Parse {@code #RRGGBB} → Vector3f(r/255, g/255, b/255) ; fallback COLOR_CYAN sur toute erreur. */
|
||||
/** Parses {@code #RRGGBB} into a normalised Vector3f; falls back to cyan on any parse error. */
|
||||
static Vector3f parseColor(String hex) {
|
||||
if (hex == null || hex.length() != 7 || hex.charAt(0) != '#') {
|
||||
return new Vector3f(DebugUtils.COLOR_CYAN);
|
||||
@@ -289,7 +236,7 @@ public final class RegionVisualizer {
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalise le mode : inconnu/null → "Outline". */
|
||||
/** Normalises the mode; unknown/null falls back to "Outline". */
|
||||
static String normalizeMode(String mode) {
|
||||
if ("Outline".equals(mode) || "Faces".equals(mode) || "Both".equals(mode)
|
||||
|| "None".equals(mode) || "Particles".equals(mode)) {
|
||||
@@ -298,19 +245,19 @@ public final class RegionVisualizer {
|
||||
return "Outline";
|
||||
}
|
||||
|
||||
/** Mapping VisualMode → flags DebugUtils. "None"/"Particles" n'utilisent pas les flags. */
|
||||
/** Maps VisualMode to DebugUtils flags. "None"/"Particles" do not use the flags. */
|
||||
static int flagsForMode(String mode) {
|
||||
switch (normalizeMode(mode)) {
|
||||
case "Faces": return DebugUtils.FLAG_NO_WIREFRAME;
|
||||
case "Both": return DebugUtils.FLAG_NONE;
|
||||
case "None": return DebugUtils.FLAG_NONE; // sentinel — caller skip avant.
|
||||
case "Particles": return DebugUtils.FLAG_NONE; // unused — particles ne passent pas par DebugUtils.
|
||||
case "None": return DebugUtils.FLAG_NONE;
|
||||
case "Particles": return DebugUtils.FLAG_NONE;
|
||||
case "Outline":
|
||||
default: return DebugUtils.FLAG_NO_SOLID;
|
||||
}
|
||||
}
|
||||
|
||||
/** Identity.translate(center).scale(sizeX, sizeY, sizeZ) pour une Box non-cubique. */
|
||||
/** Identity.translate(center).scale(sizeX, sizeY, sizeZ) for a non-cubic box. */
|
||||
static Matrix4d matrixFromBox(Box box) {
|
||||
Vector3d min = box.min;
|
||||
Vector3d max = box.max;
|
||||
|
||||
@@ -15,38 +15,7 @@ import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 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>
|
||||
*/
|
||||
/** Wand interaction: Primary click sets pos1, Secondary click sets pos2 in the shared selection store. */
|
||||
public final class GravityFlipWandInteraction extends SimpleInstantInteraction {
|
||||
|
||||
@Nonnull
|
||||
@@ -60,19 +29,14 @@ public final class GravityFlipWandInteraction extends SimpleInstantInteraction {
|
||||
+ "Secondary click sets pos2, for the clicker's selection."))
|
||||
.build();
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
// Volatile: writer (setup thread) must publish safely to reader threads (interaction dispatch).
|
||||
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) {
|
||||
STORE = store;
|
||||
}
|
||||
|
||||
/** Required no-arg constructor used by the CODEC factory. */
|
||||
public GravityFlipWandInteraction() {
|
||||
}
|
||||
|
||||
@@ -82,7 +46,6 @@ public final class GravityFlipWandInteraction extends SimpleInstantInteraction {
|
||||
@Nonnull CooldownHandler cooldownHandler) {
|
||||
WandSelectionStore store = STORE;
|
||||
if (store == null) {
|
||||
// Plugin mis-wired (bindStore never called). Silent no-op — don't crash the click.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,7 +57,6 @@ public final class GravityFlipWandInteraction extends SimpleInstantInteraction {
|
||||
|
||||
PlayerRef playerRef = commandBuffer.getComponent(entityRef, PlayerRef.getComponentType());
|
||||
if (playerRef == null) {
|
||||
// Clicker is not a player (mob, arrow, …) — ignore.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,6 +76,5 @@ public final class GravityFlipWandInteraction extends SimpleInstantInteraction {
|
||||
playerRef.sendMessage(Message.raw(
|
||||
"[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.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 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}.
|
||||
*/
|
||||
/** Thread-safe per-player wand selection store (pos1/pos2 keyed by player UUID). */
|
||||
public final class WandSelectionStore {
|
||||
|
||||
/** Immutable holder for a (possibly partial) selection. */
|
||||
public static final class Selection {
|
||||
/** {@code {x,y,z}} of the Primary click, or {@code null} if unset. */
|
||||
public final int[] pos1;
|
||||
/** {@code {x,y,z}} of the Secondary click, or {@code null} if unset. */
|
||||
public final int[] pos2;
|
||||
|
||||
Selection(int[] pos1, int[] pos2) {
|
||||
@@ -43,7 +26,7 @@ public final class WandSelectionStore {
|
||||
|
||||
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) {
|
||||
if (uuid == null) return;
|
||||
byUuid.compute(uuid, (k, prev) -> new Selection(
|
||||
@@ -51,7 +34,7 @@ public final class WandSelectionStore {
|
||||
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) {
|
||||
if (uuid == null) return;
|
||||
byUuid.compute(uuid, (k, prev) -> new Selection(
|
||||
@@ -59,23 +42,20 @@ public final class WandSelectionStore {
|
||||
new int[]{x, y, z}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current selection for {@code uuid}. Never returns {@code null} :
|
||||
* an unknown UUID yields a {@link Selection} with both corners {@code null}.
|
||||
*/
|
||||
/** Returns the current selection for the given player (never null). */
|
||||
public Selection get(UUID uuid) {
|
||||
if (uuid == null) return EMPTY;
|
||||
Selection s = byUuid.get(uuid);
|
||||
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) {
|
||||
if (uuid == null) return;
|
||||
byUuid.remove(uuid);
|
||||
}
|
||||
|
||||
/** Diagnostic : number of players with an in-flight selection. */
|
||||
/** Number of players with an in-flight selection. */
|
||||
public int 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.assertTrue;
|
||||
|
||||
/**
|
||||
* 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>
|
||||
*/
|
||||
/** Pure-data tests for {@link DefineValidation} — name regex, componentwise min/max, inflate-max-by-1 convention. */
|
||||
class DefineValidationTest {
|
||||
|
||||
// ---------- name regex ----------
|
||||
|
||||
@Test
|
||||
void validName_acceptsAlnumUnderscoreDash() {
|
||||
assertTrue(DefineValidation.isValidName("abc"));
|
||||
@@ -36,7 +24,7 @@ class DefineValidationTest {
|
||||
assertFalse(DefineValidation.isValidName(""));
|
||||
assertFalse(DefineValidation.isValidName(" "));
|
||||
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(" leading"));
|
||||
assertFalse(DefineValidation.isValidName("trailing "));
|
||||
@@ -47,8 +35,6 @@ class DefineValidationTest {
|
||||
assertFalse(DefineValidation.isValidName("has.dot"));
|
||||
}
|
||||
|
||||
// ---------- componentwise min/max ----------
|
||||
|
||||
@Test
|
||||
void componentwiseMin_returnsSmallestPerAxis() {
|
||||
int[] a = {5, 10, -3};
|
||||
@@ -75,22 +61,13 @@ class DefineValidationTest {
|
||||
DefineValidation.componentwiseMax(b, a));
|
||||
}
|
||||
|
||||
// ---------- inflate-max convention (block inclusion) ----------
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Blocks occupy the unit cube [x,x+1], so max must be inflated by +1 per axis to include the max block. */
|
||||
@Test
|
||||
void boxFromCorners_inflateMax_includesMaxBlock() {
|
||||
int[] mn = {0, 64, 0};
|
||||
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;
|
||||
// Without inflate: max = (10,70,10) — player OUT.
|
||||
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};
|
||||
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.assertTrue;
|
||||
|
||||
/**
|
||||
* 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>
|
||||
*/
|
||||
/** Round-trip tests for {@link GravityFlipConfig#CODEC} — non-null regions list, order preserved, list remains mutable. */
|
||||
class GravityFlipConfigCodecTest {
|
||||
|
||||
@Test
|
||||
@@ -49,7 +41,6 @@ class GravityFlipConfigCodecTest {
|
||||
@Test
|
||||
void roundTripOfEmptyListYieldsNonNullEmptyList() {
|
||||
GravityFlipConfig src = new GravityFlipConfig();
|
||||
// src.regions is the default empty ArrayList.
|
||||
GravityFlipConfig decoded = roundTrip(src);
|
||||
|
||||
assertNotNull(decoded.getRegions(), "decoded regions list must never be null");
|
||||
@@ -63,8 +54,7 @@ class GravityFlipConfigCodecTest {
|
||||
|
||||
GravityFlipConfig decoded = roundTrip(src);
|
||||
|
||||
// CRITICAL: must not throw UnsupportedOperationException.
|
||||
// Phase 4 commands (define / delete / toggle) all mutate this list.
|
||||
// Must not throw UnsupportedOperationException — command handlers depend on a mutable list.
|
||||
assertDoesNotThrow(() -> decoded.getRegions().add(region("added", 2, 2, 2, 3, 3, 3)));
|
||||
assertDoesNotThrow(() -> decoded.getRegions().remove(0));
|
||||
assertTrue(decoded.getRegions() instanceof ArrayList,
|
||||
@@ -86,7 +76,6 @@ class GravityFlipConfigCodecTest {
|
||||
return GravityFlipConfig.CODEC.decode(encoded, info);
|
||||
}
|
||||
|
||||
// Suppress unused-import warning if List is not directly referenced in any final assertion.
|
||||
@SuppressWarnings("unused")
|
||||
private static List<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.assertTrue;
|
||||
|
||||
/**
|
||||
* Pure-data tests for {@link FallDamageGuard} — no Hytale runtime dependency.
|
||||
* Covers entry / in-region / exit / grace-window / re-entry / FallDamage=true override.
|
||||
*/
|
||||
/** Pure-data tests for {@link FallDamageGuard} — entry, in-region, exit, grace-window, re-entry. */
|
||||
class FallDamageGuardTest {
|
||||
|
||||
@Test
|
||||
@@ -69,8 +66,7 @@ class FallDamageGuardTest {
|
||||
GravityFlipRegion region = region(false, 2500);
|
||||
guard.markInRegion(uuid, region);
|
||||
guard.markExit(uuid, region, 1000L);
|
||||
guard.markInRegion(uuid, region); // re-enter
|
||||
// In-region again with FallDamage=false → immediate suppression, grace reset.
|
||||
guard.markInRegion(uuid, region);
|
||||
assertTrue(guard.shouldSuppressFallDamage(uuid, 1500L));
|
||||
}
|
||||
|
||||
@@ -82,7 +78,6 @@ class FallDamageGuardTest {
|
||||
GravityFlipRegion allowed = region(true, 2500);
|
||||
guard.markInRegion(uuid, suppressed);
|
||||
guard.markExit(uuid, suppressed, 1000L);
|
||||
// New region has FallDamage=true → override immediately.
|
||||
guard.markInRegion(uuid, allowed);
|
||||
assertFalse(guard.shouldSuppressFallDamage(uuid, 1500L));
|
||||
}
|
||||
|
||||
@@ -8,14 +8,7 @@ import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 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}.
|
||||
*/
|
||||
/** Pure-diff + tracker-semantics tests for {@link GravityApplier}. */
|
||||
class GravityApplierDiffTest {
|
||||
|
||||
@Test
|
||||
@@ -73,16 +66,12 @@ class GravityApplierDiffTest {
|
||||
applier.__updateTrackerForTest(new HashSet<>(Set.of(c)));
|
||||
assertEquals(Set.of(c), applier.previouslyInvertedView());
|
||||
|
||||
// View is immutable.
|
||||
Set<UUID> view = applier.previouslyInvertedView();
|
||||
assertThrows(UnsupportedOperationException.class, () -> view.add(UUID.randomUUID()));
|
||||
}
|
||||
|
||||
// NOTE (Rule 3 deviation — Plan 03-02) : les tests suivants ciblent la seam pure
|
||||
// `buildFlaggedDecision(double, double, boolean)` au lieu de `buildPhysicsValuesWithFlag`
|
||||
// parce que le static init de `PhysicsValues` déclenche un `ExceptionInInitializerError`
|
||||
// hors runtime Hytale (dépendance ModuleRegistry). La décomposition pure garantit la
|
||||
// sémantique attendue (mass/drag préservés, flag = target) sans couplage ECS.
|
||||
// These tests target the pure seam buildFlaggedDecision because PhysicsValues static init
|
||||
// triggers ExceptionInInitializerError outside the Hytale runtime.
|
||||
|
||||
@Test
|
||||
void buildFlaggedDecisionPreservesMassAndDrag() {
|
||||
|
||||
@@ -11,15 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Round-trip tests for {@link GravityFlipRegion#CODEC}. Verifies the codec preserves
|
||||
* the legacy Name + Box + Enabled fields across encode -> decode cycles via the BSON
|
||||
* intermediate representation, and (Plan 03-04) the 6 optional tuning fields :
|
||||
* FallDamage, GracePeriodMs, VerticalForce, AffectPlayers, AffectNpcs, AffectItems.
|
||||
*
|
||||
* <p>Back-compat invariant (test {@link #roundTripPreservesDefaultsWhenNewFieldsAbsent}) :
|
||||
* a BSON encoded without the 6 new keys must decode with all Java defaults preserved.
|
||||
*/
|
||||
/** Round-trip tests for {@link GravityFlipRegion#CODEC} covering legacy + optional tuning + visualization fields. */
|
||||
class GravityFlipRegionCodecTest {
|
||||
|
||||
@Test
|
||||
@@ -68,11 +60,9 @@ class GravityFlipRegionCodecTest {
|
||||
assertEquals("", decoded.getName(), "empty name must survive round-trip without substitution");
|
||||
}
|
||||
|
||||
// ---------- Plan 03-04 : 6 nouveaux champs optionnels ----------
|
||||
|
||||
@Test
|
||||
void roundTripPreservesDefaultsWhenNewFieldsAbsent() {
|
||||
// Region construite via constructeur legacy 3-arg (comme un regions.json legacy).
|
||||
// Legacy 3-arg constructor simulates an old regions.json.
|
||||
GravityFlipRegion src = new GravityFlipRegion(
|
||||
"legacy",
|
||||
new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)),
|
||||
@@ -80,7 +70,6 @@ class GravityFlipRegionCodecTest {
|
||||
|
||||
GravityFlipRegion decoded = roundTrip(src);
|
||||
|
||||
// Tous les 6 nouveaux champs doivent exposer leurs defaults Java.
|
||||
assertFalse(decoded.isFallDamage(), "default FallDamage=false");
|
||||
assertEquals(2500, decoded.getGracePeriodMs(), "default GracePeriodMs=2500");
|
||||
assertEquals(0.1, decoded.getVerticalForce(), 1e-9, "default VerticalForce=0.1");
|
||||
@@ -119,7 +108,6 @@ class GravityFlipRegionCodecTest {
|
||||
src.setAffectPlayers(false);
|
||||
GravityFlipRegion decoded = roundTrip(src);
|
||||
assertFalse(decoded.isAffectPlayers());
|
||||
// Les autres filtres restent à true (non-clobber).
|
||||
assertTrue(decoded.isAffectNpcs());
|
||||
assertTrue(decoded.isAffectItems());
|
||||
}
|
||||
@@ -144,8 +132,6 @@ class GravityFlipRegionCodecTest {
|
||||
assertFalse(decoded.isAffectItems());
|
||||
}
|
||||
|
||||
// ---------- Plan 03-05 : 4 visualization fields ----------
|
||||
|
||||
@Test
|
||||
void roundTripPreservesVisualFields() {
|
||||
GravityFlipRegion src = baseRegion();
|
||||
@@ -164,8 +150,6 @@ class GravityFlipRegionCodecTest {
|
||||
|
||||
@Test
|
||||
void roundTripPreservesVisualDefaultsWhenFieldsAbsent() {
|
||||
// Region construite via constructeur legacy 3-arg — simule un regions.json legacy
|
||||
// (ni 03-04 ni 03-05 présents).
|
||||
GravityFlipRegion src = new GravityFlipRegion(
|
||||
"legacy-viz",
|
||||
new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)),
|
||||
|
||||
@@ -18,14 +18,8 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Pure-math + concurrency tests for {@link RegionRegistry}.
|
||||
*
|
||||
* <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}.
|
||||
* Pure-math + concurrency tests for {@link RegionRegistry}. Snapshot tests use the package-private
|
||||
* publishSnapshotByKey / currentSnapshotByKey hooks because Mockito cannot mock World under JDK 25.
|
||||
*/
|
||||
class RegionRegistryTest {
|
||||
|
||||
@@ -114,11 +108,9 @@ class RegionRegistryTest {
|
||||
cfg.getRegions().add(new GravityFlipRegion("a", box(), true));
|
||||
RegionRegistry reg = new RegionRegistry(cfg);
|
||||
|
||||
// Reader captures the immutable list before the swap.
|
||||
var before = reg.enabled();
|
||||
assertEquals(1, before.size());
|
||||
|
||||
// Mutator swaps via refreshFromConfig.
|
||||
cfg.getRegions().add(new GravityFlipRegion("b", box(), true));
|
||||
reg.refreshFromConfig(cfg);
|
||||
|
||||
@@ -128,7 +120,6 @@ class RegionRegistryTest {
|
||||
assertEquals(1, before.size());
|
||||
}
|
||||
|
||||
/** Minimal RegionSnapshot for the publish/read tests; world() is unused (returns null). */
|
||||
private static final class StubSnapshot implements RegionSnapshot {
|
||||
private final Map<GravityFlipRegion, Collection<Ref<EntityStore>>> by;
|
||||
private final long tick;
|
||||
|
||||
@@ -9,10 +9,7 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Scheduler-timing tests for {@link RegionTickLoop}. Use the {@code Runnable} overload so the
|
||||
* tests don't wait the 2s production initial delay and don't need a real {@code World}.
|
||||
*/
|
||||
/** Scheduler-timing tests for {@link RegionTickLoop} using the Runnable overload. */
|
||||
class RegionTickLoopTest {
|
||||
|
||||
@Test
|
||||
@@ -32,7 +29,7 @@ class RegionTickLoopTest {
|
||||
RegionTickLoop loop = new RegionTickLoop(reg, t -> {});
|
||||
AtomicInteger count = new AtomicInteger();
|
||||
loop.startWithDelay(0L, (Runnable) count::incrementAndGet);
|
||||
Thread.sleep(300); // ~3 ticks
|
||||
Thread.sleep(300);
|
||||
long t0 = System.nanoTime();
|
||||
loop.stop();
|
||||
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
|
||||
@@ -52,7 +49,7 @@ class RegionTickLoopTest {
|
||||
int n = count.incrementAndGet();
|
||||
if (n == 1) throw new RuntimeException("boom on first tick");
|
||||
});
|
||||
Thread.sleep(500); // expect ~5 ticks despite the first throwing
|
||||
Thread.sleep(500);
|
||||
loop.stop();
|
||||
assertTrue(count.get() >= 3, "scheduler died after first throw; count=" + count.get());
|
||||
assertNotNull(capturedFirst.get(), "errorHandler was not invoked");
|
||||
|
||||
@@ -10,23 +10,18 @@ import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Tests for {@link ParticleEdgeEmitter}. Verifies the 12-edge AABB emission
|
||||
* contract — no diagonals, no interior points, corner-dedup, density clamping.
|
||||
*/
|
||||
/** Tests for {@link ParticleEdgeEmitter} — 12-edge AABB emission, no diagonals, corner dedup, density clamping. */
|
||||
class ParticleEdgeEmitterTest {
|
||||
|
||||
private static final double EPS = 1e-9;
|
||||
|
||||
@Test
|
||||
void unitBox_density1_returnsExactly8Corners() {
|
||||
// 1x1x1 box, density=1 → each edge of length 1 → ceil(1*1)=1 → max(2,1)=2
|
||||
// points per edge (endpoints only), dedup → 8 corners total.
|
||||
// 1x1x1 box, density=1 -> each edge of length 1 -> 2 points/edge (endpoints), deduped -> 8 corners.
|
||||
Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1));
|
||||
List<Vector3d> pts = ParticleEdgeEmitter.edgePoints(b, 1.0);
|
||||
assertEquals(8, pts.size(), "unit box at density=1 should emit exactly 8 corner points");
|
||||
|
||||
// All 8 canonical corners present.
|
||||
Set<String> expected = new HashSet<>();
|
||||
for (double x : new double[]{0, 1})
|
||||
for (double y : new double[]{0, 1})
|
||||
@@ -39,30 +34,26 @@ class ParticleEdgeEmitterTest {
|
||||
|
||||
@Test
|
||||
void largeBox_density1_allPointsOnBoxSurfaceAndOnEdges() {
|
||||
// 10x10x10 box, density=1 → 11 points per edge (incl. endpoints).
|
||||
Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(10, 10, 10));
|
||||
List<Vector3d> pts = ParticleEdgeEmitter.edgePoints(b, 1.0);
|
||||
|
||||
// Edge membership: each point must lie on ≥ 2 of the 6 box planes
|
||||
// (i.e. at least 2 of its coords are on {min, max} of their axis).
|
||||
// Edge membership: each point must lie on at least 2 of the 6 box planes.
|
||||
for (Vector3d p : pts) {
|
||||
int onPlane = 0;
|
||||
if (approx(p.x, 0) || approx(p.x, 10)) onPlane++;
|
||||
if (approx(p.y, 0) || approx(p.y, 10)) onPlane++;
|
||||
if (approx(p.z, 0) || approx(p.z, 10)) onPlane++;
|
||||
assertTrue(onPlane >= 2,
|
||||
"point " + p + " must be on ≥ 2 box planes (edge membership), was on " + onPlane);
|
||||
"point " + p + " must be on >= 2 box planes (edge membership), was on " + onPlane);
|
||||
}
|
||||
|
||||
// Sanity: no duplicate points (corners must be deduped).
|
||||
Set<String> keys = new HashSet<>();
|
||||
for (Vector3d p : pts) {
|
||||
assertTrue(keys.add(key(p.x, p.y, p.z)),
|
||||
"duplicate point " + p + " — corners should be dedup'd");
|
||||
"duplicate point " + p + " — corners should be deduped");
|
||||
}
|
||||
|
||||
// Expected count: ceil(10*1) = 10 points/edge (incl. endpoints) → 8 interior/edge.
|
||||
// Total = 8 corners + 12 edges * 8 interior = 8 + 96 = 104.
|
||||
// 10 points/edge (incl. endpoints) -> 8 interior/edge. Total = 8 corners + 12 * 8 = 104.
|
||||
assertEquals(104, pts.size());
|
||||
}
|
||||
|
||||
@@ -70,12 +61,9 @@ class ParticleEdgeEmitterTest {
|
||||
void density_zeroClampedToMin_density1000ClampedToMax() {
|
||||
Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1));
|
||||
|
||||
// density=0 → clamp to 0.1 → per-edge ceil(1*0.1)=1 → max(2,1)=2 endpoints only.
|
||||
List<Vector3d> lo = ParticleEdgeEmitter.edgePoints(b, 0.0);
|
||||
assertEquals(8, lo.size(), "density=0 should clamp to MIN_DENSITY, yielding 8 corners on unit box");
|
||||
|
||||
// density=1000 → clamp to 10 → per-edge ceil(1*10)=10 → 10 total points/edge.
|
||||
// 8 corners + 12 * 8 interior = 8 + 96 = 104.
|
||||
List<Vector3d> hi = ParticleEdgeEmitter.edgePoints(b, 1000.0);
|
||||
assertEquals(104, hi.size(), "density=1000 should clamp to MAX_DENSITY=10");
|
||||
}
|
||||
@@ -87,14 +75,11 @@ class ParticleEdgeEmitterTest {
|
||||
assertEquals(8, pts.size());
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private static boolean approx(double a, double b) {
|
||||
return Math.abs(a - b) < EPS;
|
||||
}
|
||||
|
||||
private static String key(double x, double y, double z) {
|
||||
// Round to 6 decimals to avoid floating-point noise in the dedup check.
|
||||
return String.format("%.6f,%.6f,%.6f", x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,7 @@ import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour {@link RegionVisualizer}. Le {@code World} n'est jamais touché
|
||||
* (pas mockable sous JDK 25) : le {@code WorldExecutor} injecté exécute la lambda
|
||||
* inline sans ré-entrer dans World, et le {@code DebugEmitter} pousse dans une liste.
|
||||
*/
|
||||
/** Unit tests for {@link RegionVisualizer} with injected WorldExecutor/DebugEmitter so World is never touched. */
|
||||
class RegionVisualizerTest {
|
||||
|
||||
private static final class Call {
|
||||
@@ -38,8 +34,6 @@ class RegionVisualizerTest {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- parseColor ----------
|
||||
|
||||
@Test
|
||||
void parseColor_validHex() {
|
||||
Vector3f c = RegionVisualizer.parseColor("#FF8800");
|
||||
@@ -59,8 +53,6 @@ class RegionVisualizerTest {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- normalizeMode / flagsForMode ----------
|
||||
|
||||
@Test
|
||||
void parseMode_unknown_fallsBackToOutline() {
|
||||
assertEquals("Outline", RegionVisualizer.normalizeMode("Blah"));
|
||||
@@ -76,7 +68,6 @@ class RegionVisualizerTest {
|
||||
assertEquals(DebugUtils.FLAG_NO_WIREFRAME, RegionVisualizer.flagsForMode("Faces"));
|
||||
assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Both"));
|
||||
assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Particles"));
|
||||
// unknown → Outline
|
||||
assertEquals(DebugUtils.FLAG_NO_SOLID, RegionVisualizer.flagsForMode("xxx"));
|
||||
}
|
||||
|
||||
@@ -85,8 +76,6 @@ class RegionVisualizerTest {
|
||||
assertEquals("Particles", RegionVisualizer.normalizeMode("Particles"));
|
||||
}
|
||||
|
||||
// ---------- Particles branch ----------
|
||||
|
||||
@Test
|
||||
void visualize_particlesMode_callsParticleEmitterOncePerEdgePoint() {
|
||||
List<String> particleCalls = new ArrayList<>();
|
||||
@@ -98,14 +87,12 @@ class RegionVisualizerTest {
|
||||
(w, r) -> r.run(),
|
||||
() -> 0L);
|
||||
|
||||
// unit box at density=1 → 8 corner points emitted.
|
||||
GravityFlipRegion r = region("pz", "#FFFFFF", "Particles", 1000, 0.5);
|
||||
r.setVisualParticleId("Dust_Sparkles_Fine");
|
||||
r.setVisualParticleDensity(1.0);
|
||||
|
||||
viz.visualize(null, snapshotOf(r));
|
||||
assertEquals(8, particleCalls.size(), "unit box + density=1 → 8 corner emissions");
|
||||
// All calls use the requested id (validation falls open in test context).
|
||||
assertEquals(8, particleCalls.size(), "unit box + density=1 -> 8 corner emissions");
|
||||
for (String call : particleCalls) {
|
||||
assertTrue(call.startsWith("Dust_Sparkles_Fine@"), "unexpected call: " + call);
|
||||
}
|
||||
@@ -113,31 +100,26 @@ class RegionVisualizerTest {
|
||||
|
||||
@Test
|
||||
void particleDefaults_absentInConstructedRegion() {
|
||||
// Defaults must match the codec's documented defaults (03-06).
|
||||
GravityFlipRegion r = new GravityFlipRegion("x",
|
||||
new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)), true);
|
||||
assertEquals("Torch_Fire", r.getVisualParticleId());
|
||||
assertEquals(0.3, r.getVisualParticleDensity(), 1e-9);
|
||||
}
|
||||
|
||||
// ---------- matrixFromBox ----------
|
||||
|
||||
@Test
|
||||
void matrix_boxNonCubic() {
|
||||
Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(2, 4, 6));
|
||||
Matrix4d m = RegionVisualizer.matrixFromBox(b);
|
||||
double[] d = m.getData();
|
||||
// column-major : scale en diag [0][5][10], translation en [12][13][14]
|
||||
// column-major: scale on diagonal [0][5][10], translation on [12][13][14].
|
||||
assertEquals(2.0, d[0], 1e-9);
|
||||
assertEquals(4.0, d[5], 1e-9);
|
||||
assertEquals(6.0, d[10], 1e-9);
|
||||
assertEquals(1.0, d[12], 1e-9); // center x = 1
|
||||
assertEquals(2.0, d[13], 1e-9); // center y = 2
|
||||
assertEquals(3.0, d[14], 1e-9); // center z = 3
|
||||
assertEquals(1.0, d[12], 1e-9);
|
||||
assertEquals(2.0, d[13], 1e-9);
|
||||
assertEquals(3.0, d[14], 1e-9);
|
||||
}
|
||||
|
||||
// ---------- visualize : throttling / modes / skip ----------
|
||||
|
||||
@Test
|
||||
void visualize_throttlingSkipsSecondCallWithinWindow() {
|
||||
List<Call> calls = new ArrayList<>();
|
||||
@@ -152,15 +134,15 @@ class RegionVisualizerTest {
|
||||
RegionSnapshot snap = snapshotOf(r);
|
||||
|
||||
viz.visualize(null, snap);
|
||||
assertEquals(1, calls.size(), "premier tick émet");
|
||||
assertEquals(1, calls.size(), "first tick emits");
|
||||
|
||||
clock.set(1_500L); // +500ms < 1000 refreshMs
|
||||
clock.set(1_500L);
|
||||
viz.visualize(null, snap);
|
||||
assertEquals(1, calls.size(), "deuxième tick throttle");
|
||||
assertEquals(1, calls.size(), "second tick is throttled");
|
||||
|
||||
clock.set(2_100L); // +1100ms >= 1000
|
||||
clock.set(2_100L);
|
||||
viz.visualize(null, snap);
|
||||
assertEquals(2, calls.size(), "troisième tick ré-émet après refreshMs");
|
||||
assertEquals(2, calls.size(), "third tick re-emits after refreshMs");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -193,7 +175,7 @@ class RegionVisualizerTest {
|
||||
assertEquals(DebugShape.Cube, c.shape);
|
||||
assertEquals(DebugUtils.FLAG_NO_WIREFRAME, c.flags);
|
||||
assertEquals(0.75f, c.opacity, 1e-6);
|
||||
// TTL = 1000 * 1.2 / 1000 = 1.2s
|
||||
// TTL = 1000 * 1.2 / 1000 = 1.2s.
|
||||
assertEquals(1.2f, c.time, 1e-3);
|
||||
}
|
||||
|
||||
@@ -201,14 +183,14 @@ class RegionVisualizerTest {
|
||||
void visualize_clampsOpacityOutOfRange() {
|
||||
List<Call> calls = new ArrayList<>();
|
||||
RegionVisualizer viz = newViz(calls, 0L);
|
||||
GravityFlipRegion r = region("z1", "#00FF00", "Both", 1000, 2.5); // > 1 → clamp à 1
|
||||
GravityFlipRegion r = region("z1", "#00FF00", "Both", 1000, 2.5);
|
||||
viz.visualize(null, snapshotOf(r));
|
||||
assertEquals(1.0f, calls.get(0).opacity, 1e-6);
|
||||
}
|
||||
|
||||
@Test
|
||||
void visualize_clampsRefreshFloorBelowMin() {
|
||||
// refreshMs = 10 < MIN_REFRESH_MS (100) → effectif = 100ms
|
||||
// refreshMs=10 < MIN_REFRESH_MS(100) -> effective = 100ms.
|
||||
List<Call> calls = new ArrayList<>();
|
||||
AtomicLong clock = new AtomicLong(0L);
|
||||
RegionVisualizer viz = new RegionVisualizer(
|
||||
@@ -218,16 +200,14 @@ class RegionVisualizerTest {
|
||||
clock::get);
|
||||
GravityFlipRegion r = region("z1", "#00FF00", "Outline", 10, 0.5);
|
||||
viz.visualize(null, snapshotOf(r));
|
||||
clock.set(50L); // 50ms < 100 plancher
|
||||
clock.set(50L);
|
||||
viz.visualize(null, snapshotOf(r));
|
||||
assertEquals(1, calls.size(), "plancher 100ms protège contre flood");
|
||||
assertEquals(1, calls.size(), "100ms floor protects against flood");
|
||||
clock.set(150L);
|
||||
viz.visualize(null, snapshotOf(r));
|
||||
assertEquals(2, calls.size());
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private static RegionVisualizer newViz(List<Call> calls, long now) {
|
||||
AtomicLong clock = new AtomicLong(now);
|
||||
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.assertTrue;
|
||||
|
||||
/**
|
||||
* Pure-data tests for {@link WandSelectionStore} — no Hytale runtime dependency.
|
||||
* Covers set/get per UUID, overwrite semantics, unknown UUID default, clear,
|
||||
* and concurrent writes.
|
||||
*/
|
||||
/** Tests for {@link WandSelectionStore}: set/get per UUID, overwrite, clear, concurrency. */
|
||||
class WandSelectionStoreTest {
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user