Game/Upgrades.cs
namespace Monolith;

public enum UpgradeKind
{
	DrillSpeed,
	BlastRadius,
	ChargeChance,
	ChargeRadius,
	DustYield,
	Drones,
	Demolition,

	// Appended, never inserted. UpgradeKind doubles as the index into the saved level array, so
	// adding one in the middle would silently reassign every existing player's upgrades.
	DroneRate,
}

public sealed class UpgradeDef
{
	public UpgradeKind Kind;
	public string Name;
	public string Description;
	public double BaseCost;

	/// <summary>Human readable value at a given level, for the HUD.</summary>
	public Func<int, string> Format;

	/// <summary>
	/// What the NEXT level actually changes, stated as numbers.
	///
	/// "Shortens the delay between shots" does not tell you whether to spend on it. "0.91s to
	/// 0.71s between shots" does. Every purchase in the panel shows its own before and after so
	/// the decision can be made without arithmetic.
	/// </summary>
	public Func<int, string> Gain;
}

public static class Upgrades
{
	public static readonly UpgradeDef[] All =
	{
		new()
		{
			Kind = UpgradeKind.DrillSpeed,
			Name = "Rate of Fire",
			Description = "Shortens the delay between shots.",
			BaseCost = Tuning.CostDrillSpeed,

			// Shown as a DELAY, not a rate. "0.91s between shots" tells you what the opening
			// actually feels like; "1.1/s" does not, and the first upgrade is the one that has
			// to read as an obvious improvement.
			Format = lvl => $"{1f / MathF.Max( 0.01f, DrillSpeed( lvl ) ):0.00}s between shots",
			Gain = lvl => $"{Delay( lvl ):0.00}s to {Delay( lvl + 1 ):0.00}s between shots "
				+ $"({DrillSpeed( lvl ):0.00} to {DrillSpeed( lvl + 1 ):0.00} shots/sec)",
		},
		new()
		{
			Kind = UpgradeKind.BlastRadius,
			Name = "Blast Radius",
			Description = "How much of the monolith each shot takes.",
			BaseCost = Tuning.CostBlastRadius,
			Format = lvl => $"{CubesInSphere( BlastRadius( lvl ) ):N0} cubes",
			Gain = lvl => $"{CubesInSphere( BlastRadius( lvl ) ):N0} to "
				+ $"{CubesInSphere( BlastRadius( lvl + 1 ) ):N0} cubes per shot "
				+ $"(radius {BlastRadius( lvl ):0.00} to {BlastRadius( lvl + 1 ):0.00})",
		},
		new()
		{
			Kind = UpgradeKind.ChargeChance,
			Name = "Instability",
			Description = "Seeds the shape with volatile blocks. Hit them directly.",
			BaseCost = Tuning.CostChargeChance,
			Format = lvl => $"{VolatileChance( lvl ) * 100f:0.0}% of blocks volatile",
			Gain = lvl => $"{VolatileChance( lvl ) * 100f:0.0}% to "
				+ $"{VolatileChance( lvl + 1 ) * 100f:0.0}% of blocks volatile",
		},
		new()
		{
			Kind = UpgradeKind.ChargeRadius,
			Name = "Detonation",
			Description = "How hard a volatile block goes off when you hit it.",
			BaseCost = Tuning.CostChargeRadius,
			Format = lvl => $"{CubesInSphere( VolatileRadius( lvl ) ):N0} cubes",
			Gain = lvl => $"{CubesInSphere( VolatileRadius( lvl ) ):N0} to "
				+ $"{CubesInSphere( VolatileRadius( lvl + 1 ) ):N0} cubes per volatile hit",
		},
		new()
		{
			Kind = UpgradeKind.DustYield,
			Name = "Dust Yield",
			Description = "Dust gained per cube removed.",
			BaseCost = Tuning.CostDustYield,
			Format = lvl => $"x{DustYield( lvl ):0.00}",
			Gain = lvl => $"x{DustYield( lvl ):0.00} to x{DustYield( lvl + 1 ):0.00} dust per cube "
				+ $"(+{Tuning.DustYieldPerLevel * 100f:0}% of base)",
		},
		new()
		{
			Kind = UpgradeKind.Drones,
			Name = "Drones",
			Description = "Autonomous miners that fire without you. More of them, in the arch overhead.",
			BaseCost = Tuning.CostDrones,
			Format = lvl => $"{lvl} active",
			Gain = lvl => $"{lvl} to {lvl + 1} drones. Total drone output "
				+ $"{DroneOutput( lvl, LiveLevel( UpgradeKind.DroneRate ) ):0.00} to "
				+ $"{DroneOutput( lvl + 1, LiveLevel( UpgradeKind.DroneRate ) ):0.00} shots/sec "
				+ $"at your current cadence, each at {Tuning.DroneRadiusScale * 100f:0}% blast",
		},
		new()
		{
			Kind = UpgradeKind.DroneRate,
			Name = "Drone Cadence",
			Description = "How fast each drone fires. Cheaper than another drone, and it lifts every one you own.",
			BaseCost = Tuning.CostDroneRate,
			Format = lvl => $"{DroneFireRate( lvl ):0.00}/s each",
			Gain = lvl => $"each drone {DroneFireRate( lvl ):0.00} to {DroneFireRate( lvl + 1 ):0.00} "
				+ $"shots/sec. Across your {LiveLevel( UpgradeKind.Drones )} drones that is "
				+ $"{DroneOutput( LiveLevel( UpgradeKind.Drones ), lvl ):0.00} to "
				+ $"{DroneOutput( LiveLevel( UpgradeKind.Drones ), lvl + 1 ):0.00} shots/sec",
		},
		new()
		{
			Kind = UpgradeKind.Demolition,
			Name = "Demolition Charge",
			Description = "Right click to plant, left click to detonate. 10s cooldown, always. "
				+ "Sized as a share of the stage, so it never stops mattering.",
			BaseCost = Tuning.CostDemolition,

			// Quoted as a PERCENTAGE now, because that is what the ability actually does. A cube
			// count would be a different number on every stage and would tell you nothing about
			// whether the upgrade was worth buying.
			Format = lvl => $"{DemolitionFraction( lvl ) * 100f:0.0}% of the stage",
			Gain = lvl => $"{DemolitionFraction( lvl ) * 100f:0.0}% to "
				+ $"{DemolitionFraction( lvl + 1 ) * 100f:0.0}% of the stage per detonation "
				+ $"(cooldown stays {Tuning.DemolitionCooldown:0}s, "
				+ $"capped at {Tuning.DemolitionMonolithFractionMax * 100f:0.#}% on the Monolith)",
		},
	};

