Game/PlayerProgress.cs
namespace Monolith;
/// <summary>Everything about a player that survives a session, in a shape we can serialise.</summary>
public sealed class SaveData
{
public double Dust { get; set; }
public double LifetimeCubes { get; set; }
public int Cores { get; set; }
public int[] UpgradeLevels { get; set; } = new int[Enum.GetValues<UpgradeKind>().Length];
public int MonolithsCleared { get; set; }
/// <summary>Highest solo stage reached this run, 1-based. Drives prestige and the board.</summary>
public int HighestStage { get; set; } = 1;
/// <summary>Highest solo stage ever reached, across all collapses. Leaderboard value.</summary>
public int BestStageEver { get; set; } = 1;
/// <summary>Lifetime prestige count. One of the global boards.</summary>
public int Collapses { get; set; }
/// <summary>Best full stage 1 to 100 run, in seconds. 0 means never completed.</summary>
public float BestLadderSeconds { get; set; }
/// <summary>Levels purchased in the Core Tree, indexed by CoreNodeKind.</summary>
public int[] CoreNodes { get; set; } = new int[Enum.GetValues<CoreNodeKind>().Length];
/// <summary>Ids of earned Marks. Stored by id so reordering the list cannot corrupt them.</summary>
public List<string> EarnedMarks { get; set; } = new();
// Counters that exist purely so Marks have something to measure.
public int DeepDetonations { get; set; }
public int PerfectReloads { get; set; }
public int VolatileDetonations { get; set; }
public int InterceptorKills { get; set; }
public int MonolithCredits { get; set; }
public int BestResonance { get; set; }
/// <summary>Slag in hand. Accrues on a real-world clock, including while shut.</summary>
public int Slag { get; set; }
/// <summary>UTC ticks the Slag clock was last settled. 0 means never.</summary>
public long SlagClockTicks { get; set; }
/// <summary>
/// Whether the welcome pages have been shown. Saved rather than session-scoped, so a
/// returning player is not made to read them again every launch.
/// </summary>
public bool SeenTutorial { get; set; }
public int LeechesPopped { get; set; }
public int SpottersDowned { get; set; }
public int ShieldNodesBroken { get; set; }
public int SentinelsDowned { get; set; }
public int CrawlersKilled { get; set; }
public int OrbsShot { get; set; }
public int AnchorsCut { get; set; }
/// <summary>Restriction the current run is under, if any.</summary>
public HollowKind Hollow { get; set; } = HollowKind.None;
/// <summary>Hollow kinds already completed, for the UI and for one-time rewards.</summary>
public List<string> HollowCleared { get; set; } = new();
}
/// <summary>
/// Per-player economy and upgrades. Progression is deliberately client-side and saved locally:
/// this is a co-op game against a rock, not a competitive one, and keeping it off the wire
/// removes a large amount of networking surface. Only scoreboard values are synced.
/// </summary>
public sealed class PlayerProgress : Component
{
private const string SavePath = "monolith_progress.json";
public SaveData Data { get; private set; } = new();
/// <summary>
/// Cubes this player has removed from the current monolith. Local only for now: v1 rigs
/// are not networked GameObjects, so there is nothing to sync against yet. When avatars
/// and a scoreboard land, this becomes [Sync] on a NetworkMode.Object player.
/// </summary>
public long CubesThisMonolith { get; set; }
private GameTimeSince timeSinceSave;
/// <summary>
/// Cubes awarded but not yet submitted to the global stat. At high upgrade levels this is
/// thousands per second, so it is batched rather than submitted per blast.
/// </summary>
private double pendingStatCubes;
private GameTimeSince timeSinceStatFlush;
public static PlayerProgress Local { get; private set; }
protected override void OnStart()
{
Local = this;
Load();
// Settle the wall clock immediately: most Slag is earned while the game is shut.
AccrueSlag();
}
protected override void OnUpdate()
{
// Frozen while a blocking screen or the pause menu is up. See GameTime.
if ( GameTime.Paused )
return;
if ( timeSinceSave > 15f )
{
timeSinceSave = 0;
Save();
}
if ( timeSinceStatFlush > 5f && pendingStatCubes >= 1 )
{
timeSinceStatFlush = 0;
MonolithStats.AddCubes( pendingStatCubes );
pendingStatCubes = 0;
}
UpdateResonance();
// Buying Instability changes which blocks are volatile, so the shape has to be
// recoloured. Pushing it here keeps the mesher and the hit test reading one value.
if ( !VolatileChance.AlmostEqual( Stages.VolatileChance ) )
{
Stages.VolatileChance = VolatileChance;
MonolithManager.Instance?.RefreshVolatileAppearance();
}
// Marks are checked on a timer rather than at every call site, so adding one never
// means threading a new notification through the gameplay code.
if ( timeSinceMarkCheck > 0.5f )
{
timeSinceMarkCheck = 0;
CheckMarks();
}
if ( timeSinceSlagCheck > 20f )
{
timeSinceSlagCheck = 0;
AccrueSlag();
}
}
private GameTimeSince timeSinceMarkCheck;
private GameTimeSince timeSinceSlagCheck;
// ---------------------------------------------------------------- persistence
public void Load()
{
try
{
var loaded = FileSystem.Data.ReadJsonOrDefault<SaveData>( SavePath, null );
if ( loaded != null )
{
Data = loaded;
// Tolerate save files written before an upgrade was added.
var count = Enum.GetValues<UpgradeKind>().Length;
if ( Data.UpgradeLevels == null || Data.UpgradeLevels.Length < count )
{
var grown = new int[count];
Data.UpgradeLevels?.CopyTo( grown, 0 );
Data.UpgradeLevels = grown;
}
}
}
catch ( Exception e )
{
Log.Warning( $"Could not load progress, starting fresh: {e.Message}" );
Data = new SaveData();
}
}
public void Save()
{
try
{
FileSystem.Data.WriteJson( SavePath, Data );
}
catch ( Exception e )
{
Log.Warning( $"Could not save progress: {e.Message}" );
}
}
// ---------------------------------------------------------------- economy
public int LevelOf( UpgradeKind kind ) => Data.UpgradeLevels[(int)kind];
// ---------------------------------------------------------------- core tree
public int CoreLevel( CoreNodeKind kind ) => Data.CoreNodes[(int)kind];
/// <summary>Cores committed to the tree. Spent Cores stay spent.</summary>
public int SpentCores
{
get
{
int total = 0;
foreach ( var def in CoreTree.All )
{
for ( int level = 0; level < CoreLevel( def.Kind ); level++ )
total += CoreTree.CostAt( def, level );
}
return total;
}
}
public int AvailableCores => Math.Max( 0, Data.Cores - SpentCores );
public int CoreCost( CoreNodeKind kind )
=> CoreTree.CostAt( CoreTree.Get( kind ), CoreLevel( kind ) );
public bool CanBuyCore( CoreNodeKind kind ) => AvailableCores >= CoreCost( kind );
public bool TryBuyCore( CoreNodeKind kind )
{
if ( !CanBuyCore( kind ) )
return false;
Data.CoreNodes[(int)kind]++;
Save();
return true;
}
/// <summary>Stage a fresh run starts on, courtesy of Foresight.</summary>
public int StartingStage => CoreLevel( CoreNodeKind.Foresight ) * Tuning.CoreForesightPerLevel;
/// <summary>Upgrade levels Anchored lets you keep through a Collapse.</summary>
public int AnchoredLevels => CoreLevel( CoreNodeKind.Anchored ) * Tuning.CoreAnchoredPerLevel;
public float DemolitionCooldown => MathF.Max(
Tuning.DemolitionCooldownFloor,
Tuning.DemolitionCooldown
- CoreLevel( CoreNodeKind.Cadence ) * Tuning.CoreCadenceCooldownPerLevel );
/// <summary>Global multiplier granted by prestige Cores.</summary>
public float CoreMultiplier => 1f + Data.Cores * Tuning.CorePowerPerCore;
// ---------------------------------------------------------------- marks
public int MarksEarned => Data.EarnedMarks?.Count ?? 0;
/// <summary>Quiet background multiplier from every Mark earned.</summary>
public float MarkMultiplier => 1f + MarksEarned * Tuning.MarkPowerEach;
public bool HasMark( string id ) => Data.EarnedMarks?.Contains( id ) ?? false;
/// <summary>Most recently earned Mark, for the HUD toast.</summary>
public MarkDef LastMark { get; private set; }
public GameTimeSince TimeSinceMark { get; private set; } = 99f;
/// <summary>
/// Re-evaluates every Mark. Cheap enough to run a few times a second, and doing it on a
/// timer rather than at each call site means a new Mark never needs new plumbing.
/// </summary>
public void CheckMarks()
{
Data.EarnedMarks ??= new List<string>();
foreach ( var mark in Marks.All )
{
if ( Data.EarnedMarks.Contains( mark.Id ) )
continue;
if ( !mark.Earned( this ) )
continue;
Data.EarnedMarks.Add( mark.Id );
LastMark = mark;
TimeSinceMark = 0;
// A Mark was the only reward in the game that arrived in complete silence: the banner
// slid in and if you were looking at the rock rather than the corner you missed it.
Audio.MarkEarned();
Log.Info( $"Mark earned: {mark.Name} ({MarksEarned} total, " +
$"x{MarkMultiplier:0.00})." );
Save();
}
}
// ---------------------------------------------------------------- slag
/// <summary>
/// Settles the real-time Slag clock. Called on load and on a timer.
///
/// It works off UTC ticks rather than game time on purpose: the whole value of this
/// currency is that it accrues while the game is closed. Partial progress is preserved by
/// only advancing the stored clock by the whole Slag actually granted.
/// </summary>
public void AccrueSlag()
{
var now = DateTime.UtcNow;
if ( Data.SlagClockTicks <= 0 )
{
Data.SlagClockTicks = now.Ticks;
return;
}
var last = new DateTime( Data.SlagClockTicks, DateTimeKind.Utc );
double minutes = (now - last).TotalMinutes;
if ( minutes < 0 )
{
// Clock moved backwards. Re-anchor rather than granting anything.
Data.SlagClockTicks = now.Ticks;
return;
}
int earned = (int)(minutes / Tuning.SlagMinutesEach);
if ( earned <= 0 )
return;
int before = Data.Slag;
Data.Slag = Math.Min( Tuning.SlagMax, Data.Slag + earned );
// Advance by exactly what was granted, so the remainder keeps ticking.
Data.SlagClockTicks = last.AddMinutes( earned * Tuning.SlagMinutesEach ).Ticks;
if ( Data.Slag != before )
Save();
}
/// <summary>Minutes until the next Slag, for the UI.</summary>
public double MinutesToNextSlag
{
get
{
if ( Data.Slag >= Tuning.SlagMax ) return 0;
if ( Data.SlagClockTicks <= 0 ) return Tuning.SlagMinutesEach;
var last = new DateTime( Data.SlagClockTicks, DateTimeKind.Utc );
double elapsed = (DateTime.UtcNow - last).TotalMinutes;
return Math.Max( 0, Tuning.SlagMinutesEach - elapsed );
}
}
public bool TrySpendSlag( int amount )
{
if ( Data.Slag < amount )
return false;
Data.Slag -= amount;
Save();
return true;
}
// ---------------------------------------------------------------- resonance
/// <summary>Current Resonance stacks. Decays to nothing the moment you stop.</summary>
public int Resonance { get; private set; }
private GameTimeSince timeSinceResonance = 99f;
/// <summary>Dust multiplier from the current chain.</summary>
public float ResonanceMultiplier => 1f + Resonance * Tuning.ResonancePerStack;
public float ResonanceRemaining
=> Resonance <= 0 ? 0f : MathF.Max( 0f, Tuning.ResonanceWindow - timeSinceResonance );
/// <summary>Called by anything that should feed a chain: charges, volatiles, catches.</summary>
public void AddResonance( int stacks = 1 )
{
Resonance = Math.Min( Tuning.ResonanceMaxStacks, Resonance + stacks );
timeSinceResonance = 0;
if ( Resonance > Data.BestResonance )
Data.BestResonance = Resonance;
}
/// <summary>
/// Drops the chain. Called when something hits you.
///
/// This is the whole cost of being hit, and it is deliberately the RIGHT kind of cost:
/// Resonance is earned by playing well over the last few seconds, so losing it costs you
/// momentum without touching dust, cubes or levels. Nothing banked is ever taken.
/// </summary>
public void BreakResonance()
{
Resonance = 0;
timeSinceResonance = 99f;
}
private void UpdateResonance()
{
if ( Resonance > 0 && timeSinceResonance > Tuning.ResonanceWindow )
Resonance = 0;
}
public double CostOf( UpgradeKind kind )
=> Upgrades.CostAt( Upgrades.Get( kind ), LevelOf( kind ) )
* HollowRuns.CostMultiplier( Data.Hollow );
// ---------------------------------------------------------------- hollow runs
public HollowKind Hollow => Data.Hollow;
public bool InHollowRun => Data.Hollow != HollowKind.None;
public bool DronesDisabled => HollowRuns.DronesDisabled( Data.Hollow );
public bool DemolitionDisabled => HollowRuns.DemolitionDisabled( Data.Hollow );
public bool HasClearedHollow( HollowKind kind )
=> Data.HollowCleared?.Contains( kind.ToString() ) ?? false;
/// <summary>
/// Starts a restricted run. This is a Collapse with a rule attached, so it costs you the
/// current run exactly like a normal prestige would.
/// </summary>
public void BeginHollowRun( HollowKind kind )
{
if ( kind == HollowKind.None || !CanCollapse )
return;
Data.Hollow = kind;
Collapse();
}
/// <summary>Called when the ladder is completed. Pays out if a restriction was active.</summary>
private void ResolveHollowRun()
{
if ( !InHollowRun )
return;
var kind = Data.Hollow;
Data.HollowCleared ??= new List<string>();
if ( !Data.HollowCleared.Contains( kind.ToString() ) )
Data.HollowCleared.Add( kind.ToString() );
Data.Cores += Tuning.HollowRunCoreReward;
Data.Hollow = HollowKind.None;
Log.Info( $"Hollow run '{kind}' complete. +{Tuning.HollowRunCoreReward} cores." );
Save();
}
/// <summary>No level ceiling by design: every upgrade stays purchasable forever.</summary>
public bool CanAfford( UpgradeKind kind ) => Data.Dust >= CostOf( kind );
public bool TryBuy( UpgradeKind kind )
{
if ( !CanAfford( kind ) )
return false;
Data.Dust -= CostOf( kind );
Data.UpgradeLevels[(int)kind]++;
Save();
return true;
}
/// <summary>
/// How many levels of this upgrade the current dust could buy.
///
/// Costs are geometric, so the total for k levels starting at L is
/// <c>base * g^L * (g^k - 1) / (g - 1)</c>. Solving that for k gives the count directly
/// rather than looping, which matters once a purchase can be thousands of levels deep.
/// </summary>
public int MaxAffordable( UpgradeKind kind )
{
var def = Upgrades.Get( kind );
double g = Upgrades.GrowthOf( kind );
double first = Upgrades.CostAt( def, LevelOf( kind ) );
if ( Data.Dust < first )
return 0;
double k = Math.Log( 1.0 + Data.Dust * (g - 1.0) / first ) / Math.Log( g );
return Math.Max( 1, (int)Math.Floor( k ) );
}
/// <summary>Buys as many levels as the current dust allows. Returns how many were bought.</summary>
public int BuyMax( UpgradeKind kind )
{
int count = MaxAffordable( kind );
if ( count <= 0 )
return 0;
var def = Upgrades.Get( kind );
int level = LevelOf( kind );
double g = Tuning.UpgradeCostGrowth;
// Exact geometric sum for the block, so rounding cannot hand out a free level.
double first = Upgrades.CostAt( def, level );
double total = first * (Math.Pow( g, count ) - 1.0) / (g - 1.0);
// Guard against floating point overshoot at very large counts.
while ( count > 0 && total > Data.Dust )
{
count--;
total = first * (Math.Pow( g, count ) - 1.0) / (g - 1.0);
}
if ( count <= 0 )
return 0;
Data.Dust -= total;
Data.UpgradeLevels[(int)kind] += count;
Save();
return count;
}
/// <summary>Called whenever this player removes cubes, from any source.</summary>
public void AwardCubes( int cubes, float dustMultiplier = 1f )
{
if ( cubes <= 0 ) return;
double dust = cubes
* Upgrades.DustYield( LevelOf( UpgradeKind.DustYield ) )
* CoreMultiplier
* MarkMultiplier
* ResonanceMultiplier
* (1f + CoreLevel( CoreNodeKind.Avarice ) * Tuning.CoreAvaricePerLevel)
* dustMultiplier;
// Leeches take their cut before you ever see it, and hold onto it until popped.
double siphoned = dust * Leech.TotalSiphon;
if ( siphoned > 0 )
{
Leech.Distribute( siphoned );
dust -= siphoned;
}
Data.Dust += dust;
Data.LifetimeCubes += cubes;
CubesThisMonolith += cubes;
if ( MonolithManager.Instance.IsValid() && MonolithManager.Instance.InMonolith )
MonolithContribution += cubes;
// Batched; flushed on a timer in OnUpdate.
pendingStatCubes += cubes;
}
/// <summary>
/// Awards prestige credit for felling the shared Monolith, but only to players who did a
/// real share of the work. Without a floor, anyone idling in the lobby at the moment it
/// falls would collect the same reward as the people who spent weeks on it.
/// </summary>
public bool TryAwardMonolithCredit( long monolithTotal )
{
if ( monolithTotal <= 0 )
return false;
double share = (double)MonolithContribution / monolithTotal;
if ( share < Tuning.MonolithCreditShare )
{
Log.Info( $"Monolith felled, but your {share:P2} share is under the " +
$"{Tuning.MonolithCreditShare:P0} needed for credit." );
return false;
}
// Felling a Monolith is worth SEVERAL prestige levels, scaled by how much of it was
// actually yours. It is 16.7M cubes and takes a lobby days: paying the same single level
// as one twenty minute solo ladder made the shared destination the worst way to earn
// prestige, which defeats the point of having it.
int levels = Tuning.MonolithPrestigeBase
+ (int)Math.Round( Math.Clamp( share, 0.0, 1.0 ) * Tuning.MonolithPrestigeShareBonus );
Data.Collapses += levels;
Data.MonolithCredits++;
// CORES, not just prestige levels.
//
// This was the bug behind "I was expecting a core for clearing a monolith and didn't get
// one". Collapses is a COUNT; Cores are the currency the tree actually spends. Awarding
// levels without Cores meant felling the shared Monolith changed a number on a stats
// line and gave you nothing to spend, which is the least satisfying possible outcome for
// the longest activity in the game.
//
// One Core per prestige level, matching the solo ladder's rate of one per StagesPerCore.
Data.Cores += levels;
for ( int i = 0; i < levels; i++ )
MonolithStats.AddCollapse();
MonolithStats.ReportCollapses( Data.Collapses );
Save();
Log.Info( $"Monolith felled with a {share:P1} share. {levels} prestige levels and " +
$"{levels} cores awarded (now {Data.Collapses} collapses, {Data.Cores} cores)." );
return true;
}
// ---------------------------------------------------------------- prestige
/// <summary>Records that the welcome pages have been read, and persists it immediately.</summary>
public void MarkTutorialSeen()
{
if ( Data.SeenTutorial )
return;
Data.SeenTutorial = true;
Save();
}
/// <summary>
/// True for a save that has never been played. Drives whether the start screen offers
/// "begin" or "continue", and whether the Monolith is offered at all.
/// </summary>
public bool IsFreshSave => Data.LifetimeCubes <= 0 && Data.Collapses <= 0;
/// <summary>Collapse unlocks by reaching the end of the solo ladder, not by raw cube count.</summary>
public bool CanCollapse => Data.HighestStage >= Tuning.PrestigeStageRequirement;
/// <summary>Cores this player would gain by collapsing right now.</summary>
public int PendingCores => Math.Max( 0, Data.HighestStage / Tuning.StagesPerCore );
/// <summary>Stages still to climb before Collapse becomes available.</summary>
public int StagesToCollapse => Math.Max( 0, Tuning.PrestigeStageRequirement - Data.HighestStage );
// ---------------------------------------------------------------- run timer
/// <summary>Seconds since the current ladder run began, for the speedrun board and HUD.</summary>
public float RunSeconds => GameTime.Now - runStartedAt;
private float runStartedAt;
public void StartRunTimer() => runStartedAt = GameTime.Now;
/// <summary>Cubes this player has taken off the CURRENT shared Monolith.</summary>
public long MonolithContribution { get; private set; }
public void ResetMonolithContribution() => MonolithContribution = 0;
/// <summary>Called by the Miner whenever a new stage condenses, to track the high-water mark.</summary>
/// <param name="stageIndex">
/// ZERO-BASED stage index. This method adds one. The ladder-complete caller must therefore
/// pass <c>PrestigeStageRequirement - 1</c>, not the requirement itself: passing the count
/// recorded a finished ladder as stage 101 and would have put 101s on a public board.
/// </param>
public void ReportStage( int stageIndex )
{
int oneBased = stageIndex + 1;
// THE SPEEDRUN IS CHECKED FIRST, above the progression guard below, because the two are
// different questions and conflating them broke the board.
//
// The guard exists to avoid rewriting HighestStage for a stage you have already passed.
// Submitting a RUN TIME is not that: a second, faster completion is exactly what a
// speedrun leaderboard is for. With the check underneath the guard, a player who finished
// the ladder once and then went to the Monolith instead of collapsing kept HighestStage at
// 100 forever, so every later run returned early and no improvement was ever submitted.
if ( oneBased >= Tuning.PrestigeStageRequirement )
SubmitLadderRun();
if ( oneBased <= Data.HighestStage )
return;
Data.HighestStage = oneBased;
if ( oneBased > Data.BestStageEver )
{
Data.BestStageEver = oneBased;
MonolithStats.ReportBestStage( oneBased );
}
Save();
}
/// <summary>Records a finished ladder and submits the time if it beats the personal best.</summary>
private void SubmitLadderRun()
{
ResolveHollowRun();
float elapsed = RunSeconds;
// Guarded against a zero or negative reading. The run clock is pausable now, and a
// nonsense time on a MIN-aggregated board is permanent: it would sit at the top of the
// world rankings and there is no way to remove it afterwards.
if ( elapsed <= 1f )
{
Log.Warning( $"[stats] ladder time {elapsed:0.00}s looks wrong. Not submitted." );
return;
}
if ( Data.BestLadderSeconds > 0f && elapsed >= Data.BestLadderSeconds )
return;
Data.BestLadderSeconds = elapsed;
MonolithStats.ReportLadderTime( elapsed );
Log.Info( $"Ladder complete in {Num.Duration( elapsed )}. New personal best." );
Save();
}
public void Collapse()
{
if ( !CanCollapse ) return;
// Timed end to end. Collapse has hung the game twice with nothing but a stall warning in
// the log, and the candidates (save, stat upload, world teardown, full remesh) are all
// plausible and all invisible. Breaking the timing out by phase turns the next occurrence
// into a fact instead of another hypothesis.
var clock = System.Diagnostics.Stopwatch.StartNew();
double afterStats = 0, afterSave = 0;
Data.Cores += PendingCores;
Data.Dust = 0;
// Anchored Upgrades: keep a floor of levels rather than wiping to zero. This is what
// makes later runs structurally different rather than just faster.
int anchored = AnchoredLevels;
for ( int i = 0; i < Data.UpgradeLevels.Length; i++ )
Data.UpgradeLevels[i] = Math.Min( Data.UpgradeLevels[i], anchored );
// Foresight: skip the opening stages entirely.
Data.HighestStage = Math.Max( 1, StartingStage + 1 );
// Flush cubes before the collapse so the two boards stay consistent with each other.
if ( pendingStatCubes >= 1 )
{
MonolithStats.AddCubes( pendingStatCubes );
pendingStatCubes = 0;
}
Data.Collapses++;
MonolithStats.AddCollapse();
MonolithStats.ReportCollapses( Data.Collapses );
afterStats = clock.Elapsed.TotalMilliseconds;
Save();
afterSave = clock.Elapsed.TotalMilliseconds;
Log.Info( $"Collapsed #{Data.Collapses}. Now at {Data.Cores} cores (x{CoreMultiplier:0.00})." );
// Actually send the player back to the start. Without this the save resets but the
// world stays on whatever stage you were on, which reads as the button doing nothing.
MonolithManager.Instance?.RestartLadder();
double total = clock.Elapsed.TotalMilliseconds;
// Only shouts when it actually hurt, so it stays silent in normal play.
if ( total > 50 )
{
Log.Warning( $"[collapse] SLOW: {total:0}ms total " +
$"(stats {afterStats:0}ms, save {afterSave - afterStats:0}ms, " +
$"world rebuild {total - afterSave:0}ms)" );
}
}
// ---------------------------------------------------------------- derived stats
// Core Tree nodes multiply into the derived stats here, which is the only place they need
// to be applied: everything downstream reads these.
public float DrillSpeed => Upgrades.DrillSpeed( LevelOf( UpgradeKind.DrillSpeed ) )
* CoreMultiplier
* (1f + CoreLevel( CoreNodeKind.Cadence ) * Tuning.CoreCadencePerLevel);
public float BlastRadius => Upgrades.BlastRadius( LevelOf( UpgradeKind.BlastRadius ) )
* CoreMultiplier
* (1f + CoreLevel( CoreNodeKind.Deepening ) * Tuning.CoreDeepeningPerLevel);
/// <summary>Density of volatile blocks. Sympathy adds flat density on top.</summary>
public float VolatileChance => MathF.Min( Tuning.VolatileChanceMax,
Upgrades.VolatileChance( LevelOf( UpgradeKind.ChargeChance ) )
+ CoreLevel( CoreNodeKind.Sympathy ) * Tuning.CoreSympathyPerLevel );
public float VolatileRadius => Upgrades.VolatileRadius( LevelOf( UpgradeKind.ChargeRadius ) )
* CoreMultiplier
* (1f + CoreLevel( CoreNodeKind.Deepening ) * Tuning.CoreDeepeningPerLevel);
public int DroneCount => LevelOf( UpgradeKind.Drones );
/// <summary>Shots per second for ONE drone. Cadence lifts every drone you own.</summary>
public float DroneFireRate => Upgrades.DroneFireRate( LevelOf( UpgradeKind.DroneRate ) )
* (1f + CoreLevel( CoreNodeKind.Cadence ) * Tuning.CoreCadencePerLevel);
public int DemolitionLevel => LevelOf( UpgradeKind.Demolition );
/// <summary>
/// Projectiles fired per shot. This is a **permanent prestige reward**, not a purchase:
/// one at the start, two after your first Collapse, three after the second, and so on.
/// It is the clearest possible answer to "what did prestige actually get me", and it is
/// what lets a prestiged player make a dent in the shared Monolith.
/// </summary>
/// <remarks>
/// **BOUNDED, and it has to be.** This was `1 + Data.Collapses`, which assumed Collapses
/// rises by one per prestige. Then I made felling the shared Monolith award TWENTY collapses,
/// and the assumption silently broke: at 204 collapses this returned 205 projectiles per
/// trigger pull. Two hundred and five GameObjects, and after the visible cap folded the
/// surplus into radius, a blast sphere over three times wider removing millions of voxels a
/// shot. That is the stall.
///
/// **When a derived value assumes how its input grows, changing the input's growth rate is a
/// breaking change to the derived value.** Nothing about this line was wrong when written.
/// </remarks>
public int ProjectileCount => 1 + Math.Min( Data.Collapses, Tuning.MaxProjectileCount - 1 );
/// <summary>Direct dust award, used by things that are not cubes (interceptor kills).</summary>
public void AwardDust( double amount )
{
if ( amount <= 0 ) return;
Data.Dust += amount
* Upgrades.DustYield( LevelOf( UpgradeKind.DustYield ) )
* CoreMultiplier;
}
}