summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/维护说明.md38
-rw-r--r--src/LivingVillage.Desktop.Tests/MapGenTests.fs63
-rw-r--r--src/LivingVillage.Desktop/Game.fs13
-rw-r--r--src/LivingVillage.Desktop/MapGen.fs35
-rw-r--r--src/LivingVillage.Kernel.Tests/DeterminismTests.fs24
-rw-r--r--src/LivingVillage.Kernel.Tests/M6aTests.fs26
6 files changed, 192 insertions, 7 deletions
diff --git a/docs/维护说明.md b/docs/维护说明.md
index 9c44f48..b9bd9fe 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -365,6 +365,29 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
- 个别帧右侧有**孤立砖石小块**(地面 `GroundTile.Stone=2` 单格,`VillageArt.fs:664` 绘
`StonePathSprite`),疑似路网/门阶的单格支路残留,需后续单用定点帧确认后再修。
+## 大世界扩程 512x384(P23)
+
+- Kernel/Sim:边界本就是可变参数(`Sim.configureBounds w h`,默认仍 64x48);512x384 直接可用。
+ 存档 v2/v3 头两字段即 `bounds.w|bounds.h`,v1 旧档仍按 64x48 读入(`WorldSave.parse`)。
+ 512x384 存档 round-trip、同 world 两次保存逐字节一致、长跑 20000 tick 的 avatar/NPC 边界
+ 与有限性均由 Kernel 用例覆盖(Kernel 90 -> 92)。
+- MapGen 尺寸自适应(`paramsForSize`):
+ - 河道数量约每 96 行一条:64x48/256x192 仍 2 条,512x384 得 4 条;第 3 条起在核心区上/下方
+ 逐层外扩,互不重叠、不压核心,仍满足「每列恰好一段、宽度 >= RiverWidth、水格 >=2 水邻居」。
+ - 噪声特征尺度封顶 4x(`noiseWidthScale`),避免大图退化成过度平滑的放大图;泊松布点间距
+ `w/24`、尝试数 `(w*h)/16` 本就随尺寸缩放。
+ - **64x48 / 256x192 生成结果逐字节不变**(新增 checksum 锁定用例:64x48=6042052、
+ 256x192=1284656962)。默认世界仍是 256x192,本轮不切换。
+- 生成耗时(本机实测,单次):64x48 ≈ 3 ms;256x192 ≈ 55 ms;512x384 ≈ 134-248 ms
+ (一次性、进入世界前)。
+- 512x384 巡游帧率(Xvfb 1280x720x24,Desktop Release,`LV_UNLOCK_FPS=1` 放开 vsync):
+ 单趟巡游 161.7 s,`perf-summary samples=156 mean_fps=153.84 min_fps=51.64 max_fps=228.81`;
+ 运行中 `VmRSS` 185.9-201.9 MB。默认 vsync+固定步长为稳定 60 FPS。
+- 取证:`/tmp/opencode/lv-p23-art/`(`p23-map-512x384-seed{4242,7}.png` 整图真实渲染、
+ `p23-culled-*`/`p23-detail-*` 视口/近景、`tour512/` 巡游帧),白块回归 0 命中。
+- 取证钩子:`LV_RIVERSCAPE_WIDTH`/`LV_RIVERSCAPE_HEIGHT`(默认 256x192)把生成器世界扩到
+ 512x384 跑巡游/性能;`LV_MAPGEN_SHOT=1` + `LV_MAPGEN_WIDTH/HEIGHT/SEED` 出整图。
+
## 已验证命令
```bash
@@ -438,6 +461,18 @@ timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env LV_LEGACY_MAP=1 \
LV_AUTOPLAY_SAMPLE=1 LV_UNLOCK_FPS=1 LV_PERF_SUMMARY=1 LV_PERF_SOAK_SECONDS=60 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
+# P23 512x384 扩程取证:整图真实渲染 + 视口/近景(seed 4242 与 7)
+for seed in 4242 7; do timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env \
+ LV_MAPGEN_SHOT=1 LV_MAPGEN_WIDTH=512 LV_MAPGEN_HEIGHT=384 LV_MAPGEN_SEED=$seed \
+ LV_RECORD_DIR=/tmp/opencode/lv-p23-art \
+ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done
+
+# P23 512x384 巡游帧率(单趟,解锁 vsync;日志打印 perf-summary 与 VmRSS 可另采)
+timeout 280 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
+ LV_RIVERSCAPE_TOUR=1 LV_RIVERSCAPE_WIDTH=512 LV_RIVERSCAPE_HEIGHT=384 \
+ LV_UNLOCK_FPS=1 LV_PERF_SUMMARY=1 \
+ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
+
# P20 第三步:默认生成器世界的昼夜巡游录制(真走位,含桥面行走帧;p20c-* 命名)
for pair in day:12.0 night:20.75; do name=${pair%%:*}; hour=${pair##*:}; \
timeout 150 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
@@ -490,6 +525,9 @@ python3 scripts/check-no-white-blocks.py evidence/night-run
- `/tmp/opencode/lv-p13/evidence/`(P13:`walk-idle-frames.png`、`water-3phase.png`、
`smoke-3phase.png`、`water-2frame.png`、`smoke-2frame.png`、`day-panorama.png`、
`night-lantern.png`、`day-night-compare.png`、`walk-real-run.png`、`day-run/`、`night-run/`)
+- `/tmp/opencode/lv-p23-art/`(P23 512x384:`p23-map-512x384-seed{4242,7}.png` 整图真实渲染、
+ `p23-culled-512x384-seed{4242,7}.png` 视口裁剪、`p23-detail-512x384-seed{4242,7}.png` 1:1 近景、
+ `tour512/` 巡游帧)
- `/tmp/opencode/lv-p21-art/`(P21:`p21-day-bridge-01.png`、`p21-day-river-01.png`、
`p21-day-buildings-0{1,2}.png`、`p21-night-bridge-01.png`、`p21-night-river-01.png`、
`p21-night-street-01.png`;原始帧 `raw-day/`、`raw-night/`)
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