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

/// <summary>
/// bot AI(host 侧模拟,代理端不跑):有限状态机
/// 逃跑(范围内能吃我的威胁)&gt; 追猎(能吃的近距离小球/分身)&gt; 靠近喂养目标 &gt; 觅食 &gt; 游荡。
/// 每 BotDecisionInterval 秒决策一次,方向写入 Ball.SetBotDirection。
/// 进阶能力(v0.6.6.0 起,v0.7.8.32 本轮加强):
/// - **感知敌方分身**(v0.7.8.32):分身能吞主球也能被吞——威胁/猎物/分裂风险评估全覆盖敌方分身(此前是纯盲区);
/// - **分裂捕猎**(v0.6.6.0):贴身且分出去的一半仍够吃时朝猎物分裂(agar 秒杀手法);
///   v0.7.8.32:猎物太小不浪费分裂冷却;减半后附近有能吃我的敌人就放弃(送命分裂);
/// - **分裂逃命**(v0.7.8.7):威胁到嘴边时朝逃跑方向分裂(冲量前窜+变轻提速),逃命优先于捕猎;
/// - **多威胁合成逃跑**(v0.7.8.32):被包夹时反向按威胁远近加权合成,不往第二个威胁怀里撞;
/// - **拦截预判**(v0.7.8.32):追猎按猎物当前速度打提前量(新手直线追被风筝,王牌几乎完美拦截);
/// - **觅食粘性**(v0.7.8.32):食物/孢子目标活着就追到底(距离折扣竞争),不在两个目标间来回锯齿;
/// - **躲刺爆弹幕**(v0.7.8.32):刺爆弹幕也是真伤害(-15% 质量),近距离侧向闪避;
/// - **吐孢子喂队友**(团队赛):向范围内更大的队友喂孢子(养大哥),喂到自己不再明显小于对方为止;
///   v0.7.8.94:靠近到"孢子够得着"即停(不贴脸)、连续喂养有时长预算(喂一阵休整去觅食)、
///   超出孢子射程不空吐——修"两只 bot 缩在一起蹲几十秒";
/// - **顺路捡道具**(v0.7.8.7):附近场上有道具就冲过去(bot 拾取即用);
/// - **孢子觅食**(v0.7.8.7):散落孢子按性价比折算进觅食目标,不再无视;
/// - **团队集结**:团队赛游荡目标偏向远处队友(v0.7.8.94:已挨着队友就随机散开,不再原地 hover 抱团);
/// - **选旗摊开**(v0.7.8.94):同队 bot 已选中的旗格降权 + 赶旗进圈即停不挤旗心,防全队叠在同一格;
/// - **可追性检查**(v0.7.8.95):猎物比我快且在逃 → 追不上直接放弃,不再傻追十几秒;
/// - **TTC 预警**(v0.7.8.95):致命敌人高速逼近按"接触时间"提前避让(往当前方向掺分量,
///   不动摇状态机);新兵视界 0=还是瞎子,王牌 1.1 秒提前量;
/// - **告急回防**(v0.7.8.95):自己旗格的驻军分比上次采样下降 = 正在被啃,回防价值大幅加成;
/// - **个性原型**(v0.7.8.95):农夫/抢旗手/保镖/猎杀者,出生随机,只调效用权重不换骨架;
///   行为计数走 BotStats(MCP cr_botstats 看分布调参)。
/// - **战术手法层**(v0.7.8.96):①借刺反杀——被追时朝"追兵撞上必爆"的刺借道(队友尖刺
///   分身免疫可贴身借);②临终托孤——威胁贴脸时掉头朝安全队友吐质量,死也不白送;
///   ③主动炸草(v0.7.8.97 放开原型门)——质量烧得起的 bot 喂爆敌方目标旁边的刺,用弹幕白削对面。
/// - 猎物/威胁判定跳过队友(队友免伤,追了白追);猎物按"质量/距离"评分选性价比最高的。
/// M2 起 bot 由 host 拥有,IsProxy 端不执行。
/// </summary>
public sealed class BotBrain : Component
{
	/// <summary> 一次决策收集的态势(威胁/猎物可能是球也可能是敌方分身——分身时 Ball 引用为 null,
	/// 位置/半径/质量存独立字段,方向决策与动作层都只用这些字段取数据) </summary>
	sealed class BotContext
	{
		public Ball Threat;                     // 最近的威胁球(威胁是分身时为 null)
		public Vector3 ThreatPos;               // 最近威胁位置(球或敌方分身)
		public float ThreatRadius;
		public Ball Prey;                       // 价值最高的猎物球(猎物是分身时为 null)
		public Vector3 PreyPos;
		public float PreyRadius;
		public float PreyMass;                  // >0 = 有猎物(球或分身)
		public Vector3 PreyVel;                 // 猎物当前速度(拦截预判用)
		public Ball FeedTarget;                 // 团队赛:值得喂的队友(范围内最大且明显比我大)
		public readonly List<Ball> Mates = new();   // 存活队友(集结用)
		public Vector3 FleeDir;                 // 全部威胁的反向加权和(多威胁合成逃跑)
		public int ThreatCount;
		public Vector2 EarlyThreatDir;          // TTC 预警(v0.7.8.95):视界内高速逼近的敌人的反向和
		public int EarlyThreatCount;            // 预警计数(>0 时当前方向掺避让分量,不进逃跑状态机)
		public bool DangerAfterSplit;           // 分裂减半后附近仍有够吃"减半我"的敌人
	}

	Ball _self;
	TimeSince _sinceDecision;
	TimeSince _sinceSplit;
	TimeSince _sinceFeed;
	TimeSince _sinceFeedSession;    // 连续喂养时长(v0.7.8.94:喂满预算强制休整,不许无限蹲大哥)
	bool _feedBreak;                // 喂养休整中(休整期无视 FeedTarget,正常觅食回血)
	float _orbitSign = 1f;          // 停驻盘旋方向(v0.7.8.103:±1 每只随机,站桩对峙的解药)
	Vector3 _wanderTarget;
	bool _hasWander;

	Vector2 _stickyFood;            // 觅食粘性目标(食物点,防相邻目标间来回锯齿)
	TimeSince _sinceStickyFood;
	int _stickyBlobId = -1;         // 觅食粘性目标(散落孢子,按 CellPiece.Id 锁定)
	TimeSince _sinceStickyBlob;

	Vector3 _flagTarget;            // 领土目标旗点(M7.5,_hasFlagTarget 时有效)
	bool _hasFlagTarget;
	TimeSince _sinceFlagPick = 99f; // 选旗节流(BotFlagPickInterval 重估一次)
	TimeSince _sinceFlagFeed;       // 喂旗节流(BotFeedFlagCooldown,套在 DoEject 内置节流之上)

	Vector3 _bombSpike;             // 主动炸草目标刺位(v0.7.8.96,_hasBombTarget 时有效)
	bool _hasBombTarget;
	TimeSince _sinceBombPick = 99f; // 炸草目标重估节流
	TimeSince _sinceBombFeed;       // 喂刺节流(BotSpikeBombCooldown)
	TimeSince _sinceBombArrive;     // 伏击耐心计时(v0.7.8.99:从选刺起算,超时放弃)

	/// <summary> 选旗预约表(v0.7.8.94 摊开扎堆):同队别的 bot 最近选中的格降权,把队内火力
	/// 摊到不同旗上(旧版两只 bot 常选中同一格,一起蹲在旗心吐球=又一处"缩在一起")。
	/// key = team<<32 | 格索引;值 = (选它的球 Id, 选中时刻)。TimeSince 默认 0=很久以前,天然过期 </summary>
	static readonly Dictionary<long, (Guid BallId, TimeSince Since)> _flagClaims = new();

	/// <summary> 难度档(OnStart 随机定档;决策节奏/感知范围/准度都按档取值) </summary>
	public BotDifficulty Difficulty { get; private set; } = BotDifficulty.Veteran;

	/// <summary> 个性原型(v0.7.8.95,OnStart 随机定档):只调效用权重不换骨架——
	/// 农夫偏吃、抢旗手直奔旗、保镖贴大哥、猎杀者只认猎物 </summary>
	public BotArchetype Archetype { get; private set; } = BotArchetype.Farmer;

	BotStats.Entry _stats;              // 行为计数(cr_botstats 看分布调参用)
	float[] _cellScoreSeen;             // 上次选旗时各格驻军分快照(判"正在被啃"→告急回防)

