FishActor.cs
namespace NoChillquarium;

/// <summary>
/// One living fish in a tank. Positions are tank-local pixels.
/// </summary>
public sealed class FishActor
{
	public string InstanceId { get; init; }
	/// <summary>Mutable so Trade can swap species in place.</summary>
	public FishSpecies Species { get; set; }
	/// <summary>Optional player nickname. Empty/null = use species name.</summary>
	public string Nickname { get; set; }
	public float X { get; set; }
	public float Y { get; set; }
	public float Vx { get; set; }
	public float Vy { get; set; }
	public float BobPhase { get; set; }
	public float BobSpeed { get; set; }
	public float BobAmp { get; set; }

	/// <summary>Seconds spent sick from wrong salinity (dies around threshold).</summary>
	public float SickTimer { get; set; }

	/// <summary>Meth keeps wrong-water fish alive — jittery, chaotic, profitable.</summary>
	public bool OnMeth { get; set; }

	/// <summary>Seconds remaining on the high (wears off after this).</summary>
	public float MethTimer { get; set; }

	/// <summary>0–100 chemical dependence. Builds with each dose.</summary>
	public float Addiction { get; set; }

	/// <summary>Seconds of withdrawal left after a high ends.</summary>
	public float WithdrawalTimer { get; set; }

	/// <summary>Training stats 0–10 (start ~3).</summary>
	public float Endurance { get; set; } = 3f;
	public float SpeedStat { get; set; } = 3f;
	public float Agility { get; set; } = 3f;

	/// <summary>Steroid jacked state — bigger, faster, loud income.</summary>
	public bool Jacked { get; set; }
	public float JackedTimer { get; set; }

	/// <summary>Weed gummies high — slow, floaty, trippy visuals.</summary>
	public bool Stoned { get; set; }
	/// <summary>Seconds left on the gummy high.</summary>
	public float StonedTimer { get; set; }

	/// <summary>Seconds left of click-scare dart (overrides wander / pellet seek).</summary>
	public float ScurryTimer { get; set; }

	/// <summary>
	/// Post-fight recovery lockout (seconds). Looks sick and cannot enter battles.
	/// </summary>
	public float FightInjuryTimer { get; set; }

	/// <summary>
	/// Away on Adventure Mode (flushed) — hidden from tank until faucet return.
	/// Still exists for crawl combat via LotRunSystem.
	/// </summary>
	public bool OnAdventure { get; set; }

	/// <summary>0–100 belly from pellets. Overfeeding makes them visibly fat.</summary>
	public float Fat { get; set; }

	/// <summary>
	/// Seconds left carrying eggs (rare passive breed). While &gt;0 the fish stays fat
	/// and will lay tank eggs that hatch into guppies.
	/// </summary>
	public float PregnantTimer { get; set; }

	/// <summary>Seconds before this fish can court again after mating / laying.</summary>
	public float MateCooldown { get; set; }

	/// <summary>Seconds alive in tanks (growth clock).</summary>
	public float AgeSeconds { get; set; }

	/// <summary>
	/// Permanent bulk from eating smaller fish (0–100). Monsters only.
	/// Feeds size, presence, and a little income.
	/// </summary>
	public float PredationBulk { get; set; }

	/// <summary>Cooldown before this monster can try another hunt (seconds).</summary>
	public float PredationCooldown { get; set; }

	/// <summary>How many tankmates this fish has swallowed (flavor / banner).</summary>
	public int FishEatenCount { get; set; }

	/// <summary>Rolled on spawn/buy — never changes.</summary>
	public FishRarity Rarity { get; set; } = FishRarity.Common;

	public const float WeedHighDuration = 22f;
	/// <summary>How long a lost fight benches a fish (short slap — requeue soon).</summary>
	public const float FightInjuryDuration = 28f;

