SharkSystem.cs

Game system managing 'cocaine sharks' in an ocean habitat. Spawns and updates SharkActor instances, handles AI hunting, movement, biting/eating fish, player actions to scare or fend sharks, and persistence of kill/eaten counters.

File AccessNetworking
namespace NoChillquarium;

/// <summary>
/// Ocean hazard: cocaine sharks hunt fish in ocean habitats.
/// Scare with Ð + team power, or Fend with trained/jacked fish.
/// </summary>
public sealed class SharkActor
{
	public string Id { get; init; }
	public float X { get; set; }
	public float Y { get; set; }
	public float Vx { get; set; }
	public float Vy { get; set; }
	public float BiteCooldown { get; set; }
	public float Life { get; set; } = 1f; // 0 = dead
	public bool Alive => Life > 0.01f;
	public int Facing => Vx >= 0f ? 1 : -1;
	public float Width => 110f;
	public float Height => 56f;
}

public static class SharkSystem
{
	public const string OceanHabitatId = "ocean";
	public const float SpawnIntervalMin = 22f;
	public const float SpawnIntervalMax = 38f;
	public const int MaxSharks = 2;
	public const double ScareCost = 30;
	public const double FendCost = 0; // free but needs power
	public const float BiteRange = 38f;
	public const float SharkSpeed = 95f;

	static readonly Random _rng = new();
	static readonly List<SharkActor> _sharks = new();
	static float _spawnTimer = 12f;
	static float _alertFlash;
	static int _version;
	static int _kills;
	static int _eaten;
	static bool _everSpawned;

	public static int Version => _version;
	public static IReadOnlyList<SharkActor> Sharks => _sharks;
	public static int SharkCount => _sharks.Count( s => s.Alive );
	public static bool HasSharks => SharkCount > 0;
	public static float AlertFlash => _alertFlash;
	public static int Kills => _kills;
	public static int Eaten => _eaten;
	public static bool EverSpawned => _everSpawned;

	public static bool IsOceanActive =>
		TankSim.IsActive
		&& string.Equals( TankSim.HabitatId, OceanHabitatId, StringComparison.OrdinalIgnoreCase );

	public static void Clear()
	{
		_sharks.Clear();
		_spawnTimer = 12f;
		_alertFlash = 0f;
		_version++;
	}

	public static void ResetProgress()
	{
		_kills = 0;
		_eaten = 0;
		_everSpawned = false;
		Clear();
	}

	public static void Load( int kills, int eaten, bool everSpawned )
	{
		_kills = Math.Max( 0, kills );
		_eaten = Math.Max( 0, eaten );
		_everSpawned = everSpawned;
		Clear();
	}

	public static void WriteToSave( SaveData data )
	{
		if ( data is null )
			return;
		data.SharkKills = _kills;
		data.SharkEaten = _eaten;
		data.SharkEverSpawned = _everSpawned;
	}

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

		if ( _alertFlash > 0f )
		{
			_alertFlash = MathF.Max( 0f, _alertFlash - dt );
			_version++;
		}

		if ( !IsOceanActive )
		{
			// Leave the ocean → sharks swim off.
			if ( _sharks.Count > 0 )
			{
				_sharks.Clear();
				_version++;
			}
			return;
		}

		// Spawn cadence
		_spawnTimer -= dt;
		if ( _spawnTimer <= 0f )
		{
			_spawnTimer = SpawnIntervalMin + (float)_rng.NextDouble() * (SpawnIntervalMax - SpawnIntervalMin);
			if ( SharkCount < MaxSharks && TankSim.FishCount > 0 )
				TrySpawn();
		}

		for ( var i = _sharks.Count - 1; i >= 0; i-- )
		{
			var shark = _sharks[i];
			if ( !shark.Alive )
			{
				_sharks.RemoveAt( i );
				continue;
			}

			if ( shark.BiteCooldown > 0f )
				shark.BiteCooldown -= dt;

			Hunt( shark, dt );
			Integrate( shark, dt );

			if ( shark.BiteCooldown <= 0f )
				TryBite( shark );
		}