	protected override void OnStart()
	{
		base.OnStart();
		_self = GetComponent<Ball>();
		Difficulty = RollDifficulty();
		Archetype = (BotArchetype)Game.Random.Int( 0, 3 );
		_orbitSign = Game.Random.Float( 0f, 1f ) < 0.5f ? -1f : 1f;
		_sinceDecision = DecisionInterval();   // 首帧立即决策
	}

	BotDifficulty RollDifficulty()
	{
		var r = Game.Random.Float( 0f, 1f );
		if ( r < GameConfig.BotAceFraction ) return BotDifficulty.Ace;
		if ( r < GameConfig.BotAceFraction + GameConfig.BotVeteranFraction ) return BotDifficulty.Veteran;
		return BotDifficulty.Rookie;
	}

	float DecisionInterval() => Difficulty switch
	{
		BotDifficulty.Rookie => GameConfig.BotRookieDecisionInterval,
		BotDifficulty.Ace => GameConfig.BotAceDecisionInterval,
		_ => GameConfig.BotDecisionInterval,
	};

	float RangeScale() => Difficulty switch
	{
		BotDifficulty.Rookie => GameConfig.BotRookieRangeScale,
		BotDifficulty.Ace => GameConfig.BotAceRangeScale,
		_ => 1f,
	};

	float AimError() => Difficulty switch
	{
		BotDifficulty.Rookie => GameConfig.BotRookieAimError,
		BotDifficulty.Ace => GameConfig.BotAceAimError,
		_ => GameConfig.BotVeteranAimError,
	};

	/// <summary> 进阶动作的施展概率(新兵笨拙少用,王牌几乎必放) </summary>
	float ActionChance() => Difficulty switch
	{
		BotDifficulty.Rookie => 0.18f,
		BotDifficulty.Ace => 0.85f,
		_ => 0.5f,
	};

	/// <summary> 拦截提前量系数(v0.7.8.32):新手直线追(被风筝),老手打半拍,王牌几乎完美拦截 </summary>
	float LeadFactor() => Difficulty switch
	{
		BotDifficulty.Rookie => 0f,
		BotDifficulty.Ace => 0.75f,
		_ => 0.45f,
	};

	/// <summary> TTC 预警视界(v0.7.8.95,秒):够吃我的敌人按"接近速度"算接触时间,小于视界
	/// 就往当前方向掺避让分量(不进逃跑状态机)。新兵 0=关闭(还是瞎子),王牌看得最远 </summary>
	float ThreatHorizon() => Difficulty switch
	{
		BotDifficulty.Rookie => 0f,
		BotDifficulty.Ace => GameConfig.BotThreatHorizonAce,
		_ => GameConfig.BotThreatHorizonVeteran,
	};

	// ---- 个性原型参数表(v0.7.8.95):都是效用权重,不改变优先级骨架 ----

	/// <summary> 追猎感知倍率:猎杀者看得远,农夫懒得看 </summary>
	float ArchHuntScale() => Archetype switch
	{
		BotArchetype.Killer => 1.3f,
		BotArchetype.Farmer => 0.6f,
		BotArchetype.FlagRusher => 0.8f,
		BotArchetype.Bodyguard => 0.7f,
		_ => 1f,
	};

	/// <summary> 猎物评分加成:猎杀者眼里猎物更香 </summary>
	float ArchPreyBonus() => Archetype == BotArchetype.Killer ? 1.4f : 1f;

	/// <summary> 选旗价值倍率:抢旗手拉满,农夫基本不碰(除非告急回防) </summary>
	float ArchFlagScale() => Archetype switch
	{
		BotArchetype.FlagRusher => 1.6f,
		BotArchetype.Farmer => 0.5f,
		BotArchetype.Killer => 0.8f,
		_ => 1f,
	};

	/// <summary> 喂大哥的体型门槛:保镖 1.08 倍就开始喂(原值 1.25),更恋家 </summary>
	float ArchFeedThreshold() => Archetype == BotArchetype.Bodyguard ? 1.08f : 1.25f;

	/// <summary> 猎杀者不喂人(独狼) </summary>
	bool ArchFeeds() => Archetype != BotArchetype.Killer;

	/// <summary> 集结距离门槛:保镖超远也去汇合,农夫/猎杀者基本散养 </summary>
	float ArchRallyRange() => Archetype switch
	{
		BotArchetype.Bodyguard => 1800f,
		BotArchetype.Farmer => 700f,
		BotArchetype.Killer => 700f,
		_ => GameConfig.BotRallyRange,
	};

	protected override void OnFixedUpdate()
	{
		base.OnFixedUpdate();
		if ( IsProxy ) return;
		if ( !_self.IsValid() || !_self.Alive ) return;   // 死亡状态不决策
		if ( MatchState.MatchOver ) return;               // 结算期全场冻结

		var interval = DecisionInterval();
		if ( _sinceDecision < interval ) return;
		_sinceDecision = 0;

		_stats = BotStats.Track( _self, (byte)Difficulty, (byte)Archetype );
		if ( _stats is not null ) _stats.Decisions++;

		var ctx = Survey();

		// 领土目标层(M7.5):节流重估目标旗(选旗/喂旗规则见 PickFlagTarget)
		if ( _sinceFlagPick >= GameConfig.BotFlagPickInterval ) PickFlagTarget();
		if ( _hasFlagTarget )
		{
			// 目标格已满驻军 = 任务完成,放下目标回归觅食
			TerritoryManager.CellOf( _flagTarget, out var tcx, out var tcy );
			TerritoryManager.CellState( tcx, tcy, out var o, out _, out var sc );
			if ( o == _self.TeamIndex && sc >= GameConfig.TerritoryCaptureScore )
				_hasFlagTarget = false;
		}

		// 主动炸草目标层(v0.7.8.96):节流重估(选刺/验靶规则见 PickBombTarget)
		if ( _sinceBombPick >= GameConfig.BotSpikeBombPickInterval ) PickBombTarget();

		_self.SetBotDirection( Aim( AvoidSpines( AvoidSpikes( DecideDir( ctx ) ) ) ) );

		// 表情档(v0.7.8.36 像素风):逃跑=惊恐,追猎=兴奋(王牌=得意),闲逛=开心。
		// 写 Ball.Mood [Sync(FromHost)],各端 [Change] 换脸;值不变不发包
		byte mood = 0;
		if ( ctx.ThreatCount > 0 ) mood = 2;
		else if ( ctx.PreyMass > 0f ) mood = Difficulty == BotDifficulty.Ace ? (byte)5 : (byte)1;
		if ( _self.Mood != mood ) _self.Mood = mood;

		// 动作层(方向之外的能力):分裂逃命 > 托孤吐球 > 分裂捕猎 / 喂队友 / 喂旗 / 炸草 / 职业 Q
		TrySplitEscape( ctx );
		TryBequest( ctx );
		TrySplitHunt( ctx );
		TryFeedMate( ctx );
		TryFeedFlag( ctx );
		TrySpikeBomb( ctx );
		TryCastClassSkill( ctx );
	}

	/// <summary>
	/// 职业技能施放(M7.5b):按职业情境化使用,全部走 ClassSkillManager.Cast 权威入口
	/// (冷却/校验内置,冷却按球 Id 记——bot 各球独立)。难度概率 gate,新兵不放。
	/// 指挥官=被威胁时传到最大队友身边(逃生+集结);工兵=远距离赶旗疾行/被追加速;
	/// 护卫=被威胁时原地落尖刺断追兵;坦克=被威胁时质量爆发硬刚。
	/// </summary>
	void TryCastClassSkill( BotContext ctx )
	{
		if ( !MatchState.IsTerritory ) return;
		if ( _self.TerritoryClass > 3 ) return;
		if ( Difficulty == BotDifficulty.Rookie ) return;
		if ( Game.Random.Float( 0f, 1f ) > ActionChance() ) return;

		var use = _self.TerritoryClass switch
		{
			0 => ctx.ThreatCount > 0,
			1 => ( _hasFlagTarget && _self.WorldPosition.Distance( _flagTarget ) > 1200f ) || ctx.ThreatCount > 0,
			2 => ctx.ThreatCount > 0,
			3 => ctx.ThreatCount > 0,
			_ => false,
		};
		if ( !use ) return;

		if ( _stats is not null && ClassSkillManager.Cast( _self ) ) _stats.Skills++;   // 冷却未好静默拒绝,无副作用
	}

