CrimeSystem.cs

Game static system managing "porch crimes" and player-triggered misdemeanors. Tracks state (kicks, offers, robberies, doses, spikes, ransacks, loot), exposes getters for availability, persists to SaveData, advances chaos/karmic effects, plays audio/FX, and grants shop items and achievements.

File AccessNetworking
namespace NoChillquarium;

/// <summary>
/// GTA-porch crimes that mash existing toys together: fight loot, janitor
/// caffeine refusal, kicking glass, robbing the funeral tin, dosing pipes,
/// spiking Brad, "gifting" Grandma. Charm is the felony. Comedy is the alibi.
/// </summary>
public static class CrimeSystem
{
	const float KickCooldown = 1.15f;
	const float KickWindowSeconds = 22f;
	const int KicksBeforeCrack = 5;

	static readonly Random _rng = new();
	static int _version;

	static int _kicks;
	static int _janitorOffers;
	static bool _robbedTin;
	static bool _dosedPipes;
	static bool _giftedGrandma;
	static int _ransacks;
	static int _spikes;
	static bool _bradSpikedPending;
	static int _fightLootM80;
	static int _fightLootMeth;
	static bool _hushM80Given;
	static bool _caffeineCrackDone;

	static float _kickCd;
	static float _kickWindow;
	static int _kicksInWindow;
	static bool _ransackedThisResult;
	static bool _sheetOpen;

	public static int Version => _version;
	public static bool SheetOpen => _sheetOpen;

	public static int Kicks => _kicks;
	public static int JanitorOffers => _janitorOffers;
	public static bool RobbedTin => _robbedTin;
	public static bool DosedPipes => _dosedPipes;
	public static bool GiftedGrandma => _giftedGrandma;
	public static int Ransacks => _ransacks;
	public static int Spikes => _spikes;
	public static bool BradSpikedPending => _bradSpikedPending;

	/// <summary>How many porch felonies you've actually pulled (feeds Your Fault).</summary>
	public static int Score =>
		(_kicks >= 3 ? 1 : 0)
		+ Math.Min( 2, _janitorOffers )
		+ (_robbedTin ? 2 : 0)
		+ (_dosedPipes ? 2 : 0)
		+ (_giftedGrandma ? 2 : 0)
		+ _ransacks
		+ _spikes
		+ (_fightLootM80 > 0 ? 1 : 0);

	public static bool CanKick =>
		TankSim.IsActive && GrandmaGifts.HasTank && _kickCd <= 0f
		&& !MethRush.Open && !BattleSystem.IsOpen && !LotRunSystem.IsOpen;

	public static bool CanRobTin =>
		TankSim.IsActive && GrandmaGifts.HasReceived( "cold_wallet" ) && !_robbedTin;

	public static bool CanOfferJanitor =>
		TankSim.IsActive && Shop.MethBags > 0
		&& (GrandmaGifts.HasReceived( "janitor_fusion" ) || StorySystem.HasCombinedOnce);

	public static bool CanDosePipes =>
		TankSim.IsActive && Shop.MethBags > 0 && LotRunSystem.Unlocked && !LotRunSystem.IsOpen;

	public static bool CanGiftGrandma =>
		TankSim.IsActive && Shop.MethBags > 0 && GrandmaGifts.HasTank
		&& !GrandmaGifts.IsBusy && !GrandmaGifts.IsLookingAround && !_giftedGrandma;

	public static bool CanSpikeBrad =>
		Shop.MethBags > 0
		&& BattleSystem.IsOpen
		&& BattleSystem.Phase == BattlePhase.Lobby
		&& !_bradSpikedPending;

	public static bool CanRansack =>
		BattleSystem.Phase == BattlePhase.Result
		&& BattleSystem.PlayerWon
		&& !_ransackedThisResult;

	public static int SheetCount
	{
		get
		{
			var n = 0;
			if ( CanRobTin ) n++;
			if ( CanOfferJanitor ) n++;
			if ( CanDosePipes ) n++;
			if ( CanGiftGrandma ) n++;
			return n;
		}
	}

