Arena/ArenaFight.cs
using System;

/// <summary>Intro = the VS screen before the first turn.</summary>
public enum FightState { Waiting, Intro, Fighting, Over }

/// <summary>Duel: slave fights in the dungeon, only armour gets hurt and whoever's armour breaks loses.
/// Arena: the real thing, health and armour, to the death.</summary>
public enum FightMode { Duel, Arena }

public enum FightAction
{
	MoveBack, MoveForward, Leap, Rest, QuickAttack, NormalAttack, PowerAttack, Taunt, Push,
	CastFireball, CastLightning, CastFreeze, CastHeal, CastDrain, CastTremor,
	DrinkHealth, DrinkEnergy, DrinkMagicka
}

public sealed class ArenaFight : Component
{
	[Property] public float StepWidth { get; set; } = 250f;
	[Property] public int ArenaSteps { get; set; } = 10;
	[Property] public float TurnDelay { get; set; } = 1.1f;

	[Sync( SyncFlags.FromHost )] public Gladiator Left { get; set; }
	[Sync( SyncFlags.FromHost )] public Gladiator Right { get; set; }
	[Sync( SyncFlags.FromHost )] public FightState State { get; set; }
	[Sync( SyncFlags.FromHost )] public FightMode Mode { get; set; }
	[Sync( SyncFlags.FromHost )] public bool LeftTurn { get; set; }
	[Sync( SyncFlags.FromHost )] public bool Busy { get; set; }
	[Sync( SyncFlags.FromHost )] public TimeUntil NextTurnAt { get; set; }
	[Sync( SyncFlags.FromHost )] public string LastMessage { get; set; }
	[Sync( SyncFlags.FromHost )] public Gladiator Winner { get; set; }
	[Sync( SyncFlags.FromHost )] public int Crowd { get; set; }
	/// <summary>Turns taken so far (a long duel costs less gold when you lose it).</summary>
	[Sync( SyncFlags.FromHost )] public int Turns { get; set; }

	public bool IsDuel => Mode == FightMode.Duel;
	public Gladiator Current => LeftTurn ? Left : Right;
	public int Distance => Left.IsValid() && Right.IsValid() ? Math.Abs( Left.Step - Right.Step ) : 0;
	public bool InRange => Distance <= 1;
	public bool WaitingForInput => State == FightState.Fighting && !Busy && NextTurnAt;

	public string CrowdMood => Crowd switch
	{
		< 15 => "bored to tears",
		< 35 => "restless",
		< 60 => "watching closely",
		< 85 => "cheering",
		_ => "going wild"
	};

	public Gladiator OpponentOf( Gladiator g ) => g == Left ? Right : Left;
	public float StepToX( int step ) => (step - ArenaSteps / 2f) * StepWidth;
	public bool IsTurnOf( Gladiator g ) => g.IsValid() && g == Current && WaitingForInput;
	public int ForwardDir( Gladiator g ) => g == Left ? 1 : -1;

	// ---------------- setup ----------------

	public void Clear()
	{
		Left?.GameObject.Destroy();
		Right?.GameObject.Destroy();
		Left = null;
		Right = null;
		State = FightState.Waiting;
	}

	public void StartFight( Profile player, FightMode mode )
	{
		if ( !Networking.IsHost ) return;

		Clear();
		Mode = mode;

		Left = Spawn( player.Name, false, Connection.Local.Id, mode == FightMode.Duel ? 3 : 2 );
		Left.Level = player.Level;
		Left.Title = player.Enslaved ? "Slave" : $"Level {player.Level} gladiator";
		Left.Strength = player.Strength; Left.Attack = player.Attack; Left.Defence = player.Defence; Left.Vitality = player.Vitality;
		Left.Agility = player.Agility; Left.Stamina = player.Stamina; Left.Charisma = player.Charisma; Left.Magicka = player.Magicka;
		Left.SetLook( player.Look );
		var ench = player.EnchantFor( player.Weapon );
		Left.Enchant = ench?.Type ?? Enchant.None;
		Left.EnchantTier = ench?.Tier ?? 0;
		Left.SpellBits = player.Spells.Aggregate( 0, ( bits, s ) => bits | (1 << s) );
		Left.HealthPotions = player.Potions[0];
		Left.EnergyPotions = player.Potions[1];
		Left.MagicPotions = player.Potions[2];
		Left.ResetVitals();

		Right = Spawn( "", true, Guid.Empty, mode == FightMode.Duel ? ArenaSteps - 3 : ArenaSteps - 2 );
		EnemyMaker.Build( Right, player, mode );
		Right.ResetVitals();

		Left.RollPower();
		Right.RollPower();

		LeftTurn = Left.Agility >= Right.Agility;
		Winner = null;
		Busy = false;
		Turns = 0;
		Crowd = 15;
		LastMessage = $"{Left.DisplayName} vs {Right.DisplayName}";
		State = FightState.Intro;
	}

