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