Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.WeaponDrop.cs
T

231 lines
12 KiB
C#

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;
/// <summary>
/// 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 <c>destructibleId</c>, 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).
///
/// <see cref="ResetWeaponStateAsync"/> 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.
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>DestructibleDisposeReason.PickUpWeapon — the reason retail uses when a pick-up eats it.</summary>
private const int DestructibleDisposeReasonPickUpWeapon = 13;
/// <summary>
/// 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.
/// </summary>
[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));
}
}
/// <summary>
/// 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.
/// </summary>
[Handler(MethodId.AskPickUpWeapon, HandlerPacketKind.Invoke)]
private async Task OnPickUpWeapon(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskPickUpWeaponArgs4229938>();
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}");
}
/// <summary>
/// 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 <see cref="OnSwitchWeapon"/> uses, minus the slot mutation.
/// </summary>
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<byte>()),
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}");
}
}