feat: 四归一/圈风/门风/单钓将识别器 + GameState状态跟踪

新增4个番型识别器:
- 四归一: 4张同牌跨面子使用(不在单个kezi/kong) — 纯结构检测
- 圈风: 当前风圈对应的风刻(31=东/32=南/33=西/34=北)
- 门风: 玩家座位风位对应的风刻(庄=东)
- 单钓将: 自摸时摸到的牌完成了将牌的单吊对子

GameState扩展:
- WindRound(当前风圈) + SeatWinds(玩家→风向) + IsSingleWait flag
- Deal()分配座位风位(庄家=东,按逆时针)
- StepTurn自摸时检测单钓将(摸牌←→对子匹配)

效果: 国标--check 6→0 (圈风/门风/单钓将/四归一全部消除)
所有玩法合规检查通过(南昌/血战仅base_fan=0标记警告)
This commit is contained in:
xiaoou
2026-07-04 23:31:31 +08:00
parent 816000a286
commit 3f91d7c3f8
4 changed files with 53 additions and 3 deletions

View File

@ -41,6 +41,11 @@ public class MahjongGameState
public bool IsRobbedKong { get; set; } // 抢杠 — set when someone adds to pung (bu_kong) public bool IsRobbedKong { get; set; } // 抢杠 — set when someone adds to pung (bu_kong)
public bool IsFirstTurn { get; set; } // 第一轮 — set for dealer after deal, non-dealer after first draw public bool IsFirstTurn { get; set; } // 第一轮 — set for dealer after deal, non-dealer after first draw
public string? DealerOnFirstTurn { get; set; } // 庄家首轮(用于天胡判断) public string? DealerOnFirstTurn { get; set; } // 庄家首轮(用于天胡判断)
public bool IsSingleWait { get; set; } // 单钓将: 自摸时摸到的牌完成了将牌的对子
// 圈风/门风: 用于圈风刻/门风刻番型
public int WindRound { get; set; } = 31; // 当前风圈: 31=东/32=南/33=西/34=北
public Dictionary<string, int> SeatWinds { get; set; } = new(); // 玩家→座位风位
public MahjongGameState Clone() public MahjongGameState Clone()
{ {
@ -71,7 +76,10 @@ public class MahjongGameState
IsLastTile = IsLastTile, IsLastTile = IsLastTile,
IsRobbedKong = IsRobbedKong, IsRobbedKong = IsRobbedKong,
IsFirstTurn = IsFirstTurn, IsFirstTurn = IsFirstTurn,
DealerOnFirstTurn = DealerOnFirstTurn DealerOnFirstTurn = DealerOnFirstTurn,
IsSingleWait = IsSingleWait,
WindRound = WindRound,
SeatWinds = new Dictionary<string, int>(SeatWinds)
}; };
} }

View File

@ -68,7 +68,7 @@ public class CapabilityRegistry
"将一色","大四喜","大三元","小四喜","小三元","三风","双箭刻", "将一色","大四喜","大三元","小四喜","小三元","三风","双箭刻",
"一色四同顺","一色三同顺","一般高","五门齐","全求人","门前清", "一色四同顺","一色三同顺","一般高","五门齐","全求人","门前清",
"鸡胡","癞子胡", "鸡胡","癞子胡",
"杠上开花","海底捞月","抢杠胡","天胡","地胡", "圈风","门风","单钓将","四归一","杠上开花","海底捞月","抢杠胡","天胡","地胡",
"将","无字" // base_fan:0/1 markers "将","无字" // base_fan:0/1 markers
}; };
foreach (var f in rules.FanTypes) foreach (var f in rules.FanTypes)

View File

