namespace LivingVillage.Desktop.Tests open System open System.IO open Microsoft.VisualStudio.TestTools.UnitTesting open LivingVillage.Kernel open LivingVillage.Kernel.Sim open LivingVillage.Desktop /// P55 职业切片四:当日任务经济闭环——设计口径报酬入账 + 连续完成天数。 /// /// 报酬只发设计文档已写明的**完成结果物品**(`docs/design/occupation-system-design.md` /// §3 任务表:夜捕 得 Fish×2;送粮 交给村民 Food×1),其余模板无写明物品 → 不发、只留 /// 既有年鉴/关系反馈;禁止发明金额或新物品。连续天数按日历日累计,v3 持久化。 [] type P55ProfessionRewardTests () = let taskOn day template taskState : Occupation.DailyTask = { TemplateId = template TargetNpc = None TargetTile = None OfferedTick = day * Sim.ticksPerDay DueTick = day * Sim.ticksPerDay + Sim.ticksPerDay - 1L State = taskState } let fisherNightCatch taskState : Occupation.State = { Occupation.stateOf Occupation.Fisher with Today = Some(taskOn 0L Occupation.NightCatch taskState) } let farmerDeliverGrain taskState : Occupation.State = { Occupation.stateOf Occupation.Farmer with Today = Some(taskOn 0L Occupation.DeliverGrain taskState) } let farmerHelpWork taskState : Occupation.State = { Occupation.stateOf Occupation.Farmer with Today = Some(taskOn 0L Occupation.HelpWork taskState) } let tradeEvent buyer seller item quantity : InteractionEvent = { Tick = 10L; Kind = TradeEvent(buyer, seller, item, quantity, 10.0f) } let dialogueEvent actor target : InteractionEvent = { Tick = 10L Kind = DialogueEvent { Tick = 10L Actor = actor Target = target Intent = SmallTalk Response = Friendly Valence = 0.1f } } let signalFor (template: Occupation.TaskTemplateId) : Occupation.TaskSignal = match template with | Occupation.NightCatch -> Occupation.NightAtWater | Occupation.ObserveNotes -> Occupation.Observed | Occupation.TillSoil -> Occupation.ArrivedAt(2, 2) | Occupation.DeliverGrain -> Occupation.Traded(playerId, Food, 1) | Occupation.SellFish -> Occupation.Traded(playerId, Fish, 1) | Occupation.Resell -> Occupation.Traded(playerId, Spice, 1) | Occupation.BuyGoods -> Occupation.Purchased(Food, 1) | Occupation.HelpWork | Occupation.MarketInquiry | Occupation.ReasonDebate -> Occupation.Dialogued playerId let quantity (item: ItemKind) (state: Occupation.State) = Occupation.backpackQuantity item state [] member _.RewardTableOnlyCarriesDesignWrittenItems () = Assert.AreEqual<(ItemKind * int) option>(Some(Fish, 2), Occupation.rewardOf Occupation.NightCatch) Assert.AreEqual<(ItemKind * int) option>(Some(Food, 1), Occupation.rewardOf Occupation.DeliverGrain) for template in [ Occupation.HelpWork; Occupation.TillSoil; Occupation.SellFish; Occupation.MarketInquiry Occupation.BuyGoods; Occupation.Resell; Occupation.ObserveNotes; Occupation.ReasonDebate ] do Assert.AreEqual<(ItemKind * int) option>(None, Occupation.rewardOf template, sprintf "%A 无写明物品" template) [] member _.NightCatchCompletionGrantsFishAndBumpsStreak () = let before = fisherNightCatch Occupation.Active let after, reward = Occupation.applySignalToState Occupation.NightAtWater before Assert.AreEqual<(ItemKind * int) option>(Some(Fish, 2), reward) Assert.AreEqual(quantity Fish before + 2, quantity Fish after) Assert.AreEqual(Some Occupation.Done, after.Today |> Option.map (fun t -> t.State)) Assert.AreEqual(1, after.Streak) [] member _.DeliverGrainCompletionGrantsFoodAndBumpsStreak () = let before = farmerDeliverGrain Occupation.Active let sold, reward = Occupation.advanceTaskWithReward (tradeEvent (NpcId 1) playerId Food 2) before Assert.AreEqual<(ItemKind * int) option>(Some(Food, 1), reward) Assert.AreEqual(quantity Food before + 1, quantity Food sold) Assert.AreEqual(1, sold.Streak) [] member _.UnwrittenRewardStillCountsStreakWithoutItems () = let before = farmerHelpWork Occupation.Active let after, reward = Occupation.advanceTaskWithReward (dialogueEvent playerId (NpcId 1)) before Assert.AreEqual<(ItemKind * int) option>(None, reward) Assert.AreEqual(before.Backpack |> List.sumBy (fun (_, n) -> n), after.Backpack |> List.sumBy (fun (_, n) -> n)) Assert.AreEqual(Some Occupation.Done, after.Today |> Option.map (fun t -> t.State)) Assert.AreEqual(1, after.Streak) [] member _.CompletionNeedsTheMatchingSignalAndOfferedDoesNotPay () = // 不相干信号:不动背包、不加 streak。 let active = fisherNightCatch Occupation.Active let stalled, noReward = Occupation.applySignalToState (Occupation.ArrivedAt(1, 1)) active Assert.AreEqual<(ItemKind * int) option>(None, noReward) Assert.AreEqual(0, stalled.Streak) Assert.AreEqual(quantity Fish active, quantity Fish stalled) // Offered 未接受:完成信号不结算。 let offered = fisherNightCatch Occupation.Offered let stillOffered, offeredReward = Occupation.applySignalToState Occupation.NightAtWater offered Assert.AreEqual<(ItemKind * int) option>(None, offeredReward) Assert.AreEqual(0, stillOffered.Streak) [] member _.TaskRuntimeSurfacesRewardInShortChineseFeedback () = let after, message = TaskRuntime.advanceWithSignal Occupation.NightAtWater (fisherNightCatch Occupation.Active) Assert.AreEqual(Some "任务完成:夜捕 +鱼×2", message) Assert.AreEqual(1, after.Streak) let _, deliverMessage = TaskRuntime.advanceWithEvent (tradeEvent (NpcId 1) playerId Food 1) (farmerDeliverGrain Occupation.Active) Assert.AreEqual(Some "任务完成:送粮 +食物×1", deliverMessage) let _, helpMessage = TaskRuntime.advanceWithEvent (dialogueEvent playerId (NpcId 1)) (farmerHelpWork Occupation.Active) Assert.AreEqual(Some "任务完成:帮工", helpMessage) [] member _.SameDayRefreshKeepsStreakAndCrossDayCarriesOnlyAfterCompletion () = // 同日刷新:不动 streak。 let completed = { fisherNightCatch Occupation.Done with Streak = 1 } let sameDay = Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher 10L completed Assert.AreEqual(1, sameDay.Streak) // 跨日且上一日已完成 → 保留 streak。 let nextDayTick = Sim.ticksPerDay + 10L let nextDay = Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher nextDayTick completed Assert.AreEqual(1, nextDay.Streak) Assert.AreEqual(Some Occupation.Offered, nextDay.Today |> Option.map (fun t -> t.State)) // 跨日且上一日未完成 → 清零。 let missed = { fisherNightCatch Occupation.Active with Streak = 3 } let afterMiss = Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher nextDayTick missed Assert.AreEqual(0, afterMiss.Streak) [] member _.StreakAccumulatesAcrossConsecutiveCompletedDays () = let mutable state = Occupation.stateOf Occupation.Fisher for day in 0L .. 2L do let tick = day * Sim.ticksPerDay + 10L state <- Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher tick state state <- { state with Today = state.Today |> Option.map Occupation.acceptTask } let signal = signalFor state.Today.Value.TemplateId let next, _ = Occupation.applySignalToState signal state state <- next Assert.AreEqual(Some Occupation.Done, state.Today |> Option.map (fun t -> t.State)) Assert.AreEqual(int day + 1, state.Streak) [] member _.SkippedDayResetsStreakBackToZero () = let mutable state = Occupation.stateOf Occupation.Fisher // 第 0、1 天连续完成。 for day in 0L .. 1L do let tick = day * Sim.ticksPerDay + 10L state <- Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher tick state state <- { state with Today = state.Today |> Option.map Occupation.acceptTask } state <- fst (Occupation.applySignalToState (signalFor state.Today.Value.TemplateId) state) Assert.AreEqual(2, state.Streak) // 第 2 天不做 → 进入第 3 天刷新时归零。 state <- Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher (3L * Sim.ticksPerDay + 10L) state Assert.AreEqual(0, state.Streak) [] member _.WorldBootstrapRefreshKeepsCompletedStreakOnNewDay () = let state = { Occupation.stateOf Occupation.Farmer with Today = Some(taskOn 0L Occupation.HelpWork Occupation.Done); Streak = 2 } let refreshed = WorldBootstrap.refreshOccupationToday 42UL state (Sim.ticksPerDay + 5L) Assert.AreEqual(2, refreshed.Streak) Assert.AreEqual(Some Occupation.Offered, refreshed.Today |> Option.map (fun t -> t.State)) [] member _.V3RoundTripPersistsStreakAndRewardedBackpack () = let world = Sim.initialWorldN 42UL 4 let state = { Occupation.stateOf Occupation.Fisher with Today = Some(taskOn 0L Occupation.NightCatch Occupation.Done) Streak = 4 Backpack = [ Food, 8; Fish, 5 ] } let path = Path.Combine(Path.GetTempPath(), "living-village-p55-" + Guid.NewGuid().ToString("N") + ".save") try WorldSave.saveToFileWith (Some state) path world match WorldSave.loadFromFileWith path with | Ok (_, Some restored) -> Assert.AreEqual(4, restored.Streak) Assert.AreEqual(Some Occupation.Done, restored.Today |> Option.map (fun t -> t.State)) Assert.AreEqual(5, quantity Fish restored) // 再次保存与首次逐字节一致(round-trip 稳定)。 let text = File.ReadAllText path Assert.AreEqual(text, WorldSave.saveWith (Some restored) world) | Ok (_, None) -> Assert.Fail("v3 必须读回职业侧车") | Error message -> Assert.Fail(message) finally if File.Exists path then File.Delete path [] member _.V3WithoutStreakLoadsAsZeroAndSaveNoneStaysV2 () = let world = Sim.initialWorldN 42UL 4 let state = { Occupation.stateOf Occupation.Farmer with Today = Some(taskOn 0L Occupation.HelpWork Occupation.Done) } let withFile = Path.Combine(Path.GetTempPath(), "living-village-p55-" + Guid.NewGuid().ToString("N") + ".save") try WorldSave.saveToFileWith (Some state) withFile world match WorldSave.loadFromFileWith withFile with | Ok (_, Some restored) -> Assert.AreEqual(0, restored.Streak) | Ok (_, None) -> Assert.Fail("v3 必须读回职业侧车") | Error message -> Assert.Fail(message) finally if File.Exists withFile then File.Delete withFile // 无职业存档仍是 v2 且与 saveWith None 逐字节一致。 let v2 = WorldSave.save world Assert.IsTrue(v2.StartsWith("LV_WORLD_SAVE_V2")) Assert.AreEqual(v2, WorldSave.saveWith None world) match WorldSave.load v2 with | Ok restored -> Assert.AreEqual(v2, WorldSave.save restored) | Error message -> Assert.Fail(message) [] member _.ObserveCompletionHasNoRewardSuffix () = let state = { Occupation.stateOf Occupation.Scholar with Today = Some(taskOn 0L Occupation.ObserveNotes Occupation.Active) } let after, message = TaskRuntime.advanceWithSignal Occupation.Observed state Assert.AreEqual(Some "任务完成:观察记事", message) Assert.AreEqual(1, after.Streak)