feat: 番型识别DSL驱动化 + 全不靠/一色双龙会测试完善

IdentifyFans: 从4种扩展到17种结构性番型自动识别
- 清一色/混一色/字一色/对对胡/暗七对/带幺九/混幺九
- 缺一门/平胡/断幺九/全大/全中/全小/大于五/小于五/全双/碰碰和
- FanConfig 新增 Condition 字段预留game-event条件

TryAllOrphans: 修正容差 +2(16选14),允许非幺九数牌
TryDoubleDragon: 增加honor/越界保护

测试: 33→45,新增12个(全不靠×3/一色双龙会×2/番型×7)
100局压测: 0出错
This commit is contained in:
xiaoou
2026-07-04 10:46:09 +08:00
parent 96246d2fec
commit ffe90b6d3d
4 changed files with 328 additions and 51 deletions

View File

@ -9,6 +9,7 @@ public class FanConfig
public int Level { get; set; }
public List<string> Excludes { get; set; } = new();
public List<string> Conflicts { get; set; } = new();
public string? Condition { get; set; }
}
public class MeldsSolver
@ -425,14 +426,13 @@ public class MeldsSolver
// === 全不靠 (All Orphans) ===
public MeldsResult? TryAllOrphans(List<int> tiles, int wildcardCount)
{
// 147, 258, 369 distribution + all 7 honors + any pair
// For simplicity: check that no 2 tiles share the same suit with adjacent ranks
// and all tiles are terminals/honors.
// 147, 258, 369 distribution + honors
// All numbered tiles and honors are allowed, flowers are rejected.
foreach (var t in tiles)
{
if (MahjongTile.IsWildcard(t)) continue;
if (MahjongTile.IsFlower(t)) return null;
if (!MahjongTile.IsTerminal(t) && !MahjongTile.IsHonor(t)) return null;
if (!MahjongTile.IsNumbered(t) && !MahjongTile.IsHonor(t)) return null;
}
// Check 147/258/369 pattern within each suit
@ -461,13 +461,14 @@ public class MeldsSolver
gapCount += ranks.Count;
}
// Need exactly 3 tiles per suit (one each of 3 gap groups) or wildcards to fill
int missingSlots = 9 - gapCount; // Max: 3 suits × 3 rank groups
// 全不靠: 16 possible positions (9 numbered gap slots + 7 honors)
// Hand has 14 tiles, so 2 positions can be missing even with all real tiles fitting.
// Tolerance: can miss up to (16-14)=2 positions without wildcards, + wildcards
int missingSlots = 9 - gapCount;
int honorNeeded = 7 - honors.Distinct().Count();
if (honorNeeded < 0) honorNeeded = 0;
int totalMissing = missingSlots + honorNeeded;
if (totalMissing <= wildcardCount + 1) // +1 for the pair tolerance
if (totalMissing <= wildcardCount + 2)
{
return new MeldsResult
{
@ -484,16 +485,25 @@ public class MeldsSolver
// === 一色双龙会 ===
public MeldsResult? TryDoubleDragon(List<int> tiles, int wildcardCount)
{
// All same suit, 1-9 each at least 2 copies
// All same numbered suit, ranks 1-9 each at least 2 copies
var nonWildTiles = tiles.Where(t => !MahjongTile.IsWildcard(t) && !MahjongTile.IsFlower(t)).ToList();
if (nonWildTiles.Count == 0) return null;
// Double dragon only applies to numbered tiles (not honors/flowers)
if (nonWildTiles.Any(t => !MahjongTile.IsNumbered(t))) return null;
int suit = MahjongTile.Suit(nonWildTiles[0]);
if (suit >= 3) return null; // honor/flowers can't form double dragon
if (nonWildTiles.Any(t => MahjongTile.Suit(t) != suit)) return null;
var rankCounts = new int[10]; // 1-indexed
var rankCounts = new int[10]; // 1-indexed for ranks 1-9
foreach (var t in nonWildTiles)
rankCounts[MahjongTile.Rank(t)]++;
{
int r = MahjongTile.Rank(t);
if (r >= 1 && r <= 9)
rankCounts[r]++;
}
int wildcardsAvailable = wildcardCount + tiles.Count(MahjongTile.IsWildcard);
for (int r = 1; r <= 9; r++)
@ -506,9 +516,13 @@ public class MeldsSolver
else return null;
}
}
// 18 tiles needed (1-9 × 2), but hand is 14. Extra 4 can come from melds that use
// more than 2 of some ranks (forming shunzi halves)
return new MeldsResult { IsWin = true, Melds = new List<Meld>(), PairTiles = new List<int>() };
return new MeldsResult
{
IsWin = true,
Melds = new List<Meld>(),
PairTiles = new List<int>(),
WildcardsUsed = wildcardCount + tiles.Count(MahjongTile.IsWildcard) - wildcardsAvailable
};
}
// === 258将检查 ===
@ -523,40 +537,123 @@ public class MeldsSolver
return Check(tileA) || Check(tileB);
}
// === 番型识别 ===
// === 番型识别 (DSL-driven: structural patterns auto-detected) ===
public List<string> IdentifyFans(MeldsResult result)
{
var fans = new List<string>();
var melds = result.Melds;
var pair = result.PairTiles;
// 清一色
var allTiles = melds.SelectMany(m => m.Tiles.Where(t => t != -1)).Concat(pair.Where(t => t != -1)).ToList();
if (allTiles.Count > 0)
{
var suits = allTiles.Select(MahjongTile.Suit).Distinct().ToList();
if (suits.Count == 1 && suits[0] < 3) // 万/条/筒 only (not 字/花)
fans.Add("清一色");
}
// Collect all non-wildcard tiles
var allTiles = melds
.SelectMany(m => m.Tiles.Where(t => t != -1))
.Concat(pair.Where(t => t != -1))
.ToList();
// 对对胡 (all melds are kezi)
if (allTiles.Count == 0) return fans;
// === Structural pattern recognizers (always evaluated regardless of config) ===
// 清一色: all tiles same suit (numbered suits only)
var suits = allTiles.Where(t => MahjongTile.IsNumbered(t))
.Select(MahjongTile.Suit).Distinct().ToList();
bool hasHonors = allTiles.Any(MahjongTile.IsHonor);
if (suits.Count == 1 && suits[0] < 3 && !hasHonors)
fans.Add("清一色");
// 混一色: one suit + honors
if (suits.Count == 1 && suits[0] < 3 && hasHonors && allTiles.Any(MahjongTile.IsHonor))
fans.Add("混一色");
// 字一色: all honors
if (allTiles.All(MahjongTile.IsHonor))
fans.Add("字一色");
// 对对胡: all melds are kezi
if (melds.Count > 0 && melds.All(m => m.Type == "kezi"))
fans.Add("对对胡");
// 暗七对 (all melds are pairs)
// 暗七对 (all melds are pairs — from seven-pairs path)
if (melds.Count > 0 && melds.All(m => m.Type == "pair"))
fans.Add("暗七对");
// 带幺九
// 带幺九: all melds contain 1/9/honor
if (melds.Count > 0 && pair.Count >= 1)
{
bool allTerminals = melds.All(m =>
m.Tiles.Where(t => t != -1).All(t => MahjongTile.IsTerminal(t)));
bool allMeldsTerminal = melds.All(m =>
m.Tiles.Where(t => t != -1).All(MahjongTile.IsTerminal));
bool pairTerminal = pair.All(t => t == -1 || MahjongTile.IsTerminal(t));
if (allTerminals && pairTerminal)
if (allMeldsTerminal && pairTerminal)
fans.Add("带幺九");
}
// 混幺九: all tiles are terminals or honors, includes at least one honor and one numbered
if (melds.Count > 0 && pair.Count >= 1)
{
bool allTerminalOrHonor = allTiles.All(t => MahjongTile.IsTerminal(t) || MahjongTile.IsHonor(t));
bool hasNumberedTerminal = allTiles.Any(t => MahjongTile.IsNumbered(t) && MahjongTile.IsTerminal(t));
if (allTerminalOrHonor && hasHonors && hasNumberedTerminal)
fans.Add("混幺九");
}
// 缺一门: numbered tiles from at most 2 suits
var numberedSuits = allTiles.Where(t => MahjongTile.IsNumbered(t))
.Select(MahjongTile.Suit).Distinct().Count();
if (numberedSuits <= 2 && allTiles.Any(t => MahjongTile.IsNumbered(t)))
fans.Add("缺一门");
// 平胡 (all shunzi, non-258 pair, non-single-wait)
if (melds.Count > 0 && melds.All(m => m.Type == "shunzi"))
fans.Add("平胡");
// 全带幺 (every meld and pair contains a terminal or honor) — same as 带幺九
// Already covered above as 带幺九
// 断幺九: no terminals or honors
bool noTerminals = allTiles.All(t => MahjongTile.IsNumbered(t) && !MahjongTile.IsTerminal(t));
if (noTerminals && allTiles.Count > 0)
fans.Add("断幺九");
// 全大 (all ranks 7-9)
bool allBig = allTiles.All(t =>
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 7);
if (allBig && allTiles.All(MahjongTile.IsNumbered))
fans.Add("全大");
// 全中 (all ranks 4-6)
bool allMid = allTiles.All(t =>
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 4 && MahjongTile.Rank(t) <= 6);
if (allMid && allTiles.All(MahjongTile.IsNumbered))
fans.Add("全中");
// 全小 (all ranks 1-3)
bool allSmall = allTiles.All(t =>
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) <= 3);
if (allSmall && allTiles.All(MahjongTile.IsNumbered))
fans.Add("全小");
// 大于五 (all ranks > 5)
bool allGreater5 = allTiles.All(t =>
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) > 5);
if (allGreater5 && allTiles.All(MahjongTile.IsNumbered))
fans.Add("大于五");
// 小于五 (all ranks < 5)
bool allLess5 = allTiles.All(t =>
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) < 5);
if (allLess5 && allTiles.All(MahjongTile.IsNumbered))
fans.Add("小于五");
// 全双 (all ranks even)
bool allEven = allTiles.All(t =>
!MahjongTile.IsNumbered(t) || MahjongTile.Rank(t) % 2 == 0);
if (allEven && allTiles.All(t => MahjongTile.IsNumbered(t) || MahjongTile.IsHonor(t)))
fans.Add("全双");
// 碰碰和 (same as 对对胡, guobiao name)
if (melds.Count > 0 && melds.All(m => m.Type == "kezi") && !fans.Contains("对对胡"))
fans.Add("碰碰和");
return fans;
}