	/// <summary>
	/// 选旗(M7.5 领土目标层):16 格里挑"能动手"的旗——己方格(邻接已含)或邻接己方领土的
	/// 中立/敌格(CanActOnCell 与 Feed 的入侵门同一规则)。价值÷距离评分:
	/// 满驻军不碰;己方残血=回防加固;中立=无守军优先;敌格=驻军越薄越值得抢(王牌 ×1.5 更爱进攻)。
	/// 新兵不参与(照旧觅食游荡);每 BotFlagPickInterval 重估一次,带队随机抖动分散扎堆。
	/// </summary>
	void PickFlagTarget()
	{
		_sinceFlagPick = 0;
		_hasFlagTarget = false;

		if ( !MatchState.IsTerritory || _self.TeamIndex < 0 ) return;
		if ( Difficulty == BotDifficulty.Rookie ) return;

		var grid = GameConfig.TerritoryGrid;

		// 驻军分快照数组(热重载会给老实例留 null,这里懒加载自愈——每帧路径禁 NRE)
		if ( _cellScoreSeen is null || _cellScoreSeen.Length != grid * grid )
			_cellScoreSeen = new float[grid * grid];

		var myPos = _self.WorldPosition;
		var bestScore = 0f;

		for ( int cy = 0; cy < grid; cy++ )
		{
			for ( int cx = 0; cx < grid; cx++ )
			{
				TerritoryManager.CellState( cx, cy, out var owner, out _, out var score );
				if ( owner == _self.TeamIndex && score >= GameConfig.TerritoryCaptureScore ) continue;   // 满驻军没活干
				if ( !TerritoryManager.CanActOnCell( cx, cy, _self.TeamIndex ) ) continue;              // 够不着不空想

				float value;
				if ( owner == _self.TeamIndex )
				{
					value = 1f + ( GameConfig.TerritoryCaptureScore - score ) / GameConfig.TerritoryCaptureScore;      // 回防加固
					// 告急检测(v0.7.8.95):自己格的驻军分比上次采样掉了 → 正在被啃,紧急回防
					// (领土衰减已砍,分数只会被敌人吃掉才会降;首采样 seen=0 不会误触发)
					var seen = _cellScoreSeen[cy * grid + cx];
					if ( seen >= 0f && score < seen - GameConfig.BotCellDropThreshold )
						value += GameConfig.BotUrgentDefendBoost;
				}
				else if ( owner < 0 )
					value = 1.3f;                                                                                      // 中立无守军
				else
					value = 0.6f + ( GameConfig.TerritoryCaptureScore - score ) / GameConfig.TerritoryCaptureScore;    // 敌格看残度

				// 个性原型旗值倍率(v0.7.8.95):抢旗手 ×1.6 直奔胜利条件,农夫 ×0.5 基本不碰
				value *= ArchFlagScale();

				if ( Difficulty == BotDifficulty.Ace && owner >= 0 && owner != _self.TeamIndex ) value *= 1.5f;

				// 队友刚选中的格降权(v0.7.8.94):别全队挤同一面旗;预约超时或换格自动失效
				var claimKey = ( (long)_self.TeamIndex << 32 ) | (uint)( cy * grid + cx );
				if ( _flagClaims.TryGetValue( claimKey, out var claim ) && claim.BallId != _self.Id
					&& claim.Since < GameConfig.BotFlagClaimSeconds )
					value *= GameConfig.BotFlagClaimPenalty;

				var d = myPos.Distance( TerritoryManager.CellCenter( cx, cy ) );
				var s = value * 1000f / ( 200f + d ) * Game.Random.Float( 0.8f, 1.2f );   // 价值÷距离 + 抖动防全队扎堆
				if ( s > bestScore )
				{
					bestScore = s;
					_flagTarget = TerritoryManager.CellCenter( cx, cy );
					_hasFlagTarget = true;
				}
			}
		}

		// 登记本次选中的格(v0.7.8.94),队友下轮选旗时避开;顺带刷新全部格的驻军分快照
		for ( int cy2 = 0; cy2 < grid; cy2++ )
		{
			for ( int cx2 = 0; cx2 < grid; cx2++ )
			{
				TerritoryManager.CellState( cx2, cy2, out _, out _, out var sc );
				_cellScoreSeen[cy2 * grid + cx2] = sc;
			}
		}
		if ( _hasFlagTarget )
		{
			TerritoryManager.CellOf( _flagTarget, out var fcx, out var fcy );
			_flagClaims[( (long)_self.TeamIndex << 32 ) | (uint)( fcy * grid + fcx )] = ( _self.Id, 0f );
		}
	}

	/// <summary>
	/// 吐孢子喂旗(M7.5 领土目标层):在目标旗圈内朝旗心吐——走 DoEject 同一条权威路径
	/// (内部自带 0.15s 节流;孢子进旗圈即计分消耗)。质量低于阈值不喂(饿瘦=白给),
	/// 保命/吃优先,本方法冷却再套一层控节奏。
	/// </summary>
	void TryFeedFlag( BotContext ctx )
	{
		if ( !GameConfig.EnableEject || !_hasFlagTarget ) return;
		if ( ctx.ThreatCount > 0 || ctx.PreyMass > 0f ) return;
		if ( _self.Mass < GameConfig.BotFeedFlagMinMass ) return;
		if ( _self.WorldPosition.Distance( _flagTarget ) > GameConfig.TerritoryFlagRadius * 1.2f ) return;   // 没到位不空吐
		if ( _sinceFlagFeed < GameConfig.BotFeedFlagCooldown ) return;

		_sinceFlagFeed = 0;
		CircleroyaleGame.Current?.DoEject( _self, new Vector2( _flagTarget.x, _flagTarget.y ) );
		if ( _stats is not null ) _stats.FeedFlags++;
	}

	/// <summary>
	/// 借刺反杀(v0.7.8.96 手法1):逃跑途中找"追兵撞上必爆、自己能安全绕过"的刺——
	/// 追兵半径 > SpikeRadius 才会吃刺爆裂,自己半径必须 ≤ SpikeRadius(队友的尖刺分身
	/// 对我免疫,可以贴身借)。命中就在逃跑方向上掺一个向刺分量,与既有 AvoidSpikes
	/// 的推离平衡后,实际轨迹是贴着刺芯安全边绕行——追兵的追击线正好犁过去。
	/// 按最近威胁判定(简化:更远的大威胁不计),新兵不借。
	/// </summary>
	Vector2 KiteBias( BotContext ctx, Vector2 flee )
	{
		var bias = Difficulty switch
		{
			BotDifficulty.Rookie => 0f,
			BotDifficulty.Ace => GameConfig.BotKiteBiasAce,
			_ => GameConfig.BotKiteBiasVeteran,
		};
		if ( bias <= 0f || ctx.ThreatCount == 0 ) return Vector2.Zero;

		// 自己比刺大:撞刺爆的是我,不借;追兵不比刺大:借了也没收益
		var myRadius = _self.Radius;
		if ( myRadius > GameConfig.SpikeRadius ) return Vector2.Zero;
		if ( ctx.ThreatRadius <= GameConfig.SpikeRadius ) return Vector2.Zero;

		var game = CircleroyaleGame.Current;
		if ( game is null ) return Vector2.Zero;

		var myPos = _self.WorldPosition;
		var best = Vector2.Zero;
		var bestAlign = 0.3f;   // 只顺路借刺(与逃跑方向夹角约 <72°),不为借刺大绕路

		if ( game.Spikes is not null )
		{
			foreach ( var s in game.Spikes )
			{
				var to = new Vector2( s.x - myPos.x, s.y - myPos.y );
				var d = to.Length;
				if ( d < 0.001f || d > GameConfig.BotKiteRange ) continue;
				var align = Vector2.Dot( to / d, flee );
				if ( align <= bestAlign ) continue;
				bestAlign = align;
				best = to / d;
			}
		}

		// 队友的尖刺分身:自己免疫,最优借刺对象(敌方分身与野刺同规则,也纳入)
		var cells = game.Cells;
		if ( cells is not null )
		{
			foreach ( var c in cells )
			{
				if ( c.PieceKind != CellPiece.Kind.SpikeMinion ) continue;
				var ob = c.OwnerBall;
				bool friendly = MatchState.IsTeam && ob.IsValid() && ob.Alive && ob.TeamIndex == _self.TeamIndex;
				if ( !friendly && myRadius > GameConfig.SpikeRadius ) continue;   // 敌方分身:自己大了不敢贴

				var to = new Vector2( c.Pos.x - myPos.x, c.Pos.y - myPos.y );
				var d = to.Length;
				if ( d < 0.001f || d > GameConfig.BotKiteRange ) continue;
				var align = Vector2.Dot( to / d, flee ) * ( friendly ? 1.3f : 1f );
				if ( align <= bestAlign ) continue;
				bestAlign = align;
				best = to / d;
			}
		}

		if ( best.LengthSquared <= 0f ) return Vector2.Zero;
		if ( _stats is not null ) _stats.Kites++;
		return best * bias;
	}

