From ceb45d4006426d616aef099aaf16f5c1d827e816 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 28 Sep 2026 15:58:43 +0800 Subject: test(p39): 摆放/光晕/奇偶 spacing 回归 + 运行时证据帧 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/LivingVillage.Desktop.Tests/P39ArtTests.fs | 139 +++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 src/LivingVillage.Desktop.Tests/P39ArtTests.fs (limited to 'src/LivingVillage.Desktop.Tests/P39ArtTests.fs') 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. +[] +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 ";" + + [] + 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") + + [] + 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) + + [] + 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(map.MainRoadCenter.[x], y) + Assert.AreEqual(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(keyOf lanterns, keyOf (cc0LanternTiles map 12)) + + [] + 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") + + [] + 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((0: int), x % spacing) + for (x, _) in stalls do + Assert.AreEqual(spacing / 2, x % spacing) + + [] + 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(0, lanternCount) + Assert.AreEqual(0, stallCount) + + [] + 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(keyOf reeds, keyOf (cc0ReedTiles map 4)) + + [] + 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") + + [] + member _.SameSeedPlacesTheSameElementsAcrossCalls () = + // Byte-identical placement under a repeated generation of the same seed. + let map2 = MapGen.generateWithSize 256 192 seed + Assert.AreEqual(keyOf3 (cc0BridgeTiles map), keyOf3 (cc0BridgeTiles map2)) + Assert.AreEqual<(int * int) option>(cc0BoatTile map, cc0BoatTile map2) + Assert.AreEqual(keyOf (cc0LanternTiles map 12), keyOf (cc0LanternTiles map2 12)) + Assert.AreEqual(keyOf (cc0StallTiles map 12), keyOf (cc0StallTiles map2 12)) + Assert.AreEqual(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(MapGen.serialize map, MapGen.serialize map3) -- cgit v1.2.3