Game/GameAchievements.cs
using System;
using System.Collections.Generic;

/// <summary>
/// 成就对接(v0.7.8.14,共 25 条):成就本体在 sbox.game 游戏包页面 Services > Achievements 配置
/// (ident/标题/描述/图标/分值/解锁模式),代码只按 ident 触发 Manual 成就。
/// 引擎行为(源码 AchievementCollection.ManualUnlock):ident 未配置/已解锁/非 Manual 时
/// 静默跳过——先写代码后配页面,配好即生效;每次事件都调 Unlock,由引擎去重。
/// Unlock 只对**本机玩家**生效(专用服务器自动 no-op)——所有触发点必须先 GameSfx.IsMine
/// 判定"是不是本机赚到的",host 不替客户端解锁。
/// 累计型成就(吃食物总数等)走包页面 Stat 绑定(Stats.Increment 即可),不走这里。
/// 进程内计数器(击杀数/捡包数)重启会清零——一次性成就解锁由云端去重,只影响"晚几局解锁"。
/// </summary>
public static class GameAchievements
{
	// ---- 首批 5 条(v0.7.8.13)----
	public const string FirstBlood = "first_blood";     // 首次吃掉一名玩家
	public const string Godlike = "godlike";            // 单局五连杀
	public const string SpikeBlast = "spike_blast";     // 尖刺分身炸掉比自己大的球
	public const string TeamWinner = "team_winner";     // 团队赛所在队夺冠
	public const string Champion = "champion";          // 个人第一名结算

	// ---- 第二批 20 条(v0.7.8.14)----
	public const string DoubleKill = "double_kill";     // 单局双连杀
	public const string TripleKill = "triple_kill";     // 单局三连杀
	public const string Rampage = "rampage";            // 单局四连杀
	public const string BigPrey = "big_prey";           // 单口吞掉大球
	public const string Revenge = "revenge";            // 吃掉上次吃掉自己的玩家
	public const string Hunter10 = "hunter_10";         // 累计吃 10 名玩家
	public const string Hunter50 = "hunter_50";         // 累计吃 50 名玩家
	public const string HalfTon = "half_ton";           // 一条命总质量 500
	public const string OneTon = "one_ton";             // 一条命总质量 1000
	public const string TwoTon = "two_ton";             // 一条命总质量 2000
	public const string FiveTon = "five_ton";           // 一条命总质量 5000
	public const string FirstTool = "first_tool";       // 首次使用道具
	public const string FullBag = "full_bag";           // 两格背包同时有货
	public const string PowerHoarder = "power_hoarder"; // 累计捡 5 个道具入包
	public const string FirstFeed = "first_feed";       // 首次喂食触发道具
	public const string SpikeOp = "spike_op";           // 首次放出尖刺分身
	public const string OctoSplit = "octo_split";       // 同时拥有 8 颗身体
	public const string Untouchable = "untouchable";    // 一条命存活 5 分钟
	public const string FirstDeath = "first_death";     // 第一次被吃
	public const string FirstWin = "first_win";         // 首次获胜

	const float BigPreyMass = 800f;   // 单口吞入质量门槛(返还前的口径,对应 ~1000 质量的球)

	/// <summary> 一条命内的质量里程碑(与 MassSteps 一一对应解锁 HalfTon/OneTon/TwoTon/FiveTon) </summary>
	static readonly float[] MassSteps = { 500f, 1000f, 2000f, 5000f };

	static int _lifetimeKills;     // 进程内累计击杀
	static int _lifetimePickups;   // 进程内累计道具入包
	static long _lastKiller;       // 最近吃掉本机的玩家(复仇名单)

	/// <summary> 本机击杀(每次吃球都调,引擎对已解锁静默去重);
	/// combo = 本局连杀数(GameSfx 10s 窗口),massGained = 本口吞入质量 </summary>
	public static void LocalKill( int combo, float massGained, long eatenSteamId )
	{
		GameXp.OnLocalKill();   // 经验记账:本局击杀(v0.7.8.19)

		Unlock( FirstBlood );
		if ( combo >= 2 ) Unlock( DoubleKill );
		if ( combo >= 3 ) Unlock( TripleKill );
		if ( combo >= 4 ) Unlock( Rampage );
		if ( combo >= 5 ) Unlock( Godlike );

		if ( massGained >= BigPreyMass ) Unlock( BigPrey );
		if ( eatenSteamId != 0 && eatenSteamId == _lastKiller ) Unlock( Revenge );

		_lifetimeKills++;
		if ( _lifetimeKills >= 10 ) Unlock( Hunter10 );
		if ( _lifetimeKills >= 50 ) Unlock( Hunter50 );
	}

