GrandmaGifts.cs

Game UI/event system for one-off ‘Grandma’ gifts. Defines gift types, gift phases, gift definitions and a static manager that tracks received gifts, triggers dialogues, typewriter text, grants rewards (money/karma), runs claim callbacks and persists received state.

File AccessNetworking
namespace NoChillquarium;

/// <summary>UI prop for the gift item popup.</summary>
public enum GrandmaGiftProp
{
	None,
	/// <summary>New-game establishing shot — trailer park before the tank.</summary>
	Establishing,
	Tank,
	Wallet,
	/// <summary>Combine / fusion unlock (janitor intro).</summary>
	Fusion,
	/// <summary>Fight unlock (lot bully challenge).</summary>
	Fight,
	/// <summary>Adventure Mode unlock (flush) — last ladder intro.</summary>
	Adventure
}

/// <summary>Where we are in a gift sequence.</summary>
public enum GrandmaGiftPhase
{
	/// <summary>Playing; watching for triggers.</summary>
	Idle,
	/// <summary>RPG typewriter dialogue.</summary>
	Dialogue,
	/// <summary>Item received popup after dialogue.</summary>
	ItemPopup
}

/// <summary>One lot character gift/event: trigger → dialogue → item popup.</summary>
public sealed class GrandmaGiftDef
{
	public string Id { get; init; }
	public string[] Lines { get; init; }
	public string ItemTitle { get; init; }
	public string ItemBody { get; init; }
	public GrandmaGiftProp Prop { get; init; }
	public string PopupButton { get; init; } = "Got it!";
	/// <summary>Dialogue nameplate (default Grandma).</summary>
	public string Speaker { get; init; } = "Grandma";
	/// <summary>Portrait sprite id (default ui_grandma).</summary>
	public string PortraitSprite { get; init; } = "ui_grandma";
	/// <summary>Optional CSS theme on the dialogue shell (e.g. janitor).</summary>
	public string ThemeClass { get; init; } = "";
	/// <summary>Min total playtime before auto-check can fire.</summary>
	public float MinPlaytime { get; init; }
	/// <summary>
	/// Seconds of playtime that must pass after the previous gift claim before this one can fire.
	/// Lets the player use the last unlock before the next intro. 0 = no gap (onboarding chain).
	/// </summary>
	public float MinGapAfterPrior { get; init; }
	/// <summary>Optional: must already have received this gift id.</summary>
	public string RequiresGiftId { get; init; }
	/// <summary>Extra world condition (null = only playtime / flags).</summary>
	public Func<bool> ExtraReady { get; init; }
	/// <summary>
	/// Base Ð paid when the scene finishes (item popup closed).
	/// Long scenes get a per-line bonus on top — sitting through dialogue pays.
	/// </summary>
	public double DogeReward { get; init; }
	/// <summary>Extra karma on claim (on top of anything in OnClaimed).</summary>
	public float KarmaReward { get; init; }
	/// <summary>Run when the player closes the item popup (after cash/karma grant).</summary>
	public Action OnClaimed { get; init; }
}

/// <summary>
/// Play around → hit a gift trigger → Grandma dialogue → item popup.
/// One-shot per save. Extend <see cref="Catalog"/> for more gifts.
/// </summary>
public static class GrandmaGifts
{
	/// <summary>Fast typewriter — click still skips / advances hard.</summary>
	public const float TypeCharsPerSecond = 72f;

	static readonly HashSet<string> _received = new( StringComparer.OrdinalIgnoreCase );
	static GrandmaGiftPhase _phase = GrandmaGiftPhase.Idle;
	static GrandmaGiftDef _active;
	static int _line;
	static int _visibleChars;
	static float _typeTimer;
	static int _version;
	/// <summary>Seconds of active play this run (for “play a little first”).</summary>
	static float _sessionPlay;
	/// <summary>Economy.PlaytimeSeconds at last gift claim (persisted) — enforces MinGapAfterPrior.</summary>
	static float _lastGiftClaimPlaytime;
	/// <summary>One recovery attempt if tank gift was claimed but glass is empty.</summary>
	static bool _triedTankRecovery;

	/// <summary>Default gap between mid-ladder gifts when a def uses the shared floor.</summary>
	public const float DefaultLadderGap = 35f;

