Game/PlayerProgress.cs
namespace Monolith;

/// <summary>Everything about a player that survives a session, in a shape we can serialise.</summary>
public sealed class SaveData
{
	public double Dust { get; set; }
	public double LifetimeCubes { get; set; }
	public int Cores { get; set; }
	public int[] UpgradeLevels { get; set; } = new int[Enum.GetValues<UpgradeKind>().Length];
	public int MonolithsCleared { get; set; }

	/// <summary>Highest solo stage reached this run, 1-based. Drives prestige and the board.</summary>
	public int HighestStage { get; set; } = 1;

	/// <summary>Highest solo stage ever reached, across all collapses. Leaderboard value.</summary>
	public int BestStageEver { get; set; } = 1;

	/// <summary>Lifetime prestige count. One of the global boards.</summary>
	public int Collapses { get; set; }

	/// <summary>Best full stage 1 to 100 run, in seconds. 0 means never completed.</summary>
	public float BestLadderSeconds { get; set; }

	/// <summary>Levels purchased in the Core Tree, indexed by CoreNodeKind.</summary>
	public int[] CoreNodes { get; set; } = new int[Enum.GetValues<CoreNodeKind>().Length];

	/// <summary>Ids of earned Marks. Stored by id so reordering the list cannot corrupt them.</summary>
	public List<string> EarnedMarks { get; set; } = new();

	// Counters that exist purely so Marks have something to measure.
	public int DeepDetonations { get; set; }
	public int PerfectReloads { get; set; }
	public int VolatileDetonations { get; set; }
	public int InterceptorKills { get; set; }
	public int MonolithCredits { get; set; }
	public int BestResonance { get; set; }

	/// <summary>Slag in hand. Accrues on a real-world clock, including while shut.</summary>
	public int Slag { get; set; }

	/// <summary>UTC ticks the Slag clock was last settled. 0 means never.</summary>
	public long SlagClockTicks { get; set; }

	/// <summary>
	/// Whether the welcome pages have been shown. Saved rather than session-scoped, so a
	/// returning player is not made to read them again every launch.
	/// </summary>
	public bool SeenTutorial { get; set; }

	public int LeechesPopped { get; set; }
	public int SpottersDowned { get; set; }
	public int ShieldNodesBroken { get; set; }
	public int SentinelsDowned { get; set; }
	public int CrawlersKilled { get; set; }
	public int OrbsShot { get; set; }
	public int AnchorsCut { get; set; }

	/// <summary>Restriction the current run is under, if any.</summary>
	public HollowKind Hollow { get; set; } = HollowKind.None;

	/// <summary>Hollow kinds already completed, for the UI and for one-time rewards.</summary>
	public List<string> HollowCleared { get; set; } = new();
}

/// <summary>
/// Per-player economy and upgrades. Progression is deliberately client-side and saved locally:
/// this is a co-op game against a rock, not a competitive one, and keeping it off the wire
/// removes a large amount of networking surface. Only scoreboard values are synced.
/// </summary>
public sealed class PlayerProgress : Component
{
	private const string SavePath = "monolith_progress.json";

	public SaveData Data { get; private set; } = new();

	/// <summary>
	/// Cubes this player has removed from the current monolith. Local only for now: v1 rigs
	/// are not networked GameObjects, so there is nothing to sync against yet. When avatars
	/// and a scoreboard land, this becomes [Sync] on a NetworkMode.Object player.
	/// </summary>
	public long CubesThisMonolith { get; set; }

	private GameTimeSince timeSinceSave;

	/// <summary>
	/// Cubes awarded but not yet submitted to the global stat. At high upgrade levels this is
	/// thousands per second, so it is batched rather than submitted per blast.
	/// </summary>
	private double pendingStatCubes;
	private GameTimeSince timeSinceStatFlush;

	public static PlayerProgress Local { get; private set; }

	protected override void OnStart()
	{
		Local = this;
		Load();

		// Settle the wall clock immediately: most Slag is earned while the game is shut.
		AccrueSlag();
	}

