Files

662 lines
33 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>Client movement reports and live transform tracking.</summary>
internal sealed partial class GameRouter
{
/// <summary>How often the walking position is mirrored to disk while the player explores.</summary>
private static readonly TimeSpan PositionSaveInterval4229938 = TimeSpan.FromSeconds(20);
/// <summary>Distance (in world units) that forces an earlier save than the interval.</summary>
private const float PositionSaveDistance4229938 = 40f;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// ⛑⛑⛑ 把「留在大世界里」的旧角色钉回它们留下时的位姿(S2C 68838628)。
///
/// 客户端对 <c>SyncManagedLogicAgent</c> 标过的单位会停用自己的 AI、只做物理模拟,
/// 而这些 actor 没有地面吸附 ⇒ <b>自由落体掉出世界</b>。
/// 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 轮已证伪),但「钉住不动」正是我们要的效果。
/// </summary>
/// <summary>给「留在大世界里」的角色补发原地路点任务时用的命令序号(只需单调递增)。</summary>
private static int s_leftBehindCommandSeq;
/// <summary>
/// 给一个留下的角色发一条**原地路点任务**(<c>TaskWayPointMove</c>,points = [站立点, 站立点])。
///
/// ⛑ 这一条是止住自由落体的关键:位置流只是硬拉坐标,任务包会让客户端的 locomotion
/// 状态机接管 actor ⇒ 重新贴地。街上那些 830… 开头的 NPC 同样带 Managed 却从不掉,
/// 就是因为它们一直被喂这个包(见 <c>LeftBehindWaypointTask</c> 的注释)。
/// </summary>
internal static async Task SendLeftBehindWaypointAsync(
TcpSession session, ulong unitId, Vec3 position, CancellationToken token)
{
var sw = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations;
var points = new List<SceneMethods.UxVector3>(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);
}
/// <summary>
/// ★★ 给留下的角色派一段**真实行程**(闲逛用,开关 <c>LeftBehindWander</c>)。
///
/// 与 <see cref="SendLeftBehindWaypointAsync"/> 的差别只有一个:那条
/// points = [锚点, 锚点](距离 0 ⇒ 原地,止跌用),这条给的是
/// <c>PedLaneGraph.TryPlanJourney</c> 沿人行道规划出来的折线。
///
/// ⛑ 包体和止跌那条**完全同构**(同一个 <c>SyncLogicAgentCommand</c> + tag 39
/// <c>TaskWayPointMove</c>),只是点不同 —— 街上那些 830… 开头的 NPC 走的就是这个包。
/// </summary>
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<SceneMethods.UxVector3>(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);
}
/// <summary>
/// ⛑⛑ 留下的角色**不能只在客户端上报时才续发**:玩家站着不动观察的时候客户端一个包都不发,
/// 那一刻它们正好在自由落体(60~90 m/s,几秒就掉出世界)。
/// ⇒ 起一个跟 <c>Ambient</c> 同款的后台循环,按 <c>LeftBehindWaypointIntervalMs</c> 续发。
/// session 断开后写 socket 会抛异常,循环自己退出,不需要额外的停止钩子。
/// </summary>
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;
}
}
}
/// <summary>闲逛挑终点用的随机数。只有 pin loop 这一个线程会碰它,仍加锁以防将来改成并行。</summary>
private static readonly Random s_wanderRng = new();
/// <summary>
/// 某个角色「留场轮换」的 POI 组(<c>gameplay.rosterNpc.poiOverrides[].leftBehindPoiGroup</c>)。
/// 没配 ⇒ null(不轮换)。⛑ 按 <b>spiritId</b> 查,不是 unitId。
/// </summary>
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;
}
/// <summary>某个角色轮换时每个 POI 停多久(角色级覆盖 → 全局)。</summary>
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);
}
/// <summary>
/// ★★ 一个 tick:给配了「留场 POI 组」的角色换下一个 POI(开关 <c>LeftBehindPoiRotation</c>)。
///
/// **洗牌袋**:把组打乱后一条一条播,播完一轮重新洗牌 ⇒ 随机,但<b>一轮之内不重复</b>,
/// 不会出现"连着三次同一个、另一个半天轮不到"。
///
/// ⛑⛑ 三条门禁(少一条就是白干 / 出怪相):
/// ① 出场包带了 <c>AgentDataSetsActivityCfgId</c>(在生活日程里)⇒ **不轮换**,
/// 客户端 ambient 会把 POI 盖掉(硬规则 163:里希"一边打电话一边跑步");
/// ② 配了组的角色**不再**喂坐标 / 原地路点(见上面的 snapshot 过滤);
/// ③ 与闲逛互斥:有组就轮换,没组才走 wander(见 <c>RunLeftBehindWanderTickAsync</c>)。
/// </summary>
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)}]");
}
}
/// <summary>
/// 换到带道具的 POI 时补道具(<c>SyncAgentBelongings</c> → <c>SyncAgentUseBelongingChanged</c>),
/// 换到无道具的 POI 时发一个空 usage 尝试把上一件收掉。
/// ⛑ 与 <c>GameRouter.Civilians.cs</c> 出场那套<b>同包同结构</b>,只是时机从"出场时"改成"每次切换"。
/// 开关 <c>SwitchAnimations.LeftBehindPoiRotationProps</c>。
/// </summary>
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);
}
/// <summary>
/// ★★ 一个 tick:给「当前没在行程里」的留场角色各派一段新行程(开关 <c>LeftBehindWander</c>)。
///
/// ⛑⛑ 下次派单时间 = **本段路程 ÷ 速度 + 余量**,**不能**用固定步长(硬规则 109):
/// 步长比行程短 ⇒ 走一半就被派下一段(一步一停);比行程长 ⇒ 走完了干站着。
/// </summary>
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");
}
}
/// <summary>
/// ★★ 把客户端上报的真实坐标写回 <c>LeftBehindCharacter.Position</c>
///(开关 <c>LeftBehindTrackRealPosition</c>)。
///
/// 不写回的后果:留场角色闲逛出去之后,切回他时 <c>GameRouter.Switching.cs</c> 读的
/// <c>directPos = leftBehind!.Position</c> 还是**切走那一刻的落点** ⇒ 人瞬移回去,逛了等于没逛。
///
/// ⛑ 两个必须跳过的情形:
/// ① 上报者是玩家自己(<c>ActiveSpiritUnitId</c>)—— 他不是留场角色;
/// ② 坐标明显掉出世界(Y 是极端值,自由落体那条链上会出现 -2999.99)—— 记下来等于把人扔进虚空。
/// </summary>
private static void TrackLeftBehindRealPositions(WorldEntryState state,
IEnumerable<SceneMethods.LogicAgentSyncData> 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<SceneMethods.LogicAgentSyncData>? 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<SceneMethods.LogicAgentSyncData>? 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");
}
}