	public static int Version => _version;
	public static GrandmaGiftPhase Phase => _phase;
	public static bool IsBusy => _phase != GrandmaGiftPhase.Idle;
	public static bool IsDialogue => _phase == GrandmaGiftPhase.Dialogue;
	public static bool IsItemPopup => _phase == GrandmaGiftPhase.ItemPopup;
	public static GrandmaGiftDef Active => _active;
	public static string ActiveId => _active?.Id ?? "";
	public static GrandmaGiftProp ActiveProp => _active?.Prop ?? GrandmaGiftProp.None;
	public static int LineIndex => _line;
	public static int LineCount => _active?.Lines?.Length ?? 0;

	public static string Speaker =>
		string.IsNullOrWhiteSpace( _active?.Speaker ) ? "Grandma" : _active.Speaker;

	/// <summary>Empty string = no portrait (You monologue). Null/default → Grandma.</summary>
	public static string PortraitSprite
	{
		get
		{
			if ( _active is null )
				return "ui_grandma";
			// Explicit empty on the def = hide portrait.
			if ( _active.PortraitSprite is not null && _active.PortraitSprite.Length == 0 )
				return "";
			return string.IsNullOrWhiteSpace( _active.PortraitSprite ) ? "ui_grandma" : _active.PortraitSprite;
		}
	}

	public static string ThemeClass => _active?.ThemeClass ?? "";

	/// <summary>CSS scene class for the dialogue backdrop (avoid multi-line ternaries in Razor).</summary>
	public static string SceneCssClass => ActiveProp switch
	{
		GrandmaGiftProp.Establishing => "scene-lot",
		GrandmaGiftProp.Wallet => "scene-wallet",
		GrandmaGiftProp.Fusion => "scene-fusion",
		GrandmaGiftProp.Fight => "scene-fight",
		GrandmaGiftProp.Adventure => "scene-adventure",
		_ => "scene-tank"
	};

	/// <summary>ITEM GET vs NEW MOVE / CHALLENGE kicker.</summary>
	public static string PopupKicker => ActiveProp switch
	{
		GrandmaGiftProp.Establishing => "SCHOOL NOTICE",
		GrandmaGiftProp.Fusion => "NEW MOVE",
		GrandmaGiftProp.Fight => "CHALLENGE",
		GrandmaGiftProp.Adventure => "NEW MODE",
		_ => "ITEM GET"
	};

	/// <summary>Adventure Mode intro claimed (or mid-empire save). Gates Flush UI.</summary>
	public static bool HasAdventureIntro => HasReceived( "adventure_intro" );

	/// <summary>Playtime left before the next gap-gated gift can fire (0 = free).</summary>
	public static float GiftGapSecondsLeft
	{
		get
		{
			if ( _lastGiftClaimPlaytime <= 0f )
				return 0f;
			// Next pending gift's gap — use the largest still-relevant ladder gap as UI soft hint.
			var need = NextPendingMinGap();
			if ( need <= 0f )
				return 0f;
			var readyAt = _lastGiftClaimPlaytime + need;
			return MathF.Max( 0f, readyAt - Economy.PlaytimeSeconds );
		}
	}

	static float NextPendingMinGap()
	{
		var best = 0f;
		foreach ( var g in Catalog )
		{
			if ( g is null || _received.Contains( g.Id ) )
				continue;
			if ( g.MinGapAfterPrior > best )
				best = g.MinGapAfterPrior;
			// Only care about the first unreceived mid-ladder gift in order.
			if ( g.MinGapAfterPrior > 0f )
				return g.MinGapAfterPrior;
		}
		return best;
	}

	/// <summary>Porch tank exists — HUD / glass after Grandma gifts it.</summary>
	public static bool HasTank => HasReceived( "fish_tank" );

	/// <summary>Pre-game trailer-lot inner monologue (looking around before tank).</summary>
	public static bool IsLookingAround =>
		IsBusy && ActiveProp == GrandmaGiftProp.Establishing;

