Powerups/PowerupEffects.cs

Static utility for resolving powerup effects in the NZombies game. It maps collected powerup kinds to concrete actions (MaxAmmo, BonusPoints, Carpenter, Nuke, Spider via augment), exposes team-effect classification, tunable point values, console commands for testing/tweaking, and exposes read-only properties used by timed powerup systems (Double Points, Insta-Kill, Fire Sale).

NetworkingFile Access
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// What each powerup actually DOES.
///
/// ⛔ ONE METHOD PER POWERUP, and that is the whole payoff of building the shell and
/// the registry first. Spawning, hovering, expiry, pickup, the banner, the announcer
/// and the countdown are all handled elsewhere and identically — everything specific
/// to a powerup lives here and nowhere else.
///
/// Point awards are the original's own, from `sh_powerups.lua`:
///   Nuke 400 · Carpenter 200 · Bonus `math.random(1,6)*50`
/// </summary>
public static class PowerupEffects
{
	/// <summary>
	/// Fire the effect for a collected powerup.
	///
	/// ⚠️ The TIMED ones do nothing here — their behaviour is read live from
	/// `ActivePowerups.IsActive` by the systems they affect, rather than pushed at
	/// pickup. A Double Points that flipped a flag on would need a matching flip-off
	/// on a timer, and the timer already exists in one place.
	/// </summary>
	public static void Apply( PowerupKind kind, NZPlayer player, int pointsOverride = -1 )
	{
		// ⛔ SIX SITUATIONS FROM ONE LINE, because this is the single place every power-up resolves.
		// Hanging the voice off each individual effect method would be six call sites that can drift
		// apart, and `BonusPoints` and `Spider` would each need remembering to leave alone.
		//
		// ⚠️ AN UNMAPPED KIND PASSES null, WHICH `Say` IGNORES. `Spider`, `BonusPoints` and
		// `DeathMachine` have no recorded line, and that is not an oversight to fix — the crew never
		// recorded one.
		CharacterVoice.Say( kind switch
		{
			PowerupKind.Nuke => "nuke",
			PowerupKind.MaxAmmo => "maxammo",
			PowerupKind.InstaKill => "instakill",
			PowerupKind.DoublePoints => "doublepoints",
			PowerupKind.FireSale => "firesale",
			PowerupKind.Carpenter => "carpenter",
			_ => null,
		}, player );

		switch ( kind )
		{
			// ⚠ Widow's Wine M4. The effect lives in `WidowAugments` rather than here because it
			// is the augment's payload, not a power-up the game otherwise has — and that keeps the
			// grenade cap in one place.
			case PowerupKind.Spider: WidowAugments.CollectSpider( player ); break;
			case PowerupKind.MaxAmmo: MaxAmmo( player ); break;
			case PowerupKind.BonusPoints: BonusPoints( player, pointsOverride ); break;
			case PowerupKind.Carpenter: Carpenter( player ); break;
			case PowerupKind.Nuke: Nuke( player ); break;

			// Read live from ActivePowerups — see the properties below.
			case PowerupKind.DoublePoints:
			case PowerupKind.InstaKill:
			case PowerupKind.FireSale:
				break;
		}
	}

	/// <summary>
	/// Does this powerup happen to EVERYONE, or only to whoever picked it up?
	///
	/// ⛔ ONE AUTHOR, BECAUSE TWO COST A BUG THIS MORNING. `Powerup.Collect` and
	/// `Powerup.CollectRemote` each carried their own hand-written exception for Max Ammo, and only
	/// `CollectRemote` ever got it — so the host went without whenever a CLIENT picked one up, and
	/// it read as intermittent because it depended on who walked over it. Reported as *"max ammo
	/// só deu a uma pessoa"*. A second membership test would have been a second chance to make the
	/// same mistake with Nuke and Carpenter.
	///
	/// ⚠️ "TEAM" MEANS THE PER-PLAYER HALF REACHES EVERY PLAYER — the refill, the points. It does
	/// NOT mean the world half runs on every machine: `Nuke` kills the zombies and `Carpenter`
	/// reboards the barricades exactly once, guarded inside those methods. Both halves live in one
	/// call precisely so neither can be forgotten.
	///
	/// ⚠️ INSTA-KILL, DOUBLE POINTS AND FIRE SALE ARE NOT HERE and must not be. They are TIMED,
	/// not instant — `ActivePowerups.Activate` already starts everybody's clock, and whatever they
	/// affect reads the registry live. Adding them would apply a second, per-player effect on top of
	/// a global one that already works.
	/// </summary>
	public static bool IsTeamEffect( PowerupKind kind )
		=> kind is PowerupKind.MaxAmmo or PowerupKind.Nuke or PowerupKind.Carpenter;

