Powerups/Powerup.cs

Powerup component and related utilities. Manages spawning, rendering (glow, spin, blink), lifetime, pickup logic, networked spawn/collect broadcasts, HUD/announcer cues, and admin console commands; also defines PowerupKind enum.

NetworkingFile Access
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// A powerup lying on the floor: the shell every one of them shares.
///
/// ⛔ NO EFFECTS HERE, DELIBERATELY. Spawn, float, spin, expire and collect are
/// identical for Max Ammo, Instakill, Double Points, Nuke and Carpenter — only what
/// happens ON COLLECT differs. Building the common half first means the effects are
/// each a single method rather than five copies of a lifetime.
///
/// Rules are the original's, from `entities/drop_powerup/shared.lua`:
///   - 30s on the floor  (`SetKillTime(CurTime() + 30)`)
///   - blinks the last 5s  (`SetBlinkTime(CurTime() + 25)`)
///   - a wobbling spin, NOT a constant yaw  (`Angle(2,50,5) * sin(CurTime()/10)`)
///   - a short delay before it can be taken  (`antipowerupdelay`, 1-4s)
/// </summary>
public sealed class Powerup : Component
{
	/// <summary>Which powerup this is. Nothing acts on it yet.</summary>
	[Property] public PowerupKind Kind { get; set; } = PowerupKind.MaxAmmo;

	/// <summary>Seconds on the floor before it disappears. The original's 30.</summary>
	[Property] public float Lifetime { get; set; } = 30f;

	/// <summary>
	/// Points this powerup pays, overriding the usual roll. Negative means "roll normally".
	///
	/// ⛔ ON THE INSTANCE, NOT PASSED AT PICKUP. A dropped powerup is worth what the dropper paid
	/// for it, and that number has to survive from the drop until whoever finds it walks over it —
	/// possibly a minute later, possibly a different player. Nothing at the pickup site knows it,
	/// so the powerup carries it.
	///
	/// ⚠️ Only BonusPoints reads it. Every other kind ignores it, which is why it is one loose
	/// field rather than a payload type: a Max Ammo with a points value would be a lie.
	/// </summary>
	[Property] public int PointsOverride { get; set; } = -1;

	/// <summary>
	/// Seconds of blinking before it goes.
	///
	/// ⚠️ FIVE, from the original's 25s blink against a 30s life. The blink is the
	/// only warning a player gets, and it is what turns a powerup from something you
	/// wander into to something you decide to run for.
	/// </summary>
	[Property] public float BlinkFor { get; set; } = 5f;

	/// <summary>
	/// How long before it can be picked up.
	///
	/// ⛔ NOT ZERO. The original delays every drop (`antipowerupdelay`, 4s, or a
	/// random 1-3) because a powerup spawns ON a zombie you are standing next to —
	/// without the delay you collect it before you have seen it, and a Max Ammo you
	/// never noticed is a Max Ammo you did not get to enjoy.
	/// </summary>
	[Property] public float ArmDelay { get; set; } = 1.5f;

	/// <summary>How close you must be to take it.</summary>
	[Property] public float PickupRadius { get; set; } = 48f;

	/// <summary>
	/// Height above where it dropped.
	///
	/// ⚠️ 40, not 20. These models are authored with their origin at the BASE, so at
	/// 20 the box sits on the floor rather than hovering — it read as dropped litter
	/// instead of a pickup.
	/// </summary>
	[Property] public float Hover { get; set; } = 40f;

	/// <summary>
	/// The glow around it.
	///
	/// ⛔ A SPRITE, NOT A LIGHT. A `PointLight` small enough not to wash the room
	/// still throws colour onto the floor under it, and a large one lights the whole
	/// area — neither is what a powerup does. Described exactly: "a green ball of
	/// light with a very small radius, it englobes the powerup but does not illuminate
	/// the surroundings". An additive billboard IS only itself: it adds light to its
	/// own pixels and touches nothing else in the scene.
	/// </summary>
	[Property] public Color GlowColor { get; set; } = new( 0.25f, 1f, 0.3f, 1f );

	/// <summary>How big the glow is, in world units.</summary>
	[Property] public float GlowSize { get; set; } = 90f;

	SpriteRenderer _glow;

	TimeSince _alive;
	ModelRenderer _renderer;
	bool _taken;

	/// <summary>Has it been collected?</summary>
	public bool Taken => _taken;

	/// <summary>Seconds left before it expires.</summary>
	public float Remaining => System.MathF.Max( 0f, Lifetime - _alive );

	/// <summary>Is it in its final flashing seconds?</summary>
	public bool Blinking => Remaining <= BlinkFor;

	SoundHandle _hum;

	protected override void OnStart()
	{
		_alive = 0f;
		_renderer = Components.Get<ModelRenderer>( FindMode.EverythingInSelfAndDescendants );

		StartHum();
	}