	/// <summary>
	/// 临终托孤(v0.7.8.96 手法2):威胁已贴脸(距离 < 双方半径+BotBequestMouth,且分裂
	/// 逃命未必来得及)时找"逃跑半区内、自身离威胁足够远"的最近队友——往他身上吐质量,
	/// 死也不给追兵白吃。新兵不会这手(只会慌),质量太薄不值得折腾。
	/// </summary>
	Ball FindBequestMate( BotContext ctx )
	{
		if ( !GameConfig.EnableEject || Difficulty == BotDifficulty.Rookie ) return null;
		if ( ctx.Mates.Count == 0 || _self.Mass < GameConfig.BotBequestMinMass ) return null;

		var mouth = ctx.ThreatRadius + _self.Radius + GameConfig.BotBequestMouth;
		if ( _self.WorldPosition.Distance( ctx.ThreatPos ) > mouth ) return null;   // 还没到绝路

		var myPos = _self.WorldPosition;
		var away = myPos - ctx.ThreatPos;
		Ball best = null;
		var bestD = float.MaxValue;
		foreach ( var m in ctx.Mates )
		{
			if ( !m.IsValid() || !m.Alive ) continue;
			var to = m.WorldPosition - myPos;
			if ( Vector3.Dot( to, away ) < 0f ) continue;   // 只在逃跑半区找,别掉头冲进威胁怀里
			if ( m.WorldPosition.Distance( ctx.ThreatPos ) < GameConfig.BotBequestMateSafety ) continue;
			var d = to.Length;
			if ( d < bestD )
			{
				bestD = d;
				best = m;
			}
		}
		return best;
	}

	/// <summary> 托孤动作层:贴脸状态持续期间朝队友连吐孢子(DoEject 自带 0.15s 节流) </summary>
	void TryBequest( BotContext ctx )
	{
		if ( !GameConfig.EnableEject || ctx.ThreatCount == 0 ) return;
		var mate = FindBequestMate( ctx );
		if ( !mate.IsValid() ) return;

		CircleroyaleGame.Current?.DoEject( _self,
			new Vector2( mate.WorldPosition.x, mate.WorldPosition.y ) );
		if ( _stats is not null ) _stats.Bequests++;
	}

	/// <summary>
	/// 调试探针(临时,v0.7.8.101,cr_bombprobe 用):每只够格 bot 的炸草管线实况——
	/// 是否有目标/距刺多远/开喂圈内有几个敌人/耐心剩余。随手法稳定后删。
	/// </summary>
	public static string DebugBombProbe()
	{
		var game = CircleroyaleGame.Current;
		if ( game is null ) return "no game";

		var sb = new StringBuilder();
		foreach ( var b in game.Balls )
		{
			if ( !b.IsValid() || !b.Alive || !b.IsBot ) continue;
			if ( b.Mass < GameConfig.BotSpikeBombMinMass ) continue;
			var brain = b.GetComponent<BotBrain>();
			if ( brain is null ) continue;

			sb.AppendLine( $"'{b.PlayerName}' m={b.Mass:0} hasTarget={brain._hasBombTarget}" );
			if ( !brain._hasBombTarget ) continue;

			var d = b.WorldPosition.Distance( brain._bombSpike );
			int eRing = 0, eArea = 0;
			foreach ( var o in game.Balls )
			{
				if ( !o.IsValid() || !o.Alive || o == b ) continue;
				if ( MatchState.IsTeam && o.TeamIndex >= 0 && o.TeamIndex == b.TeamIndex ) continue;
				var od = o.WorldPosition.Distance( brain._bombSpike );
				if ( od <= GameConfig.BotSpikeBombRadius && o.Mass >= GameConfig.BotSpikeBombEnemyMinMass ) eRing++;
				if ( od <= GameConfig.BotSpikeBombAreaRadius ) eArea++;
			}
			sb.AppendLine( $"  spikeD={d:0} feedR={b.Radius + GameConfig.BotFeedEjectReach:0} enemiesInRing={eRing} inArea={eArea} patienceLeft={GameConfig.BotSpikeBombPatience - brain._sinceBombArrive:0}" );
		}
		return sb.Length == 0 ? "no eligible bot (mass < MinMass)" : sb.ToString();
	}

	/// <summary>
	/// 选炸草目标刺(v0.7.8.99/101 伏击制):质量烧得起即可选刺(v0.7.8.101 放开新兵门禁——
	/// 实测领土局能攒到 fat 的常是蹲角落的新兵,排除他们 = 炸草永远凑不齐人)。
	/// 选刺时**不要求**有敌情(3 倍场地 16 球摊得稀,"肥 bot+敌群+刺"同框是小概率事件;
	/// 真打法是守在刺旁等敌人路过),敌情只作加分;队友都在危险圈外是硬条件。
	/// 价值按敌情加成÷路程评分。
	/// </summary>
	void PickBombTarget()
	{
		_sinceBombPick = 0;
		_hasBombTarget = false;

		if ( !GameConfig.EnableEject ) return;
		if ( _self.Mass < GameConfig.BotSpikeBombMinMass ) return;

		var game = CircleroyaleGame.Current;
		var spikes = game?.Spikes;
		if ( spikes is null || spikes.Length == 0 ) return;

		var myPos = _self.WorldPosition;
		var bestScore = 0f;

		foreach ( var s in spikes )
		{
			var sp = new Vector3( s.x, s.y, 0f );

			// 队友必须都在弹幕圈外(自己进场喂刺是计划内风险)
			bool mateClear = true;
			foreach ( var b in game.Balls )
			{
				if ( !b.IsValid() || !b.Alive || b == _self ) continue;
				if ( !( MatchState.IsTeam && b.TeamIndex >= 0 && b.TeamIndex == _self.TeamIndex ) ) continue;
				if ( b.WorldPosition.Distance( sp ) < GameConfig.BotSpikeBombMateClear )
				{
					mateClear = false;
					break;
				}
			}
			if ( !mateClear ) continue;

			// 敌情只加分不设门槛:动静圈(1800)内有大目标 ×2、任意敌人 ×1.2
			float traffic = 0f;
			foreach ( var b in game.Balls )
			{
				if ( !b.IsValid() || !b.Alive || b == _self ) continue;
				if ( MatchState.IsTeam && b.TeamIndex >= 0 && b.TeamIndex == _self.TeamIndex ) continue;
				if ( b.WorldPosition.Distance( sp ) > GameConfig.BotSpikeBombAreaRadius ) continue;
				traffic = MathF.Max( traffic, b.Mass >= GameConfig.BotSpikeBombEnemyMinMass ? 2f : 1.2f );
			}

			var d = myPos.Distance( sp );
			if ( d > GameConfig.BotSpikeBombRange ) continue;   // 太远不赶场,走到的路上目标早换了

			var sc = traffic * 1000f / ( 200f + d );
			if ( sc > bestScore )
			{
				bestScore = sc;
				_bombSpike = sp;
				_hasBombTarget = true;
			}
		}

		if ( _hasBombTarget ) _sinceBombArrive = 0;   // 新目标重置耐心计时
	}

	/// <summary> 炸草目标复核(每次决策):刺还在原位 + 队友没进圈 + 自己没喂瘦 + 没超耐心。
	/// (v0.7.8.99 伏击制:走到/蹲守期间不要求敌人在场——敌人进圈才开喂是 TrySpikeBomb 的事) </summary>
	bool VerifyBombTarget()
	{
		if ( _self.Mass < GameConfig.BotSpikeBombFloorMass ) return false;
		if ( _sinceBombArrive > GameConfig.BotSpikeBombPatience ) return false;

		var game = CircleroyaleGame.Current;
		var spikes = game?.Spikes;
		if ( spikes is null ) return false;

		bool spikeAlive = false;
		foreach ( var s in spikes )
		{
			var sp = new Vector3( s.x, s.y, 0f );
			if ( sp.Distance( _bombSpike ) < 80f )
			{
				spikeAlive = true;
				break;
			}
		}
		if ( !spikeAlive ) return false;   // 已被爆掉/换位

		foreach ( var b in game.Balls )
		{
			if ( !b.IsValid() || !b.Alive || b == _self ) continue;
			if ( MatchState.IsTeam && b.TeamIndex >= 0 && b.TeamIndex == _self.TeamIndex )
			{
				if ( b.WorldPosition.Distance( _bombSpike ) < GameConfig.BotSpikeBombMateClear )
					return false;
			}
		}
		return true;
	}

