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
|
namespace LivingVillage.Desktop.Tests
open Microsoft.VisualStudio.TestTools.UnitTesting
open LivingVillage.Kernel
open LivingVillage.Kernel.Sim
open LivingVillage.Desktop
open LivingVillage.Desktop.VillagePresentation
/// P53:情境提示与输入隔离回归(brief 第 8 条交互硬要求)。
/// 每个用例都断言**显式目标**(NpcId / HomeId),并把「提示文案」与「实际作用对象」
/// 收敛到同一选择规则:提示写「E 交谈」时按 E 必须作用于同一 Npc。
/// 提示层是瞬时叠加层,绝非常驻面板。
[<TestClass>]
type P53InteractionPromptTests () =
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 avatar : Vec2 = { X = 1600.0f; Y = 800.0f }
let promptTarget (world: World) : InteractionAction option =
InteractionResolver.resolve world Outside |> Option.map (fun target -> target.Action)
let promptText (world: World) : string option =
(M5Interaction.refreshPrompt world M5Interaction.initial).Prompt
[<TestMethod>]
member _.PromptAppearsOnlyInsideRangeWithExplicitTarget () =
let baseWorld = Sim.initialWorldN 42UL 4 |> withNpcsAt []
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)), promptTarget atBoundary)
Assert.AreEqual<string>("E 交谈", (InteractionResolver.resolve atBoundary Outside).Value.Text)
Assert.AreEqual<string option>(Some "E 交谈", promptText atBoundary)
Assert.AreEqual<InteractionAction option>(None, promptTarget beyond)
Assert.AreEqual<string option>(None, promptText beyond)
[<TestMethod>]
member _.NearestReachableTargetWinsDeterministicallyAndOnlyOneIsShown () =
let world =
Sim.initialWorldN 42UL 4
|> atPosition avatar.X avatar.Y
|> withNpcsAt
[ 0, { X = avatar.X + 72.0f; Y = avatar.Y }
1, { X = avatar.X + 48.0f; Y = avatar.Y }
2, { X = avatar.X + 60.0f; Y = avatar.Y } ]
for _ in 1 .. 5 do
Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 1)), promptTarget world)
let view = M5Interaction.refreshPrompt world M5Interaction.initial
Assert.AreEqual<string option>(Some "E 交谈", view.Prompt)
// 仅一个高亮:提示目标位置就是被选中的 1 号村民,别处不出现第二个提示。
Assert.AreEqual<Vec2 option>(Some world.Npcs.[1].Pos, view.PromptTargetPos)
// 走近远端村民:最近目标切换为 0 号,仍然只选一个。
let switched = world |> atPosition (avatar.X + 84.0f) avatar.Y
Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 0)), promptTarget switched)
[<TestMethod>]
member _.EqualDistanceTieBreaksByStableId () =
let world =
Sim.initialWorldN 42UL 4
|> atPosition avatar.X avatar.Y
|> withNpcsAt
[ 0, { X = avatar.X + 40.0f; Y = avatar.Y }
1, { X = avatar.X; Y = avatar.Y + 40.0f } ]
// 两个候选与玩家的距离平方相同(1600),稳定按 NpcId 升序取 0 号。
Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 0)), promptTarget world)
[<TestMethod>]
member _.PromptDisappearsImmediatelyAfterLeavingWithExplicitNone () =
let world =
Sim.initialWorldN 42UL 4
|> atPosition avatar.X avatar.Y
|> withNpcsAt [ 0, { X = avatar.X + 32.0f; Y = avatar.Y } ]
let shown = M5Interaction.refreshPrompt world M5Interaction.initial
Assert.AreEqual<string option>(Some "E 交谈", shown.Prompt)
Assert.IsTrue(shown.PromptTargetPos.IsSome)
let left = world |> atPosition 3200.0f 2400.0f
Assert.AreEqual<InteractionAction option>(None, promptTarget left)
let cleared = M5Interaction.refreshPrompt left shown
Assert.AreEqual<string option>(None, cleared.Prompt)
Assert.AreEqual<Vec2 option>(None, cleared.PromptTargetPos)
[<TestMethod>]
member _.WallBlocksBothThePromptAndTheInteraction () =
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.IsFalse(InteractionResolver.hasClearPath wallAvatar wallNpc)
Assert.AreEqual<InteractionAction option>(None, promptTarget world)
Assert.AreEqual<string option>(None, promptText world)
// 隔墙时按 E 不产生世界副作用,面板停在世界层。
let nextWorld, nextView = M5Interaction.apply Interact world M5Interaction.initial
Assert.AreEqual<World>(world, nextWorld)
Assert.AreEqual<M5Panel>(WorldPanel, nextView.Panel)
[<TestMethod>]
member _.OpenLayersIsolateWorldInteractionAndClearThePrompt () =
let world =
Sim.initialWorldN 42UL 4
|> atPosition avatar.X avatar.Y
|> withNpcsAt [ 0, { X = avatar.X + 32.0f; Y = avatar.Y } ]
let panels = [ DialoguePanel; NeedsPanel; ObservationPanel; ChroniclePanel; TaskPanel ]
for panel in panels do
let view =
{ M5Interaction.initial with
Panel = panel
Menu = (if panel = DialoguePanel then Some { Target = NpcId 0; Options = Sim.dialogueOptions } else None) }
Assert.IsFalse(M5Interaction.worldInputAllowed view, sprintf "%A 不得放行世界输入" panel)
let nextWorld, nextView = M5Interaction.apply Interact world view
Assert.AreEqual<World>(world, nextWorld)
Assert.IsTrue((panel = nextView.Panel), sprintf "%A 开启时 Interact 不得改选目标" panel)
Assert.AreEqual<string option>(None, (M5Interaction.refreshPrompt world view).Prompt)
// 世界层里非对话面板命令不得误开对话;买卖键只在对话层生效。
let worldLayer = M5Interaction.initial
Assert.IsFalse(M5Interaction.worldInputAllowed { worldLayer with Panel = TaskPanel })
let _, bought = M5Interaction.apply BuyFromTarget world worldLayer
Assert.AreEqual<string option>(None, bought.Menu |> Option.map (fun m -> m.Target.ToString()))
[<TestMethod>]
member _.EscapeClosesTheCurrentLayerAndRecomputesTheWorldPrompt () =
let world =
Sim.initialWorldN 42UL 4
|> atPosition avatar.X avatar.Y
|> withNpcsAt [ 0, { X = avatar.X + 32.0f; Y = avatar.Y } ]
for panel in [ DialoguePanel; NeedsPanel; ObservationPanel; ChroniclePanel; TaskPanel ] do
let view =
{ M5Interaction.initial with
Panel = panel
Menu = (if panel = DialoguePanel then Some { Target = NpcId 0; Options = Sim.dialogueOptions } else None) }
let _, closed = M5Interaction.apply ClosePanel world view
Assert.IsTrue((closed.Panel = WorldPanel), sprintf "Esc 应关闭 %A" panel)
Assert.IsTrue(closed.Menu.IsNone, sprintf "关闭 %A 后应清空菜单" panel)
Assert.AreEqual<string option>(Some "E 交谈", closed.Prompt)
[<TestMethod>]
member _.PromptTextAlwaysMatchesTheActedTarget () =
// 文案映射唯一来源。
Assert.AreEqual<string>("E 交谈", InteractionResolver.actionText (TalkTo(NpcId 0)))
Assert.AreEqual<string>("E 进屋", InteractionResolver.actionText (EnterHome(HomeId 1)))
Assert.AreEqual<string>("E 出屋", InteractionResolver.actionText (ExitHome(HomeId 1)))
// 多候选中提示 1 号村民 → 按 E 必须作用于同一 1 号村民。
let world =
Sim.initialWorldN 42UL 4
|> atPosition avatar.X avatar.Y
|> withNpcsAt
[ 0, { X = avatar.X + 72.0f; Y = avatar.Y }
1, { X = avatar.X + 40.0f; Y = avatar.Y } ]
let target = InteractionResolver.resolve world Outside
Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 1)), target |> Option.map (fun t -> t.Action))
Assert.AreEqual<string>(InteractionResolver.actionText target.Value.Action, target.Value.Text)
let view = M5Interaction.refreshPrompt world M5Interaction.initial
Assert.AreEqual<string option>(Some target.Value.Text, view.Prompt)
Assert.AreEqual<Vec2 option>(Some world.Npcs.[1].Pos, view.PromptTargetPos)
let _, interacted = M5Interaction.apply Interact world M5Interaction.initial
Assert.AreEqual<M5Panel>(DialoguePanel, interacted.Panel)
Assert.AreEqual<NpcId>(NpcId 1, interacted.Menu.Value.Target)
[<TestMethod>]
member _.PromptLayerIsTransientNotAPersistentPanel () =
// 初始世界层无提示、无面板;靠近只补一条瞬时提示,不新开面板。
Assert.AreEqual<M5Panel>(WorldPanel, M5Interaction.initial.Panel)
Assert.AreEqual<string option>(None, M5Interaction.initial.Prompt)
let world =
Sim.initialWorldN 42UL 4
|> atPosition avatar.X avatar.Y
|> withNpcsAt [ 0, { X = avatar.X + 32.0f; Y = avatar.Y } ]
let shown = M5Interaction.refreshPrompt world M5Interaction.initial
Assert.AreEqual<M5Panel>(WorldPanel, shown.Panel)
Assert.IsTrue(shown.Menu.IsNone)
// 面板一旦开启,提示立即消失,且刷新多帧都保持无提示(非常驻)。
let opened = { shown with Panel = TaskPanel }
for _ in 1 .. 5 do
let refreshed = M5Interaction.refreshPrompt world opened
Assert.AreEqual<string option>(None, refreshed.Prompt)
|