using System.Buffers.Binary; using Ananta.SDK.Logging; using Ananta.SDK.Rpc; using System.Threading; // ★ 刮刮乐:CancellationToken using Ananta.SDK.Network; // ★ 刮刮乐:TcpSession(PushScratchStartAsync 收 session) using Ananta.SDK.Serialization; // ★ 刮刮乐:UxSerializer.Serialize using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; // ★ 刮刮乐:ScratchWire / ScratchLevelOneStartResult namespace Ananta.Server.Handlers.Game; /// /// ★★★ 龙栖村摊位小游戏「能玩」的入口 RPC 全集(第 90 轮,2026-09-27)。 /// /// ── 为什么是这些(而不是别的)──────────────────────────────────────────── /// 用户原话:「把能玩的小游戏都补全了,我一起测试,一次一次测太麻烦了」 /// /// 上一轮(R89-b)已确认:摊位**布景**我方已能一次全刷(`GameRouter.MiniGameStall.cs`), /// 但「看得见摊位」≠「摊位能玩」。能玩要各自的小游戏入口 RPC。 /// 对照包 Lyco-v0.2.1 的自我定性(原文): /// 「服务端不接 ⇒ 客户端 await 不到回执,模态界面永久等待 → 只能大退」 /// 「龙栖村这些小游戏(套圈/五子棋/刮刮乐/气枪/跳格子/踢毽子/炒茶…)本质不是各自独立, /// 而是同一套 `PoiGame` 配置框架 + 统一的「组队对局」入口子系统」 /// ⇒ 所以本文件把「缺失的入口」一次补齐,用户一轮测完。 /// /// ── ★ 协议形状的第一现场(铁律 31;不要凭 dump.cs 猜)────────────────────── /// ① C2S 逐字段布局:`lua/lua/LuaGen/AutoGen/ClientToGameSceneDelegate.lua` /// (Party 那三条在 `ClientToGameDelegate.lua`,因为走的是 Game 而非 GameScene 连接) /// 形如 `SerializerHelper.<方法>_Serializer = function(writer, ...)`,逐行 `WritePrimitive/WriteStruct`。 /// ② INVOKE 返回值:`RPCDeserializeAuto.lua` 的 `midToReturnMessageReader[mid]` /// —— **函数体为空 = void**;只有 `EnterRingTossZone` 回**裸 u64**、`QueryGomokuPlayerInfo` 回 complex。 /// /// ── 逐个的判据与处置(全部实测自上面两个文件)─────────────────────────── /// /// 【套圈 RingToss】mid = EnterRingTossZone **67108425** / RecordRingTossResult **67649660** / LeaveRingToss **67046402** /// C2S: EnterRingTossZone = u64 gadgetuid (无 0xFF 标记,裸 primitive) /// RecordRingTossResult = u64 gadgetuid + WriteStruct(RingTossThrowResult) /// LeaveRingToss = u64 gadgetuid + bool abort /// RET: EnterRingTossZone → **裸 u64 = pid**(客户端 `self.myPid = pid` 只在非 0 时才赋值) /// RecordRingTossResult → void ;LeaveRingToss → void /// ★ 套圈**不需要任何推送**:计分全在客户端本地 /// (`LX6/MiniGame/RingToss/RingTossNetMgr.lua` 202 行,是该目录**唯一**文件 /// ⇒ 纯本地玩法的指纹;`RING_TOSS_SINGLE_RESULT` → `OnLocalThrowSettled` → 本地算 rewardDropId, /// 再 `RecordRingTossResult` 上报;服务端若要「纠正」才回 `SyncGameGroundZoneInfo`)。 /// ⇒ 三个 handler + 一个非零 pid 即可开玩。 /// /// 【组队对局入口 PartyMiniGameMatch】mid = AskStart **63517279** / AskConfirm **63947581** / AskCancel **63953381** /// C2S: AskStart = u32 gameconfigid /// AskConfirm = u32 gameconfigid + bool confirmed /// AskCancel = u32 gameconfigid /// RET: 三条**全是 void** /// ★ 这是龙栖村所有摊位的**公共入口**(对照包唯一与我方有差异的三条), /// 缺了它 = 按 F 之后模态永久等待 = 只能大退。优先级最高。 /// ⚠️ 提醒:回 void = 客户端以为「已受理」,但真正开局还需要服务端推 /// `SyncPartyMiniGameMatchRoomReady(68130087)` 那一族(对照包也只做到「接住」,没推)。 /// 本轮先解「硬卡死」;「对局内同步」是更深一层。 /// /// 【刮刮乐 Scratch】mid = AskStart **67062589** / AskLose **67829420** / AskFinish **67996302** /// C2S: AskStart = u64 gadgetid + **byte** scratchsubtype(`CheckEnum(subtype, 52, 1)` ⇒ 枚举上限 52、默认 1) /// AskLose = u64 gadgetid /// AskFinish = u64 gadgetid + u32 rewardmoney /// RET: 三条**全是 void** /// ⛔ 这三个在 2349 个 `.lua` 里 **0 命中**(`AskStartScratchGame` 无调用者) /// ⇒ 入口不在 Lua 层(IL2CPP / Spoon),但**协议形状**在 AutoGen 里是确定的,照它实现。 /// /// 【五子棋 Gomoku】mid = EnterGomokuZoneDoublePlayer **67377693** / PlaceGomokuPiece **67496058** / LeaveGomoku **67832026** /// C2S: EnterDoublePlayer = u64 gadgetuid + bool canuseskill /// PlaceGomokuPiece = u64 gadgetuid + u32 x + u32 y /// LeaveGomoku = u64 gadgetuid /// RET: 三条**全是 void** /// 另有 QueryGomokuPlayerInfo(67225667) = **无参**,RET 是 complex `Auto.Reader[676]` /// (两个 `Dictionary`:DroppedEndGameIdSet / DroppedDoubleAIDifficultySet…) /// —— 本轮**不实现它**:返回值的字段序还没验,硬造一个空字典反而可能 deserialize failed。 /// 留作 R91,且那时必须用「两次序列化字节一致 + 尾部探针」证明形状(铁律 39)。 /// /// 【炒茶 / 炒栗 FryChestnut】mid = AskStartFryChestnutGame **67949200** / AskFinishChestnutGame **67543164** / AskFarmerFry **63258925** /// C2S: AskStartFryChestnutGame = u64 gadgetid + **byte** frytype(`CheckEnum(frytype, 91, 0)`) /// AskFinishChestnutGame = u64 gadgetid + **float** fryscore(`writer.WriteSingle`) /// AskFarmerFry = u32 ruleid + u32 count ← ★ 走 **ClientToGameDelegate**(Game 连接) /// RET: AskStartFryChestnutGame → **void** /// AskFinishChestnutGame → ★★ **裸 byte**(`local r0 = reader.ReadByte(reader)`) /// AskFarmerFry → ★★ **Dict7Bit<u32,u32>**(`Base.ReadDict7Bit(reader, ReadUInt32, ReadUInt32)`) /// ★ 这是本文件**唯一两组「不是 void」的返回**,必须 `ReturnRawAsync` 手拼载荷(不能泛型、不能空 ok)。 /// ★ 炒茶/炒栗**确实需要服务端**:`FryTeaPanelStore.lua` 明调 /// `gClientToGameSceneDelegate:AskFinishChestnutGame(gadgetid, result)` 与 `gClientToGameDelegate:AskFarmerFry(ruleId, count)` /// —— 与套圈(纯本地)/ 毽子(纯客户端,零 RPC)相反。 /// /// 【被排除的(不要顺手加)】────────────────────────────────────────────── /// • 毽子 Shuttlecock:`ShuttlecockStartPanelStore.lua` 调 `game.StartGame(game)`,**零 RPC** ⇒ 不需要 handler。 /// 面板齐全(StartPanel / PlayPanel / FinalPanel / QteBtn)。 /// • `QueryGomokuPlayerInfo(67225667)`:RET 是 complex `Auto.Reader[676]`(两个 `Dictionary`), /// 字段序未验 ⇒ 留 R91,本轮硬造可能 deserialize failed。 /// • `SyncPartyMiniGameMatch*` 推送族:那是「首包 ACK 之后」的第二层,本轮只解硬卡死。 /// /// ── 判空规律(本文件安全性依据)────────────────────────────────────────── /// ★★ 修正(R90 收尾)★★ 原先写的「除 EnterRingTossZone 外全是 void」**是错的**: /// 实测 `AskFinishChestnutGame` 回**裸 byte**、`AskFarmerFry` 回 **Dict7Bit**。 /// ⇒ 现规律是:**只有这三个需要手拼返回载荷** —— `EnterRingTossZone`(裸 u64)、 /// `AskFinishChestnutGame`(裸 byte)、`AskFarmerFry`(空字典 `FF 01`); /// 其余全部是 void,`ReturnEmptyOkAsync` 就是客户端期望的 `err=0` 空应答。 /// /// ⛑ 回滚:把本文件涉及的 mid 从 `GameRouter.cs` 的 `Enabled4229938MethodIds` 删掉即可 /// (handler 不注册 = 走 typed-default 兜底),不用删本文件。 /// 更细的回滚粒度见 miniGameStalls.stallHandlers 三个开关(见下)。 /// internal sealed partial class GameRouter { /// /// 套圈进场返回的 pid。客户端只判「非 0」就 `self.myPid = pid`,之后本地计分不再用服务端。 /// 取 1 而非随机数:固定值更利于日志对齐,且 0 会被客户端当成失败。 /// private const ulong RingTossLocalPid = 1UL; /// /// 炒栗结算回给客户端的「结果码」(`AskFinishChestnutGame` 的裸 byte 返回值)。 /// 0 = 服务端不做纠正,客户端按自己算出的结果收尾(与套圈「回本地 pid」同策略)。 /// ⚠️ 若实测面板卡住,先改 `config/private-server.json` 的 /// `world.content.miniGameStalls.chestnutFinishResult`,不用改这里的默认值。 /// private const byte ChestnutFinishResult = 0; // ───────────────────────────────────────────────────────────────────── // ① 套圈 RingToss —— 三件套,其中 Enter 回裸 u64 // ───────────────────────────────────────────────────────────────────── /// /// EnterRingTossZone(67108425): C2S = u64 gadgetuid;RET = **裸 u64 pid**。 /// /// ⛔ 必须用 `ReturnAsync(byte[])` 手工拼 8 字节小端 u64,**不能** `ReturnAsync(ulong)`: /// 泛型路径会把顶层再套一层 `0xFF + Int7(len)`,客户端按裸 u64 读就走位。 /// 这与气球里 `NotifyRawAsync` 的理由同源(见 GameRouter.Balloon.cs)。 /// [Handler(MethodId.EnterRingTossZone)] private Task EnterRingTossZoneAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; if (body is not { Length: >= 8 }) { conn.Log.Warn($"[RINGTOSS] EnterRingTossZone short body {body?.Length ?? 0}b -> empty ok"); return conn.ReturnEmptyOkAsync(msg); } var gadgetUid = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)); var pid = RingTossLocalPid; var payload = new byte[8]; BinaryPrimitives.WriteUInt64LittleEndian(payload, pid); conn.Log.Info($"[RINGTOSS] EnterRingTossZone gadgetUId={gadgetUid} -> pid={pid} " + $"(本地计分玩法,服务端无需推送)"); // ⛔ 必须 ReturnRawAsync(裸 8 字节),不能用泛型 ReturnAsync: // UxContract.WriteBuffer 对 byte[] 会写 `FF` + Int7(len) + raw ⇒ 多出 2 字节前缀, // 客户端按「裸 u64」读就会走位(与下面 AskFarmerFry 空字典同一个坑,见该文件末注释)。 // donor 原文这里用的是 ReturnAsync(msg, payload),与其注释里「必须手拼」自相矛盾 ⇒ 已修正。 return conn.ReturnRawAsync(msg, payload); } /// /// RecordRingTossResult(67649660): C2S = u64 gadgetuid + WriteStruct(RingTossThrowResult);RET = void。 /// 载荷:ThrowIndex(int) + RewardDropId(?) —— 只记日志,不参与判定(本地玩法,服务端只做记账)。 /// [Handler(MethodId.RecordRingTossResult)] private Task RecordRingTossResultAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; if (body is { Length: >= 8 }) { var gadgetUid = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)); var rest = body.Length - 8; // ThrowIndex / RewardDropId 都是 4 字节档;字段序已由 WriteRingTossThrowResult 确认为 // ThrowIndex 在前(RPCSerializeAuto.lua:7800)。只做展示,不做判定。 var extra = rest >= 8 ? $" throwIndex={BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(8, 4))}" + $" rewardDropId={BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(12, 4))}" : $" (payload {rest}b 未解析)"; conn.Log.Info($"[RINGTOSS] RecordRingTossResult gadgetUId={gadgetUid}{extra}"); } else { conn.Log.Info($"[RINGTOSS] RecordRingTossResult short body {body?.Length ?? 0}b"); } return conn.ReturnEmptyOkAsync(msg); } /// LeaveRingToss(67046402): C2S = u64 gadgetuid + bool abort;RET = void。 [Handler(MethodId.LeaveRingToss)] private Task LeaveRingTossAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; if (body is { Length: >= 8 }) { var gadgetUid = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)); var abort = body.Length >= 9 && body[8] != 0; conn.Log.Info($"[RINGTOSS] LeaveRingToss gadgetUId={gadgetUid} abort={abort}"); } else { conn.Log.Info($"[RINGTOSS] LeaveRingToss short body {body?.Length ?? 0}b"); } return conn.ReturnEmptyOkAsync(msg); } // ───────────────────────────────────────────────────────────────────── // ② 组队对局入口 PartyMiniGameMatch —— 龙栖村所有摊位的公共入口(优先级最高) // ⚠️ 走 ClientToGameDelegate(Game 连接),不是 GameScene。 // ───────────────────────────────────────────────────────────────────── /// /// AskStartPartyMiniGameMatch(63517279): C2S = u32 gameconfigid;RET = void。 /// 客户端 await 这个回执;不回 ⇒ 模态界面永久等待 ⇒ 只能大退。 /// [Handler(MethodId.AskStartPartyMiniGameMatch)] private Task AskStartPartyMiniGameMatchAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; var cfg = body is { Length: >= 4 } ? BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0, 4)) : 0u; conn.Log.Info($"[PARTYMATCH] AskStartPartyMiniGameMatch gameConfigId={cfg} " + $"bytes={body?.Length ?? 0} -> ok(入口受理;开局推送见 R91)"); return conn.ReturnEmptyOkAsync(msg); } /// AskConfirmPartyMiniGameMatch(63947581): C2S = u32 gameconfigid + bool confirmed;RET = void。 [Handler(MethodId.AskConfirmPartyMiniGameMatch)] private Task AskConfirmPartyMiniGameMatchAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; var cfg = body is { Length: >= 4 } ? BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0, 4)) : 0u; var confirmed = body is { Length: >= 5 } && body[4] != 0; conn.Log.Info($"[PARTYMATCH] AskConfirmPartyMiniGameMatch gameConfigId={cfg} confirmed={confirmed} -> ok"); return conn.ReturnEmptyOkAsync(msg); } /// AskCancelPartyMiniGameMatch(63953381): C2S = u32 gameconfigid;RET = void。 [Handler(MethodId.AskCancelPartyMiniGameMatch)] private Task AskCancelPartyMiniGameMatchAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; var cfg = body is { Length: >= 4 } ? BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0, 4)) : 0u; conn.Log.Info($"[PARTYMATCH] AskCancelPartyMiniGameMatch gameConfigId={cfg} -> ok"); return conn.ReturnEmptyOkAsync(msg); } // ───────────────────────────────────────────────────────────────────── // ③ 刮刮乐 Scratch —— 入口在 IL2CPP/Spoon(Lua 0 命中),但协议形状确定 // ───────────────────────────────────────────────────────────────────── /// /// AskStartScratchGame(67062589): C2S = u64 gadgetid + **byte** scratchsubtype;RET = void。 /// ⚠️ subtype 是 CheckEnum(subtype, 52, 1) ⇒ 编码宽度 1 字节、枚举上限 52、默认 1。 /// [Handler(MethodId.AskStartScratchGame)] private async Task AskStartScratchGameAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; var gadgetId = 0UL; var subType = ScratchWire.SubTypeLevelOne; if (body is { Length: >= 8 }) { gadgetId = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)); subType = body.Length >= 9 ? body[8] : ScratchWire.SubTypeLevelOne; conn.Log.Info($"[SCRATCH] AskStartScratchGame gadgetId={gadgetId} subType={subType} -> ok"); } else { conn.Log.Info($"[SCRATCH] AskStartScratchGame short body {body?.Length ?? 0}b -> ok"); } await conn.ReturnEmptyOkAsync(msg); // ★ 回包之后再推 —— 与五子棋同一条规矩:客户端要先拿到「受理」才会建面板上下文。 if (gadgetId != 0) await PushScratchStartAsync(conn.Session, gadgetId, subType); } /// /// ★★★ 把刮刮乐面板「开局数据」推给客户端(SyncStartScratchGameZone 68649573)。 /// /// ── wire(RPCDeserializeAuto.lua:6475 的 midToReader[68649573])───────── /// u64 gadgetUid + byte scratchSubType + tag byte(Dispatch[510]) + 子类字段。 /// 本方法只走 **LevelOne(subType=1 / tag=2)**:这是唯一我们把配表数据摸清的子类型。 /// body 是三种异构字段的串联,**不能**用泛型 NotifyAsync<T>(那会给整个对象套一层 /// 0xFF/complex 壳)⇒ 必须手拼再 NotifyAsync(mid, byte[], token)。 /// /// ── 为什么收 TcpSession 而不是 Connection ──────────────────── /// 两条调用路径都要用:① handler 里(有 conn)② 摊位摆位 tick 里(只有 TcpSession)。 /// internal static async Task PushScratchStartAsync(TcpSession session, ulong gadgetUid, byte subType) { var result = new ScratchLevelOneStartResult { CellDataList = ScratchWire.NewLevelOneCells(), TotalReward = 0, }; // Serialize() 的首字节就是 TypeMark(2),正是 Dispatch[510] 要的 tag。 var payload = UxSerializer.Serialize(result); var body = new byte[9 + payload.Length]; BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(0, 8), gadgetUid); body[8] = subType; payload.CopyTo(body, 9); await session.NotifyAsync(MethodId.IGameSceneToClient_SyncStartScratchGameZone, body, CancellationToken.None); session.Log.Info($"[SCRATCH] pushed SyncStartScratchGameZone gadgetUId={gadgetUid} " + $"subType={subType} tag={payload[0]} cells={result.CellDataList.Count} " + $"body={body.Length}b"); } /// AskLoseScratchGame(67829420): C2S = u64 gadgetid;RET = void。 [Handler(MethodId.AskLoseScratchGame)] private Task AskLoseScratchGameAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; var gadgetId = body is { Length: >= 8 } ? BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)) : 0UL; conn.Log.Info($"[SCRATCH] AskLoseScratchGame gadgetId={gadgetId} -> ok"); return conn.ReturnEmptyOkAsync(msg); } /// AskFinishScratchGame(67996302): C2S = u64 gadgetid + u32 rewardmoney;RET = void。 [Handler(MethodId.AskFinishScratchGame)] private Task AskFinishScratchGameAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; if (body is { Length: >= 12 }) { var gadgetId = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)); var reward = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, 4)); conn.Log.Info($"[SCRATCH] AskFinishScratchGame gadgetId={gadgetId} rewardMoney={reward} -> ok"); } else { conn.Log.Info($"[SCRATCH] AskFinishScratchGame short body {body?.Length ?? 0}b -> ok"); } return conn.ReturnEmptyOkAsync(msg); } // ───────────────────────────────────────────────────────────────────── // ④ 五子棋 Gomoku —— 入口同样在 IL2CPP/Spoon // ───────────────────────────────────────────────────────────────────── /// /// EnterGomokuZoneDoublePlayer(67377693): C2S = u64 gadgetuid + bool canuseskill;RET = void。 /// 注意:**RET 是 void**(与套圈的裸 u64 不同)—— 对客户端来说回空 ok 即可。 /// [Handler(MethodId.EnterGomokuZoneDoublePlayer)] private Task EnterGomokuZoneDoublePlayerAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; if (body is { Length: >= 8 }) { var gadgetUid = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)); var canUseSkill = body.Length >= 9 && body[8] != 0; conn.Log.Info($"[GOMOKU] EnterGomokuZoneDoublePlayer gadgetUId={gadgetUid} " + $"canUseSkill={canUseSkill} -> ok"); } else { conn.Log.Info($"[GOMOKU] EnterGomokuZoneDoublePlayer short body {body?.Length ?? 0}b -> ok"); } return conn.ReturnEmptyOkAsync(msg); } /// PlaceGomokuPiece(67496058): C2S = u64 gadgetuid + u32 x + u32 y;RET = void。 [Handler(MethodId.PlaceGomokuPiece)] private Task PlaceGomokuPieceAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; if (body is { Length: >= 16 }) { var gadgetUid = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)); var x = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, 4)); var y = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12, 4)); conn.Log.Info($"[GOMOKU] PlaceGomokuPiece gadgetUId={gadgetUid} cell=({x},{y}) -> ok"); } else { conn.Log.Info($"[GOMOKU] PlaceGomokuPiece short body {body?.Length ?? 0}b -> ok"); } return conn.ReturnEmptyOkAsync(msg); } /// LeaveGomoku(67832026): C2S = u64 gadgetuid;RET = void。 [Handler(MethodId.LeaveGomoku)] private async Task LeaveGomokuAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; var gadgetUid = body is { Length: >= 8 } ? BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)) : 0UL; // ⛑ 单机局收尾**刻意不撤**对手 NPC(R157,与 GameRouter.ChineseChess.cs 同源定案): // 本版客户端对 static NPC 没有可靠的删除通道(MethodId 里没有 SyncAetherAIStaticNpcRemove*; // SyncRemoveNpc 的 reader 是 Int32,装不下 830… 段的 u64)⇒ 撤不掉, // 而进场侧是按 (会话, 桌子) 复用同一个 uid 的,撤了反而会让下次进来又造一个。 // ⇒ 留着复用,代价只是场上多一个安静的棋手;换角色/断线会自然清掉。 var kept = conn.Session.Items.TryGetValue("Gomoku.AiUid", out var o) && o is ulong u && u != 0 ? u.ToString() : "(无)"; conn.Log.Info($"[GOMOKU] LeaveGomoku gadgetUId={gadgetUid} -> ok(AI 对手 uid={kept} 保留,下次进入复用)"); await conn.ReturnEmptyOkAsync(msg); } // ───────────────────────────────────────────────────────────────────── // ⑤ 炒茶 / 炒栗 FryChestnut —— ★ 这一组**不是 void**,是本文件唯一两个需要手拼返回载荷的 // // 客户端调用点(实测,不是推断): // • `LX6/SGUI/StoreDefine/FryTeaPanelStore.lua` → `gClientToGameSceneDelegate:AskFinishChestnutGame(gadgetid, result)` // (`result` 就是炒制得分,float) // • 同一族 → `gClientToGameDelegate:AskFarmerFry(ruleId, count)`(Game 连接,不是 GameScene) // ⇒ 炒茶/炒栗**确实需要服务端**(与套圈/毽子那种纯本地玩法相反)。 // // ★★ 返回值形状(`RPCDeserializeAuto.lua` 逐行核过): // AskStartFryChestnutGame(67949200) → `function(reader) end` **空体 = void** ✅ // AskFinishChestnutGame (67543164) → `local r0 = reader.ReadByte(reader)` **裸 byte** ⚠️ // AskFarmerFry (63258925) → `Base.ReadDict7Bit(reader, u32, u32)` **7bit 字典** ⚠️ // ⇒ 后两个如果照抄 `ReturnEmptyOkAsync`(0 字节)**必然**走位上崩: // 裸 byte 会读到「没有字节」;字典会读到 echo 里的下一个字节当存在标记。 // ⇒ 更不能走 `ReturnAsync`:泛型路径会加 `0xFF + Int7(len)` 外层框架(铁律:裸值不套壳)。 // 必须 `ReturnRawAsync(byte[])` 手拼。 // // ★ 空字典的字节取 `FF 01`(**不是** `FF 00`): // `RPCDeserializeBase.lua:173-187` 的 `ReadDict7Bit` = 读 1 字节存在标记(0=null / 非0=有值) // → `reader:ReadInt7Bit()`。而 `ReadInt7Bit` 与 `ReadInt7Bit` 同源的那套(我方 UxReader.Int7 = // `ReadVarInt() - 1`)带 **+1 偏置** ⇒ 0 个元素在线上是 `01`。 // 写侧 `RPCSerializeBase.lua:260-273` 打 `SerializeObjectMarkCommon = 255 = 0xFF`, // 然后 `Write7BitEncodedInt(count)`(同偏置)。⇒ `FF 01`。 // ⛔ `FF 00` 解出来是 −1 ⇒ 循环不跑但值可疑;`00`(null) 会被当成「没有这个字典」。 // 本机既有结论同源(`GadgetMethods4229938.cs` 的 MobilePlatformSyncInfo.Players 注释: // 「空字典是 `01 01`」「`FF 01` 是对的」「`01 00` 因偏置变 −1」)。 // // ⛑ 为什么 AskFinishChestnutGame 回 byte 0 而不是别的: // 客户端拿到的是**本次炒制的结算编号/结果码**,离线语义下我们没有派生规则。 // 0 = 「服务端不做纠正」,与套圈「回本地 pid」是同一种「让客户端自己算」的策略。 // 若实测发现 0 会让面板卡住,改 miniGameStalls.chestnutFinishResult 即可,不用改代码。 // ───────────────────────────────────────────────────────────────────── /// /// AskStartFryChestnutGame(67949200): C2S = u64 gadgetid + **byte** frytype;RET = **void**。 /// ⚠️ frytype 是 CheckEnum(frytype, 91, 0) ⇒ 编码宽度 1 字节、枚举组 91、默认 0。 /// [Handler(MethodId.AskStartFryChestnutGame)] private Task AskStartFryChestnutGameAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; if (body is { Length: >= 8 }) { var gadgetId = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)); var fryType = body.Length >= 9 ? body[8] : (byte)0; conn.Log.Info($"[FRYCHESTNUT] AskStartFryChestnutGame gadgetId={gadgetId} fryType={fryType} -> ok"); } else { conn.Log.Info($"[FRYCHESTNUT] AskStartFryChestnutGame short body {body?.Length ?? 0}b -> ok"); } return conn.ReturnEmptyOkAsync(msg); } /// /// AskFinishChestnutGame(67543164): C2S = u64 gadgetid + **float** fryscore;RET = **裸 byte**。 /// /// ⛔ 返回值**不是 void**:`midToReturnMessageReader[67543164]` 的函数体是 /// `local r0 = reader.ReadByte(reader); return r0`。 /// ⇒ 必须回 1 字节;空的回执会让客户端在「读结算码」这一步读到缓冲区末尾。 /// [Handler(MethodId.AskFinishChestnutGame)] private Task AskFinishChestnutGameAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; var gadgetId = 0UL; var fryScore = 0f; if (body is { Length: >= 12 }) { gadgetId = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)); fryScore = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(8, 4)); } // 裸 byte 载荷:不套 0xFF、不套长度。见类型头注释。 var payload = new[] { ChestnutFinishResult }; conn.Log.Info($"[FRYCHESTNUT] AskFinishChestnutGame gadgetId={gadgetId} fryScore={fryScore:F3} " + $"body={body?.Length ?? 0}b -> raw byte {ChestnutFinishResult}"); return conn.ReturnRawAsync(msg, payload); } /// /// AskFarmerFry(63258925): C2S = u32 ruleid + u32 count;RET = **Dict7Bit<u32,u32>**。 /// /// ⛔ 走的是 **Game 连接**(`ClientToGameDelegate.lua:2130`),不是 GameScene。 /// ⛔ 返回值**不是 void**:`Base.ReadDict7Bit(reader, ReadUInt32, ReadUInt32)`。 /// 本轮回**空字典**(`FF 01`)——离线语义下没有「炒制规则→产出」的权威映射, /// 空字典 = 「服务端不调整」,客户端用自带的本地产出表(与套圈同策略)。 /// /// ⛔ 为什么手拼 2 字节而不是用 ReturnAsync(new Dictionary<uint,uint>()): /// 泛型路径会给顶层**再套一层** `0xFF + Int7(len)`,客户端按 ReadDict7Bit 读就会 /// 把外层 0xFF 当成「字典存在」、把外层长度当成元素个数 ⇒ 走位。 /// 这与 EnterRingTossZone 必须手拼 u64 是同一个理由。 /// [Handler(MethodId.AskFarmerFry)] private Task AskFarmerFryAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; var ruleId = body is { Length: >= 4 } ? BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0, 4)) : 0u; var count = body is { Length: >= 8 } ? BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4, 4)) : 0u; // 空 Dict7Bit:0xFF = SerializeObjectMarkCommon,0x01 = Write7BitEncodedInt(0) 的 +1 偏置。 var payload = new byte[] { 0xFF, 0x01 }; conn.Log.Info($"[FRYCHESTNUT] AskFarmerFry ruleId={ruleId} count={count} " + $"body={body?.Length ?? 0}b -> empty dict(ff 01)"); return conn.ReturnRawAsync(msg, payload); } }