159 lines
10 KiB
C#
159 lines
10 KiB
C#
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 => "";
|
|
}
|