Game/Idle/IdleReactor.cs
using Sandbox;
using Sandbox.Services;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// Reactor mode (GameMode.Idle).
///
/// A self-running board: chests drop in, autonomous drones bomb them, chain
/// reactions pay out Cores. Cores buy upgrades, upgrades make bigger chains,
/// bigger chains print more Cores. Meltdown (prestige) converts a whole cycle
/// into permanent Flux.
///
/// This component owns its own board rather than borrowing GridManager, so
/// Wave and Survival keep working exactly as before.
/// </summary>
public sealed class IdleReactor : Component
{
	public static IdleReactor Instance { get; private set; }

	// ══════════════════════════════════════════════════════════════════════
	// Board
	// ══════════════════════════════════════════════════════════════════════

	public class Cell
	{
		public int Row, Col;
		public GridManager.ChestType Chest = GridManager.ChestType.None;
		public int HitsLeft;
		public int MaxHits;

		// Visual-only state, driven by the sim and read by IdleHud
		public bool  HasDrone;
		public float DroneAt;     // when the drone landed, for the arming pop
		public float FlashUntil;
		public int   FlashKind;   // 0 none · 1 blast · 2 kill · 3 drone origin
		public float SpawnAt;

		public bool HasChest => Chest != GridManager.ChestType.None;
		public bool IsEmpty  => Chest == GridManager.ChestType.None;
	}

	public Cell[,] Board { get; private set; }
	public int Size { get; private set; } = 5;

	// ══════════════════════════════════════════════════════════════════════
	// Lifecycle state
	// ══════════════════════════════════════════════════════════════════════

	public bool IsActive { get; private set; }

	public IdleSave    S      => ChainReactionGame.Save?.Idle;
	public PlayerSave  Player => ChainReactionGame.Save;

	readonly Random _rng = new();

	// Timers
	float _spawnTimer;
	float _cycleTimer;

	// A detonation happens in two beats: drones land visibly on their targets,
	// then the whole volley goes off. Resolving both in one frame made the mode
	// read as "chests silently vanish" — you never saw a bomb.
	readonly List<(int r, int c)> _armedTargets = new();
	List<(int dr, int dc)> _armedPattern = new();
	double _armedMult = 1.0;
	float  _armTimer  = -1f;

	public bool  IsArmed  => _armTimer > 0f;
	public float ArmTime  => MathF.Min( 0.40f, CycleInterval * 0.45f );

	/// <summary>
	/// True when the board is too empty for the drones to work with. This is the
	/// one failure state the mode has, and it is entirely a signal to go buy
	/// Feed Line or Bulk Hopper — coverage has outgrown supply.
	/// </summary>
	public bool FeedStarved { get; private set; }

	public int ChestCount()
	{
		int n = 0;
		for ( int r = 0; r < Size; r++ )
			for ( int c = 0; c < Size; c++ )
				if ( Board[r, c].HasChest ) n++;
		return n;
	}
	float _goldenTimer;
	float _saveTimer;
	float _autoOverloadTimer;

	// Manual play
	float _overloadCooldownLeft;
	public float Charge { get; private set; }          // 0..1 surge meter
	public bool  SurgeReady  => Charge >= 1f && !SurgeActive;
	public bool  SurgeActive { get; private set; }
	public float SurgeTimeLeft { get; private set; }

	public float OverloadCooldownLeft => _overloadCooldownLeft;
	public float OverloadCooldownFraction
	{
		get
		{
			float total = IdleBalance.OverloadCooldown( S?.Level( IdleUpgradeId.OverloadCooldown ) ?? 0 );
			return total <= 0f ? 0f : Math.Clamp( _overloadCooldownLeft / total, 0f, 1f );
		}
	}
	public bool OverloadReady => _overloadCooldownLeft <= 0f;

	// Golden chest
	public int   GoldenRow { get; private set; } = -1;
	public int   GoldenCol { get; private set; } = -1;
	public float GoldenTimeLeft { get; private set; }
	public bool  GoldenActive => GoldenRow >= 0;

	// Temporary multipliers
	public class Boost
	{
		public string Label;
		public string Icon;
		public double Mult;
		public float  SecondsLeft;
	}
	public List<Boost> Boosts { get; private set; } = new();

	// Output measurement
	public double Cps { get; private set; }
	double _cpsAccum;
	float  _cpsWindow;

	// Last detonation readout, for the HUD
	public int    LastChain     { get; private set; }
	public double LastPayout    { get; private set; }
	public bool   LastWasCrit   { get; private set; }
	public float  LastPayoutAt  { get; private set; }

	// Offline / daily staging
	public bool   OfflinePending  => (S?.PendingOfflineCores ?? 0) > 0;
	public bool   DailyPending    { get; private set; }
	public int    DailyStreakDay  { get; private set; }

	// Audio throttle — a 0.4s cycle would otherwise machine-gun the mixer
	float _lastBlastSound;
	float _lastCoinSound;
	float _lastPlaceSound;

	// ══════════════════════════════════════════════════════════════════════
	// Events for the HUD
	// ══════════════════════════════════════════════════════════════════════

	public event Action OnChanged;
	public event Action<int, int, double, bool> OnPop;              // row, col, value, crit
	public event Action<string, string, string> OnToast;            // icon, text, css class
	public event Action<int, double, bool> OnDetonation;            // chain, payout, crit

	void Changed() => OnChanged?.Invoke();
	public void Toast( string icon, string text, string cls = "" ) => OnToast?.Invoke( icon, text, cls );

	// ══════════════════════════════════════════════════════════════════════
	// Boot
	// ══════════════════════════════════════════════════════════════════════

	/// <summary>
	/// When this process booted. Accrual is clamped to this so closing the game
	/// stops production dead — the reactor only banks time you were actually
	/// running the game, just on another screen.
	/// </summary>
	static long _processStartUnix;

	protected override void OnStart()
	{
		Instance = this;
		if ( _processStartUnix == 0 )
			_processStartUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
		BuildBoard( 5 );
	}

	void BuildBoard( int size )
	{
		Size  = Math.Clamp( size, 5, 9 );
		Board = new Cell[Size, Size];
		for ( int r = 0; r < Size; r++ )
			for ( int c = 0; c < Size; c++ )
				Board[r, c] = new Cell { Row = r, Col = c };
	}

