diff options
Diffstat (limited to 'src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs | 223 |
1 files changed, 223 insertions, 0 deletions
diff --git a/src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs b/src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs new file mode 100644 index 0000000..353f728 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs @@ -0,0 +1,223 @@ +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 持久化。 +[<TestClass>] +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 + + [<TestMethod>] + 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) + + [<TestMethod>] + 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<int>(quantity Fish before + 2, quantity Fish after) + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, after.Today |> Option.map (fun t -> t.State)) + Assert.AreEqual<int>(1, after.Streak) + + [<TestMethod>] + 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<int>(quantity Food before + 1, quantity Food sold) + Assert.AreEqual<int>(1, sold.Streak) + + [<TestMethod>] + 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<int>(before.Backpack |> List.sumBy (fun (_, n) -> n), after.Backpack |> List.sumBy (fun (_, n) -> n)) + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, after.Today |> Option.map (fun t -> t.State)) + Assert.AreEqual<int>(1, after.Streak) + + [<TestMethod>] + 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<int>(0, stalled.Streak) + Assert.AreEqual<int>(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<int>(0, stillOffered.Streak) + + [<TestMethod>] + member _.TaskRuntimeSurfacesRewardInShortChineseFeedback () = + let after, message = TaskRuntime.advanceWithSignal Occupation.NightAtWater (fisherNightCatch Occupation.Active) + Assert.AreEqual<string option>(Some "任务完成:夜捕 +鱼×2", message) + Assert.AreEqual<int>(1, after.Streak) + let _, deliverMessage = TaskRuntime.advanceWithEvent (tradeEvent (NpcId 1) playerId Food 1) (farmerDeliverGrain Occupation.Active) + Assert.AreEqual<string option>(Some "任务完成:送粮 +食物×1", deliverMessage) + let _, helpMessage = TaskRuntime.advanceWithEvent (dialogueEvent playerId (NpcId 1)) (farmerHelpWork Occupation.Active) + Assert.AreEqual<string option>(Some "任务完成:帮工", helpMessage) + + [<TestMethod>] + 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<int>(1, sameDay.Streak) + // 跨日且上一日已完成 → 保留 streak。 + let nextDayTick = Sim.ticksPerDay + 10L + let nextDay = Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher nextDayTick completed + Assert.AreEqual<int>(1, nextDay.Streak) + Assert.AreEqual<Occupation.TaskState option>(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<int>(0, afterMiss.Streak) + + [<TestMethod>] + 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<Occupation.TaskState option>(Some Occupation.Done, state.Today |> Option.map (fun t -> t.State)) + Assert.AreEqual<int>(int day + 1, state.Streak) + + [<TestMethod>] + 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<int>(2, state.Streak) + // 第 2 天不做 → 进入第 3 天刷新时归零。 + state <- Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher (3L * Sim.ticksPerDay + 10L) state + Assert.AreEqual<int>(0, state.Streak) + + [<TestMethod>] + 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<int>(2, refreshed.Streak) + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Offered, refreshed.Today |> Option.map (fun t -> t.State)) + + [<TestMethod>] + 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<int>(4, restored.Streak) + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, restored.Today |> Option.map (fun t -> t.State)) + Assert.AreEqual<int>(5, quantity Fish restored) + // 再次保存与首次逐字节一致(round-trip 稳定)。 + let text = File.ReadAllText path + Assert.AreEqual<string>(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 + + [<TestMethod>] + 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<int>(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<string>(v2, WorldSave.saveWith None world) + match WorldSave.load v2 with + | Ok restored -> Assert.AreEqual<string>(v2, WorldSave.save restored) + | Error message -> Assert.Fail(message) + + [<TestMethod>] + 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<string option>(Some "任务完成:观察记事", message) + Assert.AreEqual<int>(1, after.Streak) |
