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);
}
}