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)