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; /// /// 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 away 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 /// , only with different coordinates. /// /// Coordinates and the activity id come straight from the client's own tables /// (see ): nothing here is invented. /// 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; /// spiritId -> what was placed for it. Doubles as the "already placed" set. internal Dictionary Active { get; } = new(); /// Scene the entries belong to; a change wipes them. internal uint Raid; /// 是否已经打过一次「名册里有多少人没有可落地活动」的诊断行(只打一次,别刷屏)。 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; } /// World-ready hook: start the follow loop. Idempotent; no-op when disabled. 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)); } } /// Disconnect / scene switch: stop the loop. The client clears the entities itself. 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(); } } /// How many are currently standing. Panel display only. internal static int RosterNpcActiveCount(TcpSession session) { var owner = RosterNpcStateOf(session); lock (owner.SyncRoot) return owner.Active.Count; } /// /// ★ 切人接管时**立即**撤掉常驻摆在日程点上的那具身体(09-26 00:4x 加)。 /// /// ⛑⛑ 为什么必须同步做:开了 SwitchToScheduleSpot 之后,玩家会直接瞬移到 /// 角色的日程点位。而那个点位上常驻化很可能已经摆了一具同样的身体 /// (他本来就「在城里过日子」)⇒ 瞬移过去就是「两个里希重叠」的老毛病(规则 166)。 /// 常驻循环 1.5 秒才跳一次,等它自己撤(spirit == activeSpirit)中间那一秒玩家 /// 就盯着两个自己 —— 所以切人时主动撤一次。 /// ⛑ 撤不掉也不至于更糟:RemoveRosterNpcAsync 已经是 Leave+Aether 双发(规则 166)。 /// internal static async Task 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; } /// /// spiritId -> 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). /// internal static Dictionary 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(); // 场上已经有了的身体(留场 NPC),用来防止叠人 —— 撤人那一步也要用,所以提前算。 HashSet 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 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); /// /// 目标点位 米内(3D)是否已经站着别的 NPC。 /// 用 3D 是为了让「同一栋楼 1 楼和 2 楼各站一个」这种正常情况不误判 —— 只有真的贴在一起才算冲突。 /// private static bool Taken(float x, float y, float z, List 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; } }