using Ananta.SDK.Rpc; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Handlers; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// Fashion/wardrobe handlers. The client's appearance panel sends /// AskSetSpiritFashionsWithSource when the player confirms a skin. Without a /// server-side handler the request is dropped and the model reverts to the /// login default when the panel closes. We accept the choice and echo it back /// via SyncSetSpiritFashions so the worn skin persists. /// internal sealed partial class GameRouter { // ★ 2026-10-02:补上**老版衣柜入口** AskSetSpiritFashions(63347169)(自 7k-v0.6 // GameRouter.Fashion.cs 移植)。它与带 source 的新入口差 2 字节: // 老包 = [u32 spiritId][wear object],新包 = [u32 spiritId][short source][wear object]。 // 做法是在偏移 4 处补一个 short 8(FashionWearSource.Spirit,小端 = 08 00), // 然后**走完全同一套**穿戴逻辑 —— 不另起一条分支,避免两套行为漂移。 // ⛑ 已核对主线契约 AskSetSpiritFashionsWithSource.source 就是 short(FashionMethods.cs:11) // ⇒ 补 2 字节与捐赠端的字节拼接逐字节一致。 [Handler(MethodId.AskSetSpiritFashionsWithSource, HandlerPacketKind.Invoke)] [Handler(MethodId.AskSetSpiritFashions, HandlerPacketKind.Invoke)] private async Task AskSetSpiritFashionsWithSource(Connection conn, UxRpcMessage msg) { var body = msg.Body; if (msg.MethodId == MethodId.AskSetSpiritFashions) { if (body.Length is < 4 or > 4094) { conn.Log.Warn($"[FASHION] legacy wear rejected bytes={body.Length}"); await conn.ReturnEmptyOkAsync(msg); return; } var withSource = new byte[body.Length + 2]; body.AsSpan(0, 4).CopyTo(withSource); withSource[4] = 8; // FashionWearSource.Spirit(short 小端:08 00) body.AsSpan(4).CopyTo(withSource.AsSpan(6)); body = withSource; } var args = TryDecode4229938(body); if (args is null) { conn.Log.Warn($"[FASHION] AskSetSpiritFashionsWithSource undecodable bytes={msg.Body.Length}" + (msg.MethodId == MethodId.AskSetSpiritFashions ? " (legacy entry, spliced)" : "")); await conn.ReturnEmptyOkAsync(msg); return; } var spiritId = args.spiritOrInstanceId; var wearCount = args.spiritWearFashionsInfo?.WearFashionInfoList?.Count ?? 0; var fashionIds = wearCount > 0 ? string.Join(",", args.spiritWearFashionsInfo!.WearFashionInfoList.Select(x => x.FashionId)) : "(none)"; conn.Log.Info($"[FASHION] wear spirit={spiritId} source={args.source} count={wearCount} ids=[{fashionIds}]"); // Answer before doing the work. This is an INVOKE, so the wardrobe panel sits blocked until // the response arrives — and the work below includes a disk write plus a notify. Replying // first keeps a slow save from freezing the UI. Nothing after this point can report failure // to the client, so the whole tail is wrapped and logged instead. await conn.ReturnEmptyOkAsync(msg); try { await ApplyWearAsync(conn, msg, spiritId, args); } catch (Exception ex) { conn.Log.Warn($"[FASHION] wear apply failed spirit={spiritId}: {ex.Message}"); } } private async Task ApplyWearAsync( Connection conn, UxRpcMessage msg, uint spiritId, GameMethods.AskSetSpiritFashionsWithSource args) { // Remember the outfit so a re-login publishes it instead of the authored default. The // wardrobe panel never edits the worn value locally — SyncSetSpiritFashions is its only // writer — so without this the character silently undresses on every restart. var state = GetWorldState(msg.Context); var wear = args.spiritWearFashionsInfo ?? new GameMethods.SpiritWearFashionsInfo(); var wornIds = (wear.WearFashionInfoList ?? []) .Where(x => x is not null && x.FashionId != 0) .Select(x => x.FashionId) .ToList(); // The id is the character template id (FightSpiritConfig), the same key SyncSetSpiritFashions // publishes with. Anything the roster does not know is refused so a bad id cannot poison the // snapshot and leave a character permanently naked. var knownSpirit = ClientConfigRepository.Characters().Any(x => x.TemplateId == spiritId); if (knownSpirit && wornIds.Count > 0) { var source = wear.WearSourceInfo; lock (state.SyncRoot) { state.FashionBySpirit[spiritId] = new FashionRecord4229938( wear.FunctionSuitId, source.Source, source.SourceId, wear.IsTryWear, wear.HiddenParts, wornIds); } PlayerProgressStore.Save(state, conn.Log); } // Echo the client's chosen wear back as authoritative SyncSetSpiritFashions. // This keeps the character model dressed in the selected skin after the // appearance panel closes, instead of snapping back to login defaults. var sync = new GameMethods.SyncSetSpiritFashions { spiritId = spiritId, source = args.source, spiritWearFashionsInfo = args.spiritWearFashionsInfo ?? new GameMethods.SpiritWearFashionsInfo(), }; await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, sync); } /// /// Wardrobe bookkeeping: "mark fashions as seen", "toggle favourite", "read suit list". /// /// These are INVOKE requests that ask for nothing back — the client only waits for the ack. The /// reference branch answers them with an empty OK, and leaving them unrouted is worse than /// useless: an unanswered INVOKE leaves the caller awaiting forever, which is exactly the /// failure mode that shows up as a wardrobe button that never responds. /// /// The bodies are deliberately not decoded. They are client-local bookkeeping (read receipts, /// favourite flags), and inventing a contract for bytes we have never seen would risk a decode /// exception on the only path whose job is to always answer. /// [Handler(MethodId.AskReadFashions)] [Handler(MethodId.AskFavoriteFashions)] [Handler(MethodId.AskReadFashionSuits)] private Task FashionBookkeepingAccept(Connection conn, UxRpcMessage msg) { // Logged with the byte count so a future "suits do not show up" bug has a starting point: // it tells us the client really sent the request, and how much it expected us to read. conn.Log.Info($"[FASHION] bookkeeping accepted method={msg.MethodId} bytes={msg.Body.Length}"); return conn.ReturnEmptyOkAsync(msg); } }