summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs
blob: 353f728b092d919fc8c46f1ef3c624806a26167e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
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)