Game/Menu/CommunityGoal.cs
// CommunityGoal.cs
using Sandbox;
using Sandbox.Services;
using System;
using System.Threading.Tasks;

/// <summary>
/// One source of truth for the shared Cores goal and the Cores board.
///
/// Both MainMenu and ReactorMenu render these panels. Holding the ladder and the
/// fetch in each of them separately is exactly how the evolution cinematic ended
/// up as two implementations that drifted until only one of them worked.
/// </summary>
public static class CommunityGoal
{
	public const string CoresStat = "chain_reaction_cores";
	public const string ChestStat = "chain_reaction_chests";

	// Idle economies grow exponentially, so the ladder does too — a fixed step
	// would be trivial by cycle three. 1 trillion Cores is the first gate: at the
	// ~241K/sec a developed reactor puts out, that is roughly a week of combined
	// output at the current population. Each cycle then costs 10x the last.
	const double BaseTarget    = 1_000_000_000_000d;
	const float  RefreshSeconds = 90f;

	public static double Total    { get; private set; }
	public static int    Players  { get; private set; }
	public static double Progress { get; private set; }
	public static double Target   { get; private set; } = BaseTarget;
	public static int    Fills    { get; private set; }
	public static bool   Loaded   { get; private set; }

	public static Leaderboards.Board2 Board { get; private set; }

	static bool  _busy;
	static float _nextRefresh;

	public static double TargetFor( int fill ) => BaseTarget * Math.Pow( 10, Math.Min( fill, 12 ) );

	public static float Pct => Target <= 0 ? 0f : MathF.Min( 1f, (float)(Progress / Target) ) * 100f;

	public static string PctLabel   => Pct.ToString( "F1" ) + "%";
	public static string GoalLabel  => IdleFormat.Short( Progress ) + " / " + IdleFormat.Short( Target ) + " Cores";
	public static string CycleLabel => "COMMUNITY ENERGY · GOAL " + (Fills + 1).ToString();

	public static string PlayersLabel
	{
		get
		{
			if ( Players <= 0 ) return "";
			return Players == 1 ? "1 reactor contributing" : Players.ToString( "N0" ) + " reactors contributing";
		}
	}

	public static bool HasEntries => Board?.Entries != null && Board.Entries.Length > 0;

	/// <summary>
	/// Resolves the community total into "which cycle, and how far into it".
	/// Derived purely from the total so every client agrees without shared state.
	/// </summary>
	static void Resolve()
	{
		int    fills    = 0;
		double consumed = 0;
		double target   = TargetFor( 0 );

		while ( Total - consumed >= target && fills < 32 )
		{
			consumed += target;
			fills++;
			target = TargetFor( fills );
		}

		Fills    = fills;
		Target   = target;
		Progress = Total - consumed;
	}

	/// <summary>
	/// Submits our own totals. Without this, a player whose reactor has not gone
	/// through End() since the stat shipped has no entry at all, so every Cores
	/// panel reads empty for them and looks broken rather than new.
	/// </summary>
	public static void Submit( PlayerSave save )
	{
		try
		{
			var idle = save?.Idle;
			if ( idle != null && idle.AllTimeCores > 0 )
				Stats.Increment( CoresStat, idle.AllTimeCores );

			int chests = save?.CommunalChestTotal() ?? 0;
			if ( chests > 0 )
				Stats.Increment( ChestStat, chests );
		}
		catch ( Exception e ) { Log.Warning( $"CommunityGoal submit: {e.Message}" ); }
	}

	/// <summary>
	/// Global Sum across EVERY player for the bar, plus the top-100 board for the
	/// cards. Throttled, so both menus can call it freely on entry.
	/// </summary>
	public static async Task Refresh( bool force = false )
	{
		if ( _busy ) return;
		if ( !force && Loaded && RealTime.Now < _nextRefresh ) return;

		_busy        = true;
		_nextRefresh = RealTime.Now + RefreshSeconds;

		try
		{
			// GlobalStats.Sum covers all players; a board would only ever give the
			// top 100, which is the wrong number for a bar everyone fills.
			//
			// Stats.Global and the single-argument GetFromStat both read the package
			// that is RUNNING — the same one Stats.Increment writes to. Passing an
			// ident here is what let the boards drift onto a different package and
			// report two players while every real score sat elsewhere.
			var g = Stats.Global;
			await g.Refresh();

			var stat = g.Get( CoresStat );
			Total   = stat.Sum;
			Players = (int)stat.Players;
			Resolve();

			var b = Leaderboards.GetFromStat( CoresStat );
			b.SetAggregationMax();
			b.SetSortDescending();
			b.MaxEntries = 100;
			b.Offset     = 0;   // Board2 is player-centric by default; start at the top
			await b.Refresh();
			Board = b;

			Loaded = true;

			Log.Info( $"CommunityGoal — sum: {Total}, players: {Players}, " +
					  $"board: {b.Entries?.Length ?? -1}/{b.TotalEntries}, cycle {Fills + 1}" );
		}
		catch ( Exception e ) { Log.Warning( $"CommunityGoal refresh: {e.Message}" ); }

		_busy = false;
	}
}