	/// <summary>
	/// Natural seconds to full adult (Chillquarium-style real-time grow).
	/// Feeding adds age bursts so active play finishes faster.
	/// Tuned for addiction: idle adult ~5 min; mash feed ~2 min.
	/// </summary>
	public const float AdultAgeSeconds = 4f * 60f; // ~4 min idle · mash feed ~90s
	/// <summary>Extra seconds after adult toward "mature" bulk / peak sell.</summary>
	public const float MatureExtraSeconds = 6f * 60f; // +6 min

	/// <summary>Cap age (adult + mature). Feed can't push past this.</summary>
	public static float MaxAgeSeconds => AdultAgeSeconds + MatureExtraSeconds;

	/// <summary>Swim direction only (+1 right, -1 left). Visual mirror is computed in UI.</summary>
	public int Facing => Vx >= 0f ? 1 : -1;
	public float DrawY => Y + MathF.Sin( BobPhase ) * BobAmp;
	public string DisplayName =>
		!string.IsNullOrWhiteSpace( Nickname ) ? Nickname.Trim() : (Species?.Name ?? "Fish");

	public string RarityLabel => FishRarityInfo.Label( Rarity );
	public string RarityCss => FishRarityInfo.CssClass( Rarity );

	public bool IsMonster => Species?.IsMonster == true;

	/// <summary>Average of END/SPD/AGI — used for fight strength and "failure" scrap.</summary>
	public float CombatAvg => (Endurance + SpeedStat + Agility) / 3f;

	/// <summary>0–1 bulk from predation (size / presence).</summary>
	public float PredationBulkNorm => Math.Clamp( PredationBulk / 100f, 0f, 1f );

	/// <summary>
	/// Approximate mass for "who can eat whom" — growth × species box × fat × bulk.
	/// </summary>
	public float MassScore
	{
		get
		{
			if ( Species is null )
				return 0f;
			var box = Species.Width * Species.Height * 0.01f;
			var m = box * GrowthScale;
			m *= 1f + FatNorm * 0.45f;
			m *= 1f + PredationBulkNorm * 0.9f;
			if ( IsMonster )
				m *= 1.35f;
			if ( Jacked )
				m *= 1.15f;
			return m;
		}
	}

	/// <summary>
	/// Weak / untrained abom — Grandma calls it a failure and wants it M80'd.
	/// (Commons with soft stats, or any monster under ~4 combat avg.)
	/// </summary>
	public bool IsAbominationFailure =>
		IsMonster
		&& ( CombatAvg < 4.0f || Rarity <= FishRarity.Common );

	/// <summary>
	/// Sprite to draw — meth aboms, then overfeed fat sheets, else base art.
	/// </summary>
	public string DisplaySpriteId
	{
		get
		{
			if ( Species is null )
				return "";
			if ( IsMonster && OnMeth )
			{
				// Cycle 5 meth-abom sheets by monster type.
				var n = ((Species.MonsterType - 1) % 5) + 1;
				if ( n < 1 ) n = 1;
				return SwimSpriteOr( $"monster_meth_{n}" );
			}

			var baseId = Species.SpriteId;
			// Excessive overfeed: dedicated fat sheets when available.
			if ( FatStage >= 2 && !string.IsNullOrEmpty( baseId ) )
			{
				var fatId = baseId + "_fat";
				if ( SpriteCatalog.Has( fatId ) )
					return SwimSpriteOr( fatId );
			}

			return SwimSpriteOr( baseId );

		}
	}

	/// <summary>2–4 frame GBA swim if <c>{stem}_swim_0</c> exists; else the still.</summary>
	string SwimSpriteOr( string stem )
	{
		if ( string.IsNullOrEmpty( stem ) )
			return stem;
		var n = SpriteCatalog.SwimFrameCount( stem );
		if ( n <= 0 )
			return stem;
		var fps = 5f;
		var i = ((int)(Time.Now * fps + BobPhase * 1.7f) % n + n) % n;
		return stem + "_swim_" + i;
	}