	/// <summary>
	/// The ambient hum while it lies there.
	///
	/// ⛔ THE HANDLE IS KEPT, because this is the one sound in the game that MUST be
	/// stopped by hand. Everything else is a one-shot that ends on its own; a looping
	/// cue whose handle is dropped plays until the map unloads, and the powerup it
	/// belonged to expired thirty seconds ago. The mystery box jingle needed exactly
	/// this and for exactly this reason.
	/// </summary>
	/// ⚠️ `PlayAmbient`, NOT `Play` — it culls out of earshot. A looping cue started
	/// with the plain call is audible to everyone regardless of distance AND prints a
	/// line per repeat with the audio trace on, which buried every other cue in the
	/// console when Pack-a-Punch did it.
	void StartHum()
	{
		// ⛔ NEVER RESTART IT ONCE TAKEN. The re-establish runs from OnUpdate, and on
		// the frame a powerup is collected the hum is stopped and then immediately
		// started again by the same tick — audible as a blip of ambience AFTER the
		// pickup, and visible in the trace as `nz.powerup.loop (#2)` landing a line
		// below "collected".
		if ( _taken ) return;

		if ( !_hum.IsValid() )
			_hum = NZSound.PlayAmbient( NZSound.PowerupLoop, WorldPosition );
	}

	/// <summary>
	/// Stop the hum. Safe to call twice.
	///
	/// ⚠️ CALLED FROM BOTH EXITS — collected and expired. A stop on only one of them
	/// leaves the hum playing forever down whichever path was forgotten, and that is
	/// the path nobody tests.
	/// </summary>
	void StopHum()
	{
		_hum?.Stop();
		_hum = null;
	}

	protected override void OnDestroy() => StopHum();

	protected override void OnUpdate()
	{
		if ( _taken ) return;

		if ( _alive >= Lifetime )
		{
			Log.Info( $"[nz] powerup {Kind} expired" );
			GameObject.Destroy();
			return;
		}

		Spin();
		Blink();
		TryCollect();

		// ⚠️ RE-ESTABLISHED WHEN IT ENDS, the same way Pack-a-Punch keeps its hum
		// alive — the ambient is a finite clip, so "start it once" gives you one
		// play and then silence for the remaining 28 seconds.
		StartHum();

		// ⚠️ And it FOLLOWS. The powerup does not move today, but it is dropped by a
		// dying zombie, and putting one on a slope or a moving platform later would
		// otherwise leave the sound behind at the spawn point.
		if ( _hum.IsValid() ) _hum.Position = WorldPosition;
	}

	/// <summary>
	/// The wobble.
	///
	/// ⛔ NOT A CONSTANT YAW. The original turns by `Angle(2,50,5) * sin(CurTime()/10)`
	/// per frame — the sine makes the whole rotation speed up, slow, stop and REVERSE
	/// over about a minute, and the uneven axes make it tumble rather than turn on the
	/// spot. A flat spin reads as a pickup in any other game; this reads as theirs.
	/// </summary>
	void Spin()
	{
		var s = System.MathF.Sin( Time.Now / 10f ) * Time.Delta;
		WorldRotation *= Rotation.From( 2f * s, 50f * s, 5f * s );
	}

	/// <summary>
	/// Flash out the last seconds.
	///
	/// ⚠️ TOGGLES THE RENDERER, not the object. Disabling the GameObject would stop
	/// `OnUpdate` — the powerup would freeze on its first blink and never expire or be
	/// collectable again.
	/// </summary>
	void Blink()
	{
		if ( !_renderer.IsValid() ) return;

		if ( !Blinking )
		{
			_renderer.Enabled = true;
			if ( _glow.IsValid() ) _glow.Enabled = true;
			return;
		}

		// ⚠️ Accelerating: slow at five seconds out, frantic at the end. A constant
		// flash tells you it is leaving but not WHEN.
		var urgency = 1f - (Remaining / System.MathF.Max( BlinkFor, 0.01f ));
		var rate = MathX.Lerp( 4f, 14f, urgency );

		// ⚠️ THE GLOW BLINKS WITH THE MODEL. Leaving it lit would hang a green ball in
		// the air with nothing inside it on every off-frame, which reads as a separate
		// effect rather than as the powerup flashing.
		var on = System.MathF.Sin( Time.Now * rate ) > 0f;

		_renderer.Enabled = on;
		if ( _glow.IsValid() ) _glow.Enabled = on;
	}

