diff --git a/Directory.Build.props b/Directory.Build.props
index 0fea386..200e5ad 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -5,6 +5,6 @@
enable
latest
true
- 1.0.0
+ 1.0.1
diff --git a/Outnumbered.Tests/BalanceInvariantTests.cs b/Outnumbered.Tests/BalanceInvariantTests.cs
index ba20343..f2884d1 100644
--- a/Outnumbered.Tests/BalanceInvariantTests.cs
+++ b/Outnumbered.Tests/BalanceInvariantTests.cs
@@ -100,13 +100,43 @@ public class BalanceInvariantTests
[Fact]
public void Per_mode_override_applies_only_named_fields()
{
- // The survival override down-weights the skill factors so the WAVE FLOOR drives difficulty; everything else inherits.
+ // The survival override zeroes every skill factor so the WAVE FLOOR alone drives difficulty; everything else inherits.
var baseH = T.Hcap();
var ov = T.Surv().Handicap!; // the default survival override
var eff = ov.ApplyTo(baseH);
- Assert.Equal(0.3, eff.KdWeight); // overridden
+ Assert.Equal(0.0, eff.KdWeight); // overridden -> in-run performance cannot move the handicap
+ Assert.Equal(0.0, eff.HsWeight);
+ Assert.Equal(0.0, eff.StreakWeight);
+ Assert.Equal(0.0, eff.LevelWeight);
+ Assert.Equal(0.0, eff.ProgressWeight);
Assert.Equal(10.0, eff.MTakeCeiling); // overridden
Assert.Equal(baseH.MasterDifficulty, eff.MasterDifficulty); // inherited from base (not set in the override)
Assert.Equal(baseH.Curve, eff.Curve); // inherited
}
+
+ [Fact]
+ public void Survival_handicap_is_wave_only_and_invariant_to_performance()
+ {
+ // Bug fix: survival's handicap must depend ONLY on the wave (via the floor), never on K/D / headshots / streak /
+ // level. A dominant player and a struggling player on the SAME wave get the SAME t, and killing more bots inside a
+ // wave never changes it — so the 1st and the 100th kill of a wave take identical shots. (Before, K/D + HS saturated
+ // after a handful of kills and drove a good player straight to the floor mid-wave-1.)
+ var rh = T.Resolved(T.Surv().Handicap!.ApplyTo(T.Hcap()));
+
+ foreach (var wave in new[] { 1, 5, 12, 25 })
+ {
+ double floor = SurvivalEconomy.HandicapFloor(wave, T.Surv());
+ double dominant = HandicapModel.ComputeT(
+ T.Snap(level: 100, kills: 60, deaths: 0, headshotKills: 60, streak: 50, floor: floor), rh);
+ double struggling = HandicapModel.ComputeT(
+ T.Snap(level: 1, kills: 0, deaths: 20, headshotKills: 0, streak: 0, floor: floor), rh);
+ double freshMidWave = HandicapModel.ComputeT(
+ T.Snap(level: 1, kills: 7, deaths: 0, headshotKills: 7, streak: 7, floor: floor), rh);
+
+ Assert.Equal(dominant, struggling, Eps); // same wave -> same handicap, regardless of performance
+ Assert.Equal(dominant, freshMidWave, Eps);
+ // it IS just the eased wave floor (floor >= 0 for any real wave, so Ease reduces to Pow)
+ Assert.Equal(Math.Pow(floor, rh.Config.Curve), dominant, Eps);
+ }
+ }
}
diff --git a/Outnumbered.Tests/SurvivalEconomyTests.cs b/Outnumbered.Tests/SurvivalEconomyTests.cs
index 00cc524..b6070e0 100644
--- a/Outnumbered.Tests/SurvivalEconomyTests.cs
+++ b/Outnumbered.Tests/SurvivalEconomyTests.cs
@@ -110,4 +110,79 @@ public class SurvivalEconomyTests
[InlineData(2, 50, true, 2.25)]
public void TeamMult_matches(int level, double perPick, bool increase, double expected) =>
T.Close(expected, SurvivalEconomy.TeamMult(level, perPick, increase));
+
+ // ---- Field Medic revive scaling (defaults: channel 4.0 -0.7/lvl min 1.5; HP 0.35 +0.1/lvl; charges = lvl*1) ----
+ // channelSeconds = max(ReviveChannelMin, ReviveChannelSeconds - (lvl-1)*ReviveChannelPerLevel).
+ [Theory]
+ [InlineData(1, 4.0)]
+ [InlineData(2, 3.3)]
+ [InlineData(3, 2.6)]
+ [InlineData(10, 1.5)] // -0.7*9 undershoots the 1.5 floor -> clamped
+ public void ReviveChannelSeconds_scales_and_floors(int level, double expected) =>
+ T.Close(expected, SurvivalEconomy.ReviveChannelSeconds(level, T.Surv()));
+
+ // hpFraction = clamp(ReviveHpBase + (lvl-1)*ReviveHpPerLevel, 0.05, 1.0).
+ [Theory]
+ [InlineData(1, 0.35)]
+ [InlineData(2, 0.45)]
+ [InlineData(3, 0.55)]
+ [InlineData(20, 1.0)] // upper clamp
+ public void ReviveHpFraction_scales_and_clamps(int level, double expected) =>
+ T.Close(expected, SurvivalEconomy.ReviveHpFraction(level, T.Surv()));
+
+ // chargesForWave = lvl <= 0 ? 0 : lvl * ReviveChargesPerLevel. Card-only: level 0 (no card) grants nothing.
+ [Theory]
+ [InlineData(0, 0)] // not a medic -> no revives
+ [InlineData(1, 1)]
+ [InlineData(2, 2)]
+ [InlineData(3, 3)]
+ public void ReviveChargesForWave_scales(int level, int expected) =>
+ Assert.Equal(expected, SurvivalEconomy.ReviveChargesForWave(level, T.Surv()));
+
+ [Fact]
+ public void FieldMedic_card_is_in_the_catalog() // card-only unlock lives on the draft catalog, cap 3
+ {
+ var card = T.Surv().Cards.Find(c => c.Key == CardKeys.FieldMedic);
+ Assert.NotNull(card);
+ Assert.Equal(3, card!.Cap);
+ }
+
+ // ---- HUD locator math (the arrow direction players are sent + the distance readout) ----
+ // NormalizeDeg wraps to the canonical (-180, 180] (so -180 -> +180).
+ [Theory]
+ [InlineData(0, 0)]
+ [InlineData(45, 45)]
+ [InlineData(180, 180)]
+ [InlineData(-180, 180)]
+ [InlineData(270, -90)]
+ [InlineData(-270, 90)]
+ [InlineData(360, 0)]
+ [InlineData(450, 90)]
+ public void NormalizeDeg_wraps_to_half_open(double input, double expected) =>
+ T.Close(expected, SurvivalEconomy.NormalizeDeg(input));
+
+ // ArrowIndex: relative bearing -> sector. arrows table = { ↑ ↖ ← ↙ ↓ ↘ → ↗ } indexed 0..7; 0 = ahead, CCW = left.
+ [Theory]
+ [InlineData(0, 0)] // ↑ ahead
+ [InlineData(45, 1)] // ↖ ahead-left
+ [InlineData(90, 2)] // ← left
+ [InlineData(135, 3)] // ↙ behind-left
+ [InlineData(180, 4)] // ↓ behind
+ [InlineData(-135, 5)] // ↘ behind-right
+ [InlineData(-90, 6)] // → right
+ [InlineData(-45, 7)] // ↗ ahead-right
+ [InlineData(360, 0)] // wraps to ahead
+ [InlineData(23, 1)] // rounds up into the ↖ sector
+ [InlineData(22, 0)] // rounds down into the ↑ sector
+ public void ArrowIndex_maps_bearing_to_sector(double relDeg, int expected) =>
+ Assert.Equal(expected, SurvivalEconomy.ArrowIndex(relDeg));
+
+ // LocatorMeters = max(1, round(dist/divisor)); floors at 1m so a downed ally never reads "0m".
+ [Theory]
+ [InlineData(0, 40, 1)] // floor
+ [InlineData(20, 40, 1)] // round(0.5)=0 -> floored to 1
+ [InlineData(120, 40, 3)]
+ [InlineData(200, 40, 5)]
+ public void LocatorMeters_floors_at_one(double dist, double divisor, int expected) =>
+ Assert.Equal(expected, SurvivalEconomy.LocatorMeters(dist, divisor));
}
diff --git a/Outnumbered/Config/DomainConfig.cs b/Outnumbered/Config/DomainConfig.cs
index c2c929b..ba37622 100644
--- a/Outnumbered/Config/DomainConfig.cs
+++ b/Outnumbered/Config/DomainConfig.cs
@@ -227,7 +227,20 @@ public sealed class SurvivalConfig
// ---- death / revive ----
[JsonPropertyName("ReviveOnWaveClear")] public bool ReviveOnWaveClear { get; set; } = true; // false = hardcore "you die, you spectate the rest of the run"
- [JsonPropertyName("ReviveHpPercent")] public double ReviveHpPercent { get; set; } = 0.5;
+ [JsonPropertyName("ReviveHpPercent")] public double ReviveHpPercent { get; set; } = 0.5; // HP fraction on the automatic wave-clear revive (NOT the Field Medic revive below)
+
+ // ---- Field Medic teammate-revive (survival card CardKeys.FieldMedic; card-only — no card, no revive) ----
+ // A downed teammate drops a world beacon at their death spot; a medic who stands within ReviveRadius channels a revive
+ // (no keypress — proximity IS the input). All knobs scale with the medic's card LEVEL (picks): more charges/wave, a
+ // faster channel, and a higher revive HP%. Charges refresh each wave. Live-tunable.
+ [JsonPropertyName("ReviveRadius")] public double ReviveRadius { get; set; } = 90.0; // units the medic must be within to channel (≈ standing next to the body)
+ [JsonPropertyName("ReviveChannelSeconds")] public double ReviveChannelSeconds { get; set; } = 4.0; // channel time at medic level 1
+ [JsonPropertyName("ReviveChannelPerLevel")] public double ReviveChannelPerLevel { get; set; } = 0.7; // seconds shaved per extra medic level
+ [JsonPropertyName("ReviveChannelMin")] public double ReviveChannelMin { get; set; } = 1.5; // floor on channel time (however many levels)
+ [JsonPropertyName("ReviveHpBase")] public double ReviveHpBase { get; set; } = 0.35; // revive HP fraction at medic level 1
+ [JsonPropertyName("ReviveHpPerLevel")] public double ReviveHpPerLevel { get; set; } = 0.1; // +HP fraction per extra medic level
+ [JsonPropertyName("ReviveChargesPerLevel")] public int ReviveChargesPerLevel { get; set; } = 1; // revives per wave = medic level × this (L1=1, L2=2, L3=3)
+ [JsonPropertyName("ReviveXp")] public double ReviveXp { get; set; } = 150.0; // FLAT XP to the reviver per revive (granted straight to the main table, NOT wave-multiplied; only for a combat death, once per saved teammate/wave)
// ---- XP economy (granted PER WAVE at each wave clear, NOT once at run-end). Each cleared wave grants:
// rawWaveXp (HP-damage + HS/crit, handicap-mult EXCLUDED) x prestige x waveMult(wave).
@@ -256,18 +269,22 @@ public sealed class SurvivalConfig
[JsonPropertyName("MaxNerfWave")] public int MaxNerfWave { get; set; } = 25;
// Full per-mode handicap override (all bands tunable). Seeded with a higher MTakeCeiling than base (10x) because
// cards + permanent stats stack — a maxed-HP/lifesteal/regen/thorns build can be unkillable at 8x. Tune live.
- // In survival the WAVE FLOOR is the difficulty driver, so the inherited factors are heavily down-weighted —
- // otherwise a high-level player is pre-nerfed to near-max-take on wave 1 (level/KD maxing the factor before the
- // floor even ramps). Low weights keep a mild skill component; the floor (-> max nerf at MaxNerfWave) does the work.
+ // Survival's handicap is driven SOLELY by wave progression (the monotonic HandicapFloor -> max nerf at MaxNerfWave),
+ // never by in-run performance: all four skill factors (K/D, headshot rate, streak, level) are weighted 0, so a wave's
+ // handicap is CONSTANT no matter how many bots you kill or how few times you die, and only steps up between waves.
+ // This is deliberate — a nonzero weight reintroduces within-wave escalation: K/D and headshot-rate both saturate to
+ // their max factor after ~5-7 kills-without-death, which (x MasterDifficulty) drove a good player straight to the
+ // floor mid-wave-1. With every weight 0, ComputeT can only return the wave floor, so MasterDifficulty is inert here;
+ // the wave ramp is shaped by MaxNerfWave (where the floor hits max nerf), Curve (eases it), and the bands below.
[JsonPropertyName("Handicap")]
public HandicapOverride? Handicap { get; set; } = new()
{
ProgressWeight = 0.0,
MTakeCeiling = 10.0,
- KdWeight = 0.3,
- HsWeight = 0.3,
- StreakWeight = 0.3,
- LevelWeight = 0.1,
+ KdWeight = 0.0, // in-run performance must NOT move the handicap — the wave floor is the only driver
+ HsWeight = 0.0,
+ StreakWeight = 0.0,
+ LevelWeight = 0.0,
MDealFloor = 0.1, // pin: the base dropped to 0.033 for TDM, but waves need a viable deal to be clearable
};
@@ -298,6 +315,7 @@ public sealed class SurvivalConfig
// Leveled (Cap 3): per-player, scale per pick.
new("hs_reduction", "Headshot Armor", 15, 3, detail: "-{0:0}% headshot dmg"), // armor doesn't help vs HS; this does
new("berserk_passive", "Berserker", 40, 3, detail: "+{0:0}% dmg near death"), // scaled by missing HP (always-on, no streak gate)
+ new("field_medic", "Field Medic", 1, 3), // proximity teammate-revive: walk up to a downed ally's beacon to channel a revive. Level scales charges/wave + channel speed + revive HP% (detail computed in code from the Revive* knobs)
// Leveled (Cap 3) but TEAM-WIDE: one shared squad level (max 3), compounding, applied to EVERY survivor via MDeal/MTake.
new("global_deal", "Team: +Damage", 10, 3, isTeam: true), // +10%/level ALL outgoing dmg, compounding -> x1.331 squad-wide maxed
new("global_take", "Team: -Damage Taken", 10, 3, isTeam: true),// -10%/level ALL incoming dmg, compounding -> x0.729 squad-wide maxed
diff --git a/Outnumbered/Config/OutnumberedConfig.cs b/Outnumbered/Config/OutnumberedConfig.cs
index 3f1e18a..5e9ebe5 100644
--- a/Outnumbered/Config/OutnumberedConfig.cs
+++ b/Outnumbered/Config/OutnumberedConfig.cs
@@ -69,6 +69,10 @@ public sealed class SoundsConfig
[JsonPropertyName("AbilityReady")] public string AbilityReady { get; set; } = "sounds/ambient/office/tech_oneshot_08.wav";
[JsonPropertyName("AbilityActivate")] public string AbilityActivate { get; set; } = "sounds/training/pointscored.wav";
[JsonPropertyName("Crit")] public string Crit { get; set; } = ""; // off by default — per-crit can get spammy
+ // Survival Field Medic revive: Complete plays to the reviver when a channel finishes; Warn plays while they drift toward
+ // the edge of the revive radius mid-channel (the audio half of the "about to lose it" cue). Empty = silent.
+ [JsonPropertyName("ReviveComplete")] public string ReviveComplete { get; set; } = "sounds/ui/armsrace_level_up.wav";
+ [JsonPropertyName("ReviveWarn")] public string ReviveWarn { get; set; } = "sounds/ambient/office/tech_oneshot_08.wav";
}
// The local read-only status/balance/top API, served over a per-instance Unix domain socket (Api.cs). The companion
diff --git a/Outnumbered/Domain/CardKeys.cs b/Outnumbered/Domain/CardKeys.cs
index a23937a..b9a9f5f 100644
--- a/Outnumbered/Domain/CardKeys.cs
+++ b/Outnumbered/Domain/CardKeys.cs
@@ -11,6 +11,7 @@ public static class CardKeys
public const string XpMult = "xp_mult"; // +% run-XP earned
public const string HsReduction = "hs_reduction"; // -% incoming headshot damage (per-player, leveled)
public const string BerserkPassive = "berserk_passive"; // +dmg scaled by missing HP (per-player, leveled)
+ public const string FieldMedic = "field_medic"; // unlock + scale the proximity teammate-revive (charges/wave, faster channel, more revive HP per level)
public const string GlobalDeal = "global_deal"; // TEAM: +dmg dealt, squad-wide, compounding (into MDeal)
public const string GlobalTake = "global_take"; // TEAM: -dmg taken, squad-wide, compounding (into MTake)
}
diff --git a/Outnumbered/Domain/SurvivalEconomy.cs b/Outnumbered/Domain/SurvivalEconomy.cs
index 4cc53e7..da179da 100644
--- a/Outnumbered/Domain/SurvivalEconomy.cs
+++ b/Outnumbered/Domain/SurvivalEconomy.cs
@@ -45,4 +45,30 @@ public static class SurvivalEconomy
public static double TeamMult(int level, double perPickPct, bool increase) =>
increase ? Math.Pow(1.0 + perPickPct / 100.0, level)
: Math.Pow(Math.Max(0.0, 1.0 - perPickPct / 100.0), level);
+
+ // ---- Field Medic teammate-revive scaling (all keyed on the medic's card LEVEL = FieldMedic picks; 0 = not a medic) ----
+ // Kept pure here so the numbers are testable and identical to what the engine-side channel machine (Survival.cs) reads.
+ public static double ReviveChannelSeconds(int medicLevel, SurvivalConfig c) =>
+ Math.Max(c.ReviveChannelMin, c.ReviveChannelSeconds - Math.Max(0, medicLevel - 1) * c.ReviveChannelPerLevel);
+ public static double ReviveHpFraction(int medicLevel, SurvivalConfig c) =>
+ Math.Clamp(c.ReviveHpBase + Math.Max(0, medicLevel - 1) * c.ReviveHpPerLevel, 0.05, 1.0);
+ public static int ReviveChargesForWave(int medicLevel, SurvivalConfig c) =>
+ medicLevel <= 0 ? 0 : medicLevel * Math.Max(0, c.ReviveChargesPerLevel);
+
+ // ---- HUD locator math (pure so the arrow direction + distance readout are golden-tested, not hidden behind a pawn) ----
+ // Wrap a degree delta into (-180, 180].
+ public static double NormalizeDeg(double d)
+ {
+ d %= 360.0;
+ if (d > 180) return d - 360;
+ if (d <= -180) return d + 360;
+ return d;
+ }
+
+ // Map a RELATIVE bearing (target bearing - view yaw, degrees) to one of 8 compass sectors: 0 = straight ahead, then
+ // counter-clockwise (positive = to the left). Indexes the caller's arrow table `{↑ ↖ ← ↙ ↓ ↘ → ↗}`.
+ public static int ArrowIndex(double relDeg) => ((int)Math.Round(NormalizeDeg(relDeg) / 45.0) % 8 + 8) % 8;
+
+ // The HUD "distance to the downed teammate" readout: game units -> whole metres, floored at 1 (a divisor <= 0 is guarded).
+ public static int LocatorMeters(double dist, double divisor) => Math.Max(1, (int)Math.Round(dist / Math.Max(1e-9, divisor)));
}
diff --git a/Outnumbered/Driver.cs b/Outnumbered/Driver.cs
index 40f15b8..bb2f785 100644
--- a/Outnumbered/Driver.cs
+++ b/Outnumbered/Driver.cs
@@ -220,7 +220,7 @@ public sealed partial class OutnumberedPlugin
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.
+ _driver.OnHumanDeath(victim, vpd, attacker); // survival: wipe detection + revive-beacon (attacker gates combat vs suicide). No-op elsewhere.
}
// attacker kill: human -> kills++/streak/XP + mode result; bot -> mode result only (bots have no PlayerData)
@@ -368,7 +368,10 @@ public sealed partial class OutnumberedPlugin
{
if (p.Team is CsTeam.Terrorist or CsTeam.None)
{
- p.SwitchTeam(CsTeam.Spectator);
+ // ChangeTeam, NOT SwitchTeam: SwitchTeam keeps the player alive, but an alive spectator is invalid, so a
+ // player who spawned alive on T (survival has no death-respawn to correct it) would silently stay on T.
+ // ChangeTeam follows gamemode rules (suicides + relocates), so the park actually takes.
+ p.ChangeTeam(CsTeam.Spectator);
p.PrintToChat("[Outnumbered] A survival run is in progress — you'll join the next one.");
}
return;
@@ -380,10 +383,11 @@ public sealed partial class OutnumberedPlugin
switch (p.Team)
{
- // an over-cap CT, or a T/None arrival when CT is already full -> spectator (both land on the same outcome)
+ // an over-cap CT, or a T/None arrival when CT is already full -> spectator (both land on the same outcome).
+ // ChangeTeam, not SwitchTeam: SwitchTeam keeps a live player alive, which can't hold on the spectator team.
case CsTeam.CounterTerrorist when ctHumans >= cap:
case CsTeam.Terrorist or CsTeam.None when ctHumans >= cap:
- p.SwitchTeam(CsTeam.Spectator);
+ p.ChangeTeam(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)
diff --git a/Outnumbered/Engine/WorldText.cs b/Outnumbered/Engine/WorldText.cs
index 470b8b0..9b2af51 100644
--- a/Outnumbered/Engine/WorldText.cs
+++ b/Outnumbered/Engine/WorldText.cs
@@ -67,6 +67,16 @@ internal static class WorldText
return true;
}
+ // Orientation for a WORLD-anchored panel (e.g. the revive beacon) that should FACE a viewer at `viewer`, placed at `at`.
+ // The inverse of TryEyeFrame's viewer-facing convention (yaw = the viewer's look-yaw + 270). `roll` lets a fixed panel
+ // stay upright (90) rather than tracking the viewer's pitch. Keeps the +270 magic here, in the one file that owns the
+ // point_worldtext facing convention, instead of duplicated at call sites.
+ public static QAngle FacePointAngle(Vector at, Vector viewer, float roll = 90f)
+ {
+ double yaw = Math.Atan2(at.Y - viewer.Y, at.X - viewer.X) * 180.0 / Math.PI;
+ return new QAngle(0f, (float)yaw + 270f, roll);
+ }
+
// Source-engine AngleVectors with roll assumed 0 (HUD/shop panels never roll).
private static (Vector forward, Vector right, Vector up) AngleVectors(QAngle a)
{
diff --git a/Outnumbered/Hud.cs b/Outnumbered/Hud.cs
index 2c16378..250ae24 100644
--- a/Outnumbered/Hud.cs
+++ b/Outnumbered/Hud.cs
@@ -162,6 +162,7 @@ public sealed partial class OutnumberedPlugin
double now = Server.CurrentTime; // read once for all ability segments (clock can't advance within this build)
var sb = _hudSb; sb.Clear();
if (_driver.HudStatusLine(pd) is { Length: > 0 } wline) sb.Append(wline).Append('\n');
+ if (_driver.ReviveHudLine(p, pd, html: false) is { Length: > 0 } rev) sb.Append(rev).Append('\n');
sb.Append($"Lvl {pd.Level} ({pd.Xp}/{next}) Prestige {Roman(pd.Prestige)} {pd.Points} pt Streak {pd.Streak}\n");
sb.Append($"HS x{hs:F2} Out x{md:F2} In x{mt:F2} XP x{xp:F2}\n");
for (int i = 0; i < AbilityCount; i++)
@@ -199,6 +200,7 @@ public sealed partial class OutnumberedPlugin
string ptsColor = pd.Points > 0 ? "#66ff66" : "#888888";
var sb = _hudSb; sb.Clear();
if (_driver.HudStatusLine(pd) is { Length: > 0 } wline) sb.Append($"{wline}
");
+ if (_driver.ReviveHudLine(p, pd, html: true) is { Length: > 0 } rev) sb.Append(rev).Append("
");
sb.Append($"Lv{pd.Level} {PrestigeHudTag(pd.Prestige, now)}");
sb.Append($" {pd.Points} pt");
sb.Append($" Streak {pd.Streak}");
diff --git a/Outnumbered/Modes.cs b/Outnumbered/Modes.cs
index 8968fa1..1057e78 100644
--- a/Outnumbered/Modes.cs
+++ b/Outnumbered/Modes.cs
@@ -53,8 +53,9 @@ public interface IMatchDriver
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 died (victim); `attacker` is the killer — a bot in normal PvE combat, null/self for a suicide/fall/world death.
+ // Survival: run wipe detection + register a Field Medic revive beacon (combat deaths only). No-op elsewhere (native DM respawn).
+ void OnHumanDeath(CCSPlayerController victim, PlayerData pd, CCSPlayerController? attacker) { }
// 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.
@@ -70,6 +71,10 @@ public interface IMatchDriver
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 per-player HUD line for the survival Field Medic revive: the locator (arrow + distance to a downed ally),
+ // the channel progress bar while reviving, or the edge warning. html = the center-HTML HUD (font tags) vs plain world
+ // text. "" = nothing to show (no card, no downed ally, or not survival). Only SurvivalDriver overrides it.
+ string ReviveHudLine(CCSPlayerController p, PlayerData pd, bool html) => "";
// 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.
diff --git a/Outnumbered/Outnumbered.cs b/Outnumbered/Outnumbered.cs
index 7f2ae0d..1753b78 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.0-modes";
+ public override string ModuleVersion => "1.0.1-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 f1f0f2b..ac1f27c 100644
--- a/Outnumbered/Survival.cs
+++ b/Outnumbered/Survival.cs
@@ -49,6 +49,33 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
private double _teamDealMult = 1.0;
private double _teamTakeMult = 1.0;
+ // ---- 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)
+ private readonly Dictionary _channels = []; // medic's SteamId -> the revive they're currently channeling
+ private readonly HashSet _revivedForXpThisWave = []; // victims already paid a support-XP bounty this wave (one per saved teammate; reset at wave start)
+ private const double ReviveEdgeFrac = 0.82; // within this fraction of the radius = fine; beyond it = the amber "about to exit" warn
+
+ // A downed, revivable teammate: the death spot (revive lands them here, next to the reviver) + the world beacon over it.
+ private sealed class DownedInfo
+ {
+ public required Vector Pos { get; init; }
+ public required string Name { get; init; }
+ public bool KilledByBot { get; init; } // combat death (bot kill) vs suicide/fall/world — gates the reviver's XP bounty
+ public CPointWorldText? Beacon; // field, not property, so WorldText.Destroy(ref ...) can null it
+ public string LastText = ""; // last beacon text pushed (skip redundant SetMessage)
+ public void DestroyBeacon() => WorldText.Destroy(ref Beacon);
+ }
+
+ // One medic's in-progress channel (time-based: progress = (now - StartedAt) / channelSeconds, so it's smooth regardless of tick jitter).
+ private struct ChannelState
+ {
+ public ulong TargetSid;
+ public double StartedAt;
+ public bool NearEdge; // drifting toward the radius edge -> HUD amber warn
+ public bool WarnedEdge; // edge warn sound already played this crossing (one-shot)
+ }
+
// ---- IMatchDriver: identity ----
public string Id => "survival";
public IReadOnlyList Maps =>
@@ -87,10 +114,32 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
public void OnHeadshotDeath(CCSPlayerController victim) { } // no ladder in survival
// A human died: no team switch (mp_respawn_on_death_ct 0 keeps them dead = auto death-spectate; revived at wave-clear).
- // Run wipe detection next frame (count after the pawn is actually down).
- public void OnHumanDeath(CCSPlayerController victim, PlayerData pd)
+ // Field Medic: if the squad has a medic, mark this body revivable — capture the death spot (same corpse-position read the
+ // explode-on-kill card does in the death hook) and drop a beacon so a teammate can channel a mid-wave revive. Then run
+ // wipe detection next frame (count after the pawn is actually down; a downed-awaiting-revive player counts as not-alive,
+ // so an all-down squad still wipes — you can't revive if nobody's up).
+ public void OnHumanDeath(CCSPlayerController victim, PlayerData pd, CCSPlayerController? attacker)
{
if (!_runActive) return;
+ // Field Medic: register a revivable beacon, but ONLY for a CT participant (gate on victim.Team==CT, read NOW since
+ // team can change by the deferred frame) — this excludes a bug-2 spectator-park suicide of a mid-run T-joiner, which
+ // would otherwise become a revivable entry that pulls a non-participant onto CT (fail-closed bypass). killedByBot
+ // (captured here) gates the reviver's XP bounty so a suicide/fall can't be farmed. Capture the death spot NOW (a safe
+ // read, like the explode-on-kill card in this Pre hook) but DEFER the beacon ENTITY creation out of the Pre hook.
+ bool killedByBot = OutnumberedPlugin.IsBot(attacker); // PvE: only bots kill humans (FF is off); null/self => not combat
+ if (_phase == WavePhase.Fighting && victim.Team == CsTeam.CounterTerrorist && SquadHasMedic()
+ && !_downed.ContainsKey(pd.SteamId) && victim.PlayerPawn.Value?.AbsOrigin is { } corpse)
+ {
+ var pos = new Vector(corpse.X, corpse.Y, corpse.Z);
+ string name = victim.PlayerName;
+ ulong sid = pd.SteamId;
+ Server.NextFrame(() =>
+ {
+ // re-check the run is live + they didn't disconnect in the death->frame gap (else a ghost beacon wastes a charge)
+ if (_runActive && _phase == WavePhase.Fighting && !_downed.ContainsKey(sid) && ControllerFor(sid) is not null)
+ _downed[sid] = new DownedInfo { Pos = pos, Name = name, KilledByBot = killedByBot, Beacon = CreateBeacon(pos) };
+ });
+ }
Server.NextFrame(() =>
{
if (_runActive && _phase == WavePhase.Fighting && AliveCtHumans() == 0)
@@ -99,8 +148,13 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
}
// A human left mid-run: every CLEARED wave's XP is already in their main table (granted at each wave clear), so there's
- // nothing to bank here — the in-progress (uncleared) wave is forfeited. Just forget the run.
- public void OnHumanDisconnect(ulong steamId, PlayerData pd) => _runs.Remove(steamId);
+ // nothing to bank here — the in-progress (uncleared) wave is forfeited. Drop the run + any downed/channel state they own.
+ public void OnHumanDisconnect(ulong steamId, PlayerData pd)
+ {
+ _runs.Remove(steamId);
+ _channels.Remove(steamId); // if they were mid-channel as a medic
+ if (_downed.Remove(steamId, out var info)) info.DestroyBeacon(); // if they were the one downed
+ }
// ---- IMatchDriver: cards (EffRun overlay) ----
// The active run is the snapshot's card source; null outside a run (Snapshot.Cards then null -> Domain reads 0).
@@ -140,11 +194,15 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
// Server.CurrentTime) SIGSEGVs the server. Only SCHEDULE the heartbeat (AddTimer is safe at Load); it stays
// gated by _ready until the first map setup arms it. _ready/_mapReadyAt are set in OnMatchReset + OnMapSetup.
_tick ??= _p.AddTimer(0.5f, WaveTick, TimerFlags.REPEAT); // the wave state machine heartbeat (also drives bot_quota)
+ _reviveTick ??= _p.AddTimer(0.1f, ReviveTick, TimerFlags.REPEAT); // finer revive-channel heartbeat (proximity + progress)
}
- // Plugin Unload / hot-reload: kill the heartbeat so it can't leak or double-fire against a torn-down instance
- // (a fresh driver schedules its own _tick on the next Load). The framework doesn't reliably auto-kill plugin timers.
- public void OnDeactivated() { _tick?.Kill(); _tick = null; }
+ // Plugin Unload / hot-reload: kill the heartbeats so they can't leak or double-fire against a torn-down instance
+ // (a fresh driver schedules its own on the next Load), and destroy any live beacon entities. The framework doesn't
+ // reliably auto-kill plugin timers or our entities.
+ // Also drop _runActive/_phase so any Server.NextFrame closure still queued at teardown (beacon-create / wipe-check) fails
+ // its guard against this discarded instance instead of spawning an orphan beacon a fresh driver never learns about.
+ public void OnDeactivated() { _tick?.Kill(); _tick = null; _reviveTick?.Kill(); _reviveTick = null; _runActive = false; _phase = WavePhase.Idle; ClearRevives(); }
// Called once the map is set up (Driver.SetupMap — covers both normal map start and a hot-reload mid-map), i.e. the
// server is simulating and engine access is safe. This is what actually arms the wave machine.
@@ -156,7 +214,7 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
public void OnMatchReset()
{
- _runs.Clear(); ResetTeamBuffs();
+ _runs.Clear(); ResetTeamBuffs(); ClearRevives();
_phase = WavePhase.Idle; _wave = 0; _killsThisWave = 0; _waveBudget = 0;
_highestWaveCleared = 0; _runActive = false; _runEnded = false; _phaseUntil = 0;
_ready = true; // OnMatchReset only runs on a real map start (server up) -> safe to read the clock + arm
@@ -217,6 +275,8 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
_waveBudget = SurvivalEconomy.WaveBudget(n, humans, cfg);
_p.LogSurvival($"StartWave {n}: aliveTarget={AliveForWave(n)} budget={_waveBudget} curBots={BotCount()} humans={humans}");
_p.CloseAllShops(); // nobody frozen in a menu when the wave starts
+ RefreshMedicCharges(); // Field Medic revives refresh each wave (scaled by the medic's card level)
+ _revivedForXpThisWave.Clear(); // support-XP bounties reset per wave (one per saved teammate)
UpdateBotQuota();
Server.PrintToChatAll($" {ChatColors.Red}[Survival] WAVE {n}/{cfg.WaveCount} {ChatColors.Default}— {ChatColors.LightYellow}{_waveBudget}{ChatColors.Default} hostiles inbound. Hold the line!");
}
@@ -250,6 +310,7 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
_phase = WavePhase.Break; _phaseUntil = Server.CurrentTime + cfg.WaveBreakSeconds;
UpdateBotQuota(); // Break -> cull the field for the break (steady pool, no kick)
ReviveDead();
+ ClearRevives(); // the wave is over: ReviveDead brought back the downed (non-hardcore); in hardcore they're out. Either way the beacons/channels go.
GrantCards();
Server.PrintToChatAll($" {ChatColors.Green}[Survival] Wave {_wave} cleared! {ChatColors.Default}{cfg.WaveBreakSeconds:0}s to prep — the draft pops up ({ChatColors.Gold}X{ChatColors.Default}/crouch to close).");
_p.AddTimer(0.75f, _p.OpenDraftForAll); // auto-pop the card overlay once the revive-spawns have settled
@@ -260,13 +321,242 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
_p.LogSurvival($"EndRun: {reason} (win={win}, highestWaveCleared={_highestWaveCleared}, wave={_wave}, kills={_killsThisWave}/{_waveBudget})");
// Per-wave XP was already granted into the main table at each ClearWave; an in-progress (uncleared) wave is
// forfeited. So there's nothing to bank at run-end — just drop the runs.
- _runs.Clear();
+ _runs.Clear(); ClearRevives();
_runActive = false; _runEnded = true; _phase = WavePhase.Idle; _wave = 0;
UpdateBotQuota(); // quota 0
Server.PrintToChatAll($" {(win ? ChatColors.Gold : ChatColors.Red)}[Survival] {ChatColors.Default}{reason} (reached wave {_highestWaveCleared}).");
_p.TriggerMapEnd(win ? "Survival: VICTORY" : "Survival: run over");
}
+ // ---- Field Medic teammate-revive: the proximity channel machine ----
+ // Card-only: a downed teammate drops a world beacon; a medic (someone who drafted FieldMedic) who stands within
+ // ReviveRadius channels a revive over a level-scaled time — no keypress, proximity IS the input. Runs on the 0.1s
+ // _reviveTick so the bar/locator are smooth. All downed/beacon state is torn down at wave clear / run end / reset.
+ private readonly List _channelReap = []; // reusable scratch for pruning stale channels (avoids a per-tick Keys alloc)
+ private const int BarCells = 10; // progress-bar width in glyph cells
+ private const float BeaconRise = 45f; // how far above the death spot the beacon floats
+ private const double MetersDivisor = 40.0; // units -> "m" for the locator readout (~player height per metre)
+
+ // The medic's FieldMedic card level (picks); 0 = not a medic.
+ private static int MedicLevel(SurvivalRun run) => run.Cards.GetValueOrDefault(CardKeys.FieldMedic);
+
+ // Anyone in the run drafted Field Medic? Gates beacon creation so a no-medic squad gets no useless "down" markers.
+ private bool SquadHasMedic()
+ {
+ foreach (var run in _runs.Values) if (MedicLevel(run) > 0) return true;
+ return false;
+ }
+
+ // Refresh every medic's per-wave revive charges from their card level (called at StartWave; use-them-or-lose-them).
+ private void RefreshMedicCharges()
+ {
+ foreach (var run in _runs.Values)
+ run.ReviveCharges = SurvivalEconomy.ReviveChargesForWave(MedicLevel(run), _p.Config.Survival);
+ }
+
+ // Tear down ALL downed/channel state + destroy every beacon entity (wave clear, run end, match reset, hot-reload).
+ private void ClearRevives()
+ {
+ foreach (var info in _downed.Values) info.DestroyBeacon();
+ _downed.Clear();
+ _channels.Clear();
+ }
+
+ private CPointWorldText? CreateBeacon(Vector pos)
+ {
+ // A world-ANCHORED panel (unlike the eye-relative HUD/shop panels) — squad-visible (no CheckTransmit hiding). The
+ // orientation is refreshed each tick in RefreshBeacons to face the nearest teammate.
+ var ent = WorldText.Create(48f, 0.35f, _p.Config.Hud.FontName,
+ System.Drawing.Color.FromArgb(255, 255, 80, 80), drawBackground: true, border: 0.15f,
+ PointWorldTextJustifyHorizontal_t.POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_CENTER);
+ if (ent is not null) WorldText.Place(ent, new Vector(pos.X, pos.Y, pos.Z + BeaconRise), new QAngle(0, 0, 90));
+ return ent;
+ }
+
+ // 0.1s heartbeat: advance every alive medic's proximity channel, then keep the beacons oriented + labelled.
+ private void ReviveTick()
+ {
+ if (!_ready) return;
+ if (!_runActive || _phase != WavePhase.Fighting || _downed.Count == 0)
+ {
+ if (_channels.Count > 0) _channels.Clear(); // no active reviving outside a fighting wave with downed players
+ return;
+ }
+ double now = Server.CurrentTime;
+ foreach (var medic in CtHumans())
+ if (medic.PawnIsAlive) ProcessMedic(medic, now);
+ PruneChannels();
+ RefreshBeacons();
+ }
+
+ // One medic's channel step: nearest downed teammate in range -> (re)start / advance / complete the channel, or drop it.
+ private void ProcessMedic(CCSPlayerController medic, double now)
+ {
+ var pd = _p.PdOf(medic);
+ if (pd is null) return;
+ var pawn = medic.PlayerPawn.Value;
+ if (!_runs.TryGetValue(pd.SteamId, out var run) || run.ReviveCharges <= 0 || MedicLevel(run) <= 0
+ || pawn?.AbsOrigin is not { } mpos)
+ { _channels.Remove(pd.SteamId); return; }
+
+ double radius = _p.Config.Survival.ReviveRadius, r2 = radius * radius;
+ var (best, bestD2) = NearestDowned(mpos, pd.SteamId);
+ if (best == 0 || bestD2 > r2) { _channels.Remove(pd.SteamId); return; } // nobody in range -> the channel drops
+
+ if (!_channels.TryGetValue(pd.SteamId, out var ch) || ch.TargetSid != best)
+ ch = new ChannelState { TargetSid = best, StartedAt = now }; // new target -> restart the channel
+ ch.NearEdge = bestD2 > r2 * ReviveEdgeFrac * ReviveEdgeFrac;
+ if (ch.NearEdge && !ch.WarnedEdge) { _p.PlayReviveWarn(medic); ch.WarnedEdge = true; } // one-shot edge beep
+ else if (!ch.NearEdge) ch.WarnedEdge = false;
+ _channels[pd.SteamId] = ch;
+
+ double dur = SurvivalEconomy.ReviveChannelSeconds(MedicLevel(run), _p.Config.Survival);
+ if (dur <= 0 || now - ch.StartedAt >= dur) CompleteRevive(medic, pd, run, best);
+ }
+
+ private void CompleteRevive(CCSPlayerController medic, PlayerData medicPd, SurvivalRun run, ulong targetSid)
+ {
+ _channels.Remove(medicPd.SteamId);
+ if (!_downed.Remove(targetSid, out var info)) return; // someone else revived them this tick -> don't spend a charge
+ run.ReviveCharges--;
+ double hpPct = SurvivalEconomy.ReviveHpFraction(MedicLevel(run), _p.Config.Survival);
+ var target = ControllerFor(targetSid);
+ // Land them on the REVIVER's spot (guaranteed-walkable ground the medic is standing on), not the raw death spot —
+ // which could be a hazard/kill-volume that would just re-drop and re-kill them, burning charges for nothing.
+ var landing = medic.PlayerPawn.Value?.AbsOrigin is { } mo ? new Vector(mo.X, mo.Y, mo.Z) : info.Pos;
+ _p.ReviveDownedAt(target, landing, hpPct); // respawn + teleport beside the reviver + set HP
+ info.DestroyBeacon();
+ _p.PlayReviveComplete(medic);
+ // Support-XP: a FLAT bounty (NOT routed through WaveXp, so it never rides the x24 wave multiplier), and ONLY for a
+ // genuine COMBAT death, ONCE per saved teammate per wave — rewards saving allies, not a death<->revive farm loop.
+ if (info.KilledByBot && _revivedForXpThisWave.Add(targetSid)) _p.GrantReviveXp(medicPd, medic);
+ string tname = target?.PlayerName ?? info.Name;
+ Server.PrintToChatAll($" {ChatColors.Green}[Survival] {ChatColors.Lime}{medic.PlayerName} {ChatColors.Default}revived {ChatColors.Lime}{tname}{ChatColors.Default}!");
+ _p.LogSurvival($"revive: {medic.PlayerName} -> {tname} @ {hpPct:P0} ({run.ReviveCharges} charge(s) left)");
+ }
+
+ // Drop channel state for medics who died / disconnected since the last tick (so a dead medic's channel can't keep a
+ // beacon falsely reading "reviving"). ProcessMedic already drops channels for out-of-range / out-of-charge medics.
+ private void PruneChannels()
+ {
+ if (_channels.Count == 0) return;
+ _channelReap.Clear();
+ foreach (var sid in _channels.Keys)
+ {
+ var c = ControllerFor(sid);
+ if (c is null || !c.PawnIsAlive) _channelReap.Add(sid);
+ }
+ foreach (var sid in _channelReap) _channels.Remove(sid);
+ }
+
+ // Keep each beacon facing the nearest teammate (mirrors the HUD's face-the-viewer angle convention) + reflect whether
+ // it's actively being revived. Beacons are shared entities, so they can only face ONE viewer — the nearest is the bet.
+ private void RefreshBeacons()
+ {
+ foreach (var kv in _downed)
+ {
+ var info = kv.Value;
+ if (info.Beacon is not { IsValid: true } b) continue;
+ var top = new Vector(info.Pos.X, info.Pos.Y, info.Pos.Z + BeaconRise);
+ // face the nearest teammate — the +270 convention is single-sourced in WorldText.FacePointAngle.
+ if (NearestAliveHuman(info.Pos)?.PlayerPawn.Value?.AbsOrigin is { } vp)
+ WorldText.Place(b, top, WorldText.FacePointAngle(top, vp));
+ string txt = ChannelTargets(kv.Key) ? $"⚕ {info.Name} — reviving…" : $"⚕ {info.Name}";
+ if (info.LastText != txt) { WorldText.SetText(b, txt); info.LastText = txt; }
+ }
+ }
+
+ private bool ChannelTargets(ulong downedSid)
+ {
+ foreach (var c in _channels.Values) if (c.TargetSid == downedSid) return true;
+ return false;
+ }
+
+ // The per-player HUD line (IMatchDriver): channel bar while reviving, edge warn while drifting, else the locator to the
+ // nearest downed teammate. "" for non-medics / no downed / out of charges. html = center-HTML (font tags) vs world text.
+ public string ReviveHudLine(CCSPlayerController p, PlayerData pd, bool html)
+ {
+ if (!_runActive || _phase != WavePhase.Fighting || _downed.Count == 0) return "";
+ if (!_runs.TryGetValue(pd.SteamId, out var run) || MedicLevel(run) <= 0) return ""; // not a medic
+
+ // channeling right now -> the bar (or the edge warning)
+ if (_channels.TryGetValue(pd.SteamId, out var ch) && _downed.TryGetValue(ch.TargetSid, out var tgt))
+ {
+ if (ch.NearEdge) return WarnLine(html);
+ double dur = SurvivalEconomy.ReviveChannelSeconds(MedicLevel(run), _p.Config.Survival);
+ double prog = dur <= 0 ? 1.0 : Math.Clamp((Server.CurrentTime - ch.StartedAt) / dur, 0.0, 1.0);
+ return BarLine(tgt.Name, prog, html);
+ }
+ if (run.ReviveCharges <= 0) return ""; // no charges left this wave -> nothing to prompt
+
+ // otherwise: locate the nearest downed teammate (arrow + distance)
+ if (p.PlayerPawn.Value is not { } pawn || pawn.AbsOrigin is not { } mpos) return "";
+ var (best, bestD2) = NearestDowned(mpos, pd.SteamId);
+ if (best == 0) return "";
+ var info = _downed[best];
+ int meters = SurvivalEconomy.LocatorMeters(Math.Sqrt(bestD2), MetersDivisor);
+ return LocatorLine(info.Name, CompassArrow(pawn, info.Pos), meters, html);
+ }
+
+ private static string BarLine(string name, double prog, bool html)
+ {
+ int filled = (int)Math.Round(Math.Clamp(prog, 0.0, 1.0) * BarCells);
+ string bar = new string('█', filled) + new string('░', BarCells - filled); // █ / ░
+ int pct = (int)Math.Round(prog * 100);
+ return html
+ ? $"⚕ Reviving {Esc(name)} {bar} {pct}%"
+ : $"⚕ Reviving {name} {bar} {pct}%";
+ }
+
+ private static string WarnLine(bool html) =>
+ html ? "⚠ hold position — revive slipping"
+ : "⚠ hold position — revive slipping";
+
+ private static string LocatorLine(string name, string arrow, int meters, bool html) =>
+ html ? $"⚕ {Esc(name)} down {arrow} {meters}m"
+ : $"⚕ {name} down {arrow} {meters}m";
+
+ // 8-way compass arrow from the medic's view toward a target. The bearing->sector math is pure + golden-tested in
+ // SurvivalEconomy.ArrowIndex; this wrapper only computes the live bearing from the pawn. (If left/right read mirrored
+ // in-engine, negate the (bearing - viewYaw) term.)
+ private static string CompassArrow(CCSPlayerPawn pawn, Vector target)
+ {
+ var o = pawn.AbsOrigin;
+ if (o is null) return "";
+ double bearing = Math.Atan2(target.Y - o.Y, target.X - o.X) * 180.0 / Math.PI;
+ string[] arrows = { "↑", "↖", "←", "↙", "↓", "↘", "→", "↗" }; // ↑ ↖ ← ↙ ↓ ↘ → ↗
+ return arrows[SurvivalEconomy.ArrowIndex(bearing - pawn.EyeAngles.Y)];
+ }
+
+ private static double Dist2(Vector a, Vector b) { double dx = a.X - b.X, dy = a.Y - b.Y, dz = a.Z - b.Z; return dx * dx + dy * dy + dz * dz; }
+
+ // Nearest downed teammate to `pos` (excluding `exceptSid` — you can't revive yourself); (0, +inf) if none.
+ // Shared by the channel machine (ProcessMedic) and the HUD locator so the "closest body" pick can't drift between them.
+ private (ulong sid, double dist2) NearestDowned(Vector pos, ulong exceptSid)
+ {
+ ulong best = 0; double bestD2 = double.MaxValue;
+ foreach (var kv in _downed)
+ {
+ if (kv.Key == exceptSid) continue;
+ double d2 = Dist2(pos, kv.Value.Pos);
+ if (d2 < bestD2) { bestD2 = d2; best = kv.Key; }
+ }
+ return (best, bestD2);
+ }
+ private static string Esc(string s) => s.Replace("&", "&").Replace("<", "<").Replace(">", ">");
+
+ private static CCSPlayerController? NearestAliveHuman(Vector pos)
+ {
+ CCSPlayerController? best = null; double bestD2 = double.MaxValue;
+ foreach (var h in CtHumans())
+ if (h.PawnIsAlive && h.PlayerPawn.Value?.AbsOrigin is { } o)
+ {
+ double d2 = Dist2(pos, new Vector(o.X, o.Y, o.Z));
+ if (d2 < bestD2) { bestD2 = d2; best = h; }
+ }
+ return best;
+ }
+
// ---- bots: vanilla, count-throttled via bot_quota ----
public void ManageBots()
{
@@ -450,6 +740,12 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
var cfg = _p.Config.Survival;
return $"{cfg.BurnDamagePerSecond:0} HP/s for {cfg.BurnDurationSeconds:0}s";
}
+ // Field Medic scales off the Revive* knobs (charges/wave, channel time, revive HP%), not the card PerPick.
+ if (key == CardKeys.FieldMedic)
+ {
+ var cfg = _p.Config.Survival;
+ return $"revive allies · {SurvivalEconomy.ReviveChargesForWave(lvl, cfg)}/wave · {SurvivalEconomy.ReviveChannelSeconds(lvl, cfg):0.0}s · {SurvivalEconomy.ReviveHpFraction(lvl, cfg) * 100:0}% HP";
+ }
// Everything else: the data-driven Detail template ({0} = level x PerPick). Empty -> stat card -> Now/Next % panel.
return string.IsNullOrEmpty(def.Detail) ? null : string.Format(def.Detail, add);
}
@@ -550,24 +846,46 @@ public sealed partial class OutnumberedPlugin
p?.PrintToChat($" {ChatColors.Gold}[Survival] Wave {wave} XP: {ChatColors.Lime}+{lump:n0} {ChatColors.Default}(x{SurvivalEconomy.WaveMult(wave, cfg):F1}).");
}
- // Revive a downed survivor at the configured HP% (used between waves). mp_respawn_on_death_ct 0 keeps them down,
- // so this is a manual Respawn; they're still on CT (never moved to spectator), so no team churn.
- internal void ReviveSurvivor(CCSPlayerController p)
+ // Shared revive core: respawn a dead CT ally, then (deferred, slot+SteamID-pinned so a within-frame slot-reuse can't hit
+ // the wrong player) optionally teleport them + set HP to hpPct of their max. mp_respawn_on_death_ct 0 keeps them down, so
+ // Respawn is manual. CT-only by construction (a non-CT target is refused, not force-switched) so a stray non-participant
+ // entry can never be pulled onto CT. The two revive flavors below own only their guard/pct/teleport specifics.
+ private void ReviveAt(CCSPlayerController p, double hpPct, Vector? teleportTo)
{
if (p is not { IsValid: true } || p.IsBot || p.PawnIsAlive || p.Team != CsTeam.CounterTerrorist) return;
if (p.AuthorizedSteamID?.SteamId64 is not { } sid) return;
- double pct = Config.Survival.ReviveHpPercent;
+ var dest = teleportTo is { } t ? new Vector(t.X, t.Y, t.Z) : null;
p.Respawn();
- // Defer through the seam: re-resolve by slot + pin SteamID so a within-frame slot-reuse can't revive a different player.
NextFrameForSlot(p.Slot, sid, pl =>
{
- var pd = PdOf(pl); if (pd is null) return;
+ var pd = PdOf(pl); var pawn = pl.PlayerPawn.Value;
+ if (pd is null || pawn is null) return;
+ if (dest is not null) pawn.Teleport(dest, null, new Vector(0, 0, 0));
ApplyMaxHpArmor(pl, pd);
- var pawn = pl.PlayerPawn.Value;
- if (pawn is not null) PawnWriter.SetHealth(pawn, Math.Max(1, (int)(MaxHpOf(pd) * pct)));
+ PawnWriter.SetHealth(pawn, Math.Max(1, (int)(MaxHpOf(pd) * hpPct)));
}, requireAlive: true);
}
+ // The automatic wave-clear revive: respawn in place (at a spawn point), configured HP%.
+ internal void ReviveSurvivor(CCSPlayerController p) => ReviveAt(p, Config.Survival.ReviveHpPercent, null);
+
+ // The Field Medic teammate-revive: respawn + teleport beside the reviver at a medic-scaled HP%. No-op if the target left.
+ internal void ReviveDownedAt(CCSPlayerController? p, Vector pos, double hpPct)
+ {
+ if (p is not null) ReviveAt(p, hpPct, pos);
+ }
+
+ // A FLAT support-XP bounty for a Field Medic revive: straight into the main table (NOT the per-wave accumulator, so it
+ // never rides the wave multiplier), prestige/handicap excluded — a fixed reward for saving a teammate, not a farm.
+ internal void GrantReviveXp(PlayerData pd, CCSPlayerController medic)
+ {
+ long lump = (long)Config.Survival.ReviveXp;
+ if (lump > 0) AddConvertedXp(pd, lump, medic);
+ }
+
+ internal void PlayReviveComplete(CCSPlayerController p) => PlaySound(p, Config.Sounds.ReviveComplete);
+ internal void PlayReviveWarn(CCSPlayerController p) => PlaySound(p, Config.Sounds.ReviveWarn);
+
// A survival Max-HP / Max-Armor card just took effect: raise the live cap and top current HP/armor up by the card's
// bonus ONLY — never a full heal (a full heal on any pick would erase the 50% revive penalty and reset the squad to
// full HP every wave). Other cards never touch current HP/armor.
diff --git a/Outnumbered/SurvivalRun.cs b/Outnumbered/SurvivalRun.cs
index c590bee..e488424 100644
--- a/Outnumbered/SurvivalRun.cs
+++ b/Outnumbered/SurvivalRun.cs
@@ -9,6 +9,7 @@ public sealed class SurvivalRun(Func cardDef) :
private readonly Func _cardDef = cardDef;
public double WaveXp { get; set; } // raw combat XP banked THIS wave (granted + reset at wave clear; forfeited on a wipe/leave mid-wave)
public int RunPoints { get; set; } // unspent draft picks
+ public int ReviveCharges { get; set; } // Field Medic teammate-revives left THIS wave (refreshed at wave start from the medic-card level)
public Dictionary Cards { get; } = []; // card key -> times picked (get-only: the ref is fixed, the contents mutate)
public List DrawnThisBreak { get; set; } = []; // the current offered draw (stable across reopen within a break)