BattleSystem.cs

Client-side battle system for an offline/local AI fish fighting mini-game. It defines templates, fighter and fx types, an auto-resolving turn/beat-based combat loop with visuals, team selection, AI generation, results, rewards and save integration.

File AccessExternal Download
namespace NoChillquarium;

/// <summary>
/// Local AI fish battles (v1). Stats + matchup + RNG.
/// No network — designed so a future IBattleService can wrap this.
/// </summary>
public sealed class BattleTemplate
{
	public string Id { get; init; }
	public string Name { get; init; }
	public string Theme { get; init; }
	public string Note { get; init; }
	public int TeamSize { get; init; }
	public double EntryFee { get; init; }
	public double RewardMin { get; init; }
	public double RewardMax { get; init; }
	public float AiStatMin { get; init; }
	public float AiStatMax { get; init; }
	public bool AiJacked { get; init; }
	public float UnlockPlaytime { get; init; }
	public int MinHabitatRank { get; init; }
	public float ChaosOnWin { get; init; }
	public float ChaosOnLose { get; init; }
	public float DecorChance { get; init; }
	public string AiColor { get; init; }
	public string[] AiNames { get; init; }
	/// <summary>First-win tank/habitat prize (id in HabitatDef). Empty = cash only.</summary>
	public string RewardHabitatId { get; init; }
}

public enum BattleAnim
{
	Idle,
	Lunge,
	Hit,
	Dodge,
	Down,
	Win
}

/// <summary>One fish in the fight — combat stats + arena visuals.</summary>
public sealed class BattleFighter
{
	public string Name { get; set; }
	public string Color { get; set; }
	public string FinColor { get; set; }
	public float Hp { get; set; }
	public float MaxHp { get; set; }
	public float Power { get; set; }
	public float Speed { get; set; }
	public float Agility { get; set; }
	public bool Jacked { get; set; }
	public bool Player { get; set; }
	public string SourceId { get; set; } // fish instance id if player
	public string SpriteId { get; set; }
	public bool HasSprite => !string.IsNullOrEmpty( SpriteId ) && SpriteCatalog.Has( SpriteId );
	public bool Alive => Hp > 0.01f;

	// Arena layout (pixels inside battle stage)
	public float HomeX { get; set; }
	public float HomeY { get; set; }
	public float DrawX { get; set; }
	public float DrawY { get; set; }
	public float BobPhase { get; set; }
	public float Shake { get; set; }
	/// <summary>+1 faces right, -1 faces left.</summary>
	public int Facing { get; set; } = 1;
	public BattleAnim Anim { get; set; } = BattleAnim.Idle;
	public float AnimT { get; set; }
	public float Width { get; set; } = 48f;
	public float Height { get; set; } = 32f;
}

/// <summary>Floating damage / dodge / KO text in the arena.</summary>
public sealed class BattleFxItem
{
	public string Key { get; set; }
	public string Label { get; set; }
	public float X { get; set; }
	public float Y { get; set; }
	public float Life { get; set; }
	public float MaxLife { get; set; }
	public float OffsetY { get; set; }
	public string Kind { get; set; } // hit, crit, dodge, ko
}

public enum BattlePhase
{
	Closed,
	Lobby,
	Fighting,
	Result
}

/// <summary>
/// Auto-resolving team battles against AI templates — with animated arena.
/// </summary>
public static class BattleSystem
{
	public const float ArenaW = 620f;
	public const float ArenaH = 210f;

	static readonly Random _rng = new();
	static int _version;
	static BattlePhase _phase = BattlePhase.Closed;
	static BattleTemplate _template;
	static readonly List<BattleFighter> _playerTeam = new();
	static readonly List<BattleFighter> _aiTeam = new();
	static readonly List<string> _log = new();
	static readonly List<BattleFxItem> _fx = new();
	static readonly HashSet<string> _selectedIds = new();
	static float _fightTimer;
	static float _nextBeat;
	static float _beatDuration = 0.85f;
	static int _round;
	static bool _playerWon;
	static double _payout;
	static string _resultTitle = "";
	static string _resultBody = "";
	static int _wins;
	static int _losses;
	static readonly HashSet<string> _beaten = new();
	static readonly HashSet<string> _prizeHabitatsWon = new();
	static string _lastPrizeTankName = "";

	// Current visual beat
	static BattleFighter _visAtk;
	static BattleFighter _visTgt;
	static string _visKind = ""; // hit, crit, dodge, ko
	static float _impactFlash;
	static float _arenaShake;
	static bool _impactFired;
	/// <summary>null = still fighting; true/false = end after current beat anim.</summary>
	static bool? _pendingFinish;
	static float _pendingDmg;
	static bool _pendingKo;

	public static int Version => _version;
	public static string LastPrizeTankName => _lastPrizeTankName;
	public static int PrizeTanksWon => _prizeHabitatsWon.Count;
	public static bool HasWonPrizeTank( string habitatId ) =>
		!string.IsNullOrEmpty( habitatId ) && _prizeHabitatsWon.Contains( habitatId );
	public static BattlePhase Phase => _phase;
	public static bool IsOpen => _phase != BattlePhase.Closed;
	public static BattleTemplate Template => _template;
	public static IReadOnlyList<BattleFighter> PlayerTeam => _playerTeam;
	public static IReadOnlyList<BattleFighter> AiTeam => _aiTeam;
	public static IReadOnlyList<string> Log => _log;
	public static IReadOnlyList<BattleFxItem> Fx => _fx;
	public static bool PlayerWon => _playerWon;
	public static double Payout => _payout;
	public static string ResultTitle => _resultTitle;
	public static string ResultBody => _resultBody;
	public static int Wins => _wins;
	public static int Losses => _losses;
	public static int Round => _round;
	public static float FightPulse => _fightTimer;
	public static float ImpactFlash => _impactFlash;
	public static float ArenaShake => _arenaShake;
	public static string VisKind => _visKind;
	public static BattleFighter VisAttacker => _visAtk;
	public static BattleFighter VisTarget => _visTgt;

