TankSim.cs

Game simulation for the player's dual fish tanks. Manages tank slots (fresh and salt), fish and decor state, spawning, movement/physics, salinity/meth/addiction mechanics, save/load serialization, UI banners and simple economy interactions.

File AccessNetworking
namespace NoChillquarium;

/// <summary>
/// Dual tanks (fresh + salt), swim AI, wrong-water sickness / meth survival.
/// </summary>
public static class TankSim
{
	// Sized for ~1920×1080 play — roomy tank without edge-to-edge chrome.
	public const float Width = 1280f;
	public const float Height = 620f;
	public const int DefaultCapacity = 10;
	public const float SickDeathSeconds = 10f;
	public const double SaltTankCost = 100;

	static readonly Random _rng = new();
	static readonly TankSlot _fresh = new()
	{
		Water = WaterType.Fresh,
		Name = HabitatDef.StarterFresh.Name,
		Capacity = DefaultCapacity,
		Unlocked = true,
		HabitatId = HabitatDef.StarterFresh.Id
	};
	static readonly TankSlot _salt = new()
	{
		Water = WaterType.Salt,
		Name = HabitatDef.StarterSalt.Name,
		Capacity = DefaultCapacity,
		Unlocked = false,
		HabitatId = HabitatDef.StarterSalt.Id
	};

	static TankSlot _active = _fresh;
	static string _banner = "";
	static float _bannerTimer;
	static int _bannerVersion;

	public static float WidthPx => Width;
	public static float HeightPx => Height;

	public static string TankName => _active.Name;
	public static WaterType Water => _active.Water;
	public static int Capacity => _active.Capacity;
	public static IReadOnlyList<FishActor> Fish => _active.Fish;
	public static int FishCount => _active.Fish.Count;
	public static int FrameId { get; private set; }

	/// <summary>Species counts in the tank you're looking at (for HUD census).</summary>
	public static IReadOnlyList<(FishSpecies Species, int Count)> CensusActive()
	{
		if ( _active.Fish.Count == 0 )
			return Array.Empty<(FishSpecies, int)>();

		var map = new Dictionary<string, (FishSpecies Sp, int N)>( StringComparer.OrdinalIgnoreCase );
		foreach ( var f in _active.Fish )
		{
			if ( f?.Species is null )
				continue;
			var id = f.Species.Id;
			if ( map.TryGetValue( id, out var e ) )
				map[id] = (e.Sp, e.N + 1);
			else
				map[id] = (f.Species, 1);
		}

		return map.Values
			.OrderByDescending( x => x.N )
			.ThenBy( x => x.Sp.Name, StringComparer.OrdinalIgnoreCase )
			.Select( x => (x.Sp, x.N) )
			.ToList();
	}
	public static bool IsActive { get; private set; }
	public static bool SaltUnlocked => _salt.Unlocked;
	/// <summary>True when fresh + salt are both available (move between tanks).</summary>
	public static bool HasMultipleTanks => _fresh.Unlocked && _salt.Unlocked;
	public static bool ViewingSalt => _active.Water == WaterType.Salt;
	/// <summary>Label for the other tank when dual tanks are owned.</summary>
	public static string OtherTankLabel => ViewingSalt ? "Fresh" : "Salt";
	public static string Banner => _bannerTimer > 0f ? _banner : "";
	public static int BannerVersion => _bannerVersion;
	public static HabitatDef ActiveHabitat => _active.Habitat;
	public static string HabitatCssClass => ActiveHabitat.CssClass ?? "hab-glass";
	public static string HabitatId => _active.HabitatId ?? ActiveHabitat.Id;
	public static int HabitatRank => _active.HabitatRank;
	public static HabitatDef NextHabitat => HabitatDef.NextFor( _active.Water, _active.HabitatRank );

	/// <summary>
	/// Idle income from <b>all</b> unlocked tanks — switching tabs is only a camera.
	/// </summary>
	public static double IncomePerSecond
	{
		get
		{
			double sum = 0;
			sum += SlotIncome( _fresh );
			if ( _salt.Unlocked )
				sum += SlotIncome( _salt );
			return sum;
		}
	}

	/// <summary>Income for the tank you're looking at only (HUD flavor).</summary>
	public static double ActiveTankIncomePerSecond => SlotIncome( _active );

	static double SlotIncome( TankSlot slot )
	{
		if ( slot is null || !slot.Unlocked )
			return 0;

		double sum = 0;
		foreach ( var fish in slot.Fish )
			sum += fish.Species.IncomePerSecond * fish.IncomeScale;

		foreach ( var piece in slot.Decor )
			sum += piece.Def.ComfortBonus;

		return sum * slot.IncomeMult;
	}

	public static IReadOnlyList<DecorPiece> ActiveDecor => _active.Decor;
	public static int DecorCount => _active.Decor.Count;
	public static int MaxDecor => _active.MaxDecor;

	public static int AddictedFishCount
	{
		get
		{
			var n = 0;
			foreach ( var f in _fresh.Fish )
				if ( f.IsAddicted ) n++;
			if ( _salt.Unlocked )
				foreach ( var f in _salt.Fish )
					if ( f.IsAddicted ) n++;
			return n;
		}
	}

	public static int WithdrawingFishCount
	{
		get
		{
			var n = 0;
			foreach ( var f in _active.Fish )
				if ( f.InWithdrawal ) n++;
			return n;
		}
	}

	public static void StartNewGame()
	{
		_fresh.Fish.Clear();
		_salt.Fish.Clear();
		_fresh.Decor.Clear();
		_salt.Decor.Clear();
		_fresh.ResetToStarter();
		_salt.ResetToStarter();
		_fresh.Unlocked = true;
		_salt.Unlocked = false;
		_active = _fresh;
		IsActive = true;
		FrameId = 0;
		ClearBanner();

		Spawn( FishSpecies.Goldfish );
		Spawn( FishSpecies.Goldfish );
		Spawn( FishSpecies.NeonTetra );
		Spawn( FishSpecies.Betta );

		// Free starter plant so the glass isn't barren.
		TryPlaceDecor( DecorDef.Plant, announce: false );

		ShowBanner( "Fresh tank online." );
	}