	/// <summary> 炸草动作层:到刺旁候着,敌方目标进弹幕圈才开喂(白等不白喂);
	/// 有威胁/有猎物立即让位 </summary>
	void TrySpikeBomb( BotContext ctx )
	{
		if ( !GameConfig.EnableEject || !_hasBombTarget ) return;
		if ( ctx.ThreatCount > 0 || ctx.PreyMass > 0f ) return;
		if ( _self.Mass < GameConfig.BotSpikeBombFloorMass ) return;
		if ( _self.WorldPosition.Distance( _bombSpike ) > _self.Radius + GameConfig.BotFeedEjectReach ) return;

		// 严格圈:目标真进了弹幕圈才烧自己的质量
		bool enemyInRing = false;
		var game = CircleroyaleGame.Current;
		if ( game is not null )
		{
			foreach ( var b in game.Balls )
			{
				if ( !b.IsValid() || !b.Alive || b == _self ) continue;
				if ( MatchState.IsTeam && b.TeamIndex >= 0 && b.TeamIndex == _self.TeamIndex ) continue;
				if ( b.Mass < GameConfig.BotSpikeBombEnemyMinMass ) continue;
				if ( b.WorldPosition.Distance( _bombSpike ) > GameConfig.BotSpikeBombRadius ) continue;
				enemyInRing = true;
				break;
			}
		}
		if ( !enemyInRing ) return;

		if ( _sinceBombFeed < GameConfig.BotSpikeBombCooldown ) return;

		_sinceBombFeed = 0;
		CircleroyaleGame.Current?.DoEject( _self, new Vector2( _bombSpike.x, _bombSpike.y ) );
		if ( _stats is not null ) _stats.SpikeBombs++;
	}

	/// <summary> 收集态势:威胁/猎物(球 + 敌方分身,跳过队友)+ 喂养目标 + 队友列表 </summary>
	BotContext Survey()
	{
		var ctx = new BotContext();
		var game = CircleroyaleGame.Current;
		if ( game is null ) return ctx;

		float threatDist = float.MaxValue;
		float preyScore = -1f;      // 猎物按"质量回报/距离"评分,不再单纯取最近
		float feedMass = -1f;
		var myPos = _self.WorldPosition;
		var myMass = _self.Mass;
		var shielded = _self.HasBuff( PowerUpManager.Kind.Shield );   // 护盾期连分身都吞不了我

		foreach ( var b in game.Balls )
		{
			if ( !b.IsValid() || !b.Alive || b == _self ) continue;

			bool mate = MatchState.IsTeam && b.TeamIndex >= 0 && b.TeamIndex == _self.TeamIndex;
			if ( mate ) ctx.Mates.Add( b );

			var d = myPos.Distance( b.WorldPosition );

			// 分裂风险评估(v0.7.8.32,独立于威胁分支):减半后的主体会不会被它近身吃掉——
			// 能吃"减半我"但吃不了"现在的我"的中等敌人恰恰是分裂后的头号杀手
			if ( !mate && b.Mass >= myMass * 0.5f * GameConfig.EatRatio
				&& d < b.Radius + _self.Radius * 0.75f + GameConfig.BotSplitRiskRange )
			{
				ctx.DangerAfterSplit = true;
			}

			// 威胁:质量够吃我(≥ EatRatio,与权威吃取同式)、不是队友、我不在保护/护盾期
			if ( !mate && b.Mass >= myMass * GameConfig.EatRatio && !_self.IsProtected && !shielded )
			{
				var range = b.Radius + _self.Radius + GameConfig.BotFleeRange * RangeScale();
				if ( d < range )
				{
					ctx.ThreatCount++;
					ctx.FleeDir += ( myPos - b.WorldPosition ).Normal * ( 1f - d / range );   // 越近推力越大
					if ( d < threatDist )
					{
						threatDist = d;
						ctx.Threat = b;
						ctx.ThreatPos = b.WorldPosition;
						ctx.ThreatRadius = b.Radius;
					}
				}
				// TTC 预警(v0.7.8.95):还没进逃跑圈、但正高速逼近的致命敌人——记进预警和,
				// 由 DecideDir 往当前方向掺避让分量(老手/王牌提前走位,新兵视界 0=看不见)
				else
				{
					var horizon = ThreatHorizon();
					if ( horizon > 0f )
					{
						var closing = Vector3.Dot( ( b.WorldPosition - myPos ).Normal, b.Velocity - _self.Velocity );
						if ( closing > 60f )
						{
							var ttc = d / closing;
							if ( ttc < horizon )
							{
								var away = ( myPos - b.WorldPosition ).Normal;
								ctx.EarlyThreatDir += new Vector2( away.x, away.y ) * ( 1f - ttc / horizon );
								ctx.EarlyThreatCount++;
							}
						}
					}
				}
			}
			// 猎物:我够吃它、不是队友、双方都不在保护期(护盾期免战,追了也白追);
			// 道具护盾同理——追也吃不到,跳过不浪费时间
			else if ( !mate && myMass >= b.Mass * GameConfig.EatRatio && !b.IsProtected && !_self.IsProtected
				&& !b.HasBuff( PowerUpManager.Kind.Shield ) )
			{
				var range = GameConfig.BotHuntRange * RangeScale() * ArchHuntScale();
				if ( d < range && CanCatch( b.WorldPosition, b.Velocity, myPos ) )
				{
					var score = b.Mass / ( 100f + d ) * ArchPreyBonus();
					if ( score > preyScore )
					{
						ctx.Prey = b;
						ctx.PreyPos = b.WorldPosition;
						ctx.PreyRadius = b.Radius;
						ctx.PreyMass = b.Mass;
						ctx.PreyVel = b.Velocity;
						preyScore = score;
					}
				}
			}

			// 喂养目标(团队赛,养大哥):范围内最大的、明显比我大的队友
			// (v0.7.8.95:门槛按原型取——保镖 1.08 倍就喂;猎杀者不喂人)
			if ( mate && GameConfig.EnableEject && ArchFeeds() && myMass >= GameConfig.BotFeedMinMass
				&& b.Mass > myMass * ArchFeedThreshold()
				&& d < GameConfig.BotFeedRange * RangeScale()
				&& b.Mass > feedMass )
			{
				ctx.FeedTarget = b;
				feedMass = b.Mass;
			}
		}

		// 敌方分身感知(v0.7.8.32):分身能吞无盾主球也能被吞——此前 bot 完全看不见它们。
		// 阵营按主人球判(TickCells 自愈保证 OwnerBall 有效;主人真死时分身本来也会被清)
		var cells = game.Cells;
		if ( cells is not null )
		{
			foreach ( var c in cells )
			{
				if ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;
				if ( c.OwnerSteamId == _self.OwnerSteamId ) continue;
				var ob = c.OwnerBall;
				if ( MatchState.IsTeam && ob.IsValid() && ob.TeamIndex >= 0 && ob.TeamIndex == _self.TeamIndex ) continue;

				var d = myPos.Distance( c.Pos );

				// 分裂风险评估(同球规则,独立于威胁分支)
				if ( c.Mass >= myMass * 0.5f * GameConfig.EatRatio
					&& d < c.Radius + _self.Radius * 0.75f + GameConfig.BotSplitRiskRange )
				{
					ctx.DangerAfterSplit = true;
				}

				if ( c.Mass >= myMass * GameConfig.EatRatio && !_self.IsProtected && !shielded )
				{
					var range = c.Radius + _self.Radius + GameConfig.BotFleeRange * RangeScale();
					if ( d < range )
					{
						ctx.ThreatCount++;
						ctx.FleeDir += ( myPos - c.Pos ).Normal * ( 1f - d / range );
						if ( d < threatDist )
						{
							threatDist = d;
							ctx.Threat = null;
							ctx.ThreatPos = c.Pos;
							ctx.ThreatRadius = c.Radius;
						}
					}
					else
					{
						// 分身的 TTC 预警(v0.7.8.95,同主球规则):分身速度 = 冲量 + 跟随转向
						var horizon = ThreatHorizon();
						if ( horizon > 0f )
						{
							var cv = c.Impulse + c.SteerVel;
							var closing = Vector3.Dot( ( c.Pos - myPos ).Normal, cv - _self.Velocity );
							if ( closing > 60f )
							{
								var ttc = d / closing;
								if ( ttc < horizon )
								{
									var away = ( myPos - c.Pos ).Normal;
									ctx.EarlyThreatDir += new Vector2( away.x, away.y ) * ( 1f - ttc / horizon );
									ctx.EarlyThreatCount++;
								}
							}
						}
					}
				}
				else if ( myMass >= c.Mass * GameConfig.EatRatio )
				{
					var range = GameConfig.BotHuntRange * RangeScale() * ArchHuntScale();
					var cv = c.Impulse + c.SteerVel;   // 分身当前速度(冲量+跟随转向)
					if ( d < range && CanCatch( c.Pos, cv, myPos ) )
					{
						var score = c.Mass / ( 100f + d ) * ArchPreyBonus();
						if ( score > preyScore )
						{
							ctx.Prey = null;
							ctx.PreyPos = c.Pos;
							ctx.PreyRadius = c.Radius;
							ctx.PreyMass = c.Mass;
							ctx.PreyVel = cv;
							preyScore = score;
						}
					}
				}
			}
		}
		return ctx;
	}

