Game/2D/ChainReactionGame.cs

Main game component for the Chain Reaction game mode. Manages game state, waves, spawning chests (including secrets and bosses), turn timer, survival mode, random events, consumables, challenges, scoring, persistence hooks and UI/events.

File AccessNetworking
using Sandbox;
using System;
using Sandbox.Services;
using System.Collections;
public sealed class ChainReactionGame : Component
{
	[Property] public GridManager Grid { get; set; }
	[Property] public BombHandSystem Hand { get; set; }

	// ── State ─────────────────────────────────────────────────────────────
	public int Wave { get; private set; } = 1;
	public int Score { get; private set; }
	public int BestScore { get; private set; } = 0;
	public int Lives { get; private set; } = 1;
	public int WaveTarget { get; private set; }
	public int WaveScore { get; private set; }
	public int MinChests { get; private set; } = 1;
	public float TurnTime { get; private set; }   // seconds left this turn
	public bool TimerActive { get; private set; }
	public bool IsRunning { get; private set; }
	public bool IsGameOver { get; private set; }

	public Random Rng { get; } = new Random();
	public static ChainReactionGame Instance { get; private set; }

	// ── Events ────────────────────────────────────────────────────────────
	public delegate void StateChanged();
	public delegate void ChestDestroyed( int r, int c, int coins, float mult );
	public event StateChanged OnStateChanged;
	public event ChestDestroyed OnChestDestroyed2;
	public delegate void ChallengeResolved( bool completed, int bonus );
	public event ChallengeResolved OnChallengeResolved;
	public delegate void BossKilled(int scoreBonus, int fragBonus);
	public event BossKilled OnBossKilled;
	public delegate void ScreenFlashRequest(string type, int wave);
	public event ScreenFlashRequest OnScreenFlashRequest;

	// For music & potential other things
	public delegate void GameStartedEvent();
	public delegate void GameReturnedEvent();
	public event GameStartedEvent OnGameStarted;
	public event GameReturnedEvent OnGameReturned;

	// ── Config ────────────────────────────────────────────────────────────
	public const int StartLives = 1;
	public const int MaxLives = 3;
	const int ChainBonus = 120;
	const float BaseTurnTime = 20f;  // seconds per turn wave 1
	const float MinTurnTime = 4f;   // floor at high waves

	// ── Challenges ─────────────────────────────────────────────────────────

	public WaveChallenge CurrentChallenge { get; private set; }

	int _secretChestsSpawnedThisRun = 0;

	int _chestsAtWaveStart = 0;

	// ── GameOver Stats ─────────────────────────────────────────────────────

	public int TotalChestsCleared { get; private set; }
	public int BestChainLength    { get; private set; }
	public int ChallengesCompleted { get; private set; }
	public int FragmentsEarned    { get; private set; }
	public List<SecretChestReward> SecretChestRewards { get; private set; } = new();
	public int PrevBestScore { get; private set; } = 0;

	// ── Lifecycle ─────────────────────────────────────────────────────────
	public bool GameStarted { get; set; } = false;

	// ── Game over ─────────────────────────────────────────────────────────
	public string GameOverReason { get; private set; } = "";

	// ── Modifiers ─────────────────────────────────────────────────────────

	bool _firstChainUsedThisWave = false;

	float _timerPenaltyNextWave = 0f; // cursed chest's timer curse

	// ── Events / Frags Count total ─────────────────────────────────────────
	public int BonusEventFragments { get; private set; } = 0; // lucky event..
	bool _luckyStreakFiredThisRun {get; set;}
	public int PerfectWaveFragments { get; private set; } = 0;
	public int BossFragments { get; private set; } = 0;

	protected override void OnStart()
	{
		Instance = this;

		// Load Save data once on boot, persist for the entire session
		Save = PlayerSave.Load();
    	Log.Info( $"Save loaded — fragments: {Save.TotalFragments}, best wave: {Save.BestWave}" );
		Initialize();

		// Wires up system once on boot, persist for the entire session
		Hand.OnDetonationFinished += HandleDetonationFinished;
		Hand.OnSecretChestDestroyed += RollSecretChestReward;

    	// Wait for components then show the menu
		_ = WaitThenShowMenu();
	}

	private void Initialize()
	{
		BestScore = Save?.BestScore ?? 0;
	}

	// ── Save System ────────────────────────────────────────────────────────

	public static PlayerSave Save { get; private set; }
	public bool HasMod(ModifierId id) => Save?.ModifierActive(id) ?? false;

	// ── GameMode ────────────────────────────────────────────────────────

	public GameMode Mode { get; private set; } = GameMode.Wave;

	// ── Survival mode state ──────────────────────────────────────────────
	float _survivalSpawnTimer = 0f;
	float _survivalSpawnInterval = 2.2f;
	float _survivalElapsed = 0f;
	bool _survivalSpawnFrozen = false; // pauses spawning while milestone overlay shows
	public float SurvivalSpawnTimerFraction => _survivalSpawnInterval > 0f
    ? 1f - (_survivalSpawnTimer / _survivalSpawnInterval) : 0f;
	public float SurvivalElapsed => _survivalElapsed;
	public int SurvivalSpawnCount { get; private set; } = 0;
	int _survivalFakeWave = 1;
	public int SurvivalFakeWave => _survivalFakeWave;
	public List<GridManager.BombType> SurvivalBombPool { get; private set; } = null;

	const float SurvivalMinInterval = 0.6f; // fastest spawn rate — tune to taste

	// ── Survival milestones ──────────────────────────────────────────────
	int _survivalLastMilestoneMinute = 0;
	public int SurvivalBonusBombSlots { get; private set; } = 0;
	const int MaxSurvivalBonusSlots = 2; // caps how far hand size can grow beyond base

	// ── Survival: continuous difficulty curve ───────────────────────
	const float SurvivalIntervalStart = 4.2f;  // was 2.2f — much gentler opening
	const float SurvivalIntervalFloor = 1.0f;  // was 0.9f
	const float SurvivalRampMinutes   = 9f;    // was 8f
	const float SurvivalEmptyGraceSeconds = 0.8f; // caps true dead-air time on an empty board
	float _survivalEmptySince = -1f; // -1 = board isn't currently empty

	const int SurvivalBatchMaxStart = 5;  // unchanged
	const int SurvivalBatchMaxFloor = 4;  // was 3 — kept slightly higher so late-game doesn't go flat
	const int SurvivalBatchMinStart = 3;  // unchanged
	const int SurvivalBatchMinFloor = 3;  // was 2 — same reasoning

	// ── Survival: per-minute flavor modifiers (reset each minute) ──
	float _survivalPhaseIntervalMult = 1f;
	int   _survivalPhaseBatchBonus   = 0;
	bool  _survivalGoldRushMinute    = false;

// ── Survival: score surge flavor ────────────────────────────────
float _survivalScoreBoostUntil = 0f;
const float SurvivalScoreBoostMult = 2f;


	// ── Leaderboard ────────────────────────────────────────────────────────

	float _sessionStartTime = 0f;
	async void SubmitLeaderboard( int score, int wave, int previousBestChainLength)
	{
		try
		{
			// s&box uses Stats for submission, Leaderboards for reading
			if ( score >= Save.BestScore )
				Stats.Increment( "chain_reaction_score", score );

			if ( wave >= Save.BestWave )
				Stats.Increment( "chain_reaction_wave", wave );

			if ( BestChainLength >= previousBestChainLength)
				Stats.Increment("chain_reaction_best_chain", BestChainLength);

			// Submit top Pets total xp (including level)
			int petScore = (Save.PetLevel * 1_000_000) + Save.PetXp;
			Stats.Increment("chain_reaction_pet", petScore);

			// Top sequence chain reaction + scores (in case multiple have same sequence)
			int chainScore = (BestChainLength * 10_000_000) + Score;
			Stats.Increment("chain_reaction_chain", chainScore);

			// medal tier (top 100 force contributor)
			int medalTier = Save?.ForceMedalTier ?? 0;
			if(medalTier > 0)
				Stats.Increment("chain_reaction_medal", medalTier);

			Log.Info( $"Stats submitted — score: {score}, wave: {wave}" );
		}
		catch ( System.Exception e )
		{
			Log.Warning( $"Leaderboard submit failed: {e.Message}" );
		}
	}

	// ── Gameplay ────────────────────────────────────────────────────────


	// Delay spawning chest & stuff (until tiles are created)
	async System.Threading.Tasks.Task WaitThenShowMenu()
	{
		// Wait for all components to initialize before firing first UI update
		await GameTask.DelaySeconds( 0.15f );
		OnStateChanged?.Invoke();
	}

	protected override void OnUpdate()
	{
		// Pause timer during tutorial
    	if (TutorialOverlay.Instance?.IsActive == true) return;
		if (!IsRunning || Hand.IsDetonating) return;

		// Survival has its own independent tick — doesn't use TimerActive at all
		if (Mode == GameMode.Survival)
		{
			TickSurvival();
			return;
		}

		// Handle timer freeze event
		if (_timerFrozenFor > 0f)
		{
			_timerFrozenFor -= Time.Delta;
			OnStateChanged?.Invoke();
			return; // don't tick timer while frozen
		}

		TurnTime -= Time.Delta;
		OnStateChanged?.Invoke(); // tick every frame for the timer bar

		// Hype meter passive drain — always drains slowly, faster during hype max
		if (_hypeMeter > 0f && IsRunning && MilestoneOverlay.Instance?.IsActive != true)
		{
			float drainRate = _hypeMaxActive ? 0.08f : 0.012f; // normal: ~80s to drain, max: ~12s
			_hypeMeter = MathF.Max(0f, _hypeMeter - drainRate * Time.Delta);

			if (_hypeMaxActive && _hypeMeter <= 0f)
			{
				_hypeMaxActive = false;
				_hypeTriggeredThisWave = false;
			}

			OnStateChanged?.Invoke();
		}

		// Trigger Next Wave from Time Out :
		// 1 - (Detonate (if any bomb - can still lose if conditions are not met) 
		// 2 - OR lose a live right of the bat and go next wave)
		if ( TurnTime <= 0f )
		{
			TurnTime = 0f;
			TimerActive = false;
			// Time's up — force detonate whatever is placed, or lose a life
			if ( Hand.AnyPlaced )
				Hand.ExecuteDetonate();
			else
				HandleTimeUp();
		}
	}

	// If no bomb was on any tile when time was up.. automatic result - Next Wave trigger
	void HandleTimeUp()
	{
		Lives--;

		// Check game over 
		if ( Lives <= 0 ) { 
			TriggerGameOver("⏱️ Time ran out and lives reached zero! Be faster next time my friend :)"); 
			return; 
			
		} 

		// Inform player why he lost a life
		MilestoneOverlay.Instance?.ShowMessage("⏱️ Time's up — life lost!", Wave);
		Sound.Play("TimeIsUp");

		Wave++;
		WaveScore = 0;
		WaveTarget = ComputeWaveTarget( Wave );
		MinChests = MinChestsForWave( Wave );

		SlideChestsDown();

		// Same check here — time-up also slides chests down
		if ( Grid.AnyChestInLastRow() ) { TriggerGameOver("📦 A chest reached the bottom row! Too bad.. hehe"); return; }

		SpawnChests( ChestsForWave( Wave ) );
		Hand.DrawHand( Wave );
		StartTurnTimer();
		OnStateChanged?.Invoke();
	}

