Achievements.cs

Achievement system for the game, defines AchievementDef and a static Achievements manager. Tracks unlocked achievements, lifetime counters, queues and displays toast notifications, persists and loads achievement state, and exposes event hooks that unlock achievements based on game events and periodic polling.

File Access
namespace NoChillquarium;

public sealed class AchievementDef
{
	public string Id { get; init; }
	public string Title { get; init; }
	public string Description { get; init; }
	public bool Hidden { get; init; }
}

/// <summary>
/// Unlock tracking, toasts, and save. Only achievements we can fire today.
/// </summary>
public static class Achievements
{
	static readonly HashSet<string> _unlocked = new();
	static readonly Queue<string> _toastQueue = new();
	static string _toastId;
	static float _toastTimer;
	static int _version;

	// Lifetime counters (persisted lightly via save if we want later)
	public static int LifetimeExplodes { get; private set; }
	public static int TimesFed { get; private set; }
	public static int TimesMeth { get; private set; }
	public static int DecorPlaced { get; private set; }

	public static int Version => _version;
	public static int UnlockedCount => _unlocked.Count;
	public static int CatalogCount => Catalog.Count;
	public static bool ToastVisible => _toastTimer > 0f && !string.IsNullOrEmpty( _toastId );
	public static string ToastTitle => Find( _toastId )?.Title ?? "";
	public static string ToastBody => Find( _toastId )?.Description ?? "";

	/// <summary>Unlocked first, then open locks, hidden last. Stable title sort within groups.</summary>
	public static IEnumerable<AchievementDef> MenuOrder =>
		Catalog
			.OrderBy( a => IsUnlocked( a.Id ) ? 0 : (a.Hidden ? 2 : 1) )
			.ThenBy( a => a.Title, StringComparer.OrdinalIgnoreCase );

