namespace LivingVillage.Kernel.Tests open System open System.IO open Microsoft.VisualStudio.TestTools.UnitTesting open LivingVillage.Kernel open LivingVillage.Kernel.Sim open LivingVillage.Desktop module private M6aHarness = let zeroInput = { Input = { MoveX = 0.0f; MoveY = 0.0f } } let atNpc (id: int) (world: World) : World = let npc = world.Npcs.[id] { world with Avatar = { world.Avatar with Pos = npc.Pos } } let readyForChat (world: World) : World = { world with Npcs = world.Npcs |> Array.map (fun npc -> { npc with Mind = { npc.Mind with Action = Wander Target = actionTarget Wander ActionAge = minActionTicks EffectDone = true } }) } let withDialogue (world: World) : World = match Sim.chooseDialogue (NpcId 0) SmallTalk (atNpc 0 world) with | DialogueSucceeded (_, next) -> next | DialogueRejected (failure, _) -> Assert.Fail($"dialogue rejected: {failure}"); Unchecked.defaultof let withChat (world: World) : World = match Sim.chat { Narrator = NpcId 0; Receiver = NpcId 1 } (readyForChat world) with | ChatSucceeded (Some _, next) -> next | ChatSucceeded (None, _) -> Assert.Fail("chat should create a rumor"); Unchecked.defaultof | ChatRejected (failure, _) -> Assert.Fail($"chat rejected: {failure}"); Unchecked.defaultof let withTrade (world: World) : World = match Sim.trade { Buyer = NpcId 0; Seller = NpcId 1; Item = Food; Quantity = 1 } world with | TradeSucceeded next -> next | TradeRejected (failure, _) -> Assert.Fail($"trade rejected: {failure}"); Unchecked.defaultof let advance (inputs: Input list) (world: World) : World = inputs |> List.fold (fun current input -> Sim.step { Input = input } current) world let advanceTrace (inputs: Input list) (world: World) : World list = inputs |> List.scan (fun current input -> Sim.step { Input = input } current) world |> List.tail let withTempSave (action: string -> unit) = let id = Guid.NewGuid().ToString("N") let path = Path.Combine(Path.GetTempPath(), $"living-village-m6a-{id}.save") try action path finally if File.Exists path then File.Delete path [] type M6aTests () = [] member _.SaveLoadPreservesWorldAndDeterministicContinuation () = let world = Sim.initialWorldN 7UL 4 |> M6aHarness.withDialogue |> M6aHarness.withChat |> M6aHarness.withTrade |> fun current -> let tick = 22L * (Sim.ticksPerDay / 24L) { current with Tick = tick; Time = float tick * Sim.dtSeconds } M6aHarness.withTempSave (fun path -> WorldSave.saveToFile path world match WorldSave.loadFromFile path with | Error failure -> Assert.Fail($"save should load: {failure}") | Ok restored -> Assert.AreEqual(WorldSave.save world, WorldSave.save restored) Assert.AreEqual(world.Time, restored.Time) Assert.IsTrue(Sim.isNightTick restored.Tick) Assert.AreEqual(world.Avatar.Mind.Needs, restored.Avatar.Mind.Needs) Assert.AreEqual(world.Avatar.Mind.Needs.Money, restored.Avatar.Mind.Needs.Money) CollectionAssert.AreEqual(world.Npcs, restored.Npcs) Assert.AreEqual>(world.Npcs.[0].Inventory, restored.Npcs.[0].Inventory) Assert.AreEqual(world.Events, restored.Events) Assert.AreEqual(world.Rumors, restored.Rumors) Assert.AreEqual(world.Annals, restored.Annals) Assert.AreEqual(world.Rng.State, restored.Rng.State) let inputs = [ { MoveX = 0.5f; MoveY = -0.25f } { MoveX = 0.0f; MoveY = 0.0f } { MoveX = -1.0f; MoveY = 0.75f } { MoveX = 0.25f; MoveY = 0.0f } ] let continued = M6aHarness.advanceTrace inputs world let restoredContinued = M6aHarness.advanceTrace inputs restored Assert.AreEqual(continued |> List.map WorldSave.save, restoredContinued |> List.map WorldSave.save) Assert.AreEqual(continued |> List.map (fun next -> next.Rng.State), restoredContinued |> List.map (fun next -> next.Rng.State)) Assert.AreEqual(continued |> List.map (fun next -> next.Events), restoredContinued |> List.map (fun next -> next.Events)) Assert.AreEqual(WorldSave.save (List.last continued), WorldSave.save (List.last restoredContinued))) [] member _.RejectedFilesDoNotReplaceCurrentWorld () = let currentWorld = Sim.initialWorldN 99UL 2 let before = WorldSave.save currentWorld let mutable current = currentWorld let reject path = match WorldSave.loadFromFile path with | Ok loaded -> current <- loaded Assert.Fail("invalid save must not load") | Error _ -> () Assert.AreEqual(before, WorldSave.save current) M6aHarness.withTempSave (fun path -> reject (path + ".missing") File.WriteAllText(path, "LV_WORLD_SAVE_V0") reject path File.WriteAllText(path, WorldSave.save currentWorld + "|trailing-corruption") reject path) [] member _.CorruptNumericAndInventoryValuesAreRejected () = let world = Sim.initialWorldN 7UL 2 M6aHarness.withTempSave (fun path -> let nonFinite = { world with Time = Double.NaN } WorldSave.saveToFile path nonFinite match WorldSave.loadFromFile path with | Ok _ -> Assert.Fail("non-finite time must be rejected") | Error _ -> () let invalidInventory = { world with Npcs = [| { world.Npcs.[0] with Inventory = Map.ofList [ Food, -1 ] } world.Npcs.[1] |] } WorldSave.saveToFile path invalidInventory match WorldSave.loadFromFile path with | Ok _ -> Assert.Fail("negative inventory must be rejected") | Error _ -> ()) [] member _.SimulationClockSupportsPauseAndRequestedSpeeds () = let initial = SimulationControl.initial Assert.AreEqual(1, SimulationControl.stepsPerFrame initial) Assert.AreEqual(2, SimulationControl.stepsPerFrame (SimulationControl.setSpeed TwoX initial)) Assert.AreEqual(5, SimulationControl.stepsPerFrame (SimulationControl.setSpeed FiveX initial)) let paused = SimulationControl.togglePause initial Assert.IsTrue(paused.Paused) Assert.AreEqual(0, SimulationControl.stepsPerFrame paused) let resumed = SimulationControl.togglePause paused Assert.IsFalse(resumed.Paused) Assert.AreEqual(OneX, resumed.Speed) [] member _.BoundsAreParameterizableAndClampMovementAtLargeMapEdge () = Sim.configureBounds 512 384 try let world = Sim.initialWorld (uint64 42) let stepped = Sim.step { Input = { MoveX = 1.0f; MoveY = 1.0f } } world // Legacy maps clamp within 64x48; at 512x384 the avatar is free to wander // well past the old tile limits. let maxX = float32 (Sim.mapWidthTiles * Sim.tilePixels - Sim.tilePixels) let maxY = float32 (Sim.mapHeightTiles * Sim.tilePixels - Sim.tilePixels) Assert.IsTrue(stepped.Avatar.Pos.X <= maxX) Assert.IsTrue(stepped.Avatar.Pos.Y <= maxY) Assert.AreEqual(512, Sim.mapWidthTiles) finally Sim.configureBounds 64 48 [] member _.SaveV1LegacyStillReadable () = let world = Sim.initialWorld (uint64 42) WorldSave.saveToFile "p7-legacy-src.save" world let modern = System.IO.File.ReadAllText "p7-legacy-src.save" // v2 = version|w|h|body...; strip the two bounds integers and roll the header back to v1. let firstPipe = modern.IndexOf('|') let secondPipe = modern.IndexOf('|', firstPipe + 1) let thirdPipe = modern.IndexOf('|', secondPipe + 1) System.IO.File.WriteAllText("p7-legacy.save", ("LV_WORLD_SAVE_V1" + modern.Substring(thirdPipe))) let text = System.IO.File.ReadAllText "p7-legacy.save" Sim.configureBounds 512 384 try Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V1"), "legacy header must be v1") match WorldSave.loadFromFile "p7-legacy.save" with | Ok loaded -> Assert.AreEqual(64, Sim.mapWidthTiles) Assert.AreEqual(48, Sim.mapHeightTiles) | Error message -> Assert.Fail(sprintf "legacy v1 load failed: %s" message) finally Sim.configureBounds 64 48 [] member _.SaveV2RecordsBoundsAndStaysDeterministic () = Sim.configureBounds 512 384 try let world = Sim.initialWorld (uint64 7) WorldSave.saveToFile "p7-v2.save" world let text = System.IO.File.ReadAllText "p7-v2.save" Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V2"), "v2 save must carry bounds") Sim.configureBounds 64 48 match WorldSave.loadFromFile "p7-v2.save" with | Ok loaded -> Assert.AreEqual(512, Sim.mapWidthTiles) Assert.AreEqual(384, Sim.mapHeightTiles) Assert.AreEqual(loaded.Tick, world.Tick) | Error message -> Assert.Fail(sprintf "v2 load failed: %s" message) finally Sim.configureBounds 64 48 [] member _.ConfiguredLegacyStepCountsRemainExactUntilManualSpeedSelection () = let control, legacySteps = SimulationControl.initialForConfiguredSteps 7 Assert.AreEqual(Some 7, legacySteps) Assert.AreEqual(7, SimulationControl.stepsPerFrameWithLegacy legacySteps control) Assert.AreEqual(0, SimulationControl.stepsPerFrameWithLegacy legacySteps (SimulationControl.togglePause control)) let manuallySelected = SimulationControl.setSpeed TwoX control Assert.AreEqual(2, SimulationControl.stepsPerFrameWithLegacy None manuallySelected) [] member _.OccupationProfilesMatchTheDesignTable () = let farmer = LivingVillage.Kernel.Occupation.profileOf Occupation.Farmer Assert.AreEqual(60, farmer.InitialMoney) Assert.AreEqual(100.0f, farmer.InitialEnergy) Assert.AreEqual(12, snd (List.head farmer.InitialInventory)) let fisher = Occupation.profileOf Occupation.Fisher Assert.AreEqual(45, fisher.InitialMoney) Assert.AreEqual(95.0f, fisher.InitialEnergy) Assert.AreEqual(8, snd (List.head fisher.InitialInventory)) Assert.AreEqual(3, snd (List.item 1 fisher.InitialInventory)) let peddler = Occupation.profileOf Occupation.Peddler Assert.AreEqual(90, peddler.InitialMoney) Assert.AreEqual(6, snd (List.head peddler.InitialInventory)) Assert.AreEqual(4, snd (List.item 1 peddler.InitialInventory)) let scholar = Occupation.profileOf Occupation.Scholar Assert.AreEqual(70, scholar.InitialMoney) Assert.AreEqual(10, snd (List.head scholar.InitialInventory)) Assert.AreEqual(2, snd (List.item 1 scholar.InitialInventory)) [] member _.WorldSaveV3RoundTripsOccupation () = let world = Sim.initialWorld (uint64 11) let state = Occupation.stateOf Occupation.Fisher WorldSave.saveToFileWith (Some state) "p10-v3.save" world let text = System.IO.File.ReadAllText "p10-v3.save" Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3"), "v3 header expected") match WorldSave.loadFromFileWith "p10-v3.save" with | Ok (_loaded, restored) -> match restored with | Some st -> Assert.AreEqual(45, st.Profile.InitialMoney) Assert.AreEqual(0, st.TaskToken) Assert.AreEqual(0, st.StoryStage) | None -> Assert.Fail("v3 round trip lost the occupation") | Error message -> Assert.Fail(sprintf "v3 round trip failed: %s" message) [] member _.WorldSaveLegacySavesStillReadWithoutOccupation () = let world = Sim.initialWorld (uint64 42) WorldSave.saveToFile "p10-v2.save" world Assert.IsTrue((System.IO.File.ReadAllText "p10-v2.save").StartsWith("LV_WORLD_SAVE_V2")) match WorldSave.loadFromFileWith "p10-v2.save" with | Ok (_w, occ) -> Assert.AreEqual(null, occ |> Option.map (fun _ -> box 1) |> Option.defaultValue null) | Error message -> Assert.Fail(sprintf "v2 read failed: %s" message) Sim.configureBounds 64 48 [] member _.OccupationBootstrapSavesAreByteIdenticalForSameSeed () = let seed = uint64 4242 let state1 = Some (Occupation.stateOf Occupation.Peddler) let state2 = Some (Occupation.stateOf Occupation.Peddler) let world1 = WorldBootstrap.initialWorldWithOccupation false seed 4 state1 let world2 = WorldBootstrap.initialWorldWithOccupation false seed 4 state2 let text1 = WorldSave.saveWith state1 world1 let text2 = WorldSave.saveWith state2 world2 Assert.AreEqual(text1, text2) [] member _.OccupationChineseNamesAreKernelSideConstants () = Assert.AreEqual("农夫", Occupation.nameOf Occupation.Farmer) Assert.AreEqual("渔夫", Occupation.nameOf Occupation.Fisher) Assert.AreEqual("货郎", Occupation.nameOf Occupation.Peddler) Assert.AreEqual("书生", Occupation.nameOf Occupation.Scholar) /// P20:存档 v2 的边界字段在非默认尺寸下必须 round-trip,读回后 /// mapWidthTiles/mapHeightTiles 恢复且再次保存与原文逐字节一致。 /// 旧 64x48 存档的 Round-trip 保持不变由既有 v1/v2 用例覆盖。 [] member _.SaveRoundTripsParameterizedMapBounds () = let path = "p20-bounds-v2.save" try Sim.configureBounds 256 192 let world = Sim.initialWorld (uint64 4242) WorldSave.saveToFile path world let text = File.ReadAllText path Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V2"), "bounds save must stay v2") match WorldSave.loadFromFile path with | Error failure -> Assert.Fail(sprintf "bounds save should load: %s" failure) | Ok restored -> Assert.AreEqual(256, Sim.mapWidthTiles) Assert.AreEqual(192, Sim.mapHeightTiles) Assert.AreEqual(text, WorldSave.save restored) finally Sim.configureBounds 64 48 if File.Exists path then File.Delete path /// P23:512x384 参数化边界在存档 v2 中 round-trip;同 world 两次保存逐字节一致, /// 读回后再次保存仍与原文一致(v1 的 64x48 兼容由 SaveV1LegacyStillReadable 覆盖)。 [] member _.SaveRoundTrips512x384BoundsAndIsByteStable () = let path = "p23-bounds-512-v2.save" let path2 = path + ".2" try Sim.configureBounds 512 384 let world = Sim.initialWorld (uint64 4242) WorldSave.saveToFile path world WorldSave.saveToFile path2 world let text = File.ReadAllText path Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V2"), "bounds save must stay v2") Assert.AreEqual(text, File.ReadAllText path2) Sim.configureBounds 64 48 match WorldSave.loadFromFile path with | Error failure -> Assert.Fail(sprintf "512x384 bounds save should load: %s" failure) | Ok restored -> Assert.AreEqual(512, Sim.mapWidthTiles) Assert.AreEqual(384, Sim.mapHeightTiles) Assert.AreEqual(text, WorldSave.save restored) finally Sim.configureBounds 64 48 if File.Exists path then File.Delete path if File.Exists path2 then File.Delete path2