	public static IReadOnlyList<GrandmaGiftDef> Catalog { get; } = new[]
	{
		// Jr-high troublemaker walks home with a suspension form (Bully-energy kid, not a sad softie).
		new GrandmaGiftDef
		{
			Id = "lot_establishing",
			MinPlaytime = 0.15f,
			Speaker = "You",
			PortraitSprite = "",
			ThemeClass = "you-theme",
			ItemTitle = "Suspension Form",
			ItemBody = "Jr high paper. Show Grandma.",
			Prop = GrandmaGiftProp.Establishing,
			PopupButton = "Show Grandma",
			// Short: 3 lines · one click each (skip+next collapsed).
			Lines = new[]
			{
				"Suspended. Again. Form in hand — Sunny Pines gravel under my shoes.",
				"Brad from 4B will love this. Lot runs on rumors. Grandma still has to see the paper.",
				"Porch light's on. Chin up. She's gonna roast me — then help.",
			},
			DogeReward = 3,
			KarmaReward = 1f,
			OnClaimed = () =>
			{
				TankSim.ShowBanner( "Form ready. Time to face Grandma." );
			}
		},
		new GrandmaGiftDef
		{
			Id = "fish_tank",
			MinPlaytime = 1.2f,
			RequiresGiftId = "lot_establishing",
			Speaker = "Grandma",
			PortraitSprite = "ui_grandma",
			ItemTitle = "Your First Fish Tank",
			ItemBody = "Porch glass + two goldfish.",
			Prop = GrandmaGiftProp.Tank,
			PopupButton = "Thanks, Grandma",
			DogeReward = 4,
			KarmaReward = 2f,
			Lines = new[]
			{
				"Suspended again? Wipe your feet. I've been madder about burnt toast.",
				"I don't lecture. I gift. First fish tank — two goldfish. Feed 'em or supper's on you.",
				"You're still my punk. Don't drown them. Love you. Go be useful trouble.",
			},
			OnClaimed = () =>
			{
				TankSim.GrantTrailerLotTank();
				// Story first so Grant→Evaluate doesn't run during prologue (index -1).
				StorySystem.OpenFirstCardAfterPrologue();
				// Free flakes so the first verb is Feed, not Shop scavenger hunt.
				FeedSystem.Grant( FoodDef.Flakes, announce: false );
				TankSim.ShowBanner( "Flakes in the bag. Tools → Feed." );
			}
		},
		new GrandmaGiftDef
		{
			Id = "cold_wallet",
			// First feed → wallet almost immediately. Numbers on screen = the hook.
			MinPlaytime = 12f,
			MinGapAfterPrior = 10f,
			RequiresGiftId = "fish_tank",
			Speaker = "Grandma",
			PortraitSprite = "ui_grandma",
			ExtraReady = () =>
				StorySystem.HasFedOnce
				|| Economy.LifetimeEarned >= 3
				|| StorySystem.HasPassedBeat( "feed" ),
			ItemTitle = "Dogecoin Cold Wallet",
			ItemBody = "Cookie-tin wallet. Fish earn Ð into it.",
			Prop = GrandmaGiftProp.Wallet,
			PopupButton = "Take wallet",
			DogeReward = 10,
			KarmaReward = 1f,
			Lines = new[]
			{
				"Cookie tin — dogecoin cold wallet. Was for my funeral. Plans change.",
				"Yours. Fish earn Ð. Food, not detention candy. No scratch-offs.",
				"Love you, sticky punk. Tap the wallet if your brain falls out. Shoo.",
			},
			OnClaimed = () =>
			{
				StorySystem.NotifyWalletGiftClaimed();
			}
		},
		// Fusion intro — after a little porch play, not a five-minute detention.
		new GrandmaGiftDef
		{
			Id = "janitor_fusion",
			MinPlaytime = 70f,
			MinGapAfterPrior = 55f,
			RequiresGiftId = "cold_wallet",
			Speaker = "Janitor · outside",
			PortraitSprite = "ui_janitor",
			ThemeClass = "theme-janitor",
			ExtraReady = () =>
				Progression.CombineUnlocked
				&& TankSim.TotalFishCount >= 2
				&& !StorySystem.HasCombinedOnce
				&& Economy.LifetimeEarned >= 12
				&& (StorySystem.HasSoldOnce
					|| StorySystem.HasTrainedOnce
					|| TankSim.TotalFishCount >= 3
					|| StorySystem.HasPassedBeat( "sell_tip" )
					|| Economy.PlaytimeSeconds >= 90f),
			ItemTitle = "Combine",
			ItemBody = "Fish menu → Combine. Mash two fish (or fish + decor). Pays Ð.",
			Prop = GrandmaGiftProp.Fusion,
			PopupButton = "Got it",
			DogeReward = 14,
			KarmaReward = 0.5f,
			Lines = new[]
			{
				"*tap tap* Don't mind the face. I don't mean to pry — blinds are a suggestion.",
				"Mmm. Lot janitor. Stick two fish together. Or a fish and a flamingo. Pays Ð.",
				"Fish menu → Combine → click another. Explodes? Your floor. Break time. Mmm.",
			},
			OnClaimed = () =>
			{
				Chaos.Add( 1.5f );
			}
		},
		// Brad — after train + combine practice, without multi-minute dead air.
		new GrandmaGiftDef
		{
			Id = "lot_bully_fight",
			MinPlaytime = 110f,
			MinGapAfterPrior = 60f,
			RequiresGiftId = "cold_wallet",
			Speaker = "Brad",
			PortraitSprite = "ui_bully",
			ThemeClass = "theme-bully",
			ExtraReady = () =>
				StorySystem.HasTrainedOnce
				&& BattleSystem.CanAffordAnyFight()
				&& BattleSystem.Wins + BattleSystem.Losses == 0
				// Practice combine first — risk two fish before garage receipts.
				&& (StorySystem.HasCombinedOnce || TankSim.AbominationsCreated > 0)
				&& Economy.LifetimeEarned >= 22,
			ItemTitle = "Fight",
			ItemBody = "Fight → Garage Guppies. Ð entry. Brad runs the circuit.",
			Prop = GrandmaGiftProp.Fight,
			PopupButton = "You're on",
			DogeReward = 16,
			KarmaReward = 1f,
			Lines = new[]
			{
				"Yo. Brad, unit 4B. Garage circuit — real fights, not hallway slap-fights.",
				"Ð entry. One fish. Winner takes pot. Loser walks quieter.",
				"Fight → Garage Guppies. Bring a hitter. I talk receipts.",
			},
			OnClaimed = () =>
			{
				// Don't yank them into the arena — they find Fight when ready.
				Chaos.Add( 2f );
				TankSim.ShowBanner( "Brad's garage is open. Fight when you can pay." );
			}
		},
		// Adventure — last; after aboms + a fight, still inside the fun window.
		new GrandmaGiftDef
		{
			Id = "adventure_intro",
			MinPlaytime = 180f,
			MinGapAfterPrior = 90f,
			RequiresGiftId = "cold_wallet",
			Speaker = "You",
			PortraitSprite = "",
			ThemeClass = "you-theme",
			ExtraReady = () =>
			{
				var practicedCombine = StorySystem.HasCombinedOnce || TankSim.AbominationsCreated > 0;
				var practicedFight = BattleSystem.Wins + BattleSystem.Losses >= 1;
				return practicedCombine
					&& practicedFight
					&& TankSim.AbominationsCreated >= 1
					&& Economy.LifetimeEarned >= 45
					&& TankSim.TotalFishCount >= 1
					&& StorySystem.HasFedOnce;
			},
			ItemTitle = "Adventure Mode",
			ItemBody = "Fish menu → Flush. Die → faucet home. Survive → more maps.",
			Prop = GrandmaGiftProp.Adventure,
			PopupButton = "Alright",
			DogeReward = 24,
			KarmaReward = 0.5f,
			Lines = new[]
			{
				"The pipes under the lot go farther than the trailer.",
				"Flush a fish to send them in. If they die, the kitchen faucet brings them home hurt.",
				"Survive and more maps open. Fish menu → Flush when you're ready.",
			},
			OnClaimed = () =>
			{
				Chaos.Add( 2f );
			}
		},
	};

