Game/Miner.cs
namespace Monolith;

/// <summary>
/// Turns player input and drone timers into blast requests. Nothing here mutates the voxel
/// world directly: every removal goes to the host and comes back as a broadcast, so all
/// clients see identical holes.
/// </summary>
public sealed class Miner : Component
{
	[Property] public CameraComponent Camera { get; set; }

	/// <summary>Where the player is currently aiming, for the HUD crosshair and drone targeting.</summary>
	public VoxelHit AimHit { get; private set; }

	public bool HasAim => AimHit.Hit;

	private float fireAccumulator;
	private float droneAccumulator;

	// ---------------------------------------------------------------- demolition

	// The live charge and the reload cooldown are TWO INDEPENDENT TRACKS, not one state
	// machine. Right click fires a charge and simultaneously starts the cooldown. From then on:
	//
	//   - the charge bores in and waits, and left click detonates it whenever you choose;
	//   - the cooldown runs its own course, and a right click inside its window catches the
	//     reload and returns the charge instantly.
	//
	// Those happen in any order. Missing the reload never costs you the live charge: it still
	// detonates on your next left click, you just wait the full cooldown for the next one.

	// --- track A: the live charge ---
	private bool chargeLive;
	private bool drillStopped;
	private DemoCharge chargeMarker;
	private GameTimeSince timeSinceCarve;
	private Vector3 drillOrigin;
	private Vector3 drillDirection;

	/// <summary>World units travelled from the muzzle. Covers the flight and the bore alike.</summary>
	private float drillTravelled;

	/// <summary>Travel at which it first entered solid rock. Negative means still in the air.</summary>
	private float entryTravelled = -1f;

	/// <summary>Voxels bored since entry. Zero for the whole flight.</summary>
	private float drillDepth;

	// --- track B: the reload ---
	private bool chargeAvailable = true;
	private GameTimeSince timeSinceFired = 99f;

	/// <summary>True while the short randomised reload minigame is running.</summary>
	private bool reloadAttemptOpen;

	/// <summary>Where the target sits on this attempt's bar, 0 to 1. Rerolled every shot.</summary>
	private float reloadWindowStart;

	/// <summary>Set for a moment after a successful active reload, for HUD feedback.</summary>
	public GameTimeSince TimeSinceActiveReload { get; private set; } = 99f;
	public bool ActiveReloadJustHit => TimeSinceActiveReload < 1.0f;

	/// <summary>Set for a moment after a failed attempt, for HUD feedback.</summary>
	public GameTimeSince TimeSinceActiveReloadMiss { get; private set; } = 99f;
	public bool ActiveReloadJustMissed => TimeSinceActiveReloadMiss < 1.0f;

	/// <summary>A charge is bored into the shape and waiting for a left click.</summary>
	public bool ChargeLive => chargeLive;

	/// <summary>You may fire a new charge right now.</summary>
	public bool ChargeReady => chargeAvailable && !chargeLive;

	/// <summary>True while the 2 second reload minigame is on screen.</summary>
	public bool ReloadAttemptOpen => reloadAttemptOpen;

	/// <summary>Playhead across the reload minigame bar, 0 to 1.</summary>
	public float ReloadAttemptProgress
		=> Math.Clamp( timeSinceFired / Tuning.ActiveReloadDuration, 0f, 1f );

	/// <summary>Left edge of this attempt's target, 0 to 1 along the bar.</summary>
	public float ReloadWindowStart => reloadWindowStart;
	public float ReloadWindowWidth => Tuning.ActiveReloadWindowWidth;

	/// <summary>Progress of the long fallback cooldown, once the attempt has been missed.</summary>
	public float ReloadProgress => chargeAvailable
		? 1f
		: Math.Clamp( timeSinceFired / CooldownLength, 0f, 1f );

	/// <summary>0 to 1, how close the bore is to the ideal burial depth.</summary>
	public float DrillQuality
		=> Math.Clamp( drillDepth / EffectiveIdealDepth, 0f, 1f );

