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 关闭当前层、保存反馈。 [] 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) } [] 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( Some(TalkTo(NpcId 0)), InteractionResolver.resolve atBoundary Outside |> Option.map (fun target -> target.Action)) Assert.AreEqual( None, InteractionResolver.resolve beyond Outside |> Option.map (fun target -> target.Action)) [] 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( 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( Some(TalkTo(NpcId 0)), InteractionResolver.resolve switched Outside |> Option.map (fun target -> target.Action)) [] 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(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(None, cleared.Prompt) Assert.AreEqual(None, cleared.PromptTargetPos) [] 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( None, InteractionResolver.resolve world Outside |> Option.map (fun target -> target.Action)) Assert.AreEqual(None, (M5Interaction.refreshPrompt world M5Interaction.initial).Prompt) [] 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, interactWorld) Assert.AreEqual(DialoguePanel, interactView.Panel) Assert.IsTrue(interactView.Menu.IsSome) // Q(Observe)同样被隔离。 let observeWorld, observeView = M5Interaction.apply Observe world dialogueView Assert.AreEqual(world, observeWorld) Assert.AreEqual(DialoguePanel, observeView.Panel) Assert.AreEqual([], observeView.Observations) [] 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(actionText npcTarget.Value.Action, npcTarget.Value.Text) let npcView = M5Interaction.refreshPrompt npcWorld M5Interaction.initial Assert.AreEqual(Some npcTarget.Value.Text, npcView.Prompt) let _, npcInteracted = M5Interaction.apply Interact npcWorld M5Interaction.initial Assert.AreEqual(DialoguePanel, npcInteracted.Panel) Assert.AreEqual(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("E 进屋", doorTarget.Value.Text) Assert.AreEqual(actionText doorTarget.Value.Action, doorTarget.Value.Text) let _, doorInteracted = M5Interaction.apply Interact doorWorld M5Interaction.initial Assert.AreEqual(Inside(HomeId 1), doorInteracted.HomeMode) [] 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) [] member _.PauseHelpAndControlsPageReturnToThePreviousLayerOnEscape () = let control = SimulationControl.initial let playing = MenuState.create control true |> MenuState.enterGame let helpState, paused = MenuState.enterPauseHelp control playing Assert.AreEqual(Controls, helpState.Page) Assert.IsTrue(paused.Paused) Assert.AreEqual(Playing, helpState.ReturnPage) let returnedState, restored = MenuState.exitPauseHelp control helpState Assert.AreEqual(Playing, returnedState.Page) Assert.IsFalse(restored.Paused) let backState, _ = MenuState.update Back helpState Assert.AreEqual(Playing, backState.Page) [] member _.SaveFeedbackUsesATransientChineseToast () = let success = { M5Interaction.initial with Status = "save succeeded" StatusTick = Some 100L } Assert.AreEqual("保存成功", M5Interaction.statusText success) Assert.AreEqual(Some "保存成功", M5Interaction.transientStatusLine 180L success) Assert.AreEqual( None, M5Interaction.transientStatusLine (100L + M5Interaction.statusDisplayDurationTicks) success) let failed = { M5Interaction.initial with Status = "save failed" StatusTick = Some 100L } Assert.AreEqual("保存失败", M5Interaction.statusText failed) Assert.AreEqual(Some "保存失败", M5Interaction.transientStatusLine 120L failed) [] 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)