Agenda/Agenda.cs
using System;

namespace PlanetMeat;

public sealed partial class Agenda : GameObjectSystem<Agenda>
{
	public const int MAX_SPAWN_DISTANCE = 2000;
	public const int ROUND_LENGTH = 3;

	private class EnemyGroup( float spawnAngle, float spawnSpread, int count, float delay )
	{
		public readonly int budget = count;
		public readonly float untilNext = delay;

		public float Spawn()
		{
			float spawnDistance = MAX_SPAWN_DISTANCE;
			var round = Current.RoundNum;
			if ( round < 3 )
				spawnDistance *= 0.75f + 0.25f * (round - 1) / 3.0f;

			var allSpecials = Current.PresentSpecials;
			foreach ( var v in allSpecials )
				v.BeginGroup();
			var groupSpecials = allSpecials.Where( v => v.InGroup );

			var groupFallback = Current.BaseVariant;
			var cost = 0;
			while ( cost < budget )
			{
				var v = groupSpecials.FirstOrDefault( s => s.InSpawn, groupFallback );
				v.SpawnWithGroup( Vector2.FromDegrees( spawnAngle + Game.Random.Float( spawnSpread ) ) * spawnDistance );
				cost += v.SpawnCost;
				foreach ( var other in groupSpecials.Where( s => s != v ) )
					other.OtherSpawned();
			}

			foreach ( var v in allSpecials )
				v.EndGroup();

			return untilNext;
		}
	}

	public enum Phases { Startup, Home, Wave, Upgrade, Failed, EndOfRound };
	public Phases Phase
	{
		get;
		private set
		{
			var prev = field;
			field = value;
			foreach ( var pl in Scene.GetAll<IAgendaPhaseListener>() )
				pl.OnTimelinePhase( prev, value );
		}
	} = Phases.Startup;

	private float NextGroupIn { get; set; } = 0.0f;

	public int RoundNum { get; set; } = 1;
	public int WaveNum { get; set; } = 1;

	public double MeatBounty { get; set; } = 0.0;
	public double MeatFailureLoss => Math.Ceiling( MeatBounty * (1.0f - AggregateMeatProtection.Total) );

	public double GetMeatBounty( int round ) => Math.Pow( 2, round - 1 ).MultiplyCapped( AggregateMeatRate.Total );
	public double CurrentMeatBounty => GetMeatBounty( RoundNum );
	public double NextMeatBounty => GetMeatBounty( RoundNum + 1 );

	public ValueAggregator<float> AggregateMeatProtection => field ??= ValueAggregator<float>.FromImprovement( "meat_protect" );
	public ValueAggregator<float> AggregateMeatRate => field ??= ValueAggregator<float>.FromImprovement( "meat_rate" );
	public ValueAggregator<int> AggregateMeatIncome => field ??= ValueAggregator<int>.FromImprovement( "meat_income" );

	public bool DeferWaveEndCheck { get; set; } = false;
	public int NumActiveTracked
	{
		get;
		set
		{
			var zeroed = value == 0 && field > 0;
			field = value;

			if ( DeferWaveEndCheck )
				DeferWaveEndCheck = false;
			else if ( zeroed && Phase == Phases.Wave && waitingGroups.Count == 0 )
				EndWave();
		}
	} = 0;
	private readonly Queue<EnemyGroup> waitingGroups = [];

