Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
75dc607
Rename thunderstore release workflow to publish
ricky-davis May 28, 2026
257fa35
Update README.md to clarify host requirements and installation notes
ricky-davis May 28, 2026
15a99a9
Update README
ricky-davis May 28, 2026
51c65f7
first go at autorestart
ricky-davis May 29, 2026
029e574
anticheat
ricky-davis May 30, 2026
8b0fa11
fixed MOTD for host
ricky-davis May 30, 2026
18194c8
Add setting and host exclusion
ricky-davis May 30, 2026
040e361
clean up todo
ricky-davis May 30, 2026
2371bb1
add funding.yml
ricky-davis May 30, 2026
b713839
added more crash logging and despawn hacked trinkets
ricky-davis May 30, 2026
de3831c
log connection ID
ricky-davis May 31, 2026
b2f75d2
use [] instead of <> to prevent autoclosing
ricky-davis May 31, 2026
92ecec4
fix player timers?
ricky-davis May 31, 2026
0063540
Add size command, update logging methods, block pushsled
ricky-davis Jun 6, 2026
f0cf78f
Fixed size not applying on player join
ricky-davis Jun 6, 2026
fc9aee7
migrate settings
ricky-davis Jun 6, 2026
e2c012c
Split commands into own files and add perms
ricky-davis Jun 7, 2026
c22ccec
Fixes cannon not working intermittently, fixes command player name re…
ricky-davis Jun 8, 2026
7ef3a5e
add settings change
ricky-davis Jun 8, 2026
d68184d
Fix bug with pressing escape while settings are open
ricky-davis Jun 8, 2026
86ccbce
Update settings command; remove lobby settings
ricky-davis Jun 8, 2026
2042677
fix perms and fix help formatter
ricky-davis Jun 8, 2026
d3c9415
log chat commands and don't anticheat players that left the server
ricky-davis Jun 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ko_fi: rickydavis
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key> [value]` views or changes one.
- Configurable chat level prefixes for Mod, Admin, and Owner.
- Paged `!help`, with `!help <page>` and `!help <command>` 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 <on|off>`.
- 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
Expand Down
457 changes: 293 additions & 164 deletions LobbyKit/ChatSystem.cs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion LobbyKit/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</PropertyGroup>
<PropertyGroup>
<!-- Centralized package/project version and author -->
<Version>1.0.0</Version>
<Version>1.1.0</Version>
<Authors>Spyci</Authors>
</PropertyGroup>
</Project>
179 changes: 179 additions & 0 deletions LobbyKit/Features/Anticheat/GenericServerRpcRateLimitPatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
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;

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

// 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<string, float> _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 "___<hash>"
// so logs read "ThrowObject" instead of "RpcLogic___Cmd_ThrowObject___1971385254".
private static readonly Dictionary<int, string> _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;

if (ownerId == HostConnectionId || !LobbyKitCore.BlockThrowingSpam)
return true;
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)
VerboseLog.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;
VerboseLog.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);
}
}
}
}
24 changes: 24 additions & 0 deletions LobbyKit/Features/Anticheat/KickAnnouncer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Collections.Generic;

namespace LobbyKit.Features.Anticheat
{
internal static class KickAnnouncer
{
private static readonly Dictionary<int, string> _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;
}
}
}
85 changes: 85 additions & 0 deletions LobbyKit/Features/Anticheat/PlayerScaleEnforcer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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 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.
// - 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<int, float> _lastLoggedByOwner = new();

public static void Tick()
{
if (!InstanceFinder.IsServerStarted) // host-only
return;

_timer -= Time.unscaledDeltaTime;
if (_cache == null || _timer <= 0f)
{
_cache = UnityEngine.Object.FindObjectsOfType<PlayerControl>();
_timer = CheckIntervalSeconds;
}

int hostConnId = 32767;
try { hostConnId = InstanceFinder.ClientManager.Connection.ClientId; } catch { }

foreach (var pc in _cache)
{
if (pc == null || !pc.IsSpawned) // skip despawned/pooled objects (a left player's nob lingers)
continue;

int owner;
try { owner = pc.OwnerId; } catch { continue; }
// 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 - target) <= Tolerance && Mathf.Abs(s.y - target) <= Tolerance && Mathf.Abs(s.z - target) <= Tolerance)
continue;

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, 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 {target:0.##}.");
}
}
}
Loading
Loading