Player/Knife.cs

Player component implementing a melee knife swing for NZombies. Handles input, timing (wind-up and recovery), viewmodel animation, swept-sphere tracing to find damageable targets, applying damage scaled by round and augments, sounds, and several developer console commands for testing and diagnostics.

NetworkingFile Access
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// THE KNIFE — V to swing, whatever you are holding.
///
/// Ported from the original's `nzSpecialWeapons:AddKnife`. There the knife is a real
/// weapon that gets deployed for ~0.7s and holstered back, because GMod's only way to
/// show a different viewmodel is to swap weapons. We have no knife viewmodel and no
/// reason to disturb the inventory, so this is a swing in place: a trace, damage, a
/// cooldown.
///
/// ⛔ DAMAGE IS THE TARGET'S MAX HEALTH DIVIDED BY THE ROUND, which makes the knife
/// take exactly N hits on round N — one on round 1, five on round 5. That is OURS,
/// not the original's flat 300, and it is a better rule for the same reason a flat
/// number is a worse one: zombie health scales every round, so any constant is a
/// one-hit-kill early and a rounding error later. This degrades on a line the player
/// can feel and predict.
///
/// ⚠️ NO LUNGE. The original yanks you toward the target at 800 u/s until you are
/// within 32 units. Deliberately skipped: it seizes the movement controller, and this
/// project already has a barricade vault and a downed crawl competing for that.
/// </summary>
public sealed class Knife : Component
{
	/// <summary>How far the swing reaches. Roughly a zombie's own attack range.</summary>
	[Property] public float Range { get; set; } = 80f;

	/// <summary>
	/// How wide the swing is, as a trace radius.
	///
	/// ⚠️ A SPHERE, NOT A RAY. A hitscan line demands you be looking exactly at a
	/// zombie, and a knife that misses a body filling half the screen reads as broken
	/// — the original does not even trace, it hands the swing to a weapon whose bash
	/// has its own generous box.
	/// </summary>
	[Property] public float Radius { get; set; } = 18f;

	/// <summary>
	/// Seconds between swings. The original's `AttackHolsterTime` for the BO1 knife.
	///
	/// ⚠️ MUST NOT BE SHORTER THAN <see cref="PreSwing"/> + <see cref="PostSwing"/>,
	/// or a second swing starts while the first is still on screen — the knife would
	/// snap back to frame 0 mid-arc and the strike of the first swing would land
	/// during the second. Clamped in <see cref="Swing"/> rather than trusted.
	/// </summary>
	[Property] public float Cooldown { get; set; } = 0.75f;

	/// <summary>
	/// Seconds from the keypress to the moment the blade actually connects.
	///
	/// ⛔ THE TRACE DOES NOT FIRE ON THE KEYPRESS. ARC9's own knife declares
	/// `PreBashTime = 0.25` / `PostBashTime = 0.5`, and that split is the whole feel
	/// of a melee weapon: the wind-up is a commitment you cannot take back, and the
	/// recovery is the punish for missing. Damage on the press instead makes the
	/// animation pure decoration — the hit registers before the blade has moved, so
	/// a zombie dies to a knife that is still behind the player's shoulder.
	/// </summary>
	[Property] public float PreSwing { get; set; } = 0.25f;

	/// <summary>Seconds of recovery after the blade connects. ARC9's `PostBashTime`.</summary>
	[Property] public float PostSwing { get; set; } = 0.5f;

	/// <summary>
	/// The clip played for a swing.
	///
	/// ⚠️ `swipe` is what the BO1 model calls it, and ARC9 maps its `bash` to exactly
	/// this ("bash" -> Source `swipe`, Time 1). The model also carries `stab`, which
	/// ARC9 uses for its heavy bash and backstab — we have neither.
	/// </summary>
	/// <summary>The viewmodel sequence a swing plays.
	///
	/// ⛔ "melee", NOT "swipe" — THE MODEL CHANGED AND THE NAMES WENT WITH IT. The
	/// ARC9 SOG knife this replaced carried nine sequences named
	/// idle/idle_long/draw/draw_fast/holster/holster_fast/swipe/stab/sprint_loop; the
	/// TFA BO2 knife carries three, named draw/melee/stick. A stale "swipe" here does
	/// not error — `KnifeViewModel.Show` looks the name up in `SequenceNames` and
	/// simply finds nothing — so the knife would swing silently and invisibly with
	/// every other part of the feature working.
	///
	/// ⚠️ Still a `[Property]`, so a future knife with different names is a field edit
	/// rather than a rebuild.</summary>
	[Property] public string SwingAnim { get; set; } = "melee";