	private EnemyVariant BaseVariant => field ??= new( "zomsauge", true );
	private Dictionary<string, EnemyVariant> VariantsById => field ??= new()
	{
		["zomsauge"] = BaseVariant,
		["morsel"] = new( "morsel" ) { SpawnSize = 3, WaveContributionPerLevel = 3 },
		["feast"] = new( "feast" ) { SpawnCost = 5 },
		["slinger"] = new( "slinger" ) { WaveContributionPerLevel = 2 },
		["burster"] = new( "burster" ) { SpawnCost = 2 },
	};
	private IEnumerable<EnemyVariant> SpecialVariants => field ??= VariantsById.Values.Where( v => v != BaseVariant );
	private IEnumerable<EnemyVariant> PresentSpecials => SpecialVariants.Where( v => v.IsPresent );
	private IEnumerable<EnemyVariant> PresentEnemyVariants => AllVariants.Where( v => v.IsPresent );
	public List<EnemyVariant> BakedEnemyVariants { get; } = [];
	public List<EnemyVariant> AllVariants
	{
		get
		{
			if ( field is null )
			{
				field = [.. VariantsById.Values];
				field.Sort( SortVariants );
			}
			return field;
		}
	}
	public IEnumerable<string> AllVariantIdents => AllVariants.Select( v => v.Ident );

	public EnemyVariant GetEnemyVariant( string ident ) => VariantsById.GetValueOrDefault( ident, null );

	public ScoutReport ScoutReport { get; private set; }
	public bool ShowScoutReport { get; private set; } = false;
	public int ScoutReportId { get; private set; } = 0;

	public List<Challenge> OfferedChallenges { get; } = [];

	public Agenda( Scene scene ) : base( scene )
	{
		if ( !Scene.IsEditor )
			Listen( Stage.StartUpdate, 0, OnUpdate, "AgendaStep" );
	}

	private void Reset()
	{
		Bank.Current.GetAccount( "goop" ).Clear();
		Difficulty.Current.ResetChallenges();
		RoundNum = 1;
		WaveNum = 1;
		MeatBounty = 0.0;
	}

	public static IEnumerable<int> GetGroupSizes( float debtStep, int groupCount, int groupSizeSmall )
	{
		var debt = debtStep;
		for ( var g = 0; g < groupCount; g++ )
		{
			var big = debt > 0.5f;
			debt += big ? -1.0f : debtStep;

			yield return big ? groupSizeSmall + 1 : groupSizeSmall;
		}
	}

	public void TryStartWave()
	{
		if ( Phase != Phases.Upgrade )
			return;
		Phase = Phases.Wave;

		foreach ( var t in Scene.GetAll<WaveTracked>() )
			t.FindNearestUpwards<Targetable>().Instakill();
		NumActiveTracked = 0;

		var groupDelay = ScoutReport.groupDelay;
		var groupInternalSpread = ScoutReport.groupSpread;
		var groupExternalSpread = MathF.Min( 180.0f, 20.0f * Difficulty.Current.GetMult( "GroupSpread" ) );
		var groupSpawnAngle = Game.Random.Float( 360.0f );

		foreach ( var size in GetGroupSizes( ScoutReport.groupSizeDebt, ScoutReport.groupCount, ScoutReport.groupSizeMin ) )
		{
			waitingGroups.Enqueue( new(
				groupSpawnAngle,
				groupInternalSpread,
				size,
				groupDelay
			) );
			groupSpawnAngle += Game.Random.Float( -groupExternalSpread, groupExternalSpread );
		}
		NextGroupIn = 0.25f;

		var numGroups = waitingGroups.Count;
		foreach ( var v in SpecialVariants )
			v.BeginWave( numGroups );

		PlayerTool.BakeTools( Scene.GetAllComponents<PlayerTool>() );
		foreach ( var tool in PlayerTool.Toolbox )
			tool.EndCooldown();
	}

	private void EndWave()
	{
		Career.Current.IncrementStat( "wave" );
		if ( WaveNum == ROUND_LENGTH )
		{
			EndRound();
		}
		else
		{
			WaveNum++;
			StartUpgrade();
		}
	}

	private int SortVariants( EnemyVariant a, EnemyVariant b )
	{
		if ( a == BaseVariant )
			return -1;
		if ( b == BaseVariant )
			return 1;
		return Game.Language.GetPhrase( a.DisplayName ).CompareTo( Game.Language.GetPhrase( b.DisplayName ) );
	}

