using System.Diagnostics; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.RpcTypes.Client4229938; 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; /// Client movement reports and live transform tracking. internal sealed partial class GameRouter { /// How often the walking position is mirrored to disk while the player explores. private static readonly TimeSpan PositionSaveInterval4229938 = TimeSpan.FromSeconds(20); /// Distance (in world units) that forces an earlier save than the interval. private const float PositionSaveDistance4229938 = 40f; /// /// Mirrors the live transform to the progress snapshot while the player walks around. /// /// This server has no session-end hook, so a save triggered on logout would simply never run — /// the client just goes away. Instead the position is written periodically, throttled by both /// time and distance, so standing still costs nothing and a long walk never loses more than one /// interval of progress. /// private static void MaybeSavePlayerPosition4229938(WorldEntryState state, RpcContext ctx) { if (!state.Ready || !state.HasLastReportedPlayerTransform) return; var position = state.LastReportedPlayerPosition; var now = Stopwatch.GetTimestamp(); if (state.HasSavedPlayerPosition) { var dx = position.X - state.LastSavedPlayerPosition.X; var dy = position.Y - state.LastSavedPlayerPosition.Y; var dz = position.Z - state.LastSavedPlayerPosition.Z; var movedFar = dx * dx + dy * dy + dz * dz >= PositionSaveDistance4229938 * PositionSaveDistance4229938; if (!movedFar && Stopwatch.GetElapsedTime(state.LastPositionSaveTicks, now) < PositionSaveInterval4229938) return; } state.LastPositionSaveTicks = now; state.LastSavedPlayerPosition = position; state.HasSavedPlayerPosition = true; // Quiet: this runs unattended, and logging it would drown the console. PlayerProgressStore.Save(state, ctx.Session.Log, quiet: true); } /// /// ⛑⛑⛑ 把「留在大世界里」的旧角色钉回它们留下时的位姿(S2C 68838628)。 /// /// 客户端对 SyncManagedLogicAgent 标过的单位会停用自己的 AI、只做物理模拟, /// 而这些 actor 没有地面吸附 ⇒ 自由落体掉出世界。 /// 2026-09-24 18:1x 实测(packets-20260924-180850.log): /// 18:13:17 ag=100000000000 y=-0.13 ← 刚被切换掉 /// 18:13:48 ag=100000000000 y=-0.63 /// 18:13:55 ag=100000000000 y=-293.6 /// 18:14:41 ag=100000000000 y=-2999.99 /// 18:19:21 ag=100000000000 y=-1388 ← 切回它时拿到的就是这个状态 /// 后果:切回这类角色 ⇒ 原地跑步、朝向会转、坐标不动(用户报的「切换的角色都无法移动」)。 /// /// 修法:客户端每上报一次自己的模拟位置,我们就把记下来的站立位姿回给它。 /// 这个包驱动不了步态(donor1 第 39 轮已证伪),但「钉住不动」正是我们要的效果。 /// /// 给「留在大世界里」的角色补发原地路点任务时用的命令序号(只需单调递增)。 private static int s_leftBehindCommandSeq; /// /// 给一个留下的角色发一条**原地路点任务**(TaskWayPointMove,points = [站立点, 站立点])。 /// /// ⛑ 这一条是止住自由落体的关键:位置流只是硬拉坐标,任务包会让客户端的 locomotion /// 状态机接管 actor ⇒ 重新贴地。街上那些 830… 开头的 NPC 同样带 Managed 却从不掉, /// 就是因为它们一直被喂这个包(见 LeftBehindWaypointTask 的注释)。 /// internal static async Task SendLeftBehindWaypointAsync( TcpSession session, ulong unitId, Vec3 position, CancellationToken token) { var sw = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations; var points = new List(2) { new SceneMethods.UxVector3(position.X, position.Y, position.Z), new SceneMethods.UxVector3(position.X, position.Y, position.Z), }; await session.NotifyAsync(MethodId.IGameSceneToClient_SyncLogicAgentCommand, UxSerializer.Serialize(WorldCodec.LogicAgentCommandWayPoint( unitId, Interlocked.Increment(ref s_leftBehindCommandSeq), SceneMethods.AgentMovementMethods.Walk, sw.LeftBehindWaypointSpeed, moveActionId: 0u, moveActionGroup: 0u, keepMovingAction: true, points, noRootMotion: true)), token); } /// /// ★★ 给留下的角色派一段**真实行程**(闲逛用,开关 LeftBehindWander)。 /// /// 与 的差别只有一个:那条 /// points = [锚点, 锚点](距离 0 ⇒ 原地,止跌用),这条给的是 /// PedLaneGraph.TryPlanJourney 沿人行道规划出来的折线。 /// /// ⛑ 包体和止跌那条**完全同构**(同一个 SyncLogicAgentCommand + tag 39 /// TaskWayPointMove),只是点不同 —— 街上那些 830… 开头的 NPC 走的就是这个包。 /// internal static async Task SendLeftBehindRouteAsync( TcpSession session, ulong unitId, List<(float X, float Y, float Z)> route, float speed, CancellationToken token) { if (route.Count < 2) return; var points = new List(route.Count); foreach (var p in route) points.Add(new SceneMethods.UxVector3(p.X, p.Y, p.Z)); var sw = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations; var movement = sw.LeftBehindWanderMovement == 2 ? SceneMethods.AgentMovementMethods.Run : SceneMethods.AgentMovementMethods.Walk; await session.NotifyAsync(MethodId.IGameSceneToClient_SyncLogicAgentCommand, UxSerializer.Serialize(WorldCodec.LogicAgentCommandWayPoint( unitId, Interlocked.Increment(ref s_leftBehindCommandSeq), movement, speed, moveActionId: 0u, moveActionGroup: 0u, keepMovingAction: true, points, noRootMotion: true)), token); // ★ 闲逛专属动画(开关 LeftBehindWanderPoiActionId,0 = 沿用出场 POI)。 // ⛑ 位移是上面那条命令给的,POI 只负责"看起来在走"—— 两者必须配套, // 只填 POI 不派命令 = 原地走(跑步机效应)。 if (sw.LeftBehindWanderPoiActionId != 0) await PushNpcPoiActionAsync(session, unitId, sw.LeftBehindWanderPoiActionId); } /// /// ⛑⛑ 留下的角色**不能只在客户端上报时才续发**:玩家站着不动观察的时候客户端一个包都不发, /// 那一刻它们正好在自由落体(60~90 m/s,几秒就掉出世界)。 /// ⇒ 起一个跟 Ambient 同款的后台循环,按 LeftBehindWaypointIntervalMs 续发。 /// session 断开后写 socket 会抛异常,循环自己退出,不需要额外的停止钩子。 /// internal static void EnsureLeftBehindLoop(TcpSession session, WorldEntryState state) { lock (state.SyncRoot) { if (state.LeftBehindLoop is { IsCompleted: false }) return; state.LeftBehindCts?.Dispose(); var cts = new CancellationTokenSource(); state.LeftBehindCts = cts; state.LeftBehindLoop = Task.Run(() => RunLeftBehindLoopAsync(session, state, cts.Token)); } } private static async Task RunLeftBehindLoopAsync(TcpSession session, WorldEntryState state, CancellationToken token) { session.Log.Info($"[LEFTBEHIND] pin loop started"); while (!token.IsCancellationRequested) { try { var sw = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations; // ⛑ 闲逛时要**继续跑循环**:同伴协议下原来那两个早退条件会直接 break, // 而恰恰是同伴协议下角色才是「活」的(客户端接管 locomotion),能真的走起来。 var wandering = sw.LeftBehindWander && PedLaneGraph.Available; // ★ POI 轮换同样要继续跑循环(它是唯一的驱动源:每 dwell 毫秒推一个 POI)。 var rotating = sw.LeftBehindPoiRotation; if (!sw.KeepLeftBehindCharacters || (sw.LeftBehindCompanionProtocol && !wandering && !rotating) || (!wandering && !rotating && !sw.LeftBehindWaypointTask && !sw.FeedLeftBehindPositions)) break; // 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。 if (IsBackgroundPushPaused(session)) { await Task.Delay(200, token); continue; } // ★ 轮换优先于闲逛:配了组的角色走轮换,没配的走闲逛(两者都派会互相打断)。 if (rotating) await RunLeftBehindPoiTickAsync(session, state, token); // ★ 闲逛模式:不走「喂坐标 + 原地路点」那套(那套是止跌用的,会把人钉在锚点上), // 只按行程派单 + 按路程算下次时间。 if (wandering) { await RunLeftBehindWanderTickAsync(session, state, token); await Task.Delay(Math.Max(100, sw.LeftBehindWanderPollMs), token); continue; } // ⛑ 正在轮换的角色**不能**再被喂坐标 / 原地路点:那两样是「钉在锚点上」的, // 而 380 踱步这类 POI 自带 root motion 位移 —— 每 90ms 硬拉一次就原地抽搐。 List<(ulong UnitId, Vec3 Position, float Facing)> snapshot; lock (state.SyncRoot) { snapshot = state.LeftBehindCharacters .Where(kv => !(rotating && PoiGroupFor(kv.Key) is { Length: > 0 })) .Select(kv => (kv.Value.UnitId, kv.Value.Position, kv.Value.Facing)) .ToList(); } foreach (var entry in snapshot) { if (sw.LeftBehindWaypointTask) await SendLeftBehindWaypointAsync(session, entry.UnitId, entry.Position, token); if (sw.FeedLeftBehindPositions) await session.NotifyAsync(MethodId.IGameSceneToClient_SyncLogicAgentSyncData, UxSerializer.Serialize(WorldCodec.LogicAgentSyncData( entry.UnitId, entry.Position, entry.Facing, sw.LeftBehindFeedMoveId)), token); } await Task.Delay(Math.Max(500, sw.LeftBehindWaypointIntervalMs), token); } catch (OperationCanceledException) { break; } catch (ObjectDisposedException) { break; } catch (Exception ex) { session.Log.Info($"[LEFTBEHIND] pin loop stopped: {ex.Message}"); break; } } } /// 闲逛挑终点用的随机数。只有 pin loop 这一个线程会碰它,仍加锁以防将来改成并行。 private static readonly Random s_wanderRng = new(); /// /// 某个角色「留场轮换」的 POI 组(gameplay.rosterNpc.poiOverrides[].leftBehindPoiGroup)。 /// 没配 ⇒ null(不轮换)。⛑ 按 spiritId 查,不是 unitId。 /// private static uint[]? PoiGroupFor(uint spiritId) { if (spiritId == 0) return null; var ov = PrivateServerConfigStore.Current.Gameplay.RosterNpc.PoiOverrides .FirstOrDefault(o => o.SpiritId == spiritId); return ov?.LeftBehindPoiGroup is { Length: > 0 } g ? g : null; } /// 某个角色轮换时每个 POI 停多久(角色级覆盖 → 全局)。 private static int PoiDwellMsFor(uint spiritId) { var sw = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations; var ov = PrivateServerConfigStore.Current.Gameplay.RosterNpc.PoiOverrides .FirstOrDefault(o => o.SpiritId == spiritId); return Math.Max(1000, ov?.LeftBehindPoiDwellMs ?? sw.LeftBehindPoiDwellMs); } /// /// ★★ 一个 tick:给配了「留场 POI 组」的角色换下一个 POI(开关 LeftBehindPoiRotation)。 /// /// **洗牌袋**:把组打乱后一条一条播,播完一轮重新洗牌 ⇒ 随机,但一轮之内不重复, /// 不会出现"连着三次同一个、另一个半天轮不到"。 /// /// ⛑⛑ 三条门禁(少一条就是白干 / 出怪相): /// ① 出场包带了 AgentDataSetsActivityCfgId(在生活日程里)⇒ **不轮换**, /// 客户端 ambient 会把 POI 盖掉(硬规则 163:里希"一边打电话一边跑步"); /// ② 配了组的角色**不再**喂坐标 / 原地路点(见上面的 snapshot 过滤); /// ③ 与闲逛互斥:有组就轮换,没组才走 wander(见 RunLeftBehindWanderTickAsync)。 /// private static async Task RunLeftBehindPoiTickAsync( TcpSession session, WorldEntryState state, CancellationToken token) { var now = Environment.TickCount64; List<(uint SpiritId, WorldEntryState.LeftBehindCharacter Entry, uint[] Group)> due = []; lock (state.SyncRoot) { foreach (var kv in state.LeftBehindCharacters) { var e = kv.Value; if (e is null || e.UnitId == 0) continue; if (e.ActivityCfgId != 0) continue; // ① 生活日程中 ⇒ 不抢 var group = PoiGroupFor(kv.Key); if (group is null) continue; // 没配组 ⇒ 走 wander if (e.PoiNextTicks != 0 && now < e.PoiNextTicks) continue; due.Add((kv.Key, e, group)); } } foreach (var (spiritId, e, group) in due) { // 洗牌袋:首轮 / 组被改过 / 上一轮播完 ⇒ 重新洗一遍。 if (e.PoiBag is null || e.PoiBag.Length != group.Length || e.PoiBagIdx >= e.PoiBag.Length) { var bag = (uint[])group.Clone(); lock (s_wanderRng) { for (var i = bag.Length - 1; i > 0; i--) { var j = s_wanderRng.Next(i + 1); (bag[i], bag[j]) = (bag[j], bag[i]); } } e.PoiBag = bag; e.PoiBagIdx = 0; } var poi = e.PoiBag[e.PoiBagIdx]; var idx = e.PoiBagIdx + 1; e.PoiBagIdx = idx; var dwell = PoiDwellMsFor(spiritId); e.PoiNextTicks = now + dwell; await PushNpcPoiActionAsync(session, e.UnitId, poi); await PushLeftBehindPoiPropAsync(session, e.UnitId, poi, token); session.Log.Info($"[LEFTBEHIND-POI] spirit={spiritId} unit={e.UnitId} poi={poi} " + $"step={idx}/{e.PoiBag.Length} dwell={dwell}ms " + $"bag=[{string.Join(',', e.PoiBag)}]"); } } /// /// 换到带道具的 POI 时补道具(SyncAgentBelongings → SyncAgentUseBelongingChanged), /// 换到无道具的 POI 时发一个空 usage 尝试把上一件收掉。 /// ⛑ 与 GameRouter.Civilians.cs 出场那套同包同结构,只是时机从"出场时"改成"每次切换"。 /// 开关 SwitchAnimations.LeftBehindPoiRotationProps。 /// private static async Task PushLeftBehindPoiPropAsync( TcpSession session, ulong unitId, uint poiActionId, CancellationToken token) { if (!PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.LeftBehindPoiRotationProps) return; if (unitId == 0 || poiActionId == 0) return; DonorPoiActionCatalog4229938.TryGet(poiActionId, out var poi); var items = poi?.BelongingSceneItems; if (items is { Length: > 0 }) { await session.NotifyAsync(MethodId.SyncAgentBelongings, UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongings4229938 { Id = unitId, Items = items.Select(configId => new GameMethods.DonorNpcBelongingItem4229938 { // 硬规则 104:instanceId 必须落盘递增,否则重号会被当成已存在的旧包丢掉。 InstanceId = unchecked((ulong)IdCounterStore.Next(NpcBelongingIdCounter, NpcBelongingIdFloor)), ConfigId = configId, }).ToList(), DebugAgentId = 0, }), token); await session.NotifyAsync(MethodId.SyncAgentUseBelongingChanged, UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongingUsage4229938 { Id = unitId, UsageIds = [poi!.UsageId], }), token); session.Log.Info($"[LEFTBEHIND-POI-PROP] unit={unitId} poi={poiActionId} " + $"items={string.Join(',', items)} usage={poi.UsageId}"); return; } // ⛑ 无道具 POI:空 usage 试图把上一件收掉(语义未实机验证,见 LeftBehindPoiRotationProps 注释)。 await session.NotifyAsync(MethodId.SyncAgentUseBelongingChanged, UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongingUsage4229938 { Id = unitId, UsageIds = [], }), token); } /// /// ★★ 一个 tick:给「当前没在行程里」的留场角色各派一段新行程(开关 LeftBehindWander)。 /// /// ⛑⛑ 下次派单时间 = **本段路程 ÷ 速度 + 余量**,**不能**用固定步长(硬规则 109): /// 步长比行程短 ⇒ 走一半就被派下一段(一步一停);比行程长 ⇒ 走完了干站着。 /// private static async Task RunLeftBehindWanderTickAsync( TcpSession session, WorldEntryState state, CancellationToken token) { var sw = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations; var now = Environment.TickCount64; // ⛑ LeftBehindCharacters 的键是 uint(不是 unitId),只能遍历 Values 找。 List<(WorldEntryState.LeftBehindCharacter Entry, float X, float Z)> due = []; lock (state.SyncRoot) { foreach (var kv in state.LeftBehindCharacters) { // ★ 轮换优先:配了 POI 组的角色走轮换,不再派行程(两者都派会互相打断)。 if (sw.LeftBehindPoiRotation && PoiGroupFor(kv.Key) is not null) continue; var e = kv.Value; if (e.WanderNextTicks != 0 && now < e.WanderNextTicks) continue; due.Add((e, e.Position.X, e.Position.Z)); } } foreach (var (left, fromX, fromZ) in due) { // ⛑ 终点必须走 TryPlanJourney(挑一个**已经在人行道上**的点 + 沿路网规划整条折线), // 不能用「当前位置 + 随机方向」—— 那种点 92% 落在建筑/江里,而 PlanRoute 尾部会把 // 最后一个路径点硬拉过去 ⇒ 他最后一段是扎进墙里,客户端到不了就一直往墙里推 // (2026-09-23 行人同款坑)。 List<(float X, float Y, float Z)>? route; lock (s_wanderRng) { route = PedLaneGraph.TryPlanJourney( fromX, fromZ, fromX, fromZ, sw.LeftBehindWanderMinMetres, sw.LeftBehindWanderMaxMetres, 0f, s_wanderRng); } if (route is null || route.Count < 2) { // 这附近路网规划不出来 ⇒ 过几秒再试,别刷屏。 // ⛑ 顺手把动画退回站姿:不然他会「原地走」(走路 POI 是循环动画,没有位移)。 if (sw.LeftBehindWanderPoiActionId != 0 && sw.LeftBehindNativePoiActionId != 0) await PushNpcPoiActionAsync(session, left.UnitId, sw.LeftBehindNativePoiActionId); lock (state.SyncRoot) { left.WanderNextTicks = now + 5000; } continue; } await SendLeftBehindRouteAsync(session, left.UnitId, route, sw.LeftBehindWanderSpeed, token); var length = 0f; for (var i = 0; i + 1 < route.Count; i++) { var dx = route[i + 1].X - route[i].X; var dz = route[i + 1].Z - route[i].Z; length += MathF.Sqrt(dx * dx + dz * dz); } var speed = MathF.Max(0.1f, sw.LeftBehindWanderSpeed); var etaMs = (int)(length / speed * 1000f); var gapMs = etaMs + Math.Max(0, sw.LeftBehindWanderSlackMs); lock (state.SyncRoot) { left.WanderRoute = route; left.WanderRouteIdx = route.Count - 1; left.WanderNextTicks = now + gapMs; } session.Log.Info($"[LEFTBEHIND-WANDER] unit={left.UnitId} pts={route.Count} " + $"len={length:0.#}m speed={speed:0.##} eta={etaMs}ms next=+{gapMs}ms"); } } /// /// ★★ 把客户端上报的真实坐标写回 LeftBehindCharacter.Position ///(开关 LeftBehindTrackRealPosition)。 /// /// 不写回的后果:留场角色闲逛出去之后,切回他时 GameRouter.Switching.cs 读的 /// directPos = leftBehind!.Position 还是**切走那一刻的落点** ⇒ 人瞬移回去,逛了等于没逛。 /// /// ⛑ 两个必须跳过的情形: /// ① 上报者是玩家自己(ActiveSpiritUnitId)—— 他不是留场角色; /// ② 坐标明显掉出世界(Y 是极端值,自由落体那条链上会出现 -2999.99)—— 记下来等于把人扔进虚空。 /// private static void TrackLeftBehindRealPositions(WorldEntryState state, IEnumerable samples, ulong activeSpiritUnitId) { lock (state.SyncRoot) { foreach (var item in samples) { if (item.AgentId == 0 || item.AgentId == activeSpiritUnitId) continue; var p = item.Position; if (!float.IsFinite(p.X) || !float.IsFinite(p.Y) || !float.IsFinite(p.Z)) continue; if (p.Y < -100f || p.Y > 1000f) continue; foreach (var entry in state.LeftBehindCharacters.Values) { if (entry.UnitId != item.AgentId) continue; entry.Position = new Vec3(p.X, p.Y, p.Z); break; } } } } async Task FeedLeftBehindUnitsAsync(RpcContext ctx, WorldEntryState state, IEnumerable? samples) { var sw = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations; if (samples is null || state.LeftBehindCharacters.Count == 0) return; // ★★ 先无条件记下真实坐标(切回时要用),**再**走下面的喂位姿 / 路点分支。 // ⛑ 必须放在那个早退**之前**:同伴协议下 `FeedLeftBehindPositions` 分支直接 return, // 而恰恰是同伴协议下角色才是「活」的(客户端接管 locomotion、会真的走动), // 这时最需要坐标回写 —— 否则他逛到哪都白搭,切回时还是瞬移回落点。 if (sw.LeftBehindTrackRealPosition) TrackLeftBehindRealPositions(state, samples, state.ActiveSpiritUnitId); if (!sw.FeedLeftBehindPositions || sw.LeftBehindCompanionProtocol) return; var now = Environment.TickCount64; var interval = Math.Max(0, sw.LeftBehindFeedMinIntervalMs); foreach (var item in samples) { if (item.AgentId == 0 || item.AgentId == state.ActiveSpiritUnitId) continue; WorldEntryState.LeftBehindCharacter? left = null; foreach (var entry in state.LeftBehindCharacters.Values) { if (entry.UnitId == item.AgentId) { left = entry; break; } } if (left is null) continue; if (interval > 0 && now - left.LastFeedTicks < interval) continue; var first = left.LastFeedTicks == 0; left.LastFeedTicks = now; if (first) { ctx.Session.Log.Info($"[LEFTBEHIND-FEED] pin unit={left.UnitId} " + $"pos=({left.Position.X:0.##},{left.Position.Y:0.##},{left.Position.Z:0.##}) " + $"facing={left.Facing:0.##} moveId={sw.LeftBehindFeedMoveId}"); } await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncLogicAgentSyncData, WorldCodec.LogicAgentSyncData(left.UnitId, left.Position, left.Facing, sw.LeftBehindFeedMoveId)); // 真正的止跌:按间隔补一条原地路点任务,让 locomotion 重新接管。 var wpInterval = Math.Max(0, sw.LeftBehindWaypointIntervalMs); if (sw.LeftBehindWaypointTask && (left.LastWaypointTicks == 0 || wpInterval == 0 || now - left.LastWaypointTicks >= wpInterval)) { left.LastWaypointTicks = now; await SendLeftBehindWaypointAsync(ctx.Session, left.UnitId, left.Position, CancellationToken.None); } } } async Task OnMovementReport(RpcContext ctx, IEnumerable? samples) { var state = GetWorldState(ctx); // All build-4229938 movement RPCs are projected into the same transform shape by the endpoint. // Walk the whole batch so the newest action wins instead of keeping the oldest position. if (samples is not null) { foreach (var item in samples) { // ⛔ 2026-09-27 17:2x 已删除 TrackMechanikaSheepSample4229938 调用。 // 它只服务"10 米内有没有羊"这条召唤判据;用户拍板取消该判据、且羊不再跟随 // ⇒ 坐标回写整块没有消费者了。羊存在与否请查 AskUnitMoveActionSimple 里的上报。 // 2026-10-01:随从的真实坐标也从这条流里拿(捐赠端 TrackGarmMovement 同款做法)。 // 放在 continue 之前 —— 随从不是操控单位,不加这段它就被下面那行过滤掉了。 // ⛑ 只对"正在被我们驱动的随从"做(查一下字典,空字典时几乎零成本)。 if (s_companionWorkers.ContainsKey(item.AgentId)) { var cp = item.Position; if (float.IsFinite(cp.X) && float.IsFinite(cp.Y) && float.IsFinite(cp.Z)) NoteCompanionPosition(item.AgentId, new Vec3(cp.X, cp.Y, cp.Z)); } var isActive = item.AgentId == state.ActiveSpiritUnitId; var isPendingTarget = state.PendingSwitchUnitId != 0 && item.AgentId == state.PendingSwitchUnitId; var isPendingOld = state.PendingSwitchOldUnitId != 0 && item.AgentId == state.PendingSwitchOldUnitId; if (!isActive && !isPendingTarget && !isPendingOld) { // ★ 捐赠端 GameRouter.Movement.cs:30:非操控单位的上报也要喂给警察登记表, // 这样盘查/押送的目标位置才是真坐标。放在 continue 之前 —— 否则被过滤掉。 // ⛑ 捐赠端这里还有 HackerDevice / Garm / Mechanika 三条(远程设备·随从子系统), // 本次只搬盘查/押送/牢房核心 ⇒ 那三条不搬。 await TrackPoliceNpcMovement4229938(ctx.Session, state, item); continue; } var p = item.Position; var r = item.Rotation; if (float.IsFinite(p.X) && float.IsFinite(p.Y) && float.IsFinite(p.Z) && float.IsFinite(r.X) && float.IsFinite(r.Y) && float.IsFinite(r.Z)) { // ⛑ 2026-09-28 已定位并拆除临时探针(原 `[POS-WRITE]`):这个字段就是网格 AOI // 中心。换人后它若被**非现役单位**的上报改写 ⇒ 客户端把旧位置的道具整窗拆建 // (用户实测:道具闪没 / 椅子抖动)。复现办法见 `2026-09-29.md` §21:4x。 state.LastReportedPlayerPosition = new Vec3(p.X, p.Y, p.Z); state.LastReportedPlayerRotation = new Vec3(r.X, r.Y, r.Z); state.HasLastReportedPlayerTransform = true; // AetherCity 人群流式:走出上次的密度盒就在新位置补一批行人。 // 纯通知,不参与 V5.7 那套"首次移动不重放 AOI"的约束。 _ = CheckAndRefreshCrowdOnPlayerMoveAsync(ctx.Session, state.LastReportedPlayerPosition); // V5.7 safe-entry mode: movement is a pure client->server transform report. // Do not use the first movement edge to reintroduce the same AOI/DynamicGo/social // notification burst that was removed from AskLoadingFinished. The V3 4229938 // stable capture remained playable with no post-load server world-content push. // World-content streaming will be re-enabled incrementally only after the crash // boundary is proven stable. } } } MaybeSavePlayerPosition4229938(state, ctx); await FeedLeftBehindUnitsAsync(ctx, state, samples); if (state.FirstMovementSeen) return; // Do not consume the first-movement edge before AskLoadingFinished has committed Ready. // Build 4229938 can report a transform while the loading overlay is still closing; a later // real gameplay movement must still be able to trigger the pause-only stability release. if (!state.Ready) { ctx.Session.Log.Warn("[MOVE] movement arrived before world ready; pause-only stability release remains armed"); return; } state.FirstMovementSeen = true; ctx.Session.Log.Info($"[MOVE] client-agent-traffic rpc={ctx.MethodId} bytes={ctx.Body.Length} liveTransform={state.HasLastReportedPlayerTransform} logicProjection={state.WorldEntryLogicProjectionPublishedGeneration} currentMetadata={state.WorldEntryCurrentMetadataPublishedGeneration}"); // The supplied v7 guide keeps actor creation/presentation client-owned on initial entry. // PlayerInfo + the same-ID projection already contain the build-local roster/fashion data, // so do NOT replay SyncSetSpiritFashions/weapon snapshots for the initial actor. The only // server feature intentionally layered on after GameplayReady is the requested safe web kit. var unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId; var templateId = state.ActiveSpiritTemplateId != 0 ? state.ActiveSpiritTemplateId : Profile.InitialSpiritTemplateId; if (!state.AllBuildBuffsPublished) { await PublishSafeRuntimeBuffSnapshot4229938(ctx, unitId, templateId, "first-gameplay-movement"); state.AllBuildBuffsPublished = true; } if (!state.CombatProfilePublished) await PublishCombatProfile(ctx, unitId, templateId); // Same edge: populate the phone/garage vehicle list automatically. await PublishVehicleOwnership(ctx); await ctx.NotifyAsync(MethodId.SyncGamePause, WorldCodec.GamePause(false)); state.MovementCapabilityPublished = true; state.FreeRoamReleased = true; ctx.Session.Log.Info($"[GAMEPLAY-V7] first-movement post-load-buffs unit={unitId} template={templateId} actorPresentation=client-owned safeBuffs=true pause=false catalogBulk=false"); } }