	protected override void OnUpdate()
	{
		// Frozen while a blocking screen or the pause menu is up. See GameTime.
		if ( GameTime.Paused )
			return;

		if ( timeSinceSave > 15f )
		{
			timeSinceSave = 0;
			Save();
		}

		if ( timeSinceStatFlush > 5f && pendingStatCubes >= 1 )
		{
			timeSinceStatFlush = 0;
			MonolithStats.AddCubes( pendingStatCubes );
			pendingStatCubes = 0;
		}

		UpdateResonance();

		// Buying Instability changes which blocks are volatile, so the shape has to be
		// recoloured. Pushing it here keeps the mesher and the hit test reading one value.
		if ( !VolatileChance.AlmostEqual( Stages.VolatileChance ) )
		{
			Stages.VolatileChance = VolatileChance;
			MonolithManager.Instance?.RefreshVolatileAppearance();
		}

		// Marks are checked on a timer rather than at every call site, so adding one never
		// means threading a new notification through the gameplay code.
		if ( timeSinceMarkCheck > 0.5f )
		{
			timeSinceMarkCheck = 0;
			CheckMarks();
		}

		if ( timeSinceSlagCheck > 20f )
		{
			timeSinceSlagCheck = 0;
			AccrueSlag();
		}
	}

	private GameTimeSince timeSinceMarkCheck;
	private GameTimeSince timeSinceSlagCheck;

	// ---------------------------------------------------------------- persistence

	public void Load()
	{
		try
		{
			var loaded = FileSystem.Data.ReadJsonOrDefault<SaveData>( SavePath, null );
			if ( loaded != null )
			{
				Data = loaded;

				// Tolerate save files written before an upgrade was added.
				var count = Enum.GetValues<UpgradeKind>().Length;
				if ( Data.UpgradeLevels == null || Data.UpgradeLevels.Length < count )
				{
					var grown = new int[count];
					Data.UpgradeLevels?.CopyTo( grown, 0 );
					Data.UpgradeLevels = grown;
				}
			}
		}
		catch ( Exception e )
		{
			Log.Warning( $"Could not load progress, starting fresh: {e.Message}" );
			Data = new SaveData();
		}
	}

	public void Save()
	{
		try
		{
			FileSystem.Data.WriteJson( SavePath, Data );
		}
		catch ( Exception e )
		{
			Log.Warning( $"Could not save progress: {e.Message}" );
		}
	}

	// ---------------------------------------------------------------- economy

	public int LevelOf( UpgradeKind kind ) => Data.UpgradeLevels[(int)kind];

	// ---------------------------------------------------------------- core tree

	public int CoreLevel( CoreNodeKind kind ) => Data.CoreNodes[(int)kind];

	/// <summary>Cores committed to the tree. Spent Cores stay spent.</summary>
	public int SpentCores
	{
		get
		{
			int total = 0;

			foreach ( var def in CoreTree.All )
			{
				for ( int level = 0; level < CoreLevel( def.Kind ); level++ )
					total += CoreTree.CostAt( def, level );
			}

			return total;
		}
	}

	public int AvailableCores => Math.Max( 0, Data.Cores - SpentCores );

	public int CoreCost( CoreNodeKind kind )
		=> CoreTree.CostAt( CoreTree.Get( kind ), CoreLevel( kind ) );

	public bool CanBuyCore( CoreNodeKind kind ) => AvailableCores >= CoreCost( kind );

	public bool TryBuyCore( CoreNodeKind kind )
	{
		if ( !CanBuyCore( kind ) )
			return false;

		Data.CoreNodes[(int)kind]++;
		Save();
		return true;
	}

	/// <summary>Stage a fresh run starts on, courtesy of Foresight.</summary>
	public int StartingStage => CoreLevel( CoreNodeKind.Foresight ) * Tuning.CoreForesightPerLevel;

	/// <summary>Upgrade levels Anchored lets you keep through a Collapse.</summary>
	public int AnchoredLevels => CoreLevel( CoreNodeKind.Anchored ) * Tuning.CoreAnchoredPerLevel;

	public float DemolitionCooldown => MathF.Max(
		Tuning.DemolitionCooldownFloor,
		Tuning.DemolitionCooldown
			- CoreLevel( CoreNodeKind.Cadence ) * Tuning.CoreCadenceCooldownPerLevel );

	/// <summary>Global multiplier granted by prestige Cores.</summary>
	public float CoreMultiplier => 1f + Data.Cores * Tuning.CorePowerPerCore;

	// ---------------------------------------------------------------- marks

	public int MarksEarned => Data.EarnedMarks?.Count ?? 0;

