Bot/BotStats.cs
using System;
using System.Collections.Generic;
using System.Text;

/// <summary>
/// bot 行为统计(v0.7.8.95,调参用):host 侧按球 Id 记各行为的触发计数,
/// 编辑器 MCP `cr_botstats` 读取。纯内存、不联网;热重载会清零(可接受)。
/// 目的:调 bot 数值有数据可看(逃跑/追猎/喂人/喂旗/技能的分布),不靠肉眼猜。
/// </summary>
public static class BotStats
{
	/// <summary> 一只 bot 的累计计数(Difficulty/Archetype 存 byte 避免游戏程序集类型出现在键上) </summary>
	public sealed class Entry
	{
		public string Name = "";
		public int Team = -1;
		public byte Difficulty;
		public byte Archetype;
		public TimeSince Seen;   // 最后一次决策时刻(30s 不活跃的条目不再输出)
		public long Decisions, Flees, Kites, Bequests, Hunts, SplitEscapes, SplitHunts, FeedMates, FeedFlags, SpikeBombs, Skills;
	}

	static readonly Dictionary<Guid, Entry> _byId = new();

	/// <summary> 条目数上限:球对象会被销毁重建(菜单演示赛收场、回菜单清场),而 _byId 按 Ball.Id
	/// 累积、条目永不失效——长会话会把"这台机器见过的所有 bot 球"都留在字典里。超过上限时
	/// 清掉久未决策(Seen > PruneAfterSeconds)的条目(v0.7.8.105)。 </summary>
	const int MaxEntries = 256;
	const float PruneAfterSeconds = 60f;

	/// <summary> 回收久未决策的条目(Track 内按需触发,不做每帧扫描) </summary>
	static readonly List<Guid> _pruneScratch = new();

	static void PruneStale()
	{
		if ( _byId.Count <= MaxEntries ) return;

		_pruneScratch.Clear();
		foreach ( var kv in _byId )
		{
			if ( kv.Value.Seen > PruneAfterSeconds ) _pruneScratch.Add( kv.Key );
		}

		foreach ( var k in _pruneScratch ) _byId.Remove( k );
	}

	/// <summary> 取/建一只 bot 的计数条目(BotBrain 每次决策调一次,顺带刷新名字/队伍/活跃时刻) </summary>
	public static Entry Track( Ball ball, byte difficulty, byte archetype )
	{
		if ( ball is null || !ball.IsValid() ) return null;
		PruneStale();   // 超上限时清久未决策的条目,防字典随"见过的 bot 球总数"无界增长
		if ( !_byId.TryGetValue( ball.Id, out var e ) )
		{
			e = new Entry();
			_byId[ball.Id] = e;
		}
		e.Name = ball.PlayerName;
		e.Team = ball.TeamIndex;
		e.Difficulty = difficulty;
		e.Archetype = archetype;
		e.Seen = 0;
		return e;
	}

	public static string Dump()
	{
		// 只输出 30 秒内还活着的 bot——跨局残留的旧球条目不再刷屏
		if ( _byId.Count == 0 ) return "no bot stats yet";
		var sb = new StringBuilder();
		int shown = 0;
		sb.AppendLine( "name | team | diff | archetype | dec | flee | kite | bequest | hunt | spEsc | spHunt | feed | feedFlag | bomb | skill" );
		foreach ( var e in _byId.Values )
		{
			if ( e.Seen > 30f ) continue;
			shown++;
			sb.AppendLine(
				$"{e.Name} | T{e.Team} | {(BotDifficulty)e.Difficulty} | {(BotArchetype)e.Archetype} | " +
				$"{e.Decisions} | {e.Flees} | {e.Kites} | {e.Bequests} | {e.Hunts} | {e.SplitEscapes} | {e.SplitHunts} | " +
				$"{e.FeedMates} | {e.FeedFlags} | {e.SpikeBombs} | {e.Skills}" );
		}
		sb.Insert( 0, $"bots={shown} (of {_byId.Count} tracked)\n" );
		return sb.ToString();
	}
}