feat(02-01): wire GravityFlipConfig to regions.json via named withConfig
- GravityFlipConfig wraps List<GravityFlipRegion> 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
<dataDirectory>/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).
This commit is contained in:
@@ -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.
|
||||
*
|
||||
* <p>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 <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}.
|
||||
*/
|
||||
private final Config<GravityFlipConfig> 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. <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.
|
||||
*/
|
||||
public Config<GravityFlipConfig> configHolder() {
|
||||
return configHolder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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.
|
||||
*/
|
||||
public final class GravityFlipConfig {
|
||||
|
||||
public static final BuilderCodec<GravityFlipConfig> 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<GravityFlipRegion> regions = new ArrayList<>();
|
||||
|
||||
public GravityFlipConfig() {}
|
||||
|
||||
public List<GravityFlipRegion> getRegions() {
|
||||
return regions;
|
||||
}
|
||||
|
||||
public void setRegions(List<GravityFlipRegion> r) {
|
||||
this.regions = (r == null) ? new ArrayList<>() : r;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
* <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>
|
||||
*/
|
||||
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<GravityFlipRegion> typeAnchor() { return null; }
|
||||
}
|
||||
Reference in New Issue
Block a user