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.Headless | |
| 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.Headless')
| -rw-r--r-- | src/LivingVillage.Headless/LivingVillage.Headless.fsproj | 16 | ||||
| -rw-r--r-- | src/LivingVillage.Headless/Program.fs | 97 |
2 files changed, 113 insertions, 0 deletions
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 |
