TrainingSystem.cs

TrainingSystem static class for a fish-training minigame and an inject/steroid minigame. Manages state, timing, UI feedback, applying stat gains to FishActor instances, session summaries, buying/using syringes, and per-frame ticking of progress markers.

File Access
namespace NoChillquarium;

/// <summary>
/// Fish training minigame (3-rep timing) + steroid inject minigame.
/// Gym is free; training items (meth, juice) are Ð sinks in the shop.
/// Steroids unlock after ~1 hour playtime.
/// </summary>
public static class TrainingSystem
{
	public const float SteroidUnlockSeconds = 3600f;
	public const double SyringeCost = 60;
	public const float JackedDuration = 48f;
	public const float TrainCooldown = 3.5f;
	public const int TrainReps = 3;

	static readonly Random _rng = new();
	static float _trainCooldown;
	static int _version;
	static int _syringes;

	// ---- Inject minigame ----
	static bool _injectOpen;
	static float _marker;
	static float _markerDir = 1f;
	static float _markerSpeed = 1.15f;
	public const float ZoneMin = 0.42f;
	public const float ZoneMax = 0.62f;

	// ---- Training minigame (single-fish focus) ----
	static bool _trainOpen;
	static string _trainTargetId = "";
	static float _trainMarker;
	static float _trainMarkerPrev; // last-frame pos for late-click grace
	static float _trainMarkerDir = 1f;
	static float _trainMarkerSpeed = 1.0f;
	static float _trainZoneMin = 0.38f;
	static float _trainZoneMax = 0.62f;
	static int _trainRep;          // 0..TrainReps-1 while playing; TrainReps when done
	static int _trainHits;
	static int _trainPerfects;
	static string _trainFeedback = "";
	static float _trainFeedbackTimer;
	static float _trainHitLock;    // brief pause after a rep so zone isn't a blur
	static string _trainStatFocus = "Endurance"; // which stat this session emphasizes
	static int _trainStatIndex;
	/// <summary>Extra hit width beyond the green bar (covers click lag + marker thickness).</summary>
	const float TrainHitPad = 0.06f;
	// Session totals (real 0–10 applied) for end-of-set notification
	static float _sessionEnd;
	static float _sessionSpd;
	static float _sessionAgi;
	static string _sessionSummary = "";
	static float _sessionSummaryTimer;

	public static int Version => _version;
	public static int Syringes => _syringes;
	public static bool InjectOpen => _injectOpen;
	public static float Marker => _marker;
	public static float TrainCooldownLeft => _trainCooldown;
	public static bool CanTrain =>
		TankSim.IsActive
		&& TankSim.FishCount > 0
		&& _trainCooldown <= 0f
		&& !_trainOpen
		&& !_injectOpen;

	public static bool TrainOpen => _trainOpen;
	public static string TrainTargetId => _trainTargetId;
	public static FishActor TrainTarget =>
		string.IsNullOrEmpty( _trainTargetId ) ? null : TankSim.FindFish( _trainTargetId );
	public static float TrainMarker => _trainMarker;
	public static float TrainZoneMin => _trainZoneMin;
	public static float TrainZoneMax => _trainZoneMax;
	public static int TrainRep => _trainRep;
	public static int TrainHits => _trainHits;
	public static int TrainPerfects => _trainPerfects;
	public static string TrainFeedback => _trainFeedbackTimer > 0f ? _trainFeedback : "";
	public static string TrainStatFocus => _trainStatFocus;
	public static string TrainRepLabel =>
		_trainOpen ? $"Rep {_trainRep + 1} / {TrainReps}" : "Train";
	public static string TrainTargetName =>
		TrainTarget?.DisplayName ?? "Fish";
	/// <summary>End-of-session gains line, e.g. "END +4  SPD +2  AGI +1" (lingers after gym closes).</summary>
	public static string SessionSummary =>
		_sessionSummaryTimer > 0f ? _sessionSummary : "";
	public static bool HasSessionSummary => _sessionSummaryTimer > 0f && !string.IsNullOrEmpty( _sessionSummary );

	public static float TrainZoneWidth => MathF.Max( 0.04f, _trainZoneMax - _trainZoneMin );
	public static float TrainZoneLeftPct => _trainZoneMin * 100f;
	public static float TrainZoneWidthPct => TrainZoneWidth * 100f;

