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(保留历史通配行为)。 [] 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) [] 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(first, second) assertTargetsReachable world first // 独立重建的同 seed 世界得到相同定点。 let rebuilt = worldWith (Some(Occupation.stateOf Occupation.Farmer)) for day in 0L .. 29L do Assert.AreEqual(stateAt rebuilt Occupation.Farmer day, stateAt (worldWith (Some(Occupation.stateOf Occupation.Farmer))) Occupation.Farmer day) [] 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 [] 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(npcA, npcB) Assert.IsTrue(npcA.IsSome) Assert.AreEqual(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(None, Occupation.targetNpcOf seed 7UL 3L []) Assert.AreEqual<(int * int) option>(None, Occupation.targetTileOf seed 7UL 3L []) [] 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( 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(v2, WorldSave.saveWith None noOccupationWorld) [] 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.Done, (Occupation.applySignal (Occupation.Dialogued target) active).State) Assert.AreEqual( Occupation.Active, (Occupation.applySignal (Occupation.Dialogued other) active).State) Assert.AreEqual( Occupation.Active, (Occupation.applySignal Occupation.NightAtWater active).State) [] 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.Done, (Occupation.applySignal (Occupation.ArrivedAt target) active).State) Assert.AreEqual( Occupation.Active, (Occupation.applySignal (Occupation.ArrivedAt other) active).State) Assert.AreEqual( Occupation.Active, (Occupation.applySignal (Occupation.Dialogued (NpcId 0)) active).State) [] 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(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(expected.Today, nextDay.Today) Assert.IsNotNull(nextDay.Today) [] 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(text, WorldSave.saveWith (Some state) world) match WorldSave.loadFromFileWith path with | Ok (_, Some restored) -> Assert.AreEqual(state.Today, restored.Today) Assert.AreEqual(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(v2, WorldSave.saveWith None world)