Game/DesertPumpGame.cs
namespace DesertPump;

/// <summary>
/// The whole game. Holds the money, the water and the current pump, and is the only
/// thing allowed to change them - the HUD just calls into here and renders the result.
/// </summary>
[Title( "Desert Pump Game" )]
[Category( "Desert Pump" )]
[Icon( "water_drop" )]
public sealed partial class DesertPumpGame : Component
{
	/// <summary>The active game, so the HUD doesn't need a wired-up reference.</summary>
	public static DesertPumpGame Current { get; private set; }

	[Property, Group( "Save" )]
	public bool SaveEnabled { get; set; } = true;

	[Property, Group( "Save" )]
	public string SaveFile { get; set; } = "desertpump.json";

	/// <summary>Seconds between autosaves. Progress also saves on shutdown.</summary>
	[Property, Group( "Save" ), Range( 2f, 60f )]
	public float SaveInterval { get; set; } = 5f;

	/// <summary>Longest stretch of time away that still pays out idle water.</summary>
	[Property, Group( "Save" ), Range( 0f, 24f )]
	public float MaxIdleHours { get; set; } = 4f;

	[Property, Group( "Audio" )] public SoundEvent PumpSound { get; set; }
	[Property, Group( "Audio" )] public SoundEvent SellSound { get; set; }
	[Property, Group( "Audio" )] public SoundEvent UpgradeSound { get; set; }
	[Property, Group( "Audio" )] public SoundEvent DeniedSound { get; set; }

	/// <summary>Played on a surge. Falls back to the upgrade sting when it's left empty.</summary>
	[Property, Group( "Audio" )] public SoundEvent SurgeSound { get; set; }

	public double Coins { get; private set; }
	public double Water { get; private set; }

	/// <summary>Index into <see cref="PumpModel.All"/>.</summary>
	public int PumpLevel { get; private set; }

	/// <summary>Shop upgrade levels, indexed by <see cref="UpgradeKind"/>.</summary>
	readonly int[] upgradeLevels = new int[3];

	public bool Muted => PumpSettings.Current.Muted;

	public long TotalPumps { get; private set; }
	public double TotalEarned { get; private set; }
	public double TotalLitresSold { get; private set; }

	/// <summary>Litres the pump produced while the game was closed, for the welcome-back note.</summary>
	public double IdleCatchUp { get; private set; }

	public PumpModel CurrentPump => PumpModel.Get( PumpLevel );
	public PumpTier Tier => CurrentPump.Tier;
	public PumpModel NextPump => PumpLevel < PumpModel.MaxIndex ? PumpModel.Get( PumpLevel + 1 ) : null;
	public bool IsMaxed => NextPump is null;

	// ---- effective stats: the tier's base numbers times whatever the shop has sold you

	/// <summary>Tank size including the Water Tank upgrade.</summary>
	public double TankCapacity => CurrentPump.Tank * MultiplierOf( UpgradeKind.Storage );

	/// <summary>Litres per manual pump, including Pump Power.</summary>
	public double LitresPerClick => CurrentPump.PerClick * MultiplierOf( UpgradeKind.Power );

	/// <summary>Coins per litre, including Market Contacts.</summary>
	public double PricePerLitre => CurrentPump.PricePerLitre * MultiplierOf( UpgradeKind.Value );

	public bool TankFull => Water >= TankCapacity - 0.0001;
	public float TankFraction => TankCapacity <= 0 ? 0f : (float)Math.Clamp( Water / TankCapacity, 0d, 1d );

	/// <summary>What the tank is worth right now.</summary>
	public double SaleValue => Water * PricePerLitre;

	public bool CanSell => Water > 0.0001;
	public bool CanUpgrade => NextPump is not null && Coins >= NextPump.Cost;

	public int LevelOf( UpgradeKind kind ) => upgradeLevels[(int)kind];

	public float MultiplierOf( UpgradeKind kind ) => UpgradeTrack.Get( kind ).MultiplierAt( LevelOf( kind ) );

	public bool IsMaxLevel( UpgradeKind kind ) => LevelOf( kind ) >= UpgradeTrack.Get( kind ).MaxLevel;

	/// <summary>Cost of the next level, or infinity once the track is maxed.</summary>
	public double CostOf( UpgradeKind kind )
	{
		return IsMaxLevel( kind )
			? double.PositiveInfinity
			: UpgradeTrack.Get( kind ).CostAt( LevelOf( kind ) );
	}