	// ── Public API ────────────────────────────────────────────────────────
	public void StartGame(GameMode mode, List<GridManager.BombType> survivalBombPool)
	{
		Mode = mode;
		SurvivalBombPool = survivalBombPool;
	    Save = PlayerSave.Load(); // reload fresh before each run

		 // Safety — BombSlot2 is a permanent welcome gift, always ensure it's granted
		if(!Save.OwnsModifier(ModifierId.BombSlot2))
		{
			Save.OwnedModifiers.Add("BombSlot2");
			Save.OwnedBombs.Add(GridManager.BombType.Diagonal.ToString());
			Save.Save();
			Log.Info("StartGame — BombSlot2 was missing, re-granted");
		}

    	GameStarted = true;
		OnGameStarted?.Invoke();

		// Reset random event state
		ClearRandomEvent();
		_lastEventWave              = 0;
		_consecutiveWavesNoLifeLoss = 0;
		_consecutiveFailedWaves     = 0;
		_livesAtWaveStart           = StartLives;
		_chainMasterFiredThisRun = false;
		BonusEventFragments = 0;
		_luckyStreakFiredThisRun = false;
		PerfectWaveFragments = 0;
		BossFragments = 0;

		// Base first wave data 
		Wave = 1;
		CurrentChallenge = RollChallenge( Wave ); // new challenge
		Score = 0;
		// Level 2 - Bonus Pet + Level 6 - Extra bonus life
		Lives = StartLives 
			+ (Save?.PetLevel >= 2 ? 1 : 0)
			+ (Save?.PetLevel >= 6 ? 1 : 0);
		WaveScore = 0;
		IsRunning = true;
		IsGameOver = false;
		_bossAlive = false;
		_secretChestsSpawnedThisRun = 0; // secret chest
		WaveTarget = ComputeWaveTarget( Wave );
		MinChests = MinChestsForWave( Wave );
		MilestoneOverlay.Instance?._queue.Clear(); // clear Milestone queue

		// cursed chest 
		_activeCursedChests = 0;
		_timerPenaltyNextWave = 0f;

		// mod 
 		_firstChainUsedThisWave = false;

		_sessionStartTime = Time.Now; // leaderboard

		// Hype bar
		_hypeMeter = 0f;
		_hypeMaxActive = false;
		_hypeTriggeredThisWave = false;

		// Game Over Stats
		TotalChestsCleared  = 0;
		BestChainLength     = 0;
		ChallengesCompleted = 0;
		FragmentsEarned     = 0;
		SecretChestRewards.Clear();

		Grid.InitGrid(); // 1. creates grid 
		if (Mode == GameMode.Survival)
    		Grid.ResizeGrid(7, 5); // taller grid — more buffer before bottom row
		else
			Grid.ResizeGrid(5, 5); // Wave Mode 
		SpawnChests( ChestsForWave( Wave ) ); // 2. spawn chests for wave -x
		TrySpawnSecretChest();  // 8% chance
		TrySpawnBossChest(); // added for consistency
		_chestsAtWaveStart = Grid.AllChestCells().Count; // retrieve all chests (num) for challenge checks
		int handSize = 1;
		if(HasMod(ModifierId.BombSlot2)) handSize = 2;
		if(HasMod(ModifierId.BombSlot3)) handSize = 3;
		if(HasMod(ModifierId.BombSlot4)) handSize = 4;
		Hand.HandSize = handSize;
		Hand.DrawHand( Wave );
		
		// if Survival Mode 
		if (Mode == GameMode.Survival)
		{
			_survivalSpawnTimer = 2.2f;
			_survivalSpawnInterval = 2.2f;
			_survivalElapsed = 0f;
			SurvivalSpawnCount = 0;
			_survivalLastMilestoneMinute = 0;
			_survivalPhase = -1;
			_survivalPhaseInitialized = false;
			_survivalEmptySince = -1f; // add this
			SurvivalBonusBombSlots = 0;
			_survivalSpawnFrozen = false;
			TimerActive = false;

			if (!Save.SurvivalTutorialCompleted)
			{
				_survivalSpawnFrozen = true; // freeze while tutorial shows
				SurvivalTutorial.Instance?.Activate();
				Save.SurvivalTutorialCompleted = true;
				Save.Save();
			}

			// survival starts with one extra slot over wave mode's base
    		Hand.HandSize = Math.Max(handSize + 1, 3);
			Hand.DrawHand(1); // redraw with the new hand size
		}
		else // Wave Mode 
		{
			StartTurnTimer();
		}
		
		OnStateChanged?.Invoke(); // fires delegate for listening classes (esp. UI updates)

		Log.Info($"StartGame — OwnedModifiers: {string.Join(", ", Save.OwnedModifiers)}, HandSize: {handSize}");

	}

	public void UnfreezeSurvivalSpawn()
	{
		_survivalSpawnFrozen = false;
		_survivalSpawnTimer = _survivalSpawnInterval;
	}

	// Calls Detonate (from Gamehud.razor button OnClickEvent)
	public void Detonate()
	{
		if ( !IsRunning || Hand.IsDetonating || !Hand.AnyPlaced ) return;
		TimerActive = false;
		Hand.ExecuteDetonate();
	}

	// Mainly call for UI updates
	public void OnChestDestroyed( int r, int c, int earned, float mult )
		=> OnChestDestroyed2?.Invoke( r, c, earned, mult );

	// ── Turn timer ────────────────────────────────────────────────────────
	// calculates how long a turn should last, and makes later waves faster.

	float _maxTurnTime = 20f; // track the actual max for this wave
	void StartTurnTimer()
	{
		float t;

		if (Wave <= 5)
			t = 24f - Wave * 0.8f;          // wave 1=23.2s, 5=20s — very relaxed early
		else if (Wave <= 15)
			t = 20f - (Wave - 5) * 0.9f;   // wave 6=19.1s, 15=11s
		else if (Wave <= 30)
			t = 11f - (Wave - 15) * 0.35f; // wave 16=10.6s, 30=5.75s
		else if (Wave <= 50)
			t = MathF.Max(7f, 5.75f - (Wave - 30) * 0.05f); // wave 30-50: 7s floor
		else
			t = MathF.Max(6f, 7f - (Wave - 50) * 0.02f);    // wave 50+: 6s floor

		if(Wave == 1 && HasMod(ModifierId.TimerBonus))
			t += 5f;

		// Cursed chest penalty — -2s per active cursed chest on grid
    	float cursePenalty = MathF.Min(_activeCursedChests * 2f, 6f); // max -6s from curses

		// Apply any queued penalties from previous wave
		t = MathF.Max(3f, t - _timerPenaltyNextWave - cursePenalty); // cap to 3s
		_timerPenaltyNextWave = 0f;	

		/*
			This gives players a proper learning curve — wave 1-5 are relaxed, 
			6-15 introduce pressure, 15-30 get genuinely hard, 
			30+ is consistently intense without the arbitrary 4s cliff.
		*/

		_maxTurnTime = t;
		TurnTime     = t;
		TimerActive  = true;

		// If milestone overlay is showing, immediately re-pause
		if (MilestoneOverlay.Instance?.IsActive == true)
		{
			TimerActive = false;
			Log.Info("StartTurnTimer: milestone active — re-pausing immediately");
		}

	}
	// Returns the current timer as a percentage/fraction of the original allowed turn time (for UI progress bar)
	public float TurnTimeFraction => TurnTime / _maxTurnTime;

	// ── Wave logic ────────────────────────────────────────────────────────
	async void HandleDetonationFinished( int chestsCleared, int coinsEarned )
	{
		// ------------------------ Handles the End of a Wave ------------------------- // 

		// If GameMode == Survival 
		if (Mode == GameMode.Survival)
		{
			await HandleSurvivalDetonationFinished(chestsCleared, coinsEarned);
			return;
		}

		// Tracks how player behaves in game (how much lives, for how many waves in a row)
		_livesAtWaveStart = Lives;

		TimerActive = false;
		TickHypeMeter(); // Hype meter
		int bonus = chestsCleared > 2 ? (chestsCleared - 2) * ChainBonus : 0; // Bonus points if we cleared more than 2 chests.
		int total = coinsEarned + bonus; // Total coins earned 

		// ── Challenge check ──────────────────────────────────────────────
		// Must happen BEFORE Score += total so the bonus gets included in this wave
		if ( CurrentChallenge != null && !CurrentChallenge.Completed )
		{
			bool success = CurrentChallenge.Type switch
			{
				ChallengeType.ClearAtLeast =>
					chestsCleared >= CurrentChallenge.Target,

				ChallengeType.ChainLength =>
					Hand.LastSimResult != null &&
					Hand.LastSimResult.Sequence.Any(n => n.Depth > 0),

				ChallengeType.ClearGem =>
					Hand.LastSimResult != null &&
					Hand.LastSimResult.HitChests.Any(kv =>
						kv.Value.WillDestroy &&
						kv.Value.ChestType == GridManager.ChestType.Gem),

				ChallengeType.ClearBombChest =>
					Hand.LastSimResult != null &&
					Hand.LastSimResult.HitChests.Any(kv =>
						kv.Value.WillDestroy &&
						kv.Value.ChestType == GridManager.ChestType.BombChest),

				ChallengeType.ClearInOneBlast =>
					Hand.LastSimResult != null &&
					Hand.LastSimResult.Sequence.Count(n => n.Depth == 0) == 1 &&
					chestsCleared > 0,

				ChallengeType.NoBombType =>
					!Hand.PlacedBombTypes.Contains(CurrentChallenge.ForbiddenBomb) &&
					chestsCleared >= MinChests,

				// ── New types ──
				ChallengeType.ClearAll =>
					Hand.LastSimResult != null &&
					chestsCleared >= CurrentChallenge.Target, // Target set to chest count at wave start

				ChallengeType.ChainDepth3 =>
					Hand.LastSimResult != null &&
					Hand.LastSimResult.Sequence.Any(n => n.Depth >= 3),

				ChallengeType.NoDamage =>
					chestsCleared >= MinChests && Lives == StartLives, // didn't lose a life

				_ => false
			};

			if (success)
			{
				CurrentChallenge.Completed = true;
				total += CurrentChallenge.BonusCoins;

				// check if has FirstChainDouble modifier (wave score x2)
				if(HasMod(ModifierId.FirstChainDouble) && !_firstChainUsedThisWave && chestsCleared >= 2)
				{
					total = (int)(total * 2f);
					_firstChainUsedThisWave = true;
				}

				OnChallengeResolved?.Invoke( true, CurrentChallenge.BonusCoins );
			}
			else
			{
				OnChallengeResolved?.Invoke(false, 0);
			}
		}
    	// ─────────────────────────────────────────────────────────────────

		// Game over stats (track)
		TotalChestsCleared += chestsCleared;
		int chainLen = Hand.LastSimResult?.Sequence.Count ?? 0;
		if ( chainLen > BestChainLength ) BestChainLength = chainLen;
		if ( CurrentChallenge?.Completed == true ) ChallengesCompleted++;

		// If we didn't clear the minimum required chest, we lose a life. 
		if ( chestsCleared < MinChests )
		{
			_consecutiveFailedWaves++; // track player behavior (how he performs)

			// I - Bonus Wave Action (random event)
			if (_bonusWaveActive)
			{
				// No life loss during bonus wave
			}

			// Overload CONSUMABLE — ignore min chest requirement
			else if (OverloadActive)
			{
				OverloadActive = false; // consume it
				// show popup
				// continue normally
			}

			else
			{
				Lives--;
				if(Lives <= 0)
				{
					TriggerGameOver($"You cleared {chestsCleared}/{MinChests} chests..");
					return;
				}

				// Still alive — show which source is keeping them alive
				bool hasPetLife   = Save?.PetLevel >= 2;
				bool hasHeartCons = (Save?.GetConsumableCount(ConsumableId.Heart) ?? 0) > 0;

				string lifeMsg = hasPetLife
					? "💔 Your pet shields you from defeat!"
					: hasHeartCons
						? "💔 Heart consumable absorbed the hit!"
						: "💔 You lost a life!";

				MilestoneOverlay.Instance?.ShowMessage(lifeMsg, Wave);
				Sound.Play("Heart_beat");
			}
		}
		else // success
		{
			_consecutiveFailedWaves = 0;

			if (Lives == _livesAtWaveStart)
				_consecutiveWavesNoLifeLoss++;
			else
				_consecutiveWavesNoLifeLoss = 0;

			// ── Perfect Clear — cleared every chest on the board ──
			bool isPerfect = chestsCleared >= _chestsAtWaveStart && _chestsAtWaveStart > 0;
			if (isPerfect)
			{
				int perfectBonus = Wave * 50; // scales with wave — wave 1=50, wave 10=500
				total += perfectBonus;
    			FragmentsEarned += 1; // +1 frag per perfect wave — small but meaningful
				PerfectWaveFragments += 1;
				OnScreenFlashRequest?.Invoke("perfect", Wave);
				Sound.Play("crowd_applause_big_impact_1"); // reuse a satisfying sound for now
			}
		}

		// II - Bonus Wave Action (random event) : multiply total score
		if (_bonusWaveActive)
			total = (int)(total * 3f);


		Score += total;	// updates score 
		WaveScore += total; // updates wave score 
		if ( Score > BestScore ) BestScore = Score; // updates NEW! best score

		// Reset overload regardless
		OverloadActive = false;

	    // This waits exactly as long as the animations take — no more, no less.
		// Buffer before next wave so UI animations complete (+coins msg etc..)
		  // Wait until cell flash animations finish after last bomb
		float delay = Hand.GetPostDetonationWait();
    	await GameTask.DelaySeconds( delay );

		// Move chests that weren't exploded, down.
		SlideChestsDown();

		// Move speed based on Wave num
		int bossMoveCooldown = Wave switch {
			>= 200 => 1,  // wave 200+: moves every wave
			>= 150 => 1,  // wave 150+: moves every wave  
			>= 100 => 2,  // wave 100+: moves every 2 waves
			>= 50  => 3,  // wave 50+:  moves every 3 waves
			_      => 3   // default:   moves every 3 waves
		};

		// Boss advances toward bottom (after every 'bossMoveCooldown' (i.e: 3) num of wave)
		if(_bossAlive && Wave - _bossSpawnedWave >= bossMoveCooldown)
		{
			MoveBossDown();
			_bossSpawnedWave = Wave; // reset counter
		}

		// After chests have been moved down, we check if that column is full (of chests) if so : Game Over
		if ( Grid.AnyChestInLastRow()) { TriggerGameOver("Mehh, why didn't you clear that chest before it reaches the last row!? 💔"); return; }

		// -------------------------------- Next Wave ----------------------------- //

		// Everything spawns in one frame together
		ClearRandomEvent(); // random events cleared
		int prevMinChests = MinChests;
		Wave++;
		WaveScore = 0;
		WaveTarget = ComputeWaveTarget( Wave );
		MinChests = MinChestsForWave( Wave );
		if (MinChests > prevMinChests)
		{
			MilestoneOverlay.Instance?.ShowMessage($"⚠️ Min chests raised to {MinChests}!", Wave);
			Sound.Play("Alert_Danger_MinChest");
		}

		// ------ Show milestone overlay if this wave is a milestone ------ //
		MilestoneOverlay.Instance?.ShowIfMilestone(Wave);

		// and wait for milestone overlay to be dismissed before starting timer
		if (MilestoneCatalog.GetForWave(Wave) != null)
		{
			// Wait until overlay is no longer active (give player some time to read)
			while (MilestoneOverlay.Instance?.IsActive == true)
				await GameTask.DelaySeconds(0.1f);
			
			// Apply grid expansion at milestones
			UpdateGridSize(Wave);
		}

		// ------- If no milestone to show, try showing random event overlay (based on rules inside TryTriggerRandomEvent()) ------ //

		// 1. Then check random event (only if no milestone this wave)
		if (MilestoneCatalog.GetForWave(Wave) == null)
			TryTriggerRandomEvent(Wave);

		// 2. Wait for either overlay to dismiss (give player some time to read)
		while (MilestoneOverlay.Instance?.IsActive == true)
			await GameTask.DelaySeconds(0.1f);

		// 3. Apply random event effects AFTER player sees the announcement
		ApplyRandomEvent();

		// ------------------------------------------------------------------------ //

		SpawnChests( ChestsForWave( Wave ) );
		TickHypeMeter(); // Hyper meter
		RecountCursedChests(); // cursed chest (malus time -2s)
		ConsumeOverdrive(); // reset consumable (if used)
		
		// Milestone flash if reached wave 100
		if(Wave == 100)
		{	
			MilestoneOverlay.Instance?.ShowEvent(RandomEventType.Wave100, Wave);
			Sound.Play("fanfare_100");

			await Task.Delay(3000);

			Sound.Play("applause_1");

			await Task.Delay(2000);

			Sound.Play("crowd_4_a");
			
		}
		// Milestone flash if reached wave 200
		else if(Wave == 200 )
		{
			MilestoneOverlay.Instance?.ShowEvent(RandomEventType.Wave100, Wave);
			Sound.Play("fanfare_200");

			await Task.Delay(3000);
			
			Sound.Play("applause_1");

			await Task.Delay(2000);

			Sound.Play("crowd_4_a");
		}
		// Milestone flash every 5 waves
		else if (Wave % 5 == 0)
		{
			OnScreenFlashRequest?.Invoke("wave", Wave); 
			Sound.Play("WaveMilestone");
		}
		
		TrySpawnSecretChest(); // secret chest
		_chestsAtWaveStart = Grid.AllChestCells().Count; // retrieve all chests (num) for challenge checks
		CurrentChallenge = RollChallenge(Wave); // challenge for this wave
		 _firstChainUsedThisWave = false; // reset mod
		Hand.DrawHand( Wave );
		TrySpawnBossChest(); // boss chest
		StartTurnTimer();
		OnStateChanged?.Invoke();
	}