	/// <summary>
	/// Burial depth that counts as fully buried, for THIS shape.
	///
	/// **This is why the charge felt useless early.** `DemolitionIdealDepth` is a flat 26 voxels,
	/// and stage 1 is eight voxels across. You cannot bury a charge 26 deep in an 8 deep shape,
	/// so `DrillQuality` could never leave zero, the depth bonus never applied, and the opening
	/// stages only ever saw the smallest possible blast. The bonus was unreachable by
	/// construction rather than by play.
	///
	/// Scaling it to the shape means "fully buried" means the same THING at every size: about
	/// forty percent of the way through the smallest axis, which is as deep as anything can
	/// meaningfully go before it exits the far side.
	/// </summary>
	private static float EffectiveIdealDepth
	{
		get
		{
			var world = MonolithManager.Instance.IsValid()
				? MonolithManager.Instance.World
				: null;

			if ( world == null )
				return Tuning.DemolitionIdealDepth;

			int shortest = Math.Min( world.Size.x, Math.Min( world.Size.y, world.Size.z ) );

			return MathF.Max( 2f,
				MathF.Min( Tuning.DemolitionIdealDepth, shortest * Tuning.DemolitionDepthFraction ) );
		}
	}

	/// <summary>Cooldown length, shortened by the Cadence node in the Core Tree.</summary>
	private float CooldownLength => PlayerProgress.Local.IsValid()
		? PlayerProgress.Local.DemolitionCooldown
		: Tuning.DemolitionCooldown;

	public float ChargeCooldownLeft
		=> chargeAvailable ? 0f : MathF.Max( 0f, CooldownLength - timeSinceFired );

	/// <summary>True while a right click right now would catch the target.</summary>
	public bool InActiveReloadWindow
	{
		get
		{
			if ( !reloadAttemptOpen ) return false;

			float p = ReloadAttemptProgress;
			return p >= reloadWindowStart && p <= reloadWindowStart + Tuning.ActiveReloadWindowWidth;
		}
	}

	/// <summary>World position of the live charge, for the HUD marker.</summary>
	public Vector3 LiveChargePosition => ChargePosition;

	/// <summary>
	/// Hard cap on shots resolved in one frame, so a frame hitch cannot burst the network.
	///
	/// Down from 4 to 2. This is a cap on SHOTS, but each shot is `ProjectileCount` bolts, so at
	/// 21 projectiles a volley the real per-frame spike was 84 objects. Two keeps the spike
	/// readable while still allowing 120 shots a second at 60fps, far more than any fire rate
	/// the economy produces.
	/// </summary>
	private const int MaxShotsPerFrame = 2;

	protected override void OnUpdate()
	{
		// Frozen while a blocking screen or the pause menu is up. See GameTime.
		if ( GameTime.Paused )
			return;

		var manager = MonolithManager.Instance;
		if ( !manager.IsValid() || manager.World == null || !manager.SnapshotReady )
			return;

		var progress = PlayerProgress.Local;
		if ( !progress.IsValid() )
			return;

		UpdateAim( manager );

		if ( Hud.CursorVisible )
		{
			// The upgrade panel is open, so the mouse belongs to the UI.
			fireAccumulator = 0f;
		}
		else
		{
			// A left click that sets off a charge is spent on that, not on a mining shot.
			bool detonated = UpdateDemolition( manager, progress );
			UpdatePlayerFire( manager, progress, detonated );
		}

		UpdateDrones( manager, progress );
	}

	public void OnMonolithReset()
	{
		fireAccumulator = 0f;
		droneAccumulator = 0f;
		AimHit = default;

		// The old shape is gone, so any charge boring into it is meaningless.
		chargeLive = false;
		drillStopped = false;
		chargeAvailable = true;
		reloadAttemptOpen = false;
		chargeMarker?.GameObject?.Destroy();
		chargeMarker = null;

		var progress = PlayerProgress.Local;
		if ( progress.IsValid() )
		{
			progress.Data.MonolithsCleared++;
			progress.CubesThisMonolith = 0;
			progress.Save();
		}

		if ( progress.IsValid() && MonolithManager.Instance.IsValid() )
			progress.ReportStage( MonolithManager.Instance.Tier );
	}

	// ---------------------------------------------------------------- aiming

