Arena/FightAI.cs
using System;

/// <summary>
/// Scores every move it could make and picks the best, with a bit of noise so it isn't predictable.
/// </summary>
public static class FightAI
{
	static readonly FightAction[] All = Enum.GetValues<FightAction>();

	public static FightAction Choose( ArenaFight fight, Gladiator self )
	{
		var opp = fight.OpponentOf( self );
		var best = FightAction.Rest;
		float bestScore = float.MinValue;

		foreach ( var a in All )
		{
			if ( !fight.CanUse( self, a ) ) continue;

			float score = Score( fight, self, opp, a ) * Random.Shared.Float( 0.85f, 1.15f );
			if ( score > bestScore )
			{
				bestScore = score;
				best = a;
			}
		}

		return best;
	}

	static float AvgHit( ArenaFight fight, Gladiator self, Gladiator opp, FightAction a )
	{
		var (min, max) = ArenaFight.DamageRange( self, opp, a );
		float avg = (min + max) * 0.5f;
		if ( !fight.IsDuel && self.Enchant != Enchant.None )
		{
			var (emin, emax) = Catalog.EnchantRange( self.EnchantTier );
			avg += (emin + emax) * 0.5f;
		}
		return avg;
	}

	/// <summary>How much more punishment they can take (duel: armour only).</summary>
	static float Life( ArenaFight fight, Gladiator g ) => fight.IsDuel ? Math.Max( 1, g.ArmourPoints ) : g.Health + g.ArmourPoints;

	static float Score( ArenaFight fight, Gladiator self, Gladiator opp, FightAction a )
	{
		int energyAfter = Math.Max( 0, self.Energy - ArenaFight.Cost( a ) );
		float energyFrac = self.Energy / (float)self.MaxEnergy;
		float oppLife = Life( fight, opp );
		float myFrac = fight.IsDuel ? (self.MaxArmour > 0 ? self.ArmourPoints / (float)self.MaxArmour : 0f) : self.Health / (float)self.MaxHealth;
		bool oppCanHitNext = fight.Distance <= 2;
		float score = 0f;

		if ( ArenaFight.IsAttack( a ) )
		{
			float chance = fight.HitChance( self, opp, a );
			float avg = AvgHit( fight, self, opp, a );
			score = chance * avg;

			// finishing blow: prefer whatever is most likely to land it
			if ( avg >= oppLife ) score += chance * 60f;
		}
		else if ( ArenaFight.IsSpell( a ) )
		{
			var s = ArenaFight.SpellOf( a );
			var (min, max) = ArenaFight.SpellRange( self, s );
			float avg = (min + max) * 0.5f;
			score = s switch
			{
				Spell.Fireball or Spell.Lightning => avg * 0.95f + (avg >= oppLife ? 60f : 0f),
				Spell.Drain => avg * 1.3f + (avg >= oppLife ? 50f : 0f),
				Spell.Heal => myFrac < 0.45f ? avg * 1.4f : 0.5f,
				Spell.Freeze => oppCanHitNext ? 14f * fight.FreezeChance( self, opp ) : 4f,
				Spell.Tremor => Math.Min( opp.ArmourPoints, avg * 2 ) * (fight.IsDuel ? 1f : 0.5f),
				_ => 0f
			};
		}
		else if ( ArenaFight.IsPotion( a ) )
		{
			score = ArenaFight.PotionOf( a ) switch
			{
				Potion.Health => myFrac < 0.3f ? 30f : 0f,
				Potion.Energy => energyFrac < 0.2f ? 20f : 0f,
				_ => self.Magic < 15 && self.SpellBits != 0 ? 10f : 0f
			};
		}
		else
		{
			switch ( a )
			{
				case FightAction.MoveForward:
					score = fight.InRange ? 0 : 6f + fight.Distance;
					if ( energyFrac < 0.3f && fight.Distance > 2 ) score *= 0.4f;
					break;

				case FightAction.Leap:
					score = fight.Distance >= 4 && energyAfter > self.MaxEnergy * 0.4f ? 10f + fight.Distance : 2f;
					break;

				case FightAction.MoveBack:
					score = myFrac < 0.25f && energyFrac < 0.3f ? 7f : 0.5f;
					break;

				case FightAction.Rest:
					float missing = 1f - energyFrac;
					score = missing * (fight.InRange ? 10f : 22f);
					if ( energyFrac < 0.25f ) score += 12f;
					break;

				case FightAction.Taunt:
					{
						float chance = fight.TauntChance( self, opp );
						int drain = 6 + self.Charisma * 2;
						score = chance * (drain * 0.35f + self.Charisma * 0.3f);
						// draining them to zero gives a free turn
						if ( drain >= opp.Energy ) score += chance * 18f;
						break;
					}

				case FightAction.Push:
					// shove them away when tired so there's room to rest
					score = fight.PushRoom( opp ) && energyFrac < 0.35f ? 14f * fight.PushChance( self ) : 1f;
					break;
			}
		}

		// running dry while the enemy can reach you hands them a free hit
		if ( energyAfter <= 0 && oppCanHitNext && a != FightAction.Rest && a != FightAction.DrinkEnergy )
			score *= ArenaFight.IsAttack( a ) && AvgHit( fight, self, opp, a ) >= oppLife ? 0.9f : 0.3f;
		else if ( energyAfter < 15 && oppCanHitNext )
			score *= 0.8f;

		return score;
	}
}