	/// <summary>Quiet background multiplier from every Mark earned.</summary>
	public float MarkMultiplier => 1f + MarksEarned * Tuning.MarkPowerEach;

	public bool HasMark( string id ) => Data.EarnedMarks?.Contains( id ) ?? false;

	/// <summary>Most recently earned Mark, for the HUD toast.</summary>
	public MarkDef LastMark { get; private set; }
	public GameTimeSince TimeSinceMark { get; private set; } = 99f;

	/// <summary>
	/// Re-evaluates every Mark. Cheap enough to run a few times a second, and doing it on a
	/// timer rather than at each call site means a new Mark never needs new plumbing.
	/// </summary>
	public void CheckMarks()
	{
		Data.EarnedMarks ??= new List<string>();

		foreach ( var mark in Marks.All )
		{
			if ( Data.EarnedMarks.Contains( mark.Id ) )
				continue;

			if ( !mark.Earned( this ) )
				continue;

			Data.EarnedMarks.Add( mark.Id );
			LastMark = mark;
			TimeSinceMark = 0;

			// A Mark was the only reward in the game that arrived in complete silence: the banner
			// slid in and if you were looking at the rock rather than the corner you missed it.
			Audio.MarkEarned();

			Log.Info( $"Mark earned: {mark.Name} ({MarksEarned} total, " +
				$"x{MarkMultiplier:0.00})." );

			Save();
		}
	}

	// ---------------------------------------------------------------- slag

	/// <summary>
	/// Settles the real-time Slag clock. Called on load and on a timer.
	///
	/// It works off UTC ticks rather than game time on purpose: the whole value of this
	/// currency is that it accrues while the game is closed. Partial progress is preserved by
	/// only advancing the stored clock by the whole Slag actually granted.
	/// </summary>
	public void AccrueSlag()
	{
		var now = DateTime.UtcNow;

		if ( Data.SlagClockTicks <= 0 )
		{
			Data.SlagClockTicks = now.Ticks;
			return;
		}

		var last = new DateTime( Data.SlagClockTicks, DateTimeKind.Utc );
		double minutes = (now - last).TotalMinutes;

		if ( minutes < 0 )
		{
			// Clock moved backwards. Re-anchor rather than granting anything.
			Data.SlagClockTicks = now.Ticks;
			return;
		}

		int earned = (int)(minutes / Tuning.SlagMinutesEach);
		if ( earned <= 0 )
			return;

		int before = Data.Slag;
		Data.Slag = Math.Min( Tuning.SlagMax, Data.Slag + earned );

		// Advance by exactly what was granted, so the remainder keeps ticking.
		Data.SlagClockTicks = last.AddMinutes( earned * Tuning.SlagMinutesEach ).Ticks;

		if ( Data.Slag != before )
			Save();
	}

	/// <summary>Minutes until the next Slag, for the UI.</summary>
	public double MinutesToNextSlag
	{
		get
		{
			if ( Data.Slag >= Tuning.SlagMax ) return 0;
			if ( Data.SlagClockTicks <= 0 ) return Tuning.SlagMinutesEach;

			var last = new DateTime( Data.SlagClockTicks, DateTimeKind.Utc );
			double elapsed = (DateTime.UtcNow - last).TotalMinutes;

			return Math.Max( 0, Tuning.SlagMinutesEach - elapsed );
		}
	}

	public bool TrySpendSlag( int amount )
	{
		if ( Data.Slag < amount )
			return false;

		Data.Slag -= amount;
		Save();
		return true;
	}

	// ---------------------------------------------------------------- resonance

	/// <summary>Current Resonance stacks. Decays to nothing the moment you stop.</summary>
	public int Resonance { get; private set; }

	private GameTimeSince timeSinceResonance = 99f;

	/// <summary>Dust multiplier from the current chain.</summary>
	public float ResonanceMultiplier => 1f + Resonance * Tuning.ResonancePerStack;

	public float ResonanceRemaining
		=> Resonance <= 0 ? 0f : MathF.Max( 0f, Tuning.ResonanceWindow - timeSinceResonance );

	/// <summary>Called by anything that should feed a chain: charges, volatiles, catches.</summary>
	public void AddResonance( int stacks = 1 )
	{
		Resonance = Math.Min( Tuning.ResonanceMaxStacks, Resonance + stacks );
		timeSinceResonance = 0;

		if ( Resonance > Data.BestResonance )
			Data.BestResonance = Resonance;
	}

