231 lines
12 KiB
C#
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}");
|
|
}
|
|
}
|