Jump/JumpScores.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

/// <summary>
/// 成绩记录:本地排行榜(FileSystem.Data 持久化,始终可用)
/// + 官方云端排行榜(Sandbox.Services 的 Stats/Leaderboards,尽力而为——
///   包未发布/离线时后端没有身份,拿不到数据就静默降级为纯本地)。
/// </summary>
public static class JumpScores
{
	const string SaveFile = "jump_scores.json";
	const int MaxKeep = 30;

	/// <summary> 一条本地成绩 </summary>
	public class ScoreEntry
	{
		public int Score { get; set; }
		public int Difficulty { get; set; }   // 0 简单 1 容易 2 困难
		public string Date { get; set; } = "";
	}

	static List<ScoreEntry> _scores = new();
	static bool _loaded;

	/// <summary>
	/// 每个难度对应的后端指标。
	///
	/// 简单模式沿用早期就存在、已经有全部玩家历史成绩的 <c>best_score</c>;
	/// 普通和困难各写自己的新指标(那两个榜只有玩过新版本的玩家,正常)。
	/// 读取时按当前难度取对应榜,所以简单难度看到的是那 15 个老玩家,普通/困难是各自的榜。
	/// </summary>
	public static readonly (JumpDifficulty Difficulty, string Stat, string Name)[] Boards =
	{
		( JumpDifficulty.Easy,   "best_score",        "Easy" ),
		( JumpDifficulty.Normal, "best_score_normal", "Normal" ),
		( JumpDifficulty.Hard,   "best_score_hard",   "Hard" ),
	};

	/// <summary> 当前难度的指标名与显示名 </summary>
	public static (string Stat, string Name) BoardFor( JumpDifficulty difficulty )
	{
		foreach ( var b in Boards )
		{
			if ( b.Difficulty == difficulty ) return (b.Stat, b.Name);
		}

		return (Boards[0].Stat, Boards[0].Name);
	}

	/// <summary> 提交时随成绩附带的数据(难度以这个字符串落库) </summary>
	public static Dictionary<string, object> StatData( JumpDifficulty difficulty )
		=> new() { { "difficulty", difficulty.ToString() } };

	/// <summary>
	/// 云端排行榜,按难度分别缓存。
	///
	/// 以前是单个不分难度的 List:切难度后上一次的结果还在,标题却写当前难度,
	/// 于是"Normal · Global Best"底下显示的是 Hard 的榜;拉取失败时更是继续显示别的难度。
	/// 缓存放在这里按难度取,渲染方拿到的必然是当前难度的数据。
	/// </summary>
	static readonly Dictionary<JumpDifficulty, List<(long Rank, string Name, int Score)>> _cloud = new();

	/// <summary> 指定难度的云端榜(无数据 = 还没拉到或不适用) </summary>
	public static IReadOnlyList<(long Rank, string Name, int Score)> Cloud( JumpDifficulty difficulty )
		=> _cloud.TryGetValue( difficulty, out var list ) ? list : Array.Empty<(long, string, int)>();

	/// <summary> 该难度是否成功拉取过云端榜 </summary>
	public static bool CloudLoaded( JumpDifficulty difficulty ) => _cloud.ContainsKey( difficulty );

	static JumpScores()
	{
		Load();
	}

	static void Load()
	{
		if ( _loaded ) return;

		try
		{
			// 编辑器早期(编辑模式/场景加载中)FileSystem.Data 还没就绪会抛 NRE,
			// 此时不能把 _loaded 置位,留给游戏运行期间的下次调用重试
			if ( FileSystem.Data is null ) return;

			if ( FileSystem.Data.FileExists( SaveFile ) )
			{
				_scores = Json.Deserialize<List<ScoreEntry>>( FileSystem.Data.ReadAllText( SaveFile ) ) ?? new List<ScoreEntry>();
			}
			_loaded = true;
		}
		catch ( Exception e )
		{
			Log.Warning( $"JumpScores load: {e.Message}" );
		}
	}

	/// <summary> 指定难度的历史最高分(_scores 整体降序,所以首个匹配就是最大值) </summary>
	public static int Best( JumpDifficulty difficulty )
	{
		Load();
		return _scores.FirstOrDefault( x => x.Difficulty == (int)difficulty )?.Score ?? 0;
	}

	/// <summary> 指定难度的本地前 N 名(降序) </summary>
	public static List<ScoreEntry> Top( int count, JumpDifficulty difficulty )
	{
		Load();
		return _scores.Where( x => x.Difficulty == (int)difficulty ).Take( count ).ToList();
	}

