Game/Idle/IdleBalance.cs
using System;
using System.Collections.Generic;
/// <summary>
/// Every tuning number for Reactor (Idle) mode lives here.
/// Pure functions only — the simulation, the UI and the offline estimator all
/// read from this file so a balance change never needs touching three places.
/// </summary>
public static class IdleBalance
{
// ══════════════════════════════════════════════════════════════
// PRODUCTION — how fast chests arrive on the board
// ══════════════════════════════════════════════════════════════
public const float SpawnIntervalBase = 0.9f;
public const float SpawnIntervalFloor = 0.30f;
/// <summary>Ambient drip — one chest lands every this many seconds.</summary>
public static float SpawnInterval( int spawnRateLevel )
=> MathF.Max( SpawnIntervalFloor, SpawnIntervalBase / (1f + 0.105f * spawnRateLevel) );
/// <summary>
/// Hopper batch dumped onto the board immediately before every detonation.
///
/// This exists so chain length is driven by the hopper rather than by how
/// long chests were left to pile up. Without it, a slower cycle would mean
/// longer chains and — because payout scales super-linearly with chain
/// length — buying Fuse Timing could actually *lower* income. Topping the
/// board up per cycle keeps every upgrade in the mode strictly positive.
/// </summary>
public static int SpawnCount( int spawnCountLevel ) => 3 + spawnCountLevel;
/// <summary>Chance a spawned chest rolls Gem instead of Normal.</summary>
public static float GemChance( int level ) => MathF.Min( 0.60f, 0.02f * level );
/// <summary>Chance a spawned chest rolls BombChest — the chain fuel.</summary>
public static float BombChestChance( int level ) => MathF.Min( 0.45f, 0.015f * level );
// ══════════════════════════════════════════════════════════════
// BOARD
// ══════════════════════════════════════════════════════════════
public static int GridSize( int frameLevel ) => 5 + Math.Clamp( frameLevel, 0, 4 );
// ══════════════════════════════════════════════════════════════
// DETONATION
// ══════════════════════════════════════════════════════════════
public const float CycleIntervalBase = 3.0f;
public const float CycleIntervalFloor = 0.45f;
public static float CycleInterval( int cycleSpeedLevel )
=> MathF.Max( CycleIntervalFloor, CycleIntervalBase * MathF.Pow( 0.958f, cycleSpeedLevel ) );
public static int DroneCount( int level ) => 1 + Math.Clamp( level, 0, 9 );
public static string BlastName( int level ) => level switch
{
0 => "Cross ✛",
1 => "Diagonal ✕",
2 => "Square 3×3",
3 => "Heavy 3×3 +arms",
_ => "Nova 5×5",
};
/// <summary>Blast offsets for the drone payload at a given Warhead Class level.</summary>
public static IEnumerable<(int dr, int dc)> BlastOffsets( int blastLevel )
{
switch ( Math.Clamp( blastLevel, 0, 4 ) )
{
case 0: // Cross
yield return (0, 0);
yield return (-1, 0); yield return (1, 0);
yield return (0, -1); yield return (0, 1);
break;
case 1: // Diagonal X, 2 deep — mirrors the Diagonal bomb from the main game
yield return (0, 0);
yield return (-1, -1); yield return (-1, 1); yield return (1, -1); yield return (1, 1);
yield return (-2, -2); yield return (-2, 2); yield return (2, -2); yield return (2, 2);
break;
case 2: // Full 3x3
for ( int dr = -1; dr <= 1; dr++ )
for ( int dc = -1; dc <= 1; dc++ )
yield return (dr, dc);
break;
case 3: // 3x3 with cross arms poking out to range 2
for ( int dr = -1; dr <= 1; dr++ )
for ( int dc = -1; dc <= 1; dc++ )
yield return (dr, dc);
yield return (-2, 0); yield return (2, 0);
yield return (0, -2); yield return (0, 2);
break;
default: // Nova — full 5x5
for ( int dr = -2; dr <= 2; dr++ )
for ( int dc = -2; dc <= 2; dc++ )
yield return (dr, dc);
break;
}
}
/// <summary>Blast a BombChest fires when it dies — always a cross, that is its identity.</summary>
public static IEnumerable<(int dr, int dc)> BombChestOffsets()
{
yield return (0, 0);
yield return (-1, 0); yield return (1, 0);
yield return (0, -1); yield return (0, 1);
}
// ══════════════════════════════════════════════════════════════
// VALUE
// ══════════════════════════════════════════════════════════════
/// <summary>Raw Core worth of one chest before any multiplier.</summary>
public static double ChestBaseValue( GridManager.ChestType type ) => type switch
{
GridManager.ChestType.Normal => 1.0,
GridManager.ChestType.Gem => 4.0,
GridManager.ChestType.BombChest => 2.0,
GridManager.ChestType.Locked => 14.0,
GridManager.ChestType.Cursed => 32.0,
GridManager.ChestType.Void => 55.0,
GridManager.ChestType.Boss => 400.0,
GridManager.ChestType.Secret => 25.0,
_ => 1.0,
};
/// <summary>Hits a chest takes before it pops.</summary>
public static int ChestHits( GridManager.ChestType type ) => type switch
{
GridManager.ChestType.Gem => 2,
GridManager.ChestType.Locked => 3,
GridManager.ChestType.Cursed => 4,
GridManager.ChestType.Void => 3,
GridManager.ChestType.Boss => 12,
_ => 1,
};
public static double ChestValueMult( int denseLootLevel ) => 1.0 + 0.16 * denseLootLevel;
/// <summary>
/// The heart of the mode. A detonation that clears N chests multiplies the
/// whole payout by N^exponent — so doubling chain length more than doubles income.
/// </summary>
public static double ChainExponent( int chainPowerLevel, int resonancePerkLevel )
=> 0.80 + 0.026 * chainPowerLevel + 0.02 * resonancePerkLevel;
public static double ChainMultiplier( int cleared, double exponent )
{
if ( cleared <= 1 ) return 1.0;
return Math.Pow( cleared, exponent );
}
public static float CritChance( int level ) => MathF.Min( 0.60f, 0.020f * level );
public static double CritPower( int level ) => 3.0 + 0.9 * level;
// ══════════════════════════════════════════════════════════════
// MANUAL PLAY
// ══════════════════════════════════════════════════════════════
public const double OverloadMultiplier = 3.5;
public static float OverloadCooldown( int level )
=> MathF.Max( 0.8f, 6f * MathF.Pow( 0.90f, level ) );
/// <summary>Surge meter filled per manual overload, 0..1.</summary>
public static float ChargeGain( int capacitorLevel, int momentumPerkLevel )
=> MathF.Min( 0.5f, 0.07f * (1f + 0.09f * capacitorLevel) * (1f + 0.15f * momentumPerkLevel) );
public static float SurgeDuration( int momentumPerkLevel ) => 15f + 4f * momentumPerkLevel;
public const double SurgeMultiplier = 10.0;
public static float GoldenInterval( int level )
=> MathF.Max( 25f, 110f * MathF.Pow( 0.90f, level ) );
public const float GoldenLifetime = 11f;
/// <summary>Golden Chest pays this many seconds of current output.</summary>
public const double GoldenSecondsOfOutput = 150.0;
public static float AutoOverloadInterval( int autopilotLevel ) => autopilotLevel switch
{
<= 0 => 0f,
1 => 14f,
2 => 9f,
_ => 5f,
};
// ══════════════════════════════════════════════════════════════
// OFFLINE
// ══════════════════════════════════════════════════════════════
public static float OfflineEfficiency( int nightCrewLevel, int nightShiftPerkLevel )
=> MathF.Min( 1.6f, 0.25f + 0.05f * nightCrewLevel + 0.08f * nightShiftPerkLevel );
public static float OfflineCapHours( int storageLevel, int deepStoragePerkLevel )
=> 2f + 2f * storageLevel + 2f * deepStoragePerkLevel;
/// <summary>Offline never pays for stretches under this — stops menu bounces printing money.</summary>
public const double OfflineMinSeconds = 20.0;
// ══════════════════════════════════════════════════════════════
// FRAGMENTS — the bridge into the main game economy
// ══════════════════════════════════════════════════════════════
/// <summary>
/// Cores needed to condense the next Fragment today.
///
/// The price climbs 11% per Fragment already condensed today, which is what
/// actually limits the drip — a flat price would let a late-game reactor
/// bottom out the daily allowance in seconds and make the whole condenser
/// branch pointless. Condenser Coil and Condenser Array shift the curve
/// down, so each level is worth a real handful of extra Fragments per day.
/// </summary>
public static double FragmentCost( int coilLevel, int condenserPerkLevel, int fragmentsToday )
{
int n = Math.Clamp( fragmentsToday, 0, 400 );
return 1_200.0
* Math.Pow( 1.11, n )
* Math.Pow( 0.62, coilLevel )
* Math.Pow( 0.85, condenserPerkLevel );
}
/// <summary>Hard safety ceiling on top of the escalating price.</summary>
public static int DailyFragmentCap( int condenserPerkLevel, int petLevel )
=> 200 + 60 * condenserPerkLevel + 15 * Math.Max( 0, petLevel - 1 );
// ══════════════════════════════════════════════════════════════
// PET LINK — cross-mode payoff for levelling the pet in Wave/Survival
// ══════════════════════════════════════════════════════════════
public static double PetBonus( int petLevel, bool petUnlocked, int petLinkPerk )
{
if ( !petUnlocked ) return 1.0;
double bonus = IdlePet.OutputBonus( petLevel ); // Lv6 pet = +75%
if ( petLinkPerk > 0 ) bonus *= 2.0; // Pet Link doubles it
return 1.0 + bonus;
}
// ══════════════════════════════════════════════════════════════
// MELTDOWN (prestige)
// ══════════════════════════════════════════════════════════════
public const double MeltdownRequirement = 1_000_000.0;
public static int FluxFor( double cycleCores )
{
if ( cycleCores < MeltdownRequirement ) return 0;
return (int)Math.Floor( 8.0 * Math.Pow( cycleCores / MeltdownRequirement, 0.40 ) );
}
/// <summary>Global income multiplier granted by unspent + spent Flux alike.</summary>
public static double FluxMultiplier( int fluxEarned ) => 1.0 + 0.01 * fluxEarned;
// ══════════════════════════════════════════════════════════════
// CHEST TIER UNLOCKS — paced so new material keeps appearing for hours
// ══════════════════════════════════════════════════════════════
public class ChestTier
{
public GridManager.ChestType Type;
public double Requires; // cycle cores
public string Name;
public string Icon;
public string Blurb;
public float Weight; // relative spawn weight once unlocked
}
public static readonly List<ChestTier> Tiers = new()
{
new ChestTier { Type = GridManager.ChestType.Locked, Requires = 150_000,
Name = "Locked Chests", Icon = "🔒", Weight = 0.10f,
Blurb = "3 hits, 14× the Cores of a normal chest." },
new ChestTier { Type = GridManager.ChestType.Cursed, Requires = 3_000_000,
Name = "Cursed Chests", Icon = "💀", Weight = 0.07f,
Blurb = "4 hits, 32× value. Stubborn but worth it." },
new ChestTier { Type = GridManager.ChestType.Void, Requires = 25_000_000,
Name = "Void Chests", Icon = "🌑", Weight = 0.05f,
Blurb = "Detonates a 3×3 when it dies — a free chain amplifier." },
new ChestTier { Type = GridManager.ChestType.Boss, Requires = 300_000_000,
Name = "Reactor Cores", Icon = "👑", Weight = 0.015f,
Blurb = "12 hits, 400× value. The big one." },
};
// ══════════════════════════════════════════════════════════════
// DAILY LOOP
// ══════════════════════════════════════════════════════════════
/// <summary>Day 1..7 of the login streak — boost multiplier and duration.</summary>
public static (double mult, float seconds, int frags) DailyReward( int streakDay )
{
int d = Math.Clamp( streakDay, 1, 7 );
return d switch
{
1 => (2.0, 180f, 5),
2 => (2.5, 240f, 8),
3 => (3.0, 300f, 12),
4 => (4.0, 360f, 18),
5 => (5.0, 420f, 25),
6 => (6.0, 480f, 35),
_ => (8.0, 600f, 50),
};
}
}