	public static void LoadFromSave( SaveData data )
	{
		_fresh.Fish.Clear();
		_salt.Fish.Clear();
		_fresh.Decor.Clear();
		_salt.Decor.Clear();
		FrameId = 0;
		ClearBanner();

		if ( data is null )
		{
			IsActive = false;
			return;
		}

		_fresh.Capacity = data.FreshCapacity > 0 ? data.FreshCapacity : DefaultCapacity;
		_salt.Capacity = data.SaltCapacity > 0 ? data.SaltCapacity : DefaultCapacity;
		_salt.Unlocked = data.SaltUnlocked;
		ApplyHabitatFromSave( _fresh, data.FreshHabitatId, data.FreshTankName );
		ApplyHabitatFromSave( _salt, data.SaltHabitatId, data.SaltTankName );

		LoadFishList( data.FreshFish, _fresh );
		LoadFishList( data.SaltFish, _salt );
		LoadDecorList( data.FreshDecor, _fresh );
		LoadDecorList( data.SaltDecor, _salt );

		// Back-compat: old saves only had one fish list + Water string.
		if ( (_fresh.Fish.Count + _salt.Fish.Count) == 0 && data.Fish is { Count: > 0 } )
		{
			var slot = data.Water == "Salt" ? _salt : _fresh;
			if ( data.Water == "Salt" )
				_salt.Unlocked = true;
			if ( data.Capacity > 0 )
				slot.Capacity = data.Capacity;
			LoadFishList( data.Fish, slot );
		}

		_active = (data.ActiveWater == "Salt" && _salt.Unlocked) ? _salt : _fresh;
		IsActive = _fresh.Fish.Count + _salt.Fish.Count > 0 || _fresh.Unlocked;

		if ( !IsActive )
			return;

		// Old saves placed decor with tiny sizes / wrong Y — re-seat to current defs.
		ReseatAllDecor();
	}

	static void LoadFishList( List<FishSaveData> list, TankSlot slot )
	{
		if ( list is null )
			return;

		foreach ( var entry in list )
		{
			var species = FishSpecies.Find( entry.SpeciesId );
			if ( slot.Fish.Count >= slot.Capacity )
				break;

			var actor = new FishActor
			{
				InstanceId = Guid.NewGuid().ToString( "N" )[..8],
				Species = species,
				X = entry.X,
				Y = entry.Y,
				Vx = entry.Vx,
				Vy = entry.Vy,
				BobPhase = (float)(_rng.NextDouble() * Math.PI * 2.0),
				BobSpeed = 2.2f + (float)_rng.NextDouble() * 2.5f,
				BobAmp = 2.5f + (float)_rng.NextDouble() * 3.5f,
				OnMeth = entry.OnMeth,
				MethTimer = entry.MethTimer > 0 ? entry.MethTimer : (entry.OnMeth ? MethDurationSeconds : 0f),
				SickTimer = entry.SickTimer,
				Addiction = Math.Clamp( entry.Addiction, 0f, 100f ),
				WithdrawalTimer = Math.Max( 0f, entry.WithdrawalTimer ),
				Endurance = entry.Endurance > 0 ? entry.Endurance : 3f,
				SpeedStat = entry.SpeedStat > 0 ? entry.SpeedStat : 3f,
				Agility = entry.Agility > 0 ? entry.Agility : 3f,
				Jacked = entry.Jacked,
				JackedTimer = entry.JackedTimer,
				Fat = Math.Clamp( entry.Fat, 0f, 100f ),
				Stoned = entry.Stoned,
				StonedTimer = entry.Stoned
					? Math.Max( entry.StonedTimer, 1f )
					: Math.Max( 0f, entry.StonedTimer ),
				FightInjuryTimer = Math.Max( 0f, entry.FightInjuryTimer )
			};
			if ( !string.IsNullOrWhiteSpace( entry.Nickname ) )
				actor.TrySetNickname( entry.Nickname );
			slot.Fish.Add( actor );
		}
	}

	static void LoadDecorList( List<DecorSaveData> list, TankSlot slot )
	{
		if ( list is null )
			return;

		foreach ( var entry in list )
		{
			if ( slot.Decor.Count >= slot.MaxDecor )
				break;
			if ( string.IsNullOrWhiteSpace( entry.DefId ) )
				continue;

			slot.Decor.Add( new DecorPiece
			{
				DefId = entry.DefId,
				X = entry.X,
				Y = entry.Y
			} );
		}
	}

	static void ApplyHabitatFromSave( TankSlot slot, string habitatId, string fallbackName )
	{
		var def = HabitatDef.Find( habitatId );
		if ( def is null || !HabitatDef.MatchesTank( def, slot.Water ) )
		{
			// Infer from name for older saves, else starter.
			def = HabitatDef.StarterFor( slot.Water );
			if ( !string.IsNullOrWhiteSpace( fallbackName ) )
			{
				foreach ( var h in HabitatDef.Catalog )
				{
					if ( HabitatDef.MatchesTank( h, slot.Water ) && h.Name == fallbackName )
					{
						def = h;
						break;
					}
				}
			}
		}

		slot.HabitatId = def.Id;
		slot.Name = string.IsNullOrWhiteSpace( fallbackName ) ? def.Name : fallbackName;
		// Capacity already set from save; floor to habitat base.
		slot.Capacity = Math.Max( slot.Capacity, def.BaseCapacity );
	}

	public static void WriteToSave( SaveData data )
	{
		if ( data is null )
			return;

		data.FreshTankName = _fresh.Name;
		data.SaltTankName = _salt.Name;
		data.FreshHabitatId = _fresh.HabitatId;
		data.SaltHabitatId = _salt.HabitatId;
		data.FreshCapacity = _fresh.Capacity;
		data.SaltCapacity = _salt.Capacity;
		data.SaltUnlocked = _salt.Unlocked;
		data.ActiveWater = _active.Water == WaterType.Salt ? "Salt" : "Fresh";
		// Legacy fields
		data.TankName = _active.Name;
		data.Water = data.ActiveWater;
		data.Capacity = _active.Capacity;
		data.FreshFish = SerializeFish( _fresh.Fish );
		data.SaltFish = SerializeFish( _salt.Fish );
		data.Fish = SerializeFish( _active.Fish );
		data.FreshDecor = SerializeDecor( _fresh.Decor );
		data.SaltDecor = SerializeDecor( _salt.Decor );
	}

	static List<FishSaveData> SerializeFish( List<FishActor> list ) =>
		list.Select( f => new FishSaveData
		{
			SpeciesId = f.Species.Id,
			Nickname = string.IsNullOrWhiteSpace( f.Nickname ) ? null : f.Nickname.Trim(),
			X = f.X,
			Y = f.Y,
			Vx = f.Vx,
			Vy = f.Vy,
			OnMeth = f.OnMeth,
			MethTimer = f.MethTimer,
			SickTimer = f.SickTimer,
			Addiction = f.Addiction,
			WithdrawalTimer = f.WithdrawalTimer,
			Endurance = f.Endurance,
			SpeedStat = f.SpeedStat,
			Agility = f.Agility,
			Jacked = f.Jacked,
			JackedTimer = f.JackedTimer,
			Fat = f.Fat,
			Stoned = f.Stoned,
			StonedTimer = f.StonedTimer,
			FightInjuryTimer = f.FightInjuryTimer
		} ).ToList();

	static List<DecorSaveData> SerializeDecor( List<DecorPiece> list ) =>
		list.Select( d => new DecorSaveData
		{
			DefId = d.DefId,
			X = d.X,
			Y = d.Y
		} ).ToList();

	public static void Clear()
	{
		_fresh.Fish.Clear();
		_salt.Fish.Clear();
		_fresh.Decor.Clear();
		_salt.Decor.Clear();
		_fresh.ResetToStarter();
		_salt.ResetToStarter();
		_active = _fresh;
		IsActive = false;
		FrameId = 0;
		ClearBanner();
	}

