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