	/// <summary>The sequence played when the target is inside <see cref="StabRange"/>.
	///
	/// ⚠️ "stick" is what the BO2 model calls its stab — the third of its three
	/// sequences (draw / melee / stick). Named for the animation, not for the verb.</summary>
	[Property] public string StabAnim { get; set; } = "stick";

	/// <summary>Inside this distance a swing becomes a stab. 0 disables it.
	///
	/// ⚠️ MEASURED TO THE HIT, not to the zombie's origin, so a big body counts as
	/// close when its shoulder is close — which is what "really close" looks like
	/// from behind the camera.
	///
	/// ⚠️ Under half the 80u reach, so the stab is genuinely point-blank rather than
	/// the default swing for anything in front of you.</summary>
	[Property] public float StabRange { get; set; } = 36f;

	/// <summary>
	/// A bonus applied to the FINAL figure, after max-health-over-round.
	///
	/// ⛔ ON TOP OF THE RESULT, NOT FOLDED INTO THE DIVISION. 0.1 means a swing that
	/// would take 20% of a zombie's health takes 22%. That edge is what stops the
	/// rule landing exactly on a whole number of hits: without it a round-5 zombie
	/// needs precisely 5 swings and the fifth leaves it on 0.0 health, which is a
	/// coin-flip against float rounding. With it the last swing always overkills.
	/// </summary>
	[Property] public float Bonus { get; set; } = 0.1f;

	TimeUntil _ready;

	// ── the swing in progress ────────────────────────────────────────────────
	bool _swinging;
	bool _struck;
	TimeSince _sinceSwing;

	/// <summary>Is the swing off cooldown?</summary>
	public bool Ready => _ready;

	/// <summary>Is a swing on screen right now?</summary>
	public bool Swinging => _swinging;

	/// <summary>Bled out: no body in the world and the camera on somebody else (`SpectateOthers`), so no swing either (2026-10-05).</summary>
	bool SittingOut => Components.Get<NZPlayer>() is { IsOutOfRound: true };

	KnifeViewModel ViewModel => Components.GetOrCreate<KnifeViewModel>();

	protected override void OnUpdate()
	{
		// ⛔ NO KNIFE IN CREATIVE, AND THIS IS A MARKER FIX, NOT A GAMEPLAY ONE.
		// The first swing creates the viewmodel camera (KnifeViewModel.EnsureCamera)
		// at Priority 2 with RenderTags { ViewModel, Light }. From then on it is
		// the highest-priority camera in the scene and it draws ONLY viewmodel
		// geometry — so every world-space DebugOverlay mark vanishes: spawn
		// markers, debris outlines, the barrier corner nodes, nav links, navmesh
		// dots. That is the "sometimes the orange nodes stop being visible" bug.
		//
		// ⚠️ Lowering that camera's priority is NOT the alternative — it takes the
		// player's movement with it, confirmed in play. Not creating the camera in
		// the first place is the one lever that costs nothing, and Creative is
		// exactly where the knife is useless and the markers matter.
		//
		// ⚠️ ONLY STOPS IT BEING MADE. A camera created during a round survives
		// into Creative — nz_vm_clear removes it without restarting.
		if ( NZGame.IsCreative ) { TickSwing(); return; }

		if ( Input.Pressed( "Knife" ) && !SittingOut ) Swing();
		TickSwing();
	}

	/// <summary>
	/// Advance the swing: strike at <see cref="PreSwing"/>, put the knife away at
	/// the end.
	///
	/// ⚠️ DRIVEN FROM A CLOCK, NOT FROM THE ANIMATION'S OWN PROGRESS. Reading
	/// `Sequence.Time` would tie the damage to the clip, which is the more elegant
	/// idea and the wrong one here: the model may be missing (the swing still has to
	/// work), the clip's length is whatever the BO1 animator chose, and ARC9's
	/// 0.25/0.5 split is a TUNED gameplay value that happens to sit inside it. Tying
	/// them together would mean a re-export could silently change the timing of the
	/// hit.
	/// </summary>
	void TickSwing()
	{
		if ( !_swinging ) return;

		// ⛔ RE-ASSERTED EVERY FRAME, NOT SET ONCE AT THE START. A weapon that
		// DEPLOYS mid-swing sets its own `ShouldDraw = true` on the way in — so a
		// gun handed over, bought off a wall or returned from Pack-a-Punch during
		// those 0.75s puts itself back on screen next to the knife. Hiding is a
		// state that holds for the duration, not an event at the start of it.
		ShowGuns( false );

		if ( !_struck && _sinceSwing >= PreSwing )
		{
			_struck = true;

			var msg = Strike();
			if ( !string.IsNullOrEmpty( msg ) ) Log.Info( $"[nz] {msg}" );
		}

		if ( _sinceSwing < PreSwing + PostSwing ) return;

		_swinging = false;
		ViewModel.Hide();
		ShowGuns( true );
	}

