Zombies/OberonBoss.cs

Component controlling the Oberon boss variant, implementing its two phases, special attacks (leap, hole, bomb barrage, pulse), effects, timing, and tuning console commands. It drives animation-tied cues, handles spawning effect objects, damage blasts with falloff and LOS, bomb scheduling, pull/pulse network events, and phase/idle logic.

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

namespace NZombies;

/// <summary>
/// OBERON — the giant's own moveset, on top of the ordinary swing the variant already gives him.
///
/// ⛔ THE VARIANT CAN ONLY EXPRESS "SWING AT WHAT IS IN FRONT OF YOU". `.zvar` holds movement,
/// attack and death sequence lists and a damage multiplier, which is every enemy in this game
/// except the ones that have a component — Brutus has his helmet, the napalm zombie its eruption,
/// the shrieker its scream. Oberon's three big attacks are timed events inside long clips, and
/// there is nowhere in a data asset to say "pull everyone in for five seconds, THEN hit them".
///
/// ⚠️ SO THE MELEE STAYS WHERE IT IS. `zbs_attack1` / `zbs_attack2` remain the variant's
/// `AttackSequences` and are driven by `ZombieAI`'s own swing with its own range check and
/// barricade handling. This component owns only what that cannot do, and never competes for the
/// same moment — it will not start anything while the AI is mid-swing.
///
/// ⚠️ EVERY TIMING BELOW IS THE ORIGINAL'S, CONVERTED. `npc_drg_csnz_oberon` hangs its damage off
/// `SequenceEvent( clip, { cycle }, fn )` — a fraction of the clip, not a delay — so each move here
/// carries the cycle and the clip's real duration (frames ÷ fps, measured from the decompiled SMDs)
/// and fires the event when the two say to. Copying the delays as seconds would have silently
/// re-timed every hit the moment a clip's length was re-read.
/// </summary>
public sealed class OberonBoss : Component
{
	// ── the two phases ───────────────────────────────────────────────────────

	/// <summary>
	/// Health fraction at which he takes up the knife.
	/// </summary>
	///
	/// ⚠️ THE ORIGINAL SAYS `self:Health() < 17500` AGAINST A `SpawnHealth` OF 35000, which is a
	/// half and is written here as one — a literal 17500 would silently stop being the halfway point
	/// the moment his health came from a round curve instead of a constant.
	[Property, Range( 0.05f, 0.95f )] public float PhaseTwoAt { get; set; } = 0.5f;

	/// <summary>Seconds between big attacks — the original's `bot_skill_delay`.</summary>
	///
	/// ⚠️ HALVED FROM THE ORIGINAL'S 3. The clips are the real floor on how often he can do
	/// anything — the leap is 5s, the barrage 7s, the hole 7.5s — so the gap between them is the
	/// only part of the cadence that is ours to spend.
	[Property] public float SkillCooldown { get; set; } = 1.5f;

	/// <summary>
	/// Relative odds of each special. Leap-heavy, because the leap is how he applies pressure.
	/// </summary>
	///
	/// ⚠️ WEIGHTS, NOT A 1-IN-3 ROLL. The three used to come up equally, which on clips this
	/// long meant a leap about every fifteen seconds — far too rare for the move that closes
	/// distance. At 4:1:1 it is two thirds of everything he does.
	///
	/// ⚠️ AND THE CLIP LENGTHS STILL DOMINATE. Even at this weighting a leap lands roughly
	/// every eight seconds, because the leap itself takes five of them. Shortening that gap
	/// further means cutting the recovery out of the clip, not changing these numbers.
	[Property] public float LeapWeight { get; set; } = 4f;
	[Property] public float HoleWeight { get; set; } = 1f;
	[Property] public float BombWeight { get; set; } = 1f;

	/// <summary>
	/// The pulse's odds: none unless something asks for it — basalt's boss fight gives it from its second phase
	/// (`HexPlatforms.SetMoves`), asked for as *"a pulse around it that has the effect of the shrieker, stunning the players"*.
	/// </summary>
	[Property] public float PulseWeight { get; set; } = 0f;

	/// <summary>
	/// The pulse: how far it reaches; how long its daze lasts — the Shrieker's own four seconds (`SonicDaze`); how long it
	/// charges, its warning drawn on the floor meanwhile (`PulseTelegraph`); and how long he stands after it.
	/// `nz_oberon_pulse` sets them.
	/// </summary>
	[Property] public float PulseRadius { get; set; } = 500f;
	[Property] public float PulseDaze { get; set; } = 4f;
	[Property] public float PulseCharge { get; set; } = 1.2f;
	[Property] public float PulseRecover { get; set; } = 0.5f;

	/// <summary>
	/// What he plays while the pulse charges. ⚠️ A STAND-IN: his set has no clip for it, so he stands and gathers, in his idle.
	/// `nz_oberon_pulse 0 0 0 &lt;clip&gt;` tries another of his twenty; `nz_oberon_clip &lt;clip&gt;` plays one to look at.
	/// </summary>
	[Property] public string PulseClip { get; set; } = "zbs_idle1";

	/// <summary>
	/// While true he starts no special of his own — basalt's boss fight holds him through his coming and its phase changes,
	/// where it moves him itself (<see cref="Dive"/>). His swipe is the AI's, and goes on.
	/// </summary>
	public bool Held { get; set; }

	/// <summary>
	/// He will not leap at somebody already within this much of him.
	/// </summary>
	///
	/// ⛔ A LEAP IS A GAP-CLOSER, AND ONE THROWN AT SOMEBODY ALREADY IN REACH IS A HOP BACKWARDS.
	/// `BeginArc` moves him toward the target but never past them, so at close range the whole
	/// five-second move resolves to almost no travel — five seconds of pressure spent standing
	/// still. Inside this range the weight drops to zero and he swipes or throws instead.
	[Property] public float LeapMinRange { get; set; } = 600f;

	/// <summary>
	/// How far the leap carries him, and how high it arcs.
	/// </summary>
	///
	/// ⛔ WITHOUT THIS HE CANNOT TRAVEL AT ALL, and that is a property of the model, not a choice.
	/// `zbs_attack3` has the whole jump baked into its BONES — the root rises 163 units and comes
	/// back down — and `oberon.vmdl` declares no motion extraction, so the engine plays that as a
	/// pose. The mesh lifts; the GameObject never moves. Anything that wants him to cross ground
	/// has to move the object itself.
	///
	/// ⚠️ THE ORIGINAL THROWS ITSELF WITH `SetVelocity( forward*500 + up*750 )` — ballistic under
	/// GMod's own gravity, landing wherever it lands. A nav agent cannot be shoved: it comes down
	/// off the mesh and stands still for good. So the distance is authored and the landing is
	/// snapped to the navmesh, which is the same leap with a floor under it.
	///
	/// ⚠️ CAPPED, NOT MEASURED FROM THE TARGET. He starts this from up to 5000 units away; a leap
	/// that closed the whole gap would be a teleport.
	///
	/// ⛔ BOTH ARE QUOTED AT `ModelScale` 1 AND MULTIPLIED BY HIS ACTUAL SCALE AT THE LAUNCH.
	/// Everything else that describes his size already scales itself — `ApplyModelScale` carries
	/// `BodyHeight`, `HitRadius` and `AttackRange`, and the effect models are children of a scaled
	/// object — so a leap quoted in raw world units is the one measurement that would silently
	/// stay the old size when he grows. That is exactly the shape of the `GroundSpeed` trap in
	/// INSTRUCTIONS.md: a number coupled to the model with nothing in the code saying so.
	[Property] public float LeapDistance { get; set; } = 840f;

	/// <summary>Peak height of the arc, above the straight line between the ends. At scale 1.</summary>
	[Property] public float LeapHeight { get; set; } = 260f;

	/// <summary>
	/// Colour of the shockwave his landing throws out.
	/// </summary>
	///
	/// ⚠️ IT IS THUNDERWALL'S RING, RECOLOURED — `ShockRing` was written as a general primitive
	/// for exactly this, and its default pale blue is Thunderwall's own. A red-hot one reads as
	/// mass hitting the floor rather than as electricity, and matches the red on his mask.
	[Property] public Color LandingRing { get; set; } = DefaultRing;

	/// <summary>The authored ring colour, for the console command's fallback.</summary>
	///
	/// ⚠️ AN EXPRESSION, NOT A `static readonly` FIELD. A static's initialiser does not re-run
	/// on a hotload (INSTRUCTIONS.md §1), so a field would be a value frozen at whatever it held
	/// when the session started.
	static Color DefaultRing => new Color( 1f, 0.35f, 0.12f );

	/// <summary>How far past the damage radius the ring is drawn. 1 = exactly on it.</summary>
	///
	/// ⛔ ONE, AND IT SHOULD STAY ONE. The ring is the only thing that makes a 600-unit blast
	/// legible, and a ring drawn wider than the damage is a promise the hit does not keep —
	/// `ShockRing`'s own note calls this out: what you see is what was hit.
	[Property] public float LandingRingScale { get; set; } = 1f;

