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
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
|
namespace LivingVillage.Desktop.Tests
open System
open LivingVillage.Headless
open System.IO
open Microsoft.Xna.Framework
open Microsoft.VisualStudio.TestTools.UnitTesting
open LivingVillage.Kernel
open LivingVillage.Kernel.Sim
open LivingVillage.Desktop
open LivingVillage.Desktop.ChineseText
open LivingVillage.Desktop.CjkGlyphAtlas
open LivingVillage.Desktop.VillageArt
open LivingVillage.Desktop.VillagePresentation
[<TestClass>]
type PrototypeTests () =
[<TestMethod>]
member _.SceneContainsTheJiangnanPrototypeAndReachableHomeDoor () =
let first = createScene ()
let second = createScene ()
let requiredProps =
[ WhiteWallDarkTileHome
HomeDoor
RiverChannel
RiverBank
StoneBridge
StonePaving
Bamboo
VegetableGarden ]
Assert.AreEqual<PrototypeScene>(first, second)
Assert.AreEqual<PrototypeScene>(scene, first)
Assert.IsTrue(first.Homes.Length > 0, "The prototype needs at least one home.")
Assert.IsTrue(
requiredProps
|> List.forall (fun prop -> first.Elements |> List.exists (fun element -> element.Prop = prop)),
"The scene is missing one or more required Jiangnan elements.")
let home = first.Homes |> List.head
Assert.IsTrue(isNearHomeDoor first.InitialPlayerPosition home)
[<TestMethod>]
member _.HomeEntryAndExitExposeExplicitSuccessAndFailureBranches () =
let scene = createScene ()
let home = scene.Homes |> List.head
let entered = enterHome scene.InitialPlayerPosition Outside
match entered with
| Ok (Inside enteredHome) ->
Assert.AreEqual<HomeId>(home.Id, enteredHome)
| Ok Outside -> Assert.Fail("Entering a home must produce Inside state.")
| Error failure -> Assert.Fail(sprintf "Expected entry to succeed, got %A." failure)
match entered with
| Ok inside ->
match enterHome scene.InitialPlayerPosition inside with
| Error (AlreadyInsideHome homeId) -> Assert.AreEqual<HomeId>(home.Id, homeId)
| other -> Assert.Fail(sprintf "Expected AlreadyInsideHome, got %A." other)
| Error _ -> Assert.Fail("The first entry must succeed before checking the duplicate entry.")
match exitHome (Inside home.Id) with
| Ok Outside -> ()
| other -> Assert.Fail(sprintf "Expected exit to succeed, got %A." other)
match exitHome Outside with
| Error NotInsideHome -> ()
| other -> Assert.Fail(sprintf "Expected NotInsideHome, got %A." other)
match exitHome (Inside(HomeId 999)) with
| Error(HomeNotFound(HomeId 999)) -> ()
| other -> Assert.Fail(sprintf "Expected HomeNotFound, got %A." other)
let farAway : TilePosition =
{ X = scene.InitialPlayerPosition.X + 20
Y = scene.InitialPlayerPosition.Y + 20 }
match enterHome farAway Outside with
| Error NotNearHomeDoor -> ()
| other -> Assert.Fail(sprintf "Expected NotNearHomeDoor, got %A." other)
[<TestMethod>]
member _.FourWayInputMapsToFourDirections () =
Assert.AreEqual<Direction option>(Some North, directionForInput UpInput)
Assert.AreEqual<Direction option>(Some South, directionForInput DownInput)
Assert.AreEqual<Direction option>(Some West, directionForInput LeftInput)
Assert.AreEqual<Direction option>(Some East, directionForInput RightInput)
Assert.AreEqual<Direction option>(None, directionForInput NoInput)
[<TestMethod>]
member _.AnimationFrameChangesWithSimulationTick () =
let first = animationFrame (SimulationTick 0L)
let second = animationFrame (SimulationTick 4L)
let nextCycle = animationFrame (SimulationTick 8L)
Assert.AreNotEqual<CharacterAnimationFrame>(first, second)
Assert.AreEqual<CharacterAnimationFrame>(first, nextCycle)
[<TestMethod>]
member _.NpcIdProducesStableAndDistinctCharacterStyles () =
let first = characterStyle (NpcId 0)
let sameNpc = characterStyle (NpcId 0)
let second = characterStyle (NpcId 1)
Assert.AreEqual<CharacterStyle>(first, sameNpc)
Assert.AreNotEqual<CharacterStyle>(first, second)
[<TestMethod>]
member _.StageOneCopyProvidesChineseInteractionLabels () =
Assert.AreEqual<string>("互动", stageOneCopy.Interact)
Assert.AreEqual<string>("需求", stageOneCopy.Needs)
Assert.AreEqual<string>("交易", stageOneCopy.Trade)
Assert.AreEqual<string>("进入民居", stageOneCopy.EnterHome)
[<TestMethod>]
member _.MenuControlsAndSettingsUseChineseDisplayCopy () =
let state = MenuState.create SimulationControl.initial false
let itemLabels = MenuState.items state |> List.map MenuState.itemLabel
let controlLabels = MenuState.controlRows |> List.map (fun row -> row.Action)
let settingsLabels = MenuState.settings |> List.map MenuState.settingsLabel
Assert.IsTrue(itemLabels |> List.contains "开始")
Assert.IsTrue(itemLabels |> List.contains "读档")
Assert.IsTrue(itemLabels |> List.contains "退出")
Assert.AreEqual<string>("设置", MenuState.itemLabel SettingsItem)
Assert.AreEqual<string>("操作说明", MenuState.itemLabel ControlsItem)
Assert.AreEqual<string list>(
[ "移动"
"互动"
"对话"
"观察"
"需求"
"年鉴"
"关系"
"暂停"
"速度"
"保存"
"读取"
"菜单或关闭"
"选择"
"确认" ],
controlLabels)
Assert.AreEqual<string list>([ "1倍速"; "2倍速"; "5倍速"; "暂停" ], settingsLabels)
[<TestMethod>]
member _.InteractionPanelsUseChineseLabelsAndExplicitTradeResults () =
let world = Sim.initialWorldN 42UL 4
let nearby =
{ world with
Avatar = { world.Avatar with Pos = world.Npcs.[0].Pos } }
let openedWorld, opened = M5Interaction.apply Interact nearby M5Interaction.initial
let title = M5Interaction.titleText opened
let needsWorld, needsView = M5Interaction.apply ToggleNeeds openedWorld M5Interaction.initial
let needsText = String.concat "|" (M5Interaction.panelLines needsWorld needsView)
let _, chronicleView = M5Interaction.apply ShowChronicle nearby M5Interaction.initial
let chronicleText = String.concat "|" (M5Interaction.panelLines nearby chronicleView)
let request =
{ Buyer = NpcId 0
Seller = NpcId 1
Item = Food
Quantity = 1 }
let quote = Sim.quotePrice request nearby
let successText = M5Interaction.tradeResultText request quote (Sim.trade request nearby)
let failureRequest = { request with Quantity = 0 }
let failureText =
M5Interaction.tradeResultText failureRequest (Sim.quotePrice failureRequest nearby) (Sim.trade failureRequest nearby)
Assert.IsTrue(title.Contains("互动"))
Assert.IsTrue(title.Contains("闲聊"))
Assert.IsFalse(title.Contains("SMALLTALK"))
Assert.IsTrue(needsText.Contains("需求"))
Assert.IsTrue(needsText.Contains("饥饿"))
Assert.IsTrue(chronicleText.Contains("年鉴"))
Assert.IsTrue(chronicleText.Contains("暂无记录"))
Assert.IsTrue(successText.Contains("交易成功"))
Assert.IsTrue(successText.Contains("报价"))
Assert.IsTrue(failureText.Contains("交易失败"))
Assert.IsTrue(failureText.Contains("数量"))
[<TestMethod>]
member _.DayNightAndSpeedUseChineseDisplayCopy () =
Assert.AreEqual<string>("白天", M6Presentation.dayNightLabel Day)
Assert.AreEqual<string>("夜晚", M6Presentation.dayNightLabel Night)
Assert.AreEqual<string>("暂停", M6Presentation.clockLabel { Paused = true; Speed = OneX })
Assert.AreEqual<string>("1倍速", M6Presentation.clockLabel { Paused = false; Speed = OneX })
Assert.AreEqual<string>("2倍速", M6Presentation.clockLabel { Paused = false; Speed = TwoX })
Assert.AreEqual<string>("5倍速", M6Presentation.clockLabel { Paused = false; Speed = FiveX })
[<TestMethod>]
member _.JiangnanArtPlanIsDeterministicAndContainsRequiredElements () =
let first = samplePlan ()
let second = samplePlan ()
let requiredProps =
[ VillageArt.WhiteWallDarkTileHouse
VillageArt.StoneBridge
VillageArt.Bamboo
VillageArt.VegetableGarden ]
Assert.AreEqual<JiangnanArtPlan>(first, second)
Assert.IsTrue(
requiredProps
|> List.forall (fun prop -> first.Props |> List.exists (fun sprite -> sprite.Kind = prop)),
"The Jiangnan plan is missing one or more required props.")
Assert.IsTrue(first.Tiles |> List.exists (fun tile -> tile.Kind = (VillageArt.RiverWater)))
Assert.IsTrue(first.Tiles |> List.exists (fun tile -> tile.Kind = (VillageArt.StonePaving)))
[<TestMethod>]
member _.PauseHelpOpensFromPlayingAndRestoresSimulationControl () =
let original = { Paused = false; Speed = TwoX }
let state = { MenuState.create original true with Page = Playing }
let helpState, pausedControl = MenuState.enterPauseHelp original state
Assert.AreEqual<MenuPage>(Controls, helpState.Page)
Assert.AreEqual<MenuPage>(Playing, helpState.ReturnPage)
Assert.IsTrue(pausedControl.Paused)
Assert.AreEqual<SimulationSpeed>(TwoX, pausedControl.Speed)
let backState, restoredControl = MenuState.exitPauseHelp original helpState
Assert.AreEqual<MenuPage>(Playing, backState.Page)
Assert.IsFalse(restoredControl.Paused)
Assert.AreEqual<SimulationControl>(original, restoredControl)
[<TestMethod>]
member _.ControlsPageKeyColumnIsUniformAndNeverOverprintsActions () =
let layout = TitleScreen.layout 1280 720
let rows = MenuState.controlRows
// Every row: key-name rectangle and action rectangle must be disjoint.
for rowIndex in 0 .. rows.Length - 1 do
let keyRect, actionRect = TitleScreen.controlRowRects layout rowIndex
Assert.IsTrue(TitleScreen.rectsDisjoint keyRect actionRect, sprintf "row %d key overlaps action" rowIndex)
// The key column is uniform: every row shares the same x and width.
let keyRects = [ 0 .. rows.Length - 1 ] |> List.map (fun index -> TitleScreen.controlRowRects layout index |> fst)
Assert.AreEqual<int>(1, keyRects |> List.map (fun rect -> rect.X) |> List.distinct |> List.length)
Assert.AreEqual<int>(1, keyRects |> List.map (fun rect -> rect.Width) |> List.distinct |> List.length)
// The key column fits the longest key name (latin glyph: 6px * scale 2 per char).
let longestKey = rows |> List.maxBy (fun row -> row.Keys.Length)
Assert.IsTrue(layout.ControlsKeyWidth >= longestKey.Keys.Length * 12, sprintf "key width %d too small for %s" layout.ControlsKeyWidth longestKey.Keys)
// The controls panel holds every row plus the footer inside the viewport.
let panelBottom = layout.ControlsPanelY + 60 + rows.Length * layout.ControlsRowHeight + 48
Assert.IsTrue(panelBottom <= 720, sprintf "controls panel bottom %d overflows" panelBottom)
[<TestMethod>]
member _.CornerStatusShowsDayMoneyAndEnergyFromRealData () =
let world = Sim.initialWorldN 42UL 0
let lines = M5Interaction.cornerStatusLines world
Assert.AreEqual<int>(2, lines.Length)
Assert.IsTrue(lines.[0].StartsWith("天数 "), sprintf "day line: %s" lines.[0])
Assert.IsTrue(lines.[1].StartsWith("金钱 "), sprintf "money line: %s" lines.[1])
Assert.IsTrue(lines.[1].Contains("精力"))
let nextDay = { world with Tick = world.Tick + Sim.ticksPerDay }
Assert.AreNotEqual<string>(lines.[0], M5Interaction.cornerStatusLines nextDay |> List.item 0)
let richWorld =
{ world with
Avatar =
{ world.Avatar with
Mind =
{ world.Avatar.Mind with
Needs = { world.Avatar.Mind.Needs with Money = 88.0f; Energy = 76.0f } } } }
let richLines = M5Interaction.cornerStatusLines richWorld
Assert.IsTrue(richLines.[1].Contains("88"), sprintf "money value missing: %s" richLines.[1])
Assert.IsTrue(richLines.[1].Contains("76"), sprintf "energy value missing: %s" richLines.[1])
Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "天数")
Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "金钱")
Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "精力")
[<TestMethod>]
member _.TitleScreenSelectionCursorAndLabelNeverOverprint () =
let layout = TitleScreen.layout 1280 720
// Heading must sit fully above the first menu row (no same-line overprint).
Assert.IsTrue(layout.FirstItemY - layout.HeadingY >= 32, sprintf "heading clearance %d" (layout.FirstItemY - layout.HeadingY))
// Every menu row: cursor rectangle and label rectangle must be disjoint.
for rowIndex in 0 .. 5 do
let cursor, label = TitleScreen.selectionRects layout rowIndex
Assert.IsTrue(TitleScreen.rectsDisjoint cursor label, sprintf "row %d cursor overlaps label" rowIndex)
Assert.AreEqual<int>(cursor.Y, label.Y)
// Cursor column leaves real clearance: label starts beyond cursor + margin.
Assert.IsTrue(layout.TextX >= layout.CursorX + layout.CursorWidth)
[<TestMethod>]
member _.TitleScreenLayoutIsDeterministicAndLayered () =
let first = TitleScreen.layout 1280 720
let second = TitleScreen.layout 1280 720
Assert.AreEqual<TitleScreen.TitleLayout>(first, second)
// Title: existing CJK atlas text, large scale, centered headline.
Assert.AreEqual<string>("江南水乡", first.TitleText)
Assert.AreEqual<int>(4, first.TitleScale)
// Background must be a layered gradient, never a flat fullscreen block.
Assert.IsTrue(first.SkyBands.Length >= 4)
Assert.AreEqual<int>(first.SkyBands.Length, first.SkyBands |> List.distinct |> List.length)
// Skyline reuses the Jiangnan roof tiles (ridge=8, eave=9) across the full width.
let roofSlots = TitleScreen.roofSlots
Assert.AreEqual<int>(8, fst roofSlots)
Assert.AreEqual<int>(9, snd roofSlots)
Assert.AreEqual<int>(1280 / 32, first.RoofTileSlots.Length)
Assert.IsTrue(first.RoofTileSlots |> List.forall (fun slot -> slot = 8 || slot = 9))
Assert.IsTrue(first.RoofTileSlots |> List.contains 8)
Assert.IsTrue(first.RoofTileSlots |> List.contains 9)
// Water band reuses the water tile slot and anchors the lantern row.
Assert.AreEqual<int>(1, TitleScreen.waterSlot)
Assert.AreEqual<int>(2, first.WaterRows.Length)
Assert.IsTrue(first.Lanterns.Length >= 3)
let lanternColor, lanternHalo = TitleScreen.lanternColors
Assert.AreEqual<int>(212, lanternColor.R)
Assert.AreEqual<int>(92, lanternColor.G)
Assert.AreEqual<int>(74, lanternColor.B)
Assert.IsTrue(lanternHalo.R > lanternColor.R && lanternHalo.G > lanternColor.G)
// Menu items form one vertical column below the title.
Assert.IsTrue(first.MenuTopY > first.TitleY)
Assert.IsTrue(first.MenuLineHeight > 0)
Assert.IsTrue(first.PanelX + first.PanelWidth <= 1280)
Assert.IsTrue(first.PanelY + first.PanelHeight <= 720)
[<TestMethod>]
member _.NpcVariantsAreStableAndAnimationCoversFourDirectionsAndSixFrames () =
let sameNpc = npcVisualVariant (NpcId 7)
let repeatedNpc = npcVisualVariant (NpcId 7)
let otherNpc = npcVisualVariant (NpcId 8)
let directions =
[ NorthFacing
SouthFacing
WestFacing
EastFacing ]
let sprites = directions |> List.map (fun direction -> npcSpriteSpec (NpcId 0) direction true 4L)
Assert.AreEqual<NpcVisualVariant>(sameNpc, repeatedNpc)
Assert.AreNotEqual<NpcVisualVariant>(sameNpc, otherNpc)
Assert.AreEqual<int>(4, sprites |> List.map (fun sprite -> sprite.Direction) |> List.distinct |> List.length)
Assert.IsTrue(sprites |> List.forall (fun sprite -> sprite.Frame = WalkFrame FrameTwo))
Assert.AreEqual<AnimationFrame>(FrameOne, (VillageArt.animationFrame 0L))
Assert.AreEqual<AnimationFrame>(FrameTwo, (VillageArt.animationFrame 4L))
Assert.AreEqual<AnimationFrame>(FrameThree, (VillageArt.animationFrame 8L))
Assert.AreEqual<AnimationFrame>(FrameFour, (VillageArt.animationFrame 12L))
Assert.AreEqual<AnimationFrame>(FrameFive, (VillageArt.animationFrame 16L))
Assert.AreEqual<AnimationFrame>(FrameSix, (VillageArt.animationFrame 20L))
Assert.AreEqual<AnimationFrame>(FrameOne, (VillageArt.animationFrame 24L))
[<TestMethod>]
member _.VillagersUseFourDistinctVariantsDistinctFromTheAvatar () =
let npcVariants = [ 0 .. 63 ] |> List.map (fun id -> npcVisualVariant (NpcId id)) |> Set.ofList
Assert.AreEqual<int>(4, npcVariants.Count)
Assert.IsFalse(npcVariants.Contains StrawHat)
Assert.AreEqual<NpcVisualVariant>(StrawHat, (avatarSpriteSpec SouthFacing true 0L).Variant)
let variantXes =
npcVariants
|> Set.toList
|> List.map (fun variant ->
let rect = characterSourceRectangle { Variant = variant; Direction = SouthFacing; Frame = WalkFrame FrameOne }
rect.X)
|> Set.ofList
Assert.AreEqual<int>(4, variantXes.Count)
Assert.AreEqual<int>(5120, VillageArt.characterAtlasWidth)
[<TestMethod>]
member _.WalkAnimationCoversSixFramesDeterministically () =
// 24-tick cycle, new frame every 4 sim ticks, no wall-clock dependency.
let frames = [ 0L; 4L; 8L; 12L; 16L; 20L ] |> List.map VillageArt.animationFrame
Assert.AreEqual<AnimationFrame list>([ FrameOne; FrameTwo; FrameThree; FrameFour; FrameFive; FrameSix ], frames)
let cycleFrames =
[ 0L; 4L; 8L; 12L; 16L; 20L ]
|> List.map VillageArt.animationFrame
|> List.distinct
Assert.AreEqual<AnimationFrame list>([ FrameOne; FrameTwo; FrameThree; FrameFour; FrameFive; FrameSix ], cycleFrames)
// Every frame maps to a distinct atlas cell column across the five variants.
let perFrameX =
[ FrameOne; FrameTwo; FrameThree; FrameFour; FrameFive; FrameSix ]
|> List.map (fun frame ->
let rect = characterSourceRectangle { Variant = IndigoRobe; Direction = NorthFacing; Frame = WalkFrame frame }
rect.X / 32)
Assert.AreEqual<int list>([ 0; 1; 2; 3; 4; 5 ], perFrameX)
// Idle breath poses occupy the two cells after the walk cycle for each (variant, direction).
let idleXes =
[ IdleOne; IdleTwo ]
|> List.map (fun frame ->
let rect = characterSourceRectangle { Variant = IndigoRobe; Direction = NorthFacing; Frame = IdleFrame frame }
rect.X / 32)
Assert.AreEqual<int list>([ 6; 7 ], idleXes)
[<TestMethod>]
member _.ChineseCopyProvidesFormalSceneTimeMenuAndStatusLabels () =
let labels =
[ sceneLabel VillageScene
sceneLabel Waterway
sceneLabel WhiteWallHome
sceneLabel StoneBridgeScene
sceneLabel ChineseText.BambooGrove
sceneLabel VegetableGardenScene
dayNightLabel DayTime
dayNightLabel NightTime
menuHeading MainMenuHeading
menuHeading SettingsHeading
menuHeading ControlsHeading
statusHeading StatusHeading
statusHeading ReadyStatus
statusHeading PausedStatus ]
Assert.IsTrue(labels |> List.forall (fun label -> not (String.IsNullOrWhiteSpace label)))
Assert.IsTrue(labels |> List.forall (fun label -> not (label.Contains("?"))))
Assert.AreEqual<string>("江南水乡", copy.SceneTitle)
Assert.AreEqual<string>("准备就绪", copy.Ready)
[<TestMethod>]
member _.ChineseTextWrapsAndUsesAnExplicitMissingGlyphPolicy () =
let supported = Set.ofList [ '江'; '南'; '水'; '乡' ]
let layout = layoutText 4 supported "江南水乡村"
Assert.AreEqual<string list>([ "江南水乡"; "□" ], layout.Lines)
Assert.AreEqual<char list>([ '村' ], layout.MissingGlyphs)
Assert.IsFalse(String.concat "" layout.Lines |> fun text -> text.Contains("?"))
[<TestMethod>]
member _.DesktopPixelPositionUsesExplicitSceneTileMapping () =
let doorPixel : PixelPosition =
{ X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2)
Y = float32 (23 * Sim.tilePixels + Sim.tilePixels / 2) }
let expectedSceneTile : TilePosition = { X = 6; Y = 5 }
let expectedWorldTile : TilePosition = { X = 31; Y = 23 }
Assert.AreEqual<TilePosition>(expectedSceneTile, sceneTileFromPixel doorPixel)
Assert.AreEqual<TilePosition>(expectedWorldTile, worldTileForSceneTile expectedSceneTile)
[<TestMethod>]
member _.HomeInteractionPrioritizesEntryExitBeforeNpcDialogue () =
let doorPixel : PixelPosition =
{ X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2)
Y = float32 (23 * Sim.tilePixels + Sim.tilePixels / 2) }
let farPixel = { X = 0.0f; Y = 0.0f }
Assert.AreEqual<HomeInputAction>(EnterHomeAction(HomeId 1), homeInputAction doorPixel Outside)
Assert.AreEqual<HomeInputAction>(ExitHomeAction(HomeId 1), homeInputAction doorPixel (Inside(HomeId 1)))
Assert.AreEqual<HomeInputAction>(DialogueAction, homeInputAction farPixel Outside)
Assert.AreEqual<HomeMode>(Outside, resetHomeMode ())
[<TestMethod>]
member _.JiangnanDesktopRenderPlanIsDeterministicAndHasInteriorElements () =
let first = VillageArt.sampleRenderPlan ()
let second = VillageArt.sampleRenderPlan ()
Assert.AreEqual<VillageArt.JiangnanRenderPlan>(first, second)
let expectedOrigin : TilePosition = { X = 25; Y = 18 }
Assert.AreEqual<TilePosition>(expectedOrigin, first.Origin)
Assert.IsTrue(first.Grounds |> List.exists (fun (_, kind) -> kind = VillageArt.Water))
Assert.IsTrue(first.Grounds |> List.exists (fun (_, kind) -> kind = VillageArt.StonePath))
Assert.IsTrue(first.Props |> List.exists (fun prop -> prop.Kind = VillageArt.Bridge))
// P64 补入椅/凳/床尾凳/壶 4 件家具 → 内景元素 4 → 8。
Assert.AreEqual<int>(8, first.Interior.Elements.Length)
[<TestMethod>]
member _.HouseFacadeCoversRoofEaveAndWalledDoorRow () =
let plan = VillageArt.sampleRenderPlan ()
let footprint = VillageArt.planFootprint plan |> Set.ofList
let door = scene.Homes |> List.head |> fun home -> VillageArt.worldTile plan home.Door
// The enterable home must present a full facade: ridge, eave, and a walled row with the door.
for dx in -2 .. 1 do
Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y - 2 } : TilePosition), sprintf "missing ridge tile dx=%d" dx)
Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y - 1 } : TilePosition), sprintf "missing eave tile dx=%d" dx)
Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y } : TilePosition), sprintf "missing wall tile dx=%d" dx)
// 12 house + 3 bridge + 3 bamboo + 2 garden + 2x2 willow + 2 shrub + 2 flowers + 2 reeds
// + 1 bench + 2 lanterns + 2 stone lanterns + 1 water vat + 1 market stall (P17)
// + P64 上半部补种 4 菜畦(2格)+2 竹丛(3格)+2 灌木+2 花丛 = 37 + 18 = 55。
Assert.AreEqual<int>(55, footprint.Count)
[<TestMethod>]
member _.VegetationAndRiversidePropsDecorateTheScene () =
let plan = VillageArt.sampleRenderPlan ()
let kinds = plan.Props |> List.map (fun prop -> prop.Kind)
let count kind = kinds |> List.filter (fun candidate -> candidate = kind) |> List.length
let grounds = plan.Grounds |> List.map fst |> Set.ofList
Assert.AreEqual<int>(2, count VillageArt.WillowTree)
Assert.IsTrue(count VillageArt.ShrubPatch >= 2)
Assert.IsTrue(count VillageArt.FlowerBush >= 2)
Assert.AreEqual<int>(2, count VillageArt.ReedCluster)
Assert.AreEqual<int>(1, count VillageArt.StoneBench)
Assert.AreEqual<int>(2, count VillageArt.RiverLantern)
Assert.AreEqual<int>(2, count VillageArt.StoneLantern)
Assert.AreEqual<int>(1, count VillageArt.WaterVat)
Assert.AreEqual<int>(1, count VillageArt.MarketStall)
// Reeds must root in water; lanterns and P17 street details stand on the paved bank path.
for prop in plan.Props do
let tile = VillageArt.worldTile plan prop.Position
match prop.Kind with
| VillageArt.ReedCluster -> Assert.IsTrue(grounds.Contains(prop.Position), "reeds must sit on water tiles")
| VillageArt.RiverLantern
| VillageArt.StoneLantern
| VillageArt.WaterVat
| VillageArt.MarketStall ->
Assert.IsTrue(plan.Grounds |> List.contains (prop.Position, VillageArt.StonePath), "street details must stand on the bank path")
| _ -> ()
[<TestMethod>]
member _.WaterRippleFramesAreDeterministicAndThreePhase () =
Assert.AreEqual<int>(0, VillageArt.waterFrameTick 0L)
Assert.AreEqual<int>(0, VillageArt.waterFrameTick 31L)
Assert.AreEqual<int>(1, VillageArt.waterFrameTick 32L)
Assert.AreEqual<int>(1, VillageArt.waterFrameTick 63L)
Assert.AreEqual<int>(2, VillageArt.waterFrameTick 64L)
Assert.AreEqual<int>(2, VillageArt.waterFrameTick 95L)
Assert.AreEqual<int>(0, VillageArt.waterFrameTick 96L)
Assert.AreEqual<int>(VillageArt.waterFrameTick 100L, VillageArt.waterFrameTick 100L)
[<TestMethod>]
member _.IdleBreathCycleIsDeterministicAndDistinctFromWalking () =
// Two-pose breath cycle over a 48-tick window (one pose per 24 sim ticks).
Assert.AreEqual<IdleFrame>(IdleOne, VillageArt.idleFrame 0L)
Assert.AreEqual<IdleFrame>(IdleOne, VillageArt.idleFrame 23L)
Assert.AreEqual<IdleFrame>(IdleTwo, VillageArt.idleFrame 24L)
Assert.AreEqual<IdleFrame>(IdleOne, VillageArt.idleFrame 48L)
Assert.AreEqual<IdleFrame>(VillageArt.idleFrame 77L, VillageArt.idleFrame 77L)
// Standing still breathes; moving walks. Both stay pure functions of tick.
Assert.AreEqual<CharacterFrame>(IdleFrame IdleOne, VillageArt.characterFrame false 0L)
Assert.AreEqual<CharacterFrame>(IdleFrame IdleTwo, VillageArt.characterFrame false 24L)
Assert.AreEqual<CharacterFrame>(WalkFrame FrameOne, VillageArt.characterFrame true 0L)
Assert.AreEqual<CharacterFrame>(WalkFrame FrameTwo, VillageArt.characterFrame true 4L)
Assert.AreEqual<CharacterFrame>(WalkFrame FrameFive, VillageArt.characterFrame true 16L)
Assert.AreEqual<CharacterFrame>(WalkFrame FrameSix, VillageArt.characterFrame true 20L)
Assert.AreEqual<CharacterFrame>(WalkFrame FrameOne, VillageArt.characterFrame true 24L)
Assert.AreEqual<CharacterFrame>(VillageArt.characterFrame true 12L, VillageArt.characterFrame true 12L)
[<TestMethod>]
member _.NpcTradesAndWalkRhythmsAreDistinctAndDeterministic () =
// Three trades repeat by id and stay stable, with no randomness.
Assert.AreEqual<VillageArt.NpcTrade>(VillageArt.Farmer, VillageArt.npcTrade (NpcId 0))
Assert.AreEqual<VillageArt.NpcTrade>(VillageArt.Peddler, VillageArt.npcTrade (NpcId 1))
Assert.AreEqual<VillageArt.NpcTrade>(VillageArt.Scholar, VillageArt.npcTrade (NpcId 2))
Assert.AreEqual<VillageArt.NpcTrade>(VillageArt.npcTrade (NpcId 9), VillageArt.npcTrade (NpcId 0))
// Distinct cadences: peddler shuffles fastest, scholar strolls slowest.
let farmer = VillageArt.rhythmFor VillageArt.Farmer
let peddler = VillageArt.rhythmFor VillageArt.Peddler
let scholar = VillageArt.rhythmFor VillageArt.Scholar
Assert.IsTrue(peddler.WalkWindow < farmer.WalkWindow && farmer.WalkWindow < scholar.WalkWindow)
Assert.IsTrue(peddler.IdleWindow < farmer.IdleWindow && farmer.IdleWindow < scholar.IdleWindow)
// Same tick, three trade-specific frames (at 12 ticks: Farmer IV, Peddler VI, Scholar V).
let frameAt trade tick = VillageArt.animationFrameFor (VillageArt.rhythmFor trade) tick
let frames =
[ VillageArt.Farmer; VillageArt.Peddler; VillageArt.Scholar ]
|> List.map (fun trade -> frameAt trade 12L)
Assert.AreEqual<AnimationFrame list>([ FrameFour; FrameSix; FrameFive ], frames)
Assert.AreEqual<VillageArt.WalkRhythm>(VillageArt.rhythmFor VillageArt.Scholar, VillageArt.rhythmFor VillageArt.Scholar)
// npcSpriteSpec now follows the NPC's own trade cadence.
Assert.AreEqual<CharacterFrame>(WalkFrame FrameFour, (VillageArt.npcSpriteSpec (NpcId 0) VillageArt.SouthFacing true 12L).Frame)
Assert.AreEqual<CharacterFrame>(WalkFrame FrameSix, (VillageArt.npcSpriteSpec (NpcId 1) VillageArt.SouthFacing true 12L).Frame)
Assert.AreEqual<CharacterFrame>(WalkFrame FrameFive, (VillageArt.npcSpriteSpec (NpcId 2) VillageArt.SouthFacing true 12L).Frame)
[<TestMethod>]
member _.ChimneySmokeFramesAreDeterministicAndThreePhase () =
Assert.AreEqual<int>(0, VillageArt.smokeFrameTick 0L)
Assert.AreEqual<int>(0, VillageArt.smokeFrameTick 39L)
Assert.AreEqual<int>(1, VillageArt.smokeFrameTick 40L)
Assert.AreEqual<int>(2, VillageArt.smokeFrameTick 80L)
Assert.AreEqual<int>(0, VillageArt.smokeFrameTick 120L)
Assert.AreEqual<int>(VillageArt.smokeFrameTick 99L, VillageArt.smokeFrameTick 99L)
[<TestMethod>]
member _.PremultiplyScalesEachChannelByItsOwnAlpha () =
// The warm lantern core: rgb(255,226,168) at alpha 34 must become 34/30/22 so the
// premultiplied SpriteBatch blend adds a dim warm value instead of full rgb.
let core = VillageArt.premultiply 255 226 168 34
Assert.AreEqual<byte>(34uy, core.R)
Assert.AreEqual<byte>(30uy, core.G)
Assert.AreEqual<byte>(22uy, core.B)
Assert.AreEqual<byte>(34uy, core.A)
// General fixed-point behaviour (round half away from zero).
let warm = VillageArt.premultiply 252 196 128 34
Assert.AreEqual<byte>(34uy, warm.R)
Assert.AreEqual<byte>(26uy, warm.G)
Assert.AreEqual<byte>(17uy, warm.B)
Assert.AreEqual<byte>(34uy, warm.A)
// Alpha 255 is the identity; alpha 0 zeroes the colour.
Assert.AreEqual<Color>(Color(252, 196, 128, 255), VillageArt.premultiply 252 196 128 255)
Assert.AreEqual<Color>(Color(0, 0, 0, 0), VillageArt.premultiply 252 196 128 0)
// A premultiplied channel can never exceed its own alpha, which is what makes an
// opaque white core impossible for any alpha < 250.
for alpha in [ 0; 1; 34; 70; 128; 200; 249 ] do
let c = VillageArt.premultiply 255 255 255 alpha
Assert.IsTrue(int c.R <= int c.A && int c.G <= int c.A && int c.B <= int c.A)
Assert.IsTrue(int (max c.R (max c.G c.B)) < 250)
[<TestMethod>]
member _.WaterRipplePhaseIsDistributedPerTile () =
// Neighbouring tiles must not share a phase in the same frame.
let a = VillageArt.waterFrameTickAt 128L 10 20
let b = VillageArt.waterFrameTickAt 128L 11 20
let c = VillageArt.waterFrameTickAt 128L 10 21
Assert.AreNotEqual<int>(a, b)
Assert.AreNotEqual<int>(a, c)
// Pure and in range 0..2 across the map and across ticks.
for tick in [ 0L; 31L; 32L; 40L; 64L; 127L; 200L ] do
for x in 0 .. 7 do
for y in 0 .. 7 do
let phase = VillageArt.waterFrameTickAt tick x y
Assert.IsTrue(phase >= 0 && phase <= 2)
Assert.AreEqual<int>(phase, VillageArt.waterFrameTickAt tick x y)
// A horizontal strip of river tiles shows at least two distinct phases at once.
let strip = [ 0 .. 11 ] |> List.map (fun x -> VillageArt.waterFrameTickAt 128L x 12)
Assert.IsTrue(Set.ofList strip |> Set.count >= 2)
[<TestMethod>]
member _.LanternGlowLayersArePremultipliedAndRadial () =
let centerX, centerY, radius, rings, peakAlpha = 100, 100, 44, 6, 140
let layers = VillageArt.lanternGlowLayers centerX centerY radius rings 255 214 150 peakAlpha
Assert.IsTrue(layers.Length > 0, "the glow must emit at least one layer")
Assert.AreEqual<(Rectangle * Color) list>(layers, VillageArt.lanternGlowLayers centerX centerY radius rings 255 214 150 peakAlpha)
for (rect, color) in layers do
Assert.AreEqual<int>(1, rect.Height)
Assert.IsTrue(rect.Width >= 1)
Assert.IsTrue(int color.R <= int color.A && int color.G <= int color.A && int color.B <= int color.A)
Assert.IsTrue(int (max color.R (max color.G color.B)) < 250, "no ring may be near-white")
Assert.IsTrue(rect.X >= centerX - radius && rect.X + rect.Width <= centerX + radius + 1)
Assert.IsTrue(rect.Y >= centerY - radius && rect.Y <= centerY + radius)
let covers px py =
layers |> List.exists (fun (rect, _) -> rect.X <= px && px < rect.X + rect.Width && rect.Y <= py && py < rect.Y + rect.Height)
Assert.IsTrue(covers centerX centerY, "the core must be covered")
Assert.IsFalse(covers (centerX + radius) (centerY + radius), "a square corner must stay dark: the glow is circular")
Assert.IsFalse(covers (centerX - radius - 5) centerY)
[<TestMethod>]
member _.LanternGlowIsRadialWithoutSquareCorners () =
// P26 复核:光晕为同心圆叠加,逐像素累积 alpha;边缘/四角不得出现方斑。
let centerX, centerY, radius, rings, peakAlpha = 80, 80, 40, 6, 140
let layers = VillageArt.lanternGlowLayers centerX centerY radius rings 255 214 150 peakAlpha
let size = radius * 2 + 3
let origin = centerX - radius - 1
let alpha = Array2D.zeroCreate<int> size size
for (rect, color) in layers do
for y in rect.Y .. rect.Y + rect.Height - 1 do
for x in rect.X .. rect.X + rect.Width - 1 do
alpha.[y - origin, x - origin] <- alpha.[y - origin, x - origin] + int color.A
let center = alpha.[radius + 1, radius + 1]
Assert.IsTrue(center > 0, "the core must be lit")
let corner = [ (0, 0); (size - 1, 0); (0, size - 1); (size - 1, size - 1) ]
for (cx, cy) in corner do
Assert.IsTrue(alpha.[cy, cx] = 0, "glow corner must stay empty: no square patch")
// Radial falloff along the centre row: alpha never rises as distance from the core grows.
let row = [ for dx in 0 .. radius -> alpha.[radius + 1, radius + 1 + dx] ]
Assert.IsTrue(row |> List.pairwise |> List.forall (fun (a, b) -> a >= b), "glow must fade radially, not form a flat block")
Assert.IsTrue(row.[0] = center && row.[radius] < center, "core is brightest, rim is dimmer")
[<TestMethod>]
member _.LanternFalloffIsMaxAtCoreAndMonotonicWithoutPlateau () =
// P29:夜景灯光衰减函数——半径 0 处最大(=1),向半径边缘严格单调递减至 0,无平台。
let radius = 96.0f
Assert.AreEqual<float32>(1.0f, VillageArt.lanternFalloff 0.0f radius)
Assert.AreEqual<float32>(0.0f, VillageArt.lanternFalloff radius radius)
Assert.AreEqual<float32>(0.0f, VillageArt.lanternFalloff (radius + 7.0f) radius)
Assert.IsTrue(abs (VillageArt.lanternFalloff 0.0f 0.0f) < 1e-6f, "zero radius must contribute no light")
let samples = [ for i in 0 .. int radius -> VillageArt.lanternFalloff (float32 i) radius ]
for value in samples do
Assert.IsTrue(value >= 0.0f && value <= 1.0f, "falloff must stay within [0,1]")
samples
|> List.pairwise
|> List.iter (fun (a, b) -> Assert.IsTrue(b < a, sprintf "falloff must strictly decrease (no plateau): %f then %f" a b))
// 二次衰减:一半半径处约为 0.75,且严格高于线性 0.5。
Assert.IsTrue(abs (VillageArt.lanternFalloff (radius / 2.0f) radius - 0.75f) < 1e-4f)
[<TestMethod>]
member _.NightLightTilesCombineBridgeLanternsAndHouseWindows () =
// P29:夜景光源 = 桥头灯笼 + 民居门窗暖光,确定性纯函数。
let map = MapGen.generateWithSize 512 384 (uint64 4242)
let lights = VillageArt.nightLightTiles map
Assert.IsTrue(lights.Length > 0, "large map must emit night lights")
Assert.IsTrue(lights.Length > map.Buildings.Length, "windows add light sources beyond the houses themselves")
Assert.AreEqual<(int * int) list>(lights, VillageArt.nightLightTiles map)
for lantern in VillageArt.bridgeLanternTiles map do
Assert.IsTrue(lights |> List.contains lantern, sprintf "bridge lantern (%d,%d) must be a light source" (fst lantern) (snd lantern))
for building in map.Buildings do
Assert.IsTrue(lights |> List.contains (building.DoorX, building.DoorY), "every house door must carry warm light")
[<TestMethod>]
member _.CharacterFeetSitOnTheTileGroundLine () =
Assert.AreEqual<int>(16, VillageArt.characterFeetOffset)
let destination = VillageArt.characterDestination { X = 100.0f; Y = 200.0f } (Vector2(0.0f, 0.0f))
// Feet (sprite bottom) land half a tile below the tile centre; the sprite stays x-centred.
Assert.AreEqual<int>(216, destination.Y + destination.Height)
Assert.AreEqual<int>(100, destination.X + destination.Width / 2)
Assert.AreEqual<int>(32, destination.Width)
Assert.AreEqual<int>(48, destination.Height)
[<TestMethod>]
member _.RiverBandSpansTheSceneWithBankPathsAndCrossing () =
let plan = VillageArt.sampleRenderPlan ()
let footprintOf (value: VillageArt.JiangnanRenderPlan) = VillageArt.planFootprint value
let waters = plan.Grounds |> List.filter (fun (_, kind) -> kind = VillageArt.Water) |> List.map fst |> Set.ofList
let paths = plan.Grounds |> List.filter (fun (_, kind) -> kind = VillageArt.StonePath) |> List.map fst |> Set.ofList
let door = scene.Homes |> List.head |> fun home -> home.Door
// The river must run the full width of the decorated scene as a readable water band.
Assert.AreEqual<int>(20, [ 0 .. 19 ] |> List.filter (fun x -> waters.Contains({ X = x; Y = 12 } : TilePosition)) |> List.length)
Assert.IsTrue(waters.Contains({ X = 6; Y = 13 } : TilePosition))
// Banks stay walkable stone, and the door row connects to the north bank.
for x in 0 .. 19 do
if x <> 6 && x <> 7 && x <> 8 then
Assert.IsTrue(paths.Contains({ X = x; Y = 11 } : TilePosition), sprintf "missing north bank x=%d" x)
for y in 6 .. 10 do
Assert.IsTrue(paths.Contains({ X = 6; Y = y } : TilePosition), sprintf "missing door connector y=%d" y)
// Bridge footprint crosses the full river between the banks.
let bridge = plan.Props |> List.find (fun prop -> prop.Kind = VillageArt.Bridge)
let expectedBridge : TilePosition = { X = 6; Y = 11 }
Assert.AreEqual<TilePosition>(expectedBridge, bridge.Position)
for y in 11 .. 13 do
let bridgeTile = VillageArt.worldTile plan { X = 6; Y = y }
Assert.IsTrue(footprintOf plan |> List.contains bridgeTile, sprintf "bridge tile y=%d" y)
[<TestMethod>]
member _.FacingUsesTheActualMovementOrTargetVector () =
Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.EastFacing, VillageArt.directionFromVector { X = 1.0f; Y = 0.1f })
Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.WestFacing, VillageArt.directionFromVector { X = -1.0f; Y = 0.1f })
Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.SouthFacing, VillageArt.directionFromVector { X = 0.1f; Y = 1.0f })
Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.NorthFacing, VillageArt.directionFromVector { X = 0.1f; Y = -1.0f })
[<TestMethod>]
member _.RuntimeMovementAndNpcTargetsProduceStableSpriteDirections () =
let idle = VillageArt.facingAfterMovement { X = 4.0f; Y = 4.0f } { X = 4.0f; Y = 4.0f } VillageArt.EastFacing
let moved = VillageArt.facingAfterMovement { X = 4.0f; Y = 4.0f } { X = 2.0f; Y = 4.0f } VillageArt.EastFacing
let targetSpec = VillageArt.npcSpriteSpecAtTarget (NpcId 0) { X = 10.0f; Y = 10.0f } { X = 10.0f; Y = 8.0f } 4L
let arrivedSpec = VillageArt.npcSpriteSpecAtTarget (NpcId 0) { X = 10.0f; Y = 10.0f } { X = 10.0f; Y = 10.0f } 4L
Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.EastFacing, idle)
Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.WestFacing, moved)
Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.NorthFacing, targetSpec.Direction)
// Walking toward a target uses the walk cycle; reaching it settles into the breath cycle.
Assert.AreEqual<CharacterFrame>(WalkFrame FrameTwo, targetSpec.Frame)
Assert.AreEqual<CharacterFrame>(IdleFrame IdleOne, arrivedSpec.Frame)
[<TestMethod>]
member _.RenderPlanCoordinatesAndCharacterAtlasCellsAreDeterministic () =
let plan = VillageArt.sampleRenderPlan ()
let localTile : TilePosition = { X = 6; Y = 4 }
let worldTile = VillageArt.worldTile plan localTile
let expectedWorldTile : TilePosition = { X = 31; Y = 22 }
Assert.AreEqual<TilePosition>(expectedWorldTile, worldTile)
let first = VillageArt.characterSourceRectangle { Variant = IndigoRobe; Direction = NorthFacing; Frame = WalkFrame FrameOne }
let second = VillageArt.characterSourceRectangle { Variant = JadeSash; Direction = EastFacing; Frame = WalkFrame FrameTwo }
Assert.AreNotEqual<Rectangle>(first, second)
Assert.AreEqual<int>(32, first.Width)
Assert.AreEqual<int>(48, first.Height)
Assert.AreEqual<int>(32, second.Width)
Assert.AreEqual<int>(48, second.Height)
[<TestMethod>]
member _.FormalChineseUiLabelsUseTheRealGlyphAtlasBoundary () =
Assert.IsTrue(ChineseText.requiredUiLabels |> List.forall PixelText.isRenderable)
Assert.IsTrue(ChineseText.requiredUiLabels |> List.forall (fun label -> not (label.Contains("?"))))
Assert.IsFalse(PixelText.isRenderable "龘")
[<TestMethod>]
member _.ContextualPromptSelectsOneStableReachableNpcAndClearsWhenLeaving () =
let avatar : Vec2 =
{ X = float32 (32 * Sim.tilePixels + Sim.tilePixels / 2)
Y = float32 (24 * Sim.tilePixels + Sim.tilePixels / 2) }
let world =
Sim.initialWorldN 42UL 4
|> fun value -> { value with Avatar = { value.Avatar with Pos = avatar } }
let npcPosition : Vec2 = { X = world.Avatar.Pos.X + 48.0f; Y = world.Avatar.Pos.Y }
let npc = { world.Npcs.[0] with Pos = npcPosition }
let npcs = Array.copy world.Npcs
npcs.[0] <- npc
for index in 1 .. npcs.Length - 1 do
npcs.[index] <- { npcs.[index] with Pos = ({ X = 2000.0f; Y = 1400.0f } : Vec2) }
let nearby = { world with Npcs = npcs }
let prompt = InteractionResolver.resolve nearby Outside
Assert.IsTrue(prompt.IsSome)
Assert.AreEqual<InteractionAction>(TalkTo(NpcId 0), prompt.Value.Action)
Assert.AreEqual<string>("E 交谈", prompt.Value.Text)
let view = M5Interaction.refreshPrompt nearby M5Interaction.initial
Assert.AreEqual<string option>(Some "E 交谈", view.Prompt)
let farAway = { nearby with Avatar = { nearby.Avatar with Pos = ({ X = 0.0f; Y = 0.0f } : Vec2) } }
let cleared = M5Interaction.refreshPrompt farAway view
Assert.AreEqual<string option>(None, cleared.Prompt)
[<TestMethod>]
member _.HomeEntryWinsDeterministicallyAndUsesTheSameActionAsThePrompt () =
let doorPixel : PixelPosition =
{ X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2)
Y = float32 (23 * Sim.tilePixels + Sim.tilePixels / 2) }
let world = Sim.initialWorldN 42UL 0 |> fun value -> { value with Avatar = { value.Avatar with Pos = ({ X = doorPixel.X; Y = doorPixel.Y } : Vec2) } }
let prompt = InteractionResolver.resolve world Outside
Assert.IsTrue(prompt.IsSome)
Assert.AreEqual<InteractionAction>(EnterHome(HomeId 1), prompt.Value.Action)
Assert.AreEqual<string>("E 进屋", prompt.Value.Text)
let _, view = M5Interaction.apply Interact world M5Interaction.initial
Assert.AreEqual<HomeMode>(Inside(HomeId 1), view.HomeMode)
Assert.AreEqual<string option>(None, view.Prompt)
[<TestMethod>]
member _.WallBlocksNpcPromptAndOpenPanelsDoNotAcceptWorldInput () =
let avatar : Vec2 = { X = float32 (32 * Sim.tilePixels + Sim.tilePixels / 2); Y = float32 (24 * Sim.tilePixels + Sim.tilePixels / 2) }
let npcPosition : Vec2 = { X = avatar.X - 40.0f; Y = avatar.Y - 80.0f }
let world = Sim.initialWorldN 42UL 1
let npc = { world.Npcs.[0] with Pos = npcPosition }
let blockedWorld = { world with Avatar = { world.Avatar with Pos = avatar }; Npcs = [| npc |] }
Assert.IsFalse(InteractionResolver.hasClearPath avatar npcPosition)
Assert.IsFalse(M5Interaction.worldInputAllowed { M5Interaction.initial with Panel = DialoguePanel })
Assert.IsTrue(M5Interaction.worldInputAllowed M5Interaction.initial)
Assert.IsTrue(M5Interaction.resolveAction blockedWorld Outside |> Option.isNone)
[<TestMethod>]
member _.TargetSelectionUsesPureDistancePriorityWithIdTiebreak () =
let doorPixel : PixelPosition =
{ X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2)
Y = float32 (23 * Sim.tilePixels + Sim.tilePixels / 2) }
let avatar : Vec2 = { X = doorPixel.X - 32.0f; Y = doorPixel.Y }
let world = Sim.initialWorldN 42UL 2
// Door nearer than the villager: door wins by pure distance (no kind privilege).
let doorNearer =
{ world with
Avatar = { world.Avatar with Pos = avatar }
Npcs = [| { world.Npcs.[0] with Pos = { X = avatar.X + 40.0f; Y = avatar.Y } } |] }
Assert.AreEqual<InteractionAction option>(Some(EnterHome(HomeId 1)), InteractionResolver.resolve doorNearer Outside |> Option.map (fun candidate -> candidate.Action))
// NPC nearer than the door: villager wins.
let npcNearerWorld = { doorNearer with Npcs = [| { world.Npcs.[0] with Pos = { X = avatar.X + 4.0f; Y = avatar.Y } } |] }
Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 0)), InteractionResolver.resolve npcNearerWorld Outside |> Option.map (fun candidate -> candidate.Action))
// Exact distance tie between two villagers: lower entity id wins deterministically.
let tieAvatar : Vec2 = { X = 1600.0f; Y = 800.0f }
let tieWorld =
{ world with
Avatar = { world.Avatar with Pos = tieAvatar }
Npcs = [| { world.Npcs.[0] with Pos = { X = tieAvatar.X + 64.0f; Y = tieAvatar.Y } }
{ world.Npcs.[1] with Pos = { X = tieAvatar.X - 64.0f; Y = tieAvatar.Y } } |] }
Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 0)), InteractionResolver.resolve tieWorld Outside |> Option.map (fun candidate -> candidate.Action))
// Array snapshot immutability: neither resolve nor refreshPrompt may mutate Npcs.
let snapshot = Array.copy tieWorld.Npcs
let resolvedIgnore = InteractionResolver.resolve tieWorld Outside
CollectionAssert.AreEqual(snapshot, tieWorld.Npcs)
let view = M5Interaction.refreshPrompt tieWorld M5Interaction.initial
CollectionAssert.AreEqual(snapshot, tieWorld.Npcs)
[<TestMethod>]
member _.FloatingPromptStaysInViewportAndClearOfHudBands () =
for (width, height) in [ (1280, 720); (1920, 1080) ] do
let anchors : (float32 * float32) list =
[ (100.0f, 100.0f) // near top-left
(float32 width / 2.0f, float32 height / 2.0f)
(float32 width - 60.0f, 120.0f) // near top-right strip
(200.0f, float32 height - 200.0f) // above bottom-left HUD band
(float32 width - 100.0f, float32 height - 120.0f) ]
for (anchorX, anchorY) in anchors do
for text in [ "E 交谈"; "E 进屋"; "E 出屋" ] do
let rect = FloatingPrompt.layout width height anchorX anchorY 0.0f 0.0f 2 text
let textWidth = FloatingPrompt.measureWidth text 2
Assert.AreEqual<int>(textWidth, rect.Width)
Assert.IsTrue(rect.Width > 0, "prompt must not be truncated to zero width")
Assert.IsTrue(rect.X >= 0 && rect.X + rect.Width <= width, sprintf "x overflow %A %A" (anchorX, anchorY) rect)
Assert.IsTrue(rect.Y >= 0 && rect.Y + rect.Height <= height, sprintf "y overflow %A %A" (anchorX, anchorY) rect)
// Never cover the bottom-left HUD band (camera 0: band starts at height - 160).
Assert.IsTrue(rect.Y + rect.Height <= height - FloatingPrompt.hudBottomBandHeight + 8, sprintf "bottom HUD overlap %A %A" (anchorX, anchorY) rect)
// Never cover the full-width top status bar.
Assert.IsTrue(rect.Y >= FloatingPrompt.hudTopBarHeight, sprintf "top HUD overlap %A %A" (anchorX, anchorY) rect)
[<TestMethod>]
member _.FloatingPromptMeasureMatchesPixelTextAdvances () =
// E(12) space(8) 交(16) 谈(16) at scale 2.
Assert.AreEqual<int>(52, FloatingPrompt.measureWidth "E 交谈" 2)
Assert.AreEqual<int>(20, FloatingPrompt.measureWidth "E " 2)
[<TestMethod>]
member _.FloatingPromptHiddenWhilePanelsOrMenusAreOpen () =
let world = Sim.initialWorldN 42UL 2
let dialogueView = { M5Interaction.initial with Panel = DialoguePanel }
let cleared = M5Interaction.refreshPrompt world dialogueView
Assert.AreEqual<string option>(None, cleared.Prompt)
Assert.IsFalse(M5Interaction.worldInputAllowed dialogueView)
let needsView = { M5Interaction.initial with Panel = NeedsPanel }
Assert.AreEqual<string option>(None, M5Interaction.refreshPrompt world needsView |> fun value -> value.Prompt)
[<TestMethod>]
member _.DayPhaseLightingIsPureDeterministicAndTransitionsSmoothly () =
let ticksPerHour = Sim.ticksPerDay / 24L
let atHour hour = M6Presentation.lightingAtTick (int64 hour * ticksPerHour)
// Anchors: full day at noon, full night at midnight, no drift.
Assert.AreEqual<float32>(0.0f, (atHour 12).Blend)
Assert.AreEqual<float32>(1.0f, (atHour 0).Blend)
Assert.AreEqual<float32>(1.0f, (atHour 2).Blend)
// P17 window: dawn 4:00 -> 6:30 goes night -> day, monotonic (fractional hours).
let dawnMidTick = int64 5 * ticksPerHour + ticksPerHour / 4L // 5:15
let dawnEndTick = int64 6 * ticksPerHour + ticksPerHour / 2L // 6:30
Assert.AreEqual<float32>(1.0f, (atHour 4).Blend)
Assert.AreEqual<float32>(0.5f, (M6Presentation.lightingAtTick dawnMidTick).Blend)
Assert.AreEqual<float32>(0.0f, (M6Presentation.lightingAtTick dawnEndTick).Blend)
// P18: dusk darkening ramp is 20:00 -> 22:00 (day -> night, monotonic).
let duskStartTick = int64 20 * ticksPerHour // 20:00
let duskMidTick = int64 21 * ticksPerHour // 21:00
Assert.AreEqual<float32>(0.0f, (M6Presentation.lightingAtTick duskStartTick).Blend)
Assert.AreEqual<float32>(0.5f, (M6Presentation.lightingAtTick duskMidTick).Blend)
Assert.AreEqual<float32>(1.0f, (atHour 22).Blend)
// Blue hour: the dawn lingers past 6:00; dusk stays light while it warms.
Assert.IsTrue((atHour 6).Blend > 0.0f && (atHour 6).Blend < 0.5f)
let duskGlowTick = int64 20 * ticksPerHour + ticksPerHour / 2L // 20:30
Assert.IsTrue((M6Presentation.lightingAtTick duskGlowTick).Blend > 0.0f)
Assert.IsTrue((M6Presentation.lightingAtTick duskGlowTick).Blend < 0.5f, "20:30 must still be Day mode for a visible golden dusk")
Assert.AreEqual<M6DayNight>(Day, (M6Presentation.lightingAtTick duskGlowTick).Mode)
// Golden-hour warmth peaks mid-dawn (5:15) / mid-dusk (20:30) and vanishes at noon/midnight.
let duskWarmthMidTick = int64 20 * ticksPerHour + ticksPerHour / 2L // 20:30
Assert.AreEqual<float32>(0.0f, (atHour 12).SkyWarmth)
Assert.AreEqual<float32>(0.0f, (atHour 0).SkyWarmth)
Assert.AreEqual<float32>(1.0f, (M6Presentation.lightingAtTick dawnMidTick).SkyWarmth)
Assert.AreEqual<float32>(1.0f, (M6Presentation.lightingAtTick duskWarmthMidTick).SkyWarmth)
Assert.IsTrue((atHour 5).SkyWarmth > 0.5f)
Assert.IsTrue((atHour 21).SkyWarmth > 0.5f)
// The warm shift pushes sky and world tint redder and less blue than the plain blend;
// this is what made the old dusk frame still read blue-grey.
let warmDawn = atHour 5
let warmDusk = M6Presentation.lightingAtTick duskGlowTick
Assert.IsTrue(warmDawn.Background.R > (M6Presentation.backgroundAtBlend warmDawn.Blend).R)
Assert.IsTrue(warmDawn.Background.B < (M6Presentation.backgroundAtBlend warmDawn.Blend).B)
Assert.IsTrue(warmDusk.Background.R > (M6Presentation.backgroundAtBlend warmDusk.Blend).R)
Assert.IsTrue(warmDusk.Background.B < (M6Presentation.backgroundAtBlend warmDusk.Blend).B)
Assert.IsTrue(warmDusk.WorldTint.R > (M6Presentation.worldTintAtBlend warmDusk.Blend).R)
Assert.IsTrue(warmDusk.WorldTint.B < (M6Presentation.worldTintAtBlend warmDusk.Blend).B)
// P19: no daylight warm cast. Noon (and the whole 10:00-15:00 plateau) must be warmth-free
// and channel-neutral, so the world renders at a true white balance instead of yellow.
for hour in 10 .. 15 do
let daylight = atHour hour
Assert.IsTrue(abs (daylight.SkyWarmth) < 1e-6f, sprintf "hour %d must carry no warmth" hour)
Assert.IsTrue((int daylight.WorldTint.R) = (int daylight.WorldTint.G), sprintf "hour %d world tint must be neutral" hour)
Assert.IsTrue((int daylight.WorldTint.G) = (int daylight.WorldTint.B), sprintf "hour %d world tint must be neutral" hour)
Assert.IsTrue((M6Presentation.goldenVeilAlpha (int64 hour * ticksPerHour)) = 0, sprintf "hour %d must have no golden veil" hour)
Assert.IsTrue(abs (daylight.LanternGlow) < 1e-6f, sprintf "hour %d must have no lantern glow" hour)
let noon = atHour 12
Assert.IsTrue((int noon.WorldTint.R) = 255, "neutral day tint should be full white (no attenuation)")
// Additive veil: positive and strongest at the golden peaks, zero at noon/midnight.
Assert.AreEqual<int>(0, M6Presentation.goldenVeilAlpha (int64 12 * ticksPerHour))
Assert.AreEqual<int>(0, M6Presentation.goldenVeilAlpha 0L)
Assert.AreEqual<int>(150, M6Presentation.goldenVeilAlpha duskWarmthMidTick)
Assert.IsTrue(M6Presentation.goldenVeilAlpha duskGlowTick > 100)
Assert.IsTrue(M6Presentation.goldenVeilAlpha (int64 19 * ticksPerHour) = 0, "veil must start after 19:15, not bleed into afternoon")
// Determinism without randomness.
let once = atHour 13
let twice = atHour 13
Assert.AreEqual<M6RenderProfile>(once, twice)
// Lantern glow now also lifts through the golden hour, not only at deep night.
Assert.AreEqual<float32>(0.0f, (atHour 13).LanternGlow)
Assert.AreEqual<float32>(1.0f, (atHour 0).LanternGlow)
Assert.IsTrue(warmDusk.LanternGlow > 0.5f)
// Evidence hook: fractional start hours map exactly onto sim ticks.
Assert.AreEqual<int64>(int64 20 * ticksPerHour + ticksPerHour * 3L / 4L, M6Presentation.startTickForHour 20.75)
Assert.AreEqual<int64>(6L * ticksPerHour, M6Presentation.startTickForHour 6.0)
[<TestMethod>]
member _.StartTickResolutionHonoursEvidenceHourThenDaylightThenMidnight () =
let ticksPerHour = Sim.ticksPerDay / 24L
// P17-FIX: the evidence hour must win on the sample/autoplay path too, otherwise
// StartNewGame re-created the world at 00:00 and dusk/day shots were actually night.
Assert.AreEqual<int64>(M6Presentation.startTickForHour 20.75, M6Presentation.resolveStartTick (Some 20.75) false)
Assert.AreEqual<int64>(int64 20 * ticksPerHour + ticksPerHour * 3L / 4L, M6Presentation.resolveStartTick (Some 20.75) false)
// Explicit hour overrides the daylight bootstrap.
Assert.AreEqual<int64>(12L * ticksPerHour, M6Presentation.resolveStartTick (Some 12.0) true)
// No override: daylight -> 6:00, otherwise midnight.
Assert.AreEqual<int64>(6L * ticksPerHour, M6Presentation.resolveStartTick None true)
Assert.AreEqual<int64>(0L, M6Presentation.resolveStartTick None false)
[<TestMethod>]
member _.TitleEntranceProgressIsDeterministicAndCompleteAfterTwoSeconds () =
// 120 frames at the fixed 60Hz step = 2s entrance, pure function of frame count.
Assert.AreEqual<float32>(1.0f, TitleScreen.entranceProgress 0L)
Assert.AreEqual<float32>(0.5f, TitleScreen.entranceProgress 60L)
Assert.AreEqual<float32>(0.0f, TitleScreen.entranceProgress 120L)
Assert.AreEqual<float32>(0.0f, TitleScreen.entranceProgress 1000L)
Assert.AreEqual<float32>(TitleScreen.entranceProgress 37L, TitleScreen.entranceProgress 37L)
[<TestMethod>]
member _.ProceduralMapGeneratesByteIdenticalTerrainFromSameSeed () =
let first = ProceduralMap.generate (uint64 4242)
let second = ProceduralMap.generate (uint64 4242)
Assert.AreEqual<int>(first.Tiles.Length, second.Tiles.Length)
let snapshot = Array.copy first.Tiles
let mutable diverged = -1
for i in 0 .. snapshot.Length - 1 do
if snapshot.[i] <> second.Tiles.[i] then diverged <- i
Assert.AreEqual<int>(-1, diverged)
// Different seed must produce different terrain (sanity).
let other = ProceduralMap.generate (uint64 777)
let differs = [0 .. snapshot.Length - 1] |> List.tryFind (fun i -> other.Tiles.[i] <> snapshot.[i])
Assert.IsTrue(differs.IsSome, "different seed should carve different terrain")
// Village core stays walkable: no water/peat inside the handcrafted core rect.
let coreBad =
[ for y in ProceduralMap.villageCoreMinY .. ProceduralMap.villageCoreMaxY do
for x in ProceduralMap.villageCoreMinX .. ProceduralMap.villageCoreMaxX do
let code = first.Tiles.[y * ProceduralMap.width + x]
if code = ProceduralMap.GroundTile.Water || code = ProceduralMap.GroundTile.Peat then yield (x, y, code) ]
Assert.AreEqual<(int * int * int) list>([], coreBad)
[<TestMethod>]
member _.WorldPanelDoesNotRenderA永久KeyList () =
let world = Sim.initialWorldN 42UL 0
let lines = M5Interaction.panelLines world M5Interaction.initial
Assert.AreEqual<string list>([], lines)
Assert.IsFalse(lines |> List.exists (fun line -> line = "Q 观察" || line = "TAB 需求" || line = "C 年鉴" || line = "L 关系"))
[<TestMethod>]
member _.WorldPanelStatusIsTransientAndExpiresAfterTwoAndAHalfSeconds () =
let world = Sim.initialWorldN 42UL 0
let _, interacted = M5Interaction.apply ClosePanel world M5Interaction.initial
let stamped = { interacted with StatusTick = Some world.Tick }
Assert.AreEqual<string list>([ "E 进屋"; "面板已关闭" ], M5Interaction.panelLines world stamped)
let expired = { stamped with StatusTick = Some (world.Tick - M5Interaction.statusDisplayDurationTicks) }
Assert.AreEqual<string list>([ "E 进屋" ], M5Interaction.panelLines world expired)
[<TestMethod>]
member _.DialogueCompletionReturnsToTheLightweightWorldPanel () =
let world = Sim.initialWorldN 42UL 4 |> fun value -> { value with Avatar = { value.Avatar with Pos = value.Npcs.[0].Pos } }
let openedWorld, opened = M5Interaction.apply Interact world M5Interaction.initial
Assert.AreEqual<M5Panel>(DialoguePanel, opened.Panel)
Assert.IsTrue(opened.Menu.IsSome)
let nextWorld, view = M5Interaction.apply Intent1 openedWorld opened
Assert.AreEqual<M5Panel>(WorldPanel, view.Panel)
Assert.AreEqual<DialogueMenu option>(None, view.Menu)
Assert.AreEqual<int64 option>(Some nextWorld.Tick, view.StatusTick)
Assert.IsTrue(M5Interaction.statusText view |> fun text -> text.Contains("对话完成"))
Assert.IsTrue(M5Interaction.transientStatusLine nextWorld.Tick view |> Option.map (fun line -> line.Contains("对话完成")) |> Option.defaultValue false)
[<TestMethod>]
member _.InteractionRangeAndTargetSelectionStayDeterministicAtTheBoundary () =
let avatar : Vec2 =
{ X = float32 (32 * Sim.tilePixels + Sim.tilePixels / 2)
Y = float32 (24 * Sim.tilePixels + Sim.tilePixels / 2) }
let baseWorld = Sim.initialWorldN 42UL 4
let withNpcPositions (positions: (int * Vec2) list) =
let npcs = Array.copy baseWorld.Npcs
for index in 0 .. npcs.Length - 1 do
npcs.[index] <- { npcs.[index] with Pos = ({ X = 2000.0f; Y = 1400.0f } : Vec2) }
for index, position in positions do
npcs.[index] <- { npcs.[index] with Pos = position }
{ baseWorld with
Avatar = { baseWorld.Avatar with Pos = avatar }
Npcs = npcs }
let atBoundary = withNpcPositions [ 0, { X = avatar.X + Sim.chatRangePx; Y = avatar.Y } ]
let beyondBoundary = withNpcPositions [ 0, { X = avatar.X + Sim.chatRangePx + 0.1f; Y = avatar.Y } ]
let competing =
withNpcPositions
[ 0, { X = avatar.X + 72.0f; Y = avatar.Y }
1, { X = avatar.X + 48.0f; Y = avatar.Y } ]
let switched =
{ competing with
Avatar = { competing.Avatar with Pos = { X = avatar.X + 64.0f; Y = avatar.Y } } }
Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 0)), InteractionResolver.resolve atBoundary Outside |> Option.map (fun target -> target.Action))
Assert.AreEqual<InteractionAction option>(None, InteractionResolver.resolve beyondBoundary Outside |> Option.map (fun target -> target.Action))
Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 1)), InteractionResolver.resolve competing Outside |> Option.map (fun target -> target.Action))
Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 0)), InteractionResolver.resolve switched Outside |> Option.map (fun target -> target.Action))
[<TestMethod>]
member _.CjkGlyphAtlasHasAStableCharacterTableAndGeometry () =
Assert.AreEqual<int>(345, characters.Length)
CollectionAssert.AreEqual(characters, characters |> Array.sort)
Assert.AreEqual<int>(characters.Length, characters |> Array.distinct |> Array.length)
Assert.AreEqual<int>(16, columns)
Assert.AreEqual<int>(24, cellPixels)
Assert.AreEqual<int>(22, rowCount)
Assert.AreEqual<int>(384, pixelWidth)
Assert.AreEqual<int>(528, pixelHeight)
Assert.AreEqual<string>("Assets/cjk-glyph-atlas.png", assetRelativePath)
Assert.AreEqual<string>("Assets/cjk-glyphs.txt", manifestRelativePath)
[<TestMethod>]
member _.RequiredUiLabelsAreFullyCoveredByTheGlyphTable () =
let labels = ChineseText.requiredUiLabels
Assert.IsTrue(labels |> List.forall PixelText.isRenderable)
Assert.IsTrue(labels |> List.contains "时间")
Assert.IsTrue(labels |> List.contains "E 出屋")
Assert.IsTrue(labels |> List.contains "E 进屋")
Assert.IsTrue(labels |> List.contains "E 交谈")
Assert.IsTrue(PixelText.isRenderable "时间 白天")
Assert.IsTrue(PixelText.isRenderable "2倍速")
Assert.IsTrue(PixelText.isRenderable "E 出门")
Assert.IsTrue(PixelText.isRenderable "E 交谈")
Assert.IsFalse(PixelText.isRenderable "龘")
[<TestMethod>]
member _.CjkGlyphAtlasMatchesThePackagedManifestAndRejectsUnknownCharacters () =
Assert.IsTrue(ChineseText.requiredUiLabels |> List.forall PixelText.isRenderable)
Assert.IsTrue(contains '江')
Assert.IsTrue(contains '间')
Assert.IsTrue(contains '门')
Assert.IsTrue(contains '谈')
Assert.IsTrue(contains '体')
Assert.IsTrue(contains '龘' |> not)
Assert.AreEqual<int>(0, sourceRectangle '龘' |> Option.toList |> List.length)
let manifestPath = runtimeManifestPath ()
Assert.IsTrue(File.Exists manifestPath, sprintf "Missing atlas manifest: %s" manifestPath)
let manifestLines = File.ReadAllLines manifestPath
Assert.AreEqual<int>(characters.Length, manifestLines.Length)
Assert.IsTrue(manifestLines |> Array.forall (fun line -> line.Length = 1), "The CJK manifest must contain exactly one character per line.")
CollectionAssert.AreEqual(characters, manifestLines |> Array.map (fun line -> line.[0]))
Assert.IsTrue(File.Exists(runtimeAssetPath ()), sprintf "Missing atlas PNG: %s" (runtimeAssetPath ()))
[<TestMethod>]
member _.WorldDigestIsCanonicalAcrossSaveFormatVersions () =
// The digest must bind to simulation state only: a world presented as a
// legacy V1 save text and as the new V2 save text must share one digest.
let world = Sim.initialWorldN (uint64 42) 4
let v2Text = WorldSave.save world
let firstPipe = v2Text.IndexOf('|')
let secondPipe = v2Text.IndexOf('|', firstPipe + 1)
let thirdPipe = v2Text.IndexOf('|', secondPipe + 1)
let v1Text = ("LV_WORLD_SAVE_V1" + v2Text.Substring(thirdPipe))
// v1/v2 same-world digest equality (RED before canonical digest exists).
let digestOfV1 = PerformanceProbe.WorldDigestOfText v1Text
Assert.AreEqual<string>(digestOfV1, PerformanceProbe.WorldDigestOfText v2Text)
// 953775FA 硬对齐由 --performance-baseline 于 finalWorld 支线证据。
[<TestMethod>]
member _.HouseFacadeVariantsAlternateAlongTheVillage () =
// P26:512 大图上两种民居立面都要出现,且按位置确定性交替(纯函数)。
let map = MapGen.generateWithSize 512 384 (uint64 4242)
let variants = map.Buildings |> List.map VillageArt.houseFacadeVariant
Assert.IsTrue(Set.ofList variants |> Set.contains 0, "variant A must appear")
Assert.IsTrue(Set.ofList variants |> Set.contains 1, "variant B must appear")
let repeated = MapGen.generateWithSize 512 384 (uint64 4242)
Assert.AreEqual<(int list)>(variants, repeated.Buildings |> List.map VillageArt.houseFacadeVariant)
// Neighbouring buildings along the same river bank must flip variant, i.e. not one solid run.
let flipped =
variants
|> List.pairwise
|> List.filter (fun (a, b) -> a <> b)
|> List.length
Assert.IsTrue(flipped > 0, "facades must alternate, not stay one variant")
[<TestMethod>]
member _.JiangnanArtAssetsArePackagedAsRealPngFiles () =
let requiredAssets =
[ "Assets/jiangnan-world.png"
"Assets/jiangnan-characters.png"
"Assets/jiangnan-interior.png" ]
let missing =
requiredAssets
|> List.filter (fun relativePath -> not (File.Exists(Path.Combine(AppContext.BaseDirectory, relativePath))))
Assert.AreEqual<string list>([], missing, sprintf "Missing Jiangnan PNG assets: %s" (String.concat ", " missing))
// The baked world atlas must match the 32-slot layout the renderer indexes into
// (P17 added 石灯笼/水缸/摊贩摊位 as slots 25..27; P26 added the variant-B house
// parts 屋檐挑出/木格栅窗/院门 as slots 28..30 and 护岸石垒 as slot 31).
Assert.AreEqual<int>(32, VillageArt.worldTileCount)
let worldPath = Path.Combine(AppContext.BaseDirectory, "Assets/jiangnan-world.png")
let header = File.ReadAllBytes worldPath
let be32 offset =
(int header.[offset] <<< 24)
||| (int header.[offset + 1] <<< 16)
||| (int header.[offset + 2] <<< 8)
||| int header.[offset + 3]
let atlasWidth : int = be32 16
let atlasHeight : int = be32 20
Assert.IsTrue((atlasWidth = VillageArt.worldAtlasWidth), "world atlas width must match 32 x 32px slots")
Assert.IsTrue((atlasHeight = VillageArt.worldAtlasHeight), "world atlas height must stay 32px")
/// P28:防复发。第二变体(P26)在 512 整图 ~2px/tile 下不可辨,根因是挑檐与第一变体同为
/// 近黑黛瓦。此回归直接解码 world 图集,断言变体 B 的挑檐(slot 28)平均亮度显著高于
/// 变体 A 的屋脊(slot 8),保证「配色差异」不会在后续编辑中被悄悄抹平。
[<TestMethod>]
member _.VariantBRoofPaletteIsClearlyLighterThanVariantA () =
let worldPath = Path.Combine(AppContext.BaseDirectory, "Assets/jiangnan-world.png")
let bytes = File.ReadAllBytes worldPath
let be32 offset =
(int bytes.[offset] <<< 24)
||| (int bytes.[offset + 1] <<< 16)
||| (int bytes.[offset + 2] <<< 8)
||| int bytes.[offset + 3]
Assert.AreEqual<int>(0x89504E47, be32 0)
let width = be32 16
let height = be32 20
Assert.AreEqual<int>(VillageArt.worldAtlasWidth, width)
Assert.AreEqual<int>(VillageArt.worldAtlasHeight, height)
// Collect IDAT and inflate; the baked atlas is 8-bit RGBA, non-interlaced, filter 0.
let mutable offset = 8
use idat = new MemoryStream()
while offset < bytes.Length do
let length = be32 offset
let tag = System.Text.Encoding.ASCII.GetString(bytes, offset + 4, 4)
if tag = "IDAT" then idat.Write(bytes, offset + 8, length)
offset <- offset + 12 + length
idat.Position <- 0L
use inflater = new System.IO.Compression.ZLibStream(idat, System.IO.Compression.CompressionMode.Decompress)
use raw = new MemoryStream()
inflater.CopyTo raw
let data = raw.ToArray()
let stride = width * 4 + 1
let pixel (x: int) (y: int) =
let rowStart = y * stride
Assert.AreEqual<byte>(0uy, data.[rowStart])
let p = rowStart + 1 + x * 4
(int data.[p], int data.[p + 1], int data.[p + 2], int data.[p + 3])
let slotMeanBrightness (slot: int) =
let mutable sum = 0.0
let mutable count = 0
for y in 0 .. height - 1 do
for x in slot * 32 .. slot * 32 + 31 do
let (r, g, b, a) = pixel x y
if a > 0 then
sum <- sum + float (r + g + b) / 3.0
count <- count + 1
Assert.IsTrue(count > 0, sprintf "slot %d must have opaque pixels" slot)
sum / float count
let roofA = slotMeanBrightness 8 // RoofRidgeSprite: variant A near-black 黛瓦
let roofB = slotMeanBrightness 28 // RoofEaveOverhangSprite: variant B light blue-grey 瓦
Assert.IsTrue(
roofB > roofA + 15.0,
sprintf "variant B roof must be clearly lighter than variant A (A=%.1f B=%.1f)" roofA roofB)
[<TestMethod>]
member _.CjkGlyphManifestIsUniqueAndMatchesAtlasGrid () =
// 防复发:图集用字增长时,清单必须非空、每行恰 1 个字符、字符唯一,
// 且与 16 列图集网格自洽(打包脚本不再硬编码行数)。
let assets = Path.Combine(AppContext.BaseDirectory, "Assets")
let manifestPath = Path.Combine(assets, "cjk-glyphs.txt")
let atlasPath = Path.Combine(assets, "cjk-glyph-atlas.png")
Assert.IsTrue(File.Exists manifestPath, "missing cjk-glyphs.txt")
Assert.IsTrue(File.Exists atlasPath, "missing cjk-glyph-atlas.png")
let lines = File.ReadAllLines manifestPath
Assert.IsTrue(lines.Length > 0, "CJK manifest must not be empty")
for index in 0 .. lines.Length - 1 do
Assert.AreEqual<int>(1, lines.[index].Length)
Assert.AreEqual<int>(lines.Length, (Set.ofArray lines).Count)
let header = File.ReadAllBytes atlasPath
let be32 offset =
(int header.[offset] <<< 24)
||| (int header.[offset + 1] <<< 16)
||| (int header.[offset + 2] <<< 8)
||| int header.[offset + 3]
let cell = 24
let width = be32 16
let height = be32 20
Assert.AreEqual<int>(0, width % cell)
Assert.AreEqual<int>(0, height % cell)
let columns = width / cell
let capacity = columns * (height / cell)
Assert.AreEqual<int>(16, columns)
Assert.IsTrue(capacity >= lines.Length, "CJK atlas grid must cover every manifest character")
Assert.IsTrue(capacity - lines.Length < columns, "CJK atlas must not keep an extra empty row")
|