	/// <summary>True when we're drawing a dedicated fat sheet (not just scale).</summary>
	public bool UsingFatSprite
	{
		get
		{
			if ( Species is null || FatStage < 2 )
				return false;
			return SpriteCatalog.Has( Species.SpriteId + "_fat" );
		}
	}

	/// <summary>0 = newborn guppy, 1 = full adult (before mature bulk).</summary>
	public float Growth01 =>
		Math.Clamp( AgeSeconds / AdultAgeSeconds, 0f, 1f );

	/// <summary>0–1 into mature phase after adult.</summary>
	public float Mature01 =>
		AgeSeconds <= AdultAgeSeconds
			? 0f
			: Math.Clamp( (AgeSeconds - AdultAgeSeconds) / MatureExtraSeconds, 0f, 1f );

	/// <summary>Guppy → Juvenile → Adult → Mature.</summary>
	public string GrowthStageName
	{
		get
		{
			if ( Growth01 < 0.28f ) return "Guppy";
			if ( Growth01 < 0.62f ) return "Juvenile";
			if ( Mature01 < 0.35f ) return "Adult";
			return "Mature";
		}
	}

	/// <summary>0 guppy … 3 mature — for CSS.</summary>
	public int GrowthStage
	{
		get
		{
			if ( Growth01 < 0.28f ) return 0;
			if ( Growth01 < 0.62f ) return 1;
			if ( Mature01 < 0.35f ) return 2;
			return 3;
		}
	}

	/// <summary>
	/// Visual size factor from age only (~0.50 guppy → 1.0 adult → ~1.14 mature).
	/// Combined with fat/jacked in <see cref="DrawScaleX"/>.
	/// Floor is high so a new goldfish still reads in the 1280 glass.
	/// </summary>
	public float GrowthScale
	{
		get
		{
			// Ease-out so early growth is visible, then settles.
			var t = Growth01;
			var eased = t * t * (3f - 2f * t); // smoothstep
			var s = 0.50f + eased * 0.50f;
			s += Mature01 * 0.14f;
			return s;
		}
	}

	/// <summary>
	/// Income fraction from size: babies still earn, adults full base, mature peak.
	/// Floor is high enough that starter fish feel alive — crumbs kill the first minute.
	/// </summary>
	public float GrowthIncomeMul
	{
		get
		{
			var t = Growth01;
			// Smoothstep: readable early income, full rate at adult, mature AFK prize.
			var eased = t * t * (3f - 2f * t);
			var mul = 0.48f + eased * 0.52f;
			mul += Mature01 * 0.4f;
			return mul;
		}
	}

	/// <summary>0–100 growth toward adult (menu bar).</summary>
	public int GrowthPercent => (int)MathF.Round( Growth01 * 100f );

	public float RarityIncomeMul => FishRarityInfo.IncomeMult( Rarity );
	public float RaritySellMul => FishRarityInfo.SellMult( Rarity );

	/// <summary>Species base × growth × rarity (before meth/train/etc.).</summary>
	public float EffectiveIncomePerSecond
	{
		get
		{
			if ( Species is null )
				return 0f;
			var rate = Species.IncomePerSecond * GrowthIncomeMul * RarityIncomeMul * IncomeScale;
			// Aboms print a bit harder at baseline (training already in IncomeScale).
			if ( IsMonster )
				rate *= 1.18f;
			// Apex predators that snack print a little harder.
			if ( PredationBulkNorm > 0f )
				rate *= 1f + PredationBulkNorm * 0.35f;
			return rate;
		}
	}

	public void TickGrowth( float dt )
	{
		if ( dt <= 0f )
			return;
		var before = GrowthStageName;
		AgeSeconds = MathF.Min( MaxAgeSeconds, AgeSeconds + dt );
		NotifyGrowthStageIfChanged( before, pay: false );
	}

	/// <summary>
	/// Chillquarium feed XP — each pellet ages the fish (better food = bigger bite).
	/// Active feeding should finish adult in ~2–3 minutes, not a second job.
	/// </summary>
	public void AddGrowthFromFood( FoodDef food )
	{
		// Flakes ~55s, mid foods ~70–90s, nightmare foods ~110s+.
		var bite = 36f;
		if ( food is not null )
			bite = 30f + food.BoostMultiplier * 18f + food.ChaosOnFeed * 3.2f;
		var before = GrowthStageName;
		AgeSeconds = MathF.Min( MaxAgeSeconds, AgeSeconds + bite );
		NotifyGrowthStageIfChanged( before, pay: true );
	}

	/// <summary>Stage-up banner + optional Ð snack so growth always feels like a win.</summary>
	void NotifyGrowthStageIfChanged( string beforeStage, bool pay )
	{
		if ( beforeStage == GrowthStageName )
			return;
		// Skip newborn→guppy noise; celebrate real rungs.
		if ( GrowthStage < 1 )
			return;

		TankSim.ShowBanner( $"{DisplayName} · {GrowthStageName}!" );
		StatFloat.PushOverFish( this, GrowthStageName.ToUpperInvariant() + "!", "stage", life: 1.9f );
		if ( GrowthStage >= 2 )
		{
			BoomFeel.SoftPunch( 0.35f );
			GameAudio.PlayLayered( GameAudio.Upgrade, GameAudio.Notice, 0.4f );
		}
		else
			GameAudio.PlayUi( GameAudio.Notice );

		if ( !pay )
			return;

		// Small stage tips — adult is the money moment.
		var tip = GrowthStage switch
		{
			1 => 2.0,  // Juvenile
			2 => 5.0,  // Adult
			3 => 8.0,  // Mature
			_ => 0.0
		};
		if ( tip > 0 )
			Economy.AddForced( tip * RaritySellMul );
	}

	/// <summary>Max length for player nicknames (short HUD labels).</summary>
	public const int MaxNicknameLength = 20;

	/// <summary>
	/// Set or clear nickname. Empty/whitespace clears (back to species name).
	/// Returns false if the string has invalid characters.
	/// </summary>
	public bool TrySetNickname( string name )
	{
		if ( string.IsNullOrWhiteSpace( name ) )
		{
			Nickname = null;
			return true;
		}

		var cleaned = SanitizeNickname( name );
		if ( cleaned is null )
			return false;

		if ( cleaned.Length == 0 )
		{
			Nickname = null;
			return true;
		}

		// Same as species label — treat as "no custom name"
		if ( Species is not null
			&& string.Equals( cleaned, Species.Name, StringComparison.OrdinalIgnoreCase ) )
		{
			Nickname = null;
			return true;
		}

		Nickname = cleaned;
		return true;
	}

	/// <summary>Letters, numbers, space, and a few friendly symbols. Null = invalid.</summary>
	public static string SanitizeNickname( string raw )
	{
		if ( raw is null )
			return "";

		var sb = new System.Text.StringBuilder( MaxNicknameLength );
		foreach ( var ch in raw.Trim() )
		{
			if ( sb.Length >= MaxNicknameLength )
				break;
			if ( char.IsLetterOrDigit( ch ) || ch is ' ' or '-' or '_' or '\'' or '.' )
				sb.Append( ch );
			else
				return null;
		}

		// Collapse multi-spaces
		var s = sb.ToString().Trim();
		while ( s.Contains( "  ", StringComparison.Ordinal ) )
			s = s.Replace( "  ", " ", StringComparison.Ordinal );
		return s;
	}

	/// <summary>0 = skinny, 1 = stuffed chonk.</summary>
	public float FatNorm => Math.Clamp( Fat / 100f, 0f, 1f );

	/// <summary>True once overfeeding is obvious.</summary>
	public bool IsFat => Fat >= 28f;

	/// <summary>1–3 for CSS (mild / chunky / unit).</summary>
	public int FatStage
	{
		get
		{
			if ( Fat < 28f ) return 0;
			if ( Fat < 55f ) return 1;
			if ( Fat < 80f ) return 2;
			return 3;
		}
	}

