feat: Upgrade server with HyNB v2.4 modular architecture, minigames, admin GM web panel, companion summons, and car radio
This commit is contained in:
1 parent
74e2963a12
commit
3e5adf1dc5
1571 files changed
+336468
-6348
No files matched your search
@@ -0,0 +1,230 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user