initial commit
This commit is contained in:
commit
d701598350
67 changed files with 9351 additions and 0 deletions
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue