Powerups/ActivePowerups.cs

Static registry for timed player powerups. Stores end times, supports pausing (hold/release), querying active/timed/remaining, listing sorted active powerups, activating/clearing them, and console commands for inspection and control.

File Access
using Sandbox;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// What is running on the player right now, and for how much longer.
///
/// ⛔ THE REGISTRY IS SEPARATE FROM THE EFFECTS, deliberately. Every timed powerup
/// needs the same three things — a duration, a countdown, and a way for the HUD to
/// ask what is active — and only the BEHAVIOUR differs. Building this first means
/// Insta-Kill and Double Points each become one method reading `IsActive`, rather
/// than each carrying its own timer to get subtly wrong.
///
/// ⚠️ A STATIC rather than a component, matching `PowerupBannerState`: razor panels
/// are generated types that plain .cs cannot reference, so the HUD needs a seam it
/// can read without a component lookup. It also means an effect can ask "is Double
/// Points on" from anywhere — the scoring code, a weapon, a zombie — without
/// threading a reference through all of them.
/// </summary>
public static class ActivePowerups
{
	/// <summary>
	/// How long each timed powerup lasts. The original's own durations from
	/// `sh_powerups.lua` — 30s for both.
	///
	/// ⚠️ ONLY TIMED KINDS APPEAR HERE. Max Ammo, Bonus Points and Nuke are instant
	/// (`duration = 0` in the original) and must never occupy a HUD slot; absence
	/// from this table IS what marks them instant, so there is no second list to
	/// disagree with it.
	/// </summary>
	/// <summary>
	/// How long each timed powerup lasts — the original's own durations from
	/// `sh_powerups.lua`, 30s for all three.
	///
	/// ⛔ A SWITCH, NOT A `static readonly Dictionary`. It WAS a dictionary, and Fire
	/// Sale could not be added to it in a running session: **static field
	/// initialisers do not re-run on hotload.** The dictionary kept the contents it
	/// was built with when the class first loaded, so a newly added entry was invisible
	/// to `IsTimed` no matter how many times the source was edited, recompiled or play
	/// was restarted — the assembly's statics simply never rebuilt. It reported
	/// "FireSale is instant" against a table that visibly contained FireSale.
	///
	/// A switch is evaluated per call and cannot hold a stale snapshot of itself.
	///
	/// ⚠️ RETURNING 0 IS WHAT MARKS A POWERUP INSTANT. Max Ammo, Bonus Points, Nuke
	/// and Carpenter are absent on purpose, so there is no second list to disagree
	/// with this one.
	/// </summary>
	public static float DurationOf( PowerupKind kind ) => kind switch
	{
		PowerupKind.InstaKill => 30f,
		PowerupKind.DoublePoints => 30f,
		PowerupKind.FireSale => 30f,
		_ => 0f,
	};

	/// <summary>
	/// kind -> the realtime at which it ends.
	///
	/// ⚠️ Still a static field, and that is FINE here — it is initialised EMPTY and
	/// filled at runtime, so a hotload keeping the old instance loses nothing. The
	/// trap above only bites a static whose initialiser carries CONTENT.
	/// </summary>
	static readonly Dictionary<PowerupKind, RealTimeUntil> _active = new();

	/// <summary>
	/// While a single-player game is paused (`GamePause`), what each running power-up had left. Its clock stands still, and every
	/// read below answers from here. Null when not paused.
	///
	/// ⚠️ THE CLOCKS ARE REAL TIME (`RealTimeUntil`), WHICH THE PAUSE'S TIME SCALE DOES NOT STOP (2026-10-05): without this a 30 s
	/// Insta-Kill ran out behind the pause menu.
	/// </summary>
	static Dictionary<PowerupKind, float> _held;

	/// <summary>Stop every power-up clock where it stands (`GamePause`). Twice is once.</summary>
	public static void Hold()
	{
		if ( _held is not null ) return;

		_held = new Dictionary<PowerupKind, float>();
		foreach ( var (kind, until) in _active )
			if ( until > 0f ) _held[kind] = until;
	}

	/// <summary>Start them again from where they stood.</summary>
	public static void Release()
	{
		if ( _held is null ) return;

		foreach ( var (kind, left) in _held )
			_active[kind] = left;

		_held = null;
	}

