490 lines
25 KiB
C#
490 lines
25 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 巡卫官(里希 / 15021021)正向「逮捕 NPC」—— 与 <c>GameRouter.Wanted.cs</c> 那条反向链路
|
||
/// (玩家犯罪 → 警车追 → 警车抓玩家)共用同一个 S2C,只是把 target 从玩家换成 NPC。
|
||
///
|
||
/// <code>
|
||
/// SyncPoliceBeginArrest(68839124) = { u64 agentId(执行者), u64 targetPid(被捕者), byte ArrestType }
|
||
/// </code>
|
||
/// 反向链路里 agentId 是<b>警车</b>的 entity id、targetPid 是 <c>Profile.PlayerPid</c>、
|
||
/// ArrestType=1(<c>GameRouter.Wanted.cs:723</c>)。正向这里 agentId 换成玩家自己的 unit id。
|
||
///
|
||
/// ⛑⛑ <b>能确定与不能确定的,分开写清楚:</b>
|
||
/// · 能确定:68839124 是 LuaOnly、有 lua reader(<c>RPCDeserializeAuto.lua:6986</c>),
|
||
/// 我方 Wanted.cs 已经在发且客户端吃过(玩家被捕演出成立)⇒ 帧本身是通的。
|
||
/// · 不能确定:<b>客户端什么时候会发盘查/逮捕的 C2S</b>。盘查面板与审讯一样挂在
|
||
/// <c>gPoliceJobManager.cs</c>(C#)与 Spoon 上;<c>AskPoliceExamInteract</c>(63233964) 在 lua 里
|
||
/// 零引用,<c>RpcSurface.json</c> 里也没有 ⇒ 形状未知、触发时机未知。
|
||
/// ⇒ 所以本文件做的是「把入口全都接住 + 留取证口子」,<b>不假设客户端一定会发</b>。
|
||
///
|
||
/// 三条取证口子(实机跑一次就有答案):
|
||
/// 1. <c>[POLICE-ARREST] exam-interact hex=…</c> —— 盘查选项 RPC 的真实字节;
|
||
/// 2. <c>[POLICE-ARREST] interact-cmd hex=…</c> —— 按 F 交互那条 72 字节 NOTIFY 的内容(前 5 次);
|
||
/// 3. GM 面板 / <c>POST /api/police/arrest</c> —— 不等客户端,直接验证演出帧能不能出。
|
||
///
|
||
/// ⛑ 硬规则 94:写完 [Handler] 还要去 <c>GameRouter.cs</c> 的 <c>Enabled4229938MethodIds</c>
|
||
/// 登记,否则编译通过、功能零生效,而这些都是 INVOKE(客户端会 await 到超时)。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
// ---------------------------------------------------------------------------------
|
||
// 常量
|
||
// ---------------------------------------------------------------------------------
|
||
|
||
private const string PoliceArrestStateKey = "client4229938-police-arrest";
|
||
|
||
/// <summary>
|
||
/// 与反向链路保持一致的 ArrestType=1。正向若不对,改 <c>gameplay.policeArrest.type</c> 试 0/2,
|
||
/// 不用重新编译。
|
||
/// </summary>
|
||
private const byte PoliceArrestTypeDefault = 1;
|
||
|
||
/// <summary>hex 取证最多打印的字节数。</summary>
|
||
private const int ArrestHexLogBytes = 48;
|
||
|
||
private sealed class PoliceArrestState
|
||
{
|
||
/// <summary>本局已逮捕次数(喂 <c>AskGetArrestTimes</c>)。</summary>
|
||
public int Count;
|
||
|
||
/// <summary>最近一次被捕目标的 uid(同时当 pid 用,见 <see cref="SendPoliceArrestAsync"/>)。</summary>
|
||
public ulong LastTarget;
|
||
}
|
||
|
||
// =================================================================================
|
||
// ⓪ 「抓谁?」—— 服务端账本里的可抓目标清单
|
||
//
|
||
// 2026-09-25 用户提问的根因在这里,先写死在代码里免得下次再踩:
|
||
// **城里绝大多数路人是客户端本地实体,服务端根本没有它们的 id。**
|
||
// 实据:AskInteractCmd CmdType=33(目标选择)每秒回报 3~4 次,s1 形如
|
||
// `8300000000000000188`(= 0x732F860653BE00BC),既不在我方 uid 段
|
||
// (0x200000000000 + ServerSpawnUidFloor 起)也不连续 ⇒ 客户端自己随机分配的单位。
|
||
// 这类目标服务端<b>无法点名</b>(没有稳定 id,且下一秒就换了一个)。
|
||
//
|
||
// 能抓的只有<b>服务端自己生成并推过 SyncManagedLogicAgent / SyncLogicAgentEnter 的单位</b>:
|
||
// · Ambient 行人(GameRouter.Ambient 的 Peds 账本,带服务端坐标)
|
||
// · 地盘敌人(EnemyTerritory Active:uid -> spot)
|
||
// · 功能 NPC(FunctionalNpc Active:uid -> spot)
|
||
// · 最近一次 GM 召唤的 NPC(state.LastSummonedNpcEntityId,无坐标,按 0 距离排最前)
|
||
// ⇒ 所以这里不做「对着路人按 F」,而是把账本摊开、按到玩家的距离排序点名。
|
||
// =================================================================================
|
||
|
||
/// <summary>一个可抓目标。<c>Distance</c> 为米;-1 = 服务端不知道它在哪(GM 召唤的那个)。</summary>
|
||
internal readonly record struct PoliceTarget(
|
||
ulong Uid,
|
||
string Kind,
|
||
string Label,
|
||
float Distance,
|
||
float X,
|
||
float Y,
|
||
float Z);
|
||
|
||
/// <summary>
|
||
/// 汇总当前会话里服务端已知的全部可抓单位,按到玩家的距离升序。
|
||
///
|
||
/// ⛑ 三个账本的 state 方法有各自的 SyncRoot,逐个 lock 取快照,不在锁里做 I/O。
|
||
/// ⛑ 玩家自己的单位(<c>ActiveSpiritUnitId</c> / <c>Profile.InitialUnitId</c>)会被剔除 ——
|
||
/// 自己抓自己会走到「玩家被捕」那条演出上,与正向逮捕完全相反。
|
||
/// </summary>
|
||
internal static IReadOnlyList<PoliceTarget> CollectPoliceTargets(TcpSession session, int max = 24)
|
||
{
|
||
var list = new List<PoliceTarget>();
|
||
|
||
var st = GetStateIfExists(session);
|
||
float px = 0, py = 0, pz = 0;
|
||
ulong selfUnit = 0;
|
||
var selfIds = new HashSet<ulong>();
|
||
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),是<b>客户端本地实体</b>,
|
||
// 服务端没有它的账本 ⇒ 抓不抓得到得实机验。列出来是为了让「我到底选中了谁」可见。
|
||
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 就能看到它贴在脸上。
|
||
// =================================================================================
|
||
|
||
/// <summary>把 <paramref name="targetUid"/> 瞬移到玩家正前方 <paramref name="frontMeters"/> 米处。</summary>
|
||
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 卡死)
|
||
// =================================================================================
|
||
|
||
/// <summary>
|
||
/// C2S <c>AskGetArrestTimes</c>(63170713),INVOKE,返回 <c>UXRPCTask<int></c>
|
||
/// (RpcSurface.json:5907)—— 面板上的「今日逮捕次数」。回本局累计值即可。
|
||
/// </summary>
|
||
[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}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// C2S <c>AskIsArrested</c>(63307468),INVOKE,无返回值 —— 「当前是否处于被捕状态」。
|
||
/// 我们这边玩家永远不会被自己抓,回空 OK 即可(客户端读的是 <c>SyncPlayerArrestStateNotify</c>)。
|
||
/// </summary>
|
||
[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(玩家侧永远不成立)");
|
||
}
|
||
|
||
/// <summary>
|
||
/// C2S <c>AskPoliceEffectiveExam</c>(63851241),INVOKE,无返回值 ——
|
||
/// <b>盘查链路里唯一由 lua 直接发的一条</b>(<c>PoliceExamineManager.lua:545</c>,
|
||
/// 盘查选项命中 4 项以上时发一次,用来触发"成功执法"演出)。
|
||
/// 回执即可;演出在客户端本地(<c>successPoliceEffect</c> 标志),服务端无帧可推。
|
||
/// </summary>
|
||
[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) —— 盘查面板的选项(形状未知,先接住再取证)
|
||
// =================================================================================
|
||
|
||
/// <summary>
|
||
/// 盘查面板里点下某个选项(查身份证 / 搜身 / 酒测 / 毒测 / 罚款 / 逮捕 / 放行)时由
|
||
/// <b>C# 侧</b>发出来的 INVOKE。
|
||
///
|
||
/// ⛑ 形状是猜的(见 <c>AskPoliceExamInteract4229938</c> 的注释):用 <c>TryGetArgs</c>,
|
||
/// 解析失败也照常回执,只把 hex 打出来。等拿到真实字节再收紧契约与 option 过滤。
|
||
/// </summary>
|
||
[Handler(MethodId.AskPoliceExamInteract, HandlerPacketKind.Invoke)]
|
||
private async Task AskPoliceExamInteract(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var hex = HexPrefix(msg.Body, ArrestHexLogBytes);
|
||
var parsed = msg.TryGetArgs<PoliceMethods.AskPoliceExamInteract4229938>(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 ⇒ 存进
|
||
// <c>_lastInteractTargetUnitId</c>;
|
||
// · CmdType=34 = 交互心跳,offset 24 是交互状态。
|
||
// 2026-09-25 14:08:14 在警卫署点「审讯」抓到的那条 72 字节样本,日志里就是
|
||
// <c>[INTERACT] cmd=…</c> 那一行。
|
||
// ⇒ 将来要做"对着 NPC 按 F 就逮捕",目标直接用 <c>_lastInteractTargetUnitId</c>,
|
||
// 不用重新解析这条包。本次不动它(它是全局交互通道,改了会误伤开门/上车)。
|
||
// =================================================================================
|
||
// ④ 逮捕动作本体
|
||
// =================================================================================
|
||
|
||
/// <summary>
|
||
/// 推一次 <c>SyncPoliceBeginArrest</c>(68839124) 并记账。
|
||
///
|
||
/// ⛑ 关于 <c>targetPid</c>:客户端那边是 <c>unit.Pid</c>(见
|
||
/// <c>PoliceExamineManager</c> 里 <c>gPoliceJobManager.cs:ClickOption_Story(self.unit.Pid, …)</c>),
|
||
/// 而我们服务端生成的单位手里只有 <b>uid</b>。反向链路用的 targetPid 是 <c>Profile.PlayerPid</c>
|
||
/// (一个 pid 语义的值),所以这里<b>先按 uid 当 pid 用</b>,实机看客户端认不认;
|
||
/// 不认就得回头找 uid→pid 的映射(<c>SyncManagedLogicAgent</c> 那条通道里有 pid)。
|
||
/// </summary>
|
||
private static Task<bool> 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 逮捕本体(与调用方无关的那份)。GM 面板 / <c>/api/police/arrest</c> 直接走这里,
|
||
/// 因为那边手里是 <c>(session, state)</c>,没有 <see cref="RpcContext"/>。
|
||
/// </summary>
|
||
/// <param name="actorUnit">执行者单位 id(玩家自己);传 0 ⇒ 退回 <c>Profile.InitialUnitId</c>。</param>
|
||
internal static async Task<bool> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 发 <c>SyncPoliceBeginArrest</c>(68839124)。与 <c>GameRouter.Wanted.cs:723</c> 用的是同一帧,
|
||
/// 序列化方式也保持一致(<c>UxSerializer.Serialize</c> + 三参数 NotifyAsync)。
|
||
/// </summary>
|
||
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();
|
||
}
|
||
}
|