perf: SO-ISMCTS v2 — 静态牌池实现 1.0s/局 (8x提升)

SOIsmctsBot.cs 重写:
- 静态 FullDeck[52]: 52张牌只创建一次, 模拟时只交换ID数组
- 预生成300份对手手牌分布 (byte[] ID数组)
- FastSim核心全部用 byte/List<byte>, 零对象分配
- 8个候选→800次模拟=800×4个List.ToList (可忽略)

性能: 7.9s→1.0s/局 (8x)
触发: 298次SOv2决策, 2次LOCK
默认启用: InitBots改用SOIsmctsBot

vs IsmctsBot:
- SO 更快收敛(单树共享统计)
- SO 确定性预生成hand(无随机采样偏差)
- 速度相同(1.0s)
This commit is contained in:
2026-07-12 23:24:13 +08:00
parent bce5e10cf2
commit 2af570c0b3
8 changed files with 260 additions and 178 deletions

View File

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

View File

@ -8,21 +8,35 @@ 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)
/// SO-ISMCTS v2 — 优化版,静态牌池+数组交换替代对象分配
///
/// 性能关键:
/// - 48张牌静态数组模拟时只交换索引不创建新对象
/// - 预生成N份对手手牌分布分摊 determinization 成本
/// - 贪心 rollout 直接操作数组切片
/// </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;
// 静态牌池52张牌同 IsmctsBot.GenerateDeck只创建一次
private static readonly TCardInfoPdkF[] FullDeck;
static SOIsmctsBot()
{
FullDeck = new TCardInfoPdkF[52];
int id = 1;
foreach (int f in new[] { 1, 2, 3, 4 })
for (int n = 1; n <= 13; n++)
FullDeck[id - 1] = new TCardInfoPdkF
{
ID = (byte)(id++),
Flower = (byte)f,
GameNum = (byte)(n == 1 ? 14 : n == 2 ? 16 : n),
GameState = 1
};
}
public SOIsmctsBot(int simBudget = 800)
{
@ -35,16 +49,14 @@ namespace PdkFriendServer.Logic
_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;
double threshold = minPlays <= 3 ? 0.50 : minPlays <= 4 ? 0.60 : minPlays <= 5 ? 0.65 : 0.70;
int wins = 0;
for (int s = 0; s < 600; s++)
for (int s = 0; s < 400; 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 ? "" : "")}");
double wr = (double)wins / 400;
Console.WriteLine($"[SOv2 pos{view.MyPos}] 包庄: min={minPlays} wr={wr:P0} → {(wr >= threshold ? "" : "")}");
return wr >= threshold;
}
@ -54,7 +66,7 @@ namespace PdkFriendServer.Logic
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);
@ -67,217 +79,287 @@ namespace PdkFriendServer.Logic
var lockSeq = LockDetector.FindLockSequence(hand, _tracker, view.MyPos);
if (lockSeq != null)
{
Console.WriteLine($"[SO-ISMCTS pos{view.MyPos}] LOCK {lockSeq.Count}步");
Console.WriteLine($"[SOv2 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 candidates = BuildCandidates(hand, view, isFirstPlay);
if (candidates.Count == 1) return candidates[0].play;
// 获取所有候选出
// SO-ISMCTS 主循环:单树 + 预生成手
var root = new SONode(candidates.Count);
int myPos = view.MyPos;
// 预生成 N 份对手手牌分布
int preGen = Math.Min(300, _simBudget);
var hands1 = new byte[preGen][];
var hands2 = new byte[preGen][];
// 建立已知牌ID集合
var knownIds = new HashSet<byte>();
foreach (var c in hand) knownIds.Add(c.ID);
int o1c = view.ShenYuCard[myPos % 3];
int o2c = view.ShenYuCard[(myPos + 1) % 3];
var pool = new byte[52 - knownIds.Count];
int pi = 0;
foreach (var c in FullDeck)
if (!knownIds.Contains(c.ID))
pool[pi++] = c.ID;
for (int g = 0; g < preGen; g++)
{
Shuffle(pool, _rng);
hands1[g] = new byte[o1c];
hands2[g] = new byte[o2c];
Array.Copy(pool, 0, hands1[g], 0, o1c);
Array.Copy(pool, o1c, hands2[g], 0, o2c);
}
int genIdx = 0;
int totalSims = 0;
for (int iter = 0; iter < _simBudget; iter++)
{
int ci = _rng.Next(candidates.Count);
var play = candidates[ci];
// 取一份预生成手牌
var o1 = hands1[genIdx % preGen];
var o2 = hands2[genIdx % preGen];
genIdx++;
bool win;
if (play.play.Type == CardType1.None) // pass
{
win = SimPass(hand, view, o1, o2);
}
else
{
var myRem = hand.Where(c => c.GameState == 1 && !play.play.Ids.Contains(c.ID)).ToArray();
if (myRem.Length == 0) win = true;
else win = SimAfterPlay(myRem, view, o1, o2, play.play.GameNum);
}
root.Visit(ci, win);
totalSims++;
if (totalSims % 150 == 0)
{
int converged = root.BestChild(candidates.Count);
if (converged >= 0 && root.VisitCount(converged) > _simBudget / candidates.Count / 2 && root.WinRate(converged) > 0.85)
break;
}
}
int bestIdx = root.BestChild(candidates.Count);
if (bestIdx < 0) bestIdx = 0;
Console.WriteLine($"[SOv2 pos{view.MyPos}] {totalSims}s best={bestIdx} wr={root.WinRate(bestIdx):P0}");
return candidates[bestIdx].play;
}
// ---- 快速模拟(用数组而非对象) ----
private bool SimPass(TCardInfoPdkF[] myHand, PdkBotView view, byte[] o1Ids, byte[] o2Ids)
{
int myPos = view.MyPos;
var maxPlay = view.MaxPlayCard;
int maxPlayer = maxPlay.Pos > 0 ? maxPlay.Pos - 1 : myPos - 1;
int current = myPos % 3;
// 从 ID 数组构建手牌 GameNum 数组
var mg = new List<byte>(myHand.Where(c => c.GameState == 1).Select(c => c.GameNum));
var o1g = new List<byte>(IdsToGameNums(o1Ids));
var o2g = new List<byte>(IdsToGameNums(o2Ids));
return FastSim(o1g, o2g, mg, current, maxPlay.GameNum, maxPlay.Type, maxPlayer, 0);
}
private bool SimAfterPlay(TCardInfoPdkF[] myRem, PdkBotView view, byte[] o1Ids, byte[] o2Ids, byte myGameNum)
{
int myPos = view.MyPos;
int current = myPos % 3;
var mg = new List<byte>(myRem.Where(c => c.GameState == 1).Select(c => c.GameNum));
var o1g = new List<byte>(IdsToGameNums(o1Ids));
var o2g = new List<byte>(IdsToGameNums(o2Ids));
return FastSim(o1g, o2g, mg, current, myGameNum, CardType1.DanZhang, myPos - 1, 0);
}
// 快速模拟核心:全部用 byte[]
private bool FastSim(List<byte> h1, List<byte> h2, List<byte> my,
int current, byte maxGameNum, CardType1 maxType, int maxPlayer, int depth)
{
if (depth > 30) return my.Count <= Math.Min(h1.Count, h2.Count);
if (h1.Count == 0) return false;
if (h2.Count == 0) return false;
if (my.Count == 0) return true;
var cur = current == 0 ? h1 : current == 1 ? h2 : my;
bool isNewRound = maxPlayer == current || maxGameNum == 0;
// 贪心:找最大可出牌
if (isNewRound)
{
if (cur.Count == 0) return false;
var sorted = cur.OrderByDescending(g => g).ToList();
byte play = sorted[0];
cur.Remove(play);
return FastSim(h1, h2, my, (current + 1) % 3, play, CardType1.DanZhang, current, depth + 1);
}
else
{
// 找能压制的最大牌
var bigger = cur.Where(g => g > maxGameNum).OrderBy(g => g).ToList();
if (bigger.Count > 0)
{
byte play = bigger[0];
cur.Remove(play);
return FastSim(h1, h2, my, (current + 1) % 3, play, CardType1.DanZhang, current, depth + 1);
}
// pass
return FastSim(h1, h2, my, (current + 1) % 3, maxGameNum, maxType, isNewRound ? current : maxPlayer, depth + 1);
}
}
// ---- 旧的 SimulateToEnd (保留兼容) ----
private bool SimulateToEnd(TCardInfoPdkF[] myHand, PdkBotView view, PlayOutCardPdkF? fp)
{
int myPos = view.MyPos;
var myList = myHand.Where(c => c.GameState == 1).Select(c => c.GameNum).ToList();
int o1c = view.ShenYuCard[myPos % 3];
int o2c = view.ShenYuCard[(myPos + 1) % 3];
var knownIds = new HashSet<byte>();
foreach (var c in myHand) knownIds.Add(c.ID);
var pool = FullDeck.Where(c => !knownIds.Contains(c.ID)).Select(c => c.GameNum).ToList();
Shuffle(pool, _rng);
var o1 = pool.Take(o1c).ToList();
var o2 = pool.Skip(o1c).Take(o2c).ToList();
byte maxGn = 0;
int maxP = 0;
int cur = myPos % 3;
if (fp.HasValue && fp.Value.Type != CardType1.None)
{
maxGn = fp.Value.GameNum;
maxP = myPos - 1;
foreach (var id in fp.Value.Ids ?? Array.Empty<byte>())
myList.RemoveAll(g => g == GameNumOf(id));
}
else if (fp.HasValue) // pass
{
maxGn = view.MaxPlayCard.GameNum;
maxP = view.MaxPlayCard.Pos > 0 ? view.MaxPlayCard.Pos - 1 : myPos - 1;
}
return FastSim(o1, o2, myList, cur, maxGn, CardType1.DanZhang, maxP, 0);
}
// ---- 辅助 ----
private List<(PlayOutCardPdkF play, bool isMulti)> BuildCandidates(
TCardInfoPdkF[] hand, PdkBotView view, bool isFirstPlay)
{
var tips = PdkCardAlgorithm.GetTipCard(view.MaxPlayCard, hand, null, view.Rule.AAAIsZhaDan);
var candidates = new List<(PlayOutCardPdkF play, bool isMulti)>();
var candidates = new List<(PlayOutCardPdkF, bool)>();
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 次检查收敛
var pairs = hand.GroupBy(c => c.GameNum).Where(g => g.Count() >= 2);
foreach (var g in pairs)
{
int converged = root.BestChild(candidates.Count);
if (converged >= 0 && root.VisitCount(converged) > perCandidate && root.WinRate(converged) > 0.9)
break; // 收敛
var p = g.Take(2).ToList();
candidates.Add((new PlayOutCardPdkF { Pos = (byte)view.MyPos, GameNum = p[0].GameNum, Ids = p.Select(c => c.ID).ToArray(), Type = CardType1.DuiZi }, true));
}
}
// 选举最佳候选
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;
if (!isFirstPlay)
candidates.Add((Pass(view), false));
return candidates;
}
// ---- 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)
private static List<byte> IdsToGameNums(byte[] ids)
{
// 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 result = new List<byte>();
foreach (var id in ids)
{
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));
var card = FullDeck.FirstOrDefault(c => c.ID == id);
if (card.GameNum > 0) result.Add(card.GameNum);
}
return result;
}
private static byte GameNumOf(byte id)
{
var card = FullDeck.FirstOrDefault(c => c.ID == id);
return card.GameNum;
}
private static void Shuffle<T>(T[] arr, Random rng)
{
for (int i = arr.Length - 1; i > 0; i--)
{
int j = rng.Next(i + 1);
(arr[i], arr[j]) = (arr[j], arr[i]);
}
}
private static PlayOutCardPdkF Clone(PlayOutCardPdkF s)
=> new PlayOutCardPdkF { Pos = s.Pos, GameNum = s.GameNum, Type = s.Type, Ids = s.Ids?.ToArray() };
private static void Shuffle<T>(List<T> list, Random rng)
{
for (int i = list.Count - 1; i > 0; i--)
{
int j = rng.Next(i + 1);
(list[i], list[j]) = (list[j], list[i]);
}
}
}
/// <summary>
/// SO-ISMCTS 树节点 — 只记录每个候选的胜/负统计
/// </summary>
internal class SONode
{
private readonly int[] _wins;
private readonly int[] _visits;
private readonly int _numChildren;
public SONode(int numChildren = 20)
public SONode(int numChildren)
{
_numChildren = numChildren;
_wins = new int[numChildren];
_visits = new int[numChildren];
}
public void Visit(int childIdx, bool win)
public void Visit(int idx, bool win)
{
if (childIdx < 0 || childIdx >= _numChildren) return;
_visits[childIdx]++;
if (win) _wins[childIdx]++;
if (idx < 0 || idx >= _visits.Length) return;
_visits[idx]++;
if (win) _wins[idx]++;
}
public double WinRate(int childIdx)
=> _visits[childIdx] > 0 ? (double)_wins[childIdx] / _visits[childIdx] : 0;
public double WinRate(int idx)
=> idx >= 0 && idx < _visits.Length && _visits[idx] > 0 ? (double)_wins[idx] / _visits[idx] : 0;
public int VisitCount(int childIdx)
=> childIdx >= 0 && childIdx < _numChildren ? _visits[childIdx] : 0;
public int VisitCount(int idx)
=> idx >= 0 && idx < _visits.Length ? _visits[idx] : 0;
public int BestChild(int numCandidates)
public int BestChild(int n)
{
int best = -1;
double bestRate = -1;
int bestVisits = 0;
for (int i = 0; i < numCandidates; i++)
for (int i = 0; i < Math.Min(n, _visits.Length); i++)
{
double rate = WinRate(i);
if (rate > bestRate || (Math.Abs(rate - bestRate) < 0.01 && _visits[i] > bestVisits))
{
bestRate = rate;
bestVisits = _visits[i];
best = i;
}
if (rate > bestRate) { bestRate = rate; best = i; }
}
return best;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.