namespace LivingVillage.Desktop.Tests open Microsoft.VisualStudio.TestTools.UnitTesting open LivingVillage.Kernel.Sim open LivingVillage.Desktop open LivingVillage.Desktop.VillageArt /// P40 art-deepening slice: /// * paving slabs follow the traffic network (paths / doorways / bridges) and the /// isolated decorative scatter is dropped, so cc0 ground reads as coherent bands; /// * red door lanterns complement the stone road lanterns with a distinct form, /// both feeding the existing P14 premultiplied-alpha night-glow path; /// * every placement stays a pure function of the map, so the same seed yields /// byte-identical positions across day and night frames. [] type P40ArtTests () = let seed = 4242UL let map = MapGen.generateWithSize 256 192 seed let indexOf (x: int) (y: int) = y * map.Width + x let isStone (x: int) (y: int) = x >= 0 && x < map.Width && y >= 0 && y < map.Height && map.Tiles.[indexOf x y] = int MapGen.GroundTile.Stone // --- P40 boat awning: solid arch silhouette (atlas pixel decode) ----------- // The atlas is written by scripts/make-cc0-art.py as non-interlaced 8-bit RGBA // with filter byte 0 on every scanline, so a tiny decoder is enough to assert // the awning is a filled arch with a continuous ridge (not a row of 1px posts). let atlasRgba () : int * int * (int -> int -> (int * int * int * int)) = let path = System.IO.Path.Combine(System.AppContext.BaseDirectory, cc0TileAtlasRelativePath) let bytes = System.IO.File.ReadAllBytes path let be32 (o: int) = (int bytes.[o] <<< 24) ||| (int bytes.[o + 1] <<< 16) ||| (int bytes.[o + 2] <<< 8) ||| int bytes.[o + 3] let width = be32 16 let height = be32 20 let mutable offset = 8 let mutable idat = System.IO.MemoryStream() while offset < bytes.Length do let length = be32 offset let tag = System.Text.Encoding.ASCII.GetString(bytes, offset + 4, 4) if tag = "IDAT" then idat.Write(bytes, offset + 8, length) offset <- offset + 12 + length use z = new System.IO.Compression.ZLibStream(new System.IO.MemoryStream(idat.ToArray()), System.IO.Compression.CompressionMode.Decompress) use ms = new System.IO.MemoryStream() z.CopyTo ms let decompressed = ms.ToArray() let stride = width * 4 let pixels = Array.zeroCreate (width * height * 4) for y in 0 .. height - 1 do let rowStart = y * (stride + 1) + 1 // skip the per-row filter byte (always 0 here) Array.blit decompressed rowStart pixels (y * stride) stride let get (x: int) (y: int) = if x < 0 || x >= width || y < 0 || y >= height then (0, 0, 0, 0) else let i = (y * width + x) * 4 (int pixels.[i], int pixels.[i + 1], int pixels.[i + 2], int pixels.[i + 3]) width, height, get let isBambooOpaque (r: int, g: int, b: int, a: int) = a > 0 && ((r, g, b) = (44, 58, 34) || (r, g, b) = (86, 104, 62) || (r, g, b) = (28, 38, 22) || (r, g, b) = (22, 30, 17)) [] member _.PavingCoversTrafficNetworkAndDropsIsolatedScatter () = let paving = cc0PavingTiles map Assert.IsTrue(paving.Count > 0, "seed 4242 should pave something") // Every path / bridge anchor tile that is stone must be paved. for (x, y) in map.Paths @ map.Bridges do if isStone x y then Assert.IsTrue(paving.Contains(indexOf x y), sprintf "path/bridge stone (%d,%d) must be paved" x y) // Every paved tile must be stone and touch the network (itself or a 4-neighbour). let anchors = (map.Paths @ map.Bridges) |> List.map (fun (x, y) -> indexOf x y) |> Set.ofList let anchorAt (x: int) (y: int) = x >= 0 && x < map.Width && y >= 0 && y < map.Height && Set.contains (indexOf x y) anchors for i in paving do let x = i % map.Width let y = i / map.Width Assert.IsTrue(isStone x y, "paving must only replace stone tiles") Assert.IsTrue( anchorAt x y || anchorAt (x - 1) y || anchorAt (x + 1) y || anchorAt x (y - 1) || anchorAt x (y + 1), sprintf "paved tile (%d,%d) is disconnected from the traffic network" x y) // The isolated decorative scatter must NOT be paved (that is the whole point). Assert.IsTrue(paving.Count < (map.Tiles |> Array.filter (fun c -> c = int MapGen.GroundTile.Stone) |> Array.length), "paving must be a strict subset of all stone (scatter dropped)") [] member _.PavingIsDeterministicForTheSameSeed () = let map2 = MapGen.generateWithSize 256 192 seed let a = cc0PavingTiles map |> Set.toList |> List.sort |> List.map (fun i -> sprintf "%d" i) |> String.concat ";" let b = cc0PavingTiles map2 |> Set.toList |> List.sort |> List.map (fun i -> sprintf "%d" i) |> String.concat ";" Assert.AreEqual(a, b) [] member _.RedLanternsHangAtDoorwaysAndAreDeterministic () = let lanterns = cc0RedLanternTiles map let doors = (map.Buildings @ map.Farmhouses) |> List.map (fun b -> (b.DoorX, b.DoorY)) |> List.distinct Assert.IsTrue(lanterns.Length > 0, "seed 4242 should hang red lanterns") Assert.AreEqual(doors.Length, lanterns.Length) for (x, y) in lanterns do Assert.IsTrue(List.contains (x, y) doors, "a red lantern hangs exactly at a doorway") Assert.AreEqual( (lanterns |> List.sort |> List.map (fun (x, y) -> sprintf "%d:%d" x y) |> String.concat ";"), (cc0RedLanternTiles map |> List.sort |> List.map (fun (x, y) -> sprintf "%d:%d" x y) |> String.concat ";")) [] member _.RedAndStoneLanternsFeedTheSameNightGlowPath () = let cc0Lights = nightLightTilesWith true map |> Set.ofList let fallbackLights = nightLightTilesWith false map |> Set.ofList // cc0 mode adds both the road stone lanterns and the red door lanterns. for t in cc0RedLanternTiles map do Assert.IsTrue(Set.contains t cc0Lights, "red lantern must be a cc0 night light") for t in cc0LanternTiles map 12 do Assert.IsTrue(Set.contains t cc0Lights, "road lantern must be a cc0 night light") // Red lanterns hang at doorways, which are already house lights, so the // fallback set may legitimately contain that tile; what must NOT leak is // the road stone lantern set (a cc0-only placement). for t in cc0LanternTiles map 12 do Assert.IsFalse(Set.contains t fallbackLights, "road lantern must not leak into the fallback light set") Assert.IsTrue(nightLightTiles map = nightLightTilesWith false map, "nightLightTiles keeps the old set") Assert.IsTrue(Set.isSubset fallbackLights cc0Lights, "cc0 light set is a superset of the fallback set") [] member _.SameSeedPlacementsAreStableAcrossGeneration () = // Day and night frames reuse the same map; re-generating it must reproduce // every P40 placement byte-for-byte (no clock, no hidden randomness). let map2 = MapGen.generateWithSize 256 192 seed Assert.AreEqual( (cc0PavingTiles map |> Set.toList |> List.sort |> List.map string |> String.concat ";"), (cc0PavingTiles map2 |> Set.toList |> List.sort |> List.map string |> String.concat ";")) Assert.AreEqual( (cc0RedLanternTiles map |> List.sort |> List.map (fun (x, y) -> sprintf "%d:%d" x y) |> String.concat ";"), (cc0RedLanternTiles map2 |> List.sort |> List.map (fun (x, y) -> sprintf "%d:%d" x y) |> String.concat ";")) [] member _.P40AtlasIndicesAreInRange () = for index in [ cc0TileIndexPaving; cc0TileIndexRedLantern ] do Assert.IsTrue(index >= 0 && index < cc0TileCount, sprintf "P40 tile index %d out of atlas" index) Assert.AreEqual(21, cc0TileCount) [] member _.BoatAwningIsASolidArchWithAContinuousRidge () = let width, _, pixel = atlasRgba () Assert.IsTrue(width >= (cc0TileIndexBoatRight + 1) * 32, "atlas must cover the boat tiles") for tile in [ cc0TileIndexBoatLeft; cc0TileIndexBoatRight ] do let ox = tile * 32 // Ridge: y in [1,2], x in [6,25] must have no transparent column break. for x in 6 .. 25 do for y in 1 .. 2 do let (_, _, _, a) = pixel (ox + x) y Assert.IsTrue(a > 0, sprintf "boat tile %d ridge pixel (%d,%d) is transparent" tile x y) // Solid arch: below the arch line, every x in [4,27] has opaque bamboo. for x in 4 .. 27 do let hasBamboo = [ 0 .. 31 ] |> List.exists (fun y -> isBambooOpaque (pixel (ox + x) y)) Assert.IsTrue(hasBamboo, sprintf "boat tile %d column %d has no opaque bamboo" tile x) // The two halves join: the arch top at the tile boundary is the same row. let topRow (ox: int) (x: int) = [ 0 .. 31 ] |> List.tryFind (fun y -> isBambooOpaque (pixel (ox + x) y)) Assert.AreEqual>( topRow (cc0TileIndexBoatLeft * 32) 31, topRow (cc0TileIndexBoatRight * 32) 0, "left/right awning halves must meet at the same arch height (no broken arc)")