package routers import ( "context" "sync" ) var _ = sync.Mutex{} var _ = context.Background // // NPC 邀请(HUD / 机器 / 玩法面板上的「邀请」按钮)—— 全部「邀 NPC 一起玩」玩法的唯一入口。 // 2026-09-24 第一批:原先只认 91056000(Bowling),现场实测按飞镖机的「邀请」键发的是 // **84002017(Dart)**、跳舞机是 **84002014(Dance808)**,两者都被"只回 Ok 不推送"挡掉 ⇒ // 客户端 NpcChatManager.OnSyncNpcChatLeaveGameplay 收不到推送 ⇒ 机器(C# Spoon 图)卡在等待态 // ⇒ ★ **交互按钮从此不再出现**(用户实测)。 // 2026-09-24 第二批(本文件当次改动):把已验证过交集的 9 个玩法一并接通 —— // 麻将 84002011 · 摩天轮(市区) 84002012 · 露天SPA 84002018 · 派对 91055000 · // 派对引导 91056001(5人) · 蹦迪派对 91056002 · KTV 91056003(3人) · 影院 84002002 · LiveHouse 84002010。 // 与第一批合起来共 12 个 gameplay 走同一份 handler(含保龄球)。 // 全表与交集依据见 gym_tools\多玩法邀请_接通说明.md。 // WHY THIS FILE EXISTS // 客户端 BowlingGameManager.CreateAndStartGameCs 在 mode == GameMode.NPC_BATTLE 时 // (lua\LX6\MiniGame\BowlingGame\BowlingGameManager.lua:314) // * 发 AskSimulationInviteNpc(91056000); // * 把 self.gameInstance.pendingInviteTask 挂起来(同文件 :318-325), // 等着 OnNpcInvited(npcCultivationId)(:355-361)来执行。 // 而 OnNpcInvited 的唯一来源是 **SyncNpcChatLeaveGameplay** 这条推送 // (NpcChatManager.lua:180-223)。旧的私服只做了前者、完全没做后者: // * AskSimulationInviteNpc 落在 DefaultReturnCatalog4229938.cs:414 的兜底 // case 63301526u: body = Array.Empty<byte>(); shape = "void";; // * 服务端**从不主动下发** SyncNpcChatLeaveGameplay(在全部报文日志里 0 命中)。 // 结果:invoke 回包 error=0 body=0b(成功但空)→ 客户端回调只拿到 err=Ok → // OnNpcInvited 永不触发 → 按下按钮后**完全没反应**(不报错、不弹提示、不生成 NPC)。 // 证据(现场实测,非推断) // * C2S:logs\packets-20260924-001712.log:236503 // C2S INVOKE AskSimulationInviteNpc [0x03C5E796] invoke=165283230 body=4b // 载荷 80 67 6D 05 = 小端 u32 = 91056000(= 上述 gameplay) // * S2C:logs\packets-20260924-001712.log:236505 // S2C RETURN AskSimulationInviteNpc [0x03C5E796] error=0 body=0b,之后无任何推送 // * 回包形状:RPCDeserializeAuto.lua:9694 midToReturnMessageReader[63301526] = function (reader) end // ⇒ **回 void 是对的**,缺的只有那条推送(别去改回包) // * 推送形状:同文件 :2268(见 NpcInviteWireContracts.cs 顶部) // 「邀请谁」由服务端决定:客户端请求体里**只有 gameplay 一个 u32**,没有任何候选 NPC 字段。 // 候选 = NpcCultivationGameplayTypeConfig[91056000].InvitableNPC(11 个), // 已逐个核过 NpcCultivationConfig → FightSpiritID → FightSpiritConfig.AgentId 链完整。 // 复现验证(配 NpcInviteTable 的日志 tag): // 1. 走到保龄球机前,选「对战 / NPC」模式; // 2. 按 HUD 上第 3 个按钮(键位 Z,文案「邀请伙伴」); // 3. 服务端应出现 [NPCINVITE] AskSimulationInviteNpc gameplay=91056000 -> pushed ...; // 4. 报文日志应出现 S2C NOTIFY SyncNpcChatLeaveGameplay [0x03D4B6DC] body=8b // (★ 必须是 8b;如果是 9b 且首字节 FF,说明载荷契约漏了 Inline=true,见 NpcInviteWireContracts.cs 顶部); // 5. 画面:NPC 被生成(入场 Timeline)→ 进入保龄球对战。 // // --------------------------------------------------------------------------------------- // AskSimulationInviteNpc (63301526) — args: u32 gameplay ; return: void // // 两件事缺一不可: // (a) 回一个空的 Ok(形状本来就是 void,DefaultReturnCatalog 那条 body 本身没错); // (b) ★ 主动推 SyncNpcChatLeaveGameplay(npcCultivationId, gameplay) —— 这才是让客户端动起来的东西。 // 只做 (a) 就是修复前的「按了没反应」。 // --------------------------------------------------------------------------------------- func GameRouter_NpcInvite_AskSimulationInviteNpc4229938(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_NpcInvite_AskSimulationInviteNpc4229938 var zero any; return zero } // --------------------------------------------------------------------------------------- // AskRestaurantInviteNpc (67057178) — args: u32 restaurantId ; **NOTIFY,没有回包** // // 因为是单向 notify,客户端不会等任何 invoke 回包 —— 唯一的响应手段就是那条推送。 // 修之前服务端只做了「unimplemented notify -> accepted」,等于什么都不做 ⇒ 按了没反应。 // // ⚠ 载荷是 RestaurantConfig.Id,不是 gameplay id。别照 AskSimulationInviteNpc 那样直接用。 // --------------------------------------------------------------------------------------- func GameRouter_NpcInvite_AskRestaurantInviteNpc4229938(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_NpcInvite_AskRestaurantInviteNpc4229938 var zero any; return zero } // --------------------------------------------------------------------------------------- // AskGetNpcRandomWearFashions (63398939) — args: u32 spiritId ; return: list<u32> // // CreateNpcGame_Async 在生成被邀 NPC 之前会拿它抽随机时装(BowlingGameManager.lua:423-434): // local npcFashionList = fashionResult.list // gCS.LuaUtils.CreateClientAgentByCfg(agentTemplateId, pos, eulerAngles, cb, npcFashionList or 0) // // 旧路径落在 DefaultReturnCatalog4229938.cs:483 的 "list7-empty"(体 = FF 01)。 // ⚠ 但按上面的标记规则,FF 01 解析出来是**非空的空表 {}**,而 Lua 里 {} 是**真值** // ⇒ `{} or 0` 得到 {} ⇒ 把一张表当 suitId 传进 C# 绑定,后果不可控。 // 这里显式回 **null 列表**(单字节 00)⇒ 客户端拿到 nil ⇒ `nil or 0` = 0。 // 0 是客户端自己写明的"不套随机时装"分支,最安全,且不影响 NPC 生成。 // --------------------------------------------------------------------------------------- func GameRouter_NpcInvite_AskGetNpcRandomWearFashions4229938(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_NpcInvite_AskGetNpcRandomWearFashions4229938 var zero any; return zero } // --------------------------------------------------------------------------------------- // 派对 NPC 的「预制动作」表选择(2026-09-26 第 38 轮)。 // // 判据来源(都是配表,不是我编的): // · `PartyConfig.GamePlayType`(ConfigDump_v3\PartyConfig.json,7 条全有值) // 13030000 露营=91055000 13030001 凌云=91055000 // 13030002 引导=91056001 13030003 舞会=91056002 // 13030004/13030005 联机=91055000(哨兵落点) // · 动作 id 表 = `GameplaySettings.PartyNpcPoi*`(private-server.json 可调) // // ⚠ 为什么不用 `SpawnPartyNpcsAsync` 的 `anchorNote`/`spotCfg` 推断玩法: // `spotCfg` 只在 `HasUsableSpot` 时才有意义,而联机那两条(13030004/13030005)落点被 // HasUsableSpot 判掉 ⇒ 走玩家锚点分支 ⇒ 若在那边推断就永远拿不到 GamePlayType。 // 所以这里**只看 partyId → PartyConfig.GamePlayType**,与落点走哪条分支解耦。 // --------------------------------------------------------------------------------------- func GameRouter_NpcInvite_PartyNpcPoiActionIdsFor(ctx context.Context, partyId uint32, note string) []any { // Router implementation for GameRouter_NpcInvite_PartyNpcPoiActionIdsFor return nil } func GameRouter_NpcInvite_StartSingleParty4229938(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_NpcInvite_StartSingleParty4229938 var zero any; return zero } // // 算出「本次派对每个受邀 NPC 该穿哪一套」(2026-09-26 第 53 轮)。 // 返回 NpcCultivationId → FashionSuitConfig.Id(查不到的角色**不入表** ⇒ 走旧兜底)。 // // 为什么必须由服务端决定(全部有实证,不是推断): // // 客户端在**露营**这条路上送不出玩家选的服装 —— PartyConfig#13030000.FashionsCanRent // 是空数组(只有舞会 #13030003 配了 [11199901, 11199900]), // ChangeFashionPanelStore_RentMode.lua:555 BuildRentSuitList 的租借列表因此恒空, // rentMap 恒空 ⇒ 「自行搭配」分支产出 fashionsInfo = nil。 // 实测三条 C2S 报文(30 字节,逐字节相同)是 // {Rents={}, UseDefaultFashion=true} ⇒ 玩家点的是「使用默认套装」。 // 两条出路都只能落到「服务端自己按配表选」⇒ 本函数。 // // // // 取值优先级: // // 客户端 Rents(玩家明确选的租赁套装,露营送不出、舞会能送): // Rents[spiritId] = FashionRentConfig.Id ⇒ 再跳 FashionSuitId。 // 该角色的 DressCode 原皮:FashionSpiritDressCodeConfig 里 // SpiritId 相同、TagList 含 PartyConfig.SuitTag(露营 = 1)、 // RecommendPriority 最小的那条。⛔ 放宽规则:18 个角色里只有 15021040 有 // TagList ∋ 2 的条目 ⇒ 舞会(SuitTag = 2)必须能退到「任意 tag 最优」。 // 都查不到 ⇒ 不入表 ⇒ SuitId = 0 ⇒ 客户端回落 11190001(= 改动前行为)。 // // // // 关掉本功能的官方开关:gameplay.partyNpcFashion = "off" // (见 PrivateServerConfig.GameplaySettings.PartyNpcFashion)。 // // func GameRouter_NpcInvite_ResolvePartyNpcSuits(ctx context.Context, partyId uint32, npcCultivationIds []any, fashions any) any { // Router implementation for GameRouter_NpcInvite_ResolvePartyNpcSuits var zero any; return zero } // --------------------------------------------------------------------------------------- // PartyOver (63396665) — **无参数**(客户端 writer 是空体);return: void // // ★★ 第 38 轮:派对「结束演出」的最后一块拼图。 // // 为什么以前「结束派对」什么都不发生: // PartyHudPanelStore.lua:970-992 OnClickExitBtn(确认框 MessageConfig.StopParty 之后) // gClientToGameDelegate:PartyOver().Callback = function (errorId) // if errorId ~= LTConfig.MessageConfig.Ok then ... return end ← 反编译 ==/JMPF 取反 // gPanelManager:Close(self.m_Id) -- 只关 HUD // end // 服务端侧只有 `DefaultReturnCatalog4229938.cs:481` 的**空 Ok 兜底**(未登记白名单、 // 没有 handler)⇒ 客户端关掉 HUD 就完了,**演出与结束面板永远不出现**。 // // 真正的触发者是**服务端推 SyncPartySettleData(68990488)**: // Service\GameToClientImpl.lua:2341 SyncPartySettleData → gPartyManager:OnSyncSettleData // Manager\GameSystem\PartyManager.lua:746 OnSyncSettleData // · 单机(CheckIsLinkMode()=false,PartyManager.lua:781-792): // timelineData.loadFromLocal = true // gTimelineManager:Timeline_LoadAndPlay("play_party_end", timelineData) // onPlayCallback = CheckShow(PARTY_END_PANEL, settleData) // onLoadFailedCallback = 同上 ⇒ 本地没这段 timeline 也不会卡住 // ⇒ **演出是本地资源**,settleData 只负责喂结束面板 + 起播。 // · 联机才用 settleData.OnlineSettleResult 的四个 pid 组 linkPidList 播 ol_play_party_end。 // // ★ 参数:**没有**。`LuaGen\AutoGen\ClientToGameDelegate.lua` 里 // SerializerHelper.PartyOver_Serializer = function(writer) end ← 空体 // ⇒ 千万不要按「第一个 u32 是 partyId」去解 body(body 长度 0)。 // partyId 从 session 取(StartSingleParty4229938 写进 PartySingleIdSessionKey)。 // // ★ 顺序:**先推结算再回 Ok**。回 Ok 的 Callback 只做 Close(PARTY_HUD_PANEL), // 而 OnSyncSettleData 的 PreparePartyEnd 也会关它 —— 先推能让「演出起播」 // 发生在 HUD 关闭的同一帧之前,不会出现「面板关了但演出还没起」的空档。 // // ★ 推送形状(100% 来自客户端自己的 writer,不是猜 —— 铁律 5): // RPCSerializeAuto.lua Auto.WritePartySettleData + Auto.WritePartyOnlineSettleResult // + Auto.WritePartyPlayerSettleData // RPCDeserializeAuto.lua `midToReader[68990488] = Base.ReadComplex(reader, Auto.Reader[541])` // ⇒ 外层是 **Complex(带 0xFF)**;`OnlineSettleResult` 走 Base.WriteComplex(0xFF); // `SettleList` 的每一项走 WriteComplexWrap(也是 0xFF,与 reader 的 ReadComplex 对称)。 // 三个 list 都是 WriteList7Bit ⇒ `[UxCollection]` 用默认的 Count=Int7 / Marker=0xFF 即可。 // --------------------------------------------------------------------------------------- func GameRouter_NpcInvite_PartyOver4229938(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_NpcInvite_PartyOver4229938 var zero any; return zero } func GameRouter_NpcInvite_InviteMultiNpcChat4229938(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_NpcInvite_InviteMultiNpcChat4229938 var zero any; return zero }