Files

330 lines
18 KiB
C#

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<int> Holds=[], Folds=[], HuTiles=[];
internal readonly List<Meld> Melds=[];
internal readonly HashSet<int> 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<Ledger> Actions=[];
internal readonly List<Event> Events=[];
internal readonly List<int> WonTiles=[];
internal readonly int Banker, ExchangeStep, BaseScore;
private readonly List<int> 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<int,Options> offers=[];
private Dictionary<int,(string Kind,int[] Tiles)>? responses;
private readonly List<Ledger> lastKongs=[];
private bool uninterrupted=true, afterKong;
internal MahjongRules4229938(int banker=0,int? exchangeStep=null,IEnumerable<int>? 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<int> 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<Meld>();
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<int> 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;i<winners.Length;i++)
{
int p=winners[i];var info=infos[i];int[] targets=source<0?Enumerable.Range(0,4).Where(x=>x!=p).ToArray():[source];
Transfer(new(0,p,targets,checked(BaseScore*(1<<info.Fan)),tile,info.Pattern,info.Roots,info.Fan,info.Flags));
Seats[p].HuTiles.Add(tile);
}
if(source<0)Seats[winners[0]].Holds.Remove(tile);
if(source>=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<int> hand,IReadOnlyList<Meld> 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<<fans[q]),Fan:fans[q]));
if(unready.Length>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;
}
}