StorySystem.cs

Tutorial/story system for the game. Defines StoryComplete enum, StoryBeat data, and a static StorySystem that tracks tutorial beats, progress, state, and transitions, evaluates world conditions, awards rewards on first actions, and reads/writes story state to SaveData.

File AccessNetworking
namespace NoChillquarium;

/// <summary>How a story beat finishes.</summary>
public enum StoryComplete
{
	/// <summary>Player hits Continue on the card.</summary>
	Dismiss,
	/// <summary>Own Fish Flakes (or any food).</summary>
	HasFood,
	/// <summary>Fed the tank at least once this save.</summary>
	FedOnce,
	/// <summary>At least 3 living fish across tanks.</summary>
	FishCount3,
	/// <summary>Capacity above starter glass, or habitat upgraded.</summary>
	CapacityUp,
	/// <summary>Finished one train session.</summary>
	TrainedOnce,
	/// <summary>Fought at least once (win or loss).</summary>
	FoughtOnce,
	/// <summary>Combined / evolved at least once.</summary>
	CombinedOnce,
	/// <summary>Owns salt tank.</summary>
	SaltUnlocked,
	/// <summary>Bought any shady inventory.</summary>
	ShadyBought,
	/// <summary>Detonated at least one M80.</summary>
	BoomOnce,
	/// <summary>Bought next habitat past starter.</summary>
	HabitatUp
}

/// <summary>One short onboarding tip. Body = one line. Objective = what to do.</summary>
public sealed class StoryBeat
{
	public string Id { get; init; }
	public string Chapter { get; init; }
	public string Title { get; init; }
	/// <summary>One short line. No essays.</summary>
	public string Body { get; init; }
	/// <summary>Quest bar action line.</summary>
	public string Objective { get; init; }
	/// <summary>Context hint while card is closed.</summary>
	public string Hint { get; init; }
	public StoryComplete Complete { get; init; }
	public float KarmaOnComplete { get; init; }
	public float ChaosOnComplete { get; init; }
	public double DogeReward { get; init; }
	public string CompleteBanner { get; init; }
}

/// <summary>
/// Lightweight tutorial. Teach systems fast; no lore wall.
/// </summary>
public static class StorySystem
{
	static int _index;
	static bool _done;
	static bool _cardOpen;
	static bool _fedOnce;
	static bool _trainedOnce;
	static bool _boomOnce;
	static bool _combinedOnce;
	static bool _soldOnce;
	static int _version;

	/// <summary>Catalog revision — bump when beat order/text changes so UI rebuilds cleanly.</summary>
	public const int CatalogRevision = 19;

	public static int Version => _version + CatalogRevision * 1000;
	public static bool IsDone => _done;
	public static bool CardOpen => _cardOpen && !_done;
	public static int Index => _index;
	/// <summary>True after the player has fed the tank at least once this save.</summary>
	public static bool HasFedOnce => _fedOnce;
	/// <summary>True after one completed train set with at least one hit.</summary>
	public static bool HasTrainedOnce => _trainedOnce;
	/// <summary>True after one M80 detonation.</summary>
	public static bool HasBoomOnce => _boomOnce;
	/// <summary>True after one successful combine / evolve.</summary>
	public static bool HasCombinedOnce => _combinedOnce;
	/// <summary>True after selling at least one fish.</summary>
	public static bool HasSoldOnce => _soldOnce;

	/// <summary>True if story index is past the beat id (or story done).</summary>
	public static bool HasPassedBeat( string beatId )
	{
		if ( _done )
			return true;
		if ( string.IsNullOrEmpty( beatId ) )
			return false;
		for ( var i = 0; i < Catalog.Count && i < _index; i++ )
		{
			if ( Catalog[i].Id == beatId )
				return true;
		}
		return false;
	}

