1460 lines
90 KiB
C#
1460 lines
90 KiB
C#
using Ananta.SDK.Rpc;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using Ananta.Server.RpcTypes.Client4229938;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// 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
|
||
/// 客户端 <c>BowlingGameManager.CreateAndStartGameCs</c> 在 <c>mode == GameMode.NPC_BATTLE</c> 时
|
||
/// (lua\LX6\MiniGame\BowlingGame\BowlingGameManager.lua:314)
|
||
/// * 发 <c>AskSimulationInviteNpc(91056000)</c>;
|
||
/// * 把 <c>self.gameInstance.pendingInviteTask</c> 挂起来(同文件 :318-325),
|
||
/// 等着 <c>OnNpcInvited(npcCultivationId)</c>(:355-361)来执行。
|
||
/// 而 <c>OnNpcInvited</c> 的唯一来源是 **SyncNpcChatLeaveGameplay** 这条推送
|
||
/// (NpcChatManager.lua:180-223)。旧的私服只做了前者、完全没做后者:
|
||
/// * <c>AskSimulationInviteNpc</c> 落在 DefaultReturnCatalog4229938.cs:414 的兜底
|
||
/// <c>case 63301526u: body = Array.Empty<byte>(); shape = "void";</c>;
|
||
/// * 服务端**从不主动下发** SyncNpcChatLeaveGameplay(在全部报文日志里 0 命中)。
|
||
/// 结果:invoke 回包 <c>error=0 body=0b</c>(成功但空)→ 客户端回调只拿到 err=Ok →
|
||
/// <c>OnNpcInvited</c> 永不触发 → 按下按钮后**完全没反应**(不报错、不弹提示、不生成 NPC)。
|
||
///
|
||
/// 证据(现场实测,非推断)
|
||
/// * C2S:logs\packets-20260924-001712.log:236503
|
||
/// <c>C2S INVOKE AskSimulationInviteNpc [0x03C5E796] invoke=165283230 body=4b</c>
|
||
/// 载荷 <c>80 67 6D 05</c> = 小端 u32 = <b>91056000</b>(= 上述 gameplay)
|
||
/// * S2C:logs\packets-20260924-001712.log:236505
|
||
/// <c>S2C RETURN AskSimulationInviteNpc [0x03C5E796] error=0 body=0b</c>,之后无任何推送
|
||
/// * 回包形状:RPCDeserializeAuto.lua:9694 <c>midToReturnMessageReader[63301526] = function (reader) end</c>
|
||
/// ⇒ **回 void 是对的**,缺的只有那条推送(别去改回包)
|
||
/// * 推送形状:同文件 :2268(见 NpcInviteWireContracts.cs 顶部)
|
||
///
|
||
/// 「邀请谁」由服务端决定:客户端请求体里**只有 gameplay 一个 u32**,没有任何候选 NPC 字段。
|
||
/// 候选 = NpcCultivationGameplayTypeConfig[91056000].InvitableNPC(11 个),
|
||
/// 已逐个核过 NpcCultivationConfig → FightSpiritID → FightSpiritConfig.AgentId 链完整。
|
||
///
|
||
/// 复现验证(配 NpcInviteTable 的日志 tag):
|
||
/// 1. 走到保龄球机前,选「对战 / NPC」模式;
|
||
/// 2. 按 HUD 上第 3 个按钮(键位 Z,文案「邀请伙伴」);
|
||
/// 3. 服务端应出现 <c>[NPCINVITE] AskSimulationInviteNpc gameplay=91056000 -> pushed ...</c>;
|
||
/// 4. 报文日志应出现 <c>S2C NOTIFY SyncNpcChatLeaveGameplay [0x03D4B6DC] body=8b</c>
|
||
/// (★ 必须是 8b;如果是 9b 且首字节 FF,说明载荷契约漏了 Inline=true,见 NpcInviteWireContracts.cs 顶部);
|
||
/// 5. 画面:NPC 被生成(入场 Timeline)→ 进入保龄球对战。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
/// <summary>保龄球(BowlingCfg)在 NpcCultivationGameplayTypeConfig 里的 Id。</summary>
|
||
private const uint NpcCultivationGameplayTypeBowling = 91056000;
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// 2026-09-24 追加:飞镖 / 跳舞机。
|
||
//
|
||
// 铁证(现场报文 + 服务端日志,非推断):
|
||
// logs\packets-20260924-192915.log:89966
|
||
// 19:33:34.054 C2S INVOKE AskSimulationInviteNpc body=4b E1 C4 01 05 (= 0x0501C4E1 = 84002017)
|
||
// 19:33:34.057 S2C RETURN AskSimulationInviteNpc error=0 body=0b ← 回 Ok,但没有推送
|
||
// logs\console-latest.log:22826
|
||
// [NPCINVITE] gameplay=84002017 不在本处理器覆盖范围(仅 91056000=Bowling)-> 只回 Ok
|
||
// ⇒ 客户端 NpcChatManager.OnSyncNpcChatLeaveGameplay 永远收不到 → DO_INVITE_NPC_DART_GAME 不触发
|
||
// → 机器(C# 的 Spoon 图)卡在「等待邀请结果」态 ⇒ ★ 交互按钮再也不出现(用户实测症状)。
|
||
//
|
||
// gameplay 号来源:D:\Ananta\ConfigDump\ConfigDump_v3\Static\NpcCultivationGameplayTypeConfig.static.json
|
||
// DartCfg = 84002017 Dance808Cfg = 84002014 BowlingCfg = 91056000(与既有常量吻合)
|
||
//
|
||
// ⚠ 为什么「只回 Ok」比「回错误」更糟:DefaultUnknownInvoke 的中性兜底让 invoke 看起来成功,
|
||
// 但真正驱动客户端的是那条推送。缺推送 = 静默卡死,且**不报错** ⇒ 诊断不能靠「有没有报错」。
|
||
//
|
||
// ⚠ 还发过 84002014(Dance808,19:34:05/07/16、19:40:14 共 4 次),同样被挡在门外。
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
/// <summary>飞镖(DartCfg.Id)。</summary>
|
||
private const uint NpcCultivationGameplayTypeDart = 84002017;
|
||
|
||
/// <summary>跳舞机 / 蹦迪(Dance808Cfg.Id)。</summary>
|
||
private const uint NpcCultivationGameplayTypeDance808 = 84002014;
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// 2026-09-24 第二批:麻将 / 摩天轮 / 露天SPA / 派对 / KTV / 影院 / LiveHouse。
|
||
//
|
||
// 号源(两张独立表,值集合逐条一致 ⇒ 互为交叉验证):
|
||
// 1) D:\Ananta\ConfigDump\ConfigDump_v3\Static\NpcCultivationGameplayTypeConfig.static.json
|
||
// (「名字 -> 记录」索引,30 条;★ 这就是唯一能拿到 Config.名字常量 的办法)
|
||
// 2) D:\Ananta\lua\lua\LuaGen\AutoGen\ClientConfig.lua:228725-228754
|
||
// NpcCultivationGameplayTypeConfig 的 setmetatable(__index) 内 30 条「混淆键 -> Id」映射
|
||
// (dict.count = 30)。⇒ ★ 服务端只要数字对,客户端 `gameplay ~= gamePlayType.KTV`
|
||
// 这类比较必然命中,不存在「号对不上名字」的风险。
|
||
//
|
||
// 为什么这批能"直接复用"第一批的 11 人名单:先算了交集(见 gym_tools\多玩法邀请_接通说明.md)。
|
||
// 客户端拿到 npcId 后会去**玩法自己的** NPC 表反查,查不到就 nil 索引报错 ⇒ 邀请断在半路。
|
||
// 本批 7 个玩法(Mahjong/FerriswheelCity/Sunbath/Party/DanceParty/Cinema/LiveHouse)的
|
||
// NpcCultivationGameplayTypeConfig.InvitableNPC **都恰好是同样那 11 个 id**,交集 11/11。
|
||
// KTV 只有 3 人、PartyGuide 只有 5 人 ⇒ 单独裁剪,见下方两份专用名单。
|
||
//
|
||
// ⚠ 不接的(InvitableNPC = 0 条,配了也没用):FerriswheelParkCfg 84002013 / FireworkCfg /
|
||
// ClawMachineCfg / WashwallCfg / MaidTeaCfg / FastFoodCfg / FitnessCfg / HotSpringCfg /
|
||
// DanceLivehouseCfg / CatsCfg。
|
||
// ⚠ 闲逛 HangOutCfg 91056008 **根本没有「邀请」这条入口** —— 全库唯一使用者是
|
||
// MapSubSystem_NpcCultivation.lua,它遍历 NpcCultivationHangOutConfig 摆「地图上的闲逛点」。
|
||
// ⇒ 给它加号 = 加了一个永远不会被触发的分支,故**故意不接**。
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
/// <summary>麻将(MahjongCfg.Id)。</summary>
|
||
private const uint NpcCultivationGameplayTypeMahjong = 84002011;
|
||
|
||
/// <summary>摩天轮 · 市区(FerriswheelCityCfg.Id;园区那支 84002013 无候选,不接)。</summary>
|
||
private const uint NpcCultivationGameplayTypeFerriswheelCity = 84002012;
|
||
|
||
/// <summary>露天 SPA(SunbathCfg.Id)。</summary>
|
||
private const uint NpcCultivationGameplayTypeSunbath = 84002018;
|
||
|
||
/// <summary>派对 / 派对目的地(PartyCfg.Id;PartyConfig.GamePlayType 里 4 条都指它)。</summary>
|
||
private const uint NpcCultivationGameplayTypeParty = 91055000;
|
||
|
||
/// <summary>派对引导(PartyGuideCfg.Id;只有 5 个可邀 NPC)。</summary>
|
||
private const uint NpcCultivationGameplayTypePartyGuide = 91056001;
|
||
|
||
/// <summary>蹦迪派对(DancePartyCfg.Id)。</summary>
|
||
private const uint NpcCultivationGameplayTypeDanceParty = 91056002;
|
||
|
||
/// <summary>KTV(KTVCfg.Id;只有 3 个可邀 NPC)。</summary>
|
||
private const uint NpcCultivationGameplayTypeKTV = 91056003;
|
||
|
||
/// <summary>影院(CinemaCfg.Id)。⚠ 客户端拿到推送后会转调另一个 RPC CinemaInviteNPC。</summary>
|
||
private const uint NpcCultivationGameplayTypeCinema = 84002002;
|
||
|
||
/// <summary>LiveHouse / 音乐现场(LiveHouseCfg.Id)。</summary>
|
||
private const uint NpcCultivationGameplayTypeLiveHouse = 84002010;
|
||
|
||
/// <summary>
|
||
/// ★ 2026-09-30:**角色上报 y 与地面基准的差**(≈ 眼高 / 胶囊中心高度)。
|
||
/// 实测依据(露营):玩家 HUD 读到 y ≈ 0.97~0.98,而官方把整片营地摆在
|
||
/// y = −0.09 ~ −0.30 ⇒ 差 ≈ 1.07 m。
|
||
/// ⇒ ⛑⛑ 家具 / NPC 的落点都在<b>地面基准</b>上;想「摆在玩家脚下」必须
|
||
/// <c>玩家上报 y − PlayerEyeHeightMeters</c>,直接拿上报值会整体浮空 1 m。
|
||
/// </summary>
|
||
private const float PlayerEyeHeightMeters = 1.07f;
|
||
|
||
/// <summary>按 gameplay 分别计数的轮转游标前缀(互不干扰)。</summary>
|
||
private const string NpcInviteCursorKey = "client4229938-npc-invite-cursor";
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// 2026-09-25 餐厅专链:AskRestaurantInviteNpc (67057178) —— args: u32 restaurantId, **NOTIFY**
|
||
//
|
||
// ★ 现场症状:用户在「呼噜怪兽(猫咖)」「梦花园(女仆咖啡)」按「邀请伙伴」完全没反应。
|
||
// ★ 服务端铁证(logs\console-latest.log,非推断):
|
||
// [21:45:15] -> ntf AskRestaurantInviteNpc [0x03FF361A] 4b
|
||
// [21:45:15] ! [RPC4229938] unimplemented notify AskRestaurantInviteNpc -> accepted
|
||
// ⇒ 服务端收到但**未实现**、只"受理",从不推送 ⇒ 客户端永远等不到 SyncNpcChatLeaveGameplay。
|
||
// ★ 客户端铁证(logs\packets-20260925-212643.log):
|
||
// :128705 C2S NOTIFY AskRestaurantInviteNpc body=4b C1 83 2F 05 = 87000001
|
||
// :163509 C2S NOTIFY AskRestaurantInviteNpc body=4b C2 83 2F 05 = 87000002
|
||
// ⇒ 载荷是 **RestaurantConfig.Id**(不是 gameplay id!),这点极易搞错。
|
||
//
|
||
// WHY A SEPARATE RPC
|
||
// 餐饮店(餐厅 / 女仆屋 / 瓦当劳 / 寿司)的邀请**不走** AskSimulationInviteNpc:
|
||
// lua\LX6\Manager\RestaurantManager.lua:1094 M.Spoon_RestaurantInvite(args)
|
||
// :1114 local cfg = self:GetRestaurantCfg(args.GameplayId)
|
||
// :1119 gClientToGameSceneDelegate:AskRestaurantInviteNpc(cfg.Id) ← 发的是 cfg.Id
|
||
// :1122 找不到就 print_error("[RestaurantManager] 邀请Npc出错…") ⇒ **连 RPC 都不发**
|
||
// 之后客户端等的是同一条推送 SyncNpcChatLeaveGameplay,但分派不同:
|
||
// lua\LX6\Manager\NpcChat\NpcChatManager.lua:187
|
||
// if gRestaurantManager:IsRestaurantGameplayType(gameplay) then
|
||
// gRestaurantManager:InviteClientNpc(npcId, gameplay) ← 餐厅分支
|
||
// IsRestaurantGameplayType(RestaurantManager.lua:771)原文是 `~= … or ~= …`,
|
||
// ★ 按铁律 1(反编译 JMPF↔JMPT 互换 ⇒ 比较符取反、or↔and)还原后真语义为
|
||
// `gameplay == Restaurant or == MaidHouse or == FastFood or == MaidTea or == Wadanglao or == Sushi`。
|
||
//
|
||
// ⚠ 餐厅分支用的是 NpcCultivationConfig.RestaurantNpcid(Agent 侧 id,如 40140035),
|
||
// 不是普通 Npcid —— 已逐个核对:11 个候选 **全部**配了 RestaurantNpcid(11/11)。
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
/// <summary>餐厅玩法「呼噜怪兽/猫咖」等发出的 RestaurantConfig.Id -> GamePlayType。</summary>
|
||
private static readonly Dictionary<uint, uint> RestaurantGameplayById = new()
|
||
{
|
||
// 取自 D:\Ananta\ConfigDump\ConfigDump_v3\RestaurantConfig.json(全表只有 5 条,已全列)
|
||
[87000001] = 84002001, // RestaurantCfg —— 实测 21:45 三次打的就是这条
|
||
[87000002] = 84002005, // MaidHouseCfg(女仆屋)—— 实测 21:48 两次
|
||
[87000003] = 91056004, // WadanglaoCfg
|
||
[87000004] = 91056005, // SushiCfg
|
||
// 87000801 的 GamePlayType = 0(占位/无玩法)⇒ 故意不登记,命中即记 Warn 不推送。
|
||
};
|
||
|
||
/// <summary>
|
||
/// gameplay 91056000 (Bowling) 的 InvitableNPC 列表,顺带记下 FightSpirit / Agent 链便于排错。
|
||
/// 取自 D:\Ananta\ConfigDump\ConfigDump_v3\NpcCultivationGameplayTypeConfig.json(表内 30 行之一),
|
||
/// 顺序与配置一致;服务端**轮转**挑选,方便现场反复邀请不同 NPC 做验证。
|
||
/// </summary>
|
||
private static readonly uint[] BowlingInvitableNpcCultivationIds =
|
||
{
|
||
91050014, // Taffy -> FightSpirit 15020992 -> Agent 40140035
|
||
91050027, // Aileen -> 15021024 -> 40140043
|
||
91050028, // Anonym -> 15021038 -> 40140053
|
||
91050016, // Bansy -> 15020991 -> 40140034
|
||
91050015, // Garm -> 15020989 -> 40140032
|
||
91050019, // Mechanika -> 15021017 -> 40140038
|
||
91050020, // Richie -> 15021021 -> 40140040
|
||
91050023, // Ringo -> 15021022 -> 40140041
|
||
91050030, // Mimiko -> 15021029 -> 40140048
|
||
91050032, // Seymour -> 15021023 -> 40140042
|
||
91050037, // Cheng Niang -> 15021045 -> 40155001
|
||
};
|
||
|
||
/// <summary>
|
||
/// KTV 专用名单 —— KTVCfg(91056003).InvitableNPC **只有 3 个**,不能复用 11 人名单。
|
||
/// 直接取自 NpcCultivationGameplayTypeConfig 表内 91056003 那一行的 InvitableNPC,顺序照抄。
|
||
/// (交集核对:这 3 个 id 全部属于我方 11 人 ⇒ 也都有 NpcCultivationConfig / FightSpiritID 链。)
|
||
/// </summary>
|
||
private static readonly uint[] KtvInvitableNpcCultivationIds =
|
||
{
|
||
91050014, // Taffy
|
||
91050020, // Richie
|
||
91050037, // Cheng Niang
|
||
};
|
||
|
||
/// <summary>
|
||
/// 派对引导专用名单 —— PartyGuideCfg(91056001).InvitableNPC **只有 5 个**。
|
||
/// ⚠ 注意顺序:表里是 14 / 28 / 20 / 23 / 30,与 Bowling 的 11 人名单顺序不同(那里 28 在第 3 位)。
|
||
/// 轮转挑选只看内容不看顺序,这里照抄配置表原文便于人工比对。
|
||
/// </summary>
|
||
private static readonly uint[] PartyGuideInvitableNpcCultivationIds =
|
||
{
|
||
91050014, // Taffy
|
||
91050028, // Anonym
|
||
91050020, // Richie
|
||
91050023, // Ringo
|
||
91050030, // Mimiko
|
||
};
|
||
|
||
/// <summary>
|
||
/// gameplay -> 可邀请的 NpcCultivationId 列表。**12 个玩法共用这一份 handler**。
|
||
///
|
||
/// ★★ 名单来自配置表的 InvitableNPC 字段,并且**做过交集验证**(2026-09-24,非推断):
|
||
/// BowlingCfg / DartCfg / Dance808Cfg / MahjongCfg / FerriswheelCityCfg / SunbathCfg /
|
||
/// PartyCfg / DancePartyCfg / CinemaCfg / LiveHouseCfg 十张表的 InvitableNPC
|
||
/// **都是同样这 11 个 id**;且它们同时落在各玩法自己的 NPC 表里 ——
|
||
/// · PoiGameDartNpcConfig.NpcId (17 条)∩ 11 = 11
|
||
/// · DanceDanceNPCConfig.Npcid (22 条)∩ 11 = 11
|
||
/// · KTVCfg(3/3) · PartyGuideCfg(5/5) 也全部落在我方 11 人内
|
||
/// ⇒ 一份名单可同时服务十个玩法,**不会踩「查表返回 nil」**。
|
||
///
|
||
/// ⚠⚠ 这张表存在的唯一理由就是这个安全约束:客户端拿到 npcId 后会去玩法自己的 NPC 表里反查,
|
||
/// 查不到就直接 nil 索引报错 ⇒ 邀请断在半路 ⇒ 交互按钮消失(比「没反应」更难恢复)。
|
||
/// 所以**不要**图省事往这里加没验证过交集的 id。
|
||
///
|
||
/// ⚠ 语义提醒:麻将那条链还额外要求 `groupInviteList` 恰好 3 人(MaJiangManager_Flow.lua:791),
|
||
/// 我们推的 npcId 只进 `npcId` 形参,那是**另一条**路;派对要真正开局还需服务端实现
|
||
/// StartSingleParty(63731129)。详见 gym_tools\多玩法邀请_接通说明.md 的「已知边界」。
|
||
/// </summary>
|
||
private static readonly Dictionary<uint, uint[]> InvitableNpcByGameplay = new()
|
||
{
|
||
// —— 第一批:机器上的邀请键(发出方是 C#/Spoon 状态机)——
|
||
[NpcCultivationGameplayTypeBowling] = BowlingInvitableNpcCultivationIds,
|
||
[NpcCultivationGameplayTypeDart] = BowlingInvitableNpcCultivationIds,
|
||
[NpcCultivationGameplayTypeDance808] = BowlingInvitableNpcCultivationIds,
|
||
|
||
// —— 第二批:玩法面板主动发(Lua 里 8 处 AskSimulationInviteNpc 调用点)——
|
||
[NpcCultivationGameplayTypeMahjong] = BowlingInvitableNpcCultivationIds,
|
||
[NpcCultivationGameplayTypeFerriswheelCity] = BowlingInvitableNpcCultivationIds,
|
||
[NpcCultivationGameplayTypeSunbath] = BowlingInvitableNpcCultivationIds,
|
||
[NpcCultivationGameplayTypeParty] = BowlingInvitableNpcCultivationIds,
|
||
[NpcCultivationGameplayTypeDanceParty] = BowlingInvitableNpcCultivationIds,
|
||
[NpcCultivationGameplayTypeCinema] = BowlingInvitableNpcCultivationIds,
|
||
[NpcCultivationGameplayTypeLiveHouse] = BowlingInvitableNpcCultivationIds,
|
||
|
||
// —— 名单被配置表裁剪过的两个玩法(不能复用 11 人)——
|
||
[NpcCultivationGameplayTypePartyGuide] = PartyGuideInvitableNpcCultivationIds,
|
||
[NpcCultivationGameplayTypeKTV] = KtvInvitableNpcCultivationIds,
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// AskSimulationInviteNpc (63301526) — args: u32 gameplay ; return: void
|
||
//
|
||
// 两件事缺一不可:
|
||
// (a) 回一个空的 Ok(形状本来就是 void,DefaultReturnCatalog 那条 body 本身没错);
|
||
// (b) ★ 主动推 SyncNpcChatLeaveGameplay(npcCultivationId, gameplay) —— 这才是让客户端动起来的东西。
|
||
// 只做 (a) 就是修复前的「按了没反应」。
|
||
// ---------------------------------------------------------------------------------------
|
||
[Handler(MethodId.AskSimulationInviteNpc, HandlerPacketKind.Invoke)]
|
||
private async Task AskSimulationInviteNpc4229938(Connection conn, UxRpcMessage msg)
|
||
{
|
||
// ClientToGameDelegate.AskSimulationInviteNpc(gameplay : u32)
|
||
uint gameplay = 0;
|
||
if (msg.Body is { Length: >= 4 })
|
||
{
|
||
gameplay = BitConverter.ToUInt32(msg.Body, 0);
|
||
}
|
||
else
|
||
{
|
||
conn.Log.Warn($"[NPCINVITE] AskSimulationInviteNpc body={msg.Body.Length}b < 4,按 gameplay=0 处理");
|
||
}
|
||
|
||
if (!InvitableNpcByGameplay.TryGetValue(gameplay, out var candidates) || candidates.Length == 0)
|
||
{
|
||
// 未覆盖的 gameplay 一律要回 Ok(否则各处 .Callback 会一直等),但**不能**静默 ——
|
||
// ⚠ 只回 Ok 不推送 = 客户端卡在等待态(该交互点会永久不再出按钮),这条 Warn 是唯一的线索。
|
||
// 已确认「配了也没用」的:其余餐厅类玩法(Restaurant/MaidHouse/FastFood/Wadanglao/Sushi 等
|
||
// 虽在表里有候选,但客户端另有 IsRestaurantGameplayType 专链,不走这里);
|
||
// FerriswheelPark/Firework/ClawMachine/Washwall/Fitness/HotSpring/Cats/DanceLivehouse/MaidTea
|
||
// 的 InvitableNPC **本身是空的**;闲逛 HangOut(91056008) **没有邀请入口**(只有地图闲逛点)。
|
||
conn.Log.Warn(
|
||
$"[NPCINVITE] gameplay={gameplay} 未覆盖 -> 只回 Ok(★ 不推送 ⇒ 客户端会卡在等待态;" +
|
||
$"按钮可能永久消失)。已支持 12 个:Bowling {NpcCultivationGameplayTypeBowling} / " +
|
||
$"Dart {NpcCultivationGameplayTypeDart} / Dance808 {NpcCultivationGameplayTypeDance808} / " +
|
||
$"Mahjong {NpcCultivationGameplayTypeMahjong} / FerriswheelCity {NpcCultivationGameplayTypeFerriswheelCity} / " +
|
||
$"Sunbath {NpcCultivationGameplayTypeSunbath} / Party {NpcCultivationGameplayTypeParty} / " +
|
||
$"PartyGuide {NpcCultivationGameplayTypePartyGuide} / DanceParty {NpcCultivationGameplayTypeDanceParty} / " +
|
||
$"KTV {NpcCultivationGameplayTypeKTV} / Cinema {NpcCultivationGameplayTypeCinema} / " +
|
||
$"LiveHouse {NpcCultivationGameplayTypeLiveHouse}");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
// ★ R146(2026-09-28):下面这一整块是 **v1.9 有、v2.0/v2.1 丢了** 的派对分支,
|
||
// 此处按 v1.9 原文逐字恢复(v1.9 的 NpcInvite.cs 与本文件除此之外**一字不差**)。
|
||
// 症状(用户实测 v2.1):「派对直接开始,跳过了邀请 NPC 的勾选页」——
|
||
// 少了这条分支,派对三条 gameplay 会掉进下面的**通用分支**,服务端于是推
|
||
// `SyncNpcChatLeaveGameplay`(语义 = "该 NPC 已离开这个玩法群聊"),客户端据此
|
||
// 认为邀请流程已结束、拿空名单直接 StartSingleParty ⇒ 选人页永远不弹。
|
||
// 恢复后:推 `SyncNpcChatInvite` ⇒ 客户端自己弹「NPC 邀请面板」⇒ 玩家勾选提交
|
||
// `InviteMultiNpcChat` ⇒ 本文件的群邀 handler ⇒ 再推 LeaveGameplay ⇒ 带名单开局。
|
||
// ---------------------------------------------------------------------------
|
||
// ★ 派对(91055000 Party / 91056001 PartyGuide / 91056002 DanceParty)必须走**群邀**链。
|
||
//
|
||
// 派对的 NPC 名单只来自「NPC 群聊邀请面板」的勾选:
|
||
// NpcChatManager.lua:182 local multiNpcTidList = gNpcChatManager.groupInviteList
|
||
// 而玩家在 HUD 上按的是**单人**邀请(本 RPC),它不写 groupInviteList,
|
||
// 直接推 SyncNpcChatLeaveGameplay 只会让客户端拿空表开局 ⇒ 派对里一个人都没有
|
||
// (实测开局包体里名单段编码为「非空的空表」,即 0xFF 0x01)。
|
||
//
|
||
// 改为推 SyncNpcChatInvite(64106407):客户端收到后**自己弹出**邀请面板
|
||
// (GameToClientImpl.lua:1150 → OpenChatPanel → NpcChatBasePanelStore.lua:65 → InviteChat)。
|
||
// 玩家勾满人数提交 → InviteMultiNpcChat(63447141) → 本文件的群邀 handler →
|
||
// 推 SyncNpcChatLeaveGameplay → NpcChatManager:217 带名单 StartSingleParty。
|
||
//
|
||
// ⚠ 只改这三条 gameplay,其余玩法(保龄球/飞镖/麻将…)保持原行为不变。
|
||
// ---------------------------------------------------------------------------
|
||
if (gameplay == NpcCultivationGameplayTypeParty
|
||
|| gameplay == NpcCultivationGameplayTypePartyGuide
|
||
|| gameplay == NpcCultivationGameplayTypeDanceParty)
|
||
{
|
||
await conn.NotifyAsync(MethodId.SyncNpcChatInvite, new SyncNpcChatInvitePush
|
||
{
|
||
Gameplay = gameplay,
|
||
});
|
||
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
|
||
conn.Log.Info(
|
||
$"[NPCINVITE] AskSimulationInviteNpc gameplay={gameplay} 是派对 -> 推 SyncNpcChatInvite 打开 NPC 邀请面板(勾满人数后提交 -> InviteMultiNpcChat)");
|
||
return;
|
||
}
|
||
|
||
// 轮转挑选(同一条连接的 session 内计数,**按 gameplay 分开**,免得几个玩法互相顶掉游标),
|
||
// 避免每次都邀请同一个人。
|
||
string cursorKey = $"{NpcInviteCursorKey}-{gameplay}";
|
||
int cursor = 0;
|
||
if (msg.Context.Session.Items.TryGetValue(cursorKey, out var raw) && raw is int stored && stored >= 0)
|
||
{
|
||
cursor = stored % candidates.Length;
|
||
}
|
||
|
||
uint npcCultivationId = candidates[cursor];
|
||
msg.Context.Session.Items[cursorKey] = (cursor + 1) % candidates.Length;
|
||
|
||
// ★ 先推 SyncNpcChatLeaveGameplay,再回 invoke 的 Ok —— 与 GameRouter.GameGround.cs 里
|
||
// EnterBowlingZone 的既有写法一致(推 SyncGameGroundZoneInfo 后再 ReturnAsync)。
|
||
// 客户端 pendingInviteTask 是在发 RPC 的同一帧同步挂上的,所以两条消息的先后无所谓。
|
||
//
|
||
// ⚠ 这里**不要**改成 NotifyRawAsync 手工拼字节:载荷形状问题已在契约层修好
|
||
// (SyncNpcChatLeaveGameplayPush 标了 Inline = true ⇒ 严格 8 字节,无 0xFF 标记)。
|
||
// 2026-09-24 曾因漏标 Inline 发出 9 字节,客户端裸读错位 ⇒ 按了没反应。
|
||
await conn.NotifyAsync(MethodId.SyncNpcChatLeaveGameplay, new SyncNpcChatLeaveGameplayPush
|
||
{
|
||
NpcOrNpcGroup = npcCultivationId,
|
||
Gameplay = gameplay,
|
||
});
|
||
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
|
||
conn.Log.Info(
|
||
$"[NPCINVITE] AskSimulationInviteNpc gameplay={gameplay} " +
|
||
$"-> pushed SyncNpcChatLeaveGameplay npcOrNpcGroup={npcCultivationId} (第 {cursor + 1}/{candidates.Length} 位)");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// AskRestaurantInviteNpc (67057178) — args: u32 restaurantId ; **NOTIFY,没有回包**
|
||
//
|
||
// 因为是单向 notify,客户端不会等任何 invoke 回包 —— 唯一的响应手段就是那条推送。
|
||
// 修之前服务端只做了「unimplemented notify -> accepted」,等于什么都不做 ⇒ 按了没反应。
|
||
//
|
||
// ⚠ 载荷是 RestaurantConfig.Id,不是 gameplay id。别照 AskSimulationInviteNpc 那样直接用。
|
||
// ---------------------------------------------------------------------------------------
|
||
[Handler(MethodId.AskRestaurantInviteNpc, HandlerPacketKind.Notify)]
|
||
private async Task AskRestaurantInviteNpc4229938(Connection conn, UxRpcMessage msg)
|
||
{
|
||
uint restaurantId = 0;
|
||
if (msg.Body is { Length: >= 4 })
|
||
{
|
||
restaurantId = BitConverter.ToUInt32(msg.Body, 0);
|
||
}
|
||
else
|
||
{
|
||
conn.Log.Warn($"[NPCINVITE] AskRestaurantInviteNpc body={msg.Body?.Length ?? 0}b < 4,无法取 restaurantId");
|
||
return;
|
||
}
|
||
|
||
if (!RestaurantGameplayById.TryGetValue(restaurantId, out uint gameplay) || gameplay == 0)
|
||
{
|
||
// 未登记的餐厅 id:没有 gameplay ⇒ 推了客户端也认不出(IsRestaurantGameplayType 判不中)
|
||
// ⇒ 按铁律「沉默 + 打日志」,绝不回填哨兵值。
|
||
conn.Log.Warn(
|
||
$"[NPCINVITE] AskRestaurantInviteNpc restaurantId={restaurantId} 未登记(RestaurantConfig 全表 5 条," +
|
||
$"仅 87000001/87000002/87000003/87000004 有 GamePlayType)-> 不推送");
|
||
return;
|
||
}
|
||
|
||
// 餐厅分支挑人:用 11 人名单。
|
||
// 交集依据(2026-09-25 实算,非推断):以 RestaurantCfg(84002001).InvitableNPC 为基准,
|
||
// MaidHouseCfg(84002005) / WadanglaoCfg(91056004) / SushiCfg(91056005) 与基准**交集 11/11、差集为空**;
|
||
// 且这 11 个 NpcCultivationConfig **全部**配了 RestaurantNpcid(餐厅分支真正用它生成 Agent)。
|
||
// ⚠ 未接的 CatsCfg(84002003 猫咖) / MaidTeaCfg(84002008 女仆咖啡) / FastFoodCfg(84002009) 的
|
||
// InvitableNPC **本身是 0 条**,配了也没人可邀。
|
||
var candidates = BowlingInvitableNpcCultivationIds;
|
||
|
||
string cursorKey = $"{NpcInviteCursorKey}-rest-{restaurantId}";
|
||
int cursor = 0;
|
||
if (msg.Context?.Session.Items.TryGetValue(cursorKey, out var raw) == true && raw is int stored && stored >= 0)
|
||
{
|
||
cursor = stored % candidates.Length;
|
||
}
|
||
|
||
uint npcCultivationId = candidates[cursor];
|
||
if (msg.Context is not null)
|
||
{
|
||
msg.Context.Session.Items[cursorKey] = (cursor + 1) % candidates.Length;
|
||
}
|
||
|
||
await conn.NotifyAsync(MethodId.SyncNpcChatLeaveGameplay, new SyncNpcChatLeaveGameplayPush
|
||
{
|
||
NpcOrNpcGroup = npcCultivationId,
|
||
Gameplay = gameplay,
|
||
});
|
||
|
||
conn.Log.Info(
|
||
$"[NPCINVITE] AskRestaurantInviteNpc restaurantId={restaurantId} -> gameplay={gameplay} " +
|
||
$"-> pushed SyncNpcChatLeaveGameplay npc={npcCultivationId} (第 {cursor + 1}/{candidates.Length} 位)");
|
||
}
|
||
|
||
/// <summary>
|
||
/// UX 集合的 "null" 标记 —— **单字节 0x00**。
|
||
/// 依据:客户端 RPCDeserializeBase.lua 的 ReadList7Bit 先读 1 字节,
|
||
/// <c>local SerializeObjectMarkNull = 0</c>(RPCDeserializeBase.lua:7),该字节为 0 时返回 nil,否则按 Int7 项数解析。
|
||
/// 反向验证(08-xx 那次 destructible push 事故):服务端误发 <c>0xFF 0xEF 0x0B + 1518B</c>,
|
||
/// 客户端把它读成 **Int7 项数 = 1519-1 = 1518** 项并在第 1 项失败 —— 证明首字节 0xFF 是"非空"。
|
||
/// </summary>
|
||
private static readonly byte[] UxNullListBody = { 0x00 };
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// 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 生成。
|
||
// ---------------------------------------------------------------------------------------
|
||
[Handler(MethodId.AskGetNpcRandomWearFashions, HandlerPacketKind.Invoke)]
|
||
private async Task AskGetNpcRandomWearFashions4229938(Connection conn, UxRpcMessage msg)
|
||
{
|
||
await conn.ReturnRawAsync(msg, UxNullListBody);
|
||
conn.Log.Info($"[NPCINVITE] AskGetNpcRandomWearFashions bytes={msg.Body.Length} -> null list (客户端等价 suitId=0,走默认外观)");
|
||
}
|
||
// ---------------------------------------------------------------------------------------
|
||
// StartSingleParty (63731129) — args: u32 partyId, list<u32> npcIdList, map fashionsInfo
|
||
// ; return: **void**
|
||
//
|
||
// 客户端链(PartyManager.lua;反编译 JMPF/JMPT 互换 ⇒ 比较符要取反,铁律 1):
|
||
// :594 StartSingleParty(partyId, npcIdList) —— 先在**本地** SetSinglePartyId(partyId)
|
||
// :674 RequestStartSingleParty → :690 gClientToGameDelegate:StartSingleParty(...).Callback
|
||
// :691 反编译写 `if errorId == Ok then 报错; return end`,取反后即:
|
||
// if errorId ~= Ok then 提示错误并 return end
|
||
// self.isInParty = true ; gPanelManager:CheckShow(gPanelId.PARTY_HUD_PANEL)
|
||
// ⇒ ★ 服务端只要回 Ok,客户端**自己**就打开派对 HUD,**不依赖任何 S2C 推送**。
|
||
//
|
||
// 面板数据不会崩(PartyHudPanelStore.lua,已逐行核对):
|
||
// :221 partyId = args.PartyId or gPartyManager.singlePartyId ← :599 已设,有兜底
|
||
// :454 partyOverTime = self.partyOverTime or gPartyManager.partyOverTime
|
||
// or serverTime + PartyConfig.CountdownTime ← 有兜底
|
||
// :688 邀请名单在发 RPC **之前**就 CopyRpcList 存进 partyInviteNpcIdList
|
||
//
|
||
// ⚠ 因此第一版**故意不推** SyncPartyPlayerLogin(68544397):
|
||
// 载荷含两个 List7Bit(InviteNPCIds / LotteryResults),而派对 RPC 在 unknown-methods.log 里
|
||
// **全 0 次**(面板一直锁着,客户端从没发过)⇒ 无真报文互证,硬拼必踩 0xFF 标记错位(铁律 5)。
|
||
// 等 HUD 真开起来、抓到真实交互后再补。
|
||
// ---------------------------------------------------------------------------------------
|
||
private const string PartySingleIdSessionKey = "party.single.id";
|
||
|
||
/// <summary>
|
||
/// 本 session 本次派对**已经落场**的那批 NpcCultivationId(StartSingleParty 写入)。
|
||
/// <c>PartyOver</c> 的结算推送要用它填 <c>SyncPartySettleData.InviteNPCList</c>。
|
||
/// ⚠ 与 <c>party.npc.placed</c> 分开:那个是 NpcCultivationId → entityId 的账本(生成/撤场用),
|
||
/// 这个是**给客户端的 id 列表**,两者用途不同,别合并。
|
||
/// </summary>
|
||
private const string PartySettleInvitedKey = "party.settle.invited";
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// 派对 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**,与落点走哪条分支解耦。
|
||
// ---------------------------------------------------------------------------------------
|
||
private static IReadOnlyList<uint> PartyNpcPoiActionIdsFor(uint partyId, out string note)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay;
|
||
|
||
if (!cfg.PartyNpcPoiEnabled)
|
||
{
|
||
note = "已关闭(PartyNpcPoiEnabled=false)⇒ poi=0,NPC 只有兜底 idle";
|
||
return [];
|
||
}
|
||
|
||
var gameplayType = PartySpotCatalog4229938.TryGet(partyId, out var spot) ? spot.GamePlayType : 0u;
|
||
|
||
var ids = gameplayType switch
|
||
{
|
||
NpcCultivationGameplayTypeParty or NpcCultivationGameplayTypePartyGuide => cfg.PartyNpcPoiCampActionIds,
|
||
NpcCultivationGameplayTypeDanceParty => cfg.PartyNpcPoiDanceActionIds,
|
||
_ => cfg.PartyNpcPoiDefaultActionIds,
|
||
};
|
||
|
||
// 该玩法自己的表为空 ⇒ 回退默认表(不是「什么都不发」)。
|
||
if (ids.Length == 0 && gameplayType != 0)
|
||
ids = cfg.PartyNpcPoiDefaultActionIds;
|
||
|
||
note = ids.Length == 0
|
||
? $"没有可用动作表(partyId={partyId} gameplay={gameplayType})⇒ poi=0"
|
||
: $"partyId={partyId} gameplay={gameplayType} " +
|
||
$"{(gameplayType switch
|
||
{
|
||
NpcCultivationGameplayTypeParty or NpcCultivationGameplayTypePartyGuide => "露营",
|
||
NpcCultivationGameplayTypeDanceParty => "舞会",
|
||
_ => "兜底",
|
||
})} -> [{string.Join('/', ids)}] 按人轮转";
|
||
return ids;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// 派对时长(用于 StartSingleParty handler 里回推 SyncPartyStart 的两个 Unix 秒时间戳)。
|
||
//
|
||
// ★★ 第 29 轮更正:第 28 轮首版错用联机常量 900 兜底 ⇒ 实测倒计时起点是 **15 分钟**,
|
||
// 而露营官方值是 **10 分钟**。权威字段是 **ConfigDump_v3\PartyConfig.json 的 MaxDuration**
|
||
// (单位:秒;7 条全有值)。注意 Static\PartyConfig.static.json 里**根本没有**时长字段 ——
|
||
// 这就是当时误用联机常量兜底的原因。实测值:
|
||
// 13030000 露营(单) = 600 13030001 = 600 13030002 = 600
|
||
// 13030003 舞会(单) = 900 13030006 凌云 = 900
|
||
// 13030004 露营(联) = 600 13030005 舞会(联) = 900
|
||
// ⇒ 按 partyId 查表;未登记才回落 PartyDefaultDurationSeconds(= 联机 OnlinePartyMaxTime 900)。
|
||
//
|
||
// 抽奖窗口 = 派对结束前 300 秒,取自 Static\PartyConfig.static.json 的 OnlineLotteryTime,
|
||
// 算法照抄联机侧(PartyManager.lua:944-947)的 `partyEndTime - OnlineLotteryTime`。
|
||
//
|
||
// ⚠ 第 29 轮实测更正:**单机派对客户端根本不显示抽奖区** ——
|
||
// PartyHudPanelStore.lua:342 `if not self.isOnline then return end` 把
|
||
// RefreshLotteryTypeCtrl(决定显示 抽奖/礼物/不显示 的开关)挡在门外,
|
||
// `lotteryTypeCtrl` 在单机下从未被赋值 ⇒ 抽奖整块不渲染。
|
||
// ⇒ 本值对单机**无害也无用**,保留只为联机派对语义正确。
|
||
// ---------------------------------------------------------------------------------------
|
||
private const uint PartyDefaultDurationSeconds = 900;
|
||
private const uint PartyLotteryWindowSeconds = 300;
|
||
|
||
/// <summary>partyId → 派对时长(秒)。来源:ConfigDump_v3\PartyConfig.json 的 MaxDuration。</summary>
|
||
private static readonly Dictionary<uint, uint> PartyDurationByPartyId = new()
|
||
{
|
||
[13030000] = 600, // 露营(单机)
|
||
[13030001] = 600, // 露营(单机 · 另一场景)
|
||
[13030002] = 600, // 露营(单机 · 引导)
|
||
[13030003] = 900, // 舞会(单机)
|
||
[13030004] = 600, // 露营(联机)
|
||
[13030005] = 900, // 舞会(联机)
|
||
[13030006] = 900, // 凌云舞会(用户明确不做,仅登记以免落兜底)
|
||
};
|
||
|
||
[Handler(MethodId.StartSingleParty, HandlerPacketKind.Invoke)]
|
||
private async Task StartSingleParty4229938(Connection conn, UxRpcMessage msg)
|
||
{
|
||
int bodyLen = msg.Body?.Length ?? 0;
|
||
|
||
// ★★★ 第 53 轮:**第一次真正解析 StartSingleParty 的第三个参数 `fashionsinfo`**。
|
||
//
|
||
// 形状 100% 来自客户端自己的写者,且已用真实报文逐字节回放(三条抓包 30B 全同):
|
||
// LuaGen\AutoGen\ClientToGameDelegate.lua:5464
|
||
// WritePrimitive(partyid, u32) → WriteList7Bit(npcs, u32) → WriteComplex(fashionsinfo, nullable=true)
|
||
// LuaGen\AutoGen\RPCSerializeAuto.lua:6949
|
||
// WriteDict7Bit(Rents, u32,u32) → WritePrimitive(UseDefaultFashion, bool)
|
||
// 契约见 Ananta.RpcTypes\Client4229938\PartyWireContracts.cs 的
|
||
// StartSinglePartyArgs4229938 / PartyNpcFashionsInfo4229938(那里写了四条定标判据)。
|
||
//
|
||
// 实测报文(packets-latest.log:44434,body=30b):
|
||
// 70 D2 C6 00 | FF 06 | <5×u32> | FF FF 01 01
|
||
// partyId=13030000 · npcs=5 · fashionsinfo{Rents={}, UseDefaultFashion=true}
|
||
// 合计 4+1+1+20+1+1+1+1 = 30 ⇒ 与本契约字段序逐字节对齐。
|
||
//
|
||
// ⚠ 结构化解析**失败也不许改变行为**:退回裸读前 4 字节(= 旧行为),照旧回 Ok。
|
||
// 理由:这是「多解析一层」的增量改动,不该把一条已经跑通的链路赌进去。
|
||
uint partyId = 0;
|
||
StartSinglePartyArgs4229938? partyArgs = null;
|
||
if (msg.TryGetArgs<StartSinglePartyArgs4229938>(out var parsedArgs))
|
||
{
|
||
partyArgs = parsedArgs;
|
||
partyId = parsedArgs.partyId;
|
||
conn.Log.Info(
|
||
$"[PARTY-FASHION] C2S StartSingleParty 结构解析成功:partyId={parsedArgs.partyId} " +
|
||
$"npcs={parsedArgs.npcs.Count}[{string.Join(',', parsedArgs.npcs)}] " +
|
||
$"fashionsinfo={(parsedArgs.fashionsinfo is null
|
||
? "null(自行搭配但一件未选)"
|
||
: $"Rents={parsedArgs.fashionsinfo.Rents.Count} 条 / UseDefaultFashion={parsedArgs.fashionsinfo.UseDefaultFashion}")}");
|
||
}
|
||
else if (msg.Body is { Length: >= 4 })
|
||
{
|
||
partyId = BitConverter.ToUInt32(msg.Body, 0);
|
||
conn.Log.Warn(
|
||
$"[PARTY-FASHION] C2S StartSingleParty **结构化解析失败**(body={bodyLen}b)⇒ 退回裸读 partyId={partyId}," +
|
||
$"本次不解析服装(= 旧行为)。若持续出现,请把 HexDump 与 StartSinglePartyArgs4229938 的字段序对一遍。");
|
||
}
|
||
else
|
||
{
|
||
conn.Log.Warn($"[PARTY] StartSingleParty body={bodyLen}b < 4,partyId 按 0 处理(仍然回 Ok)");
|
||
}
|
||
|
||
// 记下本 session 的派对 id,留给后续 SyncPartyPlayerLogin / 结算抽奖用。
|
||
if (msg.Context is not null)
|
||
{
|
||
msg.Context.Session.Items[PartySingleIdSessionKey] = partyId;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// ★ 回推 SyncPartyStart(68368632):让派对从"只有一个 10 秒倒计时"变成"真在跑"。
|
||
//
|
||
// 形状 100% 确定(不是猜):客户端 LuaGen\AutoGen\RPCDeserializeAuto.lua 的
|
||
// midToReader[68368632] = function (reader)
|
||
// local partyOverTime = reader.ReadUInt32(reader)
|
||
// local lotteryStartTime = reader.ReadUInt32(reader)
|
||
// end
|
||
// ⇒ **两个裸 u32,无对象标记** ⇒ 契约必须 [UxContract(Inline = true)]
|
||
// (见 Ananta.RpcTypes\Client4229938\PartyWireContracts.cs)。判据 = 铁律 5。
|
||
//
|
||
// 路由:proxy\server.js:2558 记载客户端按 floor(mid / 1000000) 选服务
|
||
// ⇒ 68368632 / 1e6 = 68 = GameSceneToClientImpl(与该函数在
|
||
// Service\GameSceneToClientImpl.lua:234 的定义吻合)。
|
||
// 同族推送(SyncDoorLockState / SyncGadgetRefresh / IGameSceneToClient_SyncAgent*)
|
||
// 在本服务端已大量在用且实测可达客户端 ⇒ 无需额外注册。
|
||
//
|
||
// 为什么必须发(实测根因,见 gym_tools\派对_NPC邀请链_客户端权威校验_第27轮.md §6.2):
|
||
// 服务端什么都不推时,PartyHudPanelStore.lua:454 的三层 fallback 里
|
||
// `serverTime + PartyConfig.CountdownTime` 生效,而 CountdownTime = 10 秒,
|
||
// 且该标题倒计时**没有 luaFinished 回调** ⇒ 10 秒走完什么都不发生
|
||
// (= 用户报的「显示露营进行中的倒计时,倒计时完没反应」);
|
||
// 同时 gPartyManager.lotteryStartTime 恒为 nil。
|
||
// ⚠ 第 29 轮实测更正:当时我推断「抽奖控件永远停在 preparing」是**错的** ——
|
||
// 单机派对根本不渲染抽奖区(见上方 PartyDurationByPartyId 处注释)。
|
||
// 实测确认:倒计时从 15 分钟开始跑 = 本推送生效,而抽奖区始终不出现 = 官方设计。
|
||
//
|
||
// ★ 顺序:**先推再回 Ok**。客户端是在 StartSingleParty 的 .Callback 里才
|
||
// CheckShow(PARTY_HUD_PANEL),先推可让 HUD 的 InitModel 直接用上
|
||
// gPartyManager.partyOverTime / lotteryStartTime(那两个字段由本条推送写入)。
|
||
// ---------------------------------------------------------------------------------------
|
||
var now = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||
|
||
// 时长按 partyId 查权威配表(ConfigDump_v3\PartyConfig.json :: MaxDuration),未登记才回落。
|
||
if (!PartyDurationByPartyId.TryGetValue(partyId, out var durationSeconds))
|
||
{
|
||
durationSeconds = PartyDefaultDurationSeconds;
|
||
conn.Log.Warn(
|
||
$"[PARTY] StartSingleParty partyId={partyId} 不在 PartyDurationByPartyId 表里 ⇒ 派对时长沿用兜底 " +
|
||
$"{durationSeconds}s。若这是新派对类型,请把 ConfigDump_v3\\PartyConfig.json 里的 MaxDuration 补进表。");
|
||
}
|
||
|
||
var partyOverTime = now + durationSeconds;
|
||
var lotteryStartTime = partyOverTime - PartyLotteryWindowSeconds;
|
||
|
||
await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncPartyStart, new SyncPartyStartPush
|
||
{
|
||
PartyOverTime = partyOverTime,
|
||
LotteryStartTime = lotteryStartTime,
|
||
});
|
||
|
||
// 返回形状本来就是 void(DefaultReturnCatalog4229938.cs:737)⇒ 空体 Ok 即可。
|
||
// ★ 第 30 轮:先落 NPC 再回 Ok —— 让客户端的 CheckShow(PARTY_HUD_PANEL)
|
||
// 发生在实体已经进场之后,避免「HUD 先开、人后到」的中间态。
|
||
string spawnNote;
|
||
var partySession = msg.Context?.Session;
|
||
if (partySession is null)
|
||
{
|
||
spawnNote = "[PARTYNPC] 跳过:没有 session";
|
||
}
|
||
else
|
||
{
|
||
float ax = 0f, ay = 0f, az = 0f, baseAngle = 0f;
|
||
var haveAnchor = false;
|
||
string anchorNote = "玩家锚点";
|
||
string why;
|
||
|
||
// ★★ 第 34 轮:**落点位次 = ①配表 PartySpot(官方规定场地)→ ②玩家锚点(兜底)**
|
||
//
|
||
// 用户明确指出:把 NPC 塞在玩家身边 2.5 m 是我自己编的站位(「取巧」);
|
||
// 官方在配表里**给了规定位置** —— `PartySpot` + `PartyRotation`。
|
||
// 来源:ConfigDump_v3\PartyConfig.json(已复制到 Server\ClientData\4229938\Configs\,
|
||
// 由 ClientData\Client4229938\PartySpotCatalog4229938.cs 读取):
|
||
// 13030000 露营 PartySpot=[1288.91003,-0.09,2374.46997] PartyRotation=-76.675
|
||
// RaidId=23300888(主城,与露营地同副本) MaxNum=3
|
||
// ⚠ 联机两条(13030004 / 13030005)配的是 [0,0,0] = 「本行没有场地」的哨兵,
|
||
// 由 HasUsableSpot 判掉 ⇒ 落到玩家锚点分支。
|
||
// ⚠ 配表**允许缺失**(PartySpotCatalog 缺表时返回空表)⇒ 同样落到玩家锚点,功能不会挂。
|
||
// ★★★ 第 47 轮:**位次 0 = 服务端覆盖圆心**(用户现场指定)。
|
||
// 用户原话:「他们出现的位置还是之前的,没有出现在我想要的位置」+
|
||
// 「位置是1282.93, 0.97, 2374.78,按我站的位置」。
|
||
// ⚠ **只取 X / Z**,y 用官方 PartySpot 的 y —— 见
|
||
// PrivateServerConfig.PartyNpcAnchorOverride 的说明(HUD 的 y ≈ 官方 y + 1.1,
|
||
// 那是眼高/胶囊中心,不是落点)。官方查不到时才退回数组第 2 个数。
|
||
// ★★★ 第 48 轮(舞会):覆盖圆心改成**按 partyId 作用域**。
|
||
// 原先 `PartyNpcAnchorOverride` 对**所有**派对无条件生效 ⇒ 一开舞会(13030003),
|
||
// NPC 会被摆到**露营**那个圆周上(用户现场给的点),而不是舞会的 PartySpot。
|
||
// 解析顺序:
|
||
// ① 按 id 的字典 partyNpcAnchorOverrides["<partyId>"](可同时给露营/舞会各一套)
|
||
// ② 旧的扁平数组 partyNpcAnchorOverride,且**仅当** partyId == partyNpcAnchorOverridePartyId
|
||
// (默认 13030000 = 露营)⇒ 旧配置行为对露营完全不变
|
||
var gpCfg48 = PrivateServerConfigStore.Current.Gameplay;
|
||
var hasSpotCfg = PartySpotCatalog4229938.TryGet(partyId, out var spotCfg) && spotCfg.HasUsableSpot;
|
||
|
||
// ★★★ 第 49 轮(舞会·走官方路径):舞会的落点锚改用**官方舞池点**
|
||
// `PartyDanceSpotConfig.DanceSpot` + 朝向 `DanceRotation`
|
||
// (独立配表,按 partyId 索引;读表见
|
||
// ClientData\Client4229938\PartyDanceSpotCatalog4229938.cs)。
|
||
// ⚠ 与 `PartySpot` **不是一个点**,别互相顶替:
|
||
// 13030003 场地锚 = [2574.24, 80.135, 1271](PartyCamp.json 的 danceOnly.pos 同值,
|
||
// 舞会大门 Gadgets_Party_DanceDoor 在 2569.63)
|
||
// 13030003 舞池 = [2605.33, 80, 1269.74](灯光控制台在 2593.94),相距 ≈ 31 m
|
||
// ⚠ 想改舞者站位:`partyNpcAnchorOverrides["13030003"]` 直接覆盖;
|
||
// 想让舞会仍站在场地锚:`partyNpcDanceAnchor` 设成 "partySpot"。
|
||
var gameplayType49 = PartySpotCatalog4229938.TryGet(partyId, out var gpSpot49) ? gpSpot49.GamePlayType : 0u;
|
||
var isDanceParty49 = gameplayType49 == NpcCultivationGameplayTypeDanceParty;
|
||
PartyDanceSpotEntry4229938? danceSpotAnchor49 = null;
|
||
if (isDanceParty49
|
||
&& !string.Equals(gpCfg48.PartyNpcDanceAnchor, "partySpot", StringComparison.OrdinalIgnoreCase)
|
||
&& PartyDanceSpotCatalog4229938.TryGetByParty(partyId, out var danceSpotCfg49)
|
||
&& danceSpotCfg49.HasUsableSpot)
|
||
{
|
||
danceSpotAnchor49 = danceSpotCfg49;
|
||
}
|
||
|
||
// ★★★ 第 54 轮(2026-09-30):**就地开局** —— 位次 −1,优先级高于一切覆盖。
|
||
//
|
||
// 用户要求:手机 → 社交派对 → 舞会,直接在**自己脚下**开,而不是跑去官方那片
|
||
// y ≈ 80 的悬空场地 —— 那层<b>没有地板</b>,只有我们摆的 2 件常驻机关 + 客户端
|
||
// 一个花坛,玩家实机只能踩道具 / 挂飞索才能不掉落(2026-09-30.md §47)。
|
||
// 「把玩家送上 y=80」已被实机证伪 ⇒ 反过来把场地搬到玩家脚下。
|
||
//
|
||
// 启用:<c>config/private-server.json</c> 的
|
||
// <c>gameplay.partyVenueAtPlayerPartyIds</c> 写上 partyId(例 [13030003])。
|
||
// 默认<b>空数组 = 关闭</b>,行为与改动前完全一致。
|
||
var venueAtPlayer = gpCfg48.PartyVenueAtPlayerPartyIds.Contains(partyId);
|
||
var venueAtPlayerOk = false;
|
||
float vpx = 0f, vpy = 0f, vpz = 0f, vpFacing = 0f;
|
||
if (venueAtPlayer && GetStateIfExists(partySession) is { } vpState)
|
||
{
|
||
lock (vpState.SyncRoot)
|
||
{
|
||
var src = vpState.HasLastReportedPlayerTransform
|
||
? vpState.LastReportedPlayerPosition
|
||
: vpState.WorldEntryCreateHeroPosition;
|
||
vpx = src.X;
|
||
vpy = src.Y;
|
||
vpz = src.Z;
|
||
vpFacing = vpState.HasLastReportedPlayerTransform
|
||
? vpState.LastReportedPlayerRotation.Y
|
||
: vpState.WorldEntryCreateHeroFacing;
|
||
}
|
||
venueAtPlayerOk = float.IsFinite(vpx) && float.IsFinite(vpy) && float.IsFinite(vpz);
|
||
if (!venueAtPlayerOk)
|
||
conn.Log.Warn($"[PARTYNPC] 就地开局 partyId={partyId} 拿不到有限玩家坐标 ⇒ 回退配表锚点");
|
||
}
|
||
|
||
float[]? anchorOv = null;
|
||
var anchorSrc = string.Empty;
|
||
if (gpCfg48.PartyNpcAnchorOverrides.TryGetValue(
|
||
partyId.ToString(System.Globalization.CultureInfo.InvariantCulture), out var perPartyOv)
|
||
&& perPartyOv is { Length: >= 3 })
|
||
{
|
||
anchorOv = perPartyOv;
|
||
anchorSrc = $"partyNpcAnchorOverrides[\"{partyId}\"]";
|
||
}
|
||
else if (partyId == gpCfg48.PartyNpcAnchorOverridePartyId
|
||
&& gpCfg48.PartyNpcAnchorOverride is { Length: >= 3 })
|
||
{
|
||
anchorOv = gpCfg48.PartyNpcAnchorOverride;
|
||
anchorSrc = $"partyNpcAnchorOverride(仅 partyId={gpCfg48.PartyNpcAnchorOverridePartyId})";
|
||
}
|
||
|
||
if (venueAtPlayerOk)
|
||
{
|
||
ax = vpx;
|
||
// ⛑ 实测(2026-09-30):减 1.07 会让全员陷进地里只露半个身子
|
||
// ⇒ NPC 落点 y 与玩家上报 y **同一参考系**(角色根),默认不偏移。
|
||
ay = vpy + gpCfg48.PartyVenueNpcYOffset;
|
||
az = vpz;
|
||
// 舞者站在玩家位置上、面朝玩家(玩家朝向 + 180°)。
|
||
baseAngle = (vpFacing + 180f) * MathF.PI / 180f;
|
||
haveAnchor = true;
|
||
anchorNote = "★就地开局(玩家坐标)";
|
||
why = $"VenueAtPlayer=({ax:F2},{ay:F2},{az:F2})(玩家坐标 − 眼高 {PlayerEyeHeightMeters}m)" +
|
||
$" 朝向={vpFacing + 180f:F1}";
|
||
|
||
// 家具那一整片也搬过来(下一次 grid tick 生效)。
|
||
// ⛑⛑ 家具是**另一套参考系**(比角色根低 ≈1.07 m)——露营实测:玩家 y≈0.97
|
||
// 而官方把家具摆在 −0.09~−0.30。所以这里要再减 PlayerEyeHeightMeters,
|
||
// 直接传 ay 会让家具整片浮空 1 m。
|
||
// ⛑⛑ 家具基准 = 玩家 y − 眼高(1.07) + 可调抬升(partyVenuePropYOffset)。
|
||
// 实测(2026-09-30)不加抬升时家具落在「玩家 y − 1.20」,城里地面起伏
|
||
// 会让控制台埋进地里 ⇒ 默认配置抬 0.5(热重载可调,不用重启)。
|
||
var propY = ay - PlayerEyeHeightMeters + gpCfg48.PartyVenuePropYOffset;
|
||
// ⛑ 把玩家朝向一并传下去:家具整片会按它旋转,让最远那件(灯光控制台)
|
||
// 落在玩家**正前方**,而不是留在官方那个写死的世界方位上(可能在你背后)。
|
||
SetPartyVenueAtPlayer(partySession, partyId, ax, propY, az, vpFacing);
|
||
conn.Log.Info($"[PARTYCAMP] 就地开局:partyId={partyId} 场地锚搬到 ({ax:F2},{propY:F2},{az:F2})" +
|
||
$"(家具基准 = NPC 基准 − {PlayerEyeHeightMeters}m + 抬升 {gpCfg48.PartyVenuePropYOffset}m," +
|
||
$"玩家朝向={vpFacing:F1}°)");
|
||
}
|
||
else if (anchorOv is not null && float.IsFinite(anchorOv[0]) && float.IsFinite(anchorOv[2]))
|
||
{
|
||
ax = anchorOv[0];
|
||
az = anchorOv[2];
|
||
// ★ 第 49 轮:舞会优先用**官方舞池点的 y**(否则才回落到 PartySpot 的 y / 覆盖值[1])。
|
||
// 露营那条路径的取值顺序**一字未改**。
|
||
ay = danceSpotAnchor49 is { } dsY49
|
||
? dsY49.DanceSpot.Y
|
||
: (hasSpotCfg ? spotCfg.Spot.Y : anchorOv[1]);
|
||
baseAngle = (hasSpotCfg ? spotCfg.Rotation : 0f) * MathF.PI / 180f;
|
||
haveAnchor = float.IsFinite(ax) && float.IsFinite(ay) && float.IsFinite(az);
|
||
anchorNote = $"★服务端覆盖圆心({anchorSrc})";
|
||
why = $"Override=({ax:F2},{ay:F2},{az:F2})(y 来源={(hasSpotCfg ? "官方 PartySpot" : "覆盖值[1]")}," +
|
||
$"XZ 来源=覆盖值[0]/[2])朝向 rot={baseAngle * 180f / MathF.PI:F1}";
|
||
}
|
||
else if (danceSpotAnchor49 is { } danceSpot49)
|
||
{
|
||
// ★ 第 49 轮:舞会走官方舞池点(PartyDanceSpotConfig)。
|
||
ax = danceSpot49.DanceSpot.X;
|
||
ay = danceSpot49.DanceSpot.Y;
|
||
az = danceSpot49.DanceSpot.Z;
|
||
baseAngle = danceSpot49.DanceRotation * MathF.PI / 180f;
|
||
haveAnchor = true;
|
||
anchorNote = $"★官方舞池 DanceSpot(PartyDanceSpotConfig#{danceSpot49.Id}) rot={danceSpot49.DanceRotation:F1}";
|
||
why = $"PartyDanceSpotConfig#{danceSpot49.Id} party={danceSpot49.PartyId} music={danceSpot49.MusicId} " +
|
||
$"DanceSpot=({ax:F2},{ay:F2},{az:F2}) DanceRotation={danceSpot49.DanceRotation:F2}";
|
||
}
|
||
else if (hasSpotCfg)
|
||
{
|
||
ax = spotCfg.Spot.X;
|
||
ay = spotCfg.Spot.Y;
|
||
az = spotCfg.Spot.Z;
|
||
baseAngle = spotCfg.Rotation * MathF.PI / 180f;
|
||
haveAnchor = true;
|
||
anchorNote = $"配表 PartySpot rot={spotCfg.Rotation:F1}";
|
||
why = $"PartySpot=({ax:F2},{ay:F2},{az:F2}) rot={spotCfg.Rotation:F2} " +
|
||
$"raid={spotCfg.RaidId} maxNum={spotCfg.MaxNum}";
|
||
}
|
||
else if (GetStateIfExists(partySession) is { } worldState)
|
||
{
|
||
bool ready, hasPos;
|
||
float wx, wy, wz;
|
||
lock (worldState.SyncRoot)
|
||
{
|
||
ready = worldState.Ready;
|
||
hasPos = worldState.HasLastReportedPlayerTransform;
|
||
|
||
// ★★ 第 32 轮修正:锚点取值**照抄 GameRouter.Crowd.cs:96 的 CrowdAnchor()** ——
|
||
// `HasLastReportedPlayerTransform ? LastReportedPlayerPosition : WorldEntryCreateHeroPosition`
|
||
// 这才是服务端**自己的**「玩家现在大概在哪」的语义,不要自己另发明。
|
||
//
|
||
// 为什么第 31 轮「只要 hasPos 就够」还是落空:
|
||
// Movement 上报(GameRouter.Movement.cs:229)才会把该标志位置 true,
|
||
// 而玩家是**站在主城里操作手机派对面板**点「开始」的 ——
|
||
// 在此之前若**没有走动过**,标志位就一直是 false。
|
||
// 实测 console-20260926-005309.log:355:
|
||
// `[PARTYNPC] 跳过:拿不到玩家坐标(Ready=True HasReportedPos=False)`
|
||
// ⇒ 卡点不是 Ready(那次 Ready=True),而是「没走动 ⇒ 没有上报」。
|
||
//
|
||
// WorldEntryCreateHeroPosition 的兜底是**可靠的**:
|
||
// World.cs:132 `state.WorldEntryCreateHeroPosition = spawnPosition;`
|
||
// 而 spawnPosition 来自 config/player-progress.json 的 Position
|
||
// (仅当 `saved.HasSavedPosition && saved.SavedRaidId == Profile.RaidId` 时采用,
|
||
// 见 World.cs:104-114)⇒ 就是玩家**真实落点**,不是凭空 spawn。
|
||
if (hasPos)
|
||
{
|
||
wx = worldState.LastReportedPlayerPosition.X;
|
||
wy = worldState.LastReportedPlayerPosition.Y;
|
||
wz = worldState.LastReportedPlayerPosition.Z;
|
||
}
|
||
else
|
||
{
|
||
wx = worldState.WorldEntryCreateHeroPosition.X;
|
||
wy = worldState.WorldEntryCreateHeroPosition.Y;
|
||
wz = worldState.WorldEntryCreateHeroPosition.Z;
|
||
}
|
||
}
|
||
|
||
// ⚠ 第 31 轮:**不再要求 state.Ready**(玩家在手机面板点「开始」时世界常判未就绪,
|
||
// Ready 只在 World.cs:362 置 true、SceneSwitch.cs:148 置 false)。
|
||
// ★ 第 32 轮:**也不再要求 hasPos** —— 走 CrowdAnchor 的兜底。
|
||
ax = wx;
|
||
ay = wy;
|
||
az = wz;
|
||
haveAnchor = float.IsFinite(wx) && float.IsFinite(wy) && float.IsFinite(wz);
|
||
why = $"Ready={ready} HasReportedPos={hasPos} " +
|
||
$"source={(hasPos ? "reported" : "world-entry")} at=({wx:F2},{wy:F2},{wz:F2})";
|
||
}
|
||
else
|
||
{
|
||
why = "配表没有可用 PartySpot,且 session 里没有 WorldEntryState";
|
||
}
|
||
|
||
// ★★ 第 38 轮:NPC 的「预制动作」(POI)表 —— 由玩法类型选,配置可调。
|
||
// ⚠ 取列表用的是 **partyId**(不是 spotCfg)⇒ 即使落点回退到玩家锚点,
|
||
// 动作表也照样按配表的 GamePlayType 选得到。
|
||
var poiActionIds = PartyNpcPoiActionIdsFor(partyId, out var poiNote);
|
||
|
||
// 邀请名单只消费一次(重开派对换人);但结算推送还要用 ⇒ 存一份副本给 PartyOver。
|
||
var invitedForSettle = TakePartyInvitedNpcs(partySession);
|
||
partySession.Items[PartySettleInvitedKey] = invitedForSettle;
|
||
|
||
// ★★★ 第 49 轮:舞会 = **统一朝向官方 DanceRotation**(不朝圆心);
|
||
// 露营 = null(保持圆周 + 朝圆心,一行未改)。
|
||
// 优先级:官方舞池的 DanceRotation → 场地 PartyRotation(无舞池表时的合理回退,
|
||
// 至少保证「统一朝向」而不是各自朝圆心)→ null(连场地朝向都没有 ⇒ 回退露营圆周)。
|
||
float? unifiedFacing49 = null;
|
||
if (isDanceParty49)
|
||
{
|
||
// ★ 就地开局:
|
||
// · 散布模式(partyVenueScatterRadius > 0)⇒ 传 **null**,走「圆周 + 朝圆心」,
|
||
// 圆心就是玩家 ⇒ 随机撒开、全员面朝玩家。
|
||
// · 否则不要用官方 DanceRotation(那是对着官方舞池的朝向),改成「面朝玩家」,
|
||
// 否则舞者会集体背对玩家。
|
||
if (venueAtPlayerOk && gpCfg48.PartyVenueScatterRadius > 0f)
|
||
unifiedFacing49 = null;
|
||
else if (venueAtPlayerOk)
|
||
unifiedFacing49 = vpFacing + 180f;
|
||
else if (danceSpotAnchor49 is { } dsF49)
|
||
unifiedFacing49 = dsF49.DanceRotation;
|
||
else if (hasSpotCfg)
|
||
unifiedFacing49 = spotCfg.Rotation;
|
||
|
||
conn.Log.Info(
|
||
$"[PARTYNPC] 舞会摆法:partyId={partyId} gameplay={gameplayType49} " +
|
||
(unifiedFacing49 is { } fv49
|
||
? $"统一朝向={fv49:F2}°(来源={(danceSpotAnchor49 is not null ? "官方 DanceRotation" : "回退 PartyRotation")})、" +
|
||
"站位=舞池点横排(不绕圈、不朝圆心)"
|
||
: "拿不到任何官方朝向 ⇒ 回退露营圆周摆法"));
|
||
}
|
||
|
||
// ★★★ 第 50 轮(室内层判定):**官方锚点落在「从外面看不见的室内」时不许照露天刷。**
|
||
// 标本:舞会 PartySpot(2574.24, 80.135, 1271) / 舞池(2605.33, 80, 1269.74)
|
||
// 横向都在 IndoorConfig#150 "Spiko Hotel" 的占地里,官方字段
|
||
// VisibleOutside = false ⇒ 那间室内从外面看不见。
|
||
// 第 49 轮把 10 个 NPC 摆到 y=80 ⇒ 现场「角色全悬在空中」(用户 04:56/04:58 实拍)。
|
||
// ⇒ 玩家不在那间室内时**本次不落位**(邀请名单照旧保留,与「拿不到落点」同一条出路)。
|
||
// ★★★ 第 52 轮(B 方案):声明通道(gameplay.npcIndoorDeclaration,默认 true)生效时
|
||
// **这里不再拦** —— 照常落位,室内归属交给 GameRouter.Civilians.cs 的
|
||
// [INDOOR-DECLARE] 声明给客户端(室外看不见、进室内可见)。舞会才真开得起来。
|
||
// 本门只在声明通道关掉时兜底(= 第 50 轮行为)。
|
||
if (haveAnchor && gpCfg48.IndoorVisibilityGate && !gpCfg48.NpcIndoorDeclaration)
|
||
{
|
||
var gateRaid49 = hasSpotCfg ? spotCfg.RaidId : PrivateServerConfigStore.Current.World.RaidId;
|
||
if (IndoorRoomCatalog4229938.TryFindHiddenAt(ax, az, gateRaid49, out var hiddenRoom50))
|
||
{
|
||
var playerInside50 = false;
|
||
var gpx50 = float.NaN;
|
||
var gpz50 = float.NaN;
|
||
if (GetStateIfExists(partySession) is { } gateState50)
|
||
{
|
||
lock (gateState50.SyncRoot)
|
||
{
|
||
// 与 GameRouter.Crowd.cs:95 CrowdAnchor() 同一取值口径:
|
||
// 走过路就取上报值,否则取世界进入时的出生点。
|
||
if (gateState50.HasLastReportedPlayerTransform)
|
||
{
|
||
gpx50 = gateState50.LastReportedPlayerPosition.X;
|
||
gpz50 = gateState50.LastReportedPlayerPosition.Z;
|
||
}
|
||
else
|
||
{
|
||
gpx50 = gateState50.WorldEntryCreateHeroPosition.X;
|
||
gpz50 = gateState50.WorldEntryCreateHeroPosition.Z;
|
||
}
|
||
}
|
||
playerInside50 = hiddenRoom50.Contains(gpx50, gpz50);
|
||
}
|
||
|
||
if (playerInside50)
|
||
{
|
||
conn.Log.Info($"[INDOOR-GATE] partyId={partyId} 落点与玩家同在室内 " +
|
||
$"{hiddenRoom50.Name}(IndoorConfig#{hiddenRoom50.Id})⇒ 放行。");
|
||
}
|
||
else
|
||
{
|
||
conn.Log.Warn($"[INDOOR-GATE] partyId={partyId} 落点 ({ax:F2},{ay:F2},{az:F2}) 位于" +
|
||
$"**从外面看不见的室内**:{hiddenRoom50.Describe()};" +
|
||
$"玩家不在其中(玩家 xz=({gpx50:F1},{gpz50:F1}))⇒ **本次不落位**。" +
|
||
$" 想强行在露天摆:① gameplay.indoorVisibilityGate=false," +
|
||
$" 或 ② partyNpcAnchorOverrides[\"{partyId}\"] 给一个露天坐标。");
|
||
ax = 0f;
|
||
ay = 0f;
|
||
az = 0f;
|
||
haveAnchor = false;
|
||
why = $"室内层判定拦下:落点在不可见室内 {hiddenRoom50.Name}" +
|
||
$"(IndoorConfig#{hiddenRoom50.Id},VisibleOutside=false)";
|
||
}
|
||
}
|
||
}
|
||
|
||
// ★★★ 第 53 轮:**派对 NPC 的服装**(用户现场「选择后开始露营显示的还是默认服装」)。
|
||
//
|
||
// 露营这条路上客户端送不出玩家选的套装(PartyConfig#13030000.FashionsCanRent = [] ⇒
|
||
// RentMode 面板租借列表恒空 ⇒ fashionsInfo 只可能是 {UseDefaultFashion=true, Rents={}} 或 nil)
|
||
// ⇒ 服务端**必须自己按官方配表决定每个人穿哪套**:
|
||
// ① 客户端真给了 Rents[spiritId] ⇒ FashionRentConfig[rentCfgId].FashionSuitId
|
||
// ② 否则按 FashionSpiritDressCodeConfig 选该角色在 PartyConfig.SuitTag 下的原皮
|
||
// (RecommendPriority 最小者;查不到就放宽到任意 tag)
|
||
// ③ 都查不到 ⇒ 不指定 ⇒ 客户端回落到旧值 11190001
|
||
// 详见 Ananta.Core\ClientData\Client4229938\PartyFashionCatalog4229938.cs 的头部说明。
|
||
var (suitByNpc, suitNotes) = ResolvePartyNpcSuits(partyId, invitedForSettle, partyArgs?.fashionsinfo);
|
||
foreach (var line in suitNotes)
|
||
conn.Log.Info($"[PARTY-FASHION] {line}");
|
||
|
||
spawnNote = haveAnchor
|
||
? await SpawnPartyNpcsAsync(
|
||
partySession, invitedForSettle, ax, ay, az, baseAngle, anchorNote, poiActionIds, unifiedFacing49,
|
||
suitByNpc,
|
||
// ★ 就地散布:只有「就地开局 + 半径 > 0」才传,其余情况恒 null(零改动)。
|
||
venueAtPlayerOk && gpCfg48.PartyVenueScatterRadius > 0f
|
||
? gpCfg48.PartyVenueScatterRadius
|
||
: (float?)null)
|
||
: $"[PARTYNPC] 跳过:拿不到落点({why})⇒ 邀请名单保留,等下一次再落" +
|
||
$"(预制动作={poiNote} 已随名单留待下一次)";
|
||
|
||
conn.Log.Info($"[PARTYNPC] 预制动作选择:{poiNote}");
|
||
}
|
||
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
|
||
conn.Log.Info(
|
||
$"[PARTY] StartSingleParty partyId={partyId} body={bodyLen}b -> Ok;已推 SyncPartyStart " +
|
||
$"partyOverTime={partyOverTime}(+{durationSeconds}s) lotteryStartTime={lotteryStartTime}(+{durationSeconds - PartyLotteryWindowSeconds}s)" +
|
||
$"(客户端自行开 PARTY_HUD_PANEL;单机无抽奖区,lotteryStartTime 仅为联机语义保留)");
|
||
conn.Log.Info(spawnNote);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 算出「本次派对每个受邀 NPC 该穿哪一套」(2026-09-26 第 53 轮)。
|
||
/// 返回 <c>NpcCultivationId → FashionSuitConfig.Id</c>(查不到的角色**不入表** ⇒ 走旧兜底)。
|
||
///
|
||
/// <para>
|
||
/// 为什么必须由服务端决定(全部有实证,不是推断):
|
||
/// <list type="number">
|
||
/// <item>客户端在**露营**这条路上送不出玩家选的服装 —— <c>PartyConfig#13030000.FashionsCanRent</c>
|
||
/// 是空数组(只有舞会 #13030003 配了 <c>[11199901, 11199900]</c>),
|
||
/// <c>ChangeFashionPanelStore_RentMode.lua:555 BuildRentSuitList</c> 的租借列表因此恒空,
|
||
/// <c>rentMap</c> 恒空 ⇒ 「自行搭配」分支产出 <c>fashionsInfo = nil</c>。</item>
|
||
/// <item>实测三条 C2S 报文(30 字节,逐字节相同)是
|
||
/// <c>{Rents={}, UseDefaultFashion=true}</c> ⇒ 玩家点的是「使用默认套装」。</item>
|
||
/// <item>两条出路都只能落到「服务端自己按配表选」⇒ 本函数。</item>
|
||
/// </list>
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// 取值优先级:
|
||
/// <list type="number">
|
||
/// <item><b>客户端 Rents</b>(玩家明确选的租赁套装,露营送不出、舞会能送):
|
||
/// <c>Rents[spiritId] = FashionRentConfig.Id</c> ⇒ 再跳 <c>FashionSuitId</c>。</item>
|
||
/// <item><b>该角色的 DressCode 原皮</b>:<c>FashionSpiritDressCodeConfig</c> 里
|
||
/// <c>SpiritId</c> 相同、<c>TagList</c> 含 <c>PartyConfig.SuitTag</c>(露营 = 1)、
|
||
/// <c>RecommendPriority</c> 最小的那条。⛔ 放宽规则:18 个角色里只有 15021040 有
|
||
/// <c>TagList ∋ 2</c> 的条目 ⇒ 舞会(SuitTag = 2)必须能退到「任意 tag 最优」。</item>
|
||
/// <item>都查不到 ⇒ 不入表 ⇒ <c>SuitId = 0</c> ⇒ 客户端回落 11190001(= 改动前行为)。</item>
|
||
/// </list>
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// 关掉本功能的官方开关:<c>gameplay.partyNpcFashion = "off"</c>
|
||
/// (见 <c>PrivateServerConfig.GameplaySettings.PartyNpcFashion</c>)。
|
||
/// </para>
|
||
/// </summary>
|
||
private static (Dictionary<uint, uint> Suits, List<string> Notes) ResolvePartyNpcSuits(
|
||
uint partyId, IReadOnlyList<uint> npcCultivationIds, PartyNpcFashionsInfo4229938? fashions)
|
||
{
|
||
var suits = new Dictionary<uint, uint>();
|
||
var notes = new List<string>();
|
||
|
||
var mode = PrivateServerConfigStore.Current.Gameplay.PartyNpcFashion;
|
||
if (!string.Equals(mode, "dresscode", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
notes.Add($"服装来源=off(gameplay.partyNpcFashion=\"{mode}\")⇒ 保持旧行为:" +
|
||
"SuitId=0 ⇒ 客户端把每个角色都落到 11190001(Bansy 的涂鸦套装)");
|
||
return (suits, notes);
|
||
}
|
||
|
||
var suitTag = PartyFashionCatalog4229938.SuitTagOf(partyId);
|
||
var rentCount = fashions?.Rents.Count ?? 0;
|
||
notes.Add($"partyId={partyId} SuitTag={suitTag} 客户端服装参数=" +
|
||
(fashions is null
|
||
? "null(自行搭配但一件未选)"
|
||
: $"UseDefaultFashion={fashions.UseDefaultFashion} Rents={rentCount} 条"));
|
||
|
||
foreach (var npcId in npcCultivationIds)
|
||
{
|
||
var spiritId = NpcCultivationCatalog4229938.Cultivations
|
||
.FirstOrDefault(e => e.Id == npcId)?.FightSpiritId ?? 0u;
|
||
if (spiritId == 0)
|
||
{
|
||
notes.Add($"npc={npcId} 查不到 FightSpiritId ⇒ 不指定服装");
|
||
continue;
|
||
}
|
||
|
||
// ① 客户端明确选的租赁套装(目前只有舞会那条路送得出来)。
|
||
if (fashions is not null && fashions.Rents.TryGetValue(spiritId, out var rentCfgId) && rentCfgId != 0)
|
||
{
|
||
if (PartyFashionCatalog4229938.TryRentSuit(rentCfgId, out var rented) && rented != 0)
|
||
{
|
||
suits[npcId] = rented;
|
||
notes.Add($"npc={npcId} spirit={spiritId} → suit={rented}「{PartyFashionCatalog4229938.SuitNameOf(rented)}」" +
|
||
$" 来源=客户端 Rents(rentCfg={rentCfgId})");
|
||
continue;
|
||
}
|
||
|
||
notes.Add($"npc={npcId} spirit={spiritId} 客户端给了 rentCfg={rentCfgId},但 FashionRentConfig 里查不到" +
|
||
" ⇒ 退到 DressCode");
|
||
}
|
||
|
||
// ② 该角色在「本玩法标签」下的原皮(RecommendPriority 最小)。
|
||
if (PartyFashionCatalog4229938.TryDressCodeSuit(spiritId, suitTag, out var dcSuit) && dcSuit != 0)
|
||
{
|
||
suits[npcId] = dcSuit;
|
||
notes.Add($"npc={npcId} spirit={spiritId} → suit={dcSuit}「{PartyFashionCatalog4229938.SuitNameOf(dcSuit)}」" +
|
||
$" 来源=DressCode(TagList∋{suitTag} 中 RecommendPriority 最小者)");
|
||
continue;
|
||
}
|
||
|
||
notes.Add($"npc={npcId} spirit={spiritId} FashionSpiritDressCodeConfig 无记录 ⇒ 不指定" +
|
||
"(客户端回落 11190001)");
|
||
}
|
||
|
||
notes.Add($"结果:{suits.Count}/{npcCultivationIds.Count} 人指定了套装(0 人 = 全部走旧兜底 11190001)");
|
||
return (suits, notes);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// 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 即可。
|
||
// ---------------------------------------------------------------------------------------
|
||
[Handler(MethodId.PartyOver, HandlerPacketKind.Invoke)]
|
||
private async Task PartyOver4229938(Connection conn, UxRpcMessage msg)
|
||
{
|
||
// ⚠ PartyOver 无参数。这里只做长度**告警**,绝不尝试解析 body。
|
||
if (msg.Body is { Length: > 0 })
|
||
{
|
||
conn.Log.Warn($"[PARTY] PartyOver 收到 {msg.Body.Length}b body(客户端 writer 是空体,预期 0b)" +
|
||
"—— 按「无参数」处理,多出的字节忽略。");
|
||
}
|
||
|
||
var session = msg.Context?.Session;
|
||
if (session is null)
|
||
{
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
conn.Log.Warn("[PARTY] PartyOver 没有 session ⇒ 只回 Ok,推不出结算数据");
|
||
return;
|
||
}
|
||
|
||
uint partyId = 0;
|
||
if (session.Items.TryGetValue(PartySingleIdSessionKey, out var rawPartyId) && rawPartyId is uint stored)
|
||
partyId = stored;
|
||
|
||
// 邀请名单:StartSingleParty 存的那份(可能为空 —— 例如还没落场就退出)。
|
||
var invitedNpcs = session.Items.TryGetValue(PartySettleInvitedKey, out var rawInvited)
|
||
&& rawInvited is List<uint> invited
|
||
? invited
|
||
: [];
|
||
|
||
if (partyId == 0)
|
||
{
|
||
// 没开过派对就点结束(或 partyId 丢失):按铁律「沉默 + 打日志」,仍然回 Ok。
|
||
conn.Log.Warn("[PARTY] PartyOver 时 session 里没有 partyId(PartySinglePartyId 未写)" +
|
||
"⇒ 不推 SyncPartySettleData,只回 Ok。客户端会关掉 PARTY_HUD_PANEL,但不会有结束演出。");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
var settle = new PartySettleDataPush
|
||
{
|
||
HostPid = Profile.PlayerPid,
|
||
PartyId = partyId,
|
||
// 单机派对没有观众/人气/礼物这些联机指标 ⇒ 全部 0。
|
||
// ⚠ 不要回填哨兵值(铁律 4):0 在这里是**真实语义**(真的一分没有),
|
||
// 而且 PartyEndPanelStore.RefreshSingleView 只读 PartyId / Drop / Popularity 三个字段。
|
||
Popularity = 0,
|
||
LikeCount = 0,
|
||
GiftCount = 0,
|
||
CommentCount = 0,
|
||
AudienceCount = 0,
|
||
WinGameCount = 0,
|
||
TaskCount = 0,
|
||
InviteFriendCount = 0,
|
||
// Drop = 0 ⇒ 结束面板走「无奖励列表」分支(RefreshSingleView:137 判 Drop)。
|
||
Drop = 0,
|
||
LikeList = [],
|
||
GiftList = [],
|
||
InviteNPCList = [.. invitedNpcs],
|
||
// 联机字段照样要序列化(wire 形状固定),置零即可;单机路径不读它。
|
||
OnlineSettleResult = new PartyOnlineSettleResultPush
|
||
{
|
||
FashionStarPid = Profile.PlayerPid,
|
||
SuperGamerPid = Profile.PlayerPid,
|
||
PopularityKingPid = Profile.PlayerPid,
|
||
SettleList = [],
|
||
},
|
||
};
|
||
|
||
await conn.NotifyAsync(MethodId.SyncPartySettleData, settle);
|
||
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
|
||
conn.Log.Info(
|
||
$"[PARTY] PartyOver -> 已推 SyncPartySettleData(68990488) partyId={partyId} " +
|
||
$"host={Profile.PlayerPid} invitedNpc={invitedNpcs.Count} 人 ⇒ " +
|
||
"客户端 OnSyncSettleData 播 play_party_end(loadFromLocal) + 开 PARTY_END_PANEL;随后回 Ok。");
|
||
|
||
// ★ 第 46 轮:派对结束 ⇒ 把场上这批 NPC 撤掉 + 停掉驻留行为循环。
|
||
// 在此之前**没有任何一处**撤实体:客户端 `PartyManager.PreparePartyEnd`(:732-736)
|
||
// 只 `Close(PARTY_HUD_PANEL / PARTY_NPC_INVITE / …)`;服务端也别处不管
|
||
// ⇒ 派对结束后角色永久留在原地(用户反馈「派对结束后角色一直不走」)。
|
||
// ⛑ 放在 `ReturnEmptyOkAsync` **之后**:撤人失败也不能拖垮「结束」这条主链路。
|
||
try
|
||
{
|
||
var removed = await RemovePlacedPartyNpcsAsync(session);
|
||
ResetPartyNpcRoam(session);
|
||
if (removed > 0)
|
||
{
|
||
conn.Log.Info($"[PARTYNPC] PartyOver ⇒ 撤掉 {removed} 人;驻留行为循环已复位");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
conn.Log.Warn($"[PARTYNPC] PartyOver 撤人失败(不影响结束主链路):{ex.GetType().Name}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// InviteMultiNpcChat (63447141) — args: u32 groupChatId, list<u32> npcIdList ; return: **void**
|
||
//
|
||
// ★ 为什么必须实现这条(2026-09-25 第 25 轮,逐行核对):
|
||
// 单人派对的 NPC 名单**不是** AskSimulationInviteNpc 给的,而是
|
||
// NpcChatManager.lua:182 local multiNpcTidList = gNpcChatManager.groupInviteList
|
||
// NpcChatManager.lua:216-217(反编译 ~= 取反为 ==)
|
||
// elseif gameplay == Party|PartyGuide|DanceParty then
|
||
// gPartyManager:StartSingleParty(singlePartyId, multiNpcTidList)
|
||
// 而 groupInviteList 只在「NPC 群聊邀请面板」勾选时填(NpcChatInvitePanelStore.lua:340),
|
||
// 提交时发的正是本 RPC(:389)。单人邀请 AskSimulationInviteNpc **不写**这个名单
|
||
// ⇒ 实测开局包 hex `70 D2 C6 00 | FF 01 | ...` 里名单是非空的空表 {} ⇒ 派对里没人。
|
||
//
|
||
// ★★ 第 33 轮更新(原「刻意不解析客户端名单」的两个理由**已作废**):
|
||
// 原文理由 1「该 RPC 从未被触发过 ⇒ 无真报文可互证,硬解 List7Bit 必踩铁律 5」
|
||
// ⇒ **已不成立**:第 31 轮起该 RPC 被真实触发,且三次报文(10b / 14b / 26b)
|
||
// 可用同一条规则解释(见下)。而且**根本不需要手解** ——
|
||
// UxSerializer 原生支持 List,只要契约写对(InviteMultiNpcChatArgs4229938)。
|
||
// 原文理由 2「groupInviteList 里存的是 itemData.subChannelId(聊天频道 id),
|
||
// 与 NpcCultivationConfig 的 Tid 是否同一回事未证实」
|
||
// ⇒ **已被实测报文直接推翻**:客户端发来的 5 个 id
|
||
// (91050014 / 91050020 / 91050023 / 91050030 / 91050019)**全部**命中
|
||
// PartyNpcAgentByCultivationId 的键 ⇒ 就是 NpcCultivationId。
|
||
//
|
||
// 仍然成立的那半句::217 用的是**客户端自己那份** groupInviteList,
|
||
// 所以 SyncNpcChatLeaveGameplay 推谁只起「把流程踢下去」的作用
|
||
// ⇒ 本 handler 的**推送行为保持不变**,只把「服务端记的邀请名单」换成客户端真实名单
|
||
// (那是 StartSingleParty 里 spawn 的依据,两件事不要混)。
|
||
//
|
||
// ⇒ 做三件事:解析客户端名单(新增)→ 记进 session → 回空 Ok + 推 SyncNpcChatLeaveGameplay。
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// groupChatId → gameplay。来源(非猜):`NpcCultivationGameplayTypeConfig.json` 的
|
||
/// `MultiplayerGameplayFirstNpcChatId` 字段,客户端同一张表反查见
|
||
/// `NpcChatInvitePanelStore.lua:397 GetGroupInviteChatId`。
|
||
/// 全表 30 条里只有 6 条有值,其中 84009000 被 84002011 与 91056006 **共用**(有歧义),
|
||
/// 故此处只登记派对三条(无歧义、且与 NpcChatManager:216 的派对分支一一对应)。
|
||
/// </summary>
|
||
private static readonly Dictionary<uint, uint> GroupChatIdToGameplay = new()
|
||
{
|
||
[84047200] = NpcCultivationGameplayTypeParty, // 91055000 露营派对
|
||
[84050786] = NpcCultivationGameplayTypePartyGuide, // 91056001
|
||
[84050624] = NpcCultivationGameplayTypeDanceParty, // 91056002 舞会
|
||
};
|
||
|
||
[Handler(MethodId.InviteMultiNpcChat, HandlerPacketKind.Invoke)]
|
||
private async Task InviteMultiNpcChat4229938(Connection conn, UxRpcMessage msg)
|
||
{
|
||
int bodyLen = msg.Body?.Length ?? 0;
|
||
uint groupChatId = 0;
|
||
if (msg.Body is { Length: >= 4 })
|
||
{
|
||
groupChatId = BitConverter.ToUInt32(msg.Body, 0);
|
||
}
|
||
|
||
if (groupChatId == 0 || !GroupChatIdToGameplay.TryGetValue(groupChatId, out var gameplay))
|
||
{
|
||
// 仍然回 Ok —— 不回的话客户端 .Callback 永远挂着(铁律 4)。
|
||
conn.Log.Warn(
|
||
$"[NPCINVITE] InviteMultiNpcChat groupChatId={groupChatId} 未登记(body={bodyLen}b)-> 只回 Ok,不推送。" +
|
||
$"已登记:84047200=Party / 84050786=PartyGuide / 84050624=DanceParty");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
var candidates = InvitableNpcByGameplay.TryGetValue(gameplay, out var list) && list.Length > 0
|
||
? list
|
||
: BowlingInvitableNpcCultivationIds;
|
||
|
||
// ★★ 第 33 轮:**解析客户端真正勾选的名单**(契约见 PartyWireContracts.cs)。
|
||
// 第 32 轮实测「塔菲出现了,但我还选了其他角色,没有出现」的根因就在这里 ——
|
||
// 本 handler 原来**刻意不解析客户端名单**,只用服务端游标 candidates[cursor],
|
||
// 于是无论玩家勾了几个,名单里永远只有 1 个(且永远 candidates[0] = 塔菲)。
|
||
// 实测报文 hex(packets-20260926-005704.log:15255,body=26b):
|
||
// 60 75 02 05 | FF 06 | 1E 50 6D 05 24 50 6D 05 27 50 6D 05 2E 50 6D 05 23 50 6D 05
|
||
// ⇒ 客户端确实把 5 个 NpcCultivationId 都发过来了(FF 06 = marker + (5+1))。
|
||
var invitedFromClient = new List<uint>();
|
||
if (msg.TryGetArgs<InviteMultiNpcChatArgs4229938>(out var parsedArgs)
|
||
&& parsedArgs?.npcIdList is { Count: > 0 } fromClient)
|
||
{
|
||
invitedFromClient.AddRange(fromClient);
|
||
}
|
||
|
||
// 与 AskSimulationInviteNpc 共用游标规则(按 gameplay 分开),避免每次都同一个人。
|
||
// ⚠ 第 33 轮起:游标**只**用于回退分支和 SyncNpcChatLeaveGameplay 的 npc 字段
|
||
// (该字段只是「把流程踢下去」的信号,客户端开局用的是它自己那份 groupInviteList)。
|
||
string cursorKey = $"{NpcInviteCursorKey}-{gameplay}";
|
||
int cursor = 0;
|
||
if (msg.Context?.Session.Items.TryGetValue(cursorKey, out var raw) == true && raw is int stored && stored >= 0)
|
||
{
|
||
cursor = stored % candidates.Length;
|
||
}
|
||
|
||
uint npcCultivationId = candidates[cursor];
|
||
if (msg.Context is not null)
|
||
{
|
||
msg.Context.Session.Items[cursorKey] = (cursor + 1) % candidates.Length;
|
||
|
||
// ★★ 第 31 轮修正:**这里**才是「玩家勾完人、点了提交」的那一刻。
|
||
// 第 30 轮我误把这段加在 AskSimulationInviteNpc4229938 里 —— 那是**死代码**:
|
||
// 派对三条 gameplay 在那个 handler 里推完 SyncNpcChatInvite 就 `return` 了,
|
||
// 后面的代码永远到不了。实测症候正是「[PARTYNPC] 邀请名单为空 ⇒ 不生成」。
|
||
// (教训:插代码前先确认自己插进了哪个 [Handler] 的哪个分支 ——
|
||
// 同一文件里有三处长得几乎一样的 `candidates[cursor]` 游标块。)
|
||
if (gameplay is NpcCultivationGameplayTypeParty
|
||
or NpcCultivationGameplayTypePartyGuide
|
||
or NpcCultivationGameplayTypeDanceParty)
|
||
{
|
||
if (invitedFromClient.Count > 0)
|
||
{
|
||
foreach (var id in invitedFromClient)
|
||
{
|
||
RememberPartyInvitedNpc(msg.Context.Session, id);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// 解析失败兜底:宁可只记 1 个,也不要名单为空(空 ⇒ StartSingleParty 不生成任何人)。
|
||
conn.Log.Warn(
|
||
$"[NPCINVITE] InviteMultiNpcChat 未能解析出 npcIdList(body={bodyLen}b)" +
|
||
$"⇒ 回退服务端游标,只记 {npcCultivationId} 一个");
|
||
RememberPartyInvitedNpc(msg.Context.Session, npcCultivationId);
|
||
}
|
||
}
|
||
}
|
||
|
||
await conn.NotifyAsync(MethodId.SyncNpcChatLeaveGameplay, new SyncNpcChatLeaveGameplayPush
|
||
{
|
||
NpcOrNpcGroup = npcCultivationId,
|
||
Gameplay = gameplay,
|
||
});
|
||
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
|
||
conn.Log.Info(
|
||
$"[NPCINVITE] InviteMultiNpcChat groupChatId={groupChatId} -> gameplay={gameplay} " +
|
||
$"-> pushed SyncNpcChatLeaveGameplay npc={npcCultivationId};" +
|
||
$"客户端勾选名单 count={invitedFromClient.Count} [{string.Join(",", invitedFromClient)}]");
|
||
}
|
||
}
|