	public static bool TryPlaceDecor( DecorDef def, bool announce = true )
	{
		if ( def is null || !_active.Unlocked )
			return false;

		if ( _active.Decor.Count >= _active.MaxDecor )
		{
			if ( announce )
				ShowBanner( "Tank is full of junk. Tasteful junk limit reached." );
			return false;
		}

		var w = def.Width;
		var h = def.Height;
		var margin = 20f;
		var maxW = Width - margin * 2f;
		if ( w > maxW )
			w = maxW;

		var x = margin + (float)_rng.NextDouble() * MathF.Max( 1f, Width - w - margin * 2f );
		var y = ComputeDecorY( def, h );

		_active.Decor.Add( new DecorPiece
		{
			DefId = def.Id,
			X = x,
			Y = y
		} );

		if ( def.ChaosOnPlace > 0f )
			Chaos.Add( def.ChaosOnPlace );

		if ( announce )
			ShowBanner( $"Placed {def.Name}." );

		FrameId++;
		return true;
	}

	/// <summary>
	/// Bottom of sprite sits on gravel, then sinks by <see cref="DecorDef.GroundSink"/>
	/// so plant dirt bases don't float above the sand.
	/// </summary>
	public static float ComputeDecorRestY( DecorDef def, float h )
	{
		const float gravelH = 28f;
		var gravelTop = Height - gravelH;
		var sink = def?.GroundSink ?? 0f;
		// Panel top = gravel top - height + sink (positive sink buries the base).
		var y = gravelTop - h + sink;
		// Keep a few px of headroom under the rim.
		if ( y < 6f )
			y = 6f;
		return y;
	}

	// Back-compat alias used by place/reseat paths.
	static float ComputeDecorY( DecorDef def, float h ) => ComputeDecorRestY( def, h );

	public static float ClampDecorX( DecorDef def, float x )
	{
		if ( def is null )
			return x;
		var maxX = MathF.Max( 8f, Width - def.Width - 8f );
		return Math.Clamp( x, 8f, maxX );
	}

	public static float ClampDecorY( DecorDef def, float y )
	{
		if ( def is null )
			return y;
		var rest = ComputeDecorRestY( def, def.Height );
		// Allow floating above gravel; never below rest (buried base).
		return Math.Clamp( y, 6f, rest );
	}

	public static DecorPiece GetActiveDecor( int index )
	{
		if ( index < 0 || index >= _active.Decor.Count )
			return null;
		return _active.Decor[index];
	}

	public static int IndexOfActiveDecor( DecorPiece piece )
	{
		if ( piece is null )
			return -1;
		return _active.Decor.IndexOf( piece );
	}

	/// <summary>Start a player drag — freezes physics on that piece.</summary>
	public static bool TryBeginDecorDrag( DecorPiece piece )
	{
		if ( piece is null || !_active.Decor.Contains( piece ) )
			return false;
		// Only one held prop at a time.
		foreach ( var p in _active.Decor )
			p.Held = false;
		piece.Held = true;
		piece.Vx = 0f;
		piece.Vy = 0f;
		// Lift slightly so it clearly leaves the gravel while grabbed.
		var def = piece.Def;
		if ( def is not null )
		{
			var rest = ComputeDecorRestY( def, def.Height );
			if ( piece.Y > rest - 18f )
				piece.Y = rest - 18f;
		}
		FrameId++;
		return true;
	}

	/// <summary>Nudge a held decor by mouse delta (tank px).</summary>
	public static void NudgeDecorHeld( DecorPiece piece, float dx, float dy )
	{
		if ( piece is null || !piece.Held )
			return;
		var def = piece.Def;
		if ( def is null )
			return;
		piece.X = ClampDecorX( def, piece.X + dx );
		piece.Y = ClampDecorY( def, piece.Y + dy );
		FrameId++;
	}

	/// <summary>Snap held decor so its center sits at tank-local (cx, cy).</summary>
	public static void SetDecorHeldCenter( DecorPiece piece, float cx, float cy )
	{
		if ( piece is null || !piece.Held )
			return;
		var def = piece.Def;
		if ( def is null )
			return;
		piece.X = ClampDecorX( def, cx - def.Width * 0.5f );
		piece.Y = ClampDecorY( def, cy - def.Height * 0.5f );
		FrameId++;
	}

	/// <summary>Release drag and apply throw velocity (tank px/s).</summary>
	public static void EndDecorDrag( DecorPiece piece, float throwVx, float throwVy )
	{
		if ( piece is null )
			return;
		piece.Held = false;
		var def = piece.Def;

		const float maxThrow = 2600f;
		var spd = MathF.Sqrt( throwVx * throwVx + throwVy * throwVy );
		if ( spd > maxThrow && spd > 0.01f )
		{
			var s = maxThrow / spd;
			throwVx *= s;
			throwVy *= s;
			spd = maxThrow;
		}

		// No deadzone kill — caller decides soft-drop by passing zeros.
		piece.Vx = throwVx;
		piece.Vy = throwVy;

		if ( spd >= 1f && def is not null )
		{
			var rest = ComputeDecorRestY( def, def.Height );
			// Leave the sand so the floor solver can't eat the launch.
			piece.Y = MathF.Min( piece.Y, rest - 48f );
			// Always give a bit of loft so throws leave gravel even on flat flicks.
			if ( piece.Vy > -220f )
				piece.Vy = -220f - MathF.Abs( piece.Vx ) * 0.2f;
			piece.ThrowGrace = 0.55f;
		}
		else if ( def is not null )
		{
			var rest = ComputeDecorRestY( def, def.Height );
			if ( piece.Y > rest - 28f )
				piece.Y = rest;
			piece.Vx = 0f;
			piece.Vy = 0f;
			piece.ThrowGrace = 0f;
		}

		FrameId++;
		SaveGame.TrySave();
	}