	public static IReadOnlyList<BattleTemplate> Catalog { get; } = new[]
	{
		new BattleTemplate
		{
			Id = "garage_guppies",
			Name = "Garage Guppies",
			Theme = "easy",
			Note = "neighborhood scrubs",
			TeamSize = 1,
			EntryFee = 12,
			RewardMin = 22,
			RewardMax = 38,
			AiStatMin = 1.5f,
			AiStatMax = 3.2f,
			AiJacked = false,
			UnlockPlaytime = 0f,
			MinHabitatRank = 0,
			ChaosOnWin = 1.5f,
			ChaosOnLose = 2f,
			DecorChance = 0.05f,
			AiColor = "#90a0b0",
			AiNames = new[] { "Gary the Guppy", "Bin Betta", "Parking Lot Pike" }
		},
		new BattleTemplate
		{
			Id = "pipe_pugs",
			Name = "Pipe Pugs",
			Theme = "sewer",
			Note = "win: Sewage Duct",
			TeamSize = 1,
			EntryFee = 28,
			RewardMin = 40,
			RewardMax = 65,
			AiStatMin = 2.5f,
			AiStatMax = 4.5f,
			AiJacked = false,
			UnlockPlaytime = 12f * 60f, // ~12 min
			MinHabitatRank = 0,
			ChaosOnWin = 3f,
			ChaosOnLose = 2.5f,
			DecorChance = 0.12f,
			AiColor = "#5a6040",
			AiNames = new[] { "Flush", "Clog Lord", "Drain Baby", "Grate Expectations" },
			RewardHabitatId = "sewage_duct"
		},
		new BattleTemplate
		{
			Id = "gutter_gauntlet",
			Name = "Gutter Gauntlet",
			Theme = "runoff",
			Note = "win: Storm Drain",
			TeamSize = 2,
			EntryFee = 35,
			RewardMin = 55,
			RewardMax = 90,
			AiStatMin = 3.2f,
			AiStatMax = 5.5f,
			AiJacked = false,
			UnlockPlaytime = 25f * 60f, // ~25 min
			MinHabitatRank = 0,
			ChaosOnWin = 3.5f,
			ChaosOnLose = 3f,
			DecorChance = 0.15f,
			AiColor = "#4a6870",
			AiNames = new[] { "Leaf Pack", "Oil Slick", "Curb Surfer", "Manhole Mike" },
			RewardHabitatId = "storm_drain"
		},
		new BattleTemplate
		{
			Id = "steroid_circuit",
			Name = "Steroid Circuit",
			Theme = "jacked",
			Note = "illegal gains",
			TeamSize = 2,
			EntryFee = 40,
			RewardMin = 70,
			RewardMax = 110,
			AiStatMin = 4f,
			AiStatMax = 7f,
			AiJacked = true,
			UnlockPlaytime = 45f * 60f, // ~45 min
			MinHabitatRank = 0,
			ChaosOnWin = 4f,
			ChaosOnLose = 3f,
			DecorChance = 0.35f,
			AiColor = "#e04040",
			AiNames = new[] { "Vein Machine", "Juice Box", "Roid Ray", "Bulkhead" }
		},
		new BattleTemplate
		{
			Id = "hoa_heat",
			Name = "HOA Heat",
			Theme = "suburb",
			Note = "win: Neighbor's Pool",
			TeamSize = 2,
			EntryFee = 48,
			RewardMin = 80,
			RewardMax = 130,
			AiStatMin = 4.2f,
			AiStatMax = 7.2f,
			AiJacked = false,
			UnlockPlaytime = 70f * 60f, // ~1h10
			MinHabitatRank = 1,
			ChaosOnWin = 4f,
			ChaosOnLose = 3.5f,
			DecorChance = 0.22f,
			AiColor = "#40a070",
			AiNames = new[] { "Karen Koi", "Deed Restriction", "Pool Nazi", "Covenant Carp" },
			RewardHabitatId = "neighbor_pool"
		},
		new BattleTemplate
		{
			Id = "fountain_fracas",
			Name = "Fountain Fracas",
			Theme = "civic",
			Note = "win: Community Fountain",
			TeamSize = 2,
			EntryFee = 52,
			RewardMin = 85,
			RewardMax = 140,
			AiStatMin = 4.5f,
			AiStatMax = 7.5f,
			AiJacked = false,
			UnlockPlaytime = 95f * 60f, // ~1h35
			MinHabitatRank = 1,
			ChaosOnWin = 3.5f,
			ChaosOnLose = 3.5f,
			DecorChance = 0.2f,
			AiColor = "#70b0d0",
			AiNames = new[] { "Wish Coin", "Pigeon Boss", "Mayor Minnow", "Tourist Trap" },
			RewardHabitatId = "community_fountain"
		},
		new BattleTemplate
		{
			Id = "abyss_amateurs",
			Name = "Abyss Amateurs",
			Theme = "salt",
			Note = "salty mid-tier",
			TeamSize = 2,
			EntryFee = 55,
			RewardMin = 95,
			RewardMax = 150,
			AiStatMin = 5f,
			AiStatMax = 8f,
			AiJacked = false,
			UnlockPlaytime = 2f * 3600f, // ~2h
			MinHabitatRank = 1,
			ChaosOnWin = 3.5f,
			ChaosOnLose = 4f,
			DecorChance = 0.2f,
			AiColor = "#2060c0",
			AiNames = new[] { "Trench Trash", "Pressure Cooker", "Abyssal Intern", "Brine Brain" }
		},
		new BattleTemplate
		{
			Id = "spa_bloodsport",
			Name = "Spa Bloodsport",
			Theme = "wellness",
			Note = "win: Abandoned Spa",
			TeamSize = 3,
			EntryFee = 70,
			RewardMin = 120,
			RewardMax = 190,
			AiStatMin = 5.5f,
			AiStatMax = 8.5f,
			AiJacked = true,
			UnlockPlaytime = 2.5f * 3600f, // ~2h30
			MinHabitatRank = 2,
			ChaosOnWin = 5f,
			ChaosOnLose = 4.5f,
			DecorChance = 0.3f,
			AiColor = "#c0a0d0",
			AiNames = new[] { "Hot Stone", "Cucumber Eye", "Essential Oil", "Robed Eel", "Sauna Serpent" },
			RewardHabitatId = "abandoned_spa"
		},
		new BattleTemplate
		{
			Id = "jet_joust",
			Name = "Jet Joust",
			Theme = "heist",
			Note = "win: Stolen Hot Tub",
			TeamSize = 3,
			EntryFee = 85,
			RewardMin = 150,
			RewardMax = 240,
			AiStatMin = 6f,
			AiStatMax = 9f,
			AiJacked = true,
			UnlockPlaytime = 3f * 3600f, // ~3h
			MinHabitatRank = 2,
			ChaosOnWin = 5.5f,
			ChaosOnLose = 5f,
			DecorChance = 0.4f,
			AiColor = "#d08040",
			AiNames = new[] { "Bubbles", "Repo Ray", "Jetstream", "Warranty Void", "Foam Party" },
			RewardHabitatId = "hot_tub_heist"
		},
		new BattleTemplate
		{
			Id = "shark_bait_invitational",
			Name = "Shark Bait Invitational",
			Theme = "ocean",
			Note = "almost sharks",
			TeamSize = 3,
			EntryFee = 100,
			RewardMin = 180,
			RewardMax = 280,
			AiStatMin = 6.5f,
			AiStatMax = 9.5f,
			AiJacked = true,
			UnlockPlaytime = 3.25f * 3600f, // ~3h15
			MinHabitatRank = 3,
			ChaosOnWin = 6f,
			ChaosOnLose = 5f,
			DecorChance = 0.55f,
			AiColor = "#102838",
			AiNames = new[] { "Bait", "Almost Shark", "Fin Debt", "Chum Bucket", "Loan Shark Jr." }
		}
	};