	/// <summary>
	/// The most one hit of his takes, as a share of a player's BASE health (`PlayerSettings.MaxHealth`, basalt's 150):
	/// two-thirds — 100. *"we cant have any attack insta kill like that"* (2026-09-27): no swipe, leap, black hole or bomb takes
	/// a player down from full health, whatever the round; two in a row can. His swipe used to kill outright.
	/// </summary>
	///
	/// ⛔ OF THE BASE HEALTH, NOT THE VICTIM'S OWN MAXIMUM, AND ON PURPOSE TWICE OVER. The host sizes his hits, and its copy of a
	/// client has never heard of their Juggernog (`Health.Apply`'s relay note) — a cap off that copy's maximum would be one
	/// number for the host's player and another for everyone else. And against the base, Juggernog still buys what it should:
	/// a third hit, not a bigger one.
	///
	/// ⚠️ THE SWIPE IS THE VARIANT'S, NOT THIS COMPONENT'S — `zbs_attack1`/`zbs_attack2` come from `oberon.zvar` and are swung
	/// by `ZombieAI` — so it is capped there (`ZombieAI.MaxHitDamage`); his blasts are capped here, in <see cref="Blast"/>.
	[Property, Range( 0.1f, 0.95f )] public float HitCap { get; set; } = DefaultHitCap;

	/// <summary>Two-thirds.</summary>
	public const float DefaultHitCap = 2f / 3f;

	/// <summary>The most one hit of his takes now, in health: <see cref="HitCap"/> of the base health — under 1, so never all of it.</summary>
	/// <remarks>⚠️ OF THE MATCH'S MAX HEALTH (the lobby's Difficulty, 2026-10-05), so on any difficulty none downs anyone from full.</remarks>
	public float MaxHit => MathF.Max( 1f, Difficulty.MaxHealth * Math.Clamp( HitCap, 0.1f, 0.95f ) );

	/// <summary>How far away he will still open with one.</summary>
	///
	/// ⚠️ THE ORIGINAL'S `RangeAttackRange` IS 5000, which is most of a map. Kept, because every one
	/// of these moves closes the distance or reaches across it — a boss that only does this in your
	/// face is a boss you walk away from.
	[Property] public float SpecialRange { get; set; } = 5000f;

	// ── damage ───────────────────────────────────────────────────────────────

	/// <summary>
	/// What each move hits for, as a MULTIPLE of his ordinary swing.
	/// </summary>
	///
	/// ⛔ NOT THE LUA'S NUMBERS. It authors 285 / 360 / 452 against players at a tenth — 28.5, 36,
	/// 45.2 — which are flat and would stop meaning anything by round 20, because damage here comes
	/// from `ZombieStats.AttackDamageForRound` and the variant's multiplier. What IS portable is the
	/// RATIO between his moves: 1 : 1.26 : 1.59. Those are kept and the round curve carries the rest,
	/// so he scales with the game instead of against it.
	[Property] public float HoleDamage { get; set; } = 1.26f;
	[Property] public float LeapDamage { get; set; } = 1.59f;
	/// ⚠️ 1.8, UP FROM 0.55, AND THIS IS THE LARGEST SINGLE NUMBER ON THE BOSS. A weight is
	/// multiplied by the round's attack damage AND by the variant's `DamageMultiplier` of 3, so
	/// 1.8 is **5.4× a round's attack per bomb** — against the leap's 4.8× and the hole's 3.8×,
	/// except that there are thirty bombs and one leap. Anyone standing in two craters is
	/// almost certainly dead. That is what was asked for; `nz_oberon_barrage` dials it live.
	[Property] public float BombDamage { get; set; } = 1.8f;

	/// <summary>Phase two hits harder — 410/430/560 against 285/360/452 upstream, so about ×1.3.</summary>
	[Property] public float PhaseTwoDamageScale { get; set; } = 1.3f;

	// ── the hole's pull ──────────────────────────────────────────────────────

	/// <summary>How hard the hole drags a player in, per tick.</summary>
	[Property] public float PullSpeed { get; set; } = 260f;

	/// <summary>How far the pull reaches.</summary>
	[Property] public float PullRadius { get; set; } = 900f;

	/// <summary>Ticks per second while the hole is open. The original runs a 0.1s timer.</summary>
	[Property] public float PullRate { get; set; } = 10f;

	// ── the bomb barrage ─────────────────────────────────────────────────────

	/// <summary>Bombs per wave, and how far out they scatter.</summary>
	///
	/// ⛔ TEN — 40% OF THE 25 IT WAS — by the user's word after playing the fight: *"there are too many bombs, reduce them to
	/// 40% on the bomb attack"* (2026-09-27). Three waves of ten, thirty in all; one wave covers some 45% of the disc it lands
	/// in, where 25 covered all of it and more, so there is floor to stand on between the craters. `nz_oberon_barrage [count]`
	/// retunes it live.
	///
	/// ⚠️ IT WAS FIFTEEN, THE ORIGINAL'S OWN COUNT, and five before that while each one was an invisible instant blast — fifteen
	/// simultaneous explosions with no cause on screen would have been noise. Thrown objects with a marked landing point are
	/// what made a number this size a barrage.
	[Property] public int BombsPerWave { get; set; } = 10;

	/// <summary>Seconds between one bomb leaving his hand and the next. 0.03.</summary>
	///
	/// ⚠️ THE ORIGINAL'S OWN `self:Timer(0.03*i, ...)`. It is short enough that the wave reads
	/// as one throw and long enough that the shells leave as a stream rather than a clump.
	[Property] public float BombStagger { get; set; } = 0.03f;

	/// <summary>How long a bomb is in the air, before its ±15% jitter. 1.8s.</summary>
	///
	/// ⚠️ AUTHORED, NOT BALLISTIC, AND THE ORIGINAL AGREES WITHIN A QUARTER SECOND. It throws
	/// with `up * rand(600,700)` under GMod's gravity of 600, which is about 2.2s of flight; an
	/// arc that is authored end to end lands exactly where the marker says instead of wherever
	/// physics puts it, which is what lets the ring be a promise.
	[Property] public float BombFlight { get; set; } = 2.2f;

	/// <summary>How high the arc peaks above the line from his hand to the floor. 420.</summary>
	[Property] public float BombApex { get; set; } = 800f;

	/// <summary>
	/// How far he leans forward to throw, in degrees.
	/// </summary>
	///
	/// ✅ NINETY, AND THE CLIP AGREES. Measured off the source SMD with `Tools/measure_anim.py`:
	/// the torso lean runs **34.4° at rest → ~115° through the throw → 34.4°**, so the authored
	/// movement is about **+80°** from his standing hunch. Ninety is within a few degrees of what
	/// the animator drew, which is why it is the number here rather than a smaller one.
	///
	/// ⚠️ THIS IS THE BODY, NOT THE CLIP. Oberon's clips came through the port with their root
	/// pitch flattened, so the pose he plays is upright however far the source leaned. Rather than
	/// re-export twenty clips to recover one attack's lean, the body is leaned under the animation
	/// — which is what "adjust the boss's position" asks for and costs one rotation.
	[Property] public float BombTilt { get; set; } = 90f;

	/// <summary>Cycles of the clip at which he goes down and comes back up. 0.17 and 0.88.</summary>
	///
	/// ⚠️ BOTH READ OFF THE SOURCE ANIMATION, not chosen. Frame 14 of 167 is still near upright
	/// (10.6°), frame 28 is already down (120.8°), it holds past frame 139, and frame 153 is on
	/// its way back (83.6°). 28/167 and 147/167 are 0.17 and 0.88.
	[Property] public float BombTiltDown { get; set; } = 0.17f;
	[Property] public float BombTiltUp { get; set; } = 0.88f;

	/// <summary>
	/// How long BEFORE the animation goes down that he starts leaning. 1 second.
	/// </summary>
	///
	/// ✅ ASKED FOR AS *"make it apply 1 second sooner"*, and it is in SECONDS rather than in
	/// cycles for exactly that reason: a second is what was asked for, and a cycle is not a fixed
	/// amount of time. `BombTiltDown` stays at the measured 0.17 — which is where the clip really
	/// goes down — and this is the deliberate lead on top of it.
	///
	/// ⛔ CONVERTED WITH THE CLIP'S REAL LENGTH, NOT A CONSTANT. Every cue on this boss was
	/// briefly half a second out because it was timed against `frames ÷ authored fps` while the
	/// DMX exported at 24 — see the 16:04 entry. A lead baked in as "subtract 0.14 cycles" would
	/// walk straight back into that the day a clip is re-exported at a different rate.
	///
	/// ⚠️ IT MOVES THE ONSET ONLY. He therefore HOLDS the lean a second longer, which is right:
	/// the throw itself has not moved, and coming up early would look like he had finished.
	[Property] public float BombTiltLead { get; set; } = 1.0f;
	[Property] public float BombSpread { get; set; } = 1800f;
	[Property] public float BombRadius { get; set; } = 380f;

	/// <summary>
	/// What the knife walk's travel comes to against the walk the tier was written for.
	/// </summary>
	///
	/// ⛔ MEASURED, AND IT IS NOT 1. `knife_walk` covers 749.7u in 3.333s = 224.9 u/s where
	/// `zbs_walk` covers 963.4u in 4.0s = 240.8. The tier's `GroundSpeed` describes the latter, and
	/// the animation rate is `velocity / clipGroundSpeed` — so playing the knife walk against the
	/// bare-handed number cycles his legs 7% slow for the ground he covers. `ExtraSpeedMultiplier`
	/// is the one field that feeds that division from outside the asset.
	[Property] public float KnifeWalkSpeedScale { get; set; } = 224.9f / 240.8f;

	/// <summary>How long an idle is held before he reconsiders.</summary>
	///
	/// ⛔ SHORTER THAN THE CLIP, ON PURPOSE. `PlaySpecial` zeroes his agent speed for the whole
	/// hold, so a full 5.25s idle is 5.25 seconds of a boss standing still while somebody shoots
	/// him. Two seconds shows the animation and hands control back before that matters.
	[Property] public float IdleHold { get; set; } = 2f;