	/// <summary>Water physics for floating / thrown decor.</summary>
	static void TickDecorPhysics( TankSlot slot, float dt )
	{
		if ( slot is null || slot.Decor.Count == 0 || dt <= 0f )
			return;

		const float gravity = 650f;
		const float waterDrag = 0.85f;
		const float bounce = 0.55f;
		const float floorFriction = 0.92f;
		const float restSpeed = 18f;

		var any = false;
		foreach ( var piece in slot.Decor )
		{
			if ( piece is null || piece.Held )
				continue;
			var def = piece.Def;
			if ( def is null )
				continue;

			if ( piece.ThrowGrace > 0f )
				piece.ThrowGrace = MathF.Max( 0f, piece.ThrowGrace - dt );

			var restY = ComputeDecorRestY( def, def.Height );
			var grace = piece.ThrowGrace > 0f;
			var moving = MathF.Abs( piece.Vx ) > 0.5f || MathF.Abs( piece.Vy ) > 0.5f;
			var onFloor = piece.Y >= restY - 1.5f && !grace;

			if ( !moving && onFloor )
			{
				piece.Vx = 0f;
				piece.Vy = 0f;
				piece.Y = restY;
				continue;
			}

			any = true;

			// Gravity (always during grace so arc looks right after loft).
			piece.Vy += gravity * dt;

			var damp = MathF.Exp( -waterDrag * dt );
			piece.Vx *= damp;
			piece.Vy *= damp;

			piece.X += piece.Vx * dt;
			piece.Y += piece.Vy * dt;

			var maxX = MathF.Max( 8f, Width - def.Width - 8f );
			if ( piece.X < 8f )
			{
				piece.X = 8f;
				piece.Vx = MathF.Abs( piece.Vx ) * bounce;
			}
			else if ( piece.X > maxX )
			{
				piece.X = maxX;
				piece.Vx = -MathF.Abs( piece.Vx ) * bounce;
			}

			if ( piece.Y < 6f )
			{
				piece.Y = 6f;
				piece.Vy = MathF.Abs( piece.Vy ) * bounce * 0.45f;
			}

			if ( piece.Y >= restY )
			{
				piece.Y = restY;
				if ( piece.Vy > 0f )
					piece.Vy = -piece.Vy * bounce;
				if ( !grace )
					piece.Vx *= floorFriction;

				if ( !grace
					&& MathF.Abs( piece.Vy ) < restSpeed
					&& MathF.Abs( piece.Vx ) < restSpeed )
				{
					piece.Vx = 0f;
					piece.Vy = 0f;
					piece.Y = restY;
					piece.ThrowGrace = 0f;
				}
			}
		}

		if ( any )
			FrameId++;
	}

	/// <summary>Re-seat every decor piece to current sizes (fixes old tiny/float saves).</summary>
	public static void ReseatAllDecor()
	{
		ReseatSlot( _fresh );
		if ( _salt.Unlocked )
			ReseatSlot( _salt );
		FrameId++;
	}

	static void ReseatSlot( TankSlot slot )
	{
		if ( slot is null )
			return;
		foreach ( var piece in slot.Decor )
		{
			var def = piece.Def;
			if ( def is null )
				continue;
			var h = def.Height;
			// Keep X; clamp so wide props stay on-screen.
			piece.X = ClampDecorX( def, piece.X );
			piece.Y = ComputeDecorY( def, h );
			piece.Vx = 0f;
			piece.Vy = 0f;
			piece.Held = false;
			piece.ThrowGrace = 0f;
		}
	}

	public static bool TryUnlockSaltTank()
	{
		if ( _salt.Unlocked )
			return false;

		_salt.Unlocked = true;
		_salt.ResetToStarter();
		ShowBanner( "Saltwater tank unlocked. Anemones not included." );
		return true;
	}

	/// <summary>Upgrade the active tank to the next habitat in the chain.</summary>
	public static bool TryUpgradeHabitat( HabitatDef def )
	{
		if ( def is null || !_active.Unlocked )
			return false;

		if ( def.FightPrizeOnly )
		{
			ShowBanner( "That tank is a fight prize — win it in the arena." );
			return false;
		}

		if ( !HabitatDef.MatchesTank( def, _active.Water ) )
		{
			ShowBanner( "That habitat doesn't fit this water type." );
			return false;
		}

		if ( def.Rank != _active.HabitatRank + 1 )
		{
			ShowBanner( "Unlock habitats in order. No skipping the bathtub." );
			return false;
		}

		_active.ApplyHabitat( def );
		ShowBanner( string.IsNullOrWhiteSpace( def.Banner ) ? $"Moved into {def.Name}." : def.Banner );
		FrameId++;
		return true;
	}

	/// <summary>
	/// Claim a tank won in battle — applies to the active tank (fresh or salt view).
	/// Keeps capacity floor if already larger; renames the vessel.
	/// </summary>
	public static bool TryApplyFightPrize( HabitatDef def )
	{
		if ( def is null || !IsActive || !_active.Unlocked )
			return false;

		if ( !HabitatDef.MatchesTank( def, _active.Water ) )
		{
			// Fall back to the other tank if it fits and is unlocked
			var other = _active == _fresh ? _salt : _fresh;
			if ( other.Unlocked && HabitatDef.MatchesTank( def, other.Water ) )
			{
				other.ApplyHabitat( def, keepCapacityFloor: true );
				other.Name = def.Name;
				ShowBanner( string.IsNullOrWhiteSpace( def.Banner )
					? $"Fight prize: {def.Name} installed on {other.Water} tank!"
					: def.Banner );
				FrameId++;
				return true;
			}
			ShowBanner( $"{def.Name} doesn't fit your current tank water." );
			return false;
		}

		_active.ApplyHabitat( def, keepCapacityFloor: true );
		_active.Name = def.Name;
		ShowBanner( string.IsNullOrWhiteSpace( def.Banner )
			? $"Fight prize tank: {def.Name}!"
			: def.Banner );
		GameAudio.PlayUi( GameAudio.Upgrade );
		FrameId++;
		return true;
	}

	public static bool CanUpgradeTo( HabitatDef def )
	{
		if ( def is null || !_active.Unlocked || !IsActive )
			return false;
		if ( !HabitatDef.MatchesTank( def, _active.Water ) )
			return false;
		if ( def.Rank != _active.HabitatRank + 1 )
			return false;
		if ( Economy.PlaytimeSeconds < def.UnlockPlaytime )
			return false;
		return true;
	}