	/// <summary>Rebuild the board when Reactor Frame is bought, keeping what fits.</summary>
	void ResizeBoard( int size )
	{
		if ( size == Size ) return;

		var old = Board;
		int oldSize = Size;
		BuildBoard( size );

		int n = Math.Min( oldSize, Size );
		for ( int r = 0; r < n; r++ )
			for ( int c = 0; c < n; c++ )
			{
				Board[r, c].Chest    = old[r, c].Chest;
				Board[r, c].HitsLeft = old[r, c].HitsLeft;
				Board[r, c].MaxHits  = old[r, c].MaxHits;
			}
	}

	// ══════════════════════════════════════════════════════════════════════
	// Enter / leave the mode
	// ══════════════════════════════════════════════════════════════════════

	public void Begin()
	{
		var s = S;
		if ( s == null ) return;

		s.Unlocked = true;

		ResizeBoard( IdleBalance.GridSize( s.Level( IdleUpgradeId.GridSize ) ) );

		// Fresh board every entry — the offline payout already covered the gap
		for ( int r = 0; r < Size; r++ )
			for ( int c = 0; c < Size; c++ )
			{
				Board[r, c].Chest    = GridManager.ChestType.None;
				Board[r, c].HitsLeft = 0;
				Board[r, c].HasDrone   = false;
				Board[r, c].FlashKind  = 0;
				Board[r, c].FlashUntil = 0f;
			}

		_armedTargets.Clear();
		_armTimer   = -1f;
		_spawnTimer = 0.25f;
		_cycleTimer = CycleInterval * 0.6f;
		_goldenTimer = GoldenInterval * 0.5f;
		_overloadCooldownLeft = 0f;
		_autoOverloadTimer = 0f;
		_saveTimer = 10f;
		Boosts.Clear();
		SurgeActive = false;
		SurgeTimeLeft = 0f;
		_cpsAccum = 0; _cpsWindow = 0;
		Cps = s.MeasuredCps;

		ComputeOffline();
		CheckDaily();

		// Seed the board so the first seconds already look alive
		SpawnChests( Math.Max( 4, Size ) );

		IsActive = true;
		Changed();
	}

	public void End()
	{
		if ( !IsActive ) return;
		IsActive = false;

		var s = S;
		if ( s != null )
		{
			s.LastSeenUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
			s.MeasuredCps  = Cps;
			SubmitReactorStats();
			Player?.Save();
		}

		Changed();
	}

	void SubmitReactorStats()
	{
		try
		{
			var s = S;
			if ( s == null ) return;

			// Flux earned is the honest "how deep are you" number for this mode
			if ( s.FluxEarned > 0 )
				Stats.Increment( "chain_reaction_reactor", s.FluxEarned );
		}
		catch ( Exception e )
		{
			Log.Warning( $"Reactor stat submit failed: {e.Message}" );
		}
	}

	// ══════════════════════════════════════════════════════════════════════
	// Derived values
	// ══════════════════════════════════════════════════════════════════════

	int Lvl( IdleUpgradeId id )  => S?.Level( id ) ?? 0;
	int Perk( IdlePerkId id )    => S?.PerkLevel( id ) ?? 0;

	public float SpawnInterval => IdleBalance.SpawnInterval( Lvl( IdleUpgradeId.SpawnRate ) );
	public float CycleInterval => IdleBalance.CycleInterval( Lvl( IdleUpgradeId.CycleSpeed ) );
	public float GoldenInterval => IdleBalance.GoldenInterval( Lvl( IdleUpgradeId.GoldenChestRate ) );

	public double ChainExponent
		=> IdleBalance.ChainExponent( Lvl( IdleUpgradeId.ChainPower ), Perk( IdlePerkId.ChainTheory ) );

	/// <summary>Everything that multiplies the whole payout, excluding chain length.</summary>
	public double GlobalMultiplier
	{
		get
		{
			var s = S;
			if ( s == null ) return 1.0;

			double m = IdleBalance.FluxMultiplier( s.FluxEarned );
			m *= 1.0 + 0.25 * Perk( IdlePerkId.CoreEfficiency );
			m *= IdleBalance.PetBonus( Player?.PetLevel ?? 1,
									   Player?.PetUnlockShown ?? false,
									   Perk( IdlePerkId.PetLink ) );

			foreach ( var b in Boosts ) m *= b.Mult;
			if ( SurgeActive ) m *= IdleBalance.SurgeMultiplier;

			return m;
		}
	}

	public float OfflineEfficiency
		=> IdleBalance.OfflineEfficiency( Lvl( IdleUpgradeId.OfflineEfficiency ), Perk( IdlePerkId.NightShift ) );

	public float OfflineCapHours
		=> IdleBalance.OfflineCapHours( Lvl( IdleUpgradeId.OfflineCap ), Perk( IdlePerkId.DeepStorage ) );

	/// <summary>Cores still needed for the next Fragment at today's escalating price.</summary>
	public double FragmentCost
		=> IdleBalance.FragmentCost( Lvl( IdleUpgradeId.FragmentYield ),
									 Perk( IdlePerkId.FragmentCondenser ),
									 S?.FragmentsToday ?? 0 );

	public int DailyFragmentCap
		=> IdleBalance.DailyFragmentCap( Perk( IdlePerkId.FragmentCondenser ), Player?.PetLevel ?? 1 );

	public int FragmentsToday => S?.FragmentsToday ?? 0;

	public float FragmentProgressFraction
	{
		get
		{
			double cost = FragmentCost;
			if ( cost <= 0 ) return 0f;
			return (float)Math.Clamp( (S?.FragProgress ?? 0) / cost, 0.0, 1.0 );
		}
	}

	// ══════════════════════════════════════════════════════════════════════
	// Update loop
	// ══════════════════════════════════════════════════════════════════════

