namespace Aeshnidae.FairPlay; /// /// Admin-only: these expose which accounts share an address, which is exactly the /// sort of thing that should not be readable by players. /// public static class Commands { [CommandHandler("fairplay", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Fair-play rules: status, shared addresses, per-account history.", "/fairplay [ shared | linked | account | ip
]")] public static void HandleFairPlay(Session session, params string[] parameters) { try { var verb = parameters.Length > 0 ? parameters[0].ToLowerInvariant() : "status"; switch (verb) { case "shared": Shared(session); return; case "linked": Linked(session); return; case "account" when parameters.Length > 1: Account(session, parameters[1]); return; case "ip" when parameters.Length > 1: Address(session, parameters[1]); return; default: Status(session); return; } } catch (Exception ex) { Reply(session, $"/fairplay failed: {ex.Message}"); ModManager.Log($"[{Mod.Name}] /fairplay failed: {ex}", ModManager.LogLevel.Error); } } private static void Status(Session? session) { var s = Mod.Settings; var text = new StringBuilder() .AppendLine($"{Mod.Name} v{Mod.Container?.Meta.Version ?? "?"} - {Mod.Container?.Status.ToString() ?? "not loaded"}") .AppendLine($"Enabled: {s.Enabled}") .AppendLine($"Max online per address: {s.MaxConcurrentPerAddress} (exempt at {s.ExemptLevel}+)") .AppendLine($"Character creation cap: {PropertyManager.GetLong("max_chars_per_account").Item}") .AppendLine($"One char outside Marketplace: {s.OneCharacterOutsideMarketplace} (landblock 0x{s.Marketplace.Landblock})") .AppendLine($"Same-IP allegiance blocked: {s.BlockSameIpAllegiance}") .AppendLine($"Login history: {Mod.Logins?.KnownAccounts ?? 0} accounts across {Mod.Logins?.KnownAddresses ?? 0} addresses") .AppendLine($"Since start - logins {Mod.LoginsRecorded}, bounces {Mod.Bounces}, forced logouts {Mod.LogoutsForced}, pledges blocked {Mod.PledgesBlocked}, linked trades {Mod.LinkedTrades}, ground transfers {Mod.GroundTransfers}, vendor handovers {Mod.VendorHandovers}"); var shared = Mod.Logins?.Shared(s.FlagAccountsPerAddress) ?? new(); if (shared.Count > 0) text.AppendLine($"Addresses at/over the flag threshold ({s.FlagAccountsPerAddress}): {shared.Count} -> /fairplay shared"); Reply(session, text.ToString()); } private static void Linked(Session? session) { var households = Mod.Logins?.Households() ?? new(); if (households.Count == 0) { Reply(session, "No accounts are linked - none have shared an address yet."); return; } var text = new StringBuilder() .AppendLine($"Linked households ({households.Count}) - accounts that have shared an address:"); foreach (var h in households.Take(20)) text.AppendLine($" [{h.Count}] {string.Join(", ", h.Take(10))}"); text.AppendLine("Rules apply per household, so a VPN on one account does not unlink it."); text.AppendLine("Use NeverLink in Settings.json to pin an account to a household of one."); Reply(session, text.ToString()); } private static void Shared(Session? session) { var shared = Mod.Logins?.Shared() ?? new(); if (shared.Count == 0) { Reply(session, "No address has been used by more than one account."); return; } var text = new StringBuilder().AppendLine($"Addresses used by more than one account ({shared.Count}):"); foreach (var (address, accounts) in shared.Take(25)) text.AppendLine($" {address,-16} {accounts.Count}: {string.Join(", ", accounts.Take(8))}"); Reply(session, text.ToString()); } private static void Account(Session? session, string account) { var addresses = Mod.Logins?.Addresses(account) ?? new(); if (addresses.Count == 0) { Reply(session, $"No login history for account \"{account}\"."); return; } var text = new StringBuilder().AppendLine($"{account} has logged in from {addresses.Count} address(es):"); foreach (var a in addresses.Take(30)) text.AppendLine($" {a}"); Reply(session, text.ToString()); } private static void Address(Session? session, string address) { var accounts = Mod.Logins?.Accounts(address) ?? new(); if (accounts.Count == 0) { Reply(session, $"No login history for address {address}."); return; } var text = new StringBuilder().AppendLine($"{address} has been used by {accounts.Count} account(s):"); foreach (var a in accounts.Take(30)) text.AppendLine($" {a}"); Reply(session, text.ToString()); } [CommandHandler("fairplay-reload", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Restart the fair-play mod, re-reading Settings.json.", "/fairplay-reload")] public static void HandleReload(Session session, params string[] parameters) { var container = Mod.Container; if (container is null) { Reply(session, $"{Mod.Name} is not loaded."); return; } container.Restart(); Reply(session, $"{Mod.Name} restarted."); } /// /// The client renders an embedded newline as a music note, so a multi-line /// message has to go out as one SendMessage per line. /// private static void Reply(Session? session, string message) { if (session?.Player is null) { ModManager.Log(message); return; } foreach (var line in (message ?? "").Split('\n')) { var text = line.TrimEnd('\r'); if (!string.IsNullOrWhiteSpace(text)) session.Player.SendMessage(text); } } }