using System.Reflection; using System.Text; using System.Text.Json; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Timers; using CounterStrikeSharp.API.Modules.Utils; using Microsoft.Extensions.Logging; using Outnumbered.Config; using Outnumbered.Data; using Outnumbered.Engine; namespace Outnumbered; // Ranks/titles + the player info commands. // Ranks live in a SEPARATE outnumbered.ranks.json that CSSharp does NOT auto-load — we load it manually and write // defaults if missing. Rank shows to others via the scoreboard clantag (m_szClan, 32 bytes; refresh/colour support // is unverified in CS2, so we set it and degrade gracefully). public sealed partial class OutnumberedPlugin { private RanksConfig _ranks = new(); private void Initialize_Ranks() { LoadRanksConfig(); RegisterEventHandler(OnPlayerSpawn_Ranks); AddCommandListener("say", OnSay); AddCommandListener("say_team", OnSayTeam); if (Config.Website.AnnounceIntervalSeconds > 0) _websiteTimer = AddTimer(Config.Website.AnnounceIntervalSeconds, AnnounceWebsite, TimerFlags.REPEAT); } private void Shutdown_Ranks() { _websiteTimer?.Kill(); RemoveCommandListener("say", OnSay, HookMode.Pre); RemoveCommandListener("say_team", OnSayTeam, HookMode.Pre); } private CounterStrikeSharp.API.Modules.Timers.Timer? _websiteTimer; // The periodic website plug. Url is read per fire (live-tunable); empty = silent, and an empty server stays quiet. private void AnnounceWebsite() { string url = Config.Website.Url; if (string.IsNullOrEmpty(url) || !Humans().Any()) return; Server.PrintToChatAll($" {ChatColors.Gold}[Outnumbered]{ChatColors.Default} Leaderboards, guides & the damage simulator: {ChatColors.Lime}{url}"); } // CS2 never shows m_szClan in chat, so we prepend the coloured rank tag ourselves: reformat the message and // swallow the default. Crucially we DON'T touch !/ /-prefixed messages, or we'd eat chat commands like !rank. private HookResult OnSay(CCSPlayerController? p, CommandInfo i) => OnSayCommand(p, i, teamOnly: false); private HookResult OnSayTeam(CCSPlayerController? p, CommandInfo i) => OnSayCommand(p, i, teamOnly: true); private HookResult OnSayCommand(CCSPlayerController? player, CommandInfo info, bool teamOnly) { if (!_ranks.Enabled || !_ranks.ChatTag) return HookResult.Continue; if (player is not { IsValid: true } || player.IsBot) return HookResult.Continue; string msg = info.GetArg(1).Trim(); if (msg.Length == 0 || msg[0] is '!' or '/') return HookResult.Continue; // let commands + empties pass through var pd = PdOf(player); if (pd is null) return HookResult.Continue; string dead = player.PawnIsAlive ? "" : $"{ChatColors.Grey}* "; string line = $" {dead}{ClanTagChat(pd)} {ChatColors.ForPlayer(player)}{player.PlayerName}{ChatColors.Default}: {msg}"; if (teamOnly) { var team = player.Team; foreach (var t in Utilities.GetPlayers()) if (t is { IsValid: true } && !t.IsBot && t.Team == team) t.PrintToChat(line); } else { Server.PrintToChatAll(line); } return HookResult.Handled; // swallow the default chat line (we printed our own) } // ---- manual config load ---- // The plugin's config dir is …/configs/plugins// — derive the folder from the assembly name (not a // hardcoded "outnumbered") so a rename can't desync it. Shared by the ranks load + the !og_reload path (Admin.cs). internal static string ConfigPath(string fileName) => Path.Combine(Server.GameDirectory, "csgo", "addons", "counterstrikesharp", "configs", "plugins", Assembly.GetExecutingAssembly().GetName().Name!, fileName); private static string RanksConfigPath() => ConfigPath("outnumbered.ranks.json"); private void LoadRanksConfig() { string path = RanksConfigPath(); try { if (File.Exists(path)) { _ranks = JsonSerializer.Deserialize(File.ReadAllText(path), new JsonSerializerOptions { ReadCommentHandling = JsonCommentHandling.Skip }) ?? new(); } else { _ranks = new RanksConfig(); Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, JsonSerializer.Serialize(_ranks, new JsonSerializerOptions { WriteIndented = true })); Logger.LogInformation("Outnumbered wrote default ranks config: {Path}", path); } } catch (Exception ex) { Logger.LogError(ex, "Outnumbered ranks config load failed; using defaults"); _ranks = new RanksConfig(); } // A hand-edited PrestigeTagFormat could be a malformed composite format string (e.g. "P {1}") that // throws in string.Format only once someone prestiges — validate now and fall back if bad. try { _ = string.Format(_ranks.PrestigeTagFormat ?? "", "I"); } catch { _ranks.PrestigeTagFormat = "P{0}"; } if (string.IsNullOrEmpty(_ranks.PrestigeTagFormat)) _ranks.PrestigeTagFormat = "P{0}"; } // ---- rank / clantag ---- private string RankNameFor(int level) { RankTier? best = null; foreach (var t in _ranks.LevelRanks) if (level >= t.MinLevel && (best is null || t.MinLevel > best.MinLevel)) best = t; return best?.Name ?? "Unranked"; } private string RankName(PlayerData pd) => RankNameFor(pd.Level); private string ClanTag(PlayerData pd) { string rank = RankNameFor(pd.Level); string tag = pd.Prestige > 0 ? $"[{string.Format(_ranks.PrestigeTagFormat, Roman(pd.Prestige))} {rank}]" : $"[{rank}]"; return tag.Length > 31 ? tag[..31] : tag; // m_szClan is 32 bytes } // Prestige -> ability indices whose tint colours make up its tag. I-V = the unlocked perk; VI-X = mixes. // Ability index colours: 0 NoReload=yellow, 1 Adrenaline=blue, 2 Overcharge=orange, 3 Bloodthirst=green, 4 Berserk=red. private static readonly int[][] PrestigeColorIdx = { new[] { 0 }, new[] { 1 }, new[] { 2 }, new[] { 3 }, new[] { 4 }, // I-V solid new[] { 4, 2 }, new[] { 1, 3 }, // VI red+orange, VII blue+green new[] { 4, 2, 0 }, new[] { 1, 3, 0 }, // VIII warm trio, IX cool trio new[] { 4, 2, 0, 3, 1 }, // X full spectrum (R-O-Y-G-B) }; private static readonly char[] AbilityChat = { ChatColors.Yellow, ChatColors.Blue, ChatColors.Gold, ChatColors.Green, ChatColors.Red }; // RGB colour set for a prestige's tag (pulled live from the perks' tints) — used by the HUD gradient. private (int r, int g, int b)[] PrestigeColorSet(int prestige) { var idx = PrestigeColorIdx[Math.Clamp(prestige, 1, 10) - 1]; return idx.Select(i => (AbilityCfg(i).TintR, AbilityCfg(i).TintG, AbilityCfg(i).TintB)).ToArray(); } // Chat-coloured "P {roman}" (palette per char; solid for I-V, static multi-colour for VI-X). private string PrestigeChatTag(int prestige) { string text = $"P {Roman(prestige)}"; if (!_ranks.PrestigeColors || prestige <= 0) return $"{ChatColors.LightPurple}{text}"; var idx = PrestigeColorIdx[Math.Clamp(prestige, 1, 10) - 1]; var sb = new StringBuilder(); int li = 0; foreach (char c in text) { if (c == ' ') { sb.Append(' '); continue; } sb.Append(AbilityChat[idx[li++ % idx.Length] % AbilityChat.Length]).Append(c); // clamp: AbilityChat may lag a new ability } return sb.ToString(); } // Fully chat-coloured clantag (gold brackets, coloured prestige, lime rank) — used by the chat reformatter. private string ClanTagChat(PlayerData pd) { string rank = RankNameFor(pd.Level); return pd.Prestige > 0 ? $"{ChatColors.Gold}[{PrestigeChatTag(pd.Prestige)} {ChatColors.Lime}{rank}{ChatColors.Gold}]" : $"{ChatColors.Gold}[{ChatColors.Lime}{rank}{ChatColors.Gold}]"; } private void ApplyClan(CCSPlayerController p, PlayerData pd) { if (!_ranks.Enabled || !_ranks.ShowClanTag || p is not { IsValid: true } || p.IsBot) return; ControllerWriter.SetClan(p, ClanTag(pd)); } private HookResult OnPlayerSpawn_Ranks(EventPlayerSpawn ev, GameEventInfo info) { var p = ev.Userid; if (!IsHuman(p)) return HookResult.Continue; NextFrameForSlot(p.Slot, pl => { if (PdOf(pl) is { } pd) ApplyClan(pl, pd); }); return HookResult.Continue; } // ---- info commands ---- private static void Msg(CCSPlayerController p, string text) => p.PrintToChat($" {ChatColors.Gold}[Outnumbered]{ChatColors.Default} {text}"); [ConsoleCommand("css_rank", "Show your rank, level and prestige")] [ConsoleCommand("css_me", "Show your rank, level and prestige")] [CommandHelper(0, "", CommandUsage.CLIENT_ONLY)] public void Cmd_Rank(CCSPlayerController? player, CommandInfo info) { if (player is not { IsValid: true }) return; var pd = PdOf(player); if (pd is null) return; string xp = pd.Level >= Config.Progression.LevelCap ? "MAX" : $"{pd.Xp}/{XpToNext(pd.Level)}"; Msg(player, $"{ChatColors.Lime}{RankName(pd)}{ChatColors.Default} | L{pd.Level} P {Roman(pd.Prestige)} | XP {xp} | {pd.Points} pt"); } [ConsoleCommand("css_stats", "Show your live bonuses and handicap")] [CommandHelper(0, "", CommandUsage.CLIENT_ONLY)] public void Cmd_Stats(CCSPlayerController? player, CommandInfo info) { if (player is not { IsValid: true }) return; var pd = PdOf(player); if (pd is null) return; var (_, effOut, effIn, effXp) = EffectiveMultipliers(player, pd); Msg(player, $"Dmg Out x{effOut:F2} | In x{effIn:F2} | XP x{effXp:F2} | Streak {pd.Streak} | K/D {pd.Kills}/{pd.Deaths}"); var owned = StatList.Where(s => LevelOf(pd, s.Key) > 0) .Select(s => $"{s.Display} {LevelOf(pd, s.Key)}/{DefFor(s.Key).MaxLevel}"); Msg(player, "Stats: " + (owned.Any() ? string.Join(", ", owned) : "none yet — spend points with !skills")); } [ConsoleCommand("css_top", "Show the top players")] [ConsoleCommand("css_leaderboard", "Show the top players")] [CommandHelper(0, "", CommandUsage.CLIENT_ONLY)] public void Cmd_Top(CCSPlayerController? player, CommandInfo info) { if (player is not { IsValid: true }) return; int slot = player.Slot; int n = Math.Clamp(_ranks.TopCount, 1, 25); Task.Run(async () => { IReadOnlyList top; try { top = await _repo.GetTopAsync(n); } catch (Exception ex) { Logger.LogError(ex, "Outnumbered !top query failed"); return; } NextFrameForSlot(slot, pl => { Msg(pl, $"Top {top.Count} players:"); int i = 1; foreach (var t in top) pl.PrintToChat($" {ChatColors.Lime}{i++}. {ChatColors.Default}{t.Name} — {RankNameFor(t.Level)} L{t.Level} P {Roman(t.Prestige)}"); }); }); } [ConsoleCommand("css_info", "How the Outnumbered RPG works")] [ConsoleCommand("css_about", "How the Outnumbered RPG works")] [ConsoleCommand("css_help", "How the Outnumbered RPG works")] [CommandHelper(0, "", CommandUsage.CLIENT_ONLY)] public void Cmd_Info(CCSPlayerController? player, CommandInfo info) { if (player is not { IsValid: true }) return; Msg(player, "Outnumbered RPG — kill bots for XP, level up, spend points in !skills."); Msg(player, "At level 100, !prestige resets you for a permanent boost + an unlocked ability."); Msg(player, $"Killstreaks unlock {AbilityCount} abilities — when ready, press that grenade key (6-0) to cast. See !abilities."); Msg(player, "A handicap scales difficulty to your power (stronger = tougher bots, faster XP). See !stats."); Msg(player, $"{ChatColors.Lime}Commands: !rank !stats !skills !prestige !abilities !guns !top !hud"); if (Config.Website.Url is { Length: > 0 } site) Msg(player, $"Full guides, leaderboards & theorycrafting: {ChatColors.Lime}{site}"); } }