feat: CardTracker — 出牌记录+对手手牌推断+炸弹检测

CardTracker.cs (新文件, 400行):
- 48位bitset追踪已出牌(按ID)
- 对手pass约束记录: pass对子9→该对手无对子≥9
- BombsOutside(): 外面还剩几个炸弹
- BombRanks(): 可能藏炸弹的rank列表
- CanAnyoneBeat(play): 判断是否可能被压制
- SampleOpponentHands(): 约束采样替代随机分配
- 支持6种牌型的压制判断: 单张/对子/炸弹/顺子/连对/飞机

IsmctsBot.cs 整合:
- CardTracker实例,每次DecidePlay/DecideBaoZhuang调用Update()
- 炸弹感知加权: 外面没炸弹→我方炸弹+25%, 快赢时+15%
- 外面有炸弹且快赢→保守出牌(-5%多牌分)
- SimulateToEnd改用约束采样(回退随机补齐)
This commit is contained in:
2026-07-12 22:36:22 +08:00
parent 2f3b62e835
commit e67b06caa3
2 changed files with 467 additions and 27 deletions

View File

@ -0,0 +1,397 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameMessage;
using GameMessage.PaoDeKuaiF;
namespace PdkFriendServer.Logic
{
/// <summary>
/// 出牌追踪器 — 累积已知牌信息,推断对手手牌约束。
///
/// 原理48 张牌去掉我的 16 张和已出现的牌 = 未知牌池。
/// 对手 pass 某种牌型 → 该对手手上没有能压制此牌型的牌。
/// 随着牌局进行,未知牌池不断缩小,推断精度不断提高。
///
/// 用法IsmctsBot 持有一个 CardTracker 实例,每次 DecidePlay 时调用 Update()。
/// </summary>
public class CardTracker
{
// 48 位 bitset已确认出现的牌ID 级别1-52 但 跑得快只用到其中 48 张)
private readonly HashSet<byte> _seenCardIds = new HashSet<byte>();
// 上次看到的 MaxPlayCard用于检测新出牌
private PlayOutCardPdkF _lastMaxPlay;
// 上次我的手牌 ID 集合
private HashSet<byte> _lastMyHandIds;
// 对手约束:[pos-2] = 该对手 pass 过的牌型列表
private readonly List<PassConstraint>[] _opponentConstraints;
// 当前轮次 pass 计数
private int _passStreak;
private bool _isNewRound;
// 每个位置的炸弹计数
private readonly int[] _bombCounts = new int[3];
public CardTracker()
{
_opponentConstraints = new List<PassConstraint>[3];
for (int i = 0; i < 3; i++)
_opponentConstraints[i] = new List<PassConstraint>();
}
/// <summary>
/// 每次 bot 决策前调用,更新已知信息。
/// myPos 从 1 开始。
/// </summary>
public void Update(PdkBotView view)
{
int myPos = view.MyPos;
var handIds = new HashSet<byte>(view.MyHand.Where(c => c.GameState == 1).Select(c => c.ID));
// 1. 追踪我的手牌变化
if (_lastMyHandIds != null)
{
var played = _lastMyHandIds.Where(id => !handIds.Contains(id));
foreach (var id in played) _seenCardIds.Add(id);
}
_lastMyHandIds = handIds;
// 2. 追踪桌面出牌
var currentMax = view.MaxPlayCard;
if (currentMax.Ids != null && currentMax.Ids.Length > 0)
{
// 如果是新出牌(不是上一轮的)
if (_lastMaxPlay.Ids == null ||
!_lastMaxPlay.Ids.SequenceEqual(currentMax.Ids))
{
foreach (var id in currentMax.Ids) _seenCardIds.Add(id);
_passStreak = 0;
_isNewRound = false;
}
else if (_lastMaxPlay.Ids != null)
{
// 同一手牌还在桌上 → 有人 pass 了
_passStreak++;
// 记录 pass 约束(轮到谁 pass 了就是谁)
int passer = currentMax.Pos > 0
? (currentMax.Pos % 3 + 1) // 下一位
: 0;
if (passer > 0 && passer != myPos)
{
RecordPassConstraint(passer, currentMax);
}
}
}
else
{
_isNewRound = true;
_passStreak = 0;
}
_lastMaxPlay = new PlayOutCardPdkF
{
Pos = currentMax.Pos,
GameNum = currentMax.GameNum,
Type = currentMax.Type,
Ids = currentMax.Ids?.ToArray()
};
// 3. 追踪炸弹计数
for (int i = 0; i < 3; i++)
_bombCounts[i] = view.ZhaDans[i];
}
/// <summary>
/// 重置追踪器(每局开始时调用)
/// </summary>
public void Reset()
{
_seenCardIds.Clear();
_lastMyHandIds = null;
_lastMaxPlay = new PlayOutCardPdkF();
_passStreak = 0;
_isNewRound = true;
foreach (var c in _opponentConstraints) c.Clear();
Array.Clear(_bombCounts, 0, 3);
}
// ---- 查询接口 ----
/// <summary>返回外面还有多少未知卡</summary>
public int UnknownCount => 48 - _seenCardIds.Count;
/// <summary>在外面可能存在的炸弹数(按 rank 计)</summary>
public int BombsOutside(TCardInfoPdkF[] myHand)
{
int bombs = 0;
var myCounts = new Dictionary<int, int>();
foreach (var c in myHand.Where(c => c.GameState == 1))
myCounts[c.GameNum] = myCounts.GetValueOrDefault(c.GameNum, 0) + 1;
// 对每个 rank: total=4, my=N, seen=M, outside=4-N-M
// 但 seen 按 ID 不按 rank简化统计已知牌中各 rank 的已出数
var seenByRank = new Dictionary<int, int>();
foreach (var id in _seenCardIds)
{
// 通过 ID 反推 rankID 1-52, rank=(ID-1)%13+1, 但 GameNum 映射不同)
// 简化:用 GameNum 直接。实际上 ID 与 GameNum 的映射由 Deal 决定。
// 我们采用近似:外面未见的牌如果某个 rank 还有 4 张 → 可能有炸弹
}
// 简化版:检查常见 rank
foreach (int rank in new[] { 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16 })
{
int myCount = myCounts.GetValueOrDefault(rank, 0);
// 近似:用 rank 对应 ID 范围推算已见数
int seenCount = CountSeenByRank(rank);
int outside = 4 - myCount - seenCount;
if (outside == 4) bombs++;
}
return bombs;
}
/// <summary>外面有炸弹的 rank 列表</summary>
public List<int> BombRanks(TCardInfoPdkF[] myHand)
{
var result = new List<int>();
var myCounts = new Dictionary<int, int>();
foreach (var c in myHand.Where(c => c.GameState == 1))
myCounts[c.GameNum] = myCounts.GetValueOrDefault(c.GameNum, 0) + 1;
foreach (int rank in new[] { 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16 })
{
int myCount = myCounts.GetValueOrDefault(rank, 0);
int seenCount = CountSeenByRank(rank);
if (4 - myCount - seenCount == 4) result.Add(rank);
}
return result;
}
/// <summary>根据已知牌判断:这个出牌有没有可能被压制</summary>
public bool CanAnyoneBeat(TCardInfoPdkF[] myHand, PlayOutCardPdkF play)
{
if (play.Ids == null || play.Ids.Length == 0) return false;
int rank = play.GameNum;
int count = play.Ids.Length;
switch (play.Type)
{
case CardType1.DanZhang:
// 外面有没有更大的单牌?
return HasHigherUnseen(rank, 1, myHand);
case CardType1.DuiZi:
return HasHigherUnseen(rank, 2, myHand);
case CardType1.ZhaDan:
// 外面有没有更大的炸弹?
return HasHigherUnseen(rank, 4, myHand);
case CardType1.ShunZi:
// 外面有没有更大的顺子?
return HasHigherStraight(rank, count, myHand);
case CardType1.LianDui:
return HasHigherLianDui(rank, count, myHand);
case CardType1.FeiJi:
case CardType1.FeijiDai2:
return HasHigherPlane(rank, count, myHand);
default:
return HasHigherUnseen(rank, count, myHand);
}
}
/// <summary>生成约束采样的对手手牌(用于 ISMCTS 模拟)</summary>
public List<TCardInfoPdkF>[] SampleOpponentHands(
TCardInfoPdkF[] myHand, PdkBotView view, Random rng)
{
int myPos = view.MyPos;
int opp1 = myPos % 3; // 下家 (0-indexed)
int opp2 = (myPos + 1) % 3; // 上家
// 未知牌池
var knownIds = new HashSet<byte>(_seenCardIds);
foreach (var c in myHand) knownIds.Add(c.ID);
var pool = new List<TCardInfoPdkF>();
for (byte suit = 1; suit <= 4; suit++)
{
for (byte n = 1; n <= 13; n++)
{
byte id = (byte)((suit - 1) * 13 + n);
if (!knownIds.Contains(id))
{
int g = n == 1 ? 14 : n == 2 ? 16 : n;
pool.Add(new TCardInfoPdkF { ID = id, Flower = suit, GameNum = (byte)g, GameState = 1 });
}
}
}
Shuffle(pool, rng);
// 约束检查:对手不能持有与 pass 约束冲突的牌
int opp1Need = view.ShenYuCard[opp1];
int opp2Need = view.ShenYuCard[opp2];
var h1 = new List<TCardInfoPdkF>();
var h2 = new List<TCardInfoPdkF>();
int idx = 0;
foreach (var card in pool)
{
bool okFor1 = SatisfiesConstraints(card, opp1);
bool okFor2 = SatisfiesConstraints(card, opp2);
if (h1.Count < opp1Need && okFor1 && (h2.Count >= opp2Need || !okFor2 || rng.Next(2) == 0))
h1.Add(card);
else if (h2.Count < opp2Need && okFor2)
h2.Add(card);
else if (okFor1 && h1.Count < opp1Need)
h1.Add(card);
else if (okFor2 && h2.Count < opp2Need)
h2.Add(card);
idx++;
if (h1.Count >= opp1Need && h2.Count >= opp2Need) break;
}
return new[] { h1, h2 };
}
// ---- 私有方法 ----
private void RecordPassConstraint(int pos, PlayOutCardPdkF play)
{
int idx = pos - 1;
_opponentConstraints[idx].Add(new PassConstraint
{
Type = play.Type,
GameNum = play.GameNum,
Count = play.Ids?.Length ?? 1
});
// 只保留最近 10 条
if (_opponentConstraints[idx].Count > 10)
_opponentConstraints[idx].RemoveAt(0);
}
private bool SatisfiesConstraints(TCardInfoPdkF card, int oppIdx)
{
foreach (var c in _opponentConstraints[oppIdx])
{
switch (c.Type)
{
case CardType1.DanZhang:
if (card.GameNum > c.GameNum) return false;
break;
case CardType1.DuiZi:
// 简化:不能持有比 pass 对子更高的对子
if (card.GameNum > c.GameNum) return false;
break;
}
}
return true;
}
private int CountSeenByRank(int gameNum)
{
// 近似ID 与 GameNum 的映射。ID=(suit-1)*13 + n, GameNum: 1→14, 2→16, else→n
int n = gameNum switch { 14 => 1, 16 => 2, _ => gameNum };
int count = 0;
for (int suit = 1; suit <= 4; suit++)
{
byte id = (byte)((suit - 1) * 13 + n);
if (_seenCardIds.Contains(id)) count++;
}
return count;
}
private bool HasHigherUnseen(int gameNum, int needPerRank, TCardInfoPdkF[] myHand)
{
var myCounts = new Dictionary<int, int>();
foreach (var c in myHand.Where(c => c.GameState == 1 && c.GameNum > gameNum))
myCounts[c.GameNum] = myCounts.GetValueOrDefault(c.GameNum, 0) + 1;
foreach (int rank in new[] { 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16 })
{
if (rank <= gameNum) continue;
int myCount = myCounts.GetValueOrDefault(rank, 0);
int seenCount = CountSeenByRank(rank);
int available = 4 - myCount - seenCount;
if (available >= needPerRank) return true;
}
return false;
}
private bool HasHigherStraight(int startRank, int length, TCardInfoPdkF[] myHand)
{
// 检查是否存在比当前顺子更大的同长顺子
// 最大顺子10-J-Q-K-A (startRank=10, length=5),再高没有了
int maxStart = 14 - length + 1; // A(14) 是最高
for (int s = startRank + 1; s <= maxStart; s++)
{
bool possible = true;
for (int r = s; r < s + length; r++)
{
int myCount = myHand.Count(c => c.GameState == 1 && c.GameNum == r);
int seenCount = CountSeenByRank(r);
if (4 - myCount - seenCount < 1) { possible = false; break; }
}
if (possible) return true;
}
return false;
}
private bool HasHigherLianDui(int startRank, int pairs, TCardInfoPdkF[] myHand)
{
// 连对≥3 对连续,检查是否有更大起点的连对
int maxStart = 14 - pairs; // A(14) 最高
for (int s = startRank + 1; s <= maxStart; s++)
{
bool possible = true;
for (int r = s; r < s + pairs; r++)
{
int myCount = myHand.Count(c => c.GameState == 1 && c.GameNum == r);
int seenCount = CountSeenByRank(r);
if (4 - myCount - seenCount < 2) { possible = false; break; }
}
if (possible) return true;
}
return false;
}
private bool HasHigherPlane(int startRank, int triples, TCardInfoPdkF[] myHand)
{
int maxStart = 14 - triples;
for (int s = startRank + 1; s <= maxStart; s++)
{
bool possible = true;
for (int r = s; r < s + triples; r++)
{
int myCount = myHand.Count(c => c.GameState == 1 && c.GameNum == r);
int seenCount = CountSeenByRank(r);
if (4 - myCount - seenCount < 3) { possible = false; break; }
}
if (possible) return true;
}
return false;
}
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]);
}
}
private struct PassConstraint
{
public CardType1 Type;
public byte GameNum;
public int Count;
}
}
}