	public static string SheetMeta =>
		SheetCount > 0 ? $"{SheetCount} ripe" : "ideas";

	public static string JanitorSill =>
		CanOfferJanitor ? "offer meth?" : "* tap tap *";

	public static void ResetForNewGame()
	{
		_kicks = 0;
		_janitorOffers = 0;
		_robbedTin = false;
		_dosedPipes = false;
		_giftedGrandma = false;
		_ransacks = 0;
		_spikes = 0;
		_bradSpikedPending = false;
		_fightLootM80 = 0;
		_fightLootMeth = 0;
		_hushM80Given = false;
		_caffeineCrackDone = false;
		ClearSession();
		_version++;
	}

	public static void Clear()
	{
		ClearSession();
		_version++;
	}

	static void ClearSession()
	{
		_kickCd = 0f;
		_kickWindow = 0f;
		_kicksInWindow = 0;
		_ransackedThisResult = false;
		_sheetOpen = false;
	}

	public static void Load( SaveData data )
	{
		_kicks = Math.Max( 0, data?.TankKicks ?? 0 );
		_janitorOffers = Math.Max( 0, data?.JanitorMethOffers ?? 0 );
		_robbedTin = data?.RobbedCookieTin ?? false;
		_dosedPipes = data?.DosedPipes ?? false;
		_giftedGrandma = data?.GiftedGrandmaMeth ?? false;
		_ransacks = Math.Max( 0, data?.GarageRansacks ?? 0 );
		_spikes = Math.Max( 0, data?.BradSpikes ?? 0 );
		_bradSpikedPending = data?.BradSpikedPending ?? false;
		_fightLootM80 = Math.Max( 0, data?.FightLootM80 ?? 0 );
		_fightLootMeth = Math.Max( 0, data?.FightLootMeth ?? 0 );
		_hushM80Given = data?.JanitorHushM80 ?? false;
		_caffeineCrackDone = data?.JanitorCaffeineCrack ?? false;
		ClearSession();
		_version++;
	}

	public static void WriteToSave( SaveData data )
	{
		if ( data is null )
			return;
		data.TankKicks = _kicks;
		data.JanitorMethOffers = _janitorOffers;
		data.RobbedCookieTin = _robbedTin;
		data.DosedPipes = _dosedPipes;
		data.GiftedGrandmaMeth = _giftedGrandma;
		data.GarageRansacks = _ransacks;
		data.BradSpikes = _spikes;
		data.BradSpikedPending = _bradSpikedPending;
		data.FightLootM80 = _fightLootM80;
		data.FightLootMeth = _fightLootMeth;
		data.JanitorHushM80 = _hushM80Given;
		data.JanitorCaffeineCrack = _caffeineCrackDone;
	}

	public static void Tick( float dt )
	{
		if ( dt <= 0f )
			return;
		if ( _kickCd > 0f )
			_kickCd = MathF.Max( 0f, _kickCd - dt );
		if ( _kickWindow > 0f )
		{
			_kickWindow -= dt;
			if ( _kickWindow <= 0f )
			{
				_kickWindow = 0f;
				_kicksInWindow = 0;
			}
		}
	}

	public static void ToggleSheet()
	{
		if ( !TankSim.IsActive || !GrandmaGifts.HasTank )
			return;
		_sheetOpen = !_sheetOpen;
		GameAudio.PlayUi( _sheetOpen ? GameAudio.Notice : GameAudio.Close );
		_version++;
	}

	public static void CloseSheet()
	{
		if ( !_sheetOpen )
			return;
		_sheetOpen = false;
		_version++;
	}

	public static void NotifyFightResult()
	{
		_ransackedThisResult = false;
		_version++;
	}