	void TryCollect()
	{
		// ⛔ THE HOST DECIDES WHO PICKED IT UP. Every machine runs this component, and two
		// machines both deciding "collected" is two of every announcement, two banners, and a
		// timed powerup started twice from two different clocks. The host collects and tells
		// everybody; a client's copy is scenery until then.
		if ( NZGame.IsClient ) return;

		// ⚠️ Armed only after the delay — see ArmDelay.
		if ( _alive < ArmDelay ) return;

		foreach ( var player in Scene.GetAllComponents<NZPlayer>() )
		{
			if ( !player.IsValid() ) continue;

			// ⚠️ Measured to the player's MIDDLE. Their origin is at the feet and the
			// powerup hovers at knee height, so a foot-to-powerup distance makes the
			// radius feel about a third smaller than it is.
			var mid = player.WorldPosition + Vector3.Up * 32f;
			if ( mid.Distance( WorldPosition ) > PickupRadius ) continue;

			Collect( player );
			return;
		}
	}

	/// <summary>
	/// Taken.
	///
	/// ⚠️ THE EFFECT GOES HERE AND NOWHERE ELSE — this is the seam the whole shell
	/// exists to provide. Right now it only announces and vanishes.
	/// </summary>
	/// <summary>
	/// The three sounds a powerup makes when it is taken.
	///
	/// ⛔ ONE AUTHOR FOR TWO CALLERS, AND THE SECOND ONE IS WHY THIS IS A METHOD. `Collect` runs
	/// on the machine that picked it up; `CollectRemote` runs on all the others — and only the
	/// first of them had the cues, so everyone else got a silent banner.
	///
	/// ⛔ THE PICKUP AND THE POWERUP'S OWN CUE ARE TWO SOUNDS, LAYERED. The pickup is the
	/// physical grab and is the same for all of them; the second is WHICH powerup you got, and it
	/// is the one that carries the information. Replacing the grab with it would lose the tactile
	/// half. The third is the announcer saying it out loud.
	///
	/// ⚠️ A NULL POSITION MEANS "AT THE LISTENER", for a machine whose copy of the powerup has
	/// already been cleaned up when the message lands.
	/// </summary>
	static void PlayCues( PowerupKind kind, Vector3? at )
	{
		void Say( string cue )
		{
			if ( string.IsNullOrEmpty( cue ) ) return;

			if ( at.HasValue ) NZSound.Play( cue, at.Value );
			else NZSound.Play( cue );
		}

		Say( NZSound.PowerupPickup );
		Say( CueFor( kind ) );
		Say( AnnouncerFor( kind ) );
	}

	/// <summary>
	/// Drop a powerup from code that may be running on ANY machine.
	///
	/// ⛔ `Spawn` ONLY ANNOUNCES ITSELF FROM THE HOST, so a client calling it directly creates an
	/// object that exists on one screen, cannot be picked up by anybody else, and never despawns
	/// for them. That is exactly the bug the user hit with a bonus-points drop — *"if a client
	/// spawns a bonus point using 5 it cannot be picked up and the host cannot see it"* — and
	/// `NZNet.PointsDropAsk` was the one-off fix for that one caller.
	///
	/// ⚠️ IT BECAME GENERAL THE MOMENT A SECOND CALLER COULD RUN ON A CLIENT. Widow's Wine's M4
	/// Spider's Gift drops a powerup on a KILL, and kill augments now run on the killer's own
	/// machine — so the moment that was fixed, a client's spider became invisible to everyone else.
	/// One more one-off would have been the third answer to one question.
	///
	/// ⚠️ RETURNS NOTHING ON A CLIENT, AND CALLERS MUST NOT READ THAT AS FAILURE. The drop is
	/// real; it simply arrives a moment later through `PowerupDropped`, like every other powerup.
	/// </summary>
	public static void SpawnShared( Vector3 pos, PowerupKind kind, int pointsOverride = -1 )
	{
		if ( Networking.IsActive && NZGame.IsClient )
		{
			NZNet.PowerupSpawnAsk( pos, (int)kind, pointsOverride );
			return;
		}

		Spawn( pos, kind, pointsOverride );
	}

