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; /// /// 派对 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(不推移动任务),但「一动不动站着」这一条已经不成立。 /// internal sealed partial class GameRouter { /// 本 session 本次派对邀请到的 NpcCultivationId(InviteMultiNpcChat 写入,StartSingleParty 消费)。 private const string PartyNpcInvitedKey = "party.npc.invited"; /// 本 session 已经放进场里的 NpcCultivationId → entityId,用于重开派对时先撤掉旧的一批。 private const string PartyNpcPlacedKey = "party.npc.placed"; /// /// 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),属"不可落地",故不入表。 /// private static readonly Dictionary 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 }; /// /// /// 撤掉当前场上这一批派对 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` 清空 ⇒ 重复调用是安全的(第二次进不来)。 /// internal static async Task RemovePlacedPartyNpcsAsync(TcpSession session) { if (!session.Items.TryGetValue(PartyNpcPlacedKey, out var raw) || raw is not Dictionary 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; } /// /// 记下「本次派对邀请了谁」。由 InviteMultiNpcChat handler 在选定 NPC 之后调用。 /// 服务端是**自己选**名单的(GameRouter.NpcInvite.cs 的 candidates[cursor]), /// 所以这里记的就是真话,不需要去解客户端那份 List7Bit。 /// private static void RememberPartyInvitedNpc(TcpSession session, uint npcCultivationId) { if (npcCultivationId == 0) return; if (!session.Items.TryGetValue(PartyNpcInvitedKey, out var raw) || raw is not List list) { list = []; session.Items[PartyNpcInvitedKey] = list; } if (!list.Contains(npcCultivationId)) list.Add(npcCultivationId); } /// StartSingleParty 用:取走本次邀请名单并清空(一次派对消费一次)。 private static List TakePartyInvitedNpcs(TcpSession session) { if (session.Items.TryGetValue(PartyNpcInvitedKey, out var raw) && raw is List list) { session.Items[PartyNpcInvitedKey] = new List(); return list; } return []; } /// /// 把名单里的养成 NPC 放到**调用方指定的圆心**周围。返回一行人类可读的日志摘要(调用方直接写日志)。 /// /// 位置策略:**按玩法分两档**(2026-09-26 第 49 轮起)—— /// /// 露营档( = null,默认): /// 以 (x,y,z) 为圆心、半径 = PartyNpcSpawnRadius(默认 2 m),从 /// 起均匀环绕;NPC 一律**面向圆心**。 /// 舞会档( 有值):以 (x,y,z) 为**舞池点**、 /// 沿「舞池正面」的**横排**铺开(与朝向垂直,间距 = 同一个 PartyNpcSpawnRadius), /// 全员**统一朝 **(官方 DanceRotation)—— /// **不是**朝圆心。舞池点的来源见 PartyDanceSpotCatalog4229938。 /// /// /// ★ 第 34 轮:**圆心与基准角由调用方给**,本函数不再自己决定「圆心 = 玩家」。 /// 调用方优先传配表的 `PartySpot` + `PartyRotation`(官方规定场地, /// 见 `ClientData\Client4229938\PartySpotCatalog4229938.cs`), /// 拿不到才回退玩家位置 —— 那只是兜底,不是设计意图。 /// /// ★★ 第 38 轮:新增 = **预制动作**表(按人轮转)。 /// 在此之前这里写死 `new CivilianSpawnItem(agentId, 0)` ⇒ 第 4 步被跳过 /// ⇒ 实测 `poi=0 action=0` ⇒ NPC 只有兜底 idle。空数组 = 保持旧行为(不发 POI)。 /// 动作 id 的语义与来源见 `GameplaySettings.PartyNpcPoi*` 的注释。 /// /// ★★★ 第 49 轮(舞会「走官方路径」):新增 —— /// **舞会摆法**的开关。null(默认)= 露营摆法(圆周 + 朝圆心,一字未改); /// 有值 = 舞会摆法(横排 + 全员统一朝这个角度,值 = 官方 /// PartyDanceSpotConfig.DanceRotation)。 /// ⚠ 两档在同一个循环里**互斥**,不是把露营的圆周参数套到舞会上 —— 这正是用户那句 /// 「舞会有舞会的摆法,不要参考露营」的落点。 /// internal static async Task SpawnPartyNpcsAsync( TcpSession session, IReadOnlyList npcCultivationIds, float x, float y, float z, float baseAngle = 0f, string anchorNote = "玩家锚点", IReadOnlyList? poiActionIds = null, float? unifiedFacingDeg = null, // ★★★ 第 53 轮(派对 NPC 服装):NpcCultivationId → FashionSuitConfig.Id。 // null / 查不到该人 ⇒ SuitId = 0 ⇒ 客户端回落 11190001(= 改动前行为,零差异)。 // 数据来源与优先级见 GameRouter.NpcInvite.cs 的 ResolvePartyNpcSuits。 IReadOnlyDictionary? suitByNpcId = null, // ★ 2026-09-30:就地散布。非 null 且 > 0 ⇒ 以 (x,z) 为圆心随机撒开(角度+距离都随机), // 每人朝圆心。null = 保持原摆法(舞会横排 / 露营圆周),零改动。 float? scatterRadiusMeters = null) { var placed = session.Items.TryGetValue(PartyNpcPlacedKey, out var rawPlaced) && rawPlaced is Dictionary existing ? existing : null; if (placed is null) { placed = new Dictionary(); 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(); var skipped = new List(); 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; } }