	// Survival Mode func
	async System.Threading.Tasks.Task HandleSurvivalDetonationFinished(int chestsCleared, int coinsEarned)
	{
		float mult = _survivalElapsed <= _survivalScoreBoostUntil ? SurvivalScoreBoostMult : 1f;
		int earned = (int)MathF.Round(coinsEarned * mult);

		Score += earned;
		if (Score > BestScore) BestScore = Score;

		TotalChestsCleared += chestsCleared;

		Hand.DrawHand(1);
		OnStateChanged?.Invoke();

		if (chestsCleared > 0)
		{
			float delay = Hand.GetPostDetonationWait();
			await GameTask.DelaySeconds(delay);

			if (!IsRunning) return; // guard in case the run ended while waiting

			var (bmin, bmax) = GetSurvivalBaseBatch();
			int count = Rng.Next(bmin, bmax + 1);

			if (!SurvivalAdvanceAndSpawn(count))
				return; // game over already triggered inside

			_survivalSpawnTimer = _survivalSpawnInterval; // reset the independent timer so it doesn't double-fire

			OnStateChanged?.Invoke();
		}
	}
	void SlideChestsDown()
	{
		// i.e : 5x5 grid - 0 == top row and Grid.Rows - 1 == 4 (last row - index start at 0)
		// so, we want to go from the bottom up,  while the 2nd (inner) loop checks every column left -> right.
		//
		// Why bottom -> top?
		// Because we're moving chests downward.
		// If we iterated top -> bottom, a chest could move into a row that was already processed this frame.
		//
		// Then, why -2 ? => because we don't want to move a chest from the LAST row (it'd be out of bounds from the grid)
		for ( int r = Grid.Rows - 2; r >= 0; r-- )
			for ( int c = 0; c < Grid.Cols; c++ )
			{
				var cell = Grid.Grid[r, c];
				if ( !cell.HasChest ) continue; // ignore cells that don't have any chest (even those exploded)
            	if ( cell.Chest == GridManager.ChestType.Secret ) continue;  // secret chest doesn't slide
				if (cell.Chest == GridManager.ChestType.Boss)   continue; // boss chest doesn't slide
				if (cell.Chest == GridManager.ChestType.Void)   continue; // void doesn't slide 

				int nr = r + 1; // increment row (to go down)
				var below = Grid.Grid[nr, c]; // fetch the grid cell a those new coords

				// if that cell is empty, then update both cells (the new empty one (above), and the new filled one (below))
				if ( below.IsEmpty )
				{
					below.Chest = cell.Chest;
					below.HitsLeft = cell.HitsLeft;
					cell.Chest = GridManager.ChestType.None;
					cell.HitsLeft = 0;
				}
				else if ( below.Chest == GridManager.ChestType.Secret )
				{
					// Normal chest passes through secret chest — swap positions
					// Secret chest floats up, normal chest sinks down
					below.Chest = cell.Chest;
					below.HitsLeft = cell.HitsLeft;
					cell.Chest = GridManager.ChestType.Secret;
					cell.HitsLeft = 1;
				}
			}
	}

	// Game Over (+ update UI OnStateChanged)
	void TriggerGameOver(string reason)
	{
		PrevBestScore = Save?.BestScore ?? 0; // capture BEFORE RecordRun overwrites it (for ov screen)
		GameOverReason = reason;
		IsRunning = false;

		 // In RecordRun, before calling Save.RecordRun:
        float minutesThisRun = (Time.Now - _sessionStartTime) / 60f;

		// base wave + any secret chest bonuses
		FragmentsEarned += Wave / 2; // wave 10 = 5 frags, wave 69 = 34 frags — not wave itself
		
		
		// Level 4 pet bonus — double frags on every milestone wave (10, 20, 30...)
		if (Save?.PetLevel >= 4 && Wave % 10 == 0)
			FragmentsEarned += Wave / 2; // doubles the wave/2 bonus earned this run
	
		// Best chain length (chain reaction)
		int previousChainLength = Save.BestChainLength;
		int newBestChainLength = 0;
		if (BestChainLength > previousChainLength) newBestChainLength = BestChainLength;
    
		Log.Info($"TriggerGameOver — FragmentsEarned: {FragmentsEarned}, Wave: {Wave}");

		// Record this run to persistent save
		Save.RecordRun(
			score:                 Score,
			wave:                  Wave,
			chestsCleared:         TotalChestsCleared,
			challengesCompleted:   ChallengesCompleted,
			fragmentsEarned:       FragmentsEarned,
			minutesPlayed: 		   minutesThisRun,
			bestChainLength:	   newBestChainLength
		);

		// Leaderboard results submission
		SubmitLeaderboard( Score, Wave, previousChainLength );
		
		TimerActive = false;
		OnScreenFlashRequest?.Invoke("gameover", 0);; // red flash screen
    	_ = ShowGameOverDelayed(); // delay game over
		Sound.Play("GameOver_1"); // game over trigger sound
	}
	
	async System.Threading.Tasks.Task ShowGameOverDelayed()
	{
		await GameTask.DelaySeconds(0.8f);   // let flash play first
		IsGameOver = true;
		Sound.Play("GameOver_2");
		OnStateChanged?.Invoke();
	}
	// ── Chest spawning ────────────────────────────────────────────────────
	int ChestsForWave(int wave)
	{
		int _base = wave < 3   ? 3
			: wave < 6         ? 4
			: wave < 10        ? 5
			: wave < 15        ? 6
			: wave < 20        ? 7
			: wave < 50        ? 8
			: wave < 100       ? 9 // grid expands 
			: wave < 150       ? 10 // grid expands 
			: wave < 300       ? 11
			: 12;              // 300+ — near-full 7×7 grid
		return Math.Min(_base + (GetDifficultyModifier() / 2), 14);
	}
	int MinChestsForWave(int wave)
	{
		int _base = wave < 15  ? 1
			: wave < 30        ? 2
			: wave < 75        ? 3
			: wave < 150       ? 4
			: wave < 300       ? 5
			: 6;               // 300+: must clear 6 minimum
		return Math.Min(_base + (GetDifficultyModifier() / 3), 7);
	}
	
	// Difficulty increases based on player in-game progression (modifiers)
	int GetDifficultyModifier()
	{
		// 0 = vanilla, 1 = slightly harder, 2 = harder
		return Save?.ActiveModifiers?.Count ?? 0;
	}
	int ComputeWaveTarget( int wave ) => 200 + wave * 150; // Score target for that wave 

	// Spawn a specific number of chests on grid cells (rule : it spawns chest ONLY in the toppest rows (the first 2 ones))
	void SpawnChests( int count )
	{
		// ── Use pre-rolled positions from Reveal CONSUMABLE, if available ── //
		if (_prerolledChests != null)
		{
			foreach (var (r, c, type) in _prerolledChests)
			{
				var cell = Grid.Grid[r, c];
				if (!cell.IsEmpty) continue; // safety check

				cell.Chest = type;
				if (type == GridManager.ChestType.Gem && HasMod(ModifierId.GemsOneHit))
					cell.HitsLeft = 1;
				else
					cell.HitsLeft = type == GridManager.ChestType.Gem ? 2 : 1;
			}
			_prerolledChests = null; // consume and clear
			Sound.Play("next_wave_spawn");
			return;
		}

		// ── Tutorial wave overrides ── //
		bool isTutorial = TutorialOverlay.Instance?.IsActive == true 
               || (Save?.TutorialCompleted == false && Wave == 1);

		if (isTutorial && Wave == 1)
		{
			// Force clear rows 0-1 completely before placing
			for (int r = 0; r < 2; r++)
				for (int c = 0; c < Grid.Cols; c++)
				{
					Grid.Grid[r, c].Chest    = GridManager.ChestType.None;
					Grid.Grid[r, c].HitsLeft = 0;
				}

			// Guaranteed gem at center top
			Grid.Grid[0, 2].Chest    = GridManager.ChestType.Gem;
			Grid.Grid[0, 2].HitsLeft = 2;

			// Fill rest with normals
			var emptyW1 = new List<GridManager.Cell>();
			for (int r = 0; r < 2; r++)
				for (int c = 0; c < Grid.Cols; c++)
					if (Grid.Grid[r, c].IsEmpty) emptyW1.Add(Grid.Grid[r, c]);
			Shuffle(emptyW1);

			int placed = 0;
			foreach (var cell in emptyW1)
			{
				if (placed >= count - 1) break;
				cell.Chest    = GridManager.ChestType.Normal;
				cell.HitsLeft = 1;
				placed++;
			}

			Sound.Play("next_wave_spawn");
			return;
		}

		if (isTutorial && Wave == 2)
		{
			// Force a cross of bomb-chests so any bomb placement triggers a 5+ chain
			// Pattern on a 5x5 grid:
			//   row 0: cols 1, 3
			//   row 1: cols 0, 2, 4
			//   Leaves cols (0,0),(0,2),(0,4),(1,1),(1,3) as empty for bomb placement
			//   Any placement adjacent to this pattern chains through all 5 bomb-chests

			var pattern = new (int r, int c)[]
			{
				(0, 1), (0, 3),
				(1, 0), (1, 2), (1, 4)
			};

			foreach (var (r, c) in pattern)
			{
				var cell = Grid.Grid[r, c];
				if (!cell.IsEmpty) continue;
				cell.Chest    = GridManager.ChestType.BombChest;
				cell.HitsLeft = 1;
			}

			// Fill a few normal chests in row 2 so the grid doesn't look empty
			Grid.Grid[2, 1].Chest    = GridManager.ChestType.Normal;
			Grid.Grid[2, 1].HitsLeft = 1;
			Grid.Grid[2, 3].Chest    = GridManager.ChestType.Normal;
			Grid.Grid[2, 3].HitsLeft = 1;

			Sound.Play("next_wave_spawn");
			return;
		}

		// --------------------- Bomb chest cap — scales with grid size and wave ------------ //
		int gridCells    = Grid.Rows * Grid.Cols;
		int existingBombChests = CountBombChestsOnGrid();

		// Base cap: roughly 20-30% of grid cells, scales up with grid size
		// 5x5=25 cells → cap ~5-6, 6x6=36 → cap ~8-9, 7x7=49 → cap ~12
		float capRatio = Wave switch {
			< 10  => 0.12f,   // early: very few bomb chests allowed
			< 20  => 0.16f,
			< 35  => 0.20f,
			< 60  => 0.24f,
			< 100 => 0.28f,
			< 200 => 0.32f,
			_     => 0.36f    // 300+: still capped, just more room on bigger grid
		};
		int bombChestCap = (int)MathF.Max(2f, gridCells * capRatio);
		// ----------------------------------------------------------------------------------- //

		// normal spawn 
		var empty = new List<GridManager.Cell>(); // create a new List of Cells (from GridManager's definition);

		// ===================================================================================== //
		// =========================== CHEST SPAWN ROW RULES ================================== //
		// ===================================================================================== //
		// Spawn rows scale with wave — higher waves spawn chests deeper into the grid (new system)
		int spawnRows = Wave switch {
			>= 200 => 4,  // rows 0-3
			>= 100 => 3,  // rows 0-2
			_      => 2   // rows 0-1 (default)
		};

		for (int r = 0; r < spawnRows; r++)
			for (int c = 0; c < Grid.Cols; c++)
				if (Grid.Grid[r, c].IsEmpty) empty.Add(Grid.Grid[r, c]);
		
		// 1. Collect all Cells in the first two top-row of GridManager's (Row 0 & Row 1 only) (old system)
		// for ( int r = 0; r < 2; r++ ) // nested loop explanation :  a. it scans a first row (r=0)
		// 	for ( int c = 0; c < Grid.Cols; c++ ) // b. in that row, we scan every cols (c < Grid.Cols). Once it's done, it goes up to increment r++ & scan second row (r=1).
		// 		if ( Grid.Grid[r, c].IsEmpty ) empty.Add( Grid.Grid[r, c] ); // 2. adds ONLY grid cells (coords) that are EMPTY 
		// ===================================================================================== //

		// 3. shuffle that, so chest placement are randomized (not picked in order according to the grid's empty cells collection)
		Shuffle( empty );

		// ── Progressive placement bias ──
		// Early waves: cluster chests together so accidental chains happen
		// Late waves: spread chests apart so you need to plan
		if (Wave >= 8)
		{
			empty.Sort((a, b) =>
			{
				float scoreA = GetPlacementScore(a.Row, a.Col);
				float scoreB = GetPlacementScore(b.Row, b.Col);
				return scoreB.CompareTo(scoreA);
			});
		}

		// ── Modifier: bomb chests near normal chests ──
		if (HasMod(ModifierId.BombChestsNearChest))
		{
			empty.Sort((a, b) => {
				int scoreA = AdjacentNormalChestCount(a.Row, a.Col);
				int scoreB = AdjacentNormalChestCount(b.Row, b.Col);
				return scoreB.CompareTo(scoreA);
			});
		}

		// Modifier: bomb chests always spawn near a normal chest
		if(HasMod(ModifierId.BombChestsNearChest))
		{
			empty.Sort((a, b) => {
				int scoreA = AdjacentNormalChestCount(a.Row, a.Col);
				int scoreB = AdjacentNormalChestCount(b.Row, b.Col);
				return scoreB.CompareTo(scoreA); // descending
			});
		}

		// 4. Min(x,y) is added so that no matter what, it never loops more than the actual free grid Cells available (bc empty)
		for(int i = 0; i < MathF.Min(count, empty.Count); i++)
		{
			var type = RollChestType(Wave);

			// Enforce bomb chest cap — demote to normal if over cap
			if (type == GridManager.ChestType.BombChest && 
				existingBombChests >= bombChestCap)
			{
				type = GridManager.ChestType.Normal;
			}

			// NERF bomb chest specifically to avoid too big clusters at higher waves
			if (type == GridManager.ChestType.BombChest)
			{
				if (Wave >= 25 && TooCloseToExistingBombChest(empty[i].Row, empty[i].Col))
					type = GridManager.ChestType.Normal; // demote if too close
				else
					existingBombChests++; // track as we place
			}

			empty[i].Chest = type;

			// 1. Base hits for chest type
			int baseHits = type switch 
			{
				GridManager.ChestType.Gem    => 2, // 2 hits
				GridManager.ChestType.Locked => 3, // 3 hits
				_                            => 1
			};

			// Level 3 pet bonus — multi-hit chests spawn pre-damaged
			if (Save?.PetLevel >= 3 && baseHits > 1)
				baseHits--;

			// 2. Modifier — GemsOneHit reduces gem hits to 1 (always wins)
			if (type == GridManager.ChestType.Gem && HasMod(ModifierId.GemsOneHit))
				baseHits = 1;

			// 3. Cursed wave event — adds extra hits on top of final base
			// Applied AFTER modifier so GemsOneHit + CursedWave = 2 hits (not 3)
			empty[i].HitsLeft = baseHits + ExtraHitsRequired;
		}

		// Spawn chest sfx
		Sound.Play("next_wave_spawn");
	}

	// Returns a chest type to add, based on Wave number (rules defined below)
	GridManager.ChestType RollChestType(int wave)
	{
		// Gold Rush event — only gems this wave
		if (_goldRushActive == true)
			return GridManager.ChestType.Gem;
			
		int roll = Rng.Next(100);

		if(wave < 4)
			// gems appear, no bomb chests yet
			return GridManager.ChestType.Normal; // waves 1-3: ALL normal, no gems, no bombs
			// return roll < 70 ? GridManager.ChestType.Normal : GridManager.ChestType.Gem;

		if(wave < 7)
		// first bomb chests — rare, exciting when they appear
			return roll < 55 ? GridManager.ChestType.Normal 
				: roll < 80 ? GridManager.ChestType.Gem 
				:              GridManager.ChestType.BombChest; // ~20% chance

		if(wave < 10)
		// bomb chests getting more common
			return roll < 45 ? GridManager.ChestType.Normal 
				: roll < 70 ? GridManager.ChestType.Gem 
				:              GridManager.ChestType.BombChest; // ~30% chance

		if(wave < 20)
			// bomb chests common but not dominant
			return roll < 45 ? GridManager.ChestType.Normal 
				: roll < 70 ? GridManager.ChestType.Gem 
				:              GridManager.ChestType.BombChest;

		if(wave < 35)
			// balanced chaos
			return roll < 35 ? GridManager.ChestType.Normal 
				: roll < 60 ? GridManager.ChestType.Gem 
				:              GridManager.ChestType.BombChest;

		 // Wave 75+ — Locked chests appear
    if (wave >= 75 && wave < 100)
        return roll < 20 ? GridManager.ChestType.Locked
             : roll < 45 ? GridManager.ChestType.Normal
             : roll < 65 ? GridManager.ChestType.Gem
             :              GridManager.ChestType.BombChest;

    // Wave 100+ — Cursed chests appear
    if (wave >= 100 && wave < 200)
        return roll < 15 ? GridManager.ChestType.Locked
             : roll < 25 ? GridManager.ChestType.Cursed
             : roll < 45 ? GridManager.ChestType.Normal
             : roll < 65 ? GridManager.ChestType.Gem
             :              GridManager.ChestType.BombChest;

    // Wave 200+ — Void chests appear
    if (wave >= 200)
        return roll < 10 ? GridManager.ChestType.Void
             : roll < 20 ? GridManager.ChestType.Cursed
             : roll < 30 ? GridManager.ChestType.Locked
             : roll < 50 ? GridManager.ChestType.Gem
             :              GridManager.ChestType.BombChest;

		// late game — bomb chests significant but gems still matter
		return roll < 25 ? GridManager.ChestType.Normal 
			: roll < 50 ? GridManager.ChestType.Gem 
			:              GridManager.ChestType.BombChest;
	}
	// Helper for Modifier: bomb chests 
	// Answer question : "how many normal chests are directly adjacent to this cell?"
	int AdjacentNormalChestCount(int r, int c)
	{
		int count = 0;
	    int[] dr = {-1, 1, 0, 0};  // row offsets: up, down, stay, stay
    	int[] dc = {0,  0, -1, 1};  // column offsets: stay, stay, left, right

		/* directions (when looping i < 4):
			d=0: dr=-1, dc=0 → one row up, same column → up
			d=1: dr=1,  dc=0 → one row down, same column → down
			d=2: dr=0,  dc=-1 → same row, one column left → left
			d=3: dr=0,  dc=1 → same row, one column right → right
		*/

		for(int d = 0; d < 4; d++)
		{
			int nr = r + dr[d];   // new row = current row + direction offset
        	int nc = c + dc[d];   // new col = current col + direction offset

			if(Grid.InBounds(nr, nc) && // neighbour exists on the grid
			Grid.Grid[nr, nc].Chest == GridManager.ChestType.Normal) // neighbour has a normal chest
					count++;
		}
		return count;  // how many of the 4 neighbours are normal chests -> determines a score 
	}

		// ===== RANGE BONUS MODIFIER ==== //
	public int GetRangeBonus(GridManager.BombType type)
	{
		// Sniper bomb +1 range anytime it's equipped
		if(type == GridManager.BombType.Sniper && Instance?.HasMod(ModifierId.SniperRange) == true)
			return 1;

		// Wave 5 and more => we have +1 range on cross bomb
		if(type == GridManager.BombType.Cross && Instance?.HasMod(ModifierId.CrossRangeAfterWave5) == true && (Instance?.Wave ?? 0) >= 5)
			return 1;

		return 0;
	}