	/// <summary>
	/// Where the crosshair points in world space, whether or not there is rock there.
	///
	/// This is separate from <see cref="AimHit"/> on purpose. Firing used to be gated on
	/// AimHit.Hit, so aiming at open sky produced NO SHOT AT ALL: a Spotter hanging above the
	/// shape, an interceptor drifting in over open ground and a shield node were all literally
	/// unshootable, which reads as "my bullets do nothing to them". A shot must always leave
	/// the muzzle; whether it finds anything is the projectile's business, not the trigger's.
	/// </summary>
	public Vector3 AimPoint { get; private set; }

	private void UpdateAim( MonolithManager manager )
	{
		var camera = Camera ?? Scene.Camera;
		if ( !camera.IsValid() )
		{
			AimHit = default;
			return;
		}

		var ray = camera.ScreenNormalToRay( new Vector2( 0.5f, 0.5f ) );

		if ( manager.World.TraceRay( ray.Position, ray.Forward, Tuning.MineRayLength, out var hit ) )
		{
			AimHit = hit;
			AimPoint = manager.World.VoxelToWorld( hit.Voxel.x, hit.Voxel.y, hit.Voxel.z );
			return;
		}

		AimHit = default;
		AimPoint = ray.Position + ray.Forward * Tuning.MineRayLength;
	}

	// ---------------------------------------------------------------- firing

	/// <summary>
	/// Right click plants a charge where you are aiming, left click sets it off. Deliberately
	/// two deliberate inputs rather than a passive proc: it is the one ability you aim with
	/// intent, and its cooldown never changes so the rhythm stays readable at any level.
	/// </summary>
	/// <summary>
	/// Right click bores a charge into the rock; left click sets it off wherever it has got to.
	/// Detonating on the surface wastes most of the blast on empty air, so the skill is holding
	/// your nerve while it sinks. Missing the timing entirely wastes the charge.
	///
	/// The cooldown also carries an active reload: right clicking in the last moment of it
	/// catches the charge early, and clicking too soon costs you.
	/// </summary>
	/// <summary>
	/// Returns true if this frame's left click was consumed detonating a charge, so the normal
	/// mining shot is suppressed for that one click.
	/// </summary>
	private bool UpdateDemolition( MonolithManager manager, PlayerProgress progress )
	{
		// An Unarmed run removes the ability outright, bores and active reloads included.
		if ( progress.DemolitionDisabled )
			return false;

		UpdateReload();
		UpdateLiveCharge( manager );

		return TryDetonate( manager, progress );
	}

	/// <summary>Track B. Independent of whether a charge is currently live.</summary>
	private void UpdateReload()
	{
		// The attempt only lasts a couple of seconds. Let it lapse if it is not taken.
		if ( reloadAttemptOpen && timeSinceFired > Tuning.ActiveReloadDuration )
		{
			reloadAttemptOpen = false;
			TimeSinceActiveReloadMiss = 0;
		}

		if ( !chargeAvailable && !reloadAttemptOpen && ChargeCooldownLeft <= 0f )
			chargeAvailable = true;

		if ( !Input.Pressed( "Attack2" ) )
			return;

		if ( reloadAttemptOpen )
		{
			// This right click is an attempt at the target, not a request to fire.
			if ( InActiveReloadWindow )
			{
				chargeAvailable = true;
				TimeSinceActiveReload = 0;

				// The skill shot feeds the chain harder than anything else, which is what
				// makes catching it worth the attention it costs.
				var progress = PlayerProgress.Local;
				if ( progress.IsValid() )
				{
					progress.Data.PerfectReloads++;
					progress.AddResonance( Tuning.ResonanceReloadBonus );
				}
			}
			else
			{
				TimeSinceActiveReloadMiss = 0;
			}

			// Hit or miss, the attempt is spent. Missing simply leaves you on the long
			// cooldown, which is already counting from the moment you fired.
			reloadAttemptOpen = false;
			return;
		}

		if ( chargeAvailable )
			TryFireCharge();
	}