	/// <summary>Short tips in play order. Do next = the only thing that matters.</summary>
	public static IReadOnlyList<StoryBeat> Catalog { get; } = new[]
	{
		// After tank gift — care first, power ladder later.
		new StoryBeat
		{
			Id = "grandma",
			Chapter = "Lot",
			Title = "Please Grandma",
			Body = "She roasted you, then gifted a tank. That's love. Don't drown it.",
			Objective = "Continue",
			Hint = "Continue",
			Complete = StoryComplete.Dismiss,
			KarmaOnComplete = 3f,
			CompleteBanner = "She believes in you."
		},
		new StoryBeat
		{
			Id = "buy_food",
			Chapter = "Lot",
			Title = "Food ready",
			Body = "Grandma left Fish Flakes. Tools → Feed. Hungry fish, louder Grandma.",
			Objective = "Tools → Feed (flakes ready)",
			Hint = "Tools → Feed",
			// Auto-clears if flakes already granted with the tank.
			Complete = StoryComplete.HasFood,
			DogeReward = 3,
			CompleteBanner = "Flakes ready. Feed them."
		},
		new StoryBeat
		{
			Id = "feed",
			Chapter = "Lot",
			Title = "Feed them",
			Body = "Tools → Feed. Watch them grow. Ð ticks up. That's the loop.",
			Objective = "Tools → Feed",
			Hint = "Tools → Feed",
			Complete = StoryComplete.FedOnce,
			KarmaOnComplete = 1f,
			CompleteBanner = "Fed. Buy a guppy or keep mashing feed."
		},
		new StoryBeat
		{
			Id = "buy_fish",
			Chapter = "Lot",
			Title = "More fish",
			Body = "Shop → Fish. Cheap guppies grow fast; rares earn more Ð.",
			Objective = "Own 3+ fish",
			Hint = "Shop → Fish",
			Complete = StoryComplete.FishCount3,
			DogeReward = 6,
			CompleteBanner = "Glass less lonely. Grow or sell."
		},
		new StoryBeat
		{
			Id = "sell_tip",
			Chapter = "Grow",
			Title = "Grow & sell",
			Body = "Fish menu → Sell. Cash grown commons; keep rares for passive Ð.",
			Objective = "Continue",
			Hint = "Click fish → Sell",
			Complete = StoryComplete.Dismiss,
			CompleteBanner = "Sell commons. Keep rares. Feed is XP."
		},
		new StoryBeat
		{
			Id = "train",
			Chapter = "Power",
			Title = "Train",
			Body = "Fish menu → Train. Free set. Stats help fights and Adventure.",
			Objective = "Click fish → Train",
			Hint = "Click fish → Train",
			Complete = StoryComplete.TrainedOnce,
			DogeReward = 8,
			KarmaOnComplete = 2f,
			CompleteBanner = "Trained. Fight money lands next."
		},
		// Ladder: Combine (janitor) → Fight (Brad) → Adventure (flush last).
		new StoryBeat
		{
			Id = "combine",
			Chapter = "Power",
			Title = "Combine",
			Body = "Janitor tip: fish menu → Combine. Two fish → abom. Birth pays Ð.",
			Objective = "Combine once",
			Hint = "Click fish → Combine",
			Complete = StoryComplete.CombinedOnce,
			DogeReward = 8,
			KarmaOnComplete = 2f,
			CompleteBanner = "Ugly on purpose. Birth paid."
		},
		new StoryBeat
		{
			Id = "fight",
			Chapter = "Power",
			Title = "Fight",
			Body = "Brad's garage (4B). Entry fee → ~2×+ purse. Loss = scrap + injury.",
			Objective = "Fight once",
			Hint = "Click fish → Fight",
			Complete = StoryComplete.FoughtOnce,
			DogeReward = 12,
			KarmaOnComplete = 3f,
			CompleteBanner = "Grandma: \"Brad who?\""
		},
		new StoryBeat
		{
			Id = "adventure",
			Chapter = "Power",
			Title = "Adventure",
			Body = "Last unlock: fish menu → Flush. Trained fish win more. Die → faucet home hurt.",
			Objective = "Continue",
			Hint = "Click fish → Flush",
			Complete = StoryComplete.Dismiss,
			DogeReward = 8,
			CompleteBanner = "Flush when the monologue hits."
		},
		new StoryBeat
		{
			Id = "capacity",
			Chapter = "Expand",
			Title = "More space",
			Body = "Full glass? Shop → Tank → +capacity.",
			Objective = "Shop → +capacity",
			Hint = "Shop → Tank",
			Complete = StoryComplete.CapacityUp,
			DogeReward = 6,
			CompleteBanner = "More room. Fill it."
		},
		new StoryBeat
		{
			Id = "salt",
			Chapter = "Expand",
			Title = "Salt tank",
			Body = "Shop → Tank → salt. Wrong water kills — she calls that supper.",
			Objective = "Buy salt tank",
			Hint = "Shop → Tank → Salt",
			Complete = StoryComplete.SaltUnlocked,
			CompleteBanner = "Salt ready. Match water."
		},
		new StoryBeat
		{
			Id = "habitat",
			Chapter = "Expand",
			Title = "Bigger habitat",
			Body = "Shop → Tank → habitat. Bigger glass, same porch.",
			Objective = "Upgrade habitat",
			Hint = "Shop → Tank → habitat",
			Complete = StoryComplete.HabitatUp,
			CompleteBanner = "Habitat upgraded."
		},
		new StoryBeat
		{
			Id = "shady",
			Chapter = "Optional",
			Title = "Shady",
			Body = "Shop → Shady → M80. Scrap weak aboms for Ð. Waste good ones and she cools.",
			Objective = "Continue",
			Hint = "Shop → Shady",
			Complete = StoryComplete.Dismiss,
			DogeReward = 4,
			CompleteBanner = "Boom carefully."
		},
		new StoryBeat
		{
			Id = "done",
			Chapter = "Done",
			Title = "Make her proud",
			Body = "No checklist. Grow, invent, fight, flush. Your pace.",
			Objective = "Free play",
			Hint = "",
			Complete = StoryComplete.Dismiss,
			CompleteBanner = "Porch is yours."
		}
	};