	/// <summary>
	/// 方向决策入口(v0.7.8.95):核心状态机外面套一层 TTC 预警避让——预警不动摇状态机
	/// (该追还追、该赶旗还赶旗),只往算出的方向里掺一个"避开正在逼近的致命敌人"的分量。
	/// 停驻状态(方向≈0,如圈内喂旗/站位喂大哥)不掺:预警视界只有 1 秒左右,真到嘴边
	/// 逃跑层自然会接管,别让喂旗 bot 被远处的敌人吓跑。
	/// </summary>
	Vector2 DecideDir( BotContext ctx )
	{
		var dir = DecideDirCore( ctx );
		if ( ctx.EarlyThreatCount > 0 && dir.Length > 0.001f )
		{
			var away = Norm( new Vector3( ctx.EarlyThreatDir.x, ctx.EarlyThreatDir.y, 0f ) );
			dir = Norm( new Vector3(
				dir.x + away.x * GameConfig.BotEarlyThreatBlend,
				dir.y + away.y * GameConfig.BotEarlyThreatBlend, 0f ) );
		}
		return dir;
	}

	/// <summary> 方向决策核心:逃跑 &gt; 追猎 &gt; 靠近喂养目标 &gt; 觅食 &gt; 游荡(团队赛偏集结) </summary>
	Vector2 DecideDirCore( BotContext ctx )
	{
		var myPos = _self.WorldPosition;

		// 逃跑:全部威胁反向合成(越近权重越大,v0.7.8.32)+ 朝场地中心分量(防被逼进角落)
		if ( ctx.ThreatCount > 0 )
		{
			if ( _stats is not null ) _stats.Flees++;

			// 临终托孤(v0.7.8.96):威胁贴脸、逃生无望,且逃跑半区有安全队友——
			// 别直线送死,掉头朝队友把质量送出去再死(动作层 TryBequest 配合吐球)
			var bequest = FindBequestMate( ctx );
			if ( bequest.IsValid() ) return Norm( bequest.WorldPosition - myPos );

			var flee = ctx.FleeDir.Length > 0.01f
				? Norm( ctx.FleeDir )
				: new Vector2( myPos.x - ctx.ThreatPos.x, myPos.y - ctx.ThreatPos.y );
			var toCenter = new Vector2( -myPos.x, -myPos.y );
			var len = toCenter.Length;
			toCenter = len > 0.001f ? toCenter / len : Vector2.Zero;
			var dir = Norm( new Vector3( flee.x + toCenter.x * 0.45f, flee.y + toCenter.y * 0.45f, 0f ) );

			// 借刺反杀(v0.7.8.96):追兵撞刺必爆、自己能安全绕过 → 逃跑方向掺向刺分量,
			// 让追兵的追击线犁过刺芯(大球转向笨拙,人类玩家更容易上当)
			var kite = KiteBias( ctx, dir );
			if ( kite.LengthSquared > 0.0001f )
				dir = Norm( new Vector3( dir.x + kite.x, dir.y + kite.y, 0f ) );
			return dir;
		}

		// 追猎:按猎物当前速度打拦截提前量(v0.7.8.32)——直线追永远追不上会跑的猎物
		if ( ctx.PreyMass > 0f )
		{
			if ( _stats is not null ) _stats.Hunts++;
			var target = ctx.PreyPos;
			var lead = LeadFactor();
			if ( lead > 0f && ctx.PreyVel.LengthSquared > 1f )
			{
				var mySpeed = GameConfig.BaseSpeed
					* MathF.Pow( GameConfig.StartMass / MathF.Max( 1f, _self.Mass ), GameConfig.SpeedCurve );
				var d = myPos.Distance( ctx.PreyPos );
				var leadVec = ctx.PreyVel * ( d / MathF.Max( 60f, mySpeed ) ) * lead;
				var leadLen = leadVec.Length;
				if ( leadLen > _self.Radius * 1.5f ) leadVec *= _self.Radius * 1.5f / leadLen;   // 封顶别冲过头
				target = ctx.PreyPos + leadVec;
			}
			return Norm( target - myPos );
		}

		// 主动炸草导航(v0.7.8.99):**压在旗目标层上面**——非新兵几乎永远有旗目标
		// (每 2s 重选总有格可打),炸草层放它后面就永远轮不到(bomb 全场为 0 的实测根因)。
		// 质量够 + 附近有动静圈合格的刺才偏离旗任务,偏差约 10 秒;目标失效当场放弃
		if ( _hasBombTarget && !VerifyBombTarget() )
			_hasBombTarget = false;
		if ( _hasBombTarget )
		{
			var d = myPos.Distance( _bombSpike );
			if ( d > _self.Radius + GameConfig.BotFeedEjectReach ) return Norm( _bombSpike - myPos );
			return OrbitDir( _bombSpike );   // 绕刺盘旋候敌:AvoidSpikes 钳安全距离,敌人进圈就开喂
		}

		// 领土目标导航(M7.5):无威胁无猎物时朝目标旗赶路——压过喂大哥/捡道具/觅食
		// (旗是胜利条件);饿瘦到喂旗阈值以下就放下目标,落回觅食层恢复
		if ( _hasFlagTarget )
		{
			if ( _self.Mass < GameConfig.BotFeedFlagMinMass )
			{
				_hasFlagTarget = false;
			}
			else
			{
				// v0.7.8.94:进旗圈站稳(圈内出生的孢子即计分),不再直奔旗心;
				// v0.7.8.103:站桩改缓速盘旋(两只相近 bot 同旗站桩=对峙卡死的死相)
				var d = myPos.Distance( _flagTarget );
				if ( d > GameConfig.BotFlagStandoff ) return Norm( _flagTarget - myPos );
				return OrbitDir( _flagTarget );
			}
		}

		// 喂养:靠近大哥到"孢子够得着"的距离即停(v0.7.8.94 修"两 bot 缩在一起"——
		// 旧版直奔大哥球心贴脸推挤,且越喂条件越成立,一蹲几十秒);
		// 连续喂养有时长预算,喂一阵强制休整去觅食回血,行为不再是"吸附"在大哥身上
		if ( ctx.FeedTarget.IsValid() )
		{
			if ( _sinceFeedSession > ( _feedBreak ? GameConfig.BotFeedBreakSeconds : GameConfig.BotFeedMaxSeconds ) )
			{
				_feedBreak = !_feedBreak;
				_sinceFeedSession = 0;
			}
			if ( !_feedBreak )
			{
				var d = myPos.Distance( ctx.FeedTarget.WorldPosition );
				var stand = ctx.FeedTarget.Radius + _self.Radius + GameConfig.BotFeedStandoff;
				if ( d > stand ) return Norm( ctx.FeedTarget.WorldPosition - myPos );
				return OrbitDir( ctx.FeedTarget.WorldPosition );   // 到位即绕大哥缓转:圈内正好接孢子,不贴脸推挤
			}
		}
		else if ( _sinceFeedSession > 1f )
		{
			_sinceFeedSession = 0;   // 长时间没目标:喂养会话清零,下次重新计时
			_feedBreak = false;
		}

		// 顺路捡道具(v0.7.8.7):无威胁无猎物时附近场上有道具就冲过去(bot 拾取即用,
		// 捡到立刻生效);喂大哥/觅食都让位给这个
		var pu = NearestPowerUpPos( myPos );
		if ( pu.HasValue )
			return Norm( new Vector3( pu.Value.x, pu.Value.y, 0f ) - myPos );

		// 觅食:先看散落孢子(质量远大于食物点,值得绕路),没有再找最近食物;
		// 两层各自带粘性(v0.7.8.32):目标活着就追到底,不在两个目标间来回锯齿
		var blob = NearestBlob( myPos );
		var food = NearestFoodPos( myPos );
		Vector2? target2 = null;
		bool blobChosen = false;

		if ( blob is not null && food is null )
		{
			target2 = new Vector2( blob.Pos.x, blob.Pos.y );
			blobChosen = true;
		}
		else if ( blob is not null && food.HasValue )
		{
			// 孢子比食物点值钱好几倍:绕路 2.5 倍距离以内都值得去拿
			var bd = myPos.Distance( blob.Pos );
			var fd = myPos.Distance( new Vector3( food.Value.x, food.Value.y, 0f ) );
			if ( bd < fd * 2.5f + 160f )
			{
				target2 = new Vector2( blob.Pos.x, blob.Pos.y );
				blobChosen = true;
			}
		}
		if ( !blobChosen && food.HasValue ) target2 = food.Value;

		if ( target2.HasValue )
		{
			if ( blobChosen )
			{
				_stickyBlobId = blob.Id;
				_sinceStickyBlob = 0;
				_sinceStickyFood = 99f;   // 失效化
			}
			else
			{
				_stickyFood = target2.Value;
				_sinceStickyFood = 0;
				_stickyBlobId = -1;
			}
			return Norm( new Vector3( target2.Value.x, target2.Value.y, 0f ) - myPos );
		}

		// 游荡:团队赛只在"离队友远"时才集结过去(v0.7.8.94:旧版无条件偏向随机队友 ±900,
		// 两个闲 bot 互相选对方就在原地 hover 成一团);已经挨着队友就随机散开
		if ( !_hasWander || myPos.Distance( _wanderTarget ) < 120f )
		{
			float half = GameConfig.ArenaHalfSize - 256f;
			var mh = GameConfig.ArenaHalfSize - 512f;
			var mate = ( MatchState.IsTeam && ctx.Mates.Count > 0 )
				? ctx.Mates[Game.Random.Int( 0, ctx.Mates.Count - 1 )]
				: null;
			if ( mate.IsValid() && myPos.Distance( mate.WorldPosition ) > ArchRallyRange() )
			{
				_wanderTarget = new Vector3(
					Math.Clamp( mate.WorldPosition.x + Game.Random.Float( -900f, 900f ), -mh, mh ),
					Math.Clamp( mate.WorldPosition.y + Game.Random.Float( -900f, 900f ), -mh, mh ), 0f );
			}
			else
			{
				_wanderTarget = new Vector3( Game.Random.Float( -half, half ), Game.Random.Float( -half, half ), 0f );
			}
			_hasWander = true;
		}
		return Norm( _wanderTarget - myPos );
	}