	/// <summary>Horizontal draw scale (growth + belly + speed length + jacked bulk).</summary>
	public float DrawScaleX
	{
		get
		{
			var s = GrowthScale;
			// Fat sheet already sells the belly — dial scale so we don't double-inflate.
			var fatMul = UsingFatSprite
				? 1f + FatNorm * 0.28f
				: 1f + FatNorm * 0.95f;
			s *= fatMul;
			// Pregnant: extra midsection chonk on top of fat.
			if ( IsPregnant )
				s *= 1.12f + (1f - PregnantProgress) * 0.1f;
			// Fast fish read longer / more torpedo-shaped.
			s *= 1f + SpeedNorm * 0.14f;
			// Endurance bulk pads the midsection a bit.
			s *= 1f + EnduranceNorm * 0.08f;
			if ( Jacked )
				s *= 1.35f;
			// Mythical / painted get a slight presence bump so they read special.
			if ( Rarity >= FishRarity.PaintedRare )
				s *= 1.06f;
			if ( Rarity >= FishRarity.Legendary )
				s *= 1.08f;
			// Eaten fish stick — permanent chonk from predation.
			if ( PredationBulkNorm > 0f )
				s *= 1f + PredationBulkNorm * 0.7f;
			// Monsters on meth: REALLY big and gross (capped so they still fit the glass).
			if ( IsMonster && OnMeth )
				s *= 2.35f + AddictionNorm * 0.45f;
			else if ( IsMonster )
				s *= 1.12f;
			return Math.Min( s, IsMonster && OnMeth ? 3.6f : 2.9f );
		}
	}

	/// <summary>Vertical draw scale (growth + fat, endurance bulk, agile fin height).</summary>
	public float DrawScaleY
	{
		get
		{
			var s = GrowthScale;
			var fatMul = UsingFatSprite
				? 1f + FatNorm * 0.18f
				: 1f + FatNorm * 0.38f;
			s *= fatMul;
			if ( IsPregnant )
				s *= 1.22f + (1f - PregnantProgress) * 0.14f;
			// Tanky fish look stockier.
			s *= 1f + EnduranceNorm * 0.12f;
			// Agile fish carry taller fin presence.
			s *= 1f + AgilityNorm * 0.06f;
			if ( Jacked )
				s *= 1.35f;
			if ( Rarity >= FishRarity.PaintedRare )
				s *= 1.05f;
			if ( Rarity >= FishRarity.Legendary )
				s *= 1.06f;
			if ( PredationBulkNorm > 0f )
				s *= 1f + PredationBulkNorm * 0.55f;
			if ( IsMonster && OnMeth )
				s *= 2.15f + AddictionNorm * 0.4f;
			else if ( IsMonster )
				s *= 1.1f;
			return Math.Min( s, IsMonster && OnMeth ? 3.4f : 2.65f );
		}
	}

	public bool IsWrongWater( WaterType tankWater )
	{
		if ( Species is null || Species.Water == WaterType.Any )
			return false;
		return Species.Water != tankWater;
	}

	public bool IsSick => SickTimer > 0f && !OnMeth;

	public bool InWithdrawal => !OnMeth && WithdrawalTimer > 0f;

	public bool IsAddicted => Addiction >= 15f;

	/// <summary>Carrying eggs after a rare courtship.</summary>
	public bool IsPregnant => PregnantTimer > 0.05f;

	/// <summary>1 at just impregnated, 0 when about to lay.</summary>
	public float PregnantProgress =>
		IsPregnant
			? Math.Clamp( PregnantTimer / 72f, 0f, 1f )
			: 0f;

	/// <summary>Benched after a loss — sick look, no fights until timer ends.</summary>
	public bool IsFightInjured => FightInjuryTimer > 0.05f;

	/// <summary>1 at full injury, 0 when recovered.</summary>
	public float FightInjuryProgress =>
		IsFightInjured
			? Math.Clamp( FightInjuryTimer / FightInjuryDuration, 0f, 1f )
			: 0f;