	public static bool HasReceived( string id ) =>
		!string.IsNullOrEmpty( id ) && _received.Contains( id );

	public static string CurrentLine
	{
		get
		{
			if ( _active?.Lines is null || _line < 0 || _line >= _active.Lines.Length )
				return "";
			return _active.Lines[_line];
		}
	}

	public static string VisibleText
	{
		get
		{
			var full = CurrentLine;
			if ( string.IsNullOrEmpty( full ) )
				return "";
			return full[..Math.Clamp( _visibleChars, 0, full.Length )];
		}
	}

	public static bool LineFullyTyped
	{
		get
		{
			var full = CurrentLine;
			return string.IsNullOrEmpty( full ) || _visibleChars >= full.Length;
		}
	}

	public static bool IsLastLine =>
		_active?.Lines is not null && _line >= _active.Lines.Length - 1;

	public static string AdvanceHint
	{
		get
		{
			if ( _phase == GrandmaGiftPhase.ItemPopup )
				return _active?.PopupButton ?? "Got it!";
			// One click always moves the scene forward (skip type + next, or claim).
			if ( ActiveProp == GrandmaGiftProp.Establishing )
				return IsLastLine ? "click · porch" : "click · walk";
			return IsLastLine ? "Click · done" : "Click · next";
		}
	}