	/// <summary>Fight purse side-loot. Returns a result-body fragment or empty.</summary>
	public static string ApplyFightLoot( bool won, bool champFinal, bool champMid, float intensity )
	{
		var m80 = 0;
		var meth = 0;
		var chance = won
			? (champFinal ? 1f : champMid ? 0.22f : Math.Clamp( 0.30f + intensity * 0.045f, 0.28f, 0.62f ))
			: 0.12f;

		if ( champFinal )
		{
			m80 = 1;
			if ( _rng.NextDouble() < 0.55 )
				m80++;
			if ( _rng.NextDouble() < 0.28 )
				meth = 1;
		}
		else if ( _rng.NextDouble() < chance )
		{
			m80 = 1;
			if ( won && _rng.NextDouble() < 0.18 )
				m80++;
			if ( won && _rng.NextDouble() < 0.10 + intensity * 0.02f )
				meth = 1;
		}

		if ( m80 <= 0 && meth <= 0 )
			return "";

		if ( m80 > 0 )
		{
			Shop.GrantM80( m80 );
			_fightLootM80 += m80;
		}
		if ( meth > 0 )
		{
			Shop.GrantMeth( meth );
			_fightLootMeth += meth;
		}

		Chaos.Add( 1.2f + m80 * 0.4f + meth );
		_version++;
		SaveGame.TrySave( quiet: true );

		if ( !won )
			return m80 > 0 ? $" Brad dropped a damp M80 running to the vending machine." : "";

		var bits = new List<string>();
		if ( m80 > 0 )
			bits.Add( m80 == 1 ? "M80" : $"M80 ×{m80}" );
		if ( meth > 0 )
			bits.Add( "meth bag" );
		var loot = string.Join( " + ", bits );
		if ( champFinal )
			return $" Garage locker: {loot}.";
		return $" Swiped {loot} off the folding table.";
	}

	public static bool TryKickTank()
	{
		if ( !CanKick )
		{
			if ( _kickCd > 0f )
				TankSim.ShowBanner( "Glass is still ringing. Wait a beat." );
			return false;
		}

		_kickCd = KickCooldown;
		_kicks++;
		_kicksInWindow++;
		_kickWindow = KickWindowSeconds;

		var n = TankSim.KickAllFish();
		BoomFeel.SoftPunch( 0.55f + Math.Min( 0.35f, _kicksInWindow * 0.06f ) );
		WaterFx.Splash( TankSim.Width * 0.5f, TankSim.Height * 0.72f, 18, WaterLayer.Tank );
		WaterFx.Squirt( TankSim.Width * 0.18f, TankSim.Height * 0.2f, -0.2f, -1f, 8, 180f, WaterLayer.Tank );
		WaterFx.Squirt( TankSim.Width * 0.82f, TankSim.Height * 0.22f, 0.2f, -1f, 8, 180f, WaterLayer.Tank );
		GameAudio.PlayUi( GameAudio.AlertWarning );
		GameAudio.PulseMusic( 0.12f, 0.6f );
		Chaos.Add( 1.6f );
		Karma.Add( -0.9f );

		string line = _kicksInWindow switch
		{
			1 => n > 0
				? "You kicked the tank. The fish filed a complaint. You are HR."
				: "You kicked empty glass. That's modern art. That's also a noise complaint.",
			2 => "Second kick. The gravel resettled in fear. Comedy is a contact sport.",
			3 => "Third kick. Grandma's porch has a pulse now. It is yours. It is wrong.",
			4 => "Fourth. The water is taking notes. The notes are wet. The notes are evidence.",
			_ => "You are beating up furniture. The furniture is winning on style points."
		};
		TankSim.ShowBanner( line );

		if ( _kicksInWindow >= KicksBeforeCrack )
		{
			MethRush.ForceCrack( 1 );
			_kicksInWindow = 0;
			_kickWindow = 0f;
		}

		if ( _kicks == 1 )
			GrandmaReact.OnFirstTankKick();
		AchievementSystem.Unlock( "kick" );
		JanitorPeep.On( "kick" );
		StorySystem.Evaluate();
		SaveGame.TrySave();
		_version++;
		return true;
	}

