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