using Ananta.SDK.Network; using Ananta.SDK.Serialization; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// 派对 NPC「驻留行为」—— 让被邀请进派对的角色**自己会动**(2026-09-26 第 46 轮)。 /// /// WHY THIS FILE EXISTS /// -------------------- /// 用户原话:「最主要的还是邀请进派对角色的行为问题,**不互动一个也要让角色们自由行动, /// 自由做动作,不然还是死气沉沉**」。 /// /// 现场事实(第 45/46 轮实测):被邀角色**能被正确认出来(cardId 已修)、会播 POI 预制动作, /// 但一动不动地站在原地做完那一套动作**。原因是 `SendCivilianAsync` 只发到「POI 动作」为止, /// 少了让客户端接管 locomotion 的那几步。 /// /// ★★★★★ 官方口径(全部来自配表/客户端契约,不是猜) /// · `SyncMoveWandering`(68745518) 的契约注释原文(CrowdMethods4229938.cs:204-207): /// 「**让一个已投放的 NPC/行人"自己走起来"的唯一官方包。** /// 它**不带目的地**——只有半径、角度窗口和时间预算,路径由客户端 /// ZoneGraph/AetherAI 自己挑。⇒ 服务端根本不需要路网来做"行走", /// 只需要周期续期(MaxTime 是总时长,走完就永久站住)。」 /// · 项目自己的 static 路人配方(GameRouter.Ambient.cs:804-834 / 792-794): /// `SendCivilianAsync`(建人)+ **`SyncAgentForceGo`(启动行为树)**… 且 /// 「crowd 通道是客户端自驱游荡,服务端发游荡包反而多余;**static 通道必须发, /// 否则只播 idle**」—— 派对 NPC 正好走 static 通道。 /// · `GameRouter.Ambient.cs:16-27` 的终版配方把 `SyncLogicAgentEnter` 列为第 3 步: /// 「把 id 注册进 logic-agent 管理器(**任务从它派发**)—— 不发它,任务可能被静默丢弃」。 /// /// ★ 为什么**不能**改用 crowd 通道(`SyncAetherAICrowdAdd` + `TargetLocationReason=1` 自驱游荡): /// `ClientCrowdInitData4229938` 的字段只有 /// `NpcFormworkId / AgentPersonaId / UrbanDiversityConfigId / DesiredSpeed / ActionId / /// TargetLocationReason / FashionSuitId` + 基类 `Id/Position/Facing/EulerAngles` /// —— **没有 `AIAgentInfo`** ⇒ 带不了 `FightSpiritId` ⇒ `ClientData.cardId` 恒 0 /// ⇒ 第 45 轮刚修好的「烤肉邀请面板」会**立刻退回空列表**。所以通道必须留在 static。 /// /// ★★ 另一条被排除的路(配表级硬判据,别再回头): /// 24 个可邀角色的 `AgentConfig.AiSettingId` 指向的行为集 /// (`AgentAIBehaviorSetsBattleAISBehaviorConfig`)**全部没有游荡行为**: /// 40213395 Companion_Taffy / 40215027 Companion_zhuchang_Lin / 40213369 Companion_Battle / /// 40215025 Bot_Common_SMG / 40213004 Behavior_EmptyBehavior(无战斗AI)… /// 它们的 `Behavior_Wander` 与 `Behavior_StandBy` **都是空字符串**。 /// 对照:会走路的市民档是 40213001/40213002(`Main_Common_NPC`, /// `Behavior_Wander = Behavior_Common_Wander`、`Behavior_StandBy = Behavior_Common_Patrol`)。 /// ⇒ 这些角色在官方场景里是「**跟着玩家的同伴**」,本来就不游荡;拿它们当驻场 NPC /// 就必须由服务端/客户端额外下发行为意图。这正是本文件的职责。 /// /// ⚠ 已知边界(诚实标注) /// 1. `SyncMoveWandering` 对 **crowd 通道(ECS/AgentEntity 管线)** 已被证伪 /// (Ambient.cs:33-34:走 WanderingActionModule = BaseUnit 管线 ⇒ 静默失败)。 /// 本文件只用在 **static 通道** 上(Ambient.cs:792-794 明确说 static 必须发)。 /// 若实测仍不动 ⇒ 把 `PartyNpcRoamMode` 换成 `"waypoint"`(路点巡游,同一份契约里 /// 被称作「唯一能产生走路步态的通道」),无需改代码。 /// 2. 游荡半径取小值(默认 2~10 m)——营地里有家具与碰撞体,绕远会走丢或穿模。 /// 3. 走完 `MaxTime` 就永久站住 ⇒ 必须**周期续期**,见 `PartyNpcRoamRearmSeconds`。 /// /// ★★★ 第 47 轮定案(用户现场):「**停走**」才是正解。 /// 用户实测 waypoint 档 = 「**可以走了,不过是乱走**」⇒ 走路通道本身已跑通 /// (`SyncLogicAgentCommand`(68050574) + `TaskWayPointMove`(tag 39) 有效, /// `SyncMoveWandering` 无效),但马上要求「把角色**围成一圈**、**坐在地面**」。 /// ⇒ 默认 `PartyNpcRoamMode = "none"`;`SyncLogicAgentEnter` / `SyncAgentForceGo` /// 已**上移到模式门之前**(`none` 档也要发 —— 行为树没起来,连 POI 坐姿都保不住)。 /// ⛔ 位置与朝向**本来就是对的**(`SpawnPartyNpcsAsync` 圆周排布 + `angle + π` 朝圆心), /// 是 walk 任务把人走散、并用行走姿态顶掉了坐姿 ⇒ 不需要改坐标,也不需要另写编排。 /// internal sealed partial class GameRouter { /// 本 session 的驻留行为账本(随派对的 NPC 名单一起被重置)。 private const string PartyNpcRoamKey = "party.npc.roam"; /// 巡游命令序号(只需单调递增;客户端把它回执回来)。 private static int s_partyRoamSeq; internal sealed class PartyNpcRoamEntry { internal ulong EntityId; internal float HomeX; internal float HomeY; internal float HomeZ; /// 上一次「下发/续期」的时刻()。 internal long ArmedTicks; /// waypoint 模式:这一腿预计走完的时刻。 internal long EtaTicks; /// waypoint 模式:下一腿的起点(= 上一腿的终点,服务端自己记账,不依赖客户端上报)。 internal float CursorX; internal float CursorY; internal float CursorZ; } internal sealed class PartyNpcRoamState { internal readonly List Entries = []; internal readonly object SyncRoot = new(); internal CancellationTokenSource? Cts; internal Task? Loop; /// ★ 第 49 轮:停走档那条日志每批只打一次(原先每人一条 ⇒ 10 条一样的话 /// 把 [PARTYNPC] 生成汇总 淹掉,而汇总才是真正要看的证据)。 internal bool StopWalkLogged; } private static PartyNpcRoamState RoamStateOf(TcpSession session) { if (!session.Items.TryGetValue(PartyNpcRoamKey, out var raw) || raw is not PartyNpcRoamState state) { state = new PartyNpcRoamState(); session.Items[PartyNpcRoamKey] = state; } return state; } /// 重开派对(撤掉上一批人)时调用:停掉旧循环、清空账本。 internal static void ResetPartyNpcRoam(TcpSession session) { if (!session.Items.TryGetValue(PartyNpcRoamKey, out var raw) || raw is not PartyNpcRoamState state) return; state.Cts?.Cancel(); state.Cts?.Dispose(); state.Cts = null; state.Loop = null; lock (state.SyncRoot) state.Entries.Clear(); state.StopWalkLogged = false; // ★ 第 49 轮:下一批重新打一次停走档日志 } private static bool PartyRoamActive() { var mode = PrivateServerConfigStore.Current.Gameplay.PartyNpcRoamMode; return !string.IsNullOrWhiteSpace(mode) && !mode.Equals("none", StringComparison.OrdinalIgnoreCase); } /// /// 把一个人登记进驻留行为,并起后台续期循环。 /// ⛑ 在 SendCivilianAsync **之后**调用:单位必须先存在,任务才有人接。 /// internal static async Task ArmPartyNpcRoamAsync( TcpSession session, ulong entityId, float x, float y, float z, CancellationToken token) { var cfg = PrivateServerConfigStore.Current.Gameplay; var state = RoamStateOf(session); // ① `SyncLogicAgentEnter` —— 把 id 注册进客户端的 logic-agent 管理器。 // Ambient.cs 原文:「任务/同步都从它派发 —— 不发它,任务可能被静默丢弃」。 if (cfg.PartyNpcLogicEnter) { await session.NotifyAsync(MethodId.SyncLogicAgentEnter, UxSerializer.Serialize(WorldCodec.LogicAgentEnter(entityId)), token); } // ② 独立 `SyncAgentForceGo` —— 启动行为树。 // static 路人配方里它是单独一个包(add 数据里的 ForceGo 字段不足以替代, // 见 Ambient.cs:825-833:SendCivilianAsync 之后还要再发一次)。 if (cfg.PartyNpcForceGo) { await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentForceGo, UxSerializer.Serialize(new SceneMethods.SyncAgentForceGo4229938 { id = entityId, isForceGo = true, }), token); } // ★★★ 第 47 轮:模式门从函数开头**搬到这里**(① Enter / ② ForceGo 之后)。 // 见 PrivateServerConfig.PartyNpcRoamMode 的 `"none"` 说明 —— 用户要的 // 「围成一圈、屁股坐在地面」就是停走档,而停走档**必须仍然**发 Enter/ForceGo // (行为树没起来 ⇒ 连 POI 坐姿都保不住)⇒ 这两步不能跟着被一起跳过。 if (!PartyRoamActive()) { // ★★★ 第 49 轮纠正:这条文案原写死「圆周站位 / 朝向圆心」= **露营专属措辞**, // 而停走档对舞会同样成立(舞会 = 舞池点横排 + 全员统一朝向,见 PartyNpc.cs 的 // `unifiedFacingDeg` 分档)⇒ 在舞会里打这句话等于**把露营摆法说成舞会的**。 // 用户指令:「舞会有舞会的摆法,不要参考露营」⇒ 改成摆放中立的措辞, // 站位/朝向一律以本批 `[PARTYNPC] 生成汇总` 那条为准。 if (!state.StopWalkLogged) { state.StopWalkLogged = true; session.Log.Info( $"[PARTYNPC] 驻留行为=停走(mode={cfg.PartyNpcRoamMode}):只发 Enter/ForceGo," + "不发任何 locomotion ⇒ 角色留在各自站位上保持坐姿(站位与朝向见本批 " + "[PARTYNPC] 生成汇总:露营=圆周+朝圆心,舞会=舞池横排+全员统一朝向)。" + "本条每批只打一次。"); } return; } var entry = new PartyNpcRoamEntry { EntityId = entityId, HomeX = x, HomeY = y, HomeZ = z, CursorX = x, CursorY = y, CursorZ = z, }; lock (state.SyncRoot) { state.Entries.RemoveAll(e => e.EntityId == entityId); state.Entries.Add(entry); } // ③ 行为意图本体。 await PushPartyRoamIntentAsync(session, entry, cfg, token); EnsurePartyNpcRoamLoop(session, state); } /// 按当前模式下发一次行为意图(并回写 )。 private static async Task PushPartyRoamIntentAsync( TcpSession session, PartyNpcRoamEntry entry, GameplaySettings cfg, CancellationToken token) { if (cfg.PartyNpcRoamMode.Equals("waypoint", StringComparison.OrdinalIgnoreCase)) await PushPartyWaypointAsync(session, entry, cfg, token); else await PushPartyWanderAsync(session, entry, cfg, token); } /// /// `SyncMoveWandering`(68745518) —— 客户端自驱游荡意图。 /// 逐字段照抄 `GameRouter.Ambient.PushAmbientWanderAsync`(同一条已验证的 static 配方), /// 只有半径/时长换成派对用的保守值。 /// ⛑ `MoveId` 不是序号,是 `BattleMoveActionType`;填 0(Unknown) 等于「没有 locomotion 状态」。 /// private static async Task PushPartyWanderAsync( TcpSession session, PartyNpcRoamEntry entry, GameplaySettings cfg, CancellationToken token) { var minDis = MathF.Max(0.5f, cfg.PartyNpcRoamMinDis); var maxDis = MathF.Max(minDis + 0.5f, cfg.PartyNpcRoamMaxDis); await session.NotifyAsync(MethodId.SyncMoveWandering, UxSerializer.Serialize(new SceneMethods.SyncMoveWandering4229938 { Data = new SceneMethods.MoveWanderingData4229938 { PathTags = cfg.PartyNpcRoamPathTags, CloseObstacleAvoidance = true, MinDis = minDis, MaxDis = maxDis, InRangeAngle = -180f, OutRangeAngle = 180f, MaxTime = cfg.PartyNpcRoamSeconds + 5f, MaxOnceWanderTime = cfg.PartyNpcRoamSeconds, UnitId = entry.EntityId, MoveType = 0, MoveId = WorldCodec.MoveAction.WalkFront, CloseIK = false, InstanceId = 0, BlockCloseCapsuleCollision = false, }, }), token); entry.ArmedTicks = Environment.TickCount64; } /// /// 对照/退路档:用 `SyncLogicAgentCommand`+`TaskWayPointMove`(tag 39) 让 NPC 沿一条 /// **绕着营地的小多边形**巡游。`Ambient.cs` 文件头称这条通道是 /// 「**唯一能产生走路步态的通道**」。 /// 服务端自己记 `Cursor*`(= 上一腿终点)⇒ 不需要客户端上报位置。 /// private static async Task PushPartyWaypointAsync( TcpSession session, PartyNpcRoamEntry entry, GameplaySettings cfg, CancellationToken token) { var radius = MathF.Max(0.5f, cfg.PartyNpcRoamMaxDis); var angle = Random.Shared.NextSingle() * 2f * MathF.PI; var tx = entry.HomeX + MathF.Sin(angle) * radius; var tz = entry.HomeZ + MathF.Cos(angle) * radius; // 落点保持与出发地同高(营地是平地;不要凭空造 Y 偏移 —— 铁律 17)。 var points = new List(2) { new(entry.CursorX, entry.CursorY, entry.CursorZ), new(tx, entry.HomeY, tz), }; var speed = MathF.Max(0.2f, cfg.PartyNpcRoamSpeed); var dist = MathF.Sqrt((tx - entry.CursorX) * (tx - entry.CursorX) + (tz - entry.CursorZ) * (tz - entry.CursorZ)); await session.NotifyAsync(MethodId.IGameSceneToClient_SyncLogicAgentCommand, UxSerializer.Serialize(WorldCodec.LogicAgentCommandWayPoint( entry.EntityId, Interlocked.Increment(ref s_partyRoamSeq), SceneMethods.AgentMovementMethods.Walk, speed, moveActionId: 0u, moveActionGroup: 0u, keepMovingAction: true, points, noRootMotion: true)), token); entry.CursorX = tx; entry.CursorY = entry.HomeY; entry.CursorZ = tz; entry.ArmedTicks = Environment.TickCount64; entry.EtaTicks = entry.ArmedTicks + (long)(dist / speed * 1000f) + Math.Max(0, cfg.PartyNpcRoamLegSlackMs); } /// /// 续期循环。`MaxTime` 走完客户端会**永久站住**,所以必须周期性补发。 /// 与 `EnsureLeftBehindLoop` 同款:session 断开后写 socket 会抛异常,循环自己退出, /// 不需要额外的停止钩子。 /// private static void EnsurePartyNpcRoamLoop(TcpSession session, PartyNpcRoamState state) { lock (state.SyncRoot) { if (state.Loop is { IsCompleted: false }) return; state.Cts?.Dispose(); var cts = new CancellationTokenSource(); state.Cts = cts; state.Loop = Task.Run(() => RunPartyNpcRoamLoopAsync(session, state, cts.Token)); } } private static async Task RunPartyNpcRoamLoopAsync( TcpSession session, PartyNpcRoamState state, CancellationToken token) { session.Log.Info($"[PARTYNPC] 驻留行为循环启动 mode={PrivateServerConfigStore.Current.Gameplay.PartyNpcRoamMode}"); var waypointMode = PrivateServerConfigStore.Current.Gameplay.PartyNpcRoamMode .Equals("waypoint", StringComparison.OrdinalIgnoreCase); while (!token.IsCancellationRequested) { try { var cfg = PrivateServerConfigStore.Current.Gameplay; var tick = Math.Clamp(cfg.PartyNpcRoamTickMs, 200, 30_000); await Task.Delay(tick, token); // 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。 if (IsBackgroundPushPaused(session)) continue; if (!PartyRoamActive()) continue; List snapshot; lock (state.SyncRoot) snapshot = [.. state.Entries]; if (snapshot.Count == 0) continue; var now = Environment.TickCount64; var rearmMs = (long)(MathF.Max(3f, cfg.PartyNpcRoamRearmSeconds) * 1000f); foreach (var entry in snapshot) { // wander 档:到点就整条续期;waypoint 档:上一腿预计走完才派下一腿。 var due = waypointMode ? now >= entry.EtaTicks : now - entry.ArmedTicks >= rearmMs; if (!due) continue; await PushPartyRoamIntentAsync(session, entry, cfg, token); } } catch (OperationCanceledException) { break; } catch (Exception ex) { // session 断了就会走到这里(往死 socket 写)。记一条然后退出,不要刷屏。 session.Log.Info($"[PARTYNPC] 驻留行为循环退出:{ex.GetType().Name}: {ex.Message}"); break; } } } }