GrandmaReact.cs

Static helper that queues and shows one-shot "Grandma" banner lines for first-time milestones. It tracks which lines have been said, queues a pending line until UI is quiet, persists said IDs to SaveData, and seeds state from older saves or progress.

File Access
namespace NoChillquarium;

/// <summary>
/// Mid-game porch reactions — one-shot dry-love Grandma lines on big firsts.
/// Queues if a gift/story card is open so nothing gets buried.
/// </summary>
public static class GrandmaReact
{
	static readonly HashSet<string> _said = new( StringComparer.OrdinalIgnoreCase );
	static string _pendingId = "";
	static string _pendingLine = "";
	/// <summary>Let system banners show first (train unlock, shady tab, fight payout).</summary>
	static float _holdTimer;
	static int _version;

	public static int Version => _version;

	// ---- Milestone ids (save as strings) ----
	public const string FirstFeed = "first_feed";
	public const string FirstTrain = "first_train";
	public const string FirstAbom = "first_abom";
	public const string FirstFightWin = "first_fight_win";
	public const string FirstFightLoss = "first_fight_loss";
	public const string FirstChamp = "first_champ";
	public const string FirstDeath = "first_death";
	public const string FirstSharkKill = "first_shark_kill";
	public const string FirstSalt = "first_salt";
	public const string FirstHabitat = "first_habitat";

	public static void ResetForNewGame()
	{
		_said.Clear();
		_pendingId = "";
		_pendingLine = "";
		_holdTimer = 0f;
		_version++;
	}

	public static void Clear()
	{
		_pendingId = "";
		_pendingLine = "";
		_holdTimer = 0f;
		_version++;
	}

	public static void Load( SaveData data )
	{
		_said.Clear();
		_pendingId = "";
		_pendingLine = "";
		_holdTimer = 0f;
		if ( data?.GrandmaReactSaid is null )
		{
			// Old saves: treat past progress as already "heard" so we don't spam.
			SeedFromProgress( data );
			_version++;
			return;
		}
		foreach ( var id in data.GrandmaReactSaid )
		{
			if ( !string.IsNullOrWhiteSpace( id ) )
				_said.Add( id.Trim() );
		}
		_version++;
	}

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

	/// <summary>Call from play tick — flush queued quip when UI is quiet.</summary>
	public static void Tick( float dt )
	{
		if ( string.IsNullOrEmpty( _pendingId ) )
			return;
		if ( _holdTimer > 0f )
		{
			_holdTimer -= MathF.Max( 0f, dt );
			return;
		}
		if ( IsUiBlocking() )
			return;

		var id = _pendingId;
		var line = _pendingLine;
		_pendingId = "";
		_pendingLine = "";
		SpeakNow( id, line );
	}

	/// <summary>One-shot Grandma banner. Always queues briefly so system tips show first.</summary>
	public static void Try( string id, string line )
	{
		if ( string.IsNullOrEmpty( id ) || string.IsNullOrEmpty( line ) )
			return;
		if ( _said.Contains( id ) )
			return;
		// Don't stack over an already-queued different quip — first wins.
		if ( !string.IsNullOrEmpty( _pendingId ) )
			return;

		_pendingId = id;
		_pendingLine = line;
		// Let the player sit with the system tip + porch beat before dry love.
		_holdTimer = IsUiBlocking() ? 0.8f : 4.5f;
		_version++;
	}

	// ---- Named firsts (call sites stay thin) ----

	public static void OnFirstFeed() =>
		Try( FirstFeed, "Grandma: \"They're eating. Good. Don't get cocky.\"" );

	public static void OnFirstTrain() =>
		Try( FirstTrain, "Grandma: \"Nice sweat. Now go make Brad regret opening that garage.\"" );

	public static void OnFirstAbom() =>
		Try( FirstAbom, "Grandma: \"Ugly little angel. Keep making those — I want more.\"" );

	public static void OnFirstFightWin() =>
		Try( FirstFightWin, "Grandma: \"I told Brad my punk hits. You just proved it.\"" );

	public static void OnFirstFightLoss() =>
		Try( FirstFightLoss, "Grandma: \"Cute try. Ice them, train again, and bite back.\"" );

	public static void OnFirstChamp() =>
		Try( FirstChamp, "Grandma: \"Belt's crooked. Fix it — and try not to look so proud.\"" );

	public static void OnFirstDeath() =>
		Try( FirstDeath, "Grandma: \"Supper's on. Love you. Still your fault.\"" );

	public static void OnFirstSharkKill() =>
		Try( FirstSharkKill, "Grandma: \"You punched a cocaine shark. Bingo will hear about this.\"" );

	public static void OnFirstSalt() =>
		Try( FirstSalt, "Grandma: \"Two tanks. Fancy. Wrong water still means supper.\"" );

	public static void OnFirstHabitat() =>
		Try( FirstHabitat, "Grandma: \"Bigger glass. Still my porch. Don't flood the steps.\"" );

	// ---- Internals ----

	static bool IsUiBlocking() =>
		GrandmaGifts.IsBusy
		|| StorySystem.CardOpen
		|| BattleSystem.Phase == BattlePhase.Result
		|| BattleSystem.Phase == BattlePhase.Fighting;

	static void SpeakNow( string id, string line )
	{
		if ( _said.Contains( id ) )
			return;
		_said.Add( id );
		TankSim.ShowBanner( line );
		_version++;
		SaveGame.TrySave( quiet: true );
	}

	/// <summary>Continuing mid-empire: don't re-fire early tutorial quips.</summary>
	static void SeedFromProgress( SaveData data )
	{
		if ( data is null )
			return;
		if ( data.StoryFedOnce || data.PlaytimeSeconds > 120f )
			_said.Add( FirstFeed );
		if ( data.StoryTrainedOnce )
			_said.Add( FirstTrain );
		if ( data.StoryCombinedOnce || data.AbominationsCreated > 0 )
			_said.Add( FirstAbom );
		if ( data.BattleWins > 0 )
			_said.Add( FirstFightWin );
		if ( data.BattleLosses > 0 )
			_said.Add( FirstFightLoss );
		if ( data.ChampionshipTitles > 0 )
			_said.Add( FirstChamp );
		if ( data.FishDeaths > 0 )
			_said.Add( FirstDeath );
		if ( data.SharkKills > 0 )
			_said.Add( FirstSharkKill );
		if ( data.SaltUnlocked )
			_said.Add( FirstSalt );
		// Any non-starter habitat id means they already upgraded once.
		if ( !string.IsNullOrEmpty( data.FreshHabitatId )
			&& !string.Equals( data.FreshHabitatId, "tank_fresh_1", StringComparison.OrdinalIgnoreCase ) )
			_said.Add( FirstHabitat );
		else if ( !string.IsNullOrEmpty( data.SaltHabitatId )
			&& !string.Equals( data.SaltHabitatId, "tank_salt_1", StringComparison.OrdinalIgnoreCase ) )
			_said.Add( FirstHabitat );
	}
}