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

930 lines
41 KiB
C#
Raw Normal View History

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;
}
}