	// --------------------------------------------------------------- //
	// ------------------------- SECRET CHEST ------------------------ //
	// --------------------------------------------------------------- //
	bool HasSecretChestOnGrid()
	{
		for ( int r = 0; r < Grid.Rows; r++ )
			for ( int c = 0; c < Grid.Cols; c++ )
				if ( Grid.Grid[r, c].Chest == GridManager.ChestType.Secret ) return true;
		return false;
	}

	void TrySpawnSecretChest()
	{
		if ( _secretChestsSpawnedThisRun >= 3 ) return;  // max 3 per run
		if ( HasSecretChestOnGrid() ) return;             // max 1 on grid at once
		if ( Rng.Next( 100 ) >= 5 ) return;              // 8% chance

		// Only spawn in rows 2-3 (middle of grid)
		// avoids row 0-1 (normal chest spawn zone) and row 4 (bottom / danger zone)
		var candidates = new List<GridManager.Cell>();
		for ( int r = 2; r < Grid.Rows - 1; r++ )
			for ( int c = 0; c < Grid.Cols; c++ )
				if ( Grid.Grid[r, c].IsEmpty ) candidates.Add( Grid.Grid[r, c] );

		if ( candidates.Count == 0 ) return;

		Shuffle( candidates );
		var cell = candidates[0];
		cell.Chest = GridManager.ChestType.Secret;
		cell.HitsLeft = 1;
		_secretChestsSpawnedThisRun++;

		MilestoneOverlay.Instance?.ShowMessage("✨ A Secret Chest appeared!", Wave);
		Sound.Play("secret_chest_alert");
	}

	void Shuffle<T>( List<T> list )
	{
		for ( int i = list.Count - 1; i > 0; i-- )
		{ int j = Rng.Next( i + 1 ); (list[i], list[j]) = (list[j], list[i]); }
	}


	// --------------------------------------------------------------- //
	// -------------------------- BOSS CHEST ------------------------- //
	// --------------------------------------------------------------- //

	public bool BossAlive => _bossAlive;
	bool _bossAlive = false;
	int _bossMaxHp = 5; // set when boss spawns
	public int BossMaxHp { get; private set; } = 5;
	int _bossSpawnedWave = -1;
	void TrySpawnBossChest(int baseBossHp = 1)
	{
		    Log.Info($"TrySpawnBossChest — wave={Wave}, bossAlive={_bossAlive}, wave%10={Wave%10}");

		// Random Event - Boss Rush (Active ?)
		if (_bossRushActive)
		{
			// Spawn 3 mini-bosses with 2 HP each in different positions
			int spawned = 0;
			for (int r = 0; r < Grid.Rows && spawned < 3; r++)
				for (int c = 0; c < Grid.Cols && spawned < 3; c++)
					if (Grid.Grid[r, c].IsEmpty)
					{
						Grid.Grid[r, c].Chest    = GridManager.ChestType.Boss;
						Grid.Grid[r, c].HitsLeft = 2; // mini version
						_bossAlive = true;
						spawned++;
					}
			_bossRushActive = false;
			OnScreenFlashRequest?.Invoke("boss", Wave);
			return;
		}

		if(Wave % 10 != 0) return;   // only on wave 10, 20, 30...
		if(_bossAlive) return;      // boss already on grid (one)

		_bossSpawnedWave = Wave;

		// Hp's increments based on wave (difficulty up!)
		int bossHp = Wave switch
		{
			<= 10 => 3, // wave 10 - easy 
			<= 20 => 5,	// wave 20 - less easy
			<= 30 => 7, // wave 30 - difficult
			<= 40 => 9, // wave 40 - more difficult..
			_ 	  => 10 + (Wave / 10) - 4 // i.e : wave 50=11 (incremental)
		};

		// Compute move cooldown based on current wave for the announcement
		int moveCooldown = Wave switch {
			>= 200 => 1,
			>= 150 => 1,
			>= 100 => 2,
			>= 50  => 3,
			_      => 3
		};

		// Spawn in center of grid (row 2, col 2 on a 5x5)
		var center = Grid.Grid[2, 2];
		if (!center.IsEmpty) 
		{
			for (int r = 1; r <= 3; r++)
				for (int c = 0; c < Grid.Cols; c++)
					if (Grid.Grid[r, c].IsEmpty)
					{
						Grid.Grid[r, c].Chest    = GridManager.ChestType.Boss;
						Grid.Grid[r, c].HitsLeft = bossHp; // ← use correct HP
						_bossMaxHp = bossHp;
						BossMaxHp  = bossHp;
						_bossAlive = true;

						MilestoneOverlay.Instance?.ShowBoss(Wave, bossHp, moveCooldown); // ← show milestone
						OnScreenFlashRequest?.Invoke("boss", Wave);
						Sound.Play("boss_alert");
						return;
					}
			return;
		}	

		// event
		MilestoneOverlay.Instance?.ShowBoss(Wave, bossHp, moveCooldown);

		// Initialize Boss Chest Cell - 
		center.Chest = GridManager.ChestType.Boss;
		center.HitsLeft = bossHp;
		_bossMaxHp = bossHp;
		BossMaxHp = bossHp;
		_bossAlive = true;
		OnScreenFlashRequest?.Invoke("boss", Wave); // Screen flash 
		Sound.Play("boss_alert"); // Spawn chest sfx
	}
	public void NotifyBossKilled()
	{
		_bossAlive   = false;

		int scoreBonus = 500 * (Wave / 10);      // wave 10=500, 20=1000, 30=1500...
    	int fragBonus  = 10 + (Wave / 10) * 5;   // wave 10=15, 20=20, 30=25...

		FragmentsEarned += 15;
		BossFragments += 15;
		OnBossKilled?.Invoke(scoreBonus, fragBonus);
		OnStateChanged?.Invoke();

		// Spawn chest sfx
		Sound.Play("destroy_boss");
	}

	void MoveBossDown()
	{
		// Find boss position first
		int bossR = -1, bossC = -1;
		for(int r = 0; r < Grid.Rows; r++)
			for(int c = 0; c < Grid.Cols; c++)
				if(Grid.Grid[r, c].Chest == GridManager.ChestType.Boss)
				{ bossR = r; bossC = c; break; }

		if(bossR < 0) return; // no boss found

		// Boss in last row already — game over
		if(bossR >= Grid.Rows - 1)
		{
			TriggerGameOver("👑 Boss reached the bottom row. Are you gonna give up now ?😏");
			return;
		}

		// Try to move down
		var below = Grid.Grid[bossR + 1, bossC];
		if(below.IsEmpty)
		{
			below.Chest    = GridManager.ChestType.Boss;
			below.HitsLeft = Grid.Grid[bossR, bossC].HitsLeft;
			Grid.Grid[bossR, bossC].Chest    = GridManager.ChestType.None;
			Grid.Grid[bossR, bossC].HitsLeft = 0;
			OnStateChanged?.Invoke();
		}
		else
		{
			// Cell below occupied — boss pushes chest off the grid (boss is heavy) 
			// Or alternative: do nothing this wave, try again next time
			// yeah for now "do nothing" since pushing chests off is weird
		}
	}

	// ------------------------------------------------------------- //
	// ------------------------- CHALLENGES ------------------------ //
	// ------------------------------------------------------------- //

	WaveChallenge RollChallenge(int wave)
	{
		var types = new List<ChallengeType>();

		// Always available
		types.Add(ChallengeType.ClearAtLeast);
		types.Add(ChallengeType.ChainLength);

		// Unlocks progressively
		if(wave >= 3)  types.Add(ChallengeType.ClearGem);
		if(wave >= 5)  types.Add(ChallengeType.NoBombType);
		if(wave >= 4 && Grid.AllChestCells().Count >= 3) types.Add(ChallengeType.ClearInOneBlast);
		if(wave >= 6)  types.Add(ChallengeType.ClearBombChest);

		// High wave exclusive challenges
		if(wave >= 10) types.Add(ChallengeType.ClearAll);      // clear every chest on board
		if(wave >= 15) types.Add(ChallengeType.ChainDepth3);   // trigger a 3-depth chain
		if(wave >= 20) types.Add(ChallengeType.NoDamage);      // clear MinChests without losing life

		var type = types[Rng.Next(types.Count)];

		// Bonus scales with wave AND difficulty tier
		int basebonus = 200 + wave * 80;
		int bonus = type switch
		{
			ChallengeType.ClearAll      => basebonus * 2,
			ChallengeType.ChainDepth3   => (int)(basebonus * 1.5f),
			ChallengeType.NoDamage      => (int)(basebonus * 1.8f),
			ChallengeType.NoBombType    => (int)(basebonus * 1.3f),
			ChallengeType.ClearInOneBlast => (int)(basebonus * 1.4f),
			_ => basebonus
		};

		// ClearAtLeast target scales with wave
		int clearTarget = wave < 5 ? 2 : wave < 10 ? 3 : wave < 20 ? 4 : 5;

		return type switch
		{
			ChallengeType.ClearAtLeast => new WaveChallenge {
				Type = type, Target = clearTarget,
				Label = $"Clear {clearTarget}+ chests", BonusCoins = bonus },

			ChallengeType.ChainLength => new WaveChallenge {
				Type = type, Target = 2,
				Label = "Trigger a chain reaction", BonusCoins = bonus },

			ChallengeType.ClearGem => new WaveChallenge {
				Type = type, Target = 1,
				Label = "Destroy a Gem chest 💎", BonusCoins = bonus + 100 },

			ChallengeType.NoBombType => new WaveChallenge {
				Type = type,
				ForbiddenBomb = Hand.BombAt(Rng.Next(Hand.HandSize)),
				Label = $"Clear without using {BombLabel(Hand.BombAt(0))}", BonusCoins = bonus + 150 },

			ChallengeType.ClearInOneBlast => new WaveChallenge {
				Type = type, Target = 1,
				Label = "Clear chests with 1 bomb only", BonusCoins = bonus + 200 },

			ChallengeType.ClearBombChest => new WaveChallenge {
				Type = type,
				Label = "Detonate a Bomb Chest 💣", BonusCoins = bonus + 100 },

			ChallengeType.ClearAll => new WaveChallenge {
				Type = type, Target = _chestsAtWaveStart,
				Label = "Clear ALL chests this wave", BonusCoins = bonus },

			ChallengeType.ChainDepth3 => new WaveChallenge {
				Type = type,
				Label = "Trigger a 3-chain reaction", BonusCoins = bonus },

			ChallengeType.NoDamage => new WaveChallenge {
				Type = type,
				Label = "Clear without losing a life", BonusCoins = bonus },

			_ => new WaveChallenge {
				Type = ChallengeType.ClearAtLeast, Target = 2,
				Label = "Clear 2+ chests", BonusCoins = basebonus }
		};
	}

	static string BombLabel( GridManager.BombType t ) => t switch 
	{
		GridManager.BombType.Cross    => "Cross ✛",
		GridManager.BombType.Sniper   => "Sniper ↑",
		GridManager.BombType.Diagonal => "Diagonal ✕",
		GridManager.BombType.Square   => "Square ■",
		GridManager.BombType.Chain    => "Chain ⛓",
			_ => "?"
	};

	// Changes grid color every x waves and then to add a sense of progression
	public string WaveGridColor => (Wave / 5) switch
	{
		0 => "#0d0820",   // waves 1-4:  deep purple
		1 => "#08101f",   // waves 5-9:  deep blue
		2 => "#081a12",   // waves 10-14: deep teal
		3 => "#1a0808",   // waves 15-19: deep red
		4 => "#1a1208",   // waves 20-24: deep amber
		_ => "#0d0820"    // cycle back
	};

