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

591 lines
34 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>
/// 可接管召唤物(android-control 一家子):
/// 黑客 APP 蜘蛛机器人(51938181)/ 送货无人机(51938183)→ 40120908 / 40120909
/// 巡卫官 APP 机械狗 NCCA Dog(51938184 召唤 / 51938186 驻场态)→ 40120911
///
/// 三条是<b>同一套通道</b>,不是三个功能。2026-09-25 补狗时的取证:
/// · AgentSummonCatalog 里 40120908 蜘蛛 / 40120909 无人机 / 40120911 机械狗 紧挨着,
/// EnemyClassType=1、GroupEnemy=40211001、AI=40213004 三个字段完全相同
/// (狗多一个 InitWeaponId 98006028);EnemyCatalog.json:82 再确认一次。
/// · SummonConfig 只有 5 条:1 无人机 / 2 蜘蛛 / **3 机器狗** / 4 鸵鸟马桶 / 5 扫地机器人
/// ⇒ 狗是官方召唤物,SyncSwitchControl 的 SummonTag 校验对它成立(硬规则 52)。
/// · 客户端有完整的 RobotDogControlsStore.lua(召回/切换操控/普攻/蹲/闪避/拍照…),
/// 它调的正是 AskStopControlAgent(true/false) —— 与蜘蛛/无人机逐字相同。
/// · 51938184 / 51938186 挂在里希(15021021)的 FightSpiritConfig.TempSkill 上,
/// 所以 GameRouter.Combat.cs 的 AllowsAuthoredCharacterSkill 已经放行,
/// 技能包到得了这里(Combat.cs 里那句 TryHandleControllableSummonSkill)。
///
/// <b>为什么以前点了没反应 —— 2026-09-23 抓包确认。</b>
/// 客户端其实一直在上报。packets 日志里这两行进得很清楚:
/// <code>
/// C2S DECODED AskClientUseCommonSkill type=AskUseSkill
/// {"data":{"Releaser":100000000012,"Location":{...},"SkillId":51938181,"SkillInstanceId":-211}}
/// [COMBAT] accept temp-skill template=15021023 skill=51938181 inst=-211
/// [COMBAT] use unit=100000000012 weapon=200141 style=39000005 skill=51938181 target=0
/// </code>
/// 也就是说:<b>C2S 到了,服务端也认了(走 AllowsAuthoredCharacterSkill 分支),然后——什么
/// 都没做。</b> OnClientUseSkill 只更新了 combat 沙盘记账就返回了,世界上一个实体也没多出来。
///
/// 客户端那边则是全套自备的(lua.zip):
/// HackScriptFunc.lua SpiderSkill = function() gCS.BattleManager.UseSkillByPid(pid, 51938181) end
/// SkillConfig.json 51938181 召唤蜘蛛机器人 / 51938183 召唤送货无人机,ClientActionAdvance=true
/// 所以玩家看到的"放置动作"是客户端先行演出;真正的单位、操控面板、操控本身,都要服务端
/// 把单位放进世界之后客户端才成立。
///
/// <b>分工(用户 2026-09-23 确认):召唤出来之后由玩家接管操控,这套逻辑在客户端。</b>
/// 服务端只做三件事:
/// 1. 把实体刷出来(不参与战斗 —— 见 <c>engage:false</c>)
/// 2. 推一次 SyncSwitchControl(68552741) 把玩家放进操控位
/// 3. 接住 AskStopControlAgent(67046103) / AskControlAgent(67291468) 这一对「交还 / 重新接管」
/// 之后 DoHackEnemy → ANDROID_CONTROL_SWITCH → RobotSpiderControlStore / DeliveryDroneStore
/// 全在客户端,我们不插手。
///
/// ⛔⛔ <b>2026-09-23 18:02 实机抓到的头号坑:spiritId 不能拿 Profile.InitialUnitId。</b>
/// 第一版用 <c>state.WorldEntryControlUnit</c>(= 100000000000 = player.initialUnitId)当
/// spiritId,结果是单位出来了、客户端也收到了两个包,但操控没交出去 —— 表现为「机器人站着
/// 不动,我还在控角色,而且手机打不开」。客户端日志里是这一行铁证:
/// <code>
/// [error] SyncHackEnemy enterOrLeave True unit or master is null
/// enemyId= 35184372188839 enemy is nullFalse
/// spiritId= 100000000000 master is nullTrue
/// LX6.Units.Module.BaseUnitModuleUtils:DoHackEnemy(UInt64, UInt64, Boolean)
/// </code>
/// <c>enemy is nullFalse</c> = 单位找得到;<c>master is nullTrue</c> = 按 spiritId 找不到施法者。
/// 玩家真正的 spirit uid 是 <b>100000000012</b>,三处互相印证:
/// · AskUseSkill 的 <c>Releaser</c>(客户端自己报的施法者)
/// · 我们下发的 <c>SyncPlayerCurrentSpirit {"playerPid":100,"spiritId":100000000012}</c>
/// · 客户端 <c>AskUnitMoveActionSimple</c> 上报自己移动时带的那个 UnitId
/// 而 100000000000 只是 <c>config/private-server.json</c> 的 <c>player.initialUnitId</c>,
/// 是进世界时的占位值,切过角色之后就不再是当前 spirit。
/// ⇒ <b>spiritId 一律优先用 req.Releaser。</b>
///
/// 手机打不开也是同一个 bug 的副作用,不是新问题:客户端 SyncSwitchControl 在 DoHackEnemy
/// 之后会执行 <c>OnSetSkillBtnState(PhoneCall, "isSummonAgentControl", true)</c> 把手机按钮禁掉
/// —— 它以为自己已经在操控召唤物了,而 DoHackEnemy 静默失败,于是「手机锁了但没接管」。
/// </summary>
internal sealed partial class GameRouter
{
// ---------------------------------------------------------------------------------
// 技能 → 召唤物模板。
//
// SkillConfig.json(table/,build 4229938)里 51938181 / 51938183 记录<s>没有任何指向
// agentId 的字段</s>(全 0),所以这个映射只能是硬编码,donor 资料里也没有。
//
// 两个模板都带 SummonTag(AgentConfig):40120908 → 2 蜘蛛,40120909 → 1 无人机。
// SummonConfig 一共 5 条:1 无人机 / 2 蜘蛛 / 3 机器狗 / 4 鸵鸟马桶 / 5 扫地机器人。
// 换模板时必须挑带 SummonTag 的那 14 个,否则客户端 SyncSwitchControl 的
// 卡死点②会静默丢包(硬规则 52)。
//
// 机械狗(2026-09-25 补):51938184 = 召唤,51938186 = 同一只狗的「驻场状态」切换
// —— PoliceDispatchCatalog id=2 的 SkillId / SwitchSkillId,PolicePanelManager 按
// dispatchState[id] 二选一。服务端表达不了「驻守」这种自主行为,所以两个技能刷同一个
// 模板,靠下面的「先收掉上一只」保证场上永远只有一只。
// ---------------------------------------------------------------------------------
private const uint HackerSpiderSkillId = 51938181;
private const uint HackerDroneSkillId = 51938183;
private const uint HackerSpiderAgentId = 40120908;
private const uint HackerDroneAgentId = 40120909;
private const uint PoliceDogSkillId = 51938184;
private const uint PoliceDogStandGuardSkillId = 51938186;
private const uint PoliceDogAgentId = 40120911;
// 机械狗的<b>武器轮盘</b>走 SceneItem 80003497(SceneitemConfig「机器狗默认武器」),
// 而战斗 spawn 用的是 <b>Weapon</b> 98006028(WeaponConfig「机器狗默认武器」)。
// 两个命名空间不能混用 —— 捐赠端 7k-v0.5 GameRouter.HackerDevices.cs:153 专门注释过。
// 电击能力的落点:98006028 → WeaponConfig.FixedFightSkill 39010040 →
// FightSkillConfig.ActiveSkill 51942305「机械狗范围电击」。武器本身 spawn 帧已经发了,
// 缺的是<b>这一帧</b>:客户端 AgentWeaponManager.lua:25 收到后填 agentWeaponSlotDict 并
// 广播 AGENT_CURRENT_WEAPON_CHANGE,操控 HUD 的武器 / 技能槽就是从这儿来的;
// 不发 ⇒ HUD 上没有武器 ⇒ 按 E 没技能可放。
private const uint PoliceDogWeaponWheelSceneItemId = 80003497;
// 送货/黑客无人机(2026-09-26 补)的武器轮盘走 SceneItem 80003471「零号爪机」
// (SceneitemConfig.OldWeaponId = 98005567「送货无人机」)。
//
// ⛑ 注意这里的<b>命名空间</b>:轮盘发的是 SceneItem id,战斗 spawn 发的是 Weapon id,
// 两者靠 SceneitemConfig.OldWeaponId 对应,不能混用(见上面机械狗那段注释)。
//
// 取值依据(SummonSkillInfoConfig 全文只有 4 行,dump 包里有、服务端 ClientData 里没有):
// Id=1 WeaponId=80003471 Normal=777 Basic=853 ← 零号爪机 / 送货无人机
// Id=2 WeaponId=80003472 Normal=777 Basic=853 ← 大型无人机 / 98005568
// Id=3 WeaponId=80003497 Normal=0 Basic=731 ← 机器狗(机械狗在用这个)
// Id=4 WeaponId=80005973 Normal=0 Basic=901 ← 蜘蛛机器人默认武器 / 98006056
// 无人机 HUD 的按键文本就是从这张表来的,所以必须挑表里存在的 id:
// GetAgentWeaponInfoCfg 查不到就退回 DEFAULT_NAME(719),按钮文案会变成占位文本。
private const uint DroneWeaponWheelSceneItemId = 80003471;
// 友方阵营:捐赠端 7k-v0.5 GameRouter.HackerDevices.cs:147 给机械狗发的是 camp = 1,
// AgentConfig 40922014/40120911 自己的 Camp 也是 1。我方 SpawnAgentAsync 默认给
// hostile 单位发 4(UnitCamp.AllEnemy)⇒ 城里谁都把它当敌人。
// ⛑ 与硬规则 132(刷友方 NPC 用 campOverride:0)的分歧:132 说的是<b>友方 NPC</b>
//(战术支援那种走 Main_Common_NPC 树的),机械狗是玩家操控的召唤物(SummonTag=3),
// 性质不同 ⇒ 这里跟捐赠端用 1。若实机被己方误伤,改回 0 试。
private const byte SummonCampFriendly = 1;
// UnitStateConfig.HideHp(dump.cs:1448623 / 1449004 = 10266)。
// 血条的判据在 EnemyHUDCtrl.lua:579:
// shouldShowHp = ... and not UnitStateMgr:HasState(unit, UnitStateConfig.HideHp) and ...
// ⇒ 挂上这个状态就能把血条关掉,而且是 <b>per-unit</b> 的:不动公共 spawn 路径、
// 不影响战斗模块(电击 51942305 照旧)、不影响其他敌人。
// ⛑ 别用「关掉 SyncAttachBattleModule」那条路:战斗模块没了电击大概率也没了。
private const uint UnitStateHideHp = 10266;
// 蓝猫物流(Cat Express / UberSim)的「部署 / Deploy」无人机支援(2026-09-25 补)。
// 出处:UberSimOrderSupportConfig Id=1 "Drone" —— functionId=51938185,
// CanUseStateText="Deploy"、UsedStateText="Call Back"、UnlockBuff=52800661。
// 与黑客 APP 的 51938183「送货无人机」是同一族 temp-skill,刷的也是同一个模板
// 40120909(AgentSummonCatalog:EnemyClassType=1、AI=40213004,与蜘蛛/机械狗同源)。
// ⛑ 按钮的可见性由 UnlockBuff 52800661 控制,buff 走
// CharacterCapabilities.GlobalCapabilityBuffIds;这里只负责"按下去真刷出来"。
private const uint DeliveryDroneSkillId = 51938185;
private const uint DeliveryDroneAgentId = 40120909;
// SwitchControlReason(dump.cs line 1736857):
// None=0 Skill=1 Client=2 ClientWithdraw=3 ClientSwitch=4 System=5
// Desinger=6 DesignerLoading=7 Distance=8 SummonedAgentDead=9 ControllerDead=10
private const byte SwitchControlReasonSkill = 1; // 经由技能夺取操控
private const byte SwitchControlReasonClientWithdraw = 3; // 玩家主动撤回(长按 F 回收)
private const byte SwitchControlReasonClientSwitch = 4; // 玩家切换操控(N 键)
/// <summary>召唤物落点:客户端上报的 Location 前方多少米。比玩家自身坐标更贴近意图。</summary>
private const float SummonDropDistanceMeters = 2.5f;
/// <summary>per-session 当前召唤物记录的 key(session.Items 是 Dictionary&lt;string,object&gt;)。</summary>
private const string ControllableSummonStateKey = "client4229938-controllable-summon";
/// <summary>重复"接管"下发的去抖窗口。客户端按住 N 会 ~200ms 连发。</summary>
private static readonly TimeSpan HandoverDebounce = TimeSpan.FromSeconds(1);
/// <summary>一次召唤的运行时快照。只存交接操控需要的三个 id。</summary>
private sealed class ControllableSummonState
{
public ulong SpiritId; // 玩家 spirit uid(= AskUseSkill.Releaser)
public ulong AgentEntityId; // 召唤物 unit uid
public uint AgentId; // AgentConfig 模板 id(40120908 / 40120909 / 40120911)
/// <summary>服务端视角:操控位此刻是否在召唤物手上(true)还是已交还玩家(false)。</summary>
public bool Controlling;
/// <summary>上次下发"接管"的时刻,用于给连发的 AskControlAgent 去抖。</summary>
public DateTime LastHandoverUtc;
}
private static uint? ControllableSummonAgentId(uint skillId) => skillId switch
{
HackerSpiderSkillId => HackerSpiderAgentId,
HackerDroneSkillId => HackerDroneAgentId,
PoliceDogSkillId => PoliceDogAgentId,
PoliceDogStandGuardSkillId => PoliceDogAgentId,
DeliveryDroneSkillId => DeliveryDroneAgentId,
_ => null,
};
/// <summary>召唤物的展示名,只用于日志。</summary>
private static string ControllableSummonName(uint agentId) => agentId switch
{
HackerSpiderAgentId => "蜘蛛机器人",
HackerDroneAgentId => "送货无人机",
PoliceDogAgentId => "机械狗(NCCA Dog)",
_ => $"agent={agentId}",
};
/// <summary>
/// SyncAgentWeaponWheel(68121167) 的载荷。抽成方法是为了让 <c>_ux_wire_check</c> 能反射
/// 直接断言<b>生产代码</b>产出的字节,而不是在测试里重写一遍。
///
/// 客户端 reader(RPCDeserializeAuto.lua:171397):
/// <code>
/// local agentId = reader.ReadUInt64(reader)
/// local currentWeaponId = reader.ReadUInt32(reader)
/// local weaponSlots = Base.ReadList7Bit(reader, function(r) return r.ReadUInt32(r) end)
/// </code>
/// ReadList7Bit = 标记字节 + Int7(count) + count 个 u32。
/// ⛑ 标记字节按项目硬规则 5 用 <c>0xFF</c>(= 有值);<c>UxWriter.Int7(n)</c> 内部写 n+1,别再加偏置。
/// </summary>
internal static byte[] BuildWeaponWheelWire(ulong uid, uint weaponWheelSceneItemId)
=> new UxWriter()
.U64(uid)
.U32(weaponWheelSceneItemId)
.U8(0xFF)
.Int7(1)
.U32(weaponWheelSceneItemId)
.ToArray();
/// <summary>
/// 召唤物的武器轮盘 SceneItem id。<b>0 = 这个模板没有轮盘,不要发这一帧。</b>
///
/// ⛑⛑ 2026-09-26 订正(原注释写错了,按它办的 ⇒ 无人机一直没有 E 技能):
/// 原注释拿 <c>AgentSummonCatalog.InitWeaponId</c> 当判据,结论是「只有机械狗有武器,
/// 蜘蛛和无人机 InitWeaponId 都是 0 ⇒ 不用发轮盘」。<b>这个判据是错的</b> —— 轮盘的
/// 命名空间是 SceneItem(<c>AgentConfig.InitWeaponSceneItemId</c>),而 HUD 上"有没有
/// 技能键"只看 <c>AgentWeaponManager:GetAgentCurrentWeapon</c> 拿不拿得到值,跟单位
/// 实际绑了哪把武器无关。
///
/// 客户端的判据(RobotFlyerControlsStore.lua,无人机操控 HUD):
/// <code>
/// M.RefreshBattleSkillInfo = function(self, weaponId)
/// if weaponId then btnHitch:SetActive(true); btnDrop:SetActive(true)
/// else btnHitch:SetActive(false); btnDrop:SetActive(false) end
/// local cfg = gAgentWeaponManager:GetAgentWeaponInfoCfg(weaponId)
/// if cfg then btnHitch:SetTipNameTotally(cfg.Normal) -- 挂钩 = 普通攻击
/// btnDrop:SetTipNameTotally(cfg.Basic) end -- 投放 = E 技能
/// </code>
/// 即:无人机 HUD 上<b>没有 btnSkillPc</b>(那是机械狗/蜘蛛的),它的"原生 E 技能"
/// 就是 <c>btnDrop</c>(长按走 <c>SkillBtnType.Basic</c>),配套还有 <c>btnHitch</c>。
/// 不发轮盘 ⇒ 这两个键直接 SetActive(false) ⇒ 玩家看到的就是"没有 E 技能"。
/// 这是硬规则 173 的同一条链路,只是消费方从 RobotDogControlsStore 换成了
/// RobotFlyerControlsStore。
///
/// 蜘蛛 40120908 暂未纳入:它的 HUD(RobotSpiderControlStore)同样按 weaponId 控制
/// btnSkillPc,映射也已知(80005973「蜘蛛机器人默认武器」→ 98006056,FixedFightSkill
/// 39010099),但本次只改无人机这一处,蜘蛛留作后续。
/// </summary>
private static uint ControllableSummonWeaponWheel(uint agentId) => agentId switch
{
PoliceDogAgentId => PoliceDogWeaponWheelSceneItemId,
HackerDroneAgentId => DroneWeaponWheelSceneItemId, // 40120909 = 黑客 APP 的无人机
// DeliveryDroneAgentId 与 HackerDroneAgentId 同为 40120909,已在上面覆盖;
// 这里再列一遍只为说明"蓝猫物流部署"走的也是同一个模板,C# switch 不允许重复 case。
_ => 0u,
};
/// <summary>
/// 每个家族一个独立回滚开关(黑客 / 巡卫犬),别共用 —— 见
/// <c>PrivateServerConfig.PoliceDogSummonEnabled</c> 的注释。
/// </summary>
private static bool ControllableSummonEnabled(uint skillId, out string switchName)
{
var gameplay = PrivateServerConfigStore.Current.Gameplay;
if (skillId is PoliceDogSkillId or PoliceDogStandGuardSkillId)
{
switchName = "Gameplay.PoliceDogSummonEnabled";
return gameplay.PoliceDogSummonEnabled;
}
if (skillId == DeliveryDroneSkillId)
{
switchName = "Gameplay.DeliveryDroneSummonEnabled";
return gameplay.DeliveryDroneSummonEnabled;
}
switchName = "Gameplay.HackerSummonEnabled";
return gameplay.HackerSummonEnabled;
}
// =================================================================================
// ① 施放召唤技能
// =================================================================================
/// <summary>
/// 接住一次可接管召唤技能(蜘蛛 / 无人机 / 机械狗)。
/// 返回 false 表示这不是召唤技能,调用方应继续走普通战斗流程。
/// </summary>
internal async Task<bool> TryHandleControllableSummonSkill(RpcContext ctx, SceneMethods.SkillUseData req)
{
var agentId = ControllableSummonAgentId(req.SkillId);
if (agentId is null)
return false;
if (!ControllableSummonEnabled(req.SkillId, out var switchName))
{
ctx.Session.Log.Info($"[SUMMON] skill={req.SkillId} skipped — {switchName}=false (回滚开关)");
return true;
}
// ⛔ spiritId 必须是客户端上报的施法者。见类注释里 18:02 那次 master is null。
// WorldEntryControlUnit / ActiveSpiritUnitId 都可能是进世界时的占位 initialUnitId。
var playerUnit = req.Releaser;
if (playerUnit == 0)
{
var st = GetStateIfExists(ctx.Session);
playerUnit = st?.ActiveSpiritUnitId ?? 0;
if (playerUnit == 0)
playerUnit = st?.WorldEntryControlUnit ?? 0;
if (playerUnit != 0)
ctx.Session.Log.Warn($"[SUMMON] skill={req.SkillId} AskUseSkill.Releaser=0 — 退回 state 的 unit={playerUnit};若客户端再报 master is null 就要查切换流程");
}
if (playerUnit == 0)
{
ctx.Session.Log.Warn($"[SUMMON] skill={req.SkillId} 拿不到玩家 spirit uid — DoHackEnemy 会 master is null,本次不放单位");
return true;
}
// Location is where the player was standing when they pressed the button. Drop the unit a
// couple of metres ahead so it does not appear inside them. Same convention as the rest of
// the spawner.
// ⛑ 2026-09-26 修:这里原来写着 "no radian conversion — Facing feeds sin/cos directly
// everywhere in this repo",把 bug 当成约定固化下来了。实际上<b>那几处全是 bug</b>:
// Facing / LastReportedPlayerRotation.Y 都是<b>度数</b>,直接喂 sin/cos 会让落点方向
// 随数值乱飘(半径碰巧还是对的,所以只看"有没有生成在旁边"发现不了)。
// 现在全仓库统一:先 × MathF.PI/180 再进三角函数。
var facing = req.Facing;
var yaw = facing * (MathF.PI / 180f);
var x = req.Location.X + MathF.Sin(yaw) * SummonDropDistanceMeters;
var z = req.Location.Z + MathF.Cos(yaw) * SummonDropDistanceMeters;
// Y stays at the player's own height: the client uses the server-supplied position verbatim
// (no ground snapping), so any upward offset leaves a flying unit hovering.
var y = req.Location.Y;
var name = ControllableSummonName(agentId.Value);
ctx.Session.Log.Info($"[SUMMON] skill={req.SkillId} agent={agentId} ({name}) inst={req.SkillInstanceId} at=({x:F2},{y:F2},{z:F2}) facing={facing:F2}");
// 场上永远只留一只。机械狗的「驻场态」技能(51938186)会被反复按(PolicePanelManager
// 的 dispatchState 二选一),不先收掉上一只就会越按越多、session 里那只还找不回来。
if (ctx.Session.Items.TryGetValue(ControllableSummonStateKey, out var previous)
&& previous is ControllableSummonState old)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave,
Ananta.Server.Protocol.Client4229938.WorldCodec.LogicAgentLeave(old.AgentEntityId));
ctx.Session.Items.Remove(ControllableSummonStateKey);
ctx.Session.Log.Info($"[SUMMON] replace previous uid={old.AgentEntityId} agent={old.AgentId} ({ControllableSummonName(old.AgentId)}) — SyncLogicAgentLeave sent");
}
var result = await GameAdminBridge.SpawnAgentAsync(agentId.Value, x, y, z, facing,
hostile: true, // 40120908/40120909/40120911 的 EnemyClassType=1 都在 Hostile 桶里,校验不放行 false
engage: false, // 但绝不能注册成敌人:召唤物是给玩家操控的,不是用来打玩家的
campOverride: SummonCampFriendly); // 阵营归友方(捐赠端做法),否则城里谁都打它
if (!result.Ok || result.Uid == 0)
{
ctx.Session.Log.Warn($"[SUMMON] spawn failed agent={agentId} ({name}) reason={result.Message}");
return true;
}
var summon = new ControllableSummonState
{
SpiritId = playerUnit,
AgentEntityId = result.Uid,
AgentId = agentId.Value,
};
ctx.Session.Items[ControllableSummonStateKey] = summon;
ctx.Session.Log.Info($"[SUMMON] unit spawned uid={result.Uid} — handing control to spirit={playerUnit}");
// 武器轮盘。⛑ 必须在 SyncSwitchControl <b>之前</b>——玩家一进操控位,HUD 立刻去读
// agentWeaponSlotDict,晚一帧就是空槽。
// 这一帧决定 HUD 上"有没有技能键",不决定单位实际拿哪把武器(那是 spawn 帧的
// SyncRaidBattleUnitAgent.WeaponId)。
// · 机械狗 40120911 → 80003497:HUD 亮出 btnSkillPc(E),按出来是电击 51942305
// (武器 98006028 的 FixedFightSkill 39010040 的 ActiveSkill)。
// · 无人机 40120909 → 80003471:HUD 亮出 btnDrop(E,投放)+ btnHitch(挂钩)。
// 文本取自 SummonSkillInfoConfig Id=1(Basic=853 / Normal=777)。
var weaponWheel = ControllableSummonWeaponWheel(agentId.Value);
if (weaponWheel != 0)
{
var wheelBody = BuildWeaponWheelWire(result.Uid, weaponWheel);
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentWeaponWheel, wheelBody);
ctx.Session.Log.Info($"[SUMMON] weapon wheel uid={result.Uid} agent={agentId} ({name}) "
+ $"sceneItem={weaponWheel} mid={MethodId.IGameSceneToClient_SyncAgentWeaponWheel} "
+ $"bytes=[{Convert.ToHexString(wheelBody)}]");
}
else
{
// 蜘蛛 40120908 目前走这里:HUD 上不会亮出 E 技能键。想加就改
// ControllableSummonWeaponWheel 的 switch(映射见那里的注释)。
ctx.Session.Log.Info($"[SUMMON] no weapon wheel for agent={agentId} ({name}) "
+ "— HUD will hide the E/skill button");
}
// 关掉血条。机械狗是玩家操控的召唤物,顶着一根敌人血条很出戏。
// AgentConfig 40120911.ShowHPBar = true,而血条的第一判据就是它
// (EnemyHUDCtrl.lua:561 canShowHp = cfg.ShowHPBar)—— 配置表是客户端压缩二进制池,
// 服务端改不了 ⇒ 只能从第二个判据下手:shouldShowHp 里的 UnitStateConfig.HideHp。
await ctx.NotifyAsync(MethodId.SyncAddUnitState, new GameMethods.SyncAddUnitStateArgs4229938
{
Id = result.Uid,
State = UnitStateHideHp,
EffectFreezeState = 0,
});
ctx.Session.Log.Info($"[SUMMON] hide hp bar uid={result.Uid} state={UnitStateHideHp} (UnitStateConfig.HideHp)");
await HandOverSummonControl(ctx, summon, SwitchControlReasonSkill, "skill");
return true;
}
// =================================================================================
// ② 交还操控 / 回收(长按 F)与重新接管(N 键)
// =================================================================================
/// <summary>
/// C2S AskStopControlAgent(67046103) — <c>clearAgent=true</c> 是长按 F 回收(连单位一起销毁),
/// <c>false</c> 是 N 键交还操控(单位留在场上,之后还能用 AskControlAgent 切回去)。
///
/// 客户端用 <c>gReliableRpcManager:RegisterRPC</c> 等回执,所以 <b>无论解析成功与否都必须先
/// 回一个 empty OK</b>,否则操控面板的 await 永远不落地(按钮按了没反应)。
/// </summary>
[Handler(MethodId.AskStopControlAgent, HandlerPacketKind.Invoke)]
private async Task AskStopControlAgent(Connection conn, UxRpcMessage msg)
{
bool clearAgent = false;
try
{
clearAgent = msg.GetArgs<SceneMethods.AskStopControlAgent4229938>().clearAgent;
}
catch (Exception ex)
{
// 回执照发,别让客户端挂住;parse 失败只是拿不到语义,行为按"交还操控"处理。
msg.Context.Session.Log.Warn($"[SUMMON] AskStopControlAgent 解析失败(按 clearAgent=false 处理): {ex.Message}");
}
await conn.ReturnEmptyOkAsync(msg);
await OnStopControlAgent(msg.Context, clearAgent);
}
/// <summary>C2S AskControlAgent(67291468) — 重新接管一个已在场的召唤物(N 键的另一半)。</summary>
/// <remarks>
/// ⛔⛔ <b>2026-09-23 18:36 实机抓到的第二个坑:包种类是 NOTIFY,不是 INVOKE。</b>
/// 第一版按 <c>HandlerPacketKind.Invoke</c> 注册,结果 packets 日志里全是这一行:
/// <code>
/// C2S NOTIFY AskControlAgent [0x0402C94C] body=9b
/// ! [RPC4229938] unimplemented notify AskControlAgent [0x0402C94C] -&gt; accepted
/// </code>
/// 也就是说 N 键切回去的包<b>到了服务端</b>,但因为 kind 不匹配被视为未实现的 notify 直接
/// accept 掉,handler 一次都没进(控制台里一条 [SUMMON] 都没有)。表现就是"按 N 没反应"。
/// 对照:交还操控那条 AskStopControlAgent(67046103) 是 <c>C2S INVOKE</c>(要回执),
/// 所以两个必须分开注册 —— 这里用 <c>Both</c> 兜住,将来客户端改成 invoke 也不用改代码。
/// </remarks>
[Handler(MethodId.AskControlAgent, HandlerPacketKind.Both)]
private async Task AskControlAgent(Connection conn, UxRpcMessage msg)
{
ulong agentEntityId = 0;
byte reason = SwitchControlReasonClientSwitch;
try
{
var args = msg.GetArgs<SceneMethods.AskControlAgent4229938>();
agentEntityId = args.agentEntityId;
reason = args.reason;
}
catch (Exception ex)
{
msg.Context.Session.Log.Warn($"[SUMMON] AskControlAgent 解析失败: {ex.Message}");
}
await conn.ReturnEmptyOkAsync(msg);
await OnControlAgent(msg.Context, agentEntityId, reason);
}
private async Task OnStopControlAgent(RpcContext ctx, bool clearAgent)
{
var summon = ctx.Session.Items.TryGetValue(ControllableSummonStateKey, out var raw)
? raw as ControllableSummonState
: null;
if (summon is null)
{
ctx.Session.Log.Warn($"[SUMMON] AskStopControlAgent(clearAgent={clearAgent}) 但没有在场的召唤物记录 — 只回执,不下发");
return;
}
var reason = clearAgent ? SwitchControlReasonClientWithdraw : SwitchControlReasonClientSwitch;
var why = clearAgent ? "recall-long-press-F" : "switch-control-N";
summon.Controlling = false;
await ctx.NotifyAsync(MethodId.SyncSwitchControl, new SceneMethods.SyncSwitchControl4229938
{
spiritId = summon.SpiritId,
agentEntityId = summon.AgentEntityId,
enterOrLeave = false,
reason = reason,
});
if (!clearAgent)
{
ctx.Session.Log.Info($"[SUMMON] control returned uid={summon.AgentEntityId} reason={reason} ({why}) — 单位留在场上,AskControlAgent 可再接管");
return;
}
// 回收:销毁单位。
//
// ⛔⛔ <b>2026-09-23 19:0x 修:移除通道发错了。</b>
// 第一版发的是 <c>SyncAetherAINpcRemove</c>(68332284),那是 <b>ambient 行人 / functional NPC</b>
// 的通道(GameRouter.Ambient.cs / GameRouter.FunctionalNpc.cs 在用)。而蜘蛛 / 无人机是走
// <c>GameAdminBridge.SpawnAgentAsync</c> 出来的,那条路径的第一句话就是
// <c>SyncLogicAgentEnter</c>(68281243) —— 与之配对的是 <b>SyncLogicAgentLeave</b>(68281244)。
// 证据就在同一个文件里:<c>DismissSummonedNpcsAsync()</c>(面板"解散全部召唤角色")用的正是
// SyncLogicAgentLeave,而且它专门注释过"用自带 uid 段才 retire 得掉",
// SyncAetherAINpcRemove 对这类单位完全是另一个注册表,发了等于没发。
// ⇒ 表现:服务端日志打印 `unit removed`,客户端的机器人照样站在原地。
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave,
Ananta.Server.Protocol.Client4229938.WorldCodec.LogicAgentLeave(summon.AgentEntityId));
ctx.Session.Items.Remove(ControllableSummonStateKey);
ctx.Session.Log.Info($"[SUMMON] recalled uid={summon.AgentEntityId} agent={summon.AgentId} reason={reason} ({why}) — SyncLogicAgentLeave sent");
}
private async Task OnControlAgent(RpcContext ctx, ulong agentEntityId, byte reason)
{
var summon = ctx.Session.Items.TryGetValue(ControllableSummonStateKey, out var raw)
? raw as ControllableSummonState
: null;
if (summon is null)
{
ctx.Session.Log.Warn($"[SUMMON] AskControlAgent(uid={agentEntityId}, reason={reason}) 但没有召唤物记录 — 忽略");
return;
}
// 客户端可能带上它自己记的 uid;不一致时以服务端这份为准,但记一条日志方便对线。
if (agentEntityId != 0 && agentEntityId != summon.AgentEntityId)
ctx.Session.Log.Warn($"[SUMMON] AskControlAgent uid={agentEntityId} != 服务端记录 {summon.AgentEntityId} — 按服务端记录下发");
// 去抖:按住 N(或连点)时客户端实测 ~200ms 一发,每次都推会让客户端反复 DoHackEnemy。
// 已经在操控位且上次下发不到 1s,就只记日志;超过 1s 说明客户端没接住,照发一次。
if (summon.Controlling && (DateTime.UtcNow - summon.LastHandoverUtc) < HandoverDebounce)
{
ctx.Session.Log.Info($"[SUMMON] AskControlAgent uid={summon.AgentEntityId} — 已在操控位且 {DateTime.UtcNow - summon.LastHandoverUtc:mm\\:ss} 前才发过,去抖跳过");
return;
}
await HandOverSummonControl(ctx, summon,
reason == 0 ? SwitchControlReasonClientSwitch : reason, "AskControlAgent");
}
// =================================================================================
// ③ 机械狗「聚焦模式」—— ⛔ 2026-09-26 05:4x 已删除(实机失败,用户要求撤掉)。
// 原设计:控制狗时按住鼠标左键 ⇒ 准星对准谁就盘查谁。实测不work,
// 盘查入口改走 GM 面板「盘查模式」+ 里希按 R(见 GameRouter.PoliceExamAuto.cs)。
// 回滚记录:日志 2026-09-26。
// =================================================================================
/// <summary>
/// 交出操控位的固定两件套。顺序是硬要求:SyncControllableAgent 先(不查 ClientData),
/// SyncSwitchControl 后(它第一件事就是 GetUnit + ClientData.AgentId,单位没建号就 print_error)。
/// </summary>
private static async Task HandOverSummonControl(RpcContext ctx, ControllableSummonState summon,
byte reason, string why)
{
await ctx.NotifyAsync(MethodId.SyncControllableAgent, new SceneMethods.SyncControllableAgent4229938
{
spiritId = summon.SpiritId,
agentEntityId = summon.AgentEntityId,
});
await ctx.NotifyAsync(MethodId.SyncSwitchControl, new SceneMethods.SyncSwitchControl4229938
{
spiritId = summon.SpiritId,
agentEntityId = summon.AgentEntityId,
enterOrLeave = true,
reason = reason,
});
summon.Controlling = true;
summon.LastHandoverUtc = DateTime.UtcNow;
ctx.Session.Log.Info($"[SUMMON] handover spirit={summon.SpiritId} uid={summon.AgentEntityId} enter=true reason={reason} ({why})");
}
}