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

307 lines
15 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.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;
/// <summary>
/// T2-32 路人骇入(Seymour 骇入路人的手机看信息)—— 2026-09-24 接入。
///
/// <b>整条链路客户端早就写好了,服务端只缺两样东西</b>(取证见
/// <c>modules/黑客大停电与广告牌坐标.md</c> §7.8):
/// 1. 从来没推过 <c>SyncUnitHackableState</c>(68941159) ⇒ C# 的 <c>HackManager.hackUnitTarget</c>
/// 永远是 nil ⇒ <c>GadgetManager.RefreshHackInteract</c> 不会把路人装进
/// <c>HackInteractList</c> ⇒ 路人身上根本没有骇入按钮;
/// 2. 三个 C2S(<c>AskHackingNpc</c> 67070057 / <c>AskHackingNpcPress</c> 67747341 /
/// <c>ReportBeHacked</c> 67093669)没进白名单 ⇒ 就算玩家按了也是静默落兜底,
/// 而前两个是 <b>Invoke</b>,不 ack 会让客户端 await 卡死。
///
/// 两者完全自洽:<c>logs/unknown-methods.log</c> 里 <c>AskHackingNpc</c> 命中 0 次。
///
/// ⛑️ 本文件<b>不</b>实现"看信息"的内容:面板(<c>HackerInfoPanelStore.lua:267</c> /
/// <c>HintInfosHudStore.lua:2132</c>)读的是客户端手里的 NPC 数据,服务端只负责放行。
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>
/// 把一个 NPC 标记为可骇 / 取消标记。
///
/// 与载具那套(<c>SyncVehicleHackableState</c> + <c>SyncVehicleBrokenCollision</c>)对齐:
/// 只在<b>状态翻转</b>时发,重复发同一个值没有意义(C# 侧就是个集合/标志位)。
/// </summary>
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}");
}
}
/// <summary>
/// NPC 的来路。<b>手机要不要发,两条通道的政策不同</b>(<c>HackNpcSettings</c> 里各有一个开关):
/// crowd 行人本来一件 belongings 都没有,static NPC 则由 POI 决定拿什么在做什么。
/// </summary>
internal enum HackNpcChannel
{
/// <summary>crowd 行人(<c>SendAmbientCrowdPedAsync</c>,<c>SyncAetherAICrowdAdd</c>)。</summary>
Crowd,
/// <summary>服务端自己生成的 static NPC(<c>SendCivilianAsync</c>,<c>SyncAetherAIStaticNpcAddData</c>)。</summary>
Static,
}
/// <summary>crowd 通道:已经发出过手机的 NPC 计数键。</summary>
private const string HackNpcPhoneCountKey = "client4229938-hacknpc-phone-count";
/// <summary>已经打过可骇标记的 NPC 计数键(<c>MaxMarkedNpcs</c> 保险丝用)。</summary>
private const string HackNpcCountKey = "client4229938-hacknpc-count";
/// <summary>
/// ⭐ <c>BelongingBelongingUsageConfig.HackTypeType</c> 是 1(PeekMessage) / 2(ListenCall),
/// 全表 125 条用到手机的 usage 里<b>只有这三条</b>的 HackType 非 0:
/// 1 「右手正常打电话」HackType=2 → 窃听通话按钮
/// 2 「右手无线耳机打电话」HackType=2(还要叠加耳机 80003544,不用)
/// 6 「右手玩手机」 HackType=1 → 偷看短信按钮
/// C# 的 <c>PhoneUsageSkillBase.CanUseInternal</c> 判的就是"当前 usage 的 HackType 是否等于
/// 技能要求",所以 NPC 必须正处在其中之一才会有按钮。
/// </summary>
internal static readonly HashSet<uint> HackableUsageIds = [1u, 2u, 6u];
/// <summary>
/// NPC 出场时调一次。 gate:<c>Gameplay.HackNpc.MarkSpawnedNpcs</c>。
/// 放在 <c>SendCivilianAsync</c> / <c>SendAmbientCrowdPedAsync</c> 的<b>尾部</b> ——
/// 必须先有单位(StaticNpcAddData / CrowdAdd + ManagedLogicAgent),
/// C# 才能在 <c>SceneDataMgr.GetUnit(unitId)</c> 里找到它。
/// </summary>
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;
}
// 手机必须<b>先于</b>可骇标记落地:belongings 是 usage 的前提,usage 是按钮的前提。
if (!skipPhone)
await PublishNpcPhoneAsync(session, unitId, channel, token);
await PublishNpcHackableAsync(session, unitId, true, token);
}
/// <summary>
/// ⭐ 给 NPC 发一部手机并让他<b>正在玩/正在打</b> —— 骇入面板下面那排按钮就是从这儿来的。
///
/// 证据链(2026-09-24,用户回报"骇入路人后只能看名字,不像汽车/售货机有选项"):
/// 1. <c>HackerInfoPanelStore.lua:119</c>(反编译,比较符极性反转)路人分支走
/// <c>self.textIds = HackManager:GetUnitHackBtns(data.unit)</c>,
/// <c>btnNum = min(#textIds,3)</c> ⇒ 列表为空就是"只剩标题(名字)";
/// 2. <c>GetUnitHackBtns</c> 遍历六个 NpcHackSkill,逐个问 <c>CanUse(unit, tracker, debug)</c>;
/// 3. <c>PhoneUsageSkillBase.CanUseInternal</c> 看的是
/// <c>BelongingBelongingUsageConfig.HackType</c>,全三条非 0 的 usage 都要求持有手机
/// <c>BelongingSceneItem = 80003481</c>;
/// 4. 我们下发的 crowd 行人<b>没有任何 belongings</b>,而 static NPC 用的是 POI 的 usage
/// (18/86/93/237/238/241 —— HackType 全 0)⇒ 一条都对不上 ⇒ 零按钮。
///
/// 与 <c>GameRouter.Civilians.cs</c> / <c>GameRouter.DonorNpc.cs</c> 给 NPC 递道具的路径<b>完全同包同结构</b>,
/// 只是把"POI 决定拿什么"换成"一律给手机"。
/// </summary>
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}");
}
}
/// <summary>
/// 敌人版本的手持道具 —— 与 <see cref="PublishNpcPhoneAsync"/> 同一套 wire
/// (<c>SyncAgentBelongings</c> → <c>SyncAgentUseBelongingChanged</c>),只是配置源换成
/// <c>gameplay.combat.enemies</c>,调敌人手里的东西时不会动到全城路人。
///
/// 为什么单独一份:两者的生命周期完全不同。路人的手机要在每次 <c>SendCivilianAsync</c>
/// / crowd ped 出场时按 <c>MaxPhonedNpcs</c> 计数限量推;敌人是 GM 面板一次性指令,
/// 数量本来就很少,再套那层保险丝只会让第 N 个敌人之后突然不带东西,很难解释。
///
/// ⛑ 调用时机必须在单位已经在客户端落地之后(<c>SyncManagedLogicAgent</c> +
/// <c>SyncRaidBattleUnitAgent</c> 都已发出),否则 <c>SceneDataMgr.GetUnit(uid)</c>
/// 查不到这个单位,两个包都会被静默丢掉。
/// </summary>
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}");
}
}
/// <summary>
/// AskHackingNpc(67070057) — 玩家点下了路人骇入按钮。
///
/// ⛑️ 必须 <see cref="HandlerPacketKind.Both"/>:客户端走的是 <c>Invoke</c>
/// (<c>ClientToGameSceneDelegate.lua:489</c>),但按 <c>AskControlAgent</c> 那次的教训,
/// 同族 RPC 会有 NOTIFY 变体,只注册 Invoke 就是静默丢包。
/// 返回体是空 reader ⇒ <c>ReturnEmptyOkAsync</c>(对 NOTIFY 是 no-op)。
/// </summary>
[Handler(MethodId.AskHackingNpc, HandlerPacketKind.Both)]
private async Task OnAskHackingNpc(Connection conn, UxRpcMessage msg)
{
ulong unit = 0;
int index = -1;
if (msg.TryGetArgs<SceneMethods.AskHackingNpc4229938>(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);
}
/// <summary>
/// AskHackingNpcPress(67747341) — 长按骇入的那一下(<c>GadgetManager.lua:1851</c>)。
/// 同样是 Invoke + 空 reader。
/// </summary>
[Handler(MethodId.AskHackingNpcPress, HandlerPacketKind.Both)]
private async Task OnAskHackingNpcPress(Connection conn, UxRpcMessage msg)
{
ulong unit = 0;
if (msg.TryGetArgs<SceneMethods.AskHackingNpcPress4229938>(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);
}
/// <summary>
/// ReportBeHacked(67093669) — NOTIFY,客户端告知"这些单位被骇了"。
/// 纯记账:没有 ack,也没有需要回推的包(面板内容在客户端)。
/// </summary>
[Handler(MethodId.ReportBeHacked, HandlerPacketKind.Both)]
private async Task OnReportBeHacked(Connection conn, UxRpcMessage msg)
{
var ids = new List<ulong>();
var decoded = msg.TryGetArgs<SceneMethods.ReportBeHacked4229938>(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);
}
}