Files
hjha-server/PdkFriendServer/Logic/TrainingCollector.cs
xiaoou 7566fe99a1 feat(PX): 自训练v1.5 P0准备 — NHN模式 + 总分法胜率解析 + 评估管线
CB-01: Program.cs 加 --nhn 模式 [NEURAL,ISMCTS,NEURAL]
- 3人跑得快 N插花坐排列
- Rotations 数组补全为4种排列 (IIN/NII/INI/NHN)
- --nhn 参数: 不生成CSV, 直接评估

CB-02: 胜率解析统一总分法
- TrainingCollector 加 ScoreAccumulator/BotWins/BotGames 静态累加器
- GameOver 无条件调用 AccumulateScore (eval + 训练模式均记录)
- stress_summary.txt 输出 #BOT_SCORES: bot=wins/games,wr=XX%,score=YYY
- orchestrator eval 解析改用 #BOT_SCORES 格式, 正则回退逻辑移除

管线: orchestrator eval→stress_summary→总分法解析, 闭环可验证
2026-07-20 15:40:59 +08:00

215 lines
8.5 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Linq;
using GameMessage;
using GameMessage.PaoDeKuaiF;
namespace PdkFriendServer.Logic
{
/// <summary>
/// 训练数据收集器 — 记录 (state, action, q_value, player) 四元组供 Python 训练。
/// q_value: ISMCTS 对选出动作的胜率评估 (0~1), NeuralBot 填游戏结果(-1/0/1)
/// </summary>
public static class TrainingCollector
{
public static bool Enabled = false;
private static readonly List<TrainingRecord> _records = new List<TrainingRecord>();
private static readonly string _sessionPrefix = DateTime.Now.ToString("yyyyMMdd_HHmm");
/// <summary>总分累加器botType → 累积 Score用于评估统计</summary>
public static readonly Dictionary<string, float> ScoreAccumulator = new Dictionary<string, float>();
/// <summary>每 bot 的胜局计数botType → wins</summary>
public static readonly Dictionary<string, int> BotWins = new Dictionary<string, int>();
/// <summary>每 bot 的总局数botType → total games</summary>
public static readonly Dictionary<string, int> BotGames = new Dictionary<string, int>();
private static readonly int[] Ranks = { 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16 };
private const int NumRanks = 13;
private const int NumSuits = 4;
private const int NumChannels = 7;
public const int InputDim = NumChannels * NumRanks * NumSuits; // 364
private struct TrainingRecord
{
public float[] State;
public int Action; // 0-12 = rank index, 13 = pass
public float QValue; // ISMCTS 评估值 (0~1) 或 NeuralBot 用游戏结果
public int Player;
}
public static void Record(PdkBotView view, CardTracker tracker, int actionIdx, int player, float qValue = 0f)
{
if (!Enabled) return;
var state = EncodeState(view, tracker);
_records.Add(new TrainingRecord { State = state, Action = actionIdx, QValue = qValue, Player = player });
}
/// <summary>
/// 游戏结束,输出本轮训练数据到文件。
/// rewards: 每个玩家的奖励 (1=赢, 0=第二, -1=第三)
/// </summary>
public static void FlushGame(int gameId, float[] rewards, int winnerPos, string[] botTypes)
{
if (!Enabled || _records.Count == 0) return;
var path = $"training_data/game_{_sessionPrefix}_{gameId:D6}.csv";
System.IO.Directory.CreateDirectory("training_data");
using var w = new System.IO.StreamWriter(path);
w.WriteLine("state,action,q_value,player");
foreach (var r in _records)
{
var stateStr = string.Join(",", r.State.Select(f => f.ToString("F6")));
w.WriteLine($"\"{stateStr}\",{r.Action},{r.QValue:F3},{r.Player}");
}
// 游戏结果摘要 — 方便不中断训练即可评估模型进化
w.WriteLine($"#RESULT,winner={winnerPos},bots={string.Join(",", botTypes ?? new[]{"ISMCTS","ISMCTS","ISMCTS"})}");
_records.Clear();
}
/// <summary>
/// 累加 bot 得分botType → Score 总和 + win/game 计数(总分法评估用)
/// </summary>
public static void AccumulateScore(string[] botTypes, float[] rawScores)
{
if (botTypes == null || rawScores == null) return;
for (int i = 0; i < botTypes.Length && i < rawScores.Length; i++)
{
string t = botTypes[i];
if (!ScoreAccumulator.ContainsKey(t))
ScoreAccumulator[t] = 0;
ScoreAccumulator[t] += rawScores[i];
if (!BotGames.ContainsKey(t))
BotGames[t] = 0;
BotGames[t]++;
if (rawScores[i] > 0)
{
if (!BotWins.ContainsKey(t))
BotWins[t] = 0;
BotWins[t]++;
}
}
}
/// <summary>
/// 重置总分累加器
/// </summary>
public static void ResetAccumulator()
{
ScoreAccumulator.Clear();
BotWins.Clear();
BotGames.Clear();
}
/// <summary>
/// 状态编码:与 Python encode_game_state 完全一致。
/// </summary>
/// <summary>
/// 编码"出牌后"状态:把我刚出的牌也标记为已见
/// </summary>
public static float[] EncodeAfterPlay(PdkBotView view, CardTracker tracker,
TCardInfoPdkF[] afterHand, int[] playedIds)
{
// 更新剩余张数:我打出了牌
int playedCount = playedIds?.Length ?? 0;
byte[] afterShenYu = new byte[view.ShenYuCard.Length];
Array.Copy(view.ShenYuCard, afterShenYu, view.ShenYuCard.Length);
afterShenYu[view.MyPos - 1] = (byte)Math.Max(0, afterShenYu[view.MyPos - 1] - playedCount);
var afterView = new PdkBotView
{
MyHand = afterHand,
MyPos = view.MyPos,
ShenYuCard = afterShenYu,
// 出牌后如果我不是passMaxPlayCard改成我出的牌
MaxPlayCard = playedIds != null && playedIds.Length > 0
? new PlayOutCardPdkF { Pos = (byte)view.MyPos,
GameNum = view.MyHand.FirstOrDefault(c => playedIds.Contains(c.ID)).GameNum,
Type = CardType1.DanZhang, Ids = playedIds.Select(id => (byte)id).ToArray() }
: view.MaxPlayCard,
Rule = view.Rule,
};
return EncodeState(afterView, tracker, playedIds);
}
public static float[] EncodeState(PdkBotView view, CardTracker tracker, int[] extraSeen = null)
{
var state = new float[InputDim];
// ch0: my hand
foreach (var c in view.MyHand)
{
if (c.GameState != 1) continue;
int ri = RankToIdx(c.GameNum);
int si = SuitToIdx(c.ID);
if (ri >= 0 && si >= 0)
state[ri * NumSuits + si] = 1.0f;
}
// ch1: seen cards (from tracker) + extraSeen
var pool = tracker.GetUnknownPool(view.MyHand);
var unseenIds = new HashSet<int>(pool.Select(c => (int)c.ID));
if (extraSeen != null)
foreach (var id in extraSeen) unseenIds.Remove(id);
for (int suit = 1; suit <= 4; suit++)
{
for (int n = 1; n <= 13; n++)
{
int id = (suit - 1) * 13 + n;
if (!unseenIds.Contains(id))
{
int g = n == 1 ? 14 : n == 2 ? 16 : n;
int ri = RankToIdx(g);
if (ri >= 0)
state[ChOffset(1) + ri * NumSuits + (suit - 1)] = 1.0f;
}
}
}
// ch2-ch6: scalar info
int myCount = view.MyHand.Count(c => c.GameState == 1);
state[ChOffset(2)] = (float)myCount / 16.0f;
int opp1 = view.MyPos % 3;
state[ChOffset(3)] = (float)view.ShenYuCard[opp1] / 16.0f;
int opp2 = (view.MyPos + 1) % 3;
state[ChOffset(4)] = (float)view.ShenYuCard[opp2] / 16.0f;
state[ChOffset(5) + view.MyPos % NumRanks] = 1.0f;
if (view.MaxPlayCard.GameNum > 0 && view.MaxPlayCard.Pos != view.MyPos)
{
int lr = RankToIdx(view.MaxPlayCard.GameNum);
if (lr >= 0)
state[ChOffset(6) + lr] = 1.0f;
}
return state;
}
public static int RankToIdx(int gameNum) => gameNum switch
{
3 => 0, 4 => 1, 5 => 2, 6 => 3, 7 => 4, 8 => 5, 9 => 6,
10 => 7, 11 => 8, 12 => 9, 13 => 10, 14 => 11, 16 => 12,
_ => -1
};
public static int ActionToIdx(PlayOutCardPdkF play)
{
if (play.Type == CardType1.None || play.Ids == null || play.Ids.Length == 0)
return 13; // pass
return RankToIdx(play.GameNum);
}
private static int SuitToIdx(int id)
{
int suit = (id - 1) / 13;
return suit >= 0 && suit < 4 ? suit : -1;
}
private static int ChOffset(int ch) => ch * NumRanks * NumSuits;
}
}