using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// T2-32 路人骇入(Seymour 骇入路人的手机看信息)—— 2026-09-24 接入。 /// /// 整条链路客户端早就写好了,服务端只缺两样东西(取证见 /// modules/黑客大停电与广告牌坐标.md §7.8): /// 1. 从来没推过 SyncUnitHackableState(68941159) ⇒ C# 的 HackManager.hackUnitTarget /// 永远是 nil ⇒ GadgetManager.RefreshHackInteract 不会把路人装进 /// HackInteractList ⇒ 路人身上根本没有骇入按钮; /// 2. 三个 C2S(AskHackingNpc 67070057 / AskHackingNpcPress 67747341 / /// ReportBeHacked 67093669)没进白名单 ⇒ 就算玩家按了也是静默落兜底, /// 而前两个是 Invoke,不 ack 会让客户端 await 卡死。 /// /// 两者完全自洽:logs/unknown-methods.log 里 AskHackingNpc 命中 0 次。 /// /// ⛑️ 本文件不实现"看信息"的内容:面板(HackerInfoPanelStore.lua:267 / /// HintInfosHudStore.lua:2132)读的是客户端手里的 NPC 数据,服务端只负责放行。 /// internal sealed partial class GameRouter { /// /// 把一个 NPC 标记为可骇 / 取消标记。 /// /// 与载具那套(SyncVehicleHackableState + SyncVehicleBrokenCollision)对齐: /// 只在状态翻转时发,重复发同一个值没有意义(C# 侧就是个集合/标志位)。 /// internal static async Task PublishNpcHackableAsync(TcpSession session, ulong unitId, bool hackable, CancellationToken token = default) { if (unitId == 0) return; try { await session.NotifyAsync(MethodId.IGameSceneToClient_SyncUnitHackableState, UxSerializer.Serialize(new SceneMethods.SyncUnitHackableState4229938 { unitId = unitId, isHackable = hackable, }), token); } catch (Exception ex) { session.Log.Warn($"[NPC-HACK] hackable publish failed unit={unitId} hackable={hackable}: {ex.Message}"); } } /// /// NPC 的来路。手机要不要发,两条通道的政策不同(HackNpcSettings 里各有一个开关): /// crowd 行人本来一件 belongings 都没有,static NPC 则由 POI 决定拿什么在做什么。 /// internal enum HackNpcChannel { /// crowd 行人(SendAmbientCrowdPedAsync,SyncAetherAICrowdAdd)。 Crowd, /// 服务端自己生成的 static NPC(SendCivilianAsync,SyncAetherAIStaticNpcAddData)。 Static, } /// crowd 通道:已经发出过手机的 NPC 计数键。 private const string HackNpcPhoneCountKey = "client4229938-hacknpc-phone-count"; /// 已经打过可骇标记的 NPC 计数键(MaxMarkedNpcs 保险丝用)。 private const string HackNpcCountKey = "client4229938-hacknpc-count"; /// /// ⭐ BelongingBelongingUsageConfig.HackTypeType 是 1(PeekMessage) / 2(ListenCall), /// 全表 125 条用到手机的 usage 里只有这三条的 HackType 非 0: /// 1 「右手正常打电话」HackType=2 → 窃听通话按钮 /// 2 「右手无线耳机打电话」HackType=2(还要叠加耳机 80003544,不用) /// 6 「右手玩手机」 HackType=1 → 偷看短信按钮 /// C# 的 PhoneUsageSkillBase.CanUseInternal 判的就是"当前 usage 的 HackType 是否等于 /// 技能要求",所以 NPC 必须正处在其中之一才会有按钮。 /// internal static readonly HashSet HackableUsageIds = [1u, 2u, 6u]; /// /// NPC 出场时调一次。 gate:Gameplay.HackNpc.MarkSpawnedNpcs。 /// 放在 SendCivilianAsync / SendAmbientCrowdPedAsync 的尾部 —— /// 必须先有单位(StaticNpcAddData / CrowdAdd + ManagedLogicAgent), /// C# 才能在 SceneDataMgr.GetUnit(unitId) 里找到它。 /// internal static async Task MarkNpcHackableOnSpawnAsync(TcpSession session, ulong unitId, HackNpcChannel channel, CancellationToken token = default, bool skipPhone = false) { var settings = PrivateServerConfigStore.Current.Gameplay.HackNpc; if (!settings.MarkSpawnedNpcs || unitId == 0) return; // 保险丝:MaxMarkedNpcs>0 时超出就不再标(避免一次性刷出上千 NPC 时逐包占用连接, // 参照 hard rule 38 —— 登录/进世界路径最怕大批量推送)。 if (settings.MaxMarkedNpcs > 0) { var seen = session.Items.TryGetValue(HackNpcCountKey, out var raw) && raw is int n ? n : 0; if (seen >= settings.MaxMarkedNpcs) return; session.Items[HackNpcCountKey] = seen + 1; } // 手机必须先于可骇标记落地:belongings 是 usage 的前提,usage 是按钮的前提。 if (!skipPhone) await PublishNpcPhoneAsync(session, unitId, channel, token); await PublishNpcHackableAsync(session, unitId, true, token); } /// /// ⭐ 给 NPC 发一部手机并让他正在玩/正在打 —— 骇入面板下面那排按钮就是从这儿来的。 /// /// 证据链(2026-09-24,用户回报"骇入路人后只能看名字,不像汽车/售货机有选项"): /// 1. HackerInfoPanelStore.lua:119(反编译,比较符极性反转)路人分支走 /// self.textIds = HackManager:GetUnitHackBtns(data.unit), /// btnNum = min(#textIds,3) ⇒ 列表为空就是"只剩标题(名字)"; /// 2. GetUnitHackBtns 遍历六个 NpcHackSkill,逐个问 CanUse(unit, tracker, debug); /// 3. PhoneUsageSkillBase.CanUseInternal 看的是 /// BelongingBelongingUsageConfig.HackType,全三条非 0 的 usage 都要求持有手机 /// BelongingSceneItem = 80003481; /// 4. 我们下发的 crowd 行人没有任何 belongings,而 static NPC 用的是 POI 的 usage /// (18/86/93/237/238/241 —— HackType 全 0)⇒ 一条都对不上 ⇒ 零按钮。 /// /// 与 GameRouter.Civilians.cs / GameRouter.DonorNpc.cs 给 NPC 递道具的路径完全同包同结构, /// 只是把"POI 决定拿什么"换成"一律给手机"。 /// internal static async Task PublishNpcPhoneAsync(TcpSession session, ulong unitId, HackNpcChannel channel, CancellationToken token = default) { var settings = PrivateServerConfigStore.Current.Gameplay.HackNpc; if (!settings.GivePhoneToNpcs || unitId == 0) return; if (channel == HackNpcChannel.Crowd && !settings.GivePhoneToCrowdPeds) return; if (channel == HackNpcChannel.Static && !settings.GivePhoneToStaticNpcs) return; if (settings.MaxPhonedNpcs > 0) { var seen = session.Items.TryGetValue(HackNpcPhoneCountKey, out var raw) && raw is int n ? n : 0; if (seen >= settings.MaxPhonedNpcs) return; session.Items[HackNpcPhoneCountKey] = seen + 1; } var usages = settings.PhoneUsageIds is { Length: > 0 } u ? u : [6u]; var usage = usages[(int)(unitId % (ulong)usages.Length)]; try { await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAgentBelongings, UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongings4229938 { Id = unitId, Items = [ new GameMethods.DonorNpcBelongingItem4229938 { // 硬规则 104:instanceId 必须落盘,否则重启后重号,客户端当成"已存在"丢掉。 InstanceId = unchecked((ulong)IdCounterStore.Next(NpcBelongingIdCounter, NpcBelongingIdFloor)), ConfigId = settings.PhoneSceneItemId, }, ], DebugAgentId = 0, }), token); await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAgentUseBelongingChanged, UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongingUsage4229938 { Id = unitId, UsageIds = [usage], }), token); session.Log.Info($"[NPC-HACK] phone unit={unitId} item={settings.PhoneSceneItemId} usage={usage} channel={channel}"); } catch (Exception ex) { session.Log.Warn($"[NPC-HACK] phone publish failed unit={unitId}: {ex.Message}"); } } /// /// 敌人版本的手持道具 —— 与 同一套 wire /// (SyncAgentBelongings → SyncAgentUseBelongingChanged),只是配置源换成 /// gameplay.combat.enemies,调敌人手里的东西时不会动到全城路人。 /// /// 为什么单独一份:两者的生命周期完全不同。路人的手机要在每次 SendCivilianAsync /// / crowd ped 出场时按 MaxPhonedNpcs 计数限量推;敌人是 GM 面板一次性指令, /// 数量本来就很少,再套那层保险丝只会让第 N 个敌人之后突然不带东西,很难解释。 /// /// ⛑ 调用时机必须在单位已经在客户端落地之后(SyncManagedLogicAgent + /// SyncRaidBattleUnitAgent 都已发出),否则 SceneDataMgr.GetUnit(uid) /// 查不到这个单位,两个包都会被静默丢掉。 /// internal static async Task PublishEnemyPropAsync(TcpSession session, ulong unitId, uint templateId, CancellationToken token = default) { var settings = PrivateServerConfigStore.Current.Gameplay.Combat.Enemies; if (!settings.GiveProp || unitId == 0) return; try { await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAgentBelongings, UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongings4229938 { Id = unitId, Items = [ new GameMethods.DonorNpcBelongingItem4229938 { // 硬规则 104:instanceId 必须落盘递增,否则重号会被当成已存在的旧包丢掉。 InstanceId = unchecked((ulong)IdCounterStore.Next(NpcBelongingIdCounter, NpcBelongingIdFloor)), ConfigId = settings.PropSceneItemId, }, ], DebugAgentId = 0, }), token); await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAgentUseBelongingChanged, UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongingUsage4229938 { Id = unitId, UsageIds = [settings.PropUsageId], }), token); session.Log.Info($"[ENEMY-PROP] unit={unitId} template={templateId} item={settings.PropSceneItemId} usage={settings.PropUsageId}"); } catch (Exception ex) { session.Log.Warn($"[ENEMY-PROP] publish failed unit={unitId}: {ex.Message}"); } } /// /// AskHackingNpc(67070057) — 玩家点下了路人骇入按钮。 /// /// ⛑️ 必须 :客户端走的是 Invoke /// (ClientToGameSceneDelegate.lua:489),但按 AskControlAgent 那次的教训, /// 同族 RPC 会有 NOTIFY 变体,只注册 Invoke 就是静默丢包。 /// 返回体是空 reader ⇒ ReturnEmptyOkAsync(对 NOTIFY 是 no-op)。 /// [Handler(MethodId.AskHackingNpc, HandlerPacketKind.Both)] private async Task OnAskHackingNpc(Connection conn, UxRpcMessage msg) { ulong unit = 0; int index = -1; if (msg.TryGetArgs(out var args) && args is not null) { unit = args.instanceid; index = args.index; } else { conn.Log.Warn($"[NPC-HACK] AskHackingNpc undecodable bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}"); } conn.Log.Info($"[NPC-HACK] ask unit={unit} index={index} kind={(msg.IsInvoke ? "invoke" : "notify")}"); if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg); } /// /// AskHackingNpcPress(67747341) — 长按骇入的那一下(GadgetManager.lua:1851)。 /// 同样是 Invoke + 空 reader。 /// [Handler(MethodId.AskHackingNpcPress, HandlerPacketKind.Both)] private async Task OnAskHackingNpcPress(Connection conn, UxRpcMessage msg) { ulong unit = 0; if (msg.TryGetArgs(out var args) && args is not null) unit = args.instanceid; else conn.Log.Warn($"[NPC-HACK] AskHackingNpcPress undecodable bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}"); conn.Log.Info($"[NPC-HACK] press unit={unit} kind={(msg.IsInvoke ? "invoke" : "notify")}"); if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg); } /// /// ReportBeHacked(67093669) — NOTIFY,客户端告知"这些单位被骇了"。 /// 纯记账:没有 ack,也没有需要回推的包(面板内容在客户端)。 /// [Handler(MethodId.ReportBeHacked, HandlerPacketKind.Both)] private async Task OnReportBeHacked(Connection conn, UxRpcMessage msg) { var ids = new List(); var decoded = msg.TryGetArgs(out var args) && args is not null; if (decoded && args!.uids is { Count: > 0 } list) ids.AddRange(list); else conn.Log.Warn($"[NPC-HACK] ReportBeHacked undecodable bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}"); conn.Log.Info($"[NPC-HACK] report units={ids.Count} ids=[{string.Join(",", ids)}]"); // NOTIFY 不需要回包;留着 IsInvoke 分支只为将来该包升级成 Invoke 时不静默卡死。 if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg); } }