	/// <summary>1–3 visual severity while fight-injured (heavier right after the loss).</summary>
	public int FightInjuryStage
	{
		get
		{
			if ( !IsFightInjured )
				return 0;
			var p = FightInjuryProgress;
			if ( p > 0.66f ) return 3;
			if ( p > 0.33f ) return 2;
			return 1;
		}
	}

	/// <summary>Salinity sick or post-fight hurt — shared tank “sick” look.</summary>
	public bool LooksSick => IsSick || IsFightInjured;

	/// <summary>Strongest sick-looking stage for CSS (salinity or fight injury).</summary>
	public int DisplaySickStage =>
		Math.Max( IsSick ? SickStage : 0, FightInjuryStage );

	/// <summary>Apply 3-minute fight injury (loss). Refreshes timer if already hurt.</summary>
	public void ApplyFightLossInjury()
	{
		FightInjuryTimer = FightInjuryDuration;
		// Slow them down after the beating.
		Vx *= 0.45f;
		Vy *= 0.45f;
		BobAmp = MathF.Min( 9f, BobAmp + 1.2f );
	}

	/// <summary>True if this tick cleared the fight injury lockout.</summary>
	public bool TickFightInjury( float dt )
	{
		if ( FightInjuryTimer <= 0f || dt <= 0f )
			return false;
		var was = FightInjuryTimer;
		FightInjuryTimer = MathF.Max( 0f, FightInjuryTimer - dt );
		if ( was > 0f && FightInjuryTimer <= 0f )
		{
			BobAmp = MathF.Max( 2f, BobAmp * 0.85f );
			return true;
		}
		return false;
	}

	/// <summary>MM:SS left on fight bench, or empty if ready.</summary>
	public string FightCooldownLeftText
	{
		get
		{
			if ( !IsFightInjured )
				return "";
			var sec = (int)MathF.Ceiling( FightInjuryTimer );
			var m = sec / 60;
			var s = sec % 60;
			return $"{m}:{s:00}";
		}
	}

	/// <summary>0 = clean, 1 = fully hooked.</summary>
	public float AddictionNorm => Math.Clamp( Addiction / 100f, 0f, 1f );

	/// <summary>1–4 for CSS / HUD.</summary>
	public int AddictionStage
	{
		get
		{
			if ( Addiction < 15f ) return 0;
			if ( Addiction < 35f ) return 1;
			if ( Addiction < 55f ) return 2;
			if ( Addiction < 75f ) return 3;
			return 4;
		}
	}

	/// <summary>0 = healthy, 1 = about to die.</summary>
	public float SickProgress
	{
		get
		{
			if ( !IsSick || TankSim.SickDeathSeconds <= 0f )
				return 0f;
			return Math.Clamp( SickTimer / TankSim.SickDeathSeconds, 0f, 1f );
		}
	}

	/// <summary>1–4 visual severity for CSS classes.</summary>
	public int SickStage => DeathFeel.ProgressToStage( SickProgress );

	public bool IsDoomed => IsSick && SickProgress >= 0.55f;

	/// <summary>Training average 0–1 for combat/income flavor.</summary>
	public float TrainingNorm =>
		Math.Clamp( (Endurance + SpeedStat + Agility) / 30f, 0f, 1f );

	/// <summary>0–1 norms for each trained stat (0.5 floor → 0, 10 → 1).</summary>
	public float SpeedNorm => Math.Clamp( (SpeedStat - 0.5f) / 9.5f, 0f, 1f );
	public float AgilityNorm => Math.Clamp( Agility / 10f, 0f, 1f );
	public float EnduranceNorm => Math.Clamp( Endurance / 10f, 0f, 1f );

	/// <summary>
	/// Swim speed multiplier from SpeedStat. ~1.0 at starting 3, up to ~2× at 10, down to ~0.55 at 0.5.
	/// </summary>
	public float SpeedMoveMul =>
		Math.Clamp( 0.55f + SpeedStat * 0.145f, 0.5f, 2.05f );

