summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop.Tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 00:26:30 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 00:26:30 +0800
commitb6b95988e525a68ea7426ecc9690b18a1b3bbfe8 (patch)
tree4c19ab58a610c6d783e1dfc45585b12a6ecb2e58 /src/LivingVillage.Desktop.Tests
parent0ea26337830ada438df144b3a51f429f00dc1967 (diff)
downloadliving-village-b6b95988e525a68ea7426ecc9690b18a1b3bbfe8.tar.gz
feat(m6c): 完成江南村庄中文交付
完成江南村庄场景、角色表现和中文界面的 M6c 交付范围。 新增 CJK 字形图集、中文文案与运行时加载,补充民居情境交互和确定性村庄美术表现。 更新资源复制与 Linux/macOS 打包校验,加入 188 字符清单、资源许可说明和对应原型测试。 补充江南美术方向与 M6c 交付说明,并保持原有模拟内核和桌面构建入口不变。
Diffstat (limited to 'src/LivingVillage.Desktop.Tests')
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj5
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs172
2 files changed, 175 insertions, 2 deletions
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index 569190a..3a9a142 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -23,4 +23,9 @@
<ProjectReference Include="..\LivingVillage.Headless\LivingVillage.Headless.fsproj" />
</ItemGroup>
+ <ItemGroup>
+ <None Include="..\LivingVillage.Desktop\Assets\cjk-glyph-atlas.png" Link="Assets\cjk-glyph-atlas.png" CopyToOutputDirectory="PreserveNewest" />
+ <None Include="..\LivingVillage.Desktop\Assets\cjk-glyphs.txt" Link="Assets\cjk-glyphs.txt" CopyToOutputDirectory="PreserveNewest" />
+ </ItemGroup>
+
</Project>
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index a4bd1ea..842ab4a 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -1,11 +1,14 @@
namespace LivingVillage.Desktop.Tests
open System
+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
@@ -68,7 +71,9 @@ type PrototypeTests () =
| Error(HomeNotFound(HomeId 999)) -> ()
| other -> Assert.Fail(sprintf "Expected HomeNotFound, got %A." other)
- let farAway = { X = scene.InitialPlayerPosition.X + 20; Y = scene.InitialPlayerPosition.Y + 20 }
+ 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)
@@ -224,7 +229,7 @@ type PrototypeTests () =
sceneLabel Waterway
sceneLabel WhiteWallHome
sceneLabel StoneBridgeScene
- sceneLabel BambooGrove
+ sceneLabel ChineseText.BambooGrove
sceneLabel VegetableGardenScene
dayNightLabel DayTime
dayNightLabel NightTime
@@ -248,3 +253,166 @@ type PrototypeTests () =
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))
+ Assert.AreEqual<int>(4, first.Interior.Elements.Length)
+
+ [<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 7) { X = 10.0f; Y = 10.0f } { X = 10.0f; Y = 8.0f } 4L
+
+ Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.EastFacing, idle)
+ Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.WestFacing, moved)
+ Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.NorthFacing, targetSpec.Direction)
+
+ [<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 = FrameOne }
+ let second = VillageArt.characterSourceRectangle { Variant = JadeSash; Direction = EastFacing; Frame = 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 _.WorldPanelDoesNotRenderA永久KeyList () =
+ let lines = M5Interaction.panelLines (Sim.initialWorldN 42UL 0) M5Interaction.initial
+ Assert.IsFalse(lines |> List.exists (fun line -> line = "Q 观察" || line = "TAB 需求" || line = "C 年鉴" || line = "L 关系"))
+
+ [<TestMethod>]
+ member _.CjkGlyphAtlasHasAStableCharacterTableAndGeometry () =
+ Assert.AreEqual<int>(188, 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>(12, rowCount)
+ Assert.AreEqual<int>(384, pixelWidth)
+ Assert.AreEqual<int>(288, pixelHeight)
+ Assert.AreEqual<string>("Assets/cjk-glyph-atlas.png", assetRelativePath)
+ Assert.AreEqual<string>("Assets/cjk-glyphs.txt", manifestRelativePath)
+
+ [<TestMethod>]
+ member _.CjkGlyphAtlasMatchesThePackagedManifestAndRejectsUnknownCharacters () =
+ Assert.IsTrue(ChineseText.requiredUiLabels |> List.forall (fun label -> label |> Seq.forall (fun character -> character = ' ' || not (Char.IsLetter character) || contains character)))
+ 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 ()))