	private void StartUpgrade()
	{
		foreach ( var v in SpecialVariants )
			v.BakeStats();

		BakedEnemyVariants.Clear();
		BakedEnemyVariants.AddRange( PresentEnemyVariants );
		BakedEnemyVariants.Sort( SortVariants );

		MeatBounty = MeatBounty.AddCapped( AggregateMeatIncome.Total );

		ScoutReport = new( PresentEnemyVariants );
		ScoutReportId++;
		Catalogue.Current.BakeUpgrades( Scene.GetAllComponents<BaseUpgrade>() );

		Catalogue.Current.CurrentTab = "upgrades";
		Phase = Phases.Upgrade;
	}

	private void EndRound()
	{
		MeatBounty += CurrentMeatBounty;
		Career.Current.IncrementStat( "round" );
		Career.Current.HighestRoundCompleted = RoundNum;
		if ( RoundNum >= 1 )
		{
			Sandbox.Services.Achievements.Unlock( "round_1" );
			if ( RoundNum >= 11 )
			{
				Sandbox.Services.Achievements.Unlock( "round_11" );
				if ( RoundNum >= 111 )
				{
					Sandbox.Services.Achievements.Unlock( "round_111" );
				}
			}
		}

		Research.Current.AdvanceResearchProgress();
		Phase = Phases.EndOfRound;
	}

	public void DecideEndOfRound( bool nextRound )
	{
		if ( nextRound )
		{
			OfferedChallenges.Clear();
			OfferedChallenges.AddRange( Difficulty.Current.CreateOffers( RoundNum ) );
			if ( OfferedChallenges.Count == 0 )
				ChooseOfferedChallenge( null );
		}
		else
		{
			RelocateToHq();
		}
	}

	public void RelocateToHq()
	{
		if ( Phase != Phases.Failed )
			Career.Current.IncrementStat( "runs_banked" );

		var meatGained = MeatBounty;
		Career.Current.IncrementStat( "meat_banked", meatGained );
		Bank.Current.GetAccount( "meat" ).Profit( meatGained );

		Career.Current.CommitRunStats();

		Research.Current.BakeCatalogue();
		Catalogue.Current.CloseTab();
		Scene.LoadFromFile( "scenes/hq.scene" );
		Reset();
		Phase = Phases.Home;
	}

	public void RelocateToArena( int startRound = 1 )
	{
		if ( startRound >= 3 )
			Sandbox.Services.Achievements.Unlock( "skip_3" );

		Research.Current.IgnoreCompletedResearch();
		Research.Current.BakeEffects();
		Catalogue.Current.CloseTab();
		Scene.LoadFromFile( "scenes/arena.scene" );
		Reset();
		RoundNum = Math.Max( 1, startRound );
		ShowScoutReport = Research.Current.HasTech( "scout" );
		StartUpgrade();
	}

	public void OnDefenseFailed()
	{
		if ( Phase != Phases.Failed )
		{
			Phase = Phases.Failed;
			foreach ( var t in Scene.GetAll<Targetable>() )
				t.Instakill();

			var loss = MeatFailureLoss;
			MeatBounty -= loss;

			Career.Current.IncrementStat( "meat_lost", loss );
			Career.Current.IncrementStat( "runs_lost" );
		}
	}

	public void ChooseOfferedChallenge( Challenge challenge )
	{
		if ( challenge is not null )
			Difficulty.Current.AddChallenge( challenge );
		OfferedChallenges.Clear();

		Career.Current.CommitRoundStats();
		RoundNum++;
		WaveNum = 1;
		StartUpgrade();
	}

	private void OnUpdate()
	{
		if ( Phase == Phases.Wave )
		{
			var dt = Time.Delta;

			if ( NextGroupIn > 0.0f )
			{
				NextGroupIn -= dt;
				if ( NextGroupIn <= 0.01f )
				{
					var delay = NextGroupIn;
					while ( delay <= 0.01f && waitingGroups.Count > 0 )
						delay += waitingGroups.Dequeue().Spawn();

					if ( waitingGroups.Count > 0 )
						NextGroupIn = delay;
				}
			}
		}
	}
}