Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.PoliceDispatch.cs
T

445 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 巡卫官(里希 / 15021021)执勤终端里的三条派遣,C2S 都是 <c>AskPoliceDispatch</c>(63500791),
/// 只靠 <c>dispatchid</c> 区分:
/// <code>
/// 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
/// </code>
///
/// <b>2026-09-24 抓包:按钮一直在发,服务端一直在回空 OK。</b>
/// <c>logs/packets-20260924-233242.log:209939</c> 23:46:42 C2S AskPoliceDispatch id=3,
/// 服务端 <c>DefaultUnknownInvoke4229938</c> 回 <c>error=0 body=0b</c> ⇒ 客户端不卡死,
/// 但世界上什么也没发生。<c>unknown-methods.log</c> 里 09-21 还有 id=2/3/4 各一次。
///
/// 本文件只接管 dispatchid=4(战术支援)。2 走技能链(GameRouter.HackerSummon.cs),
/// 3(直升机)契约只有两个参数但目前没有实体模板可刷,先留日志位。
///
/// ⛑ 硬规则 94:光写 [Handler] 不算注册,<c>GameRouter.cs</c> 的
/// <c>Enabled4229938MethodIds</c> 必须同步加 <c>MethodId.AskPoliceDispatch</c>,
/// 否则编译通过、功能零生效,而这是个 INVOKE —— 客户端会 await 到超时。
/// </summary>
internal sealed partial class GameRouter
{
// ---------------------------------------------------------------------------------
// 常量
// ---------------------------------------------------------------------------------
private const uint PoliceDispatchDog = 2;
private const uint PoliceDispatchHelicopter = 3;
private const uint PoliceDispatchArmedSupport = 4;
/// <summary>
/// 战术支援刷的两个友方模板。都来自 AgentSummonCatalog:
/// <code>
/// 40120875 巡卫官(冲锋枪) EnemyClassType=1 GroupEnemy=40211004 AI=40213022 InitWeapon=98005562
/// 40120983 冲锋枪巡卫官 同上(另一个 variant,字段完全一致)
/// </code>
/// 与 PoliceDispatchCatalog Id=4 的 <c>Des="冲锋枪2人"</c>、<c>Number=2</c> 对得上。
/// ⛑ 两者都<b>不在</b> EnemyAiCatalog.json(3160 行)里 ⇒ SpawnAgentAsync 里 aiProfile=null,
/// tree 会 fallback 到 Main_Common、武器取模板自带的 98005562、技能槽不绑。
/// 机械狗(40120911)也是同一个处境,说明这条 fallback 路径是可用的。
/// </summary>
private static readonly uint[] ArmedSupportAgentIds = [40120875, 40120983];
/// <summary>per-session 当前在场支援单位的 uid 列表的 key。</summary>
private const string ArmedSupportStateKey = "client4229938-police-armed-support";
/// <summary>per-session 直升机搜查记录的 key。</summary>
private const string HelicopterScanStateKey = "client4229938-police-helicopter";
/// <summary>
/// 「直升机搜查」落地用的载具模板 —— <c>VehicleConfig.json</c> 的 <b>81007007</b>
/// (<c>Assets/Res/Prefab/Drive/Helicopter/Helicopter01a_Rope.prefab</c>,带索降绳,比 81006001
/// "Reed·Ranger" 的素机体更贴巡卫设定;VehicleType=21、6 座、VehicleControlId=7007)。
///
/// ⛑ 它是 <b>载具</b>不是 agent:不在 AgentSummonCatalog 里,只能用
/// <c>SpawnVehicleAsync(force: true)</c> 刷(fleetIds 全是地面车,飞行载具必须 force)。
///
/// ⛑ <b>这是替代方案,不是官方实现的复刻</b>:官方的直升机搜查在 build 4229938 里是半成品
/// (硬规则 134 —— 面板无 Open 入口 / SyncPoliceDispatchHelicopter 空实现 / dump.cs 无 C# 类),
/// 服务端无论怎么接都只是在后台记账。刷这台载具是唯一能"让场景里真的出现一架直升机"的手段。
/// </summary>
private const uint HelicopterVehicleId = 81007007;
/// <summary>per-session 当前这架搜查直升机载具 uid 的 key。</summary>
private const string HelicopterVehicleStateKey = "client4229938-police-helicopter-vehicle";
/// <summary>支援单位站位:玩家正前方 4 m,左右各 1.6 m(避免两只叠在一个坐标)。</summary>
private const float ArmedSupportForwardMetres = 4.0f;
private const float ArmedSupportLateralMetres = 1.6f;
// =================================================================================
// ① C2S AskPoliceDispatch(63500791)
// =================================================================================
/// <summary>
/// 执勤终端 / 轮盘的派遣按钮。INVOKE —— 客户端 <c>gReliableRpcManager</c> 等回执,
/// <b>无论 dispatchid 认不认都得先回 empty OK</b>,否则按钮 await 永不落地。
/// </summary>
[Handler(MethodId.AskPoliceDispatch, HandlerPacketKind.Invoke)]
private async Task AskPoliceDispatch(Connection conn, UxRpcMessage msg)
{
uint dispatchId = 0;
try
{
dispatchId = msg.GetArgs<PoliceMethods.AskPoliceDispatch4229938>().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<ulong>();
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(用户点名):把这两名支援接入<b>和嘉姆随从同一套 AI</b>
// —— 跟随 + 协战 + 收血条 + 清瞄准态,见 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)}]");
}
/// <summary>
/// 收掉当前在场的支援单位。SpawnAgentAsync 出来的单位配对的移除通道是
/// <c>SyncLogicAgentLeave</c>(68281244)(第一句话是 SyncLogicAgentEnter)—— 与
/// GameRouter.HackerSummon.cs 里回收召唤物用的是同一条,别用 SyncAetherAINpcRemove(那是
/// ambient 行人 / functional NPC 的通道,对这类单位发了等于没发)。
/// </summary>
private async Task DismissArmedSupportAsync(RpcContext ctx)
{
if (!ctx.Session.Items.TryGetValue(ArmedSupportStateKey, out var raw) || raw is not List<ulong> 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# 类),
// 服务端再怎么接也只是在后台记账。刷这台载具是唯一能"看见直升机"的手段。
// =================================================================================
/// <summary>
/// 刷一台搜查直升机载具(81007007 Helicopter01a_Rope)。
///
/// ⛑ <c>SpawnDirectAsync</c> 内部自带 replacement:刷之前会把 <c>runtime.SummonedEntity</c>
/// 上那台(<b>可能是玩家自己召唤的车</b>)<c>DestroyVehicleAsync</c> 掉。这里只提前打一行
/// warn,不拦 —— 玩家点「直升机搜查」时通常没在开车,而且顶掉比堆车好。
///
/// 落点是玩家右侧 5.5 m、+0.15 m(SpawnDirectAsync 里的 proven offset)。对直升机来说
/// 就是停在旁边地面上,<b>不需要抬高度</b>(抬了反而会卡在半空下不来)。
/// </summary>
private static async Task<bool> 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;
}
/// <summary>
/// 收掉这架搜查直升机(一轮搜查结束时调)。
/// 走 <c>DestroyVehicleAsync</c> ⇒ S2C <c>SyncDestroyVehicle</c>,与玩家召唤车 / 交通车
/// 用的是同一条通道,不是 SyncLogicAgentLeave(那是 agent 的)。
/// </summary>
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
// =================================================================================
/// <summary>
/// 一轮直升机搜查的服务端记账。官方玩法里这些标记会喂给"盘查 / 通缉 / 案件"那条链,
/// 我们那条链是空的,所以先只把玩家框过的单位记下来 —— 至少让 RPC 有回执、有日志可查。
/// </summary>
private sealed class HelicopterScanState
{
/// <summary>扫描开关(AskHelicopterScanChange)。</summary>
public bool ScanOpen;
/// <summary>instanceid → 最后一次 optype。</summary>
public Dictionary<ulong, byte> Marks = new();
}
/// <summary>
/// C2S AskHelicopterNpc(67113443),INVOKE —— 玩家在直升机视角里框住一个 NPC 并下判定。
///
/// ⛑⛑ 这条以前不在 <c>Enabled4229938MethodIds</c> 里,等于走中性兜底;而它是 INVOKE,
/// 客户端四个调用点(PoliceHelicopterPanelStore.lua:162 / :378 / :470 / :558)全在用
/// <c>gReliableRpcManager:RegisterRPC</c> 等回调 ⇒ 面板会卡在"点了没反应"。
/// 硬规则 94 的老坑,别再踩。
/// </summary>
[Handler(MethodId.AskHelicopterNpc, HandlerPacketKind.Invoke)]
private async Task AskHelicopterNpc(Connection conn, UxRpcMessage msg)
{
ulong instanceId = 0;
byte optype = 0;
try
{
var args = msg.GetArgs<PoliceMethods.AskHelicopterNpc4229938>();
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} 个单位");
}
/// <summary>
/// C2S AskHelicopterScanChange(67898923),<b>NOTIFY</b> —— 直升机扫描开关。
/// 客户端用的是 <c>self.Notify</c>(ClientToGameSceneDelegate.lua:5198),没有回执;
/// 这里登记 <c>Both</c> 兜住,将来客户端改成 Invoke 也不用改代码(AskControlAgent 那次就是这么栽的)。
/// </summary>
[Handler(MethodId.AskHelicopterScanChange, HandlerPacketKind.Both)]
private async Task AskHelicopterScanChange(Connection conn, UxRpcMessage msg)
{
bool open = false;
try
{
open = msg.GetArgs<PoliceMethods.AskHelicopterScanChange4229938>().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}");
}
/// <summary>
/// C2S AskPoliceStopHelicopterDispatch(63057829),INVOKE,无参 —— 一轮搜查结束
/// (客户端在"扫描成功"后延迟 1 s 发它,见 PoliceHelicopterPanelStore.lua:483)。
///
/// 收到后清账,并推一次 S2C <c>SynExitHelicopterView</c>(68309986) 收视角。客户端那个
/// handler 就是 <c>Close(gPanelId.S_POLICE_HELICOPTER)</c>,幂等 —— 面板已经关了再 Close 一次无害。
/// </summary>
[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;
}
}