Weapons/GravityWell.cs

A game weapon effect component that implements a Gravity Well ability. It opens a visual black hole, pulls nearby zombies into a knot, optionally deals periodic damage (Spaghettify) and a final implosion damage (Collapse) based on the shooter's upgrades, and handles drawing the effect on all clients.

NetworkingFile AccessNative Interop
using Sandbox;
using System;
using System.Collections.Generic;

namespace NZombies;

/// <summary>
/// Gravity Well — a black hole opens at the zombie you hit and drags every zombie near it into a knot for two seconds.
///
/// | | value |
/// |---|---|
/// | proc | **12%** per hit, **40s** cooldown (10s until the user's nerf, 2026-10-06; no tier touches it) |
/// | reach | **300u** around the zombie hit — I, Strong Pull: **450u** |
/// | pull | into a knot **40u** across the middle, in **0.7s**; held there, unable to swing, until it closes |
/// | life | **2s** — a zombie that walks in while it is open is caught too; II, Event Horizon: **4s** |
/// | damage | **none** — it sets them up for a blast. III, Collapse: **500%** of your damage to each one held, as it closes; IV, Supernova: **1000%** |
/// | while held | **nothing** — V, Spaghettify: **100%** of your damage to each one held every **0.5s** (8 times in II's 4s, 800%) |
///
/// ⚠️ THE USER (2026-10-04): *"use the oberon gravity attack, we made an asset for it with an animation, just make it smaller
/// to fit the radius and use that animation"*. So the look IS Oberon's black hole: his effect model `ef_hole` playing its
/// own `idle1` clip, and his floor vortex (`Vortex`) drawn at the reach — both scaled from his 900u pull down to this
/// 300u one. His clip is sped up to play whole inside the two seconds rather than being cut off half way through.
///
/// ⛔ THE HOST PULLS; EVERY MACHINE DRAWS. Zombies think on the host, so a client's proc is sent there
/// (`NZNet.GravityWellAsk`), and the host tells everyone to draw it (`NZNet.GravityWellFx`) — one message for the hole and
/// the vortex together. Each pulled zombie's pose reaches the watching machines through `ZombieAI.PlayPratfall`'s relay.
///
/// ⚠️ NOT EVERY ZOMBIE COMES: a boss doesn't, nor one at or in a window or on a link, nor one already down — the guards are
/// `ZombieAI.Pull`'s, the same as Shockwave's.
///
/// ⚠️ THE UPGRADES (2026-10-05, `AmmoModUpgrades`; the user, 01:14: *"great!"*). The levels are the SHOOTER'S, read on the host
/// that opens the well: its own player's, or a client's from `Rpc.CallerId` (`NZNet.GravityWellAsk`; levels sync,
/// `NZPlayer.AmmoUpgradeNet`). One helper per number (`RadiusFor`, `SecondsFor`, `CollapseFor`). The reach and the life travel
/// in `NZNet.GravityWellFx`, so every machine draws the bigger hole for longer and fits Oberon's clip to the life it is handed
/// (`GravityHoleClip`) — Event Horizon's 4s too, the doc's building note.
///
/// ⛔ COLLAPSE'S FIGURE IS THE SHOOTER'S, ITS DAMAGE THE HOST'S, as Shockwave's Seismic Slam. The weapon damage is read on the
/// shooter's machine AT THE PROC (`AmmoMods.WeaponDamage`, snapshotted as Radioactive Decay's dose is) and rides the ask; the
/// host, whose knot it is, deals 500% of it ONCE to each zombie it still holds as the well closes. A blast, not a bullet
/// (`TechBlast.BlastTag`). ⚠️ A client's carries none of their own perks' terms and shows them no numbers (`Shockwave`).
///
/// ⚠️ TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"; the user, 23:49: *"Ok that's great"*). IV, Supernova: Collapse
/// deals 1000% (500%) — `SupernovaShare`, through `CollapseFor`. V, Spaghettify: every zombie held in the knot takes 100% of your
/// damage every 0.5s for the whole hold (`SpaghettifyFor`, `Shred`) — with II's 4s, eight times, 800%. The 40s cooldown stays
/// untouched at every tier.
///
/// ⛔ SPAGHETTIFY IS COLLAPSE'S KIND: the shooter's figure, snapshotted at the proc and carried by the ask, dealt by the host whose
/// knot it is, credited to the shooter, a blast, and rolling no mod (`AmmoMods.WithoutProcs`) — eight rounds of hits on a held crowd
/// past a cut cooldown would open the next well on the knot (`Collapse`'s note). Its shreds are counted from the opening and the
/// last is paid on the closing frame, before Collapse, so a late frame owes a shred rather than skipping it.
/// </summary>
public sealed class GravityWell : Component
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — INSTRUCTIONS.md §1.

	static float? _radius;
	/// <summary>How far the well reaches. 300u.</summary>
	public static float Radius { get => _radius ?? 300f; set => _radius = value; }

	static float? _seconds;
	/// <summary>How long it stays open. 2s.</summary>
	public static float Seconds { get => _seconds ?? 2f; set => _seconds = value; }

	static float? _knot;
	/// <summary>How far from the middle a pulled zombie ends up. 40u — a tight knot, not a pile on one point.</summary>
	public static float Knot { get => _knot ?? 40f; set => _knot = value; }

	static float? _pullIn;
	/// <summary>How long the drag into the knot takes. 0.7s, accelerating, as falling in does.</summary>
	public static float PullIn { get => _pullIn ?? 0.7f; set => _pullIn = value; }

	static float? _holeScale;
	/// <summary>
	/// A multiplier on the hole model's size. 1 — the size Oberon draws it at for his 900u pull, scaled by this well's
	/// reach against his (<see cref="OberonReach"/>).
	/// </summary>
	public static float HoleScale { get => _holeScale ?? 1f; set => _holeScale = value; }

	// ══ the upgrades (2026-10-05) ════════════════════════════════════════════
	//
	// ⛔ EACH UPGRADED NUMBER IS ITS OWN TUNABLE BESIDE THE BASE ONE, AND ONE HELPER PICKS BETWEEN THEM (§3). Level 0 reads
	// exactly the numbers above.

	static float? _strongRadius;
	/// <summary>I, STRONG PULL: how far the well reaches. 450u (300).</summary>
	public static float StrongRadius { get => _strongRadius ?? 450f; set => _strongRadius = value; }

	static float? _horizonSeconds;
	/// <summary>II, EVENT HORIZON: how long it holds the knot. 4s (2s).</summary>
	public static float HorizonSeconds { get => _horizonSeconds ?? 4f; set => _horizonSeconds = value; }

	static float? _collapseShare;
	/// <summary>III, COLLAPSE: what each zombie held takes as the well closes, as a share of your weapon's damage. 5 — 500%.</summary>
	public static float CollapseShare { get => _collapseShare ?? 5f; set => _collapseShare = value; }

	static float? _collapseShake;
	/// <summary>III: the implosion's tremor at the well, before distance takes it down. 0.4 trauma, Shockwave's.</summary>
	public static float CollapseShake { get => _collapseShake ?? 0.4f; set => _collapseShake = value; }

	static float? _collapseShakeRange;
	/// <summary>III: how far away the implosion is still felt. 1500u.</summary>
	public static float CollapseShakeRange { get => _collapseShakeRange ?? 1500f; set => _collapseShakeRange = value; }

	// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ──
	//
	// ⚠️ THE SAME SHAPE: each its own tunable beside the one it replaces, and the helpers below ask the HIGHEST level first.

	static float? _supernovaShare;
	/// <summary>IV, SUPERNOVA: what Collapse deals each zombie held, as a share of your weapon's damage. 10 — 1000% (III's 500%).</summary>
	public static float SupernovaShare { get => _supernovaShare ?? 10f; set => _supernovaShare = value; }

	static float? _spaghettifyShare;
	/// <summary>V, SPAGHETTIFY: what each zombie held takes every <see cref="SpaghettifyEvery"/>, as a share of your weapon's damage. 1 — 100%.</summary>
	public static float SpaghettifyShare { get => _spaghettifyShare ?? 1f; set => _spaghettifyShare = value; }

	static float? _spaghettifyEvery;
	/// <summary>V, SPAGHETTIFY: how often it shreds the knot. 0.5s — eight times in Event Horizon's 4s. Never under 0.05s.</summary>
	public static float SpaghettifyEvery { get => _spaghettifyEvery ?? 0.5f; set => _spaghettifyEvery = value; }

	/// <summary>The mod's id, for its upgrade levels.</summary>
	const string ModId = "gravitywell";

	/// <summary>III: the implosion's cue — the boom Oberon's own hole goes off with (`OberonBoss.Strike`).</summary>
	const string CollapseCue = "nz.oberon.attack";

	/// <summary>How far this player's well reaches: Strong Pull's reach at level I.</summary>
	public static float RadiusFor( NZPlayer owner )
		=> MathF.Max( 16f, AmmoModUpgrades.Has( owner, ModId, 1 ) ? StrongRadius : Radius );

	/// <summary>How long this player's well stays open: Event Horizon's life at level II.</summary>
	public static float SecondsFor( NZPlayer owner )
		=> MathF.Max( 0.3f, AmmoModUpgrades.Has( owner, ModId, 2 ) ? HorizonSeconds : Seconds );

	/// <summary>
	/// What Collapse deals each zombie held, from the shooter's weapon damage: Supernova's share at level IV, Collapse's at III,
	/// 0 below it.
	/// </summary>
	public static float CollapseFor( NZPlayer owner, float weaponDamage )
		=> MathF.Max( 0f, weaponDamage )
			* (AmmoModUpgrades.Has( owner, ModId, 4 ) ? MathF.Max( 0f, SupernovaShare )
				: AmmoModUpgrades.Has( owner, ModId, 3 ) ? MathF.Max( 0f, CollapseShare ) : 0f);

	/// <summary>What Spaghettify deals each zombie held at every shred, from the shooter's weapon damage. 0 below level V.</summary>
	public static float SpaghettifyFor( NZPlayer owner, float weaponDamage )
		=> AmmoModUpgrades.Has( owner, ModId, 5 ) ? MathF.Max( 0f, SpaghettifyShare ) * MathF.Max( 0f, weaponDamage ) : 0f;

	/// <summary>
	/// Oberon's pull reach, which his `ef_hole` is drawn against (`OberonBoss.PullRadius`'s default). The model is scaled by
	/// the well's reach over this (<see cref="Radius"/>, or Strong Pull's), so the hole and its reach keep his proportions.
	/// </summary>
	const float OberonReach = 900f;

	/// <summary>How far up his effect models sit at scale 1 (`OberonBoss.FxLift`): authored round his old origin.</summary>
	const float OberonLift = 61.6f;

	const string HoleModel = "models/zombies/ef_hole.vmdl";
	const string HoleClip = "idle1";

	/// <summary>His hole's cue — the one he plays when it opens.</summary>
	const string Cue = "nz.oberon.close";

	/// <summary>How often the open well looks for zombies to catch. 10 Hz.</summary>
	const float TickEvery = 0.1f;

	// ══ live state (the host's well) ═════════════════════════════════════════

	TimeUntil _closes;
	TimeUntil _nextTick;
	readonly HashSet<ZombieAI> _held = new();

	/// <summary>
	/// This well's reach: its owner's, Strong Pull's at level I. ⚠️ NULLABLE, as a field added to a live component must be (§1):
	/// a well that outlives a hotload reads the base reach, not 0.
	/// </summary>
	float? _reach;

	/// <summary>Whose well it is: the implosion's credit.</summary>
	NZPlayer _owner;

	/// <summary>III: what each zombie held takes as it closes (`Collapse`). Null or 0 — below level III — for nothing.</summary>
	float? _collapse;

	/// <summary>
	/// V: what each zombie held takes at every shred (`Shred`), and how often — the owner's, read when it opens. Null or 0 —
	/// below level V — for nothing. ⚠️ NULLABLE (§1), as `_reach`: a well alive across a hotload shreds nothing.
	/// </summary>
	float? _spaghettify;
	float? _shredEvery;

	/// <summary>V: the well's whole life, how long it has been open, and the shreds dealt so far — the clock `Shred` counts by.</summary>
	float? _life;
	TimeSince _opened;
	int _shreds;

	/// <summary>V: how many hits its shreds have landed in all, for the line it closes with.</summary>
	int _shredHits;

	/// <summary>How many zombies this well caught.</summary>
	public int Caught => _held.Count;

	// ══ the effect ═══════════════════════════════════════════════════════════

	/// <summary>The mod went off on <paramref name="zombie"/>. THE SHOOTER'S MACHINE: opened here on the host, asked of it otherwise.</summary>
	public static void Fire( NZPlayer player, GameObject zombie )
	{
		if ( !player.IsValid() || !zombie.IsValid() ) return;

		var at = zombie.WorldPosition;

		// ⚠️ THE SHOOTER'S WEAPON DAMAGE GOES WITH IT, SNAPSHOTTED NOW (2026-10-05, Collapse): this is the one machine that can
		// read it, and the implosion comes seconds later, perhaps after a swap. Whether it is used is the host's call, by the level.
		var damage = AmmoMods.WeaponDamage( player );

		if ( Networking.IsActive && !NZGame.IsHost )
		{
			NZNet.GravityWellAsk( at, damage );
			return;
		}

		Open( at, player, damage );
	}

	/// <summary>Open a well at <paramref name="at"/>. THE HOST, or solo.</summary>
	/// <param name="owner">
	/// Whose well: the host's own player, or the client who asked (`NZNet.GravityWellAsk`). Its levels, and the implosion's credit.
	/// </param>
	/// <param name="damage">The shooter's weapon damage at the proc, read on its machine (`AmmoMods.WeaponDamage`): Collapse's base.</param>
	public static void Open( Vector3 at, NZPlayer owner = null, float damage = 0f )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		var ground = PitVisual.GroundAt( at );
		var seconds = SecondsFor( owner );
		var reach = RadiusFor( owner );

		// ⚠️ EVERYBODY DRAWS IT, THIS MACHINE INCLUDED — a broadcast runs here too, and solo it simply runs. At the OWNER'S reach
		// and life, which travel in the message: no other machine has to read the levels.
		NZNet.GravityWellFx( ground, reach, seconds );
		NZSound.PlayShared( Cue, ground );

		var go = scene.CreateObject();
		go.Name = "nz_gravity_well";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THE HOST'S OWN BOOKKEEPING — every machine draws its own (§39)
		go.WorldPosition = ground;

		var well = go.Components.Create<GravityWell>();
		well._closes = seconds;
		well._reach = reach;
		well._owner = owner;
		well._collapse = CollapseFor( owner, damage );

		// ⚠️ SPAGHETTIFY (V, 2026-10-06): the same snapshot as Collapse's, and its clock starts with the well's.
		well._spaghettify = SpaghettifyFor( owner, damage );
		well._shredEvery = MathF.Max( 0.05f, SpaghettifyEvery );
		well._life = seconds;
		well._opened = 0f;

		SWB.Shared.GameObjectExtensions.DestroyAsync( go, seconds + 0.1f );

		well.Tick();

		var level = AmmoModUpgrades.Level( owner, ModId );

		Log.Info( $"[nz-ammo] GRAVITY WELL — {reach:0}u for {seconds:0.#}s · caught {well.Caught} on opening"
			+ (level > 0 ? $" · level {HudTheme.ToRoman( level )}" : "")
			+ (well._collapse > 0f ? $" · collapses for {(well._collapse ?? 0f):0} each" : "")
			+ (well._spaghettify > 0f
				? $" · shreds for {(well._spaghettify ?? 0f):0} each every {(well._shredEvery ?? 0f):0.##}s" : "") );
	}

	/// <summary>Catch every zombie in reach that is not held yet. HOST.</summary>
	void Tick()
	{
		var left = (float)_closes;

		// ⚠️ NOT IN ITS LAST MOMENT: a zombie caught a frame before the well shuts would be grabbed and dropped at once.
		if ( left < 0.25f ) return;

		var reach = MathF.Max( 16f, _reach ?? Radius );
		var at = WorldPosition;

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() || z.State == ZombieState.Dead || _held.Contains( z ) ) continue;
			if ( at.Distance( z.WorldPosition ) > reach ) continue;

			// ⚠️ EACH IS HELD UNTIL THE WELL CLOSES — its remaining life, not a fresh two seconds.
			if ( z.Pull( at, MathF.Max( 0f, Knot ), left, MathF.Min( MathF.Max( 0.05f, PullIn ), left * 0.6f ) ) )
				_held.Add( z );
		}
	}

	protected override void OnUpdate()
	{
		if ( _closes )
		{
			// ⚠️ COLLAPSE (III) GOES OFF AS IT CLOSES, while the knot is still this well's to name — and SPAGHETTIFY'S LAST SHRED
			// (V) JUST BEFORE IT: with II's 4s the eighth falls due on this very frame.
			Shred( closing: true );
			Collapse();
			GameObject.Destroy();
			return;
		}

		Shred();

		if ( _nextTick )
		{
			_nextTick = TickEvery;
			Tick();
		}
	}

	/// <summary>
	/// SPAGHETTIFY (V): every shred owed by now — one each `_shredEvery` since the well opened — deals `_spaghettify` to each
	/// zombie it still holds, credited to its owner. HOST. <paramref name="closing"/> is the well's last frame: it pays every shred
	/// the life holds (eight in 4s) and says what they came to, once — the figure is spent there, as Collapse's is.
	/// </summary>
	void Shred( bool closing = false )
	{
		var damage = _spaghettify ?? 0f;
		if ( damage <= 0f ) return;

		var every = MathF.Max( 0.05f, _shredEvery ?? SpaghettifyEvery );

		// ⚠️ COUNTED FROM THE OPENING, NOT A TIMER RE-ARMED AS IT FIRES: that runs up to a frame late every shred, and seven frames
		// late would push the eighth past the close. A late frame pays the shred it owes instead.
		var all = (int)MathF.Floor( MathF.Max( 0f, _life ?? 0f ) / every + 0.001f );
		var owed = closing ? all : Math.Min( all, (int)MathF.Floor( (float)_opened / every + 0.001f ) );
		var rounds = owed - _shreds;

		if ( rounds > 0 )
		{
			// ⚠️ CLAIMED BEFORE THE DAMAGE, as a cooldown is stamped before its effect: a throw inside cannot have them paid twice.
			_shreds = owed;

			var at = WorldPosition;
			var reach = MathF.Max( 16f, _reach ?? Radius );
			var hits = 0;

			// ⛔ ITS HITS ROLL NOTHING, for Collapse's reason (below): eight rounds of hits on a held crowd, up to 4s after the proc.
			AmmoMods.WithoutProcs( _owner, () =>
			{
				for ( var i = 0; i < rounds; i++ )
				{
					foreach ( var z in _held )
					{
						if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;

						// ⚠️ STILL IN IT, as Collapse asks: one the game moved away (relocated as stuck) is let off.
						if ( at.Distance( z.WorldPosition ) > reach ) continue;

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

						// ⚠️ NO WEAPON, as Collapse has none: a mod's damage must not pick up the gun's tech tree (`TechEffects.Of`).
						hp.OnDamage( new SWB.Shared.DamageInfo
						{
							Attacker = _owner.IsValid() ? _owner.GameObject : null,
							Damage = damage,
							Position = z.WorldPosition + Vector3.Up * (z.BodyHeight * 0.5f),
							Origin = at,
							Tags = [TechBlast.BlastTag],
						} );

						hits++;
					}
				}
			} );

			_shredHits += hits;
		}

		if ( !closing ) return;

		_spaghettify = null;

		Log.Info( $"[nz-ammo] GRAVITY WELL — Spaghettify: {_shreds} shred(s) of {damage:0}, {_shredHits} hit(s) in all" );
	}

	/// <summary>
	/// COLLAPSE (III): the well implodes as it closes, and each zombie it still holds takes `_collapse`, credited to its owner.
	/// HOST, once: the figure is spent on the first call.
	/// </summary>
	void Collapse()
	{
		var damage = _collapse ?? 0f;
		_collapse = null;
		if ( damage <= 0f ) return;

		var at = WorldPosition;
		var reach = MathF.Max( 16f, _reach ?? Radius );

		// ⚠️ THE BOOM AND THE TREMOR FIRST, on every machine, the order Thunderwall keeps: a death the damage causes cannot cancel
		// them. And no outward ring, as Oberon's own hole has none: a pull throwing a wave out reads as its opposite
		// (`OberonBoss.Strike`).
		NZSound.PlayShared( CollapseCue, at );
		NZNet.ShakeAt( at, MathF.Max( 0f, CollapseShake ), MathF.Max( 0f, CollapseShakeRange ) );

		var hit = 0;

		// ⛔ ITS HITS ROLL NOTHING (2026-10-05, the review): Collapse lands up to 4 s after the proc, past a cut 10 s cooldown
		// (Catalyst ×0.5 with Rapid Discharge or Time Warp is 4 s), and eight held zombies at 17%+ each would all but surely open
		// a new level-III well on the knot — whose own Collapse would open the next, for as long as anything lived. The host's
		// own well only: a client's body on the host holds no gun (`AmmoMods.WithoutProcs`).
		//
		// ⚠️ STILL NEEDED AT 40 s (2026-10-06, for Spaghettify too): fully cut, 40 s is about 11 s, past the 4 s hold — but the gun in
		// HAND may not be the one cooling (a second gun carrying Gravity Well keeps its own clock), and a well an Elemental Pop surge
		// opened stamped nothing at all (`AmmoMods.FireExternal`).
		AmmoMods.WithoutProcs( _owner, () =>
		{
			foreach ( var z in _held )
			{
				if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;

				// ⚠️ STILL IN IT, not merely caught once: one the game moved away (relocated as stuck) is let off.
				if ( at.Distance( z.WorldPosition ) > reach ) continue;

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

				// ⚠️ NO WEAPON, as Shockwave's slam has none: a mod's blast must not pick up the gun's tech tree (`TechEffects.Of`).
				hp.OnDamage( new SWB.Shared.DamageInfo
				{
					Attacker = _owner.IsValid() ? _owner.GameObject : null,
					Damage = damage,
					Position = z.WorldPosition + Vector3.Up * (z.BodyHeight * 0.5f),
					Origin = at,
					Tags = [TechBlast.BlastTag],
				} );

				hit++;
			}
		} );

		Log.Info( $"[nz-ammo] GRAVITY WELL — Collapse: {damage:0} to {hit} zombie(s) in the knot" );
	}

	// ══ the look — every machine ═════════════════════════════════════════════

	/// <summary>
	/// Draw a well on THIS machine: Oberon's hole at the middle, playing its clip, and his vortex out to the reach.
	/// `NZNet.GravityWellFx` calls it everywhere.
	/// </summary>
	public static void Draw( Vector3 at, float radius, float seconds )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		seconds = MathF.Max( 0.3f, seconds );

		// ⚠️ HIS VORTEX, IN HIS COLOURS, AT THIS REACH — the rim is exactly the line the pull tests, as on him.
		Vortex.Spawn( at, radius, seconds );

		var model = Model.Load( HoleModel );
		if ( model is null || model.IsError )
		{
			if ( !_holeWarned )
			{
				_holeWarned = true;
				Log.Warning( $"[nz-ammo] gravity well: no '{HoleModel}' — the vortex shows, the hole does not" );
			}

			return;
		}

		// ⚠️ HIS SIZE, SHRUNK TO THIS REACH — and the lift his effects need, shrunk with it (`OberonBoss.FxLift`).
		var scale = radius / OberonReach * MathF.Max( 0.01f, HoleScale );

		var go = scene.CreateObject();
		go.Name = "nz_gravity_hole";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN (§39)
		go.WorldPosition = at + Vector3.Up * (OberonLift * scale);
		go.WorldRotation = Rotation.FromYaw( Game.Random.Float( 0f, 360f ) );
		go.WorldScale = scale;

		var r = go.Components.Create<SkinnedModelRenderer>();
		r.Model = model;
		r.Sequence.Name = HoleClip;
		r.Sequence.Time = 0f;

		// ⚠️ THE WHOLE CLIP INSIDE THE WELL'S LIFE: his hole runs it over nearly five seconds, and cut at two it would vanish
		// mid-swirl. Fitted once the renderer knows the clip's length (`GravityHoleClip`) — on the frame it is made it may not.
		// Event Horizon's 4s (2026-10-05) is fitted the same way: the life arrives here in the message.
		go.Components.Create<GravityHoleClip>().Life = seconds;

		SWB.Shared.GameObjectExtensions.DestroyAsync( go, seconds );

		_lastScale = scale;
		_lastLife = seconds;
	}

	/// <summary>The hole's clip length as last read, for the report.</summary>
	internal static float LastClip { get => _lastClip; set => _lastClip = value; }

	static bool _holeWarned;
	static float _lastClip, _lastScale;

	/// <summary>The life the last hole here was drawn for, for the report: Event Horizon makes it the owner's, not `Seconds`.</summary>
	static float? _lastLife;

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

	/// <summary>`nz_gravitywell` — the resolved numbers, the upgrades' and yours, and the hole as last drawn here.</summary>
	[ConCmd( "nz_gravitywell" )]
	public static void Report()
	{
		var mod = AmmoMods.Find( ModId );
		var model = Model.Load( HoleModel );

		Log.Info( $"[nz-ammo] GRAVITY WELL · {(mod?.Chance ?? 0f) * 100f:0.#}% per hit, {mod?.Cooldown ?? 0f:0.#}s cooldown"
			+ $" · {Radius:0}u for {Seconds:0.#}s · knot {Knot:0}u in {PullIn:0.##}s · deals NO damage below III" );

		var me = NZPlayer.Local;
		var level = AmmoModUpgrades.Level( me, ModId );
		var weapon = AmmoMods.WeaponDamage( me );

		// ⚠️ IV AND V (2026-10-06): Supernova's share, and Spaghettify's with its beat.
		Log.Info( $"[nz-ammo]   upgrades · I {StrongRadius:0}u · II {HorizonSeconds:0.#}s · III {CollapseShare * 100f:0}% of your"
			+ $" damage to each one held, as it closes · IV {SupernovaShare * 100f:0}% · V {SpaghettifyShare * 100f:0}% to each one"
			+ $" held every {SpaghettifyEvery:0.##}s" );

		Log.Info( $"[nz-ammo]   you: {(level == 0 ? "0" : HudTheme.ToRoman( level ))} → {RadiusFor( me ):0}u for {SecondsFor( me ):0.#}s,"
			+ $" collapse {CollapseFor( me, weapon ):0}, spaghettify {SpaghettifyFor( me, weapon ):0} every {SpaghettifyEvery:0.##}s" );

		Log.Info( $"[nz-ammo]   hole {(model is null || model.IsError ? "MISSING" : $"ok, bounds {model.Bounds.Size}")}"
			+ $" · drawn at ×{Radius / OberonReach * HoleScale:0.###} (his ×1 at {OberonReach:0}u, multiplier {HoleScale:0.##})"
			+ $" · last drawn here ×{_lastScale:0.###}, its {_lastClip:0.##}s clip played in {(_lastLife ?? Seconds):0.#}s" );
	}

	/// <summary>
	/// `nz_gravitywell_set &lt;key&gt; &lt;value&gt;` — retune one number live. IV and V (2026-10-06): `supernova`, `spaghettify` and
	/// `spaghettifyevery`; a well already open keeps the numbers it opened with.
	/// </summary>
	[ConCmd( "nz_gravitywell_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "radius": Radius = value; break;
			case "seconds": Seconds = value; break;
			case "knot": Knot = value; break;
			case "pullin": PullIn = value; break;
			case "hole": HoleScale = value; break;
			case "strong": StrongRadius = value; break;
			case "horizon": HorizonSeconds = value; break;
			case "collapse": CollapseShare = value; break;
			case "collapseshake": CollapseShake = value; break;
			case "collapserange": CollapseShakeRange = value; break;
			case "supernova": SupernovaShare = value; break;
			case "spaghettify": SpaghettifyShare = value; break;
			case "spaghettifyevery": SpaghettifyEvery = value; break;

			default:
				Log.Info( "[nz-ammo] nz_gravitywell_set <radius|seconds|knot|pullin|hole|strong|horizon|collapse|collapseshake"
					+ "|collapserange|supernova|spaghettify|spaghettifyevery> <value>" );
				return;
		}

		Report();
	}
}

/// <summary>
/// Plays the gravity well's hole clip whole inside the well's life: the rate is set the first frame the renderer reports the
/// clip's length, from the time that is left. A clip that never reports one plays at its own speed.
/// </summary>
public sealed class GravityHoleClip : Component
{
	/// <summary>How long the hole lives, from when it was made.</summary>
	public float Life { get; set; } = 2f;

	float _born;
	bool _done;

	protected override void OnStart() => _born = Time.Now;

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

		var r = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelf );
		if ( !r.IsValid() ) return;

		var length = r.Sequence.Duration;
		var age = Time.Now - _born;

		if ( length <= 0.05f )
		{
			// ⚠️ GIVEN HALF A SECOND TO REPORT, then left at its own speed rather than asked forever.
			if ( age > 0.5f ) _done = true;
			return;
		}

		var left = MathF.Max( 0.1f, Life - age );
		var played = Math.Clamp( r.Sequence.Time, 0f, length );

		r.PlaybackRate = MathF.Max( 0.25f, (length - played) / left );
		GravityWell.LastClip = length;
		_done = true;
	}
}