diff --git a/build.gradle.kts b/build.gradle.kts
index 95521fb..67346ad 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -24,6 +24,8 @@ dependencies {
implementation("com.google.code.gson:gson:2.10.1")
implementation("org.jetbrains:annotations:24.1.0")
+ // Tests need the Hytale Server API (codecs, ExtraInfo, BsonValue) on the classpath.
+ testImplementation("com.hypixel.hytale:Server:$hytaleServerVersion")
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
diff --git a/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java b/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java
new file mode 100644
index 0000000..fa99eba
--- /dev/null
+++ b/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java
@@ -0,0 +1,68 @@
+package com.mythlane.gravityflip.region;
+
+import com.hypixel.hytale.codec.Codec;
+import com.hypixel.hytale.codec.KeyedCodec;
+import com.hypixel.hytale.codec.builder.BuilderCodec;
+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.
+ *
+ *
Persisted as part of {@code GravityFlipConfig} via {@link #CODEC}. The region
+ * is stored on disk as three keys:
+ *
+ * - {@code Name} — non-null string identifier
+ * - {@code Box} — non-null AABB (composed of two {@code Vector3d} corners)
+ * - {@code Enabled} — boolean toggle, default {@code true}
+ *
+ *
+ * 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).
+ *
+ *
Fields are package-private mutable so the codec can write into them directly,
+ * mirroring the canonical {@code MythLoggerConfig} pattern. Public getters/setters
+ * are provided for runtime callers (Phase 4 commands).
+ */
+public final class GravityFlipRegion {
+
+ public static final BuilderCodec CODEC =
+ BuilderCodec.builder(GravityFlipRegion.class, GravityFlipRegion::new)
+ .append(new KeyedCodec<>("Name", Codec.STRING),
+ (r, v) -> r.name = v, r -> r.name)
+ .addValidator(Validators.nonNull()).add()
+ .append(new KeyedCodec<>("Box", Box.CODEC),
+ (r, v) -> r.box = v, r -> r.box)
+ .addValidator(Validators.nonNull()).add()
+ .append(new KeyedCodec<>("Enabled", Codec.BOOLEAN),
+ (r, v) -> r.enabled = v, r -> r.enabled).add()
+ .build();
+
+ // Package-private mutable fields written directly by the codec setters.
+ String name = "";
+ Box box = new Box(new Vector3d(), new Vector3d());
+ boolean enabled = true;
+
+ public GravityFlipRegion() {}
+
+ public GravityFlipRegion(String name, Box box, boolean enabled) {
+ this.name = name;
+ this.box = box;
+ this.enabled = enabled;
+ }
+
+ public String getName() { return name; }
+ public Box getBox() { return box; }
+ public Vector3d getMin() { return box.min; }
+ public Vector3d getMax() { return box.max; }
+ public boolean isEnabled() { return enabled; }
+
+ public void setName(String n) { this.name = n; }
+ public void setBox(Box b) { this.box = b; }
+ public void setEnabled(boolean v) { this.enabled = v; }
+
+ /** Convenience accessor for tick-loop / physics consumers in Phase 02-02. */
+ public Box asBox() { return box; }
+}
diff --git a/src/test/java/com/mythlane/gravityflip/region/GravityFlipRegionCodecTest.java b/src/test/java/com/mythlane/gravityflip/region/GravityFlipRegionCodecTest.java
new file mode 100644
index 0000000..68c5c4e
--- /dev/null
+++ b/src/test/java/com/mythlane/gravityflip/region/GravityFlipRegionCodecTest.java
@@ -0,0 +1,72 @@
+package com.mythlane.gravityflip.region;
+
+import com.hypixel.hytale.codec.ExtraInfo;
+import com.hypixel.hytale.math.shape.Box;
+import org.bson.BsonValue;
+import com.hypixel.hytale.math.vector.Vector3d;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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
+ * Name + Box + Enabled fields across encode -> decode cycles via the BSON intermediate
+ * representation that all Hytale codecs share.
+ */
+class GravityFlipRegionCodecTest {
+
+ @Test
+ void roundTripPreservesNameBoxEnabled() {
+ GravityFlipRegion src = new GravityFlipRegion(
+ "zone1",
+ new Box(new Vector3d(1, 2, 3), new Vector3d(4, 5, 6)),
+ true);
+
+ GravityFlipRegion decoded = roundTrip(src);
+
+ assertEquals("zone1", decoded.getName());
+ assertNotNull(decoded.getBox());
+ assertEquals(1.0, decoded.getMin().x);
+ assertEquals(2.0, decoded.getMin().y);
+ assertEquals(3.0, decoded.getMin().z);
+ assertEquals(4.0, decoded.getMax().x);
+ assertEquals(5.0, decoded.getMax().y);
+ assertEquals(6.0, decoded.getMax().z);
+ assertTrue(decoded.isEnabled());
+ }
+
+ @Test
+ void roundTripPreservesEnabledFalse() {
+ GravityFlipRegion src = new GravityFlipRegion(
+ "off-zone",
+ new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)),
+ false);
+
+ GravityFlipRegion decoded = roundTrip(src);
+
+ assertEquals("off-zone", decoded.getName());
+ assertFalse(decoded.isEnabled(), "default-true field must not clobber loaded false");
+ }
+
+ @Test
+ void roundTripPreservesEmptyName() {
+ GravityFlipRegion src = new GravityFlipRegion(
+ "",
+ new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)),
+ true);
+
+ GravityFlipRegion decoded = roundTrip(src);
+
+ assertNotNull(decoded.getName());
+ assertEquals("", decoded.getName(), "empty name must survive round-trip without substitution");
+ }
+
+ private static GravityFlipRegion roundTrip(GravityFlipRegion src) {
+ ExtraInfo info = new ExtraInfo();
+ BsonValue encoded = GravityFlipRegion.CODEC.encode(src, info);
+ return GravityFlipRegion.CODEC.decode(encoded, info);
+ }
+}