using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.Server.Handlers; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// Weapon drop / pick-up recovery. /// /// Retail drops a weapon by creating a real Destructible in the world and handing its id to the /// client: SyncDisarmCreateWeaponDestructible (68141963) spawns it and AskPickUpWeapon (67264037) /// takes a destructibleId, not a weapon id. This server has never sent either, and the client /// never asks for one — a packet capture of a full drop + long-press-Q cycle contains zero /// AskDiscardWeapon*/AskPickUpWeapon calls, and every AskInteractCmd carries only CmdType 19/33/34 /// (SyncWeaponFBBIK / Request-SyncGameplayTagMask per NetcodeCommandUtils.NetCodeCmdType), so the /// whole drop is client-local and the server has no way to learn that it happened. /// /// What that leaves behind is a client weapon state machine with nothing to finish against: the /// long-press-Q absorb never resolves, the unit stays in its root-motion absorb loop and slides /// backwards until the player switches character (which rebuilds WeaponManager and clears it). /// /// is the server-side equivalent of that rebuild, and the admin /// panel exposes it as 「解除卡死」. See TOPIC notes / 2026-09-19.md §72 for the packet evidence. /// internal sealed partial class GameRouter { /// DestructibleDisposeReason.PickUpWeapon — the reason retail uses when a pick-up eats it. private const int DestructibleDisposeReasonPickUpWeapon = 13; /// /// Client invoke: AskSkillDestructibleCreate — the drop. /// /// This is the packet the client sends when a weapon leaves the hand, and it is answered with /// the new Destructible's id. Everything afterwards (long-press-Q psychokinesis, the pick-up /// prompt, AskPickUpWeapon) addresses the drop by that id, so answering 0 left the client /// talking to nothing: absorb animation with no exit (backwards slide), a Q prompt that never /// clears, and a weapon that returns to the hand only to fall straight back out. /// [Handler(MethodId.AskSkillDestructibleCreate, HandlerPacketKind.Invoke)] private async Task OnSkillDestructibleCreate(Connection conn, UxRpcMessage msg) { var state = GetWorldState(msg.Context); ulong id; int slotIndex = -1; ulong weaponId = 0; uint templateId = state.ActiveSpiritTemplateId; ulong fallbackWeaponId = 0; lock (state.SyncRoot) { id = state.NextWeaponDropId++; weaponId = state.ActiveWeaponInstanceId; if (weaponId != 0) { // Disarm: the weapon really left the hand, so it has to leave the slot list too. // Leaving it there is what made the wheel hand it back the moment the player // switched weapons, and what made an absorbed weapon fall straight back out — the // client ends up with the same instance twice and throws the absorbed one away. var slots = WeaponSlotIds(state, templateId); slotIndex = slots.IndexOf(weaponId); if (slotIndex >= 0) { // Never leave the character empty-handed: bail out when this was the only // weapon in the wheel. SpiritWeaponSnapshot falls back to the first filled // slot for CurrentWeaponUid, so an emptied list would publish a weapon that // is not in any slot. fallbackWeaponId = slots.FirstOrDefault(x => x != weaponId && x != 0); if (fallbackWeaponId == 0) { slotIndex = -1; } else { slots[slotIndex] = 0UL; state.ActiveWeaponInstanceId = fallbackWeaponId; } } } state.WeaponDrops[id] = new WeaponDropRecord { DestructibleId = id, WeaponInstanceId = weaponId, SpiritTemplateId = templateId, SlotIndex = slotIndex, }; } msg.Context.Session.Log.Info( $"[WEAPON-DROP] create id={id} weapon={weaponId} spirit={templateId} slot={slotIndex} hand={state.ActiveWeaponInstanceId}"); await conn.ReturnAsync(msg, id); // Publish after answering: the client is still awaiting the id, and the emptied slot is // what stops the wheel from silently re-arming the character mid-throw. if (slotIndex >= 0) { var unitId = state.ActiveSpiritUnitId; await PublishWeaponSnapshot(msg.Context, unitId, templateId, fallbackWeaponId); await msg.Context.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction, CombatCodec.SpiritSwitchWeapon(unitId, fallbackWeaponId)); // The replacement weapon needs its profile too. Switching alone leaves the client // holding a weapon with no charge data, resource pool or skill bindings, which is why // attacking right after a drop played the first swing and then aborted halfway. if (CombatCodec.Weapon(templateId, fallbackWeaponId) is { } fallback) await PublishSelectedWeaponProfile(msg.Context, unitId, templateId, fallback, ResolveWeaponStyle(state, fallback)); else await msg.Context.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon, CombatCodec.SpiritLastUsedWeapon(templateId, fallbackWeaponId)); } } /// /// Client invoke: AskPickUpWeapon (67264037) — the absorb finished, take it back. /// /// Answer with retail's own remove/add pair plus the weapon profile, and nothing else. The /// client drives the absorb itself; this server has to converge its own state (put the weapon /// back into the slot it was dropped from, make it current) and hand back a weapon the client /// can actually use, without rebuilding the weapon manager underneath a running animation. /// [Handler(MethodId.AskPickUpWeapon, HandlerPacketKind.Invoke)] private async Task OnPickUpWeapon(Connection conn, UxRpcMessage msg) { var args = msg.GetArgs(); var state = GetWorldState(msg.Context); await conn.ReturnEmptyOkAsync(msg); WeaponDropRecord? drop = null; lock (state.SyncRoot) { if (state.WeaponDrops.TryGetValue(args.destructibleId, out drop)) state.WeaponDrops.Remove(args.destructibleId); } if (drop is null) { // The client asks twice for the same drop (once when the absorb lands, once when the // skill reports end), so the second call is expected and only worth a trace. msg.Context.Session.Log.Info($"[WEAPON-DROP] repeat pick-up for id={args.destructibleId}"); return; } if (drop.SlotIndex >= 0) { lock (state.SyncRoot) { var slots = WeaponSlotIds(state, drop.SpiritTemplateId); if (drop.SlotIndex < slots.Count) slots[drop.SlotIndex] = drop.WeaponInstanceId; state.ActiveWeaponInstanceId = drop.WeaponInstanceId; } // Measured best of nine attempts (2026-09-19 02:06 / 02:12 runs): snapshot + switch + // profile. The snapshot carries CurrentWeaponUid (= make it the held weapon) and the // switch draws it; together they are what puts the weapon back in the hand. // // Retail's SyncSpiritAddWeaponAction was tried instead (§76/§78) and is strictly worse: // it only files the weapon into a slot, it does not equip it, so the second absorb // ended with the weapon owned but the hand empty until the player switched weapons. var unitId = state.ActiveSpiritUnitId; await PublishWeaponSnapshot(msg.Context, unitId, drop.SpiritTemplateId, drop.WeaponInstanceId); await msg.Context.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction, CombatCodec.SpiritSwitchWeapon(unitId, drop.WeaponInstanceId)); if (CombatCodec.Weapon(drop.SpiritTemplateId, drop.WeaponInstanceId) is { } weapon) await PublishSelectedWeaponProfile(msg.Context, unitId, drop.SpiritTemplateId, weapon, ResolveWeaponStyle(state, weapon)); else await msg.Context.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon, CombatCodec.SpiritLastUsedWeapon(drop.SpiritTemplateId, drop.WeaponInstanceId)); } // Still no SyncDestructibleObjectRemove: the client disposes of the drop on its own (it // reports AskBreakDestructibleObjects right afterwards) and the Q prompt goes with it. PlayerProgressStore.Save(state, msg.Context.Session.Log); msg.Context.Session.Log.Info( $"[WEAPON-DROP] picked up id={args.destructibleId} weapon={drop.WeaponInstanceId} slot={drop.SlotIndex} notDirectlyEquip={args.notDirectlyEquip}"); } /// /// Break whatever action the unit is stuck in and re-publish the authoritative weapon state. /// /// Order matters: SyncBreakSkill first, then the snapshot, then the selection packets — the same /// sequence uses, minus the slot mutation. /// internal static async Task<(bool Ok, string Message)> ResetWeaponStateAsync(TcpSession session) { var state = GetStateIfExists(session); if (state is null) return (false, "还没有玩家世界状态 —— 请先登录并进入世界(角色出现在游戏里)再点"); if (!state.Ready) return (false, "世界还没 ready,稍等一下再试"); var unitId = state.ActiveSpiritUnitId; var templateId = state.ActiveSpiritTemplateId; var instanceId = state.ActiveWeaponInstanceId; // Same shape as GameRouter.Switching.cs: the instance methods below are pure per-session // work and nothing on this path touches router-wide state, so a bare instance carries them. var ctx = new RpcContext( session, new RpcPacket(RpcPacketKind.Invoke, MethodId.AskSwitchWeapon, 0, Array.Empty()), CancellationToken.None); try { var router = new GameRouter(); await ctx.NotifyAsync(MethodId.SyncBreakSkill, CombatCodec.BreakSkill(unitId)); await router.PublishWeaponSnapshot(ctx, unitId, templateId, instanceId); await ctx.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction, CombatCodec.SpiritSwitchWeapon(unitId, instanceId)); await ctx.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon, CombatCodec.SpiritLastUsedWeapon(templateId, instanceId)); } catch (Exception ex) { session.Log.Warn($"[WEAPON-RESET] failed unit={unitId}: {ex.Message}"); return (false, $"重置失败:{ex.Message}"); } session.Log.Info($"[WEAPON-RESET] unit={unitId} template={templateId} weapon={instanceId}"); return (true, $"已打断动作并重发武器状态,当前武器 {instanceId}"); } }