	public bool CanBuy( UpgradeKind kind ) => !IsMaxLevel( kind ) && Coins >= CostOf( kind );

	/// <summary>
	/// Fired on an ordinary manual pump, with the litres gained. A pump that came up a
	/// surge fires <see cref="Surged"/> instead - exactly one of the two per click.
	/// </summary>
	public Action<double> Pumped { get; set; }

	/// <summary>Fired on a sale, with the coins gained.</summary>
	public Action<double> Sold { get; set; }

	/// <summary>Fired after buying a new pump, with the tier we moved to.</summary>
	public Action<PumpModel> Upgraded { get; set; }

	/// <summary>Fired when an action was refused - tank full, broke, nothing to sell.</summary>
	public Action<string> Refused { get; set; }

	/// <summary>Fired after buying a shop upgrade.</summary>
	public Action<UpgradeTrack> Purchased { get; set; }

	TimeSince timeSinceSave;

	/// <summary>
	/// True once <see cref="Load"/> has run, which only happens in play mode. The copy of
	/// this component living in the editor's scene never wakes, so it sits on default
	/// zeroes - and without this guard its shutdown save would write those zeroes straight
	/// over a real run. Never write state we haven't read.
	/// </summary>
	bool loaded;

	protected override void OnAwake()
	{
		Current = this;
		Load();
	}

	/// <summary>
	/// Quitting tears the scene down, and which of these fires first depends on how you
	/// left - closing the window, stopping play mode, or loading another scene. Saving
	/// from both, plus after every earn and spend, means there's no way out that loses
	/// a run.
	/// </summary>
	protected override void OnDisabled()
	{
		Save();
	}

	protected override void OnDestroy()
	{
		Save();

		if ( Current == this )
			Current = null;
	}

	protected override void OnUpdate()
	{
		// A code hotload wipes statics without re-running OnAwake, which would leave
		// Current null and the HUD showing "no game in the scene" for the rest of the
		// session. Cheaper to re-claim it than to debug that every time.
		if ( Current != this ) Current = this;

		// This is a mouse game - without this the cursor gets locked to the view in
		// play mode and none of the buttons can be clicked.
		Mouse.Visibility = MouseVisibility.Visible;

		// Space bar is a second pump button so you're not glued to the mouse.
		if ( Input.Pressed( "Jump" ) )
		{
			Pump();
		}

		// The pump and everyone you've hired, together.
		var passive = TotalWaterPerSecond * Time.Delta;
		if ( passive > 0f )
		{
			AddWater( passive );
		}

		TickPressure();
		TickGusher();
		TickTree();

		if ( PumpSettings.Current.AutoSell && TankFull )
		{
			SellAll();
		}

		if ( SaveEnabled && timeSinceSave > SaveInterval )
		{
			Save();
		}
	}

	/// <summary>
	/// One manual pump. Returns the litres actually gained - zero means the tank was already full.
	/// </summary>
	/// <remarks>
	/// Pressure and surges only exist on this path. Idle water, workers and the tree all
	/// go through <see cref="AddWater"/> untouched, so hands-off income is exactly what
	/// it always was.
	/// </remarks>
	public double Pump()
	{
		if ( TankFull )
		{
			Refuse( "Tank is full - sell your water" );
			return 0;
		}

		var surge = RollSurge();
		var litres = LitresPerClick * PressureBonus * (surge ? SurgeMultiplier : 1f);

		var gained = AddWater( litres );

		// Pressure builds even on the click that overflowed the tank - the rhythm is the
		// player's, and dropping it because the tank happened to be full feels like a bug.
		BumpPressure();
		TotalPumps++;

		if ( surge )
		{
			SurgeCount++;
			Play( SurgeSound ?? UpgradeSound );
			Surged?.Invoke( gained );
		}
		else
		{
			Play( PumpSound );
			Pumped?.Invoke( gained );
		}

		return gained;
	}

	/// <summary>
	/// Empty the tank into coins. Returns what we earned.
	/// </summary>
	public double SellAll()
	{
		if ( !CanSell )
		{
			Refuse( "No water to sell" );
			return 0;
		}

		var litres = Water;
		var earned = SaleValue;

		Water = 0;
		Coins += earned;
		TotalEarned += earned;
		TotalLitresSold += litres;
		IdleCatchUp = 0;

		Play( SellSound );
		Sold?.Invoke( earned );
		Save();

		return earned;
	}