	/// <summary>Seconds between shots at a level. The reciprocal of the fire rate.</summary>
	public static float Delay( int level ) => 1f / MathF.Max( 0.01f, DrillSpeed( level ) );

	/// <summary>Bolts actually drawn for a given projectile count.</summary>
	public static int VisibleProjectiles( int projectiles )
		=> Math.Clamp( projectiles, 1, Tuning.MaxVisibleProjectiles );

	/// <summary>
	/// Radius multiplier that pays back the bolts we chose not to draw.
	///
	/// Total volume removed should not change just because we stopped spawning an object per
	/// prestige. N bolts each of radius r remove roughly N * r^3, so folding the surplus into a
	/// single radius means scaling by the CUBE ROOT of the ratio. Getting this wrong by using
	/// the ratio directly would have made a capped shot wildly stronger than the volley it
	/// replaced.
	/// </summary>
	public static float MultiShotRadiusScale( int projectiles )
	{
		int drawn = VisibleProjectiles( projectiles );

		if ( projectiles <= drawn )
			return 1f;

		return MathF.Cbrt( projectiles / (float)drawn );
	}

	/// <summary>Shots per second for a single drone at a given Drone Cadence level.</summary>
	public static float DroneFireRate( int level )
		=> Tuning.DroneFireRateBase + Tuning.DroneFireRatePerLevel * level;

	/// <summary>
	/// Total shots per second from the whole flock. The number that actually matters when
	/// deciding between another drone and a faster one, which is why both hint lines quote it.
	/// </summary>
	public static float DroneOutput( int droneLevel, int rateLevel )
		=> droneLevel * DroneFireRate( rateLevel );

	/// <summary>
	/// The player's CURRENT level of another upgrade, so a hint can talk about the real trade
	/// rather than a hypothetical one. Drones and Drone Cadence multiply each other, so quoting
	/// either in isolation would be misleading in exactly the case the choice matters.
	/// </summary>
	private static int LiveLevel( UpgradeKind kind )
		=> PlayerProgress.Local.IsValid() ? PlayerProgress.Local.LevelOf( kind ) : 0;

	public static UpgradeDef Get( UpgradeKind kind ) => All.First( x => x.Kind == kind );

