Game/Idle/IdleContracts.cs
using System;
using System.Collections.Generic;
using System.Linq;
/// <summary>
/// One daily job. Progress is measured against a snapshot taken when the
/// contract was issued, so a lifetime counter can back a "do it today" goal
/// without needing its own per-day tally in the save file.
/// </summary>
public class IdleContract
{
public string Id { get; set; }
public string Name { get; set; }
public string Icon { get; set; }
/// <summary>
/// The target for a contract with no <see cref="Scale"/>. Where a Scale
/// exists it is handed this as its starting point and may use it as a
/// ceiling, a floor, or ignore it entirely — see each entry in the pool.
/// </summary>
public double BaseTarget { get; set; }
/// <summary>Fragments paid on claim, before the all-three bonus.</summary>
public int RewardFrags { get; set; }
/// <summary>Lifetime counter this contract watches.</summary>
public Func<IdleSave, double> Counter { get; set; }
/// <summary>
/// Optional rewrite of the target against the player's own progression,
/// given the save and the rolled base.
///
/// A flat number cannot serve both ends of an idle curve: "bank 250K Cores"
/// is four hours on day one and four seconds after ten Meltdowns. Anything
/// measured in currency or chain length therefore derives its target from
/// what this reactor actually produces, and only the jobs that are the same
/// work forever — catch four golden chests, fire thirty Overloads — stay
/// flat.
/// </summary>
public Func<IdleSave, double, double> Scale { get; set; }
/// <summary>Compact rendering, so "bank 4.2M Cores" does not print 14 digits.</summary>
public bool BigNumbers { get; set; }
/// <summary>Wording for the goal line, given the rolled target.</summary>
public Func<double, string> Desc { get; set; }
}
/// <summary>
/// Daily Contracts — three jobs, rerolled at UTC midnight, paying Fragments.
///
/// The reactor already rewards leaving it alone; nothing in it rewarded coming
/// back. Contracts are the counterweight: a short, visible, finishable list
/// that is only worth anything if you actually open the game today.
///
/// Everything is derived from the date, so the set survives a restart without
/// the definitions being written to disk, and every player gets the same three
/// jobs on the same day — which gives a small returning group something to
/// compare notes on.
/// </summary>
public static class IdleContracts
{
public const int DailyCount = 3;
public static readonly List<IdleContract> Pool = new()
{
new IdleContract {
Id = "detonations", Name = "Run the Cycle", Icon = "⚙️",
BaseTarget = 400, RewardFrags = 25,
Counter = s => s.TotalDetonations,
// Twenty minutes of cycles at whatever Fuse Timing this reactor runs
Scale = ( s, t ) => Math.Clamp( 1200.0 / CycleSeconds( s ), 80, 900 ),
Desc = t => $"Fire {(long)t} detonation cycles",
},
new IdleContract {
Id = "chests", Name = "Demolition Quota", Icon = "💥",
BaseTarget = 4_000, RewardFrags = 30, BigNumbers = true,
Counter = s => s.TotalChestsBlown,
// Cycles in twenty minutes, times a conservative read of how much
// this board actually clears per detonation
Scale = ( s, t ) => Math.Clamp(
(1200.0 / CycleSeconds( s )) * Math.Max( 3, s.BestChain * 0.45 ),
400, 250_000 ),
Desc = t => $"Destroy {IdleFormat.Short( t )} chests",
},
new IdleContract {
Id = "cores", Name = "Production Target", Icon = "⚛️",
BaseTarget = 5_000, RewardFrags = 35, BigNumbers = true,
Counter = s => s.AllTimeCores,
// Ten minutes of measured output, floored so a reactor with no
// measurement yet still gets a number it can reach in a sitting
Scale = ( s, t ) => Math.Max( t, Rate( s ) * 600.0 ),
Desc = t => $"Bank {IdleFormat.Short( t )} Cores",
},
new IdleContract {
Id = "golden", Name = "Quick Hands", Icon = "🌟",
BaseTarget = 4, RewardFrags = 30,
Counter = s => s.GoldenGrabbed,
Desc = t => $"Catch {(long)t} Golden Chests",
},
new IdleContract {
Id = "overloads", Name = "Hands On", Icon = "⚡",
BaseTarget = 30, RewardFrags = 25,
Counter = s => s.TotalOverloads,
Desc = t => $"Fire {(long)t} manual Overloads",
},
new IdleContract {
Id = "surges", Name = "Peak Load", Icon = "🔥",
BaseTarget = 3, RewardFrags = 35,
Counter = s => s.TotalSurges,
Desc = t => $"Unleash {(long)t} Surges",
},
new IdleContract {
Id = "upgrades", Name = "Requisition", Icon = "🛒",
BaseTarget = 40, RewardFrags = 25,
Counter = s => s.TotalUpgradesBought,
Desc = t => $"Buy {(long)t} upgrade levels",
},
new IdleContract {
Id = "chain", Name = "One Big One", Icon = "⛓️",
BaseTarget = 60, RewardFrags = 40,
Counter = s => s.BestChainToday,
// A stretch against this player's own record, never against an
// absolute number a five-by-five board physically cannot produce
Scale = ( s, t ) => Math.Clamp( s.BestChain * 0.8, 8, t ),
Desc = t => $"Clear {(long)t} chests in one detonation",
},
new IdleContract {
Id = "fragments", Name = "Condenser Run", Icon = "💠",
BaseTarget = 60, RewardFrags = 30,
Counter = s => s.TodayReactorFragments,
// Run the real condenser curve over half an hour of output, so the
// target is whatever this reactor would genuinely condense
Scale = ( s, t ) => Math.Clamp( FragmentsIn( s, Rate( s ) * 1800.0 ), 5, t ),
Desc = t => $"Condense {(long)t} Fragments today",
},
new IdleContract {
Id = "runtime", Name = "On Shift", Icon = "🕒",
BaseTarget = 900, RewardFrags = 25,
Counter = s => s.SecondsRun,
Desc = t => $"Watch the reactor for {IdleFormat.Duration( t )}",
},
};
// ── Progression yardsticks used by the Scale delegates ────────────────
/// <summary>Best available read of this reactor's Cores per second.</summary>
static double Rate( IdleSave s )
=> Math.Max( 1.0, Math.Max( s.MeasuredCps, s.BestCps * 0.5 ) );
static double CycleSeconds( IdleSave s )
=> Math.Max( 0.05, IdleBalance.CycleInterval( s.Level( IdleUpgradeId.CycleSpeed ) ) );
/// <summary>
/// How many Fragments a given pile of Cores would condense, walking the
/// same escalating price the reactor charges. Reusing the real curve is
/// what stops the Fragment contract drifting away from the condenser the
/// moment either one is retuned.
/// </summary>
static int FragmentsIn( IdleSave s, double cores )
{
int coil = s.Level( IdleUpgradeId.FragmentYield );
int perk = s.PerkLevel( IdlePerkId.FragmentCondenser );
int n = 0;
while ( n < 300 )
{
double cost = IdleBalance.FragmentCost( coil, perk, n );
if ( cores < cost ) break;
cores -= cost;
n++;
}
return n;
}
public static IdleContract Get( string id ) => Pool.FirstOrDefault( c => c.Id == id );
public static string TodayKey => DateTime.UtcNow.ToString( "yyyy-MM-dd" );
/// <summary>
/// Rerolls the set if the day has turned over. Safe to call every frame.
/// Returns true when a fresh set was just issued.
/// </summary>
public static bool EnsureToday( IdleSave s )
{
if ( s == null ) return false;
string today = TodayKey;
if ( s.ContractDate == today
&& s.ContractIds != null && s.ContractIds.Count == DailyCount
&& s.ContractTargets != null && s.ContractTargets.Count == DailyCount
&& s.ContractBaseline != null && s.ContractBaseline.Count == DailyCount
&& s.ContractClaimed != null && s.ContractClaimed.Count == DailyCount )
return false;
Issue( s, today );
return true;
}
static void Issue( IdleSave s, string today )
{
// Date-seeded so every player on the same UTC day gets the same three
// jobs, and a mid-session restart cannot reroll into an easier set.
// StringComparer is used rather than string.GetHashCode() because that
// one is randomised per process in .NET — the same date would otherwise
// produce a different set on every launch.
int seed = StringComparer.Ordinal.GetHashCode( today ) ^ 0x5EED;
var rng = new Random( seed );
var picked = Pool.OrderBy( _ => rng.Next() ).Take( DailyCount ).ToList();
s.ContractDate = today;
s.ContractIds = picked.Select( c => c.Id ).ToList();
s.ContractTargets = picked.Select( c => RollTarget( c, s, rng ) ).ToList();
s.ContractBaseline = picked.Select( c => c.Counter( s ) ).ToList();
s.ContractClaimed = picked.Select( _ => false ).ToList();
// A contract that watches a per-day counter already reads zero this
// morning; baselining it again would double the real requirement.
for ( int i = 0; i < picked.Count; i++ )
if ( IsDailyCounter( picked[i].Id ) ) s.ContractBaseline[i] = 0;
s.BestChainToday = 0;
}
static bool IsDailyCounter( string id ) => id == "fragments" || id == "chain";
static double RollTarget( IdleContract c, IdleSave s, Random rng )
{
// Scale first, wobble second: the wobble has to be a percentage of the
// number the player will actually be asked for, not of the base.
double target = c.Scale != null ? c.Scale( s, c.BaseTarget ) : c.BaseTarget;
// So two consecutive days of the same job do not read as a copy-paste
target *= 0.85 + rng.NextDouble() * 0.30;
return Math.Max( 1, Math.Round( target ) );
}
// ── Per-contract readout ──────────────────────────────────────────────
public static IdleContract At( IdleSave s, int slot )
{
if ( s == null || s.ContractIds == null ) return null;
if ( slot < 0 || slot >= s.ContractIds.Count ) return null;
return Get( s.ContractIds[slot] );
}
public static double Progress( IdleSave s, int slot )
{
var c = At( s, slot );
if ( c == null ) return 0;
double baseline = s.ContractBaseline != null && slot < s.ContractBaseline.Count
? s.ContractBaseline[slot] : 0;
return Math.Max( 0, c.Counter( s ) - baseline );
}
public static double Target( IdleSave s, int slot )
{
if ( s == null || s.ContractTargets == null ) return 0;
if ( slot < 0 || slot >= s.ContractTargets.Count ) return 0;
return s.ContractTargets[slot];
}
public static bool IsComplete( IdleSave s, int slot )
{
double t = Target( s, slot );
return t > 0 && Progress( s, slot ) >= t;
}
public static bool IsClaimed( IdleSave s, int slot )
{
if ( s == null || s.ContractClaimed == null ) return false;
if ( slot < 0 || slot >= s.ContractClaimed.Count ) return false;
return s.ContractClaimed[slot];
}
public static bool CanClaim( IdleSave s, int slot )
=> IsComplete( s, slot ) && !IsClaimed( s, slot );
public static float Fraction( IdleSave s, int slot )
{
double t = Target( s, slot );
if ( t <= 0 ) return 0f;
return (float)Math.Clamp( Progress( s, slot ) / t, 0.0, 1.0 );
}
public static int ClaimedCount( IdleSave s )
{
if ( s == null || s.ContractClaimed == null ) return 0;
return s.ContractClaimed.Count( b => b );
}
public static bool AllClaimed( IdleSave s )
=> s != null && s.ContractClaimed != null
&& s.ContractClaimed.Count == DailyCount
&& s.ContractClaimed.All( b => b );
/// <summary>
/// Fragments a slot pays. The claim that completes the set is worth double —
/// without that the third contract is the one everybody skips.
/// </summary>
public static int Reward( IdleSave s, int slot )
{
var c = At( s, slot );
if ( c == null ) return 0;
int frags = c.RewardFrags;
if ( ClaimedCount( s ) == DailyCount - 1 ) frags *= 2;
return frags;
}
/// <summary>Seconds until the set rerolls, for the countdown line.</summary>
public static double SecondsUntilReset()
{
var now = DateTime.UtcNow;
return (now.Date.AddDays( 1 ) - now).TotalSeconds;
}
/// <summary>True when at least one contract is finished and unclaimed.</summary>
public static bool AnyClaimable( IdleSave s )
{
for ( int i = 0; i < DailyCount; i++ )
if ( CanClaim( s, i ) ) return true;
return false;
}
}