	public static IReadOnlyList<AchievementDef> Catalog { get; } = new[]
	{
		new AchievementDef { Id = "ach_first_splash", Title = "First Splash", Description = "Start with fish in the tank." },
		new AchievementDef { Id = "ach_fed_once", Title = "Papa Flakes", Description = "Feed the tank once." },
		new AchievementDef { Id = "ach_doge_100", Title = "Much Economy", Description = "Hold 100 Dogecoin at once." },
		new AchievementDef { Id = "ach_doge_10k", Title = "Such Wallet", Description = "Hold 10,000 Dogecoin." },
		new AchievementDef { Id = "ach_doge_69", Title = "Nice", Description = "Earn a +Ð69 payout." },
		new AchievementDef { Id = "ach_doge_420", Title = "Blaze It", Description = "Earn a +Ð420 payout." },
		new AchievementDef { Id = "ach_doge_1337", Title = "1337 Fisher", Description = "Earn a +Ð1337 payout." },
		new AchievementDef { Id = "ach_doge_666", Title = "Number of the Bass", Description = "Earn a +Ð666 payout.", Hidden = true },
		new AchievementDef { Id = "ach_doge_777", Title = "Lucky Fins", Description = "Earn a +Ð777 payout." },
		new AchievementDef { Id = "ach_doge_9001", Title = "It's Over 9000", Description = "Earn a +Ð9001 payout." },
		new AchievementDef { Id = "ach_doge_round", Title = "Even Stevens", Description = "Earn a clean round Ð100+ payout." },
		new AchievementDef { Id = "ach_tank_full", Title = "No Vacancy", Description = "Fill a tank to capacity." },
		new AchievementDef { Id = "ach_upgrade_1", Title = "Bigger Bowl", Description = "Buy a capacity upgrade." },
		new AchievementDef { Id = "ach_decor_1", Title = "Interior Tilapia", Description = "Place your first decoration." },
		new AchievementDef { Id = "ach_save_reload", Title = "Responsible Owner", Description = "Continue a saved game." },
		new AchievementDef { Id = "ach_salt_tank", Title = "Second Fridge", Description = "Buy a saltwater tank." },
		new AchievementDef { Id = "ach_wrong_water", Title = "Osmosis? Never Heard of Her", Description = "Put a fish in the wrong water." },
		new AchievementDef { Id = "ach_fish_died_salinity", Title = "Science Fair Fail", Description = "A fish dies of wrong salinity." },
		new AchievementDef { Id = "ach_meth_first", Title = "Breaking Bass", Description = "Feed meth for the first time.", Hidden = true },
		new AchievementDef { Id = "ach_meth_survivor", Title = "Pharmaceutical Husbandry", Description = "Keep wrong-water fish alive with meth." },
		new AchievementDef { Id = "ach_hooked", Title = "Client List", Description = "Have an addicted fish." },
		new AchievementDef { Id = "ach_withdrawal", Title = "Come Down", Description = "Survive a withdrawal crash." },
		new AchievementDef { Id = "ach_tape_one", Title = "Duct Tape Solutions", Description = "Tape an M80 to a fish." },
		new AchievementDef { Id = "ach_boom_one", Title = "Goodbye Forever", Description = "Detonate a fish with an M80." },
		new AchievementDef { Id = "ach_boom_five", Title = "Cluster Carp", Description = "Detonate 5+ fish at once." },
		new AchievementDef { Id = "ach_boom_20", Title = "Fish Paste", Description = "Explode 20 fish lifetime." },
		new AchievementDef { Id = "ach_chaos_high", Title = "No Chill", Description = "Reach high chaos." },
		new AchievementDef { Id = "ach_strobe_warn", Title = "I Read the Fine Print", Description = "Pass the strobe warning." },
		new AchievementDef { Id = "ach_food_chicken", Title = "Extra Crispy", Description = "Feed fried chicken." },
		new AchievementDef { Id = "ach_food_gummies", Title = "Munchies Depths", Description = "Feed weed gummies." },
		new AchievementDef { Id = "ach_food_cannibal", Title = "Friend for Dinner", Description = "Feed other fish." },
		new AchievementDef { Id = "ach_food_hand", Title = "High Five", Description = "Feed the dismembered hand." },
		new AchievementDef { Id = "ach_food_homeless", Title = "Nobody Will Miss Him", Description = "Feed the homeless man." },
		new AchievementDef { Id = "ach_train_1", Title = "Swim Lessons", Description = "Complete one training session." },
		new AchievementDef { Id = "ach_steroids_unlock", Title = "The 1-Hour Arms Race", Description = "Unlock steroids after ~1 hour." },
		new AchievementDef { Id = "ach_inject_1", Title = "Juice the Bass", Description = "Successfully inject steroids." },
		new AchievementDef { Id = "ach_jacked_team", Title = "Gym Membership", Description = "Jack 5 fish at once." },
		new AchievementDef { Id = "ach_inject_fail", Title = "Missed the Vein", Description = "Fail the syringe minigame.", Hidden = true },
		new AchievementDef { Id = "ach_hab_deluxe", Title = "Corner Office", Description = "Upgrade to deluxe glass." },
		new AchievementDef { Id = "ach_hab_bathtub", Title = "Rubber Duck Territory", Description = "Move fish into a bathtub." },
		new AchievementDef { Id = "ach_hab_kiddie", Title = "HOA Violation", Description = "Unlock the kiddie pool." },
		new AchievementDef { Id = "ach_hab_neighbor", Title = "Borrowed Permanently", Description = "Acquire the neighbor's pool." },
		new AchievementDef { Id = "ach_hab_lake", Title = "Real Estate Water", Description = "Expand into a lake." },
		new AchievementDef { Id = "ach_hab_gulf", Title = "Branded Brine", Description = "Claim the Gulf of America." },
		new AchievementDef { Id = "ach_hab_ocean", Title = "No More Glass", Description = "Release into the ocean." },
		new AchievementDef { Id = "ach_battle_1", Title = "First Blood in the Filter", Description = "Win an AI fish battle." },
		new AchievementDef { Id = "ach_battle_loss", Title = "Tank School Dropout", Description = "Lose an AI fish battle.", Hidden = true },
		new AchievementDef { Id = "ach_battle_steroid", Title = "Circuit Approved", Description = "Beat the Steroid Circuit." },
		new AchievementDef { Id = "ach_battle_abyss", Title = "Deep End Diploma", Description = "Beat Abyss Amateurs." },
		new AchievementDef { Id = "ach_battle_shark", Title = "Not Bait", Description = "Win the Shark Bait Invitational." },
		new AchievementDef { Id = "ach_battle_5", Title = "Local Champion", Description = "Win 5 AI battles." },
		new AchievementDef { Id = "ach_tank_prize_1", Title = "Deed of Shame", Description = "Win a new tank by fighting." },
		new AchievementDef { Id = "ach_tank_sewage", Title = "Flush With Victory", Description = "Win the Sewage Duct in Pipe Pugs." },
		new AchievementDef { Id = "ach_tank_neighbor_fight", Title = "HOA Transfer", Description = "Win the Neighbor's Pool in HOA Heat." },
		new AchievementDef { Id = "ach_tank_prize_3", Title = "Slumlord of the Deep", Description = "Win 3 prize tanks via fighting." },
		new AchievementDef { Id = "ach_shark_sight", Title = "Fin In The Water", Description = "Spot a cocaine shark in the ocean." },
		new AchievementDef { Id = "ach_shark_eaten", Title = "Chum Policy", Description = "Lose a fish to a cocaine shark.", Hidden = true },
		new AchievementDef { Id = "ach_shark_scare", Title = "Not Today", Description = "Scare a shark out of your ocean." },
		new AchievementDef { Id = "ach_shark_kill", Title = "Jaws Dropped", Description = "Defeat a cocaine shark in a fend." },
		new AchievementDef { Id = "ach_shark_kill_3", Title = "Ocean Security", Description = "Defeat 3 cocaine sharks lifetime." },
		new AchievementDef { Id = "ach_wmd", Title = "Overkill Package", Description = "Purchase a WMD.", Hidden = true },
		new AchievementDef { Id = "ach_whale", Title = "Big Fish Energy", Description = "Acquire a blue whale." },
		new AchievementDef { Id = "ach_end_good", Title = "Sanctuary Hours", Description = "Reach the chill / sanctuary ending." },
		new AchievementDef { Id = "ach_end_bad", Title = "Apocalypse Aquarium", Description = "Reach the no-chill ending." },
		new AchievementDef { Id = "ach_end_mixed", Title = "It's Complicated", Description = "Reach the mixed ending." },
	};

