From 216f544d9b387cd710180f30366b1d9a62bb7950 Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Thu, 23 Apr 2026 00:43:26 +0200 Subject: [PATCH] feat(02-01): wire GravityFlipConfig to regions.json via named withConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GravityFlipConfig wraps List via ArrayCodec under the KeyedCodec("Regions", ...) entry. Decoded list is a mutable ArrayList (NOT List.of(arr)) so Phase 4 commands can mutate at runtime. - GravityFlipPlugin.configHolder uses the named overload withConfig("regions", GravityFlipConfig.CODEC) — produces /regions.json. The 1-arg overload would hardcode config.json. - configHolder() javadoc documents the save contract (no auto-save on shutdown; mutators must call configHolder.save() explicitly) and the shared mutable reference caveat that 02-02's RegionRegistry will resolve. - 4 round-trip tests cover: empty default, two-region order preservation, empty list, and list mutability (regression guard against List.of). --- .../gravityflip/GravityFlipPlugin.java | 40 +++++++- .../gravityflip/config/GravityFlipConfig.java | 50 ++++++++++ .../config/GravityFlipConfigCodecTest.java | 92 +++++++++++++++++++ 3 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/mythlane/gravityflip/config/GravityFlipConfig.java create mode 100644 src/test/java/com/mythlane/gravityflip/config/GravityFlipConfigCodecTest.java diff --git a/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java b/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java index eef05d8..7baf6ec 100644 --- a/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java +++ b/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java @@ -2,6 +2,8 @@ package com.mythlane.gravityflip; import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.plugin.JavaPluginInit; +import com.hypixel.hytale.server.core.util.Config; +import com.mythlane.gravityflip.config.GravityFlipConfig; import java.util.logging.Level; @@ -9,25 +11,53 @@ import java.util.logging.Level; * Entry point for the Gravity Flip plugin. * *

