EndingSystem.cs
namespace NoChillquarium;
public enum EndingKind
{
None,
GoodBoy, // Grandma's Pride — abom championship
BadBoy, // Your Fault — deaths / catastrophe
Mixed // Wuss — chicken out of the fight ladder
}
/// <summary>
/// Three late-game arcs:
/// <list type="bullet">
/// <item>Grandma's Pride — create aboms, fight the ladder, win Abomination Championship.</item>
/// <item>Wuss — earn Ð but refuse to fight; Grandma stops respecting you.</item>
/// <item>Your Fault — fish die (or WMD/whale meltdown); the credits blame you.</item>
/// </list>
/// </summary>
public static class EndingSystem
{
// Late-game Ð sinks — optional catastrophe keys (still valid Your Fault route).
public const double WmdCost = 8000;
/// <summary>
/// Blue whale paperwork. Estimate: ~55× old 18k sticker / ~80× WMD —
/// a real late-empire sink (championship belts + abom farms), not a weekend impulse.
/// LCD shows as 1M.
/// </summary>
public const double WhaleCost = 1_000_000;
/// <summary>~1h45 before the crate even shows as buyable.</summary>
public const float WmdUnlockPlaytime = 1.75f * 3600f;
/// <summary>~2h15 before whale paperwork.</summary>
public const float WhaleUnlockPlaytime = 2.25f * 3600f;
/// <summary>Pride: cup + aboms is the real gate; soft min playtime so it isn't instant.</summary>
public const float GoodPlaytime = 18f * 60f;
/// <summary>Your Fault needs enough neglect or the toys.</summary>
public const float BadPlaytime = 14f * 60f;
/// <summary>Wuss needs room to have chickened out for a while.</summary>
public const float MixedPlaytime = 22f * 60f;
static bool _hasWmd;
static bool _hasWhale;
static EndingKind _kind = EndingKind.None;
static bool _reached;
static float _creditsTimer;
static int _version;
static int _lineIndex;
static float _lineTimer;
// Grandma's Pride — championship + horrors (dry love, lot legend).
static readonly string[] GoodLines =
{
"Suspension form is still on the fridge. Under the championship belt photo.",
"You grew things that shouldn't swim — on her porch, under her judgment, on purpose.",
"Brad from 4B stopped talking trash. Then he asked for training tips. She laughed.",
"Every garage circuit. Every ugly combine. She called it \"character building.\"",
"The belt hangs crooked next to the flamingos. She adjusted it twice. Didn't smile once. Glowed.",
"Bingo night: \"That's my little psycho.\" Soft. Proud. The whole hall got quiet.",
"You were the problem kid. Now you're the problem with a title. She always knew.",
"GRANDMA'S PRIDE. Love, dry and mean. Best lot on the street."
};
// Your Fault — deaths / meltdown (still loves you / still blames you).
static readonly string[] BadLines =
{
"Someone left the salinity wrong. Someone is you. She already set a plate.",
"She doesn't waste protein. Wrong-water fish become supper with side eye.",
"Plate by plate she cleaned up your mess. Kissed your forehead. Called you an idiot.",
"Dogecoin graphs climbed. The trailer fridge filled with fin and quiet disappointment.",
"Brad said \"told you.\" She told Brad to eat pavement. Then she told you the truth.",
"\"Still love you,\" she says. \"Still your fault.\" Both land equally hard.",
"Neighbors heard the hugs. Neighbors heard the blame. Same porch. Same night.",
"YOUR FAULT. Leftovers in the fridge. Softness in the lecture. Never confuse the two."
};
// Wuss — never showed up to fight (love stays, respect freezes).
static readonly string[] MixedLines =
{
"You earned Ð. You fed. You rearranged porch plants like a soft little monk.",
"Fight stayed closed. Brad owned the rumor mill. She noticed. She always notices.",
"Bingo night: \"My grandkid's allergic to courage.\" Laughter. Your name. Her eyes colder.",
"She still packs snacks. She just stops packing pride in the bag.",
"Janitor mopped past and muttered \"shame.\" Even he had an opinion.",
"Respect left the lot. Love stayed. It sat on the porch like unpaid rent.",
"You weren't a villain. You just never showed up. On this lot, that's worse.",
"WUSS. The park knows. She still locks the door for you. She just doesn't brag."
};
public static int Version => _version;
public static bool HasWmd => _hasWmd;
public static bool HasWhale => _hasWhale;
public static bool Reached => _reached;
public static EndingKind Kind => _kind;
public static float CreditsTimer => _creditsTimer;
public static string CurrentLine
{
get
{
var lines = LinesFor( _kind );
if ( lines.Length == 0 )
return "";
var i = Math.Clamp( _lineIndex, 0, lines.Length - 1 );
return lines[i];
}
}
public static int LineIndex => _lineIndex;
public static int LineCount => LinesFor( _kind ).Length;
public static string Title => _kind switch
{
EndingKind.GoodBoy => "GRANDMA'S PRIDE",
EndingKind.BadBoy => "YOUR FAULT",
EndingKind.Mixed => "WUSS",
_ => ""
};
public static string Subtitle => _kind switch
{
EndingKind.GoodBoy => "ENDING · PRIDE",
EndingKind.BadBoy => "ENDING · BLAME",
EndingKind.Mixed => "ENDING · SHAME",
_ => ""
};
/// <summary>One-line thesis under the title.</summary>
public static string Tagline => _kind switch
{
EndingKind.GoodBoy => "Belt. Aboms. Bingo brags. She always knew.",
EndingKind.BadBoy => "Supper. Hugs. Still your fault.",
EndingKind.Mixed => "Love stayed. Respect left the lot.",
_ => ""
};
public static string ProgressLabel
{
get
{
var n = LineCount;
if ( n <= 0 )
return "";
return (LineIndex + 1) + "/" + n;
}
}
/// <summary>Dock button labels.</summary>
public static string GoodButtonLabel => "Grandma's Pride";
public static string BadButtonLabel => "Your Fault";
public static string MixedButtonLabel => "Wuss";
static string[] LinesFor( EndingKind k ) => k switch
{
EndingKind.GoodBoy => GoodLines,
EndingKind.BadBoy => BadLines,
EndingKind.Mixed => MixedLines,
_ => Array.Empty<string>()
};
public static void Reset()
{
_hasWmd = false;
_hasWhale = false;
_kind = EndingKind.None;
_reached = false;
_creditsTimer = 0f;
_lineIndex = 0;
_lineTimer = 0f;
_version++;
}
public static void Load( bool wmd, bool whale, string endingId )
{
_hasWmd = wmd;
_hasWhale = whale;
_reached = false;
_kind = EndingKind.None;
_creditsTimer = 0f;
_lineIndex = 0;
// Don't auto-replay ending on continue — flags only.
_version++;
}
public static void WriteToSave( SaveData data )
{
if ( data is null )
return;
data.HasWmd = _hasWmd;
data.HasWhale = _hasWhale;
data.EndingReached = _reached ? _kind.ToString() : "";
}
// ---- Shop ----
public static string WmdUnlockText
{
get
{
if ( Economy.PlaytimeSeconds >= WmdUnlockPlaytime )
return "Available";
var left = WmdUnlockPlaytime - Economy.PlaytimeSeconds;
return $"Locked {FormatLeft( left )}";
}
}
public static string WhaleUnlockText
{
get
{
if ( Economy.PlaytimeSeconds >= WhaleUnlockPlaytime )
return "Available";
var left = WhaleUnlockPlaytime - Economy.PlaytimeSeconds;
return $"Locked {FormatLeft( left )}";
}
}
public static bool CanBuyWmd() =>
TankSim.IsActive
&& !_hasWmd
&& !_reached
&& Economy.PlaytimeSeconds >= WmdUnlockPlaytime
&& Economy.Balance >= WmdCost;
public static bool CanBuyWhale() =>
TankSim.IsActive
&& !_hasWhale
&& !_reached
&& Economy.PlaytimeSeconds >= WhaleUnlockPlaytime
&& Economy.Balance >= WhaleCost;
public static bool TryBuyWmd()
{
if ( _hasWmd )
{
Shop.FailPublic( "Already own a WMD." );
return false;
}
if ( Economy.PlaytimeSeconds < WmdUnlockPlaytime )
{
Shop.FailPublic( WmdUnlockText );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( !Economy.TrySpend( WmdCost ) )
{
GameAudio.PlayUi( GameAudio.Error );
return false;
}
_hasWmd = true;
Chaos.Add( 18f );
Karma.Add( -20f );
GameAudio.PlayUi( GameAudio.Upgrade );
TankSim.ShowBanner( "WMD acquired. Grandma: \"…cute. Don't. Sit. Think about cookies instead.\"" );
SaveGame.TrySave();
_version++;
return true;
}
public static bool TryBuyWhale()
{
if ( _hasWhale )
{
Shop.FailPublic( "Already own a whale." );
return false;
}
if ( Economy.PlaytimeSeconds < WhaleUnlockPlaytime )
{
Shop.FailPublic( WhaleUnlockText );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( !Economy.TrySpend( WhaleCost ) )
{
GameAudio.PlayUi( GameAudio.Error );
return false;
}
_hasWhale = true;
Chaos.Add( 12f );
Karma.Add( -8f );
TankSim.ShowBanner( $"Blue whale acquired. Grandma: \"Fancy. Don't put it in the bath. I am serious about the bath.\"" );
GameAudio.PlayWhale();
SaveGame.TrySave();
_version++;
return true;
}
// ---- Readiness ----
/// <summary>
/// Late-game thriving: real collection, both water types, no junkie crash-out.
/// </summary>
public static bool ThrivingTank =>
TankSim.IsActive
&& TankSim.FishCount >= 8
&& TankSim.FishCount >= TankSim.Capacity * 0.3f
&& TankSim.AddictedFishCount == 0
&& TankSim.WithdrawingFishCount == 0
&& TankSim.SaltUnlocked
&& TankSim.HabitatRank >= 2;
/// <summary>Grandma's Pride — win the abom cup with a body of work.</summary>
public static bool CanGoodBoy
{
get
{
if ( _reached || !TankSim.IsActive )
return false;
if ( Economy.PlaytimeSeconds < GoodPlaytime )
return false;
if ( BattleSystem.ChampionshipTitles < 1 )
return false;
if ( TankSim.AbominationsCreated < 1 && !TankSim.HasAnyMonster )
return false;
if ( Karma.Value < 42f )
return false;
// Pure pride path: not a death spiral.
if ( TankSim.FishDeaths >= 15 )
return false;
return true;
}
}
/// <summary>Your Fault — neglect deaths or full catastrophe kit.</summary>
public static bool CanBadBoy
{
get
{
if ( _reached || !TankSim.IsActive )
return false;
if ( Economy.PlaytimeSeconds < BadPlaytime )
return false;
// Primary: enough dead fish that Grandma's lecture lands.
if ( TankSim.FishDeaths >= 4 && Karma.Value <= 42f )
return true;
// Shark buffet counts as catastrophe.
if ( SharkSystem.Eaten >= 3 && TankSim.FishDeaths >= 2 && Karma.Value <= 45f )
return true;
// Alternate: WMD + whale meltdown (legacy no-chill toys).
if ( _hasWmd && _hasWhale && Economy.PlaytimeSeconds >= BadPlaytime * 1.5f )
{
if ( Chaos.Value >= 40f || Karma.Value <= 32f )
return true;
}
// Porch crime spree — kick, rob, dose, spike, offer. Grandma noticed.
if ( CrimeSystem.Score >= 6 && Karma.Value <= 40f )
return true;
return false;
}
}
/// <summary>Wuss — had the money, never fought, Grandma cooled off.</summary>
public static bool CanMixed
{
get
{
if ( _reached || !TankSim.IsActive )
return false;
if ( Economy.PlaytimeSeconds < MixedPlaytime )
return false;
if ( CanGoodBoy || CanBadBoy )
return false;
// Never (or barely) fought while rich enough to have had the chance.
if ( BattleSystem.Wins + BattleSystem.Losses >= 3 )
return false;
if ( BattleSystem.ChampionshipTitles > 0 )
return false;
if ( Economy.LifetimeEarned < 120 )
return false;
// She stopped respecting you.
if ( Karma.Value > 38f )
return false;
return TankSim.FishCount >= 3;
}
}
public static string GoodHint
{
get
{
if ( CanGoodBoy )
return "Ready · claim pride (dock)";
if ( Economy.PlaytimeSeconds < GoodPlaytime )
return $"Play {FormatLeft( GoodPlaytime - Economy.PlaytimeSeconds )} more";
if ( TankSim.AbominationsCreated < 1 && !TankSim.HasAnyMonster )
return "Combine · make something ugly";
if ( BattleSystem.ChampionshipTitles < 1 )
return "Win Abomination Championship";
if ( Karma.Value < 42f )
return $"{Karma.MeterName} {Karma.ValueText}/42 · fight + aboms";
if ( TankSim.FishDeaths >= 15 )
return "Too many funerals for pride";
return "Almost proud…";
}
}
public static string BadHint
{
get
{
if ( CanBadBoy )
return "Ready · own the blame (dock)";
if ( Economy.PlaytimeSeconds < BadPlaytime )
return $"Play {FormatLeft( BadPlaytime - Economy.PlaytimeSeconds )} more";
if ( TankSim.FishDeaths < 4 && SharkSystem.Eaten < 3 && CrimeSystem.Score < 6 )
return $"Deaths {TankSim.FishDeaths}/4 · or porch crimes {CrimeSystem.Score}/6 · sharks / WMD";
if ( Karma.Value > 42f )
return $"{Karma.MeterName} still fond ({Karma.ValueText}) · more mess";
return "Almost your fault…";
}
}
public static string MixedHint
{
get
{
if ( CanMixed )
return "Ready · accept the shame (dock)";
if ( Economy.PlaytimeSeconds < MixedPlaytime )
return $"Play {FormatLeft( MixedPlaytime - Economy.PlaytimeSeconds )} more";
if ( CanGoodBoy || CanBadBoy )
return "Louder ending is open · pride or blame";
if ( BattleSystem.Wins + BattleSystem.Losses >= 3 )
return "You fought · not a wuss path";
if ( Economy.LifetimeEarned < 120 )
return "Earn Ð while chicken · then cool her off";
if ( Karma.Value > 38f )
return $"Hide from fights · {Karma.MeterName} {Karma.ValueText}→≤38";
if ( TankSim.FishCount < 3 )
return "Keep a few fish alive · soft monk mode";
return "Stay out of Brad's garage";
}
}
/// <summary>0–1 rough progress toward the loudest open-ish ending (for coach).</summary>
public static float NearestEndingProgress
{
get
{
if ( CanGoodBoy || CanBadBoy || CanMixed )
return 1f;
var best = 0f;
// Pride: cup + aboms + karma + time
{
var t = Math.Clamp( Economy.PlaytimeSeconds / GoodPlaytime, 0f, 1f ) * 0.2f;
var cup = BattleSystem.ChampionshipTitles > 0 ? 0.45f : 0f;
var abom = (TankSim.AbominationsCreated > 0 || TankSim.HasAnyMonster) ? 0.2f : 0f;
var k = Math.Clamp( Karma.Value / 42f, 0f, 1f ) * 0.15f;
best = Math.Max( best, t + cup + abom + k );
}
// Blame
{
var t = Math.Clamp( Economy.PlaytimeSeconds / BadPlaytime, 0f, 1f ) * 0.25f;
var d = Math.Clamp( TankSim.FishDeaths / 4f, 0f, 1f ) * 0.45f;
var k = Math.Clamp( (50f - Karma.Value) / 50f, 0f, 1f ) * 0.3f;
var c = Math.Clamp( CrimeSystem.Score / 6f, 0f, 1f ) * 0.2f;
best = Math.Max( best, t + d + k + c );
}
// Wuss
{
var t = Math.Clamp( Economy.PlaytimeSeconds / MixedPlaytime, 0f, 1f ) * 0.35f;
var e = Math.Clamp( (float)(Economy.LifetimeEarned / 120.0), 0f, 1f ) * 0.35f;
var noFight = BattleSystem.Wins + BattleSystem.Losses < 3 ? 0.2f : 0f;
var k = Karma.Value <= 38f ? 0.1f : 0f;
best = Math.Max( best, t + e + noFight + k );
}
return Math.Clamp( best, 0f, 1f );
}
}
/// <summary>Short product line for WMD shop row (not full ending checklist).</summary>
public static string WmdShopNote =>
_hasWmd ? "owned" : Economy.PlaytimeSeconds < WmdUnlockPlaytime ? WmdUnlockText : "catastrophe key";
/// <summary>Short product line for whale shop row.</summary>
public static string WhaleShopNote =>
_hasWhale ? "owned" : Economy.PlaytimeSeconds < WhaleUnlockPlaytime ? WhaleUnlockText : "catastrophe key";
/// <summary>One-line arc summary for shop.</summary>
public static string ArcTip =>
"Pride · Wuss · Your Fault — dock when Ready";
static string FormatLeft( float seconds )
{
if ( seconds < 0f )
seconds = 0f;
var totalMin = (int)(seconds / 60f);
if ( totalMin >= 60 )
{
var h = totalMin / 60;
var m = totalMin % 60;
return m > 0 ? $"{h}h {m}m" : $"{h}h";
}
var s = (int)(seconds % 60f);
return totalMin > 0 ? $"{totalMin}m {s:00}s" : $"{s}s";
}
public static bool TryStartGood()
{
if ( !CanGoodBoy )
{
TankSim.ShowBanner( GoodHint );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
return Begin( EndingKind.GoodBoy );
}
public static bool TryStartBad()
{
if ( !CanBadBoy )
{
TankSim.ShowBanner( BadHint );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
return Begin( EndingKind.BadBoy );
}
public static bool TryStartMixed()
{
if ( !CanMixed )
{
TankSim.ShowBanner( MixedHint );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
return Begin( EndingKind.Mixed );
}
static bool Begin( EndingKind kind )
{
_kind = kind;
_reached = true;
_lineIndex = 0;
_lineTimer = 0f;
_creditsTimer = 0f;
_version++;
BattleSystem.Clear();
LotRunSystem.Clear();
ExplodeSystem.Clear();
TrainingSystem.CancelInject();
TrainingSystem.CancelTrain();
SharkSystem.Clear();
FishFusion.Clear();
DogeFloat.Clear();
StatFloat.Clear();
DarkWeb.Clear();
GrandmaGifts.Clear();
GrandmaReact.Clear();
GameFlow.SetScreen( GameScreen.Ending );
if ( kind == EndingKind.GoodBoy )
GameAudio.StartMusicChillEnding();
else
GameAudio.StartMusicMetalEnding( kind == EndingKind.BadBoy );
// Closing tip so the claim always *pays* (love is free; the wallet still notices).
var tip = kind switch
{
EndingKind.GoodBoy => 120.0,
EndingKind.BadBoy => 80.0,
_ => 50.0
};
Economy.AddForced( tip );
if ( kind == EndingKind.GoodBoy )
GameAudio.PlayBigWin( bark: true );
else if ( kind == EndingKind.BadBoy )
GameAudio.PlayLayered( GameAudio.M80, GameAudio.AlertWarning, 0.4f );
else
GameAudio.PlayLayered( GameAudio.Close, GameAudio.Notice, 0.5f );
var openBanner = kind switch
{
EndingKind.GoodBoy => "Grandma's Pride. Sit. Listen. +Ð" + Economy.FormatDoge( tip ),
EndingKind.BadBoy => "Your Fault. She already knows. +Ð" + Economy.FormatDoge( tip ),
_ => "Wuss. The lot already knows. +Ð" + Economy.FormatDoge( tip )
};
Log.Info( $"[NO-CHILLquarium] Ending: {kind} · {openBanner}" );
SaveGame.TrySave();
return true;
}
public static void Tick( float dt )
{
if ( !_reached || GameFlow.Screen != GameScreen.Ending )
return;
if ( dt <= 0f )
return;
if ( dt > 0.05f )
dt = 0.05f;
_creditsTimer += dt;
_lineTimer += dt;
var lines = LinesFor( _kind );
if ( lines.Length == 0 )
return;
// Readable but snappier — click still skips.
if ( _lineTimer >= 2.8f && _lineIndex < lines.Length - 1 )
{
_lineTimer = 0f;
_lineIndex++;
_version++;
GameAudio.PlayUi( GameAudio.Notice );
}
}
public static void AdvanceLine()
{
if ( !_reached )
return;
var lines = LinesFor( _kind );
if ( _lineIndex < lines.Length - 1 )
{
_lineIndex++;
_lineTimer = 0f;
_version++;
GameAudio.PlayLayered( GameAudio.Pop, GameAudio.Notice, 0.35f );
}
}
public static bool LinesFinished =>
_reached && _lineIndex >= LinesFor( _kind ).Length - 1;
public static void FinishToMenu()
{
GameAudio.PlayUi( GameAudio.Confirm );
GameAudio.StopMusic();
// Keep save with ending flags; clear live tank for menu.
FeedSystem.Clear();
ExplodeSystem.Clear();
BattleSystem.Clear();
SharkSystem.Clear();
DeathFeel.Clear();
BoomFeel.Clear();
FusionFeel.Clear();
BuyReveal.Clear();
PixelFx.Clear();
FishFusion.Clear();
GrandmaGifts.Clear();
GrandmaReact.Clear();
TankSim.Clear();
_reached = false; // allow new game; flags stay in save until NewGame resets
_kind = EndingKind.None;
_lineIndex = 0;
_lineTimer = 0f;
_creditsTimer = 0f;
GameFlow.SetScreen( GameScreen.MainMenu );
GameAudio.StartMenuMusic();
_version++;
}
}