	private void TryFireCharge()
	{
		// One charge at a time. Catching the reload while one is still live banks the readiness,
		// so you can fire again the instant you detonate.
		//
		// It no longer requires the crosshair to be on ROCK. That gate meant a right click at
		// open sky did nothing at all, not even a sound, which is the identical bug the left
		// click had before `AimPoint` replaced `AimHit.Hit`. A charge is an area weapon; where
		// it detonates is the player's decision, not a precondition for firing it.
		if ( chargeLive )
			return;

		var manager = MonolithManager.Instance;
		var camera = Camera ?? Scene.Camera;
		if ( !manager.IsValid() || !camera.IsValid() )
			return;

		// The charge starts at the MUZZLE, not at the surface. You watch it cross the gap and
		// then sink in, and you time the detonation off that whole path: the flight is part of
		// the decision rather than something skipped over.
		drillOrigin = MuzzlePosition;
		drillDirection = camera.WorldRotation.Forward;
		drillTravelled = 0f;
		drillDepth = 0f;
		entryTravelled = -1f;
		drillStopped = false;

		chargeLive = true;
		chargeAvailable = false;
		timeSinceFired = 0;
		timeSinceCarve = 0;

		// Open a fresh reload attempt with a newly randomised target, so it can never be
		// played from muscle memory.
		reloadAttemptOpen = true;
		reloadWindowStart = Game.Random.Float( Tuning.ActiveReloadMinStart, Tuning.ActiveReloadMaxStart );

		chargeMarker = DemoCharge.Spawn( Scene, drillOrigin );
	}

	/// <summary>Track A. Bores in, then waits indefinitely for the detonation click.</summary>
	private void UpdateLiveCharge( MonolithManager manager )
	{
		if ( !chargeLive || drillStopped )
			return;

		// One continuous path: it flies at projectile speed until it meets the shape, then
		// slows to boring speed once it is inside.
		bool inside = entryTravelled >= 0f;
		float speed = inside
			? Tuning.DemolitionDrillSpeed * Tuning.VoxelSize
			: Tuning.ChargeProjectileSpeed;

		drillTravelled += speed * Time.Delta;

		var position = ChargePosition;

		if ( !inside )
		{
			// Has it reached the rock yet?
			var voxel = manager.World.WorldToVoxel( position );

			if ( manager.World.IsSolid( voxel.x, voxel.y, voxel.z ) )
			{
				entryTravelled = drillTravelled;
				inside = true;
			}
			else if ( position.z <= FloorHeight )
			{
				// IT HIT THE FLOOR. This is the rocket jump, and it fires ITSELF rather than
				// waiting for a left click. Requiring the timed detonation would have made the
				// technique a two-button combo executed while airborne and already committed,
				// which is not a movement tool, it is a stunt. Aim down, click, go.
				RocketJump( manager, position.WithZ( FloorHeight ) );
				return;
			}
			else if ( drillTravelled > Tuning.ChargeMaxFlight )
			{
				// Reached the end of its flight without meeting rock. It STOPS and waits rather
				// than fizzling: a charge that vanished after a near miss punished the player
				// twice, once for the miss and again with the full cooldown for nothing. It is
				// an area weapon, so it can still be set off where it hangs.
				drillTravelled = Tuning.ChargeMaxFlight;
				drillStopped = true;
				position = ChargePosition;
			}
		}

		if ( inside )
		{
			drillDepth = (drillTravelled - entryTravelled) / Tuning.VoxelSize;

			// Stop boring at the ideal depth and hold there. The charge is never wasted by
			// waiting: it simply stops sinking and sits until you set it off.
			if ( drillDepth >= EffectiveIdealDepth )
			{
				drillDepth = EffectiveIdealDepth;
				drillTravelled = entryTravelled + drillDepth * Tuning.VoxelSize;
				drillStopped = true;
				position = ChargePosition;
			}
		}

		if ( chargeMarker.IsValid() )
			chargeMarker.WorldPosition = position;

		// Bore a pilot hole once inside, so the descent is visible. Throttled: it goes over
		// the network. Nothing is carved during the flight, obviously.
		if ( inside && timeSinceCarve >= Tuning.DemolitionCarveInterval )
		{
			timeSinceCarve = 0;

			var voxel = manager.World.WorldToVoxel( position );
			if ( manager.World.InBounds( voxel.x, voxel.y, voxel.z ) )
				manager.RequestBlast( voxel, Tuning.DemolitionBoreRadius, false );
		}
	}

	/// <summary>
	/// Z of the arena floor. Read from the movement component rather than stored, because the
	/// floor moves with the stage: every shape sits at its own height.
	/// </summary>
	private float FloorHeight
	{
		get
		{
			var movement = Components.Get<PlayerMovement>();
			return movement.IsValid() ? movement.FloorZ : float.MinValue;
		}
	}