Extends {@link JavaPlugin} from the resolved Hytale Server API - * ({@code com.hypixel.hytale:Server:2026.03.26-89796e57b}). The lifecycle - * hooks in this API version are {@code setup()} and {@code shutdown()} - * (NOT {@code onEnable()} / {@code onDisable()}, which belong to older - * docs). See {@code .planning/phases/01-scaffold-load/JAVAPLUGIN_RESOLUTION.md} - * for the empirical resolution. + * ({@code com.hypixel.hytale:Server:2026.03.26-89796e57b}). Lifecycle hooks + * for this version are {@code setup()} / {@code shutdown()} (not the legacy + * {@code onEnable()} / {@code onDisable()}). See + * {@code .planning/phases/01-scaffold-load/JAVAPLUGIN_RESOLUTION.md} for the + * empirical resolution. */ public class GravityFlipPlugin extends JavaPlugin { + /** + * Persisted region store, materialised on disk as + * {@code /regions.json} (i.e. {@code Plugins/GravityFlip/regions.json}). + * + *

The named {@code withConfig(name, codec)} overload is REQUIRED — the + * 1-arg overload hardcodes the filename to {@code config.json}. + */ + private final Config configHolder = + withConfig("regions", GravityFlipConfig.CODEC); + public GravityFlipPlugin(JavaPluginInit init) { super(init); } @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). getLogger().at(Level.INFO).log("Gravity Flip enabled"); } @Override protected void shutdown() { + // 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. getLogger().at(Level.INFO).log("Gravity Flip disabled"); } + + /** + * Accessor for the region config holder. SAVE CONTRACT: 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. + */ + public Config configHolder() { + return configHolder; + } } diff --git a/src/main/java/com/mythlane/gravityflip/config/GravityFlipConfig.java b/src/main/java/com/mythlane/gravityflip/config/GravityFlipConfig.java new file mode 100644 index 0000000..c44d093 --- /dev/null +++ b/src/main/java/com/mythlane/gravityflip/config/GravityFlipConfig.java @@ -0,0 +1,50 @@ +package com.mythlane.gravityflip.config; + +import com.hypixel.hytale.codec.KeyedCodec; +import com.hypixel.hytale.codec.builder.BuilderCodec; +import com.hypixel.hytale.codec.codecs.array.ArrayCodec; +import com.mythlane.gravityflip.region.GravityFlipRegion; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Root config wrapping the persisted list of {@link GravityFlipRegion}s. + * + *

Persisted as {@code /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. + * + *

The decoded list is wrapped in a mutable {@code ArrayList} + * (NOT {@code List.of(arr)}, which is immutable) so Phase 4 command handlers + * can {@code add}/{@code remove} regions at runtime. Callers that mutate the + * list MUST call {@code Config.save()} afterwards — there is no auto-save + * lifecycle hook. + */ +public final class GravityFlipConfig { + + public static final BuilderCodec CODEC = + BuilderCodec.builder(GravityFlipConfig.class, GravityFlipConfig::new) + .append( + new KeyedCodec<>("Regions", + new ArrayCodec<>(GravityFlipRegion.CODEC, GravityFlipRegion[]::new)), + // Decode setter: wrap in a MUTABLE ArrayList so commands can add/remove. + (c, arr) -> c.regions = new ArrayList<>(Arrays.asList(arr)), + c -> c.regions.toArray(GravityFlipRegion[]::new) + ).add() + .build(); + + private List regions = new ArrayList<>(); + + public GravityFlipConfig() {} + + public List getRegions() { + return regions; + } + + public void setRegions(List r) { + this.regions = (r == null) ? new ArrayList<>() : r; + } +} diff --git a/src/test/java/com/mythlane/gravityflip/config/GravityFlipConfigCodecTest.java b/src/test/java/com/mythlane/gravityflip/config/GravityFlipConfigCodecTest.java new file mode 100644 index 0000000..e22656e --- /dev/null +++ b/src/test/java/com/mythlane/gravityflip/config/GravityFlipConfigCodecTest.java @@ -0,0 +1,92 @@ +package com.mythlane.gravityflip.config; + +import com.hypixel.hytale.codec.ExtraInfo; +import com.hypixel.hytale.math.shape.Box; +import com.hypixel.hytale.math.vector.Vector3d; +import com.mythlane.gravityflip.region.GravityFlipRegion; +import org.bson.BsonValue; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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: + *

    + *
  • Regions list is always non-null (empty by default).
  • + *
  • List elements survive encode -> decode in order.
  • + *
  • Decoded list is MUTABLE — Phase 4 command handlers depend on this. + * Guard against an accidental {@code List.of(arr)} regression.
  • + *
+ */ +class GravityFlipConfigCodecTest { + + @Test + void newConfigHasNonNullEmptyRegions() { + GravityFlipConfig cfg = new GravityFlipConfig(); + assertNotNull(cfg.getRegions()); + assertEquals(0, cfg.getRegions().size()); + } + + @Test + void roundTripPreservesTwoRegionsInOrder() { + GravityFlipConfig src = new GravityFlipConfig(); + src.getRegions().add(region("alpha", 0, 0, 0, 1, 1, 1)); + src.getRegions().add(region("beta", 5, 5, 5, 9, 9, 9)); + + GravityFlipConfig decoded = roundTrip(src); + + assertEquals(2, decoded.getRegions().size()); + assertEquals("alpha", decoded.getRegions().get(0).getName()); + assertEquals("beta", decoded.getRegions().get(1).getName()); + } + + @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"); + assertEquals(0, decoded.getRegions().size()); + } + + @Test + void decodedListIsMutable() { + GravityFlipConfig src = new GravityFlipConfig(); + src.getRegions().add(region("seed", 0, 0, 0, 1, 1, 1)); + + GravityFlipConfig decoded = roundTrip(src); + + // CRITICAL: must not throw UnsupportedOperationException. + // Phase 4 commands (define / delete / toggle) all mutate this list. + assertDoesNotThrow(() -> decoded.getRegions().add(region("added", 2, 2, 2, 3, 3, 3))); + assertDoesNotThrow(() -> decoded.getRegions().remove(0)); + assertTrue(decoded.getRegions() instanceof ArrayList, + "decoded regions list should be a mutable ArrayList, not List.of(...)"); + } + + private static GravityFlipRegion region(String name, + double minX, double minY, double minZ, + double maxX, double maxY, double maxZ) { + return new GravityFlipRegion( + name, + new Box(new Vector3d(minX, minY, minZ), new Vector3d(maxX, maxY, maxZ)), + true); + } + + private static GravityFlipConfig roundTrip(GravityFlipConfig src) { + ExtraInfo info = new ExtraInfo(); + BsonValue encoded = GravityFlipConfig.CODEC.encode(src, info); + 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 typeAnchor() { return null; } +}