	protected override void OnUpdate()
	{
		if ( !IsActive ) return;

		var s = S;
		if ( s == null ) return;

		// The welcome-back and daily panels hold the reactor still so the player
		// actually reads them instead of watching numbers move behind the popup.
		if ( OfflinePending || DailyPending ) return;

		float dt = Time.Delta;
		s.SecondsRun += dt;

		TickBoosts( dt );
		TickSurge( dt );
		TickCooldowns( dt );
		TickSpawning( dt );
		TickDetonation( dt );
		TickGolden( dt );
		TickCps( dt );

		_saveTimer -= dt;
		if ( _saveTimer <= 0f )
		{
			_saveTimer = 10f;
			s.LastSeenUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
			s.MeasuredCps  = Cps;
			Player?.Save();

			// Catches players who leave the reactor running past UTC midnight
			CheckDaily();
		}
	}

	void TickBoosts( float dt )
	{
		bool dirty = false;
		for ( int i = Boosts.Count - 1; i >= 0; i-- )
		{
			Boosts[i].SecondsLeft -= dt;
			if ( Boosts[i].SecondsLeft <= 0f ) { Boosts.RemoveAt( i ); dirty = true; }
		}
		if ( dirty ) Changed();
	}

	void TickSurge( float dt )
	{
		if ( !SurgeActive ) return;

		SurgeTimeLeft -= dt;
		if ( SurgeTimeLeft <= 0f )
		{
			SurgeActive   = false;
			SurgeTimeLeft = 0f;
			Charge        = 0f;
			Toast( "🔻", "Surge ended", "idle-toast-dim" );
			Changed();
		}
	}

	void TickCooldowns( float dt )
	{
		if ( _overloadCooldownLeft > 0f )
		{
			_overloadCooldownLeft = MathF.Max( 0f, _overloadCooldownLeft - dt );
			if ( _overloadCooldownLeft <= 0f ) Changed();
		}

		float autoEvery = IdleBalance.AutoOverloadInterval( Perk( IdlePerkId.AutoOverload ) );
		if ( autoEvery > 0f )
		{
			_autoOverloadTimer -= dt;
			if ( _autoOverloadTimer <= 0f )
			{
				_autoOverloadTimer = autoEvery;
				if ( OverloadReady ) Overload( auto: true );
			}
		}
	}

	void TickSpawning( float dt )
	{
		// Ambient drip — one chest at a time, purely so the board never looks static
		_spawnTimer -= dt;
		if ( _spawnTimer > 0f ) return;

		_spawnTimer = SpawnInterval;
		SpawnChests( 1 );
	}

	void TickDetonation( float dt )
	{
		// Beat two — a volley that is already on the board goes off
		if ( _armTimer > 0f )
		{
			_armTimer -= dt;
			if ( _armTimer <= 0f ) FireArmed();
		}

		_cycleTimer -= dt;
		if ( _cycleTimer > 0f ) return;

		_cycleTimer = CycleInterval;
		if ( _armTimer > 0f ) return;   // previous volley still resolving

		// Beat one — hopper dumps its batch, then the drones land on it
		SpawnChests( IdleBalance.SpawnCount( Lvl( IdleUpgradeId.SpawnCount ) ) );
		ArmVolley( 1.0, ArmTime );
	}

	/// <summary>Places the drones and leaves them sitting there so the player sees them.</summary>
	void ArmVolley( double payoutMult, float armTime )
	{
		var pattern = IdleBalance.BlastOffsets( Lvl( IdleUpgradeId.BlastSize ) ).ToList();
		var targets = PickTargets( IdleBalance.DroneCount( Lvl( IdleUpgradeId.DroneCount ) ), pattern );
		if ( targets.Count == 0 ) return;

		float now = Time.Now;
		_armedTargets.Clear();

		foreach ( var t in targets )
		{
			var cell = Board[t.r, t.c];
			cell.HasDrone = true;
			cell.DroneAt  = now;
			_armedTargets.Add( t );
		}

		// Fewer chests on the board than the drones could hit means the feed has
		// fallen behind whatever coverage the player has bought.
		FeedStarved = ChestCount() < Size * Size * 0.22f;

		_armedPattern = pattern;
		_armedMult    = payoutMult;
		_armTimer     = MathF.Max( 0.06f, armTime );

		if ( now - _lastPlaceSound > 0.25f )
		{
			_lastPlaceSound = now;
			var h = Sound.Play( "place_bomb_1" );
			h.Volume = 0.35f;
		}

		Changed();
	}

	void FireArmed()
	{
		_armTimer = -1f;
		if ( _armedTargets.Count == 0 ) return;

		var targets = new List<(int r, int c)>( _armedTargets );
		_armedTargets.Clear();

		Detonate( targets, _armedPattern, _armedMult, manual: false );
	}

	void TickGolden( float dt )
	{
		if ( GoldenActive )
		{
			GoldenTimeLeft -= dt;
			if ( GoldenTimeLeft <= 0f )
			{
				GoldenRow = GoldenCol = -1;
				Changed();
			}
			return;
		}

		_goldenTimer -= dt;
		if ( _goldenTimer > 0f ) return;

		// Randomise around the interval so it never feels metronomic
		_goldenTimer = GoldenInterval * (0.7f + (float)_rng.NextDouble() * 0.6f);
		SpawnGolden();
	}

	void TickCps( float dt )
	{
		_cpsWindow += dt;
		if ( _cpsWindow < 1.0f ) return;

		double instant = _cpsAccum / _cpsWindow;
		_cpsAccum  = 0;
		_cpsWindow = 0;

		// Smooth so a single monster chain does not spike the offline estimate
		Cps = Cps <= 0 ? instant : Cps * 0.75 + instant * 0.25;

		var s = S;
		if ( s != null && Cps > s.BestCps ) s.BestCps = Cps;

		CheckAchievements();
		Changed();
	}

	// ══════════════════════════════════════════════════════════════════════
	// Spawning
	// ══════════════════════════════════════════════════════════════════════

