diff options
Diffstat (limited to 'src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs | 195 |
1 files changed, 195 insertions, 0 deletions
diff --git a/src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs b/src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs new file mode 100644 index 0000000..214b50d --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs @@ -0,0 +1,195 @@ +namespace LivingVillage.Desktop.Tests + +open System +open System.IO +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop + +/// P66(职业收尾切片:每日任务目标具体化 TargetNpc/TargetTile)。 +/// 设计文档 §2.4-3:当日任务的目标从恒 None(通配)改为按 hash 在**可达集合**内定点。 +/// 口径:仅对既有 `satisfiesCompletion` 会读取目标的模板定点(帮工 → 指定村民 TargetNpc; +/// 翻土 → 自家地块 TargetTile),其余模板目标保持 None(不新增完成语义); +/// 空候选集合 → None(保留历史通配行为)。 +[<TestClass>] +type P66TaskTargetTests () = + + let seed = 42UL + + let worldWith (occupation: Occupation.State option) : World = + WorldBootstrap.initialWorldWithOccupation false seed Sim.npcCount occupation + + let targets (world: World) : Occupation.TaskTargets = WorldBootstrap.reachableTargets world + + let stateAt (world: World) (kind: Occupation.Kind) (day: int64) : Occupation.State = + WorldBootstrap.occupationStateForWorld seed kind (day * Sim.ticksPerDay + 10L) world + + let findDayWith (world: World) (kind: Occupation.Kind) (template: Occupation.TaskTemplateId) : int64 = + let rec loop day = + if day > 299L then + Assert.Fail(sprintf "300 天内未抽到 %A" template) + -1L + else + let task = (stateAt world kind day).Today.Value + if task.TemplateId = template then day else loop (day + 1L) + loop 0L + + let assertTargetsReachable (world: World) (state: Occupation.State) : unit = + match state.Today with + | None -> Assert.Fail("职业当日任务不应为空") + | Some task -> + let reachable = targets world + match task.TemplateId with + | Occupation.HelpWork -> + Assert.IsTrue(task.TargetNpc.IsSome, "帮工应定点到具体村民") + Assert.IsTrue( + reachable.Npcs |> List.contains task.TargetNpc.Value, + sprintf "帮工目标 %A 不在可达 NPC 集合内" task.TargetNpc.Value) + Assert.IsTrue(task.TargetTile.IsNone, "帮工不应带瓦片目标") + | Occupation.TillSoil -> + Assert.IsTrue(task.TargetTile.IsSome, "翻土应定点到具体瓦片") + Assert.IsTrue( + reachable.Tiles |> List.contains task.TargetTile.Value, + sprintf "翻土目标 %A 不在可达瓦片集合内" task.TargetTile.Value) + Assert.IsTrue(task.TargetNpc.IsNone, "翻土不应带 NPC 目标") + | _ -> + Assert.IsTrue(task.TargetNpc.IsNone, sprintf "%A 不应带 NPC 目标" task.TemplateId) + Assert.IsTrue(task.TargetTile.IsNone, sprintf "%A 不应带瓦片目标" task.TemplateId) + + [<TestMethod>] + member _.SameSeedAndKindProduceIdenticalConcreteTargets () = + for kind in Occupation.all do + let world = worldWith (Some(Occupation.stateOf kind)) + for day in 0L .. 29L do + let first = stateAt world kind day + let second = stateAt world kind day + // 同输入两次逐字节一致(结构相等覆盖 TargetNpc/TargetTile)。 + Assert.AreEqual<Occupation.State>(first, second) + assertTargetsReachable world first + // 独立重建的同 seed 世界得到相同定点。 + let rebuilt = worldWith (Some(Occupation.stateOf Occupation.Farmer)) + for day in 0L .. 29L do + Assert.AreEqual<Occupation.State>(stateAt rebuilt Occupation.Farmer day, stateAt (worldWith (Some(Occupation.stateOf Occupation.Farmer))) Occupation.Farmer day) + + [<TestMethod>] + member _.TargetsAlwaysFallInsideReachableSetsAcrossSeeds () = + for s in [ 0UL; 1UL; 7UL; 42UL; 123456789UL; 0xFFFFFFFFUL ] do + let world = WorldBootstrap.initialWorldWithOccupation false s Sim.npcCount None + for kind in Occupation.all do + for day in 0L .. 59L do + let state = WorldBootstrap.occupationStateForWorld s kind (day * Sim.ticksPerDay + 5L) world + assertTargetsReachable world state + + [<TestMethod>] + member _.CandidatePickingIsOrderIndependentAndDeterministic () = + let npcA = Occupation.targetNpcOf seed 7UL 3L [ NpcId 5; NpcId 1; NpcId 9 ] + let npcB = Occupation.targetNpcOf seed 7UL 3L [ NpcId 9; NpcId 5; NpcId 1 ] + Assert.AreEqual<Sim.NpcId option>(npcA, npcB) + Assert.IsTrue(npcA.IsSome) + Assert.AreEqual<Sim.NpcId option>(npcA, Occupation.targetNpcOf seed 7UL 3L [ NpcId 5; NpcId 1; NpcId 9 ]) + let tileA = Occupation.targetTileOf seed 7UL 3L [ (9, 2); (1, 5) ] + let tileB = Occupation.targetTileOf seed 7UL 3L [ (1, 5); (9, 2) ] + Assert.AreEqual<(int * int) option>(tileA, tileB) + Assert.IsTrue(tileA.IsSome) + // 空候选 → None(保留通配,不臆造目标)。 + Assert.AreEqual<Sim.NpcId option>(None, Occupation.targetNpcOf seed 7UL 3L []) + Assert.AreEqual<(int * int) option>(None, Occupation.targetTileOf seed 7UL 3L []) + + [<TestMethod>] + member _.LegacyWildcardPathAndNoOccupationWorldsStayUnchanged () = + // 历史口径 dailyTaskOf:目标恒 None(既有调用点/测试逐字节不变)。 + for kind in Occupation.all do + for day in 0L .. 29L do + let occupationSeed = Occupation.occupationSeedOf kind + let legacy = (Occupation.dailyTaskOf seed occupationSeed day kind).Value + Assert.IsTrue(legacy.TargetNpc.IsNone) + Assert.IsTrue(legacy.TargetTile.IsNone) + Assert.AreEqual<Occupation.DailyTask>( + legacy, + (Occupation.dailyTaskOfWith { Npcs = []; Tiles = [] } seed occupationSeed day kind).Value) + // 无职业世界不产生任务(保持旧行为)。 + let noOccupationWorld = worldWith None + Assert.IsFalse( + (Occupation.dailyTaskOf seed (Occupation.occupationSeedOf Occupation.Farmer) 0L Occupation.Farmer).Value + |> fun task -> task.TargetNpc.IsSome || task.TargetTile.IsSome) + // 无职业存档仍是 v2 且与 saveWith None 逐字节一致。 + let v2 = WorldSave.save noOccupationWorld + Assert.IsTrue(v2.StartsWith("LV_WORLD_SAVE_V2")) + Assert.AreEqual<string>(v2, WorldSave.saveWith None noOccupationWorld) + + [<TestMethod>] + member _.ConcreteHelpWorkTargetGatesCompletionThroughExistingSemantics () = + let world = worldWith (Some(Occupation.stateOf Occupation.Farmer)) + let day = findDayWith world Occupation.Farmer Occupation.HelpWork + let task = (stateAt world Occupation.Farmer day).Today.Value + let target = task.TargetNpc.Value + let other = targets world |> fun t -> t.Npcs |> List.find (fun id -> id <> target) + let active = Occupation.acceptTask task + Assert.AreEqual<Occupation.TaskState>( + Occupation.Done, + (Occupation.applySignal (Occupation.Dialogued target) active).State) + Assert.AreEqual<Occupation.TaskState>( + Occupation.Active, + (Occupation.applySignal (Occupation.Dialogued other) active).State) + Assert.AreEqual<Occupation.TaskState>( + Occupation.Active, + (Occupation.applySignal Occupation.NightAtWater active).State) + + [<TestMethod>] + member _.ConcreteTillSoilTargetGatesCompletionThroughExistingSemantics () = + let world = worldWith (Some(Occupation.stateOf Occupation.Farmer)) + let day = findDayWith world Occupation.Farmer Occupation.TillSoil + let task = (stateAt world Occupation.Farmer day).Today.Value + let target = task.TargetTile.Value + let other = + targets world + |> fun t -> t.Tiles |> List.tryFind (fun tile -> tile <> target) |> Option.defaultValue (fst target + 1, snd target) + let active = Occupation.acceptTask task + Assert.AreEqual<Occupation.TaskState>( + Occupation.Done, + (Occupation.applySignal (Occupation.ArrivedAt target) active).State) + Assert.AreEqual<Occupation.TaskState>( + Occupation.Active, + (Occupation.applySignal (Occupation.ArrivedAt other) active).State) + Assert.AreEqual<Occupation.TaskState>( + Occupation.Active, + (Occupation.applySignal (Occupation.Dialogued (NpcId 0)) active).State) + + [<TestMethod>] + member _.WorldRefreshKeepsSameDayTargetsAndRepointsOnNewDay () = + let world = worldWith (Some(Occupation.stateOf Occupation.Farmer)) + let day0 = stateAt world Occupation.Farmer 0L + let sameDay = WorldBootstrap.refreshOccupationTodayWorld seed day0 (10L) world + Assert.AreEqual<Occupation.DailyTask option>(day0.Today, sameDay.Today) + let nextDayTick = Sim.ticksPerDay + 10L + let nextDay = WorldBootstrap.refreshOccupationTodayWorld seed day0 nextDayTick world + let expected = WorldBootstrap.occupationStateForWorld seed Occupation.Farmer nextDayTick world + Assert.AreEqual<Occupation.DailyTask option>(expected.Today, nextDay.Today) + Assert.IsNotNull(nextDay.Today) + + [<TestMethod>] + member _.V3RoundTripPreservesConcreteTargetsAndV2StaysBaseline () = + let world = worldWith (Some(Occupation.stateOf Occupation.Farmer)) + let day = findDayWith world Occupation.Farmer Occupation.HelpWork + let state = stateAt world Occupation.Farmer day + Assert.IsTrue(state.Today.Value.TargetNpc.IsSome) + let path = Path.Combine(Path.GetTempPath(), "living-village-p66-" + Guid.NewGuid().ToString("N") + ".save") + try + WorldSave.saveToFileWith (Some state) path world + let text = File.ReadAllText path + Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3")) + // 同输入两次写盘逐字节一致。 + Assert.AreEqual<string>(text, WorldSave.saveWith (Some state) world) + match WorldSave.loadFromFileWith path with + | Ok (_, Some restored) -> + Assert.AreEqual<Occupation.DailyTask option>(state.Today, restored.Today) + Assert.AreEqual<Sim.NpcId option>(state.Today.Value.TargetNpc, restored.Today.Value.TargetNpc) + | Ok (_, None) -> Assert.Fail("v3 必须读回职业侧车") + | Error message -> Assert.Fail(message) + finally + if File.Exists path then File.Delete path + // 无职业存档:v2 头 + 与 saveWith None 逐字节一致。 + let v2 = WorldSave.save world + Assert.IsTrue(v2.StartsWith("LV_WORLD_SAVE_V2")) + Assert.AreEqual<string>(v2, WorldSave.saveWith None world) |