	/// <summary>
	/// The rocket jump. A charge that meets the floor detonates on contact and throws you.
	///
	/// **What it is for.** Every other way to move is free: hops, the double jump dash, sprinting.
	/// They cost nothing but timing, so there was never a decision to make about movement. This
	/// one costs a CHARGE, and a charge is the single biggest mining tool in the game. Spending
	/// it on distance instead of on rock is the trade, and it is the first time going faster is
	/// paid for out of the same budget as clearing faster.
	///
	/// **It still mines.** The blast is not reduced. Fire at the floor beside the shape and you
	/// get the launch AND the excavation, which turns a movement tool into a positioning problem
	/// worth being good at, rather than a tax you pay to travel.
	/// </summary>
	private void RocketJump( MonolithManager manager, Vector3 centre )
	{
		var progress = PlayerProgress.Local;
		var movement = Components.Get<PlayerMovement>();

		if ( manager != null && progress.IsValid() )
		{
			// REDUCED, not removed. A floor hit that mined as hard as a buried one would make the
			// timed detonation pointless: you would always fire at the ground, take the launch as
			// a free extra, and never bore anything again. Scaling the CUBES rather than the
			// radius keeps the reduction honest, since radius is the cube root of volume and
			// cutting it directly would take far more than it looks like it does.
			double cubes = Upgrades.DemolitionCubes(
				progress.DemolitionLevel, manager.TotalCubes, manager.InMonolith )
				* Tuning.RocketJumpBlastFraction;

			float radius = Upgrades.RadiusForCubes( cubes );

			manager.RequestBlast( manager.World.WorldToVoxel( centre ), radius, true );
			BlastEffect.Spawn( Scene, centre, radius * Tuning.VoxelSize, true );
			DetonateHazards( centre, radius * Tuning.VoxelSize );

			// Still pays into the chain. It is a real detonation, just a cheaper one.
			progress.AddResonance();
		}

		if ( movement.IsValid() )
		{
			var away = movement.WorldPosition - centre;
			float distance = MathF.Max( 1f, away.Length );

			// Falls off with distance, so a shot at your own feet launches you hardest. That is
			// what makes it aimable: you choose your power by choosing where to put the charge,
			// and the strongest launch is also the one that needs the most nerve.
			float falloff = Math.Clamp(
				1f - (distance / Tuning.RocketJumpRadius), 0f, 1f );

			if ( falloff > 0f )
			{
				// Biased upward regardless of geometry. Straight from the blast to the player is
				// almost vertical when you shoot your own feet and almost flat when you shoot
				// ahead of you, and the flat case would slide you along the ground instead of
				// launching. The bias makes every rocket jump a jump.
				var direction = (away.Normal + Vector3.Up * Tuning.RocketJumpUpBias).Normal;

				movement.AddImpulse( direction * Tuning.RocketJumpForce * falloff );

				Audio.Play( Audio.Explosion, centre, 1f, Audio.Vary( 0.95f ) );
			}
		}

		EndCharge();
	}

	private Vector3 ChargePosition
		=> drillOrigin + drillDirection * drillTravelled;

	private void EndCharge()
	{
		chargeLive = false;
		drillStopped = false;

		chargeMarker?.GameObject?.Destroy();
		chargeMarker = null;
	}