	public string WaveParticleColor
	{
		get
		{
			int tier = (Wave / 5) % 6;
			return tier switch {
				0 => "rgba(167,139,250,",
				1 => "rgba(52,211,153,",
				2 => "rgba(251,191,36,",
				3 => "rgba(244,114,182,",
				4 => "rgba(96,165,250,",
				_ => "rgba(251,146,60,"
			};
		}
	}
	// Happens to open Secret chests on GameOver screen
	void RollSecretChestReward()
	{
		int roll = Rng.Next( 100 );

		SecretChestReward reward;

		if ( roll < 70 )  // Common — coins
		{
			int coins = Rng.Next( 50, 151 );
			reward = new SecretChestReward {
				Icon = "🪙", Label = $"+{coins} coins",
				Coins = coins, Fragments = 0, IsRare = false
			};
			Score += coins;
		}
		else if ( roll < 95 )  // Uncommon — fragments
		{
			int frags = Rng.Next( 3, 9 );
			reward = new SecretChestReward {
				Icon = "💠", Label = $"+{frags} fragments",
				Coins = 0, Fragments = frags, IsRare = false
			};
			FragmentsEarned += frags;
			Log.Info($"Secret reward rolled: +{frags} frags, FragmentsEarned now: {FragmentsEarned}");

		}
		else  // Rare — run modifier
		{
			reward = new SecretChestReward {
				Icon = "🌟", Label = "RARE DROP!",
				Coins = 0, Fragments = 15, IsRare = true
			};
			FragmentsEarned += 15;
			
		}
		SecretChestRewards.Add( reward );
	}

	// -------------------------------------------------------- //
	// ----------------- Return to Main Menu ------------------ // 
	// -------------------------------------------------------- //
	public void ReturnToMenu()
	{
		OnGameReturned?.Invoke();
		GameStarted = false;
		IsGameOver  = false;
		IsRunning   = false;
		OnStateChanged?.Invoke();
	}

	// -------------------------------------------------------- //
	// ----------------- TUTORIAL FOR 1st GAME ---------------- // 
	// -------------------------------------------------------- //

	public void PauseTimer()
	{
		TimerActive = false;
		Log.Info($"PauseTimer called — TimerActive now false");
	}

	public void ResumeTimer() => TimerActive = true;

	// ────────────────────────────────────────────────────────────────
	// ────────────────────── Consumable effects ──────────────────────
	// ────────────────────────────────────────────────────────────────
	// FuseExtension — adds time to current wave
	public void AddTime(float seconds)
	{
		TurnTime = MathF.Min(TurnTime + seconds, _maxTurnTime); // cap at max, don't extend max (so it visually change the bar)
		OnStateChanged?.Invoke();
	}

	// ColumnSweep — destroys all chests in the most dangerous column
	public void SweepDangerColumn()
	{
		// Find column with most chests
		int bestCol = -1, bestCount = 0;
		for (int c = 0; c < Grid.Cols; c++)
		{
			int count = 0;
			for (int r = 0; r < Grid.Rows; r++)
				if (Grid.Grid[r, c].HasChest) count++;
			if (count > bestCount) { bestCount = count; bestCol = c; }
		}

		if (bestCol < 0) return;

		for (int r = 0; r < Grid.Rows; r++)
		{
			var cell = Grid.Grid[r, bestCol];
			if (!cell.HasChest) continue;
			cell.Chest = GridManager.ChestType.None;
			cell.HitsLeft = 0;
			TotalChestsCleared++;
		}
		OnStateChanged?.Invoke();
	}

	// Heart consumable
	public bool UseHeartConsumable()
	{
		if (Lives >= MaxLives) return false;
		if (Save?.GetConsumableCount(ConsumableId.Heart) <= 0) return false;
		Save.ConsumeConsumable(ConsumableId.Heart);
		Lives++;
		OnStateChanged?.Invoke();
		return true;
	}

	// ───────── Overdrive ───────────────────────────────────────────
	// — next detonation multiplier is doubled
	public bool OverdriveActive { get; private set; } = false;
	public void ActivateOverdrive()
	{
		OverdriveActive = true;
		OnStateChanged?.Invoke();
	}
	public void ConsumeOverdrive() // called in HandleDetonationFinished
	{
		OverdriveActive = false;
	}
	

	// ───────── ChainNuke (removed for now) ──────────────────────────
	// ChainNuke — force-links all placed bombs to a chain reaction
	public void ActivateChainNuke()
	{
		if (!Hand.AnyPlaced) return;
		TimerActive = false;
		Hand.ExecuteDetonateWithNuke();
	}

	// ── Reveal consumable ─────────────────────────────────────────
	// Reveal — shows next wave chest positions for 3 seconds
	public bool RevealActive { get; private set; } = false;
	public List<(int r, int c, GridManager.ChestType type)> RevealedPositions { get; private set; } = new();

	// Stored pre-rolled chest positions from Reveal consumable
	// null = no reveal active, use normal spawn
	List<(int r, int c, GridManager.ChestType type)> _prerolledChests = null;

	public async System.Threading.Tasks.Task RevealNextWave()
	{
		RevealedPositions.Clear();
		_prerolledChests = new();

		var empty = new List<GridManager.Cell>();
		for (int r = 0; r < 2; r++)
			for (int c = 0; c < Grid.Cols; c++)
				if (Grid.Grid[r, c].IsEmpty) empty.Add(Grid.Grid[r, c]);

		Shuffle(empty);

		int count = ChestsForWave(Wave + 1);
		for (int i = 0; i < MathF.Min(count, empty.Count); i++)
		{
			var type = RollChestType(Wave + 1);
			var cell = empty[i];
			_prerolledChests.Add((cell.Row, cell.Col, type));
			RevealedPositions.Add((cell.Row, cell.Col, type));
		}

		RevealActive = true;
		OnStateChanged?.Invoke();

		await GameTask.DelaySeconds(3f);

		RevealActive = false;
		// Keep _prerolledChests — it gets consumed in SpawnChests next wave
		OnStateChanged?.Invoke();
	}


	// ── ClusterDrop Consumable ─────────────────────────────────────────
	// ClusterDrop — spawns 3 bomb chests in optimal positions
	public void ActivateClusterDrop()
	{
		// Find empty cells in rows 0-3, score by adjacency to existing chests
		var candidates = new List<(int r, int c, int score)>();
		for (int r = 0; r < Grid.Rows - 1; r++)
			for (int c = 0; c < Grid.Cols; c++)
				if (Grid.Grid[r, c].IsEmpty)
				{
					int score = AdjacentNormalChestCount(r, c);
					candidates.Add((r, c, score));
				}

		// Sort by adjacency score descending — place near existing chests
		candidates.Sort((a, b) => b.score.CompareTo(a.score));

		int placed = 0;
		foreach (var (r, c, _) in candidates)
		{
			if (placed >= 3) break;
			var cell = Grid.Grid[r, c];
			if (!cell.IsEmpty) continue;
			cell.Chest = GridManager.ChestType.BombChest;
			cell.HitsLeft = 1;
			placed++;
		}

		Sound.Play("next_wave_spawn");
		OnStateChanged?.Invoke();
	}

	// ── Overload Consumable ─────────────────────────────────────────
	// Overload — ignore min chest requirement this wave
	public bool OverloadActive { get; private set; } = false;
	public void ActivateOverload()
	{
		OverloadActive = true;
		OnStateChanged?.Invoke();
	}

	// ── Random Events ──────────────────────────────────────────── //
	public RandomEventType? CurrentRandomEvent { get; private set; } = null;
	public bool RandomEventActive => CurrentRandomEvent.HasValue;
	float _timerFrozenFor = 0;

	bool _doubleDropActive = false;
	bool _goldRushActive = false;
	bool _bonusWaveActive = false;
	bool _ghostBombsActive = false;
	bool _mysteryWaveActive = false;
	bool _bombJamActive = false;
	bool _bossRushActive = false;
	int _extraHitsRequired = 0;
	bool _chainMasterFiredThisRun = false; 

	public bool IsDoubleDropActive  => _doubleDropActive;
	public bool IsGoldRushActive    => _goldRushActive;
	public bool IsBonusWaveActive   => _bonusWaveActive;
	public bool IsGhostBombsActive  => _ghostBombsActive;
	public bool IsMysteryWaveActive => _mysteryWaveActive;
	public bool IsBombJamActive     => _bombJamActive;
	public int  ExtraHitsRequired   => _extraHitsRequired;

	// ── Player Behavior tracking ─────────────────────────────────────────
	int _consecutiveWavesNoLifeLoss = 0;
	int _livesAtWaveStart           = 0;
	int _consecutiveFailedWaves = 0;

	// Wave ranges that guarantee a random event fires
	static readonly (int min, int max)[] EventWindows = {
		(8,  12),  (18, 23),  (30, 40),  (50, 65),
		(80, 100), (120,145), (170,200), (240,280),
		(320,370), (420,480)
	};

	int _lastEventWave = 0;

	// Mirror random event type
	bool _mirrorWaveActive = false;
	public bool IsMirrorWaveActive => _mirrorWaveActive;

	void TryTriggerRandomEvent(int wave)
	{
		// Check if we're in an event window and haven't fired one recently
		foreach (var (min, max) in EventWindows)
		{
			if (wave < min || wave > max) continue;
			if (_lastEventWave >= min) continue; // already fired in this window

			// Roll — 60% chance per wave within the window
			// Guaranteed on last wave of window
			bool guaranteed = wave == max;
			if (!guaranteed && Rng.Next(100) >= 60) continue;

			FireRandomEvent(wave);
			_lastEventWave = wave;
			return;
		}

		// Player behavior triggers
		TryTriggerBehaviorEvent(wave);
	}

	void FireRandomEvent(int wave)
	{
		// Weight positive vs negative based on wave (and player's lives)
		var pool = BuildEventPool(wave);
		var type = pool[Rng.Next(pool.Count)];
		CurrentRandomEvent = type;

		// Show milestone overlay with event description
		MilestoneOverlay.Instance?.ShowEvent(type, wave);
	}

	List<RandomEventType> BuildEventPool(int wave)
	{
		var pool = new List<RandomEventType>();

		// Always available positives
		pool.Add(RandomEventType.BonusWave);
		pool.Add(RandomEventType.FragmentRain);
		pool.Add(RandomEventType.TimerFreeze);
		pool.Add(RandomEventType.DoubleDrop);
		pool.Add(RandomEventType.ChestBonanza);

		// Unlocks as waves progress
		if (wave >= 15) pool.Add(RandomEventType.GoldRush);
		if (wave >= 25) pool.Add(RandomEventType.MysteryWave);
		if (wave >= 30) pool.Add(RandomEventType.GhostBombs);
		if (wave >= 45) pool.Add(RandomEventType.MirrorWave);

		// Negatives unlock later — player needs to be ready
		if (wave >= 20) pool.Add(RandomEventType.CursedWave);
		if (wave >= 30) pool.Add(RandomEventType.SpeedRound);
		//if (wave >= 40) pool.Add(RandomEventType.FogOfWar);
		if (wave >= 50) pool.Add(RandomEventType.BombJam);

		// Interesting ones
		if (wave >= 35) pool.Add(RandomEventType.BossRush);
		if (wave >= 45) pool.Add(RandomEventType.MirrorWave);

		// If player is struggling (lost life this wave), bias toward positive
		if (Lives < StartLives)
		{
			pool.Add(RandomEventType.BonusWave);
			pool.Add(RandomEventType.TimerFreeze);
			pool.Add(RandomEventType.FragmentRain);
		}

		return pool;
	}

	void TryTriggerBehaviorEvent(int wave)
	{
		// Chain Master — player just did a massive chain
		// (checked after detonation in HandleDetonationFinished)
		// Lucky Streak — 5 waves no life loss
		// etc — these fire instantly, no window needed

		// ── Lucky Streak — 5 consecutive waves without losing a life ── //
		// Only fires once per streak, not every wave after 5
		if (_consecutiveWavesNoLifeLoss == 5)
		{
			_consecutiveWavesNoLifeLoss = 0; // reset counter

			if(!_luckyStreakFiredThisRun)
			{
				//MilestoneOverlay.Instance?.ShowEvent(RandomEventType.FragmentRain, wave);
				_luckyStreakFiredThisRun = true;
				_lastEventWave = wave;
				CurrentRandomEvent = RandomEventType.FragmentRain;
				ApplyRandomEvent(); // instant effect
				MilestoneOverlay.Instance?.ShowMessage("🍀 Lucky Streak! +5 💠", Wave);
				return;
			}
		}

		// ── Chain Master — cleared 8+ chests last detonation ── //
		// -> Only fires once, then resets
		if ((Hand?.LastSimResult?.ClearedCount ?? 0) >= 8 && !_chainMasterFiredThisRun)
		{
			_chainMasterFiredThisRun = true; 
			MilestoneOverlay.Instance?.ShowEvent(RandomEventType.DoubleDrop, wave);
			// Don't apply yet — applied after overlay dismissed
			_lastEventWave = wave;
			CurrentRandomEvent = RandomEventType.DoubleDrop;
			Sound.Play("DoubleDrop_Coins");
			return;
		}

		// ── Mercy Wave — failed MinChests 2 waves in a row ── //
		// (tracked separately — see below)
		// => struggling player gets a bonus ── //
		if (_consecutiveFailedWaves >= 2)
		{
			_consecutiveFailedWaves = 0;
			MilestoneOverlay.Instance?.ShowEvent(RandomEventType.BonusWave, wave);
			Sound.Play("BonusWave");
			// Don't apply yet — try apply on next wave
			_lastEventWave  = wave;
			_bonusWaveActive = true;
			CurrentRandomEvent = RandomEventType.BonusWave;
		}
	}

