using Ananta.SDK.Rpc; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Protocol.Client4229938; using Auto = Ananta.Server.RpcTypes.Client4229938.Auto; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// Trading post for build 4229938 (TradeManager.AskTradeGetMarketList -> 63492557 and /// TradeManager.AskTradeBuyItem -> 63188959). /// /// Retail fills the market from other players' sell orders; this server has none, so every row that /// carries a price is answered with one synthesised bucket at the row's floor price and a fixed depth. /// That is all the purchase window needs to open up: its CanBuy() requires marketCount > 0, maxCount > 0 /// and price > 0, and the quantity slider's maximum is the summed bucket depth. A single bucket also /// keeps the total the client shows (price x count, via its own auto-buy walk) equal to what the wallet /// is charged here. /// /// The market settles in 寰宇币 (ConsumableConfig.RewardGold / MoneyType.Gold) and the buy reply is a /// bare uint32 "filled count" that the client turns into the reward popup. /// internal sealed partial class GameRouter { // TradeItemConfig.ItemType: 0 box/consumable, 1 wardrobe piece, 2 fashion suit (order book only), // 3 household collectible (painting/statue/valuable). private const uint TradeItemTypeBox4229938 = 0; private const uint TradeItemTypeFashion4229938 = 1; private const uint TradeItemTypeOrder4229938 = 2; private const uint TradeItemTypeCollectible4229938 = 3; private const uint TradeCurrency4229938 = CurrencyRewardGold4229938; /// Depth of the synthesised bucket; also the quantity slider's maximum in the window. private const uint TradeBucketDepth4229938 = 99; [Handler(MethodId.AskTradeGetMarketList, HandlerPacketKind.Invoke)] private async Task AskTradeGetMarketList(Connection conn, UxRpcMessage msg) { var tradeItemId = ReadUInt32At4229938(msg.Body, 0); var reply = new GameMethods.TradeMarketList4229938(); if (EconomyConfigRepository.TryTradeItem(tradeItemId, out var item) && item.MarketPrice > 0 && item.ItemType != TradeItemTypeOrder4229938) { reply.buckets.Add(new GameMethods.TradeBucketInfo4229938 { BucketId = item.MarketPrice, OrderCount = 1, Count = TradeBucketDepth4229938, MinPrice = item.MarketPrice, MaxPrice = item.MarketPrice, }); conn.Log.Info( $"[TRADE] market list row={item.Id} tab={item.Tab} type={item.ItemType} " + $"price={item.MarketPrice} depth={TradeBucketDepth4229938}"); } else { // Empty 7-bit list: the window stays on "no listing" instead of failing to parse. conn.Log.Warn($"[TRADE] market list has no bucket for row {tradeItemId} (body={Convert.ToHexString(msg.Body)})"); } await conn.ReturnAsync(msg, reply); } [Handler(MethodId.AskTradeBuyItem, HandlerPacketKind.Invoke)] private async Task AskTradeBuyItem(Connection conn, UxRpcMessage msg) { var state = GetWorldState(msg.Context); var tradeItemId = ReadUInt32At4229938(msg.Body, 0); var maxPrice = ReadUInt32At4229938(msg.Body, 4); var requested = ReadUInt32At4229938(msg.Body, 8); var count = Math.Clamp(requested == 0 ? 1u : requested, 1u, TradeBucketDepth4229938); if (!EconomyConfigRepository.TryTradeItem(tradeItemId, out var item) || item.MarketPrice == 0 || item.ItemType == TradeItemTypeOrder4229938) { conn.Log.Warn($"[TRADE] buy rejected: row {tradeItemId} is not a market row (body={Convert.ToHexString(msg.Body)})"); await conn.ReturnAsync(msg, 0u); return; } var fashions = new List(); var changes = new EconomyChanges4229938(); var granted = GrantTradePurchase4229938(conn, changes, fashions, item, count); if (granted == 0) { await conn.ReturnAsync(msg, 0u); return; } var unitPrice = maxPrice > 0 ? maxPrice : item.MarketPrice; ChargeCurrency4229938( state, changes, TradeCurrency4229938, (double)unitPrice * count, conn.Log, $"trade {item.Id}"); var summary = await ApplyEconomy4229938Async(conn, state, changes, $"trade {item.Id}"); // Wardrobe pieces are ownership, not a stack, so they arrive as a fashion unlock push. foreach (var fashionId in fashions) { await conn.NotifyAsync(MethodId.IGameToClient_SyncAddOrUpdateFashion, new Auto.FashionInfo { FashionId = fashionId, ExpiredTime = 0, GainTime = 1, Status = 0, ApplyColoringSchemeId = 0, UnlockColoringSlotCount = 0, ColoringSchemeTopNMaxCollectionScore = 0, }); } conn.Log.Info( $"[TRADE] buy row={item.Id} tab={item.Tab} type={item.ItemType} count={count} " + $"unitPrice={unitPrice} granted={granted} unlockedFashions={fashions.Count} " + $"currency={TradeCurrency4229938} -> {summary}"); await conn.ReturnAsync(msg, granted); } /// /// Queues what the row sells and returns how many units the client should show as bought. /// Item rows land in the backpack delta, fashion rows in the unlock list, and rows with nothing to /// hand out (the fashion-suit order rows) report zero so the caller charges nothing. /// private static uint GrantTradePurchase4229938( Connection conn, EconomyChanges4229938 changes, List fashions, EconomyConfigRepository.TradeItem item, uint count) { switch (item.ItemType) { case TradeItemTypeBox4229938: case TradeItemTypeCollectible4229938: if (item.ItemId == 0) break; AddGrant4229938(changes, item.ItemId, count, isBind: false); return count; case TradeItemTypeFashion4229938: if (item.FashionId == 0) break; fashions.Add(item.FashionId); return count; } conn.Log.Warn( $"[TRADE] row {item.Id} (type={item.ItemType} item={item.ItemId} fashion={item.FashionId} " + $"suit={item.FashionSuitId}) has nothing to grant"); return 0; } private static uint ReadUInt32At4229938(byte[] body, int offset) => body.Length >= offset + 4 ? BitConverter.ToUInt32(body, offset) : 0u; }