From 92bcc9b9fc4eeea4f11913a0de2a34811b262dfd Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 12:41:06 +0800 Subject: feat(desktop): P30 512 民居增密 + 乡村空区填充 + 主路柔化 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MapGen(仅大图尺寸自适应分支,默认 64x48/256x192 不进入、不消费额外 RNG): ① ExtraRiversideHouses 16 -> 30,组团 2-5 座;含基础岸屋的路段优先布组并为每座孤立 基础民居贴格配对,placeGroup 试排后按「与既有/同组民居足迹间隙 <=2」过滤,孤立单屋不落地。 512 seed4242:组团民居 24 -> 45,规模 [5;4x6;3x2;2x3],0 组 <2 座。 ② 新增 GroundTile.PaddyField=4/VegetablePlot=5(成熟 tile;非水可通行)+ Result.Groves (纯视觉)+ Result.Farmhouses(门贴院落路、计入 BFS);18x14 确定性网格铺田块/树丛/农舍, 离水 >=3 格、避开路网/核心/民居。草地瓦片 96.3% -> 83.9%,水田 11595 + 菜畦 12877 格。 ③ Result.MainRoadCenter 逐列中心线,value noise(scale 130、幅度 ±3)确定性缓弯, 相邻列步长 <=1、遇弯补格保证 4 连通;默认图主街仍笔直。 - VillageArt:水田/菜畦/树丛/农舍渲染(复用成熟 sprite 与同一立面纯函数)、农舍门窗计入夜光、 新增 drawMapRegion 局部真实离屏渲染。 - Game:P30 证据钩子 LV_MAPGEN_P30_SHOT=1(整图 3072x2304 day/night + 田块/主路 512x256@4px)。 - 回归 +5:组团规模/空区填充/田块合规/农舍可达确定性/主路确定性弯曲。 Kernel 92 / Desktop 156 全绿;Release 0 警告 0 错误; final_digest=953775FAEB2F… 3 次一致、performance_determinism=PASS; 64x48=6042052、256x192=1284656962 锁定不变。 --- src/LivingVillage.Desktop.Tests/MapGenTests.fs | 112 +++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 5 deletions(-) (limited to 'src/LivingVillage.Desktop.Tests/MapGenTests.fs') diff --git a/src/LivingVillage.Desktop.Tests/MapGenTests.fs b/src/LivingVillage.Desktop.Tests/MapGenTests.fs index d83a144..3ff2904 100644 --- a/src/LivingVillage.Desktop.Tests/MapGenTests.fs +++ b/src/LivingVillage.Desktop.Tests/MapGenTests.fs @@ -36,6 +36,25 @@ type MapGenTests () = let nx, ny = x + dx, y + dy if nx >= 0 && nx < map.Width && ny >= 0 && ny < map.Height && map.Tiles.[ny * map.Width + nx] = int MapGen.GroundTile.Water then 1 else 0) + /// P30:把民居按「足迹邻近(两轴间隙 <=2 格)」聚成组团,返回各组规模。 + let houseGroups (houses: MapGen.Building list) : int list = + let arr = houses |> List.sortBy (fun b -> b.Top, b.Left) |> Array.ofList + let n = arr.Length + let parent = Array.init n id + let rec find i = if parent.[i] = i then i else (parent.[i] <- find parent.[i]; parent.[i]) + let union a b = + let ra = find a + let rb = find b + if ra <> rb then parent.[ra] <- rb + let near (a: MapGen.Building) (b: MapGen.Building) = + let xGap = max (a.Left - (b.Left + b.Width - 1)) (b.Left - (a.Left + a.Width - 1)) + let yGap = max (a.Top - (b.Top + b.Height - 1)) (b.Top - (a.Top + a.Height - 1)) + xGap <= 2 && yGap <= 2 + for i in 0 .. n - 1 do + for j in i + 1 .. n - 1 do + if near arr.[i] arr.[j] then union i j + [ for i in 0 .. n - 1 -> find i ] |> List.groupBy id |> List.map (fun (_, group) -> group.Length) + [] member _.SameSeedGeneratesByteIdenticalTerrain () = let a = MapGen.generateWithSize 64 48 (uint64 4242) @@ -256,20 +275,20 @@ type MapGenTests () = Assert.IsTrue(a.Buildings = b.Buildings) Assert.AreEqual(MapGen.serialize a, MapGen.serialize b) - /// P28:512 沿河民居由 10 -> 16 增密(+6),并出现 2/3/4 三档宽度(附属小筑/民居/大宅), - /// 形成聚落层次;默认图仍为 0,不消耗 RNG,故 64/256 checksum 锁定不变。 + /// P28/P30:512 沿河民居增密并出现 2/3/4 三档宽度(附属小筑/民居/大宅),形成聚落层次; + /// 默认图仍为 0,不消耗 RNG,故 64/256 checksum 锁定不变。 [] member _.LargeMapHouseDensityHierarchyOnlyGrowsLargeMaps () = - Assert.AreEqual(16, (MapGen.paramsForSize 512 384 (uint64 1)).ExtraRiversideHouses) + Assert.AreEqual(30, (MapGen.paramsForSize 512 384 (uint64 1)).ExtraRiversideHouses) Assert.AreEqual(0, (MapGen.paramsForSize 64 48 (uint64 1)).ExtraRiversideHouses) Assert.AreEqual(0, (MapGen.paramsForSize 256 192 (uint64 1)).ExtraRiversideHouses) let map = MapGen.generateWithSize 512 384 (uint64 4242) - Assert.IsTrue(map.Buildings.Length >= 22, sprintf "P28 512 must place >=22 houses, saw %d" map.Buildings.Length) + Assert.IsTrue(map.Buildings.Length >= 28, sprintf "P30 512 must place >=28 houses, saw %d" map.Buildings.Length) let widths = map.Buildings |> List.map (fun b -> b.Width) |> Set.ofList Assert.IsTrue(widths.Contains 2, "outbuilding width 2 must appear") Assert.IsTrue(widths.Contains 3, "house width 3 must appear") Assert.IsTrue(widths.Contains 4, "hall width 4 must appear") - Assert.AreEqual(24, map.Buildings.Length) + Assert.IsTrue(map.Buildings.Length + map.Farmhouses.Length >= 32, "total houses (clusters + farmsteads) must be >=32") [] member _.DefaultSizeHouseCountUnchanged () = @@ -439,6 +458,89 @@ type MapGenTests () = let c = MapGen.generateWithSize 512 384 (uint64 7) Assert.AreNotEqual(MapGen.serialize a, MapGen.serialize c) + // ---- P30:512 民居增密 + 组团规模 + 乡村空区填充 + 主路柔化 ---- + + [] + member _.LargeMapGroupsAreTwoToFiveWithFewIsolated () = + let map = MapGen.generateWithSize 512 384 (uint64 4242) + let groups = houseGroups map.Buildings + Assert.IsTrue(groups.Length > 0, "512 must form house groups") + let singletons = groups |> List.filter (fun n -> n < 2) |> List.length + Assert.IsTrue(singletons <= 2, sprintf "isolated single-house groups must be <=2, saw %d" singletons) + let inRange = groups |> List.filter (fun n -> n >= 2 && n <= 5) |> List.length + Assert.IsTrue(inRange * 2 >= groups.Length, "most groups must hold 2-5 houses") + // 另一 seed 同样不产孤立组,验证不是对 4242 的过拟合。 + let other = houseGroups (MapGen.generateWithSize 512 384 (uint64 7)).Buildings + Assert.IsTrue((other |> List.filter (fun n -> n < 2) |> List.length) <= 2) + + [] + member _.LargeMapCountrysideFillsEmptyBandsWithMatureTiles () = + let map = MapGen.generateWithSize 512 384 (uint64 4242) + let isField code = code = int MapGen.GroundTile.PaddyField || code = int MapGen.GroundTile.VegetablePlot + let paddy = map.Tiles |> Array.filter (fun c -> c = int MapGen.GroundTile.PaddyField) |> Array.length + let veg = map.Tiles |> Array.filter (fun c -> c = int MapGen.GroundTile.VegetablePlot) |> Array.length + Assert.IsTrue(paddy > 0, "paddy fields must appear on 512") + Assert.IsTrue(veg > 0, "vegetable plots must appear on 512") + Assert.IsTrue(paddy + veg > 4000, sprintf "fields must cover a meaningful share, saw %d" (paddy + veg)) + Assert.IsTrue(map.Groves.Length > 0, "tree groves must appear") + Assert.IsTrue(map.Farmhouses.Length > 0, "scattered farmsteads must appear") + // 上部/下部空带都要有田块,避免大面积纯草。 + let topBand = [ for y in 0 .. 90 do for x in 0 .. map.Width - 1 -> map.Tiles.[y * map.Width + x] ] |> List.filter isField |> List.length + let bottomBand = [ for y in 296 .. map.Height - 1 do for x in 0 .. map.Width - 1 -> map.Tiles.[y * map.Width + x] ] |> List.filter isField |> List.length + Assert.IsTrue(topBand > 100, sprintf "top band must contain fields, saw %d" topBand) + Assert.IsTrue(bottomBand > 100, sprintf "bottom band must contain fields, saw %d" bottomBand) + // 默认图完全不进入乡村分支。 + let small = MapGen.generateWithSize 256 192 (uint64 4242) + Assert.AreEqual(0, small.Groves.Length) + Assert.AreEqual(0, small.Farmhouses.Length) + Assert.AreEqual(0, small.Tiles |> Array.filter isField |> Array.length) + + [] + member _.FieldsStayOnLandAwayFromWaterPathsAndCore () = + let map = MapGen.generateWithSize 512 384 (uint64 4242) + let pathSet = System.Collections.Generic.HashSet(map.Paths |> List.map (fun (x, y) -> y * map.Width + x)) + let isField code = code = int MapGen.GroundTile.PaddyField || code = int MapGen.GroundTile.VegetablePlot + for y in 0 .. map.Height - 1 do + for x in 0 .. map.Width - 1 do + let i = y * map.Width + x + if isField map.Tiles.[i] then + Assert.AreNotEqual(int MapGen.GroundTile.Water, map.Tiles.[i]) + Assert.IsFalse(pathSet.Contains i, sprintf "field (%d,%d) must not sit on a path" x y) + let inCore = x >= map.Core.MinX && x <= map.Core.MaxX && y >= map.Core.MinY && y <= map.Core.MaxY + Assert.IsFalse(inCore, sprintf "field (%d,%d) must stay outside the core" x y) + + [] + member _.LargeMapFarmsteadDoorsAreReachableAndDeterministic () = + let map = MapGen.generateWithSize 512 384 (uint64 4242) + Assert.IsTrue(map.ReachabilityOk, "512 reachability must hold with farmsteads") + let visited = MapGen.floodFill map (fst map.Spawns.Head) (snd map.Spawns.Head) + for farmhouse in map.Farmhouses do + Assert.IsTrue(MapGen.isWalkable map farmhouse.DoorX farmhouse.DoorY, sprintf "farmstead door (%d,%d) must be walkable" farmhouse.DoorX farmhouse.DoorY) + Assert.IsTrue(visited.[farmhouse.DoorY * map.Width + farmhouse.DoorX], sprintf "farmstead door (%d,%d) unreachable" farmhouse.DoorX farmhouse.DoorY) + let other = MapGen.generateWithSize 512 384 (uint64 4242) + Assert.IsTrue(map.Farmhouses = other.Farmhouses) + Assert.IsTrue(map.Groves = other.Groves) + Assert.IsTrue(map.Tiles = other.Tiles) + Assert.AreEqual(MapGen.serialize map, MapGen.serialize other) + + [] + member _.LargeMapMainRoadIsDeterministicSerpentine () = + let map = MapGen.generateWithSize 512 384 (uint64 4242) + let other = MapGen.generateWithSize 512 384 (uint64 4242) + // 同 seed 两次逐字节一致(含主路中心线)。 + Assert.IsTrue(map.MainRoadCenter = other.MainRoadCenter, "road centerline must be byte-identical across runs") + Assert.AreEqual(MapGen.serialize map, MapGen.serialize other) + // 逐点微扰:相邻列步长 <=1,且相对中段基准确有 >=2 格弯曲。 + map.MainRoadCenter + |> Array.pairwise + |> Array.iter (fun (a, b) -> Assert.IsTrue(abs (b - a) <= 1, sprintf "road step must be <=1, saw %d -> %d" a b)) + let baseY = map.MainRoadCenter.[map.Width / 2] + Assert.IsTrue(map.MainRoadCenter |> Array.exists (fun y -> abs (y - baseY) >= 2), "main road must bend by >=2 tiles") + // 默认图主街仍笔直(不进入新分支)。 + let small = MapGen.generateWithSize 256 192 (uint64 4242) + let smallBase = small.MainRoadCenter.[small.Width / 2] + Assert.IsTrue(small.MainRoadCenter |> Array.forall (fun y -> y = smallBase), "default map road must stay straight") + [] member _.VisibleRangeClampsAtLegacyMapEdges () = let x0, y0, x1, y1 = MapGen.visibleTileRange 64 48 0 0 2048 1536 -- cgit v1.2.3