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

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

159
Outnumbered/Modes.cs Normal file
View file

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