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;
///
/// 可接管召唤物(android-control 一家子):
/// 黑客 APP 蜘蛛机器人(51938181)/ 送货无人机(51938183)→ 40120908 / 40120909
/// 巡卫官 APP 机械狗 NCCA Dog(51938184 召唤 / 51938186 驻场态)→ 40120911
///
/// 三条是同一套通道,不是三个功能。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)。
///
/// 为什么以前点了没反应 —— 2026-09-23 抓包确认。
/// 客户端其实一直在上报。packets 日志里这两行进得很清楚:
///
/// 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
///
/// 也就是说:C2S 到了,服务端也认了(走 AllowsAuthoredCharacterSkill 分支),然后——什么
/// 都没做。 OnClientUseSkill 只更新了 combat 沙盘记账就返回了,世界上一个实体也没多出来。
///
/// 客户端那边则是全套自备的(lua.zip):
/// HackScriptFunc.lua SpiderSkill = function() gCS.BattleManager.UseSkillByPid(pid, 51938181) end
/// SkillConfig.json 51938181 召唤蜘蛛机器人 / 51938183 召唤送货无人机,ClientActionAdvance=true
/// 所以玩家看到的"放置动作"是客户端先行演出;真正的单位、操控面板、操控本身,都要服务端
/// 把单位放进世界之后客户端才成立。
///
/// 分工(用户 2026-09-23 确认):召唤出来之后由玩家接管操控,这套逻辑在客户端。
/// 服务端只做三件事:
/// 1. 把实体刷出来(不参与战斗 —— 见 engage:false)
/// 2. 推一次 SyncSwitchControl(68552741) 把玩家放进操控位
/// 3. 接住 AskStopControlAgent(67046103) / AskControlAgent(67291468) 这一对「交还 / 重新接管」
/// 之后 DoHackEnemy → ANDROID_CONTROL_SWITCH → RobotSpiderControlStore / DeliveryDroneStore
/// 全在客户端,我们不插手。
///
/// ⛔⛔ 2026-09-23 18:02 实机抓到的头号坑:spiritId 不能拿 Profile.InitialUnitId。
/// 第一版用 state.WorldEntryControlUnit(= 100000000000 = player.initialUnitId)当
/// spiritId,结果是单位出来了、客户端也收到了两个包,但操控没交出去 —— 表现为「机器人站着
/// 不动,我还在控角色,而且手机打不开」。客户端日志里是这一行铁证:
///
/// [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)
///
/// enemy is nullFalse = 单位找得到;master is nullTrue = 按 spiritId 找不到施法者。
/// 玩家真正的 spirit uid 是 100000000012,三处互相印证:
/// · AskUseSkill 的 Releaser(客户端自己报的施法者)
/// · 我们下发的 SyncPlayerCurrentSpirit {"playerPid":100,"spiritId":100000000012}
/// · 客户端 AskUnitMoveActionSimple 上报自己移动时带的那个 UnitId
/// 而 100000000000 只是 config/private-server.json 的 player.initialUnitId,
/// 是进世界时的占位值,切过角色之后就不再是当前 spirit。
/// ⇒ spiritId 一律优先用 req.Releaser。
///
/// 手机打不开也是同一个 bug 的副作用,不是新问题:客户端 SyncSwitchControl 在 DoHackEnemy
/// 之后会执行 OnSetSkillBtnState(PhoneCall, "isSummonAgentControl", true) 把手机按钮禁掉
/// —— 它以为自己已经在操控召唤物了,而 DoHackEnemy 静默失败,于是「手机锁了但没接管」。
///
internal sealed partial class GameRouter
{
// ---------------------------------------------------------------------------------
// 技能 → 召唤物模板。
//
// SkillConfig.json(table/,build 4229938)里 51938181 / 51938183 记录没有任何指向
// agentId 的字段(全 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;
// 机械狗的武器轮盘走 SceneItem 80003497(SceneitemConfig「机器狗默认武器」),
// 而战斗 spawn 用的是 Weapon 98006028(WeaponConfig「机器狗默认武器」)。
// 两个命名空间不能混用 —— 捐赠端 7k-v0.5 GameRouter.HackerDevices.cs:153 专门注释过。
// 电击能力的落点:98006028 → WeaponConfig.FixedFightSkill 39010040 →
// FightSkillConfig.ActiveSkill 51942305「机械狗范围电击」。武器本身 spawn 帧已经发了,
// 缺的是这一帧:客户端 AgentWeaponManager.lua:25 收到后填 agentWeaponSlotDict 并
// 广播 AGENT_CURRENT_WEAPON_CHANGE,操控 HUD 的武器 / 技能槽就是从这儿来的;
// 不发 ⇒ HUD 上没有武器 ⇒ 按 E 没技能可放。
private const uint PoliceDogWeaponWheelSceneItemId = 80003497;
// 送货/黑客无人机(2026-09-26 补)的武器轮盘走 SceneItem 80003471「零号爪机」
// (SceneitemConfig.OldWeaponId = 98005567「送货无人机」)。
//
// ⛑ 注意这里的命名空间:轮盘发的是 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 说的是友方 NPC
//(战术支援那种走 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 ...
// ⇒ 挂上这个状态就能把血条关掉,而且是 per-unit 的:不动公共 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 键)
/// 召唤物落点:客户端上报的 Location 前方多少米。比玩家自身坐标更贴近意图。
private const float SummonDropDistanceMeters = 2.5f;
/// per-session 当前召唤物记录的 key(session.Items 是 Dictionary<string,object>)。
private const string ControllableSummonStateKey = "client4229938-controllable-summon";
/// 重复"接管"下发的去抖窗口。客户端按住 N 会 ~200ms 连发。
private static readonly TimeSpan HandoverDebounce = TimeSpan.FromSeconds(1);
/// 一次召唤的运行时快照。只存交接操控需要的三个 id。
private sealed class ControllableSummonState
{
public ulong SpiritId; // 玩家 spirit uid(= AskUseSkill.Releaser)
public ulong AgentEntityId; // 召唤物 unit uid
public uint AgentId; // AgentConfig 模板 id(40120908 / 40120909 / 40120911)
/// 服务端视角:操控位此刻是否在召唤物手上(true)还是已交还玩家(false)。
public bool Controlling;
/// 上次下发"接管"的时刻,用于给连发的 AskControlAgent 去抖。
public DateTime LastHandoverUtc;
}
private static uint? ControllableSummonAgentId(uint skillId) => skillId switch
{
HackerSpiderSkillId => HackerSpiderAgentId,
HackerDroneSkillId => HackerDroneAgentId,
PoliceDogSkillId => PoliceDogAgentId,
PoliceDogStandGuardSkillId => PoliceDogAgentId,
DeliveryDroneSkillId => DeliveryDroneAgentId,
_ => null,
};
/// 召唤物的展示名,只用于日志。
private static string ControllableSummonName(uint agentId) => agentId switch
{
HackerSpiderAgentId => "蜘蛛机器人",
HackerDroneAgentId => "送货无人机",
PoliceDogAgentId => "机械狗(NCCA Dog)",
_ => $"agent={agentId}",
};
///
/// SyncAgentWeaponWheel(68121167) 的载荷。抽成方法是为了让 _ux_wire_check 能反射
/// 直接断言生产代码产出的字节,而不是在测试里重写一遍。
///
/// 客户端 reader(RPCDeserializeAuto.lua:171397):
///
/// local agentId = reader.ReadUInt64(reader)
/// local currentWeaponId = reader.ReadUInt32(reader)
/// local weaponSlots = Base.ReadList7Bit(reader, function(r) return r.ReadUInt32(r) end)
///
/// ReadList7Bit = 标记字节 + Int7(count) + count 个 u32。
/// ⛑ 标记字节按项目硬规则 5 用 0xFF(= 有值);UxWriter.Int7(n) 内部写 n+1,别再加偏置。
///
internal static byte[] BuildWeaponWheelWire(ulong uid, uint weaponWheelSceneItemId)
=> new UxWriter()
.U64(uid)
.U32(weaponWheelSceneItemId)
.U8(0xFF)
.Int7(1)
.U32(weaponWheelSceneItemId)
.ToArray();
///
/// 召唤物的武器轮盘 SceneItem id。0 = 这个模板没有轮盘,不要发这一帧。
///
/// ⛑⛑ 2026-09-26 订正(原注释写错了,按它办的 ⇒ 无人机一直没有 E 技能):
/// 原注释拿 AgentSummonCatalog.InitWeaponId 当判据,结论是「只有机械狗有武器,
/// 蜘蛛和无人机 InitWeaponId 都是 0 ⇒ 不用发轮盘」。这个判据是错的 —— 轮盘的
/// 命名空间是 SceneItem(AgentConfig.InitWeaponSceneItemId),而 HUD 上"有没有
/// 技能键"只看 AgentWeaponManager:GetAgentCurrentWeapon 拿不拿得到值,跟单位
/// 实际绑了哪把武器无关。
///
/// 客户端的判据(RobotFlyerControlsStore.lua,无人机操控 HUD):
///
/// 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 技能
///
/// 即:无人机 HUD 上没有 btnSkillPc(那是机械狗/蜘蛛的),它的"原生 E 技能"
/// 就是 btnDrop(长按走 SkillBtnType.Basic),配套还有 btnHitch。
/// 不发轮盘 ⇒ 这两个键直接 SetActive(false) ⇒ 玩家看到的就是"没有 E 技能"。
/// 这是硬规则 173 的同一条链路,只是消费方从 RobotDogControlsStore 换成了
/// RobotFlyerControlsStore。
///
/// 蜘蛛 40120908 暂未纳入:它的 HUD(RobotSpiderControlStore)同样按 weaponId 控制
/// btnSkillPc,映射也已知(80005973「蜘蛛机器人默认武器」→ 98006056,FixedFightSkill
/// 39010099),但本次只改无人机这一处,蜘蛛留作后续。
///
private static uint ControllableSummonWeaponWheel(uint agentId) => agentId switch
{
PoliceDogAgentId => PoliceDogWeaponWheelSceneItemId,
HackerDroneAgentId => DroneWeaponWheelSceneItemId, // 40120909 = 黑客 APP 的无人机
// DeliveryDroneAgentId 与 HackerDroneAgentId 同为 40120909,已在上面覆盖;
// 这里再列一遍只为说明"蓝猫物流部署"走的也是同一个模板,C# switch 不允许重复 case。
_ => 0u,
};
///
/// 每个家族一个独立回滚开关(黑客 / 巡卫犬),别共用 —— 见
/// PrivateServerConfig.PoliceDogSummonEnabled 的注释。
///
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;
}
// =================================================================================
// ① 施放召唤技能
// =================================================================================
///
/// 接住一次可接管召唤技能(蜘蛛 / 无人机 / 机械狗)。
/// 返回 false 表示这不是召唤技能,调用方应继续走普通战斗流程。
///
internal async Task 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 当成约定固化下来了。实际上那几处全是 bug:
// Facing / LastReportedPlayerRotation.Y 都是度数,直接喂 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 之前——玩家一进操控位,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 键)
// =================================================================================
///
/// C2S AskStopControlAgent(67046103) — clearAgent=true 是长按 F 回收(连单位一起销毁),
/// false 是 N 键交还操控(单位留在场上,之后还能用 AskControlAgent 切回去)。
///
/// 客户端用 gReliableRpcManager:RegisterRPC 等回执,所以 无论解析成功与否都必须先
/// 回一个 empty OK,否则操控面板的 await 永远不落地(按钮按了没反应)。
///
[Handler(MethodId.AskStopControlAgent, HandlerPacketKind.Invoke)]
private async Task AskStopControlAgent(Connection conn, UxRpcMessage msg)
{
bool clearAgent = false;
try
{
clearAgent = msg.GetArgs().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);
}
/// C2S AskControlAgent(67291468) — 重新接管一个已在场的召唤物(N 键的另一半)。
///
/// ⛔⛔ 2026-09-23 18:36 实机抓到的第二个坑:包种类是 NOTIFY,不是 INVOKE。
/// 第一版按 HandlerPacketKind.Invoke 注册,结果 packets 日志里全是这一行:
///
/// C2S NOTIFY AskControlAgent [0x0402C94C] body=9b
/// ! [RPC4229938] unimplemented notify AskControlAgent [0x0402C94C] -> accepted
///
/// 也就是说 N 键切回去的包到了服务端,但因为 kind 不匹配被视为未实现的 notify 直接
/// accept 掉,handler 一次都没进(控制台里一条 [SUMMON] 都没有)。表现就是"按 N 没反应"。
/// 对照:交还操控那条 AskStopControlAgent(67046103) 是 C2S INVOKE(要回执),
/// 所以两个必须分开注册 —— 这里用 Both 兜住,将来客户端改成 invoke 也不用改代码。
///
[Handler(MethodId.AskControlAgent, HandlerPacketKind.Both)]
private async Task AskControlAgent(Connection conn, UxRpcMessage msg)
{
ulong agentEntityId = 0;
byte reason = SwitchControlReasonClientSwitch;
try
{
var args = msg.GetArgs();
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;
}
// 回收:销毁单位。
//
// ⛔⛔ 2026-09-23 19:0x 修:移除通道发错了。
// 第一版发的是 SyncAetherAINpcRemove(68332284),那是 ambient 行人 / functional NPC
// 的通道(GameRouter.Ambient.cs / GameRouter.FunctionalNpc.cs 在用)。而蜘蛛 / 无人机是走
// GameAdminBridge.SpawnAgentAsync 出来的,那条路径的第一句话就是
// SyncLogicAgentEnter(68281243) —— 与之配对的是 SyncLogicAgentLeave(68281244)。
// 证据就在同一个文件里:DismissSummonedNpcsAsync()(面板"解散全部召唤角色")用的正是
// 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。
// =================================================================================
///
/// 交出操控位的固定两件套。顺序是硬要求:SyncControllableAgent 先(不查 ClientData),
/// SyncSwitchControl 后(它第一件事就是 GetUnit + ClientData.AgentId,单位没建号就 print_error)。
///
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})");
}
}