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; /// /// Fifth entity channel: functional NPCs at fixed city spots (shop clerks, ticket sellers, ...). /// /// Same four-step announce sequence as the citizen channels — this file does not invent a spawn /// path, it only supplies different coordinates. Instead of "a plot's pavement spots, how many /// decided by the hour" the positions are固定的: one per venue, already snapped onto pavement by /// the generator, so a shop clerk ends up where the shop actually is. /// /// What the NPC *does* is not our problem here: F-key behaviour (open a shop panel, buy a ticket, /// start a mahjong match) is driven entirely by the client's own lua through /// AgentConfig.InteractSetting. The server just has to put a body there. That is deliberate — it /// is the whole reason this card is cheap. /// /// Two ways in, sharing one placement helper so they can never disagree: /// • the follow loop () — how they normally appear: walk up to /// a shop and its clerk is standing there; /// • the panel button () — "put them all down now". /// internal sealed partial class GameRouter { private const string FunctionalNpcKey = "client4229938-functional-npcs"; internal sealed class FunctionalNpcSessionState { internal object SyncRoot { get; } = new(); internal CancellationTokenSource? Cts; internal Task? Loop; /// entityId -> the spot it was placed for. Doubles as the "already placed" set. internal Dictionary Active { get; } = new(); /// /// Formwork ids whose placement has been started but not finished yet. /// ⛑ Placement awaits several network writes and happens OUTSIDE the lock, so a tick that /// starts while the previous batch is still in flight cannot see it in /// and would place the same clerk again — two identical NPCs stacked on one spot. /// internal HashSet Pending { get; } = new(); /// Scene the entries belong to; a change wipes them. internal uint Raid; } private static FunctionalNpcSessionState FunctionalNpcStateOf(TcpSession session) { if (session.Items.TryGetValue(FunctionalNpcKey, out var existing) && existing is FunctionalNpcSessionState typed) return typed; var created = new FunctionalNpcSessionState(); session.Items[FunctionalNpcKey] = created; return created; } /// World-ready hook: start the follow loop. Idempotent; no-op when disabled. internal static void EnsureFunctionalNpcs(TcpSession session) { if (!PrivateServerConfigStore.Current.Gameplay.Aether.FunctionalNpcsEnabled) return; var owner = FunctionalNpcStateOf(session); lock (owner.SyncRoot) { if (owner.Loop is { IsCompleted: false }) return; owner.Cts?.Dispose(); owner.Cts = new CancellationTokenSource(); owner.Loop = Task.Run(() => RunFunctionalNpcLoopAsync(session, owner, owner.Cts.Token)); } } /// Disconnect / scene switch: stop the loop. The client clears the entities itself. internal static void StopFunctionalNpcs(TcpSession session) { if (!session.Items.TryGetValue(FunctionalNpcKey, out var existing) || existing is not FunctionalNpcSessionState owner) return; lock (owner.SyncRoot) { owner.Cts?.Cancel(); owner.Cts?.Dispose(); owner.Cts = null; owner.Active.Clear(); owner.Pending.Clear(); } } /// How many the follow loop currently has standing. Panel display only. internal static int FunctionalNpcActiveCount(TcpSession session) { var owner = FunctionalNpcStateOf(session); lock (owner.SyncRoot) return owner.Active.Count; } private static async Task RunFunctionalNpcLoopAsync( TcpSession session, FunctionalNpcSessionState owner, CancellationToken token) { var cfg = PrivateServerConfigStore.Current.Gameplay.Aether; var tick = Math.Max(500, cfg.FunctionalNpcTickMs); session.Log.Info($"[FUNCNPC] follow loop started enter={cfg.FunctionalNpcEnterMeters:0}m " + $"exit={cfg.FunctionalNpcExitMeters:0}m tick={tick}ms per-tick={cfg.FunctionalNpcPerTick}"); while (!token.IsCancellationRequested) { try { // 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。 if (!IsBackgroundPushPaused(session)) await TickFunctionalNpcsAsync(session, owner, cfg); } catch (OperationCanceledException) { break; } catch (ObjectDisposedException) { session.Log.Info("[FUNCNPC] loop stopped: session disposed"); break; } catch (Exception ex) { session.Log.Info($"[FUNCNPC] tick failed: {ex.Message}"); } try { await Task.Delay(tick, token); } catch (OperationCanceledException) { break; } } } private static async Task TickFunctionalNpcsAsync( TcpSession session, FunctionalNpcSessionState owner, AetherSettings cfg) { var state = GetStateIfExists(session); if (state is null) return; uint raid; float px, pz; lock (state.SyncRoot) { if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0) return; raid = state.ActiveRaidId; px = state.LastReportedPlayerPosition.X; pz = state.LastReportedPlayerPosition.Z; } if (!float.IsFinite(px) || !float.IsFinite(pz)) return; if (FunctionalNpcSpotCatalog4229938.LoadError is not null) 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.Pending.Clear(); owner.Raid = raid; } } // 1. Retire what the player walked away from. Hysteresis (exit > enter) is the whole point: // a single threshold would spawn and despawn on every tick at the boundary. // // ⛑⛑ 默认不撤(Aether.FunctionalNpcRetireOnExit = false,见配置里的说明): // SyncAetherAINpcRemove 删不掉 8300 段那具身体(2026-09-25 实机),撤不掉却在玩家折回时 // 重新发一具 ⇒ 同一坐标叠 2~3 具(2026-10-01「健身房里 3 个卢卡斯·高桥」)。 // 只铺不撤时 Active 就是「本局已铺过」的账本,第 2 步的 busy 判断自然不会重铺。 if (cfg.FunctionalNpcRetireOnExit) { var exit2 = cfg.FunctionalNpcExitMeters * cfg.FunctionalNpcExitMeters; var gone = new List<(ulong Id, FunctionalNpcSpot4229938 Spot)>(); lock (owner.SyncRoot) foreach (var (id, spot) in owner.Active) if (spot.Raid != raid || Distance2(spot, px, pz) > exit2) gone.Add((id, spot)); foreach (var (id, spot) in gone) { await RemoveFunctionalNpcAsync(session, state, owner, id, spot); session.Log.Info($"[FUNCNPC] retired entity={id}"); } } // 2. Place what the player just walked into. Two per tick at most: the client drops the tail // of a large batch, and these are meant to appear as you stroll past, not all at once. var enter2 = cfg.FunctionalNpcEnterMeters * cfg.FunctionalNpcEnterMeters; var wanted = new List(); lock (owner.SyncRoot) { var busy = new HashSet(owner.Active.Values.Select(s => s.NpcFormworkId)); busy.UnionWith(owner.Pending); // ⛑ in-flight placements count as busy too wanted = FunctionalNpcSpotCatalog4229938.ForRaid(raid, true) .Where(s => !busy.Contains(s.NpcFormworkId) && Distance2(s, px, pz) <= enter2) .OrderBy(s => Distance2(s, px, pz)) .Take(Math.Max(1, cfg.FunctionalNpcPerTick)) .ToList(); foreach (var s in wanted) owner.Pending.Add(s.NpcFormworkId); } if (wanted.Count == 0) return; await SendDonorAetherInitAsync(session); foreach (var spot in wanted) { try { await PlaceFunctionalNpcAsync(session, state, owner, spot); } finally { lock (owner.SyncRoot) owner.Pending.Remove(spot.NpcFormworkId); } } } private static async Task RemoveFunctionalNpcAsync( TcpSession session, WorldEntryState state, FunctionalNpcSessionState owner, ulong id, FunctionalNpcSpot4229938? spot = null) { // ★ 捐赠端 GameRouter.FunctionalNpc.cs:218:NPC 退役时要从警察登记表里摘掉, // 否则盘查链会一直以为这个 NPC 还在场上(控制台列表 / 押送目标都会残留)。 await UnregisterPoliceNpc4229938(session, id); await session.NotifyAsync(MethodId.SyncAetherAINpcRemove, UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = id }), CancellationToken.None); // ⛑ Second shot under the id we actually wrote into ClientStaticNpcInitData.NpcPid. // The spawn packet carries three different ids (entity id / StaticNpcInfoId / NpcPid) and // we do not know which one the client keys its remove on; a remove that silently misses // leaves the old body standing and the next visit stacks a second copy on the same spot. // Every formwork id is used by exactly one spot per raid, so this cannot over-delete. if (spot is not null && spot.NpcFormworkId != 0) { await session.NotifyAsync(MethodId.SyncAetherAINpcRemove, UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = spot.NpcFormworkId, }), CancellationToken.None); session.Log.Info($"[FUNCNPC] retired also by formwork={spot.NpcFormworkId} ({spot.Name})"); } lock (owner.SyncRoot) owner.Active.Remove(id); lock (state.SyncRoot) { state.StaticNpcPoses.Remove(id); state.StaticNpcPreparedPlotEvents.Remove(id); } } /// /// ★★★ R93(2026-09-27)—— **落脚高度吸附**:把点位声明的 Y 换成「该 XZ 上真实地面/物件的高度」。 /// /// 为什么必须做:FunctionalNpcSpots.json 的 Y 是用 CivilianDistricts 人行道点 /// 反向找的(见该文件 note)。那套人行道点只在**主城平地**有数据,一旦点位的 XZ 落在 /// **高台/庙会/室内**,找出来的就是别处(常常是 y≈0/2 那一层)的 Y。 /// 实测标本(2026-09-27):Tea Shop Owner(40954014)/Tea Stall Owner(40954022) 声明 Y=2.00, /// 而同 XZ 上 2528 件场景物一律 **y≈166~172** ⇒ NPC 被生成在**地下 167 m**, /// 现象 = 用户所说的「摊位的npc呢」(看着像根本没刷,其实刷了、埋了)。 /// /// 数据源 = config/scene_item_placements.json(mtime 热重载,4877 件带真实 y)。 /// 取点位 XZ **半径 内最近的若干件**的 y 中位数当落脚高度: /// 中位数对「同一点位旁边既有台面又有地面」的情况比最近邻稳。 /// 命中不到(荒地/新点位)⇒ **原样返回**,绝不猜。 /// private static float SnapGroundY(float x, float y, float z, out int samples, out float snapped, float radius = 8f) { samples = 0; snapped = y; List items; try { items = LoadSceneItemPlacements(); } catch { return y; } if (items.Count == 0) return y; var r2 = radius * radius; var ys = new List(16); foreach (var it in items) { var dx = it.X - x; var dz = it.Z - z; if (dx * dx + dz * dz > r2) continue; ys.Add(it.Y); } if (ys.Count == 0) return y; ys.Sort(); // 只保留「高于声明值」的候选:声明值被打错成低层(y≈2)时,低层的散件会把中位数拖回去。 var above = ys.Where(v => v > y + 1f).ToList(); var pool = above.Count > 0 ? above : ys; snapped = pool[pool.Count / 2]; samples = pool.Count; return snapped; } /// /// One placement: announce + remember. Returns the entity id, or 0 when the spot's ids are not /// in the catalogs (a bad row in the json must not kill the batch). /// private static async Task PlaceFunctionalNpcAsync( TcpSession session, WorldEntryState state, FunctionalNpcSessionState owner, FunctionalNpcSpot4229938 spot) { if (!DonorNpcCatalog4229938.TryGet(spot.NpcFormworkId, out _)) return 0; if (spot.PoiActionId != 0 && !DonorPoiActionCatalog4229938.TryGet(spot.PoiActionId, out _)) return 0; // ★ R93:落脚高度吸附(见 SnapGroundY 说明)。吸附不动时完全等价于原行为。 var y = SnapGroundY(spot.X, spot.Y, spot.Z, out var samples, out var snapped); var yNote = samples > 0 && MathF.Abs(snapped - spot.Y) > 1f ? $" ySnap={spot.Y:F2}->{snapped:F2}(n={samples})" : ""; var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor)); await SendCivilianAsync(session, new CivilianSpawnItem(spot.NpcFormworkId, spot.PoiActionId), id, spot.X, spot.Y, spot.Z, spot.Facing); lock (owner.SyncRoot) owner.Active[id] = spot; lock (state.SyncRoot) { state.StaticNpcPreparedPlotEvents[id] = 0; state.StaticNpcPoses[id] = new StaticNpcPose(spot.X, spot.Y, spot.Z, spot.Facing); } // ★ 捐赠端 GameRouter.FunctionalNpc.cs:265-266:功能 NPC 也要进警察账本, // 否则里希对它按原生 F 会走到 "untracked/unsupported target"。 await RegisterPoliceNpc4229938(session, id, spot.NpcFormworkId, new Vec3(spot.X, spot.Y, spot.Z)); _ = RefreshSpawnedPoliceNpcs4229938(session, [id], state.WorldEntryControlGeneration); session.Log.Info($"[FUNCNPC] placed {spot.Name}({spot.NpcFormworkId}) poi={spot.PoiActionId} " + $"at=({spot.X:F2},{spot.Y:F2},{spot.Z:F2}) facing={spot.Facing:F0} " + $"entity={id} anchor={spot.Anchor}"); return id; } /// /// Panel button: place every functional NPC of the current scene (nearest first when a radius /// is given, radius <= 0 = the whole scene). Anything already standing is skipped. /// internal static async Task<(bool Ok, string Message, ulong[] Ids, string Detail)> SpawnFunctionalNpcsAsync( TcpSession session, float radius, bool includeSemantic) { var state = GetStateIfExists(session); if (state is null) return (false, "没有玩家世界状态", [], ""); uint raidId; Vec3 near; lock (state.SyncRoot) { if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0) return (false, "等玩家在场景里站稳(已知道坐标)再生成", [], ""); raidId = state.ActiveRaidId; near = state.LastReportedPlayerPosition; } var loadError = FunctionalNpcSpotCatalog4229938.LoadError; if (loadError is not null) return (false, $"功能 NPC 点位没加载起来:{loadError}", [], ""); var spots = FunctionalNpcSpotCatalog4229938.WithinRadius( raidId, near.X, near.Z, radius, includeSemantic); if (spots.Count == 0) return (false, $"当前场景(raid {raidId})没有可用的功能 NPC 点位。" + $"(点位文件共 {FunctionalNpcSpotCatalog4229938.Count} 个;只在主城 23300888 / 23300999 有数据)" + (FunctionalNpcSpotCatalog4229938.NonPersonSkipped > 0 ? $";规则 199 拦下 {FunctionalNpcSpotCatalog4229938.NonPersonSkipped} 个非人模板(物件/敌人/临时通用模型)" : ""), [], ""); var owner = FunctionalNpcStateOf(session); var ids = new List(); var lines = new List(); var skipped = new List(); string? failure = null; try { await SendDonorAetherInitAsync(session); var sent = 0; foreach (var spot in spots) { lock (owner.SyncRoot) { if (owner.Active.Values.Any(a => a.NpcFormworkId == spot.NpcFormworkId)) { skipped.Add($"{spot.Name}(已经在场)"); continue; } } var id = await PlaceFunctionalNpcAsync(session, state, owner, spot); if (id == 0) { skipped.Add($"{spot.Name}(模板或 POI 不在目录里)"); continue; } ids.Add(id); var function = string.Join("/", spot.Labels.Count > 0 ? spot.Labels : spot.Actions); lines.Add($" {spot.Name}({spot.NpcFormworkId})· {function} · {spot.Anchor}"); if (++sent % 20 == 0) await Task.Delay(10); } } catch (Exception ex) { failure = ex.Message; } var head = $"场景 {raidId} · 半径 {(radius <= 0 ? "不限" : radius.ToString("0") + "m")} · " + $"命中 {spots.Count} 个点位,实际生成 {ids.Count} 人\n"; if (skipped.Count > 0) head += "跳过 " + string.Join("; ", skipped) + "\n"; var detail = head + string.Join("\n", lines); return failure is null ? (true, $"已铺 {ids.Count} 个功能 NPC", [.. ids], detail) : (false, $"已送出 {ids.Count} 个后失败:{failure}", [.. ids], detail); } private static float Distance2(FunctionalNpcSpot4229938 spot, float x, float z) { var dx = spot.X - x; var dz = spot.Z - z; return dx * dx + dz * dz; } }