	/// <summary>Points a Nuke pays each player. 400.</summary>
	public static int NukePoints { get; set; } = 400;

	/// <summary>Points a Carpenter pays each player. 200.</summary>
	public static int CarpenterPoints { get; set; } = 200;

	// ── instant effects ──────────────────────────────────────────────────────

	/// <summary>
	/// Every weapon's reserve AND magazine back to full, and grenades topped up.
	///
	/// ⛔ THE CLIP IS FILLED TOO NOW, REVERSING A STATED DESIGN DECISION. This method used to
	/// carry a ⛔ block saying "THE RESERVE ONLY, NOT THE CLIP — Max Ammo has never reloaded your
	/// weapon", on two arguments: that a player mid-reload should keep reloading, and that filling
	/// the clip hands a free instant reload to anyone who grabs it dry. Requested changed: the
	/// powerup refills the magazine as well. The free instant reload IS the feature now; the
	/// mid-reload case is handled rather than avoided — see the cancel below.
	///
	/// ⚠️ EVERY weapon, not the held one. Both slots, both fire modes.
	/// </summary>
	public static void MaxAmmo( NZPlayer player )
	{
		if ( !player.IsValid() ) return;

		int guns = 0, clips = 0;

		var inv = player.Components.Get<NZInventory>( FindMode.EverythingInSelf );

		if ( inv.IsValid() )
		{
			foreach ( var go in inv.Weapons )
			{
				// ⚠️ `EverythingInSelf` — a holstered weapon is DISABLED and the plain
				// lookup skips disabled components, which would refill only the gun in
				// your hands.
				var ammo = go.Components.Get<NZAmmo>( FindMode.EverythingInSelf );
				if ( ammo.IsValid() )
				{
					ammo.Reserve = ammo.MaxReserve;
					guns++;
				}

				// ⚠️ THE SAME `EverythingInSelf` REASON APPLIES TO THE WEAPON ITSELF, and getting
				// it wrong here would be invisible: the reserve would refill on every gun while
				// only the held one got its magazine, which reads as the powerup working.
				var wep = go.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf );
				if ( !wep.IsValid() ) continue;

				// ⛔ THE RELOAD IN PROGRESS MUST BE CANCELLED, AND THIS IS THE HALF THE OLD DESIGN
				// NOTE WAS RIGHT ABOUT. A reload that completes after the magazine is already full
				// runs `Take()` against the reserve and pours rounds into a clip with no room — so
				// the player pays for a reload they did not need, out of a reserve that was just
				// filled. Worse, the animation plays to the end over a full gun, which reads as the
				// powerup not having worked.
				//
				// ⚠️ `CancelAnyReload`, NOT `CancelReload`. Two cancels exist and a caller outside
				// Weapon.Reload cannot know which applies — `ShellReloading` is the discriminator,
				// and a tube-fed rifle shell-reloads while some shotguns do not. That method's own
				// header says it exists for exactly this kind of caller.
				//
				// ⚠️ AND IT IS SAFE ON A HOLSTERED GUN BY CONSTRUCTION: it early-returns unless
				// `IsReloading`, and a disabled weapon is not reloading, so nothing touches a null
				// ViewModelRenderer.
				wep.CancelAnyReload();

				if ( FillClip( wep.Primary ) ) clips++;
				if ( FillClip( wep.Secondary ) ) clips++;
			}
		}

		// ⛔ AND THE GRENADES. This is what the round-end +1 was standing in for, and
		// the reason `Refill()` was written with nothing able to call it.
		var nades = player.Components.Get<Grenade>( FindMode.EverythingInSelf );
		int added = nades.IsValid() ? nades.Refill() : 0;

