using Ananta.SDK.Rpc; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using PoliceMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// 巡卫官(里希 / 15021021)执勤终端里的三条派遣,C2S 都是 AskPoliceDispatch(63500791), /// 只靠 dispatchid 区分: /// /// PoliceDispatchCatalog.json /// Id=2 NCCA Dog 巡卫犬 SkillId 51938184 / SwitchSkillId 51938186 /// Id=3 Helicopter Search 直升机搜查 SkillId 0 → 走 S2C SyncPoliceDispatchHelicopter(68359668) /// Id=4 Armed Support 战术支援 SkillId 0 Des「冲锋枪2人」,DialogId 100025472 /// /// /// 2026-09-24 抓包:按钮一直在发,服务端一直在回空 OK。 /// logs/packets-20260924-233242.log:209939 23:46:42 C2S AskPoliceDispatch id=3, /// 服务端 DefaultUnknownInvoke4229938 回 error=0 body=0b ⇒ 客户端不卡死, /// 但世界上什么也没发生。unknown-methods.log 里 09-21 还有 id=2/3/4 各一次。 /// /// 本文件只接管 dispatchid=4(战术支援)。2 走技能链(GameRouter.HackerSummon.cs), /// 3(直升机)契约只有两个参数但目前没有实体模板可刷,先留日志位。 /// /// ⛑ 硬规则 94:光写 [Handler] 不算注册,GameRouter.cs 的 /// Enabled4229938MethodIds 必须同步加 MethodId.AskPoliceDispatch, /// 否则编译通过、功能零生效,而这是个 INVOKE —— 客户端会 await 到超时。 /// internal sealed partial class GameRouter { // --------------------------------------------------------------------------------- // 常量 // --------------------------------------------------------------------------------- private const uint PoliceDispatchDog = 2; private const uint PoliceDispatchHelicopter = 3; private const uint PoliceDispatchArmedSupport = 4; /// /// 战术支援刷的两个友方模板。都来自 AgentSummonCatalog: /// /// 40120875 巡卫官(冲锋枪) EnemyClassType=1 GroupEnemy=40211004 AI=40213022 InitWeapon=98005562 /// 40120983 冲锋枪巡卫官 同上(另一个 variant,字段完全一致) /// /// 与 PoliceDispatchCatalog Id=4 的 Des="冲锋枪2人"、Number=2 对得上。 /// ⛑ 两者都不在 EnemyAiCatalog.json(3160 行)里 ⇒ SpawnAgentAsync 里 aiProfile=null, /// tree 会 fallback 到 Main_Common、武器取模板自带的 98005562、技能槽不绑。 /// 机械狗(40120911)也是同一个处境,说明这条 fallback 路径是可用的。 /// private static readonly uint[] ArmedSupportAgentIds = [40120875, 40120983]; /// per-session 当前在场支援单位的 uid 列表的 key。 private const string ArmedSupportStateKey = "client4229938-police-armed-support"; /// per-session 直升机搜查记录的 key。 private const string HelicopterScanStateKey = "client4229938-police-helicopter"; /// /// 「直升机搜查」落地用的载具模板 —— VehicleConfig.json 的 81007007 /// (Assets/Res/Prefab/Drive/Helicopter/Helicopter01a_Rope.prefab,带索降绳,比 81006001 /// "Reed·Ranger" 的素机体更贴巡卫设定;VehicleType=21、6 座、VehicleControlId=7007)。 /// /// ⛑ 它是 载具不是 agent:不在 AgentSummonCatalog 里,只能用 /// SpawnVehicleAsync(force: true) 刷(fleetIds 全是地面车,飞行载具必须 force)。 /// /// ⛑ 这是替代方案,不是官方实现的复刻:官方的直升机搜查在 build 4229938 里是半成品 /// (硬规则 134 —— 面板无 Open 入口 / SyncPoliceDispatchHelicopter 空实现 / dump.cs 无 C# 类), /// 服务端无论怎么接都只是在后台记账。刷这台载具是唯一能"让场景里真的出现一架直升机"的手段。 /// private const uint HelicopterVehicleId = 81007007; /// per-session 当前这架搜查直升机载具 uid 的 key。 private const string HelicopterVehicleStateKey = "client4229938-police-helicopter-vehicle"; /// 支援单位站位:玩家正前方 4 m,左右各 1.6 m(避免两只叠在一个坐标)。 private const float ArmedSupportForwardMetres = 4.0f; private const float ArmedSupportLateralMetres = 1.6f; // ================================================================================= // ① C2S AskPoliceDispatch(63500791) // ================================================================================= /// /// 执勤终端 / 轮盘的派遣按钮。INVOKE —— 客户端 gReliableRpcManager 等回执, /// 无论 dispatchid 认不认都得先回 empty OK,否则按钮 await 永不落地。 /// [Handler(MethodId.AskPoliceDispatch, HandlerPacketKind.Invoke)] private async Task AskPoliceDispatch(Connection conn, UxRpcMessage msg) { uint dispatchId = 0; try { dispatchId = msg.GetArgs().dispatchid; } catch (Exception ex) { // 回执照发。parse 失败只是拿不到语义,行为按"不认识"处理。 msg.Context.Session.Log.Warn($"[POLICE] AskPoliceDispatch 解析失败(按 dispatchid=0 处理): {ex.Message}"); } await conn.ReturnEmptyOkAsync(msg); await OnPoliceDispatch(msg.Context, dispatchId); } private async Task OnPoliceDispatch(RpcContext ctx, uint dispatchId) { switch (dispatchId) { case PoliceDispatchArmedSupport: await DispatchArmedSupportAsync(ctx); return; case PoliceDispatchDog: // 狗走技能链:PoliceDispatchCatalog Id=2 的 SkillId=51938184 / SwitchSkillId=51938186, // 由客户端自己 UseSkillByPid 发 AskUseSkill ⇒ GameRouter.HackerSummon.cs 接。 // 这条派遣包只是 UI 入口,服务端不需要额外动作。 ctx.Session.Log.Info($"[POLICE] dispatch={dispatchId} (NCCA Dog) — 走技能链 51938184/51938186,本通道无需动作"); return; case PoliceDispatchHelicopter: // 开一轮新的搜查:清掉上一轮的标记。 // ⛔ 别指望 SyncPoliceDispatchHelicopter(68359668) 能把直升机送进场 —— 客户端那个 // handler 是空函数体(GameSceneToClientImpl.lua:770),发了等于没发。视角与面板由 // 客户端 C# 侧自己开(lua 里 S_POLICE_HELICOPTER 没有任何 Open 调用点), // 服务端在这条玩法里真正要做的只是把下面那三个 C2S 接住。 ctx.Session.Items.Remove(HelicopterScanStateKey); ctx.Session.Log.Info($"[POLICE] dispatch={dispatchId} (Helicopter Search) — 新一轮搜查,已清空上一轮标记;等待 AskHelicopterNpc / AskHelicopterScanChange / AskPoliceStopHelicopterDispatch"); await SpawnHelicopterVehicleAsync(ctx); return; default: ctx.Session.Log.Warn($"[POLICE] dispatch={dispatchId} 未知派遣 id — 只回执,不做动作"); return; } } // ================================================================================= // ② 战术支援:刷两个友方冲锋枪巡卫官 // ================================================================================= private async Task DispatchArmedSupportAsync(RpcContext ctx) { var cfg = PrivateServerConfigStore.Current.Gameplay; if (!cfg.PoliceArmedSupportEnabled) { ctx.Session.Log.Info("[POLICE] dispatch=4 (Armed Support) skipped — Gameplay.PoliceArmedSupportEnabled=false (回滚开关)"); return; } var session = ctx.Session; var state = GetStateIfExists(session); if (state is null) { ctx.Session.Log.Warn("[POLICE] dispatch=4 (Armed Support) — 没有世界状态,跳过"); return; } // 先把上一批收掉:按钮可以反复点,不收会一直在城里堆人,且旧的再也找不回来。 await DismissArmedSupportAsync(ctx); float px, py, pz, facing; lock (state.SyncRoot) { if (state.HasLastReportedPlayerTransform) { var p = state.LastReportedPlayerPosition; px = p.X; py = p.Y; pz = p.Z; facing = state.LastReportedPlayerRotation.Y; } else { px = py = pz = facing = 0f; } } // forward = (sin f, cos f);right = (cos f, -sin f)。与 GameAdminBridge 的扇形摆放同源。 // ⛑ facing 是度数,必须先转弧度(2026-09-26 修,同 SpawnAgentAsync 那处)。 var yaw = facing * (float)(Math.PI / 180.0); var fx = (float)Math.Sin(yaw); var fz = (float)Math.Cos(yaw); var rx = (float)Math.Cos(yaw); var rz = -(float)Math.Sin(yaw); var spawned = new List(); for (var i = 0; i < ArmedSupportAgentIds.Length; i++) { var lateral = (i == 0 ? -1f : 1f) * ArmedSupportLateralMetres; var x = px + fx * ArmedSupportForwardMetres + rx * lateral; var z = pz + fz * ArmedSupportForwardMetres + rz * lateral; var agentId = ArmedSupportAgentIds[i]; var result = await GameAdminBridge.SpawnAgentAsync(agentId, x, py, z, facing, hostile: true, // EnemyClassType=1 ⇒ 必须 true,否则 entry.Hostile 校验直接拒 engage: false, // 不注册进敌人账本:友军不该被打死,也不需要服务端拉去战斗 forceEnemyRelation: false, // ⛑ 别把玩家和友军标成敌对(否则能锁定、挥刀不掉血) // ⛑ 2026-10-01:原来是硬编码 0(MonsterDefault)⇒ 玩家能锁定自己人、头顶还挂血条。 // 与嘉姆随从同一处理,改读 gameplay.aether.companionCamp(默认 1 = PlayerDefault)。 campOverride: PrivateServerConfigStore.Current.Gameplay.Aether.CompanionCamp); if (!result.Ok || result.Uid == 0) { ctx.Session.Log.Warn($"[POLICE] armed-support spawn failed agent={agentId} reason={result.Message}"); continue; } spawned.Add(result.Uid); ctx.Session.Log.Info($"[POLICE] armed-support spawned agent={agentId} uid={result.Uid} at=({x:F2},{py:F2},{z:F2}) facing={facing:F2}"); // ⛑⛑ 2026-10-01(用户点名):把这两名支援接入和嘉姆随从同一套 AI // —— 跟随 + 协战 + 收血条 + 清瞄准态,见 GameRouter.CompanionBind.cs。 // 不接的话他们只会杵在原地(spawn 后服务端一个动作指令都不发)。 // ⛑ 必须等 spawn 包落地再推:客户端消费方当场拿 uid 去 SceneDataMgr 查单位。 if (PrivateServerConfigStore.Current.Gameplay.Aether.CompanionBindArmedSupport) { await Task.Delay(250); var (bindOk, bindMsg) = await BindCompanionAsync(result.Uid); ctx.Session.Log.Info($"[POLICE] armed-support companion bind: {(bindOk ? "OK" : "FAILED")} {bindMsg}"); } } if (spawned.Count == 0) { ctx.Session.Log.Warn("[POLICE] dispatch=4 (Armed Support) — 两只都没刷出来,不记账"); return; } session.Items[ArmedSupportStateKey] = spawned; ctx.Session.Log.Info($"[POLICE] dispatch=4 (Armed Support) — {spawned.Count}/{ArmedSupportAgentIds.Length} 名支援巡卫官就位 uid=[{string.Join(",", spawned)}]"); } /// /// 收掉当前在场的支援单位。SpawnAgentAsync 出来的单位配对的移除通道是 /// SyncLogicAgentLeave(68281244)(第一句话是 SyncLogicAgentEnter)—— 与 /// GameRouter.HackerSummon.cs 里回收召唤物用的是同一条,别用 SyncAetherAINpcRemove(那是 /// ambient 行人 / functional NPC 的通道,对这类单位发了等于没发)。 /// private async Task DismissArmedSupportAsync(RpcContext ctx) { if (!ctx.Session.Items.TryGetValue(ArmedSupportStateKey, out var raw) || raw is not List uids) return; ctx.Session.Items.Remove(ArmedSupportStateKey); if (uids.Count == 0) return; foreach (var uid in uids) { // 先停掉跟随/协战 worker,再撤场 —— 否则 worker 会对着已经消失的单位继续发包。 StopCompanionAssist(uid); await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(uid)); } ctx.Session.Log.Info($"[POLICE] armed-support dismissed {uids.Count} uid=[{string.Join(",", uids)}] — SyncLogicAgentLeave sent"); } // ================================================================================= // ②b 直升机载具:让场景里真的出现一架直升机 // // ⛑ 这是替代方案,不是官方实现的复刻 —— 官方直升机搜查面板在 build 4229938 里是半成品 // (硬规则 134:无 Open 入口 / SyncPoliceDispatchHelicopter 空实现 / dump.cs 无 C# 类), // 服务端再怎么接也只是在后台记账。刷这台载具是唯一能"看见直升机"的手段。 // ================================================================================= /// /// 刷一台搜查直升机载具(81007007 Helicopter01a_Rope)。 /// /// ⛑ SpawnDirectAsync 内部自带 replacement:刷之前会把 runtime.SummonedEntity /// 上那台(可能是玩家自己召唤的车)DestroyVehicleAsync 掉。这里只提前打一行 /// warn,不拦 —— 玩家点「直升机搜查」时通常没在开车,而且顶掉比堆车好。 /// /// 落点是玩家右侧 5.5 m、+0.15 m(SpawnDirectAsync 里的 proven offset)。对直升机来说 /// 就是停在旁边地面上,不需要抬高度(抬了反而会卡在半空下不来)。 /// private static async Task SpawnHelicopterVehicleAsync(RpcContext ctx) { if (!PrivateServerConfigStore.Current.Gameplay.PoliceHelicopterVehicleEnabled) { ctx.Session.Log.Info($"[POLICE-HELI] vehicle spawn skipped — Gameplay.PoliceHelicopterVehicleEnabled=false (回滚开关)"); return false; } ulong prev; var runtime = GetVehicleRuntimeState(ctx.Session); lock (runtime.SyncRoot) prev = runtime.SummonedEntity; if (prev != 0) ctx.Session.Log.Warn($"[POLICE-HELI] 上一台召唤载具 uid={prev} 将被直升机顶掉(SpawnDirectAsync 的 replacement 逻辑)"); var result = await GameAdminBridge.SpawnVehicleAsync(HelicopterVehicleId, force: true); if (!result.Ok || result.Uid == 0) { ctx.Session.Log.Warn($"[POLICE-HELI] helicopter vehicle spawn failed id={HelicopterVehicleId} reason={result.Message}"); return false; } ctx.Session.Items[HelicopterVehicleStateKey] = result.Uid; ctx.Session.Log.Info($"[POLICE-HELI] helicopter vehicle spawned id={HelicopterVehicleId} (Helicopter01a_Rope) uid={result.Uid} — 停在玩家右侧 5.5 m"); return true; } /// /// 收掉这架搜查直升机(一轮搜查结束时调)。 /// 走 DestroyVehicleAsync ⇒ S2C SyncDestroyVehicle,与玩家召唤车 / 交通车 /// 用的是同一条通道,不是 SyncLogicAgentLeave(那是 agent 的)。 /// private static async Task DismissHelicopterVehicleAsync(RpcContext ctx) { if (!ctx.Session.Items.TryGetValue(HelicopterVehicleStateKey, out var raw) || raw is not ulong uid || uid == 0) return; ctx.Session.Items.Remove(HelicopterVehicleStateKey); var runtime = GetVehicleRuntimeState(ctx.Session); await DestroyVehicleAsync(ctx.Session, runtime, uid, "police-helicopter-stop"); ctx.Session.Log.Info($"[POLICE-HELI] helicopter vehicle uid={uid} destroyed — SyncDestroyVehicle sent"); } // ================================================================================= // ③ 直升机搜查:AskHelicopterNpc / AskHelicopterScanChange / AskPoliceStopHelicopterDispatch // ================================================================================= /// /// 一轮直升机搜查的服务端记账。官方玩法里这些标记会喂给"盘查 / 通缉 / 案件"那条链, /// 我们那条链是空的,所以先只把玩家框过的单位记下来 —— 至少让 RPC 有回执、有日志可查。 /// private sealed class HelicopterScanState { /// 扫描开关(AskHelicopterScanChange)。 public bool ScanOpen; /// instanceid → 最后一次 optype。 public Dictionary Marks = new(); } /// /// C2S AskHelicopterNpc(67113443),INVOKE —— 玩家在直升机视角里框住一个 NPC 并下判定。 /// /// ⛑⛑ 这条以前不在 Enabled4229938MethodIds 里,等于走中性兜底;而它是 INVOKE, /// 客户端四个调用点(PoliceHelicopterPanelStore.lua:162 / :378 / :470 / :558)全在用 /// gReliableRpcManager:RegisterRPC 等回调 ⇒ 面板会卡在"点了没反应"。 /// 硬规则 94 的老坑,别再踩。 /// [Handler(MethodId.AskHelicopterNpc, HandlerPacketKind.Invoke)] private async Task AskHelicopterNpc(Connection conn, UxRpcMessage msg) { ulong instanceId = 0; byte optype = 0; try { var args = msg.GetArgs(); instanceId = args.instanceid; optype = args.optype; } catch (Exception ex) { msg.Context.Session.Log.Warn($"[POLICE-HELI] AskHelicopterNpc 解析失败: {ex.Message}"); } await conn.ReturnEmptyOkAsync(msg); if (!PrivateServerConfigStore.Current.Gameplay.PoliceHelicopterEnabled) { msg.Context.Session.Log.Info($"[POLICE-HELI] npc uid={instanceId} optype={optype} skipped — Gameplay.PoliceHelicopterEnabled=false(只回执,不记账)"); return; } var state = GetOrCreateHelicopterScan(msg.Context); lock (state.Marks) state.Marks[instanceId] = optype; msg.Context.Session.Log.Info($"[POLICE-HELI] npc uid={instanceId} optype={optype} scan={(state.ScanOpen ? "on" : "off")} — 本轮已标记 {state.Marks.Count} 个单位"); } /// /// C2S AskHelicopterScanChange(67898923),NOTIFY —— 直升机扫描开关。 /// 客户端用的是 self.Notify(ClientToGameSceneDelegate.lua:5198),没有回执; /// 这里登记 Both 兜住,将来客户端改成 Invoke 也不用改代码(AskControlAgent 那次就是这么栽的)。 /// [Handler(MethodId.AskHelicopterScanChange, HandlerPacketKind.Both)] private async Task AskHelicopterScanChange(Connection conn, UxRpcMessage msg) { bool open = false; try { open = msg.GetArgs().open; } catch (Exception ex) { msg.Context.Session.Log.Warn($"[POLICE-HELI] AskHelicopterScanChange 解析失败(按 open=false 处理): {ex.Message}"); } // 与 AskControlAgent(HackerSummon.cs)同一套做法:Both 注册的 handler 无条件回执。 // notify 方向多一个回执包客户端不会消费,反过来漏了 invoke 的回执会挂死,宁可多发。 await conn.ReturnEmptyOkAsync(msg); if (!PrivateServerConfigStore.Current.Gameplay.PoliceHelicopterEnabled) { msg.Context.Session.Log.Info($"[POLICE-HELI] scan open={open} skipped — Gameplay.PoliceHelicopterEnabled=false"); return; } GetOrCreateHelicopterScan(msg.Context).ScanOpen = open; msg.Context.Session.Log.Info($"[POLICE-HELI] scan open={open}"); } /// /// C2S AskPoliceStopHelicopterDispatch(63057829),INVOKE,无参 —— 一轮搜查结束 /// (客户端在"扫描成功"后延迟 1 s 发它,见 PoliceHelicopterPanelStore.lua:483)。 /// /// 收到后清账,并推一次 S2C SynExitHelicopterView(68309986) 收视角。客户端那个 /// handler 就是 Close(gPanelId.S_POLICE_HELICOPTER),幂等 —— 面板已经关了再 Close 一次无害。 /// [Handler(MethodId.AskPoliceStopHelicopterDispatch, HandlerPacketKind.Invoke)] private async Task AskPoliceStopHelicopterDispatch(Connection conn, UxRpcMessage msg) { await conn.ReturnEmptyOkAsync(msg); var marked = 0; if (msg.Context.Session.Items.TryGetValue(HelicopterScanStateKey, out var raw) && raw is HelicopterScanState st) { lock (st.Marks) marked = st.Marks.Count; msg.Context.Session.Items.Remove(HelicopterScanStateKey); } if (!PrivateServerConfigStore.Current.Gameplay.PoliceHelicopterEnabled) { msg.Context.Session.Log.Info($"[POLICE-HELI] stop — skipped(只回执,不推 SynExitHelicopterView)"); return; } // 一轮搜查结束 ⇒ 把那架载具也收掉,否则城里会一直停着直升机。 await DismissHelicopterVehicleAsync(msg.Context); await msg.Context.NotifyAsync(MethodId.SynExitHelicopterView, new PoliceMethods.SynExitHelicopterView4229938()); msg.Context.Session.Log.Info($"[POLICE-HELI] stop — 本轮标记 {marked} 个单位已清空,SynExitHelicopterView(68309986) 已下发"); } private static HelicopterScanState GetOrCreateHelicopterScan(RpcContext ctx) { if (ctx.Session.Items.TryGetValue(HelicopterScanStateKey, out var raw) && raw is HelicopterScanState st) return st; st = new HelicopterScanState(); ctx.Session.Items[HelicopterScanStateKey] = st; return st; } }