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();