	public static StoryBeat Current =>
		_done || _index < 0 || _index >= Catalog.Count ? null : Catalog[_index];

	public static string ChapterLabel => Current?.Chapter ?? "";
	public static string CardTitle => Current?.Title ?? "";
	public static string CardBody => Current?.Body ?? "";
	public static string ObjectiveText =>
		_done ? "" : (Current?.Objective ?? "");
	public static string QuestHint =>
		_done || _cardOpen ? "" : (Current?.Hint ?? "");

	public static string ProgressText
	{
		get
		{
			if ( _done )
				return "Story complete";
			// Prologue (establishing / pre-tank) — no 1/N pill yet.
			if ( _index < 0 )
				return "";
			var n = Math.Clamp( _index + 1, 1, Catalog.Count );
			return $"{n}/{Catalog.Count}";
		}
	}

	public static void ResetForNewGame()
	{
		// Hold story off until OpenFirstCardAfterPrologue (after fish_tank gift).
		// Otherwise the quest bar shows "1/13 Continue · make her proud" on boot.
		_index = -1;
		_done = false;
		// Card stays closed until fish_tank gift is claimed.
		_cardOpen = false;
		_fedOnce = false;
		_trainedOnce = false;
		_boomOnce = false;
		_combinedOnce = false;
		_soldOnce = false;
		Bump();
	}

	/// <summary>After fish_tank gift — start care loop without stacking another modal.</summary>
	public static void OpenFirstCardAfterPrologue()
	{
		if ( _done )
			return;
		// Grandma's love was the tank speech — skip the redundant dismiss card.
		_index = 0;
		_cardOpen = false;
		CompleteCurrent( silent: true ); // → buy_food, no popup
	}

	/// <summary>Wallet gift claimed via GrandmaGifts — no purple card needed.</summary>
	public static void NotifyWalletGiftClaimed()
	{
		// If tutorial still sitting on a removed "wallet" index from old saves, nudge forward.
		if ( Current?.Id == "wallet" )
			CompleteCurrent( silent: true );
		Bump();
	}

	public static void Clear()
	{
		_index = 0;
		_done = true;
		_cardOpen = false;
		_fedOnce = false;
		_trainedOnce = false;
		_boomOnce = false;
		_combinedOnce = false;
		_soldOnce = false;
		Bump();
	}

