Weapons/Fireworks.cs

A game component that implements the Fireworks ammo mod. It spawns a floating visual copy (SkinnedModelRenderer) above a hit zombie, waits, then periodically picks nearby zombies and applies damage on behalf of the owner while playing whistle/pop sounds and optional tracers, with configurable tunables and upgrade rules.

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

namespace NZombies;

/// <summary>
/// Fire Works — the ammo mod that spawns a copy of your gun in mid-air and lets it shoot for you.
///
/// It rises 36 units from the zombie you hit, waits half a second, then picks off one zombie at a
/// time within 200 units until its clip or its kill budget runs out. It tumbles as it fires and
/// whistles and pops the whole time.
///
/// ⚠️ THE UPSTREAM VERSION HAS TWO CODE PATHS AND WE ARE PORTING THE SECOND. On TFA weapons it
/// spawns a real clone weapon and calls `PrimaryAttack()` on it — the comment there admits *"i
/// literally have no idea why this works"*. On any other base (ArcCW, ARC9) there is no clone gun
/// and the effect drives itself off a timer, damaging one zombie per shot directly. Our weapons are
/// SWB, so the self-driven path is the honest port: a floating model plus a timer, not a live
/// second weapon.
///
/// ⚠️ SO THE MODEL IS A `SkinnedModelRenderer`, NOT A WEAPON. It has no ammo, no fire logic and no
/// owner — it is scenery that happens to look like your gun. Spawning an actual `Weapon` would give
/// it an inventory slot, a viewmodel and a reload, none of which it should have.
///
/// ⛔ NO PARTICLES, AND THAT IS THE WHOLE REASON THIS MOD WENT FIRST. Every other ammo mod needs
/// VFX we do not have; Fire Works needs a model we already own, two sounds, and a timer.
///
/// ⚠️ THE UPGRADES (2026-10-05, `Sbox nzombies/Docs/AMMO_MODS.md` "Upgrades"), by the OWNER'S level: I Longer Volley, 50
/// hits (24) with the clip floor raised to match; II Long Reach, 350u (200); III Twin Fire, two copies side by side, each
/// with I and II. The user: *"Make upgrade one 50 shots / Upgrade 2 as you stated / And upgrade 3 as you stated"*.
///
/// ⚠️ IV AND V (2026-10-06, "Tiers IV and V"): IV Black Powder, each copy shot deals 200% of your damage (100%); V Headliner,
/// every copy shot is a headshot, everywhere one is decided. The user: *"for V i'd do the hits count as headshots"*. With both, a
/// copy shot is 500% of your damage before perks.
/// </summary>
public sealed class Fireworks : Component
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS, as everywhere else: a static's VALUE survives a hotload but its
	// initialiser does not re-run, so `= 200f` is not what a live session holds. §1.

	static bool? _tracers;
	/// <summary>
	/// Draw a coloured tracer per shot. On.
	///
	/// ⚠️ A SWITCH BECAUSE IT IS THE ONLY VISUAL. If the tracers ever read wrong in play,
	/// `nz_fireworks_set tracers 0` gets back to the model-and-sound version without a rebuild
	/// — and tells us whether a complaint is about the tracers or about the effect itself.
	/// </summary>
	public static bool Tracers { get => _tracers ?? true; set => _tracers = value; }

	static float? _range;
	/// <summary>How far it can reach for a target. 200u, as upstream.</summary>
	public static float Range { get => _range ?? 200f; set => _range = value; }

	static float? _rise;
	/// <summary>How far it climbs from the zombie it spawned on. 36u, as upstream.</summary>
	public static float Rise { get => _rise ?? 36f; set => _rise = value; }

	static float? _armTime;
	/// <summary>Delay before it starts shooting. 0.5s, as upstream.</summary>
	public static float ArmTime { get => _armTime ?? 0.5f; set => _armTime = value; }

	static float? _lifetime;
	/// <summary>Hard cap on how long it can exist. 40s, as upstream's delayed remove.</summary>
	public static float Lifetime { get => _lifetime ?? 40f; set => _lifetime = value; }

	static float? _rpm;
	/// <summary>
	/// Shots per minute. 400 floor, as upstream.
	///
	/// ⚠️ THE HELD WEAPON'S RATE WINS IF IT IS FASTER, clamped to 1200. A pistol firework that
	/// fires slower than the pistol would read as broken.
	/// </summary>
	public static float MinRpm { get => _rpm ?? 400f; set => _rpm = value; }

	static float? _maxRpm;
	/// <summary>Ceiling on the inherited rate. 1200, as upstream.</summary>
	public static float MaxRpm { get => _maxRpm ?? 1200f; set => _maxRpm = value; }

	static int? _clip;
	/// <summary>Shots before it stops. 20 floor, the held weapon's clip if bigger, capped 100.</summary>
	public static int MinClip { get => _clip ?? 20; set => _clip = value; }

	static int? _maxClip;
	/// <summary>Ceiling on the inherited clip. 100, as upstream.</summary>
	public static int MaxClip { get => _maxClip ?? 100; set => _maxClip = value; }

	static int? _maxKills;
	/// <summary>How many zombies it will engage before shutting down. 24, as upstream.</summary>
	public static int MaxKills { get => _maxKills ?? 24; set => _maxKills = value; }

	static float? _damageFraction;
	/// <summary>
	/// Fraction of your weapon's per-shot damage each firework hit deals. 1.0 — every firework shot
	/// hits as hard as one of yours.
	/// </summary>
	///
	/// ⛔ THIS USED TO BE AN INSTAKILL. Upstream's original dealt `Health() + 666` per hit — every shot
	/// a guaranteed kill — and Phase 7 cut it to 10% of the held weapon's per-shot damage. Raised to
	/// 100% on 2026-09-24, asked for as *"make it deal 100% of the weapon's damage"*. Still a share of
	/// YOUR gun, so it scales with what you hold rather than killing whatever it touches.
	public static float DamageFraction { get => _damageFraction ?? 1f; set => _damageFraction = value; }

	static float? _noWeaponDamage;
	/// <summary>
	/// Per-hit damage assumed when the held weapon cannot be read. 50.
	///
	/// ⚠️ NOT ZERO, for the reason PhD's blast and Napalm's pit both carry: an empty-handed player
	/// is reachable, and an effect that silently does nothing reads as broken rather than as an
	/// edge case.
	/// </summary>
	public static float NoWeaponDamage { get => _noWeaponDamage ?? 50f; set => _noWeaponDamage = value; }

	// ══ upgrades (2026-10-05) ════════════════════════════════════════════════
	//
	// ⚠️ EACH UPGRADED NUMBER IS ITS OWN TUNABLE BESIDE THE BASE, and each is resolved in ONE helper below (§3), for the
	// OWNER: on the owner's machine for the shots, and on every machine for III's second copy. Levels are synced, so each
	// machine builds what the owner's builds (`NZNet.WorldFx`).

	const string ModId = "fireworks";

	static int? _volleyHits;
	/// <summary>I LONGER VOLLEY: the hit budget. 50 (`MaxKills`, 24): the user's *"50 shots"*, counted as `Landed` counts.</summary>
	public static int VolleyHits { get => _volleyHits ?? 50; set => _volleyHits = value; }

	static int? _volleyClip;
	/// <summary>
	/// I LONGER VOLLEY: the clip floor. 50 (`MinClip`, 20).
	///
	/// ⚠️ IT RISES WITH THE BUDGET (the doc's building note): a shot with nothing in reach is still spent, so a floor under
	/// 50 runs a small-magazine copy dry before its 50th hit.
	/// </summary>
	public static int VolleyClip { get => _volleyClip ?? 50; set => _volleyClip = value; }

	static float? _longReach;
	/// <summary>II LONG REACH: how far it can reach for a target. 350u (`Range`, 200).</summary>
	public static float LongReach { get => _longReach ?? 350f; set => _longReach = value; }

	static float? _twinGap;
	/// <summary>III TWIN FIRE: how far each copy rises from the middle, side by side across your line to the zombie. 30u.</summary>
	public static float TwinGap { get => _twinGap ?? 30f; set => _twinGap = value; }

	// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ──

	static float? _blackPowderFraction;
	/// <summary>
	/// IV BLACK POWDER: the share of your weapon's per-shot damage each copy shot deals. 2.0 (`DamageFraction`, 1.0).
	///
	/// ⚠️ EVERY COPY'S, so both of III's fire it. Resolved into `PerHit` at the spawn, as the base share is.
	/// </summary>
	public static float BlackPowderFraction { get => _blackPowderFraction ?? 2f; set => _blackPowderFraction = value; }

	/// <summary>The share of a shot each of this owner's copy shots deals: IV's, or the base.</summary>
	static float FractionFor( NZPlayer owner ) => AmmoModUpgrades.Has( owner, ModId, 4 ) ? BlackPowderFraction : DamageFraction;

	/// <summary>Do this owner's copies shoot headshots: V Headliner. A rule, not a number: the copy's hit carries the `head` tag.</summary>
	static bool HeadlinerFor( NZPlayer owner ) => AmmoModUpgrades.Has( owner, ModId, 5 );

	/// <summary>The hit budget of this owner's copies: I's, or the base.</summary>
	static int HitBudgetFor( NZPlayer owner ) => AmmoModUpgrades.Has( owner, ModId, 1 ) ? VolleyHits : MaxKills;

	/// <summary>The clip floor of this owner's copies: I's, or the base.</summary>
	static int ClipFloorFor( NZPlayer owner ) => AmmoModUpgrades.Has( owner, ModId, 1 ) ? VolleyClip : MinClip;

	/// <summary>How far this owner's copies reach: II's, or the base.</summary>
	static float RangeFor( NZPlayer owner ) => AmmoModUpgrades.Has( owner, ModId, 2 ) ? LongReach : Range;

	/// <summary>Does this owner get two copies: III.</summary>
	static bool TwinFor( NZPlayer owner ) => AmmoModUpgrades.Has( owner, ModId, 3 );

	// ══ instance state ═══════════════════════════════════════════════════════

	/// <summary>Who gets the kills and the points.</summary>
	public NZPlayer Owner { get; set; }

	/// <summary>Damage per hit, resolved once at spawn.</summary>
	public float PerHit { get; set; }

	/// <summary>Shots left.</summary>
	public int Clip { get; set; }

	/// <summary>
	/// Hits landed so far, counted against the hit budget: `MaxKills`, or I's `VolleyHits` (`HitBudgetFor`).
	///
	/// ⚠️ HITS, NOT DISTINCT ZOMBIES, and that changed when the ignore list started
	/// clearing. Upstream could call this a kill count because every hit killed; here a
	/// firework alone with one zombie spends its whole budget on that zombie. Measured: a
	/// Galil firework landed 23 hits on a single walker and killed it (23 x 3 = 69 of 75).
	///
	/// ⚠️ SO `MaxKills` IS A HIT BUDGET, not a target count. The console key stays
	/// `kills` for continuity with upstream's name, but the log says hits.
	/// </summary>
	public int Landed { get; set; }

	/// <summary>Where it is climbing to.</summary>
	Vector3 _target;

	TimeUntil _armed;
	TimeUntil _dies;
	TimeUntil _nextShot;
	TimeUntil _nextWhistle;
	float _shotGap;

	/// <summary>
	/// Zombies hit since the last time the list was cleared.
	///
	/// ⛔ UPSTREAM NEVER CLEARS THIS, AND THAT IS A BUG THE PHASE 7 REBALANCE LEFT BEHIND. The
	/// original firework dealt `Health() + 666` per hit — every hit killed, so "one target
	/// once" meant 24 KILLS and the field is literally named `Kills`. Phase 7 dropped the damage
	/// to 10% of per-shot and kept the rule, which turns 24 kills into 24 taps that kill nothing.
	///
	/// Measured before this changed: a Galil firework spent all 35 shots on ONE zombie for 3
	/// damage and retired with `engaged 1/24`.
	///
	/// ⚠️ SO IT CLEARS WHEN EVERY REACHABLE ZOMBIE HAS BEEN HIT, rather than being deleted
	/// outright. That keeps the half of upstream's rule that is good — spread fire across the
	/// crowd before doubling back — and drops the half that only made sense when a hit was
	/// lethal. `Engaged` still counts every engagement against `MaxKills`, so the lifetime budget
	/// is unchanged.
	/// </summary>
	readonly HashSet<GameObject> _hit = new();

	// ══ spawning ═════════════════════════════════════════════════════════════

	/// <summary>
	/// Put a firework above a zombie.
	///
	/// ⚠️ A RUNTIME-CREATED COMPONENT DOES NOT SURVIVE A HOTLOAD, and that is acceptable here for
	/// the same reason the napalm pit accepts it: this thing lives forty seconds at most. `Slide`
	/// carries the warning for the case where it does matter.
	///
	/// ⚠️ THE MODEL IS THE OWNER'S WORLD MODEL, read at spawn. Reading it later would fail the
	/// moment the player swaps weapons, which is exactly what a player does after proccing
	/// something that shoots for them.
	/// </summary>
	public static Fireworks Spawn( NZPlayer owner, GameObject zombie, bool announce = true )
	{
		// ⚠️ EVERY MACHINE DRAWS IT; ONLY THE OWNER'S TICKS ITS DAMAGE. See `NZNet.WorldFx`.
		if ( announce && Networking.IsActive && Connection.Local is not null )
			NZNet.WorldFx( Connection.Local.Id.ToString(),
				NZPlayers.OwnerOf( owner.IsValid() ? owner.GameObject : null ),
				(int)NZNet.FxKind.Fireworks, zombie.IsValid() ? zombie.WorldPosition : Vector3.Zero, zombie.IsValid() ? zombie.Id : Guid.Empty );

		if ( !owner.IsValid() || !zombie.IsValid() ) return null;

		var scene = zombie.Scene;
		if ( !scene.IsValid() ) return null;

		var wep = VultureAugments.HeldWeapon( owner );
		var si = wep.IsValid() ? wep.Primary : null;

		// ⚠️ `AmmoMods.WeaponDamage` — point blank, no hit tags, × Bullets. The firework has no distance
		// to a muzzle and no hitgroup of its own; it wants the weapon's clean per-shot number, the same
		// reading the stats card makes and every other mod now shares.
		var perShot = si is not null
			? AmmoMods.WeaponDamage( owner )
			: MathF.Max( 0f, NoWeaponDamage );

		// ⛔ `WorldModel` IS NULL ON ALL 31 WEAPON PREFABS, so the first version of
		// this was an invisible firework. It read `wep.WorldModel`, found null, skipped the
		// renderer inside a silent `if`, and the effect still damaged zombies — so it looked
		// like it worked and showed nothing. The project ships VIEWMODELS ONLY; there is not a
		// single `w_*.vmdl` under Assets.
		//
		// ⚠️ SO THE VIEWMODEL IS THE MODEL, and it is a compromise worth naming: a
		// viewmodel is authored for a camera two feet away, so its scale and origin are not
		// world-correct. It is a recognisable gun in the air, which is the point, and the fix
		// is world models rather than anything in this file.
		//
		// ⚠️ HANDS ARE A SEPARATE MODEL (`ViewModelHands`) and deliberately not used
		// — a floating pair of arms holding the gun would be a different and much
		// funnier effect than the one requested.
		// ⚠️ ITS DISPLAY MODEL WHEN IT HAS ONE (`WeaponDisplay`): an MW viewmodel with no clip playing flies in pieces
		var mdl = wep.IsValid() ? WeaponDisplay.For( wep.WorldModel ?? wep.ViewModel ) : null;

		if ( mdl is null )
		{
			// ⛔ LOUD, NOT SILENT. An invisible firework that still deals damage is the
			// exact bug this replaced, and it survived a play test because nothing said so.
			Log.Warning( "[nz-ammo] fire works has NO MODEL — the held weapon has neither a"
				+ " WorldModel nor a ViewModel. It will be invisible." );
		}

		// Rate and clip inherit upward only, never downward — see MinRpm.
		var rpm = si is not null && wep.IsValid()
			? 60f / MathF.Max( 0.0001f, wep.GetRealRPM( si.RPM ) )
			: MinRpm;

		// ⛔ THE BOUNDS ARE ORDERED BEFORE THEY ARE USED, because `Math.Clamp` THROWS when
		// min > max rather than doing something sensible. `nz_fireworks_set clip 400` against a
		// max of 100 crashed `Spawn` outright with "'400' cannot be greater than 100" — the
		// firework spawned, threw mid-setup, and then reported "timed out, 0 hit(s)", which looks
		// like a broken effect rather than a bad number. Any console tuning can do this.
		var rpmLo = MathF.Max( 1f, MinRpm );
		var rpmHi = MathF.Max( rpmLo, MaxRpm );

		var clipLo = Math.Max( 1, ClipFloorFor( owner ) );
		var clipHi = Math.Max( clipLo, MaxClip );

		var shotGap = 60f / Math.Clamp( MathF.Max( rpm, rpmLo ), rpmLo, rpmHi );
		var clip = Math.Clamp( si?.ClipSize ?? clipLo, clipLo, clipHi );

		// ⚠️ IV BLACK POWDER COMES IN HERE (2026-10-06), through `FractionFor`: 200% of the shot from IV, for every copy.
		var share = FractionFor( owner );
		var perHit = MathF.Max( 1f, perShot * MathF.Max( 0f, share ) );
		var at = zombie.WorldPosition + Vector3.Up * 48f;

		// ⚠️ III TWIN FIRE (2026-10-05): a second copy, the same in every number, side by side with the first across the
		// owner's line to the zombie. Each keeps its own clip, budget and hit list, so each fires I's volley at II's reach.
		// The second's shots fall half a gap behind the first's, so two guns are heard rather than one louder one.
		var twin = TwinFor( owner );
		var side = twin ? TwinSide( owner, zombie ) * MathF.Max( 0f, TwinGap ) : Vector3.Zero;

		var fw = Build( scene, owner, at - side, mdl, perHit, shotGap, clip, 0f );
		if ( twin ) Build( scene, owner, at + side, mdl, perHit, shotGap, clip, shotGap * 0.5f );

		Log.Info( $"[nz-ammo] FIRE WORKS{(twin ? " x2 (Twin Fire)" : "")} — {perHit:0} per hit"
			+ $" ({perShot:0} x {share:0.##})"
			+ (HeadlinerFor( owner ) ? ", every one a headshot (Headliner)" : "")
			+ $" · {clip} shots at {60f / shotGap:0} rpm"
			+ $" · {RangeFor( owner ):0}u reach · up to {HitBudgetFor( owner )} hit(s)" );

		return fw;
	}

	/// <summary>
	/// One copy at <paramref name="at"/>, climbing `Rise` from there, with the numbers `Spawn` resolved.
	///
	/// ⛔ THIS MACHINE'S OWN (§39): every machine builds its own from `NZNet.WorldFx`, so a joiner must not also get a
	/// frozen one in the snapshot.
	/// </summary>
	/// <param name="stagger">How much later than `ArmTime` its first shot comes: half a shot gap for III's second copy.</param>
	static Fireworks Build( Scene scene, NZPlayer owner, Vector3 at, Model mdl, float perHit, float shotGap, int clip,
		float stagger )
	{
		var go = scene.CreateObject();
		go.Name = "nz_firework";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;
		go.WorldPosition = at;

		if ( mdl is not null )
		{
			var r = go.Components.Create<SkinnedModelRenderer>();
			r.Model = mdl;
		}

		var fw = go.Components.Create<Fireworks>();

		fw.Owner = owner;
		fw.PerHit = perHit;
		fw._target = at + Vector3.Up * MathF.Max( 0f, Rise );
		fw._shotGap = shotGap;
		fw.Clip = clip;

		fw._armed = MathF.Max( 0f, ArmTime );
		fw._dies = MathF.Max( 1f, Lifetime );
		fw._nextShot = MathF.Max( 0f, ArmTime ) + stagger;
		fw._nextWhistle = 0f;

		Sound.Play( "nz.pop.fireworks.launch", go.WorldPosition );

		return fw;
	}

	/// <summary>III's side-by-side axis: flat, across the owner's line to the zombie, so the two stand left and right of it.</summary>
	static Vector3 TwinSide( NZPlayer owner, GameObject zombie )
	{
		var line = (zombie.WorldPosition - owner.WorldPosition).WithZ( 0f );
		if ( line.IsNearlyZero() ) line = owner.WorldRotation.Forward.WithZ( 0f );

		return line.IsNearlyZero() ? Vector3.Left : Vector3.Cross( line.Normal, Vector3.Up ).Normal;
	}

	// ══ the loop ═════════════════════════════════════════════════════════════

	protected override void OnUpdate()
	{
		// ⚠️ THE LIFETIME CAP IS CHECKED FIRST and independently of the clip. A firework that
		// spawns with nothing in range would otherwise hang in the air forever holding a full clip.
		//
		// ⛔ AND IT IS NOW ABOVE THE OWNERSHIP GATE, WHICH IS WHERE "FIRST" HAS TO MEAN. It was
		// below it, so "checked first" was only ever true on the machine that fired: everywhere
		// else the gate returned and the firework hung in the air forever, exactly the outcome this
		// comment says it exists to prevent. Same defect the fallout pit was reported for.
		if ( _dies )
		{
			Retire( "timed out" );
			return;
		}

		// ⛔ ONE MACHINE DAMAGES, EVERY MACHINE DRAWS. This effect now exists on all of them so
		// everybody can see it — but each copy ticking would hurt every zombie inside it once PER
		// MACHINE, and on a client each of those is relayed to the host separately.
		//
		// ⚠️ THE OWNER'S MACHINE, NOT THE HOST'S, so the damage carries the owner's own perks
		// through `Health.AttackerScale`.
		if ( Networking.IsActive
			&& (!Owner.IsValid() || !PlayerPresence.Mine( Owner.GameObject )) ) return;

		Climb();
		Whistle();

		if ( !_armed ) return;

		if ( Clip <= 0 ) { Retire( "out of shots" ); return; }
		if ( Landed >= MathF.Max( 1, HitBudgetFor( Owner ) ) ) { Retire( "hit budget spent" ); return; }

		if ( !_nextShot ) return;

		_nextShot = _shotGap;

		Shoot();
	}

	/// <summary>Lerp toward the hover point, as upstream does.</summary>
	void Climb()
	{
		var at = WorldPosition;
		if ( at.AlmostEqual( _target, 0.5f ) ) return;

		WorldPosition = Vector3.Lerp( at, _target, Time.Delta * 5f );
	}

	/// <summary>
	/// The whistle and pop loop.
	///
	/// ⚠️ ONE TIMER FOR BOTH, not two. Upstream runs separate 0.4–0.8s timers for the whistle and
	/// the explosion, which drift into each other and sometimes fire together; alternating on one
	/// timer keeps them interleaved.
	/// </summary>
	void Whistle()
	{
		if ( !_nextWhistle ) return;

		_nextWhistle = Game.Random.Float( 0.4f, 0.8f );

		Sound.Play( Game.Random.Int( 0, 1 ) == 0
			? "nz.pop.fireworks.whistle"
			: "nz.pop.fireworks.expl", WorldPosition );
	}

	/// <summary>
	/// One shot: find a zombie it has not hit yet and hurt it.
	///
	/// ⚠️ IT SPENDS A SHOT EVEN WITH NOTHING IN RANGE, which is upstream's behaviour and the right
	/// one: otherwise a firework in an empty corridor holds its clip until the 40s cap and keeps
	/// whistling. Running dry is how it ends.
	///
	/// ⚠️ THE DAMAGE IS ATTRIBUTED TO THE OWNER, not to the firework. Points, Deadshot's streak,
	/// Napalm's chain and Death Perception's headshot bonus all read the attacker — a firework kill
	/// is your kill, and upstream sets the attacker for exactly this reason.
	/// </summary>
	// ══ the firework tracer ══════════════════════════════

	/// <summary>
	/// The colours a firework shot can be.
	///
	/// ⚠️ FULLY SATURATED AND BRIGHT, because the tracer's own gradient is replaced rather
	/// than tinted — see `Trace`. Mid-tones read as dull smoke at the speed a tracer moves.
	///
	/// ⚠️ NO YELLOW-ORANGE. That is exactly what a normal bullet tracer already looks like in
	/// this project, so a firework using it would be indistinguishable from ordinary fire.
	/// </summary>
	static Color[] Palette => new[]
	{
		new Color( 1f, 0.15f, 0.35f ),   // rose
		new Color( 0.30f, 0.55f, 1f ),   // cornflower
		new Color( 0.35f, 1f, 0.45f ),   // green
		new Color( 1f, 0.35f, 1f ),      // magenta
		new Color( 0.35f, 1f, 1f ),      // cyan
		new Color( 1f, 0.85f, 0.25f ),   // gold
	};

	/// <summary>
	/// Draw one coloured tracer from the floating gun to what it just shot.
	///
	/// ⛔ UPSTREAM HAS NO TRACERS ON THIS PATH AT ALL. Its TFA branch gets them for free by
	/// calling `wep:PrimaryAttack()` on a real cloned weapon; its base-agnostic branch (the one
	/// this port resembles) says so outright — "there is no cloned visual gun, that part is
	/// TFA-only". The Source particle it attaches, `bo3_aat_fireworks`, is a PCF we do not have.
	/// So the whole visual is this.
	///
	/// ⚠️ THE GRADIENT IS OVERWRITTEN, NOT TINTED. `tracer.prefab`'s `ParticleEffect` ramps
	/// white to yellow to orange, and `Tint` MULTIPLIES that — so a blue tint over a yellow
	/// ramp comes out muddy green rather than blue. Assigning the gradient a flat colour is what
	/// makes the colour the one actually asked for.
	///
	/// ⚠️ THE TRAIL RENDERER CARRIES ITS OWN COLOUR and has to be set too. It is a separate
	/// component with a separate gradient; setting only the effect leaves a yellow trail behind a
	/// blue head, which looks like a bug rather than a firework.
	///
	/// ⚠️ ONE COLOUR PER SHOT, not per firework. A single firework cycling colours as it works
	/// through a crowd is the effect; one colour for its whole life is just a coloured gun.
	/// </summary>
	void Trace( Vector3 from, Vector3 to )
	{
		if ( !Tracers ) return;

		// ⚠️ THE DRAWING LIVES IN `ColourTracer` NOW, not here. It was written in
		// this file first and moved out the moment Dead Wire wanted the same thing — the two
		// prefab quirks it has to know about (the gradient multiplies, the trail renderer
		// carries its own colour) are exactly the kind that drift between copies.
		//
		// ⚠️ THE PALETTE STAYS HERE. It is Fire Works' identity, not a shared
		// resource: Dead Wire is electric blue and always the same colour, because an arc
		// that changes hue per hop would not read as one chain.
		ColourTracer.Draw( from, to,
			Palette[Game.Random.Int( 0, Palette.Length - 1 )],
			"nz_firework_tracer" );
	}

	void Shoot()
	{
		Clip--;

		var at = WorldPosition;
		var reach = MathF.Max( 0f, RangeFor( Owner ) );

		var target = Pick( at, reach );

		// ⚠️ ONE RETRY AFTER CLEARING, not a loop. If nothing fresh is in range but the list
		// is non-empty, everything reachable has already been hit once — so forget them and
		// pick again. A single retry is enough because the second `Pick` runs against an empty
		// set, and a genuinely empty room still falls through and spends the shot.
		if ( !target.IsValid() && _hit.Count > 0 )
		{
			_hit.Clear();
			target = Pick( at, reach );
		}

		// ⚠️ A SLOW IDLE SPIN WHEN THERE IS NOTHING IN RANGE, so a firework in an
		// empty corridor still reads as active rather than as a stuck prop. This is the
		// only place upstream's tumble survives, and it is the only place it helps.
		if ( !target.IsValid() )
		{
			WorldRotation = WorldRotation.Angles().WithYaw(
				WorldRotation.Angles().yaw + 45f ).ToRotation();

			return;
		}

		var hp = target.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
		if ( !hp.IsValid() || hp.IsDead ) return;

		// ⛔ IT AIMS. UPSTREAM DOES NOT — it sets a random angle on every
		// shot (`SetAngles(math.random(-90,90) x3)`) and never looks at what it is shooting,
		// because with an instakill per hit nobody watches the gun. Ported faithfully it read
		// as a spinning prop that happened to coincide with zombies dying.
		//
		// ⚠️ AIMED AT THE CHEST, NOT THE DAMAGE POSITION. The damage is credited
		// above the zombie's origin so the numbers pop at head height; pointing the barrel
		// there would tilt the gun upward at close range for no reason.
		var toward = target.WorldPosition + Vector3.Up * 40f - at;

		if ( !toward.IsNearlyZero() )
			WorldRotation = Rotation.LookAt( toward.Normal, Vector3.Up );

		// ⚠️ DRAWN TO THE AIM POINT, the same `toward` the barrel was just pointed along, so
		// the tracer leaves the muzzle rather than crossing the model diagonally.
		Trace( at, at + toward );

		// ⚠ UPSTREAM HAS A THIRD CUE, `NZ.POP.Fireworks.Shoot`, WHICH WE NEVER PLAYED. Its TFA
		// path gets a firing sound for free from the cloned weapon; ours has no real weapon, so
		// without this the gun in the air was silent between whistles.
		//
		// ⚠ THE FILE IS NOT EXTRACTED (`wpn_pap_first.wav`), so this reuses the Pack-a-Punch
		// shoot cue — which is what that wav IS upstream, by its own filename.
		Sound.Play( NZSound.PapShoot, at );

		_hit.Add( target.GameObject );
		Landed++;

		// ⚠️ AIMED AT THE HEAD POSITION, as upstream aims at the head bone for the visual and the force. BELOW V IT CARRIES NO
		// "head" TAG: a headshot would hand every copy hit the head multiplier and Death Perception's bonus on top, which is not
		// what a share of per-shot damage was meant to be — until the user asked for exactly that at V (below).
		var info = new DamageInfo
		{
			Damage = PerHit,
			Attacker = Owner.IsValid() ? Owner.GameObject : null,
			Position = target.WorldPosition + Vector3.Up * 64f,
			Tags = new TagSet(),
		};

		// ⛔ V HEADLINER (2026-10-06): *"for V i'd do the hits count as headshots"*. THE TAG, NOT A MULTIPLIER, because the tag is
		// what `Health.OnDamage` decides a headshot by (`IsHeadshot`), and everything a real one gets hangs off that one local:
		// the ×2.5 (`HeadshotDamageScale`) with Death Perception's and Deadshot's head terms, Concussion and Focus on the hit, the
		// headshot kill award (`Difficulty.PointsKillHeadshot`) and the kill augments that ask for a headshot. A multiplier would
		// have handed over the ×2.5 and none of the rest, the reason Lucky Shot promotes there too.
		//
		// ⚠️ NOT THE WEAPON TECH'S HEAD NODES: this DamageInfo carries no weapon, so `TechEffects.Of` resolves no tree, and
		// Precision Rounds, Deadeye, the class heads and One Shot One Kill stay out, as every other node always has.
		//
		// ⚠️ A CLIENT'S COPY KEEPS IT THROUGH THE RELAY (read 2026-10-06): on a client `OnDamage` reads the tag into its `head`,
		// applies the shooter's head terms (`AttackerScale`), and `NZNet.HurtRemote`'s `headshot` puts the tag back on the host's
		// DamageInfo, where the ×2.5 and the award are applied.
		//
		// ⚠️ AND THE GORE TAKES IT AS THE HEAD (`Health.GorePartOf` reads the tag as the hitbox): a copy's headshot kill pops the
		// head, as a real one does.
		if ( HeadlinerFor( Owner ) ) info.Tags.Add( "head" );

		// ⛔ FROM LEVEL I THE COPY'S OWN SHOT ROLLS NO MOD (2026-10-05, the review). Only the cooldown stamped at the proc turned
		// its hits away, and I's 50 hits outlast the 6.5 s: at the 400 rpm floor the last land about 7.9 s in, and the late
		// ones re-rolled Fire Works — a new copy, which could do the same (two copies at III, more late hits). Level 0's 24 end
		// by about 4 s and roll as they always did (`AmmoMods.WithoutProcs`).
		if ( AmmoModUpgrades.Has( Owner, ModId, 1 ) ) AmmoMods.WithoutProcs( Owner, () => hp.OnDamage( info ) );
		else hp.OnDamage( info );

		Sound.Play( "nz.pop.fireworks.expl", at );
	}

	/// <summary>The nearest live zombie in reach that has not been hit yet.</summary>
	ZombieAI Pick( Vector3 at, float reach )
		=> ZombieAI.All
			.Where( z => z.IsValid() && z.GameObject.IsValid() )
			.Where( z => z.State != ZombieState.Dead )
			.Where( z => !_hit.Contains( z.GameObject ) )
			.Where( z => at.Distance( z.WorldPosition ) <= reach )
			.OrderBy( z => at.Distance( z.WorldPosition ) )
			.FirstOrDefault();

	/// <summary>Log why it stopped, then go.</summary>
	void Retire( string why )
	{
		Log.Info( $"[nz-ammo] fire works done — {why}"
			+ $" · {Landed}/{HitBudgetFor( Owner )} hit(s) · {Clip} shot(s) left" );

		GameObject?.Destroy();
	}

	// ══ diagnostics ══════════════════════════════════════════════════════════

	/// <summary>
	/// `nz_fireworks` — every live firework, and what it is doing.
	///
	/// ⚠️ IT PRINTS THE RESOLVED SHOT GAP, not the RPM knob. `GetRealRPM` returns an INTERVAL, not
	/// a rate, and that inversion has already cost this project a hundredfold error in the napalm
	/// pit. Printing the number actually used is the only way that stays honest.
	/// </summary>
	[ConCmd( "nz_fireworks" )]
	public static void Report()
	{
		var scene = Game.ActiveScene;
		var all = scene?.GetAllComponents<Fireworks>().ToList() ?? new List<Fireworks>();

		Log.Info( $"[nz-ammo] FIRE WORKS · {all.Count} live"
			+ $" · {Range:0}u reach · {DamageFraction:0.##} of per-shot damage"
			+ $" · rpm {MinRpm:0}-{MaxRpm:0} · clip {MinClip}-{MaxClip} · cap {MaxKills}" );

		// ⚠️ IV AND V (2026-10-06): IV's share, and V's rule, which has no number.
		Log.Info( $"[nz-ammo]   upgrades: I {VolleyHits} hits, clip floor {VolleyClip} · II {LongReach:0}u reach"
			+ $" · III two copies {TwinGap:0}u either side · IV {BlackPowderFraction:0.##} of per-shot damage"
			+ $" · V every copy shot a headshot · you: level {AmmoModUpgrades.Level( NZPlayer.Local, ModId )}" );

		foreach ( var f in all )
		{
			if ( !f.IsValid() ) continue;

			Log.Info( $"[nz-ammo]   {f.PerHit:0} per hit{(HeadlinerFor( f.Owner ) ? " (headshots)" : "")} · {f.Clip} shot(s) left"
				+ $" · {f.Landed}/{HitBudgetFor( f.Owner )} hit(s) at {RangeFor( f.Owner ):0}u · every {f._shotGap:0.###}s"
				+ $" · {(f._armed ? $"arming {(float)f._armed:0.0}s" : "firing")}"
				+ $" · dies in {MathF.Max( 0f, f._dies ):0.0}s" );
		}
	}

	/// <summary>`nz_fireworks_set &lt;key&gt; &lt;value&gt;` — retune one number live.</summary>
	[ConCmd( "nz_fireworks_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "range": Range = value; break;
			case "rise": Rise = value; break;
			case "arm": ArmTime = value; break;
			case "life": Lifetime = value; break;
			case "rpm": MinRpm = value; break;
			case "maxrpm": MaxRpm = value; break;
			case "clip": MinClip = (int)value; break;
			case "maxclip": MaxClip = (int)value; break;
			case "kills": MaxKills = (int)value; break;
			case "fraction": DamageFraction = value; break;
			case "tracers": Tracers = value > 0.5f; break;
			case "volley": VolleyHits = (int)value; break;
			case "volleyclip": VolleyClip = (int)value; break;
			case "longreach": LongReach = value; break;
			case "twingap": TwinGap = value; break;

			// ⚠️ IV'S NUMBER (2026-10-06). V is a rule and has none.
			case "powder": BlackPowderFraction = value; break;

			default:
				Log.Info( "[nz-ammo] nz_fireworks_set <range|rise|arm|life|rpm|maxrpm|clip"
					+ "|maxclip|kills|fraction|volley|volleyclip|longreach|twingap|powder> <value>" );
				return;
		}

		Log.Info( $"[nz-ammo] fireworks {key} = {value:0.###}" );
		Report();
	}
}