Aetheria/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs
soul 9b56bf0136 Digest pin comment names both causes of the h6 repin
The candidate-predicate change and the release sweep each move the digest alone (b559789a, d9493bbe); releasing beams and locks
moves nothing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 15:31:43 +02:00

273 lines
15 KiB
C#

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using CultMath;
using UniRx;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
// The determinism ledger (aetheria-arena, cut determinism-ledger): every seed a match reaches is stated, never the
// clock's. The item stream is seeded by whoever builds the ItemManager and by RunStart at each step of a run; the unpack
// scatter rides the zone's own dice (ruling run-seed-drops-only); a scenario has a stable Id; and one fight between two
// piloted ships is one history, pinned for the host class that records replays.
public sealed partial class RunStartTests
{
// The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them
// (ruling replays-per-host-class), and arenas run on Linux.
private const string TwoAiPinnedHostClass = "linux-x64";
// Repinned twice, and the second repin has two causes (Soul measured the steps: group 1 + seed = f19b9992, + the Follow
// gate = ce81a426; group 2 without the seed = bf586806).
// First for the side split: the two fighters are unaffiliated, were one side and each followed itself (1b2ffe51).
// Then for staged knowledge (ruling repin-for-staged-knowledge), which moved it twice: the fighters are staged knowing
// each other, so they engage at step 0 (f19b9992); and Follow now flies only on an anchor the pilot itself tracks, so
// once the sensors drop the staged anchor the follower stops following it (ce81a426).
// Repinned a third time, once, for two causes (sim-rate cut): the rules went step-exact (timers keep their overshoot,
// passive sensing decays exactly), which alone gives 4f4a1c8b88308d0a368527549f33baf0e157a056b4c0fd33aeb110b027b862e1
// at 60 Hz; and the game step is now SimClock.GameStep (30 Hz), 1800 steps of the same 60 simulated seconds: d5e16969d5366349f2cddeec53f7e8e8c69074dcf5aa7aabb365e9a91a34511f.
// Repinned a fourth time, once (rounds-per-step): a round is born at its due moment inside its step, so its FireTime, origin
// and frozen target pose are carried back by its age (a pull's first round leaves at the step's start, not its end) and
// its commit and arrival times follow. Rebasing onto master moved nothing (d5e16969 measured at the rebased h1 head).
// Repinned a fifth time, once (h3): the shot queue is kept in fire-time order and resolved oldest first, ties by shot id, so within a step a
// later shooter's earlier round resolves before an earlier shooter's later one (19710609 before). The trigger being the weapon's own state moved nothing
// (measured alone: 19710609).
// Repinned a sixth time, once (h5): a step commits its due shots in fire-time order, then applies the arrived ones in arrival
// order, ties by shot id, so every commit of a step precedes its applies (it was commit then apply, shot by shot, in fire
// order). The two parts measured: commit-then-apply split alone a1318ee0, with the arrival sort 55ecd858; the old walk at the
// h5 head, rebased onto master, still measures 108c93dc, so the rebase and the fire-mode property moved nothing.
// Repinned a seventh time, once (h6), for two Combat changes, each of which moves the digest alone (Soul measured them against
// 55ecd858, the digest before): a held AutoWeapon stays a candidate between its rounds (the sweep disabled: b559789a), and Combat lets
// go of a held weapon it no longer commands (target gone, untracked, another group the pick, Combat ended), where a held gun fired on
// until a later step selected its group (the candidate change reverted: d9493bbe). Both together: the digest below. Releasing a beam or a
// lock the same way (h7) moved nothing.
private const string TwoAiPinnedDigest = "85e85c46cfc66693c819dd294ad02f3cd0eae5486c8f152a97e2e2787f854274";
private const float TwoAiSeconds = 60f;
[Fact]
public void ItemManagerRefusesSeedZero()
{
var refused = Assert.Throws<ArgumentOutOfRangeException>(() =>
new ItemManager(0u, _cache, new ProvenanceLedger(), _items.GameplaySettings, _ => { }));
Assert.Equal("seed", refused.ParamName);
var seeded = new ItemManager(0xBEEFu, _cache, new ProvenanceLedger(), _items.GameplaySettings, _ => { });
Assert.Equal(new CultMath.Random(0xBEEFu).state, seeded.Random.state);
}
[Fact]
public void ItemSeedIsTheRunSeedAndStep()
{
var galaxy = Launch(new Scripted(false, stage => stage.Player(stage.Bare("Djinni"), float2(0, 0)), seed: 4242)).galaxy;
Assert.Equal(4242u, galaxy.Seed);
Assert.Equal(unchecked(4242u * 0x9E3779B1u + 1), RunStart.ItemSeed(galaxy, RunStart.ArenaStep));
Assert.Equal(unchecked(4242u * 0x9E3779B1u + 2), RunStart.ItemSeed(galaxy, RunStart.StageStep));
var arena = RunStart.ItemSeed(galaxy, RunStart.ArenaStep);
var stage = RunStart.ItemSeed(galaxy, RunStart.StageStep);
var resumed = RunStart.ItemSeed(galaxy, RunStart.ContinueStep);
Assert.NotEqual(arena, resumed);
Assert.NotEqual(stage, resumed);
Assert.NotEqual(0u, RunStart.NoRunSeed);
// The stream a new run's arena generates on is the one ItemSeed names.
var items = new ItemManager(7u, _cache, new ProvenanceLedger(), _items.GameplaySettings, _ => { });
RunStart.GenerateArena(items, _zoneSettings, _planetSettings, galaxy, new Scripted(false, _ => { }, seed: 4242));
var expected = new ItemManager(arena, _cache, new ProvenanceLedger(), _items.GameplaySettings, _ => { });
RunStart.GenerateArena(expected, _zoneSettings, _planetSettings, galaxy, new Scripted(false, _ => { }, seed: 4242));
Assert.Equal(expected.Random.state, items.Random.state);
}
private const int OriginDrifters = 3;
// The galaxy seed whose ContinueStep item seed wraps to zero: -ContinueStep times the inverse of 0x9E3779B1, mod 2^32.
private const uint ZeroAtContinueSeed = 0xD45E720Du;
[Fact]
public void ItemSeedRefusesAZeroSeed()
{
var galaxy = Launch(new Scripted(false, stage => stage.Player(stage.Bare("Djinni"), float2(0, 0)),
seed: ZeroAtContinueSeed)).galaxy;
Assert.Equal(ZeroAtContinueSeed, galaxy.Seed);
Assert.NotEqual(0u, RunStart.ItemSeed(galaxy, RunStart.ArenaStep));
var refused = Assert.Throws<InvalidOperationException>(() => RunStart.ItemSeed(galaxy, RunStart.ContinueStep));
Assert.Contains("zero", refused.Message);
}
// A pack holding three unpiloted ships at the origin besides the player, from a staged arena. More than one, so
// each ship's own ordinal is visible: ships sharing a draw would stack on one point.
private (ZonePack pack, Galaxy galaxy) OriginPack()
{
var drifters = new List<Ship>();
var (galaxy, arena, staged, failures) = Launch(new Scripted(false, stage =>
{
stage.Player(stage.Bare("Djinni"), float2(-50000, -50000));
for (var i = 0; i < OriginDrifters; i++)
drifters.Add(stage.Place(stage.Bare("Longinus"), float2(0, 0)) as Ship);
}));
Assert.True(failures.Count == 0, string.Join("; ", failures));
Assert.All(drifters, drifter =>
{
Assert.NotNull(drifter);
Assert.False(drifter.IsPlayerShip);
Assert.True(lengthsq(drifter.Position) < 1, "the fixture's drifters are packed at the origin");
});
return (arena.PackZone(), galaxy);
}
private List<CultMath.float3> Scattered(ZonePack pack, GalaxyZone galaxyZone, Galaxy galaxy)
{
var zone = new Zone(_items, _planetSettings, pack, galaxyZone, galaxy);
return zone.Entities.OfType<Ship>().Where(ship => !ship.IsPlayerShip).Select(ship => ship.Position).ToList();
}
[Fact]
public void ScatterIgnoresTheItemStream()
{
var (pack, galaxy) = OriginPack();
_items.Random = new CultMath.Random(11);
var first = Scattered(pack, galaxy.Entrance, galaxy);
_items.Random = new CultMath.Random(12);
for (var i = 0; i < 37; i++) _items.Random.NextUInt();
var second = Scattered(pack, galaxy.Entrance, galaxy);
Assert.NotEmpty(first);
Assert.All(first, position => Assert.True(lengthsq(position) >= 1, "the drifter was scattered"));
Assert.Equal(first, second);
}
// Each origin ship is placed by its own draw: the 1-based ordinal of its entry in the pack, on the zone's
// ScatterStream, within half the zone's radius of the centre on every axis.
[Fact]
public void ScatterDrawsEachShipsOrdinalWithinHalfTheRadius()
{
var (pack, galaxy) = OriginPack();
var zone = new Zone(_items, _planetSettings, pack, galaxy.Entrance, galaxy);
Assert.Equal(pack.Entities.Count, zone.Entities.Count);
var half = pack.Radius * .5f;
var expected = new List<CultMath.float3>();
var actual = new List<CultMath.float3>();
for (var i = 0; i < zone.Entities.Count; i++)
{
if (zone.Entities[i] is not Ship {IsPlayerShip: false} ship) continue;
expected.Add(SimulationDice.For(zone.CombatSeed, SimulationDice.ScatterStream, (uint) i + 1)
.NextFloat3(float3(-half), float3(half)));
actual.Add(ship.Position);
}
Assert.Equal(OriginDrifters, actual.Count);
Assert.Equal(expected, actual);
Assert.Equal(OriginDrifters, actual.Distinct().Count());
Assert.All(actual, position => Assert.True(all(abs(position) <= half), $"{position} is within half the radius {half}"));
}
[Fact]
public void ScatterIsPerZone()
{
var (pack, galaxy) = OriginPack();
var other = galaxy.Zones.First(zone => zone != galaxy.Entrance && zone.Name != galaxy.Entrance.Name);
var here = Scattered(pack, galaxy.Entrance, galaxy);
var there = Scattered(pack, other, galaxy);
Assert.NotEmpty(here);
Assert.Equal(here.Count, there.Count);
Assert.NotEqual(here, there);
}
[Fact]
public void SimulationDiceStreamsAreDistinct()
{
var streams = new[]
{
SimulationDice.LootStream, SimulationDice.MineStream, SimulationDice.CombatSampleStream,
SimulationDice.PatrolStream, SimulationDice.ScatterStream
};
Assert.Equal(streams.Length, streams.Distinct().Count());
}
[Fact]
public void ScenarioIdsAreUniqueKebabAndStable()
{
var kebab = new Regex("^[a-z0-9]+(-[a-z0-9]+)*$");
var every = EveryScenario.ToList();
Assert.All(every, scenario => Assert.True(scenario.Id != null && kebab.IsMatch(scenario.Id),
$"{scenario.GetType().Name}: Id is not lowercase kebab"));
Assert.Equal(every.Count, every.Select(scenario => scenario.Id).Distinct().Count());
Assert.Equal("duel", new Duel().Id);
Assert.Equal("demo-terminus", new DemoTerminus().Id);
}
// 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, double stepSeconds) TwoAiFight(uint seed = GalaxySeed, float dt = SimClock.GameStep)
{
Ship east = null, west = null;
var (_, arena, _, failures) = Launch(new Scripted(false, stage =>
{
stage.Player(stage.Bare("Djinni"), float2(-50000, -50000));
east = stage.Place(EvLonginus(stage), float2(450, 0), facing: float2(-1, 0), piloted: true) as Ship;
west = stage.Place(EvLonginus(stage), float2(-450, 0), facing: float2(1, 0), piloted: true) as Ship;
stage.Approach(east, west);
stage.Approach(west, east);
}, seed));
Assert.True(failures.Count == 0, string.Join("; ", failures));
east.SetIff(west, true);
west.SetIff(east, true);
var hullAtStart = east.Hull.Durability + west.Hull.Durability;
var bothPiloted = arena.Agents.Any(agent => agent.Ship == east) && arena.Agents.Any(agent => agent.Ship == west);
var shots = new List<string>();
var hits = 0;
using var _ = arena.ShotResolved.Subscribe(outcome =>
{
shots.Add($"{outcome.ShotId}:{outcome.Result}");
if (outcome.Hit) hits++;
});
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);
history.Append(Snapshot(arena, shots)).Append('\n');
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, clock.Elapsed.TotalSeconds);
}
[Fact]
public void TwoAiFightIsOneHistory()
{
var first = TwoAiFight();
var second = TwoAiFight();
Assert.True(first.bothPiloted, "both fighters are piloted");
Assert.True(first.hits > 0, "at least one shot resolved as a hit");
Assert.True(first.hullLost, "at least one fighter lost hull");
Assert.Equal(first.digest, second.digest);
}
private static string HostClass() =>
(RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux"
: RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "windows"
: RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" : "other")
+ "-" + RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant();
[Fact]
public void TwoAiDigestIsPinned()
{
var host = HostClass();
var digest = TwoAiFight().digest;
Assert.True(host == TwoAiPinnedHostClass,
$"TWO-AI-DIGEST host class {host} has no pin (pinned: {TwoAiPinnedHostClass}); measured digest {digest}");
Assert.True(digest == TwoAiPinnedDigest, $"TWO-AI-DIGEST {host} measured {digest}, pinned {TwoAiPinnedDigest}");
}
}