	public static BattleTemplate Find( string id ) =>
		Catalog.FirstOrDefault( t => t.Id == id );

	public static bool IsBeaten( string id ) =>
		!string.IsNullOrEmpty( id ) && _beaten.Contains( id );

	public static bool IsSelected( string fishId ) =>
		!string.IsNullOrEmpty( fishId ) && _selectedIds.Contains( fishId );

	public static int SelectedCount => _selectedIds.Count;

	public static void DeselectFish( string fishId )
	{
		if ( string.IsNullOrEmpty( fishId ) )
			return;
		if ( _selectedIds.Remove( fishId ) )
			_version++;
	}

	public static bool IsUnlocked( BattleTemplate t )
	{
		if ( t is null || !TankSim.IsActive )
			return false;

		// First circuit always open; later ones unlock by playtime OR beating the previous.
		var idx = CatalogIndex( t );
		if ( idx <= 0 )
			return true;

		if ( TankSim.HabitatRank < t.MinHabitatRank )
			return false;

		// Beat the previous circuit → unlock this one immediately.
		var prev = Catalog[idx - 1];
		if ( IsBeaten( prev.Id ) )
			return true;

		// Or wait out the playtime gate.
		if ( Economy.PlaytimeSeconds >= t.UnlockPlaytime )
			return true;

		return false;
	}

	public static string UnlockHint( BattleTemplate t )
	{
		if ( t is null )
			return "";

		var idx = CatalogIndex( t );
		if ( idx > 0 )
		{
			var prev = Catalog[idx - 1];
			if ( !IsBeaten( prev.Id ) && Economy.PlaytimeSeconds < t.UnlockPlaytime )
			{
				var left = t.UnlockPlaytime - Economy.PlaytimeSeconds;
				var totalMin = (int)(left / 60f);
				string wait;
				if ( totalMin >= 60 )
				{
					var h = totalMin / 60;
					var m = totalMin % 60;
					wait = m > 0 ? $"{h}h {m}m" : $"{h}h";
				}
				else
				{
					var s = (int)(left % 60f);
					wait = $"{totalMin}:{s:00}";
				}
				return $"Beat {prev.Name} · or play {wait}";
			}
		}

		if ( TankSim.HabitatRank < t.MinHabitatRank )
			return $"Need habitat rank {t.MinHabitatRank}+";
		return t.Note;
	}

	static int CatalogIndex( BattleTemplate t )
	{
		if ( t is null )
			return -1;
		for ( var i = 0; i < Catalog.Count; i++ )
		{
			if ( Catalog[i].Id == t.Id )
				return i;
		}
		return -1;
	}

	public static void Clear()
	{
		_phase = BattlePhase.Closed;
		_template = null;
		_playerTeam.Clear();
		_aiTeam.Clear();
		_log.Clear();
		_fx.Clear();
		_selectedIds.Clear();
		_fightTimer = 0f;
		_nextBeat = 0f;
		_beatDuration = 0.85f;
		_round = 0;
		_playerWon = false;
		_payout = 0;
		_resultTitle = "";
		_resultBody = "";
		_visAtk = null;
		_visTgt = null;
		_visKind = "";
		_impactFlash = 0f;
		_arenaShake = 0f;
		_impactFired = false;
		_pendingFinish = null;
		_pendingDmg = 0f;
		_pendingKo = false;
		_version++;
	}

	public static void ResetProgress()
	{
		_wins = 0;
		_losses = 0;
		_beaten.Clear();
		_prizeHabitatsWon.Clear();
		_lastPrizeTankName = "";
		Clear();
	}

	public static void Load( int wins, int losses, List<string> beaten, List<string> prizeHabitats = null )
	{
		_wins = Math.Max( 0, wins );
		_losses = Math.Max( 0, losses );
		_beaten.Clear();
		if ( beaten is not null )
		{
			foreach ( var id in beaten )
			{
				if ( !string.IsNullOrWhiteSpace( id ) )
					_beaten.Add( id );
			}
		}
		_prizeHabitatsWon.Clear();
		if ( prizeHabitats is not null )
		{
			foreach ( var id in prizeHabitats )
			{
				if ( !string.IsNullOrWhiteSpace( id ) )
					_prizeHabitatsWon.Add( id );
			}
		}
		_lastPrizeTankName = "";
		Clear();
	}

	public static void WriteToSave( SaveData data )
	{
		if ( data is null )
			return;
		data.BattleWins = _wins;
		data.BattleLosses = _losses;
		data.BattlesBeaten = _beaten.ToList();
		data.PrizeHabitatsWon = _prizeHabitatsWon.ToList();
	}

	public static HabitatDef GetPrizeHabitat( BattleTemplate t )
	{
		if ( t is null || string.IsNullOrWhiteSpace( t.RewardHabitatId ) )
			return null;
		return HabitatDef.Find( t.RewardHabitatId );
	}

	public static bool PrizeAlreadyOwned( BattleTemplate t )
	{
		var hab = GetPrizeHabitat( t );
		return hab is not null && _prizeHabitatsWon.Contains( hab.Id );
	}

