188 lines
8.3 KiB
C#
188 lines
8.3 KiB
C#
using Outnumbered.Config;
|
|
using Outnumbered.Domain;
|
|
using Xunit;
|
|
|
|
namespace Outnumbered.Tests;
|
|
|
|
// SurvivalEconomy: wave population/budget/escalation + the PER-WAVE XP grant. Defaults (DomainConfig.SurvivalConfig):
|
|
// AliveBase=4, AlivePerWave=2, AliveCap=20; BudgetBase=6, BudgetPerWave=4, BudgetPerPlayer=3; MaxNerfWave=25;
|
|
// WaveCount=20, WinMult=24. XP is granted per cleared wave: rawWaveXp x prestige x WaveMult(wave) (no cap, no XpBoost,
|
|
// handicap excluded). WaveMult(w) = WinMult^((w-1)/(WaveCount-1)).
|
|
public class SurvivalEconomyTests
|
|
{
|
|
// AliveForWave = clamp(AliveBase + AlivePerWave*(wave-1), 1, AliveCap).
|
|
[Theory]
|
|
[InlineData(1, 4)] // base
|
|
[InlineData(2, 6)]
|
|
[InlineData(9, 20)] // 4 + 2*8 = 20 -> hits cap
|
|
[InlineData(50, 20)] // clamped to AliveCap
|
|
public void AliveForWave_ramps_then_caps(int wave, int expected) =>
|
|
Assert.Equal(expected, SurvivalEconomy.AliveForWave(wave, T.Surv()));
|
|
|
|
[Fact]
|
|
public void AliveForWave_never_below_one() =>
|
|
Assert.Equal(1, SurvivalEconomy.AliveForWave(-5, T.Surv())); // lower clamp
|
|
|
|
// WaveBudget = max(1, BudgetBase + BudgetPerWave*(wave-1) + BudgetPerPlayer*aliveHumans).
|
|
[Theory]
|
|
[InlineData(1, 1, 9)] // 6 + 0 + 3*1
|
|
[InlineData(1, 4, 18)] // 6 + 0 + 3*4
|
|
[InlineData(5, 3, 31)] // 6 + 16 + 9
|
|
public void WaveBudget_matches(int wave, int aliveHumans, int expected) =>
|
|
Assert.Equal(expected, SurvivalEconomy.WaveBudget(wave, aliveHumans, T.Surv()));
|
|
|
|
[Fact]
|
|
public void WaveBudget_never_below_one() =>
|
|
Assert.Equal(1, SurvivalEconomy.WaveBudget(-10, 0, T.Surv()));
|
|
|
|
// HandicapFloor = wave<=0 ? -1 : min(1, wave/max(1,MaxNerfWave)). Monotonic escalate-only floor in t-space.
|
|
[Theory]
|
|
[InlineData(0, -1.0)] // idle / between runs
|
|
[InlineData(-3, -1.0)]
|
|
[InlineData(1, 0.04)] // 1/25
|
|
[InlineData(25, 1.0)] // reaches full nerf at MaxNerfWave
|
|
[InlineData(40, 1.0)] // clamped at 1
|
|
public void HandicapFloor_matches(int wave, double expected) =>
|
|
T.Close(expected, SurvivalEconomy.HandicapFloor(wave, T.Surv()));
|
|
|
|
[Fact]
|
|
public void HandicapFloor_is_monotonic_non_decreasing()
|
|
{
|
|
var c = T.Surv();
|
|
double prev = SurvivalEconomy.HandicapFloor(1, c);
|
|
for (int w = 2; w <= c.WaveCount; w++)
|
|
{
|
|
double cur = SurvivalEconomy.HandicapFloor(w, c);
|
|
Assert.True(cur >= prev, $"floor wave {w}={cur} < wave {w - 1}={prev}");
|
|
prev = cur;
|
|
}
|
|
}
|
|
|
|
// WaveMult(w) = WinMult ^ ((w-1)/(WaveCount-1)). Use a clean config (WinMult 100, WaveCount 11) for exact landmarks.
|
|
private static SurvivalConfig Clean() => new() { WinMult = 100, WaveCount = 11 };
|
|
|
|
[Theory]
|
|
[InlineData(1, 1.0)] // wave 1 -> x1
|
|
[InlineData(6, 10.0)] // midpoint -> 100^0.5 = 10
|
|
[InlineData(11, 100.0)] // final wave -> xWinMult
|
|
public void WaveMult_ramps_one_to_winmult(int wave, double expected) =>
|
|
T.Close(expected, SurvivalEconomy.WaveMult(wave, Clean()));
|
|
|
|
[Fact]
|
|
public void WaveMult_endpoints_on_defaults()
|
|
{
|
|
var c = T.Surv();
|
|
T.Close(1.0, SurvivalEconomy.WaveMult(1, c)); // wave 1 always x1
|
|
T.Close(c.WinMult, SurvivalEconomy.WaveMult(c.WaveCount, c)); // final wave = WinMult
|
|
}
|
|
|
|
[Fact]
|
|
public void Retune_pinned_winmult_default() => Assert.Equal(24.0, T.Surv().WinMult); // pins the survival XP knob
|
|
|
|
// WaveXpLump = floor(rawWaveXp * prestigeMult * WaveMult(wave)); NO cap, NO XpBoost, handicap excluded.
|
|
[Theory]
|
|
[InlineData(1000, 6, 0, 10000)] // 1000 * 1.0 * 10
|
|
[InlineData(1000, 11, 0, 100000)] // 1000 * 1.0 * 100
|
|
[InlineData(1000, 1, 5, 1500)] // 1000 * 1.5(prestige) * 1
|
|
[InlineData(1000, 6, 10, 20000)] // 1000 * 2.0(prestige) * 10
|
|
public void WaveXpLump_matches(double rawWaveXp, int wave, int prestige, long expected) =>
|
|
Assert.Equal(expected, SurvivalEconomy.WaveXpLump(rawWaveXp, wave, prestige, Clean(), T.Prog()));
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(-50)]
|
|
public void WaveXpLump_zero_for_nonpositive(double rawWaveXp) =>
|
|
Assert.Equal(0L, SurvivalEconomy.WaveXpLump(rawWaveXp, 6, 0, Clean(), T.Prog()));
|
|
|
|
// AccrueWaveXp = amount * (1 + xpMultCard/100).
|
|
[Theory]
|
|
[InlineData(100, 0, 100)]
|
|
[InlineData(100, 50, 150)]
|
|
public void AccrueWaveXp_matches(double amount, double cardPct, double expected) =>
|
|
T.Close(expected, SurvivalEconomy.AccrueWaveXp(amount, cardPct));
|
|
|
|
// TeamMult: compounding per-level squad card. increase -> (1+p/100)^L ; decrease -> max(0,1-p/100)^L.
|
|
[Theory]
|
|
[InlineData(3, 10, true, 1.3310000000000004)]
|
|
[InlineData(3, 10, false, 0.7290000000000001)]
|
|
[InlineData(0, 10, true, 1.0)]
|
|
[InlineData(3, 100, false, 0.0)] // 1-100% = 0, floored
|
|
[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));
|
|
}
|