using Ananta.SDK.Network; using Ananta.SDK.Serialization; using Auto = Ananta.Server.RpcTypes.Client4229938.Auto; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; public sealed record DonorNpcSpawnItem(uint NpcFormworkId, uint PoiActionId); /// /// Verbatim port of the donor build's GameRouter.Npc.cs (2026-09-17), kept deliberately /// separate from GameRouter.Civilians.cs so the two implementations can be A/B'd. /// /// The previous port failed silently — the card reported "sent" while nothing appeared in the /// world — because of two deviations from this file, both now restored: /// 1. SendDonorAetherInitAsync before the loop. It was dropped on the theory that World.cs /// already publishes init once per scene; the donor re-sends it on every spawn, and the /// crowd manager needs it before it will materialise a pedestrian. /// 2. The nested sync contract. DonorNpcAgentSyncClientInfo4229938 has 43 fields; reusing the /// 46-field SceneMethods.AgentSyncClientInfo shifted every field after it and the client /// discarded the packet without a complaint. /// /// Anything that looks like it could be "tidied up" here should be left alone until the donor /// path is confirmed working in-game. /// internal sealed partial class GameRouter { // 高水位落盘(IdCounterStore §6.10):进程内递增 = 重启后复用旧 instanceId。 private const long StaticNpcIdFloor = 8_300_000_000_000_000_000L; private const string StaticNpcIdCounter = "DonorNpc.StaticNpcId"; private const long NpcBelongingIdFloor = 8_310_000_000_000_000_000L; private const string NpcBelongingIdCounter = "DonorNpc.NpcBelongingId"; /// NPC 默认动作组 = 客户端 AetherNpcConfig.NPCDefaultActionGroup(上限 499)。 /// POI 表 3978 行里 3977 行的 MainActionGroup 也是这个值。 private const uint DefaultNpcActionGroupId = 400; /// 默认动作 = 通用 idle(POI 2「idle」/ POI 17「站姿_通用_站姿闲置00」的 MainActions 都是 1001)。 private const uint DefaultNpcActionId = 1001; internal static Task<(bool Ok, string Message, ulong[] Ids)> SpawnDonorStaticNpcAsync( TcpSession session, uint npcFormworkId, uint poiActionId) => SpawnDonorStaticNpcsAsync(session, [new(npcFormworkId, poiActionId)], 1.5f, 1.5f, 10); internal static async Task<(bool Ok, string Message, ulong[] Ids)> SpawnDonorStaticNpcsAsync( TcpSession session, IReadOnlyList items, float xSpacing, float zSpacing, int maxPerRow) { if (items.Count < 1) return (false, "provide at least one NPC item", []); if (!float.IsFinite(xSpacing) || xSpacing < 0f || !float.IsFinite(zSpacing) || zSpacing < 0f) return (false, "NPC X/Z spacing must be finite non-negative numbers", []); if (maxPerRow < 1) return (false, "maximum NPCs per row must be at least 1", []); // Validate the entire batch before sending any NPCs. foreach (var item in items) { if (item is null || item.NpcFormworkId > int.MaxValue || !DonorNpcCatalog4229938.TryGet(item.NpcFormworkId, out _)) return (false, $"unknown NPC formwork {item?.NpcFormworkId}; expected an AgentConfig Id", []); if (item.PoiActionId != 0 && !DonorPoiActionCatalog4229938.TryGet(item.PoiActionId, out _)) return (false, $"unknown NPC POI action {item.PoiActionId}", []); } var state = GetStateIfExists(session); if (state is null) return (false, "no player world state", []); Vec3 near; float facing; lock (state.SyncRoot) { if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0) return (false, "wait until the player is ready in the scene with a known position", []); near = state.LastReportedPlayerPosition; facing = state.LastReportedPlayerRotation.Y; } if (!float.IsFinite(near.X) || !float.IsFinite(near.Y) || !float.IsFinite(near.Z) || !float.IsFinite(facing)) return (false, "player transform is not finite", []); var ids = new List(); var yaw = facing * MathF.PI / 180f; var npcFacing = (facing + 180f) % 360f; if (npcFacing > 180f) npcFacing -= 360f; // ★ 2026-10-02 对齐捐赠端 GameRouter.Npc.cs:29-31:静态 NPC 生成必须串行。 // ⛑ 一个 NPC 的诞生是一串**有序包**(StaticNpcAddData → ManagedLogicAgent → // BattleAiS=false → POI → 归属物 → 警察登记),两批并发会把这串包交错发出, // 客户端拿到半初始化实体 ⇒ 「NPC 看得到,但原生盘查菜单弹不出来」。 // Wait(0) 是非阻塞的:批次进行中就直接拒绝,不排队(与捐赠端一致)。 if (!state.StaticNpcSpawnGate.Wait(0)) return (false, "NPC 批次正在进行中,请等上一批结束再点", []); try { try { // The static-NPC lifecycle creates the logical agent and its pedestrian data. await SendDonorAetherInitAsync(session); for (var i = 0; i < items.Count; i++) { var item = items[i]; Console.WriteLine($"[{i}]Spawn npc formwork: {item.NpcFormworkId}. poi: {item.PoiActionId}"); var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor)); // Begin directly in front of the player, then lay out a // player-relative grid with the requested number of NPCs per row. var forward = 3f + (i / maxPerRow) * zSpacing; var right = (i % maxPerRow) * xSpacing; DonorNpcCatalog4229938.TryGet(item.NpcFormworkId, out var npc); DonorPoiActionCatalog4229938.TryGet(item.PoiActionId, out var poi); var mainActionId = poi?.SelectMainAction(id) ?? 0; var position = new SceneMethods.UxVector3( near.X + MathF.Sin(yaw) * forward + MathF.Cos(yaw) * right, near.Y, near.Z + MathF.Cos(yaw) * forward - MathF.Sin(yaw) * right); var agentSyncClientInfo = CreateDonorStaticNpcSyncInfo(item, npc, poi, mainActionId); // A standalone battle projection omits persona and static pedestrian POI // state. Let AddAetherAIStaticNpc create the complete logical entity; // ForceGo requests the full character without replacing that lifecycle. await session.NotifyAsync(MethodId.SyncAetherAIStaticNpcAddData, UxSerializer.Serialize(new GameMethods.DonorClientStaticNpcInitData4229938 { StaticNpcInfoId = item.NpcFormworkId, NpcFormworkId = item.NpcFormworkId, AgentPersonaId = npc.AgentPersonaId, SPoiActionId = 0, CPoiActionId = item.PoiActionId, UrbanDiversityId = 88888000, IgnoreAllStim = false, TaskRelated = false, // ★ 捐赠端 GameRouter.Npc.cs:110-120 —— 主线原本**整个字段都没设**。 // ⛑⛑ 2026-10-02 取证(`packets-20260927-142740.log` 会话 G:32e2a0): // 捐赠端成功那次日志是 `[NPC-HACK] entity=… enableHack=True phone=True // usage=0 items=80003481`;主线这次指纹 **0 次**。 // 序列化器照样写这个 bool ⇒ **包体大小不变(两边都是 230b)、字段位置也不变**, // 只是值是 false —— 所以「两边包一样大」证明不了「内容一样」。 // 缺了它 ⇒ 客户端不认为该 NPC 可骇入 ⇒ 原生 F 交互菜单不出现, // 服务端永远收不到 `NpcInteract`(而认 EscortRequest=21「押送上车」的 // 正是 `GameRouter.LixiPolice.cs:593` 那条 `NpcInteract` 分支)。 EnableHack = npc.AgentPersonaId != 0, NpcPid = (int)item.NpcFormworkId, AgentSyncClientInfo = agentSyncClientInfo, ForceGo = true, SourceType = 1, // UX.Game.StaticNpcSourceType.Spoon (4229938). Id = id, Position = position, Facing = npcFacing, EulerAngles = new SceneMethods.UxVector3(0, npcFacing, 0), }), CancellationToken.None); ids.Add(id); lock (state.SyncRoot) state.StaticNpcPreparedPlotEvents[id] = 0; // Static creation registers the logical entity and its sync module, // but does not assign a simulation owner. Without this notification, // LogicAgentModule.ApplyMasterPolicy(false) disables the full-model // state machine after the ECS-to-GO POI handoff. await session.NotifyAsync(MethodId.SyncManagedLogicAgent, UxSerializer.Serialize(WorldCodec.ManagedLogicAgent(id, Profile.PlayerPid, 0)), CancellationToken.None); // ★ 捐赠端 GameRouter.Npc.cs:137-142:静态 NPC 必须显式置为非战斗 AI。 // 原生 PoliceJobUtils.CanUnitInteract 会**拒绝** IsBattleAiS 的目标 ⇒ 不置 false, // 这个 NPC 永远盘查不了(F 键不亮)。主线原本缺这一段。 await session.NotifyAsync(MethodId.SyncBattleAiS, UxSerializer.Serialize( new SceneMethods.SyncBattleAiS { agentId = id, value = false }), CancellationToken.None); await RegisterPoliceNpc4229938(session, id, item.NpcFormworkId, new Vec3(position.X, position.Y, position.Z)); if (poi is { UsageId: not 0 }) { // The usage loader requires owned items before it can bind them. // Inventory is cached on AgentData; usage is retained by the ECS // handle and transferred by ECSToGOBelongingTransition. await session.NotifyAsync(MethodId.SyncAgentBelongings, UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongings4229938 { Id = id, Items = poi.BelongingSceneItems.Select(configId => new GameMethods.DonorNpcBelongingItem4229938 { InstanceId = unchecked((ulong)IdCounterStore.Next(NpcBelongingIdCounter, NpcBelongingIdFloor)), ConfigId = configId, }).ToList(), DebugAgentId = item.NpcFormworkId, }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncAgentUseBelongingChanged, UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongingUsage4229938 { Id = id, UsageIds = [poi.UsageId], }), CancellationToken.None); session.Log.Info($"[NPC] POI_BELONGINGS_SENT entity={id} poi={poi.Id} usage={poi.UsageId} items={string.Join(',', poi.BelongingSceneItems)}"); } // Store the active POI in the logical POI module at normal playback speed. // Do not periodically replay it: reactions must be able to interrupt it. if (item.PoiActionId != 0) await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentPlayPOIAction, UxSerializer.Serialize(new GameMethods.DonorClientNpcPoiActionData4229938 { Id = id, CPoiActionId = item.PoiActionId, IsActive = true, PlayPoiSpeed = 1f, }), CancellationToken.None); // T2-32 路人骇入:GM 批量刷出来的 static NPC 一样要能被骇(并且得有一部正在用的手机, // 否则面板只剩名字 —— 见 GameRouter.HackNpc.cs 的说明)。 var donorUsageHackable = poi is { UsageId: not 0 } dp && HackableUsageIds.Contains(dp.UsageId); await MarkNpcHackableOnSpawnAsync(session, id, HackNpcChannel.Static, skipPhone: donorUsageHackable); // ★ 捐赠端 GameRouter.Npc.cs:179 / :143 的两条取证指纹 —— 主线原本一条都没有, // 导致上一次排查只能靠猜(详见 2026-10-02 日志 §45)。 // ⛑ 主线 NpcCatalog 投影里**没有** PoliceExamType 列,故 ACTION_NPC 不打印 examType。 session.Log.Info($"[NPC-HACK] entity={id} enableHack={npc.AgentPersonaId != 0} persona={npc.AgentPersonaId} usage={poi?.UsageId ?? 0} skipPhone={donorUsageHackable} phone=by-MarkNpcHackableOnSpawnAsync"); session.Log.Info($"[LIXI-POLICE] ACTION_NPC entity={id} template={item.NpcFormworkId} battleAi=false; native F requires EnableHack + eligible template and player state"); session.Log.Info($"[NPC] STATIC_PEDESTRIAN_SPAWN_SENT formwork={item.NpcFormworkId} model={npc.GeneralModelId} persona={npc.AgentPersonaId} poiAction={item.PoiActionId} action={mainActionId}/{poi?.MainActionGroup ?? 0} behaviorActionConfig={npc.BehaviorActionConfigId} activityConfig={agentSyncClientInfo.AgentDataSetsActivityCfgId} ai={npc.AiSettingId} entity={id} managedPid={Profile.PlayerPid} spoon={agentSyncClientInfo.SpoonAgentId} forceGo=true ignoreStim=false poiSpeed=1 fashion={agentSyncClientInfo.FashionSuitId} pos={position.X},{position.Y},{position.Z} playerPos={near}"); } } catch (Exception ex) { return (false, $"sent {ids.Count}/{items.Count} NPCs before send failed: {ex.Message}", ids.ToArray()); } // ★ 捐赠端 GameRouter.Npc.cs:199:整批刷完之后统一刷新警察登记表(F 按钮 / 盘查候选)。 // ⛑ 即使上面中途抛异常也要刷:ids 里已有的 NPC 已经在客户端落地了。 _ = RefreshSpawnedPoliceNpcs4229938(session, ids.ToArray(), state.WorldEntryControlGeneration); return (true, $"sent spawn for {ids.Count} static NPC(s) in front of you", ids.ToArray()); } finally { state.StaticNpcSpawnGate.Release(); } } internal static GameMethods.DonorNpcAgentSyncClientInfo4229938 CreateDonorStaticNpcSyncInfo( DonorNpcSpawnItem item, DonorNpcCatalogEntry4229938 npc, DonorPoiActionCatalogEntry4229938? poi, uint mainActionId, CivilianLinkDress? dress = null, uint activityCfgId = 0, // ★ 第 46 轮(自 v2.1 捐赠端移植,2026-09-29):两个**可选**覆盖(默认值 = 原行为, // 所有既有调用点零差异)。说明见 // PrivateServerConfig.PartyNpcAISettingOverride / PartyNpcTreeNameOverride。 uint aiSettingOverride = 0, string? treeNameOverride = null) { // ⛑ 「这个 NPC 其实是某个可玩角色」的原生表达:把 AIAgentInfo(LinkAIAgentInfo) // 填上,里面带 FightSpiritId + 单件衣服列表(见 CivilianLinkDress 的说明)。 // 模板 NPC 一律留空 —— donor 原本也是空的。 var linkAiAgentInfo = dress is null ? new GameMethods.DonorNpcLinkAIAgentInfo4229938() : new GameMethods.DonorNpcLinkAIAgentInfo4229938 { Uid = dress.Uid, FightSpiritId = dress.FightSpiritId, Fashion = new GameMethods.DonorNpcLinkAIFashionInfo4229938 { SuitId = dress.SuitId, // ⛑ WearInfo 在 wire 上**不可空**(RPCSerializeAuto.lua:4718 最后一个参数 false), // 所以这里永远要给一个实例,不能留 null。 WearInfo = new Auto.OtherPlayerSpiritWearFashionsInfo { WearFashionColoringInfoDict = new Dictionary(), WearFashionInfoList = dress.FashionIds .Where(id => id != 0) .Select(id => new Auto.WearFashionInfo { FashionId = id }) .ToList(), WearFashionEditInfoList = [], HiddenParts = dress.HiddenParts, EditedHiddenParts = 0, }, }, Nickname = dress.Nickname, NameId = 0, AvatarImageId = 0, VehicleId = 0, }; return new GameMethods.DonorNpcAgentSyncClientInfo4229938 { stimIDList = new List(), CanBeExaminedByPolice = true, indoorList = new(), roomIds = new List(), SpoonAgentId = (int)item.NpcFormworkId, treeName = string.IsNullOrEmpty(treeNameOverride) ? "PedBase" : treeNameOverride, petPerformData = "", spawnEffectId = new List(), randomModelCfgId = 0, LeaveDistance = 10000, approachDistance = 10000, // ⛑ 只能填**套装** id;玩家搭配几乎全是散件(FunctionSuitId=0)⇒ 散件走 // AIAgentInfo.Fashion.WearInfo,这里只在真的有套装时才覆盖。 FashionSuitId = dress is { SuitId: not 0 } ? dress.SuitId : 11190001, AIAgentInfo = linkAiAgentInfo, // ⛑️ T0-9(2026-09-22):`actionGroupId` = 动作组(400 = NPC 默认,客户端 // AetherNpcConfig.NPCDefaultActionGroup,POI 表 3977/3978 行的 MainActionGroup 也是 400); // `actionId` = 组内的具体动作(POI 表 MainActions:1001 idle / 9018 走路 / …)。 // 没有 POI 的 NPC 原来两个槽位都是 0 = **空动作组**(IsEmptyAction,只配给怪物/隐藏单位) // ⇒ 模型在、不 T-pose,但不会有任何待机/行走/POI 动画,看着就是个雕像。 // 兜底:组恒 400,动作落到 1001(通用 idle,POI 2/17 都是它)。 actionId = mainActionId != 0 ? mainActionId : DefaultNpcActionId, actionGroupId = poi?.MainActionGroup ?? DefaultNpcActionGroupId, initPoiActionId = item.PoiActionId, useDefaultPoiOnReturn = item.PoiActionId != 0, returnPoiActionIds = item.PoiActionId == 0 ? [] : [item.PoiActionId], // ★ 过日子接线点(与 SendLeftBehindAgentAsync 里那个同义):0 = 只走 ambient POI //(只会站着);非 0 = AgentDataSetsActivityConfig.Id,客户端这个 NPC 改走生活日程 // 状态,去播那条活动的表演(跑步机 / 坐着刷手机 / 修车 …),并盖掉 ambient POI。 AgentDataSetsActivityCfgId = activityCfgId, InteractId = npc.InteractSettingId, AISetting = aiSettingOverride != 0 ? aiSettingOverride : npc.AiSettingId, beHitType = 1, // Civilian hit reaction without combat damage. agentStimType = 0, forbidStimulateType = 0, isAttackInSafeMode = true, }; } /// /// Donor's SyncAetherAIInitDatas (68704070). Sent before every static-NPC batch. /// /// v0.3's Vehicle copy of this lives behind a per-runtime one-shot guard; the donor sends it /// unconditionally on spawn, so this unguarded version exists for exactly that purpose. /// private static async Task SendDonorAetherInitAsync(TcpSession session) { var settings = PrivateServerConfigStore.Current.Gameplay.Vehicles; var activeRaidId = GetStateIfExists(session)?.ActiveRaidId ?? Profile.RaidId; var hasZoneGraph = activeRaidId == PrivateServerConfigStore.Current.World.RaidId; var handle = hasZoneGraph ? settings.ZoneStorageDataHandle : 0; // ⛑️⛑️ 不要退回 `AetherAIInitData4229938`(Intersections 用 Int32 count)。 // 客户端按 Int7 偏置读那个 count:`FF 00000000` 被解成 -64 ⇒ // `new List(-64)` ⇒ "Non-negative number required" ⇒ 整包丢弃 ⇒ // AetherCityManager.InitAetherCity 不跑 ⇒ 车/行人/静态 NPC 全没。 // 正确线格式是 12 字节 `FF188B630101611ABBC1FF01`(见 VehicleMethods4229938.cs 注释)。 // 走 World.cs 那条线用的同一个 wire 工厂,保证两处永不漂移。 var variants = PrivateServerConfigStore.Current.Gameplay.Aether.InitVariants; if (variants is null || variants.Count == 0) variants = ["top-int7"]; foreach (var variant in variants) { var wire = GameAdminBridge.BuildAetherInitWire(variant, activeRaidId, hasZoneGraph, handle); if (wire is null) { session.Log.Info($"[AETHER] donor init unknown variant '{variant}' (skipped)"); continue; } await session.NotifyAsync(MethodId.SyncAetherAIInitDatas, wire, CancellationToken.None); session.Log.Info($"[AETHER] donor init raid={activeRaidId} zoneGraph={hasZoneGraph} " + $"handle={handle} variant={variant} bytes={wire.Length} hex={Convert.ToHexString(wire)}"); } // ★ 2026-10-02:捐赠端在 Aether init 之后也会推一次路口相位(捐赠端 GameRouter.Aether.cs:449)。 // `PushIntersectionUpdatesAsync` 幂等(只推没推过的路口)、`StartIntersectionSignals` // 有"已在跑"守卫 ⇒ 这里重复调用无害,作用是把 Vehicle 那条 init 链路没跑到的情况兜住。 await PushIntersectionUpdatesAsync(session, CancellationToken.None).ConfigureAwait(false); StartIntersectionSignals(session); } }