	/// <summary>
	/// Buy the next pump in the list. Water carries over, clamped to the new tank.
	/// </summary>
	public bool BuyNextPump()
	{
		var next = NextPump;

		if ( next is null )
		{
			Refuse( "You own every pump in the desert" );
			return false;
		}

		if ( Coins < next.Cost )
		{
			Refuse( $"Need {Numbers.Short( next.Cost - Coins )} more coins" );
			return false;
		}

		Coins -= next.Cost;
		PumpLevel = next.Index;
		Water = Math.Min( Water, TankCapacity );

		Play( UpgradeSound );
		Upgraded?.Invoke( next );
		Save();

		return true;
	}

	/// <summary>Hand over coins from outside the sell loop - rewards, console commands.</summary>
	public void AddCoins( double amount )
	{
		// A NaN or infinity in here poisons the save and every number on screen with
		// no way back, so refuse it at the door rather than storing it.
		if ( amount <= 0 || double.IsNaN( amount ) || double.IsInfinity( amount ) )
			return;

		Coins += amount;
		TotalEarned += amount;
	}

	/// <summary>
	/// Buy one level of a shop upgrade. Returns false and complains if you can't afford it.
	/// </summary>
	public bool BuyUpgrade( UpgradeKind kind )
	{
		var track = UpgradeTrack.Get( kind );

		if ( IsMaxLevel( kind ) )
		{
			Refuse( $"{track.Name} is fully upgraded" );
			return false;
		}

		var cost = CostOf( kind );
		if ( Coins < cost )
		{
			Refuse( $"Need {Numbers.Short( cost - Coins )} more coins" );
			return false;
		}

		Coins -= cost;
		upgradeLevels[(int)kind]++;

		// A smaller tank than the water in it can't happen, but a bigger one is free room.
		Water = Math.Min( Water, TankCapacity );

		Play( UpgradeSound );
		Purchased?.Invoke( track );
		Save();

		return true;
	}

	public void ToggleMute()
	{
		var settings = PumpSettings.Current;

		settings.Muted = !settings.Muted;
		settings.Save();
	}

	/// <summary>Wipe the save and start over from the hand pump.</summary>
	public void ResetProgress()
	{
		Coins = 0;
		Water = 0;
		PumpLevel = 0;
		Array.Clear( upgradeLevels );
		ownedBackgrounds.Clear();
		ownedBackgrounds.Add( BackgroundStyle.DefaultId );
		SelectedBackground = BackgroundStyle.DefaultId;
		lastDailyDay = 0;
		DailyStreak = 0;
		Array.Clear( workerLevels );
		TreeHeight = 0;
		TreeWater = 0;
		treeGrowsAt = 0;
		TotalPumps = 0;
		TotalEarned = 0;
		TotalLitresSold = 0;
		IdleCatchUp = 0;
		Pressure = 0;
		SurgeCount = 0;

		Save();
	}

	/// <summary>Adds water up to the tank limit and returns how much actually fit.</summary>
	double AddWater( double amount )
	{
		var before = Water;
		Water = Math.Min( TankCapacity, Water + amount );

		return Water - before;
	}

	void Refuse( string reason )
	{
		Play( DeniedSound );
		Refused?.Invoke( reason );
	}

	void Play( SoundEvent sound )
	{
		var volume = PumpSettings.Current.EffectiveSfx;

		if ( sound is null || volume <= 0f )
			return;

		var handle = Sound.Play( sound, (Sandbox.Audio.Mixer)null );

		if ( handle.IsValid() )
		{
			handle.Volume = volume;
		}
	}