	/// <summary>The tick on the VS screen.</summary>
	[Rpc.Host]
	public void BeginFight()
	{
		if ( State != FightState.Intro ) return;
		NextTurnAt = 0.8f;
		LastMessage = IsDuel ? "Break their armour to win the duel!" : $"{Left.DisplayName} vs {Right.DisplayName}. Fight!";
		State = FightState.Fighting;
	}

	Gladiator Spawn( string name, bool ai, Guid controller, int step )
	{
		var go = new GameObject( true, string.IsNullOrEmpty( name ) ? "Enemy" : name );
		var g = go.AddComponent<Gladiator>();
		g.DisplayName = name;
		g.IsAI = ai;
		g.ControllerId = controller;
		g.Step = step;
		return g;
	}

	// ---------------- turn loop ----------------

	protected override void OnUpdate()
	{
		if ( !Networking.IsHost || State != FightState.Fighting || !NextTurnAt )
			return;

		if ( Busy )
		{
			FinishTurn();
			return;
		}

		if ( Current.Frozen > 0 )
		{
			Current.Frozen--;
			Current.ShowPopup( "FROZEN", PopupKind.Text, "#7fd0ff" );
			LastMessage = $"{Current.DisplayName} is frozen solid and can't move!";
			Busy = true;
			NextTurnAt = TurnDelay;
			return;
		}

		if ( Current.Energy <= 0 )
		{
			Exhausted( Current );
			return;
		}

		if ( Current.IsAI )
			Perform( Current, FightAI.Choose( this, Current ) );
	}

	[Rpc.Host]
	public void RequestAction( FightAction action )
	{
		var g = Current;
		if ( !WaitingForInput || !g.IsValid() || g.IsAI || g.Energy <= 0 || g.ControllerId != Rpc.CallerId ) return;
		if ( !CanUse( g, action ) ) return;

		Perform( g, action );
	}

	// ---------------- rules ----------------

	public static int Cost( FightAction a ) => a switch
	{
		FightAction.QuickAttack => 8,
		FightAction.NormalAttack => 14,
		FightAction.PowerAttack => 24,
		FightAction.Leap => 18,
		FightAction.Taunt => 10,
		FightAction.Push => 8,
		_ => 0
	};

	public static bool IsAttack( FightAction a ) => a is FightAction.QuickAttack or FightAction.NormalAttack or FightAction.PowerAttack;
	public static bool IsSpell( FightAction a ) => a >= FightAction.CastFireball && a <= FightAction.CastTremor;
	public static bool IsPotion( FightAction a ) => a >= FightAction.DrinkHealth;
	public static Spell SpellOf( FightAction a ) => (Spell)(a - FightAction.CastFireball);
	public static FightAction CastOf( Spell s ) => FightAction.CastFireball + (int)s;
	public static Potion PotionOf( FightAction a ) => (Potion)(a - FightAction.DrinkHealth);

	/// <summary>Doing something you can't afford still works, it just works worse.</summary>
	public static float EnergyFactor( Gladiator g, FightAction a )
	{
		int cost = Cost( a );
		if ( cost <= 0 || g.Energy >= cost ) return 1f;
		return 0.35f + 0.65f * g.Energy / cost;
	}

	public bool PushRoom( Gladiator target )
	{
		int next = target.Step + ForwardDir( OpponentOf( target ) );
		return next >= 0 && next <= ArenaSteps;
	}

