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;
	}
}