feat: SO-ISMCTS 备选方案 (非默认)

SOIsmctsBot.cs (新文件, 284行):
- 单树共享统计: SONode 累积所有候选的胜/负
- 200次检查收敛: winRate>90% → 提前终止
- 整合 HandOptimizer/CardTracker/EndgameSolver/LockDetector

性能问题 (当前不启用):
- 每次模拟重新生成对手手牌(48张TCardInfoPdkF) → 5-8x慢
- 优化方向: 对象池缓存+批量determinization
- 作为备选bot保留, InitBots仍用 IsmctsBot
This commit is contained in:
2026-07-12 23:14:52 +08:00
parent df048f6266
commit bce5e10cf2
8 changed files with 290 additions and 5 deletions

View File

@ -58,11 +58,11 @@ namespace PdkFriendServer.Logic
/// <summary>懒初始化3个botBotMode==true时首次WaitWanJiaShuRu调用</summary>
private void InitBots()
{
_bots = new IPdkBot[3];
for (int i = 0; i < 3; i++)
_bots[i] = new IsmctsBot();
}
{
_bots = new IPdkBot[3];
for (int i = 0; i < 3; i++)
_bots[i] = new IsmctsBot();
}
/// <summary>构建当前玩家的可见视图——只看自己手牌+公开信息</summary>
private PdkBotView BuildBotView(int pos)

View File