	/// <summary>Is this powerup timed at all?</summary>
	public static bool IsTimed( PowerupKind kind ) => DurationOf( kind ) > 0f;

	/// <summary>Is it running right now?</summary>
	public static bool IsActive( PowerupKind kind )
		=> _held is not null
			? _held.ContainsKey( kind )
			: _active.TryGetValue( kind, out var until ) && until > 0f;

	/// <summary>Seconds left, 0 when not running.</summary>
	public static float Remaining( PowerupKind kind )
		=> _held is not null
			? (_held.TryGetValue( kind, out var left ) ? left : 0f)
			: _active.TryGetValue( kind, out var until ) && until > 0f ? until : 0f;

	/// <summary>
	/// Start it, or refresh it if already running.
	///
	/// ⛔ REFRESHES, IT DOES NOT STACK. Collecting a second Insta-Kill while one is
	/// up sets the clock back to 30 — it does not give you 60. That is the original's
	/// behaviour and it is also the only version that can be shown on a HUD with one
	/// slot per powerup.
	/// </summary>
	public static void Activate( PowerupKind kind )
	{
		if ( !IsTimed( kind ) ) return;

		// ⚠ TIMESLIP M1 TIME BANK SCALES IT HERE, at the one place a duration is assigned.
		// `TimeUntil` is an absolute deadline, so the only moment a duration can be changed is
		// when it is set — there is nothing to stretch afterwards.
		//
		// ⚠ "TIMED POWER-UPS ONLY" NEEDS NO TEST. An instant power-up's `DurationOf` is 0 and
		// `IsTimed` is literally `DurationOf( kind ) > 0`, so multiplying 0 by 2 is still 0. The
		// arithmetic already says what the augment text promises.
		var holder = NZPlayer.Local;
		var seconds = DurationOf( kind ) * TimeAugments.PowerupDurationScale( holder );

		_active[kind] = seconds;

		// ⚠️ AND INTO THE HELD COPY DURING A PAUSE (the console can start one then), or the pause's end would take it back
		if ( _held is not null ) _held[kind] = seconds;
		Log.Info( $"[nz] {kind} active for {seconds:0}s" );
	}

	/// <summary>
	/// Everything running, longest remaining first.
	///
	/// ⚠️ SORTED so the row does not reorder itself as timers pass each other. A slot
	/// that jumps sideways when another expires is worse than a fixed order — the
	/// original assigns each powerup a FIXED slot index for exactly this reason
	/// (`powerup_poses`), which is the better long-term answer once there are more
	/// than a couple.
	/// </summary>
	public static IEnumerable<(PowerupKind kind, float remaining)> All()
		=> _held is not null
			? _held
				.OrderByDescending( kv => kv.Value )
				.Select( kv => (kv.Key, kv.Value) )
			: _active
				.Where( kv => kv.Value > 0f )
				.OrderByDescending( kv => (float)kv.Value )
				.Select( kv => (kv.Key, (float)kv.Value) );

	/// <summary>Drop everything — used on death and round reset.</summary>
	public static void Clear()
	{
		_active.Clear();
		_held?.Clear();
	}

	// ── commands ─────────────────────────────────────────────────────────────

	/// <summary>Start one by hand: `nz_powerup_active &lt;kind&gt;`.</summary>
	[ConCmd( "nz_powerup_active" )]
	public static void Cmd( string kind = "" )
	{
		if ( string.IsNullOrWhiteSpace( kind ) )
		{
			var live = All().ToList();

			if ( live.Count == 0 ) { Log.Info( "[nz] nothing active" ); return; }

			foreach ( var (k, r) in live )
				Log.Info( $"[nz]   {k,-14} {r:0.0}s left" );

			return;
		}

		if ( !Powerup.TryParseKind( kind, out var k2 ) )
		{
			Log.Warning( $"[nz] no powerup called '{kind}'. Try: {Powerup.KindNames()}" );
			return;
		}

		if ( !IsTimed( k2 ) )
		{
			Log.Info( $"[nz] {k2} is instant — it has no timer" );
			return;
		}

		Activate( k2 );
	}

	/// <summary>Stop everything: `nz_powerup_clear`.</summary>
	[ConCmd( "nz_powerup_clear" )]
	public static void ClearCmd()
	{
		Clear();
		Log.Info( "[nz] cleared active powerups" );
	}
}