	public bool CanUse( Gladiator g, FightAction a )
	{
		if ( !g.IsValid() ) return false;

		if ( IsSpell( a ) )
		{
			var s = SpellOf( a );
			return g.Knows( s ) && g.Magic >= Catalog.SpellInfo( s ).Cost;
		}

		if ( IsPotion( a ) ) return g.Potions( PotionOf( a ) ) > 0;

		return a switch
		{
			FightAction.MoveForward => Distance > 1,
			FightAction.Leap => Distance > 2 && g.Energy >= Cost( a ),
			FightAction.MoveBack => g.Step - ForwardDir( g ) >= 0 && g.Step - ForwardDir( g ) <= ArenaSteps,
			FightAction.QuickAttack or FightAction.NormalAttack or FightAction.PowerAttack => InRange,
			FightAction.Push => InRange,
			_ => true
		};
	}

	public float HitChance( Gladiator attacker, Gladiator defender, FightAction a )
	{
		float chance = a switch
		{
			FightAction.QuickAttack => 0.9f,
			FightAction.NormalAttack => 0.5f,
			FightAction.PowerAttack => attacker.PowerRoll,
			_ => 0f
		};

		chance += (attacker.Attack - defender.Agility) * 0.02f;
		chance *= EnergyFactor( attacker, a );
		return Math.Clamp( chance, 0.05f, 0.98f );
	}

	public float TauntChance( Gladiator taunter, Gladiator target )
	{
		float c = 0.5f + (taunter.Charisma - target.Charisma) * 0.05f;
		return Math.Clamp( c * EnergyFactor( taunter, FightAction.Taunt ), 0.05f, 0.92f );
	}

	public float PushChance( Gladiator pusher ) => EnergyFactor( pusher, FightAction.Push );

	public float FreezeChance( Gladiator caster, Gladiator target ) => Math.Clamp( 0.7f + (caster.Magicka - target.Magicka) * 0.03f, 0.25f, 0.95f );

	public static float DamageMult( FightAction a ) => a switch
	{
		FightAction.QuickAttack => 0.6f,
		FightAction.PowerAttack => 2f,
		_ => 1f
	};

	public static (int Min, int Max) DamageRange( Gladiator attacker, Gladiator defender, FightAction a )
	{
		var w = attacker.Weapon;
		float mult = DamageMult( a ) * 100f / (100f + defender.Defence * 3f);
		int min = Math.Max( 1, (int)((w.Min + attacker.Strength * 2) * mult) );
		int max = Math.Max( 1, (int)((w.Max + attacker.Strength * 2) * mult) );
		return (min, max);
	}

	/// <summary>What a spell does, for its power (damage, healing, armour smashed).</summary>
	public static (int Min, int Max) SpellRange( Gladiator caster, Spell s ) => s switch
	{
		Spell.Fireball => (4 + caster.Magicka * 2, 8 + caster.Magicka * 3),
		Spell.Lightning => (10 + caster.Magicka * 3, 18 + caster.Magicka * 5),
		Spell.Heal => (10 + caster.Magicka * 3, 14 + caster.Magicka * 4),
		Spell.Drain => (3 + caster.Magicka * 2, 6 + caster.Magicka * 2),
		Spell.Tremor => (8 + caster.Magicka * 2, 12 + caster.Magicka * 3),
		_ => (0, 0)
	};

	void AddCrowd( int amount ) => Crowd = Math.Clamp( Crowd + amount, 0, 100 );

	/// <summary>
	/// Arena: armour soaks damage first, then health takes the rest.
	/// Duel: only armour gets hurt; once it's gone (or if there was none) that's the end of the duel.
	/// </summary>
	void ApplyDamage( Gladiator target, int dmg )
	{
		if ( IsDuel )
		{
			target.ArmourPoints = Math.Max( 0, target.ArmourPoints - dmg );
			if ( target.ArmourPoints <= 0 ) target.Broken = true;
			return;
		}

		int soaked = Math.Min( target.ArmourPoints, dmg );
		target.ArmourPoints -= soaked;
		target.Health = Math.Max( 0, target.Health - (dmg - soaked) );
	}

	/// <summary>Magic goes past armour in the arena; in a duel it still only breaks armour.</summary>
	void ApplyMagic( Gladiator target, int dmg )
	{
		if ( IsDuel ) ApplyDamage( target, dmg );
		else target.Health = Math.Max( 0, target.Health - dmg );
	}