	public static AchievementDef Find( string id )
	{
		if ( string.IsNullOrEmpty( id ) )
			return null;
		var key = id.ToLowerInvariant();
		return Catalog.FirstOrDefault( a => a.Id == key );
	}

	public static bool IsUnlocked( string id ) =>
		!string.IsNullOrEmpty( id ) && _unlocked.Contains( id.ToLowerInvariant() );

	public static void ClearProgress()
	{
		_unlocked.Clear();
		_toastQueue.Clear();
		_toastId = null;
		_toastTimer = 0f;
		LifetimeExplodes = 0;
		TimesFed = 0;
		TimesMeth = 0;
		DecorPlaced = 0;
		_version++;
	}

	public static void Load( List<string> ids, int explodes = 0, int fed = 0, int meth = 0, int decor = 0 )
	{
		_unlocked.Clear();
		if ( ids is not null )
		{
			foreach ( var id in ids )
			{
				if ( string.IsNullOrWhiteSpace( id ) )
					continue;
				// Migrate pre-lowercase saves (ACH_FOO → ach_foo)
				var normalized = id.Trim().ToLowerInvariant();
				if ( Find( normalized ) is not null )
					_unlocked.Add( normalized );
			}
		}

		LifetimeExplodes = Math.Max( 0, explodes );
		TimesFed = Math.Max( 0, fed );
		TimesMeth = Math.Max( 0, meth );
		DecorPlaced = Math.Max( 0, decor );
		_version++;
	}

	public static void WriteToSave( SaveData data )
	{
		if ( data is null )
			return;

		data.Achievements = _unlocked.ToList();
		data.LifetimeExplodes = LifetimeExplodes;
		data.TimesFed = TimesFed;
		data.TimesMeth = TimesMeth;
		data.DecorPlaced = DecorPlaced;
	}