	/// <summary>
	/// 分裂逃命(v0.7.8.7,agar 保命手法):威胁即将咬到(距离小于双方半径之和 + 一口余量)且
	/// 自身分裂后仍是像样的身体 → 朝逃跑方向分裂——分身带冲量瞬间前窜、主体变轻提速,
	/// 脱险后冷却一到自然合体。与捕猎共用 _sinceSplit 冷却,逃命优先判定。
	/// v0.7.8.32:威胁可以是敌方分身(它们同样能吞主球)。
	/// </summary>
	void TrySplitEscape( BotContext ctx )
	{
		if ( !GameConfig.EnableSplit || ctx.ThreatCount == 0 ) return;
		if ( _self.Mass < GameConfig.BotSplitMinMass * 2f ) return;          // 太小分裂=白送两份
		if ( _sinceSplit < GameConfig.BotSplitCooldown ) return;

		var to = _self.WorldPosition - ctx.ThreatPos;
		var d = MathF.Max( to.Length, 0.001f );
		if ( d > ctx.ThreatRadius + _self.Radius + 60f ) return;            // 还没到嘴边不浪费质量
		if ( Game.Random.Float( 0f, 1f ) > ActionChance() ) return;

		_sinceSplit = 0;
		// 逃跑方向 = 反向 + 朝场地中心分量(与 DecideDir 逃跑同一套);传"瞄准点"语义
		var away = Norm( to + ( Vector3.Zero - _self.WorldPosition ).Normal * 0.45f );
		var aimPoint = new Vector2( _self.WorldPosition.x + away.x * 2048f,
			_self.WorldPosition.y + away.y * 2048f );
		CircleroyaleGame.Current?.DoSplit( _self, aimPoint );
		if ( _stats is not null ) _stats.SplitEscapes++;
		GameLog.Info( $"[bot] split-escape '{_self.PlayerName}' <- '{ctx.Threat?.PlayerName ?? "cell"}'" );
	}

	/// <summary>
	/// 分裂捕猎(v0.6.6.0):贴身(半径+打击距离内)、分出去的一半仍够吃猎物、过冷却与
	/// 难度概率门槛 → 朝猎物分裂。分身带冲量飞出去,之后跟随主人转向继续压猎物。
	/// v0.7.8.7:传猎物位置为瞄准点——每颗身体各自朝猎物扇形合围。
	/// v0.7.8.32:猎物太小不烧冷却(BotSplitPreyFraction);减半后 nearby 有够吃我的敌人就放弃
	/// (送命分裂,DangerAfterSplit);猎物可以是敌方分身。
	/// </summary>
	void TrySplitHunt( BotContext ctx )
	{
		if ( !GameConfig.EnableSplit || ctx.PreyMass <= 0f ) return;
		if ( _self.Mass < GameConfig.BotSplitMinMass ) return;                       // 太小分裂=白送质量
		if ( ctx.PreyMass < _self.Mass * GameConfig.BotSplitPreyFraction ) return;   // 太小的猎物不值得烧冷却
		if ( _self.Mass * 0.5f < ctx.PreyMass * GameConfig.EatRatio ) return;        // 分身必须还能吃掉猎物
		if ( ctx.DangerAfterSplit ) return;                                          // 减半=送命,不干
		if ( _sinceSplit < GameConfig.BotSplitCooldown ) return;

		var d = _self.WorldPosition.Distance( ctx.PreyPos );
		if ( d > _self.Radius + GameConfig.BotSplitRange * RangeScale() ) return;
		if ( Game.Random.Float( 0f, 1f ) > ActionChance() ) return;

		_sinceSplit = 0;
		CircleroyaleGame.Current?.DoSplit( _self, new Vector2( ctx.PreyPos.x, ctx.PreyPos.y ) );
		if ( _stats is not null ) _stats.SplitHunts++;
		GameLog.Info( $"[bot] split-hunt '{_self.PlayerName}' -> '{ctx.Prey?.PlayerName ?? "cell"}'" );
	}

	/// <summary>
	/// 吐孢子喂队友(团队赛,v0.6.6.0 养大哥):向范围内最大的队友吐孢子,
	/// 走 DoEject 同一条权威路径(内部自带 0.15s 节流;多身体一起吐)。
	/// 决策间隔 + 本方法冷却 + 难度概率三重节流,喂瘦到对方不足我 1.25 倍自动停。
	/// </summary>
	void TryFeedMate( BotContext ctx )
	{
		if ( !GameConfig.EnableEject || ctx.FeedTarget is null || !ctx.FeedTarget.IsValid() ) return;
		if ( _feedBreak ) return;                                 // 休整期不喂(DecideDir 也不靠近)
		if ( ctx.ThreatCount > 0 || ctx.PreyMass > 0f ) return;   // 保命/吃优先
		if ( _sinceFeed < GameConfig.BotFeedCooldown ) return;
		// 距离门(v0.7.8.94):孢子飞行约 350 就停,超远吐=白扔地上(还便宜路过的敌人)
		if ( _self.WorldPosition.Distance( ctx.FeedTarget.WorldPosition ) >
			ctx.FeedTarget.Radius + GameConfig.BotFeedEjectReach ) return;
		if ( Game.Random.Float( 0f, 1f ) > ActionChance() ) return;

		_sinceFeed = 0;
		// 传大哥位置为瞄准点(v0.7.8.7):每颗身体各自朝大哥吐——远处的分身不再沿
		// 主球方向平行走空,孢子真正落进大哥嘴里
		CircleroyaleGame.Current?.DoEject( _self,
			new Vector2( ctx.FeedTarget.WorldPosition.x, ctx.FeedTarget.WorldPosition.y ) );
		if ( _stats is not null ) _stats.FeedMates++;
	}

