FishActor.cs

Model for a single fish in the aquarium game. Stores identity, species, position/velocity, many gameplay states (sickness, drugs, training, fatness, fight injury) and exposes methods to mutate state (nicknames, eating, drug effects, ticking timers) plus many derived properties used for UI and simulation.

File Access
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>0–100 belly from pellets. Overfeeding makes them visibly fat.</summary>
	public float Fat { get; set; }

	public const float WeedHighDuration = 22f;
	/// <summary>How long a lost fight benches a fish.</summary>
	public const float FightInjuryDuration = 180f;

	/// <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");

	/// <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 (belly + speed length + jacked bulk).</summary>
	public float DrawScaleX
	{
		get
		{
			var s = 1f + FatNorm * 0.95f; // up to ~1.95× wide
			// 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;
			return s;
		}
	}

	/// <summary>Vertical draw scale (fat, endurance bulk, agile fin height).</summary>
	public float DrawScaleY
	{
		get
		{
			var s = 1f + FatNorm * 0.38f; // up to ~1.38× tall
			// 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;
			return s;
		}
	}

	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>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>Income scale for this fish (meth / jacked / withdrawal / sick).</summary>
	public float IncomeScale
	{
		get
		{
			var scale = 1f + TrainingNorm * 0.35f;
			if ( Jacked )
				scale *= 1.55f;
			if ( OnMeth )
				scale *= 1.35f + AddictionNorm * 0.55f;
			else if ( InWithdrawal )
				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 — stacks hard when already full (overfeed).</summary>
	public void EatPellet( float foodChaos = 0f, string foodId = null )
	{
		// First bites fill faster when empty; stuffing a full fish piles on more.
		var bite = 10f + FatNorm * 8f;
		if ( foodChaos > 2f )
			bite += 4f; // weird food = greasier
		// Munchies: weed bites hit the belly harder.
		if ( foodId == FoodDef.WeedGummies.Id || Stoned )
			bite += 6f;

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

		// Lazy after gorging
		Vx *= 0.92f;
		Vy *= 0.92f;
		BobAmp = MathF.Min( 8f, BobAmp + 0.15f );

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

		if ( before < 55f && Fat >= 55f )
			TankSim.ShowBanner( $"{Species?.Name ?? "Fish"} · thick" );
		else if ( before < 85f && Fat >= 85f )
			TankSim.ShowBanner( $"{Species?.Name ?? "Fish"} · meatball" );
	}

	/// <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;
		// ~70s empty from full if they stop eating; faster burn while jacked/meth.
		var burn = 1.35f;
		if ( Jacked )
			burn *= 1.25f;
		if ( OnMeth )
			burn *= 1.4f;
		// Stoned munchies hold the belly longer.
		if ( Stoned )
			burn *= 0.55f;
		Fat = MathF.Max( 0f, Fat - burn * dt );
	}
}