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)
|