feat(04-03): add /gravityflip define <name> subcommand (CMD-02)

- GravityFlipDefineSubCommand: consume wand selection, componentwise min/max, inflate max +1, registry.add, save().join, clear selection
- Wire into GravityFlipCommand ctor
- Duplicate name → IllegalArgumentException → user message
- Save failure → in-memory region preserved, truthful message
This commit is contained in:
2026-04-24 14:11:27 +02:00
parent 2fe0957a1c
commit 00e946a1f4
2 changed files with 117 additions and 1 deletions
@@ -21,7 +21,7 @@ public final class GravityFlipCommand extends AbstractCommandCollection {
public GravityFlipCommand(GravityFlipPlugin plugin) {
super("gravityflip", "Commandes de gestion des zones Gravity Flip");
this.addSubCommand(new GravityFlipWandSubCommand());
// 04-03 will addSubCommand(new GravityFlipDefineSubCommand(plugin));
this.addSubCommand(new GravityFlipDefineSubCommand(plugin));
// 04-04 will addSubCommand(new GravityFlipListSubCommand(plugin));
// addSubCommand(new GravityFlipDeleteSubCommand(plugin));
// addSubCommand(new GravityFlipToggleSubCommand(plugin));
@@ -0,0 +1,116 @@
package com.mythlane.gravityflip.command;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.math.shape.Box;
import com.hypixel.hytale.math.vector.Vector3d;
import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.command.system.CommandContext;
import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg;
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import com.mythlane.gravityflip.GravityFlipPlugin;
import com.mythlane.gravityflip.region.GravityFlipRegion;
import com.mythlane.gravityflip.wand.WandSelectionStore;
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.
*/
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);
public GravityFlipDefineSubCommand(GravityFlipPlugin plugin) {
super("define", "Créer une région Gravity Flip à partir de la sélection wand");
this.plugin = plugin;
}
@Override
protected void execute(@Nonnull CommandContext ctx,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref,
@Nonnull PlayerRef playerRef,
@Nonnull World world) {
String name = nameArg.get(ctx);
if (!DefineValidation.isValidName(name)) {
ctx.sendMessage(Message.raw(
"[gravityflip] Nom invalide — attendu [a-zA-Z0-9_-]{1,32}."));
return;
}
UUID uuid = playerRef.getUuid();
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."));
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).
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));
GravityFlipRegion region = new GravityFlipRegion(name, box, true);
try {
plugin.regions().add(region);
} catch (IllegalArgumentException ex) {
ctx.sendMessage(Message.raw(
"[gravityflip] Une région nommée '" + name + "' existe déjà."));
return;
}
try {
plugin.configHolder().save().join();
} catch (Throwable th) {
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."));
return;
}
plugin.wandSelections().clear(uuid);
ctx.sendMessage(Message.raw(
"[gravityflip] Région '" + name + "' créée : "
+ "(" + mn[0] + "," + mn[1] + "," + mn[2] + ") → "
+ "(" + (mx[0] + 1) + "," + (mx[1] + 1) + "," + (mx[2] + 1) + ")"));
}
}