	void SpawnChests( int count )
	{
		var empty = new List<Cell>();
		for ( int r = 0; r < Size; r++ )
			for ( int c = 0; c < Size; c++ )
				if ( Board[r, c].IsEmpty ) empty.Add( Board[r, c] );

		if ( empty.Count == 0 ) return;

		// Shuffle so the board fills organically rather than row by row
		for ( int i = empty.Count - 1; i > 0; i-- )
		{
			int j = _rng.Next( i + 1 );
			(empty[i], empty[j]) = (empty[j], empty[i]);
		}

		int placed = Math.Min( count, empty.Count );
		for ( int i = 0; i < placed; i++ )
		{
			var cell = empty[i];
			cell.Chest    = RollChestType();
			cell.MaxHits  = IdleBalance.ChestHits( cell.Chest );
			cell.HitsLeft = cell.MaxHits;
			cell.SpawnAt  = Time.Now;
		}

		if ( placed > 0 ) Changed();
	}

	GridManager.ChestType RollChestType()
	{
		var s = S;

		// Unlocked exotic tiers get first refusal — they are the rare chunky drops
		double tierRoll = _rng.NextDouble();
		foreach ( var tier in IdleBalance.Tiers )
		{
			if ( (s?.CycleCores ?? 0) < tier.Requires ) continue;
			if ( tierRoll < tier.Weight ) return tier.Type;
			tierRoll -= tier.Weight;
		}

		// Fresh roll for the two staples, so Gem Seeding and Volatile Cargo really
		// do hit the percentages their tooltips promise instead of being squeezed
		// into whatever probability the tier pass left over.
		double roll = _rng.NextDouble();

		float bombChance = IdleBalance.BombChestChance( Lvl( IdleUpgradeId.BombChestChance ) );
		float gemChance  = IdleBalance.GemChance( Lvl( IdleUpgradeId.GemChance ) );

		if ( roll < bombChance ) return GridManager.ChestType.BombChest;
		roll -= bombChance;

		if ( roll < gemChance ) return GridManager.ChestType.Gem;

		return GridManager.ChestType.Normal;
	}

	void SpawnGolden()
	{
		int r = _rng.Next( Size );
		int c = _rng.Next( Size );
		GoldenRow = r;
		GoldenCol = c;
		GoldenTimeLeft = IdleBalance.GoldenLifetime;

		Sound.Play( "secret_chest_alert" );
		Toast( "🌟", "Golden Chest — grab it!", "idle-toast-gold" );
		Changed();
	}

	// ══════════════════════════════════════════════════════════════════════
	// Detonation
	// ══════════════════════════════════════════════════════════════════════

	/// <summary>
	/// Greedy targeting: repeatedly take the cell whose pattern covers the most
	/// still-uncovered chest value. Cheap, and it reads as "smart" on screen.
	/// </summary>
	List<(int r, int c)> PickTargets( int count, List<(int dr, int dc)> pattern )
	{
		var chosen  = new List<(int r, int c)>();
		var covered = new HashSet<(int, int)>();

		for ( int n = 0; n < count; n++ )
		{
			double bestScore = 0;
			(int r, int c) best = (-1, -1);

			for ( int r = 0; r < Size; r++ )
				for ( int c = 0; c < Size; c++ )
				{
					if ( chosen.Contains( (r, c) ) ) continue;

					double score = 0;
					foreach ( var (dr, dc) in pattern )
					{
						int nr = r + dr, nc = c + dc;
						if ( nr < 0 || nr >= Size || nc < 0 || nc >= Size ) continue;
						if ( covered.Contains( (nr, nc) ) ) continue;

						var cell = Board[nr, nc];
						if ( !cell.HasChest ) continue;

						// Bomb chests are worth chasing for the chain, not the loot
						double weight = IdleBalance.ChestBaseValue( cell.Chest );
						if ( cell.Chest == GridManager.ChestType.BombChest ) weight += 6;
						if ( cell.Chest == GridManager.ChestType.Void )      weight += 10;

						// Multi-hit chests only pay when the last hit lands
						score += weight / Math.Max( 1, cell.HitsLeft );
					}

					// Slight preference for empty cells so drones look placed, not stacked
					if ( Board[r, c].IsEmpty ) score *= 1.05;

					if ( score > bestScore ) { bestScore = score; best = (r, c); }
				}

			if ( best.r < 0 ) break;
			if ( bestScore <= 0 && chosen.Count > 0 ) break;   // nothing left worth hitting

			chosen.Add( best );
			foreach ( var (dr, dc) in pattern )
			{
				int nr = best.r + dr, nc = best.c + dc;
				if ( nr < 0 || nr >= Size || nc < 0 || nc >= Size ) continue;
				covered.Add( (nr, nc) );
			}
		}

		return chosen;
	}

	// Secondary blast shapes, built once — these fire thousands of times per minute
	static readonly List<(int dr, int dc)> CrossOffsets = IdleBalance.BombChestOffsets().ToList();
	static readonly List<(int dr, int dc)> VoidOffsets = new()
	{
		(-1,-1), (-1,0), (-1,1),
		( 0,-1), ( 0,0), ( 0,1),
		( 1,-1), ( 1,0), ( 1,1),
	};

	void Detonate( List<(int r, int c)> origins, List<(int dr, int dc)> pattern, double payoutMult, bool manual )
	{
		float now = Time.Now;

		// kind: 0 = drone payload · 1 = bomb chest cross · 2 = void 3×3
		var queue   = new Queue<(int r, int c, int kind, int depth)>();
		var fired   = new HashSet<(int, int)>();
		var popped  = new List<(int r, int c, GridManager.ChestType type)>();

		foreach ( var o in origins )
		{
			fired.Add( o );
			queue.Enqueue( (o.r, o.c, 0, 0) );

			var cell = Board[o.r, o.c];
			cell.HasDrone   = true;
			cell.FlashKind  = 3;
			cell.FlashUntil = now + 0.30f;
		}

		int guard = 0;
		int maxDepth = 0;

		while ( queue.Count > 0 && guard++ < 4000 )
		{
			var (br, bc, kind, depth) = queue.Dequeue();
			if ( depth > maxDepth ) maxDepth = depth;

			var offsets = kind switch
			{
				0 => pattern,
				2 => VoidOffsets,
				_ => CrossOffsets,
			};

			foreach ( var (dr, dc) in offsets )
			{
				int nr = br + dr, nc = bc + dc;
				if ( nr < 0 || nr >= Size || nc < 0 || nc >= Size ) continue;

				var cell = Board[nr, nc];

				if ( cell.FlashKind < 2 )
				{
					cell.FlashKind  = 1;
					cell.FlashUntil = now + 0.28f;
				}

				if ( !cell.HasChest ) continue;

				cell.HitsLeft--;
				if ( cell.HitsLeft > 0 ) continue;

				var type = cell.Chest;
				popped.Add( (nr, nc, type) );

				bool secondary = type == GridManager.ChestType.BombChest
							  || type == GridManager.ChestType.Void;

				cell.Chest      = GridManager.ChestType.None;
				cell.HitsLeft   = 0;
				cell.FlashKind  = 2;
				cell.FlashUntil = now + 0.34f;

				if ( secondary && !fired.Contains( (nr, nc) ) )
				{
					fired.Add( (nr, nc) );
					queue.Enqueue( (nr, nc, type == GridManager.ChestType.Void ? 2 : 1, depth + 1) );
				}
			}
		}

		// Clear drone markers — they only exist for the flash frame
		foreach ( var o in origins ) Board[o.r, o.c].HasDrone = false;

		if ( popped.Count == 0 )
		{
			Changed();
			return;
		}

		PayOut( popped, payoutMult, manual, maxDepth );
	}

