diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-19 00:18:43 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-19 00:18:43 +0800 |
| commit | 59458d149d7cecf73e708faa6d9e6db2bcd89dfd (patch) | |
| tree | af342c6a01766be326857f48ebe5c65529e50882 /src/LivingVillage.Kernel.Tests | |
| parent | b237a11908442a227361fae24dbdd0dbb287fac9 (diff) | |
| download | living-village-59458d149d7cecf73e708faa6d9e6db2bcd89dfd.tar.gz | |
feat: M1 模拟器内核 — SplitMix64 RNG + 确定性 step + 对拍测试 + headless 冒烟工具 + Desktop 自动演示
[变更性质]
本 commit 为功能开发,交付设计文档 §2 模拟器内核(里程碑 M1),非缺陷修复或结构重构。
[新增功能]
- Kernel:Rng.fs 提供 SplitMix64 纯函数 RNG(ofSeed / nextUInt64 / nextFloat32),World 携带种子状态。
- Kernel:Sim.fs 重写为 struct record(Vec2/Avatar/Input/TimeStep/World),step : World → TimeStep → World 纯函数,dt 固定 1/60 由内核常量给定,Time = Tick * dt 不做浮点累加;initialWorld 由无参改为带 seed。
- Tests:MSTest 确定性对拍——同 seed 轨迹逐 tick 相等、异 seed 发散、step 纯度、长跑有限/有界/时间-刻度关系、SplitMix64 已知向量、nextFloat32 值域。
- Headless:--days N --seed S 冒烟工具,每 1000 tick 输出摘要行,统计 NaN/越界计数并决定退出码。
- Desktop:LV_AUTOPLAY=1 环境变量启用正弦驱动自动演示,窗口标题与启动日志标注模式。
[实现方案]
- 全部状态用 [<Struct>] record,模拟路径零堆分配,支撑 51.84 亿刻长跑。
- step 不消费 World 内 RNG,种子经由外部确定性驱动脚本生效(每 900 刻换段,8 方向查表 + 三档幅值,避开三角函数保证跨平台位精确)。
- Headless 内存口径取 GC.GetTotalMemory(true)(全收集后的稳态驻留),避免 GetTotalMemory(false) 随 Gen0 段扩张漂移误报泄漏。
[影响范围]
- Kernel 保持零 MonoGame 引用,模拟与渲染彻底分离;sln 新增 Headless 与 Tests 两个项目。
- Release 构建 0 警告 0 错误,7/7 测试通过;1000 天长跑 5,184,000,000 刻退出码 0,无 NaN 无越界,内存 87,824 → 93,000 字节(1.06x < 2x)。
- 同 seed 两次运行 stdout 字节一致,seed 42/43 轨迹不同。
Diffstat (limited to 'src/LivingVillage.Kernel.Tests')
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/DeterminismTests.fs | 135 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj | 22 |
2 files changed, 157 insertions, 0 deletions
diff --git a/src/LivingVillage.Kernel.Tests/DeterminismTests.fs b/src/LivingVillage.Kernel.Tests/DeterminismTests.fs new file mode 100644 index 0000000..2e2663b --- /dev/null +++ b/src/LivingVillage.Kernel.Tests/DeterminismTests.fs @@ -0,0 +1,135 @@ +namespace LivingVillage.Kernel.Tests + +open System +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim + +module private DriverHarness = + + let segmentTicks = 900L + + type Driver = + { Rng: RngState + Until: int64 + Input: Input } + + let zeroInput = { MoveX = 0.0f; MoveY = 0.0f } + + let nextSegment (rng: RngState) (until: int64) : Driver = + let a, r1 = Rng.nextUInt64 rng + let m, r2 = Rng.nextUInt64 r1 + let diag = 0.70710678f + let dirX, dirY = + match int (a &&& 7UL) with + | 0 -> 1.0f, 0.0f + | 1 -> diag, diag + | 2 -> 0.0f, 1.0f + | 3 -> -diag, diag + | 4 -> -1.0f, 0.0f + | 5 -> -diag, -diag + | 6 -> 0.0f, -1.0f + | _ -> diag, -diag + let mag = + match int (m % 3UL) with + | 0 -> 0.4f + | 1 -> 0.7f + | _ -> 1.0f + { Rng = r2 + Until = until + segmentTicks + Input = { MoveX = dirX * mag; MoveY = dirY * mag } } + + let run (seed: uint64) (ticks: int64) : World list = + let mutable world = Sim.initialWorld seed + let mutable driver = { Rng = Rng.ofSeed seed; Until = segmentTicks; Input = zeroInput } + let acc = ResizeArray<World> (int ticks + 1) + acc.Add world + for t in 1L .. ticks do + let d, input = + if t - 1L < driver.Until then driver, driver.Input + else + let d = nextSegment driver.Rng driver.Until + d, d.Input + driver <- d + world <- Sim.step { Input = input } world + acc.Add world + List.ofSeq acc + +[<TestClass>] +type DeterminismTests () = + + [<TestMethod>] + member _.SameSeedSameInputSequenceTracesAreTickByTickEqual () = + let a = DriverHarness.run 42UL 20000L + let b = DriverHarness.run 42UL 20000L + Assert.IsTrue(20001 = List.length a) + CollectionAssert.AreEqual(Array.ofList a, Array.ofList b) + + [<TestMethod>] + member _.DifferentSeedsProduceDivergentTraces () = + let a = DriverHarness.run 42UL 20000L + let b = DriverHarness.run 43UL 20000L + let diverged = + Seq.exists2 (fun (x: World) (y: World) -> x.Avatar.Pos <> y.Avatar.Pos) a b + if not diverged then Assert.Fail("expected avatar positions to diverge across seeds") + if a = b then Assert.Fail("expected whole traces to differ across seeds") + + [<TestMethod>] + member _.StepIsPureAndDoesNotMutateItsInput () = + let w = Sim.initialWorld 7UL + let ts = { Input = { MoveX = 1.0f; MoveY = -0.5f } } + let w1 = Sim.step ts w + let w1' = Sim.step ts w + if w1 <> w1' then Assert.Fail("step must be deterministic for identical (world, input)") + Assert.IsTrue(w.Tick = 0L) + Assert.IsTrue(w.Time = 0.0) + Assert.IsTrue(w1.Tick = 1L) + Assert.IsTrue(abs (w1.Time - Sim.dtSeconds) < 1e-12) + + [<TestMethod>] + member _.WorldCarriesSeededRngState () = + let a = Sim.initialWorld 42UL + let b = Sim.initialWorld 43UL + Assert.IsTrue(a.Rng.State = 42UL) + Assert.IsTrue(b.Rng.State = 43UL) + Assert.IsTrue(a <> b) + + [<TestMethod>] + member _.LongRunStaysFiniteBoundedAndTimeTracksTicks () = + let trace = DriverHarness.run 42UL 120000L + let maxX = float32 (Sim.mapWidthTiles * Sim.tilePixels - Sim.tilePixels) + let maxY = float32 (Sim.mapHeightTiles * Sim.tilePixels - Sim.tilePixels) + let mutable checkedTicks = 0 + for w in trace do + let p = w.Avatar.Pos + if Single.IsNaN p.X then Assert.Fail($"NaN X at tick {w.Tick}") + if Single.IsNaN p.Y then Assert.Fail($"NaN Y at tick {w.Tick}") + if Single.IsInfinity p.X then Assert.Fail($"Infinity X at tick {w.Tick}") + if Single.IsInfinity p.Y then Assert.Fail($"Infinity Y at tick {w.Tick}") + if p.X < 0.0f || p.X > maxX then Assert.Fail($"X out of bounds at tick {w.Tick}: {p.X}") + if p.Y < 0.0f || p.Y > maxY then Assert.Fail($"Y out of bounds at tick {w.Tick}: {p.Y}") + if abs (float w.Tick * Sim.dtSeconds - w.Time) >= 1e-9 then + Assert.Fail($"Time/Tick mismatch at tick {w.Tick}: {w.Time}") + checkedTicks <- checkedTicks + 1 + Assert.IsTrue(120001 = checkedTicks) + + [<TestMethod>] + member _.SplitMix64MatchesKnownVectors () = + let expected : uint64 list = + [ 0xbdd732262feb6e95UL + 0x28efe333b266f103UL + 0x47526757130f9f52UL + 0x581ce1ff0e4ae394UL ] + let mutable rng = Rng.ofSeed 42UL + for e in expected do + let v, rng' = Rng.nextUInt64 rng + if v <> e then Assert.Fail($"expected {e}, got {v}") + rng <- rng' + + [<TestMethod>] + member _.NextFloat32StaysInUnitInterval () = + let mutable rng = Rng.ofSeed 1UL + for _ in 1 .. 1000 do + let f, rng' = Rng.nextFloat32 rng + if f < 0.0f || f >= 1.0f then Assert.Fail($"out of range: {f}") + rng <- rng' diff --git a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj new file mode 100644 index 0000000..d1d3c19 --- /dev/null +++ b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj @@ -0,0 +1,22 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <IsPackable>false</IsPackable> + </PropertyGroup> + + <ItemGroup> + <Compile Include="DeterminismTests.fs" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> + <PackageReference Include="MSTest.TestAdapter" Version="3.6.3" /> + <PackageReference Include="MSTest.TestFramework" Version="3.6.3" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\LivingVillage.Kernel\LivingVillage.Kernel.fsproj" /> + </ItemGroup> + +</Project> |