		if ( _sharks.Count > 0 )
			_version++;
	}

	static void TrySpawn()
	{
		var fromLeft = _rng.NextDouble() < 0.5;
		var y = 60f + (float)_rng.NextDouble() * (TankSim.Height - 120f);
		var x = fromLeft ? -40f : TankSim.Width + 40f;
		var speed = SharkSpeed * (0.85f + (float)_rng.NextDouble() * 0.35f);
		var vx = fromLeft ? speed : -speed;

		_sharks.Add( new SharkActor
		{
			Id = Guid.NewGuid().ToString( "N" )[..6],
			X = x,
			Y = y,
			Vx = vx,
			Vy = ((float)_rng.NextDouble() - 0.5f) * 30f,
			BiteCooldown = 1.2f,
			Life = 1f
		} );

		_everSpawned = true;
		_alertFlash = 1.4f;
		Chaos.Add( 6f );
		GameAudio.PlayUi( GameAudio.AlertWarning );
		TankSim.ShowBanner( "COCAINE SHARK in the water. Your fish are snacks." );
		Achievements.OnSharkSighted();
		_version++;
	}

	static void Hunt( SharkActor shark, float dt )
	{
		if ( TankSim.FishCount == 0 )
		{
			// Patrol
			shark.Vx += ((float)_rng.NextDouble() - 0.5f) * 40f * dt;
			shark.Vy += ((float)_rng.NextDouble() - 0.5f) * 30f * dt;
			return;
		}

		// Prefer weak / slow fish
		FishActor prey = null;
		var best = float.MaxValue;
		foreach ( var f in TankSim.Fish )
		{
			var dx = f.X - shark.X;
			var dy = f.DrawY - shark.Y;
			var dist = MathF.Sqrt( dx * dx + dy * dy );
			// Jacked / trained are harder targets (deprioritize)
			var score = dist + f.Agility * 8f + f.SpeedStat * 6f + (f.Jacked ? 40f : 0f);
			if ( f.IsSick || f.InWithdrawal )
				score -= 30f;
			if ( score < best )
			{
				best = score;
				prey = f;
			}
		}

		if ( prey is null )
			return;

		var pdx = prey.X - shark.X;
		var pdy = prey.DrawY - shark.Y;
		var len = MathF.Sqrt( pdx * pdx + pdy * pdy );
		if ( len < 1f )
			return;

		var want = SharkSpeed * (0.9f + (1f - Math.Clamp( prey.TrainingNorm, 0f, 1f )) * 0.35f);
		shark.Vx = pdx / len * want;
		shark.Vy = pdy / len * want * 0.75f;

		// Panic nearby fish slightly
		if ( len < 160f )
		{
			prey.Vx += (pdx / len) * 40f * dt;
			prey.Vy += (pdy / len) * 30f * dt;
		}
	}

	static void Integrate( SharkActor shark, float dt )
	{
		shark.X += shark.Vx * dt;
		shark.Y += shark.Vy * dt;

		var minX = -20f;
		var maxX = TankSim.Width + 20f;
		var minY = 30f;
		var maxY = TankSim.Height - 40f;

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

		// Bounce horizontal within extended bounds
		if ( shark.X < minX )
		{
			shark.X = minX;
			shark.Vx = MathF.Abs( shark.Vx );
		}
		else if ( shark.X > maxX )
		{
			shark.X = maxX;
			shark.Vx = -MathF.Abs( shark.Vx );
		}
	}

	static void TryBite( SharkActor shark )
	{
		FishActor closest = null;
		var bestDist = BiteRange;
		foreach ( var f in TankSim.Fish )
		{
			var dx = f.X - shark.X;
			var dy = f.DrawY - shark.Y;
			var dist = MathF.Sqrt( dx * dx + dy * dy );
			if ( dist < bestDist )
			{
				bestDist = dist;
				closest = f;
			}
		}

		if ( closest is null )
			return;

		// Escape chance from agility / speed / jacked / meth
		var escape = 0.18f + closest.Agility * 0.04f + closest.SpeedStat * 0.03f;
		if ( closest.Jacked )
			escape += 0.22f;
		if ( closest.OnMeth )
			escape += 0.12f;
		if ( closest.IsSick || closest.InWithdrawal )
			escape -= 0.2f;
		escape = Math.Clamp( escape, 0.08f, 0.78f );

		shark.BiteCooldown = 1.6f + (float)_rng.NextDouble() * 0.8f;

		if ( _rng.NextDouble() < escape )
		{
			// Dash away
			var ang = (float)(_rng.NextDouble() * Math.PI * 2);
			closest.Vx = MathF.Cos( ang ) * closest.Species.Speed * 2.4f;
			closest.Vy = MathF.Sin( ang ) * closest.Species.Speed * 1.2f;
			if ( _rng.NextDouble() < 0.35 )
				TankSim.ShowBanner( $"{closest.Species.Name} dodged a shark. Barely." );
			return;
		}

		// Eaten
		var name = closest.Species.Name;
		var ex = closest.X;
		var ey = closest.DrawY;
		TankSim.RemoveFish( closest.InstanceId );
		ExplodeSystem.SpawnDeathPoof( ex, ey );
		_eaten++;
		Chaos.Add( 5f );
		GameAudio.PlayUi( GameAudio.M80 );
		TankSim.ShowBanner( $"Cocaine shark ate {name}. Insurance does not cover this." );
		Achievements.OnFishEatenByShark();
		_version++;
	}

	public static float TeamPower()
	{
		if ( TankSim.FishCount == 0 )
			return 0f;
		float sum = 0;
		foreach ( var f in TankSim.Fish )
			sum += BattleSystem.CombatPower( f );
		return sum;
	}

	public static bool CanScare =>
		IsOceanActive && HasSharks && Economy.Balance >= ScareCost;

	public static bool CanFend =>
		IsOceanActive && HasSharks && TankSim.FishCount > 0 && TeamPower() >= 8f;

	/// <summary>Spend Ð — chance to drive a shark off based on team power.</summary>
	public static bool TryScare()
	{
		if ( !HasSharks )
		{
			TankSim.ShowBanner( "No sharks. Ocean is... fine?" );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( !Economy.TrySpend( ScareCost ) )
		{
			TankSim.ShowBanner( $"Scare costs Ð{Economy.FormatDoge( ScareCost )}." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var power = TeamPower();
		var chance = Math.Clamp( 0.25f + power * 0.02f, 0.25f, 0.9f );
		if ( _rng.NextDouble() < chance )
		{
			// Remove weakest (first) shark
			var shark = _sharks.FirstOrDefault( s => s.Alive );
			if ( shark is not null )
			{
				ExplodeSystem.SpawnDeathPoof( shark.X, shark.Y );
				_sharks.Remove( shark );
			}
			Chaos.Add( 2f );
			Karma.Add( 3f );
			GameAudio.PlayUi( GameAudio.Upgrade );
			TankSim.ShowBanner( "Shark scared off. It'll tell its friends." );
			Achievements.OnSharkScared();
		}
		else
		{
			Chaos.Add( 3f );
			GameAudio.PlayUi( GameAudio.Error );
			TankSim.ShowBanner( "Shark unimpressed. Your wallet is lighter." );
			// Aggro: reduce bite cooldowns
			foreach ( var s in _sharks )
				s.BiteCooldown = MathF.Min( s.BiteCooldown, 0.4f );
		}

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

	/// <summary>Fend with trained fish — can kill a shark for Ð loot.</summary>
	public static bool TryFend()
	{
		if ( !HasSharks || TankSim.FishCount == 0 )
		{
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var power = TeamPower();
		var chance = Math.Clamp( 0.15f + power * 0.025f, 0.15f, 0.92f );
		// Jacked majority helps
		var jacked = TankSim.Fish.Count( f => f.Jacked );
		if ( jacked > 0 )
			chance = Math.Clamp( chance + jacked * 0.06f, 0f, 0.95f );

		var shark = _sharks.FirstOrDefault( s => s.Alive );
		if ( shark is null )
			return false;

		if ( _rng.NextDouble() < chance )
		{
			ExplodeSystem.SpawnDeathPoof( shark.X, shark.Y );
			// Extra paste bits
			for ( var i = 0; i < 8; i++ )
			{
				// reuse death poof is enough
			}
			_sharks.Remove( shark );
			_kills++;
			var loot = 40 + _rng.NextDouble() * 50;
			Economy.Add( loot );
			Chaos.Add( 4f );
			Karma.Add( 1.5f );
			GameAudio.PlayDogeBark();
			TankSim.ShowBanner( $"Shark defeated! +Ð{Economy.FormatDoge( loot )}. Cocaine not recovered." );
			Achievements.OnSharkKilled( _kills );
			// Tiny stat bump for survivors
			foreach ( var f in TankSim.Fish )
			{
				f.Endurance = MathF.Min( 10f, f.Endurance + 0.1f );
				f.Agility = MathF.Min( 10f, f.Agility + 0.12f );
			}
		}
		else
		{
			Chaos.Add( 5f );
			GameAudio.PlayUi( GameAudio.Error );
			TankSim.ShowBanner( "Fend failed. Shark laughed (probably)." );
			shark.BiteCooldown = 0.2f;
			// Maybe eat one weak fish immediately
			if ( _rng.NextDouble() < 0.4 && TankSim.FishCount > 0 )
			{
				var weak = TankSim.Fish.OrderBy( f => f.Agility + f.SpeedStat ).First();
				var name = weak.Species.Name;
				var ex = weak.X;
				var ey = weak.DrawY;
				TankSim.RemoveFish( weak.InstanceId );
				ExplodeSystem.SpawnDeathPoof( ex, ey );
				_eaten++;
				TankSim.ShowBanner( $"Retaliation: shark ate {name}." );
				Achievements.OnFishEatenByShark();
			}
		}

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