	/// <summary>
	/// Hide or restore the held weapon's viewmodel for the duration of the swing.
	///
	/// ⛔ VIA `ShouldDraw`, NOT BY DISABLING THE WEAPON. Disabling is how this project
	/// expresses HOLSTERING — it runs `OnCarryStop`, tears down attachment HUD
	/// elements and re-runs deploy on the way back — and putting a gun through all
	/// of that twice for a 0.75s animation is how the two-slot inventory broke the
	/// first time. `ShouldDraw` is the handler's own "is this on screen" flag and
	/// costs nothing to flip.
	///
	/// ⚠️ RESTORES EVERY WEAPON, HIDES ONLY THE ACTIVE ONE. If the player switches
	/// slots mid-swing, restoring just the one we hid would strand the other
	/// invisible until something else redeployed it. Setting the flag on a holstered
	/// weapon is harmless: its handler is disabled, so nothing draws, and re-deploy
	/// sets the flag itself.
	/// </summary>
	void ShowGuns( bool show )
	{
		var inv = Components.Get<NZInventory>( FindMode.EverythingInSelf );
		if ( !inv.IsValid() ) return;

		foreach ( var go in inv.Weapons )
		{
			if ( !show && go != inv.Active ) continue;

			// ⚠️ `EverythingInSelf` — a holstered weapon is DISABLED, and the plain
			// `Get<T>()` silently skips disabled components. That omission has cost
			// this project a free Pack-a-Punch level and an unbuyable wall gun.
			var wep = go.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf );
			var handler = wep?.ViewModelHandler;
			if ( handler.IsValid() ) handler.ShouldDraw = show;
		}
	}

	/// <summary>
	/// Swing once. Returns what happened, for the log and the console command.
	///
	/// ⚠️ WORKS WHILE DOWNED, which is the original's own rule — the button handler
	/// reads `(ply:GetNotDowned() or id == "knife")`, making the knife the one special
	/// weapon a crawling player still has. Taking it away would leave a downed player
	/// with literally nothing to do.
	/// </summary>
	public string Swing()
	{
		if ( !_ready ) return "";

		// ⛔ THE COOLDOWN CANNOT BE SHORTER THAN THE SWING. Taken as the larger of
		// the two rather than trusting the property: `Cooldown` is [Property] and
		// editable in the inspector, and a value below Pre+Post would let a second
		// swing begin mid-arc — the knife snapping to frame 0 while the first
		// strike is still pending.
		_ready = System.MathF.Max( Cooldown, PreSwing + PostSwing );

		var player = Components.Get<NZPlayer>();
		if ( !player.IsValid() ) return "no player";

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

		// ⛔ THE SWING CANCELS A RELOAD IN PROGRESS. Without this the reload kept running
		// invisibly behind the knife — `ShowGuns( false )` hides the weapon, not its timer — so
		// the magazine filled during a swing the player made INSTEAD of reloading, and a gun
		// hidden mid-reload came back either already loaded or snapping through the end of a
		// clip it had finished playing to nobody.
		//
		// ⚠️ HERE, NOT IN `Strike`. The damage lands `PreSwing` later and may miss entirely;
		// the decision to knife is made at the swing, and that is what interrupts the reload —
		// the same argument the `b_attack` gesture below is placed on.
		//
		// ⚠️ `EverythingInSelf` FOR THE SAME REASON `ShowGuns` USES IT — a holstered weapon is
		// disabled and a plain Get skips it. The ACTIVE weapon is the one reloading, but the
		// lookup has to be able to see a disabled component to find it at all.
		var active = Components.Get<NZInventory>( FindMode.EverythingInSelf )?.Active;
		var heldWep = active.IsValid()
			? active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
			: null;

		if ( heldWep.IsValid() && heldWep.IsReloading )
		{
			heldWep.CancelAnyReload();
			Log.Info( $"[nz-knife] cancelled {heldWep.DisplayName}'s reload" );
		}

		NZSound.Play( NZSound.Knife, eye );

		_swinging = true;
		_struck = false;
		_sinceSwing = 0f;

		// ⚠️ A PANZER'S CLAW (2026-10-06): the knife is the way out of it — every swing counts, hit or miss (`PanzerGrab.OnKnife`)
		var grab = Components.Get<PanzerGrab>( FindMode.EverythingInSelf );
		if ( grab.IsValid() ) grab.OnKnife();

		// ⚠️ THE MIMIC'S TENTACLE (2026-10-07): the same way out of it, its own count (`MimicGrab.OnKnife`)
		var tentacle = Components.Get<MimicGrab>( FindMode.EverythingInSelf );
		if ( tentacle.IsValid() ) tentacle.OnKnife();

		// ⛔ THE SWING EXISTED ONLY ON THE VIEWMODEL. `ViewModel.Show` below picks the stab or the
		// swing clip and plays it in first person; the body did nothing, so to everybody else a
		// knifing player just stood still and zombies died. `b_attack` is the engine's own gesture
		// for this — Sandbox.Engine.xml documents it as what a weapon fires on the holder "once per
		// trigger pull".
		//
		// ⚠️ SENT FROM THE SWING, NOT FROM `Strike`. The damage lands PreSwing later and may miss;
		// the gesture is the swing itself and should play either way. The comment below already
		// says the animation is a PREDICTION — this is the same prediction, shared.
		//
		// ⛔ A SWING, NOT A BARE `b_attack` (2026-10-05): alone it played the GUN's attack, the body still being in the gun's hold,
		// so a third-person knife was a recoiling rifle. `ThirdPersonWeapon.MeleeGesture` is the melee hold, the knife in the hand
		// and the swing, on every machine.
		NZNet.PlayerAnim( GameObject.Id, ThirdPersonWeapon.MeleeGesture );

		ShowGuns( false );

		// ⛔ THE ANIMATION IS CHOSEN HERE AND THE DAMAGE LANDS `PreSwing` LATER, SO
		// THIS IS A PREDICTION. A zombie can close the gap or step out in those 0.25s
		// and the clip will already be playing — unavoidable, because an animation
		// cannot be picked after the fact. The probe deliberately runs against the
		// SAME rule `Strike` will use, so the two disagree only when something moved,
		// never because they measure differently.
		//
		// ⚠️ COSTS ONE EXTRA TRACE PER SWING, which is a sweep against a handful of
		// bodies once every 0.75s at most. Caching the result to reuse in `Strike`
		// was rejected: it would freeze the target list at wind-up and let you damage
		// a zombie that had already walked away.
		var fwd = (controller?.EyeAngles.ToRotation() ?? WorldRotation).Forward;

		var close = StabRange > 0f
			&& Probe( Range * WidowAugments.RangeScale( player ), eye, fwd,
				out var probeHit, out _, out _ ).IsValid()
			&& probeHit.Distance <= StabRange;

		ViewModel.Show( close ? StabAnim : SwingAnim, PreSwing + PostSwing );

		// ⚠️ The OUTCOME is not known yet — the blade lands in PreSwing seconds, and
		// `Strike` logs what it finds when it gets there.
		return "knife — swinging";
	}

	/// <summary>
	/// The blade connecting: trace, damage, impact sound. Called once per swing,
	/// <see cref="PreSwing"/> seconds in.
	///
	/// ⚠️ AIM IS READ HERE, NOT AT THE KEYPRESS. The player can still turn during
	/// the wind-up, and a melee weapon that locks its direction the instant you press
	/// the key feels like it fires backwards when you are circling a horde — which is
	/// the whole reason you are knifing in the first place.
	/// </summary>
	/// <summary>
	/// The nearest DAMAGEABLE thing in the swing volume, or null. World geometry is
	/// passed through rather than stopping the sweep.
	///
	/// ⛔ `RunAll`, AND THE WORLD IS SKIPPED RATHER THAN ALLOWED TO STOP THE SWING.
	/// "Knifing with my back to a wall hits nothing" was this: the swing is a swept
	/// SPHERE of radius 18, and a sphere sweep that BEGINS inside geometry starts
	/// solid and resolves at fraction 0. Standing against a wall puts that wall
	/// within 18 units of the eye, so EVERY swing resolved against the wall at zero
	/// distance and the zombie in front was never reached.
	///
	/// ⚠️ `WithoutTags` DID NOT COVER THIS AND COULD NOT. That list is what BULLETS
	/// pass through — barricades, playerclip, triggers — and an ordinary solid wall
	/// is deliberately not in it. It fixed swinging through boards; it does nothing
	/// for a wall you are pressed against.
	///
	/// ⚠️ "IGNORE THE WORLD" IS EXPRESSED AS "HAS NO `Health`", NOT AS A TAG LIST.
	/// Tags would need `solid`/`world` reliably authored on every map and never on a
	/// zombie; Collision.config shows both carrying overlapping tags. "Can it be
	/// damaged" is the property the knife actually cares about, and it stays correct
	/// for props, ragdolls and anything added later.
	///
	/// ⛔ ONE COPY, TWO CALLERS. `Swing` asks it which animation to play and `Strike`
	/// asks it what to damage. A second trace written for the animation would be a
	/// second definition of "what counts as a hit" — and the two would drift, so the
	/// knife would stab at things it then failed to cut.
	/// </summary>
	Health Probe( float reach, Vector3 eye, Vector3 fwd,
		out SceneTraceResult hit, out SceneTraceResult firstWorld, out bool anyWorld )
	{
		hit = default;
		firstWorld = default;
		anyWorld = false;

		var hits = Scene.Trace
			// ⚠ m5 LONG REACH SCALES THE TRACE, not the `Range` property. That is an
			// authored `[Property]`, so assigning it would leave the longer reach behind
			// after the perk was lost — the trap `NZInventory.HolsterTime` documents.
			.Ray( eye, eye + fwd * reach )
			.Radius( Radius )
			// ⛔ THE SAME IGNORE LIST THE BULLETS USE, NOT A COPY OF IT. Reusing
			// Weapon.BulletTraceIgnoreTags means anything later marked shoot-through is
			// knife-through automatically, rather than needing to be remembered twice.
			.WithoutTags( SWB.Base.Weapon.BulletTraceIgnoreTags )

			// ⛔ NEVER OTHER PLAYERS. The knife damaged whatever had a `Health` component in
			// front of it, and a team-mate has one. User: *"players can damage eachother by
			// knifing."*
			//
			// ⚠️ EXCLUDED FROM THE TRACE RATHER THAN CHECKED AT THE HIT. If the swing merely
			// refused to damage a player it would still STOP on them, so a team-mate standing
			// between you and a zombie would armour it. Passing through is the behaviour a co-op
			// game wants and it removes the friendly-fire case at the same time.
			//
			// ⚠️ `IgnoreGameObjectHierarchy` ALREADY COVERED MY OWN BODY and nothing else — it
			// takes one hierarchy, and the other player is a different one.
			.WithoutTags( "player" )
			.IgnoreGameObjectHierarchy( GameObject )
			.RunAll();

		// ⚠️ SORTED BY DISTANCE EXPLICITLY. `RunAll` is not documented to return hits
		// in order, and "nearest zombie" is not a detail — an unsorted list would make
		// the knife pick whichever of two overlapping zombies the physics scene
		// happened to list first, which is stable enough to look correct in testing
		// and wrong in a horde.
		foreach ( var h in hits.OrderBy( h => h.Distance ) )
		{
			if ( !h.Hit || !h.GameObject.IsValid() ) continue;

			// ⚠️ Ancestors too: the collider hit is usually a hitbox or a child body,
			// not the object carrying Health. The same lookup the bullet path uses.
			var found = h.GameObject.Components
				.Get<Health>( FindMode.EverythingInSelfAndAncestors );

			if ( found.IsValid() )
			{
				hit = h;
				return found;
			}

			if ( !anyWorld )
			{
				firstWorld = h;
				anyWorld = true;
			}
		}

		return null;
	}

	string Strike()
	{
		var player = Components.Get<NZPlayer>();
		if ( !player.IsValid() ) return "no player";

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

		// ⚠️ THE TRACE RULE LIVES ON `Probe`, NOT HERE — world geometry is passed
		// through rather than stopping the sweep, and `Swing` uses the same call to pick
		// between the swing and the stab. See its remarks for why a wall used to eat
		// every swing.
		var hp = Probe( Range * WidowAugments.RangeScale( player ), eye, fwd,
			out var tr, out var worldHit, out var hitWorld );

		if ( !hp.IsValid() )
		{
			// ⚠️ A MISS KEEPS THE WHOOSH AND ADDS NOTHING. Air is the one outcome with no
			// impact — the swing sound already played, which is the feedback that the
			// input registered.
			if ( !hitWorld ) return "knife — missed";

			// ⛔ HITTING THE WORLD USES THE SURFACE'S OWN IMPACT, not a knife sound.
			// Every material already carries the right noise and the bullet path
			// already asks for it — a single generic "clang" would make wood, metal
			// and concrete indistinguishable, when the surface system is sitting
			// right there knowing the answer.
			SWB.Base.Weapon.CreateBulletImpact( worldHit );
			return $"knife — hit {worldHit.GameObject.Name}"
				+ $" ({worldHit.Surface?.ResourceName ?? "?"})";
		}

		// ⚠️ Flesh, LAYERED OVER the swing rather than replacing it — the whoosh
		// already happened, and cutting it short to substitute an impact is what
		// makes melee feel disconnected from the animation.
		NZSound.Play( NZSound.KnifeFlesh, tr.HitPosition );

		// ⚠ M2 ASSASSIN AND m2 HEAVY HANDS BOTH LAND HERE, on the one figure the swing uses,
		// so the cleave below carries the same number rather than recomputing it.
		// ⚠️ AND PISTOL WHIP'S ×3 (handgun tier 3, 2026-10-04) while a gun that owns it is in hand, multiplied with Heavy Hands.
		float damage = WidowAugments.KnifeDamage( player, DamageFor( hp ), hp.Max ) * ClassTech.MeleeScale( player );

		// ⛔ `Attacker` WAS NEVER SET, AND THAT WAS A REAL BUG. Without it every on-kill augment
		// saw a null killer, so a knife kill credited nobody — Widow's own m3 and m4 could not
		// have worked, and neither could Deadshot's or Vigor's on a melee kill. Found while
		// wiring this perk; it has been true for as long as the knife has existed.
		var info = new DamageInfo
		{
			Damage = damage,
			Attacker = GameObject,
			Position = tr.HitPosition,
		};

		// ⛔ TAGGED `melee`, WHICH IS THE WHOLE INTEGRATION. Health.LastHitWasMelee
		// reads this tag and the scoring already pays `PointsKillKnife` (130) off it
		// — both were written months ago with a comment saying "any future knife just
		// adds melee". Without the tag the knife scores like a bullet.
		info.Tags?.Add( "melee" );

		hp.OnDamage( info );

		// ⚠ AFTER the damage, so a cleaved zombie cannot be webbed by a swing that killed the
		// primary target first — and so `LastHitWasMelee` is already latched for the kill hooks.
		WidowAugments.OnKnifeHit( player, tr.GameObject, tr.HitPosition, damage );

		// ⚠ Napalm Nectar's m3 Hot Blade. Beside Widow's hook because both are "what a knife hit
		// does beyond damage", and both need the swing to have already landed.
		FireAugments.OnKnifeHit( player, tr.GameObject, tr.HitPosition );

		return $"knife — {damage:0} to {tr.GameObject.Name} "
			+ $"({hp.Current:0}/{hp.Max:0} left)";
	}

	/// <summary>
	/// Max health divided by the current round.
	///
	/// ⛔ ROUND, NOT A DAMAGE CONSTANT. On round N the knife takes exactly N hits,
	/// whatever the health curve is doing — so it is a guaranteed opener on round 1
	/// and a last resort by round 10, with no separate number to retune when zombie
	/// health changes.
	///
	/// ⚠️ Round clamped to at least 1. It reads 0 in the lobby and before the first
	/// round starts, and dividing by that is either an exception or an infinity —
	/// both of which would first show up as a knife that deletes a boss.
	/// </summary>
	public float DamageFor( Health target )
	{
		int round = System.Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
		return target.Max / round * (1f + Bonus);
	}

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

	static Knife Of()
	{
		var player = NZPlayer.Local;
		return player.IsValid() ? player.Components.GetOrCreate<Knife>() : null;
	}

	/// <summary>
	/// Swing from the console: `nz_knife [world|air]`.
	///
	/// ⚠️ AIMS AT THE NEAREST ZOMBIE BY DEFAULT, because a swing that misses proves
	/// nothing about the part worth testing — the damage, the melee tag and the flesh
	/// sound all live behind a hit. `nz_pap_shoot` needed exactly this and for the
	/// same reason: a miss and a broken feature look identical in the log.
	///
	/// ⛔ THE MODES EXIST BECAUSE THE KNIFE HAS THREE OUTCOMES AND ONLY ONE OF THEM
	/// WAS EVER REACHABLE FROM THE CONSOLE. `world` looks at the floor and `air`
	/// looks at the sky, so hit / world / miss can each be fired on demand. The
	/// sounds were wrong for weeks behind a command that could only ever produce a
	/// hit — a diagnostic that can reach one branch is a diagnostic that certifies
	/// one branch.
	/// </summary>
	[ConCmd( "nz_knife" )]
	public static void Cmd( string mode = "" )
	{
		var k = Of();
		if ( k is null ) { Log.Warning( "[nz] no player" ); return; }

		var player = NZPlayer.Local;
		var controller = player?.Components.Get<PlayerController>();

		mode = mode?.ToLowerInvariant() ?? "";

		if ( mode is "world" or "air" )
		{
			// Straight down finds the floor on any map; straight up finds nothing on
			// almost all of them. No zombie is moved — the point is to MISS one.
			if ( controller.IsValid() )
				controller.EyeAngles = new Angles( mode == "world" ? 89f : -89f, 0f, 0f );

			var m = k.Swing();
			Log.Info( string.IsNullOrEmpty( m ) ? "[nz] knife — on cooldown" : $"[nz] {m}" );
			return;
		}

		var z = ZombieAI.All
			.Where( x => x.IsValid() && x.State != ZombieState.Dead )
			.OrderBy( x => x.WorldPosition.DistanceSquared( player.WorldPosition ) )
			.FirstOrDefault();

		if ( z.IsValid() && controller.IsValid() )
		{
			// Chest height, and the zombie is brought into reach — the command exists
			// to test the swing, not the walk.
			var eye = controller.EyePosition;
			z.WorldPosition = eye + controller.EyeAngles.ToRotation().Forward * (k.Range * 0.5f)
				- Vector3.Up * 40f;

			var target = z.WorldPosition + Vector3.Up * 40f;
			controller.EyeAngles = Rotation.LookAt( (target - eye).Normal ).Angles();
		}

		var msg = k.Swing();
		Log.Info( string.IsNullOrEmpty( msg ) ? "[nz] knife — on cooldown" : $"[nz] {msg}" );
	}

	/// <summary>
	/// Everything about the swing right now: `nz_knife_state`.
	///
	/// ⛔ PRINTS THE GUN'S `ShouldDraw` PER SLOT, which is the half a screenshot
	/// cannot tell you apart. A frame showing the gun and no knife has two possible
	/// causes — the hide failed, or the blade is simply mid-wind-up and off camera —
	/// and they look identical. This distinguishes them.
	/// </summary>
	[ConCmd( "nz_knife_state" )]
	public static void State()
	{
		var k = Of();
		if ( k is null ) { Log.Warning( "[nz] no player" ); return; }

		Log.Info( $"[nz] knife: swinging={k._swinging} struck={k._struck} "
			+ $"t={(float)k._sinceSwing:0.##}s of {k.PreSwing + k.PostSwing:0.##}s "
			+ $"(strike at {k.PreSwing:0.##})" );

		var inv = k.Components.Get<NZInventory>( FindMode.EverythingInSelf );
		if ( !inv.IsValid() ) { Log.Warning( "[nz]   no inventory" ); return; }

		int n = 0;
		foreach ( var go in inv.Weapons )
		{
			var wep = go.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf );
			var h = wep?.ViewModelHandler;

			Log.Info( $"[nz]   slot {n++}: {go.Name}"
				+ $"  active={(go == inv.Active)}"
				+ $"  weapon={(wep is null ? "NULL" : "ok")}"
				+ $"  handler={(h is null ? "NULL" : $"ShouldDraw={h.ShouldDraw}")}"
				+ $"  renderer={(wep?.ViewModelRenderer is null ? "NULL" : wep.ViewModelRenderer.RenderType.ToString())}" );
		}

		if ( n == 0 ) Log.Info( "[nz]   inventory is EMPTY — nothing to hide" );
	}

	/// <summary>
	/// Stretch or restore the swing: `nz_knife_time [pre] [post]`, no args to reset.
	///
	/// ⛔ EXISTS TO MAKE A 0.75s EVENT OBSERVABLE. Every check I can make costs a
	/// round trip slower than the whole swing, so at shipping speed the knife is
	/// always either not-yet-started or already-finished — I would be certifying the
	/// end state and calling it the animation. Stretched to 3s + 6s the wind-up,
	/// strike and recovery can each be sampled, and the numbers found there are the
	/// same numbers at speed.
	///
	/// ⚠️ Also the tuning knob: `PreSwing` is how long the blade takes to land, which
	/// is the single value that decides whether the knife feels heavy or twitchy.
	/// </summary>
	[ConCmd( "nz_knife_time" )]
	public static void SetTiming( float pre = 0.25f, float post = 0.5f )
	{
		var k = Of();
		if ( k is null ) { Log.Warning( "[nz] no player" ); return; }

		k.PreSwing = pre;
		k.PostSwing = post;

		Log.Info( $"[nz] knife timing: {pre:0.##}s wind-up + {post:0.##}s recovery "
			+ $"= {pre + post:0.##}s per swing" );
	}

	/// <summary>
	/// What a swing would do right now: `nz_knife_info`.
	///
	/// ⚠️ Prints the HITS-TO-KILL, not just the damage. "417 damage" says nothing on
	/// its own — the whole point of the rule is how many swings it takes, and that is
	/// the number worth checking against the round.
	/// </summary>
	[ConCmd( "nz_knife_info" )]
	public static void Info()
	{
		var k = Of();
		if ( k is null ) { Log.Warning( "[nz] no player" ); return; }

		int round = System.Math.Max( 1, RoundManager.Instance?.Round ?? 1 );

		var z = ZombieAI.All.FirstOrDefault( x => x.IsValid() && x.State != ZombieState.Dead );
		var hp = z.IsValid() ? z.Components.Get<Health>() : null;

		if ( !hp.IsValid() )
		{
			Log.Info( $"[nz] knife: round {round}, reach {k.Range:0}u r{k.Radius:0}, "
				+ $"{k.Cooldown:0.##}s between swings — no zombie to measure against" );
			return;
		}

		float dmg = k.DamageFor( hp );
		Log.Info( $"[nz] knife: round {round}, {dmg:0} damage vs {hp.Max:0} max "
			+ $"= {System.MathF.Ceiling( hp.Max / System.MathF.Max( dmg, 1f ) ):0} hit(s) to kill" );
	}

	/// <summary>
	/// `nz_knife_trace` — every hit in the swing volume, in order, saying which one the
	/// knife takes and which are passed through.
	///
	/// ⛔ THIS IS THE COMMAND THAT WOULD HAVE FOUND THE WALL BUG IN ONE RUN. From the
	/// player's side "the knife does nothing here" fits a cooldown, damage, animation or
	/// trace problem equally well. One line reading `[0] SOLID world 0.0u` answers it.
	///
	/// ⚠️ IT DOES NOT SWING — no cooldown spent, no damage dealt — so it can be run
	/// standing in the exact spot that misbehaves.
	/// </summary>
	[ConCmd( "nz_knife_trace" )]
	public static void TraceCmd()
	{
		var k = Of();
		if ( k is null ) { Log.Warning( "[nz-knife] no player" ); return; }

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

		var hits = k.Scene.Trace
			.Ray( eye, eye + fwd * k.Range )
			.Radius( k.Radius )
			.WithoutTags( SWB.Base.Weapon.BulletTraceIgnoreTags )
			.IgnoreGameObjectHierarchy( k.GameObject )
			.RunAll()
			.OrderBy( h => h.Distance )
			.ToArray();

		Log.Info( $"[nz-knife] sweep reach {k.Range:0}u radius {k.Radius:0}u"
			+ $" · stab inside {k.StabRange:0}u ({k.StabAnim}), else {k.SwingAnim}"
			+ $" · {hits.Length} hit(s)" );

		if ( hits.Length == 0 )
		{
			Log.Info( "[nz-knife]   nothing in the volume — a swing here would MISS" );
			return;
		}

		var taken = false;

		for ( int i = 0; i < hits.Length; i++ )
		{
			var h = hits[i];
			if ( !h.Hit || !h.GameObject.IsValid() ) { Log.Info( $"[nz-knife]   [{i}] <invalid>" ); continue; }

			var hp = h.GameObject.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );

			// ⚠️ `StartedSolid` IS PRINTED because it is the whole mechanism. Seeing
			// `SOLID` next to `0.0u` turns "the knife is broken" into "the sphere starts
			// inside the wall".
			// ⚠️ Says WHICH ANIMATION this hit would produce, because "the stab never
			// plays" and "the stab range is wrong" look identical from the player's
			// side — one number next to the distance separates them.
			var mark = hp.IsValid()
				? ( taken
					? "  damageable"
					: $"  <- TAKEN, plays {( h.Distance <= k.StabRange ? k.StabAnim : k.SwingAnim )}" )
				: "  passed through (no Health)";

			if ( hp.IsValid() ) taken = true;

			Log.Info( $"[nz-knife]   [{i}] {h.GameObject.Name,-26} {h.Distance,6:0.0}u"
				+ $"{( h.StartedSolid ? "  SOLID" : "       " )}"
				+ $"  {h.Surface?.ResourceName ?? "-",-14}{mark}" );
		}

		if ( !taken )
			Log.Info( "[nz-knife]   no damageable hit — a swing here would only clang" );
	}
}