namespace LivingVillage.Kernel.Tests open System open System.IO open Microsoft.VisualStudio.TestTools.UnitTesting open LivingVillage.Kernel open LivingVillage.Kernel.Sim module private DailyTaskHarness = let taskOf (template: Occupation.TaskTemplateId) (state: Occupation.TaskState) : Occupation.DailyTask = { TemplateId = template TargetNpc = None TargetTile = None OfferedTick = 0L DueTick = Sim.ticksPerDay - 1L State = state } let accepted (template: Occupation.TaskTemplateId) : Occupation.DailyTask = Occupation.acceptTask (taskOf template Occupation.Offered) let worldWithAnnal () : World = let world = Sim.initialWorldN 7UL 2 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 taskForDay (day: int64) : Occupation.DailyTask = (Occupation.dailyTaskOf 42UL 7UL day Occupation.Fisher).Value |> Occupation.acceptTask let withTempSave (action: string -> unit) = let id = Guid.NewGuid().ToString("N") let path = Path.Combine(Path.GetTempPath(), $"living-village-p11-{id}.save") try action path finally if File.Exists path then File.Delete path [] type DailyTaskTests () = // ---- §3 生成:hash(seed ^ occupationSeed, dayIndex) mod |pool|(splitmix 同源)---- [] member _.TaskHashFollowsSplitmixOverSeedXorOccupationSeed () = Assert.AreEqual(0xF7E9F3F88CC04AD6UL, Occupation.taskHash 42UL 7UL 0L) Assert.AreEqual(0xFB624BD98F2BE6C4UL, Occupation.taskHash 42UL 7UL 1L) Assert.AreEqual(0x87FC3F1DAC740225UL, Occupation.taskHash 42UL 7UL 2L) Assert.AreEqual(0xDA258F1151868B40UL, Occupation.taskHash 42UL 7UL 3L) Assert.AreEqual(0x02D114B7ABDD7A1FUL, Occupation.taskHash 4242UL 99UL 0L) [] member _.EveryOccupationPoolIsNonEmptyAndFarmFisherKeepThree () = Assert.AreEqual( [ Occupation.DeliverGrain; Occupation.HelpWork; Occupation.TillSoil ], Occupation.taskPoolOf Occupation.Farmer) Assert.AreEqual( [ Occupation.NightCatch; Occupation.SellFish; Occupation.MarketInquiry ], Occupation.taskPoolOf Occupation.Fisher) Assert.AreEqual( [ Occupation.BuyGoods; Occupation.Resell ], Occupation.taskPoolOf Occupation.Peddler) Assert.AreEqual( [ Occupation.ObserveNotes; Occupation.ReasonDebate ], Occupation.taskPoolOf Occupation.Scholar) [] member _.GeneratedTasksStayInPoolAndRepeatDeterministically () = for seed, occ in [ (42UL, 7UL); (7UL, 42UL); (123456789UL, 555UL) ] do for kind in Occupation.all do let pool = Occupation.taskPoolOf kind for day in 0L .. 299L do let task = (Occupation.dailyTaskOf seed occ day kind).Value Assert.IsTrue(pool |> List.contains task.TemplateId, $"day {day} escaped the pool") let again = (Occupation.dailyTaskOf seed occ day kind).Value Assert.AreEqual(task, again) let distinct = [ 0L .. 299L ] |> List.map (fun day -> (Occupation.dailyTaskOf seed occ day kind).Value.TemplateId) |> Set.ofList Assert.IsTrue(distinct.Count >= 2, "300 days must not pin a single template") [] member _.PeddlerAndScholarGenerateTheirOwnDailyTasks () = for kind in [ Occupation.Peddler; Occupation.Scholar ] do for day in 0L .. 59L do let task = (Occupation.dailyTaskOf 42UL 7UL day kind).Value Assert.IsTrue(Occupation.taskPoolOf kind |> List.contains task.TemplateId) Assert.AreEqual( Occupation.BuyGoods, (Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Peddler).Value.TemplateId) Assert.AreEqual( Occupation.ObserveNotes, (Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Scholar).Value.TemplateId) [] member _.TaskWindowCoversExactlyOneDayStartingOffered () = let task = (Occupation.dailyTaskOf 42UL 7UL 3L Occupation.Farmer).Value Assert.AreEqual(3L * Sim.ticksPerDay, task.OfferedTick) Assert.AreEqual(4L * Sim.ticksPerDay - 1L, task.DueTick) Assert.AreEqual(Occupation.Offered, task.State) Assert.AreEqual(3L, Occupation.dayIndexOf (3L * Sim.ticksPerDay)) Assert.AreEqual(3L, Occupation.dayIndexOf (4L * Sim.ticksPerDay - 1L)) [] member _.TaskTemplateChineseNamesMatchDesignTable () = Assert.AreEqual("送粮", Occupation.taskNameOf Occupation.DeliverGrain) Assert.AreEqual("帮工", Occupation.taskNameOf Occupation.HelpWork) Assert.AreEqual("翻土", Occupation.taskNameOf Occupation.TillSoil) Assert.AreEqual("夜捕", Occupation.taskNameOf Occupation.NightCatch) Assert.AreEqual("卖渔", Occupation.taskNameOf Occupation.SellFish) Assert.AreEqual("询价", Occupation.taskNameOf Occupation.MarketInquiry) Assert.AreEqual("收货", Occupation.taskNameOf Occupation.BuyGoods) Assert.AreEqual("转卖", Occupation.taskNameOf Occupation.Resell) Assert.AreEqual("观察记事", Occupation.taskNameOf Occupation.ObserveNotes) Assert.AreEqual("论理", Occupation.taskNameOf Occupation.ReasonDebate) // ---- §3 状态机 Offered|Active|Done|Failed ---- [] member _.AcceptMovesOfferedToActiveOnly () = let offered = DailyTaskHarness.taskOf Occupation.HelpWork Occupation.Offered let active = Occupation.acceptTask offered Assert.AreEqual(Occupation.Active, active.State) Assert.AreEqual(Occupation.Active, (Occupation.acceptTask active).State) let finished = { active with State = Occupation.Done } Assert.AreEqual(Occupation.Done, (Occupation.acceptTask finished).State) let failed = { active with State = Occupation.Failed } Assert.AreEqual(Occupation.Failed, (Occupation.acceptTask failed).State) [] member _.SellFishCompletesOnlyOnPlayerFishSales () = let active = DailyTaskHarness.accepted Occupation.SellFish Assert.AreEqual( Occupation.Done, (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) active).State) Assert.AreEqual( Occupation.Active, (Occupation.applySignal (Occupation.Traded(playerId, Spice, 1)) active).State) Assert.AreEqual( Occupation.Active, (Occupation.applySignal (Occupation.Traded(NpcId 2, Fish, 4)) active).State) let offered = DailyTaskHarness.taskOf Occupation.SellFish Occupation.Offered Assert.AreEqual( Occupation.Offered, (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) offered).State) [] member _.DeliverGrainCompletesOnlyOnPlayerFoodGiveaways () = let active = DailyTaskHarness.accepted Occupation.DeliverGrain Assert.AreEqual( Occupation.Done, (Occupation.applySignal (Occupation.Traded(playerId, Food, 3)) active).State) Assert.AreEqual( Occupation.Active, (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) active).State) Assert.AreEqual( Occupation.Active, (Occupation.applySignal (Occupation.Traded(NpcId 5, Food, 1)) active).State) [] member _.HelpWorkAndTillSoilRespectOptionalTargets () = let wildcard = DailyTaskHarness.accepted Occupation.HelpWork Assert.AreEqual( Occupation.Done, (Occupation.applySignal (Occupation.Dialogued(NpcId 4)) wildcard).State) let targeted = { DailyTaskHarness.accepted Occupation.HelpWork with TargetNpc = Some(NpcId 2) } Assert.AreEqual( Occupation.Done, (Occupation.applySignal (Occupation.Dialogued(NpcId 2)) targeted).State) Assert.AreEqual( Occupation.Active, (Occupation.applySignal (Occupation.Dialogued(NpcId 4)) targeted).State) let untilled = DailyTaskHarness.accepted Occupation.TillSoil Assert.AreEqual( Occupation.Done, (Occupation.applySignal (Occupation.ArrivedAt(1, 1)) untilled).State) let plotTilling = { DailyTaskHarness.accepted Occupation.TillSoil with TargetTile = Some(52, 38) } Assert.AreEqual( Occupation.Done, (Occupation.applySignal (Occupation.ArrivedAt(52, 38)) plotTilling).State) Assert.AreEqual( Occupation.Active, (Occupation.applySignal (Occupation.ArrivedAt(10, 10)) plotTilling).State) [] member _.NightCatchAndMarketInquiryCompleteOnTheirSignals () = let active = DailyTaskHarness.accepted Occupation.NightCatch Assert.AreEqual( Occupation.Done, (Occupation.applySignal Occupation.NightAtWater active).State) Assert.AreEqual( Occupation.Active, (Occupation.applySignal (Occupation.Dialogued(NpcId 0)) active).State) let inquiring = DailyTaskHarness.accepted Occupation.MarketInquiry Assert.AreEqual( Occupation.Done, (Occupation.applySignal (Occupation.Dialogued(NpcId 3)) inquiring).State) Assert.AreEqual( Occupation.Active, (Occupation.applySignal Occupation.NightAtWater inquiring).State) [] member _.DoneAndFailedAreTerminalStates () = let finished = { DailyTaskHarness.accepted Occupation.SellFish with State = Occupation.Done } Assert.AreEqual( Occupation.Done, (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) finished).State) let failed = { DailyTaskHarness.accepted Occupation.SellFish with State = Occupation.Failed } Assert.AreEqual( Occupation.Failed, (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) failed).State) [] member _.OverdueTasksFailAtDayBoundaryButNotBefore () = let active = DailyTaskHarness.accepted Occupation.SellFish Assert.AreEqual(Occupation.Active, (Occupation.expireAt active.DueTick active).State) Assert.AreEqual(Occupation.Failed, (Occupation.expireAt (active.DueTick + 1L) active).State) let offered = DailyTaskHarness.taskOf Occupation.SellFish Occupation.Offered Assert.AreEqual(Occupation.Failed, (Occupation.expireAt (offered.DueTick + 1L) offered).State) let finished = { active with State = Occupation.Done } Assert.AreEqual(Occupation.Done, (Occupation.expireAt (finished.DueTick + 1L) finished).State) // ---- §2-A 存档 v3:annals 之后追加 today task 尾段 ---- [] member _.SaveV3AppendsTaskTailAfterAnnalsAndRoundTrips () = let world = DailyTaskHarness.worldWithAnnal () let state = { (Occupation.stateOf Occupation.Fisher) with TaskToken = 5 StoryStage = 2 Today = Some(DailyTaskHarness.taskForDay 2L) } DailyTaskHarness.withTempSave (fun path -> WorldSave.saveToFileWith (Some state) path world let text = File.ReadAllText path Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3"), "v3 header expected") let tokens = text.Split('|') Assert.IsTrue(tokens |> Array.exists (fun token -> token = "task-today"), "task tail marker expected") Assert.IsTrue(tokens |> Array.exists (fun token -> token = "backpack"), "backpack tail marker expected") let markerIndex = tokens |> Array.findIndex (fun token -> token = "task-today") let backpackIndex = tokens |> Array.findIndex (fun token -> token = "backpack") let lastAnnalKindIndex = tokens |> Array.findIndexBack (fun token -> token = "trade") Assert.IsTrue(markerIndex > lastAnnalKindIndex, "task tail must follow the annals segment") Assert.IsTrue(backpackIndex > markerIndex, "backpack tail must follow the task tail") Assert.AreEqual("3", Array.last tokens) match WorldSave.loadFromFileWith path with | Ok (restored, Some restoredState) -> Assert.AreEqual(state, restoredState) Assert.AreEqual(WorldSave.save world, WorldSave.save restored) Assert.AreEqual(text, WorldSave.saveWith (Some restoredState) restored) | Ok (_, None) -> Assert.Fail("v3 with task tail must restore the occupation") | Error message -> Assert.Fail($"v3 task round trip failed: {message}")) [] member _.SaveV3WithoutTaskCarriesOnlyBackpackTail () = let world = DailyTaskHarness.worldWithAnnal () let state = Occupation.stateOf Occupation.Peddler DailyTaskHarness.withTempSave (fun path -> WorldSave.saveToFileWith (Some state) path world let text = File.ReadAllText path Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3"), "v3 header expected") Assert.IsFalse(text.Contains("task-today"), "no-task v3 must not carry a task tail") Assert.IsTrue(text.Contains("backpack"), "v3 must carry the backpack tail") match WorldSave.loadFromFileWith path with | Ok (restored, Some restoredState) -> Assert.AreEqual(None, restoredState.Today) Assert.AreEqual(text, WorldSave.saveWith (Some restoredState) restored) | Ok (_, None) -> Assert.Fail("v3 with kind must restore the occupation") | Error message -> Assert.Fail($"v3 no-task read failed: {message}")) [] member _.V1AndV2SavesCarryNoTaskTail () = let world = Sim.initialWorld 42UL DailyTaskHarness.withTempSave (fun path -> WorldSave.saveToFile path world let v2Text = File.ReadAllText path Assert.IsTrue(v2Text.StartsWith("LV_WORLD_SAVE_V2"), "default save must stay v2") Assert.IsFalse(v2Text.Contains("task-today")) match WorldSave.loadFromFileWith path with | Ok (restored, occupation) -> Assert.AreEqual(None, occupation) Assert.AreEqual(v2Text, WorldSave.save restored) | Error message -> Assert.Fail($"v2 read failed: {message}") let firstPipe = v2Text.IndexOf('|') let secondPipe = v2Text.IndexOf('|', firstPipe + 1) let thirdPipe = v2Text.IndexOf('|', secondPipe + 1) let v1Text = "LV_WORLD_SAVE_V1" + v2Text.Substring(thirdPipe) File.WriteAllText(path, v1Text) match WorldSave.loadFromFileWith path with | Ok (_, occupation) -> Assert.AreEqual(None, occupation) | Error message -> Assert.Fail($"v1 read failed: {message}") File.WriteAllText(path, WorldSave.saveWith (Some (Occupation.stateOf Occupation.Fisher)) world + "|task-today|bogus|offered|0|0|npc-none|tile-none") match WorldSave.loadFromFile path with | Ok _ -> Assert.Fail("unknown task template must be rejected") | Error _ -> ()) [] member _.TrailingGarbageAfterTaskTailIsRejected () = let world = DailyTaskHarness.worldWithAnnal () let state = { (Occupation.stateOf Occupation.Farmer) with Today = Some(DailyTaskHarness.taskForDay 4L) } DailyTaskHarness.withTempSave (fun path -> let text = WorldSave.saveWith (Some state) world File.WriteAllText(path, text + "|junk") match WorldSave.loadFromFile path with | Ok _ -> Assert.Fail("trailing garbage after the task tail must be rejected") | Error _ -> ()) [] member _.TaskCarryingSavesAreByteIdenticalAcrossRuns () = let build () = let world = DailyTaskHarness.worldWithAnnal () let state = { (Occupation.stateOf Occupation.Farmer) with Today = Some(DailyTaskHarness.taskForDay 9L) } WorldSave.saveWith (Some state) world Assert.AreEqual(build (), build ()) // ---- P15:货郎/书生模板完成信号、事件通道桥、当日刷新 ---- [] member _.PeddlerAndScholarTemplatesCompleteOnTheirSignals () = let buy = DailyTaskHarness.accepted Occupation.BuyGoods Assert.AreEqual(Occupation.Done, (Occupation.applySignal (Occupation.Purchased(Food, 2)) buy).State) Assert.AreEqual(Occupation.Active, (Occupation.applySignal (Occupation.Purchased(Food, 0)) buy).State) Assert.AreEqual(Occupation.Active, (Occupation.applySignal (Occupation.Dialogued(NpcId 0)) buy).State) let resell = DailyTaskHarness.accepted Occupation.Resell Assert.AreEqual(Occupation.Done, (Occupation.applySignal (Occupation.Traded(playerId, Spice, 1)) resell).State) Assert.AreEqual(Occupation.Active, (Occupation.applySignal (Occupation.Traded(NpcId 2, Spice, 1)) resell).State) let observe = DailyTaskHarness.accepted Occupation.ObserveNotes Assert.AreEqual(Occupation.Done, (Occupation.applySignal Occupation.Observed observe).State) Assert.AreEqual(Occupation.Active, (Occupation.applySignal (Occupation.Dialogued(NpcId 0)) observe).State) let reason = DailyTaskHarness.accepted Occupation.ReasonDebate Assert.AreEqual(Occupation.Done, (Occupation.applySignal (Occupation.Dialogued(NpcId 3)) reason).State) Assert.AreEqual(Occupation.Active, (Occupation.applySignal Occupation.Observed reason).State) [] member _.SignalBridgeIgnoresNpcInitiatedEvents () = let npcDialogue = { Tick = 5L Kind = DialogueEvent { Tick = 5L; Actor = NpcId 1; Target = NpcId 2; Intent = SmallTalk; Response = Friendly; Valence = 0.3f } } Assert.AreEqual(None, Occupation.signalOfInteraction npcDialogue) let npcTrade = { Tick = 5L; Kind = TradeEvent(NpcId 1, NpcId 2, Food, 1, 1.0f) } Assert.AreEqual(None, Occupation.signalOfInteraction npcTrade) let playerBuy = { Tick = 6L; Kind = TradeEvent(playerId, NpcId 2, Spice, 3, 2.0f) } Assert.AreEqual(Some(Occupation.Purchased(Spice, 3)), Occupation.signalOfInteraction playerBuy) let playerSell = { Tick = 7L; Kind = TradeEvent(NpcId 2, playerId, Fish, 4, 2.0f) } Assert.AreEqual(Some(Occupation.Traded(playerId, Fish, 4)), Occupation.signalOfInteraction playerSell) [] member _.PlayerDialogueEventThroughExistingChannelCompletesTheDaysTask () = let world = Sim.initialWorldN 7UL 4 let nearFirst = { world with Avatar = { world.Avatar with Pos = world.Npcs.[0].Pos } } match Sim.chooseDialogue (NpcId 0) SmallTalk nearFirst with | DialogueRejected (failure, _) -> Assert.Fail($"dialogue rejected: {failure}") | DialogueSucceeded (outcome, afterDialogue) -> let event = afterDialogue.Events |> List.last Assert.AreEqual(playerId, outcome.Actor) Assert.AreEqual(Some(Occupation.Dialogued(NpcId 0)), Occupation.signalOfInteraction event) let day = [ 0L .. 60L ] |> List.find (fun day -> (Occupation.dailyTaskOf 42UL 7UL day Occupation.Farmer).Value.TemplateId = Occupation.HelpWork) let offered = (Occupation.dailyTaskOf 42UL 7UL day Occupation.Farmer).Value let notAccepted = Occupation.advanceTask event { (Occupation.stateOf Occupation.Farmer) with Today = Some offered } Assert.AreEqual(Occupation.Offered, notAccepted.Today.Value.State) let active = notAccepted.Today |> Option.map Occupation.acceptTask let completed = Occupation.advanceTask event { notAccepted with Today = active } Assert.AreEqual(Occupation.Done, completed.Today.Value.State) [] member _.RefreshTodayKeepsSameDayFinalsAndRegeneratesOnDayChange () = let day0 = (Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Farmer).Value Assert.AreEqual( Some day0, Occupation.refreshToday 42UL 7UL Occupation.Farmer (Sim.ticksPerDay - 1L) (Some day0)) let day1 = (Occupation.dailyTaskOf 42UL 7UL 1L Occupation.Farmer).Value Assert.AreEqual( Some day1, Occupation.refreshToday 42UL 7UL Occupation.Farmer Sim.ticksPerDay (Some day0)) let finished = { day0 with State = Occupation.Done } Assert.AreEqual( Some finished, Occupation.refreshToday 42UL 7UL Occupation.Farmer 10L (Some finished)) Assert.AreEqual( Some day1, Occupation.refreshToday 42UL 7UL Occupation.Farmer Sim.ticksPerDay None)