SimRateTests: idle banking, magazine cadence, glowing passive target; equivalence and shortest-timer tests; ping-arm trace repinned for the exact decay
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
549437536a
commit
48ba0a6204
3 changed files with 173 additions and 5 deletions
|
|
@ -188,7 +188,7 @@ public sealed partial class RunStartTests
|
|||
// Two piloted fighters about 900 apart, each approaching the other, hostile both ways; the player bare, unpiloted
|
||||
// and far from the fight. Steps TwoAiSeconds at `dt` (the game's step by default) and returns the SHA-256 of the joined
|
||||
// snapshots, the hull each fighter ends with and the sim time a hull first reaches zero (TwoAiSeconds when none does).
|
||||
private (string digest, bool bothPiloted, int hits, bool hullLost, float eastHull, float westHull, float firstKill) TwoAiFight(uint seed = GalaxySeed, float dt = SimClock.GameStep)
|
||||
private (string digest, bool bothPiloted, int hits, bool hullLost, float eastHull, float westHull, float firstKill, double stepSeconds) TwoAiFight(uint seed = GalaxySeed, float dt = SimClock.GameStep)
|
||||
{
|
||||
Ship east = null, west = null;
|
||||
var (_, arena, _, failures) = Launch(new Scripted(false, stage =>
|
||||
|
|
@ -215,6 +215,7 @@ public sealed partial class RunStartTests
|
|||
var history = new StringBuilder();
|
||||
var firstKill = TwoAiSeconds;
|
||||
var steps = (int) MathF.Round(TwoAiSeconds / dt);
|
||||
var clock = System.Diagnostics.Stopwatch.StartNew();
|
||||
for (var step = 0; step < steps; step++)
|
||||
{
|
||||
arena.Update(dt);
|
||||
|
|
@ -222,7 +223,7 @@ public sealed partial class RunStartTests
|
|||
if (firstKill == TwoAiSeconds && (east.Hull.Durability <= 0 || west.Hull.Durability <= 0)) firstKill = (step + 1) * dt;
|
||||
}
|
||||
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(history.ToString()))).ToLowerInvariant();
|
||||
return (digest, bothPiloted, hits, east.Hull.Durability + west.Hull.Durability < hullAtStart, east.Hull.Durability, west.Hull.Durability, firstKill);
|
||||
return (digest, bothPiloted, hits, east.Hull.Durability + west.Hull.Durability < hullAtStart, east.Hull.Durability, west.Hull.Durability, firstKill, clock.Elapsed.TotalSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -1051,7 +1051,7 @@ public sealed partial class MiningCut3Tests : IDisposable
|
|||
}
|
||||
Assert.True(unsaturatedPings > 0, "no configuration saw a ping land unsaturated");
|
||||
var pingHash = Convert.ToHexString(SHA256.HashData(bytes.ToArray()));
|
||||
Assert.True(pingHash == "REPIN", $"the ping-arm trace hash {pingHash}");
|
||||
Assert.True(pingHash == "283A6129533784AE08FDA7D9FFA2BB0862FA8F64AD32C6726E80BC1138443FE3", $"the ping-arm trace hash {pingHash}");
|
||||
}
|
||||
|
||||
// One observer and two reflecting ships in an empty zone, 80 ticks, a ping at tick 20 when `ping`.
|
||||
|
|
|
|||
|
|
@ -199,7 +199,10 @@ public sealed partial class ContactRevealTests
|
|||
{
|
||||
var observer = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
var target = Add(float3(0, 0, 100));
|
||||
Tick(.1f); // brings the signatures and sensors up
|
||||
var marker = new object();
|
||||
void Glow() => target.SetSignature(marker, SignatureChannel.Thermal, 5f, null); // held as bright as a thruster
|
||||
Glow();
|
||||
Tick(0f);
|
||||
var distance = 100f;
|
||||
while (observer.PassiveInfo(float2(0, distance), target.Visibility) > .5f)
|
||||
{
|
||||
|
|
@ -208,8 +211,172 @@ public sealed partial class ContactRevealTests
|
|||
}
|
||||
var documented = observer.PassiveInfo(target.Position.xz, target.Visibility);
|
||||
Assert.InRange(documented, 1e-4f, .5f);
|
||||
for (var step = 0; step < (int) MathF.Round(40f / dt); step++) Tick(dt);
|
||||
for (var step = 0; step < (int) MathF.Round(40f / dt); step++)
|
||||
{
|
||||
Glow();
|
||||
Tick(dt);
|
||||
}
|
||||
Assert.InRange(Track(observer, target), documented - 1e-3f, documented + 1e-3f);
|
||||
}
|
||||
}
|
||||
|
||||
// An idle timer banks at most one step: after ten idle seconds the first two restarts are still a cooldown apart.
|
||||
[Fact]
|
||||
public void AnIdleSensorBanksAtMostOneStep()
|
||||
{
|
||||
foreach (var dt in CadenceSteps)
|
||||
{
|
||||
var name = "Pinger@.5";
|
||||
if (_openCache.GetByName<ItemData>(name) == null)
|
||||
{
|
||||
var sensor = SensorBehavior(sensitivity: 2f);
|
||||
sensor.PingCooldown = Const(.5f);
|
||||
_openCache.Upsert(new GearData
|
||||
{
|
||||
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
||||
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
||||
Behaviors = { sensor }
|
||||
});
|
||||
}
|
||||
var ship = Add(float3(0, 0, 0), new[] { name });
|
||||
var times = new List<float>();
|
||||
SensorOf(ship).OnPingStart += () => times.Add(_zone.Time);
|
||||
for (var step = 0; step < (int) MathF.Round(10f / dt); step++) Tick(dt);
|
||||
for (var step = 0; step < (int) MathF.Round(3f / dt); step++)
|
||||
{
|
||||
SensorOf(ship).Ping();
|
||||
Tick(dt);
|
||||
}
|
||||
Assert.True(times.Count >= 4);
|
||||
for (var i = 1; i < times.Count; i++)
|
||||
Assert.True(times[i] - times[i - 1] >= .5f - dt - 1e-4f, $"dt {dt}: pings at {times[i - 1]} and {times[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnIdleCooldownBanksAtMostOneStep()
|
||||
{
|
||||
foreach (var dt in CadenceSteps)
|
||||
{
|
||||
var name = "Metronome@.5";
|
||||
if (_openCache.GetByName<ItemData>(name) == null)
|
||||
_openCache.Upsert(Tool(name, new CooldownData { Cooldown = Const(.5f) }));
|
||||
var ship = Add(float3(0, 0, 0), null, new[] { name });
|
||||
var clock = ship.Equipment.SelectMany(i => i.Behaviors).OfType<Cooldown>().Single();
|
||||
for (var step = 0; step < (int) MathF.Round(10f / dt); step++) Tick(dt);
|
||||
var times = new List<float>();
|
||||
for (var step = 0; step < (int) MathF.Round(3f / dt); step++)
|
||||
{
|
||||
Tick(dt);
|
||||
if (clock.Execute(dt)) times.Add(_zone.Time);
|
||||
}
|
||||
Assert.True(times.Count >= 4);
|
||||
for (var i = 1; i < times.Count; i++)
|
||||
Assert.True(times[i] - times[i - 1] >= .5f - dt - 1e-4f, $"dt {dt}: restarts at {times[i - 1]} and {times[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
// A magazine of three, cooldown .1 s and a .9 s reload is three rounds a 1.1 s cycle, at any step.
|
||||
[Fact]
|
||||
public void MagazineCadenceIsPerSecond()
|
||||
{
|
||||
foreach (var dt in CadenceSteps)
|
||||
{
|
||||
var name = "Magazine3";
|
||||
if (_openCache.GetByName<ItemData>(name) == null)
|
||||
{
|
||||
var gun = Armed(new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(.1f), ReloadTime = .9f }, 3);
|
||||
_openCache.Upsert(new WeaponItemData
|
||||
{
|
||||
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
||||
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
||||
Fuse = WeaponFuse.Proximity, BlastRadius = 30f,
|
||||
Behaviors = { gun }
|
||||
});
|
||||
}
|
||||
var ship = Add(float3(0, 0, 0), new[] { name }, new[] { "Targeting", "Reactor" });
|
||||
var rounds = HoldTrigger(ship, Guns(ship)[0], dt, 33f);
|
||||
Assert.True(abs(rounds.Count - 90f) <= 3.01f, $"dt {dt}: {rounds.Count} rounds in 33 s, expected 90 +-3");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed partial class RunStartTests
|
||||
{
|
||||
private static readonly string EquivalenceTable = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "simrate-equivalence.txt");
|
||||
|
||||
// The two-AI fight over 40 match seeds at the old 60 Hz and at the game's step: the same fight to within noise. The
|
||||
// win rate (east's hull above west's) differs by less than a 95% two-proportion interval, and the mean survivor hull
|
||||
// and the mean time to first kill each move by less than 10%. The table is written beside the test for the report.
|
||||
[Fact, Trait("Category", "Slow")]
|
||||
public void SimRateEquivalence()
|
||||
{
|
||||
const int seeds = 40;
|
||||
var table = new System.Text.StringBuilder();
|
||||
(double wins, double survivor, double firstKill, double seconds) Run(float dt)
|
||||
{
|
||||
var wins = 0;
|
||||
double survivor = 0, firstKill = 0, seconds = 0;
|
||||
for (uint seed = 1; seed <= seeds; seed++)
|
||||
{
|
||||
var fight = TwoAiFight(seed, dt);
|
||||
if (fight.eastHull > fight.westHull) wins++;
|
||||
survivor += Math.Max(fight.eastHull, fight.westHull);
|
||||
firstKill += fight.firstKill;
|
||||
seconds += fight.stepSeconds;
|
||||
table.AppendLine($"dt {dt:R} seed {seed}: east {fight.eastHull:R} west {fight.westHull:R} first kill {fight.firstKill:R} s, {fight.stepSeconds:F2} s wall");
|
||||
}
|
||||
return (wins / (double) seeds, survivor / seeds, firstKill / seeds, seconds);
|
||||
}
|
||||
|
||||
var old = Run(1f / 60f);
|
||||
var now = Run(SimClock.GameStep);
|
||||
var se = Math.Sqrt(old.wins * (1 - old.wins) / seeds + now.wins * (1 - now.wins) / seeds);
|
||||
table.AppendLine($"60 Hz: east wins {old.wins:P0}, mean survivor hull {old.survivor:F1}, mean first kill {old.firstKill:F2} s, {old.seconds:F1} s wall");
|
||||
table.AppendLine($"{SimClock.TickRate} Hz: east wins {now.wins:P0}, mean survivor hull {now.survivor:F1}, mean first kill {now.firstKill:F2} s, {now.seconds:F1} s wall");
|
||||
table.AppendLine($"win-rate difference {Math.Abs(old.wins - now.wins):F3} (95% interval +-{1.96 * se:F3}); wall-time speedup {old.seconds / now.seconds:F2}x");
|
||||
System.IO.File.WriteAllText(EquivalenceTable, table.ToString());
|
||||
|
||||
Assert.True(Math.Abs(old.wins - now.wins) <= 1.96 * se, table.ToString());
|
||||
Assert.True(Math.Abs(now.survivor - old.survivor) <= .1 * old.survivor, table.ToString());
|
||||
Assert.True(Math.Abs(now.firstKill - old.firstKill) <= .1 * old.firstKill, table.ToString());
|
||||
}
|
||||
|
||||
// Every shipped cooldown, ping cooldown and ammo interval spans at least two steps. A burst interval under one step
|
||||
// fires several rounds in one step (carry makes that exact on average); those are listed, not failed.
|
||||
[Fact]
|
||||
public void ShortestTimerIsSeveralSteps()
|
||||
{
|
||||
using var cache = RestoredHullsTests.OpenCatalog();
|
||||
var timers = new List<(string item, string timer, float seconds)>();
|
||||
var bursts = new List<string>();
|
||||
void Read(string item, IEnumerable<BehaviorData> behaviors)
|
||||
{
|
||||
foreach (var behavior in behaviors.Where(b => b != null))
|
||||
switch (behavior)
|
||||
{
|
||||
case InstantWeaponData gun:
|
||||
timers.Add((item, "Cooldown", Math.Min(gun.Cooldown.Min, gun.Cooldown.Max)));
|
||||
var count = Math.Max(1f, Math.Min(gun.Count.Min, gun.Count.Max));
|
||||
var interval = Math.Min(gun.BurstTime.Min, gun.BurstTime.Max) / count;
|
||||
if (count > 1 && interval < SimClock.GameStep) bursts.Add($"{item}: burst interval {interval:R} s");
|
||||
break;
|
||||
case SensorData sensor:
|
||||
timers.Add((item, "PingCooldown", Math.Min(sensor.PingCooldown.Min, sensor.PingCooldown.Max)));
|
||||
break;
|
||||
case ConstantWeaponData beam when beam.AmmoType.IsSet():
|
||||
timers.Add((item, "AmmoInterval", beam.AmmoInterval));
|
||||
break;
|
||||
case CooldownData cooldown:
|
||||
timers.Add((item, "Cooldown", Math.Min(cooldown.Cooldown.Min, cooldown.Cooldown.Max)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
foreach (var item in cache.GetAll<EquippableItemData>()) Read(item.Name, item.Behaviors);
|
||||
foreach (var item in cache.GetAll<ConsumableItemData>()) Read(item.Name, item.Behaviors);
|
||||
|
||||
var offenders = timers.Where(t => t.seconds > 0 && t.seconds < 2 * SimClock.GameStep)
|
||||
.Select(t => $"{t.item}.{t.timer} = {t.seconds:R} s").ToList();
|
||||
Assert.True(offenders.Count == 0, $"timers under two steps of {SimClock.GameStep:R} s: {string.Join("; ", offenders)}. Burst intervals under a step: {string.Join("; ", bursts)}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue