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
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
|
namespace LivingVillage.Kernel.Tests
open Microsoft.VisualStudio.TestTools.UnitTesting
open LivingVillage.Kernel
open LivingVillage.Kernel.Sim
module private StoryHarness =
let seed = 42UL
let withTempSave (action: string -> unit) =
let id = System.Guid.NewGuid().ToString("N")
let path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"living-village-p31-{id}.save")
try
action path
finally
if System.IO.File.Exists path then System.IO.File.Delete path
let worldAtTick (tick: int64) : World =
{ Sim.initialWorldN 4UL 4 with Tick = tick; Time = float tick * Sim.dtSeconds }
let stateOf (kind: Occupation.Kind) (stage: int) : Occupation.State =
{ Occupation.stateOf kind with StoryStage = stage }
let dayTick (day: int64) : int64 = day * Sim.ticksPerDay
let withMemory (valence: float32) (tick: int64) (world: World) : World =
{ world with
Npcs =
world.Npcs
|> Array.mapi (fun index npc ->
if index = 0 then
{ npc with
Mind =
{ npc.Mind with
Memory =
[ { Tick = tick; Kind = Chatted(Sim.playerId); Valence = valence } ] } }
else npc) }
[<TestClass>]
type OccupationStoryTests () =
// ---- 命名:四条轻剧情线 + 四段进度 ----
[<TestMethod>]
member _.EachOccupationHasItsOwnStoryLineName () =
Assert.AreEqual<string>("雨水与年成", Occupation.Story.lineNameOf Occupation.Farmer)
Assert.AreEqual<string>("水位与收成", Occupation.Story.lineNameOf Occupation.Fisher)
Assert.AreEqual<string>("新货的路子", Occupation.Story.lineNameOf Occupation.Peddler)
Assert.AreEqual<string>("庙前的争论", Occupation.Story.lineNameOf Occupation.Scholar)
[<TestMethod>]
member _.StageNamesCoverTheThreeSegmentArc () =
Assert.AreEqual<string>("未开始", Occupation.Story.stageNameOf 0)
Assert.AreEqual<string>("已接任务", Occupation.Story.stageNameOf 1)
Assert.AreEqual<string>("走访中", Occupation.Story.stageNameOf 2)
Assert.AreEqual<string>("已收束", Occupation.Story.stageNameOf 3)
// ---- 开线日:hash(seed ^ storySeed) mod 3 + 2,纯函数、确定性 ----
[<TestMethod>]
member _.StartDayIsDeterministicAndInsideTwoToFour () =
for kind in Occupation.all do
for seed in [ 42UL; 7UL; 4242UL; 999UL ] do
let first = Occupation.Story.startDayOf seed kind
let second = Occupation.Story.startDayOf seed kind
Assert.AreEqual<int64>(first, second)
Assert.IsTrue(2L <= first && first <= 4L, $"{kind} start day {first} out of range")
[<TestMethod>]
member _.StorySeedsDifferFromDailyOccupationSeeds () =
// 开线日与每日任务抽取互不串线(种子常量两两不同)
let storySeeds = Occupation.all |> List.map Occupation.Story.storySeedOf
let taskSeeds = Occupation.all |> List.map Occupation.occupationSeedOf
Assert.AreEqual<int>(storySeeds.Length, storySeeds |> Set.ofList |> Set.count)
for story in storySeeds do
Assert.IsFalse(taskSeeds |> List.contains story, "story seed must not collide with a task seed")
// ---- 推进门控:开线日前的对话不动剧情线 ----
[<TestMethod>]
member _.DialogueBeforeStartDayDoesNotAdvanceTheStory () =
for kind in Occupation.all do
let state = StoryHarness.stateOf kind 0
let world = StoryHarness.worldAtTick (StoryHarness.dayTick 1L)
let nextState, summary = Occupation.Story.onDialogue StoryHarness.seed state world
Assert.AreEqual<int>(0, nextState.StoryStage)
Assert.AreEqual<string option>(None, summary)
// ---- 三段式推进:接任务 → 走访 → 收束,逐段一对话 ----
[<TestMethod>]
member _.StoryAdvancesOneStagePerDialogueAcrossThreeDays () =
for kind in Occupation.all do
let startDay = Occupation.Story.startDayOf StoryHarness.seed kind
let mutable state = StoryHarness.stateOf kind 0
let mutable summaries = []
for offset in 0L .. 2L do
let world = StoryHarness.worldAtTick (StoryHarness.dayTick (startDay + offset))
let nextState, summary = Occupation.Story.onDialogue StoryHarness.seed state world
state <- nextState
summaries <- summary :: summaries
Assert.AreEqual<int>(3, state.StoryStage)
Assert.AreEqual<int>(3, summaries.Length)
Assert.AreEqual<string option>(Some "走访了几户人家,改日再问", summaries |> List.rev |> List.item 1)
Assert.IsTrue(
summaries
|> List.choose id
|> List.exists (fun text -> text.Contains("有了结果")),
"the third dialogue must conclude the line")
// 终态后不再推进
let world = StoryHarness.worldAtTick (StoryHarness.dayTick (startDay + 3L))
let nextState, summary = Occupation.Story.onDialogue StoryHarness.seed state world
Assert.AreEqual<int>(3, nextState.StoryStage)
Assert.AreEqual<string option>(None, summary)
[<TestMethod>]
member _.LateFirstDialogueStillAdvancesOneStageAtATime () =
let kind = Occupation.Fisher
let startDay = Occupation.Story.startDayOf StoryHarness.seed kind
let state = StoryHarness.stateOf kind 0
let world = StoryHarness.worldAtTick (StoryHarness.dayTick (startDay + 5L))
let stage1, summary1 = Occupation.Story.onDialogue StoryHarness.seed state world
Assert.AreEqual<int>(1, stage1.StoryStage)
Assert.IsTrue(summary1.IsSome)
let stage2, _ = Occupation.Story.onDialogue StoryHarness.seed stage1 world
Assert.AreEqual<int>(2, stage2.StoryStage)
let stage3, summary3 = Occupation.Story.onDialogue StoryHarness.seed stage2 world
Assert.AreEqual<int>(3, stage3.StoryStage)
Assert.IsTrue(summary3.Value.Contains("有了结果"))
// ---- 关系均值:与 relationMatrix 同口径的衰减 valence,仅计认识玩家的人 ----
[<TestMethod>]
member _.RelationMeanAveragesOnlyAcquaintedNpcs () =
let baseWorld = StoryHarness.worldAtTick 0L
// 单个熟识村民:Helpful 口径 0.5
let friendly = baseWorld |> StoryHarness.withMemory 0.5f 0L
Assert.AreEqual(0.5f, Occupation.Story.relationMeanToPlayer friendly, 1e-5f)
// 无记忆 = 0(中性,不取负)
Assert.AreEqual(0.0f, Occupation.Story.relationMeanToPlayer baseWorld, 1e-5f)
// 两名熟识村民(0.5 与 -0.4)均值 0.05
let mixed =
{ friendly with
Npcs =
friendly.Npcs
|> Array.mapi (fun index npc ->
if index = 1 then
{ npc with
Mind = { npc.Mind with Memory = [ { Tick = 0L; Kind = Chatted(Sim.playerId); Valence = -0.4f } ] } }
else npc) }
Assert.AreEqual(0.05f, Occupation.Story.relationMeanToPlayer mixed, 1e-5f)
[<TestMethod>]
member _.RelationMeanDecaysWithTheRelationHalfLife () =
let baseWorld = StoryHarness.worldAtTick Sim.ticksPerDay
let decayed = baseWorld |> StoryHarness.withMemory 0.5f 0L
// 半衰期 = 1 模拟日:一天后 0.5 → 0.25
Assert.AreEqual(0.25f, Occupation.Story.relationMeanToPlayer decayed, 1e-5f)
[<TestMethod>]
member _.DialogueMemoriesCountTowardTheRelationMean () =
let baseWorld = StoryHarness.worldAtTick 0L
let dialogued =
{ baseWorld with
Npcs =
baseWorld.Npcs
|> Array.mapi (fun index npc ->
if index = 0 then
{ npc with
Mind =
{ npc.Mind with
Memory =
[ { Tick = 0L
Kind = Dialogue(Sim.playerId, SmallTalk, Friendly)
Valence = 0.4f } ] } }
else npc) }
Assert.AreEqual(0.4f, Occupation.Story.relationMeanToPlayer dialogued, 1e-5f)
// ---- 三种结局:由当时关系均值确定性决定(三方对照)----
[<TestMethod>]
member _.EndingThresholdsSeparateThreeOutcomes () =
Assert.AreEqual<Occupation.Story.Ending>(Occupation.Story.Warm, Occupation.Story.endingOf 0.5f)
Assert.AreEqual<Occupation.Story.Ending>(Occupation.Story.Warm, Occupation.Story.endingOf 0.3f)
Assert.AreEqual<Occupation.Story.Ending>(Occupation.Story.Plain, Occupation.Story.endingOf 0.1f)
Assert.AreEqual<Occupation.Story.Ending>(Occupation.Story.Plain, Occupation.Story.endingOf 0.0f)
Assert.AreEqual<Occupation.Story.Ending>(Occupation.Story.Distant, Occupation.Story.endingOf -0.1f)
[<TestMethod>]
member _.EachOccupationHasThreeDistinctEndingTexts () =
for kind in Occupation.all do
let warm = Occupation.Story.endingTextOf kind Occupation.Story.Warm
let plain = Occupation.Story.endingTextOf kind Occupation.Story.Plain
let distant = Occupation.Story.endingTextOf kind Occupation.Story.Distant
Assert.AreNotEqual(warm, plain, $"{kind} warm/plain must differ")
Assert.AreNotEqual(plain, distant, $"{kind} plain/distant must differ")
Assert.AreNotEqual(warm, distant, $"{kind} warm/distant must differ")
Assert.IsTrue(warm.Contains(Occupation.Story.lineNameOf kind))
// 两次调用逐字节一致
Assert.AreEqual<string>(warm, Occupation.Story.endingTextOf kind Occupation.Story.Warm)
[<TestMethod>]
member _.ConcludingStageWritesTheEndingDeterminedByTheRelationMean () =
for kind in Occupation.all do
let startDay = Occupation.Story.startDayOf StoryHarness.seed kind
let conclusionTick = StoryHarness.dayTick (startDay + 2L)
// 热络结局:收束当天熟识村民 Helpful 口径 0.5
let warmWorld =
StoryHarness.worldAtTick conclusionTick
|> StoryHarness.withMemory 0.5f conclusionTick
let state2 = StoryHarness.stateOf kind 2
let warmState, warmSummary = Occupation.Story.onDialogue StoryHarness.seed state2 warmWorld
Assert.AreEqual<int>(3, warmState.StoryStage)
Assert.AreEqual<string>(Occupation.Story.endingTextOf kind Occupation.Story.Warm, warmSummary.Value)
// 淡漠结局:收束当天熟识村民 Offended 口径 -0.4
let distantWorld =
StoryHarness.worldAtTick conclusionTick
|> StoryHarness.withMemory -0.4f conclusionTick
let distantState, distantSummary = Occupation.Story.onDialogue StoryHarness.seed state2 distantWorld
Assert.AreEqual<int>(3, distantState.StoryStage)
Assert.AreEqual<string>(Occupation.Story.endingTextOf kind Occupation.Story.Distant, distantSummary.Value)
// 平常结局:无关系记忆 → 均值 0
let plainWorld = StoryHarness.worldAtTick conclusionTick
let plainState, plainSummary = Occupation.Story.onDialogue StoryHarness.seed state2 plainWorld
Assert.AreEqual<int>(3, plainState.StoryStage)
Assert.AreEqual<string>(Occupation.Story.endingTextOf kind Occupation.Story.Plain, plainSummary.Value)
[<TestMethod>]
member _.OnDialogueIsDeterministicForTheSameInputs () =
let kind = Occupation.Fisher
let startDay = Occupation.Story.startDayOf StoryHarness.seed kind
let world = StoryHarness.worldAtTick (StoryHarness.dayTick startDay) |> StoryHarness.withMemory 0.5f 0L
let state = StoryHarness.stateOf kind 0
let firstState, firstSummary = Occupation.Story.onDialogue StoryHarness.seed state world
let secondState, secondSummary = Occupation.Story.onDialogue StoryHarness.seed state world
Assert.AreEqual<Occupation.State>(firstState, secondState)
Assert.AreEqual<string option>(firstSummary, secondSummary)
// ---- §3 身份条目 + 存档持久化 ----
[<TestMethod>]
member _.IdentitySummariesUsePlainChronicleStyle () =
Assert.AreEqual<string>("以耕田为生", Occupation.Story.identitySummaryOf Occupation.Farmer)
Assert.AreEqual<string>("以打渔为生", Occupation.Story.identitySummaryOf Occupation.Fisher)
Assert.AreEqual<string>("以贩货为生", Occupation.Story.identitySummaryOf Occupation.Peddler)
Assert.AreEqual<string>("以读书为生", Occupation.Story.identitySummaryOf Occupation.Scholar)
[<TestMethod>]
member _.StoryAnnalRoundTripsThroughTheV3Save () =
let kind = Occupation.Fisher
let startDay = Occupation.Story.startDayOf StoryHarness.seed kind
let world = StoryHarness.worldAtTick (StoryHarness.dayTick (startDay + 2L))
let _, summary = Occupation.Story.onDialogue StoryHarness.seed (StoryHarness.stateOf kind 2) world
let entry = { Tick = world.Tick; Kind = StoryAnnal; Summary = summary.Value }
let worldWithAnnal = Sim.appendAnnal entry world
let text = WorldSave.saveWith (Some (StoryHarness.stateOf kind 3)) worldWithAnnal
match WorldSave.load text with
| Ok loaded ->
Assert.AreEqual(worldWithAnnal.Annals, loaded.Annals)
// 存 → 读 → 存 逐字节一致
Assert.AreEqual<string>(text, WorldSave.saveWith (Some (StoryHarness.stateOf kind 3)) loaded)
| Error message -> Assert.Fail(message)
[<TestMethod>]
member _.StoryStageRestoresFromTheV3OccupationSegment () =
StoryHarness.withTempSave (fun path ->
let state = StoryHarness.stateOf Occupation.Peddler 2
WorldSave.saveToFileWith (Some state) path (StoryHarness.worldAtTick 0L)
match WorldSave.loadFromFileWith path with
| Ok (_, Some loaded) -> Assert.AreEqual<int>(2, loaded.StoryStage)
| Ok (_, None) -> Assert.Fail("v3 occupation state expected")
| Error message -> Assert.Fail(message))
[<TestMethod>]
member _.NoOccupationSaveStaysFreeOfStoryTokens () =
// 无职业 v2 存档永不产生 story token,v1/v2 字节口径不变
let world = StoryHarness.worldAtTick 0L
let text = WorldSave.save world
Assert.IsFalse(text.Contains("story"))
Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V2|"))
|