initial commit
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s

This commit is contained in:
Kamal Tufekcic 2026-07-05 13:28:35 +03:00
commit d701598350
67 changed files with 9351 additions and 0 deletions

239
Outnumbered/Abilities.cs Normal file
View file

@ -0,0 +1,239 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.Logging;
using Outnumbered.Config;
using Outnumbered.Data;
using Outnumbered.Engine;
namespace Outnumbered;
// Killstreak abilities, indexed 0..4 in streak/prestige order: No Reload, Adrenaline, Overcharge, Bloodthirst, Berserk.
// INPUT: slot6-10 command listeners DON'T fire on this build (CS2 selects grenades client-side), and a held grenade
// is still throwable via mouse-wheel/lastinv. So instead we grant the ability's grenade only while it's castable, and
// the instant that grenade becomes the ACTIVE weapon (any selection path: key, wheel, lastinv) we cast the ability,
// confiscate the grenade, and switch back to primary — so it can never be thrown.
public sealed partial class OutnumberedPlugin
{
// Internal ability index. Streak tiers and Prestige-I..V unlocks follow this order.
private const int AbNoReload = 0, AbAdrenaline = 1, AbOvercharge = 2, AbBloodthirst = 3, AbBerserk = 4;
// The ability REGISTRY — one row per killstreak ability, the single place their identity lives. Def selects
// the LIVE config block so !og_reload tunables apply. Grenade binds stay in Config.Abilities.AbilityGrenades (JSON).
// Adding/reordering an ability = one row here (+ matching grenade in the JSON list). SEE ALSO two index-coupled
// sites that must stay reconciled with this ORDER: Ranks.PrestigeColorIdx (prestige tag colours reference ability
// indices) and AbilityUnlocked's `pd.Prestige >= i+1` (Prestige I..V unlock abilities 0..4 off-streak).
internal sealed record AbilityInfo(int Index, string Name, string Short, string KeyNum, string KeyHint,
Func<AbilitiesConfig, AbilityDef> Def);
internal static readonly AbilityInfo[] AbilityRegistry =
{
new(AbNoReload, "No Reload", "NoReload", "6", "HE / key 6", c => c.NoReload),
new(AbAdrenaline, "Adrenaline", "Adren", "7", "Flash / key 7", c => c.Adrenaline),
new(AbOvercharge, "Overcharge", "Overchg", "8", "Smoke / key 8", c => c.Overcharge),
new(AbBloodthirst, "Bloodthirst", "Bloodth", "0", "Molotov / key 0", c => c.Bloodthirst),
new(AbBerserk, "Berserk", "Berserk", "9", "Decoy / key 9", c => c.Berserk),
};
internal static int AbilityCount => AbilityRegistry.Length;
private AbilityDef AbilityCfg(int i) => AbilityRegistry[i].Def(Config.Abilities);
private string AbilityGrenadeName(int i)
{
var list = Config.Abilities.AbilityGrenades;
return i >= 0 && i < list.Count ? list[i] : "";
}
// Reverse of AbilityGrenadeName: which ability does this grenade weapon trigger? -1 if not an ability grenade.
private int AbilityForGrenade(string designerName)
{
var list = Config.Abilities.AbilityGrenades;
for (int i = 0; i < list.Count && i < AbilityCount; i++)
if (list[i] == designerName) return i;
return -1;
}
private CounterStrikeSharp.API.Modules.Timers.Timer? _grenadeTimer;
private void Initialize_Abilities()
{
if (AbilityRegistry.Length > PlayerData.AbilitySlots) // the per-player state arrays must hold every ability index
Logger.LogError("Outnumbered: AbilityRegistry has {N} rows but PlayerData.AbilitySlots is {Slots} — bump AbilitySlots.", AbilityRegistry.Length, PlayerData.AbilitySlots);
// Ranks.PrestigeColorIdx is index-coupled to the ability order; a reorder/addition that references a row past the
// registry would mis-colour or crash the prestige tag. Catch it loudly at load (the registry's "one-row" promise).
foreach (var set in PrestigeColorIdx)
foreach (var i in set)
if (i >= AbilityCount)
Logger.LogError("Outnumbered: Ranks.PrestigeColorIdx references ability {I} but only {N} abilities exist.", i, AbilityCount);
// AbilityForGrenade binds grenade->ability POSITIONALLY via a first-match scan; with 8 instances sharing one JSON
// a wrong-length or duplicated list silently casts the wrong/no ability on every server. Validate at load (warn).
var grenades = Config.Abilities.AbilityGrenades;
if (grenades.Count != AbilityCount)
Logger.LogWarning("Outnumbered: AbilityGrenades has {N} entries but there are {C} abilities — extras are ignored and missing rows get no grenade key.", grenades.Count, AbilityCount);
var seenGrenades = new HashSet<string>(StringComparer.Ordinal);
foreach (var g in grenades)
if (g.Length > 0 && !seenGrenades.Add(g))
Logger.LogWarning("Outnumbered: AbilityGrenades lists '{G}' more than once — AbilityForGrenade maps it to the first matching ability only.", g);
// The two other ability-indexed presentation lists: AbilityChat (prestige-tag colours) wraps, AbilityIcons falls
// back to the registry Short label — warn so a new ability doesn't silently mis-colour / lose its HUD icon.
if (AbilityChat.Length < AbilityCount)
Logger.LogWarning("Outnumbered: AbilityChat has {N} colours but there are {C} abilities — the prestige tag colour wraps for the extras.", AbilityChat.Length, AbilityCount);
if (Config.Hud.AbilityIcons.Count < AbilityCount)
Logger.LogWarning("Outnumbered: Hud.AbilityIcons has {N} entries but there are {C} abilities — missing rows fall back to the registry Short label.", Config.Hud.AbilityIcons.Count, AbilityCount);
RegisterEventHandler<EventWeaponFire>(OnWeaponFire_Abilities);
// OnTick is driven by OnTick_All (shared roster walk); OnTick_Abilities is invoked from there.
// Reconcile held ability-grenades with castable abilities (grant when ready, remove otherwise).
_grenadeTimer = AddTimer(0.5f, ReconcileAllGrenades, CounterStrikeSharp.API.Modules.Timers.TimerFlags.REPEAT);
}
private void Shutdown_Abilities()
{
_grenadeTimer?.Kill();
}
// ---- state queries (also read by the damage/lifesteal hooks + the HUD) ----
private static bool AbilityActive(PlayerData pd, int i) => pd.AbilityActiveUntil[i] > Server.CurrentTime;
// Overload taking a pre-read clock — lets Snapshot() read Server.CurrentTime ONCE for its 3 ability checks (the
// clock can't advance within one synchronous build, so it's bit-identical to 3 separate reads).
private static bool AbilityActive(PlayerData pd, int i, double now) => pd.AbilityActiveUntil[i] > now;
private static bool AbilityReady(PlayerData pd, int i) => Server.CurrentTime >= pd.AbilityReadyAt[i];
// Pre-read-clock overload (mirrors AbilityActive(pd,i,now)) — lets the HUD read Server.CurrentTime ONCE per build.
private static bool AbilityReady(PlayerData pd, int i, double now) => now >= pd.AbilityReadyAt[i];
private bool AbilityUnlocked(PlayerData pd, int i) =>
pd.Streak >= AbilityCfg(i).StreakReq || pd.Prestige >= i + 1; // Prestige I..V unlock 0..4 off-streak
// Castable right now = unlocked AND off cooldown. (AbilityReadyAt is set cooldown-ahead on use, so this is
// false through the active window too.) Drives both casting and which grenade the player should hold.
private bool AbilityUsable(PlayerData pd, int i) => AbilityUnlocked(pd, i) && AbilityReady(pd, i);
// ---- grenade-key lifecycle: hold the ability's grenade exactly while that ability is castable ----
private void ReconcileAllGrenades()
{
if (!Config.Abilities.Enabled || !Config.Abilities.GrenadeKeyInput) return;
foreach (var p in Utilities.GetPlayers())
{
if (!IsLiveHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick path)
if (ShopInputLocked(p.Slot)) continue; // shop owns the inputs while open (+ a grace after close)
if (PdOf(p) is { } pd) ReconcileGrenades(p, pd);
}
}
private void ReconcileGrenades(CCSPlayerController p, PlayerData pd)
{
for (int i = 0; i < AbilityCount; i++)
{
string g = AbilityGrenadeName(i);
if (g.Length == 0) continue;
bool shouldHold = AbilityUsable(pd, i);
bool holds = Inventory.Holds(p, g);
if (shouldHold && !holds) p.GiveNamedItem(g); // AutoSwitchTo=false on grenades -> no weapon switch
else if (!shouldHold && holds) p.RemoveItemByDesignerName(g);
}
}
// ---- input: an ability grenade becoming the active weapon = the player tried to use it ----
private void OnTick_Abilities(List<CCSPlayerController> players)
{
if (!Config.Abilities.Enabled || !Config.Abilities.GrenadeKeyInput) return;
foreach (var p in players)
{
if (!IsLiveHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick path)
if (ShopInputLocked(p.Slot)) continue; // shop owns the inputs while open (+ a grace after close)
var active = Inventory.ActiveWeapon(p);
if (active is null) continue;
int i = AbilityForGrenade(active.DesignerName);
if (i < 0) continue; // active weapon isn't an ability grenade — nothing to do
var pd = PdOf(p);
if (pd is null) continue;
// Cast if castable; either way confiscate the grenade and switch to the player's real weapon so it's
// never thrown and we never leave a grenade active. MUST be the best weapon they actually hold, not a
// hardcoded slot1 — on the Gun Game knife rung there's no primary, so slot1 would no-op and the next
// grenade would stay active, casting the NEXT ability every tick.
if (AbilityUsable(pd, i)) TryActivateAbility(p, i);
p.RemoveItemByDesignerName(AbilityGrenadeName(i)); // no-op if TryActivateAbility already removed it
p.ExecuteClientCommand(PreferredSlotCmd(p)); // primary -> pistol -> knife (whatever exists)
}
}
// ---- activation / usability ----
private void TryActivateAbility(CCSPlayerController p, int i)
{
if (!Config.Abilities.Enabled || i < 0 || i >= AbilityCount) return;
var pd = PdOf(p);
if (pd is null || !p.PawnIsAlive) return;
var def = AbilityCfg(i);
string name = AbilityRegistry[i].Name;
double now = Server.CurrentTime;
if (AbilityActive(pd, i)) { p.PrintToChat($"[Outnumbered] {name} is already active."); return; }
if (!AbilityReady(pd, i)) { p.PrintToChat($"[Outnumbered] {name} on cooldown ({pd.AbilityReadyAt[i] - now:F0}s)."); return; }
if (!AbilityUnlocked(pd, i))
{
p.PrintToChat($"[Outnumbered] {name} locked — need streak {def.StreakReq} (or Prestige {Roman(i + 1)}). Streak is {pd.Streak}.");
return;
}
pd.AbilityActiveUntil[i] = now + def.Duration;
// survival -Ability-Cooldown card shortens the cooldown (0 outside a run / without the card). Cooldown starts on
// use and keeps ticking through death.
double cd = def.Cooldown * Math.Max(0.0, 1.0 - EffectCardMag(pd, CardKeys.AbilityCdr) / 100.0);
pd.AbilityReadyAt[i] = now + cd;
if (Config.Abilities.GrenadeKeyInput) p.RemoveItemByDesignerName(AbilityGrenadeName(i)); // key dies now; reconciles back after cooldown
OnAbilityActivated(p, i);
p.PrintToChat($"[Outnumbered] {name} ACTIVE for {def.Duration:F0}s!");
}
private void OnAbilityActivated(CCSPlayerController p, int i)
{
if (i == AbNoReload) RefillActiveWeapon(p, fillClip: true); // top the clip immediately
PlaySound(p, Config.Sounds.AbilityActivate);
}
// ---- weapon ammo (No Reload + baseline infinite reserve) ----
private HookResult OnWeaponFire_Abilities(EventWeaponFire ev, GameEventInfo info)
{
var p = ev.Userid;
if (p is not { IsValid: true } || p.IsBot) return HookResult.Continue;
var pd = PdOf(p);
if (pd is null) return HookResult.Continue;
bool noReload = Config.Abilities.Enabled && AbilityActive(pd, AbNoReload);
if (Config.Abilities.InfiniteReserve || noReload) RefillActiveWeapon(p, fillClip: noReload);
return HookResult.Continue;
}
// SetStateChanged not needed for clip/ammo writes.
private void RefillActiveWeapon(CCSPlayerController p, bool fillClip)
{
var w = Inventory.ActiveWeapon(p);
if (w is null) return;
if (Config.Abilities.InfiniteReserve) w.ReserveAmmo[0] = Config.Abilities.InfiniteReserveAmount;
if (fillClip)
{
int max = Inventory.MaxClip(w);
if (max > 0) w.Clip1 = max;
}
}
// ---- !abilities / !perks ----
private void ShowAbilities(CCSPlayerController p)
{
var pd = PdOf(p);
if (pd is null) return;
double now = Server.CurrentTime;
p.PrintToChat("[Outnumbered] Killstreak abilities — select that grenade to cast, OR type !abilityN");
p.PrintToChat(" (custom binds? use !ability1..5, or bind a key: bind h css_ability1)");
for (int i = 0; i < AbilityCount; i++)
{
var def = AbilityCfg(i);
string state =
AbilityActive(pd, i) ? $"ACTIVE {pd.AbilityActiveUntil[i] - now:F0}s" :
!AbilityReady(pd, i) ? $"cooldown {pd.AbilityReadyAt[i] - now:F0}s" :
AbilityUnlocked(pd, i) ? "READY" :
$"locked (streak {def.StreakReq} / Prestige {Roman(i + 1)})";
p.PrintToChat($" {AbilityIcon(i)} {AbilityRegistry[i].Name} [{AbilityRegistry[i].KeyHint} or !ability{i + 1}] — {state}");
}
}
}

123
Outnumbered/Admin.cs Normal file
View file

@ -0,0 +1,123 @@
using System.Text.Json;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Admin;
using CounterStrikeSharp.API.Modules.Commands;
using Microsoft.Extensions.Logging;
using Outnumbered.Config;
using Outnumbered.Data;
namespace Outnumbered;
// Admin command suite (spec §13). Gated by @outnumbered/admin — add your SteamID to
// addons/counterstrikesharp/configs/admins.json with flag "@outnumbered/admin" (or "@css/root" for everything).
// Target arg: a name substring, "@me", or "@all". Console (no caller) is always allowed by CSSharp.
public sealed partial class OutnumberedPlugin
{
// PdOf(CCSPlayerController) lives in the resolution seam (Players.cs); the admin suite just uses it.
private static List<CCSPlayerController> ResolveTargets(string pattern, CCSPlayerController? caller)
{
var humans = Humans().ToList();
if (pattern is "@all" or "@a") return humans;
if ((pattern is "@me" or "@self") && caller is { IsValid: true }) return [caller];
return humans.Where(p => p.PlayerName.Contains(pattern, StringComparison.OrdinalIgnoreCase)).ToList();
}
// Apply an action to every matched target's PlayerData, then persist + refresh HP/armor + clantag.
private int ApplyToTargets(string pattern, CCSPlayerController? caller, Action<CCSPlayerController, PlayerData> action)
{
int n = 0;
foreach (var t in ResolveTargets(pattern, caller))
{
var pd = PdOf(t);
if (pd is null) continue;
action(t, pd);
pd.Dirty = true;
DeferReapplyCaps(t); // re-apply caps next frame (slot+SteamID-pinned) so a slot-reuse can't inherit this grant
ApplyClan(t, pd);
n++;
}
return n;
}
[RequiresPermissions("@outnumbered/admin")]
[ConsoleCommand("css_og_givexp", "[admin] grant XP")]
[CommandHelper(2, "<target> <amount>", CommandUsage.CLIENT_AND_SERVER)]
public void Cmd_GiveXp(CCSPlayerController? player, CommandInfo info)
{
if (!long.TryParse(info.GetArg(2), out var amt)) { info.ReplyToCommand("[Outnumbered] amount must be a number."); return; }
int n = ApplyToTargets(info.GetArg(1), player, (c, pd) => GrantXp(pd, amt, c));
info.ReplyToCommand($"[Outnumbered] gave {amt} XP to {n} player(s).");
}
[RequiresPermissions("@outnumbered/admin")]
[ConsoleCommand("css_og_givepoints", "[admin] grant skill points")]
[CommandHelper(2, "<target> <amount>", CommandUsage.CLIENT_AND_SERVER)]
public void Cmd_GivePoints(CCSPlayerController? player, CommandInfo info)
{
if (!int.TryParse(info.GetArg(2), out var amt)) { info.ReplyToCommand("[Outnumbered] amount must be a number."); return; }
int n = ApplyToTargets(info.GetArg(1), player, (_, pd) => pd.Points = Math.Max(0, pd.Points + amt));
info.ReplyToCommand($"[Outnumbered] gave {amt} point(s) to {n} player(s).");
}
[RequiresPermissions("@outnumbered/admin")]
[ConsoleCommand("css_og_setlevel", "[admin] set level")]
[CommandHelper(2, "<target> <level>", CommandUsage.CLIENT_AND_SERVER)]
public void Cmd_SetLevel(CCSPlayerController? player, CommandInfo info)
{
if (!int.TryParse(info.GetArg(2), out var lvl)) { info.ReplyToCommand("[Outnumbered] level must be a number."); return; }
lvl = Math.Clamp(lvl, 1, Config.Progression.LevelCap);
int n = ApplyToTargets(info.GetArg(1), player, (_, pd) => { pd.Level = lvl; pd.Xp = 0; });
info.ReplyToCommand($"[Outnumbered] set level {lvl} on {n} player(s).");
}
[RequiresPermissions("@outnumbered/admin")]
[ConsoleCommand("css_og_setprestige", "[admin] set prestige")]
[CommandHelper(2, "<target> <prestige>", CommandUsage.CLIENT_AND_SERVER)]
public void Cmd_SetPrestige(CCSPlayerController? player, CommandInfo info)
{
if (!int.TryParse(info.GetArg(2), out var pres)) { info.ReplyToCommand("[Outnumbered] prestige must be a number."); return; }
pres = Math.Clamp(pres, 0, Config.Progression.PrestigeCap);
int n = ApplyToTargets(info.GetArg(1), player, (_, pd) => pd.Prestige = pres);
info.ReplyToCommand($"[Outnumbered] set prestige {pres} on {n} player(s).");
}
[RequiresPermissions("@outnumbered/admin")]
[ConsoleCommand("css_og_resetplayer", "[admin] full progression reset")]
[CommandHelper(1, "<target>", CommandUsage.CLIENT_AND_SERVER)]
public void Cmd_ResetPlayer(CCSPlayerController? player, CommandInfo info)
{
int n = ApplyToTargets(info.GetArg(1), player, (_, pd) =>
{
pd.Xp = 0; pd.Level = 1; pd.Points = 1; pd.Prestige = 0; pd.Upgrades.Clear();
});
info.ReplyToCommand($"[Outnumbered] reset {n} player(s) to L1.");
}
[RequiresPermissions("@outnumbered/admin")]
[ConsoleCommand("css_og_reload", "[admin] live-reload outnumbered.json + ranks.json (no session reset)")]
[CommandHelper(0, "", CommandUsage.CLIENT_AND_SERVER)]
public void Cmd_ReloadConfig(CCSPlayerController? player, CommandInfo info)
{
try
{
string path = ConfigPath("outnumbered.json"); // assembly-name-derived (Ranks.cs) — no hardcoded folder literal
if (File.Exists(path))
{
var c = JsonSerializer.Deserialize<OutnumberedConfig>(File.ReadAllText(path),
new JsonSerializerOptions { ReadCommentHandling = JsonCommentHandling.Skip, PropertyNameCaseInsensitive = true });
if (c is not null) Config = c; // sub-sections have new() defaults, so missing keys never null out
}
RebuildEffectiveHandicap(); // re-resolve base Handicap + the active mode's override from the new config
RebuildStatDefs(); // re-resolve the key->StatDef registry from the new Config.Stats
LoadRanksConfig();
RebuildBalancePayload(); // the API's balance verb serves the EFFECTIVE config — must track every reload
info.ReplyToCommand("[Outnumbered] reloaded outnumbered.json + ranks.json.");
}
catch (Exception ex)
{
info.ReplyToCommand("[Outnumbered] reload failed: " + ex.Message);
Logger.LogError(ex, "Outnumbered og_reload failed");
}
}
}

216
Outnumbered/Api.cs Normal file
View file

@ -0,0 +1,216 @@
using System.Text.Json;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Modules.Cvars;
using CounterStrikeSharp.API.Modules.Timers;
using Microsoft.Extensions.Logging;
using Outnumbered.Domain;
using Outnumbered.Engine;
namespace Outnumbered;
// The local API: one Unix domain socket per instance (<SocketDir>/<ServerId>.sock). Three READ verbs —
// status : live server snapshot (map, counts, humans, mode extras), rebuilt on a ~2s game-thread timer
// balance : the EFFECTIVE in-memory config (mode-resolved handicap) + dense neutral curves, rebuilt at load + !og_reload
// top : the three leaderboards, queried per request (repository methods are contractually off-thread, DB-only)
// — plus ONE operator verb, drain (announce + kick all humans so a pending SIGINT "shutdown when empty" completes
// cleanly; the deploy flow's kick-before-stop). The socket's 0660 file mode is the drain verb's entire access control:
// only the cs2 user (the site + the deploy scripts) can connect. The socket thread NEVER touches game state directly:
// reads serve pre-serialized byte[]; drain marshals through Server.NextFrame. Every payload carries V + GeneratedAtMs.
public sealed partial class OutnumberedPlugin
{
private const int ApiSchemaVersion = 1;
private const float StatusRebuildSeconds = 2.0f;
private const int ApiTopN = 50;
private const int CurveSteps = 200; // t = -1..+1 inclusive -> 201 samples at 0.01
private UdsServer? _api;
private CounterStrikeSharp.API.Modules.Timers.Timer? _statusTimer;
private volatile byte[] _statusJson = ApiErr("not-ready"); // served until the first game-thread rebuild lands
private volatile byte[] _balanceJson = ApiErr("not-ready");
private bool _engineReady; // set once SetupMap has run — engine statics (Server.MapName etc.) crash before that
private bool _apiErrLogged; // one socket-error log line per load, not one per request
private string _apiServerId = "";
private int _apiPort;
private static byte[] ApiErr(string code) => JsonSerializer.SerializeToUtf8Bytes(new { Err = code });
private void Initialize_Api()
{
if (!Config.Api.Enabled) return;
_apiServerId = ServerId();
var args = CommandLineArgs(); // same -port -> +hostport fallback chain ServerId() resolves with
_apiPort = int.TryParse(ArgValue(args, "port") ?? ArgValue(args, "hostport"), out int port) ? port : 0;
RebuildBalancePayload(); // config + domain math only — engine-safe at Load
string path = Path.Combine(Config.Api.SocketDir, _apiServerId + ".sock");
try
{
_api = new UdsServer(path, HandleApiRequest, ex =>
{
if (_apiErrLogged) return;
_apiErrLogged = true;
Logger.LogWarning(ex, "Outnumbered API socket error (further ones suppressed this load)");
});
}
catch (Exception ex)
{
// Missing/unwritable socket dir (dev box without the tmpfiles.d entry) — the API is optional, the game isn't.
Logger.LogWarning(ex, "Outnumbered API disabled: cannot bind {Path}", path);
return;
}
_statusTimer = AddTimer(StatusRebuildSeconds, RebuildStatusPayload, TimerFlags.REPEAT);
Logger.LogInformation("Outnumbered API listening on {Path}", path);
}
private void Shutdown_Api()
{
_statusTimer?.Kill();
_api?.Dispose(); // closes the listener + unlinks the socket file, so a hot-reload can re-bind
_api = null;
}
// Socket-thread dispatch: cached bytes for status/balance; top is the one per-request worker (DB-only).
private Task<byte[]> HandleApiRequest(string verb) => verb switch
{
"status" => Task.FromResult(_statusJson),
"balance" => Task.FromResult(_balanceJson),
"top" => BuildTopPayload(),
"drain" => Drain(),
_ => Task.FromResult(ApiErr("unknown-verb")),
};
private const float DrainKickDelaySeconds = 5.0f; // long enough to read the announce, short enough for a deploy
// Deploy-flow kick: announce, then kick every human, so the engine's SIGINT "shutdown when empty" completes with a
// CLEAN plugin unload (persistence flush) instead of the stop timeout's SIGKILL. Bots don't block shutdown.
// Replies immediately; the caller sleeps past the delay before systemctl stop.
private Task<byte[]> Drain()
{
Server.NextFrame(() =>
{
if (!_live) return;
Server.PrintToChatAll(" [Outnumbered] Server is restarting for an update — reconnect in a minute!");
AddTimer(DrainKickDelaySeconds, () =>
{
if (!_live) return;
foreach (var p in Utilities.GetPlayers())
if (IsHuman(p) && p.UserId is { } uid)
Server.ExecuteCommand($"kickid {uid} \"Server updating - back in a minute!\"");
});
});
return Task.FromResult(JsonSerializer.SerializeToUtf8Bytes(new { Ok = true, KickInSeconds = (int)DrainKickDelaySeconds }));
}
// Game-thread timer (~2s): mirrors the HUD gather (materialized roster -> IsHuman -> _players). Allocation per
// rebuild is fine at this cadence. Per-player handicap bands are deliberately NOT exposed.
private void RebuildStatusPayload()
{
if (!_engineReady) return; // pre-first-map: keep serving not-ready instead of crashing on engine statics
var players = Utilities.GetPlayers();
var humans = new List<object>(players.Count);
int bots = 0;
foreach (var p in players)
{
if (IsBot(p)) { bots++; continue; }
if (!IsHuman(p)) continue;
var sid = p.AuthorizedSteamID?.SteamId64;
if (sid is null || !_players.TryGetValue(sid.Value, out var pd)) continue;
humans.Add(new
{
pd.Name,
pd.Level,
pd.Prestige,
pd.Kills,
pd.Deaths,
pd.Streak,
Rung = pd.GgRung, // site maps Rung -> weapon via the GG StatusExtra ladder; harmless zero in other modes
});
}
_statusJson = JsonSerializer.SerializeToUtf8Bytes(new
{
V = ApiSchemaVersion,
GeneratedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
ServerId = _apiServerId,
Mode = _driver.Id,
Map = Server.MapName,
Hostname = ConVar.Find("hostname")?.StringValue ?? "",
Port = _apiPort,
MaxHumans = _driver.MaxHumansOnCt,
Bots = bots,
Humans = humans,
Extra = _driver.StatusExtra(),
});
}
// The effective balance: the LIVE Config (post-!og_reload) with the mode-resolved handicap, plus neutral curves
// sampled through the same compiled HandicapModel code that scales damage. EXPLICIT allowlist of blocks — never
// the root Config (Database carries credentials). Called at Initialize_Api and from Cmd_ReloadConfig.
internal void RebuildBalancePayload()
{
// Guarded end-to-end: pathological config (e.g. Curve <= 0 -> Pow(0, neg) = Infinity -> the serializer throws
// on non-finite doubles) must degrade the API verb, never the plugin — the live damage path tolerates the same
// config without throwing, and this runs unguarded inside Load.
try
{
_balanceJson = BuildBalanceJson();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Outnumbered API balance payload rebuild failed — serving 'balance-unavailable'");
_balanceJson = ApiErr("balance-unavailable");
}
}
private byte[] BuildBalanceJson()
{
var t = new double[CurveSteps + 1];
var deal = new double[CurveSteps + 1];
var take = new double[CurveSteps + 1];
var xp = new double[CurveSteps + 1];
for (int i = 0; i <= CurveSteps; i++)
{
t[i] = -1.0 + i * (2.0 / CurveSteps);
HandicapModel.BandsFromT(t[i], _hcap, out deal[i], out take[i], out xp[i]);
}
return JsonSerializer.SerializeToUtf8Bytes(new
{
V = ApiSchemaVersion,
GeneratedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
ServerId = _apiServerId,
Mode = _driver?.Id ?? Config.Mode, // driver not yet selected only if called before Initialize_Driver
Match = Config.Match,
Stats = Config.Stats,
Progression = Config.Progression,
Abilities = Config.Abilities,
GunGame = Config.GunGame,
Survival = Config.Survival,
EffectiveHandicap = _hcap.Config,
Curves = new { T = t, Deal = deal, Take = take, Xp = xp },
});
}
// Socket thread: repository calls only (no game objects), mirroring the Cmd_Top precedent. A failure (boot race
// with the fire-and-forget EnsureSchema, DB down) degrades to an err payload — the site renders last-good.
private async Task<byte[]> BuildTopPayload()
{
try
{
var levels = await _repo.GetTopAsync(ApiTopN);
var waves = await _repo.GetTopWavesAsync(ApiTopN);
var ggTimes = await _repo.GetTopGgAsync(ApiTopN);
return JsonSerializer.SerializeToUtf8Bytes(new
{
V = ApiSchemaVersion,
GeneratedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
Levels = levels,
Waves = waves,
GgTimes = ggTimes,
});
}
catch (Exception ex)
{
if (!_apiErrLogged) { _apiErrLogged = true; Logger.LogWarning(ex, "Outnumbered API top query failed"); }
return ApiErr("db");
}
}
}

116
Outnumbered/Commands.cs Normal file
View file

@ -0,0 +1,116 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Admin;
using CounterStrikeSharp.API.Modules.Commands;
using CounterStrikeSharp.API.Modules.Utils;
namespace Outnumbered;
public sealed partial class OutnumberedPlugin
{
// Player commands ([ConsoleCommand] auto-registers; css_ maps to ! / chat triggers).
// Info commands (!rank/!me/!stats/!top/!info) live in Ranks.cs; weapon menu in Weapons.cs; !s1..!sN in Progression.cs.
private static readonly string[] PlayerCommandList =
{
"Shop: switch to the healthshot (X) — numbers select, X = back/close, crouch = quick exit",
"!skills — open the skill tree | !s1-!sN — buy a skill directly",
"!prestige — reset at level 100 for a permanent boost",
"!guns (!weapons, !loadout) — weapon menu | !rifles !smgs !snipers !shotguns !heavy !pistols",
"!abilities (!perks) — your killstreak abilities | !ability1-5 — activate one",
"!rank (!me) — rank/level/prestige | !stats — live bonuses & handicap",
"!top (!leaderboard) — top players | !info (!about, !help) — how it works",
"!hud — toggle the HUD | !dmg (!damage) — toggle the per-hit damage readout",
"!commands — this list",
};
private static readonly string[] AdminCommandList =
{
"targets: a player name, @me, or @all",
"!og_givexp <target> <amount>",
"!og_givepoints <target> <amount>",
"!og_setlevel <target> <level>",
"!og_setprestige <target> <prestige>",
"!og_resetplayer <target> — full progression reset",
"!og_reload — live-reload config + ranks (no session reset)",
};
[ConsoleCommand("css_commands", "List all player commands")]
[ConsoleCommand("css_cmds", "List all player commands")]
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
public void Cmd_Commands(CCSPlayerController? p, CommandInfo info)
{
if (p is not { IsValid: true }) return;
p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Commands:");
foreach (var line in PlayerCommandList) p.PrintToChat($" {ChatColors.Lime}{line}");
}
[RequiresPermissions("@outnumbered/admin")]
[ConsoleCommand("css_admin_commands", "List admin commands")]
[ConsoleCommand("css_admincommands", "List admin commands")]
[CommandHelper(0, "", CommandUsage.CLIENT_AND_SERVER)]
public void Cmd_AdminCommands(CCSPlayerController? p, CommandInfo info)
{
if (p is not { IsValid: true }) return;
p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Admin commands:");
foreach (var line in AdminCommandList) p.PrintToChat($" {ChatColors.Lime}{line}");
}
[ConsoleCommand("css_skills", "Open the skill tree to spend points")]
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
public void Cmd_Skills(CCSPlayerController? player, CommandInfo info)
{
if (player is { IsValid: true }) OpenSkillMenu(player);
}
[ConsoleCommand("css_prestige", "Prestige (at max level) for a permanent boost")]
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
public void Cmd_Prestige(CCSPlayerController? player, CommandInfo info)
{
if (player is { IsValid: true }) OpenPrestige(player);
}
[ConsoleCommand("css_abilities", "Show your killstreak abilities")]
[ConsoleCommand("css_perks", "Show your killstreak abilities")]
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
public void Cmd_Abilities(CCSPlayerController? player, CommandInfo info)
{
if (player is { IsValid: true }) ShowAbilities(player);
}
[ConsoleCommand("css_dmg", "Toggle a per-hit final-damage readout (tuning aid)")]
[ConsoleCommand("css_damage", "Toggle a per-hit final-damage readout (tuning aid)")]
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
public void Cmd_Dmg(CCSPlayerController? player, CommandInfo info)
{
if (player is not { IsValid: true }) return;
var sid = player.AuthorizedSteamID?.SteamId64;
if (sid is null) return;
if (_dmgReadout.Remove(sid.Value)) player.PrintToChat("[Outnumbered] damage readout OFF.");
else { _dmgReadout.Add(sid.Value); player.PrintToChat("[Outnumbered] damage readout ON — final hp/armor shown per hit."); }
}
[ConsoleCommand("css_hud", "Toggle the on-screen HUD on/off")]
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
public void Cmd_Hud(CCSPlayerController? player, CommandInfo info)
{
if (player is not { IsValid: true }) return;
var sid = player.AuthorizedSteamID?.SteamId64;
if (sid is null) return;
if (_hudOff.Remove(sid.Value)) player.PrintToChat("[Outnumbered] HUD ON.");
else { _hudOff.Add(sid.Value); player.PrintToChat("[Outnumbered] HUD OFF."); DestroyHud(player.Slot); player.PrintToCenterHtml(""); }
}
// Bindable alternates for the abilities (grenade keys are the primary input; these always work too).
// e.g. bind "h" "css_ability1". Registered as a loop tied to the AbilityRegistry (mirrors RegisterSkillCommands),
// so a new/reordered ability needs no per-command method. Called from Load.
private void RegisterAbilityCommands()
{
for (int i = 0; i < AbilityCount; i++)
{
int idx = i;
AddCommand($"css_ability{i + 1}", $"Activate ability {i + 1} ({AbilityRegistry[i].Name})",
(p, _) => { if (p is { IsValid: true }) TryActivateAbility(p, idx); });
}
}
}

View file

@ -0,0 +1,340 @@
using System.Text.Json.Serialization;
namespace Outnumbered.Config;
// The config sub-types the pure Domain layer reads (handicap / progression / stat / combat / survival economy). Split out
// of OutnumberedConfig.cs — which is CounterStrikeSharp-coupled (BasePluginConfig) — so the xUnit test project can
// <Compile Include> these alongside Domain/*.cs with NO CounterStrikeSharp reference. They're plain POCOs (System.Text.Json
// attributes only); the engine-coupled config (OutnumberedConfig + Hud/Shop/Match/GunGame/... blocks) stays in OutnumberedConfig.cs.
// One killstreak ability's tunables (spec §4). Magnitude meaning is per-ability:
// No Reload — (unused; clip-refill only)
// Adrenaline — Magnitude = % incoming-damage reduction
// Overcharge — Magnitude = % bonus outgoing damage
// Bloodthirst — Magnitude = +% HP lifesteal, Magnitude2 = +% armor lifesteal (additive, for the duration)
// Berserk — Magnitude = bonus damage per full missing-HP fraction, Magnitude2 = bonus crit-damage per missing-HP fraction
public sealed class AbilityDef
{
[JsonPropertyName("StreakReq")] public int StreakReq { get; set; }
[JsonPropertyName("Cooldown")] public float Cooldown { get; set; }
[JsonPropertyName("Duration")] public float Duration { get; set; }
[JsonPropertyName("Magnitude")] public double Magnitude { get; set; }
[JsonPropertyName("Magnitude2")] public double Magnitude2 { get; set; }
// "Movie filter" tint shown while this ability is active (blended when several are up). Spec §7 juice.
[JsonPropertyName("TintR")] public int TintR { get; set; }
[JsonPropertyName("TintG")] public int TintG { get; set; }
[JsonPropertyName("TintB")] public int TintB { get; set; }
}
// The 5 killstreak abilities + baseline infinite-reserve ammo (spec §4/§8).
public sealed class AbilitiesConfig
{
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
// Active-ability screen tint ("movie filter") via the CS2 fade user message. UNVERIFIED format — toggle off
// if it misbehaves; the HUD active-banner is the reliable fallback. TintAlpha = overlay strength (0-255).
[JsonPropertyName("ScreenTint")] public bool ScreenTint { get; set; } = true;
[JsonPropertyName("TintAlpha")] public int TintAlpha { get; set; } = 50;
// sv_infinite_ammo is cheat-flagged at sv_cheats 0, so reserve is topped up in code (spec §1).
[JsonPropertyName("InfiniteReserve")] public bool InfiniteReserve { get; set; } = true;
[JsonPropertyName("InfiniteReserveAmount")] public int InfiniteReserveAmount { get; set; } = 250;
// Grenade-key input: a grenade-select key (slot6-10) only reaches the server while the player holds
// that grenade. So we GRANT the matching grenade exactly while its ability is castable and REMOVE it on
// use — the key is live only when the ability is, the grenade can never be thrown, and there's no clutter.
[JsonPropertyName("GrenadeKeyInput")] public bool GrenadeKeyInput { get; set; } = true;
// Indexed by ability (0=No Reload, 1=Adrenaline, 2=Overcharge, 3=Bloodthirst, 4=Berserk). CT uses
// weapon_incgrenade (not molotov). Must stay aligned with AbilityForGrenade in Abilities.cs.
[JsonPropertyName("AbilityGrenades")]
public List<string> AbilityGrenades { get; set; } = new()
{ "weapon_hegrenade", "weapon_flashbang", "weapon_smokegrenade", "weapon_incgrenade", "weapon_decoy" };
[JsonPropertyName("NoReload")] public AbilityDef NoReload { get; set; } = new() { StreakReq = 10, Cooldown = 40, Duration = 12, TintR = 255, TintG = 221, TintB = 85 };
[JsonPropertyName("Adrenaline")] public AbilityDef Adrenaline { get; set; } = new() { StreakReq = 20, Cooldown = 50, Duration = 8, Magnitude = 35, TintR = 90, TintG = 150, TintB = 255 };
[JsonPropertyName("Overcharge")] public AbilityDef Overcharge { get; set; } = new() { StreakReq = 30, Cooldown = 60, Duration = 8, Magnitude = 50, TintR = 255, TintG = 140, TintB = 40 };
[JsonPropertyName("Bloodthirst")] public AbilityDef Bloodthirst { get; set; } = new() { StreakReq = 40, Cooldown = 70, Duration = 8, Magnitude = 60, Magnitude2 = 40, TintR = 90, TintG = 220, TintB = 90 };
[JsonPropertyName("Berserk")] public AbilityDef Berserk { get; set; } = new() { StreakReq = 50, Cooldown = 90, Duration = 10, Magnitude = 1.5, Magnitude2 = 1.5, TintR = 255, TintG = 60, TintB = 60 };
}
// The per-player balance handicap (spec §5). A single signed index t in [-1,+1] drives all three
// outputs (deal / take / XP) so they move together and hit their extremes at the SAME thresholds.
// t = N - B. N (nerf, 0..1) = weighted avg of four factors each normalised to its "maxed" threshold;
// B (buff, 0..1) = how far K/D sits below neutral (comeback help).
public sealed class HandicapConfig
{
// These defaults are tuned so the 100-point stat ceiling (~x3 offense) can't outscale the reachable nerf: Kd/Level are
// up-weighted and LevelMaxNerf spans the full point-investment range, keeping "better = harder + more XP, worse =
// easier + less XP" monotonic (otherwise a leveled/statted player inverts the handicap into a net advantage).
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
// Scales the whole signed deviation from neutral. 0 = off, ~1 = mild, 1.6 = retuned designed value, >2 = brutal.
[JsonPropertyName("MasterDifficulty")] public double MasterDifficulty { get; set; } = 1.6;
// Shapes |t| -> output. 1 = linear; >1 = eases IN (mild leads barely touched, then a hard cliff at the top —
// a wide "god with base stats" band); <1 = bites EARLY (debuff ramps fast the moment you pull ahead, then
// saturates). Lower = punishes a moderate lead sooner/harder. 1.3 keeps a small mild-lead toe.
[JsonPropertyName("Curve")] public double Curve { get; set; } = 1.3;
// Each nerf factor ramps 0->1 from neutral to its threshold; ALL four at threshold -> full nerf (t=1).
[JsonPropertyName("KdNeutral")] public double KdNeutral { get; set; } = 1.5; // K/D break-even (vs-many-bots avg is >1)
[JsonPropertyName("KdMaxNerf")] public double KdMaxNerf { get; set; } = 4.0; // K/D that maxes the K/D factor (4 so realistic 3-4 K/D bites, not just 5+)
[JsonPropertyName("KdMinBuff")] public double KdMinBuff { get; set; } = 0.5; // K/D that maxes the comeback buff
[JsonPropertyName("HsMaxNerf")] public double HsMaxNerf { get; set; } = 0.60; // headshot-kill rate that maxes the HS factor
[JsonPropertyName("HsMinKills")] public int HsMinKills { get; set; } = 5; // ignore HS rate until this many kills
[JsonPropertyName("StreakMaxNerf")] public int StreakMaxNerf { get; set; } = 20; // killstreak that maxes the streak factor
[JsonPropertyName("LevelMaxNerf")] public int LevelMaxNerf { get; set; } = 100; // level that maxes the level factor (100 spans the full point-investment range)
// Relative weights of the factors in the nerf average (normalised by their sum). Kd + Level up-weighted: those are the
// axes a skilled player actually maxes, so winning without farming HS/streak still bites.
[JsonPropertyName("KdWeight")] public double KdWeight { get; set; } = 2.0;
[JsonPropertyName("HsWeight")] public double HsWeight { get; set; } = 1.0;
[JsonPropertyName("StreakWeight")] public double StreakWeight { get; set; } = 1.0;
[JsonPropertyName("LevelWeight")] public double LevelWeight { get; set; } = 1.5;
// Weight of the active mode's "progress" axis (0..1, supplied by the driver). 0 in TDM (no axis). In Gun Game
// it's ladder position, so climbing nerfs you harder and a bot-demotion eases it. Default 0 (off for TDM).
[JsonPropertyName("ProgressWeight")] public double ProgressWeight { get; set; } = 0.0;
// Output bands, linear in t. At t=+1 deal=Floor / take=Ceiling / xp=Ceiling; at t=-1 deal=Ceiling / take=Floor / xp=Floor.
[JsonPropertyName("MDealFloor")] public double MDealFloor { get; set; } = 0.033; // dominant players deal ~3.3% base; the +50% Damage stat lands the effective body-shot floor at ~0.05 (GG/Survival pin this back to 0.1, where the ladder/wave needs a viable deal)
[JsonPropertyName("MDealCeiling")] public double MDealCeiling { get; set; } = 1.3; // strugglers deal up to 130% (compressed: the buff multiplies onto stats)
[JsonPropertyName("MTakeFloor")] public double MTakeFloor { get; set; } = 0.85; // strugglers take down to 85% (compressed comeback band)
[JsonPropertyName("MTakeCeiling")] public double MTakeCeiling { get; set; } = 8.0; // dominant players take 800%
[JsonPropertyName("XpFloor")] public double XpFloor { get; set; } = 0.1; // strugglers climb at 10%
[JsonPropertyName("XpCeiling")] public double XpCeiling { get; set; } = 10.0; // dominant players climb at 1000%
}
// Per-mode handicap override — a nullable mirror of HandicapConfig. The base `Handicap` block is the DEFAULT;
// each mode may carry its own `Handicap` sub-object overriding ONLY the fields it sets, e.g.
// "GunGame": { "Handicap": { "Curve": 1.6, "MTakeCeiling": 5.0 } } — everything else inherits the base. The effective
// config is resolved per active mode (Handicap.cs RebuildEffectiveHandicap). Add new fields here AND in ApplyTo.
public sealed class HandicapOverride
{
// Every field is nullable and WhenWritingNull-ignored: an unset field is OMITTED from the generated JSON entirely
// (rather than emitted as a confusing `null`), so a mode's Handicap block lists ONLY the fields it actually overrides
// and the rest transparently inherit the base block. A missing field still deserializes to null -> inherit.
[JsonPropertyName("Enabled"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? Enabled { get; set; }
[JsonPropertyName("MasterDifficulty"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MasterDifficulty { get; set; }
[JsonPropertyName("Curve"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? Curve { get; set; }
[JsonPropertyName("KdNeutral"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? KdNeutral { get; set; }
[JsonPropertyName("KdMaxNerf"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? KdMaxNerf { get; set; }
[JsonPropertyName("KdMinBuff"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? KdMinBuff { get; set; }
[JsonPropertyName("HsMaxNerf"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? HsMaxNerf { get; set; }
[JsonPropertyName("HsMinKills"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? HsMinKills { get; set; }
[JsonPropertyName("StreakMaxNerf"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? StreakMaxNerf { get; set; }
[JsonPropertyName("LevelMaxNerf"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? LevelMaxNerf { get; set; }
[JsonPropertyName("KdWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? KdWeight { get; set; }
[JsonPropertyName("HsWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? HsWeight { get; set; }
[JsonPropertyName("StreakWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? StreakWeight { get; set; }
[JsonPropertyName("LevelWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? LevelWeight { get; set; }
[JsonPropertyName("ProgressWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? ProgressWeight { get; set; }
[JsonPropertyName("MDealFloor"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MDealFloor { get; set; }
[JsonPropertyName("MDealCeiling"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MDealCeiling { get; set; }
[JsonPropertyName("MTakeFloor"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MTakeFloor { get; set; }
[JsonPropertyName("MTakeCeiling"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MTakeCeiling { get; set; }
[JsonPropertyName("XpFloor"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? XpFloor { get; set; }
[JsonPropertyName("XpCeiling"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? XpCeiling { get; set; }
// Produce a fresh effective config = base with this override's non-null fields applied.
public HandicapConfig ApplyTo(HandicapConfig b) => new()
{
Enabled = Enabled ?? b.Enabled,
MasterDifficulty = MasterDifficulty ?? b.MasterDifficulty,
Curve = Curve ?? b.Curve,
KdNeutral = KdNeutral ?? b.KdNeutral,
KdMaxNerf = KdMaxNerf ?? b.KdMaxNerf,
KdMinBuff = KdMinBuff ?? b.KdMinBuff,
HsMaxNerf = HsMaxNerf ?? b.HsMaxNerf,
HsMinKills = HsMinKills ?? b.HsMinKills,
StreakMaxNerf = StreakMaxNerf ?? b.StreakMaxNerf,
LevelMaxNerf = LevelMaxNerf ?? b.LevelMaxNerf,
KdWeight = KdWeight ?? b.KdWeight,
HsWeight = HsWeight ?? b.HsWeight,
StreakWeight = StreakWeight ?? b.StreakWeight,
LevelWeight = LevelWeight ?? b.LevelWeight,
ProgressWeight = ProgressWeight ?? b.ProgressWeight,
MDealFloor = MDealFloor ?? b.MDealFloor,
MDealCeiling = MDealCeiling ?? b.MDealCeiling,
MTakeFloor = MTakeFloor ?? b.MTakeFloor,
MTakeCeiling = MTakeCeiling ?? b.MTakeCeiling,
XpFloor = XpFloor ?? b.XpFloor,
XpCeiling = XpCeiling ?? b.XpCeiling,
};
}
// XP / level / prestige economy (spec §2). Curve: xp to go from level L to L+1
// = LevelXpBase + LevelXpStep * (L-1)^LevelXpExponent. Exponent 1.0 = linear; >1 = progressive (accelerating).
public sealed class ProgressionConfig
{
// XP is earned per-hit on damage to bots (not a flat per-kill lump): final HP damage × rate, plus flat
// bonuses for headshots/crits/the kill itself. Each component is run through the same XP multipliers
// (Xp-Boost stat × prestige × handicap) at grant time. A clean 100-HP bodyshot kill = 100×Rate + KillBonus.
[JsonPropertyName("DamageXpPerHp")] public double DamageXpPerHp { get; set; } = 0.15; // XP per point of final HP damage
[JsonPropertyName("KillXpBonus")] public double KillXpBonus { get; set; } = 10; // flat, on the lethal blow (0 = kills give no XP)
[JsonPropertyName("HeadshotXpBonus")] public double HeadshotXpBonus { get; set; } = 10; // flat, per headshot hit
[JsonPropertyName("CritXpBonus")] public double CritXpBonus { get; set; } = 10; // flat, per crit hit
[JsonPropertyName("LevelXpBase")] public long LevelXpBase { get; set; } = 100; // flat cost of level 1 -> 2
[JsonPropertyName("LevelXpStep")] public long LevelXpStep { get; set; } = 8; // coefficient on the curve term
[JsonPropertyName("LevelXpExponent")] public double LevelXpExponent { get; set; } = 1.7; // curve steepness (more convex = gentler low, steeper high)
[JsonPropertyName("LevelCap")] public int LevelCap { get; set; } = 100;
[JsonPropertyName("PrestigeCap")] public int PrestigeCap { get; set; } = 10;
[JsonPropertyName("PrestigeXpBoostPercent")] public double PrestigeXpBoostPercent { get; set; } = 10; // +% XP per prestige (25 let prestige override the struggler XP penalty)
}
// One survival draft card: a run-scoped buff that maps onto an existing stat key (so EffRun stacks it on top of the
// permanent stat, "everything stacks") with a per-pick magnitude in Eff units + a pick cap. Cards are STRONG on
// purpose — they must be able to outscale the escalating handicap floor. Bias the magnitudes UP and tune live.
public sealed class SurvivalCardDef
{
[JsonPropertyName("Key")] public string Key { get; set; } = ""; // a StatKeys.* value (damage, crit_damage, max_hp, ...) or a CardKeys.* effect key
[JsonPropertyName("Name")] public string Name { get; set; } = ""; // shown on the draft card
[JsonPropertyName("PerPick")] public double PerPick { get; set; } // added to Eff per pick (Damage 100 = +100% = x2/pick on top of base)
[JsonPropertyName("Cap")] public int Cap { get; set; } = 3; // max times this card can be drafted in a run
// ---- draft DISPLAY/COUNTING metadata (data-driven so a new card doesn't need code edits in Survival's switches) ----
[JsonPropertyName("IsTeam")] public bool IsTeam { get; set; } // squad-wide team card (shared level via MDeal/MTake) vs per-player
[JsonPropertyName("Flat")] public bool Flat { get; set; } // the draft shows "+N" flat points (HP/armor caps, flat regen) instead of "+N%"
// Optional draft detail line for EFFECT cards where a raw +N% would mislead. A string.Format template; {0} = the
// value after the next pick (level x PerPick). Empty -> the card shows the standard Now%->Next% panel. (The compounding
// team cards + Burn carry their own computed detail in code; everything else lives here as data.)
[JsonPropertyName("Detail")] public string Detail { get; set; } = "";
public SurvivalCardDef() { }
public SurvivalCardDef(string key, string name, double perPick, int cap, bool isTeam = false, bool flat = false, string detail = "")
{ Key = key; Name = name; PerPick = perPick; Cap = cap; IsTeam = isTeam; Flat = flat; Detail = detail; }
}
// Wave Survival mode (Mode="survival", see Survival.cs). Co-op escalating bot waves on the small arms-race maps.
// VANILLA bots (never tougher) — escalation is MORE bots + the inherited handicap floor tightening per wave. The
// roguelite DRAFT (strong run-scoped cards) is the counter-pressure; you outscale until the floor + horde win.
// Run-XP is a SEPARATE accumulator converted to main XP once at run-end. All values live-tunable via !og_reload.
public sealed class SurvivalConfig
{
// ---- bot population (vanilla HP; only the COUNT escalates) ----
[JsonPropertyName("AliveCap")] public int AliveCap { get; set; } = 20; // hard ceiling on bots alive at once (CPU lever; 20 = BotsPerHuman 5 x 4 conceptually)
// Bots ALIVE at once this wave = AliveBase + AlivePerWave*(wave-1), clamped to [1, AliveCap]. Separate from the kill
// budget: you face this many simultaneously (they respawn as you kill) until the wave's total kills are reached.
[JsonPropertyName("AliveBase")] public int AliveBase { get; set; } = 4; // alive at once on wave 1 (gentle start)
[JsonPropertyName("AlivePerWave")] public int AlivePerWave { get; set; } = 2; // +alive/wave -> hits AliveCap around wave 9
[JsonPropertyName("MaxHumansOnCt")] public int MaxHumansOnCt { get; set; } = 5; // co-op cap (EnforceHumanTeam)
// Total kills to clear a wave = BudgetBase + BudgetPerWave*(wave-1) + BudgetPerPlayer*aliveHumansAtWaveStart.
[JsonPropertyName("BudgetBase")] public int BudgetBase { get; set; } = 6;
[JsonPropertyName("BudgetPerWave")] public int BudgetPerWave { get; set; } = 4;
[JsonPropertyName("BudgetPerPlayer")] public int BudgetPerPlayer { get; set; } = 3;
// ---- wave structure ----
[JsonPropertyName("WaveCount")] public int WaveCount { get; set; } = 20; // finite campaign; clearing this = WIN. Tune by feel + XP-vs-TDM/GG.
[JsonPropertyName("WaveBreakSeconds")] public float WaveBreakSeconds { get; set; } = 15f;
[JsonPropertyName("WaveTimeoutSeconds")] public float WaveTimeoutSeconds { get; set; } = 240f; // no kill for this long (despite nudges) = genuine deadlock -> fail the run (measures time since last KILL, not wall-time)
[JsonPropertyName("StallNudgeSeconds")] public float StallNudgeSeconds { get; set; } = 20f; // no kill for this long -> teleport the straggler bots to the squad (so the wave never stalls/ends with bots alive)
[JsonPropertyName("StartGraceSeconds")] public float StartGraceSeconds { get; set; } = 6f; // wait this long after a (re)spawn-able map before starting wave 1
// ---- death / revive ----
[JsonPropertyName("ReviveOnWaveClear")] public bool ReviveOnWaveClear { get; set; } = true; // false = hardcore "you die, you spectate the rest of the run"
[JsonPropertyName("ReviveHpPercent")] public double ReviveHpPercent { get; set; } = 0.5;
// ---- XP economy (granted PER WAVE at each wave clear, NOT once at run-end). Each cleared wave grants:
// rawWaveXp (HP-damage + HS/crit, handicap-mult EXCLUDED) x prestige x waveMult(wave).
// WinMult is the ONLY knob: the FINAL-wave / "win" multiplier. The per-wave multiplier ramps exponentially from x1
// (wave 1) to xWinMult (wave WaveCount): waveMult(w) = WinMult^((w-1)/(WaveCount-1)) — early waves pay ~nothing, the
// last few pay big. NO per-run cap (depth is the gate: you must survive + contribute to reach the big back multipliers;
// an in-progress wave is forfeited on a wipe). Sized so a top CONTRIBUTOR (not a carried player) can climb toward max
// level over a DEEP run; tune with research/handicap-balance-calc.py --survival-wave. Default fits the ~50-wave
// production target — at the current WaveCount it pays proportionally less (re-solve when you raise WaveCount). ----
[JsonPropertyName("WinMult")] public double WinMult { get; set; } = 24.0;
// ---- the draft ----
[JsonPropertyName("DraftSize")] public int DraftSize { get; set; } = 3; // cards offered per pick
[JsonPropertyName("CardsPerWave")] public int CardsPerWave { get; set; } = 1; // picks granted on each wave clear
// ---- effect-card tunables (the new logic cards; all live-tunable) ----
[JsonPropertyName("BurnDamagePerSecond")] public double BurnDamagePerSecond { get; set; } = 3; // flat HP/s the Burn card deals (armor-skipping). Different attackers STACK; a re-hit just refreshes your own.
[JsonPropertyName("BurnDurationSeconds")] public float BurnDurationSeconds { get; set; } = 5f; // each hit (re)applies this much burn time
[JsonPropertyName("BurnTickSeconds")] public float BurnTickSeconds { get; set; } = 1f; // burn damage cadence (DPS held at BurnDamagePerSecond; changing this needs a reload to re-arm the timer)
[JsonPropertyName("ExplodeBaseDamage")] public double ExplodeBaseDamage { get; set; } = 100; // Explode-on-Kill HE base damage — then scaled UP by the killer's damage build + handicap on detonation
[JsonPropertyName("ExplodeRadius")] public double ExplodeRadius { get; set; } = 350; // Explode-on-Kill HE blast radius (engine distance falloff applies before the scaling)
[JsonPropertyName("ExplodeFuseSeconds")] public float ExplodeFuseSeconds { get; set; } = 0.1f; // delay before the corpse grenade detonates (0.1 = near-instant; bump for a telegraphed beep-then-boom)
// ---- handicap escalation ----
// The wave floor reaches max nerf (t=1) at MaxNerfWave (feel-tuned; cards must be able to outscale it). Monotonic.
[JsonPropertyName("MaxNerfWave")] public int MaxNerfWave { get; set; } = 25;
// Full per-mode handicap override (all bands tunable). Seeded with a higher MTakeCeiling than base (10x) because
// cards + permanent stats stack — a maxed-HP/lifesteal/regen/thorns build can be unkillable at 8x. Tune live.
// In survival the WAVE FLOOR is the difficulty driver, so the inherited factors are heavily down-weighted —
// otherwise a high-level player is pre-nerfed to near-max-take on wave 1 (level/KD maxing the factor before the
// floor even ramps). Low weights keep a mild skill component; the floor (-> max nerf at MaxNerfWave) does the work.
[JsonPropertyName("Handicap")]
public HandicapOverride? Handicap { get; set; } = new()
{
ProgressWeight = 0.0,
MTakeCeiling = 10.0,
KdWeight = 0.3,
HsWeight = 0.3,
StreakWeight = 0.3,
LevelWeight = 0.1,
MDealFloor = 0.1, // pin: the base dropped to 0.033 for TDM, but waves need a viable deal to be clearable
};
// Map pool (share the GG arms-race maps). EMPTY -> falls back to Match.Maps.
[JsonPropertyName("Maps")] public List<string> Maps { get; set; } = new();
// ---- the card catalog (each maps to a StatKeys.* so EffRun stacks it; STRONG on purpose, tune live) ----
[JsonPropertyName("Cards")]
public List<SurvivalCardDef> Cards { get; set; } = new()
{
new("damage", "+Damage", 100, 3), // +100%/pick -> x4 dmg from cards alone, maxed
new("hs_damage", "+Headshot Dmg", 50, 3), // +50%/pick -> +150% maxed
new("crit_chance", "+Crit Chance", 15, 3),// +15%/pick -> +45% maxed
new("crit_damage", "+Crit Dmg", 83, 3), // +83%/pick -> ~+250% maxed
new("max_hp", "+Max HP", 40, 3, flat: true), // +40 HP/pick -> +120 maxed (flat points, not %)
new("max_armor", "+Max Armor", 40, 3, flat: true),
new("lifesteal", "+Lifesteal", 8, 3), // +8%/pick -> +24% maxed
new("hp_regen", "+HP Regen", 2, 3, flat: true), // FLAT +2 HP/s/pick -> +6 HP/s maxed (stacks on the always-on regen stat)
// ---- effect-logic cards (NEW keys, NOT StatKeys; the effect hooks read them via StatBonus, never folded into EffRun) ----
// 1-pick (Cap 1): a single draft fully unlocks the effect; PerPick on the two FLAG cards is just a presence marker
// (their magnitude comes from the Burn*/Explode* knobs above), the other two carry their whole value in one pick.
// Detail = the draft line ({0} = level x PerPick); Burn + the two team cards carry computed detail in code.
new("explode_kill", "Explode on Kill", 1, 1, detail: "HE blast on kill"), // kill a bot -> real HE blast on the corpse (scales with your dmg build); chains freely
new("burn", "Burn", 1, 1), // every hit ignites the bot: flat BurnDamagePerSecond HP/s, armor-skipping, per-attacker stacking
new("ability_cdr", "-Ability Cooldown", 30, 1, detail: "-{0:0}% ability cooldown"),
new("xp_mult", "+Run XP", 50, 1, detail: "+{0:0}% run XP"),
// Leveled (Cap 3): per-player, scale per pick.
new("hs_reduction", "Headshot Armor", 15, 3, detail: "-{0:0}% headshot dmg"), // armor doesn't help vs HS; this does
new("berserk_passive", "Berserker", 40, 3, detail: "+{0:0}% dmg near death"), // scaled by missing HP (always-on, no streak gate)
// Leveled (Cap 3) but TEAM-WIDE: one shared squad level (max 3), compounding, applied to EVERY survivor via MDeal/MTake.
new("global_deal", "Team: +Damage", 10, 3, isTeam: true), // +10%/level ALL outgoing dmg, compounding -> x1.331 squad-wide maxed
new("global_take", "Team: -Damage Taken", 10, 3, isTeam: true),// -10%/level ALL incoming dmg, compounding -> x0.729 squad-wide maxed
};
}
// One tunable stat: Base value at 0 invested levels + PerLevel increment, up to MaxLevel.
public sealed class StatDef
{
[JsonPropertyName("MaxLevel")] public int MaxLevel { get; set; }
[JsonPropertyName("PerLevel")] public double PerLevel { get; set; }
[JsonPropertyName("Base")] public double Base { get; set; }
public StatDef() { }
public StatDef(int maxLevel, double perLevel, double @base = 0) { MaxLevel = maxLevel; PerLevel = perLevel; Base = @base; }
}
// The passive stat tree (spec §3). All values tunable; max levels sum to 100. Pure POCO (StatDef + primitives) so it lives
// here with the other Domain-read configs (the resolvers index it via _statDefs) — CSSharp-free, test-shareable.
public sealed class StatsConfig
{
[JsonPropertyName("Damage")] public StatDef Damage { get; set; } = new(5, 10); // +10% dmg / lvl -> +50% (x1.5) maxed
[JsonPropertyName("CritChance")] public StatDef CritChance { get; set; } = new(20, 2.5); // +2.5% chance / lvl -> 50% maxed (slow ramp, de-rushed)
[JsonPropertyName("CritDamage")] public StatDef CritDamage { get; set; } = new(10, 10, 100); // base +100% (x2), +10%/lvl -> +200% (x3) maxed
[JsonPropertyName("HeadshotDamage")] public StatDef HeadshotDamage { get; set; } = new(5, 10); // +10% / lvl -> +50% (x1.5) on top of engine 4x
[JsonPropertyName("MaxHp")] public StatDef MaxHp { get; set; } = new(10, 25); // +25 hp / lvl (over base 100)
[JsonPropertyName("MaxArmor")] public StatDef MaxArmor { get; set; } = new(10, 25);
[JsonPropertyName("Lifesteal")] public StatDef Lifesteal { get; set; } = new(10, 2.5); // +2.5% of dmg as HP / lvl
[JsonPropertyName("ArmorLifesteal")] public StatDef ArmorLifesteal { get; set; } = new(10, 2);
[JsonPropertyName("HpRegen")] public StatDef HpRegen { get; set; } = new(5, 1); // FLAT +1 HP / regen-tick(=1s) / lvl -> 5 HP/s maxed. ALWAYS ON (no out-of-combat gate).
[JsonPropertyName("ArmorRegen")] public StatDef ArmorRegen { get; set; } = new(5, 1); // FLAT +1 armor / regen-tick(=1s) / lvl. ALWAYS ON.
[JsonPropertyName("Thorns")] public StatDef Thorns { get; set; } = new(5, 2); // reflect +2% of damage taken / lvl -> 10% maxed (dealt FLAT back at the bot — a straight % of the hit you took, no build/handicap scaling; hard-capped 25 HP/hit; OFF while a knife/zeus is held so a GG melee finale needs a real kill)
[JsonPropertyName("XpBoost")] public StatDef XpBoost { get; set; } = new(5, 20); // +20% / lvl -> +100% (x2) maxed
[JsonPropertyName("RegenIntervalSeconds")] public float RegenIntervalSeconds { get; set; } = 1f;
[JsonPropertyName("RegenNoDamageDelaySeconds")] public float RegenNoDamageDelaySeconds { get; set; } = 5f; // DEPRECATED — regen is now ALWAYS ON (flat). Kept so existing JSON doesn't error; no longer read.
[JsonPropertyName("CritLifestealMultiplier")] public double CritLifestealMultiplier { get; set; } = 1.5; // crit hits steal +50%
[JsonPropertyName("LifestealMinHeal")] public int LifestealMinHeal { get; set; } = 2; // floor on a lifesteal heal (HP)
}

View file

@ -0,0 +1,360 @@
using System.Text.Json.Serialization;
using CounterStrikeSharp.API.Core;
namespace Outnumbered.Config;
// Auto-loaded from configs/plugins/outnumbered/outnumbered.json on first load.
// NOTE: the config sub-types the pure Domain layer reads (AbilityDef/AbilitiesConfig/HandicapConfig/HandicapOverride/
// ProgressionConfig/SurvivalCardDef/SurvivalConfig/StatDef) live in Config/DomainConfig.cs — split out so they're
// CounterStrikeSharp-free and the test project can compile them with Domain/. This file holds OutnumberedConfig (which
// extends the CSSharp BasePluginConfig) + the engine-coupled / presentation blocks.
public sealed class OutnumberedConfig : BasePluginConfig
{
// Which match driver runs this instance: "tdm" (default), "gungame", or "survival" (Driver.NormalizeMode also accepts
// 0/1/2 and aliases gg/wave). A per-instance launch decision — the whole RPG core is shared; only the match ruleset
// changes. Source of truth for the accepted values: Driver.ModeRegistry + NormalizeMode. (Mode-driver split, see Modes.cs.)
[JsonPropertyName("Mode")]
public string Mode { get; set; } = "tdm";
[JsonPropertyName("Database")]
public DatabaseConfig Database { get; set; } = new();
[JsonPropertyName("Api")]
public ApiConfig Api { get; set; } = new();
[JsonPropertyName("Website")]
public WebsiteConfig Website { get; set; } = new();
[JsonPropertyName("Match")]
public MatchConfig Match { get; set; } = new();
[JsonPropertyName("GunGame")]
public GunGameConfig GunGame { get; set; } = new();
[JsonPropertyName("Survival")]
public SurvivalConfig Survival { get; set; } = new();
[JsonPropertyName("Stats")]
public StatsConfig Stats { get; set; } = new();
[JsonPropertyName("Progression")]
public ProgressionConfig Progression { get; set; } = new();
[JsonPropertyName("Handicap")]
public HandicapConfig Handicap { get; set; } = new();
[JsonPropertyName("Abilities")]
public AbilitiesConfig Abilities { get; set; } = new();
[JsonPropertyName("Hud")]
public HudConfig Hud { get; set; } = new();
[JsonPropertyName("Shop")]
public ShopConfig Shop { get; set; } = new();
[JsonPropertyName("Sounds")]
public SoundsConfig Sounds { get; set; } = new();
[JsonPropertyName("FlushIntervalSeconds")]
public float FlushIntervalSeconds { get; set; } = 90f;
}
// Per-player sound cues (spec §7), played via the client `play` command. Paths are built-in CS2 sounds
// (same style GG2 uses). Empty string = no cue. Swap freely; `play <path>` resolves under csgo/.
public sealed class SoundsConfig
{
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
[JsonPropertyName("LevelUp")] public string LevelUp { get; set; } = "sounds/ui/armsrace_level_up.wav";
[JsonPropertyName("Prestige")] public string Prestige { get; set; } = "sounds/ui/armsrace_level_up.wav";
[JsonPropertyName("AbilityReady")] public string AbilityReady { get; set; } = "sounds/ambient/office/tech_oneshot_08.wav";
[JsonPropertyName("AbilityActivate")] public string AbilityActivate { get; set; } = "sounds/training/pointscored.wav";
[JsonPropertyName("Crit")] public string Crit { get; set; } = ""; // off by default — per-crit can get spammy
}
// The local read-only status/balance/top API, served over a per-instance Unix domain socket (Api.cs). The companion
// website is the consumer; there is no network exposure — the socket is a filesystem path, access = file permissions.
public sealed class ApiConfig
{
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
// Directory holding the per-instance sockets (<SocketDir>/<ServerId>.sock). Created externally (systemd tmpfiles.d);
// when it's missing or unwritable the API disables itself with one log line instead of failing the plugin load.
[JsonPropertyName("SocketDir")] public string SocketDir { get; set; } = "/run/outnumbered";
}
// The companion-website plug: a periodic chat line + an !about line pointing players at the site. Url empty = the
// whole feature is OFF (the default — an operator without a site gets no dangling advert). Url is read live per
// announce (!og_reload applies); changing the interval needs a plugin reload to re-arm the timer.
public sealed class WebsiteConfig
{
[JsonPropertyName("Url")] public string Url { get; set; } = "";
[JsonPropertyName("AnnounceIntervalSeconds")] public float AnnounceIntervalSeconds { get; set; } = 300f;
}
// The always-on HUD (spec §7): handicap multipliers + streak + the 5 ability states.
public sealed class HudConfig
{
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
// "center" = center HTML panel (crisp, multi-color, 2D, no lag — fixed upper-center + width). DEFAULT.
// "world" = CPointWorldText entity (positionable/wide/sized, but single-color, 3D parallax, lags on movement
// since the viewmodel isn't accessible to parent to — kept as an option, not recommended).
[JsonPropertyName("Mode")] public string Mode { get; set; } = "center";
// Refresh cadence in ticks. Center HTML fades only after ~5s, so it does NOT need per-tick resends: 4 (=16 Hz at
// 64 tick) keeps countdowns smooth at a quarter of the per-human message + string-build cost of 1. Every refresh
// sends a reliable usermessage per human, so this dial is also the HUD's client/network footprint.
[JsonPropertyName("RefreshEveryTicks")] public int RefreshEveryTicks { get; set; } = 4;
// Ability icons for the center HUD (indexed 0..4: No Reload, Adrenaline, Overcharge, Bloodthirst, Berserk).
// Glyph rendering depends on the client font — if any show as a box, swap it here and `css_plugins reload`.
// Defaults kept in BMP symbol blocks that render on the CS2 panel font (astral emoji like 🔥💧 show as boxes).
[JsonPropertyName("AbilityIcons")]
public List<string> AbilityIcons { get; set; } = new()
{ "∞", "⛨", "⇈", "♥", "☠" };
// ---- world-text placement — LIVE-TUNABLE: edit JSON, then `css_plugins reload outnumbered` (no rebuild) ----
[JsonPropertyName("ForwardOffset")] public float ForwardOffset { get; set; } = 7f; // units in front of the eye (keep small so walls don't clip it)
[JsonPropertyName("RightOffset")] public float RightOffset { get; set; } = 0f; // + = right, - = left
[JsonPropertyName("UpOffset")] public float UpOffset { get; set; } = -2.6f; // - = lower on screen
[JsonPropertyName("FontSize")] public float FontSize { get; set; } = 26f;
[JsonPropertyName("WorldUnitsPerPx")] public float WorldUnitsPerPx { get; set; } = 0.0075f; // smaller = smaller/finer text
[JsonPropertyName("FontName")] public string FontName { get; set; } = "Arial Bold";
[JsonPropertyName("DrawBackground")] public bool DrawBackground { get; set; } = true;
[JsonPropertyName("ColorR")] public int ColorR { get; set; } = 255;
[JsonPropertyName("ColorG")] public int ColorG { get; set; } = 255;
[JsonPropertyName("ColorB")] public int ColorB { get; set; } = 255;
}
// The quick-buy shop world-text panel. Opened by switching to the healthshot (X), frozen while open.
// Placement is LIVE-TUNABLE: edit JSON, then `css_plugins reload outnumbered` (no rebuild) to reposition.
public sealed class ShopConfig
{
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
[JsonPropertyName("ForwardOffset")] public float ForwardOffset { get; set; } = 7f; // units in front of the eye
[JsonPropertyName("RightOffset")] public float RightOffset { get; set; } = 0f; // centre anchor: + = right, - = left
[JsonPropertyName("SplitOffset")] public float SplitOffset { get; set; } = 3f; // half-gap: left panel sits -this, right panel +this
[JsonPropertyName("CardSpread")] public float CardSpread { get; set; } = 6f; // survival draft: horizontal gap between the 3 cards (centre card at RightOffset)
[JsonPropertyName("UpOffset")] public float UpOffset { get; set; } = 0f; // + = higher, - = lower
[JsonPropertyName("FontSize")] public float FontSize { get; set; } = 40f;
[JsonPropertyName("WorldUnitsPerPx")] public float WorldUnitsPerPx { get; set; } = 0.011f; // smaller = finer
[JsonPropertyName("InfoFontSize")] public float InfoFontSize { get; set; } = 34f; // right (info) panel: smaller than shop, but readable
[JsonPropertyName("InfoWorldUnitsPerPx")] public float InfoWorldUnitsPerPx { get; set; } = 0.0085f;
// Server/about text shown atop the info panel (the !info / !about blurb) — edit per-server. Keep lines SHORT
// (this panel is narrow/tall by design); long lines run off-screen at lower resolutions.
[JsonPropertyName("InfoLines")]
public List<string> InfoLines { get; set; } = new()
{
"=== OUTNUMBERED ===",
"Kill bots -> XP -> level.",
"Spend points in Skills.",
"L100: !prestige resets you",
"for a permanent boost.",
"Streaks unlock abilities",
"(grenade keys 6-0).",
"Handicap scales bots",
"to your power.",
"",
};
[JsonPropertyName("FontName")] public string FontName { get; set; } = "Arial Bold";
[JsonPropertyName("DrawBackground")] public bool DrawBackground { get; set; } = true;
[JsonPropertyName("ColorR")] public int ColorR { get; set; } = 255;
[JsonPropertyName("ColorG")] public int ColorG { get; set; } = 255;
[JsonPropertyName("ColorB")] public int ColorB { get; set; } = 255;
}
// Separate file outnumbered.ranks.json (NOT auto-loaded by CSSharp — loaded manually, see Ranks.cs). Spec §9.
public sealed class RanksConfig
{
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
[JsonPropertyName("ShowClanTag")] public bool ShowClanTag { get; set; } = true; // scoreboard tag via m_szClan
[JsonPropertyName("ChatTag")] public bool ChatTag { get; set; } = true; // coloured rank tag in chat (reformats chat)
// Prestige tag colouring: I-V = the unlocked perk's tint; VI-VII 2-colour, VIII-IX 3-colour, X all-5 (HUD animated, chat static).
[JsonPropertyName("PrestigeColors")] public bool PrestigeColors { get; set; } = true;
[JsonPropertyName("TopCount")] public int TopCount { get; set; } = 10; // !top size
// {0} = Roman prestige; only shown when prestige > 0. e.g. "P {0}" -> "P III".
[JsonPropertyName("PrestigeTagFormat")] public string PrestigeTagFormat { get; set; } = "P{0}";
// Highest MinLevel that is <= the player's level wins. Levels are within a prestige (cap 100).
[JsonPropertyName("LevelRanks")]
public List<RankTier> LevelRanks { get; set; } = new()
{
new(1, "Recruit"), new(26, "Soldier"), new(51, "Veteran"), new(76, "Elite"), new(100, "Apex"),
};
}
public sealed class RankTier
{
[JsonPropertyName("MinLevel")] public int MinLevel { get; set; }
[JsonPropertyName("Name")] public string Name { get; set; } = "";
public RankTier() { }
public RankTier(int minLevel, string name) { MinLevel = minLevel; Name = name; }
}
public sealed class DatabaseConfig
{
// "sqlite" for dev; "postgres" is the final-step swap (no logic change — repository interface).
[JsonPropertyName("Provider")]
public string Provider { get; set; } = "sqlite";
// Relative to csgo/ . Defaults next to the plugin's own config.
[JsonPropertyName("SqliteFile")]
public string SqliteFile { get; set; } = "addons/counterstrikesharp/configs/plugins/outnumbered/outnumbered.db";
// Unused while Provider == "sqlite"; filled in for the Postgres phase.
[JsonPropertyName("PostgresConnectionString")]
public string PostgresConnectionString { get; set; } = "Host=localhost;Port=5432;Database=outnumbered;Username=outnumbered;Password=";
}
// The TDM match driver (spec §1). Humans on CT vs bots on T, continuous respawn, no objectives.
public sealed class MatchConfig
{
[JsonPropertyName("BotsPerHuman")] public int BotsPerHuman { get; set; } = 4;
[JsonPropertyName("MaxBots")] public int MaxBots { get; set; } = 12;
[JsonPropertyName("MaxHumansOnCt")] public int MaxHumansOnCt { get; set; } = 3;
[JsonPropertyName("RespawnDelaySeconds")] public float RespawnDelaySeconds { get; set; } = 1.0f; // DEPRECATED — native DM respawn handles timing; no longer read (kept so existing JSON doesn't error).
// 5s so you can spend skill points on respawn; it breaks the instant you fire, so it's not abusable (PvE anyway).
[JsonPropertyName("SpawnProtectionSeconds")] public float SpawnProtectionSeconds { get; set; } = 5.0f;
// Map ends when ANY single player reaches this many kills (spec §1).
[JsonPropertyName("KillGoal")] public int KillGoal { get; set; } = 250;
[JsonPropertyName("MapChangeDelaySeconds")] public float MapChangeDelaySeconds { get; set; } = 6.0f;
[JsonPropertyName("Maps")]
public List<string> Maps { get; set; } = new()
{ "cs_italy", "de_dust2", "de_vertigo", "de_nuke", "de_inferno" };
// bot_difficulty caps at 4 on this build (+ custom_bot_difficulty) — cookbook.
[JsonPropertyName("BotDifficulty")] public int BotDifficulty { get; set; } = 4;
// Base loadout = the only weapons free from level 1; everything else is level-gated (WeaponUnlockLevel).
// Prestige resets level → re-locks everything back to these. Persisted per-player choice falls back here when locked.
[JsonPropertyName("HumanPrimary")] public string HumanPrimary { get; set; } = "weapon_mp9";
[JsonPropertyName("HumanSecondary")] public string HumanSecondary { get; set; } = "weapon_usp_silencer";
// weapon -> level required to select it (absent/0 = free). Weak/cheap early, the cheese (AK/AWP/Negev/autos) late.
[JsonPropertyName("WeaponUnlockLevel")]
public Dictionary<string, int> WeaponUnlockLevel { get; set; } = new()
{
["weapon_glock"] = 2,
["weapon_p250"] = 4,
["weapon_mac10"] = 6,
["weapon_nova"] = 8,
["weapon_hkp2000"] = 10,
["weapon_bizon"] = 12,
["weapon_mp7"] = 14,
["weapon_galilar"] = 16,
["weapon_sawedoff"] = 18,
["weapon_elite"] = 20,
["weapon_famas"] = 22,
["weapon_ump45"] = 24,
["weapon_fiveseven"] = 27,
["weapon_tec9"] = 30,
["weapon_mp5sd"] = 33,
["weapon_mag7"] = 36,
["weapon_ssg08"] = 39,
["weapon_p90"] = 42,
["weapon_aug"] = 45,
["weapon_cz75a"] = 48,
["weapon_xm1014"] = 51,
["weapon_sg556"] = 55,
["weapon_deagle"] = 59,
["weapon_revolver"] = 63,
["weapon_m4a1"] = 67,
["weapon_m4a1_silencer"] = 71,
["weapon_ak47"] = 75,
["weapon_m249"] = 80,
["weapon_scar20"] = 85,
["weapon_g3sg1"] = 90,
["weapon_negev"] = 95,
["weapon_awp"] = 100,
};
// Free-selection weapon menu (!guns), split into categories (!rifles/!smgs/!snipers/!shotguns/!heavy/!pistols).
// Edit to taste; entries must be valid weapon_ item names. "Pistols" are the secondary slot; the rest are primary.
[JsonPropertyName("Rifles")]
public List<string> Rifles { get; set; } = new()
{ "weapon_ak47", "weapon_m4a1_silencer", "weapon_m4a1", "weapon_aug", "weapon_sg556", "weapon_galilar", "weapon_famas" };
[JsonPropertyName("Smgs")]
public List<string> Smgs { get; set; } = new()
{ "weapon_mp9", "weapon_mp7", "weapon_mp5sd", "weapon_ump45", "weapon_p90", "weapon_mac10", "weapon_bizon" };
[JsonPropertyName("Snipers")]
public List<string> Snipers { get; set; } = new()
{ "weapon_awp", "weapon_ssg08", "weapon_scar20", "weapon_g3sg1" };
[JsonPropertyName("Shotguns")]
public List<string> Shotguns { get; set; } = new()
{ "weapon_nova", "weapon_xm1014", "weapon_mag7", "weapon_sawedoff" };
[JsonPropertyName("Heavy")]
public List<string> Heavy { get; set; } = new()
{ "weapon_m249", "weapon_negev" };
[JsonPropertyName("Pistols")]
public List<string> Pistols { get; set; } = new()
{ "weapon_deagle", "weapon_revolver", "weapon_glock", "weapon_usp_silencer", "weapon_hkp2000",
"weapon_p250", "weapon_fiveseven", "weapon_tec9", "weapon_cz75a", "weapon_elite" };
// Bot squad pool — cycled per bot spawn for a mixed T side (full squad templates come later).
[JsonPropertyName("BotWeapons")]
public List<string> BotWeapons { get; set; } = new()
{ "weapon_ak47", "weapon_ak47", "weapon_awp", "weapon_nova" };
[JsonPropertyName("BotGrenades")]
public List<string> BotGrenades { get; set; } = new()
{ "weapon_hegrenade" };
}
// One Gun Game ladder rung: a weapon + its strength tier (1=weak/hard-to-use .. 5=cheese). The tier indexes the
// per-tier kill curves below — it does NOT affect the climb ORDER (that's this list's order, shared by players+bots).
public sealed class GunGameRung
{
[JsonPropertyName("Weapon")] public string Weapon { get; set; } = "";
[JsonPropertyName("Tier")] public int Tier { get; set; } = 1;
public GunGameRung() { }
public GunGameRung(string weapon, int tier) { Weapon = weapon; Tier = tier; }
}
// Gun Game mode (Mode="gungame", see Modes.cs). Climb a weapon ladder by kills; the full RPG kit rides along.
// DUAL pacing: kills-to-advance a rung is driven by the weapon's TIER, and players vs bots use INVERSE curves —
// players grind weak guns + breeze strong ones (so an HS-demotion into the grind stings); bots skip weak guns
// fast + linger on strong ones (so they reach dangerous weapons instead of being stuck on pistols). The ORDER is
// shared (bots have no muscle memory) and zig-zags weapon types so muscle memory never settles.
public sealed class GunGameConfig
{
// Kills to clear a rung, indexed by tier-1 (tier 1..5). The player curve grinds weak guns / breezes strong ones.
// Bots default to a FLAT 1 kill/rung — with BotSharedLadder (per-batch pooling) a batch then needs ~ladder-length
// collective kills to win, which only lands once a player's K/D falls into handicap territory.
[JsonPropertyName("PlayerKillsByTier")] public List<int> PlayerKillsByTier { get; set; } = new() { 10, 8, 5, 2, 1 };
[JsonPropertyName("BotKillsByTier")] public List<int> BotKillsByTier { get; set; } = new() { 1, 1, 1, 1, 1 };
// Pool bot ladder progress per BATCH of BotsPerHuman (true, default): bots are grouped by slot rank into batches
// of BotsPerHuman, and each batch shares ONE rung that advances on ANY of its members' kills — so a batch (≈ one
// player's worth of bots) climbs ~BotsPerHuman× faster and can actually win, and the horde scales with player
// count (1 player -> 1 batch, 2 -> 2, ... up to MaxBots). false = each bot climbs its own rung (rarely tops).
[JsonPropertyName("BotSharedLadder")] public bool BotSharedLadder { get; set; } = true;
// Max humans on CT for THIS mode (GG wants more than the "outnumbered" TDM default of 3). Per-mode so ONE shared
// config can run both; the driver feeds it to EnforceHumanTeam.
[JsonPropertyName("MaxHumansOnCt")] public int MaxHumansOnCt { get; set; } = 5;
// Append a final knife rung — the classic Gun Game capstone (the win is a knife kill; 1 kill for both sides).
[JsonPropertyName("KnifeFinale")] public bool KnifeFinale { get; set; } = true;
// Map pool for this mode (changelevel rotation on a win). EMPTY = fall back to Match.Maps. Put the small
// arms-race maps here once you've confirmed the exact names installed on your server (e.g. ar_shoots, ar_baggage).
[JsonPropertyName("Maps")] public List<string> Maps { get; set; } = new();
// Per-mode handicap override (null = inherit the base Handicap unchanged). GG: weights the ladder-position
// progress axis (climbing nerfs you; a bot-demotion eases it), runs a rougher sub-1 Curve (bites from the start),
// and DOUBLES MasterDifficulty so the nerf cap (0.1x deal / 8x taken) is actually reachable on small GG maps —
// it's hit at n≈0.5 (half-maxed factors) instead of needing K/D+HS+streak+level+ladder ALL maxed, so a good
// climber gets driven to the floor and can no longer headshot-suppress the horde. All live-tunable via !og_reload.
// (Heads-up: MasterDifficulty scales both sides, so a struggling low-K/D player also gets a stronger comeback buff.)
[JsonPropertyName("Handicap")] public HandicapOverride? Handicap { get; set; } = new() { MasterDifficulty = 2.0, ProgressWeight = 3.0, Curve = 0.8, MDealFloor = 0.1 };
// The ladder: weapon order (shared) + per-weapon tier. EMPTY -> code falls back to a knife-only ladder (safety).
// Order zig-zags categories (no two consecutive the same; cheese scattered, not clustered).
[JsonPropertyName("Ladder")]
public List<GunGameRung> Ladder { get; set; } = new()
{
new("weapon_usp_silencer", 1), new("weapon_famas", 4), new("weapon_mac10", 2), new("weapon_mag7", 4),
new("weapon_aug", 5), new("weapon_fiveseven", 3), new("weapon_ump45", 4), new("weapon_scar20", 5),
new("weapon_nova", 3), new("weapon_m4a1", 5), new("weapon_p250", 2), new("weapon_mp5sd", 4),
new("weapon_ssg08", 4), new("weapon_galilar", 4), new("weapon_mp7", 3), new("weapon_xm1014", 4),
new("weapon_deagle", 5), new("weapon_bizon", 2), new("weapon_sg556", 5), new("weapon_tec9", 4),
new("weapon_m249", 4), new("weapon_mp9", 1), new("weapon_g3sg1", 5), new("weapon_sawedoff", 4),
new("weapon_revolver", 4), new("weapon_m4a1_silencer", 5), new("weapon_p90", 3), new("weapon_negev", 4),
new("weapon_elite", 4), new("weapon_ak47", 5), new("weapon_hkp2000", 1), new("weapon_awp", 5),
};
}

View file

@ -0,0 +1,187 @@
using System.Data.Common;
using Dapper;
namespace Outnumbered.Data;
// Shared Dapper implementation for both backends. SQLite (dev) and Postgres (prod) differ ONLY in how a connection is
// opened and in the schema DDL (INTEGER vs BIGINT, the SQLite-only drop-migration); every query and the row records are
// identical and live here once, so a new persisted column is a one-place change. Subclasses supply OpenConnectionAsync +
// EnsureSchemaAsync. Steam IDs are stored signed via unchecked((long)id), round-tripping identically on both. All methods
// run OFF the game thread. Permanent progression (players/upgrades) is GLOBAL; match_state is PER-SERVER (composite key)
// so many instances share one DB without clobbering each other's round.
public abstract class DapperPlayerRepository(string serverId) : IPlayerRepository
{
protected readonly string ServerId = string.IsNullOrEmpty(serverId) ? "default" : serverId; // scopes the per-round match_state
protected abstract Task<DbConnection> OpenConnectionAsync();
public abstract Task EnsureSchemaAsync();
public async Task<LoadedPlayer?> LoadAsync(ulong steamId)
{
await using var c = await OpenConnectionAsync();
long sid = unchecked((long)steamId);
var row = await c.QuerySingleOrDefaultAsync<PlayerRow>(
"""
SELECT name, xp, level, prestige, points,
primary_weapon AS PrimaryWeapon, secondary_weapon AS SecondaryWeapon
FROM players WHERE steamid = @sid
""", new { sid });
if (row is null) return null;
var ups = await c.QueryAsync<UpgradeRow>(
"SELECT stat_key, level FROM upgrades WHERE steamid = @sid", new { sid });
return new LoadedPlayer(row.Name, row.Xp, (int)row.Level, (int)row.Prestige, (int)row.Points,
ups.ToDictionary(u => u.Stat_Key, u => (int)u.Level), row.PrimaryWeapon, row.SecondaryWeapon);
}
public async Task<IReadOnlyList<TopPlayer>> GetTopAsync(int count)
{
await using var c = await OpenConnectionAsync();
var rows = await c.QueryAsync<TopRow>(
"SELECT name, level, prestige, xp FROM players ORDER BY prestige DESC, level DESC, xp DESC LIMIT @n",
new { n = count });
return rows.Select(r => new TopPlayer(r.Name, (int)r.Level, (int)r.Prestige, r.Xp)).ToList();
}
// Records: improve-only upserts. The bare INSERT relies on the players-table defaults for a first-ever row (the
// name backfills on the player's next regular save); the CASE keeps the better value on conflict. Existing-row
// references MUST be table-qualified: in DO UPDATE both the target row and `excluded` are in scope, and Postgres
// rejects the unqualified form as ambiguous (42702). SQLite accepts the qualified form too, so the SQL stays shared.
public async Task TryImproveBestWavesAsync(IReadOnlyList<ulong> steamIds, int wave)
{
if (steamIds.Count == 0) return;
await using var c = await OpenConnectionAsync();
await using var tx = await c.BeginTransactionAsync();
foreach (var id in steamIds.Order()) // ascending steamid = the global row-lock order (see SaveManyAsync)
await c.ExecuteAsync(
"""
INSERT INTO players(steamid, best_wave) VALUES(@sid, @w)
ON CONFLICT(steamid) DO UPDATE SET best_wave =
CASE WHEN players.best_wave IS NULL OR players.best_wave < excluded.best_wave
THEN excluded.best_wave ELSE players.best_wave END;
""", new { sid = unchecked((long)id), w = wave }, tx);
await tx.CommitAsync();
}
public async Task TryImproveGgBestAsync(ulong steamId, long elapsedMs)
{
await using var c = await OpenConnectionAsync();
await c.ExecuteAsync(
"""
INSERT INTO players(steamid, gg_best_ms) VALUES(@sid, @ms)
ON CONFLICT(steamid) DO UPDATE SET gg_best_ms =
CASE WHEN players.gg_best_ms IS NULL OR players.gg_best_ms > excluded.gg_best_ms
THEN excluded.gg_best_ms ELSE players.gg_best_ms END;
""", new { sid = unchecked((long)steamId), ms = elapsedMs });
}
public async Task<IReadOnlyList<TopWave>> GetTopWavesAsync(int count)
{
await using var c = await OpenConnectionAsync();
var rows = await c.QueryAsync<WaveRow>(
"SELECT name, best_wave AS BestWave FROM players WHERE best_wave IS NOT NULL ORDER BY best_wave DESC, xp DESC LIMIT @n",
new { n = count });
return rows.Select(r => new TopWave(r.Name, (int)r.BestWave)).ToList();
}
public async Task<IReadOnlyList<TopGgTime>> GetTopGgAsync(int count)
{
await using var c = await OpenConnectionAsync();
var rows = await c.QueryAsync<GgTimeRow>(
"SELECT name, gg_best_ms AS BestMs FROM players WHERE gg_best_ms IS NOT NULL ORDER BY gg_best_ms ASC, xp DESC LIMIT @n",
new { n = count });
return rows.Select(r => new TopGgTime(r.Name, r.BestMs)).ToList();
}
public Task SaveAsync(PersistPlayer player) => SaveManyAsync(new[] { player });
public async Task SaveManyAsync(IReadOnlyList<PersistPlayer> players)
{
if (players.Count == 0) return;
await using var c = await OpenConnectionAsync();
await using var tx = await c.BeginTransactionAsync();
// Ascending steamid in EVERY multi-row transaction (here, the match twin, the records improves) = one global
// row-lock order, so a wave-clear improve and the periodic flush can't deadlock each other on Postgres.
foreach (var p in players.OrderBy(x => x.SteamId))
{
long sid = unchecked((long)p.SteamId);
await c.ExecuteAsync(
"""
INSERT INTO players(steamid, name, xp, level, prestige, points, primary_weapon, secondary_weapon, last_seen)
VALUES(@sid, @name, @xp, @level, @prestige, @points, @primary, @secondary, CURRENT_TIMESTAMP)
ON CONFLICT(steamid) DO UPDATE SET
xp = @xp, name = @name, level = @level, prestige = @prestige, points = @points,
primary_weapon = @primary, secondary_weapon = @secondary, last_seen = CURRENT_TIMESTAMP;
""",
new
{
sid,
name = p.Name,
xp = p.Xp,
level = p.Level,
prestige = p.Prestige,
points = p.Points,
primary = p.PrimaryWeapon,
secondary = p.SecondaryWeapon
}, tx);
// Full-replace the upgrade set so a prestige reset's removals actually clear (a plain UPSERT leaves stale rows
// that reload as if the reset never happened).
await c.ExecuteAsync("DELETE FROM upgrades WHERE steamid = @sid;", new { sid }, tx);
foreach (var kv in p.Upgrades)
await c.ExecuteAsync("INSERT INTO upgrades(steamid, stat_key, level) VALUES(@sid, @k, @l);",
new { sid, k = kv.Key, l = kv.Value }, tx);
}
await tx.CommitAsync();
}
public async Task<MatchState?> LoadMatchAsync(ulong steamId)
{
await using var c = await OpenConnectionAsync();
long sid = unchecked((long)steamId);
var row = await c.QuerySingleOrDefaultAsync<MatchRow>(
"SELECT kills, deaths, streak, headshot_kills AS HeadshotKills, gg_run_started_at AS GgRunStartedAtMs FROM match_state WHERE server_id = @srv AND steamid = @sid",
new { srv = ServerId, sid });
return row is null ? null
: new MatchState(steamId, (int)row.Kills, (int)row.Deaths, (int)row.Streak, (int)row.HeadshotKills, row.GgRunStartedAtMs ?? 0);
}
public Task SaveMatchAsync(MatchState state) => SaveManyMatchAsync(new[] { state });
public async Task SaveManyMatchAsync(IReadOnlyList<MatchState> states)
{
if (states.Count == 0) return;
await using var c = await OpenConnectionAsync();
await using var tx = await c.BeginTransactionAsync();
foreach (var s in states.OrderBy(x => x.SteamId)) // global row-lock order (see SaveManyAsync)
{
long sid = unchecked((long)s.SteamId);
await c.ExecuteAsync(
"""
INSERT INTO match_state(server_id, steamid, kills, deaths, streak, headshot_kills, gg_run_started_at)
VALUES(@srv, @sid, @kills, @deaths, @streak, @hs, @ggStart)
ON CONFLICT(server_id, steamid) DO UPDATE SET
kills = @kills, deaths = @deaths, streak = @streak, headshot_kills = @hs, gg_run_started_at = @ggStart;
""",
new { srv = ServerId, sid, kills = s.Kills, deaths = s.Deaths, streak = s.Streak, hs = s.HeadshotKills, ggStart = s.GgRunStartedAtMs }, tx);
}
await tx.CommitAsync();
}
public async Task WipeMatchAsync()
{
await using var c = await OpenConnectionAsync();
await c.ExecuteAsync("DELETE FROM match_state WHERE server_id = @srv;", new { srv = ServerId });
}
// Dapper maps columns by name (case-insensitive). Integer columns -> Int64, so these are `long` (cast to int at the
// call site). The aliases + the Stat_Key param name map the snake_case columns (Postgres folds unquoted to lowercase).
protected sealed record PlayerRow(string Name, long Xp, long Level, long Prestige, long Points,
string? PrimaryWeapon, string? SecondaryWeapon);
protected sealed record UpgradeRow(string Stat_Key, long Level);
protected sealed record TopRow(string Name, long Level, long Prestige, long Xp);
protected sealed record WaveRow(string Name, long BestWave);
protected sealed record GgTimeRow(string Name, long BestMs);
protected sealed record MatchRow(long Kills, long Deaths, long Streak, long HeadshotKills, long? GgRunStartedAtMs);
}

View file

@ -0,0 +1,25 @@
namespace Outnumbered.Data;
// Hides the SQL dialect so SQLite (dev) -> PostgreSQL (prod) is a provider swap,
// not a logic change (spec §10). All methods run OFF the game thread.
public interface IPlayerRepository
{
Task EnsureSchemaAsync();
Task<LoadedPlayer?> LoadAsync(ulong steamId);
Task SaveAsync(PersistPlayer player);
Task SaveManyAsync(IReadOnlyList<PersistPlayer> players);
Task<IReadOnlyList<TopPlayer>> GetTopAsync(int count);
// Records (site leaderboards): improve-only writes kept OUT of the SaveMany upsert's column list, so a normal
// save can never clobber them and they need no LoadedPlayer/PlayerData round-trip (write-only from the plugin).
Task TryImproveBestWavesAsync(IReadOnlyList<ulong> steamIds, int wave); // higher wave wins
Task TryImproveGgBestAsync(ulong steamId, long elapsedMs); // lower time wins
Task<IReadOnlyList<TopWave>> GetTopWavesAsync(int count);
Task<IReadOnlyList<TopGgTime>> GetTopGgAsync(int count);
// Per-match stats (RAM-first; persisted only on disconnect/shutdown, wiped on round end).
Task<MatchState?> LoadMatchAsync(ulong steamId);
Task SaveMatchAsync(MatchState state);
Task SaveManyMatchAsync(IReadOnlyList<MatchState> states);
Task WipeMatchAsync();
}

View file

@ -0,0 +1,78 @@
namespace Outnumbered.Data;
// DTO for the !top leaderboard.
public sealed record TopPlayer(string Name, int Level, int Prestige, long Xp);
// DTOs for the records leaderboards (survival best wave / fastest Gun Game ladder). Names only — no SteamIDs on the wire.
public sealed record TopWave(string Name, int BestWave);
public sealed record TopGgTime(string Name, long BestMs);
// DTO returned by a load.
public sealed record LoadedPlayer(
string Name, long Xp, int Level, int Prestige, int Points, Dictionary<string, int> Upgrades,
string? PrimaryWeapon, string? SecondaryWeapon);
// DTO handed to a save (absolute values; last-write-wins).
public sealed record PersistPlayer(
ulong SteamId, string Name, long Xp, int Level, int Prestige, int Points,
IReadOnlyDictionary<string, int> Upgrades, string? PrimaryWeapon, string? SecondaryWeapon);
// Per-match stats (RAM-first: written only on disconnect/shutdown, wiped on round end). Restored on rejoin mid-match.
public sealed record MatchState(ulong SteamId, int Kills, int Deaths, int Streak, int HeadshotKills, long GgRunStartedAtMs = 0);
// In-memory cache row. Mutated ONLY on the main game thread.
public sealed class PlayerData
{
public required ulong SteamId { get; init; }
public string Name { get; set; } = "";
public long Xp { get; set; } // progress toward the next level (within the current prestige)
public int Level { get; set; } = 1;
public int Prestige { get; set; }
public int Points { get; set; } = 1; // start with one spendable point (spec §2)
public Dictionary<string, int> Upgrades { get; } = new();
// Chosen loadout (null = use the config default). Persisted. Free selection, validated against the allowed lists.
public string? PrimaryWeapon { get; set; }
public string? SecondaryWeapon { get; set; }
public bool Dirty { get; set; }
// Runtime-only: sub-1 XP remainder carried between per-hit grants so fractional damage-XP isn't lost to rounding.
public double XpCarry { get; set; }
// Runtime-only: combat tracking for the handicap (and killstreak abilities).
public int Kills { get; set; }
public int Deaths { get; set; }
public int Streak { get; set; } // current killstreak; resets on death
public int HeadshotKills { get; set; } // headshot kills this match — feeds the handicap's HS-rate factor
// Runtime-only (Gun Game mode): current weapon-ladder rung + kills banked toward clearing it. Persist across
// deaths within a match, reset on map start. NOT round-tripped in match_state — a mid-match reconnect
// restarts the climb at rung 0.
public int GgRung { get; set; }
public int GgRungKills { get; set; }
// Gun Game speedrun clock: wall-clock unix ms of the run's first damage dealt-or-taken (0 = not armed; reset on map
// start). Wall clock, not Server.CurrentTime, because it round-trips through match_state and must stay comparable
// across a changelevel/restart (the game clock restarts at ~0 there). Unlike GgRung, this DOES round-trip — a
// mid-match reconnect restarts the climb but never restarts the clock (the once-per-match anti-abuse rule).
public long GgRunStartedAtMs { get; set; }
// Runtime-only: killstreak ability state, indexed by AbilityRegistry index (0..N-1).
// AbilityReadyAt = Server.CurrentTime at which the ability comes off cooldown (survives death).
// AbilityActiveUntil = Server.CurrentTime until which the ability's effect is live (cleared on death).
// Sized to AbilitySlots (headroom over the current 5) so adding an AbilityRegistry row can't IndexOutOfRange; a
// startup guard (Initialize_Abilities) errors loudly if the registry ever exceeds this.
public const int AbilitySlots = 8;
public double[] AbilityReadyAt { get; } = new double[AbilitySlots];
public double[] AbilityActiveUntil { get; } = new double[AbilitySlots];
public PersistPlayer ToPersist() =>
new(SteamId, Name, Xp, Level, Prestige, Points, new Dictionary<string, int>(Upgrades),
PrimaryWeapon, SecondaryWeapon);
public MatchState ToMatchState() => new(SteamId, Kills, Deaths, Streak, HeadshotKills, GgRunStartedAtMs);
// GgRunStartedAtMs counts as activity: an armed-but-zero-kill speedrun clock must still reach match_state,
// or a disconnect before the first kill silently discards it (this predicate gates every match-state save).
public bool HasMatchActivity => Kills > 0 || Deaths > 0 || Streak > 0 || HeadshotKills > 0 || GgRunStartedAtMs > 0;
}

View file

@ -0,0 +1,58 @@
using System.Data.Common;
using Dapper;
using Npgsql;
namespace Outnumbered.Data;
// PostgreSQL backend — the PRODUCTION store (Provider="postgres"). Shares all queries with DapperPlayerRepository;
// supplies only the connection (Npgsql pooling per connection string; MVCC, no WAL/busy_timeout pragmas) and the schema
// DDL (BIGINT columns -> Dapper Int64 -> the same `long` row records). No drop-migration; later columns reach
// existing prod tables via the ADD COLUMN IF NOT EXISTS block (CREATE TABLE IF NOT EXISTS alone never alters them).
public sealed class NpgsqlRepository(string connectionString, string serverId) : DapperPlayerRepository(serverId)
{
private readonly string _connectionString = connectionString;
protected override async Task<DbConnection> OpenConnectionAsync()
{
var c = new NpgsqlConnection(_connectionString);
await c.OpenAsync();
return c;
}
public override async Task EnsureSchemaAsync()
{
await using var c = await OpenConnectionAsync();
await c.ExecuteAsync(
"""
CREATE TABLE IF NOT EXISTS players(
steamid BIGINT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
xp BIGINT NOT NULL DEFAULT 0,
level BIGINT NOT NULL DEFAULT 1,
prestige BIGINT NOT NULL DEFAULT 0,
points BIGINT NOT NULL DEFAULT 1,
primary_weapon TEXT,
secondary_weapon TEXT,
best_wave BIGINT,
gg_best_ms BIGINT,
last_seen TIMESTAMPTZ);
CREATE TABLE IF NOT EXISTS upgrades(
steamid BIGINT NOT NULL,
stat_key TEXT NOT NULL,
level BIGINT NOT NULL,
PRIMARY KEY(steamid, stat_key));
CREATE TABLE IF NOT EXISTS match_state(
server_id TEXT NOT NULL DEFAULT 'default',
steamid BIGINT NOT NULL,
kills BIGINT NOT NULL DEFAULT 0,
deaths BIGINT NOT NULL DEFAULT 0,
streak BIGINT NOT NULL DEFAULT 0,
headshot_kills BIGINT NOT NULL DEFAULT 0,
gg_run_started_at BIGINT,
PRIMARY KEY(server_id, steamid));
ALTER TABLE players ADD COLUMN IF NOT EXISTS best_wave BIGINT;
ALTER TABLE players ADD COLUMN IF NOT EXISTS gg_best_ms BIGINT;
ALTER TABLE match_state ADD COLUMN IF NOT EXISTS gg_run_started_at BIGINT;
""");
}
}

View file

@ -0,0 +1,82 @@
#if WITH_SQLITE
using System.Data.Common;
using Dapper;
using Microsoft.Data.Sqlite;
namespace Outnumbered.Data;
// SQLite backend — the DEV store (Provider="sqlite"). Shares all queries with DapperPlayerRepository; supplies only the
// connection (WAL + busy_timeout so periodic flush / disconnect saves don't trip over each other) and the schema DDL.
public sealed class SqliteRepository : DapperPlayerRepository
{
private readonly string _connectionString;
public SqliteRepository(string dbFilePath, string serverId) : base(serverId) =>
_connectionString = new SqliteConnectionStringBuilder { DataSource = dbFilePath }.ToString();
protected override async Task<DbConnection> OpenConnectionAsync()
{
var c = new SqliteConnection(_connectionString);
await c.OpenAsync();
await c.ExecuteAsync("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;");
return c;
}
public override async Task EnsureSchemaAsync()
{
await using var c = await OpenConnectionAsync();
// Permanent progression — GLOBAL across all servers (shared leaderboard).
await c.ExecuteAsync(
"""
CREATE TABLE IF NOT EXISTS players(
steamid INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
xp INTEGER NOT NULL DEFAULT 0,
level INTEGER NOT NULL DEFAULT 1,
prestige INTEGER NOT NULL DEFAULT 0,
points INTEGER NOT NULL DEFAULT 1,
primary_weapon TEXT,
secondary_weapon TEXT,
best_wave INTEGER,
gg_best_ms INTEGER,
last_seen TEXT);
CREATE TABLE IF NOT EXISTS upgrades(
steamid INTEGER NOT NULL,
stat_key TEXT NOT NULL,
level INTEGER NOT NULL,
PRIMARY KEY(steamid, stat_key));
""");
// match_state is PER-SERVER (composite key) + ephemeral; a one-time drop migrates off the old single-PK schema.
bool matchExists = await c.ExecuteScalarAsync<long>(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='match_state';") > 0;
bool hasServerId = matchExists && await c.ExecuteScalarAsync<long>(
"SELECT COUNT(*) FROM pragma_table_info('match_state') WHERE name='server_id';") > 0;
if (matchExists && !hasServerId) await c.ExecuteAsync("DROP TABLE match_state;");
await c.ExecuteAsync(
"""
CREATE TABLE IF NOT EXISTS match_state(
server_id TEXT NOT NULL DEFAULT 'default',
steamid INTEGER NOT NULL,
kills INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0,
streak INTEGER NOT NULL DEFAULT 0,
headshot_kills INTEGER NOT NULL DEFAULT 0,
gg_run_started_at INTEGER,
PRIMARY KEY(server_id, steamid));
""");
await TryAddColumnAsync(c, "players", "primary_weapon", "TEXT");
await TryAddColumnAsync(c, "players", "secondary_weapon", "TEXT");
await TryAddColumnAsync(c, "players", "best_wave", "INTEGER");
await TryAddColumnAsync(c, "players", "gg_best_ms", "INTEGER");
await TryAddColumnAsync(c, "match_state", "gg_run_started_at", "INTEGER");
}
private static async Task TryAddColumnAsync(DbConnection c, string table, string col, string type)
{
try { await c.ExecuteAsync($"ALTER TABLE {table} ADD COLUMN {col} {type};"); }
catch (SqliteException) { /* column already exists — fine */ }
}
}
#endif

View file

@ -0,0 +1,16 @@
namespace Outnumbered;
// Keys for the survival "effect" cards — distinct from StatKeys so EffRun never folds them into a stat; only the effect
// logic reads them (per-player via the run's IStatBonusSource, or as the two TEAM cards via the driver's team
// multipliers). CSSharp-free so Domain + the test project can reference them without the engine.
public static class CardKeys
{
public const string ExplodeKill = "explode_kill"; // 1-pick flag: HE blast on every bot-kill (chains)
public const string Burn = "burn"; // 1-pick flag: on-hit DoT (flat, armor-skipping, per-attacker)
public const string AbilityCdr = "ability_cdr"; // -% killstreak-ability cooldown
public const string XpMult = "xp_mult"; // +% run-XP earned
public const string HsReduction = "hs_reduction"; // -% incoming headshot damage (per-player, leveled)
public const string BerserkPassive = "berserk_passive"; // +dmg scaled by missing HP (per-player, leveled)
public const string GlobalDeal = "global_deal"; // TEAM: +dmg dealt, squad-wide, compounding (into MDeal)
public const string GlobalTake = "global_take"; // TEAM: -dmg taken, squad-wide, compounding (into MTake)
}

View file

@ -0,0 +1,78 @@
using System.Collections.Frozen;
using Outnumbered.Config;
namespace Outnumbered.Domain;
// The offense + defense multiplier chains — the SINGLE source of truth shared by the damage hook (applies them) and the
// HUD readout (shows them). Pure: the crit decision (which has engine side effects: a sound + the crit-XP marker) is made
// by the caller and passed in as `crit`; the HUD passes crit:false. Lifesteal/thorns amounts are here too so the reactive
// sustain glue applies identical numbers.
public static class CombatResolver
{
public static double CritChance(in PlayerSnapshot s, FrozenDictionary<string, StatDef> defs) =>
StatResolver.EffRun(s, StatKeys.CritChance, defs);
// The damage hook path: computes its own MDeal (one ComputeT per hit). Delegates to the mDeal-taking overload.
public static double OffenseMultiplier(in PlayerSnapshot s, bool headshot, bool crit,
FrozenDictionary<string, StatDef> defs, in ResolvedHandicap rh, AbilitiesConfig ab, int baseMaxHp) =>
OffenseMultiplier(s, headshot, crit, defs, ab, baseMaxHp, HandicapModel.MDeal(s, rh));
// Overload taking a PRECOMPUTED MDeal — lets the HUD share ONE ComputeT across deal/take/xp (via HandicapModel.Bands).
// The missing-HP fraction (an EffRun lookup + a divide) is computed only when a Berserk consumer is actually active.
public static double OffenseMultiplier(in PlayerSnapshot s, bool headshot, bool crit,
FrozenDictionary<string, StatDef> defs, AbilitiesConfig ab, int baseMaxHp, double mDeal)
{
double dmgMul = 1.0 + StatResolver.EffRun(s, StatKeys.Damage, defs) / 100.0;
double hsMul = headshot ? 1.0 + StatResolver.EffRun(s, StatKeys.HeadshotDamage, defs) / 100.0 : 1.0;
double critMul = crit ? 1.0 + StatResolver.EffRun(s, StatKeys.CritDamage, defs) / 100.0 : 1.0;
double abilityMul = 1.0;
double berserkCard = StatResolver.CardMag(s, CardKeys.BerserkPassive); // always-on passive card
bool needMissing = berserkCard > 0 || (ab.Enabled && s.BerserkActive); // the only consumers of `missing`
double missing = needMissing ? StatResolver.MissingHpFraction(s, StatResolver.MaxHp(s, defs, baseMaxHp)) : 0.0;
if (ab.Enabled)
{
if (s.OverchargeActive) abilityMul *= 1.0 + ab.Overcharge.Magnitude / 100.0;
if (s.BerserkActive)
{
abilityMul *= 1.0 + missing * ab.Berserk.Magnitude;
if (crit) critMul += missing * ab.Berserk.Magnitude2;
}
}
if (berserkCard > 0) abilityMul *= 1.0 + missing * berserkCard / 100.0;
return dmgMul * hsMul * critMul * abilityMul * mDeal;
}
// The damage hook path: computes its own MTake. Delegates to the mTake-taking overload.
public static double DefenseMultiplier(in PlayerSnapshot s, bool headshot, in ResolvedHandicap rh, AbilitiesConfig ab) =>
DefenseMultiplier(s, headshot, ab, HandicapModel.MTake(s, rh));
// Overload taking a PRECOMPUTED MTake (the global_take team card is already folded in by HandicapModel).
public static double DefenseMultiplier(in PlayerSnapshot s, bool headshot, AbilitiesConfig ab, double mTake)
{
double take = mTake;
if (ab.Enabled && s.AdrenalineActive) take *= 1.0 - ab.Adrenaline.Magnitude / 100.0;
if (headshot)
{
double hsCut = StatResolver.CardMag(s, CardKeys.HsReduction); // armor doesn't help vs HS; this does
if (hsCut > 0) take *= Math.Max(0.0, 1.0 - hsCut / 100.0);
}
return take;
}
// ---- pure amounts the reactive-sustain glue applies (it still does the engine HP/armor writes) ----
// dmgHealth is a double so BOTH callers share one formula bit-identically: the on-hit path passes the int
// ev.DmgHealth (widens exactly) with the crit multiplier; the thorns-reflect path passes the FLOAT `dealt`
// (the offense-scaled reflected damage — keeps full precision, no pre-truncation) with critMult=1.0 (×1.0 is exact).
public static int LifestealHeal(double dmgHealth, double lifestealPct, double critMult, int minHeal) =>
Math.Max(minHeal, (int)Math.Round(dmgHealth * lifestealPct / 100.0 * critMult));
public static int ArmorLifestealGain(double dmgHealth, double armorLifestealPct, double critMult) =>
(int)Math.Round(dmgHealth * armorLifestealPct / 100.0 * critMult);
// % of the damage ACTUALLY taken (health + armor, so it already includes the MTake handicap that sized the hit — a 5x
// handicap 250 HP hit at 10% is 25). The caller deals this FLAT (DamageDealer flat) so the bot eats exactly this: no
// build and no MDeal are re-applied on the way out.
public static double ThornsReflect(int dmgHealth, int dmgArmor, double thornsPct) => (dmgHealth + dmgArmor) * thornsPct / 100.0;
}

View file

@ -0,0 +1,102 @@
using Outnumbered.Config;
namespace Outnumbered.Domain;
// The balance spine. A single signed index t in [-1,+1] from four normalised factors (K/D, headshot-kill rate,
// killstreak, level) plus the mode progress axis, then raised by the mode floor and eased by Curve. Deal/take/XP all
// lerp from the same t so they reach their extremes together:
// t = +1 -> deal MDealFloor, take MTakeCeiling, XP XpCeiling (a dominant player)
// t = 0 -> x1 across the board
// t = -1 -> deal MDealCeiling, take MTakeFloor, XP XpFloor (a struggling player)
// t = MasterDifficulty*(N - B): N = weighted nerf average, B = how far K/D sits below neutral (comeback help).
public static class HandicapModel
{
// The four nerf factors (K/D, headshot-rate, streak, level) + the mode progress axis are kept INLINE rather than a
// HandicapFactor registry: adding a factor is rare and would need a new weight in HandicapConfig + its override mirror
// regardless, so a list buys little. The config-invariant guard denominators + weight-sum are precomputed once per
// reload in ResolvedHandicap (below) so this per-hit/per-tick kernel doesn't redo 5 Math.Max + 4 adds each call.
public static double ComputeT(in PlayerSnapshot s, in ResolvedHandicap rh)
{
var h = rh.Config;
if (!h.Enabled) return 0.0;
double kdN = 0.0, buff = 0.0;
if (s.Kills + s.Deaths >= 3)
{
double r = s.Kills / (double)Math.Max(1, s.Deaths);
kdN = Clamp01((r - h.KdNeutral) / rh.KdNerfDenom);
buff = Clamp01((h.KdNeutral - r) / rh.KdBuffDenom);
}
double hsRate = s.Kills >= h.HsMinKills ? s.HeadshotKills / (double)Math.Max(1, s.Kills) : 0.0;
double hsN = Clamp01(hsRate / rh.HsDenom);
double streakN = Clamp01(s.Streak / rh.StreakDenom);
double levelN = Clamp01(s.Level / rh.LevelDenom);
double progN = Clamp01(s.HandicapProgress);
double n = rh.WSum <= 0 ? 0.0
: (h.KdWeight * kdN + h.HsWeight * hsN + h.StreakWeight * streakN + h.LevelWeight * levelN
+ h.ProgressWeight * progN) / rh.WSum;
double t = Math.Clamp(h.MasterDifficulty * (n - buff), -1.0, 1.0);
if (s.HandicapFloor > t) t = Math.Min(s.HandicapFloor, 1.0); // monotonic floor: never eases below the mode's escalation
return Ease(t, h.Curve);
}
// The Curve easing, shared by the live path (ComputeT) and the API curve sampler (BandsFromT) — one definition, no drift.
private static double Ease(double t, double curve) => t >= 0 ? Math.Pow(t, curve) : -Math.Pow(-t, curve);
// The three bands as pure functions of an already-computed t (so callers that need several can share ONE ComputeT).
private static double DealFromT(double t, HandicapConfig h, double teamDeal) =>
(t >= 0 ? Lerp(1.0, h.MDealFloor, t) : Lerp(1.0, h.MDealCeiling, -t)) * teamDeal;
private static double TakeFromT(double t, HandicapConfig h, double teamTake) =>
(t >= 0 ? Lerp(1.0, h.MTakeCeiling, t) : Lerp(1.0, h.MTakeFloor, -t)) * teamTake;
private static double XpFromT(double t, HandicapConfig h) =>
t >= 0 ? Lerp(1.0, h.XpCeiling, t) : Lerp(1.0, h.XpFloor, -t);
// Damage the player DEALS (x1 neutral). The survival team buff rides on top so it reaches every survivor + path.
public static double MDeal(in PlayerSnapshot s, in ResolvedHandicap rh) => DealFromT(ComputeT(s, rh), rh.Config, s.TeamDealMult);
// Damage the player TAKES (x1 neutral). The survival team buff multiplies it down for every survivor.
public static double MTake(in PlayerSnapshot s, in ResolvedHandicap rh) => TakeFromT(ComputeT(s, rh), rh.Config, s.TeamTakeMult);
public static double XpMult(in PlayerSnapshot s, in ResolvedHandicap rh) => XpFromT(ComputeT(s, rh), rh.Config);
// Deal + take + XP from ONE ComputeT — for the HUD, which shows all three per player per tick (the per-hit damage
// hook needs only one band, so it keeps calling MDeal/MTake directly). Bit-identical to calling all three.
public static void Bands(in PlayerSnapshot s, in ResolvedHandicap rh, out double deal, out double take, out double xp)
{
double t = ComputeT(s, rh);
deal = DealFromT(t, rh.Config, s.TeamDealMult);
take = TakeFromT(t, rh.Config, s.TeamTakeMult);
xp = XpFromT(t, rh.Config);
}
// Neutral curve sample for a RAW index t in [-1,+1] (the ease is applied here; team multipliers stay 1): the balance
// API serializes dense arrays of these, so the site's theorycrafting curves come from the SAME compiled band code
// that scales live damage — they cannot drift from what players experience.
public static void BandsFromT(double t, in ResolvedHandicap rh, out double deal, out double take, out double xp)
{
// Same short-circuit as ComputeT: a disabled handicap IS flat x1 — sampled curves must say so too.
if (!rh.Config.Enabled) { deal = 1.0; take = 1.0; xp = 1.0; return; }
double e = Ease(Math.Clamp(t, -1.0, 1.0), rh.Config.Curve);
deal = DealFromT(e, rh.Config, 1.0);
take = TakeFromT(e, rh.Config, 1.0);
xp = XpFromT(e, rh.Config);
}
private static double Clamp01(double v) => v < 0 ? 0 : v > 1 ? 1 : v;
private static double Lerp(double a, double b, double t) => a + (b - a) * t;
}
// HandicapConfig + its config-invariant guard denominators / weight-sum, precomputed ONCE per reload so ComputeT —
// reached per damage hit + per HUD tick — doesn't redo 5 Math.Max + 4 adds each call. Two load-bearing constraints:
// - Keep `n = numerator / WSum` a DIVIDE, never `numerator * (1/WSum)` — the reciprocal differs in the last ULP.
// - StreakDenom/LevelDenom hold the EXACT (double)Math.Max(1, intField) value (int->double is exact here).
public readonly struct ResolvedHandicap(HandicapConfig c)
{
public readonly HandicapConfig Config = c;
public readonly double KdNerfDenom = Math.Max(0.01, c.KdMaxNerf - c.KdNeutral); // Max(0.01, KdMaxNerf - KdNeutral)
public readonly double KdBuffDenom = Math.Max(0.01, c.KdNeutral - c.KdMinBuff); // Max(0.01, KdNeutral - KdMinBuff)
public readonly double HsDenom = Math.Max(0.01, c.HsMaxNerf); // Max(0.01, HsMaxNerf)
public readonly double StreakDenom = Math.Max(1, c.StreakMaxNerf); // Max(1, StreakMaxNerf)
public readonly double LevelDenom = Math.Max(1, c.LevelMaxNerf); // Max(1, LevelMaxNerf)
public readonly double WSum = c.KdWeight + c.HsWeight + c.StreakWeight + c.LevelWeight + c.ProgressWeight; // KdWeight + HsWeight + StreakWeight + LevelWeight + ProgressWeight
}

View file

@ -0,0 +1,37 @@
namespace Outnumbered.Domain;
// Per-stat run-scoped bonus (the survival cards). Implemented engine-side by the active driver; the domain only sees
// this interface, so it stays pure. Returns 0 for any key without a bonus (and outside a survival run).
public interface IStatBonusSource
{
double Bonus(string statKey);
}
// An immutable read of everything the pure domain math needs about one player, built at the single engine site
// (SnapshotBuilder). The domain never touches a pawn/controller — only this. Upgrades is held by reference (not copied)
// so building a snapshot per hit/tick stays allocation-free; the dictionary is treated as read-only here.
public readonly struct PlayerSnapshot
{
public int Level { get; init; }
public int Prestige { get; init; }
public long Xp { get; init; }
public int Kills { get; init; }
public int Deaths { get; init; }
public int HeadshotKills { get; init; }
public int Streak { get; init; }
public int Health { get; init; } // current HP (0 = no live pawn captured) — drives missing-HP (Berserk)
public double HandicapProgress { get; init; } // mode progress axis, 0..1 (Gun Game ladder; 0 in TDM)
public double HandicapFloor { get; init; } // monotonic escalation floor in t-space; -1 = no floor
public double TeamDealMult { get; init; } // survival team card (squad-wide); 1.0 = none
public double TeamTakeMult { get; init; } // survival team card (squad-wide); 1.0 = none
public bool OverchargeActive { get; init; } // killstreak abilities that bend the damage chains
public bool BerserkActive { get; init; }
public bool AdrenalineActive { get; init; }
public Dictionary<string, int> Upgrades { get; init; } // permanent stat levels, by key (the PlayerData instance, by ref)
public IStatBonusSource? Cards { get; init; } // run-card bonus per stat key; null outside survival
}

View file

@ -0,0 +1,16 @@
using Outnumbered.Config;
namespace Outnumbered.Domain;
// XP/level/prestige curves (pure). The stateful level-up loop + its side effects (chat/sound/clan) stay engine-side;
// this owns only the formulas it walks.
public static class ProgressionModel
{
// XP required to go from `level` to `level+1`. LevelXpExponent shapes the curve (1 = linear, >1 = accelerating).
public static long XpToNext(int level, ProgressionConfig c) =>
c.LevelXpBase + (long)Math.Round(c.LevelXpStep * Math.Pow(Math.Max(0, level - 1), c.LevelXpExponent));
// Cumulative prestige XP boost (prestige never lowers difficulty; it only speeds the climb).
public static double PrestigeXpMultiplier(int prestige, ProgressionConfig c) =>
1.0 + prestige * (c.PrestigeXpBoostPercent / 100.0);
}

View file

@ -0,0 +1,20 @@
namespace Outnumbered;
// Canonical stat keys (upgrade dictionary keys + JSON stat ids). CSSharp-free so the Domain layer + the test project
// can reference them without the engine. Kept in the root Outnumbered namespace (not Domain) for back-compat with the
// many engine-side call sites that use the unqualified name.
public static class StatKeys
{
public const string Damage = "damage";
public const string CritChance = "crit_chance";
public const string CritDamage = "crit_damage";
public const string HeadshotDamage = "hs_damage";
public const string MaxHp = "max_hp";
public const string MaxArmor = "max_armor";
public const string Lifesteal = "lifesteal";
public const string ArmorLifesteal = "armor_lifesteal";
public const string HpRegen = "hp_regen";
public const string ArmorRegen = "armor_regen";
public const string Thorns = "thorns";
public const string XpBoost = "xp_boost";
}

View file

@ -0,0 +1,41 @@
using System.Collections.Frozen;
using Outnumbered.Config;
namespace Outnumbered.Domain;
// Resolves a player's effective stat values from their invested levels + run-card bonuses. Takes a stat-def lookup
// (key -> StatDef) rather than the named-field config, so it's already shaped for the stat registry — the engine builds
// the dictionary once and passes it. `Eff` = permanent only; `EffRun` = permanent + survival card; `CardMag` = the
// effect-card magnitude (a non-stat key, cards only).
public static class StatResolver
{
private static readonly StatDef Zero = new(0, 0);
private static int LevelOf(in PlayerSnapshot s, string key) =>
s.Upgrades is not null && s.Upgrades.TryGetValue(key, out var l) ? l : 0;
public static double Eff(in PlayerSnapshot s, string key, FrozenDictionary<string, StatDef> defs)
{
var d = defs.TryGetValue(key, out var def) ? def : Zero;
return d.Base + LevelOf(s, key) * d.PerLevel;
}
public static double EffRun(in PlayerSnapshot s, string key, FrozenDictionary<string, StatDef> defs) =>
Eff(s, key, defs) + (s.Cards?.Bonus(key) ?? 0.0);
public static double CardMag(in PlayerSnapshot s, string key) => s.Cards?.Bonus(key) ?? 0.0;
public static int MaxHp(in PlayerSnapshot s, FrozenDictionary<string, StatDef> defs, int baseMax) =>
baseMax + (int)EffRun(s, StatKeys.MaxHp, defs);
public static int MaxArmor(in PlayerSnapshot s, FrozenDictionary<string, StatDef> defs, int baseMax) =>
baseMax + (int)EffRun(s, StatKeys.MaxArmor, defs);
// 0 at full HP, 1 near death — drives Berserk (ability + passive card). Health<=0 (a snapshot built with no live
// pawn, or a downed pawn) yields 0, NOT 1: missing-HP scaling only applies to a live attacker, so "no pawn" must
// mean "no bonus", not "max bonus" (guards the pd-less Snapshot path where Health defaults to 0).
public static double MissingHpFraction(in PlayerSnapshot s, int maxHp) =>
// Health>=1 (guarded) and maxHp>=1 => 1 - Health/maxHp < 1, so the upper clamp can't bind; only the lower
// (overheal -> negative) is live. Math.Max(0, x) is bit-identical to Math.Clamp(x, 0, 1) here.
maxHp <= 0 || s.Health <= 0 ? 0.0 : Math.Max(0.0, 1.0 - s.Health / (double)maxHp);
}

View file

@ -0,0 +1,48 @@
using Outnumbered.Config;
namespace Outnumbered.Domain;
// Pure survival run-economy + escalation curves. The wave state machine + run banking stay engine-side; this owns the
// numbers they read.
public static class SurvivalEconomy
{
// Bots ALIVE at once this wave (simultaneous pressure), ramping from AliveBase to AliveCap.
public static int AliveForWave(int wave, SurvivalConfig c) =>
Math.Clamp(c.AliveBase + c.AlivePerWave * (wave - 1), 1, c.AliveCap);
// Total kills to clear a wave.
public static int WaveBudget(int wave, int aliveHumans, SurvivalConfig c) =>
Math.Max(1, c.BudgetBase + c.BudgetPerWave * (wave - 1) + c.BudgetPerPlayer * aliveHumans);
// Monotonic escalate-only handicap floor in t-space; -1 = no floor (idle / between runs).
public static double HandicapFloor(int wave, SurvivalConfig c) =>
// wave>=1 and Max(1,MaxNerfWave)>=1 => quotient>=0, so the lower clamp can never bind; Min(1,x) == Clamp(x,0,1) here.
wave <= 0 ? -1.0 : Math.Min(1.0, wave / (double)Math.Max(1, c.MaxNerfWave));
// Per-wave XP multiplier: ramps exponentially from x1 (wave 1) to xWinMult (the final wave). WinMult is the single
// knob (the FINAL-wave / "win" multiplier). Back-loaded by design so early waves pay ~nothing and the last few pay big.
// waveMult(w) = WinMult ^ ((w-1)/(WaveCount-1))
public static double WaveMult(int wave, SurvivalConfig c) =>
Math.Pow(c.WinMult, (wave - 1) / (double)Math.Max(1, c.WaveCount - 1));
// XP granted at the END of ONE cleared wave: raw wave XP (HP-damage + HS/crit, already xp_mult-card-scaled when banked)
// x prestige x the wave multiplier. NO per-run cap, NO XpBoost stat, NO handicap mult here — depth is the gate (you must
// survive + contribute to reach the big back-wave multipliers), and the handicap mult stays excluded so run XP can't
// re-couple to gameable K/D. 0 for a non-positive wave.
public static long WaveXpLump(double rawWaveXp, int wave, int prestige, SurvivalConfig c, ProgressionConfig p)
{
if (rawWaveXp <= 0) return 0;
double lump = rawWaveXp * ProgressionModel.PrestigeXpMultiplier(prestige, p) * WaveMult(wave, c);
return (long)Math.Floor(lump);
}
// Raw combat XP banked into the CURRENT wave's accumulator, scaled by the xp_mult card (so it compounds with the
// per-wave prestige x waveMult chain applied at grant time).
public static double AccrueWaveXp(double amount, double xpMultCardPct) =>
amount * (1.0 + xpMultCardPct / 100.0);
// Team-card squad multiplier (compounding): global_deal increases, global_take decreases.
public static double TeamMult(int level, double perPickPct, bool increase) =>
increase ? Math.Pow(1.0 + perPickPct / 100.0, level)
: Math.Pow(Math.Max(0.0, 1.0 - perPickPct / 100.0), level);
}

419
Outnumbered/Driver.cs Normal file
View file

@ -0,0 +1,419 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Timers;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.Logging;
using Outnumbered.Engine;
namespace Outnumbered;
// Shared match plumbing. Runs on the Deathmatch gamemode base (game_type 1 game_mode 2): native DM handles
// respawn + weapon deployment (so bots don't get stuck trying to buy); we layer on forced teams (humans CT vs
// bots T), squad loadouts, the human cap, objective removal, and kill-goal map rotation.
public sealed partial class OutnumberedPlugin
{
private bool _mapEnding;
internal bool MapEnding => _mapEnding; // drivers gate match-result writes on this (a win landing in the map-end grace window isn't a result)
private bool _seenMapStart; // false only until the FIRST map start after plugin load — the restart-restore window OnMapStart_Driver protects
private CounterStrikeSharp.API.Modules.Timers.Timer? _botTimer;
private IMatchDriver _driver = null!; // the active mode driver, chosen from the launch flag / Config.Mode
private IDraftDriver? Draft => _driver as IDraftDriver; // survival's draft/run capability; null in TDM/GG (no concrete casts)
private bool _ggTimerActive; // GG speedrun clock arms on first damage — a plain bool because the damage hook checks it per hit
// NOTE: we deliberately DON'T remove func_buyzone — bots' buy AI loops "trying to buy outside
// buy zone" if it's gone. Buying is already dead via mp_buytime 0 / mp_maxmoney 0.
private static readonly string[] ObjectiveEntities =
{ "func_bomb_target", "func_hostage_rescue", "hostage_entity", "weapon_c4", "planted_c4" };
// Mode REGISTRY — canonical id -> driver factory (the single place modes are wired). ResolveMode normalizes the
// launch flag / JSON to one of these ids; an unrecognised id -> UnbuiltMode (TDM + a warning). Add a mode = one row.
private static readonly Dictionary<string, Func<OutnumberedPlugin, IMatchDriver>> ModeRegistry = new()
{
["tdm"] = p => new TdmDriver(p),
["gungame"] = p => new GunGameDriver(p),
["survival"] = p => new SurvivalDriver(p),
};
private void Initialize_Driver()
{
string mode = ResolveMode();
_driver = ModeRegistry.TryGetValue(mode, out var make) ? make(this) : UnbuiltMode(mode); // unknown id -> warn + TDM
_ggTimerActive = _driver is GunGameDriver;
RebuildEffectiveHandicap();
_driver.OnActivated(); // survival starts its wave state machine here; no-op for TDM/GG
Logger.LogInformation("Outnumbered match driver: {Mode}", _driver.Id);
// Every ModeRegistry id must round-trip through NormalizeMode — else a mode whose canonical id is itself an alias for
// a DIFFERENT mode would be permanently unreachable (selection runs through NormalizeMode first). Adding a mode is one
// ModeRegistry row + its numeric/short aliases in NormalizeMode; this catches the "added the row, forgot the alias collides" slip.
foreach (var id in ModeRegistry.Keys)
if (NormalizeMode(id) is var mapped && mapped != id)
Logger.LogError("Outnumbered: ModeRegistry id '{Id}' does not round-trip through NormalizeMode (maps to '{Mapped}') — that mode is unreachable.", id, mapped);
RegisterListener<Listeners.OnMapStart>(OnMapStart_Driver);
RegisterListener<Listeners.OnClientPutInServer>(_ => SyncBots());
RegisterEventHandler<EventRoundStart>(OnRoundStart_Driver);
RegisterEventHandler<EventPlayerDeath>(OnPlayerDeath_Driver, HookMode.Pre);
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn_Driver);
RegisterEventHandler<EventPlayerTeam>(OnPlayerTeam_Driver);
_botTimer = AddTimer(5.0f, SyncBots, TimerFlags.REPEAT);
AddTimer(1.0f, () => SetupMap(Server.MapName)); // covers a hot-reload mid-map
}
private void Shutdown_Driver()
{
_botTimer?.Kill();
_driver?.OnDeactivated(); // tear down driver-owned timers (survival's wave heartbeat) so they don't leak on hot-reload
}
// Mode selection: a launch flag wins (so ONE shared outnumbered.json can serve every instance — just vary
// the flag per server), else the JSON "Mode", else tdm. Flag: `-outnumbered_mode <id>` (also accepts `+`),
// parsed straight from the process command line. The id may be a NAME or a NUMBER (whichever you prefer):
// 0|tdm, 1|gg|gungame, 2|survival.
// NOTE on game_type/game_mode: we deliberately DON'T select off those. All our modes run on the SAME
// Deathmatch base (game_type 1 game_mode 2) — they'd be identical — and launching CS2's native slots (e.g.
// game_mode 0 = Arms Race) would boot the engine's own ruleset, which fights our custom one. So the base
// stays DM for every mode and this dedicated flag does the picking.
private string ResolveMode()
{
string raw = "tdm", src = "default";
try
{
var tag = ArgValue(CommandLineArgs(), "outnumbered_mode");
if (!string.IsNullOrWhiteSpace(tag)) { raw = tag; src = "launch flag"; }
}
catch (Exception ex) { Logger.LogWarning(ex, "Outnumbered: reading launch args failed; using JSON Mode"); }
if (src == "default" && !string.IsNullOrWhiteSpace(Config.Mode)) { raw = Config.Mode; src = "config"; }
string mode = NormalizeMode(raw);
Logger.LogInformation("Outnumbered mode: {Mode} (from {Src}: '{Raw}')", mode, src, raw);
return mode;
}
// Canonical mode id from a name or a number (so the flag/JSON can use either form). Unknown passes through
// (Initialize_Driver's ModeRegistry lookup misses it -> UnbuiltMode -> TDM with a warning).
private static string NormalizeMode(string s) => s.Trim().ToLowerInvariant() switch
{
"0" or "tdm" => "tdm",
"1" or "gg" or "gungame" => "gungame",
"2" or "survival" or "wave" => "survival",
var other => other,
};
// A recognised-but-not-yet-built (or unrecognised) mode: log loudly and run TDM so the server still comes up.
private IMatchDriver UnbuiltMode(string requested)
{
Logger.LogWarning("Outnumbered: mode '{Mode}' is not available yet — running TDM instead.", requested);
return new TdmDriver(this);
}
// ---- map / round setup ----
private void OnMapStart_Driver(string mapName)
{
bool roundEnded = _mapEnding; // true only if a kill-goal map change brought us here (the flag persists across changelevel)
_mapEnding = false;
// K/D + streak + ability state are per-match — reset everyone in memory across the changelevel.
foreach (var pd in _players.Values)
{
pd.Kills = 0; pd.Deaths = 0; pd.Streak = 0; pd.HeadshotKills = 0; pd.GgRung = 0; pd.GgRungKills = 0;
pd.GgRunStartedAtMs = 0; // new match, new speedrun clock
Array.Clear(pd.AbilityReadyAt); Array.Clear(pd.AbilityActiveUntil);
}
_driver.OnMatchReset(); // clear per-match driver state (e.g. Gun Game bot ladder progress)
// Every map change is a match boundary -> wipe the DB match table. The ONLY protected window is the first map
// start after plugin load: that's the restart-restore case (a graceful shutdown dumped mid-match state for
// reconnecting players). Without the _seenMapStart arm, a manual/external changelevel left leavers' stale rows
// behind — restorable next match, complete with a cross-match GG speedrun clock.
if (roundEnded || _seenMapStart) WipeMatch();
_seenMapStart = true;
AddTimer(3.0f, () => SetupMap(mapName)); // entities/gamerules aren't ready at the instant of map start
}
private void SetupMap(string mapName)
{
if (!_live) return; // scheduled 1-3s out; skip if the plugin was unloaded/hot-reloaded in the meantime
ApplyCvars();
RemoveObjectives();
SyncBots();
Rules.MakeRoundEndless();
_engineReady = true; // engine statics are safe from here on — opens the status-payload rebuild (Api.cs)
_driver.OnMapSetup(); // map is up + simulating -> safe for the driver to touch the engine (survival arms its wave machine)
Logger.LogInformation("Outnumbered {Mode} driver active on {Map}", _driver.Id, mapName);
}
private HookResult OnRoundStart_Driver(EventRoundStart ev, GameEventInfo info)
{
ApplyCvars(); // some cvars reset per round
RemoveObjectives(); // objective entities respawn per round
SyncBots();
Server.NextFrame(Rules.MakeRoundEndless); // after the game has set the round timer from the cvar, override it
return HookResult.Continue;
}
private void ApplyCvars()
{
Server.ExecuteCommand(
// Team deathmatch, never FFA; no friendly fire
"mp_dm_teammode 1;mp_teammates_are_enemies 0;mp_friendlyfire 0;" +
"ff_damage_reduction_bullets 0;ff_damage_reduction_grenade 0;ff_damage_reduction_other 0;" +
"mp_autoteambalance 0;mp_limitteams 0;" +
// solo human on CT = "team wiped" on death -> suppress round-win so play is continuous
"mp_ignore_round_win_conditions 1;mp_roundtime 60;mp_roundtime_deployment 0;" +
// native DM respawn handles respawning AND weapon deployment (so bots don't try to buy)
$"mp_respawn_on_death_ct 1;mp_respawn_on_death_t 1;mp_respawn_immunitytime {Config.Match.SpawnProtectionSeconds};" +
"mp_freezetime 0;mp_timelimit 0;mp_warmuptime 3;" +
// no economy / no objectives / no dropped clutter / no DM bonus-weapon prompt
// mp_buy_anywhere 1: whole map counts as a buy zone, so bots' buy AI never loops on
// "outside buy zone" (they have $0 via maxmoney/startmoney, so they buy nothing anyway).
"mp_buytime 0;mp_buy_during_immunity 0;mp_maxmoney 0;mp_startmoney 0;mp_buy_anywhere 1;mp_buy_allow_grenades 0;mp_free_armor 0;" +
// raise the grenade carry cap (default 4) so all 5 ability-key grenades can be held at once
"ammo_grenade_limit_total 5;" +
"mp_give_player_c4 0;mp_hostages_max 0;mp_death_drop_gun 0;mp_death_drop_grenade 0;mp_death_drop_defuser 0;" +
"mp_dm_bonus_length_max 0;mp_dm_bonus_length_min 0;mp_dm_time_between_bonus_max 9999;mp_dm_time_between_bonus_min 9999;" +
// bots: forced to T, fixed difficulty, rogues allowed (lone-wolf instead of squadding up)
"bot_quota_mode normal;bot_join_team t;sv_auto_adjust_bot_difficulty 0;bot_chatter off;bot_allow_rogues 1;" +
$"bot_difficulty {Config.Match.BotDifficulty};custom_bot_difficulty {Config.Match.BotDifficulty};" +
_driver.ExtraCvars); // mode-specific cvars (e.g. GG mp_randomspawn to scatter the horde)
}
private static void RemoveObjectives()
{
foreach (var name in ObjectiveEntities)
foreach (var ent in Utilities.FindAllEntitiesByDesignerName<CEntityInstance>(name))
if (ent is { IsValid: true }) ent.Remove();
}
// ---- bots ----
private void SyncBots()
{
// Survival owns bot population (wave spawning) — the steady-state quota model steps aside.
if (_driver.OwnsBotPopulation) { _driver.ManageBots(); return; }
var players = Utilities.GetPlayers();
int humans = players.Count(IsHuman);
int desired = Math.Clamp(humans * Config.Match.BotsPerHuman, 0, Config.Match.MaxBots);
Server.ExecuteCommand($"bot_quota {desired}");
ForceBotsToTerrorist(players); // force any bots that landed on CT back to T (bot_join_team only affects new bots)
}
// ---- kill tracking for the map-end goal (respawn itself is native DM) ----
private HookResult OnPlayerDeath_Driver(EventPlayerDeath ev, GameEventInfo info)
{
var victim = ev.Userid;
var attacker = ev.Attacker;
// a "real" kill = a valid attacker killing a different player (not suicide / world death)
bool realKill = attacker is { IsValid: true } && victim is { IsValid: true } && attacker.Slot != victim.Slot;
// a bot died -> drop its burns so a fast slot-reuse (suicide/respawn within the burn window) can't inherit fire
if (IsBot(victim)) ClearBurnsForBot(victim.Slot);
// drop any crit flag the offense hook set but EventPlayerHurt never consumed (overkill collapse / 0-HP-damage hit)
if (victim is { IsValid: true }) _critPending.Remove(victim.Slot);
// victim death (human): deaths++, killstreak reset
if (victim is { IsValid: true } && !victim.IsBot && PdOf(victim) is { } vpd)
{
vpd.Deaths++; vpd.Streak = 0;
// streak-earned availability is lost on death; active effects end. Cooldowns keep ticking (spec §4).
Array.Clear(vpd.AbilityActiveUntil);
_driver.OnHumanDeath(victim, vpd); // survival: run wipe detection (no respawn; revived at wave-clear). No-op elsewhere.
}
// attacker kill: human -> kills++/streak/XP + mode result; bot -> mode result only (bots have no PlayerData)
if (realKill && !attacker!.IsBot)
{
if (PdOf(attacker) is { } apd) // the cold death path resolves via the seam (the hot damage hook is the perf-exempt one)
{
apd.Kills++; apd.Streak++;
if (ev.Headshot) apd.HeadshotKills++; // feeds the handicap's headshot-rate factor
// ding when this kill makes an ability newly castable (streak hit its exact threshold)
for (int i = 0; i < AbilityCount; i++)
if (apd.Streak == AbilityCfg(i).StreakReq) { PlaySound(attacker, Config.Sounds.AbilityReady); break; }
_driver.OnHumanKill(attacker, apd); // mode result: TDM kill-goal / Gun Game rung-advance + win
// Explode-on-Kill card (survival): drop a real HE blast on the bot's corpse, deferred a frame OUT of
// this death hook. Chains for free — a blast kill fires its own death event -> back here -> re-explodes.
if (IsBot(victim) && EffectCardMag(apd, CardKeys.ExplodeKill) > 0
&& victim.PlayerPawn.Value?.AbsOrigin is { } corpse
&& attacker.AuthorizedSteamID?.SteamId64 is { } sid) // pinned id for the deferred identity re-validation
{
var pos = new Vector(corpse.X, corpse.Y, corpse.Z);
// defer a frame OUT of this death hook; re-validate the attacker's identity (slot reuse within the frame)
NextFrameForSlot(attacker.Slot, sid, a => ExplodeAt(a, pos));
}
}
GrantKillXp(attacker); // step 4 progression (XP scaled by the handicap)
}
else if (realKill && attacker!.IsBot)
{
_driver.OnBotKill(attacker); // Gun Game: bots climb the ladder too (so players can lose); no-op in TDM
}
// headshot demotion: the victim loses a kill of ladder progress (Gun Game; no-op in TDM)
if (realKill && ev.Headshot && victim is { IsValid: true })
_driver.OnHeadshotDeath(victim);
return HookResult.Continue;
}
// ---- loadout (override DM's deployment with our loadout) ----
private HookResult OnPlayerSpawn_Driver(EventPlayerSpawn ev, GameEventInfo info)
{
var p = ev.Userid;
if (p is not { IsValid: true } || p.IsHLTV) return HookResult.Continue;
bool isBot = p.IsBot;
NextFrameForSlot(p.Slot, pl => ApplyLoadout(pl, isBot), requireAlive: true);
return HookResult.Continue;
}
// Spawn loadout. The bot branch + the knife/armor tail are shared across modes; the human weapons are the
// mode driver's call (TDM: chosen primary+secondary+shop carriers; Gun Game: the current ladder rung).
// Also reused mid-life by Gun Game to hand over the next rung's gun on a kill.
// Ability-key grenades are NOT given here — they're granted only while an ability is castable
// (see Abilities.cs ReconcileGrenades) so the key is live exactly when the ability is, and never throwable.
internal void ApplyLoadout(CCSPlayerController p, bool isBot)
{
p.RemoveWeapons();
if (isBot) _driver.GiveBotLoadout(p); // TDM: fixed squad; Gun Game: the bot's current ladder rung
else _driver.GiveHumanLoadout(p);
p.GiveNamedItem(EngineNames.WeaponKnife);
p.GiveNamedItem(EngineNames.ItemAssaultSuit); // kevlar + helmet — grants exactly 100 armor
// item_assaultsuit forces armor to 100, so a player whose MaxArmor stat exceeds that must be topped back up to
// their real cap right HERE, after the suit. (The separate spawn cap-applier races the suit and loses if it runs
// first; doing it inline makes this the deterministic last writer.) Bots keep the suit's flat 100.
if (!isBot && p.PlayerPawn.Value is { Health: > 0 } pawn && PdOf(p) is { } pd)
PawnWriter.SetArmor(pawn, MaxArmorOf(pd));
// Vanilla-parity fire gate. The rebuild deploys a FRESH weapon entity whose initial-deploy gate opens later
// than a native switch — and bots fire on the first legal tick, so a human loses every same-window draw race
// (worst at a GG rank-up, where the kill triggers regives on both sides). One frame after the deploy settles,
// open the gate: the draw still animates, but firing is allowed immediately (the cs2-gungame / CS2-Deathmatch
// FastWeaponEquip pattern). Humans only — bots already fire at the gate.
if (!isBot) NextFrameForSlot(p.Slot, OpenFireGate, requireAlive: true);
}
// Melee/zeus/carrier keep native timing: the gate only matters for guns, and the knife finale should stay a fair
// knife fight. (Ability grenades can't be in hand here — they're granted later, only while castable.)
private static void OpenFireGate(CCSPlayerController p)
{
var w = Inventory.ActiveWeapon(p);
if (w is null) return;
string name = w.DesignerName ?? "";
if (Inventory.IsMeleeOrZeus(name) || name == EngineNames.ShopCarrier) return;
w.NextPrimaryAttackTick = Server.TickCount + 1;
Utilities.SetStateChanged(w, EngineNames.CBasePlayerWeapon, EngineNames.NextPrimaryAttackTick);
}
// The two shop items every human spawns with when the quick-buy is on: the healthshot carrier (X = open menu) and the
// zeus (key 3 + the rest anchor that stops grenades auto-deploying over the knife). One definition; each driver's human
// loadout calls it at the same spot (so it stays bit-identical — Gun Game still skips it on an empty ladder via its own
// earlier return). No-op when the shop is disabled.
internal void GiveShopCarriers(CCSPlayerController p)
{
if (!Config.Shop.Enabled) return;
p.GiveNamedItem(EngineNames.ShopCarrier); // healthshot (X) toggles the quick-buy shop / the survival draft
p.GiveNamedItem(EngineNames.ShopMelee); // zeus — CT-legal, serves as shop key 3 + the grenade-rest anchor
}
// The chosen-loadout human spawn shared by every WeaponShopEnabled mode (TDM + Survival): the saved !guns primary/secondary
// + the shop carriers. Gun Game overrides with its rung weapon instead, so it keeps its own GiveHumanLoadout.
internal void GiveChosenLoadout(CCSPlayerController p)
{
var (primary, secondary) = ResolveLoadout(p); // saved !guns choice if unlocked, else config default
if (!string.IsNullOrEmpty(primary)) p.GiveNamedItem(primary);
if (!string.IsNullOrEmpty(secondary)) p.GiveNamedItem(secondary);
GiveShopCarriers(p);
}
// The standard bot loadout (TDM + Survival): one pool weapon by stable per-bot index + the configured grenades. (Gun
// Game bots use the ladder rung instead, so GunGameDriver keeps its own GiveBotLoadout.) One definition for the two that share it.
internal void GiveStandardBotLoadout(CCSPlayerController bot)
{
var pool = Config.Match.BotWeapons;
bot.GiveNamedItem(pool.Count > 0 ? pool[BotLoadoutIndex(bot) % pool.Count] : EngineNames.WeaponAk47);
foreach (var g in Config.Match.BotGrenades) bot.GiveNamedItem(g);
}
// Stable per-bot weapon slot: a bot's rank in slot order (count of bots with a lower slot). Each bot keeps
// its index across respawns -> always the same weapon. Guarantees the squad composition mirrors BotWeapons
// exactly (e.g. 2 AR / 1 shotgun / 1 AWP per 4).
internal static int BotLoadoutIndex(CCSPlayerController bot) =>
Utilities.GetPlayers().Count(b => IsBot(b) && b.Slot < bot.Slot);
// ---- team enforcement: humans on CT (cap), bots forced to T ----
private HookResult OnPlayerTeam_Driver(EventPlayerTeam ev, GameEventInfo info)
{
var p = ev.Userid;
if (!IsHuman(p)) return HookResult.Continue;
int slot = p.Slot;
Server.NextFrame(() => EnforceHumanTeam(slot));
return HookResult.Continue;
}
private void EnforceHumanTeam(int slot)
{
var p = Utilities.GetPlayerFromSlot(slot);
if (p is not { IsValid: true } || p.IsBot) return;
// Survival fail-closed: no mid-run joins. While a run is in progress, a joining human (lands on T/None first)
// is parked in spectator; existing CT participants (alive or downed-awaiting-revive) are left untouched.
if (Draft is { RunInProgress: true })
{
if (p.Team is CsTeam.Terrorist or CsTeam.None)
{
p.SwitchTeam(CsTeam.Spectator);
p.PrintToChat("[Outnumbered] A survival run is in progress — you'll join the next one.");
}
return;
}
int cap = _driver.MaxHumansOnCt; // per-mode (GG = 5, TDM = Match.MaxHumansOnCt)
int ctHumans = Utilities.GetPlayers()
.Count(x => IsHuman(x) && x.Team == CsTeam.CounterTerrorist && x.Slot != slot);
switch (p.Team)
{
// an over-cap CT, or a T/None arrival when CT is already full -> spectator (both land on the same outcome)
case CsTeam.CounterTerrorist when ctHumans >= cap:
case CsTeam.Terrorist or CsTeam.None when ctHumans >= cap:
p.SwitchTeam(CsTeam.Spectator);
p.PrintToChat($"[Outnumbered] CT is full (max {cap}) — moved to spectator.");
break;
// a T/None arrival with room -> put them on CT (native DM spawns them on the new team)
case CsTeam.Terrorist or CsTeam.None:
p.SwitchTeam(CsTeam.CounterTerrorist);
break;
}
}
// ---- map rotation on kill goal ----
// Ends the match and rotates the map. reason = the chat headline (TDM kill goal / Gun Game win); null = default.
internal void TriggerMapEnd(string? reason = null)
{
if (_mapEnding) return;
_mapEnding = true;
string next = NextMap();
Server.PrintToChatAll($"[Outnumbered] {reason ?? "Kill goal reached"} — next map: {next}");
Logger.LogInformation("Outnumbered match end on {Cur} ({Reason}); switching to {Next}",
Server.MapName, reason ?? "kill goal", next);
AddTimer(Config.Match.MapChangeDelaySeconds, () => { if (_live) Server.ExecuteCommand($"changelevel {next}"); });
}
private string NextMap()
{
var maps = _driver.Maps; // mode map pool (falls back to Match.Maps when empty)
if (maps.Count == 0) return Server.MapName;
int i = -1;
for (int k = 0; k < maps.Count; k++)
if (string.Equals(maps[k], Server.MapName, StringComparison.OrdinalIgnoreCase)) { i = k; break; }
return maps[(i + 1) % maps.Count]; // i == -1 -> first
}
}

103
Outnumbered/Effects.cs Normal file
View file

@ -0,0 +1,103 @@
using System.Reflection;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Timers;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.Logging;
using Outnumbered.Engine;
namespace Outnumbered;
// Survival "effect" card subsystems that don't belong to a single hook: the Burn DoT registry + tick, and the
// Explode-on-Kill real-grenade spawn. Both are survival-only by gating (the cards only exist there) but the code is
// mode-agnostic. The native projectile-create + the raw attributed hit live in Engine (GrenadeSpawner / DamageDealer);
// this owns only the burn registry + per-tick bookkeeping. Wired from Outnumbered.cs (Initialize_Effects / Shutdown_Effects).
public sealed partial class OutnumberedPlugin
{
// Burn DoT: keyed by (bot slot, attacker slot) so each attacker burns a bot INDEPENDENTLY (different players STACK;
// a player's own re-hit just refreshes their entry). Value = (when this attacker's burn expires, attacker SteamID for
// identity-revalidation against slot reuse). Flat DPS from config; the tick deals it via a RAW (armor-skipping) hit.
private readonly Dictionary<(int bot, int atk), (double until, ulong sid)> _burns = new();
private readonly List<KeyValuePair<(int bot, int atk), (double until, ulong sid)>> _burnScratch = new(); // reused snapshot for BurnTickAll
private CounterStrikeSharp.API.Modules.Timers.Timer? _burnTimer;
private void Initialize_Effects()
{
// Every effect-card key must have a catalog entry in Config.Survival.Cards, or the draft can never offer it
// (a typo'd/renamed key on the shared JSON silently disables that card on all 8 servers). Reflect over CardKeys
// so a newly-added constant is covered automatically.
var cardKeys = Config.Survival.Cards.Select(c => c.Key).ToHashSet(StringComparer.Ordinal);
foreach (var f in typeof(CardKeys).GetFields(BindingFlags.Public | BindingFlags.Static))
if (f.IsLiteral && f.GetRawConstantValue() is string key && !cardKeys.Contains(key))
Logger.LogError("Outnumbered: CardKeys.{Name} ('{Key}') has no entry in Config.Survival.Cards — that effect card can never be drafted.", f.Name, key);
// One steady tick (no-op when the registry is empty, i.e. in TDM/GG or a survival run with no Burn cards).
_burnTimer = AddTimer(Math.Max(0.1f, Config.Survival.BurnTickSeconds), BurnTickAll, TimerFlags.REPEAT);
}
private void Shutdown_Effects()
{
_burnTimer?.Kill();
_burns.Clear();
}
// A human with the Burn card hit a bot — (re)apply this attacker's burn on that bot.
private void RegisterBurn(int botSlot, int atkSlot, ulong atkSid)
{
_burns[(botSlot, atkSlot)] = (Server.CurrentTime + Config.Survival.BurnDurationSeconds, atkSid);
}
// A bot died — drop all its burns so a fast slot-reuse (suicide/respawn within the burn window) can't inherit fire.
private void ClearBurnsForBot(int botSlot)
{
if (_burns.Count == 0) return;
List<(int, int)>? gone = null;
foreach (var k in _burns.Keys) if (k.bot == botSlot) (gone ??= new()).Add(k);
if (gone is not null) foreach (var k in gone) _burns.Remove(k);
}
// Apply one tick of burn to every live burning bot, per attacker (so 5 shooters = 5x the DPS). Flat, armor-skipping,
// still attributed (a burn KILL credits the player -> wave count / XP / explode-on-kill chaining). Prune expired or
// now-invalid entries (bot dead/gone, attacker gone or slot reused).
private void BurnTickAll()
{
if (_burns.Count == 0) return;
double now = Server.CurrentTime;
float dmg = (float)(Config.Survival.BurnDamagePerSecond * Config.Survival.BurnTickSeconds);
// Iterate a SNAPSHOT: a LETHAL raw tick fires player_death synchronously inside DamageDealer.Deal's Invoke ->
// OnPlayerDeath_Driver (Pre, inline) -> ClearBurnsForBot -> _burns.Remove, which would corrupt a live foreach
// ("Collection was modified"). The snapshot makes that mid-loop mutation harmless; ContainsKey skips any entry
// already cleared this tick (e.g. a co-attacker's burn on a bot an earlier entry just killed). Reused scratch
// (BurnTickAll isn't re-entrant + game-thread) avoids a per-tick List alloc.
_burnScratch.Clear();
foreach (var kv in _burns) _burnScratch.Add(kv);
foreach (var kv in _burnScratch)
{
if (!_burns.ContainsKey(kv.Key)) continue;
var (botSlot, atkSlot) = kv.Key;
var (until, sid) = kv.Value;
var bot = Utilities.GetPlayerFromSlot(botSlot);
if (now >= until || !IsLiveBot(bot))
{ _burns.Remove(kv.Key); continue; } // safe to mutate _burns here: we're iterating the _burnScratch snapshot
var atk = Utilities.GetPlayerFromSlot(atkSlot);
if (atk is not { IsValid: true } || atk.AuthorizedSteamID?.SteamId64 != sid) // attacker left / slot reused
{ _burns.Remove(kv.Key); continue; } // safe to mutate _burns here: we're iterating the _burnScratch snapshot
if (dmg > 0) DamageDealer.Deal(bot, atk, dmg, raw: true);
}
}
// Explode-on-Kill: a REAL HE blast at the corpse, attributed to `attacker` so its kills credit them (wave count +
// chaining: a blast kill fires its own death event -> re-triggers this). The blast re-enters our offense hook (attacker
// = the thrower) so it SCALES with the killer's build + handicap (owner's call); CT team means it never hurts survivors
// (ff off). The native projectile-create + the manual blast fallback both live in Engine.GrenadeSpawner.
internal void ExplodeAt(CCSPlayerController attacker, Vector pos)
{
var cfg = Config.Survival;
GrenadeSpawner.Explode(attacker, pos, (float)cfg.ExplodeBaseDamage, (float)cfg.ExplodeRadius, cfg.ExplodeFuseSeconds,
(msg, ex) => { if (ex is null) LogSurvival(msg); else Logger.LogWarning(ex, "[Survival] {Msg}", msg); }); // sig/invoke breaks log full stack at WARNING
}
}

View file

@ -0,0 +1,15 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
namespace Outnumbered.Engine;
// Controller-side schema writes (the pawn-side equivalents live in PawnWriter). Currently just the scoreboard clan tag —
// kept here so the field name + the SetStateChanged notify contract live together, like every other engine write.
internal static class ControllerWriter
{
public static void SetClan(CCSPlayerController p, string tag)
{
p.Clan = tag;
Utilities.SetStateChanged(p, EngineNames.CCSPlayerController, EngineNames.Clan);
}
}

View file

@ -0,0 +1,99 @@
using System.Runtime.InteropServices;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Memory;
namespace Outnumbered.Engine;
// Deals real, attributed damage as if `source` dealt it to `target`, returning the actual damage dealt. By default the hit
// re-enters the offense hook so it scales with the source's build + handicap (Explode-on-Kill relies on this) and fires
// EventPlayerDeath on a kill (proper credit / GG-rung / killstreak). A TakeDamageOld invoke does NOT raise player_hurt, so
// EventPlayerHurt effects (lifesteal, per-hit XP) don't trigger — callers apply those on the returned amount. Mirrors the
// CSSharp reference: a zeroed CTakeDamageInfo with AttackerInfo_t at +0x88, plus a CTakeDamageResult, fed to TakeDamageOld.
// raw = a FLAT, armor-skipping BURN hit (DoT): DFLAG_IGNORE_ARMOR + DMG_BURN.
// flat = a normal bullet that respects armor but is NOT scaled (thorns: the bot eats exactly what was computed).
// Both set Unscaled so the offense hook applies `damage` verbatim; attribution is still kept so a kill credits the source.
internal static class DamageDealer
{
// TRUE around an unscaled invoke (raw burn DoT or flat thorns reflect): the offense hook (OnEntityTakeDamagePre) returns
// immediately when set, so `damage` lands verbatim — no build/handicap scaling, no crit roll. Toggled inside Deal only.
internal static bool Unscaled;
// Byte offset of AttackerInfo_t inside CTakeDamageInfo (the CSSharp Tests.Native layout). A build-volatile fixup point
// — if the struct layout shifts on a CS2 update, this is the one-line change (kept here with the marshalling it serves).
private const int AttackerInfoOffset = 0x88;
// Cached all-zero source for bulk-clearing the two unmanaged buffers (one memcpy each vs N per-byte WriteByte interop
// crossings). Grows once to the largest class size; never written so it stays zero. Game-thread only, Deal not re-entrant.
private static byte[] _zero = [];
// The two unmanaged buffers, REUSED across calls instead of AllocHGlobal/FreeHGlobal per Deal (hot in burn waves: per
// burn-tick-per-bot, per thorns hit, per explode). Safe because Deal is game-thread + NOT re-entrant — the synchronous
// player_death its Invoke can fire only REMOVES burns (ClearBurnsForBot), and explode-on-kill is deferred a frame; no
// path re-enters Deal before it returns. Grow-only to the class size; never freed (process-lifetime); re-zeroed each call.
private static IntPtr _infoBuf, _resBuf;
private static int _infoCap, _resCap;
internal static float Deal(CCSPlayerController target, CCSPlayerController source, float damage, bool raw = false, bool flat = false)
{
if (damage <= 0 || !target.IsValid || !source.IsValid) return 0f;
var targetPawn = target.PlayerPawn.Value;
if (targetPawn is null || targetPawn.Health <= 0) return 0f;
int infoSize = Schema.GetClassSize(EngineNames.CTakeDamageInfo);
int resSize = Schema.GetClassSize(EngineNames.CTakeDamageResult);
if (_zero.Length < infoSize || _zero.Length < resSize) _zero = new byte[Math.Max(infoSize, resSize)];
// Grow the reused buffers if needed (class sizes are fixed per build, so this fires only on the first call), then
// re-zero them — no per-call AllocHGlobal/FreeHGlobal.
if (_infoCap < infoSize) { _infoBuf = _infoBuf == IntPtr.Zero ? Marshal.AllocHGlobal(infoSize) : Marshal.ReAllocHGlobal(_infoBuf, (IntPtr)infoSize); _infoCap = infoSize; }
if (_resCap < resSize) { _resBuf = _resBuf == IntPtr.Zero ? Marshal.AllocHGlobal(resSize) : Marshal.ReAllocHGlobal(_resBuf, (IntPtr)resSize); _resCap = resSize; }
IntPtr infoPtr = _infoBuf, resPtr = _resBuf;
Marshal.Copy(_zero, 0, infoPtr, infoSize); // bulk-zero the reused buffers (memcpy) — restore the clean-slate contract
Marshal.Copy(_zero, 0, resPtr, resSize);
var info = new CTakeDamageInfo(infoPtr);
var ai = new AttackerInfo_t
{
NeedInit = true,
IsPawn = true,
AttackerPawn = source.Pawn.Raw,
AttackerPlayerSlot = source.Slot,
};
Marshal.StructureToPtr(ai, new IntPtr(infoPtr.ToInt64() + AttackerInfoOffset), false);
uint inflictor = source.PawnIsAlive ? source.Pawn.Raw : source.PlayerPawn.Raw;
Schema.SetSchemaValue(info.Handle, EngineNames.CTakeDamageInfo, EngineNames.Inflictor, inflictor);
Schema.SetSchemaValue(info.Handle, EngineNames.CTakeDamageInfo, EngineNames.Attacker, source.Pawn.Raw);
info.Damage = damage;
info.BitsDamageType = raw ? DamageTypes_t.DMG_BURN : DamageTypes_t.DMG_BULLET;
if (raw) info.DamageFlags |= TakeDamageFlags_t.DFLAG_IGNORE_ARMOR;
var result = new CTakeDamageResult(resPtr);
Schema.SetSchemaValue(result.Handle, EngineNames.CTakeDamageResult, EngineNames.OriginatingInfo, info.Handle);
result.HealthBefore = targetPawn.Health;
result.HealthLost = (int)damage;
result.DamageDealt = damage;
result.PreModifiedDamage = damage;
bool unscaled = raw || flat;
#pragma warning disable CS0618
if (unscaled) Unscaled = true;
try { VirtualFunctions.CBaseEntity_TakeDamageOldFunc.Invoke(targetPawn, info, result); }
finally { if (unscaled) Unscaled = false; }
#pragma warning restore CS0618
return info.Damage;
}
}
// Attribution payload the engine reads out of CTakeDamageInfo at +0x88 (kill credit / assists). Byte-laid-out to match
// the CSSharp reference (Tests.Native); written via Marshal in Deal.
[StructLayout(LayoutKind.Sequential)]
internal struct AttackerInfo_t
{
public bool NeedInit;
public bool IsPawn;
public bool IsWorld;
public uint AttackerPawn;
public int AttackerPlayerSlot;
public int TeamChecked;
public int Team;
}

View file

@ -0,0 +1,47 @@
using System.Collections.Frozen;
namespace Outnumbered.Engine;
// Every engine string/identifier in one place: schema classes + fields, designer names, entity classnames, item-def
// indices. A CS2/CSSharp rename becomes a one-line fix here instead of a hunt across files (engine churn is when, not if).
internal static class EngineNames
{
// schema classes
public const string CBaseEntity = "CBaseEntity";
public const string CCSPlayerPawn = "CCSPlayerPawn";
public const string CCSPlayerController = "CCSPlayerController";
public const string CBasePlayerWeapon = "CBasePlayerWeapon";
public const string CTakeDamageInfo = "CTakeDamageInfo";
public const string CTakeDamageResult = "CTakeDamageResult";
// schema fields
public const string Health = "m_iHealth";
public const string MaxHealth = "m_iMaxHealth";
public const string ArmorValue = "m_ArmorValue";
public const string MoveType = "m_MoveType";
public const string Clan = "m_szClan";
public const string Inflictor = "m_hInflictor";
public const string Attacker = "m_hAttacker";
public const string OriginatingInfo = "m_pOriginatingInfo";
public const string NextPrimaryAttackTick = "m_nNextPrimaryAttackTick";
// pawn designer-name varies by build — FrozenSet for the per-pellet membership check in the damage hook
public static readonly FrozenSet<string> PlayerPawnDesigners =
new[] { "cs_player_pawn", "player" }.ToFrozenSet(StringComparer.Ordinal);
// entity classnames / designer names
public const string PointWorldText = "point_worldtext";
public const string GameRulesDesigner = "cs_gamerules";
// (the HE projectile is spawned via the native create func in GrenadeSpawner, not by classname)
// common item designer-names given/compared in loadouts (the knife rung, the spawn armor, the default-pool fallback)
public const string WeaponKnife = "weapon_knife";
public const string ItemAssaultSuit = "item_assaultsuit";
public const string WeaponAk47 = "weapon_ak47";
// the two shop "carrier" items every human spawns with: the healthshot (X toggles the menu) + the zeus (shop key 3)
public const string ShopCarrier = "weapon_healthshot";
public const string ShopMelee = "weapon_taser";
// item-def index for the HE grenade (native projectile create)
public const int HeGrenadeItemDef = 44;
}

View file

@ -0,0 +1,93 @@
using System.Runtime.InteropServices;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions;
using CounterStrikeSharp.API.Modules.Utils;
namespace Outnumbered.Engine;
// A real HE blast at a point, attributed to a player. Spawns via the game's NATIVE projectile-create (the same call used
// when a player throws) because Valve removed the think-arming from the InitializeSpawnFromWorld input, so a
// CreateEntityByName grenade is inert on current CS2. Signature from MatchZy / cs2-executes. Lazy-resolved; if it doesn't
// match this build, falls back to a manual radius blast (no engine VFX). Config-agnostic: damage/radius/fuse are passed in;
// `log` receives one-time path notes. Args: (position, angle, velocity, velocity, IntPtr.Zero, itemDefIndex).
internal static class GrenadeSpawner
{
private static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
private static MemoryFunctionWithReturn<nint, nint, nint, nint, nint, int, CHEGrenadeProjectile>? _heCreate;
private static bool _init, _ok, _nativeLogged, _manualLogged;
public static void Explode(CCSPlayerController attacker, Vector pos, float baseDamage, float radius, float fuseSeconds, Action<string, Exception?> log)
{
if (attacker is not { IsValid: true } || attacker.PlayerPawn.Value is null) return;
if (!TrySpawnHe(attacker, pos, baseDamage, radius, fuseSeconds, log))
ManualBlast(attacker, pos, baseDamage, radius, log);
}
private static bool TrySpawnHe(CCSPlayerController attacker, Vector pos, float baseDamage, float radius, float fuseSeconds, Action<string, Exception?> log)
{
if (!_init)
{
_init = true;
try
{
_heCreate = new(IsLinux
? "55 4C 89 C1 48 89 E5 41 57 49 89 D7"
: "48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18 57 48 83 EC 50 48 8B AC 24 80 00 00 00 49 8B F8");
_ok = true;
}
catch (Exception ex)
{
log("explode-on-kill: HE native-create signature not found — manual blast fallback", ex);
_ok = false;
}
}
if (!_ok || _heCreate is null) return false;
try
{
var apawn = attacker.PlayerPawn.Value!;
var spawn = new Vector(pos.X, pos.Y, pos.Z + 12f);
var ang = new QAngle();
var vel = new Vector(0, 0, -10f);
var nade = _heCreate.Invoke(spawn.Handle, ang.Handle, vel.Handle, vel.Handle, IntPtr.Zero, EngineNames.HeGrenadeItemDef);
if (nade is null || !nade.IsValid) return false;
nade.Teleport(spawn, ang, vel);
nade.Globalname = "custom";
nade.TeamNum = apawn.TeamNum; // CT -> only damages bots; survivors safe (ff off)
nade.Thrower.Raw = attacker.PlayerPawn.Raw; // kill attribution + killfeed
nade.OriginalThrower.Raw = attacker.PlayerPawn.Raw;
nade.OwnerEntity.Raw = attacker.PlayerPawn.Raw;
nade.Damage = baseDamage; // scaled UP by the offense hook on detonation
nade.DmgRadius = radius;
nade.DetonateTime = Server.CurrentTime + Math.Max(0f, fuseSeconds); // think is scheduled now -> respected
if (!_nativeLogged) { _nativeLogged = true; log("explode-on-kill: native HE grenade spawned + armed OK", null); }
return true;
}
catch (Exception ex)
{
log("explode-on-kill: native HE invoke failed — manual blast fallback", ex);
_ok = false; // don't keep retrying a bad signature this session
return false;
}
}
// Snapshot the list — a lethal Deal fires player_death synchronously, which mutates bot state mid-loop.
private static void ManualBlast(CCSPlayerController attacker, Vector center, float baseDamage, float radius, Action<string, Exception?> log)
{
double r = Math.Max(1.0, radius);
foreach (var bot in Utilities.GetPlayers().ToList())
{
if (bot is not { IsValid: true, IsBot: true, IsHLTV: false } || !bot.PawnIsAlive) continue;
var bpos = bot.PlayerPawn.Value?.AbsOrigin;
if (bpos is null) continue;
double dx = bpos.X - center.X, dy = bpos.Y - center.Y, dz = bpos.Z - center.Z;
double dist = Math.Sqrt(dx * dx + dy * dy + dz * dz);
if (dist > r) continue;
float dmg = (float)(baseDamage * (1.0 - dist / r)); // linear falloff
if (dmg >= 1f) DamageDealer.Deal(bot, attacker, dmg); // non-raw -> scales + attributed + chains
}
if (!_manualLogged) { _manualLogged = true; log("explode-on-kill: native HE unavailable -> MANUAL radius blast (no engine VFX)", null); }
}
}

View file

@ -0,0 +1,67 @@
using CounterStrikeSharp.API.Core;
namespace Outnumbered.Engine;
// The weapon-inventory read surface (WeaponServices: MyWeapons / ActiveWeapon) plus the slot-select client commands.
// Centralizes the null-safe "walk / read the player's weapons" idiom shared by Abilities + Shop, so a
// CSSharp rename of WeaponServices/MyWeapons/ActiveWeapon — or a change to slot-select semantics — is one edit here.
// (The bare GiveNamedItem/RemoveItemByDesignerName loadout calls stay at their call sites: single-call, nothing to share.)
internal static class Inventory
{
// Slot-select client commands (engine INPUT, not schema). slot3 (the melee slot) is the neutral "rest" anchor; the zeus
// is deployed by name because the engine auto-deploys a granted grenade over the knife but not over a real weapon.
public const string SlotPrimary = "slot1";
public const string SlotSecondary = "slot2";
public const string SlotMelee = "slot3";
public const string UseZeus = "use weapon_taser";
// The player's currently-deployed weapon, or null (null-safe through pawn -> WeaponServices -> handle, IsValid-checked).
public static CBasePlayerWeapon? ActiveWeapon(CCSPlayerPawn? pawn)
{
var w = pawn?.WeaponServices?.ActiveWeapon.Value;
return w is { IsValid: true } ? w : null;
}
public static CBasePlayerWeapon? ActiveWeapon(CCSPlayerController p) => ActiveWeapon(p.PlayerPawn.Value);
// The active weapon's designer name, or "" when none — the common "what is this player holding?" probe.
public static string ActiveWeaponName(CCSPlayerPawn? pawn) => ActiveWeapon(pawn)?.DesignerName ?? "";
// TRUE if the designer name is a knife (any skin — weapon_knife, weapon_knife_t, weapon_bayonet, …) or the zeus. Lets the
// thorns abuse-guard tell "real melee in hand" from a gun (mirrors Shop's knife-neutral test, plus the zeus).
public static bool IsMeleeOrZeus(string designerName) =>
designerName == EngineNames.ShopMelee || designerName.Contains("knife") || designerName.Contains("bayonet");
// Every valid weapon the player holds (null-safe walk of WeaponServices.MyWeapons). Empty if no pawn / no services.
// Lazy (iterator) — fine for the cold classify-all walk; the hot exact-match check uses Holds (no enumerator alloc).
public static IEnumerable<CBasePlayerWeapon> Weapons(CCSPlayerController p)
{
var weapons = p.PlayerPawn.Value?.WeaponServices?.MyWeapons;
if (weapons is null) yield break;
foreach (var h in weapons)
{
var w = h.Value;
if (w is { IsValid: true }) yield return w;
}
}
// The weapon's full magazine size from its VData, or -1 if unavailable (the No-Reload / infinite-clip top-up reads it).
public static int MaxClip(CBasePlayerWeapon w) => w.As<CCSWeaponBase>().VData?.MaxClip1 ?? -1;
// Whether the pawn's weapon inventory is readable at all (pawn + WeaponServices + MyWeapons present) — distinguishes
// "no inventory" from "an empty inventory" for callers that branch on it (e.g. PreferredSlotCmd's no-services fallback).
public static bool HasWeaponServices(CCSPlayerController p) => p.PlayerPawn.Value?.WeaponServices?.MyWeapons is not null;
// True if the player holds a weapon with this designer name. Direct walk (no iterator alloc) for the per-reconcile path.
public static bool Holds(CCSPlayerController p, string designerName)
{
var weapons = p.PlayerPawn.Value?.WeaponServices?.MyWeapons;
if (weapons is null) return false;
foreach (var h in weapons)
{
var w = h.Value;
if (w is { IsValid: true } && w.DesignerName == designerName) return true;
}
return false;
}
}

View file

@ -0,0 +1,54 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
namespace Outnumbered.Engine;
// The one place pawn HP/armor are written: every write is paired with the required SetStateChanged so the schema-field
// names and the change-notification contract live together. Callers resolve the pawn; these never read config or pd.
internal static class PawnWriter
{
// MaxHealth MUST be set before Health, else Health clamps to 100. Sets both the pawn and the controller mirror.
public static void SetMaxHealth(CCSPlayerController p, CCSPlayerPawn pawn, int max)
{
pawn.MaxHealth = max;
p.MaxHealth = max;
Utilities.SetStateChanged(pawn, EngineNames.CBaseEntity, EngineNames.MaxHealth);
}
public static void SetHealth(CCSPlayerPawn pawn, int hp)
{
pawn.Health = hp;
Utilities.SetStateChanged(pawn, EngineNames.CBaseEntity, EngineNames.Health);
}
public static void SetArmor(CCSPlayerPawn pawn, int armor)
{
pawn.ArmorValue = armor;
Utilities.SetStateChanged(pawn, EngineNames.CCSPlayerPawn, EngineNames.ArmorValue);
}
// The pawn movement-state write + its change-notification (the shop freeze/unfreeze). Caller owns any velocity reset.
public static void SetMoveType(CCSPlayerPawn pawn, MoveType_t moveType)
{
pawn.MoveType = moveType;
Utilities.SetStateChanged(pawn, EngineNames.CBaseEntity, EngineNames.MoveType);
}
// A capped ADD never REDUCES: if the pawn is already at/over the cap (e.g. an !og_reload lowered Max-HP below the
// live value) we no-op rather than snapping it down — so regen/lifesteal preserve an over-cap surplus.
public static void AddHealthCapped(CCSPlayerPawn pawn, int amount, int cap)
{
if (amount <= 0 || pawn.Health <= 0) return;
int next = Math.Min(cap, pawn.Health + amount);
if (next <= pawn.Health) return;
SetHealth(pawn, next);
}
public static void AddArmorCapped(CCSPlayerPawn pawn, int amount, int cap)
{
if (amount <= 0 || pawn.Health <= 0) return;
int next = Math.Min(cap, pawn.ArmorValue + amount);
if (next <= pawn.ArmorValue) return;
SetArmor(pawn, next);
}
}

View file

@ -0,0 +1,18 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
namespace Outnumbered.Engine;
// Game-rules access. mp_roundtime caps at 60 min so the round timer would expire and hang; the map ends only on the kill
// goal, so we override the round duration directly on the rules each round.
internal static class Rules
{
public static CCSGameRules? Current =>
Utilities.FindAllEntitiesByDesignerName<CCSGameRulesProxy>(EngineNames.GameRulesDesigner).FirstOrDefault()?.GameRules;
public static void MakeRoundEndless()
{
var gr = Current;
gr?.RoundTime = 999999; // ~277h
}
}

View file

@ -0,0 +1,30 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.UserMessages;
namespace Outnumbered.Engine;
// CS2 screen fade via the Fade user message. The exact message format is UNVERIFIED (hard to debug blind), so the send is
// wrapped: a wrong field name no-ops via onError instead of crashing. The blend/decision logic stays plugin-side.
internal static class ScreenFade
{
public static int Pack(int r, int g, int b, int a) => r | (g << 8) | (b << 16) | (a << 24);
public static void Send(CCSPlayerController p, int r, int g, int b, int a, bool clear, Action<Exception> onError)
{
try
{
// CUserMessageFade [106]: duration, hold_time, flags, color — all INT; color is one packed value (not nested clr).
var msg = UserMessage.FromPartialName("Fade");
msg.SetInt("duration", (int)(0.3f * 512)); // Q7.9 fixed point (seconds * 512); int, not float
msg.SetInt("hold_time", 0); // FFADE_STAYOUT holds it until the next fade
// hold a tint: FFADE_OUT|FFADE_STAYOUT. clear it: FFADE_IN|FFADE_PURGE (fades the held colour out + drops stayout).
msg.SetInt("flags", clear ? (0x1 | 0x10) : (0x2 | 0x8));
msg.SetInt("color", Pack(r, g, b, a)); // packed color32, R in the low byte
msg.Send(p);
}
catch (Exception ex)
{
onError(ex);
}
}
}

View file

@ -0,0 +1,106 @@
using System.Net.Sockets;
using System.Text;
namespace Outnumbered.Engine;
// Minimal request/response server on a Unix domain socket: one verb line in, one payload out, connection closes.
// Runs entirely on background threads — the handler must NEVER touch game state (Api.cs serves pre-serialized bytes,
// and the one async verb is DB-only). Lifecycle: the ctor binds (delete-before-bind clears a crash leftover; bind
// failures throw to the caller, who degrades gracefully), Dispose cancels the accept loop, closes the listener and
// unlinks the socket file — a hot-reload (Unload then Load in-process) can then re-bind the same path.
internal sealed class UdsServer : IDisposable
{
private const int MaxRequestBytes = 256; // a verb line; anything bigger is not our client
private static readonly TimeSpan IoDeadline = TimeSpan.FromSeconds(2);
private readonly Socket _listener;
private readonly string _path;
private readonly Func<string, Task<byte[]>> _handle;
private readonly Action<Exception> _onError;
private readonly CancellationTokenSource _cts = new();
internal UdsServer(string path, Func<string, Task<byte[]>> handle, Action<Exception> onError)
{
_path = path;
_handle = handle;
_onError = onError;
File.Delete(path); // a crash leaves the old socket file behind, and bind fails on an existing path
_listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
_listener.Bind(new UnixDomainSocketEndPoint(path));
_listener.Listen(16);
// 0660: owner (the game user) + group (shared with the site user) — never world-accessible. Unix-only API,
// guarded so a Windows dev build compiles clean (there the socket just keeps default ACLs).
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.GroupWrite);
_ = Task.Run(AcceptLoop);
}
private async Task AcceptLoop()
{
while (!_cts.IsCancellationRequested)
{
Socket conn;
try { conn = await _listener.AcceptAsync(_cts.Token); }
catch (OperationCanceledException) { return; } // Dispose
catch (ObjectDisposedException) { return; } // Dispose raced the accept
catch (Exception ex)
{
// A persistent accept fault (fd exhaustion) would otherwise re-throw instantly forever — one retry per
// second turns a pegged core on the game host into a single syscall/s until the fault clears.
_onError(ex);
try { await Task.Delay(TimeSpan.FromSeconds(1), _cts.Token); }
catch (OperationCanceledException) { return; }
continue;
}
_ = Task.Run(() => Serve(conn));
}
}
// One connection = one verb line -> one payload. Deadlined so a stuck client can never pin resources; errors are
// reported (throttled by the caller) and the connection just drops — a game server must never care.
private async Task Serve(Socket conn)
{
using (conn)
{
try
{
// Inside the try: _cts.Token throws ObjectDisposedException when Dispose() wins the race against a
// queued Serve task (hot-reload while a client connects) — treated like the AcceptLoop's same race.
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
deadline.CancelAfter(IoDeadline);
var buf = new byte[MaxRequestBytes];
int len = 0;
while (len < MaxRequestBytes)
{
int n = await conn.ReceiveAsync(buf.AsMemory(len, MaxRequestBytes - len), SocketFlags.None, deadline.Token);
if (n == 0) break; // peer closed without a newline
len += n;
if (Array.IndexOf(buf, (byte)'\n', 0, len) >= 0) break;
}
int nl = Array.IndexOf(buf, (byte)'\n', 0, len);
if (nl < 0) return; // no complete verb line within the cap — not our client, drop silently
byte[] payload = await _handle(Encoding.ASCII.GetString(buf, 0, nl).Trim());
int sent = 0;
while (sent < payload.Length)
{
int n = await conn.SendAsync(payload.AsMemory(sent), SocketFlags.None, deadline.Token);
if (n <= 0) break;
sent += n;
}
}
catch (OperationCanceledException) { /* deadline hit or server shutting down — drop the connection */ }
catch (ObjectDisposedException) { /* Dispose raced a queued connection — drop it */ }
catch (Exception ex) { _onError(ex); }
}
}
public void Dispose()
{
_cts.Cancel();
try { _listener.Dispose(); } catch (Exception) { /* already closed */ }
try { File.Delete(_path); } catch (Exception) { /* unlink is best-effort; delete-before-bind covers leftovers */ }
_cts.Dispose();
}
}

View file

@ -0,0 +1,81 @@
using System.Drawing;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
namespace Outnumbered.Engine;
// The one place a point_worldtext entity is created + configured (HUD panel, the 2 shop panels, the draft cards all
// share it). CreateEntityByName returns a NON-null wrapper even on failure (e.g. the entity limit) with a Zero Handle,
// so the guard checks the raw handle (no memory deref) before touching schema members. Callers supply the per-use
// font/justify/colour/border and own the last-text caching; the volatile "SetMessage" input + the Teleport placement
// go through SetText/Place so every engine touchpoint for this entity lives here.
internal static class WorldText
{
public static CPointWorldText? Create(float fontSize, float worldUnitsPerPx, string fontName, Color color,
bool drawBackground, float border, PointWorldTextJustifyHorizontal_t justify)
{
var e = Utilities.CreateEntityByName<CPointWorldText>(EngineNames.PointWorldText);
if (e is null || e.Handle == nint.Zero) return null;
e.MessageText = " ";
e.Enabled = true;
e.Fullbright = true;
e.FontSize = fontSize;
e.WorldUnitsPerPx = worldUnitsPerPx;
if (!string.IsNullOrEmpty(fontName)) e.FontName = fontName;
e.Color = color;
e.JustifyHorizontal = justify;
e.JustifyVertical = PointWorldTextJustifyVertical_t.POINT_WORLD_TEXT_JUSTIFY_VERTICAL_CENTER;
e.ReorientMode = PointWorldTextReorientMode_t.POINT_WORLD_TEXT_REORIENT_NONE;
e.DrawBackground = drawBackground;
e.BackgroundBorderHeight = border;
e.BackgroundBorderWidth = border;
e.DispatchSpawn();
return e;
}
// Push new text via the point_worldtext "SetMessage" input (the volatile input-name literal lives here, not at the 4
// call sites). Callers keep their own last-text cache and only call this on a change.
public static void SetText(CPointWorldText ent, string text) => ent.AcceptInput("SetMessage", ent, ent, text);
// Position + orient a panel. point_worldtext is moved via Teleport (no velocity); callers compute the eye-relative frame.
public static void Place(CPointWorldText ent, Vector pos, QAngle ang) => ent.Teleport(pos, ang, null);
// Tear a panel down: Remove the entity if it's still live, then null the caller's reference. Symmetric with Create —
// every point_worldtext create + destroy goes through this file.
public static void Destroy(ref CPointWorldText? ent)
{
if (ent is { IsValid: true }) ent.Remove();
ent = null;
}
public const float EyeZFallback = 64f; // ViewOffset.Z fallback when the pawn doesn't report one
// The eye-relative frame for placing a panel in front of a pawn's view: eye position + forward/right/up basis + the
// panel orientation (yaw+270 / 90-pitch = worldtext faces the player, upright across pitch/yaw). false if AbsOrigin is
// null. Callers add only their own per-panel offsets. Shared by the HUD + the shop/draft panels.
public static bool TryEyeFrame(CCSPlayerPawn pawn, out Vector eye, out Vector fwd, out Vector right, out Vector up, out QAngle ang)
{
eye = null!; fwd = null!; right = null!; up = null!; ang = null!;
var origin = pawn.AbsOrigin;
if (origin is null) return false;
var ea = pawn.EyeAngles;
float eyeZ = pawn.ViewOffset?.Z ?? EyeZFallback;
(fwd, right, up) = AngleVectors(ea);
eye = new Vector(origin.X, origin.Y, origin.Z + eyeZ);
ang = new QAngle(0f, ea.Y + 270f, 90f - ea.X);
return true;
}
// Source-engine AngleVectors with roll assumed 0 (HUD/shop panels never roll).
private static (Vector forward, Vector right, Vector up) AngleVectors(QAngle a)
{
const double d2r = Math.PI / 180.0;
double p = a.X * d2r, y = a.Y * d2r;
double sp = Math.Sin(p), cp = Math.Cos(p), sy = Math.Sin(y), cy = Math.Cos(y);
return (
new Vector((float)(cp * cy), (float)(cp * sy), (float)(-sp)),
new Vector((float)sy, (float)(-cy), 0f),
new Vector((float)(sp * cy), (float)(sp * sy), (float)cp));
}
}

76
Outnumbered/Feel.cs Normal file
View file

@ -0,0 +1,76 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.Logging;
using Outnumbered.Data;
using Outnumbered.Engine;
namespace Outnumbered;
// "Ability is active" feedback. Two layers, sharing one blended colour:
// 1. HUD recolour (RELIABLE): while any ability is active the whole HUD tints to the blended perk colour.
// 2. Screen tint (EXPERIMENTAL): a faint full-screen "movie filter" via the CS2 fade user message — the exact
// message format is unverified and hard to debug blind, so it's toggleable (Abilities.ScreenTint) and the
// HUD recolour is the guaranteed fallback.
// Per-perk colours come from AbilityDef.Tint{R,G,B}; when several are up the colours are averaged.
public sealed partial class OutnumberedPlugin
{
private readonly Dictionary<int, int> _tintState = new(); // slot -> last packed RGBA applied via fade (0 = none)
private int _feelTick;
private bool _fadeWarned;
// Per-player sound cue via the client `play` command (built-in CS2 sounds; empty = none).
private void PlaySound(CCSPlayerController? p, string path)
{
if (!Config.Sounds.Enabled || string.IsNullOrEmpty(path) || p is not { IsValid: true } || p.IsBot) return;
p.ExecuteClientCommand($"play {path}");
}
private void Initialize_Feel() { /* nothing to initialize — OnTick runs via OnTick_All (shared roster walk); teardown is in Shutdown_Feel */ }
private void Shutdown_Feel()
{
foreach (var p in Utilities.GetPlayers()) // don't leave a stuck tint on reload
if (p is { IsValid: true } && !p.IsBot) SendFade(p, 0, 0, 0, 0, clear: true);
_tintState.Clear();
}
// Averaged colour of the player's currently-active abilities.
private (bool any, int r, int g, int b) BlendActiveTint(PlayerData pd)
{
if (!Config.Abilities.Enabled) return (false, 0, 0, 0);
long r = 0, g = 0, b = 0; int n = 0;
for (int i = 0; i < AbilityCount; i++)
if (AbilityActive(pd, i)) { var d = AbilityCfg(i); r += d.TintR; g += d.TintG; b += d.TintB; n++; }
return n == 0 ? (false, 0, 0, 0) : (true, (int)(r / n), (int)(g / n), (int)(b / n));
}
// ---- screen tint (fade) ----
private void OnTick_Feel(List<CCSPlayerController> players)
{
if (!Config.Abilities.Enabled || !Config.Abilities.ScreenTint) return;
if (++_feelTick % 8 != 0) return; // ~8 Hz; only sends on change anyway
foreach (var p in players)
{
if (!IsHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick path)
var pd = PdOf(p);
if (pd is null) continue;
var (any, r, g, b) = BlendActiveTint(pd);
int a = any ? Math.Clamp(Config.Abilities.TintAlpha, 0, 255) : 0;
int packed = any ? ScreenFade.Pack(r, g, b, a) : 0;
if (!_tintState.TryGetValue(p.Slot, out var last)) last = 0;
if (last == packed) continue;
_tintState[p.Slot] = packed;
if (any) SendFade(p, r, g, b, a, clear: false);
// clear by fading the PREVIOUS colour back out (FFADE_IN needs a colour to fade from) + purge the stayout
else SendFade(p, last & 0xff, (last >> 8) & 0xff, (last >> 16) & 0xff, (last >> 24) & 0xff, clear: true);
}
}
// CS2 screen fade via user message (the message format + send live in Engine.ScreenFade); a wrong field name no-ops
// via onError instead of crashing. The blend/decision logic stays here in OnTick_Feel.
private void SendFade(CCSPlayerController p, int r, int g, int b, int a, bool clear) =>
ScreenFade.Send(p, r, g, b, a, clear,
ex => { if (!_fadeWarned) { _fadeWarned = true; Logger.LogWarning(ex, "Outnumbered fade send failed"); } });
}

292
Outnumbered/GunGame.cs Normal file
View file

@ -0,0 +1,292 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
using Outnumbered.Config;
using Outnumbered.Data;
using Outnumbered.Engine;
namespace Outnumbered;
// Gun Game: climb a weapon ladder by kills; the FULL RPG kit (stats, abilities, handicap, XP) rides along.
// BOTS climb the same ladder, so players can actually lose (a bot topping the ladder ends the map). A headshot
// kill demotes the victim by one kill of ladder progress (whoever it is) — the arms-race knife-steal, generalised.
// Your gun is dictated by your rung, so weapon selection is off and the shop is skills-only. Top rung = the knife
// finale. The handicap keeps a steamrolling player honest (a dominant climber deals ~0.1x / takes ~8x).
// (IMatchDriver/IDraftDriver/CardView + TdmDriver live in Modes.cs.)
public sealed class GunGameDriver(OutnumberedPlugin p) : IMatchDriver
{
private readonly OutnumberedPlugin _p = p;
public string Id => "gungame";
public IReadOnlyList<string> Maps =>
_p.Config.GunGame.Maps.Count > 0 ? _p.Config.GunGame.Maps : _p.Config.Match.Maps;
public bool WeaponShopEnabled => false;
public HandicapOverride? Handicap => _p.Config.GunGame.Handicap; // GunGame.Handicap overrides the base, if set
public int MaxHumansOnCt => _p.Config.GunGame.MaxHumansOnCt;
public string ExtraCvars => "mp_randomspawn 1;"; // scatter spawns on the small arms-race maps -> the horde clumps less
// Per-bot ladder progress (humans carry theirs on PlayerData.GgRung/GgRungKills). Keyed by slot, but CS2
// reuses slots when a bot is kicked (bot_quota drops as humans leave) and a new one is added — so we also
// stamp the occupant's UserId and reset the slot's progress when a different bot takes it (EnsureBot). Without
// that, a fresh bot could inherit a near-top rung and win spuriously. All cleared on match reset.
private readonly Dictionary<int, int> _botRung = []; // per-bot mode (BotSharedLadder=false): slot -> rung
private readonly Dictionary<int, int> _botRungKills = [];
private readonly Dictionary<int, int> _botUserId = [];
private readonly Dictionary<int, int> _batchRung = []; // shared mode (default): batchId -> rung
private readonly Dictionary<int, int> _batchRungKills = [];
private readonly Dictionary<int, (int batch, int uid)> _botBatch = []; // slot -> STABLE batch assignment (+ occupant UserId)
public void OnMatchReset() { _botRung.Clear(); _botRungKills.Clear(); _botUserId.Clear(); _batchRung.Clear(); _batchRungKills.Clear(); _botBatch.Clear(); _ladder = null; }
private bool Pooled => _p.Config.GunGame.BotSharedLadder;
// A bot's batch, assigned ONCE (to the smallest current batch) and cached by slot+UserId, so a bot KEEPS its
// batch for the whole match across respawns/roster churn — a slot-rank formula would jitter as bots respawn/leave,
// hopping batches and showing mismatched weapons. Batches stay ≈BotsPerHuman-sized; solo (4 bots) = one stable
// batch. Cleared on map start.
private int BotBatch(CCSPlayerController bot)
{
int slot = bot.Slot, uid = bot.UserId ?? -1;
if (_botBatch.TryGetValue(slot, out var e) && e.uid == uid) return e.batch; // same bot -> same batch, always
int per = Math.Max(1, _p.Config.Match.BotsPerHuman);
int botCount = Utilities.GetPlayers().Count(OutnumberedPlugin.IsBot);
int batches = Math.Max(1, (botCount + per - 1) / per); // ≈ one batch per player's worth of bots
var counts = new int[batches];
foreach (var v in _botBatch.Values) if (v.batch < batches) counts[v.batch]++;
int pick = 0; // assign the new bot to the smallest batch
for (int i = 1; i < batches; i++) if (counts[i] < counts[pick]) pick = i;
_botBatch[slot] = (pick, uid);
return pick;
}
// Re-equip EVERY alive bot IN A BATCH to that batch's current rung, so the batch is ALWAYS in sync (same weapon).
// Called on any batch rung change. Without it only the killer upgrades and its batch-mates lag a rung behind until
// respawn — so a lagging bot could land the winning/visible kill on the wrong gun.
private void RegiveBatch(int batch) => Server.NextFrame(() =>
{
foreach (var p in Utilities.GetPlayers())
if (OutnumberedPlugin.IsLiveBot(p) && BotBatch(p) == batch) _p.ApplyLoadout(p, true);
});
// Ladder position in 0..1 (0 = bottom rung, 1 = top), fed to the handicap so climbing nerfs the player harder
// and a headshot-demotion eases it. Counts rungs directly (no full BuildLadder) since this runs per damage/tick.
public double HandicapProgress(PlayerData pd)
{
int count = Ladder().Count; // cached; == the old non-whitespace-rungs + knife count for every meaningful ladder
if (count <= 1) return 0.0;
return Math.Clamp(pd.GgRung / (double)(count - 1), 0.0, 1.0);
}
// The shop info-panel ladder line: current rung / total + kills toward advancing + the rung weapon. null = no ladder.
public string? LadderStatusLine(PlayerData pd)
{
var ladder = Ladder();
if (ladder.Count == 0) return null;
int r = Math.Clamp(pd.GgRung, 0, ladder.Count - 1);
return $"Rung {r + 1}/{ladder.Count} ({pd.GgRungKills}/{ladder[r].PlayerKills}): {OutnumberedPlugin.WeaponDisplayName(ladder[r].Weapon)}";
}
// Detect a new bot occupying a (possibly reused) slot and zero its ladder progress. UserId is stable across a
// bot's respawns but differs for a freshly-added bot, so same-bot respawns keep progress; slot reuse resets it.
private void EnsureBot(CCSPlayerController bot)
{
int slot = bot.Slot, uid = bot.UserId ?? -1;
if (_botUserId.GetValueOrDefault(slot, int.MinValue) != uid)
{
_botUserId[slot] = uid;
_botRung[slot] = 0;
_botRungKills[slot] = 0;
}
}
// A ladder rung: the weapon you hold + how many kills clear it (advance / win), separately for players and bots.
public readonly record struct Rung(string Weapon, int PlayerKills, int BotKills);
// Cached ladder — BuildLadder allocates a List + per-rung records, and it's read per kill/headshot/loadout/HUD call.
// Rebuild only when the GunGame config INSTANCE changes (!og_reload swaps the whole Config, flipping the ref)
// or on match reset. CONTRACT: an in-place edit to GunGame.Ladder would NOT invalidate this — only a Config swap does.
// Callers read the list read-only, so sharing the cached instance is safe.
private List<Rung>? _ladder;
private GunGameConfig? _ladderCfg;
private List<Rung> Ladder()
{
var gg = _p.Config.GunGame;
if (_ladder is null || !ReferenceEquals(_ladderCfg, gg)) { _ladder = BuildLadder(); _ladderCfg = gg; }
return _ladder;
}
// Built (and cached by Ladder()) so live config edits (!og_reload) take effect. The weapon ORDER is shared; the kills
// to clear a rung come from the weapon's TIER via the two inverse curves (players grind weak / breeze strong; bots
// the reverse). The knife finale (if enabled) wins on 1 kill for both sides.
public List<Rung> BuildLadder()
{
var gg = _p.Config.GunGame;
var rungs = new List<Rung>(gg.Ladder.Count + 1);
foreach (var e in gg.Ladder)
{
if (string.IsNullOrWhiteSpace(e.Weapon)) continue;
rungs.Add(new Rung(e.Weapon, KillsForTier(gg.PlayerKillsByTier, e.Tier), KillsForTier(gg.BotKillsByTier, e.Tier)));
}
if (gg.KnifeFinale) rungs.Add(new Rung(EngineNames.WeaponKnife, 1, 1));
if (rungs.Count == 0) rungs.Add(new Rung(EngineNames.WeaponKnife, 1, 1)); // safety: always a reachable win
return rungs;
}
// Status API extras: the ladder as display names, so the site maps each player's Rung -> current weapon without a
// per-player driver call (players carry their rung in the shared payload). Rebuilt per status refresh (~2s) — cold.
public object? StatusExtra()
{
var ladder = Ladder();
var names = new string[ladder.Count];
for (int i = 0; i < ladder.Count; i++) names[i] = OutnumberedPlugin.WeaponDisplayName(ladder[i].Weapon);
return new { Ladder = names };
}
// Kills for a tier (1-based) from a curve list; clamps to the curve's bounds and floors at 1.
private static int KillsForTier(List<int> curve, int tier)
{
if (curve is null || curve.Count == 0) return 1;
return Math.Max(1, curve[Math.Clamp(tier - 1, 0, curve.Count - 1)]);
}
public void GiveHumanLoadout(CCSPlayerController p)
{
var ladder = Ladder();
if (ladder.Count == 0) return;
int rung = Math.Clamp(_p.PdOf(p)?.GgRung ?? 0, 0, ladder.Count - 1);
GiveRungWeapon(p, ladder[rung].Weapon);
_p.GiveShopCarriers(p); // healthshot + zeus (the zeus rest anchor also fixes the knife-rung grenade-auto-deploy bug)
}
public void GiveBotLoadout(CCSPlayerController bot)
{
var ladder = Ladder();
if (ladder.Count == 0) return;
int rung;
if (Pooled) rung = Math.Clamp(_batchRung.GetValueOrDefault(BotBatch(bot)), 0, ladder.Count - 1);
else { EnsureBot(bot); rung = Math.Clamp(_botRung.GetValueOrDefault(bot.Slot), 0, ladder.Count - 1); }
GiveRungWeapon(bot, ladder[rung].Weapon);
}
// The knife rung adds nothing — the core always gives the knife, so the finale is a pure knife fight.
private static void GiveRungWeapon(CCSPlayerController p, string weapon)
{
if (!OutnumberedPlugin.WeapEq(weapon, EngineNames.WeaponKnife)) p.GiveNamedItem(weapon);
}
public void OnHumanKill(CCSPlayerController attacker, PlayerData apd)
{
var ladder = Ladder();
if (ladder.Count == 0) return;
int rung = Math.Clamp(apd.GgRung, 0, ladder.Count - 1);
if (++apd.GgRungKills < ladder[rung].PlayerKills) return; // still climbing this rung
apd.GgRungKills = 0;
apd.GgRung = rung + 1;
if (apd.GgRung >= ladder.Count) // cleared the final (knife) rung -> win
{
apd.GgRung = ladder.Count - 1;
// Kills keep processing through the map-end grace window (changelevel is delayed a few seconds). A ladder
// topped in that window — after a bot win or an earlier human winner — is NOT a match result: no record.
if (_p.MapEnding) return;
long? runMs = _p.RecordGgWin(apd); // improve-only leaderboard write; null if the clock never armed
_p.TriggerMapEnd(runMs is { } t
? $"{attacker.PlayerName} climbed the whole ladder in {OutnumberedPlugin.FormatRunTime(t)} — GUN GAME!"
: $"{attacker.PlayerName} climbed the whole ladder — GUN GAME!");
return;
}
RegiveLive(attacker.Slot, isBot: false); // hand over the next rung's gun live
var next = ladder[apd.GgRung];
attacker.PrintToChat($" {ChatColors.Gold}[Gun Game] {ChatColors.Default}Rung {apd.GgRung + 1}/{ladder.Count}: " +
$"{ChatColors.Lime}{OutnumberedPlugin.WeaponDisplayName(next.Weapon)} {ChatColors.Default}({next.PlayerKills} to advance)");
}
public void OnBotKill(CCSPlayerController bot)
{
var ladder = Ladder();
if (ladder.Count == 0) return;
if (Pooled) // the bot's BATCH shares one rung; any member's kill advances it and the WHOLE batch re-equips
{
int batch = BotBatch(bot);
int rung = Math.Clamp(_batchRung.GetValueOrDefault(batch), 0, ladder.Count - 1);
int kills = _batchRungKills.GetValueOrDefault(batch) + 1;
if (kills < ladder[rung].BotKills) { _batchRungKills[batch] = kills; return; }
_batchRungKills[batch] = 0;
rung++;
if (rung >= ladder.Count) // a batch topped the ladder -> the humans lose
{
_batchRung[batch] = ladder.Count - 1;
_p.TriggerMapEnd("The bot horde climbed the whole ladder — bots win!");
return;
}
_batchRung[batch] = rung;
RegiveBatch(batch); // the whole batch jumps to the new rung weapon together (no lagging-bot desync)
return;
}
EnsureBot(bot); // per-bot mode (BotSharedLadder=false): each bot climbs alone
int slot = bot.Slot;
int r = Math.Clamp(_botRung.GetValueOrDefault(slot), 0, ladder.Count - 1);
int k = _botRungKills.GetValueOrDefault(slot) + 1;
if (k < ladder[r].BotKills) { _botRungKills[slot] = k; return; }
_botRungKills[slot] = 0;
r++;
if (r >= ladder.Count)
{
_botRung[slot] = ladder.Count - 1;
_p.TriggerMapEnd($"The bots won — {bot.PlayerName} topped the ladder!");
return;
}
_botRung[slot] = r;
RegiveLive(slot, isBot: true);
}
// Headshot death = lose one kill of ladder progress; underflow drops a rung (landing one kill from re-advancing).
public void OnHeadshotDeath(CCSPlayerController victim)
{
var ladder = Ladder();
if (ladder.Count == 0) return;
if (victim.IsBot)
{
if (Pooled) // a headshot on any bot knocks its WHOLE batch down a weapon (your suppression tool)
{
int batch = BotBatch(victim);
int cur = Math.Clamp(_batchRung.GetValueOrDefault(batch), 0, ladder.Count - 1);
var (br, bk) = Demote(cur, _batchRungKills.GetValueOrDefault(batch), ladder, bot: true);
bool changed = br != cur;
_batchRung[batch] = br; _batchRungKills[batch] = bk;
if (changed) RegiveBatch(batch); // every bot in the batch drops to the lower rung weapon together
return;
}
EnsureBot(victim);
int slot = victim.Slot;
var (nr, nk) = Demote(_botRung.GetValueOrDefault(slot), _botRungKills.GetValueOrDefault(slot), ladder, bot: true);
_botRung[slot] = nr; _botRungKills[slot] = nk; // respawn gives the demoted weapon (GiveBotLoadout reads it)
}
else
{
var pd = _p.PdOf(victim);
if (pd is null) return;
var (nr, nk) = Demote(pd.GgRung, pd.GgRungKills, ladder, bot: false);
bool droppedRung = nr != Math.Clamp(pd.GgRung, 0, ladder.Count - 1);
pd.GgRung = nr; pd.GgRungKills = nk; // victim is dead; respawn gives the demoted weapon
if (droppedRung)
victim.PrintToChat($" {ChatColors.Red}[Gun Game] Headshot! {ChatColors.Default}Dropped to " +
$"{ChatColors.LightYellow}{OutnumberedPlugin.WeaponDisplayName(ladder[nr].Weapon)}");
}
}
// Lose one kill of progress; underflow drops a rung, landing one kill short of re-advancing (per-side kills).
private static (int rung, int kills) Demote(int rung, int kills, List<Rung> ladder, bool bot)
{
rung = Math.Clamp(rung, 0, ladder.Count - 1);
if (kills > 0) return (rung, kills - 1);
if (rung > 0) { int need = bot ? ladder[rung - 1].BotKills : ladder[rung - 1].PlayerKills; return (rung - 1, Math.Max(0, need - 1)); }
return (0, 0); // already rock bottom
}
private void RegiveLive(int slot, bool isBot) =>
OutnumberedPlugin.NextFrameForSlot(slot, pl => { if (pl.IsBot == isBot) _p.ApplyLoadout(pl, isBot); }, requireAlive: true);
}

51
Outnumbered/Handicap.cs Normal file
View file

@ -0,0 +1,51 @@
using System.Reflection;
using Microsoft.Extensions.Logging;
using Outnumbered.Config;
using Outnumbered.Data;
using Outnumbered.Domain;
namespace Outnumbered;
// The per-player handicap (the balance spine; the HUD shows each player their live deal/take/XP bands). The MATH lives in Outnumbered.Domain.HandicapModel — a single
// signed index t in [-1,+1] driving deal/take/XP together so they reach their extremes at the SAME thresholds. These are
// zero-math accessors that snapshot the player and call it. The hot damage hook reaches HandicapModel through
// CombatResolver (one snapshot); the HUD + progression use these adapters.
public sealed partial class OutnumberedPlugin
{
// The effective handicap for the active mode = base Config.Handicap with the mode's overrides applied, wrapped in a
// ResolvedHandicap (config + precomputed guard denominators). Rebuilt at driver-select (Initialize_Driver) and on
// !og_reload, so it stays live-tunable AND ComputeT never recomputes the config-invariant terms per hit/tick.
private ResolvedHandicap _hcap = new(new HandicapConfig());
internal void RebuildEffectiveHandicap() =>
_hcap = new ResolvedHandicap(_driver?.Handicap is { } o ? o.ApplyTo(Config.Handicap) : Config.Handicap);
// The XP-rate bridge (used by GrantXp + the HUD). The deal/take chains go straight through CombatResolver ->
// HandicapModel.MDeal/MTake on a snapshot, so there are no MDeal(pd)/MTake(pd) bridge methods here.
private double HandicapXpMult(PlayerData pd) => HandicapModel.XpMult(Snapshot(pd), _hcap);
// One-time structural guard that HandicapOverride stays a faithful mirror of HandicapConfig: every base field needs a
// matching nullable override AND ApplyTo must actually wire it. Catches the documented footgun — add a field to both
// POCOs but forget the `X ?? b.X` line in ApplyTo, and that field's per-mode override is silently ignored (a tuner sees
// "the GunGame/Survival override doesn't work"). Probes ApplyTo functionally via reflection. Structural, not value-
// dependent, so it runs once at load (not per !og_reload). All current HandicapConfig fields are bool/int/double.
private void Initialize_Handicap()
{
var baseCfg = new HandicapConfig();
foreach (var bp in typeof(HandicapConfig).GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (!bp.CanRead || !bp.CanWrite) continue;
var op = typeof(HandicapOverride).GetProperty(bp.Name);
if (op is null)
{
Logger.LogError("Outnumbered: HandicapOverride is missing field '{Field}' that HandicapConfig defines (add a nullable mirror + an ApplyTo line).", bp.Name);
continue;
}
object? sentinel = bp.GetValue(baseCfg) switch { bool b => !b, int i => i + 1, double d => d + 1.0, _ => null };
if (sentinel is null) continue; // non-bool/int/double field: can't auto-probe ApplyTo wiring here
var ov = new HandicapOverride();
op.SetValue(ov, sentinel);
if (!Equals(bp.GetValue(ov.ApplyTo(baseCfg)), sentinel))
Logger.LogError("Outnumbered: HandicapOverride.ApplyTo does not propagate '{Field}' — its per-mode override is silently ignored (add the matching `?? b.` line).", bp.Name);
}
}
}

268
Outnumbered/Hud.cs Normal file
View file

@ -0,0 +1,268 @@
using System.Text;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Outnumbered.Data;
using Outnumbered.Domain;
using Outnumbered.Engine;
namespace Outnumbered;
// The always-on HUD: handicap multipliers (Dmg Out/In, XP), level/prestige/streak, and the 5 ability states.
//
// Mode "world": a per-player CPointWorldText entity placed in front of the camera (positionable, font-sized).
// It's a real world entity, so CheckTransmit must hide each player's HUD from everyone else. Single-color text.
// Mode "center": a PrintToCenterHtml panel (multi-color, but fixed center + fixed width).
public sealed partial class OutnumberedPlugin
{
private readonly HashSet<ulong> _hudOff = []; // per-player !hud opt-out
private readonly Dictionary<int, CPointWorldText> _hudEntities = []; // slot -> world-text entity (world mode)
private readonly Dictionary<int, string> _hudText = []; // slot -> last text pushed (skip redundant SetMessage)
private readonly HashSet<int> _centerShown = []; // center mode: slots currently showing the panel
private int _hudTick;
private readonly StringBuilder _hudSb = new(); // reused across HUD builds (game-thread, sequential)
private readonly List<int> _hudReap = []; // reused stale-slot scratch (world mode), avoids a per-tick Keys.ToList()
private Action<int>? _hudReapAction; // cached DestroyHud delegate (so ReapOrphanSlots adds no per-tick closure)
private static readonly (int v, string s)[] RomanMap =
[ (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"),
(50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I") ];
// Prestige rendered as a Roman numeral (0 = no prestige).
public static string Roman(int n)
{
if (n <= 0) return "0";
var sb = new StringBuilder();
foreach (var (v, s) in RomanMap) while (n >= v) { sb.Append(s); n -= v; }
return sb.ToString();
}
private void Initialize_Hud()
{
// OnTick is driven by OnTick_All (shared roster walk); OnTick_Hud is invoked from there.
RegisterListener<Listeners.CheckTransmit>(OnCheckTransmit_Hud);
}
private void Shutdown_Hud()
{
RemoveListener<Listeners.CheckTransmit>(OnCheckTransmit_Hud);
foreach (var slot in _hudEntities.Keys.ToList()) DestroyHud(slot);
}
// ---- per-tick ----
private void OnTick_Hud(List<CCSPlayerController> players)
{
if (!Config.Hud.Enabled) { return; }
int every = Math.Max(1, Config.Hud.RefreshEveryTicks);
if (++_hudTick % every != 0) return;
bool world = !string.Equals(Config.Hud.Mode, "center", StringComparison.OrdinalIgnoreCase);
// Reap entities for players who disconnected (world mode) — shared collect-then-act helper (DestroyHud mutates
// _hudEntities), reused scratch + cached delegate so there's no per-tick alloc.
if (world && _hudEntities.Count > 0)
ReapOrphanSlots(_hudEntities, _hudReap, _hudReapAction ??= DestroyHud);
foreach (var p in players)
{
if (!IsHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick path)
var sid = p.AuthorizedSteamID?.SteamId64; // drives the per-player !hud opt-out (_hudOff)
bool show = sid is not null && !_hudOff.Contains(sid.Value) && p.PawnIsAlive
&& _players.TryGetValue(sid.Value, out _);
if (!world)
{
if (show && _players.TryGetValue(sid!.Value, out var cpd))
{
p.PrintToCenterHtml(BuildHudHtml(p, cpd));
_centerShown.Add(p.Slot);
}
else if (_centerShown.Remove(p.Slot)) // was showing, now hidden -> clear the stale panel once (it doesn't self-clear)
{
p.PrintToCenterHtml("");
}
continue;
}
if (!show) { DestroyHud(p.Slot); continue; }
_players.TryGetValue(sid!.Value, out var pd);
var ent = EnsureHud(p);
if (ent is null || pd is null) continue;
UpdateWorldHud(p, ent, pd);
}
}
// each player sees only their own HUD entity
private void OnCheckTransmit_Hud(CCheckTransmitInfoList infoList) =>
HideForeignSlotEntities(infoList, _hudEntities,
static (info, ent) => { if (ent is { IsValid: true }) info.TransmitEntities.Remove(ent); });
// ---- world-text entity ----
private CPointWorldText? EnsureHud(CCSPlayerController p)
{
if (_hudEntities.TryGetValue(p.Slot, out var ent) && ent is { IsValid: true }) return ent;
_hudEntities.Remove(p.Slot);
_hudText.Remove(p.Slot);
var created = CreateHud();
if (created is not null) _hudEntities[p.Slot] = created;
return created;
}
private CPointWorldText? CreateHud()
{
var h = Config.Hud;
return WorldText.Create(h.FontSize, h.WorldUnitsPerPx, h.FontName,
System.Drawing.Color.FromArgb(255, h.ColorR, h.ColorG, h.ColorB), h.DrawBackground, 0.1f,
PointWorldTextJustifyHorizontal_t.POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_CENTER);
}
private void DestroyHud(int slot)
{
if (_hudEntities.Remove(slot, out CPointWorldText? ent)) WorldText.Destroy(ref ent);
_hudText.Remove(slot);
}
private void UpdateWorldHud(CCSPlayerController p, CPointWorldText ent, PlayerData pd)
{
var pawn = p.PlayerPawn.Value;
if (pawn is null || !WorldText.TryEyeFrame(pawn, out var eye, out var fwd, out var right, out var up, out var ang)) return;
var h = Config.Hud;
var pos = eye + fwd * h.ForwardOffset + right * h.RightOffset + up * h.UpOffset;
string text = BuildHudText(p, pd);
if (!_hudText.TryGetValue(p.Slot, out var prev) || prev != text)
{
WorldText.SetText(ent, text);
_hudText[p.Slot] = text;
}
WorldText.Place(ent, pos, ang);
}
// ---- effective multipliers shown on the HUD / shop / !stats ----
// The SINGLE source for the four readouts: build ONE snapshot + run ONE ComputeT (via HandicapModel.Bands) and derive
// HS / Out / In / XP from it. Out/In come from the SAME shared CombatResolver chains the damage hook applies
// (headshot:false, crit:false = the base readout), so a readout can never drift from real damage. Used by the HUD
// (per tick) and the cold shop/!stats paths.
private (double hs, double md, double mt, double xp) EffectiveMultipliers(CCSPlayerController p, PlayerData pd)
{
var s = Snapshot(pd, p);
HandicapModel.Bands(s, _hcap, out double deal, out double take, out double xpBand);
double md = CombatResolver.OffenseMultiplier(s, headshot: false, crit: false, _statDefs, Config.Abilities, BaseMaxHp, deal);
double mt = CombatResolver.DefenseMultiplier(s, headshot: false, Config.Abilities, take);
double hs = 1.0 + StatResolver.EffRun(s, StatKeys.HeadshotDamage, _statDefs) / 100.0;
double xp = (1.0 + StatResolver.Eff(s, StatKeys.XpBoost, _statDefs) / 100.0)
* ProgressionModel.PrestigeXpMultiplier(pd.Prestige, Config.Progression) * xpBand;
return (hs, md, mt, xp);
}
// ---- content ----
private string BuildHudText(CCSPlayerController p, PlayerData pd)
{
var (hs, md, mt, xp) = EffectiveMultipliers(p, pd);
long next = XpToNext(pd.Level);
double now = Server.CurrentTime; // read once for all ability segments (clock can't advance within this build)
var sb = _hudSb; sb.Clear();
if (_driver.HudStatusLine(pd) is { Length: > 0 } wline) sb.Append(wline).Append('\n');
sb.Append($"Lvl {pd.Level} ({pd.Xp}/{next}) Prestige {Roman(pd.Prestige)} {pd.Points} pt Streak {pd.Streak}\n");
sb.Append($"HS x{hs:F2} Out x{md:F2} In x{mt:F2} XP x{xp:F2}\n");
for (int i = 0; i < AbilityCount; i++)
{
if (i > 0) sb.Append(" ");
sb.Append(AbilityTextSegment(pd, i, now));
}
return sb.ToString();
}
private string AbilityTextSegment(PlayerData pd, int i, double now)
{
string suffix =
AbilityActive(pd, i, now) ? $" ON{pd.AbilityActiveUntil[i] - now:F0}" :
!AbilityReady(pd, i, now) ? $" {pd.AbilityReadyAt[i] - now:F0}s" :
AbilityUnlocked(pd, i) ? "" :
$" L{AbilityCfg(i).StreakReq}";
return $"{AbilityRegistry[i].KeyNum}:{AbilityRegistry[i].Short}{suffix}";
}
// ---- center-HTML mode (crisp, multi-color, default) ----
private string AbilityIcon(int i)
{
var list = Config.Hud.AbilityIcons;
return i >= 0 && i < list.Count && !string.IsNullOrEmpty(list[i]) ? list[i] : AbilityRegistry[i].Short;
}
private string BuildHudHtml(CCSPlayerController p, PlayerData pd)
{
var (hs, md, mt, xp) = EffectiveMultipliers(p, pd);
long next = XpToNext(pd.Level);
double now = Server.CurrentTime; // read once for the ability segments + the prestige animation phase
string streakColor = pd.Streak > 0 ? "#ffd000" : "#bbbbbb";
string ptsColor = pd.Points > 0 ? "#66ff66" : "#888888";
var sb = _hudSb; sb.Clear();
if (_driver.HudStatusLine(pd) is { Length: > 0 } wline) sb.Append($"<font color='#ff5a5a'>{wline}</font><br>");
sb.Append($"<font color='#ffae00'>Lv{pd.Level}</font> {PrestigeHudTag(pd.Prestige, now)}");
sb.Append($" <font color='{ptsColor}'>{pd.Points} pt</font>");
sb.Append($" <font color='{streakColor}'>Streak {pd.Streak}</font>");
int xpPct = (int)(pd.Xp * 100 / Math.Max(1, next)); // progress through current level; 0-100
sb.Append($" <font color='#cccccc'>{xpPct}% xp</font><br>");
sb.Append($"<font color='#ffffff'>HS </font><font color='#ffcf6b'>{hs:F2}</font>");
sb.Append($" <font color='#ffffff'>Out </font><font color='#ff9a9a'>{md:F2}</font>");
sb.Append($" <font color='#ffffff'>In </font><font color='#ff9a9a'>{mt:F2}</font>");
sb.Append($" <font color='#ffffff'>XP </font><font color='#7dff7d'>{xp:F2}</font><br>");
for (int i = 0; i < AbilityCount; i++)
{
if (i > 0) sb.Append(" &nbsp; ");
sb.Append(AbilityHudSegment(pd, i, now));
}
return sb.ToString();
}
// Compact icon, colored by state: green ready, cyan active(+secs), orange cooldown(+secs), gray locked(+req).
private string AbilityHudSegment(PlayerData pd, int i, double now)
{
string color, suffix;
if (AbilityActive(pd, i, now)) { color = "#55ddff"; suffix = $"{pd.AbilityActiveUntil[i] - now:F0}"; }
else if (!AbilityReady(pd, i, now)) { color = "#ff8800"; suffix = $"{pd.AbilityReadyAt[i] - now:F0}"; }
else if (AbilityUnlocked(pd, i)) { color = "#66ff66"; suffix = ""; }
else { color = "#777777"; suffix = $"{AbilityCfg(i).StreakReq}"; }
return $"<font color='{color}'>{AbilityIcon(i)}{suffix}</font>";
}
// Prestige tag for the HUD: solid (I-V = perk colour) or an animated flowing gradient (VI-X).
private string PrestigeHudTag(int prestige, double now)
{
string text = $"P{Roman(prestige)}";
if (!_ranks.PrestigeColors || prestige <= 0) return $"<font color='#cc88ff'>{text}</font>";
var cols = PrestigeColorSet(prestige);
if (cols.Length == 1)
{
var (r, g, b) = cols[0];
return $"<font color='#{r:x2}{g:x2}{b:x2}'>{text}</font>";
}
double phase = now * 0.30; // flowing animation for VI-X
int letters = 0; foreach (char ch in text) if (ch != ' ') letters++;
var sb = new StringBuilder();
int li = 0;
foreach (char ch in text)
{
if (ch == ' ') { sb.Append(' '); continue; }
double t = (letters <= 1 ? 0.0 : (double)li / letters) + phase;
var (r, g, b) = SampleRing(cols, t);
sb.Append($"<font color='#{r:x2}{g:x2}{b:x2}'>{ch}</font>");
li++;
}
return sb.ToString();
}
// Sample a looping colour ring at position t (wraps), linear-interpolated between adjacent stops.
private static (int r, int g, int b) SampleRing((int r, int g, int b)[] c, double t)
{
int n = c.Length;
t = (t % 1.0 + 1.0) % 1.0;
double scaled = t * n;
double fl = Math.Floor(scaled);
int i0 = (int)fl % n, i1 = (i0 + 1) % n;
double f = scaled - fl;
var a = c[i0]; var b2 = c[i1];
return ((int)(a.r + (b2.r - a.r) * f), (int)(a.g + (b2.g - a.g) * f), (int)(a.b + (b2.b - a.b) * f));
}
}

159
Outnumbered/Modes.cs Normal file
View file

@ -0,0 +1,159 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Outnumbered.Config;
using Outnumbered.Data;
using Outnumbered.Domain;
namespace Outnumbered;
// The match-driver seam (the mode split). The whole RPG core — stats, progression, handicap, abilities, the
// shop UI, persistence — is mode-agnostic. Only the match RULESET differs per mode. ResolveMode (Driver.cs)
// picks the driver at Load. The shared match plumbing (cvars, bots, team enforcement, map setup, the endless
// round timer, per-kill bookkeeping, map rotation) stays on OutnumberedPlugin (Driver.cs); a driver supplies
// only the variant points below. Adding a mode = a new IMatchDriver, not a core rewrite.
public interface IMatchDriver
{
string Id { get; }
// Map pool for changelevel rotation. Empty -> the core falls back to Match.Maps.
IReadOnlyList<string> Maps { get; }
// Whether weapon selection (the shop's Weapons screen + !guns) is offered. Off in modes that dictate your gun.
bool WeaponShopEnabled { get; }
// Per-mode handicap override (null = use the base Handicap block unchanged). Resolved by RebuildEffectiveHandicap.
HandicapOverride? Handicap { get; }
// The mode's per-player "progress" axis in 0..1, fed into the handicap nerf (weighted by Handicap.ProgressWeight).
// 0 in TDM (no axis); in Gun Game it's ladder position so climbing nerfs you and a demotion eases it.
double HandicapProgress(PlayerData pd);
// Max humans allowed on CT for this mode (EnforceHumanTeam cap).
int MaxHumansOnCt { get; }
// Extra cvars appended to the shared ApplyCvars batch (mode-specific tweaks, e.g. GG's mp_randomspawn). "" = none.
string ExtraCvars { get; }
// Give a human's / a bot's per-spawn weapons. The shared core adds the knife + armor afterwards.
void GiveHumanLoadout(CCSPlayerController p);
void GiveBotLoadout(CCSPlayerController bot);
// Mode result, run AFTER the shared per-kill bookkeeping (kills/streak/headshots/ability-ding) and BEFORE kill XP.
void OnHumanKill(CCSPlayerController attacker, PlayerData apd);
// A bot got a kill (bots have no PlayerData). No-op in TDM; in Gun Game the bot climbs the ladder.
void OnBotKill(CCSPlayerController bot);
// The victim died to a headshot (attacker != victim). No-op in TDM; in Gun Game the victim loses a kill of progress.
void OnHeadshotDeath(CCSPlayerController victim);
// New match (map start) — clear any per-match driver state (e.g. bot ladder progress).
void OnMatchReset();
// ---- survival-mode seams (default no-ops; only SurvivalDriver overrides them) ----
// Called once right after the driver is selected (Initialize_Driver), during plugin Load — the engine isn't ready
// yet, so don't touch it here (Server.CurrentTime etc. will crash). Survival only SCHEDULES its heartbeat here.
void OnActivated() { }
// Called from SetupMap once the map is up and the server is simulating (normal start AND hot-reload) — safe to touch
// the engine. Survival arms its wave machine here. No-op for TDM/GG.
void OnMapSetup() { }
// Called from Shutdown_Driver (plugin Unload / hot-reload) — tear down any long-lived driver timers so they don't
// leak or double-fire against a torn-down instance. Survival kills its wave heartbeat here. No-op for TDM/GG.
void OnDeactivated() { }
// True if the driver owns bot population (wave spawning) — the core's quota-based SyncBots then steps aside to ManageBots.
bool OwnsBotPopulation => false;
// Drive bot population (called from SyncBots when OwnsBotPopulation): survival sets bot_quota from the wave kill-budget.
void ManageBots() { }
// A human died (victim). Survival: move to spectator + run wipe detection. No-op elsewhere (native DM respawn handles it).
void OnHumanDeath(CCSPlayerController victim, PlayerData pd) { }
// A human left mid-run. Survival: bank their accumulated run-XP into the main table before the pd is dropped.
void OnHumanDisconnect(ulong steamId, PlayerData pd) { }
// Extra run-scoped stat bonus (survival cards) added on top of Eff() at every stat site via EffRun. 0 in TDM/GG.
double StatBonus(PlayerData pd, string key) => 0.0;
// The per-player run-card bonus source fed into PlayerSnapshot.Cards (so the pure Domain reads cards without a
// pawn). null in TDM/GG and outside a survival run; the survival run itself implements IStatBonusSource.
IStatBonusSource? CardSource(PlayerData pd) => null;
// A monotonic, escalate-only handicap floor in t-space [0..1] for the active mode; -1 = "no floor" (TDM/GG, so buffs work).
double HandicapFloor(PlayerData pd) => -1.0;
// Team-wide survival card multipliers folded into MDeal / MTake (so they apply to every survivor and every damage
// path). 1.0 = no team buff (TDM/GG, and survival before any global_deal/global_take is drafted).
double TeamDealMult() => 1.0;
double TeamTakeMult() => 1.0;
// Optional mode status line for the HUD (survival shows the wave / bots-left line). "" = no line.
string HudStatusLine(PlayerData pd) => "";
// Optional weapon-ladder status line for the shop info panel (Gun Game shows the current rung). null = N/A.
string? LadderStatusLine(PlayerData pd) => null;
// Mode-specific block for the local status API (Api.cs), serialized as-is into the payload's "Extra" field.
// Called on the game thread against live driver state (the ~2s status rebuild). null = no extras.
object? StatusExtra() => null;
}
// Capability: a mode that runs the survival between-wave card DRAFT + the per-player run-XP accumulator. ONLY
// SurvivalDriver implements it; the core reaches it via `Draft` (= _driver as IDraftDriver) so it never type-checks the
// concrete driver. null everywhere else, so TDM/GG transparently skip every draft/run path.
public interface IDraftDriver
{
bool RunInProgress { get; } // a run is live -> EnforceHumanTeam blocks mid-run joins
void AccumulateWaveXp(PlayerData pd, double amount); // bank raw combat XP into THIS wave's accumulator (granted at wave clear)
bool DraftPending(CCSPlayerController p); // an unspent, spendable pick is waiting (break + draftable)
int PendingCards(CCSPlayerController p); // unspent banked picks (menu header)
List<(string key, string label)> CurrentDraw(CCSPlayerController p); // the offered hand (stable within a break)
CardView? CardInfo(CCSPlayerController p, string key); // one card's view data (name/have/cap/values/detail)
void PickCard(CCSPlayerController p, string key); // spend a pick on a drawn card
}
// View data for one draft card (the 3-card overlay): name, current/cap picks, and either the current->next % value
// (the stat cards) OR a custom Detail line (the effect cards, where a raw "+N%" would mislead).
public sealed record CardView(string Name, int Have, int Cap, double Now, double Next, bool Flat, string? Detail);
// TDM (the original mode): per-player chosen loadout, fixed bot squad; the map ends when any one player reaches the
// kill goal — OR when a bot BATCH does. Bots have no ladder, but their kills POOL per player-sized batch toward the same
// KillGoal (mirroring Gun Game's batch sharing) so the horde can actually win. Headshots don't demote.
public sealed class TdmDriver(OutnumberedPlugin p) : IMatchDriver
{
private readonly OutnumberedPlugin _p = p;
public string Id => "tdm";
public IReadOnlyList<string> Maps => _p.Config.Match.Maps;
public bool WeaponShopEnabled => true;
public HandicapOverride? Handicap => null; // TDM uses the base handicap as-is
public void GiveHumanLoadout(CCSPlayerController p) => _p.GiveChosenLoadout(p);
public void GiveBotLoadout(CCSPlayerController bot) => _p.GiveStandardBotLoadout(bot);
public void OnHumanKill(CCSPlayerController attacker, PlayerData apd)
{
if (apd.Kills >= _p.Config.Match.KillGoal) _p.TriggerMapEnd();
}
// The bot horde wins by reaching the SAME KillGoal, but pooled PER BATCH (≈BotsPerHuman bots), not per individual bot
// and not globally: each batch races one human's worth of kills, so the pace scales with player count (N humans ↔ N
// batches) and a lone player faces exactly one batch. Pooling globally would let a 3v12 horde hit the goal ~4x too
// fast. (Batch assignment mirrors Gun Game's; kept self-contained here so it can't perturb the working GG path.)
private readonly Dictionary<int, (int batch, int uid)> _botBatch = new(); // slot -> stable batch + occupant UserId
private readonly Dictionary<int, int> _batchKills = new(); // batch -> kills pooled toward KillGoal
public void OnBotKill(CCSPlayerController bot)
{
int batch = BotBatch(bot);
int kills = _batchKills.GetValueOrDefault(batch) + 1;
_batchKills[batch] = kills;
if (kills >= _p.Config.Match.KillGoal) _p.TriggerMapEnd("The bot horde reached the kill goal — bots win!");
}
public object? StatusExtra() => new { KillGoal = _p.Config.Match.KillGoal };
// A bot's batch: assigned ONCE to the smallest current batch and cached by slot+UserId, so it stays put across
// respawns/roster churn (a slot-rank formula would jitter as bots come and go). Batches stay ≈BotsPerHuman-sized;
// solo (4 bots) = one batch. Cleared on match reset.
private int BotBatch(CCSPlayerController bot)
{
int slot = bot.Slot, uid = bot.UserId ?? -1;
if (_botBatch.TryGetValue(slot, out var e) && e.uid == uid) return e.batch;
int per = Math.Max(1, _p.Config.Match.BotsPerHuman);
int botCount = Utilities.GetPlayers().Count(OutnumberedPlugin.IsBot);
int batches = Math.Max(1, (botCount + per - 1) / per);
var counts = new int[batches];
foreach (var v in _botBatch.Values) if (v.batch < batches) counts[v.batch]++;
int pick = 0;
for (int i = 1; i < batches; i++) if (counts[i] < counts[pick]) pick = i;
_botBatch[slot] = (pick, uid);
return pick;
}
public void OnHeadshotDeath(CCSPlayerController victim) { }
public void OnMatchReset() { _botBatch.Clear(); _batchKills.Clear(); }
public double HandicapProgress(PlayerData pd) => 0.0; // TDM has no progress axis
public int MaxHumansOnCt => _p.Config.Match.MaxHumansOnCt;
public string ExtraCvars => "";
}

View file

@ -0,0 +1,72 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.Logging;
using Outnumbered.Config;
namespace Outnumbered;
// Entry point. The plugin is a sealed partial class split by concern across files
// (Persistence.cs, Commands.cs, ...) — cookbook §0.
public sealed partial class OutnumberedPlugin : BasePlugin, IPluginConfig<OutnumberedConfig>
{
public override string ModuleName => "Outnumbered";
public override string ModuleVersion => "1.0.0-modes";
public override string ModuleAuthor => "snake";
public override string ModuleDescription => "Players-vs-bots RPG / perk mod.";
public OutnumberedConfig Config { get; set; } = new();
public void OnConfigParsed(OutnumberedConfig config) => Config = config; // MUST assign (cookbook §0)
// True between Load and Unload. Untracked one-shot AddTimer/NextFrame callbacks (SetupMap, the draft auto-pop, the
// changelevel) check this so a hot-reload/unload landing inside their delay window no-ops instead of firing against a
// torn-down instance. The REPEAT timers are killed explicitly in their Shutdown_*; this guards the deferred one-shots.
private bool _live;
public override void Load(bool hotReload)
{
Initialize_Database(hotReload);
Initialize_Driver();
Initialize_Handicap(); // structural guard: HandicapOverride mirrors HandicapConfig (fail-loud at load)
Initialize_Stats();
Initialize_Abilities();
Initialize_Effects(); // survival burn-DoT tick (no-op outside survival)
Initialize_Hud();
Initialize_Shop();
Initialize_Ranks();
Initialize_Feel();
Initialize_Api(); // the local UDS status/balance/top API — last: it reads the driver + effective handicap
RegisterListener<Listeners.OnTick>(OnTick_All); // ONE per-tick roster walk fanning out to the subsystems
RegisterSkillCommands(); // !s1..!sN direct skill-buy
RegisterAbilityCommands(); // !ability1..!abilityN bindable casts
_live = true;
Logger.LogInformation("Outnumbered loaded (mode driver: {Mode}) — persistence + stats + progression + handicap + abilities + HUD + shop.", _driver.Id);
}
// ONE per-tick roster walk shared by all per-tick subsystems: Utilities.GetPlayers() allocates a List, so materializing
// it once and fanning out (each subsystem keeps its own Enabled gate + throttle counter) avoids 3 extra full-roster
// scans + List allocs per tick.
private void OnTick_All()
{
var players = Utilities.GetPlayers();
OnTick_Abilities(players);
OnTick_Hud(players);
OnTick_Shop(players);
OnTick_Feel(players);
}
public override void Unload(bool hotReload)
{
_live = false; // make any in-flight one-shot timer/NextFrame callback a no-op
RemoveListener<Listeners.OnTick>(OnTick_All);
Shutdown_Api(); // reverse init order: stop serving (and unlink the socket) before the subsystems it reads tear down
Shutdown_Feel();
Shutdown_Ranks();
Shutdown_Shop();
Shutdown_Hud();
Shutdown_Abilities();
Shutdown_Effects();
Shutdown_Stats();
Shutdown_Driver();
Shutdown_Database();
}
}

293
Outnumbered/Persistence.cs Normal file
View file

@ -0,0 +1,293 @@
using System.Collections.Concurrent;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Entities;
using CounterStrikeSharp.API.Modules.Timers;
using Microsoft.Extensions.Logging;
using Outnumbered.Data;
namespace Outnumbered;
public sealed partial class OutnumberedPlugin
{
private IPlayerRepository _repo = null!;
private readonly ConcurrentDictionary<ulong, PlayerData> _players = new();
private readonly ConcurrentDictionary<int, ulong> _slotToSteam = new();
private CounterStrikeSharp.API.Modules.Timers.Timer? _flushTimer;
private void Initialize_Database(bool hotReload)
{
string serverId = ServerId();
// Backend by Config.Database.Provider: Postgres for production (shared remote DB), SQLite for dev. Both
// implement IPlayerRepository + the per-server match scoping; the rest of the plugin is provider-agnostic.
string provider = (Config.Database.Provider ?? "sqlite").Trim().ToLowerInvariant();
if (provider is "postgres" or "postgresql" or "pg")
{
_repo = new NpgsqlRepository(Config.Database.PostgresConnectionString, serverId);
Logger.LogInformation("Outnumbered DB: PostgreSQL (match scope: {Srv})", serverId);
}
#if WITH_SQLITE
else
{
string dbPath = Path.Combine(Server.GameDirectory, "csgo", Config.Database.SqliteFile);
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
_repo = new SqliteRepository(dbPath, serverId);
Logger.LogInformation("Outnumbered DB: SQLite {Path} (match scope: {Srv})", dbPath, serverId);
}
#else
else
{
// Postgres-only build (WithSqlite=false): SQLite isn't compiled in, so fall back to Postgres regardless.
Logger.LogWarning("Outnumbered: Database.Provider '{P}' requested but this is a Postgres-only build — using PostgreSQL.", provider);
_repo = new NpgsqlRepository(Config.Database.PostgresConnectionString, serverId);
Logger.LogInformation("Outnumbered DB: PostgreSQL (match scope: {Srv})", serverId);
}
#endif
Task.Run(async () =>
{
try { await _repo.EnsureSchemaAsync(); Logger.LogInformation("Outnumbered DB schema ready"); }
catch (Exception ex) { Logger.LogError(ex, "Outnumbered DB init failed"); }
});
RegisterListener<Listeners.OnClientAuthorized>(OnClientAuthorized);
RegisterListener<Listeners.OnClientDisconnect>(OnClientDisconnect);
// Periodic flush = crash insurance for permanent progression (a few dirty rows; negligible even on remote PG).
// FlushIntervalSeconds <= 0 -> pure RAM-first (write only on disconnect/shutdown). Match state is RAM-first regardless.
if (Config.FlushIntervalSeconds > 0)
_flushTimer = AddTimer(Config.FlushIntervalSeconds, FlushDirty, TimerFlags.REPEAT);
if (hotReload) SeedConnectedPlayers();
}
// On hot-reload, OnClientAuthorized won't re-fire for already-connected players (cookbook §0).
private void SeedConnectedPlayers()
{
foreach (var p in Humans())
{
var sid = p.AuthorizedSteamID?.SteamId64;
if (sid is not null) LoadPlayer(p.Slot, sid.Value);
}
}
private void OnClientAuthorized(int slot, SteamID id) => LoadPlayer(slot, id.SteamId64);
private void LoadPlayer(int slot, ulong sid)
{
_slotToSteam[slot] = sid;
Task.Run(async () =>
{
LoadedPlayer? loaded; MatchState? match;
try { loaded = await _repo.LoadAsync(sid); match = await _repo.LoadMatchAsync(sid); }
catch (Exception ex) { Logger.LogError(ex, "Outnumbered load failed for {Sid}", sid); return; }
// Entity/cache mutations back on the game thread.
Server.NextFrame(() =>
{
var pd = new PlayerData { SteamId = sid };
if (loaded is not null)
{
pd.Name = loaded.Name;
pd.Xp = loaded.Xp;
pd.Level = loaded.Level;
pd.Prestige = loaded.Prestige;
pd.Points = loaded.Points;
pd.PrimaryWeapon = loaded.PrimaryWeapon;
pd.SecondaryWeapon = loaded.SecondaryWeapon;
foreach (var kv in loaded.Upgrades) pd.Upgrades[kv.Key] = kv.Value;
}
if (match is not null) // an in-progress match (rejoin); a wiped/absent row loads as fresh zeros
{
pd.Kills = match.Kills; pd.Deaths = match.Deaths;
pd.Streak = match.Streak; pd.HeadshotKills = match.HeadshotKills;
pd.GgRunStartedAtMs = match.GgRunStartedAtMs; // restored, never re-armed: a rejoin can't restart the GG clock
}
var name = Utilities.GetPlayerFromSlot(slot)?.PlayerName;
if (!string.IsNullOrEmpty(name)) pd.Name = name;
_players[sid] = pd;
Logger.LogInformation(
"Outnumbered loaded {Sid} '{Name}': L{Lvl} P{Pre} xp={Xp} pts={Pts} ({N} upgrades){New}",
sid, pd.Name, pd.Level, pd.Prestige, pd.Xp, pd.Points, pd.Upgrades.Count, loaded is null ? " [new]" : "");
});
});
}
private void OnClientDisconnect(int slot)
{
if (!_slotToSteam.TryRemove(slot, out var sid)) return;
if (!_players.TryRemove(sid, out var pd)) return;
// survival: bank this player's accumulated run-XP into the main table BEFORE we snapshot (progress-based; run
// ends on disconnect — no mid-run reconnect-restore). Sets pd.Dirty so the converted XP is captured below.
_driver.OnHumanDisconnect(sid, pd);
// If this was the LAST human, the match is OVER (no players = not ongoing) -> wipe the per-server match_state so the
// next session starts FRESH (no ghost kills/deaths/streak). While other humans remain, the match is still ongoing,
// so we persist this leaver's state for a mid-match rejoin-restore. A graceful shutdown/restart saves via
// Shutdown_Database (which does NOT route through OnClientDisconnect — _players is still populated there), so a
// restart-while-players-connected still restores; only a genuinely-emptied server resets.
bool matchOver = _players.IsEmpty;
// capture on the game thread; player is leaving (no rollback needed).
var perm = pd.Dirty ? pd.ToPersist() : null;
var match = (!matchOver && pd.HasMatchActivity) ? pd.ToMatchState() : null;
if (perm is null && match is null && !matchOver) return;
Task.Run(async () =>
{
try
{
if (perm is not null) await _repo.SaveAsync(perm); // permanent progression always persists
if (match is not null) await _repo.SaveMatchAsync(match); // match still ongoing -> save for a rejoin-restore
if (matchOver) await _repo.WipeMatchAsync(); // server empty -> end the match (fresh next session)
Logger.LogInformation("Outnumbered saved {Sid} on disconnect{Over}", sid, matchOver ? " (last player left -> match ended, state wiped)" : "");
}
catch (Exception ex) { Logger.LogError(ex, "Outnumbered save-on-disconnect failed for {Sid}", sid); }
});
}
// Main-thread timer. Snapshot dirty rows, clear optimistically, save async, re-mark on failure.
private void FlushDirty()
{
List<PlayerData>? dirty = null;
foreach (var pd in _players.Values)
if (pd.Dirty) (dirty ??= new()).Add(pd);
if (dirty is null) return;
var snaps = dirty.Select(pd => pd.ToPersist()).ToList();
foreach (var pd in dirty) pd.Dirty = false; // optimistic; a mutation during the save re-sets it
Task.Run(async () =>
{
try { await _repo.SaveManyAsync(snaps); Logger.LogInformation("Outnumbered flushed {N} player(s)", snaps.Count); }
catch (Exception ex)
{
Logger.LogError(ex, "Outnumbered flush failed; re-marking dirty");
Server.NextFrame(() => { foreach (var pd in dirty) pd.Dirty = true; });
}
});
}
private void Shutdown_Database()
{
_flushTimer?.Kill();
var snaps = _players.Values.Where(p => p.Dirty).Select(p => p.ToPersist()).ToList();
var matchSnaps = _players.Values.Where(p => p.HasMatchActivity).Select(p => p.ToMatchState()).ToList();
_players.Clear();
_slotToSteam.Clear();
if (snaps.Count == 0 && matchSnaps.Count == 0) return;
// Bounded blocking flush — fire-and-forget here races the changelevel/unload (cookbook §1).
try
{
if (snaps.Count > 0) _repo.SaveManyAsync(snaps).GetAwaiter().GetResult();
if (matchSnaps.Count > 0) _repo.SaveManyMatchAsync(matchSnaps).GetAwaiter().GetResult();
Logger.LogInformation("Outnumbered flushed {N} player(s) + {M} match row(s) on unload", snaps.Count, matchSnaps.Count);
}
catch (Exception ex) { Logger.LogError(ex, "Outnumbered unload flush failed"); }
}
// Per-instance tag that scopes the ephemeral match_state so MANY servers (even sharing one DB / one install
// dir) don't clobber each other's round. Permanent progression / the leaderboard stays GLOBAL — only the round
// table is per-server. Read from the process command line (machine-independent, available immediately):
// -outnumbered_server <tag> (recommended; any parse-able value, like -outnumbered_mode) ->
// the game port (-port/+hostport; unique per instance on ONE box, not across boxes) -> "default".
internal string ServerId()
{
try
{
var args = CommandLineArgs();
string? tag = ArgValue(args, "outnumbered_server");
if (!string.IsNullOrWhiteSpace(tag)) return Sanitize(tag);
string? port = ArgValue(args, "port") ?? ArgValue(args, "hostport");
if (!string.IsNullOrWhiteSpace(port)) return Sanitize("port-" + port);
}
catch (Exception ex) { Logger.LogWarning(ex, "Outnumbered: server-tag resolution failed; using 'default'"); }
return "default";
}
// The server's REAL launch command line. Environment.GetCommandLineArgs() is unreliable inside the CS2/CSSharp
// embedded .NET host (it often returns only the module path, NOT the cs2 process argv), so launch flags like
// -outnumbered_mode / -outnumbered_server are invisible through it. On Linux /proc/self/cmdline is the
// authoritative NUL-separated argv of the actual cs2 process and always carries the full launch line. Falls back
// to the managed args if /proc is unavailable (non-Linux / sandboxed).
private static string[] CommandLineArgs()
{
try
{
const string p = "/proc/self/cmdline";
if (File.Exists(p))
{
var parts = File.ReadAllText(p).Split('\0', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 1) return parts;
}
}
catch { /* fall through to the managed args */ }
return Environment.GetCommandLineArgs();
}
// First value for `-name` / `+name` on the command line (both `-name value` and `-name=value` forms), or null.
// A value-less space form (the next token is itself a flag, e.g. `-outnumbered_server -port 27015`) returns null so
// callers fall through to their next source instead of swallowing the following flag as the value.
private static string? ArgValue(string[] args, string name)
{
string dash = "-" + name, plus = "+" + name;
for (int i = 0; i < args.Length; i++)
{
string a = args[i];
if (a.StartsWith(dash + "=", StringComparison.OrdinalIgnoreCase)) return a[(dash.Length + 1)..];
if (a.StartsWith(plus + "=", StringComparison.OrdinalIgnoreCase)) return a[(plus.Length + 1)..];
if ((a.Equals(dash, StringComparison.OrdinalIgnoreCase) || a.Equals(plus, StringComparison.OrdinalIgnoreCase)) && i + 1 < args.Length)
{
string v = args[i + 1];
return v.StartsWith('-') || v.StartsWith('+') ? null : v;
}
}
return null;
}
private static string Sanitize(string s)
{
string r = s.Trim().Replace(' ', '_');
return r.Length > 64 ? r[..64] : r;
}
// Clear the match table — called at match boundaries (kill-goal rotation + any map start after the first). The
// first map start after plugin load does NOT call this, so a match dumped on shutdown survives to be restored on rejoin.
private void WipeMatch() => Task.Run(async () =>
{
try { await _repo.WipeMatchAsync(); }
catch (Exception ex) { Logger.LogError(ex, "Outnumbered match-state wipe failed"); }
});
// ---- records (site leaderboards): improve-only, fire-and-forget. Deliberately outside the Dirty/flush machinery —
// the repo upserts are clobber-safe on their own, so nothing here round-trips through PlayerData. ----
// Survival: every participant of a cleared wave. Higher wave wins.
internal void RecordBestWaves(List<ulong> participants, int wave) => Task.Run(async () =>
{
try { await _repo.TryImproveBestWavesAsync(participants, wave); }
catch (Exception ex) { Logger.LogError(ex, "Outnumbered best-wave record failed (wave {Wave})", wave); }
});
// Gun Game: stop the speedrun clock at the human ladder win; lower time wins. Returns the elapsed ms for the win
// banner, or null when there's nothing sane to record (clock never armed, or skewed across a restart-restore).
internal long? RecordGgWin(PlayerData apd)
{
if (apd.GgRunStartedAtMs <= 0) return null;
long ms = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - apd.GgRunStartedAtMs;
if (ms <= 0) return null;
ulong sid = apd.SteamId;
Task.Run(async () =>
{
try { await _repo.TryImproveGgBestAsync(sid, ms); }
catch (Exception ex) { Logger.LogError(ex, "Outnumbered gg-best record failed for {Sid}", sid); }
});
return ms;
}
internal static string FormatRunTime(long ms) => $"{ms / 60000}:{ms / 1000 % 60:D2}.{ms % 1000:D3}";
}

111
Outnumbered/Players.cs Normal file
View file

@ -0,0 +1,111 @@
using System.Diagnostics.CodeAnalysis;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
using Outnumbered.Data;
namespace Outnumbered;
// The one front door from a controller/slot to PlayerData, plus the shared player predicates/enumerators and the
// slot-deferral helper. Handlers route through these so the engine's controller/SteamID surface (the part most likely
// to shift across CSSharp builds) and the slot-reuse-after-NextFrame contract live in one place. Deliberate exceptions:
// the hot OnEntityTakeDamagePre resolves inline (perf), and a few sites keep the raw SteamID where it's the actual key
// (the _hudOff/_dmgReadout HashSets, LoadPlayer, identity re-validation) rather than a PlayerData lookup.
public sealed partial class OutnumberedPlugin
{
// The canonical controller -> PlayerData lookup (by SteamID). Every other resolver routes through it.
internal PlayerData? PdOf(CCSPlayerController p)
{
var sid = p.AuthorizedSteamID?.SteamId64;
return sid is not null && _players.TryGetValue(sid.Value, out var pd) ? pd : null;
}
internal PlayerData? PdOf(int slot)
{
var p = Utilities.GetPlayerFromSlot(slot);
return p is { IsValid: true } ? PdOf(p) : null;
}
// [NotNullWhen(true)] lets `if (!IsHuman(p)) return;` flow non-null through to the body.
// Resolve a pawn back to its owning controller (the schema dance the damage hook does for attacker + victim).
internal static CCSPlayerController? ControllerOfPawn(CCSPlayerPawn? pawn) => pawn?.Controller.Value?.As<CCSPlayerController>();
internal static bool IsHuman([NotNullWhen(true)] CCSPlayerController? p) => p is { IsValid: true, IsBot: false, IsHLTV: false };
internal static bool IsLiveHuman([NotNullWhen(true)] CCSPlayerController? p) => p is { IsValid: true, IsBot: false, IsHLTV: false } && p.PawnIsAlive;
// Bot-side mirror. IsHLTV:false excludes a SourceTV/GOTV proxy (the engine marks it IsBot too) — so a relay can never
// be treated as a combat bot.
internal static bool IsBot([NotNullWhen(true)] CCSPlayerController? p) => p is { IsValid: true, IsBot: true, IsHLTV: false };
internal static bool IsLiveBot([NotNullWhen(true)] CCSPlayerController? p) => p is { IsValid: true, IsBot: true, IsHLTV: false } && p.PawnIsAlive;
// Per-tick callers iterate Utilities.GetPlayers() with the IsHuman/IsLiveHuman/IsBot predicates inline (no Where-iterator
// alloc); Humans()/Bots() are the allocating convenience for cold paths (e.g. the hot-reload seed, the bot pool walk).
internal static IEnumerable<CCSPlayerController> Humans() => Utilities.GetPlayers().Where(IsHuman);
internal static IEnumerable<CCSPlayerController> Bots() => Utilities.GetPlayers().Where(IsBot);
// Force every CT-side bot back to T (bot_join_team only affects NEWLY-added bots). Shared by the steady-state SyncBots
// and survival's ManageBots; the caller passes its already-materialized roster so there's no extra GetPlayers() walk.
internal static void ForceBotsToTerrorist(IEnumerable<CCSPlayerController> players)
{
foreach (var b in players)
if (IsBot(b) && b.Team == CsTeam.CounterTerrorist) b.SwitchTeam(CsTeam.Terrorist);
}
// Run `body` next frame for the player still occupying `slot`, re-resolved and re-validated (the slot may have been
// reused). The overload also pins the SteamID so a within-frame disconnect+reuse by a different person can't inherit.
internal static void NextFrameForSlot(int slot, Action<CCSPlayerController> body, bool requireAlive = false) =>
Server.NextFrame(() =>
{
var p = Utilities.GetPlayerFromSlot(slot);
if (p is { IsValid: true } && (!requireAlive || p.PawnIsAlive)) body(p);
});
internal static void NextFrameForSlot(int slot, ulong expectSid, Action<CCSPlayerController> body, bool requireAlive = false) =>
Server.NextFrame(() =>
{
var p = Utilities.GetPlayerFromSlot(slot);
if (p is { IsValid: true } && p.AuthorizedSteamID?.SteamId64 == expectSid && (!requireAlive || p.PawnIsAlive)) body(p);
});
// Defer a HP/armor cap re-apply (optionally re-locking the default loadout) to next frame — re-resolved by slot and
// SteamID-pinned so a within-frame disconnect+slot-reuse can't apply this player's caps to whoever inherits the slot.
// No-op if dead now / no SteamID / not alive next frame. Used after a stat buy / prestige reset / admin grant.
internal void DeferReapplyCaps(CCSPlayerController p, bool relockLoadout = false)
{
if (!p.PawnIsAlive || p.AuthorizedSteamID?.SteamId64 is not { } sid) return;
NextFrameForSlot(p.Slot, sid, pl =>
{
if (PdOf(pl) is not { } pd) return;
if (relockLoadout) ApplyLoadout(pl, false);
ApplyMaxHpArmor(pl, pd);
}, requireAlive: true);
}
// Reap entries keyed by player slot whose controller has vanished WITHOUT a clean disconnect (map change /
// bot-replaces-human / kick race). Collect-then-invoke because onOrphan typically mutates `map`. The caller owns the
// reused `scratch` list and passes the concrete Dictionary (struct enumerator) + a CACHED onOrphan delegate, so the
// per-tick walk allocates nothing. Used by the Hud (world-text) and Shop (session) per-tick reaps.
internal static void ReapOrphanSlots<T>(Dictionary<int, T> map, List<int> scratch, Action<int> onOrphan)
{
scratch.Clear();
foreach (var slot in map.Keys)
{
var pl = Utilities.GetPlayerFromSlot(slot);
if (pl is null || !pl.IsValid || pl.IsBot) scratch.Add(slot);
}
foreach (var slot in scratch) onOrphan(slot);
}
// CheckTransmit shared shape: a per-player world-text panel keyed by slot must be hidden from EVERY OTHER player (each
// sees only their own). The Hud + Shop transmit handlers differ only in which entities a slot owns — pass a STATIC
// `remove` (no captures, so it's cached, no per-call alloc on this hot listener) that strips one slot's entities.
internal static void HideForeignSlotEntities<T>(CCheckTransmitInfoList infoList, Dictionary<int, T> map, Action<CCheckTransmitInfo, T> remove)
{
if (map.Count == 0) return;
foreach (var (info, receiver) in infoList)
{
if (receiver is null) continue;
foreach (var (slot, entry) in map)
if (slot != receiver.Slot) remove(info, entry);
}
}
}

181
Outnumbered/Progression.cs Normal file
View file

@ -0,0 +1,181 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Menu;
using CounterStrikeSharp.API.Modules.Utils;
using Outnumbered.Data;
using Outnumbered.Domain;
namespace Outnumbered;
// XP -> levels -> points -> prestige, plus the !skills menu that spends points. The XP/level/prestige CURVES
// live in Outnumbered.Domain.ProgressionModel; these are zero-math accessors. The stateful level-up loop + its side
// effects (chat/sound/clan) stay engine-side below.
public sealed partial class OutnumberedPlugin
{
private long XpToNext(int level) => ProgressionModel.XpToNext(level, Config.Progression);
private double PrestigeXpMultiplier(PlayerData pd) => ProgressionModel.PrestigeXpMultiplier(pd.Prestige, Config.Progression);
// Award XP (per-hit damage, kill bonus, or dev). Applies the XP-Boost stat + cumulative prestige boost +
// handicap rate; handles level-ups. The fractional remainder is carried so tiny per-hit grants aren't lost.
private void GrantXp(PlayerData pd, double baseAmount, CCSPlayerController? p)
{
if (baseAmount <= 0 || pd.Level >= Config.Progression.LevelCap) return;
double scaled = baseAmount
* (1.0 + Eff(pd, StatKeys.XpBoost) / 100.0)
* PrestigeXpMultiplier(pd)
* HandicapXpMult(pd)
+ pd.XpCarry;
long gain = (long)Math.Floor(scaled);
pd.XpCarry = scaled - gain; // keep the sub-1 remainder for the next hit
if (gain <= 0) return;
pd.Xp += gain;
pd.Dirty = true;
ApplyXpToLevel(pd, p);
}
// Resolve any pending level-ups from pd.Xp (awarding a point + firing the level-up cascade each), then clamp at
// the cap. Shared by per-hit XP (GrantXp) and the survival per-wave grant (AddConvertedXp) so both behave alike.
private void ApplyXpToLevel(PlayerData pd, CCSPlayerController? p)
{
long need;
while (pd.Level < Config.Progression.LevelCap && pd.Xp >= (need = XpToNext(pd.Level)))
{
pd.Xp -= need; // same XpToNext(pd.Level) the condition just computed (pd.Level unchanged until ++ below)
pd.Level++;
pd.Points++;
OnLevelUp(pd, p);
}
if (pd.Level >= Config.Progression.LevelCap) { pd.Xp = 0; pd.XpCarry = 0; } // clamp at cap
}
// Route combat XP. In survival it's banked RAW into the CURRENT wave's accumulator (granted at wave clear x prestige
// x waveMult; the handicap XP-mult is deliberately excluded there). In every other mode it grants now.
internal void GrantCombatXp(PlayerData pd, double baseAmount, CCSPlayerController? p)
{
if (Draft is { } d) d.AccumulateWaveXp(pd, baseAmount); // survival: banked raw into THIS wave's accumulator
else GrantXp(pd, baseAmount, p);
}
// Add a pre-computed XP lump straight to the main table and resolve level-ups — NO per-hit multipliers re-applied
// (the survival per-wave grant has already applied prestige x waveMult and deliberately excluded the handicap mult).
internal void AddConvertedXp(PlayerData pd, long lump, CCSPlayerController? p)
{
if (lump <= 0 || pd.Level >= Config.Progression.LevelCap) return;
pd.Xp += lump;
pd.Dirty = true;
ApplyXpToLevel(pd, p);
}
private void OnLevelUp(PlayerData pd, CCSPlayerController? p)
{
if (p is not { IsValid: true }) return;
if (pd.Level >= Config.Progression.LevelCap)
p.PrintToChat($"[Outnumbered] MAX LEVEL! Type !prestige to reset for a permanent boost.");
else
p.PrintToChat($"[Outnumbered] Level {pd.Level}! +1 point — !skills to spend ({pd.Points} available).");
ApplyClan(p, pd); // rank tag may have changed
PlaySound(p, Config.Sounds.LevelUp);
AnnounceUnlocks(p, pd.Level); // shout any weapon(s) that unlock at this exact level
}
// Flat kill bonus on the lethal blow (the bulk of XP comes per-hit from damage; see OnPlayerHurt_Stats).
private void GrantKillXp(CCSPlayerController attacker)
{
if (PdOf(attacker) is { } pd)
GrantCombatXp(pd, Config.Progression.KillXpBonus, attacker); // survival: into the run accumulator
}
// ---- !skills ----
// Flat numbered legend (no menu): buy any stat directly with !s<n>, repeatable, no pagination.
// (!1-!9 are reserved by CSSharp's menu key system, so skill commands use the 's' prefix.)
private void OpenSkillMenu(CCSPlayerController p)
{
var pd = PdOf(p);
if (pd is null) return;
p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Skills — {pd.Points} pt | L{pd.Level} P {Roman(pd.Prestige)} (buy: !s1-!s{StatList.Length})");
for (int i = 0; i < StatList.Length; i++)
p.PrintToChat($" {ChatColors.Lime} " + SkillLegendSeg(pd, i));
}
private string SkillLegendSeg(PlayerData pd, int i)
{
var (key, display) = StatList[i];
return $"!s{i + 1} {display} [{LevelOf(pd, key)}/{DefFor(key).MaxLevel}]";
}
// Registered from Load: !s1..!sN each buy the matching stat (BuyStat enforces points/cap).
private void RegisterSkillCommands()
{
for (int i = 0; i < StatList.Length; i++)
{
int idx = i;
AddCommand($"css_s{i + 1}", $"Buy skill #{i + 1}", (player, _) =>
{
if (player is { IsValid: true }) BuyStat(player, StatList[idx].Key);
});
}
}
private void BuyStat(CCSPlayerController p, string key)
{
var pd = PdOf(p);
if (pd is null) return;
int lvl = LevelOf(pd, key);
int max = DefFor(key).MaxLevel;
if (pd.Points <= 0 || lvl >= max) return;
pd.Upgrades[key] = lvl + 1;
pd.Points--;
pd.Dirty = true;
DeferReapplyCaps(p); // re-apply the new max HP/armor next frame (slot+SteamID-pinned)
p.PrintToChat($"[Outnumbered] {key} -> {lvl + 1}/{max} ({pd.Points} point(s) left).");
}
// ---- !prestige ----
private void OpenPrestige(CCSPlayerController p)
{
var pd = PdOf(p);
if (pd is null) return;
if (pd.Level < Config.Progression.LevelCap)
{
p.PrintToChat($"[Outnumbered] Reach level {Config.Progression.LevelCap} to prestige (you're {pd.Level}).");
return;
}
if (pd.Prestige >= Config.Progression.PrestigeCap)
{
p.PrintToChat($"[Outnumbered] Max prestige ({Roman(pd.Prestige)}) — 100% complete!");
return;
}
var menu = new ChatMenu($"Prestige {Roman(pd.Prestige + 1)}? FULL RESET (level/points/stats)");
menu.AddMenuOption("YES — prestige now", (player, _) => DoPrestige(player));
menu.AddMenuOption("Cancel", (_, _) => { });
menu.Open(p);
}
private void DoPrestige(CCSPlayerController p)
{
var pd = PdOf(p);
if (pd is null) return;
if (pd.Level < Config.Progression.LevelCap || pd.Prestige >= Config.Progression.PrestigeCap) return;
pd.Prestige++;
pd.Level = 1;
pd.Xp = 0;
pd.Points = 1;
pd.Upgrades.Clear();
pd.Dirty = true;
// Re-apply the default loadout (level 1 re-locks the high-tier guns) and reset HP/armor to base on the live pawn.
DeferReapplyCaps(p, relockLoadout: true); // slot+SteamID-pinned so a within-frame slot-reuse can't inherit this reset
ApplyClan(p, pd); // prestige + reset to L1 changes the tag
PlaySound(p, Config.Sounds.Prestige);
Server.PrintToChatAll($"[Outnumbered] {p.PlayerName} reached Prestige {Roman(pd.Prestige)}!");
}
}

267
Outnumbered/Ranks.cs Normal file
View file

@ -0,0 +1,267 @@
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}");
}
}

57
Outnumbered/Shop.Draft.cs Normal file
View file

@ -0,0 +1,57 @@
using System.Text;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
using Outnumbered.Engine;
namespace Outnumbered;
// The survival between-wave card DRAFT overlay — the 3-card world-text render.
// The draft's input + screen dispatch (ShopScreen.CardDraft, ExecuteOption, the menu-key handling) stays in Shop.cs; this
// partial owns only the panel rendering (create / update / build-text). All state is the shared ShopSession + Config.Shop.
public sealed partial class OutnumberedPlugin
{
// A single survival-draft card panel — centre-justified, chunkier background so each reads as its own "card".
private CPointWorldText? CreateCardPanel()
{
var s = Config.Shop;
return WorldText.Create(s.FontSize, s.WorldUnitsPerPx, s.FontName,
System.Drawing.Color.FromArgb(255, s.ColorR, s.ColorG, s.ColorB), s.DrawBackground, 0.35f,
PointWorldTextJustifyHorizontal_t.POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_CENTER);
}
// The survival between-wave draft: up to 3 cards side by side (centre card at RightOffset, the others ± CardSpread).
private void UpdateDraftPanels(CCSPlayerController p, ShopSession s, Vector eye, Vector fwd, Vector right, Vector up, QAngle ang)
{
var cfg = Config.Shop;
var draw = Draft is { } sd ? sd.CurrentDraw(p) : new();
int n = Math.Min(draw.Count, s.Cards.Length); // cards actually shown (1..3)
for (int i = 0; i < s.Cards.Length; i++)
{
if (i >= n) // fewer than 3 cards left (pool nearly exhausted) -> drop the spare panel
{
WorldText.Destroy(ref s.Cards[i]); s.CardText[i] = null;
continue;
}
if (s.Cards[i] is null || !s.Cards[i]!.IsValid) { s.Cards[i] = CreateCardPanel(); s.CardText[i] = null; }
var card = s.Cards[i];
if (card is null) continue;
string txt = BuildCardPanel(p, draw[i].key, GrenadeMenuKeys[i]); // grenade keys 6/7/8 (-> [HE]/[Flash]/[Smoke] labels)
if (s.CardText[i] != txt) { WorldText.SetText(card, txt); s.CardText[i] = txt; }
float x = (i - (n - 1) / 2f) * cfg.CardSpread; // centre the row regardless of card count (1/2/3)
WorldText.Place(card, eye + fwd * cfg.ForwardOffset + right * (cfg.RightOffset + x) + up * cfg.UpOffset, ang);
}
}
// One card's text: the key to press, the card name + count, and the current -> next value so you know what you get.
private string BuildCardPanel(CCSPlayerController p, string key, char menuKey)
{
if (Draft is not { } sd || sd.CardInfo(p, key) is not { } v) return " ";
string u = v.Flat ? "" : "%";
var sb = new StringBuilder();
sb.Append($"[ {KeyItemLabel(menuKey)} ]\n");
sb.Append($"{v.Name} [{v.Have}/{v.Cap}]\n");
if (v.Detail is not null) sb.Append(v.Detail); // effect cards: a single descriptive line
else { sb.Append($"Now: +{v.Now:0}{u}\n"); sb.Append($"Next: +{v.Next:0}{u}"); } // stat cards: current -> next
return sb.ToString();
}
}

590
Outnumbered/Shop.cs Normal file
View file

@ -0,0 +1,590 @@
using System.Text;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
using Outnumbered.Data;
using Outnumbered.Engine;
namespace Outnumbered;
// The quick-buy shop. Opened by switching to the healthshot carrier (X); while open the player is
// frozen (MoveType NONE) and the menu is two world-text panels in front of the camera (left = shop options,
// right = info), each hugging the centre. Inputs are weapon-slot / grenade key presses, detected as
// active-weapon changes (the same trick the abilities use):
// * keys 1/2 = primary / secondary slots
// * key 3 = the zeus (shares the melee slot with the knife; pressing 3 flips knife->zeus = a detectable switch)
// * keys 6/7/8/9/0 = the five grenades (GIVEN on open so the slots are populated and the presses register)
// * X (healthshot) = back one screen, or close at root
// Between presses the player rests on the KNIFE (the neutral, slot 3) so repeated keys (e.g. X-1-6-6) each
// produce a fresh switch event. Resetting to the knife keeps a grenade from being held long enough to throw,
// and the healthshot from being held long enough to heal.
public sealed partial class OutnumberedPlugin
{
private const string ShopRestCmd = Inventory.SlotMelee; // reset between presses = back to the neutral (melee slot)
// (the shop "carrier" item names — weapon_healthshot / weapon_taser — live in EngineNames.ShopCarrier/ShopMelee)
// The melee slot holds knife + zeus, so ShopRestCmd toggles between them, and the client's switch is observed a network
// round-trip later — firing it every tick piles up a toggle backlog. Debounce it so each switch settles (lands on the
// knife, where no further rest fires) before the next.
private const double RestDebounceSeconds = 0.20;
// The grenade tail of Weapons.MenuKeys ({1,2,3,6,7,8,9,0}) — the 6/7/8/9/0 keys. Used by BOTH the skills-only
// (Gun Game) shop, where OpenShop always populates these so the menu works even when the player holds only one
// ladder gun (slots 1/2 may be empty by rung), AND the survival card draft. Not Gun-Game-specific; keep aligned.
private static readonly char[] GrenadeMenuKeys = { '6', '7', '8', '9', '0' };
private char ShopOptKey(int pos) => _driver.WeaponShopEnabled
? MenuKey(pos)
: (pos >= 0 && pos < GrenadeMenuKeys.Length ? GrenadeMenuKeys[pos] : '?');
// The grenade quick-buy keys: menu key -> (grenade weapon used to populate the slot, display label). ONE source for
// the slot-population (OpenShop/CloseShop), the WeaponToKey reverse lookup, and the [HE]/[Flash]/... labels — so the
// 6=HE alignment isn't re-typed across the file. (Distinct from the JSON-tunable Abilities.AbilityGrenades.)
private static readonly (char Key, string Weapon, string Label)[] GrenadeMenu =
{
('6', "weapon_hegrenade", "HE"),
('7', "weapon_flashbang", "Flash"),
('8', "weapon_smokegrenade", "Smoke"),
('9', "weapon_decoy", "Decoy"),
('0', "weapon_incgrenade", "Molotov"),
};
private static readonly string[] SkillGroupNames = { "Damage", "Defense", "Utility" };
// SkillGroupStats (the canonical key->group projection) lives in Stats.cs next to StatRegistry; SkillGroupNames[i] labels group i.
private enum ShopScreen { Root, WeaponCats, Category, SkillGroups, SkillStats, CardDraft }
private sealed record ShopOption(char Key, string Label, bool Enabled, Action Act);
private sealed class ShopSession
{
public bool Open;
public ShopScreen Screen;
public int CatIndex; // which weapon category (Category screen)
public int GroupIndex; // which skill group (SkillStats screen)
public bool CarrierWasActive; // rising-edge guard: one X press = one back/close
public bool SawNeutral; // have we rested on the knife since opening? (gates the first input)
public string LastActive = ""; // last option weapon acted on (de-dupes the deploy-lag ticks)
public CPointWorldText? OptionsEntity; // left panel: shop options (mirrors InfoEntity)
public CPointWorldText? InfoEntity; // right panel: info
public string? LastText;
public string? LastInfoText;
public readonly CPointWorldText?[] Cards = new CPointWorldText?[3]; // survival draft: the 3 card panels
public readonly string?[] CardText = new string?[3]; // last text per card (skip redundant SetMessage)
public MoveType_t SavedMoveType = MoveType_t.MOVETYPE_WALK;
public double ClosedAt = -999; // Server.CurrentTime at last close (post-close input grace)
public double RestAt = -999; // Server.CurrentTime of the last rest command (debounces the slot-select + gates picks until the weapon settles)
// Remove + null every world-text panel this session owns (the 2 shop panels + the 3 draft cards) and clear their
// last-text caches. One teardown for CloseShop + OnClientDisconnect (panels + draft) so no site forgets a panel.
public void DestroyEntities()
{
WorldText.Destroy(ref OptionsEntity);
WorldText.Destroy(ref InfoEntity);
LastText = null; LastInfoText = null;
for (int i = 0; i < Cards.Length; i++) { WorldText.Destroy(ref Cards[i]); CardText[i] = null; }
}
}
private readonly Dictionary<int, ShopSession> _shop = new();
private readonly List<int> _shopReap = new(); // reused orphan-slot scratch for the per-tick session reap
private Action<int>? _shopReapAction; // cached OnClientDisconnect_Shop delegate (so ReapOrphanSlots adds no per-tick closure)
private const double ShopCloseGrace = 0.35; // suppress ability input this long after close (trailing key-spam)
// Read by the abilities loop: true while the shop is open OR within the post-close grace, so a 6-spam to max a
// skill (key 6 is also a grenade) can't leak into a No Reload cast the instant the menu closes.
public bool ShopInputLocked(int slot) =>
_shop.TryGetValue(slot, out var s) && (s.Open || Server.CurrentTime - s.ClosedAt < ShopCloseGrace);
private ShopSession ShopOf(int slot)
{
if (!_shop.TryGetValue(slot, out var s)) { s = new ShopSession(); _shop[slot] = s; }
return s;
}
private static bool IsNeutralMelee(string n) => n == EngineNames.WeaponKnife || n.Contains("knife") || n.Contains("bayonet");
private void Initialize_Shop()
{
// OnTick is driven by OnTick_All (shared roster walk); OnTick_Shop is invoked from there.
RegisterListener<Listeners.CheckTransmit>(OnCheckTransmit_Shop);
RegisterListener<Listeners.OnClientDisconnect>(OnClientDisconnect_Shop);
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn_Shop);
}
private void Shutdown_Shop()
{
RemoveListener<Listeners.CheckTransmit>(OnCheckTransmit_Shop);
RemoveListener<Listeners.OnClientDisconnect>(OnClientDisconnect_Shop);
foreach (var slot in _shop.Keys.ToList()) CloseShop(slot, switchAway: false);
_shop.Clear();
}
// Disconnect can leave a frozen pawn + open session through the reconnect grace window — clear it outright.
private void OnClientDisconnect_Shop(int slot)
{
if (!_shop.TryGetValue(slot, out var s)) return;
s.DestroyEntities();
var pawn = Utilities.GetPlayerFromSlot(slot)?.PlayerPawn.Value;
if (s.Open && pawn is not null && pawn.Health > 0) SetFrozen(pawn, false, s.SavedMoveType);
_shop.Remove(slot); // a reconnecting player on this slot starts fresh
}
// Force the menu closed on (re)spawn so a reconnect never resumes mid-menu.
private HookResult OnPlayerSpawn_Shop(EventPlayerSpawn ev, GameEventInfo info)
{
var p = ev.Userid;
if (p is { IsValid: true } && !p.IsBot) CloseShop(p.Slot, switchAway: false);
return HookResult.Continue;
}
// ---- per-tick input + render ----
private void OnTick_Shop(List<CCSPlayerController> players)
{
if (!Config.Shop.Enabled)
{
foreach (var (slot, s) in _shop) if (s.Open) CloseShop(slot, switchAway: true); // close out if disabled live
return;
}
// Reap sessions whose controller vanished WITHOUT a clean OnClientDisconnect (map change / bot-replaces-human /
// kick race) — full cleanup (panels + unfreeze + drop session), not just the open ones. Shared collect-then-act
// helper (OnClientDisconnect_Shop mutates _shop). Normal path: no orphans -> no-op.
ReapOrphanSlots(_shop, _shopReap, _shopReapAction ??= OnClientDisconnect_Shop);
foreach (var p in players) HandleShopTick(p);
}
// Reset to the neutral, debounced (see RestDebounceSeconds) so the slot-select toggle settles between fires.
private static void Rest(CCSPlayerController p, ShopSession s)
{
if (Server.CurrentTime - s.RestAt < RestDebounceSeconds) return;
p.ExecuteClientCommand(ShopRestCmd);
s.RestAt = Server.CurrentTime;
}
// Per-player shop tick: the carrier/menu input state machine (open/back/close, the knife-neutral rest, key->option,
// panel render). Split out of OnTick_Shop so that method stays orchestration (disabled-gate + orphan reap + fan-out);
// the loop's `continue` becomes `return` here.
private void HandleShopTick(CCSPlayerController p)
{
if (!IsHuman(p)) return; // inline predicate (no Where-iterator alloc on this per-tick path)
var s = ShopOf(p.Slot);
var pawn = p.PlayerPawn.Value;
if (pawn is null || !p.PawnIsAlive)
{
if (s.Open) CloseShop(p.Slot, switchAway: false);
s.CarrierWasActive = false; s.LastActive = "";
return;
}
string activeName = Inventory.ActiveWeaponName(pawn);
bool carrierActive = activeName == EngineNames.ShopCarrier;
// X (healthshot) — rising edge: open, or back-one-screen / close at root
if (carrierActive && !s.CarrierWasActive)
{
if (s.Open) Back(p, s);
else OpenShop(p, pawn);
}
s.CarrierWasActive = carrierActive;
if (!s.Open) return;
var vel = pawn.AbsVelocity; vel.X = vel.Y = vel.Z = 0f; // hold the freeze
if ((p.Buttons & PlayerButtons.Duck) != 0) { CloseShop(p.Slot, switchAway: true); return; } // crouch = quick exit
bool neutralMelee = IsNeutralMelee(activeName);
if (neutralMelee) s.SawNeutral = true; // rested on the knife -> input is now live
if (carrierActive || neutralMelee)
{
s.LastActive = ""; // resting (on the carrier mid-toggle, or the knife neutral) — ready for the next press
}
else
{
char key = WeaponToKey(activeName);
// Accept a pick only once the weapon has SETTLED back on the neutral: not yet rested on the knife since
// opening (!SawNeutral), or a rest fired within the debounce window (the slot-select toggle / a re-deploying
// weapon is still in flight), means input isn't trustworthy yet — rest and wait. No fixed time grace; the
// settle itself is the spacing, so picks register as fast as the weapon can return to neutral (instabuy).
if (key == '\0' || !s.SawNeutral || Server.CurrentTime - s.RestAt < RestDebounceSeconds)
{
Rest(p, s);
}
else if (activeName != s.LastActive)
{
s.LastActive = activeName;
ExecuteOption(p, s, key);
// Rest to the neutral; the settle gate above re-suppresses input until that rest lands, so the
// re-deploying weapon can't register as the next pick.
if (s.Open) Rest(p, s);
}
}
if (s.Open) UpdateShopPanels(p, pawn, s);
}
// active weapon -> the menu key it represents ('\0' = not an option / it's the knife neutral or the carrier)
private char WeaponToKey(string active)
{
if (active.Length == 0) return '\0';
if (active == EngineNames.ShopMelee) return '3'; // zeus = key 3 (co-melee with the knife)
if (IsNeutralMelee(active)) return '\0'; // knife = neutral rest, not an option
if (IsPistolWeapon(active)) return '2';
if (IsPrimaryWeapon(active)) return '1';
foreach (var g in GrenadeMenu) if (g.Weapon == active) return g.Key;
return '\0';
}
// ---- open / close / back ----
private void OpenShop(CCSPlayerController p, CCSPlayerPawn pawn)
{
var s = ShopOf(p.Slot);
// Survival between-wave draft takes priority; otherwise skills-only modes skip the root and open on the groups.
ShopScreen initial = (Draft is { } sd && sd.DraftPending(p)) ? ShopScreen.CardDraft
: _driver.WeaponShopEnabled ? ShopScreen.Root : ShopScreen.SkillGroups;
s.Open = true; s.Screen = initial; s.LastActive = "";
s.SawNeutral = false; // ignore key input until the player has rested on the knife once (kills the on-open misfire)
s.SavedMoveType = pawn.MoveType;
SetFrozen(pawn, true);
foreach (var g in GrenadeMenu) p.GiveNamedItem(g.Weapon); // populate 6-0 so those keys fire
Rest(p, s); // rest on the knife (neutral)
s.LastText = null; s.LastInfoText = null;
// The draft is a distinct 3-card overlay (panels created lazily in UpdateDraftPanels); the normal shop is the 2-panel layout.
if (initial == ShopScreen.CardDraft)
p.PrintToChat($" {ChatColors.Gold}[Draft] pick a card — switch to the shown item, X / crouch to close (unspent picks carry over).");
else
{
s.OptionsEntity = CreateShopPanel(true, Config.Shop.FontSize, Config.Shop.WorldUnitsPerPx); // left: shop options
s.InfoEntity = CreateShopPanel(false, Config.Shop.InfoFontSize, Config.Shop.InfoWorldUnitsPerPx); // right: info (smaller)
p.PrintToChat($" {ChatColors.Gold}[Shop] open — numbers select, X = back / close.");
}
}
// Close every open shop/draft (used by survival at wave start so nobody is left frozen in a menu).
internal void CloseAllShops()
{
foreach (var slot in _shop.Keys.ToList()) CloseShop(slot, switchAway: true);
}
// Auto-pop the survival draft for everyone alive on CT with pending picks. Called shortly AFTER wave-clear (so the
// revive-spawns have already fired their menu-close), opening straight into the 3-card overlay. Closeable via X / crouch.
internal void OpenDraftForAll()
{
if (!_live) return; // auto-pop is scheduled ~0.75s after wave-clear; skip if unloaded since
if (Draft is not { } sd) return;
foreach (var p in Utilities.GetPlayers())
{
if (!IsLiveHuman(p)) continue;
if (!sd.DraftPending(p)) continue;
var s = ShopOf(p.Slot);
if (s.Open) continue;
var pawn = p.PlayerPawn.Value;
if (pawn is not null) OpenShop(p, pawn); // DraftPending -> OpenShop selects the CardDraft screen
}
}
private void CloseShop(int slot, bool switchAway)
{
if (!_shop.TryGetValue(slot, out var s)) return;
bool wasOpen = s.Open;
s.Open = false; s.LastActive = "";
if (wasOpen) s.ClosedAt = Server.CurrentTime; // start the post-close input grace
s.DestroyEntities();
if (!wasOpen) return;
var p = Utilities.GetPlayerFromSlot(slot);
if (p is { IsValid: true })
foreach (var g in GrenadeMenu) p.RemoveItemByDesignerName(g.Weapon); // ability reconcile re-grants real ones
var pawn = p?.PlayerPawn.Value;
if (pawn is not null && pawn.Health > 0) SetFrozen(pawn, false, s.SavedMoveType);
// Switch to the gun the player actually has, not a hardcoded slot1 — in Gun Game the rung weapon may be a
// pistol (slot2) or, on the knife finale, only the knife (slot3); slot1 would otherwise strand them on the knife.
if (switchAway && p is { IsValid: true }) p.ExecuteClientCommand(PreferredSlotCmd(p));
}
// The client command to rest on the best NON-grenade weapon the player holds: primary, else pistol, else the
// zeus, else the knife. The zeus is preferred over the knife as the rest because the engine auto-deploys granted
// ability grenades over the KNIFE (the GG knife-rung perk bug) but not over a real weapon like the zeus.
private string PreferredSlotCmd(CCSPlayerController p)
{
// Classify all held weapons in ONE pass; keep the no-WeaponServices -> slot1 fallback (distinct from
// "has weapons but no primary" -> slot3). The inner valid-weapon walk is shared via Inventory.Weapons.
if (!Inventory.HasWeaponServices(p)) return Inventory.SlotPrimary;
bool primary = false, secondary = false, zeus = false;
foreach (var w in Inventory.Weapons(p))
{
string n = w.DesignerName;
if (n == EngineNames.ShopMelee) zeus = true;
else if (IsPrimaryWeapon(n)) primary = true;
else if (IsPistolWeapon(n)) secondary = true;
}
return primary ? Inventory.SlotPrimary : secondary ? Inventory.SlotSecondary : zeus ? Inventory.UseZeus : Inventory.SlotMelee;
}
private void Back(CCSPlayerController p, ShopSession s)
{
switch (s.Screen)
{
case ShopScreen.WeaponCats:
s.Screen = ShopScreen.Root; s.LastActive = ""; Rest(p, s); break;
case ShopScreen.SkillGroups:
if (_driver.WeaponShopEnabled) { s.Screen = ShopScreen.Root; s.LastActive = ""; Rest(p, s); }
else CloseShop(p.Slot, switchAway: true); // skills-only: SkillGroups is home -> back = close
break;
case ShopScreen.Category:
s.Screen = ShopScreen.WeaponCats; s.LastActive = ""; Rest(p, s); break;
case ShopScreen.SkillStats:
s.Screen = ShopScreen.SkillGroups; s.LastActive = ""; Rest(p, s); break;
default: // Root -> close
CloseShop(p.Slot, switchAway: true); break;
}
}
private static void SetFrozen(CCSPlayerPawn pawn, bool frozen, MoveType_t restore = MoveType_t.MOVETYPE_WALK)
{
PawnWriter.SetMoveType(pawn, frozen ? MoveType_t.MOVETYPE_NONE : restore); // schema write + notify owned by PawnWriter
if (frozen) { var v = pawn.AbsVelocity; v.X = v.Y = v.Z = 0f; }
}
// ---- screen contents ----
private List<ShopOption> CurrentOptions(CCSPlayerController p, ShopSession s, PlayerData pd)
{
var opts = new List<ShopOption>();
switch (s.Screen)
{
case ShopScreen.Root:
if (_driver.WeaponShopEnabled)
opts.Add(new('1', "Weapons", true, () => s.Screen = ShopScreen.WeaponCats));
opts.Add(new('2', "Skills", true, () => s.Screen = ShopScreen.SkillGroups));
break;
case ShopScreen.WeaponCats:
for (int c = 0; c < CategoryOrder.Length; c++)
{
int ci = c;
var list = CategoryList(CategoryOrder[c]);
opts.Add(new(MenuKey(c), $"{CategoryDisplay(CategoryOrder[c])} ({list.Count})", true,
() => { s.CatIndex = ci; s.Screen = ShopScreen.Category; }));
}
break;
case ShopScreen.Category:
{
var name = CategoryOrder[s.CatIndex];
bool primary = name != "Pistols";
var list = CategoryList(name);
for (int i = 0; i < list.Count && i < 8; i++)
{
string item = list[i];
int req = UnlockLevel(item);
bool unlocked = pd.Level >= req;
string label = unlocked ? WeaponDisplayName(item) : $"{WeaponDisplayName(item)} [L{req}]";
opts.Add(new(MenuKey(i), label, unlocked,
() => { SetWeapon(p, primary, item); CloseShop(p.Slot, switchAway: true); }));
}
break;
}
case ShopScreen.SkillGroups:
for (int g = 0; g < SkillGroupNames.Length; g++)
{
int gi = g;
opts.Add(new(ShopOptKey(g), SkillGroupNames[g], true,
() => { s.GroupIndex = gi; s.Screen = ShopScreen.SkillStats; }));
}
break;
case ShopScreen.SkillStats:
{
var keys = SkillGroupStats[s.GroupIndex];
for (int i = 0; i < keys.Length && i < 8; i++)
{
string key = keys[i];
int lvl = LevelOf(pd, key), max = DefFor(key).MaxLevel;
bool canBuy = pd.Points > 0 && lvl < max;
string label = $"{StatDisplay(key)} [{lvl}/{max}]";
opts.Add(new(ShopOptKey(i), label, canBuy, () =>
{
BuyStat(p, key);
if (pd.Points <= 0) CloseShop(p.Slot, switchAway: true); // spent your last point -> auto-exit
}));
}
break;
}
case ShopScreen.CardDraft when Draft is { } sd:
{
var draw = sd.CurrentDraw(p); // (cardKey, label), stable across reopen within a break
// Cards sit on the GRENADE keys (6/7/8), NOT weapon slots 1/2/3: the player never passively holds a
// grenade and the knife/zeus neutral-rest never produces 6-0, so the engine's weapon re-deploys can't
// auto-pick. (Weapon slots collide — key 3 = the zeus, which shares slot3 with the knife rest.)
for (int i = 0; i < draw.Count && i < GrenadeMenuKeys.Length; i++)
{
var (ckey, label) = draw[i];
opts.Add(new(GrenadeMenuKeys[i], label, true, () =>
{
sd.PickCard(p, ckey);
if (!sd.DraftPending(p)) CloseShop(p.Slot, switchAway: true); // spent your last pick -> exit, free to prep
}));
}
break;
}
}
return opts;
}
private void ExecuteOption(CCSPlayerController p, ShopSession s, char key)
{
var pd = PdOf(p);
if (pd is null) return;
foreach (var opt in CurrentOptions(p, s, pd))
if (opt.Key == key)
{
if (opt.Enabled) opt.Act();
return; // matched the key — act if enabled, then stop regardless
}
}
// The ITEM a menu key maps to — shown in the menu instead of the default slot number so it reads correctly even
// when the player has rebound their slot keys (we can't read client binds, but we CAN name the item to switch to).
// 1/2/3 = primary/pistol/zeus slots; 6-0 = the grenades (per the in-game grenade-select order).
private static string KeyItemLabel(char key) => key switch
{
'1' => "Primary",
'2' => "Pistol",
'3' => "Zeus",
_ => GrenadeLabel(key), // 6-0 grenade labels come from the GrenadeMenu table (one source)
};
private static string GrenadeLabel(char key)
{
foreach (var g in GrenadeMenu) if (g.Key == key) return g.Label;
return key.ToString();
}
private static string CategoryDisplay(string internalName) => internalName switch
{
"Smgs" => "SMGs",
"Heavy" => "Machine Guns",
_ => internalName,
};
private static string StatDisplay(string key)
{
foreach (var (k, d) in StatList) if (k == key) return d;
return key;
}
private static string ScreenTitle(ShopSession s) => s.Screen switch
{
ShopScreen.Root => "SHOP",
ShopScreen.WeaponCats => "WEAPONS",
ShopScreen.Category => CategoryDisplay(CategoryOrder[s.CatIndex]).ToUpperInvariant(),
ShopScreen.SkillGroups => "SKILLS",
ShopScreen.SkillStats => SkillGroupNames[s.GroupIndex].ToUpperInvariant(),
ShopScreen.CardDraft => "DRAFT A CARD",
_ => "SHOP",
};
// ---- world-text panels (eye-relative placement via Engine.WorldText.TryEyeFrame) ----
private CPointWorldText? CreateShopPanel(bool rightJustify, float fontSize, float unitsPerPx)
{
var s = Config.Shop;
return WorldText.Create(fontSize, unitsPerPx, s.FontName,
System.Drawing.Color.FromArgb(255, s.ColorR, s.ColorG, s.ColorB), s.DrawBackground, 0.15f,
rightJustify ? PointWorldTextJustifyHorizontal_t.POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_RIGHT
: PointWorldTextJustifyHorizontal_t.POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_LEFT);
}
private void UpdateShopPanels(CCSPlayerController p, CCSPlayerPawn pawn, ShopSession s)
{
if (!WorldText.TryEyeFrame(pawn, out var eye, out var fwd, out var right, out var up, out var ang)) return;
var cfg = Config.Shop;
// Survival draft = a distinct 3-card overlay, not the 2-panel shop.
if (s.Screen == ShopScreen.CardDraft) { UpdateDraftPanels(p, s, eye, fwd, right, up, ang); return; }
// left panel (shop options) — right-justified, sits left of centre
if (s.OptionsEntity is null || !s.OptionsEntity.IsValid) { s.OptionsEntity = CreateShopPanel(true, cfg.FontSize, cfg.WorldUnitsPerPx); s.LastText = null; }
if (s.OptionsEntity is not null)
{
var lpos = eye + fwd * cfg.ForwardOffset + right * (cfg.RightOffset - cfg.SplitOffset) + up * cfg.UpOffset;
string lt = BuildShopLeft(p, s);
if (s.LastText != lt) { WorldText.SetText(s.OptionsEntity, lt); s.LastText = lt; }
WorldText.Place(s.OptionsEntity, lpos, ang);
}
// right panel (info) — left-justified, sits right of centre
if (s.InfoEntity is null || !s.InfoEntity.IsValid) { s.InfoEntity = CreateShopPanel(false, cfg.InfoFontSize, cfg.InfoWorldUnitsPerPx); s.LastInfoText = null; }
if (s.InfoEntity is not null)
{
var rpos = eye + fwd * cfg.ForwardOffset + right * (cfg.RightOffset + cfg.SplitOffset) + up * cfg.UpOffset;
string rt = BuildShopRight(p);
if (s.LastInfoText != rt) { WorldText.SetText(s.InfoEntity, rt); s.LastInfoText = rt; }
WorldText.Place(s.InfoEntity, rpos, ang);
}
}
// left panel — the current screen's selectable options
private string BuildShopLeft(CCSPlayerController p, ShopSession s)
{
var pd = PdOf(p);
var sb = new StringBuilder();
sb.Append($"===== {ScreenTitle(s)} =====\n");
if (s.Screen == ShopScreen.CardDraft && Draft is { } sd)
sb.Append($"Picks left: {sd.PendingCards(p)} (unspent carry over)\n");
if (pd is not null)
foreach (var opt in CurrentOptions(p, s, pd))
sb.Append($"[{KeyItemLabel(opt.Key)}] {opt.Label}\n"); // show the ITEM to switch to, not the default key#
// home = Root in TDM, SkillGroups in a skills-only mode, or the survival draft -> close; deeper screens -> back
bool atHome = s.Screen is ShopScreen.Root or ShopScreen.CardDraft
|| (!_driver.WeaponShopEnabled && s.Screen == ShopScreen.SkillGroups);
sb.Append(atHome ? "[Health] / [Crouch] close\n" : "[Health] back\n");
return sb.ToString();
}
// right panel — server/about blurb (top) then the player's own stats (bottom), in a much smaller font
private string BuildShopRight(CCSPlayerController p)
{
var sb = new StringBuilder();
foreach (var line in Config.Shop.InfoLines) sb.Append(line).Append('\n'); // server / about (!info, !about)
var pd = PdOf(p);
if (pd is null) return sb.ToString();
double kd = pd.Deaths > 0 ? (double)pd.Kills / pd.Deaths : pd.Kills;
int xpPct = (int)(pd.Xp * 100 / Math.Max(1, XpToNext(pd.Level)));
sb.Append("----- YOU -----\n"); // player (!me, !stats)
sb.Append($"{RankName(pd)}\n");
sb.Append($"Lv {pd.Level} P{Roman(pd.Prestige)} XP {xpPct}% {pd.Points}pt\n");
sb.Append($"K {pd.Kills} D {pd.Deaths} KD {kd:F2} Streak {pd.Streak}\n");
var (effHs, effOut, effIn, effXp) = EffectiveMultipliers(p, pd);
sb.Append($"Out x{effOut:F2} In x{effIn:F2} HS x{effHs:F2} XP x{effXp:F2}\n");
if (_driver.WeaponShopEnabled)
{
var (pri, sec) = ResolveLoadout(p);
sb.Append($"{WeaponDisplayName(pri)} / {WeaponDisplayName(sec)}\n");
}
else if (_driver.LadderStatusLine(pd) is { } rungLine) // weapon dictated by the ladder
sb.Append(rungLine).Append('\n');
var owned = StatList.Where(st => LevelOf(pd, st.Key) > 0).ToList();
if (owned.Count > 0)
{
string Cell(int j) => $"{StatDisplay(owned[j].Key)} {LevelOf(pd, owned[j].Key)}/{DefFor(owned[j].Key).MaxLevel}";
sb.Append("- Stats -\n");
for (int i = 0; i < owned.Count; i += 2) // two columns -> wider, fewer rows
sb.Append(i + 1 < owned.Count ? $"{Cell(i)} {Cell(i + 1)}\n" : $"{Cell(i)}\n");
}
return sb.ToString();
}
// each player sees only their own shop panels
private void OnCheckTransmit_Shop(CCheckTransmitInfoList infoList) =>
HideForeignSlotEntities(infoList, _shop, static (info, s) =>
{
if (s.OptionsEntity is { IsValid: true }) info.TransmitEntities.Remove(s.OptionsEntity);
if (s.InfoEntity is { IsValid: true }) info.TransmitEntities.Remove(s.InfoEntity);
foreach (var c in s.Cards) if (c is { IsValid: true }) info.TransmitEntities.Remove(c);
});
}

View file

@ -0,0 +1,56 @@
using System.Collections.Frozen;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Outnumbered.Config;
using Outnumbered.Data;
using Outnumbered.Domain;
namespace Outnumbered;
// The single engine site that reads a player into an immutable PlayerSnapshot for the pure Domain math, plus the stat
// registry (_statDefs) the Domain resolvers consume. Everything pure (handicap / combat / progression / stat resolution)
// takes a snapshot + _statDefs and never touches a pawn/controller — so the CS2/CSSharp surface lives only here.
public sealed partial class OutnumberedPlugin
{
private const int BaseMaxHp = 100;
private const int BaseMaxArmor = 100;
// key -> StatDef, rebuilt from Config.Stats on Load + every !og_reload. This is the stat registry the Domain resolvers
// index (StatResolver/CombatResolver take it) — the key->def view of the named Config.Stats fields (which stay, for JSON
// back-compat). Projected straight from StatRegistry (Stats.cs), so a new stat is one registry row, not a line here.
// FrozenDictionary: built once per load/reload, read per-hit (OffenseMultiplier alone does 3+ lookups/hit) — frozen
// has faster reads than Dictionary. Ordinal matches Dictionary<string,_>'s default, so lookups are bit-identical.
private FrozenDictionary<string, StatDef> _statDefs = FrozenDictionary<string, StatDef>.Empty;
private void RebuildStatDefs() =>
_statDefs = StatRegistry.ToFrozenDictionary(r => r.Key, r => r.Def(Config.Stats), StringComparer.Ordinal);
// Build the immutable Domain input for one player. `p` is optional: it supplies the live pawn Health (drives the
// missing-HP fraction for Berserk) — null is fine for paths that don't read Health (XP rate, where t ignores HP).
// Upgrades + Cards are held by reference, so building a snapshot per hit/tick is allocation-free.
internal PlayerSnapshot Snapshot(PlayerData pd, CCSPlayerController? p = null)
{
var pawn = p?.PlayerPawn.Value;
double now = Server.CurrentTime; // read once for the 3 ability checks below (can't advance within this build)
return new PlayerSnapshot
{
Level = pd.Level,
Prestige = pd.Prestige,
Xp = pd.Xp,
Kills = pd.Kills,
Deaths = pd.Deaths,
HeadshotKills = pd.HeadshotKills,
Streak = pd.Streak,
Health = pawn?.Health ?? 0,
HandicapProgress = _driver?.HandicapProgress(pd) ?? 0.0,
HandicapFloor = _driver?.HandicapFloor(pd) ?? -1.0,
TeamDealMult = _driver?.TeamDealMult() ?? 1.0,
TeamTakeMult = _driver?.TeamTakeMult() ?? 1.0,
OverchargeActive = AbilityActive(pd, AbOvercharge, now), // CombatResolver gates these on Abilities.Enabled
BerserkActive = AbilityActive(pd, AbBerserk, now),
AdrenalineActive = AbilityActive(pd, AbAdrenaline, now),
Upgrades = pd.Upgrades,
Cards = _driver?.CardSource(pd), // survival run (IStatBonusSource); null in TDM/GG
};
}
}

300
Outnumbered/Stats.cs Normal file
View file

@ -0,0 +1,300 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Timers;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.Logging;
using Outnumbered.Config;
using Outnumbered.Data;
using Outnumbered.Domain;
using Outnumbered.Engine;
namespace Outnumbered;
// The stat core. Offense (Damage/Crit/Headshot) goes through the central damage hook; reactive sustain (lifesteal) on
// EventPlayerHurt; HP/Armor caps per spawn; regen on a timer. All values come from the player's upgrade levels.
// (StatKeys lives in Domain/StatKeys.cs — CSSharp-free + test-shared.)
public sealed partial class OutnumberedPlugin
{
// The passive-stat REGISTRY — one row per stat, the single place a stat's identity lives: display name, the shop SKILL
// GROUP it sits in (0/1/2), and a selector picking its LIVE StatDef out of Config.Stats (so !og_reload tunables apply).
// StatList, the _statDefs key->def registry (SnapshotBuilder.RebuildStatDefs), and SkillGroupStats all PROJECT from this,
// so adding/reordering a stat is one row here (+ a StatKeys const + the named Config.Stats field kept for JSON back-compat).
// Mirrors AbilityRegistry/AbilityInfo.Def. Order = StatList order; within a group = shop key order.
internal sealed record StatInfo(string Key, string Display, int Group, Func<StatsConfig, StatDef> Def);
internal static readonly StatInfo[] StatRegistry =
[
new(StatKeys.Damage, "Damage", 0, c => c.Damage),
new(StatKeys.CritChance, "Crit Chance", 0, c => c.CritChance),
new(StatKeys.CritDamage, "Crit Damage", 0, c => c.CritDamage),
new(StatKeys.HeadshotDamage, "Headshot Damage", 0, c => c.HeadshotDamage),
new(StatKeys.MaxHp, "Max HP", 1, c => c.MaxHp),
new(StatKeys.MaxArmor, "Max Armor", 1, c => c.MaxArmor),
new(StatKeys.Lifesteal, "Lifesteal", 2, c => c.Lifesteal),
new(StatKeys.ArmorLifesteal, "Armor Lifesteal", 2, c => c.ArmorLifesteal),
new(StatKeys.HpRegen, "HP Regen", 1, c => c.HpRegen),
new(StatKeys.ArmorRegen, "Armor Regen", 1, c => c.ArmorRegen),
new(StatKeys.Thorns, "Thorns", 2, c => c.Thorns),
new(StatKeys.XpBoost, "XP Boost", 2, c => c.XpBoost),
];
// key -> display, projected from the registry (UI + the load-time validation read it). Order = StatRegistry order.
private static readonly (string Key, string Display)[] StatList =
StatRegistry.Select(r => (r.Key, r.Display)).ToArray();
// Shop skill groups (each group rendered on the grenade-key tail). Grouped from the registry in group-index order,
// within-group in registry order — lives here (the stat hub), the canonical stat grouping, not in Shop.cs.
private static readonly string[][] SkillGroupStats =
StatRegistry.GroupBy(r => r.Group).OrderBy(g => g.Key).Select(g => g.Select(r => r.Key).ToArray()).ToArray();
private const int HitgroupHead = 1; // BaseMaxHp/BaseMaxArmor live in SnapshotBuilder.cs (same partial class)
private const double ThornsReflectCapHp = 25.0; // hard, UNCONFIGURABLE cap on a single thorns reflect — a high thorns % can't trivialize waves
private CounterStrikeSharp.API.Modules.Timers.Timer? _regenTimer;
private readonly HashSet<ulong> _dmgReadout = new(); // !dmg — players seeing the per-hit final-damage readout
private readonly Dictionary<int, bool> _critPending = new(); // victim slot -> was this hit a crit (damage hook -> EventPlayerHurt, for crit XP)
private static string HitgroupName(int h) => h switch
{
1 => "HEAD",
2 => "chest",
3 => "stomach",
4 or 5 => "arm",
6 or 7 => "leg",
_ => "body",
};
private void Initialize_Stats()
{
RebuildStatDefs(); // build the key->StatDef registry the Domain resolvers index (rebuilt on !og_reload)
// StatList, _statDefs and SkillGroupStats all PROJECT from StatRegistry, so their agreement is guaranteed by
// construction. The one thing the registry can't prevent: a skill group with more stats than the shop can address
// (each group renders on the grenade-key tail), which would leave the extras unreachable in the menu.
foreach (var g in SkillGroupStats)
if (g.Length > GrenadeMenuKeys.Length)
Logger.LogError("Outnumbered: a SkillGroupStats group has {N} stats but only {K} shop keys are addressable — the extras can't be selected.", g.Length, GrenadeMenuKeys.Length);
// SkillGroupStats is derived from StatRegistry's distinct Group values; SkillGroupNames[i] labels group i. The one
// coupling the registry can't enforce: a new group (a stat with a higher Group index) needs a matching name, else
// the shop would index past SkillGroupNames when rendering that group's header.
if (SkillGroupStats.Length != SkillGroupNames.Length)
Logger.LogError("Outnumbered: {N} skill groups but {M} SkillGroupNames labels — add a name for each group.", SkillGroupStats.Length, SkillGroupNames.Length);
RegisterListener<Listeners.OnEntityTakeDamagePre>(OnEntityTakeDamagePre);
RegisterEventHandler<EventPlayerHurt>(OnPlayerHurt_Stats);
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn_Stats);
_regenTimer = AddTimer(Config.Stats.RegenIntervalSeconds, RegenTick, TimerFlags.REPEAT);
}
private void Shutdown_Stats() => _regenTimer?.Kill();
// ---- stat-resolution bridge: pd -> Domain. The FORMULAS live in Outnumbered.Domain.StatResolver; these are
// zero-math accessors that snapshot the player and call it, so cold/UI/cross-file sites stay ergonomic. The hot
// damage hook + HUD skip these and call CombatResolver with ONE snapshot (the shared offense/defense chain). ----
private StatDef DefFor(string key) => _statDefs.TryGetValue(key, out var d) ? d : new StatDef(0, 0);
private static int LevelOf(PlayerData pd, string key) => pd.Upgrades.TryGetValue(key, out var l) ? l : 0;
private double Eff(PlayerData pd, string key) => StatResolver.Eff(Snapshot(pd), key, _statDefs);
// Magnitude of a survival EFFECT card (a CardKeys.* key, NOT a stat) = picks x PerPick via the driver; 0 outside a run.
// The engine/live-path accessor for the non-snapshot presence checks (burn/explode/cdr) — distinct from the
// snapshot-pure Domain StatResolver.CardMag used inside CombatResolver.
private double EffectCardMag(PlayerData pd, string key) => _driver?.StatBonus(pd, key) ?? 0.0;
private int MaxHpOf(PlayerData pd) => StatResolver.MaxHp(Snapshot(pd), _statDefs, BaseMaxHp);
private int MaxArmorOf(PlayerData pd) => StatResolver.MaxArmor(Snapshot(pd), _statDefs, BaseMaxArmor);
// Snapshot-level overloads: when a caller already built ONE snapshot (the hot reactive-sustain + regen paths), reuse it
// across several stat reads instead of the pd-overloads rebuilding Snapshot(pd) per accessor. Identical result (the
// pd-overloads just call these on a fresh Snapshot(pd)), computed once.
private double EffRun(in PlayerSnapshot s, string key) => StatResolver.EffRun(s, key, _statDefs);
private int MaxHpOf(in PlayerSnapshot s) => StatResolver.MaxHp(s, _statDefs, BaseMaxHp);
private int MaxArmorOf(in PlayerSnapshot s) => StatResolver.MaxArmor(s, _statDefs, BaseMaxArmor);
// ---- damage hook (offense + defense). Both multiplier chains come from the SHARED Domain CombatResolver (so the
// HUD readout can never drift from what actually applies); the hook keeps only the engine bits — pawn->controller
// resolution, the crit roll plus its side effects (sound + crit-XP marker), and writing info.Damage. ----
private HookResult OnEntityTakeDamagePre(CBaseEntity entity, CTakeDamageInfo info)
{
// An unscaled plugin hit is in flight (raw burn DoT, or a flat thorns reflect): apply the damage exactly as set —
// no offense scaling, no crit roll. Re-entrancy guard, reset in DamageDealer.Deal's finally.
if (DamageDealer.Unscaled) return HookResult.Continue;
// pawn designer-name varies by build ("cs_player_pawn" or "player") — catalog in EngineNames
if (entity is not { IsValid: true } || !EngineNames.PlayerPawnDesigners.Contains(entity.DesignerName))
return HookResult.Continue;
var attacker = ControllerOfPawn(info.Attacker.Value?.As<CCSPlayerPawn>());
var victim = ControllerOfPawn(new CCSPlayerPawn(entity.Handle));
// OFFENSE — a human dealing damage: build x crit x abilities x M_deal (the whole chain in CombatResolver).
if (attacker is { IsValid: true } && !attacker.IsBot)
{
var asid = attacker.AuthorizedSteamID?.SteamId64;
if (asid is not null && _players.TryGetValue(asid.Value, out var apd))
{
// GG speedrun clock: arms on the run's FIRST damage dealt-or-taken (self-damage included — arming early
// can only lengthen your time, so it's abuse-proof). Hot path: one bool + one field check, nothing else.
if (_ggTimerActive && apd.GgRunStartedAtMs == 0)
apd.GgRunStartedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (victim is { IsValid: true } && victim.Slot == attacker.Slot) return HookResult.Continue; // no self-scaling
bool headshot = (int)info.GetHitGroup() == HitgroupHead; // pre-engine-HS; engine applies its ~4x afterward
var s = Snapshot(apd, attacker);
double critChance = CombatResolver.CritChance(s, _statDefs);
bool crit = critChance > 0 && Random.Shared.NextDouble() * 100.0 < critChance;
if (crit) PlaySound(attacker, Config.Sounds.Crit); // no-op unless a Crit sound is configured
if (victim is { IsValid: true }) _critPending[victim.Slot] = crit; // remembered for crit XP in EventPlayerHurt
info.Damage = (float)(info.Damage *
CombatResolver.OffenseMultiplier(s, headshot, crit, _statDefs, _hcap, Config.Abilities, BaseMaxHp));
return HookResult.Continue;
}
}
// DEFENSE — a human taking damage (from a bot/world): x M_take x Adrenaline x headshot-armor card.
if (victim is { IsValid: true } && !victim.IsBot)
{
var vsid = victim.AuthorizedSteamID?.SteamId64;
if (vsid is not null && _players.TryGetValue(vsid.Value, out var vpd))
{
// GG speedrun clock, taken-damage side (covers bot AND world/fall damage — the attacker branch never sees those).
if (_ggTimerActive && vpd.GgRunStartedAtMs == 0)
vpd.GgRunStartedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
bool headshot = (int)info.GetHitGroup() == HitgroupHead;
info.Damage = (float)(info.Damage *
CombatResolver.DefenseMultiplier(Snapshot(vpd, victim), headshot, _hcap, Config.Abilities));
}
}
return HookResult.Continue;
}
// ---- reactive sustain + last-damage tracking ----
private HookResult OnPlayerHurt_Stats(EventPlayerHurt ev, GameEventInfo info)
{
var victim = ev.Userid;
var attacker = ev.Attacker;
// THORNS — a human hit by a bot reflects a % of the damage taken back onto that bot. The reflect is dealt as
// real, attributed damage (so it can kill and credits the player for the kill/XP/GG-rung) and re-enters the
// offense hook in OnEntityTakeDamagePre, so it scales with the player's damage build AND the handicap
// (a nerfed steamroller's thorns are nerfed too — no bypass; a buffed weak player's thorns hit harder).
if (victim is { IsValid: true } && !victim.IsBot
&& attacker is { IsValid: true } && attacker.IsBot && attacker.Slot != victim.Slot
&& PdOf(victim) is { } tpd)
{
var st = Snapshot(tpd); // ONE snapshot: thorns % + both steal reads
double thorns = EffRun(st, StatKeys.Thorns);
// Thorns is OFF while the reflector holds a knife or the zeus: a GG knife/zeus finale must be won by a REAL melee
// kill, not by tanking hits and letting the reflect kill the bot. (The active-weapon probe runs only once thorns is
// actually in play — the cheap thorns/damage checks short-circuit first.)
if (thorns > 0 && ev.DmgHealth + ev.DmgArmor > 0
&& !Inventory.IsMeleeOrZeus(Inventory.ActiveWeaponName(victim.PlayerPawn.Value)))
{
// Thorns reflects a straight % of the damage ACTUALLY taken (already includes the MTake handicap that sized the
// hit — a 5x-handicap 250 HP hit at 10% sends 25 back), dealt FLAT so the offense hook neither scales it by the
// build nor lets the handicap nerf it. Clamp to [1, ThornsReflectCapHp]: floor so a tiny hit still reflects,
// hard cap so a high thorns % can't trivialize waves.
double reflect = Math.Clamp(CombatResolver.ThornsReflect(ev.DmgHealth, ev.DmgArmor, thorns), 1.0, ThornsReflectCapHp);
float dealt = DamageDealer.Deal(attacker, victim, (float)reflect, flat: true); // target = the bot, source = the human
// The TakeDamageOld invoke doesn't raise player_hurt, so the lifesteal block below never sees the reflected
// hit — apply the reflector's lifesteal/armor-lifesteal here, on the actual damage dealt.
if (dealt > 0 && victim.PlayerPawn.Value is { } vpawn)
{
// No crit on the reflect's steal, so critMult=1.0 — same formula as the on-hit path (CombatResolver).
double ls = EffRun(st, StatKeys.Lifesteal);
if (ls > 0) PawnWriter.AddHealthCapped(vpawn, CombatResolver.LifestealHeal(dealt, ls, 1.0, Config.Stats.LifestealMinHeal), MaxHpOf(st));
double als = EffRun(st, StatKeys.ArmorLifesteal);
if (als > 0) PawnWriter.AddArmorCapped(vpawn, CombatResolver.ArmorLifestealGain(dealt, als, 1.0), MaxArmorOf(st));
}
}
}
if (attacker is { IsValid: true } && !attacker.IsBot && victim is { IsValid: true } && attacker.Slot != victim.Slot)
{
var asid = attacker.AuthorizedSteamID?.SteamId64;
if (asid is not null)
{
if (_dmgReadout.Contains(asid.Value)) // !dmg — true final damage applied (post engine-HS + armor)
attacker.PrintToChat($" {ChatColors.Gold}[dmg]{ChatColors.Default} {HitgroupName(ev.Hitgroup)} | hp {ev.DmgHealth} + armor {ev.DmgArmor} = {ev.DmgHealth + ev.DmgArmor}");
// Burn card (survival): EVERY hit on a bot (re)applies the DoT — flat, armor-skipping, per-attacker; the
// tick timer (Effects.cs) deals it. Independent of DmgHealth so even an armor-only hit still ignites.
if (victim.IsBot && _players.TryGetValue(asid.Value, out var burnPd) && EffectCardMag(burnPd, CardKeys.Burn) > 0)
RegisterBurn(victim.Slot, attacker.Slot, asid.Value);
if (_players.TryGetValue(asid.Value, out var apd) && ev.DmgHealth > 0)
{
bool wasCrit = _critPending.Remove(victim.Slot, out bool c) && c; // this hit's crit (set in the damage hook), shared by XP + lifesteal
// XP on damage: final HP damage × rate + flat headshot/crit bonuses (only for damage to bots).
// The kill bonus is granted separately on EventPlayerDeath. Multipliers apply inside GrantXp.
if (victim.IsBot)
{
double xpBase = ev.DmgHealth * Config.Progression.DamageXpPerHp;
if (ev.Hitgroup == HitgroupHead) xpBase += Config.Progression.HeadshotXpBonus;
if (wasCrit) xpBase += Config.Progression.CritXpBonus;
GrantCombatXp(apd, xpBase, attacker); // survival: banked raw into the run accumulator; else GrantXp
}
// Bloodthirst adds flat lifesteal % for its duration, on top of the stat.
double lsBonus = 0.0, alsBonus = 0.0;
if (Config.Abilities.Enabled && AbilityActive(apd, AbBloodthirst))
{
lsBonus = Config.Abilities.Bloodthirst.Magnitude;
alsBonus = Config.Abilities.Bloodthirst.Magnitude2;
}
double critLs = wasCrit ? Config.Stats.CritLifestealMultiplier : 1.0; // crit hits steal extra (default +50%)
if (attacker.PlayerPawn.Value is { } apawn)
{
var sa = Snapshot(apd); // ONE snapshot shared by both lifesteal reads + their caps
double ls = EffRun(sa, StatKeys.Lifesteal) + lsBonus;
if (ls > 0) PawnWriter.AddHealthCapped(apawn, CombatResolver.LifestealHeal(ev.DmgHealth, ls, critLs, Config.Stats.LifestealMinHeal), MaxHpOf(sa));
double als = EffRun(sa, StatKeys.ArmorLifesteal) + alsBonus;
if (als > 0) PawnWriter.AddArmorCapped(apawn, CombatResolver.ArmorLifestealGain(ev.DmgHealth, als, critLs), MaxArmorOf(sa));
}
}
}
}
return HookResult.Continue;
}
// ---- per-spawn HP/Armor caps (humans; bots get fixed HP from the driver) ----
private HookResult OnPlayerSpawn_Stats(EventPlayerSpawn ev, GameEventInfo info)
{
var p = ev.Userid;
if (!IsHuman(p)) return HookResult.Continue;
NextFrameForSlot(p.Slot, pl => { if (PdOf(pl) is { } pd) ApplyMaxHpArmor(pl, pd); }, requireAlive: true);
return HookResult.Continue;
}
private void ApplyMaxHpArmor(CCSPlayerController p, PlayerData pd)
{
var pawn = p.PlayerPawn.Value;
if (pawn is null || pawn.Health <= 0) return;
int maxHp = MaxHpOf(pd);
PawnWriter.SetMaxHealth(p, pawn, maxHp); // max BEFORE health, else health clamps to 100
PawnWriter.SetHealth(pawn, maxHp);
PawnWriter.SetArmor(pawn, MaxArmorOf(pd));
}
// Regen is ALWAYS ON (no out-of-combat gate — so it's useful mid-fight/clutch) and FLAT, not %-of-max (so it doesn't
// balloon on a maxed-HP tank). HP/armor restored per tick = the stat's flat value (EffRun) — i.e. 1/sec/level at the
// default 1s interval, + any survival regen card. Deliberately small vs a big HP pool: a slow rescue, not a fountain.
private void RegenTick()
{
foreach (var p in Utilities.GetPlayers())
{
if (!IsLiveHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick timer)
var pd = PdOf(p);
if (pd is null) continue;
var pawn = p.PlayerPawn.Value;
if (pawn is null || pawn.Health <= 0) continue;
// FLAT HP/armor per tick (always on), capped at the build's max — the writes live in PawnWriter.
var s = Snapshot(pd); // ONE snapshot shared by the HP + armor regen reads + their caps
PawnWriter.AddHealthCapped(pawn, (int)Math.Round(EffRun(s, StatKeys.HpRegen)), MaxHpOf(s));
PawnWriter.AddArmorCapped(pawn, (int)Math.Round(EffRun(s, StatKeys.ArmorRegen)), MaxArmorOf(s));
}
}
}

594
Outnumbered/Survival.cs Normal file
View file

@ -0,0 +1,594 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Timers;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.Logging;
using Outnumbered.Config;
using Outnumbered.Data;
using Outnumbered.Domain;
using Outnumbered.Engine;
namespace Outnumbered;
// Wave Survival ("Last Stand") — co-op, escalating bot waves on the small arms-race maps. The whole RPG core rides
// along unchanged. Design (research/survival-mode-design.md): VANILLA bots (100hp/100armor, never tougher) — the
// difficulty curve is (a) MORE bots up to AliveCap, and (b) the inherited handicap floor tightening every wave; the
// counter-pressure is the roguelite DRAFT (strong run-scoped cards via EffRun). You outscale until the floor + horde
// finally win. Combat XP is banked RAW per wave and granted to the main table at EACH wave clear (x prestige x waveMult),
// so players level + buy skills mid-run; an uncleared wave is forfeited on a wipe.
//
// State machine: Idle -> (humans present) -> Fighting wave N -> (kills == budget) -> ClearWave (grant wave XP) -> Break
// (revive + draft) -> wave N+1 -> ... -> clear WaveCount = WIN; all humans dead / wave timeout = LOSE -> map end.
public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
{
private readonly OutnumberedPlugin _p;
public SurvivalDriver(OutnumberedPlugin p) => _p = p;
private enum WavePhase { Idle, Fighting, Break }
private readonly Dictionary<ulong, SurvivalRun> _runs = []; // per-player run state (RAM-only; run ends on disconnect)
private WavePhase _phase = WavePhase.Idle;
private int _wave; // current wave (0 = not started)
private int _killsThisWave;
private int _waveBudget; // kills needed to clear the current wave (locked at wave start)
private int _highestWaveCleared;
private bool _runActive;
private bool _ready; // the server is up + a map is loaded (set from OnMatchReset/OnMapSetup, NOT Load) — gates WaveTick
private bool _runEnded; // terminal latch: a run finished on THIS map -> no auto-restart until the next map
private double _phaseUntil; // Server.CurrentTime the break ends
private double _lastKillAt; // last credited kill — the stall-nudge + timeout measure PROGRESS, not wall-time
private double _lastNudgeAt; // last stall-nudge, so stalled bots aren't re-pulled every tick
private double _mapReadyAt; // don't start a run until the map has settled + players can spawn
private CounterStrikeSharp.API.Modules.Timers.Timer? _tick;
// TEAM cards (global_deal / global_take): ONE shared squad level each (0..Cap), incremented by ANY survivor's pick,
// applied to EVERY survivor via the cached multipliers (read per-hit from Handicap.MDeal/MTake -> kept as fields, not
// recomputed each call). Reset per run. RecomputeTeamMults runs on pick / run start (live PerPick edits take on next pick).
private int _teamDealLevel;
private int _teamTakeLevel;
private double _teamDealMult = 1.0;
private double _teamTakeMult = 1.0;
// ---- IMatchDriver: identity ----
public string Id => "survival";
public IReadOnlyList<string> Maps =>
_p.Config.Survival.Maps.Count > 0 ? _p.Config.Survival.Maps : _p.Config.Match.Maps;
public bool WeaponShopEnabled => true; // players choose their guns; cards are a separate draft
public HandicapOverride? Handicap => _p.Config.Survival.Handicap;
public int MaxHumansOnCt => _p.Config.Survival.MaxHumansOnCt;
// Nobody auto-respawns: humans are revived at wave-clear (ReviveSurvivor); bots are spawned/streamed entirely by the
// wave machine (force-Respawn in UpdateBotQuota). Engine auto-respawn for bots is OFF so it can't fight the wave drain
// AND so we don't depend on it spawning bots (which it refuses after repeated T-side wipes — the wave-3 stall).
public string ExtraCvars => "mp_randomspawn 1;mp_respawn_on_death_ct 0;mp_respawn_on_death_t 0;";
public double HandicapProgress(PlayerData pd) => 0.0; // survival escalates via the wave FLOOR, not the progress axis
public bool OwnsBotPopulation => true; // the wave machine drives bot_quota, not the core's SyncBots
public bool RunInProgress => _runActive; // EnforceHumanTeam fail-closed: no mid-run joins
// Status API extras: the wave machine at a glance (site server cards show "Wave X/Y").
public object? StatusExtra() =>
new { Wave = _wave, WaveCount = _p.Config.Survival.WaveCount, Phase = _phase.ToString(), RunActive = _runActive };
// ---- loadout ----
public void GiveHumanLoadout(CCSPlayerController p) => _p.GiveChosenLoadout(p);
public void GiveBotLoadout(CCSPlayerController bot) => _p.GiveStandardBotLoadout(bot); // vanilla bots, vanilla HP — only the COUNT escalates
// ---- IMatchDriver: per-kill / death results ----
// A human killed a bot — wave progress. (PvE: a human's victim is always a bot.)
public void OnHumanKill(CCSPlayerController attacker, PlayerData apd)
{
if (!_runActive || _phase != WavePhase.Fighting) return;
_killsThisWave++;
_lastKillAt = Server.CurrentTime; // progress made -> reset the stall-nudge / timeout window
UpdateBotQuota(); // shrink the quota immediately so drain-zone kills aren't spuriously re-spawned (WaveTick clears the wave)
}
public void OnBotKill(CCSPlayerController bot) { } // a bot killed a human -> no wave progress; wipe handled in OnHumanDeath
public void OnHeadshotDeath(CCSPlayerController victim) { } // no ladder in survival
// A human died: no team switch (mp_respawn_on_death_ct 0 keeps them dead = auto death-spectate; revived at wave-clear).
// Run wipe detection next frame (count after the pawn is actually down).
public void OnHumanDeath(CCSPlayerController victim, PlayerData pd)
{
if (!_runActive) return;
Server.NextFrame(() =>
{
if (_runActive && _phase == WavePhase.Fighting && AliveCtHumans() == 0)
EndRun("the squad was wiped out", win: false);
});
}
// A human left mid-run: every CLEARED wave's XP is already in their main table (granted at each wave clear), so there's
// nothing to bank here — the in-progress (uncleared) wave is forfeited. Just forget the run.
public void OnHumanDisconnect(ulong steamId, PlayerData pd) => _runs.Remove(steamId);
// ---- IMatchDriver: cards (EffRun overlay) ----
// The active run is the snapshot's card source; null outside a run (Snapshot.Cards then null -> Domain reads 0).
public IStatBonusSource? CardSource(PlayerData pd) =>
_runActive && _runs.TryGetValue(pd.SteamId, out var run) ? run : null;
// Per-player card magnitude for the non-snapshot effect checks (burn/explode/cdr presence). Mirrors CardSource.
public double StatBonus(PlayerData pd, string key) => CardSource(pd)?.Bonus(key) ?? 0.0;
// ---- TEAM cards (global_deal / global_take): squad-wide, folded into MDeal / MTake (Handicap.cs) ----
private bool IsTeamCard(string key) => CardDef(key)?.IsTeam == true; // data-driven (SurvivalCardDef.IsTeam)
public double TeamDealMult() => _runActive ? _teamDealMult : 1.0; // +dmg dealt, compounding (>=1)
public double TeamTakeMult() => _runActive ? _teamTakeMult : 1.0; // -dmg taken, compounding (<=1)
// Current level of a card: team cards use the shared squad counter; everything else is the per-player pick count.
private int CardCount(SurvivalRun run, string key) =>
key == CardKeys.GlobalDeal ? _teamDealLevel :
key == CardKeys.GlobalTake ? _teamTakeLevel :
run.Cards.GetValueOrDefault(key);
// Recompute the cached team multipliers from the shared levels + the cards' live PerPick (compounding per level).
private void RecomputeTeamMults()
{
_teamDealMult = SurvivalEconomy.TeamMult(_teamDealLevel, CardDef(CardKeys.GlobalDeal)?.PerPick ?? 0.0, increase: true);
_teamTakeMult = SurvivalEconomy.TeamMult(_teamTakeLevel, CardDef(CardKeys.GlobalTake)?.PerPick ?? 0.0, increase: false);
}
private void ResetTeamBuffs() { _teamDealLevel = 0; _teamTakeLevel = 0; _teamDealMult = 1.0; _teamTakeMult = 1.0; }
// ---- IMatchDriver: the monotonic, escalate-only handicap floor (in t-space) ----
public double HandicapFloor(PlayerData pd) => // -1 = no floor (idle / between runs); monotonic in wave
_runActive ? SurvivalEconomy.HandicapFloor(_wave, _p.Config.Survival) : -1.0;
// ---- IMatchDriver: lifecycle ----
public void OnActivated()
{
// Runs during plugin LOAD — the engine globals aren't ready yet, so touching the engine here (e.g.
// Server.CurrentTime) SIGSEGVs the server. Only SCHEDULE the heartbeat (AddTimer is safe at Load); it stays
// gated by _ready until the first map setup arms it. _ready/_mapReadyAt are set in OnMatchReset + OnMapSetup.
_tick ??= _p.AddTimer(0.5f, WaveTick, TimerFlags.REPEAT); // the wave state machine heartbeat (also drives bot_quota)
}
// Plugin Unload / hot-reload: kill the heartbeat so it can't leak or double-fire against a torn-down instance
// (a fresh driver schedules its own _tick on the next Load). The framework doesn't reliably auto-kill plugin timers.
public void OnDeactivated() { _tick?.Kill(); _tick = null; }
// Called once the map is set up (Driver.SetupMap — covers both normal map start and a hot-reload mid-map), i.e. the
// server is simulating and engine access is safe. This is what actually arms the wave machine.
public void OnMapSetup()
{
_ready = true;
_mapReadyAt = Server.CurrentTime + _p.Config.Survival.StartGraceSeconds;
}
public void OnMatchReset()
{
_runs.Clear(); ResetTeamBuffs();
_phase = WavePhase.Idle; _wave = 0; _killsThisWave = 0; _waveBudget = 0;
_highestWaveCleared = 0; _runActive = false; _runEnded = false; _phaseUntil = 0;
_ready = true; // OnMatchReset only runs on a real map start (server up) -> safe to read the clock + arm
_mapReadyAt = Server.CurrentTime + _p.Config.Survival.StartGraceSeconds;
}
// ---- the wave state machine ----
private void WaveTick()
{
if (!_ready) return; // don't touch the engine until a map is set up (the heartbeat can fire during boot)
ManageBots(); // keep the quota in sync with the current phase (also the SyncBots delegate target)
double now = Server.CurrentTime;
if (!_runActive)
{
// _runEnded gates the restart: after a run ends, TriggerMapEnd only schedules the changelevel ~6s later, and
// this heartbeat keeps firing on the dying map — without the latch a still-alive squad (a WIN) would spawn a
// phantom wave 1 (and a fresh, re-bankable run accumulator). Cleared on the next map (OnMatchReset).
if (!_runEnded && now >= _mapReadyAt && AliveCtHumans() > 0) StartRun();
return;
}
switch (_phase)
{
case WavePhase.Fighting:
if (AliveCtHumans() == 0) { EndRun("the squad was wiped out", win: false); break; }
if (_killsThisWave >= _waveBudget) { ClearWave(); break; }
// No KILLS for a while = the last bot(s) can't reach the squad (or everyone's standing still). Pull the
// stragglers to the players instead of failing the run. Only the genuine deadlock (still no kills
// WaveTimeoutSeconds after the last one, despite repeated nudges) fails out. Both windows measure
// time-since-last-KILL, not wall-clock.
if (now - _lastKillAt > _p.Config.Survival.StallNudgeSeconds && now - _lastNudgeAt > _p.Config.Survival.StallNudgeSeconds)
NudgeStalledBots(now);
if (now - _lastKillAt > _p.Config.Survival.WaveTimeoutSeconds) EndRun($"wave {_wave} stalled out", win: false);
break;
case WavePhase.Break:
// Unspent draft picks BANK to future breaks (RunPoints persists) — a missed break isn't a lost card;
// the player can spend the backlog in any later break. No forced auto-pick.
if (now >= _phaseUntil) StartWave(_wave + 1);
break;
}
}
private void StartRun()
{
_runActive = true; _highestWaveCleared = 0; _runs.Clear(); ResetTeamBuffs();
Server.PrintToChatAll($" {ChatColors.Gold}[Survival] {ChatColors.Default}LAST STAND — clear {ChatColors.Lime}{_p.Config.Survival.WaveCount}{ChatColors.Default} waves to win. Good luck.");
StartWave(1);
}
private void StartWave(int n)
{
var cfg = _p.Config.Survival;
_wave = n; _phase = WavePhase.Fighting; _killsThisWave = 0;
_lastKillAt = _lastNudgeAt = Server.CurrentTime;
int humans = Math.Max(1, AliveCtHumans());
_waveBudget = SurvivalEconomy.WaveBudget(n, humans, cfg);
_p.LogSurvival($"StartWave {n}: aliveTarget={AliveForWave(n)} budget={_waveBudget} curBots={BotCount()} humans={humans}");
_p.CloseAllShops(); // nobody frozen in a menu when the wave starts
UpdateBotQuota();
Server.PrintToChatAll($" {ChatColors.Red}[Survival] WAVE {n}/{cfg.WaveCount} {ChatColors.Default}— {ChatColors.LightYellow}{_waveBudget}{ChatColors.Default} hostiles inbound. Hold the line!");
}
private void ClearWave()
{
var cfg = _p.Config.Survival;
_highestWaveCleared = _wave;
_p.LogSurvival($"ClearWave {_wave} (kills={_killsThisWave}/{_waveBudget}); break={cfg.WaveBreakSeconds}s -> next StartWave {_wave + 1}");
// Grant THIS cleared wave's XP now (per-wave): raw x prestige x waveMult(wave), straight to the main table, then
// reset each accumulator. Players spend the resulting points in the break (!skills). A wipe/leave mid-wave forfeits
// the in-progress wave — only a CLEARED wave banks. Iterate _runs by SteamId (not just CtHumans) so a participant
// who slipped to spectator/T isn't dropped.
List<ulong>? cleared = null;
foreach (var kv in _runs)
{
var run = kv.Value;
if (run.WaveXp <= 0) continue;
// Best-wave latch: same bound as the XP bank — you contributed damage THIS wave, you get wave credit.
// (A spectator idling in _runs from earlier waves earns nothing; improve-only keeps their real peak.)
(cleared ??= new(_runs.Count)).Add(kv.Key);
if (_p.PdBySteamId(kv.Key) is { } pd) _p.BankWaveXp(pd, run.WaveXp, _wave, ControllerFor(kv.Key));
run.WaveXp = 0;
}
// Before the victory early-return, or the final wave's clear would never reach the leaderboard.
if (cleared is not null) _p.RecordBestWaves(cleared, _wave);
if (_wave >= cfg.WaveCount) { EndRun($"VICTORY — all {cfg.WaveCount} waves cleared!", win: true); return; }
_phase = WavePhase.Break; _phaseUntil = Server.CurrentTime + cfg.WaveBreakSeconds;
UpdateBotQuota(); // Break -> cull the field for the break (steady pool, no kick)
ReviveDead();
GrantCards();
Server.PrintToChatAll($" {ChatColors.Green}[Survival] Wave {_wave} cleared! {ChatColors.Default}{cfg.WaveBreakSeconds:0}s to prep — the draft pops up ({ChatColors.Gold}X{ChatColors.Default}/crouch to close).");
_p.AddTimer(0.75f, _p.OpenDraftForAll); // auto-pop the card overlay once the revive-spawns have settled
}
private void EndRun(string reason, bool win)
{
_p.LogSurvival($"EndRun: {reason} (win={win}, highestWaveCleared={_highestWaveCleared}, wave={_wave}, kills={_killsThisWave}/{_waveBudget})");
// Per-wave XP was already granted into the main table at each ClearWave; an in-progress (uncleared) wave is
// forfeited. So there's nothing to bank at run-end — just drop the runs.
_runs.Clear();
_runActive = false; _runEnded = true; _phase = WavePhase.Idle; _wave = 0;
UpdateBotQuota(); // quota 0
Server.PrintToChatAll($" {(win ? ChatColors.Gold : ChatColors.Red)}[Survival] {ChatColors.Default}{reason} (reached wave {_highestWaveCleared}).");
_p.TriggerMapEnd(win ? "Survival: VICTORY" : "Survival: run over");
}
// ---- bots: vanilla, count-throttled via bot_quota ----
public void ManageBots()
{
OutnumberedPlugin.ForceBotsToTerrorist(Utilities.GetPlayers()); // any bot on CT -> back to T (bot_join_team t only affects new bots)
UpdateBotQuota();
}
// Bots ALIVE at once this wave (the simultaneous pressure) — ramps from AliveBase up to AliveCap. Distinct from the
// kill budget: you face this many at a time (they respawn) until the wave's total kills are reached.
private int AliveForWave(int wave) => SurvivalEconomy.AliveForWave(wave, _p.Config.Survival);
// Keep a STEADY connected pool for the whole run: bot_quota stays at AliveCap and is NEVER toggled back to 0 between
// waves. Repeatedly kicking (quota 0) + re-adding bots churns the engine's fake-client slots, which run out after a
// few waves and then wedge ALL further adds. The pool stays connected (mostly dead at low waves); the per-wave ALIVE
// count is set by Respawn (refill) / CommitSuicide (cull). bot_add_t only bootstraps the pool ONCE at run start
// (0 -> AliveCap, near round start). DEADLOCK-FREE: _killsThisWave advances only on credited human kills,
// suicides/respawns don't, and ClearWave fires on kills >= budget.
private void UpdateBotQuota()
{
int cap = _p.Config.Survival.AliveCap;
Server.ExecuteCommand($"bot_quota {(_runActive ? cap : 0)}");
if (!_runActive) return;
var bots = OutnumberedPlugin.Bots().ToList();
if (bots.Count < cap) // bootstrap / replace any lost pool members — should fire ~once per run, not per wave
{
_p.LogSurvival($"pool: connected={bots.Count} cap={cap} -> +{cap - bots.Count} bot_add_t");
for (int i = bots.Count; i < cap; i++) Server.ExecuteCommand("bot_add_t");
}
int want = _phase == WavePhase.Fighting
? Math.Clamp(Math.Min(AliveForWave(_wave), _waveBudget - _killsThisWave), 0, cap)
: 0; // break / idle: clear the field
int alive = bots.Count(p => p.PawnIsAlive);
if (alive < want) // refill: spawn dead pool bots up to `want` (also the in-wave streaming respawn)
foreach (var b in bots)
{
if (alive >= want) break;
if (!b.PawnIsAlive) { b.Respawn(); alive++; }
}
else if (alive > want) // cull: kill excess down to `want` (break clear / over-count). Suicide => no kill credit
foreach (var b in bots)
{
if (alive <= want) break;
if (b.PawnIsAlive) { b.CommitSuicide(false, true); alive--; }
}
}
// The wave stalled (no kills for a while) — teleport the alive bots next to a random alive CT human so the wave can
// always be finished without the squad having to chase the last stragglers. Avoids "the match ended but bots are alive".
private void NudgeStalledBots(double now)
{
_lastNudgeAt = now;
var targets = CtHumans().Where(h => h.PawnIsAlive && h.PlayerPawn.Value?.AbsOrigin is not null).ToList();
if (targets.Count == 0) return;
int moved = 0;
foreach (var b in Utilities.GetPlayers())
{
if (!OutnumberedPlugin.IsLiveBot(b)) continue;
var botPawn = b.PlayerPawn.Value;
var dest = targets[Random.Shared.Next(targets.Count)].PlayerPawn.Value?.AbsOrigin;
if (botPawn is null || dest is null) continue;
// a modest offset around the player so they don't all telefrag the same point (small maps are open enough)
var pos = new Vector(dest.X + Random.Shared.Next(-128, 128), dest.Y + Random.Shared.Next(-128, 128), dest.Z + 24);
botPawn.Teleport(pos, null, new Vector(0, 0, 0));
moved++;
}
if (moved > 0) _p.LogSurvival($"wave {_wave} stalled (no kill {_p.Config.Survival.StallNudgeSeconds:0}s) — pulled {moved} bot(s) to the squad");
}
// HUD line for the active run: wave number + bots remaining to clear it (or the break state). "" when no run.
// Surfaced to the core via IMatchDriver.HudStatusLine so the HUD never type-checks the concrete driver.
public string HudStatusLine(PlayerData pd) => WaveHud();
private string WaveHud()
{
if (!_runActive) return "";
int cap = _p.Config.Survival.WaveCount;
if (_phase == WavePhase.Break) return $"WAVE {_highestWaveCleared}/{cap} CLEARED — break";
return $"WAVE {_wave}/{cap} — {Math.Max(0, _waveBudget - _killsThisWave)} bots left";
}
// ---- revive ----
private void ReviveDead()
{
if (!_p.Config.Survival.ReviveOnWaveClear) return;
foreach (var p in CtHumans())
if (!p.PawnIsAlive) _p.ReviveSurvivor(p);
}
// ---- the draft ----
private void GrantCards()
{
int per = Math.Max(1, _p.Config.Survival.CardsPerWave);
bool reviveOn = _p.Config.Survival.ReviveOnWaveClear;
foreach (var p in CtHumans())
{
// Hardcore (no revive): a permanently-dead player can never open/spend a pick — don't bank it or spam the chat.
// (In revive mode ReviveDead ran just above, so the dead are revived; gating on reviveOn covers the respawn lag.)
if (!p.PawnIsAlive && !reviveOn) continue;
var pd = _p.PdOf(p);
if (pd is null) continue;
var run = Run(pd);
if (!HasDraftableCard(run)) continue; // every card already maxed -> don't bank a pick that can never be spent
run.RunPoints += per;
EnsureDraw(run); // keep the existing offer if still valid (anti-cheese: skipping a wave must NOT reroll the draft)
// Show the running total (incl. any banked from missed breaks) so the player knows what's waiting.
p.PrintToChat($" {ChatColors.Gold}[Survival] {ChatColors.Lime}{run.RunPoints}{ChatColors.Default} card pick(s) available — press {ChatColors.Gold}X{ChatColors.Default} to draft (unspent picks carry over).");
}
}
// Any card still under its cap (per-player OR the shared team level) — gates the draft so it never auto-pops a
// frozen, empty overlay once everything's maxed.
private bool HasDraftableCard(SurvivalRun run) =>
_p.Config.Survival.Cards.Any(c => !string.IsNullOrWhiteSpace(c.Key) && CardCount(run, c.Key) < c.Cap);
private void Redraw(SurvivalRun run)
{
var cfg = _p.Config.Survival;
var pool = cfg.Cards.Where(c => !string.IsNullOrWhiteSpace(c.Key) && CardCount(run, c.Key) < c.Cap).ToList();
int n = Math.Min(Math.Min(Math.Max(1, cfg.DraftSize), 3), pool.Count); // cap at 3 — the overlay only renders 3 panels
run.DrawnThisBreak = pool.OrderBy(_ => Random.Shared.Next()).Take(n).Select(c => c.Key).ToList();
}
// Ensure the player has a draftable hand WITHOUT rerolling a still-valid one. ANTI-CHEESE: banking a pick across a
// wave must NOT reroll the offer (else a player skips waves to fish for the best cards). So only (re)draw when the
// current hand has no still-draftable card left — empty, or every drawn card is now maxed (e.g. a team card others
// maxed). The drawn hand persists on SurvivalRun across breaks, alongside the banked RunPoints.
private void EnsureDraw(SurvivalRun run)
{
if (run.RunPoints <= 0) return;
bool hasValid = run.DrawnThisBreak.Any(k => CardDef(k) is { } d && CardCount(run, k) < d.Cap);
if (!hasValid && HasDraftableCard(run)) Redraw(run);
}
// ---- shop-facing draft API ----
public bool DraftPending(CCSPlayerController p)
{
if (!_runActive || _phase != WavePhase.Break) return false;
var pd = _p.PdOf(p);
return pd is not null && _runs.TryGetValue(pd.SteamId, out var run) && run.RunPoints > 0 && HasDraftableCard(run);
}
// Unspent draft picks the player has banked (spendable in any break) — surfaced in the draft menu header.
public int PendingCards(CCSPlayerController p)
{
var pd = _p.PdOf(p);
return pd is not null && _runs.TryGetValue(pd.SteamId, out var run) ? run.RunPoints : 0;
}
// View data for one draft card (CardView lives in Modes.cs alongside IDraftDriver): name, current/cap picks, and
// either the current->next % value (the original stat cards) OR a custom Detail line (the effect cards).
public CardView? CardInfo(CCSPlayerController p, string key)
{
var pd = _p.PdOf(p);
if (pd is null || !_runs.TryGetValue(pd.SteamId, out var run)) return null;
var def = CardDef(key);
if (def is null) return null;
int have = CardCount(run, key); // per-player picks, or the shared team level
bool flat = def.Flat; // flat points (HP/armor caps + flat regen) vs % — data-driven
double next = (have + 1) * def.PerPick;
string? detail = EffectCardDetail(key, def, have); // null for the original stat cards -> Now/Next % shown
return new CardView(def.Name, have, def.Cap, have * def.PerPick, next, flat, detail);
}
// A human-readable effect line for the new logic cards (the Now/Next % display is meaningless for these). Values are
// shown AFTER the next pick (level have+1). Team cards COMPOUND (match RecomputeTeamMults), so their detail shows the
// real compounded total, not the additive sum. Returns null for the 12 stat cards (they keep the +Now% -> +Next% panel).
private string? EffectCardDetail(string key, SurvivalCardDef def, int have)
{
int lvl = have + 1; // value after the next pick
double add = lvl * def.PerPick; // linear per-pick total (the per-player leveled cards)
// Team cards COMPOUND, so show the real compounded total (not the additive sum); the deal/take direction is the
// only inherently-2-card distinction left.
if (def.IsTeam)
return key == CardKeys.GlobalTake
? $"squad -{(1.0 - SurvivalEconomy.TeamMult(lvl, def.PerPick, increase: false)) * 100.0:0}% dmg taken"
: $"squad +{(SurvivalEconomy.TeamMult(lvl, def.PerPick, increase: true) - 1.0) * 100.0:0}% dmg dealt";
// Burn's magnitude comes from the Burn* knobs (not the card PerPick), so it can't be a {0} template.
if (key == CardKeys.Burn)
{
var cfg = _p.Config.Survival;
return $"{cfg.BurnDamagePerSecond:0} HP/s for {cfg.BurnDurationSeconds:0}s";
}
// Everything else: the data-driven Detail template ({0} = level x PerPick). Empty -> stat card -> Now/Next % panel.
return string.IsNullOrEmpty(def.Detail) ? null : string.Format(def.Detail, add);
}
// The current offered draw as (cardKey, displayLabel) — stable across a reopen within the same break (anti-reroll).
public List<(string key, string label)> CurrentDraw(CCSPlayerController p)
{
var res = new List<(string, string)>();
var pd = _p.PdOf(p);
if (pd is null || !_runs.TryGetValue(pd.SteamId, out var run)) return res;
foreach (var key in run.DrawnThisBreak)
{
var def = CardDef(key);
if (def is null) continue;
res.Add((key, $"{def.Name} [{CardCount(run, key)}/{def.Cap}]"));
}
return res;
}
public void PickCard(CCSPlayerController p, string key)
{
var pd = _p.PdOf(p);
if (pd is null || !_runs.TryGetValue(pd.SteamId, out var run) || run.RunPoints <= 0) return;
if (!run.DrawnThisBreak.Contains(key)) return;
var def = CardDef(key);
if (def is null || CardCount(run, key) >= def.Cap) return; // already maxed (per-player OR the shared team level)
run.RunPoints--;
if (IsTeamCard(key)) // squad-wide: bump the shared level, recompute the team multiplier, tell everyone
{
if (key == CardKeys.GlobalDeal) _teamDealLevel++; else _teamTakeLevel++;
RecomputeTeamMults();
int lvl = CardCount(run, key);
Server.PrintToChatAll($" {ChatColors.Gold}[Survival] {ChatColors.Lime}{p.PlayerName} {ChatColors.Default}raised {ChatColors.Lime}{def.Name} {ChatColors.Default}-> team {lvl}/{def.Cap}");
if (lvl >= def.Cap) foreach (var r in _runs.Values) r.DrawnThisBreak.RemoveAll(k => k == key); // pull the now-maxed team card from every hand
}
else
{
run.Cards[key] = run.Cards.GetValueOrDefault(key) + 1;
p.PrintToChat($" {ChatColors.Gold}[Survival] {ChatColors.Lime}{def.Name} {ChatColors.Default}-> {run.Cards[key]}/{def.Cap} ({run.RunPoints} pick(s) left)");
// ONLY a Max-HP/Max-Armor card touches the live pawn: raise the cap + top up by the card's bonus. NO full heal
// on any pick (that would erase the 50% revive penalty and reset the squad to full HP every wave).
if (key == StatKeys.MaxHp || key == StatKeys.MaxArmor) _p.GrantCardCapBonus(p, key, (int)def.PerPick);
}
if (run.RunPoints > 0) Redraw(run); else run.DrawnThisBreak.Clear();
}
// ---- helpers ----
private SurvivalRun Run(PlayerData pd)
{
if (!_runs.TryGetValue(pd.SteamId, out var r)) { r = new SurvivalRun(CardDef); _runs[pd.SteamId] = r; }
return r;
}
// Bank raw combat XP into the player's CURRENT-wave accumulator (granted at wave clear). Called from GrantCombatXp.
// The xp_mult card (+% run-XP, per-player) scales the RAW accumulation here, so it compounds with the per-wave
// prestige x waveMult chain applied at grant time.
public void AccumulateWaveXp(PlayerData pd, double amount)
{
if (!_runActive || amount <= 0) return;
Run(pd).WaveXp += SurvivalEconomy.AccrueWaveXp(amount, StatBonus(pd, CardKeys.XpMult));
}
private SurvivalCardDef? CardDef(string key)
{
foreach (var c in _p.Config.Survival.Cards) if (c.Key == key) return c;
return null;
}
private static IEnumerable<CCSPlayerController> CtHumans() =>
Utilities.GetPlayers().Where(p => OutnumberedPlugin.IsHuman(p) && p.Team == CsTeam.CounterTerrorist);
private static int AliveCtHumans() => CtHumans().Count(p => p.PawnIsAlive);
private static int BotCount() => Utilities.GetPlayers().Count(OutnumberedPlugin.IsBot);
// The connected controller for a SteamId (any team) — for the run-end chat line; null if they've left.
private static CCSPlayerController? ControllerFor(ulong sid) =>
Utilities.GetPlayers().FirstOrDefault(p => OutnumberedPlugin.IsHuman(p) && p.AuthorizedSteamID?.SteamId64 == sid);
}
// Plugin-side survival helpers (per-wave XP grant, revive, HP reapply) — here so they can reach the private XP/stat math.
public sealed partial class OutnumberedPlugin
{
// Diagnostic logging for the survival wave machine (console: "[Survival] ...").
internal void LogSurvival(string msg) => Logger.LogInformation("[Survival] {Msg}", msg);
// Grant ONE cleared wave's XP into the main table: rawWaveXp x prestige x waveMult(wave), NO cap, handicap-mult
// EXCLUDED (anti-launder: run XP must not re-couple to gameable K/D; anti-carry is automatic since waveXp is each
// player's OWN attributed damage). Per-wave so players level + earn skill points mid-run, and the XP lands in the main
// table immediately (a disconnect keeps every CLEARED wave — there's no separate run accumulator to restore).
internal void BankWaveXp(PlayerData pd, double waveXp, int wave, CCSPlayerController? p)
{
if (waveXp <= 0) return;
var cfg = Config.Survival;
long lump = SurvivalEconomy.WaveXpLump(waveXp, wave, pd.Prestige, cfg, Config.Progression);
if (lump <= 0) return;
AddConvertedXp(pd, lump, p);
p?.PrintToChat($" {ChatColors.Gold}[Survival] Wave {wave} XP: {ChatColors.Lime}+{lump:n0} {ChatColors.Default}(x{SurvivalEconomy.WaveMult(wave, cfg):F1}).");
}
// Revive a downed survivor at the configured HP% (used between waves). mp_respawn_on_death_ct 0 keeps them down,
// so this is a manual Respawn; they're still on CT (never moved to spectator), so no team churn.
internal void ReviveSurvivor(CCSPlayerController p)
{
if (p is not { IsValid: true } || p.IsBot || p.PawnIsAlive || p.Team != CsTeam.CounterTerrorist) return;
if (p.AuthorizedSteamID?.SteamId64 is not { } sid) return;
double pct = Config.Survival.ReviveHpPercent;
p.Respawn();
// Defer through the seam: re-resolve by slot + pin SteamID so a within-frame slot-reuse can't revive a different player.
NextFrameForSlot(p.Slot, sid, pl =>
{
var pd = PdOf(pl); if (pd is null) return;
ApplyMaxHpArmor(pl, pd);
var pawn = pl.PlayerPawn.Value;
if (pawn is not null) PawnWriter.SetHealth(pawn, Math.Max(1, (int)(MaxHpOf(pd) * pct)));
}, requireAlive: true);
}
// A survival Max-HP / Max-Armor card just took effect: raise the live cap and top current HP/armor up by the card's
// bonus ONLY — never a full heal (a full heal on any pick would erase the 50% revive penalty and reset the squad to
// full HP every wave). Other cards never touch current HP/armor.
internal void GrantCardCapBonus(CCSPlayerController p, string statKey, int bonus)
{
if (bonus <= 0) return;
var pd = PdOf(p);
var pawn = p.PlayerPawn.Value;
if (pd is null || pawn is null || !p.PawnIsAlive) return;
if (statKey == StatKeys.MaxHp)
{
int maxHp = MaxHpOf(pd); // already includes the just-picked card (run.Cards incremented first)
PawnWriter.SetMaxHealth(p, pawn, maxHp);
PawnWriter.SetHealth(pawn, Math.Min(maxHp, pawn.Health + bonus));
}
else if (statKey == StatKeys.MaxArmor)
{
PawnWriter.SetArmor(pawn, Math.Min(MaxArmorOf(pd), pawn.ArmorValue + bonus));
}
}
// PlayerData for a SteamId (the survival run-end bank resolves participants by id, not current team).
internal PlayerData? PdBySteamId(ulong sid) => _players.TryGetValue(sid, out var pd) ? pd : null;
}

View file

@ -0,0 +1,23 @@
namespace Outnumbered;
// Per-player survival run state. RAM-only, never persisted; cleared on run end / disconnect (no mid-run reconnect-restore).
// Implements IStatBonusSource so it IS the PlayerSnapshot.Cards source (no per-hit adapter allocation). Bonus resolves the
// card def LIVE via the injected lookup, so !og_reload PerPick edits take effect immediately. (The wave state machine
// [SurvivalDriver] + the plugin-side survival helpers live in Survival.cs.)
public sealed class SurvivalRun(Func<string, Config.SurvivalCardDef?> cardDef) : Domain.IStatBonusSource
{
private readonly Func<string, Config.SurvivalCardDef?> _cardDef = cardDef;
public double WaveXp { get; set; } // raw combat XP banked THIS wave (granted + reset at wave clear; forfeited on a wipe/leave mid-wave)
public int RunPoints { get; set; } // unspent draft picks
public Dictionary<string, int> Cards { get; } = []; // card key -> times picked (get-only: the ref is fixed, the contents mutate)
public List<string> DrawnThisBreak { get; set; } = []; // the current offered draw (stable across reopen within a break)
// Per-stat run bonus = picks x PerPick (live def). 0 for any key without picks. (Team cards are squad-wide and folded
// into MDeal/MTake, NOT per-player here — they're keyed on the driver's shared counters, not run.Cards.)
public double Bonus(string statKey)
{
if (!Cards.TryGetValue(statKey, out int picks) || picks <= 0) return 0.0;
var def = _cardDef(statKey);
return def is null ? 0.0 : picks * def.PerPick;
}
}

249
Outnumbered/Weapons.cs Normal file
View file

@ -0,0 +1,249 @@
using System.Collections.Frozen;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Commands;
using CounterStrikeSharp.API.Modules.Menu;
using CounterStrikeSharp.API.Modules.Utils;
using Outnumbered.Config;
using Outnumbered.Data;
using Outnumbered.Engine;
namespace Outnumbered;
// Free weapon selection (no economy), split into categories. !guns lists the categories; the direct
// commands (!rifles/!smgs/!snipers/!shotguns/!heavy/!pistols) jump straight to a category. Choice persists
// per-SteamID (DB) and is applied on spawn by ApplyLoadout (default until chosen / if a saved gun is delisted).
public sealed partial class OutnumberedPlugin
{
private static readonly FrozenDictionary<string, string> WeaponNames = new Dictionary<string, string>
{
["weapon_ak47"] = "AK-47",
["weapon_m4a1_silencer"] = "M4A1-S",
["weapon_m4a1"] = "M4A4",
["weapon_aug"] = "AUG",
["weapon_sg556"] = "SG 553",
["weapon_galilar"] = "Galil AR",
["weapon_famas"] = "FAMAS",
["weapon_awp"] = "AWP",
["weapon_ssg08"] = "SSG 08",
["weapon_scar20"] = "SCAR-20",
["weapon_g3sg1"] = "G3SG1",
["weapon_mp9"] = "MP9",
["weapon_mp7"] = "MP7",
["weapon_mp5sd"] = "MP5-SD",
["weapon_ump45"] = "UMP-45",
["weapon_p90"] = "P90",
["weapon_mac10"] = "MAC-10",
["weapon_bizon"] = "PP-Bizon",
["weapon_nova"] = "Nova",
["weapon_xm1014"] = "XM1014",
["weapon_mag7"] = "MAG-7",
["weapon_sawedoff"] = "Sawed-Off",
["weapon_m249"] = "M249",
["weapon_negev"] = "Negev",
["weapon_deagle"] = "Desert Eagle",
["weapon_revolver"] = "R8 Revolver",
["weapon_glock"] = "Glock-18",
["weapon_usp_silencer"] = "USP-S",
["weapon_hkp2000"] = "P2000",
["weapon_p250"] = "P250",
["weapon_fiveseven"] = "Five-SeveN",
["weapon_tec9"] = "Tec-9",
["weapon_cz75a"] = "CZ75-Auto",
["weapon_elite"] = "Dual Berettas",
["weapon_knife"] = "Knife", // the GG finale rung — otherwise the prefix-strip fallback renders lowercase "knife"
}.ToFrozenDictionary(StringComparer.Ordinal);
internal static string WeaponDisplayName(string item) =>
WeaponNames.TryGetValue(item, out var n) ? n
: item.StartsWith("weapon_", StringComparison.Ordinal) ? item[7..].Replace('_', ' ') : item;
internal static bool WeapEq(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
// Cached weapon-category sets (OrdinalIgnoreCase == WeapEq), so membership is O(1) instead of Concat-5-lists + ToList
// + a linear .Any(WeapEq) per call. Rebuilt only when the Match config INSTANCE changes (!og_reload swaps the whole
// Config object). CONTRACT: an in-place edit to Match.Rifles/Pistols/... would NOT invalidate this — only a Config swap does.
private FrozenSet<string>? _primarySet, _pistolSet;
private MatchConfig? _weaponSetCfg;
private void EnsureWeaponSets()
{
var m = Config.Match;
if (_primarySet is not null && ReferenceEquals(_weaponSetCfg, m)) return;
_primarySet = m.Rifles.Concat(m.Smgs).Concat(m.Snipers).Concat(m.Shotguns).Concat(m.Heavy).ToFrozenSet(StringComparer.OrdinalIgnoreCase);
_pistolSet = m.Pistols.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
_weaponSetCfg = m;
}
private bool IsPrimaryWeapon(string w) { EnsureWeaponSets(); return _primarySet!.Contains(w); }
private bool IsPistolWeapon(string w) { EnsureWeaponSets(); return _pistolSet!.Contains(w); }
// Level required to use a weapon (absent/0 = free). Prestige resets level, so this re-locks on prestige.
private int UnlockLevel(string w) => Config.Match.WeaponUnlockLevel.TryGetValue(w, out var lvl) ? lvl : 0;
// Allowed = in the right category set AND the player has reached its unlock level.
private bool IsAllowedPrimary(PlayerData pd, string? w) =>
!string.IsNullOrEmpty(w) && IsPrimaryWeapon(w!) && pd.Level >= UnlockLevel(w!);
private bool IsAllowedSecondary(PlayerData pd, string? w) =>
!string.IsNullOrEmpty(w) && IsPistolWeapon(w!) && pd.Level >= UnlockLevel(w!);
// What the driver hands this player on spawn: saved choice if still allowed (unlocked), else config default.
internal (string primary, string secondary) ResolveLoadout(CCSPlayerController p)
{
string primary = Config.Match.HumanPrimary, secondary = Config.Match.HumanSecondary;
if (PdOf(p) is { } pd)
{
if (IsAllowedPrimary(pd, pd.PrimaryWeapon)) primary = pd.PrimaryWeapon!;
if (IsAllowedSecondary(pd, pd.SecondaryWeapon)) secondary = pd.SecondaryWeapon!;
}
return (primary, secondary);
}
// Which category command equips this weapon (for the unlock announcement).
private string WeaponCategoryCmd(string w)
{
var m = Config.Match;
if (m.Rifles.Any(x => WeapEq(x, w))) return "rifles";
if (m.Smgs.Any(x => WeapEq(x, w))) return "smgs";
if (m.Snipers.Any(x => WeapEq(x, w))) return "snipers";
if (m.Shotguns.Any(x => WeapEq(x, w))) return "shotguns";
if (m.Heavy.Any(x => WeapEq(x, w))) return "heavy";
return "pistols";
}
// ---- quick-buy combo: the keystrokes that reach a weapon in the shop (X -> Weapons -> category -> item) ----
// The 8 reliable menu keys in slot order (key 4 = grenade slot and 5 = C4 can't be clean selectors).
private static readonly char[] MenuKeys = ['1', '2', '3', '6', '7', '8', '9', '0'];
// Category order on the Weapons screen — fixes each category's key. Item keys come from the JSON list order.
public static readonly string[] CategoryOrder = ["Pistols", "Smgs", "Shotguns", "Rifles", "Heavy", "Snipers"];
private List<string> CategoryList(string name) => name switch
{
"Rifles" => Config.Match.Rifles,
"Smgs" => Config.Match.Smgs,
"Snipers" => Config.Match.Snipers,
"Shotguns" => Config.Match.Shotguns,
"Heavy" => Config.Match.Heavy,
"Pistols" => Config.Match.Pistols,
_ => [],
};
public static char MenuKey(int pos) => pos >= 0 && pos < MenuKeys.Length ? MenuKeys[pos] : '?';
// Derived, never stored: '1' = Weapons branch at root, then the category's key, then the weapon's key.
// Falls out of the JSON list order automatically, so it can never drift from the menu. "" if not found, OR if the item
// sits past the world-menu's key range (idx >= MenuKeys.Length) — those overflow guns have no quick-buy combo and are
// reached via the paginated !category chat menu, so we omit the combo rather than print an unreachable key ('?').
private string QuickBuySequence(string weapon)
{
for (int c = 0; c < CategoryOrder.Length; c++)
{
int idx = CategoryList(CategoryOrder[c]).FindIndex(x => WeapEq(x, weapon));
if (idx >= 0) return idx < MenuKeys.Length ? $"X{MenuKey(0)}{MenuKey(c)}{MenuKey(idx)}" : "";
}
return "";
}
// Loud, distinct chat shout for any weapon(s) that unlock exactly at this level (called on level-up).
private void AnnounceUnlocks(CCSPlayerController p, int level)
{
foreach (var kv in Config.Match.WeaponUnlockLevel)
if (kv.Value == level)
{
string combo = QuickBuySequence(kv.Key);
string tail = combo.Length > 0 ? $" or quick-buy {ChatColors.Gold}{combo}{ChatColors.Default}" : "";
p.PrintToChat($" {ChatColors.Magenta}★ WEAPON UNLOCKED: {WeaponDisplayName(kv.Key)} {ChatColors.Default}— !{WeaponCategoryCmd(kv.Key)} to equip{tail}");
}
}
// ---- top menu + category commands ----
[ConsoleCommand("css_guns", "Open the weapon selection menu")]
[ConsoleCommand("css_weapons", "Open the weapon selection menu")]
[ConsoleCommand("css_loadout", "Open the weapon selection menu")]
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
public void Cmd_Guns(CCSPlayerController? p, CommandInfo info)
{
if (p is not { IsValid: true }) return;
if (!_driver.WeaponShopEnabled) { p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Your weapon is set by the Gun Game ladder — climb it!"); return; }
var m = Config.Match;
var (cur1, cur2) = ResolveLoadout(p);
p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Loadout — {WeaponDisplayName(cur1)} / {WeaponDisplayName(cur2)} (pick a category)");
p.PrintToChat($" {ChatColors.Lime} !rifles — Rifles ({m.Rifles.Count})");
p.PrintToChat($" {ChatColors.Lime} !smgs — SMGs ({m.Smgs.Count})");
p.PrintToChat($" {ChatColors.Lime} !snipers — Snipers ({m.Snipers.Count})");
p.PrintToChat($" {ChatColors.Lime} !shotguns — Shotguns ({m.Shotguns.Count})");
p.PrintToChat($" {ChatColors.Lime} !heavy — Machine Guns ({m.Heavy.Count})");
p.PrintToChat($" {ChatColors.Lime} !pistols — Pistols ({m.Pistols.Count})");
}
[ConsoleCommand("css_rifles", "Choose a rifle")]
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
public void Cmd_Rifles(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Rifles", Config.Match.Rifles, true); }
[ConsoleCommand("css_smgs", "Choose an SMG")]
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
public void Cmd_Smgs(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "SMGs", Config.Match.Smgs, true); }
[ConsoleCommand("css_snipers", "Choose a sniper")]
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
public void Cmd_Snipers(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Snipers", Config.Match.Snipers, true); }
[ConsoleCommand("css_shotguns", "Choose a shotgun")]
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
public void Cmd_Shotguns(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Shotguns", Config.Match.Shotguns, true); }
[ConsoleCommand("css_heavy", "Choose a machine gun")]
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
public void Cmd_Heavy(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Heavy", Config.Match.Heavy, true); }
[ConsoleCommand("css_pistols", "Choose a pistol")]
[ConsoleCommand("css_secondaries", "Choose a pistol")]
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
public void Cmd_Pistols(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Pistols", Config.Match.Pistols, false); }
private void OpenCategory(CCSPlayerController p, string title, List<string> list, bool primary)
{
if (!_driver.WeaponShopEnabled) { p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Weapon selection is disabled in Gun Game."); return; }
var pd = PdOf(p);
if (pd is null) return;
var menu = new ChatMenu($"Choose {title}");
foreach (var item in list)
{
int req = UnlockLevel(item);
bool unlocked = pd.Level >= req;
string label = unlocked ? WeaponDisplayName(item) : $"{WeaponDisplayName(item)} [Lv {req}]";
menu.AddMenuOption(label, (pl, _) => SetWeapon(pl, primary, item), disabled: !unlocked);
}
menu.Open(p);
}
private void SetWeapon(CCSPlayerController p, bool primary, string item)
{
var pd = PdOf(p);
if (pd is null) return;
int req = UnlockLevel(item);
if (pd.Level < req) { p.PrintToChat($"[Outnumbered] {WeaponDisplayName(item)} unlocks at level {req}."); return; }
if (primary) pd.PrimaryWeapon = item; else pd.SecondaryWeapon = item;
pd.Dirty = true;
string slotName = primary ? "Primary" : "Secondary";
if (p.PawnIsAlive)
{
// Rebuild both slots synchronously. RemoveItemBySlot's kill is DEFERRED 0.1s (would double-occupy the
// slot / leave the player unarmed); RemoveWeapons() is synchronous (mirrors ApplyLoadout). Armor isn't
// a weapon so it survives; an ability grenade (if any) reconciles back.
var (pri, sec) = ResolveLoadout(p);
p.RemoveWeapons();
p.GiveNamedItem(EngineNames.WeaponKnife);
if (!string.IsNullOrEmpty(pri)) p.GiveNamedItem(pri);
if (!string.IsNullOrEmpty(sec)) p.GiveNamedItem(sec);
GiveShopCarriers(p); // re-give the healthshot + zeus after the rebuild (armor isn't a weapon, so RemoveWeapons left it)
p.PrintToChat($"[Outnumbered] {slotName}: {WeaponDisplayName(item)}.");
}
else
{
p.PrintToChat($"[Outnumbered] {slotName}: {WeaponDisplayName(item)} (applies on respawn).");
}
}
}

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<EnableDynamicLoading>true</EnableDynamicLoading>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<AssemblyName>outnumbered</AssemblyName>
<RootNamespace>Outnumbered</RootNamespace>
<WithSqlite Condition="'$(WithSqlite)'==''">true</WithSqlite>
</PropertyGroup>
<PropertyGroup Condition="'$(WithSqlite)'=='true'">
<DefineConstants>$(DefineConstants);WITH_SQLITE</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.369" ExcludeAssets="runtime" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" ExcludeAssets="runtime" />
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Npgsql" Version="10.0.0" />
</ItemGroup>
<ItemGroup Condition="'$(WithSqlite)'=='true'">
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.11" />
</ItemGroup>
</Project>