	public static string ItemTitle => _active?.ItemTitle ?? "";
	public static string ItemBody => _active?.ItemBody ?? "";
	public static string PopupButton => _active?.PopupButton ?? "Got it!";

	/// <summary>
	/// Preformatted LCD text for gift UI. Never build "Ð@expr" in Razor — S&box prints it raw.
	/// </summary>
	public static string WalletBalanceLabel =>
		"D" + Economy.FormatDoge( Economy.Balance );

	// ---- Lifecycle ----

	public static void ResetForNewGame()
	{
		_received.Clear();
		_sessionPlay = 0f;
		_lastGiftClaimPlaytime = 0f;
		_triedTankRecovery = false;
		ClearActive();
	}

	public static void Clear()
	{
		_sessionPlay = 0f;
		// Keep _lastGiftClaimPlaytime across soft clears mid-session; Load/Reset own it.
		_triedTankRecovery = false;
		ClearActive();
	}

	static void ClearActive()
	{
		_phase = GrandmaGiftPhase.Idle;
		_active = null;
		_line = 0;
		_visibleChars = 0;
		_typeTimer = 0f;
		_version++;
	}

	public static void Load( SaveData data )
	{
		_received.Clear();
		_sessionPlay = 0f;
		_lastGiftClaimPlaytime = MathF.Max( 0f, data?.LastGiftClaimPlaytime ?? 0f );
		ClearActive();
		if ( data?.GrandmaGiftsReceived is null )
		{
			_version++;
			return;
		}
		foreach ( var id in data.GrandmaGiftsReceived )
		{
			if ( !string.IsNullOrWhiteSpace( id ) )
				_received.Add( id.Trim() );
		}
		// Old saves: already past tutorial — mark starter gifts done so they don't re-fire.
		if ( data.StoryDone || data.PlaytimeSeconds > 90f || data.LifetimeEarned > 40
			|| (data.FreshFish is { Count: > 0 }) || (data.Fish is { Count: > 0 }) )
		{
			_received.Add( "lot_establishing" );
			_received.Add( "fish_tank" );
			_received.Add( "cold_wallet" );
		}
		if ( data.StoryDone || data.StoryCombinedOnce || data.AbominationsCreated > 0 )
			_received.Add( "janitor_fusion" );
		if ( data.StoryDone || data.BattleWins + data.BattleLosses > 0 )
			_received.Add( "lot_bully_fight" );
		// Old / mid-empire saves: already flushing or deep in free play — skip re-intro.
		if ( data.StoryDone || data.AdventureFlushed || data.PlaytimeSeconds > 600f
			|| (data.BattleWins + data.BattleLosses >= 2
				&& (data.StoryCombinedOnce || data.AbominationsCreated > 0)) )
			_received.Add( "adventure_intro" );

		// If we seeded gifts on old saves without a claim stamp, don't force a long gap.
		if ( _lastGiftClaimPlaytime <= 0f && _received.Count > 0 )
			_lastGiftClaimPlaytime = MathF.Max( 0f, Economy.PlaytimeSeconds - DefaultLadderGap );

		// Recover: tank gift marked received but fish never spawned (pre-fix save / failed grant).
		if ( _received.Contains( "fish_tank" ) && TankSim.TotalFishCount == 0 && TankSim.IsActive )
			TankSim.GrantTrailerLotTank();

		_version++;
	}

	public static void WriteToSave( SaveData data )
	{
		if ( data is null )
			return;
		data.GrandmaGiftsReceived = _received.ToList();
		data.LastGiftClaimPlaytime = _lastGiftClaimPlaytime;
	}

	// ---- Triggers ----

	/// <summary>
	/// Call from play tick. After a little playtime, auto-fires ready gifts.
	/// </summary>
	public static void Tick( float dt )
	{
		if ( dt <= 0f || !TankSim.IsActive )
			return;

		if ( _phase == GrandmaGiftPhase.Dialogue )
		{
			TickTypewriter( dt );
			return;
		}

		if ( _phase != GrandmaGiftPhase.Idle )
			return;

		// Don't interrupt other full-screen UI.
		if ( StorySystem.CardOpen || ShopOpenGuard() || BattleSystem.IsOpen
			|| TrainingSystem.TrainOpen || TrainingSystem.InjectOpen || MethRush.Open )
			return;

		_sessionPlay += dt;
		TryAutoTrigger();

		// Safety net (once per session): claimed tank but empty glass.
		if ( !_triedTankRecovery
			&& _phase == GrandmaGiftPhase.Idle
			&& _received.Contains( "fish_tank" )
			&& TankSim.TotalFishCount == 0 )
		{
			_triedTankRecovery = true;
			TankSim.GrantTrailerLotTank();
		}
	}

