summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop.Tests/P39ArtTests.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 15:58:43 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 15:58:43 +0800
commitceb45d4006426d616aef099aaf16f5c1d827e816 (patch)
treec67af5bc00c9cb3ef143986a292e9f7407d1030f /src/LivingVillage.Desktop.Tests/P39ArtTests.fs
parent3460c31b3bce6ee417e1aae95071992ba60fbf63 (diff)
downloadliving-village-ceb45d4006426d616aef099aaf16f5c1d827e816.tar.gz
test(p39): 摆放/光晕/奇偶 spacing 回归 + 运行时证据帧
- P39ArtTests 9 例:桥/船/灯笼/摊位/芦苇地形与确定性、NightLightTilesIncludeRoadLanterns、 StallsAndLanternsDoNotOverlapForOddAndEvenSpacing(奇 {3,5,7,11,13}/偶 {2,4,6,8,12})、 DegenerateSpacingPlacesNothing({0,1,-3} 返回空集) - AssetPackTests 更新为 19 格 + 新索引;fsproj 注册 P39ArtTests - scripts/make-p39-closeups.py:2x NEAREST 特写 - docs/维护说明.md 追加 P39 小节;证据 5 帧白块检查 0 命中,重复运行 byte-identical - Release 全量 Desktop 195/195、Kernel 119/119
Diffstat (limited to 'src/LivingVillage.Desktop.Tests/P39ArtTests.fs')
-rw-r--r--src/LivingVillage.Desktop.Tests/P39ArtTests.fs139
1 files changed, 139 insertions, 0 deletions
diff --git a/src/LivingVillage.Desktop.Tests/P39ArtTests.fs b/src/LivingVillage.Desktop.Tests/P39ArtTests.fs
new file mode 100644
index 0000000..831c336
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P39ArtTests.fs
@@ -0,0 +1,139 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel.Sim
+open LivingVillage.Desktop
+open LivingVillage.Desktop.VillageArt
+
+/// P39 Jiangnan element slice: the self-drawn CC0 elements appended after P34
+/// (bridge / boat / lantern / stall / reeds) must place deterministically on the
+/// generated world, must not cover water with land props (boat excluded), and
+/// must go through the same night-glow path as the P29/P32 lanterns.
+[<TestClass>]
+type P39ArtTests () =
+
+ let seed = 4242UL
+ let map = MapGen.generateWithSize 256 192 seed
+
+ let keyOf (tiles: (int * int) list) =
+ tiles |> List.sort |> List.map (fun (x, y) -> sprintf "%d:%d" x y) |> String.concat ";"
+
+ let keyOf3 (tiles: (int * int * int) list) =
+ tiles |> List.sort |> List.map (fun (x, y, i) -> sprintf "%d:%d:%d" x y i) |> String.concat ";"
+
+ [<TestMethod>]
+ member _.BridgeTilesComeFromBridgeColumnsAndKeepApproachOffWater () =
+ // Every bridge column contributes a centre tile on the bridge and an end
+ // tile one row above; the end tile is the approach, never inside the river.
+ Assert.IsTrue(map.Bridges.Length > 0, "seed 4242 generated no bridge")
+ let placed = cc0BridgeTiles map
+ let bridgeColumns = map.Bridges |> List.map fst |> List.distinct |> List.sort
+ let placedColumns = placed |> List.map (fun (x, _, _) -> x) |> List.distinct |> List.sort
+ Assert.AreEqual<(int list)>(bridgeColumns, placedColumns)
+ let indices = placed |> List.map (fun (_, _, i) -> i) |> Set.ofList
+ Assert.IsTrue(Set.isSubset (Set.ofList [ cc0TileIndexBridgeCenter; cc0TileIndexBridgeEnd ]) indices)
+ for (x, _, _) in placed do
+ Assert.IsTrue(map.Bridges |> List.exists (fun (bx, _) -> bx = x), "bridge tile must sit on a bridge column")
+
+ [<TestMethod>]
+ member _.BoatSitsOnOpenWaterAndIsDeterministic () =
+ match cc0BoatTile map with
+ | None -> Assert.Fail "seed 4242 should place a boat"
+ | Some (x, y) ->
+ let water (tx: int) (ty: int) = map.Tiles.[ty * map.Width + tx] = int MapGen.GroundTile.Water
+ Assert.IsTrue(water x y && water (x + 1) y, "boat hull must float on water")
+ Assert.IsTrue(x >= 1 && x < map.Width - 2, "boat must leave room for its right half")
+ Assert.AreEqual<(int * int) option>(cc0BoatTile map, cc0BoatTile map)
+
+ [<TestMethod>]
+ member _.LanternsFollowTheMainRoadAtFixedSpacing () =
+ let lanterns = cc0LanternTiles map 12
+ Assert.IsTrue(lanterns.Length > 0, "road lanterns should appear along the main road")
+ for (x, y) in lanterns do
+ Assert.AreEqual<int>(map.MainRoadCenter.[x], y)
+ Assert.AreEqual<int>(0, x % 12)
+ Assert.IsTrue(map.Tiles.[y * map.Width + x] <> int MapGen.GroundTile.Water)
+ // Deterministic across calls (same seed/map -> byte-identical).
+ Assert.AreEqual<string>(keyOf lanterns, keyOf (cc0LanternTiles map 12))
+
+ [<TestMethod>]
+ member _.StallsAndLanternsDoNotShareTiles () =
+ let lanterns = cc0LanternTiles map 12 |> Set.ofList
+ let stalls = cc0StallTiles map 12
+ Assert.IsTrue(stalls.Length > 0, "road stalls should appear along the main road")
+ for stall in stalls do
+ Assert.IsFalse(Set.contains stall lanterns, "a stall must not overlap a lantern")
+
+ [<TestMethod>]
+ member _.StallsAndLanternsDoNotOverlapForOddAndEvenSpacing () =
+ // The stall column is offset by `spacing / 2` from the lanterns. For any
+ // spacing >= 2 the two residue classes (0 and spacing/2) are distinct, so
+ // they never collide — this must hold for both even and odd spacing, since
+ // integer truncation of spacing/2 on odd values was a suspected edge case.
+ let oddSpacings = [ 3; 5; 7; 11; 13 ]
+ let evenSpacings = [ 2; 4; 6; 8; 12 ]
+ for spacing in oddSpacings @ evenSpacings do
+ let lanterns = cc0LanternTiles map spacing |> Set.ofList
+ let stalls = cc0StallTiles map spacing
+ Assert.IsTrue(lanterns.Count > 0, sprintf "spacing %d should place lanterns" spacing)
+ Assert.IsTrue(stalls.Length > 0, sprintf "spacing %d should place stalls" spacing)
+ for stall in stalls do
+ let message = sprintf "spacing %d: stall at (%d,%d) overlapped a lantern" spacing (fst stall) (snd stall)
+ Assert.IsFalse(Set.contains stall lanterns, message)
+ // The residue classes are exactly {0} and {spacing/2}, so no even/odd
+ // spacing can put a stall and a lantern on the same column.
+ for (x, _) in lanterns do
+ Assert.AreEqual<int>((0: int), x % spacing)
+ for (x, _) in stalls do
+ Assert.AreEqual<int>(spacing / 2, x % spacing)
+
+ [<TestMethod>]
+ member _.DegenerateSpacingPlacesNothing () =
+ // spacing = 1 would make spacing/2 = 0, putting stalls on the same residue
+ // class as lanterns (a real overlap). Both helpers guard spacing < 2 and
+ // return an empty set instead of overlapping.
+ for spacing in [ 0; 1; -3 ] do
+ let lanternCount: int = (cc0LanternTiles map spacing).Length
+ let stallCount: int = (cc0StallTiles map spacing).Length
+ Assert.AreEqual<int>(0, lanternCount)
+ Assert.AreEqual<int>(0, stallCount)
+
+ [<TestMethod>]
+ member _.ReedsHugTheWaterBank () =
+ let reeds = cc0ReedTiles map 4
+ Assert.IsTrue(reeds.Length > 0, "reeds should line the banks")
+ let water (tx: int) (ty: int) =
+ tx >= 0 && tx < map.Width && ty >= 0 && ty < map.Height
+ && map.Tiles.[ty * map.Width + tx] = int MapGen.GroundTile.Water
+ for (x, y) in reeds do
+ Assert.IsFalse(water x y, "reeds are placed on land")
+ Assert.IsTrue(water x (y - 1) || water x (y + 1), "reeds must touch the water bank")
+ Assert.AreEqual<string>(keyOf reeds, keyOf (cc0ReedTiles map 4))
+
+ [<TestMethod>]
+ member _.NightLightTilesIncludeRoadLanterns () =
+ // The P39 stone lanterns reuse the P29/P32 night-glow path, so every road
+ // lantern must be a light source in the night frame (cc0 mode). The
+ // backwards-compatible `nightLightTiles` keeps the old light set.
+ let cc0Lights = nightLightTilesWith true map |> Set.ofList
+ let fallbackLights = nightLightTiles map |> Set.ofList
+ for lantern in cc0LanternTiles map 12 do
+ Assert.IsTrue(Set.contains lantern cc0Lights, "road lantern must contribute night light in cc0 mode")
+ Assert.IsFalse(Set.contains lantern fallbackLights, "fallback light set must stay unchanged")
+
+ [<TestMethod>]
+ member _.SameSeedPlacesTheSameElementsAcrossCalls () =
+ // Byte-identical placement under a repeated generation of the same seed.
+ let map2 = MapGen.generateWithSize 256 192 seed
+ Assert.AreEqual<string>(keyOf3 (cc0BridgeTiles map), keyOf3 (cc0BridgeTiles map2))
+ Assert.AreEqual<(int * int) option>(cc0BoatTile map, cc0BoatTile map2)
+ Assert.AreEqual<string>(keyOf (cc0LanternTiles map 12), keyOf (cc0LanternTiles map2 12))
+ Assert.AreEqual<string>(keyOf (cc0StallTiles map 12), keyOf (cc0StallTiles map2 12))
+ Assert.AreEqual<string>(keyOf (cc0ReedTiles map 4), keyOf (cc0ReedTiles map2 4))
+ // Bridge/boat/lantern/stall/reed placement only depends on the map layout
+ // and fixed spacing (road lanterns are geometric, not seeded), so two seeds
+ // that happen to carve the same river still place the same elements. What
+ // must hold is that placement is a pure function of the map: regenerating
+ // the map from its own seed reproduces every position byte-for-byte.
+ let map3 = MapGen.generateWithSize map.Width map.Height map.Seed
+ Assert.AreEqual<string>(MapGen.serialize map, MapGen.serialize map3)