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

517 lines
31 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 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;
/// <summary>
/// ★★★ 龙栖村摊位小游戏「能玩」的入口 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<uint,bool>`: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&lt;u32,u32&gt;**(`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<uint,bool>`),
/// 字段序未验 ⇒ 留 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 兜底),不用删本文件。
/// 更细的回滚粒度见 <c>miniGameStalls.stallHandlers</c> 三个开关(见下)。
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>
/// 套圈进场返回的 pid。客户端只判「非 0」就 `self.myPid = pid`,之后本地计分不再用服务端。
/// 取 1 而非随机数:固定值更利于日志对齐,且 0 会被客户端当成失败。
/// </summary>
private const ulong RingTossLocalPid = 1UL;
/// <summary>
/// 炒栗结算回给客户端的「结果码」(`AskFinishChestnutGame` 的裸 byte 返回值)。
/// 0 = 服务端不做纠正,客户端按自己算出的结果收尾(与套圈「回本地 pid」同策略)。
/// ⚠️ 若实测面板卡住,先改 `config/private-server.json` 的
/// `world.content.miniGameStalls.chestnutFinishResult`,不用改这里的默认值。
/// </summary>
private const byte ChestnutFinishResult = 0;
// ─────────────────────────────────────────────────────────────────────
// ① 套圈 RingToss —— 三件套,其中 Enter 回裸 u64
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// EnterRingTossZone(67108425): C2S = u64 gadgetuid;RET = **裸 u64 pid**。
///
/// ⛔ 必须用 `ReturnAsync(byte[])` 手工拼 8 字节小端 u64,**不能** `ReturnAsync(ulong)`:
/// 泛型路径会把顶层再套一层 `0xFF + Int7(len)`,客户端按裸 u64 读就走位。
/// 这与气球里 `NotifyRawAsync` 的理由同源(见 GameRouter.Balloon.cs)。
/// </summary>
[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);
}
/// <summary>
/// RecordRingTossResult(67649660): C2S = u64 gadgetuid + WriteStruct(RingTossThrowResult);RET = void。
/// 载荷:ThrowIndex(int) + RewardDropId(?) —— 只记日志,不参与判定(本地玩法,服务端只做记账)。
/// </summary>
[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);
}
/// <summary>LeaveRingToss(67046402): C2S = u64 gadgetuid + bool abort;RET = void。</summary>
[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。
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// AskStartPartyMiniGameMatch(63517279): C2S = u32 gameconfigid;RET = void。
/// 客户端 await 这个回执;不回 ⇒ 模态界面永久等待 ⇒ 只能大退。
/// </summary>
[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);
}
/// <summary>AskConfirmPartyMiniGameMatch(63947581): C2S = u32 gameconfigid + bool confirmed;RET = void。</summary>
[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);
}
/// <summary>AskCancelPartyMiniGameMatch(63953381): C2S = u32 gameconfigid;RET = void。</summary>
[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 命中),但协议形状确定
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// AskStartScratchGame(67062589): C2S = u64 gadgetid + **byte** scratchsubtype;RET = void。
/// ⚠️ subtype 是 <c>CheckEnum(subtype, 52, 1)</c> ⇒ 编码宽度 1 字节、枚举上限 52、默认 1。
/// </summary>
[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);
}
/// <summary>
/// ★★★ 把刮刮乐面板「开局数据」推给客户端(<c>SyncStartScratchGameZone</c> 68649573)。
///
/// ── wire(<c>RPCDeserializeAuto.lua:6475</c> 的 <c>midToReader[68649573]</c>)─────────
/// <c>u64 gadgetUid</c> + <c>byte scratchSubType</c> + <c>tag byte</c>(Dispatch[510]) + 子类字段。
/// 本方法只走 **LevelOne(subType=1 / tag=2)**:这是唯一我们把配表数据摸清的子类型。
/// body 是三种异构字段的串联,**不能**用泛型 <c>NotifyAsync&lt;T&gt;</c>(那会给整个对象套一层
/// 0xFF/complex 壳)⇒ 必须手拼再 <c>NotifyAsync(mid, byte[], token)</c>。
///
/// ── 为什么收 <c>TcpSession</c> 而不是 <c>Connection</c> ────────────────────
/// 两条调用路径都要用:① handler 里(有 conn)② 摊位摆位 tick 里(只有 TcpSession)。
/// </summary>
internal static async Task PushScratchStartAsync(TcpSession session, ulong gadgetUid, byte subType)
{
var result = new ScratchLevelOneStartResult
{
CellDataList = ScratchWire.NewLevelOneCells(),
TotalReward = 0,
};
// Serialize<ScratchLevelOneStartResult>() 的首字节就是 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");
}
/// <summary>AskLoseScratchGame(67829420): C2S = u64 gadgetid;RET = void。</summary>
[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);
}
/// <summary>AskFinishScratchGame(67996302): C2S = u64 gadgetid + u32 rewardmoney;RET = void。</summary>
[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
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// EnterGomokuZoneDoublePlayer(67377693): C2S = u64 gadgetuid + bool canuseskill;RET = void。
/// 注意:**RET 是 void**(与套圈的裸 u64 不同)—— 对客户端来说回空 ok 即可。
/// </summary>
[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);
}
/// <summary>PlaceGomokuPiece(67496058): C2S = u64 gadgetuid + u32 x + u32 y;RET = void。</summary>
[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);
}
/// <summary>LeaveGomoku(67832026): C2S = u64 gadgetuid;RET = void。</summary>
[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<T>`:泛型路径会加 `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 会让面板卡住,改 <c>miniGameStalls.chestnutFinishResult</c> 即可,不用改代码。
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// AskStartFryChestnutGame(67949200): C2S = u64 gadgetid + **byte** frytype;RET = **void**。
/// ⚠️ frytype 是 <c>CheckEnum(frytype, 91, 0)</c> ⇒ 编码宽度 1 字节、枚举组 91、默认 0。
/// </summary>
[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);
}
/// <summary>
/// AskFinishChestnutGame(67543164): C2S = u64 gadgetid + **float** fryscore;RET = **裸 byte**。
///
/// ⛔ 返回值**不是 void**:`midToReturnMessageReader[67543164]` 的函数体是
/// `local r0 = reader.ReadByte(reader); return r0`。
/// ⇒ 必须回 1 字节;空的回执会让客户端在「读结算码」这一步读到缓冲区末尾。
/// </summary>
[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);
}
/// <summary>
/// AskFarmerFry(63258925): C2S = u32 ruleid + u32 count;RET = **Dict7Bit&lt;u32,u32&gt;**。
///
/// ⛔ 走的是 **Game 连接**(`ClientToGameDelegate.lua:2130`),不是 GameScene。
/// ⛔ 返回值**不是 void**:`Base.ReadDict7Bit(reader, ReadUInt32, ReadUInt32)`。
/// 本轮回**空字典**(`FF 01`)——离线语义下没有「炒制规则→产出」的权威映射,
/// 空字典 = 「服务端不调整」,客户端用自带的本地产出表(与套圈同策略)。
///
/// ⛔ 为什么手拼 2 字节而不是用 <c>ReturnAsync(new Dictionary&lt;uint,uint&gt;())</c>:
/// 泛型路径会给顶层**再套一层** `0xFF + Int7(len)`,客户端按 ReadDict7Bit 读就会
/// 把外层 0xFF 当成「字典存在」、把外层长度当成元素个数 ⇒ 走位。
/// 这与 EnterRingTossZone 必须手拼 u64 是同一个理由。
/// </summary>
[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);
}
}