	public static bool TryOfferJanitorMeth()
	{
		if ( !CanOfferJanitor )
		{
			if ( Shop.MethBags <= 0 )
				TankSim.ShowBanner( "No bag to offer. He already has a drug. It's Folgers." );
			else
				TankSim.ShowBanner( "The janitor isn't in the bit yet." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		// He refuses. The bag stays. That's the joke.
		_janitorOffers++;
		_sheetOpen = false;
		Chaos.Add( 1.8f );
		Karma.Add( -0.4f );
		GameAudio.PlayUi( GameAudio.Notice );
		JanitorPeep.SayNow( "caffeine" );

		if ( !_hushM80Given && _janitorOffers >= 3 )
		{
			_hushM80Given = true;
			Shop.GrantM80( 1 );
			TankSim.ShowBanner( "He won't take the bag. He slides you a damp M80. \"Keep the noise away from the Folgers.\"" );
			Chaos.Add( 2f );
		}
		else if ( !_caffeineCrackDone && _janitorOffers >= 5 )
		{
			_caffeineCrackDone = true;
			MethRush.ForceCrack( 1 );
			BoomFeel.SoftPunch( 0.8f );
			WaterFx.Splash( TankSim.Width * 0.85f, TankSim.Height * 0.35f, 16, WaterLayer.Tank );
			TankSim.ShowBanner( "He refused so hard the caffeine cracked the glass. That's a vibe. That's a lawsuit." );
		}
		else if ( _janitorOffers == 1 )
		{
			TankSim.ShowBanner( "You offered. He cited Folgers. Folgers has a lawyer. The lawyer is also Folgers." );
			GrandmaReact.OnFirstJanitorMeth();
		}
		else
		{
			TankSim.ShowBanner( "Still a no. He is vibrating at a legal frequency. Your powder is off-key." );
		}
		AchievementSystem.Unlock( "janitor" );

		StorySystem.Evaluate();
		SaveGame.TrySave();
		_version++;
		return true;
	}

	public static bool TryPokeJanitor()
	{
		if ( !JanitorPeep.IsVisible || JanitorPeep.Leaving )
			return false;
		if ( CanOfferJanitor )
			return TryOfferJanitorMeth();

		JanitorPeep.SayNow( "poke" );
		Chaos.Add( 0.4f );
		GameAudio.PlayUi( GameAudio.Pop );
		_version++;
		return true;
	}

	public static bool TryRobCookieTin()
	{
		if ( !CanRobTin )
		{
			TankSim.ShowBanner( _robbedTin
				? "Tin's empty. She counted. She can still count. That's worse."
				: "No funeral tin yet. Wait for the wallet gift." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		_robbedTin = true;
		_sheetOpen = false;
		var swipe = Math.Round( 48 + _rng.NextDouble() * 42, 1 );
		Economy.AddForced( swipe );
		Chaos.Add( 3.5f );
		Karma.Add( -8f );
		GameAudio.PlayBigCash();
		BoomFeel.SoftPunch( 0.45f );
		TankSim.ShowBanner( $"You robbed the funeral tin. +Ð{Economy.FormatDoge( swipe )}. Grandpa's leftover hash. I mean cash. I mean both." );
		GrandmaReact.OnRobbedTin();
		JanitorPeep.On( "tin" );
		AchievementSystem.Unlock( "tin" );
		StorySystem.Evaluate();
		SaveGame.TrySave();
		_version++;
		return true;
	}

	public static bool TryDosePipes()
	{
		if ( !CanDosePipes )
		{
			TankSim.ShowBanner( Shop.MethBags <= 0
				? "No bag. The pipes are sober. The pipes hate that."
				: "Adventure isn't open. The drain is just a drain." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( !Shop.TryTakeMeth( 1 ) )
			return false;

		_dosedPipes = true;
		_sheetOpen = false;
		Chaos.Add( 5f );
		Karma.Add( -3.2f );
		BoomFeel.SoftPunch( 0.7f );
		GameAudio.PlayUi( GameAudio.AlertWarning );
		WaterFx.Squirt( TankSim.Width * 0.5f, TankSim.Height * 0.92f, 0f, -1f, 22, 260f, WaterLayer.Tank );
		WaterFx.Splash( TankSim.Width * 0.5f, TankSim.Height * 0.88f, 16, WaterLayer.Tank );
		TankSim.ShowBanner( "You flushed a bag. The pipes are doing homework. Rent just went up." );
		JanitorPeep.ForceShow( "pipes" );
		GrandmaReact.OnDosedPipes();
		StorySystem.Evaluate();
		SaveGame.TrySave();
		_version++;
		return true;
	}

	public static bool TryGiftGrandmaMeth()
	{
		if ( !CanGiftGrandma )
		{
			TankSim.ShowBanner( _giftedGrandma
				? "She already grounded the soup. Don't test the soup."
				: "Need a bag first. Shop → Shady." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( !Shop.TryTakeMeth( 1 ) )
			return false;

		_giftedGrandma = true;
		_sheetOpen = false;
		Chaos.Add( 4f );
		Karma.Add( -6.5f );
		var pity = 12.0;
		Economy.AddForced( pity );
		GameAudio.PlayUi( GameAudio.Confirm );
		TankSim.ShowBanner( $"Grandma: \"Sugar for tea.\" … \"This isn't sugar.\" Soup is grounded. She still tips Ð{Economy.FormatDoge( pity )}." );
		GrandmaReact.OnGiftedMeth();
		JanitorPeep.On( "grandma_meth" );
		StorySystem.Evaluate();
		SaveGame.TrySave();
		_version++;
		return true;
	}

	public static bool TrySpikeBrad()
	{
		if ( !CanSpikeBrad )
		{
			TankSim.ShowBanner( _bradSpikedPending
				? "Bucket's already spicy. Fight while it's hot."
				: "Need meth and Brad's lobby." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( !Shop.TryTakeMeth( 1 ) )
			return false;

		_bradSpikedPending = true;
		_spikes++;
		Chaos.Add( 3.5f );
		Karma.Add( -2.8f );
		GameAudio.PlayUi( GameAudio.Inject );
		TankSim.ShowBanner( "You dosed Brad's bucket. His fish are about to do homework with their gills." );
		JanitorPeep.On( "spike" );
		SaveGame.TrySave();
		_version++;
		return true;
	}

	/// <summary>Consumed when a fight actually starts.</summary>
	public static bool ConsumeBradSpike()
	{
		if ( !_bradSpikedPending )
			return false;
		_bradSpikedPending = false;
		_version++;
		return true;
	}

	public static bool TryRansackGarage()
	{
		if ( !CanRansack )
			return false;

		_ransackedThisResult = true;
		_ransacks++;
		var cash = Math.Round( 16 + _rng.NextDouble() * 28, 1 );
		Economy.AddForced( cash );
		Shop.GrantM80( 1 );
		var meth = _rng.NextDouble() < 0.22;
		if ( meth )
			Shop.GrantMeth( 1 );
		Chaos.Add( 3f );
		Karma.Add( -4.2f );
		GameAudio.PlayBigCash();
		var extra = meth ? " + a bag he hid in a tube sock" : "";
		var line = $" Ransacked his drawer: +Ð{Economy.FormatDoge( cash )}, damp M80{extra}.";
		BattleSystem.AppendResultNote( line );
		TankSim.ShowBanner( $"Ransacked Brad's drawer. +Ð{Economy.FormatDoge( cash )}, damp M80{extra}. He laminated the theft." );
		JanitorPeep.On( "ransack" );
		if ( _ransacks == 1 )
			GrandmaReact.OnFirstRansack();
		SaveGame.TrySave();
		_version++;
		return true;
	}
}