	/// <summary> 本机被吃:参与奖 + 记仇(之后吃掉他解锁 Revenge) </summary>
	public static void LocalDeath( long killerSteamId )
	{
		_lastKiller = killerSteamId;
		Unlock( FirstDeath );
	}

	/// <summary>
	/// 每帧体检(GameHud.OnUpdate 调,全端):一条命质量里程碑 / 8 颗身体 / 存活 5 分钟 / 背包双满。
	/// 门槛判定每帧重复触发由引擎去重,无需本地状态。
	/// </summary>
	public static void MatchTick( CircleroyaleGame game )
	{
		if ( !game.IsMatchStarted || MatchState.MatchOver ) return;

		var local = game.LocalBall;
		if ( !local.IsValid() || !local.Alive ) return;

		var mass = game.TotalMassFor( local );
		if ( mass >= MassSteps[0] ) Unlock( HalfTon );
		if ( mass >= MassSteps[1] ) Unlock( OneTon );
		if ( mass >= MassSteps[2] ) Unlock( TwoTon );
		if ( mass >= MassSteps[3] ) Unlock( FiveTon );

		int pieces = 0;
		foreach ( var c in game.Cells )
		{
			if ( c.PieceKind == CellPiece.Kind.SplitPiece && c.OwnerSteamId == local.OwnerSteamId ) pieces++;
		}
		if ( pieces >= 8 ) Unlock( OctoSplit );

		if ( local.LifeSeconds > 300f ) Unlock( Untouchable );

		// 背包双满(PowerA/B 是 [Sync],255=空)——同步到位才触发,无时序竞态
		if ( local.PowerA != 255 && local.PowerB != 255 ) Unlock( FullBag );
	}

	/// <summary> 本机按 Q/E 释放道具(UsePowerSlot 横幅处调):首次用道具 / 首次放尖刺 </summary>
	public static void PowerUsed( byte kind )
	{
		Unlock( FirstTool );
		if ( kind == (byte)PowerUpManager.Kind.Spike ) Unlock( SpikeOp );
	}

	/// <summary> 本机捡到道具入包(host 本机拾取与客户端广播落地两条路都会调):累计 5 个 </summary>
	public static void PowerStored()
	{
		if ( ++_lifetimePickups >= 5 ) Unlock( PowerHoarder );
	}

	/// <summary> 喂食触发道具(host 的 Feed 判定 + 客户端 PowerBanner 落地,两路都过 IsMine) </summary>
	public static void FeedTriggered( long feederSteamId )
	{
		if ( GameSfx.IsMine( feederSteamId ) )
			Unlock( FirstFeed );
	}

	/// <summary> 尖刺分身炸掉比主人更大的球(host 判定;owner=尖刺主人,广播到各端后本机判定) </summary>
	public static void SpikeMinionBlast( long ownerSteamId )
	{
		if ( GameSfx.IsMine( ownerSteamId ) )
			Unlock( SpikeBlast );
		if ( Networking.IsActive )
			NetworkManager.SpikeReward( ownerSteamId );
	}

	/// <summary> 尖刺奖励广播落地(客户端;host 已在 SpikeMinionBlast 判过,挡回声) </summary>
	public static void OnSpikeRewardRemote( long ownerSteamId )
	{
		if ( NetworkManager.IsAuthority ) return;
		if ( GameSfx.IsMine( ownerSteamId ) )
			Unlock( SpikeBlast );
	}

	/// <summary> 比赛结算(全端各自判定):团队赛=本队总质量第一(并列也算);普通赛=个人第一名。
	/// 返回是否获胜(GameXp 结算经验用) </summary>
	public static bool Settlement( ScoreWire[] standings, long localSteamId )
	{
		if ( standings is null || standings.Length == 0 ) return false;

		bool won = false;

		if ( MatchState.IsTeam )
		{
			var local = CircleroyaleGame.Current?.LocalBall;
			if ( local.IsValid() && local.TeamIndex >= 0 )
			{
				var totals = new Dictionary<int, float>();
				foreach ( var row in standings )
				{
					var t = totals.TryGetValue( row.Team, out var m ) ? m : 0f;
					totals[row.Team] = t + row.Mass;
				}

				var mine = totals.GetValueOrDefault( local.TeamIndex );
				var best = -1f;
				foreach ( var kv in totals )
				{
					if ( kv.Value > best ) best = kv.Value;
				}

				if ( mine >= best )
				{
					Unlock( TeamWinner );
					won = true;
				}
			}
		}
		else if ( standings[0].SteamId == localSteamId )
		{
			Unlock( Champion );
			won = true;
		}

		if ( won ) Unlock( FirstWin );
		return won;
	}

	static void Unlock( string ident ) => Sandbox.Services.Achievements.Unlock( ident );
}