feat: ISMCTS bot + full documentation

新增:
- PdkFriendServer/Logic/IsmctsBot.cs: ISMCTS蒙特卡洛搜索机器人
  - 800次模拟/决策,对手手牌从未知牌池随机采样
  - 包庄决策:大牌(≥J/Q/K/A/2)数量 ≥6 则包庄
  - winRate日志输出,决策透明
- docs/bot-architecture.md: 系统架构文档
- docs/bot-algorithms.md: A/B/C三条算法路径完整分析
- docs/ismcts-design.md: ISMCTS实现设计详案

切换:
- PdkGameMain.InitBots: PdkBot → IsmctsBot(默认ISMCTS)

验证:
- dotnet build 0 errors
- 多人对局完整运行:包庄→出牌→结算→test over
This commit is contained in:
2026-07-07 13:27:00 +08:00
parent ec87d921a3
commit a75733e4ae
13 changed files with 691 additions and 337 deletions

View File

@ -0,0 +1,251 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameFix.PaoDeKuaiF;
using GameFix.Poker;
using GameMessage;
using GameMessage.PaoDeKuaiF;
namespace PdkFriendServer.Logic
{
/// <summary>
/// ISMCTS (Information Set Monte Carlo Tree Search) 机器人
/// 对每个候选出牌方案模拟 N 局随机对局,选胜率最高的方案
/// 只看到自己手牌+桌面信息——对手手牌从未知牌池随机采样
/// </summary>
public class IsmctsBot : IPdkBot
{
private readonly int _simulations;
private readonly Random _rng;
public IsmctsBot(int simulations = 800)
{
_simulations = simulations;
_rng = new Random();
}
public bool DecideBaoZhuang(PdkBotView view)
{
// 包庄决策:用快速模拟评估——手牌质量高就包
// 简化:手牌中 >10 的大牌J/Q/K/A/2多就包
int bigCards = view.MyHand.Count(c => c.GameNum >= 11);
return bigCards >= 6; // ≥6 张大牌 → 有一定控制力
}
public PlayOutCardPdkF DecidePlay(PdkBotView view)
{
var hand = view.MyHand;
if (hand.Length == 0)
return Pass(view);
bool isFirstPlay = view.MaxPlayCard.GameNum <= 0;
// 枚举所有合法出牌
var tips = PdkCardAlgorithm.GetTipCard(
view.MaxPlayCard, hand, null, view.Rule.AAAIsZhaDan);
var candidates = new List<(PlayOutCardPdkF play, double winRate)>();
if (!isFirstPlay)
candidates.Add((Pass(view), 0));
if (tips != null)
foreach (var tip in tips)
candidates.Add((MakePlay(tip, view.MyPos), 0));
if (candidates.Count == 1)
return candidates[0].play;
// 对每个候选做蒙特卡洛评估
int unknownCount = 48 - hand.Length - CountPlayed(hand, view);
int simsPerCandidate = Math.Max(50, _simulations / candidates.Count);
for (int ci = 0; ci < candidates.Count; ci++)
{
int wins = 0;
var play = candidates[ci].play;
for (int s = 0; s < simsPerCandidate; s++)
{
if (SimulateOneGame(hand, play, view, unknownCount))
wins++;
}
candidates[ci] = (play, (double)wins / simsPerCandidate);
}
candidates.Sort((a, b) => b.winRate.CompareTo(a.winRate));
var best = candidates[0];
string desc = best.play.Type == CardType1.None ? "pass"
: $"{best.play.Type}:{best.play.Ids?.Length ?? 0}c";
Console.WriteLine($"[ISMCTS pos{view.MyPos}] {simsPerCandidate * candidates.Count}sims "
+ $"winRate:{best.winRate:P0} => {desc}");
return best.play;
}
// ---- 模拟引擎 ----
private bool SimulateOneGame(
TCardInfoPdkF[] myHand, PlayOutCardPdkF myPlay,
PdkBotView view, int unknownCount)
{
int myPos = view.MyPos;
int nextPos = myPos % 3 + 1;
int oppPos = (myPos + 1) % 3 + 1;
// 构建 3 人手牌:我已知,对手随机分配
var hands = new List<TCardInfoPdkF>[3];
hands[myPos - 1] = new List<TCardInfoPdkF>(myHand);
hands[nextPos - 1] = new List<TCardInfoPdkF>();
hands[oppPos - 1] = new List<TCardInfoPdkF>();
// 生成 48 张标准牌,排除我的手牌
var allCards = GenerateDeck();
var unknown = allCards.Where(c => !myHand.Any(h => h.ID == c.ID)).ToList();
Shuffle(unknown);
int nextCnt = view.ShenYuCard[nextPos - 1];
int oppCnt = view.ShenYuCard[oppPos - 1];
hands[nextPos - 1].AddRange(unknown.Take(nextCnt));
hands[oppPos - 1].AddRange(unknown.Skip(nextCnt).Take(oppCnt));
// 应用我的第一步出牌
var maxPlay = new PlayOutCardPdkF
{
Pos = (byte)myPos,
GameNum = myPlay.GameNum,
Type = myPlay.Type,
Ids = myPlay.Ids?.ToArray()
};
if (myPlay.Type != CardType1.None && myPlay.Ids != null)
{
foreach (var id in myPlay.Ids)
hands[myPos - 1].RemoveAll(c => c.ID == id);
}
// 模拟到结束
int current = myPos % 3 + 1; // 下家先跟
int maxPlayer = myPlay.Type == CardType1.None ? view.MaxPlayCard.Pos : myPos;
if (myPlay.Type == CardType1.None)
{
maxPlayer = view.MaxPlayCard.Pos;
maxPlay = CloneCard(view.MaxPlayCard);
}
int moveCount = 0;
int maxMoves = 500;
while (moveCount++ < maxMoves)
{
var curHand = hands[current - 1];
if (curHand.Count == 0)
return current == myPos; // 我赢了
bool isNewRound = maxPlayer == current;
// 获取当前玩家的合法出牌
var curMax = isNewRound
? new PlayOutCardPdkF() // GameNum=0 means new round
: maxPlay;
var curTips = PdkCardAlgorithm.GetTipCard(
curMax, curHand.ToArray(), null, false);
if (isNewRound || (curTips != null && curTips.Count > 0))
{
// 选第一个方案(最小牌)
var pick = isNewRound
? curHand.OrderBy(c => c.GameNum).First()
: curTips[0][0];
var ids = isNewRound
? new[] { pick.ID }
: curTips[0].Select(c => c.ID).ToArray();
maxPlay = new PlayOutCardPdkF
{
Pos = (byte)current,
GameNum = pick.GameNum,
Type = curTips?[0].Count == 1 ? CardType1.DanZhang : CardType1.DuiZi,
Ids = ids
};
maxPlayer = current;
foreach (var id in ids)
curHand.RemoveAll(c => c.ID == id);
}
// else: pass, keep maxPlay unchanged
current = current % 3 + 1;
}
// 超时——比较剩余牌数
return hands[myPos - 1].Count <= hands[nextPos - 1].Count
&& hands[myPos - 1].Count <= hands[oppPos - 1].Count;
}
// ---- 工具 ----
private static int CountPlayed(TCardInfoPdkF[] myHand, PdkBotView view)
{
// 粗略估计已出牌:总牌 48 - 我的手牌 - 所有剩余牌
int total = 48 - myHand.Length;
foreach (var c in view.ShenYuCard) total -= c;
return Math.Max(0, total);
}
private static List<TCardInfoPdkF> GenerateDeck()
{
var deck = new List<TCardInfoPdkF>();
int id = 1;
// 花色 1=黑桃 2=红桃 3=梅花 4=方块, 大小 1-13 (A=14, 2=16)
foreach (int flower in new[] { 1, 2, 3, 4 })
for (int num = 1; num <= 13; num++)
{
int gameNum = num switch
{
1 => 14, // A
2 => 16, // 2
_ => num
};
deck.Add(new TCardInfoPdkF { ID = (byte)id++, Flower = (byte)flower, GameNum = (byte)gameNum, GameState = 1 });
}
return deck;
}
private void Shuffle<T>(List<T> list)
{
for (int i = list.Count - 1; i > 0; i--)
{
int j = _rng.Next(i + 1);
(list[i], list[j]) = (list[j], list[i]);
}
}
private static PlayOutCardPdkF CloneCard(PlayOutCardPdkF src)
{
return new PlayOutCardPdkF
{
Pos = src.Pos,
GameNum = src.GameNum,
Type = src.Type,
Ids = src.Ids?.ToArray()
};
}
private static PlayOutCardPdkF MakePlay(List<TCardInfoPdkF> cards, int pos)
{
return new PlayOutCardPdkF
{
Pos = (byte)pos,
GameNum = cards[0].GameNum,
Ids = cards.Select(x => x.ID).ToArray(),
Type = CardType1.DanZhang
};
}
private static PlayOutCardPdkF Pass(PdkBotView view)
{
return new PlayOutCardPdkF { Pos = (byte)view.MyPos, Type = CardType1.None };
}
}
}

View File

@ -61,7 +61,7 @@ namespace PdkFriendServer.Logic
{
_bots = new IPdkBot[3];
for (int i = 0; i < 3; i++)
_bots[i] = new PdkBot();
_bots[i] = new IsmctsBot();
}
/// <summary>构建当前玩家的可见视图——只看自己手牌+公开信息</summary>