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