Files

931 lines
41 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers.Binary;
using System.Diagnostics.CodeAnalysis;
using Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 搜打撤(ExtractionShooter,本季「不要抬头!」)的服务端实现。
///
/// 移植自捐赠端 handoff <c>AI_HANDOFF不要抬头2.md</c>(第二版),只取第一轮范围:
/// ① 进图 / 退图 ② 容器搜索与刷物 ③ 背包搬运 / 丢弃 / 锁定 / 排序 / 拆分 / 扩容 / 入仓
/// <b>不做</b>武器实体化(轮盘 Tab 切枪)—— 那要 SyncSpiritWeaponDetail 整表,是第二轮。
///
/// 三条先读再加代码:
/// - ⛑️ <c>[Handler]</c> ≠ 已注册:不写进 <c>GameRouter.cs</c> 的
/// <c>Enabled4229938MethodIds</c> 就是空注册 —— 请求不报错,只有一行
/// <c>unimplemented ... typed-default void 0b</c>,且 <c>unknown-methods.log</c> 里反而没有。
/// - ⛑️ <b>请求-应答型 RPC 必须回等价结构</b>:<c>midToReturnMessageReader[mid]</c> 有 reader 的
/// (典型是 SearchContainer/67410186,要求 ReadComplex(Reader[530])),回 EmptyOk 就是用 nil
/// 覆盖客户端本地那份数据 —— 容器曾经就这么坏掉。
/// - ⛑️ 容器 carrying 的实例 id 是<b>客户端给的</b>(8 字节),服务端按它确定性选容器形体;
/// 所以同一实例在这一局里怎么搜都一致,换局才会重洗。
/// </summary>
internal sealed partial class GameRouter
{
private const string ExtractionTag = "[EXTRACTION]";
// ---------------------------------------------------------------- helpers
private static ExtractionShooterSettings ExtractionCfg()
=> PrivateServerConfigStore.Current.World.Content.Extraction;
/// <summary>这个 raid 是不是搜打撤图。表里没有 raid↔图外键 ⇒ 用配置里的 raid 名单。</summary>
internal static bool IsExtractionRaid(uint raidId)
=> raidId != 0 && ExtractionCfg().Enabled && ExtractionCfg().RaidIds.Contains(raidId);
private static ExtractionShooterSessionState ExtractionStateOf(TcpSession session)
=> ExtractionShooterRuntime4229938.StateOf(session);
/// <summary>
/// 首次用到时建包。为了避免 <b>登录 flush 前就把包柜塞满</b>,这里只做一次。
/// </summary>
private static ExtractionShooterSessionState EnsureExtractionSeeded(TcpSession session)
{
var owner = ExtractionStateOf(session);
if (owner.Seeded)
return owner;
var cfg = ExtractionCfg();
if (!cfg.Enabled)
return owner;
var error = ExtractionShooterCatalog4229938.LoadError;
if (error is not null)
{
session.Log.Warn($"{ExtractionTag} catalog unusable: {error}");
return owner;
}
var bags = ExtractionShooterRuntime4229938.Seed(owner);
session.Log.Info($"{ExtractionTag} seeded bags={bags} " +
$"items={ExtractionShooterCatalog4229938.Items.Count} " +
$"containers={ExtractionShooterCatalog4229938.Containers.Count}");
return owner;
}
private static uint NowSeconds()
=> (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
// ---------------------------------------------------------------- wire builders
/// <summary>ClearBag 逐件 SetItemInfo 的整包重推 —— 只改了一格也要整包,客户端没有单删。</summary>
private static async Task PushBagAsync(TcpSession session, ExtractionShooterBagState bag, string phase)
{
await session.NotifyAsync(MethodId.IGameToClient_SyncExtractionShooterClearBag,
UxSerializer.Serialize(new SceneMethods.SyncExtractionShooterClearBagArgs4229938
{
BagConfigId = bag.BagId,
}), CancellationToken.None);
var pushed = 0;
foreach (var item in bag.Items)
{
await session.NotifyAsync(MethodId.IGameToClient_SyncExtractionShooterSetItemInfo,
UxSerializer.Serialize(new SceneMethods.SyncExtractionShooterSetItemInfoArgs4229938
{
BagConfigId = bag.BagId,
Item = new SceneMethods.ExtractionShooterBagItemInfo
{
Id = ExtractionShooterCatalog4229938.WireIdFor(item.ConfigId),
StackCount = item.StackCount,
CellX = (uint)item.CellX,
CellY = (uint)item.CellY,
IsRotated = item.IsRotated,
IsLocked = item.IsLocked,
BindPid = item.BindPid,
ShieldValue = 0f,
WeaponData = null,
CollectionBook = null,
},
}), CancellationToken.None);
pushed++;
}
session.Log.Info($"{ExtractionTag} push bag={bag.BagId} phase={phase} items={pushed}");
}
private static async Task PushBagCapacityAsync(TcpSession session, ExtractionShooterBagState bag, string phase)
{
await session.NotifyAsync(MethodId.IGameToClient_SyncExtractionShooterChangeBagCapacity,
UxSerializer.Serialize(new SceneMethods.SyncExtractionShooterChangeBagCapacityArgs4229938
{
BagConfigId = bag.BagId,
// ⛑ 列表是 [x, y] 两个 i32 —— 不是"每格写一个 1"。
Capacity = ExtractionShooterRuntime4229938.CapacityOf(bag),
}), CancellationToken.None);
session.Log.Info($"{ExtractionTag} capacity bag={bag.BagId} phase={phase} " +
$"{bag.Meta.CapacityX}x{bag.Meta.CapacityY}");
}
// =============================================================== handlers
/// <summary>
/// 搜索容器(67410186,Invoke)。
///
/// ⛑ 这条<b>必须回真正的包体</b>:<c>midToReturnMessageReader[67410186]</c> 要求
/// <c>ReadComplex(Reader[530])</c>,回 EmptyOk 会拿 nil 覆盖客户端本地那份 ContainerInfo。
/// 回完之后再补一条 SyncExtractionShooterContainerInfo(68820459) 走推送路径,
/// 两条同源同一个 BuildContainerInfo。
/// </summary>
[Handler(MethodId.AskExtractionShooterSearchContainer, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterSearchContainer(Connection conn, UxRpcMessage msg)
{
var session = msg.Context.Session;
var cfg = ExtractionCfg();
// 请求只有 8 字节实例 id,没有 config id —— 形体由服务端自己按 hash 选。
if (msg.Body.Length < 8)
{
conn.Log.Warn($"{ExtractionTag} search short body bytes={msg.Body.Length}");
await AnswerEmptyContainerAsync(conn, msg, 0UL);
return;
}
var instanceId = BinaryPrimitives.ReadUInt64LittleEndian(msg.Body);
if (!cfg.Enabled)
{
await AnswerEmptyContainerAsync(conn, msg, instanceId);
return;
}
var owner = EnsureExtractionSeeded(session);
var container = ExtractionShooterRuntime4229938.EnsureContainer(owner, instanceId);
if (container is null)
{
conn.Log.Warn($"{ExtractionTag} no container candidates instance={instanceId}");
await AnswerEmptyContainerAsync(conn, msg, instanceId);
return;
}
var pid = PrivateServerConfigStore.Current.Player.Pid;
var searched = ExtractionShooterRuntime4229938.TrySearchContainer(owner, instanceId, pid, NowSeconds());
if (!searched)
conn.Log.Info($"{ExtractionTag} already searched instance={instanceId} pid={pid}");
var info = ExtractionShooterRuntime4229938.BuildContainerInfo(container);
await conn.ReturnAsync(msg, info);
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncExtractionShooterContainerInfo,
UxSerializer.Serialize(new SceneMethods.SyncExtractionShooterContainerInfoArgs4229938
{
ContainerInstanceId = instanceId,
ContainerInfo = info,
}), CancellationToken.None);
conn.Log.Info($"{ExtractionTag} search instance={instanceId} cfg={container.CfgId} " +
$"size={container.SizeX}x{container.SizeY} items={container.Items.Count} " +
$"reveal={container.RevealDelaySeconds}s searched={searched}");
// 读条走完的收尾:延时把时间戳翻回过去再补推一帧。
// ⛑️ 少了这一帧,箱子读条会走完但物品**永远不显示**(捐赠端 v1.7 09-24 17:07 实机日志对过)。
if (!ExtractionShooterRuntime4229938.IsContainerRevealed(owner, instanceId, pid))
{
var delay = ExtractionShooterRuntime4229938.ContainerSearchDelaySeconds(owner, instanceId);
_ = RevealContainerAfterAsync(session, owner, instanceId, pid, delay, "search");
}
}
/// <summary>
/// 容器读条的收尾:<c>seconds</c> 秒后把这个 pid 的时间戳翻成过去,再推同一份容器数据。
///
/// ⛑️ <c>seconds</c> 必须 &gt; 0(取整箱最长 SearchTime)—— 传 0 会立刻揭晓,读条动画播不出来。
/// 这是一次性的延时推,不是循环,但仍然要过 <see cref="GameRouter.IsBackgroundPushPaused"/> 门控
/// (硬规则 37):等这几秒里客户端可能已经挂起,那时推等于往不消费的 TCP 里灌包。
/// </summary>
private static async Task RevealContainerAfterAsync(TcpSession session, ExtractionShooterSessionState owner,
ulong instanceId, ulong pid, int seconds, string phase)
{
if (seconds <= 0)
{
session.Log.Info($"{ExtractionTag} reveal ({phase}) skipped: no search time instance={instanceId}");
return;
}
try
{
await Task.Delay(TimeSpan.FromSeconds(seconds), CancellationToken.None);
}
catch (Exception ex)
{
session.Log.Warn($"{ExtractionTag} reveal ({phase}) wait failed: {ex.Message}");
return;
}
if (GameRouter.IsBackgroundPushPaused(session))
{
session.Log.Info($"{ExtractionTag} reveal ({phase}) deferred: client hung up instance={instanceId}");
return;
}
if (!ExtractionShooterRuntime4229938.TryRevealContainer(owner, instanceId, pid, NowSeconds()))
return;
await PushContainerAsync(session, owner, instanceId, $"{phase}:revealed");
}
private static async Task AnswerEmptyContainerAsync(Connection conn, UxRpcMessage msg, ulong instanceId)
{
await conn.ReturnAsync(msg, new SceneMethods.ExtractionShooterContainerInfo { CfgId = 0 });
conn.Log.Info($"{ExtractionTag} search empty-answer instance={instanceId}");
}
/// <summary>
/// 结束搜索(67701506)。客户端读条走完自己发的收尾。
///
/// ⛑️ <b>不能只 ack</b>:这一条是「时间戳该翻回过去了」的信号,服务端必须真的
/// <see cref="ExtractionShooterRuntime4229938.TryRevealContainer"/> 再补推一帧,
/// 否则客户端手上那份还是未来时间戳 ⇒ 物品不显示。
/// (旧实现在这里写的是「服务端无状态可清」,是错的 —— 状态就是那个时间戳。)
/// </summary>
[Handler(MethodId.AskExtractionShooterEndSearchContainer, HandlerPacketKind.Both)]
private async Task OnAskExtractionShooterEndSearchContainer(Connection conn, UxRpcMessage msg)
{
if (msg.Body.Length >= 8)
{
var session = msg.Context.Session;
var instanceId = BinaryPrimitives.ReadUInt64LittleEndian(msg.Body);
if (ExtractionCfg().Enabled)
{
var owner = EnsureExtractionSeeded(session);
var pid = PrivateServerConfigStore.Current.Player.Pid;
var revealed = ExtractionShooterRuntime4229938.TryRevealContainer(
owner, instanceId, pid, NowSeconds());
if (revealed)
await PushContainerAsync(session, owner, instanceId, "end-search");
}
conn.Log.Info($"{ExtractionTag} end-search instance={instanceId}");
}
if (msg.IsInvoke)
await conn.ReturnEmptyOkAsync(msg);
}
/// <summary>
/// 六合一搬运(63749738,25B):装备 / 卸下 / 放入安全箱 / 携带 / 放回仓库 / 旋转
/// 全走这一条,区别只是 destBag。修一条等于修全部。
/// </summary>
[Handler(MethodId.AskExtractionShooterShiftItem, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterShiftItem(Connection conn, UxRpcMessage msg)
{
var session = msg.Context.Session;
if (msg.Body.Length < 25)
{
conn.Log.Warn($"{ExtractionTag} shift short body bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}");
await conn.ReturnEmptyOkAsync(msg);
return;
}
var b = msg.Body;
var srcBag = BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0));
var fromX = (int)BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4));
var fromY = (int)BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8));
var destBag = BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12));
var toX = (int)BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(16));
var toY = (int)BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(20));
var rotated = b[24] != 0;
var ok = false;
if (ExtractionCfg().Enabled)
{
var owner = EnsureExtractionSeeded(session);
ok = ExtractionShooterRuntime4229938.TryMoveBagItem(owner, srcBag, fromX, fromY, destBag, toX, toY, rotated);
if (ok)
{
if (owner.Bags.TryGetValue(srcBag, out var src))
await PushBagAsync(session, src, "shift-src");
if (destBag != srcBag && owner.Bags.TryGetValue(destBag, out var dst))
await PushBagAsync(session, dst, "shift-dst");
await SyncWheelAfterBagChangeAsync(session, "shift", srcBag, destBag);
}
}
conn.Log.Info($"{ExtractionTag} shift {srcBag}({fromX},{fromY}) -> {destBag}({toX},{toY}) " +
$"rot={rotated} ok={ok}");
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskExtractionShooterRemoveItem, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterRemoveItem(Connection conn, UxRpcMessage msg)
{
var session = msg.Context.Session;
if (msg.Body.Length < 12)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var bagId = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(0));
var x = (int)BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(4));
var y = (int)BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(8));
var ok = false;
if (ExtractionCfg().Enabled)
{
var owner = EnsureExtractionSeeded(session);
ok = ExtractionShooterRuntime4229938.TryRemoveBagItem(owner, bagId, x, y);
if (ok && owner.Bags.TryGetValue(bagId, out var bag))
await PushBagAsync(session, bag, "discard");
await SyncWheelAfterBagChangeAsync(session, "discard", bagId);
}
conn.Log.Info($"{ExtractionTag} discard bag={bagId} ({x},{y}) ok={ok}");
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskExtractionShooterLockItem, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterLockItem(Connection conn, UxRpcMessage msg)
{
var session = msg.Context.Session;
if (msg.Body.Length < 13)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var bagId = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(0));
var x = (int)BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(4));
var y = (int)BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(8));
var locked = msg.Body[12] != 0;
var ok = false;
if (ExtractionCfg().Enabled)
{
var owner = EnsureExtractionSeeded(session);
ok = ExtractionShooterRuntime4229938.TryLockBagItem(owner, bagId, x, y, locked);
if (ok && owner.Bags.TryGetValue(bagId, out var bag))
await PushBagAsync(session, bag, "lock");
}
conn.Log.Info($"{ExtractionTag} lock bag={bagId} ({x},{y}) locked={locked} ok={ok}");
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskExtractionShooterSortBag, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterSortBag(Connection conn, UxRpcMessage msg)
{
var session = msg.Context.Session;
if (msg.Body.Length < 4)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var bagId = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(0));
var ok = false;
if (ExtractionCfg().Enabled)
{
var owner = EnsureExtractionSeeded(session);
ok = ExtractionShooterRuntime4229938.TrySortBag(owner, bagId);
if (ok && owner.Bags.TryGetValue(bagId, out var bag))
await PushBagAsync(session, bag, "sort");
await SyncWheelAfterBagChangeAsync(session, "sort", bagId);
}
conn.Log.Info($"{ExtractionTag} sort bag={bagId} ok={ok}");
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskExtractionShooterSplitItem, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterSplitItem(Connection conn, UxRpcMessage msg)
{
var session = msg.Context.Session;
if (msg.Body.Length < 17)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var bagId = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(0));
var x = (int)BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(4));
var y = (int)BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(8));
var count = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(12));
var rot = msg.Body[16] != 0;
var ok = false;
if (ExtractionCfg().Enabled)
{
var owner = EnsureExtractionSeeded(session);
ok = ExtractionShooterRuntime4229938.TrySplitBagItem(owner, bagId, x, y, count, rot);
if (ok && owner.Bags.TryGetValue(bagId, out var bag))
await PushBagAsync(session, bag, "split");
await SyncWheelAfterBagChangeAsync(session, "split", bagId);
}
conn.Log.Info($"{ExtractionTag} split bag={bagId} ({x},{y}) count={count} ok={ok}");
await conn.ReturnEmptyOkAsync(msg);
}
/// <summary>
/// 扩容(63115121,4B)。唯一的扩容入口 —— 客户端没有"直接换包"请求。
/// Type=1 给目标包加一行 ⇒ 要回 ChangeBagCapacity;Type=2 解锁新仓库。
/// </summary>
[Handler(MethodId.AskExtractionShooterBagExpansion, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterBagExpansion(Connection conn, UxRpcMessage msg)
{
var session = msg.Context.Session;
if (msg.Body.Length < 4)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var expansionId = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(0));
if (!ExtractionCfg().Enabled)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var owner = EnsureExtractionSeeded(session);
var (targetBag, newSizeY) = ExtractionShooterRuntime4229938.TryExpandBag(owner, expansionId);
if (targetBag != 0 && newSizeY > 0 && owner.Bags.TryGetValue(targetBag, out var bag))
await PushBagCapacityAsync(session, bag, "expansion");
await session.NotifyAsync(MethodId.IGameToClient_SyncExtractionShooterBagExpansionUnlocked,
UxSerializer.Serialize(new SceneMethods.SyncExtractionShooterBagExpansionUnlockedArgs4229938
{
ExpansionId = expansionId,
BagConfigId = targetBag,
}), CancellationToken.None);
conn.Log.Info($"{ExtractionTag} expansion id={expansionId} bag={targetBag} sizeY={newSizeY}");
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskExtractionShooterTransferAllToStash, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterTransferAllToStash(Connection conn, UxRpcMessage msg)
{
var session = msg.Context.Session;
if (!ExtractionCfg().Enabled)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var owner = EnsureExtractionSeeded(session);
var moved = ExtractionShooterRuntime4229938.TryTransferAllToStash(owner);
if (owner.Bags.TryGetValue(ExtractionShooterCatalog4229938.BackpackBagId, out var backpack))
await PushBagAsync(session, backpack, "stash-backpack");
if (owner.Bags.TryGetValue(ExtractionShooterCatalog4229938.SafeBoxBagId, out var safeBox))
await PushBagAsync(session, safeBox, "stash-safebox");
if (owner.Bags.TryGetValue(ExtractionShooterCatalog4229938.FirstStashBagId, out var stash))
await PushBagAsync(session, stash, "stash");
conn.Log.Info($"{ExtractionTag} transfer-all-to-stash moved={moved}");
await conn.ReturnEmptyOkAsync(msg);
}
/// <summary>全包整理(63009939,list7bit&lt;u32&gt;)。逐包重排后各自整包重推。</summary>
[Handler(MethodId.AskExtractionShooterSortBags, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterSortBags(Connection conn, UxRpcMessage msg)
{
var session = msg.Context.Session;
if (!ExtractionCfg().Enabled)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var owner = EnsureExtractionSeeded(session);
// list7bit<u32>:先把 7bit 计数解出来,序列里剩下的按 u32 取。
var bags = ReadU32List7Bit(msg.Body);
foreach (var bagId in bags)
{
if (!ExtractionShooterRuntime4229938.TrySortBag(owner, bagId))
continue;
if (owner.Bags.TryGetValue(bagId, out var bag))
await PushBagAsync(session, bag, "sort-bags");
await SyncWheelAfterBagChangeAsync(session, "sort-bags", bagId);
}
conn.Log.Info($"{ExtractionTag} sort-bags count={bags.Count}");
await conn.ReturnEmptyOkAsync(msg);
}
// 容器 ↔ 背包 / 槽位的六条搬运。
//
// ⛑️ 第一版这里的字段全是「按最可能的布局猜」的,实机一跑全错(捐赠端 §14.8/§14.10 用 C2S
// 抓包纠正):非槽位那两条是 (container, fromX, fromY, destBag/srcBag, toX, toY, isRotated),
// 槽位那两条是 (container…, slotBag, slotIndex) / (srcBag, slotIndex, container…)。
// 猜错的后果不是报错,而是 TryGetArgs 静默返回 false ⇒ 处理器什么也不做("点了没反应")。
// ⇒ 现在一律走 §8.14 的 ExtractionRequests 契约,并且解析失败要<b>打出来</b>(见 TryArgs)。
[Handler(MethodId.AskExtractionShooterShiftContainerItemToBag, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterShiftContainerItemToBag(Connection conn, UxRpcMessage msg)
{
await conn.ReturnEmptyOkAsync(msg);
if (!TryArgs(conn, msg, out SceneMethods.ExtractionRequests.ShiftContainerItemToBag? req))
return;
// 「旋转」这条分支客户端把容器实例 id 当成 destBag 一起发回来(低 32 位截断)⇒
// destBag == container 的低 32 位时是<b>容器内原地旋转</b>,不是往包里搬。
// 不识别的话:玩家点旋转,服务端会把那件物品当成「搬到包里」处理,容器视图也不会刷新。
if (req.DestBag == (uint)(req.Container & 0xFFFFFFFFUL))
{
await RunMoveAsync(conn, msg, req.Container, req.FromX, req.FromY, req.ToX, req.ToY, req.IsRotated,
"container-rotate");
return;
}
await RunTakeAsync(conn, msg, req.Container, req.FromX, req.FromY, req.DestBag, req.ToX, req.ToY,
req.IsRotated, "container->bag");
}
[Handler(MethodId.AskExtractionShooterShiftContainerItemToSlot, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterShiftContainerItemToSlot(Connection conn, UxRpcMessage msg)
{
await conn.ReturnEmptyOkAsync(msg);
if (!TryArgs(conn, msg, out SceneMethods.ExtractionRequests.ShiftContainerItemToSlot? req))
return;
var session = msg.Context.Session;
var ok = false;
if (ExtractionCfg().Enabled)
{
var owner = EnsureExtractionSeeded(session);
ok = ExtractionShooterRuntime4229938.TryShiftContainerItemToSlot(
owner, req.Container, (int)req.FromX, (int)req.FromY, req.SlotBag, req.SlotIndex);
if (ok)
{
await PushContainerAsync(session, owner, req.Container, "container->slot");
if (owner.Bags.TryGetValue(req.SlotBag, out var bag))
await PushBagAsync(session, bag, "container->slot");
await SyncWheelAfterBagChangeAsync(session, "container->slot", req.SlotBag);
}
}
session.Log.Info($"{ExtractionTag} container->slot instance={req.Container} " +
$"({req.FromX},{req.FromY}) -> bag={req.SlotBag} slot={req.SlotIndex} ok={ok}");
}
[Handler(MethodId.AskExtractionShooterShiftBagItemToContainer, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterBagItemToContainer(Connection conn, UxRpcMessage msg)
{
await conn.ReturnEmptyOkAsync(msg);
if (!TryArgs(conn, msg, out SceneMethods.ExtractionRequests.ShiftBagItemToContainer? req))
return;
await RunPutAsync(conn, msg, req.SrcBag, req.FromX, req.FromY, req.Container, req.ToX, req.ToY,
req.IsRotated, "bag->container");
}
[Handler(MethodId.AskExtractionShooterShiftSlotItemToContainer, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterSlotItemToContainer(Connection conn, UxRpcMessage msg)
{
await conn.ReturnEmptyOkAsync(msg);
if (!TryArgs(conn, msg, out SceneMethods.ExtractionRequests.ShiftSlotItemToContainer? req))
return;
// 槽位侧没有 y —— 槽位下标就是 CellX(见 runtime TryShiftContainerItemToSlot 的注释)。
await RunPutAsync(conn, msg, req.SrcBag, req.FromSlotIndex, 0, req.Container, req.ToX, req.ToY,
req.IsRotated, "slot->container");
}
private async Task RunTakeAsync(Connection conn, UxRpcMessage msg, ulong container, uint fromX, uint fromY,
uint destBag, uint toX, uint toY, bool isRotated, string phase)
{
var session = msg.Context.Session;
var ok = false;
if (ExtractionCfg().Enabled)
{
var owner = EnsureExtractionSeeded(session);
ok = ExtractionShooterRuntime4229938.TryShiftContainerItemToBag(
owner, container, (int)fromX, (int)fromY, destBag, (int)toX, (int)toY, isRotated);
if (ok)
{
await PushContainerAsync(session, owner, container, phase);
if (owner.Bags.TryGetValue(destBag, out var bag))
await PushBagAsync(session, bag, phase);
await SyncWheelAfterBagChangeAsync(session, phase, destBag);
}
}
session.Log.Info($"{ExtractionTag} {phase} instance={container} ({fromX},{fromY}) -> " +
$"bag={destBag} ({toX},{toY}) rot={isRotated} ok={ok}");
}
private async Task RunPutAsync(Connection conn, UxRpcMessage msg, uint srcBag, uint fromX, uint fromY,
ulong container, uint toX, uint toY, bool isRotated, string phase)
{
var session = msg.Context.Session;
var ok = false;
if (ExtractionCfg().Enabled)
{
var owner = EnsureExtractionSeeded(session);
ok = ExtractionShooterRuntime4229938.TryShiftBagItemToContainer(
owner, srcBag, (int)fromX, (int)fromY, container, (int)toX, (int)toY, isRotated);
if (ok)
{
if (owner.Bags.TryGetValue(srcBag, out var bag))
await PushBagAsync(session, bag, phase);
await PushContainerAsync(session, owner, container, phase);
await SyncWheelAfterBagChangeAsync(session, phase, srcBag);
}
}
session.Log.Info($"{ExtractionTag} {phase} bag={srcBag} ({fromX},{fromY}) -> " +
$"instance={container} ({toX},{toY}) rot={isRotated} ok={ok}");
}
private async Task RunMoveAsync(Connection conn, UxRpcMessage msg, ulong container, uint fromX, uint fromY,
uint toX, uint toY, bool isRotated, string phase)
{
var session = msg.Context.Session;
var ok = false;
if (ExtractionCfg().Enabled)
{
var owner = EnsureExtractionSeeded(session);
ok = ExtractionShooterRuntime4229938.TryMoveContainerItem(
owner, container, (int)fromX, (int)fromY, (int)toX, (int)toY, isRotated);
if (ok)
await PushContainerAsync(session, owner, container, phase);
}
session.Log.Info($"{ExtractionTag} {phase} instance={container} " +
$"({fromX},{fromY}) -> ({toX},{toY}) rot={isRotated} ok={ok}");
}
/// <summary>
/// <c>TryGetArgs</c> 的替代品 —— 它把异常 <b>catch 掉并返回 false</b>(见 <c>UxRpcMessage</c>),
/// 是搜打撤最大的 bug 隐身衣:契约字段数 / 顺序 / <c>Inline</c> 写错的表现全都是
/// 「处理器静默什么都不做」,日志里连一个字都没有(§14.8 三连故障的真根因)。
/// 这里失败时把<b>契约名 + 字节数 + 原始 hex</b> 全部打出来,拿 <c>packets-latest.log</c>
/// 里的 <c>C2S INVOKE</c> 对照一眼就知道差几个字段。
/// </summary>
private static bool TryArgs<T>(Connection conn, UxRpcMessage msg, [NotNullWhen(true)] out T? req)
where T : class
{
if (msg.TryGetArgs<T>(out var parsed) && parsed is not null)
{
req = parsed;
return true;
}
req = null;
conn.Log.Warn($"{ExtractionTag} {typeof(T).Name} 解析失败({msg.Body.Length}b):" +
$"{Convert.ToHexString(msg.Body)}");
return false;
}
private static async Task PushContainerAsync(TcpSession session, ExtractionShooterSessionState owner,
ulong instanceId, string phase)
{
if (!owner.Containers.TryGetValue(instanceId, out var container))
return;
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncExtractionShooterContainerInfo,
UxSerializer.Serialize(new SceneMethods.SyncExtractionShooterContainerInfoArgs4229938
{
ContainerInstanceId = instanceId,
ContainerInfo = ExtractionShooterRuntime4229938.BuildContainerInfo(container),
}), CancellationToken.None);
session.Log.Info($"{ExtractionTag} push container={instanceId} phase={phase} items={container.Items.Count}");
}
/// <summary>容器内挪位 / 原地旋转(67007990)—— 现在真的挪。</summary>
[Handler(MethodId.AskExtractionShooterShiftContainerItem, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterShiftContainerItem(Connection conn, UxRpcMessage msg)
{
await conn.ReturnEmptyOkAsync(msg);
if (!TryArgs(conn, msg, out SceneMethods.ExtractionRequests.ShiftContainerItem? req))
return;
await RunMoveAsync(conn, msg, req.Container, req.FromX, req.FromY, req.ToX, req.ToY, req.IsRotated,
"container-move");
}
/// <summary>容器内删除(67825663)—— 现在真的删。</summary>
[Handler(MethodId.AskExtractionShooterRemoveContainerItem, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterRemoveContainerItem(Connection conn, UxRpcMessage msg)
{
await conn.ReturnEmptyOkAsync(msg);
if (!TryArgs(conn, msg, out SceneMethods.ExtractionRequests.RemoveContainerItem? req))
return;
var session = msg.Context.Session;
var ok = false;
if (ExtractionCfg().Enabled)
{
var owner = EnsureExtractionSeeded(session);
ok = ExtractionShooterRuntime4229938.TryRemoveContainerItem(
owner, req.Container, (int)req.X, (int)req.Y);
if (ok)
await PushContainerAsync(session, owner, req.Container, "container-remove");
}
session.Log.Info($"{ExtractionTag} container-remove instance={req.Container} " +
$"({req.X},{req.Y}) ok={ok}");
}
/// <summary>容器内堆叠拆分(67597775,25B):u32 bag + u64 container + u32 x/y + u32 count + bool。</summary>
[Handler(MethodId.AskExtractionShooterRaidSplitItem, HandlerPacketKind.Invoke)]
private async Task OnAskExtractionShooterRaidSplitItem(Connection conn, UxRpcMessage msg)
{
var session = msg.Context.Session;
if (msg.Body.Length < 25)
{
conn.Log.Warn($"{ExtractionTag} raid-split short body bytes={msg.Body.Length}");
await conn.ReturnEmptyOkAsync(msg);
return;
}
var bagId = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(0));
var instanceId = BinaryPrimitives.ReadUInt64LittleEndian(msg.Body.AsSpan(4));
var x = (int)BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(12));
var y = (int)BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(16));
var count = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(20));
// 容器内不能出现两件同占位 ⇒ 服务端不实现"分出第二件",只按请求接住、
// 把数量按请求值回写成一件新的。这里的稳妥做法是重推容器内容。
if (ExtractionCfg().Enabled)
{
var owner = EnsureExtractionSeeded(session);
await PushContainerAsync(session, owner, instanceId, "raid-split");
}
conn.Log.Info($"{ExtractionTag} raid-split bag={bagId} instance={instanceId} ({x},{y}) count={count}");
await conn.ReturnEmptyOkAsync(msg);
}
// ------------------------------------------------------------- enter / exit
/// <summary>
/// 退出(67504722)。
///
/// ⛑ 等待房 / 主城的点也会发这条请求 ⇒ 必须先用 <see cref="IsExtractionRaid"/> 守卫,
/// 否则玩家在主城点个按钮就被扔回主城。
/// </summary>
[Handler(MethodId.AskQuitExtractionShooter, HandlerPacketKind.Both)]
private async Task OnAskQuitExtractionShooter(Connection conn, UxRpcMessage msg)
{
var session = msg.Context.Session;
var state = GetStateIfExists(session);
if (msg.IsInvoke)
await conn.ReturnEmptyOkAsync(msg);
if (state is null)
return;
uint raidId;
lock (state.SyncRoot)
raidId = state.ActiveRaidId;
if (!IsExtractionRaid(raidId))
{
conn.Log.Info($"{ExtractionTag} quit ignored (raid={raidId} is not an extraction raid)");
return;
}
var cfg = ExtractionCfg();
if (!cfg.ExitToMainCity)
return;
await ExitExtractionAsync(session, state);
}
/// <summary>
/// 退图:结算 → 清局 → 扔回主城。
/// </summary>
internal static async Task ExitExtractionAsync(TcpSession session, WorldEntryState state)
{
// ⛑ 结算必须在 SwitchSceneAsync 之前:切图一走,客户端的副本 UI 就整体卸载了,
// 这时候推 SyncRaidSettlement 是给一个已经不存在的面板发数据。
if (ExtractionCfg().ExitSettlement)
{
try
{
await PushExtractionSettlementAsync(session, true);
}
catch (Exception ex)
{
session.Log.Warn($"{ExtractionTag} settlement failed (still exiting): {ex.Message}");
}
}
var owner = ExtractionShooterRuntime4229938.StateOf(session);
ExtractionShooterRuntime4229938.Reset(owner);
// 换局 ⟹ 战利品重洗:同一个容器实例下一局不再产出同一批东西。
ExtractionShooterCatalog4229938.ReseedSessionSalt();
await SwitchSceneAsync(session, Profile.RaidId, Profile.SceneInstanceId, Profile.UniverseId,
PrivateServerConfigStore.Current.World.Spawn.X,
PrivateServerConfigStore.Current.World.Spawn.Y,
PrivateServerConfigStore.Current.World.Spawn.Z,
PrivateServerConfigStore.Current.World.Facing, "extraction-exit");
session.Log.Info($"{ExtractionTag} exit -> main city raid={Profile.RaidId}");
}
/// <summary>
/// 进图后补齐玩法侧的包结构:<c>SyncPlayerEnterExtractionShooter</c>(64811826) 空体 +
/// 各包真实容量。
///
/// 空体是必须的 —— 它是客户端把 UI 从标题切到背包 UI 的信号之一;缺它会出现
/// "进得去、但整个 HUD 不认自己在副本里"。
/// </summary>
internal static async Task PushExtractionEnterAsync(TcpSession session, WorldEntryState state)
{
if (!IsExtractionRaid(state.ActiveRaidId))
return;
var cfg = ExtractionCfg();
if (!cfg.Enabled)
return;
await session.NotifyAsync(MethodId.IGameToClient_SyncPlayerEnterExtractionShooter, [], CancellationToken.None);
var owner = ExtractionShooterRuntime4229938.StateOf(session);
ExtractionShooterRuntime4229938.Seed(owner);
ExtractionShooterCatalog4229938.ReseedSessionSalt();
foreach (var bag in owner.Bags.Values)
await PushBagCapacityAsync(session, bag, "enter-raid");
await session.NotifyAsync(MethodId.IGameToClient_SyncExtractionShooterTokenCount,
UxSerializer.Serialize(new SceneMethods.SyncExtractionShooterTokenCountArgs4229938
{
TokenCount = owner.TokenCount,
}), CancellationToken.None);
// 进图这一下最要紧:客户端刚重建过 gWeaponManager,手上只有城镇那张 16 槽表,
// 不补这一发,进图后轮盘里的枪一把都切不出来。
await PushExtractionWheelSyncAsync(session, state, "enter-raid");
session.Log.Info($"{ExtractionTag} enter raid={state.ActiveRaidId} bags={owner.Bags.Count} " +
$"token={owner.TokenCount}");
}
// ---------------------------------------------------------------- utilities
/// <summary>
/// 轮盘 bag 被改动之后的收尾:把「物品存储」的变动同步成「实体化武器」的变动。
///
/// 搜打撤里这两套是分开的 —— 往轮盘里挪进一把枪只改了 bag,Tab 切枪读的是
/// gWeaponManager.CurrWeaponSlots,不补这一步 Tab 永远只在拳头和角色自带那把之间转。
///
/// 传进来一串包 id 就行,只有真的碰到轮盘(99952004)才会动手 —— 其余包零开销。
/// </summary>
private static async Task SyncWheelAfterBagChangeAsync(TcpSession session, string phase, params uint[] touchedBags)
{
if (Array.IndexOf(touchedBags, ExtractionShooterCatalog4229938.WheelBagId) < 0)
return;
var state = GetStateIfExists(session);
if (state is null)
return;
await PushExtractionWheelSyncAsync(session, state, $"bag:{phase}");
}
/// <summary>list7bit&lt;u32&gt; 解包。7bit 计数带 +1 偏置 ⇒ 读出来的数要减一。</summary>
private static List<uint> ReadU32List7Bit(ReadOnlySpan<byte> body)
{
var result = new List<uint>();
if (body.Length == 0)
return result;
var pos = 0;
var count = ReadVarInt(body, ref pos) - 1;
if (count <= 0)
return result;
for (var i = 0; i < count && pos + 4 <= body.Length; i++)
{
result.Add(BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos, 4)));
pos += 4;
}
return result;
}
private static int ReadVarInt(ReadOnlySpan<byte> body, ref int pos)
{
var result = 0;
var shift = 0;
while (pos < body.Length)
{
var b = body[pos++];
result |= (b & 0x7F) << shift;
if ((b & 0x80) == 0)
break;
shift += 7;
if (shift > 28)
break;
}
return result;
}
}