From 44b8de01c8a2dd44bb2fefe8dca0c8d2ea448421 Mon Sep 17 00:00:00 2001 From: Kamal Tufekcic Date: Wed, 8 Jul 2026 18:33:54 +0300 Subject: [PATCH 1/3] Bot spawn improvements --- Directory.Build.props | 2 +- Outnumbered/Config/DomainConfig.cs | 4 ++ Outnumbered/Outnumbered.cs | 2 +- Outnumbered/Survival.cs | 72 +++++++++++++++++++++++++++++- 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 200e5ad..7ccc2f2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,6 +5,6 @@ enable latest true - 1.0.1 + 1.0.2 diff --git a/Outnumbered/Config/DomainConfig.cs b/Outnumbered/Config/DomainConfig.cs index ba37622..1257718 100644 --- a/Outnumbered/Config/DomainConfig.cs +++ b/Outnumbered/Config/DomainConfig.cs @@ -217,6 +217,10 @@ public sealed class SurvivalConfig [JsonPropertyName("BudgetBase")] public int BudgetBase { get; set; } = 6; [JsonPropertyName("BudgetPerWave")] public int BudgetPerWave { get; set; } = 4; [JsonPropertyName("BudgetPerPlayer")] public int BudgetPerPlayer { get; set; } = 3; + // Directed spawn placement: each streamed-in bot is teleported to a map spawn point at least this many units (horizontal) + // from every live survivor — replaces mp_randomspawn (which ignores distance-to-player and dropped clusters on your head + // at 20 alive). On a map too small for any point to satisfy it, the farthest available point is used. 0 disables placement. + [JsonPropertyName("MinSpawnDistance")] public double MinSpawnDistance { get; set; } = 700.0; // ---- wave structure ---- [JsonPropertyName("WaveCount")] public int WaveCount { get; set; } = 20; // finite campaign; clearing this = WIN. Tune by feel + XP-vs-TDM/GG. diff --git a/Outnumbered/Outnumbered.cs b/Outnumbered/Outnumbered.cs index 1753b78..ccdd6c4 100644 --- a/Outnumbered/Outnumbered.cs +++ b/Outnumbered/Outnumbered.cs @@ -10,7 +10,7 @@ namespace Outnumbered; public sealed partial class OutnumberedPlugin : BasePlugin, IPluginConfig { public override string ModuleName => "Outnumbered"; - public override string ModuleVersion => "1.0.1-modes"; + public override string ModuleVersion => "1.0.2-modes"; public override string ModuleAuthor => "snake"; public override string ModuleDescription => "Players-vs-bots RPG / perk mod."; diff --git a/Outnumbered/Survival.cs b/Outnumbered/Survival.cs index ac1f27c..c44d965 100644 --- a/Outnumbered/Survival.cs +++ b/Outnumbered/Survival.cs @@ -49,6 +49,13 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver private double _teamDealMult = 1.0; private double _teamTakeMult = 1.0; + // Directed bot spawn placement (replaces mp_randomspawn): map spawn-point origins gathered once per map, so each streamed + // -in bot lands away from the squad instead of on their heads. Entity names span the DM/arms-race/classic spawn types; + // whichever the map actually has is used. Empty (no such entities) => placement is skipped (bots keep their team spawn). + private static readonly string[] SpawnEntityNames = + ["info_deathmatch_spawn", "info_player_terrorist", "info_player_counterterrorist", "info_armsrace_counter", "info_armsrace_terrorist"]; + private readonly List _spawnPoints = []; + // ---- Field Medic teammate-revive (card-only: no FieldMedic card => none of this runs) ---- private CounterStrikeSharp.API.Modules.Timers.Timer? _reviveTick; // 0.1s heartbeat: proximity detect + channel progress (finer than the 0.5s WaveTick for a smooth bar/locator) private readonly Dictionary _downed = []; // downed player's SteamId -> death spot + world beacon (created only while the squad has a medic) @@ -86,7 +93,9 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver // 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;"; + // mp_randomspawn OFF: bot spawn placement is done ourselves (DirectedBotSpawn -> a map spawn point away from the squad), + // because mp_randomspawn ignores distance-to-player and drops clusters on your head once ~20 bots are alive at once. + public string ExtraCvars => "mp_randomspawn 0;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 @@ -210,6 +219,7 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver { _ready = true; _mapReadyAt = Server.CurrentTime + _p.Config.Survival.StartGraceSeconds; + GatherSpawnPoints(); // map entities are up by now (SetupMap runs ~3s after map start) -> cache bot spawn origins } public void OnMatchReset() @@ -595,7 +605,7 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver foreach (var b in bots) { if (alive >= want) break; - if (!b.PawnIsAlive) { b.Respawn(); alive++; } + if (!b.PawnIsAlive) { DirectedBotSpawn(b); alive++; } // respawn + teleport away from the squad (anti-on-head) } else if (alive > want) // cull: kill excess down to `want` (break clear / over-count). Suicide => no kill credit foreach (var b in bots) @@ -627,6 +637,64 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver if (moved > 0) _p.LogSurvival($"wave {_wave} stalled (no kill {_p.Config.Survival.StallNudgeSeconds:0}s) — pulled {moved} bot(s) to the squad"); } + // ---- directed spawn placement (anti-clump / anti-on-head, replaces mp_randomspawn) ---- + // Cache every map spawn-point origin once per map. Whichever entity type this map uses is found; if NONE match, the list + // stays empty and DirectedBotSpawn falls back to the plain respawn (team spawn) — with a warning so the map's spawn entity + // name can be added. Spawn geometry is static, so gathering once at map setup is enough. + private void GatherSpawnPoints() + { + _spawnPoints.Clear(); + foreach (var name in SpawnEntityNames) + foreach (var e in Utilities.FindAllEntitiesByDesignerName(name)) + if (e is { IsValid: true } && e.AbsOrigin is { } o) + _spawnPoints.Add(new Vector(o.X, o.Y, o.Z)); + if (_spawnPoints.Count == 0) + _p.LogSurvival($"WARN: no bot spawn points found ({string.Join("/", SpawnEntityNames)}) — directed placement disabled, bots use team spawn"); + else + _p.LogSurvival($"gathered {_spawnPoints.Count} bot spawn points for directed placement"); + } + + // Respawn a pool bot, then (deferred a frame so the spawn settles) teleport it to a spawn point away from the squad. + private void DirectedBotSpawn(CCSPlayerController bot) + { + bot.Respawn(); + if (_spawnPoints.Count == 0 || _p.Config.Survival.MinSpawnDistance <= 0) return; // no candidates / disabled -> plain respawn + int slot = bot.Slot; + OutnumberedPlugin.NextFrameForSlot(slot, b => + { + if (PickSpawnPoint() is { } pos && b.PlayerPawn.Value is { } pawn) + pawn.Teleport(pos, null, new Vector(0, 0, 0)); + }, requireAlive: true); + } + + // A spawn point at least MinSpawnDistance (HORIZONTAL) from every live survivor, picked at random among the valid ones so + // consecutive spawns scatter (bots approach from varied bearings, not one corner). If the map is too tight for any point + // to qualify, use the one that maximises the distance to the nearest survivor. null only if there are no spawn points. + private Vector? PickSpawnPoint() + { + if (_spawnPoints.Count == 0) return null; + var humans = CtHumans().Where(h => h.PawnIsAlive && h.PlayerPawn.Value?.AbsOrigin is not null) + .Select(h => h.PlayerPawn.Value!.AbsOrigin!).ToList(); + if (humans.Count == 0) return _spawnPoints[Random.Shared.Next(_spawnPoints.Count)]; // nobody to avoid -> any point + double min2 = _p.Config.Survival.MinSpawnDistance * _p.Config.Survival.MinSpawnDistance; + var valid = new List(); + Vector? farthest = null; + double farthestNear2 = -1; + foreach (var sp in _spawnPoints) + { + double near2 = double.MaxValue; // squared distance to the CLOSEST survivor + foreach (var h in humans) + { + double dx = sp.X - h.X, dy = sp.Y - h.Y; // horizontal only (a point above/below is still "near") + double d2 = dx * dx + dy * dy; + if (d2 < near2) near2 = d2; + } + if (near2 >= min2) valid.Add(sp); + if (near2 > farthestNear2) { farthestNear2 = near2; farthest = sp; } + } + return valid.Count > 0 ? valid[Random.Shared.Next(valid.Count)] : farthest; + } + // 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(); From 99ff0717238e6476c65ad6941bb3e3673cae48b8 Mon Sep 17 00:00:00 2001 From: Kamal Tufekcic Date: Thu, 9 Jul 2026 17:15:24 +0300 Subject: [PATCH 2/3] Update signatures for new version --- Directory.Build.props | 2 +- Outnumbered/Engine/GrenadeSpawner.cs | 2 +- Outnumbered/Outnumbered.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 7ccc2f2..9e53fec 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,6 +5,6 @@ enable latest true - 1.0.2 + 1.0.3 diff --git a/Outnumbered/Engine/GrenadeSpawner.cs b/Outnumbered/Engine/GrenadeSpawner.cs index 0fb3a4d..fb8eaf0 100644 --- a/Outnumbered/Engine/GrenadeSpawner.cs +++ b/Outnumbered/Engine/GrenadeSpawner.cs @@ -32,7 +32,7 @@ internal static class GrenadeSpawner try { _heCreate = new(IsLinux - ? "55 4C 89 C1 48 89 E5 41 57 49 89 D7" + ? "55 4C 89 C1 48 89 E5 41 57 49 89 FF 41 56 49 89 D6" : "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; } diff --git a/Outnumbered/Outnumbered.cs b/Outnumbered/Outnumbered.cs index ccdd6c4..dc8fe2d 100644 --- a/Outnumbered/Outnumbered.cs +++ b/Outnumbered/Outnumbered.cs @@ -10,7 +10,7 @@ namespace Outnumbered; public sealed partial class OutnumberedPlugin : BasePlugin, IPluginConfig { public override string ModuleName => "Outnumbered"; - public override string ModuleVersion => "1.0.2-modes"; + public override string ModuleVersion => "1.0.3-modes"; public override string ModuleAuthor => "snake"; public override string ModuleDescription => "Players-vs-bots RPG / perk mod."; From 8739fd3ac0370370464ce92ec5785d2eca5b14c6 Mon Sep 17 00:00:00 2001 From: Kamal Tufekcic Date: Fri, 10 Jul 2026 03:01:19 +0300 Subject: [PATCH 3/3] CS# bug bypass for survival mode --- Directory.Build.props | 2 +- Outnumbered/Engine/RespawnFix.cs | 23 +++++++++++++++++++++++ Outnumbered/Outnumbered.cs | 2 +- Outnumbered/Survival.cs | 4 ++-- Outnumbered/outnumbered.csproj | 2 +- README.md | 2 +- 6 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 Outnumbered/Engine/RespawnFix.cs diff --git a/Directory.Build.props b/Directory.Build.props index 9e53fec..71ab1b2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,6 +5,6 @@ enable latest true - 1.0.3 + 1.0.4 diff --git a/Outnumbered/Engine/RespawnFix.cs b/Outnumbered/Engine/RespawnFix.cs new file mode 100644 index 0000000..a9ddde8 --- /dev/null +++ b/Outnumbered/Engine/RespawnFix.cs @@ -0,0 +1,23 @@ +using System; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Memory; + +namespace Outnumbered.Engine; + +// Workaround, pending a CounterStrikeSharp release carrying the upstream fix: CSSharp's +// CCSPlayerController.Respawn() calls SetPawn(PlayerPawn.Value), which faults inside the engine's +// network-state change code when the pawn is DEAD — the usual respawn case — on the current CS2 +// build (a bot respawned right after we kill it took the crash). This mirrors CSSharp's Respawn() +// but only re-attaches a LIVE pawn; the CCSPlayerController_Respawn vfunc re-creates the pawn on +// respawn regardless. Revert every SafeRespawn() call back to .Respawn() once the fixed package ships. +internal static class RespawnFix +{ + public static void SafeRespawn(this CCSPlayerController controller) + { + if (controller is not { IsValid: true }) return; + var pawn = controller.PlayerPawn.Value; + if (pawn == null) return; + if (pawn is { IsValid: true, Health: > 0 }) controller.SetPawn(pawn); + VirtualFunction.CreateVoid(controller.Handle, GameData.GetOffset("CCSPlayerController_Respawn"))(controller.Handle); + } +} diff --git a/Outnumbered/Outnumbered.cs b/Outnumbered/Outnumbered.cs index dc8fe2d..e5c18a0 100644 --- a/Outnumbered/Outnumbered.cs +++ b/Outnumbered/Outnumbered.cs @@ -10,7 +10,7 @@ namespace Outnumbered; public sealed partial class OutnumberedPlugin : BasePlugin, IPluginConfig { public override string ModuleName => "Outnumbered"; - public override string ModuleVersion => "1.0.3-modes"; + public override string ModuleVersion => "1.0.4-modes"; public override string ModuleAuthor => "snake"; public override string ModuleDescription => "Players-vs-bots RPG / perk mod."; diff --git a/Outnumbered/Survival.cs b/Outnumbered/Survival.cs index c44d965..cb5829e 100644 --- a/Outnumbered/Survival.cs +++ b/Outnumbered/Survival.cs @@ -657,7 +657,7 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver // Respawn a pool bot, then (deferred a frame so the spawn settles) teleport it to a spawn point away from the squad. private void DirectedBotSpawn(CCSPlayerController bot) { - bot.Respawn(); + bot.SafeRespawn(); // CSSharp's Respawn() faults in SetPawn on a dead controller (see RespawnFix) if (_spawnPoints.Count == 0 || _p.Config.Survival.MinSpawnDistance <= 0) return; // no candidates / disabled -> plain respawn int slot = bot.Slot; OutnumberedPlugin.NextFrameForSlot(slot, b => @@ -923,7 +923,7 @@ public sealed partial class OutnumberedPlugin if (p is not { IsValid: true } || p.IsBot || p.PawnIsAlive || p.Team != CsTeam.CounterTerrorist) return; if (p.AuthorizedSteamID?.SteamId64 is not { } sid) return; var dest = teleportTo is { } t ? new Vector(t.X, t.Y, t.Z) : null; - p.Respawn(); + p.SafeRespawn(); // CSSharp's Respawn() faults in SetPawn on a dead controller (see RespawnFix) NextFrameForSlot(p.Slot, sid, pl => { var pd = PdOf(pl); var pawn = pl.PlayerPawn.Value; diff --git a/Outnumbered/outnumbered.csproj b/Outnumbered/outnumbered.csproj index 768d96b..f768397 100644 --- a/Outnumbered/outnumbered.csproj +++ b/Outnumbered/outnumbered.csproj @@ -12,7 +12,7 @@ - + diff --git a/README.md b/README.md index 6a43d6b..b0ea153 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ servers. ## Requirements - A CS2 dedicated server with **Metamod:Source** and **CounterStrikeSharp** installed - (compiled against CounterStrikeSharp API **1.0.369**; use a matching or newer server build). + (compiled against CounterStrikeSharp API **1.0.370**; use a matching or newer server build). - **.NET 10** — provided by the CounterStrikeSharp runtime; only needed as an SDK if you build from source. - **Linux** — the damage path, native grenade spawning, and launch-flag parsing are Linux-specific. - A database: **PostgreSQL** for production (recommended, required for multi-instance), or **SQLite** for single-server/dev.