	/// <summary>
	/// Drops the chain. Called when something hits you.
	///
	/// This is the whole cost of being hit, and it is deliberately the RIGHT kind of cost:
	/// Resonance is earned by playing well over the last few seconds, so losing it costs you
	/// momentum without touching dust, cubes or levels. Nothing banked is ever taken.
	/// </summary>
	public void BreakResonance()
	{
		Resonance = 0;
		timeSinceResonance = 99f;
	}

	private void UpdateResonance()
	{
		if ( Resonance > 0 && timeSinceResonance > Tuning.ResonanceWindow )
			Resonance = 0;
	}

	public double CostOf( UpgradeKind kind )
		=> Upgrades.CostAt( Upgrades.Get( kind ), LevelOf( kind ) )
		* HollowRuns.CostMultiplier( Data.Hollow );

	// ---------------------------------------------------------------- hollow runs

	public HollowKind Hollow => Data.Hollow;
	public bool InHollowRun => Data.Hollow != HollowKind.None;

	public bool DronesDisabled => HollowRuns.DronesDisabled( Data.Hollow );
	public bool DemolitionDisabled => HollowRuns.DemolitionDisabled( Data.Hollow );

	public bool HasClearedHollow( HollowKind kind )
		=> Data.HollowCleared?.Contains( kind.ToString() ) ?? false;

	/// <summary>
	/// Starts a restricted run. This is a Collapse with a rule attached, so it costs you the
	/// current run exactly like a normal prestige would.
	/// </summary>
	public void BeginHollowRun( HollowKind kind )
	{
		if ( kind == HollowKind.None || !CanCollapse )
			return;

		Data.Hollow = kind;
		Collapse();
	}

	/// <summary>Called when the ladder is completed. Pays out if a restriction was active.</summary>
	private void ResolveHollowRun()
	{
		if ( !InHollowRun )
			return;

		var kind = Data.Hollow;
		Data.HollowCleared ??= new List<string>();

		if ( !Data.HollowCleared.Contains( kind.ToString() ) )
			Data.HollowCleared.Add( kind.ToString() );

		Data.Cores += Tuning.HollowRunCoreReward;
		Data.Hollow = HollowKind.None;

		Log.Info( $"Hollow run '{kind}' complete. +{Tuning.HollowRunCoreReward} cores." );
		Save();
	}

	/// <summary>No level ceiling by design: every upgrade stays purchasable forever.</summary>
	public bool CanAfford( UpgradeKind kind ) => Data.Dust >= CostOf( kind );

	public bool TryBuy( UpgradeKind kind )
	{
		if ( !CanAfford( kind ) )
			return false;

		Data.Dust -= CostOf( kind );
		Data.UpgradeLevels[(int)kind]++;
		Save();
		return true;
	}

	/// <summary>
	/// How many levels of this upgrade the current dust could buy.
	///
	/// Costs are geometric, so the total for k levels starting at L is
	/// <c>base * g^L * (g^k - 1) / (g - 1)</c>. Solving that for k gives the count directly
	/// rather than looping, which matters once a purchase can be thousands of levels deep.
	/// </summary>
	public int MaxAffordable( UpgradeKind kind )
	{
		var def = Upgrades.Get( kind );
		double g = Upgrades.GrowthOf( kind );
		double first = Upgrades.CostAt( def, LevelOf( kind ) );

		if ( Data.Dust < first )
			return 0;

		double k = Math.Log( 1.0 + Data.Dust * (g - 1.0) / first ) / Math.Log( g );
		return Math.Max( 1, (int)Math.Floor( k ) );
	}

	/// <summary>Buys as many levels as the current dust allows. Returns how many were bought.</summary>
	public int BuyMax( UpgradeKind kind )
	{
		int count = MaxAffordable( kind );
		if ( count <= 0 )
			return 0;

		var def = Upgrades.Get( kind );
		int level = LevelOf( kind );
		double g = Tuning.UpgradeCostGrowth;

		// Exact geometric sum for the block, so rounding cannot hand out a free level.
		double first = Upgrades.CostAt( def, level );
		double total = first * (Math.Pow( g, count ) - 1.0) / (g - 1.0);

		// Guard against floating point overshoot at very large counts.
		while ( count > 0 && total > Data.Dust )
		{
			count--;
			total = first * (Math.Pow( g, count ) - 1.0) / (g - 1.0);
		}

		if ( count <= 0 )
			return 0;

		Data.Dust -= total;
		Data.UpgradeLevels[(int)kind] += count;
		Save();

		return count;
	}

