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 );
// Follow-up volleys owed to the current cycle — Twin Reactor schedules them
// up front, Resonance Cascade adds more as they resolve.
int _volleysLeft;
int _volleysFired;
/// <summary>
/// Hard ceiling on volleys in one cycle. Cascade is a self-feeding roll, so
/// without this a maxed one can hold _armTimer above zero indefinitely and
/// the cycle timer never gets to run again.
/// </summary>
const int MaxVolleysPerCycle = 6;
/// <summary>Set while a cascade or twin volley is resolving — the HUD flashes on it.</summary>
public bool CascadeActive { get; private set; }
/// <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;
float _lastChipSound;
// Idle players click fast, so the chip cue is rate-limited and kept quieter
// than every payout sound. Overlapping instances are what turn a fidget into
// an earache.
const float ChipSoundGap = 0.15f;
// ══════════════════════════════════════════════════════════════════════
// 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();
CheckContractDay();
SeedContractSeen();
// 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 );
// Same communal chest total the wave mode submits — the reactor is
// where most chests actually get blown, so without this the shared
// bar would barely move.
var player = Player;
if ( player != null )
Stats.Increment( "chain_reaction_chests", player.CommunalChestTotal() );
// Cores are the metric every reactor player has from minute one —
// Flux only appears after a Meltdown, so a Flux board reads empty
// for anyone who has not prestiged yet. Cores give newcomers
// something to compete on immediately.
if ( s.AllTimeCores > 0 )
Stats.Increment( "chain_reaction_cores", s.AllTimeCores );
}
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;
int Pow( IdlePowerId id ) => S?.PowerLevel( 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 ),
Pow( IdlePowerId.ChainMemory ) );
/// <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 *= IdleBalance.ContainmentMultiplier( Perk( IdlePerkId.Containment ) );
// Shards are the only multiplier a Singularity does not burn — this
// is what stops the collapse being a pure loss.
m *= IdleBalance.ShardMultiplier( s.ShardsEarned );
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();
CheckContractDay();
}
}
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 ) ) );
_volleysLeft = IdleBalance.VolleysPerCycle( Pow( IdlePowerId.TwinReactor ) ) - 1;
_volleysFired = 0;
CascadeActive = false;
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.28f;
}
Changed();
}
void FireArmed()
{
_armTimer = -1f;
if ( _armedTargets.Count == 0 ) { _volleysLeft = 0; CascadeActive = false; return; }
var targets = new List<(int r, int c)>( _armedTargets );
_armedTargets.Clear();
double mult = _armedMult;
_volleysFired++;
Detonate( targets, _armedPattern, mult, manual: false );
// Resonance Cascade — the volley that just resolved can shake another one
// loose. Rolled after the payout so the chance applies to every volley in
// the cycle, cascades included.
float cascade = IdleBalance.CascadeChance( Pow( IdlePowerId.Cascade ) );
bool rolled = cascade > 0f && _rng.NextDouble() < cascade;
if ( rolled ) _volleysLeft++;
if ( _volleysLeft <= 0 || _volleysFired >= MaxVolleysPerCycle )
{
_volleysLeft = 0;
CascadeActive = false;
return;
}
_volleysLeft--;
// A follow-up lands on a board the last volley already cleared, so it
// needs its own material or it detonates into empty cells. Half a hopper
// batch keeps it worth firing without turning the cycle into free money.
SpawnChests( Math.Max( 2, IdleBalance.SpawnCount( Lvl( IdleUpgradeId.SpawnCount ) ) / 2 ) );
// Compounding, not flat: volley three is worth 0.55 of volley two, not of
// volley one. That is what keeps a maxed Cascade from running away.
double step = rolled ? IdleBalance.CascadeMultiplier : IdleBalance.ExtraVolleyMultiplier;
CascadeActive = true;
if ( rolled ) Sound.Play( "pearl_stars" );
ArmVolley( mult * step, ArmTime * 0.35f );
}
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();
CheckContracts();
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();
int skip = Pow( IdlePowerId.TierSkip );
for ( int i = 0; i < IdleBalance.Tiers.Count; i++ )
{
var tier = IdleBalance.Tiers[i];
if ( (s?.CycleCores ?? 0) < IdleBalance.TierRequirement( i, skip ) ) 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 ( chain > s.BestChainToday ) s.BestChainToday = 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" );
// Autopilot volleys are not the player doing anything, so they do
// not count toward the "Hands On" contract.
if ( S != null ) S.TotalOverloads++;
OnManualOverload?.Invoke();
}
Changed();
}
/// <summary>Fired when the player themselves pressed Overload — drives the tutorial gate.</summary>
public event Action OnManualOverload;
/// <summary>Fired when the player buys any Core upgrade — drives the tutorial gate.</summary>
public event Action OnUpgradeBought;
/// <summary>Fired when the player cracks a chest by hand — drives the tutorial gate.</summary>
public event Action OnManualCrack;
/// <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;
if ( S != null ) S.TotalSurges++;
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;
OnManualCrack?.Invoke();
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
{
// A chip on a multi-hit chest used to play Pop_Button_1 — the same
// cue the shop uses for "you can't afford that". Chipping is the
// core fidget of this mode, so it gets its own rising crack rather
// than the rejection buzzer.
float chipNow = Time.Now;
if ( chipNow - _lastChipSound > ChipSoundGap )
{
_lastChipSound = chipNow;
// 0 on the first chip, 1 on the one before the break
int done = cell.MaxHits - cell.HitsLeft;
float t = cell.MaxHits > 1
? MathF.Min( 1f, (done - 1) / (float)(cell.MaxHits - 1) )
: 0f;
var h = Sound.Play( "gems_break" );
h.Volume = 0.22f + t * 0.10f; // stays under place_bomb_1 (0.28)
h.Pitch = 1f + t * 0.22f; // rises toward the break
}
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 );
s.TotalUpgradesBought += bought;
OnUpgradeBought?.Invoke();
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)
// ══════════════════════════════════════════════════════════════════════
/// <summary>Cycle Cores this reactor's next Meltdown costs, after Overclock.</summary>
public double MeltdownRequirement
=> IdleBalance.MeltdownRequirementFor( Pow( IdlePowerId.Overclock ) );
public int PendingFlux => IdleBalance.FluxFor( S?.CycleCores ?? 0,
Pow( IdlePowerId.Overclock ),
Pow( IdlePowerId.FluxCatalyst ) );
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) / 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();
}
// ══════════════════════════════════════════════════════════════════════
// Singularity (second prestige)
//
// Meltdown trades a cycle for Flux; Singularity trades a whole Flux career
// for Shards. Shards buy Powers, and Powers are the only thing in the mode
// that changes the SHAPE of a cycle rather than its size — which is the
// reason the layer exists at all.
// ══════════════════════════════════════════════════════════════════════
public int PendingShards => IdleBalance.ShardsFor( S?.FluxEarned ?? 0 );
public bool CanSingularity => PendingShards > 0;
public bool SingularityVisible => (S?.FluxEarned ?? 0) >= IdleBalance.SingularityRequirement / 2
|| (S?.Singularities ?? 0) > 0;
public float SingularityProgress
=> (float)Math.Clamp( (S?.FluxEarned ?? 0) / (double)IdleBalance.SingularityRequirement, 0.0, 1.0 );
public bool BuyPower( IdlePowerId id )
{
var s = S;
if ( s == null ) return false;
var power = IdleUpgradeCatalog.GetPower( id );
if ( power == null ) return false;
int lvl = s.PowerLevel( id );
if ( lvl >= power.MaxLevel ) { Sound.Play( "Pop_Button_1" ); return false; }
int cost = power.CostAt( lvl );
if ( s.Shards < cost ) { Sound.Play( "Pop_Button_1" ); return false; }
s.Shards -= cost;
s.SetPowerLevel( id, lvl + 1 );
// Overclock can drop the Meltdown gate below what is already banked, and
// Tier Memory can unlock chests mid-cycle. Both only read on the next
// tick, so nothing to recompute here beyond telling the HUD.
Sound.Play( "Purchase_Success" );
Toast( power.Icon, $"{power.Name} → Lv {lvl + 1}", "idle-toast-flux" );
Player?.Save();
Changed();
return true;
}
public void DoSingularity()
{
var s = S;
if ( s == null || !CanSingularity ) return;
// Anything gated on lifetime Flux has to be banked before the collapse
// wipes the number it is measured against.
CheckAchievements();
int gained = PendingShards;
s.Shards += gained;
s.ShardsEarned += gained;
s.ResetForSingularity();
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;
_volleysLeft = 0;
_volleysFired = 0;
CascadeActive = false;
// The perk tree is gone, so the opening minutes are barer than after a
// Meltdown. A longer, harder boost is what carries the player over that.
AddBoost( "Singularity", "✴️", 12.0, 120f );
SpawnChests( Math.Max( 6, Size ) );
Sound.Play( "fanfare_200" );
Sound.Play( "applause_1" );
Toast( "✴️", $"SINGULARITY · +{gained} Shards", "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();
}
// ══════════════════════════════════════════════════════════════════════
// Daily contracts
//
// Three finishable jobs a day. The reactor already pays you for being away;
// this is the only part of the mode that pays you for turning up.
// ══════════════════════════════════════════════════════════════════════
/// <summary>True when a finished contract is sitting there waiting to be claimed.</summary>
public bool ContractReady => IdleContracts.AnyClaimable( S );
/// <summary>Set for one frame after a contract crosses its target, so the HUD can pop.</summary>
public int JustCompletedContract { get; private set; } = -1;
// Which slots had already crossed the line last time we looked. Without
// this the completion toast would fire every second forever.
readonly bool[] _contractSeen = new bool[IdleContracts.DailyCount];
void CheckContractDay()
{
var s = S;
if ( s == null ) return;
if ( !IdleContracts.EnsureToday( s ) ) return;
for ( int i = 0; i < _contractSeen.Length; i++ ) _contractSeen[i] = false;
Toast( "📋", "New contracts posted", "idle-toast-contract" );
Player?.Save();
Changed();
}
/// <summary>
/// A save loaded mid-day already has finished contracts in it. Marking them
/// seen on entry keeps the mode from toasting three completions the player
/// already knows about.
///
/// Entry only — doing this on the ten-second save tick as well would race
/// the one-second completion check and silently eat a real toast whenever
/// the two landed on the same frame.
/// </summary>
void SeedContractSeen()
{
var s = S;
if ( s == null ) return;
for ( int i = 0; i < IdleContracts.DailyCount; i++ )
_contractSeen[i] = IdleContracts.IsComplete( s, i );
}
void CheckContracts()
{
var s = S;
if ( s == null ) return;
for ( int i = 0; i < IdleContracts.DailyCount; i++ )
{
if ( _contractSeen[i] ) continue;
if ( !IdleContracts.IsComplete( s, i ) ) continue;
_contractSeen[i] = true;
var c = IdleContracts.At( s, i );
if ( c == null ) continue;
Sound.Play( "Notification_1" );
Toast( "📋", $"Contract done · {c.Name}", "idle-toast-contract" );
JustCompletedContract = i;
Changed();
}
}
public bool ClaimContract( int slot )
{
var s = S;
if ( s == null || !IdleContracts.CanClaim( s, slot ) ) return false;
int frags = IdleContracts.Reward( s, slot );
s.ContractClaimed[slot] = true;
Player?.AddReactorFragments( frags, persist: false );
bool finishedSet = IdleContracts.AllClaimed( s );
if ( finishedSet )
{
// A streak only advances once per day, and only if yesterday's set
// was also cleared — same shape as the login streak.
string today = IdleContracts.TodayKey;
string yesterday = DateTime.UtcNow.AddDays( -1 ).ToString( "yyyy-MM-dd" );
if ( s.ContractStreakDate != today )
{
s.ContractStreak = s.ContractStreakDate == yesterday ? s.ContractStreak + 1 : 1;
s.ContractStreakDate = today;
}
// The whole set clearing is the day's real reward: a long, fat boost
// that is worth timing your Surge against.
AddBoost( "Contracts Cleared", "📋", 3.0, 300f );
Sound.Play( "fanfare_100" );
Toast( "📋", $"All contracts cleared · ×3 for 5 min · +{frags} 💠", "idle-toast-big" );
}
else
{
Sound.Play( "gift" );
Toast( "💠", $"+{frags} Fragments", "idle-toast-frag" );
}
Player?.Save();
Changed();
return true;
}
// ══════════════════════════════════════════════════════════════════════
// 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 bool TierUnlocked( int index )
=> (S?.CycleCores ?? 0) >= IdleBalance.TierRequirement( index, Pow( IdlePowerId.TierSkip ) );
public IEnumerable<IdleBalance.ChestTier> UnlockedTiers
=> IdleBalance.Tiers.Where( ( t, i ) => TierUnlocked( i ) );
public IdleBalance.ChestTier NextTier
=> IdleBalance.Tiers.Where( ( t, i ) => !TierUnlocked( i ) ).FirstOrDefault();
}