Files

420 lines
26 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 Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 派对 NPC 落地 —— 把「被邀请的养成 NPC」真正放进场景(2026-09-26 第 30 轮)。
///
/// WHY THIS FILE EXISTS
/// --------------------
/// 用户实测「可以选 NPC 了,但邀请的 NPC 看不见」。逐层查证后定位到:**派对分支根本不生成实体**。
///
/// 客户端 NpcChatManager.lua:180-223(收到 SyncNpcChatLeaveGameplay 后的分发):
/// · 餐厅 → gRestaurantManager:InviteClientNpc(npcId, gameplay)
/// → RestaurantManager.lua:789
/// gNpcInviteManager:InviteClientNpcForGamePlayWithPos(
/// cfg.RestaurantNpcid, gameplay, self, self.invitePos, ...) ← ★ 生成实体
/// · 蹦迪 → BengdiActionManager.lua:380 同上(用 cfg.BengdiNpcid) ← ★ 生成实体
/// · 派对 → gPartyManager:StartSingleParty(singlePartyId, multiNpcTidList)
/// → 只写 partyInviteNpcIdList + CheckShow(PARTY_HUD_PANEL) ← ✗ 没有实体
/// 全量 grep(_lua_allgrep.py,含 212 个含 NUL 的文件)确认 InviteClientNpc* 的调用者
/// 只有 RestaurantManager 与 BengdiActionManager 两处 —— **派对一次都没有**。
///
/// 佐证:PartyNpcInvitePanelStore.lua:113-135(派对专用 NPC 面板)是**扫场景实体**挑人的:
/// gCS.SceneDataMgr.UnitsManager:LuaGetAllUnits(units)
/// for _, unit in ipairs(units) do
/// local npcId = self.GetNpcCultivationId(self, unit) -- :180
/// ⇒ 场景里没有这个 unit,面板就是空的、场景里也看不见人。
/// GetNpcCultivationId 的判据(:180-188):
/// local spiritCfg = LTConfig.FightSpiritConfig.GetConfig(unit.ClientData.cardId)
/// return spiritCfg and spiritCfg.NpcCultivationRelatedId or 0
/// ⇒ 反查用的是 `unit.ClientData.cardId`,而 cardId 属于
/// **FightSpiritConfig 的 Id 空间(15xxxxxx)**。
/// 🛑 **第 45 轮更正**:本节初稿写的「取值链 = NpcCultivationConfig.FightSpiritID →
/// FightSpiritConfig.AgentId,cardId 由 AgentConfig 推出」**是错的**。证据:
/// ① NpcCatalog[40140035](Taffy 的 AgentConfig 行)里**没有**
/// cardId/FightSpiritId 字段,只有 GeneralModelId/AgentPersonaId/
/// BehaviorActionConfigId/AiSettingId —— 推不出 cardId;
/// ② CoreHudUIManager_Decisions.lua:557 把 `ClientData.cardId` 直接和
/// `LTConfig.FightSpiritConfig.Taffy`(=15020992) 比较。
/// ⇒ cardId 只能来自 **AIAgentInfo.FightSpiritId**(下方 dress 参数)。
/// AgentId 那张表仍然要(它管 NpcFormworkId=实体模板),
/// 但**光有它不够** —— 缺了 FightSpiritId,客户端就不把它当成可玩角色。
///
/// 服务端本来就有「把 NPC 放进场景」的通道(不是新增机制,是复用已验证的):
/// GameRouter.Civilians.cs SendCivilianAsync 的**四步序列**
/// (SyncAetherAIStaticNpcAddData → SyncManagedLogicAgent → 可选 belongings → 可选 POI),
/// GameRouter.FunctionalNpc.cs 的 PlaceFunctionalNpcAsync 就用它铺城市里的店员/售票员。
/// 本文件只做一件事:把**派对邀请名单**喂给同一个通道,位置放在玩家身边。
/// ⇒ 不发明新的 spawn 路径(对照 FunctionalNpc.cs 开头那句 "this file does not invent a spawn path")。
///
/// ⚠ 已知边界(诚实标注,未验证的部分不要当结论)
/// 1. 「能不能被 PartyNpcInvitePanelStore 认成养成 NPC」取决于客户端 unit.ClientData.cardId
/// 是否由 AgentConfig 推出 15020992(Taffy)/ 对应 FightSpiritID。本实现只保证
/// **实体生成且可见**;识别问题要等实测反馈。
/// 2. 落点用「玩家身边环绕一圈、半径 2.5m」,理由见铁律 17(落点必须实测站得住)——
/// 2.5m 是保守值,小于这个容易和玩家胶囊互顶。若发现半空抽搐/被顶飞,先把半径调大。
/// 3. ~~不做驻留 AI:POI Action 传 0(无闲置动作),NPC 会站着。~~ **★ 第 38 轮已接上**
/// —— 改为由 `GameplaySettings.PartyNpcPoi*` 提供「预制动作」表,按人轮转传给
/// `CivilianSpawnItem.PoiActionId`(露营 = 地面野餐/围坐,舞会 = 鼓掌/围观)。
/// 仍然**不做**驻留巡逻 AI(不推移动任务),但「一动不动站着」这一条已经不成立。
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>本 session 本次派对邀请到的 NpcCultivationId(InviteMultiNpcChat 写入,StartSingleParty 消费)。</summary>
private const string PartyNpcInvitedKey = "party.npc.invited";
/// <summary>本 session 已经放进场里的 NpcCultivationId → entityId,用于重开派对时先撤掉旧的一批。</summary>
private const string PartyNpcPlacedKey = "party.npc.placed";
/// <summary>
/// NpcCultivationId → AgentConfig(NpcCatalog 的 Id,即实体模板)id。
///
/// 取值链(两张表 join,24/24 命中,非猜):
/// ConfigDump_v3\NpcCultivationConfig.json :: FightSpiritID
/// → ConfigDump_v3\FightSpiritConfig.json :: AgentId
/// 例:91050014(Taffy) → FightSpiritID 15020992 → FightSpiritConfig.AgentId 40140035
/// 而 AgentConfig[40140035].Description = "唯一塔菲"、Name = "Taffy" —— 自洽。
/// 24 个目标 agentId 全部存在于服务端 ClientData\4229938\Configs\NpcCatalog.json(已验证,0 缺失)。
///
/// ⚠ 这是配表数据的内联快照(与 PartyDurationByPartyId 同一种做法)。若客户端换版本、
/// 或这 24 条之外的 NPC 也要能邀,请从上面两张表重新生成整段。
/// 末尾三名(91059xxx)在 FightSpiritConfig 里没有 AgentId(值为 0),属"不可落地",故不入表。
/// </summary>
private static readonly Dictionary<uint, uint> PartyNpcAgentByCultivationId = new()
{
[91050013] = 40140033, // Rin
[91050014] = 40140035, // Taffy(唯一塔菲)
[91050015] = 40140032, // Garm
[91050016] = 40140034, // Bansy
[91050018] = 40140039, // Tina
[91050019] = 40140038, // Mechanika
[91050020] = 40140040, // Richie
[91050021] = 40140037, // Alan
[91050022] = 40140044, // Lykaia
[91050023] = 40140041, // Ringo
[91050024] = 40140036, // Dila
[91050025] = 40140030, // Chenxiu
[91050026] = 40140031, // Zhaozhao
[91050027] = 40140043, // Aileen
[91050028] = 40140053, // Anonym
[91050029] = 40140049, // Bernard
[91050030] = 40140048, // Mimiko
[91050031] = 40140056, // Shi Ye
[91050032] = 40140042, // Seymour
[91050033] = 40140054, // Bai Jing
[91050034] = 40140055, // Yinglong
[91050035] = 40140077, // You Zhi
[91050036] = 40155013, // Li Fufeng
[91050037] = 40155001, // Cheng Niang
};
/// <summary>
/// <summary>
/// 撤掉当前场上这一批派对 NPC,返回撤掉的人数。
///
/// ★ 第 46 轮新增:抽出来给两处复用 —— ①重开派对(`SpawnPartyNpcsAsync` 开头);
/// ②**派对结束**(`PartyOver4229938`)。在此之前只有①有撤人逻辑,派对结束后
/// 那批 NPC 会**永久留在原地**(用户反馈「派对结束后角色一直不走」)。
///
/// ⛑⛑ **双包并用**(2026-09-26 现场实测后修正):
/// · `SyncAetherAINpcRemove`(68332284) 的契约注释写着
/// 「`pid` = 当初 **`ClientCrowdInitData.Id`**」⇒ 它是 **crowd 通道**的撤人包。
/// 派对 NPC 走的是 **static 通道**(`SyncAetherAIStaticNpcAddData`)
/// ⇒ 单独发它**撤不掉**(packets 日志 5 条 body=8b 都在、客户端毫无反应)。
/// · 真正「**销毁一个单位**」的是 `SyncLogicAgentLeave`(68281244),它是
/// `SyncLogicAgentEnter`(68719890) 的另一半(注册 / 注销进 logic-agent 管理器)。
/// 依据:`GameRouter.Switching.cs` 的**反向**用法已实测 —— 切人留场时**不发**它,
/// 单位就留在大世界里;发了它单位才消失(迁移期「Leave 之后模型才重建」那条记录)。
/// ⇒ 两个都发:Leave 负责 static 通道,Remove 兜住 crowd 通道,互不冲突。
/// (⚠ `GameRouter.EnemyTerritory.cs:313` 那句「Enter(68281243)」是**错注释** ——
/// 权威 id 表 `MethodId.dump.cs` 里没有 68281243。)
///
/// ⛑ 撤完就把 `placed` 清空 ⇒ 重复调用是安全的(第二次进不来)。
/// </summary>
internal static async Task<int> RemovePlacedPartyNpcsAsync(TcpSession session)
{
if (!session.Items.TryGetValue(PartyNpcPlacedKey, out var raw) || raw is not Dictionary<uint, ulong> placed
|| placed.Count == 0)
return 0;
var ids = placed.Values.ToList();
placed.Clear();
foreach (var id in ids)
{
// ① static 通道:注销 logic-agent ⇒ 客户端销毁这个单位。
await session.NotifyAsync(MethodId.SyncLogicAgentLeave,
UxSerializer.Serialize(WorldCodec.LogicAgentLeave(id)), CancellationToken.None);
// ② crowd 通道:Aether 撤人(对 static 单位可能是 no-op,但不冲突)。
await session.NotifyAsync(MethodId.SyncAetherAINpcRemove,
UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = id }),
CancellationToken.None);
}
return ids.Count;
}
/// <summary>
/// 记下「本次派对邀请了谁」。由 InviteMultiNpcChat handler 在选定 NPC 之后调用。
/// 服务端是**自己选**名单的(GameRouter.NpcInvite.cs 的 candidates[cursor]),
/// 所以这里记的就是真话,不需要去解客户端那份 List7Bit。
/// </summary>
private static void RememberPartyInvitedNpc(TcpSession session, uint npcCultivationId)
{
if (npcCultivationId == 0)
return;
if (!session.Items.TryGetValue(PartyNpcInvitedKey, out var raw) || raw is not List<uint> list)
{
list = [];
session.Items[PartyNpcInvitedKey] = list;
}
if (!list.Contains(npcCultivationId))
list.Add(npcCultivationId);
}
/// <summary>StartSingleParty 用:取走本次邀请名单并清空(一次派对消费一次)。</summary>
private static List<uint> TakePartyInvitedNpcs(TcpSession session)
{
if (session.Items.TryGetValue(PartyNpcInvitedKey, out var raw) && raw is List<uint> list)
{
session.Items[PartyNpcInvitedKey] = new List<uint>();
return list;
}
return [];
}
/// <summary>
/// 把名单里的养成 NPC 放到**调用方指定的圆心**周围。返回一行人类可读的日志摘要(调用方直接写日志)。
///
/// 位置策略:**按玩法分两档**(2026-09-26 第 49 轮起)——
/// <list type="bullet">
/// <item><b>露营档</b>(<paramref name="unifiedFacingDeg"/> = <c>null</c>,默认):
/// 以 (x,y,z) 为圆心、半径 = <c>PartyNpcSpawnRadius</c>(默认 2 m),从
/// <paramref name="baseAngle"/> 起均匀环绕;NPC 一律**面向圆心**。</item>
/// <item><b>舞会档</b>(<paramref name="unifiedFacingDeg"/> 有值):以 (x,y,z) 为**舞池点**、
/// 沿「舞池正面」的**横排**铺开(与朝向垂直,间距 = 同一个 <c>PartyNpcSpawnRadius</c>),
/// 全员**统一朝 <paramref name="unifiedFacingDeg"/>**(官方 <c>DanceRotation</c>)——
/// **不是**朝圆心。舞池点的来源见 <c>PartyDanceSpotCatalog4229938</c>。</item>
/// </list>
///
/// ★ 第 34 轮:**圆心与基准角由调用方给**,本函数不再自己决定「圆心 = 玩家」。
/// 调用方优先传配表的 `PartySpot` + `PartyRotation`(官方规定场地,
/// 见 `ClientData\Client4229938\PartySpotCatalog4229938.cs`),
/// 拿不到才回退玩家位置 —— 那只是兜底,不是设计意图。
///
/// ★★ 第 38 轮:新增 <paramref name="poiActionIds"/> = **预制动作**表(按人轮转)。
/// 在此之前这里写死 `new CivilianSpawnItem(agentId, 0)` ⇒ 第 4 步被跳过
/// ⇒ 实测 `poi=0 action=0` ⇒ NPC 只有兜底 idle。空数组 = 保持旧行为(不发 POI)。
/// 动作 id 的语义与来源见 `GameplaySettings.PartyNpcPoi*` 的注释。
///
/// ★★★ 第 49 轮(舞会「走官方路径」):新增 <paramref name="unifiedFacingDeg"/> ——
/// **舞会摆法**的开关。<c>null</c>(默认)= 露营摆法(圆周 + 朝圆心,一字未改);
/// 有值 = 舞会摆法(横排 + 全员统一朝这个角度,值 = 官方
/// <c>PartyDanceSpotConfig.DanceRotation</c>)。
/// ⚠ 两档在同一个循环里**互斥**,不是把露营的圆周参数套到舞会上 —— 这正是用户那句
/// 「舞会有舞会的摆法,不要参考露营」的落点。
/// </summary>
internal static async Task<string> SpawnPartyNpcsAsync(
TcpSession session, IReadOnlyList<uint> npcCultivationIds, float x, float y, float z,
float baseAngle = 0f, string anchorNote = "玩家锚点",
IReadOnlyList<uint>? poiActionIds = null,
float? unifiedFacingDeg = null,
// ★★★ 第 53 轮(派对 NPC 服装):NpcCultivationId → FashionSuitConfig.Id。
// null / 查不到该人 ⇒ SuitId = 0 ⇒ 客户端回落 11190001(= 改动前行为,零差异)。
// 数据来源与优先级见 GameRouter.NpcInvite.cs 的 ResolvePartyNpcSuits。
IReadOnlyDictionary<uint, uint>? suitByNpcId = null,
// ★ 2026-09-30:就地散布。非 null 且 &gt; 0 ⇒ 以 (x,z) 为圆心随机撒开(角度+距离都随机),
// 每人朝圆心。null = 保持原摆法(舞会横排 / 露营圆周),零改动。
float? scatterRadiusMeters = null)
{
var placed = session.Items.TryGetValue(PartyNpcPlacedKey, out var rawPlaced)
&& rawPlaced is Dictionary<uint, ulong> existing
? existing
: null;
if (placed is null)
{
placed = new Dictionary<uint, ulong>();
session.Items[PartyNpcPlacedKey] = placed;
}
// 重开派对:先把上一批撤掉,否则会原地叠人。
if (placed.Count > 0)
{
await RemovePlacedPartyNpcsAsync(session);
session.Log.Info($"[PARTYNPC] 撤掉上一批 {placed.Count} 人(同一 session 重开派对)");
}
// ★ 第 46 轮:上一批人的驻留行为循环也要停掉,否则它会继续给已经删掉的 id 派任务。
ResetPartyNpcRoam(session);
if (npcCultivationIds.Count == 0)
return "[PARTYNPC] 邀请名单为空 ⇒ 不生成(先走「NPC 群聊邀请面板」勾人再提交)";
// crowd 通道在收到 Aether init 之前不会把 NPC 实体化 —— 与 Civilians/FunctionalNpc 同一前置。
await SendDonorAetherInitAsync(session);
// ★ 第 47 轮:半径改为可配。默认 **2.0 m**(原硬编码 2.5;用户现场定为 2)。
var radius = MathF.Max(0.3f, PrivateServerConfigStore.Current.Gameplay.PartyNpcSpawnRadius);
var n = npcCultivationIds.Count;
var done = new List<string>();
var skipped = new List<string>();
for (var i = 0; i < n; i++)
{
var npcId = npcCultivationIds[i];
if (!PartyNpcAgentByCultivationId.TryGetValue(npcId, out var agentId) || agentId == 0)
{
skipped.Add($"{npcId}(无实体模板)");
continue;
}
// 环绕圆心一圈;单人时放在 baseAngle 方向,多人时从 baseAngle 起按角度均分。
// ★ 第 34 轮加 baseAngle 偏移:官方配表的 `PartyRotation` 就是「派对场地朝向」,
// 拿它当基准角,NPC 的排布才和场地朝向对得上(而不是恒从正前方开始)。
var angle = baseAngle + (n == 1
? 0f
: (2f * MathF.PI * i) / n);
// ★★★ 2026-09-30 就地散布:角度随机 + 距离随机(都只在散布模式下改)。
// 与原「圆周均分」的唯一差别就是这两个量不再均分;
// 朝向仍是 `angle + π`(朝圆心 = 朝玩家),所以散布的人全都面朝你。
// ⛑ 2026-09-30 修编译错误 CS0165:原来写成
// `var scatter = scatterRadiusMeters is { } sr && sr > 0f;`
// 然后在 if(scatter) 里用 sr —— C# 的确定赋值分析**不跟踪**布尔变量与
// 模式匹配变量之间的关系,编译器认为 sr 可能未赋值。
// ⇒ 改成先 `?? 0f` 落成一个确定有值的局部变量,语义逐字等价。
var scatterRadius = scatterRadiusMeters ?? 0f;
var scatter = scatterRadius > 0f;
var dist = radius;
if (scatter)
{
angle = (float)(Random.Shared.NextDouble() * 2.0 * Math.PI);
// 距离 ∈ [0.45R, 1.0R]:不至于全叠在圆心,也不会贴脸。
dist = scatterRadius * (0.45f + 0.55f * (float)Random.Shared.NextDouble());
}
// ★★★ 第 49 轮:**舞会有舞会的摆法**。
// 官方 `PartyDanceSpotConfig.DanceRotation` 是**舞池朝向** ⇒ 舞池里所有人
// **面向同一个方向**(朝舞台/DJ),而不是各自朝圆心。所以舞会档:
// · 朝向单位向量 f = (sin θ, cos θ) (与 Civilians.cs 同一约定:
// `pos = ref + (sinθ, cosθ)·d` 且 `facing = θ_度`)
// · 横排方向 s = f 绕 y 轴转 90° = (cos θ, −sin θ) ← 与朝向**垂直**
// · 第 i 人的偏移 = (i − (n−1)/2) · radius ← 以舞池点为中心左右摊开
// ⇒ 露营档(unifiedFacingDeg = null)走原来的圆周,**零改动**。
// ⚠ 舞会档里 `radius` 的含义变成「**横排间距**」(同一个配置键,语义随玩法变)。
float px, pz;
if (unifiedFacingDeg is not null)
{
var frad = unifiedFacingDeg.Value * (MathF.PI / 180f);
var offset = (i - (n - 1) * 0.5f) * radius;
px = x + MathF.Cos(frad) * offset;
pz = z - MathF.Sin(frad) * offset;
}
else
{
px = x + MathF.Sin(angle) * dist;
pz = z + MathF.Cos(angle) * dist;
}
// 实体 id 与市民/功能 NPC 共用同一个计数器 —— 另起一套会从同一个基数重启,
// 发出已被占用的 id(Civilians.cs 里对这件事有专门的踩坑注释)。
var entityId = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
// ★★ 第 38 轮:预制动作按人轮转(第 i 个人拿第 i 个 id)。
// `poi=0` ⇒ SendCivilianAsync 的第 4 步整段跳过 ⇒ 客户端只有兜底 idle。
// ⚠ 这里**不做** id 白名单校验 —— SendCivilianAsync 内部(Civilians.cs:87/432)
// 已经会 `DonorPoiActionCatalog4229938.TryGet` 并在查不到时静默降级为「无 POI」,
// 重复校验只会多一处会写错的地方。
var poiActionId = poiActionIds is { Count: > 0 }
? poiActionIds[i % poiActionIds.Count]
: 0u;
// ★★★ 第 45 轮:必须把这个 NPC 标成「某个可玩角色」,
// 否则客户端根本认不出它是谁。
// 判据链(客户端 PartyNpcInvitePanelStore.lua:180-188):
// spiritCfg = LTConfig.FightSpiritConfig.GetConfig(unit.ClientData.cardId)
// return spiritCfg and spiritCfg.NpcCultivationRelatedId or 0
// 而 `ClientData.cardId` 的唯一来源 = **本通道的**
// AIAgentInfo.FightSpiritId(wire 顺序见 Civilians.cs:30-36:
// Uid → FightSpiritId → Fashion → Nickname → NameId → AvatarImageId)。
//
// ★ 一直传 `dress: null` ⇒ `AIAgentInfo` 是**空对象** ⇒ 没有 FightSpiritId
// ⇒ cardId=0 ⇒ 反查恒 0 ⇒ PartyNpcInvitePanelStore 扫完整个场景
// **一个人都挑不出来** ⇒ 「烤肉邀请」面板恒空。
// 这就是第 45 轮现场反馈的正解。
//
// ⚠ `Uid` 取 `Profile.PlayerPid`:与 GameRouter.Switching.cs:684(切人留场)
// 同一取法 —— 那里已经走通了同一条型号的 dress。
// ⚠ `SuitId=0` / `FashionIds=[]` ⇒ DonorNpc.cs 的
// `FashionSuitId = dress is { SuitId: not 0 } ? dress.SuitId : 11190001`
// 落回 11190001 —— 与传 null 时**同值**,不会改衣服。
var spiritId = NpcCultivationCatalog4229938.Cultivations
.FirstOrDefault(e => e.Id == npcId)?.FightSpiritId ?? 0u;
// ★★★ 第 47 轮修正:**朝向必须换算成「度」** —— 这就是「5 个人朝同一个方向」的根因。
// 既有约定三处一致(`GameRouter.Civilians.cs:107-110` / `GameRouter.DonorNpc.cs:84-87` /
// `GameRouter.Ambient.cs:769`,且路人线是实机跑通的):
// · `SendCivilianAsync` 的 `facing` 形参 = **角度制(度)**
// · 摆位用 `yaw = facing * PI / 180`(度→弧度)再做 sin/cos
// · 站在「参考点 + (sin θ, cos θ)·d」上的 NPC ⇒ `facing = 归一化(θ_度 + 180)`
// 之前这里传的是 `angle + MathF.PI`(**弧度**,0~6.28)⇒ 被当成「度」,
// 等于**全部朝 ≈0°(正北)** ⇒ 现场就是「所有人朝同一个方向」。
// 本意(朝圆心)本来就是对的,错的只有单位。
// ★★★ 第 49 轮:舞会档 = 全员统一朝官方 `DanceRotation`(**不朝圆心**);
// 露营档 = 原来的「朝圆心」(`angle + π`),一行未改。
var facingDeg = unifiedFacingDeg is { } danceFacingDeg
? MathF.IEEERemainder(danceFacingDeg, 360f)
: MathF.IEEERemainder((angle + MathF.PI) * (180f / MathF.PI), 360f);
// ★★★ 第 53 轮(派对 NPC 服装):把算好的套装喂进 dress。
// 传 0 = 不指定 ⇒ DonorNpc.cs:248 的
// `FashionSuitId = dress is { SuitId: not 0 } ? dress.SuitId : 11190001`
// 落回 11190001 —— 这正是用户报的「显示的还是默认服装」。
// 之前这里硬编码 0,等于**所有角色都穿同一套 Bansy 涂鸦装**。
var suitId = suitByNpcId is not null && suitByNpcId.TryGetValue(npcId, out var resolvedSuit)
? resolvedSuit
: 0u;
await SendCivilianAsync(session, new CivilianSpawnItem(agentId, poiActionId), entityId, px, y, pz, facingDeg,
dress: spiritId == 0
? null
: new CivilianLinkDress(Profile.PlayerPid, spiritId, suitId, []),
skipHackPhone: PrivateServerConfigStore.Current.Gameplay.PartyNpcPoiSkipHackPhone && poiActionId != 0,
aiSettingOverride: PrivateServerConfigStore.Current.Gameplay.PartyNpcAISettingOverride,
treeNameOverride: PrivateServerConfigStore.Current.Gameplay.PartyNpcTreeNameOverride);
// ★★★ 第 46 轮(用户诉求「不互动也要让角色自由行动」):补上让客户端接管 locomotion 的
// 三步 —— SyncLogicAgentEnter(任务从它派发)+ SyncAgentForceGo(启动行为树)
// + SyncMoveWandering(「让已投放 NPC 自己走起来的唯一官方包」)。
// 在此之前 SendCivilianAsync 只发到「存一个 POI 动作」为止 ⇒ 角色做完那套动作就站桩。
await ArmPartyNpcRoamAsync(session, entityId, px, y, pz, CancellationToken.None);
placed[npcId] = entityId;
done.Add($"{npcId}->{agentId}@({px:F1},{y:F1},{pz:F1}) facing={facingDeg:F1}deg #e{entityId} poi={poiActionId} spirit={spiritId} suit={suitId}");
}
var summary = $"[PARTYNPC] 生成 {done.Count}/{n} 人(" +
(unifiedFacingDeg is { } uf49
? $"★舞会摆法:舞池点={anchorNote},横排间距 {radius}m," +
$"全员统一朝向 {MathF.IEEERemainder(uf49, 360f):F1}°(= 官方 DanceRotation,不朝圆心)"
: $"露营摆法:圆心={anchorNote},半径 {radius}m,全员朝圆心") + ")" +
(poiActionIds is { Count: > 0 }
? $",预制动作={string.Join('/', poiActionIds)}"
: ",预制动作=无(poi=0,只有兜底 idle)") +
$",驻留行为={PrivateServerConfigStore.Current.Gameplay.PartyNpcRoamMode}" +
(done.Count > 0 ? ":" + string.Join(",", done) : "") +
(skipped.Count > 0 ? $";跳过 {string.Join(",", skipped)}" : "");
session.Log.Info(summary);
return summary;
}
}