306 lines
15 KiB
C#
306 lines
15 KiB
C#
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);
|
|||
|
|
}
|
|||
|
|
}
|