	void PayOut( List<(int r, int c, GridManager.ChestType type)> popped, double payoutMult, bool manual, int depth )
	{
		var s = S;
		if ( s == null ) return;

		double valueMult = IdleBalance.ChestValueMult( Lvl( IdleUpgradeId.ChestValue ) );

		double raw = 0;
		foreach ( var p in popped )
			raw += IdleBalance.ChestBaseValue( p.type ) * valueMult;

		int    chain     = popped.Count;
		double chainMult = IdleBalance.ChainMultiplier( chain, ChainExponent );

		bool crit = _rng.NextDouble() < IdleBalance.CritChance( Lvl( IdleUpgradeId.CritChance ) );
		double critMult = crit ? IdleBalance.CritPower( Lvl( IdleUpgradeId.CritPower ) ) : 1.0;

		double total = raw * chainMult * critMult * payoutMult * GlobalMultiplier;

		AddCores( total );

		s.TotalDetonations++;
		s.TotalChestsBlown += chain;
		if ( chain > s.BestChain ) s.BestChain = chain;
		if ( total > s.BestSingleBlast ) s.BestSingleBlast = total;

		LastChain    = chain;
		LastPayout   = total;
		LastWasCrit  = crit;
		LastPayoutAt = Time.Now;

		// Per-chest floaters, but capped so a 60-chest wipe does not flood the layer
		int floaters = Math.Min( popped.Count, 8 );
		double per   = total / Math.Max( 1, popped.Count );
		for ( int i = 0; i < floaters; i++ )
			OnPop?.Invoke( popped[i].r, popped[i].c, per, crit );

		PlayDetonationAudio( chain, crit );
		OnDetonation?.Invoke( chain, total, crit );

		if ( chain >= 12 )
			Toast( crit ? "☢️" : "⛓️",
				   $"{chain} chest chain · +{IdleFormat.Short( total )}",
				   crit ? "idle-toast-crit" : "idle-toast-chain" );

		Changed();
	}

	void PlayDetonationAudio( int chain, bool crit )
	{
		float now = Time.Now;

		// Hard throttle — cycles can run 2.5×/second at max Fuse Timing
		if ( now - _lastBlastSound < 0.30f ) return;
		_lastBlastSound = now;

		string sfx = chain switch
		{
			>= 30 => "climax_explosion_lv5",
			>= 18 => "climax_explosion_lv4",
			>= 10 => "climax_explosion_lv3",
			>= 4  => "climax_explosion_lv2",
			_     => "climax_explosion_lv1",
		};

		var h = Sound.Play( sfx );
		h.Volume = MathF.Min( 0.55f, 0.18f + chain * 0.012f );

		if ( crit && now - _lastCoinSound > 0.6f )
		{
			_lastCoinSound = now;
			Sound.Play( "multiple_coins_earned" );
		}
	}

	// ══════════════════════════════════════════════════════════════════════
	// Currency
	// ══════════════════════════════════════════════════════════════════════

	void AddCores( double amount )
	{
		if ( amount <= 0 ) return;

		var s = S;
		s.Cores        += amount;
		s.CycleCores   += amount;
		s.AllTimeCores += amount;
		_cpsAccum      += amount;

		CondenseFragments( amount );
	}

	/// <summary>Turns a slice of Core income into Fragments for the main-game shop.</summary>
	void CondenseFragments( double coresEarned )
	{
		var s = S;
		s.EnsureFragDay();

		int cap = DailyFragmentCap;
		if ( s.TodayReactorFragments >= cap )
		{
			// Bank at most one Fragment worth so tomorrow starts primed, not rich
			s.FragProgress = Math.Min( s.FragProgress, FragmentCost );
			return;
		}

		s.FragProgress += coresEarned;

		int granted = 0;

		// Price climbs with each Fragment, so it is recomputed every iteration
		while ( s.TodayReactorFragments < cap && granted < 100 )
		{
			double cost = IdleBalance.FragmentCost(
				Lvl( IdleUpgradeId.FragmentYield ),
				Perk( IdlePerkId.FragmentCondenser ),
				s.TodayReactorFragments );

			if ( s.FragProgress < cost ) break;

			s.FragProgress -= cost;
			s.TodayReactorFragments++;
			granted++;
		}

		if ( granted > 0 )
		{
			Player?.AddReactorFragments( granted, persist: false );
			if ( Time.Now - _lastCoinSound > 0.8f )
			{
				_lastCoinSound = Time.Now;
				Sound.Play( "fragments_coins" );
			}
			Toast( "💠", $"+{granted} Fragment{(granted > 1 ? "s" : "")}", "idle-toast-frag" );
		}
	}

	// ══════════════════════════════════════════════════════════════════════
	// Manual interaction
	// ══════════════════════════════════════════════════════════════════════

