From ea3ac4bfd06c86d0157177f7ba015edec4ddb2a8 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Mon, 28 Sep 2026 21:51:35 +0200 Subject: [PATCH 01/18] feat(control): nearby reports the window pixel of each character A scenario can now click an NPC or a player the way a player does: `nearby` gives each player, monster and NPC a `pixel` at the middle of the box the mouse picks it by, or null while it is not drawn. The projection uses the world camera as it is when the mouse ray is cast (Frames::RecordWorldCamera in MainScene), because later in the frame other cameras draw the item previews; g_Camera at the time of `state` put every character at the same depth. Clicking the pixel of the vault keeper in Lorencia opens the vault. --- docs/control-socket.md | 7 +++- src/source/App/Control/ControlState.cpp | 55 +++++++++++++++++++++++++ src/source/App/Control/ControlTaps.h | 14 +++++++ src/source/Scenes/MainScene.cpp | 2 + 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/docs/control-socket.md b/docs/control-socket.md index 9551395ee4..ffd593cf3a 100644 --- a/docs/control-socket.md +++ b/docs/control-socket.md @@ -104,7 +104,12 @@ Each `nearby` object carries `id`, `kind`, `name`, `position`, and a player, monster or NPC also `alive`, `level` and `hp_percent`. `hp_percent` is a percentage of full health (`100` is untouched) and is `null` when the server has not told the client that object's health — a threshold test has to allow -for the null rather than read it as zero. +for the null rather than read it as zero. A player, monster or NPC also +carries `pixel`, the window pixel `{x, y}` at the middle of the box the mouse +picks it by, or `null` while it is not drawn: `click-ui` there talks to an NPC +or targets a player the way a player's click does. The pixel is taken from the +last rendered frame, so it lags a moving object by a frame, and a window drawn +over it catches the click instead. ### Synthetic input diff --git a/src/source/App/Control/ControlState.cpp b/src/source/App/Control/ControlState.cpp index 51c265564c..d8b478d44d 100644 --- a/src/source/App/Control/ControlState.cpp +++ b/src/source/App/Control/ControlState.cpp @@ -2,6 +2,8 @@ #include "App/Control/ControlState.h" #include "App/Control/ControlObjects.h" +#include "App/Control/ControlTaps.h" +#include "Camera/CameraState.h" #include "Core/Text/Utf8.h" #include "Engine/Object/ZzzCharacter.h" @@ -201,6 +203,49 @@ json BuffArray() } return buffs; } +// The world camera of the last frame, see App::Control::Frames::RecordWorldCamera. +CameraState g_worldCamera; +bool g_hasWorldCamera = false; + +// Where a character is drawn, in window pixels: the middle of the box the +// mouse picks it by (Input/Selection.cpp), projected as the mouse ray is cast +// (CameraProjection::ScreenToWorldRay), so `click-ui` there points at it. Null +// while it is not drawn, behind the camera or outside the window. +json ObjectPixel(const OBJECT& object) +{ + if (!g_hasWorldCamera || !object.Visible) + { + return nullptr; + } + + const OBB_t& box = object.OBB; + vec3_t center; + for (int axis = 0; axis < 3; ++axis) + { + center[axis] = box.StartPos[axis] + (box.XAxis[axis] + box.YAxis[axis] + box.ZAxis[axis]) * 0.5f; + } + + vec3_t camera; + VectorTransform(center, g_worldCamera.Matrix, camera); + if (camera[2] >= 0.0f) + { + return nullptr; + } + + const float x = + static_cast(g_worldCamera.ScreenCenterX) - camera[0] / (g_worldCamera.PerspectiveX * camera[2]); + const float y = + static_cast(g_worldCamera.ScreenCenterY) + camera[1] / (g_worldCamera.PerspectiveY * camera[2]); + if (x < 0.0f || y < 0.0f || x >= static_cast(WindowWidth) || y >= static_cast(WindowHeight)) + { + return nullptr; + } + + json pixel; + pixel["x"] = std::round(x); + pixel["y"] = std::round(y); + return pixel; +} } // namespace namespace App::Control @@ -284,6 +329,7 @@ json NearbyArray() // which is what the `float` division left behind). const double healthPercent = std::round(character.HealthStatus * 250.0f) * 100.0 / 250.0; described["hp_percent"] = character.HealthStatus < 0.0f ? json(nullptr) : json(healthPercent); + described["pixel"] = ObjectPixel(character.Object); nearby.push_back(std::move(described)); } @@ -309,3 +355,12 @@ json NearbyArray() return nearby; } } // namespace App::Control + +namespace App::Control::Frames +{ +void RecordWorldCamera() +{ + g_worldCamera = g_Camera; + g_hasWorldCamera = true; +} +} // namespace App::Control::Frames diff --git a/src/source/App/Control/ControlTaps.h b/src/source/App/Control/ControlTaps.h index 15515b10db..0232ff78ff 100644 --- a/src/source/App/Control/ControlTaps.h +++ b/src/source/App/Control/ControlTaps.h @@ -77,6 +77,15 @@ void RecordTradePartnerConfirm(int state); void RecordTradeClosed(int result); } // namespace App::Control::Events +namespace App::Control::Frames +{ +// The world camera of this frame, kept where the mouse ray is cast +// (Scenes/MainScene.cpp). Later in the frame other cameras draw the item +// previews, so `nearby` projects characters with this copy, the way the mouse +// picks them. +void RecordWorldCamera(); +} // namespace App::Control::Frames + #else #include @@ -104,4 +113,9 @@ inline void RecordTradePartnerConfirm(int) {} inline void RecordTradeClosed(int) {} } // namespace App::Control::Events +namespace App::Control::Frames +{ +inline void RecordWorldCamera() {} +} // namespace App::Control::Frames + #endif // MU_ENABLE_CONTROL_SOCKET diff --git a/src/source/Scenes/MainScene.cpp b/src/source/Scenes/MainScene.cpp index c90a47e18e..1f68587a03 100644 --- a/src/source/Scenes/MainScene.cpp +++ b/src/source/Scenes/MainScene.cpp @@ -3,6 +3,7 @@ /////////////////////////////////////////////////////////////////////////////// #include "stdafx.h" +#include "App/Control/ControlTaps.h" #include "Engine/Object/EditObjects.h" #include "UI/Chat/Chat.h" #include "MainScene.h" @@ -384,6 +385,7 @@ static void SetupMainSceneViewport(int& outWidth, int& outHeight, BYTE& outByWat // Don't disable fog - let BeginOpengl() handle it based on FogEnable } CameraProjection::ScreenToWorldRay(g_Camera, MouseX, MouseY, MouseTarget); + App::Control::Frames::RecordWorldCamera(); } // DXP-23 diagnostic toggle -- see MainScene.h's SetDisableEffects() doc comment. From 6554ea9e357d6956b1e64b45516371133f8d02ca Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Mon, 28 Sep 2026 22:09:37 +0200 Subject: [PATCH 02/18] fix(editor): the real pointer no longer blocks injected keys and clicks The editor drops the frame's Enter and mouse buttons while ImGui wants the mouse, and the left button while the real pointer rests on the "Open Editor" button; the chat box does not open on Enter then either. ImGui follows the real pointer, so a player's mouse over a test client's window could swallow a key or click the control socket injected. Those never went through ImGui: the checks now let them pass (Synthetic::IsIdle, a constant true without the control socket). --- src/MuEditor/Core/MuEditorCore.cpp | 8 ++++++-- src/MuEditor/Core/MuInputBlockerCore.cpp | 7 +++++-- src/source/Core/Input/SyntheticInput.h | 11 ++++++++--- src/source/UI/NewUI/Widgets/NewUIChatInputBox.cpp | 7 +++++-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/MuEditor/Core/MuEditorCore.cpp b/src/MuEditor/Core/MuEditorCore.cpp index a62044a65c..1d26d0b064 100644 --- a/src/MuEditor/Core/MuEditorCore.cpp +++ b/src/MuEditor/Core/MuEditorCore.cpp @@ -7,6 +7,7 @@ #include "imgui_impl_sdl3.h" #include "imgui_impl_sdlgpu3.h" #include "MuInputBlockerCore.h" +#include "Core/Input/SyntheticInput.h" #include "../Config/MuEditorConfig.h" #include "../MuEditor/UI/Common/MuEditorCenterPaneUI.h" #include "../MuEditor/UI/ItemEditor/MuItemEditorUI.h" @@ -499,8 +500,11 @@ void CMuEditorCore::Update() float buttonWidth = EDITOR_BTN_WIDTH * m_UIScale; float buttonHeight = EDITOR_BTN_HEIGHT * m_UIScale; - if (io.MousePos.x >= buttonX && io.MousePos.x <= (buttonX + buttonWidth) && - io.MousePos.y >= buttonY && io.MousePos.y <= (buttonY + buttonHeight)) + // A click the control socket injects is somewhere else than the real + // pointer, which is what ImGui's mouse position follows. + if (Core::Input::Synthetic::IsIdle() && io.MousePos.x >= buttonX && + io.MousePos.x <= (buttonX + buttonWidth) && io.MousePos.y >= buttonY && + io.MousePos.y <= (buttonY + buttonHeight)) { // Mouse is over button - block game input for this frame extern bool MouseLButton, MouseLButtonPop, MouseLButtonPush, MouseLButtonDBClick; diff --git a/src/MuEditor/Core/MuInputBlockerCore.cpp b/src/MuEditor/Core/MuInputBlockerCore.cpp index 5109979d7d..f1b72ace9a 100644 --- a/src/MuEditor/Core/MuInputBlockerCore.cpp +++ b/src/MuEditor/Core/MuInputBlockerCore.cpp @@ -5,6 +5,7 @@ #include "MuInputBlockerCore.h" #include "imgui.h" +#include "Core/Input/SyntheticInput.h" #include "UI/NewUI/NewUICommon.h" CMuInputBlockerCore& CMuInputBlockerCore::GetInstance() @@ -18,8 +19,10 @@ void CMuInputBlockerCore::ProcessInputBlocking() // Block game input ONLY when hovering UI ImGuiIO& io = ImGui::GetIO(); - // Only block when hovering or when keyboard input is wanted - bool shouldBlockInput = io.WantCaptureMouse || io.WantCaptureKeyboard; + // Only block when hovering or when keyboard input is wanted. A key or + // click the control socket injects never went through ImGui, whatever the + // real pointer hovers. + bool shouldBlockInput = (io.WantCaptureMouse || io.WantCaptureKeyboard) && Core::Input::Synthetic::IsIdle(); if (!shouldBlockInput) return; diff --git a/src/source/Core/Input/SyntheticInput.h b/src/source/Core/Input/SyntheticInput.h index c7d942fb18..9a5839aa35 100644 --- a/src/source/Core/Input/SyntheticInput.h +++ b/src/source/Core/Input/SyntheticInput.h @@ -69,10 +69,15 @@ void BeginFrame(); // flight, which may belong to another command. void Reset(); #else -// Without the control socket there is nothing to inject: the two calls the -// rest of the client makes (the frame advance and the held-key test) compile -// to nothing, exactly as the control taps do, so no call site needs a +// Without the control socket there is nothing to inject: the calls the rest +// of the client makes (the frame advance, the idle and held-key tests) compile +// to constants, exactly as the control taps do, so no call site needs a // conditional and the injector itself is not built into a player client. +[[nodiscard]] inline bool IsIdle() +{ + return true; +} + [[nodiscard]] inline bool IsKeyHeld(int) { return false; diff --git a/src/source/UI/NewUI/Widgets/NewUIChatInputBox.cpp b/src/source/UI/NewUI/Widgets/NewUIChatInputBox.cpp index bcfff497a0..8f0399ab80 100644 --- a/src/source/UI/NewUI/Widgets/NewUIChatInputBox.cpp +++ b/src/source/UI/NewUI/Widgets/NewUIChatInputBox.cpp @@ -1,6 +1,7 @@ #include "stdafx.h" #include "UI/Chat/ChatInput.h" #include "UI/NewUI/Widgets/NewUIChatInputBox.h" +#include "Core/Input/SyntheticInput.h" #include "I18N/All.h" #include "Audio/DSPlaySound.h" @@ -470,8 +471,10 @@ bool SEASON3B::CNewUIChatInputBox::UpdateKeyEvent() if (false == IsVisible() && SEASON3B::IsPress(VK_RETURN)) { #ifdef _EDITOR - // Don't open chat if editor has keyboard focus - if (g_MuEditorCore.IsEnabled()) + // Don't open chat if editor has keyboard focus. An Enter the control + // socket injects never reached the editor, whatever the real pointer + // hovers. + if (g_MuEditorCore.IsEnabled() && !Core::Input::Synthetic::IsKeyHeld(VK_RETURN)) { ImGuiIO& io = ImGui::GetIO(); if (io.WantCaptureKeyboard || io.WantCaptureMouse) From 693e180d9b075ae70734dda0e1faebffae91a82e Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Mon, 28 Sep 2026 22:09:37 +0200 Subject: [PATCH 03/18] feat(tools): chat scenario; shared meeting and trading helpers - `chat` (Player Interactions): two players meet in Lorencia; one opens the chat box with Enter, types a line and sends it with Enter, the other answers the same way, and both lines show in both chat logs. - The walking and trading steps of the trade scenario moved into Meeting and Trading, for the scenarios to come; GameClient has ZenAsync and CountAsync. - Keys.PressUntilAsync presses a key until its effect shows, at most three times: an injected Enter was lost now and then on the second client (the cause is not known yet), and each extra press is noted in the log. - `ui` also reports chat_input, party, command, my_shop and purchase_shop. --- docs/control-socket.md | 2 +- src/source/App/Control/ControlCommandsUi.cpp | 4 +- tools/InGameTests/Clients/GameClient.cs | 12 + tools/InGameTests/Program.cs | 1 + tools/InGameTests/Scenarios/ChatScenario.cs | 125 +++++++++++ tools/InGameTests/Scenarios/Keys.cs | 52 +++++ tools/InGameTests/Scenarios/Meeting.cs | 128 +++++++++++ tools/InGameTests/Scenarios/TestAccounts.cs | 6 + tools/InGameTests/Scenarios/TradeScenario.cs | 225 ++----------------- tools/InGameTests/Scenarios/Trading.cs | 89 ++++++++ 10 files changed, 438 insertions(+), 206 deletions(-) create mode 100644 tools/InGameTests/Scenarios/ChatScenario.cs create mode 100644 tools/InGameTests/Scenarios/Keys.cs create mode 100644 tools/InGameTests/Scenarios/Meeting.cs create mode 100644 tools/InGameTests/Scenarios/Trading.cs diff --git a/docs/control-socket.md b/docs/control-socket.md index ffd593cf3a..9a53581180 100644 --- a/docs/control-socket.md +++ b/docs/control-socket.md @@ -74,7 +74,7 @@ Error codes: `bad_request`, `unknown_command`, `wrong_scene`, `busy`, | `hotkey` (`key`) | press one game key for a frame: `esc`, `i`, `home`, `f1`, … | | `click-ui` (`x`, `y`, `button`) | click a window pixel (`left` by default) | | `type` (`text`) | type text into the text field that has the focus, as the keyboard's text input does, e.g. an amount into the trade's zen box; `not_open` when no field has the focus | -| `ui` | the open item windows by name (`message_box` while a dialog waits for Enter or Esc), and the window pixels of named elements: `trade.confirm`, `trade.zen` | +| `ui` | the open windows by name — `inventory`, `inventory_extension`, `character`, `trade`, `storage`, `storage_extension`, `mix`, `npc_shop`, `lucky_item`, `chat_input`, `party`, `command`, `my_shop`, `purchase_shop`, and `message_box` while a dialog waits for Enter or Esc — and the window pixels of named elements: `trade.confirm`, `trade.zen` | | `slot-pixel` (`grid`, `slot`) | the window pixel of a slot's square: `inventory` and `equipment` (the slot numbers `state` reports), `trade`, `trade_partner`, `storage`, `mix`; `not_open` while that window is closed, `bad_request` for a slot the grid does not have | | `login` (`account`, `password`, `server`) | server selection, credentials, character list | | `select-char` (`name` or `slot`) | enter the world with that character | diff --git a/src/source/App/Control/ControlCommandsUi.cpp b/src/source/App/Control/ControlCommandsUi.cpp index 03b55b59bf..3fbc0cfd07 100644 --- a/src/source/App/Control/ControlCommandsUi.cpp +++ b/src/source/App/Control/ControlCommandsUi.cpp @@ -37,7 +37,9 @@ constexpr NamedWindow Windows[] = { {"character", SEASON3B::INTERFACE_CHARACTER}, {"trade", SEASON3B::INTERFACE_TRADE}, {"storage", SEASON3B::INTERFACE_STORAGE}, {"storage_extension", SEASON3B::INTERFACE_STORAGE_EXT}, {"mix", SEASON3B::INTERFACE_MIXINVENTORY}, {"npc_shop", SEASON3B::INTERFACE_NPCSHOP}, - {"lucky_item", SEASON3B::INTERFACE_LUCKYITEMWND}, + {"lucky_item", SEASON3B::INTERFACE_LUCKYITEMWND}, {"chat_input", SEASON3B::INTERFACE_CHATINPUTBOX}, + {"party", SEASON3B::INTERFACE_PARTY}, {"command", SEASON3B::INTERFACE_COMMAND}, + {"my_shop", SEASON3B::INTERFACE_MYSHOP_INVENTORY}, {"purchase_shop", SEASON3B::INTERFACE_PURCHASESHOP_INVENTORY}, }; // A point of a window, from its window-local coordinates to window pixels. diff --git a/tools/InGameTests/Clients/GameClient.cs b/tools/InGameTests/Clients/GameClient.cs index b33db9e875..895aaeb8d0 100644 --- a/tools/InGameTests/Clients/GameClient.cs +++ b/tools/InGameTests/Clients/GameClient.cs @@ -115,6 +115,18 @@ public async Task WarpAsync(string gate, int map) /// The character and everything around it. public Task StateAsync() => this.SendAsync("state"); + /// The character's zen. + public async Task ZenAsync() => (await this.StateAsync()).GetProperty("zen").GetInt64(); + + /// + /// How many items named the inventory holds. state lists an + /// item under every square it covers, so the squares are divided by the item's size. + /// + public async Task CountAsync(string name) + => (int)Math.Round(ItemSlots.Of(await this.StateAsync(), "inventory") + .Where(item => item.Name == name) + .Sum(item => 1.0 / (item.Width * item.Height))); + /// The names of the open windows. public async Task> OpenWindowsAsync() { diff --git a/tools/InGameTests/Program.cs b/tools/InGameTests/Program.cs index e9b1df7b57..efba9e0cd8 100644 --- a/tools/InGameTests/Program.cs +++ b/tools/InGameTests/Program.cs @@ -18,6 +18,7 @@ internal static class Program public static readonly Scenario[] AllScenarios = [ new TradeScenario(), + new ChatScenario(), new IcarusFlyingItemTakeOffScenario(), ]; diff --git a/tools/InGameTests/Scenarios/ChatScenario.cs b/tools/InGameTests/Scenarios/ChatScenario.cs new file mode 100644 index 0000000000..dcf0fbc04e --- /dev/null +++ b/tools/InGameTests/Scenarios/ChatScenario.cs @@ -0,0 +1,125 @@ +using MuMain.Tools.InGameTests.Clients; + +namespace MuMain.Tools.InGameTests.Scenarios; + +/// +/// A line typed into the chat box reaches the player standing next to one, and +/// the answer comes back: Enter opens the box, the text is typed, Enter sends it. +/// +internal sealed class ChatScenario : Scenario +{ + private const string First = "first"; + private const string Second = "second"; + + // A free spot in Lorencia's town; normal chat reaches the players in view. + private const string LorenciaGate = "Lorencia"; + private const int LorenciaMap = 0; + private const int MeetingX = 135; + private const int MeetingY = 128; + private const int ChatDistance = 2; + + private static readonly TimeSpan ServerAnswer = TimeSpan.FromSeconds(10); + + public override string Name => "chat"; + + public override string Description => "a line typed into the chat box reaches the player next to one, and the answer comes back"; + + public override ScenarioCategory Category => ScenarioCategory.PlayerInteractions; + + public override IReadOnlyList Roles => [First, Second]; + + public override int StepCount => 7; + + public override async Task RunAsync(ScenarioContext context) + { + var first = context.Client(First); + var second = context.Client(Second); + var firstCharacter = TestAccounts.ChatFirst; + var secondCharacter = TestAccounts.ChatSecond; + var line = $"Hello {secondCharacter.Name}, this is {firstCharacter.Name}"; + var answer = $"Hi {firstCharacter.Name}, {secondCharacter.Name} here"; + + await context.StepAsync( + $"The first player logs in as {firstCharacter.Name}", + $"{firstCharacter.Name} enters the world in Lorencia, its home town.", + () => first.EnterWorldAsync(firstCharacter.Account, firstCharacter.Password, firstCharacter.Name)); + await context.StepAsync( + $"The second player logs in as {secondCharacter.Name}", + $"{secondCharacter.Name} enters the world in Lorencia, somewhere else in the town.", + () => second.EnterWorldAsync(secondCharacter.Account, secondCharacter.Password, secondCharacter.Name)); + await context.StepAsync( + "The first player walks to the meeting spot", + $"{firstCharacter.Name} stands at ({MeetingX},{MeetingY}), a free spot in Lorencia's town, or one tile from it.", + async () => + { + await first.WarpAsync(LorenciaGate, LorenciaMap); + await Meeting.WalkToAsync(first, MeetingX, MeetingY); + }); + await context.StepAsync( + "The second player walks up to the first", + $"{secondCharacter.Name} stands at most {ChatDistance} tiles from {firstCharacter.Name}: normal chat reaches the players in view.", + async () => + { + await second.WarpAsync(LorenciaGate, LorenciaMap); + await Meeting.WalkUpToAsync(second, first, secondCharacter.Name, ChatDistance); + }); + await context.StepAsync( + "The first player opens the chat box with Enter", + "The chat box opens at the bottom of the screen with the cursor in its text field.", + () => OpenChatAsync(context, first)); + await context.StepAsync( + $"The first player types \"{line}\" and sends it with Enter", + $"The chat box closes, and the line shows in both chat logs as said by {firstCharacter.Name}.", + () => SendLineAsync(context, first, firstCharacter.Name, line, [first, second])); + await context.StepAsync( + $"The second player answers \"{answer}\" the same way", + $"Enter opens {secondCharacter.Name}'s chat box, the answer is typed and Enter sends it; it shows in both chat logs " + + $"as said by {secondCharacter.Name}.", + async () => + { + await OpenChatAsync(context, second); + await SendLineAsync(context, second, secondCharacter.Name, answer, [first, second]); + }); + } + + private static Task OpenChatAsync(ScenarioContext context, GameClient client) + => Keys.PressUntilAsync( + context, + client, + "enter", + async () => (await client.OpenWindowsAsync()).Contains("chat_input"), + $"the {client.Role}'s chat box does not open"); + + // Types the line into the open chat box and sends it; every one of + // has to get it from . + private static async Task SendLineAsync( + ScenarioContext context, + GameClient client, + string sender, + string line, + IReadOnlyList hearers) + { + var sequences = new List(); + foreach (var hearer in hearers) + { + sequences.Add(await hearer.LastEventSequenceAsync()); + } + + await client.SendAsync("type", new { text = line }); + // Enter sends the line and closes the box. + await Keys.PressUntilAsync( + context, + client, + "enter", + async () => !(await client.OpenWindowsAsync()).Contains("chat_input"), + $"the {client.Role}'s chat box does not send the line"); + for (var index = 0; index < hearers.Count; index++) + { + await hearers[index].WaitForEventAsync( + "chat", + new Dictionary { ["sender"] = sender, ["text"] = line, ["kind"] = "public" }, + sequences[index], + ServerAnswer); + } + } +} diff --git a/tools/InGameTests/Scenarios/Keys.cs b/tools/InGameTests/Scenarios/Keys.cs new file mode 100644 index 0000000000..bcd8a82d33 --- /dev/null +++ b/tools/InGameTests/Scenarios/Keys.cs @@ -0,0 +1,52 @@ +using MuMain.Tools.InGameTests.Clients; + +namespace MuMain.Tools.InGameTests.Scenarios; + +/// Keys a scenario presses and checks. +internal static class Keys +{ + // A handled key changes what the client shows in the frame it is pressed. + private static readonly TimeSpan Effect = TimeSpan.FromSeconds(2); + private const int Presses = 3; + + /// + /// Presses until holds, at most three times. + /// + /// + /// Now and then an injected key does not reach the window it is meant for + /// (seen with Enter on the second client of a run, about one run in six; + /// the cause is not known yet). A handled key shows at once, e.g. the dialog + /// it answers closes, so a key that shows nothing after a moment was lost and + /// is pressed again: the press never acts twice. Each extra press is noted in + /// the log. + /// + public static async Task PressUntilAsync( + ScenarioContext context, + GameClient client, + string key, + Func> done, + string failure) + { + for (var press = 1; press <= Presses; press++) + { + await client.SendAsync("hotkey", new { key }); + var deadline = DateTime.UtcNow + Effect; + while (DateTime.UtcNow < deadline) + { + if (await done()) + { + return; + } + + await Task.Delay(TimeSpan.FromMilliseconds(100)); + } + + if (press < Presses) + { + context.Note($"{key} on the {client.Role}'s client showed nothing; pressing it again"); + } + } + + throw new ScenarioFailedException(failure); + } +} diff --git a/tools/InGameTests/Scenarios/Meeting.cs b/tools/InGameTests/Scenarios/Meeting.cs new file mode 100644 index 0000000000..5f9ab2438e --- /dev/null +++ b/tools/InGameTests/Scenarios/Meeting.cs @@ -0,0 +1,128 @@ +using MuMain.Tools.InGameTests.Clients; +using MuMain.Tools.InGameTests.Control; + +namespace MuMain.Tools.InGameTests.Scenarios; + +/// +/// Walking characters to each other: trading, a party invitation or a personal +/// shop needs the other player next to one, and chat reaches only the players in view. +/// +internal static class Meeting +{ + /// Walking across a town, see . + public static readonly TimeSpan WalkTimeout = TimeSpan.FromSeconds(120); + + /// + /// Walks to at most tiles from where + /// stands, as the host's client sees it: that client checks the + /// distance of a trade request or a party invitation. + /// + /// + /// A warp to a town lands anywhere in it, and a character that is in the town + /// already starts wherever it stood, so how far each one walks changes from run + /// to run: across the whole town at worst. The walk gets time for that. And + /// move answers within a tile of its target while the walk may still take + /// its last step, so the walker heads for where the host stands now, until the + /// two are next to each other. + /// + /// The walker's own client can also show it a step further than the others see + /// it, when its last step did not reach them. Then the walker walks a few tiles + /// away and comes back: a move onto a tile in reach sends no walk at all. + /// + public static async Task WalkUpToAsync(GameClient walker, GameClient host, string walkerName, int distance = 1) + { + var hostPosition = (X: 0, Y: 0); + var walkerPosition = (X: 0, Y: 0); + (int X, int Y)? seenPosition = null; + await Expect.EventuallyAsync( + async () => + { + hostPosition = await PositionAsync(host); + walkerPosition = await PositionAsync(walker); + seenPosition = await SeenPositionAsync(host, walkerName); + var nextToHost = IsWithin(walkerPosition, hostPosition, distance); + if (nextToHost && seenPosition is { } seen && IsWithin(seen, hostPosition, distance)) + { + return true; + } + + if (nextToHost) + { + await StepAwayAsync(walker, hostPosition); + return false; + } + + await WalkToAsync(walker, hostPosition.X, hostPosition.Y); + return false; + }, + WalkTimeout, + () => $"the {walker.Role} ({walkerPosition.X},{walkerPosition.Y}; the {host.Role}'s client sees it at " + + $"{(seenPosition is { } seen ? $"({seen.X},{seen.Y})" : "no place")}) did not get next to the {host.Role} " + + $"({hostPosition.X},{hostPosition.Y})"); + } + + /// + /// Walks to a tile. The client ends one move after 30 s; a longer walk (across the + /// town, or with a slowly drawing client) goes on with the next one from where the last + /// stopped, until the walk's own time is up. + /// + public static async Task WalkToAsync(GameClient client, int x, int y) + { + var deadline = DateTime.UtcNow + WalkTimeout; + while (true) + { + try + { + await client.SendAsync("move", new { x, y }, WalkTimeout); + return; + } + catch (ControlException exception) when (exception.Error == "timeout" && DateTime.UtcNow < deadline) + { + // Walk on. + } + } + } + + /// Where the client's own character stands. + public static async Task<(int X, int Y)> PositionAsync(GameClient client) + { + var position = (await client.StateAsync()).GetProperty("position"); + return (position[0].GetInt32(), position[1].GetInt32()); + } + + /// Where the client of sees the player , or null. + public static async Task<(int X, int Y)?> SeenPositionAsync(GameClient observer, string name) + { + foreach (var entry in (await observer.StateAsync()).GetProperty("nearby").EnumerateArray()) + { + if (entry.TryGetProperty("name", out var entryName) && entryName.GetString() == name + && entry.TryGetProperty("position", out var position)) + { + return (position[0].GetInt32(), position[1].GetInt32()); + } + } + + return null; + } + + private static bool IsWithin((int X, int Y) position, (int X, int Y) other, int distance) + => Math.Abs(position.X - other.X) <= distance && Math.Abs(position.Y - other.Y) <= distance; + + // Three tiles away from the host, in the first direction that can be walked. + private static async Task StepAwayAsync(GameClient walker, (int X, int Y) hostPosition) + { + const int Away = 3; + foreach (var (dx, dy) in new[] { (Away, 0), (-Away, 0), (0, Away), (0, -Away) }) + { + try + { + await WalkToAsync(walker, hostPosition.X + dx, hostPosition.Y + dy); + return; + } + catch (ControlException exception) when (exception.Error == "no_path") + { + // A wall or a building; try the next direction. + } + } + } +} diff --git a/tools/InGameTests/Scenarios/TestAccounts.cs b/tools/InGameTests/Scenarios/TestAccounts.cs index f46db7c6c9..69543d3dfb 100644 --- a/tools/InGameTests/Scenarios/TestAccounts.cs +++ b/tools/InGameTests/Scenarios/TestAccounts.cs @@ -22,4 +22,10 @@ internal static class TestAccounts /// trade buyer: level 380 Elf. public static readonly TestCharacter TradeBuyer = new("socket", "socket", "socketElf"); + + /// chat: level 51 Dark Knight, at home in Lorencia. + public static readonly TestCharacter ChatFirst = new("test5", "test5", "test5Dk"); + + /// chat: level 61 Dark Knight, at home in Lorencia. + public static readonly TestCharacter ChatSecond = new("test6", "test6", "test6Dk"); } diff --git a/tools/InGameTests/Scenarios/TradeScenario.cs b/tools/InGameTests/Scenarios/TradeScenario.cs index d61ff6d3a0..89f08c6637 100644 --- a/tools/InGameTests/Scenarios/TradeScenario.cs +++ b/tools/InGameTests/Scenarios/TradeScenario.cs @@ -1,5 +1,4 @@ using MuMain.Tools.InGameTests.Clients; -using MuMain.Tools.InGameTests.Control; namespace MuMain.Tools.InGameTests.Scenarios; @@ -19,18 +18,11 @@ internal sealed class TradeScenario : Scenario private const int LorenciaMap = 0; private const int MeetingX = 135; private const int MeetingY = 128; - private const int TradeDistance = 1; // What the buyer pays: a number that stands out in the screenshots. private const int ZenOffer = 123456; private static readonly TimeSpan ServerAnswer = TimeSpan.FromSeconds(10); - // Walking across Lorencia's town, see WalkUpToAsync. - private static readonly TimeSpan WalkTimeout = TimeSpan.FromSeconds(120); - // After an offer changes, the confirm button waits about 150 frames: some - // seconds normally, half a minute at five frames a second. - private static readonly TimeSpan CooldownWait = TimeSpan.FromSeconds(120); - private static readonly TimeSpan ConfirmWait = TimeSpan.FromSeconds(20); public override string Name => "trade"; @@ -68,27 +60,27 @@ await context.StepAsync( await context.StepAsync( "The seller walks to the meeting spot", $"{sellerCharacter.Name} stands at ({MeetingX},{MeetingY}), a free spot in Lorencia's town, or one tile from it.", - () => WalkToAsync(seller, MeetingX, MeetingY)); + () => Meeting.WalkToAsync(seller, MeetingX, MeetingY)); await context.StepAsync( "The buyer walks up to the seller", $"{buyerCharacter.Name} stands on a tile next to {sellerCharacter.Name}: a trade needs the partner at most one tile away.", - () => WalkUpToAsync(buyer, seller, buyerCharacter.Name)); + () => Meeting.WalkUpToAsync(buyer, seller, buyerCharacter.Name)); var offer = await FindJewelAsync(seller); // What each one has before the trade: the buyer may have such a jewel // already, and the warps have cost zen. - var sellerHadJewels = await CountAsync(seller, offer.Name); - var buyerHadJewels = await CountAsync(buyer, offer.Name); - var sellerHadZen = await ZenAsync(seller); - var buyerHadZen = await ZenAsync(buyer); + var sellerHadJewels = await seller.CountAsync(offer.Name); + var buyerHadJewels = await buyer.CountAsync(offer.Name); + var sellerHadZen = await seller.ZenAsync(); + var buyerHadZen = await buyer.ZenAsync(); await context.StepAsync( "The seller asks the buyer for a trade", $"{buyerCharacter.Name} gets the request: a dialog asks whether to trade with {sellerCharacter.Name}.", - () => RequestTradeAsync(seller, buyer, buyerCharacter.Name)); + () => Trading.RequestAsync(seller, buyer, buyerCharacter.Name)); await context.StepAsync( "The buyer accepts with Enter", "The trade window opens for both characters, each with the inventory next to it.", - () => AcceptTradeAsync(seller, buyer)); + () => Trading.AcceptAsync(context, seller, buyer)); await context.StepAsync( $"The seller puts '{offer.Name}' into the trade window with two clicks", $"The first click picks '{offer.Name}' up from inventory slot {offer.Slot}, the second puts it into the first square of the " @@ -114,10 +106,15 @@ await Expect.EventuallyAsync( ServerAnswer, "the zen box does not open"); await buyer.SendAsync("type", new { text = ZenOffer.ToString(System.Globalization.CultureInfo.InvariantCulture) }); - await buyer.SendAsync("hotkey", new { key = "enter" }); + await Keys.PressUntilAsync( + context, + buyer, + "enter", + async () => !(await buyer.OpenWindowsAsync()).Contains("message_box"), + "the zen box does not take Enter"); var seen = 0; await Expect.EventuallyAsync( - async () => (seen = TradeZen(await seller.StateAsync(), "partner_zen")) == ZenOffer, + async () => (seen = Trading.Zen(await seller.StateAsync(), "partner_zen")) == ZenOffer, ServerAnswer, () => $"the seller sees {seen} zen in the buyer's offer, not {ZenOffer}"); }); @@ -125,7 +122,7 @@ await context.StepAsync( "The seller presses the confirm button", "The seller's confirm button stays pressed. Right after the offer changed it ignores clicks for a moment, so it is " + "pressed until it counts.", - () => ConfirmAsync(seller)); + () => Trading.ConfirmAsync(seller)); var buyerSequence = await buyer.LastEventSequenceAsync(); await context.StepAsync( @@ -133,7 +130,7 @@ await context.StepAsync( "Both have confirmed, so the trade completes and its window closes on both sides.", async () => { - await ConfirmAsync(buyer); + await Trading.ConfirmAsync(buyer); await buyer.WaitForEventAsync("trade", new Dictionary { ["change"] = "closed", ["result"] = "completed" }, buyerSequence, ServerAnswer); }); await context.StepAsync( @@ -149,11 +146,11 @@ await context.StepAsync( var buyerHas = 0; var sellerHas = 0; await Expect.EventuallyAsync( - async () => (buyerHas = await CountAsync(buyer, offer.Name)) == buyerHadJewels + 1, + async () => (buyerHas = await buyer.CountAsync(offer.Name)) == buyerHadJewels + 1, ServerAnswer, () => $"the buyer has {buyerHas} '{offer.Name}' after the trade, not {buyerHadJewels + 1}"); await Expect.EventuallyAsync( - async () => (sellerHas = await CountAsync(seller, offer.Name)) == sellerHadJewels - 1, + async () => (sellerHas = await seller.CountAsync(offer.Name)) == sellerHadJewels - 1, ServerAnswer, () => $"the seller has {sellerHas} '{offer.Name}' after the trade, not {sellerHadJewels - 1}"); var sellerInventory = ItemSlots.Of(await seller.StateAsync(), "inventory"); @@ -161,200 +158,20 @@ await Expect.EventuallyAsync( var buyerZen = 0L; var sellerZen = 0L; await Expect.EventuallyAsync( - async () => (buyerZen = await ZenAsync(buyer)) == buyerHadZen - ZenOffer, + async () => (buyerZen = await buyer.ZenAsync()) == buyerHadZen - ZenOffer, ServerAnswer, () => $"the buyer has {buyerZen:N0} zen after the trade, not {buyerHadZen - ZenOffer:N0}"); await Expect.EventuallyAsync( - async () => (sellerZen = await ZenAsync(seller)) == sellerHadZen + ZenOffer, + async () => (sellerZen = await seller.ZenAsync()) == sellerHadZen + ZenOffer, ServerAnswer, () => $"the seller has {sellerZen:N0} zen after the trade, not {sellerHadZen + ZenOffer:N0}"); }); } - private static async Task ZenAsync(GameClient client) => (await client.StateAsync()).GetProperty("zen").GetInt64(); - - // The zen of one side of the open trade; 0 without a trade. - private static int TradeZen(System.Text.Json.JsonElement state, string side) - => state.GetProperty("trade") is { ValueKind: System.Text.Json.JsonValueKind.Object } trade ? trade.GetProperty(side).GetInt32() : 0; - - // How many items named the inventory holds. `state` - // lists an item under every square it covers, so the squares are divided by - // the item's size. - private static async Task CountAsync(GameClient client, string name) - => (int)Math.Round(ItemSlots.Of(await client.StateAsync(), "inventory") - .Where(item => item.Name == name) - .Sum(item => 1.0 / (item.Width * item.Height))); - - // Walks the buyer next to where the seller stands, as the seller's client - // sees it: that client checks the distance of a trade request. - // - // A warp to a town lands anywhere in it, and a character that is in Lorencia - // already starts wherever it stood, so how far each one walks changes from run - // to run: across the whole town at worst. The walk gets time for that. And - // `move` answers within a tile of its target while the walk may still take - // its last step, so the buyer heads for where the seller stands now, until - // the two are next to each other. - // - // The buyer's own client can also show it a step further than the others see - // it, when its last step did not reach them. Then the buyer walks a few tiles - // away and comes back: a `move` onto a tile in reach sends no walk at all. - private static async Task WalkUpToAsync(GameClient buyer, GameClient seller, string buyerName) - { - var sellerPosition = (X: 0, Y: 0); - var buyerPosition = (X: 0, Y: 0); - (int X, int Y)? seenPosition = null; - await Expect.EventuallyAsync( - async () => - { - sellerPosition = await PositionAsync(seller); - buyerPosition = await PositionAsync(buyer); - seenPosition = await SeenPositionAsync(seller, buyerName); - var nextToSeller = IsNextTo(buyerPosition, sellerPosition); - if (nextToSeller && seenPosition is { } seen && IsNextTo(seen, sellerPosition)) - { - return true; - } - - if (nextToSeller) - { - await StepAwayAsync(buyer, sellerPosition); - return false; - } - - await WalkToAsync(buyer, sellerPosition.X, sellerPosition.Y); - return false; - }, - WalkTimeout, - () => $"the buyer ({buyerPosition.X},{buyerPosition.Y}; the seller's client sees it at " - + $"{(seenPosition is { } seen ? $"({seen.X},{seen.Y})" : "no place")}) did not get next to the seller " - + $"({sellerPosition.X},{sellerPosition.Y})"); - } - - // The client ends one `move` after 30 s; a longer walk (across the town, or - // with a slowly drawing client) goes on with the next one from where the - // last stopped, until the walk's own time is up. - private static async Task WalkToAsync(GameClient client, int x, int y) - { - var deadline = DateTime.UtcNow + WalkTimeout; - while (true) - { - try - { - await client.SendAsync("move", new { x, y }, WalkTimeout); - return; - } - catch (ControlException exception) when (exception.Error == "timeout" && DateTime.UtcNow < deadline) - { - // Walk on. - } - } - } - - private static bool IsNextTo((int X, int Y) position, (int X, int Y) other) - => Math.Abs(position.X - other.X) <= TradeDistance && Math.Abs(position.Y - other.Y) <= TradeDistance; - - // Three tiles away from the seller, in the first direction that can be walked. - private static async Task StepAwayAsync(GameClient buyer, (int X, int Y) sellerPosition) - { - const int Away = 3; - foreach (var (dx, dy) in new[] { (Away, 0), (-Away, 0), (0, Away), (0, -Away) }) - { - try - { - await WalkToAsync(buyer, sellerPosition.X + dx, sellerPosition.Y + dy); - return; - } - catch (ControlException exception) when (exception.Error == "no_path") - { - // A wall or a building; try the next direction. - } - } - } - - private static async Task<(int X, int Y)> PositionAsync(GameClient client) - { - var position = (await client.StateAsync()).GetProperty("position"); - return (position[0].GetInt32(), position[1].GetInt32()); - } - - // Where the client of sees the player . - private static async Task<(int X, int Y)?> SeenPositionAsync(GameClient observer, string name) - { - foreach (var entry in (await observer.StateAsync()).GetProperty("nearby").EnumerateArray()) - { - if (entry.TryGetProperty("name", out var entryName) && entryName.GetString() == name - && entry.TryGetProperty("position", out var position)) - { - return (position[0].GetInt32(), position[1].GetInt32()); - } - } - - return null; - } - private static async Task FindJewelAsync(GameClient client) { var inventory = ItemSlots.Of(await client.StateAsync(), "inventory"); return inventory.FirstOrDefault(item => item.Name.StartsWith("Jewel of", StringComparison.Ordinal)) ?? throw new ScenarioFailedException("the seller has no jewel to trade; the OpenMU test data gives test300Dl some"); } - - // Setup: the request is a direct command; accepting it is the Enter key on the dialog. - private static async Task RequestTradeAsync(GameClient seller, GameClient buyer, string buyerName) - { - var buyerSequence = await buyer.LastEventSequenceAsync(); - // The seller's client learns where the buyer stands from the server, a - // moment after the buyer's own; until then it refuses the request as too far. - await Expect.EventuallyAsync( - async () => - { - try - { - await seller.SendAsync("trade", new { action = "request", target = buyerName }); - return true; - } - catch (ControlException exception) when (exception.Error == "not_allowed") - { - return false; - } - }, - ServerAnswer, - "the seller's client does not see the buyer next to it"); - await buyer.WaitForEventAsync("trade", new Dictionary { ["change"] = "requested" }, buyerSequence, ServerAnswer); - // Enter only reaches the dialog once it is on screen; before that it opens the chat. - await Expect.EventuallyAsync( - async () => (await buyer.OpenWindowsAsync()).Contains("message_box"), - ServerAnswer, - "the buyer sees no trade request dialog"); - } - - private static async Task AcceptTradeAsync(GameClient seller, GameClient buyer) - { - var sellerSequence = await seller.LastEventSequenceAsync(); - var buyerSequence = await buyer.LastEventSequenceAsync(); - await buyer.SendAsync("hotkey", new { key = "enter" }); - await seller.WaitForEventAsync("trade", new Dictionary { ["change"] = "opened" }, sellerSequence, ServerAnswer); - await buyer.WaitForEventAsync("trade", new Dictionary { ["change"] = "opened" }, buyerSequence, ServerAnswer); - } - - // After an offer changes the button ignores clicks for some frames; how long - // that takes depends on the frame rate, so it waits for the button, then - // presses it until the press counts. - private static async Task ConfirmAsync(GameClient client) - { - await Expect.EventuallyAsync( - async () => (await client.StateAsync()).GetProperty("trade") is not { ValueKind: System.Text.Json.JsonValueKind.Object } trade - || trade.GetProperty("my_confirm_wait").GetInt32() <= 0, - CooldownWait, - $"the {client.Role}'s confirm button does not take clicks again"); - await Expect.EventuallyAsync( - async () => - { - await client.ClickElementAsync("trade.confirm"); - var trade = (await client.StateAsync()).GetProperty("trade"); - return trade.ValueKind != System.Text.Json.JsonValueKind.Object || trade.GetProperty("my_confirmed").GetBoolean(); - }, - ConfirmWait, - $"the {client.Role}'s confirm button does not stay pressed"); - } } diff --git a/tools/InGameTests/Scenarios/Trading.cs b/tools/InGameTests/Scenarios/Trading.cs new file mode 100644 index 0000000000..f2ddb68aae --- /dev/null +++ b/tools/InGameTests/Scenarios/Trading.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using MuMain.Tools.InGameTests.Clients; +using MuMain.Tools.InGameTests.Control; + +namespace MuMain.Tools.InGameTests.Scenarios; + +/// The steps of a trade between two clients that every trade scenario takes. +internal static class Trading +{ + private static readonly TimeSpan ServerAnswer = TimeSpan.FromSeconds(10); + // After an offer changes, the confirm button waits about 150 frames: some + // seconds normally, half a minute at five frames a second. + private static readonly TimeSpan CooldownWait = TimeSpan.FromSeconds(120); + private static readonly TimeSpan ConfirmWait = TimeSpan.FromSeconds(20); + + /// + /// Setup: the request is a direct command. Waits until 's client + /// shows the request's dialog, which accepting it with Enter needs. + /// + public static async Task RequestAsync(GameClient requester, GameClient partner, string partnerName) + { + var partnerSequence = await partner.LastEventSequenceAsync(); + // The requester's client learns where the partner stands from the server, a + // moment after the partner's own; until then it refuses the request as too far. + await Expect.EventuallyAsync( + async () => + { + try + { + await requester.SendAsync("trade", new { action = "request", target = partnerName }); + return true; + } + catch (ControlException exception) when (exception.Error == "not_allowed") + { + return false; + } + }, + ServerAnswer, + $"the {requester.Role}'s client does not see the {partner.Role} next to it"); + await partner.WaitForEventAsync("trade", new Dictionary { ["change"] = "requested" }, partnerSequence, ServerAnswer); + // Enter only reaches the dialog once it is on screen; before that it opens the chat. + await Expect.EventuallyAsync( + async () => (await partner.OpenWindowsAsync()).Contains("message_box"), + ServerAnswer, + $"the {partner.Role} sees no trade request dialog"); + } + + /// The partner accepts with Enter; the trade window opens on both sides. + public static async Task AcceptAsync(ScenarioContext context, GameClient requester, GameClient partner) + { + var requesterSequence = await requester.LastEventSequenceAsync(); + var partnerSequence = await partner.LastEventSequenceAsync(); + await Keys.PressUntilAsync( + context, + partner, + "enter", + async () => !(await partner.OpenWindowsAsync()).Contains("message_box"), + $"the {partner.Role}'s trade request dialog does not take Enter"); + await requester.WaitForEventAsync("trade", new Dictionary { ["change"] = "opened" }, requesterSequence, ServerAnswer); + await partner.WaitForEventAsync("trade", new Dictionary { ["change"] = "opened" }, partnerSequence, ServerAnswer); + } + + /// + /// Presses the confirm button. After an offer changes the button ignores clicks for some + /// frames; how long that takes depends on the frame rate, so it waits for the button, + /// then presses it until the press counts. + /// + public static async Task ConfirmAsync(GameClient client) + { + await Expect.EventuallyAsync( + async () => (await client.StateAsync()).GetProperty("trade") is not { ValueKind: JsonValueKind.Object } trade + || trade.GetProperty("my_confirm_wait").GetInt32() <= 0, + CooldownWait, + $"the {client.Role}'s confirm button does not take clicks again"); + await Expect.EventuallyAsync( + async () => + { + await client.ClickElementAsync("trade.confirm"); + var trade = (await client.StateAsync()).GetProperty("trade"); + return trade.ValueKind != JsonValueKind.Object || trade.GetProperty("my_confirmed").GetBoolean(); + }, + ConfirmWait, + $"the {client.Role}'s confirm button does not stay pressed"); + } + + /// The zen of one side of the open trade (my_zen or partner_zen); 0 without a trade. + public static int Zen(JsonElement state, string side) + => state.GetProperty("trade") is { ValueKind: JsonValueKind.Object } trade ? trade.GetProperty(side).GetInt32() : 0; +} From 491f46e478036cc68fb66df19d8612fc4078e079 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Mon, 28 Sep 2026 22:19:49 +0200 Subject: [PATCH 04/18] feat: npc-shop scenario; the NPC shop and repair buttons in the socket - `npc-shop` (new category NPC Interactions): test7Dk walks up to Hanzo the Blacksmith and talks to him with a click on him; drags a potion stack onto his shop (sold at once) and a Jewel of Bless (a dialog asks, Enter confirms), each for the selling price its tooltip shows; then buys the cheapest of his goods that fits, for its price. - slot-pixel grid `npc_shop`; `ui` elements inventory.repair, inventory.my_shop, npc_shop.repair and npc_shop.repair_all while shown. - state: `npc_shop` (repair_shop, tax_rate, goods with their price, tax included), `repair_mode`, `max_durability` per item, and `sell_price` per inventory item while an NPC shop is open. - CNewUIMyInventory has one check for when its repair and shop buttons show (render and click used two copies); CNewUINPCShop exposes its grid and repair buttons. - Npcs (walk up to an NPC, talk with a click on its pixel) and ItemSlots.FreeArea, shared with the Icarus scenario. --- src/source/App/Control/ControlCommandsUi.cpp | 25 ++- src/source/App/Control/ControlState.cpp | 45 +++- .../UI/NewUI/Inventory/NewUIMyInventory.cpp | 37 ++-- .../UI/NewUI/Inventory/NewUIMyInventory.h | 8 + src/source/UI/NewUI/NPCs/NewUINPCShop.h | 14 ++ tools/InGameTests/Clients/ItemSlots.cs | 31 +++ tools/InGameTests/Program.cs | 1 + .../IcarusFlyingItemTakeOffScenario.cs | 28 +-- .../InGameTests/Scenarios/NpcShopScenario.cs | 207 ++++++++++++++++++ tools/InGameTests/Scenarios/Npcs.cs | 79 +++++++ tools/InGameTests/Scenarios/Scenario.cs | 4 + tools/InGameTests/Scenarios/TestAccounts.cs | 6 + 12 files changed, 445 insertions(+), 40 deletions(-) create mode 100644 tools/InGameTests/Scenarios/NpcShopScenario.cs create mode 100644 tools/InGameTests/Scenarios/Npcs.cs diff --git a/src/source/App/Control/ControlCommandsUi.cpp b/src/source/App/Control/ControlCommandsUi.cpp index 3fbc0cfd07..5d34816e1c 100644 --- a/src/source/App/Control/ControlCommandsUi.cpp +++ b/src/source/App/Control/ControlCommandsUi.cpp @@ -67,6 +67,14 @@ json WindowRect(const CNewUIObj& window, const RECT& rect) return pixels; } +// A button's rectangle in its window's coordinates. +RECT ButtonRect(SEASON3B::CNewUIButton& button) +{ + const POINT& position = button.GetPos(); + const POINT& size = button.GetSize(); + return {position.x, position.y, position.x + size.x, position.y + size.y}; +} + // Buttons a scenario presses with `click-ui`, by name. json Elements() { @@ -76,6 +84,19 @@ json Elements() elements["trade.confirm"] = WindowRect(*g_pTrade, g_pTrade->GetMyConfirmRect()); elements["trade.zen"] = WindowRect(*g_pTrade, g_pTrade->GetZenButtonRect()); } + if (SEASON3B::CNewUIButton* repair = g_pMyInventory->GetShownRepairButton()) + { + elements["inventory.repair"] = WindowRect(*g_pMyInventory, ButtonRect(*repair)); + } + if (SEASON3B::CNewUIButton* myShop = g_pMyInventory->GetShownMyShopButton()) + { + elements["inventory.my_shop"] = WindowRect(*g_pMyInventory, ButtonRect(*myShop)); + } + if (g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_NPCSHOP) && g_pNPCShop->IsRepairShop()) + { + elements["npc_shop.repair"] = WindowRect(*g_pNPCShop, ButtonRect(g_pNPCShop->GetRepairButton())); + elements["npc_shop.repair_all"] = WindowRect(*g_pNPCShop, ButtonRect(g_pNPCShop->GetRepairAllButton())); + } return elements; } @@ -127,13 +148,15 @@ std::optional NamedGrid(std::string_view name, int slot) return OpenGrid(SEASON3B::INTERFACE_STORAGE, g_pStorageInventory->GetInventoryCtrl()); if (name == "mix") return OpenGrid(SEASON3B::INTERFACE_MIXINVENTORY, g_pMixInventory->GetInventoryCtrl()); + if (name == "npc_shop") + return OpenGrid(SEASON3B::INTERFACE_NPCSHOP, g_pNPCShop->GetInventoryCtrl()); return std::nullopt; } [[nodiscard]] bool IsKnownGrid(std::string_view name) { return name == "inventory" || name == "trade" || name == "trade_partner" || name == "storage" || name == "mix" || - name == "equipment"; + name == "npc_shop" || name == "equipment"; } std::string SquarePixel(const Request& request, CNewUIInventoryCtrl& grid, int slot) diff --git a/src/source/App/Control/ControlState.cpp b/src/source/App/Control/ControlState.cpp index d8b478d44d..eb035f4de0 100644 --- a/src/source/App/Control/ControlState.cpp +++ b/src/source/App/Control/ControlState.cpp @@ -39,6 +39,9 @@ json DescribeItem(const ITEM& item, int slot) described["name"] = ItemName(item); described["level"] = item.Level; described["durability"] = item.Durability; + // What a repair brings the durability back to (a stack's count for potions + // and jewels, which are not repaired). + described["max_durability"] = CalcMaxDurability(&item, &ItemAttribute[item.Type], item.Level); // In inventory squares, so a script can tell whether an item fits somewhere. described["width"] = ItemAttribute[item.Type].Width; described["height"] = ItemAttribute[item.Type].Height; @@ -137,11 +140,47 @@ json InventoryArray() { continue; } - inventory.push_back(DescribeItem(*item, slot)); + json described = DescribeItem(*item, slot); + // While an NPC shop is open, what it pays for the item, as its tooltip shows. + if (g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_NPCSHOP)) + { + described["sell_price"] = ItemValue(const_cast(item), 1); + } + inventory.push_back(std::move(described)); } return inventory; } +// The open NPC shop: whether it repairs, and its goods with the price its +// tooltip shows, tax included; null while no shop is open. +json NpcShopState() +{ + if (!g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_NPCSHOP)) + { + return nullptr; + } + + json shop; + shop["repair_shop"] = g_pNPCShop->IsRepairShop(); + shop["tax_rate"] = g_pNPCShop->GetTaxRate(); + json items = json::array(); + SEASON3B::CNewUIInventoryCtrl* grid = g_pNPCShop->GetInventoryCtrl(); + for (int i = 0; grid != nullptr && i < static_cast(grid->GetNumberOfItems()); ++i) + { + ITEM* item = grid->GetItem(i); + if (item == nullptr) + { + continue; + } + json described = DescribeItem(*item, item->y * grid->GetNumberOfColumn() + item->x); + const int64_t price = ItemValue(item, 0); + described["price"] = price + price * g_pNPCShop->GetTaxRate() / 100; + items.push_back(std::move(described)); + } + shop["items"] = std::move(items); + return shop; +} + json PartyArray() { json members = json::array(); @@ -295,6 +334,10 @@ std::string WorldStateObject() state["buffs"] = BuffArray(); state["party"] = PartyArray(); state["trade"] = TradeState(); + state["npc_shop"] = NpcShopState(); + // Clicks repair items instead of picking them up (the inventory's repair + // button, `L`, or an NPC's repair button). + state["repair_mode"] = g_pMyInventory->GetRepairMode() == SEASON3B::REPAIR_MODE_ON; state["nearby"] = NearbyArray(); return state.dump(); diff --git a/src/source/UI/NewUI/Inventory/NewUIMyInventory.cpp b/src/source/UI/NewUI/Inventory/NewUIMyInventory.cpp index 6c0df5ba1c..abd53e65e9 100644 --- a/src/source/UI/NewUI/Inventory/NewUIMyInventory.cpp +++ b/src/source/UI/NewUI/Inventory/NewUIMyInventory.cpp @@ -1339,13 +1339,7 @@ void CNewUIMyInventory::RenderButtons() { EnableAlphaTest(); - if (g_pNewUISystem->IsVisible(INTERFACE_NPCSHOP) == false - && g_pNewUISystem->IsVisible(INTERFACE_TRADE) == false - && g_pNewUISystem->IsVisible(INTERFACE_DEVILSQUARE) == false - && g_pNewUISystem->IsVisible(INTERFACE_BLOODCASTLE) == false - && g_pNewUISystem->IsVisible(INTERFACE_MIXINVENTORY) == false - && g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_LUCKYITEMWND) == false - && g_pNewUISystem->IsVisible(INTERFACE_STORAGE) == false) + if (AreShopButtonsShown()) { if (m_bRepairEnableLevel == true) { @@ -1591,13 +1585,7 @@ bool CNewUIMyInventory::BtnProcess() return true; } - if (g_pNewUISystem->IsVisible(INTERFACE_NPCSHOP) == false - && g_pNewUISystem->IsVisible(INTERFACE_TRADE) == false - && g_pNewUISystem->IsVisible(INTERFACE_DEVILSQUARE) == false - && g_pNewUISystem->IsVisible(INTERFACE_BLOODCASTLE) == false - && g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_LUCKYITEMWND) == false - && g_pNewUISystem->IsVisible(INTERFACE_MIXINVENTORY) == false - && g_pNewUISystem->IsVisible(INTERFACE_STORAGE) == false) + if (AreShopButtonsShown()) { if (m_bRepairEnableLevel == true && m_BtnRepair.UpdateMouseEvent() == true) { @@ -1724,6 +1712,27 @@ bool CNewUIMyInventory::IsRepairEnableLevel() const return m_bRepairEnableLevel; } +bool CNewUIMyInventory::AreShopButtonsShown() const +{ + return g_pNewUISystem->IsVisible(INTERFACE_NPCSHOP) == false + && g_pNewUISystem->IsVisible(INTERFACE_TRADE) == false + && g_pNewUISystem->IsVisible(INTERFACE_DEVILSQUARE) == false + && g_pNewUISystem->IsVisible(INTERFACE_BLOODCASTLE) == false + && g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_LUCKYITEMWND) == false + && g_pNewUISystem->IsVisible(INTERFACE_MIXINVENTORY) == false + && g_pNewUISystem->IsVisible(INTERFACE_STORAGE) == false; +} + +CNewUIButton* CNewUIMyInventory::GetShownRepairButton() +{ + return IsVisible() && AreShopButtonsShown() && m_bRepairEnableLevel ? &m_BtnRepair : nullptr; +} + +CNewUIButton* CNewUIMyInventory::GetShownMyShopButton() +{ + return IsVisible() && AreShopButtonsShown() && m_bMyShopOpen ? &m_BtnMyShop : nullptr; +} + void CNewUIMyInventory::SetRepairEnableLevel(bool bOver) { m_bRepairEnableLevel = bOver; diff --git a/src/source/UI/NewUI/Inventory/NewUIMyInventory.h b/src/source/UI/NewUI/Inventory/NewUIMyInventory.h index 36799f3e7a..6b5155dbc8 100644 --- a/src/source/UI/NewUI/Inventory/NewUIMyInventory.h +++ b/src/source/UI/NewUI/Inventory/NewUIMyInventory.h @@ -95,6 +95,10 @@ namespace SEASON3B bool m_bRepairEnableLevel; bool m_bMyShopOpen; + // The repair and personal-shop buttons hide while a window that trades + // or stores items is open. + bool AreShopButtonsShown() const; + public: CNewUIMyInventory(); virtual ~CNewUIMyInventory(); @@ -115,6 +119,10 @@ namespace SEASON3B void ResetMouseLButton() override; int FindEmptySlot(ITEM* pItem) const override; bool IsRepairEnableLevel() const override; + // The repair and personal-shop buttons, for the control socket; null + // while the button is not shown. + CNewUIButton* GetShownRepairButton(); + CNewUIButton* GetShownMyShopButton(); bool InsertItem(int iIndex, std::span pbyItemPacket) const; void DeleteItem(int iIndex) const; diff --git a/src/source/UI/NewUI/NPCs/NewUINPCShop.h b/src/source/UI/NewUI/NPCs/NewUINPCShop.h index c7b108c697..3200ff6c8b 100644 --- a/src/source/UI/NewUI/NPCs/NewUINPCShop.h +++ b/src/source/UI/NewUI/NPCs/NewUINPCShop.h @@ -101,6 +101,20 @@ namespace SEASON3B void SetSellingItem(bool bFlag); bool IsSellingItem(); + // For the control socket: the shop's grid and its repair buttons. + CNewUIInventoryCtrl* GetInventoryCtrl() const + { + return m_pNewInventoryCtrl; + } + CNewUIButton& GetRepairButton() + { + return m_BtnRepair; + } + CNewUIButton& GetRepairAllButton() + { + return m_BtnRepairAll; + } + private: void Init(); void SetButtonInfo(); diff --git a/tools/InGameTests/Clients/ItemSlots.cs b/tools/InGameTests/Clients/ItemSlots.cs index 38447ae9d5..f6047421d4 100644 --- a/tools/InGameTests/Clients/ItemSlots.cs +++ b/tools/InGameTests/Clients/ItemSlots.cs @@ -18,9 +18,40 @@ public static IReadOnlyList OfTrade(JsonElement state, string list) ? state.GetProperty("trade").GetProperty(list).EnumerateArray().Select(Read).ToList() : []; + /// The first slot of the main inventory grid; the equipment comes before it. + public const int FirstInventorySlot = 12; + + /// The columns and rows of the main inventory grid. + public const int InventoryColumns = 8; + public const int InventoryRows = 8; + /// The item in , or null. public static ItemSlot? At(IReadOnlyList items, int slot) => items.FirstOrDefault(item => item.Slot == slot); + /// + /// The top-left slot of the first free x + /// area of the main inventory grid, row by row; null when there is none. + /// + public static int? FreeArea(IReadOnlyList inventory, int width, int height) + { + var used = inventory.Select(entry => entry.Slot).ToHashSet(); + for (var row = 0; row + height <= InventoryRows; row++) + { + for (var column = 0; column + width <= InventoryColumns; column++) + { + var fits = Enumerable.Range(0, height) + .SelectMany(dy => Enumerable.Range(0, width).Select(dx => FirstInventorySlot + ((row + dy) * InventoryColumns) + column + dx)) + .All(slot => !used.Contains(slot)); + if (fits) + { + return FirstInventorySlot + (row * InventoryColumns) + column; + } + } + } + + return null; + } + private static ItemSlot Read(JsonElement item) => new( item.GetProperty("slot").GetInt32(), diff --git a/tools/InGameTests/Program.cs b/tools/InGameTests/Program.cs index efba9e0cd8..0e6f7de5a5 100644 --- a/tools/InGameTests/Program.cs +++ b/tools/InGameTests/Program.cs @@ -19,6 +19,7 @@ internal static class Program [ new TradeScenario(), new ChatScenario(), + new NpcShopScenario(), new IcarusFlyingItemTakeOffScenario(), ]; diff --git a/tools/InGameTests/Scenarios/IcarusFlyingItemTakeOffScenario.cs b/tools/InGameTests/Scenarios/IcarusFlyingItemTakeOffScenario.cs index 9aa1e398f9..4b9783a081 100644 --- a/tools/InGameTests/Scenarios/IcarusFlyingItemTakeOffScenario.cs +++ b/tools/InGameTests/Scenarios/IcarusFlyingItemTakeOffScenario.cs @@ -15,7 +15,6 @@ internal sealed class IcarusFlyingItemTakeOffScenario : Scenario private const int HelperSlot = 8; private const string IcarusGate = "Icarus"; private const int IcarusMap = 10; - private const int InventoryColumns = 8; private static readonly TimeSpan ServerAnswer = TimeSpan.FromSeconds(10); // A refused take-off sends nothing, so "still equipped after a while" is the answer. @@ -175,7 +174,7 @@ private static async Task StaysOnAfterDragAsync(GameClient client, ItemSlot item // The first click picks the item up, the second puts it down so that it // covers the free area; an equipment item hangs from the cursor by its middle. await client.ClickSlotAsync("equipment", slot); - await client.DropOnAreaAsync("inventory", freeArea, item.Width, item.Height, InventoryColumns); + await client.DropOnAreaAsync("inventory", freeArea, item.Width, item.Height, ItemSlots.InventoryColumns); await Expect.StillAfterAsync( async () => await EquippedAsync(client, slot) is not null, RefusalWait, @@ -186,28 +185,9 @@ await Expect.StillAfterAsync( // fits . `state` lists an item under every square it // covers, so a square is free when no item is listed on it. private static async Task FreeAreaAsync(GameClient client, ItemSlot item) - { - const int FirstInventorySlot = 12; - const int Columns = InventoryColumns; - const int Rows = 8; - var used = ItemSlots.Of(await client.StateAsync(), "inventory").Select(entry => entry.Slot).ToHashSet(); - for (var row = 0; row + item.Height <= Rows; row++) - { - for (var column = 0; column + item.Width <= Columns; column++) - { - var fits = Enumerable.Range(0, item.Height) - .SelectMany(dy => Enumerable.Range(0, item.Width).Select(dx => FirstInventorySlot + ((row + dy) * Columns) + column + dx)) - .All(slot => !used.Contains(slot)); - if (fits) - { - return FirstInventorySlot + (row * Columns) + column; - } - } - } - - throw new ScenarioFailedException( - $"the inventory has no free {item.Width}x{item.Height} area for '{item.Name}'; a refused take-off would prove nothing"); - } + => ItemSlots.FreeArea(ItemSlots.Of(await client.StateAsync(), "inventory"), item.Width, item.Height) + ?? throw new ScenarioFailedException( + $"the inventory has no free {item.Width}x{item.Height} area for '{item.Name}'; a refused take-off would prove nothing"); // Puts back into unless it is // there: part of the scenario, and what leaves the test account as the diff --git a/tools/InGameTests/Scenarios/NpcShopScenario.cs b/tools/InGameTests/Scenarios/NpcShopScenario.cs new file mode 100644 index 0000000000..e400b5257d --- /dev/null +++ b/tools/InGameTests/Scenarios/NpcShopScenario.cs @@ -0,0 +1,207 @@ +using System.Text.Json; +using MuMain.Tools.InGameTests.Clients; + +namespace MuMain.Tools.InGameTests.Scenarios; + +/// +/// Selling to an NPC's shop and buying from it with clicks: an item dragged onto +/// the shop is sold for the price its tooltip shows, a valuable one after a +/// confirmation, and an item clicked in the shop is bought for its price. +/// +internal sealed class NpcShopScenario : Scenario +{ + private const string Player = "player"; + private const string ShopGrid = "npc_shop"; + private const string ShopWindow = "npc_shop"; + + private const string LorenciaGate = "Lorencia"; + private const int LorenciaMap = 0; + + private const string PotionName = "Large Healing Potion"; + private const string JewelName = "Jewel of Bless"; + + private static readonly TimeSpan ServerAnswer = TimeSpan.FromSeconds(10); + + public override string Name => "npc-shop"; + + public override string Description => "items dragged onto an NPC's shop are sold for their price, an item clicked in it is bought"; + + public override ScenarioCategory Category => ScenarioCategory.NpcInteractions; + + public override IReadOnlyList Roles => [Player]; + + public override int StepCount => 7; + + public override async Task RunAsync(ScenarioContext context) + { + var player = context.Client(Player); + var character = TestAccounts.NpcShopper; + var merchant = Npcs.Hanzo; + + await context.StepAsync( + $"Log in as {character.Name}", + $"{character.Name} enters the world in Lorencia, its home town.", + () => player.EnterWorldAsync(character.Account, character.Password, character.Name)); + await context.StepAsync( + $"Walk up to {merchant.Name}", + $"{character.Name} stands next to {merchant.Name} ({merchant.X},{merchant.Y}), who sells weapons and shields.", + async () => + { + await player.WarpAsync(LorenciaGate, LorenciaMap); + await Npcs.WalkUpToAsync(player, merchant); + }); + await context.StepAsync( + $"Talk to {merchant.Name} with a click on him", + "His shop window opens next to the inventory and lists his goods.", + async () => + { + await Npcs.TalkAsync(player, merchant, ShopWindow); + await Expect.EventuallyAsync( + async () => ShopItems(await player.StateAsync()).Count > 0, + ServerAnswer, + $"{merchant.Name}'s shop lists no goods"); + }); + + var potion = await FindAsync(player, PotionName); + await context.StepAsync( + $"Sell the '{PotionName}' stack: drag it onto the shop with two clicks", + $"The first click picks the potions up from inventory slot {potion.Slot}, the second drops them onto the shop. They are " + + $"sold without a question: they leave the inventory, and the zen goes up by the selling price the tooltip shows " + + $"({potion.SellPrice:N0}).", + () => SellAsync(player, potion)); + + var jewel = await FindAsync(player, JewelName); + await context.StepAsync( + $"Drag a '{JewelName}' onto the shop", + $"A jewel is valuable, so instead of selling it at once a dialog asks whether to sell it; the jewel stays in slot {jewel.Slot}.", + async () => + { + await player.MoveItemAsync("inventory", jewel.Slot, ShopGrid, await FreeShopSquareAsync(player)); + await Expect.EventuallyAsync( + async () => (await player.OpenWindowsAsync()).Contains("message_box"), + ServerAnswer, + "no dialog asks whether to sell the jewel"); + }); + await context.StepAsync( + "Confirm the sale with Enter", + $"The dialog closes and the jewel is sold: slot {jewel.Slot} is empty and the zen goes up by the selling price the " + + $"tooltip shows ({jewel.SellPrice:N0}).", + async () => + { + var zen = await player.ZenAsync(); + await Keys.PressUntilAsync( + context, + player, + "enter", + async () => !(await player.OpenWindowsAsync()).Contains("message_box"), + "the sell dialog does not take Enter"); + await ExpectSoldAsync(player, jewel, zen); + }); + + var goods = await CheapestFittingAsync(player); + await context.StepAsync( + $"Buy '{goods.Name}' with a click on it in the shop", + $"The cheapest of {merchant.Name}'s goods that fits into the inventory ({goods.Width}x{goods.Height} squares) goes into " + + $"the inventory, and the zen goes down by its price ({goods.Price:N0}).", + async () => + { + var zen = await player.ZenAsync(); + var had = await player.CountAsync(goods.Name); + await player.ClickSlotAsync(ShopGrid, goods.Slot); + var has = 0; + await Expect.EventuallyAsync( + async () => (has = await player.CountAsync(goods.Name)) == had + 1, + ServerAnswer, + () => $"the inventory has {has} '{goods.Name}' after buying, not {had + 1}"); + var now = 0L; + await Expect.EventuallyAsync( + async () => (now = await player.ZenAsync()) == zen - goods.Price, + ServerAnswer, + () => $"{now:N0} zen after buying for {goods.Price:N0}, not {zen - goods.Price:N0}"); + }); + } + + // An item of the inventory with what the open shop pays for it. + private sealed record Sellable(int Slot, string Name, long SellPrice); + + // Something the shop sells, where it lies in the shop and what it costs. + private sealed record Goods(int Slot, string Name, int Width, int Height, long Price); + + private static async Task FindAsync(GameClient client, string name) + { + foreach (var item in (await client.StateAsync()).GetProperty("inventory").EnumerateArray()) + { + if (item.GetProperty("name").GetString() == name && item.TryGetProperty("sell_price", out var price)) + { + return new Sellable(item.GetProperty("slot").GetInt32(), name, price.GetInt64()); + } + } + + throw new ScenarioFailedException($"the inventory has no '{name}' to sell; the OpenMU test data gives {TestAccounts.NpcShopper.Name} some"); + } + + // Two clicks: the item is picked up and dropped onto the shop, which buys it. + private static async Task SellAsync(GameClient client, Sellable item) + { + var zen = await client.ZenAsync(); + await client.MoveItemAsync("inventory", item.Slot, ShopGrid, await FreeShopSquareAsync(client)); + await ExpectSoldAsync(client, item, zen); + } + + private static async Task ExpectSoldAsync(GameClient client, Sellable item, long zenBefore) + { + await Expect.EventuallyAsync( + async () => ItemSlots.At(ItemSlots.Of(await client.StateAsync(), "inventory"), item.Slot)?.Name != item.Name, + ServerAnswer, + $"'{item.Name}' is still in slot {item.Slot}"); + var zen = 0L; + await Expect.EventuallyAsync( + async () => (zen = await client.ZenAsync()) == zenBefore + item.SellPrice, + ServerAnswer, + () => $"{zen:N0} zen after selling '{item.Name}' for {item.SellPrice:N0}, not {zenBefore + item.SellPrice:N0}"); + } + + // A square of the shop no goods cover: a drop there sells the item. + private static async Task FreeShopSquareAsync(GameClient client) + { + var used = new HashSet(); + foreach (var goods in ShopItems(await client.StateAsync())) + { + for (var dy = 0; dy < goods.Height; dy++) + { + for (var dx = 0; dx < goods.Width; dx++) + { + used.Add(goods.Slot + (dy * ShopColumns) + dx); + } + } + } + + return Enumerable.Range(0, ShopColumns * ShopRows).Reverse().First(square => !used.Contains(square)); + } + + private const int ShopColumns = 8; + private const int ShopRows = 15; + + private static async Task CheapestFittingAsync(GameClient client) + { + var state = await client.StateAsync(); + var inventory = ItemSlots.Of(state, "inventory"); + return ShopItems(state) + .Where(goods => ItemSlots.FreeArea(inventory, goods.Width, goods.Height) is not null) + .OrderBy(goods => goods.Price) + .FirstOrDefault() + ?? throw new ScenarioFailedException("none of the shop's goods fits into the inventory"); + } + + private static IReadOnlyList ShopItems(JsonElement state) + => state.GetProperty("npc_shop") is { ValueKind: JsonValueKind.Object } shop + ? shop.GetProperty("items").EnumerateArray() + .Select(item => new Goods( + item.GetProperty("slot").GetInt32(), + item.GetProperty("name").GetString() ?? string.Empty, + item.GetProperty("width").GetInt32(), + item.GetProperty("height").GetInt32(), + item.GetProperty("price").GetInt64())) + .ToList() + : []; +} diff --git a/tools/InGameTests/Scenarios/Npcs.cs b/tools/InGameTests/Scenarios/Npcs.cs new file mode 100644 index 0000000000..367f38ffda --- /dev/null +++ b/tools/InGameTests/Scenarios/Npcs.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using MuMain.Tools.InGameTests.Clients; +using MuMain.Tools.InGameTests.Control; + +namespace MuMain.Tools.InGameTests.Scenarios; + +/// An NPC a scenario talks to: its name as the client shows it and where it stands. +internal sealed record Npc(string Name, int X, int Y); + +/// Walking up to NPCs and talking to them the way a player does: with a click on them. +internal static class Npcs +{ + /// Hanzo the Blacksmith in Lorencia: sells weapons and shields, and repairs. + public static readonly Npc Hanzo = new("Hanzo the Blacksmith", 116, 141); + + private static readonly TimeSpan ServerAnswer = TimeSpan.FromSeconds(10); + + // Tiles around an NPC to stand on, nearest first: the NPC's own tile is taken. + private static readonly (int X, int Y)[] Around = + [(0, 2), (2, 0), (0, -2), (-2, 0), (2, 2), (-2, 2), (2, -2), (-2, -2), (0, 3), (3, 0), (0, -3), (-3, 0)]; + + /// Walks to a free tile next to . + public static async Task WalkUpToAsync(GameClient client, Npc npc) + { + foreach (var (dx, dy) in Around) + { + try + { + await Meeting.WalkToAsync(client, npc.X + dx, npc.Y + dy); + return; + } + catch (ControlException exception) when (exception.Error == "no_path") + { + // A wall or the NPC's counter; try the next tile. + } + } + + throw new ScenarioFailedException($"no tile next to {npc.Name} ({npc.X},{npc.Y}) can be walked to"); + } + + /// + /// Talks to with a left click on it, and waits for + /// to open. The click goes to the pixel nearby + /// reports, the middle of the box the mouse picks the NPC by. + /// + public static async Task TalkAsync(GameClient client, Npc npc, string window) + { + var pixel = await PixelAsync(client, npc); + await client.SendAsync("click-ui", new { x = pixel.X, y = pixel.Y, button = "left" }); + await Expect.EventuallyAsync( + async () => (await client.OpenWindowsAsync()).Contains(window), + ServerAnswer, + $"a click on {npc.Name} at ({pixel.X},{pixel.Y}) does not open the {window} window"); + } + + // Where the client draws the NPC; it has to be on screen. + private static async Task<(double X, double Y)> PixelAsync(GameClient client, Npc npc) + { + (double X, double Y)? pixel = null; + await Expect.EventuallyAsync( + async () => + { + foreach (var entry in (await client.StateAsync()).GetProperty("nearby").EnumerateArray()) + { + if (entry.GetProperty("name").GetString() == npc.Name + && entry.TryGetProperty("pixel", out var found) && found.ValueKind == JsonValueKind.Object) + { + pixel = (found.GetProperty("x").GetDouble(), found.GetProperty("y").GetDouble()); + return true; + } + } + + return false; + }, + ServerAnswer, + $"{npc.Name} is not on the {client.Role}'s screen"); + return pixel!.Value; + } +} diff --git a/tools/InGameTests/Scenarios/Scenario.cs b/tools/InGameTests/Scenarios/Scenario.cs index a5b28691e9..3dd92f65b5 100644 --- a/tools/InGameTests/Scenarios/Scenario.cs +++ b/tools/InGameTests/Scenarios/Scenario.cs @@ -9,6 +9,9 @@ internal enum ScenarioCategory /// Players doing something with each other, e.g. trading. PlayerInteractions, + /// A player and an NPC, e.g. buying in a shop. + NpcInteractions, + /// How the game treats a player on its own, e.g. the rules of a map. GameBehaviour, } @@ -19,6 +22,7 @@ internal static class ScenarioCategories public static string DisplayName(this ScenarioCategory category) => category switch { ScenarioCategory.PlayerInteractions => "Player Interactions", + ScenarioCategory.NpcInteractions => "NPC Interactions", ScenarioCategory.GameBehaviour => "Game Behaviour", _ => category.ToString(), }; diff --git a/tools/InGameTests/Scenarios/TestAccounts.cs b/tools/InGameTests/Scenarios/TestAccounts.cs index 69543d3dfb..7ec066c9fe 100644 --- a/tools/InGameTests/Scenarios/TestAccounts.cs +++ b/tools/InGameTests/Scenarios/TestAccounts.cs @@ -28,4 +28,10 @@ internal static class TestAccounts /// chat: level 61 Dark Knight, at home in Lorencia. public static readonly TestCharacter ChatSecond = new("test6", "test6", "test6Dk"); + + /// + /// npc-shop: level 71 Dark Knight in Lorencia with potions and Jewels of Bless to sell, + /// and 25 free squares for what it buys. + /// + public static readonly TestCharacter NpcShopper = new("test7", "test7", "test7Dk"); } From d643c18614984e65cdd818b524a3451c3bf63357 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Mon, 28 Sep 2026 22:25:18 +0200 Subject: [PATCH 05/18] feat: repair scenario; repair prices in the socket's state - `repair` (NPC Interactions): test9Dk opens the inventory, presses its repair button and clicks the worn gloves; then talks to Hanzo the Blacksmith with a click and presses Repair all for the boots. Each repair brings the durability back to its maximum and costs zen; what it cost and what the client showed go into the log. - state: `repair_price` on each worn item while a click would repair it (the tooltip's own ConvertRepairGold), and npc_shop.repair_all_price. The client shows a higher price than OpenMU 0.9.10 charges (gloves 1,700 shown, 1,400 charged; Repair all 720 shown, 620 charged): CalcRepairCost uses 3.5 * sqrt(base) * sqrt(sqrt(base)) where OpenMU's ItemPriceCalculator.CalculateRepairPrice uses 3.0. --- src/source/App/Control/ControlState.cpp | 36 +++- tools/InGameTests/Program.cs | 1 + tools/InGameTests/Scenarios/RepairScenario.cs | 164 ++++++++++++++++++ tools/InGameTests/Scenarios/TestAccounts.cs | 6 + 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 tools/InGameTests/Scenarios/RepairScenario.cs diff --git a/src/source/App/Control/ControlState.cpp b/src/source/App/Control/ControlState.cpp index eb035f4de0..e5e6123b39 100644 --- a/src/source/App/Control/ControlState.cpp +++ b/src/source/App/Control/ControlState.cpp @@ -13,6 +13,7 @@ #include "Engine/Object/ZzzObject.h" #include "Core/Utilities/_GlobalFunctions.h" #include "GameLogic/Items/InventoryUtils.h" +#include "GameLogic/Items/ShopRestrictions.h" #include "Network/Server/WSclient.h" #include "Scenes/SceneCore.h" #include "UI/NewUI/NewUISystem.h" @@ -48,6 +49,31 @@ json DescribeItem(const ITEM& item, int slot) return described; } +// What repairing the item costs, as its tooltip shows while a click would +// repair it: at an NPC that repairs, or in the inventory's repair mode (which +// costs more). Left out for an item at full durability or one that is never +// repaired. +void AddRepairPrice(json& described, const ITEM& item) +{ + const bool atNpc = g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_NPCSHOP) && g_pNPCShop->IsRepairShop(); + const bool selfRepair = g_pMyInventory->GetRepairMode() == SEASON3B::REPAIR_MODE_ON; + ITEM* repaired = const_cast(&item); + if ((!atNpc && !selfRepair) || GameLogic::Items::IsRepairBan(repaired)) + { + return; + } + + const int maxDurability = CalcMaxDurability(&item, &ItemAttribute[item.Type], item.Level); + if (item.Durability >= maxDurability) + { + return; + } + + // The tooltip's own function, which picks the NPC or the self-repair price. + wchar_t text[100] = {}; + described["repair_price"] = ConvertRepairGold(ItemValue(repaired, 2), item.Durability, maxDurability, item.Type, text); +} + // The items of a trade grid, by the grid's own slot numbers. json TradeGridItems(SEASON3B::CNewUIInventoryCtrl* grid) { @@ -125,7 +151,9 @@ json EquipmentArray() { continue; } - equipment.push_back(DescribeItem(item, slot)); + json described = DescribeItem(item, slot); + AddRepairPrice(described, item); + equipment.push_back(std::move(described)); } return equipment; } @@ -146,6 +174,7 @@ json InventoryArray() { described["sell_price"] = ItemValue(const_cast(item), 1); } + AddRepairPrice(described, *item); inventory.push_back(std::move(described)); } return inventory; @@ -163,6 +192,11 @@ json NpcShopState() json shop; shop["repair_shop"] = g_pNPCShop->IsRepairShop(); shop["tax_rate"] = g_pNPCShop->GetTaxRate(); + // What "Repair all" costs, as the shop shows it (worked out every frame). + if (g_pNPCShop->IsRepairShop()) + { + shop["repair_all_price"] = AllRepairGold; + } json items = json::array(); SEASON3B::CNewUIInventoryCtrl* grid = g_pNPCShop->GetInventoryCtrl(); for (int i = 0; grid != nullptr && i < static_cast(grid->GetNumberOfItems()); ++i) diff --git a/tools/InGameTests/Program.cs b/tools/InGameTests/Program.cs index 0e6f7de5a5..3ca43b17a8 100644 --- a/tools/InGameTests/Program.cs +++ b/tools/InGameTests/Program.cs @@ -20,6 +20,7 @@ internal static class Program new TradeScenario(), new ChatScenario(), new NpcShopScenario(), + new RepairScenario(), new IcarusFlyingItemTakeOffScenario(), ]; diff --git a/tools/InGameTests/Scenarios/RepairScenario.cs b/tools/InGameTests/Scenarios/RepairScenario.cs new file mode 100644 index 0000000000..6aa1a866fa --- /dev/null +++ b/tools/InGameTests/Scenarios/RepairScenario.cs @@ -0,0 +1,164 @@ +using System.Text.Json; +using MuMain.Tools.InGameTests.Clients; + +namespace MuMain.Tools.InGameTests.Scenarios; + +/// +/// Repairing worn items: one with the inventory's own repair button, the rest +/// at a blacksmith with "Repair all". Both bring the durability back to its +/// maximum for zen. +/// +internal sealed class RepairScenario : Scenario +{ + private const string Player = "player"; + private const int GlovesSlot = 5; + private const int BootsSlot = 6; + + private const string LorenciaGate = "Lorencia"; + private const int LorenciaMap = 0; + + private static readonly TimeSpan ServerAnswer = TimeSpan.FromSeconds(10); + + public override string Name => "repair"; + + public override string Description => "worn items are repaired with the inventory's repair button and at a blacksmith with Repair all"; + + public override ScenarioCategory Category => ScenarioCategory.NpcInteractions; + + public override IReadOnlyList Roles => [Player]; + + public override int StepCount => 7; + + public override async Task RunAsync(ScenarioContext context) + { + var player = context.Client(Player); + var character = TestAccounts.Repairer; + var blacksmith = Npcs.Hanzo; + + await context.StepAsync( + $"Log in as {character.Name}", + $"{character.Name} enters the world in Lorencia, its home town.", + () => player.EnterWorldAsync(character.Account, character.Password, character.Name)); + + var gloves = await WornAsync(player, GlovesSlot); + var boots = await WornAsync(player, BootsSlot); + await context.StepAsync( + "Open the inventory with I", + $"The inventory shows '{gloves.Name}' ({gloves.Durability} of {gloves.MaxDurability}) and '{boots.Name}' " + + $"({boots.Durability} of {boots.MaxDurability}) worn, their slots tinted.", + () => player.OpenInventoryAsync()); + await context.StepAsync( + "Press the inventory's repair button", + "The repair mode is on: the pointer turns into a hammer, and a click on an item repairs it instead of picking it up.", + async () => + { + await player.ClickElementAsync("inventory.repair"); + await Expect.EventuallyAsync( + async () => (await player.StateAsync()).GetProperty("repair_mode").GetBoolean(), + ServerAnswer, + "the repair mode does not come on"); + }); + + await context.StepAsync( + $"Click '{gloves.Name}' to repair it", + $"Its durability is back at {gloves.MaxDurability}, and zen went down by the repair price; a repair from the " + + "inventory costs two and a half times what a blacksmith asks.", + async () => + { + var shown = (await WornAsync(player, GlovesSlot)).RepairPrice; + var zen = await player.ZenAsync(); + await player.ClickSlotAsync("equipment", GlovesSlot); + await ExpectRepairedAsync(player, GlovesSlot, gloves.MaxDurability); + await NoteChargeAsync(context, player, zen, shown, gloves.Name); + }); + await context.StepAsync( + $"Close the inventory and walk up to {blacksmith.Name}", + $"{character.Name} stands next to {blacksmith.Name} ({blacksmith.X},{blacksmith.Y}), who repairs.", + async () => + { + await player.SendAsync("hotkey", new { key = "i" }); + await Expect.EventuallyAsync( + async () => !(await player.OpenWindowsAsync()).Contains("inventory"), + ServerAnswer, + "the inventory does not close"); + await player.WarpAsync(LorenciaGate, LorenciaMap); + await Npcs.WalkUpToAsync(player, blacksmith); + }); + await context.StepAsync( + $"Talk to {blacksmith.Name} with a click on him", + "His shop opens with its Repair and Repair all buttons, and shows what repairing everything costs.", + async () => + { + await Npcs.TalkAsync(player, blacksmith, "npc_shop"); + await Expect.EventuallyAsync( + async () => (await player.StateAsync()).GetProperty("npc_shop") is { ValueKind: JsonValueKind.Object } shop + && shop.GetProperty("repair_shop").GetBoolean(), + ServerAnswer, + $"{blacksmith.Name}'s shop has no repair buttons"); + }); + await context.StepAsync( + "Press Repair all", + $"'{boots.Name}' has its durability back at {boots.MaxDurability}, and zen went down by what the shop showed for " + + "repairing everything.", + async () => + { + var shown = (await player.StateAsync()).GetProperty("npc_shop").GetProperty("repair_all_price").GetInt64(); + var zen = await player.ZenAsync(); + await player.ClickElementAsync("npc_shop.repair_all"); + await ExpectRepairedAsync(player, BootsSlot, boots.MaxDurability); + await NoteChargeAsync(context, player, zen, shown, "everything"); + }); + } + + // An equipped item and what repairing it costs now, if the client shows a price. + private sealed record Worn(string Name, int Durability, int MaxDurability, long? RepairPrice); + + private static async Task WornAsync(GameClient client, int slot) + { + foreach (var item in (await client.StateAsync()).GetProperty("equipment").EnumerateArray()) + { + if (item.GetProperty("slot").GetInt32() != slot) + { + continue; + } + + var worn = new Worn( + item.GetProperty("name").GetString() ?? string.Empty, + item.GetProperty("durability").GetInt32(), + item.GetProperty("max_durability").GetInt32(), + item.TryGetProperty("repair_price", out var price) ? price.GetInt64() : null); + Expect.That( + worn.Durability < worn.MaxDurability, + $"'{worn.Name}' in slot {slot} is not worn ({worn.Durability} of {worn.MaxDurability}); nothing to repair"); + return worn; + } + + throw new ScenarioFailedException($"nothing is equipped in slot {slot}; the OpenMU test data equips {TestAccounts.Repairer.Name} there"); + } + + private static async Task ExpectRepairedAsync(GameClient client, int slot, int maxDurability) + { + var durability = 0; + await Expect.EventuallyAsync( + async () => + { + var item = (await client.StateAsync()).GetProperty("equipment").EnumerateArray() + .FirstOrDefault(entry => entry.GetProperty("slot").GetInt32() == slot); + durability = item.ValueKind == JsonValueKind.Object ? item.GetProperty("durability").GetInt32() : -1; + return durability == maxDurability; + }, + ServerAnswer, + () => $"slot {slot} has {durability} durability after the repair, not {maxDurability}"); + } + + // The repair has to cost zen; what it cost and what the client showed go into the log. + private static async Task NoteChargeAsync(ScenarioContext context, GameClient client, long zenBefore, long? shown, string what) + { + var zen = zenBefore; + await Expect.EventuallyAsync( + async () => (zen = await client.ZenAsync()) < zenBefore, + ServerAnswer, + $"repairing {what} cost no zen"); + context.Note($"repairing {what} cost {zenBefore - zen:N0} zen; the client showed {(shown is { } price ? $"{price:N0}" : "no price")}"); + } +} diff --git a/tools/InGameTests/Scenarios/TestAccounts.cs b/tools/InGameTests/Scenarios/TestAccounts.cs index 7ec066c9fe..62f8835207 100644 --- a/tools/InGameTests/Scenarios/TestAccounts.cs +++ b/tools/InGameTests/Scenarios/TestAccounts.cs @@ -34,4 +34,10 @@ internal static class TestAccounts /// and 25 free squares for what it buys. /// public static readonly TestCharacter NpcShopper = new("test7", "test7", "test7Dk"); + + /// + /// repair: level 91 Dark Knight in Lorencia, high enough for the inventory's repair + /// button. The test data gives its excellent gloves and boots 30 of 45 durability. + /// + public static readonly TestCharacter Repairer = new("test9", "test9", "test9Dk"); } From 2ad4ec19bbd61f4c26dab8c467b936bd39c259d8 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Mon, 28 Sep 2026 22:27:03 +0200 Subject: [PATCH 06/18] feat(tools): equip-all-slots scenario testgm2Sum wears something in all twelve equipment slots. Slot by slot, the item is dragged off onto a free area of the inventory with two clicks and put back on with a right-click on it; each move is checked in the equipment and the inventory. --- tools/InGameTests/Program.cs | 1 + .../Scenarios/EquipAllSlotsScenario.cs | 105 ++++++++++++++++++ tools/InGameTests/Scenarios/TestAccounts.cs | 6 + 3 files changed, 112 insertions(+) create mode 100644 tools/InGameTests/Scenarios/EquipAllSlotsScenario.cs diff --git a/tools/InGameTests/Program.cs b/tools/InGameTests/Program.cs index 3ca43b17a8..7a0b61c5da 100644 --- a/tools/InGameTests/Program.cs +++ b/tools/InGameTests/Program.cs @@ -22,6 +22,7 @@ internal static class Program new NpcShopScenario(), new RepairScenario(), new IcarusFlyingItemTakeOffScenario(), + new EquipAllSlotsScenario(), ]; // The window's file dialogs need a single-threaded apartment on Windows. diff --git a/tools/InGameTests/Scenarios/EquipAllSlotsScenario.cs b/tools/InGameTests/Scenarios/EquipAllSlotsScenario.cs new file mode 100644 index 0000000000..512985e038 --- /dev/null +++ b/tools/InGameTests/Scenarios/EquipAllSlotsScenario.cs @@ -0,0 +1,105 @@ +using MuMain.Tools.InGameTests.Clients; + +namespace MuMain.Tools.InGameTests.Scenarios; + +/// +/// Every equipment slot, one after the other: the item is dragged off into the +/// inventory with two clicks, and put back on with a right-click on it. +/// +internal sealed class EquipAllSlotsScenario : Scenario +{ + private const string Player = "player"; + + // The equipment slots in the order `state` numbers them. + private static readonly string[] SlotNames = + [ + "right hand", "left hand", "helm", "armor", "pants", "gloves", "boots", "wings", "pet", "pendant", "right ring", "left ring", + ]; + + private static readonly TimeSpan ServerAnswer = TimeSpan.FromSeconds(10); + + public override string Name => "equip-all-slots"; + + public override string Description => "the item of every equipment slot is dragged off into the inventory and put back on with a right-click"; + + public override ScenarioCategory Category => ScenarioCategory.GameBehaviour; + + public override IReadOnlyList Roles => [Player]; + + public override int StepCount => 2 + (2 * SlotNames.Length); + + public override async Task RunAsync(ScenarioContext context) + { + var player = context.Client(Player); + var character = TestAccounts.Equipper; + + await context.StepAsync( + $"Log in as {character.Name}", + $"{character.Name} enters the world in Lorencia, wearing something in all {SlotNames.Length} equipment slots.", + async () => + { + await player.EnterWorldAsync(character.Account, character.Password, character.Name); + // Right after entering the world the equipment may still be on its way. + var worn = 0; + await Expect.EventuallyAsync( + async () => (worn = ItemSlots.Of(await player.StateAsync(), "equipment").Count) == SlotNames.Length, + ServerAnswer, + () => $"{character.Name} wears {worn} items, not one in each of the {SlotNames.Length} slots"); + }); + await context.StepAsync( + "Open the inventory with I", + "The inventory opens and shows every equipment slot filled.", + () => player.OpenInventoryAsync()); + + for (var slot = 0; slot < SlotNames.Length; slot++) + { + var item = ItemSlots.At(ItemSlots.Of(await player.StateAsync(), "equipment"), slot) + ?? throw new ScenarioFailedException($"the {SlotNames[slot]} slot ({slot}) is empty before its turn"); + var inventorySlot = 0; + await context.StepAsync( + $"Drag '{item.Name}' off the {SlotNames[slot]} slot into the inventory", + $"The first click picks '{item.Name}' up from the {SlotNames[slot]} slot, the second puts it down on a free " + + $"{item.Width}x{item.Height} area of the inventory. The slot is empty and the item lies in the inventory.", + async () => inventorySlot = await TakeOffAsync(player, item, slot)); + await context.StepAsync( + $"Right-click '{item.Name}' in the inventory", + $"'{item.Name}' goes back on into the {SlotNames[slot]} slot and leaves the inventory.", + () => PutOnAsync(player, item, slot, inventorySlot)); + } + } + + // Two clicks: the first picks the item up, the second puts it down so that + // it covers a free area; an equipment item hangs from the pointer by its + // middle. Returns the inventory slot of its top-left square. + private static async Task TakeOffAsync(GameClient client, ItemSlot item, int slot) + { + var area = ItemSlots.FreeArea(ItemSlots.Of(await client.StateAsync(), "inventory"), item.Width, item.Height) + ?? throw new ScenarioFailedException($"the inventory has no free {item.Width}x{item.Height} area for '{item.Name}'"); + await client.ClickSlotAsync("equipment", slot); + await client.DropOnAreaAsync("inventory", area, item.Width, item.Height, ItemSlots.InventoryColumns); + await Expect.EventuallyAsync( + async () => + { + var state = await client.StateAsync(); + return ItemSlots.At(ItemSlots.Of(state, "equipment"), slot) is null + && ItemSlots.At(ItemSlots.Of(state, "inventory"), area)?.Name == item.Name; + }, + ServerAnswer, + $"'{item.Name}' did not move from slot {slot} onto inventory slot {area}"); + return area; + } + + private static async Task PutOnAsync(GameClient client, ItemSlot item, int slot, int inventorySlot) + { + await client.ClickSlotAsync("inventory", inventorySlot, "right"); + await Expect.EventuallyAsync( + async () => + { + var state = await client.StateAsync(); + return ItemSlots.At(ItemSlots.Of(state, "equipment"), slot)?.Name == item.Name + && ItemSlots.At(ItemSlots.Of(state, "inventory"), inventorySlot)?.Name != item.Name; + }, + ServerAnswer, + $"a right-click on '{item.Name}' did not put it back on into slot {slot}"); + } +} diff --git a/tools/InGameTests/Scenarios/TestAccounts.cs b/tools/InGameTests/Scenarios/TestAccounts.cs index 62f8835207..0d277b5227 100644 --- a/tools/InGameTests/Scenarios/TestAccounts.cs +++ b/tools/InGameTests/Scenarios/TestAccounts.cs @@ -40,4 +40,10 @@ internal static class TestAccounts /// button. The test data gives its excellent gloves and boots 30 of 45 durability. /// public static readonly TestCharacter Repairer = new("test9", "test9", "test9Dk"); + + /// + /// equip-all-slots: level 400 Dimension Master in Lorencia that wears something in all + /// twelve equipment slots and has room in the inventory for each of them. + /// + public static readonly TestCharacter Equipper = new("testgm2", "testgm2", "testgm2Sum"); } From 67aa66a700fee0a10653af4256f04c0dbdd932ef Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Mon, 28 Sep 2026 22:33:18 +0200 Subject: [PATCH 07/18] feat: party scenario; party events and the command window's buttons - `party` (Player Interactions): five clients. The leader opens the command window with D; for each of four players it clicks Party and right-clicks the player where its client draws it, and the player accepts the invitation with Enter. After each join the party lists of both show the new size with the leader first; at the end all five show the same party of five. - `party` events: `invited` (with the inviter's name) and `result` for the server's answer to an invitation that formed no party (denied, full, other_party, ...). - `ui` elements command.trade, command.purchase and command.party while the command window is open. - A step can name the clients it pictures, so five clients do not put five screenshots into every step; a failed step pictures all of them. - Meeting.PixelOfAsync finds where a client draws a player or an NPC. --- src/source/App/Control/ControlCommandsUi.cpp | 7 + src/source/App/Control/ControlEvents.cpp | 13 ++ src/source/App/Control/ControlEvents.h | 4 + src/source/App/Control/ControlTaps.cpp | 17 ++ src/source/App/Control/ControlTaps.h | 11 +- src/source/Network/Server/WSclient.cpp | 2 + src/source/UI/NewUI/HUD/NewUICommandWindow.h | 5 + tools/InGameTests/Program.cs | 1 + tools/InGameTests/Scenarios/Meeting.cs | 28 +++ tools/InGameTests/Scenarios/Npcs.cs | 27 +-- tools/InGameTests/Scenarios/PartyScenario.cs | 192 ++++++++++++++++++ .../InGameTests/Scenarios/ScenarioContext.cs | 26 ++- tools/InGameTests/Scenarios/TestAccounts.cs | 12 ++ 13 files changed, 310 insertions(+), 35 deletions(-) create mode 100644 tools/InGameTests/Scenarios/PartyScenario.cs diff --git a/src/source/App/Control/ControlCommandsUi.cpp b/src/source/App/Control/ControlCommandsUi.cpp index 5d34816e1c..d0f9d5be35 100644 --- a/src/source/App/Control/ControlCommandsUi.cpp +++ b/src/source/App/Control/ControlCommandsUi.cpp @@ -92,6 +92,13 @@ json Elements() { elements["inventory.my_shop"] = WindowRect(*g_pMyInventory, ButtonRect(*myShop)); } + if (g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_COMMAND)) + { + elements["command.trade"] = WindowRect(*g_pCommandWindow, ButtonRect(g_pCommandWindow->GetCommandButton(COMMAND_TRADE))); + elements["command.purchase"] = + WindowRect(*g_pCommandWindow, ButtonRect(g_pCommandWindow->GetCommandButton(COMMAND_PURCHASE))); + elements["command.party"] = WindowRect(*g_pCommandWindow, ButtonRect(g_pCommandWindow->GetCommandButton(COMMAND_PARTY))); + } if (g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_NPCSHOP) && g_pNPCShop->IsRepairShop()) { elements["npc_shop.repair"] = WindowRect(*g_pNPCShop, ButtonRect(g_pNPCShop->GetRepairButton())); diff --git a/src/source/App/Control/ControlEvents.cpp b/src/source/App/Control/ControlEvents.cpp index d65c4cd5b8..c9e415a4e9 100644 --- a/src/source/App/Control/ControlEvents.cpp +++ b/src/source/App/Control/ControlEvents.cpp @@ -336,6 +336,19 @@ void RecordParty(std::string_view change, const wchar_t* name) Push("party", fields); } +void RecordPartyResult(std::string_view result) +{ + if (!IsEnabled()) + { + return; + } + + json fields; + fields["change"] = "result"; + fields["result"] = result; + Push("party", fields); +} + void RecordTrade(std::string_view change, std::string_view name, std::string_view detail) { if (!IsEnabled()) diff --git a/src/source/App/Control/ControlEvents.h b/src/source/App/Control/ControlEvents.h index 265e81317f..c76e64165e 100644 --- a/src/source/App/Control/ControlEvents.h +++ b/src/source/App/Control/ControlEvents.h @@ -101,6 +101,10 @@ void RecordMap(int mapNumber, const wchar_t* mapName, int x, int y); void RecordViewEnter(int key); void RecordViewLeave(int key); void RecordParty(std::string_view change, const wchar_t* name); +// The server's answer to a party invitation that did not form or grow a +// party: `result` is failed, denied, full, user_left, other_party, left, +// opposing_gens, battle_zone, battle_zone_off or unknown. +void RecordPartyResult(std::string_view result); // A trade step: `change` is requested, opened, refused, unavailable, // partner_confirm or closed. `name` is the partner (may be empty), `detail` // the confirm state or how the trade closed (may be empty). diff --git a/src/source/App/Control/ControlTaps.cpp b/src/source/App/Control/ControlTaps.cpp index 514de99237..1e403cce78 100644 --- a/src/source/App/Control/ControlTaps.cpp +++ b/src/source/App/Control/ControlTaps.cpp @@ -222,6 +222,23 @@ void RecordPartyChange(const char* change, const wchar_t* name) RecordParty(change != nullptr ? change : "changed", name != nullptr ? name : L""); } +void RecordPartyInvited(int inviterKey) +{ + if (!IsEnabled()) + { + return; + } + const ObjectDescription inviter = App::Control::DescribeGameObject(inviterKey); + RecordParty("invited", Core::Text::FromUtf8(inviter.name).c_str()); +} + +void RecordPartyAnswer(int result) +{ + constexpr std::string_view Results[] = {"failed", "denied", "full", "user_left", "other_party", + "left", "opposing_gens", "battle_zone", "battle_zone_off"}; + RecordPartyResult(result >= 0 && result < static_cast(std::size(Results)) ? Results[result] : "unknown"); +} + void RecordDisconnected(const char* reason) { RecordDisconnect(reason != nullptr ? reason : "the server closed the connection"); diff --git a/src/source/App/Control/ControlTaps.h b/src/source/App/Control/ControlTaps.h index 0232ff78ff..a99632ccc4 100644 --- a/src/source/App/Control/ControlTaps.h +++ b/src/source/App/Control/ControlTaps.h @@ -61,9 +61,16 @@ void RecordViewCleared(const char* reason); void RecordViewEnterKey(int key); void RecordViewLeaveKey(int key); -// Party membership changes: joined, left, list. +// Party membership changes: list (the members as the server sent them, `name` +// the leader) and left. void RecordPartyChange(const char* change, const wchar_t* name); +// An invitation to a party, from the character with the server id `inviterKey`. +void RecordPartyInvited(int inviterKey); + +// The server's answer to an invitation that formed no party (0x41 value). +void RecordPartyAnswer(int result); + // The server closed the session. void RecordDisconnected(const char* reason); @@ -106,6 +113,8 @@ inline void RecordViewCleared(const char*) {} inline void RecordViewEnterKey(int) {} inline void RecordViewLeaveKey(int) {} inline void RecordPartyChange(const char*, const wchar_t*) {} +inline void RecordPartyInvited(int) {} +inline void RecordPartyAnswer(int) {} inline void RecordDisconnected(const char*) {} inline void RecordTradeRequested(const char*, bool) {} inline void RecordTradeAnswer(int, const char*) {} diff --git a/src/source/Network/Server/WSclient.cpp b/src/source/Network/Server/WSclient.cpp index 3e95121626..5c8ec2d1c0 100644 --- a/src/source/Network/Server/WSclient.cpp +++ b/src/source/Network/Server/WSclient.cpp @@ -7382,6 +7382,7 @@ void ReceiveParty(const BYTE* ReceiveBuffer) PartyKey = ((int)(Data->KeyH) << 8) + Data->KeyL; SEASON3B::CreateMessageBox(MSGBOX_LAYOUT_CLASS(SEASON3B::CPartyMsgBoxLayout)); + App::Control::Events::RecordPartyInvited(PartyKey); } void ReceivePartyResult(const BYTE* ReceiveBuffer) @@ -7418,6 +7419,7 @@ void ReceivePartyResult(const BYTE* ReceiveBuffer) g_pSystemLogBox->AddText(I18N::Game::PartiesAreNotActivatedWithinABattleZone, SEASON3B::TYPE_ERROR_MESSAGE); break; } + App::Control::Events::RecordPartyAnswer(Data->Value); } void ReceivePartyList(const BYTE* ReceiveBuffer) diff --git a/src/source/UI/NewUI/HUD/NewUICommandWindow.h b/src/source/UI/NewUI/HUD/NewUICommandWindow.h index a8eeffd991..826fb3a6f9 100644 --- a/src/source/UI/NewUI/HUD/NewUICommandWindow.h +++ b/src/source/UI/NewUI/HUD/NewUICommandWindow.h @@ -78,6 +78,11 @@ namespace SEASON3B void ClosingProcess(); int GetCurCommandType(); + // A command's button (COMMAND_TRADE, COMMAND_PARTY, ...), for the control socket. + CNewUIButton& GetCommandButton(int command) + { + return m_BtnCommand[command]; + } void SetMouseCursor(int iCursorType); int GetMouseCursor(); diff --git a/tools/InGameTests/Program.cs b/tools/InGameTests/Program.cs index 7a0b61c5da..20ff65d174 100644 --- a/tools/InGameTests/Program.cs +++ b/tools/InGameTests/Program.cs @@ -19,6 +19,7 @@ internal static class Program [ new TradeScenario(), new ChatScenario(), + new PartyScenario(), new NpcShopScenario(), new RepairScenario(), new IcarusFlyingItemTakeOffScenario(), diff --git a/tools/InGameTests/Scenarios/Meeting.cs b/tools/InGameTests/Scenarios/Meeting.cs index 5f9ab2438e..0446131c42 100644 --- a/tools/InGameTests/Scenarios/Meeting.cs +++ b/tools/InGameTests/Scenarios/Meeting.cs @@ -105,6 +105,34 @@ public static async Task WalkToAsync(GameClient client, int x, int y) return null; } + /// + /// Where the client of draws the character + /// (a player or an NPC), in window pixels: the middle of the box the mouse picks it by. Waits + /// until it is on screen. + /// + public static async Task<(double X, double Y)> PixelOfAsync(GameClient observer, string name) + { + (double X, double Y)? pixel = null; + await Expect.EventuallyAsync( + async () => + { + foreach (var entry in (await observer.StateAsync()).GetProperty("nearby").EnumerateArray()) + { + if (entry.GetProperty("name").GetString() == name + && entry.TryGetProperty("pixel", out var found) && found.ValueKind == System.Text.Json.JsonValueKind.Object) + { + pixel = (found.GetProperty("x").GetDouble(), found.GetProperty("y").GetDouble()); + return true; + } + } + + return false; + }, + TimeSpan.FromSeconds(10), + $"{name} is not on the {observer.Role}'s screen"); + return pixel!.Value; + } + private static bool IsWithin((int X, int Y) position, (int X, int Y) other, int distance) => Math.Abs(position.X - other.X) <= distance && Math.Abs(position.Y - other.Y) <= distance; diff --git a/tools/InGameTests/Scenarios/Npcs.cs b/tools/InGameTests/Scenarios/Npcs.cs index 367f38ffda..a27b8314ea 100644 --- a/tools/InGameTests/Scenarios/Npcs.cs +++ b/tools/InGameTests/Scenarios/Npcs.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using MuMain.Tools.InGameTests.Clients; using MuMain.Tools.InGameTests.Control; @@ -45,35 +44,11 @@ public static async Task WalkUpToAsync(GameClient client, Npc npc) /// public static async Task TalkAsync(GameClient client, Npc npc, string window) { - var pixel = await PixelAsync(client, npc); + var pixel = await Meeting.PixelOfAsync(client, npc.Name); await client.SendAsync("click-ui", new { x = pixel.X, y = pixel.Y, button = "left" }); await Expect.EventuallyAsync( async () => (await client.OpenWindowsAsync()).Contains(window), ServerAnswer, $"a click on {npc.Name} at ({pixel.X},{pixel.Y}) does not open the {window} window"); } - - // Where the client draws the NPC; it has to be on screen. - private static async Task<(double X, double Y)> PixelAsync(GameClient client, Npc npc) - { - (double X, double Y)? pixel = null; - await Expect.EventuallyAsync( - async () => - { - foreach (var entry in (await client.StateAsync()).GetProperty("nearby").EnumerateArray()) - { - if (entry.GetProperty("name").GetString() == npc.Name - && entry.TryGetProperty("pixel", out var found) && found.ValueKind == JsonValueKind.Object) - { - pixel = (found.GetProperty("x").GetDouble(), found.GetProperty("y").GetDouble()); - return true; - } - } - - return false; - }, - ServerAnswer, - $"{npc.Name} is not on the {client.Role}'s screen"); - return pixel!.Value; - } } diff --git a/tools/InGameTests/Scenarios/PartyScenario.cs b/tools/InGameTests/Scenarios/PartyScenario.cs new file mode 100644 index 0000000000..87c1e1f697 --- /dev/null +++ b/tools/InGameTests/Scenarios/PartyScenario.cs @@ -0,0 +1,192 @@ +using MuMain.Tools.InGameTests.Clients; +using MuMain.Tools.InGameTests.Control; + +namespace MuMain.Tools.InGameTests.Scenarios; + +/// +/// A party of five, the most a party takes: the leader invites four players one +/// after the other through the command window (D, Party, a right-click on the +/// player), and each accepts the invitation with Enter. +/// +internal sealed class PartyScenario : Scenario +{ + private const string Leader = "leader"; + + // A free spot in Lorencia's town; the command window invites a player at most two tiles away. + private const string LorenciaGate = "Lorencia"; + private const int LorenciaMap = 0; + private const int MeetingX = 135; + private const int MeetingY = 128; + private const int InviteDistance = 2; + + // Where a member steps once it joined, so the next one is not behind it + // when the leader right-clicks. + private static readonly (int X, int Y)[] Aside = [(-4, 0), (4, 0), (0, 4), (0, -4), (-4, 4), (4, -4)]; + + private static readonly TimeSpan ServerAnswer = TimeSpan.FromSeconds(10); + + private static readonly string[] Members = ["member1", "member2", "member3", "member4"]; + + public override string Name => "party"; + + public override string Description => "the leader invites four players through the command window, each accepts with Enter: a party of five"; + + public override ScenarioCategory Category => ScenarioCategory.PlayerInteractions; + + public override IReadOnlyList Roles => [Leader, .. Members]; + + public override int StepCount => 3 + (3 * Members.Length) + 1; + + public override async Task RunAsync(ScenarioContext context) + { + var leader = context.Client(Leader); + var leaderCharacter = TestAccounts.PartyLeader; + var memberCharacters = TestAccounts.PartyMembers; + + await context.StepAsync( + "All five log in", + $"{leaderCharacter.Name} (the leader) and {string.Join(", ", memberCharacters.Select(member => member.Name))} enter the " + + "world in Lorencia, their home town.", + async () => + { + await leader.EnterWorldAsync(leaderCharacter.Account, leaderCharacter.Password, leaderCharacter.Name); + for (var index = 0; index < Members.Length; index++) + { + var member = memberCharacters[index]; + await context.Client(Members[index]).EnterWorldAsync(member.Account, member.Password, member.Name); + } + }); + await context.StepAsync( + "The leader walks to the meeting spot", + $"{leaderCharacter.Name} stands at ({MeetingX},{MeetingY}), a free spot in Lorencia's town, or one tile from it.", + async () => + { + await leader.WarpAsync(LorenciaGate, LorenciaMap); + await Meeting.WalkToAsync(leader, MeetingX, MeetingY); + }, + [Leader]); + await context.StepAsync( + "The leader opens the command window with D", + "The command window opens on the right with its buttons: Trade, Buy, Party and the others.", + async () => + { + await leader.SendAsync("hotkey", new { key = "d" }); + await Expect.EventuallyAsync( + async () => (await leader.OpenWindowsAsync()).Contains("command"), + ServerAnswer, + "the command window does not open"); + }, + [Leader]); + + for (var index = 0; index < Members.Length; index++) + { + var role = Members[index]; + var member = context.Client(role); + var name = memberCharacters[index].Name; + var size = index + 2; + await context.StepAsync( + $"{name} walks up to the leader", + $"{name} stands at most {InviteDistance} tiles from {leaderCharacter.Name}: the command window invites only a player that close.", + async () => + { + await member.WarpAsync(LorenciaGate, LorenciaMap); + await Meeting.WalkUpToAsync(member, leader, name, InviteDistance); + }, + [Leader, role]); + await context.StepAsync( + $"The leader clicks Party in the command window and right-clicks {name}", + $"The click selects the Party command; the right-click on {name} invites it. {name} gets a dialog that asks " + + $"whether to join {leaderCharacter.Name}'s party.", + () => InviteAsync(leader, member, leaderCharacter.Name, name), + [Leader, role]); + await context.StepAsync( + $"{name} accepts with Enter", + $"The dialog closes, and the party lists of the leader and of {name} show {size} members, {leaderCharacter.Name} " + + $"first. {name} then steps aside to make room for the next one.", + async () => + { + await Keys.PressUntilAsync( + context, + member, + "enter", + async () => !(await member.OpenWindowsAsync()).Contains("message_box"), + $"{name}'s invitation dialog does not take Enter"); + await ExpectPartyAsync(leader, leaderCharacter.Name, size); + await ExpectPartyAsync(member, leaderCharacter.Name, size); + await StepAsideAsync(member, leader, index); + }, + [Leader, role]); + } + + var everyone = new[] { leaderCharacter.Name }.Concat(memberCharacters.Select(member => member.Name)).ToList(); + await context.StepAsync( + "Every player looks at the party list", + $"All five show the same party of five, {leaderCharacter.Name} first: {string.Join(", ", everyone)}.", + async () => + { + foreach (var role in Roles) + { + var client = context.Client(role); + IReadOnlyList party = []; + await Expect.EventuallyAsync( + async () => (party = await PartyAsync(client)).SequenceEqual(everyone), + ServerAnswer, + () => $"the {role}'s party list is {(party.Count == 0 ? "empty" : string.Join(", ", party))}, " + + $"not {string.Join(", ", everyone)}"); + } + }); + } + + // The Party command, then a right-click on the member where the leader's + // client draws it; the member's client shows the invitation. + private static async Task InviteAsync(GameClient leader, GameClient member, string leaderName, string memberName) + { + var sequence = await member.LastEventSequenceAsync(); + await leader.ClickElementAsync("command.party"); + var pixel = await Meeting.PixelOfAsync(leader, memberName); + await leader.SendAsync("click-ui", new { x = pixel.X, y = pixel.Y, button = "right" }); + await member.WaitForEventAsync( + "party", + new Dictionary { ["change"] = "invited", ["name"] = leaderName }, + sequence, + ServerAnswer); + await Expect.EventuallyAsync( + async () => (await member.OpenWindowsAsync()).Contains("message_box"), + ServerAnswer, + $"{memberName} sees no invitation dialog"); + } + + private static async Task ExpectPartyAsync(GameClient client, string leaderName, int size) + { + IReadOnlyList party = []; + await Expect.EventuallyAsync( + async () => (party = await PartyAsync(client)).Count == size && party[0] == leaderName, + ServerAnswer, + () => $"the {client.Role}'s party list is {(party.Count == 0 ? "empty" : string.Join(", ", party))}, " + + $"not {size} members led by {leaderName}"); + } + + private static async Task> PartyAsync(GameClient client) + => (await client.StateAsync()).GetProperty("party").EnumerateArray() + .Select(member => member.GetProperty("name").GetString() ?? string.Empty) + .ToList(); + + // A few tiles away from the leader, in a direction of its own for each member. + private static async Task StepAsideAsync(GameClient member, GameClient leader, int index) + { + var (x, y) = await Meeting.PositionAsync(leader); + for (var attempt = 0; attempt < Aside.Length; attempt++) + { + var (dx, dy) = Aside[(index + attempt) % Aside.Length]; + try + { + await Meeting.WalkToAsync(member, x + dx, y + dy); + return; + } + catch (ControlException exception) when (exception.Error == "no_path") + { + // A wall; try the next direction. + } + } + } +} diff --git a/tools/InGameTests/Scenarios/ScenarioContext.cs b/tools/InGameTests/Scenarios/ScenarioContext.cs index ae2afb0c03..d20836bd56 100644 --- a/tools/InGameTests/Scenarios/ScenarioContext.cs +++ b/tools/InGameTests/Scenarios/ScenarioContext.cs @@ -33,7 +33,11 @@ internal sealed class ScenarioContext( /// what should happen then, for someone reading the report; does it /// and checks the outcome. A step that throws fails the scenario. /// - public Task StepAsync(string title, string expectation, Func action) + /// + /// The roles whose clients take a screenshot after the step, when not all of them show + /// something of it; a failed step pictures every client. + /// + public Task StepAsync(string title, string expectation, Func action, IReadOnlyCollection? pictured = null) => this.StepAsync( title, expectation, @@ -41,10 +45,11 @@ public Task StepAsync(string title, string expectation, Func action) { await action(); return null; - }); + }, + pictured); - /// As , for a step that finds something out. - public async Task StepAsync(string title, string expectation, Func> action) + /// As , for a step that finds something out. + public async Task StepAsync(string title, string expectation, Func> action, IReadOnlyCollection? pictured = null) { var number = this.steps.Count + 1; log.WriteLine($" {number,2}. {title}"); @@ -54,13 +59,13 @@ public async Task StepAsync(string title, string expectation, Func { var result = await action(); var duration = stopwatch.Elapsed; - this.Finish(new StepResult(number, title, expectation, true, duration, null, await this.CaptureAsync(number))); + this.Finish(new StepResult(number, title, expectation, true, duration, null, await this.CaptureAsync(number, pictured))); return result; } catch (Exception exception) { var duration = stopwatch.Elapsed; - this.Finish(new StepResult(number, title, expectation, false, duration, exception.Message, await this.CaptureAsync(number))); + this.Finish(new StepResult(number, title, expectation, false, duration, exception.Message, await this.CaptureAsync(number, null))); throw; } } @@ -71,12 +76,17 @@ private void Finish(StepResult step) stepFinished?.Invoke(step); } - // Every client, so a trade shows both sides. - private async Task> CaptureAsync(int number) + // Every client unless the step names some, so a trade shows both sides. + private async Task> CaptureAsync(int number, IReadOnlyCollection? pictured) { var screenshots = new List(); foreach (var (role, client) in clients) { + if (pictured is not null && !pictured.Contains(role)) + { + continue; + } + var path = Path.GetFullPath(Path.Combine(folder, $"step-{number:00}-{role}.jpg")); try { diff --git a/tools/InGameTests/Scenarios/TestAccounts.cs b/tools/InGameTests/Scenarios/TestAccounts.cs index 0d277b5227..2b27045a8f 100644 --- a/tools/InGameTests/Scenarios/TestAccounts.cs +++ b/tools/InGameTests/Scenarios/TestAccounts.cs @@ -46,4 +46,16 @@ internal static class TestAccounts /// twelve equipment slots and has room in the inventory for each of them. /// public static readonly TestCharacter Equipper = new("testgm2", "testgm2", "testgm2Sum"); + + /// party leader: level 1 Dark Knight, at home in Lorencia. + public static readonly TestCharacter PartyLeader = new("test0", "test0", "test0Dk"); + + /// party members: Dark Knights of level 11 to 41, at home in Lorencia. + public static readonly TestCharacter[] PartyMembers = + [ + new("test1", "test1", "test1Dk"), + new("test2", "test2", "test2Dk"), + new("test3", "test3", "test3Dk"), + new("test4", "test4", "test4Dk"), + ]; } From eeda5bfd485ca5f1be6275d8786982f4129f60c9 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Mon, 28 Sep 2026 22:36:25 +0200 Subject: [PATCH 08/18] feat(tools): trade-inventory-full scenario test7Dw and test8Dw have no free 2x2 area. Three trades that cannot complete: 1. the first offers a 2x2 item the second has no room for; 2. the second offers one the first has no room for; 3. both: the first offers a 2x2 item, the second more single items than the first then has free squares, taken on alternate squares so that no 2x2 area of its own frees up. Each time both confirm, the trade closes for both with "inventory full", and both keep the same items in the same slots and the same zen. --- tools/InGameTests/Program.cs | 1 + tools/InGameTests/Scenarios/TestAccounts.cs | 10 + .../Scenarios/TradeInventoryFullScenario.cs | 276 ++++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 tools/InGameTests/Scenarios/TradeInventoryFullScenario.cs diff --git a/tools/InGameTests/Program.cs b/tools/InGameTests/Program.cs index 20ff65d174..326815d3bd 100644 --- a/tools/InGameTests/Program.cs +++ b/tools/InGameTests/Program.cs @@ -18,6 +18,7 @@ internal static class Program public static readonly Scenario[] AllScenarios = [ new TradeScenario(), + new TradeInventoryFullScenario(), new ChatScenario(), new PartyScenario(), new NpcShopScenario(), diff --git a/tools/InGameTests/Scenarios/TestAccounts.cs b/tools/InGameTests/Scenarios/TestAccounts.cs index 2b27045a8f..b677d78a2a 100644 --- a/tools/InGameTests/Scenarios/TestAccounts.cs +++ b/tools/InGameTests/Scenarios/TestAccounts.cs @@ -47,6 +47,16 @@ internal static class TestAccounts /// public static readonly TestCharacter Equipper = new("testgm2", "testgm2", "testgm2Sum"); + /// + /// trade-inventory-full: level 71 and 81 Dark Wizards in Lorencia. The test data leaves + /// nine single free squares and no free 2x2 area in their inventories, and gives each 2x2 pad + /// armour pieces and rows of jewels and potions to offer. + /// + public static readonly TestCharacter FullTraderFirst = new("test7", "test7", "test7Dw"); + + /// + public static readonly TestCharacter FullTraderSecond = new("test8", "test8", "test8Dw"); + /// party leader: level 1 Dark Knight, at home in Lorencia. public static readonly TestCharacter PartyLeader = new("test0", "test0", "test0Dk"); diff --git a/tools/InGameTests/Scenarios/TradeInventoryFullScenario.cs b/tools/InGameTests/Scenarios/TradeInventoryFullScenario.cs new file mode 100644 index 0000000000..f50c2754f4 --- /dev/null +++ b/tools/InGameTests/Scenarios/TradeInventoryFullScenario.cs @@ -0,0 +1,276 @@ +using MuMain.Tools.InGameTests.Clients; + +namespace MuMain.Tools.InGameTests.Scenarios; + +/// +/// A trade whose items do not fit: into the second player's inventory, into +/// the first player's, and into neither. Each time the trade closes for both +/// with "inventory full", and both keep what they had. +/// +internal sealed class TradeInventoryFullScenario : Scenario +{ + private const string First = "first"; + private const string Second = "second"; + + private const string LorenciaGate = "Lorencia"; + private const int LorenciaMap = 0; + private const int MeetingX = 135; + private const int MeetingY = 128; + + private const int TradeColumns = 8; + private const int TradeRows = 4; + + private static readonly TimeSpan ServerAnswer = TimeSpan.FromSeconds(10); + + public override string Name => "trade-inventory-full"; + + public override string Description => "a trade whose items do not fit into one side's inventory, the other's, or both, closes and changes nothing"; + + public override ScenarioCategory Category => ScenarioCategory.PlayerInteractions; + + public override IReadOnlyList Roles => [First, Second]; + + public override int StepCount => 4 + 5 + 5 + 6; + + public override async Task RunAsync(ScenarioContext context) + { + var first = context.Client(First); + var second = context.Client(Second); + var firstCharacter = TestAccounts.FullTraderFirst; + var secondCharacter = TestAccounts.FullTraderSecond; + + await context.StepAsync( + $"The first player logs in as {firstCharacter.Name}", + $"{firstCharacter.Name} enters the world in Lorencia with no free 2x2 area in its inventory.", + () => first.EnterWorldAsync(firstCharacter.Account, firstCharacter.Password, firstCharacter.Name)); + await context.StepAsync( + $"The second player logs in as {secondCharacter.Name}", + $"{secondCharacter.Name} enters the world in Lorencia, with no free 2x2 area either.", + () => second.EnterWorldAsync(secondCharacter.Account, secondCharacter.Password, secondCharacter.Name)); + await context.StepAsync( + "The first player walks to the meeting spot", + $"{firstCharacter.Name} stands at ({MeetingX},{MeetingY}), a free spot in Lorencia's town, or one tile from it.", + async () => + { + await first.WarpAsync(LorenciaGate, LorenciaMap); + await Meeting.WalkToAsync(first, MeetingX, MeetingY); + }); + await context.StepAsync( + "The second player walks up to the first", + $"{secondCharacter.Name} stands next to {firstCharacter.Name}: a trade needs the partner at most one tile away.", + async () => + { + await second.WarpAsync(LorenciaGate, LorenciaMap); + await Meeting.WalkUpToAsync(second, first, secondCharacter.Name); + }); + + // Round 1: the second player has no room for what the first offers. + var firstLarge = await LargeItemAsync(first); + await ExpectNoRoomAsync(second, firstLarge); + await RoundAsync( + context, + first, + second, + secondCharacter.Name, + "the second player's", + [(first, [firstLarge])]); + + // Round 2: the first player has no room for what the second offers. + var secondLarge = await LargeItemAsync(second); + await ExpectNoRoomAsync(first, secondLarge); + await RoundAsync( + context, + first, + second, + secondCharacter.Name, + "the first player's", + [(second, [secondLarge])]); + + // Round 3: neither has room. The first offers a 2x2 item, which leaves a + // 2x2 hole in its own inventory; the second offers more single items than + // the first then has free squares, taken so that no 2x2 area of its own + // inventory frees up. + var firstOffer = await LargeItemAsync(first); + var secondOffer = await SmallItemsBeyondAsync(second, await FreeSquaresAsync(first) + (firstOffer.Width * firstOffer.Height)); + await RoundAsync( + context, + first, + second, + secondCharacter.Name, + "neither player's", + [(first, [firstOffer]), (second, secondOffer)]); + } + + // What a client has: its inventory by slot and its zen. + private sealed record Holdings(IReadOnlyList Items, long Zen) + { + public bool SameAs(Holdings other) + => this.Zen == other.Zen + && this.Items.Select(item => (item.Slot, item.Name)).SequenceEqual(other.Items.Select(item => (item.Slot, item.Name))); + } + + private static async Task HoldingsAsync(GameClient client) + { + var state = await client.StateAsync(); + return new Holdings( + ItemSlots.Of(state, "inventory").OrderBy(item => item.Slot).ToList(), + state.GetProperty("zen").GetInt64()); + } + + // One trade: the request, the accept, the offers, both confirms, and the + // failure. `where` names the inventory that has no room, for the report. + private static async Task RoundAsync( + ScenarioContext context, + GameClient first, + GameClient second, + string secondName, + string where, + IReadOnlyList<(GameClient Offerer, IReadOnlyList Items)> offers) + { + var firstHad = await HoldingsAsync(first); + var secondHad = await HoldingsAsync(second); + + await context.StepAsync( + "The first player asks the second for a trade", + $"{secondName} gets the request: a dialog asks whether to trade.", + () => Trading.RequestAsync(first, second, secondName)); + await context.StepAsync( + "The second player accepts with Enter", + "The trade window opens for both players.", + () => Trading.AcceptAsync(context, first, second)); + foreach (var (offerer, items) in offers) + { + var partner = offerer == first ? second : first; + var what = items.Count == 1 ? $"'{items[0].Name}' ({items[0].Width}x{items[0].Height} squares)" : $"{items.Count} single-square items"; + await context.StepAsync( + $"The {offerer.Role} player puts {what} into the trade window with clicks", + $"Two clicks for each item: one picks it up from the inventory, one puts it into the {offerer.Role} player's offer. " + + $"The {partner.Role} player sees {(items.Count == 1 ? "it" : "them")} in the partner's half of the window.", + () => OfferAsync(offerer, partner, items)); + } + + await context.StepAsync( + "The first player presses the confirm button", + "The first player's confirm button stays pressed.", + () => Trading.ConfirmAsync(first)); + + var firstSequence = await first.LastEventSequenceAsync(); + var secondSequence = await second.LastEventSequenceAsync(); + await context.StepAsync( + "The second player presses the confirm button", + $"Both have confirmed, but the offer does not fit into {where} inventory: the server answers \"inventory full\", the " + + "trade window closes on both sides, and both keep exactly the items and the zen they had.", + async () => + { + await Trading.ConfirmAsync(second); + var failed = new Dictionary { ["change"] = "closed", ["result"] = "inventory_full" }; + await first.WaitForEventAsync("trade", failed, firstSequence, ServerAnswer); + await second.WaitForEventAsync("trade", failed, secondSequence, ServerAnswer); + await ExpectUnchangedAsync(first, firstHad); + await ExpectUnchangedAsync(second, secondHad); + }); + } + + // The items go into the offerer's half of the trade window one after the + // other, each where it fits, and the partner has to see them all. + private static async Task OfferAsync(GameClient offerer, GameClient partner, IReadOnlyList items) + { + var used = new HashSet(); + foreach (var item in items) + { + var slot = TradeSlotFor(item, used); + var before = ItemSlots.OfTrade(await offerer.StateAsync(), "my_items").Count; + await offerer.MoveItemAsync("inventory", item.Slot, "trade", slot); + var now = before; + await Expect.EventuallyAsync( + async () => (now = ItemSlots.OfTrade(await offerer.StateAsync(), "my_items").Count) == before + 1, + ServerAnswer, + () => $"'{item.Name}' from inventory slot {item.Slot} did not go into the trade window (it holds {now} items)"); + } + + var seen = 0; + await Expect.EventuallyAsync( + async () => (seen = ItemSlots.OfTrade(await partner.StateAsync(), "partner_items").Count) == items.Count, + ServerAnswer, + () => $"the {partner.Role} player sees {seen} of the {items.Count} offered items"); + } + + // The first free place of the 8x4 trade grid for the item; marks it used. + private static int TradeSlotFor(ItemSlot item, HashSet used) + { + for (var row = 0; row + item.Height <= TradeRows; row++) + { + for (var column = 0; column + item.Width <= TradeColumns; column++) + { + var squares = Enumerable.Range(0, item.Height) + .SelectMany(dy => Enumerable.Range(0, item.Width).Select(dx => ((row + dy) * TradeColumns) + column + dx)) + .ToList(); + if (squares.All(square => !used.Contains(square))) + { + used.UnionWith(squares); + return (row * TradeColumns) + column; + } + } + } + + throw new ScenarioFailedException($"the trade window has no room for '{item.Name}'"); + } + + private static async Task ExpectUnchangedAsync(GameClient client, Holdings had) + { + Holdings? now = null; + await Expect.EventuallyAsync( + async () => (now = await HoldingsAsync(client)).SameAs(had), + ServerAnswer, + () => $"the {client.Role} player's inventory or zen changed after the failed trade: {Describe(had)} before, {Describe(now!)} after"); + } + + private static string Describe(Holdings holdings) + => $"{holdings.Items.Count} squares used and {holdings.Zen:N0} zen"; + + // The first item of the inventory that covers 2x2 squares or more. + private static async Task LargeItemAsync(GameClient client) + { + var inventory = ItemSlots.Of(await client.StateAsync(), "inventory"); + return inventory.Where(item => item.Width >= 2 && item.Height >= 2).OrderBy(item => item.Slot).FirstOrDefault() + ?? throw new ScenarioFailedException($"the {client.Role} player has no item of 2x2 squares to offer"); + } + + private static async Task ExpectNoRoomAsync(GameClient client, ItemSlot item) + { + var inventory = ItemSlots.Of(await client.StateAsync(), "inventory"); + Expect.That( + ItemSlots.FreeArea(inventory, item.Width, item.Height) is null, + $"the {client.Role} player has room for '{item.Name}' ({item.Width}x{item.Height}); the test data should leave none"); + } + + private static async Task FreeSquaresAsync(GameClient client) + { + var used = ItemSlots.Of(await client.StateAsync(), "inventory").Select(item => item.Slot).ToHashSet(); + return Enumerable.Range(ItemSlots.FirstInventorySlot, ItemSlots.InventoryColumns * ItemSlots.InventoryRows) + .Count(slot => !used.Contains(slot)); + } + + // More than single-square items, taken like the + // black squares of a chess board: whatever is taken, no 2x2 area of the + // inventory becomes free, so a 2x2 item still has no room. + private static async Task> SmallItemsBeyondAsync(GameClient client, int count) + { + var inventory = ItemSlots.Of(await client.StateAsync(), "inventory"); + var taken = inventory + .Where(item => item.Width == 1 && item.Height == 1) + .Where(item => (((item.Slot - ItemSlots.FirstInventorySlot) / ItemSlots.InventoryColumns) + + ((item.Slot - ItemSlots.FirstInventorySlot) % ItemSlots.InventoryColumns)) % 2 == 0) + .OrderBy(item => item.Slot) + .Take(count + 1) + .ToList(); + Expect.That( + taken.Count > count && taken.Count <= TradeColumns * TradeRows, + $"the {client.Role} player has {taken.Count} single-square items to offer on alternate squares, not more than {count}"); + var left = inventory.Where(item => !taken.Contains(item)).ToList(); + Expect.That( + ItemSlots.FreeArea(left, 2, 2) is null, + $"offering these items would free a 2x2 area in the {client.Role} player's inventory"); + return taken; + } +} From abca0ae377e3879261c270d5a22420ef17b692fb Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Mon, 28 Sep 2026 22:41:32 +0200 Subject: [PATCH 09/18] feat: personal-shop scenario; personal shops in the socket - `personal-shop` (Player Interactions): quest1Dk opens the inventory, presses its shop button, puts a Jewel of Bless into the shop with two clicks, types its price into the dialog, types the shop's title and opens the shop (a dialog asks, Enter confirms). quest2Dk opens it through the command window (D, Buy, a right-click on the seller), clicks the jewel and confirms with Enter: the jewel and 9,000,000 zen change owner and the seller's shop is empty. - state: `my_shop` (open, title, goods with prices) and `purchase_shop` (seller, title, goods with prices, or null); slot-pixel grids `my_shop` and `purchase_shop` (slots 204 and up, as the server numbers them); `ui` elements my_shop.title, my_shop.open and my_shop.close. --- src/source/App/Control/ControlCommandsUi.cpp | 12 +- src/source/App/Control/ControlState.cpp | 55 ++++ .../UI/NewUI/Inventory/NewUIMyShopInventory.h | 16 ++ tools/InGameTests/Program.cs | 1 + .../Scenarios/PersonalShopScenario.cs | 248 ++++++++++++++++++ tools/InGameTests/Scenarios/TestAccounts.cs | 9 + 6 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 tools/InGameTests/Scenarios/PersonalShopScenario.cs diff --git a/src/source/App/Control/ControlCommandsUi.cpp b/src/source/App/Control/ControlCommandsUi.cpp index d0f9d5be35..72cf9a3a10 100644 --- a/src/source/App/Control/ControlCommandsUi.cpp +++ b/src/source/App/Control/ControlCommandsUi.cpp @@ -92,6 +92,12 @@ json Elements() { elements["inventory.my_shop"] = WindowRect(*g_pMyInventory, ButtonRect(*myShop)); } + if (g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_MYSHOP_INVENTORY)) + { + elements["my_shop.title"] = WindowRect(*g_pMyShopInventory, g_pMyShopInventory->GetTitleRect()); + elements["my_shop.open"] = WindowRect(*g_pMyShopInventory, ButtonRect(g_pMyShopInventory->GetOpenButton())); + elements["my_shop.close"] = WindowRect(*g_pMyShopInventory, ButtonRect(g_pMyShopInventory->GetCloseButton())); + } if (g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_COMMAND)) { elements["command.trade"] = WindowRect(*g_pCommandWindow, ButtonRect(g_pCommandWindow->GetCommandButton(COMMAND_TRADE))); @@ -157,13 +163,17 @@ std::optional NamedGrid(std::string_view name, int slot) return OpenGrid(SEASON3B::INTERFACE_MIXINVENTORY, g_pMixInventory->GetInventoryCtrl()); if (name == "npc_shop") return OpenGrid(SEASON3B::INTERFACE_NPCSHOP, g_pNPCShop->GetInventoryCtrl()); + if (name == "my_shop") + return OpenGrid(SEASON3B::INTERFACE_MYSHOP_INVENTORY, g_pMyShopInventory->GetInventoryCtrl()); + if (name == "purchase_shop") + return OpenGrid(SEASON3B::INTERFACE_PURCHASESHOP_INVENTORY, g_pPurchaseShopInventory->GetInventoryCtrl()); return std::nullopt; } [[nodiscard]] bool IsKnownGrid(std::string_view name) { return name == "inventory" || name == "trade" || name == "trade_partner" || name == "storage" || name == "mix" || - name == "npc_shop" || name == "equipment"; + name == "npc_shop" || name == "my_shop" || name == "purchase_shop" || name == "equipment"; } std::string SquarePixel(const Request& request, CNewUIInventoryCtrl& grid, int slot) diff --git a/src/source/App/Control/ControlState.cpp b/src/source/App/Control/ControlState.cpp index e5e6123b39..d143e0ca55 100644 --- a/src/source/App/Control/ControlState.cpp +++ b/src/source/App/Control/ControlState.cpp @@ -13,6 +13,7 @@ #include "Engine/Object/ZzzObject.h" #include "Core/Utilities/_GlobalFunctions.h" #include "GameLogic/Items/InventoryUtils.h" +#include "GameLogic/Items/PersonalShopTitleImp.h" #include "GameLogic/Items/ShopRestrictions.h" #include "Network/Server/WSclient.h" #include "Scenes/SceneCore.h" @@ -215,6 +216,58 @@ json NpcShopState() return shop; } +// The goods of a personal shop grid with their prices, by the slot numbers +// the server uses (204 and up); `priceTable` is PSHOPWNDTYPE_SALE for the +// player's own shop, PSHOPWNDTYPE_PURCHASE for the one it visits. +json PersonalShopItems(SEASON3B::CNewUIInventoryCtrl* grid, int priceTable) +{ + json items = json::array(); + for (int i = 0; grid != nullptr && i < static_cast(grid->GetNumberOfItems()); ++i) + { + ITEM* item = grid->GetItem(i); + if (item == nullptr) + { + continue; + } + const int slot = grid->GetIndexByItem(item); + json described = DescribeItem(*item, slot); + int price = 0; + described["price"] = GetPersonalItemPrice(slot, price, priceTable) ? json(price) : json(nullptr); + items.push_back(std::move(described)); + } + return items; +} + +// The player's own personal shop: whether it is open to others, the title in +// its title field, and its goods with their prices. +json MyShopState() +{ + json shop; + shop["open"] = g_pMyShopInventory->IsEnablePersonalShop(); + wchar_t title[MAX_SHOPTITLE + 1] = {}; + g_pMyShopInventory->GetTitle(title); + shop["title"] = Core::Text::ToUtf8(title); + shop["items"] = PersonalShopItems(g_pMyShopInventory->GetInventoryCtrl(), PSHOPWNDTYPE_SALE); + return shop; +} + +// The personal shop the player looks into: whose it is, its title and its +// goods with their prices; null while none is open. +json PurchaseShopState() +{ + if (!g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_PURCHASESHOP_INVENTORY)) + { + return nullptr; + } + + json shop; + const int seller = g_pPurchaseShopInventory->GetShopCharacterIndex(); + shop["seller"] = seller >= 0 && seller < MAX_CHARACTERS_CLIENT ? Core::Text::ToUtf8(CharactersClient[seller].ID) : ""; + shop["title"] = Core::Text::ToUtf8(g_pPurchaseShopInventory->GetTitleText().c_str()); + shop["items"] = PersonalShopItems(g_pPurchaseShopInventory->GetInventoryCtrl(), PSHOPWNDTYPE_PURCHASE); + return shop; +} + json PartyArray() { json members = json::array(); @@ -369,6 +422,8 @@ std::string WorldStateObject() state["party"] = PartyArray(); state["trade"] = TradeState(); state["npc_shop"] = NpcShopState(); + state["my_shop"] = MyShopState(); + state["purchase_shop"] = PurchaseShopState(); // Clicks repair items instead of picking them up (the inventory's repair // button, `L`, or an NPC's repair button). state["repair_mode"] = g_pMyInventory->GetRepairMode() == SEASON3B::REPAIR_MODE_ON; diff --git a/src/source/UI/NewUI/Inventory/NewUIMyShopInventory.h b/src/source/UI/NewUI/Inventory/NewUIMyShopInventory.h index aafa8cec51..afc8a462f7 100644 --- a/src/source/UI/NewUI/Inventory/NewUIMyShopInventory.h +++ b/src/source/UI/NewUI/Inventory/NewUIMyShopInventory.h @@ -72,6 +72,22 @@ namespace SEASON3B CNewUIInventoryCtrl* GetInventoryCtrl() const; + // For the control socket: the Open and Close buttons, and the title field. + CNewUIButton& GetOpenButton() + { + return m_Button[MYSHOPINVENTORY_OPEN]; + } + CNewUIButton& GetCloseButton() + { + return m_Button[MYSHOPINVENTORY_CLOSE]; + } + RECT GetTitleRect() + { + return {m_EditBox->GetPosition_x(), m_EditBox->GetPosition_y(), + m_EditBox->GetPosition_x() + m_EditBox->GetWidth(), + m_EditBox->GetPosition_y() + m_EditBox->GetHeight()}; + } + public: void ChangeSourceIndex(int sindex); const int GetSourceIndex(); diff --git a/tools/InGameTests/Program.cs b/tools/InGameTests/Program.cs index 326815d3bd..857bd98513 100644 --- a/tools/InGameTests/Program.cs +++ b/tools/InGameTests/Program.cs @@ -21,6 +21,7 @@ internal static class Program new TradeInventoryFullScenario(), new ChatScenario(), new PartyScenario(), + new PersonalShopScenario(), new NpcShopScenario(), new RepairScenario(), new IcarusFlyingItemTakeOffScenario(), diff --git a/tools/InGameTests/Scenarios/PersonalShopScenario.cs b/tools/InGameTests/Scenarios/PersonalShopScenario.cs new file mode 100644 index 0000000000..c5ef743c23 --- /dev/null +++ b/tools/InGameTests/Scenarios/PersonalShopScenario.cs @@ -0,0 +1,248 @@ +using System.Globalization; +using System.Text.Json; +using MuMain.Tools.InGameTests.Clients; + +namespace MuMain.Tools.InGameTests.Scenarios; + +/// +/// A personal shop: the seller prices an item, names the shop and opens it; the +/// buyer opens it through the command window and buys the item. The item and +/// the zen change owner, and the shop is gone once it is empty. +/// +internal sealed class PersonalShopScenario : Scenario +{ + private const string Seller = "seller"; + private const string Buyer = "buyer"; + + private const string JewelName = "Jewel of Bless"; + private const string ShopTitle = "In-game test shop"; + // The jewel's full value: a price below it makes the client ask again. + private const int Price = 9_000_000; + + // The command window buys from a player at most two tiles away. + private const int PurchaseDistance = 2; + + private static readonly TimeSpan ServerAnswer = TimeSpan.FromSeconds(10); + + public override string Name => "personal-shop"; + + public override string Description => "a player prices an item and opens a personal shop; another buys it there for the price"; + + public override ScenarioCategory Category => ScenarioCategory.PlayerInteractions; + + public override IReadOnlyList Roles => [Seller, Buyer]; + + public override int StepCount => 12; + + public override async Task RunAsync(ScenarioContext context) + { + var seller = context.Client(Seller); + var buyer = context.Client(Buyer); + var sellerCharacter = TestAccounts.ShopSeller; + var buyerCharacter = TestAccounts.ShopBuyer; + var price = Price.ToString("N0", CultureInfo.InvariantCulture); + + await context.StepAsync( + $"The seller logs in as {sellerCharacter.Name}", + $"{sellerCharacter.Name} enters the world in Devias, where the test data puts the quest characters.", + () => seller.EnterWorldAsync(sellerCharacter.Account, sellerCharacter.Password, sellerCharacter.Name)); + await context.StepAsync( + $"The buyer logs in as {buyerCharacter.Name}", + $"{buyerCharacter.Name} enters the world next to {sellerCharacter.Name}.", + async () => + { + await buyer.EnterWorldAsync(buyerCharacter.Account, buyerCharacter.Password, buyerCharacter.Name); + await Meeting.WalkUpToAsync(buyer, seller, buyerCharacter.Name, PurchaseDistance); + }); + + await context.StepAsync( + "The seller opens the inventory with I and presses its personal shop button", + "The personal shop window opens next to the inventory: an empty grid, a title field and the Open and Close buttons.", + async () => + { + await seller.OpenInventoryAsync(); + await seller.ClickElementAsync("inventory.my_shop"); + await Expect.EventuallyAsync( + async () => (await seller.OpenWindowsAsync()).Contains("my_shop"), + ServerAnswer, + "the personal shop window does not open"); + }, + [Seller]); + + var jewel = ItemSlots.Of(await seller.StateAsync(), "inventory").FirstOrDefault(item => item.Name == JewelName) + ?? throw new ScenarioFailedException($"the seller has no '{JewelName}'; the OpenMU test data gives {sellerCharacter.Name} some"); + var shopSlot = 0; + await context.StepAsync( + $"The seller puts a '{JewelName}' into the shop with two clicks", + $"The first click picks the jewel up from inventory slot {jewel.Slot}, the second puts it into the shop; a dialog asks for its price.", + async () => + { + shopSlot = await FirstShopSlotAsync(seller); + await seller.MoveItemAsync("inventory", jewel.Slot, "my_shop", shopSlot); + await Expect.EventuallyAsync( + async () => (await seller.OpenWindowsAsync()).Contains("message_box"), + ServerAnswer, + "no dialog asks for the price"); + }, + [Seller]); + await context.StepAsync( + $"The seller types the price {price} and confirms with Enter", + $"The dialog closes and the jewel lies in the shop with the price {price} zen.", + async () => + { + await seller.SendAsync("type", new { text = Price.ToString(CultureInfo.InvariantCulture) }); + await Keys.PressUntilAsync( + context, + seller, + "enter", + async () => MyShopItems(await seller.StateAsync()).Any(item => item.Name == JewelName && item.Price == Price), + $"the jewel is not in the shop for {price} zen after Enter"); + }, + [Seller]); + await context.StepAsync( + $"The seller clicks the title field and types \"{ShopTitle}\"", + "The title field shows the shop's name.", + async () => + { + await seller.ClickElementAsync("my_shop.title"); + await seller.SendAsync("type", new { text = ShopTitle }); + var title = string.Empty; + await Expect.EventuallyAsync( + async () => (title = MyShop(await seller.StateAsync()).GetProperty("title").GetString() ?? string.Empty) == ShopTitle, + ServerAnswer, + () => $"the title field shows \"{title}\""); + }, + [Seller]); + await context.StepAsync( + "The seller presses Open", + "Every item has a price and the shop a name, so a dialog asks whether to open the shop.", + async () => + { + await seller.ClickElementAsync("my_shop.open"); + await Expect.EventuallyAsync( + async () => (await seller.OpenWindowsAsync()).Contains("message_box"), + ServerAnswer, + "no dialog asks whether to open the shop"); + }, + [Seller]); + await context.StepAsync( + "The seller confirms with Enter", + $"The shop is open: the server confirms it, and \"{ShopTitle}\" shows above {sellerCharacter.Name}'s head.", + async () => + { + await Keys.PressUntilAsync( + context, + seller, + "enter", + async () => !(await seller.OpenWindowsAsync()).Contains("message_box"), + "the open dialog does not take Enter"); + await Expect.EventuallyAsync( + async () => MyShop(await seller.StateAsync()).GetProperty("open").GetBoolean(), + ServerAnswer, + "the server does not open the shop"); + }); + + await context.StepAsync( + "The buyer opens the command window with D", + "The command window opens with its Buy button.", + async () => + { + await buyer.SendAsync("hotkey", new { key = "d" }); + await Expect.EventuallyAsync( + async () => (await buyer.OpenWindowsAsync()).Contains("command"), + ServerAnswer, + "the command window does not open"); + }, + [Buyer]); + await context.StepAsync( + $"The buyer clicks Buy and right-clicks {sellerCharacter.Name}", + $"The seller's shop opens for the buyer: \"{ShopTitle}\" by {sellerCharacter.Name}, with the jewel for {price} zen.", + async () => + { + await buyer.ClickElementAsync("command.purchase"); + var pixel = await Meeting.PixelOfAsync(buyer, sellerCharacter.Name); + await buyer.SendAsync("click-ui", new { x = pixel.X, y = pixel.Y, button = "right" }); + JsonElement shop = default; + await Expect.EventuallyAsync( + async () => (shop = (await buyer.StateAsync()).GetProperty("purchase_shop")).ValueKind == JsonValueKind.Object, + ServerAnswer, + "the seller's shop does not open for the buyer"); + Expect.That(shop.GetProperty("seller").GetString() == sellerCharacter.Name, $"the shop is {shop.GetProperty("seller").GetString()}'s"); + Expect.That(shop.GetProperty("title").GetString() == ShopTitle, $"the shop is called \"{shop.GetProperty("title").GetString()}\""); + Expect.That( + ShopItems(shop).Any(item => item.Slot == shopSlot && item.Name == JewelName && item.Price == Price), + $"the shop does not offer the jewel in slot {shopSlot} for {price} zen"); + }); + + var buyerHadJewels = await buyer.CountAsync(JewelName); + var buyerHadZen = await buyer.ZenAsync(); + var sellerHadZen = await seller.ZenAsync(); + await context.StepAsync( + "The buyer clicks the jewel in the shop", + $"A dialog shows the jewel and asks whether to buy it for {price} zen.", + async () => + { + await buyer.ClickSlotAsync("purchase_shop", shopSlot); + await Expect.EventuallyAsync( + async () => (await buyer.OpenWindowsAsync()).Contains("message_box"), + ServerAnswer, + "no dialog asks whether to buy the jewel"); + }); + await context.StepAsync( + "The buyer confirms with Enter", + $"The jewel and the zen change owner: {buyerCharacter.Name} has one more jewel and {price} zen less, " + + $"{sellerCharacter.Name} {price} zen more; the shop is empty now and closes.", + async () => + { + await Keys.PressUntilAsync( + context, + buyer, + "enter", + async () => !(await buyer.OpenWindowsAsync()).Contains("message_box"), + "the buy dialog does not take Enter"); + var has = 0; + await Expect.EventuallyAsync( + async () => (has = await buyer.CountAsync(JewelName)) == buyerHadJewels + 1, + ServerAnswer, + () => $"the buyer has {has} '{JewelName}' after buying, not {buyerHadJewels + 1}"); + await ExpectZenAsync(buyer, buyerHadZen - Price); + await ExpectZenAsync(seller, sellerHadZen + Price); + await Expect.EventuallyAsync( + async () => MyShopItems(await seller.StateAsync()).Count == 0, + ServerAnswer, + "the jewel is still in the seller's shop"); + }); + } + + // Something in a personal shop, where it lies and what it costs. + private sealed record ShopItem(int Slot, string Name, long? Price); + + private static JsonElement MyShop(JsonElement state) => state.GetProperty("my_shop"); + + private static IReadOnlyList MyShopItems(JsonElement state) => ShopItems(MyShop(state)); + + private static IReadOnlyList ShopItems(JsonElement shop) + => shop.GetProperty("items").EnumerateArray() + .Select(item => new ShopItem( + item.GetProperty("slot").GetInt32(), + item.GetProperty("name").GetString() ?? string.Empty, + item.GetProperty("price").ValueKind == JsonValueKind.Number ? item.GetProperty("price").GetInt64() : null)) + .ToList(); + + // The first square of the seller's shop grid; the slots start after the inventory's. + private static async Task FirstShopSlotAsync(GameClient seller) + { + var used = MyShopItems(await seller.StateAsync()).Select(item => item.Slot).ToHashSet(); + const int FirstShopSlot = 204; + return Enumerable.Range(FirstShopSlot, 32).First(slot => !used.Contains(slot)); + } + + private static async Task ExpectZenAsync(GameClient client, long expected) + { + var zen = 0L; + await Expect.EventuallyAsync( + async () => (zen = await client.ZenAsync()) == expected, + ServerAnswer, + () => $"the {client.Role} has {zen:N0} zen, not {expected:N0}"); + } +} diff --git a/tools/InGameTests/Scenarios/TestAccounts.cs b/tools/InGameTests/Scenarios/TestAccounts.cs index b677d78a2a..bd33915b8f 100644 --- a/tools/InGameTests/Scenarios/TestAccounts.cs +++ b/tools/InGameTests/Scenarios/TestAccounts.cs @@ -57,6 +57,15 @@ internal static class TestAccounts /// public static readonly TestCharacter FullTraderSecond = new("test8", "test8", "test8Dw"); + /// + /// personal-shop seller: level 150 Dark Knight with 100,000,000 zen and Jewels of Bless; + /// the quest accounts all start on one tile in Devias. + /// + public static readonly TestCharacter ShopSeller = new("quest1", "quest1", "quest1Dk"); + + /// personal-shop buyer: level 220 Dark Knight with 100,000,000 zen, next to the seller in Devias. + public static readonly TestCharacter ShopBuyer = new("quest2", "quest2", "quest2Dk"); + /// party leader: level 1 Dark Knight, at home in Lorencia. public static readonly TestCharacter PartyLeader = new("test0", "test0", "test0Dk"); From 6701977d84bb06d9cf0d9c0320a0c83ff4be8bb0 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Mon, 28 Sep 2026 22:50:25 +0200 Subject: [PATCH 10/18] docs: the new scenarios, accounts and socket fields; fewer screenshots - in-game-tests.md: all nine scenarios with their categories and test accounts, why test items start worn, the shared helpers, what is not covered yet. - control-socket.md: the new `ui` elements, `slot-pixel` grids, state fields (shops, repair and sell prices, max_durability, repair_mode), party events, and that `type` enters text. - Steps where only one player acts picture only that player: 20 fewer screenshots in a run of all scenarios. --- docs/control-socket.md | 24 +++++--- docs/in-game-tests.md | 59 ++++++++++++++----- tools/InGameTests/Scenarios/ChatScenario.cs | 12 ++-- .../Scenarios/PersonalShopScenario.cs | 9 ++- .../Scenarios/TradeInventoryFullScenario.cs | 12 ++-- tools/InGameTests/Scenarios/TradeScenario.cs | 18 ++++-- 6 files changed, 95 insertions(+), 39 deletions(-) diff --git a/docs/control-socket.md b/docs/control-socket.md index 9a53581180..29ddf268e2 100644 --- a/docs/control-socket.md +++ b/docs/control-socket.md @@ -74,8 +74,8 @@ Error codes: `bad_request`, `unknown_command`, `wrong_scene`, `busy`, | `hotkey` (`key`) | press one game key for a frame: `esc`, `i`, `home`, `f1`, … | | `click-ui` (`x`, `y`, `button`) | click a window pixel (`left` by default) | | `type` (`text`) | type text into the text field that has the focus, as the keyboard's text input does, e.g. an amount into the trade's zen box; `not_open` when no field has the focus | -| `ui` | the open windows by name — `inventory`, `inventory_extension`, `character`, `trade`, `storage`, `storage_extension`, `mix`, `npc_shop`, `lucky_item`, `chat_input`, `party`, `command`, `my_shop`, `purchase_shop`, and `message_box` while a dialog waits for Enter or Esc — and the window pixels of named elements: `trade.confirm`, `trade.zen` | -| `slot-pixel` (`grid`, `slot`) | the window pixel of a slot's square: `inventory` and `equipment` (the slot numbers `state` reports), `trade`, `trade_partner`, `storage`, `mix`; `not_open` while that window is closed, `bad_request` for a slot the grid does not have | +| `ui` | the open windows by name — `inventory`, `inventory_extension`, `character`, `trade`, `storage`, `storage_extension`, `mix`, `npc_shop`, `lucky_item`, `chat_input`, `party`, `command`, `my_shop`, `purchase_shop`, and `message_box` while a dialog waits for Enter or Esc — and the window pixels of the named elements that are shown: `trade.confirm`, `trade.zen`, `inventory.repair`, `inventory.my_shop`, `npc_shop.repair`, `npc_shop.repair_all`, `my_shop.title`, `my_shop.open`, `my_shop.close`, `command.trade`, `command.purchase`, `command.party` | +| `slot-pixel` (`grid`, `slot`) | the window pixel of a slot's square: `inventory` and `equipment` (the slot numbers `state` reports), `trade`, `trade_partner`, `storage`, `mix`, `npc_shop`, and the personal shops `my_shop` and `purchase_shop` (slots 204 and up, as `state` and the server number them); `not_open` while that window is closed, `bad_request` for a slot the grid does not have | | `login` (`account`, `password`, `server`) | server selection, credentials, character list | | `select-char` (`name` or `slot`) | enter the world with that character | | `logout`, `quit` | back to the character list; close the client | @@ -96,9 +96,19 @@ character name, class, level, experience, zen, HP/mana/SD/AG with their maxima, map number and name, position, alive flag, safe-zone flag, current target, the skills the character owns, equipment, inventory, buffs, party, the open trade (partner, both offers with their items and zen, both confirm buttons and `my_confirm_wait`, the frames until my button takes clicks again, or `null`) and -`nearby`. An item carries `slot`, `name`, `level`, `durability`, and its -`width` and `height` in inventory squares; one that covers several squares is -listed once for each of them. +`nearby`. An item carries `slot`, `name`, `level`, `durability`, +`max_durability`, and its `width` and `height` in inventory squares; one that +covers several squares is listed once for each of them. While a click would +repair it (the inventory's repair mode, or an NPC that repairs), a worn item +also carries `repair_price`, and while an NPC shop is open an inventory item +carries `sell_price`: both as the item's tooltip shows them. + +The shops: `npc_shop` is the open NPC shop (`repair_shop`, `tax_rate`, +`repair_all_price` at an NPC that repairs, and its goods with `price`, tax +included) or `null`; `my_shop` is the player's own personal shop (`open`, the +`title` in its title field, its goods with `price`), `purchase_shop` the +personal shop the player looks into (`seller`, `title`, goods with `price`) or +`null`. `repair_mode` says whether a click repairs instead of picking up. Each `nearby` object carries `id`, `kind`, `name`, `position`, and a player, monster or NPC also `alive`, `level` and `hp_percent`. `hp_percent` is a @@ -135,7 +145,7 @@ release frame has run; a second injection while one is in flight answers an observation of the act slot, not a claim on it. The sequence follows *rendered* frames, so an injection sent to a client that is not rendering (the occluded-window case below) answers `timeout` and is dropped rather than -delivered late. Not covered: typing text (`say` sends chat), key chords, drags. +delivered late. Text goes into the focused text field with `type`. Not covered: key chords (Shift+L, Ctrl+Q), drags. ## Events @@ -152,7 +162,7 @@ strictly increasing `seq`, a UTC `time` and its own fields: | `map` | `map`, `map_name`, `position` | | `scene` | `scene` | | `view_enter` / `view_leave` | `object` | -| `party` | `change`, `name` | +| `party` | `change` (`invited` with the inviter's `name`; `list` with the leader's `name` after every change of the members; `left`; `result` with `result` — `failed`, `denied`, `full`, `user_left`, `other_party`, `left`, `opposing_gens`, `battle_zone`, `battle_zone_off` — when an invitation formed no party) | | `trade` | `change` (`requested`, `opened`, `refused`, `unavailable`, `partner_confirm`, `closed`), `name` for a request or an opened trade, `state` (`checked`, `unchecked`, `reset`; `unknown` for a value outside the protocol) for the partner's button, `result` (`completed`, `cancelled`, `inventory_full`, `request_cancelled`, `reinforced_item`) when it closes; `refused` also on the asked side, when a window that forbids trading is open and the client says no by itself | | `disconnect` | `reason` | | `error` | `command`, `error`, `message` | diff --git a/docs/in-game-tests.md b/docs/in-game-tests.md index cb402b33a8..c7ea1f307c 100644 --- a/docs/in-game-tests.md +++ b/docs/in-game-tests.md @@ -83,21 +83,35 @@ each time. ## Test accounts -Every scenario logs in with accounts of its own from OpenMU's test data (the -password is the account name), so two scenarios never share one. The game -master accounts `testgm` and `testgm2` stay free for people. The test data -puts every character in the safe zone of its class's home map, e.g. an Elf in -Noria. +Every scenario logs in with characters of its own from OpenMU's test data +(the password is the account name), so two scenarios never share one. The game +master account `testgm` stays free for people; `testgm2`'s Dimension Master is +the only character that wears something in all twelve equipment slots, so +`equip-all-slots` uses it. The test data puts every character in the safe zone +of its class's home map, e.g. an Elf in Noria, and the quest characters on one +tile in Devias. | Scenario | Account | Character | |---|---|---| | `trade` | `test300`, `socket` | `test300Dl` (level 300 Dark Lord with Jewels of Bless) sells to `socketElf` | +| `trade-inventory-full` | `test7`, `test8` | `test7Dw` and `test8Dw` (no free 2x2 area, pad armour and rows of jewels and potions to offer) | +| `chat` | `test5`, `test6` | `test5Dk` and `test6Dk` | +| `party` | `test0` to `test4` | `test0Dk` (the leader) invites `test1Dk` to `test4Dk` | +| `personal-shop` | `quest1`, `quest2` | `quest1Dk` (Jewels of Bless) sells to `quest2Dk`, both with 100,000,000 zen | +| `npc-shop` | `test7` | `test7Dk` (potions and Jewels of Bless to sell, 25 free squares) | +| `repair` | `test9` | `test9Dk` (level 91, gloves and boots at 30 of 45 durability) | | `icarus-flying-item-take-off` | `test400` | `test400Elf` (level 400 High Elf with a Wing of Illusion, a Horn of Fenrir and room in the inventory) | +| `equip-all-slots` | `testgm2` | `testgm2Sum` (level 400 Dimension Master, all twelve slots worn) | -A new scenario takes an account no other scenario uses. When the test data has -no account with what a scenario needs, OpenMU's test data gets a new one +A new scenario takes a character no other scenario uses. When the test data +has no character with what a scenario needs, OpenMU's test data gets a new one (`VersionSeasonSix/TestAccounts`). +OpenMU gives an item of a level above 0, an excellent or an ancient item its +base durability, so such items in the test data start worn, below their +maximum: that is what `repair` repairs. An item at 0 durability cannot be made +in the game without OpenMU's admin panel. + ## Running them ### The window @@ -230,7 +244,14 @@ them under these categories. | Category | Scenario | Checks | |---|---|---| +| Player Interactions | `chat` | Two players meet in Lorencia. One opens the chat box with Enter, types a line and sends it with Enter; the other answers the same way. Both lines show in both chat logs, from the right sender. | +| Player Interactions | `party` | Five clients. The leader opens the command window with D; for each of four players it clicks Party and right-clicks the player, and the player accepts the invitation with Enter. The party lists grow to five, the leader first, and all five show the same party. | +| Player Interactions | `personal-shop` | The seller opens its personal shop from the inventory, puts a jewel into it with two clicks, types its price and the shop's title, and opens the shop (a dialog asks, Enter confirms). The buyer opens the shop through the command window (D, Buy, a right-click on the seller), clicks the jewel and confirms: the jewel and the price change owner, and the seller's shop is empty. | +| Player Interactions | `trade-inventory-full` | Three trades that cannot complete: the second player has no room for the first one's 2x2 item, the first none for the second one's, and neither for what the other offers. Each time both confirm, the trade closes for both with "inventory full", and both keep the same items and zen. | | Player Interactions | `trade` | Two clients warp to Lorencia, walk up to each other and open a trade. The seller puts a jewel into the trade window with two clicks, the buyer types an amount of zen into the trade's zen box, both press the confirm button, and both inventories show that jewel and zen changed owner: the buyer has one more jewel and that much less zen, the seller one jewel fewer and that much more zen (sven-n/MuMain#588). | +| NPC Interactions | `npc-shop` | Talks to Hanzo the Blacksmith with a click on him. Drags a potion stack onto his shop (sold at once) and a Jewel of Bless (a dialog asks, Enter confirms), each for the selling price its tooltip shows, then buys the cheapest of his goods that fits, for its price. | +| NPC Interactions | `repair` | Repairs the worn gloves with the inventory's repair button, then talks to Hanzo the Blacksmith and repairs the boots with Repair all. Each repair brings the durability back to its maximum and costs zen; the log lists what it cost and what the client showed. | +| Game Behaviour | `equip-all-slots` | Slot by slot, drags the item of each of the twelve equipment slots onto a free area of the inventory with two clicks, and puts it back on with a right-click on it. | | Game Behaviour | `icarus-flying-item-take-off` | An Elf with wings and a Horn of Fenrir warps to Icarus. Right-clicking the wings takes them off, because the Fenrir flies; the Fenrir, now the last flying item, stays on both on a right-click and when dragged. Then the other way round: with the wings back on, right-clicking the Fenrir takes it off, and the wings, now the last flying item, stay on both ways (sven-n/MuMain#631). Both are put back on afterwards. | ## Writing a scenario @@ -239,9 +260,9 @@ A scenario is a class in `tools/InGameTests/Scenarios` that derives from `Scenario` and is listed in `Program.AllScenarios`. It names the clients it needs by role and gets them started and logged out, says its `Category` (`PlayerInteractions` for players doing something with each other, -`GameBehaviour` for how the game treats a player, e.g. the rules of a map; a -new kind gets a new value in `ScenarioCategory`), and its `StepCount` for -the progress bar. +`NpcInteractions` for a player and an NPC, `GameBehaviour` for how the game +treats a player, e.g. the rules of a map; a new kind gets a new value in +`ScenarioCategory`), and its `StepCount` for the progress bar. - **Write it as steps.** Everything a scenario does goes through `context.StepAsync(title, expectation, action)`: the title says what is @@ -249,7 +270,8 @@ the progress bar. then, for someone who reads the report without knowing the code ("The trade window opens for both characters"), and the action does it and checks that it happened. After each step every client takes a screenshot, so a step is - one thing a person can see. + one thing a person can see; a step of a scenario with many clients can name + the ones that show it (`pictured`), which keeps the report small. - **Test the real input path.** What the scenario checks has to go through the same code a player's click or key goes through: `ClickSlotAsync`, @@ -257,7 +279,14 @@ the progress bar. `hotkey` command. Direct commands (`login`, `warp`, `move`, `trade request`, `equip`) are fine for the setup. - **Ask for pixels, don't hard-code them.** The windows follow the responsive - layout; `slot-pixel` and `ui` give the pixels for the current window size. + layout; `slot-pixel` and `ui` give the pixels for the current window size, + and `nearby` where a player or an NPC is drawn (`Meeting.PixelOfAsync`, + `Npcs.TalkAsync`). +- **Use the shared steps.** `Meeting` walks characters to a spot or to each + other, `Trading` requests, accepts and confirms a trade, `Npcs` walks up to + an NPC and talks to it, and `Keys.PressUntilAsync` presses a key until its + effect shows (an injected key is lost now and then; each extra press is + noted in the log). - **Wait for the server.** A click is answered some frames later. Use `Expect.EventuallyAsync` for something that has to happen, and `Expect.StillAfterAsync` for a refusal, where nothing happening is the @@ -268,12 +297,12 @@ the progress bar. which puts the wings back on). - **Don't count on where a character stands.** A warp to a town lands anywhere in it, so walks take different times from run to run (see - `TradeScenario.WalkUpToAsync`). + `Meeting.WalkUpToAsync`). ## Not covered yet -- NPC windows: the chaos machine, storage and the NPC shop need a way to talk - to an NPC and open its window. +- NPC windows other than shops: the chaos machine (sven-n/MuMain#587) and + storage. Talking to their NPCs works as in `npc-shop`. - The item rule flags, e.g. that an item with `"tradable": false` cannot be put into the trade window. - Running the tests in CI next to an OpenMU container. diff --git a/tools/InGameTests/Scenarios/ChatScenario.cs b/tools/InGameTests/Scenarios/ChatScenario.cs index dcf0fbc04e..6427d530e5 100644 --- a/tools/InGameTests/Scenarios/ChatScenario.cs +++ b/tools/InGameTests/Scenarios/ChatScenario.cs @@ -42,11 +42,13 @@ public override async Task RunAsync(ScenarioContext context) await context.StepAsync( $"The first player logs in as {firstCharacter.Name}", $"{firstCharacter.Name} enters the world in Lorencia, its home town.", - () => first.EnterWorldAsync(firstCharacter.Account, firstCharacter.Password, firstCharacter.Name)); + () => first.EnterWorldAsync(firstCharacter.Account, firstCharacter.Password, firstCharacter.Name), + [First]); await context.StepAsync( $"The second player logs in as {secondCharacter.Name}", $"{secondCharacter.Name} enters the world in Lorencia, somewhere else in the town.", - () => second.EnterWorldAsync(secondCharacter.Account, secondCharacter.Password, secondCharacter.Name)); + () => second.EnterWorldAsync(secondCharacter.Account, secondCharacter.Password, secondCharacter.Name), + [Second]); await context.StepAsync( "The first player walks to the meeting spot", $"{firstCharacter.Name} stands at ({MeetingX},{MeetingY}), a free spot in Lorencia's town, or one tile from it.", @@ -54,7 +56,8 @@ await context.StepAsync( { await first.WarpAsync(LorenciaGate, LorenciaMap); await Meeting.WalkToAsync(first, MeetingX, MeetingY); - }); + }, + [First]); await context.StepAsync( "The second player walks up to the first", $"{secondCharacter.Name} stands at most {ChatDistance} tiles from {firstCharacter.Name}: normal chat reaches the players in view.", @@ -66,7 +69,8 @@ await context.StepAsync( await context.StepAsync( "The first player opens the chat box with Enter", "The chat box opens at the bottom of the screen with the cursor in its text field.", - () => OpenChatAsync(context, first)); + () => OpenChatAsync(context, first), + [First]); await context.StepAsync( $"The first player types \"{line}\" and sends it with Enter", $"The chat box closes, and the line shows in both chat logs as said by {firstCharacter.Name}.", diff --git a/tools/InGameTests/Scenarios/PersonalShopScenario.cs b/tools/InGameTests/Scenarios/PersonalShopScenario.cs index c5ef743c23..e9d01d987d 100644 --- a/tools/InGameTests/Scenarios/PersonalShopScenario.cs +++ b/tools/InGameTests/Scenarios/PersonalShopScenario.cs @@ -45,7 +45,8 @@ public override async Task RunAsync(ScenarioContext context) await context.StepAsync( $"The seller logs in as {sellerCharacter.Name}", $"{sellerCharacter.Name} enters the world in Devias, where the test data puts the quest characters.", - () => seller.EnterWorldAsync(sellerCharacter.Account, sellerCharacter.Password, sellerCharacter.Name)); + () => seller.EnterWorldAsync(sellerCharacter.Account, sellerCharacter.Password, sellerCharacter.Name), + [Seller]); await context.StepAsync( $"The buyer logs in as {buyerCharacter.Name}", $"{buyerCharacter.Name} enters the world next to {sellerCharacter.Name}.", @@ -53,7 +54,8 @@ await context.StepAsync( { await buyer.EnterWorldAsync(buyerCharacter.Account, buyerCharacter.Password, buyerCharacter.Name); await Meeting.WalkUpToAsync(buyer, seller, buyerCharacter.Name, PurchaseDistance); - }); + }, + [Buyer]); await context.StepAsync( "The seller opens the inventory with I and presses its personal shop button", @@ -187,7 +189,8 @@ await Expect.EventuallyAsync( async () => (await buyer.OpenWindowsAsync()).Contains("message_box"), ServerAnswer, "no dialog asks whether to buy the jewel"); - }); + }, + [Buyer]); await context.StepAsync( "The buyer confirms with Enter", $"The jewel and the zen change owner: {buyerCharacter.Name} has one more jewel and {price} zen less, " diff --git a/tools/InGameTests/Scenarios/TradeInventoryFullScenario.cs b/tools/InGameTests/Scenarios/TradeInventoryFullScenario.cs index f50c2754f4..a0215b740c 100644 --- a/tools/InGameTests/Scenarios/TradeInventoryFullScenario.cs +++ b/tools/InGameTests/Scenarios/TradeInventoryFullScenario.cs @@ -42,11 +42,13 @@ public override async Task RunAsync(ScenarioContext context) await context.StepAsync( $"The first player logs in as {firstCharacter.Name}", $"{firstCharacter.Name} enters the world in Lorencia with no free 2x2 area in its inventory.", - () => first.EnterWorldAsync(firstCharacter.Account, firstCharacter.Password, firstCharacter.Name)); + () => first.EnterWorldAsync(firstCharacter.Account, firstCharacter.Password, firstCharacter.Name), + [First]); await context.StepAsync( $"The second player logs in as {secondCharacter.Name}", $"{secondCharacter.Name} enters the world in Lorencia, with no free 2x2 area either.", - () => second.EnterWorldAsync(secondCharacter.Account, secondCharacter.Password, secondCharacter.Name)); + () => second.EnterWorldAsync(secondCharacter.Account, secondCharacter.Password, secondCharacter.Name), + [Second]); await context.StepAsync( "The first player walks to the meeting spot", $"{firstCharacter.Name} stands at ({MeetingX},{MeetingY}), a free spot in Lorencia's town, or one tile from it.", @@ -54,7 +56,8 @@ await context.StepAsync( { await first.WarpAsync(LorenciaGate, LorenciaMap); await Meeting.WalkToAsync(first, MeetingX, MeetingY); - }); + }, + [First]); await context.StepAsync( "The second player walks up to the first", $"{secondCharacter.Name} stands next to {firstCharacter.Name}: a trade needs the partner at most one tile away.", @@ -133,7 +136,8 @@ private static async Task RoundAsync( await context.StepAsync( "The first player asks the second for a trade", $"{secondName} gets the request: a dialog asks whether to trade.", - () => Trading.RequestAsync(first, second, secondName)); + () => Trading.RequestAsync(first, second, secondName), + [Second]); await context.StepAsync( "The second player accepts with Enter", "The trade window opens for both players.", diff --git a/tools/InGameTests/Scenarios/TradeScenario.cs b/tools/InGameTests/Scenarios/TradeScenario.cs index 89f08c6637..07a7307d48 100644 --- a/tools/InGameTests/Scenarios/TradeScenario.cs +++ b/tools/InGameTests/Scenarios/TradeScenario.cs @@ -44,23 +44,28 @@ public override async Task RunAsync(ScenarioContext context) await context.StepAsync( $"The seller logs in as {sellerCharacter.Name}", $"{sellerCharacter.Name} enters the world; the test data puts it in the safe zone of its home map.", - () => seller.EnterWorldAsync(sellerCharacter.Account, sellerCharacter.Password, sellerCharacter.Name)); + () => seller.EnterWorldAsync(sellerCharacter.Account, sellerCharacter.Password, sellerCharacter.Name), + [Seller]); await context.StepAsync( $"The buyer logs in as {buyerCharacter.Name}", $"{buyerCharacter.Name} enters the world in the safe zone of its home map.", - () => buyer.EnterWorldAsync(buyerCharacter.Account, buyerCharacter.Password, buyerCharacter.Name)); + () => buyer.EnterWorldAsync(buyerCharacter.Account, buyerCharacter.Password, buyerCharacter.Name), + [Buyer]); await context.StepAsync( "The seller warps to Lorencia", $"{sellerCharacter.Name} stands in Lorencia's town. A warp to a town lands anywhere in it.", - () => seller.WarpAsync(LorenciaGate, LorenciaMap)); + () => seller.WarpAsync(LorenciaGate, LorenciaMap), + [Seller]); await context.StepAsync( "The buyer warps to Lorencia", $"{buyerCharacter.Name} stands in Lorencia's town, somewhere else than the seller.", - () => buyer.WarpAsync(LorenciaGate, LorenciaMap)); + () => buyer.WarpAsync(LorenciaGate, LorenciaMap), + [Buyer]); await context.StepAsync( "The seller walks to the meeting spot", $"{sellerCharacter.Name} stands at ({MeetingX},{MeetingY}), a free spot in Lorencia's town, or one tile from it.", - () => Meeting.WalkToAsync(seller, MeetingX, MeetingY)); + () => Meeting.WalkToAsync(seller, MeetingX, MeetingY), + [Seller]); await context.StepAsync( "The buyer walks up to the seller", $"{buyerCharacter.Name} stands on a tile next to {sellerCharacter.Name}: a trade needs the partner at most one tile away.", @@ -76,7 +81,8 @@ await context.StepAsync( await context.StepAsync( "The seller asks the buyer for a trade", $"{buyerCharacter.Name} gets the request: a dialog asks whether to trade with {sellerCharacter.Name}.", - () => Trading.RequestAsync(seller, buyer, buyerCharacter.Name)); + () => Trading.RequestAsync(seller, buyer, buyerCharacter.Name), + [Buyer]); await context.StepAsync( "The buyer accepts with Enter", "The trade window opens for both characters, each with the inventory next to it.", From ae88e9ec6553d196987707fc3a435cc1fcdac540 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Mon, 28 Sep 2026 22:50:52 +0200 Subject: [PATCH 11/18] style: clang-format the changed lines --- src/MuEditor/Core/MuEditorCore.cpp | 5 ++-- src/MuEditor/Core/MuInputBlockerCore.cpp | 2 +- src/source/App/Control/ControlCommandsUi.cpp | 27 ++++++++++++------- src/source/App/Control/ControlState.cpp | 6 +++-- src/source/App/Control/ControlTaps.cpp | 4 +-- .../UI/NewUI/Inventory/NewUIMyInventory.cpp | 14 +++++----- 6 files changed, 34 insertions(+), 24 deletions(-) diff --git a/src/MuEditor/Core/MuEditorCore.cpp b/src/MuEditor/Core/MuEditorCore.cpp index 1d26d0b064..4fad7499db 100644 --- a/src/MuEditor/Core/MuEditorCore.cpp +++ b/src/MuEditor/Core/MuEditorCore.cpp @@ -502,9 +502,8 @@ void CMuEditorCore::Update() // A click the control socket injects is somewhere else than the real // pointer, which is what ImGui's mouse position follows. - if (Core::Input::Synthetic::IsIdle() && io.MousePos.x >= buttonX && - io.MousePos.x <= (buttonX + buttonWidth) && io.MousePos.y >= buttonY && - io.MousePos.y <= (buttonY + buttonHeight)) + if (Core::Input::Synthetic::IsIdle() && io.MousePos.x >= buttonX && io.MousePos.x <= (buttonX + buttonWidth) && + io.MousePos.y >= buttonY && io.MousePos.y <= (buttonY + buttonHeight)) { // Mouse is over button - block game input for this frame extern bool MouseLButton, MouseLButtonPop, MouseLButtonPush, MouseLButtonDBClick; diff --git a/src/MuEditor/Core/MuInputBlockerCore.cpp b/src/MuEditor/Core/MuInputBlockerCore.cpp index f1b72ace9a..c5fb844012 100644 --- a/src/MuEditor/Core/MuInputBlockerCore.cpp +++ b/src/MuEditor/Core/MuInputBlockerCore.cpp @@ -23,7 +23,7 @@ void CMuInputBlockerCore::ProcessInputBlocking() // click the control socket injects never went through ImGui, whatever the // real pointer hovers. bool shouldBlockInput = (io.WantCaptureMouse || io.WantCaptureKeyboard) && Core::Input::Synthetic::IsIdle(); - + if (!shouldBlockInput) return; diff --git a/src/source/App/Control/ControlCommandsUi.cpp b/src/source/App/Control/ControlCommandsUi.cpp index 72cf9a3a10..1997d2431c 100644 --- a/src/source/App/Control/ControlCommandsUi.cpp +++ b/src/source/App/Control/ControlCommandsUi.cpp @@ -33,13 +33,20 @@ struct NamedWindow // The windows `ui` reports when they are open. constexpr NamedWindow Windows[] = { - {"inventory", SEASON3B::INTERFACE_INVENTORY}, {"inventory_extension", SEASON3B::INTERFACE_INVENTORY_EXT}, - {"character", SEASON3B::INTERFACE_CHARACTER}, {"trade", SEASON3B::INTERFACE_TRADE}, - {"storage", SEASON3B::INTERFACE_STORAGE}, {"storage_extension", SEASON3B::INTERFACE_STORAGE_EXT}, - {"mix", SEASON3B::INTERFACE_MIXINVENTORY}, {"npc_shop", SEASON3B::INTERFACE_NPCSHOP}, - {"lucky_item", SEASON3B::INTERFACE_LUCKYITEMWND}, {"chat_input", SEASON3B::INTERFACE_CHATINPUTBOX}, - {"party", SEASON3B::INTERFACE_PARTY}, {"command", SEASON3B::INTERFACE_COMMAND}, - {"my_shop", SEASON3B::INTERFACE_MYSHOP_INVENTORY}, {"purchase_shop", SEASON3B::INTERFACE_PURCHASESHOP_INVENTORY}, + {"inventory", SEASON3B::INTERFACE_INVENTORY}, + {"inventory_extension", SEASON3B::INTERFACE_INVENTORY_EXT}, + {"character", SEASON3B::INTERFACE_CHARACTER}, + {"trade", SEASON3B::INTERFACE_TRADE}, + {"storage", SEASON3B::INTERFACE_STORAGE}, + {"storage_extension", SEASON3B::INTERFACE_STORAGE_EXT}, + {"mix", SEASON3B::INTERFACE_MIXINVENTORY}, + {"npc_shop", SEASON3B::INTERFACE_NPCSHOP}, + {"lucky_item", SEASON3B::INTERFACE_LUCKYITEMWND}, + {"chat_input", SEASON3B::INTERFACE_CHATINPUTBOX}, + {"party", SEASON3B::INTERFACE_PARTY}, + {"command", SEASON3B::INTERFACE_COMMAND}, + {"my_shop", SEASON3B::INTERFACE_MYSHOP_INVENTORY}, + {"purchase_shop", SEASON3B::INTERFACE_PURCHASESHOP_INVENTORY}, }; // A point of a window, from its window-local coordinates to window pixels. @@ -100,10 +107,12 @@ json Elements() } if (g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_COMMAND)) { - elements["command.trade"] = WindowRect(*g_pCommandWindow, ButtonRect(g_pCommandWindow->GetCommandButton(COMMAND_TRADE))); + elements["command.trade"] = + WindowRect(*g_pCommandWindow, ButtonRect(g_pCommandWindow->GetCommandButton(COMMAND_TRADE))); elements["command.purchase"] = WindowRect(*g_pCommandWindow, ButtonRect(g_pCommandWindow->GetCommandButton(COMMAND_PURCHASE))); - elements["command.party"] = WindowRect(*g_pCommandWindow, ButtonRect(g_pCommandWindow->GetCommandButton(COMMAND_PARTY))); + elements["command.party"] = + WindowRect(*g_pCommandWindow, ButtonRect(g_pCommandWindow->GetCommandButton(COMMAND_PARTY))); } if (g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_NPCSHOP) && g_pNPCShop->IsRepairShop()) { diff --git a/src/source/App/Control/ControlState.cpp b/src/source/App/Control/ControlState.cpp index d143e0ca55..0ff565629d 100644 --- a/src/source/App/Control/ControlState.cpp +++ b/src/source/App/Control/ControlState.cpp @@ -72,7 +72,8 @@ void AddRepairPrice(json& described, const ITEM& item) // The tooltip's own function, which picks the NPC or the self-repair price. wchar_t text[100] = {}; - described["repair_price"] = ConvertRepairGold(ItemValue(repaired, 2), item.Durability, maxDurability, item.Type, text); + described["repair_price"] = + ConvertRepairGold(ItemValue(repaired, 2), item.Durability, maxDurability, item.Type, text); } // The items of a trade grid, by the grid's own slot numbers. @@ -262,7 +263,8 @@ json PurchaseShopState() json shop; const int seller = g_pPurchaseShopInventory->GetShopCharacterIndex(); - shop["seller"] = seller >= 0 && seller < MAX_CHARACTERS_CLIENT ? Core::Text::ToUtf8(CharactersClient[seller].ID) : ""; + shop["seller"] = + seller >= 0 && seller < MAX_CHARACTERS_CLIENT ? Core::Text::ToUtf8(CharactersClient[seller].ID) : ""; shop["title"] = Core::Text::ToUtf8(g_pPurchaseShopInventory->GetTitleText().c_str()); shop["items"] = PersonalShopItems(g_pPurchaseShopInventory->GetInventoryCtrl(), PSHOPWNDTYPE_PURCHASE); return shop; diff --git a/src/source/App/Control/ControlTaps.cpp b/src/source/App/Control/ControlTaps.cpp index 1e403cce78..e99c530b7b 100644 --- a/src/source/App/Control/ControlTaps.cpp +++ b/src/source/App/Control/ControlTaps.cpp @@ -234,8 +234,8 @@ void RecordPartyInvited(int inviterKey) void RecordPartyAnswer(int result) { - constexpr std::string_view Results[] = {"failed", "denied", "full", "user_left", "other_party", - "left", "opposing_gens", "battle_zone", "battle_zone_off"}; + constexpr std::string_view Results[] = {"failed", "denied", "full", "user_left", "other_party", + "left", "opposing_gens", "battle_zone", "battle_zone_off"}; RecordPartyResult(result >= 0 && result < static_cast(std::size(Results)) ? Results[result] : "unknown"); } diff --git a/src/source/UI/NewUI/Inventory/NewUIMyInventory.cpp b/src/source/UI/NewUI/Inventory/NewUIMyInventory.cpp index abd53e65e9..89d5df0213 100644 --- a/src/source/UI/NewUI/Inventory/NewUIMyInventory.cpp +++ b/src/source/UI/NewUI/Inventory/NewUIMyInventory.cpp @@ -1714,13 +1714,13 @@ bool CNewUIMyInventory::IsRepairEnableLevel() const bool CNewUIMyInventory::AreShopButtonsShown() const { - return g_pNewUISystem->IsVisible(INTERFACE_NPCSHOP) == false - && g_pNewUISystem->IsVisible(INTERFACE_TRADE) == false - && g_pNewUISystem->IsVisible(INTERFACE_DEVILSQUARE) == false - && g_pNewUISystem->IsVisible(INTERFACE_BLOODCASTLE) == false - && g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_LUCKYITEMWND) == false - && g_pNewUISystem->IsVisible(INTERFACE_MIXINVENTORY) == false - && g_pNewUISystem->IsVisible(INTERFACE_STORAGE) == false; + return g_pNewUISystem->IsVisible(INTERFACE_NPCSHOP) == false && + g_pNewUISystem->IsVisible(INTERFACE_TRADE) == false && + g_pNewUISystem->IsVisible(INTERFACE_DEVILSQUARE) == false && + g_pNewUISystem->IsVisible(INTERFACE_BLOODCASTLE) == false && + g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_LUCKYITEMWND) == false && + g_pNewUISystem->IsVisible(INTERFACE_MIXINVENTORY) == false && + g_pNewUISystem->IsVisible(INTERFACE_STORAGE) == false; } CNewUIButton* CNewUIMyInventory::GetShownRepairButton() From a616bad1088395d43aa8e83c9b8004447045fc24 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Tue, 29 Sep 2026 09:38:09 +0200 Subject: [PATCH 12/18] feat(tools): the test list stays usable while a run goes on, and shows more - The list takes the window's free height, and each test is one line (the description in full on hover); the log sits below a divider that can be dragged. Eight tests and the three categories fit at once in the default window, where two did before. - A run no longer disables the list: it scrolls and folds as before, only the checkboxes and the wait and quality fields are locked. - The running test's row is highlighted, its category opens and the row scrolls into view when it starts; a "Now" line under the overall bar shows the test and its current step. - "Open report folder" also works during a run. --- docs/in-game-tests.md | 15 +- tools/InGameTests/Gui/MainWindow.axaml | 192 +++++++++++++--------- tools/InGameTests/Gui/MainWindow.axaml.cs | 64 +++++++- tools/InGameTests/Gui/ScenarioRow.cs | 15 ++ 4 files changed, 196 insertions(+), 90 deletions(-) diff --git a/docs/in-game-tests.md b/docs/in-game-tests.md index c7ea1f307c..e36a6dedcb 100644 --- a/docs/in-game-tests.md +++ b/docs/in-game-tests.md @@ -123,9 +123,11 @@ build everything and open it: cmake --build out/build/windows-x64-mueditor --config Release --target InGameTestsGui ``` -The window lists every scenario with a checkbox, grouped by category in boxes -that fold in and out (it remembers which are folded in); **Check all** toggles -them all. It has a **Wait after each action** field: the milliseconds each +The window lists every scenario on one line with a checkbox, grouped by +category in boxes that fold in and out (it remembers which are folded in); +hovering a description shows it in full, and **Check all** toggles them all. +The list takes the window's free height; the divider above the log below it +can be dragged. It has a **Wait after each action** field: the milliseconds each client pauses after every click, key, walk or warp, so a person can follow what happens; `1000` is easy to watch, `0` runs at full speed. The **Screenshot quality** field sets the JPEG quality of the step screenshots (1 to 100, default 70): @@ -133,8 +135,11 @@ lower makes the report smaller, e.g. 40 takes a run of both scenarios from about 8 MB to about 5 MB. A scenario row can have its own wait and its own quality, which then win over the fields. **Run and write report** runs the checked scenarios one after the other; a bar over the list shows the steps of -all of them together (and at the end PASSED in green, or FAILED in red), and -each row its own progress and current step. **Open report** opens the report of +all of them together (and at the end PASSED in green, or FAILED in red), a +**Now** line below it the running scenario and its step, and each row its own +progress. The running scenario's row is highlighted and scrolled into view +when it starts; the list stays free to scroll and fold while the run goes on, +only the checkboxes and fields wait for its end. **Open report** opens the report of the run, and **Open report folder** shows the file in the file browser, ready to be dragged into a pull request comment (before a run: the folder the reports go to). The window remembers its diff --git a/tools/InGameTests/Gui/MainWindow.axaml b/tools/InGameTests/Gui/MainWindow.axaml index 7985e3f6d3..6192738d19 100644 --- a/tools/InGameTests/Gui/MainWindow.axaml +++ b/tools/InGameTests/Gui/MainWindow.axaml @@ -3,11 +3,27 @@ xmlns:gui="using:MuMain.Tools.InGameTests.Gui" x:Class="MuMain.Tools.InGameTests.Gui.MainWindow" Title="MuMain in-game tests" - Width="1040" Height="760" MinWidth="760" MinHeight="520"> + Width="1100" Height="880" MinWidth="820" MinHeight="560"> + + + + + + + + - +