summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-19 00:18:43 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-19 00:18:43 +0800
commit59458d149d7cecf73e708faa6d9e6db2bcd89dfd (patch)
treeaf342c6a01766be326857f48ebe5c65529e50882
parentb237a11908442a227361fae24dbdd0dbb287fac9 (diff)
downloadliving-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 轨迹不同。
-rw-r--r--LivingVillage.sln14
-rw-r--r--src/LivingVillage.Desktop/Game.fs36
-rw-r--r--src/LivingVillage.Headless/LivingVillage.Headless.fsproj16
-rw-r--r--src/LivingVillage.Headless/Program.fs97
-rw-r--r--src/LivingVillage.Kernel.Tests/DeterminismTests.fs135
-rw-r--r--src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj22
-rw-r--r--src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj1
-rw-r--r--src/LivingVillage.Kernel/Rng.fs24
-rw-r--r--src/LivingVillage.Kernel/Sim.fs40
9 files changed, 361 insertions, 24 deletions
diff --git a/LivingVillage.sln b/LivingVillage.sln
index 4e8f210..5792693 100644
--- a/LivingVillage.sln
+++ b/LivingVillage.sln
@@ -9,6 +9,10 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "LivingVillage.Kernel", "src
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "LivingVillage.Desktop", "src\LivingVillage.Desktop\LivingVillage.Desktop.fsproj", "{CC15DA38-E36A-45A2-A031-5392C8A6BF0D}"
EndProject
+Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "LivingVillage.Headless", "src\LivingVillage.Headless\LivingVillage.Headless.fsproj", "{0CC4C137-0002-4A62-A743-BBCF022DF8E3}"
+EndProject
+Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "LivingVillage.Kernel.Tests", "src\LivingVillage.Kernel.Tests\LivingVillage.Kernel.Tests.fsproj", "{524DF44B-ECF6-4537-8F42-2C6CA357372F}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -26,9 +30,19 @@ Global
{CC15DA38-E36A-45A2-A031-5392C8A6BF0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CC15DA38-E36A-45A2-A031-5392C8A6BF0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CC15DA38-E36A-45A2-A031-5392C8A6BF0D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0CC4C137-0002-4A62-A743-BBCF022DF8E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0CC4C137-0002-4A62-A743-BBCF022DF8E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0CC4C137-0002-4A62-A743-BBCF022DF8E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0CC4C137-0002-4A62-A743-BBCF022DF8E3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {524DF44B-ECF6-4537-8F42-2C6CA357372F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {524DF44B-ECF6-4537-8F42-2C6CA357372F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {524DF44B-ECF6-4537-8F42-2C6CA357372F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {524DF44B-ECF6-4537-8F42-2C6CA357372F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{504BBDD4-106D-4423-A3BE-9A82848D1656} = {DFF4B1C6-CF0A-45CB-912B-5BFD446A76FF}
{CC15DA38-E36A-45A2-A031-5392C8A6BF0D} = {DFF4B1C6-CF0A-45CB-912B-5BFD446A76FF}
+ {0CC4C137-0002-4A62-A743-BBCF022DF8E3} = {DFF4B1C6-CF0A-45CB-912B-5BFD446A76FF}
+ {524DF44B-ECF6-4537-8F42-2C6CA357372F} = {DFF4B1C6-CF0A-45CB-912B-5BFD446A76FF}
EndGlobalSection
EndGlobal
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 }