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