	/// <summary>Called whenever this player removes cubes, from any source.</summary>
	public void AwardCubes( int cubes, float dustMultiplier = 1f )
	{
		if ( cubes <= 0 ) return;

		double dust = cubes
			* Upgrades.DustYield( LevelOf( UpgradeKind.DustYield ) )
			* CoreMultiplier
			* MarkMultiplier
			* ResonanceMultiplier
			* (1f + CoreLevel( CoreNodeKind.Avarice ) * Tuning.CoreAvaricePerLevel)
			* dustMultiplier;

		// Leeches take their cut before you ever see it, and hold onto it until popped.
		double siphoned = dust * Leech.TotalSiphon;

		if ( siphoned > 0 )
		{
			Leech.Distribute( siphoned );
			dust -= siphoned;
		}

		Data.Dust += dust;
		Data.LifetimeCubes += cubes;
		CubesThisMonolith += cubes;

		if ( MonolithManager.Instance.IsValid() && MonolithManager.Instance.InMonolith )
			MonolithContribution += cubes;

		// Batched; flushed on a timer in OnUpdate.
		pendingStatCubes += cubes;
	}

	/// <summary>
	/// Awards prestige credit for felling the shared Monolith, but only to players who did a
	/// real share of the work. Without a floor, anyone idling in the lobby at the moment it
	/// falls would collect the same reward as the people who spent weeks on it.
	/// </summary>
	public bool TryAwardMonolithCredit( long monolithTotal )
	{
		if ( monolithTotal <= 0 )
			return false;

		double share = (double)MonolithContribution / monolithTotal;

		if ( share < Tuning.MonolithCreditShare )
		{
			Log.Info( $"Monolith felled, but your {share:P2} share is under the " +
				$"{Tuning.MonolithCreditShare:P0} needed for credit." );
			return false;
		}

		// Felling a Monolith is worth SEVERAL prestige levels, scaled by how much of it was
		// actually yours. It is 16.7M cubes and takes a lobby days: paying the same single level
		// as one twenty minute solo ladder made the shared destination the worst way to earn
		// prestige, which defeats the point of having it.
		int levels = Tuning.MonolithPrestigeBase
			+ (int)Math.Round( Math.Clamp( share, 0.0, 1.0 ) * Tuning.MonolithPrestigeShareBonus );

		Data.Collapses += levels;
		Data.MonolithCredits++;

		// CORES, not just prestige levels.
		//
		// This was the bug behind "I was expecting a core for clearing a monolith and didn't get
		// one". Collapses is a COUNT; Cores are the currency the tree actually spends. Awarding
		// levels without Cores meant felling the shared Monolith changed a number on a stats
		// line and gave you nothing to spend, which is the least satisfying possible outcome for
		// the longest activity in the game.
		//
		// One Core per prestige level, matching the solo ladder's rate of one per StagesPerCore.
		Data.Cores += levels;

		for ( int i = 0; i < levels; i++ )
			MonolithStats.AddCollapse();

		MonolithStats.ReportCollapses( Data.Collapses );
		Save();

		Log.Info( $"Monolith felled with a {share:P1} share. {levels} prestige levels and " +
			$"{levels} cores awarded (now {Data.Collapses} collapses, {Data.Cores} cores)." );

		return true;
	}

	// ---------------------------------------------------------------- prestige

	/// <summary>Records that the welcome pages have been read, and persists it immediately.</summary>
	public void MarkTutorialSeen()
	{
		if ( Data.SeenTutorial )
			return;

		Data.SeenTutorial = true;
		Save();
	}

	/// <summary>
	/// True for a save that has never been played. Drives whether the start screen offers
	/// "begin" or "continue", and whether the Monolith is offered at all.
	/// </summary>
	public bool IsFreshSave => Data.LifetimeCubes <= 0 && Data.Collapses <= 0;

	/// <summary>Collapse unlocks by reaching the end of the solo ladder, not by raw cube count.</summary>
	public bool CanCollapse => Data.HighestStage >= Tuning.PrestigeStageRequirement;