	/// <summary>
	/// 局终记录成绩(云端上报顺带触发)。
	/// 截断按难度分别做:全局 Take(MaxKeep) 会让高分难度把别的难度全挤出去,
	/// 低分难度的本地榜最后一条都不剩。
	/// </summary>
	public static void Record( int score, JumpDifficulty difficulty )
	{
		if ( score <= 0 ) return;
		Load();

		_scores.Add( new ScoreEntry { Score = score, Difficulty = (int)difficulty, Date = DateTime.Now.ToString( "MM-dd" ) } );
		_scores = Trim( _scores );

		Save();

		SubmitCloud( score, difficulty );
	}

	/// <summary>
	/// 排行保留规则:每个难度各留 MaxKeep 条,整体再按分数降序。
	///
	/// 不能对全表做 OrderByDescending().Take(MaxKeep):那 30 个名额是所有难度共用的,
	/// 一个难度分高就把别的难度全挤出去(实测存下来 30 条里 28 条 Easy、2 条 Hard、
	/// Normal 一条不剩,Normal 的本地榜整个是空的)。
	/// 整体降序是 Best/Top 读取时"第一个匹配即最大值"的前提。
	/// </summary>
	public static List<ScoreEntry> Trim( List<ScoreEntry> scores )
	{
		return scores
			.GroupBy( x => x.Difficulty )
			.SelectMany( g => g.OrderByDescending( x => x.Score ).Take( MaxKeep ) )
			.OrderByDescending( x => x.Score )
			.ToList();
	}

	static void Save()
	{
		try
		{
			FileSystem.Data.WriteAllText( SaveFile, Json.Serialize( _scores ) );
		}
		catch ( Exception e )
		{
			Log.Warning( $"JumpScores save: {e.Message}" );
		}
	}

	// ---- 云端(官方 Services,尽力而为)----

	/// <summary>
	/// 上报成绩到官方云端(打包发布后生效;本地开发包可能没有后端身份)。
	/// 按难度写各自的指标:简单 → best_score(老榜),普通/困难 → 各自的新榜。
	/// </summary>
	public static void SubmitCloud( int score, JumpDifficulty difficulty )
	{
		try
		{
			var ( stat, _ ) = BoardFor( difficulty );

			Sandbox.Services.Stats.SetValue( stat, score, StatData( difficulty ) );
			_ = FlushAndReloadCloud( difficulty );
		}
		catch ( Exception e )
		{
			Log.Warning( $"JumpScores cloud submit: {e.Message}" );
		}
	}

	/// <summary> 拉取指定难度的云端排行榜(聚合 = 每人历史最高分,降序) </summary>
	public static async Task RefreshCloud( JumpDifficulty difficulty )
	{
		try
		{
			var ( stat, _ ) = BoardFor( difficulty );

			var board = Sandbox.Services.Leaderboards.GetFromStat( stat );
			board.SetAggregationMax();
			board.MaxEntries = 10;

			await board.Refresh();

			// 拉成功才覆盖该难度的缓存:失败时保留上一次的结果,重试成功后自然被替换。
			// 注意别清空缓存——清掉就把"上次看到的榜"也一起丢了,界面直接变占位
			_cloud[difficulty] = board.Entries
				.Select( e => ( e.Rank, e.DisplayName ?? "???", (int)e.Value ) )
				.ToList();
		}
		catch ( Exception e )
		{
			// 本地包 / 离线:云端不可用,降级为纯本地排行,但要说一声是在哪一步挂的
			Log.Warning( $"JumpScores cloud refresh ({difficulty}): {e.Message}" );
		}
	}

	/// <summary>
	/// 上报后把成绩推给后端并重新拉榜。
	///
	/// 必须用 FlushAndWaitAsync:FlushAsync 是"发出即忘"(引擎注释:Don't wait for confirmation
	/// of ingestion),紧接着查榜时这一局的成绩通常还没入库,结算画面就永远看不到自己刚打的分数。
	/// </summary>
	static async Task FlushAndReloadCloud( JumpDifficulty difficulty )
	{
		try
		{
			await Sandbox.Services.Stats.FlushAndWaitAsync();
			await RefreshCloud( difficulty );
		}
		catch ( Exception e )
		{
			Log.Warning( $"JumpScores cloud flush ({difficulty}): {e.Message}" );
		}
	}
}