	/// <summary>The big red button — an instant, hard-hitting extra cycle.</summary>
	public void Overload( bool auto = false )
	{
		if ( !IsActive || !OverloadReady ) return;

		_overloadCooldownLeft = IdleBalance.OverloadCooldown( Lvl( IdleUpgradeId.OverloadCooldown ) );

		if ( _armTimer > 0f )
		{
			// A volley is already sitting on the board — set it off early, harder
			_armedMult = Math.Max( _armedMult, IdleBalance.OverloadMultiplier );
			FireArmed();
		}
		else
		{
			SpawnChests( IdleBalance.SpawnCount( Lvl( IdleUpgradeId.SpawnCount ) ) );
			ArmVolley( IdleBalance.OverloadMultiplier, 0.16f );   // snappier than the ambient cycle
			_cycleTimer = CycleInterval;
		}

		if ( !SurgeActive )
		{
			Charge = MathF.Min( 1f, Charge +
				IdleBalance.ChargeGain( Lvl( IdleUpgradeId.ChargeGain ), Perk( IdlePerkId.Momentum ) ) );

			if ( Charge >= 1f )
			{
				Sound.Play( "Notification_1" );
				Toast( "⚡", "SURGE READY", "idle-toast-surge" );
			}
		}

		if ( !auto ) Sound.Play( "detonate_1" );
		Changed();
	}

	/// <summary>Cash in a full Surge meter: a short window of enormous output.</summary>
	public void ActivateSurge()
	{
		if ( !IsActive || !SurgeReady ) return;

		SurgeActive   = true;
		SurgeTimeLeft = IdleBalance.SurgeDuration( Perk( IdlePerkId.Momentum ) );
		Charge        = 0f;

		Sound.Play( "fanfare_100" );
		Toast( "🔥", $"SURGE — ×{IdleBalance.SurgeMultiplier:F0} for {SurgeTimeLeft:F0}s", "idle-toast-surge" );
		Changed();
	}

	/// <summary>Clicking the board: grab a Golden Chest, or crack a chest by hand.</summary>
	public void ClickCell( int r, int c )
	{
		if ( !IsActive ) return;
		if ( r < 0 || r >= Size || c < 0 || c >= Size ) return;

		if ( GoldenActive && GoldenRow == r && GoldenCol == c )
		{
			GrabGolden();
			return;
		}

		var cell = Board[r, c];
		if ( !cell.HasChest ) return;

		cell.HitsLeft--;
		cell.FlashKind  = 1;
		cell.FlashUntil = Time.Now + 0.2f;

		if ( cell.HitsLeft <= 0 )
		{
			var popped = new List<(int, int, GridManager.ChestType)> { (r, c, cell.Chest) };
			cell.Chest      = GridManager.ChestType.None;
			cell.HitsLeft   = 0;
			cell.FlashKind  = 2;
			cell.FlashUntil = Time.Now + 0.3f;

			// Hand-cracked chests pay a flat bonus rather than a chain — they are
			// a fidget, never a better strategy than upgrading the reactor.
			PayOut( popped, 2.0, manual: true, depth: 0 );
		}
		else
		{
			Sound.Play( "Pop_Button_1" );
			Changed();
		}
	}

	void GrabGolden()
	{
		var s = S;
		GoldenRow = GoldenCol = -1;
		GoldenTimeLeft = 0f;
		s.GoldenGrabbed++;

		// Pays a chunk of real output, with a floor so it matters on day one
		double payout = Math.Max( 250.0, Cps * IdleBalance.GoldenSecondsOfOutput );
		AddCores( payout );

		// Plus a short multiplier — the reason to keep the tab focused
		AddBoost( "Golden Rush", "🌟", 3.0, 30f );

		Sound.Play( "open_last_chest" );
		Sound.Play( "coins_earned" );
		Toast( "🌟", $"Golden Chest · +{IdleFormat.Short( payout )} · ×3 for 30s", "idle-toast-gold" );

		CheckAchievements();
		Changed();
	}

	public void AddBoost( string label, string icon, double mult, float seconds )
	{
		var existing = Boosts.FirstOrDefault( b => b.Label == label );
		if ( existing != null )
		{
			existing.SecondsLeft = MathF.Max( existing.SecondsLeft, seconds );
			return;
		}

		Boosts.Add( new Boost { Label = label, Icon = icon, Mult = mult, SecondsLeft = seconds } );
		Changed();
	}

	// ══════════════════════════════════════════════════════════════════════
	// Shopping
	// ══════════════════════════════════════════════════════════════════════

	public double CostFor( IdleUpgradeId id, int count )
	{
		var up = IdleUpgradeCatalog.Get( id );
		if ( up == null ) return double.MaxValue;

		int level = Lvl( id );
		double total = 0;
		for ( int i = 0; i < count; i++ )
		{
			if ( level + i >= up.MaxLevel ) break;
			total += up.CostAt( level + i );
		}
		return total;
	}

	/// <summary>How many levels the current Core balance can afford, capped at 250.</summary>
	public int MaxAffordable( IdleUpgradeId id )
	{
		var up = IdleUpgradeCatalog.Get( id );
		if ( up == null ) return 0;

		int level = Lvl( id );
		double budget = S?.Cores ?? 0;
		int n = 0;

		while ( level + n < up.MaxLevel && n < 250 )
		{
			double c = up.CostAt( level + n );
			if ( c > budget ) break;
			budget -= c;
			n++;
		}
		return n;
	}

	public bool CanAfford( IdleUpgradeId id, int count )
	{
		var up = IdleUpgradeCatalog.Get( id );
		if ( up == null ) return false;
		if ( Lvl( id ) >= up.MaxLevel ) return false;
		return (S?.Cores ?? 0) >= CostFor( id, count );
	}

