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