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。 /// 提示层是瞬时叠加层,绝非常驻面板。 [] 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 [] 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(Some(TalkTo(NpcId 0)), promptTarget atBoundary) Assert.AreEqual("E 交谈", (InteractionResolver.resolve atBoundary Outside).Value.Text) Assert.AreEqual(Some "E 交谈", promptText atBoundary) Assert.AreEqual(None, promptTarget beyond) Assert.AreEqual(None, promptText beyond) [] 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(Some(TalkTo(NpcId 1)), promptTarget world) let view = M5Interaction.refreshPrompt world M5Interaction.initial Assert.AreEqual(Some "E 交谈", view.Prompt) // 仅一个高亮:提示目标位置就是被选中的 1 号村民,别处不出现第二个提示。 Assert.AreEqual(Some world.Npcs.[1].Pos, view.PromptTargetPos) // 走近远端村民:最近目标切换为 0 号,仍然只选一个。 let switched = world |> atPosition (avatar.X + 84.0f) avatar.Y Assert.AreEqual(Some(TalkTo(NpcId 0)), promptTarget switched) [] 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(Some(TalkTo(NpcId 0)), promptTarget world) [] 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(Some "E 交谈", shown.Prompt) Assert.IsTrue(shown.PromptTargetPos.IsSome) let left = world |> atPosition 3200.0f 2400.0f Assert.AreEqual(None, promptTarget left) let cleared = M5Interaction.refreshPrompt left shown Assert.AreEqual(None, cleared.Prompt) Assert.AreEqual(None, cleared.PromptTargetPos) [] 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(None, promptTarget world) Assert.AreEqual(None, promptText world) // 隔墙时按 E 不产生世界副作用,面板停在世界层。 let nextWorld, nextView = M5Interaction.apply Interact world M5Interaction.initial Assert.AreEqual(world, nextWorld) Assert.AreEqual(WorldPanel, nextView.Panel) [] 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, nextWorld) Assert.IsTrue((panel = nextView.Panel), sprintf "%A 开启时 Interact 不得改选目标" panel) Assert.AreEqual(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(None, bought.Menu |> Option.map (fun m -> m.Target.ToString())) [] 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(Some "E 交谈", closed.Prompt) [] member _.PromptTextAlwaysMatchesTheActedTarget () = // 文案映射唯一来源。 Assert.AreEqual("E 交谈", InteractionResolver.actionText (TalkTo(NpcId 0))) Assert.AreEqual("E 进屋", InteractionResolver.actionText (EnterHome(HomeId 1))) Assert.AreEqual("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(Some(TalkTo(NpcId 1)), target |> Option.map (fun t -> t.Action)) Assert.AreEqual(InteractionResolver.actionText target.Value.Action, target.Value.Text) let view = M5Interaction.refreshPrompt world M5Interaction.initial Assert.AreEqual(Some target.Value.Text, view.Prompt) Assert.AreEqual(Some world.Npcs.[1].Pos, view.PromptTargetPos) let _, interacted = M5Interaction.apply Interact world M5Interaction.initial Assert.AreEqual(DialoguePanel, interacted.Panel) Assert.AreEqual(NpcId 1, interacted.Menu.Value.Target) [] member _.PromptLayerIsTransientNotAPersistentPanel () = // 初始世界层无提示、无面板;靠近只补一条瞬时提示,不新开面板。 Assert.AreEqual(WorldPanel, M5Interaction.initial.Panel) Assert.AreEqual(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(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(None, refreshed.Prompt)