	static bool ShopOpenGuard()
	{
		// MainShell owns ShopOpen — soft avoid via no static. Gifts still ok if shop closed.
		return false;
	}

	static void TryAutoTrigger()
	{
		try
		{
			foreach ( var gift in Catalog )
			{
				if ( !IsReady( gift ) )
					continue;
				BeginGift( gift );
				return;
			}
		}
		catch ( Exception e )
		{
			Log.Warning( $"[NO-CHILLquarium] GrandmaGifts trigger failed: {e.Message}" );
		}
	}

	public static bool IsReady( GrandmaGiftDef gift )
	{
		if ( gift is null || string.IsNullOrEmpty( gift.Id ) )
			return false;
		if ( _received.Contains( gift.Id ) )
			return false;
		if ( _phase != GrandmaGiftPhase.Idle )
			return false;
		if ( _sessionPlay < gift.MinPlaytime && Economy.PlaytimeSeconds < gift.MinPlaytime )
			return false;
		// Space ladder intros — player should play between unlocks.
		if ( gift.MinGapAfterPrior > 0f && _lastGiftClaimPlaytime > 0f )
		{
			if ( Economy.PlaytimeSeconds < _lastGiftClaimPlaytime + gift.MinGapAfterPrior )
				return false;
		}
		if ( !string.IsNullOrEmpty( gift.RequiresGiftId ) && !_received.Contains( gift.RequiresGiftId ) )
			return false;
		if ( gift.ExtraReady is not null )
		{
			try
			{
				if ( !gift.ExtraReady() )
					return false;
			}
			catch ( Exception e )
			{
				Log.Warning( $"[NO-CHILLquarium] GrandmaGifts ExtraReady ({gift.Id}): {e.Message}" );
				return false;
			}
		}
		return true;
	}

	/// <summary>True when skill gates pass but the play-gap is still cooling down.</summary>
	public static bool IsWaitingOnGiftGap( GrandmaGiftDef gift )
	{
		if ( gift is null || string.IsNullOrEmpty( gift.Id ) || _received.Contains( gift.Id ) )
			return false;
		if ( gift.MinGapAfterPrior <= 0f || _lastGiftClaimPlaytime <= 0f )
			return false;
		if ( Economy.PlaytimeSeconds >= _lastGiftClaimPlaytime + gift.MinGapAfterPrior )
			return false;
		// Soft check: prior gift + base playtime OK, ExtraReady not required (avoid false "waiting").
		if ( !string.IsNullOrEmpty( gift.RequiresGiftId ) && !_received.Contains( gift.RequiresGiftId ) )
			return false;
		if ( _sessionPlay < gift.MinPlaytime && Economy.PlaytimeSeconds < gift.MinPlaytime )
			return false;
		return true;
	}

	/// <summary>Force a gift (debug / story hooks). No-op if already received or busy.</summary>
	public static bool TryTrigger( string id )
	{
		if ( _phase != GrandmaGiftPhase.Idle )
			return false;
		GrandmaGiftDef gift = null;
		for ( var i = 0; i < Catalog.Count; i++ )
		{
			if ( string.Equals( Catalog[i].Id, id, StringComparison.OrdinalIgnoreCase ) )
			{
				gift = Catalog[i];
				break;
			}
		}
		if ( gift is null || _received.Contains( gift.Id ) )
			return false;
		BeginGift( gift );
		return true;
	}

	static void BeginGift( GrandmaGiftDef gift )
	{
		_active = gift;
		_phase = GrandmaGiftPhase.Dialogue;
		_line = 0;
		_visibleChars = 0;
		_typeTimer = 0f;
		_version++;
		GameAudio.PlayUi( GameAudio.Notice );
	}

	static void TickTypewriter( float dt )
	{
		if ( LineFullyTyped )
			return;
		_typeTimer += dt;
		var step = 1f / TypeCharsPerSecond;
		while ( _typeTimer >= step && !LineFullyTyped )
		{
			_typeTimer -= step;
			_visibleChars++;
			_version++;
		}
	}