	/// <summary>Healing: health in the arena, armour in a duel.</summary>
	int Mend( Gladiator g, int amount )
	{
		if ( IsDuel )
		{
			int got = Math.Min( amount, g.MaxArmour - g.ArmourPoints );
			g.ArmourPoints += got;
			return got;
		}
		int heal = Math.Min( amount, g.MaxHealth - g.Health );
		g.Health += heal;
		return heal;
	}

	void Perform( Gladiator g, FightAction a )
	{
		var opp = OpponentOf( g );
		g.Energy = Math.Max( 0, g.Energy - Cost( a ) );
		Turns++;

		switch ( a )
		{
			case FightAction.MoveForward:
				{
					int steps = Math.Min( 1 + g.Agility / 6, Distance - 1 );
					g.Step += ForwardDir( g ) * steps;
					LastMessage = $"{g.DisplayName} walks forward.";
					AddCrowd( -1 );
					break;
				}

			case FightAction.MoveBack:
				{
					int steps = 1 + g.Agility / 8;
					g.Step = Math.Clamp( g.Step - ForwardDir( g ) * steps, 0, ArenaSteps );
					LastMessage = $"{g.DisplayName} walks backward.";
					AddCrowd( -3 );
					break;
				}

			case FightAction.Leap:
				{
					int steps = Math.Min( 3 + g.Agility / 5, Distance - 1 );
					g.Step += ForwardDir( g ) * steps;
					g.PlayLeap();
					g.SetMood( Mood.Grin, 1.5f );
					LastMessage = $"{g.DisplayName} leaps through the air!";
					AddCrowd( 7 );
					break;
				}

			case FightAction.Rest:
				{
					int energy = Math.Min( g.MaxEnergy - g.Energy, 12 + g.Stamina * 3 );
					g.Energy += energy;
					int health = 0;
					if ( !IsDuel )
					{
						health = Math.Min( g.MaxHealth - g.Health, g.Vitality );
						g.Health += health;
					}
					g.PlayAnim( "rest" );
					g.ShowPopup( $"+{energy} energy", PopupKind.Gain, null );
					LastMessage = $"{g.DisplayName} catches their breath.";
					AddCrowd( -5 );
					break;
				}

			case FightAction.QuickAttack:
			case FightAction.NormalAttack:
			case FightAction.PowerAttack:
				Swing( g, opp, a );
				break;

			case FightAction.Taunt:
				{
					g.PlayAnim( "taunt" );
					if ( Random.Shared.Float() <= TauntChance( g, opp ) )
					{
						int drain = 6 + g.Charisma * 2;
						opp.Energy = Math.Max( 0, opp.Energy - drain );
						if ( !IsDuel ) opp.Health = Math.Max( 0, opp.Health - Math.Max( 1, g.Charisma / 2 ) );
						opp.ShowPopup( $"-{drain} energy", PopupKind.Text, null );
						opp.SetMood( Mood.Upset, 2f );
						g.SetMood( Mood.Grin, 2f );
						LastMessage = $"{g.DisplayName}'s taunt gets to {opp.DisplayName}!";
						AddCrowd( 10 + g.Charisma / 3 );
					}
					else
					{
						g.ShowPopup( "IGNORED", PopupKind.Miss, null );
						g.SetMood( Mood.Upset, 1.5f );
						opp.SetMood( Mood.Happy, 1.5f );
						LastMessage = $"{opp.DisplayName} ignores {g.DisplayName}'s taunt.";
						AddCrowd( -2 );
					}
					break;
				}

			case FightAction.Push:
				{
					g.PlayAnim( "push" );
					if ( PushRoom( opp ) && Random.Shared.Float() <= PushChance( g ) )
					{
						opp.Step += ForwardDir( g );
						opp.PlayAnim( "hit" );
						opp.ShowPopup( "PUSHED", PopupKind.Text, null );
						g.SetMood( Mood.Grin, 1.2f );
						LastMessage = $"{g.DisplayName} shoves {opp.DisplayName} back.";
						AddCrowd( 3 );
					}
					else
					{
						opp.ShowPopup( "HOLDS", PopupKind.Miss, null );
						g.SetMood( Mood.Upset, 1.2f );
						LastMessage = PushRoom( opp ) ? $"{opp.DisplayName} stands firm." : $"{opp.DisplayName} has nowhere to go!";
					}
					break;
				}

			case >= FightAction.CastFireball and <= FightAction.CastTremor:
				Cast( g, opp, SpellOf( a ) );
				break;

			default:
				Drink( g, PotionOf( a ) );
				break;
		}

		if ( opp.IsDead )
		{
			g.SetMood( Mood.Grin, 30f );
			AddCrowd( 20 );
		}

		Busy = true;
		NextTurnAt = a == FightAction.Leap ? MathF.Max( TurnDelay, Gladiator.LeapTime + 0.4f ) : a == FightAction.Rest ? 1.4f : TurnDelay;

		// the free swing your opponent gets is the point of running out of energy
		if ( g.Energy <= 0 && !opp.IsDead )
			LastMessage += $" {g.DisplayName} is out of energy!";
	}

