267 lines
13 KiB
C#
267 lines
13 KiB
C#
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<EventPlayerSpawn>(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/<assembly-name>/ — 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<RanksConfig>(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<TopPlayer> 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}");
|
|
}
|
|
}
|