Files

353 lines
23 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
/// <summary>
/// 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.
/// </summary>
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";
/// <summary>NPC 默认动作组 = 客户端 AetherNpcConfig.NPCDefaultActionGroup(上限 499)。
/// POI 表 3978 行里 3977 行的 MainActionGroup 也是这个值。</summary>
private const uint DefaultNpcActionGroupId = 400;
/// <summary>默认动作 = 通用 idle(POI 2「idle」/ POI 17「站姿_通用_站姿闲置00」的 MainActions 都是 1001)。</summary>
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<DonorNpcSpawnItem> 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<ulong>();
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<uint, Auto.FashionColoringInfo>(),
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<int>(),
CanBeExaminedByPolice = true,
indoorList = new(),
roomIds = new List<int>(),
SpoonAgentId = (int)item.NpcFormworkId,
treeName = string.IsNullOrEmpty(treeNameOverride) ? "PedBase" : treeNameOverride,
petPerformData = "",
spawnEffectId = new List<uint>(),
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,
};
}
/// <summary>
/// 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.
/// </summary>
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<T>(-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);
}
}