AchievementSystem.cs

Achievement system for the game. Defines AchievementDef and a static AchievementSystem that holds the catalog of achievements, tracks unlocked ones, queues and displays toast notifications, saves/loads unlocked state, evaluates achievements from world systems, and exposes UI state and counters.

File AccessNetworking
namespace NoChillquarium;

/// <summary>One porch trophy. Hint is what to do; punchline is the unlock toast.</summary>
public sealed class AchievementDef
{
	public string Id { get; init; }
	public string Name { get; init; }
	/// <summary>Shown while locked — points at the next verb.</summary>
	public string Hint { get; init; }
	/// <summary>Toast line when it pops.</summary>
	public string Punch { get; init; }
}

/// <summary>
/// Twenty gameplay trophies. They teach the ladder and celebrate the felonies.
/// </summary>
public static class AchievementSystem
{
	public static IReadOnlyList<AchievementDef> Catalog { get; } = new[]
	{
		new AchievementDef
		{
			Id = "feed",
			Name = "Papa Flakes",
			Hint = "Tools → Feed. They have to eat.",
			Punch = "They're eating. Don't get cocky."
		},
		new AchievementDef
		{
			Id = "train",
			Name = "Swim Lessons",
			Hint = "Click a fish → Train. Sweat first.",
			Punch = "Nice sweat. Brad's garage is listening."
		},
		new AchievementDef
		{
			Id = "combine",
			Name = "Two Become Wet",
			Hint = "Click a fish → Combine. Mash two.",
			Punch = "Ugly little angel. The janitor took notes."
		},
		new AchievementDef
		{
			Id = "fight",
			Name = "Garage Receipt",
			Hint = "Fish menu → Fight. Pay Brad's entry.",
			Punch = "You showed up. He laminated it either way."
		},
		new AchievementDef
		{
			Id = "win",
			Name = "Brad Cried",
			Hint = "Win a fight in the garage.",
			Punch = "He cried into a receipt. Fridge magnet pending."
		},
		new AchievementDef
		{
			Id = "flush",
			Name = "Municipal Directions",
			Hint = "Click a fish → Flush. Adventure starts dirty.",
			Punch = "Down. Down. Down. The pipes said thank you."
		},
		new AchievementDef
		{
			Id = "pipes",
			Name = "Adventure Mode",
			Hint = "Clear a crawl. Die = faucet home.",
			Punch = "You mapped a hole. Grandma is pretending not to know."
		},
		new AchievementDef
		{
			Id = "sell",
			Name = "Cash for Wet",
			Hint = "Fish menu → Sell. Flip a common.",
			Punch = "Yard sale energy. Very American."
		},
		new AchievementDef
		{
			Id = "decor",
			Name = "Tasteful Junk",
			Hint = "Shop → Decor. Put something in the glass.",
			Punch = "Interior design. The fish did not consent."
		},
		new AchievementDef
		{
			Id = "habitat",
			Name = "Bigger Bowl",
			Hint = "Shop → Tank. Upgrade the habitat.",
			Punch = "Bigger glass. Still her porch."
		},
		new AchievementDef
		{
			Id = "salt",
			Name = "Second Fridge",
			Hint = "Shop → Tank → Salt. Clownfish come free.",
			Punch = "Two tanks. Wrong water still means supper."
		},
		new AchievementDef
		{
			Id = "shady",
			Name = "Science Illegal",
			Hint = "Shop → Shady. Buy an M80 or a bag.",
			Punch = "The laptop made a noise. The noise has a mop."
		},
		new AchievementDef
		{
			Id = "tape",
			Name = "Duct Tape Solutions",
			Hint = "Tools → M80 → click a fish.",
			Punch = "You taped an M80 to a coworker. HR is the janitor."
		},
		new AchievementDef
		{
			Id = "boom",
			Name = "Indoor Fireworks",
			Hint = "Detonate a taped M80.",
			Punch = "Pop. Quality control. Grandma: \"That one had potential.\""
		},
		new AchievementDef
		{
			Id = "furniture",
			Name = "Fish + Furniture",
			Hint = "Combine a fish with a tank prop.",
			Punch = "That's a flamingo with gills. That's a sentence."
		},
		new AchievementDef
		{
			Id = "meth",
			Name = "Breaking Bass",
			Hint = "Tools → Meth. Dose the glass.",
			Punch = "Wrong water, right snack. Contain the thrash."
		},
		new AchievementDef
		{
			Id = "janitor",
			Name = "Folgers Has a Lawyer",
			Hint = "BAD or click the janitor. Offer meth.",
			Punch = "He refused. Caffeine is his manager."
		},
		new AchievementDef
		{
			Id = "kick",
			Name = "Assault Furniture",
			Hint = "Tools → Kick. The glass remembers.",
			Punch = "You kicked a living room. The gravel filed a complaint."
		},
		new AchievementDef
		{
			Id = "tin",
			Name = "Funeral Funds",
			Hint = "BAD → Rob the funeral tin.",
			Punch = "Grandpa's leftover hash. I mean cash. She can still count."
		},
		new AchievementDef
		{
			Id = "dead",
			Name = "Still Your Fault",
			Hint = "Let one die. Wrong water works.",
			Punch = "Supper's on. Love you. Wipe the glass."
		},
	};

	const float ToastSeconds = 4.2f;

	static readonly HashSet<string> _unlocked = new( StringComparer.OrdinalIgnoreCase );
	static readonly Queue<string> _toastQ = new();
	static string _toastId = "";
	static float _toastLeft;
	static bool _boardOpen;
	static int _version;

