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; /// /// 搜打撤(ExtractionShooter,本季「不要抬头!」)的服务端实现。 /// /// 移植自捐赠端 handoff AI_HANDOFF不要抬头2.md(第二版),只取第一轮范围: /// ① 进图 / 退图 ② 容器搜索与刷物 ③ 背包搬运 / 丢弃 / 锁定 / 排序 / 拆分 / 扩容 / 入仓 /// 不做武器实体化(轮盘 Tab 切枪)—— 那要 SyncSpiritWeaponDetail 整表,是第二轮。 /// /// 三条先读再加代码: /// - ⛑️ [Handler] ≠ 已注册:不写进 GameRouter.cs 的 /// Enabled4229938MethodIds 就是空注册 —— 请求不报错,只有一行 /// unimplemented ... typed-default void 0b,且 unknown-methods.log 里反而没有。 /// - ⛑️ 请求-应答型 RPC 必须回等价结构:midToReturnMessageReader[mid] 有 reader 的 /// (典型是 SearchContainer/67410186,要求 ReadComplex(Reader[530])),回 EmptyOk 就是用 nil /// 覆盖客户端本地那份数据 —— 容器曾经就这么坏掉。 /// - ⛑️ 容器 carrying 的实例 id 是客户端给的(8 字节),服务端按它确定性选容器形体; /// 所以同一实例在这一局里怎么搜都一致,换局才会重洗。 /// internal sealed partial class GameRouter { private const string ExtractionTag = "[EXTRACTION]"; // ---------------------------------------------------------------- helpers private static ExtractionShooterSettings ExtractionCfg() => PrivateServerConfigStore.Current.World.Content.Extraction; /// 这个 raid 是不是搜打撤图。表里没有 raid↔图外键 ⇒ 用配置里的 raid 名单。 internal static bool IsExtractionRaid(uint raidId) => raidId != 0 && ExtractionCfg().Enabled && ExtractionCfg().RaidIds.Contains(raidId); private static ExtractionShooterSessionState ExtractionStateOf(TcpSession session) => ExtractionShooterRuntime4229938.StateOf(session); /// /// 首次用到时建包。为了避免 登录 flush 前就把包柜塞满,这里只做一次。 /// 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 /// ClearBag 逐件 SetItemInfo 的整包重推 —— 只改了一格也要整包,客户端没有单删。 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 /// /// 搜索容器(67410186,Invoke)。 /// /// ⛑ 这条必须回真正的包体:midToReturnMessageReader[67410186] 要求 /// ReadComplex(Reader[530]),回 EmptyOk 会拿 nil 覆盖客户端本地那份 ContainerInfo。 /// 回完之后再补一条 SyncExtractionShooterContainerInfo(68820459) 走推送路径, /// 两条同源同一个 BuildContainerInfo。 /// [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"); } } /// /// 容器读条的收尾:seconds 秒后把这个 pid 的时间戳翻成过去,再推同一份容器数据。 /// /// ⛑️ seconds 必须 > 0(取整箱最长 SearchTime)—— 传 0 会立刻揭晓,读条动画播不出来。 /// 这是一次性的延时推,不是循环,但仍然要过 门控 /// (硬规则 37):等这几秒里客户端可能已经挂起,那时推等于往不消费的 TCP 里灌包。 /// 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}"); } /// /// 结束搜索(67701506)。客户端读条走完自己发的收尾。 /// /// ⛑️ 不能只 ack:这一条是「时间戳该翻回过去了」的信号,服务端必须真的 /// 再补推一帧, /// 否则客户端手上那份还是未来时间戳 ⇒ 物品不显示。 /// (旧实现在这里写的是「服务端无状态可清」,是错的 —— 状态就是那个时间戳。) /// [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); } /// /// 六合一搬运(63749738,25B):装备 / 卸下 / 放入安全箱 / 携带 / 放回仓库 / 旋转 /// 全走这一条,区别只是 destBag。修一条等于修全部。 /// [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); } /// /// 扩容(63115121,4B)。唯一的扩容入口 —— 客户端没有"直接换包"请求。 /// Type=1 给目标包加一行 ⇒ 要回 ChangeBagCapacity;Type=2 解锁新仓库。 /// [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); } /// 全包整理(63009939,list7bit<u32>)。逐包重排后各自整包重推。 [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:先把 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 契约,并且解析失败要打出来(见 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 位时是容器内原地旋转,不是往包里搬。 // 不识别的话:玩家点旋转,服务端会把那件物品当成「搬到包里」处理,容器视图也不会刷新。 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}"); } /// /// TryGetArgs 的替代品 —— 它把异常 catch 掉并返回 false(见 UxRpcMessage), /// 是搜打撤最大的 bug 隐身衣:契约字段数 / 顺序 / Inline 写错的表现全都是 /// 「处理器静默什么都不做」,日志里连一个字都没有(§14.8 三连故障的真根因)。 /// 这里失败时把契约名 + 字节数 + 原始 hex 全部打出来,拿 packets-latest.log /// 里的 C2S INVOKE 对照一眼就知道差几个字段。 /// private static bool TryArgs(Connection conn, UxRpcMessage msg, [NotNullWhen(true)] out T? req) where T : class { if (msg.TryGetArgs(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}"); } /// 容器内挪位 / 原地旋转(67007990)—— 现在真的挪。 [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"); } /// 容器内删除(67825663)—— 现在真的删。 [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}"); } /// 容器内堆叠拆分(67597775,25B):u32 bag + u64 container + u32 x/y + u32 count + bool。 [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 /// /// 退出(67504722)。 /// /// ⛑ 等待房 / 主城的点也会发这条请求 ⇒ 必须先用 守卫, /// 否则玩家在主城点个按钮就被扔回主城。 /// [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); } /// /// 退图:结算 → 清局 → 扔回主城。 /// 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}"); } /// /// 进图后补齐玩法侧的包结构:SyncPlayerEnterExtractionShooter(64811826) 空体 + /// 各包真实容量。 /// /// 空体是必须的 —— 它是客户端把 UI 从标题切到背包 UI 的信号之一;缺它会出现 /// "进得去、但整个 HUD 不认自己在副本里"。 /// 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 /// /// 轮盘 bag 被改动之后的收尾:把「物品存储」的变动同步成「实体化武器」的变动。 /// /// 搜打撤里这两套是分开的 —— 往轮盘里挪进一把枪只改了 bag,Tab 切枪读的是 /// gWeaponManager.CurrWeaponSlots,不补这一步 Tab 永远只在拳头和角色自带那把之间转。 /// /// 传进来一串包 id 就行,只有真的碰到轮盘(99952004)才会动手 —— 其余包零开销。 /// 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}"); } /// list7bit<u32> 解包。7bit 计数带 +1 偏置 ⇒ 读出来的数要减一。 private static List ReadU32List7Bit(ReadOnlySpan body) { var result = new List(); 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 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; } }