View File

@ -18,32 +18,34 @@ namespace PdkFriendServer.Logic
{
private readonly int _simulations;
private readonly Random _rng;
private const double BaoThreshold = 0.70; // 100局校准: 60%→30%, 提至70%
private const double MultiBonusPerCard = 0.10; // 多牌加权: 每多1张+10%
private const int FastSims = 40; // 快速评估模拟次数
private readonly CardTracker _tracker = new CardTracker();
private const double BaoThreshold = 0.70;
private const double MultiBonusPerCard = 0.10;
private const int FastSims = 40;
public IsmctsBot(int simulations = 800)
{
_simulations = simulations;
_rng = new Random();
}
public IsmctsBot(int simulations = 800)
{
_simulations = simulations;
_rng = new Random();
}
// ---- 自适应模拟次数 ----
private int AdaptiveSims(int handSize)
{
if (handSize <= 3) return 2000; // 决胜时刻
if (handSize <= 6) return 1500;
if (handSize <= 10) return 1200;
if (handSize <= 13) return 1000;
return 800; // 开局快速
}
// ---- 自适应模拟次数 ----
private int AdaptiveSims(int handSize)
{
if (handSize <= 3) return 2000;
if (handSize <= 6) return 1500;
if (handSize <= 10) return 1200;
if (handSize <= 13) return 1000;
return 800;
}
// ---- 包庄决策 ----
public bool DecideBaoZhuang(PdkBotView view)
{
// 最小步数规划:手牌结构好不好
int minPlays = HandOptimizer.MinPlays(view.MyHand);
public bool DecideBaoZhuang(PdkBotView view)
{
_tracker.Update(view);
int minPlays = HandOptimizer.MinPlays(view.MyHand);
double handEfficiency = view.MyHand.Length > 0
? (double)(view.MyHand.Length - minPlays) / view.MyHand.Length : 0;
@ -77,8 +79,9 @@ namespace PdkFriendServer.Logic
// ---- 出牌决策 ----
public PlayOutCardPdkF DecidePlay(PdkBotView view)
{
var hand = view.MyHand;
{
_tracker.Update(view);
var hand = view.MyHand;
if (hand.Length == 0) return Pass(view);
bool isFirstPlay = view.MaxPlayCard.GameNum <= 0;
@ -147,7 +150,33 @@ namespace PdkFriendServer.Logic
}
}
// 5. 两阶段选择:多牌优先
// 5. 炸弹感知加权
int bombsOutside = _tracker.BombsOutside(hand);
var bombRanks = _tracker.BombRanks(hand);
for (int i = 0; i < scoredCandidates.Count; i++)
{
var (p, wr, am, m) = scoredCandidates[i];
bool isBomb = p.Ids != null && p.Ids.Length == 4
&& hand.Count(c => c.GameNum == p.GameNum && c.GameState == 1) == 4;
if (isBomb)
{
// 我方炸弹
if (bombsOutside == 0) wr += 0.25; // 外面没炸弹 → 我方炸弹无敌
else if (am <= 3) wr += 0.15; // 快赢了,果断炸
else wr -= 0.05; // 先留着
}
else if (bombsOutside > 0 && am <= 3)
{
// 外面有炸弹但我快赢了 → 出牌保守,少加多牌分
if (m) wr -= 0.05;
}
scoredCandidates[i] = (p, wr, am, m);
}
// 6. 两阶段选择:多牌优先
scoredCandidates.Sort((a, b) => b.winRate.CompareTo(a.winRate));
var best = scoredCandidates[0];
@ -189,9 +218,23 @@ namespace PdkFriendServer.Logic
var allCards = GenerateDeck();
var unknown = allCards.Where(c => !myHand.Any(h => h.ID == c.ID)).ToList();
Shuffle(unknown);
hands[nextPos - 1].AddRange(unknown.Take(view.ShenYuCard[nextPos - 1]));
hands[oppPos - 1].AddRange(unknown.Skip(view.ShenYuCard[nextPos - 1]).Take(view.ShenYuCard[oppPos - 1]));
// 使用 CardTracker 进行约束采样
var sampled = _tracker.SampleOpponentHands(myHand, view, _rng);
hands[nextPos - 1].AddRange(sampled[0]);
hands[oppPos - 1].AddRange(sampled[1]);
// 如果约束采样没填满,用随机采样补齐
var usedIds = new HashSet<byte>(myHand.Select(c => c.ID));
foreach (var c in hands[nextPos - 1]) usedIds.Add(c.ID);
foreach (var c in hands[oppPos - 1]) usedIds.Add(c.ID);
var remaining = unknown.Where(c => !usedIds.Contains(c.ID)).ToList();
Shuffle(remaining);
int n1Need = view.ShenYuCard[nextPos - 1] - hands[nextPos - 1].Count;
int n2Need = view.ShenYuCard[oppPos - 1] - hands[oppPos - 1].Count;
if (n1Need > 0) hands[nextPos - 1].AddRange(remaining.Take(n1Need));
if (n2Need > 0) hands[oppPos - 1].AddRange(remaining.Skip(n1Need).Take(n2Need));
int current, maxPlayer;
PlayOutCardPdkF maxPlay;