Economy.cs

Static Economy manager for an idle game. Tracks real and shown Dogecoin balance, lifetime earnings, playtime, idle income accrual, milestone thresholds and autosave timing, and exposes formatted balance/income strings and spend/add methods.

File Access
namespace NoChillquarium;

/// <summary>
/// Dogecoin wallet + idle income from living fish.
/// Real balance is always accurate; the HUD number settles calmly every couple seconds.
/// </summary>
public static class Economy
{
	static readonly double[] MilestoneThresholds =
	{
		10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000
	};

	/// <summary>How often idle income refreshes the visible wallet (seconds).</summary>
	const float DisplaySettleInterval = 3f;

	static double _balance;
	static double _shownBalance;
	static double _lifetimeEarned;
	static double _nextMilestone = 10;
	static float _playtimeSeconds;
	static float _autosaveTimer;
	static float _displayTimer;
	static float _pendingShowDelay;
	static float _walletPulse;
	static int _displayVersion;

	public static double Balance => _balance;
	/// <summary>Calm on-screen balance (settles every few seconds).</summary>
	public static double ShownBalance => _shownBalance;
	public static double LifetimeEarned => _lifetimeEarned;
	public static float PlaytimeSeconds => _playtimeSeconds;
	public static int DisplayVersion => _displayVersion;
	public static double IncomePerSecond => TankSim.IncomePerSecond * FeedSystem.IncomeMultiplier;

	/// <summary>True while the wallet chip is doing a soft settle pulse.</summary>
	public static bool WalletPulse => _walletPulse > 0.04f;

	/// <summary>Settled HUD balance (not the frantic live accrual).</summary>
	public static string BalanceText => FormatDoge( _shownBalance );

	public static string IncomeText
	{
		get
		{
			var rate = IncomePerSecond;
			// Round rate for a calm readout — not three decimals twitching.
			var shownRate = rate >= 10 ? Math.Round( rate, 1 ) : Math.Round( rate, 2 );
			var text = $"+{FormatDoge( shownRate )}/s";
			if ( FeedSystem.IsBoosted )
				text += " FED";
			if ( TankSim.SaltUnlocked )
				text += " ALL TANKS";
			return text;
		}
	}

	public static void ResetForNewGame()
	{
		// Small starter wallet so the shop is reachable without a long AFK.
		_balance = 25;
		_shownBalance = 25;
		_lifetimeEarned = 0;
		_playtimeSeconds = 0;
		_autosaveTimer = 0;
		_displayTimer = 0;
		_pendingShowDelay = 0;
		_walletPulse = 0;
		_nextMilestone = MilestoneThresholds[0];
		FeedSystem.Clear();
		ExplodeSystem.Clear();
		DogeFloat.Clear();
		BumpDisplay();
	}

	public static void LoadFromSave( SaveData data )
	{
		if ( data is null )
		{
			ResetForNewGame();
			return;
		}

		_balance = Math.Max( 0, data.Dogecoin );
		_shownBalance = _balance;
		_lifetimeEarned = Math.Max( 0, data.LifetimeEarned );
		_playtimeSeconds = Math.Max( 0, data.PlaytimeSeconds );
		_autosaveTimer = 0;
		_displayTimer = 0;
		_pendingShowDelay = 0;
		_walletPulse = 0;
		RecalcNextMilestone();
		BumpDisplay();
	}

	public static void WriteToSave( SaveData data )
	{
		if ( data is null )
			return;

		data.Dogecoin = _balance;
		data.LifetimeEarned = _lifetimeEarned;
		data.PlaytimeSeconds = _playtimeSeconds;
	}

	public static void Tick( float dt )
	{
		if ( !TankSim.IsActive || dt <= 0f )
			return;

		if ( dt > 0.05f )
			dt = 0.05f;

		_playtimeSeconds += dt;

		var rate = IncomePerSecond;
		if ( rate > 0 )
		{
			var gained = rate * dt;
			_balance += gained;
			_lifetimeEarned += gained;
			// Idle drip — advance milestones silently (no bark spam).
			CheckMilestones( playSound: false );
		}

		_autosaveTimer += dt;
		if ( _autosaveTimer >= 15f )
		{
			_autosaveTimer = 0f;
			SaveGame.TrySave( quiet: true );
		}

		if ( _walletPulse > 0f )
			_walletPulse = MathF.Max( 0f, _walletPulse - dt * 1.35f );

		// Explicit earn: short pause so the float can appear, then settle the number.
		if ( _pendingShowDelay > 0f )
		{
			_pendingShowDelay -= dt;
			if ( _pendingShowDelay <= 0f )
			{
				_pendingShowDelay = 0f;
				SettleShown( soft: true );
				_displayTimer = 0f;
			}
		}
		else
		{
			// Idle: only refresh the chip every couple seconds — calm ticker, not a slot machine.
			_displayTimer += dt;
			if ( _displayTimer >= DisplaySettleInterval )
			{
				_displayTimer = 0f;
				SettleShown( soft: true );
			}
		}
	}

