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.
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++;
}