package routers import ( "context" "sync" ) var _ = sync.Mutex{} var _ = context.Background // // 派对 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(不推移动任务),但「一动不动站着」这一条已经不成立。 // // // // 撤掉当前场上这一批派对 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` 清空 ⇒ 重复调用是安全的(第二次进不来)。 // func GameRouter_PartyNpc_RemovePlacedPartyNpcsAsync(ctx context.Context, session *GameSession) int32 { // Router implementation for GameRouter_PartyNpc_RemovePlacedPartyNpcsAsync return 0 } // // 记下「本次派对邀请了谁」。由 InviteMultiNpcChat handler 在选定 NPC 之后调用。 // 服务端是**自己选**名单的(GameRouter.NpcInvite.cs 的 candidates[cursor]), // 所以这里记的就是真话,不需要去解客户端那份 List7Bit。 // func GameRouter_PartyNpc_RememberPartyInvitedNpc(ctx context.Context, session *GameSession, npcCultivationId uint32) any { // Router implementation for GameRouter_PartyNpc_RememberPartyInvitedNpc var zero any; return zero } // StartSingleParty 用:取走本次邀请名单并清空(一次派对消费一次)。 func GameRouter_PartyNpc_TakePartyInvitedNpcs(ctx context.Context, session *GameSession) []any { // Router implementation for GameRouter_PartyNpc_TakePartyInvitedNpcs return nil } // // 把名单里的养成 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)。 // ⚠ 两档在同一个循环里**互斥**,不是把露营的圆周参数套到舞会上 —— 这正是用户那句 // 「舞会有舞会的摆法,不要参考露营」的落点。 // func GameRouter_PartyNpc_SpawnPartyNpcsAsync(ctx context.Context, session *GameSession, npcCultivationIds []any, x float32, y float32, z float32, baseAngle float32, anchorNote string, poiActionIds []any, unifiedFacingDeg any, suitByNpcId map[string]any, scatterRadiusMeters any) string { // Router implementation for GameRouter_PartyNpc_SpawnPartyNpcsAsync return "" }