	public static void Unlock( string id )
	{
		if ( string.IsNullOrEmpty( id ) )
			return;

		id = id.ToLowerInvariant();
		if ( _unlocked.Contains( id ) )
			return;

		if ( Find( id ) is null )
			return;

		_unlocked.Add( id );
		_toastQueue.Enqueue( id );
		if ( _toastTimer <= 0f )
			ShowNextToast();

		GameAudio.PlayUi( GameAudio.Achievement );
		_version++;
	}

	public static void Tick( float dt )
	{
		if ( _toastTimer > 0f )
		{
			_toastTimer -= dt;
			if ( _toastTimer <= 0f )
			{
				_toastId = null;
				ShowNextToast();
				_version++;
			}
		}

		// Passive-based checks while playing.
		if ( GameFlow.Screen == GameScreen.Playing && TankSim.IsActive )
			PollState();
	}

	static void ShowNextToast()
	{
		if ( _toastQueue.Count == 0 )
		{
			_toastTimer = 0f;
			_toastId = null;
			return;
		}

		_toastId = _toastQueue.Dequeue();
		_toastTimer = 3.6f;
		_version++;
	}

	// ---- event hooks ----

	public static void OnWarningAccepted() => Unlock( "ach_strobe_warn" );

	public static void OnNewGameStarted()
	{
		if ( TankSim.FishCount > 0 )
			Unlock( "ach_first_splash" );
	}

	public static void OnContinue() => Unlock( "ach_save_reload" );

	public static void OnFed()
	{
		TimesFed++;
		Unlock( "ach_fed_once" );
	}

	public static void OnFoodFed( string foodId )
	{
		switch ( foodId )
		{
			case "fried_chicken": Unlock( "ach_food_chicken" ); break;
			case "weed_gummies": Unlock( "ach_food_gummies" ); break;
			case "other_fish": Unlock( "ach_food_cannibal" ); break;
			case "hand": Unlock( "ach_food_hand" ); break;
			case "homeless_man": Unlock( "ach_food_homeless" ); break;
		}
	}

	public static void OnFoodUnlocked( string foodId )
	{
		// Reserved for meta unlocks later.
	}

	public static void OnTrained() => Unlock( "ach_train_1" );

	/// <summary>Called when a pleasant / meme Dogecoin earn float fires.</summary>
	public static void OnPleasantDogeEarn( int whole, bool funny )
	{
		if ( whole <= 0 )
			return;

		if ( whole >= 100 && whole % 100 == 0 )
			Unlock( "ach_doge_round" );

		switch ( whole )
		{
			case 69: Unlock( "ach_doge_69" ); break;
			case 420: Unlock( "ach_doge_420" ); break;
			case 1337: Unlock( "ach_doge_1337" ); break;
			case 666: Unlock( "ach_doge_666" ); break;
			case 777: Unlock( "ach_doge_777" ); break;
			case 9001: Unlock( "ach_doge_9001" ); break;
		}
	}

	public static void OnSteroidsUnlocked() => Unlock( "ach_steroids_unlock" );

	public static void OnInjectSuccess( int jackedCount )
	{
		Unlock( "ach_inject_1" );
		if ( jackedCount >= 5 )
			Unlock( "ach_jacked_team" );
	}

	public static void OnInjectFail() => Unlock( "ach_inject_fail" );

	public static void OnMethFed( int savedWrongWater )
	{
		TimesMeth++;
		Unlock( "ach_meth_first" );
		if ( savedWrongWater > 0 )
			Unlock( "ach_meth_survivor" );
	}

	public static void OnCapacityUpgraded() => Unlock( "ach_upgrade_1" );

	public static void OnSaltUnlocked() => Unlock( "ach_salt_tank" );

