refactor(command): clean GSD comments and translate user-facing messages to English

This commit is contained in:
2026-04-24 17:25:31 +02:00
parent 15fc0702f1
commit 6b28dc2d2a
9 changed files with 51 additions and 211 deletions
@@ -2,38 +2,19 @@ package com.mythlane.gravityflip.command;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /** Pure-data validation helpers for the define sub-command (region name regex, componentwise min/max). */
* Pure-data validation helpers for {@code /gravityflip define <name>}.
*
* <p>No Hytale runtime dependency — same philosophy as {@code FallDamageGuard}
* and {@code WandSelectionStore}. Testable with JUnit alone.
*
* <p>Exposes :
* <ul>
* <li>{@link #isValidName(String)} — region name regex gate.</li>
* <li>{@link #componentwiseMin(int[], int[])} / {@link #componentwiseMax(int[], int[])}
* — so the builder can click pos1/pos2 in any order.</li>
* </ul>
*
* <p>See {@code DefineValidationTest} for the accepted/rejected-name corpus and
* the rationale for the inflate-max-by-1 convention applied in
* {@code GravityFlipDefineSubCommand}.
*/
public final class DefineValidation { public final class DefineValidation {
private static final Pattern NAME = Pattern.compile("^[a-zA-Z0-9_-]{1,32}$"); private static final Pattern NAME = Pattern.compile("^[a-zA-Z0-9_-]{1,32}$");
private DefineValidation() {} private DefineValidation() {}
/** /** Returns true iff the name matches {@code ^[a-zA-Z0-9_-]{1,32}$}. */
* Returns {@code true} iff {@code n} matches {@code ^[a-zA-Z0-9_-]{1,32}$}.
* Rejects {@code null}, blank, spaces, path separators, non-ASCII.
*/
public static boolean isValidName(String n) { public static boolean isValidName(String n) {
return n != null && NAME.matcher(n).matches(); return n != null && NAME.matcher(n).matches();
} }
/** Returns {@code {min(a.x,b.x), min(a.y,b.y), min(a.z,b.z)}}. */ /** Returns the per-axis minimum of two 3D integer vectors. */
public static int[] componentwiseMin(int[] a, int[] b) { public static int[] componentwiseMin(int[] a, int[] b) {
return new int[]{ return new int[]{
Math.min(a[0], b[0]), Math.min(a[0], b[0]),
@@ -42,7 +23,7 @@ public final class DefineValidation {
}; };
} }
/** Returns {@code {max(a.x,b.x), max(a.y,b.y), max(a.z,b.z)}}. */ /** Returns the per-axis maximum of two 3D integer vectors. */
public static int[] componentwiseMax(int[] a, int[] b) { public static int[] componentwiseMax(int[] a, int[] b) {
return new int[]{ return new int[]{
Math.max(a[0], b[0]), Math.max(a[0], b[0]),
@@ -3,23 +3,11 @@ package com.mythlane.gravityflip.command;
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
import com.mythlane.gravityflip.GravityFlipPlugin; import com.mythlane.gravityflip.GravityFlipPlugin;
/** /** Root command {@code /gravityflip} — aggregates all sub-commands. */
* Root command {@code /gravityflip}. Aggregates all Gravity Flip sub-commands.
*
* <p>Extends {@link AbstractCommandCollection} — pattern sourced from
* {@code builtin/teleport/commands/teleport/TeleportCommand} and
* {@code modules/debug/commands/DebugCommand}. When invoked without a
* sub-command, the base class emits a usage message listing registered
* sub-commands (no extra work required here).
*
* <p>The plugin reference is stored for future sub-commands (04-03 define,
* 04-04 list/delete/toggle/tp) that need {@code plugin.wandSelections()} or
* {@code plugin.configHolder()}.
*/
public final class GravityFlipCommand extends AbstractCommandCollection { public final class GravityFlipCommand extends AbstractCommandCollection {
public GravityFlipCommand(GravityFlipPlugin plugin) { public GravityFlipCommand(GravityFlipPlugin plugin) {
super("gravityflip", "Commandes de gestion des zones Gravity Flip"); super("gravityflip", "Gravity Flip region management commands");
this.addSubCommand(new GravityFlipWandSubCommand()); this.addSubCommand(new GravityFlipWandSubCommand());
this.addSubCommand(new GravityFlipDefineSubCommand(plugin)); this.addSubCommand(new GravityFlipDefineSubCommand(plugin));
this.addSubCommand(new GravityFlipListSubCommand(plugin)); this.addSubCommand(new GravityFlipListSubCommand(plugin));
@@ -20,42 +20,16 @@ import javax.annotation.Nonnull;
import java.util.UUID; import java.util.UUID;
import java.util.logging.Level; import java.util.logging.Level;
/** /** Creates and persists a new region from the caller's wand selection. */
* {@code /gravityflip define <name>} — consume the caller's wand selection
* (pos1 + pos2) to create and persist a new {@link GravityFlipRegion}.
*
* <p>Flow :
* <ol>
* <li>Validate {@code name} via {@link DefineValidation#isValidName}.</li>
* <li>Read the caller's selection from {@link WandSelectionStore}. Bail out
* if either pos1 or pos2 is unset (builder must click 2 blocks first).</li>
* <li>Compute componentwise min/max so the builder can click in any order.
* Inflate max by +1 per axis so the max block is INSIDE the AABB (a block
* occupies the unit cube between {@code (x,y,z)} and {@code (x+1,y+1,z+1)};
* without inflation, a player standing on the max block is OUT of the
* region — see {@code DefineValidationTest#boxFromCorners_inflateMax_includesMaxBlock}).</li>
* <li>{@code registry.add(region)} — throws {@link IllegalArgumentException}
* on duplicate name (T-04-03-05 mitigation — synchronized mutationLock).</li>
* <li>{@code configHolder.save().join()} forces durability before responding.
* Cost : a few ms disk. Acceptable in an interactive command.</li>
* <li>Clear the caller's selection — {@code define} consumes it, next define
* requires re-clicking 2 blocks (avoid chained accidental defines).</li>
* </ol>
*
* <p>On save failure the region remains in-memory (registry is already updated).
* We report this truthfully rather than silently rolling back — operators can
* inspect logs and the region is still active until restart.
*/
public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand { public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand {
private final GravityFlipPlugin plugin; private final GravityFlipPlugin plugin;
/** Required STRING arg; full validation applied via {@link DefineValidation#isValidName}. */
private final RequiredArg<String> nameArg = private final RequiredArg<String> nameArg =
this.withRequiredArg("name", "Nom de la région (a-z0-9_-, 1-32 chars)", ArgTypes.STRING); this.withRequiredArg("name", "Region name (a-z0-9_-, 1-32 chars)", ArgTypes.STRING);
public GravityFlipDefineSubCommand(GravityFlipPlugin plugin) { public GravityFlipDefineSubCommand(GravityFlipPlugin plugin) {
super("define", "Créer une région Gravity Flip à partir de la sélection wand"); super("define", "Create a Gravity Flip region from the wand selection");
this.plugin = plugin; this.plugin = plugin;
} }
@@ -68,7 +42,7 @@ public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand {
String name = nameArg.get(ctx); String name = nameArg.get(ctx);
if (!DefineValidation.isValidName(name)) { if (!DefineValidation.isValidName(name)) {
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Nom invalide — attendu [a-zA-Z0-9_-]{1,32}.")); "[gravityflip] Invalid name — expected [a-zA-Z0-9_-]{1,32}."));
return; return;
} }
@@ -76,14 +50,13 @@ public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand {
WandSelectionStore.Selection sel = plugin.wandSelections().get(uuid); WandSelectionStore.Selection sel = plugin.wandSelections().get(uuid);
if (sel.pos1 == null || sel.pos2 == null) { if (sel.pos1 == null || sel.pos2 == null) {
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Sélection incomplète — left-click puis right-click un bloc avec le wand avant define.")); "[gravityflip] Incomplete selection — left-click then right-click a block with the wand before define."));
return; return;
} }
int[] mn = DefineValidation.componentwiseMin(sel.pos1, sel.pos2); int[] mn = DefineValidation.componentwiseMin(sel.pos1, sel.pos2);
int[] mx = DefineValidation.componentwiseMax(sel.pos1, sel.pos2); int[] mx = DefineValidation.componentwiseMax(sel.pos1, sel.pos2);
// Inflate max by +1 per axis so the block at maxBlock is inside the AABB // Inflate max by +1 per axis so the max block is INSIDE the AABB (blocks occupy the unit cube [x,x+1]).
// (see DefineValidationTest#boxFromCorners_inflateMax_includesMaxBlock).
Box box = new Box( Box box = new Box(
new Vector3d(mn[0], mn[1], mn[2]), new Vector3d(mn[0], mn[1], mn[2]),
new Vector3d(mx[0] + 1.0, mx[1] + 1.0, mx[2] + 1.0)); new Vector3d(mx[0] + 1.0, mx[1] + 1.0, mx[2] + 1.0));
@@ -93,7 +66,7 @@ public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand {
plugin.regions().add(region); plugin.regions().add(region);
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Une région nommée '" + name + "' existe déjà.")); "[gravityflip] A region named '" + name + "' already exists."));
return; return;
} }
@@ -103,14 +76,14 @@ public final class GravityFlipDefineSubCommand extends AbstractPlayerCommand {
plugin.getLogger().at(Level.WARNING).withCause(th) plugin.getLogger().at(Level.WARNING).withCause(th)
.log("[define] save failed for region '" + name + "'"); .log("[define] save failed for region '" + name + "'");
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Région créée (en mémoire) mais persistance échouée — voir logs.")); "[gravityflip] Region created (in memory) but persistence failed — see logs."));
return; return;
} }
plugin.wandSelections().clear(uuid); plugin.wandSelections().clear(uuid);
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Région '" + name + "' créée : " "[gravityflip] Region '" + name + "' created: "
+ "(" + mn[0] + "," + mn[1] + "," + mn[2] + ") " + "(" + mn[0] + "," + mn[1] + "," + mn[2] + ") -> "
+ "(" + (mx[0] + 1) + "," + (mx[1] + 1) + "," + (mx[2] + 1) + ")")); + "(" + (mx[0] + 1) + "," + (mx[1] + 1) + "," + (mx[2] + 1) + ")"));
} }
} }
@@ -10,30 +10,16 @@ import com.mythlane.gravityflip.GravityFlipPlugin;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import java.util.logging.Level; import java.util.logging.Level;
/** /** Deletes a Gravity Flip region and persists the change. */
* {@code /gravityflip delete <name>} — supprime la région nommée et persiste.
*
* <p>Étend {@link CommandBase} pour permettre l'usage depuis la console serveur
* (opération admin, pas besoin d'un joueur caller).
*
* <p>Flow :
* <ol>
* <li>{@code registry.remove(name)} — renvoie {@code false} si nom inconnu → message
* d'erreur clair et retour précoce (pas de save inutile).</li>
* <li>{@code configHolder.save().join()} force la durabilité. Même pattern que
* {@code GravityFlipDefineSubCommand} : sur échec disque, on reporte truthfully
* ("en mémoire OK, persistance échouée") plutôt que silent rollback.</li>
* </ol>
*/
public final class GravityFlipDeleteSubCommand extends CommandBase { public final class GravityFlipDeleteSubCommand extends CommandBase {
private final GravityFlipPlugin plugin; private final GravityFlipPlugin plugin;
private final RequiredArg<String> nameArg = private final RequiredArg<String> nameArg =
this.withRequiredArg("name", "Nom de la région à supprimer", ArgTypes.STRING); this.withRequiredArg("name", "Name of the region to delete", ArgTypes.STRING);
public GravityFlipDeleteSubCommand(GravityFlipPlugin plugin) { public GravityFlipDeleteSubCommand(GravityFlipPlugin plugin) {
super("delete", "Supprime une région Gravity Flip"); super("delete", "Delete a Gravity Flip region");
this.plugin = plugin; this.plugin = plugin;
} }
@@ -41,7 +27,7 @@ public final class GravityFlipDeleteSubCommand extends CommandBase {
protected void executeSync(@Nonnull CommandContext ctx) { protected void executeSync(@Nonnull CommandContext ctx) {
String name = nameArg.get(ctx); String name = nameArg.get(ctx);
if (!plugin.regions().remove(name)) { if (!plugin.regions().remove(name)) {
ctx.sendMessage(Message.raw("[gravityflip] Région '" + name + "' introuvable.")); ctx.sendMessage(Message.raw("[gravityflip] Region '" + name + "' not found."));
return; return;
} }
try { try {
@@ -50,9 +36,9 @@ public final class GravityFlipDeleteSubCommand extends CommandBase {
plugin.getLogger().at(Level.WARNING).withCause(th) plugin.getLogger().at(Level.WARNING).withCause(th)
.log("[delete] save failed for region '" + name + "'"); .log("[delete] save failed for region '" + name + "'");
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Suppression en mémoire OK, persistance échouée — voir logs.")); "[gravityflip] Deleted in memory, but persistence failed — see logs."));
return; return;
} }
ctx.sendMessage(Message.raw("[gravityflip] Région '" + name + "' supprimée.")); ctx.sendMessage(Message.raw("[gravityflip] Region '" + name + "' deleted."));
} }
} }
@@ -10,29 +10,13 @@ import com.mythlane.gravityflip.region.GravityFlipRegion;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import java.util.Collection; import java.util.Collection;
/** /** Lists all persisted Gravity Flip regions. */
* {@code /gravityflip list} — liste toutes les régions Gravity Flip persistées.
*
* <p>Étend {@link CommandBase} (et non {@code AbstractPlayerCommand}) pour
* fonctionner aussi depuis la console serveur : cette commande est admin-only
* de par sa permission auto-générée ({@code mythlane.gravityflip.command.gravityflip.list}),
* mais n'a pas besoin d'un {@code PlayerRef} — seulement de {@code ctx.sendMessage(...)}.
*
* <p>Format de sortie (une ligne par région) :
* <pre>
* - test-zone-1 : (10,64,10) → (21,71,21) [enabled]
* </pre>
*
* <p>Threat surface : T-04-04-01 (Information Disclosure) accepté — les coords
* des régions sont visibles pour tout opérateur avec la permission list ; c'est
* la spec attendue pour un outil builder.
*/
public final class GravityFlipListSubCommand extends CommandBase { public final class GravityFlipListSubCommand extends CommandBase {
private final GravityFlipPlugin plugin; private final GravityFlipPlugin plugin;
public GravityFlipListSubCommand(GravityFlipPlugin plugin) { public GravityFlipListSubCommand(GravityFlipPlugin plugin) {
super("list", "Liste toutes les régions Gravity Flip"); super("list", "List all Gravity Flip regions");
this.plugin = plugin; this.plugin = plugin;
} }
@@ -40,15 +24,15 @@ public final class GravityFlipListSubCommand extends CommandBase {
protected void executeSync(@Nonnull CommandContext ctx) { protected void executeSync(@Nonnull CommandContext ctx) {
Collection<GravityFlipRegion> all = plugin.regions().all(); Collection<GravityFlipRegion> all = plugin.regions().all();
if (all.isEmpty()) { if (all.isEmpty()) {
ctx.sendMessage(Message.raw("[gravityflip] Aucune région définie.")); ctx.sendMessage(Message.raw("[gravityflip] No regions defined."));
return; return;
} }
ctx.sendMessage(Message.raw("[gravityflip] " + all.size() + " région(s) :")); ctx.sendMessage(Message.raw("[gravityflip] " + all.size() + " region(s):"));
for (GravityFlipRegion r : all) { for (GravityFlipRegion r : all) {
Vector3d mn = r.getMin(); Vector3d mn = r.getMin();
Vector3d mx = r.getMax(); Vector3d mx = r.getMax();
ctx.sendMessage(Message.raw(String.format( ctx.sendMessage(Message.raw(String.format(
" - %s : (%.0f,%.0f,%.0f) (%.0f,%.0f,%.0f) [%s]", " - %s : (%.0f,%.0f,%.0f) -> (%.0f,%.0f,%.0f) [%s]",
r.getName(), mn.x, mn.y, mn.z, mx.x, mx.y, mx.z, r.getName(), mn.x, mn.y, mn.z, mx.x, mx.y, mx.z,
r.isEnabled() ? "enabled" : "disabled"))); r.isEnabled() ? "enabled" : "disabled")));
} }
@@ -11,29 +11,16 @@ import com.mythlane.gravityflip.region.GravityFlipRegion;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import java.util.logging.Level; import java.util.logging.Level;
/** /** Enables/disables a Gravity Flip region and persists the change. */
* {@code /gravityflip toggle <name>} — flippe le flag {@code enabled} d'une région
* sans toucher aux corners ni aux autres champs.
*
* <p>Étend {@link CommandBase} (utilisable console + joueur).
*
* <p>Flow : lookup région → lecture {@code isEnabled()} → {@code registry.setEnabled(name, !current)}
* → {@code configHolder.save().join()} → message avec le nouvel état.
*
* <p>Race (T-04-04-04) : lookup + setEnabled non atomique entre eux. Deux toggles
* simultanés sur le même nom produisent un état final indéterminé mais cohérent
* sur disque ({@code RegionRegistry.setEnabled} est synchronisé sur {@code mutationLock}).
* Acceptable v1 (single-operator builder).
*/
public final class GravityFlipToggleSubCommand extends CommandBase { public final class GravityFlipToggleSubCommand extends CommandBase {
private final GravityFlipPlugin plugin; private final GravityFlipPlugin plugin;
private final RequiredArg<String> nameArg = private final RequiredArg<String> nameArg =
this.withRequiredArg("name", "Nom de la région à toggler", ArgTypes.STRING); this.withRequiredArg("name", "Name of the region to toggle", ArgTypes.STRING);
public GravityFlipToggleSubCommand(GravityFlipPlugin plugin) { public GravityFlipToggleSubCommand(GravityFlipPlugin plugin) {
super("toggle", "Active/désactive une région Gravity Flip"); super("toggle", "Enable/disable a Gravity Flip region");
this.plugin = plugin; this.plugin = plugin;
} }
@@ -48,13 +35,13 @@ public final class GravityFlipToggleSubCommand extends CommandBase {
} }
} }
if (found == null) { if (found == null) {
ctx.sendMessage(Message.raw("[gravityflip] Région '" + name + "' introuvable.")); ctx.sendMessage(Message.raw("[gravityflip] Region '" + name + "' not found."));
return; return;
} }
boolean next = !found.isEnabled(); boolean next = !found.isEnabled();
if (!plugin.regions().setEnabled(name, next)) { if (!plugin.regions().setEnabled(name, next)) {
// Course ultra-rare : région supprimée entre all() et setEnabled(). // Rare race: region removed between all() and setEnabled().
ctx.sendMessage(Message.raw("[gravityflip] Région '" + name + "' introuvable.")); ctx.sendMessage(Message.raw("[gravityflip] Region '" + name + "' not found."));
return; return;
} }
try { try {
@@ -63,10 +50,10 @@ public final class GravityFlipToggleSubCommand extends CommandBase {
plugin.getLogger().at(Level.WARNING).withCause(th) plugin.getLogger().at(Level.WARNING).withCause(th)
.log("[toggle] save failed for region '" + name + "'"); .log("[toggle] save failed for region '" + name + "'");
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Toggle en mémoire OK, persistance échouée — voir logs.")); "[gravityflip] Toggled in memory, but persistence failed — see logs."));
return; return;
} }
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] Région '" + name + "' " + (next ? "activée" : "désactivée") + ".")); "[gravityflip] Region '" + name + "' " + (next ? "enabled" : "disabled") + "."));
} }
} }
@@ -20,37 +20,16 @@ import com.mythlane.gravityflip.region.GravityFlipRegion;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
/** /** Teleports the calling player to the center of a Gravity Flip region's AABB. */
* {@code /gravityflip tp <name>} — téléporte le joueur appelant au centre de
* l'AABB d'une région Gravity Flip. Outil debug/builder (CMD-06).
*
* <p>Étend {@link AbstractPlayerCommand} : tp ne fait sens que pour un joueur,
* la base class gère automatiquement le message "must be player" pour un appel console.
*
* <p>Pattern Teleport copié ligne-à-ligne sur
* {@code com.hypixel.hytale.builtin.teleport.commands.teleport.variant.TeleportToCoordinatesCommand}
* (lignes 48-68) :
* <ol>
* <li>Lire {@link TransformComponent} (rotation corps) et {@link HeadRotation}
* (rotation tête) pour préserver l'orientation du joueur.</li>
* <li>Calculer le centre componentwise : {@code c = (min + max) / 2}.</li>
* <li>Construire via {@code Teleport.createForPlayer(pos, rotation).setHeadRotation(...)}.</li>
* <li>{@code store.addComponent(ref, Teleport.getComponentType(), teleport)} —
* le {@code TeleportSystem} du core consomme le composant au prochain tick.</li>
* </ol>
*
* <p>T-04-04-02 : permission auto-générée {@code mythlane.gravityflip.command.gravityflip.tp}
* sert de gate. Pas de restriction GameMode v1 (builder tool).
*/
public final class GravityFlipTpSubCommand extends AbstractPlayerCommand { public final class GravityFlipTpSubCommand extends AbstractPlayerCommand {
private final GravityFlipPlugin plugin; private final GravityFlipPlugin plugin;
private final RequiredArg<String> nameArg = private final RequiredArg<String> nameArg =
this.withRequiredArg("name", "Nom de la région cible", ArgTypes.STRING); this.withRequiredArg("name", "Name of the target region", ArgTypes.STRING);
public GravityFlipTpSubCommand(GravityFlipPlugin plugin) { public GravityFlipTpSubCommand(GravityFlipPlugin plugin) {
super("tp", "Téléporte au centre d'une région Gravity Flip"); super("tp", "Teleport to the center of a Gravity Flip region");
this.plugin = plugin; this.plugin = plugin;
} }
@@ -69,7 +48,7 @@ public final class GravityFlipTpSubCommand extends AbstractPlayerCommand {
} }
} }
if (target == null) { if (target == null) {
ctx.sendMessage(Message.raw("[gravityflip] Région '" + name + "' introuvable.")); ctx.sendMessage(Message.raw("[gravityflip] Region '" + name + "' not found."));
return; return;
} }
@@ -82,13 +61,12 @@ public final class GravityFlipTpSubCommand extends AbstractPlayerCommand {
TransformComponent tc = store.getComponent(ref, TransformComponent.getComponentType()); TransformComponent tc = store.getComponent(ref, TransformComponent.getComponentType());
if (tc == null) { if (tc == null) {
ctx.sendMessage(Message.raw( ctx.sendMessage(Message.raw(
"[gravityflip] TransformComponent manquant — tp impossible.")); "[gravityflip] TransformComponent missing — teleport impossible."));
return; return;
} }
HeadRotation hr = store.getComponent(ref, HeadRotation.getComponentType()); HeadRotation hr = store.getComponent(ref, HeadRotation.getComponentType());
// Préserve l'orientation courante : body rotation depuis Transform, head rotation // Preserve current orientation: body rotation from Transform, head rotation when available.
// si disponible (sinon on laisse la default côté Teleport.createForPlayer).
Vector3f bodyRotation = tc.getRotation().clone(); Vector3f bodyRotation = tc.getRotation().clone();
Teleport teleport = Teleport.createForPlayer( Teleport teleport = Teleport.createForPlayer(
new Vector3d(cx, cy, cz), bodyRotation); new Vector3d(cx, cy, cz), bodyRotation);
@@ -98,7 +76,7 @@ public final class GravityFlipTpSubCommand extends AbstractPlayerCommand {
store.addComponent(ref, Teleport.getComponentType(), teleport); store.addComponent(ref, Teleport.getComponentType(), teleport);
ctx.sendMessage(Message.raw(String.format( ctx.sendMessage(Message.raw(String.format(
"[gravityflip] Téléporté au centre de '%s' : (%.0f,%.0f,%.0f)", "[gravityflip] Teleported to the center of '%s': (%.0f,%.0f,%.0f)",
name, cx, cy, cz))); name, cx, cy, cz)));
} }
} }
@@ -14,27 +14,13 @@ import com.hypixel.hytale.server.core.universe.world.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
/** /** {@code /gravityflip wand} — gives a Gravity Flip Wand to the calling player. */
* {@code /gravityflip wand} — donne un Gravity Flip Wand au joueur appelant.
*
* <p>Pattern copié ligne-à-ligne sur
* {@code com.hypixel.hytale.server.core.command.commands.player.inventory.GiveCommand}
* (cf. GiveCommand.java:47-76). La seule différence : l'item est résolu par
* constante ({@link #WAND_ITEM_ID}) au lieu d'un {@code RequiredArg<Item>}.
*
* <p>L'item {@code gravityflip_wand} est enregistré via le JSON bundle
* {@code src/main/resources/Items/gravityflip_wand.json} (Phase 04-01).
* Si l'asset n'est pas chargé (JSON absent / pas picked-up par l'AssetStore
* core), le lookup renvoie {@code null} et la commande envoie un message
* d'erreur clair au lieu de crasher (T-04-02 scope).
*/
public final class GravityFlipWandSubCommand extends AbstractPlayerCommand { public final class GravityFlipWandSubCommand extends AbstractPlayerCommand {
/** ItemID du wand défini dans {@code Items/gravityflip_wand.json} (Phase 04-01). */
private static final String WAND_ITEM_ID = "gravityflip_wand"; private static final String WAND_ITEM_ID = "gravityflip_wand";
public GravityFlipWandSubCommand() { public GravityFlipWandSubCommand() {
super("wand", "Obtenir un Gravity Flip Wand"); super("wand", "Get a Gravity Flip Wand");
} }
@Override @Override
@@ -47,22 +33,22 @@ public final class GravityFlipWandSubCommand extends AbstractPlayerCommand {
if (item == null) { if (item == null) {
context.sendMessage(Message.raw( context.sendMessage(Message.raw(
"[gravityflip] Item '" + WAND_ITEM_ID "[gravityflip] Item '" + WAND_ITEM_ID
+ "' introuvable — asset pas chargé ?")); + "' not found — asset not loaded?"));
return; return;
} }
Player playerComponent = store.getComponent(ref, Player.getComponentType()); Player playerComponent = store.getComponent(ref, Player.getComponentType());
if (playerComponent == null) { if (playerComponent == null) {
context.sendMessage(Message.raw("[gravityflip] Player component manquant.")); context.sendMessage(Message.raw("[gravityflip] Player component missing."));
return; return;
} }
ItemStack stack = new ItemStack(item.getId(), 1, null); ItemStack stack = new ItemStack(item.getId(), 1, null);
ItemStackTransaction transaction = playerComponent.giveItem(stack, ref, store); ItemStackTransaction transaction = playerComponent.giveItem(stack, ref, store);
ItemStack remainder = transaction.getRemainder(); ItemStack remainder = transaction.getRemainder();
if (remainder == null || remainder.isEmpty()) { if (remainder == null || remainder.isEmpty()) {
context.sendMessage(Message.raw("[gravityflip] Wand ajouté à ton inventaire.")); context.sendMessage(Message.raw("[gravityflip] Wand added to your inventory."));
} else { } else {
context.sendMessage(Message.raw( context.sendMessage(Message.raw(
"[gravityflip] Inventaire plein — impossible d'ajouter le wand.")); "[gravityflip] Inventory full — cannot add the wand."));
} }
} }
} }
@@ -6,21 +6,9 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
/** /** Pure-data tests for {@link DefineValidation} — name regex, componentwise min/max, inflate-max-by-1 convention. */
* Pure-data tests for {@link DefineValidation} — no Hytale runtime dependency.
*
* <p>Covers :
* <ul>
* <li>Name regex accepts alnum + underscore + dash, 1..32 chars.</li>
* <li>Name regex rejects blank, spaces, special chars, non-ASCII, over-length.</li>
* <li>Componentwise min/max return smallest/largest per axis.</li>
* <li>Inflate-max-by-1 convention (max block is INSIDE the AABB only if we add 1 per axis).</li>
* </ul>
*/
class DefineValidationTest { class DefineValidationTest {
// ---------- name regex ----------
@Test @Test
void validName_acceptsAlnumUnderscoreDash() { void validName_acceptsAlnumUnderscoreDash() {
assertTrue(DefineValidation.isValidName("abc")); assertTrue(DefineValidation.isValidName("abc"));
@@ -36,7 +24,7 @@ class DefineValidationTest {
assertFalse(DefineValidation.isValidName("")); assertFalse(DefineValidation.isValidName(""));
assertFalse(DefineValidation.isValidName(" ")); assertFalse(DefineValidation.isValidName(" "));
assertFalse(DefineValidation.isValidName("my zone")); assertFalse(DefineValidation.isValidName("my zone"));
assertFalse(DefineValidation.isValidName("nom avec espaces")); assertFalse(DefineValidation.isValidName("name with spaces"));
assertFalse(DefineValidation.isValidName("a\tb")); assertFalse(DefineValidation.isValidName("a\tb"));
assertFalse(DefineValidation.isValidName(" leading")); assertFalse(DefineValidation.isValidName(" leading"));
assertFalse(DefineValidation.isValidName("trailing ")); assertFalse(DefineValidation.isValidName("trailing "));
@@ -47,8 +35,6 @@ class DefineValidationTest {
assertFalse(DefineValidation.isValidName("has.dot")); assertFalse(DefineValidation.isValidName("has.dot"));
} }
// ---------- componentwise min/max ----------
@Test @Test
void componentwiseMin_returnsSmallestPerAxis() { void componentwiseMin_returnsSmallestPerAxis() {
int[] a = {5, 10, -3}; int[] a = {5, 10, -3};
@@ -75,22 +61,13 @@ class DefineValidationTest {
DefineValidation.componentwiseMax(b, a)); DefineValidation.componentwiseMax(b, a));
} }
// ---------- inflate-max convention (block inclusion) ---------- /** Blocks occupy the unit cube [x,x+1], so max must be inflated by +1 per axis to include the max block. */
/**
* A block occupies the cube between (x,y,z) and (x+1,y+1,z+1). If an AABB's max is the
* raw block coord (maxBlockX, maxBlockY, maxBlockZ), a player standing on top of that
* block is OUTSIDE the AABB. We therefore inflate max by +1 per axis.
*/
@Test @Test
void boxFromCorners_inflateMax_includesMaxBlock() { void boxFromCorners_inflateMax_includesMaxBlock() {
int[] mn = {0, 64, 0}; int[] mn = {0, 64, 0};
int[] mx = {10, 70, 10}; int[] mx = {10, 70, 10};
// Player feet at (10.5, 70.5, 10.5) — standing in the maxBlock cube.
double px = 10.5, py = 70.5, pz = 10.5; double px = 10.5, py = 70.5, pz = 10.5;
// Without inflate: max = (10,70,10) — player OUT.
assertFalse(px < mx[0] && py < mx[1] && pz < mx[2]); assertFalse(px < mx[0] && py < mx[1] && pz < mx[2]);
// With inflate: max = (11,71,11) — player IN.
int[] mxInflated = {mx[0] + 1, mx[1] + 1, mx[2] + 1}; int[] mxInflated = {mx[0] + 1, mx[1] + 1, mx[2] + 1};
assertTrue(px < mxInflated[0] && py < mxInflated[1] && pz < mxInflated[2]); assertTrue(px < mxInflated[0] && py < mxInflated[1] && pz < mxInflated[2]);
} }