	void Load()
	{
		timeSinceSave = 0;

		if ( !SaveEnabled )
			return;

		// From here on we own the file - a missing save just means a fresh run.
		loaded = true;

		var save = ReadSave( SaveFile );

		// Nothing came back but there is a file on disk, so it is unreadable rather
		// than absent - a write cut short by a crash, most likely. The backup below is
		// one session behind, which beats handing the player a fresh run and no
		// explanation. This is the half of that feature that was missing: the backup
		// was being written every load and never once read.
		if ( save is null && FileSystem.Data.FileExists( SaveFile ) )
		{
			save = ReadSave( SaveFile + ".bak" );

			Log.Warning( save is null
				? $"{SaveFile} is unreadable and so is its backup - starting a fresh run."
				: $"{SaveFile} is unreadable - restored the backup from the last session." );
		}

		if ( save is null )
			return;

		// Stash the run as we found it. One session of history is enough to rescue a
		// player whose save gets eaten by a bug, and it costs one small write per load.
		FileSystem.Data.WriteJson( SaveFile + ".bak", save );

		Coins = Math.Max( 0, save.Coins );
		PumpLevel = Math.Clamp( save.PumpLevel, 0, PumpModel.MaxIndex );

		ownedBackgrounds.Clear();
		ownedBackgrounds.Add( BackgroundStyle.DefaultId );
		foreach ( var id in save.OwnedBackgrounds ?? new List<string>() )
		{
			ownedBackgrounds.Add( id );
		}

		SelectedBackground = OwnsBackground( save.SelectedBackground )
			? save.SelectedBackground
			: BackgroundStyle.DefaultId;

		lastDailyDay = save.LastDailyDay;
		Array.Clear( workerLevels );
		var levels = save.WorkerLevels ?? new List<int>();
		for ( var i = 0; i < Math.Min( levels.Count, workerLevels.Length ); i++ )
		{
			workerLevels[i] = Math.Clamp( levels[i], 0, WorkerType.MaxLevel );
		}

		TreeHeight = Math.Max( 0, save.TreeHeight );
		TreeWater = Math.Max( 0, save.TreeWater );
		treeGrowsAt = save.TreeGrowsAt;
		DailyStreak = save.DailyStreak;

		upgradeLevels[(int)UpgradeKind.Storage] = ClampLevel( save.StorageLevel, UpgradeKind.Storage );
		upgradeLevels[(int)UpgradeKind.Power] = ClampLevel( save.PowerLevel, UpgradeKind.Power );
		upgradeLevels[(int)UpgradeKind.Value] = ClampLevel( save.ValueLevel, UpgradeKind.Value );

		// Levels have to be in before the tank is clamped, or a full tank gets trimmed
		// to the un-upgraded size on every load.
		Water = Math.Clamp( save.Water, 0, TankCapacity );
		TotalPumps = save.TotalPumps;
		TotalEarned = save.TotalEarned;
		TotalLitresSold = save.TotalLitresSold;

		PayIdleTime( save.SavedAt );
	}

	/// <summary>
	/// Read one save file, or null if it isn't there and null if it's rubbish.
	/// </summary>
	/// <remarks>
	/// ReadJsonOrDefault already returns the default on a missing or malformed file, so
	/// the catch is only for what it doesn't cover - a locked or unreadable file. Either
	/// way a broken save must never take the game down with it: that turns "you lost
	/// your progress" into "the game no longer starts".
	/// </remarks>
	PumpSave ReadSave( string path )
	{
		try
		{
			return FileSystem.Data.ReadJsonOrDefault<PumpSave>( path, null );
		}
		catch ( Exception e )
		{
			Log.Warning( $"Couldn't read {path}: {e.Message}" );
			return null;
		}
	}

	static int ClampLevel( int level, UpgradeKind kind ) =>
		Math.Clamp( level, 0, UpgradeTrack.Get( kind ).MaxLevel );

	/// <summary>Runs the passive pump for the time we were away, capped so it can't be farmed.</summary>
	void PayIdleTime( long savedAt )
	{
		if ( savedAt <= 0 || TotalWaterPerSecond <= 0 )
			return;

		var away = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - savedAt;
		if ( away <= 0 )
			return;

		var seconds = Math.Min( away, (long)(MaxIdleHours * 3600f) );
		IdleCatchUp = AddWater( TotalWaterPerSecond * seconds );
	}

	void Save()
	{
		timeSinceSave = 0;

		if ( !SaveEnabled )
			return;

		// Never overwrite a real run with state we never loaded. See the field comment.
		if ( !loaded )
			return;

		FileSystem.Data.WriteJson( SaveFile, new PumpSave
		{
			Coins = Coins,
			Water = Water,
			PumpLevel = PumpLevel,
			OwnedBackgrounds = ownedBackgrounds.ToList(),
			SelectedBackground = SelectedBackground,
			LastDailyDay = lastDailyDay,
			WorkerLevels = workerLevels.ToList(),
			TreeHeight = TreeHeight,
			TreeWater = TreeWater,
			TreeGrowsAt = treeGrowsAt,
			DailyStreak = DailyStreak,
			StorageLevel = LevelOf( UpgradeKind.Storage ),
			PowerLevel = LevelOf( UpgradeKind.Power ),
			ValueLevel = LevelOf( UpgradeKind.Value ),
			Muted = Muted,
			TotalPumps = TotalPumps,
			TotalEarned = TotalEarned,
			TotalLitresSold = TotalLitresSold,
			SavedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
		} );
	}
}