From 75dc607dced4896704cb05c44344cf776edd268f Mon Sep 17 00:00:00 2001 From: rdavis Date: Thu, 28 May 2026 00:38:30 -0500 Subject: [PATCH 01/23] Rename thunderstore release workflow to publish --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3a07d4f..565b034 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ This repository includes GitHub Actions workflows for Thunderstore packaging and - `.github/workflows/thunderstore-build.yml` - Runs on push/PR/manual dispatch. - Builds `LobbyKit` and uploads a Thunderstore zip artifact. -- `.github/workflows/thunderstore-release.yml` +- `.github/workflows/thunderstore-publish.yml` - Runs when a GitHub Release is published (or manually via workflow dispatch). - Builds and publishes with `tcli publish`. - All workflows accept `dryrun` on manual dispatch; `dryrun=true` echoes commands and skips execution-sensitive steps. From 257fa35145e9cd8f6cf7994ac96820d4e97ddc83 Mon Sep 17 00:00:00 2001 From: rdavis Date: Thu, 28 May 2026 01:04:28 -0500 Subject: [PATCH 02/23] Update README.md to clarify host requirements and installation notes --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 565b034..953342e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ Host and lobby quality-of-life tools for **Sledding Game**. The internal project is named `LobbyKit`, matching the mod package name. +## Host Requirement + +LobbyKit is primarily a host/server-side mod. Most functionality (especially chat commands) is driven by the lobby host having LobbyKit installed. Clients generally do not need to install LobbyKit to join and use host-provided features. + ## Features - Host lobbies with up to 64 players. @@ -41,6 +45,8 @@ Install with Gale. MelonLoader is declared as a dependency. 3. Copy `LobbyKit.dll` into the game's `Mods/` folder. 4. Start the game through MelonLoader. +For hosted-lobby features, install LobbyKit on the host machine; clients typically do not need a local install. + ## Configuration Most options are available in-game through `!settings` while hosting. The mod also stores preferences through MelonLoader under the `LobbyKit` category. From 15a99a9c419bccd913b4b798608f7ec4bfe813f0 Mon Sep 17 00:00:00 2001 From: rdavis Date: Thu, 28 May 2026 01:11:59 -0500 Subject: [PATCH 03/23] Update README --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 953342e..6e3bed5 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ Host and lobby quality-of-life tools for **Sledding Game**. -The internal project is named `LobbyKit`, matching the mod package name. - ## Host Requirement LobbyKit is primarily a host/server-side mod. Most functionality (especially chat commands) is driven by the lobby host having LobbyKit installed. Clients generally do not need to install LobbyKit to join and use host-provided features. @@ -34,9 +32,9 @@ LobbyKit is primarily a host/server-side mod. Most functionality (especially cha ## Installation -### Gale +### Gale / r2modman -Install with Gale. MelonLoader is declared as a dependency. +Install with Gale or r2modman. MelonLoader is declared as a dependency. ### Manual From 51c65f74f0beeb1390f513500ee4fa88656e39b1 Mon Sep 17 00:00:00 2001 From: rdavis Date: Fri, 29 May 2026 02:19:26 -0500 Subject: [PATCH 04/23] first go at autorestart --- LobbyKit/ChatSystem.cs | 8 + LobbyKit/Features/Lobby/LobbyPatches.cs | 223 +++++++++++++++++++ LobbyKit/Features/Settings/SettingsDraft.cs | 11 +- LobbyKit/Features/Settings/SettingsSchema.cs | 5 + LobbyKit/LobbyKitCore.cs | 16 ++ docs/TODO.md | 5 + 6 files changed, 265 insertions(+), 3 deletions(-) diff --git a/LobbyKit/ChatSystem.cs b/LobbyKit/ChatSystem.cs index dca61c0..38507cb 100644 --- a/LobbyKit/ChatSystem.cs +++ b/LobbyKit/ChatSystem.cs @@ -224,6 +224,14 @@ public static void ForgetTeleportRequests(int connectionId) } } + public static void ResetSessionState() + { + MotdRecipients.Clear(); + TeleportRequests.Clear(); + LastCommandBySource.Clear(); + LastExplicitCommandBySource.Clear(); + } + private static bool TryHandleCommand(string message, int connectionId, bool isHostLocal) { string trimmedMessage = message.Trim(); diff --git a/LobbyKit/Features/Lobby/LobbyPatches.cs b/LobbyKit/Features/Lobby/LobbyPatches.cs index 97ce887..a6718e3 100644 --- a/LobbyKit/Features/Lobby/LobbyPatches.cs +++ b/LobbyKit/Features/Lobby/LobbyPatches.cs @@ -1,5 +1,8 @@ +using System.Collections; using System.Collections.Generic; using HarmonyLib; +using Il2Cpp; +using Il2CppFishNet; using Il2CppInterop.Runtime; using Il2CppEpic.OnlineServices; using Il2CppEpic.OnlineServices.Lobby; @@ -17,6 +20,20 @@ namespace LobbyKit.Patches [HarmonyPatch] public static class LobbyPatches { + private static bool _lastProximityChatEnabled = false; + private static string _lastPlatform = string.Empty; + private static string _lastRegion = string.Empty; + private static bool _lastCrossplayEnabled = true; + private static bool _hasLastLobbyParams = false; + + private static int _pendingAutoRestartAttempts = 0; + private const int MaxAutoRestartAttempts = 3; + private const int AutoRestartRetryDelayFrames = 300; // 5 seconds + + private static bool _awaitingLobbyLeft = false; + private static bool _createCompleteFired = false; + private static bool _pendingFishNetStop = false; + [HarmonyPatch(typeof(LobbyManager), "CreateLobby")] [HarmonyPrefix] private static void LobbyManager_CreateLobby_Prefix( @@ -31,9 +48,215 @@ private static void LobbyManager_CreateLobby_Prefix( ref string region, ref bool crossplayEnabled) { + _lastProximityChatEnabled = proximityChatEnabled; + _lastPlatform = platform ?? string.Empty; + _lastRegion = region ?? string.Empty; + _lastCrossplayEnabled = crossplayEnabled; + _hasLastLobbyParams = true; + LobbyUiController.Instance.ApplyLobbyManagerCreateLobby(ref lobbyName, ref maxPlayers); } + [HarmonyPatch(typeof(UiReferenceController), "LeaveGame")] + [HarmonyPrefix] + private static void UiReferenceController_LeaveGame_Prefix() + { + LobbyKitCore.WasHosting = false; + } + + [HarmonyPatch(typeof(UiReferenceController), "ReturnToMainMenu")] + [HarmonyPrefix] + private static void UiReferenceController_ReturnToMainMenu_Prefix(int messageType) + { + if (!LobbyKitCore.AutoRestartOnCrash || !LobbyKitCore.WasHosting || messageType == 0) + return; + + LobbyKitCore.WasHosting = false; + + if (!_hasLastLobbyParams) + { + MelonLogger.Warning("[LobbyKit] Auto-restart: no prior lobby params recorded, skipping restart."); + return; + } + + _pendingAutoRestartAttempts = MaxAutoRestartAttempts; + MelonCoroutines.Start(AutoRestartLobbyCoroutine()); + } + + [HarmonyPatch(typeof(LobbyManager), "OnCreateLobbyComplete")] + [HarmonyPrefix] + private static bool LobbyManager_OnCreateLobbyComplete_Prefix(int result) + { + // On success during auto-restart: stop any ghost FishNet server synchronously + // before the original method calls serverMgr.StartConnection(). The ghost + // server (recreated by Dissonance after the crash) may have set FishNet's + // internal _started flag even if IsAnyServerStarted() returns false, causing + // StartConnection to bail with "Server is already running." + if (result == 0 && _pendingFishNetStop) + { + _pendingFishNetStop = false; + try { InstanceFinder.ServerManager?.StopConnection(false); } catch { } + try { InstanceFinder.ClientManager?.StopConnection(); } catch { } + } + + if (result == 0 || _pendingAutoRestartAttempts <= 0) + { + _pendingAutoRestartAttempts = 0; + _createCompleteFired = false; + return true; + } + + // Guard: EOSLobbyManager can fire this callback twice for one failure + // (duplicate registration survives scene transitions). Only handle the first. + if (_createCompleteFired) + return false; + _createCompleteFired = true; + + _pendingAutoRestartAttempts--; + int attempt = MaxAutoRestartAttempts - _pendingAutoRestartAttempts; + MelonLogger.Warning($"[LobbyKit] Auto-restart: lobby creation failed (attempt {attempt}/{MaxAutoRestartAttempts}), retrying in 5 seconds..."); + MelonCoroutines.Start(AutoRestartRetryCoroutine()); + return false; + } + + [HarmonyPatch(typeof(UiReferenceController), "OnLobbyLeft")] + [HarmonyFinalizer] + private static Exception UiReferenceController_OnLobbyLeft_Finalizer(Exception __exception, int result) + { + if (!_awaitingLobbyLeft) + return __exception; + + _awaitingLobbyLeft = false; + + if (__exception != null) + MelonLogger.Warning("[LobbyKit] Auto-restart: OnLobbyLeft threw during FishNet teardown — continuing restart."); + else if (result == 0) + MelonLogger.Msg("[LobbyKit] Auto-restart: EOS lobby destroyed — creating new lobby..."); + else + MelonLogger.Warning($"[LobbyKit] Auto-restart: EOS lobby destroy returned {result} — attempting create anyway..."); + + MelonCoroutines.Start(AutoRestartCreateAfterLeaveCoroutine()); + return null; // suppress exception — EOS lobby is already destroyed + } + + private static IEnumerator AutoRestartLobbyCoroutine() + { + MelonLogger.Msg("[LobbyKit] Lobby crashed — restarting lobby..."); + + // Brief delay for the main menu to finish its UI transition + for (int i = 0; i < 120; i++) + yield return null; + + // Pre-stop FishNet before calling LeaveGame. In crash scenarios where + // Dissonance recreates a ghost server, this primes FishNet's state so + // OnLobbyLeft's StopConnection is more likely to complete cleanly. + // Silently ignored if it throws (e.g., during test crashes with broken objects). + try + { + var serverManager = InstanceFinder.ServerManager; + if (serverManager != null && serverManager.IsAnyServerStarted()) + serverManager.StopConnection(false); + InstanceFinder.ClientManager?.StopConnection(); + } + catch { } + + // Use the game's own leave path so the owner check runs and + // DestroyCurrentLobby (not LeaveLobby) is called for the host. + // LobbyManager.LeaveLobby() always calls LeaveLobby on EOSLobbyManager, + // which EOS rejects for lobby owners — leaving the presence entry alive. + var uiController = UiReferenceController.Instance; + if (uiController != null) + { + MelonLogger.Msg("[LobbyKit] Auto-restart: destroying stale EOS lobby..."); + _awaitingLobbyLeft = true; + uiController.LeaveGame(0); + // CreateLobby is triggered from UiReferenceController_OnLobbyLeft_Finalizer + // once EOS confirms the destroy completed (or if OnLobbyLeft throws). + } + else + { + MelonLogger.Warning("[LobbyKit] Auto-restart: UiReferenceController unavailable, skipping destroy."); + InvokeCreateLobby(LobbyManager.Instance); + } + } + + private static IEnumerator AutoRestartCreateAfterLeaveCoroutine() + { + // Settle time after EOS confirms the lobby is destroyed + for (int i = 0; i < 180; i++) + yield return null; + + // StopConnection is async — poll until FishNet reports fully stopped. + // Starting a new host before shutdown completes causes "Server failed to + // start in host mode within timeout." + int fishNetWait = 0; + while (fishNetWait < 300) + { + bool stillRunning; + try { stillRunning = InstanceFinder.ServerManager?.IsAnyServerStarted() == true; } + catch { break; } + if (!stillRunning) break; + fishNetWait++; + yield return null; + } + if (fishNetWait > 0) + MelonLogger.Msg($"[LobbyKit] Auto-restart: waited {fishNetWait} extra frames for FishNet to stop."); + + InvokeCreateLobby(LobbyManager.Instance); + } + + private static IEnumerator AutoRestartRetryCoroutine() + { + _createCompleteFired = false; + for (int i = 0; i < AutoRestartRetryDelayFrames; i++) + yield return null; + + InvokeCreateLobby(LobbyManager.Instance); + } + + private static void InvokeCreateLobby(LobbyManager lobbyManager) + { + if (lobbyManager == null) + { + MelonLogger.Warning("[LobbyKit] Auto-restart: LobbyManager unavailable."); + _pendingAutoRestartAttempts = 0; + return; + } + + _createCompleteFired = false; + _pendingFishNetStop = true; + + string lobbyName = !string.IsNullOrWhiteSpace(LobbyKitCore.ServerName) + ? LobbyKitCore.ServerName + : $"{GameInfo.Instance?.PlayerName ?? "Host"}'s Lobby"; + + MelonLogger.Msg("[LobbyKit] Auto-restart: creating lobby..."); + lobbyManager.CreateLobby( + lobbyName, + LobbyKitCore.ServerCapacity, + LobbyKitCore.IsPublicLobby, + _lastProximityChatEnabled, + LobbyKitCore.IsPasswordProtected, + LobbyKitCore.LobbyPassword, + LobbyKitCore.IsPeacefulMode, + _lastPlatform, + _lastRegion, + _lastCrossplayEnabled); + } + + // Any NetworkObject whose stop callback throws (e.g. a TrinketPack with an + // uninitialized SyncVar from a test-spawned object) will abort FishNet's despawn + // loop, leaving the server in a corrupt state that prevents clean restart. Suppress + // exceptions here so teardown continues past any broken NetworkObject. + [HarmonyPatch(typeof(Il2CppFishNet.Object.NetworkObject), "InvokeStopCallbacks")] + [HarmonyFinalizer] + private static Exception NetworkObject_InvokeStopCallbacks_Finalizer(Exception __exception) + { + if (__exception != null) + MelonLogger.Warning($"[LobbyKit] NetworkObject stop callback threw during teardown (suppressed): {__exception.GetType().Name}"); + return null; + } + [HarmonyPatch(typeof(EOSLobbyManager), "CreateLobby")] [HarmonyPrefix] private static void EOSLobbyManager_CreateLobby_Prefix(EOSLobbyManager __instance, EosLobby lobbyProperties) diff --git a/LobbyKit/Features/Settings/SettingsDraft.cs b/LobbyKit/Features/Settings/SettingsDraft.cs index bbae551..0fe40ee 100644 --- a/LobbyKit/Features/Settings/SettingsDraft.cs +++ b/LobbyKit/Features/Settings/SettingsDraft.cs @@ -18,6 +18,7 @@ internal sealed class SettingsDraft public bool ShowLeaveMessages { get; set; } public int JoinMessageSize { get; set; } public int LeaveMessageSize { get; set; } + public bool AutoRestartOnCrash { get; set; } public static SettingsDraft FromCurrent() { @@ -35,7 +36,8 @@ public static SettingsDraft FromCurrent() ShowJoinMessages = LobbyKitCore.ShowJoinMessages, ShowLeaveMessages = LobbyKitCore.ShowLeaveMessages, JoinMessageSize = LobbyKitCore.JoinMessageSize, - LeaveMessageSize = LobbyKitCore.LeaveMessageSize + LeaveMessageSize = LobbyKitCore.LeaveMessageSize, + AutoRestartOnCrash = LobbyKitCore.AutoRestartOnCrash }; } @@ -55,7 +57,8 @@ public SettingsDraft Clone() ShowJoinMessages = ShowJoinMessages, ShowLeaveMessages = ShowLeaveMessages, JoinMessageSize = JoinMessageSize, - LeaveMessageSize = LeaveMessageSize + LeaveMessageSize = LeaveMessageSize, + AutoRestartOnCrash = AutoRestartOnCrash }; } @@ -76,7 +79,8 @@ public bool ValueEquals(SettingsDraft other) ShowJoinMessages == other.ShowJoinMessages && ShowLeaveMessages == other.ShowLeaveMessages && JoinMessageSize == other.JoinMessageSize && - LeaveMessageSize == other.LeaveMessageSize; + LeaveMessageSize == other.LeaveMessageSize && + AutoRestartOnCrash == other.AutoRestartOnCrash; } public void Apply() @@ -94,6 +98,7 @@ public void Apply() LobbyKitCore.SetShowLeaveMessages(ShowLeaveMessages); LobbyKitCore.SetJoinMessageSize(JoinMessageSize); LobbyKitCore.SetLeaveMessageSize(LeaveMessageSize); + LobbyKitCore.SetAutoRestartOnCrash(AutoRestartOnCrash); LobbyUiController.Instance.RefreshCreateLobbyFromPreferences(); } diff --git a/LobbyKit/Features/Settings/SettingsSchema.cs b/LobbyKit/Features/Settings/SettingsSchema.cs index 7aef2a7..35a3af0 100644 --- a/LobbyKit/Features/Settings/SettingsSchema.cs +++ b/LobbyKit/Features/Settings/SettingsSchema.cs @@ -85,6 +85,11 @@ internal static class SettingsSchema "Enable Guest !Bang Commands", draft => draft.EnableGuestBangCommands, (draft, value) => draft.EnableGuestBangCommands = value), + new ToggleSettingsField( + "auto-restart-on-crash", + "Auto-Restart Lobby On Crash", + draft => draft.AutoRestartOnCrash, + (draft, value) => draft.AutoRestartOnCrash = value), new TextSettingsField( "message-of-the-day", "Message of the Day", diff --git a/LobbyKit/LobbyKitCore.cs b/LobbyKit/LobbyKitCore.cs index d4ec396..6ab483b 100644 --- a/LobbyKit/LobbyKitCore.cs +++ b/LobbyKit/LobbyKitCore.cs @@ -20,6 +20,7 @@ public class LobbyKitCore : MelonMod private readonly Dictionary _playerJoinTimes = new Dictionary(); private readonly Dictionary _playerJoinTimesByProductId = new Dictionary(); public static bool isHost = false; + public static bool WasHosting = false; private static MelonPreferences_Category _preferences; private static MelonPreferences_Entry _enableGuestBangCommands; @@ -45,6 +46,7 @@ public class LobbyKitCore : MelonMod private static MelonPreferences_Entry _showLeaveMessages; private static MelonPreferences_Entry _joinMessageSize; private static MelonPreferences_Entry _leaveMessageSize; + private static MelonPreferences_Entry _autoRestartOnCrash; public static bool EnableGuestBangCommands => _enableGuestBangCommands?.Value ?? true; public static string ServerName => _serverName?.Value ?? string.Empty; @@ -67,6 +69,7 @@ public class LobbyKitCore : MelonMod public static bool ShowLeaveMessages => _showLeaveMessages?.Value ?? true; public static int JoinMessageSize => _joinMessageSize?.Value ?? 75; public static int LeaveMessageSize => _leaveMessageSize?.Value ?? 75; + public static bool AutoRestartOnCrash => _autoRestartOnCrash?.Value ?? false; private PlayerReferenceManager _playerReferenceManager; @@ -103,6 +106,7 @@ public override unsafe void OnInitializeMelon() _showLeaveMessages = _preferences.CreateEntry("ShowLeaveMessages", true, "Show Leave Messages", "Broadcast a chat message when a player leaves your hosted lobby."); _joinMessageSize = _preferences.CreateEntry("JoinMessageSize", 75, "Join Message Size", "Font size percentage for join messages (e.g. 75 for 75%)."); _leaveMessageSize = _preferences.CreateEntry("LeaveMessageSize", 75, "Leave Message Size", "Font size percentage for leave messages (e.g. 75 for 75%)."); + _autoRestartOnCrash = _preferences.CreateEntry("AutoRestartOnCrash", false, "Auto-Restart On Crash", "Automatically re-host the lobby when it crashes unexpectedly."); MelonPreferences.Save(); HarmonyInstance.PatchAll(); @@ -114,6 +118,7 @@ public override void OnSceneWasLoaded(int buildIndex, string sceneName) players.Clear(); _playerJoinTimes.Clear(); _playerJoinTimesByProductId.Clear(); + Patches.ChatSystem.ResetSessionState(); MelonCoroutines.Start(LoadReferences()); } @@ -167,6 +172,8 @@ public void PlayerJoinedGame(PlayerReference p) { localPlayer = p; isHost = p.ConnectionID == 32767; + if (isHost) + WasHosting = true; } else if (isHost && isNewConnection && ShowJoinMessages) { @@ -527,6 +534,15 @@ public static void SetLeaveMessageSize(int value) MelonPreferences.Save(); } + public static void SetAutoRestartOnCrash(bool value) + { + if (_autoRestartOnCrash == null) + return; + + _autoRestartOnCrash.Value = value; + MelonPreferences.Save(); + } + private IEnumerator LoadReferences() { while ((Object)(object)_playerReferenceManager == (Object)null) diff --git a/docs/TODO.md b/docs/TODO.md index 185cd21..8780cb6 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -23,3 +23,8 @@ - !help paging - Offline mode? - Proximity chat range + + + +- [x] Leaving a lobby you host doesn't reset player list so MOTD isn't resent if you re-start lobby +- [x] Auto-restart crashed lobbies From 029e5748f6da136682b7803d012d33681c1ceeb1 Mon Sep 17 00:00:00 2001 From: rdavis Date: Fri, 29 May 2026 23:36:35 -0500 Subject: [PATCH 05/23] anticheat --- .../Anticheat/CrashProtectionPatches.cs | 23 ++ .../GenericServerRpcRateLimitPatch.cs | 174 +++++++++++++ LobbyKit/Features/Anticheat/KickAnnouncer.cs | 24 ++ LobbyKit/Features/Lobby/AutoRestartPatches.cs | 230 +++++++++++++++++ LobbyKit/Features/Lobby/LobbyPatches.cs | 235 +----------------- LobbyKit/LobbyKit.csproj | 4 + LobbyKit/LobbyKitCore.cs | 8 +- 7 files changed, 462 insertions(+), 236 deletions(-) create mode 100644 LobbyKit/Features/Anticheat/CrashProtectionPatches.cs create mode 100644 LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs create mode 100644 LobbyKit/Features/Anticheat/KickAnnouncer.cs create mode 100644 LobbyKit/Features/Lobby/AutoRestartPatches.cs diff --git a/LobbyKit/Features/Anticheat/CrashProtectionPatches.cs b/LobbyKit/Features/Anticheat/CrashProtectionPatches.cs new file mode 100644 index 0000000..ac7946e --- /dev/null +++ b/LobbyKit/Features/Anticheat/CrashProtectionPatches.cs @@ -0,0 +1,23 @@ +using System; +using HarmonyLib; +using MelonLoader; + +namespace LobbyKit.Patches +{ + [HarmonyPatch] + internal static class CrashProtectionPatches + { + // Any NetworkObject whose stop callback throws (e.g. a TrinketPack with an + // uninitialized SyncVar from a test-spawned object) will abort FishNet's despawn + // loop, leaving the server in a corrupt state that prevents clean restart. Suppress + // exceptions here so teardown continues past any broken NetworkObject. + [HarmonyPatch(typeof(Il2CppFishNet.Object.NetworkObject), "InvokeStopCallbacks")] + [HarmonyFinalizer] + private static Exception NetworkObject_InvokeStopCallbacks_Finalizer(Exception __exception) + { + if (__exception != null) + MelonLogger.Warning($"[LobbyKit] NetworkObject stop callback threw during teardown (suppressed): {__exception.GetType().Name}"); + return null; + } + } +} diff --git a/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs b/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs new file mode 100644 index 0000000..e67f68a --- /dev/null +++ b/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; // HarmonyMethod, HarmonyPatch +using Il2CppFishNet; +using Il2CppFishNet.Managing.Server; +using Il2CppFishNet.Object; +using MelonLoader; +using UnityEngine; + +namespace LobbyKit.Features.Anticheat +{ + internal static class GenericServerRpcRateLimitPatch + { + // Set to true to log every ServerRpc call — useful for observing natural call + // rates before committing to per-command limits. + internal static bool LogAllRpcCalls = true; + + // Loose limit intended to catch actual spam, not restrict normal gameplay. Any faster than this number we treat as a potential exploit attempt. + private const float MinInterval = 0.05f; // 50ms — max 20 calls/sec per client + + // Per-command overrides — keyed by friendly name (e.g. "ThrowObject"). + // Commands listed here use their specific limit instead of MinInterval. + // Any faster than this number we treat as a potential exploit attempt. + private static readonly Dictionary _commandOverrides = new() + { + { "ThrowObject", 0.5f }, + }; + + // FishNet host-mode calls RpcLogic twice for the same command in the same frame. + // Any gap shorter than this is treated as that duplicate, not a separate call. + private const float SameCallTolerance = 0.015f; + + private const int ViolationsBeforeKick = 3; + private const float ViolationWindow = 3f; + + // Key: (ownerId, method metadata token) — MetadataToken is an int unique per + // method in the assembly, so this is two int comparisons with no string allocation. + private static readonly Dictionary<(int, int), float> _lastCallTime = []; + private static readonly Dictionary<(int, int), int> _violationCount = []; + private static readonly Dictionary<(int, int), float> _violationWindowStart = []; + + // Friendly name cache: strips "RpcLogic___Cmd_" prefix and trailing "___" + // so logs read "ThrowObject" instead of "RpcLogic___Cmd_ThrowObject___1971385254". + private static readonly Dictionary _friendlyNames = []; + + internal static void ApplyPatches(HarmonyLib.Harmony harmony) + { + var asm = Array.Find( + AppDomain.CurrentDomain.GetAssemblies(), + a => a.GetName().Name == "Assembly-CSharp"); + + if (asm == null) + { + MelonLogger.Warning("[LobbyKit] Anticheat: Assembly-CSharp not found — generic ServerRpc rate limit not applied."); + return; + } + + var prefix = new HarmonyMethod(typeof(GenericServerRpcRateLimitPatch), nameof(Prefix)); + int count = 0; + + foreach (var type in asm.GetTypes()) + { + foreach (var method in type.GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (!method.Name.StartsWith("RpcLogic___Cmd_", StringComparison.Ordinal)) + continue; + + try + { + harmony.Patch(method, prefix: prefix); + _friendlyNames[method.MetadataToken] = FriendlyName(method.Name); + count++; + } + catch (Exception ex) + { + MelonLogger.Warning($"[LobbyKit] Anticheat: could not patch {type.Name}.{method.Name}: {ex.Message}"); + } + } + } + + MelonLogger.Msg($"[LobbyKit] Anticheat: generic ServerRpc rate limit applied to {count} Cmd method(s)."); + } + + private static bool Prefix(NetworkBehaviour __instance, MethodBase __originalMethod) + { + int ownerId = __instance.OwnerId; + float now = Time.realtimeSinceStartup; + int token = __originalMethod.MetadataToken; + var key = (ownerId, token); + _friendlyNames.TryGetValue(token, out string name); + + if (!_lastCallTime.TryGetValue(key, out float lastTime)) + { + _lastCallTime[key] = now; + if (LogAllRpcCalls) + MelonLogger.Msg($"[RPC] {name ?? __originalMethod.Name} | client={ownerId} | first call"); + return true; + } + + float elapsed = now - lastTime; + + if (elapsed < SameCallTolerance) + return true; // FishNet host-mode duplicate call — same command, same frame + + float limit = name != null && _commandOverrides.TryGetValue(name, out float ov) ? ov : MinInterval; + + if (LogAllRpcCalls) + { + string tag = elapsed < limit ? " BLOCKED" : string.Empty; + MelonLogger.Msg($"[RPC] {name ?? __originalMethod.Name} | client={ownerId} | gap={elapsed * 1000f:F0}ms{tag}"); + } + + if (elapsed < limit) + { + if (!LogAllRpcCalls) + MelonLogger.Warning($"[LobbyKit] Anticheat: client {ownerId} spamming {name ?? __originalMethod.Name} ({elapsed * 1000f:F0}ms since last), blocking."); + RecordViolation(ownerId, key, name ?? __originalMethod.Name, now); + return false; + } + + _lastCallTime[key] = now; + + // If the client had prior violations but their window has since expired, + // clear the stale tracking so they start fresh next time they spam. + if (_violationWindowStart.TryGetValue(key, out float windowStart) && now - windowStart > ViolationWindow) + { + _violationCount.Remove(key); + _violationWindowStart.Remove(key); + } + + return true; + } + + private static string FriendlyName(string methodName) + { + const string prefix = "RpcLogic___Cmd_"; + if (methodName.StartsWith(prefix, StringComparison.Ordinal)) + methodName = methodName[prefix.Length..]; + int sep = methodName.IndexOf("___", StringComparison.Ordinal); + return sep >= 0 ? methodName[..sep] : methodName; + } + + private static void RecordViolation(int ownerId, (int, int) key, string methodName, float now) + { + _violationWindowStart.TryGetValue(key, out float windowStart); + _violationCount.TryGetValue(key, out int count); + + if (now - windowStart > ViolationWindow) + { + count = 1; + _violationWindowStart[key] = now; + } + else + { + count++; + } + + _violationCount[key] = count; + + if (count >= ViolationsBeforeKick) + { + MelonLogger.Warning($"[LobbyKit] Anticheat: kicking client {ownerId} for spamming {methodName} ({count} violations in {ViolationWindow}s)."); + KickAnnouncer.Enqueue(ownerId, "was kicked for cheating (experimental)"); + try { InstanceFinder.ServerManager?.Kick(ownerId, KickReason.ExploitAttempt); } + catch { MelonLogger.Warning($"[LobbyKit] Anticheat: failed to kick client {ownerId}."); } + _violationCount.Remove(key); + _violationWindowStart.Remove(key); + _lastCallTime.Remove(key); + } + } + } +} diff --git a/LobbyKit/Features/Anticheat/KickAnnouncer.cs b/LobbyKit/Features/Anticheat/KickAnnouncer.cs new file mode 100644 index 0000000..c7d630a --- /dev/null +++ b/LobbyKit/Features/Anticheat/KickAnnouncer.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace LobbyKit.Features.Anticheat +{ + internal static class KickAnnouncer + { + private static readonly Dictionary _pendingReasons = []; + + internal static void Enqueue(int clientId, string reason) + { + _pendingReasons[clientId] = reason; + } + + internal static bool TryConsume(int clientId, out string reason) + { + if (_pendingReasons.TryGetValue(clientId, out reason)) + { + _pendingReasons.Remove(clientId); + return true; + } + return false; + } + } +} diff --git a/LobbyKit/Features/Lobby/AutoRestartPatches.cs b/LobbyKit/Features/Lobby/AutoRestartPatches.cs new file mode 100644 index 0000000..0f1643c --- /dev/null +++ b/LobbyKit/Features/Lobby/AutoRestartPatches.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections; +using HarmonyLib; +using Il2Cpp; +using Il2CppFishNet; +using Il2Cpp_Scripts.Managers; +using MelonLoader; + +namespace LobbyKit.Patches +{ + [HarmonyPatch] + internal static class AutoRestartPatches + { + private static bool _lastProximityChatEnabled = false; + private static string _lastPlatform = string.Empty; + private static string _lastRegion = string.Empty; + private static bool _lastCrossplayEnabled = true; + private static bool _hasLastLobbyParams = false; + + private static int _pendingAutoRestartAttempts = 0; + private const int MaxAutoRestartAttempts = 3; + private const int AutoRestartRetryDelayFrames = 300; // 5 seconds + + private static bool _awaitingLobbyLeft = false; + private static bool _createCompleteFired = false; + private static bool _pendingFishNetStop = false; + + [HarmonyPatch(typeof(LobbyManager), "CreateLobby")] + [HarmonyPrefix] + private static void LobbyManager_CreateLobby_Prefix( + ref bool proximityChatEnabled, + ref string platform, + ref string region, + ref bool crossplayEnabled) + { + _lastProximityChatEnabled = proximityChatEnabled; + _lastPlatform = platform ?? string.Empty; + _lastRegion = region ?? string.Empty; + _lastCrossplayEnabled = crossplayEnabled; + _hasLastLobbyParams = true; + } + + [HarmonyPatch(typeof(UiReferenceController), "LeaveGame")] + [HarmonyPrefix] + private static void UiReferenceController_LeaveGame_Prefix() + { + LobbyKitCore.WasHosting = false; + } + + [HarmonyPatch(typeof(UiReferenceController), "ReturnToMainMenu")] + [HarmonyPrefix] + private static void UiReferenceController_ReturnToMainMenu_Prefix(int messageType) + { + if (!LobbyKitCore.AutoRestartOnCrash || !LobbyKitCore.WasHosting || messageType == 0) + return; + + LobbyKitCore.WasHosting = false; + + if (!_hasLastLobbyParams) + { + MelonLogger.Warning("[LobbyKit] Auto-restart: no prior lobby params recorded, skipping restart."); + return; + } + + _pendingAutoRestartAttempts = MaxAutoRestartAttempts; + MelonCoroutines.Start(AutoRestartLobbyCoroutine()); + } + + [HarmonyPatch(typeof(LobbyManager), "OnCreateLobbyComplete")] + [HarmonyPrefix] + private static bool LobbyManager_OnCreateLobbyComplete_Prefix(int result) + { + // On success during auto-restart: stop any ghost FishNet server synchronously + // before the original method calls serverMgr.StartConnection(). The ghost + // server (recreated by Dissonance after the crash) may have set FishNet's + // internal _started flag even if IsAnyServerStarted() returns false, causing + // StartConnection to bail with "Server is already running." + if (result == 0 && _pendingFishNetStop) + { + _pendingFishNetStop = false; + try { InstanceFinder.ServerManager?.StopConnection(false); } catch { } + try { InstanceFinder.ClientManager?.StopConnection(); } catch { } + } + + if (result == 0 || _pendingAutoRestartAttempts <= 0) + { + _pendingAutoRestartAttempts = 0; + _createCompleteFired = false; + return true; + } + + // Guard: EOSLobbyManager can fire this callback twice for one failure + // (duplicate registration survives scene transitions). Only handle the first. + if (_createCompleteFired) + return false; + _createCompleteFired = true; + + _pendingAutoRestartAttempts--; + int attempt = MaxAutoRestartAttempts - _pendingAutoRestartAttempts; + MelonLogger.Warning($"[LobbyKit] Auto-restart: lobby creation failed (attempt {attempt}/{MaxAutoRestartAttempts}), retrying in 5 seconds..."); + MelonCoroutines.Start(AutoRestartRetryCoroutine()); + return false; + } + + [HarmonyPatch(typeof(UiReferenceController), "OnLobbyLeft")] + [HarmonyFinalizer] + private static Exception UiReferenceController_OnLobbyLeft_Finalizer(Exception __exception, int result) + { + if (!_awaitingLobbyLeft) + return __exception; + + _awaitingLobbyLeft = false; + + if (__exception != null) + MelonLogger.Warning("[LobbyKit] Auto-restart: OnLobbyLeft threw during FishNet teardown — continuing restart."); + else if (result == 0) + MelonLogger.Msg("[LobbyKit] Auto-restart: EOS lobby destroyed — creating new lobby..."); + else + MelonLogger.Warning($"[LobbyKit] Auto-restart: EOS lobby destroy returned {result} — attempting create anyway..."); + + MelonCoroutines.Start(AutoRestartCreateAfterLeaveCoroutine()); + return null; // suppress exception — EOS lobby is already destroyed + } + + private static IEnumerator AutoRestartLobbyCoroutine() + { + MelonLogger.Msg("[LobbyKit] Lobby crashed — restarting lobby..."); + + // Brief delay for the main menu to finish its UI transition + for (int i = 0; i < 120; i++) + yield return null; + + // Pre-stop FishNet before calling LeaveGame. In crash scenarios where + // Dissonance recreates a ghost server, this primes FishNet's state so + // OnLobbyLeft's StopConnection is more likely to complete cleanly. + // Silently ignored if it throws (e.g., during test crashes with broken objects). + try + { + var serverManager = InstanceFinder.ServerManager; + if (serverManager != null && serverManager.IsAnyServerStarted()) + serverManager.StopConnection(false); + InstanceFinder.ClientManager?.StopConnection(); + } + catch { } + + // Use the game's own leave path so the owner check runs and + // DestroyCurrentLobby (not LeaveLobby) is called for the host. + // LobbyManager.LeaveLobby() always calls LeaveLobby on EOSLobbyManager, + // which EOS rejects for lobby owners — leaving the presence entry alive. + var uiController = UiReferenceController.Instance; + if (uiController != null) + { + MelonLogger.Msg("[LobbyKit] Auto-restart: destroying stale EOS lobby..."); + _awaitingLobbyLeft = true; + uiController.LeaveGame(0); + // CreateLobby is triggered from UiReferenceController_OnLobbyLeft_Finalizer + // once EOS confirms the destroy completed (or if OnLobbyLeft throws). + } + else + { + MelonLogger.Warning("[LobbyKit] Auto-restart: UiReferenceController unavailable, skipping destroy."); + InvokeCreateLobby(LobbyManager.Instance); + } + } + + private static IEnumerator AutoRestartCreateAfterLeaveCoroutine() + { + // Settle time after EOS confirms the lobby is destroyed + for (int i = 0; i < 180; i++) + yield return null; + + // StopConnection is async — poll until FishNet reports fully stopped. + // Starting a new host before shutdown completes causes "Server failed to + // start in host mode within timeout." + int fishNetWait = 0; + while (fishNetWait < 300) + { + bool stillRunning; + try { stillRunning = InstanceFinder.ServerManager?.IsAnyServerStarted() == true; } + catch { break; } + if (!stillRunning) break; + fishNetWait++; + yield return null; + } + if (fishNetWait > 0) + MelonLogger.Msg($"[LobbyKit] Auto-restart: waited {fishNetWait} extra frames for FishNet to stop."); + + InvokeCreateLobby(LobbyManager.Instance); + } + + private static IEnumerator AutoRestartRetryCoroutine() + { + _createCompleteFired = false; + for (int i = 0; i < AutoRestartRetryDelayFrames; i++) + yield return null; + + InvokeCreateLobby(LobbyManager.Instance); + } + + private static void InvokeCreateLobby(LobbyManager lobbyManager) + { + if (lobbyManager == null) + { + MelonLogger.Warning("[LobbyKit] Auto-restart: LobbyManager unavailable."); + _pendingAutoRestartAttempts = 0; + return; + } + + _createCompleteFired = false; + _pendingFishNetStop = true; + + string lobbyName = !string.IsNullOrWhiteSpace(LobbyKitCore.ServerName) + ? LobbyKitCore.ServerName + : $"{GameInfo.Instance?.PlayerName ?? "Host"}'s Lobby"; + + MelonLogger.Msg("[LobbyKit] Auto-restart: creating lobby..."); + lobbyManager.CreateLobby( + lobbyName, + LobbyKitCore.ServerCapacity, + LobbyKitCore.IsPublicLobby, + _lastProximityChatEnabled, + LobbyKitCore.IsPasswordProtected, + LobbyKitCore.LobbyPassword, + LobbyKitCore.IsPeacefulMode, + _lastPlatform, + _lastRegion, + _lastCrossplayEnabled); + } + } +} diff --git a/LobbyKit/Features/Lobby/LobbyPatches.cs b/LobbyKit/Features/Lobby/LobbyPatches.cs index a6718e3..2110a1f 100644 --- a/LobbyKit/Features/Lobby/LobbyPatches.cs +++ b/LobbyKit/Features/Lobby/LobbyPatches.cs @@ -1,8 +1,5 @@ -using System.Collections; using System.Collections.Generic; using HarmonyLib; -using Il2Cpp; -using Il2CppFishNet; using Il2CppInterop.Runtime; using Il2CppEpic.OnlineServices; using Il2CppEpic.OnlineServices.Lobby; @@ -20,243 +17,13 @@ namespace LobbyKit.Patches [HarmonyPatch] public static class LobbyPatches { - private static bool _lastProximityChatEnabled = false; - private static string _lastPlatform = string.Empty; - private static string _lastRegion = string.Empty; - private static bool _lastCrossplayEnabled = true; - private static bool _hasLastLobbyParams = false; - - private static int _pendingAutoRestartAttempts = 0; - private const int MaxAutoRestartAttempts = 3; - private const int AutoRestartRetryDelayFrames = 300; // 5 seconds - - private static bool _awaitingLobbyLeft = false; - private static bool _createCompleteFired = false; - private static bool _pendingFishNetStop = false; - [HarmonyPatch(typeof(LobbyManager), "CreateLobby")] [HarmonyPrefix] - private static void LobbyManager_CreateLobby_Prefix( - ref string lobbyName, - ref int maxPlayers, - ref bool isPublic, - ref bool proximityChatEnabled, - ref bool passwordProtected, - ref string password, - ref bool peacefulMode, - ref string platform, - ref string region, - ref bool crossplayEnabled) + private static void LobbyManager_CreateLobby_Prefix(ref string lobbyName, ref int maxPlayers) { - _lastProximityChatEnabled = proximityChatEnabled; - _lastPlatform = platform ?? string.Empty; - _lastRegion = region ?? string.Empty; - _lastCrossplayEnabled = crossplayEnabled; - _hasLastLobbyParams = true; - LobbyUiController.Instance.ApplyLobbyManagerCreateLobby(ref lobbyName, ref maxPlayers); } - [HarmonyPatch(typeof(UiReferenceController), "LeaveGame")] - [HarmonyPrefix] - private static void UiReferenceController_LeaveGame_Prefix() - { - LobbyKitCore.WasHosting = false; - } - - [HarmonyPatch(typeof(UiReferenceController), "ReturnToMainMenu")] - [HarmonyPrefix] - private static void UiReferenceController_ReturnToMainMenu_Prefix(int messageType) - { - if (!LobbyKitCore.AutoRestartOnCrash || !LobbyKitCore.WasHosting || messageType == 0) - return; - - LobbyKitCore.WasHosting = false; - - if (!_hasLastLobbyParams) - { - MelonLogger.Warning("[LobbyKit] Auto-restart: no prior lobby params recorded, skipping restart."); - return; - } - - _pendingAutoRestartAttempts = MaxAutoRestartAttempts; - MelonCoroutines.Start(AutoRestartLobbyCoroutine()); - } - - [HarmonyPatch(typeof(LobbyManager), "OnCreateLobbyComplete")] - [HarmonyPrefix] - private static bool LobbyManager_OnCreateLobbyComplete_Prefix(int result) - { - // On success during auto-restart: stop any ghost FishNet server synchronously - // before the original method calls serverMgr.StartConnection(). The ghost - // server (recreated by Dissonance after the crash) may have set FishNet's - // internal _started flag even if IsAnyServerStarted() returns false, causing - // StartConnection to bail with "Server is already running." - if (result == 0 && _pendingFishNetStop) - { - _pendingFishNetStop = false; - try { InstanceFinder.ServerManager?.StopConnection(false); } catch { } - try { InstanceFinder.ClientManager?.StopConnection(); } catch { } - } - - if (result == 0 || _pendingAutoRestartAttempts <= 0) - { - _pendingAutoRestartAttempts = 0; - _createCompleteFired = false; - return true; - } - - // Guard: EOSLobbyManager can fire this callback twice for one failure - // (duplicate registration survives scene transitions). Only handle the first. - if (_createCompleteFired) - return false; - _createCompleteFired = true; - - _pendingAutoRestartAttempts--; - int attempt = MaxAutoRestartAttempts - _pendingAutoRestartAttempts; - MelonLogger.Warning($"[LobbyKit] Auto-restart: lobby creation failed (attempt {attempt}/{MaxAutoRestartAttempts}), retrying in 5 seconds..."); - MelonCoroutines.Start(AutoRestartRetryCoroutine()); - return false; - } - - [HarmonyPatch(typeof(UiReferenceController), "OnLobbyLeft")] - [HarmonyFinalizer] - private static Exception UiReferenceController_OnLobbyLeft_Finalizer(Exception __exception, int result) - { - if (!_awaitingLobbyLeft) - return __exception; - - _awaitingLobbyLeft = false; - - if (__exception != null) - MelonLogger.Warning("[LobbyKit] Auto-restart: OnLobbyLeft threw during FishNet teardown — continuing restart."); - else if (result == 0) - MelonLogger.Msg("[LobbyKit] Auto-restart: EOS lobby destroyed — creating new lobby..."); - else - MelonLogger.Warning($"[LobbyKit] Auto-restart: EOS lobby destroy returned {result} — attempting create anyway..."); - - MelonCoroutines.Start(AutoRestartCreateAfterLeaveCoroutine()); - return null; // suppress exception — EOS lobby is already destroyed - } - - private static IEnumerator AutoRestartLobbyCoroutine() - { - MelonLogger.Msg("[LobbyKit] Lobby crashed — restarting lobby..."); - - // Brief delay for the main menu to finish its UI transition - for (int i = 0; i < 120; i++) - yield return null; - - // Pre-stop FishNet before calling LeaveGame. In crash scenarios where - // Dissonance recreates a ghost server, this primes FishNet's state so - // OnLobbyLeft's StopConnection is more likely to complete cleanly. - // Silently ignored if it throws (e.g., during test crashes with broken objects). - try - { - var serverManager = InstanceFinder.ServerManager; - if (serverManager != null && serverManager.IsAnyServerStarted()) - serverManager.StopConnection(false); - InstanceFinder.ClientManager?.StopConnection(); - } - catch { } - - // Use the game's own leave path so the owner check runs and - // DestroyCurrentLobby (not LeaveLobby) is called for the host. - // LobbyManager.LeaveLobby() always calls LeaveLobby on EOSLobbyManager, - // which EOS rejects for lobby owners — leaving the presence entry alive. - var uiController = UiReferenceController.Instance; - if (uiController != null) - { - MelonLogger.Msg("[LobbyKit] Auto-restart: destroying stale EOS lobby..."); - _awaitingLobbyLeft = true; - uiController.LeaveGame(0); - // CreateLobby is triggered from UiReferenceController_OnLobbyLeft_Finalizer - // once EOS confirms the destroy completed (or if OnLobbyLeft throws). - } - else - { - MelonLogger.Warning("[LobbyKit] Auto-restart: UiReferenceController unavailable, skipping destroy."); - InvokeCreateLobby(LobbyManager.Instance); - } - } - - private static IEnumerator AutoRestartCreateAfterLeaveCoroutine() - { - // Settle time after EOS confirms the lobby is destroyed - for (int i = 0; i < 180; i++) - yield return null; - - // StopConnection is async — poll until FishNet reports fully stopped. - // Starting a new host before shutdown completes causes "Server failed to - // start in host mode within timeout." - int fishNetWait = 0; - while (fishNetWait < 300) - { - bool stillRunning; - try { stillRunning = InstanceFinder.ServerManager?.IsAnyServerStarted() == true; } - catch { break; } - if (!stillRunning) break; - fishNetWait++; - yield return null; - } - if (fishNetWait > 0) - MelonLogger.Msg($"[LobbyKit] Auto-restart: waited {fishNetWait} extra frames for FishNet to stop."); - - InvokeCreateLobby(LobbyManager.Instance); - } - - private static IEnumerator AutoRestartRetryCoroutine() - { - _createCompleteFired = false; - for (int i = 0; i < AutoRestartRetryDelayFrames; i++) - yield return null; - - InvokeCreateLobby(LobbyManager.Instance); - } - - private static void InvokeCreateLobby(LobbyManager lobbyManager) - { - if (lobbyManager == null) - { - MelonLogger.Warning("[LobbyKit] Auto-restart: LobbyManager unavailable."); - _pendingAutoRestartAttempts = 0; - return; - } - - _createCompleteFired = false; - _pendingFishNetStop = true; - - string lobbyName = !string.IsNullOrWhiteSpace(LobbyKitCore.ServerName) - ? LobbyKitCore.ServerName - : $"{GameInfo.Instance?.PlayerName ?? "Host"}'s Lobby"; - - MelonLogger.Msg("[LobbyKit] Auto-restart: creating lobby..."); - lobbyManager.CreateLobby( - lobbyName, - LobbyKitCore.ServerCapacity, - LobbyKitCore.IsPublicLobby, - _lastProximityChatEnabled, - LobbyKitCore.IsPasswordProtected, - LobbyKitCore.LobbyPassword, - LobbyKitCore.IsPeacefulMode, - _lastPlatform, - _lastRegion, - _lastCrossplayEnabled); - } - - // Any NetworkObject whose stop callback throws (e.g. a TrinketPack with an - // uninitialized SyncVar from a test-spawned object) will abort FishNet's despawn - // loop, leaving the server in a corrupt state that prevents clean restart. Suppress - // exceptions here so teardown continues past any broken NetworkObject. - [HarmonyPatch(typeof(Il2CppFishNet.Object.NetworkObject), "InvokeStopCallbacks")] - [HarmonyFinalizer] - private static Exception NetworkObject_InvokeStopCallbacks_Finalizer(Exception __exception) - { - if (__exception != null) - MelonLogger.Warning($"[LobbyKit] NetworkObject stop callback threw during teardown (suppressed): {__exception.GetType().Name}"); - return null; - } - [HarmonyPatch(typeof(EOSLobbyManager), "CreateLobby")] [HarmonyPrefix] private static void EOSLobbyManager_CreateLobby_Prefix(EOSLobbyManager __instance, EosLobby lobbyProperties) diff --git a/LobbyKit/LobbyKit.csproj b/LobbyKit/LobbyKit.csproj index 56fb367..09a8c4e 100644 --- a/LobbyKit/LobbyKit.csproj +++ b/LobbyKit/LobbyKit.csproj @@ -648,7 +648,11 @@ + + + + diff --git a/LobbyKit/LobbyKitCore.cs b/LobbyKit/LobbyKitCore.cs index 6ab483b..ff98295 100644 --- a/LobbyKit/LobbyKitCore.cs +++ b/LobbyKit/LobbyKitCore.cs @@ -110,6 +110,7 @@ public override unsafe void OnInitializeMelon() MelonPreferences.Save(); HarmonyInstance.PatchAll(); + Features.Anticheat.GenericServerRpcRateLimitPatch.ApplyPatches(HarmonyInstance); } public override void OnSceneWasLoaded(int buildIndex, string sceneName) @@ -192,10 +193,13 @@ public void PlayerLeftGame(PlayerReference removedPlayer) bool wasTrackedPlayer = players.Any(player => player != null && player.ConnectionID == removedPlayer.ConnectionID); bool isLocalPlayer = removedPlayer.IsLocalPlayerInstance(); - if (isHost && wasTrackedPlayer && !isLocalPlayer && ShowLeaveMessages) + if (isHost && wasTrackedPlayer && !isLocalPlayer) { string username = Patches.ChatSystem.AutoCloseTmpRichText(string.IsNullOrWhiteSpace(removedPlayer.Username) ? "A player" : removedPlayer.Username); - Patches.ChatSystem.BroadcastSystemMessage($"<#FA0>{username} left."); + if (Features.Anticheat.KickAnnouncer.TryConsume(removedPlayer.ConnectionID, out string kickReason)) + Patches.ChatSystem.BroadcastSystemMessage($"<#F44>{username} {kickReason}."); + else if (ShowLeaveMessages) + Patches.ChatSystem.BroadcastSystemMessage($"<#FA0>{username} left."); } players.RemoveAll(player => player == null || player.ConnectionID == removedPlayer.ConnectionID); From 8b0fa11baa8aa13886da3275c58185ebaacd2d1c Mon Sep 17 00:00:00 2001 From: rdavis Date: Sat, 30 May 2026 00:00:17 -0500 Subject: [PATCH 06/23] fixed MOTD for host --- LobbyKit/ChatSystem.cs | 15 +++++++++------ LobbyKit/Features/Lobby/AutoRestartPatches.cs | 2 ++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/LobbyKit/ChatSystem.cs b/LobbyKit/ChatSystem.cs index 38507cb..a551d08 100644 --- a/LobbyKit/ChatSystem.cs +++ b/LobbyKit/ChatSystem.cs @@ -295,12 +295,15 @@ private static bool TryHandleCommand(string message, int connectionId, bool isHo private static IEnumerator SendMotdWhenReady(int connectionId) { - for (int frame = 0; frame < 60; frame++) + bool isLocal = IsLocalConnectionId(connectionId); + + for (int frame = 0; frame < 300; frame++) { - var serverManager = InstanceFinder.ServerManager; - if (serverManager != null && - serverManager.Clients != null && - serverManager.Clients.TryGetValue(connectionId, out _)) + bool ready = isLocal + ? ChatManager.Instance != null + : InstanceFinder.ServerManager?.Clients?.ContainsKey(connectionId) == true; + + if (ready) { string motd = LobbyKitCore.MessageOfTheDay; if (string.IsNullOrWhiteSpace(motd)) @@ -317,7 +320,7 @@ private static IEnumerator SendMotdWhenReady(int connectionId) } MotdRecipients.Remove(connectionId); - Debug.LogWarning($"[ChatSystem] MOTD was not sent: client {connectionId} was not ready."); + Debug.LogWarning($"[ChatSystem] MOTD was not sent: client {connectionId} was not ready after 300 frames."); } private static ChatMessage CreatePublicChatMessage(string text, string username, int showAboveUser) diff --git a/LobbyKit/Features/Lobby/AutoRestartPatches.cs b/LobbyKit/Features/Lobby/AutoRestartPatches.cs index 0f1643c..2b76ba0 100644 --- a/LobbyKit/Features/Lobby/AutoRestartPatches.cs +++ b/LobbyKit/Features/Lobby/AutoRestartPatches.cs @@ -45,6 +45,7 @@ private static void LobbyManager_CreateLobby_Prefix( private static void UiReferenceController_LeaveGame_Prefix() { LobbyKitCore.WasHosting = false; + ChatSystem.ResetSessionState(); } [HarmonyPatch(typeof(UiReferenceController), "ReturnToMainMenu")] @@ -125,6 +126,7 @@ private static Exception UiReferenceController_OnLobbyLeft_Finalizer(Exception _ private static IEnumerator AutoRestartLobbyCoroutine() { MelonLogger.Msg("[LobbyKit] Lobby crashed — restarting lobby..."); + ChatSystem.ResetSessionState(); // Brief delay for the main menu to finish its UI transition for (int i = 0; i < 120; i++) From 18194c83e8a19a539dbf579f22b8b9dd750d7a54 Mon Sep 17 00:00:00 2001 From: rdavis Date: Sat, 30 May 2026 00:09:08 -0500 Subject: [PATCH 07/23] Add setting and host exclusion --- .../Anticheat/GenericServerRpcRateLimitPatch.cs | 5 +++++ LobbyKit/Features/Settings/SettingsDraft.cs | 11 ++++++++--- LobbyKit/Features/Settings/SettingsSchema.cs | 5 +++++ LobbyKit/LobbyKitCore.cs | 12 ++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs b/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs index e67f68a..166b979 100644 --- a/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs +++ b/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs @@ -16,6 +16,8 @@ internal static class GenericServerRpcRateLimitPatch // rates before committing to per-command limits. internal static bool LogAllRpcCalls = true; + private const int HostConnectionId = 32767; + // Loose limit intended to catch actual spam, not restrict normal gameplay. Any faster than this number we treat as a potential exploit attempt. private const float MinInterval = 0.05f; // 50ms — max 20 calls/sec per client @@ -86,6 +88,9 @@ internal static void ApplyPatches(HarmonyLib.Harmony harmony) private static bool Prefix(NetworkBehaviour __instance, MethodBase __originalMethod) { int ownerId = __instance.OwnerId; + + if (ownerId == HostConnectionId || !LobbyKitCore.EnableAnticheat) + return true; float now = Time.realtimeSinceStartup; int token = __originalMethod.MetadataToken; var key = (ownerId, token); diff --git a/LobbyKit/Features/Settings/SettingsDraft.cs b/LobbyKit/Features/Settings/SettingsDraft.cs index 0fe40ee..bd820f3 100644 --- a/LobbyKit/Features/Settings/SettingsDraft.cs +++ b/LobbyKit/Features/Settings/SettingsDraft.cs @@ -19,6 +19,7 @@ internal sealed class SettingsDraft public int JoinMessageSize { get; set; } public int LeaveMessageSize { get; set; } public bool AutoRestartOnCrash { get; set; } + public bool EnableAnticheat { get; set; } public static SettingsDraft FromCurrent() { @@ -37,7 +38,8 @@ public static SettingsDraft FromCurrent() ShowLeaveMessages = LobbyKitCore.ShowLeaveMessages, JoinMessageSize = LobbyKitCore.JoinMessageSize, LeaveMessageSize = LobbyKitCore.LeaveMessageSize, - AutoRestartOnCrash = LobbyKitCore.AutoRestartOnCrash + AutoRestartOnCrash = LobbyKitCore.AutoRestartOnCrash, + EnableAnticheat = LobbyKitCore.EnableAnticheat }; } @@ -58,7 +60,8 @@ public SettingsDraft Clone() ShowLeaveMessages = ShowLeaveMessages, JoinMessageSize = JoinMessageSize, LeaveMessageSize = LeaveMessageSize, - AutoRestartOnCrash = AutoRestartOnCrash + AutoRestartOnCrash = AutoRestartOnCrash, + EnableAnticheat = EnableAnticheat }; } @@ -80,7 +83,8 @@ public bool ValueEquals(SettingsDraft other) ShowLeaveMessages == other.ShowLeaveMessages && JoinMessageSize == other.JoinMessageSize && LeaveMessageSize == other.LeaveMessageSize && - AutoRestartOnCrash == other.AutoRestartOnCrash; + AutoRestartOnCrash == other.AutoRestartOnCrash && + EnableAnticheat == other.EnableAnticheat; } public void Apply() @@ -99,6 +103,7 @@ public void Apply() LobbyKitCore.SetJoinMessageSize(JoinMessageSize); LobbyKitCore.SetLeaveMessageSize(LeaveMessageSize); LobbyKitCore.SetAutoRestartOnCrash(AutoRestartOnCrash); + LobbyKitCore.SetEnableAnticheat(EnableAnticheat); LobbyUiController.Instance.RefreshCreateLobbyFromPreferences(); } diff --git a/LobbyKit/Features/Settings/SettingsSchema.cs b/LobbyKit/Features/Settings/SettingsSchema.cs index 35a3af0..a55f91b 100644 --- a/LobbyKit/Features/Settings/SettingsSchema.cs +++ b/LobbyKit/Features/Settings/SettingsSchema.cs @@ -90,6 +90,11 @@ internal static class SettingsSchema "Auto-Restart Lobby On Crash", draft => draft.AutoRestartOnCrash, (draft, value) => draft.AutoRestartOnCrash = value), + new ToggleSettingsField( + "enable-anticheat", + "Enable AntiCheat (experimental)", + draft => draft.EnableAnticheat, + (draft, value) => draft.EnableAnticheat = value), new TextSettingsField( "message-of-the-day", "Message of the Day", diff --git a/LobbyKit/LobbyKitCore.cs b/LobbyKit/LobbyKitCore.cs index ff98295..81749bd 100644 --- a/LobbyKit/LobbyKitCore.cs +++ b/LobbyKit/LobbyKitCore.cs @@ -47,6 +47,7 @@ public class LobbyKitCore : MelonMod private static MelonPreferences_Entry _joinMessageSize; private static MelonPreferences_Entry _leaveMessageSize; private static MelonPreferences_Entry _autoRestartOnCrash; + private static MelonPreferences_Entry _enableAnticheat; public static bool EnableGuestBangCommands => _enableGuestBangCommands?.Value ?? true; public static string ServerName => _serverName?.Value ?? string.Empty; @@ -70,6 +71,7 @@ public class LobbyKitCore : MelonMod public static int JoinMessageSize => _joinMessageSize?.Value ?? 75; public static int LeaveMessageSize => _leaveMessageSize?.Value ?? 75; public static bool AutoRestartOnCrash => _autoRestartOnCrash?.Value ?? false; + public static bool EnableAnticheat => _enableAnticheat?.Value ?? false; private PlayerReferenceManager _playerReferenceManager; @@ -107,6 +109,7 @@ public override unsafe void OnInitializeMelon() _joinMessageSize = _preferences.CreateEntry("JoinMessageSize", 75, "Join Message Size", "Font size percentage for join messages (e.g. 75 for 75%)."); _leaveMessageSize = _preferences.CreateEntry("LeaveMessageSize", 75, "Leave Message Size", "Font size percentage for leave messages (e.g. 75 for 75%)."); _autoRestartOnCrash = _preferences.CreateEntry("AutoRestartOnCrash", false, "Auto-Restart On Crash", "Automatically re-host the lobby when it crashes unexpectedly."); + _enableAnticheat = _preferences.CreateEntry("EnableAnticheat", false, "Enable AntiCheat", "Rate-limit and kick clients who spam server RPCs."); MelonPreferences.Save(); HarmonyInstance.PatchAll(); @@ -538,6 +541,15 @@ public static void SetLeaveMessageSize(int value) MelonPreferences.Save(); } + public static void SetEnableAnticheat(bool value) + { + if (_enableAnticheat == null) + return; + + _enableAnticheat.Value = value; + MelonPreferences.Save(); + } + public static void SetAutoRestartOnCrash(bool value) { if (_autoRestartOnCrash == null) From 040e361ce5e4d4430f4f4305ef3abf15ce106a04 Mon Sep 17 00:00:00 2001 From: rdavis Date: Sat, 30 May 2026 00:10:52 -0500 Subject: [PATCH 08/23] clean up todo --- docs/TODO.md | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 8780cb6..fde5211 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,30 +1,10 @@ # TODO -## High priority -- [ ] Add GitHub Actions workflow (build + artifact upload) - ## Chat system - [ ] Add vote kick system -## Lobby Search -- [x] Max Player slider goes to 64 -- [x] Max player slider is greater than or equal -- [x] Remember search settings -- [x] Opening the lobby search should respect settings. Currently only respects settings when the refresh button is clicked. - - -## Add a command to open an in-game-menu for host to edit mod lobby settings --[x] MOTD --[x] Enable Bang Commands --[x] Server settings should not immediately apply. Have an apply button and try to updatelobby when pressed. Update MelonPrefs when pressed as well. - ## Other - !help paging - Offline mode? - Proximity chat range - - - -- [x] Leaving a lobby you host doesn't reset player list so MOTD isn't resent if you re-start lobby -- [x] Auto-restart crashed lobbies From 2371bb167f2aba6897c4aacf61ee70a64c8414d8 Mon Sep 17 00:00:00 2001 From: rdavis Date: Sat, 30 May 2026 00:15:10 -0500 Subject: [PATCH 09/23] add funding.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..4033c69 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: rickydavis \ No newline at end of file From b713839f886303d4b54ad35d771b0a7dc4b4f319 Mon Sep 17 00:00:00 2001 From: rdavis Date: Sat, 30 May 2026 01:28:11 -0500 Subject: [PATCH 10/23] added more crash logging and despawn hacked trinkets --- .../Anticheat/CrashProtectionPatches.cs | 23 ---- .../Features/Lobby/CrashProtectionPatches.cs | 114 ++++++++++++++++ .../Lobby/SilentCrashDetectionPatches.cs | 124 ++++++++++++++++++ LobbyKit/LobbyKit.csproj | 3 +- LobbyKit/LobbyKitCore.cs | 4 + 5 files changed, 244 insertions(+), 24 deletions(-) delete mode 100644 LobbyKit/Features/Anticheat/CrashProtectionPatches.cs create mode 100644 LobbyKit/Features/Lobby/CrashProtectionPatches.cs create mode 100644 LobbyKit/Features/Lobby/SilentCrashDetectionPatches.cs diff --git a/LobbyKit/Features/Anticheat/CrashProtectionPatches.cs b/LobbyKit/Features/Anticheat/CrashProtectionPatches.cs deleted file mode 100644 index ac7946e..0000000 --- a/LobbyKit/Features/Anticheat/CrashProtectionPatches.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using HarmonyLib; -using MelonLoader; - -namespace LobbyKit.Patches -{ - [HarmonyPatch] - internal static class CrashProtectionPatches - { - // Any NetworkObject whose stop callback throws (e.g. a TrinketPack with an - // uninitialized SyncVar from a test-spawned object) will abort FishNet's despawn - // loop, leaving the server in a corrupt state that prevents clean restart. Suppress - // exceptions here so teardown continues past any broken NetworkObject. - [HarmonyPatch(typeof(Il2CppFishNet.Object.NetworkObject), "InvokeStopCallbacks")] - [HarmonyFinalizer] - private static Exception NetworkObject_InvokeStopCallbacks_Finalizer(Exception __exception) - { - if (__exception != null) - MelonLogger.Warning($"[LobbyKit] NetworkObject stop callback threw during teardown (suppressed): {__exception.GetType().Name}"); - return null; - } - } -} diff --git a/LobbyKit/Features/Lobby/CrashProtectionPatches.cs b/LobbyKit/Features/Lobby/CrashProtectionPatches.cs new file mode 100644 index 0000000..ecf7bde --- /dev/null +++ b/LobbyKit/Features/Lobby/CrashProtectionPatches.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections; +using HarmonyLib; +using Il2Cpp; +using Il2CppFishNet; +using MelonLoader; + +namespace LobbyKit.Patches +{ + [HarmonyPatch] + internal static class CrashProtectionPatches + { + // On awake, validate that the TrinketPack's player SyncVar is properly + // initialized. If it's not (e.g. a test-spawned object without a real FishNet + // owner), the SyncVar throws on .Value access and causes continuous spam. + // Wait until the object is server-initialized, then despawn it if invalid. + [HarmonyPatch(typeof(TrinketPack), "Awake")] + [HarmonyPostfix] + private static void TrinketPack_Awake_Postfix(TrinketPack __instance) + { + MelonCoroutines.Start(ValidateTrinketPackCoroutine(__instance)); + } + + private static IEnumerator ValidateTrinketPackCoroutine(TrinketPack trinketPack) + { + // Wait until FishNet has initialized this object on the server + int waited = 0; + while (!trinketPack.IsServerInitialized && waited < 300) + { + yield return null; + waited++; + } + + if (!trinketPack.IsServerInitialized) + yield break; + + string reason = GetTrinketInvalidReason(trinketPack); + if (reason == null) + yield break; + + MelonLogger.Warning($"[LobbyKit] Despawning invalid TrinketPack: {reason}"); + try { InstanceFinder.ServerManager?.Despawn(trinketPack.NetworkObject); } + catch (Exception ex) { MelonLogger.Warning($"[LobbyKit] TrinketPack despawn failed: {ex.GetType().Name}"); } + } + + // Returns a reason string if the trinket is in an invalid state, null if healthy. + // Validates the same property chain that TrinketEffectPosition accesses, so any + // state that would cause it to throw is caught here first. + private static string GetTrinketInvalidReason(TrinketPack trinketPack) + { + try + { + var syncVar = trinketPack.sync_PlayerThatPickedUpObject; + if (syncVar == null) + return "sync_PlayerThatPickedUpObject is null"; + + // Access .Value without ?. to force the throw if the SyncVar is uninitialized + var player = syncVar.Value; + if (player == null) + return null; // not held by anyone — valid idle state + + // Player reference exists but their network connection is gone — stale owner + if (player.Owner == null) + return $"player owner NetworkConnection is null (player disconnected while holding)"; + + // Validate holdingController — what TrinketEffectPosition accesses + var holdingController = player.holdingController; + if (holdingController == null) + return "player.holdingController is null"; + + return null; // all good + } + catch (Exception ex) + { + return $"exception during validation: {ex.GetType().Name}"; + } + } + + // Sled.FollowOwnerWhileInactive is called every FixedUpdate and spams + // "Owner is null!" errors after a crash or unexpected disconnect. The method + // already returns early for a null owner, but Unity logs the error first. + // Skip the method silently when there's no valid owner to follow. + [HarmonyPatch(typeof(Sled), "FollowOwnerWhileInactive")] + [HarmonyPrefix] + private static bool Sled_FollowOwnerWhileInactive_Prefix(Sled __instance) + { + try + { + var ownerSync = __instance.sync_Owner; + if (ownerSync == null || ownerSync.Value == null) + return false; + } + catch (Exception ex) + { + MelonLogger.Warning($"[LobbyKit] Sled.FollowOwnerWhileInactive guard threw: {ex.GetType().Name}"); + return false; + } + return true; + } + + // Any NetworkObject whose stop callback throws (e.g. a held object with a null + // owner after a crash) will abort FishNet's despawn loop, leaving objects stuck + // and spamming NullReferenceExceptions every time the pool resets them. Suppress + // exceptions here so teardown and object pooling continue past any broken callback. + [HarmonyPatch(typeof(Il2CppFishNet.Object.NetworkObject), "InvokeStopCallbacks")] + [HarmonyFinalizer] + private static Exception NetworkObject_InvokeStopCallbacks_Finalizer(Exception __exception) + { + if (__exception != null) + MelonLogger.Warning($"[LobbyKit] NetworkObject stop callback threw (suppressed): {__exception.GetType().Name}"); + return null; + } + } +} diff --git a/LobbyKit/Features/Lobby/SilentCrashDetectionPatches.cs b/LobbyKit/Features/Lobby/SilentCrashDetectionPatches.cs new file mode 100644 index 0000000..42c0d0f --- /dev/null +++ b/LobbyKit/Features/Lobby/SilentCrashDetectionPatches.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using HarmonyLib; +using Il2CppFishNet; +using Il2CppFishNet.Managing.Server; +using Il2CppFishNet.Transporting; +using Il2Cpp_Scripts.Managers; +using MelonLoader; + +namespace LobbyKit.Patches +{ + [HarmonyPatch] + internal static class SilentCrashDetectionPatches + { + // Route 1: FishNet server connection state change + // Fires whenever the server transitions between Stopped/Starting/Started/Stopping. + // If it reaches Stopped while WasHosting is still true, LeaveGame was never called + // — strong indicator of a silent crash. + [HarmonyPatch(typeof(ServerManager), "Transport_OnServerConnectionState")] + [HarmonyPostfix] + private static void ServerManager_Transport_OnServerConnectionState_Postfix(ServerConnectionStateArgs args) + { + MelonLogger.Msg($"[CrashDetect] Route1 — ServerConnectionState: {args.ConnectionState} | WasHosting={LobbyKitCore.WasHosting} | isHost={LobbyKitCore.isHost}"); + + if (args.ConnectionState == LocalConnectionState.Stopped && LobbyKitCore.WasHosting) + MelonLogger.Warning("[CrashDetect] Route1 — Server stopped while WasHosting=true: possible silent crash."); + } + + // Route 2: Remote client connection state change + // Tracks each client connecting/disconnecting. A burst of disconnects in quick + // succession (many clients leaving within a short window) while still hosting + // suggests a crash rather than voluntary leaves. + private static readonly Dictionary _recentDisconnects = []; + private const float DisconnectBurstWindow = 2f; + private const int DisconnectBurstThreshold = 2; + + [HarmonyPatch(typeof(ServerManager), "Transport_OnRemoteConnectionState")] + [HarmonyPostfix] + private static void ServerManager_Transport_OnRemoteConnectionState_Postfix(RemoteConnectionStateArgs args) + { + MelonLogger.Msg($"[CrashDetect] Route2 — RemoteConnectionState: clientId={args.ConnectionId} state={args.ConnectionState} | WasHosting={LobbyKitCore.WasHosting}"); + + if (args.ConnectionState != RemoteConnectionState.Stopped || !LobbyKitCore.WasHosting) + return; + + float now = UnityEngine.Time.realtimeSinceStartup; + _recentDisconnects[now] = args.ConnectionId; + + int burstCount = 0; + foreach (var ts in _recentDisconnects.Keys) + { + if (now - ts <= DisconnectBurstWindow) + burstCount++; + } + + MelonLogger.Msg($"[CrashDetect] Route2 — Remote client {args.ConnectionId} disconnected. {burstCount} disconnect(s) in last {DisconnectBurstWindow}s."); + + if (burstCount >= DisconnectBurstThreshold) + MelonLogger.Warning($"[CrashDetect] Route2 — {burstCount} clients disconnected within {DisconnectBurstWindow}s: possible silent crash."); + } + + // Routes 3 & 4: Polling coroutine + // Periodically checks FishNet server state and EOS lobby state while hosting. + // Route 3: IsAnyServerStarted() returning false while WasHosting is true. + // Route 4: EOS lobby still active (GetCurrentLobby non-null) but FishNet server stopped. + private static bool _pollingActive = false; + + internal static void StartPolling() + { + if (_pollingActive) + return; + _pollingActive = true; + MelonCoroutines.Start(PollHostStateCoroutine()); + } + + internal static void StopPolling() + { + _pollingActive = false; + } + + private static IEnumerator PollHostStateCoroutine() + { + MelonLogger.Msg("[CrashDetect] Polling coroutine started."); + + while (_pollingActive) + { + for (int i = 0; i < 120; i++) + yield return null; // poll every ~2 seconds + + if (!LobbyKitCore.WasHosting) + continue; + + bool serverRunning; + try { serverRunning = InstanceFinder.ServerManager?.IsAnyServerStarted() == true; } + catch { serverRunning = false; } + + bool eosLobbyActive; + string lobbyId; + try + { + var lobby = LobbyManager.Instance?.GetCurrentLobby(); + lobbyId = LobbyManager.Instance?.GetLobbyId() ?? ""; + eosLobbyActive = lobby != null && !string.IsNullOrEmpty(lobbyId); + } + catch + { + eosLobbyActive = false; + lobbyId = ""; + } + + MelonLogger.Msg($"[CrashDetect] Route3/4 poll — serverRunning={serverRunning} | eosLobbyActive={eosLobbyActive} | lobbyId={lobbyId} | WasHosting={LobbyKitCore.WasHosting}"); + + if (!serverRunning) + MelonLogger.Warning("[CrashDetect] Route3 — IsAnyServerStarted()=false while WasHosting=true: possible silent crash."); + + if (!serverRunning && eosLobbyActive) + MelonLogger.Warning("[CrashDetect] Route4 — EOS lobby active but FishNet server stopped: mismatch detected."); + } + + MelonLogger.Msg("[CrashDetect] Polling coroutine stopped."); + } + } +} diff --git a/LobbyKit/LobbyKit.csproj b/LobbyKit/LobbyKit.csproj index 09a8c4e..2d225a7 100644 --- a/LobbyKit/LobbyKit.csproj +++ b/LobbyKit/LobbyKit.csproj @@ -648,10 +648,11 @@ - + + diff --git a/LobbyKit/LobbyKitCore.cs b/LobbyKit/LobbyKitCore.cs index 81749bd..7594f8f 100644 --- a/LobbyKit/LobbyKitCore.cs +++ b/LobbyKit/LobbyKitCore.cs @@ -177,7 +177,10 @@ public void PlayerJoinedGame(PlayerReference p) localPlayer = p; isHost = p.ConnectionID == 32767; if (isHost) + { WasHosting = true; + Patches.SilentCrashDetectionPatches.StartPolling(); + } } else if (isHost && isNewConnection && ShowJoinMessages) { @@ -216,6 +219,7 @@ public void PlayerLeftGame(PlayerReference removedPlayer) { localPlayer = null; isHost = false; + Patches.SilentCrashDetectionPatches.StopPolling(); } } From de3831c5c9f7c37a63bc6ed71f64f7e0b32e0911 Mon Sep 17 00:00:00 2001 From: rdavis Date: Sat, 30 May 2026 19:14:53 -0500 Subject: [PATCH 11/23] log connection ID --- LobbyKit/LobbyKitCore.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/LobbyKit/LobbyKitCore.cs b/LobbyKit/LobbyKitCore.cs index 7594f8f..22cb326 100644 --- a/LobbyKit/LobbyKitCore.cs +++ b/LobbyKit/LobbyKitCore.cs @@ -190,6 +190,16 @@ public void PlayerJoinedGame(PlayerReference p) if (isHost) Patches.ChatSystem.SendMotdToPlayer(p); + + try + { + string cID = InstanceFinder.TransportManager.Transport.GetConnectionAddress(p.ConnectionID); + Debug.Log($"Client {p.ConnectionID} connection ID: {cID}"); + } + catch (Exception ex) + { + Debug.LogError($"Error occurred while fetching connection ID for client {p.ConnectionID}: {ex.Message}"); + } } public void PlayerLeftGame(PlayerReference removedPlayer) From b2f75d2a0b7cddb8e6c8d3e5d262ac60b2d38460 Mon Sep 17 00:00:00 2001 From: rdavis Date: Sat, 30 May 2026 23:28:37 -0500 Subject: [PATCH 12/23] use [] instead of <> to prevent autoclosing --- LobbyKit/ChatSystem.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/LobbyKit/ChatSystem.cs b/LobbyKit/ChatSystem.cs index a551d08..7d47f98 100644 --- a/LobbyKit/ChatSystem.cs +++ b/LobbyKit/ChatSystem.cs @@ -71,16 +71,16 @@ public CommandDefinition( "Shows the message of the day. Host can pass a message to set it."), ["!bc"] = new CommandDefinition( HandleBangCommandsCommand, - "!bc ", + "!bc [on|off]", "Enable or disable guest bang commands.", hostCommand: true), ["!tp"] = new CommandDefinition( HandleTpCommand, - "!tp ", + "!tp [name]", "Teleport to a player by name."), ["!tpme"] = new CommandDefinition( HandleTpMeCommand, - "!tpme ", + "!tpme [name]", "Ask a player to Teleport to you."), ["!tpa"] = new CommandDefinition( HandleTpAcceptCommand, @@ -88,7 +88,7 @@ public CommandDefinition( "Accept a Teleport request."), ["!tpf"] = new CommandDefinition( HandleTpForceCommand, - "!tpf ", + "!tpf [name]", "Force a player to Teleport to you.", hostCommand: true) }; From 92ecec4a6a61ed07a9cf146f5937504c963b7b26 Mon Sep 17 00:00:00 2001 From: rdavis Date: Sun, 31 May 2026 00:06:45 -0500 Subject: [PATCH 13/23] fix player timers? --- LobbyKit/Features/Lobby/AutoRestartPatches.cs | 2 + LobbyKit/LobbyKitCore.cs | 41 ++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/LobbyKit/Features/Lobby/AutoRestartPatches.cs b/LobbyKit/Features/Lobby/AutoRestartPatches.cs index 2b76ba0..8713bd4 100644 --- a/LobbyKit/Features/Lobby/AutoRestartPatches.cs +++ b/LobbyKit/Features/Lobby/AutoRestartPatches.cs @@ -46,6 +46,7 @@ private static void UiReferenceController_LeaveGame_Prefix() { LobbyKitCore.WasHosting = false; ChatSystem.ResetSessionState(); + LobbyKitCore.Instance?.ResetLobbyTrackingState("LeaveGame"); } [HarmonyPatch(typeof(UiReferenceController), "ReturnToMainMenu")] @@ -56,6 +57,7 @@ private static void UiReferenceController_ReturnToMainMenu_Prefix(int messageTyp return; LobbyKitCore.WasHosting = false; + LobbyKitCore.Instance?.ResetLobbyTrackingState("ReturnToMainMenu"); if (!_hasLastLobbyParams) { diff --git a/LobbyKit/LobbyKitCore.cs b/LobbyKit/LobbyKitCore.cs index 22cb326..89e23e4 100644 --- a/LobbyKit/LobbyKitCore.cs +++ b/LobbyKit/LobbyKitCore.cs @@ -119,9 +119,7 @@ public override unsafe void OnInitializeMelon() public override void OnSceneWasLoaded(int buildIndex, string sceneName) { ReferencesLoaded = false; - players.Clear(); - _playerJoinTimes.Clear(); - _playerJoinTimesByProductId.Clear(); + ResetLobbyTrackingState("scene loaded"); Patches.ChatSystem.ResetSessionState(); MelonCoroutines.Start(LoadReferences()); } @@ -164,13 +162,26 @@ public void PlayerJoinedGame(PlayerReference p) if (p == null) return; + double nowUptime = GetLobbyUptimeSeconds(); bool isLocalPlayer = p.IsLocalPlayerInstance(); bool isNewConnection = !players.Any(player => player != null && player.ConnectionID == p.ConnectionID); if (isNewConnection) players.Add(p); - if (isNewConnection && (isHost || isLocalPlayer)) - TrackPlayerJoinTime(p, GetLobbyUptimeSeconds()); + if (isHost || isLocalPlayer) + { + // Guard against stale timestamps carried across lobby transitions where + // remove events were missed. Old values often exceed current uptime. + if (!string.IsNullOrWhiteSpace(p.ProductUserId) && + _playerJoinTimesByProductId.TryGetValue(p.ProductUserId, out double trackedJoinTime) && + trackedJoinTime > nowUptime + 1d) + { + _playerJoinTimesByProductId[p.ProductUserId] = nowUptime; + } + + if (isNewConnection) + TrackPlayerJoinTime(p, nowUptime); + } if (isLocalPlayer) { @@ -230,6 +241,7 @@ public void PlayerLeftGame(PlayerReference removedPlayer) localPlayer = null; isHost = false; Patches.SilentCrashDetectionPatches.StopPolling(); + ResetLobbyTrackingState("local player removed"); } } @@ -265,6 +277,11 @@ public bool TryGetPlayerSessionDuration(PlayerReference playerReference, out dou joinTimeSeconds = playerReference.ConnectionID == 32767 ? 0d : currentUptimeSeconds; TrackPlayerJoinTime(playerReference, joinTimeSeconds); } + else if (joinTimeSeconds > currentUptimeSeconds + 1d) + { + joinTimeSeconds = playerReference.ConnectionID == 32767 ? 0d : currentUptimeSeconds; + TrackPlayerJoinTime(playerReference, joinTimeSeconds); + } sessionSeconds = currentUptimeSeconds - joinTimeSeconds; return true; @@ -283,6 +300,11 @@ public bool TryGetPlayerSessionDuration(string productUserId, out double session joinTimeSeconds = currentUptimeSeconds; _playerJoinTimesByProductId[productUserId] = joinTimeSeconds; } + else if (joinTimeSeconds > currentUptimeSeconds + 1d) + { + joinTimeSeconds = currentUptimeSeconds; + _playerJoinTimesByProductId[productUserId] = joinTimeSeconds; + } sessionSeconds = currentUptimeSeconds - joinTimeSeconds; return true; @@ -335,6 +357,15 @@ private void TrackPlayerJoinTime(PlayerReference playerReference, double joinTim _playerJoinTimesByProductId[playerReference.ProductUserId] = joinTimeSeconds; } + public void ResetLobbyTrackingState(string reason = null) + { + players.Clear(); + _playerJoinTimes.Clear(); + _playerJoinTimesByProductId.Clear(); + if (!string.IsNullOrWhiteSpace(reason)) + Debug.Log($"[LobbyKit] Reset lobby tracking state ({reason})."); + } + public static string FormatLobbyJoinTime(double uptimeSeconds) { if (uptimeSeconds < 0d) From 0063540f8c1f4e6325f242a3453ee5aaaa7ae6c8 Mon Sep 17 00:00:00 2001 From: rdavis Date: Sat, 6 Jun 2026 02:56:13 -0500 Subject: [PATCH 14/23] Add size command, update logging methods, block pushsled --- LobbyKit/ChatSystem.cs | 42 +- .../GenericServerRpcRateLimitPatch.cs | 4 +- .../Features/Anticheat/PlayerScaleEnforcer.cs | 76 ++++ .../Anticheat/PlayerScalePacketClamp.cs | 358 ++++++++++++++++++ .../Features/Anticheat/PlayerSizeRegistry.cs | 40 ++ .../Features/Anticheat/SledPushBlockPatch.cs | 99 +++++ .../Lobby/SilentCrashDetectionPatches.cs | 12 +- LobbyKit/LobbyKit.csproj | 5 + LobbyKit/LobbyKitCore.cs | 134 ++++++- LobbyKit/VerboseLog.cs | 55 +++ 10 files changed, 811 insertions(+), 14 deletions(-) create mode 100644 LobbyKit/Features/Anticheat/PlayerScaleEnforcer.cs create mode 100644 LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs create mode 100644 LobbyKit/Features/Anticheat/PlayerSizeRegistry.cs create mode 100644 LobbyKit/Features/Anticheat/SledPushBlockPatch.cs create mode 100644 LobbyKit/VerboseLog.cs diff --git a/LobbyKit/ChatSystem.cs b/LobbyKit/ChatSystem.cs index 7d47f98..f49e9f0 100644 --- a/LobbyKit/ChatSystem.cs +++ b/LobbyKit/ChatSystem.cs @@ -90,7 +90,11 @@ public CommandDefinition( HandleTpForceCommand, "!tpf [name]", "Force a player to Teleport to you.", - hostCommand: true) + hostCommand: true), + ["!size"] = new CommandDefinition( + HandleSizeCommand, + "!size [0.2-3.0]", + "Set your player size. !size 1 resets to normal.") }; private static void OpenSettingsMenu(PlayerControl playerControl, string args) @@ -171,6 +175,7 @@ public static void BroadcastMessage(int clientId, string text, string username = if (clientId == 0) { serverManager.Broadcast(CreatePublicChatMessage(text, username, showAboveUser), true); + MelonLogger.Msg($"[LobbyKit] chat | {StripRichText(username)}: {StripRichText(text)}"); return; } @@ -205,6 +210,7 @@ public static void SendMotdToPlayer(PlayerReference player) if (string.IsNullOrWhiteSpace(motd) || !MotdRecipients.Add(connectionId)) return; + MelonLogger.Msg($"[LobbyKit] MOTD -> {StripRichText(player.Username)} (conn {connectionId})"); MelonCoroutines.Start(SendMotdWhenReady(connectionId)); } @@ -230,6 +236,7 @@ public static void ResetSessionState() TeleportRequests.Clear(); LastCommandBySource.Clear(); LastExplicitCommandBySource.Clear(); + Features.Anticheat.PlayerSizeRegistry.Clear(); } private static bool TryHandleCommand(string message, int connectionId, bool isHostLocal) @@ -336,6 +343,14 @@ private static ChatMessage CreatePublicChatMessage(string text, string username, }; } + // Strips all TMP/HTML-style rich-text tags (, , <#FA0>, etc.) for clean log output. + public static string StripRichText(string text) + { + if (string.IsNullOrEmpty(text)) + return text; + return Regex.Replace(text, "<[^>]*>", string.Empty); + } + public static string AutoCloseTmpRichText(string text) { if (string.IsNullOrEmpty(text)) @@ -664,6 +679,31 @@ private static void HandleTpForceCommand(PlayerControl playerControl, string arg BroadcastMessage(target.ConnectionID, $"<#FF0>The host TP'd you to them."); } + private static void HandleSizeCommand(PlayerControl playerControl, string args) + { + if (string.IsNullOrWhiteSpace(args)) + { + float current = Features.Anticheat.PlayerSizeRegistry.GetSize(playerControl.OwnerId); + Reply(playerControl, $"<#7FF>Your size is {current:0.##}. Usage: !size <{Features.Anticheat.PlayerSizeRegistry.MinSize:0.##}-{Features.Anticheat.PlayerSizeRegistry.MaxSize:0.##}>"); + return; + } + + if (!float.TryParse(args.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float size)) + { + Reply(playerControl, "<#F00>Usage: !size "); + return; + } + + if (!Features.Anticheat.PlayerSizeRegistry.TrySetSize(playerControl.OwnerId, size)) + { + Reply(playerControl, $"<#FA0>Size must be between {Features.Anticheat.PlayerSizeRegistry.MinSize:0.##} and {Features.Anticheat.PlayerSizeRegistry.MaxSize:0.##}."); + return; + } + + Features.Anticheat.PlayerScalePacketClamp.ApplySize(playerControl, size); + Reply(playerControl, $"<#FF0>Size set to {size:0.##}."); + } + private static void TeleportPlayerTo(PlayerControl player, PlayerControl destination) { player.RpcWriter___RpcResetPosition___3848837105( diff --git a/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs b/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs index 166b979..43798de 100644 --- a/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs +++ b/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs @@ -100,7 +100,7 @@ private static bool Prefix(NetworkBehaviour __instance, MethodBase __originalMet { _lastCallTime[key] = now; if (LogAllRpcCalls) - MelonLogger.Msg($"[RPC] {name ?? __originalMethod.Name} | client={ownerId} | first call"); + VerboseLog.Msg($"[RPC] {name ?? __originalMethod.Name} | client={ownerId} | first call"); return true; } @@ -114,7 +114,7 @@ private static bool Prefix(NetworkBehaviour __instance, MethodBase __originalMet if (LogAllRpcCalls) { string tag = elapsed < limit ? " BLOCKED" : string.Empty; - MelonLogger.Msg($"[RPC] {name ?? __originalMethod.Name} | client={ownerId} | gap={elapsed * 1000f:F0}ms{tag}"); + VerboseLog.Msg($"[RPC] {name ?? __originalMethod.Name} | client={ownerId} | gap={elapsed * 1000f:F0}ms{tag}"); } if (elapsed < limit) diff --git a/LobbyKit/Features/Anticheat/PlayerScaleEnforcer.cs b/LobbyKit/Features/Anticheat/PlayerScaleEnforcer.cs new file mode 100644 index 0000000..e1d2de8 --- /dev/null +++ b/LobbyKit/Features/Anticheat/PlayerScaleEnforcer.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using Il2Cpp; +using Il2CppFishNet; +using MelonLoader; +using UnityEngine; + +namespace LobbyKit.Features.Anticheat +{ + // Server-side anticheat: keeps every non-host player's avatar root at scale 1,1,1 on the SERVER. + // + // Design (LobbyKit is server-first): + // - Runs ONLY on the host (InstanceFinder.IsServerStarted). No-op on pure clients. + // - Never relies on clients running LobbyKit. + // - Never touches the host's own player. + // + // NOTE on scope: player size is a CLIENT-AUTHORITATIVE NetworkTransform value, so the owning client + // wins on its OWN screen. The only way to override the owner's local view is to transfer ownership — + // but that churns FishNet ownership state and corrupts this game's fragile join/spawn path (breaks + // players joining), so we do NOT do that. Instead we simply clamp the SERVER's authoritative copy to + // 1,1,1 (no ownership change, no network churn): this keeps the server's own copy correct and is safe + // during joins. A cheater may still see themselves enlarged on their own screen (cosmetic). To also + // stop the cheat for OTHER players reliably, escalate to a kick (see KickOversizedPlayers option). + internal static class PlayerScaleEnforcer + { + private const float Tolerance = 0.001f; + private const float CheckIntervalSeconds = 0.5f; + + private static float _timer; + private static PlayerControl[] _cache; + private static readonly Dictionary _lastLoggedByOwner = new(); + + public static void Tick() + { + if (!InstanceFinder.IsServerStarted) // host-only + return; + + _timer -= Time.unscaledDeltaTime; + if (_cache == null || _timer <= 0f) + { + _cache = UnityEngine.Object.FindObjectsOfType(); + _timer = CheckIntervalSeconds; + } + + int hostConnId = 32767; + try { hostConnId = InstanceFinder.ClientManager.Connection.ClientId; } catch { } + + foreach (var pc in _cache) + { + if (pc == null) + continue; + + int owner; + try { owner = pc.OwnerId; } catch { continue; } + if (owner == hostConnId) // never affect the host's own player + continue; + + Transform t = pc.transform; + Vector3 s = t.localScale; + if (Mathf.Abs(s.x - 1f) <= Tolerance && Mathf.Abs(s.y - 1f) <= Tolerance && Mathf.Abs(s.z - 1f) <= Tolerance) + continue; + + t.localScale = Vector3.one; // clamp the server's copy only — no ownership transfer + LogCorrection(owner, s); + } + } + + private static void LogCorrection(int owner, Vector3 was) + { + float now = Time.unscaledTime; + if (_lastLoggedByOwner.TryGetValue(owner, out float last) && now - last < 5f) + return; + _lastLoggedByOwner[owner] = now; + MelonLogger.Msg($"[LobbyKit] Anticheat: player size (client={owner}) was ({was.x:F2},{was.y:F2},{was.z:F2}) — clamped server copy to 1,1,1."); + } + } +} diff --git a/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs b/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs new file mode 100644 index 0000000..46f8e15 --- /dev/null +++ b/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs @@ -0,0 +1,358 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Il2Cpp; +using Il2CppFishNet; +using Il2CppFishNet.Component.Transforming; +using Il2CppFishNet.Serializing; +using MelonLoader; + +namespace LobbyKit.Features.Anticheat +{ + // Server-side player-size control + anticheat. Each player's avatar scale is forced to their REGISTERED + // size (PlayerSizeRegistry, default 1.0) inside the FishNet transform packet, so the SERVER and every OTHER + // client render them at that size — with no ownership transfer (which churns ownership state and breaks + // joining players). Host-only, never relies on clients. + // + // A scale a player did not earn (a cheat, or any value != their registered size) is rewritten back here. + // The !size command sets a player's registered size within [0.5, 3.0] and pushes it out (see ApplySize). + // Because the NetworkTransform is client-authoritative, the owner's OWN screen ignores server transforms + // (MoveToTarget hard-returns for IsOwner, GameAssembly.dll.c:1153198) — so we briefly forge them non-owner + // (the ownership nudge / ApplySize push) to make their client adopt the server value, with no broadcast and + // no RebuildObservers, so joins are unaffected. + // + // FishNet's client-authoritative NetworkTransform relays the owner's RAW serialized packet to observers, + // so we rewrite the scale bytes on the server before they're stored/forwarded. + // + // Wire format (SerializeChanged): [flagA][posX?][posY?][posZ?][rot?][flagB][scaleX?][scaleY?][scaleZ?][parent?] + // flagA bits: posX i16(0x01)/single(0x02), posY i16(0x04)/single(0x08), posZ i16(0x10)/single(0x20), + // rotation(0x40), extended-section-present(0x80). + // flagB bits (only if flagA&0x80): scaleX i16(0x01)/single(0x02), scaleY i16(0x04)/single(0x08), + // scaleZ i16(0x10)/single(0x20), parent(0x40), teleport(0x80). + // Each scale axis is Int16(scale*100) [2B] or full Single [4B] — both fixed width, so we overwrite the + // value bytes in place with 1.0 (Int16 100 / Single 1.0f). Scale is only present when it CHANGES, so + // normal movement packets early-out on byte 0. + // + // Failsafe: the parsed structure must consume EXACTLY the whole packet (length self-check). Any mismatch, + // any parenting, any exception -> the packet is left 100% untouched (worst case: cheat not clamped, never + // a corrupt transform packet). + internal static class PlayerScalePacketClamp + { + // _packing.Rotation == Packed (AutoPackType.Packed=1) → Writer.WriteQuaternion uses Quaternion32 + // compression = 4 bytes (NOT 8: that's PackedLess/Quaternion64). Rotation is only in the packet when + // the player is turning (walking), so an off-by-this failed the p==n self-check and silently skipped + // every rotating packet. The self-check still guards this value. + private const int QuaternionBytes = 4; + private const float Tolerance = 0.01f; + private static readonly Dictionary _lastLoggedByOwner = new(); + + public static void Apply(HarmonyLib.Harmony harmony) + { + try + { + MethodInfo m = typeof(NetworkTransform).GetMethod( + "RpcLogic___ServerUpdateTransform___2713644489", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (m == null) + { + MelonLogger.Warning("[LobbyKit] Anticheat: ServerUpdateTransform not found; scale packet clamp disabled."); + return; + } + harmony.Patch(m, prefix: new HarmonyMethod(typeof(PlayerScalePacketClamp), nameof(Prefix))); + MelonLogger.Msg("[LobbyKit] Anticheat: player-scale packet clamp installed."); + } + catch (Exception ex) + { + MelonLogger.Warning($"[LobbyKit] Anticheat: scale packet clamp patch failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static void Prefix(NetworkTransform __instance, Il2CppSystem.ArraySegment __0) + { + try + { + if (!LobbyKitCore.EnforcePlayerScale) return; + if (!InstanceFinder.IsServerStarted) return; + + var arr = __0.Array; + if (arr == null) return; + int o = __0.Offset; + int n = __0.Count; + if (n < 2) return; + + byte flagA = arr[o]; + if ((flagA & 0x80) == 0) return; // no extended section -> no scale (the common, hot path) + + int hostConnId = 32767; + try { hostConnId = InstanceFinder.ClientManager.Connection.ClientId; } catch { } + int owner; + try { owner = __instance.OwnerId; } catch { return; } + if (owner == hostConnId) return; // never touch the host's own player + if (__instance.GetComponent() == null) return; // players only + + float target = PlayerSizeRegistry.GetSize(owner); // their !size choice, default 1.0 + + int p = 1; + p += (flagA & 0x01) != 0 ? 2 : 0; p += (flagA & 0x02) != 0 ? 4 : 0; // posX + p += (flagA & 0x04) != 0 ? 2 : 0; p += (flagA & 0x08) != 0 ? 4 : 0; // posY + p += (flagA & 0x10) != 0 ? 2 : 0; p += (flagA & 0x20) != 0 ? 4 : 0; // posZ + if ((flagA & 0x40) != 0) p += QuaternionBytes; // rotation + if (p >= n) return; + + byte flagB = arr[o + p]; + p += 1; + if ((flagB & 0x40) != 0) return; // parent present (variable length) -> skip, very rare + + int scaleStart = o + p; + int sx = (flagB & 0x01) != 0 ? 2 : ((flagB & 0x02) != 0 ? 4 : 0); + int sy = (flagB & 0x04) != 0 ? 2 : ((flagB & 0x08) != 0 ? 4 : 0); + int sz = (flagB & 0x10) != 0 ? 2 : ((flagB & 0x20) != 0 ? 4 : 0); + p += sx + sy + sz; + + if (p != n) return; // structure must consume the whole packet, else fail safe + if (sx == 0 && sy == 0 && sz == 0) return; + + // Only act if the scale deviates from the player's registered size (leave matching packets alone). + int ri = scaleStart; + float vx = ReadOneScale(arr, ref ri, sx); + float vy = ReadOneScale(arr, ref ri, sy); + float vz = ReadOneScale(arr, ref ri, sz); + if (NearTarget(vx, target) && NearTarget(vy, target) && NearTarget(vz, target)) return; + + int wi = scaleStart; + wi = WriteOneScale(arr, wi, sx, target); + wi = WriteOneScale(arr, wi, sy, target); + wi = WriteOneScale(arr, wi, sz, target); + + LogClamp(owner, vx, vy, vz, target); + + // Also correct the player's OWN screen: briefly tell only their client it doesn't own the + // object so it applies the server's clamped scale, then tell it it owns the object again. + TriggerOwnershipNudge(__instance, owner); + } + catch + { + // Never let a parse error corrupt the packet or throw into FishNet's RPC path. + } + } + + // ── Ownership nudge: fixes the cheater's own view without a real ownership change ─────────────── + // A client-authoritative owner ignores all server transforms for its own object (MoveToTarget hard- + // returns for IsOwner). We forge an ownership-change packet (id 11) sent ONLY to the cheater telling + // them they no longer own the object — their NetworkTransform then applies the server's authoritative + // (clamped) scale=1 to their own view — then forge it back. The server's real ownership never changes, + // so there is NO RebuildObservers and NO broadcast to anyone else: joins are completely unaffected. + // Validated live. For a one-time size cheat this corrects the owner's view permanently; a continuously + // re-applied cheat would re-snap on the next cooldown. + private const short OwnershipChangePacketId = 11; + // Owners with a nudge currently in flight. We don't time-throttle: every cheat packet re-nudges as soon + // as the previous nudge finishes, so re-applied cheats are corrected promptly (a continuously re-applied + // cheat just gets nudged back-to-back ~every NudgeHoldSeconds — a constant hitch for the cheater). + private static readonly HashSet _nudgingOwners = new(); + + private static void TriggerOwnershipNudge(NetworkTransform nt, int ownerId) + { + try + { + if (_nudgingOwners.Contains(ownerId)) return; // a nudge for this owner is already running + var nob = nt.NetworkObject; + var conn = nt.Owner; + if (nob == null || conn == null || !conn.IsValid) return; + _nudgingOwners.Add(ownerId); + MelonCoroutines.Start(NudgeCoroutine(nob, conn, ownerId)); + } + catch { } + } + + private static IEnumerator NudgeCoroutine(Il2CppFishNet.Object.NetworkObject nob, Il2CppFishNet.Connection.NetworkConnection conn, int ownerId) + { + try + { + SendOwnershipChange(nob, Il2CppFishNet.Managing.NetworkManager.EmptyConnection, conn); // "you don't own this" + + // Wait exactly one FishNet tick before restoring, so the restore goes out in a SEPARATE send + // batch than the remove (otherwise they coalesce and the client never spends a tick as a + // non-owner -> MoveToTarget never applies scale=1). Tick-synchronized = tightest reliable gap. + var tm = InstanceFinder.TimeManager; + if (tm != null) + { + var startTick = tm.Tick; + int guard = 0; + while (tm.Tick == startTick && guard++ < 120) // guard caps the wait if ticks ever stall + yield return null; + } + else + { + yield return new UnityEngine.WaitForSecondsRealtime(0.05f); + } + + if (nob != null && conn != null && conn.IsValid) + SendOwnershipChange(nob, conn, conn); // "you own this again" + } + finally + { + _nudgingOwners.Remove(ownerId); // ready to re-nudge on the next cheat packet + } + } + + // Sends a forged FishNet ownership-change packet ([int16 id=11][NetworkObject][NetworkConnection]) + // to a single target client only (mirrors GiveOwnership's per-connection send; never broadcasts). + private static void SendOwnershipChange(Il2CppFishNet.Object.NetworkObject nob, + Il2CppFishNet.Connection.NetworkConnection reportedOwner, + Il2CppFishNet.Connection.NetworkConnection target) + { + try + { + var nm = InstanceFinder.NetworkManager; + if (nm == null || nob == null || target == null) return; + var w = WriterPool.Retrieve(nm); + w.WriteInt16(OwnershipChangePacketId); + w.WriteNetworkObject(nob); + w.WriteNetworkConnection(reportedOwner); + var seg = w.GetArraySegment(); + nm.TransportManager.SendToClient((byte)0, seg, target, true, default(Il2CppFishNet.Object.DataOrderType)); + w.Store(); + } + catch (Exception ex) + { + MelonLogger.Warning($"[LobbyKit] Anticheat: ownership nudge send failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static bool NearTarget(float v, float target) => Math.Abs(v - target) <= Tolerance; + + private static float ReadOneScale(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppArrayBase arr, ref int idx, int len) + { + if (len == 2) { short s = (short)(arr[idx] | (arr[idx + 1] << 8)); idx += 2; return s / 100f; } + if (len == 4) + { + int bits = arr[idx] | (arr[idx + 1] << 8) | (arr[idx + 2] << 16) | (arr[idx + 3] << 24); + idx += 4; return BitConverter.Int32BitsToSingle(bits); + } + return 1f; + } + + // Overwrites a scale axis value with `target` in its existing encoding (little-endian): + // 2 bytes -> Int16(target * 100) | 4 bytes -> Single(target) + private static int WriteOneScale(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppArrayBase arr, int idx, int len, float target) + { + if (len == 2) + { + short s = (short)UnityEngine.Mathf.RoundToInt(target * 100f); + arr[idx] = (byte)(s & 0xFF); + arr[idx + 1] = (byte)((s >> 8) & 0xFF); + return idx + 2; + } + if (len == 4) + { + byte[] b = BitConverter.GetBytes(target); // little-endian on x86/x64 + arr[idx] = b[0]; arr[idx + 1] = b[1]; arr[idx + 2] = b[2]; arr[idx + 3] = b[3]; + return idx + 4; + } + return idx; + } + + private static void LogClamp(int owner, float vx, float vy, float vz, float target) + { + float now; + try { now = UnityEngine.Time.unscaledTime; } catch { now = 0f; } + if (_lastLoggedByOwner.TryGetValue(owner, out float last) && now - last < 5f) return; + _lastLoggedByOwner[owner] = now; + MelonLogger.Msg($"[LobbyKit] Anticheat: corrected player (client={owner}) scale ({vx:F2},{vy:F2},{vz:F2}) -> {target:0.##} for the server and all other clients."); + } + + // ── !size apply: push a player's chosen size out to everyone ───────────────────────────────────── + // Called by the !size command. Sets the server's authoritative copy, relays the new scale to all + // OTHER clients, and ownership-forges the OWNER (whose client-auth NetworkTransform otherwise ignores + // server transforms) so their own screen updates too. The pushed packet carries the player's CURRENT + // position (single-encoded) so applying it only changes scale — it never moves them. After this, the + // clamp's per-user target keeps them there (and snaps cheats back). + public static void ApplySize(PlayerControl pc, float size) + { + try + { + if (pc == null) return; + + pc.transform.localScale = new UnityEngine.Vector3(size, size, size); // server copy + new joiners + + var nob = pc.NetworkObject; + var nt = pc.GetComponent(); + if (nob == null || nt == null) return; + var conn = nob.Owner; + + byte[] packet = BuildPositionScalePacket(pc.transform.position, size); + + // Everyone except the owner: relay the transform (the owner's reader skips this RPC for itself). + try { nt.RpcWriter___ObserversUpdateClientAuthoritativeTransform___2713644489(ToSeg(packet), Il2CppFishNet.Transporting.Channel.Reliable); } + catch (Exception ex) { MelonLogger.Warning($"[LobbyKit] !size observer relay failed: {ex.GetType().Name}: {ex.Message}"); } + + // The owner: forge them non-owner so MoveToTarget applies it, deliver the goal, restore ownership. + if (conn != null && conn.IsValid) + MelonCoroutines.Start(PushSizeToOwner(nob, nt, conn, packet)); + } + catch (Exception ex) + { + MelonLogger.Warning($"[LobbyKit] !size apply failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static IEnumerator PushSizeToOwner(Il2CppFishNet.Object.NetworkObject nob, NetworkTransform nt, + Il2CppFishNet.Connection.NetworkConnection conn, byte[] packet) + { + SendOwnershipChange(nob, Il2CppFishNet.Managing.NetworkManager.EmptyConnection, conn); // "you don't own this" + yield return WaitOneTick(); // separate send batch + try { nt.RpcWriter___TargetUpdateTransform___748863190(conn, ToSeg(packet), Il2CppFishNet.Transporting.Channel.Reliable); } catch { } + yield return new UnityEngine.WaitForSecondsRealtime(0.4f); // let the scale reach target + if (nob != null && conn != null && conn.IsValid) + SendOwnershipChange(nob, conn, conn); // "you own this again" + } + + // Full transform packet carrying position (single) + scale (single). Position is included so the client + // applies the goal without moving — only scale changes. flagA: posX|posY|posZ single + extended(0x80); + // flagB: scaleX|scaleY|scaleZ single. 1 + 12 + 1 + 12 = 26 bytes. + private static byte[] BuildPositionScalePacket(UnityEngine.Vector3 pos, float scale) + { + byte[] pkt = new byte[26]; + pkt[0] = 0xAA; // posX(0x02) posY(0x08) posZ(0x20) extended(0x80) + WriteFloatLE(pkt, 1, pos.x); + WriteFloatLE(pkt, 5, pos.y); + WriteFloatLE(pkt, 9, pos.z); + pkt[13] = 0x2A; // scaleX(0x02) scaleY(0x08) scaleZ(0x20) + WriteFloatLE(pkt, 14, scale); + WriteFloatLE(pkt, 18, scale); + WriteFloatLE(pkt, 22, scale); + return pkt; + } + + private static void WriteFloatLE(byte[] arr, int idx, float f) + { + byte[] b = BitConverter.GetBytes(f); + arr[idx] = b[0]; arr[idx + 1] = b[1]; arr[idx + 2] = b[2]; arr[idx + 3] = b[3]; + } + + private static Il2CppSystem.ArraySegment ToSeg(byte[] data) + { + var arr = new Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray(data.Length); + for (int i = 0; i < data.Length; i++) arr[i] = data[i]; + return new Il2CppSystem.ArraySegment(arr); + } + + private static IEnumerator WaitOneTick() + { + var tm = InstanceFinder.TimeManager; + if (tm != null) + { + var startTick = tm.Tick; + int guard = 0; + while (tm.Tick == startTick && guard++ < 120) + yield return null; + } + else + { + yield return new UnityEngine.WaitForSecondsRealtime(0.05f); + } + } + } +} diff --git a/LobbyKit/Features/Anticheat/PlayerSizeRegistry.cs b/LobbyKit/Features/Anticheat/PlayerSizeRegistry.cs new file mode 100644 index 0000000..7adfe65 --- /dev/null +++ b/LobbyKit/Features/Anticheat/PlayerSizeRegistry.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace LobbyKit.Features.Anticheat +{ + // Per-player avatar size chosen via the !size command. Keyed on connectionId, which matches both the + // NetworkTransform OwnerId (used by the scale clamp) and PlayerReference.ConnectionID (used by the chat + // command + leave hook). Default is 1.0 (normal). Entries are removed when a player leaves + // (LobbyKitCore.PlayerLeftGame) and cleared on session reset, so a reconnecting player is back at normal + // until they !size again. + // + // The scale clamp reads GetSize() as its enforced target: any scale packet a player sends is rewritten to + // their registered size (default 1.0), so this doubles as the size anticheat — players can only be a size + // the host's range allows, and cheats are clamped back to it. + internal static class PlayerSizeRegistry + { + public const float MinSize = 0.2f; + public const float MaxSize = 3.0f; + public const float DefaultSize = 1.0f; + + private static readonly Dictionary _sizes = new(); + + public static float GetSize(int connectionId) + => _sizes.TryGetValue(connectionId, out float s) ? s : DefaultSize; + + public static bool HasSize(int connectionId) => _sizes.ContainsKey(connectionId); + + // Stores the size if it is within [MinSize, MaxSize]. Returns false (and stores nothing) if out of range. + public static bool TrySetSize(int connectionId, float size) + { + if (size < MinSize || size > MaxSize) + return false; + _sizes[connectionId] = size; + return true; + } + + public static void Remove(int connectionId) => _sizes.Remove(connectionId); + + public static void Clear() => _sizes.Clear(); + } +} diff --git a/LobbyKit/Features/Anticheat/SledPushBlockPatch.cs b/LobbyKit/Features/Anticheat/SledPushBlockPatch.cs new file mode 100644 index 0000000..3c1b462 --- /dev/null +++ b/LobbyKit/Features/Anticheat/SledPushBlockPatch.cs @@ -0,0 +1,99 @@ +using System; +using System.Reflection; +using HarmonyLib; +using Il2CppFishNet; +using MelonLoader; + +namespace LobbyKit.Features.Anticheat +{ + // No-ops the CLIENT-initiated sled push (Cmd_PushSled) while leaving SpeedBoosts working. + // + // Cmd_PushSled(Vector3 force, bool doEffects) is a ServerRpc on Sled: the client hands the server an + // arbitrary force that goes straight into Rigidbody.AddForce in Server_PushSled (no cap) — a raw + // speed/launch lever. BUT Server_PushSled is ALSO the sink for legit SpeedBoosts: SpeedBoost.OnTriggerEnter + // -> Sled.Server_SpeedBoost_Flat -> Server_PushSled. So patching Server_PushSled outright kills boosts too + // (confirmed live). And patching only RpcLogic misses every REMOTE client (their Cmd runs + // RpcReader___Cmd_PushSled -> Server_PushSled DIRECTLY, never touching RpcLogic — see + // [[fishnet-serverrpc-patch-sink]]). + // + // So we bracket the two Cmd entry points (RpcReader for remote, RpcLogic for local/host) with a depth + // counter and no-op Server_PushSled ONLY while we're inside one. A SpeedBoost's Server_PushSled call has + // depth 0 -> allowed; a Cmd's has depth > 0 -> blocked. The Cmd readers still run fully (we don't skip + // them), so no PooledReader desync. A finalizer (not a postfix) decrements, so a throw inside a reader can + // never leave the counter stuck and silently break boosts. Fail-open everywhere: any error -> allow. + internal static class SledPushBlockPatch + { + private static int _cmdPushDepth; + private static int _blockedCount; + private static float _lastLog; + + public static void Apply(HarmonyLib.Harmony harmony) + { + try + { + var t = typeof(Il2Cpp.Sled); + var bf = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + + MethodInfo sink = t.GetMethod("Server_PushSled", bf); + if (sink == null) + { + MelonLogger.Warning("[LobbyKit] Anticheat: Sled.Server_PushSled not found; sled-push block disabled."); + return; + } + + MethodInfo reader = t.GetMethod("RpcReader___Cmd_PushSled___1082256137", bf); + MethodInfo logic = t.GetMethod("RpcLogic___Cmd_PushSled___1082256137", bf); + + var enter = new HarmonyMethod(typeof(SledPushBlockPatch), nameof(EnterCmdPush)); + var leave = new HarmonyMethod(typeof(SledPushBlockPatch), nameof(LeaveCmdPush)); + if (reader != null) harmony.Patch(reader, prefix: enter, finalizer: leave); + if (logic != null) harmony.Patch(logic, prefix: enter, finalizer: leave); + + harmony.Patch(sink, prefix: new HarmonyMethod(typeof(SledPushBlockPatch), nameof(SinkPrefix))); + MelonLogger.Msg($"[LobbyKit] Anticheat: sled-push block installed (Cmd_PushSled blocked, SpeedBoost allowed; reader={reader != null}, logic={logic != null})."); + } + catch (Exception ex) + { + MelonLogger.Warning($"[LobbyKit] Anticheat: sled-push block patch failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + // Bracket the Cmd entry points. EnterCmdPush returns void (never skips the reader -> no desync); the + // finalizer always runs, even if the reader throws, so the counter can't get stuck. + private static void EnterCmdPush() { _cmdPushDepth++; } + + private static Exception LeaveCmdPush(Exception __exception) + { + if (_cmdPushDepth > 0) _cmdPushDepth--; + return __exception; // propagate any original exception unchanged (don't swallow) + } + + // The shared sink: block AddForce only when we got here from a client Cmd push (depth > 0). + // SpeedBoosts (Server_SpeedBoost_Flat -> here) have depth 0 and pass through untouched. + private static bool SinkPrefix(Il2Cpp.Sled __instance) + { + try + { + if (!InstanceFinder.IsServerStarted) return true; // only act as the host + if (!LobbyKitCore.BlockSledPush) return true; // pref off -> allow all pushes + if (_cmdPushDepth <= 0) return true; // not a client Cmd (e.g. SpeedBoost) -> allow + + _blockedCount++; + float now; + try { now = UnityEngine.Time.unscaledTime; } catch { now = 0f; } + if (now - _lastLog >= 5f) + { + _lastLog = now; + int owner = -1; + try { owner = __instance.OwnerId; } catch { } + MelonLogger.Msg($"[LobbyKit] Anticheat: blocked Cmd_PushSled (latest sled owner={owner}; {_blockedCount} total since start)."); + } + return false; // no-op the client's manual push + } + catch + { + return true; // fail-open: never break a legit push (boost) on our error + } + } + } +} diff --git a/LobbyKit/Features/Lobby/SilentCrashDetectionPatches.cs b/LobbyKit/Features/Lobby/SilentCrashDetectionPatches.cs index 42c0d0f..1b56bcc 100644 --- a/LobbyKit/Features/Lobby/SilentCrashDetectionPatches.cs +++ b/LobbyKit/Features/Lobby/SilentCrashDetectionPatches.cs @@ -21,7 +21,7 @@ internal static class SilentCrashDetectionPatches [HarmonyPostfix] private static void ServerManager_Transport_OnServerConnectionState_Postfix(ServerConnectionStateArgs args) { - MelonLogger.Msg($"[CrashDetect] Route1 — ServerConnectionState: {args.ConnectionState} | WasHosting={LobbyKitCore.WasHosting} | isHost={LobbyKitCore.isHost}"); + VerboseLog.Msg($"[CrashDetect] Route1 — ServerConnectionState: {args.ConnectionState} | WasHosting={LobbyKitCore.WasHosting} | isHost={LobbyKitCore.isHost}"); if (args.ConnectionState == LocalConnectionState.Stopped && LobbyKitCore.WasHosting) MelonLogger.Warning("[CrashDetect] Route1 — Server stopped while WasHosting=true: possible silent crash."); @@ -39,7 +39,7 @@ private static void ServerManager_Transport_OnServerConnectionState_Postfix(Serv [HarmonyPostfix] private static void ServerManager_Transport_OnRemoteConnectionState_Postfix(RemoteConnectionStateArgs args) { - MelonLogger.Msg($"[CrashDetect] Route2 — RemoteConnectionState: clientId={args.ConnectionId} state={args.ConnectionState} | WasHosting={LobbyKitCore.WasHosting}"); + VerboseLog.Msg($"[CrashDetect] Route2 — RemoteConnectionState: clientId={args.ConnectionId} state={args.ConnectionState} | WasHosting={LobbyKitCore.WasHosting}"); if (args.ConnectionState != RemoteConnectionState.Stopped || !LobbyKitCore.WasHosting) return; @@ -54,7 +54,7 @@ private static void ServerManager_Transport_OnRemoteConnectionState_Postfix(Remo burstCount++; } - MelonLogger.Msg($"[CrashDetect] Route2 — Remote client {args.ConnectionId} disconnected. {burstCount} disconnect(s) in last {DisconnectBurstWindow}s."); + VerboseLog.Msg($"[CrashDetect] Route2 — Remote client {args.ConnectionId} disconnected. {burstCount} disconnect(s) in last {DisconnectBurstWindow}s."); if (burstCount >= DisconnectBurstThreshold) MelonLogger.Warning($"[CrashDetect] Route2 — {burstCount} clients disconnected within {DisconnectBurstWindow}s: possible silent crash."); @@ -81,7 +81,7 @@ internal static void StopPolling() private static IEnumerator PollHostStateCoroutine() { - MelonLogger.Msg("[CrashDetect] Polling coroutine started."); + VerboseLog.Msg("[CrashDetect] Polling coroutine started."); while (_pollingActive) { @@ -109,7 +109,7 @@ private static IEnumerator PollHostStateCoroutine() lobbyId = ""; } - MelonLogger.Msg($"[CrashDetect] Route3/4 poll — serverRunning={serverRunning} | eosLobbyActive={eosLobbyActive} | lobbyId={lobbyId} | WasHosting={LobbyKitCore.WasHosting}"); + VerboseLog.Msg($"[CrashDetect] Route3/4 poll — serverRunning={serverRunning} | eosLobbyActive={eosLobbyActive} | lobbyId={lobbyId} | WasHosting={LobbyKitCore.WasHosting}"); if (!serverRunning) MelonLogger.Warning("[CrashDetect] Route3 — IsAnyServerStarted()=false while WasHosting=true: possible silent crash."); @@ -118,7 +118,7 @@ private static IEnumerator PollHostStateCoroutine() MelonLogger.Warning("[CrashDetect] Route4 — EOS lobby active but FishNet server stopped: mismatch detected."); } - MelonLogger.Msg("[CrashDetect] Polling coroutine stopped."); + VerboseLog.Msg("[CrashDetect] Polling coroutine stopped."); } } } diff --git a/LobbyKit/LobbyKit.csproj b/LobbyKit/LobbyKit.csproj index 2d225a7..2ce1bbf 100644 --- a/LobbyKit/LobbyKit.csproj +++ b/LobbyKit/LobbyKit.csproj @@ -658,6 +658,11 @@ + + + + + diff --git a/LobbyKit/LobbyKitCore.cs b/LobbyKit/LobbyKitCore.cs index 89e23e4..03b7a5c 100644 --- a/LobbyKit/LobbyKitCore.cs +++ b/LobbyKit/LobbyKitCore.cs @@ -48,6 +48,8 @@ public class LobbyKitCore : MelonMod private static MelonPreferences_Entry _leaveMessageSize; private static MelonPreferences_Entry _autoRestartOnCrash; private static MelonPreferences_Entry _enableAnticheat; + private static MelonPreferences_Entry _enforcePlayerScale; + private static MelonPreferences_Entry _blockSledPush; public static bool EnableGuestBangCommands => _enableGuestBangCommands?.Value ?? true; public static string ServerName => _serverName?.Value ?? string.Empty; @@ -72,6 +74,8 @@ public class LobbyKitCore : MelonMod public static int LeaveMessageSize => _leaveMessageSize?.Value ?? 75; public static bool AutoRestartOnCrash => _autoRestartOnCrash?.Value ?? false; public static bool EnableAnticheat => _enableAnticheat?.Value ?? false; + public static bool EnforcePlayerScale => _enforcePlayerScale?.Value ?? true; + public static bool BlockSledPush => _blockSledPush?.Value ?? true; private PlayerReferenceManager _playerReferenceManager; @@ -110,10 +114,44 @@ public override unsafe void OnInitializeMelon() _leaveMessageSize = _preferences.CreateEntry("LeaveMessageSize", 75, "Leave Message Size", "Font size percentage for leave messages (e.g. 75 for 75%)."); _autoRestartOnCrash = _preferences.CreateEntry("AutoRestartOnCrash", false, "Auto-Restart On Crash", "Automatically re-host the lobby when it crashes unexpectedly."); _enableAnticheat = _preferences.CreateEntry("EnableAnticheat", false, "Enable AntiCheat", "Rate-limit and kick clients who spam server RPCs."); + _enforcePlayerScale = _preferences.CreateEntry("EnforcePlayerScale", true, "Enforce Player Scale", "Anticheat: force each player's avatar to their allowed size (default 1, or their !size choice) and clamp any cheated scale back."); + _blockSledPush = _preferences.CreateEntry("BlockSledPush", true, "Block Sled Push", "Anticheat: no-op the client-initiated Cmd_PushSled (a raw AddForce lever). Boosts use a separate path and are unaffected. Disable to allow manual sled pushing."); MelonPreferences.Save(); HarmonyInstance.PatchAll(); Features.Anticheat.GenericServerRpcRateLimitPatch.ApplyPatches(HarmonyInstance); + Features.Anticheat.PlayerScalePacketClamp.Apply(HarmonyInstance); + Features.Anticheat.SledPushBlockPatch.Apply(HarmonyInstance); + + MelonCoroutines.Start(PlayerCountLogLoop()); + } + + // Runs after all gameplay Updates each frame; re-asserts player scale so a size cheat can't stick. + public override void OnLateUpdate() + { + if (EnforcePlayerScale) + Features.Anticheat.PlayerScaleEnforcer.Tick(); + } + + // Number of connected non-host players (excludes the host's own clientHost connection 32767). + private int RemotePlayerCount => players.Count(pl => pl != null && pl.ConnectionID != 32767); + + // Periodically logs the connected player count (and names) while hosting, as a server heartbeat. + private IEnumerator PlayerCountLogLoop() + { + while (true) + { + yield return new WaitForSecondsRealtime(60f); + if (!isHost) + continue; + int n = RemotePlayerCount; + string list = n > 0 + ? " [" + string.Join(", ", players + .Where(pl => pl != null && pl.ConnectionID != 32767) + .Select(pl => Patches.ChatSystem.StripRichText(string.IsNullOrWhiteSpace(pl.Username) ? "?" : pl.Username))) + "]" + : ""; + MelonLogger.Msg($"[LobbyKit] Players online: {n}{list}"); + } } public override void OnSceneWasLoaded(int buildIndex, string sceneName) @@ -157,6 +195,77 @@ private static void Postfix(PlayerReferenceManager __instance, int index, Player Instance.PlayerLeftGame(removedItem); } } + + // ───────────────────────── Dedicated / headless server support ───────────────────────── + // PlayerJoinPatch / PlayerLeavePatch above hook PlayerReferenceManager.OnPlayerReferenceAdded / + // OnPlayerReferenceRemoved. Those game methods only run when the SyncList + // sync_PlayerReferences.OnChange callback is subscribed — which the game does ONLY in + // PlayerReferenceManager.OnStartClient. An avatar-less headless host never wires that up, so on it + // those hooks never fire and MOTD / join / leave go silent. We drive the SAME PlayerJoinedGame / + // PlayerLeftGame off the server-authoritative Server_AddPlayerReference / Server_RemovePlayerReference + // instead. + // + // GATE = Application.isBatchMode. IMPORTANT: a headless host is NOT FishNet "server-only" — it is + // booted through the normal HOST path, so it is a clientHost: IsServerStarted AND IsClientStarted + // are BOTH true (verified live), hence IsServerOnlyStarted == false. It just has no local avatar. + // So IsServerOnlyStarted is the wrong discriminator here; the reliable "this is a dedicated/headless + // server" signal is Application.isBatchMode (the server is always launched -batchmode -nographics, + // and SledHeadless gates its whole headless mode on it). A normal graphical client-host has + // isBatchMode == false and keeps using the OnPlayerReferenceAdded path, so the two paths never + // collide — and LobbyKit's own isNewConnection / MotdRecipients / wasTrackedPlayer dedup makes any + // overlap idempotent regardless. We call PlayerJoinedGame / PlayerLeftGame directly rather than + // re-running the game's OnPlayerReferenceAdded, whose tail (WarmCommunicationPolicy, EOS host-only + // state) NREs on an avatar-less host. + [HarmonyPatch(typeof(PlayerReferenceManager), "Server_AddPlayerReference")] + public static class DedicatedServerJoinPatch + { + private static void Postfix(PlayerReferenceManager __instance, int connectionId) + { + if (Instance == null || !Application.isBatchMode) + return; + if (connectionId == 32767) + return; // the host's own dict-only registration, not a joining player + + isHost = true; // a dedicated server IS the host; no local PlayerReference will set this + PlayerReference joined = FindByConnectionId(__instance, connectionId); + if (joined != null) + Instance.PlayerJoinedGame(joined); + } + } + + [HarmonyPatch(typeof(PlayerReferenceManager), "Server_RemovePlayerReference")] + public static class DedicatedServerLeavePatch + { + // Prefix: the original removes the reference from sync_PlayerReferences in its own body, so we + // must resolve it BEFORE the original runs. + private static void Prefix(PlayerReferenceManager __instance, int playerNetId) + { + if (Instance == null || !Application.isBatchMode) + return; + if (playerNetId == 32767) + return; + + isHost = true; + PlayerReference leaving = FindByConnectionId(__instance, playerNetId); + if (leaving != null) + Instance.PlayerLeftGame(leaving); + } + } + + private static PlayerReference FindByConnectionId(PlayerReferenceManager prm, int connectionId) + { + var refs = prm.GetPlayerReferences(); + if (refs == null) + return null; + for (int i = 0; i < refs.Count; i++) + { + var r = refs[i]; + if (r != null && r.ConnectionID == connectionId) + return r; + } + return null; + } + public void PlayerJoinedGame(PlayerReference p) { if (p == null) @@ -193,10 +302,15 @@ public void PlayerJoinedGame(PlayerReference p) Patches.SilentCrashDetectionPatches.StartPolling(); } } - else if (isHost && isNewConnection && ShowJoinMessages) + else if (isHost && isNewConnection) { - string username = Patches.ChatSystem.AutoCloseTmpRichText(string.IsNullOrWhiteSpace(p.Username) ? "A player" : p.Username); - Patches.ChatSystem.BroadcastSystemMessage($"<#FA0>{username} joined."); + if (ShowJoinMessages) + { + string username = Patches.ChatSystem.AutoCloseTmpRichText(string.IsNullOrWhiteSpace(p.Username) ? "A player" : p.Username); + Patches.ChatSystem.BroadcastSystemMessage($"<#FA0>{username} joined."); + } + string logName = Patches.ChatSystem.StripRichText(string.IsNullOrWhiteSpace(p.Username) ? "A player" : p.Username); + MelonLogger.Msg($"[LobbyKit] + {logName} joined (conn {p.ConnectionID}) — {RemotePlayerCount} online."); } if (isHost) @@ -223,10 +337,19 @@ public void PlayerLeftGame(PlayerReference removedPlayer) if (isHost && wasTrackedPlayer && !isLocalPlayer) { string username = Patches.ChatSystem.AutoCloseTmpRichText(string.IsNullOrWhiteSpace(removedPlayer.Username) ? "A player" : removedPlayer.Username); + string logName = Patches.ChatSystem.StripRichText(string.IsNullOrWhiteSpace(removedPlayer.Username) ? "A player" : removedPlayer.Username); + int remaining = RemotePlayerCount - 1; // leaver still counted until RemoveAll below if (Features.Anticheat.KickAnnouncer.TryConsume(removedPlayer.ConnectionID, out string kickReason)) + { Patches.ChatSystem.BroadcastSystemMessage($"<#F44>{username} {kickReason}."); - else if (ShowLeaveMessages) - Patches.ChatSystem.BroadcastSystemMessage($"<#FA0>{username} left."); + MelonLogger.Msg($"[LobbyKit] - {logName} {kickReason} (conn {removedPlayer.ConnectionID}) — {remaining} online."); + } + else + { + if (ShowLeaveMessages) + Patches.ChatSystem.BroadcastSystemMessage($"<#FA0>{username} left."); + MelonLogger.Msg($"[LobbyKit] - {logName} left (conn {removedPlayer.ConnectionID}) — {remaining} online."); + } } players.RemoveAll(player => player == null || player.ConnectionID == removedPlayer.ConnectionID); @@ -235,6 +358,7 @@ public void PlayerLeftGame(PlayerReference removedPlayer) _playerJoinTimesByProductId.Remove(removedPlayer.ProductUserId); Patches.ChatSystem.ForgetMotdRecipient(removedPlayer.ConnectionID); Patches.ChatSystem.ForgetTeleportRequests(removedPlayer.ConnectionID); + Features.Anticheat.PlayerSizeRegistry.Remove(removedPlayer.ConnectionID); if (localPlayer != null && localPlayer.ConnectionID == removedPlayer.ConnectionID) { diff --git a/LobbyKit/VerboseLog.cs b/LobbyKit/VerboseLog.cs new file mode 100644 index 0000000..b77ff4a --- /dev/null +++ b/LobbyKit/VerboseLog.cs @@ -0,0 +1,55 @@ +using System; +using System.IO; +using MelonLoader; +using MelonLoader.Utils; +using UnityEngine; + +namespace LobbyKit +{ + // Routes high-volume debug/telemetry lines (e.g. [RPC] anticheat traffic, [CrashDetect] state + // dumps) so they don't flood the live console on a headless server. + // + // - Headless server (-batchmode): write ONLY to a dedicated file, UserData/LobbyKit-verbose.log, + // keeping the MelonLoader console/Latest.log clean for the operator. + // - Normal client / graphical host: behave exactly like MelonLogger.Msg (console as before). + // + // Genuine warnings/errors are NOT routed through here — call MelonLogger.Warning/Error directly so + // they always reach the console regardless of headless mode. + internal static class VerboseLog + { + private static readonly object _lock = new object(); + private static StreamWriter _writer; + private static bool _failed; + + private static string FilePath => + Path.Combine(MelonEnvironment.UserDataDirectory, "LobbyKit-verbose.log"); + + public static void Msg(string message) + { + // Graphical host / client: unchanged behaviour — straight to the MelonLoader console. + if (!Application.isBatchMode) + { + MelonLogger.Msg(message); + return; + } + + // Headless server: file only. + if (_failed) + return; + try + { + lock (_lock) + { + if (_writer == null) + _writer = new StreamWriter(FilePath, append: true) { AutoFlush = true }; + _writer.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] {message}"); + } + } + catch (Exception ex) + { + _failed = true; // never let logging throw or spam; report once and fall back to silence + MelonLogger.Warning($"[LobbyKit] VerboseLog file disabled ({ex.GetType().Name}: {ex.Message}); suppressing verbose output."); + } + } + } +} From f0cf78f0ff5862b800cdf3d10413f4bd9bb62e44 Mon Sep 17 00:00:00 2001 From: rdavis Date: Sat, 6 Jun 2026 03:07:56 -0500 Subject: [PATCH 15/23] Fixed size not applying on player join --- .../Anticheat/PlayerScalePacketClamp.cs | 47 +++++++++++++++++-- .../Features/Anticheat/PlayerSizeRegistry.cs | 2 + LobbyKit/LobbyKitCore.cs | 17 +++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs b/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs index 46f8e15..295e04e 100644 --- a/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs +++ b/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs @@ -282,15 +282,15 @@ public static void ApplySize(PlayerControl pc, float size) if (nob == null || nt == null) return; var conn = nob.Owner; - byte[] packet = BuildPositionScalePacket(pc.transform.position, size); - // Everyone except the owner: relay the transform (the owner's reader skips this RPC for itself). - try { nt.RpcWriter___ObserversUpdateClientAuthoritativeTransform___2713644489(ToSeg(packet), Il2CppFishNet.Transporting.Channel.Reliable); } - catch (Exception ex) { MelonLogger.Warning($"[LobbyKit] !size observer relay failed: {ex.GetType().Name}: {ex.Message}"); } + PushSizeToObservers(pc, size); // The owner: forge them non-owner so MoveToTarget applies it, deliver the goal, restore ownership. if (conn != null && conn.IsValid) + { + byte[] packet = BuildPositionScalePacket(pc.transform.position, size); MelonCoroutines.Start(PushSizeToOwner(nob, nt, conn, packet)); + } } catch (Exception ex) { @@ -298,6 +298,45 @@ public static void ApplySize(PlayerControl pc, float size) } } + // Relays a player's size to all CURRENT observers (the owner skips this RPC for itself). Late joiners + // spawn the player from the server's cached transform and never received the original ObserversUpdate, + // so RepushAllSizes() must be re-run when someone joins (see LobbyKitCore.PlayerJoinedGame). + public static void PushSizeToObservers(PlayerControl pc, float size) + { + try + { + if (pc == null) return; + var nt = pc.GetComponent(); + if (nt == null) return; + byte[] packet = BuildPositionScalePacket(pc.transform.position, size); + nt.RpcWriter___ObserversUpdateClientAuthoritativeTransform___2713644489(ToSeg(packet), Il2CppFishNet.Transporting.Channel.Reliable); + } + catch (Exception ex) + { + MelonLogger.Warning($"[LobbyKit] !size observer relay failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + // Re-broadcasts every non-default registered size to all observers. Called (delayed + retried) when a + // player joins, so late joiners pick up sizes that were applied before they arrived. Re-applying to + // players who already have the size is harmless. No-op when nobody has a custom size. + public static void RepushAllSizes() + { + try + { + if (!InstanceFinder.IsServerStarted || !PlayerSizeRegistry.AnySizes) return; + foreach (var pc in UnityEngine.Object.FindObjectsOfType()) + { + int owner; + try { owner = pc.OwnerId; } catch { continue; } + float size = PlayerSizeRegistry.GetSize(owner); + if (Math.Abs(size - PlayerSizeRegistry.DefaultSize) <= Tolerance) continue; + PushSizeToObservers(pc, size); + } + } + catch { } + } + private static IEnumerator PushSizeToOwner(Il2CppFishNet.Object.NetworkObject nob, NetworkTransform nt, Il2CppFishNet.Connection.NetworkConnection conn, byte[] packet) { diff --git a/LobbyKit/Features/Anticheat/PlayerSizeRegistry.cs b/LobbyKit/Features/Anticheat/PlayerSizeRegistry.cs index 7adfe65..ac7d346 100644 --- a/LobbyKit/Features/Anticheat/PlayerSizeRegistry.cs +++ b/LobbyKit/Features/Anticheat/PlayerSizeRegistry.cs @@ -19,6 +19,8 @@ internal static class PlayerSizeRegistry private static readonly Dictionary _sizes = new(); + public static bool AnySizes => _sizes.Count > 0; + public static float GetSize(int connectionId) => _sizes.TryGetValue(connectionId, out float s) ? s : DefaultSize; diff --git a/LobbyKit/LobbyKitCore.cs b/LobbyKit/LobbyKitCore.cs index 03b7a5c..b164aa6 100644 --- a/LobbyKit/LobbyKitCore.cs +++ b/LobbyKit/LobbyKitCore.cs @@ -311,6 +311,11 @@ public void PlayerJoinedGame(PlayerReference p) } string logName = Patches.ChatSystem.StripRichText(string.IsNullOrWhiteSpace(p.Username) ? "A player" : p.Username); MelonLogger.Msg($"[LobbyKit] + {logName} joined (conn {p.ConnectionID}) — {RemotePlayerCount} online."); + + // A late joiner spawns existing players from the server's cached transform and missed any earlier + // !size ObserversUpdate, so re-broadcast registered sizes across their load window. + if (Features.Anticheat.PlayerSizeRegistry.AnySizes) + MelonCoroutines.Start(RepushSizesForJoin()); } if (isHost) @@ -728,6 +733,18 @@ public static void SetAutoRestartOnCrash(bool value) MelonPreferences.Save(); } + // Re-broadcasts registered !size values a few times across a joiner's load window so they pick up sizes + // applied before they arrived (an ObserversUpdate only reaches clients already observing the player). + private static IEnumerator RepushSizesForJoin() + { + float[] increments = { 0.0f, 0.1f, 2f, 2f, 3f, 5f }; // re-push at ~2, 4, 7, 12s after join + foreach (float inc in increments) + { + yield return new WaitForSecondsRealtime(inc); + Features.Anticheat.PlayerScalePacketClamp.RepushAllSizes(); + } + } + private IEnumerator LoadReferences() { while ((Object)(object)_playerReferenceManager == (Object)null) From fc9aee709d69d5679ddd5ec7ec1531d7745ab28d Mon Sep 17 00:00:00 2001 From: rdavis Date: Sat, 6 Jun 2026 15:52:50 -0500 Subject: [PATCH 16/23] migrate settings --- LobbyKit/Features/Lobby/AutoRestartPatches.cs | 40 ++++---------- LobbyKit/LobbyKitCore.cs | 53 ++++++++++++++++--- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/LobbyKit/Features/Lobby/AutoRestartPatches.cs b/LobbyKit/Features/Lobby/AutoRestartPatches.cs index 8713bd4..3a0a77f 100644 --- a/LobbyKit/Features/Lobby/AutoRestartPatches.cs +++ b/LobbyKit/Features/Lobby/AutoRestartPatches.cs @@ -11,12 +11,6 @@ namespace LobbyKit.Patches [HarmonyPatch] internal static class AutoRestartPatches { - private static bool _lastProximityChatEnabled = false; - private static string _lastPlatform = string.Empty; - private static string _lastRegion = string.Empty; - private static bool _lastCrossplayEnabled = true; - private static bool _hasLastLobbyParams = false; - private static int _pendingAutoRestartAttempts = 0; private const int MaxAutoRestartAttempts = 3; private const int AutoRestartRetryDelayFrames = 300; // 5 seconds @@ -25,21 +19,6 @@ internal static class AutoRestartPatches private static bool _createCompleteFired = false; private static bool _pendingFishNetStop = false; - [HarmonyPatch(typeof(LobbyManager), "CreateLobby")] - [HarmonyPrefix] - private static void LobbyManager_CreateLobby_Prefix( - ref bool proximityChatEnabled, - ref string platform, - ref string region, - ref bool crossplayEnabled) - { - _lastProximityChatEnabled = proximityChatEnabled; - _lastPlatform = platform ?? string.Empty; - _lastRegion = region ?? string.Empty; - _lastCrossplayEnabled = crossplayEnabled; - _hasLastLobbyParams = true; - } - [HarmonyPatch(typeof(UiReferenceController), "LeaveGame")] [HarmonyPrefix] private static void UiReferenceController_LeaveGame_Prefix() @@ -59,12 +38,6 @@ private static void UiReferenceController_ReturnToMainMenu_Prefix(int messageTyp LobbyKitCore.WasHosting = false; LobbyKitCore.Instance?.ResetLobbyTrackingState("ReturnToMainMenu"); - if (!_hasLastLobbyParams) - { - MelonLogger.Warning("[LobbyKit] Auto-restart: no prior lobby params recorded, skipping restart."); - return; - } - _pendingAutoRestartAttempts = MaxAutoRestartAttempts; MelonCoroutines.Start(AutoRestartLobbyCoroutine()); } @@ -217,18 +190,23 @@ private static void InvokeCreateLobby(LobbyManager lobbyManager) ? LobbyKitCore.ServerName : $"{GameInfo.Instance?.PlayerName ?? "Host"}'s Lobby"; + // Platform/region aren't LobbyKit settings, so derive them the way the game/Headless do; a + // re-hosting client already has its region resolved. Crossplay is non-functional in this game. + string region = RegionHandler.DefaultRegion; + try { region = PlayerPrefsManager.Instance?.playerSavedSettings?.PlayerRegion ?? region; } catch { } + MelonLogger.Msg("[LobbyKit] Auto-restart: creating lobby..."); lobbyManager.CreateLobby( lobbyName, LobbyKitCore.ServerCapacity, LobbyKitCore.IsPublicLobby, - _lastProximityChatEnabled, + !LobbyKitCore.IsTextChatOnly, // proximityChatEnabled; text-chat-only == proximity voice off LobbyKitCore.IsPasswordProtected, LobbyKitCore.LobbyPassword, LobbyKitCore.IsPeacefulMode, - _lastPlatform, - _lastRegion, - _lastCrossplayEnabled); + "PC", + region, + false); // crossplay is non-functional in this game } } } diff --git a/LobbyKit/LobbyKitCore.cs b/LobbyKit/LobbyKitCore.cs index b164aa6..7c9637d 100644 --- a/LobbyKit/LobbyKitCore.cs +++ b/LobbyKit/LobbyKitCore.cs @@ -23,6 +23,7 @@ public class LobbyKitCore : MelonMod public static bool WasHosting = false; private static MelonPreferences_Category _preferences; + private static MelonPreferences_Category _serverSettings; // shared with SledHeadless private static MelonPreferences_Entry _enableGuestBangCommands; private static MelonPreferences_Entry _serverName; private static MelonPreferences_Entry _serverCapacity; @@ -91,13 +92,27 @@ public override unsafe void OnInitializeMelon() _preferences = MelonPreferences.CreateCategory("LobbyKit", "LobbyKit"); _enableGuestBangCommands = _preferences.CreateEntry("EnableGuestBangCommands", true, "Enable Guest Bang Commands", "Allow non-host players to use custom bang chat commands like !tp."); - _serverName = _preferences.CreateEntry("ServerName", string.Empty, "Server Name", "Custom default lobby/server name. Leave empty to use '\'s Lobby'."); - _serverCapacity = _preferences.CreateEntry("ServerCapacity", 8, "Server Capacity", "Saved default value for the max players slider."); - _isPublicLobby = _preferences.CreateEntry("IsPublicLobby", true, "Public Lobby", "Saved default for public/private lobby."); - _isPasswordProtected = _preferences.CreateEntry("IsPasswordProtected", false, "Password Protected", "Saved default for password protection."); - _lobbyPassword = _preferences.CreateEntry("LobbyPassword", string.Empty, "Lobby Password", "Saved default lobby password."); - _isPeacefulMode = _preferences.CreateEntry("IsPeacefulMode", false, "Peaceful Mode", "Saved default for peaceful mode."); - _isTextChatOnly = _preferences.CreateEntry("IsTextChatOnly", false, "Text Chat Only", "Saved default for text-chat-only mode."); + + // Server config lives in its own shared category so LobbyKit and SledHeadless read one source of truth. + _serverSettings = MelonPreferences.CreateCategory("ServerSettings", "Server Settings"); + _serverName = _serverSettings.CreateEntry("ServerName", string.Empty, "Server Name", "Custom default lobby/server name. Leave empty to use '\'s Lobby'."); + _serverCapacity = _serverSettings.CreateEntry("ServerCapacity", 8, "Server Capacity", "Saved default value for the max players slider."); + _isPublicLobby = _serverSettings.CreateEntry("IsPublicLobby", true, "Public Lobby", "Saved default for public/private lobby."); + _isPasswordProtected = _serverSettings.CreateEntry("IsPasswordProtected", false, "Password Protected", "Saved default for password protection."); + _lobbyPassword = _serverSettings.CreateEntry("LobbyPassword", string.Empty, "Lobby Password", "Saved default lobby password."); + _isPeacefulMode = _serverSettings.CreateEntry("IsPeacefulMode", false, "Peaceful Mode", "Saved default for peaceful mode."); + _isTextChatOnly = _serverSettings.CreateEntry("IsTextChatOnly", false, "Text Chat Only", "Saved default for text-chat-only mode."); + + // One-time migration: pull any non-default value users saved under the old "LobbyKit" category into + // ServerSettings, then drop the stale key. No-ops once migrated (ServerSettings already non-default). + MigrateServerSetting(_preferences, "ServerName", _serverName, string.Empty); + MigrateServerSetting(_preferences, "ServerCapacity", _serverCapacity, 8); + MigrateServerSetting(_preferences, "IsPublicLobby", _isPublicLobby, true); + MigrateServerSetting(_preferences, "IsPasswordProtected", _isPasswordProtected, false); + MigrateServerSetting(_preferences, "LobbyPassword", _lobbyPassword, string.Empty); + MigrateServerSetting(_preferences, "IsPeacefulMode", _isPeacefulMode, false); + MigrateServerSetting(_preferences, "IsTextChatOnly", _isTextChatOnly, false); + // Disabled for now. Leave the field/property/setter in place so this can be re-enabled without rewiring callers. // _searchMinPlayers = _preferences.CreateEntry("SearchMinPlayers", 1, "Search Min Players", "Minimum players filter for lobby search."); _searchMaxPlayers = _preferences.CreateEntry("SearchMaxPlayers", 0, "Search Max Players", "Maximum players filter for lobby search. 0 == Any."); @@ -126,6 +141,30 @@ public override unsafe void OnInitializeMelon() MelonCoroutines.Start(PlayerCountLogLoop()); } + // Copies a value users previously saved under an old category into its new ServerSettings entry, then + // removes the stale key. Only overwrites when the ServerSettings entry is still at its default, so an + // already-migrated or explicitly-set value is never clobbered (and it no-ops on subsequent launches). + private static void MigrateServerSetting(MelonPreferences_Category oldCategory, string key, MelonPreferences_Entry target, T defaultValue) + { + try + { + if (oldCategory == null || target == null) + return; + + MelonPreferences_Entry old = oldCategory.CreateEntry(key, defaultValue); // binds to the saved value if one exists + bool oldIsDefault = EqualityComparer.Default.Equals(old.Value, defaultValue); + bool targetIsDefault = EqualityComparer.Default.Equals(target.Value, defaultValue); + if (targetIsDefault && !oldIsDefault) + target.Value = old.Value; + + oldCategory.DeleteEntry(key); // drop the stale key from the old category + } + catch (Exception ex) + { + MelonLogger.Warning($"[LobbyKit] ServerSettings migrate '{key}' failed: {ex.GetType().Name}: {ex.Message}"); + } + } + // Runs after all gameplay Updates each frame; re-asserts player scale so a size cheat can't stick. public override void OnLateUpdate() { From e2c012c86ee61c217f7cbf1e85df04ad48083f51 Mon Sep 17 00:00:00 2001 From: rdavis Date: Sat, 6 Jun 2026 22:42:04 -0500 Subject: [PATCH 17/23] Split commands into own files and add perms --- LobbyKit/ChatSystem.cs | 264 ++++++++---------- .../Commands/ChatSystem.ModerationCommands.cs | 137 +++++++++ .../Commands/ChatSystem.TeleportCommands.cs | 140 ++++++++++ LobbyKit/Features/Permissions/PermLevel.cs | 40 +++ LobbyKit/Features/Permissions/Perms.cs | 218 +++++++++++++++ LobbyKit/LobbyKit.csproj | 7 + LobbyKit/LobbyKitCore.cs | 59 +++- 7 files changed, 716 insertions(+), 149 deletions(-) create mode 100644 LobbyKit/Features/Commands/ChatSystem.ModerationCommands.cs create mode 100644 LobbyKit/Features/Commands/ChatSystem.TeleportCommands.cs create mode 100644 LobbyKit/Features/Permissions/PermLevel.cs create mode 100644 LobbyKit/Features/Permissions/Perms.cs diff --git a/LobbyKit/ChatSystem.cs b/LobbyKit/ChatSystem.cs index f49e9f0..fd82e68 100644 --- a/LobbyKit/ChatSystem.cs +++ b/LobbyKit/ChatSystem.cs @@ -5,18 +5,22 @@ using Il2Cpp; using Il2CppFishNet; using Il2CppFishNet.Connection; +using Il2CppFishNet.Managing.Server; using Il2CppDissonance.Integrations.FishNet; using Il2Cpp_Scripts.Player; using Il2Cpp_Scripts.Systems.Chat; using MelonLoader; using LobbyKit.Features.Settings; +using LobbyKit.Features.Permissions; using UnityEngine; using System.Text.RegularExpressions; namespace LobbyKit.Patches { + // Command handlers live in partial-class files under Features/Commands/ (ChatSystem.*.cs); the framework + // (chat patches, dispatch, permissions, broadcast, confirmation, help) stays here. [HarmonyPatch] - public static class ChatSystem + public static partial class ChatSystem { private const int HostConnectionId = 32767; private const int PrivateReplySystemMessageType = 0; @@ -31,20 +35,20 @@ public CommandDefinition( ChatCommandHandler handler, string usage, string description, - bool hostCommand = false, + PermLevel minLevel = PermLevel.Everyone, bool hiddenFromHelp = false) { Handler = handler; Usage = usage; Description = description; - HostCommand = hostCommand; + MinLevel = minLevel; HiddenFromHelp = hiddenFromHelp; } public ChatCommandHandler Handler { get; } public string Usage { get; } public string Description { get; } - public bool HostCommand { get; } + public PermLevel MinLevel { get; } // minimum level to invoke the command at all public bool HiddenFromHelp { get; } } @@ -64,16 +68,16 @@ public CommandDefinition( OpenSettingsMenu, "!settings", "Open LobbyKit settings.", - hostCommand: true), + minLevel: PermLevel.Admin), [MotdCommand] = new CommandDefinition( HandleMotdCommand, "!motd [message]", - "Shows the message of the day. Host can pass a message to set it."), + "Show the message of the day. Admins can pass text to set it."), ["!bc"] = new CommandDefinition( HandleBangCommandsCommand, "!bc [on|off]", "Enable or disable guest bang commands.", - hostCommand: true), + minLevel: PermLevel.Admin), ["!tp"] = new CommandDefinition( HandleTpCommand, "!tp [name]", @@ -85,18 +89,60 @@ public CommandDefinition( ["!tpa"] = new CommandDefinition( HandleTpAcceptCommand, "!tpa", - "Accept a Teleport request."), + "Accept a Teleport request.", + hiddenFromHelp: true), ["!tpf"] = new CommandDefinition( HandleTpForceCommand, "!tpf [name]", "Force a player to Teleport to you.", - hostCommand: true), + minLevel: PermLevel.Mod), ["!size"] = new CommandDefinition( HandleSizeCommand, "!size [0.2-3.0]", - "Set your player size. !size 1 resets to normal.") + "Set your player size. !size 1 resets to normal."), + ["!kick"] = new CommandDefinition( + HandleKickCommand, + "!kick [name] [reason]", + "Kick a player.", + minLevel: PermLevel.Mod), + ["!ban"] = new CommandDefinition( + HandleBanCommand, + "!ban [name|puid] [reason]", + "Ban a player (use !unban to reverse).", + minLevel: PermLevel.Admin), + ["!unban"] = new CommandDefinition( + HandleUnbanCommand, + "!unban [name|puid]", + "Remove a ban by player name or PUID.", + minLevel: PermLevel.Admin), + ["!op"] = new CommandDefinition( + HandleOpCommand, + "!op [level] [name]", + "Set a player's level (everyone/mod/admin/owner). Requires confirmation.", + minLevel: PermLevel.Owner), + ["!level"] = new CommandDefinition( + HandleLevelCommand, + "!level [name]", + "Show your level, or another player's.", + minLevel: PermLevel.Mod), + ["!confirm"] = new CommandDefinition( + HandleConfirmCommand, + "!confirm", + "Confirm a pending action (kick, ban, op, etc.).", + hiddenFromHelp: true) }; + // Seeds the op-file with each command's default required level (without overwriting existing entries), + // so the JSON lists every command and the operator can edit its level. Set a level above Owner to + // disable a command. Called once at startup after Perms is loaded. + internal static void SeedCommandLevels() + { + var defaults = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in Commands) + defaults[entry.Key] = (int)entry.Value.MinLevel; + Perms.SeedCommandLevels(defaults); + } + private static void OpenSettingsMenu(PlayerControl playerControl, string args) { SettingsMenuController.Instance.Open(SettingsMenuView.Build, SettingsMenuView.HasRequiredTemplates); @@ -124,7 +170,7 @@ private static bool ChatManager_ProcessChatInput_Prefix(ChatManager __instance) bool handledCommand = TryHandleCommand(message, localPlayer.ConnectionID, isHostLocal: true); if (!handledCommand) - BroadcastMessage(0, message, AutoCloseTmpRichText(localPlayer.Username), showAboveUser: localPlayer.ConnectionID); + BroadcastMessage(0, message, AutoCloseTmpRichText(ApplyLevelPrefix(localPlayer.ConnectionID, localPlayer.Username)), showAboveUser: localPlayer.ConnectionID); chatBox.inputFieldValue = string.Empty; chatBox.ClearInputBox(); @@ -236,6 +282,7 @@ public static void ResetSessionState() TeleportRequests.Clear(); LastCommandBySource.Clear(); LastExplicitCommandBySource.Clear(); + PendingConfirmations.Clear(); Features.Anticheat.PlayerSizeRegistry.Clear(); } @@ -258,22 +305,30 @@ private static bool TryHandleCommand(string message, int connectionId, bool isHo if (parts.Length == 0 || !Commands.TryGetValue(parts[0], out CommandDefinition command)) return false; - bool isHost = connectionId == HostConnectionId; + string commandName = parts[0]; string args = parts.Length > 1 ? parts[1].Trim() : string.Empty; - bool isMotdCommand = parts[0].Equals(MotdCommand, StringComparison.OrdinalIgnoreCase); + bool isMotdCommand = commandName.Equals(MotdCommand, StringComparison.OrdinalIgnoreCase); + PermLevel level = Perms.GetLevel(connectionId); - if (isMotdCommand && !HasMotd() && (string.IsNullOrWhiteSpace(args) || !isHost)) + // Silently hide an unset MOTD from non-admins so !motd isn't noise on a server without one. + if (isMotdCommand && !HasMotd() && (string.IsNullOrWhiteSpace(args) || level < PermLevel.Admin)) return true; - if (!LobbyKitCore.EnableGuestBangCommands && !isHost) + // Required level: the op-file's per-command override, or the command's built-in default. + // A configured level above Owner (3) disables the command for everyone. + int requiredLevel = Perms.GetCommandLevel(commandName, (int)command.MinLevel); + if ((int)level < requiredLevel) { - BroadcastMessage(connectionId, "<#FA0>Commands are disabled on this server."); + BroadcastMessage(connectionId, requiredLevel > (int)PermLevel.Owner + ? "<#FA0>That command is disabled." + : "<#F00>You don't have permission for that command."); return true; } - if (command.HostCommand && !isHost) + // !bc gates GUEST (Everyone-level) command use only; mods/admins/owners bypass it entirely. + if (level == PermLevel.Everyone && !LobbyKitCore.EnableGuestBangCommands) { - BroadcastMessage(connectionId, "<#F00>Only the host can use that command."); + BroadcastMessage(connectionId, "<#FA0>Commands are disabled on this server."); return true; } @@ -395,7 +450,14 @@ private static void BroadcastPlayerMessage(int connectionId, string text, string ? player.Username : fallbackUsername; - BroadcastMessage(0, text, AutoCloseTmpRichText(username), showAboveUser: connectionId); + BroadcastMessage(0, text, AutoCloseTmpRichText(ApplyLevelPrefix(connectionId, username)), showAboveUser: connectionId); + } + + // Prepends the sender's configurable level chat-prefix (empty string disables it for that level). + private static string ApplyLevelPrefix(int connectionId, string username) + { + string prefix = LobbyKitCore.ChatPrefixFor(Perms.GetLevel(connectionId)); + return string.IsNullOrEmpty(prefix) ? username : prefix + username; } private static ChatMessage CreateSystemChatMessage(string text) @@ -486,7 +548,7 @@ private static void HandleHelpCommand(PlayerControl playerControl, string args) requested = "!" + requested; if (Commands.TryGetValue(requested, out CommandDefinition command) && CanShowInHelp(requested, command, playerControl)) - Reply(playerControl, $"{helpSizing}<#7FF>{FormatCommandUsage(command)} - {command.Description}"); + Reply(playerControl, $"{helpSizing}<#7FF>{FormatCommandUsage(requested, command)} - {command.Description}"); else Reply(playerControl, $"{helpSizing}<#FA0>Unknown command: {requested}"); @@ -497,7 +559,7 @@ private static void HandleHelpCommand(PlayerControl playerControl, string args) foreach (var entry in Commands) { if (CanShowInHelp(entry.Key, entry.Value, playerControl)) - Reply(playerControl, $"{helpSizing}<#7FF>{FormatCommandUsage(entry.Value)}"); + Reply(playerControl, $"{helpSizing}<#7FF>{FormatCommandUsage(entry.Key, entry.Value)}"); } } @@ -505,7 +567,7 @@ private static bool CanShowInHelp(string commandName, CommandDefinition command, { return !command.HiddenFromHelp && (!commandName.Equals(MotdCommand, StringComparison.OrdinalIgnoreCase) || HasMotd()) && - CanUseCommand(command, playerControl); + CanUseCommand(commandName, command, playerControl); } private static bool HasMotd() @@ -513,16 +575,24 @@ private static bool HasMotd() return !string.IsNullOrWhiteSpace(LobbyKitCore.MessageOfTheDay); } - private static bool CanUseCommand(CommandDefinition command, PlayerControl playerControl) + private static bool CanUseCommand(string commandName, CommandDefinition command, PlayerControl playerControl) { - return !command.HostCommand || playerControl.OwnerId == HostConnectionId; + if (playerControl == null) return false; + int required = Perms.GetCommandLevel(commandName, (int)command.MinLevel); + return (int)Perms.GetLevel(playerControl.OwnerId) >= required; } - private static string FormatCommandUsage(CommandDefinition command) + private static string FormatCommandUsage(string commandName, CommandDefinition command) { - return command.HostCommand ? $"{command.Usage} (host)" : command.Usage; + int required = Perms.GetCommandLevel(commandName, (int)command.MinLevel); + return required > (int)PermLevel.Everyone + ? $"{command.Usage} ({LevelLabel(required)})" + : command.Usage; } + private static string LevelLabel(int level) + => level > (int)PermLevel.Owner ? "Disabled" : ((PermLevel)level).DisplayName(); + private static void HandleBangCommandsCommand(PlayerControl playerControl, string args) { if (string.IsNullOrWhiteSpace(args)) @@ -552,132 +622,22 @@ private static void HandleBangCommandsCommand(PlayerControl playerControl, strin private static void HandleMotdCommand(PlayerControl playerControl, string args) { - if (string.IsNullOrWhiteSpace(args) || playerControl.OwnerId != HostConnectionId) + if (string.IsNullOrWhiteSpace(args)) // read — anyone { Reply(playerControl, $"<#7FF>MOTD: {LobbyKitCore.MessageOfTheDay}"); return; } - LobbyKitCore.SetMessageOfTheDay(args.Trim()); - Reply(playerControl, $"<#FF0>MOTD set: {LobbyKitCore.MessageOfTheDay}"); - } - - private static void HandleTpCommand(PlayerControl playerControl, string args) - { - if (string.IsNullOrWhiteSpace(args)) - { - Reply(playerControl, "<#F00>Usage: !tp "); - return; - } - - string targetName = args.Trim(); - PlayerReference target = Utils.FindPlayerByName(targetName, sanitized: true); - if (target == null) - { - Reply(playerControl, $"<#FA0>Player not found: {targetName}"); - return; - } - - string targetUsername = AutoCloseTmpRichText(target.Username); - if (target.PlayerControl == null) - { - return; - } - - TeleportPlayerTo(playerControl, target.PlayerControl); - Reply(playerControl, $"<#FF0>TP'd to {targetUsername}"); - } - - private static void HandleTpMeCommand(PlayerControl playerControl, string args) - { - if (string.IsNullOrWhiteSpace(args)) - { - Reply(playerControl, "<#F00>Usage: !tpme "); - return; - } - - PlayerReference requester = Utils.FindPlayerFromConnectionId(playerControl.OwnerId); - if (requester == null) + if (!Perms.Has(playerControl, PermLevel.Admin)) // write — admin only { - Reply(playerControl, "<#F00>Command failed: player is not ready."); + Reply(playerControl, "<#F00>Only admins can set the MOTD."); return; } - PlayerReference target = Utils.FindPlayerByName(args.Trim(), sanitized: true); - if (target == null) - { - Reply(playerControl, $"<#FA0>Player not found: {args.Trim()}"); - return; - } - - if (target.ConnectionID == requester.ConnectionID) - { - Reply(playerControl, "<#FA0>You cannot request yourself."); - return; - } - - string requesterUsername = AutoCloseTmpRichText(requester.Username); - string targetUsername = AutoCloseTmpRichText(target.Username); - TeleportRequests[target.ConnectionID] = requester.ConnectionID; - BroadcastMessage(target.ConnectionID, $"<#7FF>{requesterUsername} wants you to TP to them. Type !tpa to accept."); - Reply(playerControl, $"<#FF0>TP request sent to {targetUsername}."); - } - - private static void HandleTpAcceptCommand(PlayerControl playerControl, string args) - { - int targetConnectionId = playerControl.OwnerId; - if (!TeleportRequests.TryGetValue(targetConnectionId, out int requesterConnectionId)) - { - Reply(playerControl, "<#FA0>You have no pending TP request."); - return; - } - - TeleportRequests.Remove(targetConnectionId); - - PlayerReference requester = Utils.FindPlayerFromConnectionId(requesterConnectionId); - if (requester?.PlayerControl == null) - { - return; - } - - string requesterUsername = AutoCloseTmpRichText(requester.Username); - TeleportPlayerTo(playerControl, requester.PlayerControl); - Reply(playerControl, $"<#FF0>TP'd to {requesterUsername}."); - string accepterName = AutoCloseTmpRichText(Utils.FindPlayerFromConnectionId(targetConnectionId)?.Username ?? "A player"); - BroadcastMessage(requester.ConnectionID, $"<#FF0>{accepterName} accepted your TP request."); + LobbyKitCore.SetMessageOfTheDay(args.Trim()); + Reply(playerControl, $"<#FF0>MOTD set: {LobbyKitCore.MessageOfTheDay}"); } - private static void HandleTpForceCommand(PlayerControl playerControl, string args) - { - if (string.IsNullOrWhiteSpace(args)) - { - Reply(playerControl, "<#F00>Usage: !tpf "); - return; - } - - PlayerReference target = Utils.FindPlayerByName(args.Trim(), sanitized: true); - if (target == null) - { - Reply(playerControl, $"<#FA0>Player not found: {args.Trim()}"); - return; - } - - string targetUsername = AutoCloseTmpRichText(target.Username); - if (target.PlayerControl == null) - { - return; - } - - if (target.ConnectionID == playerControl.OwnerId) - { - Reply(playerControl, "<#FA0>You cannot force TP yourself."); - return; - } - - TeleportPlayerTo(target.PlayerControl, playerControl); - Reply(playerControl, $"<#FF0>Forced {targetUsername} to TP to you."); - BroadcastMessage(target.ConnectionID, $"<#FF0>The host TP'd you to them."); - } private static void HandleSizeCommand(PlayerControl playerControl, string args) { @@ -704,6 +664,28 @@ private static void HandleSizeCommand(PlayerControl playerControl, string args) Reply(playerControl, $"<#FF0>Size set to {size:0.##}."); } + // ── Moderation & permissions ───────────────────────────────────────────────── + // One pending confirmation per requester (by connection id). Destructive commands stage an action and + // the requester runs !confirm to execute it. + private static readonly Dictionary PendingConfirmations = new(); + + internal static void RequestConfirmation(PlayerControl playerControl, string description, Action action) + { + PendingConfirmations[playerControl.OwnerId] = (action, description); + Reply(playerControl, $"<#FA0>{description}? Type <#FFF>!confirm to proceed (or ignore to cancel)."); + } + + + private static (string First, string Remainder) SplitFirstWord(string args) + { + if (string.IsNullOrWhiteSpace(args)) return (string.Empty, string.Empty); + string[] p = args.Trim().Split(new[] { ' ' }, 2, StringSplitOptions.RemoveEmptyEntries); + return (p[0], p.Length > 1 ? p[1].Trim() : string.Empty); + } + + private static bool LooksLikePuid(string value) + => !string.IsNullOrWhiteSpace(value) && value.Length >= 16 && value.IndexOf(' ') < 0; + private static void TeleportPlayerTo(PlayerControl player, PlayerControl destination) { player.RpcWriter___RpcResetPosition___3848837105( diff --git a/LobbyKit/Features/Commands/ChatSystem.ModerationCommands.cs b/LobbyKit/Features/Commands/ChatSystem.ModerationCommands.cs new file mode 100644 index 0000000..446fba0 --- /dev/null +++ b/LobbyKit/Features/Commands/ChatSystem.ModerationCommands.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using HarmonyLib; +using Il2Cpp; +using Il2CppFishNet; +using Il2CppFishNet.Connection; +using Il2CppFishNet.Managing.Server; +using Il2CppDissonance.Integrations.FishNet; +using Il2Cpp_Scripts.Player; +using Il2Cpp_Scripts.Systems.Chat; +using MelonLoader; +using LobbyKit.Features.Settings; +using LobbyKit.Features.Permissions; +using UnityEngine; +using System.Text.RegularExpressions; + +namespace LobbyKit.Patches +{ + // Moderation & permission commands: !confirm / !kick / !ban / !unban / !op / !level. + public static partial class ChatSystem + { + private static void HandleConfirmCommand(PlayerControl playerControl, string args) + { + if (!PendingConfirmations.TryGetValue(playerControl.OwnerId, out var pending)) + { + Reply(playerControl, "<#FA0>Nothing to confirm."); + return; + } + PendingConfirmations.Remove(playerControl.OwnerId); + try { pending.Action(); } + catch (Exception ex) { Reply(playerControl, $"<#F00>Action failed: {ex.Message}"); } + } + + private static void HandleKickCommand(PlayerControl playerControl, string args) + { + if (string.IsNullOrWhiteSpace(args)) { Reply(playerControl, "<#F00>Usage: !kick [reason]"); return; } + + (string name, string reason) = SplitFirstWord(args); + PlayerReference target = Utils.FindPlayerByName(name, sanitized: true); + if (target == null) { Reply(playerControl, $"<#FA0>Player not found: {name}"); return; } + if (target.ConnectionID == playerControl.OwnerId) { Reply(playerControl, "<#FA0>You can't kick yourself."); return; } + if (Perms.GetLevel(target.ConnectionID) >= Perms.GetLevel(playerControl.OwnerId)) + { Reply(playerControl, "<#FA0>You can't kick someone of equal or higher level."); return; } + + string display = AutoCloseTmpRichText(target.Username); + int conn = target.ConnectionID; + string reasonClean = string.IsNullOrWhiteSpace(reason) ? null : StripRichText(reason); + RequestConfirmation(playerControl, $"Kick {display}{(reasonClean != null ? $" ({reasonClean})" : string.Empty)}", () => + { + Features.Anticheat.KickAnnouncer.Enqueue(conn, reasonClean == null ? "was kicked" : $"was kicked: {reasonClean}"); + InstanceFinder.ServerManager?.Kick(conn, KickReason.Unset); + Reply(playerControl, $"<#FF0>Kicked {display}."); + }); + } + + private static void HandleBanCommand(PlayerControl playerControl, string args) + { + if (string.IsNullOrWhiteSpace(args)) { Reply(playerControl, "<#F00>Usage: !ban [reason]"); return; } + + (string nameOrPuid, string reason) = SplitFirstWord(args); + string puid = null; int conn = -1; string targetName = null; string display = nameOrPuid; + + PlayerReference target = Utils.FindPlayerByName(nameOrPuid, sanitized: true); + if (target != null) { puid = target.ProductUserId; conn = target.ConnectionID; targetName = StripRichText(target.Username); display = AutoCloseTmpRichText(target.Username); } + else if (LooksLikePuid(nameOrPuid)) { puid = nameOrPuid; } + + if (string.IsNullOrWhiteSpace(puid)) { Reply(playerControl, $"<#FA0>Player not found: {nameOrPuid}. Pass an online name or a PUID."); return; } + if (conn == playerControl.OwnerId) { Reply(playerControl, "<#FA0>You can't ban yourself."); return; } + if (conn >= 0 && Perms.GetLevel(conn) >= Perms.GetLevel(playerControl.OwnerId)) + { Reply(playerControl, "<#FA0>You can't ban someone of equal or higher level."); return; } + + string reasonClean = string.IsNullOrWhiteSpace(reason) ? null : StripRichText(reason); + string p = puid; int c = conn; string n = targetName; + RequestConfirmation(playerControl, $"Ban {display}{(reasonClean != null ? $" ({reasonClean})" : string.Empty)}", () => + { + Perms.Ban(p, reasonClean, n); + if (c >= 0) + { + Features.Anticheat.KickAnnouncer.Enqueue(c, reasonClean == null ? "was banned" : $"was banned: {reasonClean}"); + try { InstanceFinder.ServerManager?.Kick(c, KickReason.Unset); } catch { } + } + Reply(playerControl, $"<#FF0>Banned {display} <#888>({p})"); + }); + } + + private static void HandleUnbanCommand(PlayerControl playerControl, string args) + { + string query = args?.Trim(); + if (string.IsNullOrWhiteSpace(query)) { Reply(playerControl, "<#F00>Usage: !unban "); return; } + + // A banned PUID matches directly; otherwise resolve by stored player name. + string puid = (LooksLikePuid(query) && Perms.IsBanned(query)) ? query : Perms.FindBannedPuidByName(query); + if (string.IsNullOrWhiteSpace(puid)) { Reply(playerControl, $"<#FA0>No ban found for: {query}"); return; } + + string display = AutoCloseTmpRichText(Perms.GetName(puid) ?? puid); + string p = puid; + RequestConfirmation(playerControl, $"Unban {display}", () => + Reply(playerControl, Perms.Unban(p) ? $"<#FF0>Unbanned {display}." : $"<#FA0>No ban found for {display}.")); + } + + private static void HandleOpCommand(PlayerControl playerControl, string args) + { + (string levelStr, string name) = SplitFirstWord(args); + if (string.IsNullOrWhiteSpace(levelStr) || string.IsNullOrWhiteSpace(name) || !PermLevelExtensions.TryParse(levelStr, out PermLevel level)) + { Reply(playerControl, "<#F00>Usage: !op "); return; } + + PlayerReference target = Utils.FindPlayerByName(name, sanitized: true); + if (target == null || string.IsNullOrWhiteSpace(target.ProductUserId)) + { Reply(playerControl, $"<#FA0>Player not found (must be online): {name}"); return; } + + string puid = target.ProductUserId; string targetName = StripRichText(target.Username); PermLevel lvl = level; + string display = AutoCloseTmpRichText(target.Username); + RequestConfirmation(playerControl, $"Set {display} to {lvl.DisplayName()}", () => + { + Perms.SetLevel(puid, lvl, targetName); + Reply(playerControl, $"<#FF0>{display} is now {lvl.DisplayName()}."); + }); + } + + private static void HandleLevelCommand(PlayerControl playerControl, string args) + { + if (string.IsNullOrWhiteSpace(args)) + { + Reply(playerControl, $"<#7FF>Your level: {Perms.GetLevel(playerControl.OwnerId).DisplayName()}"); + return; + } + PlayerReference target = Utils.FindPlayerByName(args.Trim(), sanitized: true); + if (target == null) + { + Reply(playerControl, $"<#FA0>Player not found: {args.Trim()}"); + return; + } + Reply(playerControl, $"<#7FF>{AutoCloseTmpRichText(target.Username)}: {Perms.GetLevel(target.ConnectionID).DisplayName()}"); + } + } +} diff --git a/LobbyKit/Features/Commands/ChatSystem.TeleportCommands.cs b/LobbyKit/Features/Commands/ChatSystem.TeleportCommands.cs new file mode 100644 index 0000000..85892fd --- /dev/null +++ b/LobbyKit/Features/Commands/ChatSystem.TeleportCommands.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using HarmonyLib; +using Il2Cpp; +using Il2CppFishNet; +using Il2CppFishNet.Connection; +using Il2CppFishNet.Managing.Server; +using Il2CppDissonance.Integrations.FishNet; +using Il2Cpp_Scripts.Player; +using Il2Cpp_Scripts.Systems.Chat; +using MelonLoader; +using LobbyKit.Features.Settings; +using LobbyKit.Features.Permissions; +using UnityEngine; +using System.Text.RegularExpressions; + +namespace LobbyKit.Patches +{ + // Teleport commands: !tp / !tpme / !tpa / !tpf. + public static partial class ChatSystem + { + private static void HandleTpCommand(PlayerControl playerControl, string args) + { + if (string.IsNullOrWhiteSpace(args)) + { + Reply(playerControl, "<#F00>Usage: !tp "); + return; + } + + string targetName = args.Trim(); + PlayerReference target = Utils.FindPlayerByName(targetName, sanitized: true); + if (target == null) + { + Reply(playerControl, $"<#FA0>Player not found: {targetName}"); + return; + } + + string targetUsername = AutoCloseTmpRichText(target.Username); + if (target.PlayerControl == null) + { + return; + } + + TeleportPlayerTo(playerControl, target.PlayerControl); + Reply(playerControl, $"<#FF0>TP'd to {targetUsername}"); + } + + private static void HandleTpMeCommand(PlayerControl playerControl, string args) + { + if (string.IsNullOrWhiteSpace(args)) + { + Reply(playerControl, "<#F00>Usage: !tpme "); + return; + } + + PlayerReference requester = Utils.FindPlayerFromConnectionId(playerControl.OwnerId); + if (requester == null) + { + Reply(playerControl, "<#F00>Command failed: player is not ready."); + return; + } + + PlayerReference target = Utils.FindPlayerByName(args.Trim(), sanitized: true); + if (target == null) + { + Reply(playerControl, $"<#FA0>Player not found: {args.Trim()}"); + return; + } + + if (target.ConnectionID == requester.ConnectionID) + { + Reply(playerControl, "<#FA0>You cannot request yourself."); + return; + } + + string requesterUsername = AutoCloseTmpRichText(requester.Username); + string targetUsername = AutoCloseTmpRichText(target.Username); + TeleportRequests[target.ConnectionID] = requester.ConnectionID; + BroadcastMessage(target.ConnectionID, $"<#7FF>{requesterUsername} wants you to TP to them. Type !tpa to accept."); + Reply(playerControl, $"<#FF0>TP request sent to {targetUsername}."); + } + + private static void HandleTpAcceptCommand(PlayerControl playerControl, string args) + { + int targetConnectionId = playerControl.OwnerId; + if (!TeleportRequests.TryGetValue(targetConnectionId, out int requesterConnectionId)) + { + Reply(playerControl, "<#FA0>You have no pending TP request."); + return; + } + + TeleportRequests.Remove(targetConnectionId); + + PlayerReference requester = Utils.FindPlayerFromConnectionId(requesterConnectionId); + if (requester?.PlayerControl == null) + { + return; + } + + string requesterUsername = AutoCloseTmpRichText(requester.Username); + TeleportPlayerTo(playerControl, requester.PlayerControl); + Reply(playerControl, $"<#FF0>TP'd to {requesterUsername}."); + string accepterName = AutoCloseTmpRichText(Utils.FindPlayerFromConnectionId(targetConnectionId)?.Username ?? "A player"); + BroadcastMessage(requester.ConnectionID, $"<#FF0>{accepterName} accepted your TP request."); + } + + private static void HandleTpForceCommand(PlayerControl playerControl, string args) + { + if (string.IsNullOrWhiteSpace(args)) + { + Reply(playerControl, "<#F00>Usage: !tpf "); + return; + } + + PlayerReference target = Utils.FindPlayerByName(args.Trim(), sanitized: true); + if (target == null) + { + Reply(playerControl, $"<#FA0>Player not found: {args.Trim()}"); + return; + } + + string targetUsername = AutoCloseTmpRichText(target.Username); + if (target.PlayerControl == null) + { + return; + } + + if (target.ConnectionID == playerControl.OwnerId) + { + Reply(playerControl, "<#FA0>You cannot force TP yourself."); + return; + } + + TeleportPlayerTo(target.PlayerControl, playerControl); + Reply(playerControl, $"<#FF0>Forced {targetUsername} to TP to you."); + BroadcastMessage(target.ConnectionID, $"<#FF0>The host TP'd you to them."); + } + } +} diff --git a/LobbyKit/Features/Permissions/PermLevel.cs b/LobbyKit/Features/Permissions/PermLevel.cs new file mode 100644 index 0000000..6753288 --- /dev/null +++ b/LobbyKit/Features/Permissions/PermLevel.cs @@ -0,0 +1,40 @@ +using System; + +namespace LobbyKit.Features.Permissions +{ + // Linear permission ranks. A command declares a MinLevel floor (the minimum to invoke it at all); handlers + // self-gate elevated branches (e.g. setting the MOTD vs reading it) with Perms.Has(player, PermLevel.X). + public enum PermLevel + { + Everyone = 0, + Mod = 1, + Admin = 2, + Owner = 3 + } + + public static class PermLevelExtensions + { + public static string DisplayName(this PermLevel level) => level switch + { + PermLevel.Owner => "Owner", + PermLevel.Admin => "Admin", + PermLevel.Mod => "Mod", + _ => "Everyone" + }; + + // Parses a level from a name or number (case-insensitive). "everyone"/"none"/"remove"/"0" -> Everyone. + public static bool TryParse(string input, out PermLevel level) + { + level = PermLevel.Everyone; + switch ((input ?? string.Empty).Trim().ToLowerInvariant()) + { + case "owner": case "3": level = PermLevel.Owner; return true; + case "admin": case "2": level = PermLevel.Admin; return true; + case "mod": case "moderator": case "1": level = PermLevel.Mod; return true; + case "everyone": case "none": case "guest": case "remove": case "deop": case "0": + level = PermLevel.Everyone; return true; + default: return false; + } + } + } +} diff --git a/LobbyKit/Features/Permissions/Perms.cs b/LobbyKit/Features/Permissions/Perms.cs new file mode 100644 index 0000000..b909990 --- /dev/null +++ b/LobbyKit/Features/Permissions/Perms.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MelonLoader; +using MelonLoader.Utils; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace LobbyKit.Features.Permissions +{ + // JSON-backed permission store, persisted to UserData/LobbyKit-permissions.json. Holds op levels, banned + // players, and per-command required levels. Keyed on EOS ProductUserId (PUID) so a player's level/ban + // survives reconnects. Each player/ban entry also stores an optional last-known name (refreshed on join) + // purely for human reference and name-based lookups. The host (connection 32767) is always Owner. + internal static class Perms + { + private const int HostConnectionId = 32767; + + // A player op entry. Accepts either a bare integer (level only) or an object { level, name } in JSON. + [JsonConverter(typeof(PlayerEntryConverter))] + private sealed class PlayerEntry { public int level; public string name; } + + // A ban entry. Accepts either a bare string (reason only) or an object { reason, name } in JSON. + [JsonConverter(typeof(BanEntryConverter))] + private sealed class BanEntry { public string reason; public string name; } + + private sealed class PermData + { + public string[] _readme { get; set; } + public Dictionary players { get; set; } = new(); + public Dictionary commandLevels { get; set; } = new(); + public Dictionary banned { get; set; } = new(); + } + + private static readonly string[] ReadmeLines = + { + "LobbyKit permissions. Edit while the server is STOPPED, then restart (no hot-reload).", + "Levels: Everyone=0, Mod=1, Admin=2, Owner=3. The host (the server itself) is always Owner.", + "players: \"\": { \"level\": 2, \"name\": \"...\" }. You may also write just the level: \"\": 2.", + " PUIDs appear in the join log: + Name joined (conn X, puid ). Names auto-fill on join.", + "commandLevels: \"\": required level. Set a level above 3 (e.g. 4) to disable a command.", + "banned: \"\": { \"reason\": \"...\", \"name\": \"...\" }. Manage with !ban / !unban in chat.", + "Example - make someone an Admin: \"players\": { \"00024765...ee5c\": { \"level\": 2 } }" + }; + + private static PermData _data = new(); + private static string FilePath => Path.Combine(MelonEnvironment.UserDataDirectory, "LobbyKit-permissions.json"); + + public static void Initialize() + { + try + { + if (File.Exists(FilePath)) + { + _data = JsonConvert.DeserializeObject(File.ReadAllText(FilePath)) ?? new PermData(); + _data.players ??= new(); + _data.commandLevels ??= new(); + _data.banned ??= new(); + MelonLogger.Msg($"[LobbyKit] Permissions: {_data.players.Count} op(s), {_data.banned.Count} ban(s), {_data.commandLevels.Count} command level(s)."); + } + else + { + Save(); + MelonLogger.Msg($"[LobbyKit] Permissions file created: {FilePath}"); + } + } + catch (Exception ex) + { + MelonLogger.Warning($"[LobbyKit] Permissions load failed: {ex.GetType().Name}: {ex.Message}. Using empty set."); + _data = new PermData(); + } + } + + public static void Save() + { + try + { + _data._readme = ReadmeLines; // refresh the help header every save so it's never lost + File.WriteAllText(FilePath, JsonConvert.SerializeObject(_data, Formatting.Indented)); + } + catch (Exception ex) { MelonLogger.Warning($"[LobbyKit] Permissions save failed: {ex.GetType().Name}: {ex.Message}"); } + } + + // ── Levels ─────────────────────────────────────────────────────────────────── + public static PermLevel GetLevel(string puid) + => string.IsNullOrWhiteSpace(puid) ? PermLevel.Everyone + : (_data.players.TryGetValue(puid, out PlayerEntry e) ? Clamp(e.level) : PermLevel.Everyone); + + public static PermLevel GetLevel(int connectionId) + => connectionId == HostConnectionId ? PermLevel.Owner : GetLevel(ResolvePuid(connectionId)); + + public static bool Has(int connectionId, PermLevel min) => GetLevel(connectionId) >= min; + public static bool Has(Il2Cpp.PlayerControl pc, PermLevel min) => pc != null && Has(pc.OwnerId, min); + + public static void SetLevel(string puid, PermLevel level, string name = null) + { + if (string.IsNullOrWhiteSpace(puid)) return; + if (level <= PermLevel.Everyone) { _data.players.Remove(puid); } + else + { + if (!_data.players.TryGetValue(puid, out PlayerEntry e)) { e = new PlayerEntry(); _data.players[puid] = e; } + e.level = (int)level; + if (!string.IsNullOrWhiteSpace(name)) e.name = name; + } + Save(); + } + + // ── Names ──────────────────────────────────────────────────────────────────── + public static string GetName(string puid) + { + if (string.IsNullOrWhiteSpace(puid)) return null; + if (_data.players.TryGetValue(puid, out PlayerEntry p) && !string.IsNullOrWhiteSpace(p.name)) return p.name; + if (_data.banned.TryGetValue(puid, out BanEntry b) && !string.IsNullOrWhiteSpace(b.name)) return b.name; + return null; + } + + // Refreshes the stored name for a PUID on any op/ban entry that exists (we only keep names for tracked + // players). Saves only when something actually changed, to avoid churning the file on every join. + public static void RememberName(string puid, string name) + { + if (string.IsNullOrWhiteSpace(puid) || string.IsNullOrWhiteSpace(name)) return; + bool changed = false; + if (_data.players.TryGetValue(puid, out PlayerEntry p) && p.name != name) { p.name = name; changed = true; } + if (_data.banned.TryGetValue(puid, out BanEntry b) && b.name != name) { b.name = name; changed = true; } + if (changed) Save(); + } + + // ── Bans ───────────────────────────────────────────────────────────────────── + public static bool IsBanned(string puid) => !string.IsNullOrWhiteSpace(puid) && _data.banned.ContainsKey(puid); + public static string BanReason(string puid) => _data.banned.TryGetValue(puid ?? string.Empty, out BanEntry b) ? b.reason : string.Empty; + + public static void Ban(string puid, string reason, string name = null) + { + if (string.IsNullOrWhiteSpace(puid)) return; + _data.banned[puid] = new BanEntry { reason = string.IsNullOrWhiteSpace(reason) ? "Banned" : reason, name = name }; + _data.players.Remove(puid); // a banned player loses any op level + Save(); + } + + public static bool Unban(string puid) + { + if (string.IsNullOrWhiteSpace(puid) || !_data.banned.Remove(puid)) return false; + Save(); + return true; + } + + // Finds a banned PUID by stored name (case-insensitive). Returns null if no banned player matches. + public static string FindBannedPuidByName(string name) + { + if (string.IsNullOrWhiteSpace(name)) return null; + foreach (KeyValuePair kv in _data.banned) + if (string.Equals(kv.Value.name, name, StringComparison.OrdinalIgnoreCase)) + return kv.Key; + return null; + } + + // ── Per-command required level (JSON-configurable) ─────────────────────────── + public static int GetCommandLevel(string name, int defaultLevel) + => _data.commandLevels.TryGetValue(Normalize(name), out int lvl) ? lvl : defaultLevel; + + public static void SeedCommandLevels(IEnumerable> defaults) + { + bool changed = false; + foreach (KeyValuePair kv in defaults) + { + string key = Normalize(kv.Key); + if (!_data.commandLevels.ContainsKey(key)) { _data.commandLevels[key] = kv.Value; changed = true; } + } + if (changed) Save(); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + private static string ResolvePuid(int connectionId) + { + try { return Utils.FindPlayerFromConnectionId(connectionId)?.ProductUserId; } + catch { return null; } + } + + private static string Normalize(string command) => (command ?? string.Empty).Trim().ToLowerInvariant().TrimStart('!'); + private static PermLevel Clamp(int lvl) => (PermLevel)Math.Max(0, Math.Min(3, lvl)); + + // Reads "": 2 OR "": { "level": 2, "name": "..." }; writes the object form. + private sealed class PlayerEntryConverter : JsonConverter + { + public override PlayerEntry ReadJson(JsonReader reader, Type t, PlayerEntry existing, bool has, JsonSerializer s) + { + if (reader.TokenType == JsonToken.Integer) return new PlayerEntry { level = Convert.ToInt32(reader.Value) }; + JObject o = JObject.Load(reader); + return new PlayerEntry { level = o.Value("level") ?? 0, name = (string)o["name"] }; + } + public override void WriteJson(JsonWriter writer, PlayerEntry v, JsonSerializer s) + { + writer.WriteStartObject(); + writer.WritePropertyName("level"); writer.WriteValue(v.level); + if (!string.IsNullOrEmpty(v.name)) { writer.WritePropertyName("name"); writer.WriteValue(v.name); } + writer.WriteEndObject(); + } + } + + // Reads "": "reason" OR "": { "reason": "...", "name": "..." }; writes the object form. + private sealed class BanEntryConverter : JsonConverter + { + public override BanEntry ReadJson(JsonReader reader, Type t, BanEntry existing, bool has, JsonSerializer s) + { + if (reader.TokenType == JsonToken.String) return new BanEntry { reason = (string)reader.Value }; + JObject o = JObject.Load(reader); + return new BanEntry { reason = (string)o["reason"], name = (string)o["name"] }; + } + public override void WriteJson(JsonWriter writer, BanEntry v, JsonSerializer s) + { + writer.WriteStartObject(); + writer.WritePropertyName("reason"); writer.WriteValue(v.reason ?? "Banned"); + if (!string.IsNullOrEmpty(v.name)) { writer.WritePropertyName("name"); writer.WriteValue(v.name); } + writer.WriteEndObject(); + } + } + } +} diff --git a/LobbyKit/LobbyKit.csproj b/LobbyKit/LobbyKit.csproj index 2ce1bbf..70d0e58 100644 --- a/LobbyKit/LobbyKit.csproj +++ b/LobbyKit/LobbyKit.csproj @@ -591,6 +591,9 @@ $(RefRoot)\net6\MelonLoader.dll + + $(RefRoot)\net6\Newtonsoft.Json.dll + $(RefRoot)\net6\0Harmony.dll @@ -663,6 +666,10 @@ + + + + diff --git a/LobbyKit/LobbyKitCore.cs b/LobbyKit/LobbyKitCore.cs index 7c9637d..89a64c7 100644 --- a/LobbyKit/LobbyKitCore.cs +++ b/LobbyKit/LobbyKitCore.cs @@ -51,6 +51,9 @@ public class LobbyKitCore : MelonMod private static MelonPreferences_Entry _enableAnticheat; private static MelonPreferences_Entry _enforcePlayerScale; private static MelonPreferences_Entry _blockSledPush; + private static MelonPreferences_Entry _prefixMod; + private static MelonPreferences_Entry _prefixAdmin; + private static MelonPreferences_Entry _prefixOwner; public static bool EnableGuestBangCommands => _enableGuestBangCommands?.Value ?? true; public static string ServerName => _serverName?.Value ?? string.Empty; @@ -78,6 +81,15 @@ public class LobbyKitCore : MelonMod public static bool EnforcePlayerScale => _enforcePlayerScale?.Value ?? true; public static bool BlockSledPush => _blockSledPush?.Value ?? true; + // Chat prefix shown before a player's name, by permission level. Empty string disables that level's prefix. + public static string ChatPrefixFor(Features.Permissions.PermLevel level) => level switch + { + Features.Permissions.PermLevel.Owner => _prefixOwner?.Value ?? string.Empty, + Features.Permissions.PermLevel.Admin => _prefixAdmin?.Value ?? string.Empty, + Features.Permissions.PermLevel.Mod => _prefixMod?.Value ?? string.Empty, + _ => string.Empty + }; + private PlayerReferenceManager _playerReferenceManager; public bool ReferencesLoaded = false; @@ -94,14 +106,16 @@ public override unsafe void OnInitializeMelon() _enableGuestBangCommands = _preferences.CreateEntry("EnableGuestBangCommands", true, "Enable Guest Bang Commands", "Allow non-host players to use custom bang chat commands like !tp."); // Server config lives in its own shared category so LobbyKit and SledHeadless read one source of truth. + // GetOrCreate (not CreateEntry): LobbyKit and SledHeadless share this category, and CreateEntry + // throws if the entry already exists, so the second mod to load must reuse the existing entries. _serverSettings = MelonPreferences.CreateCategory("ServerSettings", "Server Settings"); - _serverName = _serverSettings.CreateEntry("ServerName", string.Empty, "Server Name", "Custom default lobby/server name. Leave empty to use '\'s Lobby'."); - _serverCapacity = _serverSettings.CreateEntry("ServerCapacity", 8, "Server Capacity", "Saved default value for the max players slider."); - _isPublicLobby = _serverSettings.CreateEntry("IsPublicLobby", true, "Public Lobby", "Saved default for public/private lobby."); - _isPasswordProtected = _serverSettings.CreateEntry("IsPasswordProtected", false, "Password Protected", "Saved default for password protection."); - _lobbyPassword = _serverSettings.CreateEntry("LobbyPassword", string.Empty, "Lobby Password", "Saved default lobby password."); - _isPeacefulMode = _serverSettings.CreateEntry("IsPeacefulMode", false, "Peaceful Mode", "Saved default for peaceful mode."); - _isTextChatOnly = _serverSettings.CreateEntry("IsTextChatOnly", false, "Text Chat Only", "Saved default for text-chat-only mode."); + _serverName = GetOrCreate(_serverSettings, "ServerName", string.Empty, "Server Name", "Custom default lobby/server name. Leave empty to use '\'s Lobby'."); + _serverCapacity = GetOrCreate(_serverSettings, "ServerCapacity", 8, "Server Capacity", "Saved default value for the max players slider."); + _isPublicLobby = GetOrCreate(_serverSettings, "IsPublicLobby", true, "Public Lobby", "Saved default for public/private lobby."); + _isPasswordProtected = GetOrCreate(_serverSettings, "IsPasswordProtected", false, "Password Protected", "Saved default for password protection."); + _lobbyPassword = GetOrCreate(_serverSettings, "LobbyPassword", string.Empty, "Lobby Password", "Saved default lobby password."); + _isPeacefulMode = GetOrCreate(_serverSettings, "IsPeacefulMode", false, "Peaceful Mode", "Saved default for peaceful mode."); + _isTextChatOnly = GetOrCreate(_serverSettings, "IsTextChatOnly", false, "Text Chat Only", "Saved default for text-chat-only mode."); // One-time migration: pull any non-default value users saved under the old "LobbyKit" category into // ServerSettings, then drop the stale key. No-ops once migrated (ServerSettings already non-default). @@ -131,6 +145,12 @@ public override unsafe void OnInitializeMelon() _enableAnticheat = _preferences.CreateEntry("EnableAnticheat", false, "Enable AntiCheat", "Rate-limit and kick clients who spam server RPCs."); _enforcePlayerScale = _preferences.CreateEntry("EnforcePlayerScale", true, "Enforce Player Scale", "Anticheat: force each player's avatar to their allowed size (default 1, or their !size choice) and clamp any cheated scale back."); _blockSledPush = _preferences.CreateEntry("BlockSledPush", true, "Block Sled Push", "Anticheat: no-op the client-initiated Cmd_PushSled (a raw AddForce lever). Boosts use a separate path and are unaffected. Disable to allow manual sled pushing."); + _prefixMod = _preferences.CreateEntry("ChatPrefixMod", "<#7DFF7D>[Mod] ", "Chat Prefix: Mod", "Prefix shown before a Mod's name in chat. Empty to disable."); + _prefixAdmin = _preferences.CreateEntry("ChatPrefixAdmin", "<#7DD0FF>[Admin] ", "Chat Prefix: Admin", "Prefix shown before an Admin's name in chat. Empty to disable."); + _prefixOwner = _preferences.CreateEntry("ChatPrefixOwner", "<#FFE066>[Owner] ", "Chat Prefix: Owner", "Prefix shown before an Owner's name in chat. Empty to disable."); + + Features.Permissions.Perms.Initialize(); + Patches.ChatSystem.SeedCommandLevels(); MelonPreferences.Save(); HarmonyInstance.PatchAll(); @@ -141,6 +161,15 @@ public override unsafe void OnInitializeMelon() MelonCoroutines.Start(PlayerCountLogLoop()); } + // GetEntry if it already exists (e.g. SledHeadless created it on the shared ServerSettings category), + // otherwise CreateEntry. MelonLoader's CreateEntry throws on a duplicate identifier. + private static MelonPreferences_Entry GetOrCreate(MelonPreferences_Category category, string identifier, T defaultValue, string displayName, string description = null) + { + return category.HasEntry(identifier) + ? category.GetEntry(identifier) + : category.CreateEntry(identifier, defaultValue, displayName, description); + } + // Copies a value users previously saved under an old category into its new ServerSettings entry, then // removes the stale key. Only overwrites when the ServerSettings entry is still at its default, so an // already-migrated or explicitly-set value is never clobbered (and it no-ops on subsequent launches). @@ -310,6 +339,20 @@ public void PlayerJoinedGame(PlayerReference p) if (p == null) return; + // Ban enforcement: kick a banned PUID the moment they register (host-side only). + if (isHost && p.ConnectionID != 32767 && !p.IsLocalPlayerInstance() && + Features.Permissions.Perms.IsBanned(p.ProductUserId)) + { + Features.Anticheat.KickAnnouncer.Enqueue(p.ConnectionID, "is banned"); + try { InstanceFinder.ServerManager?.Kick(p.ConnectionID, Il2CppFishNet.Managing.Server.KickReason.Unset); } catch { } + MelonLogger.Msg($"[LobbyKit] Banned player kicked on join (conn {p.ConnectionID}, puid {p.ProductUserId})."); + return; + } + + // Keep stored op/ban names current for anyone tracked in the op-file. + if (isHost && p.ConnectionID != 32767) + Features.Permissions.Perms.RememberName(p.ProductUserId, Patches.ChatSystem.StripRichText(p.Username)); + double nowUptime = GetLobbyUptimeSeconds(); bool isLocalPlayer = p.IsLocalPlayerInstance(); bool isNewConnection = !players.Any(player => player != null && player.ConnectionID == p.ConnectionID); @@ -349,7 +392,7 @@ public void PlayerJoinedGame(PlayerReference p) Patches.ChatSystem.BroadcastSystemMessage($"<#FA0>{username} joined."); } string logName = Patches.ChatSystem.StripRichText(string.IsNullOrWhiteSpace(p.Username) ? "A player" : p.Username); - MelonLogger.Msg($"[LobbyKit] + {logName} joined (conn {p.ConnectionID}) — {RemotePlayerCount} online."); + MelonLogger.Msg($"[LobbyKit] + {logName} joined (conn {p.ConnectionID}, puid {p.ProductUserId}) — {RemotePlayerCount} online."); // A late joiner spawns existing players from the server's cached transform and missed any earlier // !size ObserversUpdate, so re-broadcast registered sizes across their load window. From c22ccecb278fa4f116cbe8697941239a77006a4a Mon Sep 17 00:00:00 2001 From: rdavis Date: Sun, 7 Jun 2026 22:28:45 -0500 Subject: [PATCH 18/23] Fixes cannon not working intermittently, fixes command player name resolution, adds help paging --- LobbyKit/ChatSystem.cs | 146 ++++++++++++++++-- .../Commands/ChatSystem.ModerationCommands.cs | 23 ++- .../Commands/ChatSystem.TeleportCommands.cs | 6 +- LobbyKit/Features/Fixes/CannonLaunchFix.cs | 62 ++++++++ LobbyKit/LobbyKit.csproj | 1 + LobbyKit/LobbyKitCore.cs | 1 + LobbyKit/Utils.cs | 30 ++++ docs/TODO.md | 2 +- 8 files changed, 247 insertions(+), 24 deletions(-) create mode 100644 LobbyKit/Features/Fixes/CannonLaunchFix.cs diff --git a/LobbyKit/ChatSystem.cs b/LobbyKit/ChatSystem.cs index fd82e68..fef87ba 100644 --- a/LobbyKit/ChatSystem.cs +++ b/LobbyKit/ChatSystem.cs @@ -61,8 +61,8 @@ public CommandDefinition( { ["!help"] = new CommandDefinition( HandleHelpCommand, - "!help [command]", - "Shows available commands or details for one command.", + "!help [command|page]", + "Lists commands (paged) or details for one command.", hiddenFromHelp: true), ["!settings"] = new CommandDefinition( OpenSettingsMenu, @@ -538,29 +538,100 @@ private static void MarkHandled(int connectionId, string message) LastCommandBySource[connectionId] = (message, Time.frameCount); } + // The in-game chatbox hard-wraps at this many characters per line (including any sender prefix), and + // we keep each help page to this many lines (a header line + command lines). Each Reply renders as one + // chat line, so every help line must stay within HelpLineChars (else it wraps into extra lines). + private const int HelpLineChars = 36; + private const int HelpLinesPerPage = 8; + private static void HandleHelpCommand(PlayerControl playerControl, string args) { - string helpSizing = ""; - if (!string.IsNullOrWhiteSpace(args)) - { - string requested = args.Trim(); - if (!requested.StartsWith("!", StringComparison.Ordinal)) - requested = "!" + requested; + string a = args?.Trim() ?? string.Empty; + // !help → detailed help for a single command (anything non-numeric is a command name). + if (a.Length > 0 && !int.TryParse(a, out _)) + { + string requested = a.StartsWith("!", StringComparison.Ordinal) ? a : "!" + a; if (Commands.TryGetValue(requested, out CommandDefinition command) && CanShowInHelp(requested, command, playerControl)) - Reply(playerControl, $"{helpSizing}<#7FF>{FormatCommandUsage(requested, command)} - {command.Description}"); + { + foreach (string line in HelpWordWrap($"{FormatCommandUsage(requested, command)} - {command.Description}", HelpLineChars, HelpLinesPerPage)) + Reply(playerControl, HelpLine(line)); + } else - Reply(playerControl, $"{helpSizing}<#FA0>Unknown command: {requested}"); - + { + Reply(playerControl, HelpLine($"Unknown command: {requested}", warn: true)); + } return; } - Reply(playerControl, $"{helpSizing}<#7FF>Available commands:"); + // List view, paginated. Each visible command is one line (usage, truncated to the line width). + var entries = new List(); foreach (var entry in Commands) - { if (CanShowInHelp(entry.Key, entry.Value, playerControl)) - Reply(playerControl, $"{helpSizing}<#7FF>{FormatCommandUsage(entry.Key, entry.Value)}"); + entries.Add(HelpTruncate(FormatCommandUsage(entry.Key, entry.Value), HelpLineChars)); + + if (entries.Count == 0) + { + Reply(playerControl, HelpLine("No commands available.")); + return; + } + + // First line is always a header showing the page number; the rest of the page lists commands. + int perPage = HelpLinesPerPage - 1; + int pages = (entries.Count + perPage - 1) / perPage; + + int page = (a.Length > 0 && int.TryParse(a, out int p)) ? p : 1; + page = Math.Max(1, Math.Min(page, pages)); + + string header = $"Commands (Page {page}/{pages})"; + if (page < pages) header += $" !help {page + 1}"; + Reply(playerControl, HelpLine(HelpTruncate(header, HelpLineChars), header: true)); + + int start = (page - 1) * perPage; + int stop = Math.Min(start + perPage, entries.Count); + for (int i = start; i < stop; i++) + Reply(playerControl, HelpLine(entries[i])); + } + + // One help chat line: keeps the existing small size; yellow header, blue content, amber warning. + private static string HelpLine(string text, bool header = false, bool warn = false) + => $"<#{(warn ? "FA0" : header ? "FF0" : "7FF")}>{text}"; + + private static string HelpTruncate(string s, int max) + { + if (string.IsNullOrEmpty(s) || s.Length <= max) return s; + return max <= 1 ? s.Substring(0, max) : s.Substring(0, max - 1) + "…"; + } + + // Greedy word-wrap of PLAIN text (no rich-text tags) to lines of at most maxChars, capped at maxLines. + // A single token longer than a line is hard-trimmed with an ellipsis. + private static List HelpWordWrap(string text, int maxChars, int maxLines) + { + var lines = new List(); + if (string.IsNullOrWhiteSpace(text)) return lines; + + var current = new System.Text.StringBuilder(); + foreach (string word in text.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)) + { + string w = word.Length > maxChars ? word.Substring(0, maxChars - 1) + "…" : word; + if (current.Length == 0) + { + current.Append(w); + } + else if (current.Length + 1 + w.Length <= maxChars) + { + current.Append(' ').Append(w); + } + else + { + lines.Add(current.ToString()); + current.Clear(); + if (lines.Count >= maxLines) return lines; + current.Append(w); + } } + if (current.Length > 0 && lines.Count < maxLines) lines.Add(current.ToString()); + return lines; } private static bool CanShowInHelp(string commandName, CommandDefinition command, PlayerControl playerControl) @@ -683,6 +754,53 @@ private static (string First, string Remainder) SplitFirstWord(string args) return (p[0], p.Length > 1 ? p[1].Trim() : string.Empty); } + // Splits " [trailing...]" where is an ONLINE player's name that may contain spaces, + // quotes, or other special characters (e.g. a player literally named: "I don't know"). We can't rely + // on whitespace to find the name boundary, and we don't use quoting (the name itself may contain + // quotes). Instead we greedily try the longest word-prefix of args and shrink it one word at a time + // until it EXACTLY matches a connected player's name; everything after that is the trailing remainder + // (e.g. a kick/ban reason). Returns (null, "") when no exact-name prefix matches — callers may then + // fall back to a fuzzy single-word match for typo tolerance. + private static (PlayerReference Match, string Remainder) MatchLeadingPlayer(string args) + { + if (string.IsNullOrWhiteSpace(args)) return (null, string.Empty); + string s = args.Trim(); + + int cut = s.Length; + while (cut > 0) + { + string candidate = s.Substring(0, cut); + PlayerReference match = Utils.FindPlayerByExactName(candidate, sanitized: true); + if (match != null) + return (match, s.Substring(cut).Trim()); + + // Back up to the previous space so the next candidate ends on a word boundary. + int prevSpace = s.LastIndexOf(' ', cut - 1); + if (prevSpace < 0) break; + cut = prevSpace; + } + + return (null, string.Empty); + } + + // Resolves a leading player reference from a command's args, supporting names with spaces/special + // characters via MatchLeadingPlayer, and falling back to the legacy single-word fuzzy match (typo + // tolerance) when no exact-name prefix matches. Returns the player (null if unresolved) and the + // trailing remainder (e.g. a reason). + private static (PlayerReference Target, string Remainder) ResolveLeadingPlayer(string args) + { + var (exact, remainder) = MatchLeadingPlayer(args); + if (exact != null) return (exact, remainder); + + var (firstWord, rest) = SplitFirstWord(args); + // Never fuzzy-match a PUID-looking token to a player NAME — that could ban/kick the wrong online + // player (a long PUID can exceed the loose 0.1 similarity threshold against a short name). A real + // player literally named like a PUID would already have matched above via the exact pass, so this + // only suppresses the fuzzy fallback and lets callers (e.g. !ban) handle the token as a PUID. + if (LooksLikePuid(firstWord)) return (null, rest); + return (Utils.FindPlayerByName(firstWord, sanitized: true), rest); + } + private static bool LooksLikePuid(string value) => !string.IsNullOrWhiteSpace(value) && value.Length >= 16 && value.IndexOf(' ') < 0; diff --git a/LobbyKit/Features/Commands/ChatSystem.ModerationCommands.cs b/LobbyKit/Features/Commands/ChatSystem.ModerationCommands.cs index 446fba0..da5e1e7 100644 --- a/LobbyKit/Features/Commands/ChatSystem.ModerationCommands.cs +++ b/LobbyKit/Features/Commands/ChatSystem.ModerationCommands.cs @@ -36,9 +36,10 @@ private static void HandleKickCommand(PlayerControl playerControl, string args) { if (string.IsNullOrWhiteSpace(args)) { Reply(playerControl, "<#F00>Usage: !kick [reason]"); return; } - (string name, string reason) = SplitFirstWord(args); - PlayerReference target = Utils.FindPlayerByName(name, sanitized: true); - if (target == null) { Reply(playerControl, $"<#FA0>Player not found: {name}"); return; } + // Resolve the target by name — exact match first (so names with spaces/quotes/special chars + // resolve precisely and the rest becomes the reason), then fuzzy for typos. See ResolveLeadingPlayer. + (PlayerReference target, string reason) = ResolveLeadingPlayer(args); + if (target == null) { Reply(playerControl, $"<#FA0>Player not found: {args.Trim()}"); return; } if (target.ConnectionID == playerControl.OwnerId) { Reply(playerControl, "<#FA0>You can't kick yourself."); return; } if (Perms.GetLevel(target.ConnectionID) >= Perms.GetLevel(playerControl.OwnerId)) { Reply(playerControl, "<#FA0>You can't kick someone of equal or higher level."); return; } @@ -58,14 +59,20 @@ private static void HandleBanCommand(PlayerControl playerControl, string args) { if (string.IsNullOrWhiteSpace(args)) { Reply(playerControl, "<#F00>Usage: !ban [reason]"); return; } - (string nameOrPuid, string reason) = SplitFirstWord(args); - string puid = null; int conn = -1; string targetName = null; string display = nameOrPuid; + // Resolve an online player by name — exact match first (names with spaces/quotes/special chars), + // then fuzzy for typos; the rest is the reason. A PUID token never fuzzy-matches a name (see + // ResolveLeadingPlayer), so if nothing resolves we treat the first token as a PUID (offline ban). + (PlayerReference target, string reason) = ResolveLeadingPlayer(args); + string puid = null; int conn = -1; string targetName = null; string display = args.Trim(); - PlayerReference target = Utils.FindPlayerByName(nameOrPuid, sanitized: true); if (target != null) { puid = target.ProductUserId; conn = target.ConnectionID; targetName = StripRichText(target.Username); display = AutoCloseTmpRichText(target.Username); } - else if (LooksLikePuid(nameOrPuid)) { puid = nameOrPuid; } + else + { + string firstToken = SplitFirstWord(args).First; + if (LooksLikePuid(firstToken)) { puid = firstToken; display = firstToken; } + } - if (string.IsNullOrWhiteSpace(puid)) { Reply(playerControl, $"<#FA0>Player not found: {nameOrPuid}. Pass an online name or a PUID."); return; } + if (string.IsNullOrWhiteSpace(puid)) { Reply(playerControl, $"<#FA0>Player not found: {args.Trim()}. Pass an online name or a PUID."); return; } if (conn == playerControl.OwnerId) { Reply(playerControl, "<#FA0>You can't ban yourself."); return; } if (conn >= 0 && Perms.GetLevel(conn) >= Perms.GetLevel(playerControl.OwnerId)) { Reply(playerControl, "<#FA0>You can't ban someone of equal or higher level."); return; } diff --git a/LobbyKit/Features/Commands/ChatSystem.TeleportCommands.cs b/LobbyKit/Features/Commands/ChatSystem.TeleportCommands.cs index 85892fd..d48ef4d 100644 --- a/LobbyKit/Features/Commands/ChatSystem.TeleportCommands.cs +++ b/LobbyKit/Features/Commands/ChatSystem.TeleportCommands.cs @@ -132,9 +132,13 @@ private static void HandleTpForceCommand(PlayerControl playerControl, string arg return; } + // Announce who forced the teleport (the command runner), not a generic "the host". + string actorUsername = AutoCloseTmpRichText( + Utils.FindPlayerFromConnectionId(playerControl.OwnerId)?.Username ?? "The host"); + TeleportPlayerTo(target.PlayerControl, playerControl); Reply(playerControl, $"<#FF0>Forced {targetUsername} to TP to you."); - BroadcastMessage(target.ConnectionID, $"<#FF0>The host TP'd you to them."); + BroadcastMessage(target.ConnectionID, $"<#FF0>{actorUsername} TP'd you to them."); } } } diff --git a/LobbyKit/Features/Fixes/CannonLaunchFix.cs b/LobbyKit/Features/Fixes/CannonLaunchFix.cs new file mode 100644 index 0000000..5b66eb3 --- /dev/null +++ b/LobbyKit/Features/Fixes/CannonLaunchFix.cs @@ -0,0 +1,62 @@ +using System; +using System.Reflection; +using HarmonyLib; +using Il2CppFishNet; +using MelonLoader; + +namespace LobbyKit.Features.Fixes +{ + // Restores cannon launches on a dedicated/host server. + // + // A cannon is a Seat: sitting + clicking sends PlayerControl.Cmd_DoSeatAction -> (server) + // Cannon.Server_StartSeatAction -> PlayerControl.Server_GetHitBySomething(launchForce, self, ...) which + // TargetRpcs the launch to the player. Server_GetHitBySomething suppresses a SELF-inflicted hit while + // PlayerSnowmanRollingController.JustRolledSnowball() is true (so finishing a snowball roll doesn't knock + // you down). JustRolledSnowball() returns `justRolledCooldown > 0`. + // + // The bug: the server sets justRolledCooldown = 1.5 in Cmd_StopRolling (when a player finishes rolling a + // snowball), but PlayerSnowmanRollingController.Update only decrements it `if (IsOwner)` — and the server is + // never the owner of a remote player's controller — so on the server it STICKS at 1.5 forever. Once a player + // rolls a snowball, every cannon self-launch is permanently suppressed and the cannon does nothing. + // + // JustRolledSnowball() is read ONLY by Server_GetHitBySomething, so forcing it false on the server fully + // fixes cannons with no other effect (the server's 1.5s grace window is already broken/stuck anyway). Runs + // only on the server (the only place JustRolledSnowball is consulted); real clients are untouched. + internal static class CannonLaunchFix + { + public static void Apply(HarmonyLib.Harmony harmony) + { + try + { + var t = typeof(Il2Cpp.PlayerSnowmanRollingController); + var m = t.GetMethod("JustRolledSnowball", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (m == null) + { + MelonLogger.Warning("[LobbyKit] CannonLaunchFix: PlayerSnowmanRollingController.JustRolledSnowball not found; cannon fix disabled."); + return; + } + + harmony.Patch(m, prefix: new HarmonyMethod(typeof(CannonLaunchFix), nameof(JustRolledSnowball_Prefix))); + MelonLogger.Msg("[LobbyKit] CannonLaunchFix: JustRolledSnowball forced false on server — restores cannon launches (server-side justRolledCooldown never decrements)."); + } + catch (Exception ex) + { + MelonLogger.Warning($"[LobbyKit] CannonLaunchFix patch failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + // Only override on the server — that's the only side that suffers the stuck cooldown and the only place + // JustRolledSnowball is consulted (Server_GetHitBySomething). Fail-open: any error runs the original. + private static bool JustRolledSnowball_Prefix(ref bool __result) + { + try + { + if (!InstanceFinder.IsServer) return true; // run original on a pure client + } + catch { return true; } + + __result = false; + return false; // skip original + } + } +} diff --git a/LobbyKit/LobbyKit.csproj b/LobbyKit/LobbyKit.csproj index 70d0e58..d745ba7 100644 --- a/LobbyKit/LobbyKit.csproj +++ b/LobbyKit/LobbyKit.csproj @@ -652,6 +652,7 @@ + diff --git a/LobbyKit/LobbyKitCore.cs b/LobbyKit/LobbyKitCore.cs index 89a64c7..b306540 100644 --- a/LobbyKit/LobbyKitCore.cs +++ b/LobbyKit/LobbyKitCore.cs @@ -157,6 +157,7 @@ public override unsafe void OnInitializeMelon() Features.Anticheat.GenericServerRpcRateLimitPatch.ApplyPatches(HarmonyInstance); Features.Anticheat.PlayerScalePacketClamp.Apply(HarmonyInstance); Features.Anticheat.SledPushBlockPatch.Apply(HarmonyInstance); + Features.Fixes.CannonLaunchFix.Apply(HarmonyInstance); MelonCoroutines.Start(PlayerCountLogLoop()); } diff --git a/LobbyKit/Utils.cs b/LobbyKit/Utils.cs index ca88244..a716abd 100644 --- a/LobbyKit/Utils.cs +++ b/LobbyKit/Utils.cs @@ -71,6 +71,36 @@ public static PlayerReference FindPlayerByName(string name, float similarityThre return bestMatch; } + // EXACT (case-insensitive, sanitized) name match only — returns null if no connected player's name + // equals `name`. Unlike FindPlayerByName this never fuzzy-matches, so it's safe for detecting the + // exact boundary of a player name inside a longer argument string (e.g. " "), including + // names with spaces, quotes, or other special characters. + public static PlayerReference FindPlayerByExactName(string name, bool sanitized = true) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + + var manager = PlayerReferenceManager.Instance; + if (manager == null || manager.sync_PlayerReferences == null) + return null; + + for (int i = 0; i < manager.sync_PlayerReferences.Count; i++) + { + PlayerReference playerRef = manager.sync_PlayerReferences[i]; + if (playerRef == null) + continue; + + string username = sanitized ? SanitizeUsername(playerRef.Username) : playerRef.Username; + if (string.IsNullOrWhiteSpace(username)) + continue; + + if (username.Equals(name, StringComparison.OrdinalIgnoreCase)) + return playerRef; + } + + return null; + } + public static PlayerReference FindPlayerFromConnectionId(int connectionId) { var manager = PlayerReferenceManager.Instance; diff --git a/docs/TODO.md b/docs/TODO.md index fde5211..1613022 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -5,6 +5,6 @@ ## Other - - !help paging - Offline mode? - Proximity chat range + - fix headless to hide player model and nametag if possible \ No newline at end of file From 7ef3a5e990bcb66b642910e1308b9ad493ae499d Mon Sep 17 00:00:00 2001 From: rdavis Date: Mon, 8 Jun 2026 01:03:32 -0500 Subject: [PATCH 19/23] add settings change --- LobbyKit/ChatSystem.cs | 38 +--- .../GenericServerRpcRateLimitPatch.cs | 2 +- .../Anticheat/PlayerScalePacketClamp.cs | 2 +- .../Features/Anticheat/SledPushBlockPatch.cs | 2 +- .../Commands/ChatSystem.SettingsCommands.cs | 184 ++++++++++++++++++ LobbyKit/Features/Settings/SettingsDraft.cs | 35 +++- .../Features/Settings/SettingsMenuView.cs | 8 + LobbyKit/Features/Settings/SettingsSchema.cs | 48 ++++- LobbyKit/LobbyKit.csproj | 1 + LobbyKit/LobbyKitCore.cs | 80 +++++--- 10 files changed, 325 insertions(+), 75 deletions(-) create mode 100644 LobbyKit/Features/Commands/ChatSystem.SettingsCommands.cs diff --git a/LobbyKit/ChatSystem.cs b/LobbyKit/ChatSystem.cs index fef87ba..6957c1c 100644 --- a/LobbyKit/ChatSystem.cs +++ b/LobbyKit/ChatSystem.cs @@ -65,19 +65,14 @@ public CommandDefinition( "Lists commands (paged) or details for one command.", hiddenFromHelp: true), ["!settings"] = new CommandDefinition( - OpenSettingsMenu, - "!settings", - "Open LobbyKit settings.", + HandleSettingsCommand, + "!settings [key] [value]", + "View or change LobbyKit settings (e.g. !settings bc on).", minLevel: PermLevel.Admin), [MotdCommand] = new CommandDefinition( HandleMotdCommand, "!motd [message]", "Show the message of the day. Admins can pass text to set it."), - ["!bc"] = new CommandDefinition( - HandleBangCommandsCommand, - "!bc [on|off]", - "Enable or disable guest bang commands.", - minLevel: PermLevel.Admin), ["!tp"] = new CommandDefinition( HandleTpCommand, "!tp [name]", @@ -664,33 +659,6 @@ private static string FormatCommandUsage(string commandName, CommandDefinition c private static string LevelLabel(int level) => level > (int)PermLevel.Owner ? "Disabled" : ((PermLevel)level).DisplayName(); - private static void HandleBangCommandsCommand(PlayerControl playerControl, string args) - { - if (string.IsNullOrWhiteSpace(args)) - { - string state = LobbyKitCore.EnableGuestBangCommands ? "enabled" : "disabled"; - Reply(playerControl, $"<#7FF>Guest bang commands are {state}. Usage: !bangcommands "); - return; - } - - string value = args.Trim().ToLowerInvariant(); - bool? enabled = value switch - { - "on" or "enable" or "enabled" or "true" or "1" => true, - "off" or "disable" or "disabled" or "false" or "0" => false, - _ => null - }; - - if (!enabled.HasValue) - { - Reply(playerControl, "<#F00>Usage: !bangcommands "); - return; - } - - LobbyKitCore.SetEnableGuestBangCommands(enabled.Value); - Reply(playerControl, $"<#FF0>Guest bang commands {(enabled.Value ? "enabled" : "disabled")}."); - } - private static void HandleMotdCommand(PlayerControl playerControl, string args) { if (string.IsNullOrWhiteSpace(args)) // read — anyone diff --git a/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs b/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs index 43798de..63028fc 100644 --- a/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs +++ b/LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs @@ -89,7 +89,7 @@ private static bool Prefix(NetworkBehaviour __instance, MethodBase __originalMet { int ownerId = __instance.OwnerId; - if (ownerId == HostConnectionId || !LobbyKitCore.EnableAnticheat) + if (ownerId == HostConnectionId || !LobbyKitCore.BlockThrowingSpam) return true; float now = Time.realtimeSinceStartup; int token = __originalMethod.MetadataToken; diff --git a/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs b/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs index 295e04e..1af380d 100644 --- a/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs +++ b/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs @@ -73,7 +73,7 @@ private static void Prefix(NetworkTransform __instance, Il2CppSystem.ArraySegmen { try { - if (!LobbyKitCore.EnforcePlayerScale) return; + if (!LobbyKitCore.BlockPlayerSizeCheat) return; if (!InstanceFinder.IsServerStarted) return; var arr = __0.Array; diff --git a/LobbyKit/Features/Anticheat/SledPushBlockPatch.cs b/LobbyKit/Features/Anticheat/SledPushBlockPatch.cs index 3c1b462..1f6edcf 100644 --- a/LobbyKit/Features/Anticheat/SledPushBlockPatch.cs +++ b/LobbyKit/Features/Anticheat/SledPushBlockPatch.cs @@ -75,7 +75,7 @@ private static bool SinkPrefix(Il2Cpp.Sled __instance) try { if (!InstanceFinder.IsServerStarted) return true; // only act as the host - if (!LobbyKitCore.BlockSledPush) return true; // pref off -> allow all pushes + if (!LobbyKitCore.BlockFlyingSleds) return true; // pref off -> allow all pushes if (_cmdPushDepth <= 0) return true; // not a client Cmd (e.g. SpeedBoost) -> allow _blockedCount++; diff --git a/LobbyKit/Features/Commands/ChatSystem.SettingsCommands.cs b/LobbyKit/Features/Commands/ChatSystem.SettingsCommands.cs new file mode 100644 index 0000000..7f5b081 --- /dev/null +++ b/LobbyKit/Features/Commands/ChatSystem.SettingsCommands.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using Il2Cpp; +using LobbyKit.Features.Permissions; +using LobbyKit.Features.Settings; + +namespace LobbyKit.Patches +{ + // Chat-driven settings: !settings, !settings , !settings , !settings . + // Runs server-side, so a non-host Admin/Mod (per permissions.json on "!settings") can edit the host's + // settings from chat without opening the local UI menu. A local host with no args still opens the UI menu. + public static partial class ChatSystem + { + private sealed class ChatSetting + { + public string Key; + public string Section; + public Func Show; // current value, display form + public Func Apply; // parse + set, returns a plain result message + } + + private static ChatSetting SBool(string key, string section, Func get, Action set) => new ChatSetting + { + Key = key, + Section = section, + Show = () => get() ? "on" : "off", + Apply = v => + { + bool? b = ParseOnOff(v); + if (b == null) return (false, $"{key}: use on/off"); + set(b.Value); + return (true, $"{key} = {(b.Value ? "on" : "off")}"); + } + }; + + private static ChatSetting SInt(string key, string section, Func get, Action set, int min, int max) => new ChatSetting + { + Key = key, + Section = section, + Show = () => get().ToString(), + Apply = v => + { + if (!int.TryParse(v.Trim(), out int n)) return (false, $"{key}: number {min}-{max}"); + n = Math.Max(min, Math.Min(max, n)); + set(n); + return (true, $"{key} = {n}"); + } + }; + + // mask: never show the value (e.g. password). strip: show without rich-text tags (e.g. prefixes). + private static ChatSetting SText(string key, string section, Func get, Action set, bool mask = false, bool strip = false) => new ChatSetting + { + Key = key, + Section = section, + Show = () => + { + string s = get(); + if (string.IsNullOrEmpty(s)) return "(empty)"; + if (mask) return "(set)"; + return strip ? StripRichText(s) : s; + }, + Apply = v => + { + string val = v.Trim(); + if (val.Equals("clear", StringComparison.OrdinalIgnoreCase) || val == "-") val = string.Empty; + set(val); + return (true, val.Length == 0 ? $"{key} cleared" : $"{key} set"); + } + }; + + private static bool? ParseOnOff(string v) => (v ?? string.Empty).Trim().ToLowerInvariant() switch + { + "on" or "true" or "1" or "yes" or "enable" or "enabled" => true, + "off" or "false" or "0" or "no" or "disable" or "disabled" => false, + _ => (bool?)null + }; + + // The full editable-setting registry, grouped by section (order preserved for the list view). + private static readonly List SettingsRegistry = new() + { + SText("name", "Lobby", () => LobbyKitCore.ServerName, LobbyKitCore.SetServerName), + SInt ("cap", "Lobby", () => LobbyKitCore.ServerCapacity, LobbyKitCore.SetServerCapacity, 1, 64), + SBool("public", "Lobby", () => LobbyKitCore.IsPublicLobby, LobbyKitCore.SetIsPublicLobby), + SBool("password", "Lobby", () => LobbyKitCore.IsPasswordProtected, LobbyKitCore.SetIsPasswordProtected), + SText("pw", "Lobby", () => LobbyKitCore.LobbyPassword, LobbyKitCore.SetLobbyPassword, mask: true), + SBool("peaceful", "Lobby", () => LobbyKitCore.IsPeacefulMode, LobbyKitCore.SetIsPeacefulMode), + SBool("textchat", "Lobby", () => LobbyKitCore.IsTextChatOnly, LobbyKitCore.SetIsTextChatOnly), + + SBool("bc", "Mod", () => LobbyKitCore.EnableGuestBangCommands, LobbyKitCore.SetEnableGuestBangCommands), + SBool("autorestart", "Mod", () => LobbyKitCore.AutoRestartOnCrash, LobbyKitCore.SetAutoRestartOnCrash), + + SText("motd", "Messages", () => LobbyKitCore.MessageOfTheDay, LobbyKitCore.SetMessageOfTheDay), + SBool("jm", "Messages", () => LobbyKitCore.ShowJoinMessages, LobbyKitCore.SetShowJoinMessages), + SInt ("jmsize", "Messages", () => LobbyKitCore.JoinMessageSize, LobbyKitCore.SetJoinMessageSize, 50, 100), + SBool("lm", "Messages", () => LobbyKitCore.ShowLeaveMessages, LobbyKitCore.SetShowLeaveMessages), + SInt ("lmsize", "Messages", () => LobbyKitCore.LeaveMessageSize, LobbyKitCore.SetLeaveMessageSize, 50, 100), + + SBool("throwspam", "Anticheat", () => LobbyKitCore.BlockThrowingSpam, LobbyKitCore.SetBlockThrowingSpam), + SBool("sizecheat", "Anticheat", () => LobbyKitCore.BlockPlayerSizeCheat, LobbyKitCore.SetBlockPlayerSizeCheat), + SBool("flyingsleds", "Anticheat", () => LobbyKitCore.BlockFlyingSleds, LobbyKitCore.SetBlockFlyingSleds), + + SText("prefixmod", "Prefixes", () => LobbyKitCore.ChatPrefixFor(PermLevel.Mod), v => LobbyKitCore.SetChatPrefix(PermLevel.Mod, v), strip: true), + SText("prefixadmin", "Prefixes", () => LobbyKitCore.ChatPrefixFor(PermLevel.Admin), v => LobbyKitCore.SetChatPrefix(PermLevel.Admin, v), strip: true), + SText("prefixowner", "Prefixes", () => LobbyKitCore.ChatPrefixFor(PermLevel.Owner), v => LobbyKitCore.SetChatPrefix(PermLevel.Owner, v), strip: true), + }; + + private static readonly Dictionary SettingsByKey = BuildSettingsByKey(); + + private static Dictionary BuildSettingsByKey() + { + var d = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (ChatSetting s in SettingsRegistry) d[s.Key] = s; + return d; + } + + private static void HandleSettingsCommand(PlayerControl playerControl, string args) + { + string a = args?.Trim() ?? string.Empty; + + // No args: a local host (with a real UI) opens the menu; non-host admins (and headless) get the list. + if (a.Length == 0) + { + if (IsLocalConnectionId(playerControl.OwnerId)) + OpenSettingsMenu(playerControl, args); + else + ShowSettingsList(playerControl, 1); + return; + } + + // !settings + if (int.TryParse(a, out int pageArg)) + { + ShowSettingsList(playerControl, pageArg); + return; + } + + // !settings [value] + (string key, string value) = SplitFirstWord(a); + if (!SettingsByKey.TryGetValue(key, out ChatSetting setting)) + { + Reply(playerControl, $"<#FA0>Unknown setting: {key} (try !settings)"); + return; + } + + if (string.IsNullOrWhiteSpace(value)) + { + Reply(playerControl, $"<#7FF>{setting.Key} = {setting.Show()}"); + return; + } + + (bool ok, string msg) = setting.Apply(value); + Reply(playerControl, (ok ? "<#FF0>" : "<#FA0>") + msg); + } + + // Paged chat listing of all settings + current values, grouped by section (uses the help line budget). + private static void ShowSettingsList(PlayerControl playerControl, int page) + { + var lines = new List<(string text, bool divider)>(); + string lastSection = null; + foreach (ChatSetting s in SettingsRegistry) + { + if (s.Section != lastSection) + { + lines.Add(($"-- {s.Section} --", true)); + lastSection = s.Section; + } + lines.Add((HelpTruncate($"{s.Key} = {s.Show()}", HelpLineChars), false)); + } + + int perPage = HelpLinesPerPage - 1; // reserve the header line + int pages = Math.Max(1, (lines.Count + perPage - 1) / perPage); + page = Math.Max(1, Math.Min(page, pages)); + + string header = $"Settings (Page {page}/{pages})"; + if (page < pages) header += $" !settings {page + 1}"; + Reply(playerControl, HelpLine(HelpTruncate(header, HelpLineChars), header: true)); + + int start = (page - 1) * perPage; + int stop = Math.Min(start + perPage, lines.Count); + for (int i = start; i < stop; i++) + Reply(playerControl, HelpLine(lines[i].text, header: lines[i].divider)); + } + } +} diff --git a/LobbyKit/Features/Settings/SettingsDraft.cs b/LobbyKit/Features/Settings/SettingsDraft.cs index bd820f3..d50ce80 100644 --- a/LobbyKit/Features/Settings/SettingsDraft.cs +++ b/LobbyKit/Features/Settings/SettingsDraft.cs @@ -19,7 +19,12 @@ internal sealed class SettingsDraft public int JoinMessageSize { get; set; } public int LeaveMessageSize { get; set; } public bool AutoRestartOnCrash { get; set; } - public bool EnableAnticheat { get; set; } + public bool BlockThrowingSpam { get; set; } + public bool BlockPlayerSizeCheat { get; set; } + public bool BlockFlyingSleds { get; set; } + public string PrefixMod { get; set; } + public string PrefixAdmin { get; set; } + public string PrefixOwner { get; set; } public static SettingsDraft FromCurrent() { @@ -39,7 +44,12 @@ public static SettingsDraft FromCurrent() JoinMessageSize = LobbyKitCore.JoinMessageSize, LeaveMessageSize = LobbyKitCore.LeaveMessageSize, AutoRestartOnCrash = LobbyKitCore.AutoRestartOnCrash, - EnableAnticheat = LobbyKitCore.EnableAnticheat + BlockThrowingSpam = LobbyKitCore.BlockThrowingSpam, + BlockPlayerSizeCheat = LobbyKitCore.BlockPlayerSizeCheat, + BlockFlyingSleds = LobbyKitCore.BlockFlyingSleds, + PrefixMod = LobbyKitCore.ChatPrefixFor(Features.Permissions.PermLevel.Mod), + PrefixAdmin = LobbyKitCore.ChatPrefixFor(Features.Permissions.PermLevel.Admin), + PrefixOwner = LobbyKitCore.ChatPrefixFor(Features.Permissions.PermLevel.Owner) }; } @@ -61,7 +71,12 @@ public SettingsDraft Clone() JoinMessageSize = JoinMessageSize, LeaveMessageSize = LeaveMessageSize, AutoRestartOnCrash = AutoRestartOnCrash, - EnableAnticheat = EnableAnticheat + BlockThrowingSpam = BlockThrowingSpam, + BlockPlayerSizeCheat = BlockPlayerSizeCheat, + BlockFlyingSleds = BlockFlyingSleds, + PrefixMod = PrefixMod, + PrefixAdmin = PrefixAdmin, + PrefixOwner = PrefixOwner }; } @@ -84,7 +99,12 @@ public bool ValueEquals(SettingsDraft other) JoinMessageSize == other.JoinMessageSize && LeaveMessageSize == other.LeaveMessageSize && AutoRestartOnCrash == other.AutoRestartOnCrash && - EnableAnticheat == other.EnableAnticheat; + BlockThrowingSpam == other.BlockThrowingSpam && + BlockPlayerSizeCheat == other.BlockPlayerSizeCheat && + BlockFlyingSleds == other.BlockFlyingSleds && + string.Equals(PrefixMod, other.PrefixMod, StringComparison.Ordinal) && + string.Equals(PrefixAdmin, other.PrefixAdmin, StringComparison.Ordinal) && + string.Equals(PrefixOwner, other.PrefixOwner, StringComparison.Ordinal); } public void Apply() @@ -103,7 +123,12 @@ public void Apply() LobbyKitCore.SetJoinMessageSize(JoinMessageSize); LobbyKitCore.SetLeaveMessageSize(LeaveMessageSize); LobbyKitCore.SetAutoRestartOnCrash(AutoRestartOnCrash); - LobbyKitCore.SetEnableAnticheat(EnableAnticheat); + LobbyKitCore.SetBlockThrowingSpam(BlockThrowingSpam); + LobbyKitCore.SetBlockPlayerSizeCheat(BlockPlayerSizeCheat); + LobbyKitCore.SetBlockFlyingSleds(BlockFlyingSleds); + LobbyKitCore.SetChatPrefix(Features.Permissions.PermLevel.Mod, PrefixMod); + LobbyKitCore.SetChatPrefix(Features.Permissions.PermLevel.Admin, PrefixAdmin); + LobbyKitCore.SetChatPrefix(Features.Permissions.PermLevel.Owner, PrefixOwner); LobbyUiController.Instance.RefreshCreateLobbyFromPreferences(); } diff --git a/LobbyKit/Features/Settings/SettingsMenuView.cs b/LobbyKit/Features/Settings/SettingsMenuView.cs index b5f7b0e..4bdb2ab 100644 --- a/LobbyKit/Features/Settings/SettingsMenuView.cs +++ b/LobbyKit/Features/Settings/SettingsMenuView.cs @@ -126,6 +126,14 @@ public static SettingsMenuHandle Build(Transform parent, SettingsDraft draft) draft.LeaveMessageSize, value => draft.LeaveMessageSize = value); + AddDivider(content, "Anticheat"); + foreach (SettingsField field in SettingsSchema.AnticheatFields) + AddSchemaField(content, field, draft); + + AddDivider(content, "Chat Prefixes"); + foreach (SettingsField field in SettingsSchema.PrefixFields) + AddSchemaField(content, field, draft); + AddActionButtons(panel.Transform); return new SettingsMenuHandle(root, _firstSelectable); diff --git a/LobbyKit/Features/Settings/SettingsSchema.cs b/LobbyKit/Features/Settings/SettingsSchema.cs index a55f91b..12c293c 100644 --- a/LobbyKit/Features/Settings/SettingsSchema.cs +++ b/LobbyKit/Features/Settings/SettingsSchema.cs @@ -90,17 +90,55 @@ internal static class SettingsSchema "Auto-Restart Lobby On Crash", draft => draft.AutoRestartOnCrash, (draft, value) => draft.AutoRestartOnCrash = value), - new ToggleSettingsField( - "enable-anticheat", - "Enable AntiCheat (experimental)", - draft => draft.EnableAnticheat, - (draft, value) => draft.EnableAnticheat = value), new TextSettingsField( "message-of-the-day", "Message of the Day", draft => draft.MessageOfTheDay, (draft, value) => draft.MessageOfTheDay = value ?? string.Empty) }; + + // Anticheat toggles — rendered under an "Anticheat" divider in the menu. + public static IReadOnlyList AnticheatFields { get; } = new SettingsField[] + { + new ToggleSettingsField( + "block-throwing-spam", + "Block Throwing Spam", + draft => draft.BlockThrowingSpam, + (draft, value) => draft.BlockThrowingSpam = value), + new ToggleSettingsField( + "block-player-size-cheat", + "Block Player Size Cheat", + draft => draft.BlockPlayerSizeCheat, + (draft, value) => draft.BlockPlayerSizeCheat = value), + new ToggleSettingsField( + "block-flying-sleds", + "Block Flying Sleds", + draft => draft.BlockFlyingSleds, + (draft, value) => draft.BlockFlyingSleds = value) + }; + + // Chat name-prefix text fields by level — rendered under a "Chat Prefixes" divider in the menu. + public static IReadOnlyList PrefixFields { get; } = new SettingsField[] + { + new TextSettingsField( + "chat-prefix-mod", + "Chat Prefix: Mod", + draft => draft.PrefixMod, + (draft, value) => draft.PrefixMod = value ?? string.Empty, + placeholder: "<#7DFF7D>[Mod] "), + new TextSettingsField( + "chat-prefix-admin", + "Chat Prefix: Admin", + draft => draft.PrefixAdmin, + (draft, value) => draft.PrefixAdmin = value ?? string.Empty, + placeholder: "<#7DD0FF>[Admin] "), + new TextSettingsField( + "chat-prefix-owner", + "Chat Prefix: Owner", + draft => draft.PrefixOwner, + (draft, value) => draft.PrefixOwner = value ?? string.Empty, + placeholder: "<#FFE066>[Owner] ") + }; } } diff --git a/LobbyKit/LobbyKit.csproj b/LobbyKit/LobbyKit.csproj index d745ba7..0b6edb9 100644 --- a/LobbyKit/LobbyKit.csproj +++ b/LobbyKit/LobbyKit.csproj @@ -671,6 +671,7 @@ + diff --git a/LobbyKit/LobbyKitCore.cs b/LobbyKit/LobbyKitCore.cs index b306540..3839426 100644 --- a/LobbyKit/LobbyKitCore.cs +++ b/LobbyKit/LobbyKitCore.cs @@ -48,12 +48,10 @@ public class LobbyKitCore : MelonMod private static MelonPreferences_Entry _joinMessageSize; private static MelonPreferences_Entry _leaveMessageSize; private static MelonPreferences_Entry _autoRestartOnCrash; - private static MelonPreferences_Entry _enableAnticheat; - private static MelonPreferences_Entry _enforcePlayerScale; - private static MelonPreferences_Entry _blockSledPush; - private static MelonPreferences_Entry _prefixMod; - private static MelonPreferences_Entry _prefixAdmin; - private static MelonPreferences_Entry _prefixOwner; + private static MelonPreferences_Entry _blockThrowingSpam; + private static MelonPreferences_Entry _blockPlayerSizeCheat; + private static MelonPreferences_Entry _blockFlyingSleds; + private static MelonPreferences_Entry> _chatPrefixes; public static bool EnableGuestBangCommands => _enableGuestBangCommands?.Value ?? true; public static string ServerName => _serverName?.Value ?? string.Empty; @@ -77,18 +75,18 @@ public class LobbyKitCore : MelonMod public static int JoinMessageSize => _joinMessageSize?.Value ?? 75; public static int LeaveMessageSize => _leaveMessageSize?.Value ?? 75; public static bool AutoRestartOnCrash => _autoRestartOnCrash?.Value ?? false; - public static bool EnableAnticheat => _enableAnticheat?.Value ?? false; - public static bool EnforcePlayerScale => _enforcePlayerScale?.Value ?? true; - public static bool BlockSledPush => _blockSledPush?.Value ?? true; + public static bool BlockThrowingSpam => _blockThrowingSpam?.Value ?? true; + public static bool BlockPlayerSizeCheat => _blockPlayerSizeCheat?.Value ?? true; + public static bool BlockFlyingSleds => _blockFlyingSleds?.Value ?? true; - // Chat prefix shown before a player's name, by permission level. Empty string disables that level's prefix. - public static string ChatPrefixFor(Features.Permissions.PermLevel level) => level switch + // Chat prefixes by permission level, stored as one list [Mod, Admin, Owner] (index = (int)level - 1). + // Empty string disables that level's prefix. + public static string ChatPrefixFor(Features.Permissions.PermLevel level) { - Features.Permissions.PermLevel.Owner => _prefixOwner?.Value ?? string.Empty, - Features.Permissions.PermLevel.Admin => _prefixAdmin?.Value ?? string.Empty, - Features.Permissions.PermLevel.Mod => _prefixMod?.Value ?? string.Empty, - _ => string.Empty - }; + int idx = (int)level - 1; // Mod(1)->0, Admin(2)->1, Owner(3)->2 + var list = _chatPrefixes?.Value; + return (list != null && idx >= 0 && idx < list.Count && list[idx] != null) ? list[idx] : string.Empty; + } private PlayerReferenceManager _playerReferenceManager; @@ -142,12 +140,13 @@ public override unsafe void OnInitializeMelon() _joinMessageSize = _preferences.CreateEntry("JoinMessageSize", 75, "Join Message Size", "Font size percentage for join messages (e.g. 75 for 75%)."); _leaveMessageSize = _preferences.CreateEntry("LeaveMessageSize", 75, "Leave Message Size", "Font size percentage for leave messages (e.g. 75 for 75%)."); _autoRestartOnCrash = _preferences.CreateEntry("AutoRestartOnCrash", false, "Auto-Restart On Crash", "Automatically re-host the lobby when it crashes unexpectedly."); - _enableAnticheat = _preferences.CreateEntry("EnableAnticheat", false, "Enable AntiCheat", "Rate-limit and kick clients who spam server RPCs."); - _enforcePlayerScale = _preferences.CreateEntry("EnforcePlayerScale", true, "Enforce Player Scale", "Anticheat: force each player's avatar to their allowed size (default 1, or their !size choice) and clamp any cheated scale back."); - _blockSledPush = _preferences.CreateEntry("BlockSledPush", true, "Block Sled Push", "Anticheat: no-op the client-initiated Cmd_PushSled (a raw AddForce lever). Boosts use a separate path and are unaffected. Disable to allow manual sled pushing."); - _prefixMod = _preferences.CreateEntry("ChatPrefixMod", "<#7DFF7D>[Mod] ", "Chat Prefix: Mod", "Prefix shown before a Mod's name in chat. Empty to disable."); - _prefixAdmin = _preferences.CreateEntry("ChatPrefixAdmin", "<#7DD0FF>[Admin] ", "Chat Prefix: Admin", "Prefix shown before an Admin's name in chat. Empty to disable."); - _prefixOwner = _preferences.CreateEntry("ChatPrefixOwner", "<#FFE066>[Owner] ", "Chat Prefix: Owner", "Prefix shown before an Owner's name in chat. Empty to disable."); + _blockThrowingSpam = _preferences.CreateEntry("BlockThrowingSpam", false, "Block Throwing Spam", "Anticheat: rate-limit and kick clients who spam server RPCs (e.g. rapid throwing)."); + _blockPlayerSizeCheat = _preferences.CreateEntry("BlockPlayerSizeCheat", true, "Block Player Size Cheat", "Anticheat: force each player's avatar to their allowed size (default 1, or their !size choice) and clamp any cheated scale back."); + _blockFlyingSleds = _preferences.CreateEntry("BlockFlyingSleds", true, "Block Flying Sleds", "Anticheat: no-op the client-initiated Cmd_PushSled (a raw AddForce lever used to fly sleds). Boosts use a separate path and are unaffected."); + _chatPrefixes = _preferences.CreateEntry("ChatPrefixes", + new System.Collections.Generic.List { "<#7DFF7D>[Mod] ", "<#7DD0FF>[Admin] ", "<#FFE066>[Owner] " }, + "Chat Prefixes", + "Prefixes shown before a name in chat, by level: [Mod, Admin, Owner]. Empty string disables that level's prefix."); Features.Permissions.Perms.Initialize(); Patches.ChatSystem.SeedCommandLevels(); @@ -198,7 +197,7 @@ private static void MigrateServerSetting(MelonPreferences_Category oldCategor // Runs after all gameplay Updates each frame; re-asserts player scale so a size cheat can't stick. public override void OnLateUpdate() { - if (EnforcePlayerScale) + if (BlockPlayerSizeCheat) Features.Anticheat.PlayerScaleEnforcer.Tick(); } @@ -798,12 +797,39 @@ public static void SetLeaveMessageSize(int value) MelonPreferences.Save(); } - public static void SetEnableAnticheat(bool value) + public static void SetBlockThrowingSpam(bool value) { - if (_enableAnticheat == null) - return; + if (_blockThrowingSpam == null) return; + _blockThrowingSpam.Value = value; + MelonPreferences.Save(); + } + + public static void SetBlockPlayerSizeCheat(bool value) + { + if (_blockPlayerSizeCheat == null) return; + _blockPlayerSizeCheat.Value = value; + MelonPreferences.Save(); + } + + public static void SetBlockFlyingSleds(bool value) + { + if (_blockFlyingSleds == null) return; + _blockFlyingSleds.Value = value; + MelonPreferences.Save(); + } + + // Sets a single level's chat prefix within the ChatPrefixes list (index = (int)level - 1), growing the + // list as needed so any of Mod/Admin/Owner can be set independently. + public static void SetChatPrefix(Features.Permissions.PermLevel level, string value) + { + if (_chatPrefixes == null) return; + int idx = (int)level - 1; // Mod(1)->0, Admin(2)->1, Owner(3)->2 + if (idx < 0) return; - _enableAnticheat.Value = value; + var list = _chatPrefixes.Value ?? new System.Collections.Generic.List(); + while (list.Count <= idx) list.Add(string.Empty); + list[idx] = value ?? string.Empty; + _chatPrefixes.Value = list; MelonPreferences.Save(); } From d68184dac9febaa651fad4180e2f7fb02fee013d Mon Sep 17 00:00:00 2001 From: rdavis Date: Mon, 8 Jun 2026 01:18:07 -0500 Subject: [PATCH 20/23] Fix bug with pressing escape while settings are open --- .../Settings/SettingsMenuController.cs | 51 ++++++++++++++----- LobbyKit/Features/Settings/SettingsPatches.cs | 13 +++-- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/LobbyKit/Features/Settings/SettingsMenuController.cs b/LobbyKit/Features/Settings/SettingsMenuController.cs index 8c06ba1..e528650 100644 --- a/LobbyKit/Features/Settings/SettingsMenuController.cs +++ b/LobbyKit/Features/Settings/SettingsMenuController.cs @@ -19,6 +19,7 @@ internal sealed class SettingsMenuController private SettingsDraft _draft; private SettingsDraft _cleanDraft; private Action _dirtyStateChanged; + private bool _closing; // true while we tear the menu down ourselves, so the CloseMenu prefix doesn't re-intercept public static SettingsMenuController Instance { get; } = new SettingsMenuController(); @@ -135,25 +136,47 @@ private void CloseWithoutPrompt() return; } - UiReferenceController.Instance?.CloseAllOpenMenus(false); - _confirmationRoot = null; - Object.Destroy(_root); - _root = null; - _firstSelectable = null; - _draft = null; - _cleanDraft = null; - _dirtyStateChanged = null; + _closing = true; + try + { + UiReferenceController.Instance?.CloseAllOpenMenus(false); + _confirmationRoot = null; + Object.Destroy(_root); + _root = null; + _firstSelectable = null; + _draft = null; + _cleanDraft = null; + _dirtyStateChanged = null; - ForceCloseChat(); - RestoreClosedCursorState(); + ForceCloseChat(); + RestoreClosedCursorState(); + } + finally + { + _closing = false; + } } - public void HandleEscape() + // Invoked from a Harmony prefix on UiReferenceController.CloseMenu (the game's Escape / back action). + // Returns false to SUPPRESS the game's close because we handle it ourselves; true to let the game close + // normally (our menu isn't involved, or we're already closing it). + public bool InterceptGameCloseMenu(UiReferenceController uiController) { - if (_root == null || !UnityEngine.Input.GetKeyDown(KeyCode.Escape)) - return; - + if (_root == null || _closing) + return true; + + // Only intercept when our settings menu is the menu actually being closed (the current top menu). + UiReferenceController.UiToggleableMenu top = uiController != null + ? uiController.GetCurrentOpenMenu() + : default; + if (top.panel != _root) + return true; + + // Behave exactly like the Close (X) button: prompt if dirty, else close cleanly. The game calls + // CloseMenu repeatedly (twice per Escape press), so this MUST be idempotent — RequestClose only + // creates/re-shows the prompt and never toggles it off, so the modal stays put instead of flickering. RequestClose(); + return false; } private void ShowCloseConfirmation() diff --git a/LobbyKit/Features/Settings/SettingsPatches.cs b/LobbyKit/Features/Settings/SettingsPatches.cs index 3499f51..57d9270 100644 --- a/LobbyKit/Features/Settings/SettingsPatches.cs +++ b/LobbyKit/Features/Settings/SettingsPatches.cs @@ -7,11 +7,16 @@ namespace LobbyKit.Patches [HarmonyPatch] public static class SettingsPatches { - [HarmonyPatch(typeof(UiReferenceController), "Update")] - [HarmonyPostfix] - private static void UiReferenceController_Update_Postfix() + // The game closes the current (top) menu on Escape / the in-game back action via + // UiReferenceController.CloseMenu. While OUR settings menu is the top menu, route that through our own + // close logic so Escape behaves exactly like the in-menu Close (X) button: prompt when there are + // unapplied changes, otherwise close cleanly — instead of the game silently discarding the menu (which + // also left the controller's state stale and corrupted the next open). + [HarmonyPatch(typeof(UiReferenceController), "CloseMenu")] + [HarmonyPrefix] + private static bool CloseMenu_Prefix(UiReferenceController __instance) { - SettingsMenuController.Instance.HandleEscape(); + return SettingsMenuController.Instance.InterceptGameCloseMenu(__instance); } } } From 86ccbced204489d9f1650050863e18b1e959f555 Mon Sep 17 00:00:00 2001 From: rdavis Date: Mon, 8 Jun 2026 01:30:03 -0500 Subject: [PATCH 21/23] Update settings command; remove lobby settings --- LobbyKit/ChatSystem.cs | 4 ++-- .../Commands/ChatSystem.SettingsCommands.cs | 19 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/LobbyKit/ChatSystem.cs b/LobbyKit/ChatSystem.cs index 6957c1c..f05d55c 100644 --- a/LobbyKit/ChatSystem.cs +++ b/LobbyKit/ChatSystem.cs @@ -66,8 +66,8 @@ public CommandDefinition( hiddenFromHelp: true), ["!settings"] = new CommandDefinition( HandleSettingsCommand, - "!settings [key] [value]", - "View or change LobbyKit settings (e.g. !settings bc on).", + "!settings [?|key] [value]", + "List settings with '!settings ?', or change e.g. !settings bc on.", minLevel: PermLevel.Admin), [MotdCommand] = new CommandDefinition( HandleMotdCommand, diff --git a/LobbyKit/Features/Commands/ChatSystem.SettingsCommands.cs b/LobbyKit/Features/Commands/ChatSystem.SettingsCommands.cs index 7f5b081..2ff911e 100644 --- a/LobbyKit/Features/Commands/ChatSystem.SettingsCommands.cs +++ b/LobbyKit/Features/Commands/ChatSystem.SettingsCommands.cs @@ -78,15 +78,7 @@ private sealed class ChatSetting // The full editable-setting registry, grouped by section (order preserved for the list view). private static readonly List SettingsRegistry = new() { - SText("name", "Lobby", () => LobbyKitCore.ServerName, LobbyKitCore.SetServerName), - SInt ("cap", "Lobby", () => LobbyKitCore.ServerCapacity, LobbyKitCore.SetServerCapacity, 1, 64), - SBool("public", "Lobby", () => LobbyKitCore.IsPublicLobby, LobbyKitCore.SetIsPublicLobby), - SBool("password", "Lobby", () => LobbyKitCore.IsPasswordProtected, LobbyKitCore.SetIsPasswordProtected), - SText("pw", "Lobby", () => LobbyKitCore.LobbyPassword, LobbyKitCore.SetLobbyPassword, mask: true), - SBool("peaceful", "Lobby", () => LobbyKitCore.IsPeacefulMode, LobbyKitCore.SetIsPeacefulMode), - SBool("textchat", "Lobby", () => LobbyKitCore.IsTextChatOnly, LobbyKitCore.SetIsTextChatOnly), - - SBool("bc", "Mod", () => LobbyKitCore.EnableGuestBangCommands, LobbyKitCore.SetEnableGuestBangCommands), + SBool("bangcommands", "Mod", () => LobbyKitCore.EnableGuestBangCommands, LobbyKitCore.SetEnableGuestBangCommands), SBool("autorestart", "Mod", () => LobbyKitCore.AutoRestartOnCrash, LobbyKitCore.SetAutoRestartOnCrash), SText("motd", "Messages", () => LobbyKitCore.MessageOfTheDay, LobbyKitCore.SetMessageOfTheDay), @@ -117,6 +109,13 @@ private static void HandleSettingsCommand(PlayerControl playerControl, string ar { string a = args?.Trim() ?? string.Empty; + // "!settings ?" always shows the chat list, even for the local host (who otherwise gets the UI menu). + if (a == "?") + { + ShowSettingsList(playerControl, 1); + return; + } + // No args: a local host (with a real UI) opens the menu; non-host admins (and headless) get the list. if (a.Length == 0) { @@ -138,7 +137,7 @@ private static void HandleSettingsCommand(PlayerControl playerControl, string ar (string key, string value) = SplitFirstWord(a); if (!SettingsByKey.TryGetValue(key, out ChatSetting setting)) { - Reply(playerControl, $"<#FA0>Unknown setting: {key} (try !settings)"); + Reply(playerControl, $"<#FA0>Unknown setting: {key} (try `!settings ?`)"); return; } From 20426771696673b636c1668bdd70476a177e5173 Mon Sep 17 00:00:00 2001 From: rdavis Date: Mon, 8 Jun 2026 10:34:34 -0500 Subject: [PATCH 22/23] fix perms and fix help formatter --- LobbyKit/ChatSystem.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LobbyKit/ChatSystem.cs b/LobbyKit/ChatSystem.cs index f05d55c..f442138 100644 --- a/LobbyKit/ChatSystem.cs +++ b/LobbyKit/ChatSystem.cs @@ -68,7 +68,7 @@ public CommandDefinition( HandleSettingsCommand, "!settings [?|key] [value]", "List settings with '!settings ?', or change e.g. !settings bc on.", - minLevel: PermLevel.Admin), + minLevel: PermLevel.Owner), [MotdCommand] = new CommandDefinition( HandleMotdCommand, "!motd [message]", @@ -119,7 +119,7 @@ public CommandDefinition( HandleLevelCommand, "!level [name]", "Show your level, or another player's.", - minLevel: PermLevel.Mod), + hiddenFromHelp: true), ["!confirm"] = new CommandDefinition( HandleConfirmCommand, "!confirm", @@ -595,7 +595,7 @@ private static string HelpLine(string text, bool header = false, bool warn = fal private static string HelpTruncate(string s, int max) { if (string.IsNullOrEmpty(s) || s.Length <= max) return s; - return max <= 1 ? s.Substring(0, max) : s.Substring(0, max - 1) + "…"; + return max <= 1 ? s.Substring(0, max) : s.Substring(0, max - 2) + "…"; } // Greedy word-wrap of PLAIN text (no rich-text tags) to lines of at most maxChars, capped at maxLines. From d3c9415303b6f889f36472c88d5df4fad56e3912 Mon Sep 17 00:00:00 2001 From: rdavis Date: Thu, 11 Jun 2026 00:23:29 -0500 Subject: [PATCH 23/23] log chat commands and don't anticheat players that left the server --- CHANGELOG.md | 30 ++++++++++ LobbyKit/ChatSystem.cs | 10 ++++ LobbyKit/Directory.Build.props | 2 +- .../Features/Anticheat/PlayerScaleEnforcer.cs | 25 +++++--- .../Anticheat/PlayerScalePacketClamp.cs | 2 +- LobbyKit/Features/Settings/SettingsSchema.cs | 6 +- LobbyKit/LobbyKitCore.cs | 15 +++-- README.md | 60 +++++++++++++++---- thunderstore.toml | 2 +- 9 files changed, 123 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d54f492..9f459b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to this project will be documented in this file. +## [1.1.0] - 2026-06-08 + +### Added + +- Permission system backed by `UserData/LobbyKit-permissions.json`, keyed on EOS PUID so levels and bans survive reconnects. Levels: Everyone, Mod, Admin, Owner (the host is always Owner). +- `!op [level] [name]` to set a player's level and `!level [name]` to view one (Owner; `!op` requires `!confirm`). +- Per-command required levels, editable in the permissions file (set a level above Owner to disable a command). +- Moderation commands: `!kick [name] [reason]` (Mod), `!ban [name|puid] [reason]` and `!unban [name|puid]` (Admin). Names resolve exact-first then fuzzy, with a PUID guard; bans accept an offline PUID. +- `!size [0.2-3.0]` to set your own player size (`!size 1` resets). +- Anticheat: throwing-spam rate limiting, player-size cheat packet clamping (also enforced on join), and flying-sled blocking, each individually toggleable. +- Chat-driven settings: `!settings ?` lists all settings, `!settings [value]` views or changes one. +- Configurable chat level prefixes for Mod, Admin, and Owner. +- Paged `!help`, with `!help ` and `!help ` for details. +- Auto-restart support for the hosted lobby. +- Crash protection and silent-crash detection, plus despawning of hacked trinkets. + +### Changed + +- Settings are now adjustable from chat; standalone `!bc` was removed in favour of `!settings bc `. +- Settings menu gained Anticheat and Chat Prefixes sections. +- Commands and features split into per-area files under `Features/`. + +### Fixed + +- Cannons intermittently launching no one after a player rolled a snowball. +- Player-name resolution for commands. +- Player size not applying when a player joined. +- Message of the day not showing for the host's lobby. +- Pressing Escape with unapplied settings changes now prompts to confirm instead of silently discarding the menu state. + ## [1.0.0] - 2026-05-28 ### Added diff --git a/LobbyKit/ChatSystem.cs b/LobbyKit/ChatSystem.cs index f442138..5935ab7 100644 --- a/LobbyKit/ChatSystem.cs +++ b/LobbyKit/ChatSystem.cs @@ -314,6 +314,7 @@ private static bool TryHandleCommand(string message, int connectionId, bool isHo int requiredLevel = Perms.GetCommandLevel(commandName, (int)command.MinLevel); if ((int)level < requiredLevel) { + try { MelonLogger.Msg($"[LobbyKit] command DENIED (needs lvl {requiredLevel}) | {CommandSenderName(connectionId, isHostLocal)} ({level}): {commandName}{(string.IsNullOrEmpty(args) ? string.Empty : " " + args)}"); } catch { } BroadcastMessage(connectionId, requiredLevel > (int)PermLevel.Owner ? "<#FA0>That command is disabled." : "<#F00>You don't have permission for that command."); @@ -337,6 +338,10 @@ private static bool TryHandleCommand(string message, int connectionId, bool isHo if (!string.Equals(trimmedMessage, "!!", StringComparison.Ordinal)) LastExplicitCommandBySource[connectionId] = trimmedMessage; + // Log every command invocation (after it passed the permission/guest gates). + try { MelonLogger.Msg($"[LobbyKit] command | {CommandSenderName(connectionId, isHostLocal)} ({level}): {commandName}{(string.IsNullOrEmpty(args) ? string.Empty : " " + args)}"); } + catch { } + try { command.Handler(playerControl, args); @@ -350,6 +355,11 @@ private static bool TryHandleCommand(string message, int connectionId, bool isHo return true; } + // Clean display name for command logs; falls back to "host" / "conn N" when the reference isn't found. + private static string CommandSenderName(int connectionId, bool isHostLocal) + => StripRichText(Utils.FindPlayerFromConnectionId(connectionId)?.Username) + ?? (isHostLocal ? "host" : $"conn {connectionId}"); + private static IEnumerator SendMotdWhenReady(int connectionId) { bool isLocal = IsLocalConnectionId(connectionId); diff --git a/LobbyKit/Directory.Build.props b/LobbyKit/Directory.Build.props index 7b53510..0544124 100755 --- a/LobbyKit/Directory.Build.props +++ b/LobbyKit/Directory.Build.props @@ -14,7 +14,7 @@ - 1.0.0 + 1.1.0 Spyci diff --git a/LobbyKit/Features/Anticheat/PlayerScaleEnforcer.cs b/LobbyKit/Features/Anticheat/PlayerScaleEnforcer.cs index e1d2de8..29402bb 100644 --- a/LobbyKit/Features/Anticheat/PlayerScaleEnforcer.cs +++ b/LobbyKit/Features/Anticheat/PlayerScaleEnforcer.cs @@ -6,7 +6,9 @@ namespace LobbyKit.Features.Anticheat { - // Server-side anticheat: keeps every non-host player's avatar root at scale 1,1,1 on the SERVER. + // Server-side anticheat: keeps every non-host player's avatar root at their REGISTERED size on the SERVER + // (PlayerSizeRegistry.GetSize, default 1,1,1 — a player's chosen !size is their registered value, so this + // enforces it rather than fighting it back to 1). // // Design (LobbyKit is server-first): // - Runs ONLY on the host (InstanceFinder.IsServerStarted). No-op on pure clients. @@ -46,31 +48,38 @@ public static void Tick() foreach (var pc in _cache) { - if (pc == null) + if (pc == null || !pc.IsSpawned) // skip despawned/pooled objects (a left player's nob lingers) continue; int owner; try { owner = pc.OwnerId; } catch { continue; } - if (owner == hostConnId) // never affect the host's own player + // Skip the host's own player AND server-owned / despawning objects (OwnerId -1 after a player + // leaves): those aren't real remote clients, and clamping them spams the log post-leave. + if (owner == hostConnId || owner < 0) continue; + // Target = the player's REGISTERED size (their !size choice, default 1.0) — NOT a hardcoded 1, + // so a legitimate !size is enforced instead of being fought back to 1 (which spammed the log + // and reset the server copy / new-joiner baseline every frame). Mirrors PlayerScalePacketClamp. + float target = PlayerSizeRegistry.GetSize(owner); + Transform t = pc.transform; Vector3 s = t.localScale; - if (Mathf.Abs(s.x - 1f) <= Tolerance && Mathf.Abs(s.y - 1f) <= Tolerance && Mathf.Abs(s.z - 1f) <= Tolerance) + if (Mathf.Abs(s.x - target) <= Tolerance && Mathf.Abs(s.y - target) <= Tolerance && Mathf.Abs(s.z - target) <= Tolerance) continue; - t.localScale = Vector3.one; // clamp the server's copy only — no ownership transfer - LogCorrection(owner, s); + t.localScale = new Vector3(target, target, target); // clamp the server's copy only — no ownership transfer + LogCorrection(owner, s, target); } } - private static void LogCorrection(int owner, Vector3 was) + private static void LogCorrection(int owner, Vector3 was, float target) { float now = Time.unscaledTime; if (_lastLoggedByOwner.TryGetValue(owner, out float last) && now - last < 5f) return; _lastLoggedByOwner[owner] = now; - MelonLogger.Msg($"[LobbyKit] Anticheat: player size (client={owner}) was ({was.x:F2},{was.y:F2},{was.z:F2}) — clamped server copy to 1,1,1."); + MelonLogger.Msg($"[LobbyKit] Anticheat: player size (client={owner}) was ({was.x:F2},{was.y:F2},{was.z:F2}) — clamped server copy to {target:0.##}."); } } } diff --git a/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs b/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs index 1af380d..55d63eb 100644 --- a/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs +++ b/LobbyKit/Features/Anticheat/PlayerScalePacketClamp.cs @@ -89,7 +89,7 @@ private static void Prefix(NetworkTransform __instance, Il2CppSystem.ArraySegmen try { hostConnId = InstanceFinder.ClientManager.Connection.ClientId; } catch { } int owner; try { owner = __instance.OwnerId; } catch { return; } - if (owner == hostConnId) return; // never touch the host's own player + if (owner == hostConnId || owner < 0) return; // never touch the host's own / server-owned players if (__instance.GetComponent() == null) return; // players only float target = PlayerSizeRegistry.GetSize(owner); // their !size choice, default 1.0 diff --git a/LobbyKit/Features/Settings/SettingsSchema.cs b/LobbyKit/Features/Settings/SettingsSchema.cs index 12c293c..227e37c 100644 --- a/LobbyKit/Features/Settings/SettingsSchema.cs +++ b/LobbyKit/Features/Settings/SettingsSchema.cs @@ -125,19 +125,19 @@ internal static class SettingsSchema "Chat Prefix: Mod", draft => draft.PrefixMod, (draft, value) => draft.PrefixMod = value ?? string.Empty, - placeholder: "<#7DFF7D>[Mod] "), + placeholder: "<#7DFF7D>[M] "), new TextSettingsField( "chat-prefix-admin", "Chat Prefix: Admin", draft => draft.PrefixAdmin, (draft, value) => draft.PrefixAdmin = value ?? string.Empty, - placeholder: "<#7DD0FF>[Admin] "), + placeholder: "<#7DD0FF>[A] "), new TextSettingsField( "chat-prefix-owner", "Chat Prefix: Owner", draft => draft.PrefixOwner, (draft, value) => draft.PrefixOwner = value ?? string.Empty, - placeholder: "<#FFE066>[Owner] ") + placeholder: "<#FFE066>[O] ") }; } } diff --git a/LobbyKit/LobbyKitCore.cs b/LobbyKit/LobbyKitCore.cs index 3839426..963b752 100644 --- a/LobbyKit/LobbyKitCore.cs +++ b/LobbyKit/LobbyKitCore.cs @@ -140,11 +140,11 @@ public override unsafe void OnInitializeMelon() _joinMessageSize = _preferences.CreateEntry("JoinMessageSize", 75, "Join Message Size", "Font size percentage for join messages (e.g. 75 for 75%)."); _leaveMessageSize = _preferences.CreateEntry("LeaveMessageSize", 75, "Leave Message Size", "Font size percentage for leave messages (e.g. 75 for 75%)."); _autoRestartOnCrash = _preferences.CreateEntry("AutoRestartOnCrash", false, "Auto-Restart On Crash", "Automatically re-host the lobby when it crashes unexpectedly."); - _blockThrowingSpam = _preferences.CreateEntry("BlockThrowingSpam", false, "Block Throwing Spam", "Anticheat: rate-limit and kick clients who spam server RPCs (e.g. rapid throwing)."); + _blockThrowingSpam = _preferences.CreateEntry("BlockThrowingSpam", true, "Block Throwing Spam", "Anticheat: rate-limit and kick clients who spam server RPCs (e.g. rapid throwing)."); _blockPlayerSizeCheat = _preferences.CreateEntry("BlockPlayerSizeCheat", true, "Block Player Size Cheat", "Anticheat: force each player's avatar to their allowed size (default 1, or their !size choice) and clamp any cheated scale back."); _blockFlyingSleds = _preferences.CreateEntry("BlockFlyingSleds", true, "Block Flying Sleds", "Anticheat: no-op the client-initiated Cmd_PushSled (a raw AddForce lever used to fly sleds). Boosts use a separate path and are unaffected."); _chatPrefixes = _preferences.CreateEntry("ChatPrefixes", - new System.Collections.Generic.List { "<#7DFF7D>[Mod] ", "<#7DD0FF>[Admin] ", "<#FFE066>[Owner] " }, + new System.Collections.Generic.List { "<#7DFF7D>[M] ", "<#7DD0FF>[A] ", "<#FFE066>[O] " }, "Chat Prefixes", "Prefixes shown before a name in chat, by level: [Mod, Admin, Owner]. Empty string disables that level's prefix."); @@ -204,7 +204,10 @@ public override void OnLateUpdate() // Number of connected non-host players (excludes the host's own clientHost connection 32767). private int RemotePlayerCount => players.Count(pl => pl != null && pl.ConnectionID != 32767); - // Periodically logs the connected player count (and names) while hosting, as a server heartbeat. + // Last "Players online" line we logged, so the periodic check only emits when the roster changes. + private string _lastPlayerCountLog; + + // Periodically checks the connected player count (and names) while hosting, logging only when it changes. private IEnumerator PlayerCountLogLoop() { while (true) @@ -218,7 +221,11 @@ private IEnumerator PlayerCountLogLoop() .Where(pl => pl != null && pl.ConnectionID != 32767) .Select(pl => Patches.ChatSystem.StripRichText(string.IsNullOrWhiteSpace(pl.Username) ? "?" : pl.Username))) + "]" : ""; - MelonLogger.Msg($"[LobbyKit] Players online: {n}{list}"); + string line = $"[LobbyKit] Players online: {n}{list}"; + if (line == _lastPlayerCountLog) + continue; + _lastPlayerCountLog = line; + MelonLogger.Msg(line); } } diff --git a/README.md b/README.md index 6e3bed5..a14c760 100644 --- a/README.md +++ b/README.md @@ -15,20 +15,56 @@ LobbyKit is primarily a host/server-side mod. Most functionality (especially cha - Show live session timers beside player names in the player list. - Send join and leave messages with configurable size. - Send an optional message of the day to players who join your hosted lobby. -- Use chat commands for settings and teleport workflows. +- Grant trusted players Mod/Admin/Owner permissions that survive reconnects, with per-command level requirements. +- Moderate your lobby with kick, ban, and unban commands. +- Block common cheats: throwing spam, player-size manipulation, and flying sleds (each toggleable). +- Optionally auto-restart your hosted lobby. +- Use chat commands for settings, moderation, permissions, and teleport workflows. ## Chat Commands -| Command | Host only | Description | +Commands require a minimum permission level. The host is always Owner, and you can grant other players levels with `!op`. Default levels are shown below; each command's required level can be changed in `UserData/LobbyKit-permissions.json`. + +| Command | Min level | Description | | --- | --- | --- | -| `!!` | No | Repeat last command | -| `!settings` | Yes | Opens the in-game LobbyKit settings menu. | -| `!motd [message]` | Set: yes | Shows the message of the day. Hosts can pass a message to update it. | -| `!bc ` | Yes | Enables or disables guest bang commands. | -| `!tp ` | No | Teleport to a player by name. | -| `!tpme ` | No | Ask another player to teleport to you. | -| `!tpa` | No | Accept a pending teleport request. | -| `!tpf ` | Yes | Force a player to teleport to you. | +| `!!` | Everyone | Repeat your last command. | +| `!help [command\|page]` | Everyone | List commands (paged), or show details for one command. | +| `!motd [message]` | Everyone | Show the message of the day. Admins can pass text to set it. | +| `!tp [name]` | Everyone | Teleport to a player by name. | +| `!tpme [name]` | Everyone | Ask a player to teleport to you. | +| `!tpa` | Everyone | Accept a pending teleport request. | +| `!size [0.2-3.0]` | Everyone | Set your player size (`!size 1` resets to normal). | +| `!level [name]` | Everyone | Show your level, or another player's. | +| `!tpf [name]` | Mod | Force a player to teleport to you. | +| `!kick [name] [reason]` | Mod | Kick a player. | +| `!ban [name\|puid] [reason]` | Admin | Ban a player (use `!unban` to reverse). | +| `!unban [name\|puid]` | Admin | Remove a ban by name or PUID. | +| `!settings [?\|key] [value]` | Owner | List settings with `!settings ?`, or change one, e.g. `!settings bc on`. | +| `!op [level] [name]` | Owner | Set a player's level (everyone/mod/admin/owner). Requires `!confirm`. | + +### Changing or disabling commands per level + +The required level for every command lives under `commandLevels` in `UserData/LobbyKit-permissions.json`. Levels are `Everyone = 0`, `Mod = 1`, `Admin = 2`, `Owner = 3`. Edit the file while the server is **stopped** (there is no hot-reload), then restart. + +- **Lower the bar** so more players can use a command, e.g. let anyone tp: + + ```json + "commandLevels": { "!tp": 0 } + ``` + +- **Raise the bar** to restrict a command, e.g. make teleporting Mod-only: + + ```json + "commandLevels": { "!tp": 1 } + ``` + +- **Disable a command** entirely by setting a level above Owner (e.g. `4`): + + ```json + "commandLevels": { "!size": 4 } + ``` + +Every command is listed in the file on first run, so you can see and adjust each one. The host (the server itself) is always Owner and can use every enabled command. ## Installation @@ -47,7 +83,9 @@ For hosted-lobby features, install LobbyKit on the host machine; clients typical ## Configuration -Most options are available in-game through `!settings` while hosting. The mod also stores preferences through MelonLoader under the `LobbyKit` category. +Most options are available through `!settings` while hosting — open the in-game menu locally, or list and change settings from chat with `!settings ?` and `!settings [value]`. The mod stores preferences through MelonLoader under the `LobbyKit` category. + +Player permissions and bans are stored in `UserData/LobbyKit-permissions.json`, keyed on EOS PUID. Edit it while the server is stopped (no hot-reload), or manage levels and bans live with `!op`, `!ban`, and `!unban`. ## CI/CD (Thunderstore) diff --git a/thunderstore.toml b/thunderstore.toml index f0313ba..9fc342b 100644 --- a/thunderstore.toml +++ b/thunderstore.toml @@ -4,7 +4,7 @@ schemaVersion = "0.0.1" [package] namespace = "Spyci" name = "LobbyKit" -versionNumber = "1.0.0" +versionNumber = "1.1.0" description = "Host and lobby quality-of-life tools for Sledding Game: bigger lobbies, saved lobby/search settings, player timers, MOTD, and teleport commands." websiteUrl = "https://github.com/ricky-davis/SGLobbyKit" containsNsfwContent = false