	// ---- Input ----

	/// <summary>
	/// One click = real progress. Skip typewriter and advance a line,
	/// or finish the scene (claim gift) on the last line — no extra gift-modal hop.
	/// </summary>
	public static void Advance()
	{
		if ( _phase == GrandmaGiftPhase.ItemPopup )
		{
			ClaimItem();
			return;
		}

		if ( _phase != GrandmaGiftPhase.Dialogue || _active is null )
			return;

		// Always show full current line first (skip typewriter).
		if ( !LineFullyTyped )
		{
			_visibleChars = CurrentLine.Length;
			_typeTimer = 0f;
		}

		// Same click: leave this line.
		if ( !IsLastLine )
		{
			_line++;
			// Next line appears fully — fewer clicks, still readable.
			_visibleChars = CurrentLine.Length;
			_typeTimer = 0f;
			_version++;
			// No click SFX per line — it spams on every dialogue tap.
			return;
		}

		// Last line → claim immediately (skip ITEM GET modal).
		// Tank fish still spawn before HasTank is needed next frame.
		if ( _active.Prop == GrandmaGiftProp.Tank )
			TankSim.GrantTrailerLotTank();
		ClaimItem();
	}

	static void ClaimItem()
	{
		var gift = _active;
		if ( gift is null )
		{
			ClearActive();
			return;
		}

		_received.Add( gift.Id );
		// Stamp claim time so MinGapAfterPrior can breathe before the next intro.
		_lastGiftClaimPlaytime = MathF.Max( _lastGiftClaimPlaytime, Economy.PlaytimeSeconds );

		// Sitting through dialogue pays — base tip + bonus for long scenes.
		var doge = DialogueDogePayout( gift );
		var karma = gift.KarmaReward;
		if ( doge > 0 )
			Economy.Add( doge );
		if ( karma != 0f )
			Karma.Add( karma );

		try
		{
			gift.OnClaimed?.Invoke();
		}
		catch ( Exception e )
		{
			Log.Warning( $"[NO-CHILLquarium] Grandma gift claim failed ({gift.Id}): {e.Message}" );
		}

		// Banner after OnClaimed so tank/wallet grants don't bury the tip.
		if ( doge > 0 )
		{
			var tip = DialogueTipBanner( gift, doge );
			if ( !string.IsNullOrEmpty( tip ) )
				TankSim.ShowBanner( tip );
			if ( doge >= 10 )
				GameAudio.PlayDogeBark();
			else
				GameAudio.PlayCoin();
		}
		else
		{
			GameAudio.PlayUi( GameAudio.Confirm );
		}

		ClearActive();
		SaveGame.TrySave( quiet: true );
	}

	/// <summary>
	/// Ð for finishing a gift scene. Longer line counts pay more so long monologues feel worth it.
	/// </summary>
	public static double DialogueDogePayout( GrandmaGiftDef gift )
	{
		if ( gift is null )
			return 0;
		var lines = gift.Lines?.Length ?? 0;
		// Every line past 4 is a small tip (long lectures pay better).
		var longBonus = Math.Max( 0, lines - 4 ) * 1.5;
		var basePay = gift.DogeReward;
		if ( basePay <= 0 && lines > 0 )
			basePay = 2 + lines; // fallback for future gifts
		return Math.Max( 0, Math.Round( basePay + longBonus, 1 ) );
	}

	static string DialogueTipBanner( GrandmaGiftDef gift, double doge )
	{
		var pay = Economy.FormatDoge( doge );
		return gift.Prop switch
		{
			GrandmaGiftProp.Establishing => $"Chin up. +Ð{pay} in pocket change.",
			GrandmaGiftProp.Tank => $"Tank + goldfish. Lecture tip +Ð{pay}.",
			GrandmaGiftProp.Wallet => $"Cookie tin cold wallet. +Ð{pay}. Don't blow it.",
			GrandmaGiftProp.Fusion => $"Window tip. Combine unlocked. +Ð{pay}.",
			GrandmaGiftProp.Fight => $"Brad opened the garage. Seed cash +Ð{pay}.",
			GrandmaGiftProp.Adventure => $"Adventure unlocked. Monologue tip +Ð{pay}. Flush when bored.",
			_ => $"+Ð{pay} for listening."
		};
	}
}