	// Call at start of each wave in HandleDetonationFinished
	void ApplyRandomEvent()
	{
		if (!CurrentRandomEvent.HasValue) return;

		switch (CurrentRandomEvent.Value)
		{
			 case RandomEventType.FragmentRain:
            	FragmentsEarned += 5; 
				BonusEventFragments += 5;
				break;

			case RandomEventType.MysteryWave:
				_mysteryWaveActive = true;
				break;

			case RandomEventType.TimerFreeze:
				_timerFrozenFor = 8f; // count down in OnUpdate
				break;

			case RandomEventType.DoubleDrop:
				_doubleDropActive = true;
				break;

			case RandomEventType.CursedWave:
				_extraHitsRequired = 1; // all chests need +1 hit
				break;

			case RandomEventType.SpeedRound:
				TurnTime *= 0.5f;
				_maxTurnTime *= 0.5f;
				break;

			case RandomEventType.ChestBonanza:
				SpawnChests(3); // 3 extra chests on top of normal
				break;

			case RandomEventType.GoldRush:
				_goldRushActive = true; // flag checked in SpawnChests
				break;

			case RandomEventType.BombJam:
            _bombJamActive = true;
            break;

			case RandomEventType.BossRush:
			_bossRushActive = true; // flag checked in TrySpawnBossChest
			break;
			
			// BonusWave event — all chests worth 3×
			// in ExecuteDetonate() -> float bonusMult  = ChainReactionGame.Instance?.IsBonusWaveActive == true ? 3f : 1f;
			case RandomEventType.BonusWave: // if player failed chests requirements 2 times in a row 
			_bonusWaveActive = true;
			break;

			case RandomEventType.MirrorWave:
			_mirrorWaveActive = true;
			ApplyMirrorWave();
			break;
		}
	}

	// Reset event at end of wave
	void ClearRandomEvent()
	{
		 CurrentRandomEvent   = null;
		_doubleDropActive     = false;
		_goldRushActive       = false;
		_bonusWaveActive      = false;
		_ghostBombsActive     = false;
		_mysteryWaveActive    = false;
		_bombJamActive        = false;
		_bossRushActive       = false;
		_mirrorWaveActive 	  = false;
		_extraHitsRequired    = 0;
		_timerFrozenFor       = 0f;
		// note : here we don't reset _consecutiveWavesNoLifeLoss or _consecutiveFailedWaves
		// — those track across waves intentionally
	}

	// Mirror event 
	void ApplyMirrorWave()
	{
		// Mirror all existing chest positions left ↔ right
		// Step 1 — SNAPSHOT: photograph all positions before touching anything
		// i.e : snapshot = [ (1, 0, Normal, 1), (1, 4, Gem, 2) ]
		var snapshot = new List<(int r, int c, GridManager.ChestType type, int hits)>();
		// Snpashot : only retrieve cells that have chest
		for (int r = 0; r < Grid.Rows; r++)
			for (int c = 0; c < Grid.Cols; c++)
				if (Grid.Grid[r, c].HasChest)
					snapshot.Add((r, c, Grid.Grid[r, c].Chest, Grid.Grid[r, c].HitsLeft));

		// Step 2 — CLEAR: wipe the grid clean
		// i.e Grid[1,0].Chest = None
		// Grid[1,4].Chest = None
		// Grid is now empty at those positions.
		foreach (var (r, c, _, _) in snapshot)
		{
			Grid.Grid[r, c].Chest    = GridManager.ChestType.None;
			Grid.Grid[r, c].HitsLeft = 0;
		}

		// Step 3 — PLACE MIRRORED: put each chest at its mirrored column
		// (0,1) → mirroredC = (4 - 1) - 1 = 3 → place at (0,3)
		// (0,3) → mirroredC = (4 - 1) - 3 = 1 → place at (0,1)
		// Result: chests swapped left ↔ right
		foreach (var (r, c, type, hits) in snapshot)
		{
			int GridSize = Grid.Cols - 1;

			int mirroredC =  GridSize - c; // i.e : (5-1) - 0 = 4
			var target = Grid.Grid[r, mirroredC]; //  ← was just cleared, so IsEmpty = true

			// The target.IsEmpty guard handles collision  (it's from the grid cell) — 
			// if two chests mirror to the same cell (e.g. both at col 2 center), 
			// the second one just skips.
			if (target.IsEmpty) // i.e : Grid[1, 4].Chest = Normal ✅ -> it's a ref to the grid cell btw
			{
				target.Chest    = type;
				target.HitsLeft = hits;
			}
		}
		// What mirroring does :
		/*  col 0 → 4  (leftmost ↔ rightmost)
			col 1 → 3
			col 2 → 2  (center stays center)
			col 3 → 1
			col 4 → 0 
		*/

		OnStateChanged?.Invoke();
	}

	void UpdateGridSize(int wave)
	{
		int newRows = wave switch {
			>= 100 => 7,
			>= 50  => 6,
			>= 20  => 6,   // ← was 150, now 20
			_      => 5
		};
		int newCols = wave switch {
			>= 100 => 7,
			>= 50  => 6,
			>= 20  => 5,   // ← 6 rows but still 5 cols — feels wider not just taller
			_      => 5
		};

		if (newRows == Grid.Rows && newCols == Grid.Cols) return;
		Grid.ResizeGrid(newRows, newCols);
	}

	/// <summary>
	///  Void chest (new chest)
	/// </summary>
	/// <param name="row"></param>
	/// <param name="col"></param>
	public void TriggerVoidExplosion(int row, int col)
	{
		// Remove all chests in 2×2 area around the void chest
		for (int r = row - 1; r <= row + 1; r++)
			for (int c = col - 1; c <= col + 1; c++)
			{
				if (!Grid.InBounds(r, c)) continue;
				var cell = Grid.Grid[r, c];
				if (!cell.HasChest) continue;
				if (cell.Chest == GridManager.ChestType.Void) continue; // skip self

				// Flat 50 score — no multiplier, no bonus
				// Void clears are strategic, not skill-based
				Score += 50;
				OnChestDestroyed(r, c, 50, 1f);
				TotalChestsCleared++;

				cell.Chest = GridManager.ChestType.None;
				cell.HitsLeft = 0;
			}

		//ScreenFlash.Instance?.WaveStart(Wave); // reuse flash for effect
		Sound.Play("chest_break");
		OnStateChanged?.Invoke();
	}

	// used for cursed chest so far
	public void QueueTimerPenalty(float seconds)
	{
		_timerPenaltyNextWave += seconds;
	}

	int _activeCursedChests = 0;
	public int ActiveCursedChests => _activeCursedChests;
	void RecountCursedChests()
	{
		_activeCursedChests = 0;
		for (int r = 0; r < Grid.Rows; r++)
			for (int c = 0; c < Grid.Cols; c++)
				if (Grid.Grid[r, c].Chest == GridManager.ChestType.Cursed)
					_activeCursedChests++;
	}


	// * -------------------------------------------------------------------- //
	// * ------------------------- Hype meter state ------------------------- //
	// * -------------------------------------------------------------------- //

	float _hypeMeter = 0f;          // 0.0 to 1.0
	bool _hypeMaxActive = false;    // temp multiplier active
	bool _hypeTriggeredThisWave = false; // prevent double trigger
	public float HypeMeter => _hypeMeter;
	public bool HypeMaxActive => _hypeMaxActive;

	public void AddHype(int projectedCleared)
	{
		if (_hypeMaxActive) return; // draining, not filling

		float gain = projectedCleared switch {
			>= 8 => 0.40f,
			>= 6 => 0.30f,
			>= 4 => 0.20f,
			>= 2 => 0.10f,
			_    => 0f
		};

		_hypeMeter = MathF.Min(1f, _hypeMeter + gain);

		if (_hypeMeter >= 1f && !_hypeTriggeredThisWave)
		{
			_hypeTriggeredThisWave = true;
			TriggerHypeMax();
		}

		OnStateChanged?.Invoke();
	}

	void TriggerHypeMax()
	{
		_hypeMaxActive = true;
		// PopupLayer fires from GameHud — just set state here
		OnStateChanged?.Invoke();
	}

	// Called at start of each wave in HandleDetonationFinished
	public void TickHypeMeter()
	{
		// Don't drain here — OnUpdate handles continuous drain
		// Just reset the trigger flag so hype max can fire again next time
		if (!_hypeMaxActive)
			_hypeTriggeredThisWave = false;
	}


	// * -------------------------------------------------------------------- //
	// * ------------------ Smart Chest Placement Difficulty ---------------- //
	// * -------------------------------------------------------------------- //

	// ── How desirable is this cell for placement ──
	float GetPlacementScore(int r, int c)
	{
		float score = 0f;
		int centerC = Grid.Cols / 2;
		float distFromCenter = MathF.Abs(c - centerC);
		int adjacentCount = AdjacentNormalChestCount(r, c);

		if (Wave < 8)
		{
			// Very easy — cluster everything in center
			score += (Grid.Cols - distFromCenter) * 3f;
			score += adjacentCount * 4f;
		}
		else if (Wave < 20)
		{
			// Easy — slight center bias, some clustering
			score += (Grid.Cols - distFromCenter) * 1.5f;
			score += adjacentCount * 2f;
			score += Rng.Next(4); // noise keeps it feeling natural
		}
		else if (Wave < 50)
		{
			// Was too weak — double the penalties
			score += distFromCenter * 1.5f;
			score -= adjacentCount * 2f;  // was 0.5f — this is what breaks clusters
			score += Rng.Next(3);
		}
		else if (Wave < 100)
		{
			// Hard — prefer spread, penalize clustering
			score += distFromCenter * 2f;
			score -= adjacentCount * 1.5f;
			score += Rng.Next(2);
		}
		else if (Wave < 200)
		{
			// Very hard — edges preferred, clustering punished
			bool isEdge = c == 0 || c == Grid.Cols - 1;
			score += distFromCenter * 3f;
			score -= adjacentCount * 2.5f;
			if (isEdge) score += 2f;
			score += Rng.Next(2);
		}
		else
		{
			// Wave 200+ — maximum spread, corners and edges strongly preferred
			bool isEdge = c == 0 || c == Grid.Cols - 1;
			bool isCorner = isEdge && (r == 0 || r == Grid.Rows - 1);
			score += distFromCenter * 4f;
			score -= adjacentCount * 3f;
			if (isEdge)   score += 3f;
			if (isCorner) score += 2f;
			// No noise at 200+ — placement is deliberately punishing
		}

		return score;
	}

	bool TooCloseToExistingBombChest(int r, int c, int minDist = 2)
	{
		for (int row = 0; row < Grid.Rows; row++)
			for (int col = 0; col < Grid.Cols; col++)
			{
				if (Grid.Grid[row, col].Chest != GridManager.ChestType.BombChest) continue;
				if (MathF.Abs(row - r) <= minDist && MathF.Abs(col - c) <= minDist)
					return true;
			}
		return false;
	}

	// ── Count existing bomb chests across the entire grid ──
	int CountBombChestsOnGrid()
	{
		int count = 0;
		for (int r = 0; r < Grid.Rows; r++)
			for (int c = 0; c < Grid.Cols; c++)
				if (Grid.Grid[r, c].Chest == GridManager.ChestType.BombChest)
					count++;
		return count;
	}


	// * -------------------------------------------------------------------- //
	// * ------------------------- GAME MODE SURVIVAL ----------------------- //
	// * -------------------------------------------------------------------- //

	float _survivalSlideTimer = 0f;
	float _survivalSlideInterval = 3.0f; // starts slower than spawn

