Survival mode bugfixes and revive mechanism
This commit is contained in:
parent
d701598350
commit
2fa85e8b1f
14 changed files with 529 additions and 35 deletions
|
|
@ -5,6 +5,6 @@
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Deterministic>true</Deterministic>
|
<Deterministic>true</Deterministic>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -100,13 +100,43 @@ public class BalanceInvariantTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Per_mode_override_applies_only_named_fields()
|
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 baseH = T.Hcap();
|
||||||
var ov = T.Surv().Handicap!; // the default survival override
|
var ov = T.Surv().Handicap!; // the default survival override
|
||||||
var eff = ov.ApplyTo(baseH);
|
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(10.0, eff.MTakeCeiling); // overridden
|
||||||
Assert.Equal(baseH.MasterDifficulty, eff.MasterDifficulty); // inherited from base (not set in the override)
|
Assert.Equal(baseH.MasterDifficulty, eff.MasterDifficulty); // inherited from base (not set in the override)
|
||||||
Assert.Equal(baseH.Curve, eff.Curve); // inherited
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,4 +110,79 @@ public class SurvivalEconomyTests
|
||||||
[InlineData(2, 50, true, 2.25)]
|
[InlineData(2, 50, true, 2.25)]
|
||||||
public void TeamMult_matches(int level, double perPick, bool increase, double expected) =>
|
public void TeamMult_matches(int level, double perPick, bool increase, double expected) =>
|
||||||
T.Close(expected, SurvivalEconomy.TeamMult(level, perPick, increase));
|
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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,20 @@ public sealed class SurvivalConfig
|
||||||
|
|
||||||
// ---- death / revive ----
|
// ---- death / revive ----
|
||||||
[JsonPropertyName("ReviveOnWaveClear")] public bool ReviveOnWaveClear { get; set; } = true; // false = hardcore "you die, you spectate the rest of the run"
|
[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:
|
// ---- 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).
|
// 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;
|
[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
|
// 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.
|
// 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 —
|
// Survival's handicap is driven SOLELY by wave progression (the monotonic HandicapFloor -> max nerf at MaxNerfWave),
|
||||||
// otherwise a high-level player is pre-nerfed to near-max-take on wave 1 (level/KD maxing the factor before the
|
// never by in-run performance: all four skill factors (K/D, headshot rate, streak, level) are weighted 0, so a wave's
|
||||||
// floor even ramps). Low weights keep a mild skill component; the floor (-> max nerf at MaxNerfWave) does the work.
|
// 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")]
|
[JsonPropertyName("Handicap")]
|
||||||
public HandicapOverride? Handicap { get; set; } = new()
|
public HandicapOverride? Handicap { get; set; } = new()
|
||||||
{
|
{
|
||||||
ProgressWeight = 0.0,
|
ProgressWeight = 0.0,
|
||||||
MTakeCeiling = 10.0,
|
MTakeCeiling = 10.0,
|
||||||
KdWeight = 0.3,
|
KdWeight = 0.0, // in-run performance must NOT move the handicap — the wave floor is the only driver
|
||||||
HsWeight = 0.3,
|
HsWeight = 0.0,
|
||||||
StreakWeight = 0.3,
|
StreakWeight = 0.0,
|
||||||
LevelWeight = 0.1,
|
LevelWeight = 0.0,
|
||||||
MDealFloor = 0.1, // pin: the base dropped to 0.033 for TDM, but waves need a viable deal to be clearable
|
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.
|
// 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("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("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.
|
// 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_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
|
new("global_take", "Team: -Damage Taken", 10, 3, isTeam: true),// -10%/level ALL incoming dmg, compounding -> x0.729 squad-wide maxed
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,10 @@ public sealed class SoundsConfig
|
||||||
[JsonPropertyName("AbilityReady")] public string AbilityReady { get; set; } = "sounds/ambient/office/tech_oneshot_08.wav";
|
[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("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
|
[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
|
// The local read-only status/balance/top API, served over a per-instance Unix domain socket (Api.cs). The companion
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ public static class CardKeys
|
||||||
public const string XpMult = "xp_mult"; // +% run-XP earned
|
public const string XpMult = "xp_mult"; // +% run-XP earned
|
||||||
public const string HsReduction = "hs_reduction"; // -% incoming headshot damage (per-player, leveled)
|
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 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 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)
|
public const string GlobalTake = "global_take"; // TEAM: -dmg taken, squad-wide, compounding (into MTake)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,4 +45,30 @@ public static class SurvivalEconomy
|
||||||
public static double TeamMult(int level, double perPickPct, bool increase) =>
|
public static double TeamMult(int level, double perPickPct, bool increase) =>
|
||||||
increase ? Math.Pow(1.0 + perPickPct / 100.0, level)
|
increase ? Math.Pow(1.0 + perPickPct / 100.0, level)
|
||||||
: Math.Pow(Math.Max(0.0, 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)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@ public sealed partial class OutnumberedPlugin
|
||||||
vpd.Deaths++; vpd.Streak = 0;
|
vpd.Deaths++; vpd.Streak = 0;
|
||||||
// streak-earned availability is lost on death; active effects end. Cooldowns keep ticking (spec §4).
|
// streak-earned availability is lost on death; active effects end. Cooldowns keep ticking (spec §4).
|
||||||
Array.Clear(vpd.AbilityActiveUntil);
|
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)
|
// 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)
|
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.");
|
p.PrintToChat("[Outnumbered] A survival run is in progress — you'll join the next one.");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -380,10 +383,11 @@ public sealed partial class OutnumberedPlugin
|
||||||
|
|
||||||
switch (p.Team)
|
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.CounterTerrorist when ctHumans >= cap:
|
||||||
case CsTeam.Terrorist or CsTeam.None 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.");
|
p.PrintToChat($"[Outnumbered] CT is full (max {cap}) — moved to spectator.");
|
||||||
break;
|
break;
|
||||||
// a T/None arrival with room -> put them on CT (native DM spawns them on the new team)
|
// a T/None arrival with room -> put them on CT (native DM spawns them on the new team)
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,16 @@ internal static class WorldText
|
||||||
return true;
|
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).
|
// Source-engine AngleVectors with roll assumed 0 (HUD/shop panels never roll).
|
||||||
private static (Vector forward, Vector right, Vector up) AngleVectors(QAngle a)
|
private static (Vector forward, Vector right, Vector up) AngleVectors(QAngle a)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
double now = Server.CurrentTime; // read once for all ability segments (clock can't advance within this build)
|
||||||
var sb = _hudSb; sb.Clear();
|
var sb = _hudSb; sb.Clear();
|
||||||
if (_driver.HudStatusLine(pd) is { Length: > 0 } wline) sb.Append(wline).Append('\n');
|
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($"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");
|
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++)
|
for (int i = 0; i < AbilityCount; i++)
|
||||||
|
|
@ -199,6 +200,7 @@ public sealed partial class OutnumberedPlugin
|
||||||
string ptsColor = pd.Points > 0 ? "#66ff66" : "#888888";
|
string ptsColor = pd.Points > 0 ? "#66ff66" : "#888888";
|
||||||
var sb = _hudSb; sb.Clear();
|
var sb = _hudSb; sb.Clear();
|
||||||
if (_driver.HudStatusLine(pd) is { Length: > 0 } wline) sb.Append($"<font color='#ff5a5a'>{wline}</font><br>");
|
if (_driver.HudStatusLine(pd) is { Length: > 0 } wline) sb.Append($"<font color='#ff5a5a'>{wline}</font><br>");
|
||||||
|
if (_driver.ReviveHudLine(p, pd, html: true) is { Length: > 0 } rev) sb.Append(rev).Append("<br>");
|
||||||
sb.Append($"<font color='#ffae00'>Lv{pd.Level}</font> {PrestigeHudTag(pd.Prestige, now)}");
|
sb.Append($"<font color='#ffae00'>Lv{pd.Level}</font> {PrestigeHudTag(pd.Prestige, now)}");
|
||||||
sb.Append($" <font color='{ptsColor}'>{pd.Points} pt</font>");
|
sb.Append($" <font color='{ptsColor}'>{pd.Points} pt</font>");
|
||||||
sb.Append($" <font color='{streakColor}'>Streak {pd.Streak}</font>");
|
sb.Append($" <font color='{streakColor}'>Streak {pd.Streak}</font>");
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,9 @@ public interface IMatchDriver
|
||||||
bool OwnsBotPopulation => false;
|
bool OwnsBotPopulation => false;
|
||||||
// Drive bot population (called from SyncBots when OwnsBotPopulation): survival sets bot_quota from the wave kill-budget.
|
// Drive bot population (called from SyncBots when OwnsBotPopulation): survival sets bot_quota from the wave kill-budget.
|
||||||
void ManageBots() { }
|
void ManageBots() { }
|
||||||
// A human died (victim). Survival: move to spectator + run wipe detection. No-op elsewhere (native DM respawn handles it).
|
// A human died (victim); `attacker` is the killer — a bot in normal PvE combat, null/self for a suicide/fall/world death.
|
||||||
void OnHumanDeath(CCSPlayerController victim, PlayerData pd) { }
|
// 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.
|
// 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) { }
|
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.
|
// 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;
|
double TeamTakeMult() => 1.0;
|
||||||
// Optional mode status line for the HUD (survival shows the wave / bots-left line). "" = no line.
|
// Optional mode status line for the HUD (survival shows the wave / bots-left line). "" = no line.
|
||||||
string HudStatusLine(PlayerData pd) => "";
|
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.
|
// Optional weapon-ladder status line for the shop info panel (Gun Game shows the current rung). null = N/A.
|
||||||
string? LadderStatusLine(PlayerData pd) => null;
|
string? LadderStatusLine(PlayerData pd) => null;
|
||||||
// Mode-specific block for the local status API (Api.cs), serialized as-is into the payload's "Extra" field.
|
// Mode-specific block for the local status API (Api.cs), serialized as-is into the payload's "Extra" field.
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ namespace Outnumbered;
|
||||||
public sealed partial class OutnumberedPlugin : BasePlugin, IPluginConfig<OutnumberedConfig>
|
public sealed partial class OutnumberedPlugin : BasePlugin, IPluginConfig<OutnumberedConfig>
|
||||||
{
|
{
|
||||||
public override string ModuleName => "Outnumbered";
|
public override string ModuleName => "Outnumbered";
|
||||||
public override string ModuleVersion => "1.0.0-modes";
|
public override string ModuleVersion => "1.0.1-modes";
|
||||||
public override string ModuleAuthor => "snake";
|
public override string ModuleAuthor => "snake";
|
||||||
public override string ModuleDescription => "Players-vs-bots RPG / perk mod.";
|
public override string ModuleDescription => "Players-vs-bots RPG / perk mod.";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,33 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
||||||
private double _teamDealMult = 1.0;
|
private double _teamDealMult = 1.0;
|
||||||
private double _teamTakeMult = 1.0;
|
private double _teamTakeMult = 1.0;
|
||||||
|
|
||||||
|
// ---- 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<ulong, DownedInfo> _downed = []; // downed player's SteamId -> death spot + world beacon (created only while the squad has a medic)
|
||||||
|
private readonly Dictionary<ulong, ChannelState> _channels = []; // medic's SteamId -> the revive they're currently channeling
|
||||||
|
private readonly HashSet<ulong> _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 ----
|
// ---- IMatchDriver: identity ----
|
||||||
public string Id => "survival";
|
public string Id => "survival";
|
||||||
public IReadOnlyList<string> Maps =>
|
public IReadOnlyList<string> Maps =>
|
||||||
|
|
@ -87,10 +114,32 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
||||||
public void OnHeadshotDeath(CCSPlayerController victim) { } // no ladder in survival
|
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).
|
// 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).
|
// Field Medic: if the squad has a medic, mark this body revivable — capture the death spot (same corpse-position read the
|
||||||
public void OnHumanDeath(CCSPlayerController victim, PlayerData pd)
|
// 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;
|
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(() =>
|
Server.NextFrame(() =>
|
||||||
{
|
{
|
||||||
if (_runActive && _phase == WavePhase.Fighting && AliveCtHumans() == 0)
|
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
|
// 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.
|
// 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);
|
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) ----
|
// ---- IMatchDriver: cards (EffRun overlay) ----
|
||||||
// The active run is the snapshot's card source; null outside a run (Snapshot.Cards then null -> Domain reads 0).
|
// 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
|
// 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.
|
// 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)
|
_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
|
// 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 _tick on the next Load). The framework doesn't reliably auto-kill plugin timers.
|
// (a fresh driver schedules its own on the next Load), and destroy any live beacon entities. The framework doesn't
|
||||||
public void OnDeactivated() { _tick?.Kill(); _tick = null; }
|
// 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
|
// 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.
|
// 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()
|
public void OnMatchReset()
|
||||||
{
|
{
|
||||||
_runs.Clear(); ResetTeamBuffs();
|
_runs.Clear(); ResetTeamBuffs(); ClearRevives();
|
||||||
_phase = WavePhase.Idle; _wave = 0; _killsThisWave = 0; _waveBudget = 0;
|
_phase = WavePhase.Idle; _wave = 0; _killsThisWave = 0; _waveBudget = 0;
|
||||||
_highestWaveCleared = 0; _runActive = false; _runEnded = false; _phaseUntil = 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
|
_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);
|
_waveBudget = SurvivalEconomy.WaveBudget(n, humans, cfg);
|
||||||
_p.LogSurvival($"StartWave {n}: aliveTarget={AliveForWave(n)} budget={_waveBudget} curBots={BotCount()} humans={humans}");
|
_p.LogSurvival($"StartWave {n}: aliveTarget={AliveForWave(n)} budget={_waveBudget} curBots={BotCount()} humans={humans}");
|
||||||
_p.CloseAllShops(); // nobody frozen in a menu when the wave starts
|
_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();
|
UpdateBotQuota();
|
||||||
Server.PrintToChatAll($" {ChatColors.Red}[Survival] WAVE {n}/{cfg.WaveCount} {ChatColors.Default}— {ChatColors.LightYellow}{_waveBudget}{ChatColors.Default} hostiles inbound. Hold the line!");
|
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;
|
_phase = WavePhase.Break; _phaseUntil = Server.CurrentTime + cfg.WaveBreakSeconds;
|
||||||
UpdateBotQuota(); // Break -> cull the field for the break (steady pool, no kick)
|
UpdateBotQuota(); // Break -> cull the field for the break (steady pool, no kick)
|
||||||
ReviveDead();
|
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();
|
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).");
|
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
|
_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})");
|
_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
|
// 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.
|
// 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;
|
_runActive = false; _runEnded = true; _phase = WavePhase.Idle; _wave = 0;
|
||||||
UpdateBotQuota(); // quota 0
|
UpdateBotQuota(); // quota 0
|
||||||
Server.PrintToChatAll($" {(win ? ChatColors.Gold : ChatColors.Red)}[Survival] {ChatColors.Default}{reason} (reached wave {_highestWaveCleared}).");
|
Server.PrintToChatAll($" {(win ? ChatColors.Gold : ChatColors.Red)}[Survival] {ChatColors.Default}{reason} (reached wave {_highestWaveCleared}).");
|
||||||
_p.TriggerMapEnd(win ? "Survival: VICTORY" : "Survival: run over");
|
_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<ulong> _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
|
||||||
|
? $"<font color='#7dff7d'>⚕ Reviving {Esc(name)} {bar} {pct}%</font>"
|
||||||
|
: $"⚕ Reviving {name} {bar} {pct}%";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WarnLine(bool html) =>
|
||||||
|
html ? "<font color='#ffb000'>⚠ hold position — revive slipping</font>"
|
||||||
|
: "⚠ hold position — revive slipping";
|
||||||
|
|
||||||
|
private static string LocatorLine(string name, string arrow, int meters, bool html) =>
|
||||||
|
html ? $"<font color='#66ddff'>⚕ {Esc(name)} down {arrow} {meters}m</font>"
|
||||||
|
: $"⚕ {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 ----
|
// ---- bots: vanilla, count-throttled via bot_quota ----
|
||||||
public void ManageBots()
|
public void ManageBots()
|
||||||
{
|
{
|
||||||
|
|
@ -450,6 +740,12 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
||||||
var cfg = _p.Config.Survival;
|
var cfg = _p.Config.Survival;
|
||||||
return $"{cfg.BurnDamagePerSecond:0} HP/s for {cfg.BurnDurationSeconds:0}s";
|
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.
|
// 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);
|
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}).");
|
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,
|
// Shared revive core: respawn a dead CT ally, then (deferred, slot+SteamID-pinned so a within-frame slot-reuse can't hit
|
||||||
// so this is a manual Respawn; they're still on CT (never moved to spectator), so no team churn.
|
// the wrong player) optionally teleport them + set HP to hpPct of their max. mp_respawn_on_death_ct 0 keeps them down, so
|
||||||
internal void ReviveSurvivor(CCSPlayerController p)
|
// 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 is not { IsValid: true } || p.IsBot || p.PawnIsAlive || p.Team != CsTeam.CounterTerrorist) return;
|
||||||
if (p.AuthorizedSteamID?.SteamId64 is not { } sid) return;
|
if (p.AuthorizedSteamID?.SteamId64 is not { } sid) return;
|
||||||
double pct = Config.Survival.ReviveHpPercent;
|
var dest = teleportTo is { } t ? new Vector(t.X, t.Y, t.Z) : null;
|
||||||
p.Respawn();
|
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 =>
|
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);
|
ApplyMaxHpArmor(pl, pd);
|
||||||
var pawn = pl.PlayerPawn.Value;
|
PawnWriter.SetHealth(pawn, Math.Max(1, (int)(MaxHpOf(pd) * hpPct)));
|
||||||
if (pawn is not null) PawnWriter.SetHealth(pawn, Math.Max(1, (int)(MaxHpOf(pd) * pct)));
|
|
||||||
}, requireAlive: true);
|
}, 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
|
// 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
|
// 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.
|
// full HP every wave). Other cards never touch current HP/armor.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ public sealed class SurvivalRun(Func<string, Config.SurvivalCardDef?> cardDef) :
|
||||||
private readonly Func<string, Config.SurvivalCardDef?> _cardDef = cardDef;
|
private readonly Func<string, Config.SurvivalCardDef?> _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 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 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<string, int> Cards { get; } = []; // card key -> times picked (get-only: the ref is fixed, the contents mutate)
|
public Dictionary<string, int> Cards { get; } = []; // card key -> times picked (get-only: the ref is fixed, the contents mutate)
|
||||||
public List<string> DrawnThisBreak { get; set; } = []; // the current offered draw (stable across reopen within a break)
|
public List<string> DrawnThisBreak { get; set; } = []; // the current offered draw (stable across reopen within a break)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue