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 | |
| 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')
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 36 | ||||
| -rw-r--r-- | src/LivingVillage.Headless/LivingVillage.Headless.fsproj | 16 | ||||
| -rw-r--r-- | src/LivingVillage.Headless/Program.fs | 97 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/DeterminismTests.fs | 135 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj | 22 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Rng.fs | 24 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Sim.fs | 40 |
8 files changed, 347 insertions, 24 deletions
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 960a8c6..6e0feb6 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -66,12 +66,15 @@ module Atlas = type LivingVillageGame() as this = inherit Game() + let autoplay = Environment.GetEnvironmentVariable("LV_AUTOPLAY") = "1" + let titleSuffix = if autoplay then " [AUTOPLAY]" else "" + let modeName = if autoplay then "autoplay" else "keyboard" let graphics = new GraphicsDeviceManager(this) let mutable spriteBatch = Unchecked.defaultof<SpriteBatch> let mutable atlas = Unchecked.defaultof<Texture2D> let mutable pixel = Unchecked.defaultof<Texture2D> let mutable map = MapGen.generate Sim.mapWidthTiles Sim.mapHeightTiles - let mutable world = Sim.initialWorld () + let mutable world = Sim.initialWorld 42UL let mutable camera = Vector2.Zero let mutable fpsFrames = 0 let mutable fpsSeconds = 0.0 @@ -82,8 +85,8 @@ type LivingVillageGame() as this = graphics.SynchronizeWithVerticalRetrace <- true this.IsFixedTimeStep <- true this.TargetElapsedTime <- TimeSpan.FromTicks(TimeSpan.TicksPerSecond / 60L) - this.Window.Title <- "Living Village M0" - + this.Window.Title <- if autoplay then "Living Village M1 [AUTOPLAY]" else "Living Village M1" + printfn $"mode={modeName} seed=42" member private this.CenterCamera() = let viewport = this.GraphicsDevice.Viewport let vw = float32 viewport.Width @@ -105,17 +108,20 @@ type LivingVillageGame() as this = let kb = Keyboard.GetState() if kb.IsKeyDown(Keys.Escape) then this.Exit() let input = - { MoveX = - (if kb.IsKeyDown(Keys.D) then 1.0f - elif kb.IsKeyDown(Keys.A) then -1.0f - else 0.0f) - MoveY = - (if kb.IsKeyDown(Keys.S) then 1.0f - elif kb.IsKeyDown(Keys.W) then -1.0f - else 0.0f) } - let ts = - { Dt = float32 gameTime.ElapsedGameTime.TotalSeconds - Input = input } + if autoplay then + let t = float32 world.Tick + { MoveX = MathF.Sin(t / 60.0f) * 0.8f + MoveY = MathF.Cos(t / 90.0f) * 0.6f } + else + { MoveX = + (if kb.IsKeyDown(Keys.D) then 1.0f + elif kb.IsKeyDown(Keys.A) then -1.0f + else 0.0f) + MoveY = + (if kb.IsKeyDown(Keys.S) then 1.0f + elif kb.IsKeyDown(Keys.W) then -1.0f + else 0.0f) } + let ts = { Input = input } world <- Sim.step ts world this.CenterCamera() fpsFrames <- fpsFrames + 1 @@ -124,7 +130,7 @@ type LivingVillageGame() as this = let fps = float fpsFrames / fpsSeconds fpsFrames <- 0 fpsSeconds <- 0.0 - this.Window.Title <- $"Living Village M0 | fps {fps:F1} | tick {world.Tick}" + this.Window.Title <- $"Living Village M1{titleSuffix} | fps {fps:F1} | tick {world.Tick}" printfn $"fps={fps:F1} tick={world.Tick} pos=({world.Avatar.Pos.X:F0},{world.Avatar.Pos.Y:F0})" override this.Draw(gameTime: GameTime) = diff --git a/src/LivingVillage.Headless/LivingVillage.Headless.fsproj b/src/LivingVillage.Headless/LivingVillage.Headless.fsproj new file mode 100644 index 0000000..612b584 --- /dev/null +++ b/src/LivingVillage.Headless/LivingVillage.Headless.fsproj @@ -0,0 +1,16 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net8.0</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <Compile Include="Program.fs" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\LivingVillage.Kernel\LivingVillage.Kernel.fsproj" /> + </ItemGroup> + +</Project> diff --git a/src/LivingVillage.Headless/Program.fs b/src/LivingVillage.Headless/Program.fs new file mode 100644 index 0000000..ea8b1b0 --- /dev/null +++ b/src/LivingVillage.Headless/Program.fs @@ -0,0 +1,97 @@ +module Program + +open System +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim + +let ticksPerSecond = 60L +let secondsPerDay = 86400L +let ticksPerDay = secondsPerDay * ticksPerSecond + +let segmentTicks = 900L + +let zeroInput = { MoveX = 0.0f; MoveY = 0.0f } + +type Driver = + { Rng: RngState + Until: int64 + Input: Input } + +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 runSimulation (days: int64) (seed: uint64) : int = + let totalTicks = days * ticksPerDay + printfn "living-village headless days=%d seed=%d ticks=%d tickrate=%d" days seed totalTicks ticksPerSecond + let mutable world = Sim.initialWorld seed + let mutable driver = { Rng = Rng.ofSeed seed; Until = segmentTicks; Input = zeroInput } + let maxX = float32 (Sim.mapWidthTiles * Sim.tilePixels - Sim.tilePixels) + let maxY = float32 (Sim.mapHeightTiles * Sim.tilePixels - Sim.tilePixels) + let mutable nonFinite = 0L + let mutable outOfBounds = 0L + let mutable t = 0L + while t < totalTicks do + let d, input = + if t < 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 + t <- t + 1L + let p = world.Avatar.Pos + if Single.IsNaN p.X || Single.IsNaN p.Y || Single.IsInfinity p.X || Single.IsInfinity p.Y then + nonFinite <- nonFinite + 1L + if p.X < 0.0f || p.X > maxX || p.Y < 0.0f || p.Y > maxY then outOfBounds <- outOfBounds + 1L + if t % 1000L = 0L then + let mem = GC.GetTotalMemory(true) + let day = float t / float ticksPerDay + printfn "tick=%d time=%.4fs day=%.4f pos=(%.1f,%.1f) mem=%d rng=%016x" t world.Time day p.X p.Y mem world.Rng.State + printfn "done tick=%d non-finite=%d out-of-bounds=%d" world.Tick nonFinite outOfBounds + if nonFinite > 0L || outOfBounds > 0L then 1 else 0 + +[<EntryPoint>] +let main argv = + let rec parse (i: int) (days: int64 option) (seed: uint64 option) : Result<int64 * uint64, string> = + if i >= argv.Length then + match days, seed with + | Some d, Some s -> Ok(d, s) + | _ -> Error "missing --days/--seed" + else + match argv.[i] with + | "--days" when i + 1 < argv.Length -> + (match Int64.TryParse argv.[i + 1] with + | true, d when d > 0L -> parse (i + 2) (Some d) seed + | _ -> Error $"invalid --days '{argv.[i + 1]}'") + | "--seed" when i + 1 < argv.Length -> + (match UInt64.TryParse argv.[i + 1] with + | true, s -> parse (i + 2) days (Some s) + | _ -> Error $"invalid --seed '{argv.[i + 1]}'") + | other -> Error $"unknown argument '{other}'" + + match parse 0 None None with + | Ok(days, seed) -> runSimulation days seed + | Error msg -> + eprintfn $"headless: {msg}" + eprintfn "usage: dotnet run -c Release --project src/LivingVillage.Headless -- --days N --seed S" + 2 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> diff --git a/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj b/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj index a8ed312..8a4fad1 100644 --- a/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj +++ b/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj @@ -5,6 +5,7 @@ </PropertyGroup> <ItemGroup> + <Compile Include="Rng.fs" /> <Compile Include="Sim.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Kernel/Rng.fs b/src/LivingVillage.Kernel/Rng.fs new file mode 100644 index 0000000..9564b56 --- /dev/null +++ b/src/LivingVillage.Kernel/Rng.fs @@ -0,0 +1,24 @@ +namespace LivingVillage.Kernel + +[<Struct>] +type RngState = + { State: uint64 } + +module Rng = + + let goldenGamma = 0x9E3779B97F4A7C15UL + + let ofSeed (seed: uint64) : RngState = { State = seed } + + let nextUInt64 (rng: RngState) : uint64 * RngState = + let s = rng.State + goldenGamma + let mutable z = s + z <- (z ^^^ (z >>> 30)) * 0xBF58476D1CE4E5B9UL + z <- (z ^^^ (z >>> 27)) * 0x94D049BB133111EBUL + let out = z ^^^ (z >>> 31) + out, { State = s } + + let nextFloat32 (rng: RngState) : float32 * RngState = + let z, rng' = nextUInt64 rng + let bits = uint32 (z >>> 40) + float32 bits / 16777216.0f, rng' diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs index d1b2e57..0e3683b 100644 --- a/src/LivingVillage.Kernel/Sim.fs +++ b/src/LivingVillage.Kernel/Sim.fs @@ -2,10 +2,12 @@ namespace LivingVillage.Kernel module Sim = + [<Struct>] type Vec2 = { X: float32 Y: float32 } + [<Struct>] type Avatar = { Pos: Vec2 } @@ -14,33 +16,53 @@ module Sim = MoveY: float32 } type TimeStep = - { Dt: float32 - Input: Input } + { Input: Input } + [<Struct>] + type NoHost = + { Reserved: uint64 } + + [<Struct>] type World = { Tick: int64 - Avatar: Avatar } + Time: float + Rng: RngState + Avatar: Avatar + NoHost: NoHost } + + let ticksPerSecond = 60L + let secondsPerDay = 86400L + let ticksPerDay = secondsPerDay * ticksPerSecond + let dtSeconds = 1.0 / float ticksPerSecond + let dtSecondsF = 1.0f / float32 ticksPerSecond let mapWidthTiles = 64 let mapHeightTiles = 48 let tilePixels = 32 let avatarSpeed = 160.0f - let initialWorld () : World = + let initialWorld (seed: uint64) : World = let centerX = float32 (mapWidthTiles * tilePixels / 2 - tilePixels / 2) let centerY = float32 (mapHeightTiles * tilePixels / 2 - tilePixels / 2) { Tick = 0L - Avatar = { Pos = { X = centerX; Y = centerY } } } + Time = 0.0 + Rng = Rng.ofSeed seed + Avatar = { Pos = { X = centerX; Y = centerY } } + NoHost = { Reserved = 0UL } } let clamp v lo hi = if v < lo then lo elif v > hi then hi else v let step (ts: TimeStep) (world: World) : World = + let tick = world.Tick + 1L let maxX = float32 (mapWidthTiles * tilePixels - tilePixels) let maxY = float32 (mapHeightTiles * tilePixels - tilePixels) - let dx = ts.Input.MoveX * avatarSpeed * ts.Dt - let dy = ts.Input.MoveY * avatarSpeed * ts.Dt - { Tick = world.Tick + 1L + let dx = ts.Input.MoveX * avatarSpeed * dtSecondsF + let dy = ts.Input.MoveY * avatarSpeed * dtSecondsF + { Tick = tick + Time = float tick * dtSeconds + Rng = world.Rng Avatar = { Pos = { X = clamp (world.Avatar.Pos.X + dx) 0.0f maxX - Y = clamp (world.Avatar.Pos.Y + dy) 0.0f maxY } } } + Y = clamp (world.Avatar.Pos.Y + dy) 0.0f maxY } } + NoHost = world.NoHost } |