	public static bool SteroidsUnlocked =>
		Economy.PlaytimeSeconds >= SteroidUnlockSeconds;

	public static string SteroidUnlockText
	{
		get
		{
			if ( SteroidsUnlocked )
				return "Unlocked";
			var left = SteroidUnlockSeconds - Economy.PlaytimeSeconds;
			var m = (int)(left / 60f);
			var s = (int)(left % 60f);
			return $"Unlocks in {m}:{s:00}";
		}
	}

	public static void Clear()
	{
		_trainCooldown = 0f;
		_syringes = 0;
		_injectOpen = false;
		_trainOpen = false;
		_trainTargetId = "";
		_marker = 0f;
		_markerDir = 1f;
		_trainMarker = 0f;
		_trainMarkerPrev = 0f;
		_trainMarkerDir = 1f;
		_trainRep = 0;
		_trainHits = 0;
		_trainPerfects = 0;
		_trainFeedback = "";
		_trainFeedbackTimer = 0f;
		_trainHitLock = 0f;
		_sessionEnd = 0f;
		_sessionSpd = 0f;
		_sessionAgi = 0f;
		_sessionSummary = "";
		_sessionSummaryTimer = 0f;
		_version++;
	}

	public static void Load( int syringes )
	{
		_syringes = Math.Max( 0, syringes );
		_injectOpen = false;
		_trainOpen = false;
		_trainTargetId = "";
		_version++;
	}

	public static void WriteToSave( SaveData data )
	{
		if ( data is not null )
			data.Syringes = _syringes;
	}

	public static void Tick( float dt )
	{
		if ( dt <= 0f )
			return;
		if ( dt > 0.05f )
			dt = 0.05f;

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

		if ( _trainFeedbackTimer > 0f )
		{
			_trainFeedbackTimer -= dt;
			_version++;
		}

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

		if ( _injectOpen )
			TickMarker( ref _marker, ref _markerDir, ref _markerSpeed, dt, 0.95f );

		if ( _trainOpen )
		{
			if ( _trainHitLock > 0f )
			{
				// Freeze the bar briefly after a hit so feedback is readable
				_trainHitLock = MathF.Max( 0f, _trainHitLock - dt );
				_version++;
			}
			else
			{
				// Readable pace — was ~1s full sweeps (too fast for click latency)
				var baseSpeed = 0.62f + _trainRep * 0.12f;
				_trainMarkerPrev = _trainMarker;
				TickMarker( ref _trainMarker, ref _trainMarkerDir, ref _trainMarkerSpeed, dt, baseSpeed );
			}
		}
	}

	static void TickMarker( ref float marker, ref float dir, ref float speed, float dt, float baseSpeed )
	{
		// Use baseSpeed directly (don't stack old speed state)
		speed = baseSpeed + MathF.Sin( Time.Now * 2.1f ) * 0.08f;
		marker += dir * speed * dt;
		if ( marker >= 1f )
		{
			marker = 1f;
			dir = -1f;
		}
		else if ( marker <= 0f )
		{
			marker = 0f;
			dir = 1f;
		}
		_version++;
	}

	public static void TickFish( FishActor fish, float dt )
	{
		if ( fish is null )
			return;

		if ( fish.JackedTimer > 0f )
		{
			fish.JackedTimer -= dt;
			if ( fish.JackedTimer <= 0f )
			{
				fish.Jacked = false;
				fish.JackedTimer = 0f;
				fish.Vx *= 0.6f;
				fish.Vy *= 0.6f;
			}
		}
	}

	// ---------- TRAINING MINIGAME ----------