	public void Collect( NZPlayer player )
	{
		if ( _taken ) return;
		_taken = true;

		StopHum();

		PlayCues( Kind, WorldPosition );

		PowerupBannerState.Show( PowerupBannerState.NameFor( Kind ) );

		// ⚠️ EVERY MACHINE GETS THE BANNER, THE CUES AND THE TIMER — a powerup in this game is a
		// TEAM event, not a personal one. Sent from the host's collect, and a client reaches this
		// method only through `CollectRemote`, which does not announce again.
		//
		// ⚠️ AND WHO COLLECTED IT TRAVELS, because the INSTANT half is not a team event. Bonus
		// Points pays one player; without this every machine paid its own, so a client's own drop
		// paid it twice — once relayed from the host's `AddPoints`, once again locally.
		// User: *"if a client spawns a bonus points and picks it up they pick up 2000 instead of
		// 1000."*
		if ( Networking.IsActive && NZGame.IsHost )
			NZNet.PowerupTaken( NetId, (int)Kind, PointsOverride,
				Guid.TryParse( NZPlayers.OwnerOf( player.GameObject ), out var g ) ? g
					: Connection.Local?.Id ?? default );

		// ⚠️ Starts the clock for the TIMED kinds only — `Activate` no-ops on the
		// instant ones, so there is no condition to keep in sync here.
		ActivePowerups.Activate( Kind );

		// ⚠️ The instant effects fire here; the timed ones do nothing at pickup and
		// are read live from the registry by whatever they affect.
		//
		// ⛔ BUT ONLY WHEN THE COLLECTOR IS *THIS* MACHINE'S PLAYER, AND THAT IS WHY THE DOUBLE
		// PAYOUT SURVIVED THE LAST FIX. The host collects on behalf of a client's body, and
		// `PowerupEffects.Apply` → `AddPoints` → **relays the award to the owner**. Then the
		// broadcast lands and the owner's own `CollectRemote` applies it a second time. Moving the
		// payout to the collector's machine, as the last build did, did not remove the double — it
		// only changed which of the two machines was paying twice.
		// User: *"client's point drop by pressing 5 still awards double the amount."*
		//
		// ⚠️ THE REMOTE COLLECTOR IS NOT SKIPPED, IT IS DEFERRED. `NZNet.PowerupTaken` carries the
		// collector and the payout, and their own machine applies it — with their own perks and
		// their own augments in the calculation, which the host's copy does not have.
		// ⛔ MAX AMMO IS A TEAM EFFECT AND THIS BRANCH DID NOT KNOW IT — SO THE HOST WENT
		// WITHOUT WHENEVER A CLIENT PICKED ONE UP. Reported as *"max ammo só deu a uma pessoa"*.
		//
		// `CollectRemote` has carried the exception since it was written; this path never got a
		// copy, and the two halves only disagree in one direction:
		//
		//   host collects    -> Mine is true, host refills here     · client refills via the
		//                       broadcast                             -> BOTH, looks correct
		//   client collects  -> pickup is host-authoritative, so `player` is the CLIENT'S body
		//                       on the host and `Mine` is false     · the host refills NOBODY
		//                       and cannot recover, because
		//                       `NZNet.PowerupTaken` opens with
		//                       `if ( NZGame.IsHost ) return`       -> only the client
		//
		// Which is why it reads as intermittent rather than broken: it depends on who walked
		// over it.
		//
		// ⚠️ `NZPlayer.Local`, NOT `player`, AND THAT IS THE WHOLE POINT. `player` is whoever
		// collected it — possibly someone else's body being driven by the host. Every machine
		// refills the person sitting at it, exactly as `CollectRemote` does.
		//
		// ⚠️ NO DOUBLE-APPLY IS POSSIBLE HERE, unlike the payout this branch's own ⛔ block
		// above is about. The host early-returns from its own broadcast, so a machine reaches the
		// refill through exactly one of the two paths. And a refill is idempotent anyway: it
		// assigns `MaxReserve` and a full clip rather than adding.
		if ( PowerupEffects.IsTeamEffect( Kind ) )
			PowerupEffects.Apply( Kind, NZPlayer.Local, PointsOverride );
		else if ( !Networking.IsActive || PlayerPresence.Mine( player.GameObject ) )
			PowerupEffects.Apply( Kind, player, PointsOverride );

		Log.Info( $"[nz] powerup {Kind} collected" );

		GameObject.Destroy();
	}

	// ── spawning ─────────────────────────────────────────────────────────────

	/// <summary>
	/// The cue that says WHICH powerup was taken.
	///
	/// ⚠️ Empty for the ones not yet imported, and `NZSound.Play` is skipped rather
	/// than asked for a missing cue — a failing sound lookup logs per call, and this
	/// one would fire on every pickup of every unfinished powerup.
	/// </summary>
	public static string CueFor( PowerupKind kind ) => kind switch
	{
		PowerupKind.MaxAmmo => NZSound.PowerupMaxAmmo,
		PowerupKind.Nuke => NZSound.PowerupNuke,
		// ⛔ NO PICKUP CUE — `firesale_jingle` is the LOOP that plays for the whole
		// 30 seconds (see PowerupMusic), not a one-shot. Firing it here as well would
		// start the tune twice, a beat apart.

		// ⚠️ EMPTY IS CORRECT for the rest, not an omission. Double Points, Bonus
		// Points and Carpenter have no pickup flux in the pack — what they own is a
		// LOOP that plays while the effect runs, which belongs to the effect and not
		// to the moment of collection.
		_ => "",
	};

