Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,6 +47,7 @@ public record GameConfig(
StaminaConfig stamina,
AdrenalineConfig adrenaline,
TelemetryConfig telemetry,
MinimapConfig minimap,
float lobbyAtmosphereShare
) {

Expand Down Expand Up @@ -127,6 +129,7 @@ public record GameConfig(
StaminaConfig.DEFAULT,
AdrenalineConfig.DEFAULT,
TelemetryConfig.DEFAULT,
MinimapConfig.DEFAULT,
DEFAULT_LOBBY_ATMOSPHERE_SHARE
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,14 @@
* <li>stamina.* (see {@link StaminaConfig})</li>
* <li>adrenaline.* (see {@link AdrenalineConfig})</li>
* <li>telemetry.* (see {@link TelemetryConfig})</li>
* <li>minimap.mode (see {@link MinimapConfig})</li>
* </ul>
* <p>
* 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
*/
Expand All @@ -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;

Expand Down Expand Up @@ -155,6 +157,7 @@ public GameConfig getConfig() {
getStamina(properties),
getAdrenaline(properties),
getTelemetry(properties),
getMinimap(properties),
getFloat(properties, "lobbyAtmosphereShare", GameConfig.DEFAULT.lobbyAtmosphereShare())
);
}
Expand Down Expand Up @@ -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}.
* <p>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package net.onelitefeather.cygnus.common.config;

/**
* Settings for the request Cygnus sends to client minimap mods to switch themselves off.
* <p>
* 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.
* </p>
*
* @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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
}
}
20 changes: 20 additions & 0 deletions config.properties.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* </p>
* <p>
* 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.
* </p>
*
* @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<Event> 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<String> 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<String> 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<String> 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<Event> node() {
return this.node;
}

@Override
public boolean enabled() {
return this.config.mode() != MinimapConfig.Mode.OFF;
}
}
Loading
Loading