662 lines
33 KiB
C#
662 lines
33 KiB
C#
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");
|
||
}
|
||
}
|