Game/Idle/IdleReactor.cs
using Sandbox;
using Sandbox.Services;
using System;
using System.Collections.Generic;
using System.Linq;
/// <summary>
/// Reactor mode (GameMode.Idle).
///
/// A self-running board: chests drop in, autonomous drones bomb them, chain
/// reactions pay out Cores. Cores buy upgrades, upgrades make bigger chains,
/// bigger chains print more Cores. Meltdown (prestige) converts a whole cycle
/// into permanent Flux.
///
/// This component owns its own board rather than borrowing GridManager, so
/// Wave and Survival keep working exactly as before.
/// </summary>
public sealed class IdleReactor : Component
{
public static IdleReactor Instance { get; private set; }
// ══════════════════════════════════════════════════════════════════════
// Board
// ══════════════════════════════════════════════════════════════════════
public class Cell
{
public int Row, Col;
public GridManager.ChestType Chest = GridManager.ChestType.None;
public int HitsLeft;
public int MaxHits;
// Visual-only state, driven by the sim and read by IdleHud
public bool HasDrone;
public float DroneAt; // when the drone landed, for the arming pop
public float FlashUntil;
public int FlashKind; // 0 none · 1 blast · 2 kill · 3 drone origin
public float SpawnAt;
public bool HasChest => Chest != GridManager.ChestType.None;
public bool IsEmpty => Chest == GridManager.ChestType.None;
}
public Cell[,] Board { get; private set; }
public int Size { get; private set; } = 5;
// ══════════════════════════════════════════════════════════════════════
// Lifecycle state
// ══════════════════════════════════════════════════════════════════════
public bool IsActive { get; private set; }
public IdleSave S => ChainReactionGame.Save?.Idle;
public PlayerSave Player => ChainReactionGame.Save;
readonly Random _rng = new();
// Timers
float _spawnTimer;
float _cycleTimer;
// A detonation happens in two beats: drones land visibly on their targets,
// then the whole volley goes off. Resolving both in one frame made the mode
// read as "chests silently vanish" — you never saw a bomb.
readonly List<(int r, int c)> _armedTargets = new();
List<(int dr, int dc)> _armedPattern = new();
double _armedMult = 1.0;
float _armTimer = -1f;
public bool IsArmed => _armTimer > 0f;
public float ArmTime => MathF.Min( 0.40f, CycleInterval * 0.45f );
/// <summary>
/// True when the board is too empty for the drones to work with. This is the
/// one failure state the mode has, and it is entirely a signal to go buy
/// Feed Line or Bulk Hopper — coverage has outgrown supply.
/// </summary>
public bool FeedStarved { get; private set; }
public int ChestCount()
{
int n = 0;
for ( int r = 0; r < Size; r++ )
for ( int c = 0; c < Size; c++ )
if ( Board[r, c].HasChest ) n++;
return n;
}
float _goldenTimer;
float _saveTimer;
float _autoOverloadTimer;
// Manual play
float _overloadCooldownLeft;
public float Charge { get; private set; } // 0..1 surge meter
public bool SurgeReady => Charge >= 1f && !SurgeActive;
public bool SurgeActive { get; private set; }
public float SurgeTimeLeft { get; private set; }
public float OverloadCooldownLeft => _overloadCooldownLeft;
public float OverloadCooldownFraction
{
get
{
float total = IdleBalance.OverloadCooldown( S?.Level( IdleUpgradeId.OverloadCooldown ) ?? 0 );
return total <= 0f ? 0f : Math.Clamp( _overloadCooldownLeft / total, 0f, 1f );
}
}
public bool OverloadReady => _overloadCooldownLeft <= 0f;
// Golden chest
public int GoldenRow { get; private set; } = -1;
public int GoldenCol { get; private set; } = -1;
public float GoldenTimeLeft { get; private set; }
public bool GoldenActive => GoldenRow >= 0;
// Temporary multipliers
public class Boost
{
public string Label;
public string Icon;
public double Mult;
public float SecondsLeft;
}
public List<Boost> Boosts { get; private set; } = new();
// Output measurement
public double Cps { get; private set; }
double _cpsAccum;
float _cpsWindow;
// Last detonation readout, for the HUD
public int LastChain { get; private set; }
public double LastPayout { get; private set; }
public bool LastWasCrit { get; private set; }
public float LastPayoutAt { get; private set; }
// Offline / daily staging
public bool OfflinePending => (S?.PendingOfflineCores ?? 0) > 0;
public bool DailyPending { get; private set; }
public int DailyStreakDay { get; private set; }
// Audio throttle — a 0.4s cycle would otherwise machine-gun the mixer
float _lastBlastSound;
float _lastCoinSound;
float _lastPlaceSound;
// ══════════════════════════════════════════════════════════════════════
// Events for the HUD
// ══════════════════════════════════════════════════════════════════════
public event Action OnChanged;
public event Action<int, int, double, bool> OnPop; // row, col, value, crit
public event Action<string, string, string> OnToast; // icon, text, css class
public event Action<int, double, bool> OnDetonation; // chain, payout, crit
void Changed() => OnChanged?.Invoke();
public void Toast( string icon, string text, string cls = "" ) => OnToast?.Invoke( icon, text, cls );
// ══════════════════════════════════════════════════════════════════════
// Boot
// ══════════════════════════════════════════════════════════════════════
/// <summary>
/// When this process booted. Accrual is clamped to this so closing the game
/// stops production dead — the reactor only banks time you were actually
/// running the game, just on another screen.
/// </summary>
static long _processStartUnix;
protected override void OnStart()
{
Instance = this;
if ( _processStartUnix == 0 )
_processStartUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
BuildBoard( 5 );
}
void BuildBoard( int size )
{
Size = Math.Clamp( size, 5, 9 );
Board = new Cell[Size, Size];
for ( int r = 0; r < Size; r++ )
for ( int c = 0; c < Size; c++ )
Board[r, c] = new Cell { Row = r, Col = c };
}
/// <summary>Rebuild the board when Reactor Frame is bought, keeping what fits.</summary>
void ResizeBoard( int size )
{
if ( size == Size ) return;
var old = Board;
int oldSize = Size;
BuildBoard( size );
int n = Math.Min( oldSize, Size );
for ( int r = 0; r < n; r++ )
for ( int c = 0; c < n; c++ )
{
Board[r, c].Chest = old[r, c].Chest;
Board[r, c].HitsLeft = old[r, c].HitsLeft;
Board[r, c].MaxHits = old[r, c].MaxHits;
}
}
// ══════════════════════════════════════════════════════════════════════
// Enter / leave the mode
// ══════════════════════════════════════════════════════════════════════
public void Begin()
{
var s = S;
if ( s == null ) return;
s.Unlocked = true;
ResizeBoard( IdleBalance.GridSize( s.Level( IdleUpgradeId.GridSize ) ) );
// Fresh board every entry — the offline payout already covered the gap
for ( int r = 0; r < Size; r++ )
for ( int c = 0; c < Size; c++ )
{
Board[r, c].Chest = GridManager.ChestType.None;
Board[r, c].HitsLeft = 0;
Board[r, c].HasDrone = false;
Board[r, c].FlashKind = 0;
Board[r, c].FlashUntil = 0f;
}
_armedTargets.Clear();
_armTimer = -1f;
_spawnTimer = 0.25f;
_cycleTimer = CycleInterval * 0.6f;
_goldenTimer = GoldenInterval * 0.5f;
_overloadCooldownLeft = 0f;
_autoOverloadTimer = 0f;
_saveTimer = 10f;
Boosts.Clear();
SurgeActive = false;
SurgeTimeLeft = 0f;
_cpsAccum = 0; _cpsWindow = 0;
Cps = s.MeasuredCps;
ComputeOffline();
CheckDaily();
// Seed the board so the first seconds already look alive
SpawnChests( Math.Max( 4, Size ) );
IsActive = true;
Changed();
}
public void End()
{
if ( !IsActive ) return;
IsActive = false;
var s = S;
if ( s != null )
{
s.LastSeenUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
s.MeasuredCps = Cps;
SubmitReactorStats();
Player?.Save();
}
Changed();
}
void SubmitReactorStats()
{
try
{
var s = S;
if ( s == null ) return;
// Flux earned is the honest "how deep are you" number for this mode
if ( s.FluxEarned > 0 )
Stats.Increment( "chain_reaction_reactor", s.FluxEarned );
}
catch ( Exception e )
{
Log.Warning( $"Reactor stat submit failed: {e.Message}" );
}
}
// ══════════════════════════════════════════════════════════════════════
// Derived values
// ══════════════════════════════════════════════════════════════════════
int Lvl( IdleUpgradeId id ) => S?.Level( id ) ?? 0;
int Perk( IdlePerkId id ) => S?.PerkLevel( id ) ?? 0;
public float SpawnInterval => IdleBalance.SpawnInterval( Lvl( IdleUpgradeId.SpawnRate ) );
public float CycleInterval => IdleBalance.CycleInterval( Lvl( IdleUpgradeId.CycleSpeed ) );
public float GoldenInterval => IdleBalance.GoldenInterval( Lvl( IdleUpgradeId.GoldenChestRate ) );
public double ChainExponent
=> IdleBalance.ChainExponent( Lvl( IdleUpgradeId.ChainPower ), Perk( IdlePerkId.ChainTheory ) );
/// <summary>Everything that multiplies the whole payout, excluding chain length.</summary>
public double GlobalMultiplier
{
get
{
var s = S;
if ( s == null ) return 1.0;
double m = IdleBalance.FluxMultiplier( s.FluxEarned );
m *= 1.0 + 0.25 * Perk( IdlePerkId.CoreEfficiency );
m *= IdleBalance.PetBonus( Player?.PetLevel ?? 1,
Player?.PetUnlockShown ?? false,
Perk( IdlePerkId.PetLink ) );
foreach ( var b in Boosts ) m *= b.Mult;
if ( SurgeActive ) m *= IdleBalance.SurgeMultiplier;
return m;
}
}
public float OfflineEfficiency
=> IdleBalance.OfflineEfficiency( Lvl( IdleUpgradeId.OfflineEfficiency ), Perk( IdlePerkId.NightShift ) );
public float OfflineCapHours
=> IdleBalance.OfflineCapHours( Lvl( IdleUpgradeId.OfflineCap ), Perk( IdlePerkId.DeepStorage ) );
/// <summary>Cores still needed for the next Fragment at today's escalating price.</summary>
public double FragmentCost
=> IdleBalance.FragmentCost( Lvl( IdleUpgradeId.FragmentYield ),
Perk( IdlePerkId.FragmentCondenser ),
S?.FragmentsToday ?? 0 );
public int DailyFragmentCap
=> IdleBalance.DailyFragmentCap( Perk( IdlePerkId.FragmentCondenser ), Player?.PetLevel ?? 1 );
public int FragmentsToday => S?.FragmentsToday ?? 0;
public float FragmentProgressFraction
{
get
{
double cost = FragmentCost;
if ( cost <= 0 ) return 0f;
return (float)Math.Clamp( (S?.FragProgress ?? 0) / cost, 0.0, 1.0 );
}
}
// ══════════════════════════════════════════════════════════════════════
// Update loop
// ══════════════════════════════════════════════════════════════════════
protected override void OnUpdate()
{
if ( !IsActive ) return;
var s = S;
if ( s == null ) return;
// The welcome-back and daily panels hold the reactor still so the player
// actually reads them instead of watching numbers move behind the popup.
if ( OfflinePending || DailyPending ) return;
float dt = Time.Delta;
s.SecondsRun += dt;
TickBoosts( dt );
TickSurge( dt );
TickCooldowns( dt );
TickSpawning( dt );
TickDetonation( dt );
TickGolden( dt );
TickCps( dt );
_saveTimer -= dt;
if ( _saveTimer <= 0f )
{
_saveTimer = 10f;
s.LastSeenUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
s.MeasuredCps = Cps;
Player?.Save();
// Catches players who leave the reactor running past UTC midnight
CheckDaily();
}
}
void TickBoosts( float dt )
{
bool dirty = false;
for ( int i = Boosts.Count - 1; i >= 0; i-- )
{
Boosts[i].SecondsLeft -= dt;
if ( Boosts[i].SecondsLeft <= 0f ) { Boosts.RemoveAt( i ); dirty = true; }
}
if ( dirty ) Changed();
}
void TickSurge( float dt )
{
if ( !SurgeActive ) return;
SurgeTimeLeft -= dt;
if ( SurgeTimeLeft <= 0f )
{
SurgeActive = false;
SurgeTimeLeft = 0f;
Charge = 0f;
Toast( "🔻", "Surge ended", "idle-toast-dim" );
Changed();
}
}
void TickCooldowns( float dt )
{
if ( _overloadCooldownLeft > 0f )
{
_overloadCooldownLeft = MathF.Max( 0f, _overloadCooldownLeft - dt );
if ( _overloadCooldownLeft <= 0f ) Changed();
}
float autoEvery = IdleBalance.AutoOverloadInterval( Perk( IdlePerkId.AutoOverload ) );
if ( autoEvery > 0f )
{
_autoOverloadTimer -= dt;
if ( _autoOverloadTimer <= 0f )
{
_autoOverloadTimer = autoEvery;
if ( OverloadReady ) Overload( auto: true );
}
}
}
void TickSpawning( float dt )
{
// Ambient drip — one chest at a time, purely so the board never looks static
_spawnTimer -= dt;
if ( _spawnTimer > 0f ) return;
_spawnTimer = SpawnInterval;
SpawnChests( 1 );
}
void TickDetonation( float dt )
{
// Beat two — a volley that is already on the board goes off
if ( _armTimer > 0f )
{
_armTimer -= dt;
if ( _armTimer <= 0f ) FireArmed();
}
_cycleTimer -= dt;
if ( _cycleTimer > 0f ) return;
_cycleTimer = CycleInterval;
if ( _armTimer > 0f ) return; // previous volley still resolving
// Beat one — hopper dumps its batch, then the drones land on it
SpawnChests( IdleBalance.SpawnCount( Lvl( IdleUpgradeId.SpawnCount ) ) );
ArmVolley( 1.0, ArmTime );
}
/// <summary>Places the drones and leaves them sitting there so the player sees them.</summary>
void ArmVolley( double payoutMult, float armTime )
{
var pattern = IdleBalance.BlastOffsets( Lvl( IdleUpgradeId.BlastSize ) ).ToList();
var targets = PickTargets( IdleBalance.DroneCount( Lvl( IdleUpgradeId.DroneCount ) ), pattern );
if ( targets.Count == 0 ) return;
float now = Time.Now;
_armedTargets.Clear();
foreach ( var t in targets )
{
var cell = Board[t.r, t.c];
cell.HasDrone = true;
cell.DroneAt = now;
_armedTargets.Add( t );
}
// Fewer chests on the board than the drones could hit means the feed has
// fallen behind whatever coverage the player has bought.
FeedStarved = ChestCount() < Size * Size * 0.22f;
_armedPattern = pattern;
_armedMult = payoutMult;
_armTimer = MathF.Max( 0.06f, armTime );
if ( now - _lastPlaceSound > 0.25f )
{
_lastPlaceSound = now;
var h = Sound.Play( "place_bomb_1" );
h.Volume = 0.35f;
}
Changed();
}
void FireArmed()
{
_armTimer = -1f;
if ( _armedTargets.Count == 0 ) return;
var targets = new List<(int r, int c)>( _armedTargets );
_armedTargets.Clear();
Detonate( targets, _armedPattern, _armedMult, manual: false );
}
void TickGolden( float dt )
{
if ( GoldenActive )
{
GoldenTimeLeft -= dt;
if ( GoldenTimeLeft <= 0f )
{
GoldenRow = GoldenCol = -1;
Changed();
}
return;
}
_goldenTimer -= dt;
if ( _goldenTimer > 0f ) return;
// Randomise around the interval so it never feels metronomic
_goldenTimer = GoldenInterval * (0.7f + (float)_rng.NextDouble() * 0.6f);
SpawnGolden();
}
void TickCps( float dt )
{
_cpsWindow += dt;
if ( _cpsWindow < 1.0f ) return;
double instant = _cpsAccum / _cpsWindow;
_cpsAccum = 0;
_cpsWindow = 0;
// Smooth so a single monster chain does not spike the offline estimate
Cps = Cps <= 0 ? instant : Cps * 0.75 + instant * 0.25;
var s = S;
if ( s != null && Cps > s.BestCps ) s.BestCps = Cps;
CheckAchievements();
Changed();
}
// ══════════════════════════════════════════════════════════════════════
// Spawning
// ══════════════════════════════════════════════════════════════════════
void SpawnChests( int count )
{
var empty = new List<Cell>();
for ( int r = 0; r < Size; r++ )
for ( int c = 0; c < Size; c++ )
if ( Board[r, c].IsEmpty ) empty.Add( Board[r, c] );
if ( empty.Count == 0 ) return;
// Shuffle so the board fills organically rather than row by row
for ( int i = empty.Count - 1; i > 0; i-- )
{
int j = _rng.Next( i + 1 );
(empty[i], empty[j]) = (empty[j], empty[i]);
}
int placed = Math.Min( count, empty.Count );
for ( int i = 0; i < placed; i++ )
{
var cell = empty[i];
cell.Chest = RollChestType();
cell.MaxHits = IdleBalance.ChestHits( cell.Chest );
cell.HitsLeft = cell.MaxHits;
cell.SpawnAt = Time.Now;
}
if ( placed > 0 ) Changed();
}
GridManager.ChestType RollChestType()
{
var s = S;
// Unlocked exotic tiers get first refusal — they are the rare chunky drops
double tierRoll = _rng.NextDouble();
foreach ( var tier in IdleBalance.Tiers )
{
if ( (s?.CycleCores ?? 0) < tier.Requires ) continue;
if ( tierRoll < tier.Weight ) return tier.Type;
tierRoll -= tier.Weight;
}
// Fresh roll for the two staples, so Gem Seeding and Volatile Cargo really
// do hit the percentages their tooltips promise instead of being squeezed
// into whatever probability the tier pass left over.
double roll = _rng.NextDouble();
float bombChance = IdleBalance.BombChestChance( Lvl( IdleUpgradeId.BombChestChance ) );
float gemChance = IdleBalance.GemChance( Lvl( IdleUpgradeId.GemChance ) );
if ( roll < bombChance ) return GridManager.ChestType.BombChest;
roll -= bombChance;
if ( roll < gemChance ) return GridManager.ChestType.Gem;
return GridManager.ChestType.Normal;
}
void SpawnGolden()
{
int r = _rng.Next( Size );
int c = _rng.Next( Size );
GoldenRow = r;
GoldenCol = c;
GoldenTimeLeft = IdleBalance.GoldenLifetime;
Sound.Play( "secret_chest_alert" );
Toast( "🌟", "Golden Chest — grab it!", "idle-toast-gold" );
Changed();
}
// ══════════════════════════════════════════════════════════════════════
// Detonation
// ══════════════════════════════════════════════════════════════════════
/// <summary>
/// Greedy targeting: repeatedly take the cell whose pattern covers the most
/// still-uncovered chest value. Cheap, and it reads as "smart" on screen.
/// </summary>
List<(int r, int c)> PickTargets( int count, List<(int dr, int dc)> pattern )
{
var chosen = new List<(int r, int c)>();
var covered = new HashSet<(int, int)>();
for ( int n = 0; n < count; n++ )
{
double bestScore = 0;
(int r, int c) best = (-1, -1);
for ( int r = 0; r < Size; r++ )
for ( int c = 0; c < Size; c++ )
{
if ( chosen.Contains( (r, c) ) ) continue;
double score = 0;
foreach ( var (dr, dc) in pattern )
{
int nr = r + dr, nc = c + dc;
if ( nr < 0 || nr >= Size || nc < 0 || nc >= Size ) continue;
if ( covered.Contains( (nr, nc) ) ) continue;
var cell = Board[nr, nc];
if ( !cell.HasChest ) continue;
// Bomb chests are worth chasing for the chain, not the loot
double weight = IdleBalance.ChestBaseValue( cell.Chest );
if ( cell.Chest == GridManager.ChestType.BombChest ) weight += 6;
if ( cell.Chest == GridManager.ChestType.Void ) weight += 10;
// Multi-hit chests only pay when the last hit lands
score += weight / Math.Max( 1, cell.HitsLeft );
}
// Slight preference for empty cells so drones look placed, not stacked
if ( Board[r, c].IsEmpty ) score *= 1.05;
if ( score > bestScore ) { bestScore = score; best = (r, c); }
}
if ( best.r < 0 ) break;
if ( bestScore <= 0 && chosen.Count > 0 ) break; // nothing left worth hitting
chosen.Add( best );
foreach ( var (dr, dc) in pattern )
{
int nr = best.r + dr, nc = best.c + dc;
if ( nr < 0 || nr >= Size || nc < 0 || nc >= Size ) continue;
covered.Add( (nr, nc) );
}
}
return chosen;
}
// Secondary blast shapes, built once — these fire thousands of times per minute
static readonly List<(int dr, int dc)> CrossOffsets = IdleBalance.BombChestOffsets().ToList();
static readonly List<(int dr, int dc)> VoidOffsets = new()
{
(-1,-1), (-1,0), (-1,1),
( 0,-1), ( 0,0), ( 0,1),
( 1,-1), ( 1,0), ( 1,1),
};
void Detonate( List<(int r, int c)> origins, List<(int dr, int dc)> pattern, double payoutMult, bool manual )
{
float now = Time.Now;
// kind: 0 = drone payload · 1 = bomb chest cross · 2 = void 3×3
var queue = new Queue<(int r, int c, int kind, int depth)>();
var fired = new HashSet<(int, int)>();
var popped = new List<(int r, int c, GridManager.ChestType type)>();
foreach ( var o in origins )
{
fired.Add( o );
queue.Enqueue( (o.r, o.c, 0, 0) );
var cell = Board[o.r, o.c];
cell.HasDrone = true;
cell.FlashKind = 3;
cell.FlashUntil = now + 0.30f;
}
int guard = 0;
int maxDepth = 0;
while ( queue.Count > 0 && guard++ < 4000 )
{
var (br, bc, kind, depth) = queue.Dequeue();
if ( depth > maxDepth ) maxDepth = depth;
var offsets = kind switch
{
0 => pattern,
2 => VoidOffsets,
_ => CrossOffsets,
};
foreach ( var (dr, dc) in offsets )
{
int nr = br + dr, nc = bc + dc;
if ( nr < 0 || nr >= Size || nc < 0 || nc >= Size ) continue;
var cell = Board[nr, nc];
if ( cell.FlashKind < 2 )
{
cell.FlashKind = 1;
cell.FlashUntil = now + 0.28f;
}
if ( !cell.HasChest ) continue;
cell.HitsLeft--;
if ( cell.HitsLeft > 0 ) continue;
var type = cell.Chest;
popped.Add( (nr, nc, type) );
bool secondary = type == GridManager.ChestType.BombChest
|| type == GridManager.ChestType.Void;
cell.Chest = GridManager.ChestType.None;
cell.HitsLeft = 0;
cell.FlashKind = 2;
cell.FlashUntil = now + 0.34f;
if ( secondary && !fired.Contains( (nr, nc) ) )
{
fired.Add( (nr, nc) );
queue.Enqueue( (nr, nc, type == GridManager.ChestType.Void ? 2 : 1, depth + 1) );
}
}
}
// Clear drone markers — they only exist for the flash frame
foreach ( var o in origins ) Board[o.r, o.c].HasDrone = false;
if ( popped.Count == 0 )
{
Changed();
return;
}
PayOut( popped, payoutMult, manual, maxDepth );
}
void PayOut( List<(int r, int c, GridManager.ChestType type)> popped, double payoutMult, bool manual, int depth )
{
var s = S;
if ( s == null ) return;
double valueMult = IdleBalance.ChestValueMult( Lvl( IdleUpgradeId.ChestValue ) );
double raw = 0;
foreach ( var p in popped )
raw += IdleBalance.ChestBaseValue( p.type ) * valueMult;
int chain = popped.Count;
double chainMult = IdleBalance.ChainMultiplier( chain, ChainExponent );
bool crit = _rng.NextDouble() < IdleBalance.CritChance( Lvl( IdleUpgradeId.CritChance ) );
double critMult = crit ? IdleBalance.CritPower( Lvl( IdleUpgradeId.CritPower ) ) : 1.0;
double total = raw * chainMult * critMult * payoutMult * GlobalMultiplier;
AddCores( total );
s.TotalDetonations++;
s.TotalChestsBlown += chain;
if ( chain > s.BestChain ) s.BestChain = chain;
if ( total > s.BestSingleBlast ) s.BestSingleBlast = total;
LastChain = chain;
LastPayout = total;
LastWasCrit = crit;
LastPayoutAt = Time.Now;
// Per-chest floaters, but capped so a 60-chest wipe does not flood the layer
int floaters = Math.Min( popped.Count, 8 );
double per = total / Math.Max( 1, popped.Count );
for ( int i = 0; i < floaters; i++ )
OnPop?.Invoke( popped[i].r, popped[i].c, per, crit );
PlayDetonationAudio( chain, crit );
OnDetonation?.Invoke( chain, total, crit );
if ( chain >= 12 )
Toast( crit ? "☢️" : "⛓️",
$"{chain} chest chain · +{IdleFormat.Short( total )}",
crit ? "idle-toast-crit" : "idle-toast-chain" );
Changed();
}
void PlayDetonationAudio( int chain, bool crit )
{
float now = Time.Now;
// Hard throttle — cycles can run 2.5×/second at max Fuse Timing
if ( now - _lastBlastSound < 0.30f ) return;
_lastBlastSound = now;
string sfx = chain switch
{
>= 30 => "climax_explosion_lv5",
>= 18 => "climax_explosion_lv4",
>= 10 => "climax_explosion_lv3",
>= 4 => "climax_explosion_lv2",
_ => "climax_explosion_lv1",
};
var h = Sound.Play( sfx );
h.Volume = MathF.Min( 0.55f, 0.18f + chain * 0.012f );
if ( crit && now - _lastCoinSound > 0.6f )
{
_lastCoinSound = now;
Sound.Play( "multiple_coins_earned" );
}
}
// ══════════════════════════════════════════════════════════════════════
// Currency
// ══════════════════════════════════════════════════════════════════════
void AddCores( double amount )
{
if ( amount <= 0 ) return;
var s = S;
s.Cores += amount;
s.CycleCores += amount;
s.AllTimeCores += amount;
_cpsAccum += amount;
CondenseFragments( amount );
}
/// <summary>Turns a slice of Core income into Fragments for the main-game shop.</summary>
void CondenseFragments( double coresEarned )
{
var s = S;
s.EnsureFragDay();
int cap = DailyFragmentCap;
if ( s.TodayReactorFragments >= cap )
{
// Bank at most one Fragment worth so tomorrow starts primed, not rich
s.FragProgress = Math.Min( s.FragProgress, FragmentCost );
return;
}
s.FragProgress += coresEarned;
int granted = 0;
// Price climbs with each Fragment, so it is recomputed every iteration
while ( s.TodayReactorFragments < cap && granted < 100 )
{
double cost = IdleBalance.FragmentCost(
Lvl( IdleUpgradeId.FragmentYield ),
Perk( IdlePerkId.FragmentCondenser ),
s.TodayReactorFragments );
if ( s.FragProgress < cost ) break;
s.FragProgress -= cost;
s.TodayReactorFragments++;
granted++;
}
if ( granted > 0 )
{
Player?.AddReactorFragments( granted, persist: false );
if ( Time.Now - _lastCoinSound > 0.8f )
{
_lastCoinSound = Time.Now;
Sound.Play( "fragments_coins" );
}
Toast( "💠", $"+{granted} Fragment{(granted > 1 ? "s" : "")}", "idle-toast-frag" );
}
}
// ══════════════════════════════════════════════════════════════════════
// Manual interaction
// ══════════════════════════════════════════════════════════════════════
/// <summary>The big red button — an instant, hard-hitting extra cycle.</summary>
public void Overload( bool auto = false )
{
if ( !IsActive || !OverloadReady ) return;
_overloadCooldownLeft = IdleBalance.OverloadCooldown( Lvl( IdleUpgradeId.OverloadCooldown ) );
if ( _armTimer > 0f )
{
// A volley is already sitting on the board — set it off early, harder
_armedMult = Math.Max( _armedMult, IdleBalance.OverloadMultiplier );
FireArmed();
}
else
{
SpawnChests( IdleBalance.SpawnCount( Lvl( IdleUpgradeId.SpawnCount ) ) );
ArmVolley( IdleBalance.OverloadMultiplier, 0.16f ); // snappier than the ambient cycle
_cycleTimer = CycleInterval;
}
if ( !SurgeActive )
{
Charge = MathF.Min( 1f, Charge +
IdleBalance.ChargeGain( Lvl( IdleUpgradeId.ChargeGain ), Perk( IdlePerkId.Momentum ) ) );
if ( Charge >= 1f )
{
Sound.Play( "Notification_1" );
Toast( "⚡", "SURGE READY", "idle-toast-surge" );
}
}
if ( !auto ) Sound.Play( "detonate_1" );
Changed();
}
/// <summary>Cash in a full Surge meter: a short window of enormous output.</summary>
public void ActivateSurge()
{
if ( !IsActive || !SurgeReady ) return;
SurgeActive = true;
SurgeTimeLeft = IdleBalance.SurgeDuration( Perk( IdlePerkId.Momentum ) );
Charge = 0f;
Sound.Play( "fanfare_100" );
Toast( "🔥", $"SURGE — ×{IdleBalance.SurgeMultiplier:F0} for {SurgeTimeLeft:F0}s", "idle-toast-surge" );
Changed();
}
/// <summary>Clicking the board: grab a Golden Chest, or crack a chest by hand.</summary>
public void ClickCell( int r, int c )
{
if ( !IsActive ) return;
if ( r < 0 || r >= Size || c < 0 || c >= Size ) return;
if ( GoldenActive && GoldenRow == r && GoldenCol == c )
{
GrabGolden();
return;
}
var cell = Board[r, c];
if ( !cell.HasChest ) return;
cell.HitsLeft--;
cell.FlashKind = 1;
cell.FlashUntil = Time.Now + 0.2f;
if ( cell.HitsLeft <= 0 )
{
var popped = new List<(int, int, GridManager.ChestType)> { (r, c, cell.Chest) };
cell.Chest = GridManager.ChestType.None;
cell.HitsLeft = 0;
cell.FlashKind = 2;
cell.FlashUntil = Time.Now + 0.3f;
// Hand-cracked chests pay a flat bonus rather than a chain — they are
// a fidget, never a better strategy than upgrading the reactor.
PayOut( popped, 2.0, manual: true, depth: 0 );
}
else
{
Sound.Play( "Pop_Button_1" );
Changed();
}
}
void GrabGolden()
{
var s = S;
GoldenRow = GoldenCol = -1;
GoldenTimeLeft = 0f;
s.GoldenGrabbed++;
// Pays a chunk of real output, with a floor so it matters on day one
double payout = Math.Max( 250.0, Cps * IdleBalance.GoldenSecondsOfOutput );
AddCores( payout );
// Plus a short multiplier — the reason to keep the tab focused
AddBoost( "Golden Rush", "🌟", 3.0, 30f );
Sound.Play( "open_last_chest" );
Sound.Play( "coins_earned" );
Toast( "🌟", $"Golden Chest · +{IdleFormat.Short( payout )} · ×3 for 30s", "idle-toast-gold" );
CheckAchievements();
Changed();
}
public void AddBoost( string label, string icon, double mult, float seconds )
{
var existing = Boosts.FirstOrDefault( b => b.Label == label );
if ( existing != null )
{
existing.SecondsLeft = MathF.Max( existing.SecondsLeft, seconds );
return;
}
Boosts.Add( new Boost { Label = label, Icon = icon, Mult = mult, SecondsLeft = seconds } );
Changed();
}
// ══════════════════════════════════════════════════════════════════════
// Shopping
// ══════════════════════════════════════════════════════════════════════
public double CostFor( IdleUpgradeId id, int count )
{
var up = IdleUpgradeCatalog.Get( id );
if ( up == null ) return double.MaxValue;
int level = Lvl( id );
double total = 0;
for ( int i = 0; i < count; i++ )
{
if ( level + i >= up.MaxLevel ) break;
total += up.CostAt( level + i );
}
return total;
}
/// <summary>How many levels the current Core balance can afford, capped at 250.</summary>
public int MaxAffordable( IdleUpgradeId id )
{
var up = IdleUpgradeCatalog.Get( id );
if ( up == null ) return 0;
int level = Lvl( id );
double budget = S?.Cores ?? 0;
int n = 0;
while ( level + n < up.MaxLevel && n < 250 )
{
double c = up.CostAt( level + n );
if ( c > budget ) break;
budget -= c;
n++;
}
return n;
}
public bool CanAfford( IdleUpgradeId id, int count )
{
var up = IdleUpgradeCatalog.Get( id );
if ( up == null ) return false;
if ( Lvl( id ) >= up.MaxLevel ) return false;
return (S?.Cores ?? 0) >= CostFor( id, count );
}
public bool TryBuy( IdleUpgradeId id, int count = 1 )
{
var s = S;
var up = IdleUpgradeCatalog.Get( id );
if ( s == null || up == null ) return false;
int level = s.Level( id );
if ( level >= up.MaxLevel ) return false;
int bought = 0;
double spent = 0;
for ( int i = 0; i < count; i++ )
{
if ( level + bought >= up.MaxLevel ) break;
double c = up.CostAt( level + bought );
if ( s.Cores - spent < c ) break;
spent += c;
bought++;
}
if ( bought == 0 ) return false;
s.Cores -= spent;
s.SetLevel( id, level + bought );
if ( id == IdleUpgradeId.GridSize )
{
ResizeBoard( IdleBalance.GridSize( s.Level( IdleUpgradeId.GridSize ) ) );
Sound.Play( "whoosh_deep_wave_milestone" );
Toast( "🔲", $"Reactor expanded to {Size}×{Size}", "idle-toast-big" );
}
else if ( id == IdleUpgradeId.BlastSize )
{
Sound.Play( "Purchase_Success" );
Toast( "💥", $"Warhead upgraded — {IdleBalance.BlastName( s.Level( id ) )}", "idle-toast-big" );
}
else
{
Sound.Play( "BuyShop" );
}
Player?.Save();
Changed();
return true;
}
public bool CanBuyPerk( IdlePerkId id )
{
var s = S;
var perk = IdleUpgradeCatalog.GetPerk( id );
if ( s == null || perk == null ) return false;
int lvl = s.PerkLevel( id );
if ( lvl >= perk.MaxLevel ) return false;
return s.Flux >= perk.CostAt( lvl );
}
public bool TryBuyPerk( IdlePerkId id )
{
if ( !CanBuyPerk( id ) ) return false;
var s = S;
var perk = IdleUpgradeCatalog.GetPerk( id );
int lvl = s.PerkLevel( id );
s.Flux -= perk.CostAt( lvl );
s.SetPerkLevel( id, lvl + 1 );
Sound.Play( "Purchase_Success" );
Toast( perk.Icon, $"{perk.Name} → Lv {lvl + 1}", "idle-toast-flux" );
Player?.Save();
Changed();
return true;
}
// ══════════════════════════════════════════════════════════════════════
// Meltdown (prestige)
// ══════════════════════════════════════════════════════════════════════
public int PendingFlux => IdleBalance.FluxFor( S?.CycleCores ?? 0 );
public bool CanMeltdown => PendingFlux > 0;
/// <summary>Progress toward the very first Meltdown, for the intro bar.</summary>
public float MeltdownProgress
=> (float)Math.Clamp( (S?.CycleCores ?? 0) / IdleBalance.MeltdownRequirement, 0.0, 1.0 );
public void DoMeltdown()
{
var s = S;
if ( s == null || !CanMeltdown ) return;
int gained = PendingFlux;
s.Flux += gained;
s.FluxEarned += gained;
s.ResetForMeltdown();
ResizeBoard( IdleBalance.GridSize( s.Level( IdleUpgradeId.GridSize ) ) );
for ( int r = 0; r < Size; r++ )
for ( int c = 0; c < Size; c++ )
{
Board[r, c].Chest = GridManager.ChestType.None;
Board[r, c].HitsLeft = 0;
}
Boosts.Clear();
SurgeActive = false;
Charge = 0f;
Cps = 0;
s.MeasuredCps = 0;
_cpsAccum = 0; _cpsWindow = 0;
_spawnTimer = 0.2f;
_cycleTimer = CycleInterval;
// A fresh Meltdown deserves a running start
AddBoost( "Fresh Core", "☢️", 5.0, 60f );
SpawnChests( Math.Max( 4, Size ) );
Sound.Play( "fanfare_200" );
Sound.Play( "applause_1" );
Toast( "☢️", $"MELTDOWN · +{gained} Flux", "idle-toast-flux" );
CheckAchievements();
Player?.Save();
Changed();
}
// ══════════════════════════════════════════════════════════════════════
// Offline
// ══════════════════════════════════════════════════════════════════════
void ComputeOffline()
{
var s = S;
if ( s == null ) return;
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if ( s.LastSeenUnix <= 0 )
{
s.LastSeenUnix = now;
return;
}
// Never pay for wall-clock time spent with the game shut. After a restart
// the process start is later than the stored stamp, so elapsed collapses
// to roughly zero.
long since = Math.Max( s.LastSeenUnix, _processStartUnix );
double elapsed = Math.Max( 0, now - since );
s.LastSeenUnix = now;
if ( elapsed < IdleBalance.OfflineMinSeconds ) return;
if ( s.MeasuredCps <= 0 ) return;
double capped = Math.Min( elapsed, OfflineCapHours * 3600.0 );
double cores = s.MeasuredCps * capped * OfflineEfficiency;
if ( cores <= 0 ) return;
// Accumulate rather than assign — quitting with an unclaimed report must
// not throw the previous haul away.
s.PendingOfflineCores += cores;
s.PendingOfflineSeconds += elapsed;
// Fragments are previewed here and actually granted on claim
s.EnsureFragDay();
s.PendingOfflineFrags = PreviewFragmentsFor( s.FragProgress + cores );
}
// ── Menu-side preview (works without the mode being open) ──────────────
/// <summary>Real seconds since the reactor was last attended.</summary>
public static double PreviewOfflineSeconds( IdleSave s )
{
if ( s == null || s.LastSeenUnix <= 0 ) return 0;
long since = Math.Max( s.LastSeenUnix, _processStartUnix );
return Math.Max( 0, DateTimeOffset.UtcNow.ToUnixTimeSeconds() - since );
}
/// <summary>What the welcome-back panel would pay right now.</summary>
public static double PreviewOfflineCores( IdleSave s )
{
if ( s == null || s.MeasuredCps <= 0 ) return 0;
double elapsed = PreviewOfflineSeconds( s );
if ( elapsed < IdleBalance.OfflineMinSeconds ) return 0;
float capHours = IdleBalance.OfflineCapHours(
s.Level( IdleUpgradeId.OfflineCap ), s.PerkLevel( IdlePerkId.DeepStorage ) );
float eff = IdleBalance.OfflineEfficiency(
s.Level( IdleUpgradeId.OfflineEfficiency ), s.PerkLevel( IdlePerkId.NightShift ) );
double capped = Math.Min( elapsed, capHours * 3600.0 );
return s.PendingOfflineCores + s.MeasuredCps * capped * eff;
}
/// <summary>0..1 fill of the offline storage tank, for the menu bar.</summary>
public static float PreviewStorageFraction( IdleSave s )
{
if ( s == null ) return 0f;
float capHours = IdleBalance.OfflineCapHours(
s.Level( IdleUpgradeId.OfflineCap ), s.PerkLevel( IdlePerkId.DeepStorage ) );
if ( capHours <= 0f ) return 0f;
return (float)Math.Clamp( PreviewOfflineSeconds( s ) / (capHours * 3600.0), 0.0, 1.0 );
}
/// <summary>True when a new daily streak reward is waiting to be claimed.</summary>
public static bool DailyAvailable( IdleSave s )
=> s != null && s.Unlocked && s.LastDailyDate != DateTime.UtcNow.ToString( "yyyy-MM-dd" );
/// <summary>Dry-run of the condenser so the offline report can promise a number.</summary>
int PreviewFragmentsFor( double banked )
{
var s = S;
if ( s == null ) return 0;
int coil = Lvl( IdleUpgradeId.FragmentYield );
int perk = Perk( IdlePerkId.FragmentCondenser );
int cap = DailyFragmentCap;
int at = s.TodayReactorFragments;
int got = 0;
while ( at < cap && got < 100 )
{
double cost = IdleBalance.FragmentCost( coil, perk, at );
if ( banked < cost ) break;
banked -= cost;
at++;
got++;
}
return got;
}
public double PendingOfflineCores => S?.PendingOfflineCores ?? 0;
public double PendingOfflineSeconds => S?.PendingOfflineSeconds ?? 0;
public int PendingOfflineFrags => S?.PendingOfflineFrags ?? 0;
public void ClaimOffline( bool doubled = false )
{
var s = S;
if ( s == null || s.PendingOfflineCores <= 0 ) return;
double cores = s.PendingOfflineCores * (doubled ? 2.0 : 1.0);
s.PendingOfflineCores = 0;
s.PendingOfflineSeconds = 0;
s.PendingOfflineFrags = 0;
AddCores( cores );
Sound.Play( "coins_earned" );
Sound.Play( "Whoosh_Stat" );
Toast( "🛢️", $"Collected {IdleFormat.Short( cores )} Cores", "idle-toast-big" );
Player?.Save();
Changed();
}
// ══════════════════════════════════════════════════════════════════════
// Daily streak
// ══════════════════════════════════════════════════════════════════════
void CheckDaily()
{
var s = S;
if ( s == null ) return;
string today = DateTime.UtcNow.ToString( "yyyy-MM-dd" );
if ( s.LastDailyDate == today )
{
DailyPending = false;
return;
}
string yesterday = DateTime.UtcNow.AddDays( -1 ).ToString( "yyyy-MM-dd" );
s.DailyStreak = s.LastDailyDate == yesterday ? Math.Min( 7, s.DailyStreak + 1 ) : 1;
DailyStreakDay = s.DailyStreak;
DailyPending = true;
s.DailyClaimed = false;
}
public void ClaimDaily()
{
var s = S;
if ( s == null || !DailyPending ) return;
var (mult, seconds, frags) = IdleBalance.DailyReward( s.DailyStreak );
s.LastDailyDate = DateTime.UtcNow.ToString( "yyyy-MM-dd" );
s.DailyClaimed = true;
DailyPending = false;
AddBoost( $"Day {s.DailyStreak} Bonus", "📅", mult, seconds );
Player?.AddReactorFragments( frags );
Sound.Play( "gift" );
Sound.Play( "fireworks" );
Toast( "📅", $"Day {s.DailyStreak} · ×{mult:F1} for {IdleFormat.Duration( seconds )} · +{frags} 💠", "idle-toast-big" );
Player?.Save();
Changed();
}
// ══════════════════════════════════════════════════════════════════════
// Achievements
// ══════════════════════════════════════════════════════════════════════
public void CheckAchievements()
{
var s = S;
if ( s == null ) return;
var done = IdleAchievements.CollectNewlyCompleted( s );
if ( done.Count == 0 ) return;
foreach ( var a in done )
{
if ( a.RewardFlux > 0 )
{
s.Flux += a.RewardFlux;
s.FluxEarned += a.RewardFlux;
}
if ( a.RewardFrags > 0 )
Player?.AddReactorFragments( a.RewardFrags );
string reward = a.RewardFlux > 0 && a.RewardFrags > 0
? $"+{a.RewardFlux} 🔷 +{a.RewardFrags} 💠"
: a.RewardFlux > 0 ? $"+{a.RewardFlux} 🔷" : $"+{a.RewardFrags} 💠";
Toast( a.Icon, $"{a.Name} · {reward}", "idle-toast-achievement" );
}
Sound.Play( "pearl_stars" );
Player?.Save();
}
// ══════════════════════════════════════════════════════════════════════
// Unlock gating helpers used by the HUD
// ══════════════════════════════════════════════════════════════════════
/// <summary>An upgrade only shows once the player has been near its price.</summary>
public bool IsRevealed( IdleUpgrade up )
{
if ( up.RevealAt <= 0 ) return true;
var s = S;
if ( s == null ) return false;
return s.AllTimeCores >= up.RevealAt || s.Level( up.Id ) > 0;
}
public IEnumerable<IdleBalance.ChestTier> UnlockedTiers
=> IdleBalance.Tiers.Where( t => (S?.CycleCores ?? 0) >= t.Requires );
public IdleBalance.ChestTier NextTier
=> IdleBalance.Tiers.FirstOrDefault( t => (S?.CycleCores ?? 0) < t.Requires );
}