TankSim.cs
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;
	/// <summary>Starter glass is tight — capacity pressure is the early game.</summary>
	public const int DefaultCapacity = 4;
	public const float SickDeathSeconds = 10f;
	/// <summary>Second tank is a midgame buy — reachable same session, not a wall.</summary>
	public const double SaltTankCost = 220;

	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;
	static int _abominationsCreated;
	static int _fishDeaths;
	/// <summary>
	/// Fish instance id with the context menu open. Soft-holds thrash so buttons stay usable
	/// (especially meth) without killing the high visuals.
	/// </summary>
	static string _menuFocusId;

	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;
	/// <summary>Living fish across every unlocked tank (story / census totals).</summary>
	public static int TotalFishCount =>
		_fresh.Fish.Count + ( _salt.Unlocked ? _salt.Fish.Count : 0 );

	/// <summary>Any fusion abom / decor-hybrid alive in an unlocked tank.</summary>
	public static bool HasAnyMonster
	{
		get
		{
			foreach ( var f in _fresh.Fish )
			{
				if ( f?.Species?.IsMonster == true )
					return true;
			}
			if ( _salt.Unlocked )
			{
				foreach ( var f in _salt.Fish )
				{
					if ( f?.Species?.IsMonster == true )
						return true;
				}
			}
			return false;
		}
	}

	/// <summary>Lifetime successful combines / evolves this save.</summary>
	public static int AbominationsCreated => _abominationsCreated;
	/// <summary>Lifetime salinity / neglect deaths this save.</summary>
	public static int FishDeaths => _fishDeaths;

	/// <summary>Instance id of fish currently showing the fish menu (null/empty = none).</summary>
	public static string MenuFocusId
	{
		get => _menuFocusId;
		set => _menuFocusId = string.IsNullOrEmpty( value ) ? null : value;
	}

	public static bool IsMenuFocused( string instanceId ) =>
		!string.IsNullOrEmpty( _menuFocusId )
		&& string.Equals( _menuFocusId, instanceId, StringComparison.Ordinal );

	/// <summary>External systems (sharks, etc.) when a living fish is lost to the world.</summary>
	public static void NotifyFishDied()
	{
		_fishDeaths++;
		if ( _fishDeaths == 1 )
			GrandmaReact.OnFirstDeath();
		JanitorPeep.On( "dead" );
		AchievementSystem.Unlock( "dead" );
	}

	/// <summary>Wrong-water death punchline — Grandma claims the corpse (dry love).</summary>
	public static string GrandmaAteLine( string fishName )
	{
		if ( string.IsNullOrWhiteSpace( fishName ) )
			fishName = "That fish";
		// Rotate a few lines so repeat deaths stay funny.
		return (_fishDeaths % 4) switch
		{
			1 => $"{fishName} hit the glass. Grandma: \"Supper. Still love you. Your fault. Wipe anyway.\"",
			2 => $"Grandma plated {fishName}. Wrong water. Kiss on the forehead. Your fault. The kiss is not a pardon.",
			3 => $"{fishName} is gone. Fridge full. Grandma full of opinions. Your fault. She said it nicer than this.",
			_ => $"Wrong water. Grandma ate {fishName}. Hugs later. Blame now. That's the religion."
		};
	}

	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 || f.OnAdventure )
				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.EffectiveIncomePerSecond;

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

		var backdrop = DecorDef.FindBackdrop( slot.BackdropId );
		if ( backdrop is not null )
			sum += backdrop.ComfortBonus;

		var gravel = DecorDef.FindGravel( slot.GravelId );
		if ( gravel is not null )
			sum += gravel.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 string ActiveBackdropId => _active.BackdropId;
	public static DecorDef ActiveBackdrop => DecorDef.FindBackdrop( _active.BackdropId );
	public static bool HasBackdrop => ActiveBackdrop is not null && ActiveBackdrop.HasSprite;
	public static string ActiveGravelId => _active.GravelId;
	public static DecorDef ActiveGravel => DecorDef.FindGravel( _active.GravelId );
	public static bool HasGravel => ActiveGravel is not null && ActiveGravel.HasSprite;
	/// <summary>Always a floor sprite — stock river gravel if they haven't bought a bag.</summary>
	public static string GravelSpriteId =>
		HasGravel ? ActiveGravel.SpriteId
			: (SpriteCatalog.Has( "gravel_brown" ) ? "gravel_brown" : "");

	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;
		_abominationsCreated = 0;
		_fishDeaths = 0;
		ClearBanner();

		// No tank until Grandma gifts it.
		ShowBanner( "Suspended. Walk home. Chin up." );
	}

	/// <summary>
	/// Grandma's fish_tank gift — two goldfish + a free plant on the porch table.
	/// Safe to call more than once; only fills an empty starter tank.
	/// </summary>
	public static void GrantTrailerLotTank()
	{
		if ( !IsActive )
		{
			// Gift can fire while sim is flagged active; never soft-fail silent.
			IsActive = true;
		}

		// Always deliver into the porch (fresh) tank, not whatever view is active.
		_active = _fresh;
		_fresh.Unlocked = true;
		if ( _fresh.Capacity < DefaultCapacity )
			_fresh.Capacity = DefaultCapacity;

		// Already has fish (continue / re-claim) — still ensure a free plant if barren.
		if ( _fresh.Fish.Count > 0 )
		{
			if ( _fresh.Decor.Count == 0 )
			{
				TryPlaceDecor( DecorDef.Plant, announce: false );
				FrameId++;
			}
			return;
		}

		// Start juvenile — babies that print crumbs make the first minute feel broken.
		var a = SpawnInto( _fresh, FishSpecies.Goldfish, FishRarity.Common, ageSeconds: 95f );
		var b = SpawnInto( _fresh, FishSpecies.Goldfish, FishRarity.Common, ageSeconds: 150f );
		if ( _fresh.Decor.Count == 0 )
			TryPlaceDecor( DecorDef.Plant, announce: false );

		FrameId++;
		if ( a && b )
			ShowBanner( "First tank. Two goldfish. Don't drown them." );
		else if ( _fresh.Fish.Count > 0 )
			ShowBanner( "First tank. Try not to kill the hostages." );
		else
		{
			Log.Warning( $"[NO-CHILLquarium] GrantTrailerLotTank failed — capacity={_fresh.Capacity}, fish={_fresh.Fish.Count}" );
			ShowBanner( "Tank's on the porch… but the goldfish slipped out. Try again?" );
		}
	}

	/// <summary>Spawn into a specific slot (used by starter grant so we never miss _active).</summary>
	static bool SpawnInto( TankSlot slot, FishSpecies species, FishRarity rarity, float ageSeconds ) =>
		SpawnInto( slot, species, rarity, ageSeconds, x: null, y: null );

	static bool SpawnInto( TankSlot slot, FishSpecies species, FishRarity rarity, float ageSeconds, float? x, float? y )
	{
		if ( slot is null || species is null )
			return false;
		if ( slot.Capacity < 2 )
			slot.Capacity = DefaultCapacity;
		if ( slot.Fish.Count >= slot.Capacity )
			return false;

		var prev = _active;
		_active = slot;
		var ok = Spawn( species, rarity, ageSeconds );
		if ( ok && ( x.HasValue || y.HasValue ) && slot.Fish.Count > 0 )
		{
			var baby = slot.Fish[^1];
			if ( x.HasValue )
				baby.X = Math.Clamp( x.Value, 8f, Width - 8f );
			if ( y.HasValue )
				baby.Y = Math.Clamp( y.Value, 8f, Height - 8f );
		}
		_active = prev ?? _fresh;
		return ok;
	}

	/// <summary>Spawn at a tank-local point (egg hatch, scripted births).</summary>
	public static bool SpawnAt( FishSpecies species, FishRarity rarity, float ageSeconds, float x, float y, TankSlot slot = null )
	{
		slot ??= _active;
		return SpawnInto( slot, species, rarity, ageSeconds, x, y );
	}

	/// <summary>Currently viewed tank (null only if sim never set up).</summary>
	public static TankSlot ActiveSlotOrNull => _active;

	public static FishActor FindFishInSlot( TankSlot slot, string instanceId )
	{
		if ( slot is null || string.IsNullOrEmpty( instanceId ) )
			return null;
		foreach ( var f in slot.Fish )
		{
			if ( f.InstanceId == instanceId )
				return f;
		}
		return null;
	}

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

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

		_abominationsCreated = Math.Max( 0, data.AbominationsCreated );
		_fishDeaths = Math.Max( 0, data.FishDeaths );

		_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 );
		_fresh.BackdropId = DecorDef.FindBackdrop( data.FreshBackdropId )?.Id;
		_salt.BackdropId = DecorDef.FindBackdrop( data.SaltBackdropId )?.Id;
		_fresh.GravelId = DecorDef.FindGravel( data.FreshGravelId )?.Id;
		_salt.GravelId = DecorDef.FindGravel( data.SaltGravelId )?.Id;

		// 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();
		BreedingSystem.LoadEggs( data.TankEggs );
	}

	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 ),
				// Old saves: treat as adult commons so empires don't shrink to guppies.
				AgeSeconds = entry.AgeSeconds > 0f
					? entry.AgeSeconds
					: FishActor.AdultAgeSeconds,
				PredationBulk = Math.Clamp( entry.PredationBulk, 0f, 100f ),
				FishEatenCount = Math.Max( 0, entry.FishEatenCount ),
				Rarity = FishRarityInfo.FromSave( entry.Rarity ),
				PregnantTimer = Math.Max( 0f, entry.PregnantTimer ),
				MateCooldown = Math.Max( 0f, entry.MateCooldown )
			};
			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;

			// Old saves shouldn't park a poster in the gravel list.
			var maybe = DecorDef.Catalog.FirstOrDefault( d => d.Id == entry.DefId );
			if ( maybe is { IsBackdrop: true } )
			{
				slot.BackdropId = maybe.Id == DecorDef.BackdropNone.Id ? null : maybe.Id;
				continue;
			}
			if ( maybe is { IsSubstrate: true } )
			{
				slot.GravelId = maybe.Id == DecorDef.GravelNone.Id ? null : maybe.Id;
				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 );
		data.FreshBackdropId = _fresh.BackdropId;
		data.SaltBackdropId = _salt.BackdropId;
		data.FreshGravelId = _fresh.GravelId;
		data.SaltGravelId = _salt.GravelId;
		data.AbominationsCreated = _abominationsCreated;
		data.FishDeaths = _fishDeaths;
		data.TankEggs = BreedingSystem.WriteEggs();
	}

	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,
			AgeSeconds = f.AgeSeconds,
			PredationBulk = f.PredationBulk,
			FishEatenCount = f.FishEatenCount,
			Rarity = (int)f.Rarity,
			PregnantTimer = f.PregnantTimer,
			MateCooldown = f.MateCooldown
		} ).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()
	{
		BreedingSystem.Clear();
		_fresh.Fish.Clear();
		_salt.Fish.Clear();
		_fresh.Decor.Clear();
		_salt.Decor.Clear();
		_fresh.ResetToStarter();
		_salt.ResetToStarter();
		_active = _fresh;
		IsActive = false;
		FrameId = 0;
		_abominationsCreated = 0;
		_fishDeaths = 0;
		ClearBanner();
	}

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

		if ( def is null || def.Id == DecorDef.BackdropNone.Id || !def.IsBackdrop )
		{
			_active.BackdropId = null;
			if ( announce )
				ShowBanner( "Peeled the poster. Plain glass." );
			FrameId++;
			return true;
		}

		_active.BackdropId = def.Id;
		if ( def.ChaosOnPlace > 0f )
			Chaos.Add( def.ChaosOnPlace );
		if ( announce )
			ShowBanner( $"Hung {def.Name}." );
		FrameId++;
		return true;
	}

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

		if ( def is null || def.Id == DecorDef.GravelNone.Id || !def.IsSubstrate )
		{
			_active.GravelId = null;
			if ( announce )
				ShowBanner( "Scooped back to lot dirt." );
			FrameId++;
			return true;
		}

		_active.GravelId = def.Id;
		if ( def.ChaosOnPlace > 0f )
			Chaos.Add( def.ChaosOnPlace );
		if ( announce )
			ShowBanner( $"Dumped {def.Name}." );
		FrameId++;
		return true;
	}

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

		if ( def.IsBackdrop )
			return TrySetBackdrop( def, announce );
		if ( def.IsSubstrate )
			return TrySetGravel( def, announce );

		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}." );

		AchievementSystem.Unlock( "decor" );
		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( bool announce = true )
	{
		if ( _salt.Unlocked )
			return false;

		_salt.Unlocked = true;
		_salt.ResetToStarter();
		// Free starter salt fish so the second tank isn't an empty glass.
		var prev = _active;
		_active = _salt;
		Spawn( FishSpecies.Clownfish, FishRarity.Common, ageSeconds: 120f );
		_active = prev ?? _fresh;
		if ( announce )
			ShowBanner( "Salt tank + clownfish. Match the water or she eats them." );
		else
			ShowBanner( "Salt tank online · free clownfish · wrong water = supper." );
		BoomFeel.SoftPunch( 0.55f );
		Economy.AddForced( 12 );
		GameAudio.PlaySaltUnlock();
		GrandmaReact.OnFirstSalt();
		return true;
	}

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

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

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

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

		_active.ApplyHabitat( def );
		if ( announce )
			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 _fresh.Fish )
		{
			if ( f.InstanceId == instanceId )
				return f;
		}
		foreach ( var f in _salt.Fish )
		{
			if ( f.InstanceId == instanceId )
				return f;
		}
		return null;
	}

	/// <summary>Every living fish across both tanks (includes OnAdventure).</summary>
	public static IEnumerable<FishActor> AllFish()
	{
		foreach ( var f in _fresh.Fish )
			yield return f;
		foreach ( var f in _salt.Fish )
			yield return f;
	}

	public static void BumpFrame() => FrameId++;

	/// <summary>
	/// Chillquarium flip price: grow then sell.
	/// Guppies = crumbs; adults profit on commons; rarity is the jackpot.
	/// </summary>
	public static double SellPrice( FishActor fish )
	{
		if ( fish?.Species is null )
			return 1;

		// List: shop cost, or income-based for monsters/hybrids (BuyCost 0).
		var list = fish.Species.BuyCost;
		if ( list < 1.0 )
			list = Math.Max( 18.0, fish.Species.IncomePerSecond * 90.0 );

		// Growth curve: baby still flips · adult ~1.2× · mature ~1.55× list.
		// Floor high enough that early sells fund the next buy (not a guilt tax).
		var growthMul = 0.38 + fish.Growth01 * 0.82 + fish.Mature01 * 0.35;
		var price = list * growthMul * fish.RaritySellMul;

		// Trained aboms flip harder — rewards investment over raw birth.
		if ( fish.IsMonster )
			price *= 1.15 + fish.TrainingNorm * 0.45;
		// Apex predators that snacked are meatier on the market.
		if ( fish.PredationBulkNorm > 0f )
			price *= 1.0 + fish.PredationBulkNorm * 0.4;
		if ( fish.Jacked )
			price *= 1.2;
		if ( fish.FatNorm > 0.5f )
			price *= 0.92; // chonk is harder to flip
		if ( fish.IsSick )
			price *= 0.4;
		return Math.Max( 1.0, Math.Round( price, 1 ) );
	}

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

		var wasMonster = fish.Species?.IsMonster == true;
		var wasFailure = fish.IsAbominationFailure;
		var price = SellPrice( fish );
		var name = fish.DisplayName;
		var sx = fish.X;
		var sy = fish.DrawY;
		var rare = fish.Rarity;
		if ( !RemoveFish( instanceId ) )
			return false;

		// Always float + confetti — sell is the active dopamine verb.
		Economy.AddForced( price );
		ExplodeSystem.SpawnCoinBurst( sx, sy, count: rare >= FishRarity.Rare ? 20 : 12 );
		BoomFeel.SoftPunch( rare >= FishRarity.Rare ? 0.65f : 0.4f );
		StatFloat.PushTag( "SOLD +" + "Ð" + Economy.FormatDoge( price ), "sell", sx, sy - 20f, life: 1.7f );
		if ( rare >= FishRarity.Rare )
			StatFloat.PushTag( FishRarityInfo.Label( rare ).ToUpperInvariant() + "!", "rare", sx, sy - 36f, delay: 0.12f, life: 1.9f );
		if ( rare >= FishRarity.Rare || price >= 40 )
			GameAudio.PlayBigCash();
		else
			GameAudio.PlayCoin();
		if ( wasMonster && wasFailure )
		{
			// Soft flip of a dud — she prefers M80, but cash is fine.
			Karma.Add( 0.4f );
			ShowBanner( $"Sold failure {name} for Ð{Economy.FormatDoge( price )}." );
		}
		else if ( wasMonster )
		{
			Karma.Add( -1.2f );
			ShowBanner( $"Sold {name} for Ð{Economy.FormatDoge( price )}. Grandma wanted fights." );
		}
		else
		{
			Karma.Add( -0.3f );
			ShowBanner( $"Sold {name} for Ð{Economy.FormatDoge( price )}." );
		}
		if ( wasMonster )
			GameAudio.OnMoodMaybeChanged( force: true );
		StorySystem.NotifySold();
		// Routine guppy flips are noise — he only comments on a real goodbye.
		if ( rare >= FishRarity.Rare || wasMonster || price >= 40 )
			JanitorPeep.On( "sell" );
		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;
	}

	/// <summary>Luck tilt for rarity rolls (habitat, salt, aboms, playtime).</summary>
	public static float RarityLuck
	{
		get
		{
			var luck = 1f + Math.Clamp( HabitatRank, 0, 7 ) * 0.09f;
			if ( SaltUnlocked )
				luck += 0.08f;
			if ( HasAnyMonster )
				luck += 0.06f;
			// Mild late-session spice so rares show up during empire hours.
			luck += Math.Clamp( Economy.PlaytimeSeconds / 3600f, 0f, 0.35f );
			return Math.Clamp( luck, 0.8f, 2.6f );
		}
	}

	public static bool Spawn( FishSpecies species ) =>
		Spawn( species, FishRarityInfo.Roll( _rng, RarityLuck ), ageSeconds: 0f );

	/// <summary>Spawn with explicit rarity/age (story starters, debug, packs).</summary>
	public static bool Spawn( FishSpecies species, FishRarity rarity, float ageSeconds = 0f )
	{
		if ( species is null || _active.Fish.Count >= _active.Capacity )
			return false;

		// Size uses growth scale — room for guppies.
		var grow = ageSeconds <= 0f ? 0.50f : Math.Clamp( ageSeconds / FishActor.AdultAgeSeconds, 0.50f, 1.14f );
		var marginX = species.Width * grow * 0.5f + 8f;
		var marginY = species.Height * grow * 0.5f + 8f;
		var angle = (float)(_rng.NextDouble() * Math.PI * 2.0);
		// New fish start at base training (~3) so SpeedMoveMul ≈ 1.
		// Guppies dart a bit quicker relative to size.
		var speed = species.Speed * (0.65f + (float)_rng.NextDouble() * 0.5f) * (1.1f - grow * 0.15f);

		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,
			AgeSeconds = Math.Max( 0f, ageSeconds ),
			Rarity = rarity
		};

		_active.Fish.Add( fish );
		LastSpawnRarity = rarity;
		LastSpawnX = fish.X;
		LastSpawnY = fish.Y;

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

		return true;
	}

	/// <summary>Rarity of the most recent successful <see cref="Spawn"/>.</summary>
	public static FishRarity LastSpawnRarity { get; private set; } = FishRarity.Common;
	public static float LastSpawnX { get; private set; }
	public static float LastSpawnY { get; private set; }

	/// <summary>
	/// Fuse two base fish into a monster. Removes both, spawns abomination, triggers flash VFX.
	/// </summary>
	public static bool TryCombineFish( string idA, string idB )
	{
		if ( !IsActive || string.IsNullOrEmpty( idA ) || string.IsNullOrEmpty( idB ) || idA == idB )
		{
			ShowBanner( "Need two different fish." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var a = FindFish( idA );
		var b = FindFish( idB );
		if ( a is null || b is null )
		{
			ShowBanner( "One of those fish swam off." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var monster = FishFusion.ResolveMonster( a.Species?.Id, b.Species?.Id );
		if ( monster is null )
		{
			ShowBanner( "Those two refuse to fuse." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		// Midpoint of the crime
		var mx = (a.X + b.X) * 0.5f;
		var my = (a.Y + b.Y) * 0.5f;

		// Inherit rarity: max parent, chance to bump one tier
		var rar = (FishRarity)Math.Max( (int)a.Rarity, (int)b.Rarity );
		if ( (int)rar < (int)FishRarity.Mythical && _rng.NextDouble() < 0.18 )
			rar = (FishRarity)((int)rar + 1);

		// Age: half-grown abomination (visible, already earning). Evolutions / tentacles keep bulk.
		var evoBoost = monster.MonsterType >= 30 ? 0.15f : 0f;
		if ( monster.MonsterType >= 40 )
			evoBoost = 0.22f;
		var age = MathF.Max( a.AgeSeconds, b.AgeSeconds ) * (0.55f + evoBoost)
			+ MathF.Min( a.AgeSeconds, b.AgeSeconds ) * 0.2f;
		age = Math.Clamp( age, FishActor.AdultAgeSeconds * 0.35f, FishActor.AdultAgeSeconds * 1.1f );

		var end = Math.Clamp( (a.Endurance + b.Endurance) * 0.55f + 0.8f + evoBoost * 4f, 0.5f, 10f );
		var spd = Math.Clamp( (a.SpeedStat + b.SpeedStat) * 0.55f + 0.6f + evoBoost * 3f, 0.5f, 10f );
		var agi = Math.Clamp( (a.Agility + b.Agility) * 0.55f + 0.6f + evoBoost * 3f, 0.5f, 10f );
		// Monster parent bleeds a little bulk into the beast.
		var parentBulk = MathF.Max( a.PredationBulk, b.PredationBulk );

		// Remove both parents (capacity: -2 before +1 abom).
		var okA = RemoveFish( idA );
		var okB = RemoveFish( idB );
		if ( !okA || !okB )
		{
			ShowBanner( "Combine failed mid-scream." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		// Spawn monster in place (capacity: -2 +1 = net free slot)
		var grow = Math.Clamp( age / FishActor.AdultAgeSeconds, 0.50f, 1.14f );
		var marginX = monster.Width * grow * 0.5f + 8f;
		var marginY = monster.Height * grow * 0.5f + 8f;
		mx = Math.Clamp( mx, marginX, Width - marginX );
		my = Math.Clamp( my, marginY, Height - marginY );

		var angle = (float)(_rng.NextDouble() * Math.PI * 2.0);
		var speed = monster.Speed * (0.7f + (float)_rng.NextDouble() * 0.4f);

		var abom = new FishActor
		{
			InstanceId = Guid.NewGuid().ToString( "N" )[..8],
			Species = monster,
			X = mx,
			Y = my,
			Vx = MathF.Cos( angle ) * speed,
			Vy = MathF.Sin( angle ) * speed * 0.55f,
			BobPhase = (float)(_rng.NextDouble() * Math.PI * 2.0),
			BobSpeed = 3.2f + (float)_rng.NextDouble() * 2.8f,
			BobAmp = 4f + (float)_rng.NextDouble() * 4f,
			Endurance = end,
			SpeedStat = spd,
			Agility = agi,
			AgeSeconds = age,
			Rarity = rar,
			PredationBulk = monster.MonsterType >= 40
				? Math.Clamp( parentBulk * 0.6f + 18f, 0f, 100f )
				: Math.Clamp( parentBulk * 0.35f, 0f, 100f )
		};
		_active.Fish.Add( abom );
		LastSpawnRarity = rar;

		FusionFeel.Trigger( mx, my );
		WaterFx.Splash( mx, my, 16, WaterLayer.Tank );
		WaterFx.Squirt( mx, my, 0f, -1f, 8, 220f, WaterLayer.Tank );
		Chaos.Add( 6f + (int)rar * 1.2f + (monster.MonsterType >= 40 ? 4f : 0f) );
		// Grandma lives for this. Higher tiers = more love + Ð.
		_abominationsCreated++;
		var birthPay = BirthPayout( monster, rar, evolved: monster.MonsterType >= 30 );
		Economy.AddForced( birthPay );
		BoomFeel.SoftPunch( 0.7f );
		StatFloat.PushOverFish( abom, "BORN +" + "Ð" + Economy.FormatDoge( birthPay ), "rare", life: 2f );
		GameAudio.PlayBigWin( bark: rar >= FishRarity.Rare || birthPay >= 40 );
		Karma.Add( 4f + (int)rar * 0.9f + (monster.MonsterType >= 30 ? 4f : 0f) + (monster.MonsterType >= 40 ? 2f : 0f) );
		GameAudio.PlayUi( GameAudio.Inject );
		var born = monster.MonsterType >= 40
			? $"{monster.Name} unfurls. The glass looks smaller. +Ð{Economy.FormatDoge( birthPay )}"
			: monster.MonsterType >= 30
				? $"{monster.Name} evolves. Grandma gasps. +Ð{Economy.FormatDoge( birthPay )}"
				: $"{monster.Name} born. Grandma is into it. +Ð{Economy.FormatDoge( birthPay )}";
		ShowBanner( born );
		StorySystem.NotifyCombined();
		JanitorPeep.On( "combine" );
		GameAudio.OnMoodMaybeChanged( force: true );
		SaveGame.TrySave();
		FrameId++;
		return true;
	}

	/// <summary>Ð grant for creating / evolving an abom — makes combine feel rewarding.</summary>
	public static double BirthPayout( FishSpecies monster, FishRarity rar, bool evolved )
	{
		if ( monster is null )
			return 0;
		var tier = Math.Max( 1, monster.MonsterType );
		// Juicy birth tip — combining should feel like a jackpot, not a fee.
		var pay = 16.0 + tier * 2.2 + (int)rar * 6.0;
		if ( evolved )
			pay += 55;
		// Strong income monsters pay a little more at birth (investment signal).
		pay += monster.IncomePerSecond * 14.0;
		return Math.Max( 14.0, Math.Round( pay, 1 ) );
	}

	/// <summary>
	/// Fuse a base fish with a decor prop → hilarious hybrid abomination.
	/// Removes fish + decor, spawns hybrid, full fusion flash.
	/// </summary>
	public static bool TryCombineFishWithDecor( string fishId, DecorPiece piece )
	{
		if ( !IsActive || string.IsNullOrEmpty( fishId ) || piece is null )
		{
			ShowBanner( "Need a fish and a prop." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var fish = FindFish( fishId );
		if ( fish is null )
		{
			ShowBanner( "That fish swam off." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}
		if ( fish.Species?.IsMonster == true )
		{
			ShowBanner( "Monsters don't eat furniture. Usually." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var def = piece.Def;
		if ( def is null )
		{
			ShowBanner( "That decor is a ghost." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var hybrid = FishFusion.ResolveDecorHybrid( def.Id );
		if ( hybrid is null )
		{
			ShowBanner( $"{def.Name} refuses the fish." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		// Not in this tank?
		if ( !_active.Decor.Contains( piece ) )
		{
			ShowBanner( "Decor is on the other tank." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var mx = (fish.X + piece.X + def.Width * 0.5f) * 0.5f;
		var my = (fish.Y + piece.Y + def.Height * 0.35f) * 0.5f;

		var rar = fish.Rarity;
		if ( (int)rar < (int)FishRarity.Mythical && _rng.NextDouble() < 0.22 )
			rar = (FishRarity)((int)rar + 1);

		var age = Math.Clamp( fish.AgeSeconds * 0.7f + 60f, FishActor.AdultAgeSeconds * 0.4f, FishActor.AdultAgeSeconds );
		var end = Math.Clamp( fish.Endurance + 0.5f, 0.5f, 10f );
		var spd = Math.Clamp( fish.SpeedStat + 0.3f, 0.5f, 10f );
		var agi = Math.Clamp( fish.Agility + 0.3f, 0.5f, 10f );

		if ( !RemoveFish( fishId ) )
		{
			ShowBanner( "Fish bolted mid-fusion." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}
		if ( !_active.Decor.Remove( piece ) )
		{
			ShowBanner( "Decor escaped. How." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var grow = Math.Clamp( age / FishActor.AdultAgeSeconds, 0.4f, 1.14f );
		var marginX = hybrid.Width * grow * 0.5f + 8f;
		var marginY = hybrid.Height * grow * 0.5f + 8f;
		mx = Math.Clamp( mx, marginX, Width - marginX );
		my = Math.Clamp( my, marginY, Height - marginY );

		var angle = (float)(_rng.NextDouble() * Math.PI * 2.0);
		var speed = hybrid.Speed * (0.65f + (float)_rng.NextDouble() * 0.45f);

		var abom = new FishActor
		{
			InstanceId = Guid.NewGuid().ToString( "N" )[..8],
			Species = hybrid,
			X = mx,
			Y = my,
			Vx = MathF.Cos( angle ) * speed,
			Vy = MathF.Sin( angle ) * speed * 0.55f,
			BobPhase = (float)(_rng.NextDouble() * Math.PI * 2.0),
			BobSpeed = 2.8f + (float)_rng.NextDouble() * 3f,
			BobAmp = 5f + (float)_rng.NextDouble() * 5f,
			Endurance = end,
			SpeedStat = spd,
			Agility = agi,
			AgeSeconds = age,
			Rarity = rar
		};
		_active.Fish.Add( abom );
		LastSpawnRarity = rar;

		FusionFeel.Trigger( mx, my );
		WaterFx.Splash( mx, my, 16, WaterLayer.Tank );
		WaterFx.Squirt( mx, my, 0f, -1f, 8, 220f, WaterLayer.Tank );
		Chaos.Add( 8f + def.ChaosOnPlace + (int)rar * 0.8f );
		_abominationsCreated++;
		var birthPay = BirthPayout( hybrid, rar, evolved: false ) + 4.0; // furniture crime bonus
		Economy.Add( birthPay );
		Karma.Add( 5f + (int)rar * 0.7f );
		GameAudio.PlayUi( GameAudio.Inject );
		ShowBanner( $"{hybrid.Name}! ({fish.Species.Name} + {def.Name}) +Ð{Economy.FormatDoge( birthPay )}" );
		StorySystem.NotifyCombined();
		JanitorPeep.On( "combine" );
		AchievementSystem.Unlock( "furniture" );
		GameAudio.OnMoodMaybeChanged( force: true );
		SaveGame.TrySave();
		FrameId++;
		return true;
	}

	public const float MethDurationSeconds = 28f;
	/// <summary>Monsters stay high longer — they asked for it.</summary>
	public const float MonsterMethDurationSeconds = 48f;

	/// <summary>Dose every fish in the active tank. Builds addiction, clears withdrawal, zoomies.</summary>
	public static int ApplyMethToActiveTank()
	{
		var count = 0;
		var monsterHits = 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;
			var isMon = fish.IsMonster;

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

			// Tolerance: more addiction → shorter high (monsters barely care).
			float duration;
			if ( isMon )
			{
				duration = MonsterMethDurationSeconds * (1f - fish.AddictionNorm * 0.2f);
				duration = MathF.Max( 22f, duration );
			}
			else
			{
				duration = MethDurationSeconds * (1f - fish.AddictionNorm * 0.55f);
				duration = MathF.Max( 8f, duration );
			}

			// Stack the habit hard on each dose (monsters still get hooked — they just enjoy it).
			var before = fish.Addiction;
			var addictHit = isMon
				? 10f + fish.AddictionNorm * 6f
				: 16f + fish.AddictionNorm * 10f;
			fish.Addiction = Math.Clamp( fish.Addiction + addictHit, 0f, 100f );
			if ( !wasAddicted && fish.IsAddicted )
				newlyHooked++;

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

			// Monsters also swell up (fat) when they "enjoy" the dose — visual gross.
			if ( isMon )
				fish.Fat = Math.Clamp( fish.Fat + 18f + fish.AddictionNorm * 12f, 0f, 100f );

			// Launch manic sprint — monsters thrash harder.
			var angle = (float)(_rng.NextDouble() * Math.PI * 2.0);
			var burstMul = isMon
				? (3.4f + fish.AddictionNorm * 1.6f)
				: (2.4f + fish.AddictionNorm * 1.2f);
			var burst = fish.Species.Speed * fish.SpeedMoveMul * burstMul;
			fish.Vx = MathF.Cos( angle ) * burst;
			fish.Vy = MathF.Sin( angle ) * burst * (isMon ? 0.9f : 0.75f);
			count++;
			if ( isMon )
				monsterHits++;
			if ( wasWrong )
				savedWrongWater++;

			if ( isMon )
				ShowBanner( $"{fish.DisplayName} LOVES this. Growing wrong." );
			else 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 + monsterHits * 5f );
			if ( monsterHits > 0 && monsterHits == count )
				ShowBanner( $"{monsterHits} abomination{(monsterHits == 1 ? "" : "s")} high · stats & size up." );
			else if ( monsterHits > 0 )
				ShowBanner( $"{count} dosed · {monsterHits} monster{(monsterHits == 1 ? "" : "s")} thrashing." );
			else 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 );
				NotifyFishDied();
				// She is mad at you — and she still takes the body for dinner.
				Karma.Add( -6f );
				Chaos.Add( 3f );
				if ( visual || slot == _active )
				{
					ShowBanner( GrandmaAteLine( name ) );
					ExplodeSystem.SpawnDeathPoof( deathX, deathY );
					WaterFx.Splash( deathX, deathY, 12, WaterLayer.Tank );
				}
				GameAudio.PlayUi( GameAudio.Error );
				continue;
			}

			TrainingSystem.TickFish( fish, dt );
			fish.TickGrowth( 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).
			// Menu-focused fish: no thrash turns — player is trying to click buttons.
			foreach ( var fish in slot.Fish )
			{
				if ( IsMenuFocused( fish.InstanceId ) )
					continue;

				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 );
			}
		}

		// Aboms snack on smaller tankmates (both tanks — off-screen still gets eaten).
		TickPredation( slot, dt, visual );

		// Rare courtship → fat pregnant → eggs → guppies (fresh tanks).
		BreedingSystem.TickSlot( slot, dt, visual );
	}

	/// <summary>
	/// Monster fish occasionally swallow smaller tankmates.
	/// Prey must be lighter by mass; predator gains stats + permanent bulk/size.
	/// </summary>
	static void TickPredation( TankSlot slot, float dt, bool visual )
	{
		// Need a spare body in the tank — don't vacuum a 2-fish starter abom setup.
		if ( slot is null || slot.Fish.Count < 3 || dt <= 0f )
			return;

		// Snapshot monsters — list mutates when someone gets eaten.
		var monsters = slot.Fish.Where( f => f.IsMonster && f.Species is not null ).ToList();
		if ( monsters.Count == 0 )
			return;

		foreach ( var predator in monsters )
		{
			if ( !slot.Fish.Contains( predator ) )
				continue;

			if ( predator.PredationCooldown > 0f )
			{
				predator.PredationCooldown = MathF.Max( 0f, predator.PredationCooldown - dt );
				continue;
			}

			// ~once every ~70–120s base; hungrier when jacked / meth / already bulked.
			// Soft early so new aboms don't instantly eat the last combine partner.
			var hunger = 0.009f
				+ predator.PredationBulkNorm * 0.005f
				+ (predator.Jacked ? 0.006f : 0f)
				+ (predator.OnMeth ? 0.01f : 0f)
				+ (predator.IsSick || predator.IsFightInjured ? -0.006f : 0f);
			hunger = Math.Clamp( hunger, 0.003f, 0.03f );
			if ( _rng.NextDouble() > hunger * dt )
				continue;

			var prey = PickPredationPrey( slot, predator );
			if ( prey is null )
			{
				// No snack this roll — short retry so they don't spam checks.
				predator.PredationCooldown = 4f + (float)_rng.NextDouble() * 6f;
				continue;
			}

			// Must be somewhat close (or meth thrash reaches further).
			var dx = prey.X - predator.X;
			var dy = prey.Y - predator.Y;
			var dist = MathF.Sqrt( dx * dx + dy * dy );
			var reach = 70f + predator.MassScore * 2.5f + (predator.OnMeth ? 40f : 0f);
			if ( dist > reach )
			{
				// Lunge toward them for next time.
				var len = MathF.Max( 1f, dist );
				var lunge = predator.Species.Speed * predator.SpeedMoveMul * 1.8f;
				predator.Vx = dx / len * lunge;
				predator.Vy = dy / len * lunge * 0.7f;
				predator.PredationCooldown = 1.2f + (float)_rng.NextDouble() * 2f;
				continue;
			}

			TryMonsterEat( slot, predator, prey, visual );
		}
	}

	static FishActor PickPredationPrey( TankSlot slot, FishActor predator )
	{
		if ( predator?.Species is null )
			return null;

		var predMass = predator.MassScore;
		FishActor best = null;
		var bestScore = float.MaxValue;

		foreach ( var f in slot.Fish )
		{
			if ( f is null || f.InstanceId == predator.InstanceId )
				continue;
			// Don't eat other apex monsters unless much smaller (evo chains stay safe).
			if ( f.IsMonster && f.MassScore >= predMass * 0.55f )
				continue;
			if ( f.MassScore >= predMass * 0.72f )
				continue;
			// Prefer soft targets: smaller mass + closer.
			var dx = f.X - predator.X;
			var dy = f.Y - predator.Y;
			var dist = MathF.Sqrt( dx * dx + dy * dy );
			var score = f.MassScore * 3f + dist * 0.15f;
			if ( f.IsFightInjured || f.IsSick )
				score *= 0.7f;
			if ( score < bestScore )
			{
				bestScore = score;
				best = f;
			}
		}

		return best;
	}

	static void TryMonsterEat( TankSlot slot, FishActor predator, FishActor prey, bool visual )
	{
		if ( predator is null || prey is null || slot is null )
			return;
		if ( !slot.Fish.Contains( predator ) || !slot.Fish.Contains( prey ) )
			return;

		var preyName = prey.DisplayName;
		var predName = predator.DisplayName;
		var preyMass = MathF.Max( 0.2f, prey.MassScore );
		var predMass = MathF.Max( 0.5f, predator.MassScore );
		var ratio = Math.Clamp( preyMass / predMass, 0.08f, 0.85f );

		// Stat siphon — bigger meals = bigger gains (capped).
		var endGain = Math.Clamp( 0.15f + prey.Endurance * 0.12f * ratio + 0.08f, 0.12f, 0.85f );
		var spdGain = Math.Clamp( 0.12f + prey.SpeedStat * 0.1f * ratio + 0.05f, 0.1f, 0.7f );
		var agiGain = Math.Clamp( 0.12f + prey.Agility * 0.1f * ratio + 0.05f, 0.1f, 0.7f );

		var beforeEnd = predator.Endurance;
		var beforeSpd = predator.SpeedStat;
		var beforeAgi = predator.Agility;
		predator.Endurance = Math.Clamp( predator.Endurance + endGain, 0.5f, 10f );
		predator.SpeedStat = Math.Clamp( predator.SpeedStat + spdGain, 0.5f, 10f );
		predator.Agility = Math.Clamp( predator.Agility + agiGain, 0.5f, 10f );

		// Permanent size bulk + belly swell.
		var bulkHit = Math.Clamp( 6f + ratio * 14f + (prey.IsMonster ? 4f : 0f), 5f, 22f );
		predator.PredationBulk = Math.Clamp( predator.PredationBulk + bulkHit, 0f, 100f );
		predator.Fat = Math.Clamp( predator.Fat + 10f + ratio * 18f, 0f, 100f );
		// Nudge growth clock so snacks look older/meatier.
		predator.AgeSeconds = MathF.Min(
			FishActor.MaxAgeSeconds,
			predator.AgeSeconds + 45f + ratio * 90f );
		predator.FishEatenCount++;

		// Cooldown: bigger meal = longer digest (still hunts again later).
		predator.PredationCooldown = 28f + ratio * 40f + (float)_rng.NextDouble() * 20f;

		// Lunge visual on the predator.
		predator.Vx *= 0.4f;
		predator.Vy *= 0.4f;
		predator.BobAmp = MathF.Min( 12f, predator.BobAmp + 1.5f );

		var deathX = prey.X;
		var deathY = prey.DrawY;
		// Cancel combine if either parent vanishes mid-pick.
		if ( FishFusion.PickingSecond
			&& ( FishFusion.PendingId == prey.InstanceId || FishFusion.PendingId == predator.InstanceId ) )
			FishFusion.Cancel();
		slot.Fish.Remove( prey );
		// Drop M80 tape / fight pick if any.
		ExplodeSystem.Untape( prey.InstanceId );
		BattleSystem.DeselectFish( prey.InstanceId );

		// Predation is not "your fault" salinity death — grandma might even approve.
		Chaos.Add( 2.5f + ratio * 3f );
		Karma.Add( 0.6f + (prey.IsMonster ? 0.4f : 0f) );

		if ( visual || slot == _active )
		{
			ExplodeSystem.SpawnDeathPoof( deathX, deathY );
			WaterFx.Splash( deathX, deathY, 14, WaterLayer.Tank );
			var de = predator.Endurance - beforeEnd;
			var ds = predator.SpeedStat - beforeSpd;
			var da = predator.Agility - beforeAgi;
			StatFloat.ShowSessionSummary( predator, de, ds, da );
			var bulkPct = (int)MathF.Round( predator.PredationBulk );
			ShowBanner( $"{predName} ate {preyName}. Bulk {bulkPct}% · stats up." );
			GameAudio.PlayUi( GameAudio.M80 );
			GameAudio.OnMoodMaybeChanged( force: true );
		}

		FrameId++;
		SaveGame.TrySave();
	}

	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;
					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>Kick / tap the whole glass — every swimmer scurries.</summary>
	public static int KickAllFish()
	{
		if ( !IsActive || _active is null )
			return 0;
		var n = 0;
		foreach ( var f in _active.Fish )
		{
			if ( f is null || f.OnAdventure )
				continue;
			if ( PokeFish( f.InstanceId ) )
				n++;
		}
		FrameId++;
		return n;
	}

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

		// Fish menu open on this body: gentle hover so meth thrash doesn't steal clicks.
		// Visuals (meth aura / slime) still play — only locomotion calms.
		if ( IsMenuFocused( fish.InstanceId ) )
		{
			fish.ScurryTimer = 0f;
			fish.Vx *= MathF.Pow( 0.12f, dt );
			fish.Vy *= MathF.Pow( 0.12f, dt );
			// Soft bob only — no manic phase spin.
			fish.BobPhase += fish.BobSpeed * dt * 0.55f;
			fish.X += fish.Vx * dt * 0.35f;
			fish.Y += fish.Vy * dt * 0.35f;
			ClampFish( fish, dt );
			return;
		}

		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++;
	}
}