summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs
blob: d204ca636f45a8ac81531917d81c0e8ddf773e0f (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
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|"))