summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop.Tests/P36PromptTests.fs
blob: bc398d3f570cffc89a5d59aaf03e5b6842ba2aa0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
namespace LivingVillage.Desktop.Tests

open Microsoft.VisualStudio.TestTools.UnitTesting
open LivingVillage.Kernel
open LivingVillage.Kernel.Sim
open LivingVillage.Desktop
open LivingVillage.Desktop.VillagePresentation

/// P36:情境提示系统契约回归。覆盖距离边界、目标切换、离开消失、隔墙不触发、
/// 对话层输入隔离、提示对象与按键对象一致、布局不遮挡、Esc 关闭当前层、保存反馈。
[<TestClass>]
type P36PromptTests () =

    let atPosition (x: float32) (y: float32) (world: World) : World =
        { world with Avatar = { world.Avatar with Pos = ({ X = x; Y = y } : Vec2) } }

    /// 把所有村民先移到远处,再按给定位置摆放,隔离出唯一候选目标。
    let withNpcsAt (positions: (int * Vec2) list) (world: World) : World =
        let npcs = Array.copy world.Npcs
        for index in 0 .. npcs.Length - 1 do
            npcs.[index] <- { npcs.[index] with Pos = ({ X = 4000.0f; Y = 4000.0f } : Vec2) }
        for (index, position) in positions do
            npcs.[index] <- { npcs.[index] with Pos = position }
        { world with Npcs = npcs }

    let actionText (action: InteractionAction) : string =
        match action with
        | TalkTo _ -> "E 交谈"
        | EnterHome _ -> "E 进屋"
        | ExitHome _ -> "E 出屋"

    let doorCenter : Vec2 =
        { X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2)
          Y = float32 (23 * Sim.tilePixels + Sim.tilePixels / 2) }

    [<TestMethod>]
    member _.PromptAppearsOnlyInsideInteractionRangeAtBoundary () =
        let baseWorld = Sim.initialWorldN 42UL 4 |> withNpcsAt []
        let avatar : Vec2 = { X = 1600.0f; Y = 800.0f }
        let atBoundary =
            baseWorld
            |> atPosition avatar.X avatar.Y
            |> withNpcsAt [ 0, { X = avatar.X + Sim.chatRangePx; Y = avatar.Y } ]
        let beyond =
            baseWorld
            |> atPosition avatar.X avatar.Y
            |> withNpcsAt [ 0, { X = avatar.X + Sim.chatRangePx + 0.5f; 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 beyond Outside |> Option.map (fun target -> target.Action))

    [<TestMethod>]
    member _.PromptSwitchesToTheNearestReachableTargetDeterministically () =
        let baseWorld = Sim.initialWorldN 42UL 4
        let avatar : Vec2 = { X = 1600.0f; Y = 800.0f }
        let competing =
            baseWorld
            |> atPosition avatar.X avatar.Y
            |> withNpcsAt
                [ 0, { X = avatar.X + 72.0f; Y = avatar.Y }
                  1, { X = avatar.X + 48.0f; Y = avatar.Y } ]

        Assert.AreEqual<InteractionAction option>(
            Some(TalkTo(NpcId 1)),
            InteractionResolver.resolve competing Outside |> Option.map (fun target -> target.Action))

        // 走近后最近目标切换为 0 号,仍然一帧只选一个。
        let switched = competing |> atPosition (avatar.X + 64.0f) avatar.Y
        Assert.AreEqual<InteractionAction option>(
            Some(TalkTo(NpcId 0)),
            InteractionResolver.resolve switched Outside |> Option.map (fun target -> target.Action))

    [<TestMethod>]
    member _.PromptDisappearsImmediatelyAfterLeavingRange () =
        let world =
            Sim.initialWorldN 42UL 4
            |> atPosition 1600.0f 800.0f
            |> withNpcsAt [ 0, { X = 1632.0f; Y = 800.0f } ]
        let view = M5Interaction.refreshPrompt world M5Interaction.initial
        Assert.AreEqual<string option>(Some "E 交谈", view.Prompt)
        Assert.IsTrue(view.PromptTargetPos.IsSome)

        let left = world |> atPosition 2400.0f 1600.0f
        let cleared = M5Interaction.refreshPrompt left view
        Assert.AreEqual<string option>(None, cleared.Prompt)
        Assert.AreEqual<Vec2 option>(None, cleared.PromptTargetPos)

    [<TestMethod>]
    member _.PromptNeverTriggersThroughAWall () =
        // 民居西墙世界瓦片 (31,22);玩家在墙东侧紧邻,村民在墙后,水平距离在交谈范围内,
        // 但视线被墙体挡住 -> 不得触发。
        let wallAvatar : Vec2 =
            { X = float32 (32 * Sim.tilePixels + Sim.tilePixels / 2)
              Y = float32 (22 * Sim.tilePixels + Sim.tilePixels / 2) }
        let wallNpc : Vec2 =
            { X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2)
              Y = float32 (22 * Sim.tilePixels + Sim.tilePixels / 2) }
        let world =
            Sim.initialWorldN 42UL 1
            |> atPosition wallAvatar.X wallAvatar.Y
            |> withNpcsAt [ 0, wallNpc ]

        Assert.IsTrue(InteractionResolver.hasClearPath wallAvatar wallNpc |> not)
        Assert.AreEqual<InteractionAction option>(
            None,
            InteractionResolver.resolve world Outside |> Option.map (fun target -> target.Action))
        Assert.AreEqual<string option>(None, (M5Interaction.refreshPrompt world M5Interaction.initial).Prompt)

    [<TestMethod>]
    member _.OpenDialogueLayerIsolatesWorldInteractionCommands () =
        let world =
            Sim.initialWorldN 42UL 4
            |> atPosition 1600.0f 800.0f
            |> withNpcsAt [ 0, { X = 1632.0f; Y = 800.0f } ]
        let dialogueView =
            { M5Interaction.initial with
                Panel = DialoguePanel
                Menu = Some { Target = NpcId 0; Options = Sim.dialogueOptions } }

        Assert.IsFalse(M5Interaction.worldInputAllowed dialogueView)

        // E(Interact)在对白层打开时不得穿透重开/改选目标。
        let interactWorld, interactView = M5Interaction.apply Interact world dialogueView
        Assert.AreEqual<World>(world, interactWorld)
        Assert.AreEqual<M5Panel>(DialoguePanel, interactView.Panel)
        Assert.IsTrue(interactView.Menu.IsSome)

        // Q(Observe)同样被隔离。
        let observeWorld, observeView = M5Interaction.apply Observe world dialogueView
        Assert.AreEqual<World>(world, observeWorld)
        Assert.AreEqual<M5Panel>(DialoguePanel, observeView.Panel)
        Assert.AreEqual<Observation list>([], observeView.Observations)

    [<TestMethod>]
    member _.PromptObjectAlwaysMatchesTheInteractTarget () =
        // 村民:提示文本与 resolve 的动作一致,按 E 后菜单目标是同一村民。
        let npcWorld =
            Sim.initialWorldN 42UL 4
            |> atPosition 1600.0f 800.0f
            |> withNpcsAt [ 0, { X = 1632.0f; Y = 800.0f } ]
        let npcTarget = InteractionResolver.resolve npcWorld Outside
        Assert.IsTrue(npcTarget.IsSome)
        Assert.AreEqual<string>(actionText npcTarget.Value.Action, npcTarget.Value.Text)
        let npcView = M5Interaction.refreshPrompt npcWorld M5Interaction.initial
        Assert.AreEqual<string option>(Some npcTarget.Value.Text, npcView.Prompt)
        let _, npcInteracted = M5Interaction.apply Interact npcWorld M5Interaction.initial
        Assert.AreEqual<M5Panel>(DialoguePanel, npcInteracted.Panel)
        Assert.AreEqual<NpcId>(NpcId 0, npcInteracted.Menu.Value.Target)

        // 民居门:提示 "E 进屋",按 E 后进入同一民居。
        let doorWorld =
            Sim.initialWorldN 42UL 4
            |> withNpcsAt []
            |> atPosition doorCenter.X doorCenter.Y
        let doorTarget = InteractionResolver.resolve doorWorld Outside
        Assert.IsTrue(doorTarget.IsSome)
        Assert.AreEqual<string>("E 进屋", doorTarget.Value.Text)
        Assert.AreEqual<string>(actionText doorTarget.Value.Action, doorTarget.Value.Text)
        let _, doorInteracted = M5Interaction.apply Interact doorWorld M5Interaction.initial
        Assert.AreEqual<HomeMode>(Inside(HomeId 1), doorInteracted.HomeMode)

    [<TestMethod>]
    member _.EscapeClosesTheCurrentLayerBackToWorld () =
        let world = Sim.initialWorldN 42UL 0
        for panel in [ DialoguePanel; NeedsPanel; ObservationPanel; ChroniclePanel; TaskPanel ] do
            let view = { M5Interaction.initial with Panel = panel }
            let _, closed = M5Interaction.apply ClosePanel world view
            Assert.IsTrue(closed.Panel = WorldPanel, sprintf "Esc 应关闭面板 %A" panel)
            Assert.IsTrue(closed.Menu.IsNone, sprintf "关闭 %A 后应清空菜单" panel)

    [<TestMethod>]
    member _.PauseHelpAndControlsPageReturnToThePreviousLayerOnEscape () =
        let control = SimulationControl.initial
        let playing = MenuState.create control true |> MenuState.enterGame
        let helpState, paused = MenuState.enterPauseHelp control playing
        Assert.AreEqual<MenuPage>(Controls, helpState.Page)
        Assert.IsTrue(paused.Paused)
        Assert.AreEqual<MenuPage>(Playing, helpState.ReturnPage)

        let returnedState, restored = MenuState.exitPauseHelp control helpState
        Assert.AreEqual<MenuPage>(Playing, returnedState.Page)
        Assert.IsFalse(restored.Paused)

        let backState, _ = MenuState.update Back helpState
        Assert.AreEqual<MenuPage>(Playing, backState.Page)

    [<TestMethod>]
    member _.SaveFeedbackUsesATransientChineseToast () =
        let success =
            { M5Interaction.initial with
                Status = "save succeeded"
                StatusTick = Some 100L }
        Assert.AreEqual<string>("保存成功", M5Interaction.statusText success)
        Assert.AreEqual<string option>(Some "保存成功", M5Interaction.transientStatusLine 180L success)
        Assert.AreEqual<string option>(
            None,
            M5Interaction.transientStatusLine (100L + M5Interaction.statusDisplayDurationTicks) success)

        let failed =
            { M5Interaction.initial with
                Status = "save failed"
                StatusTick = Some 100L }
        Assert.AreEqual<string>("保存失败", M5Interaction.statusText failed)
        Assert.AreEqual<string option>(Some "保存失败", M5Interaction.transientStatusLine 120L failed)

    [<TestMethod>]
    member _.PromptLayoutFitsSmallAndLargeWindowsWithoutCoveringHud () =
        for (width, height) in [ (800, 600); (1024, 576); (1280, 720); (1920, 1080) ] do
            let anchors : (int * int) list =
                [ (16, 16)
                  (width / 2, height / 2)
                  (width - 40, FloatingPrompt.hudTopBarHeight + 4)
                  (24, height - FloatingPrompt.hudBottomBandHeight - 4)
                  (width - 24, height - 24) ]
            for (anchorX, anchorY) in anchors do
                for text in [ "E 交谈"; "E 进屋"; "E 出屋" ] do
                    let rect =
                        FloatingPrompt.layout width height (float32 anchorX) (float32 anchorY) 0.0f 0.0f 2 text
                    Assert.IsTrue(rect.Width > 0, sprintf "提示宽度被截为 0 (%dx%d)" width height)
                    Assert.IsTrue(
                        rect.X >= 0 && rect.X + rect.Width <= width,
                        sprintf "水平越界 %dx%d anchor=(%d,%d) %A" width height anchorX anchorY rect)
                    Assert.IsTrue(
                        rect.Y >= 0 && rect.Y + rect.Height <= height,
                        sprintf "垂直越界 %dx%d anchor=(%d,%d) %A" width height anchorX anchorY rect)
                    Assert.IsTrue(
                        rect.Y >= FloatingPrompt.hudTopBarHeight,
                        sprintf "遮挡顶部状态条 %dx%d" width height)
                    Assert.IsTrue(
                        rect.Y + rect.Height <= height - FloatingPrompt.hudBottomBandHeight + 8,
                        sprintf "遮挡底部 HUD 带 %dx%d" width height)