diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java
index 37a0cdd2..3257ec45 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java
@@ -26,6 +26,7 @@
* @param stamina the survivors' sprint and the slender's appearing
* @param adrenaline the rush a survivor gets when the visible slender comes close
* @param telemetry the OpenTelemetry traces, which only do anything with the javaagent attached
+ * @param minimap the request to client minimap mods to switch themselves off
* @param lobbyAtmosphereShare how far the lobby's atmosphere is taken towards the map's own: {@code 0}
* leaves the vanilla overworld, {@code 1} is exactly the map's atmosphere
* @author theEvilReaper
@@ -46,6 +47,7 @@ public record GameConfig(
StaminaConfig stamina,
AdrenalineConfig adrenaline,
TelemetryConfig telemetry,
+ MinimapConfig minimap,
float lobbyAtmosphereShare
) {
@@ -127,6 +129,7 @@ public record GameConfig(
StaminaConfig.DEFAULT,
AdrenalineConfig.DEFAULT,
TelemetryConfig.DEFAULT,
+ MinimapConfig.DEFAULT,
DEFAULT_LOBBY_ATMOSPHERE_SHARE
);
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
index 3e09c43d..4a7bb7e1 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
@@ -48,13 +48,14 @@
*
stamina.* (see {@link StaminaConfig})
* adrenaline.* (see {@link AdrenalineConfig})
* telemetry.* (see {@link TelemetryConfig})
+ * minimap.mode (see {@link MinimapConfig})
*
*
* If a property can not be found in the file, the default value will be used.
* The default values are defined in {@link GameConfig#DEFAULT}.
*
* @author theEvilReaper
- * @version 1.9.0
+ * @version 1.10.0
* @see GameConfig
* @since 1.0.0
*/
@@ -72,6 +73,7 @@ public final class GameConfigReader {
private static final String STAMINA_PREFIX = "stamina.";
private static final String ADRENALINE_PREFIX = "adrenaline.";
private static final String TELEMETRY_PREFIX = "telemetry.";
+ private static final String MINIMAP_MODE_KEY = "minimap.mode";
private final Path path;
@@ -155,6 +157,7 @@ public GameConfig getConfig() {
getStamina(properties),
getAdrenaline(properties),
getTelemetry(properties),
+ getMinimap(properties),
getFloat(properties, "lobbyAtmosphereShare", GameConfig.DEFAULT.lobbyAtmosphereShare())
);
}
@@ -335,6 +338,27 @@ private TelemetryConfig getTelemetry(Properties properties) {
TelemetryConfig.DEFAULT.slowTickThresholdMillis()));
}
+ /**
+ * Reads the minimap mode. Like every unreadable value, an unknown mode falls back to the default.
+ *
+ * @param properties the loaded properties
+ * @return the minimap settings, never {@code null}
+ */
+ private MinimapConfig getMinimap(Properties properties) {
+ String value = getString(properties, MINIMAP_MODE_KEY);
+ if (value == null) {
+ return MinimapConfig.DEFAULT;
+ }
+ for (MinimapConfig.Mode mode : MinimapConfig.Mode.values()) {
+ if (mode.name().equalsIgnoreCase(value)) {
+ return new MinimapConfig(mode);
+ }
+ }
+ CONFIG_LOGGER.warn("'{}' is not a minimap mode (disabled, fair, off): '{}'. Falling back to default: {}",
+ MINIMAP_MODE_KEY, value, MinimapConfig.DEFAULT.mode());
+ return MinimapConfig.DEFAULT;
+ }
+
/**
* Reads the adrenaline settings. All keys start with {@value #ADRENALINE_PREFIX}.
*
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/MinimapConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/MinimapConfig.java
new file mode 100644
index 00000000..6a45cc47
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/MinimapConfig.java
@@ -0,0 +1,48 @@
+package net.onelitefeather.cygnus.common.config;
+
+/**
+ * Settings for the request Cygnus sends to client minimap mods to switch themselves off.
+ *
+ * This is opt-in on the side of the mods: Xaero's Minimap, VoxelMap and similar mods read an invisible
+ * chat line and obey it, a modified client or a mod that does not know the convention ignores it. It is
+ * a courtesy to honest players, not an anti-cheat.
+ *
+ *
+ * @param mode how much of the minimap the mods are asked to give up
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public record MinimapConfig(Mode mode) {
+
+ /**
+ * The default settings: ask for the minimap to be off.
+ */
+ public static final MinimapConfig DEFAULT = new MinimapConfig(Mode.DISABLED);
+
+ /**
+ * Checks the values.
+ *
+ * @throws IllegalArgumentException if the mode is missing
+ */
+ public MinimapConfig {
+ if (mode == null) {
+ throw new IllegalArgumentException("Minimap mode must not be null");
+ }
+ }
+
+ /**
+ * How much of the minimap the mods are asked to give up.
+ */
+ public enum Mode {
+
+ /** The minimap is asked to switch off wherever the mod supports it, and radar and cave mode with it. */
+ DISABLED,
+
+ /** The map stays, but radar and cave mode are asked to switch off ("fair play"). */
+ FAIR,
+
+ /** Nothing is sent. */
+ OFF
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java
index 861b627e..d0529a1f 100644
--- a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java
@@ -629,4 +629,32 @@ void testTheOldDreadKeysAreIgnored(@TempDir Path tempDir) throws IOException {
assertEquals(CreekConfig.DEFAULT, new GameConfigReader(tempDir).getConfig().creek());
}
+
+ @Test
+ void testMinimapModeDefaultsToDisabled(@TempDir Path tempDir) throws IOException {
+ Files.writeString(tempDir.resolve("config.properties"), "minPlayers=4\n");
+
+ assertEquals(MinimapConfig.Mode.DISABLED, new GameConfigReader(tempDir).getConfig().minimap().mode());
+ }
+
+ @Test
+ void testMinimapModeIsReadCaseInsensitively(@TempDir Path tempDir) throws IOException {
+ Files.writeString(tempDir.resolve("config.properties"), "minimap.mode= Fair \n");
+
+ assertEquals(MinimapConfig.Mode.FAIR, new GameConfigReader(tempDir).getConfig().minimap().mode());
+ }
+
+ @Test
+ void testMinimapModeOffIsRead(@TempDir Path tempDir) throws IOException {
+ Files.writeString(tempDir.resolve("config.properties"), "minimap.mode=off\n");
+
+ assertEquals(MinimapConfig.Mode.OFF, new GameConfigReader(tempDir).getConfig().minimap().mode());
+ }
+
+ @Test
+ void testAnUnknownMinimapModeFallsBackToTheDefault(@TempDir Path tempDir) throws IOException {
+ Files.writeString(tempDir.resolve("config.properties"), "minimap.mode=banana\n");
+
+ assertEquals(MinimapConfig.DEFAULT, new GameConfigReader(tempDir).getConfig().minimap());
+ }
}
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java
index 1bc55489..e3d7cf00 100644
--- a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java
@@ -41,7 +41,7 @@ void testInvalidLobbyAtmosphereShare() {
assertRejected("Lobby atmosphere share must be between 0 and 1", () -> new GameConfig(
defaults.round(), defaults.teams(), null, defaults.resourcePack(), defaults.pageProximity(),
defaults.damageSound(), defaults.glitch(), defaults.pageGlitch(), defaults.creek(),
- defaults.sanity(), defaults.stamina(), defaults.adrenaline(), defaults.telemetry(), 1.5F));
+ defaults.sanity(), defaults.stamina(), defaults.adrenaline(), defaults.telemetry(), defaults.minimap(), 1.5F));
}
@Test
@@ -110,4 +110,9 @@ private static GameConfig.PageProximity proximityWithFactor(float volumeFactor)
private static void assertRejected(String message, Executable creation) {
assertEquals(message, assertThrows(IllegalArgumentException.class, creation).getMessage());
}
+
+ @Test
+ void testMinimapModeMustBeSet() {
+ assertRejected("Minimap mode must not be null", () -> new MinimapConfig(null));
+ }
}
diff --git a/config.properties.example b/config.properties.example
index e32cfe7b..387f9d01 100644
--- a/config.properties.example
+++ b/config.properties.example
@@ -308,3 +308,23 @@ survivorTeamSize=12
# cygnus.tick.slow span. At least 1; a smaller value is rejected and the service refuses
# to start. 50 is Minestom's tick budget, so the default flags every tick that ran over.
#telemetry.slowTickThresholdMillis=50
+
+# --- Minimap ----------------------------------------------------------------
+# Asks client minimap mods to switch themselves off, so nobody finds the slender or the
+# pages on a radar. The request is a line of invisible colour codes in chat, sent after the
+# player spawns and again whenever they spawn into another instance. OPT-IN ONLY: it is
+# honoured by Xaero's Minimap / World Map and by VoxelMap (and mods using its codes), and
+# ignored by modified clients and mods that do not know the convention. It can not be
+# enforced and is no replacement for anti-cheat.
+#
+# disabled minimap off where the mod supports it (Xaero), plus radar and cave mode off.
+# VoxelMap has no "off" code, it only loses radar and cave mode.
+# fair the map stays, radar and cave mode are asked to switch off.
+# off nothing is sent.
+#
+# LabyMod 4 is asked, after it announces itself on labymod:neo, to deny entity_marker. It has
+# no minimap or radar permission, so every mode but off sends the same thing.
+#
+# JourneyMap (current versions) is not covered: it is controlled by its own server mod.
+# An unknown value is logged and replaced by the default.
+#minimap.mode=disabled
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index 1b8031d1..8e3c08d8 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
@@ -41,6 +41,8 @@
import net.minestom.server.network.packet.client.common.ClientSettingsPacket;
import net.minestom.server.network.packet.client.play.ClientEntityActionPacket;
import net.onelitefeather.cygnus.adrenaline.AdrenalineService;
+import net.onelitefeather.cygnus.minimap.LabyModPolicy;
+import net.onelitefeather.cygnus.minimap.MinimapPolicy;
import net.onelitefeather.cygnus.ambient.AmbientProvider;
import net.onelitefeather.cygnus.page.PageProximityService;
import net.onelitefeather.cygnus.damage.DamageSoundService;
@@ -230,6 +232,8 @@ public Cygnus() {
this.spectatorService,
// Not part of the OverlayModule: the sound is the feedback a hit owes the player
// either way, and it needs neither the resource pack nor the overlay gate to be heard.
+ new MinimapPolicy(this.gameConfig.minimap()),
+ new LabyModPolicy(this.gameConfig.minimap()),
new DamageSoundService(this.gameConfig.damageSound(), System::currentTimeMillis),
new CreekModule(this.gameConfig.creek(), this.teamService, this.mapProvider,
sanityService, new HuntedTabWitness(new TracingCreekWitness(sanityService, this.actionTracer, Cygnus::survivorActor),
diff --git a/game/src/main/java/net/onelitefeather/cygnus/minimap/LabyModPolicy.java b/game/src/main/java/net/onelitefeather/cygnus/minimap/LabyModPolicy.java
new file mode 100644
index 00000000..61d20465
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/minimap/LabyModPolicy.java
@@ -0,0 +1,131 @@
+package net.onelitefeather.cygnus.minimap;
+
+import net.minestom.server.entity.Player;
+import net.minestom.server.event.Event;
+import net.minestom.server.event.EventNode;
+import net.minestom.server.event.player.PlayerPluginMessageEvent;
+import net.onelitefeather.cygnus.GameFeature;
+import net.onelitefeather.cygnus.common.config.MinimapConfig;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+/**
+ * Asks LabyMod to switch off the features that could give a survivor an edge.
+ *
+ * LabyMod 4 talks to servers on the plugin channel {@value #CHANNEL}. A packet is a VarInt id followed by
+ * its fields. The client opens the conversation with a {@code VersionLoginPacket} (id {@value #LOGIN_ID}),
+ * and only then the policy answers with a {@code PermissionPacket} (id {@value #PERMISSION_ID}): a VarInt
+ * count, then per permission its identifier as a VarInt-prefixed UTF-8 string and an allowed flag as one
+ * byte. The few bytes are written by hand instead of pulling in the Server API.
+ *
+ *
+ * LabyMod 4 has no minimap or radar of its own, so there is no permission to take away for it. What the
+ * client does register and what could leak the position of others is {@value #ENTITY_MARKER}, which lets
+ * a player put a marker on an entity. It is off by default; denying it makes that explicit. Like the
+ * minimap lines this is a request that a modified client can ignore.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class LabyModPolicy implements GameFeature {
+
+ /** The channel LabyMod 4 uses. */
+ public static final String CHANNEL = "labymod:neo";
+
+ /** The id of the packet the client opens the conversation with. */
+ public static final int LOGIN_ID = 0;
+
+ /** The id of the packet that allows and denies features. */
+ public static final int PERMISSION_ID = 10;
+
+ /** Lets a player put a marker on an entity. */
+ public static final String ENTITY_MARKER = "entity_marker";
+
+ private final EventNode node = EventNode.all("labymod");
+ private final MinimapConfig config;
+
+ /**
+ * Creates the policy.
+ *
+ * @param config the mode, shared with the minimap policy
+ */
+ public LabyModPolicy(MinimapConfig config) {
+ this.config = config;
+ this.node.addListener(PlayerPluginMessageEvent.class, event -> {
+ if (CHANNEL.equals(event.getIdentifier()) && isLogin(event.getMessage())) {
+ this.apply(event.getPlayer());
+ }
+ });
+ }
+
+ /**
+ * Returns the permissions a mode denies.
+ *
+ * @param mode the mode
+ * @return the identifiers, empty for {@link MinimapConfig.Mode#OFF}
+ */
+ public static List denied(MinimapConfig.Mode mode) {
+ return mode == MinimapConfig.Mode.OFF ? List.of() : List.of(ENTITY_MARKER);
+ }
+
+ /**
+ * Encodes a {@code PermissionPacket} that denies the given permissions.
+ *
+ * @param denied the identifiers to deny
+ * @return the payload to send on {@value #CHANNEL}
+ */
+ public static byte[] permissionPacket(List denied) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ writeVarInt(out, PERMISSION_ID);
+ writeVarInt(out, denied.size());
+ for (String identifier : denied) {
+ byte[] bytes = identifier.getBytes(StandardCharsets.UTF_8);
+ writeVarInt(out, bytes.length);
+ out.writeBytes(bytes);
+ out.write(0);
+ }
+ return out.toByteArray();
+ }
+
+ /**
+ * Sends the permissions of the configured mode to a player. Does nothing for
+ * {@link MinimapConfig.Mode#OFF}.
+ *
+ * @param player the LabyMod player
+ */
+ public void apply(Player player) {
+ List denied = denied(this.config.mode());
+ if (denied.isEmpty()) {
+ return;
+ }
+ player.sendPluginMessage(CHANNEL, permissionPacket(denied));
+ }
+
+ private static boolean isLogin(byte[] message) {
+ // The id is a VarInt; 0 is a single zero byte.
+ return message.length > 0 && message[0] == LOGIN_ID;
+ }
+
+ private static void writeVarInt(ByteArrayOutputStream out, int value) {
+ int rest = value;
+ while ((rest & -128) != 0) {
+ out.write(rest & 127 | 128);
+ rest >>>= 7;
+ }
+ out.write(rest);
+ }
+
+ @Override
+ public EventNode node() {
+ return this.node;
+ }
+
+ @Override
+ public boolean enabled() {
+ return this.config.mode() != MinimapConfig.Mode.OFF;
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/minimap/MinimapPolicy.java b/game/src/main/java/net/onelitefeather/cygnus/minimap/MinimapPolicy.java
new file mode 100644
index 00000000..83f35a3a
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/minimap/MinimapPolicy.java
@@ -0,0 +1,99 @@
+package net.onelitefeather.cygnus.minimap;
+
+import net.kyori.adventure.text.Component;
+import net.minestom.server.entity.Player;
+import net.minestom.server.event.Event;
+import net.minestom.server.event.EventNode;
+import net.minestom.server.event.player.PlayerSpawnEvent;
+import net.onelitefeather.cygnus.GameFeature;
+import net.onelitefeather.cygnus.common.config.MinimapConfig;
+
+import java.util.List;
+
+/**
+ * Asks cooperating client minimap mods to switch themselves off.
+ *
+ * The mods listen to the system chat for lines made of nothing but colour codes. Such a line renders as
+ * an empty message, yet the mod reads the raw text and obeys it. The codes are sent as literal text, so
+ * the {@code §} characters reach the client untouched:
+ *
+ *
+ * - Xaero's Minimap / World Map: {@value #XAERO_FAIR_PLAY} (no entity radar, no cave mode) and
+ * {@value #XAERO_NO_MINIMAP} (no minimap at all).
+ * - VoxelMap and mods that copy its convention: {@value #VOXEL_NO_RADAR} (no radar) and
+ * {@value #VOXEL_NO_CAVES} (no cave mode). There is no code that switches VoxelMap off completely.
+ *
+ *
+ * This is opt-in on the mods' side and can not be enforced: a client that ignores the lines keeps its
+ * minimap. The lines are sent on every {@link PlayerSpawnEvent}, that is on the first spawn and whenever
+ * the player spawns into another instance, so a mod that forgets its flags on a world change is told
+ * again. JourneyMap is not covered, it takes its rules from a server mod.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class MinimapPolicy implements GameFeature {
+
+ /** Xaero: switch off the entity radar and the cave mode. */
+ public static final String XAERO_FAIR_PLAY = "§f§a§i§r§x§a§e§r§o";
+
+ /** Xaero: switch off the minimap entirely. */
+ public static final String XAERO_NO_MINIMAP = "§n§o§m§i§n§i§m§a§p";
+
+ /** VoxelMap: switch off the radar. */
+ public static final String VOXEL_NO_RADAR = "§3 §6 §3 §6 §3 §6 §e";
+
+ /** VoxelMap: switch off the cave mode. */
+ public static final String VOXEL_NO_CAVES = "§3 §6 §3 §6 §3 §6 §d";
+
+ private final EventNode node = EventNode.all("minimap");
+ private final MinimapConfig config;
+
+ /**
+ * Creates the policy.
+ *
+ * @param config the mode to ask the mods for
+ */
+ public MinimapPolicy(MinimapConfig config) {
+ this.config = config;
+ this.node.addListener(PlayerSpawnEvent.class, event -> this.apply(event.getPlayer()));
+ }
+
+ /**
+ * Returns the lines a mode sends, in the order they are sent.
+ *
+ * @param mode the mode
+ * @return the raw lines, empty for {@link MinimapConfig.Mode#OFF}
+ */
+ public static List codes(MinimapConfig.Mode mode) {
+ return switch (mode) {
+ case DISABLED -> List.of(XAERO_NO_MINIMAP, XAERO_FAIR_PLAY, VOXEL_NO_RADAR, VOXEL_NO_CAVES);
+ case FAIR -> List.of(XAERO_FAIR_PLAY, VOXEL_NO_RADAR, VOXEL_NO_CAVES);
+ case OFF -> List.of();
+ };
+ }
+
+ /**
+ * Sends the lines of the configured mode to a player, one system message each: Xaero compares a
+ * whole message with its code, so the lines must not be joined.
+ *
+ * @param player the player to ask
+ */
+ public void apply(Player player) {
+ for (String code : codes(this.config.mode())) {
+ player.sendMessage(Component.text(code));
+ }
+ }
+
+ @Override
+ public EventNode node() {
+ return this.node;
+ }
+
+ @Override
+ public boolean enabled() {
+ return this.config.mode() != MinimapConfig.Mode.OFF;
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/minimap/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/minimap/package-info.java
new file mode 100644
index 00000000..bbc126d5
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/minimap/package-info.java
@@ -0,0 +1,9 @@
+/**
+ * Holds the request to client minimap mods to switch themselves off.
+ *
+ * @since 2.15.0
+ */
+@NotNullByDefault
+package net.onelitefeather.cygnus.minimap;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/game/src/test/java/net/onelitefeather/cygnus/minimap/LabyModPolicyTest.java b/game/src/test/java/net/onelitefeather/cygnus/minimap/LabyModPolicyTest.java
new file mode 100644
index 00000000..97e5d61d
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/minimap/LabyModPolicyTest.java
@@ -0,0 +1,129 @@
+package net.onelitefeather.cygnus.minimap;
+
+import net.minestom.server.entity.Player;
+import net.minestom.server.event.EventDispatcher;
+import net.minestom.server.event.player.PlayerPluginMessageEvent;
+import net.minestom.server.instance.Instance;
+import net.minestom.server.network.packet.server.common.PluginMessagePacket;
+import net.minestom.testing.Collector;
+import net.minestom.testing.Env;
+import net.minestom.testing.TestConnection;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import net.onelitefeather.cygnus.common.config.MinimapConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class LabyModPolicyTest extends CygnusPlayerTestBase {
+
+ /** VarInt 10, VarInt 1, VarInt 13, "entity_marker", false. */
+ private static final byte[] DENY_ENTITY_MARKER = concat(
+ new byte[]{10, 1, 13}, "entity_marker".getBytes(StandardCharsets.UTF_8), new byte[]{0});
+
+ private Env installedIn;
+ private LabyModPolicy installed;
+
+ /** The event handler outlives a test, so a policy left on it would answer the next test too. */
+ @AfterEach
+ void uninstall() {
+ if (this.installed != null) {
+ this.installedIn.process().eventHandler().removeChild(this.installed.node());
+ }
+ }
+
+ private LabyModPolicy install(Env env, MinimapConfig.Mode mode) {
+ LabyModPolicy policy = new LabyModPolicy(new MinimapConfig(mode));
+ env.process().eventHandler().addChild(policy.node());
+ this.installedIn = env;
+ this.installed = policy;
+ return policy;
+ }
+
+ private static byte[] concat(byte[] first, byte[]... more) {
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+ out.writeBytes(first);
+ for (byte[] part : more) {
+ out.writeBytes(part);
+ }
+ return out.toByteArray();
+ }
+
+ /** The client's hello: VarInt id 0, then its version string. */
+ private static void clientHello(Player player, String channel) {
+ byte[] version = "4.2.0".getBytes(StandardCharsets.UTF_8);
+ byte[] payload = concat(new byte[]{0, (byte) version.length}, version);
+ EventDispatcher.call(new PlayerPluginMessageEvent(player, channel, payload));
+ }
+
+ @Test
+ @DisplayName("The permission packet denying the entity marker has the documented bytes")
+ void packetBytes() {
+ assertArrayEquals(DENY_ENTITY_MARKER, LabyModPolicy.permissionPacket(List.of("entity_marker")));
+ }
+
+ @Test
+ @DisplayName("Disabled mode answers the LabyMod hello with the permission packet")
+ void disabledAnswersHello(Env env) {
+ install(env, MinimapConfig.Mode.DISABLED);
+ TestConnection connection = env.createConnection();
+ Player player = connection.connect(env.createFlatInstance());
+ Collector messages = connection.trackIncoming(PluginMessagePacket.class);
+
+ clientHello(player, "labymod:neo");
+
+ List sent = messages.collect();
+ assertEquals(1, sent.size(), "one permission packet");
+ assertEquals("labymod:neo", sent.get(0).channel());
+ assertArrayEquals(DENY_ENTITY_MARKER, sent.get(0).data());
+ }
+
+ @Test
+ @DisplayName("Fair mode sends the same permissions")
+ void fairAnswersHello(Env env) {
+ install(env, MinimapConfig.Mode.FAIR);
+ TestConnection connection = env.createConnection();
+ Player player = connection.connect(env.createFlatInstance());
+ Collector messages = connection.trackIncoming(PluginMessagePacket.class);
+
+ clientHello(player, "labymod:neo");
+
+ assertArrayEquals(DENY_ENTITY_MARKER, messages.collect().get(0).data());
+ }
+
+ @Test
+ @DisplayName("Off mode sends nothing and is disabled")
+ void offSendsNothing(Env env) {
+ LabyModPolicy policy = install(env, MinimapConfig.Mode.OFF);
+ TestConnection connection = env.createConnection();
+ Player player = connection.connect(env.createFlatInstance());
+ Collector messages = connection.trackIncoming(PluginMessagePacket.class);
+
+ clientHello(player, "labymod:neo");
+ policy.apply(player);
+
+ assertTrue(messages.collect().isEmpty(), "off must not send any plugin message");
+ assertFalse(policy.enabled(), "off must not hook into the event tree");
+ }
+
+ @Test
+ @DisplayName("Other channels and other LabyMod packets do not trigger an answer")
+ void ignoresOtherMessages(Env env) {
+ install(env, MinimapConfig.Mode.DISABLED);
+ TestConnection connection = env.createConnection();
+ Player player = connection.connect(env.createFlatInstance());
+ Collector messages = connection.trackIncoming(PluginMessagePacket.class);
+
+ clientHello(player, "minecraft:brand");
+ EventDispatcher.call(new PlayerPluginMessageEvent(player, "labymod:neo", new byte[]{35, 0}));
+
+ assertTrue(messages.collect().isEmpty(), "only the hello is answered");
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/minimap/MinimapPolicyTest.java b/game/src/test/java/net/onelitefeather/cygnus/minimap/MinimapPolicyTest.java
new file mode 100644
index 00000000..00338b6f
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/minimap/MinimapPolicyTest.java
@@ -0,0 +1,127 @@
+package net.onelitefeather.cygnus.minimap;
+
+import net.kyori.adventure.text.TextComponent;
+import net.minestom.server.entity.Player;
+import net.minestom.server.instance.Instance;
+import net.minestom.server.network.packet.server.play.SystemChatPacket;
+import net.minestom.testing.Collector;
+import net.minestom.testing.Env;
+import net.minestom.testing.TestConnection;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import net.onelitefeather.cygnus.common.config.MinimapConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MinimapPolicyTest extends CygnusPlayerTestBase {
+
+ private Env installedIn;
+ private MinimapPolicy installed;
+
+ /** The event handler outlives a test, so a policy left on it would answer the next test's joins too. */
+ @AfterEach
+ void uninstall() {
+ if (this.installed != null) {
+ this.installedIn.process().eventHandler().removeChild(this.installed.node());
+ }
+ }
+
+ private MinimapPolicy install(Env env, MinimapConfig.Mode mode) {
+ MinimapPolicy policy = new MinimapPolicy(new MinimapConfig(mode));
+ env.process().eventHandler().addChild(policy.node());
+ this.installedIn = env;
+ this.installed = policy;
+ return policy;
+ }
+
+ private static List raw(Collector collector) {
+ return collector.collect().stream()
+ .map(packet -> ((TextComponent) packet.message()).content())
+ .toList();
+ }
+
+ @Test
+ @DisplayName("Disabled mode sends the Xaero and VoxelMap lines on join")
+ void disabledSendsAllCodes(Env env) {
+ install(env, MinimapConfig.Mode.DISABLED);
+ Instance instance = env.createFlatInstance();
+ TestConnection connection = env.createConnection();
+ Collector chat = connection.trackIncoming(SystemChatPacket.class);
+
+ connection.connect(instance);
+
+ assertEquals(List.of("§n§o§m§i§n§i§m§a§p", "§f§a§i§r§x§a§e§r§o",
+ "§3 §6 §3 §6 §3 §6 §e", "§3 §6 §3 §6 §3 §6 §d"), raw(chat));
+ }
+
+ @Test
+ @DisplayName("Fair mode keeps the map and only asks for radar and cave mode off")
+ void fairSendsNoMinimapOffCode(Env env) {
+ install(env, MinimapConfig.Mode.FAIR);
+ Instance instance = env.createFlatInstance();
+ TestConnection connection = env.createConnection();
+ Collector chat = connection.trackIncoming(SystemChatPacket.class);
+
+ connection.connect(instance);
+
+ List lines = raw(chat);
+ assertEquals(List.of("§f§a§i§r§x§a§e§r§o", "§3 §6 §3 §6 §3 §6 §e", "§3 §6 §3 §6 §3 §6 §d"), lines);
+ assertFalse(lines.contains(MinimapPolicy.XAERO_NO_MINIMAP), "fair play must not switch the minimap off");
+ }
+
+ @Test
+ @DisplayName("Off mode sends nothing and the feature is disabled")
+ void offSendsNothing(Env env) {
+ MinimapPolicy policy = install(env, MinimapConfig.Mode.OFF);
+ Instance instance = env.createFlatInstance();
+ TestConnection connection = env.createConnection();
+ Collector chat = connection.trackIncoming(SystemChatPacket.class);
+
+ connection.connect(instance);
+
+ assertTrue(raw(chat).isEmpty(), "off must not send any message");
+ assertFalse(policy.enabled(), "off must not hook into the event tree");
+ }
+
+ @Test
+ @DisplayName("Spawning into another instance asks the mods again")
+ void worldChangeResends(Env env) {
+ install(env, MinimapConfig.Mode.FAIR);
+ Instance lobby = env.createFlatInstance();
+ Instance map = env.createFlatInstance();
+ TestConnection connection = env.createConnection();
+ Player player = connection.connect(lobby);
+ Collector chat = connection.trackIncoming(SystemChatPacket.class);
+
+ player.setInstance(map).join();
+
+ assertEquals(MinimapPolicy.codes(MinimapConfig.Mode.FAIR), raw(chat));
+ }
+
+ @Test
+ @DisplayName("The lines are literal text with the section signs intact and nothing visible")
+ void linesAreRawLiteralText(Env env) {
+ install(env, MinimapConfig.Mode.DISABLED);
+ Instance instance = env.createFlatInstance();
+ TestConnection connection = env.createConnection();
+ Collector chat = connection.trackIncoming(SystemChatPacket.class);
+
+ connection.connect(instance);
+
+ List packets = chat.collect();
+ assertEquals(4, packets.size(), "one message per code");
+ for (SystemChatPacket packet : packets) {
+ TextComponent text = (TextComponent) packet.message();
+ assertTrue(text.content().startsWith("§"), "the section sign must survive: " + text.content());
+ assertTrue(text.children().isEmpty(), "no children that could show something");
+ assertEquals("", text.content().replaceAll("§.| ", ""), "only colour codes, nothing visible");
+ assertFalse(packet.overlay(), "a system line, not the action bar");
+ }
+ }
+}