	/// <summary>Cores this player would gain by collapsing right now.</summary>
	public int PendingCores => Math.Max( 0, Data.HighestStage / Tuning.StagesPerCore );

	/// <summary>Stages still to climb before Collapse becomes available.</summary>
	public int StagesToCollapse => Math.Max( 0, Tuning.PrestigeStageRequirement - Data.HighestStage );

	// ---------------------------------------------------------------- run timer

	/// <summary>Seconds since the current ladder run began, for the speedrun board and HUD.</summary>
	public float RunSeconds => GameTime.Now - runStartedAt;

	private float runStartedAt;

	public void StartRunTimer() => runStartedAt = GameTime.Now;

	/// <summary>Cubes this player has taken off the CURRENT shared Monolith.</summary>
	public long MonolithContribution { get; private set; }

	public void ResetMonolithContribution() => MonolithContribution = 0;

	/// <summary>Called by the Miner whenever a new stage condenses, to track the high-water mark.</summary>
	/// <param name="stageIndex">
	/// ZERO-BASED stage index. This method adds one. The ladder-complete caller must therefore
	/// pass <c>PrestigeStageRequirement - 1</c>, not the requirement itself: passing the count
	/// recorded a finished ladder as stage 101 and would have put 101s on a public board.
	/// </param>
	public void ReportStage( int stageIndex )
	{
		int oneBased = stageIndex + 1;

		// THE SPEEDRUN IS CHECKED FIRST, above the progression guard below, because the two are
		// different questions and conflating them broke the board.
		//
		// The guard exists to avoid rewriting HighestStage for a stage you have already passed.
		// Submitting a RUN TIME is not that: a second, faster completion is exactly what a
		// speedrun leaderboard is for. With the check underneath the guard, a player who finished
		// the ladder once and then went to the Monolith instead of collapsing kept HighestStage at
		// 100 forever, so every later run returned early and no improvement was ever submitted.
		if ( oneBased >= Tuning.PrestigeStageRequirement )
			SubmitLadderRun();

		if ( oneBased <= Data.HighestStage )
			return;

		Data.HighestStage = oneBased;

		if ( oneBased > Data.BestStageEver )
		{
			Data.BestStageEver = oneBased;
			MonolithStats.ReportBestStage( oneBased );
		}

		Save();
	}

	/// <summary>Records a finished ladder and submits the time if it beats the personal best.</summary>
	private void SubmitLadderRun()
	{
		ResolveHollowRun();

		float elapsed = RunSeconds;

		// Guarded against a zero or negative reading. The run clock is pausable now, and a
		// nonsense time on a MIN-aggregated board is permanent: it would sit at the top of the
		// world rankings and there is no way to remove it afterwards.
		if ( elapsed <= 1f )
		{
			Log.Warning( $"[stats] ladder time {elapsed:0.00}s looks wrong. Not submitted." );
			return;
		}

		if ( Data.BestLadderSeconds > 0f && elapsed >= Data.BestLadderSeconds )
			return;

		Data.BestLadderSeconds = elapsed;
		MonolithStats.ReportLadderTime( elapsed );

		Log.Info( $"Ladder complete in {Num.Duration( elapsed )}. New personal best." );

		Save();
	}

	public void Collapse()
	{
		if ( !CanCollapse ) return;

		// Timed end to end. Collapse has hung the game twice with nothing but a stall warning in
		// the log, and the candidates (save, stat upload, world teardown, full remesh) are all
		// plausible and all invisible. Breaking the timing out by phase turns the next occurrence
		// into a fact instead of another hypothesis.
		var clock = System.Diagnostics.Stopwatch.StartNew();
		double afterStats = 0, afterSave = 0;

		Data.Cores += PendingCores;
		Data.Dust = 0;

		// Anchored Upgrades: keep a floor of levels rather than wiping to zero. This is what
		// makes later runs structurally different rather than just faster.
		int anchored = AnchoredLevels;

		for ( int i = 0; i < Data.UpgradeLevels.Length; i++ )
			Data.UpgradeLevels[i] = Math.Min( Data.UpgradeLevels[i], anchored );

		// Foresight: skip the opening stages entirely.
		Data.HighestStage = Math.Max( 1, StartingStage + 1 );

		// Flush cubes before the collapse so the two boards stay consistent with each other.
		if ( pendingStatCubes >= 1 )
		{
			MonolithStats.AddCubes( pendingStatCubes );
			pendingStatCubes = 0;
		}

		Data.Collapses++;
		MonolithStats.AddCollapse();
		MonolithStats.ReportCollapses( Data.Collapses );

		afterStats = clock.Elapsed.TotalMilliseconds;

		Save();

		afterSave = clock.Elapsed.TotalMilliseconds;

		Log.Info( $"Collapsed #{Data.Collapses}. Now at {Data.Cores} cores (x{CoreMultiplier:0.00})." );

		// Actually send the player back to the start. Without this the save resets but the
		// world stays on whatever stage you were on, which reads as the button doing nothing.
		MonolithManager.Instance?.RestartLadder();

		double total = clock.Elapsed.TotalMilliseconds;

		// Only shouts when it actually hurt, so it stays silent in normal play.
		if ( total > 50 )
		{
			Log.Warning( $"[collapse] SLOW: {total:0}ms total " +
				$"(stats {afterStats:0}ms, save {afterSave - afterStats:0}ms, " +
				$"world rebuild {total - afterSave:0}ms)" );
		}
	}