	int _survivalPhase = 0;
	bool _survivalBombChestBoost = false;

	bool _survivalPhaseInitialized = false;

	void TickSurvival()
	{
		if (_survivalSpawnFrozen || MilestoneOverlay.Instance?.IsActive == true)
			return;

		_survivalElapsed += Time.Delta;

		int minute = (int)(_survivalElapsed / 60f);
		if (minute != _survivalPhase || !_survivalPhaseInitialized)
		{
			_survivalPhase = minute;
			_survivalPhaseInitialized = true;
			OnSurvivalPhaseChange(minute);
		}

		// ── Empty-board watchdog — caps dead-air time without touching pacing while board is occupied ──
		bool boardEmpty = Grid.AllChestCells().Count == 0;
		if (boardEmpty)
		{
			if (_survivalEmptySince < 0f)
				_survivalEmptySince = _survivalElapsed;
			else if (_survivalElapsed - _survivalEmptySince >= SurvivalEmptyGraceSeconds)
				_survivalSpawnTimer = 0f; // force the next spawn now
		}
		else
		{
			_survivalEmptySince = -1f;
		}

		_survivalSpawnTimer -= Time.Delta;
		if (_survivalSpawnTimer <= 0f)
		{
			_survivalSpawnTimer = _survivalSpawnInterval;
			SurvivalSpawnWave();
		}

		int currentMinute = (int)(_survivalElapsed / 60f);
		if (currentMinute > _survivalLastMilestoneMinute)
		{
			_survivalLastMilestoneMinute = currentMinute;
			TriggerSurvivalMilestone(currentMinute);
		}
	}

	void OnSurvivalPhaseChange(int minute)
	{
		int phaseInCycle = minute % 6;

		_survivalPhaseIntervalMult = 1f;
		_survivalPhaseBatchBonus   = 0;
		_survivalBombChestBoost    = false; // add this — was never reset before

		switch (phaseInCycle)
		{
			case 0:
				break;

			case 1:
				break;

			case 2:
				_survivalPhaseIntervalMult = 0.94f;
				_survivalBombChestBoost = true;
				MilestoneOverlay.Instance?.ShowMessage("💣 More bomb chests incoming!", 0, 3f);
				break;

			case 3:
				SpawnSurvivalMiniBoss();
				break;

			case 4:
				_survivalGoldRushMinute = true;
				MilestoneOverlay.Instance?.ShowMessage("💎 Gold Rush! Gems incoming!", 0, 3f);
				break;

			case 5:
				_survivalScoreBoostUntil = _survivalElapsed + 10f;
				MilestoneOverlay.Instance?.ShowMessage("⭐ 2x SCORE for 10 seconds!", 0, 3f);
				break;
		}
	}

	void SpawnSurvivalMiniBoss()
	{
		// find an empty top-row cell
		var empty = new List<GridManager.Cell>();
		for (int c = 0; c < Grid.Cols; c++)
			if (Grid.Grid[0, c].IsEmpty) empty.Add(Grid.Grid[0, c]);
		
		if (empty.Count == 0) return;
		
		var cell = empty[Rng.Next(empty.Count)];
		cell.Chest = GridManager.ChestType.Boss;
		cell.HitsLeft = 4; // tune HP
		
		MilestoneOverlay.Instance?.ShowMessage("👑 A boss chest appeared! Clear it before it reaches the bottom!", 0, 4f);
		Sound.Play("boss_alert");
	}
	
	void SurvivalSpawnWave()
	{
		_survivalFakeWave = 1 + (SurvivalSpawnCount / 12);

		var (baseMin, baseMax) = GetSurvivalBaseBatch();
		int min = Math.Max(2, baseMin + _survivalPhaseBatchBonus);
		int max = Math.Max(min, baseMax + _survivalPhaseBatchBonus);

		int count = Rng.Next(min, max + 1);

		if (!SurvivalAdvanceAndSpawn(count))
			return;

		_survivalSpawnInterval = MathF.Max(
			SurvivalIntervalFloor * 0.8f,
			GetSurvivalBaseInterval() * _survivalPhaseIntervalMult
		);

		OnStateChanged?.Invoke();
	}

	// Survival-only: slide chests down, and if a chest lands on a bomb cell, trigger it
	List<(int r, int c)> _survivalTriggeredBombs = new();

	void SurvivalSlideAndCheckBombTriggers()
	{
		_survivalTriggeredBombs.Clear();

		for (int r = Grid.Rows - 2; r >= 0; r--)
			for (int c = 0; c < Grid.Cols; c++)
			{
				var cell = Grid.Grid[r, c];
				if (!cell.HasChest) continue;
				if (cell.Chest == GridManager.ChestType.Secret) continue;
				if (cell.Chest == GridManager.ChestType.Boss) continue;
				if (cell.Chest == GridManager.ChestType.Void) continue;

				int nr = r + 1;
				var below = Grid.Grid[nr, c];

				// Chest slides into a cell with a placed bomb — mark for trigger
				if (below.HasBomb && !below.HasChest)
				{
					below.Chest = cell.Chest;
					below.HitsLeft = cell.HitsLeft;
					cell.Chest = GridManager.ChestType.None;
					cell.HitsLeft = 0;

					_survivalTriggeredBombs.Add((nr, c));
				}
				else if (below.IsEmpty)
				{
					below.Chest = cell.Chest;
					below.HitsLeft = cell.HitsLeft;
					cell.Chest = GridManager.ChestType.None;
					cell.HitsLeft = 0;
				}
			}
	}

	// Thin wrappers so we never touch the real Wave-driven side effects (grid resize, boss spawn, etc.)
	int SurvivalChestsForWave(int fakeWave) => ChestsForWave(fakeWave); // reuse existing curve directly — no side effects in that method

	void SurvivalSpawnChests(int count)
	{
		var empty = new List<GridManager.Cell>();
		int spawnRows = _survivalFakeWave switch { >= 200 => 4, >= 100 => 3, _ => 2 };
		for (int r = 0; r < spawnRows; r++)
			for (int c = 0; c < Grid.Cols; c++)
				if (Grid.Grid[r, c].IsEmpty) empty.Add(Grid.Grid[r, c]);

		Shuffle(empty);

		for (int i = 0; i < MathF.Min(count, empty.Count); i++)
		{
			var type = RollSurvivalChestType();
			empty[i].Chest = type;
			int baseHits = type switch {
				GridManager.ChestType.Gem    => 2,
				GridManager.ChestType.Locked => 3,
				_                            => 1
			};
			empty[i].HitsLeft = baseHits;
		}

		_survivalGoldRushMinute = false; // one-shot, consumed after this batch
		Sound.Play("next_wave_spawn");
	}

	// Survival-specific chest roll — small bomb-chest chance from the very start,
	// scales up gently as _survivalFakeWave climbs
	GridManager.ChestType RollSurvivalChestType()
	{
		if (_survivalGoldRushMinute)
			return GridManager.ChestType.Gem;

		int roll = Rng.Next(100);
		int bombBoost = _survivalBombChestBoost ? 15 : 0; // shifts 15% of roll odds toward bomb chests

		if (_survivalFakeWave <= 2)
			return roll < (75 - bombBoost) ? GridManager.ChestType.Normal
				: roll < (90 - bombBoost) ? GridManager.ChestType.Gem
				:                            GridManager.ChestType.BombChest; // 10% → 25% when boosted

		if (_survivalFakeWave <= 6)
			return roll < (55 - bombBoost) ? GridManager.ChestType.Normal
				: roll < (78 - bombBoost) ? GridManager.ChestType.Gem
				:                            GridManager.ChestType.BombChest; // 22% → 37% when boosted

		// Late game — shared wave-mode curve already scales bomb chest odds with wave;
		// boost doesn't stack here to avoid runaway density at high fakeWave
		return RollChestType(_survivalFakeWave);
	}

	async void TriggerSurvivalMilestone(int minute)

	{
		// Cycle through reward types so runs feel varied minute to minute
		string title;
		string desc;

		switch (minute % 4)
		{
			case 1: // extra bomb slot, capped
				if (SurvivalBonusBombSlots < MaxSurvivalBonusSlots)
				{
					SurvivalBonusBombSlots++;
					Hand.HandSize++;
					title = "💣 EXTRA BOMB SLOT!";
					desc  = $"Your hand grows to {Hand.HandSize} bombs.";
				}
				else
				{
					// fallback once capped — small score burst instead
					Score += 500;
					title = "⚡ +500 BONUS!";
					desc  = "Max bomb slots reached — score burst instead.";
				}
				break;

			case 2: // brief breather — spawn pauses for 3s
				_survivalSpawnTimer += 3f;
				title = "🌬️ BREATHER!";
				desc  = "Spawns pause briefly. Catch your breath.";
				break;

			case 3: // fragment bonus, scales with minute
				int frags = 3 + minute;
				FragmentsEarned += frags;
				title = $"💠 +{frags} FRAGMENTS!";
				desc  = "Bonus fragments for surviving this long.";
				break;

			default: // score multiplier moment — clear a free row
				SurvivalClearRandomRow();
				title = "🧹 ROW CLEARED!";
				desc  = "A random row was wiped clean for you.";
				break;
		}

		MilestoneOverlay.Instance?.ShowMessage($"⏱️ {minute} min survived! {title}", 0, 5f);
		Sound.Play("WaveMilestone");
		OnStateChanged?.Invoke();

		// Wait for overlay to be dismissed (same pattern wave mode uses)
		while (MilestoneOverlay.Instance?.IsActive == true)
			await GameTask.DelaySeconds(0.1f);

		// Small grace buffer after dismiss so the player has a beat before spawns resume
		await GameTask.DelaySeconds(0.4f);

		_survivalSpawnFrozen = false;
		_survivalSpawnTimer = _survivalSpawnInterval; // don't let a stale short timer fire instantly

	}

	void SurvivalClearRandomRow()
	{
		var rowsWithChests = new List<int>();
		for (int r = 0; r < Grid.Rows; r++)
			for (int c = 0; c < Grid.Cols; c++)
				if (Grid.Grid[r, c].HasChest) { rowsWithChests.Add(r); break; }

		if (rowsWithChests.Count == 0) return;
		int row = rowsWithChests[Rng.Next(rowsWithChests.Count)];

		for (int c = 0; c < Grid.Cols; c++)
		{
			var cell = Grid.Grid[row, c];
			if (!cell.HasChest) continue;
			Score += 50;
			TotalChestsCleared++;
			cell.Chest = GridManager.ChestType.None;
			cell.HitsLeft = 0;
		}
	}

	GridManager.ChestType RollSurvivalChestTypeEarly()
	{
		int roll = Rng.Next(100);
		return roll < 75 ? GridManager.ChestType.Normal
			: roll < 90 ? GridManager.ChestType.Gem
			:              GridManager.ChestType.BombChest; // ~10% from the very start
	}

	static float Lerp(float a, float b, float t) => a + (b - a) * t;

	float GetSurvivalBaseInterval()
	{
		float t = MathF.Max(0f, MathF.Min(1f, _survivalElapsed / 60f / SurvivalRampMinutes));
		return Lerp(SurvivalIntervalStart, SurvivalIntervalFloor, t);
	}

	(int min, int max) GetSurvivalBaseBatch()
	{
		float t = MathF.Max(0f, MathF.Min(1f, _survivalElapsed / 60f / SurvivalRampMinutes));
		int max = (int)MathF.Round(Lerp(SurvivalBatchMaxStart, SurvivalBatchMaxFloor, t));
		int min = (int)MathF.Round(Lerp(SurvivalBatchMinStart, SurvivalBatchMinFloor, t));
		min = Math.Max(min, 2);
		max = Math.Max(max, min);
		return (min, max);
	}

	// Shared by both the periodic timer and detonation-triggered top-ups —
	// keeps slide/game-over/spawn logic in exactly one place.
	bool SurvivalAdvanceAndSpawn(int count)
	{
		SlideChestsDown();

		if (Grid.AnyChestInLastRow())
		{
			TriggerGameOver("💀 A chest reached the bottom! Survival run over.");
			return false; // game over already triggered — caller should stop
		}

		SurvivalSpawnChests(count);
		SurvivalSpawnCount++;
		return true;
	}
}