Player/PhdAugments.cs

Augment/perk logic for the PhD Flopper perk. It tracks fall state, handles ground-slam and double-jump inputs, computes blast damage/radius from the held weapon and augments, triggers explosions (including chained detonations), applies damage to zombies via Health.OnDamage, and provides console commands to report and retune values.

File AccessNetworking
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// PhD Flopper's augments. Base perk: explosive/fall damage negation, **plus a fall blast**.
///
/// | id | effect | status |
/// |----|--------|--------|
/// | base           | falling far enough detonates for **×5 the held weapon's damage** | ⚠️ NEW — replaced the dive |
/// | M1 Bigger Boom | that blast deals ×3 damage over a +50% radius | as the original |
/// | M2 Chain Blast | every blast this perk causes fires **3 times, 0.2s apart** | ⚠️ NEW — replaced Double Jump |
/// | M3 Kinetic Burst | a zombie hitting you **while sprinting** detonates | as the original |
/// | M4 Reactive Blast | taking damage **below 30% HP** detonates | as the original |
/// | m1 Ground Slam | crouch mid-air to **drop fast** and detonate on impact | ⚠️ retargeted — was "dive without the double jump" |
/// | m2 Trap Immunity | **(not implemented)** — no traps exist yet | ⛔ description only, by request |
/// | m3 Slide Bomb  | sliding detonates | as the original |
/// | m4 Hops        | **+30% jump height** | ⚠️ was m5's effect, retuned |
/// | m5 Double Jump | a second jump in mid-air | ⚠️ was M2, demoted to a minor |
///
/// ⛔ THE DIVE DOES NOT EXIST IN THIS PORT AND IS NOT BEING BUILT. Four of the original nine
/// augments scaled, triggered or extended a dive-slam that was never ported, so the pool was
/// four-ninths dead on arrival. The base effect is now a FALL blast — you already fall, so the
/// trigger exists for free — and every augment that referenced the dive was retargeted onto it.
///
/// ⚠️ ONE BLAST PRIMITIVE, FIVE TRIGGERS. The base fall, M3's sprint hit, M4's low-HP hit,
/// m1's slam and m3's slide all call <see cref="Blast"/>. That is deliberate: M1's scaling and
/// M2's chaining then apply to all five without being written five times, and "the explosion"
/// means one thing everywhere. Five bespoke explosions is the §3 shape, and here it would show
/// up as M1 scaling some of them.
///
/// ⚠️ DAMAGE IS READ OFF THE HELD WEAPON, so the blast scales with the gun, Pack-a-Punch and
/// every tech node — a late-round player is not detonating for pistol damage. It uses the same
/// `Damage × Bullets` product the stats card's Damage row uses, so the two agree about what
/// "the weapon's damage" means.
/// </summary>
public static class PhdAugments
{
	const string Perk = "phd";

	// ══ tuning ════════════════════════════════════════════════════════════════
	//
	// ⚠️ EVERY DEFAULT LIVES IN A GETTER OVER A NULLABLE FIELD, NOT AN INITIALISER. A field
	// initialiser runs once per session and hotload carries the VALUE forward, so changing a
	// default here would have no effect until a restart — which is exactly how Vigor Rush spent
	// an evening dealing ×2 while its source read `1.2f` (§1). These are numbers that will be
	// retuned repeatedly, so they are built to be retunable.

	static float? _fallThreshold;
	/// <summary>How far you must fall before it detonates, in units. 220 ≈ three storeys.</summary>
	public static float FallThreshold { get => _fallThreshold ?? 220f; set => _fallThreshold = value; }

	static float? _damageMultiple;
	/// <summary>Blast damage as a multiple of the held weapon's damage. ×5.</summary>
	public static float DamageMultiple { get => _damageMultiple ?? 5f; set => _damageMultiple = value; }

	static float? _radius;
	/// <summary>Blast radius in units. 260, matching Cranial Detonation's.</summary>
	public static float Radius { get => _radius ?? 260f; set => _radius = value; }

