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"); + } + } +}