initial commit
This commit is contained in:
commit
d701598350
67 changed files with 9351 additions and 0 deletions
239
Outnumbered/Abilities.cs
Normal file
239
Outnumbered/Abilities.cs
Normal 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
123
Outnumbered/Admin.cs
Normal 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
216
Outnumbered/Api.cs
Normal 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
116
Outnumbered/Commands.cs
Normal 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); });
|
||||
}
|
||||
}
|
||||
}
|
||||
340
Outnumbered/Config/DomainConfig.cs
Normal file
340
Outnumbered/Config/DomainConfig.cs
Normal 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)
|
||||
}
|
||||
360
Outnumbered/Config/OutnumberedConfig.cs
Normal file
360
Outnumbered/Config/OutnumberedConfig.cs
Normal 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),
|
||||
};
|
||||
}
|
||||
|
||||
187
Outnumbered/Data/DapperPlayerRepository.cs
Normal file
187
Outnumbered/Data/DapperPlayerRepository.cs
Normal 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);
|
||||
}
|
||||
25
Outnumbered/Data/IPlayerRepository.cs
Normal file
25
Outnumbered/Data/IPlayerRepository.cs
Normal 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();
|
||||
}
|
||||
78
Outnumbered/Data/Models.cs
Normal file
78
Outnumbered/Data/Models.cs
Normal 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;
|
||||
}
|
||||
58
Outnumbered/Data/NpgsqlRepository.cs
Normal file
58
Outnumbered/Data/NpgsqlRepository.cs
Normal 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;
|
||||
""");
|
||||
}
|
||||
}
|
||||
82
Outnumbered/Data/SqliteRepository.cs
Normal file
82
Outnumbered/Data/SqliteRepository.cs
Normal 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
|
||||
16
Outnumbered/Domain/CardKeys.cs
Normal file
16
Outnumbered/Domain/CardKeys.cs
Normal 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)
|
||||
}
|
||||
78
Outnumbered/Domain/CombatResolver.cs
Normal file
78
Outnumbered/Domain/CombatResolver.cs
Normal 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;
|
||||
}
|
||||
102
Outnumbered/Domain/HandicapModel.cs
Normal file
102
Outnumbered/Domain/HandicapModel.cs
Normal 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
|
||||
}
|
||||
37
Outnumbered/Domain/PlayerSnapshot.cs
Normal file
37
Outnumbered/Domain/PlayerSnapshot.cs
Normal 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
|
||||
}
|
||||
16
Outnumbered/Domain/ProgressionModel.cs
Normal file
16
Outnumbered/Domain/ProgressionModel.cs
Normal 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);
|
||||
}
|
||||
20
Outnumbered/Domain/StatKeys.cs
Normal file
20
Outnumbered/Domain/StatKeys.cs
Normal 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";
|
||||
}
|
||||
41
Outnumbered/Domain/StatResolver.cs
Normal file
41
Outnumbered/Domain/StatResolver.cs
Normal 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);
|
||||
}
|
||||
48
Outnumbered/Domain/SurvivalEconomy.cs
Normal file
48
Outnumbered/Domain/SurvivalEconomy.cs
Normal 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
419
Outnumbered/Driver.cs
Normal 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
103
Outnumbered/Effects.cs
Normal 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
|
||||
}
|
||||
}
|
||||
15
Outnumbered/Engine/ControllerWriter.cs
Normal file
15
Outnumbered/Engine/ControllerWriter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
99
Outnumbered/Engine/DamageDealer.cs
Normal file
99
Outnumbered/Engine/DamageDealer.cs
Normal 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;
|
||||
}
|
||||
47
Outnumbered/Engine/EngineNames.cs
Normal file
47
Outnumbered/Engine/EngineNames.cs
Normal 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;
|
||||
}
|
||||
93
Outnumbered/Engine/GrenadeSpawner.cs
Normal file
93
Outnumbered/Engine/GrenadeSpawner.cs
Normal 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); }
|
||||
}
|
||||
}
|
||||
67
Outnumbered/Engine/Inventory.cs
Normal file
67
Outnumbered/Engine/Inventory.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
54
Outnumbered/Engine/PawnWriter.cs
Normal file
54
Outnumbered/Engine/PawnWriter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
18
Outnumbered/Engine/Rules.cs
Normal file
18
Outnumbered/Engine/Rules.cs
Normal 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
|
||||
}
|
||||
}
|
||||
30
Outnumbered/Engine/ScreenFade.cs
Normal file
30
Outnumbered/Engine/ScreenFade.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
Outnumbered/Engine/UdsServer.cs
Normal file
106
Outnumbered/Engine/UdsServer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
81
Outnumbered/Engine/WorldText.cs
Normal file
81
Outnumbered/Engine/WorldText.cs
Normal 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
76
Outnumbered/Feel.cs
Normal 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
292
Outnumbered/GunGame.cs
Normal 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
51
Outnumbered/Handicap.cs
Normal 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
268
Outnumbered/Hud.cs
Normal 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(" ");
|
||||
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
159
Outnumbered/Modes.cs
Normal 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 => "";
|
||||
}
|
||||
72
Outnumbered/Outnumbered.cs
Normal file
72
Outnumbered/Outnumbered.cs
Normal 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
293
Outnumbered/Persistence.cs
Normal 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
111
Outnumbered/Players.cs
Normal 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
181
Outnumbered/Progression.cs
Normal 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
267
Outnumbered/Ranks.cs
Normal 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
57
Outnumbered/Shop.Draft.cs
Normal 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
590
Outnumbered/Shop.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
56
Outnumbered/SnapshotBuilder.cs
Normal file
56
Outnumbered/SnapshotBuilder.cs
Normal 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
300
Outnumbered/Stats.cs
Normal 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
594
Outnumbered/Survival.cs
Normal 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;
|
||||
}
|
||||
23
Outnumbered/SurvivalRun.cs
Normal file
23
Outnumbered/SurvivalRun.cs
Normal 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
249
Outnumbered/Weapons.cs
Normal 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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
25
Outnumbered/outnumbered.csproj
Normal file
25
Outnumbered/outnumbered.csproj
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue