using Ananta.SDK.Rpc; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// NPC shop shelves for build 4229938 (ShopManager: AskNpcShop -> SyncFullCommodityInfos). /// /// Why this file exists: a placed shop clerk opens its panel fine but the panel is empty. The /// client builds the rows from tables it already owns (ShopConfig / ShopCommodityGroupConfig / /// ShopCommodityConfig) and only waits for the server to say what is actually on the shelf — /// ShopManager.lua aborts with "获取不到服务端的商品数据" when /// shopCommodityDataDict[shopId] has no entry, which is exactly the empty panel. That /// dictionary is filled by nothing but SyncFullCommodityInfos (64769566). /// /// The server is not asked to price or validate anything here: it restocks every row at its /// configured price with unlimited stock. Charging for a purchase is a separate step /// (AskBuyCommodity) and is deliberately not implemented yet. /// internal sealed partial class GameRouter { /// Solution Pawn — the shop standing 1.95 m from the 典斥屋 scene preset, used as the A0 pilot. private const uint PilotShopId = 94100024u; [Handler(MethodId.AskNpcShop, HandlerPacketKind.Invoke)] private async Task AskNpcShop(Connection conn, UxRpcMessage msg) { var mode = PrivateServerConfigStore.Current.Gameplay.NpcShopPayloadMode; // AskNpcShop is a void INVOKE the client awaits, so it must always be answered — a shop we // cannot restock still has to return OK, otherwise the clerk looks like a failed interaction. if (string.Equals(mode, "off", StringComparison.OrdinalIgnoreCase)) { await conn.ReturnEmptyOkAsync(msg); return; } var shopId = msg.Body.Length >= 4 ? BitConverter.ToUInt32(msg.Body, 0) : 0u; if (shopId == 0) { conn.Log.Warn($"[NPCSHOP] AskNpcShop without a shop id (body={msg.Body.Length}B hex={Convert.ToHexString(msg.Body)})"); await conn.ReturnEmptyOkAsync(msg); return; } if (string.Equals(mode, "pilot", StringComparison.OrdinalIgnoreCase) && shopId != PilotShopId) { conn.Log.Info($"[NPCSHOP] skip shop={shopId} (pilot mode only serves {PilotShopId})"); await conn.ReturnEmptyOkAsync(msg); return; } var loadError = NpcShopCatalog4229938.LoadError; if (loadError is not null) { conn.Log.Warn($"[NPCSHOP] catalog unusable: {loadError}"); await conn.ReturnEmptyOkAsync(msg); return; } if (!NpcShopCatalog4229938.TryGetShop(shopId, out var shop)) { conn.Log.Info($"[NPCSHOP] shop={shopId} is not in NpcShopCatalog.json ({NpcShopCatalog4229938.Count} shops known)"); await conn.ReturnEmptyOkAsync(msg); return; } var infos = new List(shop.Commodities.Count); foreach (var commodity in shop.Commodities) { infos.Add(new GameMethods.NpcShopCommodityInfo4229938 { TemplateId = commodity.TemplateId, // A positive stock keeps the row out of the sold-out state; LimitNum <= 0 means the // official row is unlimited, in which case there is no meaningful number to print. Count = commodity.LimitNum > 0 ? commodity.LimitNum : 9999, RefreshTime = 0, Status = 0, Discount = 100, // 100 = no discount; anything else flips the client's HasDiscount. DiscountPrice = commodity.Price, MaxBuyCount = 0, // 0 = the client leaves the stock figure alone. DailyDiscount = 0, CommodityBadgeDiscountDict = new Dictionary(), BlackMarketPriceModifier = 0, }); } await conn.NotifyAsync( MethodId.IGameToClient_SyncFullCommodityInfos, new GameMethods.SyncFullCommodityInfosArgs4229938 { shopId = shopId, infos = infos }); conn.Log.Info($"[NPCSHOP] shop={shopId} '{shop.Name}' restocked {infos.Count} rows mode={mode}"); await conn.ReturnEmptyOkAsync(msg); } }