	public bool TryBuy( IdleUpgradeId id, int count = 1 )
	{
		var s = S;
		var up = IdleUpgradeCatalog.Get( id );
		if ( s == null || up == null ) return false;

		int level = s.Level( id );
		if ( level >= up.MaxLevel ) return false;

		int bought = 0;
		double spent = 0;

		for ( int i = 0; i < count; i++ )
		{
			if ( level + bought >= up.MaxLevel ) break;
			double c = up.CostAt( level + bought );
			if ( s.Cores - spent < c ) break;
			spent += c;
			bought++;
		}

		if ( bought == 0 ) return false;

		s.Cores -= spent;
		s.SetLevel( id, level + bought );

		if ( id == IdleUpgradeId.GridSize )
		{
			ResizeBoard( IdleBalance.GridSize( s.Level( IdleUpgradeId.GridSize ) ) );
			Sound.Play( "whoosh_deep_wave_milestone" );
			Toast( "🔲", $"Reactor expanded to {Size}×{Size}", "idle-toast-big" );
		}
		else if ( id == IdleUpgradeId.BlastSize )
		{
			Sound.Play( "Purchase_Success" );
			Toast( "💥", $"Warhead upgraded — {IdleBalance.BlastName( s.Level( id ) )}", "idle-toast-big" );
		}
		else
		{
			Sound.Play( "BuyShop" );
		}

		Player?.Save();
		Changed();
		return true;
	}

	public bool CanBuyPerk( IdlePerkId id )
	{
		var s = S;
		var perk = IdleUpgradeCatalog.GetPerk( id );
		if ( s == null || perk == null ) return false;

		int lvl = s.PerkLevel( id );
		if ( lvl >= perk.MaxLevel ) return false;
		return s.Flux >= perk.CostAt( lvl );
	}

	public bool TryBuyPerk( IdlePerkId id )
	{
		if ( !CanBuyPerk( id ) ) return false;

		var s = S;
		var perk = IdleUpgradeCatalog.GetPerk( id );
		int lvl = s.PerkLevel( id );

		s.Flux -= perk.CostAt( lvl );
		s.SetPerkLevel( id, lvl + 1 );

		Sound.Play( "Purchase_Success" );
		Toast( perk.Icon, $"{perk.Name} → Lv {lvl + 1}", "idle-toast-flux" );

		Player?.Save();
		Changed();
		return true;
	}

	// ══════════════════════════════════════════════════════════════════════
	// Meltdown (prestige)
	// ══════════════════════════════════════════════════════════════════════

	public int PendingFlux => IdleBalance.FluxFor( S?.CycleCores ?? 0 );
	public bool CanMeltdown => PendingFlux > 0;

	/// <summary>Progress toward the very first Meltdown, for the intro bar.</summary>
	public float MeltdownProgress
		=> (float)Math.Clamp( (S?.CycleCores ?? 0) / IdleBalance.MeltdownRequirement, 0.0, 1.0 );

	public void DoMeltdown()
	{
		var s = S;
		if ( s == null || !CanMeltdown ) return;

		int gained = PendingFlux;
		s.Flux       += gained;
		s.FluxEarned += gained;
		s.ResetForMeltdown();

		ResizeBoard( IdleBalance.GridSize( s.Level( IdleUpgradeId.GridSize ) ) );

		for ( int r = 0; r < Size; r++ )
			for ( int c = 0; c < Size; c++ )
			{
				Board[r, c].Chest    = GridManager.ChestType.None;
				Board[r, c].HitsLeft = 0;
			}

		Boosts.Clear();
		SurgeActive = false;
		Charge      = 0f;
		Cps         = 0;
		s.MeasuredCps = 0;
		_cpsAccum = 0; _cpsWindow = 0;
		_spawnTimer = 0.2f;
		_cycleTimer = CycleInterval;

		// A fresh Meltdown deserves a running start
		AddBoost( "Fresh Core", "☢️", 5.0, 60f );
		SpawnChests( Math.Max( 4, Size ) );

		Sound.Play( "fanfare_200" );
		Sound.Play( "applause_1" );
		Toast( "☢️", $"MELTDOWN · +{gained} Flux", "idle-toast-flux" );

		CheckAchievements();
		Player?.Save();
		Changed();
	}

	// ══════════════════════════════════════════════════════════════════════
	// Offline
	// ══════════════════════════════════════════════════════════════════════

	void ComputeOffline()
	{
		var s = S;
		if ( s == null ) return;

		long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();

		if ( s.LastSeenUnix <= 0 )
		{
			s.LastSeenUnix = now;
			return;
		}

		// Never pay for wall-clock time spent with the game shut. After a restart
		// the process start is later than the stored stamp, so elapsed collapses
		// to roughly zero.
		long since = Math.Max( s.LastSeenUnix, _processStartUnix );
		double elapsed = Math.Max( 0, now - since );
		s.LastSeenUnix = now;

		if ( elapsed < IdleBalance.OfflineMinSeconds ) return;
		if ( s.MeasuredCps <= 0 ) return;

		double capped = Math.Min( elapsed, OfflineCapHours * 3600.0 );
		double cores  = s.MeasuredCps * capped * OfflineEfficiency;
		if ( cores <= 0 ) return;

		// Accumulate rather than assign — quitting with an unclaimed report must
		// not throw the previous haul away.
		s.PendingOfflineCores   += cores;
		s.PendingOfflineSeconds += elapsed;

		// Fragments are previewed here and actually granted on claim
		s.EnsureFragDay();
		s.PendingOfflineFrags = PreviewFragmentsFor( s.FragProgress + cores );
	}

	// ── Menu-side preview (works without the mode being open) ──────────────

	/// <summary>Real seconds since the reactor was last attended.</summary>
	public static double PreviewOfflineSeconds( IdleSave s )
	{
		if ( s == null || s.LastSeenUnix <= 0 ) return 0;
		long since = Math.Max( s.LastSeenUnix, _processStartUnix );
		return Math.Max( 0, DateTimeOffset.UtcNow.ToUnixTimeSeconds() - since );
	}

	/// <summary>What the welcome-back panel would pay right now.</summary>
	public static double PreviewOfflineCores( IdleSave s )
	{
		if ( s == null || s.MeasuredCps <= 0 ) return 0;

		double elapsed = PreviewOfflineSeconds( s );
		if ( elapsed < IdleBalance.OfflineMinSeconds ) return 0;

		float capHours = IdleBalance.OfflineCapHours(
			s.Level( IdleUpgradeId.OfflineCap ), s.PerkLevel( IdlePerkId.DeepStorage ) );
		float eff = IdleBalance.OfflineEfficiency(
			s.Level( IdleUpgradeId.OfflineEfficiency ), s.PerkLevel( IdlePerkId.NightShift ) );

		double capped = Math.Min( elapsed, capHours * 3600.0 );
		return s.PendingOfflineCores + s.MeasuredCps * capped * eff;
	}

