using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Core.Capabilities; using CounterStrikeSharp.API.Modules.Admin; using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Entities.Constants; using CounterStrikeSharp.API.Modules.Timers; using CounterStrikeSharp.API.Modules.Utils; using CS2_SimpleAdminApi; using GroupVerifyApi; using Microsoft.Extensions.Logging; using Timer = CounterStrikeSharp.API.Modules.Timers.Timer; namespace GroupVerify; // Marks players as verified when they're in a Steam group, or have a manual override in the // gv_verified table (written by the web panel), and tells other plugins through GroupVerifyApi. // Group membership is checked per player with the Steam Web API when they join, and kept in // CS2-SimpleAdmin's database, over its connection string, like everything except the API key. // // Threads: every field below is only touched on the game thread. HTTP and database work runs in // Task.Run and hands its result back with Server.NextFrame(Async). public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig { public override string ModuleName => "GroupVerify"; public override string ModuleVersion => "0.3.0"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Verifies players by Steam group membership or a manual override"; private const string VerifyCommand = "css_verify"; // Changing settings is for the owner, unless admin_overrides.json says otherwise. private const string SettingsPermission = "@css/root"; // css_verify answers per player up to this many targets, with a summary above it. private const int PerPlayerReplyLimit = 5; private static readonly PluginCapability SimpleAdminCapability = new("simpleadmin:api"); private ICS2_SimpleAdminApi? _simpleAdmin; private Database? _db; private readonly CancellationTokenSource _unloading = new(); private Settings _settings = Settings.Default; // Set once the first database load has applied; nothing is evaluated before that. private bool _ready; // Bumped when a command changes a setting, so a reload that read the table before the change // doesn't put the old value back. private int _settingsVersion; public GroupVerifyConfig Config { get; set; } = new(); // Group members as of the database: gv_verified's 'group' rows, reread with every reload. private HashSet _groupRows = []; // This session's Steam checks, which win over the database until the next restart: SteamID64 -> // whether they're a member. Players Steam wouldn't answer for aren't in here. private readonly Dictionary _membership = []; // SteamID64 -> when they were last asked about (UTC), answered or not, so rejoining or // css_verify doesn't ask more often than min_forced_refresh_seconds. private readonly Dictionary _checkedAt = []; // SteamID64 -> why Steam couldn't say, for css_verify. private readonly Dictionary _checkProblem = []; // Checks in flight, shared by everyone who asks about the same player. private readonly Dictionary _checks = []; private bool _noKeyWarned; // SteamID64 -> override end (null: never), from gv_verified. private Dictionary _overrides = []; private DateTime? _dbLoadedAt; // UTC private DateTime? _dbAttemptAt; // UTC private Task? _dbReload; // Slot -> the SteamID64 evaluated there and whether they were verified, to tell other plugins // only about changes. private readonly Dictionary _evaluated = []; private Timer? _dbTimer; // A css_verify target: an online player (Slot set) or a SteamID looked up offline. private sealed record Target(ulong SteamId, string Name, int? Slot); private sealed record Caller(int Slot, ulong SteamId, string Name); public void OnConfigParsed(GroupVerifyConfig config) { config.SteamWebApiKey = config.SteamWebApiKey.Trim(); Config = config; } public override void Load(bool hotReload) { RegisterEventHandler(OnPlayerConnectFull); RegisterListener(OnClientAuthorized); RegisterListener(OnClientDisconnect); // The bridge lives in the shared GroupVerifyApi assembly, so it's the same object across // reloads of this plugin. CSS keeps every supplier ever registered and always uses the // first, so register it once per server run, whichever load gets here first. GroupVerifyBridge.Instance.Attach(this); if (!ApiRegistered()) Capabilities.RegisterPluginCapability(IGroupVerifyApi.Capability, () => GroupVerifyBridge.Instance); } private static bool ApiRegistered() { try { return IGroupVerifyApi.Capability.Get() != null; } catch (KeyNotFoundException) { return false; } } public override void OnAllPluginsLoaded(bool hotReload) { try { _simpleAdmin = SimpleAdminCapability.Get(); } catch (KeyNotFoundException) { } if (_simpleAdmin == null) { Logger.LogError("CS2-SimpleAdmin's API isn't available, so there's no database: verification is off"); return; } var connectionString = _simpleAdmin.GetConnectionString(); if (string.IsNullOrEmpty(connectionString)) { Logger.LogError("CS2-SimpleAdmin has no MySQL connection string (is it on SQLite?): verification is off"); return; } _db = new Database(connectionString); var db = _db; Task.Run(async () => { try { foreach (var version in await db.MigrateAsync()) Logger.LogInformation("Migration \"{Version}\" applied", version); } catch (Exception ex) { Logger.LogError(ex, "Couldn't create GroupVerify's tables: verification is off"); return; } await Server.NextFrameAsync(() => { ReloadDatabase(); }); }); } public override void Unload(bool hotReload) { GroupVerifyBridge.Instance.Detach(this); _unloading.Cancel(); _dbTimer?.Kill(); _evaluated.Clear(); } // --- API (game thread) ------------------------------------------------------------------- public bool IsActive => _ready && _settings.Active; public string ClanTag => _settings.ClanTag; public VerifyStatus GetStatus(ulong steamId64) { if (!IsActive) return default; var inGroup = _membership.TryGetValue(steamId64, out var member) ? member : _groupRows.Contains(steamId64); if (!_overrides.TryGetValue(steamId64, out var ends)) return new VerifyStatus(inGroup, false, null); // Overrides can run out between reloads. var active = ends == null || ends > _settings.Now(); return new VerifyStatus(inGroup, active, active ? ends : null); } // --- Refreshing (game thread) ------------------------------------------------------------ // Rereads gv_settings and gv_verified, then re-evaluates everyone. Concurrent calls share one. private Task ReloadDatabase() { if (_db == null) return Task.CompletedTask; if (_dbReload != null) return _dbReload; var db = _db; var version = _settingsVersion; _dbAttemptAt = DateTime.UtcNow; return _dbReload = Task.Run(async () => { Database.Snapshot? snapshot = null; try { snapshot = await db.LoadAsync(message => Logger.LogWarning("{Message}", message)); } catch (Exception ex) { Logger.LogError("Couldn't load gv_settings/gv_verified, keeping what's loaded: {Error}", ex.Message); } if (_unloading.IsCancellationRequested) return; await Server.NextFrameAsync(() => { _dbReload = null; if (snapshot == null) return; _overrides = snapshot.Overrides; _groupRows = snapshot.GroupRows; _dbLoadedAt = DateTime.UtcNow; if (snapshot.Expired > 0) Logger.LogInformation("Removed {Count} expired gv_verified rows", snapshot.Expired); ApplySettings(version == _settingsVersion ? snapshot.Settings : _settings); }); }); } // Switches to new settings (or just re-evaluates, if they're the same). private void ApplySettings(Settings next) { var old = _settings; var first = !_ready; _settings = next; _ready = true; var groupChanged = !first && old.GroupId64 != next.GroupId64; if (groupChanged) { // Everything known so far is about the old group. _groupRows = []; _membership.Clear(); _checkedAt.Clear(); _checkProblem.Clear(); var db = _db; if (db != null) { Task.Run(async () => { try { await db.ClearGroupRowsAsync(); } catch (Exception ex) { Logger.LogError("Couldn't clear the old group's rows from gv_verified: {Error}", ex.Message); } }); } } if (first || old.Active != next.Active) { if (next.Active) Logger.LogInformation("Verification is on, Steam group {Group}", next.GroupId64); else Logger.LogWarning("Verification is off ({Why}): nobody is verified", next.Enabled ? "no Steam group set, css_gv_group" : "disabled, css_gv_enable"); } if (first || old.DbReloadSeconds != next.DbReloadSeconds) { _dbTimer?.Kill(); _dbTimer = AddTimer(_settings.DbReloadSeconds, () => ReloadDatabase(), TimerFlags.REPEAT); } EvaluateAll(); if ((first ? "" : old.EffectiveClanTag) != next.EffectiveClanTag) { Logger.LogInformation("Clan tag for verified players: {Tag}", next.EffectiveClanTag.Length > 0 ? next.EffectiveClanTag : "(none)"); foreach (var ex in GroupVerifyBridge.Instance.RaiseClanTagChanged()) Logger.LogError(ex, "An OnClanTagChanged handler threw"); } // Ask Steam about everyone on the server when there's something new to ask about. if (next.Active && (first || groupChanged || !old.Active)) CheckAll(); } private void CheckAll() { foreach (var player in Utilities.GetPlayers()) { if (player is { IsValid: true, IsBot: false, IsHLTV: false } && player.AuthorizedSteamID is { } id) CheckMembership(id.SteamId64); } } // Asks Steam whether the player is in the group, then records the answer in gv_verified (a // member's row is added or refreshed, a non-member's removed) and re-evaluates them. When Steam // won't say, nothing changes and what the database has stands. Skipped while verification is // off, without an API key, or if they were asked about within min_forced_refresh_seconds. private Task CheckMembership(ulong steamId) { if (_db == null || !IsActive) return Task.CompletedTask; if (Config.SteamWebApiKey.Length == 0) { if (!_noKeyWarned) Logger.LogWarning("No SteamWebApiKey in configs/plugins/GroupVerify/GroupVerify.json, so nobody's group membership is checked"); _noKeyWarned = true; return Task.CompletedTask; } if (_checks.TryGetValue(steamId, out var running)) return running; if (_checkedAt.TryGetValue(steamId, out var at) && DateTime.UtcNow - at < TimeSpan.FromSeconds(_settings.MinForcedRefreshSeconds)) return Task.CompletedTask; var db = _db; var settings = _settings; var key = Config.SteamWebApiKey; var cancel = _unloading.Token; _checkedAt[steamId] = DateTime.UtcNow; var task = Task.Run(async () => { var (result, problem) = (Membership.Unknown, (string?)null); try { (result, problem) = await SteamApi.CheckAsync(key, steamId, settings.GroupId64, cancel); } catch (OperationCanceledException) when (cancel.IsCancellationRequested) { return; } catch (Exception ex) { // Only the type: messages could quote the request, key and all. (result, problem) = (Membership.Unknown, $"unexpected {ex.GetType().Name}"); } if (result == Membership.Unknown) { Logger.LogWarning("Couldn't check {SteamId} with Steam, keeping what the database has: {Problem}", steamId, problem); } else { try { await db.SetGroupMemberAsync(steamId, result == Membership.Member, settings.Now()); } catch (Exception ex) { // The answer still counts until a restart; it just isn't saved. Logger.LogError("Couldn't save {SteamId}'s group membership to gv_verified: {Error}", steamId, ex.Message); } } if (cancel.IsCancellationRequested) return; await Server.NextFrameAsync(() => { _checks.Remove(steamId); // Dropped if the group changed or verification was turned off while asking. if (settings.GroupId64 != _settings.GroupId64 || !IsActive) return; if (result == Membership.Unknown) { _checkProblem[steamId] = problem ?? "no reason given"; return; } _checkProblem.Remove(steamId); _membership[steamId] = result == Membership.Member; foreach (var player in Utilities.GetPlayers()) { if (player.AuthorizedSteamID?.SteamId64 == steamId) Evaluate(player); } }); }); _checks[steamId] = task; return task; } // --- Verification (game thread) ---------------------------------------------------------- private void EvaluateAll() { foreach (var player in Utilities.GetPlayers()) Evaluate(player); } // Tells other plugins (OnVerifiedChanged) when the player's verified status has changed. private void Evaluate(CCSPlayerController? player) { if (!_ready || player is not { IsValid: true, IsBot: false, IsHLTV: false }) return; // Only Steam-authorized IDs count; OnClientAuthorized evaluates again once it's known. var steamId = player.AuthorizedSteamID?.SteamId64; if (steamId == null) return; var status = GetStatus(steamId.Value); var had = _evaluated.TryGetValue(player.Slot, out var last) && last.SteamId == steamId && last.Verified; _evaluated[player.Slot] = (steamId.Value, status.Verified); if (status.Verified == had) return; Logger.LogInformation("{Name} ({SteamId}) is {Status}", player.PlayerName, steamId, status.Verified ? "verified" : "no longer verified"); foreach (var ex in GroupVerifyBridge.Instance.RaiseVerifiedChanged(player, status)) Logger.LogError(ex, "An OnVerifiedChanged handler threw for {Name}", player.PlayerName); } // The player is leaving: other plugins get no event, just forget them. private void ForgetPlayer(int slot) { _evaluated.Remove(slot); } private HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info) { Evaluate(@event.Userid); return HookResult.Continue; } // Evaluated at once from what the database knows, then checked with Steam, which may change it. private void OnClientAuthorized(int playerSlot, SteamID steamId) { var player = Utilities.GetPlayerFromSlot(playerSlot); Evaluate(player); if (player is { IsValid: true, IsBot: false, IsHLTV: false }) CheckMembership(steamId.SteamId64); } private void OnClientDisconnect(int playerSlot) { ForgetPlayer(playerSlot); } // --- css_verify -------------------------------------------------------------------------- [ConsoleCommand(VerifyCommand, "Shows whether players are verified (Steam group or manual override)")] [CommandHelper(usage: "<@all|@t|@ct|@spec|@alive|@dead|@me|#userid|name|steamid>")] public void OnVerify(CCSPlayerController? player, CommandInfo command) { if (player != null && !player.IsValid) return; var arg = command.ArgCount > 1 ? command.ArgString.Trim().Trim('"').Trim() : ""; if (arg.Length == 0) { if (player == null) { command.ReplyToCommand($"Usage: {VerifyCommand} <@all|@t|@ct|@spec|@alive|@dead|#userid|name|steamid>"); return; } arg = "@me"; } if (_db == null || !_ready) { command.ReplyToCommand("[Verify] Not ready: the database hasn't loaded (see the server log)."); return; } var commandString = command.GetCommandString; var targets = ResolveTargets(player, arg, out var error); if (targets == null) { command.ReplyToCommand($"[Verify] {error}"); return; } if (!TryGetCaller(player, command, out var caller)) return; // The server console always may. An admin_overrides.json entry for css_verify is enforced // by CSS before this runs, and then replaces the rank check. if (caller == null || AdminManager.CommandIsOverriden(VerifyCommand)) { Run(caller, commandString, targets); return; } var db = _db; var settings = _settings; var serverId = _simpleAdmin?.GetServerId(); Task.Run(async () => { bool allowed; string? problem; try { (allowed, problem) = await db.CanVerifyAsync(caller!.SteamId, settings.VerifyMinRank, settings.VerifyExtraRanks, serverId, settings.Now()); } catch (Exception ex) { (allowed, problem) = (false, $"rank check failed: {ex.Message}"); } Server.NextFrame(() => { if (problem != null) Logger.LogWarning("css_verify: {Problem}", problem); if (allowed) { Run(caller, commandString, targets); return; } Logger.LogInformation("{Name} ({SteamId}) was refused {Command}", caller!.Name, caller.SteamId, commandString); Reply(caller, $"{ChatColors.Red}You don't have access to this command."); }); }); } // Rechecks each target with Steam and rereads the database, where those are older than // min_forced_refresh_seconds, then answers. private void Run(Caller? caller, string commandString, List targets) { LogCommand(caller, commandString); var maxAge = TimeSpan.FromSeconds(_settings.MinForcedRefreshSeconds); var pending = targets.Select(t => CheckMembership(t.SteamId)).ToList(); if (_dbAttemptAt == null || DateTime.UtcNow - _dbAttemptAt > maxAge) pending.Add(ReloadDatabase()); if (pending.All(t => t.IsCompleted)) { Answer(caller, targets); return; } Task.WhenAll(pending).ContinueWith(_ => Server.NextFrame(() => Answer(caller, targets))); } private void Answer(Caller? caller, List targets) { if (caller != null && CurrentPlayer(caller) == null) return; int verified = 0, byGroup = 0, byOverrideOnly = 0; var lines = new List(); foreach (var target in targets) { // An online target who left since is looked up by SteamID instead. var online = target.Slot is { } slot && Utilities.GetPlayerFromSlot(slot) is { IsValid: true } p && p.AuthorizedSteamID?.SteamId64 == target.SteamId ? p : null; if (online != null) Evaluate(online); var status = GetStatus(target.SteamId); if (status.Verified) verified++; if (status.InGroup) byGroup++; else if (status.HasOverride) byOverrideOnly++; if (targets.Count <= PerPlayerReplyLimit) { var name = online?.PlayerName ?? target.Name; var where = online == null ? " (offline)" : ""; var unchecked_ = _checkProblem.TryGetValue(target.SteamId, out var problem) ? $" {ChatColors.Grey}(couldn't check with Steam: {problem}; using the database){ChatColors.Default}" : ""; lines.Add($"{ChatColors.Gold}{name}{ChatColors.Default} [{target.SteamId}]{where}: {Describe(status)}{unchecked_}"); } } if (targets.Count > PerPlayerReplyLimit) { lines.Add($"{targets.Count} players: {ChatColors.Green}{verified} verified{ChatColors.Default} " + $"({byGroup} Steam group, {byOverrideOnly} override only), " + $"{ChatColors.Red}{targets.Count - verified} not verified{ChatColors.Default}."); } lines.Add($"{ChatColors.Grey}{DescribeSources()}"); foreach (var line in lines) Reply(caller, line); } private string Describe(VerifyStatus status) { if (!status.Verified) return $"{ChatColors.Red}not verified{ChatColors.Default}"; var reasons = new List(); if (status.InGroup) reasons.Add("Steam group"); if (status.HasOverride) { reasons.Add(status.OverrideExpires is { } ends ? $"override until {ends:yyyy-MM-dd HH:mm} {_settings.TimeZone.Id} (in {FormatSpan(ends - _settings.Now())})" : "override, no expiry"); } return $"{ChatColors.Green}verified{ChatColors.Default} ({string.Join(" + ", reasons)})"; } private string DescribeSources() { if (!_settings.Active) return DescribeState(); var group = Config.SteamWebApiKey.Length == 0 ? "no Steam Web API key (GroupVerify.json), so group membership isn't being checked" : $"group membership checked with Steam on join; {_groupRows.Count} known members"; var db = _dbLoadedAt is { } loaded ? $"overrides {FormatSpan(DateTime.UtcNow - loaded)} old" : "overrides not loaded"; return $"{group}; {db}."; } private static string FormatSpan(TimeSpan span) { if (span < TimeSpan.Zero) span = TimeSpan.Zero; if (span.TotalMinutes < 1) return $"{(int)span.TotalSeconds}s"; if (span.TotalHours < 1) return $"{(int)span.TotalMinutes}m"; if (span.TotalDays < 1) return $"{(int)span.TotalHours}h {span.Minutes}m"; return $"{(int)span.TotalDays}d {span.Hours}h"; } // On or off, and why. private string DescribeState() { if (_settings.Active) { var tag = _settings.ClanTag.Length > 0 ? $"clan tag \"{_settings.ClanTag}\"" : "no clan tag"; return $"{ChatColors.Green}On{ChatColors.Default}, Steam group {_settings.GroupId64}, {tag}."; } if (!_settings.Enabled) { var group = _settings.GroupId64 == 0 ? "no Steam group set" : $"Steam group {_settings.GroupId64}"; return $"{ChatColors.Red}Off{ChatColors.Default}: disabled (css_gv_enable), {group}."; } return $"{ChatColors.Red}Off{ChatColors.Default}: no Steam group set (css_gv_group )."; } // The player who ran a command, or null for the server console. False, after telling them, if // Steam hasn't authorized them yet. private static bool TryGetCaller(CCSPlayerController? player, CommandInfo command, out Caller? caller) { caller = null; if (player == null) return true; var steamId = player.AuthorizedSteamID?.SteamId64; if (steamId == null) { command.ReplyToCommand("[Verify] You aren't authorized with Steam yet, try again in a moment."); return false; } caller = new Caller(player.Slot, steamId.Value, player.PlayerName); return true; } // Logged here and through SimpleAdmin (server log and its Discord log), like its own commands. private void LogCommand(Caller? caller, string commandString) { Logger.LogInformation("{Name} ({SteamId}) ran {Command}", caller?.Name ?? "Console", caller?.SteamId ?? 0, commandString); var player = caller == null ? null : CurrentPlayer(caller); if (caller == null || player != null) _simpleAdmin?.LogCommand(player, commandString); } // The caller if they're still on the server in the same slot, else null. private static CCSPlayerController? CurrentPlayer(Caller caller) { var player = Utilities.GetPlayerFromSlot(caller.Slot); return player is { IsValid: true } && player.AuthorizedSteamID?.SteamId64 == caller.SteamId ? player : null; } // Answers in chat, or on the server console. Not through CommandInfo: by now it's gone. private void Reply(Caller? caller, string message) { if (caller == null) { Server.PrintToConsole($"[Verify] {StripColors(message)}"); return; } CurrentPlayer(caller)?.PrintToChat($" {ChatColors.Green}[Verify]{ChatColors.Default} {message}"); } private static string StripColors(string message) => new(message.Where(c => c > '\u0010' || c == '\n').ToArray()); // --- Settings commands ------------------------------------------------------------------- // Saved to gv_settings and applied at once. @css/root, or what admin_overrides.json says. [ConsoleCommand("css_gv_enable", "Turns GroupVerify on (it also needs a Steam group, css_gv_group)")] [RequiresPermissions(SettingsPermission)] public void OnEnable(CCSPlayerController? player, CommandInfo command) => ChangeSetting(player, command, "enabled", "1", s => s with { Enabled = true }); [ConsoleCommand("css_gv_disable", "Turns GroupVerify off: nobody is verified")] [RequiresPermissions(SettingsPermission)] public void OnDisable(CCSPlayerController? player, CommandInfo command) => ChangeSetting(player, command, "enabled", "0", s => s with { Enabled = false }); [ConsoleCommand("css_gv_group", "Shows or sets the Steam group (its 64-bit ID); 0 turns GroupVerify off")] [CommandHelper(usage: "[groupId64|0]")] [RequiresPermissions(SettingsPermission)] public void OnGroup(CCSPlayerController? player, CommandInfo command) { if (command.ArgCount < 2) { command.ReplyToCommand($"[Verify] {StripColors(DescribeState())} {StripColors(DescribeSources())}"); return; } var text = command.GetArg(1).Trim(); if (!ulong.TryParse(text, out var groupId) || (groupId != 0 && new SteamID(groupId) is var id && (id.AccountType != SteamAccountType.Clan || !id.IsValid()))) { command.ReplyToCommand($"[Verify] \"{text}\" isn't a Steam group ID64 (103582791...). It's on " + "https://steamcommunity.com/groups//memberslistxml/?xml=1. 0 turns verification off."); return; } ChangeSetting(player, command, "group_id64", groupId.ToString(), s => s with { GroupId64 = groupId }); } [ConsoleCommand("css_gv_clantag", "Shows or sets the scoreboard tag verified players without a rank get; - clears it")] [CommandHelper(usage: "[tag|-]")] [RequiresPermissions(SettingsPermission)] public void OnClanTag(CCSPlayerController? player, CommandInfo command) { if (command.ArgCount < 2) { var current = _settings.ClanTag.Length > 0 ? $"\"{_settings.ClanTag}\"" : "not set"; command.ReplyToCommand($"[Verify] Clan tag for verified players: {current}. {StripColors(DescribeState())}"); return; } var tag = command.ArgString.Trim().Trim('"'); if (tag == "-") tag = ""; if (!Settings.ValidClanTag(tag)) { command.ReplyToCommand($"[Verify] A clan tag is at most {Settings.ClanTagMaxLength} characters, without quotes, ; or control characters. - clears it."); return; } ChangeSetting(player, command, "clan_tag", tag, s => s with { ClanTag = tag }); } private void ChangeSetting(CCSPlayerController? player, CommandInfo command, string name, string value, Func change) { if (_db == null || !_ready) { command.ReplyToCommand("[Verify] Not ready: the database hasn't loaded (see the server log)."); return; } if (!TryGetCaller(player, command, out var caller)) return; var commandString = command.GetCommandString; var db = _db; Task.Run(async () => { string? error = null; try { await db.SetSettingAsync(name, value); } catch (Exception ex) { error = ex.Message; } Server.NextFrame(() => { if (error != null) { Logger.LogError("Couldn't save gv_settings {Name}: {Error}", name, error); Reply(caller, $"{ChatColors.Red}Couldn't save the setting: {error}"); return; } LogCommand(caller, commandString); _settingsVersion++; ApplySettings(change(_settings)); Reply(caller, DescribeState()); }); }); } // --- Targets ----------------------------------------------------------------------------- // @all, @t, @ct, @spec, @alive, @dead, @me, #userid, a SteamID (64, STEAM_X:Y:Z or [U:1:N], // online or not), or a name: exact first, then partial, refusing ambiguous matches. private static List? ResolveTargets(CCSPlayerController? caller, string arg, out string? error) { error = null; var players = Utilities.GetPlayers() .Where(p => p is { IsValid: true, IsBot: false, IsHLTV: false, Connected: PlayerConnectedState.Connected }) .Where(p => p.AuthorizedSteamID != null) .ToList(); static List Online(IEnumerable matches) => matches.Select(p => new Target(p.AuthorizedSteamID!.SteamId64, p.PlayerName, p.Slot)).ToList(); List? group = arg.ToLowerInvariant() switch { "@all" => Online(players), "@t" => Online(players.Where(p => p.Team == CsTeam.Terrorist)), "@ct" => Online(players.Where(p => p.Team == CsTeam.CounterTerrorist)), "@spec" => Online(players.Where(p => p.Team == CsTeam.Spectator)), "@alive" => Online(players.Where(p => p.PawnIsAlive)), "@dead" => Online(players.Where(p => !p.PawnIsAlive)), _ => null }; if (group != null) { if (group.Count == 0) error = $"No players match {arg}."; return group.Count == 0 ? null : group; } if (arg.Equals("@me", StringComparison.OrdinalIgnoreCase)) { if (caller?.AuthorizedSteamID == null) { error = "@me only works for players."; return null; } return Online([caller]); } if (arg.StartsWith('#') && int.TryParse(arg[1..], out var userId)) { var byUserId = players.FirstOrDefault(p => p.UserId == userId); if (byUserId == null) error = $"No player with userid {userId}."; return byUserId == null ? null : Online([byUserId]); } if (SteamID.TryParse(arg, out var parsed) && parsed != null && parsed.IsValid()) { var bySteamId = players.FirstOrDefault(p => p.AuthorizedSteamID!.SteamId64 == parsed.SteamId64); return bySteamId != null ? Online([bySteamId]) : [new Target(parsed.SteamId64, "offline player", null)]; } var exact = players.Where(p => p.PlayerName.Equals(arg, StringComparison.OrdinalIgnoreCase)).ToList(); var matches = exact.Count > 0 ? exact : players.Where(p => p.PlayerName.Contains(arg, StringComparison.OrdinalIgnoreCase)).ToList(); switch (matches.Count) { case 0: error = $"No player matches \"{arg}\"."; return null; case 1: return Online(matches); default: var names = string.Join(", ", matches.Take(5).Select(p => $"{p.PlayerName} (#{p.UserId})")); error = $"\"{arg}\" matches {matches.Count} players: {names}. Use #userid."; return null; } } }