517 lines
31 KiB
C#
517 lines
31 KiB
C#
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<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<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<T></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<u32,u32>**。
|
||
///
|
||
/// ⛔ 走的是 **Game 连接**(`ClientToGameDelegate.lua:2130`),不是 GameScene。
|
||
/// ⛔ 返回值**不是 void**:`Base.ReadDict7Bit(reader, ReadUInt32, ReadUInt32)`。
|
||
/// 本轮回**空字典**(`FF 01`)——离线语义下没有「炒制规则→产出」的权威映射,
|
||
/// 空字典 = 「服务端不调整」,客户端用自带的本地产出表(与套圈同策略)。
|
||
///
|
||
/// ⛔ 为什么手拼 2 字节而不是用 <c>ReturnAsync(new Dictionary<uint,uint>())</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);
|
||
}
|
||
}
|