	/// <summary>
	/// The announcer line for a powerup — the VOICE, separate from the effect cue.
	///
	/// ⚠️ THREE SOUNDS ON A PICKUP, not one: the grab (same for all), the effect's
	/// own sound, and the announcer naming it. The original layers all three, and
	/// each carries something the others do not — the grab is tactile, the flux says
	/// ammo arrived, the voice says WHICH powerup to the whole team.
	/// </summary>
	public static string AnnouncerFor( PowerupKind kind ) => kind switch
	{
		PowerupKind.MaxAmmo => NZSound.AnnouncerMaxAmmo,
		PowerupKind.DoublePoints => NZSound.AnnouncerDoublePoints,
		PowerupKind.BonusPoints => NZSound.AnnouncerBonusPoints,
		PowerupKind.Carpenter => NZSound.AnnouncerCarpenter,
		PowerupKind.Nuke => NZSound.AnnouncerNuke,
		PowerupKind.FireSale => NZSound.AnnouncerFireSale,

		// ⚠️ Insta-Kill's line comes from a DIFFERENT pack to the other five — see
		// NZSound.AnnouncerInstaKill.
		PowerupKind.InstaKill => NZSound.AnnouncerInstaKill,

		_ => "",
	};

	/// <summary>
	/// The 2D HUD icon for a powerup.
	///
	/// ⚠️ The BO1 set from `nz_moo/powerup_icons/bo1` — the game's own HUD art, not a
	/// render of the 3D model. The floor model and the icon are different assets on
	/// purpose: one is lit and tumbling in the world, the other has to read at 40px.
	/// </summary>
	public static string IconFor( PowerupKind kind ) => kind switch
	{
		// ⚠ REUSES MAX AMMO'S ICON AND MODEL AS A PLACEHOLDER. No spider art exists in the
		// asset system (checked: only `ui/perks/widowswine.png`), and an unresolvable path fails
		// at LOAD with a green compile — §18 — so it would ship as an invisible power-up. Swap
		// both when the art lands.
		PowerupKind.Spider => "materials/nz/powerups/maxammo.png",
		PowerupKind.MaxAmmo => "materials/nz/powerups/maxammo.png",
		PowerupKind.InstaKill => "materials/nz/powerups/insta.png",
		PowerupKind.DoublePoints => "materials/nz/powerups/dp.png",
		PowerupKind.BonusPoints => "materials/nz/powerups/bonuspoints.png",
		PowerupKind.Carpenter => "materials/nz/powerups/carpenter.png",
		PowerupKind.Nuke => "materials/nz/powerups/nuke.png",
		PowerupKind.FireSale => "materials/nz/powerups/firesale.png",
		_ => "",
	};

	/// <summary>The soft round flare the glow is drawn with.</summary>
	public const string GlowSprite = "sprites/nz/powerup_glow.sprite";

	/// <summary>Where each kind's model lives.</summary>
	public static string ModelFor( PowerupKind kind ) => kind switch
	{
		PowerupKind.Spider => "models/nz/powerups/maxammo.vmdl",
		PowerupKind.MaxAmmo => "models/nz/powerups/maxammo.vmdl",
		PowerupKind.InstaKill => "models/nz/powerups/insta.vmdl",
		PowerupKind.DoublePoints => "models/nz/powerups/2x.vmdl",
		PowerupKind.BonusPoints => "models/nz/powerups/bonus.vmdl",
		PowerupKind.Carpenter => "models/nz/powerups/carpenter.vmdl",
		PowerupKind.Nuke => "models/nz/powerups/nuke.vmdl",
		PowerupKind.FireSale => "models/nz/powerups/firesale.vmdl",

		// ⚠️ Falls back to the ammo can rather than returning null — an unported
		// powerup then LOOKS wrong on the floor instead of failing to spawn, which is
		// far easier to notice than a drop that silently does not happen.
		_ => "models/nz/powerups/maxammo.vmdl",
	};

	/// <summary>
	/// Drop one at a position.
	///
	/// ⚠️ LIFTED OFF THE FLOOR by <see cref="Hover"/>. A powerup dropped exactly where
	/// a zombie died sits half inside the ground on any sloped surface, and the models
	/// are authored with their origin at the base.
	/// </summary>
	/// <summary>
	/// Which drop this is, shared across machines.
	///
	/// ⛔ A GUID RATHER THAN A POSITION. Two powerups can drop on the same spot within a second
	/// of each other, and "the one near here" would then take the wrong one away. The host makes
	/// the id when it makes the drop and every machine's copy carries it.
	/// </summary>
	public Guid NetId { get; set; }