	/// <summary> 避刺:任何体积都得绕(感知距离 = 自身半径 + SpikeRadius×2.2)。
	/// v0.7.8.103 修"bot 在刺旁左右抖、原地钉死":切向绕行符号改用每 bot 固定旋向
	/// (旧版按"期望方向"逐帧选边——正对刺走近时对齐量≈0,符号每帧翻转 = 左右横跳);
	/// 贴得越近切向增重越压过径向推离,把"顶死"稳定变成绕圈。 </summary>
	Vector2 AvoidSpikes( Vector2 dir )
	{
		var spikes = CircleroyaleGame.Current?.Spikes;
		if ( spikes is null ) return dir;

		var myPos = _self.WorldPosition;
		var aware = _self.Radius + GameConfig.SpikeRadius * GameConfig.BotSpikeAwareness;
		foreach ( var s in spikes )
		{
			var away = new Vector2( myPos.x - s.x, myPos.y - s.y );
			var d = away.Length;
			if ( d >= aware || d < 0.001f ) continue;

			away /= d;
			var tx = -away.y * _orbitSign;
			var ty = away.x * _orbitSign;
			var near = 1f - d / aware;
			var push = 0.55f + 0.45f * near;
			var tangential = 0.9f + 1.3f * near;   // 贴近时绕行压过推离 → 稳定弧线不顶死
			dir = Norm( new Vector3(
				dir.x + ( away.x * 1.6f + tx * tangential ) * push,
				dir.y + ( away.y * 1.6f + ty * tangential ) * push, 0f ) );
		}
		return dir;
	}

	/// <summary> 躲刺爆弹幕(v0.7.8.32):飞行中的刺也是真伤害(命中 -15% 质量),近距离侧向闪避 </summary>
	Vector2 AvoidSpines( Vector2 dir )
	{
		var spines = CircleroyaleGame.Current?.Spines;
		if ( spines is null || spines.Count == 0 ) return dir;

		var myPos = _self.WorldPosition;
		var aware = _self.Radius + 96f;
		foreach ( var s in spines )
		{
			if ( s.SinceSpawn > 0.5f ) continue;   // 后半程动能耗尽,不管

			var dx = myPos.x - s.Pos.x;
			var dy = myPos.y - s.Pos.y;
			var d = MathF.Sqrt( dx * dx + dy * dy );
			if ( d >= aware || d < 0.001f ) continue;

			dir = Norm( new Vector3( dir.x + dx / d * 1.2f, dir.y + dy / d * 1.2f, 0f ) );
		}
		return dir;
	}

	/// <summary> 瞄准噪声:低难度方向打不准(±弧度内随机偏转),王牌几乎指哪打哪 </summary>
	Vector2 Aim( Vector2 dir )
	{
		var err = AimError();
		if ( err <= 0f || dir.Length <= 0.001f ) return dir;

		var a = Game.Random.Float( -err, err );
		var cos = MathF.Cos( a );
		var sin = MathF.Sin( a );
		return new Vector2( dir.x * cos - dir.y * sin, dir.x * sin + dir.y * cos );
	}

	Vector2? NearestFoodPos( Vector3 myPos )
	{
		var foods = CircleroyaleGame.Current.Food?.Foods;
		if ( foods is null ) return null;

		var best = float.MaxValue;
		Vector2? bestPos = null;
		var seek = GameConfig.BotSeekFoodRange * RangeScale();
		var seekSq = seek * seek;

		// 粘性折扣(v0.7.8.32):上次的目标还活着就当它更近(圈内匹配),防相邻食物间来回锯齿
		var sticky = _sinceStickyFood < 2.5f ? (Vector2?)_stickyFood : null;

		for ( int i = 0; i < foods.Length; i++ )
		{
			if ( !foods[i].Alive ) continue;

			var dx = foods[i].Pos.x - myPos.x;
			var dy = foods[i].Pos.y - myPos.y;
			var dSq = dx * dx + dy * dy;
			if ( sticky.HasValue )
			{
				var sx = foods[i].Pos.x - sticky.Value.x;
				var sy = foods[i].Pos.y - sticky.Value.y;
				if ( sx * sx + sy * sy < 64f * 64f ) dSq *= 0.45f;
			}
			if ( dSq > seekSq || dSq >= best ) continue;

			best = dSq;
			bestPos = foods[i].Pos;
		}
		return bestPos;
	}

	/// <summary> 感知范围内最近的场上道具(v0.7.8.7:bot 会专程去捡,拾取即用) </summary>
	Vector2? NearestPowerUpPos( Vector3 myPos )
	{
		var mgr = CircleroyaleGame.Current?.PowerUps;
		if ( mgr is null || !mgr.Ready ) return null;

		var best = float.MaxValue;
		Vector2? bestPos = null;
		var seek = GameConfig.BotSeekFoodRange * 0.75f * RangeScale();
		var seekSq = seek * seek;

		foreach ( var s in mgr.Slots )
		{
			if ( !s.Alive ) continue;

			var dx = s.Pos.x - myPos.x;
			var dy = s.Pos.y - myPos.y;
			var dSq = dx * dx + dy * dy;
			if ( dSq > seekSq || dSq >= best ) continue;

			best = dSq;
			bestPos = s.Pos;
		}
		return bestPos;
	}

	/// <summary> 感知范围内最值得的散落孢子(v0.7.8.7 起不再无视;v0.7.8.32:按质量折算距离 + 目标粘性) </summary>
	CellPiece NearestBlob( Vector3 myPos )
	{
		var cells = CircleroyaleGame.Current?.Cells;
		if ( cells is null || cells.Count == 0 ) return null;

		CellPiece best = null;
		var bestScore = float.MaxValue;
		var seek = GameConfig.BotSeekFoodRange * RangeScale();
		var seekSq = seek * seek;

		// 粘性:还在追同一团(Id 未变)就打距离折扣
		var stickyId = _sinceStickyBlob < 3f ? _stickyBlobId : -1;

		foreach ( var c in cells )
		{
			if ( c.PieceKind != CellPiece.Kind.EjectedMass ) continue;

			var dx = c.Pos.x - myPos.x;
			var dy = c.Pos.y - myPos.y;
			var dSq = dx * dx + dy * dy;

			// 大孢子值得绕路:按质量折算等效距离(30 质量≈距离减半)
			dSq /= 1f + c.Mass / 30f;
			if ( c.Id == stickyId ) dSq *= 0.5f;

			if ( dSq > seekSq || dSq >= bestScore ) continue;

			bestScore = dSq;
			best = c;
		}
		return best;
	}

	/// <summary>
	/// 可追性检查(v0.7.8.95):猎物速度超过我方极速×余量且正在远离 → 永远追不上,
	/// 不进猎物池(旧版会傻追十几秒,观感极差)。速度公式与移动层同式(质量越大越慢)。
	/// 猎物朝我方向有速度分量(dot &gt; 0)视为"正在远离"判定——它自己撞过来另说。
	/// </summary>
	bool CanCatch( Vector3 preyPos, Vector3 preyVel, Vector3 myPos )
	{
		var preySpeed = preyVel.Length;
		if ( preySpeed < 1f ) return true;   // 基本没动:肯定追得上

		var mySpeed = SpeedOf( _self.Mass );
		if ( preySpeed <= mySpeed * GameConfig.BotCatchSpeedMargin ) return true;

		var toPrey = preyPos - myPos;
		var len = toPrey.Length;
		if ( len < 0.001f ) return true;
		return Vector3.Dot( toPrey / len, preyVel ) <= 0f;   // 在逃才放弃;迎面/横移照追
	}

	/// <summary> 该质量下的极速(与 Ball 移动层同式,追猎提前量计算早已在用) </summary>
	float SpeedOf( float mass ) => GameConfig.BaseSpeed
		* MathF.Pow( GameConfig.StartMass / MathF.Max( 1f, mass ), GameConfig.SpeedCurve );

	/// <summary>
	/// 停驻层的缓速盘旋(v0.7.8.103,"对峙卡住"的解药):绕锚点切向慢移(移动力 0.45),
	/// 每只 bot 固定旋向。此前喂旗/喂大哥/炸草三处停驻都是 Vector2.Zero 站桩——两只质量
	/// 相近的 bot(互相吃不动对方,谁也不触发谁的威胁/猎物判定)在同一锚点双双站桩
	/// = 玩家看到的"对峙谁都不动"。盘旋后保持喂食位置(孢子照常命中锚点)又永远有动态。
	/// </summary>
	Vector2 OrbitDir( Vector3 center )
	{
		var to = new Vector2( _self.WorldPosition.x - center.x, _self.WorldPosition.y - center.y );
		var len = to.Length;
		if ( len < 0.001f ) return new Vector2( _orbitSign, 0f ) * 0.45f;
		return new Vector2( -to.y, to.x ) / len * _orbitSign * 0.45f;
	}

	static Vector2 Norm( Vector3 v )
	{
		var v2 = new Vector2( v.x, v.y );
		var len = v2.Length;
		return len > 0.001f ? v2 / len : Vector2.Zero;
	}
}