	void Swing( Gladiator g, Gladiator opp, FightAction a )
	{
		g.PlayAnim( a == FightAction.QuickAttack ? "quick" : a == FightAction.NormalAttack ? "normal" : "power" );

		if ( Random.Shared.Float() > HitChance( g, opp, a ) )
		{
			opp.ShowPopup( "MISS", PopupKind.Miss, null );
			opp.SetMood( Mood.Happy, 1.5f );
			g.SetMood( Mood.Upset, 1.8f );
			LastMessage = $"{g.DisplayName} misses!";
			AddCrowd( -3 );
			return;
		}

		var (min, max) = DamageRange( g, opp, a );
		int dmg = Random.Shared.Int( min, max );
		bool hadArmour = opp.ArmourPoints > 0;
		ApplyDamage( opp, dmg );
		opp.PlayAnim( "hit" );
		opp.ShowPopup( dmg.ToString(), PopupKind.Damage, null );
		opp.SetMood( Mood.Hurt, 1.2f );
		g.SetMood( a == FightAction.PowerAttack ? Mood.Grin : Mood.Happy, 1.8f );

		string msg = IsDuel ? $"{g.DisplayName} hits {opp.DisplayName}'s armour for {dmg}" : $"{g.DisplayName} hits {opp.DisplayName} for {dmg}";

		if ( !IsDuel && g.Enchant != Enchant.None && g.EnchantTier > 0 )
		{
			var (emin, emax) = Catalog.EnchantRange( g.EnchantTier );
			int extra = Random.Shared.Int( emin, emax );
			opp.Health = Math.Max( 0, opp.Health - extra );
			opp.ShowPopup( $"+{extra} {g.Enchant.ToString().ToLower()}", PopupKind.Enchant, Catalog.EnchantColour( g.Enchant ) );
			msg += $" (+{extra} {g.Enchant.ToString().ToLower()})";
		}

		if ( IsDuel && opp.Broken )
			msg = hadArmour ? $"{g.DisplayName} smashes {opp.DisplayName}'s armour to pieces" : $"{g.DisplayName} lands a clean hit on the unarmoured {opp.DisplayName}";

		LastMessage = msg + "!";
		AddCrowd( a == FightAction.PowerAttack ? 12 : a == FightAction.NormalAttack ? 6 : 3 );
	}

