Game/Idle/UI/IdleHud.razor
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@inherits PanelComponent
@namespace Sandbox

@*
	IMPORTANT — s&box text rendering quirk.

	Razor compiles a literal containing non-ASCII characters (emoji, ×, ·, —)
	into AddMarkupContent, and plain-ASCII literals into AddContent. The panel
	renderer hoists every AddMarkupContent node ahead of every AddContent node,
	so "@A × @B" comes out as "×AB".

	The rule this file follows: an element holds EITHER pure literal text OR a
	single @expression. Anything composed goes through a helper below.
*@

<root>
@if ( Visible )
{
	<div class="idle-root">

		@* ══════════════════════ AMBIENT BACKDROP ══════════════════════ *@
		<div class="idle-bg"></div>
		<div class="idle-bg-glow @GlowClass()"></div>

		@for ( int i = 0; i < 12; i++ )
		{
			float pLeft = (i * 8.3f) % 100f;
			float pDur  = 10f + (i * 0.9f) % 8f;
			float pDel  = (i * 1.4f) % 12f;
			int   pSz   = (i % 4 == 0) ? 4 : 2;
			string pCol = (i % 3 == 0) ? "rgba(94,234,212,0.45)" : "rgba(96,165,250,0.30)";
			<div class="idle-particle"
				 style="left:@pLeft.ToString("F1")%;width:@(pSz)px;height:@(pSz)px;background-color:@pCol;animation-duration:@pDur.ToString("F1")s;animation-delay:@pDel.ToString("F1")s;"></div>
		}

		@* ══════════════════════════ TOP BAR ══════════════════════════ *@
		<div class="idle-top">
			<button class="idle-back" onclick=@OnBack>⌂</button>

			<div class="idle-brand">
				<div class="idle-brand-title">THE REACTOR</div>
				<div class="idle-brand-sub">chain reactions, forever</div>
			</div>

			<div class="idle-currencies">
				<div class="idle-cur cores">
					<span class="idle-cur-icon">⚛️</span>
					<span class="idle-cur-val">@CoresLabel()</span>
					<span class="idle-cur-lbl">Cores</span>
				</div>
				<div class="idle-cur rate">
					<span class="idle-cur-icon">📈</span>
					<span class="idle-cur-val">@RateLabel()</span>
					<span class="idle-cur-lbl">per sec</span>
				</div>
				<div class="idle-cur flux">
					<span class="idle-cur-icon">🔷</span>
					<span class="idle-cur-val">@FluxLabel()</span>
					<span class="idle-cur-lbl">Flux</span>
				</div>
				<div class="idle-cur frag">
					<span class="idle-cur-icon">💠</span>
					<span class="idle-cur-val">@FragLabel()</span>
					<span class="idle-cur-lbl">Frags</span>
				</div>
			</div>
		</div>

		@* ═══════════════════════════ BODY ════════════════════════════ *@
		@* Hidden behind a modal — the panel renderer draws emoji glyphs above
		   overlay panels, so the board has to actually leave the tree. *@
		@if ( !ModalOpen )
		{
		<div class="idle-body">

			@* ─────────────────────── LEFT COLUMN ─────────────────────── *@
			<div class="idle-col idle-left">

				<div class="idle-card">
					<div class="idle-card-hd">OUTPUT</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Cores / sec</span>
						<span class="idle-stat-val hot">@RateLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Global multiplier</span>
						<span class="idle-stat-val">@GlobalMultLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Chain exponent</span>
						<span class="idle-stat-val">@ExponentLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Last chain</span>
						<span class="idle-stat-val">@LastChainLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Best chain</span>
						<span class="idle-stat-val">@BestChainLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Board</span>
						<span class="idle-stat-val">@BoardLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Feed line</span>
						<span class="idle-stat-val @FeedClass()">@FeedLabel()</span>
					</div>
				</div>

				@if ( ShowBoosts )
				{
					<div class="idle-card">
						<div class="idle-card-hd">ACTIVE</div>
						@if ( R.SurgeActive )
						{
							<div class="idle-boost surge">
								<span class="idle-boost-icon">⚡</span>
								<span class="idle-boost-lbl">@SurgeBoostLabel()</span>
								<span class="idle-boost-time">@SecondsLabel( R.SurgeTimeLeft )</span>
							</div>
						}
						@foreach ( var boost in R.Boosts )
						{
							var b = boost;
							<div class="idle-boost">
								<span class="idle-boost-icon">@b.Icon</span>
								<span class="idle-boost-lbl">@BoostLabel( b )</span>
								<span class="idle-boost-time">@SecondsLabel( b.SecondsLeft )</span>
							</div>
						}
					</div>
				}

				<div class="idle-card">
					<div class="idle-card-hd">CONDENSER</div>
					<div class="idle-bar-track">
						<div class="idle-bar-fill frag" style="width:@Pct( R.FragmentProgressFraction )%;"></div>
					</div>
					<div class="idle-card-note">@CondenserLabel()</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Today</span>
						<span class="idle-stat-val @CapClass()">@FragTodayLabel()</span>
					</div>
				</div>

				<div class="idle-card">
					<div class="idle-card-hd">MANIFEST</div>
					@foreach ( var t in IdleBalance.Tiers )
					{
						var tier = t;
						bool got = S.CycleCores >= tier.Requires;
						<div class="idle-tier @(got ? "on" : "")">
							<span class="idle-tier-icon">@tier.Icon</span>
							<div class="idle-tier-body">
								<div class="idle-tier-name">@tier.Name</div>
								<div class="idle-tier-blurb">@TierBlurb( tier, got )</div>
							</div>
						</div>
					}
				</div>

			</div>

			@* ────────────────────── CENTER COLUMN ────────────────────── *@
			<div class="idle-col idle-center">

				<div class="idle-board-wrap">
					<div class="idle-board @SurgeClass() @BoardShake()">
						@for ( int r = 0; r < R.Size; r++ )
						{
							<div class="idle-row">
								@for ( int c = 0; c < R.Size; c++ )
								{
									int row = r;
									int col = c;
									var cell = R.Board[row, col];
									bool golden = R.GoldenActive && R.GoldenRow == row && R.GoldenCol == col;

									<div class="idle-cell @CellClass( cell, golden )"
										 style="width:@(CellPx)px;height:@(CellPx)px;"
										 onclick=@(() => OnCellClick( row, col ))>

										@if ( golden )
										{
											<div class="idle-golden">🌟</div>
											<div class="idle-golden-timer">@GoldenTimerLabel()</div>
										}
										@if ( !golden && cell.HasChest )
										{
											<div class="idle-chest" style="@ChestStyle( cell )">@ChestGlyph( cell )</div>
										}
										@if ( !golden && cell.HasChest && cell.MaxHits > 1 )
										{
											<div class="idle-hits">@cell.HitsLeft</div>
										}
										@if ( cell.HasDrone )
										{
											<div class="idle-drone" style="@DroneStyle( cell )">💣</div>
										}
									</div>
								}
							</div>
						}
					</div>

					@foreach ( var floater in _floaters )
					{
						var f = floater;
						<div class="idle-float @f.Cls"
							 style="left:@f.X.ToString("F1")%;top:@f.Y.ToString("F1")%;opacity:@f.Alpha.ToString("F2");transform:translateY(@f.Rise.ToString("F0")px) scale(@f.Scale.ToString("F2"));">
							@f.Text
						</div>
					}

					@if ( _calloutLife > 0f )
					{
						<div class="idle-callout @_calloutCls" style="opacity:@_calloutLife.ToString("F2");">@_calloutText</div>
					}
				</div>

				<div class="idle-controls">

					<button class="idle-overload @OverloadClass()" onclick=@OnOverload>
						<div class="idle-overload-fill" style="width:@OverloadFillPct()%;"></div>
						<span class="idle-overload-label">@OverloadLabel()</span>
						<span class="idle-overload-sub">@OverloadSubLabel()</span>
					</button>

					<div class="idle-surge-wrap">
						<div class="idle-surge-track @SurgeReadyClass()">
							<div class="idle-surge-fill" style="width:@Pct( R.Charge )%;"></div>
							<span class="idle-surge-lbl">@SurgeLabel()</span>
						</div>
						@if ( R.SurgeReady )
						{
							<button class="idle-surge-btn" onclick=@OnSurge>@UnleashLabel()</button>
						}
					</div>
				</div>

				<div class="idle-meltdown-strip @MeltdownStripClass()">
					<span class="idle-melt-icon">☢️</span>
					<span class="idle-melt-text">@MeltdownLabel()</span>
					@if ( R.CanMeltdown )
					{
						<button class="idle-melt-btn" onclick=@OnOpenMeltdown>REVIEW</button>
					}
					@if ( !R.CanMeltdown )
					{
						<div class="idle-melt-bar">
							<div class="idle-melt-fill" style="width:@Pct( R.MeltdownProgress )%;"></div>
						</div>
					}
				</div>
			</div>

			@* ─────────────────────── RIGHT COLUMN ────────────────────── *@
			<div class="idle-col idle-right">

				<div class="idle-tabs">
					<button class="idle-tab @TabClass("reactor")" onclick=@(() => SetTab("reactor"))>⚙️</button>
					<button class="idle-tab @TabClass("chains")"  onclick=@(() => SetTab("chains"))>⛓️</button>
					<button class="idle-tab @TabClass("systems")" onclick=@(() => SetTab("systems"))>🛠️</button>
					<button class="idle-tab fluxtab @TabClass("flux")" onclick=@(() => SetTab("flux"))>🔷</button>
					<button class="idle-tab @TabClass("log")" onclick=@(() => SetTab("log"))>📜</button>
				</div>

				@if ( IsUpgradeTab )
				{
					<div class="idle-buyrow">
						<span class="idle-buyrow-lbl">BUY</span>
						@foreach ( var amount in BuyAmounts )
						{
							int amt = amount;
							<button class="idle-buy-amt @BuyAmtClass( amt )" onclick=@(() => SetBuyAmount( amt ))>@BuyAmountLabel( amt )</button>
						}
					</div>
				}

				<div class="idle-panel">

					@if ( IsUpgradeTab )
					{
						@foreach ( var upgrade in IdleUpgradeCatalog.InCategory( CurrentCategory ) )
						{
							var up = upgrade;
							bool revealed = R.IsRevealed( up );
							int  lvl      = S.Level( up.Id );
							bool maxed    = lvl >= up.MaxLevel;
							int  count    = revealed ? BuyCount( up.Id ) : 0;
							double cost   = (!revealed || maxed) ? 0 : R.CostFor( up.Id, count );
							bool afford   = revealed && !maxed && count > 0 && Cores >= cost;

							<div class="idle-up @UpgradeClass( revealed, maxed, afford )" onclick=@(() => OnBuy( up.Id ))>
								<div class="idle-up-icon">@UpgradeIcon( up, revealed )</div>
								<div class="idle-up-body">
									<div class="idle-up-top">
										<span class="idle-up-name">@UpgradeName( up, revealed )</span>
										<span class="idle-up-lvl">@LevelLabel( up, lvl, revealed )</span>
									</div>
									<div class="idle-up-desc">@UpgradeDesc( up, revealed )</div>
									@if ( revealed )
									{
										<div class="idle-up-bottom">
											<span class="idle-up-effect">@up.Effect( lvl )</span>
											<span class="idle-up-cost @MaxedClass( maxed )">@CostLabel( maxed, cost, count )</span>
										</div>
									}
								</div>
							</div>
						}
					}

					@if ( _tab == "flux" )
					{
						<div class="idle-flux-head">
							<div class="idle-flux-amount">@FluxHeadLabel()</div>
							<div class="idle-flux-sub">@FluxSubLabel()</div>
						</div>

						@foreach ( var perk in IdleUpgradeCatalog.Perks )
						{
							var p      = perk;
							int lvl    = S.PerkLevel( p.Id );
							bool maxed = lvl >= p.MaxLevel;
							int cost   = p.CostAt( lvl );
							bool afford = !maxed && S.Flux >= cost;

							<div class="idle-up perk @UpgradeClass( true, maxed, afford )" onclick=@(() => OnBuyPerk( p.Id ))>
								<div class="idle-up-icon">@p.Icon</div>
								<div class="idle-up-body">
									<div class="idle-up-top">
										<span class="idle-up-name">@p.Name</span>
										<span class="idle-up-lvl">@PerkLevelLabel( p, lvl )</span>
									</div>
									<div class="idle-up-desc">@p.Desc</div>
									<div class="idle-up-bottom">
										<span class="idle-up-effect">@p.Effect( lvl )</span>
										<span class="idle-up-cost flux @MaxedClass( maxed )">@PerkCostLabel( maxed, cost )</span>
									</div>
								</div>
							</div>
						}
					}

					@if ( _tab == "log" )
					{
						<div class="idle-flux-head">
							<div class="idle-flux-amount">@LogHeadLabel()</div>
							<div class="idle-flux-sub">Reactor Log — every entry pays out once.</div>
						</div>

						@foreach ( var achievement in IdleAchievements.All )
						{
							var a = achievement;
							bool done = S.HasAchievement( a.Id );

							<div class="idle-up ach @(done ? "done" : "")">
								<div class="idle-up-icon">@AchIcon( a, done )</div>
								<div class="idle-up-body">
									<div class="idle-up-top">
										<span class="idle-up-name">@a.Name</span>
										<span class="idle-ach-reward">@RewardLabel( a )</span>
									</div>
									<div class="idle-up-desc">@a.Desc</div>
									@if ( !done )
									{
										<div class="idle-ach-bar">
											<div class="idle-ach-fill" style="width:@Pct( a.Fraction( S ) )%;"></div>
										</div>
										<div class="idle-ach-prog">@ProgressLabel( a )</div>
									}
								</div>
							</div>
						}
					}
				</div>
			</div>
		</div>
		}

		@* ═════════════════════════ TOASTS ═════════════════════════ *@
		<div class="idle-toasts">
			@foreach ( var toast in _toasts )
			{
				var t = toast;
				<div class="idle-toast @t.Cls" style="opacity:@t.Alpha.ToString("F2");">
					<span class="idle-toast-icon">@t.Icon</span>
					<span class="idle-toast-text">@t.Text</span>
				</div>
			}
		</div>

		@* ═══════════════════ WELCOME BACK OVERLAY ═══════════════════ *@
		@if ( R.OfflinePending )
		{
			<div class="idle-modal-bg"></div>
			<div class="idle-modal offline">
				<div class="idle-modal-eyebrow">REACTOR REPORT</div>
				<div class="idle-modal-title">Welcome back</div>
				<div class="idle-modal-sub">@OfflineSubLabel()</div>

				<div class="idle-offline-haul">
					<div class="idle-offline-amount">@OfflineAmountLabel()</div>
					<div class="idle-offline-lbl">CORES</div>
				</div>

				@if ( R.PendingOfflineFrags > 0 )
				{
					<div class="idle-offline-extra">@OfflineFragLabel()</div>
				}

				@if ( OfflineTankFull )
				{
					<div class="idle-offline-warn">@OfflineWarnLabel()</div>
				}

				<button class="idle-modal-btn" onclick=@OnClaimOffline>COLLECT</button>
			</div>
		}

		@* ═══════════════════ DAILY STREAK OVERLAY ═══════════════════ *@
		@if ( !R.OfflinePending && !_showIntro && R.DailyPending )
		{
			<div class="idle-modal-bg"></div>
			<div class="idle-modal daily">
				<div class="idle-modal-eyebrow">DAILY CYCLE</div>
				<div class="idle-modal-title">@DailyTitleLabel()</div>
				<div class="idle-modal-sub">Come back tomorrow to keep the streak climbing. Day 7 pays eight times.</div>

				<div class="idle-streak-row">
					@for ( int d = 1; d <= 7; d++ )
					{
						int day = d;
						<div class="idle-streak-pip @StreakPipClass( day )">@day</div>
					}
				</div>

				<div class="idle-daily-reward">
					<div class="idle-daily-mult">@DailyMultLabel()</div>
					<div class="idle-daily-lbl">@DailyRewardLabel()</div>
				</div>

				<button class="idle-modal-btn" onclick=@OnClaimDaily>CLAIM</button>
			</div>
		}

		@* ══════════════════════ MELTDOWN MODAL ══════════════════════ *@
		@if ( !R.OfflinePending && !_showIntro && !R.DailyPending && _showMeltdown )
		{
			<div class="idle-modal-bg"></div>
			<div class="idle-modal meltdown">
				<div class="idle-modal-eyebrow">PRESTIGE</div>
				<div class="idle-modal-title">MELTDOWN</div>
				<div class="idle-modal-sub">Collapse this cycle into permanent Flux. Every Flux you have ever earned adds income forever, and Flux buys perks that survive every reset.</div>

				<div class="idle-melt-payout">
					<div class="idle-melt-payout-val">@MeltPayoutLabel()</div>
					<div class="idle-melt-payout-lbl">FLUX</div>
				</div>

				<div class="idle-melt-cols">
					<div class="idle-melt-col keep">
						<div class="idle-melt-col-hd">KEPT</div>
						<div class="idle-melt-line">Flux and every perk</div>
						<div class="idle-melt-line">Reactor Log entries</div>
						<div class="idle-melt-line">Records and Fragments</div>
						<div class="idle-melt-line">Kickstart head start</div>
					</div>
					<div class="idle-melt-col lose">
						<div class="idle-melt-col-hd">BURNED</div>
						<div class="idle-melt-line">@BurnedCoresLabel()</div>
						<div class="idle-melt-line">All Core upgrades</div>
						<div class="idle-melt-line">Reactor Frame size</div>
						<div class="idle-melt-line">Unlocked chest tiers</div>
					</div>
				</div>

				<div class="idle-modal-btns">
					<button class="idle-modal-btn ghost" onclick=@OnCloseMeltdown>Not yet</button>
					<button class="idle-modal-btn danger" onclick=@OnMeltdown>MELT DOWN</button>
				</div>
			</div>
		}

		@* ════════════════════════ INTRO MODAL ═══════════════════════ *@
		@if ( !R.OfflinePending && _showIntro )
		{
			<div class="idle-modal-bg"></div>
			<div class="idle-modal intro">
				<div class="idle-modal-eyebrow">HOW IT WORKS</div>
				<div class="idle-modal-title">THE REACTOR</div>
				<div class="idle-modal-sub">A board that plays itself. Chests drop, drones bomb them, chain reactions pay Cores. You never lose here — you only grow.</div>

				<div class="idle-intro-steps">
					<div class="idle-intro-step">
						<span class="idle-intro-num">1</span>
						<span class="idle-intro-txt">Spend Cores on the right. Longer chains are worth far more than richer chests.</span>
					</div>
					<div class="idle-intro-step">
						<span class="idle-intro-num">2</span>
						<span class="idle-intro-txt">Hit OVERLOAD, or press SPACE, for a burst. It charges the Surge meter — ten times output when you unleash it.</span>
					</div>
					<div class="idle-intro-step">
						<span class="idle-intro-num">3</span>
						<span class="idle-intro-txt">It keeps running while you are on the menu or another screen. Close the game and it stops.</span>
					</div>
					<div class="idle-intro-step">
						<span class="idle-intro-num">4</span>
						<span class="idle-intro-txt">Cores condense into Fragments, which feed your companion for a permanent output bonus. Meltdown turns a whole cycle into Flux.</span>
					</div>
				</div>

				<button class="idle-modal-btn" onclick=@OnCloseIntro>START THE REACTOR</button>
			</div>
		}
	</div>
}
</root>

