diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-28 15:58:43 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-28 15:58:43 +0800 |
| commit | ceb45d4006426d616aef099aaf16f5c1d827e816 (patch) | |
| tree | c67af5bc00c9cb3ef143986a292e9f7407d1030f /src | |
| parent | 3460c31b3bce6ee417e1aae95071992ba60fbf63 (diff) | |
| download | living-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')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/AssetPackTests.fs | 11 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P39ArtTests.fs | 139 |
3 files changed, 149 insertions, 2 deletions
diff --git a/src/LivingVillage.Desktop.Tests/AssetPackTests.fs b/src/LivingVillage.Desktop.Tests/AssetPackTests.fs index 46513f9..85fc99c 100644 --- a/src/LivingVillage.Desktop.Tests/AssetPackTests.fs +++ b/src/LivingVillage.Desktop.Tests/AssetPackTests.fs @@ -43,7 +43,7 @@ type AssetPackTests () = [<TestMethod>] member _.Cc0StructureTileIndicesFitTheAtlasAndWaterHasTwoPhases () = - // Every P34 self-drawn structure cell must sit inside the 12-tile atlas band. + // Every P34/P39 self-drawn cell must sit inside the 19-tile atlas band. let indices = [ cc0TileIndexWaterA cc0TileIndexWaterB @@ -52,7 +52,14 @@ type AssetPackTests () = cc0TileIndexWallWood cc0TileIndexWallWindow cc0TileIndexRailing - cc0TileIndexDoor ] + cc0TileIndexDoor + cc0TileIndexBridgeCenter + cc0TileIndexBridgeEnd + cc0TileIndexBoatLeft + cc0TileIndexBoatRight + cc0TileIndexStoneLantern + cc0TileIndexStall + cc0TileIndexReeds ] for index in indices do Assert.IsTrue(index >= 0 && index < cc0TileCount, sprintf "cc0 tile index %d out of atlas" index) let width, _ = pngDimensions (assetPath cc0TileAtlasRelativePath) diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index 8142917..aae83a1 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -19,6 +19,7 @@ <Compile Include="P36PromptTests.fs" /> <Compile Include="P37ProfessionTests.fs" /> <Compile Include="P38HudIconTests.fs" /> + <Compile Include="P39ArtTests.fs" /> <Compile Include="SampleTests.fs" /> </ItemGroup> 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) |