	/// <summary>0..1 fill of the offline storage tank, for the menu bar.</summary>
	public static float PreviewStorageFraction( IdleSave s )
	{
		if ( s == null ) return 0f;
		float capHours = IdleBalance.OfflineCapHours(
			s.Level( IdleUpgradeId.OfflineCap ), s.PerkLevel( IdlePerkId.DeepStorage ) );
		if ( capHours <= 0f ) return 0f;
		return (float)Math.Clamp( PreviewOfflineSeconds( s ) / (capHours * 3600.0), 0.0, 1.0 );
	}

	/// <summary>True when a new daily streak reward is waiting to be claimed.</summary>
	public static bool DailyAvailable( IdleSave s )
		=> s != null && s.Unlocked && s.LastDailyDate != DateTime.UtcNow.ToString( "yyyy-MM-dd" );

	/// <summary>Dry-run of the condenser so the offline report can promise a number.</summary>
	int PreviewFragmentsFor( double banked )
	{
		var s = S;
		if ( s == null ) return 0;

		int coil = Lvl( IdleUpgradeId.FragmentYield );
		int perk = Perk( IdlePerkId.FragmentCondenser );
		int cap  = DailyFragmentCap;

		int at = s.TodayReactorFragments;
		int got = 0;

		while ( at < cap && got < 100 )
		{
			double cost = IdleBalance.FragmentCost( coil, perk, at );
			if ( banked < cost ) break;
			banked -= cost;
			at++;
			got++;
		}
		return got;
	}

	public double PendingOfflineCores   => S?.PendingOfflineCores ?? 0;
	public double PendingOfflineSeconds => S?.PendingOfflineSeconds ?? 0;
	public int    PendingOfflineFrags   => S?.PendingOfflineFrags ?? 0;

	public void ClaimOffline( bool doubled = false )
	{
		var s = S;
		if ( s == null || s.PendingOfflineCores <= 0 ) return;

		double cores = s.PendingOfflineCores * (doubled ? 2.0 : 1.0);

		s.PendingOfflineCores   = 0;
		s.PendingOfflineSeconds = 0;
		s.PendingOfflineFrags   = 0;

		AddCores( cores );

		Sound.Play( "coins_earned" );
		Sound.Play( "Whoosh_Stat" );
		Toast( "🛢️", $"Collected {IdleFormat.Short( cores )} Cores", "idle-toast-big" );

		Player?.Save();
		Changed();
	}

	// ══════════════════════════════════════════════════════════════════════
	// Daily streak
	// ══════════════════════════════════════════════════════════════════════

	void CheckDaily()
	{
		var s = S;
		if ( s == null ) return;

		string today = DateTime.UtcNow.ToString( "yyyy-MM-dd" );
		if ( s.LastDailyDate == today )
		{
			DailyPending = false;
			return;
		}

		string yesterday = DateTime.UtcNow.AddDays( -1 ).ToString( "yyyy-MM-dd" );
		s.DailyStreak = s.LastDailyDate == yesterday ? Math.Min( 7, s.DailyStreak + 1 ) : 1;

		DailyStreakDay = s.DailyStreak;
		DailyPending   = true;
		s.DailyClaimed = false;
	}

	public void ClaimDaily()
	{
		var s = S;
		if ( s == null || !DailyPending ) return;

		var (mult, seconds, frags) = IdleBalance.DailyReward( s.DailyStreak );

		s.LastDailyDate = DateTime.UtcNow.ToString( "yyyy-MM-dd" );
		s.DailyClaimed  = true;
		DailyPending    = false;

		AddBoost( $"Day {s.DailyStreak} Bonus", "📅", mult, seconds );

		Player?.AddReactorFragments( frags );

		Sound.Play( "gift" );
		Sound.Play( "fireworks" );
		Toast( "📅", $"Day {s.DailyStreak} · ×{mult:F1} for {IdleFormat.Duration( seconds )} · +{frags} 💠", "idle-toast-big" );

		Player?.Save();
		Changed();
	}

	// ══════════════════════════════════════════════════════════════════════
	// Achievements
	// ══════════════════════════════════════════════════════════════════════

	public void CheckAchievements()
	{
		var s = S;
		if ( s == null ) return;

		var done = IdleAchievements.CollectNewlyCompleted( s );
		if ( done.Count == 0 ) return;

		foreach ( var a in done )
		{
			if ( a.RewardFlux > 0 )
			{
				s.Flux       += a.RewardFlux;
				s.FluxEarned += a.RewardFlux;
			}
			if ( a.RewardFrags > 0 )
				Player?.AddReactorFragments( a.RewardFrags );

			string reward = a.RewardFlux > 0 && a.RewardFrags > 0
				? $"+{a.RewardFlux} 🔷 +{a.RewardFrags} 💠"
				: a.RewardFlux > 0 ? $"+{a.RewardFlux} 🔷" : $"+{a.RewardFrags} 💠";

			Toast( a.Icon, $"{a.Name} · {reward}", "idle-toast-achievement" );
		}

		Sound.Play( "pearl_stars" );
		Player?.Save();
	}

	// ══════════════════════════════════════════════════════════════════════
	// Unlock gating helpers used by the HUD
	// ══════════════════════════════════════════════════════════════════════

	/// <summary>An upgrade only shows once the player has been near its price.</summary>
	public bool IsRevealed( IdleUpgrade up )
	{
		if ( up.RevealAt <= 0 ) return true;
		var s = S;
		if ( s == null ) return false;
		return s.AllTimeCores >= up.RevealAt || s.Level( up.Id ) > 0;
	}

	public IEnumerable<IdleBalance.ChestTier> UnlockedTiers
		=> IdleBalance.Tiers.Where( t => (S?.CycleCores ?? 0) >= t.Requires );

	public IdleBalance.ChestTier NextTier
		=> IdleBalance.Tiers.FirstOrDefault( t => (S?.CycleCores ?? 0) < t.Requires );
}