	/// <summary>Train a specific fish (from fish menu). Free gym; starts timing minigame.</summary>
	public static bool TryStartTrain( string fishInstanceId )
	{
		if ( !TankSim.IsActive )
		{
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( MethRush.Open )
		{
			TankSim.ShowBanner( "Finish meth rush first." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var fish = TankSim.FindFish( fishInstanceId );
		if ( fish is null )
		{
			TankSim.ShowBanner( "That fish swam off." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( _trainOpen )
		{
			// Already training this one — keep panel open
			if ( _trainTargetId == fishInstanceId )
				return true;
			TankSim.ShowBanner( "Finish the current gym set first." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( _injectOpen )
		{
			TankSim.ShowBanner( "Finish the inject minigame first." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( _trainCooldown > 0f )
		{
			TankSim.ShowBanner( $"Gym cooldown {_trainCooldown:0.0}s" );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		_trainTargetId = fish.InstanceId;
		_trainOpen = true;
		_trainRep = 0;
		_trainHits = 0;
		_trainPerfects = 0;
		_trainHitLock = 0f;
		_trainFeedback = "";
		_trainFeedbackTimer = 0f;
		_sessionEnd = 0f;
		_sessionSpd = 0f;
		_sessionAgi = 0f;
		_sessionSummary = "";
		_sessionSummaryTimer = 0f;
		_trainFeedback = "Hit the green zone!";
		_trainFeedbackTimer = 1.4f;
		_trainStatIndex = _rng.Next( 3 );
		_trainStatFocus = _trainStatIndex switch
		{
			0 => "Endurance",
			1 => "Speed",
			_ => "Agility"
		};
		SetupTrainRep();
		GameAudio.PlayUi( GameAudio.Notice );
		TankSim.ShowBanner( $"Train {fish.DisplayName} · {_trainStatFocus}" );
		_version++;
		return true;
	}

	/// <summary>Legacy: train a random active fish.</summary>
	public static bool TryStartTrain()
	{
		if ( TankSim.FishCount == 0 )
		{
			TankSim.ShowBanner( "No fish to train." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}
		var list = TankSim.Fish;
		var pick = list[_rng.Next( list.Count )];
		return TryStartTrain( pick.InstanceId );
	}

	public static bool TryTrain() => TryStartTrain();

	public static void CancelTrain()
	{
		if ( !_trainOpen )
			return;
		// Short cooldown so bail-spam doesn't feel free of effort.
		_trainOpen = false;
		_trainTargetId = "";
		_trainCooldown = TrainCooldown * 0.5f;
		TankSim.ShowBanner( "Gym skipped." );
		GameAudio.PlayUi( GameAudio.Close );
		_version++;
	}

	static void SetupTrainRep()
	{
		// Zone drifts + gently shrinks each rep (still wide enough to hit)
		var width = 0.30f - _trainRep * 0.035f;
		width = Math.Clamp( width, 0.18f, 0.32f );
		var center = 0.28f + (float)_rng.NextDouble() * 0.44f;
		_trainZoneMin = center - width * 0.5f;
		_trainZoneMax = center + width * 0.5f;
		// Keep fully on the bar
		if ( _trainZoneMin < 0.04f )
		{
			_trainZoneMax += 0.04f - _trainZoneMin;
			_trainZoneMin = 0.04f;
		}
		if ( _trainZoneMax > 0.96f )
		{
			_trainZoneMin -= _trainZoneMax - 0.96f;
			_trainZoneMax = 0.96f;
		}
		_trainZoneMin = Math.Clamp( _trainZoneMin, 0.04f, 0.9f );
		_trainZoneMax = Math.Clamp( _trainZoneMax, _trainZoneMin + 0.12f, 0.96f );

		_trainMarker = _rng.NextDouble() < 0.5 ? 0.04f : 0.96f;
		_trainMarkerPrev = _trainMarker;
		_trainMarkerDir = _trainMarker < 0.5f ? 1f : -1f;
		_trainMarkerSpeed = 0.62f + _trainRep * 0.12f;
		_trainHitLock = 0f;
	}

	/// <summary>True if a 0–1 marker position is inside the green (with optional pad).</summary>
	static bool MarkerInZone( float m, float pad )
	{
		return m >= _trainZoneMin - pad && m <= _trainZoneMax + pad;
	}

	/// <summary>
	/// True if the marker is in the green now, was last frame, or crossed the zone this frame.
	/// Covers UI click lag so “looks green” ≈ “counts as hit”.
	/// </summary>
	static bool HitGreenZone()
	{
		if ( MarkerInZone( _trainMarker, TrainHitPad ) )
			return true;
		if ( MarkerInZone( _trainMarkerPrev, TrainHitPad ) )
			return true;

		// Segment cross: prev → current straddles the zone
		var a = MathF.Min( _trainMarkerPrev, _trainMarker );
		var b = MathF.Max( _trainMarkerPrev, _trainMarker );
		var z0 = _trainZoneMin - TrainHitPad;
		var z1 = _trainZoneMax + TrainHitPad;
		return a <= z1 && b >= z0;
	}

	/// <summary>Player hits on the current rep — gains + floating +stat pops on hit.</summary>
	public static bool TryHitTrain()
	{
		if ( !_trainOpen )
			return false;

		// Ignore double-taps during post-hit freeze
		if ( _trainHitLock > 0.05f )
			return false;

		var inZone = HitGreenZone();
		var mid = ( _trainZoneMin + _trainZoneMax ) * 0.5f;
		var half = MathF.Max( 0.02f, ( _trainZoneMax - _trainZoneMin ) * 0.5f );
		// Perfect uses true zone center (no pad) for a skill read
		var perfect = MarkerInZone( _trainMarker, 0f )
			&& MathF.Abs( _trainMarker - mid ) <= half * 0.45f;

		if ( inZone )
		{
			_trainHits++;
			if ( perfect )
			{
				_trainPerfects++;
				_trainFeedback = "PERFECT!";
			}
			else
			{
				_trainFeedback = "Good rep!";
			}
			GameAudio.PlayTrainRep( hit: true, perfect: perfect );
			ApplyRepGains( perfect );
		}
		else
		{
			_trainFeedback = "Miss — weak form.";
			GameAudio.PlayTrainRep( hit: false );
			Chaos.Add( 0.5f );
		}

		_trainFeedbackTimer = 0.9f;
		_trainHitLock = 0.28f; // freeze bar so next zone is clear
		_trainRep++;

		if ( _trainRep >= TrainReps )
		{
			FinishTrainSession();
			return inZone;
		}

		SetupTrainRep();
		_trainHitLock = 0.22f; // short beat before marker moves on next rep
		_version++;
		return inZone;
	}

	/// <summary>Per-rep gains on the trainee + RPG float pops over the fish.</summary>
	static void ApplyRepGains( bool perfect )
	{
		var fish = TrainTarget;
		if ( fish is null )
			return;

		var mul = perfect ? 1.35f : 1.0f;
		// Readable pops: ~+2…+5 primary per rep (display = gain × 10).
		var baseGain = (0.28f + _trainRep * 0.06f) * mul;
		baseGain *= 0.95f + (float)_rng.NextDouble() * 0.12f;

		var primary = ApplyGain( fish, _trainStatIndex, baseGain );
		var secondary = ApplyGain( fish, ( _trainStatIndex + 1 ) % 3, baseGain * 0.5f );
		var tertiary = ApplyGain( fish, ( _trainStatIndex + 2 ) % 3, baseGain * 0.3f );

		// Display-only pad if cap ate the real gain (session totals stay honest).
		var showPrimary = primary > 0.001f ? primary : 0.1f;
		var showSecondary = secondary > 0.001f ? secondary : 0.05f;

		var ends = new float[3];
		ends[_trainStatIndex] = showPrimary;
		ends[( _trainStatIndex + 1 ) % 3] = showSecondary;
		ends[( _trainStatIndex + 2 ) % 3] = tertiary;
		StatFloat.SpawnTrio( fish, ends[0], ends[1], ends[2] );

		// Accumulate real applied for the end-of-set card.
		AddSessionGain( _trainStatIndex, primary );
		AddSessionGain( ( _trainStatIndex + 1 ) % 3, secondary );
		AddSessionGain( ( _trainStatIndex + 2 ) % 3, tertiary );

		// Little celebratory dart
		fish.Vx *= 1.04f + mul * 0.06f;
		_version++;
	}

	static void AddSessionGain( int stat, float amount )
	{
		if ( amount <= 0.001f )
			return;
		switch ( stat )
		{
			case 0: _sessionEnd += amount; break;
			case 1: _sessionSpd += amount; break;
			default: _sessionAgi += amount; break;
		}
	}

	static void FinishTrainSession()
	{
		_trainOpen = false;
		_trainCooldown = TrainCooldown;

		var hitMul = _trainHits / (float)TrainReps;
		var fish = TrainTarget;
		var name = fish?.DisplayName ?? "Fish";

		// Perfect-set bonus before summary
		if ( fish is not null && _trainPerfects >= TrainReps )
		{
			var bonus = ApplyGain( fish, _trainStatIndex, 0.35f );
			AddSessionGain( _trainStatIndex, bonus );
		}

		var gainsLine = FormatSessionGains( _sessionEnd, _sessionSpd, _sessionAgi );
		var hasGains = !string.IsNullOrEmpty( gainsLine );

		if ( _trainHits == 0 )
		{
			_sessionSummary = "No gains — hit the green next time";
			_sessionSummaryTimer = 3.5f;
			TankSim.ShowBanner( $"{name} quit early." );
			GameAudio.PlayTrainSessionDone( anyHits: false );
			Karma.Add( 0.3f );
		}
		else if ( _trainPerfects >= TrainReps )
		{
			_sessionSummary = hasGains ? gainsLine : $"{_trainStatFocus} up";
			_sessionSummaryTimer = 5f;
			TankSim.ShowBanner( hasGains
				? $"{name} · PERFECT set · {gainsLine}"
				: $"{name} · PERFECT {_trainStatFocus} set!" );
			GameAudio.PlayTrainSessionDone( anyHits: true );
			Karma.Add( 3.5f );
			Chaos.Add( 1f );
			if ( fish is not null && hasGains )
				StatFloat.ShowSessionSummary( fish, _sessionEnd, _sessionSpd, _sessionAgi );
		}
		else if ( _trainHits >= TrainReps )
		{
			_sessionSummary = hasGains ? gainsLine : $"{_trainStatFocus} up";
			_sessionSummaryTimer = 4.5f;
			TankSim.ShowBanner( hasGains
				? $"{name} trained · {gainsLine}"
				: $"{name} · clean set · {_trainStatFocus} up." );
			GameAudio.PlayTrainSessionDone( anyHits: true );
			Karma.Add( 2.5f );
			if ( fish is not null && hasGains )
				StatFloat.ShowSessionSummary( fish, _sessionEnd, _sessionSpd, _sessionAgi );
		}
		else
		{
			_sessionSummary = hasGains
				? $"{_trainHits}/{TrainReps} hits · {gainsLine}"
				: $"{_trainHits}/{TrainReps} hits";
			_sessionSummaryTimer = 4.5f;
			TankSim.ShowBanner( hasGains
				? $"{name} · {_trainHits}/{TrainReps} · {gainsLine}"
				: $"{name} · {_trainHits}/{TrainReps} hits · {_trainStatFocus}." );
			GameAudio.PlayTrainSessionDone( anyHits: true );
			Karma.Add( 1.2f + _trainHits * 0.4f );
			if ( fish is not null && hasGains )
				StatFloat.ShowSessionSummary( fish, _sessionEnd, _sessionSpd, _sessionAgi );
		}

		if ( fish is not null && _trainPerfects >= 2 )
		{
			var ang = (float)(_rng.NextDouble() * Math.PI * 2);
			fish.Vx = MathF.Cos( ang ) * fish.Species.Speed * (1.4f + hitMul);
			fish.Vy = MathF.Sin( ang ) * fish.Species.Speed * 0.5f;
		}

		if ( _trainHits > 0 )
			Achievements.OnTrained();

		_trainTargetId = "";
		SaveGame.TrySave();
		_version++;
	}

	/// <summary>Display points = tenths of 0–10 stat (0.35 → +4). Skips zero stats.</summary>
	public static string FormatSessionGains( float end, float spd, float agi )
	{
		var parts = new List<string>( 3 );
		void Add( string label, float gain )
		{
			if ( gain <= 0.001f )
				return;
			var pts = Math.Max( 1, (int)MathF.Round( gain * 10f ) );
			parts.Add( $"{label} +{pts}" );
		}
		// Largest first so the focus stat tends to lead
		var ordered = new[]
		{
			("END", end),
			("SPD", spd),
			("AGI", agi)
		};
		Array.Sort( ordered, ( a, b ) => b.Item2.CompareTo( a.Item2 ) );
		foreach ( var (label, gain) in ordered )
			Add( label, gain );
		return string.Join( "  ", parts );
	}

	/// <returns>Actual applied amount after 0–10 cap.</returns>
	static float ApplyGain( FishActor fish, int stat, float gain )
	{
		if ( fish is null || gain <= 0f )
			return 0f;

		float before;
		switch ( stat )
		{
			case 0:
				before = fish.Endurance;
				fish.Endurance = MathF.Min( 10f, fish.Endurance + gain );
				return fish.Endurance - before;
			case 1:
				before = fish.SpeedStat;
				fish.SpeedStat = MathF.Min( 10f, fish.SpeedStat + gain );
				return fish.SpeedStat - before;
			default:
				before = fish.Agility;
				fish.Agility = MathF.Min( 10f, fish.Agility + gain );
				return fish.Agility - before;
		}
	}

	/// <summary>
	/// Meth bag dose: chemical gym. Strong SPD lean, some END/AGI.
	/// Diminishing returns as addiction climbs (tolerance). Spawns +stat floats.
	/// </summary>
	public static void ApplyMethTraining( FishActor fish )
	{
		if ( fish is null )
			return;

		// ~0.35 solid hit when fresh; ~0.18 when full client — still trains, just less.
		var gain = 0.38f * (0.55f + (1f - fish.AddictionNorm) * 0.45f);
		gain *= 0.9f + (float)_rng.NextDouble() * 0.2f;

		// Zoomies theme: speed primary, agility secondary, endurance a little.
		var spd = ApplyGain( fish, 1, gain );
		var agi = ApplyGain( fish, 2, gain * 0.55f );
		var end = ApplyGain( fish, 0, gain * 0.35f );
		StatFloat.SpawnTrio( fish, end, spd, agi );

		_version++;
	}

	// ---------- STEROIDS ----------

	public static bool CanBuySyringe() =>
		TankSim.IsActive && SteroidsUnlocked && Economy.Balance >= SyringeCost;

	public static bool TryBuySyringe()
	{
		if ( !SteroidsUnlocked )
		{
			TankSim.ShowBanner( $"Steroids {SteroidUnlockText.ToLower()}." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( !Economy.TrySpend( SyringeCost ) )
		{
			TankSim.ShowBanner( $"Need Ð{Economy.FormatDoge( SyringeCost )} for juice." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		_syringes++;
		GameAudio.PlayUi( GameAudio.Trade );
		Chaos.Add( 3f );
		TankSim.ShowBanner( $"Bought juice ×{_syringes}." );
		SaveGame.TrySave();
		_version++;
		return true;
	}

	public static bool TryStartInject()
	{
		if ( !TankSim.IsActive || TankSim.FishCount == 0 )
		{
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( MethRush.Open )
		{
			TankSim.ShowBanner( "Finish meth rush first." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( _trainOpen )
		{
			TankSim.ShowBanner( "Finish training first." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( _syringes <= 0 )
		{
			TankSim.ShowBanner( "No juice — buy in Shady." );
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		_injectOpen = true;
		_marker = 0f;
		_markerDir = 1f;
		_markerSpeed = 1.2f;
		GameAudio.PlayUi( GameAudio.Notice );
		_version++;
		return true;
	}

	public static void CancelInject()
	{
		_injectOpen = false;
		_version++;
	}

	public static bool TryHitInject()
	{
		if ( !_injectOpen )
			return false;

		var hit = _marker >= ZoneMin && _marker <= ZoneMax;
		_injectOpen = false;

		if ( !hit )
		{
			if ( _syringes > 0 )
				_syringes--;
			Chaos.Add( 4f );
			GameAudio.PlayUi( GameAudio.Error );
			Achievements.OnInjectFail();
			TankSim.ShowBanner( "Missed. Syringe wasted." );
			_version++;
			SaveGame.TrySave();
			return false;
		}

		if ( _syringes > 0 )
			_syringes--;

		var n = 0;
		foreach ( var fish in TankSim.Fish )
		{
			fish.Jacked = true;
			fish.JackedTimer = JackedDuration;
			var end = ApplyGain( fish, 0, 1.2f );
			var spd = ApplyGain( fish, 1, 1.5f );
			var agi = ApplyGain( fish, 2, 0.8f );
			StatFloat.SpawnTrio( fish, end, spd, agi );
			var ang = (float)(_rng.NextDouble() * Math.PI * 2);
			fish.Vx = MathF.Cos( ang ) * fish.Species.Speed * 2.2f;
			fish.Vy = MathF.Sin( ang ) * fish.Species.Speed * 0.8f;
			n++;
		}

		Chaos.Add( 8f + n );
		Karma.Add( -5f - n * 0.4f );
		GameAudio.PlayUi( GameAudio.Inject );
		Achievements.OnInjectSuccess( n );
		TankSim.ShowBanner( n == 1 ? "Fish jacked." : $"{n} fish jacked." );
		SaveGame.TrySave();
		_version++;
		return true;
	}
}