	static float? _noWeaponDamage;
	/// <summary>
	/// Blast damage when nothing is held, before the ×5.
	///
	/// ⚠️ NOT ZERO. An empty-handed player is rare but reachable — mid-swap, or a fresh
	/// Creative spawn — and a blast that silently does nothing reads as the perk being broken
	/// rather than as an edge case.
	/// </summary>
	public static float NoWeaponDamage { get => _noWeaponDamage ?? 40f; set => _noWeaponDamage = value; }

	static float? _biggerBoomDamage;
	/// <summary>M1 Bigger Boom — damage multiplier on the blast. ×3.</summary>
	public static float BiggerBoomDamage { get => _biggerBoomDamage ?? 3f; set => _biggerBoomDamage = value; }

	static float? _biggerBoomRadius;
	/// <summary>M1 Bigger Boom — radius multiplier. +50%.</summary>
	public static float BiggerBoomRadius { get => _biggerBoomRadius ?? 1.5f; set => _biggerBoomRadius = value; }

	static int? _chainCount;
	/// <summary>M2 Chain Blast — how many detonations per trigger. 3.</summary>
	public static int ChainCount { get => _chainCount ?? 3; set => _chainCount = value; }

	static float? _chainDelay;
	/// <summary>M2 Chain Blast — seconds between them. 0.2.</summary>
	public static float ChainDelay { get => _chainDelay ?? 0.2f; set => _chainDelay = value; }

	static float? _sprintCooldown;
	/// <summary>M3 Kinetic Burst — seconds between sprint detonations. 10.</summary>
	public static float SprintCooldown { get => _sprintCooldown ?? 10f; set => _sprintCooldown = value; }

	static float? _reactiveCooldown;
	/// <summary>M4 Reactive Blast — seconds between low-HP detonations. 15.</summary>
	public static float ReactiveCooldown { get => _reactiveCooldown ?? 15f; set => _reactiveCooldown = value; }

	static float? _reactiveThreshold;
	/// <summary>M4 Reactive Blast — the HP fraction below which a hit detonates. 30%.</summary>
	public static float ReactiveThreshold { get => _reactiveThreshold ?? 0.30f; set => _reactiveThreshold = value; }

	static float? _slamSpeed;
	/// <summary>m1 Ground Slam — downward speed the slam forces, units/sec. 1400.</summary>
	public static float SlamSpeed { get => _slamSpeed ?? 1400f; set => _slamSpeed = value; }

	static float? _jumpScale;
	/// <summary>m4 Hops — jump height multiplier. +30%.</summary>
	public static float JumpScale { get => _jumpScale ?? 1.3f; set => _jumpScale = value; }

	static float? _doubleJumpScale;
	/// <summary>
	/// m5 Double Jump — the second jump's strength, relative to the first.
	///
	/// ⚠️ 0.9, NOT 1.0. A mid-air jump with no ground contact to slow you reads as floaty at
	/// full strength; slightly weaker keeps it a recovery move rather than a flight mode.
	/// </summary>
	public static float DoubleJumpScale { get => _doubleJumpScale ?? 0.9f; set => _doubleJumpScale = value; }

	static bool Has( NZPlayer p, string augId )
		=> p.IsValid() && p.HasPerk( Perk ) && PerkAugments.Has( p, Perk, augId );

	// ══ the blast ═════════════════════════════════════════════════════════════

	/// <summary>
	/// Damage one detonation deals. ×5 the held weapon, ×3 again with M1.
	///
	/// ⚠️ `Damage × Bullets`, THE SAME PRODUCT THE STATS CARD'S DAMAGE ROW USES. A shotgun's
	/// per-pellet figure would make it detonate for a tenth of what the card says the gun does,
	/// and "the weapon's damage" has to mean one thing in both places.
	/// </summary>
	public static float BlastDamage( NZPlayer player )
	{
		if ( !player.IsValid() ) return 0f;

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

		var weaponDamage = si is not null && si.Damage > 0f
			? si.Damage * Math.Max( 1, si.Bullets )
			: NoWeaponDamage;

		var mult = DamageMultiple;
		if ( Has( player, "M1" ) ) mult *= BiggerBoomDamage;

		return weaponDamage * MathF.Max( 0f, mult );
	}

