Files

592 lines
30 KiB
C#
Raw Permalink Normal View History

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