diff --git a/docs/control-socket.md b/docs/control-socket.md index 9551395ee4..b42848e507 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 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` | -| `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`, `npc_quest` (the quest dialog of Sebina, Marlon and Devin), 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`, `npc_quest.answer.0` and on (the rows of the quest dialog's answers), `npc_quest.complete`, `npc_quest.close`, `character.stat.strength` (`agility`, `vitality`, `energy`, `command`: the character window's "+" buttons, while there are level-up points) | +| `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,15 +96,41 @@ 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. + +For the quests and their rewards: `class_name` (e.g. `Blade Knight`; `class` +is the client's number, e.g. 1 Dark Knight, 8 Blade Knight, 12 Blade Master), +`level_up_points`, `stats` (`strength`, `agility`, `vitality`, `energy`, +`command`, without bonuses), `combo` (the Blade Knight's combo from Marlon), +`quests` (the seven legacy quests with `index`, `name` and `state`: +`active`, `complete`, `not_started`, `none`, or `unknown` for a value outside +these, as in the `quest` events), and `npc_quest`, the quest +dialog on screen (`quest`, `page`, `text`, `need_zen`, and its `answers`, each +with its `text` and `action`: `next` turns the page, `accept` takes the quest, +`complete` hands it in, `close` ends the talk) or `null`. 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 @@ -130,7 +156,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 @@ -147,7 +173,8 @@ 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` | +| `quest` | `change`: `state` with the legacy `quest` and its new `state` (`active`, `complete`, `not_started`, `none`, `unknown`), or `reward` with the character's `name`, the `reward` (`level_up_points`, `second_class`, `points_per_level`, `combo`, `third_class`), its `amount` (none for the two class changes, whose new class is `class`) and the character's `class` afterwards; the client records the rewards of every player in view, so a script matches its own `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` | @@ -180,11 +207,12 @@ The event recorders are one-line calls named `App::Control::Events::Record…`, sitting at the end of the packet receive functions in `src/source/Network/Server/WSclient.cpp` (hits, deaths, experience, stats, chat, whisper, drops appearing and vanishing, view enter/leave, party changes, +invitations and answers, quest states and rewards, trade steps, logout) plus the scene and map watcher in `App/Control/ControlServer.cpp`. When one of those functions is rewritten: 1. `rg -c 'App::Control::Events::' src/source/Network/Server/WSclient.cpp` — - the count is 27; a lower one means a tap was dropped. Compare it against + the count is 32; a lower one means a tap was dropped. Compare it against `git show upstream/main:…` when the number itself is in doubt: the count is a smoke test, the list above is the contract. 2. Re-run the live checks that cover the dropped tap (a fight records `hit`, diff --git a/docs/in-game-tests.md b/docs/in-game-tests.md index cb402b33a8..6915e066cf 100644 --- a/docs/in-game-tests.md +++ b/docs/in-game-tests.md @@ -76,6 +76,14 @@ file with the connection to the machine is missing (`podman system connection list` is empty); the runner then connects as `podman machine inspect` describes the machine, so nothing has to be set up by hand. +Marlon, whom the hero status and combo quests need, wanders between four +spots in Devias, Lorencia, Noria and Atlans: OpenMU puts him on a random one +some 20 seconds after it starts and moves him every one to three hours. The +compose file has OpenMU log each of his moves (Debug level for its +`WanderingMerchantsPlugIn`, with the game server in every line), and the +tester reads the container's log to walk straight to him; without the log it +looks on his spots, which takes about a minute. + The tests also run against any other OpenMU with the test data, but then they start from whatever state the last run left: `trade`, for one, moves a jewel from `test300Dl` to `socketElf` @@ -83,21 +91,41 @@ 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, next to Sebina the Priest and Apostle Devin. The quest +characters are all first class with 100,000,000 zen; a class change or a +finished quest lasts until the test server is recreated, so the quest +scenarios need `--fresh-server` (or **Recreate the test server**) to run again. | 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` | `quest1Dl` (Jewels of Bless) sells to `quest2Dl`, 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) | +| `quest-blade-knight`, `quest-soul-master`, `quest-muse-elf` | `quest1` | `quest1Dk`, `quest1Dw`, `quest1Elf` (level 150, first class, with Sebina's items) | +| `quest-combo` | `quest2` | `quest2Dk` (level 220, with Sebina's items, the Ring of Honor and the Dark Stone) | +| `quest-hero-status-wizard`, `quest-hero-status-elf`, `quest-evidence-of-strength` | `quest3` | `quest3Dw`, `quest3Elf` (level 400, with Sebina's items and the Ring of Honor), `quest3Dl` (with Devin's three items) | -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 @@ -109,9 +137,13 @@ 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; +each category's header has its own **Check all** / **Uncheck all** for its +tests. +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): @@ -119,8 +151,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 @@ -230,7 +265,21 @@ 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. | +| Quests | `quest-blade-knight` | Talks to Sebina the Priest with a click, goes through her conversation with clicks on its answers, takes "Find the Scroll of the Emperor" (1,000,000 zen) and hands the scroll in (+10 level-up points); then "Three Treasures of MU" (2,000,000 zen) with the Broken Sword: +10 points and the Dark Knight becomes a Blade Knight. One point then goes to strength with the character window's "+". | +| Quests | `quest-soul-master` | The same two quests with the Soul Shard of Wizard: the Dark Wizard becomes a Soul Master. | +| Quests | `quest-muse-elf` | The same two quests with the Tear of Elf: the Elf becomes a Muse Elf. | +| Quests | `quest-hero-status-wizard` | A level 400 Dark Wizard becomes a Soul Master, goes to Marlon (he wanders between Devias, Lorencia, Noria and Atlans; the test server's log says where he is) and hands him the Ring of Honor for "Gain Hero Status": one more level-up point per level, which the server pays for the 180 levels above 220 at once. | +| Quests | `quest-hero-status-elf` | The same for a level 400 Elf, which also learns Infinity Arrow. | +| Quests | `quest-combo` | A level 220 Dark Knight becomes a Blade Knight, gains hero status from Marlon and hands him the Dark Stone for "Secret of Dark Stone": the combo, which is still there after logging out and in again. | +| Quests | `quest-evidence-of-strength` | A level 400 Dark Lord hands Apostle Devin the three items of "Evidence of Strength", the first quest towards the third class: +20 level-up points. | | 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 +288,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 +298,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 +307,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 +325,16 @@ 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 third class change itself: Devin's quests after "Evidence of Strength" + need 20 kills each of three monsters of the Barracks of Balgass (75,000 to + 90,000 health) and one of the Dark Elf in its refuge (1,500,000), which the + quest characters of the test data cannot do in a test. - 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/src/MuEditor/Core/MuEditorCore.cpp b/src/MuEditor/Core/MuEditorCore.cpp index a62044a65c..15874357b6 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::IsInjecting() && 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..6b7df28d6a 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,9 +19,11 @@ 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::IsInjecting(); + if (!shouldBlockInput) return; diff --git a/src/source/App/Control/ControlCommandsUi.cpp b/src/source/App/Control/ControlCommandsUi.cpp index 03b55b59bf..63d1f6cf19 100644 --- a/src/source/App/Control/ControlCommandsUi.cpp +++ b/src/source/App/Control/ControlCommandsUi.cpp @@ -33,11 +33,21 @@ 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}, + {"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}, + {"npc_quest", SEASON3B::INTERFACE_NPCQUEST}, }; // A point of a window, from its window-local coordinates to window pixels. @@ -65,6 +75,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() { @@ -74,6 +92,56 @@ 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_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_NPCQUEST)) + { + for (int answer = 0; answer < g_pNPCQuest->GetAnswerCount(); ++answer) + { + elements["npc_quest.answer." + std::to_string(answer)] = + WindowRect(*g_pNPCQuest, g_pNPCQuest->GetAnswerRect(answer)); + } + if (g_pNPCQuest->IsCompleteShown()) + { + elements["npc_quest.complete"] = WindowRect(*g_pNPCQuest, ButtonRect(g_pNPCQuest->GetCompleteButton())); + } + elements["npc_quest.close"] = WindowRect(*g_pNPCQuest, ButtonRect(g_pNPCQuest->GetCloseButton())); + } + constexpr std::string_view StatNames[] = {"strength", "agility", "vitality", "energy", "command"}; + for (int stat = 0; stat < static_cast(std::size(StatNames)); ++stat) + { + if (SEASON3B::CNewUIButton* button = g_pCharacterInfoWindow->GetShownStatButton(stat)) + { + elements["character.stat." + std::string(StatNames[stat])] = + WindowRect(*g_pCharacterInfoWindow, ButtonRect(*button)); + } + } + 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())); + elements["npc_shop.repair_all"] = WindowRect(*g_pNPCShop, ButtonRect(g_pNPCShop->GetRepairAllButton())); + } return elements; } @@ -125,13 +193,19 @@ 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()); + 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 == "equipment"; + name == "npc_shop" || name == "my_shop" || name == "purchase_shop" || name == "equipment"; } std::string SquarePixel(const Request& request, CNewUIInventoryCtrl& grid, int slot) @@ -205,7 +279,8 @@ std::string SlotPixel(const Request& request, std::unique_ptr&) { return EncodeError(request.EncodedId(), ErrorCode::BadRequest, "unknown `grid` `" + grid + - "`; known: inventory, equipment, trade, trade_partner, storage, mix"); + "`; known: inventory, equipment, trade, trade_partner, storage, mix, npc_shop, my_shop, " + "purchase_shop"); } if (grid == "equipment") { diff --git a/src/source/App/Control/ControlEvents.cpp b/src/source/App/Control/ControlEvents.cpp index d65c4cd5b8..2d08707530 100644 --- a/src/source/App/Control/ControlEvents.cpp +++ b/src/source/App/Control/ControlEvents.cpp @@ -336,6 +336,52 @@ 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 RecordQuestChange(int quest, std::string_view state) +{ + if (!IsEnabled()) + { + return; + } + + json fields; + fields["change"] = "state"; + fields["quest"] = quest; + fields["state"] = state; + Push("quest", fields); +} + +void RecordQuestReward(std::string_view name, std::string_view reward, std::optional amount, int characterClass) +{ + if (!IsEnabled()) + { + return; + } + + json fields; + fields["change"] = "reward"; + fields["name"] = name; + fields["reward"] = reward; + if (amount) + { + fields["amount"] = *amount; + } + fields["class"] = characterClass; + Push("quest", 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..959839e7d3 100644 --- a/src/source/App/Control/ControlEvents.h +++ b/src/source/App/Control/ControlEvents.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -101,6 +102,17 @@ 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 legacy quest's new state: active, complete, not_started or none. +void RecordQuestChange(int quest, std::string_view state); +// A legacy quest reward for the character `name`: `reward` is +// level_up_points, second_class, points_per_level, combo, third_class or +// unknown, with its `amount` where it has one; `characterClass` is the +// character's class afterwards. +void RecordQuestReward(std::string_view name, std::string_view reward, std::optional amount, int characterClass); // 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/ControlState.cpp b/src/source/App/Control/ControlState.cpp index 51c265564c..820db90965 100644 --- a/src/source/App/Control/ControlState.cpp +++ b/src/source/App/Control/ControlState.cpp @@ -2,6 +2,9 @@ #include "App/Control/ControlState.h" #include "App/Control/ControlObjects.h" +#include "App/Control/ControlTaps.h" +#include "Camera/CameraProjection.h" +#include "Camera/CameraState.h" #include "Core/Text/Utf8.h" #include "Engine/Object/ZzzCharacter.h" @@ -11,6 +14,11 @@ #include "Engine/Object/ZzzObject.h" #include "Core/Utilities/_GlobalFunctions.h" #include "GameLogic/Items/InventoryUtils.h" +#include "Character/CharacterManager.h" +#include "GameLogic/Quests/CSQuest.h" +#include "GameLogic/Quests/DialogStructure.h" +#include "GameLogic/Items/PersonalShopTitleImp.h" +#include "GameLogic/Items/ShopRestrictions.h" #include "Network/Server/WSclient.h" #include "Scenes/SceneCore.h" #include "UI/NewUI/NewUISystem.h" @@ -21,6 +29,12 @@ #include #include +// The quest dialog's page, text and answers (Scenes/SceneCore.cpp). +extern int g_iNumLineMessageBoxCustom; +extern wchar_t g_lpszMessageBoxCustom[NUM_LINE_CMB][MAX_LENGTH_CMB]; +extern wchar_t g_lpszDialogAnswer[MAX_ANSWER_FOR_DIALOG][NUM_LINE_DA][MAX_LENGTH_CMB]; +extern int g_iCurrentDialogScript; + namespace { using nlohmann::json; @@ -37,12 +51,48 @@ 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; 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; + } + + // No value yet (a Dark Lord pet whose data has not arrived): the tooltip + // shows no price either. + const int64_t value = ItemValue(repaired, 2); + if (value < 0) + { + return; + } + + // The tooltip's own function, which picks the NPC or the self-repair price. + wchar_t text[100] = {}; + described["repair_price"] = ConvertRepairGold(value, item.Durability, maxDurability, item.Type, text); +} + // The items of a trade grid, by the grid's own slot numbers. json TradeGridItems(SEASON3B::CNewUIInventoryCtrl* grid) { @@ -120,7 +170,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; } @@ -135,11 +187,190 @@ 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); + } + AddRepairPrice(described, *item); + 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(); + // 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) + { + 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; +} + +// 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; +} + +// The legacy quests (0 and 1 the second class, 2 and 3 Marlon's, 4 to 6 the +// third class) with their state as the server told the client. +json LegacyQuestArray() +{ + constexpr int LegacyQuestCount = 7; + json quests = json::array(); + for (int index = 0; index < LegacyQuestCount; ++index) + { + json quest; + quest["index"] = index; + const wchar_t* title = g_csQuest.getQuestTitle(static_cast(index)); + quest["name"] = title != nullptr ? Core::Text::ToUtf8(title) : ""; + switch (g_csQuest.getQuestState2(index)) + { + case QUEST_ING: + quest["state"] = "active"; + break; + case QUEST_END: + quest["state"] = "complete"; + break; + case QUEST_NO: + quest["state"] = "not_started"; + break; + case QUEST_NONE: + quest["state"] = "none"; + break; + default: + // QUEST_READY and QUEST_ERROR; the events name them the same. + quest["state"] = "unknown"; + break; + } + quests.push_back(std::move(quest)); + } + return quests; +} + +// The quest dialog on screen: the quest, the page, its text and the answers +// a click acts on; null while it is closed. +json NpcQuestState() +{ + if (!g_pNewUISystem->IsVisible(SEASON3B::INTERFACE_NPCQUEST)) + { + return nullptr; + } + + json dialog; + dialog["quest"] = g_csQuest.GetCurrQuestIndex(); + dialog["page"] = g_iCurrentDialogScript; + std::wstring text; + for (int line = 0; line < g_iNumLineMessageBoxCustom; ++line) + { + text += (line > 0 ? L" " : L"") + std::wstring(g_lpszMessageBoxCustom[line]); + } + dialog["text"] = Core::Text::ToUtf8(text.c_str()); + // Each answer with what a click on it does: `next` shows another page, + // `accept` starts the quest, `complete` hands it in, `close` ends the talk. + json answers = json::array(); + const auto& entry = GameLogic::Quests::Dialog::GetEntry(g_iCurrentDialogScript); + for (int answer = 0; answer < g_pNPCQuest->GetAnswerCount(); ++answer) + { + json described; + described["text"] = Core::Text::ToUtf8(g_lpszDialogAnswer[answer][0]); + switch (entry.answers[answer].returnCode) + { + case 1: + described["action"] = "accept"; + break; + case 2: + described["action"] = "close"; + break; + case 3: + described["action"] = "complete"; + break; + default: + described["action"] = entry.answers[answer].link > 0 ? "next" : "none"; + break; + } + answers.push_back(std::move(described)); + } + dialog["answers"] = std::move(answers); + dialog["need_zen"] = g_csQuest.GetNeedZen(); + return dialog; +} + json PartyArray() { json members = json::array(); @@ -201,6 +432,45 @@ 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; + } + + float x = 0.0f; + float y = 0.0f; + if (!CameraProjection::WorldToWindowPixel(g_worldCamera, center, &x, &y)) + { + return nullptr; + } + + 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 @@ -218,10 +488,20 @@ std::string WorldStateObject() state["character"] = Core::Text::ToUtf8(CharacterAttribute->Name); state["class"] = static_cast(CharacterAttribute->Class); + state["class_name"] = Core::Text::ToUtf8(gCharacterManager.GetCharacterClassText(CharacterAttribute->Class)); state["level"] = CharacterAttribute->Level; state["experience"] = CharacterAttribute->Experience; state["next_experience"] = CharacterAttribute->NextExperience; state["zen"] = CharacterMachine != nullptr ? CharacterMachine->Gold : 0; + // Points the player can add to a stat with the character window's "+" buttons. + state["level_up_points"] = CharacterAttribute->LevelUpPoint; + state["stats"] = {{"strength", CharacterAttribute->Strength}, + {"agility", CharacterAttribute->Dexterity}, + {"vitality", CharacterAttribute->Vitality}, + {"energy", CharacterAttribute->Energy}, + {"command", CharacterAttribute->Charisma}}; + // The Blade Knight's combo, from Marlon's "Secret of Dark Stone". + state["combo"] = Hero->byExtensionSkill == 1; state["hp"] = CharacterAttribute->Life; state["max_hp"] = CharacterAttribute->LifeMax; @@ -250,6 +530,14 @@ std::string WorldStateObject() state["buffs"] = BuffArray(); 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; + state["quests"] = LegacyQuestArray(); + state["npc_quest"] = NpcQuestState(); state["nearby"] = NearbyArray(); return state.dump(); @@ -284,6 +572,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 +598,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.cpp b/src/source/App/Control/ControlTaps.cpp index 514de99237..0ce7bdffa1 100644 --- a/src/source/App/Control/ControlTaps.cpp +++ b/src/source/App/Control/ControlTaps.cpp @@ -14,6 +14,7 @@ #include "World/MapInfra/MapManager.h" #include +#include #include #include @@ -222,6 +223,54 @@ 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 RecordQuestStateChanged(int quest, int state) +{ + // The client's QUEST_STATE values: 1 active, 2 complete, 3 not started. + constexpr std::string_view States[] = {"none", "active", "complete", "not_started"}; + RecordQuestChange(quest, state >= 0 && state < static_cast(std::size(States)) ? States[state] : "unknown"); +} + +void RecordQuestPrize(int key, int reward, int amount) +{ + if (!IsEnabled()) + { + return; + } + + // The reward codes of the legacy quest prize packet, from 200 on. + constexpr std::string_view Rewards[] = {"level_up_points", "second_class", "points_per_level", "combo", + "third_class"}; + constexpr int FirstReward = 200; + const int offset = reward - FirstReward; + const std::string_view name = + offset >= 0 && offset < static_cast(std::size(Rewards)) ? Rewards[offset] : "unknown"; + const int index = FindCharacterIndex(key); + const bool known = index >= 0 && index < MAX_CHARACTERS_CLIENT; + // For a class change the packet's number is the server's class code, not + // an amount; the event names the new class instead. + const bool classChange = name == "second_class" || name == "third_class"; + RecordQuestReward(known ? Core::Text::ToUtf8(CharactersClient[index].ID) : std::string(), name, + classChange ? std::nullopt : std::optional(amount), + known ? static_cast(CharactersClient[index].Class) : -1); +} + 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 15515b10db..84d7c56a0a 100644 --- a/src/source/App/Control/ControlTaps.h +++ b/src/source/App/Control/ControlTaps.h @@ -61,9 +61,22 @@ 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); + +// Legacy quest packets: a quest's new state as the client decoded it from A1, +// or A2 when the server took the step (QUEST_STATE: 1 active, 2 complete, +// 3 not started), and a reward (A3) for the character with the server id `key`. +void RecordQuestStateChanged(int quest, int state); +void RecordQuestPrize(int key, int reward, int amount); + // The server closed the session. void RecordDisconnected(const char* reason); @@ -77,6 +90,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 @@ -97,6 +119,10 @@ 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 RecordQuestStateChanged(int, int) {} +inline void RecordQuestPrize(int, int, int) {} inline void RecordDisconnected(const char*) {} inline void RecordTradeRequested(const char*, bool) {} inline void RecordTradeAnswer(int, const char*) {} @@ -104,4 +130,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/Camera/CameraProjection.cpp b/src/source/Camera/CameraProjection.cpp index 800a7709b6..4556dc0530 100644 --- a/src/source/Camera/CameraProjection.cpp +++ b/src/source/Camera/CameraProjection.cpp @@ -86,6 +86,20 @@ void CameraProjection::WorldToScreen(const CameraState& state, const vec3_t worl *outY = *outY * REFERENCE_HEIGHT / (int)WindowHeight; } +bool CameraProjection::WorldToWindowPixel(const CameraState& state, const vec3_t worldPos, float* outX, float* outY) +{ + vec3_t transformPos; + VectorTransform(worldPos, state.Matrix, transformPos); + if (transformPos[2] >= 0.0f) + { + return false; + } + + *outX = static_cast(state.ScreenCenterX) - transformPos[0] / (state.PerspectiveX * transformPos[2]); + *outY = static_cast(state.ScreenCenterY) + transformPos[1] / (state.PerspectiveY * transformPos[2]); + return true; +} + void CameraProjection::TransformPosition(const CameraState& state, const vec3_t position, vec3_t outWorldPosition, int* outX, int* outY) { diff --git a/src/source/Camera/CameraProjection.h b/src/source/Camera/CameraProjection.h index ec09ffc51b..29c514df1a 100644 --- a/src/source/Camera/CameraProjection.h +++ b/src/source/Camera/CameraProjection.h @@ -66,6 +66,15 @@ class CameraProjection static void WorldToScreen(const CameraState& state, const vec3_t worldPos, int* outX, int* outY); + /** + * @brief Projects a world position to window pixels, as ScreenToWorldRay + * casts the mouse ray back (WorldToScreen without its 640x480 + * conversion and integer steps) + * + * @return false when the position is behind the camera + */ + static bool WorldToWindowPixel(const CameraState& state, const vec3_t worldPos, float* outX, float* outY); + /** * @brief Transforms position relative to camera (pixel coordinates) * diff --git a/src/source/Core/Input/SyntheticInput.h b/src/source/Core/Input/SyntheticInput.h index c7d942fb18..bc65137594 100644 --- a/src/source/Core/Input/SyntheticInput.h +++ b/src/source/Core/Input/SyntheticInput.h @@ -49,6 +49,14 @@ enum class MouseButton : std::uint8_t // answers once this turns true again. [[nodiscard]] bool IsIdle(); +// Whether a key or click the control socket injects is in flight. It never +// went through ImGui, so the editor's checks of the real pointer and keyboard +// must not claim this frame's input for the editor. +[[nodiscard]] inline bool IsInjecting() +{ + return !IsIdle(); +} + // Identifies the injection most recently accepted: every `PressKey` or // `Click` that returns true gets a value of its own. A command reads it when // its injection is scheduled and compares later, so it can tell its own @@ -69,10 +77,20 @@ 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 IsInjecting() +{ + return false; +} + [[nodiscard]] inline bool IsKeyHeld(int) { return false; diff --git a/src/source/Network/Server/WSclient.cpp b/src/source/Network/Server/WSclient.cpp index 3e95121626..42cb01a458 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) @@ -10421,6 +10423,7 @@ void ReceiveQuestState(const BYTE* ReceiveBuffer) g_csQuest.setQuestList(Data->m_byQuestIndex, Data->m_byState); g_pNewUISystem->HideAll(); g_pNewUISystem->Show(SEASON3B::INTERFACE_NPCQUEST); + App::Control::Events::RecordQuestStateChanged(Data->m_byQuestIndex, g_csQuest.getQuestState2(Data->m_byQuestIndex)); } void ReceiveQuestResult(const BYTE* ReceiveBuffer) @@ -10432,6 +10435,8 @@ void ReceiveQuestResult(const BYTE* ReceiveBuffer) g_csQuest.setQuestList(Data->m_byQuestIndex, Data->m_byState); g_pNewUISystem->HideAll(); g_pNewUISystem->Show(SEASON3B::INTERFACE_NPCQUEST); + App::Control::Events::RecordQuestStateChanged(Data->m_byQuestIndex, + g_csQuest.getQuestState2(Data->m_byQuestIndex)); } } @@ -10583,6 +10588,7 @@ void ReceiveQuestPrize(const BYTE* ReceiveBuffer) default: break; } + App::Control::Events::RecordQuestPrize(Key, Data->m_byReparation, Data->m_byNumber); } void ReceiveQuestMonKillInfo(const BYTE* ReceiveBuffer) 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. diff --git a/src/source/UI/NewUI/Character/NewUICharacterInfoWindow.cpp b/src/source/UI/NewUI/Character/NewUICharacterInfoWindow.cpp index ce6d8b787c..e022e82751 100644 --- a/src/source/UI/NewUI/Character/NewUICharacterInfoWindow.cpp +++ b/src/source/UI/NewUI/Character/NewUICharacterInfoWindow.cpp @@ -169,16 +169,7 @@ bool SEASON3B::CNewUICharacterInfoWindow::BtnProcess() if (CharacterAttribute->LevelUpPoint > 0) { - int iBaseClass = gCharacterManager.GetBaseClass(Hero->Class); - int iCount = 0; - if (iBaseClass == CLASS_DARK_LORD) - { - iCount = 5; - } - else - { - iCount = 4; - } + const int iCount = StatButtonCount(); for (int i = 0; i < iCount; ++i) { if (m_BtnStat[i].UpdateMouseEvent() == true) @@ -1528,16 +1519,7 @@ void SEASON3B::CNewUICharacterInfoWindow::RenderAttribute() void SEASON3B::CNewUICharacterInfoWindow::RenderButtons() { - int iBaseClass = gCharacterManager.GetBaseClass(Hero->Class); - int iCount = 0; - if (iBaseClass == CLASS_DARK_LORD) - { - iCount = 5; - } - else - { - iCount = 4; - } + const int iCount = StatButtonCount(); if (CharacterAttribute->LevelUpPoint > 0) { @@ -1658,3 +1640,19 @@ void SEASON3B::CNewUICharacterInfoWindow::ResetEquipmentLevel() CheckFullSet(Hero); } + +SEASON3B::CNewUIButton* SEASON3B::CNewUICharacterInfoWindow::GetShownStatButton(int stat) +{ + if (!IsVisible() || CharacterAttribute->LevelUpPoint <= 0 || stat < 0) + { + return nullptr; + } + + return stat < StatButtonCount() ? &m_BtnStat[stat] : nullptr; +} + +int SEASON3B::CNewUICharacterInfoWindow::StatButtonCount() const +{ + // Command is a Dark Lord's stat only. + return gCharacterManager.GetBaseClass(Hero->Class) == CLASS_DARK_LORD ? 5 : 4; +} diff --git a/src/source/UI/NewUI/Character/NewUICharacterInfoWindow.h b/src/source/UI/NewUI/Character/NewUICharacterInfoWindow.h index 672868c014..4c88d93083 100644 --- a/src/source/UI/NewUI/Character/NewUICharacterInfoWindow.h +++ b/src/source/UI/NewUI/Character/NewUICharacterInfoWindow.h @@ -79,7 +79,15 @@ namespace SEASON3B float GetLayerDepth(); //. 5.1f void OpenningProcess(); + // A stat's "+" button (STAT_STRENGTH, ...), for the control socket; + // null while it is not shown: without level-up points, and the command + // button for anyone but a Dark Lord. + CNewUIButton* GetShownStatButton(int stat); + private: + // The "+" buttons shown: strength, agility, vitality, energy, and + // command for a Dark Lord. + int StatButtonCount() const; void LoadImages(); void UnloadImages(); void ResetEquipmentLevel(); 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/src/source/UI/NewUI/Inventory/NewUIMyInventory.cpp b/src/source/UI/NewUI/Inventory/NewUIMyInventory.cpp index 6c0df5ba1c..89d5df0213 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/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/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/src/source/UI/NewUI/Quests/NewUINPCQuest.cpp b/src/source/UI/NewUI/Quests/NewUINPCQuest.cpp index b5b7d59e96..95dc4a0fab 100644 --- a/src/source/UI/NewUI/Quests/NewUINPCQuest.cpp +++ b/src/source/UI/NewUI/Quests/NewUINPCQuest.cpp @@ -100,22 +100,41 @@ bool CNewUINPCQuest::UpdateMouseEvent() return true; } +int CNewUINPCQuest::AnswerTop() +{ + const BYTE byCurQuestState = g_csQuest.getQuestState2(int(g_csQuest.GetCurrQuestIndex())); + if (byCurQuestState != QUEST_ING) + { + return m_Pos.y + 250; + } + + const int iTotalLine = g_iNumLineMessageBoxCustom + g_iNumAnswer; + return m_Pos.y + 66 + (NUM_LINE_CMB - iTotalLine) * 18 / 2 + 18 * g_iNumLineMessageBoxCustom; +} + +int CNewUINPCQuest::GetAnswerCount() const +{ + return GameLogic::Quests::Dialog::GetEntry(g_iCurrentDialogScript).numAnswer; +} + +RECT CNewUINPCQuest::GetAnswerRect(int answer) +{ + const int top = AnswerTop() + 18 * answer; + return {m_Pos.x, top, m_Pos.x + NPCQUEST_WIDTH, top + 18}; +} + +bool CNewUINPCQuest::IsCompleteShown() +{ + return g_csQuest.getQuestState2(int(g_csQuest.GetCurrQuestIndex())) == QUEST_ING; +} + bool CNewUINPCQuest::UpdateSelTextMouseEvent() { BYTE byCurQuestIndex = g_csQuest.GetCurrQuestIndex(); - BYTE byCurQuestState = g_csQuest.getQuestState2(int(byCurQuestIndex)); bool bErrorMessage = false; int iButtonPush = -1; - int iTotalLine = g_iNumLineMessageBoxCustom + g_iNumAnswer; - int yPos = m_Pos.y + 66 + (NUM_LINE_CMB - iTotalLine) * 18 / 2; - - yPos += 18 * g_iNumLineMessageBoxCustom; - - if (byCurQuestState != QUEST_ING) - { - yPos = m_Pos.y + 250; - } + const int yPos = AnswerTop(); if (SEASON3B::IsRelease(VK_LBUTTON)) { diff --git a/src/source/UI/NewUI/Quests/NewUINPCQuest.h b/src/source/UI/NewUI/Quests/NewUINPCQuest.h index fb53a4c78f..4ce42867bd 100644 --- a/src/source/UI/NewUI/Quests/NewUINPCQuest.h +++ b/src/source/UI/NewUI/Quests/NewUINPCQuest.h @@ -70,11 +70,29 @@ namespace SEASON3B void ProcessOpening(); bool ProcessClosing(); + // For the control socket: the answers of the page on screen that a + // click acts on, the row each is clicked in (window-local, as + // UpdateSelTextMouseEvent hits them), and the Proceed and Close buttons. + int GetAnswerCount() const; + RECT GetAnswerRect(int answer); + // Proceed is drawn while the quest is in progress. + bool IsCompleteShown(); + CNewUIButton& GetCompleteButton() + { + return m_btnComplete; + } + CNewUIButton& GetCloseButton() + { + return m_btnClose; + } + private: void LoadImages(); void UnloadImages(); bool UpdateSelTextMouseEvent(); + // The top of the first answer row. + int AnswerTop(); void RenderBackImage(); void RenderText(); bool RenderItemMobText(); diff --git a/src/source/UI/NewUI/Widgets/NewUIChatInputBox.cpp b/src/source/UI/NewUI/Widgets/NewUIChatInputBox.cpp index bcfff497a0..e010d36669 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::IsInjecting()) { ImGuiIO& io = ImGui::GetIO(); if (io.WantCaptureKeyboard || io.WantCaptureMouse) diff --git a/tools/InGameTests/Clients/GameClient.cs b/tools/InGameTests/Clients/GameClient.cs index b33db9e875..b86daa6f4d 100644 --- a/tools/InGameTests/Clients/GameClient.cs +++ b/tools/InGameTests/Clients/GameClient.cs @@ -22,9 +22,11 @@ internal sealed class GameClient : IAsyncDisposable private readonly ControlConnection control; private readonly TimeSpan stepDelay; - private GameClient(string role, Process process, ControlConnection control, TimeSpan stepDelay) + private GameClient(string role, Process process, ControlConnection control, TimeSpan stepDelay, ClientOptions options) { this.Role = role; + this.ServerHost = options.ServerHost; + this.ServerPort = options.ServerPort; this.process = process; this.control = control; this.stepDelay = stepDelay; @@ -36,6 +38,11 @@ private GameClient(string role, Process process, ControlConnection control, Time /// The scenario's name for this client, e.g. "seller". public string Role { get; } + /// The game server the client logs in to. + public string ServerHost { get; } + + public int ServerPort { get; } + /// Starts a client and waits until its control socket answers. public static async Task StartAsync(ClientOptions options, string role, CancellationToken cancellationToken) { @@ -56,7 +63,7 @@ public static async Task StartAsync(ClientOptions options, string ro try { control = await ConnectAsync(socketPath, process, options.StartTimeout, cancellationToken); - var client = new GameClient(role, process, control, options.StepDelay); + var client = new GameClient(role, process, control, options.StepDelay, options); var ping = await client.SendAsync("ping"); if (ping.TryGetProperty("commit", out var commit) && commit.GetString() is { } hash) { @@ -115,6 +122,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() { @@ -122,6 +141,10 @@ public async Task> OpenWindowsAsync() return ui.GetProperty("windows").EnumerateArray().Select(window => window.GetString()!).ToList(); } + /// Whether ui reports the element , i.e. it is shown now. + public async Task HasElementAsync(string name) + => (await this.SendAsync("ui")).GetProperty("elements").TryGetProperty(name, out _); + /// Opens the inventory with its key, unless it is open already. public async Task OpenInventoryAsync() { diff --git a/tools/InGameTests/Clients/ItemSlots.cs b/tools/InGameTests/Clients/ItemSlots.cs index 38447ae9d5..ed6820fd03 100644 --- a/tools/InGameTests/Clients/ItemSlots.cs +++ b/tools/InGameTests/Clients/ItemSlots.cs @@ -18,9 +18,50 @@ 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) + => FreeArea(inventory.Select(entry => entry.Slot).ToHashSet(), width, height, InventoryColumns, InventoryRows, FirstInventorySlot); + + /// + /// The top-left slot of the first x area of + /// a grid ( x , slots numbered row by row from + /// ) that none of the squares covers; null + /// when there is none. + /// + public static int? FreeArea(IReadOnlySet used, int width, int height, int columns, int rows, int firstSlot) + { + for (var row = 0; row + height <= rows; row++) + { + for (var column = 0; column + width <= columns; column++) + { + var fits = Squares(firstSlot + (row * columns) + column, width, height, columns).All(slot => !used.Contains(slot)); + if (fits) + { + return firstSlot + (row * columns) + column; + } + } + } + + return null; + } + + /// The squares an item of x covers from . + public static IEnumerable Squares(int topLeft, int width, int height, int columns) + => Enumerable.Range(0, height).SelectMany(dy => Enumerable.Range(0, width).Select(dx => topLeft + (dy * columns) + dx)); + private static ItemSlot Read(JsonElement item) => new( item.GetProperty("slot").GetInt32(), diff --git a/tools/InGameTests/Gui/MainWindow.axaml b/tools/InGameTests/Gui/MainWindow.axaml index 7985e3f6d3..dabbe839ba 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"> + + + + + + + + - +