	public static void Load( SaveData data )
	{
		if ( data is null )
		{
			Clear();
			return;
		}

		_fedOnce = data.StoryFedOnce;
		_trainedOnce = data.StoryTrainedOnce;
		_boomOnce = data.StoryBoomOnce;
		_combinedOnce = data.StoryCombinedOnce;
		_soldOnce = data.StorySoldOnce;
		_done = data.StoryDone;
		// -1 = pre-tank prologue (no quest pill). Clamp only non-negative.
		_index = data.StoryIndex < 0
			? -1
			: Math.Clamp( data.StoryIndex, 0, Catalog.Count );
		_cardOpen = false;

		// Old saves: no story fields → free play (don't force tutorial mid-empire).
		if ( data.Version < 6 && string.IsNullOrEmpty( data.StoryBeatId ) && data.PlaytimeSeconds > 30f )
		{
			_done = true;
			_index = Catalog.Count;
			_cardOpen = false;
			// Treat mid-empire loads as having practiced core menus.
			_fedOnce = true;
			_trainedOnce = true;
			_combinedOnce = true;
			_soldOnce = true;
			Bump();
			return;
		}

		// Pre-v10 empires that already progressed past early tutorial shouldn't re-lock menus.
		if ( data.Version < 10 && ( _done || data.PlaytimeSeconds > 120f || data.LifetimeEarned > 80 ) )
		{
			if ( FeedSystem.HasAnyFood || _fedOnce )
				_fedOnce = true;
			if ( data.BattleWins + data.BattleLosses > 0 )
				_trainedOnce = true; // fought implies they could have trained path; soft-unlock fight
			if ( data.AbominationsCreated > 0 )
				_combinedOnce = true;
		}

		if ( _done || _index >= Catalog.Count )
		{
			_done = true;
			_index = Catalog.Count;
			_cardOpen = false;
		}
		else
		{
			// Skip removed "wallet" beat ids from older saves.
			if ( Current?.Id == "wallet" )
				_index = Math.Min( _index + 1, Catalog.Count );
			if ( _index >= Catalog.Count )
			{
				_done = true;
				_cardOpen = false;
			}
			else
				_cardOpen = Current?.Complete == StoryComplete.Dismiss;
		}

		Bump();
	}

	public static void WriteToSave( SaveData data )
	{
		if ( data is null )
			return;
		data.StoryIndex = _index;
		data.StoryDone = _done;
		data.StoryFedOnce = _fedOnce;
		data.StoryTrainedOnce = _trainedOnce;
		data.StoryBoomOnce = _boomOnce;
		data.StoryCombinedOnce = _combinedOnce;
		data.StorySoldOnce = _soldOnce;
		data.StoryBeatId = Current?.Id ?? ( _done ? "done" : "" );
	}

	public static void DismissCard()
	{
		if ( !CardOpen )
			return;

		var beat = Current;
		_cardOpen = false;
		GameAudio.PlayUi( GameAudio.Confirm );

		if ( beat is not null && beat.Complete == StoryComplete.Dismiss )
			CompleteCurrent( silent: false );
		else
			Bump();
	}

	/// <summary>Re-read the current beat (quest log).</summary>
	public static void OpenCard()
	{
		if ( _done || Current is null )
			return;
		if ( GrandmaGifts.IsBusy )
			return;
		_cardOpen = true;
		GameAudio.PlayUi( GameAudio.Notice );
		Bump();
	}

	public static void NotifyFed()
	{
		if ( _fedOnce )
			return;
		_fedOnce = true;
		// One payout for first feed (opens Train + Combine). Story beat does not double-pay.
		Economy.AddForced( 6 );
		Karma.Add( 1f );
		GrandmaReact.OnFirstFeed();
		Bump();
		Evaluate();
	}

	public static void NotifyTrained()
	{
		if ( _trainedOnce )
			return;
		_trainedOnce = true;
		// One payout for first gym set — should cover first circuit entry when saved.
		Economy.AddForced( 12 );
		Karma.Add( 2f );
		// System tip first; Grandma's dry love follows when UI is free.
		if ( BattleSystem.CanAffordAnyFight() )
			TankSim.ShowBanner( "Fight unlocked on the fish menu." );
		else
			TankSim.ShowBanner( "Trained. Fight opens when you can pay entry." );
		GrandmaReact.OnFirstTrain();
		Bump();
		Evaluate();
	}

