658 lines
28 KiB
C#
658 lines
28 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 单机麻将(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 规则本来就不吃。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
// ---------------------------------------------------------------- 常量
|
||
|
||
/// <summary>座位 duty 基值(SyncMahjongReadyStart 的第二字段 = 12114040 + seat)。</summary>
|
||
private const uint MahjongSeatDutyBase = 12114040;
|
||
|
||
/// <summary>
|
||
/// NPC 座位的展示模板 —— 实机样本(r62-mahjong-wire-samples.json 的 SyncMjLoginResult)
|
||
/// 四个座位都是 15020967,NPC 与真人同模板(用 Python 按本文件字段序解析后核对:见 _mj_wire_check.py)。
|
||
/// </summary>
|
||
private const int MahjongNpcTemplateId = 15020967;
|
||
|
||
/// <summary>NPC agent 实例 id 前缀(实机样本里是 0x3000000002 / 05 / 07)。</summary>
|
||
private const ulong MahjongNpcAgentBase = 0x0000300000000000UL;
|
||
|
||
/// <summary>合成房间 id 的自增起点(客户端只当它是桌子 uid)。</summary>
|
||
private static ulong _mahjongRoomSeq = 0x0000300000000000UL;
|
||
|
||
// ---------------------------------------------------------------- 状态
|
||
|
||
/// <summary>一个座位的展示身份(对应 SyncMjLoginResult 每座位那 16 段)。</summary>
|
||
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<ulong> Ready = new();
|
||
internal readonly HashSet<ulong> Loaded = new();
|
||
internal readonly HashSet<ulong> 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<ulong, MahjongGame> MahjongGames = new();
|
||
|
||
/// <summary>替代捐赠端的 CoopChanges:单人场景下就是一把进程内互斥锁。</summary>
|
||
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<int> 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<int> 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<Mj.Meld> 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<int>());
|
||
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<Mj.Meld>(), 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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 让三个 NPC 座位把该做的动作做掉(换牌 / 定缺 / 摸切 / 胡 / 过)。
|
||
/// Mj.Automatic 自带守卫(State、轮次、是否已响应),所以无脑轮询是安全的。
|
||
/// </summary>
|
||
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";
|
||
}
|
||
|
||
/// <summary>
|
||
/// AskStartMahjongGame(63184671) —— 单机麻将入口。
|
||
/// C2S = roomType(u8) + npcIdLs(List7Bit u32) + seatIndex(i32) + addFavor(bool) + gameType(u8)。
|
||
/// RET = void(空 body + retcode 0,与捐赠端同形)。
|
||
/// </summary>
|
||
[Handler(MethodId.AskStartMahjongGame)]
|
||
private async Task MahjongStart4229938(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var b = msg.Body ?? Array.Empty<byte>();
|
||
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<byte>(), 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<byte>(), 0);
|
||
return;
|
||
}
|
||
|
||
int seat = HumanSeat(g);
|
||
var b = msg.Body ?? Array.Empty<byte>();
|
||
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<byte>(), 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<int>();
|
||
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;
|
||
}
|
||
}
|