diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 08:21:32 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 08:21:32 +0800 |
| commit | 7dfd58b71568e28fc591b55765635f3f32ad01e5 (patch) | |
| tree | 2d90c06a7862935858d3435805b3af186bbd310b /src | |
| parent | fa20cc35eab8b597707bebc0ff8ef0e2ccb58d47 (diff) | |
| parent | 91d3e7dc95981f92f56a82c19334b5c26d52c226 (diff) | |
| download | living-village-7dfd58b71568e28fc591b55765635f3f32ad01e5.tar.gz | |
merge: P23 512x384 扩程(writer hash 保留)
Diffstat (limited to 'src')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/MapGenTests.fs | 63 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 13 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/MapGen.fs | 35 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/DeterminismTests.fs | 24 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/M6aTests.fs | 26 |
5 files changed, 154 insertions, 7 deletions
diff --git a/src/LivingVillage.Desktop.Tests/MapGenTests.fs b/src/LivingVillage.Desktop.Tests/MapGenTests.fs index 4079ad0..4d11dd1 100644 --- a/src/LivingVillage.Desktop.Tests/MapGenTests.fs +++ b/src/LivingVillage.Desktop.Tests/MapGenTests.fs @@ -172,6 +172,69 @@ type MapGenTests () = Assert.IsTrue(visited.[ay * map.Width + ax], "avatar spawn must sit in the reachable core") Assert.IsTrue(ax >= map.Core.MinX && ax <= map.Core.MaxX && ay >= map.Core.MinY && ay <= map.Core.MaxY, "avatar spawn must sit in the core") + // ---- P23:512x384 扩程(尺寸自适应参数 + 连续性/可达性 + 默认图锁定) ---- + + [<TestMethod>] + member _.SizeAdaptiveParamsScaleRiversWithHeight () = + Assert.AreEqual<int>(2, (MapGen.paramsForSize 64 48 (uint64 1)).RiverCount) + Assert.AreEqual<int>(2, (MapGen.paramsForSize 256 192 (uint64 1)).RiverCount) + Assert.AreEqual<int>(4, (MapGen.paramsForSize 512 384 (uint64 1)).RiverCount) + + [<TestMethod>] + member _.HugeMap512SameSeedIsByteIdentical () = + let a = MapGen.generateWithSize 512 384 (uint64 4242) + let b = MapGen.generateWithSize 512 384 (uint64 4242) + tilesEqual a b "512x384 same seed" + Assert.AreEqual<string>(MapGen.serialize a, MapGen.serialize b) + Assert.AreEqual<int>(512, a.Width) + Assert.AreEqual<int>(384, a.Height) + Assert.AreEqual<int>(30, a.Spawns.Length) + Assert.IsTrue(a.ReachabilityOk, "512x384 must keep the core reachable") + Assert.IsTrue(a.BridgeCrossingsOk, "512x384 bridges must connect both banks") + + [<TestMethod>] + member _.HugeMap512DifferentSeedsDiverge () = + let a = MapGen.generateWithSize 512 384 (uint64 4242) + let b = MapGen.generateWithSize 512 384 (uint64 7) + Assert.AreNotEqual<int64>(checksum a, checksum b, "different seeds must differ at 512x384") + Assert.AreNotEqual<string>(MapGen.serialize a, MapGen.serialize b) + + [<TestMethod>] + member _.HugeMap512RiversStayContinuousAcrossAllBands () = + let map = MapGen.generateWithSize 512 384 (uint64 4242) + Assert.AreEqual<int>(4, map.Rivers.Length) + let minWidth = map.Rivers |> List.map (fun river -> river.Width) |> List.min + Assert.IsTrue(minWidth >= 3, "river width lower bound holds at 512x384") + for x in 1 .. map.Width - 2 do + let runs = waterRunsInColumn map x + if runs.Length <> map.Rivers.Length then + Assert.Fail(sprintf "column %d must hold exactly %d contiguous runs, saw %d" x map.Rivers.Length runs.Length) + for run in runs do + Assert.IsTrue(run >= minWidth, sprintf "column %d has a run of %d < min width %d" x run minWidth) + for y in 0 .. map.Height - 1 do + for x in 0 .. map.Width - 1 do + if map.Tiles.[y * map.Width + x] = int MapGen.GroundTile.Water then + Assert.IsTrue(waterNeighbourCount map x y >= 2, sprintf "water tile (%d,%d) is isolated" x y) + + [<TestMethod>] + member _.HugeMap512ThirtySpawnsReachCoreAndBothBanks () = + let map = MapGen.generateWithSize 512 384 (uint64 4242) + let startX, startY = map.Spawns.Head + let visited = MapGen.floodFill map startX startY + for (x, y) in map.Spawns do + Assert.IsTrue(visited.[y * map.Width + x], sprintf "spawn (%d,%d) unreachable at 512x384" x y) + for building in map.Buildings do + Assert.IsTrue(visited.[building.DoorY * map.Width + building.DoorX], "building door unreachable at 512x384") + for (x, y) in map.Bridges do + Assert.IsTrue(visited.[y * map.Width + x], sprintf "bridge tile (%d,%d) unreachable at 512x384" x y) + Assert.IsTrue(map.Buildings.Length >= map.Rivers.Length, "riverside building per band at 512x384") + + /// 锁定当前默认尺寸的生成结果,防止 P23 参数适配无意改变 64x48 / 256x192。 + [<TestMethod>] + member _.DefaultSizeChecksumsArePinned () = + Assert.AreEqual<int64>(6042052L, checksum (MapGen.generateWithSize 64 48 (uint64 4242))) + Assert.AreEqual<int64>(1284656962L, checksum (MapGen.generateWithSize 256 192 (uint64 4242))) + [<TestMethod>] member _.VisibleRangeClampsAtLegacyMapEdges () = let x0, y0, x1, y1 = MapGen.visibleTileRange 64 48 0 0 2048 1536 diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 4305cb6..8309867 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -136,10 +136,19 @@ type LivingVillageGame() as this = // 证据用 sample 流程(LV_AUTOPLAY_SAMPLE)保持旧 64x48 以确保既有录像可复现,除非显式请求巡游。 let riverscapeTourRequested = Environment.GetEnvironmentVariable("LV_RIVERSCAPE_TOUR") = "1" let riverscapeActive = not legacyMapMode && not mapTourMode && mapScale <= 1 && (not sampleMode || riverscapeTourRequested) + // P23 取证钩子:可把生成器世界扩到 512x384(默认仍是 256x192,不改默认值)。 + let riverscapeWidth = + match Environment.GetEnvironmentVariable("LV_RIVERSCAPE_WIDTH") with + | null | "" -> 256 + | v -> match Int32.TryParse v with (true, value) when value > 0 -> value | _ -> 256 + let riverscapeHeight = + match Environment.GetEnvironmentVariable("LV_RIVERSCAPE_HEIGHT") with + | null | "" -> 192 + | v -> match Int32.TryParse v with (true, value) when value > 0 -> value | _ -> 192 let riverscapeMap : MapGen.Result option = if riverscapeActive then - Sim.configureBounds 256 192 - Some (MapGen.generateWithSize 256 192 42UL) + Sim.configureBounds riverscapeWidth riverscapeHeight + Some (MapGen.generateWithSize riverscapeWidth riverscapeHeight 42UL) else None let riverscapeTourMode = riverscapeTourRequested && riverscapeActive diff --git a/src/LivingVillage.Desktop/MapGen.fs b/src/LivingVillage.Desktop/MapGen.fs index 7c7059a..ce9bccf 100644 --- a/src/LivingVillage.Desktop/MapGen.fs +++ b/src/LivingVillage.Desktop/MapGen.fs @@ -72,6 +72,15 @@ module MapGen = SpawnColumns = 5 SpawnRows = 6 } + /// P23 尺寸自适应参数:河道数量随高度增长(约每 96 行一条),其余沿用默认。 + /// 64x48 / 256x192 仍得到 2 条河道,与既有输出逐字节一致;512x384 得到 4 条。 + let paramsForSize (width: int) (height: int) (seed: uint64) : Params = + let base_ = defaultParams seed + { base_ with + Width = width + Height = height + RiverCount = max base_.RiverCount (max 1 (height / 96)) } + // ---- splitmix64 与 value noise(无外部依赖、无时钟) ---- let private splitmix (state: uint64) : uint64 * uint64 = @@ -104,6 +113,10 @@ module MapGen = (v00 * (1.0f - sx) + v10 * sx) * (1.0f - sy) + (v01 * (1.0f - sx) + v11 * sx) * sy /// 2-3 octave 叠加;特征尺度随地图宽度等比缩放,保证小图/大图观感一致。 + /// P23:特征尺度封顶(<=4x),避免 512x384 退化成过于平滑的「放大图」。 + let private noiseWidthScale (width: int) : float32 = + min 4.0f (float32 width / 64.0f) + let private multiOctave (widthScale: float32) (x: int) (y: int) (seed: uint64) : float32 = let xf = float32 x let yf = float32 y @@ -138,7 +151,7 @@ module MapGen = x >= core.MinX && x <= core.MaxX && y >= core.MinY && y <= core.MaxY // 1) 多八度噪声:低洼草地转泥炭(跳过核心区)。 - let widthScale = float32 w / 64.0f + let widthScale = noiseWidthScale w for y in 0 .. h - 1 do for x in 0 .. w - 1 do if multiOctave widthScale x y p.Seed < 0.18f && not (inCore x y) then @@ -148,14 +161,26 @@ module MapGen = let riverWidth = max 3 p.RiverWidth let riverBands = [ for i in 0 .. max 0 p.RiverCount - 1 do - if i % 2 = 0 then + if i = 0 then let centerY = max 1 (min (h - 1 - riverWidth) (min (core.MinY - riverWidth - 2) (h / 4))) if centerY + riverWidth - 1 < core.MinY then yield { CenterY = centerY; Width = riverWidth } - else + elif i = 1 then let centerY = max 1 (min (h - 1 - riverWidth) (max (core.MaxY + 2) (3 * h / 4))) if centerY > core.MaxY then - yield { CenterY = centerY; Width = riverWidth } ] + yield { CenterY = centerY; Width = riverWidth } + else + // P23:第 3 条起在核心上/下方逐层外扩堆叠,互不重叠、不压核心。 + let layer = (i - 1) / 2 + let offset = if i % 2 = 1 then layer - 1 else layer + if i % 2 = 0 then + let centerY = core.MinY - riverWidth - 2 - offset * (riverWidth + 3) + if centerY >= 1 then + yield { CenterY = centerY; Width = riverWidth } + else + let centerY = core.MaxY + 2 + offset * (riverWidth + 3) + if centerY + riverWidth - 1 <= h - 2 then + yield { CenterY = centerY; Width = riverWidth } ] for river in riverBands do for x in 1 .. w - 2 do for dy in 0 .. river.Width - 1 do @@ -310,7 +335,7 @@ module MapGen = BridgeCrossingsOk = crossingsOk } let generateWithSize (width: int) (height: int) (seed: uint64) : Result = - generate { defaultParams seed with Width = width; Height = height } + generate (paramsForSize width height seed) /// 生成结果的确定性序列化(同 seed 逐字节一致),供回归测试与跨机核对。 let serialize (map: Result) : string = diff --git a/src/LivingVillage.Kernel.Tests/DeterminismTests.fs b/src/LivingVillage.Kernel.Tests/DeterminismTests.fs index 52cae89..c4b4154 100644 --- a/src/LivingVillage.Kernel.Tests/DeterminismTests.fs +++ b/src/LivingVillage.Kernel.Tests/DeterminismTests.fs @@ -113,6 +113,30 @@ type DeterminismTests () = checkedTicks <- checkedTicks + 1 Assert.IsTrue(120001 = checkedTicks) + /// P23:512x384 参数化边界下,avatar 与全部 NPC 在长跑中仍留在 [0, max] 且数值有限。 + [<TestMethod>] + member _.LargeMapBoundsClampAvatarAndNpcs () = + Sim.configureBounds 512 384 + try + let trace = DriverHarness.run 4242UL 20000L + let maxX = float32 (Sim.mapWidthTiles * Sim.tilePixels - Sim.tilePixels) + let maxY = float32 (Sim.mapHeightTiles * Sim.tilePixels - Sim.tilePixels) + Assert.IsTrue(maxX > 64.0f * float32 Sim.tilePixels, "512 map must extend past legacy width") + for w in trace do + let p = w.Avatar.Pos + if Single.IsNaN p.X || Single.IsNaN p.Y then Assert.Fail($"NaN avatar at tick {w.Tick}") + if p.X < 0.0f || p.X > maxX then Assert.Fail($"avatar X out of bounds at tick {w.Tick}: {p.X}") + if p.Y < 0.0f || p.Y > maxY then Assert.Fail($"avatar Y out of bounds at tick {w.Tick}: {p.Y}") + for npc in w.Npcs do + if Single.IsNaN npc.Pos.X || Single.IsNaN npc.Pos.Y then + Assert.Fail($"NaN npc {npc.Id} at tick {w.Tick}") + if npc.Pos.X < 0.0f || npc.Pos.X > maxX then + Assert.Fail($"npc {npc.Id} X out of bounds at tick {w.Tick}: {npc.Pos.X}") + if npc.Pos.Y < 0.0f || npc.Pos.Y > maxY then + Assert.Fail($"npc {npc.Id} Y out of bounds at tick {w.Tick}: {npc.Pos.Y}") + finally + Sim.configureBounds 64 48 + [<TestMethod>] member _.SplitMix64MatchesKnownVectors () = let expected : uint64 list = diff --git a/src/LivingVillage.Kernel.Tests/M6aTests.fs b/src/LivingVillage.Kernel.Tests/M6aTests.fs index 9348b0f..94341a1 100644 --- a/src/LivingVillage.Kernel.Tests/M6aTests.fs +++ b/src/LivingVillage.Kernel.Tests/M6aTests.fs @@ -314,3 +314,29 @@ type M6aTests () = finally Sim.configureBounds 64 48 if File.Exists path then File.Delete path + + /// P23:512x384 参数化边界在存档 v2 中 round-trip;同 world 两次保存逐字节一致, + /// 读回后再次保存仍与原文一致(v1 的 64x48 兼容由 SaveV1LegacyStillReadable 覆盖)。 + [<TestMethod>] + member _.SaveRoundTrips512x384BoundsAndIsByteStable () = + let path = "p23-bounds-512-v2.save" + let path2 = path + ".2" + try + Sim.configureBounds 512 384 + let world = Sim.initialWorld (uint64 4242) + WorldSave.saveToFile path world + WorldSave.saveToFile path2 world + let text = File.ReadAllText path + Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V2"), "bounds save must stay v2") + Assert.AreEqual<string>(text, File.ReadAllText path2) + Sim.configureBounds 64 48 + match WorldSave.loadFromFile path with + | Error failure -> Assert.Fail(sprintf "512x384 bounds save should load: %s" failure) + | Ok restored -> + Assert.AreEqual<int>(512, Sim.mapWidthTiles) + Assert.AreEqual<int>(384, Sim.mapHeightTiles) + Assert.AreEqual<string>(text, WorldSave.save restored) + finally + Sim.configureBounds 64 48 + if File.Exists path then File.Delete path + if File.Exists path2 then File.Delete path2 |
