summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs
diff options
context:
space:
mode:
Diffstat (limited to 'src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs')
-rw-r--r--src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs223
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)