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
343
344
345
346
|
namespace LivingVillage.Desktop.Tests
open Microsoft.VisualStudio.TestTools.UnitTesting
open LivingVillage.Kernel
open LivingVillage.Kernel.Sim
open LivingVillage.Desktop
open LivingVillage.Desktop.VillagePresentation
module private Harness =
let atNpc (id: int) (world: World) : World =
let npc = world.Npcs.[id]
{ world with Avatar = { world.Avatar with Pos = npc.Pos } }
[<TestClass>]
type DesktopTests () =
[<TestMethod>]
member _.InteractOpensTheSixIntentMenu () =
let world = Sim.initialWorldN 42UL 4 |> Harness.atNpc 0
let next, view = M5Interaction.apply Interact world M5Interaction.initial
Assert.AreEqual<World>(world, next)
Assert.AreEqual<M5Panel>(DialoguePanel, view.Panel)
Assert.AreEqual<int>(6, view.Menu.Value.Options.Length)
Assert.IsTrue(M5Interaction.titleText view |> fun text -> text.Contains("闲聊"))
[<TestMethod>]
member _.SelectingAnIntentUpdatesWorldAndChronicle () =
let world = Sim.initialWorldN 42UL 4 |> Harness.atNpc 0
let openedWorld, opened = M5Interaction.apply Interact world M5Interaction.initial
let next, view = M5Interaction.apply Intent1 openedWorld opened
Assert.IsTrue(next.Events |> List.exists (fun event ->
match event.Kind with
| DialogueEvent _ -> true
| _ -> false))
Assert.IsTrue(next.Annals |> List.exists (fun entry ->
match entry.Kind with
| DialogueAnnal _ -> true
| _ -> false))
Assert.IsTrue(view.Chronicle.Contains("dialogue"))
Assert.IsTrue(view.Status.Contains("dialogue"))
[<TestMethod>]
member _.NeedsAndObservationPanelsUseKernelViews () =
let world = Sim.initialWorldN 42UL 4 |> Harness.atNpc 0
let afterStep = Sim.step { Input = { MoveX = 0.0f; MoveY = 0.0f } } world
let _, needsView = M5Interaction.apply ToggleNeeds afterStep M5Interaction.initial
let _, observationView = M5Interaction.apply Observe world M5Interaction.initial
Assert.AreEqual<Needs>(afterStep.Avatar.Mind.Needs, needsView.Needs.Value.Needs)
Assert.IsTrue(observationView.Observations |> List.exists (fun observation -> observation.Id = NpcId 0))
[<TestMethod>]
member _.DialogueAndOverlayViewsRejectWorldCommandsUntilClosed () =
let world = Sim.initialWorldN 42UL 4 |> Harness.atNpc 0
let openedWorld, dialogueView = M5Interaction.apply Interact world M5Interaction.initial
let _, afterObserve = M5Interaction.apply Observe openedWorld dialogueView
let _, afterNeeds = M5Interaction.apply ToggleNeeds openedWorld dialogueView
let _, afterChronicle = M5Interaction.apply ShowChronicle openedWorld dialogueView
let _, needsView = M5Interaction.apply ToggleNeeds world M5Interaction.initial
let _, interactionAttempt = M5Interaction.apply Interact world needsView
let _, closedView = M5Interaction.apply ClosePanel world dialogueView
Assert.AreEqual<M5View>(dialogueView, afterObserve)
Assert.AreEqual<M5View>(dialogueView, afterNeeds)
Assert.AreEqual<M5View>(dialogueView, afterChronicle)
Assert.AreEqual<M5Panel>(NeedsPanel, interactionAttempt.Panel)
Assert.AreEqual<M5Panel>(WorldPanel, closedView.Panel)
[<TestMethod>]
member _.HeadlessSmokeProvesTheThreeHopCausalChain () =
let result = Program.M5Smoke.run ()
Assert.IsTrue(result.Passed, result.Failure)
Assert.AreEqual<int>(6, result.MenuOptions)
Assert.AreEqual<int>(4, result.RumorPathLength)
Assert.IsTrue(result.Chronicle.Contains("dialogue"))
Assert.IsTrue(result.RumorTrace.Contains("receiver=-1"))
Assert.IsTrue(result.RumorTrace.Contains("depth=3"))
[<TestMethod>]
member _.DayNightStateChangesTheRenderProfile () =
let day = M6Presentation.profileAtTick (6L * Sim.ticksPerDay / 24L)
let night = M6Presentation.profileAtTick (22L * Sim.ticksPerDay / 24L)
Assert.AreEqual<M6DayNight>(Day, day.Mode)
Assert.AreEqual<M6DayNight>(Night, night.Mode)
Assert.AreNotEqual(day.Background, night.Background)
Assert.AreNotEqual(day.WorldTint, night.WorldTint)
[<TestMethod>]
member _.DesktopBootstrapMovesAvatarOffTheHomeDoorAndSeparatesVillagers () =
let world = WorldBootstrap.initialWorld 42UL Sim.npcCount
let avatarPixel : PixelPosition =
{ X = world.Avatar.Pos.X
Y = world.Avatar.Pos.Y }
let avatarTile =
sceneTileFromPixel avatarPixel
let home = scene.Homes |> List.head
let renderPlan = VillageArt.sampleRenderPlan ()
let blockedTiles =
(renderPlan.Grounds |> List.map (fun (tile, _) -> VillageArt.worldTile renderPlan tile))
@ VillageArt.planFootprint renderPlan
|> List.map (fun tile -> tile.X, tile.Y)
|> Set.ofList
let sceneTileOf (position: Vec2) : TilePosition =
sceneTileFromPixel
{ X = position.X
Y = position.Y }
let distanceSquared (first: Vec2) (second: Vec2) : float32 =
let dx = second.X - first.X
let dy = second.Y - first.Y
dx * dx + dy * dy
let npcTiles = world.Npcs |> Array.map (fun npc -> sceneTileOf npc.Pos)
Assert.AreEqual<int>(6, avatarTile.X)
Assert.AreEqual<int>(7, avatarTile.Y)
Assert.IsFalse(isNearHomeDoor avatarTile home, "The avatar must start beyond the doorway interaction radius.")
Assert.IsTrue(
npcTiles |> Array.forall (fun tile -> not (Set.contains (tile.X, tile.Y) blockedTiles)),
"A villager must not spawn on the house, water, bridge, or garden footprint.")
Assert.IsTrue(
world.Npcs
|> Array.forall (fun npc -> distanceSquared world.Avatar.Pos npc.Pos >= 96.0f * 96.0f),
"Villagers must not overlap the avatar spawn.")
for first in 0 .. world.Npcs.Length - 2 do
for second in first + 1 .. world.Npcs.Length - 1 do
Assert.IsTrue(
distanceSquared world.Npcs.[first].Pos world.Npcs.[second].Pos >= 96.0f * 96.0f,
sprintf "villagers %d and %d are too close" first second)
let repeated = WorldBootstrap.initialWorld 42UL Sim.npcCount
Assert.AreEqual<World>(world, repeated)
[<TestMethod>]
member _.DaylightUsesAWarmHighContrastWorldTint () =
let day = M6Presentation.profileAtTick (6L * Sim.ticksPerDay / 24L)
Assert.IsTrue(day.WorldTint.R > day.WorldTint.G)
Assert.IsTrue(day.WorldTint.G > day.WorldTint.B)
[<TestMethod>]
member _.NoonLightingIsWarmthFreeWithNeutralWhiteBalance () =
let noonTick = 12L * Sim.ticksPerDay / 24L
let noon = M6Presentation.profileAtTick noonTick
Assert.AreEqual<M6DayNight>(Day, noon.Mode)
Assert.AreEqual<float32>(0.0f, noon.SkyWarmth)
Assert.AreEqual<float32>(0.0f, noon.LanternGlow)
Assert.AreEqual<int>(0, M6Presentation.goldenVeilAlpha noonTick)
// P19 offender was the day world tint attenuating blue (255/248/224); it is neutral now.
Assert.AreEqual<int>(255, int noon.WorldTint.R)
Assert.AreEqual<int>(int noon.WorldTint.R, int noon.WorldTint.G)
Assert.AreEqual<int>(int noon.WorldTint.G, int noon.WorldTint.B)
// The sky stays a plain blue, never warm.
Assert.IsTrue(noon.Background.B > noon.Background.G)
Assert.IsTrue(noon.Background.B > noon.Background.R)
[<TestMethod>]
member _.DaylightAutoplayBootstrapStartsAtDawnAndPreservesDefaultStart () =
let dawnTick = 6L * Sim.ticksPerDay / 24L
let daylightWorld = WorldBootstrap.initialWorldForDaylight true 42UL Sim.npcCount
let defaultWorld = WorldBootstrap.initialWorldForDaylight false 42UL Sim.npcCount
let daylightProfile = M6Presentation.profileAtTick daylightWorld.Tick
let defaultProfile = M6Presentation.profileAtTick defaultWorld.Tick
Assert.AreEqual<int64>(dawnTick, daylightWorld.Tick)
Assert.IsTrue(abs (daylightWorld.Time - float dawnTick * Sim.dtSeconds) < 1e-9)
Assert.AreEqual<M6DayNight>(Day, daylightProfile.Mode)
Assert.AreEqual<int64>(0L, defaultWorld.Tick)
Assert.AreEqual<float>(0.0, defaultWorld.Time)
Assert.AreEqual<M6DayNight>(Night, defaultProfile.Mode)
// ---- P12 中文每日任务面板 ----
module private TaskPanelHarness =
let occupationWithTask (taskState: Occupation.TaskState) : Occupation.State =
let occupation = Occupation.stateOf Occupation.Farmer
let task =
(Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Farmer).Value
|> Occupation.acceptTask
|> fun active -> if taskState = Occupation.Done then { active with State = Occupation.Done } else { active with State = taskState }
{ occupation with Today = Some task }
[<TestClass>]
type TaskPanelTests () =
[<TestMethod>]
member _.TaskPanelWithoutOccupationShowsNoOccupationLine () =
let world = Sim.initialWorldN 42UL 4
let _, view = M5Interaction.apply ShowTaskPanel world M5Interaction.initial
Assert.AreEqual<M5Panel>(TaskPanel, view.Panel)
Assert.AreEqual<string>("task", M5Interaction.panelName view)
let lines = M5Interaction.panelLines world view
Assert.IsTrue(lines |> List.contains "任务", "panel heading expected")
Assert.IsTrue(lines |> List.contains "无职业")
[<TestMethod>]
member _.TaskPanelShowsChineseOccupationNameAndTheThreeStates () =
let world = Sim.initialWorldN 42UL 4
for taskState, expected in [ Occupation.Offered, "待接受"; Occupation.Active, "进行中"; Occupation.Done, "已完成" ] do
let withTask = { M5Interaction.initial with Task = Some (TaskPanelHarness.occupationWithTask taskState) }
let _, view = M5Interaction.apply ShowTaskPanel world withTask
let lines = M5Interaction.panelLines world view
Assert.IsTrue(lines |> List.contains "农夫", $"occupation name missing for {taskState}")
Assert.IsTrue(lines |> List.exists (fun line -> line.Contains(expected)), $"state {expected} missing in {lines}")
[<TestMethod>]
member _.TaskPanelTitleUsesChineseHeading () =
let world = Sim.initialWorldN 42UL 4
let _, view = M5Interaction.apply ShowTaskPanel world M5Interaction.initial
Assert.IsTrue(M5Interaction.titleText view |> fun text -> text.Contains("任务"))
[<TestMethod>]
member _.ShowTaskPanelIsRejectedFromDialoguePanelLikeOtherOverlays () =
let world = Sim.initialWorldN 42UL 4 |> Harness.atNpc 0
let openedWorld, dialogueView = M5Interaction.apply Interact world M5Interaction.initial
let _, afterTask = M5Interaction.apply ShowTaskPanel openedWorld dialogueView
Assert.AreEqual<M5View>(dialogueView, afterTask)
let _, closed = M5Interaction.apply ClosePanel world { dialogueView with Panel = TaskPanel }
Assert.AreEqual<M5Panel>(WorldPanel, closed.Panel)
// ---- P15 开局职业选择接入:bootstrap 职业状态 / 存档 round-trip / 面板联调 ----
module private OccupationFlowHarness =
let withTempSave (action: string -> unit) =
let id = System.Guid.NewGuid().ToString("N")
let path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"living-village-p15-{id}.save")
try
action path
finally
if System.IO.File.Exists path then System.IO.File.Delete path
[<TestClass>]
type OccupationFlowTests () =
[<TestMethod>]
member _.BootstrapStampsOccupationMoneyEnergyAndTodaysOfferedTask () =
for kind in Occupation.all do
let state = WorldBootstrap.occupationStateFor 42UL kind 0L
let task = state.Today.Value
Assert.AreEqual<Occupation.TaskState>(Occupation.Offered, task.State)
Assert.IsTrue(Occupation.taskPoolOf kind |> List.contains task.TemplateId)
Assert.AreEqual<Occupation.DailyTask>(
(Occupation.dailyTaskOf 42UL (Occupation.occupationSeedOf kind) 0L kind).Value,
task)
let world = WorldBootstrap.initialWorldWithOccupation true 42UL 4 (Some state)
Assert.AreEqual<float32>(float32 state.Profile.InitialMoney, world.Avatar.Mind.Needs.Money)
Assert.AreEqual<float32>(state.Profile.InitialEnergy, world.Avatar.Mind.Needs.Energy)
[<TestMethod>]
member _.ChosenOccupationAndTodaysTaskRoundTripThroughV3Save () =
for kind in Occupation.all do
let state = WorldBootstrap.occupationStateFor 42UL kind (3L * Sim.ticksPerDay + 120L)
let world = WorldBootstrap.initialWorldWithOccupation false 42UL 4 (Some state)
OccupationFlowHarness.withTempSave (fun path ->
WorldSave.saveToFileWith (Some state) path world
let text = System.IO.File.ReadAllText path
Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3"), "chosen occupation must save as v3")
match WorldSave.loadFromFileWith path with
| Ok (restoredWorld, Some restoredState) ->
Assert.AreEqual<Occupation.Kind>(kind, restoredState.Profile.Kind)
Assert.AreEqual<Occupation.State>(state, restoredState)
Assert.AreEqual<string>(text, WorldSave.saveWith (Some restoredState) restoredWorld)
| Ok (_, None) -> Assert.Fail($"v3 occupation lost for {kind}")
| Error message -> Assert.Fail($"v3 round trip failed for {kind}: {message}"))
[<TestMethod>]
member _.TaskPanelShowsTheChosenOccupationsOfferedTask () =
let world = Sim.initialWorldN 42UL 4
for kind in Occupation.all do
let state = WorldBootstrap.occupationStateFor 42UL kind 0L
let view = { M5Interaction.initial with Task = Some state }
let _, taskView = M5Interaction.apply ShowTaskPanel world view
let lines = M5Interaction.panelLines world taskView
Assert.IsTrue(lines |> List.contains (Occupation.nameOf kind), $"occupation name missing for {kind}")
Assert.IsTrue(
lines |> List.exists (fun line -> line.Contains(Occupation.taskNameOf state.Today.Value.TemplateId)),
$"task name missing for {kind}")
Assert.IsTrue(lines |> List.exists (fun line -> line.Contains("待接受")), $"offered state missing for {kind}")
[<TestMethod>]
member _.RefreshOccupationTodayKeepsSameDayAndRegeneratesForTheNewDay () =
let state = WorldBootstrap.occupationStateFor 42UL Occupation.Farmer 0L
Assert.AreEqual<Occupation.State>(state, WorldBootstrap.refreshOccupationToday 42UL state (Sim.ticksPerDay - 1L))
let day1 = WorldBootstrap.refreshOccupationToday 42UL state Sim.ticksPerDay
Assert.AreEqual<Occupation.DailyTask option>(
Some (Occupation.dailyTaskOf 42UL (Occupation.occupationSeedOf Occupation.Farmer) 1L Occupation.Farmer).Value,
day1.Today)
[<TestMethod>]
member _.ChosenOccupationFirstDayTaskReachesDoneThroughItsSignal () =
// 既有事件通道:交易/对话模板由 InteractionEvent 经 signalOfInteraction 推进;
// 到达瓦片/夜水边/观察由调用方折叠为信号(Sim.step 不感知)。
let eventOf (template: Occupation.TaskTemplateId) : Sim.InteractionEvent option =
match template with
| Occupation.DeliverGrain ->
Some { Tick = 0L; Kind = Sim.TradeEvent(NpcId 1, Sim.playerId, Food, 1, 1.0f) }
| Occupation.SellFish ->
Some { Tick = 0L; Kind = Sim.TradeEvent(NpcId 1, Sim.playerId, Fish, 1, 1.0f) }
| Occupation.Resell ->
Some { Tick = 0L; Kind = Sim.TradeEvent(NpcId 1, Sim.playerId, Spice, 1, 1.0f) }
| Occupation.BuyGoods ->
Some { Tick = 0L; Kind = Sim.TradeEvent(Sim.playerId, NpcId 1, Spice, 1, 1.0f) }
| Occupation.HelpWork
| Occupation.MarketInquiry
| Occupation.ReasonDebate ->
Some
{ Tick = 0L
Kind =
Sim.DialogueEvent
{ Tick = 0L
Actor = Sim.playerId
Target = NpcId 0
Intent = SmallTalk
Response = Friendly
Valence = 0.3f } }
| Occupation.TillSoil
| Occupation.NightCatch
| Occupation.ObserveNotes -> None
let foldedSignal (template: Occupation.TaskTemplateId) : Occupation.TaskSignal =
match template with
| Occupation.TillSoil -> Occupation.ArrivedAt(52, 38)
| Occupation.NightCatch -> Occupation.NightAtWater
| Occupation.ObserveNotes -> Occupation.Observed
| _ -> failwith "event-backed template"
for kind in Occupation.all do
let state = WorldBootstrap.occupationStateFor 42UL kind 0L
let offered = state.Today.Value
Assert.AreEqual<Occupation.TaskState>(Occupation.Offered, offered.State)
Assert.IsTrue(Occupation.taskPoolOf kind |> List.contains offered.TemplateId)
let active = { state with Today = Some(Occupation.acceptTask offered) }
let completed =
match eventOf offered.TemplateId with
| Some event -> Occupation.advanceTask event active
| None ->
{ active with
Today = active.Today |> Option.map (Occupation.applySignal (foldedSignal offered.TemplateId)) }
Assert.AreEqual<Occupation.TaskState>(Occupation.Done, completed.Today.Value.State)
|