	/// <summary>Blast radius, +50% with M1.</summary>
	public static float BlastRadius( NZPlayer player )
		=> Radius * (Has( player, "M1" ) ? BiggerBoomRadius : 1f);

	/// <summary>How many detonations one trigger produces. 3 with M2.</summary>
	public static int BlastCount( NZPlayer player )
		=> Has( player, "M2" ) ? Math.Max( 1, ChainCount ) : 1;

	/// <summary>
	/// Detonate at a position. THE one entry point for all five triggers.
	///
	/// ⚠️ M2's CHAIN IS HANDLED HERE, not at the call sites, so every trigger chains. Putting
	/// it in the triggers would mean adding it five times and forgetting it once.
	///
	/// ⚠️ THE CHAIN RE-READS `BlastDamage` PER DETONATION rather than computing it once. A
	/// player who swaps weapons or packs one mid-chain should have the later blasts match the
	/// gun in hand — and more importantly the first blast can KILL the zombie whose death would
	/// change nothing, so re-reading costs nothing and cannot go stale.
	/// </summary>
	public static void Blast( NZPlayer player, Vector3 at, string reason )
	{
		if ( !player.IsValid() || !player.HasPerk( Perk ) ) return;

		var count = BlastCount( player );

		Detonate( player, at, reason, 1, count );

		if ( count > 1 ) ChainRest( player, at, reason, count );
	}

	/// <summary>
	/// The 2nd..Nth detonations of an M2 chain.
	///
	/// ⚠️ `GameTask.Delay( ms )`, THIS PROJECT'S DELAY IDIOM. `Task.DelaySeconds` does not
	/// exist here and adding `using System.Threading.Tasks` shadows `Sandbox.Task` and makes it
	/// worse — that is written down because it cost two build failures.
	///
	/// ⚠️ RE-VALIDATES THE PLAYER EVERY STEP. 0.4s is long enough to go down, lose the perk, or
	/// end the round, and an async continuation holding a stale reference is how this project
	/// got `NullReferenceException at Weapon.OnUpdate` spam once already.
	/// </summary>
	static async void ChainRest( NZPlayer player, Vector3 at, string reason, int count )
	{
		var ms = (int)(MathF.Max( 0.02f, ChainDelay ) * 1000f);

		for ( var i = 2; i <= count; i++ )
		{
			await GameTask.Delay( ms );

			if ( !player.IsValid() || !player.HasPerk( Perk ) ) return;

			Detonate( player, at, reason, i, count );
		}
	}

	/// <summary>
	/// One detonation.
	///
	/// ⚠️ THE SAME SHAPE AS `DeadshotAugments.Detonate` — walk `ZombieAI.All`, measure to a
	/// point 32u up from the feet, damage through `Health.OnDamage` with the player as attacker.
	/// Going through `OnDamage` rather than subtracting HP is what makes the kill count, the
	/// points pay and every on-kill augment fire.
	///
	/// ⛔ IT DOES NOT DAMAGE THE PLAYER. PhD's whole identity is immunity to your own
	/// explosions, and the base perk already blocks blast damage in `Health.Apply` — but
	/// relying on that would mean a player who somehow lost the perk mid-chain taking their own
	/// blast. Never adding them as a target is one less thing to be conditional about.
	/// </summary>
	static void Detonate( NZPlayer player, Vector3 at, string reason, int index, int total )
	{
		var damage = BlastDamage( player );
		var radius = BlastRadius( player );

		if ( damage <= 0f || radius <= 0f ) return;

		var hit = 0;

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;

			var target = z.WorldPosition + Vector3.Up * 32f;
			if ( at.Distance( target ) > radius ) continue;

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

			hp.OnDamage( new DamageInfo
			{
				Damage = damage,
				Attacker = player.GameObject,
				Position = target,
				Tags = new TagSet(),
			} );

			hit++;
		}

