Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.NpcInvite.cs
T

1460 lines
90 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Ananta.SDK.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&lt;byte&gt;(); 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&lt;u32&gt;
//
// 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 &gt; 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,
// ★ 就地散布:只有「就地开局 + 半径 &gt; 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)}]");
}
}