	private bool TryDetonate( MonolithManager manager, PlayerProgress progress )
	{
		if ( !chargeLive || !Input.Pressed( "Attack1" ) )
			return false;

		var voxel = manager.World.WorldToVoxel( ChargePosition );

		// Sized against the STAGE, not as a fixed sphere, so one charge means the same thing at
		// stage 1 and stage 90. `InitialCount` rather than what remains: the charge should not
		// get weaker as you clear the level, which would make it useless exactly when you are
		// hunting the last of it.
		double cubes = Upgrades.DemolitionCubes(
			progress.DemolitionLevel, manager.TotalCubes, manager.InMonolith );

		// The depth bonus stays, but as a bonus on top of a full blast rather than the
		// difference between a blast and nothing. Burying it is rewarded twice over: the quoted
		// fraction assumes solid packing, so a surface detonation already loses half its sphere
		// to open air before this multiplier applies at all.
		float radius = Upgrades.RadiusForCubes( cubes )
			* (1f + Tuning.DemolitionDepthBonus * DrillQuality);

		// Detonates wherever it is, in rock or in open air, and the sphere reaches whatever it
		// reaches. Requiring the CENTRE to be a valid voxel meant a charge sitting just off the
		// surface did nothing, even with most of its radius overlapping the shape. The blast is
		// clamped to the world by HostHandleBlast anyway, so an out-of-bounds centre is safe.
		manager.RequestBlast( voxel, radius, true );

		BlastEffect.Spawn( Scene, ChargePosition, radius * Tuning.VoxelSize, true );
		Audio.Play( Audio.Explosion, ChargePosition, 1f, Audio.Vary( 0.75f ) );

		DetonateHazards( ChargePosition, radius * Tuning.VoxelSize );

		progress.AddResonance();

		// Only a fully buried detonation counts toward the Mark, so it rewards the nerve to
		// let it sink rather than simply firing often.
		if ( DrillQuality >= 0.999f )
			progress.Data.DeepDetonations++;

		EndCharge();
		return true;
	}

	/// <summary>
	/// A detonating charge kills anything caught in it.
	///
	/// Rock and hazards were entirely separate systems: the blast removed voxels and left every
	/// machine standing in the middle of it untouched, which reads as the explosion not being
	/// real. It also left the charge as the one tool with no answer to the things that exist to
	/// interrupt you, so a Spotter locking on could not be solved by the biggest weapon you had.
	///
	/// Damage is deliberately overwhelming rather than scaled. This is a ten second cooldown
	/// aimed by hand: anything standing in it should die, and a charge that merely wounded a
	/// Sentinel would be a worse version of clicking on it.
	/// </summary>
	private void DetonateHazards( Vector3 centre, float worldRadius )
	{
		const int lethal = 999;

		// Snapshots throughout: TakeHit destroys, and destruction mutates the list being walked.
		foreach ( var spotter in Spotter.All.ToList() )
		{
			if ( spotter.IsValid() && spotter.WorldPosition.Distance( centre ) <= worldRadius + spotter.Radius )
				spotter.TakeHit( lethal );
		}

		foreach ( var sentinel in Sentinel.All.ToList() )
		{
			if ( sentinel.IsValid() && sentinel.WorldPosition.Distance( centre ) <= worldRadius + sentinel.Radius )
				sentinel.TakeHit( lethal );
		}

		foreach ( var crawler in Crawler.All.ToList() )
		{
			if ( crawler.IsValid() && crawler.WorldPosition.Distance( centre ) <= worldRadius + crawler.Radius )
				crawler.TakeHit( lethal );
		}

		foreach ( var anchor in Anchor.All.ToList() )
		{
			if ( anchor.IsValid() && anchor.WorldPosition.Distance( centre ) <= worldRadius + anchor.Radius )
				anchor.TakeHit( lethal );
		}

		foreach ( var leech in Leech.All.ToList() )
		{
			if ( leech.IsValid() && leech.WorldPosition.Distance( centre ) <= worldRadius )
				leech.TakeHit( lethal );
		}

		foreach ( var interceptor in Interceptor.All.ToList() )
		{
			if ( interceptor.IsValid() && interceptor.WorldPosition.Distance( centre ) <= worldRadius )
				interceptor.TakeHit( lethal );
		}

		// Incoming orbs are swept out of the air too, so a charge clears the space around you
		// rather than clearing the rock and leaving the shot that was already on its way.
		foreach ( var orb in SentinelOrb.All.ToList() )
		{
			if ( orb.IsValid() && orb.WorldPosition.Distance( centre ) <= worldRadius )
				orb.Shatter();
		}
	}

