using Ananta.SDK.Rpc; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// Weapon-skin surface (build 4229938), ported from the Lyco donor build on 2026-09-26. /// /// Ownership is seeded at login: PlayerWeaponSkinInfo.OwnedSkinIds gets every authored /// WeaponSkinConfig id (221 in stock 4229938), which is what makes the skin picker list anything at /// all. These handlers then persist the per-character equipped set /// (SpiritWeaponSkinDict) and answer empty OK — the client commits the assignment locally. /// /// ⛑ Both bodies are decoded defensively: they are C2S shapes read off the donor's /// ClientToGameDelegate.lua serializers, and an unanswered INVOKE would leave the picker waiting /// forever, which is worse than a skin the server forgets. /// internal sealed partial class GameRouter { [Handler(MethodId.AskSetWeaponSkins, HandlerPacketKind.Invoke)] private async Task AskSetWeaponSkins(Connection conn, UxRpcMessage msg) { if (!msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[WEAPON-SKIN] set undecodable bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}"); await conn.ReturnEmptyOkAsync(msg); return; } var state = GetWorldState(msg.Context); foreach (var spiritId in args.spiritIds) WeaponSkinStore.SetSkin(state, spiritId, args.sceneItemIds, args.skinId); conn.Log.Info( $"[WEAPON-SKIN] set skin={args.skinId} spirits=[{string.Join(',', args.spiritIds)}] " + $"sceneItems=[{string.Join(',', args.sceneItemIds)}]"); await conn.ReturnEmptyOkAsync(msg); PlayerProgressStore.Save(state, conn.Log, quiet: true); } [Handler(MethodId.AskSyncWeaponSkinToSpirits, HandlerPacketKind.Invoke)] private async Task AskSyncWeaponSkinToSpirits(Connection conn, UxRpcMessage msg) { if (!msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[WEAPON-SKIN] sync undecodable bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}"); await conn.ReturnEmptyOkAsync(msg); return; } var state = GetWorldState(msg.Context); var source = WeaponSkinStore.GetEquipped(state, args.sourceSpiritId); foreach (var spiritId in args.targetSpiritIds) WeaponSkinStore.SetAll(state, spiritId, source); conn.Log.Info( $"[WEAPON-SKIN] sync spirit={args.sourceSpiritId} -> [{string.Join(',', args.targetSpiritIds)}] " + $"entries={source.Count}"); await conn.ReturnEmptyOkAsync(msg); PlayerProgressStore.Save(state, conn.Log, quiet: true); } /// /// Push newly acquired skins (skinId -> acquireTime). With unlock-all the whole catalog is owned /// from login, so this stays unused — it is the only channel for granting a skin mid-session and /// is kept in shape for that. /// private Task PushAddWeaponSkinsAsync(Connection conn, IReadOnlyDictionary skinIdToAcquireTime) => conn.NotifyAsync(MethodId.IGameToClient_SyncAddWeaponSkinList, new GameMethods.SyncAddWeaponSkinList { skinIdToAcquireTime = new Dictionary(skinIdToAcquireTime), }); }