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