	private void UpdatePlayerFire( MonolithManager manager, PlayerProgress progress, bool detonated )
	{
		if ( detonated )
		{
			fireAccumulator = 0f;
			return;
		}

		// NOTHING BLOCKS FIRING. Not being staggered, not anything.
		//
		// A stagger used to stop you shooting for 0.7s and zero the banked accumulator with it.
		// On paper that was "costs time, never progress". In practice, once Sentinels started
		// actually working, up to four of them landing an orb every few seconds meant a large
		// fraction of every fight was spent unable to fire, with no explanation on screen. The
		// only possible reading was that the gun had broken.
		//
		// Taking away AGENCY is worse than taking away either time or progress, and it is the
		// one cost this game should never charge. Being hit still costs plenty: your momentum is
		// scrubbed, which breaks a hop chain, and your Resonance drops, which is real money. Both
		// are things you can see happening. Neither makes the mouse button stop working.

		if ( !Input.Down( "Attack1" ) )
		{
			// Keep at most one banked shot so tapping stays responsive without letting
			// a long hold build up a burst.
			fireAccumulator = MathF.Min( fireAccumulator, 1f );
			return;
		}

		fireAccumulator += Time.Delta * progress.DrillSpeed;

		int shots = 0;
		while ( fireAccumulator >= 1f && shots < MaxShotsPerFrame )
		{
			fireAccumulator -= 1f;
			shots++;

			// Always fires. See AimPoint: gating this on hitting rock made everything that flies
			// above the shape unshootable.
			Fire( manager, progress, AimPoint, 1f, false );
		}

		// CARRY the leftover instead of discarding it.
		//
		// This used to zero the accumulator whenever the per-frame limit was hit, which made
		// firing lumpy in exactly the way reported as "a few shots, then a large shotgun blast":
		// you build up a backlog, release MaxShotsPerFrame at once, throw the rest away, and
		// build up again. With multi-shot at 21 bolts a volley, one of those frames is 40+
		// projectiles appearing simultaneously.
		//
		// Clamped rather than unbounded, so a frame hitch cannot bank a huge burst, but a
		// sustainable rate now flows evenly across frames.
		fireAccumulator = MathF.Min( fireAccumulator, 1.5f );
	}

	private void UpdateDrones( MonolithManager manager, PlayerProgress progress )
	{
		// A Handmade run turns drones off entirely: every cube has to be one you shot.
		if ( progress.DronesDisabled )
			return;

		// A shielded stage jams them too, so idling through it is not an option.
		if ( manager.ShieldActive )
			return;

		int drones = progress.DroneCount;
		if ( drones <= 0 ) return;

		droneAccumulator += Time.Delta * drones * progress.DroneFireRate;

		int shots = 0;
		while ( droneAccumulator >= 1f && shots < MaxShotsPerFrame )
		{
			droneAccumulator -= 1f;
			shots++;

			if ( TryFindDroneTarget( manager, out var target ) )
			{
				Fire( manager, progress,
					manager.World.VoxelToWorld( target.x, target.y, target.z ),
					Tuning.DroneRadiusScale, true );
			}
		}

		if ( shots >= MaxShotsPerFrame )
			droneAccumulator = 0f;
	}

	/// <summary>
	/// Muzzle position: the tip of the gun in Terry's hand.
	///
	/// This used to be derived from the CAMERA, which was fine in first person (bolts appeared
	/// from just off-screen) and obviously wrong the moment the camera moved behind the body:
	/// every shot spawned in the bottom right corner of the screen, attached to nothing.
	///
	/// The camera version survives only as a fallback for the frames before the avatar has
	/// built its gun.
	/// </summary>
	private Vector3 MuzzlePosition
	{
		get
		{
			var avatar = Components.Get<PlayerAvatar>();

			if ( avatar.IsValid() && avatar.HasMuzzle )
				return avatar.MuzzlePosition;

			var camera = Camera ?? Scene.Camera;
			if ( !camera.IsValid() )
				return WorldPosition;

			var rot = camera.WorldRotation;
			return camera.WorldPosition + rot.Forward * 30f + rot.Right * 14f + rot.Down * 12f;
		}
	}

	/// <summary>
	/// Which drone head fires next. Round robin so the ring visibly takes it in turns rather
	/// than one head doing all the work while the rest sit there.
	/// </summary>
	private int nextDrone;

	private MiningDrone NextDrone()
	{
		if ( MiningDrone.All.Count == 0 )
			return null;

		nextDrone = (nextDrone + 1) % MiningDrone.All.Count;
		return MiningDrone.All[nextDrone];
	}