	public static void OnHabitatUpgraded( HabitatDef def )
	{
		if ( def is null )
			return;

		switch ( def.Id )
		{
			case "tank_fresh_2":
			case "tank_salt_2":
				Unlock( "ach_hab_deluxe" );
				break;
			case "bathtub":
				Unlock( "ach_hab_bathtub" );
				break;
			case "kiddie_pool":
				Unlock( "ach_hab_kiddie" );
				break;
			case "neighbor_pool":
				Unlock( "ach_hab_neighbor" );
				break;
			case "lake":
				Unlock( "ach_hab_lake" );
				break;
			case "gulf":
				Unlock( "ach_hab_gulf" );
				break;
			case "ocean":
				Unlock( "ach_hab_ocean" );
				break;
		}
	}

	public static void OnBattleWin( BattleTemplate template, int totalWins )
	{
		Unlock( "ach_battle_1" );
		if ( totalWins >= 5 )
			Unlock( "ach_battle_5" );
		if ( template is null )
			return;
		switch ( template.Id )
		{
			case "steroid_circuit":
				Unlock( "ach_battle_steroid" );
				break;
			case "abyss_amateurs":
				Unlock( "ach_battle_abyss" );
				break;
			case "shark_bait_invitational":
				Unlock( "ach_battle_shark" );
				break;
		}
	}

	public static void OnBattleLose() => Unlock( "ach_battle_loss" );

	public static void OnFightTankWon( HabitatDef def )
	{
		Unlock( "ach_tank_prize_1" );
		if ( BattleSystem.PrizeTanksWon >= 3 )
			Unlock( "ach_tank_prize_3" );
		if ( def is null )
			return;
		switch ( def.Id )
		{
			case "sewage_duct":
				Unlock( "ach_tank_sewage" );
				break;
			case "neighbor_pool":
				Unlock( "ach_tank_neighbor_fight" );
				Unlock( "ach_hab_neighbor" );
				break;
		}
		// Reuse habitat achievements where ids match
		OnHabitatUpgraded( def );
	}

	public static void OnSharkSighted() => Unlock( "ach_shark_sight" );

	public static void OnFishEatenByShark() => Unlock( "ach_shark_eaten" );

	public static void OnSharkScared() => Unlock( "ach_shark_scare" );

	public static void OnSharkKilled( int totalKills )
	{
		Unlock( "ach_shark_kill" );
		if ( totalKills >= 3 )
			Unlock( "ach_shark_kill_3" );
	}

	public static void OnWmdBought() => Unlock( "ach_wmd" );

	public static void OnWhaleBought() => Unlock( "ach_whale" );

	public static void OnEnding( EndingKind kind )
	{
		switch ( kind )
		{
			case EndingKind.GoodBoy: Unlock( "ach_end_good" ); break;
			case EndingKind.BadBoy: Unlock( "ach_end_bad" ); break;
			case EndingKind.Mixed: Unlock( "ach_end_mixed" ); break;
		}
	}

	public static void OnDecorPlaced()
	{
		DecorPlaced++;
		Unlock( "ach_decor_1" );
	}

	public static void OnWrongWaterSpawn() => Unlock( "ach_wrong_water" );

	public static void OnFishDiedSalinity() => Unlock( "ach_fish_died_salinity" );

	public static void OnFishAddicted() => Unlock( "ach_hooked" );

	public static void OnWithdrawalStarted() => Unlock( "ach_withdrawal" );

	public static void OnTape() => Unlock( "ach_tape_one" );

	public static void OnDetonate( int count )
	{
		LifetimeExplodes += count;
		if ( count >= 1 )
			Unlock( "ach_boom_one" );
		if ( count >= 5 )
			Unlock( "ach_boom_five" );
		if ( LifetimeExplodes >= 20 )
			Unlock( "ach_boom_20" );
	}

	public static void PollState()
	{
		if ( Economy.Balance >= 100 )
			Unlock( "ach_doge_100" );
		if ( Economy.Balance >= 10000 )
			Unlock( "ach_doge_10k" );
		if ( TankSim.FishCount > 0 && TankSim.FishCount >= TankSim.Capacity )
			Unlock( "ach_tank_full" );
		if ( Chaos.Value >= 80f )
			Unlock( "ach_chaos_high" );
		if ( TankSim.AddictedFishCount > 0 )
			Unlock( "ach_hooked" );
		if ( TrainingSystem.SteroidsUnlocked )
			Unlock( "ach_steroids_unlock" );
	}
}