	/// <summary>Find a drop by its shared id, on any machine.</summary>
	public static Powerup ById( Guid id )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() || id == default ) return null;

		foreach ( var p in scene.GetAllComponents<Powerup>() )
			if ( p.IsValid() && p.NetId == id ) return p;

		return null;
	}

	/// <summary>
	/// The host collected this one. Clients only.
	///
	/// ⚠️ IT RUNS THE REAL `Collect` so the banner, the cues, the announcer and the timed
	/// registry all come from the one method that owns them — a hand-rolled subset here is how
	/// a client ends up with the sound and not the timer, or the timer and not the banner.
	///
	/// ⚠️ WITH THIS MACHINE'S OWN PLAYER as the collector. The instant effects — max ammo, the
	/// points bonus — are applied to whoever is passed in, and on a client that must be the
	/// person sitting at it. Max Ammo refills EVERYONE in nZombies, so each machine applying it
	/// to its own player is not an approximation; it is the rule.
	/// </summary>
	public static void CollectRemote( Guid id, PowerupKind kind, int pointsOverride, Guid collector )
	{
		var p = ById( id );

		if ( p.IsValid() )
		{
			p.StopHum();
			p._taken = true;
			p.GameObject.Destroy();
		}

		// ── the TEAM half: everybody, every time ──────────────────────────────────────────
		//
		// ⚠️ THE BANNER, THE CUES AND THE TIMER ARE THE POWERUP HAPPENING, and a powerup happens
		// to the whole team. Insta-Kill and Double Points are read live out of `ActivePowerups`
		// by whatever they affect, so every machine has to start its own clock.
		PowerupBannerState.Show( PowerupBannerState.NameFor( kind ) );
		ActivePowerups.Activate( kind );

		// ⛔ THE CUES WERE NEVER PLAYED HERE, AND `Collect`'S OWN COMMENT SAYS THEY SHOULD BE:
		// *"EVERY MACHINE GETS THE BANNER, THE CUES AND THE TIMER — a powerup in this game is a
		// TEAM event."* Two of those three arrived. A client saw INSTA-KILL flash across the
		// screen in silence, which is the half of the announcement that carries least.
		// User: *"the anouncer for the powerups."*
		//
		// ⚠️ NOT THROUGH `WorldSound`. This method already runs on every machine — it is the
		// broadcast — so relaying again would be a second message for a sound already arriving.
		//
		// ⚠️ AT THE POWERUP IF IT IS STILL THERE, AT THE LISTENER IF IT IS NOT. The object is
		// destroyed a few lines above, and a message that arrives after it was already cleaned up
		// locally must still be heard rather than played at the world origin.
		var at = p.IsValid() ? p.WorldPosition : (Vector3?)null;

		PlayCues( kind, at );

		// ── the INSTANT half: the collector alone ─────────────────────────────────────────
		//
		// ⛔ `PowerupEffects.Apply` PAYS AND REFILLS THE PLAYER IT IS GIVEN, and it must be given
		// one player once. Running it on every machine for that machine's own player paid a
		// client's own drop twice — the host's `AddPoints` relays to the owner, and then the
		// owner's copy applied it again. Running it NOWHERE, for a natural drop whose points the
		// host had already relayed, is how the other half of the same bug looked from the client:
		// *"if a client picks up a naturally spawning bonus points they earn nothing."*
		//
		// ⚠️ MAX AMMO IS THE EXCEPTION AND IT IS DELIBERATE. It refills EVERYONE in nZombies, so
		// it is a team effect wearing an instant effect's clothes — applied here on every machine,
		// to that machine's own player.
		if ( PowerupEffects.IsTeamEffect( kind ) )
		{
			PowerupEffects.Apply( kind, NZPlayer.Local, 0 );
			return;
		}

		if ( Connection.Local is not null && Connection.Local.Id == collector )
			PowerupEffects.Apply( kind, NZPlayer.Local, pointsOverride );
	}

	/// <summary>
	/// The host says a powerup dropped. Clients only — build the same scenery, and nothing else.
	///
	/// ⚠️ IT GOES THROUGH THE SAME `Spawn` the host used, so a client's copy hovers, tumbles and
	/// glows identically rather than being a second, simpler thing that has to be kept in step.
	/// </summary>
	public static void SpawnRemote( Guid id, Vector3 pos, PowerupKind kind, int pointsOverride = -1 )
	{
		if ( ById( id ).IsValid() ) return;

		var p = Spawn( pos, kind, pointsOverride );
		if ( p.IsValid() ) p.NetId = id;
	}

	/// <param name="pointsOverride">
	/// What a Bonus Points drop is worth, or -1 for none: a natural drop, which rolls for the team.
	///
	/// ⛔ -1, NOT 0, AND 0 WAS THE BUG. This defaulted to 0 while `PointsOverride` and
	/// `PowerupEffects.BonusPoints` both read "0 or more" as a player's own drop — so every natural Bonus
	/// Points paid its collector exactly 0 and nobody else anything. User: *"bonus points is not giving
	/// points to any player."* (2026-09-27)
	///
	/// ⛔ AN ARGUMENT RATHER THAN A FIELD SET AFTERWARDS. `Spawn` announces the drop to every
	/// machine, and it does that before returning — so a caller assigning `PointsOverride` on the
	/// way out is assigning it AFTER the message has gone, and every other machine's copy is worth
	/// the default instead of what the player paid.
	/// </param>
	public static Powerup Spawn( Vector3 pos, PowerupKind kind = PowerupKind.MaxAmmo, int pointsOverride = -1 )
	{
		var path = ModelFor( kind );
		var model = Model.Load( path );

		if ( model is null || model.IsError )
		{
			Log.Warning( $"[nz] powerup model '{path}' missing — not spawning" );
			return null;
		}

		var go = new GameObject( true, $"powerup_{kind}" );
		go.NetworkMode = NetworkMode.Never;

		var r = go.Components.Create<ModelRenderer>();
		r.Model = model;

		var p = go.Components.Create<Powerup>();
		p.Kind = kind;
		p.PointsOverride = pointsOverride;

		// ⚠️ THE HOST GIVES IT AN IDENTITY AND TELLS EVERYONE. Clients reach this same method
		// through `SpawnRemote`, which sets the id itself and must not announce it again.
		//
		// ⚠️ AFTER `PointsOverride`, so what the message carries is what this drop is actually
		// worth. A caller setting it on the returned object would be a frame and a network message
		// too late.
		if ( Networking.IsActive && NZGame.IsHost )
		{
			p.NetId = Guid.NewGuid();
			NZNet.PowerupDropped( p.NetId, pos, (int)kind, pointsOverride );
		}

		// ⚠️ AFTER the component exists, so it uses the component's own `Hover`
		// rather than a literal that would silently disagree with it.
		go.WorldPosition = pos + Vector3.Up * p.Hover;

		// ⛔ THE GLOW IS A CHILD, NOT A COMPONENT ON THE POWERUP ITSELF. The powerup
		// TUMBLES — a billboard on the same object inherits that rotation, and a
		// billboard fighting a spin flickers as the two resolve against each other.
		// A child that never rotates just sits there glowing.
		var glowGO = new GameObject( true, "glow" );
		glowGO.SetParent( go, false );
		glowGO.LocalPosition = Vector3.Zero;

		var glow = glowGO.Components.Create<SpriteRenderer>();
		var sprite = ResourceLibrary.Get<Sprite>( GlowSprite );

		if ( sprite is not null ) glow.Sprite = sprite;

		glow.Size = new Vector2( p.GlowSize, p.GlowSize );
		glow.Color = p.GlowColor;
		glow.Additive = true;

		// ⚠️ Unlit and shadowless — it is emissive by definition, and a glow that
		// takes the room's lighting goes dim in exactly the dark corners where a
		// powerup most needs to be spotted.
		glow.Lighting = false;
		glow.Shadows = false;

		// ⚠️ Softens where the sprite meets the floor, so it does not cut a hard
		// circle into the ground.
		glow.DepthFeather = 8f;

		p._glow = glow;

		Log.Info( $"[nz] powerup {kind} dropped at {go.WorldPosition} — "
			+ $"{p.Lifetime:0}s, armed in {p.ArmDelay:0.#}s" );

		return p;
	}

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

	/// <summary>
	/// Drop one in front of you: `nz_powerup [kind] [distance]`.
	///
	/// ⚠️ THE DISTANCE EXISTS TO TEST PICKUP. The default 80u puts it outside the 48u
	/// radius so you can see it before you take it — which also means the command
	/// cannot verify collection on its own. `nz_powerup maxammo 0` drops it on your
	/// feet and it is taken the moment the arm delay elapses.
	/// </summary>
	[ConCmd( "nz_powerup" )]
	public static void Cmd( string kind = "maxammo", float distance = 80f )
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var controller = player.Components.Get<PlayerController>();
		var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
		var fwd = (controller?.EyeAngles.ToRotation() ?? player.WorldRotation).Forward;

		// Dropped a little ahead and traced down, so it lands ON the floor rather
		// than hanging wherever the eye happened to be.
		var from = eye + fwd * distance;
		var tr = Game.ActiveScene.Trace.Ray( from, from + Vector3.Down * 200f )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();

		var at = tr.Hit ? tr.HitPosition : from;

		if ( !TryParseKind( kind, out var k ) )
		{
			// ⛔ WARN, DO NOT FALL BACK SILENTLY. This used to default to MaxAmmo on
			// any unrecognised name, so a typo spawned the wrong powerup and read as
			// "the command ignores its argument" — the failure looked like the
			// feature was broken rather than the input.
			Log.Warning( $"[nz] no powerup called '{kind}'. Try: {KindNames()}" );
			return;
		}

		Spawn( at, k );
	}

	/// <summary>
	/// Resolve a name to a kind, accepting the short forms people actually type.
	///
	/// ⚠️ The enum names are the long forms (`DoublePoints`), but everyone says "dp"
	/// and "2x" — the original's own data calls it `dp` and its model is `2x.mdl`. A
	/// command that only accepts the C# spelling makes you look the enum up.
	/// </summary>
	public static bool TryParseKind( string name, out PowerupKind kind )
	{
		kind = PowerupKind.MaxAmmo;
		if ( string.IsNullOrWhiteSpace( name ) ) return false;

		switch ( name.Trim().ToLowerInvariant() )
		{
			case "maxammo": case "max": case "ammo":
				kind = PowerupKind.MaxAmmo; return true;

			case "instakill": case "insta": case "ik":
				kind = PowerupKind.InstaKill; return true;

			case "doublepoints": case "dp": case "2x": case "double":
				kind = PowerupKind.DoublePoints; return true;

			case "bonuspoints": case "bonus": case "points":
				kind = PowerupKind.BonusPoints; return true;

			case "carpenter": case "carp":
				kind = PowerupKind.Carpenter; return true;

			case "nuke": case "bomb":
				kind = PowerupKind.Nuke; return true;

			case "firesale": case "sale": case "fire":
				kind = PowerupKind.FireSale; return true;
		}

		// Anything else still resolves if it matches an enum name exactly-ish, so
		// the unported kinds (FireSale, DeathMachine) remain spawnable for testing.
		return System.Enum.TryParse( name, true, out kind );
	}

	/// <summary>The names `nz_powerup` accepts, for the error message.</summary>
	public static string KindNames()
		=> "maxammo, instakill, doublepoints, bonuspoints, carpenter, nuke, firesale";

	/// <summary>
	/// One of each, in a row: `nz_powerup_all`.
	///
	/// ⚠️ SPREAD ALONG A LINE rather than stacked on one spot — the whole point is
	/// comparing the models and their glows side by side, and six overlapping pickups
	/// is one pickup you cannot see.
	///
	/// ⚠️ Arm delay aside, they are collectable: walk the line and every announcer
	/// fires in turn, which is the fastest way to check the audio set.
	/// </summary>
	[ConCmd( "nz_powerup_all" )]
	public static void All()
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var controller = player.Components.Get<PlayerController>();
		var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
		var rot = controller?.EyeAngles.ToRotation() ?? player.WorldRotation;

		var kinds = new[]
		{
			PowerupKind.MaxAmmo, PowerupKind.InstaKill, PowerupKind.DoublePoints,
			PowerupKind.BonusPoints, PowerupKind.Carpenter, PowerupKind.Nuke,
			PowerupKind.FireSale,
		};

		// Centred on the player's facing, 90u apart.
		for ( int i = 0; i < kinds.Length; i++ )
		{
			var offset = (i - (kinds.Length - 1) / 2f) * 90f;
			var from = eye + rot.Forward * 160f + rot.Right * offset;

			var tr = Game.ActiveScene.Trace.Ray( from, from + Vector3.Down * 300f )
				.IgnoreGameObjectHierarchy( player.GameObject )
				.Run();

			Spawn( tr.Hit ? tr.HitPosition : from, kinds[i] );
		}

		Log.Info( $"[nz] spawned {kinds.Length} powerups in a row" );
	}

	/// <summary>What is on the floor: `nz_powerups`.</summary>
	[ConCmd( "nz_powerups" )]
	public static void List()
	{
		var all = Game.ActiveScene?.GetAllComponents<Powerup>().ToList();

		if ( all is null || all.Count == 0 )
		{
			Log.Info( "[nz] no powerups on the floor" );
			return;
		}

		foreach ( var p in all )
			Log.Info( $"[nz]   {p.Kind,-14} {p.Remaining:0.0}s left"
				+ (p.Blinking ? "  BLINKING" : "")
				+ $"  at {p.WorldPosition}" );
	}
}

/// <summary>
/// Every powerup the original has. ⚠️ NAMED NOW, WIRED LATER — the shell does not
/// care which one it is, and having the list present keeps `ModelFor` and the drop
/// tables honest about what is still missing.
/// </summary>
public enum PowerupKind
{
	MaxAmmo,
	InstaKill,
	DoublePoints,
	BonusPoints,
	Nuke,
	Carpenter,

	FireSale,

	// ⚠️ Named but NOT imported — no model, no cue, no effect. Resolves to the
	// fallback can and a silent pickup. Listed so the drop table and `ModelFor` stay
	// honest about what is still missing.
	DeathMachine,

	/// <summary>
	/// Widow's Wine M4 — refills one grenade.
	///
	/// ⛔ NOT IN THE RANDOM DROP TABLE, deliberately. `PowerupDrops` rolls the seven classic
	/// power-ups on any kill; this one is rolled separately by `WidowAugments` and only for a
	/// player holding the augment. Adding it to that table would hand it to everybody.
	/// </summary>
	Spider,

}