	/// <summary>
	/// Sends one volley at a world point. <paramref name="fromDrone"/> matters: a drone shot is
	/// not allowed to break a shield node, because a shield that clears its own jam is not a
	/// shield.
	/// </summary>
	private void Fire( MonolithManager manager, PlayerProgress progress, Vector3 target,
		float radiusScale, bool fromDrone )
	{
		// A volatile cube only goes off if you hit it squarely. Catching one in the blast of a
		// neighbouring shot does nothing, so they stay something you aim at rather than
		// something that happens to you.
		//
		// A drone shot leaves the nose of one of the drones overhead, not your gun. Buying a
		// drone should look like buying a drone.
		var muzzle = MuzzlePosition;

		if ( fromDrone )
		{
			var drone = NextDrone();

			if ( drone.IsValid() )
			{
				muzzle = drone.MuzzlePosition;
				drone.OnFired();
			}
		}

		var baseDirection = (target - muzzle).Normal;

		// One projectile per prestige, CAPPED at what can still be read as separate bolts.
		//
		// Past the cap the reward is converted rather than lost: the bolts we do not draw are
		// paid back as blast radius, scaled by the cube root of the ratio so the volume removed
		// is unchanged. Twenty-one simultaneous bolts is not twenty-one times the feedback, it
		// is a wall nobody can parse, and it was reported as a shotgun blast twice.
		int owned = Math.Max( 1, progress.ProjectileCount );
		int count = Upgrades.VisibleProjectiles( owned );

		float radius = progress.BlastRadius * radiusScale
			* Upgrades.MultiShotRadiusScale( owned );

		for ( int i = 0; i < count; i++ )
		{
			// The first shot goes exactly where you aimed; the rest fan out so they land on
			// different cubes, which is where the throughput actually comes from.
			var direction = i == 0
				? baseDirection
				: Spread( baseDirection, Tuning.MultiShotSpreadDegrees );

			// The first bolt of every volley is ESSENTIAL: it ignores the population cap, so a
			// trigger pull always produces something. Only the multi-shot extras thin out under
			// load, which degrades throughput rather than responsiveness.
			Projectile.Fire( Scene, muzzle, direction, radius, false, false, fromDrone,
				essential: i == 0 );
		}
	}

	private static Vector3 Spread( Vector3 direction, float degrees )
	{
		float radians = degrees * MathF.PI / 180f;

		// Random tilt in a cone around the aim direction.
		var random = Vector3.Random.Normal;
		var perpendicular = Vector3.Cross( direction, random ).Normal;

		if ( perpendicular.Length < 0.01f )
			perpendicular = Vector3.Cross( direction, Vector3.Up ).Normal;

		float angle = Game.Random.Float( -radians, radians );
		return (direction + perpendicular * MathF.Tan( angle )).Normal;
	}

	/// <summary>
	/// Drones pick their own target by firing a ray at the shape from a random direction and
	/// taking the first surface hit.
	///
	/// **That alone stops working exactly when you need it most.** A random ray through a mostly
	/// empty 98^3 volume essentially never finds the last few cubes, so drones silently went
	/// idle for the entire tail of every stage: the part where hunting for one cube 2,000 units
	/// away is least fun and most in need of help. That was a bug, not a design.
	///
	/// The fallback is the manager's residue list, which is already maintained for the HUD
	/// beacon. Random rays stay the fast path while the shape is full; the list takes over once
	/// it is not.
	/// </summary>
	private bool TryFindDroneTarget( MonolithManager manager, out Vector3Int target )
	{
		target = default;

		var world = manager.World;
		var bounds = world.WorldBounds;
		float outerRadius = bounds.Size.Length;

		for ( int attempt = 0; attempt < 8; attempt++ )
		{
			var from = bounds.Center + Vector3.Random.Normal * outerRadius;
			var to = new Vector3(
				Game.Random.Float( bounds.Mins.x, bounds.Maxs.x ),
				Game.Random.Float( bounds.Mins.y, bounds.Maxs.y ),
				Game.Random.Float( bounds.Mins.z, bounds.Maxs.z ) );

			if ( world.TraceRay( from, (to - from).Normal, outerRadius * 2.5f, out var hit ) )
			{
				target = hit.Voxel;
				return true;
			}
		}

		return manager.TryPickResidueVoxel( out target );
	}
}