		Log.Info( $"[nz] MAX AMMO — {guns} weapon(s) refilled, {clips} magazine(s) topped up,"
			+ $" +{added} grenade(s)" );
	}

	/// <summary>
	/// Top one fire mode's magazine to full. Returns whether it actually needed filling.
	///
	/// ⛔ `ClipSize <= 0` IS "FEEDS STRAIGHT FROM THE RESERVE", NOT "AN EMPTY MAGAZINE". SWB
	/// spells a magazineless weapon as a clip size of -1, and `ShouldAutoReload` already tests it
	/// that way — "there is no magazine to fill". Writing `Ammo = ClipSize` on one of those would
	/// set the round count NEGATIVE, and every fire gate reads `Ammo > 0`.
	///
	/// ⚠️ IT REPORTS WHETHER ANYTHING CHANGED so the log line counts magazines that were
	/// actually short. "3 magazines topped up" on a player who was already full is the kind of
	/// number that makes a report agree with itself while the game does something else.
	///
	/// ⚠️ THE ROUNDS ARE FREE — nothing is taken from the reserve. The reserve is set to its
	/// own maximum in the same pass, so charging the clip against it would be charging a number
	/// that is about to be overwritten anyway.
	/// </summary>
	static bool FillClip( SWB.Base.ShootInfo si )
	{
		if ( si is null || si.ClipSize <= 0 || si.Ammo >= si.ClipSize ) return false;

		si.Ammo = si.ClipSize;
		return true;
	}

	/// <summary>Bonus Points — the low end of the roll. 500.</summary>
	public static int BonusPointsMin { get; set; } = 500;

	/// <summary>Bonus Points — the high end. 1500.</summary>
	public static int BonusPointsMax { get; set; } = 1500;

	/// <summary>
	/// A points windfall, paid to EVERY player.
	///
	/// ⛔ IT USED TO PAY 50-300 TO THE COLLECTOR ALONE, which is why it was reported as
	/// "doing nothing". Both halves were wrong for this game: a wall buy costs 950 and a
	/// Pack-a-Punch 5,000, so a 50-point award at round 20 is invisible — and a team powerup
	/// that only pays whoever walked over it is not a team powerup. `math.random(1,6)*50` is
	/// the original's roll and the original's economy; this one is not that.
	///
	/// ⚠️ RANDOM 500-1500, ROLLED ONCE FOR THE WHOLE TEAM. Rolling per player would hand one
	/// player 1500 and another 500 off the same pickup, which reads as a bug from both ends.
	///
	/// ⚠️ VARIANCE IS STILL THE POINT — a bonus that always paid the same would just be a
	/// slower Double Points.
	/// </summary>
	public static void BonusPoints( NZPlayer player, int amountOverride = -1 )
	{
		// ⚠️ A DROPPED ONE PAYS EXACTLY WHAT WAS PUT IN, TO THE COLLECTOR ONLY, and that is the
		// whole contract of the drop: the points are MOVED, not created. Rolling on a powerup
		// someone paid 1000 for would make dropping a way to destroy points, and paying the
		// whole team what one player dropped would make it a way to print them.
		//
		// ⛔ -1 IS "NOT A DROP", AND ONLY -1. `Powerup.Spawn` defaulted its override to 0, which this
		// line reads as a drop worth 0 — so a natural Bonus Points paid the collector nothing and
		// returned before the roll. The default is -1 now, matching `Powerup.PointsOverride`.
		if ( amountOverride >= 0 )
		{
			if ( !player.IsValid() ) return;
			player.AddPoints( amountOverride );
			Log.Info( $"[nz] BONUS POINTS — +{amountOverride} (dropped)" );
			return;
		}

		var scene = Game.ActiveScene;
		if ( scene is null ) return;

		var amount = Game.Random.Int( BonusPointsMin, BonusPointsMax );

		// ⛔ EVERY PLAYER, NOT `NZPlayer.Local` AND NOT THE COLLECTOR. This runs on ONE machine, the
		// collector's — `Powerup.Collect` applies it for the host's own pickup and `CollectRemote` for a
		// client's — so the local player here is one of the team, not the team.
		//
		// ⚠️ PAYING A PROXY IS SAFE BECAUSE `AddPoints` RELAYS, from a client as well as from the host.
		// It tests `PlayerPresence.Theirs` and forwards through `NZNet.AwardPoints`, a broadcast only
		// the body's owner acts on. Without that this loop would raise a number on four copies and
		// pay one person.
		var paid = 0;
		foreach ( var p in scene.GetAllComponents<NZPlayer>() )
		{
			if ( !p.IsValid() ) continue;
			p.AddPoints( amount );
			paid++;
		}

		Log.Info( $"[nz] BONUS POINTS — +{amount} to {paid} player(s)" );
	}

	/// <summary>
	/// `nz_bonuspoints [min] [max]` — the roll, and a live payout to prove it.
	///
	/// ⚠️ PAYING IS THE TEST. "It is not giving points" was the original report, and a command
	/// that only printed the range could not have distinguished a wrong number from a broken
	/// award path.
	/// </summary>
	[ConCmd( "nz_bonuspoints" )]
	public static void BonusPointsCmd( int min = -1, int max = -1 )
	{
		if ( min >= 0 ) BonusPointsMin = min;
		if ( max >= 0 ) BonusPointsMax = max;
		if ( BonusPointsMax < BonusPointsMin ) BonusPointsMax = BonusPointsMin;

		Log.Info( $"[nz] Bonus Points rolls {BonusPointsMin}-{BonusPointsMax}, paid to every player" );
		BonusPoints( null );
	}

	/// <summary>
	/// Every barricade back to full boards.
	///
	/// ⚠️ Pays 200 ONCE, not per barricade. The original awards a flat 200 however
	/// many boards went back up — paying per plank would make Carpenter worth more on
	/// a map you had let fall apart, which rewards playing badly.
	/// </summary>
	public static void Carpenter( NZPlayer player )
	{
		var scene = Game.ActiveScene;
		if ( scene is null ) return;

		int fixedUp = 0;

		// ⛔ THE BOARDS GO BACK ON ONCE, ON THE HOST. This method now runs on EVERY machine so
		// that everyone gets paid — see `IsTeamEffect` — and barricade state is the host's. A client
		// calling `SetBoards` would be writing a value the next replication overwrites, and doing it
		// on four machines is four writes for one outcome.
		if ( !Networking.IsActive || NZGame.IsHost )
		{
			foreach ( var b in scene.GetAllComponents<Barricade>() )
			{
				if ( !b.IsValid() || b.IsFull ) continue;

				b.SetBoards( Barricade.MaxPlanks );
				fixedUp++;
			}
		}

		// ⚠️ PAID TO THIS MACHINE'S OWN PLAYER, whoever collected it. `player` is `NZPlayer.Local`
		// on every machine for a team effect, so each person is paid once, locally, with their own
		// perks and augments in the calculation — the same reasoning `Powerup.Collect` gives for
		// deferring a remote collector's payout rather than paying it on the host.
		if ( player.IsValid() ) player.AddPoints( CarpenterPoints );

		Log.Info( $"[nz] CARPENTER — {fixedUp} barricade(s) reboarded, +{CarpenterPoints}"
			+ " to each player" );
	}

	/// <summary>
	/// Kill everything on the map that a nuke is allowed to kill.
	///
	/// ⛔ IT NO LONGER KILLS EVERYTHING, AND `ZombieVariant.NukeDamageFraction` DECIDES WHO. The
	/// horde, the hellhound and the pest die as they always did; the napalm, the shrieker and
	/// Brutus take 30% of their maximum health, and Oberon is untouched. The rule is authored per
	/// variant rather than listed here — see the property for why no flag we already had could
	/// express that set.
	///
	/// ⛔ PAYS A FLAT 400, NOT PER ZOMBIE. The original is explicit about this
	/// (`GivePoints(400)`), and it is what stops a Nuke on a full round being worth
	/// more than the round itself.
	///
	/// ⚠️ Damage rather than a silent despawn, so the death path runs — ragdolls,
	/// death sounds and the round's alive-count all hang off it. Removing them
	/// outright would leave the wave believing they were still coming.
	/// </summary>
	public static void Nuke( NZPlayer player )
	{
		var scene = Game.ActiveScene;
		if ( scene is null ) return;

		int killed = 0, hurt = 0, spared = 0;

		// ⛔ THE ZOMBIES DIE ONCE, ON THE HOST. This method now runs on EVERY machine so that
		// everyone gets paid — see `IsTeamEffect` — and without this guard each client would run the
		// sweep too. That is not harmless: `Health.OnDamage` RELAYS a client's damage to the host
		// rather than refusing it, so a four-player nuke would send four full sets of kill messages
		// for one set of zombies, and every kill would be attributed on four machines.
		//
		// ⚠️ Materialised with ToList FIRST — killing a zombie mutates the scene's
		// component list, and iterating it live while it changes throws.
		if ( !Networking.IsActive || NZGame.IsHost )
		{
			foreach ( var z in scene.GetAllComponents<ZombieAI>().ToList() )
			{
				if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;

				var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
				if ( !hp.IsValid() ) continue;

				var share = NukeShare( z.Variant );

				// ⛔ IMMUNE MEANS NOTHING HAPPENS, NOT THAT ZERO DAMAGE IS DEALT. Oberon is
				// authored at 0 and was asked for as *"do nothing to oberon"* — so no flinch, no
				// hit sound, and no `OnDamaged` for anything downstream to react to.
				if ( share <= 0f ) { spared++; continue; }

				if ( share >= 1f )
				{
					hp.OnDamage( new DamageInfo
					{
						Damage = hp.Max * 10f,
						Position = z.WorldPosition,
					} );

					killed++;
					continue;
				}

				// ⛔ `Apply` RATHER THAN `OnDamage`, AND ON BRUTUS THAT IS THE DIFFERENCE BETWEEN
				// 30% AND 4.5%. `OnDamage` runs `BrutusHelmet.ScaleOn`, whose body scale is 0.15:
				// a helmeted boss reads an untagged body hit as a fifteenth of itself, which is
				// right for a bullet and wrong for a bomb going off over the whole map. `Apply` is
				// the raw sink both routes end in — `OnDamage` finishes by calling it — so the
				// death path, the ragdoll, the drops and the round's alive-count all still run.
				//
				// ⚠️ NO ATTACKER, WHICH IS THE SAME null THE KILL BRANCH PASSES. So no damage
				// points and no kill credit: a nuke that paid per-hit on a boss's health bar would
				// be a farm sitting on top of the flat 400.
				hp.Apply( hp.Max * share, false, null );

				if ( hp.IsDead ) killed++;
				else hurt++;
			}
		}

		// ⚠️ PAID LOCALLY, ONCE PER PLAYER. See the note on Carpenter's own payout.
		if ( player.IsValid() ) player.AddPoints( NukePoints );

		Log.Info( $"[nz] NUKE — {killed} zombie(s) killed, {hurt} hurt, {spared} immune,"
			+ $" +{NukePoints} to each player" );
	}

	/// <summary>
	/// What a Nuke does to one variant: the share of its MAX health it takes off.
	/// 1 = killed outright, 0 = untouched. `nz_nuke_resist` prints the whole table.
	/// </summary>
	///
	/// ⚠️ A NULL VARIANT IS THE WALKER, AND THE WALKER DIES. Almost every zombie in the game
	/// carries no variant at all, so the default has to be the killing one — a 0 default would
	/// have made the power-up do nothing to the horde it exists for.
	public static float NukeShare( ZombieVariant variant )
		=> MathX.Clamp( variant?.NukeDamageFraction ?? 1f, 0f, 1f );

	/// <summary>
	/// `nz_nuke_resist [variant] [share]` — who survives a nuke, and a knob to try another answer.
	/// </summary>
	///
	/// ⚠️ IT READS THE ASSETS RATHER THAN A LIST IN THIS FILE. A printed table written out here
	/// would agree with the game exactly until the first time a `.zvar` changed, and then be the
	/// most convincing wrong answer in the project.
	///
	/// ⚠️ THE WRITE IS THIS SESSION ONLY AND SAYS SO. It sets the property on the LOADED resource,
	/// which is the object the sweep reads, so a value tried here is live on the next nuke and
	/// gone on the next hotload. The keeper goes in the `.zvar`.
	[ConCmd( "nz_nuke_resist" )]
	public static void NukeResistCmd( string variant = "", float share = -1f )
	{
		if ( !string.IsNullOrWhiteSpace( variant ) )
		{
			var v = SpecialEnemies.VariantFor( variant );
			if ( v is null )
			{
				Log.Info( $"[nz] no special called '{variant}' —"
					+ $" try one of {string.Join( ", ", SpecialEnemies.Names )}" );
				return;
			}

			if ( share >= 0f )
			{
				v.NukeDamageFraction = MathX.Clamp( share, 0f, 1f );
				Log.Info( $"[nz] {variant} now takes {v.NukeDamageFraction:P0} of its max health"
					+ " from a nuke — THIS SESSION ONLY, the .zvar still says what it said" );
			}
		}

		// ⚠️ THE LABEL IS A VARIABLE because a string literal nested inside an interpolation hole
		// is a C# 11 feature and this project does not assume one.
		var walker = "(walker)";

		Log.Info( "[nz] NUKE — share of MAX health taken off:" );
		Log.Info( $"[nz]   {walker,-10} {NukeShare( null ),6:P0}  killed outright" );

		foreach ( var name in SpecialEnemies.Names )
		{
			var s = NukeShare( SpecialEnemies.VariantFor( name ) );
			var says = s <= 0f ? "untouched"
				: s >= 1f ? "killed outright"
				: $"{System.MathF.Ceiling( 1f / s ):0} nukes from full health";

			Log.Info( $"[nz]   {name,-10} {s,6:P0}  {says}" );
		}
	}

	// ── timed effects, read live ─────────────────────────────────────────────

	/// <summary>Points multiplier — 2 while Double Points runs, else 1.</summary>
	public static int PointsMultiplier
		=> ActivePowerups.IsActive( PowerupKind.DoublePoints ) ? 2 : 1;

	/// <summary>Is Insta-Kill running?</summary>
	public static bool InstaKill => ActivePowerups.IsActive( PowerupKind.InstaKill );

	/// <summary>
	/// What Insta-Kill does to one variant: null = killed by any hit, otherwise the damage multiplier
	/// it takes instead. `nz_instakill_resist` prints the table.
	/// </summary>
	///
	/// ⚠️ A NULL VARIANT IS THE WALKER, AND THE WALKER DIES — the same default `NukeShare` needs, for
	/// the same reason: almost every zombie in the game carries no variant at all.
	public static float? InstaKillScale( ZombieVariant variant )
		=> variant?.InstaKillMultiplier is float m ? System.MathF.Max( 0f, m ) : null;

	/// <summary>
	/// `nz_instakill_resist [variant] [multiplier]` — who survives Insta-Kill, and by how much.
	/// </summary>
	///
	/// ⚠️ A MULTIPLIER OF 0 OR BELOW CLEARS IT, putting the variant back on "killed outright". There
	/// is no other way to type "empty" into a console argument, and a variant that took zero damage
	/// under Insta-Kill would be a stranger rule than either of the two the game actually has.
	///
	/// ⚠️ THE WRITE IS THIS SESSION ONLY, like `nz_nuke_resist`'s: it sets the loaded resource, which
	/// is what `Health.OnDamage` reads, and the keeper goes in the `.zvar`.
	[ConCmd( "nz_instakill_resist" )]
	public static void InstaKillResistCmd( string variant = "", float multiplier = float.NaN )
	{
		if ( !string.IsNullOrWhiteSpace( variant ) )
		{
			var v = SpecialEnemies.VariantFor( variant );
			if ( v is null )
			{
				Log.Info( $"[nz] no special called '{variant}' —"
					+ $" try one of {string.Join( ", ", SpecialEnemies.Names )}" );
				return;
			}

			if ( !float.IsNaN( multiplier ) )
			{
				v.InstaKillMultiplier = multiplier > 0f ? multiplier : null;
				Log.Info( $"[nz] {variant} under Insta-Kill: "
					+ (v.InstaKillMultiplier is float m ? $"{m:0.##}x damage" : "killed outright")
					+ " — THIS SESSION ONLY, the .zvar still says what it said" );
			}
		}

		var walker = "(walker)";

		Log.Info( "[nz] INSTA-KILL — what a hit does while it runs:" );
		Log.Info( $"[nz]   {walker,-10} killed outright" );

		foreach ( var name in SpecialEnemies.Names )
		{
			var s = InstaKillScale( SpecialEnemies.VariantFor( name ) );
			Log.Info( $"[nz]   {name,-10} {(s is float m ? $"{m:0.##}x damage, not killed" : "killed outright")}" );
		}

		Log.Info( $"[nz] Insta-Kill is {(InstaKill ? "RUNNING" : "not running")}"
			+ " — nz_powerup instakill to drop one" );
	}

	/// <summary>
	/// Is a Fire Sale on? Three separate things read this.
	///
	/// ⛔ THIS COMMENT USED TO SAY ONLY THE PRICE WAS WIRED, and that the project "has one box that
	/// relocates rather than a set of fixed spots, so all boxes open has nothing yet to mean". The
	/// second half was already untrue when it was written — MysteryBoxSpot is a LIST in the config
	/// and MysteryBoxManager picks one to start at — so the note read as a design limitation when it
	/// was really an unfinished feature. All three behaviours are now wired:
	///
	///   • PRICE — MysteryBox.Price returns FireSaleCost (10) instead of the spot's cost.
	///   • EVERY LOCATION — MysteryBoxManager builds a box at every spot for the duration.
	///   • NO BEAR — MysteryBox.RollTeddy refuses, so a sale cannot end itself by moving the box.
	/// </summary>
	public static bool FireSale => ActivePowerups.IsActive( PowerupKind.FireSale );
}