	public static void NotifyBoom()
	{
		if ( _boomOnce )
			return;
		_boomOnce = true;
		Economy.AddForced( 5 );
		Bump();
		Evaluate();
	}

	public static void NotifyCombined()
	{
		if ( _combinedOnce )
			return;
		_combinedOnce = true;
		// First abom — Shady scrap path opens (single payout).
		Economy.AddForced( 10 );
		Karma.Add( 2f );
		TankSim.ShowBanner( "Shady tab unlocked · scrap failures with M80." );
		GrandmaReact.OnFirstAbom();
		Bump();
		Evaluate();
	}

	public static void NotifySold()
	{
		if ( _soldOnce )
			return;
		_soldOnce = true;
		// Small tip only — sell already pays market price.
		Economy.AddForced( 3 );
		Bump();
		Evaluate();
	}

	/// <summary>Poll world state; advance when the active objective is met.</summary>
	public static void Evaluate()
	{
		if ( _done || _cardOpen )
			return;
		// Prologue (pre-tank) — index -1 means "not started", not "story finished".
		if ( _index < 0 )
			return;

		var guard = 0;
		while ( !_done && !_cardOpen && guard++ < 12 )
		{
			var beat = Current;
			if ( beat is null )
			{
				_done = true;
				Bump();
				return;
			}

			if ( beat.Complete == StoryComplete.Dismiss )
				return;

			if ( !IsSatisfied( beat.Complete ) )
				return;

			CompleteCurrent( silent: false );
		}
	}

	public static void Tick( float dt )
	{
		if ( dt <= 0f || !TankSim.IsActive )
			return;
		Evaluate();
	}

	static bool IsSatisfied( StoryComplete rule ) => rule switch
	{
		StoryComplete.Dismiss => false,
		StoryComplete.HasFood => FeedSystem.HasAnyFood,
		StoryComplete.FedOnce => _fedOnce,
		StoryComplete.FishCount3 => TankSim.TotalFishCount >= 3,
		StoryComplete.CapacityUp =>
			TankSim.Capacity > HabitatDef.StarterFresh.BaseCapacity
			|| TankSim.HabitatRank > 0,
		StoryComplete.TrainedOnce => _trainedOnce,
		StoryComplete.FoughtOnce => BattleSystem.Wins + BattleSystem.Losses > 0,
		StoryComplete.CombinedOnce => _combinedOnce,
		StoryComplete.SaltUnlocked => TankSim.SaltUnlocked,
		StoryComplete.ShadyBought =>
			Shop.MethBags > 0 || Shop.M80Bags > 0 || TrainingSystem.Syringes > 0,
		StoryComplete.BoomOnce => _boomOnce,
		StoryComplete.HabitatUp => TankSim.HabitatRank > 0,
		_ => false
	};

	static void CompleteCurrent( bool silent )
	{
		var beat = Current;
		if ( beat is null )
		{
			_done = true;
			_cardOpen = false;
			Bump();
			return;
		}

		if ( beat.KarmaOnComplete != 0f )
			Karma.Add( beat.KarmaOnComplete );
		if ( beat.ChaosOnComplete != 0f )
			Chaos.Add( beat.ChaosOnComplete );
		if ( beat.DogeReward > 0 )
			Economy.Add( beat.DogeReward );

		if ( !silent && !string.IsNullOrWhiteSpace( beat.CompleteBanner ) )
			TankSim.ShowBanner( beat.CompleteBanner );

		if ( !silent )
			GameAudio.PlayUi( GameAudio.Upgrade );

		_index++;
		if ( _index >= Catalog.Count )
		{
			_done = true;
			_cardOpen = false;
		}
		else
		{
			// Don't chain the next card immediately — let them explore the porch.
			// Quest pill holds the objective; click it to re-read when ready.
			_cardOpen = false;
			if ( !silent )
			{
				var next = Current;
				if ( next is not null && !string.IsNullOrWhiteSpace( next.Objective )
					&& next.Complete != StoryComplete.Dismiss )
				{
					// Soft nudge only — not a modal.
					TankSim.ShowBanner( "Next: " + next.Objective );
				}
			}
		}

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

	static void Bump() => _version++;
}