	public static bool TrySpend( double amount )
	{
		if ( amount <= 0 )
			return true;

		if ( _balance < amount )
			return false;

		_balance -= amount;
		DogeFloat.NotifySpend( amount );
		// Spends snap now — you always trust the number after a purchase.
		_pendingShowDelay = 0f;
		_displayTimer = 0f;
		SettleShown( soft: false );
		return true;
	}

	public static void Add( double amount )
	{
		if ( amount <= 0 )
			return;

		_balance += amount;
		_lifetimeEarned += amount;
		// Explicit payouts only (not idle drip) — pleasant/meme floats + milestone SFX.
		DogeFloat.NotifyEarn( amount );
		CheckMilestones( playSound: true );
		// Let the float lead, then the wallet ticks up with a soft pulse.
		_pendingShowDelay = 0.45f;
		BumpDisplay();
	}

	/// <summary>
	/// Push the visible balance toward the real one.
	/// Soft = only if it changed enough to matter; hard = always (spends).
	/// </summary>
	static void SettleShown( bool soft )
	{
		var delta = Math.Abs( _balance - _shownBalance );
		if ( soft && delta < 0.05 )
			return;

		_shownBalance = _balance;
		_walletPulse = soft ? 0.85f : 1f;
		BumpDisplay();
	}

	static void CheckMilestones( bool playSound )
	{
		while ( _balance >= _nextMilestone )
		{
			var hit = _nextMilestone;
			if ( playSound )
			{
				// Bark only for chunky wallet thresholds; small ones soft coin.
				if ( hit >= 100 )
					GameAudio.PlayDogeBark();
				else
					GameAudio.PlayCoin();
			}
			AdvanceMilestone();
		}
	}

	static void AdvanceMilestone()
	{
		for ( var i = 0; i < MilestoneThresholds.Length; i++ )
		{
			if ( MilestoneThresholds[i] > _nextMilestone + 0.001 )
			{
				_nextMilestone = MilestoneThresholds[i];
				return;
			}
		}

		// Beyond table: keep doubling.
		_nextMilestone = Math.Max( _nextMilestone * 2.0, _nextMilestone + 10000 );
	}

	static void RecalcNextMilestone()
	{
		_nextMilestone = MilestoneThresholds[0];
		foreach ( var t in MilestoneThresholds )
		{
			if ( _balance < t )
			{
				_nextMilestone = t;
				return;
			}
		}

		_nextMilestone = Math.Max( _balance * 2.0, MilestoneThresholds[^1] * 2.0 );
	}

	static void BumpDisplay() => _displayVersion++;

	public static string FormatDoge( double value )
	{
		if ( value >= 1_000_000_000 )
			return $"{value / 1_000_000_000:0.##}B";
		if ( value >= 1_000_000 )
			return $"{value / 1_000_000:0.##}M";
		if ( value >= 10_000 )
			return $"{value / 1000:0.#}K";
		if ( value >= 100 )
			return $"{value:0}";
		if ( value >= 10 )
			return $"{value:0.#}";
		return $"{value:0.##}";
	}

	/// <summary>
	/// Extra-compact form for the fixed LCD (keeps glyph count low so text stays large).
	/// Prefer abbreviations earlier than general HUD formatting.
	/// </summary>
	public static string FormatDogeLcd( double value )
	{
		value = Math.Abs( value );
		if ( value >= 1_000_000_000 )
			return $"{value / 1_000_000_000:0.#}B";
		if ( value >= 1_000_000 )
			return $"{value / 1_000_000:0.#}M";
		// LCD switches to K earlier so we don't show 5–6 raw digits
		if ( value >= 1_000 )
			return $"{value / 1000:0.#}K";
		if ( value >= 100 )
			return $"{value:0}";
		if ( value >= 10 )
			return $"{value:0.#}";
		return $"{value:0.##}";
	}
}