	/// <summary>
	/// How far up his own effect models sit, in model space.
	/// </summary>
	///
	/// ⛔ IT IS THE FOOT DROP, AND IT IS NOT A COINCIDENCE. His mesh was lifted 61.6 when `--stand`
	/// put his feet on Z=0, so his object origin is now his feet — while `ef_hole` and the claws are
	/// authored around where his origin USED to be. One offset on the child object restores the
	/// relationship the two were drawn with, and costs nothing at runtime.
	///
	/// ⚠️ NOT BAKED INTO THE EFFECT MESHES. Four models lifted by four different amounts is four
	/// chances to be wrong; one number beside the reason is one.
	[Property] public float FxLift { get; set; } = 61.6f;

	/// <summary>
	/// The scale his body is actually drawn at, for the measurements quoted at scale 1.
	/// </summary>
	///
	/// ⚠️ READ FROM THE AI, NOT FROM THE VARIANT. `ZombieAI.ModelScale` is what
	/// `ApplyModelScale` LAST APPLIED, so it follows `nz_zscale` and the tuner panel; the variant
	/// is only what the asset asked for, and the two differ the moment anybody tries a size.
	///
	/// ⚠️ FLOORED, because a zero would collapse every distance derived from it to nothing and
	/// the symptom would be a leap that does not move.
	float BodyScale => MathF.Max( 0.05f, _ai.IsValid() ? _ai.ModelScale : 1f );

	/// <summary>Which phase he is in. 1 is bare-handed, 2 is the knife.</summary>
	public int Phase { get; private set; } = 1;

	/// <summary>What he is doing right now, for `nz_oberon`.</summary>
	public string Status { get; private set; } = "idle";

	// ── the moveset ──────────────────────────────────────────────────────────

	enum Kind { Leap, Hole, Bomb, Pulse }

	/// <summary>
	/// One big attack: a clip, its real length, and the cycles its events sit at.
	/// </summary>
	///
	/// ⚠️ `Seconds` IS MEASURED, NOT GUESSED — frames ÷ fps from the decompiled SMDs, and the fps
	/// varies per clip in this model (the bomb runs at 15, the run at 50). `PlaySpecial` plays at
	/// rate 1, so cycle × Seconds is when an event actually lands on screen.
	sealed class Move
	{
		public Kind Kind;
		public string Clip;
		public float Seconds;
		public float HitAt;
		public float Damage;
		public float Radius;

		/// <summary>Hole only: when the pull starts and stops.</summary>
		public float PullFrom;

		/// <summary>Bomb only: the three waves.</summary>
		public float[] Waves;

		/// <summary>Leap only: when he leaves the ground.</summary>
		public float LaunchAt;

		/// <summary>
		/// Leap only: where to, if not at his target; and whether it is a dive — landing exactly there, off the navmesh, with
		/// no blow (<see cref="Dive"/>).
		/// </summary>
		public Vector3? To;
		public bool Dive;

		/// <summary>
		/// The cycle at which he is released back to the AI. 1 = the whole clip.
		/// </summary>
		///
		/// ⛔ IT CUTS THE HOLD AS WELL AS THE MOVE, AND IT HAS TO CUT BOTH. `PlaySpecial` freezes
		/// the agent until its hold expires, so ending the Move early while the hold ran on would
		/// leave him standing exactly as long and simply stop this component watching. The two are
		/// set from the same number.
		public float Ends = 1f;
	}

	static Move Leap( string clip ) => new()
	{
		Kind = Kind.Leap, Clip = clip,
		// 121 frames @ 30fps
		Seconds = 4.0f, LaunchAt = 16f / 120f, HitAt = 63f / 120f, Radius = 600f,

		// ⛔ CUT AT 0.78, WHICH IS THE RECOVERY. He lands at 0.525 and the remaining two and a
		// half seconds are the animator standing him back up — during which `PlaySpecial` has his
		// agent pinned at zero speed, so a boss meant to APPLY PRESSURE spends half of his main
		// attack as a stationary target. Releasing him at 0.78 keeps the part of the recovery that
		// reads as landing and hands him back while he is still moving.
		//
		// ⚠️ THE CLIP IS NOT TRUNCATED, THE HOLD IS. `TickSpecial` re-picks the walk clip on the
		// way out and the anim files carry a 0.2s fade, so it blends rather than snaps.
		Ends = 0.78f,
	};

	static Move Hole( string clip ) => new()
	{
		Kind = Kind.Hole, Clip = clip,
		// 181 frames @ 30fps
		Seconds = 6.0f, PullFrom = 5f / 180f, HitAt = 150f / 180f, Radius = 1000f,
	};

	static Move Bomb( string clip ) => new()
	{
		Kind = Kind.Bomb, Clip = clip,
		// 168 frames @ 15fps — eleven seconds, and that is the animation, not a choice
		Seconds = 11.13f, HitAt = -1f, Radius = 0f,
		Waves = new[] { 45f / 167f, 89f / 167f, 134f / 167f },
	};

	/// <summary>
	/// The pulse: the charge, then the release. ⚠️ TIMED IN SECONDS, NOT OFF THE CLIP — the clip is a stand-in of any length
	/// (<see cref="PulseClip"/>) — so the hold is the charge and the recovery, and the release lands at the charge's end.
	/// </summary>
	static Move PulseMove( string clip, float charge, float recover ) => new()
	{
		Kind = Kind.Pulse, Clip = clip,
		Seconds = MathF.Max( 0.2f, charge + recover ),
		HitAt = MathF.Max( 0.05f, charge ) / MathF.Max( 0.2f, charge + recover ),
		Radius = 0f,
	};

	ZombieAI _ai;
	Health _hp;

	Move _move;
	float _startedAt;
	bool _hit;
	int _wave;
	bool _launched;

	/// <summary>Set by `nz_oberon_move`: the next special to run instead of a rolled one.</summary>
	Kind? _forced;

	/// <summary>What he did last, so the barrage cannot run twice running.</summary>
	///
	/// ⚠️ IT SURVIVES THE MOVE ENDING, obviously, but NOT a re-spawn — a fresh boss may open
	/// with a barrage. That is correct: the rule is about repetition inside one fight.
	Kind? _lastKind;

	/// <summary>
	/// A barrage blast waiting for its bomb to come down.
	/// </summary>
	///
	/// ⛔ THE DAMAGE STAYS HERE AND THE PICTURE GOES OVER THE WIRE, WHICH IS THE WHOLE SPLIT.
	/// A `BombShell` exists on every machine so everyone can watch the barrage, so a shell that
	/// dealt its own damage would hurt each player once per client. The shell is inert; this list
	/// is the host's own copy of when each one arrives.
	///
	/// ⚠️ IT OUTLIVES THE MOVE, ON PURPOSE. The last wave is thrown at cycle 134/167 and the
	/// flight is nearly two seconds, so bombs are still falling after the clip has ended and
	/// `_move` is null. That is also why the damage is CAPTURED at the throw rather than read from
	/// `_move` at the landing — by then there is nothing to read.
	readonly List<(float At, Vector3 Where, float Damage)> _pending = new();
	TimeUntil _cooldown;
	TimeUntil _idle;

	// ── effects ──────────────────────────────────────────────────────────────
	readonly List<(GameObject Go, TimeUntil Until)> _fx = new();
	string _lastClip = "";
	string _clawModel;
	string _clawClip;
	TimeUntil _clawAt;

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

		// ⚠️ CAPPED FROM HIS FIRST FRAME — `OnUpdate` keeps it so after (`HitCap`)
		if ( _ai.IsValid() ) _ai.MaxHitDamage = MaxHit;

		// ⚠️ THE FIRST ONE IS NOT FREE. Without this he opens with a six-second hole the instant he
		// is spawned, before anybody has seen him stand up.
		_cooldown = SkillCooldown;

		// ⛔ HE HAS NONE OF THE WALKER'S CLIMB CLIPS. `WalkerTraverse.ClimbUp` names
		// `nz_base_zombie_jump_up_*`, and an unknown sequence is the bind pose rather than an error
		// — so without this he crosses every nav link frozen. `zbs_jump` is 15 frames and is the
		// only clip in his set the lua never calls; this is what it is for.
		_ai.ClimbClipOverride = "zbs_jump";