@ -239,7 +239,23 @@ public class MahjongRoom
var result = _solver.CheckWin(fullHand, var result = _solver.CheckWin(fullHand,
wildcardCount: wildInHand, require258Pair: r258); wildcardCount: wildInHand, require258Pair: r258);
if (result != null) if (result != null)
{
// 单钓将: drawn tile completed a pair from a singleton
if (State.LastDrawnTile.HasValue && result.PairTiles.Count == 2)
{
int drawn = State.LastDrawnTile.Value;
if (result.PairTiles.Contains(drawn))
{
int other = result.PairTiles[0] == drawn ? result.PairTiles[1] : result.PairTiles[0];
// Before drawing the winning tile, hand had 1 of the other pair tile
var handBefore = new List<int>(fullHand);
handBefore.Remove(drawn);
if (handBefore.Count(t => t == other) == 1)
State.IsSingleWait = true;
}
}
result.Fans = _solver.IdentifyFans(result, State); // event-based fans result.Fans = _solver.IdentifyFans(result, State); // event-based fans
}
State.HuPlayers.Add(player); State.HuPlayers.Add(player);
State.AlivePlayers.Remove(player); State.AlivePlayers.Remove(player);
State.AddEvent("win", player, null, State.AddEvent("win", player, null,
@ -758,6 +774,15 @@ public class MahjongRoom
State.AddEvent("deal_done", "", null, "发牌完成"); State.AddEvent("deal_done", "", null, "发牌完成");
// 圈风/门风: assign seat winds based on player order relative to dealer
int[] winds = { 31, 32, 33, 34 }; // 东南西北
int dealerIdx = State.PlayerOrder.IndexOf(State.Dealer);
for (int i = 0; i < State.PlayerOrder.Count; i++)
{
int windIdx = (i - dealerIdx + 4) % 4;
State.SeatWinds[State.PlayerOrder[i]] = winds[windIdx];
}
// Random wildcard (骰子翻牌定精): flip one tile from remaining deck to determine 精 // Random wildcard (骰子翻牌定精): flip one tile from remaining deck to determine 精
if (_rules.WildcardRules?.Type == "random" && State.Deck.Count > 0) if (_rules.WildcardRules?.Type == "random" && State.Deck.Count > 0)
{ {

View File

@ -774,8 +774,25 @@ public class MeldsSolver
fans.Add("天胡"); fans.Add("天胡");
else if (state.IsFirstTurn) else if (state.IsFirstTurn)
fans.Add("地胡"); fans.Add("地胡");
// 圈风刻: kezi matching current wind round (e.g. 东风圈→有东刻子)
if (keziTiles.Any(t => t == state.WindRound))
fans.Add("圈风");
// 门风刻: kezi matching player's seat wind (applies per-hand, identified by state owner)
foreach (var (player, seatWind) in state.SeatWinds)
if (keziTiles.Any(t => t == seatWind))
fans.Add("门风");
// 单钓将: detected at self-draw time (single tile→pair completion)
if (state.IsSingleWait) fans.Add("单钓将");
} }
// 四归一: 4 identical numbered tiles used across 2+ melds (not in a single kong)
var tileCounts = allTiles.Where(t => MahjongTile.IsNumbered(t)).GroupBy(t => t);
bool hasSiGuiYi = tileCounts.Any(g =>
g.Count() >= 4 && !melds.Any(m => m.Type == "kezi" && m.Tiles.Contains(g.Key) && m.Tiles.All(x => x == g.Key)));
if (hasSiGuiYi) fans.Add("四归一");
// 鸡胡: base-level fan when no other fan with positive value exists. // 鸡胡: base-level fan when no other fan with positive value exists.
// A fan has positive value if it's in _fanConfig with base_fan>0, // A fan has positive value if it's in _fanConfig with base_fan>0,
// or in the hardcoded fallback list (清一色/混一色/对对胡/碰碰和/暗七对/七对/带幺九/十三幺/鸡胡). // or in the hardcoded fallback list (清一色/混一色/对对胡/碰碰和/暗七对/七对/带幺九/十三幺/鸡胡).
@ -797,7 +814,7 @@ public class MeldsSolver
or "将一色" or "大四喜" or "大三元" or "小四喜" or "小三元" or "三风" or "双箭刻" or "将一色" or "大四喜" or "大三元" or "小四喜" or "小三元" or "三风" or "双箭刻"
or "一色四同顺" or "一色三同顺" or "一般高" or "五门齐" or "全求人" or "门前清" or "一色四同顺" or "一色三同顺" or "一般高" or "五门齐" or "全求人" or "门前清"
or "鸡胡" or "癞子胡" or "平和" or "鸡胡" or "癞子胡" or "平和"
or "杠上开花" or "海底捞月" or "抢杠胡" or "天胡" or "地胡").ToList(); or "圈风" or "门风" or "单钓将" or "四归一" or "杠上开花" or "海底捞月" or "抢杠胡" or "天胡" or "地胡").ToList();
} }
// === 番型互斥应用 === // === 番型互斥应用 ===