using System.Text; using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using PoliceMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// 巡卫官(里希 / 15021021)正向「逮捕 NPC」—— 与 GameRouter.Wanted.cs 那条反向链路 /// (玩家犯罪 → 警车追 → 警车抓玩家)共用同一个 S2C,只是把 target 从玩家换成 NPC。 /// /// /// SyncPoliceBeginArrest(68839124) = { u64 agentId(执行者), u64 targetPid(被捕者), byte ArrestType } /// /// 反向链路里 agentId 是警车的 entity id、targetPid 是 Profile.PlayerPid、 /// ArrestType=1(GameRouter.Wanted.cs:723)。正向这里 agentId 换成玩家自己的 unit id。 /// /// ⛑⛑ 能确定与不能确定的,分开写清楚: /// · 能确定:68839124 是 LuaOnly、有 lua reader(RPCDeserializeAuto.lua:6986), /// 我方 Wanted.cs 已经在发且客户端吃过(玩家被捕演出成立)⇒ 帧本身是通的。 /// · 不能确定:客户端什么时候会发盘查/逮捕的 C2S。盘查面板与审讯一样挂在 /// gPoliceJobManager.cs(C#)与 Spoon 上;AskPoliceExamInteract(63233964) 在 lua 里 /// 零引用,RpcSurface.json 里也没有 ⇒ 形状未知、触发时机未知。 /// ⇒ 所以本文件做的是「把入口全都接住 + 留取证口子」,不假设客户端一定会发。 /// /// 三条取证口子(实机跑一次就有答案): /// 1. [POLICE-ARREST] exam-interact hex=… —— 盘查选项 RPC 的真实字节; /// 2. [POLICE-ARREST] interact-cmd hex=… —— 按 F 交互那条 72 字节 NOTIFY 的内容(前 5 次); /// 3. GM 面板 / POST /api/police/arrest —— 不等客户端,直接验证演出帧能不能出。 /// /// ⛑ 硬规则 94:写完 [Handler] 还要去 GameRouter.cs 的 Enabled4229938MethodIds /// 登记,否则编译通过、功能零生效,而这些都是 INVOKE(客户端会 await 到超时)。 /// internal sealed partial class GameRouter { // --------------------------------------------------------------------------------- // 常量 // --------------------------------------------------------------------------------- private const string PoliceArrestStateKey = "client4229938-police-arrest"; /// /// 与反向链路保持一致的 ArrestType=1。正向若不对,改 gameplay.policeArrest.type 试 0/2, /// 不用重新编译。 /// private const byte PoliceArrestTypeDefault = 1; /// hex 取证最多打印的字节数。 private const int ArrestHexLogBytes = 48; private sealed class PoliceArrestState { /// 本局已逮捕次数(喂 AskGetArrestTimes)。 public int Count; /// 最近一次被捕目标的 uid(同时当 pid 用,见 )。 public ulong LastTarget; } // ================================================================================= // ⓪ 「抓谁?」—— 服务端账本里的可抓目标清单 // // 2026-09-25 用户提问的根因在这里,先写死在代码里免得下次再踩: // **城里绝大多数路人是客户端本地实体,服务端根本没有它们的 id。** // 实据:AskInteractCmd CmdType=33(目标选择)每秒回报 3~4 次,s1 形如 // `8300000000000000188`(= 0x732F860653BE00BC),既不在我方 uid 段 // (0x200000000000 + ServerSpawnUidFloor 起)也不连续 ⇒ 客户端自己随机分配的单位。 // 这类目标服务端无法点名(没有稳定 id,且下一秒就换了一个)。 // // 能抓的只有服务端自己生成并推过 SyncManagedLogicAgent / SyncLogicAgentEnter 的单位: // · Ambient 行人(GameRouter.Ambient 的 Peds 账本,带服务端坐标) // · 地盘敌人(EnemyTerritory Active:uid -> spot) // · 功能 NPC(FunctionalNpc Active:uid -> spot) // · 最近一次 GM 召唤的 NPC(state.LastSummonedNpcEntityId,无坐标,按 0 距离排最前) // ⇒ 所以这里不做「对着路人按 F」,而是把账本摊开、按到玩家的距离排序点名。 // ================================================================================= /// 一个可抓目标。Distance 为米;-1 = 服务端不知道它在哪(GM 召唤的那个)。 internal readonly record struct PoliceTarget( ulong Uid, string Kind, string Label, float Distance, float X, float Y, float Z); /// /// 汇总当前会话里服务端已知的全部可抓单位,按到玩家的距离升序。 /// /// ⛑ 三个账本的 state 方法有各自的 SyncRoot,逐个 lock 取快照,不在锁里做 I/O。 /// ⛑ 玩家自己的单位(ActiveSpiritUnitId / Profile.InitialUnitId)会被剔除 —— /// 自己抓自己会走到「玩家被捕」那条演出上,与正向逮捕完全相反。 /// internal static IReadOnlyList CollectPoliceTargets(TcpSession session, int max = 24) { var list = new List(); var st = GetStateIfExists(session); float px = 0, py = 0, pz = 0; ulong selfUnit = 0; var selfIds = new HashSet(); if (st is not null) { lock (st.SyncRoot) { px = st.LastReportedPlayerPosition.X; py = st.LastReportedPlayerPosition.Y; pz = st.LastReportedPlayerPosition.Z; selfUnit = st.ActiveSpiritUnitId != 0 ? st.ActiveSpiritUnitId : Profile.InitialUnitId; // ⛑ 2026-09-25 实机:玩家「实际」单位常常是 InitialUnitId+10(100000000010), // 而 ActiveSpiritUnitId 还是 InitialUnitId(100000000000)⇒ 只比 selfUnit 一个值 // 会把玩家自己列成「瞄准」目标。这里把两个都算作自己。 selfIds.Add(selfUnit); selfIds.Add(Profile.InitialUnitId); if (st.ActiveSpiritUnitId != 0) selfIds.Add(st.ActiveSpiritUnitId); if (st.LastSummonedNpcEntityId != 0 && !selfIds.Contains(st.LastSummonedNpcEntityId)) { list.Add(new PoliceTarget(st.LastSummonedNpcEntityId, "召唤", "最近召唤的 NPC", -1f, 0, 0, 0)); } } } // ⓿ 客户端「当前瞄准的单位」—— AskInteractCmd CmdType=33 的 stringParam1, // 由 GameRouter.Vehicle.cs:280 填进 _lastInteractTargetUnitId。 // ⛑ 实测这些 id 形如 8300000000000000188(0x732F860653BE00BC),是客户端本地实体, // 服务端没有它的账本 ⇒ 抓不抓得到得实机验。列出来是为了让「我到底选中了谁」可见。 var aim = s_lastInteractTargetUnitId; // 静态镜像,见 GameRouter.Vehicle.cs 的字段注释 if (aim != 0 && !selfIds.Contains(aim)) list.Add(new PoliceTarget(aim, "瞄准", "客户端当前瞄准的单位(本地实体,多半抓不动)", -2f, 0, 0, 0)); void Add(ulong uid, string kind, string label, float x, float y, float z) { if (uid == 0 || selfIds.Contains(uid)) return; var dx = x - px; var dy = y - py; var dz = z - pz; var d = MathF.Sqrt(dx * dx + dy * dy + dz * dz); list.Add(new PoliceTarget(uid, kind, label, d, x, y, z)); } // ① Ambient 行人 —— 服务端账本里带实时坐标的那批。 var amb = AmbientState(session); lock (amb.SyncRoot) { foreach (var ped in amb.Peds) Add(ped.Id, "行人", $"路人 #{ped.Id & 0xFFFF:X}", ped.X, ped.Y, ped.Z); } // ② 地盘敌人 var turf = EnemyTerritoryStateOf(session); lock (turf.SyncRoot) { foreach (var (uid, spot) in turf.Active) Add(uid, "地盘敌人", spot.AreaName, spot.X, spot.Y, spot.Z); } // ③ 功能 NPC(36 个点:商店 / 出租 / 手机店…) var fn = FunctionalNpcStateOf(session); lock (fn.SyncRoot) { foreach (var (uid, spot) in fn.Active) Add(uid, "功能NPC", spot.Name, spot.X, spot.Y, spot.Z); } // 距离升序:-2 = 瞄准(本地实体)、-1 = GM 召唤(位置不明)排最前,其后是真·有坐标的账本单位。 return list .OrderBy(t => t.Distance) .ThenBy(t => t.Uid) .Take(Math.Max(1, max)) .ToArray(); } // ================================================================================= // ⓪b 「把人拽到面前」—— 反向解法:不挪玩家,挪 NPC // // 2026-09-25 用户第三次反馈「面前没 NPC」之后才想明白的: // **挪玩家那条路是死的**(两条都验过): // · instant(SyncUnitPositionAndFacing + SwitchSpirit):帧发了(unit=100000000000), // 玩家没动;客户端后续上报把 LastReportedPlayerPosition 覆盖回原位。 // · loading(SyncPreTeleportOption + SyncTeleport 64030755):客户端直接反序列化失败 // —— `deserialize failed mode=3 len=35 / ReadBuffer invalid size=4 remain=3 // at ReadTeleportOption`。⛑ 我方 TeleportOption **是 30B 且契约与 // RPCDeserializeAuto.lua Auto.Reader[287]、dump.cs TypeDefIndex 33016 逐字段一致** // (_ux_wire_check 已加断言并 PASS)⇒ 35B 说明多出来的字节不在 DTO 里, // 而是这条帧根本不该以 notify 形态发(它有 invoke 语义)。不值得再钻。 // **挪 NPC 是活的**:SyncUnitPositionAndFacing 本来就是服务端驱动市民走路用的帧 // (GameRouter.Civilians.cs:391,WorldCodec.Walking),NPC 是 proxy 单位, // NetworkGhostModule 只挡本地角色 ⇒ 一定能落位。 // ⇒ 所以改成:把目标 NPC 瞬移到玩家正前方 2.2 m,并让服务端账本同步改写, // 下一次 /api/police/targets 就能看到它贴在脸上。 // ================================================================================= /// 把 瞬移到玩家正前方 米处。 internal static async Task<(bool Ok, string Message)> PullTargetToPlayerAsync( TcpSession session, ulong targetUid, float frontMeters = 2.2f, CancellationToken token = default) { var st = GetStateIfExists(session); if (st is null) return (false, "还没有玩家会话(先用客户端进一次游戏)"); float px, py, pz, pf; lock (st.SyncRoot) { px = st.LastReportedPlayerPosition.X; py = st.LastReportedPlayerPosition.Y; pz = st.LastReportedPlayerPosition.Z; // ⛑ 客户端上报的是 Unity 欧拉角(度)。Civilians.cs:385 直接把度喂给 MathF.Sin 是它自己的 // 老 bug(走路方向偏),这里**不要**照抄 —— 先转弧度。 pf = st.HasLastReportedPlayerTransform ? st.LastReportedPlayerRotation.Y : Profile.WorldFacing; } var rad = pf * MathF.PI / 180f; var nx = px + MathF.Sin(rad) * frontMeters; var nz = pz + MathF.Cos(rad) * frontMeters; var ny = py; // NPC 回头面对玩家。 var npcFacing = (pf + 180f) % 360f; if (npcFacing > 180f) npcFacing -= 360f; // ① 服务端账本同步改写(否则下一次点名它还在老位置,而且服务端驱动行走会把它拽回去) var ledger = "未命中任何账本"; var amb = AmbientState(session); lock (amb.SyncRoot) { var ped = amb.Peds.FirstOrDefault(p => p.Id == targetUid); if (ped is not null) { ped.X = nx; ped.Y = ny; ped.Z = nz; ped.Walking = false; // ⛑ 不关掉的话服务端路网下一拍就把它走回去 ped.Route = null; ledger = "行人账本已改写(并停掉服务端行走)"; } } if (ledger == "未命中任何账本") { var turf = EnemyTerritoryStateOf(session); lock (turf.SyncRoot) { if (turf.Active.TryGetValue(targetUid, out var spot)) { turf.Active[targetUid] = spot with { X = nx, Y = ny, Z = nz }; ledger = "地盘敌人账本已改写"; } } } if (ledger == "未命中任何账本") { var fn = FunctionalNpcStateOf(session); lock (fn.SyncRoot) { if (fn.Active.TryGetValue(targetUid, out var spot)) { fn.Active[targetUid] = spot with { X = nx, Y = ny, Z = nz }; ledger = "功能NPC账本已改写"; } } } // ② 落位帧 —— ⛑⛑ 两拍,照 GameRouter.Civilians.cs:391 走路循环的同款,**别用硬 snap**。 // 2026-09-25 20:34 实机:只发 `PositionAndFacing`(moveId=Unknown=0、continueMove=false) // ⇒ 客户端 `ComponentMovement.OutputVelocity` 每帧抛 NullReferenceException(一秒好几次), // 被拽的行人直接"不存在" —— 用户原话「根本没有 NPC 在我面前」。 // 原因是 Unknown 不是一个合法步态,行为调度器拿到它就没有 velocity 可输出。 // ⇒ 第一拍 WalkFront + continueMove=true(让它"走过去"), // 第二拍 Idle + continueMove=false 收尾(不收尾步态会一直活着,原地踏步)。 await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing, UxSerializer.Serialize(WorldCodec.Walking(targetUid, new Vec3(nx, ny, nz), npcFacing)), token); await Task.Delay(220, token); await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing, UxSerializer.Serialize(WorldCodec.PositionAndFacing( targetUid, new Vec3(nx, ny, nz), npcFacing, WorldCodec.SetPositionType.Gm, continueMove: false, moveId: WorldCodec.MoveAction.Idle)), token); var msg = $"target={targetUid} 已拽到玩家前方 {frontMeters:F1}m:" + $"({nx:F1},{ny:F1},{nz:F1}) facing={npcFacing:F1}(玩家 ({px:F1},{py:F1},{pz:F1}) facing={pf:F1});{ledger}"; session.Log.Info($"[POLICE-PULL] {msg}"); return (true, msg); } // ================================================================================= // ① C2S:查询类(回执即可,防 await 卡死) // ================================================================================= /// /// C2S AskGetArrestTimes(63170713),INVOKE,返回 UXRPCTask<int> /// (RpcSurface.json:5907)—— 面板上的「今日逮捕次数」。回本局累计值即可。 /// [Handler(MethodId.AskGetArrestTimes, HandlerPacketKind.Invoke)] private async Task AskGetArrestTimes(Connection conn, UxRpcMessage msg) { var count = GetOrCreatePoliceArrest(msg.Context.Session).Count; await conn.ReturnAsync(msg, count); msg.Context.Session.Log.Info($"[POLICE-ARREST] AskGetArrestTimes → {count}"); } /// /// C2S AskIsArrested(63307468),INVOKE,无返回值 —— 「当前是否处于被捕状态」。 /// 我们这边玩家永远不会被自己抓,回空 OK 即可(客户端读的是 SyncPlayerArrestStateNotify)。 /// [Handler(MethodId.AskIsArrested, HandlerPacketKind.Invoke)] private async Task AskIsArrested(Connection conn, UxRpcMessage msg) { await conn.ReturnEmptyOkAsync(msg); msg.Context.Session.Log.Info("[POLICE-ARREST] AskIsArrested → false(玩家侧永远不成立)"); } /// /// C2S AskPoliceEffectiveExam(63851241),INVOKE,无返回值 —— /// 盘查链路里唯一由 lua 直接发的一条(PoliceExamineManager.lua:545, /// 盘查选项命中 4 项以上时发一次,用来触发"成功执法"演出)。 /// 回执即可;演出在客户端本地(successPoliceEffect 标志),服务端无帧可推。 /// [Handler(MethodId.AskPoliceEffectiveExam, HandlerPacketKind.Invoke)] private async Task AskPoliceEffectiveExam(Connection conn, UxRpcMessage msg) { await conn.ReturnEmptyOkAsync(msg); msg.Context.Session.Log.Info("[POLICE-ARREST] AskPoliceEffectiveExam(盘查有效)— 只回执,演出在客户端本地"); } // ================================================================================= // ② C2S AskPoliceExamInteract(63233964) —— 盘查面板的选项(形状未知,先接住再取证) // ================================================================================= /// /// 盘查面板里点下某个选项(查身份证 / 搜身 / 酒测 / 毒测 / 罚款 / 逮捕 / 放行)时由 /// C# 侧发出来的 INVOKE。 /// /// ⛑ 形状是猜的(见 AskPoliceExamInteract4229938 的注释):用 TryGetArgs, /// 解析失败也照常回执,只把 hex 打出来。等拿到真实字节再收紧契约与 option 过滤。 /// [Handler(MethodId.AskPoliceExamInteract, HandlerPacketKind.Invoke)] private async Task AskPoliceExamInteract(Connection conn, UxRpcMessage msg) { var hex = HexPrefix(msg.Body, ArrestHexLogBytes); var parsed = msg.TryGetArgs(out var args); // 先回执:INVOKE 不回 = 客户端 await 到超时,这条比什么都重要。 await conn.ReturnEmptyOkAsync(msg); if (!parsed || args is null) { msg.Context.Session.Log.Warn($"[POLICE-ARREST] exam-interact 解析失败(按 pid=0 处理)hex={hex} bytes={msg.Body.Length}"); return; } msg.Context.Session.Log.Info($"[POLICE-ARREST] exam-interact pid={args.pid} option={args.option} hex={hex}"); if (args.pid == 0) { msg.Context.Session.Log.Warn("[POLICE-ARREST] exam-interact pid=0 — 没有目标,不执行逮捕"); return; } await PerformArrestAsync(msg.Context, args.pid, $"exam-interact option={args.option}"); } // ================================================================================= // ③ C2S AskInteractCmd(67390408) —— ⛔ 别在这里加 handler,已经有了 // // GameRouter.Vehicle.cs:268 那条就是它(Notify,带完整解码): // · CmdType=19 是 2 次/秒的心跳,不打日志; // · CmdType=33 = 目标选择,stringParam1 是目标单位 id ⇒ 存进 // _lastInteractTargetUnitId; // · CmdType=34 = 交互心跳,offset 24 是交互状态。 // 2026-09-25 14:08:14 在警卫署点「审讯」抓到的那条 72 字节样本,日志里就是 // [INTERACT] cmd=… 那一行。 // ⇒ 将来要做"对着 NPC 按 F 就逮捕",目标直接用 _lastInteractTargetUnitId, // 不用重新解析这条包。本次不动它(它是全局交互通道,改了会误伤开门/上车)。 // ================================================================================= // ④ 逮捕动作本体 // ================================================================================= /// /// 推一次 SyncPoliceBeginArrest(68839124) 并记账。 /// /// ⛑ 关于 targetPid:客户端那边是 unit.Pid(见 /// PoliceExamineManager 里 gPoliceJobManager.cs:ClickOption_Story(self.unit.Pid, …)), /// 而我们服务端生成的单位手里只有 uid。反向链路用的 targetPid 是 Profile.PlayerPid /// (一个 pid 语义的值),所以这里先按 uid 当 pid 用,实机看客户端认不认; /// 不认就得回头找 uid→pid 的映射(SyncManagedLogicAgent 那条通道里有 pid)。 /// private static Task PerformArrestAsync(RpcContext ctx, ulong targetUid, string source) { var state = GetStateIfExists(ctx.Session); ulong actorUnit = 0; if (state is not null) { lock (state.SyncRoot) actorUnit = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId; } return ArrestBySessionAsync(ctx.Session, actorUnit, targetUid, source); } /// /// 逮捕本体(与调用方无关的那份)。GM 面板 / /api/police/arrest 直接走这里, /// 因为那边手里是 (session, state),没有 。 /// /// 执行者单位 id(玩家自己);传 0 ⇒ 退回 Profile.InitialUnitId。 internal static async Task ArrestBySessionAsync( TcpSession session, ulong actorUnit, ulong targetUid, string source) { var cfg = PrivateServerConfigStore.Current.Gameplay; if (!cfg.PoliceArrestEnabled) { session.Log.Info($"[POLICE-ARREST] 跳过({source})— Gameplay.PoliceArrestEnabled=false(回滚开关)"); return false; } if (actorUnit == 0) actorUnit = Profile.InitialUnitId; var st = GetOrCreatePoliceArrest(session); st.Count++; st.LastTarget = targetUid; session.Log.Info($"[POLICE-ARREST] arrest #{st.Count} target={targetUid} actor={actorUnit} type={cfg.PoliceArrestType} source={source}"); await SendPoliceArrestAsync(session, actorUnit, targetUid, (byte)cfg.PoliceArrestType); // 演出结束后把人带走:城里不该一直堆着被捕的 NPC。 // ⛑ 用 Task.Run 而不是阻塞 handler —— 延迟期间玩家可能已经断线,异常一律吞掉。 var delay = cfg.PoliceArrestRemoveDelayMs; if (!cfg.PoliceArrestRemoveEnabled || delay <= 0) return true; _ = Task.Run(async () => { try { await Task.Delay(delay).ConfigureAwait(false); // ⛑ session.NotifyAsync 的这个重载要 byte[](ctx.NotifyAsync 那个才吃对象), // 所以这里必须自己序列化 —— 与 GameRouter.Wanted.cs 里发 S2C 的写法一致。 await session.NotifyAsync(MethodId.SyncLogicAgentLeave, UxSerializer.Serialize(WorldCodec.LogicAgentLeave(targetUid)), CancellationToken.None); session.Log.Info($"[POLICE-ARREST] target={targetUid} 已带走 — SyncLogicAgentLeave sent (delay={delay}ms)"); } catch (Exception ex) { session.Log.Warn($"[POLICE-ARREST] 带走 target={targetUid} 失败(多半已断线): {ex.Message}"); } }); return true; } /// /// 发 SyncPoliceBeginArrest(68839124)。与 GameRouter.Wanted.cs:723 用的是同一帧, /// 序列化方式也保持一致(UxSerializer.Serialize + 三参数 NotifyAsync)。 /// internal static Task SendPoliceArrestAsync( TcpSession session, ulong actorUnitId, ulong targetPid, byte arrestType, CancellationToken token = default) => session.NotifyAsync(MethodId.SyncPoliceBeginArrest, UxSerializer.Serialize( new PoliceMethods.SyncPoliceBeginArrest4229938 { agentId = actorUnitId, targetPid = targetPid, ArrestType = arrestType, }), token); private static PoliceArrestState GetOrCreatePoliceArrest(TcpSession session) { if (session.Items.TryGetValue(PoliceArrestStateKey, out var raw) && raw is PoliceArrestState st) return st; st = new PoliceArrestState(); session.Items[PoliceArrestStateKey] = st; return st; } private static string HexPrefix(byte[] body, int max) { if (body is null || body.Length == 0) return "-"; var n = Math.Min(max, body.Length); var sb = new StringBuilder(n * 2 + 8); for (var i = 0; i < n; i++) sb.Append(body[i].ToString("X2")); if (body.Length > n) sb.Append("…"); return sb.ToString(); } }