This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.Fashion.cs
T

146 lines
7.1 KiB
C#
Raw Normal View History

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;
/// <summary>
/// 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.
/// </summary>
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<GameMethods.AskSetSpiritFashionsWithSource>(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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
}