using Ananta.SDK.Network; 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; /// /// 把一个已经在场的单位接进客户端原生的随从 AI。 /// /// ⛑⛑ 2026-10-01 取证:这套协议主线服务端一行都没实现过,但客户端侧是全的 —— /// 随从的跟随、协战、UI 全由客户端 C# 状态机负责,服务端只需要把两个包推下去。 /// 在此之前主线只能把随从当成「不打人的敌人」生成,表现就是挂着血条、打不死、能被玩家锁定。 /// /// 两个包(签名都取自官方 dump.cs 的 IGameSceneToClient 抽象声明,不是猜的): /// SyncAgentCompanionAI(ulong pid, bool isCompanionAI) = 68277327 (dump.cs:1900945) /// SyncCompanionMaster(ulong companionId, ulong masterId) = 68813982 (dump.cs:1901368) /// /// 为什么不自己写跟随:客户端 lua 里找不到这两个方法名以外的任何实现 /// (lua.zip 只有 RPCMethodIdToName 里有名字,实现在 C# 侧),自己驱动等于重新实现一遍 /// 官方的行为树 —— 性价比远低于把原生开关打开。 /// internal sealed partial class GameRouter { /// UnitStateConfig.HideHp = 10266(dump.cs:1448623 / 1449004)。 private const uint CompanionUnitStateHideHp = 10266; /// /// 收起随从头顶的血条:SyncAddUnitState(68026128) + HideHp。 /// 与 GameRouter.EnemyHpBar.cs:55 逐字段对齐(Id / State / EffectFreezeState)。 /// private static async Task NotifyCompanionHideHpAsync(TcpSession session, ulong uid) { try { await session.NotifyAsync(MethodId.SyncAddUnitState, UxSerializer.Serialize(new GameMethods.SyncAddUnitStateArgs4229938 { Id = uid, State = CompanionUnitStateHideHp, EffectFreezeState = 0u, }), CancellationToken.None); } catch (Exception ex) { session.Log.Warn($"[COMPANION] hide-hp failed uid={uid}: {ex.Message}"); } } /// /// 把 绑给当前操控的角色。 /// /// /// 由 SpawnAgentAsync 返回的单位 uid。必须已经 spawn 完 —— 客户端消费方会用 /// masterId / agentId 去 SceneDataMgr 里查单位,spawn 包没落地就查不到。 /// internal static async Task<(bool Ok, string Message)> BindCompanionAsync(ulong companionUid) { if (companionUid == 0) return (false, "随从 uid 为空(生成失败?)"); var target = GameAdminBridge.RequirePlayerSession(); if (target is null) return (false, "还没有玩家会话(先用游戏客户端登录)"); var session = target.Value.Session; // ⛑⛑ masterId **不是** Profile.PlayerPid。那个是账号 pid(本机 100 / 捐赠端 666), // 不在 unit id 空间 —— 2026-09-26 伤害数字攻坚的反汇编结论写得很死: // 「Profile.PlayerPid 是账号 pid,不在 unit id 空间,三段必空」。 // 捐赠端 GameRouter.Enemy.cs:400-402 回退的也是 world?.ActiveSpiritUnitId ?? Profile.InitialUnitId。 // 所以这里必须拿**当前操控单位**的 unit id。 var state = GetStateIfExists(session); var masterId = state?.ActiveSpiritUnitId ?? 0; if (masterId == 0) return (false, "拿不到当前操控单位的 unit id(world 未 ready 或 ActiveSpiritUnitId=0)"); await session.NotifyAsync( MethodId.IGameSceneToClient_SyncAgentCompanionAI, UxSerializer.Serialize(new SceneMethods.SyncAgentCompanionAI4229938 { pid = companionUid, isCompanionAI = true, }), CancellationToken.None); await session.NotifyAsync( MethodId.IGameSceneToClient_SyncCompanionMaster, UxSerializer.Serialize(new SceneMethods.SyncCompanionMaster4229938 { companionId = companionUid, masterId = masterId, }), CancellationToken.None); session.Log.Info($"[COMPANION] bound uid={companionUid} -> master={masterId} " + "(SyncAgentCompanionAI + SyncCompanionMaster)"); // ⛑⛑ 2026-10-01:默认不开服务端驱动。 // 这两个模板的行走/出招写在**专属伴生行为树**里(晴 Companion_JiaMu_Shuangmawei、 // 盖斯 Companion_Guys),服务端查不到它们的技能(AI 行为集里 Behavior_Battle // 是空串、RelatedSkillIds 是空表),硬喂只能给金属吉他手的挥击 —— 就是实机上看到的 // "只有远程射击动作、打不死人"。 // ⇒ 正确做法是把它标记成 Companion(上面两个包)+ 发**模板自带的阵营**(spawn 侧已改), // 然后闭嘴让客户端那棵树自己跑;它自己出招的话,命中的伤害由 // TrackLocalEnemyHitAsync 结算(它在 OnSkillHit 里排在 releaser 校验之前, // 所以 ReleaserId 是随从 uid 也能记账)。 // ⇒ 只有 Companion 树确实不接管时才把 gameplay.aether.companionServerDrive 设 true, // 让服务端用敌人那四个包代劳(GameRouter.CompanionAssist.cs)。 var cfg = PrivateServerConfigStore.Current.Gameplay.Aether; session.Log.Info($"[COMPANION] camp={cfg.CompanionCamp} " + $"driver={(cfg.CompanionServerDrive ? "server(packets)" : "client companion tree")} " + "(开关 gameplay.aether.companionServerDrive)"); // ⛑⛑ 头顶血条(2026-10-01 用户点名):血条不看阵营 —— 判据在 // EnemyHUDCtrl.lua:579:shouldShowHp = ... and not UnitStateMgr:HasState(unit, // UnitStateConfig.HideHp) and ... ⇒ 唯一 per-unit 且服务端可写的开关就是挂 // HideHp 状态(10266)。主线 GameRouter.EnemyHpBar.cs:39 的 // HideLocalEnemyHpBarAsync 用的是同一个包,但它只处理账本内的敌人 // (随从 engage=false、不在账本里)⇒ 这里直接发。 // ⛑ 补一拍:spawn 批是几十个包,AddUnitState 可能比单位在客户端建好更早到 // (engage 五件套也吃这个亏,主线同样延 1.5 s)。 if (cfg.CompanionHideHpBar) { await NotifyCompanionHideHpAsync(session, companionUid); session.Log.Info($"[COMPANION] hp-bar hidden uid={companionUid} state=HideHp(10266) " + "(开关 gameplay.aether.companionHideHpBar)"); _ = Task.Run(async () => { await Task.Delay(1500); await NotifyCompanionHideHpAsync(session, companionUid); }); } // ⛑⛑ 2026-10-01:随从一直「举着枪、慢慢挪」的真凶 —— 客户端给它的 // 上半身持枪瞄准态从来没被清过。主线 GameRouter.EnemyStance.cs:32 // ClearEnemyGunAim4229938 的原文注释: // "upper-body aim is independent of the weapon model and the selected battle // locomotion set" ⇒ 跟手里有没有武器、用哪个动画集都无关,是独立的一层。 // 它只在 profile 判成 unarmed 时才发,而随从 profile 是 null(没进 // EnemyAiCatalog,因为它的招在行为树里)⇒ 永远走不到那行。 // ⇒ 这里补一刀。射击态的 locomotion 本身就是慢速瞄准步,所以「慢慢」多半也是它。 if (cfg.CompanionClearGunAim) { await ClearEnemyGunAim4229938(session, companionUid); session.Log.Info($"[COMPANION] gun-aim cleared uid={companionUid} " + "(SyncEndShootingState + SyncEndShootGameplayTag)"); } // 附加手段(默认关):真的把武器卸掉。随从 InitWeaponId=0,所以这只是个对照开关, // 留着是为了在「清了瞄准态还举枪」时能一键验证是不是武器模型的问题。 if (cfg.CompanionDisarm) { await session.NotifyAsync(MethodId.SyncAgentChangeWeapon, UxSerializer.Serialize(new SceneMethods.EnemyAiWeapon4229938 { agentId = companionUid, weaponId = 0, // 0 = 卸武器(7K melee 模式就是这么干的) }), CancellationToken.None); session.Log.Info($"[COMPANION] disarmed uid={companionUid} (weaponId=0)"); } // ⛑⛑ 7K melee 模式三件套的最后一件:**空手战斗动画集**。 // 官方 Companion 协议里没有 follow/move 包(dump.cs 里一共才三个 Companion 方法), // 跟随本该由客户端那棵树做;它没启动,我们就只能用敌人那套 locomotion 代劳 // ⇒ 走什么步伐、手怎么摆,全由 SyncBattleAnimSet 指的 AnimationSetConfig 决定。 // 主线 SendEnemyCombatStanceAsync 只在 profile 判成 unarmed 时推,随从 profile 是 // null ⇒ 从来没推过 ⇒ 它一直在用默认的持枪/远程动画集走慢步。 // 值取 EnemyAiMethods.cs 注释里写的:325 = 十二面空手、327 = 通用胖子空手, // 0 = 移除覆盖。默认 0(不发)是为了对照;想修「举枪慢步」就填 325。 if (cfg.CompanionAnimSetTag != 0) { await session.NotifyAsync(MethodId.IGameSceneToClient_SyncBattleAnimSet, UxSerializer.Serialize(new SceneMethods.EnemyAiBattleAnim4229938 { id = companionUid, animSetTag = cfg.CompanionAnimSetTag, }), CancellationToken.None); session.Log.Info($"[COMPANION] anim-set uid={companionUid} tag={cfg.CompanionAnimSetTag} " + "(unarmed 325 / 327;0 = 不发)"); } if (cfg.CompanionServerDrive) StartCompanionAssist(companionUid, masterId); return (true, $"uid={companionUid} -> master={masterId}"); } }