From 33cff90c828dbb5f1d0d05be53d5bbfefd1e4fb9 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Wed, 30 Sep 2026 12:24:42 +0200 Subject: [PATCH] feat(mod): apply server policy from the proxy --- .../main/java/dev/loki/lovisual/LoVisual.java | 2 + .../core/ext/ModuleLifecycleHelper.java | 22 ++++- .../platform/serverpolicy/ServerPolicy.java | 31 +++++++ .../serverpolicy/ServerPolicyClient.java | 79 +++++++++++++++++ .../serverpolicy/ServerPolicyCodec.java | 76 +++++++++++++++++ .../serverpolicy/ServerPolicyState.java | 66 ++++++++++++++ .../resources/assets/lovisual/lang/en_us.json | 5 ++ .../resources/assets/lovisual/lang/ru_ru.json | 5 ++ .../serverpolicy/ServerPolicyCodecTest.java | 85 +++++++++++++++++++ .../serverpolicy/ServerPolicyStateTest.java | 85 +++++++++++++++++++ 10 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicy.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyClient.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyCodec.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyState.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyCodecTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyStateTest.java diff --git a/mod/src/main/java/dev/loki/lovisual/LoVisual.java b/mod/src/main/java/dev/loki/lovisual/LoVisual.java index d4f413f..6476f76 100644 --- a/mod/src/main/java/dev/loki/lovisual/LoVisual.java +++ b/mod/src/main/java/dev/loki/lovisual/LoVisual.java @@ -25,6 +25,7 @@ import dev.loki.lovisual.features.module.phase.Notifier; import dev.loki.lovisual.features.module.lifecycle.ModuleAutoLoader; import dev.loki.lovisual.features.module.core.ModuleManager; import dev.loki.lovisual.features.module.phase.WorldPhase; +import dev.loki.lovisual.features.platform.serverpolicy.ServerPolicyClient; import dev.loki.lovisual.render.effects.NetherPortalRiftPass; import dev.loki.lovisual.render.effects.SleepOverlayPass; import dev.loki.lovisual.render.engine.LoVisualRenderEngineBootstrap; @@ -60,6 +61,7 @@ public class LoVisual implements ClientModInitializer { AccountConfig.get(); ProxyBackend.init(); CommandManager.init(); + ServerPolicyClient.init(); MediaSessionService.get().init(); ClientLifecycleEvents.CLIENT_STOPPING.register(client -> MediaSessionService.get().shutdown()); diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/core/ext/ModuleLifecycleHelper.java b/mod/src/main/java/dev/loki/lovisual/features/module/core/ext/ModuleLifecycleHelper.java index 62df25d..44b8a8f 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/core/ext/ModuleLifecycleHelper.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/core/ext/ModuleLifecycleHelper.java @@ -4,9 +4,12 @@ import dev.loki.lovisual.Lifecycle; import dev.loki.lovisual.addon.manager.ModuleExtensionManager; import dev.loki.lovisual.config.values.mode.EnumValue; import dev.loki.lovisual.config.values.primitive.BooleanValue; +import dev.loki.lovisual.features.command.util.CommandOutput; import dev.loki.lovisual.features.module.core.Module; import dev.loki.lovisual.features.module.core.ModuleManager; import dev.loki.lovisual.features.module.lifecycle.ModuleActivationSource; +import dev.loki.lovisual.features.platform.serverpolicy.ServerPolicyState; +import net.minecraft.client.resources.language.I18n; final class ModuleLifecycleHelper { private final Module owner; @@ -28,6 +31,7 @@ final class ModuleLifecycleHelper { void setEnabled(boolean state, ModuleActivationSource src, BooleanValue ev, EnumValue av) { if (state && !Lifecycle.isActive()) return; + if (state && !serverPolicyAllows()) return; ModuleActivationSource norm = normalize(state, src); if (enabled==state) { if (source==norm && ev.get()==enabled && av.get()==source) return; @@ -47,7 +51,23 @@ final class ModuleLifecycleHelper { dev.loki.lovisual.features.module.module.ModuleConfigValues.requestSaveQuietly(owner); } + /** Server policy gate: deny in lock mode, warn-but-allow in suggest mode. */ + private boolean serverPolicyAllows() { + String id = owner.name(); + if (ServerPolicyState.allows(id)) { + if (ServerPolicyState.suggestWarn(id)) { + CommandOutput.warning(I18n.get("lovisual.policy.suggest", + owner.getDisplayName(), ServerPolicyState.reason())); + } + return true; + } + CommandOutput.warning(I18n.get("lovisual.policy.denied", + owner.getDisplayName(), ServerPolicyState.reason())); + return false; + } + void setTransient(boolean state, BooleanValue ev, EnumValue av) { + if (state && !ServerPolicyState.allows(owner.name())) return; if (enabled==state) return; enabled=state; if (enabled) { if (ModuleExtensionManager.beforeEnable(owner)) { owner.onEnable(); ModuleExtensionManager.afterEnable(owner);} else enabled=false; @@ -56,7 +76,7 @@ final class ModuleLifecycleHelper { } void loadAndApply(BooleanValue ev, EnumValue av) { - enabled=ev.get(); source=normalize(enabled, av.get()); av.set(source); + enabled=ev.get() && ServerPolicyState.allows(owner.name()); source=normalize(enabled, av.get()); av.set(source); if (enabled) { if (ModuleExtensionManager.beforeEnable(owner)) { owner.onEnable(); ModuleExtensionManager.afterEnable(owner);} else { enabled=false; source=ModuleActivationSource.NONE; ev.set(false); av.set(source); } diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicy.java b/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicy.java new file mode 100644 index 0000000..3a9b7e8 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicy.java @@ -0,0 +1,31 @@ +package dev.loki.lovisual.features.platform.serverpolicy; + +import java.util.List; + +/** + * A parsed server policy from the {@code lovisual:policy} channel: which + * modules the proxy denies ({@link Mode#LOCK} = refuse to enable and lock the + * ClickGui card, {@link Mode#SUGGEST} = warn only). + */ +public record ServerPolicy(Mode mode, String reason, List deny, String server) { + + public enum Mode { + LOCK, + SUGGEST + } + + public ServerPolicy { + mode = mode == null ? Mode.LOCK : mode; + reason = reason == null ? "" : reason; + deny = deny == null ? List.of() : List.copyOf(deny); + server = server == null ? "" : server; + } + + public boolean isLocked() { + return mode == Mode.LOCK; + } + + public boolean denies(String moduleId) { + return moduleId != null && deny.contains(moduleId); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyClient.java b/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyClient.java new file mode 100644 index 0000000..30eea64 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyClient.java @@ -0,0 +1,79 @@ +package dev.loki.lovisual.features.platform.serverpolicy; + +import dev.loki.lovisual.features.command.util.CommandOutput; +import dev.loki.lovisual.features.module.core.Module; +import dev.loki.lovisual.features.module.core.ModuleManager; +import io.netty.buffer.ByteBuf; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.minecraft.client.resources.language.I18n; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +/** + * Fabric side of the {@code lovisual:policy} channel: registers the payload, + * asks the proxy for a policy on every join, applies incoming policies (force + * disabling denied modules) and forgets everything on disconnect so + * singleplayer has no policy. + */ +public final class ServerPolicyClient { + + private ServerPolicyClient() { + } + + /** The one-field JSON payload travelling on {@code lovisual:policy}. */ + public record Payload(String json) implements CustomPacketPayload { + + public static final CustomPacketPayload.Type TYPE = + new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath("lovisual", "policy")); + public static final StreamCodec CODEC = + StreamCodec.composite(ByteBufCodecs.STRING_UTF8, Payload::json, Payload::new); + + @Override + public Type type() { + return TYPE; + } + } + + /** Called once from {@code LoVisual#onInitializeClient}. */ + public static void init() { + PayloadTypeRegistry.serverboundPlay().register(Payload.TYPE, Payload.CODEC); + PayloadTypeRegistry.clientboundPlay().register(Payload.TYPE, Payload.CODEC); + ClientPlayNetworking.registerReceiver(Payload.TYPE, + (payload, context) -> onPolicy(payload.json())); + ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> + ClientPlayNetworking.send(new Payload(ServerPolicyCodec.REQUEST_JSON))); + ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> + ServerPolicyState.clear()); + } + + private static void onPolicy(String json) { + ServerPolicy policy = ServerPolicyCodec.parse(json); + if (policy == null) { + return; + } + ServerPolicyState.apply(policy); + forceDisableDenied(policy); + } + + /** A lock policy must stop modules that are already running. */ + private static void forceDisableDenied(ServerPolicy policy) { + if (!policy.isLocked()) { + return; + } + boolean stoppedAny = false; + for (String id : policy.deny()) { + Module module = ModuleManager.get(id); + if (module != null && module.isEnabled()) { + module.setEnabledInternal(false); + stoppedAny = true; + } + } + if (stoppedAny) { + CommandOutput.warning(I18n.get("lovisual.policy.applied", policy.reason())); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyCodec.java b/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyCodec.java new file mode 100644 index 0000000..4455d76 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyCodec.java @@ -0,0 +1,76 @@ +package dev.loki.lovisual.features.platform.serverpolicy; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.ArrayList; +import java.util.List; + +/** + * JSON half of the {@code lovisual:policy} channel. Parsing is deliberately + * forgiving: anything that is not a well-formed {@code "type":"policy"} + * object returns {@code null}, and an unknown mode degrades to the + * conservative {@link ServerPolicy.Mode#LOCK} — a broken payload must never + * silently unlock the client. + */ +public final class ServerPolicyCodec { + + /** The C→S answer the proxy matches on to push the policy again. */ + public static final String REQUEST_JSON = "{\"type\":\"request\"}"; + + private ServerPolicyCodec() { + } + + public static ServerPolicy parse(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + JsonElement element = JsonParser.parseString(json); + if (!element.isJsonObject()) { + return null; + } + JsonObject object = element.getAsJsonObject(); + JsonElement type = object.get("type"); + if (type == null || !type.isJsonPrimitive() || !"policy".equals(type.getAsString())) { + return null; + } + return new ServerPolicy( + parseMode(object.get("mode")), + string(object.get("reason")), + parseDeny(object.get("deny")), + string(object.get("server"))); + } catch (RuntimeException e) { + return null; + } + } + + private static ServerPolicy.Mode parseMode(JsonElement raw) { + if (raw == null || !raw.isJsonPrimitive()) { + return ServerPolicy.Mode.LOCK; + } + String value = raw.getAsString(); + if ("suggest".equalsIgnoreCase(value)) { + return ServerPolicy.Mode.SUGGEST; + } + return ServerPolicy.Mode.LOCK; + } + + private static List parseDeny(JsonElement raw) { + if (raw == null || !raw.isJsonArray()) { + return List.of(); + } + List out = new ArrayList<>(); + for (JsonElement item : raw.getAsJsonArray()) { + if (item != null && item.isJsonPrimitive()) { + out.add(item.getAsString()); + } + } + return out; + } + + private static String string(JsonElement raw) { + return raw != null && raw.isJsonPrimitive() ? raw.getAsString() : ""; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyState.java b/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyState.java new file mode 100644 index 0000000..8d5d3b9 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyState.java @@ -0,0 +1,66 @@ +package dev.loki.lovisual.features.platform.serverpolicy; + +/** + * Static holder for the active server policy. {@link #allows} is the single + * gate the module lifecycle consults; ClickGui reads {@link #isLocked} for the + * card lock and {@link #suggestWarn} for the warn-but-allow path. + */ +public final class ServerPolicyState { + + private static volatile ServerPolicy current; + private static volatile long receivedAtMs; + + private ServerPolicyState() { + } + + /** Installs a parsed policy (replacing any previous one). */ + public static void apply(ServerPolicy policy) { + if (policy == null) { + return; + } + current = policy; + receivedAtMs = System.currentTimeMillis(); + } + + /** Drops the policy — called on disconnect so singleplayer is unaffected. */ + public static void clear() { + current = null; + receivedAtMs = 0L; + } + + public static ServerPolicy current() { + return current; + } + + public static long receivedAtMs() { + return receivedAtMs; + } + + public static boolean active() { + return current != null; + } + + /** True when the module may be enabled: no policy, no lock, or not denied. */ + public static boolean allows(String moduleId) { + ServerPolicy policy = current; + return policy == null || !policy.isLocked() || !policy.denies(moduleId); + } + + /** True when the module is listed by a {@code lock} policy. */ + public static boolean isLocked(String moduleId) { + ServerPolicy policy = current; + return policy != null && policy.isLocked() && policy.denies(moduleId); + } + + /** True when the module is listed by a {@code suggest} policy. */ + public static boolean suggestWarn(String moduleId) { + ServerPolicy policy = current; + return policy != null && !policy.isLocked() && policy.denies(moduleId); + } + + /** The proxy-supplied reason text, for messages; empty when no policy. */ + public static String reason() { + ServerPolicy policy = current; + return policy == null ? "" : policy.reason(); + } +} diff --git a/mod/src/main/resources/assets/lovisual/lang/en_us.json b/mod/src/main/resources/assets/lovisual/lang/en_us.json index 5a38475..3088883 100644 --- a/mod/src/main/resources/assets/lovisual/lang/en_us.json +++ b/mod/src/main/resources/assets/lovisual/lang/en_us.json @@ -3259,6 +3259,7 @@ "command.iris.description": "Shows Iris and shader compatibility diagnostics.", "command.modules.description": "Lists modules by state or category.", "command.ping.description": "Shows the current server latency.", + "command.policy.description": "Shows the policy the proxy sends to this client (mode, denied modules, reason).", "command.profiler.description": "Controls rendering and runtime profilers.", "command.serverinfo.description": "Shows information about the current world or server.", "command.staff.description": "Manages the client staff list.", @@ -3266,6 +3267,10 @@ "command.tps.description": "Shows estimated server ticks per second.", "command.username.description": "Shows the current Minecraft username and UUID.", "command.xaero.description": "Adds, lists, or removes Xaero waypoints.", + "lovisual.policy.applied": "The server disabled modules by its rules: %s", + "lovisual.policy.denied": "Module %s is disabled by server policy: %s", + "lovisual.policy.locked": "Disabled by server: %s", + "lovisual.policy.suggest": "The server does not recommend %s: %s", "module.animations.description": "Animates interface transitions.", "setting.animations.hotbar_raise": "Hotbar raise", "setting.animations.inventory": "Inventory open", diff --git a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json index 7392d77..ec844d2 100644 --- a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json +++ b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json @@ -3054,6 +3054,7 @@ "command.iris.description": "Показывает диагностику совместимости Iris и шейдеров.", "command.modules.description": "Показывает модули по состоянию или категории.", "command.ping.description": "Показывает текущую задержку до сервера.", + "command.policy.description": "Показывает политику, которую прокси отправляет клиенту (режим, запрещённые модули, причина).", "command.profiler.description": "Управляет профилировщиками рендера и среды выполнения.", "command.serverinfo.description": "Показывает информацию о текущем мире или сервере.", "command.staff.description": "Управляет списком персонала клиента.", @@ -3061,6 +3062,10 @@ "command.tps.description": "Показывает расчётное количество серверных тиков в секунду.", "command.username.description": "Показывает текущее имя Minecraft и UUID.", "command.xaero.description": "Добавляет, показывает или удаляет точки Xaero.", + "lovisual.policy.applied": "Сервер отключил модули по своим правилам: %s", + "lovisual.policy.denied": "Модуль %s отключён политикой сервера: %s", + "lovisual.policy.locked": "Отключено сервером: %s", + "lovisual.policy.suggest": "Сервер не рекомендует %s: %s", "module.animations.description": "Плавные анимации интерфейса.", "setting.animations.hotbar_raise": "Подъём хотбара", "setting.animations.inventory": "Открытие инвентаря", diff --git a/mod/src/test/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyCodecTest.java b/mod/src/test/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyCodecTest.java new file mode 100644 index 0000000..69de425 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyCodecTest.java @@ -0,0 +1,85 @@ +package dev.loki.lovisual.features.platform.serverpolicy; + +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Contract of the S→C half of the {@code lovisual:policy} channel: the proxy + * pushes one JSON object, everything else (garbage, non-policy messages) must + * degrade to {@code null} so a broken payload never locks the client. + */ +public final class ServerPolicyCodecTest { + + @Test + void parsesFullPolicy() { + ServerPolicy policy = ServerPolicyCodec.parse(""" + {"type":"policy","mode":"lock","reason":"Правила сервера", + "deny":["esp","freecam"],"server":"lobby-1"}"""); + assertNotNull(policy); + assertEquals(ServerPolicy.Mode.LOCK, policy.mode()); + assertTrue(policy.isLocked()); + assertEquals("Правила сервера", policy.reason()); + assertEquals(List.of("esp", "freecam"), policy.deny()); + assertEquals("lobby-1", policy.server()); + assertTrue(policy.denies("esp")); + assertFalse(policy.denies("hitboxes")); + } + + @Test + void suggestModeParses() { + ServerPolicy policy = ServerPolicyCodec.parse( + "{\"type\":\"policy\",\"mode\":\"suggest\",\"reason\":\"только подсказка\",\"deny\":[\"esp\"]}"); + assertNotNull(policy); + assertEquals(ServerPolicy.Mode.SUGGEST, policy.mode()); + assertFalse(policy.isLocked()); + } + + @Test + void unknownOrMissingModeFallsBackToLock() { + ServerPolicy unknown = ServerPolicyCodec.parse( + "{\"type\":\"policy\",\"mode\":\"banana\",\"deny\":[\"esp\"]}"); + assertNotNull(unknown); + assertEquals(ServerPolicy.Mode.LOCK, unknown.mode()); + + ServerPolicy missing = ServerPolicyCodec.parse("{\"type\":\"policy\",\"deny\":[\"esp\"]}"); + assertNotNull(missing); + assertEquals(ServerPolicy.Mode.LOCK, missing.mode()); + } + + @Test + void missingDenyOrServerBecomesEmpty() { + ServerPolicy policy = ServerPolicyCodec.parse("{\"type\":\"policy\",\"mode\":\"lock\"}"); + assertNotNull(policy); + assertTrue(policy.deny().isEmpty()); + assertEquals("", policy.server()); + assertEquals("", policy.reason()); + assertFalse(policy.denies("esp")); + } + + @Test + void nonPolicyPayloadsReturnNull() { + assertNull(ServerPolicyCodec.parse(null)); + assertNull(ServerPolicyCodec.parse("")); + assertNull(ServerPolicyCodec.parse(" ")); + assertNull(ServerPolicyCodec.parse("garbage")); + assertNull(ServerPolicyCodec.parse("[1,2,3]")); + assertNull(ServerPolicyCodec.parse("\"policy\"")); + assertNull(ServerPolicyCodec.parse("{\"type\":\"request\"}")); + assertNull(ServerPolicyCodec.parse("{\"deny\":[\"esp\"]}")); + assertNull(ServerPolicyCodec.parse("{\"type\":42}")); + } + + @Test + void requestJsonCarriesTheTypeTheProxyMatchesOn() { + com.google.gson.JsonObject request = com.google.gson.JsonParser + .parseString(ServerPolicyCodec.REQUEST_JSON).getAsJsonObject(); + assertEquals("request", request.get("type").getAsString()); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyStateTest.java b/mod/src/test/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyStateTest.java new file mode 100644 index 0000000..ccfa1ed --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/platform/serverpolicy/ServerPolicyStateTest.java @@ -0,0 +1,85 @@ +package dev.loki.lovisual.features.platform.serverpolicy; + +import org.junit.jupiter.api.BeforeEach; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The gate the whole mod obeys: {@link ServerPolicyState#allows} is consulted + * by module enablement, ClickGui locks and the suggest-warning path. State is + * static, so every test resets it in {@code @BeforeEach}. + */ +public final class ServerPolicyStateTest { + + private static ServerPolicy lockPolicy() { + return new ServerPolicy(ServerPolicy.Mode.LOCK, "Правила сервера", + List.of("esp", "freecam"), "lobby-1"); + } + + @BeforeEach + void reset() { + ServerPolicyState.clear(); + } + + @Test + void startsUnlockedWithNoPolicy() { + assertNull(ServerPolicyState.current()); + assertTrue(ServerPolicyState.allows("esp")); + assertFalse(ServerPolicyState.isLocked("esp")); + assertFalse(ServerPolicyState.suggestWarn("esp")); + assertEquals("", ServerPolicyState.reason()); + assertEquals(0L, ServerPolicyState.receivedAtMs()); + } + + @Test + void lockPolicyBlocksOnlyDeniedModules() { + ServerPolicyState.apply(lockPolicy()); + + assertFalse(ServerPolicyState.allows("esp")); + assertFalse(ServerPolicyState.allows("freecam")); + assertTrue(ServerPolicyState.isLocked("esp")); + assertTrue(ServerPolicyState.allows("hitboxes")); + assertFalse(ServerPolicyState.isLocked("hitboxes")); + assertEquals("Правила сервера", ServerPolicyState.reason()); + assertEquals("lobby-1", ServerPolicyState.current().server()); + assertTrue(ServerPolicyState.receivedAtMs() > 0L); + } + + @Test + void suggestPolicyWarnsButStillAllows() { + ServerPolicyState.apply(new ServerPolicy(ServerPolicy.Mode.SUGGEST, "рекомендация", + List.of("esp"), "")); + + assertTrue(ServerPolicyState.allows("esp")); + assertFalse(ServerPolicyState.isLocked("esp")); + assertTrue(ServerPolicyState.suggestWarn("esp")); + assertFalse(ServerPolicyState.suggestWarn("freecam")); + } + + @Test + void applyingNewPolicyReplacesTheOldOne() { + ServerPolicyState.apply(lockPolicy()); + ServerPolicyState.apply(new ServerPolicy(ServerPolicy.Mode.SUGGEST, "новая", List.of(), "bedwars")); + + assertTrue(ServerPolicyState.allows("esp")); + assertEquals("новая", ServerPolicyState.reason()); + assertEquals("bedwars", ServerPolicyState.current().server()); + } + + @Test + void clearRestoresFullAccess() { + ServerPolicyState.apply(lockPolicy()); + ServerPolicyState.clear(); + + assertTrue(ServerPolicyState.allows("esp")); + assertFalse(ServerPolicyState.isLocked("esp")); + assertNull(ServerPolicyState.current()); + assertEquals(0L, ServerPolicyState.receivedAtMs()); + } +}