Zombies/AvogadroBoss.cs

Avogadro boss and its bolt projectile for NZombies. Implements boss behavior (teleports/dashes, jumps, bolts, shockwave, stun on melee), movement, animation/clip syncing, networking hooks for effects, and the AvogadroBolt flight, collision and blast logic.

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

namespace NZombies;

/// <summary>
/// Avogadro — the electric man, ported 2026-10-06 from GMod nZombies' `nz_zombie_boss_avogadro` (GhostlyMoo's port of Alpha
/// Omega's). The user picked him with the Astronaut, the Director and the Panzer Soldat, after the Margwa.
///
/// | | a melee hit, once every <see cref="MeleeLock"/> s | everything else |
/// |---|---|---|
/// | **damage** | **×2**, and he is stunned (`nz_avo_pain_long`, untouchable while it plays) | **×0.1** |
///
/// ⛔ UPSTREAM'S TABLE AS UPSTREAM MEANT IT. Its `OnInjured` tests the weapon with `~= or ~= or ~=`, which is always true, so
/// in GMod every hit is ×0.1 and the melee branch — ×2, the stun, `pain_long`, five seconds before a knife counts again — is
/// dead code. Here the melee branch is live: the knife is how you answer him, at a price (he is untouchable for the stun).
///
/// HIS MOVES (host), every think while he is walking at his target, a coin flip like upstream's `math.random(2)`:
///   • TELEPORT (every 5 s): one of his twelve dashes — forward, back, left or right, short, medium or long, upstream's own
///     clips — invisible from the start of it to frame 8 (`phase_out`), a ball of lightning crossing the floor meanwhile. The
///     direction is random, as upstream's is; a dash into a wall is skipped and still costs the five seconds, as upstream's
///     `SequenceHasSpace` failure does.
///   • JUMP (every 8-12 s, 5-8 under half health; the user's, 2026-10-06: *"teleport around to appear near any player"*): to
///     ANY player at least <see cref="JumpFrom"/> away — at random among them, so in co-op he hunts the whole team — he turns
///     into lightning, bolts across, and lands 150-300 from them on the navmesh. ⚠️ A WARNING FIRST: the spot crackles and a
///     ring fills for <see cref="JumpWarn"/> s before he is there. He comes out facing them, with a bolt ready a second later.
///   • BOLT (every 4-7 s, the first after 6; the user took all of it): `ranged_attack_intro`, then a bolt of lightning at
///     1250 u/s (`AvogadroBolt`), then `ranged_attack_end`. It is AIMED WHERE YOU ARE GOING (your speed times its flight, up to
///     <see cref="LeadMax"/>), THREE IN A FAN under half health, and where it lands it leaves an ELECTRIC PATCH for 3 s
///     (`AvogadroField`) that hurts and stops regen. ⚠️ ONLY AT A TARGET HE CAN SEE, within <see cref="ZapRange"/>: upstream's
///     trace ignores the world (`ignoreworld = true`) and measures nothing, so it fired through walls from anywhere.
///   • SHOCKED (the user's: *"when shocked, we can make the player unable to shoot"*): a bolt's blast or his patch leaves you
///     unable to shoot (`PlayerShock`, read by `Weapon.CanShoot`) — 2 s from a bolt, for as long as you stand in a patch. The
///     knife still works.
///   • SHOCKWAVE (every 5 s): anyone within <see cref="ShockRange"/> sets it off — players hurt a little and thrown, zombies
///     knocked over, and he recoils (`pain_med`). ⚠️ ONE PLAYER IS ENOUGH: upstream's test counts players inside its own loop
///     and needs two, so it never fired solo and fired N-1 times with N close.
///   • UNDER HALF HEALTH HE RUNS (`nz_avo_run`, 70.6 u/s): upstream compares `Health()` with `Health() / 2`, which is never
///     true; `MaxHealth() / 2` was the plain intent.
///
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS: the host decides; `NZNet.AvogadroPhase` / `AvogadroShock` / `AvogadroBoltLand` /
/// `AvogadroJump` carry it, and `AvogadroFx` draws it on each machine — with his body's electricity (`AvogadroCrackle`) and his hum.
/// ⚠️ HIS SPECIAL CLIPS ARE RELAYED (`PlayShared`): `ZombieAI.PlaySpecial` does not tell watching machines what it plays.
///
/// ⛔ NO ATTACK OF HIS DOWNS ANYONE FROM FULL HEALTH (<see cref="HitCap"/>), Oberon's rule.
/// </summary>
public sealed class AvogadroBoss : Component
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED, INSTRUCTIONS.md §1: a static's value survives a hotload, its initialiser does not re-run.

	static float? _bulletScale, _meleeScale, _meleeLock, _stunSeconds;
	/// <summary>What every hit but a counted melee does: ×0.1, upstream's `ScaleDamage(0.1)`.</summary>
	public static float BulletScale { get => _bulletScale ?? 0.1f; set => _bulletScale = value; }
	/// <summary>A counted melee hit: ×2, upstream's.</summary>
	public static float MeleeScale { get => _meleeScale ?? 2f; set => _meleeScale = value; }
	/// <summary>Seconds after a counted melee hit before the next one counts (×0 meanwhile): 5, upstream's `LastStun`.</summary>
	public static float MeleeLock { get => _meleeLock ?? 5f; set => _meleeLock = value; }
	/// <summary>The stun: `nz_avo_pain_long`, 97 frames at 30.</summary>
	public static float StunSeconds { get => _stunSeconds ?? 97f / 30f; set => _stunSeconds = value; }

	static float? _teleportGap;
	/// <summary>Seconds between dashes: 5, upstream's `LastTeleport`.</summary>
	public static float TeleportGap { get => _teleportGap ?? 5f; set => _teleportGap = value; }

	static float? _zapGapMin, _zapGapMax, _zapRange, _zapSpeed, _zapRadius, _zapWeight, _zapDaze;
	/// <summary>Seconds between bolts, after one: 4-7 (upstream's `math.random(5, 10)` until the "more dangerous" pass).</summary>
	public static float ZapGapMin { get => _zapGapMin ?? 4f; set => _zapGapMin = value; }
	public static float ZapGapMax { get => _zapGapMax ?? 7f; set => _zapGapMax = value; }
	/// <summary>How far he will throw one: 1500 (upstream: anywhere).</summary>
	public static float ZapRange { get => _zapRange ?? 1500f; set => _zapRange = value; }
	/// <summary>The bolt's speed: 1250 u/s, upstream's `Launch` velocity.</summary>
	public static float ZapSpeed { get => _zapSpeed ?? 1250f; set => _zapSpeed = value; }
	/// <summary>Its blast: 50 units, upstream's `FindInSphere(50)`.</summary>
	public static float ZapRadius { get => _zapRadius ?? 50f; set => _zapRadius = value; }
	/// <summary>Its damage against one swipe: 1.2 — upstream's 90 against his 75.</summary>
	public static float ZapWeight { get => _zapWeight ?? 1.2f; set => _zapWeight = value; }
	/// <summary>The daze it leaves: 2 s, upstream's `NZSonicBlind(2)`.</summary>
	public static float ZapDaze { get => _zapDaze ?? 2f; set => _zapDaze = value; }

	static float? _zapFirst, _fanSpread, _leadMax, _shockSeconds, _fieldSeconds, _fieldRadius, _fieldDps;
	/// <summary>His first bolt after he arrives: 6 s (upstream's 10).</summary>
	public static float ZapFirst { get => _zapFirst ?? 6f; set => _zapFirst = value; }
	/// <summary>Under half health he throws three: the side ones this many degrees off the middle one.</summary>
	public static float FanSpread { get => _fanSpread ?? 12f; set => _fanSpread = value; }
	/// <summary>The most a bolt leads its target by: 350 units (your speed times its flight time, capped).</summary>
	public static float LeadMax { get => _leadMax ?? 350f; set => _leadMax = value; }
	/// <summary>How long a bolt's blast leaves you SHOCKED — unable to shoot (`PlayerShock`): 2 s, the length of its daze.</summary>
	public static float ShockSeconds { get => _shockSeconds ?? 2f; set => _shockSeconds = value; }
	/// <summary>The electric patch a bolt leaves on the floor: 3 s, 70 across at size 1, 0.25 of a swipe a second.</summary>
	public static float FieldSeconds { get => _fieldSeconds ?? 3f; set => _fieldSeconds = value; }
	public static float FieldRadius { get => _fieldRadius ?? 70f; set => _fieldRadius = value; }
	public static float FieldDps { get => _fieldDps ?? 0.25f; set => _fieldDps = value; }

	static float? _jumpGapMin, _jumpGapMax, _angryJumpGapMin, _angryJumpGapMax, _jumpFrom, _jumpNear, _jumpFar, _jumpWarn;
	/// <summary>Seconds between jumps: 8-12; under half health 5-8.</summary>
	public static float JumpGapMin { get => _jumpGapMin ?? 8f; set => _jumpGapMin = value; }
	public static float JumpGapMax { get => _jumpGapMax ?? 12f; set => _jumpGapMax = value; }
	public static float AngryJumpGapMin { get => _angryJumpGapMin ?? 5f; set => _angryJumpGapMin = value; }
	public static float AngryJumpGapMax { get => _angryJumpGapMax ?? 8f; set => _angryJumpGapMax = value; }
	/// <summary>He jumps to a player at least this far from him: 450 (nearer, he walks and dashes).</summary>
	public static float JumpFrom { get => _jumpFrom ?? 450f; set => _jumpFrom = value; }
	/// <summary>Where he lands: 150-300 from that player, on the navmesh, on their floor.</summary>
	public static float JumpNear { get => _jumpNear ?? 150f; set => _jumpNear = value; }
	public static float JumpFar { get => _jumpFar ?? 300f; set => _jumpFar = value; }
	/// <summary>The warning on the landing spot before he arrives: 0.6 s.</summary>
	public static float JumpWarn { get => _jumpWarn ?? 0.6f; set => _jumpWarn = value; }

	static float? _shockRange, _shockRadius, _shockWeight, _shockGap, _shockPush;
	/// <summary>A player this near sets the shockwave off: 100, upstream's `GetRangeTo &lt; 100`.</summary>
	public static float ShockRange { get => _shockRange ?? 100f; set => _shockRange = value; }
	/// <summary>How far it reaches: 250, upstream's `FindInSphere(250)`.</summary>
	public static float ShockRadius { get => _shockRadius ?? 250f; set => _shockRadius = value; }
	/// <summary>Its damage against one swipe: 0.27 — upstream's flat 20 against his 75.</summary>
	public static float ShockWeight { get => _shockWeight ?? 0.27f; set => _shockWeight = value; }
	/// <summary>Seconds between shockwaves: 5, upstream's.</summary>
	public static float ShockGap { get => _shockGap ?? 5f; set => _shockGap = value; }
	/// <summary>The throw: 255 u/s out and 255 up, upstream's `SetVelocity`.</summary>
	public static float ShockPush { get => _shockPush ?? 255f; set => _shockPush = value; }

	static float? _enrageAt, _runSpeed, _hitCap, _thinkEvery;
	/// <summary>Health share at which he starts running: 0.5.</summary>
	public static float EnrageAt { get => _enrageAt ?? 0.5f; set => _enrageAt = value; }
	/// <summary>His run: 70.6 u/s, `nz_avo_run`'s own ground speed (165 u over 70 frames). He walks at 46 (`FixedSpeed`).</summary>
	public static float RunSpeed { get => _runSpeed ?? 70.6f; set => _runSpeed = value; }
	/// <summary>The most one hit of his takes, as a share of the match's base health. Two-thirds, Oberon's.</summary>
	public static float HitCap { get => _hitCap ?? (2f / 3f); set => _hitCap = value; }
	/// <summary>How often the coin is flipped: 0.2 s (upstream every tick, which comes to the same once one is ready).</summary>
	public static float ThinkEvery { get => _thinkEvery ?? 0.2f; set => _thinkEvery = value; }

	public static float MaxHit => MathF.Max( 1f, Difficulty.MaxHealth * Math.Clamp( HitCap, 0.1f, 0.95f ) );

	// ══ the dashes ═══════════════════════════════════════════════════════════
	//
	// ⛔ SWITCHES, NOT ARRAYS (§1). Direction 0 forward, 1 back, 2 left, 3 right; length 0 short, 1 medium, 2 long.

	static string DirName( int d ) => d switch { 0 => "forward", 1 => "back", 2 => "left", _ => "right" };
	static string LenName( int l ) => l switch { 0 => "short", 1 => "med", _ => "long" };
	static string DashClip( int d, int l ) => $"nz_avo_teleport_{DirName( d )}_{LenName( l )}";

	/// <summary>How far each dash goes, at size 1: its root travel, measured from the decompiled SMDs.</summary>
	static float DashTravel( int d, int l ) => (d, l) switch
	{
		(0, 0) => 64.9f, (0, 1) => 103.3f, (0, 2) => 178.8f,
		(1, 0) => 64.8f, (1, 1) => 135.1f, (1, 2) => 184.4f,
		(2, 0) => 46.0f, (2, 1) => 84.7f, (2, 2) => 146.6f,
		(3, 0) => 42.1f, (3, 1) => 84.7f, (_, _) => 146.6f,
	};

	/// <summary>Every dash is 11 frames at 30: the travel runs from frame 1 to 10; `phase_in` is frame 2, `phase_out` frame 8.</summary>
	const float DashLast = 10f / 30f;
	const float PhaseInAt = 2f / 30f;
	const float PhaseOutAt = 8f / 30f;

	// ══ state ════════════════════════════════════════════════════════════════

	ZombieAI _ai;
	Health _hp;
	SkinnedModelRenderer _body;
	AvogadroCrackle _crackle;

	float _nextTeleport, _nextZap, _nextShock, _nextThink, _stunUntil, _meleeLockUntil, _deathAt, _nextJump;

	// the jump in progress: where he lands and who he came for
	Vector3 _jumpTo;
	GameObject _jumpFor;

	// his target's ground speed, measured frame to frame (a client's body only arrives as positions) — the bolt's lead
	GameObject _tracked;
	Vector3 _trackedAt, _trackedVel;
	bool _stunPending, _enraged, _phased, _shielded, _deadHandled, _hidDead;

	bool _sliding;
	Vector3 _slideFrom, _slideTo;
	float _slideStart;

	SoundHandle _hum;
	float _nextHumTry;

	/// <summary>A timed beat inside one of his moves — data, not a lambda, so a hotload mid-move cannot strand a closure.</summary>
	enum Beat { ZapFire, PhaseIn, PhaseOut, JumpLand, JumpRetarget }

	readonly List<(float At, Beat What)> _pending = new();

	public bool Enraged => _enraged;
	public bool Phased => _phased;

	/// <summary>Which way his BODY faces — the object's rotation carries the rig's yaw correction (`ZombieAI.ModelTurn`).</summary>
	Vector3 Facing => _ai.IsValid() ? (WorldRotation * _ai.ModelTurn.Inverse).Forward.WithZ( 0 ).Normal : WorldRotation.Forward;

	float Size => MathF.Max( 0.1f, WorldScale.x );

	/// <summary>Where his voice comes from: head height (his model stands 74 tall).</summary>
	Vector3 Voice => WorldPosition + Vector3.Up * 64f * Size;

	protected override void OnStart()
	{
		_ai = Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
		_hp = Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
		_body = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );

		// ⚠️ EVERY MACHINE: the electricity on his body is each machine's own drawing
		_crackle = Components.GetOrCreate<AvogadroCrackle>();

		var now = Time.Now;
		_nextZap = now + ZapFirst;   // upstream's `LastShot = CurTime() + 10`; 6 since the "more dangerous" pass
		_nextJump = now + 8f;
		_nextTeleport = now + 5f;    // `LastTeleport`, `ShockWave` = CurTime() + 5
		_nextShock = now + 5f;

		if ( _ai.IsValid() && !NZGame.IsClient )
		{
			_ai.MaxHitDamage = MaxHit;

			// ⛔ HE HAS NONE OF THE WALKER'S CLIMB CLIPS, and an unknown sequence is the bind pose (Oberon's note). His jump across
			// is his traversal clip upstream (`ACT_JUMP`), in place here since `boss_port.py` strips its travel.
			_ai.ClimbClipOverride = "nz_avo_jump_across_120";
		}

		if ( !NZGame.IsClient )
			Log.Info( $"[nz-avo] spawned — {_hp?.Max ?? 0f:0} hp · melee x{MeleeScale:0.##} (one per {MeleeLock:0.#}s, stuns),"
				+ $" else x{BulletScale:0.##} · hitboxes {_ai?.HitboxCount ?? 0}" );
	}

	protected override void OnUpdate()
	{
		if ( !_ai.IsValid() ) return;

		if ( _ai.State == ZombieState.Dead )
		{
			OnDeath();
			TickDeadHide();
			return;
		}

		TickHum();

		// ── the host decides ──
		if ( _ai.IsPuppet ) return;

		_ai.MaxHitDamage = MaxHit;
		TickShield();
		TickStun();
		TickEnrage();
		TickPending();
		TickSlide();
		TrackTarget();
		TickMoves();
	}

	protected override void OnDestroy()
	{
		if ( _hum.IsValid() ) _hum.Stop();
	}

	// ══ every machine ════════════════════════════════════════════════════════

	/// <summary>
	/// His hum (`move_loop.wav`, which loops on its own `smpl` chunk), following him — upstream's ambient loop. Started again
	/// whenever it is not playing and you are near enough to hear it: a loop started out of earshot is culled at the start
	/// (`NZSound.CullOutOfEarshot`) and would otherwise never come back.
	/// </summary>
	void TickHum()
	{
		if ( Time.Now < _nextHumTry ) return;
		_nextHumTry = Time.Now + 1f;

		if ( _hum.IsValid() && _hum.IsPlaying ) return;
		if ( NZSound.Ear.Distance( WorldPosition ) > 1300f ) return;

		_hum = NZSound.Play( "nz.avo.loop", WorldPosition + Vector3.Up * 40f * Size );
		_ai.TrackVoice( _hum );
	}

	/// <summary>
	/// Dead, on every machine: shown again if he died phased out, his electricity fading over the exit, his hum stopped.
	/// </summary>
	void OnDeath()
	{
		if ( _deadHandled ) return;
		_deadHandled = true;
		_deathAt = Time.Now;

		_pending.Clear();
		_sliding = false;
		_stunPending = false;

		// ⚠️ NEVER A HIDDEN BODY FOR HIS EXIT: killed mid-dash (a nuke, a command), each machine shows its own copy again
		_phased = false;
		MargwaFx.SetHidden( GameObject, false );
		if ( _crackle.IsValid() ) { _crackle.Phased = false; _crackle.Fading = true; }

		if ( _hum.IsValid() ) _hum.Stop();

		// ⚠️ THE HOST CLEARS HIM AWAY THE MOMENT THE EXIT ENDS: upstream removes him there, and a corpse 520 units up has
		// nothing to lie on
		if ( !_ai.IsPuppet ) _ai.CorpseLinger = 0.2f;
	}

	/// <summary>
	/// Gone at the end of his exit: `nz_avo_exit` lifts him 520 units into the air and upstream removes him there, so no corpse
	/// lies on the floor — each machine hides its own copy once the clip is over.
	/// </summary>
	void TickDeadHide()
	{
		if ( _hidDead || Time.Now < _deathAt + 126f / 30f - 0.1f ) return;
		_hidDead = true;
		MargwaFx.SetHidden( GameObject, true );
	}

	// ══ the host ═════════════════════════════════════════════════════════════

	/// <summary>Untouchable while he arrives (upstream's `SetInvulnerable` over `nz_avo_arrive`), while phased out, and stunned.</summary>
	void TickShield()
	{
		var want = _ai.State == ZombieState.Spawning || _phased || Time.Now < _stunUntil;
		if ( want == _shielded || !_hp.IsValid() ) return;

		_shielded = want;
		_hp.Invulnerable = want;
	}

	/// <summary>A counted melee hit asked for the stun (`ScaleFor`); it starts here, outside the damage call.</summary>
	void TickStun()
	{
		if ( !_stunPending ) return;
		_stunPending = false;

		if ( _sliding || _phased ) return;
		if ( PlayShared( "nz_avo_pain_long", StunSeconds ) )
		{
			_stunUntil = Time.Now + StunSeconds;
			_pending.RemoveAll( p => p.What == Beat.ZapFire );
		}
	}

	void TickEnrage()
	{
		if ( _enraged || !_hp.IsValid() || _hp.Max <= 0f ) return;
		if ( _ai.State == ZombieState.Spawning || _hp.Current / _hp.Max > EnrageAt ) return;

		_enraged = true;
		_ai.SpeedOverride = MathF.Max( 1f, RunSpeed * Size );
		_ai.RepickAnimations();
		NZSound.PlayShared( "nz.avo.damaged", Voice );

		Log.Info( $"[nz-avo] UNDER {EnrageAt * 100f:0}% — he runs, {_ai.SpeedOverride:0} u/s" );
	}

	void TickMoves()
	{
		var state = _ai.State;
		if ( state is not (ZombieState.Chasing or ZombieState.Attacking) ) return;
		if ( _sliding || _phased ) return;

		var target = _ai.Target;
		if ( !target.IsValid() ) return;

		var now = Time.Now;

		// ── the shockwave: anyone close ──
		if ( now >= _nextShock && AnyPlayerWithin( ShockRange * Size ) )
		{
			Shockwave();
			return;
		}

		// ⚠️ UPSTREAM'S AI WAITS OUT A SWING (`IsAttackBlocked`): the dash and the bolt only from a walk
		if ( state != ZombieState.Chasing || now < _nextThink ) return;
		_nextThink = now + ThinkEvery;

		// ── the jump: to a player far from him, any of them ──
		if ( now >= _nextJump && StartJump() ) return;

		if ( Game.Random.Int( 0, 1 ) == 0 )
		{
			if ( now >= _nextTeleport ) StartTeleport();
		}
		else if ( now >= _nextZap && WorldPosition.Distance( target.WorldPosition ) <= ZapRange && CanSee( target ) )
		{
			StartZap();
		}
	}

	bool AnyPlayerWithin( float range )
	{
		foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
		{
			if ( !p.IsValid() || (p.IsDown || p.DownedNet) || (p.IsOutOfRound || p.OutOfRoundNet) ) continue;
			var d = p.WorldPosition - WorldPosition;
			if ( d.WithZ( 0 ).Length <= range && MathF.Abs( d.z ) < 80f ) return true;
		}
		return false;
	}

	void Queue( float after, Beat what ) => _pending.Add( (Time.Now + after, what) );

	void TickPending()
	{
		for ( int i = _pending.Count - 1; i >= 0; i-- )
		{
			if ( i >= _pending.Count ) continue;
			var p = _pending[i];
			if ( Time.Now < p.At ) continue;
			_pending.RemoveAt( i );
			Run( p.What );
		}
	}

	void Run( Beat what )
	{
		switch ( what )
		{
			case Beat.ZapFire:
			{
				var target = _ai.Target;
				if ( target.IsValid() ) Face( target.WorldPosition );
				SwapClip( "nz_avo_ranged_attack_end" );
				NZSound.PlayShared( "nz.avo.zap", Voice );

				// ⚠️ FROM HIS HEAD, as upstream's shot leaves his `EyePos`, at his target's chest (`GetPos() + 50 up`) — and since the
				// "more dangerous" pass, AHEAD of them: their ground speed times the bolt's flight time, up to `LeadMax`
				var from = _body.IsValid() && _body.TryGetBoneTransform( "j_head", out var head )
					? head.Position + Facing * 8f * Size
					: Voice + Facing * 8f;
				var to = target.IsValid() ? target.WorldPosition + Vector3.Up * 50f : from + Facing * 500f;
				if ( target.IsValid() && target == _tracked )
				{
					var lead = _trackedVel.WithZ( 0 ) * (from.Distance( to ) / MathF.Max( 1f, ZapSpeed ));
					if ( lead.Length > LeadMax ) lead = lead.Normal * LeadMax;
					to += lead;
				}

				// under half health, three in a fan
				var aim = to - from;
				var shots = _enraged ? 3 : 1;
				for ( int i = 0; i < shots; i++ )
				{
					var yaw = shots == 1 ? 0f : (i - 1) * FanSpread;
					var dir = Rotation.FromYaw( yaw ) * aim;
					AvogadroBolt.Launch( Scene, from, dir, GameObject, SwipeDamage() * ZapWeight,
						shock: ShockSeconds, fieldSeconds: FieldSeconds, fieldRadius: FieldRadius * Size, fieldDps: SwipeDamage() * FieldDps );
				}
				break;
			}

			case Beat.PhaseIn:
				NZSound.PlayShared( "nz.avo.warp", WorldPosition + Vector3.Up * 40f * Size );
				break;

			case Beat.PhaseOut:
				Phase( false );
				NZSound.PlayShared( "nz.avo.tele", WorldPosition + Vector3.Up * 40f * Size );
				break;

			case Beat.JumpLand:
			{
				// out of the lightning beside the one he came for, facing them, a bolt ready a second later
				WarpTo( _jumpTo );
				if ( _jumpFor.IsValid() ) Face( _jumpFor.WorldPosition );
				Phase( false );
				NZSound.PlayShared( "nz.avo.tele", _jumpTo + Vector3.Up * 40f * Size );
				NZSound.PlayShared( "nz.avo.damaged", Voice );
				_nextZap = MathF.Min( _nextZap, Time.Now + 1f );
				break;
			}

			case Beat.JumpRetarget:
				// ⚠️ AFTER THE HOLD ENDS: `ForceRetarget` sets him chasing, which would cut a special in half
				if ( _ai.State != ZombieState.Special ) _ai.ForceRetarget();
				break;
		}
	}

	/// <summary>What one of his swipes deals on this round — the bolt's and the shockwave's yardstick.</summary>
	float SwipeDamage()
	{
		int round = Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
		return ZombieStats.AttackDamageForRound( round ) * (_ai?.Variant?.DamageMultiplier ?? 1.5f) * Difficulty.ZombieDamage;
	}

	// ── the bolt ──

	/// <summary>Wind up the bolt: `ranged_attack_intro` (21 frames), the bolt at its end, then `ranged_attack_end`. THE HOST.</summary>
	bool StartZap()
	{
		var target = _ai.Target;
		if ( !target.IsValid() ) return false;

		Face( target.WorldPosition );
		if ( !PlayShared( "nz_avo_ranged_attack_intro", 21f / 30f + 31f / 30f ) ) return false;

		_nextZap = Time.Now + 52f / 30f + Game.Random.Float( ZapGapMin, MathF.Max( ZapGapMin, ZapGapMax ) );
		Queue( 21f / 30f, Beat.ZapFire );
		return true;
	}

	// ── the jump ──

	/// <summary>
	/// Lightning to a player at least <see cref="JumpFrom"/> away — any of them, at random — landing 150-300 from them after a
	/// <see cref="JumpWarn"/> s warning on the spot. THE HOST. False when there is nobody to jump to, or nowhere to land.
	/// </summary>
	bool StartJump()
	{
		var now = Time.Now;
		_nextJump = now + 2f;   // nobody far enough, or no floor near them: look again in a moment

		var far = Scene.GetAllComponents<NZPlayer>()
			.Where( p => p.IsValid() && !(p.IsDown || p.DownedNet) && !(p.IsOutOfRound || p.OutOfRoundNet) && p.WorldPosition.Distance( WorldPosition ) >= JumpFrom * Size )
			.ToList();
		if ( far.Count == 0 ) return false;

		var who = far[Game.Random.Int( 0, far.Count - 1 )];
		var spot = JumpSpot( who );
		if ( spot is null ) return false;

		// ⚠️ ANY CLIP WILL DO: he is hidden for the whole of it. The hold is the jump; the landing is a beat of its own.
		if ( !PlayShared( "nz_avo_teleport_forward_short", JumpWarn + 0.05f ) ) return false;

		_nextJump = now + (_enraged
			? Game.Random.Float( AngryJumpGapMin, MathF.Max( AngryJumpGapMin, AngryJumpGapMax ) )
			: Game.Random.Float( JumpGapMin, MathF.Max( JumpGapMin, JumpGapMax ) ));
		_jumpTo = spot.Value;
		_jumpFor = who.GameObject;

		Phase( true );
		NZNet.AvogadroJump( WorldPosition, _jumpTo, JumpWarn );
		NZSound.PlayShared( "nz.avo.warp", WorldPosition + Vector3.Up * 40f * Size );
		NZSound.PlayShared( "nz.avo.warp", _jumpTo + Vector3.Up * 40f * Size );

		Queue( JumpWarn, Beat.JumpLand );
		Queue( JumpWarn + 0.12f, Beat.JumpRetarget );

		Log.Info( $"[nz-avo] JUMP to {who.GameObject.Name} — {WorldPosition.Distance( who.WorldPosition ):0}u away, landing"
			+ $" {_jumpTo.Distance( who.WorldPosition ):0}u from them in {JumpWarn:0.##}s" );
		return true;
	}

	/// <summary>
	/// Where to land near <paramref name="who"/>: on the navmesh, 150-300 from them, on their floor, in their sight when it can
	/// (a few tries at that first, then any such spot). Null when there is none.
	/// </summary>
	Vector3? JumpSpot( NZPlayer who )
	{
		var nav = Scene.NavMesh;
		if ( nav is null ) return null;

		var center = who.WorldPosition;
		Vector3? fallback = null;
		for ( int i = 0; i < 16; i++ )
		{
			var p = nav.GetRandomPoint( center, JumpFar * Size );
			if ( !p.HasValue ) continue;

			var flat = (p.Value - center).WithZ( 0 ).Length;
			if ( flat < JumpNear * Size || flat > JumpFar * Size || MathF.Abs( p.Value.z - center.z ) > 64f ) continue;

			var eye = center + Vector3.Up * 60f;
			var tr = Scene.Trace.Ray( p.Value + Vector3.Up * 50f, eye ).WithoutTags( "zombie", "player", "trigger" ).Run();
			if ( !tr.Hit || tr.HitPosition.Distance( eye ) < 30f ) return p.Value;
			fallback ??= p.Value;
		}
		return fallback;
	}

	/// <summary>Put him there at once, the agent with him, as a snap on every machine (`MargwaBoss.WarpTo`).</summary>
	void WarpTo( Vector3 to )
	{
		Place( to );

		// ⛔ A SNAP, NOT A SLIDE: without this every other machine interpolates the body across the map
		GameObject.Network.ClearInterpolation();
	}

	/// <summary>His target's ground speed, frame to frame, smoothed — what the bolt leads by. THE HOST.</summary>
	void TrackTarget()
	{
		var t = _ai.Target;
		if ( !t.IsValid() ) { _tracked = null; return; }

		var pos = t.WorldPosition;
		if ( t != _tracked )
		{
			_tracked = t;
			_trackedAt = pos;
			_trackedVel = Vector3.Zero;
			return;
		}

		var dt = MathF.Max( 0.001f, Time.Delta );
		var v = (pos - _trackedAt) / dt;
		_trackedAt = pos;
		if ( v.Length > 1200f ) return;   // a teleport or a respawn, not a run
		_trackedVel = Vector3.Lerp( _trackedVel, v, 1f - MathF.Exp( -8f * dt ) );
	}

	// ── the dash ──

	/// <summary>One of the twelve dashes, at random. THE HOST.</summary>
	bool StartTeleport()
	{
		_nextTeleport = Time.Now + TeleportGap;   // ⚠️ spent even when there is no room, as upstream's is

		int d = Game.Random.Int( 0, 3 ), l = Game.Random.Int( 0, 2 );
		var f = Facing;
		var left = new Vector3( -f.y, f.x, 0f );
		var way = d switch { 0 => f, 1 => -f, 2 => left, _ => -left };

		var landing = Landing( WorldPosition + way * DashTravel( d, l ) * Size );
		if ( landing is null ) return false;

		if ( !PlayShared( DashClip( d, l ), DashLast + 0.05f ) ) return false;

		Phase( true );
		_slideFrom = WorldPosition;
		_slideTo = landing.Value;
		_slideStart = Time.Now;
		_sliding = true;

		Queue( PhaseInAt, Beat.PhaseIn );
		Queue( PhaseOutAt, Beat.PhaseOut );
		return true;
	}

	/// <summary>Where a dash may end: on the navmesh near the point, on his floor, with no wall between. Else null.</summary>
	Vector3? Landing( Vector3 want )
	{
		var nav = Scene.NavMesh;
		var p = nav?.GetClosestPoint( want );
		if ( !p.HasValue ) return null;

		var at = p.Value;
		if ( (at - want).WithZ( 0 ).Length > 32f * Size || MathF.Abs( at.z - WorldPosition.z ) > 48f ) return null;

		var up = Vector3.Up * 40f * Size;
		var tr = Scene.Trace.Ray( WorldPosition + up, at + up ).IgnoreGameObjectHierarchy( GameObject )
			.WithoutTags( "zombie", "player", "trigger" ).Run();
		return tr.Hit ? null : at;
	}

	/// <summary>Carry him along the dash, frame 1 to 10, eased — the clip's own travel, which `boss_port.py` took off it.</summary>
	void TickSlide()
	{
		if ( !_sliding ) return;

		var t = Math.Clamp( (Time.Now - _slideStart - 1f / 30f) / (DashLast - 1f / 30f), 0f, 1f );
		Place( Vector3.Lerp( _slideFrom, _slideTo, t * t * (3f - 2f * t) ) );

		if ( t < 1f ) return;
		_sliding = false;
		Place( _slideTo );
	}

	/// <summary>Put him somewhere, the agent with him (`MargwaBoss.WarpTo`'s pair, without the snap: the slide is the look).</summary>
	void Place( Vector3 at )
	{
		WorldPosition = at;
		var agent = Components.Get<NavMeshAgent>( FindMode.EverythingInSelfAndDescendants );
		if ( agent.IsValid() ) agent.SetAgentPosition( at );
	}

	/// <summary>Phase out or in, on every machine. THE HOST.</summary>
	void Phase( bool hidden )
	{
		_phased = hidden;
		NZNet.AvogadroPhase( GameObject.Id, hidden );
	}

	// ── the shockwave ──

	/// <summary>
	/// Upstream's `Explode(20)`: every player within <see cref="ShockRadius"/> hurt (a little) and thrown, every zombie
	/// knocked over, and he recoils. THE HOST.
	/// </summary>
	void Shockwave()
	{
		_nextShock = Time.Now + ShockGap;

		var at = WorldPosition + Vector3.Up * 50f * Size;
		var radius = ShockRadius * Size;

		NZSound.PlayShared( "nz.avo.shock", at );
		NZNet.AvogadroShock( at, radius, ShockPush );

		var damage = MathF.Min( SwipeDamage() * ShockWeight, MaxHit );
		foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
		{
			if ( !p.IsValid() || (p.IsDown || p.DownedNet) ) continue;

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

			var to = p.WorldPosition + Vector3.Up * 40f;
			if ( to.Distance( at ) > radius ) continue;

			var tr = Scene.Trace.Ray( at, to ).IgnoreGameObjectHierarchy( GameObject ).WithoutTags( "zombie", "player", "trigger" ).Run();
			if ( tr.Hit && tr.HitPosition.Distance( to ) > 24f ) continue;

			hp.Apply( damage, false, GameObject, blast: true, blastAt: at );
		}

		// upstream flings everything solid near him; a zombie here goes over backwards (Shockwave's own fall)
		foreach ( var z in Scene.GetAllComponents<ZombieAI>().ToList() )
		{
			if ( !z.IsValid() || z == _ai || z.State == ZombieState.Dead ) continue;
			if ( z.WorldPosition.Distance( WorldPosition ) > radius ) continue;
			z.Knockback( WorldPosition, 110f, 1.6f, 0.6f );
		}

		// he recoils, upstream's `DoSpecialAnimation("nz_avo_pain_med")`
		PlayShared( "nz_avo_pain_med", 18f / 30f );
	}

	// ── clips ──

	/// <summary>`ZombieAI.PlaySpecial`, and the same clip on every watching machine — `PlaySpecial` does not relay.</summary>
	bool PlayShared( string clip, float seconds )
	{
		if ( !_ai.PlaySpecial( clip, seconds ) ) return false;
		if ( Networking.IsActive ) NZNet.ZombieClip( GameObject.Id, clip, 1f );
		return true;
	}

	/// <summary>
	/// The next clip of a move already running (the bolt's intro into its end): written straight to the renderer, which a
	/// special owns (`ZombieAI.UpdateAnimation` leaves it alone), and relayed.
	/// </summary>
	void SwapClip( string clip )
	{
		if ( _ai.State != ZombieState.Special || !_body.IsValid() ) return;

		_body.Sequence.Name = clip;
		_body.Sequence.Time = 0f;
		if ( Networking.IsActive ) NZNet.ZombieClip( GameObject.Id, clip, 1f );
	}

	/// <summary>Turn to face a point — his own facing, the rig's yaw correction included (`ZombieAI.FaceMovement`'s rule).</summary>
	void Face( Vector3 at )
	{
		var dir = (at - WorldPosition).WithZ( 0 );
		if ( dir.Length < 1f || !_ai.IsValid() ) return;
		WorldRotation = Rotation.LookAt( dir.Normal, Vector3.Up ) * _ai.ModelTurn;
	}

	bool CanSee( GameObject target )
	{
		var from = Voice;
		var to = target.WorldPosition + Vector3.Up * 50f;
		var tr = Scene.Trace.Ray( from, to ).IgnoreGameObjectHierarchy( GameObject ).WithoutTags( "zombie", "trigger" ).Run();
		return !tr.Hit || tr.HitPosition.Distance( to ) < 40f;
	}

	// ══ the damage table ═════════════════════════════════════════════════════

	/// <summary>
	/// The multiplier for one hit. THE HOST (`Health.OnDamage`).
	///
	/// ⛔ IT MUTATES, SO EXACTLY ONCE PER HIT, the helmet's rule: a counted melee hit starts the lock and asks for the stun.
	/// </summary>
	public float ScaleFor( bool melee, GameObject attacker )
	{
		if ( _deadHandled ) return 1f;
		if ( _shielded ) return 0f;
		if ( !melee ) return MathF.Max( 0f, BulletScale );

		// a knife inside the lock does nothing, upstream's `ScaleDamage(0)` until `LastStun`
		if ( Time.Now < _meleeLockUntil ) return 0f;

		_meleeLockUntil = Time.Now + MeleeLock;
		_stunPending = true;
		NZSound.PlayShared( "nz.avo.pain", Voice );
		NZSound.PlayShared( "nz.avo.damaged", Voice );

		Log.Info( $"[nz-avo] KNIFED by {(attacker.IsValid() ? attacker.Name : "?")} — x{MeleeScale:0.##}, stunned {StunSeconds:0.#}s,"
			+ $" next knife counts in {MeleeLock:0.#}s" );
		return MathF.Max( 0f, MeleeScale );
	}

	/// <summary>The scale for a hit on this object: Avogadro's table, or 1 when it is not him (`Health.OnDamage`).</summary>
	public static float ScaleOn( GameObject victim, bool melee, GameObject attacker )
	{
		if ( !victim.IsValid() ) return 1f;

		var a = victim.Components.Get<AvogadroBoss>( FindMode.EverythingInSelfAndAncestors );
		return a.IsValid() ? a.ScaleFor( melee, attacker ) : 1f;
	}

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

	static IEnumerable<AvogadroBoss> All
		=> Game.ActiveScene?.GetAllComponents<AvogadroBoss>() ?? Enumerable.Empty<AvogadroBoss>();

	/// <summary>`nz_avo` — every Avogadro alive, and the table.</summary>
	[ConCmd( "nz_avo" )]
	public static void Report()
	{
		Log.Info( $"[nz-avo] table — melee x{MeleeScale:0.##} once per {MeleeLock:0.#}s (stun {StunSeconds:0.#}s), else x{BulletScale:0.##}"
			+ $" · dash every {TeleportGap:0.#}s · jump every {JumpGapMin:0}-{JumpGapMax:0}s ({AngryJumpGapMin:0}-{AngryJumpGapMax:0} angry)"
			+ $" to a player {JumpFrom:0}u+ away, warning {JumpWarn:0.##}s"
			+ $" · bolt every {ZapGapMin:0}-{ZapGapMax:0}s, {ZapRange:0}u, x{ZapWeight:0.##}, daze {ZapDaze:0.#}s, shock {ShockSeconds:0.#}s,"
			+ $" lead up to {LeadMax:0}u, fan of 3 angry, patch {FieldSeconds:0.#}s/{FieldRadius:0}u/x{FieldDps:0.##}/s"
			+ $" · shock under {ShockRange:0}u, {ShockRadius:0}u, x{ShockWeight:0.##}, every {ShockGap:0.#}s · runs under {EnrageAt * 100f:0}%"
			+ $" · hit cap {MaxHit:0}" );

		var list = All.ToList();
		Log.Info( $"[nz-avo] {list.Count} alive" );
		foreach ( var a in list )
			Log.Info( $"[nz-avo]   hp {a._hp?.Current ?? 0f:0}/{a._hp?.Max ?? 0f:0} · state {a._ai?.State} · speed {a._ai?.MoveSpeed ?? 0f:0} u/s"
				+ $"{(a._enraged ? " (running)" : "")}{(a._phased ? " · PHASED" : "")}{(Time.Now < a._stunUntil ? " · STUNNED" : "")}"
				+ $" · hitboxes {a._ai?.HitboxCount ?? 0}" );
	}

	/// <summary>`nz_avo_zap` — every Avogadro throws a bolt at its target now.</summary>
	[ConCmd( "nz_avo_zap" )]
	public static void ZapCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-avo] the host decides" ); return; }
		var n = All.Count( a => a._ai.IsValid() && a.StartZap() );
		Log.Info( $"[nz-avo] {n} bolt(s) — {ZapSpeed:0} u/s, {ZapRadius:0}u blast, daze {ZapDaze:0.#}s" );
	}

	/// <summary>`nz_avo_teleport` — every Avogadro dashes now (a random one of the twelve).</summary>
	[ConCmd( "nz_avo_teleport" )]
	public static void TeleportCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-avo] the host decides" ); return; }
		var n = All.Count( a => a._ai.IsValid() && a.StartTeleport() );
		Log.Info( $"[nz-avo] {n} dash(es) · a dash with no room is skipped (run it again)" );
	}

	/// <summary>`nz_avo_jump` — every Avogadro jumps to a player now (one at least `JumpFrom` away).</summary>
	[ConCmd( "nz_avo_jump" )]
	public static void JumpCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-avo] the host decides" ); return; }
		var n = All.Count( a => a._ai.IsValid() && a._ai.State == ZombieState.Chasing && a.StartJump() );
		Log.Info( $"[nz-avo] {n} jump(s) · needs a player {JumpFrom:0}u+ from him, and him walking (not mid-move)" );
	}

	/// <summary>`nz_avo_shock` — every Avogadro lets the shockwave go now.</summary>
	[ConCmd( "nz_avo_shock" )]
	public static void ShockCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-avo] the host decides" ); return; }
		var n = 0;
		foreach ( var a in All.Where( a => a._ai.IsValid() ).ToList() ) { a.Shockwave(); n++; }
		Log.Info( $"[nz-avo] {n} shockwave(s) · {ShockRadius:0}u" );
	}

	/// <summary>`nz_avo_stun` — every Avogadro takes the knife's stun now (no damage, no lock).</summary>
	[ConCmd( "nz_avo_stun" )]
	public static void StunCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-avo] the host decides" ); return; }
		foreach ( var a in All ) a._stunPending = true;
		Log.Info( $"[nz-avo] stunned for {StunSeconds:0.#}s" );
	}

	/// <summary>`nz_avo_set &lt;key&gt; &lt;value&gt;` — retune him live.</summary>
	[ConCmd( "nz_avo_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "bullet": BulletScale = value; break;
			case "melee": MeleeScale = value; break;
			case "lock": MeleeLock = value; break;
			case "stun": StunSeconds = value; break;
			case "dash": TeleportGap = value; break;
			case "zapmin": ZapGapMin = value; break;
			case "zapmax": ZapGapMax = value; break;
			case "zaprange": ZapRange = value; break;
			case "zapspeed": ZapSpeed = value; break;
			case "zapweight": ZapWeight = value; break;
			case "daze": ZapDaze = value; break;
			case "shockrange": ShockRange = value; break;
			case "shockradius": ShockRadius = value; break;
			case "shockweight": ShockWeight = value; break;
			case "shockgap": ShockGap = value; break;
			case "push": ShockPush = value; break;
			case "enrage": EnrageAt = value; break;
			case "run": RunSpeed = value; break;
			case "hitcap": HitCap = value; break;
			case "jumpmin": JumpGapMin = value; break;
			case "jumpmax": JumpGapMax = value; break;
			case "jumpfrom": JumpFrom = value; break;
			case "warn": JumpWarn = value; break;
			case "fan": FanSpread = value; break;
			case "lead": LeadMax = value; break;
			case "shocked": ShockSeconds = value; break;
			case "field": FieldSeconds = value; break;
			case "fieldradius": FieldRadius = value; break;
			case "fielddps": FieldDps = value; break;
			default:
				Log.Info( "[nz-avo] nz_avo_set <bullet|melee|lock|stun|dash|zapmin|zapmax|zaprange|zapspeed|zapweight|daze|shockrange"
					+ "|shockradius|shockweight|shockgap|push|enrage|run|hitcap|jumpmin|jumpmax|jumpfrom|warn|fan|lead|shocked|field"
					+ "|fieldradius|fielddps> <value>" );
				return;
		}

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

/// <summary>
/// Avogadro's bolt (2026-10-06), upstream's `nz_avo_shot`: a ball of lightning flying straight at where its target was, at
/// 1250 u/s — no gravity, no homing — through zombies, until it meets a wall or a player. Then a 50-unit blast: damage to
/// the players in it (more at the middle) and a 2 s daze.
///
/// ⚠️ THE HOST FLIES IT; EVERYONE ELSE WATCHES. `Launch` network-spawns the object, so its transform replicates, and the look
/// (a light at the head, sparks left in its wake) is built on every machine in <see cref="OnStart"/>. ⚠️ A 3 s LIFE: upstream's
/// has none and flies until it meets something.
/// </summary>
public sealed class AvogadroBolt : Component
{
	static float? _life, _touch;
	/// <summary>Seconds before it fizzles: 3 (3750 units at its speed).</summary>
	public static float Life { get => _life ?? 3f; set => _life = value; }
	/// <summary>How near a player's middle it must pass to hit: 24.</summary>
	public static float Touch { get => _touch ?? 24f; set => _touch = value; }

	/// <summary>Who threw it: the damage carries his name.</summary>
	public GameObject Owner { get; set; }

	/// <summary>Its damage at the middle of the blast, sized on the host at launch, so his death mid-flight changes nothing.</summary>
	public float Damage { get; set; }

	/// <summary>This bolt's blast, daze and speed — Avogadro's statics unless a thrower set its own (the Panzer's taser).</summary>
	public float Radius { get; set; }
	public float Daze { get; set; } = -1f;
	public float Speed { get; set; }

	/// <summary>Seconds its blast leaves a player SHOCKED (no shooting), and the electric patch it lays — 0, none (the Panzer's).</summary>
	public float Shock { get; set; }
	public float FieldSeconds { get; set; }
	public float FieldRadius { get; set; }
	public float FieldDps { get; set; }

	Vector3 _dir;
	float _dieAt, _nextSpark;
	bool _done;
	GameObject _lightGo;

	/// <summary>Throw one. THE HOST; a client only watches.</summary>
	public static AvogadroBolt Launch( Scene scene, Vector3 from, Vector3 dir, GameObject owner, float damage,
		float radius = 0f, float daze = -1f, float speed = 0f, float shock = 0f, float fieldSeconds = 0f, float fieldRadius = 0f,
		float fieldDps = 0f )
	{
		if ( !scene.IsValid() || NZGame.IsClient ) return null;

		var go = new GameObject( true, "nz_avo_bolt" );
		go.Flags |= GameObjectFlags.NotSaved;
		go.WorldPosition = from;
		go.WorldRotation = Rotation.LookAt( dir.Length > 0.01f ? dir.Normal : Vector3.Forward, Vector3.Up );

		var b = go.Components.Create<AvogadroBolt>();
		b.Owner = owner;
		b.Damage = damage;
		b.Radius = radius;
		b.Daze = daze;
		b.Speed = speed;
		b.Shock = shock;
		b.FieldSeconds = fieldSeconds;
		b.FieldRadius = fieldRadius;
		b.FieldDps = fieldDps;

		// ⚠️ UNCONDITIONAL (a no-op solo), and after the component is configured, since the spawn is serialised now
		go.NetworkSpawn();
		return b;
	}

	protected override void OnStart()
	{
		_dir = WorldRotation.Forward;
		_dieAt = Time.Now + Life;

		_lightGo = new GameObject { Parent = GameObject, Name = "light" };
		_lightGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		var light = _lightGo.Components.Create<PointLight>();
		light.LightColor = AvogadroFx.Blue * 5f;
		light.Radius = 200f;
		light.Shadows = false;

		// ⚠️ A BALL YOU CAN SEE (2026-10-07). The user: *"AVOGADRO is great, but i cannot see the projectile"* — it was this light
		// and the hair-thin sparks below. A glowing blue head with a fading trail, every machine (the Panzer's taser too)
		BossShotLook.On( GameObject, AvogadroFx.Blue, 26f, trailWidth: 8f, trailSeconds: 0.14f, brightness: 4f );
	}

	protected override void OnUpdate()
	{
		// ── every machine: sparks left in its wake ──
		if ( !_done && Time.Now >= _nextSpark )
		{
			_nextSpark = Time.Now + 0.025f;
			var c = WorldPosition;
			AvogadroSpark.Between( c + Vector3.Random * 5f, c + Vector3.Random.Normal * Game.Random.Float( 9f, 16f ),
				Game.Random.Float( 0.06f, 0.12f ), 0.16f );
		}

		if ( _done || NZGame.IsClient ) return;

		var from = WorldPosition;
		var next = from + _dir * (Speed > 0f ? Speed : AvogadroBoss.ZapSpeed) * Time.Delta;

		// ── a wall ends it; zombies it passes through, as upstream's trigger does ──
		var wall = Scene.Trace.Ray( from, next ).IgnoreGameObjectHierarchy( GameObject )
			.WithoutTags( "zombie", "player", "trigger", "ragdoll" ).Run();
		if ( wall.Hit )
		{
			End( wall.HitPosition - _dir * 4f );
			return;
		}

		// ── a player it passes ──
		foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
		{
			if ( !p.IsValid() || (p.IsDown || p.DownedNet) ) continue;
			if ( DistanceToBody( p, from, next ) > Touch ) continue;
			End( next );
			return;
		}

		WorldPosition = next;
		if ( Time.Now >= _dieAt ) End( next );
	}

	/// <summary>How near the segment this frame passes to a player's body (a line from shin to head).</summary>
	static float DistanceToBody( NZPlayer p, Vector3 a, Vector3 b )
	{
		var lo = p.WorldPosition + Vector3.Up * 12f;
		var hi = p.WorldPosition + Vector3.Up * 64f;
		var best = float.MaxValue;
		for ( int i = 0; i <= 4; i++ )
		{
			var q = Vector3.Lerp( a, b, i / 4f );
			var z = Math.Clamp( q.z, lo.z, hi.z );
			best = MathF.Min( best, q.Distance( new Vector3( lo.x, lo.y, z ) ) );
		}
		return best;
	}

	/// <summary>The blast. THE HOST; destroying a network object ends it everywhere.</summary>
	void End( Vector3 at )
	{
		if ( _done ) return;
		_done = true;

		var radius = Radius > 0f ? Radius : AvogadroBoss.ZapRadius;
		foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
		{
			if ( !p.IsValid() || (p.IsDown || p.DownedNet) ) continue;

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

			var d = DistanceToBody( p, at, at );
			if ( d > radius ) continue;

			// ⚠️ A FLOOR OF 0.35 AT THE RIM: upstream measures to the body's centre, so a hit on the shoulder already read as 60%
			var share = Math.Clamp( 1f - d / MathF.Max( 1f, radius ), 0.35f, 1f );
			hp.Apply( MathF.Min( Damage * share, AvogadroBoss.MaxHit ), false, Owner.IsValid() ? Owner : GameObject,
				blast: true, blastAt: at );
		}

		NZNet.AvogadroBoltLand( at, radius, Daze >= 0f ? Daze : AvogadroBoss.ZapDaze, Shock, FieldSeconds, FieldRadius, FieldDps );
		NZSound.PlayShared( "nz.pop.deadwire.shock", at );
		NZNet.ShakeAt( at, 0.35f, 300f );

		GameObject.Destroy();
	}
}