From d06656aa968ccb2769ba7c90c97f57f2818bb828 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 02:57:12 +0800 Subject: feat(mapgen): 生成器序列化 + 世界站位接入(P20 第三步 A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MapGen.serialize:生成结果的确定性序列化(尺寸/seed/瓦片/Rivers/Bridges/ Paths/Buildings),同 seed 逐字节一致,供回归与跨机核对。 - WorldBootstrap.initialWorldWithPlacement:显式站位建世界;旧 64x48 路径 (initialWorld/initialWorldWithOccupation)字节口径不变。 - WorldBootstrap.initialWorldInMap:用生成器核心可达区做站位,30 spawn 全落 可达核心;新增 serialization/placement 两条测试。 - fsproj:MapGen 先于 WorldBootstrap 编译。 - Desktop 119->121,Kernel 90 不变。 --- src/LivingVillage.Desktop.Tests/MapGenTests.fs | 25 ++++++++++++++++ .../LivingVillage.Desktop.fsproj | 2 +- src/LivingVillage.Desktop/MapGen.fs | 24 +++++++++++++++ src/LivingVillage.Desktop/WorldBootstrap.fs | 35 ++++++++++++++++++---- 4 files changed, 79 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/LivingVillage.Desktop.Tests/MapGenTests.fs b/src/LivingVillage.Desktop.Tests/MapGenTests.fs index b0b58e7..4079ad0 100644 --- a/src/LivingVillage.Desktop.Tests/MapGenTests.fs +++ b/src/LivingVillage.Desktop.Tests/MapGenTests.fs @@ -147,6 +147,31 @@ type MapGenTests () = let c = MapGen.generateWithSize 256 192 (uint64 7) Assert.IsTrue(a.Buildings <> c.Buildings || a.Bridges <> c.Bridges, "different seeds must relocate structures") + // ---- P20 第三步:序列化确定性 + 默认可玩世界接入生成器站位 ---- + + [] + member _.SameSeedSerializationIsByteIdentical () = + let a = MapGen.generateWithSize 256 192 (uint64 4242) + let b = MapGen.generateWithSize 256 192 (uint64 4242) + Assert.AreEqual(MapGen.serialize a, MapGen.serialize b) + let c = MapGen.generateWithSize 256 192 (uint64 7) + Assert.AreNotEqual(MapGen.serialize a, MapGen.serialize c) + + [] + member _.MapWorldPlacementUsesReachableCoreSpawns () = + let map = MapGen.generateWithSize 256 192 (uint64 4242) + let world = WorldBootstrap.initialWorldInMap false 42UL Sim.npcCount map + Assert.AreEqual(Sim.npcCount, world.Npcs.Length) + let visited = MapGen.floodFill map (fst map.Spawns.Head) (snd map.Spawns.Head) + for npc in world.Npcs do + let tx = int npc.Pos.X / Sim.tilePixels + let ty = int npc.Pos.Y / Sim.tilePixels + Assert.IsTrue(visited.[ty * map.Width + tx], sprintf "npc tile (%d,%d) must be reachable from the core" tx ty) + let ax = int world.Avatar.Pos.X / Sim.tilePixels + let ay = int world.Avatar.Pos.Y / Sim.tilePixels + Assert.IsTrue(visited.[ay * map.Width + ax], "avatar spawn must sit in the reachable core") + Assert.IsTrue(ax >= map.Core.MinX && ax <= map.Core.MaxX && ay >= map.Core.MinY && ay <= map.Core.MaxY, "avatar spawn must sit in the core") + [] member _.VisibleRangeClampsAtLegacyMapEdges () = let x0, y0, x1, y1 = MapGen.visibleTileRange 64 48 0 0 2048 1536 diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj index 739b750..6f388f2 100644 --- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj +++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj @@ -7,9 +7,9 @@ - + diff --git a/src/LivingVillage.Desktop/MapGen.fs b/src/LivingVillage.Desktop/MapGen.fs index c59e6bd..7c7059a 100644 --- a/src/LivingVillage.Desktop/MapGen.fs +++ b/src/LivingVillage.Desktop/MapGen.fs @@ -312,6 +312,30 @@ module MapGen = let generateWithSize (width: int) (height: int) (seed: uint64) : Result = generate { defaultParams seed with Width = width; Height = height } + /// 生成结果的确定性序列化(同 seed 逐字节一致),供回归测试与跨机核对。 + let serialize (map: Result) : string = + let builder = System.Text.StringBuilder() + builder.Append(map.Width).Append('x').Append(map.Height).Append('|').Append(map.Seed).Append('|') |> ignore + for code in map.Tiles do + builder.Append(code).Append(',') |> ignore + builder.Append('|') |> ignore + for river in map.Rivers do + builder.Append(river.CenterY).Append('*').Append(river.Width).Append(';') |> ignore + builder.Append('|') |> ignore + for (x, y) in map.Bridges do + builder.Append(x).Append(':').Append(y).Append(';') |> ignore + builder.Append('|') |> ignore + for (x, y) in map.Paths do + builder.Append(x).Append(':').Append(y).Append(';') |> ignore + builder.Append('|') |> ignore + for building in map.Buildings do + builder + .Append(building.Left).Append(':').Append(building.Top).Append(':') + .Append(building.Width).Append(':').Append(building.Height).Append(':') + .Append(building.DoorX).Append(':').Append(building.DoorY).Append(';') + |> ignore + builder.ToString() + /// 可行走判定:非水,或位于桥面上(桥面仍标记为水但可通行)。 let isWalkable (map: Result) (x: int) (y: int) : bool = if x < 0 || x >= map.Width || y < 0 || y >= map.Height then diff --git a/src/LivingVillage.Desktop/WorldBootstrap.fs b/src/LivingVillage.Desktop/WorldBootstrap.fs index f8ba775..bf0ef65 100644 --- a/src/LivingVillage.Desktop/WorldBootstrap.fs +++ b/src/LivingVillage.Desktop/WorldBootstrap.fs @@ -35,17 +35,26 @@ module WorldBootstrap = nowTick state.Today } - let initialWorldWithOccupation (startAtDaylight: bool) (seed: uint64) (count: int) (occupation: Occupation.State option) : World = - if count < 0 || count > npcSpawnTiles.Length then + /// 以显式站位建世界:avatarTile 为玩家出生瓦片,npcTiles 为 NPC 出生瓦片序列。 + /// 64x48 旧档与世界走 `avatarSpawnPosition` + `npcSpawnTiles` 的等价调用,字节口径不变。 + let initialWorldWithPlacement + (startAtDaylight: bool) + (seed: uint64) + (count: int) + (avatarTile: int * int) + (npcTiles: (int * int) list) + (occupation: Occupation.State option) : World = + if count < 0 || count > npcTiles.Length then invalidArg (nameof count) "Desktop bootstrap supports at most 30 NPCs." + let avatarPosition = tileCenter (fst avatarTile) (snd avatarTile) let startTick = if startAtDaylight then daylightStartTick else 0L let baseWorld = Sim.initialWorldN seed count let avatar = let baseAvatar = { baseWorld.Avatar with - Pos = avatarSpawnPosition - Mind = { baseWorld.Avatar.Mind with Target = avatarSpawnPosition } } + Pos = avatarPosition + Mind = { baseWorld.Avatar.Mind with Target = avatarPosition } } match occupation with | None -> baseAvatar | Some state -> @@ -56,11 +65,11 @@ module WorldBootstrap = Needs = { needs with Money = float32 state.Profile.InitialMoney - Energy = state.Profile.InitialEnergy } } } + Energy = float32 state.Profile.InitialEnergy } } } let npcs = baseWorld.Npcs |> Array.mapi (fun index npc -> - let x, y = npcSpawnTiles.[index] + let x, y = List.item index npcTiles { npc with Pos = tileCenter x y }) { baseWorld with @@ -69,6 +78,20 @@ module WorldBootstrap = Avatar = avatar Npcs = npcs } + let initialWorldWithOccupation (startAtDaylight: bool) (seed: uint64) (count: int) (occupation: Occupation.State option) : World = + initialWorldWithPlacement + startAtDaylight + seed + count + (31, 25) + (List.ofArray npcSpawnTiles) + occupation + + /// P20 第三步:默认可玩世界。生成器的核心可达区作为站位,30 个 spawn 全部落核心可达域。 + let initialWorldInMap (startAtDaylight: bool) (seed: uint64) (count: int) (map: MapGen.Result) : World = + let avatarTile = ((map.Core.MinX + map.Core.MaxX) / 2, (map.Core.MinY + map.Core.MaxY) / 2) + initialWorldWithPlacement startAtDaylight seed count avatarTile map.Spawns None + let initialWorldForDaylight (startAtDaylight: bool) (seed: uint64) (count: int) : World = initialWorldWithOccupation startAtDaylight seed count None -- cgit v1.2.3