using System.Globalization; using Ananta.SDK.Network; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// 自动氛围行人(ambient pedestrians)—— 玩家一进世界,街上就有会走的人。 /// /// ★★★★★ 2026-09-20 终版配方(移植 donor1「PrivateServer修改」实机验证的路人网络): /// 1. `SyncAetherAICrowdAdd`(crowd 通道)建人:ActionId=**400**(行为动作组,非 POI 动作)、 /// TargetLocationReason=1(Wander)、speed=1.6。 /// 2. `SyncManagedLogicAgent`(68771737)(LogicHandoff)—— **必发**。没有它 ⇒ T-pose; /// 而且它还是**客户端来要路径(AskClientNpcFindPath)的前提**(donor1 第 81 轮三重证据: /// 成功会话 12 条 vs 失败 2 条 / FindPath 310 次 vs 0 次)。 /// 3. `SyncLogicAgentEnter`(68719890) —— 把 id 注册进 logic-agent 管理器(任务从它派发)。 /// 4. `SyncAgentForceGo`(68698324) —— 启动行为树。 /// 5. **移动任务** `SyncLogicAgentCommand`(68050574) + `TaskWayPointMoveCommandData` /// (wire tag 39):自带速度/步态(SpecificMethod=Walk)/动画组,客户端的 /// LogicBehaviorSchedulerModule 负责寻路+避障+步态。**这是唯一能产生走路步态的通道**。 /// 6. 沿真实人行道路网(PedLaneGraph,88361 条车道)每 30m 一段、预计走完+5s 后发下一段。 /// /// ⛑️ 已证伪的两条路(别回头): /// · **位置流** `SyncLogicAgentSyncData`(68838628):不带任务 ⇒ 客户端产生不了步态。 /// 我们 10Hz 推 6280 条、字节布局逐字节对齐客户端上报,人纹丝不动; /// donor1 第 16~23 轮的「服务端硬推位置」同一条死路。 /// · **`SyncMoveWandering`**(68745518):走 `WanderingActionModule`(BaseUnit 管线), /// 而 crowd NPC 是 AgentEntity(ECS 管线)⇒ 静默失败。 /// /// 多态命令线格式(donor1 第 40 轮反汇编钉死,见 LogicAgentCommandMethods.cs): /// tag 字节 = 类型名字母序(39=TaskWayPointMove);基类尾部 MoveId(1B)+DataSource(2B, /// Other=255);Lua 表偏 1 不可信。 /// internal sealed partial class GameRouter { private const string AmbientKey = "client4229938-ambient-peds"; /// 批量退役的分块大小(T1-24)。一次连发上千个 Remove 会打爆客户端 socket。 private const int RemoveBatchSize = 48; private const int RemoveBatchDelayMs = 12; /// 移动任务的相关 id(客户端会把它回执回来;donor1 同款语义)。 private static int s_ambientCommandSeq; /// 模板池过滤结果只打一次日志(见 )。 private static int s_formworkPoolLogged; internal sealed class AmbientPed { internal ulong Id; internal float X; internal float Y; internal float Z; internal long WanderArmedTicks; /// 服务端驱动行走的路网状态()。Walking=false 就是原地不动。 internal bool Walking; internal PedLaneGraph.WalkState Walk; internal float Speed; /// 上一个移动任务的下发时间(TickCount64)—— 任务步进的续接时钟。 internal long TargetSetTicks; /// 上一次实际下发的路程(米)。<=0 表示还没量过,按 stepMetres 兜底。 /// 用它算续接时钟才准:末段常常短于 stepMetres,按满步长等就会"多站一会儿"。 internal float LastStepMetres; /// 续接时钟的随机相位(毫秒)。同一批生成的人如果节拍完全一致, /// 就会"一起起步、一起掉头" —— 各人偏移一点就错开了。 internal int StepJitterMs; /// 目标导向行程的剩余折线(服务端规划,客户端沿它走)。null = 退回随机游走。 internal (float X, float Y, float Z)[]? Route; /// 下一个要走的折线下标。 internal int RouteIdx; /// /// 连续派单失败次数(成功即清零)。达到 `AmbientPedsStepFailLimit` 就回收重生。 /// /// ⛑️ 这是**服务端自己就能发现**的一路卡死:路网状态坏了(车道没了 / 规划不出来 / /// 采样推进失败)时,这个行人会永久停在"派不出任务"的状态 —— 客户端看不到任何异常, /// 表现出来就是一个人杵着不动。donor round 84 的判据是"连续 5 次失败 ⇒ 判定客户端已不再走"。 /// internal int StepFailCount; /// /// 位置回馈校正次数,计在 `AmbientPedsStallWindowMs` 这个滑动窗口里。 /// 达到 `AmbientPedsStallResyncLimit` 说明这个单位**反复**跟服务端账本脱节(贴墙 / 卡地形), /// 校正已经救不回来 ⇒ 回收重生到别处,而不是让它一直杵在那儿。 /// internal int ResyncCount; /// 的计数窗口起点(TickCount64)。 internal long ResyncWindowTicks; /// /// 客户端回执「命令已装好」的次数(AskReportLogicAgentCommandSuccess2 67057559)。 /// /// 2026-09-24 自捐赠端 DremkaPS 移植。他们那句注释是移植的理由: /// SyncLogicAgentCommand 对服务端是 fire-and-forget —— 客户端装了这条命令、还是拿到 /// 就丢了,在出站日志里长得一模一样。有了这对计数,「行人还是不走」才能分成 /// 「收了 12 条一条没跑」和「每一条都被拒了」两种完全不同的故障。 /// internal int CommandAcks; /// 客户端回执「命令失败」的次数(AskReportLogicAgentCommandFail 67193144)。 internal int CommandFails; /// 最近一条回执带回的 commandId(与 s_ambientCommandSeq 同源)。 internal int LastAckCommandId; } internal sealed class AmbientSessionState { internal object SyncRoot { get; } = new(); internal CancellationTokenSource? Cts; internal CancellationTokenSource? MoveCts; /// 最近一次已知的玩家位置 —— 目标导向行程以它为中心取终点, /// 保证行人不会一路走出 ambientPedsRecycleMeters 被回收。 internal float HomeX; internal float HomeZ; internal Task? Loop; internal Task? MoveLoop; internal List Peds { get; } = []; } private static AmbientSessionState AmbientState(TcpSession session) { if (session.Items.TryGetValue(AmbientKey, out var existing) && existing is AmbientSessionState typed) return typed; var created = new AmbientSessionState(); session.Items[AmbientKey] = created; return created; } /// /// 用客户端上报的真实位置校正行人的服务端账本。 /// /// ⛑️ 这是**唯一**的客户端位置回馈通道:散人是客户端自驱的(硬规则 19),服务端推完 /// `TaskWayPointMove` 就不知道人到底走到哪了,只在自己的账本里把 ped.X/Z 推到 /// 任务终点。一旦客户端实际被卡住(贴墙、被玩家推开、插值中断),两边就永久脱节 ⇒ /// 之后每一段任务都从幻想位置出发,行人看起来"站在原地不动"。 /// 2026-09-23 用户实测:"有 1 个卡在原地不动,打他几下让他位移之后又正常走了" —— /// 正是因为击退让客户端位置重新贴合了服务端账本。 /// /// 触发源:AskClientNpcFindPath(67974714) 请求里的 From,就是客户端手里那个 /// 单位的真实坐标(同一 session 34/40 个行人 id 都能对上,确认是我们的单位)。 /// /// ⛑️ 双阈值是必须的:小于 min 是正常误差(车道采样点有间距),大于 max 说明这不是 /// 我们的单位或者数据异常 —— 两种情况都不能写回,否则行人会被拖着乱跳。 /// internal static bool TryResyncAmbientPed(TcpSession session, ulong pedId, float realX, float realZ, out float drift, out bool hardReset) { drift = 0f; hardReset = false; var cfg = PrivateServerConfigStore.Current.Gameplay.Aether; if (!session.Items.TryGetValue(AmbientKey, out var raw) || raw is not AmbientSessionState owner) return false; lock (owner.SyncRoot) { AmbientPed? ped = null; foreach (var p in owner.Peds) { if (p.Id != pedId) continue; ped = p; break; } if (ped is null) return false; var dx = realX - ped.X; var dz = realZ - ped.Z; drift = MathF.Sqrt(dx * dx + dz * dz); if (!float.IsFinite(drift) || drift < MathF.Max(0.5f, cfg.AmbientPedsResyncMinMetres)) return false; if (drift > MathF.Max(1f, cfg.AmbientPedsResyncMaxMetres)) return false; ped.X = realX; ped.Z = realZ; // ⛑️⛑️ 2026-09-23 改:**问路不等于卡住**。 // 实测每人每 ~15 s 问一次路,drift 中位 38 m —— 因为账本在下发任务时就被推到了路线末端, // 客户端多半只是"还没走完"。以前只要 drift 落在 [3,60] 就 Route=null + 立刻重派 // ⇒ 行人每 15 秒被掐断一次行程,表现就是走走停停 / 杵着不动。 // 现在只有"真实位置已经不在当前路线上"才算真脱节,才清路线重规划。 if (ped.Route is { Length: > 0 } route && RouteDistance(route, realX, realZ, out var nearestIdx) <= MathF.Max(3f, cfg.AmbientPedsResyncRouteToleranceMetres)) { // 软校正:账本归位 + 跳过已经走过的折线点,剩下的行程继续走完。 var next = Math.Min(nearestIdx + 1, route.Length - 1); if (next > ped.RouteIdx) ped.RouteIdx = next; var walk0 = ped.Walk; PedLaneGraph.SnapTo(realX, realZ, ref walk0); ped.Walk = walk0; return true; } // 硬校正:确实偏离了路线(被击退 / 自驱跑偏 / 卡地形)⇒ 从真实位置重新规划。 hardReset = true; ped.Route = null; ped.RouteIdx = 0; var walk = ped.Walk; PedLaneGraph.SnapTo(realX, realZ, ref walk); ped.Walk = walk; ped.TargetSetTicks = 0; // 立刻派下一段,不再干等余量 // 滑动窗口计数:窗口过期就重新开一局。反复脱节(而不是偶发一次)才算"顽固卡住"。 var nowTick = Environment.TickCount64; if (nowTick - ped.ResyncWindowTicks > Math.Max(1000, cfg.AmbientPedsStallWindowMs)) { ped.ResyncWindowTicks = nowTick; ped.ResyncCount = 0; } ped.ResyncCount++; return true; } } /// 点到折线的最短距离,并给出最近点的下标。 private static float RouteDistance((float X, float Y, float Z)[] route, float x, float z, out int nearestIdx) { nearestIdx = 0; var best = float.MaxValue; for (var i = 0; i < route.Length; i++) { var d = MathF.Sqrt((route[i].X - x) * (route[i].X - x) + (route[i].Z - z) * (route[i].Z - z)); if (d < best) { best = d; nearestIdx = i; } } // 折线可能稀疏(采样间距 6 m),点到点的距离不够,还要算点到线段的距离。 for (var i = 0; i + 1 < route.Length; i++) { var d = SegmentDistance(route[i].X, route[i].Z, route[i + 1].X, route[i + 1].Z, x, z); if (d < best) { best = d; nearestIdx = i; } } return best; } private static float SegmentDistance(float ax, float az, float bx, float bz, float px, float pz) { var vx = bx - ax; var vz = bz - az; var wx = px - ax; var wz = pz - az; var len2 = vx * vx + vz * vz; var t = len2 <= 0f ? 0f : Math.Clamp((wx * vx + wz * vz) / len2, 0f, 1f); var cx = ax + vx * t; var cz = az + vz * t; return MathF.Sqrt((px - cx) * (px - cx) + (pz - cz) * (pz - cz)); } /// 世界就绪后调用一次;幂等。配置关掉就直接返回。 internal static void EnsureAmbientPeds(TcpSession session) { var cfg = PrivateServerConfigStore.Current.Gameplay.Aether; // ⛑️ crowdEnabled 是人群总开关:它关着时一个服务端行人都不发, // 否则用户关了「启用人群」街上照样有 40 个行人(2026-09-24 实测)。 if (!cfg.AmbientPedsEnabled || !cfg.CrowdEnabled) return; var owner = AmbientState(session); lock (owner.SyncRoot) { if (owner.Loop is { IsCompleted: false }) return; owner.Cts?.Dispose(); owner.Cts = new CancellationTokenSource(); var token = owner.Cts.Token; owner.Loop = Task.Run(() => RunAmbientLoopAsync(session, owner, token)); // 服务端驱动行走:独立的较高频循环(默认 100ms)只负责推进 + 下发位置。 if (cfg.AmbientPedsDriven && PedLaneGraph.Available) { owner.MoveCts?.Dispose(); owner.MoveCts = new CancellationTokenSource(); owner.MoveLoop = Task.Run(() => RunAmbientMoveLoopAsync(session, owner, owner.MoveCts.Token)); } } } /// /// 把场上所有服务端行人**真回收**:逐个发 SyncAetherAINpcRemove 再停循环。 /// /// ⛑️ 为什么不能直接 StopAmbientPeds:那个只停循环、本地链表一清了事, /// 客户端上的人还站着 —— 面板关「启用人群」后用户看到的"还是有人"多半就是这批。 /// 分块(RemoveBatchSize + RemoveBatchDelayMs)沿用回收路径的老规矩,一次性连发会打爆 socket。 /// internal static async Task RetireAmbientPedsAsync(TcpSession session) { if (!session.Items.TryGetValue(AmbientKey, out var existing) || existing is not AmbientSessionState owner) return; List all; lock (owner.SyncRoot) { all = [.. owner.Peds]; owner.Peds.Clear(); owner.Cts?.Cancel(); owner.MoveCts?.Cancel(); } session.Log.Info($"[AMBIENT] retire all peds count={all.Count}"); if (all.Count == 0) return; for (var i = 0; i < all.Count; i += RemoveBatchSize) { if (i > 0) await Task.Delay(RemoveBatchDelayMs, CancellationToken.None); var end = Math.Min(all.Count, i + RemoveBatchSize); for (var j = i; j < end; j++) { try { await session.NotifyAsync(MethodId.SyncAetherAINpcRemove, UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = all[j].Id }), CancellationToken.None); } catch (Exception ex) { session.Log.Warn($"[AMBIENT] retire remove failed pid={all[j].Id}: {ex.Message}"); } } } } /// 断开/切场景时停掉循环(不主动移除行人 —— 场景卸载客户端自己清)。 internal static void StopAmbientPeds(TcpSession session) { if (!session.Items.TryGetValue(AmbientKey, out var existing) || existing is not AmbientSessionState owner) return; lock (owner.SyncRoot) { owner.Cts?.Cancel(); owner.Cts?.Dispose(); owner.Cts = null; owner.MoveCts?.Cancel(); owner.MoveCts?.Dispose(); owner.MoveCts = null; owner.Peds.Clear(); } } private static async Task RunAmbientLoopAsync(TcpSession session, AmbientSessionState owner, CancellationToken token) { var a = PrivateServerConfigStore.Current.Gameplay.Aether; var laneStats = PedLaneGraph.Stats(); var channel = IsCrowdChannel(a) ? "crowd" : "static"; session.Log.Info($"[AMBIENT] pedestrian loop started channel={channel} " + $"actionId={a.AmbientPedsCrowdActionId} " + $"targetReason={a.AmbientPedsTargetLocationReason} " + $"poiAction={a.AmbientPedsPoiActionId} speed={a.AmbientPedsDesiredSpeed} " + $"driven={a.AmbientPedsDriven && PedLaneGraph.Available} " + $"task=TaskWayPointMove(tag39) lanes={laneStats.Lanes}/banned={laneStats.Banned}/width={laneStats.WithWidth} " + $"step={a.AmbientPedsStepMetres:F0}m tickMs={a.AmbientPedsSyncMs} " + $"logicEnter={a.AmbientPedsLogicEnter} handoff={a.AmbientPedsCrowdManagedLogicAgent}"); var tick = Math.Max(200, PrivateServerConfigStore.Current.Gameplay.Aether.AmbientPedsTickMs); while (!token.IsCancellationRequested) { // 运行中被面板关掉人群 ⇒ 当场回收已下发的行人并退出(不用等重进场景)。 if (!PrivateServerConfigStore.Current.Gameplay.Aether.CrowdEnabled) { session.Log.Info("[AMBIENT] loop stopping: crowd disabled from panel"); await RetireAmbientPedsAsync(session); break; } try { // 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。 if (!IsBackgroundPushPaused(session)) await TickAmbientAsync(session, owner, token); } catch (OperationCanceledException) { break; } catch (ObjectDisposedException) { session.Log.Info("[AMBIENT] loop stopped: session disposed"); break; } catch (Exception ex) { session.Log.Info($"[AMBIENT] tick failed: {ex.Message}"); } try { await Task.Delay(tick, token); } catch (OperationCanceledException) { break; } } } /// /// 服务端驱动行走循环(donor1 实机配方,2026-09-20 移植):**移动任务步进**,不是位置流。 /// /// 每个 Walking 的行人沿 PedLaneGraph 抽一段 30m 折线,发一条 `TaskWayPointMove` 命令 /// (`SyncLogicAgentCommand` 68050574 + wire tag 39,自带速度/步态/动画组); /// 预计走完的时间(步长 ÷ 速度 + 5s 余量)过后再发下一段 —— 与 donor1 的 /// `CrowdLaneNeedsTarget` 同一条时钟。 /// /// ⛑️ 为什么不用位置流:`SyncLogicAgentSyncData`(68838628)不携带任何任务, /// 客户端产生不了步态。donor1 第 39 轮实测 Servo 流 = 走两秒动画然后站住; /// 我们 09-20 实测 10Hz 推 6280 条、字节布局与客户端上报逐字节对齐,人依然纹丝不动。 /// donor1 AI_HANDOFF 明确把「服务端硬推位置」标记为**已证伪路线**(路人第 16~23 轮的老路)。 /// private static async Task RunAmbientMoveLoopAsync( TcpSession session, AmbientSessionState owner, CancellationToken token) { var cfg = PrivateServerConfigStore.Current.Gameplay.Aether; var intervalMs = Math.Clamp(cfg.AmbientPedsSyncMs, 200, 2000); var stepMetres = MathF.Max(6f, cfg.AmbientPedsStepMetres); var stepsSent = 0; while (!token.IsCancellationRequested) { try { await Task.Delay(intervalMs, token); } catch (OperationCanceledException) { break; } catch (ObjectDisposedException) { break; } // 客户端挂起期间不推进行走任务 —— 见 GameRouter.IsBackgroundPushPaused 的说明。 if (IsBackgroundPushPaused(session)) continue; var now = Environment.TickCount64; List walkers; lock (owner.SyncRoot) walkers = owner.Peds.Where(p => p.Walking).ToList(); if (walkers.Count == 0) continue; var sent = 0; foreach (var ped in walkers) { // donor1 的续接时钟:expected + 5s 余量(Walk 期间客户端有到达判定,提前发会重启步态)。 // ⛑️ 这个余量就是"行人站着发呆"的那段时间:走完一段后要空等它过去才发下一段。 // 想让发呆变短就调 `ambientPedsStepSlackMs`,但调太小会在客户端还没走完时提前派活 // ⇒ 行人被拽回上一段终点(瞬移)。零瞬移的做法是加长 stepMetres、降低发呆频率。 // ⛑️ 按**实际下发**的路程算,不是按满步长:末段常常短于 stepMetres, // 按 60m 去等就会多站一会儿(用户看到的"走走停停"有一部分来自这里)。 var metres = ped.LastStepMetres > 0f ? ped.LastStepMetres : stepMetres; var expected = (long)(metres / MathF.Max(0.3f, ped.Speed) * 1000f) + Math.Max(0, cfg.AmbientPedsStepSlackMs) + ped.StepJitterMs; if (ped.TargetSetTicks != 0 && now - ped.TargetSetTicks < expected) continue; if (await PushAmbientStepCommandAsync(session, owner, ped, stepMetres, token)) { sent++; ped.StepFailCount = 0; } else { // 派不出来(路网状态坏 / 规划不出来)不报错,只累加 —— 由 TickAmbientAsync // 的看门狗在达到 `AmbientPedsStepFailLimit` 时回收重生。 // ⛑️ 不在这里直接回收:这个 Task 只负责推任务,remove/补点归 TickAmbientAsync。 ped.StepFailCount++; } } if (sent > 0) { stepsSent += sent; session.Log.Info($"[AMBIENT] step commands sent={sent} total={stepsSent} live={walkers.Count}"); } } } /// /// 给一个行人发下一段 `TaskWayPointMove` 移动任务:从当前位置沿路网推进 /// 米,沿途采样折线(起点 + 每 ~6m 一点 + 终点)。 /// 只有推进成功才写回路网状态 —— 失败时 原样保留,下一拍重试。 /// private static async Task PushAmbientStepCommandAsync( TcpSession session, AmbientSessionState owner, AmbientPed ped, float stepMetres, CancellationToken token) { var cfg = PrivateServerConfigStore.Current.Gameplay.Aether; // ⛑️ 采样间距固定(`ambientPedsSampleMetres`,默认 6m),不再随路程放大 —— // 车道中位数才 3.58m,采样太稀会让行人切弯穿绿化。 var chunk = MathF.Max(2f, cfg.AmbientPedsSampleMetres); var points = new List(24) { new(ped.X, ped.Y, ped.Z) }; // ---- ① 目标导向行程(默认):一次规划几十米开外的整条路径,行人穿过多个路口 ---- if (cfg.AmbientPedsJourneyEnabled && PedLaneGraph.Available) { if (ped.Route is null || ped.RouteIdx >= ped.Route.Length - 1) { // 传**比例**而不是米数:实际偏移 = 该点车道的 HalfWalk × 这个值,窄路自动不偏 // (2026-09-23 修:"窄路上行人卡在街边墙壁里" —— 固定 0.6m 跟路宽无关,照样把人推进墙)。 var planned = PedLaneGraph.TryPlanJourney(ped.X, ped.Z, owner.HomeX, owner.HomeZ, cfg.AmbientPedsJourneyMinMetres, cfg.AmbientPedsJourneyMaxMetres, ped.Walk.Offset, Random.Shared); ped.Route = planned is { Count: >= 2 } ? planned.ToArray() : null; ped.RouteIdx = 0; } var route = ped.Route; if (route is { Length: >= 2 } && ped.RouteIdx < route.Length - 1) { var need = stepMetres; var acc = 0f; var cx = ped.X; var cz = ped.Z; while (need > 0f && ped.RouteIdx < route.Length - 1) { var p = route[++ped.RouteIdx]; var dx = p.X - cx; var dz = p.Z - cz; var d = MathF.Sqrt(dx * dx + dz * dz); cx = p.X; cz = p.Z; need -= d; acc += d; if (acc >= chunk) { points.Add(new SceneMethods.UxVector3(p.X, p.Y, p.Z)); acc = 0f; } } // 末点必须是真实落点,否则客户端走到倒数第二个点就"没路了",又会站住。 var tail = route[ped.RouteIdx]; var ex = tail.X - points[^1].X; var ez = tail.Z - points[^1].Z; if (ex * ex + ez * ez > 0.09f) points.Add(new SceneMethods.UxVector3(tail.X, tail.Y, tail.Z)); return await SendAmbientStepAsync(session, ped, points, token); } } // ---- ② 退回随机游走 ---- // ⛑️ 从目标导向退回来必须先吸附:Walk.Lane 还停在生成时那条老车道上, // 直接 Advance 会从几十米外出点 ⇒ 行人瞬移。 var walk = ped.Walk; // struct 副本:只有整段推进成功才写回 PedLaneGraph.SnapTo(ped.X, ped.Z, ref walk); var remaining = stepMetres; while (remaining > 0f) { var d = MathF.Min(chunk, remaining); if (!PedLaneGraph.Advance(ref walk, d, Random.Shared, out var x, out var y, out var z, out _)) return false; points.Add(new SceneMethods.UxVector3(x, y, z)); remaining -= d; } ped.Walk = walk; ped.Route = null; // 下次再试目标导向 return await SendAmbientStepAsync(session, ped, points, token); } /// 把折线封装成一条 `TaskWayPointMove` 发出,并回写行人位置与实际路程。 private static async Task SendAmbientStepAsync( TcpSession session, AmbientPed ped, List points, CancellationToken token) { if (points.Count < 2) return false; var metres = 0f; for (var i = 1; i < points.Count; i++) { var dx = points[i].X - points[i - 1].X; var dz = points[i].Z - points[i - 1].Z; metres += MathF.Sqrt(dx * dx + dz * dz); } await session.NotifyAsync(MethodId.IGameSceneToClient_SyncLogicAgentCommand, UxSerializer.Serialize(WorldCodec.LogicAgentCommandWayPoint( ped.Id, Interlocked.Increment(ref s_ambientCommandSeq), SceneMethods.AgentMovementMethods.Walk, ped.Speed, moveActionId: 0u, moveActionGroup: 0u, keepMovingAction: true, points, noRootMotion: true)), token); ped.X = points[^1].X; ped.Y = points[^1].Y; ped.Z = points[^1].Z; ped.LastStepMetres = metres; ped.TargetSetTicks = Environment.TickCount64; return true; } private static async Task TickAmbientAsync(TcpSession session, AmbientSessionState owner, CancellationToken token) { var cfg = PrivateServerConfigStore.Current.Gameplay.Aether; var state = GetStateIfExists(session); if (state is null) return; Vec3 player; lock (state.SyncRoot) { if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0) return; player = state.LastReportedPlayerPosition; } if (!float.IsFinite(player.X) || !float.IsFinite(player.Z)) return; // 目标导向行程的中心锚点(驱动循环是另一个 Task,拿不到这里的 player)。 owner.HomeX = player.X; owner.HomeZ = player.Z; // ---- 1. 回收走远了的 ---- var recycle = MathF.Max(cfg.AmbientPedsRecycleMeters, cfg.AmbientPedsRadiusMeters + 50f); List gone = []; var stalled = 0; var nowWatch = Environment.TickCount64; var stallWindow = Math.Max(1000, cfg.AmbientPedsStallWindowMs); var failLimit = Math.Max(2, cfg.AmbientPedsStepFailLimit); var resyncLimit = Math.Max(2, cfg.AmbientPedsStallResyncLimit); lock (owner.SyncRoot) { for (var i = owner.Peds.Count - 1; i >= 0; i--) { var ped = owner.Peds[i]; // ---- 看门狗:两路卡死判定,命中就回收重生(下面第 3 步会自动补点)---- // ① 服务端侧:连续派不出任务 —— 路网状态坏了,行人会永久杵着不动。 // ② 客户端侧:窗口内反复位置脱节 —— 贴墙/卡地形,校正也救不回来。 // ⛑️ 判据都用"连续/窗口内 N 次"而不是一次:偶发一次是正常抖动, // 一次就回收会让行人在正常游走时凭空消失(比站着不动更难看)。 var stalledNow = ped.StepFailCount >= failLimit || (ped.ResyncCount >= resyncLimit && nowWatch - ped.ResyncWindowTicks <= stallWindow); if (stalledNow) { stalled++; gone.Add(ped); owner.Peds.RemoveAt(i); continue; } var dx = ped.X - player.X; var dz = ped.Z - player.Z; if (dx * dx + dz * dz <= recycle * recycle) continue; gone.Add(ped); owner.Peds.RemoveAt(i); } } // ⛑️ 一次性连发上千个 Remove 会把客户端 socket 直接打爆(donor 实测 1952~2204 个 ⇒ // "你的主机中的软件中止了一个已建立的连接")。分块 48 个/批 + 批间 12 ms 之后, // 他们的进世界延迟 2500/4500 ms → 0。传送 / 切场景时 gone 会很大,这是我们最容易莫名掉线的地方之一。 for (var i = 0; i < gone.Count; i += RemoveBatchSize) { if (i > 0) await Task.Delay(RemoveBatchDelayMs, token); var end = Math.Min(gone.Count, i + RemoveBatchSize); for (var j = i; j < end; j++) await session.NotifyAsync(MethodId.SyncAetherAINpcRemove, UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = gone[j].Id }), token); } // ---- 2. 游荡续期(MaxTime 是总预算,走完就永久站住)---- // crowd 通道是客户端自驱游荡,没有总时长上限,不用重发。 List due = []; if (!IsCrowdChannel(cfg)) { var rearmMs = (long)(MathF.Max(5f, cfg.AmbientPedsWanderRearmSeconds) * 1000f); var now = Environment.TickCount64; lock (owner.SyncRoot) due = owner.Peds.Where(p => now - p.WanderArmedTicks >= rearmMs).ToList(); foreach (var ped in due) await PushAmbientWanderAsync(session, cfg, ped, token); } if (gone.Count > 0 || due.Count > 0) session.Log.Info($"[AMBIENT] recycle={gone.Count} (stalled={stalled}) reWander={due.Count} live={owner.Peds.Count}"); // ---- 3. 补点 ---- int live; lock (owner.SyncRoot) live = owner.Peds.Count; var deficit = cfg.AmbientPedsTarget - live; if (deficit <= 0) return; var batch = Math.Min(deficit, cfg.AmbientPedsTopUpPerTick); var spawned = 0; var driven = cfg.AmbientPedsDriven && PedLaneGraph.Available; for (var i = 0; i < batch; i++) { float sx, sy, sz; var walking = false; var walk = default(PedLaneGraph.WalkState); if (driven && PedLaneGraph.TryPickSpawn(player.X, player.Z, cfg.AmbientPedsRadiusMeters, Random.Shared, out sx, out sy, out sz, out walk)) { walking = true; } else { var spot = PedSpawnTable.PickNear(player.X, player.Z, cfg.AmbientPedsRadiusMeters, cfg.AmbientPedsMinGapMeters, owner.Peds); if (spot is null) break; sx = spot.Value.X; sy = spot.Value.Y; sz = spot.Value.Z; } var ped = await SpawnAmbientPedAsync(session, owner, cfg, sx, sy, sz, walking, walk, token); if (ped is null) break; lock (owner.SyncRoot) owner.Peds.Add(ped); spawned++; } if (spawned > 0) session.Log.Info($"[AMBIENT] spawned {spawned} pedestrians near " + $"({player.X:F0},{player.Z:F0}) live={live + spawned}/{cfg.AmbientPedsTarget}"); } private static bool IsCrowdChannel(AetherSettings cfg) => string.Equals(cfg.AmbientPedsChannel, "crowd", StringComparison.OrdinalIgnoreCase); private static async Task SpawnAmbientPedAsync( TcpSession session, AmbientSessionState owner, AetherSettings cfg, float sx, float sy, float sz, bool walking, PedLaneGraph.WalkState walk, CancellationToken token) { var formwork = PedFormworkPool.Pick(); if (formwork is null) return null; // T0-28:模板池过滤结果只打一次 —— 判"是不是滤空了 / 滤掉了多少"全靠这一行。 if (Interlocked.Exchange(ref s_formworkPoolLogged, 1) == 0) session.Log.Info($"[AMBIENT] formwork pool {PedFormworkPool.Count} usable " + $"(raw={PedFormworkPool.RawCount}, filtered={PedFormworkPool.Filtered})"); var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor)); // ⛑️ 2026-09-26:出生朝向必须跟着车道走向。原来是纯随机(-180~180), // 而位移由 TaskWayPointMove 的路径点决定 ⇒ 身体朝 A、位移朝 B,看起来就是"倒退着走路"。 // 非路网出生(站位表)没有走向可言,才退回随机。 var facing = Random.Shared.NextSingle() * 360f - 180f; if (walking && PedLaneGraph.TryFacing(walk, out var laneFacing)) facing = laneFacing; var crowd = IsCrowdChannel(cfg); var spot = new PedSpawnPoint(sx, sy, sz); if (crowd) await SendAmbientCrowdPedAsync(session, cfg, formwork.Value, id, spot, facing, token); else await SendAmbientStaticPedAsync(session, cfg, formwork.Value, id, spot, facing, token); var ped = new AmbientPed { Id = id, X = sx, Y = sy, Z = sz, WanderArmedTicks = 0, Walking = walking, Walk = walk, Speed = MathF.Max(0.1f, cfg.AmbientPedsDesiredSpeed) * (0.85f + Random.Shared.NextSingle() * 0.3f), // 同一批生成的人如果节拍完全一致,就会"一起起步、一起掉头" —— 各人错开一点。 StepJitterMs = Random.Shared.Next(0, Math.Max(1, cfg.AmbientPedsStepSlackMs)), }; // crowd 通道是客户端自驱游荡,服务端发游荡包反而多余;static 通道必须发,否则只播 idle。 if (!crowd) await PushAmbientWanderAsync(session, cfg, ped, token); // donor1 第 39 轮:移动任务在**创建时**就发第一条,不等驱动循环的首拍 —— // 否则路人在第一拍之前是站着的。路网可用(walking)才发。 if (walking) await PushAmbientStepCommandAsync(session, owner, ped, MathF.Max(6f, cfg.AmbientPedsStepMetres), token); return ped; } /// static 通道:静态 NPC + 行为树 + simulation owner(人一定出得来、有动画)。 private static async Task SendAmbientStaticPedAsync( TcpSession session, AetherSettings cfg, uint formwork, ulong id, PedSpawnPoint spot, float facing, CancellationToken token) { // ⛑️⛑️ POI 动作**不能留 0**。0 = 无动作 ⇒ 站着不动。 // 1 = UrbanDiversityPOIActionConfig.Walk(走路)。 await SendCivilianAsync(session, new CivilianSpawnItem(formwork, cfg.AmbientPedsPoiActionId), id, spot.X, spot.Y, spot.Z, facing); // 客户端是空 stub(无 override),默认关;留着给未来客户端版本/对照实验。 if (cfg.AmbientPedsSendDesiredSpeed) { await session.NotifyAsync(MethodId.SyncChangeNpcMoveDesiredSpeed, UxSerializer.Serialize(new SceneMethods.SyncChangeNpcMoveDesiredSpeed4229938 { EntityId = id, DesiredSpeed = cfg.AmbientPedsDesiredSpeed, }), token); } if (cfg.AmbientPedsForceGo) { await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentForceGo, UxSerializer.Serialize(new SceneMethods.SyncAgentForceGo4229938 { id = id, isForceGo = true, }), token); } } /// /// crowd 通道:`SyncAetherAICrowdAdd`(68757757)(donor_lyco082 实机验证的市民通道)。 /// 客户端自驱游荡,服务端不用发移动包。⚠️ NpcFormworkId 必须是 AgentConfig id(4013xxxx)。 /// private static async Task SendAmbientCrowdPedAsync( TcpSession session, AetherSettings cfg, uint formwork, ulong id, PedSpawnPoint spot, float facing, CancellationToken token) { await session.NotifyAsync(MethodId.SyncAetherAICrowdAdd, UxSerializer.Serialize(new SceneMethods.SyncAetherAICrowdAdd4229938 { crowd = new SceneMethods.ClientCrowdInitData4229938 { NpcFormworkId = formwork, AgentPersonaId = cfg.AmbientPedsCrowdPersonaId, UrbanDiversityConfigId = 0, DesiredSpeed = cfg.AmbientPedsDesiredSpeed, // ★ 走起来靠 TargetLocationReason=1(Wander):之前填 0(None) = 告诉客户端 // "这个人没有目标位置意图" ⇒ 人出来了但不走。 // ⛑️ ActionId 是**动作组 400**(不是 POI 动作 id 1)—— 见下面 POI 处注释,别改回 1。 ActionId = cfg.AmbientPedsCrowdActionId, TargetLocationReason = cfg.AmbientPedsTargetLocationReason, FashionSuitId = 0, Id = id, Position = new SceneMethods.UxVector3(spot.X, spot.Y, spot.Z), Facing = facing, EulerAngles = new SceneMethods.UxVector3(0f, facing, 0f), }, }), token); // ⛑️⛑️ 没有 simulation owner ⇒ ECS→GO 交接后全模型状态机被关掉 ⇒ **T-pose**。 // ★ donor1 第 81 轮(三重证据):`SyncManagedLogicAgent` 也是**客户端来要路径 // (AskClientNpcFindPath)的前提** —— 状态机被关掉,客户端根本不来问, // 服务端推多少命令都没人接。所以 driven 模式必须发。 if (cfg.AmbientPedsCrowdManagedLogicAgent) { await session.NotifyAsync(MethodId.SyncManagedLogicAgent, UxSerializer.Serialize(WorldCodec.ManagedLogicAgent(id, Profile.PlayerPid, 0)), token); } // donor1 第 37 轮:Enter 把 id 注册进客户端的 logic-agent 同步管理器, // 任务/同步都从它派发 —— 不发它,任务可能被静默丢弃。 if (cfg.AmbientPedsLogicEnter) { await session.NotifyAsync(MethodId.SyncLogicAgentEnter, UxSerializer.Serialize(WorldCodec.LogicAgentEnter(id)), token); } // ⛑️⛑️ 2026-09-22 槽位归位(T0-9): // `ClientCrowdInitData.ActionId` = **动作组**(400 = NPC 默认,donor 第 38 轮面板实测,上限 499) // `SyncAgentPlayPOIAction.CPoiActionId` = **POI 动作 id**(POI 表:1=走路/9018、2=idle/1001、17=站姿闲置/1001) // 这两个是**不同的表**。之前把动作组号 400 塞进 POI 槽位 ⇒ 每个行人都在播 // POI id 400「站姿_工人_记录(平板)」(还带 UsageId=81,需要平板道具,而 crowd 通道从不发 belongings) // ⇒ 出场就是个拿平板记录的站姿,跟"走路"相反。位移靠 TaskWayPointMove,这里只负责动画状态 ⇒ 填 1。 if (cfg.AmbientPedsCrowdPlayPoiAction && cfg.AmbientPedsPoiActionId != 0) { await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentPlayPOIAction, UxSerializer.Serialize(new GameMethods.DonorClientNpcPoiActionData4229938 { Id = id, SPoiActionId = 0, CPoiActionId = cfg.AmbientPedsPoiActionId, IsActive = true, PlayPoiSpeed = 1f, }), token); } if (cfg.AmbientPedsForceGo) { await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentForceGo, UxSerializer.Serialize(new SceneMethods.SyncAgentForceGo4229938 { id = id, isForceGo = true, }), token); } // T2-32 路人骇入:crowd 通道同样要标可骇(static 通道在 SendCivilianAsync 尾部标)。 // 行人一条 belongings 都没有 ⇒ 连手机都没有 ⇒ 按钮不会出,所以这里是 GivePhoneToCrowdPeds 的主战场。 await MarkNpcHackableOnSpawnAsync(session, id, HackNpcChannel.Crowd, token); } /// /// 下发/续期一个行人的游荡意图。MoveId 不是序号,是 BattleMoveActionType, /// 填 0(Unknown) 等于"没有 locomotion 状态",所以填 2(WalkFront)。 /// ⛑️ PathTags / 角度窗口是最容易踩的两个静默失败点,见文件头。 /// private static async Task PushAmbientWanderAsync( TcpSession session, AetherSettings cfg, AmbientPed ped, CancellationToken token) { var maxDis = MathF.Max(cfg.AmbientPedsWanderMinDis + 1f, cfg.AmbientPedsWanderMaxDis); await session.NotifyAsync(MethodId.SyncMoveWandering, UxSerializer.Serialize(new SceneMethods.SyncMoveWandering4229938 { Data = new SceneMethods.MoveWanderingData4229938 { PathTags = cfg.AmbientPedsWanderPathTags, CloseObstacleAvoidance = true, MinDis = cfg.AmbientPedsWanderMinDis, MaxDis = maxDis, InRangeAngle = cfg.AmbientPedsWanderLeftAngle, OutRangeAngle = cfg.AmbientPedsWanderRightAngle, MaxTime = cfg.AmbientPedsWanderSeconds + 5f, MaxOnceWanderTime = cfg.AmbientPedsWanderSeconds, UnitId = ped.Id, MoveType = 0, MoveId = WorldCodec.MoveAction.WalkFront, CloseIK = false, InstanceId = 0, BlockCloseCapsuleCollision = false, }, }), token); ped.WanderArmedTicks = Environment.TickCount64; } } /// 一个候选站位(真实行人车道端点,带地面高度)。 internal readonly record struct PedSpawnPoint(float X, float Y, float Z); /// /// 行人布点表:`Ananta.Server/Ananta.App/data/ped_spawn_points.csv`(x,y,z)。 /// 100 米网格索引,按玩家位置查半径内候选。 /// internal static class PedSpawnTable { private const float CellSize = 100f; private sealed class Table { internal PedSpawnPoint[] Points = []; internal Dictionary<(int, int), int[]> Cells = []; } private static readonly Lazy Cache = new(Load); internal static int Count => Cache.Value.Points.Length; private static Table Load() { var path = PrivateServerConfigStore.ResolveProjectPath( "Ananta.Server/Ananta.App/data/ped_spawn_points.csv"); var points = new List(); if (File.Exists(path)) { foreach (var line in File.ReadLines(path)) { if (line.Length == 0 || line.StartsWith("x,", StringComparison.OrdinalIgnoreCase)) continue; var parts = line.Split(','); if (parts.Length < 3) continue; if (!float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x) || !float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y) || !float.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var z)) continue; points.Add(new PedSpawnPoint(x, y, z)); } } var cells = new Dictionary<(int, int), List>(); for (var i = 0; i < points.Count; i++) { var key = (CellOf(points[i].X), CellOf(points[i].Z)); if (!cells.TryGetValue(key, out var bucket)) cells[key] = bucket = []; bucket.Add(i); } return new Table { Points = [.. points], Cells = cells.ToDictionary(kv => kv.Key, kv => kv.Value.ToArray()), }; } private static int CellOf(float v) => (int)MathF.Floor(v / CellSize); /// /// 在 (x,z) 半径内随机挑一个站位,且与已有行人保持 minGap 米。 /// 找不到就返回 null(比如玩家站在水域/荒地,路网上没点)。 /// internal static PedSpawnPoint? PickNear(float x, float z, float radius, float minGap, IReadOnlyList existing) { var table = Cache.Value; if (table.Points.Length == 0) return null; var span = (int)MathF.Ceiling(radius / CellSize); var cx = CellOf(x); var cz = CellOf(z); List candidates = []; for (var dx = -span; dx <= span; dx++) for (var dz = -span; dz <= span; dz++) { if (!table.Cells.TryGetValue((cx + dx, cz + dz), out var bucket)) continue; foreach (var idx in bucket) { var p = table.Points[idx]; var ox = p.X - x; var oz = p.Z - z; if (ox * ox + oz * oz <= radius * radius) candidates.Add(idx); } } if (candidates.Count == 0) return null; var gapSq = minGap * minGap; for (var attempt = 0; attempt < 24; attempt++) { var pick = table.Points[candidates[Random.Shared.Next(candidates.Count)]]; var ok = true; foreach (var ped in existing) { var ox = ped.X - pick.X; var oz = ped.Z - pick.Z; if (ox * ox + oz * oz < gapSq) { ok = false; break; } } if (ok) return pick; } return null; } } /// /// 氛围行人的 NPC 模板池(= AgentConfig id)。直接复用已投影好的市民组目录, /// 只取 NpcFormworkId —— POI 一律留 0,否则人会去播动作而不走路。 /// /// `CivilianGroups` 是为"给每个 POI 动作配一个体型匹配的人形"建的, /// 它不保证模板是路人 —— 池里原本混着店员、摊主、刷怪NPC、剧情角色,还有上百个 /// 共用同一具通用模特的"假人"。这里按 `GameRouter.IsCivilianFormwork` 的三道筛子收敛成纯市民。 /// /// ⛑️ **来源恒为 `CivilianGroups.json`(用户 2026-09-26 定)**:本池只做减法, /// 绝不从 `NpcCatalog`(9345 行全表)往里补 id —— 那里有敌人、动物、机器人、淤泥怪。 /// 过滤掉一批之后剩下多少就是多少(现在 1985 个),不要为了凑数去别的表里捞。 /// internal static class PedFormworkPool { private static readonly Lazy Cache = new(Load); internal static int Count => Cache.Value.Length; /// 过滤前 / 过滤后的数量,给日志和面板看(判"是不是滤空了")。 internal static int RawCount { get; private set; } internal static int Filtered { get; private set; } private static uint[] Load() { var raw = new HashSet(); var kept = new List(); foreach (var group in GameRouter.CivilianGroups) foreach (var item in group.Items) { var id = item.NpcFormworkId; if (id == 0 || !raw.Add(id)) continue; // 三道筛子(id 段白名单 / 通用临时模型黑名单 / 名字黑名单)全在 IsCivilianFormwork 里。 if (GameRouter.IsCivilianFormwork(id)) kept.Add(id); } RawCount = raw.Count; Filtered = raw.Count - kept.Count; // 两道筛子把池子滤空 ⇒ 退回全池(宁可混进店员,也不要一个路人都刷不出来)。 if (kept.Count == 0 && raw.Count > 0) { Filtered = 0; return [.. raw]; } return [.. kept]; } internal static uint? Pick() { var pool = Cache.Value; return pool.Length == 0 ? null : pool[Random.Shared.Next(pool.Length)]; } }