	/// <summary>
	/// Cost growth is PER UPGRADE, not global. An upgrade whose value scales with the cube of
	/// a radius has to get expensive far faster than one that scales linearly, or it dominates
	/// every purchase decision and the economy runs away.
	/// </summary>
	public static double GrowthOf( UpgradeKind kind ) => kind switch
	{
		UpgradeKind.DrillSpeed => Tuning.GrowthDrillSpeed,
		UpgradeKind.BlastRadius => Tuning.GrowthBlastRadius,
		UpgradeKind.ChargeChance => Tuning.GrowthChargeChance,
		UpgradeKind.ChargeRadius => Tuning.GrowthChargeRadius,
		UpgradeKind.DustYield => Tuning.GrowthDustYield,
		UpgradeKind.Drones => Tuning.GrowthDrones,
		UpgradeKind.DroneRate => Tuning.GrowthDroneRate,
		UpgradeKind.Demolition => Tuning.GrowthDemolition,
		_ => Tuning.UpgradeCostGrowth,
	};

	public static double CostAt( UpgradeDef def, int level )
		=> def.BaseCost * Math.Pow( GrowthOf( def.Kind ), level );

	// ---------------------------------------------------------------- curves

	// No clamps anywhere. See the note in Tuning: an upgrade that reads "MAX" is a dead end,
	// and every one of these has to stay worth buying at level 500.

	public static float DrillSpeed( int level )
		=> Tuning.DrillSpeedBase + Tuning.DrillSpeedPerLevel * level;

	public static float BlastRadius( int level )
		=> Tuning.BlastRadiusBase + Tuning.BlastRadiusPerLevel * level;

	/// <summary>
	/// Fraction of blocks in the shape that are volatile. Approaches a ceiling without ever
	/// reaching it, so there is always another level worth buying.
	/// </summary>
	public static float VolatileChance( int level )
	{
		float t = 1f - 1f / (1f + Tuning.VolatileChanceRate * level);
		return Tuning.VolatileChanceBase
			+ (Tuning.VolatileChanceMax - Tuning.VolatileChanceBase) * t;
	}

	/// <summary>Blast radius when a volatile block is hit squarely.</summary>
	public static float VolatileRadius( int level )
		=> Tuning.VolatileRadiusBase + Tuning.VolatileRadiusPerLevel * level;

	public static float DustYield( int level )
		=> Tuning.DustYieldBase + Tuning.DustYieldPerLevel * level;

	/// <summary>
	/// Fraction of the stage a charge removes. Approaches
	/// <see cref="Tuning.DemolitionFractionMax"/> without reaching it, so this upgrade can never
	/// show "MAX" and the increments shrink forever.
	/// </summary>
	public static float DemolitionFraction( int level )
	{
		float t = 1f - 1f / (1f + Tuning.DemolitionFractionRate * level);

		return Tuning.DemolitionFractionBase
			+ (Tuning.DemolitionFractionMax - Tuning.DemolitionFractionBase) * t;
	}

	/// <summary>
	/// The fraction actually applied, given where you are mining.
	///
	/// The shared Monolith clamps hard: see `Tuning.DemolitionMonolithFractionMax`. A tenth of a
	/// 16.7M cube rock per click would turn the game's long communal goal into ten button presses.
	/// </summary>
	public static float DemolitionFractionFor( int level, bool inMonolith )
	{
		float fraction = DemolitionFraction( level );

		return inMonolith
			? MathF.Min( fraction, Tuning.DemolitionMonolithFractionMax )
			: fraction;
	}

	/// <summary>
	/// Blast radius, in voxels, that would contain <paramref name="cubes"/> of solid rock.
	///
	/// Inverting the sphere volume is what turns "a tenth of the stage" into something the blast
	/// code can use, since everything downstream works in radii. Solid packing is assumed, so a
	/// charge detonated at the SURFACE removes noticeably less than the quoted fraction. That is
	/// the whole reason burying it is still the skill.
	/// </summary>
	public static float RadiusForCubes( double cubes )
	{
		if ( cubes <= 1 )
			return 0.5f;

		return (float)Math.Cbrt( cubes * 3.0 / (4.0 * Math.PI) );
	}

	/// <summary>Cubes a charge is sized to remove against a stage of the given size.</summary>
	public static double DemolitionCubes( int level, long stageCubes, bool inMonolith )
		=> Math.Max( 1.0, stageCubes * DemolitionFractionFor( level, inMonolith ) );

	/// <summary>Approximate voxel count in a sphere of the given radius, for HUD copy.</summary>
	public static long CubesInSphere( float radius )
	{
		if ( radius < 0.5f ) return 1;
		return (long)(4.0 / 3.0 * Math.PI * radius * radius * radius);
	}
}