using System.Security.Cryptography; namespace Ananta.Server.Protocol.Client4229938; // XLCH rules for the four-player link entries. Sources: MahjongHelpConfig, // MahjongConfig and TingHelper in build 4229938. Scores are table points only. internal sealed class MahjongRules4229938 { internal sealed record Meld(int Source, int Tile, int[] Selected, byte Kind) { internal int[] Tiles => Kind == 2 ? Selected : Selected.Append(Tile).ToArray(); } internal sealed record Win(byte Pattern, int Roots, int Fan, int Flags); internal sealed record Ledger(byte Kind, int Owner, int[] Targets, int Score, int Tile=0, byte Pattern=2, int Roots=0, int Fan=0, int Flags=0) { internal bool Transferred; } internal sealed record Event(string Kind, int Seat=-1, int Tile=0, int[]? Tiles=null, int Value=0, int Source=-1, int[]? Seats=null, int[]? Flags=null, int[][]? Hands=null); internal sealed record Options(int Tile, bool Hu, bool Discard, Meld[] Peng, Meld[] Gang); internal sealed class Seat { internal readonly List Holds=[], Folds=[], HuTiles=[]; internal readonly List Melds=[]; internal readonly HashSet PassedPeng=[]; internal int[]? Exchange; internal int[] Received=[]; internal byte Que; internal int Score=10000, Draws, Discards; internal bool HasWon=>HuTiles.Count>0; } internal readonly Seat[] Seats=Enumerable.Range(0,4).Select(_=>new Seat()).ToArray(); internal readonly List Actions=[]; internal readonly List Events=[]; internal readonly List WonTiles=[]; internal readonly int Banker, ExchangeStep, BaseScore; private readonly List wall; internal byte State; // Begin=0, HuanPai=1, HuanPaiOver=2, DingQue=3, Playing=4, Over=5. internal int Turn, LastTurn=-1, LastDrawSeat=-1, DrawnTile, Version, Round=1; internal int Remaining=>wall.Count; internal bool Waiting=>responses!=null; internal int PendingTile, PendingSource=-1; internal bool RobKong; private readonly Dictionary offers=[]; private Dictionary? responses; private readonly List lastKongs=[]; private bool uninterrupted=true, afterKong; internal MahjongRules4229938(int banker=0,int? exchangeStep=null,IEnumerable? deck=null,int baseScore=100) { if(banker is <0 or >3||baseScore<1)throw new ArgumentOutOfRangeException(nameof(banker)); Banker=Turn=banker;BaseScore=baseScore;ExchangeStep=exchangeStep??RandomNumberGenerator.GetInt32(1,4); if(ExchangeStep is <1 or >3)throw new ArgumentOutOfRangeException(nameof(exchangeStep)); wall=deck?.ToList()??Enumerable.Range(1,108).ToList(); if(wall.Count!=108||wall.Distinct().Count()!=108||wall.Any(t=>t is <1 or >108))throw new ArgumentException("108 unique tiles required"); if(deck==null)for(int i=wall.Count-1;i>0;i--){int j=RandomNumberGenerator.GetInt32(i+1);(wall[i],wall[j])=(wall[j],wall[i]);} } internal static int Index(int tile)=>(tile-1)/4; internal static byte Suit(int tile)=>(byte)(Index(tile)/9+1); private bool ValidSeat(int seat)=>seat is >=0 and <4; private int[][] Hands()=>Seats.Select(s=>s.Holds.ToArray()).ToArray(); private int Take(bool tail=false){int at=tail?wall.Count-1:0;int t=wall[at];wall.RemoveAt(at);return t;} internal void Start() { if(State!=0)return; for(int n=0;n<13;n++)for(int p=0;p<4;p++)Seats[(Banker+p)%4].Holds.Add(Take()); DrawnTile=Take();Seats[Banker].Holds.Add(DrawnTile);Seats[Banker].Draws=1;LastDrawSeat=Banker; State=1;Version++;Events.Add(new("ExchangeBegin")); } internal int[] DefaultExchange(int seat)=>Seats[seat].Holds.GroupBy(Suit).Where(g=>g.Count()>=3) .OrderBy(g=>g.Count()).ThenBy(g=>g.Key).First().OrderBy(Index).Take(3).ToArray(); internal byte DefaultQue(int seat)=>(byte)Enumerable.Range(1,3).OrderBy(s=>Seats[seat].Holds.Count(t=>Suit(t)==s)).ThenBy(s=>s).First(); internal bool Exchange(int seat,IReadOnlyList tiles) { if(State!=1||!ValidSeat(seat)||Seats[seat].Exchange!=null||tiles.Count!=3||tiles.Distinct().Count()!=3 ||tiles.Any(t=>!Seats[seat].Holds.Contains(t))||tiles.Select(Suit).Distinct().Count()!=1)return false; Seats[seat].Exchange=tiles.ToArray();Events.Add(new("ExchangeChosen",seat,Tiles:tiles.ToArray())); if(Seats.Any(s=>s.Exchange==null))return true; foreach(var s in Seats)foreach(int t in s.Exchange!)s.Holds.Remove(t); for(int p=0;p<4;p++) {var s=Seats[(p+ExchangeStep)%4];s.Received=Seats[p].Exchange!.ToArray();s.Holds.AddRange(s.Received);} // The extra tile can have been passed. The banker still has fourteen tiles. if(!Seats[Banker].Holds.Contains(DrawnTile))DrawnTile=Seats[Banker].Holds[^1]; Events.Add(new("ExchangeDone",Value:ExchangeStep-1,Hands:Hands())); State=3;Version++;Events.Add(new("DingBegin"));return true; } internal bool DingQue(int seat,byte suit) { if(State!=3||!ValidSeat(seat)||suit is <1 or >3||Seats[seat].Que!=0)return false; Seats[seat].Que=suit; if(Seats.All(s=>s.Que!=0)){State=4;Version++;Events.Add(new("DingDone"));Events.Add(new("Turn",Turn));} return true; } internal Options GetOptions(int seat) { if(!ValidSeat(seat)||State!=4)return new(0,false,false,[],[]); if(Waiting)return offers.TryGetValue(seat,out var o)&&!responses!.ContainsKey(seat)?o:new(0,false,false,[],[]); if(seat!=Turn)return new(0,false,false,[],[]); var s=Seats[seat];var gangs=new List(); if(!s.HasWon&&DrawnTile!=0&&Remaining>0&&!s.Holds.Any(t=>Suit(t)==s.Que)) { foreach(var g in s.Holds.GroupBy(Index).Where(g=>g.Count()==4))gangs.Add(new(seat,g.First(),g.ToArray(),2)); foreach(var m in s.Melds.Where(m=>m.Kind==0))foreach(int t in s.Holds.Where(t=>Index(t)==Index(m.Tile))) gangs.Add(new(m.Source,t,[t],4)); } return new(DrawnTile,DrawnTile!=0&&Evaluate(s.Holds.Select(Index).ToArray(),s.Melds,s.Que)!=null,true,[],gangs.ToArray()); } internal bool Discard(int seat,int tile) { if(State!=4||Waiting||seat!=Turn||!ValidSeat(seat))return false; var s=Seats[seat]; if(!s.Holds.Contains(tile)||s.HasWon&&tile!=DrawnTile||s.Holds.Any(t=>Suit(t)==s.Que)&&Suit(tile)!=s.Que)return false; s.Holds.Remove(tile);s.Folds.Add(tile);s.Discards++;LastTurn=seat;DrawnTile=0; Events.Add(new("Discard",seat,tile,Hands:Hands())); BeginClaims(seat,tile,false);return true; } private void BeginClaims(int source,int tile,bool rob) { PendingSource=source;PendingTile=tile;RobKong=rob;offers.Clear();responses=[];Version++; for(int p=0;p<4;p++) { if(p==source)continue;var s=Seats[p];int[] same=s.Holds.Where(t=>Index(t)==Index(tile)).ToArray(); bool hu=Evaluate(s.Holds.Select(Index).Append(Index(tile)).ToArray(),s.Melds,s.Que)!=null; bool mayMeld=!rob&&!s.HasWon&&!s.Holds.Any(t=>Suit(t)==s.Que)&&Suit(tile)!=s.Que; Meld[] peng=mayMeld&&same.Length>=2&&!s.PassedPeng.Contains(Index(tile))?[new(source,tile,same.Take(2).ToArray(),0)]:[]; Meld[] gang=mayMeld&&same.Length==3&&Remaining>0?[new(source,tile,same,3)]:[]; if(hu||peng.Length+gang.Length>0)offers[p]=new(tile,hu,false,peng,gang); if(hu&&s.HasWon)responses[p]=("Hu",[]); } ResolveClaims(); } internal bool Pass(int seat) { if(State!=4||!ValidSeat(seat))return false; if(!Waiting) { if(seat!=Turn)return false; // Passing a self-draw offer merely leaves the discard operation enabled. Events.Add(new("Pass",seat,DrawnTile));return true; } return Respond(seat,"Pass",[]); } internal bool Hu(int seat) { if(!GetOptions(seat).Hu)return false; if(Waiting)return Respond(seat,"Hu",[]); WinTiles([seat],DrawnTile,-1,false);DrawnTile=0;lastKongs.Clear();Next((seat+1)%4);return true; } internal bool Peng(int seat,IReadOnlyList chosen) { var o=GetOptions(seat); // A player holding three identical tiles may select any two physical copies. // The offered pair is a display suggestion, not a restriction on that choice. return o.Peng.Length>0&&chosen.Count==2&&chosen[0]!=chosen[1] &&chosen.All(t=>Seats[seat].Holds.Contains(t)&&Index(t)==Index(o.Tile)) &&Respond(seat,"Peng",chosen.ToArray()); } internal bool Gang(int seat,int tile) { var o=GetOptions(seat);var match=o.Gang.FirstOrDefault(m=>m.Tile==tile); if(match==null)return false; if(Waiting)return Respond(seat,"Gang",match.Selected); if(match.Kind==4){Events.Add(new("HanGang",seat,tile,Value:4));BeginClaims(seat,tile,true);} else CompleteKong(seat,match); return true; } private bool Respond(int seat,string kind,int[] tiles) { if(responses==null||!offers.TryGetValue(seat,out var o)||responses.ContainsKey(seat))return false; if(kind!="Peng"&&o.Peng.Length>0)Seats[seat].PassedPeng.Add(Index(PendingTile)); responses[seat]=(kind,tiles);if(kind=="Pass")Events.Add(new("Pass",seat,PendingTile)); ResolveClaims();return true; } private void ResolveClaims() { if(responses==null||offers.Keys.Any(p=>!responses.ContainsKey(p)))return; var replies=responses;responses=null;int source=PendingSource,tile=PendingTile;bool rob=RobKong; int[] winners=replies.Where(k=>k.Value.Kind=="Hu").Select(k=>k.Key).OrderBy(p=>(p-source+4)%4).ToArray(); if(winners.Length>0) { if(rob)Seats[source].Holds.Remove(tile);else Seats[source].Folds.Remove(tile); WinTiles(winners,tile,source,rob);lastKongs.Clear();Next((source+1)%4);return; } if(rob){CompleteKong(source,new(source,tile,[tile],4));return;} var claim=replies.Where(k=>k.Value.Kind is "Peng" or "Gang").OrderBy(k=>(k.Key-source+4)%4).ToArray(); if(claim.Length>0) { int p=claim[0].Key;var r=claim[0].Value; var meld=new Meld(source,tile,r.Tiles,r.Kind=="Peng"?(byte)0:(byte)3); Seats[source].Folds.Remove(tile);lastKongs.Clear(); if(r.Kind=="Gang")CompleteKong(p,meld); else { foreach(int t in meld.Selected)Seats[p].Holds.Remove(t);Seats[p].Melds.Add(meld);uninterrupted=false; Turn=p;DrawnTile=0;afterKong=false;Version++; Events.Add(new("Peng",p,tile,meld.Selected,Source:source,Hands:Hands()));Events.Add(new("Turn",Turn)); } return; } lastKongs.Clear();Next((source+1)%4); } private void CompleteKong(int seat,Meld meld) { var s=Seats[seat]; if(meld.Kind==4) { int at=s.Melds.FindIndex(m=>m.Kind==0&&Index(m.Tile)==Index(meld.Tile));var old=s.Melds[at]; s.Holds.Remove(meld.Tile);s.Melds[at]=new(old.Source,old.Tile,old.Selected.Append(meld.Tile).ToArray(),4); } else {foreach(int t in meld.Selected)s.Holds.Remove(t);s.Melds.Add(meld);} uninterrupted=false;Turn=seat;DrawnTile=0; Events.Add(new("Gang",seat,meld.Tile,meld.Selected,meld.Kind,Source:meld.Source,Hands:Hands())); var targets=meld.Kind==3?new[]{meld.Source}:Enumerable.Range(0,4).Where(p=>p!=seat).ToArray(); var action=new Ledger(meld.Kind==2?(byte)1:meld.Kind==4?(byte)2:(byte)3,seat,targets, BaseScore*(meld.Kind==4?1:2),meld.Tile); Transfer(action);lastKongs.Add(action);Next(seat,true); } private void Next(int seat,bool kong=false) { if(Remaining==0){Finish();return;} Turn=seat;Round++;LastDrawSeat=seat;afterKong=kong;RobKong=false;PendingTile=0;PendingSource=-1; DrawnTile=Take(kong);Seats[seat].Holds.Add(DrawnTile);Seats[seat].Draws++;Seats[seat].PassedPeng.Clear();Version++; Events.Add(new("Turn",seat));Events.Add(new("Draw",seat,DrawnTile,Value:Remaining,Hands:Hands())); } private Win WinInfo(int seat,int tile,int source,bool rob) { var s=Seats[seat];var tiles=s.Holds.Select(Index).ToList();if(source>=0)tiles.Add(Index(tile)); var info=Evaluate(tiles,s.Melds,s.Que)!;int fan=info.Fan,flags=info.Flags; if(source<0) { if(uninterrupted&&seat==Banker&&s.Draws==1&&s.Discards==0)return info with{Fan=5,Flags=32}; if(uninterrupted&&seat!=Banker&&s.Draws==1)return info with{Fan=5,Flags=64}; fan++;flags|=128;if(afterKong){fan++;flags|=1;}if(Remaining==0){fan++;flags|=16;} } else {flags|=1024;if(rob){fan++;flags|=8;}else if(lastKongs.Count>0){fan++;flags|=4;}} return info with{Fan=fan,Flags=flags}; } private void WinTiles(int[] winners,int tile,int source,bool rob) { var infos=winners.Select(p=>WinInfo(p,tile,source,rob)).ToArray(); for(int i=0;ix!=p).ToArray():[source]; Transfer(new(0,p,targets,checked(BaseScore*(1<=0&&!rob&&lastKongs.Count>0) { int transfer=lastKongs.Sum(a=>a.Score*a.Targets.Length); foreach(int p in winners)Transfer(new(4,p,[source],transfer,tile)); foreach(var a in lastKongs)a.Transferred=true; Events.Add(new("Transfer",source)); } WonTiles.Add(tile);Events.Add(new("Hu",Tile:tile,Value:rob?1:0,Source:source,Seats:winners,Flags:infos.Select(i=>i.Flags).ToArray(),Hands:Hands())); uninterrupted=false; } private void Transfer(Ledger a) { Actions.Add(a); foreach(int p in a.Targets){Seats[p].Score=checked(Seats[p].Score-a.Score);Events.Add(new("Score",p,Value:Seats[p].Score,Source:-a.Score));} int gained=checked(a.Score*a.Targets.Length);Seats[a.Owner].Score=checked(Seats[a.Owner].Score+gained); Events.Add(new("Score",a.Owner,Value:Seats[a.Owner].Score,Source:gained)); } internal static Win? Evaluate(IReadOnlyList hand,IReadOnlyList melds,byte que) { if(que is <1 or >3||hand.Count!=14-melds.Count*3||hand.Any(i=>i is <0 or >=27||i/9+1==que))return null; var all=hand.Concat(melds.SelectMany(m=>m.Tiles.Select(Index))).ToArray(); if(all.Any(i=>i/9+1==que)||all.GroupBy(i=>i).Any(g=>g.Count()>4))return null; int[] counts=new int[27];foreach(int i in hand)counts[i]++; bool seven=melds.Count==0&&counts.All(n=>n%2==0); bool triplets=counts.Count(n=>n%3==2)==1&&counts.All(n=>n%3!=1); bool normal=false; for(int i=0;i<27&&!normal;i++)if(counts[i]>=2){counts[i]-=2;normal=Sets(counts);counts[i]+=2;} if(!seven&&!normal)return null; byte pattern=seven?(byte)0:triplets?(byte)1:(byte)2; int roots=all.GroupBy(i=>i).Count(g=>g.Count()==4),fan=(seven?2:triplets?1:0)+roots,flags=0; if(all.Select(i=>i/9).Distinct().Count()==1){fan+=2;flags|=256;} if(hand.Count==2){fan++;flags|=512;} return new(pattern,roots,fan,flags); } private static bool Sets(int[] c) { int at=Array.FindIndex(c,n=>n>0);if(at<0)return true; if(c[at]>=3){c[at]-=3;bool ok=Sets(c);c[at]+=3;if(ok)return true;} if(at%9<=6&&c[at+1]>0&&c[at+2]>0){c[at]--;c[at+1]--;c[at+2]--;bool ok=Sets(c);c[at]++;c[at+1]++;c[at+2]++;if(ok)return true;} return false; } internal int MaximumReadyFan(int seat) { var s=Seats[seat];int best=-1; for(int i=0;i<27;i++)if(Evaluate(s.Holds.Select(Index).Append(i).ToArray(),s.Melds,s.Que) is {} win)best=Math.Max(best,win.Fan); return best; } internal void Finish(bool interrupted=false) { if(State==5)return; responses=null;State=5;Version++; if(!interrupted) { int[] fans=Enumerable.Range(0,4).Select(MaximumReadyFan).ToArray(); int[] pigs=Enumerable.Range(0,4).Where(p=>Seats[p].Holds.Any(t=>Suit(t)==Seats[p].Que)).ToArray(); foreach(int p in pigs)foreach(int q in Enumerable.Range(0,4).Except(pigs))Transfer(new(7,q,[p],16*BaseScore)); if(pigs.Length>0)Events.Add(new("Pigs",Seats:pigs)); int[] unready=Enumerable.Range(0,4).Where(p=>fans[p]<0).ToArray(); foreach(var a in Actions.Where(a=>a.Kind is 1 or 2 or 3&&!a.Transferred&&unready.Contains(a.Owner)).ToArray()) foreach(int p in a.Targets)Transfer(new(5,p,[a.Owner],a.Score,a.Tile)); if(unready.Length>0)Events.Add(new("Refund",Seats:unready)); foreach(int p in unready.Except(pigs))foreach(int q in Enumerable.Range(0,4).Where(q=>fans[q]>=0&&!Seats[q].HasWon)) Transfer(new(6,q,[p],BaseScore*(1<0)Events.Add(new("Unready",Seats:unready)); } Events.Add(new("End",Value:interrupted?1:0)); } internal bool Automatic(int seat,byte flags=7) { if(!ValidSeat(seat))return false; if(State==1)return Seats[seat].Exchange==null&&Exchange(seat,DefaultExchange(seat)); if(State==3)return Seats[seat].Que==0&&DingQue(seat,DefaultQue(seat)); if(State!=4)return false;var o=GetOptions(seat); if(o.Hu&&(flags&1)!=0)return Hu(seat); if(Waiting)return (flags&2)!=0&&offers.ContainsKey(seat)&&!responses!.ContainsKey(seat)&&Pass(seat); if(o.Discard&&(flags&4)!=0) { var s=Seats[seat];int tile=s.Holds.FirstOrDefault(t=>Suit(t)==s.Que); return Discard(seat,tile!=0?tile:DrawnTile!=0?DrawnTile:s.Holds[^1]); } return false; } }