Aetheria/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs
soul 961bdafade Determinism ledger: the TwoAiDigestIsPinned comment names both causes of the second repin
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:26:03 +02:00

250 lines
12 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).
private const string TwoAiPinnedDigest = "ce81a426c40495e4b4b7b7f83ad23767cc781dce5523d713b4455e0aa5b7fe33";
private const int TwoAiSteps = 3600;
[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 3600 times at 1/60 s and returns the SHA-256 of the joined snapshots.
private (string digest, bool bothPiloted, int hits, bool hullLost) TwoAiFight()
{
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);
}));
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();
for (var step = 0; step < TwoAiSteps; step++)
{
arena.Update(1f / 60f);
history.Append(Snapshot(arena, shots)).Append('\n');
}
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(history.ToString()))).ToLowerInvariant();
return (digest, bothPiloted, hits, east.Hull.Durability + west.Hull.Durability < hullAtStart);
}
[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}");
}
}