	public static bool TryOpen()
	{
		if ( !TankSim.IsActive )
			return false;
		if ( TankSim.FishCount < 1 )
		{
			TankSim.ShowBanner( "Need at least one fish to fight." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}
		if ( TrainingSystem.InjectOpen )
			TrainingSystem.CancelInject();
		if ( ExplodeSystem.ModeActive )
			ExplodeSystem.Clear();

		_phase = BattlePhase.Lobby;
		_template = null;
		_selectedIds.Clear();
		_playerTeam.Clear();
		_aiTeam.Clear();
		_log.Clear();
		_resultTitle = "";
		_resultBody = "";
		GameAudio.PlayUi( GameAudio.Notice );
		_version++;
		return true;
	}

	public static void Close()
	{
		if ( _phase == BattlePhase.Fighting )
			return; // finish the fight first
		Clear();
		GameAudio.PlayUi( GameAudio.Close );
	}

	public static void SelectTemplate( BattleTemplate t )
	{
		if ( _phase != BattlePhase.Lobby || t is null )
			return;
		if ( !IsUnlocked( t ) )
		{
			var hint = UnlockHint( t );
			TankSim.ShowBanner( string.IsNullOrEmpty( hint )
				? $"{t.Name} is locked."
				: $"{t.Name}: {hint}" );
			GameAudio.PlayUi( GameAudio.Error );
			_version++;
			return;
		}

		// Re-clicking same circuit is fine — keep roster.
		if ( _template?.Id == t.Id )
		{
			GameAudio.PlayUi( GameAudio.Pop );
			_version++;
			return;
		}

		_template = t;
		_selectedIds.Clear();
		// Auto-pick best fish for team size
		AutoSelectBest( t.TeamSize );
		TankSim.ShowBanner( $"{t.Name} · pick {t.TeamSize} fish · entry Ð{Economy.FormatDoge( t.EntryFee )}" );
		GameAudio.PlayUi( GameAudio.Pop );
		_version++;
	}

	public static bool CanFight( FishActor fish ) =>
		fish is not null && fish.Species is not null && !fish.IsFightInjured;

	public static string FightBlockedReason( FishActor fish )
	{
		if ( fish is null )
			return "No fish.";
		if ( fish.IsFightInjured )
			return $"{fish.DisplayName} is hurt · ready in {fish.FightCooldownLeftText}";
		return "";
	}

	public static void ToggleFish( string instanceId )
	{
		if ( _phase != BattlePhase.Lobby || _template is null || string.IsNullOrEmpty( instanceId ) )
			return;

		var fish = TankSim.Fish.FirstOrDefault( f => f.InstanceId == instanceId );
		if ( fish is null )
			return;

		if ( _selectedIds.Contains( instanceId ) )
		{
			_selectedIds.Remove( instanceId );
		}
		else
		{
			if ( !CanFight( fish ) )
			{
				TankSim.ShowBanner( FightBlockedReason( fish ) );
				GameAudio.PlayUi( GameAudio.Error );
				_version++;
				return;
			}
			EnsureFishOnTeam( instanceId, playSound: false );
			GameAudio.PlayUi( GameAudio.Pop );
			_version++;
			return;
		}

		GameAudio.PlayUi( GameAudio.Pop );
		_version++;
	}

	/// <summary>
	/// Force this fish onto the roster (never toggles off). Used when opening Fight from the fish menu
	/// so AutoSelect + Toggle doesn't accidentally empty the team.
	/// </summary>
	public static void EnsureFishOnTeam( string instanceId, bool playSound = true )
	{
		if ( _phase != BattlePhase.Lobby || _template is null || string.IsNullOrEmpty( instanceId ) )
			return;

		var fish = TankSim.Fish.FirstOrDefault( f => f.InstanceId == instanceId );
		if ( fish is null )
			return;

		if ( !CanFight( fish ) )
		{
			TankSim.ShowBanner( FightBlockedReason( fish ) );
			GameAudio.PlayUi( GameAudio.Error );
			_version++;
			return;
		}

		if ( _selectedIds.Contains( instanceId ) )
		{
			if ( playSound )
			{
				GameAudio.PlayUi( GameAudio.Pop );
				_version++;
			}
			return;
		}

		if ( _selectedIds.Count >= _template.TeamSize )
		{
			// Drop the weakest current pick to make room for the requested fish.
			string weakestId = null;
			var weakestPow = float.MaxValue;
			foreach ( var id in _selectedIds )
			{
				var f = TankSim.Fish.FirstOrDefault( x => x.InstanceId == id );
				var p = CombatPower( f );
				if ( p < weakestPow )
				{
					weakestPow = p;
					weakestId = id;
				}
			}
			if ( !string.IsNullOrEmpty( weakestId ) )
				_selectedIds.Remove( weakestId );
		}

		_selectedIds.Add( instanceId );
		if ( playSound )
			GameAudio.PlayUi( GameAudio.Pop );
		_version++;
	}

	static void AutoSelectBest( int n )
	{
		_selectedIds.Clear();
		var ranked = TankSim.Fish
			.Where( CanFight )
			.OrderByDescending( CombatPower )
			.Take( Math.Max( 1, n ) )
			.ToList();
		foreach ( var f in ranked )
			_selectedIds.Add( f.InstanceId );
	}

	public static float CombatPower( FishActor f )
	{
		if ( f?.Species is null )
			return 0f;
		if ( f.IsFightInjured )
			return 0f;
		var baseP = f.Endurance * 1.25f + f.SpeedStat * 1.05f + f.Agility * 1.15f;
		baseP += f.Species.IncomePerSecond * 2f;
		if ( f.Jacked )
			baseP *= 1.4f;
		if ( f.OnMeth )
			baseP *= 1.2f;
		if ( f.InWithdrawal )
			baseP *= 0.45f;
		if ( f.IsSick )
			baseP *= 0.4f;
		return baseP;
	}

	public static bool CanStart()
	{
		if ( _phase != BattlePhase.Lobby || _template is null )
			return false;
		if ( _selectedIds.Count < _template.TeamSize )
			return false;
		if ( Economy.Balance < _template.EntryFee )
			return false;
		// No injured fish on the card.
		foreach ( var id in _selectedIds )
		{
			var f = TankSim.Fish.FirstOrDefault( x => x.InstanceId == id );
			if ( !CanFight( f ) )
				return false;
		}
		return true;
	}

	public static bool TryStart()
	{
		if ( !CanStart() )
		{
			if ( _template is not null && _selectedIds.Count < _template.TeamSize )
				TankSim.ShowBanner( $"Pick {_template.TeamSize} fish." );
			else if ( _template is not null && _selectedIds.Any( id =>
				!CanFight( TankSim.Fish.FirstOrDefault( f => f.InstanceId == id ) ) ) )
				TankSim.ShowBanner( "Injured fish can't fight. Wait out the cooldown." );
			else if ( _template is not null )
				TankSim.ShowBanner( $"Need Ð{Economy.FormatDoge( _template.EntryFee )} entry." );
			GameAudio.PlayUi( GameAudio.Error );
			_version++;
			return false;
		}

		if ( !Economy.TrySpend( _template.EntryFee ) )
		{
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		_playerTeam.Clear();
		_aiTeam.Clear();
		_log.Clear();
		_round = 0;
		_playerWon = false;
		_payout = 0;

		foreach ( var id in _selectedIds )
		{
			var fish = TankSim.Fish.FirstOrDefault( f => f.InstanceId == id );
			if ( fish is null )
				continue;
			_playerTeam.Add( FromPlayer( fish ) );
		}

		// Pad if fish died mid-select (shouldn't) — never pad with injured fish.
		while ( _playerTeam.Count < _template.TeamSize && TankSim.Fish.Count > 0 )
		{
			var extra = TankSim.Fish
				.Where( CanFight )
				.OrderByDescending( CombatPower )
				.FirstOrDefault( f => _playerTeam.All( p => p.SourceId != f.InstanceId ) );
			if ( extra is null )
				break;
			_playerTeam.Add( FromPlayer( extra ) );
		}

		BuildAiTeam( _template );
		LayoutArena();

		_phase = BattlePhase.Fighting;
		_fightTimer = 0f;
		_beatDuration = 0.95f;
		_nextBeat = 0.65f; // brief stare-down
		_visAtk = null;
		_visTgt = null;
		_visKind = "";
		_impactFired = false;
		_pendingFinish = null;
		_pendingDmg = 0f;
		_pendingKo = false;
		_fx.Clear();
		_log.Add( $"FIGHT: {_template.Name}" );
		_log.Add( $"You put up Ð{Economy.FormatDoge( _template.EntryFee )}." );
		GameAudio.PlayUi( GameAudio.Confirm );
		Chaos.Add( 1.5f );
		_version++;
		return true;
	}

	static BattleFighter FromPlayer( FishActor fish )
	{
		var power = CombatPower( fish );
		var hp = 28f + fish.Endurance * 14f + (fish.Jacked ? 18f : 0f);
		var sp = fish.Species;
		return new BattleFighter
		{
			Name = fish.DisplayName,
			Color = sp.Color,
			FinColor = sp.FinColor,
			Hp = hp,
			MaxHp = hp,
			Power = power,
			Speed = fish.SpeedStat + (fish.Jacked ? 2f : 0f),
			Agility = fish.Agility,
			Jacked = fish.Jacked,
			Player = true,
			SourceId = fish.InstanceId,
			SpriteId = sp.Id,
			Width = Math.Clamp( sp.Width * 0.95f, 36f, 56f ),
			Height = Math.Clamp( sp.Height * 0.95f, 24f, 42f ),
			BobPhase = (float)(_rng.NextDouble() * Math.PI * 2),
			Facing = 1
		};
	}

	static void BuildAiTeam( BattleTemplate t )
	{
		_aiTeam.Clear();
		var names = t.AiNames ?? new[] { "Rival Fish" };
		var catalog = FishSpecies.AllCatalog;
		for ( var i = 0; i < t.TeamSize; i++ )
		{
			var end = Lerp( t.AiStatMin, t.AiStatMax, (float)_rng.NextDouble() );
			var spd = Lerp( t.AiStatMin, t.AiStatMax, (float)_rng.NextDouble() );
			var agi = Lerp( t.AiStatMin, t.AiStatMax, (float)_rng.NextDouble() );
			var power = end * 1.25f + spd * 1.05f + agi * 1.15f + 2f;
			if ( t.AiJacked )
				power *= 1.35f;
			var hp = 28f + end * 14f + (t.AiJacked ? 16f : 0f);
			// Pick a species look so AI has a real sprite to thrash around.
			var look = catalog[Math.Abs( (names[i % names.Length] + i).GetHashCode() ) % catalog.Count];
			_aiTeam.Add( new BattleFighter
			{
				Name = names[i % names.Length],
				Color = t.AiColor ?? look.Color,
				FinColor = look.FinColor,
				Hp = hp,
				MaxHp = hp,
				Power = power,
				Speed = spd + (t.AiJacked ? 1.5f : 0f),
				Agility = agi,
				Jacked = t.AiJacked,
				Player = false,
				SourceId = $"ai_{i}",
				SpriteId = look.Id,
				Width = Math.Clamp( look.Width * 0.95f, 36f, 56f ),
				Height = Math.Clamp( look.Height * 0.95f, 24f, 42f ),
				BobPhase = (float)(_rng.NextDouble() * Math.PI * 2),
				Facing = -1
			} );
		}
	}

	/// <summary>Park teams on left / right of the arena stage.</summary>
	static void LayoutArena()
	{
		PlaceTeam( _playerTeam, playerSide: true );
		PlaceTeam( _aiTeam, playerSide: false );
	}

	static void PlaceTeam( List<BattleFighter> team, bool playerSide )
	{
		var n = Math.Max( 1, team.Count );
		for ( var i = 0; i < team.Count; i++ )
		{
			var f = team[i];
			var lane = (i + 0.5f) / n;
			// Leave room under sprites for name + HP bar
			var y = 40f + lane * (ArenaH - 88f);
			// Slight X stagger so they don't stack perfectly
			var xJitter = (float)(_rng.NextDouble() * 18.0 - 9.0);
			if ( playerSide )
			{
				f.HomeX = 70f + (i % 2) * 18f + xJitter;
				f.Facing = 1;
			}
			else
			{
				f.HomeX = ArenaW - 70f - (i % 2) * 18f + xJitter;
				f.Facing = -1;
			}
			f.HomeY = y;
			f.DrawX = f.HomeX;
			f.DrawY = f.HomeY;
			f.Anim = BattleAnim.Idle;
			f.AnimT = 0f;
			f.Shake = 0f;
		}
	}

	static float Lerp( float a, float b, float t ) => a + (b - a) * t;
	static float EaseOut( float t ) => 1f - (1f - t) * (1f - t);
	static float EaseInOut( float t ) =>
		t < 0.5f ? 2f * t * t : 1f - MathF.Pow( -2f * t + 2f, 2f ) * 0.5f;

	public static void Tick( float dt )
	{
		if ( _phase != BattlePhase.Fighting && _phase != BattlePhase.Result )
			return;
		if ( dt <= 0f )
			return;
		if ( dt > 0.05f )
			dt = 0.05f;

		if ( _impactFlash > 0f )
			_impactFlash = MathF.Max( 0f, _impactFlash - dt * 3.5f );
		if ( _arenaShake > 0f )
			_arenaShake = MathF.Max( 0f, _arenaShake - dt * 4f );

		TickFx( dt );
		TickFighterVisuals( dt );

		if ( _phase != BattlePhase.Fighting )
		{
			_version++;
			return;
		}

		_fightTimer += dt;
		_nextBeat -= dt;

		// Drive lunge / impact within the current beat window
		var progress = _beatDuration > 0.01f
			? Math.Clamp( 1f - _nextBeat / _beatDuration, 0f, 1f )
			: 1f;
		UpdateBeatAnim( progress );

		if ( _nextBeat > 0f )
		{
			_version++;
			return;
		}

		// End-of-fight delayed so last lunge / KO is visible
		if ( _pendingFinish.HasValue )
		{
			FinishFight( _pendingFinish.Value );
			_pendingFinish = null;
			_version++;
			return;
		}

		// Start next beat
		_beatDuration = 0.75f + (float)_rng.NextDouble() * 0.35f;
		_nextBeat = _beatDuration;
		_round++;
		_impactFired = false;
		ResolveBeat();
		_version++;
	}

	static void TickFx( float dt )
	{
		for ( var i = _fx.Count - 1; i >= 0; i-- )
		{
			var fx = _fx[i];
			fx.Life -= dt;
			fx.OffsetY -= 40f * dt;
			if ( fx.Life <= 0f )
				_fx.RemoveAt( i );
		}
	}

	static void TickFighterVisuals( float dt )
	{
		foreach ( var f in _playerTeam.Concat( _aiTeam ) )
		{
			f.BobPhase += dt * (f.Alive ? 3.2f : 1.2f);
			if ( f.Shake > 0f )
				f.Shake = MathF.Max( 0f, f.Shake - dt * 8f );

			if ( !f.Alive )
			{
				f.Anim = BattleAnim.Down;
				// Sink a little
				f.DrawY = Lerp( f.DrawY, f.HomeY + 18f, Math.Clamp( dt * 3f, 0f, 1f ) );
				f.DrawX = Lerp( f.DrawX, f.HomeX, Math.Clamp( dt * 2f, 0f, 1f ) );
				continue;
			}

			if ( _phase == BattlePhase.Result )
			{
				var wonSide = (f.Player && _playerWon) || (!f.Player && !_playerWon);
				f.Anim = wonSide ? BattleAnim.Win : BattleAnim.Idle;
				// Drift home; winners bounce
				var bob = MathF.Sin( f.BobPhase * (wonSide ? 1.6f : 1f) ) * (wonSide ? 8f : 3f);
				f.DrawX = Lerp( f.DrawX, f.HomeX, Math.Clamp( dt * 4f, 0f, 1f ) );
				f.DrawY = Lerp( f.DrawY, f.HomeY + bob, Math.Clamp( dt * 4f, 0f, 1f ) );
				if ( wonSide )
					f.Facing = f.Player ? 1 : -1;
			}
		}
	}

	/// <summary>Lunge attacker toward target; impact flash mid-beat.</summary>
	static void UpdateBeatAnim( float t )
	{
		// Default: idle at home + bob (applied in Draw helpers via Home + sin)
		foreach ( var f in _playerTeam.Concat( _aiTeam ) )
		{
			if ( !f.Alive )
				continue;
			if ( f != _visAtk && f != _visTgt )
			{
				f.Anim = BattleAnim.Idle;
				f.DrawX = f.HomeX;
				f.DrawY = f.HomeY + MathF.Sin( f.BobPhase ) * 4f;
			}
		}

		if ( _visAtk is null || _visTgt is null )
			return;

		var atk = _visAtk;
		var tgt = _visTgt;

		if ( atk.Alive )
		{
			atk.Facing = atk.HomeX < tgt.HomeX ? 1 : -1;
			// Lunge 0–0.42, hold 0.42–0.55, return 0.55–1
			if ( t < 0.42f )
			{
				atk.Anim = BattleAnim.Lunge;
				var u = EaseOut( t / 0.42f );
				var aimX = Lerp( tgt.HomeX, atk.HomeX, 0.28f ); // stop short of center of target
				var aimY = tgt.HomeY;
				atk.DrawX = Lerp( atk.HomeX, aimX, u );
				atk.DrawY = Lerp( atk.HomeY, aimY, u ) + MathF.Sin( atk.BobPhase ) * 2f;
			}
			else if ( t < 0.55f )
			{
				atk.Anim = BattleAnim.Lunge;
				var aimX = Lerp( tgt.HomeX, atk.HomeX, 0.28f );
				atk.DrawX = aimX;
				atk.DrawY = tgt.HomeY;
			}
			else
			{
				atk.Anim = BattleAnim.Idle;
				var u = EaseInOut( (t - 0.55f) / 0.45f );
				var aimX = Lerp( tgt.HomeX, atk.HomeX, 0.28f );
				atk.DrawX = Lerp( aimX, atk.HomeX, u );
				atk.DrawY = Lerp( tgt.HomeY, atk.HomeY, u ) + MathF.Sin( atk.BobPhase ) * 4f;
			}
		}

		if ( tgt.Alive || _visKind is "hit" or "crit" or "ko" )
		{
			tgt.Facing = tgt.HomeX < atk.HomeX ? 1 : -1;
			if ( _visKind == "dodge" && t >= 0.35f && t < 0.75f )
			{
				tgt.Anim = BattleAnim.Dodge;
				var side = tgt.Player ? -1f : 1f;
				var dodgeU = MathF.Sin( (t - 0.35f) / 0.4f * MathF.PI );
				tgt.DrawX = tgt.HomeX + side * 22f * dodgeU;
				tgt.DrawY = tgt.HomeY - 10f * dodgeU + MathF.Sin( tgt.BobPhase ) * 3f;
			}
			else if ( (_visKind is "hit" or "crit" or "ko") && t >= 0.4f )
			{
				if ( !_impactFired && t >= 0.4f )
				{
					_impactFired = true;
					// Apply damage on impact so HP bar drops with the hit
					if ( _pendingDmg > 0f && tgt is not null )
					{
						tgt.Hp = MathF.Max( 0f, tgt.Hp - _pendingDmg );
						_pendingDmg = 0f;
						if ( _pendingKo || !tgt.Alive )
						{
							_visKind = "ko";
							PushFx( tgt, "KO", "ko" );
							_log.Add( $"→ {tgt.Name} is toast." );
							TrimLog();
						}
						_pendingKo = false;
					}

					_impactFlash = _visKind is "crit" or "ko" ? 1f : 0.65f;
					_arenaShake = _visKind is "crit" or "ko" ? 6f : 3.5f;
					tgt.Shake = _visKind is "crit" or "ko" ? 10f : 6f;
					if ( _visKind == "ko" || !tgt.Alive )
						GameAudio.PlayUi( GameAudio.Error );
					else if ( _visKind == "crit" )
						GameAudio.PlayUi( GameAudio.Confirm );
					else
						GameAudio.PlayUi( GameAudio.Pop );
				}

				tgt.Anim = tgt.Alive ? BattleAnim.Hit : BattleAnim.Down;
				var knock = (_visKind == "crit" ? 16f : 10f) * (tgt.Player ? -1f : 1f);
				var hold = Math.Clamp( (t - 0.4f) / 0.2f, 0f, 1f );
				var back = t > 0.6f ? EaseOut( Math.Clamp( (t - 0.6f) / 0.4f, 0f, 1f ) ) : 0f;
				tgt.DrawX = tgt.HomeX + knock * hold * (1f - back);
				tgt.DrawY = tgt.HomeY + MathF.Sin( tgt.BobPhase ) * 2f
					+ (tgt.Alive ? 0f : 12f * hold);
			}
			else
			{
				tgt.Anim = tgt.Alive ? BattleAnim.Idle : BattleAnim.Down;
				tgt.DrawX = tgt.HomeX;
				tgt.DrawY = tgt.HomeY + MathF.Sin( tgt.BobPhase ) * 4f;
			}
		}
	}

	/// <summary>One attack per beat so the arena is readable.</summary>
	static void ResolveBeat()
	{
		var pAlive = _playerTeam.Where( f => f.Alive ).ToList();
		var aAlive = _aiTeam.Where( f => f.Alive ).ToList();

		if ( pAlive.Count == 0 || aAlive.Count == 0 )
		{
			_pendingFinish = pAlive.Count > 0;
			return;
		}

		// Speed + RNG picks who swings this round
		var atk = pAlive.Concat( aAlive )
			.OrderByDescending( f => f.Speed + (float)_rng.NextDouble() * 2.5f )
			.First();

		var foes = atk.Player
			? _aiTeam.Where( f => f.Alive ).ToList()
			: _playerTeam.Where( f => f.Alive ).ToList();
		if ( foes.Count == 0 )
		{
			_pendingFinish = atk.Player;
			return;
		}

		var target = foes[_rng.Next( foes.Count )];
		_visAtk = atk;
		_visTgt = target;
		_impactFired = false;

		// Dodge
		var dodgeChance = Math.Clamp( target.Agility * 0.035f, 0.05f, 0.38f );
		if ( _rng.NextDouble() < dodgeChance )
		{
			_visKind = "dodge";
			_pendingDmg = 0f;
			_pendingKo = false;
			_log.Add( $"{target.Name} dodges {atk.Name}!" );
			PushFx( target, "DODGE", "dodge" );
			TrimLog();
			return;
		}

		var dmg = atk.Power * (0.35f + (float)_rng.NextDouble() * 0.45f);
		dmg += atk.Speed * 0.4f;
		if ( atk.Jacked )
			dmg *= 1.15f;

		var crit = _rng.NextDouble() < atk.Agility * 0.025f;
		if ( crit )
		{
			dmg *= 1.6f;
			_visKind = "crit";
			_log.Add( $"{atk.Name} CRITS {target.Name} for {dmg:0}!" );
			PushFx( target, $"CRIT {dmg:0}", "crit" );
		}
		else
		{
			_visKind = "hit";
			_log.Add( $"{atk.Name} hits {target.Name} ({dmg:0})" );
			PushFx( target, $"-{dmg:0}", "hit" );
		}

		// Damage lands mid-lunge (UpdateBeatAnim impact) so bar + body sync
		_pendingDmg = dmg;
		_pendingKo = target.Hp - dmg <= 0.01f;
		if ( _pendingKo )
			_visKind = "ko";

		TrimLog();

		// Defer result until the lunge/KO plays out (projected HP for target)
		bool SideWiped( IEnumerable<BattleFighter> side ) =>
			side.All( f => f != target ? !f.Alive : _pendingKo );

		if ( SideWiped( _playerTeam ) )
			_pendingFinish = false;
		else if ( SideWiped( _aiTeam ) )
			_pendingFinish = true;
	}

	static void PushFx( BattleFighter f, string label, string kind )
	{
		if ( f is null )
			return;
		_fx.Add( new BattleFxItem
		{
			Key = Guid.NewGuid().ToString( "N" )[..8],
			Label = label,
			X = f.HomeX,
			Y = f.HomeY - 20f,
			Life = 1.1f,
			MaxLife = 1.1f,
			OffsetY = 0f,
			Kind = kind
		} );
		while ( _fx.Count > 12 )
			_fx.RemoveAt( 0 );
	}

	public static float FxLifeNorm( BattleFxItem fx )
	{
		if ( fx is null || fx.MaxLife <= 0f )
			return 0f;
		return Math.Clamp( fx.Life / fx.MaxLife, 0f, 1f );
	}

	/// <summary>Screen draw position including shake jitter.</summary>
	public static void GetDrawPos( BattleFighter f, out float x, out float y )
	{
		x = f?.DrawX ?? 0f;
		y = f?.DrawY ?? 0f;
		if ( f is null )
			return;
		if ( f.Shake > 0.1f )
		{
			x += (float)(_rng.NextDouble() * 2.0 - 1.0) * f.Shake;
			y += (float)(_rng.NextDouble() * 2.0 - 1.0) * f.Shake * 0.6f;
		}
		if ( _arenaShake > 0.1f )
		{
			x += (float)(_rng.NextDouble() * 2.0 - 1.0) * _arenaShake;
			y += (float)(_rng.NextDouble() * 2.0 - 1.0) * _arenaShake * 0.5f;
		}
	}

	static void TrimLog()
	{
		while ( _log.Count > 8 )
			_log.RemoveAt( 0 );
	}

	static void FinishFight( bool won )
	{
		// Safety: flush any impact damage that never animated
		if ( _pendingDmg > 0f && _visTgt is not null )
		{
			_visTgt.Hp = MathF.Max( 0f, _visTgt.Hp - _pendingDmg );
			_pendingDmg = 0f;
		}
		_pendingKo = false;

		_playerWon = won;
		_phase = BattlePhase.Result;
		_payout = 0;

		if ( won )
		{
			_wins++;
			var lo = _template.RewardMin;
			var hi = _template.RewardMax;
			_payout = lo + _rng.NextDouble() * (hi - lo);
			Economy.Add( _payout );
			Chaos.Add( _template.ChaosOnWin );
			_beaten.Add( _template.Id );
			_lastPrizeTankName = "";

			var decorLine = "";
			if ( _rng.NextDouble() < _template.DecorChance )
			{
				var def = PickRewardDecor();
				if ( def is not null && TankSim.TryPlaceDecor( def, announce: false ) )
					decorLine = $" Loot: {def.Name}.";
			}

			// First-time circuit tank prize (Neighbor's Pool, Sewage Duct, …)
			var tankLine = "";
			var prizeHab = GetPrizeHabitat( _template );
			if ( prizeHab is not null && !_prizeHabitatsWon.Contains( prizeHab.Id ) )
			{
				if ( TankSim.TryApplyFightPrize( prizeHab ) )
				{
					_prizeHabitatsWon.Add( prizeHab.Id );
					_lastPrizeTankName = prizeHab.Name;
					tankLine = $" NEW TANK: {prizeHab.Name}!";
					Achievements.OnFightTankWon( prizeHab );
				}
				else
				{
					// Couldn't install (wrong water, etc.) — still mark + consoliation cash
					_prizeHabitatsWon.Add( prizeHab.Id );
					var bonus = Math.Max( 40, prizeHab.BaseCapacity );
					Economy.Add( bonus );
					tankLine = $" Tank deed banked (+Ð{Economy.FormatDoge( bonus )}).";
					_lastPrizeTankName = prizeHab.Name;
					Achievements.OnFightTankWon( prizeHab );
				}
			}
			else if ( prizeHab is not null )
			{
				tankLine = " (tank already claimed)";
			}

			_resultTitle = string.IsNullOrEmpty( _lastPrizeTankName ) ? "VICTORY" : "TANK CLAIMED";
			_resultBody = $"+Ð{Economy.FormatDoge( _payout )}.{decorLine}{tankLine}";
			_log.Add( $"WIN · +Ð{Economy.FormatDoge( _payout )}{tankLine}" );
			GameAudio.PlayDogeBark();
			Karma.Add( 2.5f );
			Achievements.OnBattleWin( _template, _wins );
			var banner = string.IsNullOrEmpty( _lastPrizeTankName )
				? $"Won {_template.Name}! +Ð{Economy.FormatDoge( _payout )}"
				: $"Won {_template.Name}! Claimed {_lastPrizeTankName}!";
			TankSim.ShowBanner( banner );
		}
		else
		{
			_losses++;
			Chaos.Add( _template.ChaosOnLose );
			// Bench every fighter for 3 minutes — they look sick and can't re-queue.
			var hurt = 0;
			foreach ( var pf in _playerTeam )
			{
				var fish = TankSim.Fish.FirstOrDefault( f => f.InstanceId == pf.SourceId );
				if ( fish is null )
					continue;
				fish.ApplyFightLossInjury();
				hurt++;
			}
			_resultTitle = "DEFEAT";
			_resultBody = hurt > 0
				? "Fee gone. Team hurt — 3 min lockout."
				: "Fee gone. Train or juice.";
			_log.Add( hurt > 0 ? "LOSS · team benched 3:00" : "LOSS · go train" );
			GameAudio.PlayUi( GameAudio.Error );
			Achievements.OnBattleLose();
			Karma.Add( -1.5f );
			TankSim.ShowBanner( hurt > 0
				? $"Lost {_template.Name}. 3 min lockout."
				: $"Lost {_template.Name}." );
		}

		// Tiny post-fight bump to participating fish (winners only)
		if ( won )
		{
			foreach ( var pf in _playerTeam )
			{
				var fish = TankSim.Fish.FirstOrDefault( f => f.InstanceId == pf.SourceId );
				if ( fish is null )
					continue;
				fish.Endurance = MathF.Min( 10f, fish.Endurance + 0.15f );
				fish.Agility = MathF.Min( 10f, fish.Agility + 0.1f );
			}
		}

		// Drop injured fish from the lobby roster so AutoSelect doesn't re-pick corpses.
		_selectedIds.RemoveWhere( id =>
		{
			var f = TankSim.Fish.FirstOrDefault( x => x.InstanceId == id );
			return f is null || f.IsFightInjured;
		} );

		SaveGame.TrySave();
		_version++;
	}

	static DecorDef PickRewardDecor()
	{
		var catalog = DecorDef.Catalog;
		if ( catalog is null || catalog.Count == 0 )
			return null;
		// Prefer slightly nicer pieces for hard fights
		if ( _template is not null && _template.Id == "shark_bait_invitational" )
		{
			var fancy = catalog.Where( d => d.Cost >= 25 ).ToList();
			if ( fancy.Count > 0 )
				return fancy[_rng.Next( fancy.Count )];
		}
		return catalog[_rng.Next( catalog.Count )];
	}

	public static void AcknowledgeResult()
	{
		if ( _phase != BattlePhase.Result )
			return;
		// Back to lobby for rematch
		_phase = BattlePhase.Lobby;
		_playerTeam.Clear();
		_aiTeam.Clear();
		_log.Clear();
		_selectedIds.Clear();
		if ( _template is not null )
			AutoSelectBest( _template.TeamSize );
		_version++;
	}
}