Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8739fd3ac0 | ||
|
|
99ff071723 | ||
|
|
44b8de01c8 |
8 changed files with 103 additions and 8 deletions
|
|
@ -5,6 +5,6 @@
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Deterministic>true</Deterministic>
|
<Deterministic>true</Deterministic>
|
||||||
<Version>1.0.1</Version>
|
<Version>1.0.4</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,10 @@ public sealed class SurvivalConfig
|
||||||
[JsonPropertyName("BudgetBase")] public int BudgetBase { get; set; } = 6;
|
[JsonPropertyName("BudgetBase")] public int BudgetBase { get; set; } = 6;
|
||||||
[JsonPropertyName("BudgetPerWave")] public int BudgetPerWave { get; set; } = 4;
|
[JsonPropertyName("BudgetPerWave")] public int BudgetPerWave { get; set; } = 4;
|
||||||
[JsonPropertyName("BudgetPerPlayer")] public int BudgetPerPlayer { get; set; } = 3;
|
[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 ----
|
// ---- wave structure ----
|
||||||
[JsonPropertyName("WaveCount")] public int WaveCount { get; set; } = 20; // finite campaign; clearing this = WIN. Tune by feel + XP-vs-TDM/GG.
|
[JsonPropertyName("WaveCount")] public int WaveCount { get; set; } = 20; // finite campaign; clearing this = WIN. Tune by feel + XP-vs-TDM/GG.
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ internal static class GrenadeSpawner
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_heCreate = new(IsLinux
|
_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");
|
: "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;
|
_ok = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
Outnumbered/Engine/RespawnFix.cs
Normal file
23
Outnumbered/Engine/RespawnFix.cs
Normal file
|
|
@ -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<IntPtr>(controller.Handle, GameData.GetOffset("CCSPlayerController_Respawn"))(controller.Handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ namespace Outnumbered;
|
||||||
public sealed partial class OutnumberedPlugin : BasePlugin, IPluginConfig<OutnumberedConfig>
|
public sealed partial class OutnumberedPlugin : BasePlugin, IPluginConfig<OutnumberedConfig>
|
||||||
{
|
{
|
||||||
public override string ModuleName => "Outnumbered";
|
public override string ModuleName => "Outnumbered";
|
||||||
public override string ModuleVersion => "1.0.1-modes";
|
public override string ModuleVersion => "1.0.4-modes";
|
||||||
public override string ModuleAuthor => "snake";
|
public override string ModuleAuthor => "snake";
|
||||||
public override string ModuleDescription => "Players-vs-bots RPG / perk mod.";
|
public override string ModuleDescription => "Players-vs-bots RPG / perk mod.";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
||||||
private double _teamDealMult = 1.0;
|
private double _teamDealMult = 1.0;
|
||||||
private double _teamTakeMult = 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<Vector> _spawnPoints = [];
|
||||||
|
|
||||||
// ---- Field Medic teammate-revive (card-only: no FieldMedic card => none of this runs) ----
|
// ---- 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 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<ulong, DownedInfo> _downed = []; // downed player's SteamId -> death spot + world beacon (created only while the squad has a medic)
|
private readonly Dictionary<ulong, DownedInfo> _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
|
// 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
|
// 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).
|
// 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 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 OwnsBotPopulation => true; // the wave machine drives bot_quota, not the core's SyncBots
|
||||||
public bool RunInProgress => _runActive; // EnforceHumanTeam fail-closed: no mid-run joins
|
public bool RunInProgress => _runActive; // EnforceHumanTeam fail-closed: no mid-run joins
|
||||||
|
|
@ -210,6 +219,7 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
||||||
{
|
{
|
||||||
_ready = true;
|
_ready = true;
|
||||||
_mapReadyAt = Server.CurrentTime + _p.Config.Survival.StartGraceSeconds;
|
_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()
|
public void OnMatchReset()
|
||||||
|
|
@ -595,7 +605,7 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
||||||
foreach (var b in bots)
|
foreach (var b in bots)
|
||||||
{
|
{
|
||||||
if (alive >= want) break;
|
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
|
else if (alive > want) // cull: kill excess down to `want` (break clear / over-count). Suicide => no kill credit
|
||||||
foreach (var b in bots)
|
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");
|
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<CBaseEntity>(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.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 =>
|
||||||
|
{
|
||||||
|
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>();
|
||||||
|
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.
|
// 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.
|
// Surfaced to the core via IMatchDriver.HudStatusLine so the HUD never type-checks the concrete driver.
|
||||||
public string HudStatusLine(PlayerData pd) => WaveHud();
|
public string HudStatusLine(PlayerData pd) => WaveHud();
|
||||||
|
|
@ -855,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 is not { IsValid: true } || p.IsBot || p.PawnIsAlive || p.Team != CsTeam.CounterTerrorist) return;
|
||||||
if (p.AuthorizedSteamID?.SteamId64 is not { } sid) return;
|
if (p.AuthorizedSteamID?.SteamId64 is not { } sid) return;
|
||||||
var dest = teleportTo is { } t ? new Vector(t.X, t.Y, t.Z) : null;
|
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 =>
|
NextFrameForSlot(p.Slot, sid, pl =>
|
||||||
{
|
{
|
||||||
var pd = PdOf(pl); var pawn = pl.PlayerPawn.Value;
|
var pd = PdOf(pl); var pawn = pl.PlayerPawn.Value;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.369" ExcludeAssets="runtime" />
|
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.370" ExcludeAssets="runtime" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" ExcludeAssets="runtime" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" ExcludeAssets="runtime" />
|
||||||
<PackageReference Include="Dapper" Version="2.1.72" />
|
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||||
<PackageReference Include="Npgsql" Version="10.0.0" />
|
<PackageReference Include="Npgsql" Version="10.0.0" />
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ servers.
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- A CS2 dedicated server with **Metamod:Source** and **CounterStrikeSharp** installed
|
- 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.
|
- **.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.
|
- **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.
|
- A database: **PostgreSQL** for production (recommended, required for multi-instance), or **SQLite** for single-server/dev.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue