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