		Effect( at, radius );

		var step = total > 1 ? $" [{index}/{total}]" : "";

		Log.Info( $"[nz-aug] phd blast{step} — {reason} — {damage:0} to {hit} zombie(s)"
			+ $" within {radius:0}u" );
	}

	/// <summary>
	/// The visible half.
	///
	/// ⛔ `prefabs/engine/explosion_med.prefab`, THE SAME ONE THE GRENADE USES, AND ITS OWN
	/// NOTE EXPLAINS WHY: it is the ONLY explosion prefab that ships, and searching the whole
	/// asset system returns exactly one. My first attempt at this method invented
	/// `prefabs/effects/nz_explosion.prefab`, which does not exist — an unverified asset path
	/// fails silently at LOAD time with a green compile (§18), so it would have shipped as
	/// "the blast does damage but you cannot see it".
	///
	/// ⚠ A LIGHT AS WELL AS THE PARTICLE, for the reason `Grenade.Effect` records: most of a
	/// blast's impact in a dark map is the flash on the walls, and a fireball that leaves the
	/// geometry unlit reads as a sprite pasted over the scene.
	/// </summary>
	static void Effect( Vector3 at, float radius )
		=> BlastEffect.Spawn( at, radius );


	// ══ base — the fall ═══════════════════════════════════════════════════════

	/// <summary>
	/// Per-frame: track the fall, and run m1's slam and m5's double jump.
	///
	/// ⛔ CALLED FROM `NZPlayer.OnUpdate`, NOT FROM A COMPONENT OF ITS OWN. A component created
	/// at runtime is destroyed by a hotload and does not come back — `Slide` carries that exact
	/// warning and the fix there was `Components.GetOrCreate` from OnUpdate. A static called
	/// from the player's own tick has nothing to lose in the first place.
	///
	/// ⚠️ THE PEAK IS TRACKED WHILE AIRBORNE, NOT THE TAKEOFF HEIGHT. Jumping up off a ledge
	/// and falling should measure from the top of the arc, which is what the player sees as
	/// "how far I fell". Takeoff height would under-count every fall that started with a jump.
	/// </summary>
	public static void Tick( NZPlayer player )
	{
		if ( !player.IsValid() ) return;

		var c = player.Components.Get<PlayerController>();
		if ( !c.IsValid() ) return;

		var onGround = c.IsOnGround;
		var z = player.WorldPosition.z;

		if ( onGround )
		{
			// ⚠️ THE LANDING IS RESOLVED BEFORE THE STATE IS CLEARED, and the order matters:
			// clearing first would lose the peak this frame's blast is measured against.
			if ( player.PhdAirborne )
			{
				var fell = player.PhdFallPeak - z;

				if ( player.HasPerk( Perk )
					&& (player.PhdSlamming || fell >= FallThreshold) )
				{
					Blast( player, player.WorldPosition,
						player.PhdSlamming ? "ground slam" : $"fell {fell:0}u" );
				}

				// ⚠ BASALT SEAL 1: a Ground Slam that lands on one of this round's picked tiles lights it. Only the
				// slam counts, not a long fall. `HexPlatforms` reports the tile and the host decides.
				if ( player.PhdSlamming )
					HexPlatforms.OnSlamLanded( player, player.WorldPosition );
			}

			player.PhdAirborne = false;
			player.PhdSlamming = false;
			player.PhdFallPeak = z;
			player.PhdJumps = 0;
			return;
		}

		// ── airborne ──
		if ( !player.PhdAirborne )
		{
			player.PhdAirborne = true;
			player.PhdFallPeak = z;
		}

		if ( z > player.PhdFallPeak ) player.PhdFallPeak = z;

		TickSlam( player, c );
		TickDoubleJump( player, c );
	}

	/// <summary>
	/// m1 Ground Slam — crouch in mid-air to drop hard.
	///
	/// ⚠️ IT DOES NOT DETONATE IN THE AIR. The blast lands with the player, on impact, which is
	/// both what a slam is and what makes it aimable — detonating at the moment of the keypress
	/// would put the explosion wherever you happened to be, usually nowhere near the zombies
	/// you dived at.
	///
	/// ⚠️ IT SETS A DOWNWARD SPEED, IT DOES NOT ADD ONE. Adding would make a second crouch
	/// mid-slam twice as fast, and a third faster still.
	///
	/// ⚠️ THIS SHARES ITS INPUT WITH BANANA COLADA'S SLIDE CHAIN, and both firing is correct:
	/// the slam drops you, the blast goes off on impact, and Banana's chain resumes the slide
	/// from the landing. They compose rather than compete, so neither is gated on the other.
	/// </summary>
	static void TickSlam( NZPlayer player, PlayerController c )
	{
		if ( player.PhdSlamming ) return;
		if ( !Has( player, "m1" ) ) return;
		if ( !Input.Pressed( "Duck" ) ) return;

		player.PhdSlamming = true;

		// ⚠ THROUGH `c.Body`, NOT `c.Velocity`. `PlayerController.Velocity` is READ-ONLY;
		// `Slide` writes `_c.Body.Velocity` for the same reason, and that is the one place in
		// this project that already moves the player by hand.
		if ( c.Body.IsValid() )
			c.Body.Velocity = c.Body.Velocity.WithZ( -MathF.Abs( SlamSpeed ) );

		Log.Info( $"[nz-aug] phd m1 Ground Slam — dropping at {SlamSpeed:0}u/s" );
	}

	/// <summary>
	/// m5 Double Jump — one extra jump per airborne period.
	///
	/// ⚠️ COUNTS FROM 1, NOT 0, because leaving the ground already spent the first jump — and a
	/// player who WALKED off a ledge never jumped at all, so they get the mid-air one. Counting
	/// presses instead would silently grant a free extra jump to anyone stepping off a kerb.
	/// </summary>
	static void TickDoubleJump( NZPlayer player, PlayerController c )
	{
		if ( !Has( player, "m5" ) ) return;
		if ( player.PhdJumps >= 1 ) return;
		if ( !Input.Pressed( "Jump" ) ) return;

		player.PhdJumps++;

		// ⚠ SETS the upward speed rather than adding to it, so a double jump taken while still
		// rising is not a rocket, and one taken while falling fast is not swallowed by the
		// existing downward velocity.
		//
		// ⚠ `c.Body.Velocity`, because `PlayerController.Velocity` is read-only — see the note
		// in TickSlam.
		if ( c.Body.IsValid() )
			c.Body.Velocity = c.Body.Velocity
				.WithZ( ActiveConfig.Player.JumpPower * DoubleJumpScale );

		Log.Info( "[nz-aug] phd m5 Double Jump" );
	}

	/// <summary>
	/// m4 Hops — the jump-height multiplier, read at `NZPlayer`'s JumpSpeed write.
	///
	/// ⛔ A MULTIPLIER RETURNED, NOT A WRITE. `NZPlayer` recomputes `JumpSpeed` from the config
	/// every frame, so anything written here would be undone on the next tick — the same reason
	/// its own comment gives for zeroing jump while downed rather than swallowing the key.
	/// </summary>
	public static float JumpMultiplier( NZPlayer player )
		=> Has( player, "m4" ) ? MathF.Max( 0.1f, JumpScale ) : 1f;

	// ══ M3 / M4 — being hit ═══════════════════════════════════════════════════

	/// <summary>
	/// A zombie damaged this player. Runs M3 Kinetic Burst and M4 Reactive Blast.
	///
	/// ⚠️ RETURNS NOTHING AND CHANGES NOTHING ABOUT THE DAMAGE. Both augments are side effects
	/// of being hit, not reductions of it — Juggernog owns that axis.
	///
	/// ⚠️ BOTH CAN FIRE ON ONE HIT, and they should: sprinting into a hit at low HP is exactly
	/// the moment the player bought both augments for. Each has its own cooldown, so a build
	/// with both gets two blasts and then two independent waits.
	///
	/// ⚠️ THE HP TEST USES THE PRE-HIT FRACTION, because it is what the augment text describes
	/// ("taking damage below 30%") and because the post-hit value may be 0 — a killing blow
	/// would otherwise always qualify, which turns a defensive augment into a death rattle.
	/// </summary>
	public static void OnPlayerDamaged( NZPlayer victim, float amount )
	{
		if ( !victim.IsValid() || !victim.HasPerk( Perk ) || amount <= 0f ) return;

		var at = victim.WorldPosition;

		if ( Has( victim, "M3" )
			&& victim.IsRunning
			&& victim.PhdSprintReady <= 0f )
		{
			// ⚠ THROUGH `TimeAugments.Cooldown`, so Timeslip Tonic's m4 Time Warp shortens it.
			// That helper returns its input unchanged without the augment, which is what lets
			// every cooldown site wrap unconditionally instead of branching — and it makes an
			// UNWRAPPED site a visible omission rather than a silent one.
			victim.PhdSprintReady = TimeAugments.Cooldown( victim, SprintCooldown );
			Blast( victim, at, "kinetic burst (sprinting)" );
		}

		if ( !Has( victim, "M4" ) || victim.PhdReactiveReady > 0f ) return;

		var hp = victim.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
		if ( !hp.IsValid() || hp.Max <= 0f ) return;

		if ( hp.Current / hp.Max > ReactiveThreshold ) return;

		victim.PhdReactiveReady = TimeAugments.Cooldown( victim, ReactiveCooldown );
		Blast( victim, at, $"reactive blast ({hp.Current / hp.Max * 100f:0}% HP)" );
	}

	// ══ m3 — sliding ══════════════════════════════════════════════════════════

	/// <summary>
	/// m3 Slide Bomb — a slide started.
	///
	/// ⚠️ ON THE START, NOT PER FRAME. Banana Colada makes slides long and chainable, so a
	/// per-frame blast would be a continuous explosion for as long as the player held crouch.
	/// </summary>
	public static void OnSlideStarted( NZPlayer player )
	{
		if ( !Has( player, "m3" ) ) return;

		Blast( player, player.WorldPosition, "slide bomb" );
	}

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

	public static void Report( NZPlayer player )
	{
		if ( !player.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		var has = player.HasPerk( Perk );
		var equipped = PerkAugments.EquippedOn( player, Perk );

		Log.Info( $"[nz-aug] PhD FLOPPER {(has ? "owned" : "NOT OWNED — every line below is inert")}"
			+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]" );

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

		// ⚠️ RESOLVED NUMBERS, NOT MULTIPLIERS. "×5 of ×3" cannot be checked against anything;
		// "1350 damage over 390u" can.
		Log.Info( $"[nz-aug]  base blast      {BlastDamage( player ):0} damage"
			+ $" over {BlastRadius( player ):0}u"
			+ $" · after a {FallThreshold:0}u fall"
			+ $" · weapon {(si is null ? $"none (assumes {NoWeaponDamage:0})" : $"{si.Damage:0}x{Math.Max( 1, si.Bullets )}")}" );

		Log.Info( $"[nz-aug]  M1 Bigger Boom  {(Has( player, "M1" ) ? $"x{BiggerBoomDamage:0.##} damage, x{BiggerBoomRadius:0.##} radius" : "-")}" );
		Log.Info( $"[nz-aug]  M2 Chain Blast  {(Has( player, "M2" ) ? $"{ChainCount} blasts {ChainDelay:0.##}s apart" : "-")}"
			+ $"   this trigger fires {BlastCount( player )}x" );
		Log.Info( $"[nz-aug]  M3 Kinetic      {(Has( player, "M3" ) ? $"on a hit while sprinting, {SprintCooldown:0.#}s cd" : "-")}"
			+ $"   sprinting now {player.IsRunning}"
			+ $" · ready in {MathF.Max( 0f, player.PhdSprintReady ):0.0}s" );
		Log.Info( $"[nz-aug]  M4 Reactive     {(Has( player, "M4" ) ? $"on a hit below {ReactiveThreshold * 100f:0}% HP, {ReactiveCooldown:0.#}s cd" : "-")}"
			+ $"   ready in {MathF.Max( 0f, player.PhdReactiveReady ):0.0}s" );
		Log.Info( $"[nz-aug]  m1 Ground Slam  {(Has( player, "m1" ) ? $"crouch mid-air, {SlamSpeed:0}u/s" : "-")}"
			+ $"   airborne {player.PhdAirborne}"
			+ $" · slamming {player.PhdSlamming}" );
		Log.Info( "[nz-aug]  m2 Trap Immunity  NOT IMPLEMENTED — no traps exist yet" );
		Log.Info( $"[nz-aug]  m3 Slide Bomb   {(Has( player, "m3" ) ? "on" : "-")}" );
		Log.Info( $"[nz-aug]  m4 Hops         jump x{JumpMultiplier( player ):0.##}" );
		Log.Info( $"[nz-aug]  m5 Double Jump  {(Has( player, "m5" ) ? $"x{DoubleJumpScale:0.##} of a normal jump" : "-")}"
			+ $"   used {player.PhdJumps}/1 this airborne" );
	}

	/// <summary>`nz_aug_phd` — the resolved state of all nine.</summary>
	[ConCmd( "nz_aug_phd" )]
	public static void ReportCmd()
		=> Report( NZPlayer.Local );

	/// <summary>
	/// `nz_phd_blast` — detonate at the player, bypassing every trigger.
	///
	/// ⚠️ EXISTS BECAUSE ALL FIVE TRIGGERS NEED SOMETHING TO HAPPEN FIRST — a long fall, a
	/// zombie hit while sprinting, low HP. Testing M1's scaling or M2's chain through them means
	/// setting up the trigger every time, and a failure could be either half.
	/// </summary>
	[ConCmd( "nz_phd_blast" )]
	public static void BlastCmd()
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		if ( !p.HasPerk( Perk ) )
		{
			Log.Warning( "[nz-aug] PhD Flopper not owned — nz_perk_give phd" );
			return;
		}

		Blast( p, p.WorldPosition, "nz_phd_blast" );
	}

	/// <summary>`nz_phd_set` — retune live. Negative or omitted leaves a value alone.</summary>
	[ConCmd( "nz_phd_set" )]
	public static void SetCmd( float fall = -1f, float multiple = -1f, float radius = -1f,
		float boomDamage = -1f, float boomRadius = -1f, int chain = -1, float chainDelay = -1f,
		float sprintCd = -1f, float reactiveCd = -1f, float reactiveHp = -1f,
		float slam = -1f, float jump = -1f, float doubleJump = -1f )
	{
		if ( fall >= 0f ) FallThreshold = fall;
		if ( multiple >= 0f ) DamageMultiple = multiple;
		if ( radius >= 0f ) Radius = radius;
		if ( boomDamage >= 0f ) BiggerBoomDamage = boomDamage;
		if ( boomRadius >= 0f ) BiggerBoomRadius = boomRadius;
		if ( chain >= 1 ) ChainCount = chain;
		if ( chainDelay >= 0f ) ChainDelay = chainDelay;
		if ( sprintCd >= 0f ) SprintCooldown = sprintCd;
		if ( reactiveCd >= 0f ) ReactiveCooldown = reactiveCd;
		if ( reactiveHp >= 0f ) ReactiveThreshold = reactiveHp;
		if ( slam >= 0f ) SlamSpeed = slam;
		if ( jump >= 0f ) JumpScale = jump;
		if ( doubleJump >= 0f ) DoubleJumpScale = doubleJump;

		ReportCmd();
	}
}