		Log.Info( $"[nz-oberon] awake — {_hp?.Max ?? 0f:0} hp, phase two at {PhaseTwoAt * 100f:0}%" );
	}

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

		// ⛔ ABOVE THE DEATH CHECK, AND THAT IS DELIBERATE. A bomb already in the air is a thing
		// in the world, not a thing he is doing — killing him between the throw and the landing
		// should not disarm it, and the shells keep falling and exploding either way. Below the
		// check they would draw their craters and do nothing.
		TickPending();

		if ( _ai.State == ZombieState.Dead )
		{
			_move = null;
			Status = "dead";

			// ⚠️ A CORPSE CARRIES NO SLASH TRAIL. These are children of a body that stays in the
			// scene, so without this the last claw hangs off him for as long as he lies there.
			foreach ( var f in _fx )
				if ( f.Go.IsValid() ) f.Go.Destroy();
			_fx.Clear();

			// ⚠️ A CORPSE DOES NOT KEEP THROWING. Dying mid-barrage would otherwise leave the
			// lean set, and the death clip would play on a body tipped ninety degrees forward.
			Unlean();

			return;
		}

		// ⚠️ PUSHED EVERY FRAME, NOT ONLY IN `OnStart`. The cap is editable live from the inspector and from
		// `nz_oberon_hitcap`, and a one-time write would silently ignore both. It is one float assignment.
		_ai.MaxHitDamage = MaxHit;

		TickPhase();
		TickFx();
		TickClaw();

		if ( _move is not null ) { TickMove(); return; }

		TryStart();
		TickIdle();
	}

	// ── phase ────────────────────────────────────────────────────────────────

	/// <summary>
	/// Take up the knife at half health, once.
	/// </summary>
	///
	/// ⚠️ IT INTERRUPTS NOTHING. The original drops whatever it is doing, kills the hole and plays
	/// the transition; here the switch waits for the current move to finish, because `PlaySpecial`
	/// refuses while another special is running and a transition that silently did not play would
	/// leave him bare-handed with a knife moveset.
	void TickPhase()
	{
		if ( Phase != 1 || _move is not null ) return;
		if ( !_hp.IsValid() || _hp.Max <= 0f ) return;
		if ( _hp.Current > _hp.Max * PhaseTwoAt ) return;

		// 261 frames @ 30fps
		if ( !_ai.PlaySpecial( "scene_knife", 8.67f ) ) return;

		Phase = 2;
		Status = "taking the knife";
		_cooldown = SkillCooldown;

		// ⛔ PER-INSTANCE OVERRIDES, NOT AN EDIT TO THE `.zvar`. The variant is a shared asset that
		// is written to disk; changing it here would give every other Oberon the knife and persist
		// it. See `ZombieAI.MovementOverride`.
		//
		// ⚠️ AND `RepickAnimations()` OR NOTHING READS THEM. The clip set and the speed derivation
		// are both computed inside `PickAnimations`, so a caller that sets a field and walks away
		// keeps the old gait until some unrelated tier change happens to re-pick — which may be
		// never.
		_ai.MovementOverride = new List<string> { "knife_walk" };
		_ai.AttackOverride = new List<string> { "knife_attack1", "knife_attack2" };
		_ai.ExtraSpeedMultiplier = KnifeWalkSpeedScale;
		_ai.RepickAnimations();

		NZSound.PlayShared( "nz.oberon.close", WorldPosition );
		CameraShake.Punch( WorldPosition, 0.5f, 2500f );

		Log.Info( $"[nz-oberon] phase two — {_hp.Current:0}/{_hp.Max:0} hp" );
	}

	/// <summary>
	/// Spawn one of his effect models as a child, playing its own single clip.
	/// </summary>
	///
	/// ⚠️ A CHILD OBJECT, NOT A BONE MERGE. GMod bone-merges these, but their bones are their own —
	/// `ef_k_03`, `Bone_Root` — and none of them exist on Oberon, so the merge there is really just
	/// parenting with extra steps. Parenting is what it reproduces.
	///
	/// ⚠️ AND IT INHERITS HIS SCALE FOR FREE, which matters: he renders at ×0.5, and an effect at
	/// full size would be twice the boss it is drawn on.
	void Fx( string model, string clip, float life )
	{
		var m = Model.Load( $"models/zombies/{model}.vmdl" );
		if ( m is null )
		{
			Log.Warning( $"[nz-oberon] no effect model '{model}' — is it compiled?" );
			return;
		}

		var go = Scene.CreateObject();
		go.Name = $"oberon {model}";
		go.Flags |= GameObjectFlags.NotSaved;
		go.SetParent( GameObject );

		// ⚠️ SET AFTER PARENTING. `SetParent` preserves the world transform by default, so a local
		// offset written before it is immediately undone.
		go.LocalPosition = Vector3.Up * FxLift;
		go.LocalRotation = Rotation.Identity;
		go.LocalScale = 1f;

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

		_fx.Add( (go, life) );
	}

	/// <summary>Throw away the effects whose time is up.</summary>
	void TickFx()
	{
		for ( int i = _fx.Count - 1; i >= 0; i-- )
		{
			if ( !_fx[i].Until ) continue;

			if ( _fx[i].Go.IsValid() ) _fx[i].Go.Destroy();
			_fx.RemoveAt( i );
		}
	}

	/// <summary>
	/// The claw trail on a knife swing — the one effect whose attack this component does not own.
	/// </summary>
	///
	/// ⛔ IT READS WHICH SWING IS ACTUALLY PLAYING. `knife_attack1` and `knife_attack2` are picked
	/// at random by `ZombieAI` out of the override list, and they carry their claws at different
	/// cycles — 21/55 against 8/45. Alternating the effects would put the right trail on the wrong
	/// swing half the time, which is exactly the kind of wrong that looks like a timing bug.
	void TickClaw()
	{
		var clip = _ai.CurrentClip ?? "";

		if ( clip != _lastClip )
		{
			_lastClip = clip;

			// 56 frames @ 30 = 1.83s, claw at 21/55.   46 @ 30 = 1.50s, claw at 8/45.
			if ( clip == "knife_attack1" )
			{
				_clawModel = "ef_knife1"; _clawClip = "knife_attack1";
				_clawAt = 1.83f * (21f / 55f);
			}
			else if ( clip == "knife_attack2" )
			{
				_clawModel = "ef_knife2"; _clawClip = "knife_attack2";
				_clawAt = 1.50f * (8f / 45f);
			}
		}

		if ( _clawModel is null || !_clawAt ) return;

		Fx( _clawModel, _clawClip, 1f );
		_clawModel = null;
	}

	/// <summary>
	/// Stand and breathe when there is nobody to chase.
	/// </summary>
	///
	/// ⛔ ONLY WITH NO TARGET AT ALL. `PlaySpecial` zeroes his agent speed for the duration, so an
	/// idle played while a player is in the room is a boss who stops chasing to pose. With no
	/// target he is standing still anyway, and this is the difference between standing and being
	/// frozen mid-stride.
	///
	/// ⚠️ THE VARIANT HAS NO IDLE FIELD, which is why this is here rather than in the asset: a
	/// stationary zombie otherwise holds its movement clip at the 0.05 rate floor, because the
	/// playback rate is `velocity / groundSpeed` and velocity is zero.
	void TickIdle()
	{
		if ( _move is not null || !_idle ) return;
		if ( _ai.Target.IsValid() ) return;
		if ( _ai.State is not (ZombieState.Idle or ZombieState.Chasing) ) return;

		var clip = Phase == 2 ? "knife_idle" : "zbs_idle1";

		if ( _ai.PlaySpecial( clip, IdleHold ) )
		{
			_idle = IdleHold + 0.15f;
			Status = Phase == 1 ? "idle" : "idle (knife)";
		}
	}

	// ── choosing ─────────────────────────────────────────────────────────────

	void TryStart()
	{
		Status = Phase == 1 ? "idle" : "idle (knife)";

		if ( Held ) return;
		if ( !_cooldown ) return;

		// ⛔ NEVER OVER THE TOP OF THE AI'S OWN SWING. `PlaySpecial` would refuse anyway while the
		// state is Special, but Attacking is a state it does NOT refuse — and stealing a swing
		// halfway through is how a hit that was already paid for goes missing.
		if ( _ai.State is not (ZombieState.Chasing or ZombieState.Idle) ) return;

		var target = _ai.Target;
		if ( !target.IsValid() ) return;
		if ( WorldPosition.Distance( target.WorldPosition ) > SpecialRange ) return;

		var knife = Phase == 2;

		// ⚠️ `nz_oberon_move` FORCES THE NEXT ONE, AND THAT IS WHY IT EXISTS. The three are rolled
		// uniformly and a move plus its cooldown is about ten seconds, so waiting on a particular one
		// is a dice roll that can easily run a minute — a leap fix was reported broken against a
		// session in which the roll came up barrage twice and the leap never ran at all.
		//
		// ⚠️ ONE SHOT. It is cleared as it is read, so a forced move does not latch the boss into
		// repeating it and the fight goes back to rolling by itself.
		var pick = _forced.HasValue ? (int)_forced.Value : RollKind( target );
		_forced = null;

		// ⚠️ NOTHING ALLOWED — every weight at nothing, or the one there is too near, as basalt's first phase has the leap alone:
		// no special, and the swipe goes on
		if ( pick < 0 ) { _cooldown = SkillCooldown; return; }

		var move = pick switch
		{
			0 => Leap( knife ? "knife_attack3" : "zbs_attack3" ),
			1 => Hole( knife ? "knife_attack_hole" : "zbs_attack_hole" ),
			3 => PulseMove( PulseClip, PulseCharge, PulseRecover ),
			// ⚠️ TWO TAKES OF THE BARRAGE IN PHASE ONE. `zbs_attack_bomb_2` is the same length as
			// `zbs_attack_bomb` and the lua never calls it — an alternate the pack shipped and its
			// own entity forgot. Eleven seconds is a long time to watch the same clip, so it is
			// rolled between them. Phase two has only the one.
			_ => Bomb( knife
					? "knife_attack_bomb"
					: (Game.Random.Int( 0, 1 ) == 0 ? "zbs_attack_bomb" : "zbs_attack_bomb_2") ),
		};

		move.Damage = move.Kind switch
		{
			Kind.Leap => LeapDamage,
			Kind.Hole => HoleDamage,
			Kind.Pulse => 0f,
			_ => BombDamage,
		};

		if ( !_ai.PlaySpecial( move.Clip, move.Seconds ) ) return;

		// ⛔ RE-TIME THE WHOLE MOVE FROM THE CLIP THE ENGINE IS ACTUALLY PLAYING, NOT FROM `Seconds`.
		// Every cue in a Move is a CYCLE — a fraction of the clip — so the seconds it is multiplied
		// by has to be the length the clip really has, or a correctly-placed cue lands at the wrong
		// moment of an animation that itself looks perfectly fine.
		//
		// ⛔ AND THEY DID DISAGREE, ON EVERY CLIP THIS MODEL HAS. `Seconds` was measured as frames ÷
		// the fps the ORIGINAL was authored at (30 for the leap, 15 for the barrage). The DMX export
		// never set Blender's scene fps, so all twenty clips left at its default of 24 — and the
		// vmdl says `framerate = -1`, meaning "use the file's". The leap is 121 frames: 4.03s by the
		// authored rate, 5.04s as it actually plays. The landing cue at cycle 0.525 therefore fired
		// at 2.12s against a touchdown at 2.65s, and the damage and the sound arrived half a second
		// before he reached the floor. Reported exactly that way.
		//
		// ⚠️ THE HOLD IS RE-ARMED WITH IT TOO. `PlaySpecial` was told 4s for a 5s clip, so the last
		// second of the leap — the whole recovery — was being cut off by the return to walking.
		//
		// ⚠️ THE AUTHORED NUMBER SURVIVES AS THE FALLBACK, for the frame where the renderer has not
		// caught up: a length of zero puts every cycle at infinity and the move never fires at all.
		var authored = move.Seconds;
		var real = _ai.CurrentClipDuration;
		if ( real > 0.05f && move.Kind != Kind.Pulse )
		{
			move.Seconds = real;

			// ⚠️ `Ends` — see the field. For everything but the leap this is 1 and the hold is
			// the whole clip, exactly as before.
			_ai.HoldSpecialFor( real * MathX.Clamp( move.Ends, 0.1f, 1f ) );
		}

		_move = move;
		_lastKind = move.Kind;
		_startedAt = Time.Now;
		_hit = false;
		_launched = false;
		_wave = 0;

		Status = $"{move.Kind.ToString().ToLowerInvariant()} ({move.Clip})";

		// ⚠️ ONE LINE PER SPECIAL, AND THEY ARE 3 SECONDS APART AT BEST. "The effect lands but there
		// is no animation" is not a question this code can answer from outside — `PlaySpecial`
		// returning true means the sequence was found and set, so if that is logged and nothing
		// moves on screen, the fault is downstream of here rather than in the choosing.
		Log.Info( $"[nz-oberon] {move.Kind} '{move.Clip}' {move.Seconds:0.00}s (phase {Phase})"
			+ (MathF.Abs( real - authored ) > 0.02f ? $" [authored {authored:0.00}s]" : "") );

		if ( move.Kind == Kind.Hole )
		{
			NZSound.PlayShared( "nz.oberon.close", WorldPosition );

			// ⚠️ 4.9s IS THE ORIGINAL'S OWN TIMER, against a six-second attack — the hole closes
			// while he is still finishing the swing, which is what it does in GMod.
			Fx( "ef_hole", "idle1", 4.9f );

			// ⛔ DRAWN AT `PullRadius`, WHICH MAKES IT A TELEGRAPH RATHER THAN DECORATION. The
			// rim is exactly the line `TickPull` tests against, so standing outside the ring is
			// standing outside the attack — and a vortex drawn generously would be the attack
			// lying about its reach. `ef_hole` is the prop at his feet; this is the reach.
			//
			// ⚠️ IT LASTS THE PULL, NOT THE CLIP. From the cue the drag starts to the cue it
			// lands, off `move.Seconds` — which by here is the clip's REAL length, so this follows
			// the same re-timing every other cue in the move does.
			//
			// ⚠️ AND IT DOES NOT FOLLOW HIM, because he does not move: `PlaySpecial` zeroes his
			// agent for the duration. That is what lets the vortex measure the floor once at spawn
			// instead of every frame — see the note on `Vortex._height`.
			Vortex.SpawnShared( WorldPosition, PullRadius,
				(move.HitAt - move.PullFrom) * move.Seconds );

			// ⛔ AND THE PULL IS FELT ON EACH MACHINE BY ITS OWN PLAYER (`BossPull`): the host dragging everyone from here moved
			// its own player and nobody else's — a body another machine simulates is written back within a frame
			NZNet.OberonPull( WorldPosition, PullRadius, PullSpeed, move.PullFrom * move.Seconds,
				(move.HitAt - move.PullFrom) * move.Seconds );
		}

		// ⛔ THE PULSE'S WARNING: its reach on the floor, brightening as it charges, a ring closing in on him — every machine
		// draws its own
		if ( move.Kind == Kind.Pulse )
		{
			NZSound.PlayShared( "nz.oberon.close", WorldPosition );
			NZNet.OberonPulseCharge( WorldPosition, PulseRadius, move.HitAt * move.Seconds );
		}
	}

	// ── running one ──────────────────────────────────────────────────────────

	void TickMove()
	{
		var m = _move;
		var cycle = m.Seconds <= 0f ? 1f : (Time.Now - _startedAt) / m.Seconds;

		if ( m.Kind == Kind.Leap && !_launched && cycle >= m.LaunchAt )
		{
			_launched = true;

			// ⛔ THE ARC RUNS FROM THE LAUNCH CUE TO THE LANDING HIT, not for the whole clip — the
			// authored cycles are 16/120 and 63/120. He is in the air for that window and stood on
			// the floor for the wind-up and the recovery, which is what the animation draws.
			//
			// ⚠️ OFF `m.Seconds`, WHICH IS THE CLIP'S REAL LENGTH, so the arc ends on the same frame
			// the landing hit fires whatever the export did to the frame rate.
			var airborne = (m.HitAt - m.LaunchAt) * m.Seconds;

			var to = WorldPosition;
			if ( m.To is Vector3 sent )
			{
				to = sent;
			}
			else if ( _ai.Target.IsValid() )
			{
				var course = (_ai.Target.WorldPosition - WorldPosition).WithZ( 0f );

				// ⚠️ TOWARD THE TARGET BUT NO FURTHER THAN THE AUTHORED DISTANCE, and never past
				// them: a leap that overshoots puts a boss the size of a house on the far side of
				// the person it was jumping at.
				var reach = MathF.Min( course.Length, LeapDistance * BodyScale );
				to = WorldPosition + course.Normal * reach;
			}
			else
			{
				to = WorldPosition + _ai.WorldRotation.Forward.WithZ( 0f ).Normal * LeapDistance * BodyScale;
			}

			var began = _ai.BeginArc( to, airborne, LeapHeight * BodyScale, snapLanding: !m.Dive );

			// ⛔ THIS LINE IS THE POINT OF THE WHOLE EXERCISE. "It stays on the ground" has at least
			// four causes that are identical from outside — the cue never fired, `BeginArc` refused,
			// the arc ran for no time, or the landing snapped to where he already stood — and a
			// fifth that is not a leap at all: the barrage, which he performs on all fours without
			// moving. Two rounds of this were spent on evidence that could not tell them apart.
			Log.Info( $"[nz-oberon] leap launch{(began ? "" : " REFUSED")}"
				+ $" · airborne {airborne:0.00}s · lift {LeapHeight:0}"
				+ $" · travel {WorldPosition.Distance( to ):0}u" );

			if ( !NZGame.IsClient ) NZNet.ShakeAt( WorldPosition, 0.3f, 1800f );
		}

		// ⛔ THE LEAN IS DRIVEN EVERY FRAME, NOT SET ONCE. It is a ramp read off the cycle, and
		// `FaceMovement` rebuilds the rotation from it each frame — so a single write at the wave
		// cue would be overwritten before it was ever drawn.
		if ( m.Kind == Kind.Bomb )
			_ai.LeanPitch = BombTilt * TiltAt( cycle, m.Seconds );

		if ( m.Kind == Kind.Pulse )
		{
			if ( !_hit && cycle >= m.HitAt )
			{
				_hit = true;
				FirePulse();
			}
		}
		// ⛔ A DIVE ENDS A MOMENT BEFORE ITS ARC DOES, UNDER THE LAVA: gone before the arc hands him back to an agent that would
		// set him on the navmesh again (`EndDive`)
		else if ( m.Dive )
		{
			if ( !_hit && cycle >= m.HitAt - 0.03f )
			{
				_hit = true;
				EndDive();
				return;
			}
		}
		else if ( m.Kind == Kind.Bomb && m.Waves is not null )
		{
			while ( _wave < m.Waves.Length && cycle >= m.Waves[_wave] )
			{
				BombWave();
				_wave++;
			}
		}
		// ⚠️ `!Arcing` IS PART OF THE LEAP'S CUE, NOT A SAFETY CHECK. The cycle says when the clip
		// draws the touchdown; the arc is what carries the body down, and the two are advanced by
		// different components on the same frame — so on the frame the cycle crosses the cue the
		// body may still be a step above the floor. For every other move `Arcing` is already false
		// and this reads as the cycle alone.
		else if ( !_hit && m.HitAt > 0f && cycle >= m.HitAt && !_ai.Arcing )
		{
			_hit = true;
			Strike( m );

			// ⚠️ THE THIRD CLAW GOES WITH THE KNIFE HOLE AND NOTHING ELSE — the lua hangs
			// `CreateClaw3` off `knife_attack_hole` at the same cycle as the hit.
			if ( Phase == 2 && m.Kind == Kind.Hole )
				Fx( "ef_knife3", "knife_attack3", 1f );
		}

		if ( cycle >= MathX.Clamp( m.Ends, 0.1f, 1f ) )
		{
			Unlean();
			_move = null;
			_cooldown = SkillCooldown;
			Status = Phase == 1 ? "idle" : "idle (knife)";
		}
	}

	/// <summary>
	/// The pulse lands: every machine sees it go out, and dazes its own player inside its reach (`NZNet.OberonPulse` →
	/// <see cref="LandPulse"/>) — the Shrieker's daze, and no damage, as the Shrieker's scream has none. HOST.
	/// </summary>
	void FirePulse()
	{
		NZSound.PlayShared( "nz.oberon.attack", WorldPosition );
		// ⚠️ NO SHAKE OF ITS OWN HERE: `NZNet.OberonPulse` lands on every machine, the host too, and shakes each one there
		// (`LandPulse`) — this one made the host's shake twice (the co-op audit, 2026-09-27).
		NZNet.OberonPulse( WorldPosition, PulseRadius, PulseDaze );
	}

	/// <summary>
	/// Dive: his own leap, `zbs_attack3`, to exactly there — off the navmesh, with no blow where he lands: into the lava, as
	/// basalt's boss fight has him at each phase change. Refused while he is doing anything else; the fight asks again. HOST.
	/// </summary>
	public bool Dive( Vector3 into )
	{
		if ( !_ai.IsValid() || _move is not null || Dived ) return false;
		if ( _ai.State is not (ZombieState.Chasing or ZombieState.Idle) ) return false;

		var move = Leap( "zbs_attack3" );
		move.To = into;
		move.Dive = true;
		move.Ends = 1f;
		if ( !_ai.PlaySpecial( move.Clip, move.Seconds ) ) return false;

		var real = _ai.CurrentClipDuration;
		if ( real > 0.05f )
		{
			move.Seconds = real;
			_ai.HoldSpecialFor( real );
		}

		_move = move;
		_lastKind = Kind.Leap;
		_startedAt = Time.Now;
		_hit = false;
		_launched = false;
		_wave = 0;
		Status = "diving into the lava";
		NZSound.PlayShared( "nz.oberon.close", WorldPosition );
		Log.Info( $"[nz-oberon] dive into {into:0} — {move.Seconds:0.00}s" );
		return true;
	}

	/// <summary>Has the dive gone under, and where: basalt's fight despawns him and splashes there.</summary>
	public bool Dived { get; private set; }
	public Vector3 DivedAt { get; private set; }

	/// <summary>Under the lava: out of sight at once — switched off, his AI with him — for the fight to take away.</summary>
	void EndDive()
	{
		Dived = true;
		DivedAt = WorldPosition;
		Unlean();
		_move = null;
		GameObject.Enabled = false;
		Log.Info( $"[nz-oberon] under the lava at {DivedAt:0}" );
	}

	/// <summary>The pulse's warning, drawn here. EVERY machine — `NZNet.OberonPulseCharge`.</summary>
	public static void ShowPulseCharge( Vector3 at, float radius, float seconds ) => PulseTelegraph.Fire( at, radius, seconds );

	/// <summary>
	/// The pulse gone off. EVERY machine — `NZNet.OberonPulse`: the ring going out, the ground's thump, and this machine's own
	/// player dazed if they stand inside it — the daze is movement, and movement is the owner's (`SonicDaze`).
	/// </summary>
	public static void LandPulse( Vector3 at, float radius, float daze )
	{
		ShockRing.Fire( at, radius, PulseTelegraph.Colour );
		CameraShake.Punch( at, 0.5f, radius * 3f );

		var me = NZPlayer.Local;
		if ( !me.IsValid() || me.IsOutOfRound || me.WorldPosition.Distance( at ) > radius ) return;
		SonicDaze.Apply( me, daze );
	}

	/// <summary>The hole's pull, felt by this machine's own player. EVERY machine — `NZNet.OberonPull`.</summary>
	public static void FeelPull( Vector3 at, float radius, float speed, float delay, float seconds )
		=> BossPull.Apply( NZPlayer.Local, at, radius, speed, delay, seconds );

	/// <summary>One wave of the barrage — several blasts scattered around him.</summary>
	///
	/// ⚠️ NOT PROJECTILES. The original spawns fifteen `proj_drg_bomb` per wave on a 0.03s stagger,
	/// flung at randomised velocities; there is no ported equivalent, and inventing a projectile to
	/// get a barrage is a much larger change than the barrage is. Blasts land in a ring instead —
	/// same shape of threat, none of the travel time, and the clip is what sells it either way.
	void BombWave()
	{
		NZSound.PlayShared( "nz.oberon.attack", WorldPosition );

		// ⚠️ THROWN FROM HIS UPPER BODY, off `BodyHeight` — which `ApplyModelScale` already
		// carries — so the shells leave his hands rather than his feet at any size.
		var from = WorldPosition + Vector3.Up * (_ai.BodyHeight * 0.8f);

		// ⛔ CAPTURED NOW, NOT READ AT THE LANDING. `_move` is null by the time the last wave's
		// bombs arrive; see `_pending`.
		var damage = _move.Damage;

		for ( int i = 0; i < Math.Max( 1, BombsPerWave ); i++ )
		{
			var a = Game.Random.Float( 0f, MathF.Tau );
			var r = Game.Random.Float( BombSpread * 0.25f, BombSpread );

			// ⚠️ THE LANDING POINT IS SNAPPED TO THE FLOOR HERE, ONCE, and then sent verbatim to
			// every machine. The marker ring, the crater and the damage all read this one vector,
			// so there is nothing for them to disagree about.
			var to = PitVisual.GroundAt(
				WorldPosition + new Vector3( MathF.Cos( a ) * r, MathF.Sin( a ) * r, 0f ) );

			var delay = MathF.Max( 0f, BombStagger ) * i;

			// ⛔ THE ARC SCALES WITH THE THROW, OR THE FAR ONES READ AS TRACER FIRE. `BombFlight`
			// and `BombApex` describe a bomb going the WHOLE distance; at 1800 units of spread a
			// near one covering 450 of them on the same timing is a flat horizontal streak. Both
			// are eased down for short throws so every bomb is recognisably lobbed.
			var f = MathX.Clamp( to.Distance( WorldPosition )
				/ MathF.Max( 1f, BombSpread ), 0f, 1f );

			var flight = MathF.Max( 0.15f, BombFlight )
				* MathX.Lerp( 0.6f, 1f, f ) * Game.Random.Float( 0.85f, 1.15f );

			var apex = BombApex * MathX.Lerp( 0.55f, 1f, f );

			BombShell.ThrowShared( from, to, delay, flight, apex, BombRadius );

			// ⚠️ 24 UNITS UP, the height the blast used to be centred at — a blast centred on
			// the floor itself loses its line-of-sight trace to anything standing on that floor.
			_pending.Add( (Time.Now + delay + flight, to + Vector3.Up * 24f, damage) );
		}

		if ( !NZGame.IsClient ) NZNet.ShakeAt( WorldPosition, 0.35f, 2200f );
	}

	/// <summary>
	/// How far through the lean he is at a given cycle: 0 upright, 1 fully down.
	/// </summary>
	///
	/// ⚠️ TWO SMOOTHSTEPS SUBTRACTED, not one ramp with a hold. Down minus up gives the hold
	/// for free and cannot produce a value outside 0..1, which a hand-written three-phase ramp
	/// does the first time the two cycles are set the wrong way round.
	float TiltAt( float cycle, float seconds )
	{
		const float ramp = 0.08f;

		static float Smooth( float x )
		{
			x = MathX.Clamp( x, 0f, 1f );
			return x * x * (3f - 2f * x);
		}

		// ⚠️ THE LEAD IS SECONDS, SO IT HAS TO BE DIVIDED BY THE CLIP'S REAL LENGTH TO BECOME A
		// CYCLE. At the bomb's actual 6.96s, one second is 0.144 of it — so the lean starts at
		// cycle 0.026 and is fully down by 0.11, about three quarters of a second in.
		//
		// ⚠️ A LEAD LONG ENOUGH TO PUSH THE START BELOW ZERO IS HARMLESS: `Smooth` clamps, so he
		// simply begins the move already leaning rather than popping.
		var lead = seconds > 0.01f ? MathF.Max( 0f, BombTiltLead ) / seconds : 0f;

		return Smooth( (cycle - (BombTiltDown - lead)) / ramp )
			- Smooth( (cycle - BombTiltUp) / ramp );
	}

	/// <summary>
	/// Put him upright, now, rather than next frame.
	/// </summary>
	///
	/// ⛔ THE ROTATION IS WRITTEN HERE AND NOT LEFT TO `FaceMovement`. That method returns early
	/// when there is nothing to face — the target died during the barrage is the ordinary case —
	/// and a lean cleared without also fixing the transform would leave him lying on his face for
	/// the rest of the round.
	void Unlean()
	{
		if ( !_ai.IsValid() || _ai.LeanPitch == 0f ) return;

		_ai.LeanPitch = 0f;
		_ai.WorldRotation = Rotation.FromYaw( _ai.WorldRotation.Angles().yaw ) * _ai.ModelTurn;
	}

	/// <summary>Blast anything standing where a bomb has just landed.</summary>
	///
	/// ⚠️ BACKWARDS, so removing the one that fired does not skip the next.
	/// <summary>
	/// Which special to run: weighted, never the barrage twice, never a leap into someone's face.
	/// </summary>
	///
	/// ⚠️ A WEIGHT OF ZERO IS A REAL OUTCOME HERE, and two of the three can hit it at once —
	/// a barrage that just ran, at point-blank range. The total is checked rather than assumed,
	/// and nothing left is -1: no special. It fell back to the hole, which basalt's boss fight gives
	/// him only in its last phase.
	int RollKind( GameObject target )
	{
		var leap = MathF.Max( 0f, LeapWeight );
		var hole = MathF.Max( 0f, HoleWeight );
		var bomb = MathF.Max( 0f, BombWeight );
		var pulse = MathF.Max( 0f, PulseWeight );

		// ⛔ NEVER TWICE RUNNING. Seven seconds of standing still throwing bombs is the longest
		// thing he does, and back to back it is fifteen seconds in which he never closes.
		if ( _lastKind == Kind.Bomb ) bomb = 0f;

		// ⛔ AND NEVER AT SOMEBODY ALREADY ON TOP OF HIM. See `LeapMinRange`.
		if ( target.IsValid()
			&& WorldPosition.Distance( target.WorldPosition ) < LeapMinRange ) leap = 0f;

		// ⛔ AND NO PULSE AT SOMEBODY OUT OF ITS REACH: a daze that lands on nobody is a second and a half of standing still
		if ( target.IsValid()
			&& WorldPosition.Distance( target.WorldPosition ) > PulseRadius ) pulse = 0f;

		var total = leap + hole + bomb + pulse;
		if ( total <= 0f ) return -1;

		var roll = Game.Random.Float( 0f, total );

		if ( roll < leap ) return (int)Kind.Leap;
		if ( roll < leap + hole ) return (int)Kind.Hole;
		if ( roll < leap + hole + bomb ) return (int)Kind.Bomb;

		return (int)Kind.Pulse;
	}

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

			Blast( _pending[i].Where, BombRadius, _pending[i].Damage );
			_pending.RemoveAt( i );
		}
	}

	/// <summary>The landing hit of a leap, or the hole going off.</summary>
	void Strike( Move m )
	{
		var at = WorldPosition + Vector3.Up * 32f;

		NZSound.PlayShared( "nz.oberon.attack", WorldPosition );
		Blast( at, m.Radius, m.Damage );

		// The original shakes at 125 over 1000 units for both; the hole reaches further, so it
		// shakes further.
		if ( !NZGame.IsClient ) NZNet.ShakeAt( WorldPosition, 0.6f, m.Kind == Kind.Hole ? 3000f : 2200f );

		// ⛔ THE LEAP ONLY. This fires at the landing cue for the hole as well, and a hole that
		// PULLS everything inward while throwing a wave outward reads as the opposite of itself.
		//
		// ⚠️ DRAWN AT THE DAMAGE RADIUS, so what you see is what was hit — `ShockRing`'s own
		// reason for existing. ⚠️ AT HIS FEET, not the +32 the blast is centred on: the ring
		// snaps itself to the floor and a centre inside his chest only makes the trace longer.
		if ( m.Kind == Kind.Leap )
			ShockRing.FireShared( WorldPosition, m.Radius * LandingRingScale, LandingRing );
	}

	/// <summary>
	/// Damage everything of ours inside a radius, with line of sight and linear falloff.
	/// </summary>
	///
	/// ⚠️ THE SAME SHAPE AS THE NAPALM ZOMBIE'S BLAST, INCLUDING THE 24 UNITS OF TRACE SLACK. A ray
	/// aimed at a player's centre stops on their CAPSULE about 16 units short, so the obvious
	/// "did the ray arrive" test throws the hit away against a target standing in the open.
	void Blast( Vector3 at, float radius, float weight )
	{
		var scene = Scene;
		if ( !scene.IsValid() || radius <= 0f ) return;

		int round = RoundManager.Instance?.Round ?? 1;
		if ( round < 1 ) round = 1;

		// ⚠️ AND THE MATCH'S ZOMBIE DAMAGE (the lobby's Difficulty, 2026-10-05), under `MaxHit` as ever
		var damage = ZombieStats.AttackDamageForRound( round )
			* (_ai.Variant?.DamageMultiplier ?? 1f)
			* weight
			* (Phase == 2 ? PhaseTwoDamageScale : 1f)
			* Difficulty.ZombieDamage;

		foreach ( var p in scene.GetAllComponents<NZPlayer>().ToList() )
		{
			if ( !p.IsValid() ) continue;

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

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

			var tr = scene.Trace.Ray( at, to ).IgnoreGameObject( GameObject ).Run();
			if ( tr.Hit && tr.HitPosition.Distance( to ) > 24f ) continue;

			var falloff = 1f - MathX.Clamp( dist / MathF.Max( radius, 1f ), 0f, 1f );

			// ⛔ NEVER MORE THAN `MaxHit`: no blast of his takes a player down from full health (`HitCap`).
			//
			// ⛔ AND WITH HIS NAME ON IT, AS AN AREA HIT (`Health.Apply`'s `blast`): armor spends on it as on his swipe — *"armor
			// needs to react to the other attacks too"* (2026-09-27) — and Victorious Tortoise judges it by where it went off
			// (`blastAt`, *"fix that too"*), while what answers a claw does not fire, so Retaliate cannot stun him with his own
			// bombs. Through `OnDamage` it came with no attacker, and armor, which answers only an enemy's hit, let it through.
			hp.Apply( MathF.Min( damage * falloff, MaxHit ), false, GameObject, blast: true, blastAt: at );
		}
	}

	/// <summary>
	/// `nz_oberon_hitcap [share]` — the most one hit of his takes, as a share of a player's base health (0.1-0.95), and what
	/// each attack does at this round under it: the swipe, and the leap, the black hole and one bomb at their middles — less
	/// further out. Bare, it reports. A share set here holds for the Oberons in the scene; a new one starts at two-thirds.
	/// ⚠️ It replaces `nz_oberon_swipe`, whose one job — to make his swipe kill outright, or not — is gone.
	/// </summary>
	[ConCmd( "nz_oberon_hitcap" )]
	public static void HitCapCmd( float share = -1f )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }

		var all = scene.GetAllComponents<OberonBoss>().ToList();
		if ( share > 0f )
			foreach ( var o in all ) o.HitCap = Math.Clamp( share, 0.1f, 0.95f );

		var b = all.FirstOrDefault();
		var baseHealth = ActiveConfig.Player.MaxHealth;
		var cap = b is not null ? b.MaxHit : baseHealth * DefaultHitCap;
		Log.Info( $"[nz-oberon] the most one hit of his takes: {cap:0} — {(b?.HitCap ?? DefaultHitCap) * 100f:0}% of a player's"
			+ $" {baseHealth:0} base health, so none downs anyone from full" );
		if ( b is null || !b._ai.IsValid() ) { Log.Info( "[nz-oberon]   none in the scene for the rest — nz_spawn oberon" ); return; }

		int round = Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
		var swing = ZombieStats.AttackDamageForRound( round ) * (b._ai.Variant?.DamageMultiplier ?? 1f);
		string Hit( float weight ) => $"{MathF.Min( swing * weight, cap ):0}";
		var trigger = b._ai.AttackRange + b._ai.AttackRangePadding;
		Log.Info( $"[nz-oberon]   round {round}: swipe {Hit( 1f )} · leap {Hit( b.LeapDamage )} · black hole {Hit( b.HoleDamage )}"
			+ $" · bomb {Hit( b.BombDamage )} — the three at their middles, less further out · the pulse none" );
		Log.Info( $"[nz-oberon]   his swipe starts within {trigger:0}u and lands within {trigger * b._ai.ScaledAttackReach:0}u" );
	}

	/// <summary>
	/// `nz_oberon_odds [leap] [hole] [bomb] [cooldown]` — how often he picks each special.
	/// </summary>
	///
	/// ⚠️ IT PRINTS THE RESULTING SECONDS-PER-LEAP, which is the thing being tuned and is not
	/// readable off the weights: the clips are 5 to 7.5 seconds long, so they dominate the cadence
	/// far more than the odds do.
	[ConCmd( "nz_oberon_odds" )]
	public static void OddsCmd( float leap = -1f, float hole = -1f,
		float bomb = -1f, float cooldown = -1f )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }

		var all = scene.GetAllComponents<OberonBoss>().ToList();
		if ( all.Count == 0 ) { Log.Info( "[nz-oberon] none in the scene" ); return; }

		foreach ( var o in all )
		{
			if ( leap >= 0f ) o.LeapWeight = leap;
			if ( hole >= 0f ) o.HoleWeight = hole;
			if ( bomb >= 0f ) o.BombWeight = bomb;
			if ( cooldown >= 0f ) o.SkillCooldown = cooldown;
		}

		var b = all[0];
		var total = MathF.Max( 0.001f, b.LeapWeight + b.HoleWeight + b.BombWeight );

		Log.Info( $"[nz-oberon] odds leap {b.LeapWeight / total * 100f:0}%"
			+ $" · hole {b.HoleWeight / total * 100f:0}%"
			+ $" · bomb {b.BombWeight / total * 100f:0}%"
			+ $" · cooldown {b.SkillCooldown:0.##}s" );

		// ⚠️ HOW LONG EACH ONE HOLDS HIM, WHICH FOR THE LEAP IS NOT ITS CLIP LENGTH: 5.04s of
		// animation cut to 0.78 by `Move.Ends`. Bomb 6.96 and hole 7.54 run whole.
		var avg = (b.LeapWeight * 3.93f + b.HoleWeight * 7.54f + b.BombWeight * 6.96f) / total
			+ b.SkillCooldown;

		Log.Info( $"[nz-oberon]   a move every {avg:0.#}s ·"
			+ $" a leap every {avg / MathF.Max( 0.01f, b.LeapWeight / total ):0.#}s" );
	}

	/// <summary>
	/// `nz_oberon_barrage [count] [spread] [radius] [damage]` — retune the whole attack.
	/// </summary>
	///
	/// ⚠️ FOUR NUMBERS IN ONE COMMAND BECAUSE THEY ARE ONE DECISION. Raising the count without
	/// the spread packs the same floor tighter; raising the spread without the crater leaves gaps
	/// wide enough to stand in. Anything omitted is left alone.
	///
	/// ⚠️ DAMAGE IS A WEIGHT, NOT A NUMBER OF HIT POINTS. It multiplies the round's attack
	/// damage and the variant's ×3, so the printed figure is what a bomb is worth in rounds, not
	/// what it takes off.
	[ConCmd( "nz_oberon_barrage" )]
	public static void BarrageCmd( float count = -1f, float spread = -1f,
		float radius = -1f, float damage = -1f )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }

		var all = scene.GetAllComponents<OberonBoss>().ToList();
		if ( all.Count == 0 ) { Log.Info( "[nz-oberon] none in the scene" ); return; }

		foreach ( var o in all )
		{
			if ( count > 0f ) o.BombsPerWave = (int)count;
			if ( spread > 0f ) o.BombSpread = spread;
			if ( radius > 0f ) o.BombRadius = radius;
			if ( damage > 0f ) o.BombDamage = damage;
		}

		var b = all[0];

		Log.Info( $"[nz-oberon] barrage {b.BombsPerWave} × 3 waves"
			+ $" · spread {b.BombSpread:0}u · crater {b.BombRadius:0}u"
			+ $" · weight {b.BombDamage:0.##}" );

		// ⚠️ THE COVERAGE IS PRINTED BECAUSE IT IS THE THING BEING TUNED and it is not obvious
		// from the four inputs. Over 1 rather than capped: above 1 the craters overlap, which is
		// the point at which the floor stops having safe gaps in it.
		var area = MathF.PI * b.BombSpread * b.BombSpread;
		var cover = b.BombsPerWave * MathF.PI * b.BombRadius * b.BombRadius / MathF.Max( 1f, area );

		Log.Info( $"[nz-oberon]   one wave covers {cover * 100f:0}% of the disc"
			+ $" · {b.BombDamage * (b._ai.Variant?.DamageMultiplier ?? 1f):0.##}×"
			+ " a round's attack per bomb" );
	}

	/// <summary>
	/// `nz_oberon_tilt [degrees] [lead]` — how far he leans to throw the barrage.
	/// </summary>
	///
	/// ⚠️ IT TAKES EFFECT MID-THROW. The lean is recomputed from the cycle every frame, so a
	/// value set while the barrage is running is visible on the next one — which is the only
	/// practical way to judge an angle on an eleven-second attack.
	///
	/// ⚠️ WITH NO ARGUMENT IT ONLY REPORTS. Negative values lean him backwards, which is worth
	/// trying once if forward turns out to be the wrong sign on this rig.
	[ConCmd( "nz_oberon_tilt" )]
	public static void TiltCmd( float degrees = float.NaN, float lead = float.NaN )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }

		var all = scene.GetAllComponents<OberonBoss>().ToList();
		if ( all.Count == 0 ) { Log.Info( "[nz-oberon] none in the scene" ); return; }

		foreach ( var o in all )
		{
			if ( !float.IsNaN( degrees ) ) o.BombTilt = degrees;
			if ( !float.IsNaN( lead ) ) o.BombTiltLead = lead;
		}

		foreach ( var o in all )
			Log.Info( $"[nz-oberon] barrage lean {o.BombTilt:0.#}°"
				+ $" · {o.BombTiltLead:0.##}s early"
				+ $" · down {o.BombTiltDown:0.##} up {o.BombTiltUp:0.##} (cycles)"
				+ $" · now {(o._ai.IsValid() ? o._ai.LeanPitch : 0f):0.#}°" );
	}

	/// <summary>
	/// `nz_oberon_ring [r] [g] [b]` — recolour the landing shockwave, and fire one to look at.
	/// </summary>
	///
	/// ⚠️ IT FIRES A PREVIEW BECAUSE COLOUR CANNOT BE JUDGED FROM A NUMBER, and the real trigger
	/// is the back half of a five-second leap that has to be rolled for first. Values are 0–1.
	///
	/// ⚠️ WITH NO ARGUMENTS IT ONLY REPORTS AND PREVIEWS, so it is safe to type to see what is
	/// currently set rather than only to change it.
	[ConCmd( "nz_oberon_ring" )]
	public static void RingCmd( float r = -1f, float g = 0f, float b = 0f )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }

		var all = scene.GetAllComponents<OberonBoss>().ToList();
		var colour = all.Count > 0 ? all[0].LandingRing : DefaultRing;

		if ( r >= 0f )
		{
			colour = new Color( r, g, b );
			foreach ( var o in all ) o.LandingRing = colour;
		}

		Log.Info( $"[nz-oberon] landing ring {colour.r:0.##},{colour.g:0.##},{colour.b:0.##}"
			+ $" on {all.Count} boss(es){(r >= 0f ? " — set" : "")}" );

		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Info( "[nz-oberon] no local player to preview at" ); return; }

		// ⚠️ `Fire`, NOT `FireShared`. A preview is for the person typing the command; announcing
		// it would put a ring under everybody else's feet for no reason.
		ShockRing.Fire( p.WorldPosition, 600f, colour );
		Log.Info( "[nz-oberon] preview ring at your feet" );
	}

	/// <summary>
	/// `nz_oberon_move leap|hole|bomb|pulse` — force the next special instead of waiting on the roll.
	/// </summary>
	///
	/// ⚠️ IT CLEARS THE COOLDOWN TOO, so the forced move starts on the next opportunity rather than
	/// up to three seconds later. It does NOT interrupt one already running: stealing a special
	/// halfway through leaves its effects half-applied, and the thing being tested is the whole move.
	[ConCmd( "nz_oberon_move" )]
	public static void ForceMove( string which )
	{
		if ( !Enum.TryParse<Kind>( which, ignoreCase: true, out var kind ) )
		{
			Log.Warning( $"[nz-oberon] '{which}' is not a move — leap, hole, bomb or pulse" );
			return;
		}

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }

		var all = scene.GetAllComponents<OberonBoss>().ToList();
		if ( all.Count == 0 ) { Log.Info( "[nz-oberon] none in the scene" ); return; }

		foreach ( var o in all )
		{
			o._forced = kind;
			o._cooldown = 0f;
		}

		Log.Info( $"[nz-oberon] next move forced to {kind} on {all.Count}" );
	}

	/// <summary>
	/// `nz_oberon_pulse [radius] [daze] [charge] [clip]` — the pulse's reach, its daze and its charge, in units and seconds, and
	/// what he plays for it; 0 or "" leaves one alone. It fires none — `nz_oberon_move pulse` does.
	/// </summary>
	[ConCmd( "nz_oberon_pulse" )]
	public static void PulseCmd( float radius = 0f, float daze = 0f, float charge = 0f, string clip = "" )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }

		var all = scene.GetAllComponents<OberonBoss>().ToList();
		if ( all.Count == 0 ) { Log.Info( "[nz-oberon] none in the scene" ); return; }

		foreach ( var o in all )
		{
			if ( radius > 0f ) o.PulseRadius = radius;
			if ( daze > 0f ) o.PulseDaze = daze;
			if ( charge > 0f ) o.PulseCharge = charge;
			if ( !string.IsNullOrWhiteSpace( clip ) ) o.PulseClip = clip.Trim();
		}

		var b = all[0];
		Log.Info( $"[nz-oberon] pulse {b.PulseRadius:0}u · daze {b.PulseDaze:0.#}s · charge {b.PulseCharge:0.##}s"
			+ $" · recover {b.PulseRecover:0.##}s · clip '{b.PulseClip}' · weight {b.PulseWeight:0.##}" );
	}

	/// <summary>
	/// `nz_oberon_clip &lt;clip&gt; [seconds]` — play one of his clips on every Oberon, held this long, to look at it: for
	/// choosing the pulse's. Refused while he is in a move of his own.
	/// </summary>
	[ConCmd( "nz_oberon_clip" )]
	public static void ClipCmd( string clip = "", float seconds = 3f )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }
		if ( string.IsNullOrWhiteSpace( clip ) ) { Log.Warning( "[nz-oberon] nz_oberon_clip <clip> [seconds] — zbs_idle1, scene_appear, zbs_attack2 …" ); return; }

		foreach ( var o in scene.GetAllComponents<OberonBoss>().ToList() )
		{
			var played = o._move is null && o._ai.IsValid() && o._ai.PlaySpecial( clip.Trim(), MathF.Max( 0.2f, seconds ) );
			Log.Info( played
				? $"[nz-oberon] playing '{clip.Trim()}' — the clip is {o._ai.CurrentClipDuration:0.00}s, held {seconds:0.#}s"
				: $"[nz-oberon] '{clip.Trim()}' not played — he is busy, or has no such clip" );
		}
	}

	/// <summary>
	/// `nz_oberon` — what every Oberon in the scene is doing.
	/// </summary>
	///
	/// ⚠️ IT REPORTS RATHER THAN SPAWNS. `nz_boss_spawn oberon` already exists; a second command
	/// that also spawned one would be two answers to "how do I get a boss".
	[ConCmd( "nz_oberon" )]
	public static void Report()
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-oberon] no scene" ); return; }

		var all = scene.GetAllComponents<OberonBoss>().ToList();
		if ( all.Count == 0 ) { Log.Info( "[nz-oberon] none in the scene" ); return; }

		foreach ( var o in all )
		{
			var hp = o.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
			Log.Info( $"[nz-oberon] phase {o.Phase} · {o.Status}"
				+ $" · {(hp.IsValid() ? $"{hp.Current:0}/{hp.Max:0} hp" : "no health")}"
				+ $" · cooldown {(float)o._cooldown:0.0}s" );
		}
	}
}