using System; using System.Buffers.Binary; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Mj = Ananta.Server.Protocol.Client4229938.MahjongRules4229938; namespace Ananta.Server.Handlers.Game; /// /// 单机麻将(1 真人 + 3 NPC)—— 移植自捐赠分支 DRMK-PS_09_HyNB-v1.9-R64 的 /// GameRouter.MahjongOnline.cs(391 行)+ MahjongRules4229938.cs(329 行,零改动直拷)。 /// /// 为什么现在能做(2026-10-04 重估,推翻 10-03 的"重活、建议单独排期"判断): /// 1. 主线 MethodIds.json 与该分支字节级相同,40+ 个 SyncMj* 常量本来就全齐,不需要搬协议表; /// 2. 该分支带 .runtime/r62-mahjong-wire-samples.json(41KB 实机抓包), /// SyncMjLoginResult 里三个 NPC 座位就是 Player101/102/103, /// 说明实机跑的正是"1 真人 + 3 NPC"这个形态; /// 3. 原实现的 Coop 依赖只集中在 5 个辅助函数里(MahjongPeers / NotifyCoopPeerAsync / /// CoopRoster.TryGetBySession / CoopChanges / ActivityLobbies), /// 单人场景下全部退化为 MahjongGate + 直连推送。 /// /// wire 形状逐字段照抄捐赠端,未重新推导。本机复核过的样本片段(SyncMjLoginResult): /// FF + U64(pid) + U64(agentId) + I32(templateId) + U32(level) + UxString(name) + /// FF 00 + U32(templateId) + U32(0) + I32(score=10000) + I32(seat) + U32(0) + U32(0), /// 四座位之后接 FF 05 + 4 个 bool + I32(owner)。 /// /// 已知局限:Mj.Automatic 的对手策略是"能胡就胡 + 弃定缺花色/摸切 + 别人出牌一律 Pass", /// 所以 NPC 不会碰/杠/吃。规则层能打完整局,但对手很笨。 /// 客户端侧确认无本地 AI(Gameplay/Majiang/ 22 个 lua 里 AI/Robot 零命中)。 /// /// 没登记的号:Exit(63320834) 与 Chi(63693559) 故意不进白名单 —— /// 前者名字太通用(别的玩法也在用),后者 XLCH 规则本来就不吃。 /// internal sealed partial class GameRouter { // ---------------------------------------------------------------- 常量 /// 座位 duty 基值(SyncMahjongReadyStart 的第二字段 = 12114040 + seat)。 private const uint MahjongSeatDutyBase = 12114040; /// /// NPC 座位的展示模板 —— 实机样本(r62-mahjong-wire-samples.json 的 SyncMjLoginResult) /// 四个座位都是 15020967,NPC 与真人同模板(用 Python 按本文件字段序解析后核对:见 _mj_wire_check.py)。 /// private const int MahjongNpcTemplateId = 15020967; /// NPC agent 实例 id 前缀(实机样本里是 0x3000000002 / 05 / 07)。 private const ulong MahjongNpcAgentBase = 0x0000300000000000UL; /// 合成房间 id 的自增起点(客户端只当它是桌子 uid)。 private static ulong _mahjongRoomSeq = 0x0000300000000000UL; // ---------------------------------------------------------------- 状态 /// 一个座位的展示身份(对应 SyncMjLoginResult 每座位那 16 段)。 private sealed class MjSeatInfo { internal ulong Pid; internal ulong AgentId; // 自己视角看到的 agent internal ulong RemoteAgentId; // 别人视角看到的 agent internal int TemplateId; internal uint Level; internal string Name = string.Empty; internal bool IsHuman; } private sealed class MahjongGame { internal ulong Room; internal TcpSession Session = null!; internal readonly ulong[] Players = new ulong[4]; internal MjSeatInfo[] Seats = new MjSeatInfo[4]; internal readonly HashSet Ready = new(); internal readonly HashSet Loaded = new(); internal readonly HashSet Again = new(); internal readonly byte[] Auto = new byte[4]; internal readonly int[] Misses = new int[4]; internal Mj Rules = new(RandomNumberGenerator.GetInt32(4)); internal int[] WireIds = NewMahjongIds(); internal byte Phase; // 0 未开局 / 1 loading / 2 playing / 3 finished internal int SeenVersion = -1; internal long Clock = Stopwatch.GetTimestamp(); } private static readonly Dictionary MahjongGames = new(); /// 替代捐赠端的 CoopChanges:单人场景下就是一把进程内互斥锁。 private static readonly SemaphoreSlim MahjongGate = new(1, 1); private static ulong NextMahjongRoom() => Interlocked.Increment(ref _mahjongRoomSeq); private static int[] NewMahjongIds() { int[] a = Enumerable.Range(1, 108).ToArray(); for (int i = a.Length - 1; i > 0; i--) { int j = RandomNumberGenerator.GetInt32(i + 1); (a[i], a[j]) = (a[j], a[i]); } return a; } private static MahjongGame? FindMahjong(TcpSession session) { foreach (var g in MahjongGames.Values) if (ReferenceEquals(g.Session, session)) return g; return null; } private static int HumanSeat(MahjongGame g) { for (int i = 0; i < 4; i++) if (g.Seats[i] != null && g.Seats[i].IsHuman) return i; return 0; } private static void FillMahjongSeats(MahjongGame g, ulong playerPid, uint templateId, string playerName, int human) { for (int i = 0; i < 4; i++) { bool isHuman = i == human; ulong npcAgent = MahjongNpcAgentBase | (uint)(i * 2 + 1); g.Seats[i] = new MjSeatInfo { Pid = isHuman ? playerPid : (ulong)(101 + i), AgentId = isHuman ? playerPid : npcAgent, RemoteAgentId = isHuman ? playerPid : npcAgent, TemplateId = isHuman ? (int)templateId : MahjongNpcTemplateId, Level = 1, // 实机样本里 NPC 座位名就是 Player101/102/103;先用同款保证 wire 同构。 Name = isHuman ? playerName : "Player" + (100 + i).ToString(), IsHuman = isHuman, }; g.Players[i] = g.Seats[i].Pid; } } private static Task MjSend(MahjongGame g, uint method, UxWriter body) { byte[] bytes = body.ToArray(); g.Session.Log.Info($"[MJ] -> mid={method} {bytes.Length}b {Convert.ToHexString(bytes)}"); return g.Session.NotifyAsync(method, bytes, CancellationToken.None); } // ---------------------------------------------------------------- wire(照抄捐赠端) private static UxWriter MjInts(UxWriter w, IReadOnlyList values) { w.U8(255).Int7(values.Count); foreach (int n in values) w.I32(n); return w; } private static UxWriter MjTile(UxWriter w, MahjongGame g, int tile, bool reveal = true) { // 牌面 id 是一次随机置换:客户端动画需要稳定 id,但不该从 id 反推暗牌。 if (tile == 0) return w.I32(-1).I32(0).U8(1).Bool(false).I32(27); return w.I32(g.WireIds[tile - 1]).I32(reveal ? Mj.Index(tile) % 9 : 0) .U8(reveal ? Mj.Suit(tile) : (byte)1).Bool(false).I32(reveal ? Mj.Index(tile) : 27); } private static UxWriter MjTiles(UxWriter w, MahjongGame g, IEnumerable tiles, bool reveal = true) { int[] a = tiles.ToArray(); w.U8(255).Int7(a.Length); foreach (int t in a) MjTile(w, g, t, reveal); return w; } private static bool MjVisible(MahjongGame g, int seat, int viewer) => seat == viewer || g.Rules.Seats[seat].HasWon || g.Phase == 3; private static UxWriter MjMeld(UxWriter w, MahjongGame g, Mj.Meld m, bool reveal = true) { MjTile(w.U8(255).I32(m.Kind == 2 ? -1 : m.Source), g, m.Tile, reveal); return MjTiles(w, g, m.Kind == 2 ? m.Selected.Where(t => t != m.Tile) : m.Selected, reveal).U8(m.Kind); } private static UxWriter MjMelds(UxWriter w, MahjongGame g, IEnumerable melds, int seat, int viewer) { var a = melds.ToArray(); w.U8(255).Int7(a.Length); foreach (var m in a) MjMeld(w, g, m, m.Kind != 2 || MjVisible(g, seat, viewer)); return w; } private static UxWriter MjInfo(MahjongGame g, int viewer) { var r = g.Rules; var w = new UxWriter().U8(255).U8(r.State).I32(r.Round).I32(r.Remaining).I32(r.Banker) .I32(r.Turn).I32(r.LastTurn).I32(r.LastDrawSeat).U8(255).Int7(4); for (int seat = 0; seat < 4; seat++) { var s = r.Seats[seat]; w.U8(255).I32(s.Score).I32(s.Holds.Count); MjTiles(w, g, s.Holds, MjVisible(g, seat, viewer)); MjTiles(w, g, s.Folds); MjMelds(w, g, s.Melds, seat, viewer); w.I32(0).U8(r.State >= 4 || seat == viewer ? s.Que : (byte)0); MjTiles(w, g, s.Received, seat == viewer); MjTiles(w, g, s.HuTiles); } MjTiles(w, g, r.WonTiles); for (int i = 0; i < 4; i++) MjTiles(w, g, Array.Empty()); return w.F32((float)Math.Min(30, Stopwatch.GetElapsedTime(g.Clock).TotalSeconds)); } private static UxWriter MjRoom(MahjongGame g, int viewer) { var w = new UxWriter().U8(255).I32(1).U64(g.Room).U8(2) .U8(g.Phase == 3 ? (byte)3 : g.Phase >= 1 ? (byte)2 : (byte)0) .U8(255).Int7(4); for (int seat = 0; seat < 4; seat++) { var s = g.Seats[seat]; bool self = g.Seats[viewer].Pid == s.Pid; w.U8(255).U64(s.Pid).U64(self ? s.AgentId : s.RemoteAgentId) .I32(s.TemplateId).U32(s.Level).UxString(s.Name) .U8(255).U8(0).U32((uint)s.TemplateId).U32(0) .I32(g.Rules.Seats[seat].Score).I32(seat).U32(0).U32(0); } w.U8(255).Int7(4); for (int i = 0; i < 4; i++) w.Bool(g.Loaded.Contains(g.Players[i])); return w.I32(HumanSeat(g)); } private static UxWriter MjWorldRoom(MahjongGame g) { var w = new UxWriter().U8(255).U64(g.Room).U64(g.Seats[HumanSeat(g)].Pid) .U8(255).Int7(g.Players.Count(p => p != 0)); for (int i = 0; i < 4; i++) if (g.Players[i] != 0) w.U8(255).U64(g.Players[i]).U32(MahjongSeatDutyBase + (uint)i) .Bool(g.Ready.Contains(g.Players[i])).Bool(g.Seats[i].IsHuman); return w; } private static UxWriter MjResult(MahjongGame g) { var w = new UxWriter().U8(255).U8(255).Int7(g.Rules.Actions.Count); foreach (var a in g.Rules.Actions) { w.U8(255).U8(a.Kind).I32(a.Owner); MjInts(w, a.Targets).I32(a.Score).Bool(a.Transferred) .I32(a.Pattern == 0 ? 2 : a.Pattern == 1 ? 1 : 0).U8(a.Pattern).U8(255).Int7(a.Kind == 0 ? 1 : 0); if (a.Kind == 0) w.U8(a.Pattern); MjTile(w, g, a.Tile).I32(a.Roots).I32(a.Flags).I32(a.Fan); } w.U8(255).Int7(4); foreach (var s in g.Rules.Seats) MjTiles(w.U8(255), g, s.Holds); return w; } // ---------------------------------------------------------------- 流程 private static async Task LoadMahjongCoreAsync(MahjongGame g) { g.Phase = 1; g.Loaded.Clear(); g.Clock = Stopwatch.GetTimestamp(); await MjSend(g, MethodId.IGameSceneToClient_SyncMahjongWorldBattleRoomInfo, MjWorldRoom(g)); await MjSend(g, MethodId.IGameSceneToClient_SyncMahjongReadyStart, new UxWriter().U64(g.Room).U32(MahjongSeatDutyBase + (uint)HumanSeat(g))); } private static async Task BeginMahjongCoreAsync(MahjongGame g) { if (g.Phase >= 2) return; g.Phase = 2; g.Rules.Start(); g.Clock = Stopwatch.GetTimestamp(); g.SeenVersion = g.Rules.Version; for (int i = 0; i < 4; i++) g.Loaded.Add(g.Players[i]); await MjSend(g, MethodId.SyncInMjGame, new UxWriter().Bool(true).Bool(false).U8(2)); await MjSend(g, MethodId.SyncMjLoginResult, MjRoom(g, HumanSeat(g))); await MjSend(g, MethodId.SyncMjGameInfo, MjInfo(g, HumanSeat(g))); await FlushMahjongEvents(g); await RunMahjongNpcTurnsAsync(g); } private static async Task FlushMahjongEvents(MahjongGame g) { int viewer = HumanSeat(g); if (g.SeenVersion != g.Rules.Version) { g.SeenVersion = g.Rules.Version; g.Clock = Stopwatch.GetTimestamp(); } foreach (var e in g.Rules.Events) { switch (e.Kind) { case "ExchangeBegin": case "DingBegin": await MjResumePromptAsync(g, viewer); break; case "ExchangeChosen": if (e.Seat == viewer) await MjSend(g, MethodId.SyncMjHuanPai, MjTiles(new(), g, e.Tiles!).I32(e.Seat)); break; case "ExchangeDone": for (int i = 0; i < 4; i++) await MjSend(g, MethodId.SyncMjHuanPaiResult, MjTiles(MjTiles(new UxWriter().I32(i), g, e.Hands![i], i == viewer), g, g.Rules.Seats[i].Received, i == viewer).I32(e.Value)); break; case "DingDone": { var q = new UxWriter().U8(255).Int7(4); foreach (var s in g.Rules.Seats) q.U8(s.Que); await MjSend(g, MethodId.SyncMjDingQue, q); break; } case "Turn": await MjSend(g, MethodId.SyncMjTurn, new UxWriter().I32(e.Seat).F32(0)); break; case "Score": await MjSend(g, MethodId.SyncMjScoreChange, new UxWriter().I32(e.Seat).I32(e.Value).I32(e.Source)); break; case "Draw": case "Discard": case "Peng": case "Gang": { bool reveal = MjVisible(g, e.Seat, viewer); var w = MjTile(new UxWriter().I32(e.Seat), g, e.Tile, e.Kind == "Draw" ? reveal : e.Kind != "Gang" || e.Value != 2 || reveal); uint mid; if (e.Kind == "Draw") { w.I32(e.Value); mid = MethodId.SyncMjMoPai; } else if (e.Kind == "Discard") { w.Bool(false); mid = MethodId.SyncMjChuPai; } else { MjTiles(w, g, e.Kind == "Gang" && e.Value == 2 ? e.Tiles!.Where(t => t != e.Tile) : e.Tiles!, e.Kind != "Gang" || e.Value != 2 || reveal); // 这条 RPC 用 MjActionType;已经落袋的副露用 MjPCGType。 if (e.Kind == "Gang") w.U8(e.Value == 2 ? (byte)1 : e.Value == 4 ? (byte)2 : (byte)3); mid = e.Kind == "Gang" ? MethodId.SyncMjGang : MethodId.SyncMjPeng; } await MjSend(g, mid, MjTiles(w, g, e.Hands![e.Seat], reveal)); break; } case "HanGang": await MjSend(g, MethodId.SyncMjHanGang, MjTile(new UxWriter().I32(e.Seat), g, e.Tile).U8((byte)e.Value)); break; case "Hu": { var hu = MjTile(MjInts(new(), e.Seats!), g, e.Tile).I32(e.Source); MjInts(hu, e.Flags!); hu.U8(255).Int7(e.Seats!.Length); foreach (int seat in e.Seats) MjTiles(hu.U8(255), g, e.Hands![seat]); await MjSend(g, MethodId.SyncMjHu, hu); if (e.Value == 1) await MjSend(g, MethodId.SyncMjHolds, MjTiles(new(), g, e.Hands![e.Source], MjVisible(g, e.Source, viewer)) .I32(e.Hands[e.Source].Length).I32(e.Source)); break; } case "Pass": if (e.Seat == viewer) await MjSend(g, MethodId.SyncMjGuo, MjTile(new(), g, e.Tile)); break; case "Pigs": case "Refund": case "Unready": await MjSend(g, e.Kind == "Pigs" ? MethodId.SyncMjChaHuaZhu : e.Kind == "Refund" ? MethodId.SyncMjTuiShui : MethodId.SyncMjChaDaJiao, MjInts(new(), e.Seats!)); break; case "Transfer": await MjSend(g, MethodId.SyncMjMaoZhuanYu, new UxWriter().I32(e.Seat)); break; case "End": g.Phase = 3; g.Again.Clear(); await MjSend(g, MethodId.SyncMjGameOver, MjResult(g)); await MjSend(g, MethodId.SyncMjRoomState, new UxWriter().U8(3)); break; } } g.Rules.Events.Clear(); if (g.Phase == 2 && g.Rules.State == 4) await MjSendOptionsAsync(g, viewer); } private static async Task MjResumePromptAsync(MahjongGame g, int seat) { var r = g.Rules; float elapsed = (float)Stopwatch.GetElapsedTime(g.Clock).TotalSeconds; if (r.State == 1 && r.Seats[seat].Exchange == null) await MjSend(g, MethodId.SyncMjHuanPaiBegin, MjTiles(new(), g, r.DefaultExchange(seat)).F32(elapsed)); if (r.State == 3 && r.Seats[seat].Que == 0) await MjSend(g, MethodId.SyncMjDingQueBegin, new UxWriter().U8(r.DefaultQue(seat)).F32(elapsed)); if (r.State == 4) await MjSendOptionsAsync(g, seat); } private static Task MjSendOptionsAsync(MahjongGame g, int seat) { var o = g.Rules.GetOptions(seat); var w = MjTile(new UxWriter().U8(255), g, o.Tile).Bool(o.Hu).Bool(false); MjMelds(w, g, o.Peng, seat, seat); MjMelds(w, g, Array.Empty(), seat, seat); MjMelds(w, g, o.Gang, seat, seat); return MjSend(g, MethodId.SyncMjOperations, w.Bool(o.Discard).F32((float)Stopwatch.GetElapsedTime(g.Clock).TotalSeconds)); } /// /// 让三个 NPC 座位把该做的动作做掉(换牌 / 定缺 / 摸切 / 胡 / 过)。 /// Mj.Automatic 自带守卫(State、轮次、是否已响应),所以无脑轮询是安全的。 /// private static async Task RunMahjongNpcTurnsAsync(MahjongGame g) { int human = HumanSeat(g); for (int round = 0; round < 24; round++) { bool moved = false; for (int seat = 0; seat < 4; seat++) { if (seat == human) continue; byte flags = (byte)(g.Auto[seat] == 0 ? 7 : g.Auto[seat]); if (g.Rules.Automatic(seat, flags)) moved = true; } if (!moved) break; await FlushMahjongEvents(g); await Task.Delay(350); // 给客户端留出牌动画的时间 } await FlushMahjongEvents(g); } private static async Task RemoveMahjongGameCoreAsync(MahjongGame g) { if (!MahjongGames.Remove(g.Room)) return; await MjSend(g, MethodId.SyncInMjGame, new UxWriter().Bool(false).Bool(false).U8(2)); await MjSend(g, MethodId.IGameSceneToClient_SyncMahjongTimeOut, new UxWriter().U64(g.Room)); await MjSend(g, MethodId.IGameSceneToClient_SyncMahjongWorldBattleRoomInfo, new UxWriter().U8(0)); } private static async Task WatchMahjongAsync(MahjongGame g) { while (true) { await Task.Delay(500); await MahjongGate.WaitAsync(); try { if (!MahjongGames.TryGetValue(g.Room, out var now) || !ReferenceEquals(now, g)) return; double elapsed = Stopwatch.GetElapsedTime(g.Clock).TotalSeconds; var r = g.Rules; if (g.Phase != 2) { if (elapsed < (g.Phase == 3 ? 300 : 600)) continue; await RemoveMahjongGameCoreAsync(g); return; } double limit = r.State == 1 ? 20 : r.State == 3 ? 10 : 30; bool expired = elapsed >= limit; int human = HumanSeat(g); if (expired) { // 真人超时:替他自动走一步;连续 3 次转托管(与捐赠端同策略)。 if (r.Automatic(human, 7)) { if (++g.Misses[human] >= 3 && g.Auto[human] != 7) { g.Auto[human] = 7; await MjSend(g, MethodId.SyncMjAutoEnterTuoGuan, new UxWriter()); } g.Clock = Stopwatch.GetTimestamp(); } } if (expired || r.Waiting || r.State == 4) await RunMahjongNpcTurnsAsync(g); } finally { MahjongGate.Release(); } } } // ---------------------------------------------------------------- 入口 / 打牌 private static string MahjongPlayerName() { var p = PrivateServerConfigStore.Current.Player; if (!string.IsNullOrWhiteSpace(p.DisplayName)) return p.DisplayName; if (!string.IsNullOrWhiteSpace(p.UserName)) return p.UserName; return "Player"; } /// /// AskStartMahjongGame(63184671) —— 单机麻将入口。 /// C2S = roomType(u8) + npcIdLs(List7Bit u32) + seatIndex(i32) + addFavor(bool) + gameType(u8)。 /// RET = void(空 body + retcode 0,与捐赠端同形)。 /// [Handler(MethodId.AskStartMahjongGame)] private async Task MahjongStart4229938(Connection conn, UxRpcMessage msg) { var b = msg.Body ?? Array.Empty(); byte roomType = b.Length > 0 ? b[0] : (byte)0; int seatIndex = b.Length >= 6 ? BinaryPrimitives.ReadInt32LittleEndian(b.AsSpan(b.Length - 6, 4)) : -1; bool addFavor = b.Length >= 2 && b[^2] != 0; byte gameType = b.Length >= 1 ? b[^1] : (byte)0; var cfg = PrivateServerConfigStore.Current.Player; int human = seatIndex >= 0 && seatIndex < 4 ? seatIndex : 0; await MahjongGate.WaitAsync(); try { if (FindMahjong(conn.Session) is { } old) await RemoveMahjongGameCoreAsync(old); var g = new MahjongGame { Room = NextMahjongRoom(), Session = conn.Session }; FillMahjongSeats(g, cfg.Pid, cfg.InitialSpiritTemplateId, MahjongPlayerName(), human); MahjongGames[g.Room] = g; conn.Log.Info($"[MJ] start room={g.Room} roomType={roomType} seat={human} " + $"addFavor={addFavor} gameType={gameType} pid={cfg.Pid}"); await conn.ReturnRawAsync(msg, Array.Empty(), 0); await LoadMahjongCoreAsync(g); // 客户端若发 MahjongPlayerReady 会提前开局;否则 1.2s 后直接开,不等它。 _ = Task.Run(async () => { await Task.Delay(1200); await MahjongGate.WaitAsync(); try { if (MahjongGames.TryGetValue(g.Room, out var cur) && ReferenceEquals(cur, g)) await BeginMahjongCoreAsync(g); } finally { MahjongGate.Release(); } }); _ = WatchMahjongAsync(g); } finally { MahjongGate.Release(); } } [Handler(MethodId.MahjongPlayerReady)] [Handler(MethodId.HuanPai)] [Handler(MethodId.DingQue)] [Handler(MethodId.ChuPai)] [Handler(MethodId.Peng)] [Handler(MethodId.Gang)] [Handler(MethodId.Hu)] [Handler(MethodId.Guo)] [Handler(MethodId.TuoGuan)] [Handler(MethodId.ArrangeMjHolds)] [Handler(MethodId.BackToMahjong)] private async Task MahjongRequest4229938(Connection conn, UxRpcMessage msg) { await MahjongGate.WaitAsync(); try { if (FindMahjong(conn.Session) is not { } g) { if (msg.IsInvoke) await conn.ReturnRawAsync(msg, Array.Empty(), 0); return; } int seat = HumanSeat(g); var b = msg.Body ?? Array.Empty(); bool ok = false; var r = g.Rules; if (msg.MethodId == MethodId.MahjongPlayerReady) { if (b.Length == 8 && BitConverter.ToUInt64(b) == g.Room && g.Phase >= 1) { ok = true; g.Loaded.Add(g.Players[seat]); if (g.Phase == 1) await BeginMahjongCoreAsync(g); } } else if (msg.MethodId == MethodId.BackToMahjong) { if (b.Length == 0 && g.Phase >= 2) { ok = true; await MjSend(g, MethodId.SyncMjLoginResult, MjRoom(g, seat)); await MjSend(g, MethodId.SyncMjGameInfo, MjInfo(g, seat)); await MjResumePromptAsync(g, seat); } } else if (g.Phase == 2) { if (msg.MethodId == MethodId.HuanPai) ok = TryMjTiles(b, g, 3, out var exchange) && r.Exchange(seat, exchange); else if (msg.MethodId == MethodId.DingQue) ok = b.Length == 1 && r.DingQue(seat, b[0]); else if (msg.MethodId == MethodId.Peng) ok = TryMjTiles(b, g, 2, out var peng) && r.Peng(seat, peng); else if (msg.MethodId == MethodId.Gang) ok = b.Length == 14 && TryMjTile(new(b), g, out int kong) && r.Gang(seat, kong); else if (msg.MethodId == MethodId.ChuPai) ok = b.Length == 15 && b[14] == 0 && TryMjTile(new(b), g, out int discard) && r.Discard(seat, discard); else if (msg.MethodId == MethodId.Hu) ok = b.Length == 0 && r.Hu(seat); else if (msg.MethodId == MethodId.Guo) ok = b.Length == 0 && r.Pass(seat); else if (msg.MethodId == MethodId.TuoGuan) { if (b.Length == 1 && b[0] <= 7) { g.Auto[seat] = b[0]; ok = true; } } else if (msg.MethodId == MethodId.ArrangeMjHolds) ok = true; // 手牌排序纯客户端表现,服务端不记账。 if (ok) { g.Misses[seat] = 0; g.Clock = Stopwatch.GetTimestamp(); } } conn.Log.Info($"[MJ] req mid={msg.MethodId} {b.Length}b ok={ok} phase={g.Phase} state={r.State}"); if (msg.IsInvoke) await conn.ReturnRawAsync(msg, ok && msg.MethodId == MethodId.Gang ? new UxWriter().Bool(r.Waiting && r.RobKong).ToArray() : Array.Empty(), 0); if (MahjongGames.ContainsKey(g.Room)) { await FlushMahjongEvents(g); await RunMahjongNpcTurnsAsync(g); } } finally { MahjongGate.Release(); } } private static bool TryMjTile(UxReader reader, MahjongGame g, out int tile) { int id = reader.I32(), pai = reader.I32(); byte suit = reader.U8(), red = reader.U8(); int index = reader.I32(); tile = Array.IndexOf(g.WireIds, id) + 1; return tile > 0 && index == Mj.Index(tile) && pai == index % 9 && suit == Mj.Suit(tile) && red == 0; } private static bool TryMjTiles(byte[] body, MahjongGame g, int count, out int[] tiles) { tiles = Array.Empty(); if (body.Length != 2 + 14 * count || body[0] != 255 || body[1] != count + 1) return false; var r = new UxReader(body); r.U8(); r.Int7(); tiles = new int[count]; for (int i = 0; i < count; i++) if (!TryMjTile(r, g, out tiles[i])) return false; return true; } }