From 8fcee0f6b70dfe2973e5269bcb9b532ef3018c2c Mon Sep 17 00:00:00 2001 From: Sven Nicolai Date: Sun, 11 Oct 2026 11:41:42 +0000 Subject: [PATCH 1/4] build(network): packet definitions 0.9.11 Builds against MUnique.OpenMU.Network.Packets 0.9.11, which adds the player shop currency packets (MUnique/OpenMU#1186), and regenerates the packet bindings from it. The castle siege tax type and guild command are enums in the new definitions, so their call sites pass the enum values now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y7XufeAZ22z7ocXAXUqUsE --- ...nnectionManager.ClientToServerFunctions.cs | 58 ++++++++++++++++--- ClientLibrary/MUnique.Client.Library.csproj | 2 +- src/CMakeLists.txt | 2 +- .../Dotnet/PacketBindings_ClientToServer.h | 6 +- .../Dotnet/PacketFunctions_ClientToServer.cpp | 21 +++++-- .../Dotnet/PacketFunctions_ClientToServer.h | 27 ++++++--- .../PacketFunctions_ClientToServer_Enums.h | 15 +++++ src/source/UI/Legacy/UIGateKeeper.cpp | 2 +- src/source/UI/Legacy/UISenatus.cpp | 4 +- .../NewUI/Combat/NewUISiegeWarCommander.cpp | 2 +- 10 files changed, 110 insertions(+), 29 deletions(-) diff --git a/ClientLibrary/ConnectionManager.ClientToServerFunctions.cs b/ClientLibrary/ConnectionManager.ClientToServerFunctions.cs index 7963153b5d..77a6922db9 100644 --- a/ClientLibrary/ConnectionManager.ClientToServerFunctions.cs +++ b/ClientLibrary/ConnectionManager.ClientToServerFunctions.cs @@ -626,6 +626,46 @@ public static void SendPlayerShopCloseOther(int handle, ushort @playerId, IntPtr } } + /// + /// Sends a to this connection. + /// + /// The handle of the connection. + /// The price in zen, or in account coins when PriceItemType is one of the account coin values. Encoded like the price of PlayerShopItemExtended. + /// The item (group in the highest 4 bits, number in the remaining ones) with which the price is paid, when RequiredItemAmount is greater than 0. Otherwise, 0xFFF0 = WCoin (C), 0xFFF1 = WCoin (P) and 0xFFF2 = Goblin Points define an account coin price; any other value a zen price. Encoded like the price of PlayerShopItemExtended. + /// The number of pieces of the item which is defined by PriceItemType; 0, if the price is not paid with items. Encoded like the price of PlayerShopItemExtended. + /// The item slot. + /// + /// Is sent by the client when: The player wants to set a price of an item which is inside his personal item shop, in zen or in one of the price types which are allowed by the server (see PlayerShopPriceTypesExtended). + /// Causes reaction on server side: The price is set for the specified item, if its price type is allowed and the amount is within the configured limits. Works only if the shop is currently closed. The server responds with a PlayerShopSetItemPriceResponse. + /// + [UnmanagedCallersOnly(EntryPoint = "SendPlayerShopSetItemPriceExtended")] + public static void SendPlayerShopSetItemPriceExtended(int handle, uint @moneyPrice, ushort @priceItemType, ushort @requiredItemAmount, byte @itemSlot) + { + if (!Connections.TryGetValue(handle, out var connection)) + { + return; + } + + try + { + connection.CreateAndSend(pipeWriter => + { + var length = PlayerShopSetItemPriceExtendedRef.Length; + var packet = new PlayerShopSetItemPriceExtendedRef(pipeWriter.GetSpan(length)[..length]); + packet.MoneyPrice = @moneyPrice; + packet.PriceItemType = @priceItemType; + packet.RequiredItemAmount = @requiredItemAmount; + packet.ItemSlot = @itemSlot; + + return length; + }); + } + catch (Exception ex) + { + Debug.WriteLine(ex); + } + } + /// /// Sends a to this connection. /// @@ -1570,14 +1610,14 @@ public static void SendCastleSiegeTaxInfoRequest(int handle) /// Sends a to this connection. /// /// The handle of the connection. - /// 0=Undefined, 1=ChaosMachine, 2 = Normal, 3 = EntranceFeeLandOfTrials - /// The tax rate. + /// The tax type. + /// The percentage rate for shop and Chaos Machine taxes, or the entrance fee amount for the hunting zone. /// /// Is sent by the client when: The guild master wants to change the tax rate in the castle npc. /// Causes reaction on server side: The server changes the tax rates accordingly. /// [UnmanagedCallersOnly(EntryPoint = "SendCastleSiegeTaxChangeRequest")] - public static void SendCastleSiegeTaxChangeRequest(int handle, byte @taxType, uint @taxRate) + public static void SendCastleSiegeTaxChangeRequest(int handle, CastleSiegeTaxType @taxType, uint @taxValue) { if (!Connections.TryGetValue(handle, out var connection)) { @@ -1591,7 +1631,7 @@ public static void SendCastleSiegeTaxChangeRequest(int handle, byte @taxType, ui var length = CastleSiegeTaxChangeRequestRef.Length; var packet = new CastleSiegeTaxChangeRequestRef(pipeWriter.GetSpan(length)[..length]); packet.TaxType = @taxType; - packet.TaxRate = @taxRate; + packet.TaxValue = @taxValue; return length; }); @@ -1640,14 +1680,14 @@ public static void SendCastleSiegeTaxMoneyWithdraw(int handle, uint @amount) /// Sends a to this connection. /// /// The handle of the connection. - /// The close state. + /// The is open. /// The gate id. /// /// Is sent by the client when: The guild member of the castle owner wants to toggle the gate switch. /// Causes reaction on server side: The castle gate is getting opened or closed. /// [UnmanagedCallersOnly(EntryPoint = "SendToggleCastleGateRequest")] - public static void SendToggleCastleGateRequest(int handle, byte @closeState, ushort @gateId) + public static void SendToggleCastleGateRequest(int handle, byte @isOpen, ushort @gateId) { if (!Connections.TryGetValue(handle, out var connection)) { @@ -1660,7 +1700,7 @@ public static void SendToggleCastleGateRequest(int handle, byte @closeState, ush { var length = ToggleCastleGateRequestRef.Length; var packet = new ToggleCastleGateRequestRef(pipeWriter.GetSpan(length)[..length]); - packet.CloseState = @closeState == 1; + packet.IsOpen = @isOpen == 1; packet.GateId = @gateId; return length; @@ -1679,13 +1719,13 @@ public static void SendToggleCastleGateRequest(int handle, byte @closeState, ush /// Team Number 0 to 7. /// The position x. /// The position y. - /// 0 = Attack, 1 = Defend, 2 = Wait + /// The command. /// /// Is sent by the client when: The guild master sent a command to his guild during the castle siege event. /// Causes reaction on server side: The command is shown on the mini map of the guild members. /// [UnmanagedCallersOnly(EntryPoint = "SendCastleGuildCommand")] - public static void SendCastleGuildCommand(int handle, byte @team, byte @positionX, byte @positionY, byte @command) + public static void SendCastleGuildCommand(int handle, byte @team, byte @positionX, byte @positionY, CastleSiegeGuildCommandType @command) { if (!Connections.TryGetValue(handle, out var connection)) { diff --git a/ClientLibrary/MUnique.Client.Library.csproj b/ClientLibrary/MUnique.Client.Library.csproj index 59610080e8..e7fecc998e 100644 --- a/ClientLibrary/MUnique.Client.Library.csproj +++ b/ClientLibrary/MUnique.Client.Library.csproj @@ -15,7 +15,7 @@ nullable;CS4014;VSTHRD110;VSTHRD100 True - 0.9.10 + 0.9.11 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 34ffee7502..f9a3e34c5c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -482,7 +482,7 @@ file(MAKE_DIRECTORY "${MU_NUGET_CACHE_DIR}") # same package the .NET ClientLibrary consumes (see MUnique.Client.Library.csproj). # Sourcing from NuGet means a single versioned dependency instead of vendoring # the entire OpenMU repository as a submodule. -set(OPENMU_PACKETS_VERSION "0.9.10" CACHE STRING +set(OPENMU_PACKETS_VERSION "0.9.11" CACHE STRING "Version of MUnique.OpenMU.Network.Packets to source packet XML from") set(OPENMU_PACKETS_XML "${MU_NUGET_CACHE_DIR}/munique.openmu.network.packets/${OPENMU_PACKETS_VERSION}/contentFiles/any/net10.0/ServerToClient/ServerToClientPackets.xml") diff --git a/src/source/Dotnet/PacketBindings_ClientToServer.h b/src/source/Dotnet/PacketBindings_ClientToServer.h index 773349bde4..f5f976140e 100644 --- a/src/source/Dotnet/PacketBindings_ClientToServer.h +++ b/src/source/Dotnet/PacketBindings_ClientToServer.h @@ -52,6 +52,8 @@ typedef void(CORECLR_DELEGATE_CALLTYPE* SendPlayerShopItemBuyRequest)(int32_t, u typedef void(CORECLR_DELEGATE_CALLTYPE* SendPlayerShopCloseOther)(int32_t, uint16_t , const char16_t* ); +typedef void(CORECLR_DELEGATE_CALLTYPE* SendPlayerShopSetItemPriceExtended)(int32_t, uint32_t , uint16_t , uint16_t , BYTE ); + typedef void(CORECLR_DELEGATE_CALLTYPE* SendPickupItemRequest)(int32_t, uint16_t ); typedef void(CORECLR_DELEGATE_CALLTYPE* SendPickupItemRequest075)(int32_t, uint16_t ); @@ -104,13 +106,13 @@ typedef void(CORECLR_DELEGATE_CALLTYPE* SendCastleSiegeDefenseUpgradeRequest)(in typedef void(CORECLR_DELEGATE_CALLTYPE* SendCastleSiegeTaxInfoRequest)(int32_t); -typedef void(CORECLR_DELEGATE_CALLTYPE* SendCastleSiegeTaxChangeRequest)(int32_t, BYTE , uint32_t ); +typedef void(CORECLR_DELEGATE_CALLTYPE* SendCastleSiegeTaxChangeRequest)(int32_t, CastleSiegeTaxType , uint32_t ); typedef void(CORECLR_DELEGATE_CALLTYPE* SendCastleSiegeTaxMoneyWithdraw)(int32_t, uint32_t ); typedef void(CORECLR_DELEGATE_CALLTYPE* SendToggleCastleGateRequest)(int32_t, BYTE , uint16_t ); -typedef void(CORECLR_DELEGATE_CALLTYPE* SendCastleGuildCommand)(int32_t, BYTE , BYTE , BYTE , BYTE ); +typedef void(CORECLR_DELEGATE_CALLTYPE* SendCastleGuildCommand)(int32_t, BYTE , BYTE , BYTE , CastleSiegeGuildCommandType ); typedef void(CORECLR_DELEGATE_CALLTYPE* SendCastleSiegeHuntingZoneEntranceSetting)(int32_t, BYTE ); diff --git a/src/source/Dotnet/PacketFunctions_ClientToServer.cpp b/src/source/Dotnet/PacketFunctions_ClientToServer.cpp index 9e5777a14b..7b060cad97 100644 --- a/src/source/Dotnet/PacketFunctions_ClientToServer.cpp +++ b/src/source/Dotnet/PacketFunctions_ClientToServer.cpp @@ -194,6 +194,17 @@ void PacketFunctions_ClientToServer::SendPlayerShopCloseOther(uint16_t playerId, dotnet_SendPlayerShopCloseOther(this->GetHandle(), playerId, playerName); } +void PacketFunctions_ClientToServer::SendPlayerShopSetItemPriceExtended(uint32_t moneyPrice, uint16_t priceItemType, uint16_t requiredItemAmount, BYTE itemSlot) +{ + static const auto dotnet_SendPlayerShopSetItemPriceExtended = LoadManagedSymbol<::SendPlayerShopSetItemPriceExtended>("SendPlayerShopSetItemPriceExtended"); + if (!dotnet_SendPlayerShopSetItemPriceExtended) + { + return; + } + + dotnet_SendPlayerShopSetItemPriceExtended(this->GetHandle(), moneyPrice, priceItemType, requiredItemAmount, itemSlot); +} + void PacketFunctions_ClientToServer::SendPickupItemRequest(uint16_t itemId) { static const auto dotnet_SendPickupItemRequest = LoadManagedSymbol<::SendPickupItemRequest>("SendPickupItemRequest"); @@ -480,7 +491,7 @@ void PacketFunctions_ClientToServer::SendCastleSiegeTaxInfoRequest() dotnet_SendCastleSiegeTaxInfoRequest(this->GetHandle()); } -void PacketFunctions_ClientToServer::SendCastleSiegeTaxChangeRequest(BYTE taxType, uint32_t taxRate) +void PacketFunctions_ClientToServer::SendCastleSiegeTaxChangeRequest(CastleSiegeTaxType taxType, uint32_t taxValue) { static const auto dotnet_SendCastleSiegeTaxChangeRequest = LoadManagedSymbol<::SendCastleSiegeTaxChangeRequest>("SendCastleSiegeTaxChangeRequest"); if (!dotnet_SendCastleSiegeTaxChangeRequest) @@ -488,7 +499,7 @@ void PacketFunctions_ClientToServer::SendCastleSiegeTaxChangeRequest(BYTE taxTyp return; } - dotnet_SendCastleSiegeTaxChangeRequest(this->GetHandle(), taxType, taxRate); + dotnet_SendCastleSiegeTaxChangeRequest(this->GetHandle(), taxType, taxValue); } void PacketFunctions_ClientToServer::SendCastleSiegeTaxMoneyWithdraw(uint32_t amount) @@ -502,7 +513,7 @@ void PacketFunctions_ClientToServer::SendCastleSiegeTaxMoneyWithdraw(uint32_t am dotnet_SendCastleSiegeTaxMoneyWithdraw(this->GetHandle(), amount); } -void PacketFunctions_ClientToServer::SendToggleCastleGateRequest(BYTE closeState, uint16_t gateId) +void PacketFunctions_ClientToServer::SendToggleCastleGateRequest(BYTE isOpen, uint16_t gateId) { static const auto dotnet_SendToggleCastleGateRequest = LoadManagedSymbol<::SendToggleCastleGateRequest>("SendToggleCastleGateRequest"); if (!dotnet_SendToggleCastleGateRequest) @@ -510,10 +521,10 @@ void PacketFunctions_ClientToServer::SendToggleCastleGateRequest(BYTE closeState return; } - dotnet_SendToggleCastleGateRequest(this->GetHandle(), closeState, gateId); + dotnet_SendToggleCastleGateRequest(this->GetHandle(), isOpen, gateId); } -void PacketFunctions_ClientToServer::SendCastleGuildCommand(BYTE team, BYTE positionX, BYTE positionY, BYTE command) +void PacketFunctions_ClientToServer::SendCastleGuildCommand(BYTE team, BYTE positionX, BYTE positionY, CastleSiegeGuildCommandType command) { static const auto dotnet_SendCastleGuildCommand = LoadManagedSymbol<::SendCastleGuildCommand>("SendCastleGuildCommand"); if (!dotnet_SendCastleGuildCommand) diff --git a/src/source/Dotnet/PacketFunctions_ClientToServer.h b/src/source/Dotnet/PacketFunctions_ClientToServer.h index 98dae06bb5..fa78c881f9 100644 --- a/src/source/Dotnet/PacketFunctions_ClientToServer.h +++ b/src/source/Dotnet/PacketFunctions_ClientToServer.h @@ -218,6 +218,19 @@ class PacketFunctions_ClientToServer : public PacketFunctions_ClientToServer_Cus /// void SendPlayerShopCloseOther(uint16_t playerId, const char16_t* playerName); + /// + /// Sends a PlayerShopSetItemPriceExtended to this connection. + /// + /// The price in zen, or in account coins when PriceItemType is one of the account coin values. Encoded like the price of PlayerShopItemExtended. + /// The item (group in the highest 4 bits, number in the remaining ones) with which the price is paid, when RequiredItemAmount is greater than 0. Otherwise, 0xFFF0 = WCoin (C), 0xFFF1 = WCoin (P) and 0xFFF2 = Goblin Points define an account coin price; any other value a zen price. Encoded like the price of PlayerShopItemExtended. + /// The number of pieces of the item which is defined by PriceItemType; 0, if the price is not paid with items. Encoded like the price of PlayerShopItemExtended. + /// The item slot. + /// + /// Is sent by the client when: The player wants to set a price of an item which is inside his personal item shop, in zen or in one of the price types which are allowed by the server (see PlayerShopPriceTypesExtended). + /// Causes reaction on server side: The price is set for the specified item, if its price type is allowed and the amount is within the configured limits. Works only if the shop is currently closed. The server responds with a PlayerShopSetItemPriceResponse. + /// + void SendPlayerShopSetItemPriceExtended(uint32_t moneyPrice, uint16_t priceItemType, uint16_t requiredItemAmount, BYTE itemSlot); + /// /// Sends a PickupItemRequest to this connection. /// @@ -514,13 +527,13 @@ class PacketFunctions_ClientToServer : public PacketFunctions_ClientToServer_Cus /// /// Sends a CastleSiegeTaxChangeRequest to this connection. /// - /// 0=Undefined, 1=ChaosMachine, 2 = Normal, 3 = EntranceFeeLandOfTrials - /// The tax rate. + /// The tax type. + /// The percentage rate for shop and Chaos Machine taxes, or the entrance fee amount for the hunting zone. /// /// Is sent by the client when: The guild master wants to change the tax rate in the castle npc. /// Causes reaction on server side: The server changes the tax rates accordingly. /// - void SendCastleSiegeTaxChangeRequest(BYTE taxType, uint32_t taxRate); + void SendCastleSiegeTaxChangeRequest(CastleSiegeTaxType taxType, uint32_t taxValue); /// /// Sends a CastleSiegeTaxMoneyWithdraw to this connection. @@ -535,13 +548,13 @@ class PacketFunctions_ClientToServer : public PacketFunctions_ClientToServer_Cus /// /// Sends a ToggleCastleGateRequest to this connection. /// - /// The close state. + /// The is open. /// The gate id. /// /// Is sent by the client when: The guild member of the castle owner wants to toggle the gate switch. /// Causes reaction on server side: The castle gate is getting opened or closed. /// - void SendToggleCastleGateRequest(BYTE closeState, uint16_t gateId); + void SendToggleCastleGateRequest(BYTE isOpen, uint16_t gateId); /// /// Sends a CastleGuildCommand to this connection. @@ -549,12 +562,12 @@ class PacketFunctions_ClientToServer : public PacketFunctions_ClientToServer_Cus /// Team Number 0 to 7. /// The position x. /// The position y. - /// 0 = Attack, 1 = Defend, 2 = Wait + /// The command. /// /// Is sent by the client when: The guild master sent a command to his guild during the castle siege event. /// Causes reaction on server side: The command is shown on the mini map of the guild members. /// - void SendCastleGuildCommand(BYTE team, BYTE positionX, BYTE positionY, BYTE command); + void SendCastleGuildCommand(BYTE team, BYTE positionX, BYTE positionY, CastleSiegeGuildCommandType command); /// /// Sends a CastleSiegeHuntingZoneEntranceSetting to this connection. diff --git a/src/source/Dotnet/PacketFunctions_ClientToServer_Enums.h b/src/source/Dotnet/PacketFunctions_ClientToServer_Enums.h index 09fc84fba4..26eb05bc76 100644 --- a/src/source/Dotnet/PacketFunctions_ClientToServer_Enums.h +++ b/src/source/Dotnet/PacketFunctions_ClientToServer_Enums.h @@ -67,6 +67,21 @@ Join = 1, ///< The join type. Leave = 2, ///< The leave type. }; +enum class CastleSiegeTaxType : uint32_t +{ +Undefined = 0, ///< No tax type is selected. +ChaosMachine = 1, ///< The Chaos Machine tax rate. +Store = 2, ///< The NPC store tax rate. +HuntingZoneEntranceFee = 3, ///< The hunting-zone entrance fee. +}; + +enum class CastleSiegeGuildCommandType : uint32_t +{ +Attack = 0, ///< Orders the team to attack. +Defend = 1, ///< Orders the team to defend. +Wait = 2, ///< Orders the team to wait. +}; + enum class FruitUsage : uint32_t { AddPoints = 0, ///< Adds 1~3 stat points to the character. diff --git a/src/source/UI/Legacy/UIGateKeeper.cpp b/src/source/UI/Legacy/UIGateKeeper.cpp index b1023292d9..eaba70e93c 100644 --- a/src/source/UI/Legacy/UIGateKeeper.cpp +++ b/src/source/UI/Legacy/UIGateKeeper.cpp @@ -27,7 +27,7 @@ void CUIGateKeeper::SendPublicSetting() void CUIGateKeeper::SendEnteranceFee() { - SocketClient->ToGameServer()->SendCastleSiegeTaxChangeRequest(3, m_iViewEntranceFee); + SocketClient->ToGameServer()->SendCastleSiegeTaxChangeRequest(CastleSiegeTaxType::HuntingZoneEntranceFee, m_iViewEntranceFee); } void CUIGateKeeper::EnteranceFeeUp() diff --git a/src/source/UI/Legacy/UISenatus.cpp b/src/source/UI/Legacy/UISenatus.cpp index a97562e623..c98e2a507d 100644 --- a/src/source/UI/Legacy/UISenatus.cpp +++ b/src/source/UI/Legacy/UISenatus.cpp @@ -288,8 +288,8 @@ void CSenatusInfo::DoStatueUpgradeRecoverAction() void CSenatusInfo::DoApplyTaxAction() { - SocketClient->ToGameServer()->SendCastleSiegeTaxChangeRequest(1, m_iChaosTaxRate); - SocketClient->ToGameServer()->SendCastleSiegeTaxChangeRequest(2, m_iNormalTaxRate); + SocketClient->ToGameServer()->SendCastleSiegeTaxChangeRequest(CastleSiegeTaxType::ChaosMachine, m_iChaosTaxRate); + SocketClient->ToGameServer()->SendCastleSiegeTaxChangeRequest(CastleSiegeTaxType::Store, m_iNormalTaxRate); } void CSenatusInfo::DoWithdrawAction(DWORD dwMoney) diff --git a/src/source/UI/NewUI/Combat/NewUISiegeWarCommander.cpp b/src/source/UI/NewUI/Combat/NewUISiegeWarCommander.cpp index b45dc9e954..ce6102e0f8 100644 --- a/src/source/UI/NewUI/Combat/NewUISiegeWarCommander.cpp +++ b/src/source/UI/NewUI/Combat/NewUISiegeWarCommander.cpp @@ -89,7 +89,7 @@ bool SEASON3B::CNewUISiegeWarCommander::OnUpdateMouseEvent() SelectCmd.byLifeTime = 100; SocketClient->ToGameServer()->SendCastleGuildCommand(SelectCmd.byTeam, SelectCmd.byX, SelectCmd.byY, - SelectCmd.byCmd); + static_cast(SelectCmd.byCmd)); m_iCurSelectBtnCommand = -1; From 8f364e3e9ff76addb1369cb344c04fcc6a4f45c2 Mon Sep 17 00:00:00 2001 From: Sven Nicolai Date: Sun, 11 Oct 2026 11:49:01 +0000 Subject: [PATCH 2/4] feat(personal-shop): prices in items and account coins When the server allows other prices than zen for personal shops (MUnique/OpenMU#1186), for example in jewels or account coins, the client: - remembers the allowed price types (C1 3F 0A), which the server sends on world entry and before a shop list; - offers a currency button in the price input box, which switches between zen and the allowed price types, and sends other prices than zen with the extended set-price packet (C3 3F 09), whose price fields are encoded like the ones of the shop item list; zen still uses the original packet; - reads the item and coin prices of the extended shop item list instead of refusing the whole shop when an item has no zen price; - shows the price with its currency (item name or coin) in the item and pet tooltips; - leaves the "not enough items/coins" message to the server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y7XufeAZ22z7ocXAXUqUsE --- docs/control-socket.md | 7 +- docs/personal-shop.md | 37 +++++ src/Localization/Game.de.resx | 9 + src/Localization/Game.en.resx | 9 + src/source/App/Control/ControlState.cpp | 8 +- src/source/Engine/Object/ZzzInventory.cpp | 17 +- .../GameLogic/Items/PersonalShopCurrency.cpp | 133 +++++++++++++++ .../GameLogic/Items/PersonalShopCurrency.h | 55 ++++++ .../GameLogic/Items/PersonalShopTitleImp.h | 35 ++-- src/source/GameLogic/Pets/GIPetManager.cpp | 13 +- src/source/Network/Server/WSclient.cpp | 50 +++++- src/source/Network/Server/WSclient.h | 13 +- .../NewUI/Dialogs/NewUICustomMessageBox.cpp | 156 +++++++++--------- .../UI/NewUI/Dialogs/NewUICustomMessageBox.h | 4 +- .../NewUI/Dialogs/PersonalShopPriceMsgBox.cpp | 95 +++++++++++ .../NewUI/Dialogs/PersonalShopPriceMsgBox.h | 40 +++++ .../NewUI/Inventory/NewUIMyShopInventory.cpp | 1 + tests/gamelogic/CMakeLists.txt | 8 + .../gamelogic/test_personal_shop_currency.cpp | 92 +++++++++++ 19 files changed, 676 insertions(+), 106 deletions(-) create mode 100644 docs/personal-shop.md create mode 100644 src/source/GameLogic/Items/PersonalShopCurrency.cpp create mode 100644 src/source/GameLogic/Items/PersonalShopCurrency.h create mode 100644 src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.cpp create mode 100644 src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.h create mode 100644 tests/gamelogic/test_personal_shop_currency.cpp diff --git a/docs/control-socket.md b/docs/control-socket.md index b42848e507..6b55dbd7c7 100644 --- a/docs/control-socket.md +++ b/docs/control-socket.md @@ -108,7 +108,12 @@ The shops: `npc_shop` is the open NPC shop (`repair_shop`, `tax_rate`, 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. +`null`. The goods of the personal shops whose price isn't in zen also carry the +`price_item_type` of their price, as the server encodes it: the item (group in +the highest 4 bits, number in the others) or the account coin (`0xFFF0` WCoin +(C), `0xFFF1` WCoin (P), `0xFFF2` Goblin Points); see +[personal-shop.md](personal-shop.md). `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), diff --git a/docs/personal-shop.md b/docs/personal-shop.md new file mode 100644 index 0000000000..82f3a40191 --- /dev/null +++ b/docs/personal-shop.md @@ -0,0 +1,37 @@ +# Personal shop prices in items and account coins + +A personal shop sells its items for zen. When the server allows it (the +OpenMU plugin *Player shop currencies*), the price of each item can also be +set in: + +- items which the server configured as price items, by default the Jewels of + Bless, Soul, Chaos, Creation and Life; +- the account coins WCoin (C), WCoin (P) and Goblin Points, if the server + allows them. + +The server tells the client which price items and coins it allows when the +character enters the game. Without that, the shop works with zen only, as in +the original client. + +## Setting a price + +Right-click an item in the own shop (or drop one into it) to enter its price. +When the server allows other prices, the input box shows a button with the +currency below the message; each click on it switches to the next allowed +currency, in the order the server sent them, after zen. Zen prices below the +item's value still ask for a confirmation; the other currencies don't. + +The server checks every price: a price item or coin that isn't allowed, or an +amount above the server's maximum, is refused with a message. + +## Buying + +The tooltip of an item in a shop shows its price with the currency, e.g. +`Selling Price: 12 Jewel of Bless`. The "short of zen" warning is only shown +for zen prices. + +Item prices are paid with the price items of the inventory: a single item +counts as one piece, and the jewel bundles of the jewel mix as their number of +pieces. The server doesn't split bundles and gives no change, so the buyer +needs items which match the price exactly. When the items or coins are not +enough, the server says which amount is missing. diff --git a/src/Localization/Game.de.resx b/src/Localization/Game.de.resx index a0772e23f0..5d6b9c8036 100644 --- a/src/Localization/Game.de.resx +++ b/src/Localization/Game.de.resx @@ -13114,4 +13114,13 @@ Bonus: %ls + + WCoin (C) + + + WCoin (P) + + + Goblin-Punkte + diff --git a/src/Localization/Game.en.resx b/src/Localization/Game.en.resx index 59b1ab07dc..efe92a6c7d 100644 --- a/src/Localization/Game.en.resx +++ b/src/Localization/Game.en.resx @@ -13117,4 +13117,13 @@ Bonus: %ls + + WCoin (C) + + + WCoin (P) + + + Goblin Points + diff --git a/src/source/App/Control/ControlState.cpp b/src/source/App/Control/ControlState.cpp index 820db90965..a392c8967c 100644 --- a/src/source/App/Control/ControlState.cpp +++ b/src/source/App/Control/ControlState.cpp @@ -249,8 +249,12 @@ json PersonalShopItems(SEASON3B::CNewUIInventoryCtrl* grid, int priceTable) } const int slot = grid->GetIndexByItem(item); json described = DescribeItem(*item, slot); - int price = 0; - described["price"] = GetPersonalItemPrice(slot, price, priceTable) ? json(price) : json(nullptr); + GameLogic::PersonalShop::ItemPrice price; + const bool hasPrice = GetPersonalItemPrice(slot, price, priceTable); + described["price"] = hasPrice ? json(price.Amount) : json(nullptr); + described["price_item_type"] = hasPrice && price.Kind != GameLogic::PersonalShop::PriceKind::Zen + ? json(price.PriceItemType) + : json(nullptr); items.push_back(std::move(described)); } return items; diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index d375e79e96..b2c461a643 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -44,6 +44,7 @@ #include "GameLogic/Items/MixMgr.h" #include "UI/NewUI/Dialogs/NewUICommonMessageBox.h" #include "UI/NewUI/Dialogs/NewUICustomMessageBox.h" +#include "UI/NewUI/Dialogs/PersonalShopPriceMsgBox.h" #include "UI/NewUI/Inventory/NewUIInventoryCtrl.h" #include "GameLogic/Events/w_CursedTemple.h" #include "Network/Server/SocketSystem.h" @@ -2006,12 +2007,24 @@ void RenderItemInfo(int sx, int sy, ITEM* ip, bool Sell, int Inventype, bool bIt && !GameLogic::Items::IsPersonalShopBan(ip)) { { - int price = 0; + GameLogic::PersonalShop::ItemPrice itemPrice; int indexInv = g_pMyShopInventory->GetInventoryCtrl()->GetIndexByItem(ip); wchar_t Text[100]; - if (GetPersonalItemPrice(indexInv, price, g_IsPurchaseShop)) + const bool hasPrice = GetPersonalItemPrice(indexInv, itemPrice, g_IsPurchaseShop); + if (hasPrice && itemPrice.Kind != GameLogic::PersonalShop::PriceKind::Zen) { + // The server tells the player when the jewels or coins are not enough. + mu_swprintf(Text, L"%d %ls", itemPrice.Amount, GameLogic::PersonalShop::GetCurrencyName(itemPrice)); + mu_swprintf(TextList[TextNum], I18N::Game::SellingPriceS, Text); + TextListColor[TextNum] = TEXT_COLOR_YELLOW; + TextBold[TextNum] = true; + TextNum++; + mu_swprintf(TextList[TextNum], L"\n"); TextNum++; SkipNum++; + } + else if (hasPrice) + { + const int price = itemPrice.Amount; ConvertGold(price, Text); mu_swprintf(TextList[TextNum], I18N::Game::SellingPriceS, Text); diff --git a/src/source/GameLogic/Items/PersonalShopCurrency.cpp b/src/source/GameLogic/Items/PersonalShopCurrency.cpp new file mode 100644 index 0000000000..cd8b8144fe --- /dev/null +++ b/src/source/GameLogic/Items/PersonalShopCurrency.cpp @@ -0,0 +1,133 @@ +#include "stdafx.h" +#include "GameLogic/Items/PersonalShopCurrency.h" + +#include +#include +#include + +#include "Engine/Object/ZzzInfomation.h" +#include "I18N/All.h" + +namespace +{ +using GameLogic::PersonalShop::ItemPrice; +using GameLogic::PersonalShop::PriceKind; + +// Bits of the item in the price item type: the group in the highest 4 bits, +// the number in the remaining 12, as the server encodes it. +constexpr int PriceItemTypeGroupShift = 12; +constexpr uint16_t PriceItemTypeNumberMask = (1u << PriceItemTypeGroupShift) - 1; + +// The server marks prices in account coins with these price item types, in +// the order WCoin (C), WCoin (P), Goblin Points. The amount is in the money field. +constexpr uint16_t FirstAccountCoinPriceItemType = 0xFFF0; +constexpr uint16_t AccountCoinCount = 3; + +std::vector AllowedPriceTypes; + +bool IsAccountCoin(uint16_t priceItemType) +{ + return priceItemType >= FirstAccountCoinPriceItemType + && priceItemType < FirstAccountCoinPriceItemType + AccountCoinCount; +} + +ItemPrice ToPrice(uint16_t priceItemType, int amount) +{ + const PriceKind kind = IsAccountCoin(priceItemType) ? PriceKind::AccountCoin : PriceKind::Item; + return { amount, kind, priceItemType }; +} + +int ToClientItemType(uint16_t priceItemType) +{ + const int group = priceItemType >> PriceItemTypeGroupShift; + const int number = priceItemType & PriceItemTypeNumberMask; + return group * MAX_ITEM_INDEX + number; +} +} // namespace + +namespace GameLogic::PersonalShop +{ + ItemPrice DecodeExtendedPrice(uint32_t moneyPrice, uint16_t priceItemType, uint16_t requiredItemAmount) + { + if (requiredItemAmount > 0) + { + return { requiredItemAmount, PriceKind::Item, priceItemType }; + } + + if (IsAccountCoin(priceItemType)) + { + return { static_cast(moneyPrice), PriceKind::AccountCoin, priceItemType }; + } + + return { static_cast(moneyPrice), PriceKind::Zen, 0 }; + } + + EncodedPrice EncodeExtendedPrice(const ItemPrice& price) + { + switch (price.Kind) + { + case PriceKind::Item: + return { 0, price.PriceItemType, static_cast(price.Amount) }; + case PriceKind::AccountCoin: + return { static_cast(price.Amount), price.PriceItemType, 0 }; + case PriceKind::Zen: + default: + return { static_cast(price.Amount), 0, 0 }; + } + } + + void SetAllowedPriceTypes(std::span priceItemTypes) + { + AllowedPriceTypes.assign(priceItemTypes.begin(), priceItemTypes.end()); + } + + bool AreOtherPricesThanZenAllowed() + { + return !AllowedPriceTypes.empty(); + } + + ItemPrice GetNextAllowedPriceType(const ItemPrice& current) + { + // The order of the choices: zen, then the allowed price types as the server sent them. + auto next = AllowedPriceTypes.begin(); + if (current.Kind != PriceKind::Zen) + { + next = std::find(AllowedPriceTypes.begin(), AllowedPriceTypes.end(), current.PriceItemType); + if (next != AllowedPriceTypes.end()) + { + ++next; + } + } + + if (next == AllowedPriceTypes.end()) + { + return { current.Amount, PriceKind::Zen, 0 }; + } + + return ToPrice(*next, current.Amount); + } + + const wchar_t* GetCurrencyName(const ItemPrice& price) + { + switch (price.Kind) + { + case PriceKind::Item: + { + const int itemType = ToClientItemType(price.PriceItemType); + return itemType < MAX_ITEM ? ItemAttribute[itemType].Name : I18N::Game::Zen; + } + case PriceKind::AccountCoin: + { + static const std::array CoinNames = { + I18N::Game::WCoinC, + I18N::Game::WCoinP, + I18N::Game::GoblinPoints, + }; + return CoinNames[price.PriceItemType - FirstAccountCoinPriceItemType]; + } + case PriceKind::Zen: + default: + return I18N::Game::Zen; + } + } +} diff --git a/src/source/GameLogic/Items/PersonalShopCurrency.h b/src/source/GameLogic/Items/PersonalShopCurrency.h new file mode 100644 index 0000000000..f52d542174 --- /dev/null +++ b/src/source/GameLogic/Items/PersonalShopCurrency.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +// The prices of items in personal shops. Zen always works; prices in items +// (e.g. jewels) and account coins only when the server allows them and told +// the client which ones with the price types message. +namespace GameLogic::PersonalShop +{ + enum class PriceKind : uint8_t + { + Zen, + Item, + AccountCoin, + }; + + /// The price of an item in a personal shop. + struct ItemPrice + { + /// Zen, pieces of the price item or account coins, depending on the kind. + int Amount = 0; + PriceKind Kind = PriceKind::Zen; + /// The price type as the server names it: the item for item prices + /// (group in the highest 4 bits, number in the others), the reserved + /// value of the coin for coin prices, 0 for zen. + uint16_t PriceItemType = 0; + }; + + /// The price fields of the extended protocol, which the shop item list + /// and the set price request share. + struct EncodedPrice + { + uint32_t MoneyPrice = 0; + uint16_t PriceItemType = 0; + uint16_t RequiredItemAmount = 0; + }; + + ItemPrice DecodeExtendedPrice(uint32_t moneyPrice, uint16_t priceItemType, uint16_t requiredItemAmount); + EncodedPrice EncodeExtendedPrice(const ItemPrice& price); + + /// Stores the price types which the server allows in addition to zen. + void SetAllowedPriceTypes(std::span priceItemTypes); + + /// True when the server allows other prices than zen, so the price can be + /// set with the extended packet. + bool AreOtherPricesThanZenAllowed(); + + /// The allowed price type after the one of the given price, wrapping + /// around to zen. The amount of the result is the one of the given price. + ItemPrice GetNextAllowedPriceType(const ItemPrice& current); + + /// The name of the currency of the price, e.g. "Jewel of Bless". + const wchar_t* GetCurrencyName(const ItemPrice& price); +} diff --git a/src/source/GameLogic/Items/PersonalShopTitleImp.h b/src/source/GameLogic/Items/PersonalShopTitleImp.h index f4f806bf25..d70517e0ec 100644 --- a/src/source/GameLogic/Items/PersonalShopTitleImp.h +++ b/src/source/GameLogic/Items/PersonalShopTitleImp.h @@ -7,6 +7,7 @@ #include "Render/Models/ZzzBMD.h" #include "Engine/Object/ZzzObject.h" #include "Engine/Object/ZzzCharacter.h" +#include "GameLogic/Items/PersonalShopCurrency.h" inline POINT MakePos(long x, long y) { @@ -220,7 +221,7 @@ inline void DrawPersonalShopTitleImp() //. CPersonalItemPriceTable class CPersonalItemPriceTable { - std::map m_mapTable; + std::map m_mapTable; static CPersonalItemPriceTable* ms_pSeller; static CPersonalItemPriceTable* ms_pBuyer; @@ -239,14 +240,9 @@ class CPersonalItemPriceTable RemoveAllItemPrice(); } - void AddItemPrice(int index, int price) + void AddItemPrice(int index, const GameLogic::PersonalShop::ItemPrice& price) { - auto mi = m_mapTable.find(index); - if (mi != m_mapTable.end()) - { - m_mapTable.erase(mi); - } - m_mapTable.insert(std::map::value_type(index, price)); + m_mapTable[index] = price; } void RemoveItemPrice(int index) { @@ -261,7 +257,7 @@ class CPersonalItemPriceTable m_mapTable.clear(); } - bool GetItemPrice(int index, int& price) + bool GetItemPrice(int index, GameLogic::PersonalShop::ItemPrice& price) { auto mi = m_mapTable.find(index); if (mi != m_mapTable.end()) @@ -323,7 +319,7 @@ inline void ReleasePersonalItemTable() CPersonalItemPriceTable::ReleasePersonalItemTable(); } -inline void AddPersonalItemPrice(int index, int price, int type) +inline void AddPersonalItemPrice(int index, const GameLogic::PersonalShop::ItemPrice& price, int type) { CPersonalItemPriceTable* pPersonalItemTable = CPersonalItemPriceTable::GetObjPtr(type); if (pPersonalItemTable) @@ -332,6 +328,12 @@ inline void AddPersonalItemPrice(int index, int price, int type) } } +/// Adds a price in zen. +inline void AddPersonalItemPrice(int index, int price, int type) +{ + AddPersonalItemPrice(index, GameLogic::PersonalShop::ItemPrice{ price }, type); +} + inline void RemovePersonalItemPrice(int index, int type) { CPersonalItemPriceTable* pPersonalItemTable = CPersonalItemPriceTable::GetObjPtr(type); @@ -350,7 +352,7 @@ inline void RemoveAllPerosnalItemPrice(int type) } } -inline bool GetPersonalItemPrice(int index, int& price, int type) +inline bool GetPersonalItemPrice(int index, GameLogic::PersonalShop::ItemPrice& price, int type) { CPersonalItemPriceTable* pPersonalItemTable = CPersonalItemPriceTable::GetObjPtr(type); if (!pPersonalItemTable) @@ -359,6 +361,17 @@ inline bool GetPersonalItemPrice(int index, int& price, int type) return pPersonalItemTable->GetItemPrice(index, price); } +/// Gets the amount of the price, whatever its currency is. +inline bool GetPersonalItemPrice(int index, int& price, int type) +{ + GameLogic::PersonalShop::ItemPrice itemPrice; + if (!GetPersonalItemPrice(index, itemPrice, type)) + return false; + + price = itemPrice.Amount; + return true; +} + inline bool CheckPriceIntegrity(const wchar_t* szZen, int size) { if (size > 255) diff --git a/src/source/GameLogic/Pets/GIPetManager.cpp b/src/source/GameLogic/Pets/GIPetManager.cpp index 4ade6af2a2..3a58ca144d 100644 --- a/src/source/GameLogic/Pets/GIPetManager.cpp +++ b/src/source/GameLogic/Pets/GIPetManager.cpp @@ -644,12 +644,21 @@ static std::uint8_t g_tabBar = 0; } else if ((iInvenType == SEASON3B::TOOLTIP_TYPE_MY_SHOP) || (iInvenType == SEASON3B::TOOLTIP_TYPE_PURCHASE_SHOP)) { - int price = 0; + GameLogic::PersonalShop::ItemPrice itemPrice; const int indexInv = g_pMyShopInventory->GetInventoryCtrl()->GetIndexByItem(pItem); wchar_t textBuffer[kTooltipBufferCapacity] {}; - if (GetPersonalItemPrice(indexInv, price, g_IsPurchaseShop)) + const bool hasPrice = GetPersonalItemPrice(indexInv, itemPrice, g_IsPurchaseShop); + if (hasPrice && itemPrice.Kind != GameLogic::PersonalShop::PriceKind::Zen) { + // The server tells the player when the jewels or coins are not enough. + std::swprintf(textBuffer, kTooltipBufferCapacity, L"%d %ls", itemPrice.Amount, GameLogic::PersonalShop::GetCurrencyName(itemPrice)); + appendLine(TEXT_COLOR_YELLOW, true, false, priceFormat.c_str(), textBuffer); + appendEmptyLine(); + } + else if (hasPrice) + { + const int price = itemPrice.Amount; ConvertGold(price, textBuffer); int priceColor = TEXT_COLOR_WHITE; diff --git a/src/source/Network/Server/WSclient.cpp b/src/source/Network/Server/WSclient.cpp index 1063691e62..214c4eeb2e 100644 --- a/src/source/Network/Server/WSclient.cpp +++ b/src/source/Network/Server/WSclient.cpp @@ -686,6 +686,9 @@ void ReceiveCharacterListExtended(const BYTE* ReceiveBuffer) { InitGuildWar(); + // The server sends the allowed price types again when a character enters the game. + GameLogic::PersonalShop::SetAllowedPriceTypes({}); + ClearCharacters(); SelectedCharacter = -1; SelectedHero = -1; @@ -6305,7 +6308,7 @@ BOOL ReceiveEquipmentItemExtended(std::span ReceiveBuffer) if (iSourceIndex >= MAX_MY_INVENTORY_EX_INDEX) { - int price = 0; + GameLogic::PersonalShop::ItemPrice price; if (GetPersonalItemPrice(iSourceIndex, price, g_IsPurchaseShop)) { RemovePersonalItemPrice(iSourceIndex, g_IsPurchaseShop); @@ -9388,6 +9391,35 @@ void ReceiveDestroyPersonalShop(const BYTE* ReceiveBuffer) } } +namespace +{ +GameLogic::PersonalShop::ItemPrice DecodeShopItemPrice(const GETPSHOPITEM_DATAINFO& shopItem) +{ + return GameLogic::PersonalShop::DecodeExtendedPrice( + static_cast(shopItem.MoneyPrice), shopItem.PriceItemType, shopItem.RequiredItemAmount); +} +} // namespace + +void ReceivePersonalShopPriceTypes(std::span ReceiveBuffer) +{ + auto Header = safe_cast(ReceiveBuffer); + if (Header == nullptr) + { + assert(false); + return; + } + + const auto priceTypesData = ReceiveBuffer.subspan(sizeof(PSHOP_PRICETYPES_HEADERINFO)); + const size_t count = std::min(Header->Count, priceTypesData.size() / sizeof(WORD)); + std::vector priceItemTypes(count); + for (size_t i = 0; i < count; ++i) + { + priceItemTypes[i] = static_cast(priceTypesData[i * sizeof(WORD)] | (priceTypesData[i * sizeof(WORD) + 1] << 8)); + } + + GameLogic::PersonalShop::SetAllowedPriceTypes(priceItemTypes); +} + void ReceivePersonalShopItemList(std::span ReceiveBuffer) { auto Header = safe_cast(ReceiveBuffer); @@ -9437,11 +9469,11 @@ void ReceivePersonalShopItemList(std::span ReceiveBuffer) int length = CalcItemLength(itemData); itemData = itemData.subspan(0, length); - // todo: use item prices as well when the UI is ready - if (pShopItem->MoneyPrice > 0) + const auto price = DecodeShopItemPrice(*pShopItem); + if (price.Amount > 0) { g_pPurchaseShopInventory->InsertItem(pShopItem->ItemSlot, itemData); - AddPersonalItemPrice(pShopItem->ItemSlot, pShopItem->MoneyPrice, PSHOPWNDTYPE_PURCHASE); + AddPersonalItemPrice(pShopItem->ItemSlot, price, PSHOPWNDTYPE_PURCHASE); } else { @@ -9514,7 +9546,7 @@ void ReceiveRefreshItemList(std::span ReceiveBuffer) itemData = itemData.subspan(0, length); g_pMyShopInventory->InsertItem(pShopItem->ItemSlot, itemData); - AddPersonalItemPrice(pShopItem->ItemSlot, pShopItem->MoneyPrice, PSHOPWNDTYPE_SALE); + AddPersonalItemPrice(pShopItem->ItemSlot, DecodeShopItemPrice(*pShopItem), PSHOPWNDTYPE_SALE); Offset += length; } @@ -9546,7 +9578,7 @@ void ReceiveRefreshItemList(std::span ReceiveBuffer) itemData = itemData.subspan(0, length); g_pPurchaseShopInventory->InsertItem(pShopItem->ItemSlot, itemData); - AddPersonalItemPrice(pShopItem->ItemSlot, pShopItem->MoneyPrice, PSHOPWNDTYPE_PURCHASE); + AddPersonalItemPrice(pShopItem->ItemSlot, DecodeShopItemPrice(*pShopItem), PSHOPWNDTYPE_PURCHASE); Offset += length; } @@ -9621,6 +9653,9 @@ void ReceivePurchaseItem(std::span ReceiveBuffer) g_pSystemLogBox->AddText(I18N::Game::InventoryIsFull, SEASON3B::TYPE_ERROR_MESSAGE); } break; + case PURCHASEITEM_RESULTINFO::LackOfPriceCurrency: + // The server tells which amount of jewels or coins is missing in a message. + break; case PURCHASEITEM_RESULTINFO::ItemBlock: default: g_ErrorReport.Write(L"@ [Fault] ReceivePurchaseItem (result : %d)\n", Header->Result); @@ -14336,6 +14371,9 @@ static void ProcessPacket(const BYTE* ReceiveBuffer, int32_t Size) case 0x08: NotifySoldItem(ReceiveBuffer); break; + case 0x0A: + ReceivePersonalShopPriceTypes(received_span); + break; case 0x10: ReceiveShopTitleChange(ReceiveBuffer); break; diff --git a/src/source/Network/Server/WSclient.h b/src/source/Network/Server/WSclient.h index 6778d76a1e..a1ee1a9d89 100644 --- a/src/source/Network/Server/WSclient.h +++ b/src/source/Network/Server/WSclient.h @@ -1728,11 +1728,19 @@ typedef struct tagGETPSHOPITEMLIST_HEADER { #pragma pack(push, 1) // just to get the actual length by sizeof... typedef struct tagGETPSHOPITEM_DATA { INT MoneyPrice; - WORD PriceItemType; // not yet used - WORD RequiredItemAmount; // not yet used + WORD PriceItemType; + WORD RequiredItemAmount; BYTE ItemSlot; BYTE Item[PACKET_ITEM_LENGTH_EXTENDED_MIN]; } GETPSHOPITEM_DATAINFO, * LPGETPSHOPITEM_DATAINFO; + +// The price types which the server allows for prices in personal shops, in +// addition to zen (C1 3F 0A). The price item types (WORD) follow it. +typedef struct tagPSHOP_PRICETYPES_HEADER { + PBMSG_HEADER Header; + BYTE bySubcode; + BYTE Count; +} PSHOP_PRICETYPES_HEADERINFO, * LPPSHOP_PRICETYPES_HEADERINFO; #pragma pack(pop) typedef struct tagCLOSEPSHOP_RESULT { @@ -1755,6 +1763,7 @@ typedef struct tagPURCHASEITEM_RESULT { LackOfMoney = 7, MoneyOverflowOrNotEnoughSpace = 8, ItemBlock = 9, + LackOfPriceCurrency = 10, }; PBMSG_HEADER Header; diff --git a/src/source/UI/NewUI/Dialogs/NewUICustomMessageBox.cpp b/src/source/UI/NewUI/Dialogs/NewUICustomMessageBox.cpp index 597964d50a..308e1fb60f 100644 --- a/src/source/UI/NewUI/Dialogs/NewUICustomMessageBox.cpp +++ b/src/source/UI/NewUI/Dialogs/NewUICustomMessageBox.cpp @@ -13,6 +13,7 @@ #include "GameLogic/Events/w_CursedTemple.h" #include "GameLogic/Items/MixMgr.h" #include "GameLogic/Items/PersonalShopTitleImp.h" +#include "UI/NewUI/Dialogs/PersonalShopPriceMsgBox.h" #include "GameLogic/NPCs/npcBreeder.h" #include "Engine/Object/ZzzOpenData.h" #include "GameLogic/Items/InventoryUtils.h" @@ -4351,7 +4352,7 @@ CALLBACK_RESULT SEASON3B::CZenPaymentMsgBoxLayout::CancelBtnDown(class CNewUIMes bool SEASON3B::CPersonalShopItemValueMsgBoxLayout::SetLayout() { - CNewUITextInputMsgBox* pMsgBox = GetMsgBox(); + CPersonalShopPriceMsgBox* pMsgBox = GetMsgBox(); if (0 == pMsgBox) return false; @@ -4360,6 +4361,7 @@ bool SEASON3B::CPersonalShopItemValueMsgBoxLayout::SetLayout() pMsgBox->SetInputBoxOption(UIOPTION_NUMBERONLY | UIOPTION_PAINTBACK); pMsgBox->AddMsg(I18N::Game::EnterSellingPrice); + pMsgBox->AddCurrencySelection(); pMsgBox->AddCallbackFunc(CPersonalShopItemValueMsgBoxLayout::ReturnDown, MSGBOX_EVENT_PRESSKEY_RETURN); pMsgBox->AddCallbackFunc(CPersonalShopItemValueMsgBoxLayout::OkBtnDown, MSGBOX_EVENT_USER_COMMON_OK); pMsgBox->AddCallbackFunc(CPersonalShopItemValueMsgBoxLayout::CancelBtnDown, MSGBOX_EVENT_USER_COMMON_CANCEL); @@ -4368,107 +4370,103 @@ bool SEASON3B::CPersonalShopItemValueMsgBoxLayout::SetLayout() return true; } -CALLBACK_RESULT SEASON3B::CPersonalShopItemValueMsgBoxLayout::ProcessOk(class CNewUIMessageBoxBase* pOwner, const leaf::xstreambuf& xParam) +namespace { - auto* pMsgBox = dynamic_cast(pOwner); - - wchar_t strText[MAX_TEXT_LENGTH] = { 0, }; +// A zen price below the value of the item has to be confirmed by the player. +bool IsZenPriceBelowItemValue(int price) +{ + SEASON3B::CNewUIPickedItem* pPickedItem = SEASON3B::CNewUIInventoryCtrl::GetPickedItem(); + ITEM* pItem = pPickedItem + ? pPickedItem->GetItem() + : g_pMyShopInventory->FindItem(g_pMyShopInventory->GetSourceIndex()); + if (pItem == nullptr) + { + return false; + } - pMsgBox->GetInputBoxText(strText); + return price < static_cast(ItemValue(pItem, 2)); +} - if (wcslen(strText) == 0) +void ShowZenPriceConfirmation(const wchar_t* strPrice, int price) +{ + SEASON3B::CNewUI3DItemCommonMsgBox* lpMsgBox = NULL; + SEASON3B::CreateMessageBox(MSGBOX_LAYOUT_CLASS(SEASON3B::CPersonalShopItemValueCheckMsgBoxLayout), &lpMsgBox); + if (lpMsgBox == nullptr) { - return CALLBACK_CONTINUE; + return; } - int iInputZen = _wtoi(strText); - if (iInputZen == 0) + wchar_t strText[MAX_TEXT_LENGTH] = { 0, }; + mu_swprintf(strText, I18N::Game::SellingPriceSZen, strPrice); + lpMsgBox->AddMsg(strText, RGBA(255, 0, 0, 255), SEASON3B::MSGBOX_FONT_BOLD); + lpMsgBox->AddMsg(I18N::Game::DoYouWantToSellItemAtThisPrice); + lpMsgBox->SetItemValue(price); +} + +// Sends the price of the item which is moved into or within the own shop, or +// whose price is changed, and remembers it for the tooltip. +void ApplyPersonalShopItemPrice(const GameLogic::PersonalShop::ItemPrice& price) +{ + if (g_pMyShopInventory->IsEnablePersonalShop() == true) { - return CALLBACK_CONTINUE; + SocketClient->ToGameServer()->SendPlayerShopClose(); } - CNewUIPickedItem* pPickedItem = CNewUIInventoryCtrl::GetPickedItem(); - ITEM* pItem = NULL; - if (pPickedItem) + SEASON3B::CNewUIPickedItem* pPickedItem = SEASON3B::CNewUIInventoryCtrl::GetPickedItem(); + if (pPickedItem == nullptr) { - pItem = pPickedItem->GetItem(); + const int iSourceIndex = g_pMyShopInventory->GetSourceIndex(); + UI::PersonalShop::SendItemPrice(iSourceIndex, price); + AddPersonalItemPrice(iSourceIndex, price, g_IsPurchaseShop); + return; } - else + + ITEM* pItemObj = pPickedItem->GetItem(); + const int iSourceIndex = pPickedItem->GetSourceLinealPos(); + const int iTargetIndex = g_pMyShopInventory->GetTargetIndex(); + const auto* pOwnerInventory = pPickedItem->GetOwnerInventory(); + const bool isFromInventory = pOwnerInventory == g_pMyInventory->GetInventoryCtrl() || pOwnerInventory == nullptr; + const bool isFromShop = pOwnerInventory == g_pMyShopInventory->GetInventoryCtrl(); + if (isFromInventory || isFromShop) { - int iSourceIndex = g_pMyShopInventory->GetSourceIndex(); - pItem = g_pMyShopInventory->FindItem(iSourceIndex); + UI::PersonalShop::SendItemPrice(iSourceIndex, price); + const STORAGE_TYPE sourceStorage = isFromShop ? STORAGE_TYPE::MYSHOP : STORAGE_TYPE::INVENTORY; + SendRequestEquipmentItem(sourceStorage, iSourceIndex, pItemObj, STORAGE_TYPE::MYSHOP, iTargetIndex); } - bool bResult = false; - if (pItem) + AddPersonalItemPrice(iTargetIndex, price, g_IsPurchaseShop); +} +} // namespace + +CALLBACK_RESULT SEASON3B::CPersonalShopItemValueMsgBoxLayout::ProcessOk(class CNewUIMessageBoxBase* pOwner, const leaf::xstreambuf& xParam) +{ + auto* pMsgBox = dynamic_cast(pOwner); + if (pMsgBox == nullptr) { - DWORD dwItemValue = ItemValue(pItem, 2); + return CALLBACK_CONTINUE; + } - if (iInputZen < (int)dwItemValue) - { - bResult = true; - } + wchar_t strText[MAX_TEXT_LENGTH] = { 0, }; + pMsgBox->GetInputBoxText(strText); + if (wcslen(strText) == 0) + { + return CALLBACK_CONTINUE; } - if (bResult == true) + GameLogic::PersonalShop::ItemPrice price = pMsgBox->GetPriceType(); + price.Amount = _wtoi(strText); + if (price.Amount == 0) { - SEASON3B::CNewUI3DItemCommonMsgBox* lpMsgBox = NULL; + return CALLBACK_CONTINUE; + } - SEASON3B::CreateMessageBox(MSGBOX_LAYOUT_CLASS(SEASON3B::CPersonalShopItemValueCheckMsgBoxLayout), &lpMsgBox); - if (lpMsgBox) - { - wchar_t strText2[MAX_TEXT_LENGTH] = { 0, }; - mu_swprintf(strText2, I18N::Game::SellingPriceSZen, strText); - lpMsgBox->AddMsg(strText2, RGBA(255, 0, 0, 255), MSGBOX_FONT_BOLD); - lpMsgBox->AddMsg(I18N::Game::DoYouWantToSellItemAtThisPrice); - lpMsgBox->SetItemValue(iInputZen); - } + if (price.Kind == GameLogic::PersonalShop::PriceKind::Zen && IsZenPriceBelowItemValue(price.Amount)) + { + ShowZenPriceConfirmation(strText, price.Amount); } else { - if (g_pMyShopInventory->IsEnablePersonalShop() == true) - { - SocketClient->ToGameServer()->SendPlayerShopClose(); - } - - CNewUIPickedItem* pPickedItem = CNewUIInventoryCtrl::GetPickedItem(); - - int iSourceIndex = -1, iTargetIndex = -1; - int shopWndType = 0; - - if (pPickedItem) - { - ITEM* pItemObj = pPickedItem->GetItem(); - iSourceIndex = pPickedItem->GetSourceLinealPos(); - iTargetIndex = g_pMyShopInventory->GetTargetIndex(); - - if (pPickedItem->GetOwnerInventory() == g_pMyInventory->GetInventoryCtrl()) - { - SocketClient->ToGameServer()->SendPlayerShopSetItemPrice(iSourceIndex, iInputZen); - - SendRequestEquipmentItem(STORAGE_TYPE::INVENTORY, iSourceIndex, pItemObj, STORAGE_TYPE::MYSHOP, iTargetIndex); - } - else if (pPickedItem->GetOwnerInventory() == nullptr) - { - SocketClient->ToGameServer()->SendPlayerShopSetItemPrice(iSourceIndex, iInputZen); - - SendRequestEquipmentItem(STORAGE_TYPE::INVENTORY, iSourceIndex, pItemObj, STORAGE_TYPE::MYSHOP, iTargetIndex); - } - else if (pPickedItem->GetOwnerInventory() == g_pMyShopInventory->GetInventoryCtrl()) - { - SocketClient->ToGameServer()->SendPlayerShopSetItemPrice(iSourceIndex, iInputZen); - - SendRequestEquipmentItem(STORAGE_TYPE::MYSHOP, iSourceIndex, pItemObj, STORAGE_TYPE::MYSHOP, iTargetIndex); - } - - AddPersonalItemPrice(iTargetIndex, iInputZen, g_IsPurchaseShop); - } - else - { - iSourceIndex = g_pMyShopInventory->GetSourceIndex(); - SocketClient->ToGameServer()->SendPlayerShopSetItemPrice(iSourceIndex, iInputZen); - AddPersonalItemPrice(iSourceIndex, iInputZen, g_IsPurchaseShop); - } + ApplyPersonalShopItemPrice(price); } g_pMyShopInventory->SetInputValueTextBox(false); diff --git a/src/source/UI/NewUI/Dialogs/NewUICustomMessageBox.h b/src/source/UI/NewUI/Dialogs/NewUICustomMessageBox.h index 3dfbd0c53e..247dfd007b 100644 --- a/src/source/UI/NewUI/Dialogs/NewUICustomMessageBox.h +++ b/src/source/UI/NewUI/Dialogs/NewUICustomMessageBox.h @@ -1044,7 +1044,9 @@ namespace SEASON3B static CALLBACK_RESULT ProcessOk(class CNewUIMessageBoxBase* pOwner, const leaf::xstreambuf& xParam); }; - class CPersonalShopItemValueMsgBoxLayout : public TMsgBoxLayout + class CPersonalShopPriceMsgBox; + + class CPersonalShopItemValueMsgBoxLayout : public TMsgBoxLayout { public: bool SetLayout(); diff --git a/src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.cpp b/src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.cpp new file mode 100644 index 0000000000..4b7ac4730f --- /dev/null +++ b/src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.cpp @@ -0,0 +1,95 @@ +#include "stdafx.h" +#include "UI/NewUI/Dialogs/PersonalShopPriceMsgBox.h" + +#include "Audio/DSPlaySound.h" +#include "I18N/All.h" +#include "Network/Server/WSclient.h" + +namespace +{ +// The currency button takes the height of two message lines, which are left +// blank for it below the "enter the selling price" message. +constexpr int CurrencyButtonLines = 2; +} // namespace + +void SEASON3B::CPersonalShopPriceMsgBox::AddCurrencySelection() +{ + m_isCurrencySelectable = GameLogic::PersonalShop::AreOtherPricesThanZenAllowed(); + if (!m_isCurrencySelectable) + { + return; + } + + for (int i = 0; i < CurrencyButtonLines; ++i) + { + AddMsg(L" "); + } + + const float x = GetPos().x + (MSGBOX_WIDTH / 2) - (MSGBOX_BTN_EMPTY_WIDTH / 2); + const float y = GetPos().y + MSGBOX_TEXT_TOP_BLANK + MSGBOX_MIDDLE_HEIGHT; + m_BtnCurrency.SetInfo(CNewUIMessageBoxMng::IMAGE_MSGBOX_BTN_EMPTY, x, y, + MSGBOX_BTN_EMPTY_WIDTH, MSGBOX_BTN_EMPTY_HEIGHT, CNewUIMessageBoxButton::MSGBOX_BTN_SIZE_EMPTY); + m_BtnCurrency.SetText(GameLogic::PersonalShop::GetCurrencyName(m_priceType)); + + // Replaces the callback of the base class, which is called for the other buttons. + AddCallbackFunc(CPersonalShopPriceMsgBox::LButtonUp, MSGBOX_EVENT_MOUSE_LBUTTON_UP); +} + +bool SEASON3B::CPersonalShopPriceMsgBox::Update() +{ + if (m_isCurrencySelectable) + { + m_BtnCurrency.Update(); + } + + return CNewUITextInputMsgBox::Update(); +} + +bool SEASON3B::CPersonalShopPriceMsgBox::Render() +{ + CNewUITextInputMsgBox::Render(); + if (!m_isCurrencySelectable) + { + return true; + } + + EnableAlphaTest(); + m_BtnCurrency.Render(); + DisableAlphaBlend(); + return true; +} + +CALLBACK_RESULT SEASON3B::CPersonalShopPriceMsgBox::LButtonUp(class CNewUIMessageBoxBase* pOwner, const leaf::xstreambuf& xParam) +{ + auto* pMsgBox = dynamic_cast(pOwner); + if (pMsgBox && pMsgBox->m_isCurrencySelectable && pMsgBox->m_BtnCurrency.IsMouseIn()) + { + pMsgBox->SelectNextCurrency(); + PlayBuffer(SOUND_CLICK01); + return CALLBACK_BREAK; + } + + return CNewUITextInputMsgBox::LButtonUp(pOwner, xParam); +} + +void SEASON3B::CPersonalShopPriceMsgBox::SelectNextCurrency() +{ + m_priceType = GameLogic::PersonalShop::GetNextAllowedPriceType(m_priceType); + m_BtnCurrency.SetText(GameLogic::PersonalShop::GetCurrencyName(m_priceType)); +} + +namespace UI::PersonalShop +{ + void SendItemPrice(int itemSlot, const GameLogic::PersonalShop::ItemPrice& price) + { + if (price.Kind == GameLogic::PersonalShop::PriceKind::Zen) + { + SocketClient->ToGameServer()->SendPlayerShopSetItemPrice(itemSlot, price.Amount); + return; + } + + const auto encoded = GameLogic::PersonalShop::EncodeExtendedPrice(price); + SocketClient->ToGameServer()->SendPlayerShopSetItemPriceExtended( + encoded.MoneyPrice, encoded.PriceItemType, encoded.RequiredItemAmount, itemSlot); + } +} diff --git a/src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.h b/src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.h new file mode 100644 index 0000000000..fd0a259048 --- /dev/null +++ b/src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.h @@ -0,0 +1,40 @@ +#pragma once + +#include "GameLogic/Items/PersonalShopCurrency.h" +#include "UI/NewUI/Dialogs/NewUICustomMessageBox.h" + +namespace SEASON3B +{ + /// The input box for the price of an item of the own personal shop. + /// When the server allows other prices than zen, it offers a button + /// which switches between the allowed currencies. + class CPersonalShopPriceMsgBox : public CNewUITextInputMsgBox + { + public: + /// Adds the currency button below the messages, if the server allows + /// other prices than zen. Call it after the messages were added. + void AddCurrencySelection(); + + /// The selected currency, as a price without amount. + const GameLogic::PersonalShop::ItemPrice& GetPriceType() const { return m_priceType; } + + bool Update() override; + bool Render() override; + + static CALLBACK_RESULT LButtonUp(class CNewUIMessageBoxBase* pOwner, const leaf::xstreambuf& xParam); + + private: + void SelectNextCurrency(); + + GameLogic::PersonalShop::ItemPrice m_priceType; + bool m_isCurrencySelectable = false; + CNewUIMessageBoxButton m_BtnCurrency; + }; +} + +namespace UI::PersonalShop +{ + /// Sends the price of an item of the own personal shop. Zen prices use the + /// original packet, so they work with every server. + void SendItemPrice(int itemSlot, const GameLogic::PersonalShop::ItemPrice& price); +} diff --git a/src/source/UI/NewUI/Inventory/NewUIMyShopInventory.cpp b/src/source/UI/NewUI/Inventory/NewUIMyShopInventory.cpp index bb4a971eab..dac08fb82d 100644 --- a/src/source/UI/NewUI/Inventory/NewUIMyShopInventory.cpp +++ b/src/source/UI/NewUI/Inventory/NewUIMyShopInventory.cpp @@ -6,6 +6,7 @@ #include "Audio/DSPlaySound.h" #include "UI/NewUI/NewUISystem.h" #include "UI/NewUI/Dialogs/NewUICustomMessageBox.h" +#include "UI/NewUI/Dialogs/PersonalShopPriceMsgBox.h" #include "GameLogic/Items/PersonalShopTitleImp.h" #include "I18N/All.h" #include "GameLogic/Items/ShopRestrictions.h" diff --git a/tests/gamelogic/CMakeLists.txt b/tests/gamelogic/CMakeLists.txt index 79e4f6c739..ae26bd3c01 100644 --- a/tests/gamelogic/CMakeLists.txt +++ b/tests/gamelogic/CMakeLists.txt @@ -5,3 +5,11 @@ mu_add_test( LINK_LIBS MuClient ) + +mu_add_test( + NAME test_personal_shop_currency + SOURCES + test_personal_shop_currency.cpp + LINK_LIBS + MuClient +) diff --git a/tests/gamelogic/test_personal_shop_currency.cpp b/tests/gamelogic/test_personal_shop_currency.cpp new file mode 100644 index 0000000000..3ab156813f --- /dev/null +++ b/tests/gamelogic/test_personal_shop_currency.cpp @@ -0,0 +1,92 @@ +// Tests of the prices in personal shops: how the extended protocol encodes them, and which currencies the price +// input offers after the server sent its allowed price types. + +#include "stdafx.h" + +#include "doctest.h" + +#include "GameLogic/Items/PersonalShopCurrency.h" + +#include +#include + +using GameLogic::PersonalShop::DecodeExtendedPrice; +using GameLogic::PersonalShop::EncodeExtendedPrice; +using GameLogic::PersonalShop::GetNextAllowedPriceType; +using GameLogic::PersonalShop::ItemPrice; +using GameLogic::PersonalShop::PriceKind; +using GameLogic::PersonalShop::SetAllowedPriceTypes; + +namespace +{ +// Jewel of Bless: group 14, number 13; Jewel of Soul: group 14, number 14. +constexpr uint16_t JewelOfBless = 0xE00D; +constexpr uint16_t JewelOfSoul = 0xE00E; +constexpr uint16_t GoblinPoints = 0xFFF2; +} // namespace + +TEST_CASE("a price without item type and amount is in zen") +{ + const auto price = DecodeExtendedPrice(1000, 0, 0); + CHECK(price.Amount == 1000); + CHECK(price.Kind == PriceKind::Zen); +} + +TEST_CASE("a price in items has the item as type and the pieces as amount") +{ + const auto price = DecodeExtendedPrice(0, JewelOfBless, 12); + CHECK(price.Amount == 12); + CHECK(price.Kind == PriceKind::Item); + CHECK(price.PriceItemType == JewelOfBless); +} + +TEST_CASE("a price in account coins has a reserved item type and the coins as money") +{ + const auto price = DecodeExtendedPrice(500, GoblinPoints, 0); + CHECK(price.Amount == 500); + CHECK(price.Kind == PriceKind::AccountCoin); + CHECK(price.PriceItemType == GoblinPoints); +} + +TEST_CASE("a price is encoded like it's decoded") +{ + const std::array prices = { { + { 1000, PriceKind::Zen, 0 }, + { 12, PriceKind::Item, JewelOfBless }, + { 500, PriceKind::AccountCoin, GoblinPoints }, + } }; + + for (const auto& price : prices) + { + const auto encoded = EncodeExtendedPrice(price); + const auto decoded = DecodeExtendedPrice(encoded.MoneyPrice, encoded.PriceItemType, encoded.RequiredItemAmount); + CHECK(decoded.Amount == price.Amount); + CHECK(decoded.Kind == price.Kind); + CHECK(decoded.PriceItemType == price.PriceItemType); + } +} + +TEST_CASE("the price input offers only zen until the server allows other prices") +{ + SetAllowedPriceTypes({}); + CHECK_FALSE(GameLogic::PersonalShop::AreOtherPricesThanZenAllowed()); + CHECK(GetNextAllowedPriceType({}).Kind == PriceKind::Zen); +} + +TEST_CASE("the price input cycles through zen and the allowed price types") +{ + const std::array allowed = { JewelOfSoul, GoblinPoints }; + SetAllowedPriceTypes(allowed); + CHECK(GameLogic::PersonalShop::AreOtherPricesThanZenAllowed()); + + const auto soul = GetNextAllowedPriceType({}); + CHECK(soul.Kind == PriceKind::Item); + CHECK(soul.PriceItemType == JewelOfSoul); + + const auto goblinPoints = GetNextAllowedPriceType(soul); + CHECK(goblinPoints.Kind == PriceKind::AccountCoin); + CHECK(goblinPoints.PriceItemType == GoblinPoints); + + CHECK(GetNextAllowedPriceType(goblinPoints).Kind == PriceKind::Zen); + SetAllowedPriceTypes({}); +} From 9ab1ad0a8048539c6f90475a94deb21cedd541aa Mon Sep 17 00:00:00 2001 From: Sven Nicolai Date: Mon, 12 Oct 2026 20:15:27 +0000 Subject: [PATCH 3/4] fix(personal-shop): build issues found in review - Drop the new "WCoin (P)" translation, which collides with the existing "W Coin(P)" key (both become I18N::Game::WCoinP) and made ResxGen abort. Reuse the existing key and name the new one "W Coin(C)" alike. - Qualify the return type of CPersonalShopPriceMsgBox::LButtonUp, which g++ doesn't find outside of the SEASON3B namespace. - Move GetCurrencyName to its own translation unit. It needs the item and text tables of the game, which pulled UI globals of Winmain.cpp into the tests of the protocol logic. The protocol constants are shared through the header now. - Format the changed lines with clang-format 21, as the CI checks them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y7XufeAZ22z7ocXAXUqUsE --- src/Localization/Game.de.resx | 7 +- src/Localization/Game.en.resx | 7 +- src/source/App/Control/ControlState.cpp | 4 +- src/source/Engine/Object/ZzzInventory.cpp | 4 +- .../GameLogic/Items/PersonalShopCurrency.cpp | 138 ++++++------------ .../GameLogic/Items/PersonalShopCurrency.h | 107 ++++++++------ .../Items/PersonalShopCurrencyName.cpp | 46 ++++++ .../GameLogic/Items/PersonalShopTitleImp.h | 2 +- src/source/GameLogic/Pets/GIPetManager.cpp | 3 +- src/source/Network/Server/WSclient.cpp | 7 +- src/source/Network/Server/WSclient.h | 15 +- src/source/UI/Legacy/UIGateKeeper.cpp | 3 +- .../NewUI/Combat/NewUISiegeWarCommander.cpp | 5 +- .../NewUI/Dialogs/NewUICustomMessageBox.cpp | 12 +- .../NewUI/Dialogs/PersonalShopPriceMsgBox.cpp | 27 ++-- .../NewUI/Dialogs/PersonalShopPriceMsgBox.h | 53 +++---- 16 files changed, 230 insertions(+), 210 deletions(-) create mode 100644 src/source/GameLogic/Items/PersonalShopCurrencyName.cpp diff --git a/src/Localization/Game.de.resx b/src/Localization/Game.de.resx index 5d6b9c8036..ac4adc39fb 100644 --- a/src/Localization/Game.de.resx +++ b/src/Localization/Game.de.resx @@ -13114,11 +13114,8 @@ Bonus: %ls - - WCoin (C) - - - WCoin (P) + + W Coin(C) Goblin-Punkte diff --git a/src/Localization/Game.en.resx b/src/Localization/Game.en.resx index efe92a6c7d..e33fa265b9 100644 --- a/src/Localization/Game.en.resx +++ b/src/Localization/Game.en.resx @@ -13117,11 +13117,8 @@ Bonus: %ls - - WCoin (C) - - - WCoin (P) + + W Coin(C) Goblin Points diff --git a/src/source/App/Control/ControlState.cpp b/src/source/App/Control/ControlState.cpp index a392c8967c..d5aa6b20ae 100644 --- a/src/source/App/Control/ControlState.cpp +++ b/src/source/App/Control/ControlState.cpp @@ -253,8 +253,8 @@ json PersonalShopItems(SEASON3B::CNewUIInventoryCtrl* grid, int priceTable) const bool hasPrice = GetPersonalItemPrice(slot, price, priceTable); described["price"] = hasPrice ? json(price.Amount) : json(nullptr); described["price_item_type"] = hasPrice && price.Kind != GameLogic::PersonalShop::PriceKind::Zen - ? json(price.PriceItemType) - : json(nullptr); + ? json(price.PriceItemType) + : json(nullptr); items.push_back(std::move(described)); } return items; diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index b2c461a643..19540a52c8 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -2020,7 +2020,9 @@ void RenderItemInfo(int sx, int sy, ITEM* ip, bool Sell, int Inventype, bool bIt TextListColor[TextNum] = TEXT_COLOR_YELLOW; TextBold[TextNum] = true; TextNum++; - mu_swprintf(TextList[TextNum], L"\n"); TextNum++; SkipNum++; + mu_swprintf(TextList[TextNum], L"\n"); + TextNum++; + SkipNum++; } else if (hasPrice) { diff --git a/src/source/GameLogic/Items/PersonalShopCurrency.cpp b/src/source/GameLogic/Items/PersonalShopCurrency.cpp index cd8b8144fe..84d69d5f7e 100644 --- a/src/source/GameLogic/Items/PersonalShopCurrency.cpp +++ b/src/source/GameLogic/Items/PersonalShopCurrency.cpp @@ -2,132 +2,82 @@ #include "GameLogic/Items/PersonalShopCurrency.h" #include -#include #include -#include "Engine/Object/ZzzInfomation.h" -#include "I18N/All.h" - namespace { +using GameLogic::PersonalShop::IsAccountCoin; using GameLogic::PersonalShop::ItemPrice; using GameLogic::PersonalShop::PriceKind; -// Bits of the item in the price item type: the group in the highest 4 bits, -// the number in the remaining 12, as the server encodes it. -constexpr int PriceItemTypeGroupShift = 12; -constexpr uint16_t PriceItemTypeNumberMask = (1u << PriceItemTypeGroupShift) - 1; - -// The server marks prices in account coins with these price item types, in -// the order WCoin (C), WCoin (P), Goblin Points. The amount is in the money field. -constexpr uint16_t FirstAccountCoinPriceItemType = 0xFFF0; -constexpr uint16_t AccountCoinCount = 3; - std::vector AllowedPriceTypes; -bool IsAccountCoin(uint16_t priceItemType) -{ - return priceItemType >= FirstAccountCoinPriceItemType - && priceItemType < FirstAccountCoinPriceItemType + AccountCoinCount; -} - ItemPrice ToPrice(uint16_t priceItemType, int amount) { const PriceKind kind = IsAccountCoin(priceItemType) ? PriceKind::AccountCoin : PriceKind::Item; - return { amount, kind, priceItemType }; -} - -int ToClientItemType(uint16_t priceItemType) -{ - const int group = priceItemType >> PriceItemTypeGroupShift; - const int number = priceItemType & PriceItemTypeNumberMask; - return group * MAX_ITEM_INDEX + number; + return {amount, kind, priceItemType}; } } // namespace namespace GameLogic::PersonalShop { - ItemPrice DecodeExtendedPrice(uint32_t moneyPrice, uint16_t priceItemType, uint16_t requiredItemAmount) +ItemPrice DecodeExtendedPrice(uint32_t moneyPrice, uint16_t priceItemType, uint16_t requiredItemAmount) +{ + if (requiredItemAmount > 0) { - if (requiredItemAmount > 0) - { - return { requiredItemAmount, PriceKind::Item, priceItemType }; - } - - if (IsAccountCoin(priceItemType)) - { - return { static_cast(moneyPrice), PriceKind::AccountCoin, priceItemType }; - } - - return { static_cast(moneyPrice), PriceKind::Zen, 0 }; + return {requiredItemAmount, PriceKind::Item, priceItemType}; } - EncodedPrice EncodeExtendedPrice(const ItemPrice& price) + if (IsAccountCoin(priceItemType)) { - switch (price.Kind) - { - case PriceKind::Item: - return { 0, price.PriceItemType, static_cast(price.Amount) }; - case PriceKind::AccountCoin: - return { static_cast(price.Amount), price.PriceItemType, 0 }; - case PriceKind::Zen: - default: - return { static_cast(price.Amount), 0, 0 }; - } + return {static_cast(moneyPrice), PriceKind::AccountCoin, priceItemType}; } - void SetAllowedPriceTypes(std::span priceItemTypes) - { - AllowedPriceTypes.assign(priceItemTypes.begin(), priceItemTypes.end()); - } + return {static_cast(moneyPrice), PriceKind::Zen, 0}; +} - bool AreOtherPricesThanZenAllowed() +EncodedPrice EncodeExtendedPrice(const ItemPrice& price) +{ + switch (price.Kind) { - return !AllowedPriceTypes.empty(); + case PriceKind::Item: + return {0, price.PriceItemType, static_cast(price.Amount)}; + case PriceKind::AccountCoin: + return {static_cast(price.Amount), price.PriceItemType, 0}; + case PriceKind::Zen: + default: + return {static_cast(price.Amount), 0, 0}; } +} - ItemPrice GetNextAllowedPriceType(const ItemPrice& current) - { - // The order of the choices: zen, then the allowed price types as the server sent them. - auto next = AllowedPriceTypes.begin(); - if (current.Kind != PriceKind::Zen) - { - next = std::find(AllowedPriceTypes.begin(), AllowedPriceTypes.end(), current.PriceItemType); - if (next != AllowedPriceTypes.end()) - { - ++next; - } - } +void SetAllowedPriceTypes(std::span priceItemTypes) +{ + AllowedPriceTypes.assign(priceItemTypes.begin(), priceItemTypes.end()); +} + +bool AreOtherPricesThanZenAllowed() +{ + return !AllowedPriceTypes.empty(); +} - if (next == AllowedPriceTypes.end()) +ItemPrice GetNextAllowedPriceType(const ItemPrice& current) +{ + // The order of the choices: zen, then the allowed price types as the server sent them. + auto next = AllowedPriceTypes.begin(); + if (current.Kind != PriceKind::Zen) + { + next = std::find(AllowedPriceTypes.begin(), AllowedPriceTypes.end(), current.PriceItemType); + if (next != AllowedPriceTypes.end()) { - return { current.Amount, PriceKind::Zen, 0 }; + ++next; } - - return ToPrice(*next, current.Amount); } - const wchar_t* GetCurrencyName(const ItemPrice& price) + if (next == AllowedPriceTypes.end()) { - switch (price.Kind) - { - case PriceKind::Item: - { - const int itemType = ToClientItemType(price.PriceItemType); - return itemType < MAX_ITEM ? ItemAttribute[itemType].Name : I18N::Game::Zen; - } - case PriceKind::AccountCoin: - { - static const std::array CoinNames = { - I18N::Game::WCoinC, - I18N::Game::WCoinP, - I18N::Game::GoblinPoints, - }; - return CoinNames[price.PriceItemType - FirstAccountCoinPriceItemType]; - } - case PriceKind::Zen: - default: - return I18N::Game::Zen; - } + return {current.Amount, PriceKind::Zen, 0}; } + + return ToPrice(*next, current.Amount); } +} // namespace GameLogic::PersonalShop diff --git a/src/source/GameLogic/Items/PersonalShopCurrency.h b/src/source/GameLogic/Items/PersonalShopCurrency.h index f52d542174..b4b7970a18 100644 --- a/src/source/GameLogic/Items/PersonalShopCurrency.h +++ b/src/source/GameLogic/Items/PersonalShopCurrency.h @@ -8,48 +8,67 @@ // the client which ones with the price types message. namespace GameLogic::PersonalShop { - enum class PriceKind : uint8_t - { - Zen, - Item, - AccountCoin, - }; - - /// The price of an item in a personal shop. - struct ItemPrice - { - /// Zen, pieces of the price item or account coins, depending on the kind. - int Amount = 0; - PriceKind Kind = PriceKind::Zen; - /// The price type as the server names it: the item for item prices - /// (group in the highest 4 bits, number in the others), the reserved - /// value of the coin for coin prices, 0 for zen. - uint16_t PriceItemType = 0; - }; - - /// The price fields of the extended protocol, which the shop item list - /// and the set price request share. - struct EncodedPrice - { - uint32_t MoneyPrice = 0; - uint16_t PriceItemType = 0; - uint16_t RequiredItemAmount = 0; - }; - - ItemPrice DecodeExtendedPrice(uint32_t moneyPrice, uint16_t priceItemType, uint16_t requiredItemAmount); - EncodedPrice EncodeExtendedPrice(const ItemPrice& price); - - /// Stores the price types which the server allows in addition to zen. - void SetAllowedPriceTypes(std::span priceItemTypes); - - /// True when the server allows other prices than zen, so the price can be - /// set with the extended packet. - bool AreOtherPricesThanZenAllowed(); - - /// The allowed price type after the one of the given price, wrapping - /// around to zen. The amount of the result is the one of the given price. - ItemPrice GetNextAllowedPriceType(const ItemPrice& current); - - /// The name of the currency of the price, e.g. "Jewel of Bless". - const wchar_t* GetCurrencyName(const ItemPrice& price); +/// Bits of the item in the price item type: the group in the highest 4 +/// bits, the number in the remaining 12, as the server encodes it. +inline constexpr int PriceItemTypeGroupShift = 12; +inline constexpr uint16_t PriceItemTypeNumberMask = (1u << PriceItemTypeGroupShift) - 1; + +/// The server marks prices in account coins with these price item types, +/// in the order WCoin (C), WCoin (P), Goblin Points. The amount is in the +/// money field. +inline constexpr uint16_t FirstAccountCoinPriceItemType = 0xFFF0; +inline constexpr uint16_t AccountCoinCount = 3; + +constexpr bool IsAccountCoin(uint16_t priceItemType) +{ + return priceItemType >= FirstAccountCoinPriceItemType && + priceItemType < FirstAccountCoinPriceItemType + AccountCoinCount; } + +enum class PriceKind : uint8_t +{ + Zen, + Item, + AccountCoin, +}; + +/// The price of an item in a personal shop. +struct ItemPrice +{ + /// Zen, pieces of the price item or account coins, depending on the kind. + int Amount = 0; + PriceKind Kind = PriceKind::Zen; + /// The price type as the server names it: the item for item prices + /// (group in the highest 4 bits, number in the others), the reserved + /// value of the coin for coin prices, 0 for zen. + uint16_t PriceItemType = 0; +}; + +/// The price fields of the extended protocol, which the shop item list +/// and the set price request share. +struct EncodedPrice +{ + uint32_t MoneyPrice = 0; + uint16_t PriceItemType = 0; + uint16_t RequiredItemAmount = 0; +}; + +ItemPrice DecodeExtendedPrice(uint32_t moneyPrice, uint16_t priceItemType, uint16_t requiredItemAmount); +EncodedPrice EncodeExtendedPrice(const ItemPrice& price); + +/// Stores the price types which the server allows in addition to zen. +void SetAllowedPriceTypes(std::span priceItemTypes); + +/// True when the server allows other prices than zen, so the price can be +/// set with the extended packet. +bool AreOtherPricesThanZenAllowed(); + +/// The allowed price type after the one of the given price, wrapping +/// around to zen. The amount of the result is the one of the given price. +ItemPrice GetNextAllowedPriceType(const ItemPrice& current); + +/// The name of the currency of the price, e.g. "Jewel of Bless". +/// Defined in PersonalShopCurrencyName.cpp, as it needs the item and +/// text tables of the game. +const wchar_t* GetCurrencyName(const ItemPrice& price); +} // namespace GameLogic::PersonalShop diff --git a/src/source/GameLogic/Items/PersonalShopCurrencyName.cpp b/src/source/GameLogic/Items/PersonalShopCurrencyName.cpp new file mode 100644 index 0000000000..20e9f3885e --- /dev/null +++ b/src/source/GameLogic/Items/PersonalShopCurrencyName.cpp @@ -0,0 +1,46 @@ +#include "stdafx.h" +#include "GameLogic/Items/PersonalShopCurrency.h" + +#include + +#include "Engine/Object/ZzzInfomation.h" +#include "I18N/All.h" + +// Kept apart from the protocol logic in PersonalShopCurrency.cpp, so that the +// tests of that logic don't need the item and text tables of the game. +namespace +{ +int ToClientItemType(uint16_t priceItemType) +{ + const int group = priceItemType >> GameLogic::PersonalShop::PriceItemTypeGroupShift; + const int number = priceItemType & GameLogic::PersonalShop::PriceItemTypeNumberMask; + return group * MAX_ITEM_INDEX + number; +} +} // namespace + +namespace GameLogic::PersonalShop +{ +const wchar_t* GetCurrencyName(const ItemPrice& price) +{ + switch (price.Kind) + { + case PriceKind::Item: + { + const int itemType = ToClientItemType(price.PriceItemType); + return itemType < MAX_ITEM ? ItemAttribute[itemType].Name : I18N::Game::Zen; + } + case PriceKind::AccountCoin: + { + static const std::array CoinNames = { + I18N::Game::WCoinC, + I18N::Game::WCoinP, + I18N::Game::GoblinPoints, + }; + return CoinNames[price.PriceItemType - FirstAccountCoinPriceItemType]; + } + case PriceKind::Zen: + default: + return I18N::Game::Zen; + } +} +} // namespace GameLogic::PersonalShop diff --git a/src/source/GameLogic/Items/PersonalShopTitleImp.h b/src/source/GameLogic/Items/PersonalShopTitleImp.h index d70517e0ec..38bdbb05cd 100644 --- a/src/source/GameLogic/Items/PersonalShopTitleImp.h +++ b/src/source/GameLogic/Items/PersonalShopTitleImp.h @@ -331,7 +331,7 @@ inline void AddPersonalItemPrice(int index, const GameLogic::PersonalShop::ItemP /// Adds a price in zen. inline void AddPersonalItemPrice(int index, int price, int type) { - AddPersonalItemPrice(index, GameLogic::PersonalShop::ItemPrice{ price }, type); + AddPersonalItemPrice(index, GameLogic::PersonalShop::ItemPrice{price}, type); } inline void RemovePersonalItemPrice(int index, int type) diff --git a/src/source/GameLogic/Pets/GIPetManager.cpp b/src/source/GameLogic/Pets/GIPetManager.cpp index 3a58ca144d..0b107b7771 100644 --- a/src/source/GameLogic/Pets/GIPetManager.cpp +++ b/src/source/GameLogic/Pets/GIPetManager.cpp @@ -652,7 +652,8 @@ static std::uint8_t g_tabBar = 0; if (hasPrice && itemPrice.Kind != GameLogic::PersonalShop::PriceKind::Zen) { // The server tells the player when the jewels or coins are not enough. - std::swprintf(textBuffer, kTooltipBufferCapacity, L"%d %ls", itemPrice.Amount, GameLogic::PersonalShop::GetCurrencyName(itemPrice)); + std::swprintf(textBuffer, kTooltipBufferCapacity, L"%d %ls", itemPrice.Amount, + GameLogic::PersonalShop::GetCurrencyName(itemPrice)); appendLine(TEXT_COLOR_YELLOW, true, false, priceFormat.c_str(), textBuffer); appendEmptyLine(); } diff --git a/src/source/Network/Server/WSclient.cpp b/src/source/Network/Server/WSclient.cpp index 214c4eeb2e..1cd370a422 100644 --- a/src/source/Network/Server/WSclient.cpp +++ b/src/source/Network/Server/WSclient.cpp @@ -9395,8 +9395,8 @@ namespace { GameLogic::PersonalShop::ItemPrice DecodeShopItemPrice(const GETPSHOPITEM_DATAINFO& shopItem) { - return GameLogic::PersonalShop::DecodeExtendedPrice( - static_cast(shopItem.MoneyPrice), shopItem.PriceItemType, shopItem.RequiredItemAmount); + return GameLogic::PersonalShop::DecodeExtendedPrice(static_cast(shopItem.MoneyPrice), + shopItem.PriceItemType, shopItem.RequiredItemAmount); } } // namespace @@ -9414,7 +9414,8 @@ void ReceivePersonalShopPriceTypes(std::span ReceiveBuffer) std::vector priceItemTypes(count); for (size_t i = 0; i < count; ++i) { - priceItemTypes[i] = static_cast(priceTypesData[i * sizeof(WORD)] | (priceTypesData[i * sizeof(WORD) + 1] << 8)); + priceItemTypes[i] = + static_cast(priceTypesData[i * sizeof(WORD)] | (priceTypesData[i * sizeof(WORD) + 1] << 8)); } GameLogic::PersonalShop::SetAllowedPriceTypes(priceItemTypes); diff --git a/src/source/Network/Server/WSclient.h b/src/source/Network/Server/WSclient.h index a1ee1a9d89..977d4f362f 100644 --- a/src/source/Network/Server/WSclient.h +++ b/src/source/Network/Server/WSclient.h @@ -1728,19 +1728,20 @@ typedef struct tagGETPSHOPITEMLIST_HEADER { #pragma pack(push, 1) // just to get the actual length by sizeof... typedef struct tagGETPSHOPITEM_DATA { INT MoneyPrice; - WORD PriceItemType; - WORD RequiredItemAmount; + WORD PriceItemType; + WORD RequiredItemAmount; BYTE ItemSlot; BYTE Item[PACKET_ITEM_LENGTH_EXTENDED_MIN]; } GETPSHOPITEM_DATAINFO, * LPGETPSHOPITEM_DATAINFO; // The price types which the server allows for prices in personal shops, in // addition to zen (C1 3F 0A). The price item types (WORD) follow it. -typedef struct tagPSHOP_PRICETYPES_HEADER { - PBMSG_HEADER Header; - BYTE bySubcode; - BYTE Count; -} PSHOP_PRICETYPES_HEADERINFO, * LPPSHOP_PRICETYPES_HEADERINFO; +typedef struct tagPSHOP_PRICETYPES_HEADER +{ + PBMSG_HEADER Header; + BYTE bySubcode; + BYTE Count; +} PSHOP_PRICETYPES_HEADERINFO, *LPPSHOP_PRICETYPES_HEADERINFO; #pragma pack(pop) typedef struct tagCLOSEPSHOP_RESULT { diff --git a/src/source/UI/Legacy/UIGateKeeper.cpp b/src/source/UI/Legacy/UIGateKeeper.cpp index eaba70e93c..df4f31f9db 100644 --- a/src/source/UI/Legacy/UIGateKeeper.cpp +++ b/src/source/UI/Legacy/UIGateKeeper.cpp @@ -27,7 +27,8 @@ void CUIGateKeeper::SendPublicSetting() void CUIGateKeeper::SendEnteranceFee() { - SocketClient->ToGameServer()->SendCastleSiegeTaxChangeRequest(CastleSiegeTaxType::HuntingZoneEntranceFee, m_iViewEntranceFee); + SocketClient->ToGameServer()->SendCastleSiegeTaxChangeRequest(CastleSiegeTaxType::HuntingZoneEntranceFee, + m_iViewEntranceFee); } void CUIGateKeeper::EnteranceFeeUp() diff --git a/src/source/UI/NewUI/Combat/NewUISiegeWarCommander.cpp b/src/source/UI/NewUI/Combat/NewUISiegeWarCommander.cpp index ce6102e0f8..e6ecbe1f10 100644 --- a/src/source/UI/NewUI/Combat/NewUISiegeWarCommander.cpp +++ b/src/source/UI/NewUI/Combat/NewUISiegeWarCommander.cpp @@ -88,8 +88,9 @@ bool SEASON3B::CNewUISiegeWarCommander::OnUpdateMouseEvent() SelectCmd.byY = 256 - (MouseY + m_MiniMapScaleOffset.y - m_MiniMapPos.y) * m_iMiniMapScale; SelectCmd.byLifeTime = 100; - SocketClient->ToGameServer()->SendCastleGuildCommand(SelectCmd.byTeam, SelectCmd.byX, SelectCmd.byY, - static_cast(SelectCmd.byCmd)); + SocketClient->ToGameServer()->SendCastleGuildCommand( + SelectCmd.byTeam, SelectCmd.byX, SelectCmd.byY, + static_cast(SelectCmd.byCmd)); m_iCurSelectBtnCommand = -1; diff --git a/src/source/UI/NewUI/Dialogs/NewUICustomMessageBox.cpp b/src/source/UI/NewUI/Dialogs/NewUICustomMessageBox.cpp index 308e1fb60f..1c3b538790 100644 --- a/src/source/UI/NewUI/Dialogs/NewUICustomMessageBox.cpp +++ b/src/source/UI/NewUI/Dialogs/NewUICustomMessageBox.cpp @@ -4376,9 +4376,8 @@ namespace bool IsZenPriceBelowItemValue(int price) { SEASON3B::CNewUIPickedItem* pPickedItem = SEASON3B::CNewUIInventoryCtrl::GetPickedItem(); - ITEM* pItem = pPickedItem - ? pPickedItem->GetItem() - : g_pMyShopInventory->FindItem(g_pMyShopInventory->GetSourceIndex()); + ITEM* pItem = + pPickedItem ? pPickedItem->GetItem() : g_pMyShopInventory->FindItem(g_pMyShopInventory->GetSourceIndex()); if (pItem == nullptr) { return false; @@ -4396,7 +4395,7 @@ void ShowZenPriceConfirmation(const wchar_t* strPrice, int price) return; } - wchar_t strText[MAX_TEXT_LENGTH] = { 0, }; + wchar_t strText[MAX_TEXT_LENGTH] = {}; mu_swprintf(strText, I18N::Game::SellingPriceSZen, strPrice); lpMsgBox->AddMsg(strText, RGBA(255, 0, 0, 255), SEASON3B::MSGBOX_FONT_BOLD); lpMsgBox->AddMsg(I18N::Game::DoYouWantToSellItemAtThisPrice); @@ -4438,7 +4437,8 @@ void ApplyPersonalShopItemPrice(const GameLogic::PersonalShop::ItemPrice& price) } } // namespace -CALLBACK_RESULT SEASON3B::CPersonalShopItemValueMsgBoxLayout::ProcessOk(class CNewUIMessageBoxBase* pOwner, const leaf::xstreambuf& xParam) +CALLBACK_RESULT SEASON3B::CPersonalShopItemValueMsgBoxLayout::ProcessOk(class CNewUIMessageBoxBase* pOwner, + const leaf::xstreambuf& xParam) { auto* pMsgBox = dynamic_cast(pOwner); if (pMsgBox == nullptr) @@ -4446,7 +4446,7 @@ CALLBACK_RESULT SEASON3B::CPersonalShopItemValueMsgBoxLayout::ProcessOk(class CN return CALLBACK_CONTINUE; } - wchar_t strText[MAX_TEXT_LENGTH] = { 0, }; + wchar_t strText[MAX_TEXT_LENGTH] = {}; pMsgBox->GetInputBoxText(strText); if (wcslen(strText) == 0) { diff --git a/src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.cpp b/src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.cpp index 4b7ac4730f..ea07e4f221 100644 --- a/src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.cpp +++ b/src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.cpp @@ -27,8 +27,8 @@ void SEASON3B::CPersonalShopPriceMsgBox::AddCurrencySelection() const float x = GetPos().x + (MSGBOX_WIDTH / 2) - (MSGBOX_BTN_EMPTY_WIDTH / 2); const float y = GetPos().y + MSGBOX_TEXT_TOP_BLANK + MSGBOX_MIDDLE_HEIGHT; - m_BtnCurrency.SetInfo(CNewUIMessageBoxMng::IMAGE_MSGBOX_BTN_EMPTY, x, y, - MSGBOX_BTN_EMPTY_WIDTH, MSGBOX_BTN_EMPTY_HEIGHT, CNewUIMessageBoxButton::MSGBOX_BTN_SIZE_EMPTY); + m_BtnCurrency.SetInfo(CNewUIMessageBoxMng::IMAGE_MSGBOX_BTN_EMPTY, x, y, MSGBOX_BTN_EMPTY_WIDTH, + MSGBOX_BTN_EMPTY_HEIGHT, CNewUIMessageBoxButton::MSGBOX_BTN_SIZE_EMPTY); m_BtnCurrency.SetText(GameLogic::PersonalShop::GetCurrencyName(m_priceType)); // Replaces the callback of the base class, which is called for the other buttons. @@ -59,7 +59,8 @@ bool SEASON3B::CPersonalShopPriceMsgBox::Render() return true; } -CALLBACK_RESULT SEASON3B::CPersonalShopPriceMsgBox::LButtonUp(class CNewUIMessageBoxBase* pOwner, const leaf::xstreambuf& xParam) +SEASON3B::CALLBACK_RESULT SEASON3B::CPersonalShopPriceMsgBox::LButtonUp(class CNewUIMessageBoxBase* pOwner, + const leaf::xstreambuf& xParam) { auto* pMsgBox = dynamic_cast(pOwner); if (pMsgBox && pMsgBox->m_isCurrencySelectable && pMsgBox->m_BtnCurrency.IsMouseIn()) @@ -80,16 +81,16 @@ void SEASON3B::CPersonalShopPriceMsgBox::SelectNextCurrency() namespace UI::PersonalShop { - void SendItemPrice(int itemSlot, const GameLogic::PersonalShop::ItemPrice& price) +void SendItemPrice(int itemSlot, const GameLogic::PersonalShop::ItemPrice& price) +{ + if (price.Kind == GameLogic::PersonalShop::PriceKind::Zen) { - if (price.Kind == GameLogic::PersonalShop::PriceKind::Zen) - { - SocketClient->ToGameServer()->SendPlayerShopSetItemPrice(itemSlot, price.Amount); - return; - } - - const auto encoded = GameLogic::PersonalShop::EncodeExtendedPrice(price); - SocketClient->ToGameServer()->SendPlayerShopSetItemPriceExtended( - encoded.MoneyPrice, encoded.PriceItemType, encoded.RequiredItemAmount, itemSlot); + SocketClient->ToGameServer()->SendPlayerShopSetItemPrice(itemSlot, price.Amount); + return; } + + const auto encoded = GameLogic::PersonalShop::EncodeExtendedPrice(price); + SocketClient->ToGameServer()->SendPlayerShopSetItemPriceExtended(encoded.MoneyPrice, encoded.PriceItemType, + encoded.RequiredItemAmount, itemSlot); } +} // namespace UI::PersonalShop diff --git a/src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.h b/src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.h index fd0a259048..e3ac48398e 100644 --- a/src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.h +++ b/src/source/UI/NewUI/Dialogs/PersonalShopPriceMsgBox.h @@ -5,36 +5,39 @@ namespace SEASON3B { - /// The input box for the price of an item of the own personal shop. - /// When the server allows other prices than zen, it offers a button - /// which switches between the allowed currencies. - class CPersonalShopPriceMsgBox : public CNewUITextInputMsgBox - { - public: - /// Adds the currency button below the messages, if the server allows - /// other prices than zen. Call it after the messages were added. - void AddCurrencySelection(); +/// The input box for the price of an item of the own personal shop. +/// When the server allows other prices than zen, it offers a button +/// which switches between the allowed currencies. +class CPersonalShopPriceMsgBox : public CNewUITextInputMsgBox +{ +public: + /// Adds the currency button below the messages, if the server allows + /// other prices than zen. Call it after the messages were added. + void AddCurrencySelection(); - /// The selected currency, as a price without amount. - const GameLogic::PersonalShop::ItemPrice& GetPriceType() const { return m_priceType; } + /// The selected currency, as a price without amount. + const GameLogic::PersonalShop::ItemPrice& GetPriceType() const + { + return m_priceType; + } - bool Update() override; - bool Render() override; + bool Update() override; + bool Render() override; - static CALLBACK_RESULT LButtonUp(class CNewUIMessageBoxBase* pOwner, const leaf::xstreambuf& xParam); + static CALLBACK_RESULT LButtonUp(class CNewUIMessageBoxBase* pOwner, const leaf::xstreambuf& xParam); - private: - void SelectNextCurrency(); +private: + void SelectNextCurrency(); - GameLogic::PersonalShop::ItemPrice m_priceType; - bool m_isCurrencySelectable = false; - CNewUIMessageBoxButton m_BtnCurrency; - }; -} + GameLogic::PersonalShop::ItemPrice m_priceType; + bool m_isCurrencySelectable = false; + CNewUIMessageBoxButton m_BtnCurrency; +}; +} // namespace SEASON3B namespace UI::PersonalShop { - /// Sends the price of an item of the own personal shop. Zen prices use the - /// original packet, so they work with every server. - void SendItemPrice(int itemSlot, const GameLogic::PersonalShop::ItemPrice& price); -} +/// Sends the price of an item of the own personal shop. Zen prices use the +/// original packet, so they work with every server. +void SendItemPrice(int itemSlot, const GameLogic::PersonalShop::ItemPrice& price); +} // namespace UI::PersonalShop From a5ff8f7204c3a7aa5e32705446b0d700ca7729b7 Mon Sep 17 00:00:00 2001 From: Sven Nicolai Date: Wed, 14 Oct 2026 10:32:03 +0000 Subject: [PATCH 4/4] feat(personal-shop): currency line and currency menu in the price dialog A long currency name didn't fit the dialog's secondary button. The dialog now names the currency in its own line ("Currency: Jewel of Bless"), and its "Currency" button opens a menu of the allowed currencies instead of switching to the next one: - the common ones first: zen, the account coins and the jewels (Bless, Soul, Chaos, Life, Creation, Guardian, Harmony); - the other items the server allows behind an "Other items" button, in the order of the server. A long menu takes two columns, so it stays on the screen. Choosing a currency or closing the menu returns to the price dialog and keeps the typed amount. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y7XufeAZ22z7ocXAXUqUsE --- docs/personal-shop.md | 20 ++++- src/Localization/Game.de.resx | 9 +++ src/Localization/Game.en.resx | 9 +++ .../GameLogic/Items/PersonalShopCurrency.cpp | 51 ++++++++---- .../GameLogic/Items/PersonalShopCurrency.h | 12 ++- src/source/UI/Inventory/MyShopInventory.cpp | 80 ++++++++++++++++--- .../gamelogic/test_personal_shop_currency.cpp | 43 +++++++--- 7 files changed, 181 insertions(+), 43 deletions(-) diff --git a/docs/personal-shop.md b/docs/personal-shop.md index a5834bfaa5..e6d1ae947f 100644 --- a/docs/personal-shop.md +++ b/docs/personal-shop.md @@ -16,10 +16,22 @@ the original client. ## Setting a price Right-click an item in the own shop (or drop one into it) to enter its price. -When the server allows other prices, the price dialog shows a button with the -currency next to OK; each click on it switches to the next allowed currency, in -the order the server sent them, after zen, and keeps the typed amount. Zen prices below the -item's value still ask for a confirmation; the other currencies don't. +When the server allows other prices, the price dialog names the currency of the +price in its own line ("Currency: Jewel of Bless"), and its "Currency" button +opens a menu of the allowed currencies: + +- the common ones first: zen, the account coins, and the jewels (Bless, Soul, + Chaos, Life, Creation, Guardian, Harmony); +- the other items the server allows are listed in a second menu, behind an + "Other items" button, in the order the server sent them. + +A long menu takes two columns. Choosing a currency, or closing the menu, returns +to the price dialog and keeps the typed amount. Zen prices below the item's value +still ask for a confirmation; the other currencies don't. + +Which items can be prices is the server's choice: OpenMU offers the five jewels +Bless, Soul, Chaos, Creation and Life by default, and its administrators can add +any other item. The server checks every price: a price item or coin that isn't allowed, or an amount above the server's maximum, is refused with a message. diff --git a/src/Localization/Game.de.resx b/src/Localization/Game.de.resx index ec1f478f58..ba32c8e86e 100644 --- a/src/Localization/Game.de.resx +++ b/src/Localization/Game.de.resx @@ -13120,4 +13120,13 @@ Goblin-Punkte + + Währung + + + Währung: %ls + + + Andere Gegenstände + diff --git a/src/Localization/Game.en.resx b/src/Localization/Game.en.resx index 019fb053e1..e0582d5a53 100644 --- a/src/Localization/Game.en.resx +++ b/src/Localization/Game.en.resx @@ -13183,4 +13183,13 @@ Goblin Points + + Currency + + + Currency: %ls + + + Other items + diff --git a/src/source/GameLogic/Items/PersonalShopCurrency.cpp b/src/source/GameLogic/Items/PersonalShopCurrency.cpp index 84d69d5f7e..37dfcdf51a 100644 --- a/src/source/GameLogic/Items/PersonalShopCurrency.cpp +++ b/src/source/GameLogic/Items/PersonalShopCurrency.cpp @@ -2,6 +2,7 @@ #include "GameLogic/Items/PersonalShopCurrency.h" #include +#include #include namespace @@ -12,6 +13,27 @@ using GameLogic::PersonalShop::PriceKind; std::vector AllowedPriceTypes; +// The jewels which are commonly used as prices, in the order of their usual value: Bless, Soul, +// Chaos, Life, Creation, Guardian and Harmony (group in the highest 4 bits, number in the others). +constexpr std::array CommonPriceItemTypes = {0xE00D, 0xE00E, 0xC00F, 0xE010, 0xE016, 0xE01F, 0xE02A}; + +// The position of a price item type in the menu: account coins, then common jewels, then the rest. +size_t MenuRank(uint16_t priceItemType) +{ + if (IsAccountCoin(priceItemType)) + { + return priceItemType - GameLogic::PersonalShop::FirstAccountCoinPriceItemType; + } + + const auto common = std::find(CommonPriceItemTypes.begin(), CommonPriceItemTypes.end(), priceItemType); + if (common != CommonPriceItemTypes.end()) + { + return GameLogic::PersonalShop::AccountCoinCount + (common - CommonPriceItemTypes.begin()); + } + + return GameLogic::PersonalShop::AccountCoinCount + CommonPriceItemTypes.size(); +} + ItemPrice ToPrice(uint16_t priceItemType, int amount) { const PriceKind kind = IsAccountCoin(priceItemType) ? PriceKind::AccountCoin : PriceKind::Item; @@ -60,24 +82,25 @@ bool AreOtherPricesThanZenAllowed() return !AllowedPriceTypes.empty(); } -ItemPrice GetNextAllowedPriceType(const ItemPrice& current) +bool IsCommonPriceType(const ItemPrice& price) { - // The order of the choices: zen, then the allowed price types as the server sent them. - auto next = AllowedPriceTypes.begin(); - if (current.Kind != PriceKind::Zen) - { - next = std::find(AllowedPriceTypes.begin(), AllowedPriceTypes.end(), current.PriceItemType); - if (next != AllowedPriceTypes.end()) - { - ++next; - } - } + return price.Kind != PriceKind::Item || + MenuRank(price.PriceItemType) < AccountCoinCount + CommonPriceItemTypes.size(); +} + +std::vector GetAllowedPriceTypes() +{ + std::vector priceItemTypes = AllowedPriceTypes; + // Stable, so the items which aren't common keep the order of the server. + std::stable_sort(priceItemTypes.begin(), priceItemTypes.end(), + [](uint16_t a, uint16_t b) { return MenuRank(a) < MenuRank(b); }); - if (next == AllowedPriceTypes.end()) + std::vector result{ItemPrice{}}; + for (const uint16_t priceItemType : priceItemTypes) { - return {current.Amount, PriceKind::Zen, 0}; + result.push_back(ToPrice(priceItemType, 0)); } - return ToPrice(*next, current.Amount); + return result; } } // namespace GameLogic::PersonalShop diff --git a/src/source/GameLogic/Items/PersonalShopCurrency.h b/src/source/GameLogic/Items/PersonalShopCurrency.h index b4b7970a18..170e9f3d4f 100644 --- a/src/source/GameLogic/Items/PersonalShopCurrency.h +++ b/src/source/GameLogic/Items/PersonalShopCurrency.h @@ -2,6 +2,7 @@ #include #include +#include // The prices of items in personal shops. Zen always works; prices in items // (e.g. jewels) and account coins only when the server allows them and told @@ -63,9 +64,14 @@ void SetAllowedPriceTypes(std::span priceItemTypes); /// set with the extended packet. bool AreOtherPricesThanZenAllowed(); -/// The allowed price type after the one of the given price, wrapping -/// around to zen. The amount of the result is the one of the given price. -ItemPrice GetNextAllowedPriceType(const ItemPrice& current); +/// True for the currencies which are commonly used as prices: zen, the +/// account coins and the jewels. +bool IsCommonPriceType(const ItemPrice& price); + +/// The price types the player can choose from, without amount: the common +/// ones first (zen, the account coins, the jewels), then the other allowed +/// items in the order the server sent them. +std::vector GetAllowedPriceTypes(); /// The name of the currency of the price, e.g. "Jewel of Bless". /// Defined in PersonalShopCurrencyName.cpp, as it needs the item and diff --git a/src/source/UI/Inventory/MyShopInventory.cpp b/src/source/UI/Inventory/MyShopInventory.cpp index d356a43fad..0b62f26fe6 100644 --- a/src/source/UI/Inventory/MyShopInventory.cpp +++ b/src/source/UI/Inventory/MyShopInventory.cpp @@ -6,6 +6,7 @@ #include "UI/RmlBridge/RmlPointer.h" #include "UI/RmlBridge/RmlWindowClose.h" #include "UI/Dialogs/GenericConfirmDialog.h" +#include "UI/Dialogs/GenericMenuDialog.h" #include "UI/Core/WindowCommon.h" // g_IsPurchaseShop #include "GameLogic/Items/PersonalShopCurrency.h" #include "GameLogic/Items/PersonalShopTitleImp.h" @@ -19,6 +20,8 @@ #include "UI/RmlBridge/RmlNativeTextSize.h" #include "Core/Utilities/StringUtils.h" #include +#include +#include const int iMAX_SHOPTITLE_MULTI = 26; @@ -120,9 +123,69 @@ void ShowZenPriceConfirmation(const ITEM& item, const wchar_t* strPrice, g_pGenericConfirmDialog->Show(std::move(cfg)); } -// The price dialog with the given currency. When the server allows other prices than zen, its -// secondary button names the currency and shows the dialog again with the next allowed one, -// keeping the typed amount. +void ShowItemPriceDialog(const GameLogic::PersonalShop::ItemPrice& priceType, const std::wstring& initialText); + +// A menu of currencies to choose the one of the price from. Choosing one, or closing the menu, +// shows the price dialog again with the typed amount. A long list takes two columns, so the +// menu stays on the screen. +void ShowPriceTypeMenu(const std::wstring& title, const std::vector& priceTypes, + std::optional> otherItems, + const GameLogic::PersonalShop::ItemPrice& current, const std::wstring& typedText) +{ + constexpr size_t MaximumSingleColumnButtons = 6; + + GenericMenuConfig cfg; + cfg.title = title; + for (const auto& priceType : priceTypes) + { + GenericMenuConfig::MenuButton button; + button.label = GameLogic::PersonalShop::GetCurrencyName(priceType); + button.onClick = [priceType, typedText] { ShowItemPriceDialog(priceType, typedText); }; + cfg.buttons.push_back(std::move(button)); + } + + if (otherItems) + { + GenericMenuConfig::MenuButton button; + button.label = I18N::Game::OtherItems; + button.onClick = [items = *otherItems, current, typedText] + { ShowPriceTypeMenu(I18N::Game::OtherItems, items, std::nullopt, current, typedText); }; + cfg.buttons.push_back(std::move(button)); + } + + if (cfg.buttons.size() > MaximumSingleColumnButtons) + { + cfg.columns = 2; + } + + GenericMenuConfig::MenuButton cancel; + cancel.label = I18N::Game::Cancel; + cancel.compact = true; + cancel.dismiss = true; + cancel.onClick = [current, typedText] { ShowItemPriceDialog(current, typedText); }; + cfg.buttons.push_back(std::move(cancel)); + cfg.onCancel = [current, typedText] { ShowItemPriceDialog(current, typedText); }; + + g_pGenericMenuDialog->Show(std::move(cfg)); +} + +// The currencies the server allows: the common ones (zen, the account coins, the jewels), and +// the other items in a second menu behind an "Other items" button. +void ShowPriceTypeMenu(const GameLogic::PersonalShop::ItemPrice& current, const std::wstring& typedText) +{ + std::vector common; + std::vector others; + for (const auto& priceType : GameLogic::PersonalShop::GetAllowedPriceTypes()) + { + (GameLogic::PersonalShop::IsCommonPriceType(priceType) ? common : others).push_back(priceType); + } + + ShowPriceTypeMenu(I18N::Game::Currency, common, others.empty() ? std::nullopt : std::optional(std::move(others)), + current, typedText); +} + +// The price dialog with the given currency. When the server allows other prices than zen, it +// names the currency in its own line, and its secondary button opens the menu of the currencies. void ShowItemPriceDialog(const GameLogic::PersonalShop::ItemPrice& priceType, const std::wstring& initialText) { GenericDialogConfig cfg; @@ -136,12 +199,11 @@ void ShowItemPriceDialog(const GameLogic::PersonalShop::ItemPrice& priceType, co if (GameLogic::PersonalShop::AreOtherPricesThanZenAllowed()) { - cfg.secondaryLabel = GameLogic::PersonalShop::GetCurrencyName(priceType); - cfg.onSecondary = [priceType] - { - ShowItemPriceDialog(GameLogic::PersonalShop::GetNextAllowedPriceType(priceType), - g_pGenericConfirmDialog->GetInputText()); - }; + wchar_t currencyLine[MAX_TEXT_LENGTH] = {}; + mu_swprintf(currencyLine, I18N::Game::CurrencyLs, GameLogic::PersonalShop::GetCurrencyName(priceType)); + cfg.lines.push_back({currencyLine, true, RGBA(255, 210, 60, 255)}); + cfg.secondaryLabel = I18N::Game::Currency; + cfg.onSecondary = [priceType] { ShowPriceTypeMenu(priceType, g_pGenericConfirmDialog->GetInputText()); }; } cfg.onPrimary = [priceType] diff --git a/tests/gamelogic/test_personal_shop_currency.cpp b/tests/gamelogic/test_personal_shop_currency.cpp index 3ab156813f..a5277cc37c 100644 --- a/tests/gamelogic/test_personal_shop_currency.cpp +++ b/tests/gamelogic/test_personal_shop_currency.cpp @@ -12,16 +12,22 @@ using GameLogic::PersonalShop::DecodeExtendedPrice; using GameLogic::PersonalShop::EncodeExtendedPrice; -using GameLogic::PersonalShop::GetNextAllowedPriceType; +using GameLogic::PersonalShop::GetAllowedPriceTypes; +using GameLogic::PersonalShop::IsCommonPriceType; using GameLogic::PersonalShop::ItemPrice; using GameLogic::PersonalShop::PriceKind; using GameLogic::PersonalShop::SetAllowedPriceTypes; namespace { -// Jewel of Bless: group 14, number 13; Jewel of Soul: group 14, number 14. +// Jewel of Bless: group 14, number 13; Jewel of Soul: group 14, number 14; Jewel of Chaos: group 12, +// number 15; Loch's Feather: group 13, number 14; Gemstone: group 14, number 41. constexpr uint16_t JewelOfBless = 0xE00D; constexpr uint16_t JewelOfSoul = 0xE00E; +constexpr uint16_t JewelOfChaos = 0xC00F; +constexpr uint16_t LochsFeather = 0xD00E; +constexpr uint16_t Gemstone = 0xE029; +constexpr uint16_t WCoinC = 0xFFF0; constexpr uint16_t GoblinPoints = 0xFFF2; } // namespace @@ -70,23 +76,34 @@ TEST_CASE("the price input offers only zen until the server allows other prices" { SetAllowedPriceTypes({}); CHECK_FALSE(GameLogic::PersonalShop::AreOtherPricesThanZenAllowed()); - CHECK(GetNextAllowedPriceType({}).Kind == PriceKind::Zen); + const auto choices = GetAllowedPriceTypes(); + REQUIRE(choices.size() == 1); + CHECK(choices[0].Kind == PriceKind::Zen); } -TEST_CASE("the price input cycles through zen and the allowed price types") +TEST_CASE("the price input offers zen, the coins and the jewels before the other items") { - const std::array allowed = { JewelOfSoul, GoblinPoints }; + // As a server could send them: items of its configuration first, then the coins. + const std::array allowed = { Gemstone, JewelOfChaos, LochsFeather, JewelOfBless, GoblinPoints, + JewelOfSoul, WCoinC }; SetAllowedPriceTypes(allowed); CHECK(GameLogic::PersonalShop::AreOtherPricesThanZenAllowed()); - const auto soul = GetNextAllowedPriceType({}); - CHECK(soul.Kind == PriceKind::Item); - CHECK(soul.PriceItemType == JewelOfSoul); - - const auto goblinPoints = GetNextAllowedPriceType(soul); - CHECK(goblinPoints.Kind == PriceKind::AccountCoin); - CHECK(goblinPoints.PriceItemType == GoblinPoints); + const auto choices = GetAllowedPriceTypes(); + const std::array expected = { 0, WCoinC, GoblinPoints, JewelOfBless, JewelOfSoul, JewelOfChaos, + Gemstone, LochsFeather }; + REQUIRE(choices.size() == expected.size()); + for (size_t i = 0; i < expected.size(); ++i) + { + CHECK(choices[i].PriceItemType == expected[i]); + CHECK(choices[i].Amount == 0); + } - CHECK(GetNextAllowedPriceType(goblinPoints).Kind == PriceKind::Zen); + CHECK(choices[0].Kind == PriceKind::Zen); + CHECK(choices[1].Kind == PriceKind::AccountCoin); + CHECK(choices[3].Kind == PriceKind::Item); + CHECK(IsCommonPriceType(choices[5])); + CHECK_FALSE(IsCommonPriceType(choices[6])); + CHECK_FALSE(IsCommonPriceType(choices[7])); SetAllowedPriceTypes({}); }