	public static bool TrySwitchTank( WaterType water )
	{
		if ( water == WaterType.Salt && !_salt.Unlocked )
		{
			ShowBanner( "No salt tank yet. Buy one in the shop." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		_active = water == WaterType.Salt ? _salt : _fresh;
		FrameId++;
		ShowBanner( water == WaterType.Salt ? "Now viewing SALT tank." : "Now viewing FRESH tank." );
		GameAudio.PlayUi( GameAudio.Notice );
		return true;
	}

	public static void IncreaseCapacity( int amount )
	{
		if ( amount <= 0 )
			return;

		_active.Capacity += amount;
	}

	public static bool RemoveFish( string instanceId )
	{
		if ( string.IsNullOrEmpty( instanceId ) )
			return false;

		var removed = _active.Fish.RemoveAll( f => f.InstanceId == instanceId ) > 0;
		if ( removed )
			FrameId++;
		return removed;
	}

	public static FishActor FindFish( string instanceId )
	{
		if ( string.IsNullOrEmpty( instanceId ) )
			return null;
		foreach ( var f in _active.Fish )
		{
			if ( f.InstanceId == instanceId )
				return f;
		}
		return null;
	}

	/// <summary>Shop buyback — ~half of buy cost, fat/jacked tweak. Removes fish.</summary>
	public static double SellPrice( FishActor fish )
	{
		if ( fish?.Species is null )
			return 1;
		var basePrice = Math.Max( 1.0, fish.Species.BuyCost * 0.5 );
		if ( fish.Jacked )
			basePrice *= 1.25;
		if ( fish.FatNorm > 0.5f )
			basePrice *= 0.9; // chonk is harder to flip
		if ( fish.IsSick )
			basePrice *= 0.35;
		return Math.Max( 1.0, Math.Round( basePrice, 1 ) );
	}

	public static bool TrySellFish( string instanceId )
	{
		var fish = FindFish( instanceId );
		if ( fish is null )
			return false;

		var price = SellPrice( fish );
		var name = fish.DisplayName;
		if ( !RemoveFish( instanceId ) )
			return false;

		Economy.Add( price );
		GameAudio.PlayCoin();
		ShowBanner( $"Sold {name} for Ð{Economy.FormatDoge( price )}." );
		Karma.Add( -0.5f );
		return true;
	}

	/// <summary>
	/// Move a fish from the active tank to the other unlocked tank.
	/// Switches the camera to the destination so you see them arrive.
	/// </summary>
	public static bool TryMoveFishToOtherTank( string instanceId )
	{
		if ( !IsActive || !HasMultipleTanks )
		{
			ShowBanner( "Need both tanks to move fish." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var fish = FindFish( instanceId );
		if ( fish is null )
		{
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var dest = ViewingSalt ? _fresh : _salt;
		var destLabel = ViewingSalt ? "Fresh" : "Salt";

		if ( dest.Fish.Count >= dest.Capacity )
		{
			ShowBanner( $"{destLabel} tank is full ({dest.Capacity})." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		// Drop M80 tape / fight roster if this fish was armed or picked.
		ExplodeSystem.Untape( instanceId );
		BattleSystem.DeselectFish( instanceId );

		_active.Fish.RemoveAll( f => f.InstanceId == instanceId );

		// Drop into the middle of the other tank with a mild kick.
		var marginX = fish.Species.Width * fish.DrawScaleX * 0.5f + 12f;
		var marginY = fish.Species.Height * fish.DrawScaleY * 0.5f + 12f;
		fish.X = marginX + (float)_rng.NextDouble() * (Width - marginX * 2f);
		fish.Y = marginY + (float)_rng.NextDouble() * (Height - marginY * 2f);
		var ang = (float)(_rng.NextDouble() * Math.PI * 2.0);
		var spd = fish.Species.Speed * fish.SpeedMoveMul * (0.55f + (float)_rng.NextDouble() * 0.35f);
		fish.Vx = MathF.Cos( ang ) * spd;
		fish.Vy = MathF.Sin( ang ) * spd * 0.45f;

		dest.Fish.Add( fish );
		_active = dest;

		var name = fish.DisplayName;
		var wrong = fish.IsWrongWater( dest.Water );
		if ( wrong )
		{
			ShowBanner( $"Moved {name} → {destLabel}. Wrong water ⚠" );
			Chaos.Add( 2f );
			Karma.Add( -1.5f );
		}
		else
		{
			ShowBanner( $"Moved {name} → {destLabel}." );
		}

		GameAudio.PlayUi( GameAudio.Confirm );
		SaveGame.TrySave();
		FrameId++;
		return true;
	}

	/// <summary>
	/// Trade for a different random species of the same water (stats mostly reset).
	/// Small fee if upgrade; refund crumbs if downgrade.
	/// </summary>
	public static bool TryTradeFish( string instanceId )
	{
		var fish = FindFish( instanceId );
		if ( fish is null )
			return false;

		var pool = FishSpecies.AllCatalog
			.Where( s => s.Water == fish.Species.Water || s.Water == WaterType.Any )
			.Where( s => s.Id != fish.Species.Id )
			.ToList();
		if ( pool.Count == 0 )
		{
			ShowBanner( "Nothing to trade for. Lonely catalog." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var next = pool[_rng.Next( pool.Count )];
		var fee = Math.Max( 0, Math.Round( (next.BuyCost - fish.Species.BuyCost) * 0.35, 1 ) );
		if ( fee > 0 && !Economy.TrySpend( fee ) )
		{
			ShowBanner( $"Trade needs Ð{Economy.FormatDoge( fee )}." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( fee < 0 )
			Economy.Add( Math.Abs( fee ) );

		var old = fish.Species.Name;
		fish.Species = next;
		// Mild keep of training; drop jacked/meth comedy state for fairness
		fish.Jacked = false;
		fish.JackedTimer = 0f;
		fish.OnMeth = false;
		fish.MethTimer = 0f;
		fish.Stoned = false;
		fish.StonedTimer = 0f;
		fish.Fat = MathF.Max( 0f, fish.Fat * 0.5f );
		fish.Vx = fish.Species.Speed * (fish.Vx >= 0 ? 0.7f : -0.7f);

		GameAudio.PlayUi( GameAudio.Trade );
		ShowBanner( fee > 0
			? $"Traded {old} → {next.Name} (−Ð{Economy.FormatDoge( fee )})."
			: $"Traded {old} → {next.Name}." );
		FrameId++;
		return true;
	}

	public static bool Spawn( FishSpecies species )
	{
		if ( species is null || _active.Fish.Count >= _active.Capacity )
			return false;

		var marginX = species.Width * 0.5f + 8f;
		var marginY = species.Height * 0.5f + 8f;
		var angle = (float)(_rng.NextDouble() * Math.PI * 2.0);
		// New fish start at base training (~3) so SpeedMoveMul ≈ 1.
		var speed = species.Speed * (0.65f + (float)_rng.NextDouble() * 0.5f);

		var fish = new FishActor
		{
			InstanceId = Guid.NewGuid().ToString( "N" )[..8],
			Species = species,
			X = marginX + (float)_rng.NextDouble() * (Width - marginX * 2f),
			Y = marginY + (float)_rng.NextDouble() * (Height - marginY * 2f),
			Vx = MathF.Cos( angle ) * speed,
			Vy = MathF.Sin( angle ) * speed * 0.55f,
			BobPhase = (float)(_rng.NextDouble() * Math.PI * 2.0),
			BobSpeed = 2.2f + (float)_rng.NextDouble() * 2.5f,
			BobAmp = 2.5f + (float)_rng.NextDouble() * 3.5f,
			Endurance = 3f,
			SpeedStat = 3f,
			Agility = 3f
		};

		_active.Fish.Add( fish );

		if ( fish.IsWrongWater( _active.Water ) )
		{
			ShowBanner( $"{species.Name} in {_active.Water} water. Science will notice." );
			Chaos.Add( 4f );
			Karma.Add( -3f );
			Achievements.OnWrongWaterSpawn();
		}

		return true;
	}

	public const float MethDurationSeconds = 28f;

	/// <summary>Dose every fish in the active tank. Builds addiction, clears withdrawal, zoomies.</summary>
	public static int ApplyMethToActiveTank()
	{
		var count = 0;
		var savedWrongWater = 0;
		var newlyHooked = 0;
		foreach ( var fish in _active.Fish )
		{
			var wasWrong = fish.IsWrongWater( _active.Water ) || fish.IsSick;
			var wasAddicted = fish.IsAddicted;

			// Chemical gym first (uses pre-dose addiction for diminishing returns).
			TrainingSystem.ApplyMethTraining( fish );

			// Tolerance: more addiction → shorter high, nastier redose need.
			var duration = MethDurationSeconds * (1f - fish.AddictionNorm * 0.55f);
			duration = MathF.Max( 8f, duration );

			// Stack the habit hard on each dose.
			var before = fish.Addiction;
			fish.Addiction = Math.Clamp( fish.Addiction + 16f + fish.AddictionNorm * 10f, 0f, 100f );
			if ( !wasAddicted && fish.IsAddicted )
			{
				newlyHooked++;
				Achievements.OnFishAddicted();
			}

			fish.OnMeth = true;
			fish.MethTimer = duration;
			fish.WithdrawalTimer = 0f;
			fish.SickTimer = 0f;

			// Launch manic sprint — harder the more hooked; trained SPD still matters.
			var angle = (float)(_rng.NextDouble() * Math.PI * 2.0);
			var burst = fish.Species.Speed * fish.SpeedMoveMul * (2.4f + fish.AddictionNorm * 1.2f);
			fish.Vx = MathF.Cos( angle ) * burst;
			fish.Vy = MathF.Sin( angle ) * burst * 0.75f;
			count++;
			if ( wasWrong )
				savedWrongWater++;

			if ( fish.Addiction >= 75f && before < 75f )
				ShowBanner( $"{fish.Species.Name} is a full-time client now." );
		}

		if ( count > 0 )
		{
			Chaos.Add( 10f + count * 2.5f + newlyHooked * 4f );
			Achievements.OnTrained();
			if ( newlyHooked > 0 )
				ShowBanner( $"{count} dosed + trained. {newlyHooked} just got hooked." );
			else if ( savedWrongWater > 0 )
				ShowBanner( $"{count} fish FRIED · stats up. {savedWrongWater} wrong-water survivors." );
			else
				ShowBanner( $"{count} fish juiced · SPD training on the house." );
		}
		else
		{
			ShowBanner( "Empty tank. Meth has no audience." );
		}

		FrameId++;
		return count;
	}

	public static void Tick( float dt )
	{
		if ( !IsActive || dt <= 0f )
			return;

		if ( dt > 0.05f )
			dt = 0.05f;

		if ( _bannerTimer > 0f )
		{
			_bannerTimer -= dt;
			if ( _bannerTimer <= 0f )
				_bannerVersion++;
		}

		// Swim + salinity for the tank you're watching.
		TickSlot( _active, dt, visual: true );

		// Off-screen tank still suffers salinity (cruel but fair).
		var other = _active == _fresh ? _salt : _fresh;
		if ( other.Unlocked )
			TickSlot( other, dt, visual: false );

		// Decor tumble / throw settle (both tanks so mid-flight props keep moving).
		TickDecorPhysics( _active, dt );
		if ( _salt.Unlocked )
			TickDecorPhysics( _salt, dt );

		FrameId++;
	}

	static void TickSlot( TankSlot slot, float dt, bool visual )
	{
		var recoveredFromFight = 0;
		string recoveredName = null;

		for ( var i = slot.Fish.Count - 1; i >= 0; i-- )
		{
			var fish = slot.Fish[i];
			TickSalinity( slot, fish, dt );

			if ( fish.SickTimer >= SickDeathSeconds && !fish.OnMeth )
			{
				var name = fish.DisplayName;
				var deathX = fish.X;
				var deathY = fish.DrawY;
				slot.Fish.RemoveAt( i );
				if ( visual || slot == _active )
				{
					ShowBanner( $"{name} died of salinity." );
					ExplodeSystem.SpawnDeathPoof( deathX, deathY );
					Achievements.OnFishDiedSalinity();
				}
				Chaos.Add( 3f );
				GameAudio.PlayUi( GameAudio.Error );
				continue;
			}

			TrainingSystem.TickFish( fish, dt );
			fish.TickFat( dt );
			fish.TickStoned( dt );
			if ( fish.TickFightInjury( dt ) )
			{
				recoveredFromFight++;
				recoveredName ??= fish.DisplayName;
			}

			if ( visual )
				Integrate( fish, dt, slot );
		}

		// One banner for fight cooldown ending (not one per fish).
		if ( recoveredFromFight > 0 && (visual || slot == _active) )
		{
			if ( recoveredFromFight == 1 && !string.IsNullOrEmpty( recoveredName ) )
				ShowBanner( $"{recoveredName} is fight-ready again." );
			else
				ShowBanner( $"{recoveredFromFight} fish ready to fight again." );
			GameAudio.PlayUi( GameAudio.Notice );
		}

		if ( visual && slot.Fish.Count > 0 )
		{
			// Per-fish wander: high Agility turns more often and sharper (via NudgeHeading).
			foreach ( var fish in slot.Fish )
			{
				if ( fish.OnMeth )
				{
					if ( _rng.NextDouble() < dt * (3.2f + fish.Agility * 0.25f) )
						NudgeHeadingMeth( fish );
					continue;
				}

				if ( FeedSystem.TryGetNearestPellet( fish.X, fish.Y, out _, out _ ) )
					continue;

				if ( _rng.NextDouble() < dt * fish.WanderRate )
					NudgeHeading( fish );
			}
		}
	}

	static void TickSalinity( TankSlot slot, FishActor fish, float dt )
	{
		// --- High ---
		if ( fish.OnMeth )
		{
			fish.MethTimer -= dt;
			// Habit creeps up while fried.
			fish.Addiction = Math.Clamp( fish.Addiction + dt * 0.35f, 0f, 100f );

			if ( fish.MethTimer <= 0f )
			{
				fish.OnMeth = false;
				fish.MethTimer = 0f;
				// Come-down crash → withdrawal if they've built a habit.
				fish.Vx *= 0.28f;
				fish.Vy *= 0.28f;
				if ( fish.Addiction >= 15f )
				{
					fish.WithdrawalTimer = 10f + fish.AddictionNorm * 22f;
					Achievements.OnWithdrawalStarted();
					if ( slot == _active )
						ShowBanner( $"{fish.Species.Name} crashed. Withdrawal has entered the chat." );
				}
			}
			else
			{
				fish.SickTimer = 0f;
				fish.WithdrawalTimer = 0f;
				if ( _rng.NextDouble() < dt * 0.35 )
					Chaos.Add( 0.25f );
			}
		}
		// --- Withdrawal (sober but hurting) ---
		else if ( fish.WithdrawalTimer > 0f )
		{
			fish.WithdrawalTimer -= dt;
			// Slow natural recovery of the habit while clean.
			fish.Addiction = MathF.Max( 0f, fish.Addiction - dt * 0.55f );
			if ( _rng.NextDouble() < dt * 0.4 )
				Chaos.Add( 0.15f + fish.AddictionNorm * 0.2f );

			// Heavy addicts in withdrawal + wrong water die faster.
			if ( fish.IsWrongWater( slot.Water ) )
				fish.SickTimer += dt * (1f + fish.AddictionNorm * 0.85f);
		}
		else if ( fish.IsAddicted && !fish.OnMeth )
		{
			// Sober addict: very slow recovery, mild restlessness.
			fish.Addiction = MathF.Max( 0f, fish.Addiction - dt * 0.2f );
		}

		if ( !fish.IsWrongWater( slot.Water ) )
		{
			if ( !fish.OnMeth && !fish.InWithdrawal )
				fish.SickTimer = MathF.Max( 0f, fish.SickTimer - dt * 2f );
			return;
		}

		// Wrong water without meth (and not already accelerating via withdrawal branch).
		if ( !fish.OnMeth && fish.WithdrawalTimer <= 0f )
			fish.SickTimer += dt;
	}

	/// <summary>Rename a fish in either tank. Empty name clears nickname.</summary>
	public static bool TryRenameFish( string instanceId, string nickname )
	{
		var fish = FindFish( instanceId );
		if ( fish is null )
			return false;

		if ( !fish.TrySetNickname( nickname ) )
		{
			ShowBanner( "Name needs letters/numbers only." );
			return false;
		}

		// Quiet — fish menu title updates live; no banner spam while cycling names.
		SaveGame.TrySave();
		return true;
	}

	/// <summary>Click a fish — it darts away. Does not feed.</summary>
	public static bool PokeFish( string instanceId )
	{
		if ( !IsActive || string.IsNullOrEmpty( instanceId ) )
			return false;

		FishActor fish = null;
		foreach ( var f in _active.Fish )
		{
			if ( f.InstanceId == instanceId )
			{
				fish = f;
				break;
			}
		}

		if ( fish is null )
			return false;

		// Burst away from center-ish with some randomness so they "scurry".
		var awayX = fish.X - Width * 0.5f;
		var awayY = fish.Y - Height * 0.5f;
		if ( MathF.Abs( awayX ) < 8f && MathF.Abs( awayY ) < 8f )
		{
			awayX = (float)(_rng.NextDouble() * 2.0 - 1.0);
			awayY = (float)(_rng.NextDouble() * 2.0 - 1.0);
		}

		// Bias horizontal escape so facing flip is obvious.
		awayX += (float)(_rng.NextDouble() * 2.0 - 1.0) * 40f;
		awayY += (float)(_rng.NextDouble() * 2.0 - 1.0) * 18f;
		var len = MathF.Sqrt( awayX * awayX + awayY * awayY );
		if ( len < 0.01f )
			len = 1f;

		var burst = fish.Species.Speed * fish.SpeedMoveMul * (2.6f + (float)_rng.NextDouble() * 1.1f);
		if ( fish.Jacked )
			burst *= 1.25f;
		if ( fish.OnMeth )
			burst *= 1.35f;
		// Agile fish snap the dart harder; slow tanks lumber the escape.
		burst *= 0.85f + fish.AgilityTurnMul * 0.2f;

		fish.Vx = awayX / len * burst;
		fish.Vy = awayY / len * burst * 0.72f;
		// High agility = shorter, snappier scurry; low = longer coast.
		fish.ScurryTimer = (0.4f + (float)_rng.NextDouble() * 0.3f) * (1.15f - fish.AgilityNorm * 0.35f);
		fish.BobPhase += 1.2f;
		FrameId++;
		return true;
	}

	static void Integrate( FishActor fish, float dt, TankSlot slot )
	{
		var trainSpd = fish.SpeedMoveMul;
		var trainAgi = fish.AgilityTurnMul;

		if ( fish.ScurryTimer > 0f )
		{
			fish.ScurryTimer = MathF.Max( 0f, fish.ScurryTimer - dt );
			// Agile fish zigzag harder while darting.
			var wiggle = 70f + trainAgi * 55f;
			fish.Vx += ((float)_rng.NextDouble() - 0.5f) * wiggle * dt;
			fish.Vy += ((float)_rng.NextDouble() - 0.5f) * (wiggle * 0.8f) * dt;
			var scurryBob = 2.4f + fish.SpeedNorm;
			fish.BobPhase += fish.BobSpeed * dt * scurryBob;
			var scurryMul = 1.2f + trainSpd * 0.25f;
			fish.X += fish.Vx * dt * scurryMul;
			fish.Y += fish.Vy * dt * scurryMul;
			ClampFish( fish, dt );
			return;
		}

		// Seek flakes when present — trained speed actually chases faster.
		if ( FeedSystem.TryGetNearestPellet( fish.X, fish.Y, out var px, out var py ) )
		{
			var dx = px - fish.X;
			var dy = py - fish.Y;
			var len = MathF.Sqrt( dx * dx + dy * dy );
			if ( len > 1f )
			{
				var seek = fish.Species.Speed * trainSpd * 1.15f * FeedSystem.FishSpeedMul;
				// Agile fish cut corners toward the pellet (blend less with old vel).
				var wantVx = dx / len * seek;
				var wantVy = dy / len * seek * 0.85f;
				var snap = Math.Clamp( fish.TurnSnap, 0.35f, 1f );
				fish.Vx += (wantVx - fish.Vx) * snap;
				fish.Vy += (wantVy - fish.Vy) * snap;
			}
		}

		// Meth zoomies / withdrawal shuffle / sick fade / food buzz.
		// Trained Speed is the base swim multiplier for healthy fish.
		var speedMul = FeedSystem.FishSpeedMul * trainSpd;
		var bobMul = FeedSystem.FishBobMul;
		// Fast fish bob harder (exertion); tanky fish bob slower/steadier.
		bobMul *= 1f + fish.SpeedNorm * 0.35f - fish.EnduranceNorm * 0.12f;

		// Continuous micro-steering: agile fish weave; clumsy fish hold a line.
		if ( !fish.OnMeth && !fish.Stoned && !fish.IsSick && !fish.IsFightInjured )
		{
			var weave = 12f + trainAgi * 38f;
			fish.Vx += ((float)_rng.NextDouble() - 0.5f) * weave * dt;
			fish.Vy += ((float)_rng.NextDouble() - 0.5f) * (weave * 0.7f) * dt;
		}

		// Post-fight injury: sluggish + limp bob until cooldown ends.
		if ( fish.IsFightInjured )
		{
			var limp = 0.45f + (1f - fish.FightInjuryProgress) * 0.35f;
			speedMul *= limp;
			bobMul *= 1.4f + fish.FightInjuryProgress * 0.8f;
			fish.Vy += 10f * fish.FightInjuryProgress * dt;
		}

		// Overfed chonks lumber — still swim, just slower and bouncier.
		if ( fish.FatNorm > 0.2f )
		{
			speedMul *= 1f - fish.FatNorm * 0.32f;
			bobMul *= 1f + fish.FatNorm * 0.9f;
			fish.Vy += fish.FatNorm * 8f * dt; // sink a little under their own weight
		}
		if ( fish.Jacked && !fish.OnMeth )
		{
			speedMul *= 1.45f + fish.SpeedStat * 0.05f;
			bobMul *= 1.8f;
			// Bulk presence — slightly more aggressive wander
			fish.Vx += ((float)_rng.NextDouble() - 0.5f) * 40f * dt;
		}

		if ( fish.OnMeth )
		{
			// Base zoomies + addiction makes them even more unhinged.
			speedMul = (2.7f + fish.AddictionNorm * 0.9f) * trainSpd;
			bobMul = 4.2f + fish.AddictionNorm * 2f;
			if ( fish.Jacked )
				speedMul += 0.4f;
			var thrash = 220f + fish.AddictionNorm * 120f + trainAgi * 40f;
			fish.Vx += ((float)_rng.NextDouble() - 0.5f) * thrash * dt;
			fish.Vy += ((float)_rng.NextDouble() - 0.5f) * (thrash * 0.8f) * dt;
			var spd = MathF.Sqrt( fish.Vx * fish.Vx + fish.Vy * fish.Vy );
			var want = fish.Species.Speed * trainSpd * (2.3f + fish.AddictionNorm * 0.8f);
			if ( spd < want && spd > 0.01f )
			{
				fish.Vx *= want / spd;
				fish.Vy *= want / spd;
			}
			else if ( spd < 0.01f )
			{
				NudgeHeadingMeth( fish );
			}
		}
		else if ( fish.Stoned )
		{
			// Lava-lamp drift — slow, wavy, occasionally forgets where the wall is.
			// Trained speed still helps a little; agility becomes floaty weave.
			speedMul *= 0.42f;
			bobMul *= 2.4f;
			fish.BobPhase += dt * 1.1f;
			fish.Vx += MathF.Sin( fish.BobPhase * 0.7f ) * (22f + trainAgi * 10f) * dt;
			fish.Vy += MathF.Cos( fish.BobPhase * 0.55f ) * (18f + trainAgi * 8f) * dt;
			fish.Vx *= 0.992f;
			fish.Vy *= 0.992f;
		}
		else if ( fish.InWithdrawal )
		{
			// Slow, twitchy, miserable — training barely helps.
			speedMul = MathF.Max( 0.18f, (0.45f - fish.AddictionNorm * 0.2f) * (0.7f + trainSpd * 0.3f) );
			bobMul = 1.6f + fish.AddictionNorm;
			fish.Vx += ((float)_rng.NextDouble() - 0.5f) * 40f * dt;
			fish.Vy += ((float)_rng.NextDouble() - 0.5f) * 35f * dt;
			fish.Vy += 12f * dt; // sink a little, depressed
		}
		else if ( fish.IsSick )
		{
			var p = fish.SickProgress;
			speedMul = MathF.Max( 0.12f, (0.7f - p * 0.55f) * (0.75f + trainSpd * 0.25f) );
			bobMul = 1f + p * 2.8f;
			fish.Vx += ((float)_rng.NextDouble() - 0.5f) * (20f + p * 140f) * dt;
			fish.Vy += ((float)_rng.NextDouble() - 0.5f) * (15f + p * 100f) * dt;
			fish.Vy += (18f + p * 55f) * dt;
		}

		fish.BobPhase += fish.BobSpeed * dt * bobMul;
		fish.X += fish.Vx * dt * speedMul;
		fish.Y += fish.Vy * dt * speedMul;
		ClampFish( fish, dt );
	}

	static void ClampFish( FishActor fish, float dt )
	{
		var halfW = fish.Species.Width * fish.DrawScaleX * 0.5f;
		var halfH = fish.Species.Height * fish.DrawScaleY * 0.5f;
		var minX = halfW + 4f;
		var maxX = Width - halfW - 4f;
		var minY = halfH + 6f;
		var maxY = Height - halfH - 10f;

		// High agility = snappy wall bank; low = soft bounce (lose speed).
		var bank = Math.Clamp( 0.55f + fish.Agility * 0.05f, 0.55f, 1.05f );

		if ( fish.X < minX )
		{
			fish.X = minX;
			fish.Vx = MathF.Abs( fish.Vx ) * bank;
		}
		else if ( fish.X > maxX )
		{
			fish.X = maxX;
			fish.Vx = -MathF.Abs( fish.Vx ) * bank;
		}

		if ( fish.Y < minY )
		{
			fish.Y = minY;
			fish.Vy = MathF.Abs( fish.Vy ) * bank;
		}
		else if ( fish.Y > maxY )
		{
			fish.Y = maxY;
			fish.Vy = -MathF.Abs( fish.Vy ) * bank;
		}

		fish.Vy *= 1f - (0.15f * dt);
		// Cruise floor from species + trained speed (food boost when fed).
		var minSpeed = fish.Species.Speed * fish.SpeedMoveMul * 0.32f;
		if ( FeedSystem.IsBoosted )
			minSpeed *= FeedSystem.FishSpeedMul;
		var speed = MathF.Sqrt( fish.Vx * fish.Vx + fish.Vy * fish.Vy );
		if ( speed < minSpeed && speed > 0.001f )
		{
			var scale = minSpeed / speed;
			fish.Vx *= scale;
			fish.Vy *= scale;
		}
	}

	static void NudgeHeading( FishActor fish )
	{
		// Turn within agility cone around current heading — high AGI = hard cut, low = gentle bank.
		var cur = MathF.Atan2( fish.Vy, fish.Vx );
		if ( float.IsNaN( cur ) )
			cur = 0f;
		var maxDelta = fish.MaxTurnRadians;
		var delta = ((float)_rng.NextDouble() * 2f - 1f) * maxDelta;
		var angle = cur + delta;
		var speed = fish.Species.Speed * fish.SpeedMoveMul * (0.7f + (float)_rng.NextDouble() * 0.45f);
		var wantVx = MathF.Cos( angle ) * speed;
		var wantVy = MathF.Sin( angle ) * speed * (0.35f + fish.AgilityNorm * 0.25f);
		var snap = fish.TurnSnap;
		fish.Vx += (wantVx - fish.Vx) * snap;
		fish.Vy += (wantVy - fish.Vy) * snap;
	}

	static void NudgeHeadingMeth( FishActor fish )
	{
		// Manic sprint — still respects trained speed; agility widens thrash angles.
		var cur = MathF.Atan2( fish.Vy, fish.Vx );
		if ( float.IsNaN( cur ) )
			cur = 0f;
		var maxDelta = MathF.Min( MathF.PI * 0.95f, fish.MaxTurnRadians * 1.35f );
		var delta = ((float)_rng.NextDouble() * 2f - 1f) * maxDelta;
		var angle = cur + delta;
		var speed = fish.Species.Speed * fish.SpeedMoveMul * (2.2f + (float)_rng.NextDouble() * 1.4f);
		var wantVx = MathF.Cos( angle ) * speed;
		var wantVy = MathF.Sin( angle ) * speed * 0.85f;
		var snap = Math.Clamp( fish.TurnSnap + 0.15f, 0.5f, 1f );
		fish.Vx += (wantVx - fish.Vx) * snap;
		fish.Vy += (wantVy - fish.Vy) * snap;
	}

	public static void ShowBanner( string text )
	{
		_banner = text ?? "";
		// Short, discreet overlay — doesn't hold the screen hostage
		_bannerTimer = 2.4f;
		_bannerVersion++;
	}

	static void ClearBanner()
	{
		_banner = "";
		_bannerTimer = 0f;
		_bannerVersion++;
	}
}