summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel.Tests/M6aTests.fs
blob: 94341a185e53cdf08dff427ddb63d35b731e2d54 (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
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
namespace LivingVillage.Kernel.Tests

open System
open System.IO
open Microsoft.VisualStudio.TestTools.UnitTesting
open LivingVillage.Kernel
open LivingVillage.Kernel.Sim
open LivingVillage.Desktop

module private M6aHarness =

    let zeroInput = { Input = { MoveX = 0.0f; MoveY = 0.0f } }

    let atNpc (id: int) (world: World) : World =
        let npc = world.Npcs.[id]
        { world with Avatar = { world.Avatar with Pos = npc.Pos } }

    let readyForChat (world: World) : World =
        { world with
            Npcs =
                world.Npcs
                |> Array.map (fun npc ->
                    { npc with
                        Mind =
                            { npc.Mind with
                                Action = Wander
                                Target = actionTarget Wander
                                ActionAge = minActionTicks
                                EffectDone = true } }) }

    let withDialogue (world: World) : World =
        match Sim.chooseDialogue (NpcId 0) SmallTalk (atNpc 0 world) with
        | DialogueSucceeded (_, next) -> next
        | DialogueRejected (failure, _) -> Assert.Fail($"dialogue rejected: {failure}"); Unchecked.defaultof<World>

    let withChat (world: World) : World =
        match Sim.chat { Narrator = NpcId 0; Receiver = NpcId 1 } (readyForChat world) with
        | ChatSucceeded (Some _, next) -> next
        | ChatSucceeded (None, _) -> Assert.Fail("chat should create a rumor"); Unchecked.defaultof<World>
        | ChatRejected (failure, _) -> Assert.Fail($"chat rejected: {failure}"); Unchecked.defaultof<World>

    let withTrade (world: World) : World =
        match Sim.trade { Buyer = NpcId 0; Seller = NpcId 1; Item = Food; Quantity = 1 } world with
        | TradeSucceeded next -> next
        | TradeRejected (failure, _) -> Assert.Fail($"trade rejected: {failure}"); Unchecked.defaultof<World>

    let advance (inputs: Input list) (world: World) : World =
        inputs |> List.fold (fun current input -> Sim.step { Input = input } current) world

    let advanceTrace (inputs: Input list) (world: World) : World list =
        inputs
        |> List.scan (fun current input -> Sim.step { Input = input } current) world
        |> List.tail

    let withTempSave (action: string -> unit) =
        let id = Guid.NewGuid().ToString("N")
        let path = Path.Combine(Path.GetTempPath(), $"living-village-m6a-{id}.save")
        try
            action path
        finally
            if File.Exists path then File.Delete path

[<TestClass>]
type M6aTests () =

    [<TestMethod>]
    member _.SaveLoadPreservesWorldAndDeterministicContinuation () =
        let world =
            Sim.initialWorldN 7UL 4
            |> M6aHarness.withDialogue
            |> M6aHarness.withChat
            |> M6aHarness.withTrade
            |> fun current ->
                let tick = 22L * (Sim.ticksPerDay / 24L)
                { current with Tick = tick; Time = float tick * Sim.dtSeconds }

        M6aHarness.withTempSave (fun path ->
            WorldSave.saveToFile path world

            match WorldSave.loadFromFile path with
            | Error failure -> Assert.Fail($"save should load: {failure}")
            | Ok restored ->
                Assert.AreEqual<string>(WorldSave.save world, WorldSave.save restored)
                Assert.AreEqual<float>(world.Time, restored.Time)
                Assert.IsTrue(Sim.isNightTick restored.Tick)
                Assert.AreEqual<Needs>(world.Avatar.Mind.Needs, restored.Avatar.Mind.Needs)
                Assert.AreEqual<float32>(world.Avatar.Mind.Needs.Money, restored.Avatar.Mind.Needs.Money)
                CollectionAssert.AreEqual(world.Npcs, restored.Npcs)
                Assert.AreEqual<Map<ItemKind, int>>(world.Npcs.[0].Inventory, restored.Npcs.[0].Inventory)
                Assert.AreEqual<InteractionEvent list>(world.Events, restored.Events)
                Assert.AreEqual<RumorEvent list>(world.Rumors, restored.Rumors)
                Assert.AreEqual<AnnalEntry list>(world.Annals, restored.Annals)
                Assert.AreEqual<uint64>(world.Rng.State, restored.Rng.State)

                let inputs =
                    [ { MoveX = 0.5f; MoveY = -0.25f }
                      { MoveX = 0.0f; MoveY = 0.0f }
                      { MoveX = -1.0f; MoveY = 0.75f }
                      { MoveX = 0.25f; MoveY = 0.0f } ]
                let continued = M6aHarness.advanceTrace inputs world
                let restoredContinued = M6aHarness.advanceTrace inputs restored
                Assert.AreEqual<string list>(continued |> List.map WorldSave.save, restoredContinued |> List.map WorldSave.save)
                Assert.AreEqual<uint64 list>(continued |> List.map (fun next -> next.Rng.State), restoredContinued |> List.map (fun next -> next.Rng.State))
                Assert.AreEqual<InteractionEvent list list>(continued |> List.map (fun next -> next.Events), restoredContinued |> List.map (fun next -> next.Events))
                Assert.AreEqual<string>(WorldSave.save (List.last continued), WorldSave.save (List.last restoredContinued)))

    [<TestMethod>]
    member _.RejectedFilesDoNotReplaceCurrentWorld () =
        let currentWorld = Sim.initialWorldN 99UL 2
        let before = WorldSave.save currentWorld
        let mutable current = currentWorld

        let reject path =
            match WorldSave.loadFromFile path with
            | Ok loaded ->
                current <- loaded
                Assert.Fail("invalid save must not load")
            | Error _ -> ()
            Assert.AreEqual<string>(before, WorldSave.save current)

        M6aHarness.withTempSave (fun path ->
            reject (path + ".missing")

            File.WriteAllText(path, "LV_WORLD_SAVE_V0")
            reject path

            File.WriteAllText(path, WorldSave.save currentWorld + "|trailing-corruption")
            reject path)

    [<TestMethod>]
    member _.CorruptNumericAndInventoryValuesAreRejected () =
        let world = Sim.initialWorldN 7UL 2

        M6aHarness.withTempSave (fun path ->
            let nonFinite = { world with Time = Double.NaN }
            WorldSave.saveToFile path nonFinite
            match WorldSave.loadFromFile path with
            | Ok _ -> Assert.Fail("non-finite time must be rejected")
            | Error _ -> ()

            let invalidInventory =
                { world with
                    Npcs =
                        [| { world.Npcs.[0] with Inventory = Map.ofList [ Food, -1 ] }
                           world.Npcs.[1] |] }
            WorldSave.saveToFile path invalidInventory
            match WorldSave.loadFromFile path with
            | Ok _ -> Assert.Fail("negative inventory must be rejected")
            | Error _ -> ())

    [<TestMethod>]
    member _.SimulationClockSupportsPauseAndRequestedSpeeds () =
        let initial = SimulationControl.initial
        Assert.AreEqual<int>(1, SimulationControl.stepsPerFrame initial)
        Assert.AreEqual<int>(2, SimulationControl.stepsPerFrame (SimulationControl.setSpeed TwoX initial))
        Assert.AreEqual<int>(5, SimulationControl.stepsPerFrame (SimulationControl.setSpeed FiveX initial))
        let paused = SimulationControl.togglePause initial
        Assert.IsTrue(paused.Paused)
        Assert.AreEqual<int>(0, SimulationControl.stepsPerFrame paused)
        let resumed = SimulationControl.togglePause paused
        Assert.IsFalse(resumed.Paused)
        Assert.AreEqual<SimulationSpeed>(OneX, resumed.Speed)

    [<TestMethod>]
    member _.BoundsAreParameterizableAndClampMovementAtLargeMapEdge () =
        Sim.configureBounds 512 384
        try
            let world = Sim.initialWorld (uint64 42)
            let stepped = Sim.step { Input = { MoveX = 1.0f; MoveY = 1.0f } } world
            // Legacy maps clamp within 64x48; at 512x384 the avatar is free to wander
            // well past the old tile limits.
            let maxX = float32 (Sim.mapWidthTiles * Sim.tilePixels - Sim.tilePixels)
            let maxY = float32 (Sim.mapHeightTiles * Sim.tilePixels - Sim.tilePixels)
            Assert.IsTrue(stepped.Avatar.Pos.X <= maxX)
            Assert.IsTrue(stepped.Avatar.Pos.Y <= maxY)
            Assert.AreEqual<int>(512, Sim.mapWidthTiles)
        finally
            Sim.configureBounds 64 48

    [<TestMethod>]
    member _.SaveV1LegacyStillReadable () =
        let world = Sim.initialWorld (uint64 42)
        WorldSave.saveToFile "p7-legacy-src.save" world
        let modern = System.IO.File.ReadAllText "p7-legacy-src.save"
        // v2 = version|w|h|body...; strip the two bounds integers and roll the header back to v1.
        let firstPipe = modern.IndexOf('|')
        let secondPipe = modern.IndexOf('|', firstPipe + 1)
        let thirdPipe = modern.IndexOf('|', secondPipe + 1)
        System.IO.File.WriteAllText("p7-legacy.save", ("LV_WORLD_SAVE_V1" + modern.Substring(thirdPipe)))
        let text = System.IO.File.ReadAllText "p7-legacy.save"
        Sim.configureBounds 512 384
        try
            Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V1"), "legacy header must be v1")
            match WorldSave.loadFromFile "p7-legacy.save" with
            | Ok loaded ->
                Assert.AreEqual<int>(64, Sim.mapWidthTiles)
                Assert.AreEqual<int>(48, Sim.mapHeightTiles)
            | Error message ->
                Assert.Fail(sprintf "legacy v1 load failed: %s" message)
        finally
            Sim.configureBounds 64 48

    [<TestMethod>]
    member _.SaveV2RecordsBoundsAndStaysDeterministic () =
        Sim.configureBounds 512 384
        try
            let world = Sim.initialWorld (uint64 7)
            WorldSave.saveToFile "p7-v2.save" world
            let text = System.IO.File.ReadAllText "p7-v2.save"
            Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V2"), "v2 save must carry bounds")
            Sim.configureBounds 64 48
            match WorldSave.loadFromFile "p7-v2.save" with
            | Ok loaded ->
                Assert.AreEqual<int>(512, Sim.mapWidthTiles)
                Assert.AreEqual<int>(384, Sim.mapHeightTiles)
                Assert.AreEqual<int64>(loaded.Tick, world.Tick)
            | Error message ->
                Assert.Fail(sprintf "v2 load failed: %s" message)
        finally
            Sim.configureBounds 64 48

    [<TestMethod>]
    member _.ConfiguredLegacyStepCountsRemainExactUntilManualSpeedSelection () =
        let control, legacySteps = SimulationControl.initialForConfiguredSteps 7
        Assert.AreEqual<int option>(Some 7, legacySteps)
        Assert.AreEqual<int>(7, SimulationControl.stepsPerFrameWithLegacy legacySteps control)
        Assert.AreEqual<int>(0, SimulationControl.stepsPerFrameWithLegacy legacySteps (SimulationControl.togglePause control))
        let manuallySelected = SimulationControl.setSpeed TwoX control
        Assert.AreEqual<int>(2, SimulationControl.stepsPerFrameWithLegacy None manuallySelected)

    [<TestMethod>]
    member _.OccupationProfilesMatchTheDesignTable () =
        let farmer = LivingVillage.Kernel.Occupation.profileOf Occupation.Farmer
        Assert.AreEqual<int>(60, farmer.InitialMoney)
        Assert.AreEqual<float32>(100.0f, farmer.InitialEnergy)
        Assert.AreEqual<int>(12, snd (List.head farmer.InitialInventory))
        let fisher = Occupation.profileOf Occupation.Fisher
        Assert.AreEqual<int>(45, fisher.InitialMoney)
        Assert.AreEqual<float32>(95.0f, fisher.InitialEnergy)
        Assert.AreEqual<int>(8, snd (List.head fisher.InitialInventory))
        Assert.AreEqual<int>(3, snd (List.item 1 fisher.InitialInventory))
        let peddler = Occupation.profileOf Occupation.Peddler
        Assert.AreEqual<int>(90, peddler.InitialMoney)
        Assert.AreEqual<int>(6, snd (List.head peddler.InitialInventory))
        Assert.AreEqual<int>(4, snd (List.item 1 peddler.InitialInventory))
        let scholar = Occupation.profileOf Occupation.Scholar
        Assert.AreEqual<int>(70, scholar.InitialMoney)
        Assert.AreEqual<int>(10, snd (List.head scholar.InitialInventory))
        Assert.AreEqual<int>(2, snd (List.item 1 scholar.InitialInventory))

    [<TestMethod>]
    member _.WorldSaveV3RoundTripsOccupation () =
        let world = Sim.initialWorld (uint64 11)
        let state = Occupation.stateOf Occupation.Fisher
        WorldSave.saveToFileWith (Some state) "p10-v3.save" world
        let text = System.IO.File.ReadAllText "p10-v3.save"
        Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3"), "v3 header expected")
        match WorldSave.loadFromFileWith "p10-v3.save" with
        | Ok (_loaded, restored) ->
            match restored with
            | Some st ->
                Assert.AreEqual<int>(45, st.Profile.InitialMoney)
                Assert.AreEqual<int>(0, st.TaskToken)
                Assert.AreEqual<int>(0, st.StoryStage)
            | None -> Assert.Fail("v3 round trip lost the occupation")
        | Error message -> Assert.Fail(sprintf "v3 round trip failed: %s" message)

    [<TestMethod>]
    member _.WorldSaveLegacySavesStillReadWithoutOccupation () =
        let world = Sim.initialWorld (uint64 42)
        WorldSave.saveToFile "p10-v2.save" world
        Assert.IsTrue((System.IO.File.ReadAllText "p10-v2.save").StartsWith("LV_WORLD_SAVE_V2"))
        match WorldSave.loadFromFileWith "p10-v2.save" with
        | Ok (_w, occ) -> Assert.AreEqual<obj>(null, occ |> Option.map (fun _ -> box 1) |> Option.defaultValue null)
        | Error message -> Assert.Fail(sprintf "v2 read failed: %s" message)
        Sim.configureBounds 64 48

    [<TestMethod>]
    member _.OccupationBootstrapSavesAreByteIdenticalForSameSeed () =
        let seed = uint64 4242
        let state1 = Some (Occupation.stateOf Occupation.Peddler)
        let state2 = Some (Occupation.stateOf Occupation.Peddler)
        let world1 = WorldBootstrap.initialWorldWithOccupation false seed 4 state1
        let world2 = WorldBootstrap.initialWorldWithOccupation false seed 4 state2
        let text1 = WorldSave.saveWith state1 world1
        let text2 = WorldSave.saveWith state2 world2
        Assert.AreEqual<string>(text1, text2)

    [<TestMethod>]
    member _.OccupationChineseNamesAreKernelSideConstants () =
        Assert.AreEqual<string>("农夫", Occupation.nameOf Occupation.Farmer)
        Assert.AreEqual<string>("渔夫", Occupation.nameOf Occupation.Fisher)
        Assert.AreEqual<string>("货郎", Occupation.nameOf Occupation.Peddler)
        Assert.AreEqual<string>("书生", Occupation.nameOf Occupation.Scholar)

    /// P20:存档 v2 的边界字段在非默认尺寸下必须 round-trip,读回后
    /// mapWidthTiles/mapHeightTiles 恢复且再次保存与原文逐字节一致。
    /// 旧 64x48 存档的 Round-trip 保持不变由既有 v1/v2 用例覆盖。
    [<TestMethod>]
    member _.SaveRoundTripsParameterizedMapBounds () =
        let path = "p20-bounds-v2.save"
        try
            Sim.configureBounds 256 192
            let world = Sim.initialWorld (uint64 4242)
            WorldSave.saveToFile path world
            let text = File.ReadAllText path
            Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V2"), "bounds save must stay v2")
            match WorldSave.loadFromFile path with
            | Error failure -> Assert.Fail(sprintf "bounds save should load: %s" failure)
            | Ok restored ->
                Assert.AreEqual<int>(256, Sim.mapWidthTiles)
                Assert.AreEqual<int>(192, Sim.mapHeightTiles)
                Assert.AreEqual<string>(text, WorldSave.save restored)
        finally
            Sim.configureBounds 64 48
            if File.Exists path then File.Delete path

    /// P23:512x384 参数化边界在存档 v2 中 round-trip;同 world 两次保存逐字节一致,
    /// 读回后再次保存仍与原文一致(v1 的 64x48 兼容由 SaveV1LegacyStillReadable 覆盖)。
    [<TestMethod>]
    member _.SaveRoundTrips512x384BoundsAndIsByteStable () =
        let path = "p23-bounds-512-v2.save"
        let path2 = path + ".2"
        try
            Sim.configureBounds 512 384
            let world = Sim.initialWorld (uint64 4242)
            WorldSave.saveToFile path world
            WorldSave.saveToFile path2 world
            let text = File.ReadAllText path
            Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V2"), "bounds save must stay v2")
            Assert.AreEqual<string>(text, File.ReadAllText path2)
            Sim.configureBounds 64 48
            match WorldSave.loadFromFile path with
            | Error failure -> Assert.Fail(sprintf "512x384 bounds save should load: %s" failure)
            | Ok restored ->
                Assert.AreEqual<int>(512, Sim.mapWidthTiles)
                Assert.AreEqual<int>(384, Sim.mapHeightTiles)
                Assert.AreEqual<string>(text, WorldSave.save restored)
        finally
            Sim.configureBounds 64 48
            if File.Exists path then File.Delete path
            if File.Exists path2 then File.Delete path2