@code {
	public static IdleHud Instance { get; private set; }

	IdleReactor       R    => IdleReactor.Instance;
	ChainReactionGame Game => ChainReactionGame.Instance;
	IdleSave          S    => ChainReactionGame.Save?.Idle;

	bool Visible => Game?.GameStarted == true
				 && Game?.Mode == GameMode.Idle
				 && R != null && S != null;

	double Cores => S?.Cores ?? 0;

	bool ModalOpen  => R.OfflinePending || R.DailyPending || _showMeltdown || _showIntro;
	bool ShowBoosts => R.Boosts.Count > 0 || R.SurgeActive;
	bool OfflineTankFull => R.PendingOfflineSeconds > R.OfflineCapHours * 3600f;

	// ── UI state ──────────────────────────────────────────────────
	string _tab       = "reactor";
	int    _buyAmount = 1;                      // -1 == MAX
	bool   _showMeltdown;
	bool   _showIntro;
	bool   _subscribed;
	bool   _wasVisible;

	static readonly int[] BuyAmounts = { 1, 10, 25, -1 };

	bool IsUpgradeTab => _tab == "reactor" || _tab == "chains" || _tab == "systems";

	IdleUpgradeCategory CurrentCategory => _tab switch
	{
		"reactor" => IdleUpgradeCategory.Reactor,
		"chains"  => IdleUpgradeCategory.Chains,
		_         => IdleUpgradeCategory.Systems,
	};

	// ── Floating numbers ──────────────────────────────────────────
	class Floater
	{
		public string Text;
		public string Cls;
		public float X, Y;
		public float Life, MaxLife;
		public float Alpha => MathF.Min( 1f, Life / 0.35f );
		public float Rise  => -(MaxLife - Life) * 42f;
		public float Scale => 0.85f + MathF.Min( 0.35f, (MaxLife - Life) * 1.2f );
	}
	readonly List<Floater> _floaters = new();

	class ToastMsg
	{
		public string Icon;
		public string Text;
		public string Cls;
		public float Life;
		public float Alpha => MathF.Min( 1f, Life / 0.4f );
	}
	readonly List<ToastMsg> _toasts = new();

	string _calloutText = "";
	string _calloutCls  = "";
	float  _calloutLife;

	// Board shake is kept local rather than going through ScreenShaker so the
	// class string stays stable between rebuilds — a changing class would
	// restart the animation on every repaint and read as a vibration.
	string _shakeCls = "";
	float  _shakeUntil;

	int CellPx => R == null ? 92 : R.Size switch
	{
		5 => 96,
		6 => 84,
		7 => 74,
		8 => 66,
		_ => 59,
	};

	// ══════════════════════════════════════════════════════════════
	// Lifecycle
	// ══════════════════════════════════════════════════════════════

	protected override void OnStart()
	{
		Instance = this;
	}

	protected override void OnUpdate()
	{
		if ( !_subscribed && R != null ) Subscribe();

		bool vis = Visible;
		if ( vis != _wasVisible )
		{
			_wasVisible = vis;
			if ( vis )
			{
				_floaters.Clear();
				_toasts.Clear();
				_calloutLife  = 0f;
				_shakeUntil   = 0f;
				_showMeltdown = false;
				_showIntro    = S != null && !S.IntroShown;
				_tab          = "reactor";
			}
			StateHasChanged();
		}

		if ( !vis ) return;

		// SPACE is the detonate key everywhere else in the game, so it fires the
		// reactor's manual burst here too.
		if ( Input.Pressed( "Jump" ) && R.OverloadReady && !ModalOpen )
			R.Overload();

		float dt = Time.Delta;

		for ( int i = _floaters.Count - 1; i >= 0; i-- )
		{
			_floaters[i].Life -= dt;
			if ( _floaters[i].Life <= 0f ) _floaters.RemoveAt( i );
		}

		for ( int i = _toasts.Count - 1; i >= 0; i-- )
		{
			_toasts[i].Life -= dt;
			if ( _toasts[i].Life <= 0f ) _toasts.RemoveAt( i );
		}

		if ( _calloutLife > 0f ) _calloutLife -= dt * 1.4f;
	}

	void Subscribe()
	{
		var r = R;
		if ( r == null || _subscribed ) return;

		r.OnPop        += OnPop;
		r.OnToast      += OnToastEvent;
		r.OnDetonation += OnDetonation;

		_subscribed = true;
	}

	/// <summary>
	/// Rebuilds only when something on screen would actually differ.
	///
	/// The first version forced a repaint 15 times a second, which meant
	/// rebuilding the board, both side columns and every upgrade row constantly
	/// — that was the source of the stutter. Between detonations almost nothing
	/// changes, so hashing real state instead of a clock cuts the repaint rate
	/// by roughly an order of magnitude.
	/// </summary>
	protected override int BuildHash()
	{
		if ( !Visible ) return 0;

		var hc = new HashCode();
		hc.Add( _tab );
		hc.Add( _buyAmount );
		hc.Add( _showMeltdown );
		hc.Add( _showIntro );
		hc.Add( ModalOpen );

		// Currencies — quantised to what the labels actually show
		hc.Add( (long)Cores );
		hc.Add( (long)R.Cps );
		hc.Add( S.Flux );
		hc.Add( ChainReactionGame.Save?.TotalFragments ?? 0 );
		hc.Add( S.TodayReactorFragments );
		hc.Add( (int)(R.FragmentProgressFraction * 60f) );

		// Live controls
		hc.Add( (int)(R.OverloadCooldownLeft * 10f) );
		hc.Add( (int)(R.Charge * 100f) );
		hc.Add( R.SurgeActive );
		hc.Add( (int)R.SurgeTimeLeft );
		hc.Add( (int)R.GoldenTimeLeft );
		hc.Add( R.Boosts.Count );
		hc.Add( R.Boosts.Count > 0 ? (int)R.Boosts[0].SecondsLeft : 0 );
		hc.Add( R.FeedStarved );

		// Transient effects
		hc.Add( _floaters.Count );
		hc.Add( (int)(_calloutLife * 20f) );
		hc.Add( Time.Now < _shakeUntil ? _shakeCls : "" );
		hc.Add( _toasts.Count );

		// Board — chest layout, damage, drones and any live flash
		hc.Add( R.Size );
		float now = Time.Now;
		bool animating = _floaters.Count > 0 || _calloutLife > 0f || now < _shakeUntil;

		for ( int r = 0; r < R.Size; r++ )
			for ( int c = 0; c < R.Size; c++ )
			{
				var cell = R.Board[r, c];
				hc.Add( (int)cell.Chest );
				hc.Add( cell.HitsLeft );
				hc.Add( cell.HasDrone );
				hc.Add( cell.FlashUntil > now ? cell.FlashKind : 0 );

				if ( cell.FlashUntil > now ) animating = true;
				if ( cell.HasChest && now - cell.SpawnAt < 0.25f ) animating = true;
				if ( cell.HasDrone && now - cell.DroneAt < 0.18f ) animating = true;
			}

		hc.Add( R.GoldenRow );
		hc.Add( R.GoldenCol );
		hc.Add( R.LastChain );

		// Anything mid-flight is drawn from inline styles rather than keyframes,
		// so it needs a clock — but only while it is actually moving. The rest of
		// the time this hash is stable and the panel is never rebuilt at all.
		if ( animating ) hc.Add( (int)(now * 20f) );

		return hc.ToHashCode();
	}

	// ══════════════════════════════════════════════════════════════
	// Reactor events
	// ══════════════════════════════════════════════════════════════

	void OnPop( int row, int col, double value, bool crit )
	{
		if ( R == null || _floaters.Count > 10 ) return;

		float step = 100f / R.Size;
		_floaters.Add( new Floater {
			Text = "+" + IdleFormat.Short( value ),
			Cls  = crit ? "crit" : "",
			X = step * col + step * 0.5f,
			Y = step * row + step * 0.5f,
			Life = 0.9f,
			MaxLife = 0.9f,
		} );
	}

	void OnToastEvent( string icon, string text, string cls )
	{
		_toasts.Insert( 0, new ToastMsg { Icon = icon, Text = text, Cls = cls, Life = 3.2f } );
		while ( _toasts.Count > 4 ) _toasts.RemoveAt( _toasts.Count - 1 );
	}

	void OnDetonation( int chain, double payout, bool crit )
	{
		string shake = crit || chain >= 25 ? "idle-shake-3"
					 : chain >= 12 ? "idle-shake-2"
					 : chain >= 4  ? "idle-shake-1"
					 : "";

		if ( shake != "" )
		{
			_shakeCls   = shake;
			_shakeUntil = Time.Now + (shake == "idle-shake-3" ? 0.45f : 0.28f);
		}

		if ( chain < 15 && !crit ) return;

		double critPower = IdleBalance.CritPower( S?.Level( IdleUpgradeId.CritPower ) ?? 0 );

		_calloutText = crit
			? $"CRIT ×{critPower:F0}  ·  +{IdleFormat.Short( payout )}"
			: $"{chain} CHAIN  ·  +{IdleFormat.Short( payout )}";

		_calloutCls  = crit ? "crit" : chain >= 35 ? "mega" : "big";
		_calloutLife = 1f;
	}

	// ══════════════════════════════════════════════════════════════
	// Input
	// ══════════════════════════════════════════════════════════════

	void SetTab( string tab )       { _tab = tab; Sound.Play( "UI_Button" ); }
	void SetBuyAmount( int amount ) { _buyAmount = amount; Sound.Play( "UI_Button" ); }

	int BuyCount( IdleUpgradeId id )
		=> _buyAmount < 0 ? Math.Max( 1, R.MaxAffordable( id ) ) : _buyAmount;

	void OnBuy( IdleUpgradeId id )
	{
		if ( R == null ) return;
		if ( !R.TryBuy( id, BuyCount( id ) ) ) Sound.Play( "Pop_Button_1" );
	}

	void OnBuyPerk( IdlePerkId id )
	{
		if ( R == null ) return;
		if ( !R.TryBuyPerk( id ) ) Sound.Play( "Pop_Button_1" );
	}

	void OnOverload()  => R?.Overload();
	void OnSurge()     => R?.ActivateSurge();
	void OnCellClick( int r, int c ) => R?.ClickCell( r, c );

	void OnClaimOffline() => R?.ClaimOffline();
	void OnClaimDaily()   => R?.ClaimDaily();

	void OnOpenMeltdown()  { _showMeltdown = true;  Sound.Play( "UI_Button" ); }
	void OnCloseMeltdown() { _showMeltdown = false; Sound.Play( "UI_Button" ); }

	void OnMeltdown()
	{
		_showMeltdown = false;
		R?.DoMeltdown();
	}

	void OnCloseIntro()
	{
		_showIntro = false;
		if ( S != null )
		{
			S.IntroShown = true;
			ChainReactionGame.Save?.Save();
		}
		Sound.Play( "PlayButton" );
	}

	void OnBack()
	{
		Sound.Play( "UI_Button" );
		Game?.ReturnToMenu();
	}

	// ══════════════════════════════════════════════════════════════
	// Class helpers
	// ══════════════════════════════════════════════════════════════

	string Pct( float f ) => (Math.Clamp( f, 0f, 1f ) * 100f).ToString( "F1" );

	string GlowClass()        => R.SurgeActive ? "surging" : "";
	string SurgeClass()       => R.SurgeActive ? "surging" : "";
	string SurgeReadyClass()  => R.SurgeReady ? "ready" : "";
	string OverloadClass()    => R.OverloadReady ? "ready" : "cooling";
	string BoardShake()       => Time.Now < _shakeUntil ? _shakeCls : "";
	string TabClass( string t )     => _tab == t ? "on" : "";
	string BuyAmtClass( int amt )   => _buyAmount == amt ? "on" : "";
	string MaxedClass( bool maxed ) => maxed ? "maxed" : "";
	string CapClass()  => R.FragmentsToday >= R.DailyFragmentCap ? "capped" : "";
	string FeedClass() => R.FeedStarved ? "starved" : "";
	string MeltdownStripClass() => R.CanMeltdown ? "ready" : "";

	string StreakPipClass( int day )
	{
		int streak = S?.DailyStreak ?? 1;
		if ( day == streak ) return "on today";
		return day < streak ? "on" : "";
	}

	// ══════════════════════════════════════════════════════════════
	// Text helpers — one composed string per element (see note at top)
	// ══════════════════════════════════════════════════════════════

	string CoresLabel() => IdleFormat.Short( Cores );
	string RateLabel()  => IdleFormat.Short( R.Cps );
	string FluxLabel()  => (S?.Flux ?? 0).ToString();
	string FragLabel()  => (ChainReactionGame.Save?.TotalFragments ?? 0).ToString();

	string GlobalMultLabel() => "×" + IdleFormat.Short( R.GlobalMultiplier );
	string ExponentLabel()   => R.ChainExponent.ToString( "F2" );
	string LastChainLabel()  => $"{R.LastChain} chests";
	string BestChainLabel()  => $"{S.BestChain} chests";
	string BoardLabel()      => $"{R.Size} × {R.Size}";

	string FeedLabel() => R.FeedStarved ? "starving" : "keeping up";

	string SurgeBoostLabel() => $"SURGE ×{IdleBalance.SurgeMultiplier:F0}";
	string BoostLabel( IdleReactor.Boost b ) => $"{b.Label} ×{b.Mult:F1}";
	string SecondsLabel( float s ) => $"{s:F0}s";

	string CondenserLabel()
		=> $"next at {IdleFormat.Short( R.FragmentCost )} Cores · price climbs with each one";

	string FragTodayLabel() => $"{R.FragmentsToday} / {R.DailyFragmentCap}";

	string TierBlurb( IdleBalance.ChestTier tier, bool unlocked )
		=> unlocked ? tier.Blurb : $"unlocks at {IdleFormat.Short( tier.Requires )} cycle Cores";

	string GoldenTimerLabel() => R.GoldenTimeLeft.ToString( "F0" );

	string OverloadLabel()
		=> R.OverloadReady ? "⚡ OVERLOAD" : $"{R.OverloadCooldownLeft:F1}s";

	string OverloadSubLabel() => $"×{IdleBalance.OverloadMultiplier:F1} burst · SPACE";
	string OverloadFillPct()  => ((1f - R.OverloadCooldownFraction) * 100f).ToString( "F0" );
	string UnleashLabel()     => $"UNLEASH ×{IdleBalance.SurgeMultiplier:F0}";

	string SurgeLabel()
	{
		if ( R.SurgeActive ) return $"SURGE {R.SurgeTimeLeft:F0}s";
		if ( R.SurgeReady )  return "SURGE READY";
		return $"CHARGE {R.Charge * 100f:F0}%";
	}

	string MeltdownLabel()
		=> R.CanMeltdown
			? $"Meltdown available — +{R.PendingFlux} Flux"
			: $"Meltdown unlocks at {IdleFormat.Short( IdleBalance.MeltdownRequirement )} cycle Cores · {IdleFormat.Short( S.CycleCores )} banked";

	string BuyAmountLabel( int amount ) => amount < 0 ? "MAX" : $"×{amount}";

	string UpgradeClass( bool revealed, bool maxed, bool afford )
	{
		if ( !revealed ) return "locked";
		if ( maxed ) return "maxed";
		return afford ? "afford" : "poor";
	}

	string UpgradeIcon( IdleUpgrade up, bool revealed ) => revealed ? up.Icon : "🔒";
	string UpgradeName( IdleUpgrade up, bool revealed ) => revealed ? up.Name : "Locked";

	string UpgradeDesc( IdleUpgrade up, bool revealed )
		=> revealed ? up.Desc : $"Reach {IdleFormat.Short( up.RevealAt )} lifetime Cores to reveal.";

	string LevelLabel( IdleUpgrade up, int lvl, bool revealed )
	{
		if ( !revealed ) return "—";
		return up.MaxLevel < 100 ? $"Lv {lvl} / {up.MaxLevel}" : $"Lv {lvl}";
	}

	string CostLabel( bool maxed, double cost, int count )
	{
		if ( maxed ) return "MAXED";
		return count > 1
			? $"{IdleFormat.Short( cost )}  ×{count}"
			: IdleFormat.Short( cost );
	}

	string PerkLevelLabel( IdlePerk p, int lvl ) => $"Lv {lvl} / {p.MaxLevel}";
	string PerkCostLabel( bool maxed, int cost ) => maxed ? "MAXED" : $"🔷 {cost}";

	string FluxHeadLabel() => $"🔷 {S.Flux}";

	string FluxSubLabel()
		=> $"{S.FluxEarned} earned · ×{IdleBalance.FluxMultiplier( S.FluxEarned ):F2} permanent income";

	string LogHeadLabel() => $"{IdleAchievements.CompletedCount( S )} / {IdleAchievements.All.Count}";

	string AchIcon( IdleAchievement a, bool done ) => done ? a.Icon : "🔘";

	string RewardLabel( IdleAchievement a )
	{
		if ( a.RewardFlux > 0 && a.RewardFrags > 0 ) return $"+{a.RewardFlux}🔷  +{a.RewardFrags}💠";
		if ( a.RewardFlux > 0 ) return $"+{a.RewardFlux}🔷";
		return $"+{a.RewardFrags}💠";
	}

	string ProgressLabel( IdleAchievement a )
	{
		double cur = a.Progress( S );
		return a.BigNumbers
			? $"{IdleFormat.Short( cur )} / {IdleFormat.Short( a.Target )}"
			: $"{(long)cur} / {(long)a.Target}";
	}

	// ── Modal labels ──────────────────────────────────────────────
	string OfflineSubLabel()
		=> $"The reactor ran for {IdleFormat.Duration( R.PendingOfflineSeconds )} at {IdleFormat.Pct( R.OfflineEfficiency )} efficiency.";

	string OfflineAmountLabel() => "+" + IdleFormat.Short( R.PendingOfflineCores );

	string OfflineFragLabel() => $"condensing {R.PendingOfflineFrags} Fragments";

	string OfflineWarnLabel()
		=> $"Storage filled up after {R.OfflineCapHours:F0}h — upgrade Core Storage to bank longer trips.";

	string DailyTitleLabel() => $"Day {S?.DailyStreak ?? 1}";
	string DailyMultLabel()  => $"×{IdleBalance.DailyReward( S?.DailyStreak ?? 1 ).mult:F1}";

	string DailyRewardLabel()
	{
		var reward = IdleBalance.DailyReward( S?.DailyStreak ?? 1 );
		return $"for {IdleFormat.Duration( reward.seconds )}  ·  +{reward.frags} Fragments";
	}

	string MeltPayoutLabel()  => "+" + R.PendingFlux;
	string BurnedCoresLabel() => $"{IdleFormat.Short( Cores )} Cores";

	// ══════════════════════════════════════════════════════════════
	// Cell rendering
	// ══════════════════════════════════════════════════════════════

	string CellClass( IdleReactor.Cell cell, bool golden )
	{
		if ( golden ) return "golden";

		string cls = cell.HasChest ? "filled" : "empty";

		if ( cell.FlashUntil > Time.Now )
		{
			cls += cell.FlashKind switch
			{
				3 => " fx-drone",
				2 => " fx-kill",
				_ => " fx-blast",
			};
		}

		if ( cell.HasDrone ) cls += " armed";

		if ( cell.HasChest )
		{
			cls += cell.Chest switch
			{
				GridManager.ChestType.Gem       => " c-gem",
				GridManager.ChestType.BombChest => " c-bomb",
				GridManager.ChestType.Locked    => " c-locked",
				GridManager.ChestType.Cursed    => " c-cursed",
				GridManager.ChestType.Void      => " c-void",
				GridManager.ChestType.Boss      => " c-boss",
				_                               => " c-normal",
			};
		}

		return cls;
	}

	/// <summary>
	/// Spawn pop, computed here rather than as a keyframe: the panel can rebuild
	/// at any moment and a class-driven animation would restart mid-flight.
	/// </summary>
	string ChestStyle( IdleReactor.Cell cell )
	{
		float age = Time.Now - cell.SpawnAt;
		if ( age >= 0.25f || age < 0f ) return "";

		float scale = 0.4f + 0.6f * (age / 0.25f);
		return $"transform:scale({scale.ToString( "F2" )});";
	}

	/// <summary>Drone drop — falls onto the cell as the volley arms.</summary>
	string DroneStyle( IdleReactor.Cell cell )
	{
		float age = Time.Now - cell.DroneAt;
		if ( age >= 0.18f || age < 0f ) return "";

		float t = age / 0.18f;
		float scale = 2.0f - 1.0f * t;
		return $"transform:scale({scale.ToString( "F2" )});opacity:{t.ToString( "F2" )};";
	}

	string ChestGlyph( IdleReactor.Cell cell ) => cell.Chest switch
	{
		GridManager.ChestType.BombChest => "💣",
		GridManager.ChestType.Locked    => "🔒",
		GridManager.ChestType.Cursed    => "💀",
		GridManager.ChestType.Void      => "🌑",
		GridManager.ChestType.Boss      => "👑",
		GridManager.ChestType.Gem       => "💎",
		_                               => "🎁",
	};
}