	void Cast( Gladiator g, Gladiator opp, Spell s )
	{
		var info = Catalog.SpellInfo( s );
		g.Magic = Math.Max( 0, g.Magic - info.Cost );
		g.PlayAnim( "cast" );
		var (min, max) = SpellRange( g, s );
		int amount = Random.Shared.Int( min, max );

		switch ( s )
		{
			case Spell.Fireball:
			case Spell.Lightning:
				ApplyMagic( opp, amount );
				opp.PlayAnim( "hit" );
				opp.ShowPopup( amount.ToString(), PopupKind.Damage, null );
				opp.SetMood( Mood.Hurt, 1.5f );
				g.SetMood( Mood.Grin, 1.8f );
				LastMessage = $"{g.DisplayName} casts {info.Name}! {amount} damage!";
				AddCrowd( s == Spell.Lightning ? 15 : 8 );
				break;

			case Spell.Freeze:
				if ( Random.Shared.Float() <= FreezeChance( g, opp ) )
				{
					opp.Frozen = 1;
					opp.ShowPopup( "FROZEN", PopupKind.Text, "#7fd0ff" );
					opp.SetMood( Mood.Upset, 2f );
					LastMessage = $"{g.DisplayName} freezes {opp.DisplayName} solid!";
					AddCrowd( 8 );
				}
				else
				{
					opp.ShowPopup( "RESISTED", PopupKind.Miss, null );
					LastMessage = $"{opp.DisplayName} shakes off the freeze spell.";
				}
				break;

			case Spell.Heal:
				{
					int got = Mend( g, amount );
					g.ShowPopup( IsDuel ? $"+{got} armour" : $"+{got} health", PopupKind.Gain, "#7fff7f" );
					g.SetMood( Mood.Happy, 1.5f );
					LastMessage = IsDuel ? $"{g.DisplayName} magically mends their armour." : $"{g.DisplayName} heals their wounds.";
					AddCrowd( -2 );
					break;
				}

			case Spell.Drain:
				{
					ApplyMagic( opp, amount );
					int got = Mend( g, amount );
					opp.PlayAnim( "hit" );
					opp.ShowPopup( amount.ToString(), PopupKind.Damage, null );
					g.ShowPopup( $"+{got}", PopupKind.Gain, "#7fff7f" );
					LastMessage = $"{g.DisplayName} drains the life out of {opp.DisplayName}!";
					AddCrowd( 8 );
					break;
				}

			case Spell.Tremor:
				{
					// smashes armour; in the arena whatever's left over hits health
					int smashed = Math.Min( opp.ArmourPoints, amount * 2 );
					if ( IsDuel ) ApplyDamage( opp, amount * 2 );
					else opp.ArmourPoints -= smashed;
					opp.PlayAnim( "hit" );
					opp.ShowPopup( $"-{amount * 2} armour", PopupKind.Text, "#e8f060" );
					LastMessage = $"The ground shakes! {opp.DisplayName}'s armour cracks!";
					AddCrowd( 10 );
					break;
				}
		}
	}

	void Drink( Gladiator g, Potion p )
	{
		g.UsePotion( p );
		g.PlayAnim( "rest" );
		switch ( p )
		{
			case Potion.Health:
				{
					int got = Mend( g, IsDuel ? (int)(g.MaxArmour * 0.35f) : (int)(g.MaxHealth * 0.35f) );
					g.ShowPopup( $"+{got}", PopupKind.Gain, "#ff6a6a" );
					break;
				}
			case Potion.Energy:
				{
					int got = Math.Min( 40, g.MaxEnergy - g.Energy );
					g.Energy += got;
					g.ShowPopup( $"+{got} energy", PopupKind.Gain, null );
					break;
				}
			default:
				{
					int got = Math.Min( 30, g.MaxMagic - g.Magic );
					g.Magic += got;
					g.ShowPopup( $"+{got} magicka", PopupKind.Gain, "#b07fff" );
					break;
				}
		}
		LastMessage = $"{g.DisplayName} drinks a {Catalog.PotionName( p ).ToLower()}.";
	}

	void Exhausted( Gladiator g )
	{
		int energy = Math.Min( g.MaxEnergy, 12 + g.Stamina * 3 );
		g.Energy = energy;
		g.PlayAnim( "rest" );
		g.ShowPopup( "EXHAUSTED", PopupKind.Text, null );
		g.SetMood( Mood.Upset, 2f );
		OpponentOf( g ).SetMood( Mood.Grin, 2f );
		LastMessage = $"{g.DisplayName} is exhausted and must rest!";
		AddCrowd( -5 );

		Busy = true;
		NextTurnAt = 1.4f;
	}

	void FinishTurn()
	{
		Busy = false;

		var loser = Left.IsDead ? Left : Right.IsDead ? Right : null;
		if ( loser is not null )
		{
			Winner = OpponentOf( loser );
			State = FightState.Over;
			LastMessage = IsDuel ? $"{Winner.DisplayName} wins the duel!" : $"{Winner.DisplayName} is victorious!";
			return;
		}

		LeftTurn = !LeftTurn;
		Current.RollPower();
	}
}