	/// <summary>
	/// Turn sharpness from Agility. High = bigger heading snaps, more frequent course changes.
	/// ~0.75 at starting 3, ~1.6 at 10.
	/// </summary>
	public float AgilityTurnMul =>
		Math.Clamp( 0.35f + Agility * 0.125f, 0.3f, 1.65f );

	/// <summary>Max heading delta (radians) when choosing a new wander direction.</summary>
	public float MaxTurnRadians =>
		MathF.PI * Math.Clamp( 0.22f + AgilityNorm * 0.78f, 0.22f, 1.0f );

	/// <summary>How hard a new heading replaces the old one (0–1).</summary>
	public float TurnSnap =>
		Math.Clamp( 0.32f + Agility * 0.075f, 0.3f, 1f );

	/// <summary>Wander re-nudge rate (events per second, roughly).</summary>
	public float WanderRate =>
		0.18f + Agility * 0.09f + (OnMeth ? 2.5f : 0f);

	/// <summary>1–3 visual tier for CSS (0 = baseline starter-ish).</summary>
	public int SpeedStage => StatStage( SpeedStat );
	public int AgilityStage => StatStage( Agility );
	public int EnduranceStage => StatStage( Endurance );

	static int StatStage( float stat )
	{
		if ( stat < 4.2f ) return 0;
		if ( stat < 6.2f ) return 1;
		if ( stat < 8.2f ) return 2;
		return 3;
	}

	/// <summary>
	/// Status mult only (train / meth / sick / fat). Growth + rarity applied in
	/// <see cref="EffectiveIncomePerSecond"/> / tank income.
	/// </summary>
	public float IncomeScale
	{
		get
		{
			var scale = 1f + TrainingNorm * 0.35f;
			if ( Jacked )
				scale *= 1.55f;
			if ( OnMeth )
			{
				// Monsters love it — income goes stupid.
				if ( IsMonster )
					scale *= 2.8f + AddictionNorm * 1.1f;
				else
					scale *= 1.35f + AddictionNorm * 0.55f;
			}
			else if ( InWithdrawal )
			{
				// Monsters crash harder after the party.
				if ( IsMonster )
					scale *= MathF.Max( 0.05f, 0.22f - AddictionNorm * 0.12f );
				else
					scale *= MathF.Max( 0.08f, 0.35f - AddictionNorm * 0.25f );
			}
			else if ( IsSick )
				scale *= 0.35f;
			else if ( IsAddicted )
				scale *= 1f - AddictionNorm * 0.12f;
			// Stoned: chill productivity, munchies already fat-buffed below.
			if ( Stoned )
				scale *= 1.18f;
			// Well-fed fish print a bit more; stuffed chonks slow the grind slightly.
			if ( FatNorm > 0.15f )
				scale *= 1f + MathF.Min( 0.2f, FatNorm * 0.18f );
			if ( FatNorm > 0.75f )
				scale *= 0.92f;
			return scale;
		}
	}