	// ---------------------------------------------------------------- derived stats

	// Core Tree nodes multiply into the derived stats here, which is the only place they need
	// to be applied: everything downstream reads these.

	public float DrillSpeed => Upgrades.DrillSpeed( LevelOf( UpgradeKind.DrillSpeed ) )
		* CoreMultiplier
		* (1f + CoreLevel( CoreNodeKind.Cadence ) * Tuning.CoreCadencePerLevel);

	public float BlastRadius => Upgrades.BlastRadius( LevelOf( UpgradeKind.BlastRadius ) )
		* CoreMultiplier
		* (1f + CoreLevel( CoreNodeKind.Deepening ) * Tuning.CoreDeepeningPerLevel);

	/// <summary>Density of volatile blocks. Sympathy adds flat density on top.</summary>
	public float VolatileChance => MathF.Min( Tuning.VolatileChanceMax,
		Upgrades.VolatileChance( LevelOf( UpgradeKind.ChargeChance ) )
		+ CoreLevel( CoreNodeKind.Sympathy ) * Tuning.CoreSympathyPerLevel );

	public float VolatileRadius => Upgrades.VolatileRadius( LevelOf( UpgradeKind.ChargeRadius ) )
		* CoreMultiplier
		* (1f + CoreLevel( CoreNodeKind.Deepening ) * Tuning.CoreDeepeningPerLevel);
	public int DroneCount => LevelOf( UpgradeKind.Drones );

	/// <summary>Shots per second for ONE drone. Cadence lifts every drone you own.</summary>
	public float DroneFireRate => Upgrades.DroneFireRate( LevelOf( UpgradeKind.DroneRate ) )
		* (1f + CoreLevel( CoreNodeKind.Cadence ) * Tuning.CoreCadencePerLevel);

	public int DemolitionLevel => LevelOf( UpgradeKind.Demolition );

	/// <summary>
	/// Projectiles fired per shot. This is a **permanent prestige reward**, not a purchase:
	/// one at the start, two after your first Collapse, three after the second, and so on.
	/// It is the clearest possible answer to "what did prestige actually get me", and it is
	/// what lets a prestiged player make a dent in the shared Monolith.
	/// </summary>
	/// <remarks>
	/// **BOUNDED, and it has to be.** This was `1 + Data.Collapses`, which assumed Collapses
	/// rises by one per prestige. Then I made felling the shared Monolith award TWENTY collapses,
	/// and the assumption silently broke: at 204 collapses this returned 205 projectiles per
	/// trigger pull. Two hundred and five GameObjects, and after the visible cap folded the
	/// surplus into radius, a blast sphere over three times wider removing millions of voxels a
	/// shot. That is the stall.
	///
	/// **When a derived value assumes how its input grows, changing the input's growth rate is a
	/// breaking change to the derived value.** Nothing about this line was wrong when written.
	/// </remarks>
	public int ProjectileCount => 1 + Math.Min( Data.Collapses, Tuning.MaxProjectileCount - 1 );

	/// <summary>Direct dust award, used by things that are not cubes (interceptor kills).</summary>
	public void AwardDust( double amount )
	{
		if ( amount <= 0 ) return;

		Data.Dust += amount
			* Upgrades.DustYield( LevelOf( UpgradeKind.DustYield ) )
			* CoreMultiplier;
	}
}