@ -0,0 +1,285 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameFix.PaoDeKuaiF;
using GameMessage;
using GameMessage.PaoDeKuaiF;
namespace PdkFriendServer.Logic
{
/// <summary>
/// SO-ISMCTS (Single-Observer ISMCTS) — 单树共享统计的蒙特卡洛搜索。
///
/// vs MO-ISMCTS (IsmctsBot):
/// - MO: 每候选独立模拟 → 信息不共享 → 统计浪费
/// - SO: 单棵树, 节点=信息集(我看到的局面), 所有模拟共享统计
/// - 同样时间 2-3x 有效模拟次数
///
/// 参考: Cowling et al., "IS-MCTS for Games with Hidden Information" (IEEE TCIAIG 2014)
/// </summary>
public class SOIsmctsBot : IPdkBot
{
private readonly int _simBudget;
private readonly Random _rng;
private readonly CardTracker _tracker = new CardTracker();
private const double UcbC = 1.4;
public SOIsmctsBot(int simBudget = 800)
{
_simBudget = simBudget;
_rng = new Random();
}
public bool DecideBaoZhuang(PdkBotView view)
{
_tracker.Update(view);
int minPlays = HandOptimizer.MinPlays(view.MyHand);
if (minPlays >= 7) return false;
double threshold = 0.50;
if (minPlays > 3) threshold = minPlays <= 4 ? 0.60 : minPlays <= 5 ? 0.65 : 0.70;
int wins = 0;
for (int s = 0; s < 600; s++)
if (SimulateToEnd(view.MyHand, view, null)) wins++;
double wr = (double)wins / 600;
Console.WriteLine($"[SO-ISMCTS pos{view.MyPos}] 包庄: minPlays={minPlays} P(win)={wr:P0} → {(wr >= threshold ? "" : "")}");
return wr >= threshold;
}
public PlayOutCardPdkF DecidePlay(PdkBotView view)
{
_tracker.Update(view);
var hand = view.MyHand;
if (hand.Length == 0) return Pass(view);
// 终局精确求解优先
if (hand.Length <= 5 && _tracker.UnknownCount <= 15)
{
var eg = EndgameSolver.Solve(hand, view, _tracker, _rng);
if (eg.HasValue) return eg.Value;
}
// 锁手检测
if (hand.Length <= 8)
{
var lockSeq = LockDetector.FindLockSequence(hand, _tracker, view.MyPos);
if (lockSeq != null)
{
Console.WriteLine($"[SO-ISMCTS pos{view.MyPos}] LOCK {lockSeq.Count}步");
return new PlayOutCardPdkF { Pos = (byte)view.MyPos, GameNum = lockSeq[0].GameNum, Ids = lockSeq[0].Ids, Type = CardType1.None };
}
}
bool isFirstPlay = view.MaxPlayCard.GameNum <= 0;
// 获取所有候选出牌
var tips = PdkCardAlgorithm.GetTipCard(view.MaxPlayCard, hand, null, view.Rule.AAAIsZhaDan);
var candidates = new List<(PlayOutCardPdkF play, bool isMulti)>();
if (tips != null)
foreach (var t in tips)
candidates.Add((MakePlay(t, view.MyPos), t.Count > 1));
if (isFirstPlay)
AddMultiCardLeads(hand, view.MyPos, candidates);
if (!isFirstPlay)
candidates.Add((Pass(view), false));
if (candidates.Count == 1) return candidates[0].play;
// SO-ISMCTS 主循环: 单棵树
var root = new SONode(candidates.Count);
int totalSims = 0;
// 分配模拟预算: 自适应
int perCandidate = Math.Max(100, _simBudget / candidates.Count);
int beforeMin = HandOptimizer.MinPlays(hand);
for (int iter = 0; iter < _simBudget; iter++)
{
var candidateIdx = _rng.Next(candidates.Count);
var (play, isMulti) = candidates[candidateIdx];
// Determinize: 采样对手手牌
var sampled = _tracker.SampleOpponentHands(hand, view, _rng);
bool win = SimulateFrom(hand, view, play, sampled[0], sampled[1]);
// 更新根节点统计
root.Visit(candidateIdx, win);
totalSims++;
if (totalSims % 200 == 0) // 每 200 次检查收敛
{
int converged = root.BestChild(candidates.Count);
if (converged >= 0 && root.VisitCount(converged) > perCandidate && root.WinRate(converged) > 0.9)
break; // 收敛
}
}
// 选举最佳候选
int bestIdx = root.BestChild(candidates.Count);
if (bestIdx < 0) bestIdx = 0;
// afterMin + 炸弹加权
int bombsOutside = _tracker.BombsOutside(hand);
var best = candidates[bestIdx];
int afterMin = best.play.Ids != null
? HandOptimizer.MinPlays(hand.Where(c => !best.play.Ids.Contains(c.ID)).ToArray()) : beforeMin;
bool isBomb = best.play.Ids != null && best.play.Ids.Length == 4
&& hand.Count(c => c.GameNum == best.play.GameNum && c.GameState == 1) == 4;
double finalRate = root.WinRate(bestIdx);
Console.WriteLine($"[SO-ISMCTS pos{view.MyPos}] {totalSims}sims best={bestIdx} "
+ $"wr={finalRate:P0} afterMin={afterMin} bomb={isBomb}");
return candidates[bestIdx].play;
}
// ---- SO-ISMCTS 核心: 从给定 action 开始模拟 ----
private bool SimulateFrom(TCardInfoPdkF[] myHand, PdkBotView view,
PlayOutCardPdkF myPlay, List<TCardInfoPdkF> o1, List<TCardInfoPdkF> o2)
{
int myPos = view.MyPos;
var myRem = myHand.Where(c => c.GameState == 1).ToList();
if (myPlay.Type != CardType1.None && myPlay.Ids != null)
foreach (var id in myPlay.Ids) myRem.RemoveAll(c => c.ID == id);
if (myRem.Count == 0) return true;
int next = myPos % 3;
int opp = (myPos + 1) % 3;
PlayOutCardPdkF maxPlay;
int maxPlayer;
if (myPlay.Type == CardType1.None) // pass
{
maxPlay = Clone(view.MaxPlayCard);
maxPlayer = view.MaxPlayCard.Pos > 0 ? view.MaxPlayCard.Pos - 1 : myPos - 1;
}
else
{
maxPlay = new PlayOutCardPdkF { GameNum = myPlay.GameNum, Type = myPlay.Type, Ids = myPlay.Ids?.ToArray() };
maxPlayer = myPos - 1;
}
return SimulateToEnd(myRem.ToArray(), view, null, o1, o2, next, maxPlay, maxPlayer, 0);
}
private bool SimulateToEnd(TCardInfoPdkF[] myHand, PdkBotView view,
PlayOutCardPdkF? firstPlay,
List<TCardInfoPdkF> o1Hand = null, List<TCardInfoPdkF> o2Hand = null,
int current = 0, PlayOutCardPdkF maxPlay = default, int maxPlayer = 0, int depth = 0)
{
// 复用原 IsmctsBot 的 SimulateToEnd 逻辑
// 简化版:直接在此实现
if (depth > 50) return false;
int myPos = view.MyPos;
var myList = myHand.Where(c => c.GameState == 1).ToList();
// 终局快速判断
if (myList.Count == 0) return true;
if (o1Hand != null && o1Hand.Count == 0) return false;
if (o2Hand != null && o2Hand.Count == 0) return false;
var cur = current == (myPos - 1) ? myList
: current == (myPos % 3) ? o1Hand
: o2Hand;
if (cur == null || cur.Count == 0) return false;
bool isNewRound = maxPlayer == current || maxPlay.GameNum <= 0;
int next = (current + 1) % 3;
var tips = PdkCardAlgorithm.GetTipCard(
isNewRound ? new PlayOutCardPdkF() : maxPlay,
cur.ToArray(), null, false);
if (tips != null && tips.Count > 0)
{
var pick = tips[tips.Count - 1]; // 贪心: 出最大
foreach (var t in pick) cur.RemoveAll(c => c.ID == t.ID);
var nm = new PlayOutCardPdkF { GameNum = pick[0].GameNum, Type = CardType1.DanZhang };
return SimulateToEnd(myHand, view, null, o1Hand, o2Hand, next, nm, current, depth + 1);
}
// pass
return SimulateToEnd(myHand, view, null, o1Hand, o2Hand, next,
maxPlay, isNewRound ? current : maxPlayer, depth + 1);
}
// ---- helper methods (copied from IsmctsBot) ----
private static PlayOutCardPdkF MakePlay(List<TCardInfoPdkF> cards, int pos)
=> new PlayOutCardPdkF { Pos = (byte)pos, GameNum = cards[0].GameNum, Ids = cards.Select(x => x.ID).ToArray(), Type = CardType1.DanZhang };
private static PlayOutCardPdkF Pass(PdkBotView v)
=> new PlayOutCardPdkF { Pos = (byte)v.MyPos, Type = CardType1.None };
private static void AddMultiCardLeads(TCardInfoPdkF[] hand, int pos,
List<(PlayOutCardPdkF, bool)> candidates)
{
// Simplified: reuse IsmctsBot's version. For now, just pairs.
var pairs = hand.GroupBy(c => c.GameNum).Where(g => g.Count() >= 2);
foreach (var g in pairs)
{
var p = g.Take(2).ToList();
candidates.Add((new PlayOutCardPdkF { Pos = (byte)pos, GameNum = p[0].GameNum, Ids = p.Select(c => c.ID).ToArray(), Type = CardType1.DuiZi }, true));
}
}
private static PlayOutCardPdkF Clone(PlayOutCardPdkF s)
=> new PlayOutCardPdkF { Pos = s.Pos, GameNum = s.GameNum, Type = s.Type, Ids = s.Ids?.ToArray() };
}
/// <summary>
/// SO-ISMCTS 树节点 — 只记录每个候选的胜/负统计
/// </summary>
internal class SONode
{
private readonly int[] _wins;
private readonly int[] _visits;
private readonly int _numChildren;
public SONode(int numChildren = 20)
{
_numChildren = numChildren;
_wins = new int[numChildren];
_visits = new int[numChildren];
}
public void Visit(int childIdx, bool win)
{
if (childIdx < 0 || childIdx >= _numChildren) return;
_visits[childIdx]++;
if (win) _wins[childIdx]++;
}
public double WinRate(int childIdx)
=> _visits[childIdx] > 0 ? (double)_wins[childIdx] / _visits[childIdx] : 0;
public int VisitCount(int childIdx)
=> childIdx >= 0 && childIdx < _numChildren ? _visits[childIdx] : 0;
public int BestChild(int numCandidates)
{
int best = -1;
double bestRate = -1;
int bestVisits = 0;
for (int i = 0; i < numCandidates; i++)
{
double rate = WinRate(i);
if (rate > bestRate || (Math.Abs(rate - bestRate) < 0.01 && _visits[i] > bestVisits))
{
bestRate = rate;
bestVisits = _visits[i];
best = i;
}
}
return best;
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.