Game/ClassSkillManager.cs
using System;
using System.Collections.Generic;

/// <summary>
/// 职业技能管理器(M7.4,领土模式专属):Q 键施放的四职业冷却技能,host 权威。
/// 指挥官=传送到最大队友身边(远程玩家经 ClassTeleport 广播瞬移);工兵=6 秒大加速(复用 Speed buff);
/// 护卫=原地锚定尖刺 8 秒(复用尖刺分身碰撞判定);坦克=+40% 当前质量 10 秒,到期回收、期间禁分裂。
/// 冷却按球 Id 记(真人=本球;bot 各球独立)。客户端冷却为本地估算,
/// host 冷却未好时静默拒绝(无副作用)。坦克回收表持有 Ball 引用,热重载换程序集对象会失效——
/// 届时该次回收静默消失(对局本身也会被热重载打断),可接受的边界。
/// </summary>
public static class ClassSkillManager
{
	sealed class Surge
	{
		public Ball Ball;
		public float Added;
		public TimeSince Since;
	}

	static readonly List<Surge> _surges = new();
	static readonly Dictionary<Guid, TimeSince> _cd = new();   // 球 Id → 上次施放(按球不按 SteamId——bot 的 SteamId 全 0 会全队共享一个冷却)

	public static string NameOf( byte cls ) => cls switch
	{
		0 => "COMMANDER",
		1 => "ENGINEER",
		2 => "GUARD",
		3 => "TANK",
		_ => "",
	};

	/// <summary> HUD 短标(Q 钮就绪态显示) </summary>
	public static string TagOf( byte cls ) => cls switch
	{
		0 => "CMD",
		1 => "ENG",
		2 => "GRD",
		3 => "TNK",
		_ => "",
	};

	public static float CooldownOf( byte cls ) => cls switch
	{
		0 => GameConfig.ClassCdCommander,
		1 => GameConfig.ClassCdEngineer,
		2 => GameConfig.ClassCdGuard,
		3 => GameConfig.ClassCdTank,
		_ => 999f,
	};

	/// <summary> 球复活时清冷却(v0.7.8.87):复活会重掷职业,但冷却按球 Id 记、球对象又跨局复用——
	/// 不清的话刚复活 Q 显示着新职业却按不动(上一条命/上一局的 45~50s 冷却还挂着) </summary>
	public static void ResetBall( Ball ball )
	{
		if ( ball.IsValid() ) _cd.Remove( ball.Id );
	}

	/// <summary> 局间清账(v0.7.8.87):清全部冷却 + 未回收的坦克临时质量记账
	/// (跨局残留会在新局把重生球的质量又扣一遍) </summary>
	public static void ResetAll()
	{
		_cd.Clear();
		_surges.Clear();
	}

	/// <summary> 坦克爆发期内禁分裂(临时质量分裂=套现永久质量) </summary>
	public static bool IsTankSurging( Ball ball )
	{
		if ( ball is null || !ball.IsValid() ) return false;
		foreach ( var s in _surges )
		{
			if ( s.Ball == ball ) return true;
		}
		return false;
	}

	/// <summary> host 权威施放:冷却/存活/模式校验后执行;返回是否真的放了(没队友的指挥官不进冷却) </summary>
	public static bool Cast( Ball ball )
	{
		if ( !MatchState.IsTerritory ) return false;
		if ( ball is null || !ball.IsValid() || !ball.Alive ) return false;
		if ( ball.TerritoryClass > 3 ) return false;

		var sid = ball.Id;   // 冷却按球身记(真人=本球;bot 各球独立,不受 SteamId 全 0 影响)
		if ( _cd.TryGetValue( sid, out var since ) && since < CooldownOf( ball.TerritoryClass ) ) return false;

		switch ( ball.TerritoryClass )
		{
			case 0:
				if ( !CastCommander( ball ) ) return false;
				break;
			case 1:
				ball.ApplyBuff( (byte)PowerUpManager.Kind.Speed, GameConfig.ClassEngineerSeconds );
				break;
			case 2:
				CircleroyaleGame.Current?.SpawnGuardSpike( ball );
				break;
			case 3:
				CastTank( ball );
				break;
		}

		_cd[sid] = 0;
		GameLog.Info( $"[class] {NameOf( ball.TerritoryClass )} cast by '{ball.PlayerName}'" );
		return true;
	}

	static bool CastCommander( Ball ball )
	{
		var game = CircleroyaleGame.Current;
		if ( game is null ) return false;

		Ball best = null;
		foreach ( var b in game.Balls )
		{
			if ( !b.IsValid() || !b.Alive || b == ball ) continue;
			if ( b.TeamIndex < 0 || b.TeamIndex != ball.TeamIndex ) continue;
			if ( best is null || b.Mass > best.Mass ) best = b;
		}
		if ( best is null ) return false;   // 孤身无队友:技能不放、不进冷却

		var pos = best.WorldPosition;
		if ( ball.IsBot || ball.OwnerSteamId == Game.SteamId.Value )
		{
			ball.TeleportTo( pos );           // host 本机球/bot:直接瞬移
			game.MarkTeleport( ball, pos );
		}
		else
		{
			game.MarkTeleport( ball, pos );   // 远程球:host 先锚定校验位,owner 端收广播自行瞬移
			NetworkManager.ClassTeleport( ball.OwnerSteamId, pos.x, pos.y );
		}
		return true;
	}

	static void CastTank( Ball ball )
	{
		var added = MathF.Max( GameConfig.ClassTankMassMin, ball.Mass * GameConfig.ClassTankMassFraction );
		ball.Mass = MathF.Min( GameConfig.MaxMass, ball.Mass + added );
		_surges.Add( new Surge { Ball = ball, Added = added, Since = 0 } );
	}

	/// <summary> host 每帧(TickTerritory 调):坦克临时质量到期回收(下限出生质量;球没了就不补) </summary>
	public static void Tick()
	{
		for ( int i = _surges.Count - 1; i >= 0; i-- )
		{
			var s = _surges[i];
			if ( s.Since < GameConfig.ClassTankSeconds ) continue;

			var b = s.Ball;
			if ( b.IsValid() && b.Alive )
				b.Mass = MathF.Max( GameConfig.StartMass, b.Mass - s.Added );
			_surges.RemoveAt( i );
		}
	}
}