	/// <summary>Absorb a food pellet — growth XP + fat (Chillquarium feed loop).</summary>
	public void EatPellet( float foodChaos = 0f, string foodId = null )
	{
		var food = FoodDef.FindExact( foodId );

		// Growth first — this is the chill grind.
		var beforeGrowth = GrowthPercent;
		AddGrowthFromFood( food );
		// Soft juice: sparkle + occasional GROW so feeding feels like XP.
		ExplodeSystem.SpawnFeedSpark( X, DrawY );
		if ( GrowthPercent > beforeGrowth && GrowthPercent % 5 == 0 )
			StatFloat.PushOverFish( this, "GROW", "grow", life: 1.1f );

		// First bites fill when empty; once stuffed, excess piles on hard.
		var bite = 12f + FatNorm * 10f;
		if ( FatNorm > 0.4f )
			bite += 8f + FatNorm * 12f; // excessive overfeed → real chonk
		if ( foodChaos > 2f )
			bite += 4f; // weird food = greasier
		// Munchies: weed bites hit the belly harder.
		if ( foodId == FoodDef.WeedGummies.Id || Stoned )
			bite += 8f;

		var before = Fat;
		var beforeStage = FatStage;
		Fat = Math.Clamp( Fat + bite, 0f, 100f );

		// Lazy after gorging
		Vx *= 0.9f;
		Vy *= 0.9f;
		BobAmp = MathF.Min( 9f, BobAmp + 0.2f );

		if ( foodId == FoodDef.WeedGummies.Id )
			GetStoned();

		// Stage banners — sprite swap lands at stage 2 (Fat >= 55).
		if ( beforeStage < 1 && FatStage >= 1 )
			TankSim.ShowBanner( $"{DisplayName} · getting round" );
		else if ( beforeStage < 2 && FatStage >= 2 )
			TankSim.ShowBanner( $"{DisplayName} · thick. New look." );
		else if ( beforeStage < 3 && FatStage >= 3 )
			TankSim.ShowBanner( $"{DisplayName} · meatball unit" );
	}

	/// <summary>Weed gummies kick in — floaty stats + trippy VFX timer.</summary>
	public void GetStoned()
	{
		var was = Stoned;
		Stoned = true;
		StonedTimer = MathF.Max( StonedTimer, WeedHighDuration );
		// Soft stats: chill (endurance), munchies path already fat; speed tanks, agility wavy.
		var r = (float)(InstanceId?.GetHashCode() ?? 0);
		r = MathF.Abs( r % 1000f ) / 1000f;
		var endGain = 0.35f + r * 0.25f;
		var agiGain = 0.2f + (1f - r) * 0.2f;
		var spdLoss = 0.15f + r * 0.12f;

		Endurance = MathF.Min( 10f, Endurance + endGain );
		Agility = MathF.Min( 10f, Agility + agiGain );
		SpeedStat = MathF.Max( 0.5f, SpeedStat - spdLoss );

		// Drift like a lava lamp
		Vx *= 0.45f;
		Vy *= 0.45f;
		BobAmp = MathF.Min( 12f, BobAmp + 1.8f );
		BobSpeed = MathF.Max( 0.4f, BobSpeed * 0.7f );

		StatFloat.SpawnWeedHigh( this, endGain, agiGain, spdLoss );

		if ( !was )
			TankSim.ShowBanner( $"{Species?.Name ?? "Fish"} · baked" );
	}

	public void TickStoned( float dt )
	{
		if ( !Stoned || dt <= 0f )
			return;
		StonedTimer -= dt;
		if ( StonedTimer <= 0f )
		{
			Stoned = false;
			StonedTimer = 0f;
			BobAmp = MathF.Max( 2f, BobAmp * 0.7f );
		}
	}

	/// <summary>Slow burn-off when not slamming pellets.</summary>
	public void TickFat( float dt )
	{
		if ( Fat <= 0f || dt <= 0f )
			return;
		// Eggs need the belly — don't burn fat while pregnant.
		if ( IsPregnant )
			return;
		// ~90s empty from full if they stop eating; faster burn while jacked/meth.
		// Stuffed fish keep the fat sheet a little longer so the joke lands.
		var burn = FatNorm > 0.55f ? 1.05f : 1.25f;
		if ( Jacked )
			burn *= 1.25f;
		if ( OnMeth )
			burn *= 1.4f;
		// Stoned munchies hold the belly longer.
		if ( Stoned )
			burn *= 0.5f;
		Fat = MathF.Max( 0f, Fat - burn * dt );
	}

	/// <summary>Short status label for the fish menu.</summary>
	public string FatStatusLabel =>
		IsPregnant
			? "Eggs"
			: FatStage switch
			{
				1 => "Chonk",
				2 => "Unit",
				3 => "Meatball",
				_ => ""
			};
}