	public static int Version => _version;
	public static int UnlockedCount => _unlocked.Count;
	public static int Total => Catalog.Count;
	public static string ProgressLabel => UnlockedCount + "/" + Total;
	public static bool BoardOpen => _boardOpen;
	public static bool ToastVisible => _toastLeft > 0f && !string.IsNullOrEmpty( _toastId );
	public static AchievementDef ToastDef => Find( _toastId );

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

	public static AchievementDef Find( string id )
	{
		if ( string.IsNullOrEmpty( id ) )
			return null;
		foreach ( var d in Catalog )
		{
			if ( string.Equals( d.Id, id, StringComparison.OrdinalIgnoreCase ) )
				return d;
		}
		return null;
	}

	public static void ResetForNewGame()
	{
		_unlocked.Clear();
		_toastQ.Clear();
		_toastId = "";
		_toastLeft = 0f;
		_boardOpen = false;
		_version++;
	}

	public static void Clear()
	{
		_toastQ.Clear();
		_toastId = "";
		_toastLeft = 0f;
		_boardOpen = false;
		_version++;
	}

	public static void Load( SaveData data )
	{
		_unlocked.Clear();
		_toastQ.Clear();
		_toastId = "";
		_toastLeft = 0f;
		_boardOpen = false;
		if ( data?.AchievementsUnlocked is not null )
		{
			foreach ( var id in data.AchievementsUnlocked )
			{
				if ( !string.IsNullOrWhiteSpace( id ) && Find( id.Trim() ) is not null )
					_unlocked.Add( id.Trim() );
			}
		}
		_version++;
		Evaluate( silent: true );
	}

	public static void WriteToSave( SaveData data )
	{
		if ( data is null )
			return;
		data.AchievementsUnlocked = _unlocked.ToList();
	}

	public static void ToggleBoard()
	{
		if ( !GrandmaGifts.HasTank )
			return;
		_boardOpen = !_boardOpen;
		GameAudio.PlayUi( _boardOpen ? GameAudio.Notice : GameAudio.Close );
		_version++;
	}

	public static void CloseBoard()
	{
		if ( !_boardOpen )
			return;
		_boardOpen = false;
		_version++;
	}

	public static void Tick( float dt )
	{
		if ( dt <= 0f )
			return;
		if ( _toastLeft > 0f )
		{
			_toastLeft -= dt;
			if ( _toastLeft <= 0f )
			{
				_toastLeft = 0f;
				_toastId = "";
				_version++;
				TryShowNextToast();
			}
		}

		// Cheap poll so we don't miss a flag that fired without a hook.
		if ( TankSim.IsActive && GrandmaGifts.HasTank )
			Evaluate( silent: false );
	}

	public static bool Unlock( string id, bool silent = false )
	{
		if ( string.IsNullOrEmpty( id ) )
			return false;
		if ( Find( id ) is null )
			return false;
		if ( !_unlocked.Add( id ) )
			return false;

		if ( !silent )
		{
			_toastQ.Enqueue( id );
			if ( !ToastVisible )
				TryShowNextToast();
			SaveGame.TrySave( quiet: true );
		}

		_version++;
		return true;
	}

	/// <summary>Backfill from world state. Silent on load so we don't spam a mid-empire save.</summary>
	public static void Evaluate( bool silent )
	{
		if ( !TankSim.IsActive || !GrandmaGifts.HasTank )
			return;

		if ( StorySystem.HasFedOnce )
			Unlock( "feed", silent );
		if ( StorySystem.HasTrainedOnce )
			Unlock( "train", silent );
		if ( StorySystem.HasCombinedOnce || TankSim.AbominationsCreated > 0 )
			Unlock( "combine", silent );
		if ( BattleSystem.Wins + BattleSystem.Losses > 0 )
			Unlock( "fight", silent );
		if ( BattleSystem.Wins > 0 )
			Unlock( "win", silent );
		if ( LotRunSystem.HasFlushed )
			Unlock( "flush", silent );
		if ( AnyMapCleared() )
			Unlock( "pipes", silent );
		if ( StorySystem.HasSoldOnce )
			Unlock( "sell", silent );
		if ( TankSim.DecorCount > 0 || !string.IsNullOrEmpty( TankSim.ActiveBackdropId ) )
			Unlock( "decor", silent );
		if ( TankSim.HabitatRank > 0 )
			Unlock( "habitat", silent );
		if ( TankSim.SaltUnlocked )
			Unlock( "salt", silent );
		if ( Shop.M80Bags > 0 || Shop.MethBags > 0 || StorySystem.HasBoomOnce )
			Unlock( "shady", silent );
		if ( StorySystem.HasBoomOnce )
			Unlock( "boom", silent );
		if ( CrimeSystem.JanitorOffers > 0 )
			Unlock( "janitor", silent );
		if ( CrimeSystem.Kicks > 0 )
			Unlock( "kick", silent );
		if ( CrimeSystem.RobbedTin )
			Unlock( "tin", silent );
		if ( TankSim.FishDeaths > 0 )
			Unlock( "dead", silent );
	}

	static bool AnyMapCleared()
	{
		foreach ( var loc in LotLocationDef.Catalog )
		{
			if ( LotRunSystem.IsCleared( loc.Id ) )
				return true;
		}
		return false;
	}

	static void TryShowNextToast()
	{
		if ( ToastVisible || _toastQ.Count == 0 )
			return;
		_toastId = _toastQ.Dequeue();
		_toastLeft = ToastSeconds;
		GameAudio.PlayUi( GameAudio.Achievement );
		_version++;
	}
}