Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.RosterNpc.cs
T

593 lines
30 KiB
C#
Raw 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.Linq;
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 SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Playable characters standing in the city, each at whatever their own daily schedule says they
/// are doing right now — Richie on the treadmill, Garm at the claw machine, and so on.
///
/// Two reasons this file exists:
///
/// 1. Official has them roaming from the start (the 趴卜 schedule tab and the big-map markers exist
/// precisely so the player can go find them). We only ever materialised one at the moment the
/// player switched <em>away</em> from that character, so walking to a schedule coordinate found
/// nobody — which is exactly what the player reported on 09-25.
///
/// 2. The client unloads distant units and never re-adds them by itself: coming back to a spot from
/// far away shows an empty pavement until you switch the character away and back. So the server
/// has to own the distance bookkeeping — place on enter, retire on exit, place again on return.
/// That is not a new idea; it is the same follow-loop + hysteresis shape as
/// <see cref="GameRouter.EnsureFunctionalNpcs"/>, only with different coordinates.
///
/// Coordinates and the activity id come straight from the client's own tables
/// (see <see cref="NpcActivityCatalog4229938"/>): nothing here is invented.
/// </summary>
internal sealed partial class GameRouter
{
private const string RosterNpcKey = "client4229938-roster-npcs";
internal sealed class RosterNpcSessionState
{
internal object SyncRoot { get; } = new();
internal CancellationTokenSource? Cts;
internal Task? Loop;
/// <summary>spiritId -&gt; what was placed for it. Doubles as the "already placed" set.</summary>
internal Dictionary<uint, RosterNpcPlacement> Active { get; } = new();
/// <summary>Scene the <see cref="Active"/> entries belong to; a change wipes them.</summary>
internal uint Raid;
/// <summary>是否已经打过一次「名册里有多少人没有可落地活动」的诊断行(只打一次,别刷屏)。</summary>
internal bool Diagnosed;
}
internal sealed record RosterNpcPlacement(ulong UnitId, float X, float Y, float Z, uint ActivityId);
private static RosterNpcSessionState RosterNpcStateOf(TcpSession session)
{
if (session.Items.TryGetValue(RosterNpcKey, out var existing)
&& existing is RosterNpcSessionState typed)
return typed;
var created = new RosterNpcSessionState();
session.Items[RosterNpcKey] = created;
return created;
}
/// <summary>World-ready hook: start the follow loop. Idempotent; no-op when disabled.</summary>
internal static void EnsureRosterNpcs(TcpSession session)
{
if (!PrivateServerConfigStore.Current.Gameplay.RosterNpc.Enabled)
return;
var owner = RosterNpcStateOf(session);
lock (owner.SyncRoot)
{
if (owner.Loop is { IsCompleted: false })
return;
owner.Cts?.Dispose();
owner.Cts = new CancellationTokenSource();
owner.Loop = Task.Run(() => RunRosterNpcLoopAsync(session, owner, owner.Cts.Token));
}
}
/// <summary>Disconnect / scene switch: stop the loop. The client clears the entities itself.</summary>
internal static void StopRosterNpcs(TcpSession session)
{
if (!session.Items.TryGetValue(RosterNpcKey, out var existing)
|| existing is not RosterNpcSessionState owner)
return;
lock (owner.SyncRoot)
{
owner.Cts?.Cancel();
owner.Cts?.Dispose();
owner.Cts = null;
owner.Active.Clear();
}
}
/// <summary>How many are currently standing. Panel display only.</summary>
internal static int RosterNpcActiveCount(TcpSession session)
{
var owner = RosterNpcStateOf(session);
lock (owner.SyncRoot)
return owner.Active.Count;
}
/// <summary>
/// ★ 切人接管时**立即**撤掉常驻摆在日程点上的那具身体(09-26 00:4x 加)。
///
/// ⛑⛑ 为什么必须同步做:开了 <c>SwitchToScheduleSpot</c> 之后,玩家会直接瞬移到
/// 角色的日程点位。而那个点位上常驻化<b>很可能已经摆了一具同样的身体</b>
/// (他本来就「在城里过日子」)⇒ 瞬移过去就是「两个里希重叠」的老毛病(规则 166)。
/// 常驻循环 1.5 秒才跳一次,等它自己撤(<c>spirit == activeSpirit</c>)中间那一秒玩家
/// 就盯着两个自己 —— 所以切人时主动撤一次。
/// ⛑ 撤不掉也不至于更糟:<c>RemoveRosterNpcAsync</c> 已经是 Leave+Aether 双发(规则 166)。
/// </summary>
internal static async Task<bool> RetireRosterNpcForSwitchAsync(TcpSession session, uint spiritId)
{
var state = GetStateIfExists(session);
if (state is null)
return false;
var owner = RosterNpcStateOf(session);
ulong id;
lock (owner.SyncRoot)
{
if (!owner.Active.TryGetValue(spiritId, out var place))
return false;
id = place.UnitId;
}
await RemoveRosterNpcAsync(session, state, owner, spiritId);
session.Log.Info($"[ROSTER-NPC] retired spirit={spiritId} unit={id} (切人接管,立即撤掉日程点上的身体)");
return true;
}
/// <summary>
/// spiritId -&gt; unitId for everything currently on the field. The GM panel uses it to mark
/// which rows are actually standing in the world right now (as opposed to merely scheduled).
/// </summary>
internal static Dictionary<uint, ulong> RosterNpcPlacements(TcpSession session)
{
var owner = RosterNpcStateOf(session);
lock (owner.SyncRoot)
return owner.Active.ToDictionary(p => p.Key, p => p.Value.UnitId);
}
private static async Task RunRosterNpcLoopAsync(
TcpSession session, RosterNpcSessionState owner, CancellationToken token)
{
var cfg = PrivateServerConfigStore.Current.Gameplay.RosterNpc;
var tick = Math.Max(500, cfg.TickMs);
session.Log.Info($"[ROSTER-NPC] follow loop started enter={cfg.EnterMeters:0}m " +
$"exit={cfg.ExitMeters:0}m tick={tick}ms dailyLife={cfg.DailyLife}");
while (!token.IsCancellationRequested)
{
try
{
if (!IsBackgroundPushPaused(session))
await TickRosterNpcsAsync(session, owner, cfg);
}
catch (OperationCanceledException) { break; }
catch (ObjectDisposedException)
{
session.Log.Info("[ROSTER-NPC] loop stopped: session disposed");
break;
}
catch (Exception ex)
{
session.Log.Info($"[ROSTER-NPC] tick failed: {ex.Message}");
}
try { await Task.Delay(tick, token); }
catch (OperationCanceledException) { break; }
}
}
private static async Task TickRosterNpcsAsync(
TcpSession session, RosterNpcSessionState owner, RosterNpcSettings cfg)
{
var state = GetStateIfExists(session);
if (state is null)
return;
uint raid;
uint activeSpirit;
float px, py, pz;
int daySecond;
// ⛑⛑ 切人**进行中**的标志(规则 166):`HasSwitchOldUnitPose` 在切人请求进来时置 true、
// 整个切人流程跑完才置 false(`GameRouter.Switching.cs:101 / :899`)。
// 常驻循环 1.5 秒一跳,很容易正好落在切人那几百毫秒里:
// 22:22:34 / 22:22:38 / 22:22:52 三次都是「切人还没跑完就摆了一个里希」,
// 于是跑步机上每切一次就多一具。进行中一律不摆(下一跳再补)。
bool switchInFlight;
lock (state.SyncRoot)
{
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
return;
raid = state.ActiveRaidId;
activeSpirit = state.ActiveSpiritTemplateId;
px = state.LastReportedPlayerPosition.X;
py = state.LastReportedPlayerPosition.Y;
pz = state.LastReportedPlayerPosition.Z;
// ⛑ 时间基准收在 WorldEntryState.CurrentDaySecond 一处(规则 149:本地时间,非 UTC)。
daySecond = state.CurrentDaySecond();
switchInFlight = state.HasSwitchOldUnitPose || state.PendingSwitchTemplateId != 0;
}
if (!float.IsFinite(px) || !float.IsFinite(pz))
return;
// A scene switch wiped every entity on the client, so our bookkeeping goes with it.
lock (owner.SyncRoot)
{
if (owner.Raid != raid)
{
owner.Active.Clear();
owner.Raid = raid;
}
}
// 1. Retire what the player walked away from (hysteresis: exit > enter), AND what just
// changed activity — the clock moved on (panel time edit, or simply crossing into the
// next slice), so the body is standing in the wrong place doing the wrong thing.
// Step 2 re-places it at the new activity on this very tick.
// ⛑ 没有这一段的话,改游戏时间对**已经在场上**的角色完全没效果 ——
// 他们只在「走远 420m 再回来」时才重放,玩家会以为时间不影响日程。
var exit2 = cfg.ExitMeters * cfg.ExitMeters;
var gone = new List<uint>();
// 场上已经有了的身体(留场 NPC),用来防止叠人 —— 撤人那一步也要用,所以提前算。
HashSet<uint> leftBehind;
lock (state.SyncRoot)
leftBehind = state.LeftBehindCharacters.Keys.ToHashSet();
lock (owner.SyncRoot)
{
foreach (var (spirit, place) in owner.Active)
{
// ⛑⛑ 玩家**切回了**这个角色 ⇒ 我们摆在城里的那具身体必须撤掉。
// 不撤的话场上就有两个同一张脸:玩家操控的那个 + 我们按日程摆的那个,
// 而且切回的落点正是 `LeftBehindCharacters.Position`(也就是日程坐标)——
// 于是两个里希原地重叠(09-25 玩家报的就是这个)。
if (spirit == activeSpirit)
{
gone.Add(spirit);
continue;
}
// ⛑⛑ 这个角色已经归 Switching 的「留场」管了(我们在切人赛道上摆了一具),
// 常驻这具就是多余的,撤掉。少了这条,切走一次就永久多一具(规则 166)。
if (leftBehind.Contains(spirit))
{
gone.Add(spirit);
continue;
}
if (Dist2(place.X, place.Z, px, pz) > exit2)
{
gone.Add(spirit);
continue;
}
if (cfg.DailyLife)
{
var now = NpcActivityCatalog4229938.Resolve(
spirit, daySecond, raid, cfg.RequirePosition, px, py, pz);
if ((now.Entry?.Id ?? 0) != place.ActivityId)
gone.Add(spirit);
}
}
}
foreach (var spirit in gone)
{
await RemoveRosterNpcAsync(session, state, owner, spirit);
session.Log.Info($"[ROSTER-NPC] retired spirit={spirit} (walked away, slice changed, or player took control)");
}
// 2. Place what the player just walked into.
// ⛑⛑ 切人**进行中**一律不摆(规则 166):切人那几百毫秒里 activeSpirit /
// LeftBehindCharacters 都还没更新完,这一跳会照旧判「该摆」⇒ 每切一次多一具。
// 下一跳(1.5s 后)状态已经稳定,该摆的自然会摆上。
if (switchInFlight)
{
session.Log.Info("[ROSTER-NPC] skip place — 切人进行中");
return;
}
// ★★ 游戏时间跨进新的日程时段 ⇒ 把「被玩家操控过、切走后赖在原地的」角色
// **交还常驻化去演新活动**(用户 09-26 05:0x 要的;开关 LeftBehindFollowSchedule)。
//
// 放在 switchInFlight 判断之后:切人那几百毫秒里 LeftBehindCharacters 还没稳定,别插手
// (规则 166 的同款教训 —— 跑步机上每切一次多一具就是这么来的)。
//
// 判据(**两条,任一满足即放行**):
// ① 时段翻篇:现在时段表(Timed)的活动 id ≠ 切走时记的 TimedActivityId。
// ⛑ 用 Timed 不用 Entry 的理由见下面 nowTimedId 那一段(用 Entry 会恒等、永远不触发)。
// ② 玩家主动推时间:现在 TimeEpoch ≠ 切走时记的 TimeEpoch(开关 LeftBehindFollowOnTimeJump)。
// ⛑ 光有 ① 不够:时刻表一段就是 4~7 小时,玩家在 06:13 用「度过时间」跳到 06:00,
// 里希仍在同一段(00:00-07:00)⇒ ① 判不出来 ⇒ 玩家看到「我明明推了时间他还在原地」
// (2026-09-26 06:1x 实机)。② 抓的正是这种"主动推时间"的动作。
// ⛑ 不能拿 daySecond 直接比:实时也是每秒 +1,那样玩家逛 10 分钟角色就自己跑了。
if (cfg.DailyLife &&
PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.LeftBehindFollowSchedule)
{
uint nowEpoch;
lock (state.SyncRoot)
nowEpoch = state.TimeEpoch;
var followOnJump = PrivateServerConfigStore.Current.Gameplay
.SwitchAnimations.LeftBehindFollowOnTimeJump;
List<(uint Spirit, ulong UnitId, uint TimedActivityId, uint TimeEpoch)> behind;
lock (state.SyncRoot)
behind = state.LeftBehindCharacters
.Where(kv => kv.Key != activeSpirit)
.Select(kv => (kv.Key, kv.Value.UnitId, kv.Value.TimedActivityId, kv.Value.TimeEpoch))
.ToList();
foreach (var (spirit, unitId, timedActivityId, epochAtSwitch) in behind)
{
var now = NpcActivityCatalog4229938.Resolve(
spirit, daySecond, raid, cfg.RequirePosition, px, py, pz);
// ⛑⛑⛑ 判据必须是 **Timed**(时段表当下那一段指的活动),不能是 Entry。
// Entry 在「该时段活动没编坐标」时会被 FirstPositioned 兜底成一条**与时间无关**的
// 活动(同 tag 在本 raid 里 Id 最小的有坐标那条)⇒ 白天和夜里算出来是同一个 id
// ⇒ 判据恒等 ⇒ 玩家把时间从 05:45 跳到 18:00,一个角色都不会动
//(2026-09-26 06:0x 实机翻车:Mechanika 11400031→11400031、里希 11400012→11400012)。
var nowTimedId = now?.Timed?.Id ?? 0u;
var nowEntryId = now?.Entry?.Id ?? 0u;
var sliceChanged = nowTimedId != timedActivityId;
var timeJumped = followOnJump && nowEpoch != epochAtSwitch;
// 时段没变、玩家也没主动推时间 ⇒ 继续赖在他最后被操控的地方(前一条需求要的效果)。
if (!sliceChanged && !timeJumped)
continue;
var why = sliceChanged
? (timeJumped ? "时段翻篇+玩家推时间" : "时段翻篇")
: "玩家推时间(同一时段内)";
// ⛑ 新时段没有可落地的坐标就先不动:撤掉容易,摆不回去人就凭空消失了。
// ⛑ 但**必须打日志** —— 否则「到底跨没跨」完全不可观测,只能靠猜
//(52 个角色里当前时段有坐标的往往只有十几个,剩下 36 个 no-activity 是常态)。
if (now?.Placeable != true)
{
session.Log.Info($"[LEFTBEHIND-SLICE] spirit={spirit} slice {timedActivityId} -> {nowTimedId} " +
$"epoch {epochAtSwitch}->{nowEpoch} ({why}) daySecond={daySecond} " +
$"新时段无坐标(no-activity) ⇒ 保持原地");
continue;
}
// ① 撤掉留场那具身体(留场单位在 retirable 段,SyncLogicAgentLeave 收得掉,规则 165)
await session.NotifyAsync(MethodId.SyncLogicAgentLeave,
UxSerializer.Serialize(WorldCodec.LogicAgentLeave(unitId)), CancellationToken.None);
// ② 退出留场管理 ⇒ step 2 的距离筛选不再拦他,但也别让他再被摆第二具
// (PlaceRosterNpcAsync 会记账到 owner.Active,step 2 的 busy 检查会跳过)
lock (state.SyncRoot)
state.LeftBehindCharacters.Remove(spirit);
session.Log.Info($"[LEFTBEHIND-SLICE] spirit={spirit} unit={unitId} " +
$"slice {timedActivityId} -> {nowTimedId} " +
$"epoch {epochAtSwitch}->{nowEpoch} ({why}) " +
$"落到 {nowEntryId}/{now?.Entry?.Performance}({now?.Mode}) " +
$"daySecond={daySecond} ⇒ 交还常驻化去演新活动");
// ③ 直接摆,**不走 EnterMeters 距离筛选** —— 否则玩家离新日程点一远人就消失。
// ⛑⛑ 摆放前必须先发 donor aether init(常驻化 step 2 在 :363 也是先发它再摆的),
// 漏了这一步摆出来的身体可能不完整。
await SendDonorAetherInitAsync(session);
await PlaceRosterNpcAsync(session, state, owner, spirit, now, cfg, "leftbehind-slice");
}
}
var enter2 = cfg.EnterMeters * cfg.EnterMeters;
var wanted = new List<(uint Spirit, NpcActivityChoice? Choice, string Mode)>();
var noSlot = 0;
List<StaticNpcPose> taken;
lock (state.SyncRoot)
taken = state.StaticNpcPoses.Values.ToList();
lock (owner.SyncRoot)
{
var busy = owner.Active.Keys;
foreach (var character in ClientConfigRepository.Characters())
{
var spirit = character.TemplateId;
// 玩家正操控的那个不要放第二个 —— 场上会有两个同一张脸。
if (spirit == 0 || spirit == activeSpirit || busy.Contains(spirit))
continue;
// ⛑⛑ 切人留场已经把**同一个角色**摆了一具身体在世界里(就站在他的日程点位上),
// 这里再加一具就是「两个里希重叠」。留场那具归 Switching 管,我们不要再放。
if (leftBehind.Contains(spirit))
continue;
var agentId = GameAdminBridge.AgentIdForSpirit(spirit);
if (agentId == 0 || !DonorNpcCatalog4229938.TryGet(agentId, out _))
continue;
var choice = cfg.DailyLife
? NpcActivityCatalog4229938.Resolve(
spirit, daySecond, raid, cfg.RequirePosition, px, py, pz)
: null;
var mode = choice?.Mode ?? "daily-life-off";
// ⛑⛑ 既没有可落地的活动、也没有手工常驻点 ⇒ **不要放人**。以前这里会落到
// 「玩家位置 +3m」当兜底,结果名单里查不到日程的角色全部贴着玩家刷出来 ——
// 登录刷一圈,每传送一次再刷一圈。官方数据是「这个点没人就是没人」,照做。
if (choice?.Placeable != true && !cfg.PlaceWithoutActivity)
{
noSlot++;
continue;
}
var x = choice?.PlaceX ?? px;
var z = choice?.PlaceZ ?? pz;
if (Dist2(x, z, px, pz) > enter2)
continue;
// ⛑ 那个点位上已经站了一个 NPC(市民 / 功能 NPC / 别的角色)⇒ 不叠人。
// 用 3D 距离:同一栋楼 1 楼和 2 楼本来就该各自站一个人,只有真的贴在一起才算冲突。
var y = choice?.PlaceY ?? py;
if (cfg.MinSeparationMeters > 0f && Taken(x, y, z, taken, cfg.MinSeparationMeters))
{
session.Log.Info($"[ROSTER-NPC] skip spirit={spirit} — 点位 ({x:0.##},{y:0.##},{z:0.##}) " +
$"{cfg.MinSeparationMeters:0.#}m 内已经站着别的 NPC");
continue;
}
wanted.Add((spirit, choice, mode));
}
wanted = wanted.OrderBy(w => Dist2(w.Choice?.PlaceX ?? px, w.Choice?.PlaceZ ?? pz, px, pz))
.Take(Math.Max(1, cfg.PerTick))
.ToList();
if (!owner.Diagnosed)
{
owner.Diagnosed = true;
session.Log.Info($"[ROSTER-NPC] roster={ClientConfigRepository.Characters().Count} " +
$"no-activity={noSlot} placeWithoutActivity={cfg.PlaceWithoutActivity} " +
$"raid={raid} daySecond={daySecond} —— 只放当前时段有坐标的角色,其余不落地");
}
}
if (wanted.Count == 0)
return;
await SendDonorAetherInitAsync(session);
foreach (var (spirit, choice, mode) in wanted)
await PlaceRosterNpcAsync(session, state, owner, spirit, choice, cfg, mode);
}
private static async Task PlaceRosterNpcAsync(
TcpSession session, WorldEntryState state, RosterNpcSessionState owner,
uint spiritId, NpcActivityChoice? choice, RosterNpcSettings cfg, string mode = "?")
{
var agentId = GameAdminBridge.AgentIdForSpirit(spiritId);
if (agentId == 0)
return;
var activity = choice?.Entry;
// ⛑ 走到这里 choice.Placeable 一定为真 —— 除非显式开了 PlaceWithoutActivity,
// 那时只能站在玩家旁边待机(这是 09-25「一圈人贴着玩家刷出来」的老行为,默认已关)。
var x = choice?.PlaceX ?? 0f;
var y = choice?.PlaceY ?? 0f;
var z = choice?.PlaceZ ?? 0f;
if (choice?.Placeable != true)
{
lock (state.SyncRoot)
{
x = state.LastReportedPlayerPosition.X + 3f;
y = state.LastReportedPlayerPosition.Y;
z = state.LastReportedPlayerPosition.Z + 3f;
}
}
// ★★ 出场表现:活动级覆盖(按活动 id)> 角色级覆盖(按 spiritId)> 默认。
// 里希那套(POI 198 + 不带活动 id + 朝向 220.5 + 不发手机)现在对所有有日程的角色
// 都能用:在 `gameplay.rosterNpc.activityPoiOverrides` 里按活动 id 配即可
// (表里那 78 条是按中文名自动匹配生成的,猜错的改 poiActionId 就行,热加载)。
// 唯一裁决点在 `GameRouter.NpcLife.ResolveActivityPresentation` —— 别在这里另写一套。
var (poiActionId, activityCfgId, facingOrNull, skipPhone) =
ResolveActivityPresentation(spiritId, activity?.Id ?? 0u, cfg.PoiActionId);
var facing = facingOrNull ?? 0f;
// ★ 活动级坐标微调(官方 MapPosition 跟场景家具差几十厘米时的实机挪动,热加载)。
// ⛑ 只在真按活动坐标落地时生效(activity 非空)—— PlaceWithoutActivity 那条
// 「贴着玩家站」的兜底不参与,否则会出现"围着玩家站成一圈再整体平移"的怪相。
if (activity is not null)
(x, y, z) = ApplyActivityOffset(activity.Id, x, y, z);
// ⛑ 号段可切(gameplay.rosterNpc.retirableUidSegment,热加载):默认与市民同段,
// 若 Aether 那条移除帧确实删不掉,改成 true 走 0x300000000000(Leave 已验证可收)。
var id = cfg.RetirableUidSegment
? GameAdminBridge.NextRetirableNpcUid()
: unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
if (cfg.AgentUnitChannel)
{
await SendLeftBehindAgentAsync(session, agentId, id, x, y, z, facing,
poiActionId, null, forceGo: true, activityCfgId: activityCfgId);
}
else
{
await SendCivilianAsync(session,
new CivilianSpawnItem(agentId, poiActionId),
id, x, y, z, facing, null, activityCfgId, skipPhone);
}
// ⛑⛑ 账本里存的是**真实活动 id**,不是出场包里那个 activityCfgId。
// 上面 `poiOverrides.sendActivityCfgId=false` 会把出场包的活动 id 抹成 0
// (为了不进生活日程状态、去掉 ambient 那层打电话),但 `RunRosterNpcLoopAsync:214`
// 是用 `place.ActivityId` 和当前时段的活动比对来判「时段变了要撤人」的 ——
// 记 0 ⇒ 每个 tick 都判定 slice changed ⇒ 1.5 秒 retire+place 一次,
// 客户端单位又没被撤掉 ⇒ 跑步机上堆出一排里希(09-25 20:1x 实机)。
lock (owner.SyncRoot)
owner.Active[spiritId] = new RosterNpcPlacement(id, x, y, z, activity?.Id ?? 0);
// ★ 生活日程正式通道(硬规则 160):光有活动 id 客户端只会播 ambient(站着玩手机),
// 还得告诉它「这个 agentTag 现在占着几号 spoon 位、站哪儿」—— SyncFavorNpcSpoonAgentId
// (64003213)。默认关,见 gameplay.npcLife。
await EnsureNpcSpoonAsync(session, spiritId, agentId, activity?.Id ?? 0, x, y, z);
// 兜底通道(可选,默认关):直接给这个单位播一个 POI 动作,例如
// 198「站姿_运动_跑步机跑步」。见 gameplay.npcLife.poiActionId。
await EnsureNpcPoiActionAsync(session, id);
var what = activity is not null
? $"{activity.Id}/{activity.Performance}"
: (string.IsNullOrEmpty(choice?.Spot?.Note) ? "none(standby)" : choice!.Spot!.Note);
// ⛑ poi / activityCfgId 是**裁决之后**的结果(不是配置原值),排查「他怎么在演这个」
// 全靠这一行:activityCfgId=0 表示本条活动配了 POI、走了「不带活动 id」那条路。
session.Log.Info($"[ROSTER-NPC] placed spirit={spiritId} agent={agentId} unit={id} " +
$"mode={mode} activity={what} " +
$"poi={poiActionId} activityCfgId={activityCfgId} facing={facing:0.##} skipPhone={skipPhone} " +
$"raid={activity?.Raid ?? choice?.Spot?.Raid ?? 0} at=({x:0.##},{y:0.##},{z:0.##})");
}
private static async Task RemoveRosterNpcAsync(
TcpSession session, WorldEntryState state, RosterNpcSessionState owner, uint spiritId)
{
ulong id;
lock (owner.SyncRoot)
{
if (!owner.Active.TryGetValue(spiritId, out var place))
return;
id = place.UnitId;
}
// ⛑⛑⛑ 两个帧都发(规则 166)。原来只发 `SyncAetherAINpcRemove`(68332284),
// 而它对 `StaticNpcIdFloor`(8300000000000000000) 这具身体**根本没删掉** ——
// 09-25 22:2x 实机:日志里 `[ROSTER-NPC] retired spirit=15021021` 打了 5 次,
// 玩家那边跑步机上的里希一具都没少(「跑步机上的里希没有被回收」)。
// `SendCivilianAsync` 建这具身体时是发了 `SyncManagedLogicAgent` 的(它就是个
// logic agent),所以补一条通用回收帧 `SyncLogicAgentLeave`(68281244);
// Aether 那条保留(对真正的 Aether 行人它才是对的通道),两条一起发互不冲突。
await session.NotifyAsync(MethodId.SyncLogicAgentLeave,
UxSerializer.Serialize(WorldCodec.LogicAgentLeave(id)),
CancellationToken.None);
await session.NotifyAsync(MethodId.SyncAetherAINpcRemove,
UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = id }),
CancellationToken.None);
lock (owner.SyncRoot)
owner.Active.Remove(spiritId);
lock (state.SyncRoot)
{
state.StaticNpcPoses.Remove(id);
state.StaticNpcPreparedPlotEvents.Remove(id);
}
}
private static float Dist2(float x, float z, float px, float pz)
=> (x - px) * (x - px) + (z - pz) * (z - pz);
/// <summary>
/// 目标点位 <paramref name="separation"/> 米内(3D)是否已经站着别的 NPC。
/// 用 3D 是为了让「同一栋楼 1 楼和 2 楼各站一个」这种正常情况不误判 —— 只有真的贴在一起才算冲突。
/// </summary>
private static bool Taken(float x, float y, float z, List<StaticNpcPose> poses, float separation)
{
var limit = separation * separation;
foreach (var p in poses)
{
var dx = p.X - x;
var dy = p.Y - y;
var dz = p.Z - z;
if (dx * dx + dy * dy + dz * dz < limit)
return true;
}
return false;
}
}